Всякий раз, когда вы открываете браузер, кликаете мышкой или набираете текс в Linux, вы работаете в уютном мире user space или пользовательского пространства. Все привычные программы работают в этой песочнице, используя ОС для безопасного доступа к аппаратной части.
А за ширмой пользовательского интерфейса таится ядро Linux, которое уже и взаимодействует с железом. У ядра есть дополнительные привилегии при работе с аппаратурой, например, по обработке прерываний, управлению памятью, работой с GPIO.
Само ядро представляет собой монолит. Но как быть с поддержкой оборудования или файловых систем? Неужели ядро нужно пересобирать каждый раз при внесении каких-либо изменений? На заре истории Linux именно так дело и обстояло. Но со временем разработчики добавили механизм динамической загрузки отдельных компонентов ядра. Именно это и именуется модулями.
Частным случаем модуля ядра является драйвер. Он служит в качестве переводчика, который приводит взаимодействие с аппаратным устройством к некоторому стандартному API, через который с ним работает ядро.
Типы драйверов
- Символьные - для устройств, с которыми можно работать, как с потоком байт. Одним из примеров является консоль или последовательный порт (/dev/tty1). Для взаимодействия с устройством в пользовательском пространстве используются файлы символьного устройства (CDF).
- Блочные драйвера предназначены для работы с устройствами хранения данных (жесткие диски, SSD, USB-накопители), которые могут использоваться для размещения файловых систем. Сами файловые системы реализованы другими модулями ядра, которые работают поверх блочного драйвера.
- Сетевые драйвера обеспечивают передачу сетевых пакетов. Ввиду особенностей сетевого обмена и реализации стека, данный класс устройств стоит особняком, и в отличие от первых двух типов не имеет файловых узлов (нод), а использует сокеты, netdevice-API.
Особенности ядерного программирования
При необходимости, можно самостоятельно писать и загружать драйвера. Так как ядро написано на Си, драйвера пишутся на этом же языке.
Но с нюансами..:
- Это очень похоже на программирование в пространстве ядра, но эта видимость обманчива.
- Для модулей используются не библиотеки пространства пользователя, а ядерные. За бортом даже стандартная библиотека Си.
- Пользовательские и ядерные пространства изолированы между собой, для передачи данных нужно использовать специальные методы.
- Внутри ядра используется общая память, поэтому надо осторожно работать с глобальными переменными. Хорошим тоном считает их объявление через
static
. - При разработки драйверов у нас отсутствуют
stdin
,stdout
и тд. Вместо этого используется журнал ядра. Он представляет собой циклический буфер, куда отправляются сообщения от системных модулей. Как правило туда нет доступа из пространства пользователя, чтобы прочитать логи ядра можно использоватьdmesg
. А вообще, в системе за разбор лога отвечаетsyslog
. Сообщения разделены по уровням, которые можно посмотреть вlinux/kernel.h
:
#define KERN_EMERG "<0>" /* system is unusable */
#define KERN_ALERT "<1>" /* action must be taken immediately */
#define KERN_CRIT "<2>" /* critical conditions */
#define KERN_ERR "<3>" /* error conditions */
#define KERN_WARNING "<4>" /* warning conditions */
#define KERN_NOTICE "<5>" /* normal but significant condition */
#define KERN_INFO "<6>" /* informational */
#define KERN_DEBUG "<7>" /* debug-level messages */
Немного практики
Перед реализацией нашего первого драйвера, необходимо установить зависимости - заголовки ядра:
$ sudo apt install linux-headers-$(uname -r)
Так же необходимо подготовить инструменты для сборки нашего модуля:
KVERSION := $(strip $(shell uname -r))
KDIR := /lib/modules/$(KVERSION)/build
PWD := $(shell pwd)
obj-m := hello.o
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
@rm -f *.o .*.cmd .*.flags *.mod.c *.order .*.o *.ko *.mod
@rm -f .*.*.cmd *.symvers *~ *.*~ TODO.*
@rm -fR .tmp*
@rm -rf .tmp_versions
Теперь можно посмотреть на helloword в мире ядерного пространства:
#include <linux/module.h>
#include <linux/init.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Eugene Zubkov <evzubkov@inbox.ru>");
static int __init hello_init(void) {
printk(KERN_INFO "Hello, world!\n");
return 0;
}
static void __exit hello_exit(void) {
printk(KERN_INFO "Goodbye, world!\n");
}
module_init(hello_init);
module_exit(hello_exit);
Использованные нами заголовки являются стандартными для любого драйвера. Первый подключается любым модулей, во втором объявлены макросы для инициализации и деинициализации драйвера.
Далее у нас указывается лицензия и автор драйвера. Автора можно было бы и не указывать, а вот информация о лицензии обязательно должна быть. Причем, если вы укажете несвободную лицензию, то ядро будет помечено как испорченное. Поэтому тут и далее будет использоваться GPL.
hello_init и hello_exit отвечают за загрузку модуля и завершение его работы. Следует обратить внимание, что инициализация драйвера имеет возвращаемое значение, и если в данной процедуре что-то идет не так - следует вернуть значение отличное от 0. Директива __init говорит ядру о том, что данная функция нужна только при инициализации модуля. После загрузки модуля функция будет отброшена, а память освобождена. Cуществует аналогичный атрибут для данных __initdata .
В самом конце используются макросы, которые сообщают системе о функциях инициализации и окончания работы модуля.
Соберем модуль:
$ make all
make -C /lib/modules/6.12.20+rpt-rpi-v8/build M=/home/pi/develop/kernel-hello modules
make[1]: Entering directory '/usr/src/linux-headers-6.12.20+rpt-rpi-v8'
CC [M] /home/pi/develop/kernel-hello/hello.o
MODPOST /home/pi/develop/kernel-hello/Module.symvers
CC [M] /home/pi/develop/kernel-hello/hello.mod.o
CC [M] /home/pi/develop/kernel-hello/.module-common.o
LD [M] /home/pi/develop/kernel-hello/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-6.12.20+rpt-rpi-v8'
Скомпилированный драйвер представляет собой объектный модуль, но имеет расширение .ko:
$ file hello.ko
hello.ko: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), BuildID[sha1]=6a2e220672992880c52270fef026e34697780d03, not stripped
Мы можем посмотреть информацию о нашем модуле:
$ modinfo hello.ko
filename: /home/pi/develop/kernel-hello/hello.ko
author: Eugene Zubkov <evzubkov@inbox.ru>
license: GPL
srcversion: 92AE62C10FC0CE6F8AAD98C
depends:
name: hello
vermagic: 6.12.20+rpt-rpi-v8 SMP preempt mod_unload modversions aarch64
Загрузить и выгрузить модуль:
$ sudo insmod hello.ko
$ sudo rmmod hello.ko
Посмотрим журнал и убедимся, что модуль отработал:
$ dmesg | tail -n 2
[ 3017.491769] Hello, world!
[ 3028.534210] Goodbye, world!
Простое символьное устройство
Скелет для создания драйвера у нас есть, теперь давайте заставим наш драйвер что-то делать. Работа с символьными устройствами осуществляется через файловые операции (в линуксе все - файл!). Таким образом, мы можем взаимодйствовать с модулем с помощью методов open, read, write, release т.д. Полный список можно посмотреть в структуре file_operations в <linux/fs.h>. Поэтому нам надо как минимум реализовать калбэки для этих операций.
Как уже упомянулось, устройство отображается для пользователя как файл, стандартно файлы устройств хранятся в /dev. Во всяком случае для пользователя. Ядро же идентифицирует устройство с помощью пары чисел: старшего и младшего номера устройства. Старший связывает устройство с его драйвером, а младший указывает с каким конкретно устройством надо работать. Таким образом один драйвер может обслужить несколько устройств.
Давайте разберем это на коде:
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Eugene Zubkov <evzubkov@inbox.ru>");
MODULE_VERSION("0.9");
#define MODULE_NAME "chardevice_module"
#define DEVICE_NAME "chardevice"
#define DEVICE_CLASS "chardevice_class"
#define NUM_DEVICES 2
#define DEVICE_FIRST_INDEX 0
static int major;
static struct cdev some_device;
static struct class *device_class;
static int __init device_init(void);
static void __exit device_exit(void);
static ssize_t device_read(struct file * file, char * buf, size_t count, loff_t *pos);
static const struct file_operations device_file_operations = {
.owner = THIS_MODULE,
.read = device_read,
};
static int __init device_init(void)
{
int ret, i;
dev_t dev;
struct device *device_ptr;
if(major) {
dev = MKDEV(major, DEVICE_FIRST_INDEX);
ret = register_chrdev_region(dev, NUM_DEVICES, MODULE_NAME);
} else {
ret = alloc_chrdev_region(&dev, DEVICE_FIRST_INDEX, NUM_DEVICES, MODULE_NAME);
major = MAJOR(dev);
}
if(ret < 0) {
printk(KERN_ERR "Can't get major %d\n", major);
return ret;
}
cdev_init(&some_device, &device_file_operations);
some_device.owner = THIS_MODULE;
ret = cdev_add(&some_device, dev, NUM_DEVICES);
if(ret < 0) {
printk(KERN_ERR "Can't add char device\n");
goto cdev_err;
}
device_class = class_create(DEVICE_CLASS);
if (IS_ERR(device_class)) {
ret = PTR_ERR(device_class);
printk(KERN_ERR "Failed to create device class: %d\n", ret);
goto class_err;
}
for(i = 0; i < NUM_DEVICES; i++) {
device_ptr = device_create(device_class, NULL, MKDEV(major, i), NULL, "%s%d", DEVICE_NAME, i);
if (IS_ERR(device_ptr)) {
ret = PTR_ERR(device_ptr);
printk(KERN_ERR "Failed to create device %s%d\n", DEVICE_NAME, i);
while (--i >= 0) {
device_destroy(device_class, MKDEV(major, i));
}
goto device_err;
}
}
printk(KERN_INFO "Module installed %d:[%d-%d]\n",
MAJOR(dev),
DEVICE_FIRST_INDEX,
DEVICE_FIRST_INDEX + NUM_DEVICES - 1);
return 0;
device_err:
class_destroy(device_class);
class_err:
cdev_del(&some_device);
cdev_err:
unregister_chrdev_region(MKDEV(major, DEVICE_FIRST_INDEX), NUM_DEVICES);
return ret;
}
module_init(device_init);
static void __exit device_exit(void)
{
dev_t dev;
int i;
for( i = 0; i < NUM_DEVICES; i++ ) {
dev = MKDEV(major, DEVICE_FIRST_INDEX + i);
device_destroy(device_class, dev);
}
class_destroy(device_class);
cdev_del(&some_device);
unregister_chrdev_region(MKDEV( major, DEVICE_FIRST_INDEX), NUM_DEVICES);
printk(KERN_INFO "Module removed\n");
}
module_exit(device_exit);
static const char *buffer = "some data in module\n";
static ssize_t device_read(struct file * file, char * buf, size_t count, loff_t *pos)
{
int len = strlen(buffer);
printk(KERN_INFO "Read : %ld\n", (long)count);
if(count < len)
return -EINVAL;
if(*pos != 0) {
printk(KERN_INFO "Read return : 0\n");
return 0;
}
if(copy_to_user(buf, buffer, len))
return -EINVAL;
*pos = len;
printk(KERN_INFO "Read return : %d\n", len);
return len;
}
Для начала мы задаем структуру, в которой связываем системные вызовы с нашими калбэками для них:
static const struct file_operations device_file_operations = {
.owner = THIS_MODULE,
.read = device_read,
};
В данном случае мы реализуем только чтение.
Далее у нас описана функция инициализации модуля. В первую очередь мы получаем номера устройств:
if(major) {
dev = MKDEV(major, DEVICE_FIRST_INDEX);
ret = register_chrdev_region(dev, NUM_DEVICES, MODULE_NAME);
} else {
ret = alloc_chrdev_region(&dev, DEVICE_FIRST_INDEX, NUM_DEVICES, MODULE_NAME);
major = MAJOR(dev);
}
if(ret < 0) {
printk(KERN_ERR "Can't get major %d\n", major);
return ret;
}
За выделение адресов отвечает функция register_chrdev_region. В этом случае мы сами задаем номер для устройства, например, через параметры модуля. Однако номер может быть занят, и наш драйвер не будет загружен в ядро. Существует возможность автоматически получать номер устройства, с помощью функции alloc_chrdev_region. Если номер устройства не получен, то функция загрузки модуля завершается ошибкой.
В ядре за представление символьного устройства отвечает структура cdev. С помощью нее мы связываем наши операции и добавляем устройство в ядро для диапазона номеров dev.
cdev_init(&some_device, &device_file_operations);
some_device.owner = THIS_MODULE;
ret = cdev_add(&some_device, dev, NUM_DEVICES);
if(ret < 0) {
printk(KERN_ERR "Can't add char device\n");
goto cdev_err;
}
Если по какой-то причине не удалось добавить устройство, то необходимо завершить инициализацию модуля, вернув ошибку. Но перед этим необходимо осободить ресурсы, которые мы заняли в ядре.
goto!?
Обычно безусловный переход в программировании считается плохим тоном. Но в системном программировании вообще и ядерном в частности goto довольно распространен. Все как завещал Дональд Кнут: при использования этой техники нужна строгая дисциплина. Поэтому не стоит пугаться, но использовать нужно только там, где это действительно нужно.
Теперь нам нужно создать экземпляры устройств. Чтобы не делать это в ручную мы определяем класс, который создаст экземляры в /dev. Кроме того создастся иерархия в /sys/class/<имя_класса>/, где для каждого устройства хранятся его параметры и настройки.
device_class = class_create(DEVICE_CLASS);
if (IS_ERR(device_class)) {
ret = PTR_ERR(device_class);
printk(KERN_ERR "Failed to create device class: %d\n", ret);
goto class_err;
}
for(i = 0; i < NUM_DEVICES; i++) {
device_ptr = device_create(device_class, NULL, MKDEV(major, i), NULL, "%s%d", DEVICE_NAME, i);
if (IS_ERR(device_ptr)) {
ret = PTR_ERR(device_ptr);
printk(KERN_ERR "Failed to create device %s%d\n", DEVICE_NAME, i);
while (--i >= 0)
device_destroy(device_class, MKDEV(major, i));
goto device_err;
}
}
С загрузкой модуля закончили, в device_exit просто освободим занятые нами ресурсы:
static void __exit device_exit(void)
{
dev_t dev;
int i;
for( i = 0; i < NUM_DEVICES; i++ ) {
dev = MKDEV(major, DEVICE_FIRST_INDEX + i);
device_destroy(device_class, dev);
}
class_destroy(device_class);
cdev_del(&some_device);
unregister_chrdev_region(MKDEV( major, DEVICE_FIRST_INDEX), NUM_DEVICES);
printk(KERN_INFO "Module removed\n");
}
И наконец, реализуем функцию чтения. Сигнатуры file_operations можно посмотреть в /usr/src/$(uname -r)/include/linux/fs.h
. Особый интерес представляет функция copy_to_user. Как мы помним, ядерное и пользовательское пространства в linux изолированы между собой. И для передачи данных между ними необходимо использовать специальные функции. copy_to_user как раз позволяет безопастно скопировать данные из ядра в юзерспейс.
Итак, собираем и пробуем загрузить наш драйвер:
$ sudo insmod chardevice.ko
$ ls -l /dev/chardevice*
crw------- 1 root root 236, 0 Jun 3 14:37 /dev/chardevice0
crw------- 1 root root 236, 1 Jun 3 14:37 /dev/chardevice1
$ dmesg
[23115.289599] Module installed 236:[0-1]
Как можно заметить, наш модуль загрузился, создал пару устройств. Попытка чтения:
$ sudo cat /dev/chardevice1
some data in module
И так, нам удалось прочитать данные. Как видно из примера, создать свой драйвер в linux не так уж и сложно. Хотя данный драйвер не выполняет полезных действий, но может послужить основой для новых устройств.