Разработчики драйверов сталкиваются с уникальным вызовом. Код, который они пишут, работает в самом привилегированном режиме системы, где одна ошибка может привести к полному краху операционной системы. В отличие от пользовательских приложений, которые изолированы механизмами защиты памяти, модуль ядра получает полный доступ ко всему адресному пространству и аппаратным ресурсам. Одновременно это и сила, и опасность. Загружаемые модули ядра предоставляют механизм расширения функциональности Linux без перекомпиляции базового ядра, а DKMS автоматизирует процесс пересборки модулей при обновлении системы. Способность динамически инструментировать работающее ядро через kprobes превращает отладку из мучительного процесса перезагрузок в интерактивное исследование.
Анатомия модуля ядра и жизненный цикл
Минимальный модуль ядра состоит из функций инициализации и очистки, помеченных макросами __init и __exit. Эти макросы указывают компоновщику, что код инициализации может быть выгружен из памяти после загрузки модуля, если модуль встроен в ядро. Для загружаемых модулей эти макросы не имеют эффекта, но их использование считается хорошей практикой:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Simple hardware driver");
MODULE_VERSION("1.0");
static int __init driver_init(void)
{
printk(KERN_INFO "Hardware driver: Initializing\n");
return 0;
}
static void __exit driver_exit(void)
{
printk(KERN_INFO "Hardware driver: Cleaning up\n");
}
module_init(driver_init);
module_exit(driver_exit);
Макрос MODULE_LICENSE критически важен для совместимости с ядром. Linux помечает ядро как "испорченное", если загружен проприетарный модуль, что может усложнить получение поддержки. Допустимые значения включают "GPL", "GPL v2", "Dual BSD/GPL", "Dual MIT/GPL". Функция printk работает аналогично printf, но записывает сообщения в кольцевой буфер ядра, доступный через dmesg или journalctl. Уровни логирования (KERN_INFO, KERN_WARNING, KERN_ERR) определяют приоритет сообщения и его видимость в системном журнале.
Компиляция модуля использует систему сборки kbuild, которая интегрирована в инфраструктуру ядра. Makefile для модуля выглядит минималистично, но скрывает сложную машинерию под капотом:
obj-m += hardware_driver.o
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
install:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install
Переменная obj-m указывает, что hardware_driver.o должен быть скомпилирован как модуль. Система сборки автоматически находит соответствующий .c файл и обрабатывает зависимости. Загрузка модуля выполняется командой insmod, которая требует root привилегий:
make
sudo insmod hardware_driver.ko
dmesg | tail
lsmod | grep hardware_driver
sudo rmmod hardware_driver
Команда lsmod отображает загруженные модули, показывая имя, размер в памяти и количество использований. Modinfo предоставляет метаинформацию о модуле без его загрузки, включая лицензию, автора, зависимости и параметры.
DKMS для автоматической пересборки при обновлении ядра
Dynamic Kernel Module Support решает фундаментальную проблему внешних модулей. Когда обновляется ядро, все модули, скомпилированные для предыдущей версии, становятся несовместимыми. DKMS отслеживает исходный код модулей и автоматически пересобирает их при установке нового ядра. Настройка DKMS начинается с создания конфигурационного файла dkms.conf в директории с исходниками:
PACKAGE_NAME="hardware_driver"
PACKAGE_VERSION="1.0"
BUILT_MODULE_NAME[0]="hardware_driver"
DEST_MODULE_LOCATION[0]="/kernel/drivers/misc"
AUTOINSTALL="yes"
MAKE[0]="make KERNELRELEASE=${kernelrelease} all"
CLEAN="make clean"
Параметр DEST_MODULE_LOCATION определяет, куда будет установлен скомпилированный модуль относительно /lib/modules/kernel_version/. Значение /kernel/drivers/misc подходит для общих драйверов, в то время как специфичные устройства могут использовать /kernel/drivers/net для сетевых карт или /kernel/drivers/block для блочных устройств. AUTOINSTALL="yes" указывает DKMS автоматически устанавливать модуль при пересборке.
Регистрация модуля в DKMS требует копирования исходников в /usr/src и вызова команд add, build, install:
sudo cp -r . /usr/src/hardware_driver-1.0
sudo dkms add -m hardware_driver -v 1.0
sudo dkms build -m hardware_driver -v 1.0
sudo dkms install -m hardware_driver -v 1.0
Команда dkms status отображает состояние всех зарегистрированных модулей, показывая, для каких версий ядра они собраны и установлены:
dkms status
hardware_driver, 1.0, 5.15.0-91-generic, x86_64: installed
hardware_driver, 1.0, 6.2.0-39-generic, x86_64: installed
При установке нового ядра через apt или yum, DKMS автоматически запускается через hooks пакетного менеджера. Сервис lvm2-monitor и аналогичные используют systemd unit файл dkms.service для управления автоматической пересборкой. Для отладки проблем со сборкой логи находятся в /var/lib/dkms/hardware_driver/1.0/build/make.log.
Удаление модуля из DKMS выполняется в обратном порядке:
sudo dkms uninstall -m hardware_driver -v 1.0
sudo dkms remove -m hardware_driver -v 1.0 --all
sudo rm -rf /usr/src/hardware_driver-1.0
Флаг --all удаляет модуль для всех версий ядра. Для создания бинарных пакетов DKMS поддерживает mktarball, который создает архив с предкомпилированными модулями для распространения на системах без компилятора.
Отладка модулей через kprobes и динамическую трассировку
Kprobes предоставляют механизм динамической инструментации ядра, позволяя вставлять точки останова практически в любом месте кода ядра без перекомпиляции. Существует два основных типа: kprobes для точек входа в функции и kretprobes для точек возврата. При регистрации kprobe, адрес инструкции заменяется breakpoint инструкцией. Когда процессор достигает этой точки, происходит trap, сохраняются регистры, и управление передается обработчику kprobe:
#include <linux/kprobes.h>
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
printk(KERN_INFO "pre_handler: p->addr = 0x%p, ip = %lx\n",
p->addr, regs->ip);
return 0;
}
static struct kprobe kp = {
.symbol_name = "__x64_sys_openat",
.pre_handler = handler_pre,
};
static int __init kprobe_init(void)
{
int ret;
ret = register_kprobe(&kp);
if (ret < 0) {
printk(KERN_ERR "register_kprobe failed, returned %d\n", ret);
return ret;
}
printk(KERN_INFO "Planted kprobe at %p\n", kp.addr);
return 0;
}
static void __exit kprobe_exit(void)
{
unregister_kprobe(&kp);
printk(KERN_INFO "kprobe at %p unregistered\n", kp.addr);
}
module_init(kprobe_init);
module_exit(kprobe_exit);
Важное примечание: В современных ядрах Linux (версии 5.6+) внутренние имена функций системных вызовов изменились. Функция do_sys_open может быть заинлайнена или заменена на другие реализации. Для трассировки операций открытия файлов используйте __x64_sys_openat (на архитектуре x86-64) или найдите актуальное имя функции через /proc/kallsyms:
sudo grep openat /proc/kallsyms | grep sys
Структура pt_regs содержит сохраненное состояние регистров процессора. На x86-64 аргументы функции передаются через регистры rdi, rsi, rdx, rcx, r8, r9 согласно System V ABI. Для функции системного вызова первый аргумент (file descriptor) находится в regs->di, второй (filename pointer) в regs->si. Kretprobes работают через trampolines, модифицируя адрес возврата в стеке:
static int ret_handler(struct kretprobe_instance *ri, struct pt_regs *regs)
{
long retval = regs_return_value(regs);
printk(KERN_INFO "syscall returned %ld\n", retval);
return 0;
}
static struct kretprobe my_kretprobe = {
.handler = ret_handler,
.entry_handler = NULL,
.maxactive = 20,
.kp.symbol_name = "__x64_sys_openat"
};
Параметр maxactive определяет, сколько одновременных вызовов функции могут быть обработаны. При рекурсивных или высокочастотных функциях недостаточное значение приведет к пропущенным событиям, отслеживаемым через nmissed счетчик в /sys/kernel/debug/kprobes/list.
Для интерактивной отладки без написания модуля используется интерфейс kprobe_events через ftrace:
echo 'p:myprobe __x64_sys_openat dfd=%di filename=+0(%si):string flags=%dx mode=%cx' > /sys/kernel/debug/tracing/kprobe_events
echo 1 > /sys/kernel/debug/tracing/events/kprobes/myprobe/enable
cat /sys/kernel/debug/tracing/trace_pipe
Синтаксис %di ссылается на регистр rdi, +0(%si) дерeferencing указателя в rsi, :string интерпретирует данные как C строку. Фильтры позволяют ограничить трассировку конкретными условиями:
echo 'filename ~ "*stat"' > /sys/kernel/debug/tracing/events/kprobes/myprobe/filter
Это отфильтрует только вызовы с именами файлов, заканчивающимися на "stat". Perf-probe предоставляет более дружественный интерфейс, автоматически определяя типы переменных через debuginfo:
perf probe --add '__x64_sys_openat filename:string'
perf record -e probe:__x64_sys_openat -aR sleep 10
perf script
Флаг -R включает запись call graphs, показывая стек вызовов для каждого события. Это критично для понимания контекста, в котором функция была вызвана. Для систем с CONFIG_OPTPROBES=y, kprobes оптимизируются, заменяя breakpoint инструкции на jump инструкции, что снижает overhead с ~1000 циклов до ~50 циклов на событие.
Взаимодействие с аппаратным обеспечением и PCI устройствами
Драйверы устройств взаимодействуют с hardware через port I/O или memory-mapped I/O. Для PCI устройств kernel предоставляет высокоуровневый API через структуру pci_driver. Регистрация PCI драйвера начинается с определения таблицы поддерживаемых устройств:
static const struct pci_device_id hw_pci_tbl[] = {
{ PCI_DEVICE(0x8086, 0x100E) }, // Intel 82540EM
{ 0, }
};
MODULE_DEVICE_TABLE(pci, hw_pci_tbl);
static int hw_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
int bars, err;
void __iomem *hw_addr;
err = pci_enable_device(pdev);
if (err)
return err;
bars = pci_select_bars(pdev, IORESOURCE_MEM);
err = pci_request_regions(pdev, "hardware_driver");
if (err)
goto err_disable;
pci_set_master(pdev);
hw_addr = pci_iomap(pdev, 0, 0);
if (!hw_addr) {
err = -EIO;
goto err_release;
}
/* КРИТИЧЕСКИ ВАЖНО: сохраняем указатель для использования в remove */
pci_set_drvdata(pdev, hw_addr);
printk(KERN_INFO "Device initialized at %p\n", hw_addr);
return 0;
err_release:
pci_release_regions(pdev);
err_disable:
pci_disable_device(pdev);
return err;
}
static void hw_remove(struct pci_dev *pdev)
{
void __iomem *hw_addr = pci_get_drvdata(pdev);
if (hw_addr)
pci_iounmap(pdev, hw_addr);
pci_release_regions(pdev);
pci_disable_device(pdev);
}
static struct pci_driver hw_pci_driver = {
.name = "hardware_driver",
.id_table = hw_pci_tbl,
.probe = hw_probe,
.remove = hw_remove,
};
module_pci_driver(hw_pci_driver);
Важно: Вызов pci_set_drvdata(pdev, hw_addr) необходим для сохранения указателя на отображенную память. Без него функция hw_remove не сможет корректно освободить ресурсы, что приведет к утечке памяти или попытке освобождения некорректного адреса.
Функция pci_iomap отображает BAR (Base Address Register) устройства в виртуальное адресное пространство ядра. Чтение и запись в память устройства выполняются через ioread32/iowrite32 для 32-битных операций или readl/writel, которые гарантируют правильную упорядоченность операций на разных архитектурах.
Обработка прерываний требует регистрации interrupt handler через request_irq или devm_request_irq:
static irqreturn_t hw_interrupt(int irq, void *dev_id)
{
struct hw_device *hw = (struct hw_device *)dev_id;
u32 status = ioread32(hw->base_addr + STATUS_REG);
if (!(status & IRQ_PENDING))
return IRQ_NONE;
iowrite32(status, hw->base_addr + STATUS_REG);
wake_up_interruptible(&hw->wait_queue);
return IRQ_HANDLED;
}
err = devm_request_irq(&pdev->dev, pdev->irq, hw_interrupt,
IRQF_SHARED, "hw_driver", hw);
Префикс devm_ автоматически освобождает ресурсы при отсоединении драйвера, упрощая управление памятью и предотвращая утечки. Флаг IRQF_SHARED позволяет разделять IRQ линию с другими устройствами, что критично для legacy PCI систем с ограниченным количеством IRQ.
Integration testing и валидация модулей
Тестирование модулей ядра требует специфических подходов из-за невозможности использовать традиционные unit testing фреймворки. KUnit предоставляет in-kernel testing framework для изолированного тестирования функций. Для корректной работы тестов с функциями драйвера необходимо либо размещать тесты в том же файле, либо экспортировать тестируемые функции:
#include <kunit/test.h>
/* Для тестирования делаем функции видимыми */
int driver_init(void); /* убираем static */
void driver_exit(void); /* убираем static */
static void hw_driver_test_init(struct kunit *test)
{
int result = driver_init();
KUNIT_EXPECT_EQ(test, result, 0);
}
static void hw_driver_test_register(struct kunit *test)
{
struct hw_device *dev = kzalloc(sizeof(*dev), GFP_KERNEL);
KUNIT_ASSERT_NOT_NULL(test, dev);
int ret = hw_register_device(dev);
KUNIT_EXPECT_EQ(test, ret, 0);
hw_unregister_device(dev);
kfree(dev);
}
static struct kunit_case hw_driver_test_cases[] = {
KUNIT_CASE(hw_driver_test_init),
KUNIT_CASE(hw_driver_test_register),
{}
};
static struct kunit_suite hw_driver_test_suite = {
.name = "hw_driver",
.test_cases = hw_driver_test_cases,
};
kunit_test_suite(hw_driver_test_suite);
Альтернативный подход: Размещение тестов в том же файле с условной компиляцией:
#if IS_ENABLED(CONFIG_KUNIT)
/* тесты здесь имеют доступ к static функциям */
#endif
Компиляция с CONFIG_KUNIT=y включает фреймворк, и тесты выполняются через:
./tools/testing/kunit/kunit.py run --kunitconfig=drivers/hw_driver/
Для функционального тестирования в виртуальной среде QEMU предоставляет безопасную песочницу. Запуск kernel с параметром init=/bin/sh загружает минимальную оболочку для ручного тестирования модуля:
qemu-system-x86_64 -kernel arch/x86/boot/bzImage \
-initrd /path/to/initrd.img \
-append "console=ttyS0 init=/bin/sh" \
-nographic -m 2G
Статический анализ через sparse выявляет проблемы с указателями kernel space vs user space, lock ordering issues и другие потенциальные баги:
make C=2 M=drivers/hw_driver/
Параметр C=2 включает проверку всех файлов, включая уже скомпилированные. Lockdep обнаруживает deadlocks и нарушения порядка захвата локов во время выполнения, если ядро скомпилировано с CONFIG_PROVE_LOCKING=y. KASAN (Kernel Address Sanitizer) с CONFIG_KASAN=y детектирует use-after-free, out-of-bounds доступы и другие проблемы с памятью. Комбинация этих инструментов превращает разработку модулей из игры в угадайку в систематический процесс с контролируемыми рисками. Правильно спроектированный модуль с DKMS конфигурацией, инструментированный kprobes для отладки и покрытый тестами, становится надежным компонентом, который автоматически адаптируется к обновлениям ядра и предоставляет диагностическую информацию при возникновении проблем.