Записки программиста
Основы написания модулей ядра в Linux
Вопрос ковыряния ядра Linux впервые поднимался в этом блоге еще в далеком 2016-м году. Мы научились собирать ядро из исходников и цепляться к нему отладчиком. Но на этом все и заглохло. Тогда найти актуальную информацию по разработке ядерного кода в Linux, да еще и в удобоваримом виде, было проблемой. Я предпочел дождаться появления свежих книг по теме, а пока заняться изучением чего-то другого. И вот, спустя пять лет, такие книги были опубликованы. В связи с чем я решил попробовать написать пару модулей ядра, и посмотреть, как пойдет.
Проводить эксперименты было решено на Raspberry Pi 3 Model B+. На то есть три основные причины. Во-первых, малинка широко доступна и стоит недорого (особенно третья малинка, после выхода четвертой), что делает эксперименты повторяемыми. Во-вторых, запускать модули ядра на той же машине, где вы их разрабатываете, в любом случае не лучшая затея. Ведь ошибка в ядерном коде может приводить к какими угодно последствиям, не исключая повреждения ФС. И в-третьих, в отличие от виртуальной машины, малинка не отъедает ресурсы на вашей основной системе и позволяет реально взаимодействовать с реальным железом.
Образ системы был записан на SD-карту при помощи Raspberry Pi Imager. Приложение использовало образ на основе Raspbian 10 с ядром Linux 5.10. Это LTS-версия ядра, поддержка которого прекратится в декабре 2026-го года.
Для написания модулей ядра необходимо установить пакет с заголовочными файлами. В Raspbian это делается так:
В других системах пакет может называться linux-headers-* или как-то иначе.
Создадим новую директорию с файлом hello.c:
int init_module ( void ) <
pr_info ( «Hello world \n » ) ;
return 0 ;
>
void cleanup_module ( void ) <
pr_info ( «Goodbye world \n » ) ;
>
Рядом положим файл Makefile:
Теперь попробуем следующие команды:
# посмотреть информацию о модуле
modinfo hello.ko
# загрузить модуль
sudo insmod hello.ko
# список загруженных модулей ядра
lsmod | grep hello
# выгрузить модуль
sudo rmmod hello
# почитать логи
tail / var / log / syslog
А вот printk() уже является экспортируемым символом:
Далее, рассмотрим модуль посложнее:
MODULE_LICENSE ( «GPL» ) ;
MODULE_AUTHOR ( «Aleksander Alekseev» ) ;
MODULE_DESCRIPTION ( «A simple driver» ) ;
static char * name = «%username%» ;
module_init ( init ) ;
module_exit ( cleanup ) ;
Из этого примера мы узнаем ряд важных вещей. Во-первых, что процедуры, вызываемые при загрузке и выгрузке модуля, могут называться как угодно. Во-вторых, что в модуле можно указать не только его лицензию, но также автора и краткое описание. Сравните вывод modinfo для этого модуля и предыдущего. И в-третьих, модуль может принимать параметры:
В логах мы предсказуемо увидим:
Параметры, переданные модулю, видны через sysfs. Но чтобы это работало, код нужно немного изменить:
Если теперь пересобрать модуль, то можно сделать так:
Думаю, что для первого раза удивительных открытий достаточно. Полная версия кода доступна в этом репозитории на GitHub. Там же есть список дополнительных материалов для самостоятельного изучения.
Пишем модуль ядра Linux: I2C
Данная статья посвящена разработке I2C (Inter-Integrated Circuit) модуля ядра Linux. Далее описан процесс реализация базовой структуры I2C драйвера, в которую можно легко добавить реализацию необходимого функционала.
Опишем входные данные: I2C блок для нового процессора «зашитый» на ПЛИС, запущенный Linux версии 3.18.19 и периферийные устройства (EEPROM AT24C64 и BME280).
Принцип работы I2C достаточно прост, но если нужно освежить знания, то можно почитать тут.
Рисунок 1. Временная диаграмма сигналов шины I2C
Перед тем как начать разрабатывать драйвер посмотрим как user space приложения взаимодействуют с модулем ядра, для этого:
Шаг первый
Для начала познакомимся с утилитой i2cdetect. Результат работы i2cdetect выглядит следующим образом:
Утилита последовательно выставляет на I2C шину адреса устройств и при получении положительного ответа (в данном случае положительным ответом является ACK) выводит в консоль номер адреса устройства на шине.
Напишем небольшую программку, которая считывает уникальный ID датчика температуры, и выведем результат ее работы в консоль. Выглядит очень просто:
Становятся понятно что модуль ядра принимает данные в виде полей сообщения i2c_rdwr_ioctl_data. Структура содержит такие поля как i2c_msg и nmsgs, которые используется для передачи:
Шаг второй
Теперь, не углубляюсь во внутренности, познакомимся с одним вариантом работы I2C драйвера.
Как уже было установлено, модуль ядра получает сообщения в виде структуры. Для примера рассмотрим алгоритм работы драйвера при выполнении операции записи (аппаратно-зависимая часть):
Шаг третий
Добавим модуль ядра в сборку и опишем аппаратную часть устройств в device tree:
1. Создадим source файл в следующей директории:
В результате появится файл:
2. Добавим конфигурацию драйвера в drivers/i2c/busses/Kconfig:
3. Добавим в сборку драйвер drivers/i2c/busses/Makefile:
4. Добавим в devicetree (*.dts) описание I2C блока, а также сразу поддержу eeprom устройства:
Подробно рассматриваться выше перечисленные шаги не будут, но любопытным читателям можно заглянуть сюда.
Шаг четвертый
После ознакомления с принципом работы драйвера приступим к реализации.
Сначала подключим заголовочные файлы, опишем «виртуальную» регистровую карту, а также представление драйвера I2C.
Главными управляющими регистрами контроллера являются:
Из названий структур и функций очевидно их назначение, опишем только главную структуру из представленных выше:
Наиболее простой функцией является skel_i2c_remove, которая отключает источник тактовой частоты и освобождает используемую память. Функция skel_i2c_init выполняет первичную инициализацию I2C контроллера.
Как упоминалось ранее skel_i2c_probe регистрирует драйвер в системе. Последовательность действий, условно, можно разделить на два этапа:
В первом шаге было описано взаимодействие user space приложения с модулем ядра системы. После того как мы реализовали внутренности драйвера легко увидеть интерфейс, через который происходит обмен. В общем случае передача сообщений происходит следующем образом:
Полный скелет драйвера прикреплен ниже. Пожалуйста, если вы нашли ошибки/неточности, или вам есть что добавить — напишите в ЛС или в комментарии.
Пишем простой модуль ядра Linux
Захват Золотого Кольца-0
Linux предоставляет мощный и обширный API для приложений, но иногда его недостаточно. Для взаимодействия с оборудованием или осуществления операций с доступом к привилегированной информации в системе нужен драйвер ядра.
Модуль ядра Linux — это скомпилированный двоичный код, который вставляется непосредственно в ядро Linux, работая в кольце 0, внутреннем и наименее защищённом кольце выполнения команд в процессоре x86–64. Здесь код исполняется совершенно без всяких проверок, но зато на невероятной скорости и с доступом к любым ресурсам системы.
Не для простых смертных
Написание модуля ядра Linux — занятие не для слабонервных. Изменяя ядро, вы рискуете потерять данные. В коде ядра нет стандартной защиты, как в обычных приложениях Linux. Если сделать ошибку, то повесите всю систему.
Ситуация ухудшается тем, что проблема необязательно проявляется сразу. Если модуль вешает систему сразу после загрузки, то это наилучший сценарий сбоя. Чем больше там кода, тем выше риск бесконечных циклов и утечек памяти. Если вы неосторожны, то проблемы станут постепенно нарастать по мере работы машины. В конце концов важные структуры данных и даже буфера могут быть перезаписаны.
Можно в основном забыть традиционные парадигмы разработки приложений. Кроме загрузки и выгрузки модуля, вы будете писать код, который реагирует на системные события, а не работает по последовательному шаблону. При работе с ядром вы пишете API, а не сами приложения.
У вас также нет доступа к стандартной библиотеке. Хотя ядро предоставляет некоторые функции вроде printk (которая служит заменой printf ) и kmalloc (работает похоже на malloc ), в основном вы остаётесь наедине с железом. Вдобавок, после выгрузки модуля следует полностью почистить за собой. Здесь нет сборки мусора.
Необходимые компоненты
Прежде чем начать, следует убедиться в наличии всех необходимых инструментов для работы. Самое главное, нужна машина под Linux. Знаю, это неожиданно! Хотя подойдёт любой дистрибутив Linux, в этом примере я использую Ubuntu 16.04 LTS, так что в случае использования других дистрибутивов может понадобиться слегка изменить команды установки.
Во-вторых, нужна или отдельная физическая машина, или виртуальная машина. Лично я предпочитаю работать на виртуальной машине, но выбирайте сами. Не советую использовать свою основную машину из-за потери данных, когда сделаете ошибку. Я говорю «когда», а не «если», потому что вы обязательно подвесите машину хотя бы несколько раз в процессе. Ваши последние изменения в коде могут ещё находиться в буфере записи в момент паники ядра, так что могут повредиться и ваши исходники. Тестирование в виртуальной машине устраняет эти риски.
И наконец, нужно хотя бы немного знать C. Рабочая среда C++ слишком велика для ядра, так что необходимо писать на чистом голом C. Для взаимодействия с оборудованием не помешает и некоторое знание ассемблера.
Установка среды разработки
На Ubuntu нужно запустить:
Устанавливаем самые важные инструменты разработки и заголовки ядра, необходимые для данного примера.
Примеры ниже предполагают, что вы работаете из-под обычного пользователя, а не рута, но что у вас есть привилегии sudo. Sudo необходима для загрузки модулей ядра, но мы хотим работать по возможности за пределами рута.
Начинаем
Приступим к написанию кода. Подготовим нашу среду:
Запустите любимый редактор (в моём случае это vim) и создайте файл lkm_example.c следующего содержания:
Мы сконструировали самый простой возможный модуль, рассмотрим подробнее самые важные его части:
Теперь можно внедрить модуль и проверить его. Для этого запускаем:
Если всё нормально, то вы ничего не увидите. Функция printk обеспечивает выдачу не в консоль, а в журнал ядра. Для просмотра нужно запустить:
Вы должны увидеть строку “Hello, World!” с меткой времени в начале. Это значит, что наш модуль ядра загрузился и успешно сделал запись в журнал ядра. Мы можем также проверить, что модуль ещё в памяти:
Для удаления модуля запускаем:
Если вы снова запустите dmesg, то увидите в журнале запись “Goodbye, World!”. Можно снова запустить lsmod и убедиться, что модуль выгрузился.
Как видите, эта процедура тестирования слегка утомительна, но её можно автоматизировать, добавив:
в конце Makefile, а потом запустив:
для тестирования модуля и проверки выдачи в журнал ядра без необходимости запускать отдельные команды.
Теперь у нас есть полностью функциональный, хотя и абсолютно тривиальный модуль ядра!
Немного интереснее
Копнём чуть глубже. Хотя модули ядра способны выполнять все виды задач, взаимодействие с приложениями — один из самых распространённых вариантов использования.
Поскольку приложениям запрещено просматривать память в пространстве ядра, для взаимодействия с ними приходится использовать API. Хотя технически есть несколько способов такого взаимодействия, наиболее привычный — создание файла устройства.
В нашем примере мы возвращаем “Hello, World”. Хотя это не особенно полезная функция для приложений, она всё равно демонстрирует процесс взаимодействия с приложением через файл устройства.
Вот полный листинг:
Тестирование улучшенного примера
Теперь наш пример делает нечто большее, чем просто вывод сообщения при загрузке и выгрузке, так что понадобится менее строгая процедура тестирования. Изменим Makefile только для загрузки модуля, без его выгрузки.
Теперь после запуска make test вы увидите выдачу старшего номера устройства. В нашем примере его автоматически присваивает ядро. Однако этот номер нужен для создания нового устройства.
(в этом примере замените MAJOR значением, полученным в результате выполнения make test или dmesg )
Параметр c в команде mknod говорит mknod, что нам нужно создать файл символьного устройства.
Теперь мы можем получить содержимое с устройства:
или даже через команду dd :
Вы также можете получить доступ к этому файлу из приложений. Это необязательно должны быть скомпилированные приложения — даже у скриптов Python, Ruby и PHP есть доступ к этим данным.
Когда мы закончили с устройством, удаляем его и выгружаем модуль:
Заключение
Надеюсь, вам понравились наши шалости в пространстве ядра. Хотя показанные примеры примитивны, эти структуры можно использовать для создания собственных модулей, выполняющих очень сложные задачи.
Просто помните, что в пространстве ядра всё под вашу ответственность. Там для вашего кода нет поддержки или второго шанса. Если делаете проект для клиента, заранее запланируйте двойное, если не тройное время на отладку. Код ядра должен быть идеален, насколько это возможно, чтобы гарантировать цельность и надёжность систем, на которых он запускается.
The Linux Kernel Module Programming Guide
Авторы: Peter Jay Salzman, Michael Burian, Ori Pomerantz
Copyright 2001, Peter Jay Salzman.
Перевод: Андрей Киселёв (kis_an [at] linuxgazette [dot] ru)
Оригинальная версия была опубликована на сайте проекта The Linux Documentation Project.
Эта книга распространяется в надежде на то, что она будет вам полезна, но без каких-либо гарантий, в том числе и без подразумеваемых гарантий высокого спроса или пригодности для специфических целей.
Авторы приветствуют широкое распространение этой книги как для персонального, так и для коммерческого пользования, при условии соблюдения вышеупомянутого примечания относительно авторских прав, а так же при условии, что распространитель твердо придерживается условий Open Software License. Вы можете копировать и распространять эту книгу как бесплатно, так и с целью получения прибыли. От авторов не требуется никакого явного разрешения для воспроизводства этой книги на любом носителе, будь то твердая копия или электронная.
Производные работы и переводы этого документа должны размещаться на условиях Open Software License, а первоначальное примечание об авторских правах должно остаться нетронутым. Если вы добавили новый материал в эту книгу, то вам следует сделать его общедоступным. Пожалуйста извещайте руководителя проекта (Peter Jay Salzman ) о внесенных изменениях и дополнениях. Он объединит модификации и обеспечит непротиворечивость изменений документа.
Если Вы планируете издавать и распространять эту книгу на коммерческой основе, пожертвования, лицензионные отчисления и/или печатные копии будут высоко оценены автором и The Linux Documentation Project. Таким образом вы окажете поддержку свободному программному обеспечению и LDP. Если у вас появятся вопросы или предложения, пожалуйста пишите руководителю проекта по адресу, указанному выше.
Предисловие
1. Об авторах
Эта книга изначально была написана Ори Померанцем (Ori Pomerantz) для ядра Linux версии 2.2. К сожалению у Ори не хватает времени на продолжение работы над этой книгой. В конце концов ядро Linux продолжает быстро развиваться. Питер Зальцман (Peter Jay Salzman) взял на себя труд по адаптации документа для ядра версии 2.4. К сожалению и Питер не нашел достаточно свободного времени, чтобы продолжить работу над книгой и дополнить ее материалами, касающимися ядра версии 2.6. Таким образом, майкл Бариан (Michael Burian) взялся за адаптацию материала книги для ядра 2.6.
2. Нумерация версий и дополнительные примечания
3. Благодарности
Ори Померанц выражает свою благодарность Yoav Weiss, за многочисленные предложения, обсуждение материала и внесение исправлений. Он так же хотел бы поблагодарить Фродо Лоойаарда из Нидердандов, Стефана Джадда из Новой Зеландии, Магнуса Альторпа из Швеции и Эммануэль Папиракис из Квебека, Канада.
Питер выражает свою благодарность Ори, за то что позволил ему принять участие в проекте LKMPG. А так же Джеффа Ньюмиллера, Ронду Франчес Бэйли (ныне Ронда Франчес Зальцман) и Марка Кима за науку и долготерпение. Он так же хотел бы поблагодарить Дэвида Портера, который оказал неоценимую помощь в переводе документа из формата LaTeX в формат docbook. Это была тяжелая и нудная работа, но ее необходимо было сделать.
И Ори и я хотели бы сказать слова благодарности в адрес Ричарда М. Столлмана и Линаса Торвальдса не только за эту превосходную операционную систему, но и за то, что позволили исследовать ее исходный код, чтобы разобраться в том, как она работает.
Хочется выразить благодарность тем, кто внес свои исправления в документ или внес свои предложения по улучшению. Это Игнасио Мартин, Дэвид Портер, Дэниэл Паоло Скарпацца и Димо Велев.
Глава 1. Введение.
1.1. Что такое «Модуль Ядра»?
Итак, Вы хотите писать модули ядра. Вы знакомы с языком C и у вас есть опыт создания обычных программ, а теперь вы хотите забраться туда, где свершается великое таинство. Туда, где один ошибочный указатель может «снести» файловую систему или «подвесить» компьютер.
1.2. Как модули попадают в ядро?
Вы можете просмотреть список загруженных модулей командой lsmod, которая в свою очередь обращается за необходимыми сведениями к файлу /proc/modules.
Как же модули загружаются ядром? Когда ядро обнаруживает необходимость в тех или иных функциональных возможностях, еще не загруженных в память, то демон kmod [1] вызывает утилиту modprobe, передавая ей строку в виде:
Название модуля, например softdog или ppp.
Универсальный идентификатор, например char-major-10-30.
Это соответствует утверждению: «Данному универсальному идентификатору соответствует файл модуля softdog.ko«.
В большинстве дистрибутивов Linux, утилиты modprobe, insmod, depmod входят в состав пакета modutils или mod-utils.
Прежде чем закончить эту главу, я предлагаю вкратце ознакомиться с содержимым файла /etc/modules.conf:
Строки, начинающиеся с символа «#» являются комментариями. Пустые строки игнорируются.
Строка path[misc] сообщает modprobe о том, что модули ядра из категории misc следует искать в каталоге /lib/modules/2.6.?/local. Как видите, здесь вполне допустимы шаблонные символы.
Строка path[net] задает каталог размещения модулей категории net, однако, директива keep, стоящая выше, сообщает, что каталог
p/mymodules не замещает стандартный путь поиска модулей (как это происходит в случае с path[misc]), а лишь добавляется к нему.
Строка alias говорит о том, что если запрошена загрузка модуля по универсальному идентификатору eth0, то следует загружать модуль eepro.ko
Вы едва ли встретите в этом файле строки, подобные:
поскольку modprobe уже знает о существовании стандартных драйверов устройств, которые используются в большинстве систем.
1.2.1. Прежде, чем продолжить
Прежде, чем мы приступим к программированию, необходимо обсудить еще ряд моментов. Любая система имеет свои отличительные черты и каждый из нас имеет разный багаж знаний. Написать, скомпилировать и запустить свою первую программу «Hello World!» для многих может оказаться довольно сложной задачей. Однако, после преодоления этого начального препятствия, работа, как правило, продвигается без особых проблем.
1.2.1.1. Механизм контроля версий
Модули, скомпилированные с одним ядром, могут не загружаться другим ядром, если в ядре включен механизм проверки версий модулей. В большинстве дистрибутивов ядро собирается с такой поддержкой. Мы не собираемся обсуждать проблему контроля версий в этой книге, поэтому нам остается порекомендовать, в случае возникновения проблем, пересобрать ядро без поддержки механизма контроля версий.
1.2.1.2. Работа в XWindow
Мы настоятельно рекомендуем скачать и опробовать все примеры, обсуждаемые в книге. Кроме того, мы настаиваем на том, чтобы всю работу, связанную с редактированием исходных текстов, компиляцией и запуском модулей, вы выполняли из текстовой консоли. Поверьте нашему опыту, XWindow не подходит для выполнения подобных задач.
Модули не могут использовать функцию printf() для вывода не экран, но они могут регистрировать сообщения об ошибках, которые в конечном итоге попадают на экран, но только в текстовой консоли. Если же модуль загружается из окна терминала, например xterm, то эти сообщения будут попадать только в системный журнал и не будут выводиться на экран. Чтобы видеть выводимые сообщения на экране, работайте в текстовой консоли ( от переводчика: при опробовании примеров из книги мне не удалось вывести ни одного сообщения на экран, так что ищите ваши сообщения в системном журнале, в моем случае это был файл /var/log/kern.log ).
1.2.1.3. Проблемы компиляции
Зачастую, дистрибутивостроители распространяют исходные тексты ядра, на которые уже наложены разные нестандартные заплаты. Это может породить определенные проблемы.
Чтобы избежать этих двух проблем, мы рекомендуем собрать и установить наиболее свежее ядро. Скачать исходные тексты ядра вы сможете на любом из зеркал, распространяющих ядро Linux. За более подробной информацией обращайтесь к «Kernel HOWTO».
Глава 2. Hello World
2.1. «Hello, World» (часть 1): Простейший модуль ядра.
Ниже приводится исходный текст самого простого модуля ядра, какой только возможен. Пока только ознакомьтесь с его содержимым, а компиляцию и запуск модуля мы обсудим в следующем разделе.
Пример 2-1. hello-1.c
Обычно функция init_module() выполняет регистрацию обработчика какого-либо события или замещает какую-либо функцию в ядре своим кодом (который, как правило, выполнив некие специфические действия, вызывает оригинальную версию функции в ядре). Функция cleanup_module() является полной противоположностью, она производит «откат» изменений, сделаных функцией init_module(), что делает выгрузку модуля безопасной.
2.1.1. Знакомство с printk()
Если задан уровень ниже, чем int console_loglevel, то сообщение выводится на экран. Если запущены и syslog, и klogd, то сообщение попадет также и в системный журнал /var/log/messages, при этом оно может быть выведено на экран, а может и не выводиться. Мы использовали достаточно высокий уровень приоритета KERN_ALERT для того, чтобы гарантировать вывод сообщения на экран функцией printk(). Когда вы вплотную займетесь созданием модулей ядра, вы будете использовать уровни приоритета наиболее подходящие под конкретную ситуацию.
2.2. Сборка модулей ядра
Чтобы модуль был работоспособен, при компиляции необходимо передать gcc ряд опций. Кроме того, необходимо чтобы модули компилировались с предварительно определенными символами. Ранние версии ядра полностью полагались, в этом вопросе, на программиста и ему приходилось явно указывать требуемые определения в Makefile-ах. Несмотря на иерархическую организацию, в Makefile-ах, на вложенных уровнях, накапливалось такое огромное количество параметров настройки, что управление и сопровождение этих настроек стало довольно трудоемким делом. К счастью появился kbuild, в результате процесс сборки внешних загружаемых модулей теперь полностью интегрирован в механизм сборки ядра. Дополнительные сведения по сборке модулей, которые не являются частью официального ядра (как в нашем случае), вы найдете в файле linux/Documentation/kbuild/modules.txt.
Пример 2-2. Makefile для модуля ядра
Любой загруженный модуль ядра заносится в список /proc/modules, так что дружно идем туда и смотрим содержимое этого файла. как вы можете убедиться, наш модуль стал частью ядра. С чем вас и поздравляем, теперь вы стали одним из авторов кода ядра! Вдоволь насладившись ощущением новизны, выгрузите модуль командой rmmod hello-1 и загляните в файл /var/log/messages, здесь вы увидите сообщения, которые сгенерировал ваш модуль. ( от переводчика: в моем случае на экран консоли сообщения не выводились, зато они появились в файле /var/log/kern.log ).
2.3. Hello World (часть 2)
Как мы уже упоминали, начиная с ядра, версии 2.3.13, требования к именованию начальной и конечной функций модуля были сняты. Достигается это с помощью макроопределений module_init() и module_exit(). Они определены в файле linux/init.h. Единственное замечание: начальная и конечная функции должны быть определены выше строк, в которых вызываются эти макросы, в противном случае вы получите ошибку времени компиляции. Ниже приводится пример использования этих макроопределений:
Пример 2-3. hello-2.c
Теперь мы имеем в своем багаже два настоящих модуля ядра. Добавить сборку второго модуля очень просто:
Пример 2-4. Makefile для сборки обоих модулей
2.4. Hello World (часть 3): Макроопределения __init и __exit
Это демонстрация особенностей ядра, появивишихся, начиная с версии 2.2. Обратите внимание на то, как изменились определения функций инициализации и завершения работы модуля. Макроопределение __init вынуждает ядро, после выполнения инициализации модуля, освободить память, занимаемую функцией, правда относится это только к встроенным модулям и не имеет никакого эффекта для загружаемых модулей. Если вы мысленно представите себе весь процесс инициализации встроенного модуля, то все встанет на свои места.
То же относится и к макросу __initdata, но только для переменных.
Оба этих макроса определены в файле linux/init.h и отвечают за освобождение неиспользуемой памяти в ядре. Вам наверняка приходилось видеть на экране, во аремя загрузки, сообщение примерно такого содержания: Freeing unused kernel memory: 236k freed. Это как раз и есть результат работы данных макроопределений.
Пример 2-5. hello-3.c
2.5. Hello World (часть 4): Вопросы лицензирования и документирования модулей
Если у вас установлено ядро 2.4 или более позднее, то наверняка, во время запуска примеров модулей, вам пришлось столкнуться с сообщениями вида:
В ядра версии 2.4 и выше был добавлен механизм контроля лицензий, чтобы иметь возможность предупреждать пользователя об использовании проприетарного (не свободного) кода. Задать условия лицензирования модуля можно с помощью макроопределения MODULE_LICENSE(). Ниже приводится выдержка из файла linux/module.h ( от переводчика: я взял на себя смелость перевести текст комментариев на русский язык ):
Все эти макроопределения описаны в файле linux/module.h. Они не используются ядром и служат лишь для описания модуля, которое может быть просмотрено с помощью objdump. Попробуйте с помощью утилиты grep посмотреть, как авторы модулей используют эти макросы (в каталоге linux/drivers).
Пример 2-6. hello-4.c
2.6. Передача модулю параметров командной строки
Имеется возможность передачи модулю дополнительных параметров командной строки, но делается это не с помощью argc/argv.
Для начала вам нужно объявить глобальные переменные, в которые будут записаны входные параметры, а затем вставить макрос MODULE_PARAM(), для запуска механизма приема внешних аргументов. Значения параметров могут быть переданы модулю с помощью команд insmod или modprobe. Например: insmod mymodule.ko myvariable=5. Для большей ясности, объявления переменных и вызовы макроопределений следует размещать в начале модуля. Пример кода прояснит мое, по общему признанию, довольно неудачное объяснение.
Макрос MODULE_PARAM() принимает 2 аргумента: имя переменной и ее тип. Поддерживаются следующие типы переменных
Желательно, чтобы все входные параметры модуля имели значения по-умолчанию, например адреса портов ввода-вывода. Модуль может выполнять проверку переменных на значения по-умолчанию и если такая проверка дает положительный результат, то переходить к автоматическому конфигурированию (вопрос автонастройки будет обсуждаться ниже).
Пример 2-7. hello-5.c
Давайте немножко поэкспериментируем с этим модулем:
2.7. Модули, состоящие из нескольких файлов
Иногда возникает необходимость разместить исходные тексты модуля в нескольких файлах. В этом случае kbuild опять возьмет на себя всю «грязную» работу, а Makefile поможет сохранить наши руки чистыми, а голову светлой! Ниже приводится пример модуля, состоящего из двух файлов:
Пример 2-8. start.c
Пример 2-9. stop.c
Пример 2-10. Makefile для сборки всех модулей
2.8. Сборка модулей под существующее ядро
Как бы то ни было, но ситуация может сложиться так, что вам потребуется загрузить модуль в ранее откомпилированное ядро, например, на другой системе, или в случае, когда вы не можете пересобрать ядро по каким либо соображениям. Если вы не предполагаете возникновение таких ситуаций, то можете просто пропустить эту часть главы.
Если вы лишь установили дерево с исходными текстами ядра и использовали их для сборки своего модуля, то в большинстве случаев, при попытке загрузить его в работающее ядро, вы получите следующее сообщение об ошибке:
Более подробная информация будет помещена в файл /var/log/messages:
Прежде всего вам необходимо установить дерево с исходными текстами ядра той же версии, что и целевое ядро. Найдите файл конфигурации целевого ядра, как правило он располагается в каталоге /boot, под именем, что-то вроде config-2.6.x. Просто скопируйте его в каталог с исходными текстами ядра на своей машине.
Вернемся к сообщению об ошибке, которое было приведено выше, и еще раз внимательно прочитаем его. Как видите, версия ядра практически та же самая, но даже небольшого отличия хватило, чтобы ядро отказалось загружать модуль. Все различие заключается лишь в наличии слова custom в сигнатуре версии модуля. Теперь откройте Makefile ядра и удостоверьтесь, что информация о версии в точности соответствует целевому ядру. Например, в данном конкретном случае Makefile должен содержать строки
Теперь запустите make, чтобы обновить информацию о версии:
Если вы не желаете полностью пересобирать ядро, то можете прервать процесс сборки (CTRL_C) сразу же после появления строки, начинающейся со слова SPLIT, поскольку в этот момент все необходимые файлы уже будут готовы. Перейдем в каталог с исходными текстами модуля и скомпилируем его. Теперь сигнатура версии модуля будет в точности соответствовать версии целевого ядра и будет загружено им без каких либо проблем.
Глава 3. Дополнительные сведения
3.1. Модули ядра и прикладные программы
Работа программы обычно начинается с исполнения функции main(). После выполнения всей последовательность команд программа завершает свою работу. Модули исполняются иначе. Они всегда начинают работу с исполнения функции init_module, или с функции, которую вы определили через вызов module_init. Это функция запуска модуля, которая подготавливает его для последующих вызовов. После завершения исполнения функции init_module модуль больше ничего не делает, он просто «сидит и ждет», когда ядро обратится к нему для выполнения специфических действий.
Любой модуль обязательно должен иметь функцию инициализации и функцию завершения. Так как существует более чем один способ определить функции инициализации и завершения, я буду стараться использовать термины «начальная» и «конечная» функции, если я собьюсь и укажу названия init_module и cleanup_module, то думаю, что вы поймете меня правильно.
3.2. Функции, которые доступны из модулей
Модули ядра в этом плане сильно отличаются от прикладных программ. В примере «Hello World» мы использовали функцию printk(), но не подключали стандартную библиотеку ввода-вывода. Модули так же проходят стадию связывания, но только с ядром, и могут вызывать только те функции, которые экспортируются ядром. Разрешение ссылок на внешние символы производится утилитой insmod. Если у вас есть желание взглянуть на список имен, экспортируемых ядром, загляните в файл /proc/kallsyms.
Как в этом можно убедиться? Да очень просто! Скомпилируйте следующую программу:
Вы можете даже написать модули, которые подменяют системные вызовы ядра, вскоре мы продемонстрируем это. Взломщики довольно часто используют эту возможность для создания «черного хода» в систему или «троянов», но вы можете использовать ее в менее вредоносных целях, например заставить ядро выводить строку «Tee hee, that tickles!» («Хи-хи, щекотно!») каждый раз, когда кто нибудь пробует удалить файл.
3.3. Пространство пользователя и пространство ядра
За доступ к ресурсам системы отвечает ядро, будь то видеоплата, жесткий диск или даже память. Программы часто конкурируют между собой за доступ к тем или иным ресурсам. Например, при подготовке этого документа, я сохраняю файл с текстом на жесткий диск, тут же стартует updatedb, чтобы обновить локальную базу данных. В результате мой vim и updatedb начинают конкурировать за обладание жестким диском. Ядро должно обслужить конкурирующие запросы, и «выстроить» их в порядке очередности. К тому же сам центральный процессор может работать в различных режимах. Каждый из режимов имеет свою степень «свободы» действий. Микропроцессор Intel 80386 имеет четыре таких режима, которые часто называют «кольцами». Unix использует только два из них: наивысший (нулевое кольцо, известное так же под названием привилегированный режим ) и низший ( пользовательский режим ).
3.4. Пространство имен
Когда вы пишете небольшую программку на C, вы именуете свои функции и переменные так, как вам это удобно. С другой стороны, когда вы разрабатываете некую программную единицу, входящую в состав большого программного пакета, любые глобальные переменные, которые вы вводите, становятся частью всего набора глобальных переменных пакета. В этой ситуации могут возникнуть конфликты имен. Внедрение большого количества имен функций и переменных, с глобальной областью видимости, значение которых не интутивно и трудноразличимо, приводит к «загрязнению» пространства имен. Программист, который работает с такими приложениями, тратит огромное количество умственных сил на то, чтобы запомнить «зарезервированные» имена и придумать свои уникальные названия.
Файл /proc/kallsyms содержит все имена в ядре, с глобальной областью видимости, которые доступны для ваших модулей.
3.5. Адресное пространство
Если вы никогда не задумывалесь над тем, что означает слово segfault, то для вас скорее всего окажется сюрпризом тот факт, что указатели фактически не указывают на какой-то реальный участок физической памяти. В любом случе, эти адреса не являются реальными. Когда запускается процесс, ядро выделяет под него кусок физической памяти и передает его процессу. Эта память используется для размещения исполняемого кода, стека, переменных, динамической «кучи» и других вещей, о чем наверняка знают компьютерные гении. [3] Эта память начинается с логического адреса #0 и простирается до того адреса, который необходим. Поскольку области памяти, выделенные для разных процессов, не пересекаются, то каждый из процессов, обратившись к ячейке памяти с адресом, скажем 0xbffff978, получит данные из различных областей физической памяти! В даннм случае число 0xbffff978 можно рассматривать как смещение относительно начала области памяти, выделенной процессу. Как правило программы, подобные нашей «Hello World», не могут обратиться к памяти, занимаемой другим процессом, хотя существуют обходные пути, позволяющие добиться этого, но оставим пока эту тему для более позднего обсуждения.
Ядро тоже имеет свое собственное адресное пространство. Поскольку модуль по сути является частью ядра, то он так же работает в адресном пространстве ядра. Если ошибка segmentation fault, возникающая в приложении может быть отслежена и устранена без особых проблем, то в модуле подобная ошибка может стать фатальной для всей системы. Из-за незначительной ошибки в модуле вы рискуете «затоптать» ядро. Результат может быть самым плачевным. Поэтому будьте предельно внимательны!
Хотелось бы заметить, что это справедливо для любой операционной системы, которая построена на монолитном ядре. [4] Есть операционные системы, в основе которых лежит микроядро. В таких ОС каждый модуль получает свое адресное пространство. Примерами могут служить GNU Hurd и QNX Neutrino.
3.6. Драйверы устройств
Драйверы устройств являются одной из разновидностей модулей ядра. Они играют особую роль. Это настоящие «черные ящики», которые полностью скрывают детали, касающиеся работы устройства, и предоставляют четкий программный интерфейс для работы с аппаратурой. В Unix каждое аппаратное устройство представлено псевдофайлом (файлом устройства) в каталоге /dev. Этот файл обеспечивает средства взаимодействия с аппаратурой. Так, например, драйвер звуковой платы es1370.ko связывает файл устройства /dev/sound со звуковой платой Ensoniq IS1370. Пользовательское приложение, например mp3blaster может использовать для своей работы /dev/sound, ничего не подозревая о типе установленной звуковой платы.
3.6.1. Старший и младший номер устройства
Давайте взглянем на некоторые файлы устройств. Ниже перечислены те из них, которые представляют первые три раздела на первичном жестком диске:
Младший номер используется драйвером, для различения аппаратных средств, которыми он управляет. Возвращаясь к примеру выше, заметим, что хотя все три устройства обслуживаются одним и тем же драйвером, тем не менее каждое из них имеет уникальный младший номер, поэтому драйвер «видит» их как различные аппаратные устройства.
Если вам интересно узнать, как назначаются старшие номера устройств, загляните в файл /usr/src/linux/documentation/devices.txt.
Между прочим, когда я говорю «устройства», я подразумеваю нечто более абстрактное чем, скажем, PCI плата, которую вы можете подержать в руке. Взгляните на эти два файла устройств:
Глава 4. Файлы символьных устройств
4.1. Структура file_operations
Структура file_operations определена в файле linux/fs.h и содержит указатели на функции драйвера, которые отвечают за выполнение различных операций с устройством. Например, практически любой драйвер символьного устройства реализует функцию чтения данных из устройства. Адрес этой функции, среди всего прочего, хранится в структуре file_operations. Ниже приводится определение структуры, взятое из исходных текстов ядра 2.6.5:
Компилятор gcc предоставляет программисту довольно удобный способ заполнения полей структуры в исходном тексте. Поэтому, если вы встретите подобный прием в современных драйверах, пусть это вас не удивляет. Ниже приводится пример подобного заполнения:
Однако, существует еще один способ заполнения структур, который описывается стандартом C99. Причем этот способ более предпочтителен. gcc 2.95, который я использую, поддерживает синтаксис C99. Вам так же следует придерживаться этого синтаксиса, если вы желаете обеспечить переносимость своему драйверу:
4.2. Структура file
Каждое устройство представлено в ядре структурой file, которая определена в файле linux/fs.h. Эта структура используется исключительно ядром и никогда не используется прикладными программами, работающими в пространстве пользователя. Это совершенно не то же самое, что и FILE, определяемое библиотекой glibc и которое в свою очередь в ядре нигде не используется. Имя структуры может ввести в заблуждение, поскольку она представляет абстракцию открытого файла, а не файла на диске, который представляет структура inode.
Как правило указатель на структуру file называют filp.
Загляните в заголовочный файл и посмотрите определение структуры file. Большинство имеющихся полей структуры, например struct dentry *f_dentry, не используются драйверами устройств, и вы можете игнорировать их. Драйверы не заполняют структуру file непосредственно, они только используют структуры, содержащиеся в ней.
4.3. Регистрация устройства
Как уже говорилось ранее, доступ к символьным устройствам осуществляется посредством файлов устройств, которые как правило располагаются в каталоге /dev. [5] Старший номер устройства говорит о том, какой драйвер с каким файлом устройства связан. Младший номер используется самим драйвером для идентификации устройства, если он обслуживает несколько таких устройств.
Если вы передадите функции register_chrdev(), в качестве старшего номера, число 0, то возвращаемое положительное значение будет представлять собой, динамически выделенный ядром, старший номер устройства. Один из неприятных моментов здесь состоит в том, что вы заранее не можете создать файл устройства, поскольку старший номер устройства вам заранее не известен. Тем не менее, можно предложить ряд способов решения этой проблемы.
Драйвер может выводить сообщение в системный журнал (как это делает модуль «Hello World»), а вы затем вручную создадите файл устройства.
Для вновь зарегистрированного устройства, в файле /proc/devices появится запись. Вы можете найти эту запись и вручную создать файл устройства или можно написать небольшой сценарий, который выполнит эту работу за вас.
Можно «заставить» сам драйвер создавать файл устройства, с помощью системного вызова mknod, после успешной регистрации. А внутри cleanup_module() предусмотреть возможность удаления файла устройства с помощью rm.
4.4. Отключение устройства
Обычно, если какая-то операция должна быть отвергнута, функция возвращает код ошибки (отрицательное число). В случае с функцией cleanup_module() это невозможно, поскольку она не имеет возвращаемого значения. Однако, для каждого модуля в системе имеется счетчик обращений, который хранит число процессов, использующих модуль. Вы можете увидеть это число в третьем поле, в файле /proc/devices. Если это поле не равно нулю, то rmmod не сможет выгрузить модуль. Обратите внимание: вам нет нужды следить за состоянием счетчика в cleanup_module(), это делает система, внутри системного вызова sys_delete_module (определение функции вы найдете в файле linux/module.c). Вы не должны изменять значение счетчика напрямую, тем не менее, ядро предоставляет в ваше распоряжение функции, которые увеличивают и уменьшают значение счетчика обращений:
try_module_get(THIS_MODULE): увеличивает счетчик обращений на 1.
try_module_put(THIS_MODULE): уменьшает счетчик обращений на 1.
Очень важно сохранять точное значение счетчика! Если Вы каким-либо образом потеряете действительное значение, то вы никогда не сможете выгрузить модуль. Тут, милые мои мальчики и девочки, поможет только перезагрузка! Это обязательно случиться с вами, рано или поздно, при разработке какого-либо модуля!
4.5. chardev.c
Следующий пример создает устройство с именем chardev. Вы можете читать содержимое файла устройства с помощью команды cat или открывать его на чтение из программы (функцией open()). Посредством этого файла драйвер будет извещать о количестве попыток обращения к нему. Модуль не поддерживает операцию записи (типа: echo «hi» > /dev/chardev), но определяет такую попытку и сообщает пользователю о том, что операция записи не поддерживается.
Пример 4-1. chardev.c
Пример 4-2. Makefile
4.6. Создание модулей для работы с разными версиями ядра
Системные вызовы, которые суть есть основной интерфейс с ядром, как правило не изменяют свой синтаксис вызова от версии к версии. В ядро могут быть добавлены новые системные вызовы, но старые, практически всегда, сохраняют свое поведение, независимо от версии ядра. Делается это с целью сохранения обратной совместимости, чтобы не нарушить корректную работу ранее выпущенных приложений. В большинстве случаев, файлы устройств также останутся теми же самыми. С другой стороны, внутренние интерфейсы ядра могут изменяться от версии к версии.
Итак, мы уже поняли, что между разными версиями ядра могут существовать весьма существенные отличия. Если у вас появится необходимость в создании модуля, который мог бы работать с разными версиями ядра, то можете воспользоваться директивами условной компиляции, основываясь на сравнении макроопределений LINUX_VERSION_CODE и KERNEL_VERSION. Для версии a.b.c, макрос KERNEL_VERSION вернет код версии, вычисленный в соответствии с выражением: 2^<16>a+2^<8>b+c. Макрос LINUX_VERSION_CODE возвращает текущую версию ядра.
Глава 5. Файловая система /proc
5.1. Файловая система /proc: создание файлов, доступных для чтения
Методика работы с файловой системой /proc очень похожа на работу драйверов с файлами устройств: вы создаете структуру со всей необходимой информацией, включая указатели на функции-обработчики (в нашем случае имеется только один обработчик, который обслуживает чтение файла в /proc). Функция init_module регистрирует структуру, а cleanup_module отменяет регистрацию.
Поскольку мы не предусматриваем обработку операций открытия/закрытия файла в файловой системе /proc, то нам некуда вставлять вызовы функций try_module_get и try_module_put. Если вдруг случится так, что модуль был выгружен в то время как соответствующий файл в /proc оставался открытым, к сожалению у нас не будет возможности избежать возможных последствий. В следующем разделе мы расскажем о довольно сложном, но достаточно гибком способе защиты от подобных ситуаций.
Пример 5-1. procfs.c
Пример 5-2. Makefile
5.2. Файловая система /proc: создание файлов, доступных для записи
Поскольку файловая система /proc была написана, главным образом, для того чтобы получать данные от ядра, она не предусматривает специальных средств для записи данных в файлы. Структура proc_dir_entry не содержит указатель на функцию-обработчик записи. Поэтому, вместо того, чтобы писать в /proc напрямую, мы вынуждены будем использовать стандартный, для файловой системы, механизм.
Пример 5-3. procfs.c
Хотите еще примеры работы с файловой системой /proc? Хорошо, но имейте ввиду, ходят слухи, что /proc уходит в небытие и вместо нее следует использовать sysfs. Дополнительные сведения о файловой системе /proc вы найдете в linux/Documentation/DocBook/. Дайте команду make help, она выведет инструкции по созданию документации в различных форматах, например: make htmldocs.
Глава 6. Работа с файлами устройств
Ответ: в Unix следует использовать специальную функцию с именем ioctl (сокращенно от Input Output ConTroL). Любое устройство может иметь свои команды ioctl, которые могут читать (для передачи данных от процесса ядру), писать (для передачи данных от ядра к процессу), и писать и читать, и ни то ни другое, [8] Функция ioctl вызывается с тремя параметрами: дескриптор файла устройства, номер ioctl и третий параметр, который имеет тип long, используется для передачи дополнительных аргументов. [9]
Номер ioctl содержит комбинацию бит, составляющих старший номер устройства, тип команды и тип дополнительного параметра. Обычно номер ioctl создается макроопределением (_IO, _IOR, _IOW или _IOWR, в зависимости от типа) в файле заголовка. Этот заголовочный должен подключаться директивой #include, к исходным файлам программы, которая использует ioctl для обмена данными с модулем. В примере, приводимом ниже, представлены файл заголовка chardev.h и программа, которая взаимодействует с модулем ioctl.c.
Если вы предполагаете использовать ioctl в ваших собственных модулях, то вам надлежит обратиться к файлу Documentation/ioctl-number.txt с тем, чтобы не «занять» зарегистрированные номера ioctl.
Пример 6-1. chardev.c
Пример 6-2. chardev.h
Пример 6-3. ioctl.c
Пример 6-4. Makefile
Для облегчения сборки примера, предлагается скрипт, который выполнит эту работу за вас:
Пример 6-5. build.sh
Глава 7. Системные вызовы
До сих пор, все что мы делали, это использовали ранее определенные механизмы ядра для регистрации файлов в файловой системе /proc и файлов устройств. Это все замечательно, но это годится только для создания драйверов устройств. А что если вы хотите сделать действительно что-то необычное, например изменить реакцию системы на какое либо событие?
Мы как раз вступаем в ту область, где программирование действительно становится опасным. При разработке примера, приведенного ниже, я «уничтожил» системный вызов open. В результате система потеряла возможность открывать любые файлы, что равносильно отказу системы выполнять любые программы. Я не мог даже остановить систему командой shutdown. Из-за этого пришлось прибегнуть к «помощи» кнопки выключения питания. К счастью, ни один файл не был уничтожен. Чтобы обезопасить себя от потери данных, в аналогичных ситуациях, перед загрузкой подобных модулей всегда выполняйте резервное копирование.
Ниже приводится исходный текст такого модуля. Он «шпионит» за выбранным пользователем, и посылать через printk сообщение всякий раз, когда данный пользователь открывает какой-либо файл. Для этого, системный вызов open(), подменяется функцией с именем our_sys_open. Она проверяет UID (User ID) текущего процесса, и если он равен заданному, то вызывает printk, чтобы сообщить имя открываемого файла, и в заключение вызывает оригинальную функцию open() с теми же параметрами, которая открывает требуемый файл.
Обратите внимание: подобные проблемы делают такую «подмену» системных вызовов неприменимой для широкого распространения.С целью предотвращения потенциальной опасности, связанной с подменой адресов системных вызовов, ядро более не экспортирует sys_call_table. Поэтому, если вы желаете сделать нечто большее, чем просто пробежать глазами по тексту данного примера, вам надлежит наложить «заплату» на ядро. В каталоге с примерами вы найдете файл README и «заплату». Как вы наверняка понимаете, подобные модификации сопряжены с определенными трудностями, поэтому я не рекомендую производить их на системах, владельцем которых вы не являетесь или не в состоянии быстро восстановить. Если вас одолевают сомнения, то лучшим выбором будет отказ от прогона этого примера.
Пример 7-1. syscall.c
Пример 7-2. «Заплата» на ядро (export_sys_call_table_patch_for_linux_2.6.x)