Параллелизм
Параллелизм возникает, когда разные части вашей программы могут выполняться в разное время или не по порядку. В контексте встраиваемых систем это включает:
- Обработчики прерываний, которые запускаются при возникновении соответствующего прерывания.
- Различные формы многопоточности, когда микропроцессор регулярно переключается между частями вашей программы.
- В некоторых системах — многоядерные микропроцессоры, где каждое ядро может независимо выполнять разные части программы одновременно.
Поскольку многие программы для встраиваемых систем должны работать с прерываниями, вопросы параллелизма возникают рано или поздно, и именно здесь могут появляться тонкие и сложные ошибки. К счастью, Rust предоставляет ряд абстракций и гарантий безопасности, которые помогают писать корректный код.
Отсутствие параллелизма
Самый простой подход к параллелизму в программе для встраиваемых систем — это его полное отсутствие: программа состоит из одного основного цикла, который непрерывно выполняется, и прерывания полностью отсутствуют. Иногда это идеально подходит для решаемой задачи! Обычно такой цикл считывает входные данные, выполняет их обработку и записывает выходные данные.
#[entry]
fn main() {
let peripherals = setup_peripherals();
loop {
let inputs = read_inputs(&peripherals);
let outputs = process(inputs);
write_outputs(&peripherals, outputs);
}
}
Поскольку параллелизм отсутствует, нет необходимости беспокоиться о совместном использовании данных между частями программы или синхронизации доступа к периферийным устройствам. Если такой простой подход подходит для вашей задачи, это может быть отличным решением.
Глобальные изменяемые данные
В отличие от Rust для не-встраиваемых систем, мы обычно не можем позволить себе создавать выделения памяти на куче и передавать ссылки на эти данные в новые потоки. Вместо этого обработчики прерываний могут быть вызваны в любой момент и должны знать, как получить доступ к общей памяти. На самом низком уровне это означает, что у нас должна быть статически выделенная изменяемая память, на которую могут ссылаться как обработчик прерываний, так и основной код.
В Rust такие переменные static mut всегда небезопасны для чтения или записи, так как без особых мер предосторожности можно вызвать состояние гонки, когда доступ к переменной прерывается на полпути прерыванием, которое также обращается к этой переменной.
Для примера, как это поведение может вызывать тонкие ошибки, рассмотрим программу для встраиваемых систем, которая подсчитывает восходящие фронты входного сигнала за каждый односекундный период (счетчик частоты):
static mut COUNTER: u32 = 0;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// ОПАСНОСТЬ - На самом деле не безопасно! Может вызвать гонки данных.
unsafe { COUNTER += 1 };
}
last_state = state;
}
}
#[interrupt]
fn timer() {
unsafe { COUNTER = 0; }
}
Каждую секунду прерывание таймера сбрасывает счетчик в 0. Тем временем основной цикл непрерывно измеряет сигнал и увеличивает счетчик при обнаружении перехода с низкого уровня на высокий. Нам пришлось использовать unsafe для доступа к COUNTER, так как это static mut, что означает, что мы обещаем компилятору не вызывать неопределенное поведение. Можете ли вы заметить состояние гонки? Операция увеличения COUNTER не гарантированно атомарна — на большинстве платформ для встраиваемых систем она будет разделена на загрузку, увеличение и сохранение. Если прерывание сработает после загрузки, но до сохранения, сброс в 0 будет проигнорирован после возврата из прерывания — и мы подсчитаем в два раза больше переходов за этот период.
Критические секции
Как справиться с гонками данных? Простой подход — использовать критические секции, контекст, в котором прерывания отключены. Обернув доступ к COUNTER в main в критическую секцию, мы можем быть уверены, что прерывание таймера не сработает, пока мы не закончим увеличение COUNTER:
static mut COUNTER: u32 = 0;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// Новая критическая секция обеспечивает синхронизированный доступ к COUNTER
cortex_m::interrupt::free(|_| {
unsafe { COUNTER += 1 };
});
}
last_state = state;
}
}
#[interrupt]
fn timer() {
unsafe { COUNTER = 0; }
}
В этом примере мы используем cortex_m::interrupt::free, но на других платформах будут аналогичные механизмы для выполнения кода в критической секции. Это эквивалентно отключению прерываний, выполнению кода и последующему их включению.
Обратите внимание, что критическая секция не понадобилась внутри прерывания таймера по двум причинам:
- Запись 0 в
COUNTERне может быть затронута гонкой, так как мы не читаем его. - Прерывание в любом случае не будет прервано основным потоком.
Если COUNTER разделяется между несколькими обработчиками прерываний, которые могут вытеснять друг друга, то для каждого из них также может потребоваться критическая секция.
Этот подход решает нашу проблему, но мы по-прежнему пишем много небезопасного кода, о котором нужно тщательно рассуждать, и можем использовать критические секции без необходимости. Каждая критическая секция временно приостанавливает обработку прерываний, что увеличивает размер кода, задержку и джиттер прерываний (прерывания могут обрабатываться дольше, и время до их обработки становится более переменным). Является ли это проблемой, зависит от вашей системы, но в целом мы хотели бы этого избежать.
Важно отметить, что критическая секция гарантирует отсутствие срабатывания прерываний, но не обеспечивает гарантии эксклюзивности в многоядерных системах! Другое ядро может одновременно обращаться к той же памяти, даже без прерываний. Для многоядерных систем потребуются более мощные примитивы синхронизации.
Атомарный доступ
На некоторых платформах доступны специальные атомарные инструкции, которые обеспечивают гарантии для операций чтения-модификации-записи. В частности, для Cortex-M: thumbv6 (Cortex-M0, Cortex-M0+) поддерживает только атомарные инструкции загрузки и сохранения, тогда как thumbv7 (Cortex-M3 и выше) предоставляет полные инструкции Compare and Swap (CAS). Эти инструкции CAS являются альтернативой полному отключению прерываний: мы можем попытаться выполнить увеличение, которое в большинстве случаев успешно, но если оно прерывается, операция увеличения автоматически повторяется. Эти атомарные операции безопасны даже в многоядерных системах.
use core::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// Используем `fetch_add` для атомарного добавления 1 к COUNTER
COUNTER.fetch_add(1, Ordering::Relaxed);
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// Используем `store` для прямой записи 0 в COUNTER
COUNTER.store(0, Ordering::Relaxed);
}
Теперь COUNTER — это безопасная static переменная. Благодаря типу AtomicUsize переменная COUNTER может быть безопасно модифицирована как из обработчика прерываний, так и из основного потока без отключения прерываний. Использование Ordering::Relaxed минимизирует накладные расходы на синхронизацию, так как в данном случае не требуется строгая упорядоченность операций между потоками.
Доступ к периферийным устройствам
В дополнение к общим данным программы часто требуется разделять доступ к периферийным устройствам между основным потоком и обработчиками прерываний. Например, мы можем захотеть считывать входной сигнал с пина GPIO в основном цикле и управлять выходным сигналом с того же порта GPIO в обработчике прерываний.
Типичная проблема в embedded-программах заключается в том, что периферийные устройства являются синглтонами: существует только один экземпляр каждого периферийного устройства. В Rust это часто моделируется с помощью метода take(), который возвращает Option и позволяет захватить периферийное устройство в момент выполнения программы. Чтобы безопасно разделять доступ к такому синглтону, мы можем использовать Mutex и RefCell из библиотеки cortex_m для обеспечения синхронизированного доступа в критических секциях.
use core::cell::RefCell;
use cortex_m::interrupt::{self, Mutex};
use stm32f4::stm32f405;
static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
Mutex::new(RefCell::new(None));
#[entry]
fn main() -> ! {
// Получаем синглтоны периферийных устройств и настраиваем их.
// Этот пример использует крейт, сгенерированный svd2rust, но
// большинство крейтов для встраиваемых устройств будут похожими.
let dp = stm32f405::Peripherals::take().unwrap();
let gpioa = &dp.GPIOA;
// Некоторая функция настройки.
// Предположим, она устанавливает PA0 как вход и PA1 как выход.
configure_gpio(gpioa);
// Сохраняем GPIOA в мьютекс, перемещая его.
interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));
// Теперь мы больше не можем использовать `gpioa` или `dp.GPIOA`, и должны
// обращаться к нему через мьютекс.
// Будьте осторожны, включая прерывание только после настройки MY_GPIO:
// иначе прерывание может сработать, пока MY_GPIO содержит None,
// и в текущей реализации (с `unwrap()`) это вызовет панику.
set_timer_1hz();
let mut last_state = false;
loop {
// Теперь мы будем считывать состояние как цифровой вход через мьютекс
let state = interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().idr.read().idr0().bit_is_set()
});
if state && !last_state {
// Устанавливаем PA1 в высокий уровень, если обнаружен восходящий фронт на PA0.
interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// В прерывании мы просто сбрасываем PA0.
interrupt::free(|cs| {
// Мы можем использовать `unwrap()`, потому что знаем, что прерывание
// не было включено до установки MY_GPIO; в противном случае нужно
// обрабатывать возможность значения None.
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().clear_bit());
});
}
Это довольно объемный код, поэтому разберем ключевые моменты.
static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
Mutex::new(RefCell::new(None));
Наша общая переменная — это Mutex, содержащий RefCell, который в свою очередь содержит Option. Mutex обеспечивает доступ только в критической секции, что делает переменную безопасной для синхронизации (Sync), хотя обычный RefCell не является Sync. RefCell предоставляет внутреннюю изменяемость через ссылки, что необходимо для работы с GPIOA. Option позволяет инициализировать переменную пустым значением и позже переместить в нее фактическое значение. Мы не можем получить доступ к синглтону периферийного устройства статически, только во время выполнения, поэтому это необходимо.
interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));
Внутри критической секции мы вызываем borrow() на мьютексе, что дает нам ссылку на RefCell. Затем мы вызываем replace(), чтобы переместить новое значение в RefCell.
interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});
Наконец, мы используем MY_GPIO безопасно и параллельно. Критическая секция предотвращает срабатывание прерывания, как обычно, и позволяет нам заимствовать мьютекс. RefCell дает нам &Option<GPIOA> и отслеживает, как долго он остается заимствованным — после выхода ссылки из области видимости RefCell обновляется, указывая, что он больше не заимствован.
Поскольку мы не можем переместить GPIOA из &Option, нам нужно преобразовать его в &Option<&GPIOA> с помощью as_ref(), который мы затем можем unwrap(), чтобы получить &GPIOA, позволяющий модифицировать периферийное устройство.
Если требуется изменяемая ссылка на общий ресурс, следует использовать borrow_mut и deref_mut. Следующий код показывает пример с использованием таймера TIM2.
use core::cell::RefCell;
use core::ops::DerefMut;
use cortex_m::interrupt::{self, Mutex};
use cortex_m::asm::wfi;
use stm32f4::stm32f405;
static G_TIM: Mutex<RefCell<Option<Timer<stm32f4::stm32f405::TIM2>>>> =
Mutex::new(RefCell::new(None));
#[entry]
fn main() -> ! {
let mut cp = cortex_m::Peripherals::take().unwrap();
let dp = stm32f4::stm32f405::Peripherals::take().unwrap();
// Некоторая функция настройки таймера.
// Предположим, она настраивает таймер TIM2, его прерывание NVIC
// и запускает таймер.
let tim = configure_timer_interrupt(&mut cp, dp);
interrupt::free(|cs| {
G_TIM.borrow(cs).replace(Some(tim));
});
loop {
wfi();
}
}
#[interrupt]
fn timer() {
interrupt::free(|cs| {
if let Some(ref mut tim) = G_TIM.borrow(cs).borrow_mut().deref_mut() {
tim.start(1.hz());
}
});
}
Это безопасно, но немного громоздко. Есть ли другие варианты?
RTIC
Одной из альтернатив является фреймворк RTIC, сокращение от Real Time Interrupt-driven Concurrency (Параллелизм, управляемый прерываниями реального времени). Он обеспечивает статические приоритеты и отслеживает доступ к переменным static mut (называемым "ресурсами"), чтобы статически гарантировать безопасный доступ к общим ресурсам без необходимости постоянного входа в критические секции и использования подсчета ссылок (как в RefCell). Это дает ряд преимуществ, таких как отсутствие тупиков и чрезвычайно низкие затраты по времени и памяти.
Фреймворк также включает другие функции, такие как передача сообщений, что уменьшает необходимость в явном общем состоянии, и возможность планировать задачи для выполнения в определенное время, что можно использовать для реализации периодических задач. Подробности см. в документации.
Операционные системы реального времени
Еще одна распространенная модель для параллелизма во встраиваемых системах — операционные системы реального времени (RTOS). Хотя в Rust они пока менее исследованы, они широко используются в традиционной разработке для встраиваемых систем. Примеры с открытым исходным кодом включают FreeRTOS и ChibiOS. Эти RTOS предоставляют поддержку выполнения нескольких потоков приложения, между которыми процессор переключается, либо когда потоки уступают управление (кооперативная многозадачность), либо на основе регулярного таймера или прерываний (вытесняющая многозадачность). RTOS обычно предоставляют мьютексы и другие примитивы синхронизации и часто взаимодействуют с аппаратными функциями, такими как движки DMA.
На момент написания мало примеров RTOS на Rust, но это интересная область, так что следите за обновлениями!
Многоядерные системы
Наличие двух или более ядер в процессорах для встраиваемых систем становится все более распространенным, что добавляет дополнительный уровень сложности к параллелизму. Все примеры с использованием критических секций (включая cortex_m::interrupt::Mutex) предполагают, что единственный другой поток выполнения — это поток прерываний, но в многоядерной системе это уже не так. Вместо этого потребуются примитивы синхронизации, разработанные для многоядерных систем (также называемых SMP, симметричная многопроцессорность).
Они обычно используют атомарные инструкции, которые мы видели ранее, поскольку система обработки гарантирует сохранение атомарности на всех ядрах.
Подробное рассмотрение этих тем пока выходит за рамки этой книги, но общие шаблоны аналогичны случаю с одним ядром.