Всякий раз, когда вы открываете браузер, кликаете мышкой или набираете текс в Linux, вы работаете в уютном мире user space или пользовательского пространства. Все привычные программы работают в этой песочнице, используя ОС для безопасного доступа к аппаратной части.

А за ширмой пользовательского интерфейса таится ядро Linux, которое уже и взаимодействует с железом. У ядра есть дополнительные привилегии при работе с аппаратурой, например, по обработке прерываний, управлению памятью, работой с GPIO.

Само ядро представляет собой монолит. Но как быть с поддержкой оборудования или файловых систем? Неужели ядро нужно пересобирать каждый раз при внесении каких-либо изменений? На заре истории Linux именно так дело и обстояло. Но со временем разработчики добавили механизм динамической загрузки отдельных компонентов ядра. Именно это и именуется модулями.

Частным случаем модуля ядра является драйвер. Он служит в качестве переводчика, который приводит взаимодействие с аппаратным устройством к некоторому стандартному API, через который с ним работает ядро.

Типы драйверов

  • Символьные - для устройств, с которыми можно работать, как с потоком байт. Одним из примеров является консоль или последовательный порт (/dev/tty1). Для взаимодействия с устройством в пользовательском пространстве используются файлы символьного устройства (CDF).
  • Блочные драйвера предназначены для работы с устройствами хранения данных (жесткие диски, SSD, USB-накопители), которые могут использоваться для размещения файловых систем. Сами файловые системы реализованы другими модулями ядра, которые работают поверх блочного драйвера.
  • Сетевые драйвера обеспечивают передачу сетевых пакетов. Ввиду особенностей сетевого обмена и реализации стека, данный класс устройств стоит особняком, и в отличие от первых двух типов не имеет файловых узлов (нод), а использует сокеты, netdevice-API.

Особенности ядерного программирования

При необходимости, можно самостоятельно писать и загружать драйвера. Так как ядро написано на Си, драйвера пишутся на этом же языке.
Но с нюансами..:

  1. Это очень похоже на программирование в пространстве ядра, но эта видимость обманчива.
  2. Для модулей используются не библиотеки пространства пользователя, а ядерные. За бортом даже стандартная библиотека Си.
  3. Пользовательские и ядерные пространства изолированы между собой, для передачи данных нужно использовать специальные методы.
  4. Внутри ядра используется общая память, поэтому надо осторожно работать с глобальными переменными. Хорошим тоном считает их объявление через static.
  5. При разработки драйверов у нас отсутствуют 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 не так уж и сложно. Хотя данный драйвер не выполняет полезных действий, но может послужить основой для новых устройств.