Раздел 23 · Rust

MMIO и прерывания

senior~30 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

MMIO и прерывания

Процессор из прошлых уроков умеет ровно одно: читать и писать байты по адресу через шину. Этого, оказывается, достаточно, чтобы подключить к нему целый мир устройств. Хитрость в том, что часть адресов будет вести не в память, а в регистры железа. А чтобы устройство могло привлечь внимание процессора, не дожидаясь, пока тот его опросит, заведём прерывания.

Устройства, прикинувшиеся памятью

Memory-mapped I/O это идея переиспользовать те же LW/SW/LB/SB для общения с железом. Договоримся, что верхняя часть адресного пространства это не ОЗУ, а окна устройств: запись по адресу порта зажигает светодиод, чтение по адресу таймера возвращает его счётчик. Процессору не нужны новые инструкции, он по-прежнему просто читает и пишет байты.

Каждое устройство видит не глобальный адрес, а локальное смещение своего регистра. Опишем это трейтом.

/// Устройство на шине. Видит локальное смещение регистра, а не глобальный адрес.
pub trait Device {
    fn read(&mut self, offset: u16) -> u8;
    fn write(&mut self, offset: u16, value: u8);

    /// Продвинуть внутреннее время устройства (счётчики таймера и подобное).
    fn tick(&mut self, _cycles: u32) {}

    /// Поднята ли прямо сейчас линия запроса прерывания.
    fn irq(&self) -> bool { false }

    /// Контроллер обслужил запрос, опускаем линию.
    fn ack_irq(&mut self) {}
}

Шина-диспетчер

Теперь нужна шина, которая по адресу решает, кому отдать доступ: в ОЗУ или в устройство. Она хранит память и список отображений (какое устройство на какой диапазон адресов повешено и какой у него вектор прерывания).

/// Одно отображение: диапазон адресов, устройство и его вектор прерывания.
struct Region {
    start: u16,
    len: u16,
    vector: Option<u8>,
    device: Box<dyn Device>,
}

/// Шина с памятью и периферией.
pub struct MmioBus {
    ram: Ram,
    regions: Vec<Region>,
}

Главное в MmioBus это маршрутизация: на каждый доступ она ищет, попал ли адрес в окно какого-то устройства, и если да, отдаёт доступ ему (с пересчётом адреса в локальное смещение), иначе идёт в ОЗУ. Процессор по-прежнему видит только трейт Bus и ничего не знает про эту развилку.

impl Bus for MmioBus {
    fn read8(&mut self, addr: u16) -> u8 {
        match self.region_for(addr) {
            Some(region) => region.device.read(addr - region.start),
            None => self.ram.read8(addr),
        }
    }

    fn write8(&mut self, addr: u16, value: u8) {
        match self.region_for(addr) {
            Some(region) => region.device.write(addr - region.start, value),
            None => self.ram.write8(addr, value),
        }
    }

    fn tick(&mut self, cycles: u32) {
        for region in &mut self.regions {
            region.device.tick(cycles);
        }
    }
}

Вот зачем в прошлом уроке Bus был трейтом: мы подставили MmioBus вместо Ram, и ядро процессора не заметило подмены. Карта адресов это просто соглашение этой машины: ОЗУ снизу, GPIO на 0xFF00, таймер на 0xFF10, UART на 0xFF20, таблица векторов под самым потолком на 0xFFE0.

Таймер, который сам себя считает

Возьмём для примера таймер обратного отсчёта. У него несколько регистров по смещениям: управление, статус, значение перезагрузки, текущий счётчик. Самое интересное это tick: на каждом продвижении времени счётчик уменьшается, а дойдя до нуля, поднимает флаг и (если разрешено) линию прерывания.

impl Device for Timer {
    fn tick(&mut self, cycles: u32) {
        if !self.enabled() { return; }
        let mut remaining = cycles;
        while remaining > 0 {
            if (self.count as u32) > remaining {
                self.count -= remaining as u16;
                return;
            }
            // Счётчик достиг нуля в этом окне тактов.
            remaining -= self.count as u32;
            self.expired = true;
            if self.ctrl & CTRL_IRQ_EN != 0 {
                self.pending_irq = true;
            }
            if self.ctrl & CTRL_AUTO_RELOAD != 0 && self.reload != 0 {
                self.count = self.reload; // снова заводим
            } else {
                self.count = 0;
                self.ctrl &= !CTRL_ENABLE;
                return;
            }
        }
    }

    fn irq(&self) -> bool { self.pending_irq }
    fn ack_irq(&mut self) { self.pending_irq = false; }
}

Прерывания: устройство дёргает процессор

Опрашивать таймер в цикле расточительно. Лучше пусть он сам дёрнет процессор, когда сработает. Это и есть прерывание. Перед выборкой очередной инструкции процессор спрашивает шину, не поднял ли кто линию. Если да, он сохраняет на стек PC и флаги, запрещает вложенные прерывания и прыгает по адресу из таблицы векторов.

impl Cpu {
    pub fn step(&mut self, bus: &mut impl Bus) -> Step {
        if self.halted { return Step::Halted { code: 0 }; }

        // Перед выборкой: не просится ли прерывание?
        if self.interrupts_enabled {
            if let Some(vector) = bus.poll_irq() {
                self.enter_interrupt(bus, vector);
                // ... учли такты, тикнули периферию ...
                return Step::Interrupt { vector, cycles: 4 };
            }
        }

        let word = bus.read16(self.pc);   // fetch
        let instr = decode(word);          // decode
        self.pc = self.pc.wrapping_add(2);
        let step = self.execute(bus, instr); // execute
        // между шагами двигаем время периферии:
        bus.tick(/* стоимость шага */);
        step
    }

    fn enter_interrupt(&mut self, bus: &mut impl Bus, vector: u8) {
        self.push16(bus, self.pc);            // адрес возврата на стек
        self.push16(bus, pack_flags(self.flags)); // и флаги
        self.interrupts_enabled = false;      // запрет вложенных
        self.pc = bus.read16(IVT_BASE.wrapping_add((vector as u16) << 1));
    }
}

Обработчик заканчивается командой возврата из прерывания (SYS RETI), которая снимает со стека флаги и PC и снова разрешает прерывания. Вот тут и пригодились push16/pop16 из прошлого урока: прерывание это аппаратный вызов через тот же стек-соглашение.

Попробуй сам

В виджете прошивка-мигалка: она заводит таймер на 0xFF10, разрешает прерывания и уходит в цикл ожидания. Каждый раз, когда счётчик таймера добегает до нуля, процессор прыгает в обработчик, тот переключает ножку 0 (светодиод) и уменьшает счётчик миганий. Жми «до прерывания», чтобы перескочить скучный цикл ожидания и попасть ровно в момент срабатывания.

Посмотри, как полоска счётчика таймера ползёт вниз, на нуле вспыхивает EXPIRED, и управление прыгает в обработчик (адреса с серым фоном). Светодиод переключается ровно пять раз, потом главный цикл видит обнулённый счётчик миганий и останавливает процессор.

Развилка дизайна и её цена

Мысль урока: когда тикать периферию, это выбор уровня точности из урока 36, доведённый до кода.

Мы двигаем время устройств между инструкциями: исполнили шаг, посчитали его стоимость в тактах, вызвали bus.tick(cost). Это инструкционная модель: периферия узнаёт о времени порциями, по целой инструкции за раз. Альтернатива это тикать на каждом такте внутри инструкции, проверяя прерывания и состояние устройств между микрошагами. Так делают цикл-точные эмуляторы консолей, где видеочип должен увидеть запись ровно на нужном такте, иначе строка нарисуется не так.

Цена инструкционной модели: прерывание срабатывает не точно в тот такт, когда счётчик дошёл до нуля, а на границе следующей инструкции. Для мигалки это незаметно (число переключений детерминировано, плавает только тайминг). Для NES это было бы фатально. Цена цикл-точной модели: на порядок больше работы на каждую инструкцию и куда более сложный код. Мы сознательно берём инструкционную точность и закладываем tick(cost) так, чтобы при желании уплотнить её до цикл-точной в последнем уроке блока, где как раз будем гоняться за точностью осознанно. В следующем уроке поднимемся на уровень выше периферии: спрячем её за HAL.

Домашка