Как перенести Linux Device Drivers на современные ядра

от автора

Наверное, каждый разработчик рано или поздно задумывается о том, что же происходит в операционной системе на уровне ядра. Для ОС на базе ядра Linux относительно простой точкой входа является написание своих модулей. Модули по своей сути — это драйверы устройств (символьные char device, блочные block device, сетевые network device и другие).

В книге Linux Device Drivers есть такое определение драйверов устройств:

«Драйверы — это „чёрные ящики“, которые заставляют специфичную часть оборудования соответствовать строго заданному программному интерфейсу. Они полностью скрывают детали того, как работает устройство. Действия пользователя сводятся к выполнению стандартизированных вызовов, которые не зависят от специфики драйвера. Перевод этих вызовов в специфичные для данного устройства операции, которые выполняются реальным оборудованием, является задачей драйвера устройства. Этот программный интерфейс таков, что драйверы могут быть собраны отдельно от остальной части ядра и подключены в процессе работы, когда это необходимо. Такая модульность делает драйверы Linux простыми для написания, так что теперь доступны сотни драйверов».

Вообще в Linux Device Drivers (LDD) подробно описано, как создать свой модуль ядра для интересующего класса устройств. Однако эта книга очень устарела, поскольку в ней рассматриваются случаи, справедливые для ядра версии 2.X.X. А в 2025 году третьему изданию Linux Device Drivers исполняется 20 лет!

На сегодняшний день большинство устройств используют ядра 5.X.X или 6.X.X, в которых многое изменилось. Так и появилась идея этой статьи — адаптировать информацию из LDD под современные ядра. Всю работу мы проделали совместно с Вячеславом Григоровичем @daredevil2002 и Александром Костриковым @akostrikov — за что им огромное спасибо!

Ниже рассмотрим следующие классы устройств: char device, block device и network device.

Используемая операционная система

Для разработки и проверки модулей под современные версии ядра использовалась ОС Ubuntu с версией ядра 6.5.0–25. Её можно получить на странице с архивными релизами. Далее необходимо выполнить пререквизиты, чтобы в дальнейшем заниматься только написанием и отладкой самих модулей.

Установка Kernel module package:

sudo apt-get install build-essential kmod

Установка заголовочных файлов:

sudo apt-get update apt-cache search linux-headers-`uname -r`

Получается следующий ответ: 

linux-headers-6.5.0-25-generic - Linux kernel headers for version 6.5.0 on 64 bit x86 SMP

После получения версии ядра нужно указать его в следующей команде (пример для моей машины):

sudo apt install kmod linux-headers-6.5.0-25-generic

После этого можно переходить непосредственно к написанию модулей.

Для пользователей vscode, чтобы все заголовочные файлы находились в IDE и работало автозаполнение, собран отдельный конфигурационный файл. Чтобы его получить, необходимо поставить расширение для C/C++ от Microsoft и в Command palette найти и выбрать C/C++: Edit Configurations (JSON). Тогда этот файл откроется, и можно смело добавлять:

{     "configurations": [         {             "name": "Linux",             "includePath": [                 "${workspaceFolder}/**",                 "/lib/modules/6.5.0-25-generic/build/include",                 "/lib/modules/6.5.0-25-generic/build/arch/x86/include",                 "/usr/src/linux-headers-6.5.0-25-generic/arch/x86/include/generated/"             ],             "defines": [                 "__GNUC__",                 "__KERNEL__"             ],             "compilerPath": "/usr/bin/gcc",             "cStandard": "c17",             "cppStandard": "gnu++17",             "intelliSenseMode": "linux-gcc-x64"         }     ],     "version": 4 }

С чего же начинать

Хочется сразу приступить к активным действиям и начать писать наш первый hello world, но сперва всё же покажу, что есть более современное пособие в данном направлении, которое может стать более простой точкой входа, — The Linux Kernel Module: Programming Guide (LKMPG). В этой книге рассматривается более современное ядро — 5.X.X. Сама книга написана более дружелюбно для тех, кто впервые столкнулся с написанием модулей — для новичков рекомендуется именно она.

Исходя из этого, следует справедливый вопрос: «А зачем адаптировать старый материал, когда уже существует новый?» Ответ прост: в LKMPG рассматривается только один класс устройств без сложных примеров использования. Для начала это то, что нужно, но если хочется чего‑то большего, нужно брать более сложные и интересные примеры, которые как раз есть в книге LDD.

Hello world

Первое, что вам встретится в любом руководстве по созданию модулей, — написание своего hello world. К счастью, эта часть актуальна и не претерпела серьёзных изменений, поэтому можно открывать любую из рассматриваемых книг и спокойно повторять пример оттуда.

Для самых любознательных: наиболее весомое изменение, которое удалось обнаружить, — выбор типа лицензии. Выбор лицензии обязателен для современных ядер. Исходный код можно посмотреть на GitLab.

Char device

Первым серьёзным испытанием при написании модулей ядра становится символьное устройство. Хорошая новость в том, что полный листинг вполне себе рабочего устройства представлен в LKMPG. Плохая новость — интересное устройство, которое действительно имеет некоторую логику в пространстве ядра и ощутимо для пользователя, приведено в LDD.

Далее описано использование памяти — не самое оптимальное, но всё же позволяющее сократить число аллокаций. Листинг кода не всегда полный, поэтому необходимо вдумчивое прочтение всего раздела и желание искать дополнительную информацию, чтобы собрать нечто, работающее корректно. Для самых нетерпеливых — готовый листинг кода для блочных устройств.

Как и любой модуль, символьное устройство начинается с описания точек входа и выхода. В нашем случае это функции scull_init и scull_cleanup:

static int __init scull_init(void) { dev_t dev; int alloc_ret = 1; alloc_ret = alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);  if (alloc_ret) {     pr_alert("Cannot register char device with\n");     return alloc_ret; }      major = MAJOR(dev);  pr_info("Assigned major number: %d.\n", major);  #if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 4, 0) cls = class_create(DEVICE_NAME); #else cls = class_create(THIS_MODULE, DEVICE_NAME); #endif  device_create(cls, NULL,  MKDEV(major, 0), NULL, DEVICE_NAME);  scull_device = kmalloc(sizeof(struct scull_dev), GFP_KERNEL);  if (!scull_device) {     scull_cleanup();     return -ENOMEM; }  memset(scull_device, 0, sizeof(struct scull_dev));  scull_device->quantum = SCULL_QUANTUM; scull_device->qset = SCULL_QSET; sema_init(&scull_device->sem, 1);      cdev_init(&scull_device->cdev, &scull_fops); scull_device->cdev.owner = THIS_MODULE;  int err = cdev_add(&scull_device->cdev, MKDEV(major, 0), 1);      if (err)     pr_notice("Can't add scull");  pr_info("Device created on /dev/%s\n", DEVICE_NAME);  return 0; }
void scull_cleanup(void) { dev_t dev = MKDEV(major, 0); if (scull_device) {     scull_trim(scull_device);     cdev_del(&scull_device->cdev);     kfree(scull_device); }  device_destroy(cls, MKDEV(major, 0)); class_destroy(cls);  unregister_chrdev_region(dev, 1); }

В этих функциях рассматривается присвоение и очистка major‑ и minor‑номеров устройства, создание класса и самого устройства. Это снимает необходимость создавать устройство в директории /dev/ самостоятельно, однако выдать права через chmod всё же потребуется. А ещё здесь выделяется память и проводится инициализация устройства.

Чтобы устройство выполняло свою работу, необходимо определить его операции. В данном примере описаны следующие методы:

struct file_operations scull_fops = {  .owner = THIS_MODULE,  .llseek = scull_llseek,  .read = scull_read,  .write = scull_write,  .ioctl = scull_ioctl,  .open = scull_open,  .release = scull_release,  };

Для актуальных версий ядра функции ioctl для символьных устройств больше не существует. На смену ей пришли unlocked_ioctl и compat_ioctl. Общая рекомендация — использовать unlocked_ioctl всегда, когда это возможно. Исторически функция ioctl могла заблокировать ядро, используя Big Kernel Lock (BKL). Следовательно, unlocked_ioctl направлена на то, чтобы исключить BKL. Функция compat_ioctl нужна для совместимости: она нужна, чтобы позволить 32-битному пользовательскому пространству вызывать 64-битное ядро.

Таким образом, итоговая структура выглядит следующим образом:

static struct file_operations scull_fops = {     .owner = THIS_MODULE,     .open = scull_open,     .read = scull_read,     .write = scull_write,     .release = scull_release,     .llseek = scull_llseek,     .unlocked_ioctl = scull_ioctl, };

Описание тривиальных операций scull_open и scull_release достаточно подробно изложено в LDD, поэтому заострять внимание на этом не стоит. Лучше обратимся к функции scull_write и scull_read. Для записи используется структура, представленная на схеме.

Структура символьного устройства

Структура символьного устройства

Как и в большинстве случаев, в ядре используются связные списки. Они, в свою очередь, разбиты на некоторые фрагменты данных, которые состоят из квантов. Выделение памяти происходит небольшими кусками (qset), в которых содержится некоторое количество квантов. Эта структура не является оптимальной для символьных устройств, однако она позволяет снизить число выделений памяти. Это достаточно дорогая операция, особенно в пространстве ядра.

Сами функции чтения и записи описаны достаточно хорошо, но функция scull_follow, которая всплывает в процессе чтения, упущена.

static struct scull_qset *scull_follow(struct scull_dev *dev, int n) { pr_info("scull_follow called\n"); struct scull_qset *qs = dev->data;   /* Allocate first qset explicitly if need be */ if (!qs) {     qs = dev->data = kmalloc(sizeof(struct scull_qset), GFP_KERNEL);     if (qs == NULL)         return NULL;  /* Never mind */       memset(qs, 0, sizeof(struct scull_qset)); }    while (n--) {     if (!qs->next) {         qs->next = kmalloc(sizeof(struct scull_qset), GFP_KERNEL);         if (qs->next == NULL)             return NULL;         memset(qs->next, 0, sizeof(struct scull_qset));     }     qs = qs->next;     continue; } return qs; }

Таким образом, scull_follow забирает на себя управление узлами связного списка.

Также следует обратить внимание, что здесь часто используется функция kmalloc с флагом GFP_KERNEL. Это обычный способ выделить память ядра для объектов, размер которых меньше, чем размер страницы памяти в ядре. Флаг GFP_KERNEL говорит о том, что производится выделение памяти: для некритичного участка данную аллокацию можно поставить на ожидание (sleep).

Функция scull_trim необходима для очистки неиспользуемой памяти. В ней вызывается функция kfree, в которую должны поступать лишь те объекты, которые были выделены при помощи функции kmalloc. Подробнее о способах выделения памяти можно узнать из главы 8 LDD.

int scull_trim(struct scull_dev* dev) { pr_info("scull_trim called\n"); struct scull_qset *dptr, *next; int qset = dev->qset; int i;  for (dptr = dev->data; dptr; dptr = next) {     if (dptr->data) {         for (i = 0; i < qset; ++i)             kfree(dptr->data[i]);         kfree(dptr->data);         dptr->data = NULL;     }     next = dptr->next;     kfree(dptr); }  dev->size = 0; dev->quantum = scull_quantum; dev->qset = scull_qset; dev->data = NULL; return 0; }

В остальном функциональность символьного устройства совпадает с функциональностью, описанной в LDD, и не нуждается в дополнительных уточнениях. В репозитории представлены несколько вариантов символьных устройств, каждое из которых немного отличается от других (например, в некоторых используются другие типы выделения памяти).

Block device

Переход к блочным устройствам выглядит обнадёживающим. Как и в случае с символьными устройствами, первое, что нужно сделать, — зарегистрировать устройство. В современных ядрах регистрация производится в точности как в книге LDD. Из явных отличий можно отметить define‑секции, которые используются для сборки устройства в разных режимах.

static int __init sbull_init(void) { #ifndef BIO_BASED_SBULL pr_info("SBULL: init sbull in request mode\n"); #else pr_info("SBULL: init sbull in bio mode\n"); #endif  #ifdef PRINT_INFO pr_info("SBULL: print info when fucntions called\n"); #endif  int ret = 0; sbull_major = register_blkdev(sbull_major, DEVICE_NAME); if (sbull_major <= 0) {     pr_warn("SBULL: unable to get major number\n");     return -EBUSY; }  sbull_device = sbull_add_device(sbull_major); if (IS_ERR(sbull_device))     ret = PTR_ERR(sbull_device);  if (ret != 0)     unregister_blkdev(sbull_major, DEVICE_NAME);  return ret; }

Далее рассмотрим операции блочных устройств в структуре block_device_operations. Сразу сталкиваемся с тем, что отсутствуют методы:

int (*media_changed) (struct gendisk *gd); int (*revalidate_disk) (struct gendisk *gd);

Кажется, что это можно обойти, — смотрим дальше. Следующая структура, с которой нужно работать, — gendisk. Первое, что бросается в глаза, — то, что она объявлена в <linux/blkdev.h>, а не в <linux/genhd.h>, как указано в книге. Также отсутствует поле capacity, возможно, что это небольшая проблема. В процессе инициализации мы доходим до функции blk_init_queue, которой просто нет в современных ядрах, а это очень важная часть, поскольку блочные устройства работают посредством запросов в очередь.

При переходе на версию ядра 5.X.X произошло изменение блочных устройств. Это связано с появлением multi-queue block layer (blk-mq) и удалением blk_init_queue за ненадобностью. Также появились новые параметры, планировщик и другие вещи. Напрашивается вывод, что написать простое блочное устройство по книге «в лоб» не получится, нужно разбираться, какие же изменения произошли.

Чтобы разобраться с проблемой, лучше всего использовать относительно свежий перевод статьи с Хабра, где детально рассмотрены эти изменения.

В итоге наша структура основного устройства стала проще:

typedef struct sbull_dev_t { sector_t capacity;            // Device size in bytes u8* data;                    // The data aray. u8 - 8 bytes struct blk_mq_tag_set tag_set; struct gendisk *disk; atomic_t open_counter; } sbull_dev_t;

Рассмотрим функцию добавления нового устройства:

Код
sbull_dev_t* sbull_add_device(int major) { sbull_dev_t *dev = NULL; int ret = 0; struct gendisk *disk; pr_info("SBULL: add device '%s' capacity %d sectors\n", DEVICE_NAME, DEVICE_CAPACITY);  dev = kzalloc(sizeof(sbull_dev_t), GFP_KERNEL); if (!dev) {     ret = -ENOMEM;     goto fail; }  atomic_set(&dev->open_counter, 0);  dev->capacity = DEVICE_CAPACITY; dev->data = vmalloc(DEVICE_CAPACITY << SECTOR_SHIFT); if (!dev->data) {     ret = -ENOMEM;     goto fail_kfree; } ret = init_tag_set(&dev->tag_set, dev); if (ret) {     pr_err("SBULL: Failed to allocate tag set\n");     goto fail_vfree; }  disk = blk_mq_alloc_disk(&dev->tag_set, dev); if (unlikely(!disk)) {     ret = -ENOMEM;     pr_err("SBULL: Failed to allocate disk\n");     goto fail_free_tag_set; } if (IS_ERR(disk)) {     ret = PTR_ERR(disk);     pr_err("SBULL: Failed to allocate disk\n");     goto fail_free_tag_set; } dev->disk = disk;  disk->flags |= GENHD_FL_NO_PART;  disk->major = major; disk->first_minor = 0; disk->minors = 1;  disk->fops = &sbull_fops;  disk->private_data = dev;  sprintf(disk->disk_name, DEVICE_NAME); set_capacity(disk, dev->capacity);  blk_queue_physical_block_size(disk->queue, SECTOR_SIZE); blk_queue_logical_block_size(disk->queue, SECTOR_SIZE); blk_queue_max_hw_sectors(disk->queue, BLK_DEF_MAX_SECTORS); blk_queue_flag_set(QUEUE_FLAG_NOMERGES, disk->queue);  ret = add_disk(disk); if (ret) {     pr_err("SBULL: Failed to add disk '%s'\n", disk->disk_name);     goto fail_put_disk; }  pr_info("SBULL: Simple block device [%d:%d] was added\n", major, 0); return dev;  fail_put_disk: put_disk(dev->disk); fail_free_tag_set: blk_mq_free_tag_set(&dev->tag_set); fail_vfree: vfree(dev->data); fail_kfree: kfree(dev); fail: pr_err("SBULL: Failed to add block device\n");  return ERR_PTR(ret); }

В процессе инициализации создаётся объект gendisk при помощи функции blk_mq_alloc_disk, а конфигурация очереди производится набором функций:

blk_queue_physical_block_size(disk->queue, SECTOR_SIZE); blk_queue_logical_block_size(disk->queue, SECTOR_SIZE); blk_queue_max_hw_sectors(disk->queue, BLK_DEF_MAX_SECTORS); blk_queue_flag_set(QUEUE_FLAG_NOMERGES, disk->queue);

В них указывается размер физического и логического блоков, максимальный сектор, в который можно обратиться с запросом, и устанавливается флаг (в данном случае флаг отключает попытку слияния запросов).

Также можно встретить новые типы выделения памяти kzalloc и vmalloc. Если кратко, то kzalloc — это kmalloc, который инициализируется нулем. А vmalloc — тип выделения памяти, который позволяет выделять больший объём, но выделенная память будет непрерывна виртуально, а не физически.

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

static blk_status_t _queue_rq(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd) { unsigned int nr_bytes = 0; blk_status_t status = BLK_STS_OK; struct request *rq = bd->rq; cant_sleep();  blk_mq_start_request(rq);  if (process_request(rq, &nr_bytes))     status = BLK_STS_IOERR;  #ifdef PRINT_INFO pr_info("SBULL: request %llu:%d processed\n", blk_rq_pos(rq), nr_bytes); #endif  blk_mq_end_request(rq, status); return status; }

В процессе обработки запросов вызывается функция blk_mq_start_request, которая оповещает блочные устройства о начале выполнения запроса и позволяет выполнить подготовительные операции, например включение таймера на время выполнения запроса. Затем происходит выполнение запроса, а позже завершение с получением статуса. Функция process_request отвечает за запись или чтение (подобно функциям read/write).

Функция ioctl написана схоже с примером из книги.

Также в соответствии с LDD реализована структура bio (block I/O) и реализована возможность выбрать, какой режим блочного устройства применять. Bio позволяет обрабатывать запрос специфичным для устройства путём и снизить время простоя (время, когда блочному устройству нельзя уходить в состояние сна).

Итоговый исходный код и процесс сборки можно посмотреть на GitLab.

Если детально разобраться в новой структуре blk_mq_queue_data, можно обнаружить, что блочное устройство вполне себе можно собрать по LDD, адаптировав под новые структуры и функции. Но это оставим для самых любопытных читателей:)

Network device

Сетевые устройства очень детально описаны в LDD-3. В отличие от прошлых разделов, здесь хочется отметить только небольшие изменения, которые могут помешать написать свой модуль в полном соответствии с книгой:

  1. Начиная с ядра 5.15, нельзя напрямую менять адрес устройства, для этого существует отдельная функция → static inline void dev_addr_set(struct net_device *dev, const u8 *addr).

  2. В функции ndo_tx_timeout изменилось число аргументов, теперь она выглядит так → void (*ndo_tx_timeout) (struct net_device *dev, unsigned int txqueue).

  3. При использовании napi вместо прямой установки dev->poll и dev->weight необходимо использовать функцию staticinline void netif_napi_add(struct net_device *dev, struct napi_struct *napi, int (*poll)(struct napi_struct *, int)).


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

При изучении модулей ядра рекомендую начать с книги LKMPG, а после усвоения переходить к LDD как к более сложной и детальной. Надеюсь, что данная работа окажется полезной и станет подспорьем в работе нашим коллегам в системном программировании.

Полезные ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/873288/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *