Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Прерывания

До сих пор мы «потрогали» немало оборудования MB2. Мы считывали кнопки, ждали таймеры, выполняли последовательную связь и общались с устройствами по I2C. Во всех этих случаях нам приходилось ждать, пока одна или несколько периферийных схем будут готовы. До сих пор ожидание шло через «опрос» (polling): мы снова и снова спрашивали у периферии, готова ли она, пока та не станет готова.

Поскольку у нашего микроконтроллера всего одно ядро процессора, пока он ждёт, он не может делать ничего другого. К тому же ядро, которое непрерывно опрашивает периферию, тратит энергию впустую, а во многих приложениях так позволить себе нельзя. Можно ли сделать лучше?

Можно! Хотя наш маленький микроконтроллер не может вычислять параллельно, он легко переключается между разными задачами во время выполнения, реагируя на события внешнего мира. Такое переключение делается с помощью механизма «прерываний» (interrupts).

Название «прерывания» вполне оправдано: они позволяют периферии прервать выполнение основной программы в любой момент времени. В nRF52833 нашего MB2 периферия подключена к Nested Vectored Interrupt Controller (NVIC), вложенному векторному контроллеру прерываний ядра. NVIC может мгновенно остановить процессор, велеть ему заняться чем-то другим, а когда это закончится, вернуть процессор к тому, чем он занимался до прерывания. О словах «вложенный» и «векторный» в названии контроллера мы поговорим позже: сначала разберёмся, как ядро переключает задачи.

Обработка прерываний

Модель вычислений, которую использует наш nRF52833, — та же, что у почти всех современных процессоров. Внутри процессора есть ячейки «черновой» памяти, которые называются «регистрами процессора». (Путаницы добавляет то, что регистры процессора — не то же самое, что «регистры устройства», о которых мы говорили в главе Регистры.) Чтобы выполнить вычисление, процессор обычно загружает значения из памяти в регистры процессора, выполняет вычисление с их помощью, а затем сохраняет результат обратно в память. (Это известно как «архитектура загрузки-сохранения», load-store architecture.)

Всё, что касается текущего вычисления, хранится в регистрах процессора. Если ядру нужно переключить задачу, оно должно где-то сохранить содержимое регистров процессора, чтобы новая задача могла использовать регистры как собственную черновую память. Когда новая задача закончится, процессор может восстановить значения регистров и продолжить прерванное вычисление. И действительно, именно это первое, что делает ядро в ответ на запрос прерывания: оно немедленно останавливает текущую работу и сохраняет содержимое регистров процессора в стеке.

Следующий шаг — переход к коду, который должен выполниться в ответ на прерывание. Служебная подпрограмма прерывания (Interrupt Service Routine, ISR), которую часто называют обработчиком прерывания (interrupt «handler»), — это особая функция в коде вашего приложения, которую ядро вызывает в ответ на прерывания. В памяти есть «таблица прерываний», которая содержит «вектор прерывания» для каждого возможного прерывания: вектор указывает, какую ISR вызвать при получении конкретного прерывания. Подробности о том, как выбирается ISR, описаны в разделе NVIC и приоритеты прерываний.

ISR «возвращает управление» с помощью специальной машинной инструкции возврата из прерывания, которая заставляет процессор восстановить регистры процессора и вернуться к тому месту, откуда была вызвана ISR.

Ткнём в MB2

Определим ISR и настроим прерывание, которое будет «тыкать» MB2 при нажатии Button A (examples/poke.rs). Плата ответит фразой «ouch» и запаникует.

#![no_main]
#![no_std]

use cortex_m::asm;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        gpiote,
        pac::{self, interrupt},
    },
    Board,
};

/// Эта «функция» будет вызвана при получении прерывания. Пока что просто
/// выводим сообщение и паникуем.
#[interrupt]
fn GPIOTE() {
    rprintln!("ouch");
    panic!();
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let button_a = board.buttons.button_a.into_floating_input();

    // Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
    // линия переходит в низкий уровень).
    let gpiote = gpiote::Gpiote::new(board.GPIOTE);
    let channel = gpiote.channel0();
    channel
        .input_pin(&button_a.degrade())
        .hi_to_lo()
        .enable_interrupt();
    channel.reset_events();

    // Настраиваем NVIC для обработки прерываний GPIO.
    unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
    pac::NVIC::unpend(pac::Interrupt::GPIOTE);

    loop {
        // «wait for interrupt»: процессор засыпает до прихода прерывания.
        asm::wfi();
    }
}

Функция-обработчик ISR «особенная». Имя GPIOTE здесь обязательно: оно указывает, что эта ISR должна быть записана в место для прерывания GPIOTE в таблице прерываний.

Атрибут #[interrupt] используется на этапе компиляции, чтобы пометить функцию как ISR, которую нужно обрабатывать особым образом. (Это «процедурный макрос» (proc macro): подробнее о нём можно прочитать в книге по Rust, если хотите.)

По сути, «процедурный макрос» превращает исходный код в другой исходный код. Если вам интересно, во что превращается конкретный вызов макроса, его можно раскрыть. Для этого можно воспользоваться инструментами в Rust Playground или командой «rust-analyzer: Expand macro» в вашей IDE.

Пометка функции атрибутом #[interrupt] подразумевает для неё несколько особых вещей:

  • Компилятор проверит, что функция не принимает аргументов и не возвращает значение (или никогда не возвращает управление). У процессора нет аргументов, которые можно передать ISR, и нет места, куда можно вернуть значение из ISR. Всё потому, что у обработчиков прерываний собственный стек вызовов (по крайней мере, концептуально, хотя на практике не всегда).

  • Указатель на эту функцию (то есть адрес функции) будет помещён в таблицу прерываний в позицию, которая соответствует имени функции.

  • Компилятор не даст вызывать ISR напрямую из обычного кода.

Для настройки прерывания есть два шага. Сначала GPIOTE нужно настроить так, чтобы он генерировал прерывание, когда вывод, подключённый к Button A, переходит из высокого уровня напряжения в низкий. Затем NVIC нужно настроить так, чтобы он разрешал это прерывание. Порядок важен: если сделать всё в «неправильном» порядке, прерывание может возникнуть раньше, чем вы будете к нему готовы.

Примечание. Как и у большинства микроконтроллеров, у GPIOTE есть много гибкости в том, когда он может генерировать прерывание. Прерывания могут возникать при переходе вывода от низкого к высокому, от высокого к низкому (как здесь), при любом изменении («фронт»), когда вывод в низком уровне или когда в высоком. На nRF52833 прерывание генерирует событие, которое в ISR нужно сбросить вручную, иначе ISR будет вызвана повторно по тому же прерыванию. У других микроконтроллеров всё может работать немного иначе: чтобы разобраться в деталях для другой платы, читайте документацию крейта Rust и микроконтроллера.

Когда вы нажимаете кнопку A, вы видите сообщение «ouch», а затем панику. Почему обработчик прерывания вызывает panic!()? Попробуйте закомментировать вызов panic!() и посмотрите, что произойдёт при нажатии кнопки. Вы увидите, как сообщения «ouch» бесконечно пролетают по экрану. NVIC запоминает, когда прерывание произошло: это «событие» сохраняется, пока программа явно его не сбросит. Без panic!() при возврате из обработчика прерывания NVIC (в этом случае) снова разрешит прерывание, заметит, что событие прерывания всё ещё ожидает обработки, и запустит обработчик снова. Так будет продолжаться вечно: каждый раз, когда обработчик прерывания возвращает управление, его вызовут снова. Как мы скоро увидим, индикацию прерывания можно сбросить внутри обработчика с помощью метода периферии reset_event().

Вы можете определить ISR для многих разных источников прерываний: когда готов I2C, когда истекает таймер и так далее. Внутри ISR можно делать почти всё что угодно, но хорошей практикой считается держать обработчики прерываний короткими и быстрыми.

Обычно после завершения ISR основная программа продолжает работать так, как работала бы, если бы прерывания не было. Но это немного проблема: как же ваше приложение узнает, что ISR выполнилась и что-то сделала? Раз у ISR нет входных параметров и результата, как код ISR может взаимодействовать с кодом приложения?