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

  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 как к более сложной и детальной. Надеюсь, что данная работа окажется полезной и станет подспорьем в работе нашим коллегам в системном программировании.

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

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