Как стать автором
Обновить
558.14
Яндекс
Как мы делаем Яндекс

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

Время на прочтение13 мин
Количество просмотров7.1K

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

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

Теги:
Хабы:
Всего голосов 42: ↑42 и ↓0+57
Комментарии6

Публикации

Информация

Сайт
www.ya.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия