Взаимодействие приложения с ядром Linux
Если есть приложение, которое взаимодействует с ядром Linux, то это взаимодействие осуществляется через интерфейс системных вызовов (system call interface) механизм, позволяющий пользовательским программам запрашивать услуги у операционной системы. В ядре существуют подсистемы, и нас интересуют те, что относятся к вводу-выводу. Эти подсистемы взаимодействуют с компонентами ядра Linux через интерфейс символьного драйвера (character device driver interface).
Интерфейс символьного драйвера
Интерфейс символьного драйвера представляет собой набор стандартных вызовов:
Open - Открывает файл устройства, инициализирует драйвер и подготавливает устройство к работе
Close - Закрывает файл устройства, освобождает ресурсы и завершает работу с устройством
Read - Читает данные из устройства в буфер пользовательского пространства
Write - Записывает данные из пользовательского буфера в устройство
IOCTL (Input/Output Control) - Выполняет специфические для устройства команды управления, не covered by read/write
MMAP (Memory Map) - Отображает память устройства или ядра в адресное пространство пользовательского процесса
В пользовательском приложении, написанном на любом предпочтительном языке, мы взаимодействуем, открывая специальные файлы устройств для данного драйвера, и далее начинаем читать, писать, вызывать IOCTL и так далее.
Специальные файлы устройств
В каталоге `/dev` существуют специальные файлы устройств для символьных драйверов. Для них характерно то, что при выполнении `ls -l` отображается информация о том, что это символьный драйвер (обозначается буквой `c` в начале строки разрешений).
Специальный файл устройства — это файл особого типа. Он фактически не содержит драйвер, а содержит два ключевых идентификатора:
- Старший номер устройства (Major number) — идентифицирует драйвер (какой драйвер будет обрабатывать операции)
- Младший номер устройства (Minor number) — идентифицирует конкретное устройство, с которым работает этот драйвер (например, если драйвер управляет несколькими портами)
Таким образом, старший номер устройства является ключом, используемым для идентификации драйвера, с которым будет взаимодействовать пользовательское приложение.
Механизм вызова функций драйвера
Если в пользовательской программе выполняются вызовы `open()`, `close()`, `read()`, `write()`, `ioctl()`, `mmap()` или другие, то мы вызываем соответствующую функцию в драйвере, идентификатор которого находится по номеру, хранящемуся в специальном файле устройства. Если посмотреть на вывод команды `ls -l /dev`, то можно увидеть старшие номера устройства.
Альтернативные способы взаимодействия: sysfs
Существуют ли другие способы взаимодействия с простыми устройствами, если мы не рассматриваем сетевые устройства и блочные устройства? Да. Существует интерфейс через специальную файловую систему, которая называется sysfs (system filesystem).
Интерфейс sysfs позволяет драйверу взаимодействовать фактически через запись в специальный файл в виртуальной файловой системе sysfs, расположенной в `/sys`. В определенной степени это удобнее, так как позволяет взаимодействовать с устройством не только из программ, но и из скриптовых языков.
Пример работы с GPIO через sysfs:
```bash
# Экспорт GPIO-пина (делает его доступным)
echo 17 > /sys/class/gpio/export
# Настройка направления (вход/выход)
echo "out" > /sys/class/gpio/gpio17/direction
# Запись значения (1 - высокий уровень)
echo 1 > /sys/class/gpio/gpio17/value
# Чтение значения
cat /sys/class/gpio/gpio17/value
```
Данный пример показывает, как можно писать и читать данные из GPIO при помощи стандартных команд из командной строки. Можно записать, изменить какой-либо пин GPIO или прочитать его текущее состояние, даже не запуская никакую программу.
Сравнение интерфейсов: символьный драйвер vs sysfs
Для того же GPIO существует стандартный интерфейс символьного драйвера, когда при помощи IOCTL команд можно изменять значение пина, открывать его, закрывать, читать значение пина и так далее.
Интерфейс символьного драйвера представляет более мощный интерфейс, однако он не всегда удобен при использовании скриптовых языков. С другой стороны, интерфейс символьного драйвера в значительной степени более гибкий.
Примеры устройств с интерфейсом символьного драйвера
Интерфейс символьного драйвера имеют различные устройства:
- Последовательные порты: `/dev/tty` или `/dev/ttyUSB` — терминальные устройства для работы с COM-портами и USB-to-Serial адаптерами
- Video4Linux: `/dev/video` — устройства видеозахвата (камеры)
- Фреймбуферы — устройства прямого доступа к видеопамяти
- I2C-устройства — `/dev/i2c-*` — шина для связи с низкоскоростными периферийными устройствами
- SPI-устройства — `/dev/spidev*` — шина для связи с периферией по протоколу SPI
Все они так или иначе имеют интерфейс символьного драйвера.
Структура взаимодействия в ядре
Для пользовательской программы взаимодействие с ними может осуществляться через файловые операции `open()`, `close()`, `read()`, `write()`, `ioctl()` и так далее. Та же самая операция `open()` может напрямую вызывать вызов `open()`, если обращение идет к простому драйверу, или если драйвер взаимодействует с каким-либо фреймбуфером, то вызывается фреймбуфер, который в свою очередь вызывает нижележащий драйвер.
Драйверы в пользовательском пространстве
Поскольку в Linux у нас монолитное ядро, драйвер обычно находится внутри этого ядра. Однако иногда существует возможность разработать драйвер в пользовательской программе, то есть не погружаясь в ядро.
Подсистема UIO (Userspace I/O)
Существует подсистема UIO, которая позволяет:
1. Отобразить регистры устройства в адресное пространство пользователя — дает прямой доступ к аппаратным регистрам без написания ядерного кода
2. Работать с прерываниями — UIO может перенаправлять прерывания от устройства в пользовательское пространство через файловый дескриптор
Эта подсистема потенциально позволяет работать и с прерываниями. То есть при желании можно сделать драйвер в пользовательском пространстве.
Другие подсистемы пользовательского пространства
- libusb — библиотека для работы с USB-устройствами без ядерного драйвера
- USB 2.0 в пользовательском пространстве — некоторые реализации предоставляют интерфейс в пользовательском пространстве
Механизм MMAP для прямого доступа к регистрам
Существует возможность отобразить регистры или регистровую память устройства в пользовательское пространство. Это потенциально делается при помощи вызова `mmap()` (memorymap), который есть в структуре `file_operations` специфичного для символьных драйверов.
Процесс работы с mmap:
1. Устройство открывается через `open()`
2. Вызывается `mmap()` для отображения физических адресов регистров в виртуальное адресное пространство процесса
3. Далее можно работать с устройством через прямое чтение/запись в память без системных вызовов
Можно взять устройство и его адреса, они отображаются в пользовательское пространство и далее можно с ними работать. Можно оформить это в виде библиотеки в пользовательском пространстве. И в этом случае взаимодействие с устройством не будет включать системный вызов. Можно напрямую писать в регистры устройства и взаимодействовать с ними.
Преимущества и недостатки драйверов в пользовательском пространстве
У данного подхода есть свои преимущества. Если нужно быстро написать драйвер устройства и не хочется заниматься ядерным драйвером или у нас недостаточно опыта для написания ядерных драйверов, можно пойти по этому пути.
Ключевой минус: Все ошибки, которые могут произойти при взаимодействии с устройством, при параллельном или непараллельном доступе, скрыты внутри драйвера. В ядерном драйвере разработчик позаботился о том, чтобы пользователь не мог случайно создать проблемы. Если регистры устройства отображаются в пользовательское пространство, соответственно, пользователю дается много возможностей для ошибок, поэтому в Linux рекомендуются ядерные драйверы.
Структура file_operations в ядре
Для символьного драйвера существует специальный файл устройства. В специальном файле устройства содержатся старший номер устройства и младший номер устройства. Мы работаем с таким специальным файлом устройства как со стандартным файлом. То есть открываем его, начинаем читать, начинаем писать туда, можем вызвать `ioctl()`, можем при помощи `mmap()` отобразить, если данная операция поддерживается.
Примеры использования mmap:
- Если данный драйвер имеет такую операцию, то он может включить работу с отображением, можно работать непосредственно с регистрами
- `/dev/video` позволяет читать кадры с устройства без выполнения системного вызова, что критично для производительности видеозахвата
Стандартный способ — действовать через системные вызовы. Необходимо отметить, что внутри ядра для каждого символьного драйвера существует структура `file_operations`, в которой есть точки входа, соответствующие `read`, `write`, `ioctl`, `mmap`, `open`, `close`.
Процесс создания драйвера:
1. Взять структуру `file_operations`
2. Реализовать собственные функции, специфичные для конкретного драйвера
3. Заполнить структуру этими функциями или указателями на эти функции
4. Зарегистрировать структуру `file_operations` в ядре
5. Ядро сообщит, какой старший номер устройств выделен драйверу
Необходимо отметить следующее: если мы посмотрим, например, на функцию `read`, как она выглядит, то можно увидеть, что функции `write`, `ioctl` выглядят аналогичным образом — все они следуют соглашению о передаче параметров через структуру `file` и буферы пользовательского пространства.