ОС · Persistence · 14 мин
Файлы, каталоги, дескрипторы
Что такое файл на самом деле
Когда мы говорим «файл», в голове обычно картинка: иконка с именем report.txt.
Но для операционной системы файл — это две совершенно разные вещи, склеенные вместе:
- Данные — просто линейный массив байт. ОС не знает и не хочет знать, что там: текст, картинка, видео. Для неё это «байты от 0 до N».
- Метаданные — служебная информация о файле: размер, владелец, права доступа, время изменения, и — самое важное — где на диске лежат сами байты.
Бытовая аналогия. Представь камеру хранения на вокзале. Данные — это твой чемодан. Метаданные — это бирка на ячейке: номер ячейки, кто хозяин, когда положил. Ты сам по бирке находишь чемодан. В файловой системе роль такой «бирки со всей служебной информацией» играет inode (index node, «индексный узел»).
ФАЙЛ = ДАННЫЕ + МЕТАДАННЫЕ
┌──────────────────────────────────────┐
│ Метаданные (inode) Данные (блоки) │
│ ┌─────────────┐ ┌────┬────┬────┐│
│ │ размер │ │byte│byte│byte││
│ │ владелец/uid │ ───► │ s │ s │ s ││
│ │ права rwx │ └────┴────┴────┘│
│ │ времена a/m/c│ массив байт │
│ │ refcount │ │
│ │ → блоки данных│ │
│ └─────────────┘ │
└──────────────────────────────────────┘Что нарисовано: слева «паспорт» файла (inode) со всеми атрибутами и указателями; справа — собственно содержимое, разбросанное по блокам диска. Подробно, как inode указывает на блоки (прямые и непрямые указатели), разбираем в главе fs-implementation — здесь нам важна сама идея разделения.
Inode: паспорт без имени
Ключевой и самый контринтуитивный факт главы: имя файла в inode не хранится.
В inode есть размер, права, времена, указатели на блоки — но строки report.txt
там нет. Inode опознаётся не по имени, а по номеру (inode number, i-number) —
это его адрес внутри файловой системы.
Проверить можно прямо в терминале:
$ ls -i report.txt
1234567 report.txt
▲
└── это и есть номер inodeПочему так сделали? Потому что имя — свойство расположения файла в дереве каталогов, а не свойство самих данных. Один и тот же файл может иметь несколько имён одновременно (увидим ниже, в разделе про жёсткие ссылки). А вот inode у данных всегда один.
Каталог — это тоже файл
Откуда же берётся имя? Из каталога. Каталог (directory, «папка») — это не какая-то особая магическая сущность, а тоже файл. Только содержимое у него специальное: это таблица соответствия «имя → номер inode». Каждая строка такой таблицы называется dentry (directory entry, запись каталога).
КАТАЛОГ /home/user (его данные = таблица)
┌──────────────┬────────────┐
│ имя │ номер inode│
├──────────────┼────────────┤
│ . │ 88 │ ← сам каталог
│ .. │ 12 │ ← родительский каталог
│ report.txt │ 1234567 │ ─────────┐
│ photo.jpg │ 1234999 │ │
└──────────────┴────────────┘ ▼
┌───────────────┐
│ inode 1234567 │
│ права, размер,│
│ → блоки данных│
└───────────────┘Что нарисовано: каталог — это список пар. Чтобы открыть report.txt, ОС читает
таблицу каталога, находит строку с этим именем, берёт оттуда номер inode (1234567),
а уже по номеру достаёт сам inode и через него — данные. Имя живёт в каталоге,
данные живут в inode — это два разных места.
Каждый каталог автоматически содержит две особые записи: . (он сам) и ..
(родитель). Именно по ним работает навигация cd ...
Как путь превращается в файл
Открытие /home/user/report.txt — это цепочка просмотров (path traversal):
"/" читаем корневой inode → его таблицу → ищем "home" → inode 12
"/home" читаем inode 12 → таблицу → ищем "user" → inode 88
"/home/user" читаем inode 88 → таблицу → ищем "report.txt" → inode 1234567
готово: открыли inode 1234567Что нарисовано: ОС идёт по пути слева направо, на каждом шаге читая каталог и находя следующий номер inode. Поэтому длинные пути и глубокая вложенность — это буквально больше работы для ФС.
Жёсткие ссылки: одни данные, много имён
Раз имя лежит в каталоге, а данные — в inode, ничто не мешает завести две
записи каталога, указывающие на один и тот же номер inode. Это и есть
жёсткая ссылка (hard link). Создаётся командой ln:
$ ln report.txt copy.txt # не копия! второе имя того же inode
КАТАЛОГ
┌──────────────┬────────────┐
│ report.txt │ 1234567 │──┐
│ copy.txt │ 1234567 │──┤ оба указывают
└──────────────┴────────────┘ │ в ОДИН inode
▼
┌──────────────────┐
│ inode 1234567 │
│ refcount = 2 │ ← счётчик имён
│ → блоки данных │
└──────────────────┘Что нарисовано: два разных имени, один inode, одни данные на диске. Это не копия —
второго экземпляра байт нет. Изменишь через report.txt — увидишь то же через
copy.txt.
В inode есть поле refcount (link count, счётчик ссылок) — сколько имён на него
указывает. Это и объясняет, как на самом деле работает удаление файла. Системный
вызов называется unlink, а не delete, и это важно:
unlink("copy.txt") → удаляем запись из каталога, refcount 2 → 1
unlink("report.txt")→ удаляем запись из каталога, refcount 1 → 0
refcount == 0 ⇒ ВОТ ТЕПЕРЬ блоки данных
и inode освобождаютсяТо есть rm не стирает данные. Он убирает имя и уменьшает счётчик. Реальные
байты освобождаются, только когда исчезло последнее имя (refcount дошёл до 0).
Важная тонкость: счётчик учитывает не только имена, но и открытые дескрипторы
(о них ниже). Если процесс держит файл открытым, а ты сделал rm, имя из каталога
пропадёт, но данные останутся живы, пока процесс не закроет файл. На этом основан
классический трюк: создать временный файл, открыть его и сразу unlink — файл
невидим в каталоге, но доступен процессу и гарантированно удалится при выходе.
Символические ссылки: ярлык с путём
У жёстких ссылок два ограничения: они не могут указывать на файл в другой
файловой системе (номера inode уникальны только внутри одной ФС) и обычно нельзя
жёстко слинковать каталог. Поэтому есть второй вид — символическая ссылка
(symlink, мягкая ссылка), создаётся ln -s.
Symlink — это отдельный маленький файл со своим собственным inode, а его содержимое — просто текстовая строка пути на цель.
$ ln -s /home/user/report.txt link
HARD LINK SYMLINK
имя → тот же inode имя → отдельный inode (тип: symlink)
данные = строка "/home/user/report.txt"
┌──────────┐ ┌──────────┐ ┌──────────────────────┐
│ inode │◄─── copy.txt │ inode │ │ inode report (цель) │
│ данные │◄─── report.txt │ "путь" │────►│ данные │
└──────────┘ └──────────┘ по └──────────────────────┘
имени, не по номеру!Что нарисовано: hard link делит сам inode; symlink хранит строку-путь и при обращении ОС заново проходит этот путь, чтобы найти цель.
Отсюда все различия:
| Свойство | Hard link | Symlink |
|---|---|---|
| Что хранит | то же имя для inode | отдельный файл с путём |
| Указывает на | номер inode | строку пути |
| Через разные ФС | нельзя | можно |
| На каталог | нельзя (обычно) | можно |
| Удалили цель | данные живы (refcount) | висячая ссылка (broken) |
| Влияет на refcount цели | да, +1 | нет |
Главная ловушка: если удалить оригинал, symlink станет «битым» (показывает на несуществующий путь), а hard link продолжит работать — ведь данные не делись никуда, пока их refcount > 0.
Файловый дескриптор: ручка к открытому файлу
Чтобы читать и писать, файл сначала открывают вызовом open, который
возвращает файловый дескриптор (file descriptor, fd) — небольшое
целое число. Это не сам файл и не inode, а индекс в таблице открытых файлов
процесса.
Аналогия: fd — это номерок из гардероба. Сам по себе он ничего не значит, но по нему гардеробщик (ядро) находит твоё пальто. У каждого процесса свой гардероб (своя таблица fd), поэтому fd=3 у одного процесса и fd=3 у другого — это разные файлы.
По соглашению первые три дескриптора заняты всегда:
fd 0 → stdin (стандартный ввод)
fd 1 → stdout (стандартный вывод)
fd 2 → stderr (стандартный вывод ошибок)
fd 3 → первый файл, который ты откроешьСамое интересное — трёхуровневая структура. Между fd и inode есть промежуточный слой: запись открытого файла (open file description), и в ней живёт смещение (offset) — текущая позиция чтения/записи внутри файла.
ТАБЛИЦА fd ОБЩЕСИСТЕМНАЯ ТАБЛИЦА ТАБЛИЦА inode
(своя у процесса) ОТКРЫТЫХ ФАЙЛОВ (одна на ФС)
┌────┬─────┐ ┌──────────────────┐ ┌────────────┐
│ fd │ ──► │ ─────► │ offset = 1024 │ ─────► │ inode 1234 │
│ 3 │ │ │ режим: чтение │ │ блоки, │
└────┴─────┘ │ ссылка на inode │ │ refcount │
└──────────────────┘ └────────────┘Что нарисовано: fd показывает на запись открытого файла, а та — на inode. Offset
лежит в средней таблице, не в inode и не рядом с fd. Это объяснит поведение
при fork.
Базовые операции работают так:
fd = open("/home/user/report.txt", O_RDONLY) → fd = 3, offset = 0
read(fd, buf, 100) → читает 100 байт, offset: 0 → 100
read(fd, buf, 100) → читает следующие 100, offset: 100 → 200
write(fd, buf, 50) → пишет 50 байт, двигает offset
close(fd) → освобождает запись, fd снова свободенКаждый read/write двигает offset автоматически — поэтому последовательное
чтение «само» идёт дальше по файлу, без указания позиции. Сдвинуть offset вручную
можно через lseek.
dup: ещё один fd на ту же запись
Вызов dup (и dup2) копирует дескриптор: новый fd указывает на ту же самую
запись открытого файла, а значит, делит с оригиналом offset.
fd 3 ─┐
├──► [ offset, inode ] dup(3) → fd 4, та же запись
fd 4 ─┘Именно через dup2 шелл реализует перенаправление command > file: открывает
файл и подменяет им fd 1 (stdout), чтобы вывод программы ушёл в файл, а сама
программа об этом даже не знает.
Что происходит с fd при fork
Когда процесс делает fork (создаёт копию себя — см. process),
дочерний процесс наследует таблицу fd. Но копируются не сами открытые файлы, а
только указатели на записи: родитель и ребёнок делят одну запись открытого
файла, а значит — один offset.
РОДИТЕЛЬ ДОЧЕРНИЙ
┌────┐ ┌────┐
│fd 3│──┐ ┌──│fd 3│
└────┘ │ │ └────┘
▼ ▼
┌──────────────────┐
│ offset (ОБЩИЙ!) │ ── если ребёнок пишет и двигает offset,
│ → inode │ родитель пишет уже дальше, не поверх
└──────────────────┘Что нарисовано: после fork два процесса смотрят в одну запись с общим offset. Поэтому если оба пишут в один унаследованный fd, их вывод не затирает друг друга, а аккуратно дописывается по очереди — общий offset сдвигается для обоих. Это, по сути, основа того, как пайплайн и редиректы в шелле работают корректно.
Сравним это с открытием файла дважды независимо (два open): тогда у каждого
будет своя запись и свой offset, и записи будут затирать друг друга.
| Способ | Записи открытого файла | Offset |
|---|---|---|
fork / dup |
одна общая | общий |
два независимых open |
две разные | независимые |
А где же гарантия, что данные на диске?
Важный момент, который ловит на собесах: успешный write не означает, что
байты уже на диске. Чаще всего они лежат в кэше страниц в памяти (page cache), а
на диск попадут позже. Если в этот момент пропадёт питание — данные потеряются.
Чтобы заставить ОС додавить данные до носителя, есть fsync. Это настолько
важная и тонкая тема, что ей посвящена отдельная глава
fsync-durability — здесь просто запомни: write ≠
durable (долговечно записано).
Где хранится имя файла?
Что реально делает rm (системный вызов unlink) для обычного файла с refcount = 1?
Чем символическая ссылка (symlink) отличается от жёсткой (hard link)?
Где хранится смещение (offset) чтения/записи открытого файла?
После fork родитель и ребёнок пишут в унаследованный fd. Что с offset?
Какой номер файлового дескриптора по соглашению соответствует стандартному выводу (stdout)?
Что спрашивают на собесе
- Где хранится имя файла? В каталоге (таблица «имя → номер inode»), а не в inode. Inode знает про файл всё, кроме имени.
- В чём разница hard link и symlink? Hard link — ещё одно имя для того же inode (делит данные, влияет на refcount, в пределах одной ФС). Symlink — отдельный файл со строкой-путём (может ломаться, работает между ФС и на каталоги).
- Что реально делает
rm? Вызываетunlink: убирает имя из каталога и уменьшает refcount. Данные освобождаются только когда refcount = 0 и файл никем не открыт. - Что такое файловый дескриптор и где живёт offset? fd — индекс в таблице открытых файлов процесса. Offset лежит в записи открытого файла (средний уровень), не в inode.
- Что с fd после fork? Наследуются; родитель и ребёнок делят запись открытого
файла и общий offset (как и при
dup). Два независимыхopenдали бы разные offset. - Гарантирует ли успешный
writeзапись на диск? Нет, данные могут быть в page cache; для долговечности нуженfsync(см. главу про durability).