Linux Kernel — это, пожалуй, один из самых распространённых (и, возможно, до сих пор недооценённых) программных продуктов в мире. Он является основой всех дистрибутивов Linux (что очевидно), но на этом его роль не заканчивается. Ядро также работает на множестве встроенных устройств практически повсюду. У вас есть микроволновка? Скорее всего, она работает на ядре Linux. Посудомоечная машина? Тоже. Если у вас достаточно средств на автомобиль Tesla, вы даже сможете найти несколько багов, исправить их и отправить патч в код Model S или Model X на GitHub. А что насчёт схем, которые не позволяют Международной космической станции сойти с орбиты и врезаться в Землю? Конечно, и там тоже Linux. Ядро легковесное — значит, отлично работает даже в условиях невесомости.

Разработка ядра Linux проходит через циклы, которые можно назвать безумными. Например, статистика выпуска ядра 5.10 показывает, что в нём было 252 новых автора, которые внесли свои изменения (это, кстати, минимальное количество новых участников с версии 5.6), и новые релизы появляются каждые 9 недель. В конечном итоге, ядро является прочным фундаментом для значительной части вычислительной индустрии, но оно совсем не архаичное. Всё это звучит здорово, но что, если вы захотите разобраться, как оно работает, и, возможно, написать собственный код? Это может показаться сложной задачей, поскольку это область программирования, которую редко затрагивают в учебных заведениях или на курсах. Более того, в отличие от очередного модного JavaScript-фреймворка, который появляется чуть ли не каждый месяц, вы не сможете просто зайти на StackOverflow и найти миллионы постов, чтобы решить все возникающие проблемы.

Итак, интересуетесь созданием проекта «Hello, World» для самого стабильного и продолжительного open-source проекта? Хотите немного окунуться в теорию операционных систем? Нравится программировать на языке, который был создан в 70-х и даёт невероятное чувство удовлетворения, когда ваша программа хоть что-то делает правильно? Отлично, потому что я не могу придумать лучшего способа провести время.

Данная статья это перевод с английского с некоторыми адаптациями. Перевод сделан совместно с автором курса по ”Написание модулей ядра на Linux (Kernel Linux Developer)” Вы можете получить доступ к первому демо-уроку по введению в тему ядер бесплатно у нас на сайте.

Замечание: в этой статье я предполагаю, что вы умеете настраивать виртуальную машину с Ubuntu. В интернете уже полно материалов на эту тему, так что выберите любимый менеджер виртуальных машин и настройте её. Также предполагается, что вы знакомы с языком программирования C, на котором написано ядро Linux. Поскольку мы пишем лишь простой модуль «Hello, World», сложного программирования здесь не будет, но я не буду объяснять основные концепции языка. В любом случае, код должен быть достаточно простым и понятным. С учётом всего вышесказанного, давайте начнём.

Написание базового модуля

Прежде всего, давайте определим, что такое модуль ядра. Обычный модуль также называют драйвером, и он представляет собой что-то вроде API, только между оборудованием и программным обеспечением. В большинстве операционных систем есть два пространства, где происходят вычисления: пространство ядра и пространство пользователя. Linux точно работает таким образом, как и Windows. Пространство пользователя — это то, что касается взаимодействия с пользователем, например, когда вы слушаете музыку на Spotify. Пространство ядра — это всё, что связано с низкоуровневыми внутренними процессами операционной системы. Если вы слушаете музыку на Spotify, сначала должно быть установлено соединение с их серверами, а затем что-то на вашем компьютере должно отслеживать сетевые пакеты, получать данные из них и передавать их на ваши динамики или наушники, чтобы вы могли услышать звук. Всё это происходит в пространстве ядра. Один из драйверов, задействованных здесь, — это программное обеспечение, которое позволяет пакеты, поступающие через ваш сетевой порт, преобразовывать в музыку. Сам драйвер имеет интерфейс, похожий на API, который позволяет пользовательским приложениям (или даже другим приложениям в пространстве ядра) вызывать его функции и получать пакеты.

К счастью, наш модуль не будет настолько сложным, так что не переживайте. Он даже не будет взаимодействовать с оборудованием. Многие модули полностью программные. Хорошим примером может служить планировщик процессов в ядре, который решает, какие ядра вашего процессора в данный момент работают с какими запущенными процессами. Модуль, который работает только с программным обеспечением, — это лучшее место, чтобы начать изучение разработки модулей ядра. Запустите вашу виртуальную машину, откройте терминал, нажав Ctrl+Alt+T, и выполните команду...

sudo apt update && sudo apt upgrade

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

sudo apt install gcc make build-essential libncurses-dev exuberant-ctags

Теперь мы наконец можем приступить к написанию кода. Начнём с простого: поместите следующий код в исходный файл. Я разместил свой файл в папке Documents и назвал его dvt-driver.c.

#include <linux/init.h>
#include <linux/module.h>
#include <linux/uaccess.h>
#include <linux/fs.h>
#include <linux/proc_fs.h>
// Module metadata
MODULE_AUTHOR("Ruan de Bruyn");
MODULE_DESCRIPTION("Hello world driver");
MODULE_LICENSE("GPL");
// Custom init and exit methods
static int __init custom_init(void) {
 printk(KERN_INFO "Hello world driver loaded.");
 return 0;
}
static void __exit custom_exit(void) {
 printk(KERN_INFO "Goodbye my friend, I shall miss you dearly...");
}
module_init(custom_init);
module_exit(custom_exit);

Обратите внимание, что нам не нужны все заголовочные файлы (includes) прямо сейчас, но скоро мы ими воспользуемся. Теперь нужно скомпилировать код. Создайте новый файл с именем Makefile рядом с исходным кодом и добавьте в него следующее содержимое:

obj-m += dvt-driver.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. На этом этапе вы должны увидеть вывод в консоли, который показывает процесс компиляции вашего модуля, и в результате должен появиться файл с именем dvt-driver.ko. Это ваш полностью рабочий, скомпилированный модуль ядра. Давайте загрузим это революционное произведение интеллектуальной собственности в ядро, ведь оно не принесёт нам пользы, просто находясь здесь. В том же каталоге, где находится ваш код, выполните команду:

sudo insmod dvt-driver.ko

…и ваш драйвер должен быть загружен в ядро. Вы можете убедиться в этом, запустив команду lsmod, которая выводит список всех модулей, загруженных в ядро на данный момент. Среди них вы должны увидеть dvt_driver. Обратите внимание, что ядро заменяет дефисы в имени вашего модуля на символы подчёркивания при его загрузке. Если вы хотите удалить модуль, выполните команду:

sudo rmmod dvt_driver

В исходном коде мы также добавили логирование, чтобы убедиться, что наш драйвер загрузился корректно, поэтому выполните команду dmesg в терминале. Эта команда служит для вывода журналов ядра на экран и делает их более удобными для чтения. Последние строки вывода dmesg должны содержать сообщения от вашего драйвера, подтверждающие, что модуль «hello world» загружен и так далее. Обратите внимание, что иногда сообщения функций инициализации (init) и завершения (exit) драйверов появляются с задержкой, но если вы дважды загрузите и выгрузите модуль, все эти сообщения должны быть зарегистрированы. Если вы хотите наблюдать за логами в реальном времени, можно открыть второй терминал и выполнить команду dmesg --follow. Тогда, когда вы будете загружать и выгружать драйвер в первом терминале, вы увидите, как сообщения будут появляться в режиме реального времени.

Снимок экрана 2024-10-15 в 15.53.01.png
Снимок экрана 2024-10-15 в 15.53.01.png

Итак, давайте разберём, что у нас получилось. В исходном коде мы начинаем с добавления метаданных для модуля. Можно обойтись без указания автора и других данных, но лучше всё же вписать своё имя. Компилятор также выдаст предупреждение, если не указать лицензию, а моя патологическая потребность в одобрении заставляет меня всегда добавлять этот код лицензии. Если вас не беспокоят подобные психологические нюансы, всё же стоит отметить, что сопровождающие ядро весьма внимательно следят за тем, чтобы код был открытым, и уделяют особое внимание лицензиям. В прошлом крупные компании лишались возможности добавить проприетарные модули в исходный код ядра. Не будьте как эти ребята. Будьте хорошими. Будьте за открытый исходный код. Используйте открытые лицензии.

Далее мы создаём функции init и exit. Каждый раз, когда модуль загружается в ядро, вызывается его функция init, а при выгрузке вызывается функция exit. В наших функциях нет ничего сложного — они просто записывают текст в журналы ядра. Функция printk() является аналогом классической функции print из языка C. Очевидно, что ядро не имеет терминала или экрана для вывода случайной информации, поэтому printk() выводит сообщения в журналы ядра. Для этого используется макрос KERN_INFO, который логирует общую информацию. Также можно использовать макросы вроде KERN_ERROR для записи сообщений об ошибках, что изменяет формат вывода в dmesg. В любом случае, две функции init и exit регистрируются в последних двух строках исходного кода. Это обязательно, так как драйвер иначе не сможет понять, какие функции нужно выполнять. Вы можете назвать эти функции как угодно, главное — соблюсти ту же сигнатуру (аргументы и возвращаемый тип), что и в примере.

Наконец, рассмотрим Makefile. Многие проекты с открытым исходным кодом используют утилиту GNU Make для компиляции библиотек. Она обычно используется для библиотек, написанных на C/C++, и автоматизирует процесс компиляции. Приведённый здесь Makefile является стандартным способом компиляции модуля. В первой строке к переменной obj-m добавляется ваш файл .o для компиляции. Ядро компилируется аналогичным образом, добавляя множество файлов .o в эту переменную перед сборкой. В следующей строке мы применяем небольшую хитрость. Дело в том, что правила и команды для сборки модулей ядра уже определены в Makefile, который поставляется вместе с ядром. Нам не нужно писать свои правила, мы можем использовать правила ядра, что мы и делаем. В аргументе -C мы указываем путь к корневой директории исходных файлов ядра, а затем говорим Make скомпилировать модули в рабочей директории нашего проекта. Voilà. GNU Make — это невероятно мощный инструмент для компиляции, который можно использовать для автоматизации сборки любого проекта, а не только на C/C++. Если вам интересно узнать больше, вы можете прочитать об этом в книге, которая абсолютно бесплатна (как в смысле пива, так и в смысле свободы слова).

Вход в /proc

Теперь перейдём к основной части этой статьи. Логирование сообщений в ядре — это хорошо, но великие модули создаются не из этого. Ранее в статье я упомянул, что модули ядра обычно работают как API для программ в пользовательском пространстве. Сейчас наш драйвер ничего подобного не делает. Linux использует очень удобный способ взаимодействия с этим: он работает с абстракцией «всё — это файл».

Для демонстрации откройте другой терминал и выполните команду cd /proc. Выполнив ls, вы увидите список файлов. Теперь выполните команду cat modules, и на экран будет выведен текст. Похоже на что-то знакомое? Так и должно быть; все модули, отображаемые в команде lsmod, которую вы запускали ранее, присутствуют и здесь. Попробуем теперь cat meminfo. Теперь на экране будет информация об использовании памяти виртуальной машины. Здорово! Ещё одна команда для выполнения: ls -sh. Это выведет размер каждого файла рядом с его именем, и… стоп, что это за безумие?

image.png
image.png

Размеры всех этих файлов равны 0 байтам. Ничего. И хотя ни один бит не был затрачен на эти файлы, мы только что прочитали их содержимое...? Ну, на самом деле да. Видите ли, /proc — это процессный каталог, своего рода центральное место, откуда программы в пользовательском пространстве получают информацию (а иногда и управляют) модулями ядра. В Ubuntu диспетчер задач называется System Monitor (Системный монитор), его можно запустить, нажав клавишу с логотипом ОС на клавиатуре и введя "system" — после этого должна появиться ссылка на System Monitor. System Monitor показывает такие параметры, как запущенные процессы, использование ЦП, использование памяти и т.д. И всю эту информацию он получает, читая специальные файлы в /proc, такие как meminfo.

Давайте добавим функциональность в наш драйвер, чтобы у нас появилась собственная запись в /proc. Мы сделаем так, чтобы при чтении из неё приложение в пользовательском пространстве выводило сообщение «hello world». Замените весь код под метаданными модуля следующим:

static struct proc_dir_entry* proc_entry;
static ssize_t custom_read(struct file* file, char __user* user_buffer, size_t count, loff_t* offset)
{
 printk(KERN_INFO "calling our very own custom read method.");
 char greeting[] = "Hello world!\\n";
 int greeting_length = strlen(greeting);
 if (*offset > 0)
  return 0;
 copy_to_user(user_buffer, greeting, greeting_length);
 *offset = greeting_length;
 return greeting_length;
}
static struct file_operations fops =
{
 .owner = THIS_MODULE,
 .read = custom_read
};
// Custom init and exit methods
static int __init custom_init(void) {
 proc_entry = proc_create("helloworlddriver", 0666, NULL, &fops);
 printk(KERN_INFO "Hello world driver loaded.");
 return 0;
}
static void __exit custom_exit(void) {
 proc_remove(proc_entry);
 printk(KERN_INFO "Goodbye my friend, I shall miss you dearly...");
}
module_init(custom_init);
module_exit(custom_exit);

Теперь удалите драйвер из ядра, перекомпилируйте его и загрузите новый модуль .ko в ядро. Выполните команду cat /proc/helloworlddriver, и вы должны увидеть, что наш драйвер выводит приветствие «hello world» в терминал. Очень круто, если спросите меня. Но, увы, команда cat — это слишком простой способ для того, чтобы по-настоящему продемонстрировать, что мы делаем. Поэтому давайте напишем собственное приложение для пользовательского пространства, которое будет взаимодействовать с этим драйвером. Поместите следующий код на Python в скрипт в любом каталоге (я назвал свой файл hello.py):

kernel_module = open('/proc/helloworlddriver')
greeting = kernel_module.readline();
print(greeting)
kernel_module.close()

Этот код должен быть вполне понятным сам по себе, и, как вы можете видеть, это именно тот способ, которым вы бы выполняли операции ввода-вывода с файлами в любом языке программирования. Файл /proc/helloworlddriver — это наш интерфейс (API) к только что созданному нами модулю ядра. Если вы запустите команду python3 hello.py, то должны увидеть, как наш скрипт выводит приветствие в терминал. Здорово, не так ли?

image.png
image.png

В нашем коде мы создали пользовательскую функцию чтения. Как вы могли догадаться, можно также переопределить функцию записи, если ваш модуль требует ввода из пользовательского пространства. Например, если у вас есть драйвер, который контролирует скорость вращения вентиляторов вашего компьютера, можно создать функцию записи, которая принимает число в процентах (от 0 до 100) и на основе этого корректирует скорость вращения. Если вас интересует, как на самом деле работает это переопределение функций, прочтите следующий раздел. Если нет, можете смело переходить к концу статьи.

Бонусный раздел — Как это вообще работает?

В этом разделе я подумал, что некоторым из вас может быть интересно, как именно работает переопределение функций чтения/записи для записи в /proc. Для этого нам придётся погрузиться немного в теорию операционных систем, и я приведу аналогию с ассемблером.

В ассемблере ваша программа использует "стек" для отслеживания переменных, которые создаются во время выполнения. Это немного отличается от классического стека в информатике, поскольку вы можете не только помещать данные (push) и извлекать их (pop), но также получать доступ к произвольным элементам стека, а не только к верхнему элементу, и изменять/читать их. Итак, допустим, вы определяете функцию с двумя аргументами в ассемблере. Вы не просто передаёте эти переменные при вызове функции — нет, сэр. Передача переменных в скобках — это для тех, кто копирует код для чат-бота на Python из онлайн-уроков. Ассемблерные программисты занимались отправкой Apollo 11 на Луну. Здесь без усилий не обойтись. Прежде чем вызвать функцию с двумя аргументами, вам нужно сначала поместить аргументы в стек. Затем вы вызываете функцию, которая обычно читает аргументы из стека в обратном порядке и использует их по необходимости. Много потенциальных ошибок, поскольку можно легко передать аргументы в неправильном порядке, и тогда функция будет считывать их как бессмысленные данные.

Я упоминаю это, потому что ваша операционная система тоже имеет подобные методы выполнения кода. У неё есть свой стек для отслеживания переменных, и когда ядро вызывает функцию ОС, оно ищет аргументы на вершине стека и затем выполняет код. Если вы хотите прочитать файл с диска, вызывается функция чтения с несколькими аргументами, эти аргументы помещаются в стек ядра, и затем вызывается функция чтения, которая извлекает файл (или его части) с диска. Ядро хранит информацию обо всех своих функциях в огромной таблице, где содержатся имена функций и адреса в памяти, где они находятся. И вот здесь в дело вступают наши собственные функции. Даже несмотря на то, что взаимодействие с нашим модулем происходит через файлы, нет жёсткого правила, по которому при чтении этого файла обязательно вызывается стандартная функция чтения. Функция чтения — это просто адрес в памяти, хранящийся в таблице. Мы можем переопределить, какая функция в памяти вызывается, когда программа из пользовательского пространства читает запись /proc нашего модуля, и именно это мы и делаем! В структуре file_operations мы назначаем атрибут .read нашей функции custom_read и затем регистрируем запись /proc с этой функцией. Когда функция чтения вызывается из пользовательского приложения на Python, может показаться, что вы читаете файл с диска, и все аргументы передаются правильно в стек ядра, но в последний момент вместо этого вызывается наша функция custom_read через её адрес в памяти, о котором мы сообщили ядру. Это работает потому, что наша функция custom_read принимает точно такие же аргументы, как и функция чтения файла с диска, поэтому правильные аргументы читаются из стека ядра в правильном порядке.

Важно помнить, что приложения из пользовательского пространства будут считать нашу запись в /proc обычным файлом на диске и будут работать с ним соответственно. Ответственность за корректное взаимодействие лежит на нас. Наш модуль должен вести себя как обычный файл на диске, даже если это не так. Большинство языков программирования читают файлы по частям. Допустим, эти части составляют по 1024 байта. Вы прочитываете первые 1024 байта файла в буфер, который будет содержать байты с 0 по 1023. Функция чтения вернёт 1024, сообщая, что 1024 байта были успешно прочитаны. Затем считываются следующие 1024 байта, и буфер содержит байты с 1024 по 2047. В конце концов, мы достигаем конца файла. Возможно, последний блок данных запросит 1024 байта, но останется только 800. В таком случае функция чтения вернёт 800, и эти последние 800 байт будут записаны в буфер. Наконец, функция чтения попытается прочитать ещё один блок данных, но содержимое файла уже полностью прочитано, и тогда функция вернёт 0. Когда это происходит, язык программирования понимает, что файл достигнут до конца, и прекращает попытки его читать.

Если вы посмотрите на аргументы нашей функции custom_read, вы, вероятно, увидите, как это происходит. Структура file представляет файл, который наша программа читает (хотя эта структура доступна только в пространстве ядра, но это не важно для этой статьи). Последние аргументы — это буфер, количество байт и смещение. Буфер — это буфер в пространстве пользователя, который, по сути, содержит адрес массива, в который мы записываем байты. Count — это размер блока данных, а offset — это точка в файле, с которой мы начинаем читать блок данных. Давайте рассмотрим, что произойдёт, когда мы читаем из нашего модуля. Мы возвращаем строку «Hello world!» в пользовательское пространство. Вместе с символом новой строки это 13 символов, что легко поместится в любой размер блока. Когда мы попытаемся прочитать запись в /proc, это будет выглядеть так: мы читаем первый блок данных, записываем приветствие в буфер и возвращаем 13 (длину нашей строки приветствия) в приложение, так как было прочитано 13 байт. Затем следующий блок данных начнёт чтение со смещением 13, что является «концом» нашего файла (у нас больше нет данных для передачи), поэтому мы вернём 0. Логика в нашей функции custom_read отражает это. Если переданное смещение больше 0, это означает, что мы уже передали наше приветствие, и просто возвращаем 0. В противном случае мы копируем строку приветствия в буфер и обновляем смещение.

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

Заключение

Спасибо, что прочитали эту статью. Надеюсь, она оказалась для вас достаточно интересной, чтобы вы начали исследовать ядро самостоятельно. Хотя мы использовали виртуальную машину в этой статье, знание того, как писать модули ядра, является обязательным, если вы когда-нибудь будете писать код для встроенных систем (например, устройств IoT). Если это ваш случай или вы хотите узнать больше о разработке ядра, обратитесь к сайту InzhenerkaTech, особенно к этому вводному уроку. Также существует множество книг на эту тему, но перед покупкой обратите внимание на дату публикации — выбирайте более свежие издания. Как бы там ни было, вы, вероятно, только что написали свой первый модуль ядра Linux, так что гордитесь собой и удачи в кодинге!

Комментарии (4)


  1. kenomimi
    15.10.2024 14:13

    Про подписание модулей забыли. Сейчас много где неподписаные модули не загружаются.



  1. CitizenOfDreams
    15.10.2024 14:13

    У вас есть микроволновка? Скорее всего, она работает на ядре Linux.

    Нет. Полноценно реализовать UI моей микроволновки на Линуксе будет слишком сложно.

    Скрытый текст


  1. EHOT_B_TPABE
    15.10.2024 14:13

    Я наверное криворукий, но при попытке создать dvt-driver.ko команда make выдаёт в поток ошибок "Makefile:4: *** пропущен разделитель. Останов.".