Язык Crystal на микроконтроллерах

Вступление

Язык Crystal каждый раз удивляет меня. Я думал что язык с синтаксисом Руби не может быть быстрым как Си. Я думал что учитывая что его авторы сидят на Маке или Линуксе его никогда не портируют на винду. Я думал что не справятся с многопоточностью учитывая насколько это усложняет шедулер. И уж совершенно точно я был уверен что портировать его на микроконтроллеры нереальная задача — большой рантайм, ориентированная на GC стдлиба. Я конечно слышал что на нем написали OS для защищенного режима х86–64 (https://github.com/ffwff/lilith), но там все-таки особый случай.

В этой статье я опишу как можно запустить код на Crystal на микроконтроллере. Материалом послужила тема на форуме https://forum.crystal-lang.org/t/embedded-crystal/7408 и мои изыскания.

Ограничения

В настоящий момент это всё экспериментально, поэтому хотя у меня заработало, но «острых углов» хватает.

  1. Если взять релиз компилятора для Windows, то будет ошибка ` LLVM was built without ARM support `, на форуме посоветовали собрать LLVM под windows с поддержкой ARM (и видимо пересобрать компилятор?), я в итоге просто компилирую из виртуалки с линуксом. В линуксе, соответственно, llvm ставится из пакетного менеджера и ARM поддерживается.

  2. Придется отказаться от стандартной библиотеки. Да, невесело, но и логично — хотя библиотека в целом неплохо оптимизирована и не выделяет память там где без этого можно обойтись, но язык все-таки с гц, так что лишние строки или массивы никого не удивляют, а мы тут наоборот бережем каждый байт. RX14 начал делать свою версию стандартной библиотеки (https://github.com/RX14/kecil.cr) для эмбеддед, так что ее можно взять за основу. Там конечно многого не хватает, но никто не мешает добавлять туда код из «обычной» стдлибы хоть целыми файлами. Правда для Array и Hash придется сначала решить как менеджить память (лично я пока не определился), но есть же всякие Slice, Tuple, StaticArray и прочие Enumerable которым динамическая память не нужна.

Сложный путь

Для начала рассмотрим способ, описанный Stephanie Wilde-Hobbs, который позволяет программировать на Кристалле вообще без использования С. Она запустила код на RPI Zero и MSPM0L (https://github.com/RX14/led-light-square/), ну, а я запустил его на нескольких STM32 которые были у меня под рукой.

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

Сообщество Rust сделало набор утилит и патчей для этих файлов, например пропатченные файлы для stm32 можно взять тут: https://stm32-rs.github.io/stm32-rs/ (а те что от вендора — у них же https://github.com/stm32-rs/stm32-rs/tree/master/svd/vendor если не хочется искать их на оффсайте.)

Нужна только утилита которая преобразует svd файлы в код на кристалле. И она есть!

https://github.com/RX14/svd.cr/

Правда поддерживаются не все теги, поэтому пропатченные svd файлы мне обработать не удалось. Но вот вендоровские, после добавления небольших костылей, вполне заработали. Итак, клонируем репозиторий https://github.com/konovod/svd.cr (это мой форк с костылями позволяющими обработать нужные мне файлы), компилируем

shards build

Качаем SVD файл например отсюда https://github.com/stm32-rs/stm32-rs/tree/master/svd/vendor и кладем его в папку example

Запускаем (можете подставить файл для своего процессора)

bin\crystal-svd.exe example\stm32f429.svd

получаем кучу .cr файлов в каталоге example — по одному для каждой периферии.

Кроме собственно исходника программы нам понадобятся всякие служебные файлы — файл линкера, ассемблерный файл задающий вектор прерываний, код который копирует значения преинициализированных переменных из флеша в RAM. В общем, можете клонировать мой репозиторий https://gitlab.com/kipar/crystal_stm32_template (источник — я нагло передрал код из примеров которые увидел у RX14).

В папку bindings пихаем сгенерированные на прошлом шаге байндинги, в папке kecil — стдлиба https://github.com/RX14/kecil.cr, в папке boot — служебные файлы, ну, а в main.cr собственно можно писать код.

Напишем там что-то вроде

require "./bindings/*"
require "./boot/*"
 
def wait
  100_0000.times { asm("nop" :::: "volatile") }
end

MCU.init
RCC::AHB1ENR.set(gpioben: true) 

wait
GPIOB::MODER._0 = 1
loop do
  GPIOB::ODR._0 = true
  wait
  GPIOB::ODR._0 = false
  wait
end

Выглядит жутковато, но никто (кроме лени) не мешает сделать красивые обертки для всей периферии, тем более у кристалла с метапрограммированием дела получше чем у Си. Чуть более продвинутый пример где я написал обертку для gpio можно посмотреть тут (https://github.com/konovod/stm32_crystal_test)

Скрытый текст

BTNS = StaticArray[STM32::InputPin.new(GPIOC, 13)]
LEDS = StaticArray[STM32::OutputPin.new(GPIOB, 0), STM32::OutputPin.new(GPIOB, 7), STM32::OutputPin.new(GPIOB, 14)]

LEDS.each &.configure
BTNS[0].configure
while true
  LEDS[0].turn(true)             
  LEDS[1].turn(!LEDS[1].read)
  LEDS[2].turn(!BTNS[0].read)
  wait
end
  • Шаг 3. Компилируем и заливаем на плату

    На всякий случай повторю — релиз компилятора на винде не умеет собирать под arm (он поставляется со статически собранной LLVM которая скомпилирована без поддержки arm), поэтому этот шаг придется делать в линуксе.

    Компилируем код на кристалле (CRYSTAL_PATH должен указывать на путь где у нас находится стдлиба)

    CRYSTAL_PATH=/mnt/crystal/stm32/kecil crystal build --cross-compile --release --no-debug --target arm-none-eabi --mcpu cortex-m4 main.cr

    Компилируем ассемблерный файл с векторами прерываний

    clang --target=arm-none-eabi -mcpu=cortex-m4 -c boot/vector_table.S

    Компонуем:

    ld.lld --gc-sections -T boot/stm32.ld --defsym=__flash_size=2048K --defsym=__ram_size=192K ./main.o ./vector_table.o

    Формируем hex файл (на мой взгляд hex однозначно лучше bin хотя бы тем что не надо отдельно указывать стартовый адрес прошивки)

    objcopy -S -O ihex a.out a.hex

    Заливаем на плату

    openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c «adapter speed 4000; init; reset halt; flash write_image erase a.hex; flash verify_image a.hex; reset; exit»

    Если всё сделано правильно, плата будет моргать светодиодом.

Простой путь

У описанного выше подхода есть недостатки. Основной — нужно заново писать обертки для периферии (я устал это делать как только посмотрел как сложно инициализируется voltage regulator на STM32F429 в проекте сгенерированном кубом) и высокоуровневые компоненты (RTOS, IP стек и так далее). Это конечно круто что можно переделать всё с нуля на приятном языке и без лишнего бойлерплейта, но времени на это, как обычно, нет — надо проекты пилить.

Поэтому встречайте — простой путь. Можно добавить код на Кристалле к любому существующему сишному проекту, так что сишные функции смогут вызывать код на Кристалле и наоборот. Соответственно то что уже есть на Си (инициализацию периферии, RTOS, LWIP и так далее) можно оставить, а высокоуровневую логику писать на Кристалле.

По шагам:

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

  • Шаг 1. Запустим STM32CubeMX, новый проект, Board selector, Nucleo-F429ZI, ответим Yes на вопрос об инициализации периферии

    12d5d9922917a077d013cc3d770b67b5.png

Разумеется, вы можете выбрать другую плату которая есть под рукой (или вообще пропустить этот шаг если готовый сишный проект).

В Middlewares включим FreeRTOS (CMSIS_V2), В System Core\SYS выберем Timebase Source TIM14 чтоб не было варнинга при генерации, перейдем на вкладку Project Manager, там выберем Toolchain — Makefile, введем имя проекта и папку

013955c90de53f3782bd07eaf78cec9a.png

Ах да, на вкладке «Code generator» выберем Copy only the necessary library files, чтоб он не тащил в проект десятки мегабайт мусора.

Ах да, если включите Enable Full Assert то напоретесь на еще один баг в stm32cube

Ах да, если включите Enable Full Assert то напоретесь на еще один баг в stm32cube

Дальше жмем Generate Code, переходим в папку проекта, пытаемся его собрать. Если вы гуру mаkе у вас это получится сразу, у меня получилось не сразу, но получилось:

PATH d:\Programs\GNU Tools ARM Embedded\gcc-arm-none-eabi-10-2020-q4-major\bin\
c:\msys64\usr\bin\make.exe

Разумеется, вам нужны будут GNU Arm Embedded Toolchain (раньше я их качал на developer.arm.com, счас легко гуглятся альтернативы, но сам их не проверял) и GNU Make (я нашел make.exe поиском по диску С, где найти актуальный билд для винды не хочу даже вникать).

Если всё скомпилировалось, откроем Core\Src\main.c и исправим там код StartDefaultTask на

/* USER CODE BEGIN 4 */
#include "stdbool.h"

void led_set(int number, bool value)
{
	switch(number)
	{
		case 1: HAL_GPIO_WritePin(GPIOB, LD1_Pin, value); break;
		case 2: HAL_GPIO_WritePin(GPIOB, LD2_Pin, value); break;
		case 3: HAL_GPIO_WritePin(GPIOB, LD3_Pin, value); break;
	}	
}
/* USER CODE END 4 */

/* USER CODE BEGIN Header_StartDefaultTask */
/**
  * @brief  Function implementing the defaultTask thread.
  * @param  argument: Not used
  * @retval None
  */
/* USER CODE END Header_StartDefaultTask */

void StartDefaultTask(void *argument)
{
  /* USER CODE BEGIN 5 */
  /* Infinite loop */
  for(;;)
  {
	led_set(1, true);
    osDelay(100);
	led_set(1, false);
    osDelay(100);
  }
  /* USER CODE END 5 */
}

ах да. STM32Cube пишут профессионалы, поэтому прошивка не будет работать пока не воткнем разъем Ethernet (инициализация Ethernet ожидает завершения подключения, а до тех пор задачи не стартуют). Так что в начале функции MX_ETH_Init надо написать return;

Перекомпилируем. Подключим плату по USB, осталось залить на плату.

Можно использовать OpenOCD, можно к примеру тот же STM32 ST-LINK utility, но в консольном режиме, но для простоты: Запускаем STM32 ST-LINK utility, выбираем Target\Program&Verify, выбираем наш файл, заливаем. Убеждаемся что плата моргает светодиодом.

f5849983defb980d0fb4ae74b63f4ec9.png

  • Шаг 2. Создадим папку crystal в нашем проекте, склонируем туда репозиторий https://github.com/RX14/kecil.cr — это будет стдлиба.

    Можно взять мою [слегка расширенную версию](https://github.com/konovod/stm32_crystal_test/tree/master/kecil), можно придумать что-то свое.

    Дальше создадим там файл blink.cr со следующим содержимым:

    lib CCode
      fun led_set(number : Int32, value : Bool)
      fun vTaskDelay(ticks : UInt32)
    end
    
    N_LEDS = 3
    DELAY = 100
    
    fun crystal_logic : Void
      N_LEDS.times do |i|
        CCode.led_set(i+1, true)
    	CCode.vTaskDelay(DELAY)
      end
      N_LEDS.times do |i|
        CCode.led_set(i+1, false)
    	CCode.vTaskDelay(DELAY)
      end
    end

    Здесь в lib объявлены сишные функции, которые мы будем использовать, ну, а fun вместо привычного def используется для объявления функций которые будут вызываться в сишном коде.

  • Замечание

    По сравнению с шагом 1 я пропускаю инициализацию рантайма Кристалла. Просто потому что с текущим состоянием стдлибы никакой инициализации нет, всё работает и без нее. Но для «феншуя», чтобы не напороться на проблемы в будущем, можно добавить код типа

    lib LibCrystalMain
      @[Raises]
      fun __crystal_main(argc : Int32, argv : UInt8**)
    end
    
    fun crystal_init : Void
      LibCrystalMain.__crystal_main(0, Pointer(UInt8*).null)
    end

    и вызывать crystal_init (); в начале сишной программы.

  • Шаг 3. Создаем виртуальную машину Linux, ставим туда Crystal, LLVM

для archlinux sudo pacman -Syu crystal clang

Создаем в виртуалке точку монтирования

851fc7f8da9f111b6e641a18f7487884.png

Монтируем ее в гостевой ОС

sudo mount --mkdir -t vboxsf -o gid=vboxsf crystal /mnt/crystal

cd /mnt/crystal

Компилируем код на Кристалле:

CRYSTAL_PATH=/mnt/crystal/kecil crystal build --cross-compile --release --no-debug --target arm-none-eabihf --mcpu cortex-m4 -o crystal_obj blink.cr

Замечание — для микроконтроллера без FPU команда будет

CRYSTAL_PATH=/mnt/crystal/kecil crystal build --cross-compile --release --no-debug --target arm-none-eabi --mcpu cortex-m3 -o crystal_obj blink.cr

И да, это определяет передачу параметров между процедурами, так что главное чтоб и сишный и кристалловский код были скомпилированы с одинаковыми настройками. Для Си аналогичная настройка -mfloat-abi=hard / -mfloat-abi=soft

Мы получили объектный файл crystal_obj.o

Слинкуем его с нашей программой, добавив в make файл после строки OBJECTS +=… строку

OBJECTS += crystal/crystal_obj.o

Ах да, еще во флаги линкера (LDFLAGS) надо добавить -specs=nosys.specs, иначе линкер будет ругаться на undefined reference to 'kill'. Почему этого флага нет в сгенерированном мейкфайле и каким образом работает без него — не знаю и не хочу знать.

Исправим функцию StartDefaultTask в Core\Src\main.c следующим образом:

extern void crystal_logic(void);

void StartDefaultTask(void *argument)
{
  /* USER CODE BEGIN 5 */
  /* Infinite loop */
  for(;;)
  {
    crystal_logic();
  }
  /* USER CODE END 5 */
}

Запускаем make, заливаем прошивку, если всё сделано правильно то плата моргает диодами уже кодом на crystal. Готово!

Заключение

Список источников — тема на форуме Кристалла https://forum.crystal-lang.org/t/embedded-crystal/7408, мои эксперименты. Сделала возможным компиляцию кода на Кристалл под микроконтроллеры — Stephanie Wilde-Hobbs.

Код описанный в статье можно скачать: https://gitlab.com/kipar/crystal_stm32_template (первый способ), https://gitlab.com/kipar/crystal_meets_cube (второй способ).

© Habrahabr.ru