Лабораторная работа №3 Проектирование драйвера устройства в среде Linux Цель работы: Получение навыка проектирования драйвера в среде Linux с использованием технологии модулей ядра, получение навыка работы на уровне ядра операционной системы. План: 1. Подготовить окружение дистрибутива Linux для сборки модуля ядра; 2. Изучить структуру кода модуля ядра Linux; 3. Изучить структуру скрипта сборки модуля ядра Linux (Makefile); 4. Написать простейший модуль ядра и скрипт сборки, осуществить сборку; 5. Внедрить модуль ядра и осуществить его выгрузку из ядра; 6. Реализовать функционал символьного устройства; 7. Протестировать работу. 1. Подготовить окружение дистрибутива Linux для сборки модуля ядра. Драйвера устройств в операционных системах, выполненных на базе ядра Linux представляют собой модули ядра. Модуль ядра - бинарный файл содержащий код, предназначенный для выполнения на уровне ядра. Так как ядро Linux работает в одном адресном пространстве с его модулями, ошибка в модуле ядра может привести с отказу всей системы. При работе на уровне ядра операционной системы следует руководствоваться следующими рекомендациями: - тчательно проверять код модуля ядра; - аккуратно обращаться с указателями; - тестировать модуль ядра при помощи средств виртуализации. Так как ядро Linux имеет монолитную архитектуру, скомпилированный модуль ядра гарантированно работает только под той версией ядра Linux, под которой он был собран. Поэтому для сборки модуля под конкретную версию ядра Linux, требуются заголовочные файлы этой версии. Заголовочные файлы ядра Linux представлены в формате языка Си, имеют расширения *.h и описывают интерфейс взаимодействия с ядром (прототипы функций и структур данных). Для установки заголовочных файлов ядра Linux в различных дистрибутивах существует специальный пакет, при установке которого осуществляется их загрузка и распаковка. Обычно пакет имеет имя формата: linux-headers-<версия_ядра> Версия ядра в свою очередь имеет следующий формат: <основная_версия>-<версия_сборки>-<архитектура> Версию загруженного ядра Linux можно посмотреть командой: uname -r Примеры имени пакета с заголовочными файлами ядра Linux: linux-headers-4.19.0-14-amd64 linux-headers-3.16.0-10-586 linux-headers-3.16.0-11-armhf Для установки пакета в Debian-подобных (Ubuntu, Mint, Raspberry Pi OS, Kali, SteamOS, AstraLinux, Knoppix) дистрибутивах используется команда: sudo apt-get install <имя_пакета> Для установки пакета в RedHat-подобных (RHE, Fedora, CentOS, Mandriva, OpenSUSE, ClearOS, Oracle Linux) дистрибутивах используется команда: sudo dnf install <имя_пакета> Кроме заголовочных файлов ядра Linux для сборки модуля ядра потребуется пакет средств разработки - build-essential. Этот пакет содержит компиляторы С/C++, утилиту сборки make и несколько базовых пакетов заголовочных файлов пользовательского уровня выполнения. 2. Изучить структуру кода модуля ядра Linux Так как код любого модуля ядра выполняется на уровне ядра операционной системы, становятся недоступными функции из пользовательских заголовочных файлов (как и сами файлы). Так например отсутствует функция printf из stdio.h, вместо нее используется printk из linux/kernel.h, которая выводит текст в буфер сообщений ядра (dmesg). На уровне ядра ОС отсутствуют стандартные потоки ввода/вывода. Минимально необходимое количество подключаемых заголовочных файлов для реализации минимального модуля ядра: linux/module.h - необходимый для всех модулей ядра и linux/kernel.h - содержащий функцию printk и константы уровня логирования. Модуль ядра должен иметь как минимум 2 функции. Функция входа: int init_module(void){ ... } Функция init_module вызывается ядром Linux при запуске модуля. Функция должна возвратить 0 в случае успешного запуска. При ином значении ядро Linux считает, что при запуске модуля возникла ошибка и он не запущен. Обычно в функции init_module выполняется инициализация модуля и основных структур данных, необходимых для реализации драйвера. Функция выхода: void cleanup_module(void){ ... } Функция cleanup_module вызывается ядром Linux при выгрузке модуля из ядра Linux. Функция cleanup_module выполняет деинициализацию модуля ядра. Таким образом, структура простейшего модуля ядра представляет собой: - подключение заголовочных файлов linux/module.h и linux/kernel.h при помощи дерективы #include; - реализация функции init_module; - реализация функции cleanup_module. 3. Изучить структуру скрипта сборки модуля ядра Linux (Makefile). Для компиляции и сборки модуля ядра используется система сборки GNU Make. Система сборки GNU Make выполняет последовательности операций описанных в файле Makefile. Каждая последовательность команд имеет свой идентификатор, который называется целью (target). Для сборки модуля ядра обычно используется 2 цели: all и clean. all осуществляет вызов команд компиляции и линковки модуля, а clean осуществляет очистку каталога разработки от бинарных и отладочных файлов, генерируемых при сборке модуля. Версия ядра, под которую выполняется сборка модуля, определяет в том числе и последовательность команд для сборки. Поэтому вместе с заголовками ядра конкретной версии распространяется и Makefile для сборки модулей под данное ядро. Таким образом, для организации сборки модуля нужно написать Makefile, вызывающий выполнение Makefile из заголовков ядра. Для определения перечня файлов исходных кодов (*.c) для компиляции используется переменная obj-m, содержащая список объектных файлов (*.o) с соответствующими именами. Схема компиляции и сборки модуля ядра: │ │ ┌───────────┐ │ │ ┌───────────┐ │ source1.c ├┐ │ │ ┌┤ source1.o ├┐ └───────────┘│ ▼ │ │└───────────┘│ ┌───────────┐│компиляция │┌───────────┐│ │ source2.c ├┼──────────▶├┤ source2.o ├┤ └───────────┘│ │ │└───────────┘│ ┌───────────┐│ │ │┌───────────┐│ │ source3.c ├┘ │ └┤ source3.o ├┤ └───────────┘ ▼ └───────────┘│ ┌────────────┐ линковка │ │ kernmod.ko │◀────────────────────────┘ └────────────┘ Общий вид Makefile для сборки модуля ядра: obj-m = имя_исходника.o all: make -C /lib/modules/$(uname -r)/build/ M=$(pwd) modules clean: make -C /lib/modules/$(uname -r)/build/ M=$(pwd) clean 4. Написать простейший модуль ядра и скрипт сборки, осуществить сборку; Простейший модуль ядра состоит из функций загрузки и выгрузки ядра. Для реализации простейшего модуля также потребуется функция вывода сообщений в очередь сообщений ядра printk. Для реализации простейшего модуля ядра необходимо: 4.1 Подключить заголовочные файлы linux/module.h и linux/kernel.h 4.2 Реализовать функцию init_module; Вывести сообщение о загрузке модуля с помощью функции printk: printk(KERN_INFO строковое_сообщение); Возвратить 0 (return 0;) 4.3 Реализация функции cleanup_module. Вывести сообщение о выгрузке модуля с помощью функции printk. 4.4 Указать лицензию модуля при помощи макроса: MODULE_LICENSE("GPL"); Сборку модуля осуществить командой: make all В результате успешной компиляции будет собран файл модуля ядра с расширением *.ko 5. Внедрить модуль ядра и осуществить его выгрузку из ядра; Все операции по загрузке и выгрузке модулей ядра следует выполнять от суперпользователя (root). Для внедрения модуля ядра используется команда insmod имя_файла_модуля.ko После загрузки модуля необходимо убедиться, что сообщение о загрузке от модуля попало в сообщения ядра. Для этого необходимо выполнить команду: dmesg Для выгрузки модуля ядра используется команда: rmmod имя_файла_модуля Убедиться в присутствии сообщения о выгрузке модуля в сообщениях ядра. 6. Реализовать функционал символьного устройства. Символьное устройство представляет собой устройство, использующее файловые операции для побайтного чтения/записи. Общая задача - реализовать устройство записи и чтения сообщения, которое хранится в памяти уровня ядра. Общие указания по написанию кода модуля: - все функции кроме init_module и cleanup_module должны быть static; - не использовать объявления переменных и структур внутри функций; - все объявления вне функций должны быть static. Для реализации драйвера символьного устройства необходимо: 6.1 Инициализировать драйвер символьного устройства Инициализация выполняется в точке входа драйвера (функция init_module). 6.1.1 Подключение заголовочных файлов Для выполнения данной работы потребуется подключить следующие заголовочные файлы: linux/module.h - структура модуля ядра linux/kernel.h - функция printk linux/fs.h - структура файловых операций linux/cdev.h - структура и функции символьных устройств linux/uaccess.h - функции copy_to_user и copy_from_user linux/slab.h - функции выделения/освобождения памяти kmalloc kfree Также потребуется 3 константы define: #define DEVICE_NAME "ttyIIVS_ФИО" #define DEVICE_CLASS "CLASS_ФИО" #define MEM_CNT 255 Макрос указания лицензии модуля: MODULE_LICENSE("GPL"); 6.1.2 Заполнение структуры файловых операций Для описания реакции драйвера на файловые операции необходимо заполнить структуру с callback функциями. Эти функции будут вызываться ядром при выполнении файловых операций к драйверу через файл устройства. Тип структуры: struct file_operations Требуемые поля для заполнения: .owner = THIS_MODULE; //владелец .read - ссылка на функцию чтения Функция чтения должна иметь следующий прототип: static ssize_t имя_функции(struct file *file, char *buf, size_t bufLen, loff_t *offset); .write - ссылка на функцию записи Функция записи имеет следующий прототип: static ssize_t имя_функции(struct file *file, const char *buf, size_t bufLen, loff_t *offset); 6.1.3 Выделение памяти под сообщение Для хранения сообщения необходимо выделить память с помощью функции kmalloc. Указатель на выделенный буфер необходимо сохранить в поле модуля с типом static char*. Функция kmalloc имеет следующий прототип: void * kmalloc(size_t size, gfp_t flags); где size - размер буфера в байтах flags - тип выделяемой памяти (для наших задач обычная память ядра - значение GFP_KERNEL) kmalloc возвращает ссылку на выделенную память или 0 в случае неудачи. 6.1.4 Выделение номера символьного устройства. Для связи драйвера и файла устройства в ОС Linux используются мажорный и минорный номер драйвера, который однозначно определяет файл устройства. Мажорный и минорный номера устройств имеют тип dev_t. Перед использованием данный номер необходимо зарегистрировать в системе. Регистрация осуществляется при помощи функции alloc_chrdev_region, которая имеет следующий прототип: int alloc_chrdev_region (dev_t * dev, unsigned baseminor, unsigned count, const char * name); где dev - ссылка на переменную dev_t, в которую будет записан номер для нового устройства (объявить как поле static dev_t) baseminor - начальный минорный номер, используемый для выделения (0); count - количество выделяемых номеров устройств (1); name - имя устройства (определенный заранее define: DEVICE_NAME). Функция возвращает 0 в случае успешного выполнения или отрицательное значение в случае ошибки. После выделения номера получить мажорный и минорный номер из переменной dev_t можно при помощи макросов MAJOR(dev_t dev) и MINOR (dev_t dev) соответственно. 6.1.5 Инициализация структуры символьного устройства Для реализации драйвера символьного устройства необходимо выделить память под структуру символьного устройства (static struct cdev*), заполнить ее и зарегистрировать новый символьный драйвер в ядре. Для выделения памяти под структуру символьного устройства используется функция cdev_alloc, которая имеет следующий прототип: struct cdev * cdev_alloc(void); Функция возвращает указатель на созданную структуру в случае успеха и NULL в ином случае. После выделения памяти для структуры struct cdev ее необходимо заполнить, используя указатель на эту структуру. Требуется заполнить следующие поля: ->owner = THIS_MODULE; - указывает на владельца структуры ->ops - указатель на структуру с файловыми операциями (struct file_operations), которые будут привязаны к данному символьному устройству. Необходимо присвоить значение адреса инициализированной ранее структуры с файловыми операциями. После заполнения структуры символьного устройства (struct cdev) его необходимо зарегистрировать в ядре. Для регистрации символьного устройства используется функция cdev_add, которая имеет следующий прототип: int cdev_add (struct cdev *p, dev_t dev, unsigned count); где p - указатель на регистрируемую структуру dev - выделенный мажорный/минорный номер устройства count - количество устройств (1) Функция возвращает 0 в случае успеха и номер ошибки в ином случае. 6.1.6 Создание файла устройства Для создания файла устройства в каталоге устройств (/dev) необходимо создать новый класс устройств, и используя данный класс создать файл устройства. Для хранения указателя на новый класс необходимо объявить поле типа: static struct class* Для создания нового класса используется функция class_create, которая имеет следующий прототип: struct class * class_create(struct module *owner, const char *name) где owner - владелец нового класса (THIS_MODULE) name - строковое значение имени класса (определенный заранее define: DEVICE_CLASS) Функция возвращает указатель на созданный класс в случае успеха и NULL в противном случае. Далее необходимо непосредственно создать файл устройства при помощи зарегистрированного класса. Для создания файла устройства используется функция device_create, которая имеет следующий прототип: struct device * device_create(struct class * class, struct device * parent, dev_t devt, void * drvdata, const char * fmt, ...); где class - указатель на созданный ранее класс parent - родительское устройство (в нашем случае NULL) devt - мажорный/минорный номера устройства drvdata — опциональные данные, передаваемые драйверу (в нашем случае NULL) fmt - имя устройства (определенный заранее define: DEVICE_NAME) Возвращает указатель на структуру созданного устройства в случае успеха или NULL в ином случае. 6.2 Описать функции файловых операций Для описания функционала файловых операций минимум необходимо реализовать функцию чтения и функцию записи устройства. 6.2.1 Реализация функции записи Функция записи устройства вызывается ядром ОС в случае выполнения операции записи в файл устройства /dev/ttyIIVS_ФИО. Указатель на эту функцию должен присутствовать в структуре файловых операций (struct file_operations), указатель на которую в свою очередь располагается в структуре символьного устройства (struct cdev). Функция записи должна иметь следующий прототип: static ssize_t имя_функции_записи(struct file *file, const char *buf, size_t bufLen, loff_t *offst) где file - указатель на структуру открытого файла устройства buf - записываемые в файл устройства данные bufLen - кол-во записываемых данных offst - указатель на текущую позицию в файле устройства (loff_t === long long) Функция должна: - записать данные от пользователя во внутренний буфер, который был выделен при помощи kmalloc при инициализации. Для этого используется функция copy_from_user, которая имеет следующий прототип: unsigned long copy_from_user ( void * to, const void * from, unsigned long n); где to - буфер назначения в пространстве ядра from - буфер с пользовательскими данными n - кол-во записываемых байт Функция возвращает 0 в случае успеха или кол-во байт, которые не удалось скопировать. - увеличить *offst на количество записанных байт - сохранить количество записанных байт в поле модуля - возвратить из функции количество записанных байт. 6.2.2 Реализация функции чтения Функция чтения устройства вызывается ядром ОС в случае выполнения операции чтения из файла устройства /dev/ttyIIVS_ФИО. Указатель на эту функцию должен присутствовать в структуре файловых операций (struct file_operations), указатель на которую в свою очередь располагается в структуре символьного устройства (struct cdev). Функция записи должна иметь следующий прототип: static ssize_t имя_функции_чтения(struct file *file, char *buf, size_t bufLen, loff_t * offst); где file - указатель на структуру открытого файла устройства buf - указатель на данные, которые будут прочитаны из файла пользователем bufLen - максимальное кол-во читаемых пользователем данных за один вызов read (максимальный размер выходного буфера) offst - указатель на текущую позицию в файле устройства (loff_t === long long) Функция должна: - если значение *offst != 0 - возвратить 0, что определит окончание чтения - записать данные из внутреннего буфера в выходной буфер пользователя (buf) Для этого используется функция copy_to_user, которая имеет следующий прототип: unsigned long copy_to_user ( void * to, const void * from, unsigned long n); где to - буфер назначения в пространстве пользователя from - буфер источника из пространства ядра n - кол-во записываемых байт Функция возвращает 0 в случае успеха или кол-во байт, которые не удалось скопировать. - увеличить *offst на количество прочитанных байт - возвратить из функции количество прочитанных байт. 6.3 Описать функцию выгрузки драйвера 6.3.1 Освободить созданное устройство Освобождение устройства осуществляется при помощи функции device_destroy, которая имеет следующий прототип: void device_destroy(struct class * class, dev_t devt) где class - указатель на класс освобождаемого устройства devt - мажорный/минорный номера устройства 6.3.2 Дерегистрация класса устройства Дерегистрация ранее зарегистрированного класса драйвера осуществляется при помощи функции class_destroy, которая имеет следующий прототип: void class_destroy(struct class *cls) где cls - указатель на дерегистрируемый класс 6.3.3 Удаление структуры символьного устройства Удаление структуры символьного устройства осуществляется при помощи функции cdev_del, которая имеет следующий прототип: void cdev_del (struct cdev *p); где p - указатель на структуру символьного устройства 6.3.4 Дерегистрация номера символьного устройства Дерегистрация номера (мажор/минор) символьного устройства выполняется при помощи функции unregister_chrdev_region, которая имеет следующий прототип: void unregister_chrdev_region (dev_t from, unsigned count); где from - дерегистрируемые мажорный/минорный номера устройства count - кол-во устройств 6.3.5 Освобождение памяти под сообщение Освобождение памяти под сообщение осуществляется с помощью функции kfree, которая имеет следующий прототип: void kfree(const void * prt); где prt - указатель на освобождаемую память 7. Протестировать работу. Для тестирования работы драйвера символьного устройства необходимо выполнить следующую последовательность: - собрать модуль ядра при помощи команды: make all - в отдельном терминале от суперпользователя (root) запустить вывод очереди сообщений ядра при помощи команды: dmesg -w - выполнить вход от суперпользователя (root) при помощи команды: su или sudo su - выполнить загрузку модуля ядра при помощи команды: insmod имя_модуля.ko - убедиться в успешной загрузке модуля ядра отслеживая сообщения из очереди сообщений ядра в отдельном терминале - убедиться в создании успешном создании файла устройства при помощи команды: ls -l /dev/ttyIIVS_ФИО - выполнить запись сообщения в драйвер символьного устройства при помощи команды: echo "text_of_message" > /dev/ttyIIVS_ФИО - выполнить чтение сообщения из драйвера символьного устройства при помощи команды: cat /dev/ttyIIVS_ФИО - убедиться в выводе записанного сообщения из драйвера символьного устройства в терминал.