Основы написания модулей ядра в Linux

29 сентября 2021

Вопрос ковыряния ядра Linux впервые поднимался в этом блоге еще в далеком 2016-м году. Мы научились собирать ядро из исходников и цепляться к нему отладчиком. Но на этом все и заглохло. Тогда найти актуальную информацию по разработке ядерного кода в Linux, да еще и в удобоваримом виде, было проблемой. Я предпочел дождаться появления свежих книг по теме, а пока заняться изучением чего-то другого. И вот, спустя пять лет, такие книги были опубликованы. В связи с чем я решил попробовать написать пару модулей ядра, и посмотреть, как пойдет.

Проводить эксперименты было решено на Raspberry Pi 3 Model B+. На то есть три основные причины. Во-первых, малинка широко доступна и стоит недорого (особенно третья малинка, после выхода четвертой), что делает эксперименты повторяемыми. Во-вторых, запускать модули ядра на той же машине, где вы их разрабатываете, в любом случае не лучшая затея. Ведь ошибка в ядерном коде может приводить к какими угодно последствиям, не исключая повреждения ФС. И в-третьих, в отличие от виртуальной машины, малинка не отъедает ресурсы на вашей основной системе и позволяет реально взаимодействовать с реальным железом.

Образ системы был записан на SD-карту при помощи Raspberry Pi Imager. Приложение использовало образ на основе Raspbian 10 с ядром Linux 5.10. Это LTS-версия ядра, поддержка которого прекратится в декабре 2026-го года.

Для написания модулей ядра необходимо установить пакет с заголовочными файлами. В Raspbian это делается так:

sudo apt install raspberrypi-kernel-headers
ls /lib/modules/$(uname -r)

В других системах пакет может называться linux-headers-* или как-то иначе.

Создадим новую директорию с файлом hello.c:

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

int init_module(void) {
    pr_info("Hello world\n");
    return 0;
}

void cleanup_module(void) {
    pr_info("Goodbye world\n");
}

MODULE_LICENSE("GPL");

Рядом положим файл Makefile:

obj-m += hello.o

all:
    $(MAKE) -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
    $(MAKE) -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Говорим make. В результате должен появиться файл hello.ko.

Теперь попробуем следующие команды:

# посмотреть информацию о модуле
modinfo hello.ko

# загрузить модуль
sudo insmod hello.ko

# список загруженных модулей ядра
lsmod | grep hello

# выгрузить модуль
sudo rmmod hello

# почитать логи
tail /var/log/syslog

При загрузке модуля будет вызвана процедура init_module(), а при выгрузке — cleanup_module(). Они напишут соответствующие логи через pr_info(), и мы увидим их в /var/log/syslog. С этим все понятно. Давайте перейдем к чему-то поинтересней.

Начнем с более детального рассмотрения pr_info(). Среди символов, экспортируемых ядром, вы его не найдете. Поиск по заголовочным файлам показывает, что на самом деле это макрос, объявленный в linux/printk.h:

#define pr_info(fmt, ...) \
        printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__)

А вот printk() уже является экспортируемым символом:

$ sudo cat /proc/kallsyms | grep 'T printk'
...
801833e0 T printk_nmi_direct_exit
809f1508 T printk
809f16f4 T printk_deferred
...

Первая колонка — это адрес символа. Он отображаются, только если читать /proc/kallsyms под суперпользователем. В противном случае, мы увидим нули. Во второй колонке показано, откуда экспортируется символ. Согласно man nm, T и t соответствуют секции кода (.text). Заглавная буква означает, что символ виден глобально, а значит, может использоваться в модулях ядра. Теперь мы чуть лучше понимаем, как происходит общение между ядром и его модулями.

Далее, рассмотрим модуль посложнее:

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

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Aleksander Alekseev");
MODULE_DESCRIPTION("A simple driver");

static char* name = "%username%";

module_param(name, charp, 0);
MODULE_PARM_DESC(name, "Enter your name");

static int __init init(void) {
    pr_info("Hello, %s\n", name);
    return 0;
}

static void __exit cleanup(void) {
    pr_info("Goodbye, %s\n", name);
}

module_init(init);
module_exit(cleanup);

Из этого примера мы узнаем ряд важных вещей. Во-первых, что процедуры, вызываемые при загрузке и выгрузке модуля, могут называться как угодно. Во-вторых, что в модуле можно указать не только его лицензию, но также автора и краткое описание. Сравните вывод modinfo для этого модуля и предыдущего. И в-третьих, модуль может принимать параметры:

sudo insmod param.ko name=Alex
sudo rmmod param
tail /var/log/syslog

В логах мы предсказуемо увидим:

Hello, Alex
Goodbye, Alex

Параметры, переданные модулю, видны через sysfs. Но чтобы это работало, код нужно немного изменить:

// module_param(name, charp, 0);
module_param(name, charp, S_IRUGO);

Если теперь пересобрать модуль, то можно сделать так:

cat /sys/module/param/parameters/name

Думаю, что для первого раза удивительных открытий достаточно. Полная версия кода доступна в этом репозитории на GitHub. Там же есть список дополнительных материалов для самостоятельного изучения.

Дополнение: В продолжение темы см статьи Модули ядра Linux: пример символьного устройства, Модули ядра Linux: таймеры и GPIO и Модули ядра Linux: обработка прерываний.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.