Когда программист пишет open("/etc/passwd", O_RDONLY), он не думает о том, на каком разделе лежит этот файл - на ext4, Btrfs или, допустим, tmpfs в оперативной памяти. Команда ls одинаково работает с локальным жёстким диском, USB-флешкой с FAT32 и сетевым NFS-ресурсом за тысячу километров. Это не магия и не случайность. За этим стоит один из самых элегантных архитектурных решений в ядре Linux - Virtual File System, VFS.

VFS - это слой ядра, который принимает на себя все системные вызовы, связанные с файлами, и перенаправляет их к конкретной реализации файловой системы. Приложение говорит с VFS на одном языке - универсальном POSIX-интерфейсе. VFS переводит этот язык на диалект конкретной файловой системы. Ext4, XFS, NFS, procfs, NTFS через FUSE - для приложения они все выглядят одинаково.

Четыре кита архитектуры VFS

Общая модель файловой системы, к которой должна быть сведена любая конкретная реализация, состоит из четырёх чётко определённых сущностей: суперблок, inode, файл и dentry. Понять VFS - значит понять эти четыре объекта и то, как они взаимодействуют между собой.

Суперблок - это паспорт смонтированной файловой системы. Он хранит глобальную информацию: тип файловой системы, размер блока, указатель на корневой inode, статистику свободного пространства. При монтировании функция суперблока получает информацию о носителе и должна заполнить структуру суперблока, а также загрузить inode корневого каталога. Именно в суперблоке находится указатель на таблицу операций super_operations - набор функций, специфичных для данной файловой системы: чтение и запись inode, синхронизация с диском, статистика.

Inode (index node) - это настоящий дескриптор файлового объекта. Inode хранит информацию о файле в общем смысле: обычный файл, каталог, специальный файл (pipe, fifo), блочное устройство, символическая ссылка - всё, что может быть абстрагировано как файл. Как правило, inode не содержит имени файла - имя хранится в dentry. Это разделение - одно из ключевых решений всей архитектуры. Именно оно делает возможными жёсткие ссылки: несколько имён, несколько dentry, но один inode и, соответственно, одни данные на диске.

Dentry (directory entry) - связующее звено между именем файла и его inode. Dentry существует не только для имён файлов, но и для каталогов, и таким образом для каждого компонента пути. Например, путь /mnt/cdrom/foo содержит dentry для компонентов /, mnt, cdrom и foo. Объекты dentry создаются динамически по мере необходимости и кэшируются в dcache - специальном кэше ядра, который существенно ускоряет разрешение путей. Строковое сравнение путей дорого обходится, и dcache позволяет найти нужный inode по пути без повторного обхода дерева каталогов.

Структура file - это абстракция открытого файла с точки зрения конкретного процесса. Если inode абстрагирует файл на диске, то структура file абстрагирует открытый файл. С точки зрения процесса именно file является сущностью файла, тогда как с точки зрения реализации файловой системы ключевой сущностью остаётся inode. Каждый вызов open() создаёт новую структуру file, которая хранит текущую позицию чтения/записи, флаги доступа и указатель на dentry. Два процесса могут держать открытым один и тот же файл - у них будут разные структуры file, но один и тот же inode.

Как системный вызов проходит сквозь VFS

Рассмотрим, что происходит, когда процесс вызывает read(fd, buf, count). Этот путь от пользовательского пространства до реальных данных - наглядная иллюстрация того, зачем нужен VFS и как он работает.

Ядро получает системный вызов и по файловому дескриптору fd находит структуру file в таблице открытых файлов процесса. Файловый дескриптор - это индекс в массиве указателей на структуры file. Каждая структура file указывает на dentry через поле f_dentry. Из dentry ядро получает inode, а из inode - указатель на таблицу операций file_operations. Именно в этой таблице лежит указатель на функцию read, реализованную конкретной файловой системой. VFS вызывает её - и с этого момента начинается код ext4, или NFS, или любой другой файловой системы.

Приложение при этом не знает и не должно знать, что происходит дальше. Для него read() вернул данные - и всё. Что за этим стояло: чтение с локального NVMe, сетевой запрос к серверу NFS или генерация данных на лету в /proc - совершенно неважно.

Посмотреть на эти структуры в действии можно прямо из пространства ядра через /proc. Например, открытые файловые дескрипторы любого процесса видны через:

# Все открытые файлы процесса с их реальными путями
ls -la /proc/$(pgrep postgres | head -1)/fd

# Тип файловой системы для каждого открытого файла
cat /proc/$(pgrep postgres | head -1)/fdinfo/1

Таблицы операций - сердце полиморфизма VFS

Проблема прозрачной обработки разных форматов данных решена превращением суперблоков, inode и файлов в "объекты": каждый объект объявляет набор операций, которые должны использоваться при работе с ним. Суперблок содержит поле s_op, inode - i_op, файл - f_op. Это классический полиморфизм через таблицы виртуальных функций, реализованный на чистом C задолго до того, как C++ стал стандартом системного программирования.

Когда разработчик реализует новую файловую систему, он по сути заполняет эти три структуры своими функциями. Структура inode_operations описывает операции над метаданными: create, lookup, link, unlink, mkdir, rename. Структура file_operations описывает операции над содержимым: read, write, mmap, fsync, ioctl. Поля, оставленные равными NULL, сигнализируют VFS использовать поведение по умолчанию или вернуть ошибку EOPNOTSUPP.

Именно поэтому, например, создать жёсткую ссылку на файл в NFS на другой файловой системе невозможно - операция link в NFS возвращает ошибку, потому что inode на удалённом сервере не может быть привязан к dentry в другом суперблоке. VFS принимает ошибку от функции и передаёт её приложению как EXDEV - "cross-device link". Один и тот же механизм, одно и то же приложение - но поведение определяет конкретная реализация файловой системы.

Посмотреть, какие файловые системы зарегистрированы в ядре прямо сейчас:

cat /proc/filesystems

Строки с пометкой nodev - это псевдофайловые системы, которые не требуют блочного устройства: proc, sysfs, tmpfs, devpts. Остальные - реальные файловые системы для работы с носителями. Каждая из них зарегистрирована в VFS через структуру file_system_type и готова предоставить свои операции при монтировании.

Кэши VFS - почему файловые операции не всегда идут на диск

VFS держит два кэша, которые принципиально влияют на производительность файловых операций. Первый - dcache, кэш dentry. Кэш dentry задуман как представление всего файлового пространства. Поскольку большинство компьютеров не могут уместить все dentry в RAM одновременно, часть кэша всегда отсутствует. При разрешении пути в dentry VFS может создавать dentry по ходу пути, а затем загружать inode.

Второй - icache, кэш inode. Когда VFS впервые обращается к файлу на диске, inode считывается в память и остаётся в icache. Последующие обращения к тому же файлу обходятся без дискового ввода-вывода - inode уже в памяти. При изменении метаданных файла inode в памяти помечается как "грязный" и будет записан на диск при следующей синхронизации или явном вызове fsync().

Размер этих кэшей управляется ядром динамически, но посмотреть на их текущее состояние можно так:

# Общая статистика кэшей inode и dentry
cat /proc/sys/fs/inode-nr
# Формат: всего inode / свободных inode

cat /proc/sys/fs/dentry-state
# Формат: всего / неиспользуемых / возраст / - / - / -

# Детальная статистика через slab
cat /proc/slabinfo | grep -E 'dentry|inode_cache'

Именно эти кэши объясняют, почему повторный ls в большом каталоге работает заметно быстрее первого: при первом обращении VFS строит dentry-цепочку, загружает inode с диска, заполняет кэши. При втором - всё уже в памяти.

Псевдофайловые системы как расширение VFS

Файловая система /proc позволяет модулям работать с внутренностями VFS без необходимости регистрировать полноценный тип файловой системы. Каждый файл в /proc может определять собственные операции inode и file, эксплуатируя все возможности VFS.

Это глубокая идея, которую часто недооценивают. VFS - это не только абстракция для файлов на диске. Это универсальный интерфейс для любых иерархических структур данных, которые удобно представить в виде дерева файлов. /proc экспортирует состояние процессов и ядра. /sys (sysfs) экспортирует дерево устройств и их атрибуты. tmpfs хранит данные в RAM, используя page cache ядра напрямую. cgroups реализует управление ресурсами через файловый интерфейс.

Все они используют один и тот же механизм VFS: регистрируют свои операции в таблицах inode_operations и file_operations, и с точки зрения приложения ничем не отличаются от ext4. open(), read(), write(), stat() - всё работает одинаково.

Зарегистрировать новую псевдофайловую систему в пространстве ядра можно в несколько строк:

static struct file_system_type my_fs_type = {
    .owner      = THIS_MODULE,
    .name       = "myfs",
    .mount      = myfs_mount,
    .kill_sb    = kill_litter_super,
};

// Регистрация при загрузке модуля
static int __init myfs_init(void)
{
    return register_filesystem(&my_fs_type);
}

После регистрации файловая система становится доступна для монтирования: mount -t myfs none /mnt/myfs. VFS вызовет myfs_mount, получит суперблок с таблицей операций - и с этого момента файловая система полноправно существует в дереве каталогов.

Монтирование и пространства имён - как VFS видит дерево каталогов

Монтирование файловой системы в VFS - это не просто "подключить устройство к точке монтирования". Это создание структуры vfsmount, которая связывает суперблок смонтированной файловой системы с конкретным dentry в родительском дереве. Новая структура vfsmount привязывается к списку sb->s_mounts суперблока и к глобальному списку vfsmntlist. Поле mnt_sb указывает на суперблок, а mnt_root хранит ссылку на dentry корня.

Когда VFS разрешает путь /mnt/data/file.txt и встречает dentry, помеченный как точка монтирования, он переключается на корневой dentry смонтированной файловой системы - совершенно прозрачно. Ни приложение, ни ls этого перехода не замечают.

Современный Linux добавляет к этому механизм пространств имён монтирования (mount namespaces). Каждый процесс может иметь собственное представление дерева каталогов - и всё это реализовано поверх той же архитектуры VFS. Посмотреть текущие точки монтирования и связанные с ними файловые системы:

# Все смонтированные файловые системы с типами
findmnt --output TARGET,SOURCE,FSTYPE,OPTIONS

# Дерево монтирований
findmnt --tree

# Статистика конкретной точки монтирования
stat --file-system /mnt/data

VFS - это редкий пример архитектуры, которая не устарела за тридцать лет. Интерфейс, придуманный в ранних версиях Linux, сегодня без изменений обслуживает контейнеры, сетевые файловые системы, виртуальные машины и встроенные устройства. Когда команда ls одинаково работает с FAT32 на флешке и с NFS-шарой на другом конце датацентра - за этим стоит именно VFS: четыре объекта, три таблицы операций и многолетняя работа по поддержанию абстракции, которая никогда не протекает.