Наверное, каждый разработчик рано или поздно задумывается о том, что же происходит в операционной системе на уровне ядра. Для ОС на базе ядра 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
После получения версии ядра нужно указать его в следующей команде (пример для моей машины):
$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