Многие годами пользуются Linux, не догадываясь, что заглянуть внутрь ядра и добавить туда собственный код можно за один вечер. Специальный SDK не нужен, графическая среда тоже: достаточно компилятора, заголовочных файлов установленного ядра и пары десятков строк на C. В этой статье с нуля пишется учебный драйвер-пустышка, который при загрузке и выгрузке оставляет приветствия в системном журнале, а читать их удобнее всего командой dmesg. Попутно разберёмся, чем модуль отличается от обычной программы и как устроена система сборки ядра.

Чем модуль ядра отличается от обычной программы

Обычное приложение живёт в пространстве пользователя. Операционная система выдаёт ему отдельную память, прячет за своими интерфейсами доступ к железу и спокойно переживает крах: падает один процесс, остальные даже не заметят. Код модуля загружается прямо в адресное пространство ядра и исполняется в привилегированном режиме, где разделения между компонентами нет. Ошибочная запись по случайному указателю здесь оборачивается не привычным segmentation fault, а сообщением kernel panic на весь экран.

Отсюда и другие правила игры. У модуля нет функции main, точками входа служат две функции, которые разработчик регистрирует макросами module_init и module_exit. Нет и стандартной библиотеки C: вместо printf используется printk, вместо malloc функция kmalloc, а заголовки вроде stdio.h просто недоступны. Модуль жёстко привязан к версии ядра, под которой собран, поэтому после обновления системы его придётся пересобрать.

Зато круг задач впечатляет. Модулями поставляются драйверы устройств, файловые системы, сетевые фильтры и виртуализация. Ядро Linux монолитное, но благодаря загружаемым модулям нужный код попадает в него на лету, без пересборки и перезагрузки. Посмотреть текущий набор можно командой lsmod: на рабочей станции список легко переваливает за сотню позиций, и почти весь он собран тем же способом, что и наш первый hello.ko.

Готовим окружение и заголовочные файлы ядра

Первым делом узнайте точную версию ядра командой uname -r. На свежей Ubuntu она выглядит, например, как 6.8.0-45-generic. Под эту версию нужны заголовочные файлы и дерево сборки ядра, иначе компилятор не соберёт ни один модуль.

Для Debian и Ubuntu установка выглядит так:

sudo apt update
sudo apt install build-essential linux-headers-$(uname -r)

В Fedora те же компоненты поставляет dnf:

sudo dnf install kernel-devel kernel-headers gcc make

В Arch Linux достаточно пакета linux-headers и группы base-devel. После установки проверьте, что на месте появился каталог сборки:

ls -ld /lib/modules/$(uname -r)/build

Если команда показала существующий каталог с Makefile и подкаталогами arch и include, окружение готово. Каталог build представляет собой символическую ссылку на заголовочные файлы, и именно туда система сборки будет обращаться при компиляции. Совет из практики: первые модули собирайте и испытывайте в виртуальной машине, ведь цена ошибки в пространстве ядра выше цены опечатки в приложении.

Пишем код учебного модуля на C

Вот полный код файла hello.c, готовый к копированию:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Test Author");
MODULE_DESCRIPTION("Учебный драйвер-пустышка с сообщениями в системный журнал");
MODULE_VERSION("1.0");

static int __init hello_init(void)
{
    pr_info("hello: модуль загружен, привет из пространства ядра\n");
    return 0;
}

static void __exit hello_exit(void)
{
    pr_info("hello: модуль выгружен, журнал запомнит наше знакомство\n");
}

module_init(hello_init);
module_exit(hello_exit);

Разберём ключевые места. Макрос module_init сообщает ядру, какую функцию вызвать при загрузке, module_exit при выгрузке. Суффиксы __init и __exit подсказывают ядру освободить память кода инициализации после старта, для одноразовых функций это приятная экономия. Функция инициализации возвращает целое число: ноль означает успех, отрицательное значение отменяет загрузку, например -ENOMEM при нехватке памяти.

Вывод в журнал делает pr_info, обёртка над printk с уровнем KERN_INFO, за которым закреплено число 6. Всего уровней восемь: KERN_EMERG с номером 0 зарезервирован для критических сбоев системы, дальше идут KERN_ALERT, KERN_CRIT, KERN_ERR, KERN_WARNING, KERN_NOTICE, а замыкает шкалу KERN_DEBUG с номером 7 для отладочных сообщений. Если уровень в вызове printk не указан вовсе, ядро применяет уровень по умолчанию. Перевод строки в конце обязателен, иначе записи в журнале склеятся. Метка hello в начале сообщения не случайна: в живом журнале десятки источников, и по ней ваши строки находятся мгновенно.

Строки MODULE_LICENSE, MODULE_AUTHOR и MODULE_DESCRIPTION хранят метаданные модуля. Лицензия GPL здесь не формальность: ядро помечает систему словом tainted при загрузке модулей с несовместимой лицензией, а часть внутренних функций доступна только GPL-коду, и без правильной лицензии сборка закончится ошибками о неопознанных символах.

Собираем модуль штатной системой сборки ядра

Модуль не собирают одиночным вызовом gcc. Для этого существует kbuild, штатный механизм ядра, и вся настройка сводится к файлу Makefile:

obj-m += hello.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

Строка obj-m перечисляет объектные файлы, которые нужно собрать в виде модулей. Параметр -C приказывает make перейти в дерево ядра, а переменная M=$(PWD) вернуться в текущий каталог и собрать здесь внешний модуль. Запуск ещё проще:

make

Вывод займёт несколько десятков строк, в конце появится файл hello.ko. Хотя исходного кода всего двадцать строк, готовый файл легко весит больше десятка килобайт: внутрь вшиваются метаданные, таблицы символов и отладочная информация. Отступы перед командами в Makefile должны быть символом табуляции, и ошибка missing separator у новичков почти всегда означает пробелы вместо него.

Быстрая проверка результата:

modinfo ./hello.ko

Утилита покажет имя файла, описание, лицензию, версию и поле vermagic с точной строкой версии ядра, например 6.8.0-45-generic SMP mod_unload. Именно эта строка сверяется при каждой загрузке, и любое расхождение даёт ошибку Invalid module format. В поле signer при подписанном модуле появится имя ключа, что пригодится в конце статьи.

Загружаем модуль и читаем его сообщения в dmesg

Загрузка выполняется одной командой с правами администратора:

sudo insmod ./hello.ko

Строки ушли в кольцевой буфер ядра, который экспортируется в пространство пользователя через устройство /dev/kmsg. Читается он командой dmesg, а в системах с systemd те же записи доступны через journalctl -k:

sudo dmesg | tail -n 1

Ожидаемый вывод выглядит примерно так:

[ 8452.311204] hello: модуль загружен, привет из пространства ядра

Число в квадратных скобках, это секунды с момента старта системы с точностью до микросекунд. Почему сообщение не вывалилось прямо в терминал при загрузке? За это отвечает console_loglevel, текущее значение которого показывает файл /proc/sys/kernel/printk. На типичной системе там записано четыре числа вида 4 4 1 7: текущий уровень консоли, уровень по умолчанию, минимально допустимый и загрузочный. На консоль попадают только сообщения с номером меньше текущего уровня, то есть при значении 4 это KERN_EMERG, KERN_ALERT, KERN_CRIT и KERN_ERR. Наш KERN_INFO с номером 6 молча лежит в буфере, иначе каждая служба засыпала бы экран болтовнёй. Уровень консоли меняется на лету, например командой dmesg -n 8.

В свежих версиях Ubuntu по умолчанию включён параметр ядра kernel.dmesg_restrict со значением 1, поэтому обычному пользователю dmesg отвечает Operation not permitted, и перед командой нужен sudo либо значение sysctl, сброшенное в 0. Для наблюдения в реальном времени у dmesg есть ключ --follow:

sudo dmesg --follow

Терминал превращается в живой хвост журнала. Откройте вторую вкладку, выполните там sudo rmmod hello, и строка о выгрузке сама появится в первой. Перед повторными экспериментами буфер удобно чистить командой sudo dmesg -C.

Убедиться, что модуль действительно в ядре, можно двумя способами:

lsmod | grep hello
grep hello /proc/modules

Обе команды выведут одну строку с именем модуля, размером в байтах, счётчиком использования и списком зависимостей, например hello 12288 0. Выгрузка симметрична загрузке:

sudo rmmod hello
sudo dmesg | tail -n 1

В журнале появится второе сообщение. Утилита modprobe работает иначе: она ищет модуль по имени в каталоге /lib/modules/$(uname -r)/, сама подтягивает зависимости и читает параметры из файлов в /etc/modprobe.d/. После копирования своего модуля в этот каталог нужно выполнить depmod -a, чтобы обновилась карта зависимостей modules.dep. Разработчику на время отладки удобнее insmod с явным путём, modprobe остаётся способом постоянной установки.

От заготовки к настоящему символьному устройству

Драйвером-пустышкой уже можно гордиться, но настоящий драйвер отвечает на обращения к файлу устройства. Кратчайший правильный путь для учебных целей, это интерфейс misc device: ядро само создаст узел /dev/hello, без ручного вызова mknod и подбора старших номеров устройств.

#include <linux/module.h>
#include <linux/miscdevice.h>
#include <linux/fs.h>

static const char msg[] = "Привет из ядра!\n";

static ssize_t hello_read(struct file *file, char __user *buf,
                          size_t count, loff_t *ppos)
{
    return simple_read_from_buffer(buf, count, ppos, msg, sizeof(msg));
}

static const struct file_operations hello_fops = {
    .owner = THIS_MODULE,
    .read  = hello_read,
};

static struct miscdevice hello_dev = {
    .minor = MISC_DYNAMIC_MINOR,
    .name  = "hello",
    .fops  = &hello_fops,
};

static int __init hello_init(void)
{
    int ret = misc_register(&hello_dev);
    if (ret)
        return ret;
    pr_info("hello: устройство /dev/hello зарегистрировано\n");
    return 0;
}

static void __exit hello_exit(void)
{
    misc_deregister(&hello_dev);
    pr_info("hello: устройство /dev/hello удалено\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");

Поле .owner со значением THIS_MODULE заслуживает отдельного слова: оно увеличивает счётчик модуля на время открытого файла и не даёт ядру выгрузить код, пока какой-нибудь процесс держит /dev/hello открытым, иначе получили бы падение при обращении к выгруженной памяти. Помощник simple_read_from_buffer корректно копирует данные в пространство пользователя с учётом текущей позиции чтения, избавляя от ручной работы с функцией copy_to_user. После сборки и загрузки проверка занимает одну строку:

cat /dev/hello

В терминале появится строка из массива msg, а в dmesg добавятся записи о регистрации. Выгрузка через rmmod автоматически уберёт узел из каталога /dev: среагирует подсистема устройств, получившая событие о снятии регистрации. Если файл устройства кем-то открыт, ядро откажет в выгрузке сообщением Module hello is in use, и это ещё одна страховка, встроенная в архитектуру.

Безопасная загрузка модуля и типичные ошибки новичка

Несколько минут на проверку экономят часы перезагрузок. Перед загрузкой нового модуля полезно прогонять его по короткому чек-листу:

  1. Версия ядра совпадает с той, под которой собран модуль, иначе insmod ответит Invalid module format, а после очередного обновления системы код нужно пересобрать заново;
  2. Лицензия объявлена как GPL, иначе в dmesg появится пометка о tainted kernel и часть GPL-символов окажется недоступной;
  3. Счётчик использования в третьей колонке lsmod равен нулю, иначе rmmod откажется выгружать модуль сообщением Module hello is in use;
  4. При включённом Secure Boot модуль подписан, иначе загрузка завершится отказом с записью Key was rejected by service, а подпись ставится утилитой sign-file из каталога scripts в заголовках ядра.

Ошибки новичков легко предсказать, большинство уже мелькали выше. Пробелы вместо табуляции в Makefile дают missing separator. Забытый перевод строки в pr_info склеивает записи журнала. Повторный insmod того же модуля отвечает File exists, потому что второй копии в ядре быть не может. Модуль, собранный под другое ядро, отвергается на этапе vermagic, и это не вредность, а защита: ABI ядра между версиями меняется, и старый код способен навредить новой системе.

Куда двигаться после первой победы? В исходниках ядра в каталоге Documentation лежит подробное описание подсистем, особенно полезен файл driver-api/misc_devices.rst, описывающий только что использованный интерфейс. Дальше обычно идут атрибуты sysfs, обработка прерываний, платформенные драйверы и трассировка eBPF. Каждый шаг добавляет в код по несколько строк, а ощущение от первой собственной записи в dmesg стоит потраченного вечера: после него архитектура операционной системы перестаёт быть абстракцией из учебника.