Продолжаем создавать модуль ядра в Линукс на примере виртуальной файловой системы.

Часть 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 и работу в пользовательском режиме - слышал / читал. Если хотите обсудить этот подход, то лучше это сделать в комментариях к первой части. В этой же статье рассматриваются именно модули ядра и именно внутреннее устройство файловых систем в Линуксе.

Заставка является изображением, сгенерированным нейросетью.

Комментарии (0)