Ввод-вывод: шины, прерывания и DMA
Урок объясняет, как процессор обменивается данными с клавиатурой, диском, сетью и другими устройствами.
Шина — общий набор проводов для передачи данных, адресов и управляющих сигналов между процессором, памятью и устройствами. Прерывание — сигнал устройства процессору «я готов, обрати внимание».
Зачем особый механизм для ввода-вывода
Процессор работает в наносекундах, а диск и клавиатура — в миллисекундах: разница в миллионы раз. Если бы процессор просто ждал устройство, он простаивал бы вечность. Нужны механизмы, позволяющие процессору заниматься делом, пока устройство работает, и узнавать о готовности вовремя.
Шины
Устройства подключены к процессору и памяти через шины. Классически выделяют три: шина данных (передаёт значения), шина адреса (указывает, куда/откуда), шина управления (сигналы чтения/записи, прерываний). Ширина шины данных (например, 64 бита) определяет, сколько передаётся за раз.
┌─────────┐ шина адреса ┌─────────┐
│ CPU │════════════════→│ ПАМЯТЬ │
│ │←═══ шина данных ═│ │
└────┬────┘ шина управл. └─────────┘
║════════╦═══════════╦══════════╗
┌──╨──┐ ┌──╨──┐ ┌───╨──┐ ┌───╨──┐
│ диск│ │сеть │ │клав. │ │ GPU │
└─────┘ └─────┘ └──────┘ └──────┘
Опрос против прерываний
Есть два способа узнать, готово ли устройство. Опрос (polling): процессор в цикле спрашивает «готов? готов?» — просто, но тратит такты впустую. Прерывание (interrupt): процессор занимается своими делами, а устройство само подаёт сигнал, когда готово; процессор бросает текущую работу, обрабатывает событие и возвращается. Сравним «потраченные такты»:
def polling_cycles(wait_cycles, poll_cost):
# процессор крутит цикл ожидания всё время
return wait_cycles * poll_cost
def interrupt_cycles(wait_cycles, handler_cost):
# процессор занят полезным делом, тратит такты лишь на обработчик
return handler_cost # сама работа во время ожидания полезна
wait = 1000 # устройство готовится 1000 тактов
print("опрос: потрачено", polling_cycles(wait, 1), "тактов впустую")
print("прерывание: потрачено", interrupt_cycles(wait, 50), "тактов (только обработчик)")
print("за время ожидания CPU при прерывании сделал ~", wait, "тактов полезной работы")Вывод:
опрос: потрачено 1000 тактов впустую прерывание: потрачено 50 тактов (только обработчик) за время ожидания CPU при прерывании сделал ~ 1000 тактов полезной работы
Как работает под капотом: DMA
Даже с прерываниями есть проблема: при передаче большого файла процессор должен копировать каждый байт «диск → процессор → память». Это снова занятость впустую. Решение — DMA (Direct Memory Access): специальный контроллер копирует данные между устройством и памятью напрямую, минуя процессор. Процессор лишь говорит «перенеси N байт оттуда сюда» и получает прерывание по завершении.
БЕЗ DMA: диск -> CPU -> память (CPU копирует каждый байт)
С DMA: диск ===DMA===> память (CPU свободен!)
CPU только: 1) задал задачу DMA
2) получил прерывание "готово"
| Механизм | Нагрузка на CPU | Когда применяют |
| Опрос (polling) | высокая (крутит цикл) | очень частые быстрые события |
| Прерывания | средняя (обработчик) | редкие события (клавиша, пакет) |
| DMA | минимальная | большие передачи (диск, сеть, видео) |
Глубже в тему
Корень всех проблем ввода-вывода — чудовищная разница в скоростях. Процессор оперирует тактами в доли наносекунды, а механический диск отвечает за миллисекунды, клавиатура — за десятки миллисекунд между нажатиями. Разница достигает миллионов раз: если бы процессор просто стоял и ждал диск, это было бы всё равно что человеку замереть на несколько месяцев в ожидании одного письма. Поэтому вся подсистема ввода-вывода построена вокруг одной цели — не дать быстрому процессору простаивать, пока медленные устройства делают своё дело. Три механизма из урока (опрос, прерывания, DMA) — это три последовательных шага по освобождению процессора: сначала от активного ожидания, затем от ручного копирования данных.
Выбор между опросом и прерываниями не так однозначен, как кажется на первый взгляд, и зрелый инженер понимает оба компромисса. Прерывание кажется безусловно лучше — процессор занят полезной работой и реагирует лишь по сигналу. Но у прерывания есть накладные расходы: сохранить состояние, переключиться в обработчик, восстановить состояние — это десятки-сотни тактов на каждое событие. Если события идут очень часто (например, высокоскоростная сетевая карта на 10 гигабит, где пакеты сыплются непрерывно), лавина прерываний может «утопить» процессор накладными расходами — это явление называют interrupt storm. Тогда выгоднее вернуться к опросу или гибридным схемам: современные сетевые драйверы (механизм NAPI в Linux) при высокой нагрузке отключают прерывания и переходят на опрос, а при затишье — обратно. Так «устаревший» опрос оказывается оптимальным в условиях, где прерывания захлёбываются.
DMA — третий и решающий шаг освобождения процессора, и стоит понять, почему даже прерываний недостаточно. Представьте копирование большого файла с диска: даже если процессор узнаёт о готовности данных по прерыванию, ему всё равно приходится самому переписывать каждый байт по маршруту «устройство → регистр процессора → память». Для мегабайтов это миллионы команд копирования — процессор снова занят впустую, просто перекладывая данные. DMA-контроллер берёт эту черновую работу на себя: процессор лишь говорит «перенеси N байт оттуда сюда» и возвращается к полезным делам, а специализированный контроллер копирует данные напрямую между устройством и памятью, минуя процессор, и шлёт прерывание лишь по завершении всей передачи. Так процессор тратит на гигабайтную передачу не миллионы команд, а буквально две: задать задачу и принять рапорт.
У DMA есть тонкая, но важная цена — проблема когерентности кэша, о которой легко забыть. DMA-контроллер пишет данные прямо в ОЗУ, в обход процессора, а значит, и в обход его кэша. Что произойдёт, если процессор держит в кэше старую копию той области памяти, которую DMA только что обновил свежими данными с диска? Процессор увидит устаревшее значение из кэша, а не настоящее из памяти — классическая рассогласованность. Поэтому система должна согласовать кэш с памятью после DMA-передачи: либо аппаратно (когерентный DMA, где контроллер участвует в протоколе когерентности кэшей), либо программно (драйвер явно сбрасывает или инвалидирует соответствующие кэш-строки до и после передачи). Эта проблема — частный случай той же темы когерентности, что возникает между ядрами в многоядерных процессорах, и она показывает, что кэш, ускоряя обычную работу, добавляет хлопот всякий раз, когда память меняет кто-то «со стороны». Понимание этого тонкого момента отличает того, кто умеет писать корректные драйверы, от того, кто ловит загадочные ошибки порчи данных.
Частые ошибки
- Считать опрос всегда плохим. Для очень частых событий опрос дешевле, чем накладные расходы на прерывания.
- Думать, что DMA «обходит» память. Наоборот, DMA пишет прямо в память, обходя именно процессор.
- Забывать про когерентность. После DMA данные в памяти могли измениться, а в кэше CPU — устареть; это требует согласования.
Итог
- Шины (данных/адреса/управления) связывают процессор, память и устройства.
- Прерывания освобождают процессор от опроса: устройство само сигналит о готовности.
- DMA переносит большие объёмы между устройством и памятью напрямую, разгружая CPU.