У меня нет особого опыта работы с микроконтроллерами. Раньше я немного экспериментировал с Arduino, а главной точкой входа моей домашней сети является Raspberry Pi, но на этом мой недавний опыт заканчивается. Я прошёл один курс по микроконтроллерам несколько лет назад, и справлялся с ним ужасно, едва набрав проходной балл. Тем не менее они меня восхищают — это устройства с низким энергопотреблением, которые можно запрограммировать выполнять практически любые операции, если быть аккуратным с управлением ресурсами и не стрелять себе в ногу.
При обсуждении Julia всегда подразумевается обязательное наличие двух аспектов: среды исполнения и сборщика мусора. Чаще всего оптимизация Julia (да и любого другого кода) сводится к двум аспектам:
- минимизация времени, потраченного на выполнение кода, который вы не писали,
- иметь достаточно кода, который нужно запускать скомпилированным в нативные команды той системы, где он должен работать.
Требование 1 сводится к принципу «не обменивайтесь информацией со средой исполнения и GC, если это необязательно», а требование 2 — к принципу «убедитесь, что не выполняется ненужный код, например, интерпретатор», то есть статически компилируйте свой код и по возможности избегайте динамичности.
[Забавно, что если присмотреться, эти принципы можно найти где угодно. Например, нужно стремиться к тому, чтобы минимизировать количество общения с ядром Linux, потому что переключение контекста тратит ресурсы. Кроме того, если требуется производительность, то следует максимально часто вызывать быстрый нативный код, как это делается в Python посредством вызова кода на C.]
Я уже привык к требованию 1 благодаря регулярной оптимизации в процессе помощи людям в Slack и Discourse; а из-за того, что поддержка статичной компиляции становится в течение последних лет только лучше, я подумал следующее:
- Julia основан на LLVM и, по сути, уже является компилируемым языком.
- У меня завалялось несколько старых Arduino.
- Я знаю, что в них можно загружать блоб AVR для выполнения в качестве их кода.
- LLVM имеет бэкенд AVR.
Сразу после этого я подумал «заставить работать эту систему будет не так сложно, правда?».
В этой статье я расскажу «неожиданно короткую» историю о том, как мне удалось запустить код на Julia в Arduino.
<blink> светодиодом на C
Итак, с чем же нам предстоит иметь дело? Даже сайт Arduino больше не продаёт такие устройства:
Это Arduino Ethernet R3 — разновидность популярного Arduino UNO. Это третья версия, имеющая на борту ATmega328p, разъём Ethernet, разъём для SD-карты и 14 контактов ввода-вывода, большинство из которых зарезервировано. Устройство имеет 32 КиБ флэш-памяти, 2 КиБ SRAM и 1 КиБ EEPROM. Тактовая частота 16 МГц, есть последовательный интерфейс для внешнего программатора, вес 28 г.
Имея эту документацию, схему платы, даташит микроконтроллера и уверенность, что «бывали вещи и посложнее», я приступил к реализации простейшей цели: заставить мигать светодиод L9
(в левом нижнем углу платы на фотографии выше, прямо над светодиодом on
и разъёмом питания).
Для сравнения (и чтобы иметь работающую реализацию), с которой можно проверить Arduino, я написал реализацию на C того, что мы попытаемся сделать:
#include <avr/io.h>
#include <util/delay.h>
#define MS_DELAY 3000
int main (void) {
DDRB |= _BV(DDB1);
while(1) {
PORTB |= _BV(PORTB1);
_delay_ms(MS_DELAY);
PORTB &= ~_BV(PORTB1);
_delay_ms(MS_DELAY);
}
}
Этот короткий код выполняет несколько действий. Сначала он конфигурирует контакт LED как выход, что можно сделать, установив контакт DDB1
в DDRB
(это расшифровывается как Data Direction Register Port B). [Поиск нужного контакта и порта потребовал времени. В документации говорится, что светодиод подключён к «digital pin 9», сопровождающемуся маркировкой L9
рядом с самим светодиодом. Далее в ней говорится, что на большинстве плат Arduino этот светодиод помещён на контакт 13, который в моей плате использован под SPI. Это сбивает с толку, поскольку даташит моей платы соединяет этот светодиод с контактом 13 (PB1
, порт B бит 1) контроллера, который имеет разветвляющуюся дорожку, ведущую к pin 9 вывода J5
. Я ошибочно посчитал, что «pin 9» относится к микроконтроллеру и довольно долго пытался управлять светодиодом через PD5
(порт D, бит 5), только потом заметив свою ошибку. Плюс этого заключался в том, что теперь у меня имелся точно проверенный код, с которым можно выполнять сравнение, даже на ассемблерном уровне.] Этот порт управляет тем, что указанный контакт ввода-вывода интерпретируется как вход или как выход. После этого код переходит в бесконечный цикл, в котором мы сначала задаём контакту PORTB1
в PORTB
значение HIGH
(или 1
), чтобы приказать контроллеру зажечь светодиод. Затем мы ждём в течение MS_DELAY
миллисекунд, или 3 секунды. Затем отключаем питание светодиода, установив тому же контакту PORTB1
значение LOW
(или 0
). Компилируется этот код следующим образом:
avr-gcc -Os -DF_CPU=16000000UL -mmcu=atmega328p -c -o blink_led.o blink_led.c
avr-gcc -mmcu=atmega328p -o blink_led.elf blink_led.o
avr-objcopy -O ihex blink_led.elf blink_led.hex
avrdude -V -c arduino -p ATMEGA328P -P /dev/ttyACM0 -U flash:w:blink_led.hex
После компиляции и прошивки мы получаем мигающий светодиод. [-DF_CPU=16000000UL
необходимо, чтобы _delay_ms
преобразовала в циклах миллисекунды в «количество тактов ожидания». Хоть это и удобно, но необязательно — нам достаточно подождать столько, чтобы было заметно мигание, поэтому я не стал реализовывать это в версии на Julia.]
Эти shell-команды компилируют наш исходный код .c
в объектный файл .o
для нужного микроконтроллера, компонуют его в .elf
, транслируют его в ожидаемый контроллером формат Intel .hex
, а затем выполняют прошивку контроллера с соответствующими параметрами avrdude
. Всё достаточно просто. Транслировать это, должно быть, не так сложно, но в чём же хитрость?
Основная часть приведённого выше кода написана даже не на C, это директивы предпроцессора C, предназначенные именно для того, что они должны делать. Мы не можем использовать их в Julia и не можем импортировать файлы .h
, поэтому нам нужно разобраться, что они значат. Я не проверял, но думаю, что даже _delay_ms
не является функцией.
Кроме всего прочего, у нас нет удобного готового avr-gcc
, чтобы скомпилировать Julia для AVR. Однако если нам удастся воссоздать файл .o
, то оставшийся тулчейн заработает — в конце концов, avr-gcc
не сможет отличить созданный при помощи Julia .o
от .o
, созданного avr-gcc
.
Первый псевдокод Julia
Итак, с учётом всего этого давайте опишем, как должен выглядеть наш код:
const DDRB = ??
const PORTB = ??
function main()
set_high(DDRB, DDB1) # ??
while true
set_high(PORTB, PORTB1) # ??
for _ in 1:500000
# активный цикл
end
set_low(PORTB, PORTB1) # ??
for _ in 1:500000
# активный цикл
end
end
end
На высоком уровне всё остаётся почти неизменным. Устанавливаем биты, активный цикл, обнуляем биты, цикл. Я пометил все места, где нам нужно что-то делать, но пока неизвестно, что делать со знаком ??
. Все эти части взаимосвязаны, поэтому давайте разберёмся с первым серьёзным вопросом: как нам воссоздать то, что делают макросы C DDRB
, DDB1
, PORTB
и PORTB1
?
▍ Даташиты и отображение памяти
Чтобы ответить на него, нам нужно сначала сделать шаг назад, забыть, что они определены, как макросы на C и задуматься о том, что же они обозначают. DDRB
и PORTB
ссылаются на конкретные регистры ввода-вывода микропроцессора. DDB1
и PORTB1
ссылаются на «отсчитываемый от нуля» первый бит соответствующего регистра. Теоретически, чтобы светодиод замигал, нам достаточно лишь задать эти биты в указанных выше регистрах. Однако как задать бит в конкретном регистре? Он должен быть каким-то образом открыт для высокоуровневого языка наподобие C. В ассемблерном коде мы просто нативно получаем доступ к регистру, но если не учитывать встраиваемый ассемблерный код, на C или Julia этого сделать нельзя.
Изучая даташит микроконтроллера, можно заметить, что главе 36. Register Summary
на странице 621 есть справочная таблица регистров. В ней существует запись по каждому из регистров, в которой указывается адрес, имя, имя каждого бита, а также страница даташита, где можно найти подробную документацию, в том числе исходные значения. Дойдя до конца, мы находим то, что нам было нужно:
Address | Name | Bit 7 | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 | Page |
---|---|---|---|---|---|---|---|---|---|---|
0x05 (0x25) | PORTB | PORTB7 | PORTB6 | PORTB5 | PORTB4 | PORTB3 | PORTB2 | PORTB1 | PORTB0 | 100 |
0x04 (0x24) | DDRB | DDR7 | DDR6 | DDR5 | DDR4 | DDR3 | DDR2 | DDR1 | DDR0 | 100 |
Итак, PORTB
отображается на адреса 0x05
и 0x25
, а DDRB
отображается на адреса 0x04
и 0x24
. К какой памяти относятся эти адреса? Ведь у нас есть EEPROM, флэш-память и SRAM. Тут нам на помощь снова приходит даташит: в главе 8 AVR Memories
есть краткий раздел по памяти SRAM с очень интересным рисунком:
и объяснением:
Первые 32 ячейки SRAM адресуют Register File, следующие 64 ячейки — стандартную память ввода-вывода, затем идут 160 ячеек расширенной памяти ввода-вывода, а следующие 512/1024/1024/2048 ячеек адресуют SRAM внутренних данных.
Итак, указанные в описаниях регистров адреса один в один соответствуют адресам SRAM. [Это сильно отличается от ситуации с более высокоуровневыми системами наподобие ядра ОС, которые используют виртуальную RAM и пагинацию разделов памяти для обеспечения иллюзии работы с «голой» машиной и обработки сырых указателей.] Отлично!
Если преобразовать эту информацию в код, то наш прототип будет выглядеть так
const DDRB = Ptr{UInt8}(36) # 0x25, однако Julia предоставляет методы преобразования только для Int
const PORTB = Ptr{UInt8}(37) # 0x26
# Интересующие нас биты, находятся в одном и том же бите 1
# 76543210
const DDB1 = 0b00000010
const PORTB1 = 0b00000010
function main_pointers()
unsafe_store!(DDRB, DDB1)
while true
pb = unsafe_load(PORTB)
unsafe_store!(PORTB, pb | PORTB1) # включаем LED
for _ in 1:500000
# активный цикл
end
pb = unsafe_load(PORTB)
unsafe_store!(PORTB, pb & ~PORTB1) # отключаем LED
for _ in 1:500000
# активный цикл
end
end
end
builddump(main_pointers, Tuple{})
Теперь мы можем выполнять запись в регистры, сохраняя данные по их адресам, а также выполнять чтение регистра, считывая тот же адрес.
Одним махом мы избавились от всех наших ??
одновременно! Похоже, в этом коде теперь есть всё, что и в коде на C, так что давайте перейдём к самой большой неизвестной: как нам это компилировать?
Компиляция кода
Julia уже довольно долго работает не только на x86(_64), в нём есть поддержка и Linux, а также macOS на ARM. Всё это в большой степени возможно благодаря тому, что LLVM поддерживает ARM. Однако существует и ещё одна большая территория, на которой код на Julia может выполняться напрямую: GPU. Разработчики пакета GPUCompiler.jl проделали большую работу по компилированию Julia для NVPTX
и AMDGPU
— архитектур NVidia и AMD, поддерживаемых LLVM. Так как GPUCompiler.jl взаимодействует непосредственно с LLVM, мы можем подключить его в тот же механизм, чтобы создавать AVR — интерфейс расширяемый!
▍ Конфигурируем LLVM
По умолчанию в установке Julia не включён бэкенд AVR для LLVM, поэтому нам нужно собрать LLVM и Julia самостоятельно. Это нужно сделать с одной из бета-версий 1.8
, например, v1.8.0-beta3
. Более новые коммиты пока ломают GPUCompiler.jl, что в будущем будет исправлено.
К счастью, Julia уже поддерживает сборку своих зависимостей, поэтому нам достаточно внести небольшие изменения в два Makefile
, что позволит использовать бэкенд.
diff --git a/deps/llvm.mk b/deps/llvm.mk
index 5afef0b83b..8d5bbd5e08 100644
--- a/deps/llvm.mk
+++ b/deps/llvm.mk
@@ -60,7 +60,7 @@ endif
LLVM_LIB_FILE := libLLVMCodeGen.a
# Задаём целевые архитектуры для сборки
-LLVM_TARGETS := host;NVPTX;AMDGPU;WebAssembly;BPF
+LLVM_TARGETS := host;NVPTX;AMDGPU;WebAssembly;BPF;AVR
LLVM_EXPERIMENTAL_TARGETS :=
LLVM_CFLAGS :=
и приказываем Julia не использовать предварительно собранный LLVM, задав флаг в Make.user
:
USE_BINARYBUILDER_LLVM=0
После выполнения make
для запуска процесса сборки LLVM скачивается, патчится и собирается из исходников, а потом становится доступным для нашего кода на Julia. На моём ноутбуке весь процесс компиляции LLVM занял примерно 40 минут. Честно говоря, я ожидал худшего.
▍ Определяем архитектуру
Создав собственную сборку LLVM, мы можем определить всё необходимое для того, чтобы GPUCompiler.jl понял, что нам нужно.
Начнём с импорта зависимостей, определения целевой архитектуры и её target triplet:
using GPUCompiler
using LLVM
#####
# Compiler Target
#####
struct Arduino <: GPUCompiler.AbstractCompilerTarget end
GPUCompiler.llvm_triple(::Arduino) = "avr-unknown-unkown"
GPUCompiler.runtime_slug(::GPUCompiler.CompilerJob{Arduino}) = "native_avr-jl_blink"
struct ArduinoParams <: GPUCompiler.AbstractCompilerParams end
В качестве целевой мы задаём машину, выполняющую avr
без известного производителя и без ОС — в конце концов, мы имеем дело с «голой» системой. Также мы указываем runtime slug, по которому идентифицируется наш двоичный файл. Ещё в коде определяется структура-заглушка для хранения дополнительных параметров целевой архитектуры. Нам они не требуются, поэтому просто можно оставить их пустыми и игнорировать.
Так как среда исполнения Julia не может работать на GPU, GPUCompiler.jl также ожидает, что мы укажем модуль замены для различных операций, которые нам могут пригодиться, например, для распределения памяти на целевой архитектуре или для выдачи исключений. Разумеется, ничего такого мы делать не будем, поэтому определим для них пустой заполнитель:
module StaticRuntime
# the runtime library
signal_exception() = return
malloc(sz) = C_NULL
report_oom(sz) = return
report_exception(ex) = return
report_exception_name(ex) = return
report_exception_frame(idx, func, file, line) = return
end
GPUCompiler.runtime_module(::GPUCompiler.CompilerJob{<:Any,ArduinoParams}) = StaticRuntime
GPUCompiler.runtime_module(::GPUCompiler.CompilerJob{Arduino}) = StaticRuntime
GPUCompiler.runtime_module(::GPUCompiler.CompilerJob{Arduino,ArduinoParams}) = StaticRuntime
В будущем эти вызовы можно будет использовать для обеспечения простого bump-аллокатора или для сообщений об исключениях через последовательную шину для другого кода, целевой платформой для которого является Arduino. Однако пока этой среды исполнения, которая «ничего не делает», нам достаточно. [Внимательные читатели могли заметить, что это подозрительно похоже на то, что требуется для Rust — нечто для распределения и нечто для сообщений об ошибках. И это не совпадение — это минимум, требуемый для языка, обычно имеющего среду исполнения, обрабатывающую такие вещи, как сигналы и распределение памяти. Дальнейшее исследование может привести к выводу о том, что в Rust тоже используется сборка мусора, поскольку разработчику никогда не нужно вызывать malloc
и free
— всем этим занимаются среда исполнения и компилятор, вставляющие в соответствующие места вызовы этого (или другого) аллокатора.]
Теперь перейдём к компиляции. Для начала определим job для нашего конвейера:
function native_job(@nospecialize(func), @nospecialize(types))
@info "Creating compiler job for '$func($types)'"
source = GPUCompiler.FunctionSpec(
func, # наша функция
Base.to_tuple_type(types), # её сигнатура
false, # является ли это ядром GPU
GPUCompiler.safe_name(repr(func))) # имя для использования в asm
target = Arduino()
params = ArduinoParams()
job = GPUCompiler.CompilerJob(target, source, params)
end
Затем это передаётся нашему сборщику LLVM IR:
function build_ir(job, @nospecialize(func), @nospecialize(types))
@info "Bulding LLVM IR for '$func($types)'"
mi, _ = GPUCompiler.emit_julia(job)
ir, ir_meta = GPUCompiler.emit_llvm(
job, # наш job
mi; # экземпляр метода для компиляции
libraries=false, # использует ли этот код библиотеки
deferred_codegen=false, # есть ли codegen среды исполнения?
optimize=true, # хотим ли мы оптимизировать llvm?
only_entry=false, # является ли это точкой входа?
ctx=JuliaContext()) # используемый контекст LLVM
return ir, ir_meta
end
Сначала мы получаем экземпляр метода из среды исполнения Julia и просим GPUCompiler дать нам соответствующий LLVM IR для указанного job, т. е. для нашей целевой архитектуры. Мы не используем библиотеки и не можем выполнять codegen, однако оптимизации Julia вполне пригодятся. Кроме того, они для нас необходимы, ведь они убирают, очевидно, мёртвый код, относящийся к среде исполнения Julia, которую мы не хотим и не можем вызывать. Если она останется в IR, то попытка сборки ASM завершится ошибкой из-за отсутствующих символов.
После этого мы просто выдаём AVR ASM:
function build_obj(@nospecialize(func), @nospecialize(types); kwargs...)
job = native_job(func, types)
@info "Compiling AVR ASM for '$func($types)'"
ir, ir_meta = build_ir(job, func, types)
obj, _ = GPUCompiler.emit_asm(
job, # наш job
ir; # полученный нами IR
strip=true, # удалить ли из двоичного файла отладочную информацию?
validate=true, # валидировать ли LLVM IR ?
format=LLVM.API.LLVMObjectFile) # какой формат нужно создать?
return obj
end
Также мы удалим отладочную информацию, поскольку всё не можем выполнять отладку, а также дополнительно просим LLVM валидировать наш IR — очень полезная возможность!
Изучаем двоичный файл
При вызове вида build_obj(main_pointers, Tuple{})
(мы не передаём никаких аргументов main
) мы получаем String
, содержащую двоичные данные — это наш скомпилированный объектный файл:
obj = build_obj(main_pointers, Tuple{})
x7fELFx01x01x01x01Sx01xf8x02x004(x05x01x82xe0x84xb9xc0ax04xf1xffx03x02ex06x12x02?x10fx10x04x03x02x04.rela.text__do_clear_bssjulia_main_pointers.strtab.symtab__do_copy_data/x03xa8Nx01x06x01x06x004x06x04x01x04x9cfx04x02x04fx007x02<`x01x03x04x10
Давайте взглянем на дизассемблированные данные, чтобы убедиться, что именно это мы и ожидали увидеть:
function builddump(fun, args)
obj = build_obj(fun, args)
mktemp() do path, io
write(io, obj)
flush(io)
str = read(`avr-objdump -dr $path`, String)
end |> print
end
builddump(main_pointers, Tuple{})
/tmp/jl_uOAUKI: file format elf32-avr
Disassembly of section .text:
00000000 <julia_main_pointers>:
0: 82 e0 ldi r24, 0x02 ; 2
2: 84 b9 out 0x04, r24 ; 4
4: 00 c0 rjmp .+0 ; 0x6 <julia_main_pointers+0x6>
4: R_AVR_13_PCREL .text+0x4
Выглядит не очень хорошо — куда пропал весь наш код? Единственное, что осталось — это один out
, за которым следует относительный переход, не делающий ничего. Если сравнить с эквивалентным кодом на C, то это практически ничто:
$ avr-objdump -d blink_led.elf
[...]
00000080 <main>:
80: 21 9a sbi 0x04, 1 ; 4
82: 2f ef ldi r18, 0xFF ; 255
84: 8b e7 ldi r24, 0x7B ; 123
86: 92 e9 ldi r25, 0x92 ; 146
88: 21 50 subi r18, 0x01 ; 1
8a: 80 40 sbci r24, 0x00 ; 0
8c: 90 40 sbci r25, 0x00 ; 0
8e: e1 f7 brne .-8 ; 0x88 <main+0x8>
90: 00 c0 rjmp .+0 ; 0x92 <main+0x12>
92: 00 00 nop
94: 29 98 cbi 0x05, 1 ; 5
96: 2f ef ldi r18, 0xFF ; 255
98: 8b e7 ldi r24, 0x7B ; 123
9a: 92 e9 ldi r25, 0x92 ; 146
9c: 21 50 subi r18, 0x01 ; 1
9e: 80 40 sbci r24, 0x00 ; 0
a0: 90 40 sbci r25, 0x00 ; 0
a2: e1 f7 brne .-8 ; 0x9c <main+0x1c>
a4: 00 c0 rjmp .+0 ; 0xa6 <main+0x26>
a6: 00 00 nop
a8: ec cf rjmp .-40 ; 0x82 <main+0x2>
[...]
Здесь устанавливается тот же бит в 0x04
, что и в нашем коде (напомню, что это былDDRB
), в трёх словах инициализируется переменная цикла, выполняются ветвления, переходы, установка и сброс битов. По сути, происходит всё то, что мы ожидаем от нашего кода, так в чём дело?
Чтобы разобраться, что же происходит, мы должны вспомнить, что Julia, LLVM и gcc — оптимизирующие компиляторы. Если они понимают, что какой-то фрагмент кода не имеет видимого влияния, например, потому что разработчик всегда перезаписывает предыдущие итерации цикла известными константами, то компилятор обычно просто удаляет ненужные операции записи, потому что мы всё равно не увидим разницы.
Мне кажется, здесь происходит две вещи:
- Исходная
unsafe_load
из нашего указателя вызывала неопределённое поведение, поскольку исходное значение этого указателя не определено. LLVM увидел это, увидел, что мы используем считанное значение, и удалил чтение с сохранением, поскольку это неопределённое поведение и можно заменить «считываемое» значение тем, которое мы записали, поэтому пара загрузки/сохранения оказывается ненужной. - Пустые теперь циклы не имеют предназначения, поэтому тоже удаляются.
В C эту проблему можно решить при помощи volatile
. Это ключевое слово позволяет очень строго сказать компилятору: «Я хочу, чтобы каждое считывание и запись в эту переменную происходили. Не удаляй их и не перемещай (если только они недолговременные (non-volatile), их можешь перемещать). В Julia такой концепции нет, однако существуют атомарные операции. Давайте используем их и проверим, достаточно ли их, несмотря на то, что семантически они немного отличаются. [»Atomic и volatile в IR ортогональны; «volatile» — это volatile из C/C++, они гарантируют, что каждая volatile-загрузка и сохранение происходят и выполняются в указанном порядке. Пара примеров: если за сохранением SequentiallyConsistent непосредственно следует ещё одно сохранение SequentiallyConsistent по тому же адресу, то первое сохранение можно удалить. Это преобразование не разрешено для пары volatile-сохранений". LLVM Documentation — Atomics].
▍ Атомарность
С атомарными командами наш код будет выглядеть так:
const DDRB = Ptr{UInt8}(36) # 0x25, но Julia предоставляет методы преобразований только для Int
const PORTB = Ptr{UInt8}(37) # 0x26
# Интересующие нас биты - это тот же бит, что и в даташите
# 76543210
const DDB1 = 0b00000010
const PORTB1 = 0b00000010
function main_atomic()
ddrb = unsafe_load(PORTB)
Core.Intrinsics.atomic_pointerset(DDRB, ddrb | DDB1, :sequentially_consistent)
while true
pb = unsafe_load(PORTB)
Core.Intrinsics.atomic_pointerset(PORTB, pb | PORTB1, :sequentially_consistent) # включаем LED
for _ in 1:500000
# активный цикл
end
pb = unsafe_load(PORTB)
Core.Intrinsics.atomic_pointerset(PORTB, pb & ~PORTB1, :sequentially_consistent) # отключаем LED
for _ in 1:500000
# активный цикл
end
end
end
Примечание: атомарные операции в Julia обычно используются не так. Я использую intrinsics в надежде общаться с LLVM напрямую, потому что здесь мы имеем дело с указателями. В более высокоуровневом коде использовались бы операции
@atomic
с полями структур.
Этот код даёт нам следующий ассемблерный код:
/tmp/jl_UfT1Rf: file format elf32-avr
Disassembly of section .text:
00000000 <julia_main_atomic>:
0: 85 b1 in r24, 0x05 ; 5
2: 82 60 ori r24, 0x02 ; 2
4: a4 e2 ldi r26, 0x24 ; 36
6: b0 e0 ldi r27, 0x00 ; 0
8: 0f b6 in r0, 0x3f ; 63
a: f8 94 cli
c: 8c 93 st X, r24
e: 0f be out 0x3f, r0 ; 63
10: 85 b1 in r24, 0x05 ; 5
12: a5 e2 ldi r26, 0x25 ; 37
14: b0 e0 ldi r27, 0x00 ; 0
16: 98 2f mov r25, r24
18: 92 60 ori r25, 0x02 ; 2
1a: 0f b6 in r0, 0x3f ; 63
1c: f8 94 cli
1e: 9c 93 st X, r25
20: 0f be out 0x3f, r0 ; 63
22: 98 2f mov r25, r24
24: 9d 7f andi r25, 0xFD ; 253
26: 0f b6 in r0, 0x3f ; 63
28: f8 94 cli
2a: 9c 93 st X, r25
2c: 0f be out 0x3f, r0 ; 63
2e: 00 c0 rjmp .+0 ; 0x30 <julia_main_atomic+0x30>
2e: R_AVR_13_PCREL .text+0x18
Поначалу выглядит неплохо. Кода стало чуть побольше и появились команды out
, значит, всё в порядке? К сожалению, нет. Есть только один rjmp
, то есть наши активные циклы удаляются. Также мне пришлось вставить эти unsafe_load
, чтобы не получать segfault при компиляции. Кроме того, атомарные операции, похоже, считывают довольно странные адреса — считывание/запись производится по адресу 0x3f
(или 63
), что отображается на SREG
, или регистр состояния. Ещё более странно то, что код делает со считанным значением:
8: 0f b6 in r0, 0x3f ; 63
a: f8 94 cli
...
e: 0f be out 0x3f, r0 ; 63
Сначала считывается SREG
в r0
, затем сбрасывается бит прерывания, далее снова записывается сохранённое нами значение. Я не знаю, как это попало в код, но знаю, что нам нужно не это. То есть атомарные операции нам не подходят.
▍ Встроенный LLVM-IR
Другой вариант, который у нас по-прежнему есть — это написание встроенного LLVM-IR. В Julia есть отличная поддержка таких конструкций, поэтому давайте ими воспользуемся:
const DDRB = Ptr{UInt8}(36)
const PORTB = Ptr{UInt8}(37)
const DDB1 = 0b00000010
const PORTB1 = 0b00000010
const PORTB_none = 0b00000000 # нам не нужны все остальные контакты - устанавливаем везде низкий сигнал
function volatile_store!(x::Ptr{UInt8}, v::UInt8)
return Base.llvmcall(
"""
%ptr = inttoptr i64 %0 to i8*
store volatile i8 %1, i8* %ptr, align 1
ret void
""",
Cvoid,
Tuple{Ptr{UInt8},UInt8},
x,
v
)
end
function main_volatile()
volatile_store!(DDRB, DDB1)
while true
volatile_store!(PORTB, PORTB1) # включаем LED
for _ in 1:500000
# активный цикл
end
volatile_store!(PORTB, PORTB_none) # отключаем LED
for _ in 1:500000
# активный цикл
end
end
end
А дизассемблированный код выглядит так:
/tmp/jl_3twwq9: file format elf32-avr
Disassembly of section .text:
00000000 <julia_main_volatile>:
0: 82 e0 ldi r24, 0x02 ; 2
2: 84 b9 out 0x04, r24 ; 4
4: 90 e0 ldi r25, 0x00 ; 0
6: 85 b9 out 0x05, r24 ; 5
8: 95 b9 out 0x05, r25 ; 5
a: 00 c0 rjmp .+0 ; 0xc <julia_main_volatile+0xc>
a: R_AVR_13_PCREL .text+0x6
Гораздо лучше! Наши команды out
выполняют сохранение в нужный регистр. Ожидаемо, что все циклы по-прежнему удаляются. Мы можем принудительно заставить существовать переменную из активных циклов, записав её значение куда-нибудь в SRAM, но это лишняя трата ресурсов. Вместо этого — можно спуститься на уровень ниже с вложением кода и вставить ассемблерный код AVR во встроенный LLVM-IR:
const DDRB = Ptr{UInt8}(36)
const PORTB = Ptr{UInt8}(37)
const DDB1 = 0b00000010
const PORTB1 = 0b00000010
const PORTB_none = 0b00000000 # нам не нужны все остальные контакты - устанавливаем везде низкий сигнал
function volatile_store!(x::Ptr{UInt8}, v::UInt8)
return Base.llvmcall(
"""
%ptr = inttoptr i64 %0 to i8*
store volatile i8 %1, i8* %ptr, align 1
ret void
""",
Cvoid,
Tuple{Ptr{UInt8},UInt8},
x,
v
)
end
function keep(x)
return Base.llvmcall(
"""
call void asm sideeffect "", "X,~{memory}"(i16 %0)
ret void
""",
Cvoid,
Tuple{Int16},
x
)
end
function main_keep()
volatile_store!(DDRB, DDB1)
while true
volatile_store!(PORTB, PORTB1) # включаем LED
for y in Int16(1):Int16(3000)
keep(y)
end
volatile_store!(PORTB, PORTB_none) # отключаем LED
for y in Int16(1):Int16(3000)
keep(y)
end
end
end
Эта слегка необычная конструкция притворяется, что исполняет команду, имеющую какой-то побочный эффект, используя в качестве аргумента наши входящие данные. Я изменил цикл так, чтобы он выполнялся в течение меньшего количества итераций, потому что это упрощает чтение ассемблерного кода.
Проверим получившийся ассемблерный код…
/tmp/jl_xOZ5hH: file format elf32-avr
Disassembly of section .text:
00000000 <julia_main_keep>:
0: 82 e0 ldi r24, 0x02 ; 2
2: 84 b9 out 0x04, r24 ; 4
4: 21 e0 ldi r18, 0x01 ; 1
6: 30 e0 ldi r19, 0x00 ; 0
8: 9b e0 ldi r25, 0x0B ; 11
a: 40 e0 ldi r20, 0x00 ; 0
c: 85 b9 out 0x05, r24 ; 5
e: 62 2f mov r22, r18
10: 73 2f mov r23, r19
12: e6 2f mov r30, r22
14: f7 2f mov r31, r23
16: 31 96 adiw r30, 0x01 ; 1
18: 68 3b cpi r22, 0xB8 ; 184
1a: 79 07 cpc r23, r25
1c: 6e 2f mov r22, r30
1e: 7f 2f mov r23, r31
20: 01 f4 brne .+0 ; 0x22 <julia_main_keep+0x22>
20: R_AVR_7_PCREL .text+0x16
22: 45 b9 out 0x05, r20 ; 5
24: 62 2f mov r22, r18
26: 73 2f mov r23, r19
28: e6 2f mov r30, r22
2a: f7 2f mov r31, r23
2c: 31 96 adiw r30, 0x01 ; 1
2e: 68 3b cpi r22, 0xB8 ; 184
30: 79 07 cpc r23, r25
32: 6e 2f mov r22, r30
34: 7f 2f mov r23, r31
36: 01 f4 brne .+0 ; 0x38 <julia_main_keep+0x38>
36: R_AVR_7_PCREL .text+0x2c
38: 00 c0 rjmp .+0 ; 0x3a <julia_main_keep+0x3a>
38: R_AVR_13_PCREL .text+0xc
Ура! Здесь есть всё, что мы ожидали увидеть:
- Мы выполняем запись в
0x05
при помощиout
- У нас есть
brne
, чтобы занять активный цикл - Мы прибавляем что-то к какому-то регистру для реализации цикла
Разумеется, двоичный файл получился не таким маленьким, как если бы его компилировали с -Os
из C, но он должен работать! Единственное, что нам осталось — это избавиться от всех этих меток переходов .+0
, которые не позволят нам выполнять циклы. Также я включил дампинг меток релокации (они относятся к R_AVR_7_PCREL
), вставляемых компилятором, чтобы код мог релоцироваться в файл ELF и используемых компоновщиком в процессе окончательной компоновки ассемблерного кода. Теперь, когда мы, вероятно, готовы к прошивке, можно скомпоновать наш код в двоичный файл (таким образом, зарезолвив эти метки релокации) и прошить его в Arduino:
$ avr-ld -o jl_blink.elf jl_blink.o
$ avr-objcopy -O ihex jl_blink.elf jl_blink.hex
$ avrdude -V -c arduino -p ATMEGA328P -P /dev/ttyACM0 -U flash:w:jl_blink.hex
avrdude: AVR device initialized and ready to accept instructions
Reading | ################################################## | 100% 0.00s
avrdude: Device signature = 0x1e950f (probably m328p)
avrdude: NOTE: "flash" memory has been specified, an erase cycle will be performed
To disable this feature, specify the -D option.
avrdude: erasing chip
avrdude: reading input file "jl_blink.hex"
avrdude: input file jl_blink.hex auto detected as Intel Hex
avrdude: writing flash (168 bytes):
Writing | ################################################## | 100% 0.04s
avrdude: 168 bytes of flash written
avrdude done. Thank you.
И после прошивки мы получаем…
<blink> светодиодом на Julia
Два дня потрачены с пользой! Питание на Arduino подаётся через последовательный разъём справа, который я использую для прошивки программ.
Хочу поблагодарить пользователей Slack-канала Julialang #static-compilation
за помощь в процессе работы! Без них я не подумал бы о метках модификации при компоновке и их помощь была неоценимой при анализе того, что работает и не работает при компилировании Julia для экзотичной, для этого языка архитектуры.
Ограничения
Использовал ли бы я эту систему в продакшене? Маловероятно, но, возможно, в будущем. Система оказалась привередливой, а произвольные ошибки сегментации в процессе компиляции утомляли. Но всё это не было частью поддерживаемого рабочего процесса, поэтому я очень рад, что всё заработало! Я верю, что эта область будет постепенно совершенствоваться — в конце концов, она уже хорошо работает на GPU FPGA (по крайней мере, мне так сказали: Julia на FPGA является одним из коммерческих предложений компании). Насколько я знаю, это первый код на Julia, запущенный нативно на «голом» чипе Arduino/ATmega, что восхитительно само по себе. Тем не менее отсутствие среды исполнения для этого (Julia использует для задач libuv, а реализовать её на Arduino будет сложно) означает, что мы, скорее всего, будем ограничены собственным или проверенным кодом, который не использует особо сложные функции наподобие сборки мусора.
Мне бы хотелось получить улучшенную поддержку собственных аллокаторов, чтобы обеспечить возможность настоящего распределения «куч». Пока я не пробовал, но думаю, неизменяемые структуры (они часто помещаются в стек, который у ATmega328p есть!) должны работать «из коробки».
Мне хотелось бы попробовать реализовать обмен данными по i²c и SPI, но моя интуиция говорит мне, что это будет сильно отличаться от кода на C (если только мы не реализуем поддержку собственных аллокаторов или я не воспользуюсь одним из массивов на основе malloc
из StaticTools.jl).
Ссылки и справочные материалы
Автор:
ru_vds