
Наверное, каждый разработчик рано или поздно задумывается о том, что же происходит в операционной системе на уровне ядра. Для ОС на базе ядра 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. В отличие от прошлых разделов, здесь хочется отметить только небольшие изменения, которые могут помешать написать свой модуль в полном соответствии с книгой:
Начиная с ядра 5.15, нельзя напрямую менять адрес устройства, для этого существует отдельная функция →
static inline void dev_addr_set(struct net_device *dev, const u8 *addr).В функции
ndo_tx_timeoutизменилось число аргументов, теперь она выглядит так →void (*ndo_tx_timeout) (struct net_device *dev, unsigned int txqueue).При использовании
napiвместо прямой установкиdev->pollиdev->weightнеобходимо использовать функциюstatic→inline void netif_napi_add(struct net_device *dev, struct napi_struct *napi, int (*poll)(struct napi_struct *, int)).
В результате проделанной работы нам удалось актуализировать некоторые главы существующего учебника по написанию модулей ядра. В идеале хотелось бы, чтобы подобная информация была более доступной и хорошо написанной, потому что на сегодняшний день порог входа в эту область остаётся достаточно высоким, а понятных (и уж тем более современных) книг по данной тематике преступно мало.
При изучении модулей ядра рекомендую начать с книги LKMPG, а после усвоения переходить к LDD как к более сложной и детальной. Надеюсь, что данная работа окажется полезной и станет подспорьем в работе нашим коллегам в системном программировании.
Полезные ссылки
Linux Device Drivers, Third Edition, Jonathan Corbet, Alessandro Rubini, Greg Kroah‑Hartman, 2005
The Linux Kernel Module: Programming Guide, Peter Jay Salzman, Michael Burian, Ori Pomerantz, Bob Mottram, Jim Huang, 2024
