Вступление
Язык 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 которые были у меня под рукой.
Шаг 1. Сгенерировать байндинги к регистрам МК
Это кажется нереальной задачей, но, к счастью, большая часть уже сделана за нас. Производители микроконтроллеров предоставляют информацию о регистрах своих МК в формате 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 - по одному для каждой периферии.
Шаг 2. Создаем шаблон проекта
Кроме собственно исходника программы нам понадобятся всякие служебные файлы - файл линкера, ассемблерный файл задающий вектор прерываний, код который копирует значения преинициализированных переменных из флеша в 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, чтоб он не тащил в проект десятки мегабайт мусора.
Дальше жмем 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 (второй способ).