Как стать автором
Обновить
2753.78
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Запускаем Julia на Arduino

Время на прочтение19 мин
Количество просмотров6.1K
Автор оригинала: Sukera

У меня нет особого опыта работы с микроконтроллерами. Раньше я немного экспериментировал с Arduino, а главной точкой входа моей домашней сети является Raspberry Pi, но на этом мой недавний опыт заканчивается. Я прошёл один курс по микроконтроллерам несколько лет назад, и справлялся с ним ужасно, едва набрав проходной балл. Тем не менее они меня восхищают — это устройства с низким энергопотреблением, которые можно запрограммировать выполнять практически любые операции, если быть аккуратным с управлением ресурсами и не стрелять себе в ногу.

При обсуждении Julia всегда подразумевается обязательное наличие двух аспектов: среды исполнения и сборщика мусора. Чаще всего оптимизация Julia (да и любого другого кода) сводится к двум аспектам:

  1. минимизация времени, потраченного на выполнение кода, который вы не писали,
  2. иметь достаточно кода, который нужно запускать скомпилированным в нативные команды той системы, где он должен работать.

Требование 1 сводится к принципу «не обменивайтесь информацией со средой исполнения и GC, если это необязательно», а требование 2 — к принципу «убедитесь, что не выполняется ненужный код, например, интерпретатор», то есть статически компилируйте свой код и по возможности избегайте динамичности.

[Забавно, что если присмотреться, эти принципы можно найти где угодно. Например, нужно стремиться к тому, чтобы минимизировать количество общения с ядром Linux, потому что переключение контекста тратит ресурсы. Кроме того, если требуется производительность, то следует максимально часто вызывать быстрый нативный код, как это делается в Python посредством вызова кода на C.]

Я уже привык к требованию 1 благодаря регулярной оптимизации в процессе помощи людям в Slack и Discourse; а из-за того, что поддержка статичной компиляции становится в течение последних лет только лучше, я подумал следующее:

  1. Julia основан на LLVM и, по сути, уже является компилируемым языком.
  2. У меня завалялось несколько старых Arduino.
  3. Я знаю, что в них можно загружать блоб AVR для выполнения в качестве их кода.
  4. 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{})

\x7fELF\x01\x01\x01\0\0\0\0\0\0\0\0\0\x01\0S\0\x01\0\0\0\0\0\0\0\0\0\0\0\xf8\0\0\0\x02\0\0\x004\0\0\0\0\0(\0\x05\0\x01\0\x82\xe0\x84\xb9\0\xc0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\a\0\0\0\0\0\0\0\0\0\0\0\x04\0\xf1\xff\0\0\0\0\0\0\0\0\0\0\0\0\x03\0\x02\0\e\0\0\0\0\0\0\0\x06\0\0\0\x12\0\x02\0?\0\0\0\0\0\0\0\0\0\0\0\x10\0\0\0\f\0\0\0\0\0\0\0\0\0\0\0\x10\0\0\0\x04\0\0\0\x03\x02\0\0\x04\0\0\0\0.rela.text\0__do_clear_bss\0julia_main_pointers\0.strtab\0.symtab\0__do_copy_data\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0/\0\0\0\x03\0\0\0\0\0\0\0\0\0\0\0\xa8\0\0\0N\0\0\0\0\0\0\0\0\0\0\0\x01\0\0\0\0\0\0\0\x06\0\0\0\x01\0\0\0\x06\0\0\0\0\0\0\x004\0\0\0\x06\0\0\0\0\0\0\0\0\0\0\0\x04\0\0\0\0\0\0\0\x01\0\0\0\x04\0\0\0\0\0\0\0\0\0\0\0\x9c\0\0\0\f\0\0\0\x04\0\0\0\x02\0\0\0\x04\0\0\0\f\0\0\x007\0\0\0\x02\0\0\0\0\0\0\0\0\0\0\0<\0\0\0`\0\0\0\x01\0\0\0\x03\0\0\0\x04\0\0\0\x10\0\0\0

Давайте взглянем на дизассемблированные данные, чтобы убедиться, что именно это мы и ожидали увидеть:

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 — оптимизирующие компиляторы. Если они понимают, что какой-то фрагмент кода не имеет видимого влияния, например, потому что разработчик всегда перезаписывает предыдущие итерации цикла известными константами, то компилятор обычно просто удаляет ненужные операции записи, потому что мы всё равно не увидим разницы.

Мне кажется, здесь происходит две вещи:

  1. Исходная unsafe_load из нашего указателя вызывала неопределённое поведение, поскольку исходное значение этого указателя не определено. LLVM увидел это, увидел, что мы используем считанное значение, и удалил чтение с сохранением, поскольку это неопределённое поведение и можно заменить «считываемое» значение тем, которое мы записали, поэтому пара загрузки/сохранения оказывается ненужной.
  2. Пустые теперь циклы не имеют предназначения, поэтому тоже удаляются.

В 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).

Ссылки и справочные материалы



Теги:
Хабы:
Всего голосов 42: ↑40 и ↓2+38
Комментарии1

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds