Устройства и драйверы

Прерывания | Сетевой стек

Прерывания дали способ реагировать на внешние события: NIC (Network Interface Card) сообщает о пакете, таймер --- о кванте планировщика. Но прерывание --- это сигнал, а не интерфейс. Когда процесс вызывает open("/dev/sda", O_RDONLY) и затем read(fd, buf, 4096), ядро должно знать, какой код отвечает за это конкретное устройство и какие операции оно поддерживает. За этим стоит цепочка: файловый дескриптор inode таблица функций драйвера аппаратура. Драйвер --- код, который умеет разговаривать с конкретным оборудованием. Далее --- механизмы, которые связывают пользовательский read() с байтами, возвращёнными устройством.

Символьные и блочные устройства

read() из терминала и read() с диска --- одинаковый системный вызов, но устройства ведут себя принципиально по-разному: с диска можно прочитать блок в произвольном месте, а у терминала нет никакого «места» --- только поток символов. Ядро различает два класса устройств по характеру доступа к данным.

Символьные устройства (character devices) передают данные потоком --- байт за байтом, без произвольного доступа. Терминал /dev/tty принимает символы по мере набора на клавиатуре. Генератор случайных чисел /dev/urandom выдаёт бесконечный поток байтов. Последовательный порт /dev/ttyUSB0 передаёт данные от GPS-модуля. Общая черта --- операция lseek() для них не имеет смысла: нельзя «перемотать» поток случайных чисел на 500 байт назад. Чтение из такого устройства возвращает следующую порцию данных, а не данные по конкретному смещению.

Блочные устройства (block devices) работают с данными блоками фиксированного размера (обычно 512 байт или 4 КБ) и поддерживают произвольный доступ. Диск /dev/sda --- классический пример: можно прочитать блок по смещению 2 ГБ, затем блок по смещению 100 КБ. Ядро буферизует блочный ввод-вывод через page cache: read() из /dev/sda проходит через кэш страниц, а не напрямую к оборудованию. Поверх блочных устройств работают файловые системы --- ext4, XFS, Btrfs.

Узнать тип устройства можно по первому символу в выводе ls -l /dev/sda: b --- блочное, c --- символьное. Каждое устройство идентифицируется парой чисел: major number (главный номер) определяет драйвер, minor number (дополнительный номер) определяет конкретное устройство внутри драйвера. У /dev/sda major = 8, minor = 0; у /dev/sda1 --- major = 8, minor = 1. Оба обслуживаются одним драйвером SCSI-дисков (SCSI, Small Computer System Interface). Список зарегистрированных драйверов и их major-номеров виден в cat /proc/devices.

file_operations: виртуальная таблица устройства

Когда процесс вызывает open("/dev/sda", O_RDONLY), ядро находит inode файла /dev/sda. В inode специального файла устройства хранится не указатель на блоки данных, а пара major/minor. По major-номеру ядро находит зарегистрированный драйвер, а у драйвера --- структуру file_operations (fops): таблицу указателей на функции, реализующие каждый системный вызов.

Механизм диспетчеризации работает как vtable. Вызов read(fd, buf, 4096) из пространства пользователя превращается в системный вызов sys_read(). Тот вызывает vfs_read() --- функцию VFS-слоя, которая по fd находит struct file и извлекает file_operations. Вызов f->f_op->read(f, buf, 4096, &pos) передаёт управление конкретному драйверу. Если fd указывает на /dev/sda, вызовется read() SCSI-драйвера; если на /dev/tty --- read() драйвера терминала. Пользовательский код не знает и не должен знать, какой драйвер стоит за дескриптором.

Пространство пользователя           Ядро

  read(fd, buf, 4096)
       |
       v
  syscall (trap)
       |
       v
  sys_read()
       |
       v
  vfs_read()           <-- VFS-слой: находит struct file по fd
       |
       v
  f->f_op->read()
       |
       +----------+----------+
       |                     |
       v                     v
  scsi_read()           tty_read()
  (блочное)             (символьное)
       |                     |
       v                     v
  контроллер             UART-чип
  диска

Эта схема работает одинаково для всех типов ресурсов. Обычный файл на ext4, сокет, pipe, устройство --- у каждого своя file_operations, и read() / write() маршрутизируются через неё. Маршрутизацией занимается VFS (Virtual File System) --- слой ядра, который предоставляет единый интерфейс «всё --- файл» поверх любых файловых систем и устройств.

ioctl: команды за пределами read/write

Не всё взаимодействие с устройством сводится к потоку байтов. Терминалу нужно сообщить размер окна. Сетевой карте --- включить promiscuous mode (неразборчивый режим), чтобы захватывать все пакеты в сегменте. Диску --- узнать его геометрию. Эти операции не укладываются в семантику read() / write(), и для них существует системный вызов ioctl() (input/output control).

// Получить размер терминала
struct winsize ws;
ioctl(fd, TIOCGWINSZ, &ws);
// ws.ws_row = 24, ws.ws_col = 80
 
// Включить promiscuous mode на сетевом интерфейсе
struct ifreq ifr;
strncpy(ifr.ifr_name, "eth0", IFNAMSIZ);
ioctl(sock_fd, SIOCGIFFLAGS, &ifr);
ifr.ifr_flags |= IFF_PROMISC;
ioctl(sock_fd, SIOCSIFFLAGS, &ifr);

Каждый ioctl() определяется номером команды (второй аргумент --- TIOCGWINSZ, SIOCGIFFLAGS) и специфичен для конкретного драйвера. Драйвер реализует обработчик в поле unlocked_ioctl своей file_operations. Если команда неизвестна, ядро возвращает -ENOTTY (исторически --- «not a typewriter», хотя смысл давно шире).

udev: автоматическое создание /dev

file_operations привязаны к inode через пару major/minor --- но откуда взялся сам файл /dev/sda?

Файлы в /dev/ --- не обычные файлы на диске. Раньше их создавали вручную командой mknod, указывая тип (блочное/символьное), major и minor номера. На системе с сотнями устройств, часть которых подключается и отключается динамически (USB-накопители, Bluetooth-адаптеры), ручное управление нежизнеспособно.

Современный Linux использует udev --- демон пространства пользователя, который автоматически создаёт и удаляет файлы устройств. Механизм работает в два этапа. Первый: ядро обнаруживает новое устройство (USB-накопитель подключён к порту) и отправляет uevent --- структурированное сообщение через сокет netlink (механизм обмена сообщениями между ядром и userspace). Uevent содержит путь устройства в /sys/, тип действия (add/remove/change), major/minor номера и атрибуты устройства (vendor ID, product ID, серийный номер). Второй: демон udevd получает uevent, применяет набор правил (/etc/udev/rules.d/ и /lib/udev/rules.d/) и создаёт файл в /dev/ с нужными правами, владельцем и, при необходимости, символической ссылкой.

Правила udev записываются в файлах с расширением .rules. Пример: USB-накопитель SanDisk с серийным номером AA00000012345 всегда получает путь /dev/backup-disk:

# /etc/udev/rules.d/99-backup.rules
SUBSYSTEM=="block", ATTRS{serial}=="AA00000012345", SYMLINK+="backup-disk"

Без этого правила накопитель получит имя /dev/sdb или /dev/sdc в зависимости от порядка подключения. Правило гарантирует стабильный путь.

/dev/ смонтирован как devtmpfs --- временная файловая система устройств в RAM. При загрузке системы ядро само создаёт минимальный набор устройств в devtmpfs (/dev/null, /dev/zero, /dev/console), а udevd подхватывает управление после старта и обрабатывает все остальные устройства.

Шина устройство драйвер

udev реагирует на uevent от ядра --- но как ядро обнаруживает устройства на шинах?

Устройства не существуют сами по себе --- они подключены к шинам. Модель устройств ядра Linux (Linux Device Model) отражает эту физическую реальность тремя абстракциями: шина (bus) --- канал связи (PCI --- Peripheral Component Interconnect, USB, I2C), устройство (device) --- оборудование на шине, драйвер (driver) --- код, управляющий устройством.

При загрузке системы ядро перечисляет (enumerate) устройства на каждой шине. PCI-шина --- наиболее наглядный пример. Пространство конфигурации PCI (PCI configuration space) --- 256 байт на каждое устройство (4 КБ в PCIe) --- содержит vendor ID и device ID. Ядро читает эти идентификаторы и ищет драйвер, который объявил поддержку данной пары. NVMe SSD (NVMe, Non-Volatile Memory Express) Samsung 980 PRO имеет vendor ID 0x144d (Samsung), device ID 0xa80a. Драйвер NVMe в ядре регистрирует таблицу поддерживаемых ID, и ядро находит совпадение.

Когда совпадение найдено, ядро вызывает функцию probe() драйвера, передавая ей ссылку на устройство. В probe() драйвер инициализирует оборудование: настраивает DMA-буферы (DMA, Direct Memory Access), регистрирует обработчики прерываний, создаёт очереди ввода-вывода. Если инициализация успешна, устройство готово к работе. При отключении устройства (или выгрузке драйвера) ядро вызывает remove().

Перечисление устройств на PCI-шине

  PCI bus scan
       |
       v
  slot 0: vendor=0x8086  device=0x15b8   --> e1000e (Intel NIC)
  slot 1: vendor=0x144d  device=0xa80a   --> nvme    (Samsung SSD)
  slot 2: vendor=0x10de  device=0x2504   --> nvidia  (GPU)
       |
       v
  Для каждого: driver->probe(dev)
       |
       v
  Инициализация: DMA, прерывания, очереди I/O

Результат перечисления виден в /sys/bus/pci/devices/. Каждое устройство представлено директорией с именем в формате домен:шина:слот.функция --- например, 0000:03:00.0. Внутри --- файлы с атрибутами: vendor, device, driver (символическая ссылка на драйвер), resource (MMIO-регионы, Memory-Mapped I/O).

$ ls /sys/bus/pci/devices/0000:03:00.0/
class  device  driver  irq  resource  vendor  ...
$ cat /sys/bus/pci/devices/0000:03:00.0/vendor
0x144d
$ cat /sys/bus/pci/devices/0000:03:00.0/device
0xa80a

Команда lspci -v показывает все PCI-устройства с их драйверами, MMIO-регионами и номерами прерываний --- это текстовое представление того, что ядро собрало при перечислении шины.

Модули ядра

При перечислении шины ядро ищет подходящий драйвер --- но что если он не встроен в ядро?

Не все драйверы нужны одновременно. На машине с NVMe-диском и Intel NIC не нужен драйвер Realtek Wi-Fi. Встраивание всех возможных драйверов в ядро раздуло бы его до сотен мегабайт. Ядро Linux решает это через модули (loadable kernel modules, LKM) --- файлы с расширением .ko (kernel object), которые загружаются в ядро на лету.

Модуль --- это скомпилированный объектный файл в формате ELF (Executable and Linkable Format). module_init() --- обязательная функция, вызываемая при загрузке: здесь драйвер регистрирует себя в подсистеме (PCI, USB, блочных устройств). module_exit() вызывается при выгрузке: драйвер снимает регистрацию и освобождает ресурсы. Если module_exit() не определена, модуль становится non-removable --- ядро не позволит его выгрузить командой rmmod. Между этими двумя вызовами драйвер живёт в адресном пространстве ядра и имеет полный доступ ко всем структурам ядра --- никакой изоляции между модулями нет. Сбой в модуле роняет всю систему (kernel panic).

Три команды для работы с модулями: insmod module.ko загружает один конкретный файл. rmmod module выгружает модуль. modprobe module --- интеллектуальная обёртка: она читает базу зависимостей, сгенерированную утилитой depmod, и загружает модуль вместе со всеми его зависимостями. На практике insmod почти не используется напрямую --- modprobe надёжнее.

Команда lsmod показывает загруженные модули, их размер и количество зависимых. Типичная система имеет 100-200 загруженных модулей. Модуль nvme зависит от nvme_core; ext4 зависит от jbd2 (подсистема журналирования) и mbcache. Зависимости хранятся в файле modules.dep, который depmod генерирует при установке ядра.

I/O scheduler: переупорядочивание запросов

Драйвер загружен, устройство инициализировано. Но запросы к блочному устройству не уходят к диску немедленно.

Когда процесс вызывает read() на блочном устройстве, запрос попадает в очередь, и I/O scheduler (планировщик ввода-вывода) решает, в каком порядке запросы будут отправлены на устройство. Для HDD это критично: перемещение головки между дорожками стоит 5-10 мс, и переупорядочивание запросов по номеру сектора (elevator sorting — «лифтовая» сортировка, головка движется в одном направлении) может сократить суммарное время обслуживания в разы. Для NVMe SSD, где произвольный доступ стоит столько же, сколько последовательный (~70 мкс), переупорядочивание бессмысленно и создаёт лишнюю задержку.

Начиная с ядра 3.13 доступна архитектура blk-mq (multi-queue block layer); с 5.0 она стала единственной. Каждому CPU назначается отдельная очередь отправки (software queue), а устройство имеет одну или несколько аппаратных очередей (hardware queues). NVMe SSD может поддерживать десятки аппаратных очередей --- например, по одной на каждое ядро CPU --- что устраняет конкуренцию потоков за единственный lock.

Текущий планировщик блочного устройства виден через /sys/block/<dev>/queue/scheduler:

$ cat /sys/block/sda/queue/scheduler
[mq-deadline] bfq none

$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline bfq

Квадратные скобки показывают активный планировщик. Три доступных варианта:

mq-deadline разделяет запросы на две очереди --- чтения и записи --- и гарантирует, что ни один запрос не ждёт дольше установленного дедлайна: 500 мс для чтения, 5 секунд для записи по умолчанию. Чтения приоритетнее: пользователь ждёт данных синхронно, а запись часто буферизуется в page cache. Внутри каждой очереди запросы сортируются по номеру сектора (elevator), что минимизирует перемещение головки HDD. Хороший выбор для серверов с HDD, где важна предсказуемость задержек.

BFQ (Budget Fair Queueing) назначает каждому процессу «бюджет» --- количество секторов, которое он может прочитать/записать за один квант обслуживания. Процесс, читающий маленькие файлы (браузер, IDE), получает высокий приоритет, потому что его запросы короткие и интерактивные. Фоновая задача, копирующая 10 ГБ, получает оставшуюся пропускную способность. BFQ хорош для десктопов: система остаётся отзывчивой при фоновых операциях с диском. Накладные расходы BFQ выше, чем у mq-deadline --- на быстрых SSD (100K+ IOPS, Input/Output Operations Per Second) он может стать узким местом.

none --- отсутствие переупорядочивания. Запросы передаются устройству в том порядке, в котором поступили. Оптимальный выбор для NVMe SSD: произвольный доступ стоит столько же, сколько последовательный, а любая обработка в планировщике --- лишняя задержка. На NVMe SSD с многими аппаратными очередями и задержкой около 70 мкс каждая микросекунда в software-планировщике --- потеря.

Переключение планировщика на лету:

echo "none" > /sys/block/nvme0n1/queue/scheduler
echo "mq-deadline" > /sys/block/sda/queue/scheduler

Типичная конфигурация сервера: none для NVMe, mq-deadline для HDD.

Наблюдаемость: /proc и /sys

Цепочка от read() до аппаратуры проходит через несколько слоёв: file_operations драйвера, модель шина-устройство-драйвер, I/O scheduler. Ядро выставляет два виртуальных интерфейса, через которые этот путь можно наблюдать: procfs (/proc/) и sysfs (/sys/). «Виртуальный» означает, что за файлами нет блоков на диске --- при каждом read() ядро собирает содержимое из своих внутренних структур на лету.

/proc

/proc/devices --- список зарегистрированных символьных и блочных драйверов с их major-номерами. Это тот же реестр, который ядро использует при разрешении major → драйвер при открытии файла устройства.

/proc/interrupts --- счётчики прерываний по каждому CPU и каждому источнику. Помогает диагностировать аппаратные проблемы: если один CPU обрабатывает в 10 раз больше прерываний от NIC, чем остальные, --- interrupt affinity (привязка прерываний к ядрам CPU) настроена неравномерно.

/proc/<pid>/fd/ --- директория с символическими ссылками на все открытые файловые дескрипторы процесса. ls -l /proc/1234/fd/ покажет, что fd=0 указывает на /dev/pts/0, fd=3 --- на сокет. Если количество записей в fd/ растёт и не сокращается, процесс не закрывает дескрипторы.

/sys: зеркало модели устройств

/proc появился в ранних версиях Unix для информации о процессах, и со временем в него добавляли всё подряд --- от состояния памяти до параметров SCSI-устройств. К версии ядра 2.4 /proc превратился в неструктурированную свалку. В ядре 2.6 (2003) появилась sysfs --- виртуальная файловая система, смонтированная в /sys/, с чёткой иерархией, отражающей модель устройств ядра.

/sys/bus/ --- устройства, сгруппированные по шинам. /sys/bus/pci/devices/ содержит символические ссылки на все PCI-устройства. /sys/bus/usb/devices/ --- на все USB-устройства.

/sys/class/ --- устройства, сгруппированные по функции. /sys/class/net/ содержит сетевые интерфейсы (eth0, wlan0), /sys/class/block/ --- блочные устройства, /sys/class/input/ --- устройства ввода. Одно и то же устройство видно и через /sys/bus/ (по физическому подключению), и через /sys/class/ (по логической функции).

/sys/devices/ --- дерево всех устройств, отражающее физическую топологию: платформа PCI-контроллер шина слот устройство. Это «настоящая» иерархия; файлы в /sys/bus/ и /sys/class/ --- символические ссылки в неё.

/sys/block/ --- символические ссылки на блочные устройства. Каждое устройство представлено директорией (sda, nvme0n1), внутри --- атрибуты устройства и поддиректория queue/ с параметрами очереди ввода-вывода, включая активный I/O scheduler.

Разница между /proc и /sys --- в уровне организации. /proc хранит данные в плоских текстовых файлах без единой структуры. /sys отражает реальную топологию оборудования, и каждый файл содержит ровно один атрибут (одно значение), а не многострочную таблицу.

Привязка: от устройства к данным

Итог всей цепочки: устройства зарегистрированы в модели шина-устройство-драйвер, драйверы предоставляют file_operations, udev создаёт файлы в /dev/, /proc и /sys дают наблюдаемость. open("/dev/sda") --- самый прямой путь к устройству: дескриптор, inode с major/minor, file_operations драйвера, аппаратура. Когда процесс Nginx читает обычный файл, путь длиннее: VFS диспетчеризует вызов в файловую систему (ext4), смонтированную на нужном разделе; файловая система транслирует смещение в файле в номера блоков, и запрос попадает на блочный уровень с I/O scheduler, через драйвер NVMe к DMA-передаче.

Но что происходит, когда данные приходят не с диска, а из сети? NIC получает Ethernet-фрейм, генерирует прерывание, драйвер копирует данные из DMA-буфера --- дальше эти байты должны пройти через стек сетевых протоколов и попасть в сокет приложения. Как именно --- в следующей заметке.

Sources


Прерывания | Сетевой стек