Вероятно, вы знаете, что если запустить ядро Linux без корневой файловой системы или файла initramfs, то оно упадет с сообщением о панике ядра.

Но возможна ли работа ядра Linux без этих, вроде бы обязательных компонентов? Ответ на вопрос — да, возможна, но использовать такие возможности в конечном продукте не стоит.

При запуске ядра ему могут передаваться параметры через командную строку. Одним из параметров является rootwait, указывающий ядру на то, что нужно подождать появление корневой файловой системы. В этом случае ядро ожидает появление корневой системы, а не завершается с ошибкой.

Формально ничто не мешает написать модуль ядра, который взаимодействует с клавиатурой и дисплеем и временно выполняет функции пользовательского приложения, пока ядро ожидает корневую файловую систему.

Пользовательским приложением может быть, например, игра Тетрис. Она из-за своей простоты в реализации и зрелищности добавляет наглядности в изучении темы и дает чувство завершенности. А мысль о том, что тетрис, работает в ядре, усиливает эффект.

❯ Что содержит в себе статья

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

Тетрис и инструкция по сборке и запуску ядра с ним находится в репозитории на GitHub, а в статье основное внимание уделяется внутренним компонентам ядра и внутренним интерфейсам, которыми необходимо уметь пользоваться для написания игры, работающей в контексте ядра.

В статье не рассматриваются или рассматриваются только в контексте разработки модуля ядра такие аспекты игры Тетрис как:

  • работа с двумерными массивами;

  • обработка событий и игровой цикл;

  • коллизии и валидация движений;

  • повороты матриц;

  • отрисовка игрового процесса;

  • логика удаления линий и подсчёт очков;

  • генерация случайных фигур.

Статья должна быть интересной и полезной для вас, если вы хотите узнать как:

  • получить исходный код ядра;

  • написать программный код, который будет выполняться в ядре Linux;

  • отобразить игровой процесс на экране;

  • обработать нажимаемые клавиши на клавиатуре;

  • организовать игровой цикл в модуле ядра Linux;

  • написать логику игры на языке C, которая не зависит от стандартной библиотеки;

  • определиться, с какими параметрами собирать ядро;

  • собрать ядро.

❯ Написание модуля ядра

Написание модуля ядра не такая сложная задача, как кажется на первый взгляд. Это не значит, что нет сложностей в разработке архитектуры, выявлении ошибок и поиске нужных компонентов ядра. Я имею ввиду, что минимальный набор знаний, позволяющий начать писать модули ядра Linux, — небольшой.

Если работаете в каком-то дистрибутиве Linux, создаете директорию, например, ~/sandbox/sample_module в нее помещаете всего два файла:

  • файл simple_module.c

    #include <linux/module.h>
    #include <linux/printk.h> 
    
    static int __init simple_module_init(void)
    {
        pr_info("simple_module: Hello, World!\n");
        return 0;
    }
    
    static void __exit simple_module_exit(void)
    {
        pr_info("simple_moudule: Bye!\n");
    }
    
    module_init(simple_module_init);
    module_exit(simple_module_exit);
    
    MODULE_LICENSE("GPL");
    MODULE_AUTHOR("whoever");
    MODULE_DESCRIPTION("simple module");
    
  • файлMakefile:

    obj-m += simple_module.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

Помните, что в Makefile-файлах для отступов используются табуляции.

В директории должны появиться следующие файлы:

.
├── Makefile
├── modules.order
├── Module.symvers
├── README.md
├── simple_module.c
├── simple_module.ko
├── simple_module.mod
├── simple_module.mod.c
├── simple_module.mod.o
└── simple_module.o

Если ничего не получилось, а особенно если вы не увидели среди файлов simple_module.ko и получили ошибку типа:

user@debian:~/sandbox/simple_module$ make
make -C /lib/modules/6.12.63+deb13-amd64/build M=/home/user/sandbox/simple_module modules
make[1]: Entering directory '/home/user/sandbox/simple_module'
make[1]: *** /lib/modules/6.12.63+deb13-amd64/build: No such file or directory.  Stop.
make[1]: Leaving directory '/home/user/simple_module'
make: *** [Makefile:4: all] Error 2

то, скорее всего, вы не установили необходимые пакеты необходимые для сборки ядра Linux. Решается это следующей командой:

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

Теперь можно загрузить модуль в ядро и увидеть его работу.

sudo insmod simple_module.ko

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

lsmod | grep simple_module

Смотрим, что вывел модуль в буфер сообщений ядра:

sudo dmesg 

[115232.697183] simple_module: loading out-of-tree module taints kernel.
[115232.697187] simple_module: module verification failed: signature and/or required key missing - tainting kernel
[115232.702298] simple_module: Hello, World!

На предупреждение об ошибке верификации не обращайте внимание. Для простого учебного примера подписывание модулей не нужно.

Модуль вывел сообщение и больше ничего не делает, поэтому выгружаем модуль из ядра:

sudo rmmod simple_module

и смотрим, что он нам сообщил:

sudo dmesg
[116334.688925] simple_module: Bye!

Написанный модуль будет работать только для версии ядра, для которой его собирали. Иными словами, на Linux с отличающейся версией ядра модуль не загрузится, его нужно пересобирать. Вообще, внутренние интерфейсы ядра изменчивые, и часто исходный код модуля нужно тоже изменять, если меняется версия ядра. Разбираемый в статье пример для версий ядер 6.12.y.

Если у вас не упал Linux и все работает, страх перед написанием модуля ядра должен исчезнуть, но предупреждаю, лучше дальнейшие шаги делать не на основном компьютере.

Модуль работает, но ничего полезного не делает, а нам для игры нужно реализовать обработку клавиш и вывод символов на экран. Разберемся для начала с обработкой клавиш.

❯ Обработка нажатий клавиш

Задумывались ли когда-нибудь, что происходит от момента нажатия клавиши на клавиатуре, до момента, когда вы видите реакцию компьютера на нажатие? Постараюсь объяснить.

Электрический сигнал, который получается от замыкания контактов на клавиатуре, попадает в контроллер клавиатуры — микросхему, знающую как превратить сигнал от нажатия или отпускания определенной клавиши в последовательность байт. Эта последовательность байт называется сканкодом. Она так называется, потому что контроллер сканирует текущее состояние клавиш клавиатуры.

Возникает вопрос, а что дальше происходит со сканкодом? Сканкод нужно передать дальше компьютеру, чтобы он мог его как-то обработать. Т.е. сканкод нужно передать при помощи какого-то интерфейса. Это может быть PS/2, USB, Bluetooth, BLE. Соответственно и компьютер, и клавиатура должны иметь одинаковые интерфейсы.

Контроллер и электронные схемы клавиатуры кодируют сканкод в последовательность электрических или электромагнитных сигналов, а материнская плата компьютера эти сигналы превращает обратно в сканкод, который обрабатывается программой, выполняемой процессором (в нашем случае это ядро операционной системы Linux).

Так как сканкоды могут поступать по различным интерфейсам, в ядре Linux для каждого из интерфейсов существует драйвер, умеющий превращать сканкод в keycode — унифицированный код для клавиш на различных устройствах. Эти коды можно посмотреть в файле include/uapi/linux/input-event-codes.h.

В принципе нам повезло, и разбираться во всех подробностях, что происходит дальше — не нужно, как и разбираться во всем разнообразии драйверов для клавиатур. Вот где настоящая сила абстракции ядра Linux!

Нужно просто найти способ, как получить и обработать этот keycode в модуле ядра Linux. Оказалось, это несложно. В файле include/linux/keyboard.h определена следующая структура:

struct keyboard_notifier_param {
	struct vc_data *vc;	/* VC on which the keyboard press was done */
	int down;		/* Pressure of the key? */
	int shift;		/* Current shift mask */
	int ledstate;		/* Current led state */
	unsigned int value;	/* keycode, unicode value or keysym */
};

А так же объявлены две функции для подписывания и отписывания от получения нотификаций от ядра с кодами нажатых клавиш.

extern int register_keyboard_notifier(struct notifier_block *nb);
extern int unregister_keyboard_notifier(struct notifier_block *nb);

В поле value содержит значение keycode, а поле down — информацию о том, была нажата или отжата клавиша. Интересно поле vc (виртуальная консоль) — но об этом позже.

В исходном коде модуля нужно определить функцию обработчика нотификаций от ядра с сигнатурой:

static int kbd_notifier_callback (struct notifier_block *nb,
                                 unsigned long action, void *data)

Имя функции можно выбирать любое доступное, так как мы все-равно должны проинициализировать поле notifier_call структуры notifier_block именем нашего обработчика:

static struct notifier_block kbd_nb = {.notifier_call = kbd_notifier_callback};

Теперь нужно не забыть добавить регистрацию обработчика в функции simple_module_init и разрегистрацию в simple_module_exit:

static int __init simple_module_init(void)
{
    pr_info("simple_module: Hello, World!\n");
    return register_keyboard_notifier(&kbd_nb);
}

static void __exit simple_module_exit(void)
{
    pr_info("simple_module: Bye!\n");
    unregister_keyboard_notifier(&kbd_nb);
}

Если хотите острых ощущений и на личном опыте ощутить, что такое не проинициализированный указатель — не помещайте unregister_keyboard_notifier в simple_module_exit.

Все, теперь можно собрать модуль и загрузить его в ядро.


sudo apt install linux-headers-$(uname -r)

make

sudo insmod simple_module

sudo dmesg

Вы увидите в логах, какие клавиши нажимали после загрузки модуля:

[36950.903738] simple_module: KEYCODE = 67 down = down
[36951.022016] simple_module: KEYCODE = 67 down = up
[36951.266171] simple_module: KEYCODE = 67 down = down
[36951.376498] simple_module: KEYCODE = 67 down = up
[36952.194590] simple_module: KEYCODE = 6c down = down
[36952.296999] simple_module: KEYCODE = 6c down = up
Черт побери
Черт побери

А теперь про острые ощущения, упомянутые ранее. Если вы забыли добавить unregister_keyboard_notifier в simple_module_exit, то при выгрузке модуля командой rmmod:

sudo rmmod simple_module

вы полностью повесите операционную систему, и вам придётся выполнить аварийную перезагрузку.

Наш модуль может только обработать и вывести, какая клавиша была нажата или отжата, но мы приблизились к созданию Тетриса в ядре. Нужно разобраться, как вывести что-нибудь на экран монитора.

❯ Вывод в Linux

Наверное, с первого раза понять, как работает вывод на экран в Linux очень сложно. Возможно, после прочтения статьи вы приблизитесь к пониманию этой сложной темы.

Ядро Linux выводит информацию в консоль. В большинстве случаев в роли консоли выступает или последовательный порт (ttyS0...ttySn) или экран монитора (tty1..ttyN).

❯ Последовательный порт

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

Плата роутера с подключенным к ней USB-UART адаптером
Плата роутера с подключенным к ней USB-UART адаптером

Чтобы подключиться к такому порту, вам нужно будет разобрать роутер, найти отверстия на плате на которые выведен последовательный порт, припаять гребенку и подключить к компьютеру через USB-UART преобразователь.

А еще вам придется менять код в ядре, для того, чтобы туда поместить Тетрис. Работа с ядрами для встраиваемых устройств тема интересная, но тогда статья будет занимать узкую нишу.

Последовательный порт практически отсутствует на современных ноутбуках и довольно редко встречается на стационарных компьютерах, а там где есть, используются другие логические уровни. Одним словом — морока.

Поэтому я отбросил идею использования последовательного порта.

❯ Виртуальная консоль

Экран монитора представлен в Linux виртуальными консолями, или виртуальными терминалами. Вы можете переключаться между виртуальными консолями используя сочетания клавиш Ctrl + Alt + F1 .. F7. На моем Debian 13 Ctrl + Alt + F1 и Ctrl + Alt + F2 зарезервированы для графических сессий, а при нажатии Ctrl + Alt + F3 .. F6 я могу переключаться между текстовыми виртуальными консолями.

Нужно разобраться, как эти виртуальные консоли представлены в ядре, и как с ними можно работать из модуля. После небольшого поиска информации узнаем, что обратиться ко всем виртуальным консолям можно через глобальную! переменную vc_cons, определенную в файле drivers/tty/vt/vt.c (когда-то давно в институте слышал фразу «глобальные переменные — это плохо»).

struct vc vc_cons [MAX_NR_CONSOLES];

Структура vc, определенная в файле include/linux/console_struct.h

struct vc {
	struct vc_data *d;
	struct work_struct SAK_work;

	/* might add  scrmem, kbd  at some time,
	   to have everything in one place */
};

нам интереса не представляет, разве что ее поле d. Кстати, комментарий, который видим в коде, как бы намекает, мы лезем туда, где кто-то что-то может поменять в будущем. Но для нашего учебного примера это не страшно.

Смотрим определение структуры vc_data в файле include/linux/console_struct.h

struct vc_data {
	struct tty_port port;			/* Upper level data */

	struct vc_state state, saved_state;

	unsigned short	vc_num;			/* Console number */
	unsigned int	vc_cols;		/* [#] Console size */
	unsigned int	vc_rows;
	unsigned int	vc_size_row;		/* Bytes per row */
	unsigned int	vc_scan_lines;		/* # of scan lines */
	unsigned int	vc_cell_height;		/* CRTC character cell height */
	unsigned long	vc_origin;		/* [!] Start of real screen */
	unsigned long	vc_scr_end;		/* [!] End of real screen */
	unsigned long	vc_visible_origin;	/* [!] Top of visible window */
	unsigned int	vc_top, vc_bottom;	/* Scrolling region */
	const struct consw *vc_sw;
	unsigned short	*vc_screenbuf;		/* In-memory character/attribute buffer */
	unsigned int	vc_screenbuf_size;
	unsigned char	vc_mode;		/* KD_TEXT, ... */
	/* attributes for all characters on screen */
	unsigned char	vc_attr;		/* Current attributes */
	unsigned char	vc_def_color;		/* Default colors */
	unsigned char	vc_ulcolor;		/* Color for underline mode */
	unsigned char   vc_itcolor;
	unsigned char	vc_halfcolor;		/* Color for half intensity mode */
	/* cursor */
	unsigned int	vc_cursor_type;
	unsigned short	vc_complement_mask;	/* [#] Xor mask for mouse pointer */
	unsigned short	vc_s_complement_mask;	/* Saved mouse pointer mask */
	unsigned long	vc_pos;			/* Cursor address */
	...
}

Все определение не привожу, так как слишком длинная простыня получается. Но эта структура — то, что нам нужно. Важны поля:

  • vc_num — номер виртуальной консоли;

  • vc_col — количество столбцов;

  • vc_rows — количество строк;

  • vc_attr — текущие аттрибуты цвета и фона используемые при выводе символов на экран;

  • vc_screenbuf— массив содержащий коды символов с атрибутами, которые отображаются на экране.

Номер текущей виртуальной консоли хранится в глобальной переменной fg_console, определенной в файле drivers/tty/vt/vt.c.

Таким образом, программный код для получения структуры vc_data для текущей консоли будет выглядеть:

struct vc_data *vc;
vc = vc_cons[fg_console].d;

Вывод в текущую виртуальную консоль символа будет выглядеть:

static void console_putchar(char c, int col, int row)
{
    vc->vc_screenbuf[col + row * vc->vc_cols] = c | (vc->vc_attr << 8);
} 

Очистка консоли:

static void console_clear(void)
{
    console_lock();
    for (int i = 0; i < vc->vc_cols * vc->vc_rows; i++)
    {
        vc->vc_screenbuf[i] = ' ' | (vc->vc_attr << 8);
    }
    console_unlock();
}

Функции console_lock и console_unlock вызвал для надежности, чтобы изменения во фреймбуфере были атомарными.

Изменение значений в vc_screen_buf не означает, что вы увидите изменения на экране, так как необходимо еще вызвать функцию redraw_screen.

static void console_update(void)
{
    if (vc)
    {
        redraw_screen(vc, 0);
    }
}

Код, хоть и простой, но перестает быть уровня «Hello World», а весь листинг файла исходного кода модуля неудобно приводить в статье, поэтому я подготовил репозиторий с исходным кодом модуля, и для каждого этапа создал тег. Вам достаточно клонировать один раз репозиторий, и потом делать checkout нужного этапа, чтобы увидеть как выглядит весь исходный код.

git clone 

git checkout output

make

Переходим в текстовую виртуальную консоль, например Ctrl + Alt + F3, заходим под своим пользователем и загружаем из нее модуль

sudo insmod simple_module.ko

Наблюдаем очистку консоли и символ посередине консоли.

Вывод модуля на виртуальную консоль
Вывод модуля на виртуальную консоль

На скриншоте виден промпт командной строки, но это из-за того, что наш драйвер и терминал совместно используют виртуальную консоль. Я намеренно оставил терминал за виртуальной консолью, чтобы не повесить операционную систему.

Выгружаем модуль на всякий случай.

sudo rmmod simple_module

Уже можно получать коды нажатий клавиш и отображать информацию в виртуальной консоли. Сейчас предстоит закодировать управление перемещением символом при помощи клавиш клавиатуры.

❯ Добавляем интерактивность

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

Чтобы символ превратить в перо, которым мы будем рисовать в текстовой виртуальной консоли, нужно при получении нотификаций о нажатых клавишах обновлять переменную, содержащую keycode нажатой клавиши, с определенной периодичностью читать эту переменную и обновлять положение символа в vc_screenbuf. Чем меньше интервал между опросами переменной, тем быстрее будет рисовать «перо».

И тут мы приходим, к тому, что уже получается многопоточное приложение. Пишем мы в одном потоке выполнения, а читаем в другом. Переменная key_state является атомарной (не может быть случая, когда она обновится по частям), но мы в одном потоке пишем, а в другом читаем и пишем, что подразумевает использование примитивов синхронизации, сделаем из key state атомарную переменную.

static atomic_t key_state  = ATOMIC_INIT(KEY_RESERVED);

...

if (action == KBD_KEYCODE && down)
{
    atomic_set(&key_state, param->value);
}

...

unsigned int k = atomic_xchg(&key_state, KEY_RESERVED);

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

   git checkout interactive
Вывод результата перемещения пера по экрану
Вывод результата перемещения пера по экрану

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

Теперь мы можем рисовать в виртуальной консоли. Но каждый запуск выполняется с опаской, потому что ошибка в исходном коде модуле может полностью повесить Linux, на котором и работаем. Чем-то напоминает операцию на собственном головном мозге, поэтому сделаем из модуля встроенный и будем запускать ядро в эмуляторе QEMU.

❯ Превращаем внешний модуль во внутренний

Начать разработку внутреннего модуля немного сложнее, так как одних заголовочных файлов ядра вам не хватит, необходим весь исходный код ядра.

Вопрос откуда взять исходный код? Есть upstream-репозиторий, который поддерживает Линус Торвальдс, есть репозиторий со стабильными ветками ядра: [https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git]. Все это ванильные ядра.
Исходный код ванильных ядра кроме git-репозиториев доступны и в [архивах: https://cdn.kernel.org/pub/linux/kernel].

Многие разработчики дистрибутивов и встраиваемых систем поддерживают свои версии ядер, являющиеся форками от ванильных ядер.

Можно использовать исходный код, распространяемый вместе с вашим дистрибутивом, но интереснее взять код upstream.

Размер всего Git репозитория ядра Linux со стабильными ветками уже больше 6 ГБ. Репозиторий Линуса Торвальдса — 3,5 ГБ. Распакованный архив с исходным кодом ядра стабиль��ой ветки 6.12 — около 1,6 ГБ.

Можно использовать архив с исходниками, но без Git неудобно вносить правки. Поэтому лучше клонировать весь репозиторий или выполнить поверхностное клонирование репозитория.

Думаю, лучше всего директорию с Linux source tree разместить на том же уровне, где и проект simple_module.

Выполняем поверхностное клонирование репозитория, указав конкретный тег версии ядра v6.12.67:

git clone --branch v6.12.67 --depth 1 https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git ../linux

cd ../linux

git checkout -b simple_module-v6.12.67

Помещаем исходный код модуля в Linux source tree.

mkdir -p drivers/misc/simple_module

cp ../simple_module/simple_module.c drivers/misc/simple_module/

Изменяем и добавляем файлы Кconfig и Makefile.

Чтобы внутренний (in-tree) модуль собирался системой сборки ядра Linux, нужно в директории с модулем создать два файла Makefile и Kconfig.

Создаем файл drivers/misc/simple_module/Kconfig:

cat << EOF > drivers/misc/simple_module/Kconfig
config SIMPLE_MODULE
    tristate "Simple in-tree module example"
    default y
    help
      Sample of the user application in Linux kernel without userspace.
EOF

Создаем файл drivers/misc/simple_module/Makefile:

echo 'obj-$(CONFIG_SIMPLE_MODULE) += simple_module.o' > drivers/misc/simple_module/Makefile

Для интеграции встроенного модуля в дерево исходного кода ядра Linux тоже используются Makefile и Kconfig, но они располагаются на уровень выше, и их нужно изменять, а не добавлять.

В файл drivers/misc/Kconfig нужно добавить строку перед endmenu:

sed -i '/^endmenu\>/i \
source "drivers/misc/simple_module/Kconfig"' \
    drivers/misc/Kconfig

А в конец файл drivers/misc/Makefile:

echo 'obj-$(CONFIG_SIMPLE_MODULE)       += simple_module/' >> drivers/misc/Makefile 

Добавляем в файлы в git:

git add .

Смотрим изменения, которые будут вноситься:

git diff --cached

Делаем коммит, чтобы не пропал наш труд:

git commit -m "My first in-tree module"

Теперь модуль у нас встроенный в ядро. Нужно сконфигурировать и собрать все ядро.

❯ Собираем и запускаем ядро

Проще всего взять минимальную конфигурацию ядра и добавить нужные опции.

make tinyconfig
./scripts/config -e 64BIT -e PCI -e PCI_HOST_GENERIC -e TTY -e BINFMT_ELF -e DEVTMPFS -e DEVTMPFS_MOUNT -e PROC_FS -e SYSFS -e TMPFS -e PRINTK -e EARLY_PRINTK -e PRINTK_TIME -e ACPI -e EFI -e EFI_STUB -e FB -e FB_EFI -e FRAMEBUFFER_CONSOLE -e CMDLINE_BOOL --set-str CMDLINE "rootwait" -e SIMPLE_MODULE -e USB -e USB_SUPPORT -e USB_XHCI_HCD -e USB_PCI -e USB_XHCI_PCI
make olddefconfig

Собираем ядро:

make -j$(nproc)

Собранное ядро находится по пути arch/x86/boot/bzImage.

Устанавливаем QEMU, если еще он не установлен:

sudo apt install qemu-system-x86 qemu-utils

И наконец, мы добрались до заветной строки, где ядро запускается как программа, внутри которого наше приложение:

qemu-system-x86_64 -kernel arch/x86/boot/bzImage 
Работа встроенного in-tree module
Работа встроенного in-tree module

Можно себя поздравить и выпить чашечку кофе.

Фейерверк
Фейерверк

Но все-таки есть часть недостатков, которые хочется исправить:

  • ядро продолжает логировать сообщения в консоль после запуска нашего модуля, а хочется чистый экран;

  • системная к��нсоль Linux тоже обрабатывает нажатия клавиш, и мы видим это внизу экрана;

  • раздражает мерцающий курсор, его тоже желательно убрать;

  • при запуске с UEFI-загрузкой — ядро некорректно работает.

Для того, чтобы ядро прекращало писать логи в консоль нужно в console_loglevel записать 0.

Дальнейшую обработку клавиш я намеренно не отключал во внешнем модуле, так вероятность того, что вы бы повесили ядро, в котором работали была бы выше. Теперь достаточно заменить NOTIFY_OK на NOTIFY_STOP в нашем kbd_notifier_callback

Отображение курсора отключается модифицированием структуры vc_data.

static void console_hide_cursor(void) {
    console_lock();
	vc = vc_cons[fg_console].d;
	if (vc) {
		vc->vc_deccm = 0;
		vc->vc_cursor_type = 0;
	}
	console_unlock();
}

Если внутренний модуль ядра встраивается в ядро (не является загружаемым), то функция module_exit не нужна, ее можно удалить, кроме того, чтобы корректно работала консоль при UEFI-загрузке замените module_init на late_initcall. Не буду останавливаться на том, как ядро вызывает иницилиазирующие функции для встраиваемых модулей, и почему именно при UEFI-загрузке без late_initcall не работает. Будем считать, что это ваше домашнее задание.

Вносим изменения и коммитим:

git add .
git commit -m "Fixes"

QEMU позволяет эмулировать как загрузку систем с BIOS-загрузкой, так и с UEFI-загрузкой. Для UEFI-загрузки ему необходимо передать два файла с прошивками:

  • OVMF_CODE_4M.fd или OVMF_CODE.fd — сама прошивка UEFI;

  • OVMF_VARS_4M.fd или OVMF_VARS.fd — значения NVRAM переменных UEFI.

Если вы установили QEMU, то эти файлы у вас уже есть. Чтобы найти, выполните:

find /usr/share/ -name OVMF*
user@debian:~$ find /usr/share/ -name OVMF*
/usr/share/OVMF
/usr/share/OVMF/OVMF_CODE_4M.secboot.strictnx.fd
/usr/share/OVMF/OVMF_VARS_4M.ms.fd
/usr/share/OVMF/OVMF_CODE_4M.fd
/usr/share/OVMF/OVMF_CODE_4M.snakeoil.fd
/usr/share/OVMF/OVMF_VARS_4M.snakeoil.fd
/usr/share/OVMF/OVMF_VARS_4M.fd
/usr/share/OVMF/OVMF_CODE_4M.ms.fd
/usr/share/OVMF/OVMF_CODE_4M.secboot.fd
/usr/share/ovmf/OVMF.fd
/usr/share/qemu/OVMF.fd

Можно прописывать эти пути при запуске QEMU, но я для удобства и безопасности, скопировал их в директорию на уровень выше директории, в которой запускаю QEMU

Перезапускаем в режиме UEFI-загрузки:

qemu-system-x86_64 -drive if=pflash,format=raw,readonly=on,file=../OVMF_CODE_4M.fd -drive if=pflash,format=raw,readonly=on,file=../OVMF_VARS_4M.fd -kernel arch/x86/boot/bzImage
Работающее приложение после загрузки через UEFI
Работающее приложение после загрузки через UEFI

Патчи

Как передать исходный код измененного ядра, который мы разработали, другому человеку. Можно конечно, создать удаленный репозиторий и запушить туда, но можно сделать более удобнее при помощи патча.

Патчи позволяют не создавать отдельный удалённый репозиторий со всем ядром, но еще они обеспечивают масштабируемую, проверяемую и децентрализованную разработку.

git checkout -b simple_module-patch-v6.12.67

У нас два коммита:

  • один после создания внутреннего модуля из внешнего;

  • второй после правок.

Поэтому желательно два последних коммита объединить в один перед созданием патча:

git reset --soft HEAD~2
git commit -s -m "In-tree module sample v6.12.67"
git format-patch -1
mkdir -p ../simple_module/patches
cp 0001-In-tree-module-sample-v6.12.67.patch ../simple_module/patches/

Теперь изменения для добавление нашего модуля дерево исходного кода Linux хранятся в одном текстовом файле 0001-In-tree-module-sample-v6.12.67.patch со всеми необходимыми метаданными. Можно брать различные версии ядра Linux или различные ветки в репозиториях ядра Linux, пробовать применить этот патч, собирать, экспериментировать.

Созданный патч не соответствует требованиям, предъявляемым к патчам, отправляемым в upstream. Не буду рассказывать, как подготовить отправить патч для upstream, вы уже и так устали, если дочитали статью до этих слов, а Тетриса до сих пор не увидели.

Теперь можно применить патч к чистому дереву исходного кода Linux при помощи команды patch:

cd ..

wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.67.tar.gz

tar xvf linux-6.12.67.tar.gz 

cd linux-6.12.67/

patch -p1 <  ../simple_module/patches/0001-In-tree-module-sample-v6.12.67.patch

make tinyconfig

./scripts/config -e 64BIT -e PCI -e PCI_HOST_GENERIC -e TTY -e BINFMT_ELF -e DEVTMPFS -e DEVTMPFS_MOUNT -e PROC_FS -e SYSFS -e TMPFS -e PRINTK -e EARLY_PRINTK -e PRINTK_TIME -e ACPI -e EFI -e EFI_STUB -e FB -e FB_EFI -e FRAMEBUFFER_CONSOLE -e CMDLINE_BOOL --set-str CMDLINE "rootwait" -e SIMPLE_MODULE -e USB -e USB_SUPPORT -e USB_XHCI_HCD -e USB_PCI -e USB_XHCI_PCI

make olddefconfig

make -j$(nproc)

qemu-system-x86_64 -kernel arch/x86/boot/bzImage 

Также патч можно применть репозиторию c исходным кодом ядра при помощи команды git am.

❯ Тетрис

Мы собрали и запустили ядро с пользовательским приложением внутри. Как и говорил вначале, в статье не рассказывается, как создать Тетрис в ядре, но рассматриваются основные аспекты его Тетриса, кроме реализации самой логики игры.

Приведенных сведений должно хватить, чтобы вы реализовали такой Тетрис сами.

Вы можете посмотреть исходный код Тетриса в ядре и запустить в эмуляторе или на реальном железе.

Тетрис в ядре, запущенный в эмуляторе QEMU
Тетрис в ядре, запущенный в эмуляторе QEMU

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

Для переработки модуля, что мы создавали в статье, в ядро с Тетрисом нужно сделать малость.

  • найти и адаптировать исходный код Тетриса (я взял за основу следующий и перевел с С++ на С, параллельно убрав код, зависящий от userspace) или написать свой;

  • оформить его в виде патча, чтобы его можно было удобно передать другим людям.

Джонни, монтаж!
Джонни, монтаж!

Расскажу еще немного о том, с чем столкнулся при написании Тетриса.

Когда вы пишете консольное приложение на языке С, в коде вы используете стандартную библиотеку C или другие библиотеки пространства пользователя. Но когда вся логика находится в ядре, этой возможности нет. Поэтому нужно или искать аналоги нужных функций в ядре или писать свои функции, предоставляющие необходимый функционал.

Для Тетриса в ядре таких функций немного:

  • вывод символа на экран;

  • генерация случайного числа;

  • обработка кода нажатой клавиши;

  • запуск кода в отдельном потоке.

Статья оказалась длинной и насыщенной, хотелось рассказать больше, но все в статью не уместишь. Но можно продолжить обсуждение в комментариях.

❯ Ссылки, которые вам могут пригодиться

  1. Учебный проект, который описывается в статье.

  2. Тетрис в виде патча к ядру Linux.

  3. Архивы с исходным кодом ядра Linux.

  4. Git-репозиторий со стабильными ветками ядра.

  5. Удобный сайт с навигацией по исходному коду ядра Linux.

❯ Выводы

В статье на примере было доказано, что ядро Linux может работать как пользовательское приложение. Целесообразность подобной реализации сомнительна в продакшене, но для изучения внутренних механизмов ядра и повышения мотивации в изучении такая задача является хорошим примером.

Хотелось запускать Тетрис после того, как ядро Linux упадет с паникой, но к сожалению это не получилось, так как после паники приостанавливает свою работу планировщик и запустить поток с игровым процессом нельзя. Но можно написать патч для ядра, где не найденный файл init является не паникой, а поводом для запуска Тетриса.

Вероятно, было бы интересно добавить Тетрис в качестве патча ядра, чтобы можно скоротать время за игрой, на системах, где достаточно долго загружается Linux. Недавно на Хабре была статья, где рассказывалось про игры при загрузке на ретро компьютерах, а в нашем случае это будет более современный вариант.

Можно было бы исследовать возможность реализации подобного тетриса на системах, где нет виртуальных консолей, а только консоли, работающие на последовательном порту.

Если вы прочитали статью внимательно и параллельно осознанно выполняли описываемые действия, должны были заметить, что для написания модуля ядра владение С, git и командной строкой Linux являются обязательными. Поэтому, если вы планируете дальше разбираться с написанием модулей, стоит обновить свои знания или подучить те темы, где чувствуете пробелы.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.