Продолжение последней версии руководства по написанию модулей ядра от 2 июля 2022. В первой половине текущей части мы подробнее разберём структуру и принцип действия модулей, узнаем, чем отличается пространство пользователя от пространства ядра, а также немного поговорим об использовании памяти. Вторая же половина будет посвящена одному из типов модулей — драйверам устройств, основы работы с которыми мы также подробно рассмотрим.
Ссылки
Ссылки на предыдущие части руководства:
- Часть 1
5. Общие сведения
▍ 5.1 Начало и завершение модулей
Программа обычно начинается с функции
main()
, выполняет ряд инструкций, после чего завершается. А вот модули ядра работают несколько иначе. Модуль всегда начинается либо с init_module
, либо с функции, которую мы указываем вызовом module_init
. У модулей это функция входа, которая сообщает ядру, какую функциональность модуль несёт, и настраивает ядро на выполнение этой функциональности при необходимости. По завершении функции входа, модуль переходит в состояние бездействия, пока ядру не потребуется от него некая функциональность для работы с кодом.Заканчиваются все модули вызовом либо
cleanup_module
, либо функции, указываемой вызовом module_exit
. У модулей это функция выхода. Она отменяет всё, что до этого сделала функция входа, и отменяет регистрацию всей ранее введённой ей функциональности.Обе описанные функции входа и выхода должны присутствовать в каждом модуле. А поскольку для их определения существует не один способ, я постараюсь использовать общие термины «функция входа» и «функция выхода», но если вдруг по недосмотру назову их
init_module
и cleanup_module
, то, думаю, вы поймёте, что я имею в виду.▍ 5.2 Функции, доступные модулям
Программисты используют функции, не требующие постоянного переопределения. Хорошим примером этого является
printf()
. Это лишь одна из функций, предоставляемых стандартной библиотекой libc. Фактически их определения не попадают в программу до этапа линковки, который гарантирует доступность кода (например, для printf()
) и направляет на этот код инструкцию вызова.Здесь модули ядра тоже отличаются. В примере “Hello world” вы могли заметить, что мы использовали функцию
pr_info
, но не включали стандартную библиотеку ввода-вывода. Причина в том, что модули – это объектные файлы, чьи символы разрешаются при выполнении insmod
или modprobe
. Определение для символов поступает из самого ядра. Единственными внешними функциями, которые можно использовать, являются те, что предоставляет ядро. Если вам интересно узнать, какие символы ваше ядро экспортировало, загляните в /proc/kallsyms
.При всём при этом нужно помнить о различии между библиотечными функциями и системными вызовами. Библиотечные функции работают на более высоком уровне, выполняясь полностью в пользовательском пространстве и предоставляя программисту более удобный интерфейс для доступа к функциям, которые и совершают реальную работу – системным вызовам. Эти вызовы, в свою очередь, выполняются в режиме ядра от имени пользователя и предоставляются самим ядром. Библиотечная функция
printf()
может выглядеть как обобщённая функция вывода, но на деле она лишь форматирует данные в строки и записывает эти строчные данные с помощью низкоуровневого системного вызова write
, который затем отправляет их в стандартный вывод.Хотите увидеть, какие системные вызовы совершает
printf()
? Легко! Скомпилируйте следующую программу с помощью gcc -Wall -o hello hello.c
:#include <stdio.h>
int main(void)
{
printf("hello");
return 0;
}
Запустите исполняемый файл командой
strace ./hello
. Впечатлены? Каждая строка, которую вы видите, соответствует системному вызову. strace – это удобная утилита, сообщающая подробности о том, какие системные вызовы совершает программа, включая то, какие аргументы эти вызовы содержат и какие результаты возвращают.Это невероятно ценный инструмент, позволяющий выяснять, к каким файлам обращается программа. Ближе к концу вы увидите строку вроде
write(1, "hello", 5hello)
. Вот оно – лицо, скрытое за маской printf()
. Вы можете быть незнакомы с write()
, поскольку большинство людей для файлового ввода-вывода используют библиотечные функции (например, fopen
, fputs
, fclose
). Если так и есть, то рекомендую заглянуть в мануал, write 2 man
. Второй раздел в нём посвящён системным вызовам (вроде kill()
и read()
). Третий раздел описывает библиотечные вызовы (вроде cosh()
и random()
), с которыми вы наверняка уже более знакомы.Вы даже можете писать модули на замену системных вызовов ядра, чем мы вскоре и займёмся. Взломщики зачастую используют подобные приёмы для бэкдоров или троянов, но вы можете создавать собственные модули из более доброжелательных побуждений, например, чтобы ядро писало
“Tee hee, that tickles!”
(Хи-хи, щекотно!) каждый раз, когда кто-то пытается удалить в системе файл.▍ 5.3 Пользовательское пространство и пространство ядра
Ядро (по своей сути), регулирует доступ к ресурсам, будь то видеокарта, жёсткий диск или память. При этом программы зачастую соперничают за право использовать один и тот же ресурс. Как только я сохранил документ,
updatedb
начала обновлять локальную базу данных. Мой сеанс VIM и updatedb
используют жёсткий диск конкурентно. Ядру необходимо сохранять во всём этом порядок, а не давать пользователям доступ к ресурсам в любой момент, когда им вздумается.В связи с этим ЦПУ может работать в нескольких режимах. Каждый режим даёт определённый уровень свободы действий в системе. В архитектуре 80386 есть 4 таких режима, называемых кольцами защиты. В Unix используется только два таких кольца: внутреннее (кольцо 0, также известное как «режим супервизора», в котором допустимы все действия) и внешнее, называемое «режим пользователя».
Вспомним разговор о библиотечных и системных вызовах. Обычно мы используем библиотечную функцию в режиме пользователя. Эта библиотечная функция, в свою очередь, совершает один или более системных вызовов, которые выполняют от её имени действия, но делают это уже в режиме супервизора, поскольку являются частью самого ядра. Как только системный вызов завершает задачу, он делает возврат, и выполнение передаётся обратно в режим пользователя.
▍ 5.4 Пространство имён
Когда вы пишете небольшую программу Си, то используете удобные переменные, которые будут иметь смысл для пользователя. Если же, напротив, вы пишете подпрограммы, которые станут частью более крупной задачи, то любые используемые глобальные переменные являются частью коллекции глобальных переменных других людей, в связи с чем иногда могут возникать коллизии между их имён. Когда в программе используется множество глобальных переменных, которые недостаточно значительны, чтобы проводить между ними различие, у нас получается загрязнение пространства имён. В крупных проектах необходимо стремиться запоминать зарезервированные имена и вырабатывать схему для именования уникальных переменных и символов.
При написании кода ядра даже малейший модуль будет залинкован со всем ядром, так что это определённо важно. Проще всего в таком случае объявлять все переменные статическими и использовать для символов грамотные префиксы. По соглашению все префиксы в ядре пишутся в нижнем регистре. Если же вы не хотите объявлять что-либо статично, то другой вариант – объявить таблицу символов и зарегистрировать её с помощью ядра. Чуть позже мы об этом поговорим.
В файле /proc/kallsyms хранятся все символы, о которых ядро знает, и которые, благодаря этому, являются доступными для модулей, поскольку находятся в едином пространстве кода ядра.
▍ 5.5 Кодовое пространство
Управление памятью является очень сложной темой, и большая часть книги Understanding The Linux Kernel издательства O’Reilly посвящено именно ей. Мы не ставим задачу стать экспертами в этой области, но для того, чтобы даже задуматься над написанием реальных модулей нам необходимо знать пару фактов.
Если вы ещё не думали о том, что в самом деле значит
segfault
(ошибка сегментации), то можете удивиться, услышав, что в действительности указатели не указывают на области памяти, по крайней мере, на реальные. При создании процесса ядро выделяет часть реальной физической памяти и передаёт её этому процессу для размещения в ней выполняемого кода, переменных, стека, кучи и прочих вещей, о которых должен знать специалист по информатике.Эта память начинается с
0х00000000
и простирается до необходимых значений. Поскольку область памяти для любых двух процессов не пересекается, все процессы, которые могут обращаться к адресу памяти, скажем 0xbffff978
, будут обращаться к разным областям реальной физической памяти. Они будут обращаться к индексу 0xbffff978
, указывающему на некое смещение в области памяти, выделенной конкретно для этого процесса. В большинстве случаев процесс вроде нашей программы “Hello World” не может получить доступ к пространству другого процесса, хотя для этого есть определённые способы, о которых мы поговорим позже.У ядра также есть собственная область памяти. Поскольку модуль является кодом, который может внедряться в ядро и извлекаться из него (в противоположность полуавтономному объекту), он использует кодовое пространство ядра, не имея собственного. Следовательно, если ваш модуль допускает ошибку сегментации, то и с ядром происходит то же самое. И если вы начнёте производить запись поверх данных в результате ошибки смещения на единицу, то происходить это будет поверх данных (или кода) ядра. На деле это даже хуже, чем звучит, так что будьте очень осторожны.
Кстати, хочу отметить, что описанное выше касается любой операционной системы, использующей монолитное ядро. Это не совсем то же, что «встраивание всех модулей в ядро», хотя суть аналогична. Существует такое понятие, как микроядра, которые имеют модули, получающие собственное кодовое пространство. Примерами таких микроядер являются GNU Hurd и Zircon.
▍ 5.6 Драйверы устройств
Одним из классов модулей являются драйверы устройств, которые предоставляют функциональность для оборудования вроде последовательных портов. В Unix каждый элемент оборудования представлен файлом устройства, расположенным в /dev и предоставляющим средства для связи с этим оборудованием. Драйвер устройства обеспечивает связь со стороны пользовательской программы. Например, драйвер звуковой карты es1370.ko может подключать файл устройства /dev/sound к звуковой карте Ensoniq IS1370. В результате программа в пользовательском пространстве, например, mp3blaster, может использовать /dev/sound, даже не зная, какая именно звуковая карта установлена.
Рассмотрим некоторые файлы устройств. Ниже приведены их примеры, которые представляют первые три раздела на ведущем HDD:
$ ls -l /dev/hda[1-3]
brw-rw---- 1 root disk 3, 1 Jul 5 2000 /dev/hda1
brw-rw---- 1 root disk 3, 2 Jul 5 2000 /dev/hda2
brw-rw---- 1 root disk 3, 3 Jul 5 2000 /dev/hda3
Обратите внимание на числа, отделённые запятой. Первое называется старшим (major) номером устройства, а второе младшим (minor). Старший номер сообщает, какой драйвер используется для доступа к оборудованию. Каждому драйверу присваивается уникальный старший номер. Все файлы устройств с одинаковым старшим номером управляются одним драйвером. Выше мы видим в качестве таких номеров три
3
, поскольку всеми этими устройствами управляет один драйвер.При этом по младшим номерам драйвер отличает один управляемый им компонент оборудования от другого. В примере выше, несмотря на то что все три устройства управляются одним драйвером, их младшие номера отличаются, поскольку этот драйвер видит их как разные компоненты оборудования.
Устройства делятся на два типа: блочные и символьные. Отличие между ними в том, что блочные имеют буфер для запросов, благодаря чему могут выбирать наилучший порядок, в котором на эти запросы отвечать. Это важно в случае устройств хранения, когда получается быстрее считывать/записывать близкорасположенные сектора, нежели те, что удалены друг от друга. Ещё одним отличием является то, что блочные устройства могут получать вход и возвращать выход только блоками (чей размер может отличаться в зависимости от устройства), а символьным дозволено использовать любое необходимое им количество байтов. Большинство устройств являются именно символьными, поскольку им не требуется подобная буферизация, и они не работают с фиксированным размером блоков.
Понять, для какого устройства используется файл устройства – блочного или символьного – можно по первому символу вывода команды
ls -l
. Если это b
, значит — устройство блочное, а если c
, значит — символьное. Приведённые выше устройства все являются блочными, а вот несколько символьных (последовательные порты):crw-rw---- 1 root dial 4, 64 Feb 18 23:34 /dev/ttyS0
crw-r----- 1 root dial 4, 65 Nov 17 10:26 /dev/ttyS1
crw-rw---- 1 root dial 4, 66 Jul 5 2000 /dev/ttyS2
crw-rw---- 1 root dial 4, 67 Jul 5 2000 /dev/ttyS3
Если хотите увидеть, какие устройствам были присвоены старшие номера, можете заглянуть в Documentation/admin-guide/devices.txt.
При установке системы все эти файлы устройств создавались командой
mknod
. Для создания нового символьного устройства под именем coffee
со старшим/младшим номерам 12
/2
просто выполните mknod /dev/coffee c 12 2
. Вам не обязательно помещать файлы устройств в /dev, но того требует соглашение. Линукс размещает эти файлы в /dev, и вам стоит делать так же. Однако при создании файла устройства для тестирования вполне допустимо разместить его в рабочем каталоге, где вы компилируете модуль ядра. Только не забудьте перенести его в нужное место, когда закончите написание драйвера.Напоследок хочу дополнительно прояснить момент, который может быть неочевиден из пояснения выше. Когда происходит обращение к файлу устройства, ядро по его старшему номеру определяет, какой драйвер нужно использовать для обработки этого обращения. То есть ядру не обязательно использовать, или даже знать, младший номер. Этот номер интересует лишь драйвер устройства, который использует его для различения отдельных компонент оборудования.
Кстати, когда я говорю «оборудование», то подразумеваю несколько более абстрактное понятие, нежели какая-нибудь PCI-карта, которую вы держите в руках. Взгляните на эти два файла устройств:
$ ls -l /dev/sda /dev/sdb
brw-rw---- 1 root disk 8, 0 Jan 3 09:02 /dev/sda
brw-rw---- 1 root disk 8, 16 Jan 3 09:02 /dev/sdb
Теперь, глядя на них, вы можете сходу понять, что они являются блочными устройствами и обрабатываются одним драйвером. Иногда два файла устройств с одним старшим, но разными младшими номерами на деле могут представлять один и тот же компонент оборудования. Так что имейте в виду, что слово «оборудование» в этом пособии может иметь весьма абстрактное значение.
6. Драйверы символьных устройств
▍ 6.1 Структура file_operations
Структура
file_operations
находится в include/linux/fs.h и содержит указатели на определённые драйвером функции, которые выполняют различные действия с устройством. Каждое поле этой структуры соответствует адресу некой функции, определённой драйвером для обработки операции запроса.Например, каждый символьный драйвер должен определять функцию, считывающую данные с устройства. Структура
file_operations
содержит адрес функции модуля, которая выполняет эту операцию. Вот как это определение выглядит в ядре 5.4:struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iopoll)(struct kiocb *kiocb, bool spin);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;
При этом некоторые операции драйвером не реализуются. Например, драйверу, обрабатывающему видеокарту, не требуется выполнять чтение из структуры каталогов. Соответствующие записи в структуре
file_operations
должны быть установлены на NULL
.Для компилятора gcc есть расширение, которое упрощает присваивание значений в этой структуре. В современных драйверах оно встречается довольно часто, так что не удивляйтесь, если его увидите. Так выглядит новый способ присваивания значений в структуре:
struct file_operations fops = {
read: device_read,
write: device_write,
open: device_open,
release: device_release
};
Однако присваивать элементам структуры значения можно и в соответствии со стандартом С99, с помощью назначенных инициализаторов. Причём такой способ определённо предпочтительнее, чем применение расширения GNU. Этот синтаксис желательно использовать в случае, когда стоит задача портировать драйвер, так как он обеспечит лучшую совместимость:
struct file_operations fops = {
.read = device_read,
.write = device_write,
.open = device_open,
.release = device_release
};
Смысл ясен, и вам нужно иметь ввиду, что любой член структуры, которому вы не присвоите значение явно, gcc инициализирует с
NULL
.Экземпляр
struct file_operations
, содержащий указатели на функции, используемые для реализации системных вызовов read
, write
, open
и так далее, обычно называется fops
.Начиная с Linux v3.14, операции чтения, записи и поиска гарантированно потокобезопасны за счёт использования специальной блокировки
f_pos
, которая превращает обновление позиции файла во взаимное исключение. Благодаря этому, можно безопасно реализовывать подобные операции без излишних блокировок.Начиная с Linux v5.6, была введена структура
proc_ops
, заменившая использование структуры file_operations
при регистрации обработчиков процессов.▍ 6.2 Структура file
Каждое устройство представлено в ядре структурой
file
, которая определяется в include/linux/fs.h. Имейте ввиду, что file
– это структура уровня ядра, которая никогда не появляется в программе пользовательского пространства. Это не то же самое, что FILE
, который определяется glibc и никогда не встречается в функции пространства ядра. Кроме того, само имя структуры может сбивать с толку, так как представляет абстрактный открытый file
, а не файл на диске, который представляется структурой inode
.Экземпляр структуры
file
обычно называется filp
. Вы также увидите, что порой её называют структурой file object – пусть это не вводит вас в заблуждение.Загляните в определение
file
. Большинство записей здесь, такие как struct dentry
, не используются драйверами устройств, и их можно игнорировать. Причина в том, что драйверы не заполняют file
непосредственно, а лишь используют содержащиеся в ней структуры, которые создаются где-то ещё.▍ 6.3 Регистрация устройства
Как уже говорилось, обращение к символьным устройствам происходит через файлы устройств, обычно расположенные в /dev. Тем не менее — при написании драйвера вполне допустимо поместить файл устройства в текущий рабочий каталог с тем условием, что по завершении он будет перенесён в /dev. Старший номер сообщает, какой драйвер какой файл устройства обрабатывает. Младший же номер используется только самим драйвером для определения конкретного устройства, с которым он работает.
Добавление драйвера в систему означает регистрацию его с помощью ядра. Это аналогично присваиванию ему старшего номера во время инициализации модуля и выполняется с помощью функции
register_chrdev
, определённой в include/linux/fs.h.int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);
Здесь
unsigned major int
является старшим номером, который мы хотим запросить, const char *name
– это имя устройства в том виде, в котором оно отобразится в /proc/devices, а struct file_operations *fops
– это указатель на таблицу file_operations
для вашего драйвера. Отрицательное возвращаемое значение означает, что регистрация провалилась. Заметьте, что мы не передавали в register_chrdev
младший номер. Ещё раз напомню, что ядру он не важен, его использует только драйвер.Следующий вопрос в том, как получить старший номер, не взяв случайно тот, что уже используется? Проще всего заглянуть в Documentation/admin-guide/devices.txt и выбрать свободный. Но это будет не самый удачный способ, поскольку вы никогда не сможете быть уверены, что выбранный вами номер не окажется присвоен где-то позднее. Решением будет попросить ядро присвоить динамический старший номер.
Если передать в
register_chrdev
старший номер 0
, возвратным значением будет его динамически выделяемое значение. Недостаток такого решения в том, что не получится создать файл устройства наперёд, поскольку вы не будете знать, какой ему будет присвоен старший номер.Выйти из ситуации можно несколькими путями:
- номер может выводить сам драйвер, и мы будем создавать файл устройства вручную.
- регистрируемое устройство будет иметь запись в /proc/devices, и мы сможем либо сами создать файл устройства, либо написать для этого специальный скрипт оболочки.
- можно сделать и так, чтобы наш драйвер сам создавал файл устройства, используя функцию
device_create
после успешной регистрации, иdevice_destroy
во время вызоваcleanup_module
.
Однако
register_chrdev()
будет занимать ряд младших номеров, связанных с заданным старшим. Поэтому с целью уменьшения лишних затрат при регистрации символьного устройства рекомендуется использовать интерфейс cdev
.Этот более свежий интерфейс завершает регистрацию в два раздельных этапа. Во-первых, нам нужно зарегистрировать серию номеров устройств, что можно сделать с помощью
register_chrdev_region
или alloc_chrdev_region
.int register_chrdev_region(dev_t from, unsigned count, const char *name);
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
Выбор одной из этих функций будет зависеть от того, известны ли вам старшие номера вашего устройства. Используйте
register_chrdev_region
, если знаете их, и alloc_chrdev_region
, если хотите сделать их выделение динамическим.Вторым этапом необходимо инициализировать для нашего символьного устройства структуру данных
struct cdev
и связать её с номерами устройства. Эту инициализацию можно осуществить следующей последовательностью команд:struct cdev *my_dev = cdev_alloc();
my_cdev->ops = &my_fops;
Тем не менее в стандартном сценарии
struct cdev
будет встроена в вашу собственную связанную с устройством структуру. В этом случае нам для инициализации необходима cdev_init
.void cdev_init(struct cdev *cdev, const struct file_operations *fops);
По завершении инициализации можно добавить символьное устройства в систему с помощью
cdev_add
.int cdev_add(struct cdev *p, dev_t dev, unsigned count);
Пример использования этого интерфейса можно найти в ioctl.c, описанном в разделе 9.
▍ 6.4 Отмена регистрации устройства
Мы не можем позволить рут-пользователю извлекать (
rmmod
) модуль ядра в любой момент, когда ему это вздумается. Если извлечь модуль в то время, когда файл устройства будет открыт процессом, то использование этого файла приведёт к вызову из области памяти, где ранее находилась нужная функция (чтения/записи). В лучшем случае, если никакой другой код в эту область ещё записан не был, мы просто получим неприятную ошибку. В худшем же в эту память уже мог быть загружен другой модуль, что приведёт к перескакиванию в середину уже иной функции внутри ядра, вызвав непредсказуемый и явно не радужный результат.Как правило, когда вы хотите запретить какое-то действие, то возвращаете код ошибки (отрицательное число) из функции, которая это действие должна была выполнить. В случае с
cleanup_module
так сделать не получится, поскольку это пустая функция.Тем не менее существует счётчик, который отслеживает, сколько процессов используют ваш модуль. Значение этого счётчика можно увидеть в 3 поле вывода команды
cat /proc/modules
или sudo lsmod
. Если это не нуль, значит, rmmod
провалится. Имейте в виду, что проверять счётчик в cleanup_module
не нужно, так как эта проверка будет выполнена за вас системным вызовом sys_delete_module
, определённым в include/linux/syscalls.h. Этот счётчик не требуется использовать непосредственно, но include/linux/module.h содержит функции, которые позволяют вам при необходимости увеличивать, уменьшать и отображать его:try_module_get(THIS_MODULE)
: инкрементирует число активных обращений к текущему модулю;module_put(THIS_MODULE)
: декрементирует число активных обращений к текущему модулю;module_refcount(THIS_MODULE)
: возвращает число активных обращений к текущему модулю.
Важно поддерживать точное значение счётчика. Если вы вдруг утратите верный счёт, то уже не сможете выгрузить модуль, и останется единственный выход – перезагрузка. В процессе разработки модуля такая ситуация с вами рано или поздно неизбежно случится.
▍ 6.5 chardev.c
Код ниже создаёт символьный драйвер
chardev
. Можете сделать дамп его файла устройства.cat /proc/devices
(Либо откройте этот файл программой), и драйвер добавит в него значение, указывающее количество раз, которое он был считан. Запись в этот файл (вроде
echo "hi" > /dev/hello
) мы не поддерживаем, перехватывая такие попытки и сообщая пользователю, что данная операция недопустима. Не беспокойтесь, если не видите, что мы делаем с данными, которые считываем в буфер – они просто считываются, и выводится сообщение, подтверждающее их получение.В многопоточной среде без защиты параллельное обращение к одному участку памяти может привести к состоянию гонки и снизить производительность. В модуле ядра эта проблема может происходить в результате обращения нескольких экземпляров программ к общим ресурсам.
Решается она обеспечением индивидуального доступа. Мы используем атомарную инструкцию сравнения с обменом (CAS) для сохранения состояний
CDEV_NOT_USED
и CDEV_EXCLUSIVE_OPEN
, чтобы определять, открыт ли в данный момент файл какой-либо программой. CAS сравнивает содержимое области памяти с ожидаемым значением и только в случае их совпадения изменяет содержимое этой памяти на нужное значение. Подробнее о конкурентности читайте в разделе 12.
Код chardev.c
/*
* chardev.c: создаёт символьное устройство, которое сообщает, сколько
* раз происходило считывание из файла.
*/
#include <linux/cdev.h>
#include <linux/delay.h>
#include <linux/device.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/irq.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/poll.h>
/* Prototypes – обычно помещается в файл .h */
static int device_open(struct inode *, struct file *);
static int device_release(struct inode *, struct file *);
static ssize_t device_read(struct file *, char __user *, size_t, loff_t *);
static ssize_t device_write(struct file *, const char __user *, size_t,
loff_t *);
#define SUCCESS 0
#define DEVICE_NAME "chardev" /* Имя устройства, как оно показано в /proc/devices */
#define BUF_LEN 80 /* Максимальная длина сообщения устройства. */
/* Глобальные переменные объявляются как static, поэтому являются глобальными в пределах файла. */
static int major; /* Старший номер, присвоенный драйверу устройства */
enum {
CDEV_NOT_USED = 0,
CDEV_EXCLUSIVE_OPEN = 1,
};
/* Устройство открыто? Используется для предотвращения множественных обращений к устройству. */
static atomic_t already_open = ATOMIC_INIT(CDEV_NOT_USED);
static char msg[BUF_LEN]; /* msg, которое устройство будет выдавать при запросе. */
static struct class *cls;
static struct file_operations chardev_fops = {
.read = device_read,
.write = device_write,
.open = device_open,
.release = device_release,
};
static int __init chardev_init(void)
{
major = register_chrdev(0, DEVICE_NAME, &chardev_fops);
if (major < 0) {
pr_alert("Registering char device failed with %d\n", major);
return major;
}
pr_info("I was assigned major number %d.\n", major);
cls = class_create(THIS_MODULE, DEVICE_NAME);
device_create(cls, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
pr_info("Device created on /dev/%s\n", DEVICE_NAME);
return SUCCESS;
}
static void __exit chardev_exit(void)
{
device_destroy(cls, MKDEV(major, 0));
class_destroy(cls);
/* Отмена регистрации устройства. */
unregister_chrdev(major, DEVICE_NAME);
}
/* Методы. */
/* Вызывается, когда процесс пытается открыть файл устройства, например
* "sudo cat /dev/chardev"
*/
static int device_open(struct inode *inode, struct file *file)
{
static int counter = 0;
if (atomic_cmpxchg(&already_open, CDEV_NOT_USED, CDEV_EXCLUSIVE_OPEN))
return -EBUSY;
sprintf(msg, "I already told you %d times Hello world!\n", counter++);
try_module_get(THIS_MODULE);
return SUCCESS;
}
/* Вызывается, когда процесс закрывает файл устройства. */
static int device_release(struct inode *inode, struct file *file)
{
/* Теперь можно принимать следующий вызов. */
atomic_set(&already_open, CDEV_NOT_USED);
/* Декрементируйте число использований, иначе, открыв файл, вы уже
* не сможете извлечь модуль.
*/
module_put(THIS_MODULE);
return SUCCESS;
}
/* Вызывается, когда процесс, который уже открыл файл устройства,
* пытается из него считать.
*/
static ssize_t device_read(struct file *filp, /* см. include/linux/fs.h */
char __user *buffer, /* буфер для данных. */
size_t length, /* длина буфера. */
loff_t *offset)
{
/* Количество байт, обычно записываемых в буфер. */
int bytes_read = 0;
const char *msg_ptr = msg;
if (!*(msg_ptr + *offset)) { /* мы находимся в конце сообщения. */
*offset = 0; /* сброс смещения. */
return 0; /* обозначение конца файла. */
}
msg_ptr += *offset;
/* Помещение данных в буфер. */
while (length && *msg_ptr) {
/* Буфер находится в пользовательском сегменте данных, а не в
* сегменте ядра, поэтому присваивание "*" не сработает. Тут 133 * нужно использовать put_user, которая копирует данные из
* сегмента ядра в пользовательский сегмент.
*/
put_user(*(msg_ptr++), buffer++);
length--;
bytes_read++;
}
*offset += bytes_read;
/* Большинство функций чтения возвращают количество байт, помещённых в буфер. */
return bytes_read;
}
/* Вызывается, когда процесс производит запись в файл устройства: echo "hi" > /dev/hello */
static ssize_t device_write(struct file *filp, const char __user *buff,
size_t len, loff_t *off)
{
pr_alert("Sorry, this operation is not supported.\n");
return -EINVAL;
}
module_init(chardev_init);
module_exit(chardev_exit);
MODULE_LICENSE("GPL");
▍ 6.6 Создание модулей для нескольких версий ядра
Системные вызовы, являющиеся основным интерфейсом, который ядро раскрывает процессам, обычно среди разных версий сохраняются. Иногда могут добавляться новые системные вызовы, но старые, как правило, продолжают работать по-прежнему. Это необходимо для обратной совместимости – новая версия ядра не должна нарушать работу стандартных процессов. Файлы устройств в большинстве случаев также остаются неизменными. С другой стороны, внутренние интерфейсы ядра между версиями вполне могут меняться.
Различные версии ядра определённо имеют между собой отличия, и если вам нужна поддержка нескольких версий, то придётся писать дополнительные директивы компиляции. Делается это путём сопоставления макроса
LINUX_VERSION_CODE
с макросом KERNEL_VERSION
. В версии a.b.c
ядра значение этого макроса будет 216a + 28b + с.Продолжение
В следующей части речь пойдёт о файловой системе
/proc
, взаимодействии с модулями посредством sysfs
, а также работе с файлами устройств.Мощные VPS на SSD со скидками до 53%. Панель ISPmanager в подарок*.