Демистификация методов Split и Constrain встроенного HAL в Rust


Введение

Интересно отметить, что когда я взялся за изучение встроенного Rust на уровне HAL, я обнаружил, что это более сложная задача, чем разработка с помощью PAC. Для меня PAC был в некотором роде простым. На уровне PAC я точно знал, что мне нужно делать на уровне регистров. С помощью предоставленного API на уровне PAC мне нужно было только читать и записывать в регистры, основываясь на описаниях справочного руководства. Я также мог бы создать собственные драйверы для различных периферийных устройств, хотя и не обязательно придерживаясь встроенных стандартов HAL. Я думаю, что сложность заключалась в двух основных причинах. Первая — это навигация по документации, а вторая — методы, специфичные для встраиваемой модели Rust.

Даже если начинать с простых примеров, два метода, с которыми у меня возникли трудности, и на понимание которых у меня ушло некоторое время, были split() и constrain(). Из различных ресурсов, в которых я копался, я не нашел достаточных объяснений того, что делает тот или иной метод. Документация также была довольно расплывчатой во многих случаях и мало что объясняла. Кроме того, у меня остались вопросы, такие как:

Каковы различия между split() и constrain()?

На каких периферийных устройствах я должен использовать тот или иной метод?

Как я узнаю, что мне нужно использовать тот или иной метод?

Эти вопросы — те, которые фактически помешали мне использовать HAL некоторое время, хотя технически HAL должен быть более абстрактным и простым в использовании. Это было в дополнение к привыканию к навигации по документации, которая, казалось, временами зацикливалась. Мне не нравилось ощущение, что я делаю что-то непонятное, поэтому я потратил некоторое время на то, чтобы разобраться в теме. Кроме того, я практиковался в выполнении одних и тех же действий на разных HAL, чтобы проверить, удалось ли мне правильно прочитать документацию. С недавних пор я считаю, что нашел ответы на все вышеупомянутые вопросы. Поэтому в этой заметке я собираюсь пролить больше света, надеясь дать полезные ответы новичкам во встроенном Rust.

Абстракции встроенного Rust

Перед тем, как углубиться в тему, будет полезно быстро вернуться к абстракциям встроенного Rust. Во встроенном Rust существует несколько уровней абстракции, которые вводятся поверх аппаратного обеспечения микроконтроллера, как показано на рисунке. Первый уровень — это крейт периферийного доступа (PAC), который дает нам доступ к низкоуровневым регистрам микроконтроллера на уровне битов. Стоит также отметить, что PAC специфичен для конкретной серии микроконтроллеров, например, ST Microelectronics stm32f4xx или Texas Instruments tm4c123x. Крейт микроархитектуры находится на том же уровне абстракции, что и PAC, но специфичен для функций ядра процессора (например, ARM Cortex-M).

Поднимаясь еще на один уровень вверх по цепочке, мы получаем крейт уровня аппаратной абстракции (HAL). HAL crates призваны обеспечить большую переносимость и удобство API для конкретного процессора. Это происходит за счет реализации некоторых общих черт, определенных в так называемом встроенном HAL. Кроме того, HAL пытается включить механизмы, или обертки вокруг функций нижнего уровня, которые являются частью модели безопасности Rust.

Примечание: существуют также крейты уровня платы, которые располагаются поверх HAL, но они опущены на рисунке, так как не нужны для нашего обсуждения.

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

Пересмотр Blinky

Я решил, что для лучшего объяснения рассматриваемых концепций будет полезно вернуться к простой программе Blinky на уровне HAL. Итак, ниже приведен код главной функции программы blinky, предназначенной для платы Nucleo-F401RE и использующей HAL stm32f4xx.

fn main() -> ! {

        // Create handles for the device and core peripherals
        let dp = pac::Peripherals::take().unwrap();
        let cp = cortex_m::peripheral::Peripherals::take().unwrap();

        // Create an the LED abstraction. On the Nucleo-401RE it's connected to pin PA5.
        let gpioa = dp.GPIOA.split();
        let mut led = gpioa.pa5.into_push_pull_output();

        // Set up the system clock. We want to run at 48MHz for this one.
        let rcc = dp.RCC.constrain();
        let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();

        // Create a delay abstraction based on SysTick
       let mut delay = cp.SYST.delay(&clocks);

        loop {
            // On for 1s, off for 1s.
            led.set_high();
            delay.delay_us(1000_u32);
            led.set_low();
            delay.delay_us(1000_u32);

    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Рассматривая приведенную выше программу, мы обнаруживаем, что общая анатомия программы состоит из следующих шагов:

  1. Получение доступа к устройству и/или периферийным устройствам ядра
  2. Конфигурирование периферийного(ых) устройства(ов)
  3. Настроить системную/основную функцию(и)
  4. Использовать периферийное(ые) устройство(а)

Далее мы проанализируем код для отдельных частей, чтобы получить большее представление об интересующих нас областях.

1. Получение доступа к периферийным устройствам устройства и/или ядра

Получение доступа к периферийным устройствам осуществляется в следующих двух строках:

let dp = pac::Peripherals::take().unwrap();
let cp = cortex_m::peripheral::Peripherals::take().unwrap();
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы создаем дескрипторы для периферийных устройств и ядра. По сути, мы переносим периферийные структуры из PAC и ядра в текущий контекст выполнения с помощью метода take(). Все это является частью модели безопасности встроенного Rust для обеспечения того, что создается только один экземпляр Peripherals. Встроенный Rust следует шаблону проектирования singleton, который ограничивает инстанцирование класса одним объектом. Поэтому метод take() возвращает Option struct (объясняет использование unwrap()), а при первом вызове метода на Peripherals struct он возвращает Some. При последующих вызовах take(), вместо него возвращается None. Это гарантирует, что в программе есть только один экземпляр Peripherals и что он существует только в текущем контексте выполнения. Это также означает, что Peripherals не разделяется глобально между потоками. Тем не менее, Peripherals является «Sendable» для других контекстов выполнения, но это отдельная тема для другого раза.

2. Настроить периферийное устройство (устройства)

Периферийное устройство, которое мы будем использовать в нашей мигающей программе, — это GPIO. PA5 (контакт 5 в порту A) на плате Nucleo-401RE подключен к встроенному в плату светодиоду. Поэтому мы хотим сконфигурировать вывод PA5 на плате Nucleo-401RE как push-pull выход, чтобы в дальнейшем мы могли управлять его уровнем. Для этого используются следующие две строки:

let gpioa = dp.GPIOA.split();
let mut led = gpioa.pa5.into_push_pull_output();
Войти в полноэкранный режим Выход из полноэкранного режима

Без необходимости обращаться к документации я всегда находил метод into_push_pull_output() самообъясняющимся. Это метод, который преобразует вывод в push-pull выход, что является одной из конфигураций, доступных для вывода, как описано в справочном руководстве. Теперь, что меня всегда удивляло, так это то, зачем нам нужна строка let gpioa = dp.GPIOA.split();? Почему мы не можем сделать что-то вроде let led = dp.GPIOA.pa5.into_push_pull_output(); сразу?! Это имело бы больше смысла. На самом деле, я пытался сделать что-то подобное в какой-то момент и получил такой вывод компилятора:

error[E0609]: no field `pa5` on type `GPIOA`
  --> src/main.rs:58:32
   |
58 |         let mut led = dp.GPIOA.pa5.into_push_pull_output();
   |                                ^^^ unknown field
Войти в полноэкранный режим Выход из полноэкранного режима

Так что компилятор не распознал pa5 в GPIOA, в результате я посмотрел документацию для split() и нашел следующее описание:

Свойство расширения для разделения периферийного устройства GPIO на независимые пины и регистры

со следующей сигнатурой:

fn split(self) -> Self::Parts;
Вход в полноэкранный режим Выход из полноэкранного режима

Таким образом, метод split() возвращает структуру Parts. Кроме того, копаясь дальше в документации по STM32F4xx HAL GPIO, в модуле gpioa я нашел структуру Parts, которая выглядит следующим образом:

pub struct Parts {
    pub pa0: PA0,
    pub pa1: PA1,
    pub pa2: PA2,
    pub pa3: PA3,
    pub pa4: PA4,
    pub pa5: PA5,
    pub pa6: PA6,
    pub pa7: PA7,
    pub pa8: PA8,
    pub pa9: PA9,
    pub pa10: PA10,
    pub pa11: PA11,
    pub pa12: PA12,
    pub pa13: PA13<Debugger>,
    pub pa14: PA14<Debugger>,
    pub pa15: PA15<Debugger>,
}
Вход в полноэкранный режим Выход из полноэкранного режима

Изучая члены структуры Parts, как может быть очевидно, каждый из членов соответствует пину в порту A. Далее, из документации я узнал, что каждый член является общим типом Pin. Для типа Pin я также нашел в документации все связанные с Pin методы, включая into_push_pull_output(), которые мы уже видели, в дополнение к методам set_high() и set_low(), которые мы будем использовать позже, чтобы установить и очистить вывод пина, соответственно.

Итак, что все это значит? Методы типа into_push_pull_output() применяются к типу Pin, который является частью структуры Parts, которая еще не является частью модуля PAC-уровня GPIOA. Оказывается, что структура Parts не существует с самого начала, просто импортируя HAL. Это решается с помощью метода split() на уровне PAC модуля GPIOA. Это означает, что вы должны вручную «продвинуть» модуль уровня PAC в структуру Parts уровня HAL, вызвав метод split(). Это можно прочитать так: мы «разбиваем» структуру уровня PAC на отдельные части, делая структуру Parts доступной для использования с абстракциями/методами HAL. Как мы видели ранее, в случае GPIO, структура Parts представляет собой коллекцию отдельных пинов в порту, где каждый из них имеет тип Pin, который имеет коллекцию методов, применимых к HAL.

3. Конфигурирование системы/основной функции (функций)

Поскольку мы хотим ввести задержку, нам необходим некий таймер, в качестве которого мы будем использовать периферийный таймер ядра SysTick. В результате, чтобы использовать таймер SysTick, мы должны настроить системные часы через регистры сброса и управления часами (RCC). Давайте сначала начнем с конфигурирования часов через RCC. Мы хотим, чтобы системный тактовый генератор работал на частоте 48 МГц. Это достигается следующими двумя строками:

let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
Войти в полноэкранный режим Выйти из полноэкранного режима

Глядя на первую строку, мы видим, что метод constrain() применяется к RCC. Заглянув в документацию, мы найдем следующее расплывчатое описание constrain():

Ограничивает периферию RCC, чтобы она хорошо сочеталась с другими абстракциями.

Кроме того, сигнатура метода constrain выглядит примерно так:

fn constrain(self) -> Rcc;
Войти в полноэкранный режим Выйти из полноэкранного режима

где Rcc struct был определен как «constrained RCC peripheral» и имеет следующее определение:

pub struct Rcc {
    pub cfgr: CFGR,
}
Вход в полноэкранный режим Выход из полноэкранного режима

Немного покопавшись, я нашел ответ в блоге Embedded in Rust [brave new I/O blog post]((https://blog.japaric.io/brave-new-io/)), хотя и немного запутанный в начале. Если заглянуть в запись блога, кажется, что split() и constrain() в какой-то момент используются как взаимозаменяемые. Кроме того, сигнатура метода constrain() возвращает структуру Parts, как и метод split(). Как и в случае с Parts, методы уровня HAL применяются к членам «ограниченной» структуры Rcc. По сути, происходит следующее: модуль уровня PAC RCC, предоставляющий доступ ко всем регистрам, потребляется для обеспечения HAL-версии с «ограниченными» операциями. Эти операции выполняются над регистрами RCC через методы, определенные HAL. В данном случае Rcc имеет тип CFGR, который раскрывает API, позволяющий нам настраивать частоту каждого тактового генератора.

Мне лично нравится рассматривать constrain() как частный случай split(). Потому что опять же в случае constrain() мы переводим структуру уровня PAC в структуру уровня HAL, но на особых условиях. Зачем все это нужно, спросите вы? Ну, есть несколько причин, которые в основном связаны с моделью безопасности Rust. Мотивация этого подхода также в значительной степени объясняется в [brave new I/O blog post]((https://blog.japaric.io/brave-new-io/).

Переходя к следующей строке let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();, sysclk() и freeze() применяются для настройки часов. Метод sysclk() устанавливает желаемую частоту для часов SYSCLK. Метод freeze(), с другой стороны, делает конфигурацию эффективной, потребляя CFGR и возвращая Clocks struct, указывающий, что конфигурация часов больше не может быть изменена. Опять же, это механизм, введенный встроенной моделью безопасности Rust. Это необходимо сделать, потому что ниже в коде замороженные часы требуются для инициализации других абстракций. В нашем случае это таймер SysTick. Этот механизм гарантирует, что часы не будут изменены в течение времени работы.

Наконец, в оставшейся строке, показанной ниже, мы создаем абстракцию задержки на основе периферийного таймера ядра SYST SysTick. Это делается путем передачи ссылки на замороженный clocks handle методу delay().

let mut delay = cp.SYST.delay(&clocks);
Вход в полноэкранный режим Выход из полноэкранного режима

4. Использование периферийного(ых) устройства(й)

Это последний шаг в нашем коде, который, по сути, связан с применением логики blinky. Теперь, когда все настроено, становится довольно просто управлять выходом gpio и вставлять задержки, как показано ниже:

led.set_high();
delay.delay_us(10000_u32);
led.set_low();
delay.delay_us(10000_u32);
Вход в полноэкранный режим Выход из полноэкранного режима

Как уже может быть очевидно, методы set_high() и set_low() применяются к абстракции led для установки и очистки выхода gpio (pa5). Кроме того, метод delay_us(10000_u32) применяется к абстракции delay для введения задержки в 10000 us.

Итак, когда мне использовать split() и/или constrain()?

После того, как мы выяснили назначение split() и constrain(), возникает вопрос: как узнать, когда использовать тот или иной метод? Можем ли мы считать, что эти методы применяются к одним и тем же модулям во всех HAL? Быстрый ответ заключается в том, что мы всегда должны проверять документацию для конкретного используемого устройства. Это делается путем изучения сигнатур и абстракций, к которым применяются методы. Для себя я фактически просканировал документацию нескольких HAL, чтобы проверить наличие вариаций (побочное замечание: на самом деле требуется некоторое время, чтобы привыкнуть к навигации по документации, особенно в иерархическом аспекте). Хорошим примером является сравнение модуля флэш-памяти в HALs stm32f4xx и stm32f1xx. В stm32f1xx HAL фактически используется метод constrain() для создания ограниченной абстракции флэш-памяти по той же схеме, которая была описана ранее. С другой стороны, stm32f4xx HAL не требует такого подхода, и методы применяются непосредственно к структуре уровня PAC pac::FLASH.

Заключение

Хотя разработка для встроенных систем не требует такой глубины языка Rust, как для других областей применения, существуют вариации, специфичные для встроенных систем. Эти вариации в некоторых случаях также связаны с тем, как Rust был принят для безопасной работы. Это включает использование таких методов, как split() и constrain(). Приступая к изучению встроенного Rust, я обнаружил, что мне было трудно понять необходимость split() и constrain(). Надеюсь, в этой статье мне удалось пролить свет на эти методы. Каким был ваш опыт? С какими трудностями вы столкнулись при работе со встроенным Rust? Поделитесь своими мыслями в комментариях 👇. Если вы нашли это полезным, не забудьте подписаться на рассылку новостей здесь, чтобы быть в курсе новых статей блога.

Оцените статью
Procodings.ru
Добавить комментарий