Rust для микроконтроллеров: что он даёт по сравнению с С

C остаётся основным языком разработки под микроконтроллеры. Под него есть библиотеки производителей, отладчики, примеры, документация и огромное количество уже написанного кода.
На этом фоне возникает вполне разумный вопрос: что вообще даёт Rust в микроконтроллерах?
Речь здесь не пойдёт о синтаксисе, настройке среды или запуске первого проекта. Вместо этого посмотрим на несколько вещей, которые интересны именно с точки зрения встраиваемой разработки:
владение аппаратными ресурсами;
работа с DMA;
представление состояния периферии в системе типов;
совместный доступ из основного кода и обработчиков прерываний;
стоимость этих механизмов во время выполнения.
Сразу оговоримся: Rust не является обязательной заменой C. Многие задачи прекрасно решаются на C. Интереснее другое: какие правила, которые в C обычно держатся на архитектуре программы и дисциплине разработчика, можно заставить проверять компилятор?

Rust без операционной системы
Rust можно использовать без стандартной библиотеки:
#![no_std]
Это позволяет писать программы для микроконтроллеров без операционной системы и без обязательного динамического выделения памяти. После компиляции получается обычный машинный код для целевого процессора.
Поэтому Rust в данном случае логичнее сравнивать с C и C++, а не с MicroPython и другими языками, которым требуется интерпретатор или виртуальная машина. Типичная программа для Cortex-M по-прежнему может содержать:
таблицу векторов прерываний;
обработчики прерываний;
непосредственную работу с регистрами;
статически размещённые данные;
DMA;
точный контроль над памятью.
Сам выбор Rust не добавляет обязательного промежуточного слоя между программой и микроконтроллером. Основные различия появляются раньше — во время компиляции.
Где C и так отлично справляется
Для начала полезно посмотреть на задачи, где преимущества Rust почти не проявляются. Например:
АЦП -> цифровой фильтр -> регулятор -> ШИМ
Если программа небольшая, данные размещены статически, а у периферии есть очевидные владельцы, C позволяет получить компактное и вполне понятное решение. То же самое с обычными вычислениями:
for (size_t i = 0; i < N; i++) {
y[i] = a * x[i] + b * y[i];
}
Аналогичный код на Rust:
for i in 0..N {
y[i] = a * x[i] + b * y[i];
}
Сам по себе другой язык не делает этот цикл принципиально лучше. В обоих случаях задача компилятора одна: получить эффективный машинный код. Поэтому основное различие стоит искать не в арифметике, а в организации доступа к ресурсам.
DMA: кому сейчас принадлежит буфер?
Рассмотрим обычную передачу данных через DMA. На C можно объявить буфер:
uint16_t adc_buffer[1024];
и передать его DMA:
HAL_ADC_Start_DMA(
&hadc1,
(uint32_t *)adc_buffer,
1024
);
После вызова HAL_ADC_Start_DMA() переменная adc_buffer никуда не исчезает. Код по-прежнему может сделать:
adc_buffer[0] = 123;
в тот момент, когда DMA записывает данные в тот же участок памяти. Разумеется, в нормально написанной программе разработчик знает, что так делать нельзя, и организует доступ соответствующим образом.
Но само правило:
Пока идёт передача, буфер принадлежит DMA.
в C является не свойством языка, а договорённостью внутри программы.
Передаём владение
В Rust интерфейс DMA можно построить иначе:
let transfer = dma.start(buffer);
start() получает буфер во владение. После этого исходную переменную уже нельзя использовать:
let transfer = dma.start(buffer);
buffer[0] = 123;
Такой код компилятор может отвергнуть. После завершения передачи буфер возвращается программе:
let buffer = transfer.wait();

Аппаратный факт — «этот участок памяти сейчас используется DMA» — оказывается отражён в структуре программы. Конкретные библиотеки реализуют DMA по-разному, но здесь важен сам принцип: право доступа к памяти можно связать со временем жизни аппаратной операции.
Периферия тоже ресурс
То же самое можно сделать с периферийными блоками. На уровне железа TIM1, ADC1 или USART2 — это в первую очередь наборы регистров, отображённых в адресное пространство.
На C запись в таймер может выглядеть так:
TIM1->CCR1 = value;
Язык при этом ничего не знает о том, что TIM1, например, должен использоваться только драйвером двигателя. Если другой модуль получит доступ к тем же определениям регистров, он тоже сможет туда записать.
Обычно это решается архитектурой программы:
TIM1 → управление двигателем
ADC1 → измерение тока
USART2 → обмен данными
Rust позволяет часть этой схемы выразить через владение объектами. Во многих библиотеках сначала получается набор доступной периферии:
let p = Peripherals::take().unwrap();
После чего отдельные блоки передаются своим владельцам:
let motor = Motor::new(p.TIM1);
let current_sensor = CurrentSensor::new(p.ADC1);
let uart = SerialPort::new(p.USART2);

После перемещения p.TIM1 в Motor тот же объект нельзя передать ещё куда-нибудь:
let motor1 = Motor::new(p.TIM1);
let motor2 = Motor::new(p.TIM1);
В безопасном Rust такой код недопустим. То есть правило:
TIM1 имеет одного владельца.
может поддерживаться уже не только архитектурой проекта, но и компилятором.
На маленькой программе это иногда выглядит как лишнее ограничение. Но когда появляются несколько таймеров, DMA, прерывания и десяток модулей, возможность явно закрепить владение ресурсами становится заметно интереснее.
Состояние периферии как часть типа
Ещё один пример — обычный GPIO. Физически одна ножка может работать в разных режимах:
Вход
Выход
Аналоговый вход
Альтернативная функция
Реальное состояние хранится в регистрах GPIO, но часть этой информации можно дополнительно выразить в типе объекта. Например:
Pin<Input>
и:
Pin<Output>
После переключения режима:
let pin = pin.into_output();
получается уже объект другого типа. Функция, которая устанавливает высокий уровень, может принимать только выход:
fn set_high(pin: &mut Pin<Output>) {
// запись в регистр
}
А такой код:
let pin: Pin<Input> = /* ... */;
set_high(&mut pin);
не скомпилируется.
На C аналогичное правило обычно существует в виде правильной последовательности действий:
GPIO_Init(...);
/* Теперь вывод настроен как выход */
HAL_GPIO_WritePin(...);
То есть часть информации, которая в C живёт в состоянии регистров, комментариях и голове разработчика, можно перенести в тип переменной.
Когда тип описывает состояние устройства
Тот же приём можно применять уже не только к периферии, но и к состояниям устройства. Допустим, привод имеет четыре состояния:
Выключен
Инициализация
Работа
Авария
Один из вариантов на C — перечисление и несколько дополнительных признаков:
enum State {
STATE_OFF,
STATE_STARTING,
STATE_RUNNING,
STATE_FAULT
};
bool pwm_enabled;
bool adc_ready;
bool calibrated;
Такая структура данных сама по себе допускает состояние:
state = STATE_RUNNING
pwm_enabled = false
adc_ready = false
calibrated = false
В корректной программе оно, возможно, никогда не появится, но представить его можно. Другой вариант — связать нужные ресурсы непосредственно с состоянием:
enum Drive {
Off,
Starting {
calibration: Calibration,
},
Running {
pwm: Pwm,
adc: CurrentSensor,
},
Fault {
code: FaultCode,
},
}
Теперь состояние Running само содержит ресурсы, необходимые для работы. Отдельные признаки вроде:
pwm_enabled
adc_ready
могут просто не понадобиться.
Это не делает алгоритм управления автоматически правильным: компилятор не знает, правильно ли рассчитаны коэффициенты регулятора и допустим ли ток двигателя. Но часть бессмысленных комбинаций состояния можно исключить ещё до запуска программы.
Прерывания и общие данные
Одна из классических задач в микроконтроллере — обмен данными между основным кодом и обработчиком прерывания. Простейший вариант на C:
volatile uint32_t value;
volatile bool ready;
Обработчик меняет значения:
void ADC_IRQHandler(void)
{
value = ADC1->DR;
ready = true;
}
А основной код их читает:
if (ready) {
process(value);
ready = false;
}
volatile в таких программах часто нужен, чтобы компилятор действительно выполнял необходимые обращения к памяти, но сам по себе volatile не решает синхронизацию. По мере роста программы появляются вопросы:
атомарна ли операция;
может ли обработчик сработать между двумя обращениями;
кто вообще имеет право менять объект;
нужно ли временно запрещать прерывания;
использует ли ту же память DMA.
В безопасном Rust нельзя просто раздать изменяемый доступ к одному объекту нескольким независимым участкам программы. Нужно явно определить механизм совместного доступа: атомарную операцию, критический участок, блокировку, передачу сообщений или другую подходящую схему.
Rust не выбирает механизм синхронизации за разработчика. Он требует явно показать, почему этот совместный доступ считается корректным.
А как же unsafe?
При прямой работе с аппаратурой полностью избежать операций, корректность которых компилятор проверить не способен, невозможно. Например:
обращение по известному адресу регистра;
работа с необработанным указателем;
часть низкоуровневых операций DMA;
начальная настройка памяти.
Для этого в Rust существует unsafe. Например:
unsafe {
core::ptr::write_volatile(address, value);
}
Важно, что unsafe не отключает проверки языка для всей программы. Он означает, что корректность конкретной операции разработчик подтверждает сам.
Именно поэтому имеет смысл ограничивать область применения unsafe.
Вместо того чтобы размазывать работу с указателями и регистрами по всей программе, потенциально опасную часть можно сосредоточить внутри драйвера, а остальной код оставить проверяемым.
Сколько всё это стоит?
После всех этих типов возникает очевидный для микроконтроллерщика вопрос: сколько дополнительной памяти и процессорного времени они требуют? Не обязательно хоть сколько-нибудь.
Возьмём упрощённый пример:
struct Input;
struct Output;
struct Pin<State> {
register: *mut u32,
_state: PhantomData<State>,
}
Input и Output здесь нужны только системе типов и не содержат данных. PhantomData<State> тоже не требует хранить отдельное поле состояния во время выполнения.
Поэтому различие между:
Pin<Input>
и:
Pin<Output>
может существовать только для компилятора. В памяти объект по-прежнему содержит только то, что действительно необходимо для доступа к железу.
Проверка:
можно ли вызвать set_high() для этого объекта?
происходит при компиляции. На самом микроконтроллере проверять это уже не нужно.
А что получится в машинном коде?
В микроконтроллерах это, пожалуй, важнее количества конструкций в исходном тексте. Например:
fn set_high(pin: &mut Pin<Output>) {
unsafe {
core::ptr::write_volatile(pin.bsrr, 1 << pin.number);
}
}
Если после подстановки функций и оптимизации всё это превращается в обычную запись по адресу регистра, более строгий интерфейс сам по себе не создаёт затрат во время выполнения. Поэтому сравнивать имеет смысл не только исходный код, но и результат компиляции.
Rust не обещает, что абсолютно любая абстракция всегда будет бесплатной. Но владение, времена жизни и многие признаки типов существуют главным образом для компилятора и не требуют отдельной работы во время исполнения программы.
Чего Rust всё-таки не знает
Из всего сказанного легко сделать слишком сильный вывод: будто Rust превращает прошивку в формально проверенную систему. Нет.
Компилятор не обнаружит:
неверную частоту ШИМ;
ошибку в коэффициентах регулятора;
неправильную последовательность включения силовой части;
ошибку протокола;
выход физической величины за допустимый диапазон.
Если написать:
let duty = 100;
а железо допускает максимум 80, язык ничего об этом не узнает, пока ограничение явно не появится в программе.
Rust прежде всего проверяет свойства самого кода:
правила владения;
время жизни ссылок;
допустимость операций над типами;
часть ошибок совместного доступа к памяти.
Модель физического устройства, алгоритмы и ограничения железа по-прежнему остаются задачей разработчика.
Где за это приходится платить
Порог входа
Чтобы нормально пользоваться всеми перечисленными механизмами, придётся разобраться хотя бы с:
владением;
заимствованием;
временем жизни ссылок;
обобщёнными типами;
особенностями
no_std.
Код, который на C выглядит очевидно, иногда приходится заметно перестраивать под требования компилятора. Особенно в начале это легко воспринимать как борьбу с языком.
Производители всё ещё живут в мире C
Для большинства микроконтроллеров производитель в первую очередь предоставляет:
библиотеки на C;
примеры на C;
средства генерации начального кода;
промежуточное программное обеспечение на C;
документацию с примерами на C.
При использовании Rust приходится либо брать библиотеки сообщества, либо работать ближе к регистрам, либо связывать Rust с существующим кодом на C. У популярных семейств ситуация заметно лучше, чем у редких контроллеров и специализированной периферии.
Иногда всё это просто не нужно
Если прошивка выглядит примерно так:
while (1) {
read_adc();
update_controller();
set_pwm();
}
и на этом практически всё заканчивается, строгая модель владения может дать меньше пользы, чем дополнительной сложности.
Но картина меняется с ростом системы. Чем больше в ней:
периферийных блоков;
DMA;
обработчиков прерываний;
состояний;
каналов обмена;
совместно используемых данных,
тем интереснее становится возможность заставить компилятор следить за частью архитектурных правил.
Итог
Главное отличие Rust в микроконтроллерах не в том, что он каким-то другим способом складывает числа или записывает значения в регистры. Разница в том, где проверяются правила программы.
На C правило:
Во время передачи буфер принадлежит DMA.
обычно поддерживает разработчик. На Rust его можно выразить через владение.
Правило:
TIM1 используется только драйвером двигателя.
можно выразить передачей периферийного блока одному владельцу.
А правило:
set_high() допустим только для выхода.
— типом Pin<Output>.
На C все эти схемы тоже прекрасно реализуются. Просто значительная часть ограничений существует в архитектуре проекта, документации и голове программиста. Rust позволяет часть этих ограничений превратить в то, что проверяет компилятор.
На мой взгляд, именно в этом и находится его главный интерес для встраиваемых систем.
Материалы
При подготовке статьи использовались:
The Embedded Rust Book — разделы Static Guarantees и Zero Cost Abstractions.
Jiří Klimeš — Coming Back to Embedded with Rust.
SiliconWit — UART, DMA, and Ownership.
Документация проекта Rust Embedded.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.