Top.Mail.Ru
Linux kernel. Programming. Глава 4. Написание первого модуля ядра - часть 1


Feb 8, 2025

4. Глава. Написание первого модуля ядра - часть 1

В предыдущих двух главах вы узнали все тонкости получения дерева исходного кода ядра и настройки и сборки его (для x86). Теперь добро пожаловать в ваше путешествие по изучению фундаментального аспекта разработки ядра Linux – фреймворка Загружаемых Модулей Ядра (LKM) – и как он используется пользователем или автором модуля, который обычно является программистом ядра или драйверов устройств. Эта тема довольно обширна, поэтому она разделена на две главы – эту и следующую. В этой главе мы начнем с быстрого обзора основ архитектуры ядра Linux, что поможет нам понять фреймворк LKM. Затем мы рассмотрим, почему модули ядра полезны, напишем, соберем и запустим наш простой модуль “Hello, world” LKM. Мы увидим, как сообщения записываются в журнал ядра, и поймем и используем Makefile для LKM. К концу этой главы вы узнаете основы архитектуры ядра Linux и фреймворка LKM, применив это знание для написания простого, но полного куска кода ядра. В этой главе мы рассмотрим следующие рецепты:

Технические требования

Если вы уже внимательно следовали инструкциям из онлайн главы “Kernel Workspace Setup”, то часть технических предпосылок, которые следуют, уже будет выполнена. (Первая глава также упоминает различные полезные открытые инструменты и проекты; я определенно рекомендую просмотреть её хотя бы один раз.) Для вашего удобства мы суммируем здесь некоторые ключевые моменты.

Для сборки и использования внешнего (или вне-дерева) модуля ядра на дистрибутиве Linux (или кастомной системе), вам нужно:

Этот пакет может называться kernel-headers-<ver#> на некоторых дистрибутивах. Также, для разработки непосредственно на Raspberry Pi, установите соответствующий пакет заголовочных файлов ядра, названный raspberrypi-kernel-headers.

На всякий случай, если вы не можете собирать модули, попробуйте это: подготовьте ядро для сборки внешних модулей, выполнив make modules_prepare в корне вашего дерева исходного кода ядра. С типичными современными дистрибутивами, вам не должно понадобиться это делать; сборка модулей должна просто работать.

Весь исходный код для этой книги доступен в её репозитории на GitHub по адресу: https://github.com/PacktPublishing/Linux-Kernel-Programming_2E; мы ожидаем, что вы клонируете его:

git clone \
  https://github.com/PacktPublishing/Linux-Kernel-Programming_2E.git

Код для этой главы находится в соответствующей директории, обозначенной как chn (где n - номер главы; таким образом, здесь это будет ch4/).

Понимание архитектуры ядра – часть 1

В этом разделе мы начинаем углублять наше понимание ядра Linux. Конкретно, здесь мы рассмотрим, что такое пространства пользователя и ядра, а также основные подсистемы и различные компоненты, из которых состоит ядро. Эта информация рассматривается на более высоком уровне абстракции и намеренно держится кратко. Мы значительно углубим понимание структуры ядра в главе 6, “Kernel Internals Essentials – Processes and Threads”.

Пространство пользователя и пространство ядра

Современные микропроцессоры поддерживают выполнение кода как минимум на двух уровнях привилегий. В качестве примера из реальной жизни, семейство процессоров Intel/AMD x86[-64] поддерживает четыре уровня привилегий (их называют уровнями кольца), семейство микропроцессоров AArch32 (ARM-32) поддерживает до семи режимов (ARM называет их режимами выполнения; шесть из них привилегированы и один не привилегирован), а семейство микропроцессоров AArch64 (ARM-64/ARMv8) поддерживает четыре уровня исключений (EL0 до EL3, где EL0 наименее привилегирован, а EL3 наиболее привилегирован).

Ключевая мысль здесь заключается в том, что для обеспечения безопасности и стабильности платформы все современные операционные системы, работающие на этих процессорах, будут использовать (как минимум) два уровня привилегий (или режима) выполнения, что приводит к разделению Виртуального Адресного Пространства (VAS) на два четко различимых (виртуальных) адресных пространства:

Следующая фигура демонстрирует эту базовую архитектуру:

![[figure41.png]] Рисунок 4.1: Базовая архитектура – два адресных пространства, два режима привилегий

Далее следует несколько деталей о системной архитектуре Linux; продолжайте чтение.

Библиотеки и API системных вызовов

Приложения в пространстве пользователя часто используют интерфейсы программирования приложений (API) для выполнения своей работы. Библиотека по сути является коллекцией или архивом API, что позволяет использовать стандартизированный, хорошо написанный и протестированный интерфейс (и получать обычные преимущества: не изобретать велосипед с нуля, обеспечивать переносимость, стандартизацию и т.д.). В системах Linux есть множество библиотек; на системах корпоративного уровня их могут быть даже сотни. Из них все приложения Linux в режиме пользователя (исполняемые файлы) “автоматически связываются” с одной важной, постоянно используемой библиотекой: glibc – это стандартная библиотека GNU C, как вы узнаете. Однако библиотеки всегда доступны только в пользовательском режиме; ядро не использует эти библиотеки пользовательского режима (подробнее об этом в следующей главе).

Примеры API библиотек включают такие известные как printf(3), scanf(3), strcmp(3), malloc(3) и free(3). (Напомним, из онлайн главы “Kernel Workspace Setup”, раздел “Using the Linux man pages”.)

Теперь ключевой момент: если пользовательское и ядерное пространства являются отдельными адресными пространствами и находятся на разных уровнях привилегий, как процесс пользователя – который, как мы только что узнали, ограничен пространством пользователя – может получить доступ к ядру? Краткий ответ: через системные вызовы. Системный вызов – это особый API в том смысле, что он является единственным легальным (синхронным) способом для процессов (или потоков) пространства пользователя получить доступ к ядру. Другими словами, системные вызовы являются единственной легальной точкой входа в пространство ядра.

Системные вызовы имеют встроенную способность переключаться из непривилегированного пользовательского режима в привилегированный режим ядра (подробнее об этом и монолитной архитектуре в главе 6, “Kernel Internals Essentials – Processes and Threads”, в разделе “Process and interrupt contexts”). Примеры системных вызовов включают fork(2), execve(2), open(2), read(2), write(2), socket(2), accept(2), chmod(2) и так далее.

Посмотрите все API библиотек и системные вызовы в онлайн-мануалах:

Основным акцентом здесь является то, что только через системные вызовы пользовательские приложения (процессы и потоки) и ядро взаимодействуют – это и есть интерфейс. В этой книге мы не углубляемся в эти детали. Если вас интересует больше информации, пожалуйста, обратитесь к моей предыдущей книге “Hands-On System Programming with Linux”, Packt (конкретно к главе 1, “Linux System Architecture”).

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

Компоненты пространства ядра

Конечно, эта книга полностью сфокусирована на пространстве ядра. Сегодня ядро Linux – это довольно большой и сложный механизм. Внутри него есть несколько основных подсистем и множество компонентов. Широкий перечень подсистем и компонентов ядра включает следующее:

Все это составляет основные подсистемы ядра; кроме того, у нас есть:

Вспомните, что в главе 2, “Сборка ядра Linux 6.x из исходного кода – Часть 1”, в разделе “Краткий обзор дерева исходного кода ядра” был дан обзор структуры дерева исходного кода ядра, соответствующий основным подсистемам и другим компонентам.

Хорошо известно, что ядро Linux следует монолитной архитектуре ядра (в противоположность микроядерной архитектуре). В монолитном дизайне все компоненты ядра (которые мы упомянули в этом разделе) существуют и разделяют виртуальное адресное пространство ядра (VAS) или сегмент ядра. Это можно четко увидеть на следующей диаграмме:

![[figure42.png]] Рисунок 4.2: Пространство ядра Linux – основные подсистемы и блоки

Еще один факт, о котором следует знать, заключается в том, что эти адресные пространства, конечно же, являются виртуальными адресными пространствами, а не физическими. Ядро будет сопоставлять, на уровне гранулярности страниц, виртуальные страницы с физическими фреймами страниц, используя аппаратные блоки, такие как блок управления памятью (MMU) и кэш процессора, плюс кэш трансляционных буферов (TLB), для повышения эффективности. Это делается с помощью главной таблицы страничной памяти ядра для сопоставления виртуальных страниц ядра с физическими фреймами (ОЗУ), и для каждого живого процесса пространства пользователя оно сопоставляет виртуальные страницы процесса с физическими фреймами страниц через индивидуальные таблицы страничной памяти для каждого процесса.

Более углубленное освещение основ архитектуры и внутренностей ядра и управления памятью ожидает вас в главе 6, “Kernel Internals Essentials – Processes and Threads” (и последующих главах).

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

Исследование LKM

Модуль ядра - это способ предоставления функциональности на уровне ядра без необходимости работы непосредственно в дереве исходного кода ядра и с использованием статического образа ядра.

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

Хотя это может показаться простым, это много работы – каждое изменение в написанном нами коде, независимо от того, насколько оно мало, потребует перестроения образа ядра и перезагрузки системы для его тестирования. Должен быть более чистый и простой способ; действительно, существует – фреймворк LKM!

Фреймворк LKM

Фреймворк LKM позволяет компилировать часть кода ядра обычно вне дерева исходного кода ядра, часто называемого “вне-деревом” кодом, сохраняя его в ограниченной степени независимым от ядра, а затем вставлять или подключать созданный “объект модуля” в память ядра, в виртуальное адресное пространство ядра, запускать его и выполнять его работу, а затем удалять его (или отключать) из памяти ядра. (Обратите внимание, что фреймворк LKM также может использоваться для создания модулей ядра внутри дерева, как мы делали при сборке ядра. Здесь мы сосредоточимся на модулях вне дерева).

Исходный код модуля ядра, обычно состоящий из одного или нескольких файлов на C, заголовочных файлов и Makefile, компилируется (с помощью make, конечно) в модуль ядра. Сам модуль ядра является лишь бинарным объектным файлом, а не исполняемым бинарным файлом. В Linux 2.4 и ранее, имя файла модуля ядра имело суффикс .o; в современных версиях Linux 2.6 и позже, он имеет суффикс .ko (объект ядра). После сборки, вы можете вставить этот файл .ko – модуль ядра – в работающее ядро во время выполнения, тем самым сделав его частью ядра.

Обратите внимание, что не вся функциональность ядра может быть предоставлена через фреймворк LKM. Несколько основных функций, таких как базовый код планировщика задач (CPU), управление памятью, сигнализация, таймеры, пути кода управления прерываниями, специфичные для платформы драйвера для управления контроллерами пинов, часами и т.д., могут быть разработаны только внутри самого ядра. Аналогично, модулю ядра разрешён доступ только к подмножеству полного API ядра и переменных данных; об этом мы поговорим позже.

Вы можете спросить: как вставить этот объект модуля в ядро во время выполнения? Давайте упростим - ответ заключается в использовании утилиты insmod. Пока что мы пропустим детали (они будут объяснены в предстоящем разделе “Running the kernel module”). Следующая фигура дает обзор процесса первоначальной сборки и последующей вставки модуля ядра в память ядра:

![[figure43.png]] Рисунок 4.3: Сборка и последующая вставка модуля ядра в память ядра

Не беспокойтесь: фактический код как для C-исходников модуля ядра, так и для его Makefile будет рассмотрен глубоко в предстоящем разделе; пока что мы хотим получить только концептуальное понимание.

Модуль ядра загружается и существует в памяти ядра – то есть, в виртуальном адресном пространстве ядра (VAS) (нижняя часть рисунка 4.3) в области пространства, выделенной для него ядром. Не заблуждайтесь, это код ядра и он выполняется с привилегиями ядра.

Таким образом, вам, разработчику ядра или драйверов, не нужно каждый раз перенастраивать, пересобирать и перезагружать систему. Вам всего лишь нужно отредактировать код модуля ядра, пересобрать его, удалить старую копию из памяти (если она существует) и вставить новую версию. Это экономит время и повышает производительность.

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

Еще одним преимуществом ядровых модулей является их способность к динамической конфигурации продукта. Например, модули ядра могут быть разработаны для предоставления различных функций по разным ценам; скрипт, создающий финальный образ для встраиваемого продукта, может установить определённый набор модулей ядра в зависимости от цены, которую готов заплатить клиент. Вот еще один пример использования этой технологии в сценарии отладки или устранения неполадок: модуль ядра может быть использован для динамического создания диагностических данных и отладочных журналов на существующем продукте. Технологии вроде Kprobes и подобные позволяют это сделать (мои книги по отладке ядра Linux охватывают это в деталях). Фактически, фреймворк LKM предоставляет нам способ динамически расширять функциональность ядра, позволяя нам вставлять (и позже удалять) живой код в (из) памяти ядра. Эта возможность подключать и отключать функциональность ядра по нашему желанию заставляет нас осознать, что ядро Linux не является чисто монолитным, оно также модульное.

Модули ядра внутри дерева исходного кода ядра

На самом деле, объект модуля ядра нам не совсем незнаком. В главе 3, “Building the 6.x Linux Kernel from Source – Part 2”, мы собирали модули ядра как часть процесса сборки ядра и устанавливали их.

Вспомните, что эти модули ядра являются частью исходного кода ядра и были настроены как модули путем выбора M в приглашении конфигурационного меню ядра. Они устанавливаются в директории под /lib/modules/$(uname -r)/. Таким образом, чтобы немного узнать о модулях ядра, установленных под нашим текущим ядром гостевой системы Ubuntu 22.04 LTS на x86_64, мы можем сделать следующее:

$ lsb_release -a 2>/dev/null |grep Description
Description: Ubuntu 22.04.2 LTS
$ uname -r
5.19.0-45-generic
$ find /lib/modules/$(uname -r)/ -name "*.ko" | wc -l
6189

Хорошо поработали ребята из Canonical и других мест! Более шести тысяч модулей ядра… Подумайте об этом, это логично: дистрибьюторы не могут заранее знать, какое именно аппаратное обеспечение будет использовать пользователь (особенно на универсальных компьютерах, таких как x86 PC). Модули ядра служат удобным средством поддержки огромного количества оборудования без чрезмерного раздувания файла изображения ядра (например, файлы bzImage или Image).

Установленные модули ядра для нашей системы Ubuntu Linux располагаются в директории /lib/modules/$(uname -r)/kernel, как показано здесь:

$ ls /lib/modules/5.19.0-45-generic/kernel/
arch/ block/ crypto/ drivers/ fs/ kernel/ lib/ mm/ net/ 
samples/ sound/ ubuntu/ v4l2loopback/ zfs/
$ ls /lib/modules/6.1.25-lkp-kernel/kernel/
arch/ crypto/ drivers/ fs/ lib/ net/ sound/

Здесь, рассматривая верхний уровень директории kernel/ под /lib/modules/$(uname -r) для ядра дистрибутива (Ubuntu 22.04 LTS с запущенным ядром 5.19.0-45-generic), мы видим, что здесь множество подпапок и буквально несколько тысяч модулей ядра. Напротив, для ядра, которое мы собрали (см. главу 2, “Building the 6.x Linux Kernel from Source – Part 1”, и главу 3, “Building the 6.x Linux Kernel from Source – Part 2”, для деталей), их значительно меньше. Вы, вероятно, помните из наших обсуждений в главе 2, что мы специально использовали целевую настройку localmodconfig для того, чтобы сборка оставалась (относительно) маленькой и быстрой. Таким образом, например, наше собственное ядро версии 6.1.25 имеет всего около 70 модулей ядра, собранных для него.

Одна из областей, где модули ядра используются наиболее активно, - это драйверы устройств. Например, давайте посмотрим на драйвер сетевого устройства, который архитектурно построен как модуль ядра. Вы можете найти несколько (с знакомыми названиями брендов!) в папке kernel/drivers/net/ethernet дистрибутива:

![[figure44.png]] Рисунок 4.4: Содержимое папки драйверов сети Ethernet (модулей ядра) нашего дистрибутива

Популярным в многих ноутбуках на базе Intel является сетевой адаптер Intel 1GbE Network Interface Card (NIC). Драйвер сетевого устройства, который его обслуживает, называется драйвером e1000 (более новые системы, возможно, используют более позднюю модель, и, следовательно, модуль драйвера e1000e).

Удобный способ просмотреть все модули ядра, которые сейчас находятся в памяти ядра, - это использование утилиты lsmod (читайте как “list mod(ules)” – мы позже узнаем больше о формате её вывода). Наш гость Ubuntu 22.04 на x86_64 (запущенный на ноутбуке хоста x86_64) показывает, что он действительно использует этот драйвер:

$ lsmod | grep e1000
e1000 159744 0

Важно для нас сейчас то, что драйвер e1000 является модулем ядра! Как насчет получения дополнительной информации об этом конкретном модуле ядра? Это легко сделать, используя утилиту modinfo (для удобства чтения здесь мы сократили её подробный вывод):

$ ls -l /lib/modules/5.19.0-45-generic/kernel/drivers/net/ethernet/intel/e1000
total 364
-rw-r--r-- 1 root root 372185 7 июн 19:53 e1000.ko
$ modinfo /lib/modules/5.19.0-45-generic/kernel/drivers/net/ethernet/intel/
e1000/e1000.ko
filename: /lib/modules/5.19.0-45-generic/kernel/drivers/net/ethernet/
intel/e1000/e1000.ko
license: GPL v2
description: Intel(R) PRO/1000 Network Driver
author: Intel Corporation, <linux.nics@intel.com>
srcversion: F35F102C5522A6614A9D65C
alias: pci:v00008086d00002E6Esv*sd*bc*sc*i*
alias: pci:v00008086d000010B5sv*sd*bc*sc*i*
[ ... ]
intree: Y
name: e1000
vermagic: 5.19.0-45-generic SMP preempt mod_unload modversions
sig_id: PKCS#7
signer: Build time autogenerated kernel key
[ ... ]
parm: SmartPowerDownEnable:Enable PHY smart power down (массив int)
parm: copybreak:Maximum size of packet that is copied to a new buffer on receive (uint)
parm: debug:Debug level (0=none,...,16=all) (int)
$

Утилита modinfo позволяет нам заглянуть в бинарный образ модуля ядра и извлечь некоторые детали о нем; больше о использовании modinfo будет рассказано в следующем разделе.

Вы можете обнаружить, что файл модуля ядра сжат (например, e1000.ko.xz); это особенность, а не ошибка (больше об этом позже). Еще один способ получить полезную информацию о системе, включая информацию о загруженных модулях ядра, - это использование утилиты systool. Для установленного модуля ядра (детали по установке модуля ядра будут приведены в следующей главе, в разделе “Автозагрузка модулей при запуске системы”), выполнение systool -m <имя_модуля> -v раскрывает информацию о нем. Посмотрите страницу руководства systool(1) для деталей использования.

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

Итак, если вы хотите научиться писать драйвер устройства Linux, файловую систему или фаервол, настоятельно рекомендуется сначала научиться писать модуль ядра, тем самым используя мощный фреймворк LKM ядра. Именно это мы и будем делать дальше.

Написание нашего самого первого модуля ядра

При введении нового языка программирования или темы стало широко принятой традицией в программировании воспроизвести оригинальную программу “Hello, world” в качестве первого куска кода. Я рад следовать этой почитаемой традиции, чтобы ввести мощный фреймворк LKM ядра Linux. В этом разделе вы узнаете шаги для написания простого LKM. Мы подробно объясним код.

Введение нашего кода на C для LKM “Hello, world”

Без лишних слов, вот простой код на C “Hello, world”, реализованный в соответствии с фреймворком LKM ядра Linux:

По причинам читаемости и ограничения пространства здесь показаны только ключевые части большинства исходного кода. Чтобы просмотреть полный исходный код (со всеми комментариями), собрать его и запустить, весь исходный код для этой книги доступен в его репозитории на GitHub здесь: https://github.com/PacktPublishing/Linux-Kernel-Programming_2E. Мы определенно ожидаем, что вы клонируете его и будете использовать:

git clone https://github.com/PacktPublishing/Linux-Kernel-Programming_2E.git
$ cat <LKP2E_src>/ch4/helloworld_lkm/helloworld_lkm.c
// ch4/helloworld_lkm/helloworld_lkm.c
#include <linux/init.h>
#include <linux/module.h>

/* Модульные вещи */
MODULE_AUTHOR("<insert your name here>");
MODULE_DESCRIPTION("LKP2E book:ch4/helloworld_lkm: hello, world, our first LKM");
MODULE_LICENSE("Dual MIT/GPL");
MODULE_VERSION("0.2");

static int __init helloworld_lkm_init(void)
{
    printk(KERN_INFO "Hello, world\n");
    return 0; /* успех */
}

static void __exit helloworld_lkm_exit(void)
{
    printk(KERN_INFO "Goodbye, world! Climate change has done us in...\n");
}
module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

Вы можете сразу же попробовать этот простой модуль ядра “Hello, world”! Просто перейдите в правильный исходный каталог и используйте наш вспомогательный скрипт lkm, чтобы собрать и запустить его:

$ cd ~/Linux-Kernel-Programming_2E/ch4/helloworld_lkm
$ ../../lkm helloworld_lkm.c
Usage: lkm name-of-kernel-module-file ONLY (do NOT put any extension).
$ ../../lkm helloworld_lkm
Version info:
Distro: Ubuntu 22.04.2 LTS
Kernel: 5.19.0-45-generic
[]
make || exit 1
------------------------------
make -C /lib/modules/5.19.0-45-generic/build/ M=/home/c2kp/Linux-KernelProgramming_2E/ch4/helloworld_lkm modules
[]
sudo dmesg
------------------------------
[ 4123.028252] Hello, world
$

Как и почему это работает, будет объяснено очень подробно в ближайшее время. Хотя код этого, нашего первого модуля ядра, очень маленький, он требует тщательного изучения и понимания. Продолжайте читать!

Распределение по пунктам

В следующих подразделах объясняется практически каждая строка предыдущего кода на C для LKM “Hello, world”. Помните, что хотя программа кажется очень маленькой и простой, есть многое, что нужно понять о ней и окружающем фреймворке LKM. Остальная часть этой главы сосредоточена на этом и входит в детали. Я настоятельно рекомендую потратить время на чтение и понимание этих основ. Это очень поможет вам в дальнейшем, возможно, в ситуациях, которые сложно отлаживать.

Заголовочные файлы ядра

Первое, что мы делаем в коде - это используем #include для (очевидного) включения нескольких заголовочных файлов. В отличие от разработки приложений на C в пространстве пользователя, здесь используются заголовочные файлы ядра (как упоминалось в разделе “Технические требования”). Вспомните из главы 3, “Building the 6.x Linux Kernel from Source – Part 2”, что модули ядра были установлены под определенной веткой, доступной для записи только пользователем root.

Давайте проверим это снова (здесь мы работаем на нашей гостевой виртуальной машине Ubuntu x86_64 с ядром дистрибутива 5.19.0-45-generic):

$ ls -l /lib/modules/$(uname -r)
total 6712
lrwxrwxrwx 1 root root 40 7 июн 19:53 build -> /usr/src/linux-headers-5.19.0-45-generic
drwxr-xr-x 2 root root 4096 23 июн 09:47 initrd
[ ... ]

Обратите внимание на символическую или мягкую ссылку здесь под названием build. Она указывает на местоположение заголовочных файлов ядра в системе. В предыдущем блоке кода вы можете увидеть, что это в директории /usr/src/linux-headers-5.19.0-45-generic/! Как вы увидите, мы предоставим эту информацию для Makefile, используемого для сборки нашего модуля ядра. (Также, на некоторых системах есть аналогичная мягкая ссылка, называемая source.)

Пакет kernel-headers или linux-headers распаковывает ограниченное дерево исходного кода ядра на системе, обычно под /usr/src/…. Однако эта база кода ядра не является полной, поэтому мы используем выражение “ограниченное дерево исходного кода”. Это потому, что для сборки модулей не требуется полное дерево исходного кода ядра – достаточно только необходимых компонентов (заголовки, Makefile и т.д.), которые упакованы и извлечены.

Первая строка кода в нашем модуле ядра “Hello, world” - это #include <linux/init.h>. Компилятор разрешает эту строку, выполняя поиск вышеупомянутого файла заголовка ядра в директории /lib/modules/$(uname -r)/build/include/. Таким образом, следуя за мягкой ссылкой build, мы видим, что в итоге он выбирает этот файл заголовка:

$ ls -l /usr/src/linux-headers-5.19.0-45-generic/include/linux/init.h
-rw-r--r-- 1 root root 11963 1 авг 2022 /usr/src/linux-headers-5.19.0-45-generic/include/linux/init.h

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

Макросы модуля

Далее у нас есть несколько макросов модуля вида MODULE_FOO(); (неформально называемых “модульные вещи”). Большинство из них довольно интуитивны:

В отсутствие исходного кода, как эта информация будет передана конечному пользователю (или клиенту)? Вот тут и приходит на помощь утилита modinfo! Эти макросы и их информация могут показаться мелочью, но они важны в проектах и продуктах.

Эта информация, например, используется поставщиком для установления (открытых источников) лицензий, под которыми работает код, с помощью grep по выводу modinfo для всех установленных модулей ядра. (Это основные макросы модуля; будут рассмотрены и другие по мере продвижения.)

Точки входа и выхода

Не забывайте, модули ядра, в конце концов, это код ядра, работающий с привилегиями ядра. Это не приложение, и, следовательно, у него нет точки входа в виде знакомой нам функции main(). Это, конечно, вызывает вопрос: каковы точки входа и выхода модуля ядра? Обратите внимание на следующие строки в конце нашего простого модуля ядра:

module_init(helloworld_lkm_init);
module_exit(helloworld_lkm_exit);

Макросы module_{init|exit}() указывают на точки входа и выхода, соответственно. Параметр каждого из них - это указатель на функцию. С современными компиляторами C мы можем просто указать имя функции. Таким образом, в нашем коде это выглядит так:

Можно почти считать эти точки входа и выхода парой конструктор/деструктор для модуля ядра. Технически, это не совсем так, поскольку это не объектно-ориентированный код C++, а просто C. Тем не менее, это полезная аналогия, возможно.

Возвращаемые значения

Обратите внимание на сигнатуры функций инициализации и выхода, которые выглядят следующим образом:

static int __init <modulename>_init(void);
static void __exit <modulename>_exit(void);

Как хорошая практика программирования, мы использовали формат именования для функций как <modulename>_{init|exit}(), где <modulename> заменяется именем модуля ядра. Вы поймете, что эта конвенция именования - это всего лишь конвенция, которая технически не обязательна, но она интуитивна и полезна (помните, мы, люди, пишем код для того, чтобы люди его читали и понимали, а не машины). Очевидно, что ни одна из этих функций не принимает параметров.

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

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

Конвенция возврата 0/-E

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

Чтобы вернуть значение, фреймворк LKM следует тому, что неформально называется конвенцией 0/-E:

Обратите внимание, что errno - это глобальный целочисленный тип, находящийся в VAS процесса пользователя в его неинициализированном сегменте данных. За очень редкими исключениями, когда системный вызов Linux завершается неудачно, возвращается -1, и errno устанавливается в положительное значение, представляющее код ошибки или диагностику; эту работу выполняет “клеевая” кодовая база glibc на пути возврата системного вызова. Кроме того, значение errno является индексом в глобальной таблице сообщений об ошибках на английском языке (const char * sys_errlist[]); так функции, такие как perror(3), strerror_r и тому подобные, могут выводить диагностику ошибок. Кстати, вы можете найти полный список доступных вам кодов ошибок (errno) в следующих файлах заголовков (дерева исходного кода ядра): include/uapi/asm-generic/errnobase.h и include/uapi/asm-generic/errno.h.

Быстрый пример того, как вернуть значение из функции инициализации модуля ядра, поможет прояснить эту ключевую точку: предположим, что функция инициализации нашего модуля ядра пытается динамически выделить память ядра (детали API kmalloc() и т.д. будут рассмотрены в последующих главах, пожалуйста, пока игнорируйте это). Тогда мы могли бы написать код так:

[...]
ptr = kmalloc(87, GFP_KERNEL);
if (!ptr) {
    pr_warning("%s():%s():%d: kmalloc failed! Out of memory\n", __FILE__, __func__, __LINE__);
    return -ENOMEM;
}
[...]
return 0; /* успех */

Если выделение памяти действительно не удается (что маловероятно, но, эй, это может случиться в плохой день!), мы делаем следующее:

  1. Сначала мы выводим предупреждение с помощью printk (не беспокойтесь, мы покроем эти синтаксические детали и многое другое о printk). В этом конкретном случае - “нет памяти” - считается педантичным и ненужным выводить сообщение. Ядро определенно выдаст достаточно диагностической информации, если когда-либо не удастся выделение памяти в пространстве ядра! Подробнее см. здесь: https://lkml.org/lkml/2014/6/10/382; мы делаем это здесь просто потому, что это ранняя стадия обсуждения и для непрерывности чтения.
  2. Вернуть целочисленное значение -ENOMEM:
    • Слой, которому это значение будет возвращено в пользовательском пространстве, на самом деле это glibc; у него есть “клеевой” код, который умножает это значение на -1 и устанавливает глобальный целочисленный errno этим значением.
    • Теперь системный вызов [f]init_module() вернет -1, указывая на неудачу (потому что insmod фактически вызывает системный вызов finit_module() (или ранее init_module()), как вы скоро увидите).
    • errno будет установлен в ENOMEM, отражая тот факт, что вставка модуля ядра не удалась из-за сбоя выделения памяти.

Наоборот, фреймворк ожидает, что функция инициализации вернет значение 0 при успехе. На самом деле, в более старых версиях ядра, если не возвращать 0 при успехе, модуль ядра был бы немедленно и резко выгружен из памяти ядра. Сегодня такого удаления модуля не происходит; вместо этого ядро выдает предупреждающее сообщение о том, что возвращено подозрительное ненулевое значение. Более того, современные компиляторы обычно ловят тот факт, что вы не возвращаете значение, когда ожидается, выдавая сообщение об ошибке, подобное этому: error: no return statement in function returning non-void [-Werror=return-type].

Про функцию очистки сказать нечего. Она не принимает параметров и ничего не возвращает (void). Ее задача - выполнить все необходимые действия по очистке (освобождение объектов памяти, установка определенных регистров, возможно, и так далее, в зависимости от того, что предназначено делать модулю) перед тем, как модуль ядра будет выгружен из памяти ядра.

Не включение макроса module_exit() в ваш модуль ядра делает невозможным его выгрузку (если не считать выключения или перезагрузки системы, конечно). Интересно… Я предлагаю вам попробовать это в качестве небольшого упражнения! Конечно, все не так просто: такое поведение, препятствующее выгрузке модуля, гарантировано только если ядро собрано с флагом CONFIG_MODULE_FORCE_UNLOAD установленным в Отключено (по умолчанию).

Макросы ERR_PTR и PTR_ERR

В обсуждении возвращаемых значений, вы теперь понимаете, что функция инициализации модуля ядра должна возвращать целое число. Что, если вы хотите вернуть вместо этого указатель? На помощь приходит встроенная функция ERR_PTR(), позволяя нам вернуть целое число, замаскированное под указатель, просто приведя его к типу void *. Это еще лучше: вы можете проверить на ошибку с помощью встроенной функции IS_ERR() (которая фактически только определяет, находится ли значение в диапазоне от -1 до -4095), кодирует отрицательное значение ошибки в указатель при помощи ERR_PTR(), и извлекает это значение из указателя с помощью противоположной функции PTR_ERR().

Как пример, рассмотрим следующий код вызываемой функции. На этот раз, в качестве примера, пусть функция myfunc() (пример) возвращает указатель (на структуру с именем mystruct), а не целое число:

struct mystruct * myfunc(void)
{
    struct mystruct *mys = NULL;
    mys = kzalloc(sizeof(struct mystruct), GFP_KERNEL);
    if (!mys)
        return ERR_PTR(-ENOMEM);
    [...]
    return mys;
}

Код вызывающей стороны следующий:

[...]
retp = myfunc();
if (IS_ERR(retp)) {
    pr_warn("myfunc() mystruct alloc failed, aborting...\n");
    stat = PTR_ERR(retp); /* устанавливает 'stat' в значение -ENOMEM */
    goto out_fail_1;
}
[...]
out_fail_1:
    return stat;
}

Для справки, встроенные функции ERR_PTR(), PTR_ERR(), и IS_ERR() находятся в файле заголовка ядра (include/linux/err.h). Пример использования этих функций можно найти здесь: https://elixir.bootlin.com/linux/v6.1.25/source/arch/x86/kernel/cpu/sgx/ioctl.c#L269 (и ERR_PTR() на строке 31).

Ключевые слова __init и __exit

Вспомните функции инициализации и очистки нашего простого модуля:

static int __init helloworld_lkm_init(void)
{
    [ ... ]
static void __exit helloworld_lkm_exit(void)
{
    [ ... ]

Остается вопрос: что именно означают макросы __init и __exit, которые мы видим в сигнатурах этих функций? Эти макросы просто указывают на атрибуты оптимизации памяти для компоновщика.

Макрос __init определяет секцию init.text для кода. Аналогично, любые данные, объявленные с атрибутом __initdata, помещаются в секцию init.data. Суть в том, что код и данные в функции инициализации используются ровно один раз во время инициализации.

После того как функция вызвана, она больше не будет вызвана; таким образом, после вызова весь код и данные в этих секциях освобождаются (с помощью free_initmem()). То же самое касается макроса __exit, хотя, конечно, это имеет смысл только для модулей ядра. После вызова функции очистки вся память освобождается. Если бы код был частью статического образа ядра (или если бы поддержка модулей была отключена), этот макрос не имел бы эффекта. Хорошо, но пока мы еще не объяснили некоторые практические моменты: как именно можно собрать новый модуль ядра, ввести его в память ядра и запустить, а затем выгрузить его, плюс несколько других операций, которые вы можете захотеть выполнить? Давайте обсудим это в следующем разделе.

Общие операции с модулями ядра

Теперь давайте углубимся в то, как именно вы можете собрать, загрузить и выгрузить модуль ядра. Кроме этого, мы также пройдемся по основам невероятно полезного API ядра printk(), деталям списка текущих загруженных модулей ядра с помощью lsmod, и удобному скрипту для автоматизации некоторых общих задач во время разработки модуля ядра. Итак, начнем!

Сборка модуля ядра

Здесь мы покажем шаг за шагом, как именно можно собрать и затем вставить наш самый первый модуль ядра в память ядра. Еще раз напомним: мы выполняли эти шаги на виртуальной машине Linux x86_64 (под Oracle VirtualBox 7.x), работающей под управлением дистрибутива Ubuntu 22.04 LTS (не то чтобы это сильно зависело от дистрибутива, который вы используете; общие шаги остаются теми же):

  1. Перейдите в директорию с исходным кодом главы книги и в поддиректорию. Наш первый модуль ядра находится в своей собственной папке (как и должно быть!) под названием helloworld_lkm:
    cd <book-code-dir>/ch4/helloworld_lkm
    
  2. Теперь проверьте базу кода: ```bash $ pwd
/ch4/helloworld_lkm $ ls -l ``` ```bash total 8 -rw-rw-r-- 1 c2kp c2kp 1238 Dec 18 12:38 helloworld_lkm.c -rw-rw-r-- 1 c2kp c2kp 290 Oct 27 07:26 Makefile ``` (Не беспокойтесь, если размеры не совпадают точно с теми, что у вас есть; программное обеспечение развивается.) 3. Соберите модуль ядра с помощью make: ![[figure45.png]] Рисунок 4.5: Перечисление и сборка нашего первого модуля ядра "Hello, world" > Если вы столкнетесь с этим при сборке модуля, вы можете пока вполне безопасно игнорировать любые предупреждающие сообщения, связанные с "Пропуск генерации BTF для <...>/helloworld_lkm.ko из-за недоступности vmlinux…." На предыдущем снимке экрана показано, что наш модуль ядра успешно собран; это файл, "объект ядра", с именем ./helloworld_lkm.ko. > Всегда используйте make, и, следовательно, предоставленный Makefile, для сборки модуля ядра; не пытайтесь собирать его вручную, вызывая gcc (или Clang) напрямую. Обратите также внимание, что мы загрузили и, следовательно, собрали модуль ядра для нашего пользовательского ядра 6.1.25 LTS, которое было собрано в предыдущих главах. Теперь, когда мы успешно собрали наш модуль, давайте запустим его! #### Запуск модуля ядра Чтобы модуль ядра мог выполниться, его сначала нужно загрузить в пространство памяти ядра, конечно. Это известно как вставка модуля в память ядра. Загрузка модуля ядра в сегмент ядра Linux или VAS (который, конечно, находится в ОЗУ) может быть выполнена несколькими способами, все они в конечном итоге сводятся к вызову одного из системных вызовов [f]init_module(). Для удобства существует несколько оболочечных утилит, которые могут это сделать (или вы всегда можете написать свою собственную). Мы будем использовать популярную утилиту insmod ("insert module"); параметром для insmod является путь к модулю ядра, который нужно вставить (для справки, под капотом insmod вызывает системный вызов finit_module() для загрузки модуля в память ядра): ```bash $ insmod ./helloworld_lkm.ko insmod: ERROR: could not insert module ./helloworld_lkm.ko: Operation not permitted $ ``` Не получается! На самом деле, причина должна быть очевидной. Подумайте об этом: вставка кода в ядро в очень реальном смысле превосходит даже права суперпользователя (root) на системе – напоминаю вам: это код ядра, который будет выполняться с привилегиями ядра. Если бы любой пользователь мог вставлять или удалять модули ядра, хакеры бы устроили настоящий праздник! Внедрение вредоносного кода на уровне ОС стало бы тривиальной задачей. Поэтому по соображениям безопасности только с правами root можно вставлять или удалять модули ядра. > Технически, быть root означает, что Real и/или Effective UID (RUID/EUID) процесса (или потока) имеет специальное значение ноль. Кроме того, современное ядро "видит" поток как обладающий определенными возможностями (через более современную и улучшенную модель POSIX Capabilities). Помимо того, чтобы быть root, еще лучшим подходом является использование этой модели возможностей; с ее помощью только процесс/поток с возможностью CAP_SYS_MODULE может (раз)гружать модули ядра. Для получения дополнительной информации мы отсылаем вас к странице руководства по capabilities(7). Итак, давайте еще раз попробуем вставить наш модуль ядра в память, на этот раз с привилегиями root через sudo: ```bash $ sudo insmod ./helloworld_lkm.ko [sudo] password for c2kp: $ echo $? 0 ``` Теперь это работает (значение переменной результата $? равно 0, что означает, что предыдущая команда оболочки была успешной)! Как упоминалось ранее, утилита insmod работает за счет вызова системного вызова finit_module(). Когда может не сработать утилита insmod? Вот несколько случаев: - Если конфигурация ядра CONFIG_MODULES не установлена на y – то есть, ядро собрано без поддержки загружаемых модулей ядра. - Когда не выполняется от имени root, или отсутствует возможность CAP_SYS_MODULE (в этом случае errno устанавливается в значение EPERM). - Когда переменная ядра в файловой системе proc, /proc/sys/kernel/modules_disabled, установлена на 1 (по умолчанию она равна 0). - Когда в памяти ядра уже есть модуль ядра с таким же именем (в этом случае errno устанавливается в значение EEXISTS). Отлично, здесь все выглядит хорошо. Это здорово, но где же наше драгоценное сообщение "Hello, world"? Читайте дальше! #### Быстрый первый взгляд на kernel printk() Чтобы вывести сообщение, разработчик в пространстве пользователя часто использует надежный API glibc printf() (или, возможно, cout, если пишет на C++). Однако важно понимать, что в пространстве ядра нет (пользовательских или других) библиотек. Следовательно, у нас нет доступа к старому доброму API printf(). Вместо этого он был в основном переосмыслен в ядре как API ядра printk(). Любопытно узнать, где находится его код? Ну, он определен здесь как макрос в дереве исходного кода ядра: include/linux/printk.h:printk(fmt, …). Реальная функция находится здесь: kernel/printk/printk.c:_printk(). На самом деле, внутреннее внедрение printk довольно сложное, включает в себя слой обертки для индексации printk и многое другое; см. раздел "Дальнейшее чтение" для получения дополнительной информации. К счастью, для ядра или модуля ядра вывести сообщение через API printk() просто и очень похоже на использование printf(). В нашем простом модуле ядра действие происходит здесь: ```c printk(KERN_INFO "Hello, world\n"); ``` Хотя на первый взгляд это очень похоже на printf, API printk в ядре на самом деле довольно отличается. В плане сходства, API принимает форматную строку в качестве параметра. Форматная строка практически идентична той, что используется в printf. Но на этом сходство заканчивается. Основное отличие между printf и printk заключается в следующем: API библиотеки printf в пространстве пользователя работает, форматируя текстовую строку согласно запросу и вызывая системный вызов write(), который затем фактически выполняет запись в устройство stdout, которое по умолчанию является окном терминала (или консольным устройством). API printk ядра также форматирует текстовую строку согласно запросу, но его места назначения отличаются. Он пишет по крайней мере в одно место – первое в следующем списке – и возможно в несколько других: - Буфер журнала ядра в ОЗУ (летучий) - Файл лога ядра (не летучий) - Устройство консоли > Пока мы пропустим внутренние детали работы printk. Также, пока игнорируйте токен KERN_INFO внутри API printk; мы скоро охватим все это. > Далее, не привязывайтесь слишком сильно к API printk(); вы скоро узнаете, что современный и рекомендуемый способ его использования - через макросы вида pr_foo() (мы рассмотрим их в предстоящем разделе "The `pr_` convenience macros"). Когда вы выводите сообщение через printk, гарантированно, что вывод попадет в буфер журнала в памяти ядра (ОЗУ). Это, по сути, составляет (летучий) журнал ядра. Важно отметить, что вы никогда не увидите вывод printk напрямую, работая в графическом режиме с запущенным процессом сервера X (Xorg, Xwayland или что-то еще) (что является стандартной средой при работе на типичном дистрибутиве Linux). Итак, очевидный вопрос здесь: как можно увидеть содержимое буфера журнала ядра? Существует несколько способов. Пока давайте воспользуемся быстрым и простым методом. Используем утилиту dmesg! По умолчанию dmesg выведет все содержимое буфера журнала ядра в stdout. Давайте попробуем! ```bash $ dmesg dmesg: read kernel buffer failed: Operation not permitted ``` > Мы часто будем сталкиваться с тем, что называется sysctl. Здесь sysctl - это сокращение от system control, своего рода регулировочный рычаг внутри ядра, обычно расположенный под `/proc/sys//*`; есть несколько директорий, включая fs, kernel, net, user, и vm. Обычно для записи в sysctl требуются привилегии root, в то время как многие можно прочитать без спец. привилегий. Официальная документация ядра по sysctl ядра здесь, обязательно посмотрите: [https://www.kernel.org/doc/Documentation/sysctl/kernel.txt](https://www.kernel.org/doc/Documentation/sysctl/kernel.txt). Аналогичным образом, для других ветвей также есть документация; ищите здесь: [https://www.kernel.org/doc/Documentation/sysctl/](https://www.kernel.org/doc/Documentation/sysctl/). Также посмотрите на этот полезный сайт "sysctl explorer": [https://sysctl-explorer.net/](https://sysctl-explorer.net/). На нескольких современных дистрибутивах sysctl с именем dmesg_restrict (больше об этом позже) предотвращает просмотр содержимого журнала ядра обычными пользователями; это хорошо для безопасности (предотвращение утечек информации). Итак, давайте попробуем снова, но на этот раз с привилегиями root; посмотрим последние две строки буфера журнала ядра: ```bash $ sudo dmesg | tail -n2 [39884.691954] Hello, world ``` Вот она, наконец: наше сообщение "Hello, world"! > Вы можете увидеть сообщение в журнале ядра, что-то вроде "loading out-of-tree module taints kernel" и возможно "module verification failed: signature and/or required key missing - tainting kernel". Вы можете просто игнорировать их пока. > По соображениям безопасности, большинство современных дистрибутивов Linux будут помечать ядро как "tainted" (дословно, "загрязненное" или "зараженное"), если вставляется сторонний/вне-дерево/внешний (или не подписанный) модуль ядра. (На самом деле, это больше похоже на псевдо-юридическую страховку вроде "если что-то пойдет не так с этого момента и далее, мы за это не отвечаем, и так далее..." - вы понимаете идею!) В журнале ядра, как отображается утилитой dmesg, числа в квадратных скобках в левой колонке - это простой временной штамп в формате [секунды.микросекунды] - время, прошедшее с момента загрузки системы (но не рекомендуется считать его идеально точным). Кстати, этот временной штамп является переменной Kconfig - опцией конфигурации ядра - с названием CONFIG_PRINTK_TIME; её можно переопределить параметром ядра printk.time (ссылка: [https://elixir.bootlin.com/linux/v6.1.25/source/Documentation/admin-guide/kernel-parameters.txt#L4504](https://elixir.bootlin.com/linux/v6.1.25/source/Documentation/admin-guide/kernel-parameters.txt#L4504)). #### Перечисление живых модулей ядра Вернемся к нашему модулю ядра. До сих пор мы его собрали, загрузили в ядро и убедились, что его точка входа, функция helloworld_lkm_init(), была вызвана, тем самым выполнив API printk. Так что после этого, что он делает? Ну, ничего; модуль ядра просто (счастливо?) сидит в памяти ядра, ничего не делая. Мы можем легко найти его с помощью утилиты lsmod: ```bash $ lsmod | head helloworld_lkm 16384 0 isofs 49152 1 vboxvideo 45056 0 vboxsf 90112 0 binfmt_misc 24576 1 snd_intel8x0 49152 2 snd_ac97_codec 176128 1 snd_intel8x0 ac97_bus 16384 1 snd_ac97_codec snd_pcm 151552 2 snd_intel8x0,snd_ac97_codec ``` lsmod показывает все модули ядра, которые в данный момент находятся (или живут) в памяти ядра, отсортированные в обратном хронологическом порядке. Его вывод форматирован столбцами, с тремя основными и необязательным четвертым. Давайте рассмотрим каждый столбец отдельно: - Первый столбец отображает имя модуля ядра. - Второй столбец - это (статический) размер в байтах, который он занимает в ядре. - Третий столбец - это счетчик использования модуля. - Необязательный четвертый столбец указывает на зависимый(е) модуль(и) и объясняется в следующей главе (в разделе "Module stacking"). Также, на недавних дистрибутивах Ubuntu x86_64 минимум 16 КБ памяти ядра кажется занятым модулем ядра, и около 12 КБ на Fedora. Отлично! К этому моменту вы успешно собрали, загрузили и запустили ваш первый модуль ядра в памяти ядра, и он работает в основном: что дальше? Ну, с этим модулем ничего особенного! Мы просто узнаем, как его выгрузить в следующем разделе. Конечно, впереди еще много чего... продолжайте! #### Выгрузка модуля из памяти ядра Для выгрузки модуля ядра используем удобную утилиту rmmod (remove module): ```bash $ rmmod rmmod: ERROR: missing module name. $ rmmod helloworld_lkm rmmod: ERROR: ../libkmod/libkmod-module.c:799 kmod_module_remove_module() could not remove 'helloworld_lkm': Operation not permitted rmmod: ERROR: could not remove module helloworld_lkm: Operation not permitted $ sudo rmmod helloworld_lkm [sudo] password for c2kp: $ dmesg | tail -n2 [39884.691954] Hello, world [40280.138269] Goodbye, world! Climate change has done us in... $ ``` Параметром для rmmod является имя модуля ядра (как показано в первом столбце lsmod), а не путь к нему. Очевидно, как и с insmod, нам нужно запускать утилиту rmmod от имени пользователя root, чтобы это сработало (или с возможностью CAP_SYS_MODULE). Здесь мы также видим, что из-за нашего rmmod, фреймворк LKM сначала вызывает "выходную" процедуру (или "деструктор") функцию helloworld_lkm_exit() модуля, который удаляется. Она в свою очередь вызывает printk, который выводит сообщение "Goodbye, world..." (которое мы проверили с помощью dmesg). Когда может не сработать rmmod (обратите внимание, что внутри он вызывает системный вызов delete_module())? Вот несколько случаев: - Права доступа: Если не запускать от имени root или нет возможности CAP_SYS_MODULE (тогда errno устанавливается в значение EPERM). - Если код и/или данные модуля ядра используются другим модулем (если существует зависимость; это подробно рассматривается в следующей главе в разделе "Module stacking") или модуль в данный момент используется процессом (или потоком), то счетчик использования модуля будет положительным и rmmod не удастся (тогда errno устанавливается в значение EBUSY); это логично. - Модуль ядра не указал выходную процедуру (или деструктор) с макросом module_exit() и опция конфигурации ядра CONFIG_MODULE_FORCE_UNLOAD отключена. Несколько удобных утилит, связанных с управлением модулями, не что иное, как символические (мягкие) ссылки на единственную утилиту kmod (аналогично тому, что делает популярная утилита busybox). Обертки - это lsmod, rmmod, insmod, modinfo, modprobe и depmod. Посмотрите на несколько из них: ```bash $ ls -l $(which insmod) ; ls -l $(which lsmod) ; ls -l $(which rmmod) lrwxrwxrwx 1 root root 9 23 апр 2022 /usr/sbin/insmod -> /bin/kmod lrwxrwxrwx 1 root root 9 23 апр 2022 /usr/sbin/lsmod -> /bin/kmod lrwxrwxrwx 1 root root 9 23 апр 2022 /usr/sbin/rmmod -> /bin/kmod ``` Обратите внимание, что точное местоположение этих утилит (/bin, /sbin или /usr/sbin) может варьироваться в зависимости от дистрибутива. #### Наш скрипт lkm Завершим обсуждение первого модуля ядра простым, но полезным пользовательским скриптом Bash под названием lkm, который автоматизирует работу по сборке, загрузке, просмотру dmesg и выгрузке модуля ядра. Вот он (полный код находится в корне дерева исходного кода этой книги): ```bash #!/bin/bash # lkm : a silly kernel module dev - build, load, unload - helper wrapper script […] # Включаем неофициальный строгий режим Bash! Очень полезно # "Преобразует многие виды скрытых, периодических или тонких ошибок в немедленные, очевидные ошибки" [...] set -euo pipefail unset ARCH unset CROSS_COMPILE name=$(basename "${0}") # Отображение и выполнение предоставленной команды. # Параметр(ы) : команда для выполнения runcmd() { local SEP="------------------------------" [[ $# -eq 0 ]] && return echo "${SEP} $* ${SEP}" eval "$@" [[ $? -ne 0 ]] && echo " ^--[FAILED]" } ### "main" здесь [[ $# -ne 1 ]] && { echo "Usage: ${name} name-of-kernel-module-file (without the .c)" exit 1 } [[ "${1}" = *"."* ]] && { echo "Usage: ${name} name-of-kernel-module-file ONLY (do NOT put any extension)." exit 1 } echo "Version info:" which lsb_release >/dev/null 2>&1 && { echo -n "Distro: " lsb_release -a 2>/dev/null |grep "Description" |awk -F':' '{print $2}' } echo -n "Kernel: " ; uname -r runcmd "sudo rmmod $1 2> /dev/null" || true # runcmd "make clean" || true runcmd "sudo dmesg -C > /dev/null" || true runcmd "make || exit 1" || true [[ ! -f "$1".ko ]] && { echo "[!] ${name}: $1.ko has not been built, aborting..." exit 1 } runcmd "sudo insmod ./$1.ko && lsmod|grep $1" || true # Ubuntu 20.10 и выше включила CONFIG_SECURITY_DMESG_RESTRICT ! # Это хорошо для безопасности; так что нам нужно 'sudo' для dmesg runcmd "sudo dmesg" || true exit 0 ``` Указывая имя модуля ядра как параметр – без части расширения (например, .c) – скрипт lkm выполняет некоторые проверки на валидность, отображает информацию о версии (хотя на данный момент это работает только для Ubuntu), и затем использует обертку функции Bash runcmd() для отображения имени и выполнения заданной команды, в результате чего обеспечивается безболезненное выполнение процесса сборки/загрузки/lsmod/dmesg. Давайте будем эмпиричны и попробуем это на нашем первом модуле ядра (обратите внимание, что /home/c2kp/lkp2e - это всего лишь символическая ссылка на полный путь, где находится код книги): ![[figure46.png]] Рисунок 4.6: Скриншот, показывающий работу нашего удобного скрипта lkm, выполняющего сборку, загрузку и dmesg для нашего модуля ядра Все сделано! Не забудьте выгрузить модуль ядра с помощью rmmod. Поздравляем! Вы теперь научились писать и тестировать простой модуль ядра "Hello, world". Но до того, как вы будете отдыхать на лаврах, остается еще много работы; следующий раздел углубляется в дополнительные ключевые детали касательно логирования ядра и универсального API printk. #### Понимание логирования ядра и printk Еще многое предстоит обсудить по поводу логирования сообщений ядра через kernel API printk. Этот раздел углубляется в некоторые детали. Важно, чтобы начинающий разработчик ядра/драйверов, как вы, четко понимал эти темы. Ранее, в разделе "Быстрый первый взгляд на kernel printk()", мы рассмотрели основы использования функционала API printk ядра (если хотите, посмотрите еще раз). Здесь мы исследуем гораздо больше относительно использования API printk(). Начнем! #### Использование кольцевого буфера памяти ядра Буфер журнала ядра - это просто буфер памяти в виртуальном адресном пространстве ядра, где сохраняется (логируется) вывод printk. Технически, это глобальная переменная __log_buf[]. Её определение в исходном коде ядра выглядит следующим образом: ```c kernel/printk/printk.c #define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT) #define LOG_BUF_LEN_MAX (u32)(1 << 31) static char __log_buf[__LOG_BUF_LEN] __aligned(LOG_ALIGN); static char *log_buf = __log_buf; static u32 log_buf_len = __LOG_BUF_LEN; ``` Он архитектурно представляет собой кольцевой буфер; он имеет конечный размер (__LOG_BUF_LEN байт), и когда он заполняется, перезапись начинается с нулевого байта. Отсюда и название "кольцевой" (или циркулярный) буфер. Здесь мы видим, что размер основан на переменной Kconfig CONFIG_LOG_BUF_SHIFT (1 << n в C означает 2^n). Это значение показано и может быть изменено как часть конфигурации ядра здесь: General Setup | Kernel log buffer size. Это целочисленное значение с диапазоном от 12 до 25 (мы всегда можем искать в init/Kconfig и увидеть его спецификацию), со значением по умолчанию 18. Таким образом, размер буфера журнала ядра по умолчанию составляет (1 << 18), что равно 218 = 256 КБ. Однако реальный размер в процессе выполнения также зависит от других директив конфигурации - в частности, от LOG_CPU_MAX_BUF_SHIFT, который делает размер функцией от количества процессоров в системе. Более того, соответствующий файл Kconfig говорит: "Также эта опция игнорируется, если используется параметр ядра log_buf_len, так как он принудительно задает точный (степень двойки) размер кольцевого буфера." Это интересно; мы часто можем переопределить значения по умолчанию, передав параметр ядра (через загрузчик)! > К слову, параметры ядра полезны, их много и они разнообразны, и их определенно стоит посмотреть. Официальная документация доступна здесь: [https://www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html](https://www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html). Отрывок из документации ядра Linux по параметру ядра log_buf_len раскрывает детали: ```text log_buf_len=n[KMG] Sets the size of the printk ring buffer, in bytes. n must be a power of two and greater than the minimal size. The minimal size is defined by LOG_BUF_SHIFT kernel config parameter. There is also CONFIG_LOG_CPU_MAX_BUF_SHIFT config parameter that allows to increase the default size depending on the number of CPUs. See init/Kconfig for more details. ``` Независимо от размера буфера журнала ядра, становятся очевидны две проблемы при работе с API printk: - Его сообщения записываются в летучую память (ОЗУ); если система выйдет из строя или будет перезагружена любым способом, мы потеряем драгоценный журнал ядра (часто это сильно ограничивает или даже исключает нашу возможность отладить проблемы ядра). - Буфер журнала по умолчанию не очень большой, обычно всего 256 КБ (и/или возможно 4 до 8 КБ на каждый CPU в системе); ясно, что обильные выводы переполнят кольцевой буфер, заставляя его зацикливаться и, следовательно, теряя информацию. Как это можно исправить? Читайте дальше... #### Логирование ядра и journalctl от systemd Так же, как приложения в пользовательском пространстве используют журналы, так и ядро; эта функция называется логированием ядра и она необходима на всех, за исключением самых ограниченных по ресурсам систем. Очевидным решением проблем, упомянутых выше, с логированием ядра в маленький и летучий буфер памяти, является запись (добавление, точнее говоря) каждого сообщения printk ядра в файл на вторичном хранилище (не летучем). Именно так настроены большинство современных дистрибутивов Linux. Место расположения файла журнала ядра варьируется в зависимости от дистрибутива: традиционно, основанные на Red Hat записывают в файл /var/log/messages, а основанные на Debian - в /var/log/syslog. Традиционно, printk ядра подключался к демону регистратора системы пользовательского пространства (syslogd) для выполнения регистрации в файл, тем самым автоматически получая выгоду от более продвинутых функций, таких как ротация логов, сжатие и архивация. В последние годы, однако, системное логирование полностью перешло к новому полезному и мощному фреймворку для инициализации системы под названием systemd (он заменяет или часто работает в дополнение к старому фреймворку SysV init). Действительно, systemd теперь регулярно используется даже на встраиваемых устройствах Linux. В рамках фреймворка systemd, логирование выполняется процессом демона под названием systemd-journal, а утилита journalctl является пользовательским интерфейсом к нему. > Подробное рассмотрение systemd и его связанных утилит относится к режиму пользователя и, следовательно, не входит в объем этой книги. Пожалуйста, обратитесь к разделу "Дальнейшее чтение" этой главы за ссылками на (много) дополнительную информацию по этому вопросу. Одним из ключевых преимуществ использования журнала для извлечения и интерпретации логов является то, что все логи – от приложений (процессов и потоков), библиотек, системных демонов, ядра, драйверов и так далее – записываются (сливаются) здесь. Таким образом, мы можем видеть (обратную) хронологическую линию событий, не требуя ручного объединения различных логов в хронологию. Страница руководства по утилите journalctl(1) детально описывает её различные опции. Здесь мы представляем несколько (надеюсь, удобных) псевдонимов на основе этой утилиты (мы предполагаем, что она находится в PATH): ```bash #--- несколько псевдонимов для journalctl(1) # jlog: только текущий (последний) запуск, все alias jlog='journalctl -b --all --catalog –no-pager' # jlogr: только текущий (последний) запуск, все, # в *обратном* хронологическом порядке alias jlogr='journalctl -b --all --catalog --no-pager --reverse' # jlogall: *все*, за все время; --merge => все логи объединены alias jlogall='journalctl --all --catalog --merge --no-pager' # jlogf: *смотреть* за живым логом, подобно режиму 'tail -f'; очень полезно для 'живого' просмотра логов; # используйте 'journalctl -f -k' для просмотра только printk ядра alias jlogf='journalctl -f' # jlogk: только сообщения ядра, этот (последний) запуск alias jlogk='journalctl -b -k --no-pager' ``` > Обратите внимание, что опция -b означает "показать логи только для текущего запуска" – другими словами, журнал отображается с момента последнего запуска системы до настоящего момента. Список сохраненных системных (пере)запусков можно увидеть с помощью journalctl --list-boots. Мы намеренно используем опцию --no-pager, так как она позволяет нам далее фильтровать вывод с помощью стандартных утилит Linux, таких как grep, awk, sort и т.д., по мере необходимости. Вот простой пример использования journalctl: ```bash $ journalctl -b -k --no-pager | tail -n2 Jul 07 12:25:05 osboxes kernel: Goodbye, world! Climate change has done us in... Jul 07 12:37:57 osboxes kernel: Hello, world ``` Обратите внимание на формат логов журнала по умолчанию: ```text [timestamp] [hostname] [source-of-msg]: [... log message ...] ``` Здесь [source-of-msg] будет kernel для сообщений ядра или именем конкретного приложения или службы, которое пишет сообщение. Полезно рассмотреть пару примеров использования из страницы руководства по journalctl(1): - Показать все логи ядра с предыдущего запуска: ```bash journalctl -k -b -1 ``` - Показать живой вывод логов от системной службы apache.service: ```bash journalctl -f -u apache ``` Кроме того, journalctl позволяет легко искать логи в удобном для человека временном формате – например, чтобы увидеть все логи ядра, записанные за последний полчаса: ```bash journalctl -k --since="30 min ago" ``` Несомненно, нелетучее логирование сообщений ядра в файлы очень полезно. Однако следует учитывать, что существуют обстоятельства, часто обусловленные ограничениями аппаратного обеспечения, которые могут сделать это невозможным. Например, крошечное, сильно ограниченное по ресурсам встраиваемое устройство на базе Linux может использовать небольшой внутренний чип флеш-памяти в качестве хранилища. Не только этот чип мал по размеру и все пространство практически занято приложениями, библиотеками, ядром, драйверами и загрузчиком, но также известно, что чипы на базе флеш-памяти имеют эффективный предел циклов стирания-записи, которые они могут выдержать до износа. Таким образом, запись в него несколько миллионов раз может привести к его выходу из строя! Поэтому иногда конструкторы систем намеренно и/или дополнительно используют более дешевую внешнюю флеш-память, такую как карты (micro)SD/MMC (MultiMediaCard), для не критичных данных, чтобы смягчить это воздействие, так как они легко (и дешево) заменяются. Давайте перейдем к пониманию уровней логирования printk. #### Использование уровней логирования printk Когда вы отправляете сообщение в журнал ядра через API printk (и его аналоги), обычно вы также должны указать уровень логирования для данного сообщения. Чтобы понять и использовать эти уровни логирования printk, давайте начнем с повторения той единственной строки кода – первого printk из нашего всемирно известного модуля ядра helloworld_lkm: ```c printk(KERN_INFO "Hello, world\n"); ``` Теперь давайте обсудим очевидное: что именно означает KERN_INFO? Во-первых, будьте внимательны; это не параметр, как может показаться на первый взгляд. Нет! Обратите внимание, что между ним и строкой формата "Hello, world\n" нет запятой, только пробелы. KERN_INFO - это один из восьми уровней логирования, на которых может регистрироваться kernel printk. Важно сразу понять, что этот уровень логирования не является указателем приоритета; его наличие позволяет нам фильтровать сообщения на основе уровня лога. Ядро определяет восемь возможных уровней логирования для printk; вот они: ```c // include/linux/kern_levels.h #ifndef __KERN_LEVELS_H__ #define __KERN_LEVELS_H__ #define KERN_SOH "\001" /* ASCII Start Of Header */ #define KERN_SOH_ASCII '\001' #define KERN_EMERG KERN_SOH "0" /* system is unusable */ #define KERN_ALERT KERN_SOH "1" /* action must be taken immediately */ #define KERN_CRIT KERN_SOH "2" /* critical conditions */ #define KERN_ERR KERN_SOH "3" /* error conditions */ #define KERN_WARNING KERN_SOH "4" /* warning conditions */ #define KERN_NOTICE KERN_SOH "5" /* normal but significant condition */ #define KERN_INFO KERN_SOH "6" /* informational */ #define KERN_DEBUG KERN_SOH "7" /* debug-level messages */ #define KERN_DEFAULT "" /* the default kernel loglevel */ ``` Теперь мы видим, что уровни логирования `KERN_` - это просто строки ("0", "1", ..., "7"), которые добавляются перед сообщениями ядра, выводимыми через printk, ничего больше. Это позволяет нам удобно фильтровать сообщения на основе уровня логирования. Комментарий справа от каждого из них четко показывает разработчику, когда использовать тот или иной уровень логирования. > Что такое KERN_SOH? Это ASCII символ начала заголовка (Start Of Header, SOH) со значением \001. Смотрите страницу руководства по ascii(7); утилита ascii выводит таблицу ASCII в различных числовых основаниях. Отсюда мы четко видим, что числовое значение 1 (или \001) - это символ SOH, что является здесь используемой конвенцией (не беспокойтесь об этом). Давайте быстро рассмотрим пару реальных примеров использования printk из дерева исходного кода ядра Linux. Когда драйвер таймера проверки зависания (hangcheck-timer) ядра (что-то вроде программного сторожевого таймера) определяет, что истечение определенного времени (по умолчанию 60 секунд) было задержано более, чем на определенный порог (по умолчанию 180 секунд), он перезапускает систему! Вот соответствующий код ядра – место, где драйвер hangcheck-timer выводит printk в этом контексте: [https://elixir.bootlin.com/linux/v6.1.25/source/drivers/char/hangcheck-timer.c#L134](https://elixir.bootlin.com/linux/v6.1.25/source/drivers/char/hangcheck-timer.c#L134): ```c // drivers/char/hangcheck-timer.c [...] if (hangcheck_reboot) { printk(KERN_CRIT "Hangcheck: hangcheck is restarting the machine.\n"); emergency_restart(); } else { [...] ``` Обратите внимание, как был вызван API printk с уровнем логирования, установленным на KERN_CRIT. С другой стороны, вывод информационного сообщения может быть как раз тем, что нужно. Здесь мы видим, как общий параллельный принтерный драйвер вежливо информирует всех заинтересованных лиц о том, что принтер горит (довольно сдержанно, да?). Соответствующий код находится здесь:[https://elixir.bootlin.com/linux/v6.1.25/source/drivers/char/lp.c#L260](https://elixir.bootlin.com/linux/v6.1.25/source/drivers/char/lp.c#L260): ```c // drivers/char/lp.c [...] if (last != LP_PERRORP) { last = LP_PERRORP; printk(KERN_INFO "lp%d on fire\n", minor); } ``` Можно подумать, что горящее устройство заслуживает вывода printk на уровне аварийного логирования, правда? По крайней мере, так считает arch/x86/kernel/cpu/mce/p5.c:pentium_machine_check(), [https://elixir.bootlin.com/linux/v6.1.25/source/arch/x86/kernel/cpu/mce/p5.c#L24](https://elixir.bootlin.com/linux/v6.1.25/source/arch/x86/kernel/cpu/mce/p5.c#L24): ```c // arch/x86/kernel/cpu/mce/p5.c [...] pr_emerg("CPU#%d: Machine Check Exception: 0x%8X (type 0x%8X).\n", smp_processor_id(), loaddr, lotype); if (lotype & (1<<5)) { pr_emerg("CPU#%d: Possible thermal failure (CPU on fire ?).\n", smp_processor_id()); } [...] ``` Далее следуют удобные макросы `pr_()`. > Часто задаваемый вопрос: если в printk() не указан уровень логирования, на каком уровне будет выведено сообщение? По умолчанию это будет 4, то есть KERN_WARNING (раздел "Writing to the console" объясняет, почему именно это так). Однако от вас ожидается, что вы всегда будете указывать подходящий уровень логирования при использовании printk. Существует более простой способ указать уровень логирования сообщения ядра; именно это мы исследуем далее. #### Макросы удобства `pr_` Макросы `pr_()` (часто называемые pr_*()), описанные здесь, облегчают написание кода. Громоздкое ```c printk(KERN_FOO "", vars...); ``` заменяется на элегантное ```c pr_foo("", vars...); ``` где `` - это уровень логирования, один из emerg, alert, crit, err, warn, notice, info или debug. Их использование поощряется: ```c // include/linux/printk.h: [ ... ] /** * pr_emerg - Print an emergency-level message * @fmt: format string * @...: arguments for the format string * * This macro expands to a printk with KERN_EMERG loglevel. It uses pr_fmt() to * generate the format string. */ #define pr_emerg(fmt, ...) \ printk(KERN_EMERG pr_fmt(fmt), ##__VA_ARGS__) /** * pr_alert - Print an alert-level message * @fmt: format string * @...: arguments for the format string * * This macro expands to a printk with KERN_ALERT loglevel. It uses pr_fmt() to * generate the format string. */ #define pr_alert(fmt, ...) \ printk(KERN_ALERT pr_fmt(fmt), ##__VA_ARGS__) [...] #define pr_err(fmt, ...) \ printk(KERN_ERR pr_fmt(fmt), ##__VA_ARGS__) [ ... ] #define pr_warn(fmt, ...) \ printk(KERN_WARNING pr_fmt(fmt), ##__VA_ARGS__) [ ... ] #define pr_notice(fmt, ...) \ printk(KERN_NOTICE pr_fmt(fmt), ##__VA_ARGS__) [ ... ] #define pr_info(fmt, ...) \ printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__) [ ... ] /** * pr_devel - Print a debug-level message conditionally [ ... ] * This macro expands to a printk with KERN_DEBUG loglevel if DEBUG is * defined. Otherwise it does nothing. [ ... ] #ifdef DEBUG #define pr_devel(fmt, ...) \ printk(KERN_DEBUG pr_fmt(fmt), ##__VA_ARGS__) [ ... ] ``` Обсуждая использование макросов pr_*(), стоит упомянуть pr_cont(). Его задача - выступать в роли продолжения строки, продолжая предыдущий printk! Это может быть полезно... вот пример его использования: ```c // kernel/module.c if (last_unloaded_module[0]) pr_cont(" [last unloaded: %s]", last_unloaded_module); pr_cont("\n"); ``` Мы обычно гарантируем, что только последний pr_cont() содержит символ новой строки. > Чтобы избежать неприятных сюрпризов, таких как эффект от printk(), который кажется не сработавшим, привыкайте заканчивать ваши printk символом новой строки \n (это верно также и для printf()). С этого момента мы будем использовать эти макросы pr_foo() (или pr_*()) для вывода сообщений printk в журнал ядра. Кроме того, и это очень важно: авторы драйверов должны использовать макросы dev_*(). Это подразумевает передачу дополнительного первого параметра - указателя на устройство (struct device *). Все они определены здесь: [https://elixir.bootlin.com/linux/v6.1.25/source/include/linux/dev_printk.h#L137](https://elixir.bootlin.com/linux/v6.1.25/source/include/linux/dev_printk.h#L137). Причина их использования в том, что ядро драйверов автоматически добавляет полезные детали к сообщениям printk, тем самым улучшая логи (и позволяя быстро заметить детали). Например, при написании драйвера для I2C, сообщение dev_*() будет включать такие детали, как имя драйвера, номер шины I2C и адрес чипа! Вот пример из (переделанного) драйвера I2C RTC: ```text ks3231 1-0068: IRQ! #5 ``` Здесь ks3231 - это имя драйвера; для токена 1-0068, 1 - это номер шины I2C, а 0x68 - адрес, где находится клиентский чип (RTC) на этой шине. Собственно сообщение - IRQ! #5. Полезно. > Ядро позволяет нам передавать loglevel=n как параметр командной строки ядра, где n - это целое число от 0 до 7, соответствующее восьми упомянутым ранее уровням логирования. Как и ожидалось (и как вы скоро узнаете), все экземпляры printk с уровнем логирования меньше того, который был передан, также будут направлены в устройство консоли. Запись сообщения ядра непосредственно на консольное устройство иногда очень полезна; в следующем разделе мы рассмотрим как этого добиться. #### Запись в консольное устройство Вспомните, что вывод printk может направляться в три локации: - Первое - это буфер журнала в памяти ядра (всегда) - Второе - нелетучие файлы логов (обычно) - Последнее (что мы рассмотрим здесь) - это консольное устройство Традиционно, консольное устройство - это чисто ядерная функция, начальное окно терминала, в которое (супер)пользователь входит в неграфическом окружении (/dev/console). Интересно, что на Linux мы можем определить несколько консолей - окно терминала teletype (tty) (например, /dev/console), текстовый режим VGA консоли, фреймбуфер или даже последовательный порт, обслуживаемый через USB (что распространено на встраиваемых системах во время разработки; см. больше информации о консолях Linux в разделе "Дальнейшее чтение" этой главы). Например, когда мы подключаем плату Raspberry Pi к ноутбуку x86_64 через кабель USB-to-RS232 TTL UART (см. раздел "Дальнейшее чтение" этой главы для статьи блога об этом полезном аксессуаре и о том, как его настроить на Raspberry Pi) и затем используем приложение эмулятор терминала, такое как minicom (или screen), вот что появляется как устройство tty на Raspberry Pi - это последовательный порт: ```bash rpi # tty /dev/ttyS0 ``` Суть в том, что консоль часто является целью важных сообщений журнала, включая те, что исходят из глубины ядра. printk Linux использует sysctl, механизм на основе proc, для условной доставки своих логов на консольное устройство. Чтобы лучше это понять, давайте сначала рассмотрим соответствующий псевдофайл proc (здесь, на нашем гостевом x86_64 Ubuntu): ```bash $ cat /proc/sys/kernel/printk 4 4 1 7 ``` Мы интерпретируем эти четыре числа как уровни логирования printk (где 0 - самый высокий, а 7 - самый низкий по "срочности"). Значение этой последовательности из четырех целых чисел следующее: - Текущий (консольный) уровень логирования. Ключевое следствие - все сообщения ниже этого значения также будут отправлены на консольное устройство! - Уровень по умолчанию для сообщений, у которых нет явного уровня логирования. - Минимально допустимый уровень логирования. - Уровень логирования по умолчанию на момент загрузки. Мы видим, что уровень 4 соответствует KERN_WARNING. Таким образом, с первым числом, равным 4 (действительно, типичное значение по умолчанию на дистрибутиве Linux), все экземпляры printk, имеющие уровень меньше 4, будут отправлены на консольное устройство, помимо логирования в буфер журнала ядра и файл, разумеется. В эффекте, с этими настройками, это применимо ко всем сообщениям ядра на следующих уровнях логирования: KERN_EMERG, KERN_ALERT, KERN_CRIT, и KERN_ERR будут по умолчанию автоматически отправлены на консольное устройство. > Сообщения ядра уровня 0 [KERN_EMERG] всегда выводятся на консоль, и действительно, во все (не-GUI) окна терминала, а также в буфер журнала ядра и файл лога, независимо от каких-либо настроек. Стоит отметить, что очень часто при работе с встраиваемым Linux или любым развитием ядра вы будете работать с консольным устройством, как это было показано на примере Raspberry Pi. На моей Raspberry Pi 4 модели B, работающей на стандартном дистрибутиве (и ядре), вот какое значение по умолчанию для уровня логирования printk: ![[figure47.png]] Рисунок 4.7: Скриншот работы minicom для подключения к плате Raspberry Pi через USB-to-serial кабель, просмотр нескольких вещей Обратите внимание, что значения по умолчанию для логирования printk (то есть, printk sysctl) отличаются от тех, что на x86_64; такое случается. Интересный момент: установка первого целого числа в псевдофайле /proc/sys/kernel/printk на 8 гарантирует, что все экземпляры printk будут отправлены непосредственно на консоль, таким образом, заставляя printk вести себя как обычный printf! Здесь мы показываем, как пользователь root может легко это настроить: ```bash $ sudo sh -c "echo '8 4 1 7' > /proc/sys/kernel/printk" $ cat /proc/sys/kernel/printk 8 4 1 7 ``` Конечно, мы делаем это от имени root; обратите внимание на используемый синтаксис shell. Настройка вывода на консоль таким образом может быть очень удобной во время разработки и тестирования! Однако, осознайте, что изменения, внесенные в этот sysctl (через proc), временны; они применяются только для данного сеанса. После перезагрузки системы или цикла питания изменения теряются. Один из способов сделать изменения sysctl постоянными - это через утилиту sysctl(8) – например, выполнив, от имени root, следующее: ```bash sysctl -w kernel.printk='8 4 1 7' ``` Это заставит sysctl printk устанавливаться в эти значения при каждой загрузке. > На моей Raspberry Pi (и других платах) у меня есть скрипт запуска, который содержит следующую строку: ```bash [[ $(id -u) -eq 0 ]] && echo "8 4 1 7" > /proc/sys/kernel/printk ``` > Таким образом, при запуске от имени root, это вступает в силу, и все экземпляры printk теперь напрямую отображаются на консоли minicom, как это делает printf; отлично для отладки! Используя sysctl, вы можете сделать это постоянным. Говоря о универсальном Raspberry Pi, следующий раздел демонстрирует выполнение модуля ядра на нём. #### Вывод на консоль Raspberry Pi Ко второму модулю ядра! Здесь мы (с этого момента) будем использовать макросы стиля pr_*(), чтобы вывести девять экземпляров printk, по одному на каждом из восьми уровней логирования, плюс один через макрос pr_devel() (который на самом деле ничем не отличается от уровня KERN_DEBUG). Давайте посмотрим на соответствующий код: ```c // ch4/printk_loglvl/printk_loglvl.c [ … ] static int __init printk_loglvl_init(void) { pr_emerg("Hello, world @ log-level KERN_EMERG [0]\n"); pr_alert( "Hello, world @ log-level KERN_ALERT [1]\n"); pr_crit( "Hello, world @ log-level KERN_CRIT [2]\n"); pr_err( "Hello, world @ log-level KERN_ERR [3]\n"); pr_warn( "Hello, world @ log-level KERN_WARNING [4]\n"); pr_notice("Hello, world @ log-level KERN_NOTICE [5]\n"); pr_info( "Hello, world @ log-level KERN_INFO [6]\n"); pr_debug("Hello, world @ log-level KERN_DEBUG [7]\n"); pr_devel( "Hello, world via the pr_devel() macro" " (eff @KERN_DEBUG) [7]\n"); return 0; /* success */ } static void __exit printk_loglvl_exit(void) { pr_info("Goodbye, world @ log-level KERN_INFO [6]\n"); } module_init(printk_loglvl_init); module_exit(printk_loglvl_exit); ``` > Теперь мы обсудим вывод при запуске указанного модуля ядра printk_loglvl на устройстве Raspberry Pi. Если у вас его нет или он недоступен, это не проблема; идите и попробуйте это на x86_64 (гостевой виртуальной машине или родной системе). На устройстве Raspberry Pi (здесь я использовал Raspberry Pi 4 модель B с установленной по умолчанию операционной системой Raspberry Pi OS), мы входим в систему и получаем root-шелл с помощью простой команды sudo -s. Затем мы собираем модуль ядра. Если вы установили стандартный образ Raspberry Pi на устройство, все необходимые инструменты для разработки, заголовочные файлы ядра и прочее будут предустановлены! Итак, войдите в систему на плате, соберите модуль и попробуйте его. Рисунок 4.8 - это скриншот выполнения нашего модуля ядра printk_loglvl на плате Raspberry Pi. Также важно понимать, что мы работаем на консольном устройстве, так как используем упомянутый выше USB-to-serial кабель через приложение эмулятора терминала minicom, а не просто через SSH-соединение: ![[figure48.png]] Рисунок 4.8: Окно приложения эмулятора терминала minicom – консоль – с выводом модуля ядра printk_loglvl Обратите внимание на кое-что немного отличающееся от среды x86_64: здесь, по умолчанию, первое целое число в выводе /proc/sys/kernel/printk – текущий уровень логирования консоли – это 3 (не 4). Это значит, что все экземпляры printk ядра с уровнем логирования меньше уровня 3 будут появляться непосредственно на консольном устройстве. Смотрите на скриншот: это действительно так! Кроме того, как и ожидалось, экземпляр printk на уровне "аварийного" логирования (0, KERN_EMERG) всегда появляется на консоли – действительно, на каждом открытом не-GUI окне терминала. Теперь к интересной части; давайте установим (конечно, как root) текущий уровень логирования консоли (помните, это первое целое число в выводе /proc/sys/kernel/printk) на значение 8. Таким образом, все экземпляры printk должны появляться непосредственно на консоли. Мы проверяем именно это здесь: ![[figure49.png]] Рисунок 4.9: Окно терминала minicom – фактически, консоль – с уровнем логирования консоли установленным на 8 Конечно, сначала мы удаляем предыдущий экземпляр модуля из памяти с помощью rmmod. Действительно, как и ожидалось, мы видим все экземпляры printk на самом консольном устройстве, что делает ненужным использование dmesg. Однако, подождите минуту: что случилось с макросами pr_debug() и pr_devel(), которые должны были вывести сообщение ядра на уровне логирования KERN_DEBUG (то есть, целое значение 7)? Оно не появилось ни на консоли, ни в следующем выводе dmesg. Мы объясним это сейчас; продолжайте читать. С помощью dmesg, конечно, будут показаны все сообщения ядра – по крайней мере, те, которые все еще находятся в буфере журнала (кольцевом) ядра в ОЗУ. Мы видим, что это так: ```bash rpi # rmmod printk_loglvl rpi # dmesg [...] [ 2086.684939] Hello, world @ log-level KERN_EMERG [0] [ 2086.690143] Hello, world @ log-level KERN_ALERT [1] [ 2086.695526] Hello, world @ log-level KERN_CRIT [2] [ 2086.700826] Hello, world @ log-level KERN_ERR [3] [ 2086.706233] Hello, world @ log-level KERN_WARNING [4] [ 2086.711999] Hello, world @ log-level KERN_NOTICE [5] [ 2086.717931] Hello, world @ log-level KERN_INFO [6] [ 2372.690250] Goodbye, world @ log-level KERN_INFO [6] rpi # ``` Все экземпляры printk – кроме тех, что на уровне KERN_DEBUG – видны, так как мы смотрим на журнал ядра через утилиту dmesg. Так как же отобразить сообщение отладки? Это рассмотрено в следующем разделе. #### Включение отладочных сообщений ядра Мы только что увидели, что можно выводить экземпляры printk на различных уровнях логирования; все они отображались, кроме сообщений уровня отладки. Да, pr_debug() (а также dev_dbg()) оказывается особым случаем; если символ DEBUG не определен для модуля ядра, экземпляр printk на уровне KERN_DEBUG не отображается. Мы можем изменить Makefile модуля ядра, чтобы это включить. Есть (по крайней мере) два способа сделать это: - Вставить эту строку в Makefile: ```makefile CFLAGS_printk_loglvl.o := -DDEBUG ``` Общий вид: `CFLAGS_.o := ` - Мы также можем – и это более распространено – просто вставить это утверждение в Makefile: ```makefile ccflags_y += -DDEBUG ``` Традиционно переменная Makefile для передачи дополнительных флагов компилятора C называлась EXTRA_CFLAGS; теперь это считается устаревшим, поэтому мы используем новую переменную ccflags_y. В нашем Makefile мы намеренно оставили -DDEBUG закомментированным изначально. Теперь, чтобы попробовать это, раскомментируйте одну из следующих закомментированных строк (я бы предложил раскомментировать первую, переменную ccflags_y): ```makefile # Enable the pr_debug() as well (rm the comment from one of the lines below) # (Note: EXTRA_CFLAGS deprecated; use ccflags-y) #ccflags-y += -DDEBUG #CFLAGS_printk_loglvl.o := -DDEBUG ``` После редактирования, пересоберите модуль и вставьте его, используя наш скрипт lkm. Частичный скриншот (Рисунок 4.10) вывода скрипта lkm ясно показывает цветовое кодирование dmesg, где фон KERN_ALERT / KERN_CRIT / KERN_ERR выделен красным цветом/жирным красным шрифтом/красным цветом переднего плана соответственно, а KERN_WARNING - жирным черным шрифтом, помогая нам быстро заметить важные сообщения ядра: ![[figure410.png]] Рисунок 4.10: Частичный скриншот вывода скрипта lkm при запуске модуля с определенным DEBUG Мы выделили два экземпляра printk, выводимых на уровне KERN_DEBUG. Обратите внимание, что поведение pr_debug() не идентично, когда включена функция "динамическая отладка" (CONFIG_DYNAMIC_DEBUG=y; - рассмотрено следующим), в этом случае. > Как упоминалось ранее, авторам драйверов устройств следует внимательно отметить, что вы должны использовать специальные макросы dev_*() для вывода экземпляров printk, а не обычные pr_*(), используемые ядром и чистыми модулями. Это подразумевает передачу дополнительного первого параметра, указателя на устройство (struct device *). Все они определены здесь: [https://elixir.bootlin.com/linux/v6.1.25/source/include/linux/dev_printk.h#L137](https://elixir.bootlin.com/linux/v6.1.25/source/include/linux/dev_printk.h#L137). > Кроме того, pr_devel() предназначен для использования в отладочных printk внутри ядра, чей вывод никогда не должен быть виден в производственных системах. Теперь вернемся к разделу о выводе на консоль. Так вот, возможно, для целей отладки ядра/драйвера (если ничего другого), есть ли какой-то другой гарантированный способ убедиться, что все экземпляры printk направляются на консоль? Да, действительно – просто передайте параметр загрузки ядра под названием ignore_level. Для получения дополнительной информации об этом, ознакомьтесь с описанием в официальной документации ядра: [https://www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html](https://www.kernel.org/doc/html/latest/admin-guide/kernel-parameters.html). Дополнительно, вы можете включить игнорирование уровней логирования printk во время выполнения, записав Y в следующий псевдофайл, тем самым позволяя всем экземплярам printk появляться на консольном устройстве: ```bash sudo bash -c "echo Y > /sys/module/printk/parameters/ignore_loglevel" ``` Напротив, вы можете отключить это во время выполнения, записав N в тот же псевдофайл. Полезно! Утилита dmesg также может использоваться для управления включением/отключением сообщений ядра на консольное устройство, а также уровнем логирования консоли (то есть, уровнем, ниже которого сообщения будут отображаться на консоли) с помощью различных опциональных переключателей (в частности, опции --console-level). Я оставлю вам самостоятельное изучение страницы руководства по dmesg(1) для деталей. > Важное замечание: Использование API/mакросов printk (и их аналогов) для целей отладки подробно рассмотрено в моей книге "Linux Kernel Debugging" (конкретно в главе 3, "Debug via Instrumentation – printk and Friends"). Там рассматриваются обычные вещи с printk (которые мы здесь касались), советы и хитрости использования printk для отладки, ограничение скорости вывода и, что важно, использование мощной динамической отладочной структуры ядра. Обязательно ознакомьтесь с ней. #### Введение в мощную функцию динамической отладки ядра Нам, как программистам, нужны техники отладки. Возможно, самая известная - это инструментация, где отладочные выводы размещаются в стратегических местах в кодовой базе. Просматривая журнал, мы затем понимаем поток управления и, возможно, можем обнаружить ошибку. В ядре Linux отладочные и другие сообщения выводятся в журнал ядра через различные варианты printk – обычный макрос printk(), предпочтительные макросы pr_*(), и макросы dev_*() для драйверов; те, которые выводятся на уровне KERN_DEBUG, называются отладочными выводами. Таким образом, следующее являются возможными точками вызова для "отладочного уровня": - `pr_debug(""[, args…])`; - `dev_dbg(dev, ""[, args…])`; - `printk(KERN_DEBUG ""[, args…])`; Очень важно помнить, что отладочные экземпляры printk по умолчанию всегда отключены; только когда символ DEBUG определен, они действительно вступают в силу. Это кажется удобным; однако, при настоящей эксплуатации, что если вам нужно вывести, скажем, пару отладочных сообщений из данного модуля? Для этого вам придется определить символ DEBUG (конечно, вы можете сделать это в Makefile), пересобрать, выгрузить и перезагрузить модуль. Однако такой подход просто не практичен (или даже не разрешен) на большинстве производственных систем. Таким образом, нам нужен более динамичный подход, который и предлагает функция динамической отладки ядра. С функцией динамической отладки ядра, каждый отдельный вызов printk, выведенный на уровне KERN_DEBUG, как из самого ядра, так и из модулей ядра, компилируется в ядро. В интерфейсе make menuconfig, опция динамической отладки находится здесь: Kernel hacking > printk and dmesg options > Enable dynamic printk() support. Настройка - это булевская переменная под названием CONFIG_DYNAMIC_DEBUG.