Многие годами пользуются 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, и это ещё одна страховка, встроенная в архитектуру.
Безопасная загрузка модуля и типичные ошибки новичка
Несколько минут на проверку экономят часы перезагрузок. Перед загрузкой нового модуля полезно прогонять его по короткому чек-листу:
- Версия ядра совпадает с той, под которой собран модуль, иначе insmod ответит Invalid module format, а после очередного обновления системы код нужно пересобрать заново;
- Лицензия объявлена как GPL, иначе в dmesg появится пометка о tainted kernel и часть GPL-символов окажется недоступной;
- Счётчик использования в третьей колонке lsmod равен нулю, иначе rmmod откажется выгружать модуль сообщением Module hello is in use;
- При включённом 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 стоит потраченного вечера: после него архитектура операционной системы перестаёт быть абстракцией из учебника.