Прерывания дали способ реагировать на внешние события: 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): таблицу указателей на функции, реализующие каждый системный вызов.
Драйвер заполняет только те поля, которые поддерживает. Остальные остаются NULL --- ядро вернёт ошибку (конкретный код зависит от операции) или использует реализацию по умолчанию. Символьный драйвер обычно реализует open, read, write, release и unlocked_ioctl. Блочный драйвер регистрирует не file_operations напрямую, а struct block_device_operations --- аналогичную таблицу для блочного уровня.
Механизм диспетчеризации работает как 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:
Без этого правила накопитель получит имя /dev/sdb или /dev/sdc в зависимости от порядка подключения. Правило гарантирует стабильный путь.
/dev/ смонтирован как devtmpfs --- временная файловая система устройств в RAM. При загрузке системы ядро само создаёт минимальный набор устройств в devtmpfs (/dev/null, /dev/zero, /dev/console), а udevd подхватывает управление после старта и обрабатывает все остальные устройства.
NixOS: systemd-udevd и /dev
В NixOS udevd реализован через systemd-udevd --- часть systemd. Правила udev описываются декларативно в конфигурации NixOS:
Nix генерирует файлы правил в /etc/udev/rules.d/ из конфигурации при nixos-rebuild switch. Модули NixOS для различных подсистем (bluetooth, sound, input) автоматически добавляют нужные udev-правила.
Шина → устройство → драйвер
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 генерирует при установке ядра.
NixOS: модули ядра
В NixOS модули ядра хранятся в /run/current-system/kernel-modules/lib/modules/<версия>/. Путь определяется текущим поколением системы --- при nixos-rebuild switch модули пересобираются вместе с ядром и помещаются в новый путь. modprobe в NixOS настроен на поиск модулей в этом пути.
Автозагрузка по событию udev работает через modalias --- строку, описывающую устройство. Ядро отправляет uevent с modalias, systemd-udevd вызывает modprobe с этой строкой, и modprobe находит подходящий модуль по таблице алиасов.
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:
Квадратные скобки показывают активный планировщик. Три доступных варианта:
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-планировщике --- потеря.
Типичная конфигурация сервера: 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-буфера --- дальше эти байты должны пройти через стек сетевых протоколов и попасть в сокет приложения. Как именно --- в следующей заметке.