Язык Crystal на микроконтроллерах
Вступление
Язык Crystal каждый раз удивляет меня. Я думал что язык с синтаксисом Руби не может быть быстрым как Си. Я думал что учитывая что его авторы сидят на Маке или Линуксе его никогда не портируют на винду. Я думал что не справятся с многопоточностью учитывая насколько это усложняет шедулер. И уж совершенно точно я был уверен что портировать его на микроконтроллеры нереальная задача — большой рантайм, ориентированная на GC стдлиба. Я конечно слышал что на нем написали OS для защищенного режима х86–64 (https://github.com/ffwff/lilith), но там все-таки особый случай.
В этой статье я опишу как можно запустить код на Crystal на микроконтроллере. Материалом послужила тема на форуме https://forum.crystal-lang.org/t/embedded-crystal/7408 и мои изыскания.
Ограничения
В настоящий момент это всё экспериментально, поэтому хотя у меня заработало, но «острых углов» хватает.
Если взять релиз компилятора для Windows, то будет ошибка ` LLVM was built without ARM support `, на форуме посоветовали собрать LLVM под windows с поддержкой ARM (и видимо пересобрать компилятор?), я в итоге просто компилирую из виртуалки с линуксом. В линуксе, соответственно, llvm ставится из пакетного менеджера и ARM поддерживается.
Придется отказаться от стандартной библиотеки. Да, невесело, но и логично — хотя библиотека в целом неплохо оптимизирована и не выделяет память там где без этого можно обойтись, но язык все-таки с гц, так что лишние строки или массивы никого не удивляют, а мы тут наоборот бережем каждый байт. 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 на вопрос об инициализации периферии
Разумеется, вы можете выбрать другую плату которая есть под рукой (или вообще пропустить этот шаг если готовый сишный проект).
В Middlewares включим FreeRTOS (CMSIS_V2), В System Core\SYS выберем Timebase Source TIM14 чтоб не было варнинга при генерации, перейдем на вкладку Project Manager, там выберем Toolchain — Makefile, введем имя проекта и папку
Ах да, на вкладке «Code generator» выберем Copy only the necessary library files, чтоб он не тащил в проект десятки мегабайт мусора.
Ах да, если включите 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, выбираем наш файл, заливаем. Убеждаемся что плата моргает светодиодом.
Шаг 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
Создаем в виртуалке точку монтирования
Монтируем ее в гостевой ОС
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 (второй способ).