
Часто в учебной литературе по Linux приведены скучные и неинтересные примеры написания модулей ядра. Я решил исправить этот пробел и показать, что разработка небольшого модуля — это задача под силу многим, если понимаешь принципы разработки программ.
Возможно, вы помните шуточные резидентные программы для DOS, которые переворачивали изображение на экране или изменяли печатаемый на принтере или набираемый на клавиатуре текст. При всей своей бесполезности в использовании они обладают преимуществом — при их разработке лучше начинаешь понимать внутренние механизмы операционной системы.
Желание кого-то разыграть мотивирует. Многие изобретения прошлого происходили из шутки или розыгрыша. Мне кажется, что игровая форма раскрепощает , и вы вместо кортизола получаете дофамин.
Без чётко сформулированных целей и задач достичь результата сложно, так как вы не будете понимать, достигли вы цели и решили ли задачи. Поэтому сформулирую постановку задачи.
Постановка задачи
Разработать шуточный модуль ядра Linux, перехватывающий нажатия клавиш и при нажатии запятой вставляющий слово.
Поиск решения
Можно, конечно, не вдумываясь найти решение в интернете где-нибудь на форуме или попросить написать модуль ChatGPT, но я предпочитаю понимать, что делаю. Меня мучили два вопроса: как перехватить нажатия клавиш и как вставлять нажатия клавиш?
Помню, что в далёкие студенческие годы у нас были лабораторные работы по работе AT-клавиатуры. Аппаратная реализация протокола, прерывания, системные вызовы BIOS и DOS — всё перемешивалось тогда в сознании, и сдавались лабораторные работы при полном отсутствии понимания сути.
Сейчас понимание гораздо лучше, но и клавиатур побольше стало: PS/2, USB, Bluetooth. Да и операционная система Linux сильно отличается от DOS.
Интуиция говорила, что нужно что-то сделать на уровне ядра. Но если делать на уровне ядра, нужно написать какой-то модуль, потому что изменять само ядро — задача посложнее. Позже, когда я глубже разобрался с работой клавиатуры Linux, я понял, что не ошибся.
Если хотите сразу увидеть работу модуля в действии можете склонировать его себе из
моего репозитория на GitHub, собрать и загрузить.
Как реализована клавиатура в Linux
Чтобы знать, какой API ядра использовать, нужно понимать, как представлена клавиатура в ядре Linux.
При всём разнообразии современных клавиатур в зависимости от протокола подключения к компьютеру они бывают четырёх типов:
-
PS/2,
-
USB,
-
Bluetooth,
-
BLE.
Отличаются как физические интерфейсы, протоколы передачи данных, так и данные, которые передаются. Ядро Linux превращает информацию, получаемую от клавиатуры о нажатии/отпускании клавиш в события EV_KEY, описанные в файле include/uapi/linux/input-event-codes.h.
Для PS/2-клавиатур информацией, получаемой от клавиатуры, являются значения скан-кодов, для остальных клавиатур — HID-репорты, содержащие Usage ID нажатых клавиш.
Информация формируется контроллером клавиатуры (специальным чипом в клавиатуре) на основе опроса матрицы из клавиш клавиатуры.
Драйвер клавиатуры (особый модуль ядра Linux) знает, как превратить полученные данные от клавиатуры в события для подсистем Linux, таких как evdev и VT (virtual terminals).
evdev
Подразумевается, что сообщения, попадающие в evdev, должны обрабатываться в userspace. Evdev виден из userspace, как набор файлов символьных устройств в директории /dev/input. Их можно посмотреть с помощью команды.
ls -lA /dev/input/
Но обычно никто не читает из этих файлов напрямую, для этого используются библиотека libinput, превращающая поток событий из файлов /dev/input/eventX в высокоуровневый событийный API для оконных систем Wayland и X11, которые уже обрабатывают события и рассылают их по различным окнам. Работа раскладки клавиатуры обеспечивается программами в userspace.
VT
Сообщения о нажатиях клавиш, попадающие в VT, обрабатываются внутри ядра. Поддержка раскладки клавиатуры, как и используемых шрифтов, обеспечивается ядром. Userspace используется только для загрузки шрифтов и раскладок (key maps). Для конфигурирования виртуальных консолей используются команды:
-
sudo dpkg-reconfigure console-setup— настройка кодировки консоли и используемых шрифтов, -
sudo dpkg-reconfigure locales— выбор используемых локалей (локали содержат в себе и шрифты), -
sudo dpkg-reconfigure keyboard-configuration— настройка раскладок клавиатуры, -
setupcon -f— применение настроек без перезагрузки операционной системы.
Вам желательно разобраться с этими командами и настроить русскую раскладку, если хотите увидеть работу модуля при вводе запятой в русской раскладке.
Задача сводится к перехвату события нажатия клавиши в ядре нашим модулем и генерации ещё нескольких сообщений о нажатии/отпускания клавиш.
Определимся с видом модуля, как создать и загрузить внешний модуль Linux и куда поместить код по внедрению событий.
Выбор вида модуля ядра
Модули ядра Linux бывают:
-
внешние, когда модуль оформляется в отдельном от ядра дереве исходного кода, а полученный после сборки файл с расширением
.koможно загружать при помощи командыinsmodи выгружать при помощи —rmmod, -
внутренние — модуль оформляется в дереве исходного кода Linux, после сборки и установки ядра находится в директории
/lib/modules/$(uname -r)/kernel, -
встроенные — модуль оформляется в дереве исходного кода Linux, но бинарный код модуля находится внутри файла
/boot/vmlinuz-$(uname -r).
Сборка ядра Linux на своём рабочем компьютере — задача, я бы сказал, не очень трудная, но рискованная. Внешний модуль лучше всего подходит под такую задачу — так как вообще не нужно перекомпилировать ядро. Риски есть, но их меньше. Главное — понимать, что делаешь и не забывать выгружать модуль.
Как внешний модуль ядра взаимодействует с ядром
Внутренний модуль ядра должен содержать как минимум две функции: одну из них операционная система вызывает при загрузке модуля, вторую — при выгрузке.
После загрузки модуль получает доступ к внутренним API ядра Linux, которые в соответствии с дизайном имеют изменчивую природу, а документация этого API недостаточно хорошая. API неизменный в пределах стабильной ветки. Мой дистрибутив Debian 13 был построен на 6.12.y для неё я и писал модуль.
Особенности модулей Linux, что при изменениях в ядре их нужно пересобирать. Собранный для одного ядра модуль вы не сможете запустить на другом ядре, необходимо его будет пересобрать.
Создание и запуск внешнего модуля ядра Linux
Итак, сначала нам нужно создать и запустить внешний модуль ядра. Информации о том, как создать такой модуль — море в интернете, я приведу информацию в виде тезисов.
-
Для сборки модуля необходима настройка окружения, состоящего из различных утилит и заголовочных файлов ядра.
-
Исходный код внутреннего модуля ядра — это один или более файлов на языке С, заголовочных файлов и Makefile.
-
В исходном коде должны быть определены две функции: одна которая вызывается при загрузке модуля ядром, другая при выгрузке. Возможности метапрограммирования в С слабо развиты, поэтому для обозначения этих функций используются макросы
module_initиmodule_exit. -
При написании модуля важно понимать, в каком контексте будет выполняться написанный код, иначе можно намертво повесить операционную систему.
-
Нужно уделять внимание выделяемым и освобождаемым ресурсам.
-
Использование непроинициализированного указателя или выход за пределы массивов также может нарушить работу операционной системы.
-
Код модуля не использует привычные функции стандартной библиотеки Си, так как стандартная библиотека предназначена для использования кодом, который запускается в userspace.
-
Для загрузки и выгрузки модуля используются функции
insmodиrmmod. Обе требуют привилегий суперпользователя. -
Вы имеете больший контроль над железом компьютера, но и цена ошибки в модуле дороже, чем обычном приложении.
-
Вероятность того, что вы нарушите работу ядра велика, при определённых обстоятельствах это может привести и к аппаратной поломке, поэтому я рекомендую запускать и отлаживать код или в виртуальной машине, или на компьютере, который вам не жалко использовать для экспериментов.
Исходный код минимально возможного внешнего модуля:
// SPDX-License-Identifier: GPL-2.0
#include <linux/module.h>
MODULE_LICENSE("GPL");
static int __init funny_kbd_init(void) {
pr_info("funny-kbd: Module loadedn");
return 0;
}
static void __exit funny_kbd_exit(void) {
pr_info("funny-kbd: Module unloadedn");
return;
}
module_init(funny_kbd_init);
module_exit(funny_kbd_exit);
И Makefile для его сборки:
obj-m += funny-kbd.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
Устанавливаем необходимые пакеты:
sudo apt install linux-headers-$(uname -r) make build-essential
Собираем модуль:
make
Загружаем модуль:
sudo insmod funny-kbd.ko
Выгружаем модуль:
sudo rmmod funny-kbd.ko
Смотрим, что модуль вывел в кольцевой буфер сообщений ядра:
sudo dmesg
Не забывайте выгружать модуль после изменений в его исходном коде и после экспериментов с модулем. Лучше все действия выполнять в виртуальной машине с установленным на ней дистрибутиве Linux.
После запуска и проверки работы модуля, можно приступать к реализации функционала модуля.
Разработка функционала модуля ядра Linux
Наш модуль является и простым, учебным и шуточным. Но и для него нужно разрабатывать архитектуру. Может, где-то в заумных книжках и рассказывается, как сразу разработать «правильную» архитектуру, но в реальности построение архитектуры — итеративный процесс. У меня архитектура сформировалась после решения задач на каждой итерации. У меня получилось 4 итерации. Некоторые задачи я не стал решать, чтобы не раздувать программу и статью.
Итерация 1: Перехват события клавиатуры через keyboard_notifier callback.
Модуль подписывается на keyboard_notifier, который является частью notifier-цепочки клавиатуры ядра Linux. Это позволяет получать события, содержащие keycode ещё до передачи их в evdev или VТ.
Определение callback-функции, вызываемой при нажатии клавиш на клавиатуре:
static int keyboard_notifier_cb(struct notifier_block *nblock,
unsigned long action, void *param)
{
struct keyboard_notifier_param *p = param;
unsigned int keycode = p->value;
int down = p->down;
pr_info("funny-kbd: action = %lu keycode = %u down = %dn", action, keycode, down);
return NOTIFY_OK;
}
Определение переменной kbd_nb. Её адрес нужно передавать в функции register_keyboard_notifier unregister_keyboard_notifier:
static struct notifier_block kbd_nb = {
.notifier_call = keyboard_notifier_cb};
static int __init funny_kbd_init(void)
{
pr_info("funny-kbd: Module loadedn");
return register_keyboard_notifier(&kbd_nb);
}
static void __exit funny_kbd_exit(void)
{
unregister_keyboard_notifier(&kbd_nb);
pr_info("funny-kbd: Module unloadedn");
return;
}
Такой подход даёт следующие преимущества:
-
отсутствие модификаций драйверов,
-
минимальное вмешательство в работу ядра,
-
мы получаем доступ к keycode и состоянию модификаторов.
Не забывайте освобождать ресурсы, выделенные при загрузке модуля, при его выгрузке.
Итерация 2: Асинхронная обработка события в workqueue
Callback-функция keyboard_notifier_cb выполняется в контексте, где запрещены блокирующие операции. То есть если функция будет выполняться долго или использоваться функции, блокирующие выполнение до своего возврата, нарушится работа ядра операционной системы.
Поэтому вся логика обработки была вынесена workqueue. Чтобы упростить освобождение ресурсов, использовалась локальная очередь.
Объявляем указатель на локальную очередь:
static struct workqueue_struct *kb_wq;
Определяем структуру kb_work, которую будем использовать для передачи параметров задаче:
struct kb_work
{
struct work_struct work;
unsigned long action;
unsigned int keycode;
int shift;
};
Определяем функцию, которая будет выполняться при запуске задачи. Похожий код можно найти во многих модулях Linux:
static void kb_work_fn(struct work_struct *work)
{
struct kb_work *kw =
container_of(work, struct kb_work, work);
pr_info("funny-kbd: action = %lu keycode = %u shift = %dn", kw->action, kw->keycode, kw->shift);
kfree(kw);
}
Переписываем логику работы callback-функции с прошлой итерации. Обратите внимание на использование kmalloc, INIT_WORK, queue_work:
static int keyboard_notifier_cb(struct notifier_block *nblock,
unsigned long action, void *param)
{
struct kb_work *kw;
struct keyboard_notifier_param *p = param;
if (action != KBD_KEYCODE || !p->down)
return NOTIFY_OK;
kw = kmalloc(sizeof(*kw), GFP_ATOMIC);
if (!kw)
return NOTIFY_OK;
INIT_WORK(&kw->work, kb_work_fn);
kw->action = action;
kw->keycode = p->value;
kw->shift = p->shift;
queue_work(kb_wq, &kw->work);
return NOTIFY_OK;
}
Создаём локальную очередь при загрузке модуля:
kb_wq = alloc_workqueue("kb_wq", WQ_UNBOUND, 1);
if (!kb_wq)
return -ENOMEM;
Не забываем освободить ресурсы, выделенные для локальной очереди при выгрузке модуля:
flush_workqueue(kb_wq);
destroy_workqueue(kb_wq);
Использование очереди:
-
отделяет перехват от обработки,
-
повышает устойчивость к ошибкам.
Итерация 3: Генерация последовательности клавиш через виртуальную клавиатуру
Проще всего и безопаснее генерировать нажатия клавиш при помощи виртуальной клавиатуры. Её можно создать в модуле, и она будет восприниматься ядром как обычная клавиатура.
Объявляем указатель на виртуальную клавиатуру:
static struct input_dev *virt_kbd;
Определяем функцию для создания виртуальной клавиатуры. Обратите внимание на использование функций input_allocate_device,input_register_device,input_free_device и заполнение полей переменной virt_kbd:
static int init_virt_kbd(void)
{
int ret;
virt_kbd = input_allocate_device();
if (!virt_kbd)
{
pr_err("input_allocate_device failedn");
return -ENOMEM;
}
virt_kbd->name = "Funny Keyboard";
virt_kbd->phys = "virtual/filtered/kbd";
virt_kbd->id.bustype = BUS_VIRTUAL;
virt_kbd->id.vendor = 0xDEAD;
virt_kbd->id.product = 0xBEEF;
set_bit(EV_KEY, virt_kbd->evbit);
set_bit(EV_REP, virt_kbd->evbit);
for (int i = 0; i < KEY_CNT; i++)
{
set_bit(i, virt_kbd->keybit);
}
ret = input_register_device(virt_kbd);
if (ret)
{
pr_err(KERN_ERR "input_register_device failed: %dn", ret);
input_free_device(virt_kbd);
return ret;
}
return 0;
}
Не забываем освободить ресурсы при выгрузке модуля:
input_unregister_device(virt_kbd);
input_free_device(virt_kbd);
Определяем функцию для отсылки событий нажатия на созданную виртуальную клавиатуру:
static void send_keys(struct input_dev *kbd, unsigned int *keys, size_t num)
{
for (int i = 0; i < num; i++)
{
input_report_key(kbd, keys[i], 1);
input_report_key(kbd, keys[i], 0);
input_sync(kbd);
}
}
Вызываем её в функции kb_work_fn:
if (kw->keycode == KEY_COMMA)
{
send_keys(virt_kbd, eng_word, sizeof(eng_word) / sizeof(unsigned int));
}
Обратите внимание, в массиве
eng_wordне должно быть ',', иначе вы повесите ядро.
Подход с использованием виртуальной клавиатуры:
-
совместим с X11 и Wayland,
-
не требует изменений в userspace,
-
не ломает существующую обработку ввода,
-
позволяет легко отключить модуль без последствий.
Итерация 4: Анализ текущей раскладки через внутренние структуры VT
Ну и наконец, вишенка на торте. Я потратил больше всего времени на неё. Как определить, выводить русское слово при нажатии на «,» при активной русской раскладке и английское при английской раскладке?
Задача интересна тем, что мы работаем в ядре, и ядро не знает, в какой именно раскладке оно работает. Но это можно решить, правда, с хаками.
Для определения активной раскладки используется доступ к key_maps — внутренней структуре VT, сопоставляющей keycode и символы, которые будут отображаться на экране.
Так как key_maps не экспортируется, модуль динамически получает адрес kallsyms_lookup_name через kprobe. Это позволяет исследовать текущее отображение клавиш без участия userspace.
Определяем свою реализацию функции kallsyms_lookup_name c применением возможности kprobe:
static unsigned long kallsyms_lookup_name_hack(const char *name)
{
struct kprobe kp = {
.symbol_name = "kallsyms_lookup_name"
};
unsigned long addr = 0;
if (register_kprobe(&kp) < 0)
{
pr_err("Failed to register kprobe on kallsyms_lookup_namen");
return 0;
}
addr = (unsigned long)kp.addr;
unregister_kprobe(&kp);
return addr;
}
Определяем функцию для доступа к key_maps:
static unsigned short **get_key_maps(void)
{
typedef unsigned long (*kallsyms_fn)(const char *);
kallsyms_fn lookup = (kallsyms_fn)kallsyms_lookup_name_hack("kallsyms_lookup_name");
if (!lookup)
return NULL;
unsigned long addr = lookup("key_maps");
if (!addr)
return NULL;
return (unsigned short **)addr;
}
Получаем key_maps при загрузке модуля:
maps = get_key_maps();
Для определения раскладки используем информацию о том, какой символ генерирует клавиша с твёрдым знаком:
static void kb_work_fn(struct work_struct *work)
{
bool place_russian = false;
struct kb_work *kw =
container_of(work, struct kb_work, work);
unsigned short symbol = maps[kw->shift][kw->keycode];
if (maps[kw->shift][27] == 0x42a || maps[kw->shift][27] == 0x44a)
{
place_russian = true;
}
else
{
place_russian = false;
}
pr_info("funny-kbd: keycode=%u action=%lu shift=%d symbol=%hx place_russian=%dn",
kw->keycode, kw->action, kw->shift, symbol, place_russian);
if (kw->keycode == KEY_COMMA && !place_russian && symbol == 0xf02c)
{
send_keys(virt_kbd, english_word, sizeof(english_word) / sizeof(unsigned int));
}
else if (kw->keycode == KEY_SLASH && place_russian && symbol == 0xf02c)
{
send_keys(virt_kbd, russian_word, sizeof(russian_word) / sizeof(unsigned int));
}
kfree(kw);
}
Ну вот и всё, можете играться с модулем. Также он доступен по ссылке.
Выводы
Написав простой модуль, мы затронули несколько тем из ядра Linux, изучение которых можно углубить. Но у вас должно сформироваться понимание, как можно применить знания на практике.
Учебный модуль можно расширить, например, добавить возможность изменения вставляемого слова или вероятности, когда будет происходить вставка символа. Также можно было создать unit в systemd, который загрузил бы модуль при старте Linux. Я специально этого не делал, чтобы модуль был проще.
В настоящее время замечаешь, как уходят времена хакерства, когда использование небольших низкоуровневых программ давало значительный эффект. Сейчас из-за политик безопасности всё становится громоздким и сложным в освоении, увеличивая порог вхождения. Например, если у вас настроена безопасная загрузка Linux, так просто уже модуль не напишешь, а в старых ядрах Linux не нужно было придумывать хак к вызову kallsyms_lookup_name.
© 2026 ООО «МТ ФИНАНС»
Автор: artyomsoft
