STM32 з нуля без HAL: Місяць 4. Linux Kernel Modules: пишемо hello world з ядра. Частина 11

Десять статей серії позаду. Bare-metal STM32, Buildroot на Luckfox, GStreamer стрім, NPU розпізнавання обличчя — все це userspace код, який звертався до /dev/ttyS1, /dev/video0, до драйверів які хтось колись написав. Я туди ще не заліз, руки чухаються. Час лізти.

Місяць 4 це зоряний час kernel space. Тут немає printf, є printk. Немає malloc, є kmalloc. Помилився з вказівником — не segfault, а kernel panic і reboot. Десь чув, що embedded middle від junior відрізняється саме здатністю писати свій драйвер, а не тільки використовувати чужі. Та байдуже, що говорять, як на мене це круто забацати свій драйвер, а ще краще зрозуміти, що воно таке і з чим його їдять

◆ Замість дисклеймера

Трохи предісторії. Linux я відкрив не минулого тижня, але й не дуже так і давно починав з 16 Ubuntu і остаточно пересів з 2019 року. На Orange pi — Arch Linux ще з доковідних часів. На полицях артефакти з тих самих часів — Raspbian Buster 2019, CentOS 7 ISO, ще одна стара малинка з підписом CentOS 6 і маркером на коробці. Колись я ці системи ставив, ламав, відновлював, переставляв. Не так щоб сильно навчився, але точно вже звик і на вінду вже не хочу. Весело пригадувати, як вивчав лінукс через Cygwin UNIX-подібну незалежну оболонку та набір інструментів, що емулює роботу Linux-оточення у Windows. Навіщо? Та для мене стало сюрпризом, що нода і пайтон трохи обрізані, а С++ там меж інший і різноманітні приклади рішень усі були на linux. Але зламало мене те, що я ніяк не міг налаштувати на вінді Docker.

Користуючись нагодою, хочу нагадати, що паралельно з цією серією веду інші, зокрема про Zephyr RTOS на Pi Pico (Частина 0 вже на DOU), поки на паузі, бо думаю вона стане логічним продовженням цієї де ми теж будемо кулупати RTOS. Ну, а тим хто, як і я в захваті від малінки, апельсинів і прочих фруктів до душі може припасти П’ять малинок, стійка і DietPi: як я будую домашній IoT-сервер

◆ Ґуля (граблі набили) № 0: де я хотів додати голос

Останнє що залишилось з Місяця 3 — відкрите архітектурне питання. Хотів додати голосове керування на Luckfox: моя wake-word модель «Катя» слухає, NPU розпізнає обличчя, STM32 крутить реле. Класична embedded магія.

Сів читати даташит RV1106. Потер очі. Знову прочитав. Подивився на свій Pico Pro і ще раз на pinout. Audio I²S піни на header не виведені. Внутрішній codec є — це я бачу у специфікації чіпа. А назовні не прокинули. Можливо Pico Pro позиціонується саме як IP-камера SoC: аудіо в дизайні плати не передбачено.

⚠ Тут варто зробити уточнення яке часто плутають.

I²C виведений нормально, він є на пінах, ним я вже користувався — BMP180, MPU6050 в попередніх статтях через нього спілкувалися з STM32. Але тут потрібен I²S — це зовсім інший інтерфейс, не плутати:

I²CI²S
РозшифровкаInter-Integrated CircuitInter-IC Sound
Для чогоСенсори, EEPROM, RTC, OLEDТільки аудіо
ЛініїSDA, SCL (2 проводи)BCLK, LRCK, DATA (3+ проводи)
Швидкість100 kHz — 3.4 MHzДесятки MHz, безперервний потік
ПризначенняКоманди + дрібні даніСтрім PCM семплів

I²C — для датчиків, типу BMP180, MPU6050. I²S — спеціально для цифрового аудіо. Передає неперервний потік PCM семплів (наприклад 16 000 чисел за секунду = 16 kHz audio). На таких швидкостях I²C просто не вистачить bandwidth, плюс у I²S інша архітектура — continuous streaming замість transaction-based.

Може виникнути логічна думка: «а є I²C мікрофон?» Технічно — є. Але для wake-word він не підходить. I²C-мікрофон передає дані запитами (request-response). Кожен раз треба слати команду «дай мені семпл», потім приймати відповідь. Для 16 000 семплів на секунду це 16 000 транзакцій I²C — bus буде задушено, ніякого іншого I²C девайса вже не приєднати. Окрім того jitter — між семплами непостійний, що псує якість для wake-word.

Що з чим на Luckfox Pico Pro

ІнтерфейсВиведений на header?Для чого
I²C✓ ТакСенсори, OLED, RTC
SPI✓ ТакДисплеї, флеш, ADC
UART (1, 3, 4)✓ ТакSTM32, GPS, debug
GPIO✓ ТакКнопки, LED, реле
PWM✓ ТакСервоприводи, реле
I²S (audio)✗ НіНа цій моделі плати не виведений
USB audio через USB-CТехнічно такЄдиний шлях, але конфлікт з живленням

Залишався один варіант — USB audio dongle. Але:

  • USB-C порт на Luckfox один, і він мені потрібен для живлення і RNDIS network.
  • USB камера з мікрофоном тягне 200-500 mA, на межі того що Luckfox може дати з власного host port.
  • Тре пересборку ядра з SND_USB_AUDIO + alsa-utils у Buildroot.

Ось тут тре подумати. А чи це взагалі правильна архітектура? Якщо Luckfox — це наш «мозок з очима» (NPU + камера), то навіщо туди ще й «вуха» прикручувати? Echo Dot же не на одному SoC робить — там завжди увімкнений low-power MCU слухає wake-word, а основний процесор спить.

Архітектурне рішення: wake-word йде на STM32

Чим більше думав, тим більше мені це подобалось, ну прям модульно, ух красота:

  • STM32 має I²S виведений на пінах — INMP441 мікрофон і MAX98357A DAC підключаються прямо.
  • DMA на STM32 пише I²S семпли в RAM без участі CPU — стабільний 16 kHz без jitter, який буде на Linux scheduler.
  • TFLite Micro компілюється під STM32 F4/F7/H7. Моя 50 KB модель Катя влізе з запасом (Стаття Як я навчив нейромережу чути своє ім’я: wake word з нуля на TensorFlow вже майже в редакції ).
  • Bare-metal = real-time. Від звуку до спрацювання ~50 ms predictable. На Linux 100-300 ms з jitter.
  • Енергія: STM32F4 у активному режимі ~50 mA, Luckfox ~300-500 mA. Для always-on wake-word різниця в 10 разів.
  • STM32 слухає Катю → детектить → шле UART trigger на Luckfox, той прокидається з камерою та NPU.

Це і є справжня heterogeneous embedded архітектура — та що використовують Amazon Echo Dot, Apple HomePod, Google Nest. Завжди увімкнений low-power MCU з wake-word моделлю. SoC прокидається тільки коли wake-word спрацював.

Як само собою виходить, що в Місяць 3 Luckfox призначено стати мозком, а STM32 буде керувати м’язом. А тепер додаємо ще й вухо на STM32. Це щось неймовірне, м’язи, які чують і реагують.

⚠ Це окрема стаття, до якої я ще повернусь у Місяці 5 (FreeRTOS на STM32 + I²S + TFLite Micro). А зараз в нас інші важливі справи, дізнатись, що всередені самого Linux. Власне колупаємо, kernel modules.

◆ Залізо: шість малинок, п’ять простих + одна з плюсиком

У тумбочці 6 штук Pi 3: 5 звичайних 3B + одна 3B+. Дістав ту що вже використовую в серії де я будую домашній IoT-сервер. Перший boot:

$ ssh pi
$ cat /proc/device-tree/model
Raspberry Pi 3 Model B Rev 1.2

Чекай, я ж думав це 3B+. А це звичайний 3B без плюсика. Малінка в корпусі, а /proc/device-tree/model не робив, от і маєш. Корисна команда, щоб не розбирати корпус і дізнатись модель, запам’ятовуємо.

Чим відрізняються:

Pi 3BPi 3B+
CPUCortex-A53 @ 1.2 GHzCortex-A53 @ 1.4 GHz
Ethernet100 MbpsGigabit (через USB 2.0)
WiFi2.4 GHz2.4 + 5 GHz

Для kernel модулів — той самий BCM2837, той самий GPIO chip, той самий код. Поточну 3B пускаю в експерименти (їх багато, не шкода якщо щось зламаю в ядрі), 3B+ резервую для Місяця 5 — там буде FreeRTOS + OpenCV, додаткові 200 MHz і двосмуговий WiFi не зайві.

◆ Перший boot: чиста картка з нуля

SD картка в ній вже з образом і він мені тре. Тому розпакую свіжу SanDisk 32 GB.

Записувати буду з Ubuntu. Найпростіший шлях — офіційний інструмент Raspberry Pi Imager:

sudo snap install rpi-imager
# або
sudo apt install rpi-imager

Перед тим як встромляти картку — перевіряю які диски в системі зараз:

lsblk

⚠ Запам’ятай вивід. Тепер встромляєш картку — і знову lsblk. Нова строка з розміром ~29-30 GB (32 GB номінально завжди показуються трохи меншими) — це наша SD. Назву обов’язково запам’ятай, бо якщо переплутаєш з системним диском, dd або Imager радо затре твою Ubuntu без зайвих запитань.

Запуск Imager

Запускаю rpi-imager, тиснемо три кнопки:

  • CHOOSE DEVICE → Raspberry Pi 3
  • CHOOSE OS → Raspberry Pi OS (other) → Raspberry Pi OS Lite (64-bit). Чому Lite — нам не треба GUI, kernel розробка вся в терміналі. Чому 64-bit — Pi 3B це Cortex-A53, 64-bit native, майбутнє за aarch64.
  • CHOOSE STORAGE → наша SanDisk. Звіряємо розмір з тим що бачив у lsblk.

Далі — Next → EDIT SETTINGS. Це ключовий момент, тут одразу налаштовуємо все що знадобиться при першому boot, щоб потім не возитись.

General вкладка:

  • Hostname: alex-pi
  • Username: alex, пароль будь-який
  • Configure wireless LAN: вмикаю, вписую WiFi мережу. Country code: UA. Це резервний канал на випадок якщо з ethernet щось піде не так.
  • Locale: timezone Europe/Kyiv, keyboard us

Services вкладка:

  • Enable SSH → ✓
  • Password authentication (ключі додам потім)

SAVE → Yes → Yes. Запис триває ~5 хвилин, Imager сам перевіряє контрольну суму.

◆ Перший boot і SSH у мережі

Картку в Pi, ethernet кабель у роутер, HDMI у монітор (на всяк випадок щоб бачити що відбувається), USB клавіатура, і в останню чергу — micro USB живлення. Pi стартує одразу як з’явилось живлення.

Через ~30 секунд на моніторі: alex-pi login:. Перший boot пройшов чисто.

Тепер як знайти Pi в мережі з ноута? Підказка — nmap по підмережі шукаючи MAC префікс Raspberry Pi Foundation (B8:27:EB):

ip route | grep default
# default via 192.168.1.1 dev enp0s25 proto dhcp metric 100
# отже наша мережа 192.168.1.0/24

sudo apt install nmap
sudo nmap -sn 192.168.1.0/24 | grep -B 2 -i raspberry

Вивід:

Nmap scan report for 192.168.1.109
Host is up (0.0015s latency).
MAC Address: B8:27:EB:D4:26:95 (Raspberry Pi Foundation)
Nmap scan report for 192.168.1.114
Host is up (0.0016s latency).
MAC Address: B8:27:EB:D4:26:95 (Raspberry Pi Foundation)

Два IP, але MAC однаковий — це одна і та сама Pi на двох інтерфейсах. Підключаюся:

ssh [email protected]

На питання fingerprint — yes, пароль той що задав у Imager.

Вже на Pi перевіряю інтерфейси:

$ ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0             UP             192.168.1.109/24
wlan0            UP             192.168.1.114/24

OK, eth0 = .109, wlan0 = .114. Зафіксую ethernet IP як основний — для kernel розробки стабільність важлива (якщо щось зламаю в мережевому стеку через невдалий модуль, wifi першим помре).

На Ubuntu додаю alias у ~/.ssh/config щоб не запам’ятовувати IP:

Host pi
    HostName 192.168.1.109
    User alex

Тепер просто ssh pi. На роутері додатково прив’язую MAC B8:27:EB:D4:26:95 до цього IP в DHCP reservation, щоб ніколи не плавав.

◆ Перші команди на Pi

Системна інформація:

$ uname -a
Linux alex-pi 6.12.75+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.75-1+rpt1
(2026-03-11) aarch64 GNU/Linux

Ядро 6.12.75 від березня 2026, aarch64. Свіже.

$ cat /proc/device-tree/model
Raspberry Pi 3 Model B Rev 1.2

$ df -h /
Filesystem      Size  Used Avail Use% Mounted on
/dev/mmcblk0p2   29G  3.0G   24G  11% /

$ vcgencmd measure_temp
temp=51.0'C

29 GB rootfs (Pi сама розширила до повного розміру картки при першому boot), 3 GB зайнято — місця море. 51°C у спокої — нормально для Pi 3 без радіатора.

Запускаю оновлення:

sudo apt update
sudo apt upgrade -y

⚠ Якщо у тебе тут оновиться ядро — обов’язково sudo reboot після завершення і перепідключитись по SSH. Інакше uname -r показуватиме одне ядро, а /lib/modules з headers матиме інше — і потім всі kernel модулі будуть з помилкою «Invalid module format».

◆ Ґуля № 2: де поділись raspberrypi-kernel-headers

Класичний рецепт для kernel модулів на Pi казав так:

sudo apt install raspberrypi-kernel-headers build-essential

Запускаю — і отримую:

Error: Unable to locate package raspberrypi-kernel-headers

Хм. На свіжих Raspberry Pi OS Bookworm пакет Headers окремо більше не існує. Headers тепер пакетуються разом з ядром, в одному пакеті linux-image-rpi-v8. Ставлячи ОС з образу — ти вже маєш все що потрібно.

Raspberry Pi OS Bookworm — це офіційна операційна система для одноплатних комп’ютерів Raspberry Pi, яка базується на випуску Debian 12 «Bookworm». Вона прийшла на зміну версії Bullseye (на базі Debian 11) та принесла значні архітектурні зміни, підвищену продуктивність та повну підтримку новітніх моделей, таких як Raspberry Pi 5

Перевіряємо чи це справді так:

$ ls /lib/modules/$(uname -r)/build/ | head
arch
include
Makefile
Module.symvers
scripts
tools
vmlinux

Бачимо Makefile, include, scripts — все необхідне для kbuild. Чудово, ставити нічого не треба, рухаємось далі.

⚠ Урок: туторіали 2-3-річної давності можуть пропонувати команди яких уже не існує. Завжди перевіряємо ls /lib/modules/$(uname -r)/build/ — якщо там є вміст, headers вже встановлені. Якщо порожньо або битий symlink — це окрема пригода з rpi-source, але у нашому випадку обійшлось. Істина проста. Як було раніше вже не буде))

Доставляю інші утиліти для розробки (без kernel-headers, який вже не потрібен):

sudo apt install build-essential git bc bison flex libssl-dev

Перевіряю що компілятор і make на місці:

gcc --version
make --version
git --version

◆ Ґуля № 3: Pi пропала з мережі

Між сесіями робив паузу, повернувся за ноут — ssh pi:

$ ssh pi
ssh: connect to host 192.168.1.109 port 22: No route to host

«No route to host» — це не «пароль не той» і не «ssh не запущений». Це нижчий рівень: ноут просто не знає куди слати пакети. Сама Pi або вимкнена, або відключена від мережі, або змінила IP. Перед тим як панікувати — короткий чек-ліст з 4 кроків:

Крок 1: чи Pi взагалі жива

ping -c 3 192.168.1.109

Якщо «Destination Host Unreachable» — Pi нема в мережі. Якщо «100% packet loss» без unreachable — Pi є але не відповідає.

Крок 2: перевір WiFi адресу як резерв

ping -c 3 192.168.1.114

У мене дві адреси — eth0 і wlan0. Якщо одна не пінгується, інша може.

Крок 3: чи ноут в тій самій мережі

ip route | grep default
# default via 192.168.1.1 dev enp0s25 proto dhcp metric 100

Маршрут має йти через 192.168.1.x. Якщо там інша підмережа (наприклад 192.168.0.1 або 10.x.x.x) — ноут перепідключився до іншої мережі.

Крок 4: сканування — раптом IP змінився

sudo nmap -sn 192.168.1.0/24 | grep -B 2 -i raspberry

Якщо Pi знайшлась на іншому IP — отже DHCP видав їй нову адресу після перезавантаження. Тре робити DHCP reservation у роутері, але не сьогодні.

У моєму випадку: подивився на плату — червоний LED живлення не горить. Просто був вимкнений блок живлення (хтось випадково смикнув подовжувач, не питайте). Підключив назад — Pi за 30 секунд знову у мережі, ssh pi працює як треба. У мене завжди так)), починаю з самого важкого, а тре з самого простого і очевидного, але ж тоді це буде інша пригода.

⚠ Урок сісадміна: перш ніж лізти у Wireshark або переустановлювати OS — глянь на саму плату. Чи горять світлодіоди? Чи в розетці кабель живлення? У 80% випадків відповідь там.

Користуючись нагодою. Ось класичний жарт про сисадміна та пошук несправностей: Сидить сисадмін, глибоко задумавшись, крутить крісло туди-сюди, нахиляється, заглядає під системний блок, знову крутиться. До нього підходить колега:— Щось серйозне сталося? Сервер «ліг»? База даних не відповідає?Сисадмін, не перестаючи крутити крісло:— Та ні, розумієш... У кріслі щось поскрипує, коли кручуся. Вже пів години не можу зрозуміти, чи це крісло змастити треба, чи в мене десь кулер на сервері так шумить.

◆ Частина 2: пишемо hello.ko

Все готове, рухаємось до самого цікавого. Створюю робочу структуру і одразу git репо щоб коміти не загубились:

mkdir -p ~/kernel-modules/01-hello
cd ~/kernel-modules/01-hello
git init

hello.c — перший модуль

Файл hello.c — найпростіший kernel модуль з параметрами. Init друкує привіт, exit прощається, параметри name і times передаються при insmod:

#include <linux/init.h>          /* __init, __exit */
#include <linux/module.h>        /* всі макроси для модуля */
#include <linux/kernel.h>        /* pr_info, pr_err */
#include <linux/moduleparam.h>   /* module_param */

/* ─── параметри модуля ─── */
static char *name = "World";
static int times = 1;

module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Кому передаємо привіт");

module_param(times, int, 0644);
MODULE_PARM_DESC(times, "Скільки разів привітатись");

/* ─── викликається при insmod ─── */
static int __init hello_init(void)
{
    int i;

    pr_info("hello: модуль завантажено\n");

    for (i = 0; i < times; i++) {
        pr_info("hello: Привіт, %s! (раз %d з %d)\n",
                name, i + 1, times);
    }

    /* 0 = успіх. Будь-яке інше — помилка */
    return 0;
}

/* ─── викликається при rmmod ─── */
static void __exit hello_exit(void)
{
    pr_info("hello: до побачення, %s! Модуль вивантажено\n", name);
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Alex");
MODULE_DESCRIPTION("Перший kernel module: hello world з параметрами");
MODULE_VERSION("0.1");

Кілька цікавих деталей:

  • __init і __exit — це не для красоти, це секції в ELF файлі. Код з __init після успішного завантаження модуля звільняється з пам’яті ядра. Тобто hello_init існує тільки до моменту коли він повернув 0 — потім його пам’ять вивільняється. Мертвий код у kernel space не тримаємо.
  • pr_info — обгортка над printk(KERN_INFO ...). Має багато братів: pr_err, pr_warn, pr_debug. У серйозному коді додають #define pr_fmt(fmt) KBUILD_MODNAME ": " fmt — тоді кожне повідомлення автоматично починається з імені модуля.
  • module_param — третій параметр 0644 це права доступу до файлу /sys/module/hello/parameters/. 0644 = читати можна всім, записує тільки root. Якщо 0000 — параметр не буде видно у sysfs.
  • MODULE_LICENSE("GPL") — обов’язково. Без цього ядро видасть «taints kernel» з warning’ом про missing key.

Makefile

⚠ Дуже важливо: відступи перед командами це таб, а НЕ пробіли. Якщо копіюєш через буфер обміну, перевір cat -A Makefile — табуляція показується як ^I. Якщо там пробіли — отримаєш *** missing separator. Stop. У свій час дуже з тим намучився, тому постійно на цьому наголошую.

# Makefile для kernel модуля — використовує kbuild систему ядра

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

reload:
    -sudo rmmod hello 2>/dev/null
    sudo insmod hello.ko
    dmesg | tail -20

Як це працює: ми не компілюємо самі. Ми просимо kbuild систему ядра ($(MAKE) -C $(KDIR)) зайти в директорію headers і скомпілювати наш файл з усіма правильними прапорами, які використовуються для збірки ядра. M=$(PWD) каже kbuild «зовнішній модуль лежить отут». Це ключовий момент: всі kernel модулі компілюються через kbuild.

Збираємо

$ make
make -C /lib/modules/6.18.33+rpt-rpi-v8/build M=/home/alex/kernel-modules/01-hello modules
make[1]: Entering directory '/usr/src/linux-headers-6.18.33+rpt-rpi-v8'
  CC [M]  hello.o
  MODPOST Module.symvers
  CC [M]  hello.mod.o
  LD [M]  hello.ko

⚠ Стривайте — ядро тут 6.18.33, а коли я перевіряв уперше було 6.12.75. Виявляється apt upgrade тихенько оновив ядро до 6.18 під час встановлення git і build-essential. Headers оновились разом з ним. Якби між sudo apt upgrade і make я не зробив reboot — uname показував би старе 6.12, а build linkувався б до 6.18 headers, і модуль не завантажився б. Це той самий випадок «Invalid module format» який лякає всіх початківців, ну чи не всіх.

Перевіряємо що вийшло:

$ ls -la
-rw-rw-r-- 1 alex alex  1994 Jun 10 08:02 hello.c
-rw-rw-r-- 1 alex alex  8112 Jun 10 08:31 hello.ko
-rw-rw-r-- 1 alex alex   761 Jun 10 08:02 Makefile
...

$ modinfo hello.ko
filename:       /home/alex/kernel-modules/01-hello/hello.ko
version:        0.1
description:    Перший kernel module: hello world з параметрами
author:         Alex
license:        GPL
name:           hello
vermagic:       6.18.33+rpt-rpi-v8 SMP preempt mod_unload modversions aarch64
parm:           name:Кому передаємо привіт (charp)
parm:           times:Скільки разів привітатись (int)

vermagic — це той самий «штамп сумісності» який ядро перевіряє при завантаженні модуля. Якщо твій vermagic не співпадає з ядром що працює зараз — модуль не вантажиться.

Завантажуємо у ядро

$ sudo insmod hello.ko
$ dmesg | tail -5
[2271.782480] hello: loading out-of-tree module taints kernel.
[2271.784431] hello: модуль завантажено
[2271.784448] hello: Привіт, World! (раз 1 з 1)

$ lsmod | grep hello
hello                  12288  0

Працює! Розбираємо:

  • taints kernel — це нормально і очікувано. Tainted kernel означає що в ядро завантажено код не з основного дерева (out-of-tree). Кожен такий модуль робить це, включаючи Nvidia, VirtualBox, VMware. Це попередження для мейнтейнерів: «якщо потім буде kernel panic, не плачтесь офіційним розробникам».
  • hello 12288 0 — ім’я, розмір у байтах (12K — мінімум для будь-якого kernel модуля на ARM64), reference count (0 = ніхто не використовує).

Вивантажуємо:

$ sudo rmmod hello
$ dmesg | tail -3
[2271.784431] hello: модуль завантажено
[2271.784448] hello: Привіт, World! (раз 1 з 1)
[2314.989473] hello: до побачення, World! Модуль вивантажено

Тепер з параметрами

$ sudo insmod hello.ko name=Alex times=5
$ dmesg | tail -10
[2324.628422] hello: модуль завантажено
[2324.628446] hello: Привіт, Alex! (раз 1 з 5)
[2324.628458] hello: Привіт, Alex! (раз 2 з 5)
[2324.628466] hello: Привіт, Alex! (раз 3 з 5)
[2324.628474] hello: Привіт, Alex! (раз 4 з 5)
[2324.628482] hello: Привіт, Alex! (раз 5 з 5)

П’ять привітів як замовляли. Тепер найцікавіший трюк — параметри видно через sysfs і можна змінювати на льоту:

$ sudo insmod hello.ko name=Alex times=3
$ cat /sys/module/hello/parameters/name
Alex
$ cat /sys/module/hello/parameters/times
3

$ echo "Stm32" | sudo tee /sys/module/hello/parameters/name
Stm32

$ sudo rmmod hello
$ dmesg | tail -3
[2335.933427] hello: Привіт, Alex! (раз 3 з 3)
[2336.082876] hello: до побачення, Stm32
               ! Модуль вивантажено

⚠ Бачите ту дивну розривку «до побачення, Stm32 ! Модуль вивантажено»? Це не баг ядра — це echo додало символ переносу рядка в значення параметра. Параметр name тепер містить "Stm32\n", а коли модуль друкує його з форматом «до побачення, %s! Модуль вивантажено», новий рядок ріже повідомлення посередині. Якщо тре чистіше — printf "Stm32" | sudo tee без переносу. Дрібниця, але наглядно: userspace tooling працює непомітно, і це може ламати твій kernel код.

Поки це робив згадав один бородатий анекдот, але мене він веселить до цих пір.

Зі слів юзера:
— Комп не працював. Прийшов адмін, підніс руки до неба, пробурмотів якісь заклинання, прокрутив мій стілець 3 рази навколо своєї осі, штурхнув комп’ютер — і сталося диво — він запрацював!!!

Зі слів адміна:
— Дзвінок. Біжу до юзера. Цей бовдур так крутився на новому стільці, що кабель живлення намотався на ніжку і висмикнувся з блока. Побачивши це, підводжу руки до неба, виголошую кілька триповерхових матюків, розмотую кабель, заштовхую комп ногою подалі під стіл і вмикаю.

◆ Експерименти: коли все йде не так

Тепер давайте навмисно ламати. Бо знайти баг в чужому коді легко, а в своєму найскладніше — і навчитись передбачати проблеми можна тільки борячись з багами. Писати код легко, важко вмовити його працювати стабільно.

Експеримент 1: відмова від завантаження через -EINVAL

Що буде якщо init поверне не 0? Дописуємо в hello_init:

    pr_err("hello: відмовляюсь завантажуватись, тестую помилку\n");
    return -EINVAL;

$ make
$ sudo insmod hello.ko
insmod: ERROR: could not insert module hello.ko: Invalid parameters
$ echo "exit code: $?"
exit code: 1

$ dmesg | tail -10
[24360.911041] hello: модуль завантажено
[24360.911064] hello: Привіт, World! (раз 1 з 1)
[24360.911074] hello: відмовляюсь завантажуватись, тестую помилку

$ lsmod | grep hello
(нічого — модуль НЕ завантажений)

Що сталось:

  • init функція повертає int, де 0 = успіх, мінус errno = помилка. Це contract ядра.
  • -EINVAL = −22. insmod переклав це в стандартне повідомлення «Invalid parameters».
  • Хоча pr_info вивів привіти і pr_err вивело помилку — модуль не залишився в системі. Ядро відкотило ініціалізацію.
  • Реальний приклад: USB драйвер не знайшов потрібний chip → повертає -ENODEV. Не сумісне залізо → -ENXIO. Параметр модуля недопустимий → -EINVAL.

Експеримент 2: rate limiting та ring buffer

А що якщо просто навалити купу повідомлень? Запускаю times=10000:

$ sudo insmod hello.ko name=Test times=10000
$ dmesg | grep "hello: Привіт" | wc -l
2045

Стоп. Просив 10000, отримав 2045. Куди поділись 8000 повідомлень? Збільшую буфер запиту:

$ dmesg --buffer-size=10485760 | grep "hello: Привіт" | wc -l
1987

Все одно ~2000. Значить це не обмеження dmesg, це обмеження самого kernel ring buffer. Дивимось перший рядок який залишився:

$ dmesg --buffer-size=10485760 | grep "hello: Привіт" | head -1
[24379.679135] hello: Привіт, Test! (раз 8016 з 10000)

Перші 8015 повідомлень буквально витиснуті новішими. Kernel ring buffer — це circular buffer фіксованого розміру (часто 256K-1M, задається при компіляції ядра через CONFIG_LOG_BUF_SHIFT). Коли він повний — старі повідомлення затираються.

  • Цикл виконався весь — побачили «(раз 10000 з 10000)» в кінці.
  • Але історія обрізана — лише ~2000 останніх повідомлень доступні.

⚠ Урок: pr_info в тісному циклі — це антипаттерн. У реальних драйверах для гарячого коду використовують pr_info_ratelimited() (явно обмежує частоту) або dev_dbg() (виводиться тільки якщо ядро в debug режимі). Або просто не друкують у hot path взагалі.

Експеримент 3: справжній kernel oops

Тепер найкраще. Робимо навмисний NULL pointer dereference в init. Це безпечно: ядро спіймає помилку, вб’є процес insmod, але система продовжить працювати. Я це робив десятки разів.

Додаю параметр crash_test:

static int crash_test = 0;
module_param(crash_test, int, 0644);
MODULE_PARM_DESC(crash_test, "Якщо 1 — навмисно ламаємось у init");

/* в hello_init, перед return 0: */
    if (crash_test) {
        int *bad_pointer = NULL;
        pr_warn("hello: зараз буде боляче — навмисний NULL deref\n");
        *bad_pointer = 42;   // BOOM
    }

Спочатку — без crash, переконатись що нічого не зламали:

$ make
$ sudo insmod hello.ko name=Safe
$ sudo rmmod hello
$ dmesg | tail -3
[24477.510478] hello: модуль завантажено
[24477.510500] hello: Привіт, Safe! (раз 1 з 1)
[24477.643819] hello: до побачення, Safe! Модуль вивантажено

Працює як раніше. Тепер з crash_test=1:

$ sudo insmod hello.ko name=Boom crash_test=1
$ dmesg | tail -50

І ось воно, шикарне:

[24497.385684] hello: модуль завантажено
[24497.385714] hello: Привіт, Boom! (раз 1 з 1)
[24497.385733] hello: зараз буде боляче — навмисний NULL deref
[24497.385763] Unable to handle kernel NULL pointer dereference at virtual
               address 0000000000000000
[24497.394196] Mem abort info:
[24497.396997]   ESR = 0x0000000096000045
[24497.400755]   EC = 0x25: DABT (current EL), IL = 32 bits
[24497.412356]   FSC = 0x05: level 1 translation fault
[24497.417305] Data abort info:
[24497.420195]   ISV = 0, ISS = 0x00000045, ISS2 = 0x00000000
[24497.425759]   CM = 0, WnR = 1, TnD = 0, TagAccess = 0
[24497.451571] Internal error: Oops: 0000000096000045 [#1]  SMP
[24497.457280] Modules linked in: hello(O+) rfcomm cmac ... [last unloaded: hello(O)]
[24497.524318] CPU: 1 UID: 0 PID: 2409 Comm: insmod
                Tainted: G         C O        6.18.33+rpt-rpi-v8 #1 PREEMPT
[24497.540962] Hardware name: Raspberry Pi 3 Model B Rev 1.2 (DT)
[24497.553910] pc : hello_init+0x78/0xff8 [hello]
[24497.558403] lr : hello_init+0x70/0xff8 [hello]
[24497.631250] x2 : 0000000000000000 x1 : 000000000000002a x0 : 0000000000000000
[24497.638475] Call trace:
[24497.640942]  hello_init+0x78/0xff8 [hello] (P)
[24497.645434]  do_one_initcall+0x60/0x2a0
[24497.649309]  do_init_module+0x5c/0x268
[24497.653096]  load_module+0x197c/0x1fb8
[24497.656885]  init_module_from_file+0x90/0xe0
[24497.661201]  __arm64_sys_finit_module+0x1c4/0x340
[24497.665957]  invoke_syscall+0x4c/0x100
[24497.669746]  el0_svc_common.constprop.0+0x48/0xf0
[24497.674502]  do_el0_svc+0x24/0x38
[24497.677849]  el0_svc+0x38/0x120
[24497.681020]  el0t_64_sync_handler+0xa0/0xe8
[24497.685249]  el0t_64_sync+0x198/0x1a0
[24497.695119] ---[ end trace 0000000000000000 ]---

Це справжній kernel forensics артефакт — exactly те що embedded інженер бачить коли драйвер багнувся в продакшні. Уміння читати stack trace — окрема навичка. Швидкий розбір зверху вниз:

  • Unable to handle kernel NULL pointer dereference at virtual address 0x0 — ARM64 MMU спіймало запис у адресу 0. Це найважливіший рядок, з нього починаємо читати будь-який oops.
  • ESR = 0x96000045, FSC = level 1 translation fault, WnR = 1 — це деталі: переклад віртуальної адреси в фізичну впав на верхньому рівні page table, операція була запис (WnR=1, не читання). Тобто ми писали в неіснуючу пам’ять.
  • Modules linked in: hello(O+) — наш модуль зі статусом O (Out-of-tree) і + (init in inconsistent state).
  • Hardware name: Raspberry Pi 3 Model B Rev 1.2 — куди корисніше за просто «Linux» :)
  • pc : hello_init+0x78 [hello] — Program Counter в момент крашу. +0x78 від початку нашої функції. Якщо мати символи з налагоджувальною інформацією, можна знайти точний рядок в коді.
  • x1 : 000000000000002a — регістр x1 містить значення 0x2a = 42 (саме його ми хотіли записати в NULL!). x0 : 0 — наш bad_pointer.
  • Call trace знизу вгору — як ми сюди дістались: userspace викликав syscall (el0_svc), ядро викликало __arm64_sys_finit_module (це syscall завантаження модуля), потім load_moduledo_init_moduledo_one_initcall → і нарешті наш hello_init+0x78 — БУМ.

⚠ Зверни увагу на «last unloaded: hello(O)» — це наш попередній чистий rmmod після Safe тесту. Ядро пам’ятає недавно вивантажені модулі, бо їх символи можуть фігурувати в oops’ах якщо одразу після rmmod. Корисна функція для debug’у.

Невеличкий рікошет: одна навичка — два шляхи

Той oops тригернув таракана в голові. Якщо в kernel модулі можна писати в довільну пам’ять (наприклад через bug — buffer overflow у драйвері), теоретично можна переписати функцію в kernel symbol table, підмінити syscall у sys_call_table, викликати потрібний код з повними kernel-привілеями. Можливо це саме те, що називається kernel exploitation і privilege escalation через kernel bug.

Тобто та сама базова навичка — написати kernel module — це одночасно фундамент для embedded driver development та перший крок у kernel security. Один і той самий insmod, два різні шляхи з нього. Хочу показати один невинний експеримент який ілюструє це наочно.

Робимо модуль який знаходить current task struct (себе самого, процес insmod) і друкує його UID. Потім намагається змінити свій UID на 0 через kernel API. Якщо це працює — побачимо що kernel code справді має повний доступ до credentials будь-якого процесу. Якщо не працює — побачимо як сучасне ядро себе захищає.

⚠ Це НЕ exploit. Цей модуль завантажується лише через sudo insmod — тобто ми вже root перед запуском. Демонстрація лише ілюструє що означає «kernel space має повний доступ». Не використовується жодна CVE, жодного memory corruption, жодного обходу захисту. Просто два публічні виклики kernel API які кожен embedded інженер мусить знати.

Створив директорію 02-creds-demo, написав модуль, який в init робить три речі: читає current_cred(), виводить UID/EUID, потім викликає prepare_kernel_cred(NULL) і commit_creds() щоб отримати root credentials.

Перший запуск:

$ sudo insmod creds_demo.ko
insmod: ERROR: could not insert module creds_demo.ko: Cannot allocate memory

Cannot allocate memory? Подивимось dmesg:

[25836.342661] x2 : 0000000000000000 x1 : ffffff8002e48000 x0 : 0000000000000000
[25836.345127] Call trace:
[25836.349797]  prepare_kernel_cred+0x2a8/0x300 (P)
[25836.354816]  creds_demo_init+0x90/0xff8 [creds_demo]
[25836.358692]  do_one_initcall+0x60/0x2a0
[25836.362479]  do_init_module+0x5c/0x268
[25836.366268]  load_module+0x197c/0x1fb8
[25836.403085] creds_demo: не вдалось prepare_kernel_cred

⚠ Ого. prepare_kernel_cred(NULL) повернув NULL — функція пройшла далеко (+0x2a8/0x300, тобто майже кінець реалізації) і явно вирішила відмовити. Це не баг — це захист. У Linux 5.18 (2022 рік) prepare_kernel_cred(NULL) було навмисно зламано, бо саме цей виклик був класичним інструментом kernel rootkit’ів — повертав чисті root credentials. Спільнота зважила на те що це більше шкодить ніж допомагає, і додала перевірку. Моє ядро 6.18.33 успадкувало цей захист.

Виправлення для legitimate use case — передавати task struct реального процесу замість NULL:

/* було: */
new_cred = prepare_kernel_cred(NULL);

/* стало (додаємо #include <linux/sched/task.h>): */
new_cred = prepare_kernel_cred(&init_task);

init_task — це PID 1, він завжди root. Тобто ми просимо ядро не «дай root з повітря», а «дай мені копію credentials реального процесу init». Це legitimate use, який використовується багатьма kernel threads. Запускаємо знову:

$ sudo insmod creds_demo.ko
$ dmesg | tail -10
creds_demo: ===== STAGE 1: хто запустив insmod =====
creds_demo: PID=2917, name=insmod
creds_demo: UID=0, EUID=0 (це твій реальний sudo user)
creds_demo: ===== STAGE 2: змінюємо UID процесу на 0 =====
creds_demo: тепер UID=0, EUID=0
creds_demo: ===== STAGE 3: що це означало =====
creds_demo: процес insmod (PID 2917) тепер має UID 0
creds_demo: але це не exploit — sudo вже дав нам root,
creds_demo: ми просто продемонстрували що kernel code
creds_demo: має повний доступ до task credentials

Працює. Це і є ілюстрація: моя 50-рядкова функція з обчисленнями над struct cred та полями task_struct має ту саму вагу, що й код будь-якого реального драйвера USB чи мережі. Жодного «security» рівня всередині ядра немає — є тільки контракти між модулями і ядром. Якщо твій код запустився всередині ядра — він всемогутній.

З цього випливають defensive lessons для embedded інженера: Module signing (CONFIG_MODULE_SIG_FORCE) щоб тільки підписані модулі могли завантажуватись. Kernel lockdown mode щоб обмежити навіть root. Secure Boot щоб обмежити що взагалі може стартонути. Для серйозного embedded продукту відкритий root + можливість insmod = catastrophe — ніби ти комусь дав незаповнений підписаний бланк з печаткою на своє їм’я.

Тут зупиняюсь — це окрема глибока тема, яку я люблю і вивчаю, але це не для цієї статті та і взагалі не для DOU. Для тих хто хоче далі: MITRE ATT&CK T1547.006 (Kernel Modules and Extensions), документація Linux kernel hardening, репозиторій linux-hardened. На DOU я колись писав огляд книги Packt про malware development — теж дотичне.

Це фінальна ілюстрація для розділу: одна навичка insmod hello.ko — і два світи. Один пише /dev/hcsr04 для робота, інший вивчає як цей сам механізм еволюціонує проти зловживань. Обидва шляхи варто знати.

◆ Що зробили і що далі

У цій статті ми розглянули фундамент для Місяця 4:

  • Виявили що audio на Luckfox Pico Pro архітектурно не варто (I²S не виведений на header, Linux scheduler нестабільний для real-time). Wake-word йде на STM32 — це окрема стаття можливо для Місяця 5.
  • Записали свіжу Raspberry Pi OS Lite 64-bit, ядро 6.12.75 (потім apt upgrade тихенько оновив до 6.18.33).
  • Знайшли Pi у мережі через nmap, підключились по SSH, зафіксували IP, додали alias.
  • Виявили що 3B+ виявилась 3B Rev 1.2 — без розбирання корпусу не побачиш.
  • Headers вже стоять разом з ядром, окремого пакета raspberrypi-kernel-headers більше не існує.
  • Зібрали чек-ліст «Pi пропала з мережі» з 4 кроків.
  • Написали і завантажили перший hello.ko з module params та sysfs runtime зміною.
  • Експериментували: -EINVAL відмова, ring buffer переповнення, повний живий kernel oops з ARM64 stack trace.
Непогано розважились, як на мене
В наступній статті — переходимо від «hello world» до реальної взаємодії з залізом. Пишемо kernel module який блимає LED через GPIO ядра. Перша різниця між sysfs способом (echo > /sys/class/gpio/export, як ми звикли з userspace) і власним driver’ом. Зробимо те ж саме — але як справжні драйверщики.

До зустрічі — Hello, GPIO! Підпишись і клацни зірочку, у мене ще багато чого є написано в шухляді, окрім цієї серії.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось14
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
перш ніж лізти у Wireshark або переустановлювати OS — глянь на саму плату. Чи горять світлодіоди? Чи в розетці кабель живлення?

А ще бувай глючний USB дріт/повербанк/блок-живлення. І при цьому все горить як треба, але ознаків життя немає. 5-10 хвилин працює, а потім все зникає. Після перезапуску знову працює, і знову зникає.

Є таке 🙂. Це класика, це бісить але як без цього 🤗

А ще бувас помилка в FPGA дизайнi плати, якщо ти отримав не стандартну. Attention: asic vs fpga vs jetson. Note: У автора мова постійно іде тільки про ASIC

Підписатись на коментарі