Ввод-вывод: шины, прерывания и 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.
Проверьте себя
1. В чём преимущество прерываний над опросом (polling)?
AПрерывания проще программировать
BПроцессор не тратит такты на ожидание: устройство само сигналит о готовности
CПрерывания не требуют шины
DОпрос невозможен на современных CPU
2. Что делает DMA-контроллер?
AУскоряет АЛУ
BПереносит данные между устройством и памятью напрямую, минуя процессор
CПредсказывает переходы
DУправляет кэшем
3. Для чего нужна шина адреса?
AПередавать значения данных
BУказывать, по какому адресу читать или писать
CПодавать питание
DХранить команды