Вступление

Язык Crystal каждый раз удивляет меня. Я думал что язык с синтаксисом Руби не может быть быстрым как Си. Я думал что учитывая что его авторы сидят на Маке или Линуксе его никогда не портируют на винду. Я думал что не справятся с многопоточностью учитывая насколько это усложняет шедулер. И уж совершенно точно я был уверен что портировать его на микроконтроллеры нереальная задача - большой рантайм, ориентированная на GC стдлиба. Я конечно слышал что на нем написали OS для защищенного режима х86-64, но там все-таки особый случай.

В этой статье я опишу как можно запустить код на Crystal на микроконтроллере. Материалом послужила тема на форуме и мои изыскания.


Ограничения

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

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

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

Сложный путь

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

  • Шаг 1. Сгенерировать байндинги к регистрам МК

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

Сообщество Rust сделало набор утилит и патчей для этих файлов, например пропатченные файлы для stm32 можно взять тут (а те что от вендора — у них же, если не хочется искать их на оффсайте.)

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

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

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. В общем, можете клонировать мой репозиторий (источник — я нагло передрал код из примеров, которые увидел у RX14).

В папку bindings пихаем сгенерированные на прошлом шаге байндинги, в папке kecil - стдлиба, в папке 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 можно посмотреть тут.

Скрытый текст
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
Ах да, если включите 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. Готово!

Заключение

Список источников — тема на форуме Кристалла, мои эксперименты. Сделала возможным компиляцию кода на Кристалл под микроконтроллеры - Stephanie Wilde-Hobbs.

Код описанный в статье можно скачать (первый способ), (второй способ).

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