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

image


Аппаратное обеспечение


В качестве образца воспользуемся в этом посте процессором STM32F411RE. К счастью, в сообществе Rust обеспечивается поддержка для великого множества процессоров. Чтобы проверить, поддерживается ли конкретная модель, убедитесь, что выполняются следующие условия:
  • Архитектура данного процессора поддерживается компилятором Rust. Чтобы проверить, так ли это, попробуйте найти вашу архитектуру в этом списке.
  • Хотя, с чисто технической точки зрения, для выполнения вашего кода будет достаточно, просто чтобы поддерживалась ваша архитектура, вы, пожалуй, не откажетесь от нескольких абстракций поверх голых регистров. В данном случае стоит для начала поискать крейт, сгенерированный на основе предоставляемых производителем SVD-файлов. Например, такой.
  • Чтобы работать с процессором было ещё проще, воспользуйтесь реализацией инструментария embedded hal, который сильно снижает издержки. Для работы с процессорами, рассматриваемыми в данной статье, вам понадобится этот крейт.
  • Если вы хотите продвинуться ещё на шаг дальше, то можете поискать крейт, который поддерживает именно вашу плату, либо сами написать такой крейт. Он будет абстрагировать для конкретной платы такие вещи, как отдельные кнопки и светодиодные индикаторы. В качестве образца, который потребует лишь минимальной настройки, подойдёт крейт для платы STM32F411RE-NUCLEO, показанной выше.

Чтобы удостовериться, что с вашим аппаратным обеспечением всё нормально, и не тратить впустую время на поиск ошибок в настроенной вами конфигурации Rust, убедитесь, что программа от поставщика работает. Можете даже записать на чип предоставленную производителем прошивку и конфигурацию инструментария. Так вы сможете убедиться, что плата корректно распознаётся вашей операционной системой.

Инструментарий Rust


Если вы работаете под Linux или OSX, настройка не составит труда. О том, как она делается, рассказано ниже. Если вы работаете под Windows — что ж, удачи. Вас ждут сюрпризы. Вам потребуется установить драйверы ST и некоторые компоненты набора сборочных инструментов VS BuildTools, но не доверяйте туториалам (в том числе, тем, которые я размещал онлайн). Все они отчасти неверны.

Под Linux и OSX можно добиться желаемого, просто обустроив работающую конфигурацию Rust при помощи rustup. Если вы собираетесь пользоваться gdb в качестве отладчика, попробуйте установить gdb-multiarch на Linux или armmbed/formulae/arm-none-eabi-gcc на Mac.
При работе с любыми ОС необходимо установить правильную целевую платформу, а также озаботиться некоторыми инструментами, которые можно установить при помощи cargo:

rustup update
rustup component add llvm-tools-preview
rustup target add thumbv7em-none-eabihf
cargo install cargo-binutils cargo-embed cargo-flash cargo-expand

Убедитесь, что у вас действительно установлена новейшая версия Rust, заранее запустив rustup update. В противном случае, возможно, ваш компьютер скомпилирует некоторые вещи дважды.

Благодаря probe.rs (устанавливается при помощи cargo-embed, как показано выше), мы получаем практически всё, что нам нужно, чтобы приступать к написанию кода.

Настройка проекта


В репозитории к этой статье есть пример проекта, в который можно войти при помощи cd и запустить его, чтобы проверить, работает ли собранная вами конфигурация. Для этого просто выполните cargo embed и наблюдайте, как эта команда творит магию. Вы должны заметить, как начинает мигать watch LED-индикатор, а в терминале выводится Hello, world! от контроллера и растёт последовательность цифр.

Давайте быстро пройдёмся по компонентам, используемым в этом проекте, чтобы вы научились легко создавать собственные проекты, не копируя шаблон. В конце концов, нам придётся рассмотреть и сам код из файла main.rs, так как это самое интересное.

В файле memory.x описана та компоновка памяти, которую мы хотим предусмотреть на нашем чипе. В этот файл нужно заглянуть один раз – и можно забыть о нём до тех пор, пока не захочется сменить аппаратную платформу.

В файле Cargo.toml содержится несколько зависимостей, все они нужны для того, чтобы код работал. Несмотря на то, что некоторые из зависимостей специфичны для конкретной платформы, нам поможет крейт rtt-target, обеспечивающий простую коммуникацию между хостом и контроллером по механизму RTT. Именно он отвечает за вывод hello world, который мы видели выше. Ещё есть крейт panic-halt, где определён обработчик паник, которые могут возникать в нашем коде. При возникновении паники он просто останавливает процессор. Ни шатко, ни валко. В наличии есть и другие обработчики паник, но они требуют небольшой дополнительной настройки.

В платформо-специфичных крейтах заключена вся та магия, которой мы собираемся пользоваться – от реализации уровня HAL до скрипта-линковщика. Поскольку данная тема довольно непростая, в этой статье не будем на ней останавливаться.

Код


Возьмём за образец код, выложенный здесь. Шаблонную часть кода пока полностью игнорируем.

#![no_main]
#![no_std]
// ... импорты ...
#[entry]
fn main() -> ! {

    // ... шаблонный код ...

    let gpioa = device.GPIOA.split();

    let mut led = gpioa.pa5.into_push_pull_output();
    let mut delay = Delay::new(core.SYST, &clocks);
    
    rprintln!("Hello, world!"); 

    let mut ctr = 0;

    loop {
        rprintln!("ctr: {}",ctr); 
        ctr+=1;
        led.toggle();
        delay.delay_ms(200u16);
    }
}

При помощи аннотаций мы сообщаем Rust, что у нас нет главной функции (на самом деле, она у нас есть, но об этом мы поговорим попозже), а также что мы не собираемся использовать стандартную библиотеку. Поэтому данный режим называется no_std, в нём предлагается ограниченный функционал. Чтобы было понятнее, почему мы отказываемся работать с std , достаточно задуматься о потоках. Операционная система отвечает за создание, изоляцию и пуск потоков. На контроллере нет операционной системы. Поэтому, чтобы заставить std, потребуется предоставить реализацию для всего такого функционала.

Аннотация #[entry] означает, что данная функция служит входной точкой для нашего кода. Мы могли бы назвать эту функцию как пожелаем, макрос сам установит её в правильную позицию в памяти контроллера. Именно поэтому выше мы написали #[no_main].

Дальше идёт немного стереотипного кода, а затем мы используем GPIO-порт A и распределяем код по отдельным контактам. Выбираем тот, к которому подключён LED, и соответствующим образом его называем. Далее нужно выставить задержку, ориентируясь на показания системных часов. Страшно неэффективно, зато просто.

Макрос rprintln! – это импровизированная замена println!. Он использует RTT, поэтому позволяет печатать прямо в оболочке хоста. В цикле мы снова им воспользуемся, чтобы аккуратно отформатировать и вывести текущее значение счётчика. Далее цикл увеличивает значение счётчика на единицу, переключает состояние контакта, отвечающего за светодиод, и выжидает несколько миллисекунд.

image

Безопасность


Обратите внимание: во всех приведённых выше ситуациях не используется никакого небезопасного кода. Весь небезопасный код содержится в библиотеках и (остаётся надеяться) хорошо протестирован. Это одна из самых сильных сторон Rust: даже если вы решите воспользоваться no_std, характеристики, имеющие отношение к безопасности (например, система владения) всё равно останутся активны. То же касается компонентов, облегчающих взаимодействие с системой владения, например, типа Option или RefCell.

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

Непростая ситуация


Раз за разом в различных блогах всплывают вопросы, а не слишком ли сложен Rust для конкретной ситуации – и эти вопросы остаются без ответов. Этот пост – не исключение. Рассмотрим такую непростую ситуацию: что делать, если в контроллере происходит прерывание?

Объясню, как может сложиться такой сценарий. Вы нажимаете на плате кнопку (ту, которая обозначена зелёным на рисунке в начале этого поста) – и световой индикатор должен инвертироваться. Как только нажата кнопка, срабатывает прерывание, и наш контроллер отрывается от задачи, которую в данный момент обрабатывал – при этом предполагается, что прерывание вообще разрешено и активировано. Это нужно, чтобы выполнить обработчик прерывания (или ISR). В нашем случае обработчик прерывания – это простая функция. И вот у нас возникает некоторая проблема. Приходится разделять состояние между главным кодом и обработчиком прерывания. Это нужно делать либо на самом LED, код которого создавался и конфигурировался в главной функции, либо предусматривать некоторое сменное состояние вида вкл/выкл. Такое состояние индикатора модифицируется в ISR и может циклически использоваться в главной функции, переключая LED. Если вы готовы углубиться в код – смотрите, здесь показан именно такой подход, а также несколько других.

Здесь я использую «запрещённый приём»: разделяю изменяемое состояние. В Rust мы стараемся максимально избегать такого, но иногда без подобных приёмов не обойтись. В таких случаях нам на выбор предлагаются мьютексы, RefCell и/или каналы.

Но подождите, а нужен ли нам вообще мьютекс, если мы всё равно работаем только на одном ядре? Более того, если на этом ядре выполняется всего один процесс? К сожалению, нужен. Представьте, что вы записываете что-либо в разделяемый участок памяти, и тут вдруг происходит прерывание, поэтому запись прерывается, и далее могут быть считаны лишь неполные данные. С той же проблемой мы столкнулись бы и на одноядерном процессоре, на котором применяется планирование с вытеснением!

К счастью, в крейте coretx_m предоставляется специальная реализация мьютекса. Вот как работает такой мьютекс: он требует от нас ссылку на структуру CriticalSection, которую можно (безопасно) получить лишь при условии, что код выполняется в замыкании, которое работает в контексте без прерываний. Таким образом, мы можем убедиться, что пока мьютекс разблокирован, никакое прерывание в работу вмешаться не может.

С точки зрения безопасность такая возможность просто бесценна. Rust не полагается ни на программиста, которому нужно «не забыть» что-то сделать, ни на чистую удачу, а вынуждает нас использовать эти примитивы. Соответствующий фрагмент кода показан ниже:

// ...
static G_LED: Mutex<RefCell<Option<LedPin>>> = Mutex::new(RefCell::new(None));


#[entry]
fn main() -> ! {
    // ...
    let mut led = setup.gpioa.pa5.into_push_pull_output();
    cortex_m::interrupt::free(|cs| {
        *G_LED.borrow(cs).borrow_mut() = Some(led);
    });
    unsafe {
        cortex_m::peripheral::NVIC::unmask(pac::Interrupt::EXTI15_10);
    }    

    loop {
        wfi(); // ничего не делаем
    }
}

#[interrupt]
fn EXTI15_10() {
    cortex_m::interrupt::free(|cs| {
        G_LED
            .borrow(cs)
            .borrow_mut()
            .as_mut()
            .unwrap()
            .toggle();
    });  
}

Обработчик прерываний в EXTI15_10 использует достаточно сложный синтаксис. Здесь можно немного сократить код, воспользовавшись макросом. Поскольку LED больше ни разу нигде использоваться не будет, о таком коде будет проще судить, если воспользоваться макросом #[interrupt]. Он позволяет определять глобальные статические переменные с «заданной областью видимости», и такие переменные можно безопасно использовать в ISR. Таким образом, можно переместить LED из G_LED в «локальную глобальную статическую» область. Это показано здесь. Обратите внимание: так можно работать и в контексте, допускающем прерывания. Переменная будет безопасно использоваться. Поэтому такой подход лучше всего сработает с состоянием, которое «переносится» всего один раз от главной функции к обработчику прерывания.

Кстати, прежде, чем отказаться от Rust при программировании встраиваемых систем, вспомните, что здесь можно использовать атомарность, способствующую чистоте кода. Есть такие фреймворки как RTIC, выполняющие за вас многие из таких вещей за кулисами, поэтому код у вас не будет переусложнённым. Это уже тема для другого поста.

Заключение


Rust отлично подходит для разработки встраиваемых систем. Он обеспечивает такие же гарантии безопасности, к каким привыкли программисты-системщики, но при этом в самом деле позволяет вам писать код для всевозможных устройств. Притом, что такой код может быстро усложниться, если вам придётся работать с изменяемым разделяемым состоянием, вы можете (скорее, вам придётся) делать это безопасным и разумным образом. Чтобы код не слишком усложнялся, пользуйтесь инкапсуляцией, макросами или такими фреймворками как RTIC.


P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.

Комментарии (6)


  1. tttinnny
    02.08.2024 18:03
    +1

    Радует, что вводное упомянула ртт, но все же ртик не только про синхронизацию, ссылка на который, к слову, невалидна, а так норм


  1. lazbaphilipp
    02.08.2024 18:03

    Забавно - в Rust, выходит, на уровне языка реализованы многие функции RTOS.


  1. zolandv
    02.08.2024 18:03
    +1

    А что такое "крейт" На обычном русском? Или это сленг для дистанцирования "посвященных"? И жаль, что ссылка на список архитектур не работает. А так информация хорошая, особенно про рассуждения про прерывания


    1. Amareis
      02.08.2024 18:03

      Крейт ‐ это либа.


  1. QuarkDoe
    02.08.2024 18:03

    Не смотрел в сторону асинхронной embassy?


  1. UUSR
    02.08.2024 18:03

    Очень актуально ,в Rust безопасная работа с памятью гарантируется за счет механизма «владения». Оплошности, допускаемые программистами на C при работе с памятью, могут приводить к возникновению массы ошибок и потенциальных уязвимостей, выявление которых является крайне непростой задачей даже при использовании специализированных инструментов анализа кода.