Продолжаем создавать модуль ядра в Линукс на примере виртуальной файловой системы.
Часть 1: Описание задачи, Модуль ядра
Часть 2: Модуль ядра, Регистрация файловой системы
Что в результате получилось можно увидеть по ссылкам: демо-видео, код.
Модуль ядра (ядро 5.10) - продолжение
Сборка модуля ядра
Для сборки модулей ядра в линуксе есть система построения kbuild. Для её работы используются конфигурационные файлы и файлы заголовков операционной системы. Все эти файлы устанавливаются пакетным менеджером. Например, в debian можно использовать команду:
sudo apt install linux-headers-$(uname - r)
Также требуется конфигурационный файл с описанием сборки модулей. В этом файле описываются собираемые модули (их может собираться несколько) и список зависимых файлов, из которых они собираются.
Так как конфигурационный файл модуля по своей структуре похож на обычный make-файл, то можно команды на выполнение сборки и конфигурацию модулей сделать в виде единого файла. В результате получится один файл, который используется вместе с утилитой make. При этом make вытащит свои данные о целях и запустит ещё один make, уже для kbuild. В свою очередь kbuild вытащит свои данные из того же самого файла и сделает сборку модуля. Пример получающегося файла:
obj-m := tagvfs.o
tagvfs-y := common.o tag_allfiles_dir.o tag_dir.o tag_file.o tag_fs.o tag_inode.o tag_module.o tag_onlytags_dir.o tag_storage.o tag_tag_dir.o tag_tag_mask.o
PWD := $(CURDIR)
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
Здесь obj-m - это специальное определение, по которому kbuild определяет, какие модули собирать. При сборке нескольких модулей их можно перечислить через пробел, например:
obj-m := m1.o m2.o
Далее указываются объектные файлы из которых собирается модуль ядра.
Есть два способа указать объектные файлы в зависимости от количества исходных файлов с кодом:
всего один исходный файл (отлично подходит для небольших модулей ядра). В этом случае файл кода именуется так же, как и создаваемый модуль. Например, есть файл кода modsrc.c и для сборки его в модуль можно сделать объявление obj-m := modsrc.o . На выходе получим модуль modsrc;
несколько исходных файлов. В этом случае название модуля становится "зарезервированным" и его нельзя использовать для именования файла с кодом. Для указания используемых файлов используется дополнительное объявление: modsrc-y := src1.o src2.o src3.o . Объявление строится из объединения названия модуля (modsrc) и специального суффикса -y, используемого kbuild.
По этим объявлениям kbuild будет искать файлы с кодом (с расширением .c вместо .o) и собирать в заявленный модуль.
В примере выше собирается один модуль tagvfs и для сборки используется несколько файлов common.c, tag_allfiles_dir.c и т.д.
Оставшиеся строки это способ удобного использования утилиты make для сборки: в зависимости от цели (all, clean) вызывается утилита make уже со специальными параметрами:
Параметр M=... указывает на использование kbuild для сборки модулей ядра. В качестве значения указывается папка с конфигурационным файлом модуля, т.е. директория с нашим Makefile-ом.
Параметр -С указывает на папку с конфигурационным файлом ядра Линукс (фактически это тоже make-файл).
В качестве цели для kbuild указывается modules (собираем модули) или clean (очистим всё).
Конечно, всё это можно вызвать раздельно, без использования миксованного make-файла. И подробнее про раздельный запуск kbuild, построение модулей ядра с дополнительными настройками и т.п. можно прочитать в статье (по-русски): Создание внешних модулей.
Запуск модуля ядра
Запускать модуль ядра можно из командой: sudo insmod tagvfs.ko
Остановить модуль можно командой: sudo rmmod tagvfs
Также есть способы запускать модули при старте операционной системы - об этом лучше прочитать в интернете.
Виртуальная файловая система
Итак, переходим с предмету разработки - виртуальной файловой системе. Общую последовательность этапов кодирования при её использовании можно представить следующей схемой:
регистрация - монтирование - заполнение_суперблока -
- ... работаем ... -
- удаление_суперблока - разрегистрация
Операции по регистрации выполняются только один раз за работу модуля и регистрируют они тип файловой системы. Цепочка процедур
монтирование - заполнение_суперблока - удаление_суперблока
может уже выполняться несколько раз: например, монтирование файловой системы в разные точки или последовательное монтирование/размонтирование одной файловой системы.
Рассмотрим этапы подробнее:
Регистрация
Для использования файловой системы, её необходимо прежде всего зарегистрировать. Для этого необходимо определиться с именем файловой системы и двумя функциями: монтирования файловой системы и удаления суперблока. По этим значениям заполняется экземпляр структуры file_system_type и далее указатель на него передаётся в функцию регистрации register_filesystem (и этот указатель должен быть валиден до разрегистрации). Для разрегистрации файловой системы тот же указатель передаётся в функцию unregister_filesystem. Функции регистрации обычно вызываются при инициализации и удалении модуля. Пример кода:
struct file_system_type fs_type = { .name = tagvfs_name, .mount = fs_mount,
.kill_sb = fs_kill, .owner = THIS_MODULE, .next = NULL};
int init_fs(void) {
return register_filesystem(&fs_type);
}
int exit_fs(void) {
return unregister_filesystem(&fs_type);
}
Собственно, на этом с регистрацией всё. Функции регистрируют файловую систему и дальше Линукс ждёт, когда кто-нибудь захочет смонтировать такую файловую систему. И вот тогда ядро и вызовет функции монтирования (и потом размонтирования, куда же без неё).
Монтирование
Когда пользователь захочет примонтировать файловую систему (и введёт команду mount), то ядро ищет структуру-описание нужной файловой системы и далее вызовет функцию из поля mount. Когда пользователь решит размонтировать - ядро вызовет функцию из поля kill_sb.
Функция монтирования должна возвратить указатель на корневой dentry. В свою очередь, функция размонтирования должна удалить суперблок. Такое несоответствие связано с взаимодействием трёх столпов виртуальной файловой системы: суперблок, dentry и inode.
Сначала пару слов про суперблок.
Вообще обычно точка монтирования файловой системы определяется или описывается суперблоком: это структура, описывающая для ядра саму файловую систему; это точка входа в виртуальную файловую систему со стороны ядра операционной системы. В суперблоке (а это структура) большое количество полей и их состав зависит от версии ядра, макросов условной компиляции; технически даже порядок полей может меняться.
Часть полей заполняются программистом: это операции с суперблоком, пользовательские данные и т.п..
Часть полей использует само ядро: списки нодов, dentry, блокировки и много другого. Очевидно, что не рекомендуется залезать в поля, которые управляются самим ядром: это может привести к интересным эффектам, также не забываем, что наличие полей и их тип зависит от версии ядра.
Кроме суперблока есть описатели для отдельных файлов: inode или, проще, нода. Это уже данные, приближенные к самой файловой системе. И помимо суперблока и нод есть ещё кэш. Этот кэш и представлен структурами dentry, которые как бы заменяют/ускоряют доступ к данным нода, организуют ноды в иерархические структуры и в ряде случае выступают как заменители нод.
Итак, при монтировании нужно выдать в ядро указатель на корневой dentry. Отмечу что создание нового суперблока при монтировании - это типовой побочный эффект (ориг: creation of new superblock is a common side effect). Поэтому технически можно выдавать dentry от уже существующей файловой системы и не создавать ещё один суперблок - ядро Линукса такие штуки позволяет делать.
В нашем случае использование существующей файловой системы не подходит, поэтому создаём новый суперблок вместе с корневым элементом.
Если вы делаете типовые задачи в виртуальной файловой системе, то для монтирования и создания суперблока рекомендуется использовать готовые функции, предоставляемые ядром:
mount_bdev - монтирование файловой системы на основе блочного устройства. В этом случае ядро откроет блочное устройство и немного его проинспектирует. При удалении суперблока соответствующей функцией ядро закроет блочное устройство;
mount_nodev - монтирование файловой системы без явного устройства. В этом случае заботы об источнике информации модуль берёт на себя;
mount_single - монтирование файловой системы, в которой все точки монтирования используют один суперблок.
Функции работают по примерно одинаковой схеме: в неё передаётся структура-описатель файловой системы, флаги, указатель на данные и функция-заполнитель суперблока. Далее функция mount_... создаёт суперблок и вызывает функцию-заполнитель. На выходе mount_... выдаёт указатель на корневой dentry, взятый из суперблока.
Разделение операции монтирования на 2 части позволяет гибче настроить процесс (см. выше про типовой побочный эффект) и обработать ошибки. Например, нашей файловой системе tagvfs требуется доступ к хранилищу с данными. И если такого доступа нет (или хранилище невозможно создать), то и необходимости что-то создавать тоже нет - можно сразу выходить с ошибкой. Поэтому хранилище открывается в функции монтирования.
При размонтировании вызывается функция, прописанная в поле kill_sb описателя файловой системы (в нашем случае это fs_kill). Функция удаления должна подчищать ресурсы, относящиеся к файловой системе. Соответственно, если вы для монтирования используете mount_bdev, то при удалении суперблока используете функцию kill_block_super (или, если вы экстремал, сами закрываете устройство). В нашем случае достаточно удалить суперблок и для этого есть общая функция generic_shutdown_super.
Заполнение суперблока
Вообще суперблок попадает в функцию заполнения уже набитый дефалтовыми значениями. ВНИМАНИЕ!: Дефалтовые значения НЕ значит нулевые. Поэтому, если вы не собираетесь заполнять их правильными значениями, то не обнуляйте их.
Однако, желательно, чтобы суперблок содержал информацию о хранилище информации. Это пользовательские данные и для их хранения предназначено поле s_fs_info - вот там и храните указатель на свои данные. Далее в работе всегда есть возможность достучаться до суперблока и получить это значение.
sb->s_fs_info = data;
Помимо данных об общем хранилище для файловой системы требуется для каждой ноды хранить свой блок информации. Для хранения можно использовать поле i_private в структуре inode. В tagvfs используется другой подход - кастомные функции выделения и освобожения inode для хранения дополнительных данных. Т.к. ядро создаёт ноды при участии суперблока, то эти кастомные операции прописываются в суперблок. Общая схема выглядит так:
создаётся структура (TagfsInode), содержащая inode;
определяются две функции: для создания inode и для удаления inode. ВНИМАНИЕ!: в функции создания необходимо обязательно инициализировать вложенный inode через вызов функции inode_init_once();
создаётся экземпляр структуры super_operations, используемой для описания операций самого суперблока. В структуру прописываются функции создания и удаления inode;
указатель на экземпляр операций прописывается в суперблок.
В коде это выглядит так::
struct TagfsInode {
struct inode nod;
// Дополнительные поля
};
struct inode* tagfs_inode_alloc(struct super_block *sb) {
struct TagfsInode* d;
d = kzalloc(sizeof(struct TagfsInode), GFP_KERNEL);
if (!d) { return NULL; }
// Обязательно инициализировать, иначе при размонтировании будет крэш
inode_init_once(&(d->nod));
// Дополнительная инициализация
return &(d->nod);
}
void tagfs_inode_free(struct inode* nod) {
struct TagfsInode* d;
d = container_of(nod, struct TagfsInode, nod);
// Освобождение ресурсов
kfree(d);
}
static const struct super_operations tagfs_ops = {
.alloc_inode = tagfs_inode_alloc,
.free_inode = tagfs_inode_free
};
// Инициализируем суперблок
sb->s_op = &tagfs_ops;
Корневой элемент
Напомню, что основной артефакт при монтировании - указатель на корневой dentry. Этот указатель функция mount_nodev берёт из поля s_root суперблока, который передаётся в функцию заполнения. Заполнение поля s_root проводится в два этапа:
создали корневую ноду
создали dentry от корневой ноды и записали в s_root
Здесь кратко опишу шаги по получению корневой ноды. Подробнее про создание распишу при описании dentry и inode.
Итак, для получения ноды выполняем последовательность действий:
создаём новую ноду / inode, для чего используется функция ядра new_inode;
заполняем поля ноды. В частности потребуются номер ноды (поле i_ino), её тип / права доступа (поле i_mode). Можно заполнить время доступа/создания/модификации (поля i_atime, i_ctime, i_mtime), размер элемента (поле i_size);
заполняем операции: как для ноды и как для файла (поля i_op, i_fop);
если корневая нода является директорией (обычно это так), выставляем счётчик использования в 2. Это такая тонкость для директорий.
Полученную ноду используем для получения dentry: вызываем функцию d_make_root, которая выдаёт dentry на созданный корневой элемент. Полученный dentry присваиваем полю s_root суперблока и выходим из функции заполнения. В коде это выглядит так:
struct inode* n = new_inode(sb);
if (!n) {
return NULL;
}
n->i_mode = mode;
n->i_ino = index;
n->i_atime = n->i_ctime = n->i_mtime = current_time(n);
set_nlink(n, 2);
n->i_fop = &tagfs_root_file_ops;
n->i_op = &tagfs_root_inode_ops;
sb->s_root = d_make_root(n);
if (!sb->s_root) { return -ENOMEM; }
return 0;
Дальше рассмотрим наполнение файловой системы содержимым.
Примечания:
Про FUSE и работу в пользовательском режиме - слышал / читал. Если хотите обсудить этот подход, то лучше это сделать в комментариях к первой части. В этой же статье рассматриваются именно модули ядра и именно внутреннее устройство файловых систем в Линуксе.
Заставка является изображением, сгенерированным нейросетью.