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

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

Прерывания дали способ реагировать на внешние события: NIC (Network Interface Card) сообщает о пакете, таймер --- о кванте планировщика. Но прерывание --- это сигнал, а не интерфейс. Когда процесс вызывает open("/dev/sda", O_RDONLY) и затем read(fd, buf, 4096), ядро должно знать, какой код отвечает за это конкретное устройство и какие операции оно поддерживает. За этим стоит цепочка: файловый дескриптор inode таблица функций драйвера аппаратура. Драйвер --- код, который умеет разговаривать с конкретным оборудованием. Далее --- механизмы, которые связывают пользовательский 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(). Ядро по 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
  fd_table[fd] --> struct file
                      |
                      v
                   f->f_op->read()
                      |
           +----------+----------+
           |                     |
           v                     v
     scsi_read()           tty_read()
     (блочное)             (символьное)
           |                     |
           v                     v
     контроллер             UART-чип
     диска

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

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 байт на каждое устройство --- содержит 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() вызывается при выгрузке: драйвер снимает регистрацию и освобождает ресурсы. Между этими двумя вызовами драйвер живёт в адресном пространстве ядра и имеет полный доступ ко всем структурам ядра --- никакой изоляции между модулями нет. Сбой в модуле роняет всю систему (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 мкс), переупорядочивание бессмысленно и создаёт лишнюю задержку.

Современное ядро (5.0+) использует архитектуру blk-mq (multi-queue block layer): каждому CPU назначается отдельная очередь отправки (software queue), а устройство имеет одну или несколько аппаратных очередей (hardware queues). NVMe SSD может иметь 64 аппаратных очереди, по одной на каждое ядро 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 (noop) --- отсутствие переупорядочивания. Запросы передаются устройству в том порядке, в котором поступили. Оптимальный выбор для NVMe SSD: произвольный доступ стоит столько же, сколько последовательный, а любая обработка в планировщике --- лишняя задержка. На NVMe SSD с 64 аппаратными очередями и задержкой 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 на блочном уровне. Ядро предоставляет два виртуальных интерфейса для наблюдения за этим путём и настройки параметров.

/proc: окно в ядро

Как узнать, сколько памяти потребляет процесс? Какие файлы он держит открытыми? Сколько прерываний обработал каждый CPU? Для этого нужен интерфейс к внутренним структурам ядра. Выделенный системный вызов на каждый вопрос --- комбинаторный взрыв. Linux решает это через procfs --- виртуальную файловую систему, смонтированную в /proc/.

Слово «виртуальная» означает, что за файлами нет блоков на диске. Файл /proc/meminfo не занимает ни одного байта на SSD. При каждом read() ядро генерирует содержимое на лету, собирая данные из своих внутренних структур. Запись cat /proc/meminfo эквивалентна вызову функции ядра, которая форматирует текущее состояние памяти в текст.

Процессы: /proc/<pid>/

Каждый процесс представлен директорией /proc/<pid>/, где <pid> --- идентификатор процесса. Внутри --- файлы, описывающие всё, что ядро знает о процессе:

/proc/<pid>/status --- состояние процесса в человекочитаемом виде: имя, PID, PPID, состояние (running/sleeping/zombie), потребление памяти (VmRSS --- резидентная, VmSize --- виртуальная), количество потоков. VmRSS: 124000 kB означает, что процесс удерживает 124 МБ физической памяти.

/proc/<pid>/maps --- карта виртуальной памяти: какие диапазоны адресов заняты, с какими правами (rwxp), какие файлы в них отображены. Здесь видны сегменты кода (r-xp), данных (rw-p), стека, heap и отображённые библиотеки. Утечку памяти через mmap() можно обнаружить по росту анонимных регионов в maps.

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

Общесистемная информация

/proc/meminfo --- состояние памяти: MemTotal (всего RAM), MemFree (свободно), MemAvailable (доступно с учётом рекультивируемых кэшей), Buffers, Cached, SwapTotal, SwapFree. Разница между MemFree и MemAvailable существенна: MemFree --- страницы, не занятые ничем; MemAvailable учитывает page cache, который ядро может освободить при необходимости. Система с MemFree: 200 MB и MemAvailable: 6 GB работает нормально --- 6 ГБ кэша в любой момент могут быть отданы процессам.

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

/proc/sys/ и sysctl

/proc/sys/ --- особая ветка, через которую можно не только читать, но и менять параметры ядра в реальном времени. Утилита sysctl --- обёртка над чтением и записью файлов в /proc/sys/.

vm.overcommit_memory (/proc/sys/vm/overcommit_memory) управляет политикой выделения виртуальной памяти. Значение 0 (по умолчанию) --- ядро использует эвристику, разрешая «мягкий» overcommit. Значение 1 --- разрешать всегда (malloc никогда не вернёт NULL, но OOM killer может убить процесс позже). Значение 2 --- строгий учёт: ядро не выделит больше, чем swap + RAM * overcommit_ratio/100.

vm.swappiness (/proc/sys/vm/swappiness) --- число от 0 до 200, определяющее склонность ядра вытеснять анонимные страницы (heap процессов) в swap относительно вытеснения страниц файлового кэша. Значение по умолчанию --- 60. На серверах с БД часто ставят 10 или 1, чтобы ядро предпочитало вытеснять файловый кэш, а не данные процессов.

net.ipv4.ip_forward (/proc/sys/net/ipv4/ip_forward) --- включение маршрутизации пакетов между интерфейсами. По умолчанию 0 (выключено) --- машина не пересылает пакеты, адресованные не ей. Значение 1 превращает машину в маршрутизатор. Нужно для контейнерных сред: Docker и Kubernetes требуют ip_forward=1, чтобы пакеты проходили между виртуальными сетями.

Запись через sysctl действует до перезагрузки. Для постоянных настроек параметры прописываются в /etc/sysctl.d/*.conf (в NixOS --- через boot.kernel.sysctl).

/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/ с параметрами очереди ввода-вывода.

Разница между /proc и /sys --- в уровне организации. /proc хранит данные в плоских текстовых файлах без единой структуры: /proc/scsi/scsi и /proc/net/dev не связаны никакой иерархией. /sys отражает реальную топологию оборудования, и каждый файл содержит ровно один атрибут (одно значение), а не многострочную таблицу. Для информации о процессах по-прежнему используется /proc/<pid>/, для всего, что касается оборудования и параметров ядра, --- /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


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