RISC-V и его ассемблер, часть 2

В предыдущей части мы установили и настроили сборочный тулчейн для МК Амур, а также собрали и запустили простую программу мигания светодиодом. Но с этим прекрасно справляется и автоматизированная установка через Platform IO в VS Code. Моя же цель была в том, чтобы иметь возможность писать программы не на Си, а прямо на ассемблере. Конечно, можно использовать всё тот же Platform IO и просто заменять файл main.c на main.s, но в этом очень мало смысла.

Зачем всё это нужно?

Чтобы понять мотивацию писать на ассемблере вместо Си давайте заглянем в процесс сборки программы и посмотрим, что там получается на выходе. Вот что появляется в сборочной директории нашей прошивки с мигающим светодиодом:

~/Developer/elbear/Leds_Blinker ❯ ls -lA build                                    
total 92K
-rwxrwxr-x 1 plushcube plushcube  796 окт 15 21:38 firmware.bin
-rwxrwxr-x 1 plushcube plushcube 9,5K окт 15 21:38 firmware.elf
-rw-rw-r-- 1 plushcube plushcube  65K окт 15 21:38 firmware.elf.map
-rw-rw-r-- 1 plushcube plushcube 2,3K окт 15 21:38 firmware.hex
-rw-rw-r-- 1 plushcube plushcube  452 окт 16 14:19 openocd.log

Для прошивки платы мы используем файл firmware.hex. Он довольно объёмный, хотя программа ничего особенного не делает. При отправке данных непосредственно на устройство бинарник преобразуется и фактический размер будет другим: для hex-файла он составил 896 байт. Вроде и не так уж много, но наша программа просто инициализирует GPIO и потом в цикле переключает 1 бит.

Кажется, что на ассемблере будет заметно эффективнее. Ведь микроконтроллер - это устройство с очень ограниченными ресурсами, и если их можно немного сэкономить, то стоит попробовать это сделать.

Запуск голой программы мигания

Переписываем всё на ассемблер!

Итак, давайте попробуем написать и запустить на устройстве программу, целиком написанную на ассемблере. В качестве такой программы я возьму всё тот же код мигания светодиодом, чтобы эксперимент был по-настоящему чистым.

Собственно, вот код нашей программы, который я поместил в файл src/main.s:

.equ GPIO_BASE,                       (0x00084800)    # Базовый адрес регистров GPIO
.equ PM_BASE_ADDRESS,                 (0x00050000)    # Базовый адрес регистров управления питанием
.equ PAD_CONFIG_BASE_ADDRESS,         (0x00050c00)    # Базовый адрес конфигурирования PAD

.equ GPIO_DIRECTION,                  (GPIO_BASE + 0x08) # Адрес регистра направления GPIO
.equ GPIO_OUTPUT,                     (GPIO_BASE + 0x10) # Адрес регистра выходного состояния GPIO

.equ PM_CLK_APB_M_SET,                (PM_BASE_ADDRESS) + (0x14) # Адрес регистра тактирования для смены режима выводов
.equ PM_CLK_APB_P_SET,                (PM_BASE_ADDRESS) + (0x1C) # Адрес регистра тактирования для GPIO

.equ PM_CLOCK_APB_P_GPIO_2,           (1 << 14)       # Бит включения тактирования порта 2
.equ PM_CLOCK_APB_M_PM,               (1 << 0)
.equ PM_CLOCK_APB_M_PAD_CONFIG,       (1 << 3)
.equ PM_CLOCK_APB_M_WU,               (1 << 7)

.equ PAD_CONFIG_PORT_2_CFG,           (PAD_CONFIG_BASE_ADDRESS) + (0x18)  # Адрес конфигурации порта 2

.equ PIN_LED,                         7               # Определяет номер вывода для светодиода (PORT_2_7)
.equ LED_MASK,                        (1 << PIN_LED)

.equ BLINK_DELAY,                     (500 * 3600)    # 500 мс

.section .text
.globl _start

_start:                               # Точка входа в программу
  jal   init_gpio

  li    t0, GPIO_OUTPUT               # Загружаем адрес выхода GPIO
main_loop:                            # Основной цикл программы
  lw    t1, 0(t0)                     # Читаем текущее значение выхода
  xor   t1, t1, LED_MASK              # Переворачиваем состояние LED (включаем/выключаем)
  sw    t1, 0(t0)                     # Записываем новое состояние

  jal   sleep                         # Вызываем функцию задержки
  j     main_loop                     # Возвращаемся в основной цикл


init_gpio:                            # Функция инициализации GPIO
  # Включаем тактирование GPIO
  li    t0, PM_CLK_APB_P_SET          # Загружаем адрес регистра тактирования
  lw    t1, 0(t0)                     # Читаем текущее значение регистра
  li    t2, PM_CLOCK_APB_P_GPIO_2     # Подготавливаем бит для включения тактирования GPIO
  or    t1, t1, t2                    # Устанавливаем соответствующий бит
  sw    t1, 0(t0)                     # Записываем новое значение обратно в регистр
  # Включаем тактирование блока для смены режима выводов
  li    t0, PM_CLK_APB_M_SET
  lw    t1, 0(t0)
  li    t2, (PM_CLOCK_APB_M_PAD_CONFIG | PM_CLOCK_APB_M_WU | PM_CLOCK_APB_M_PM)
  or    t1, t1, t2
  sw    t1, 0(t0)
  # Добавляем конфигурацию порта 2
  li    t0, PAD_CONFIG_PORT_2_CFG     # Загружаем адрес конфигурации порта 2
  lw    t1, 0(t0)
  li    t2, ~(3 << (2 * PIN_LED))
  and   t1, t1, t2
  sw    t1, 0(t0)                     # Записываем конфигурацию в регистр порта
  # Устанавливаем LED как выход
  li    t0, GPIO_DIRECTION            # Загружаем адрес регистра направления GPIO
  li    t1, LED_MASK                  # Устанавливаем LED как выход (бит = 1)
  sw    t1, 0(t0)
  # Выходим из функции
  ret


sleep:                                # Функция задержки
  li    a0, BLINK_DELAY
sleep_loop:
  addi  a0, a0, -1
  bnez  a0, sleep_loop
  ret

Коненчо, листинг получился заметно больше, чем на Си, хотя его можно немного сократить, если заменить повторяющийся блок настройки регистров контроллера макросом. Но для наглядности я оставлю полную версию.

Как это теперь собрать?

В прошлой части мы использовали расширение Platform IO для VS Code, которое скрывает большую часть работы по сборке и заливке прошивки в своих недрах. Сейчас у нас такой роскоши нет, ведь мы хотим досканально во всём разобраться. Поэтому воспользуемся настоящим олдскульным вариантом сборки программ - утилитой make. Для этого создадим в корневой директории нашего “проекта” файл Makefile:

PROJ_NAME ?= blinker

SRC_DIR = src
BUILD_DIR ?= .build

TARGET = $(BUILD_DIR)/$(PROJ_NAME)
SOURCES = $(wildcard $(SRC_DIR)/*.s)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.s=$(BUILD_DIR)/%.o)

$(shell mkdir -p $(BUILD_DIR))

TOOLCHAIN = $(RISCV_TOOLS_PATH)/toolchain/bin
UPLOADER = $(RISCV_TOOLS_PATH)/mik32/upload

AS = $(TOOLCHAIN)/riscv-none-elf-as
LD = $(TOOLCHAIN)/riscv-none-elf-ld
OBJ = $(TOOLCHAIN)/riscv-none-elf-objcopy

ASFLAGS = -march=rv32imc_zicsr_zifencei -mabi=ilp32
LDSCRIPTS = $(RISCV_TOOLS_PATH)/mik32/mik32v2-shared/ldscripts
LDFLAGS = -L $(LDSCRIPTS) -T eeprom.ld -Map $(TARGET).map -gc-sections --print-memory-usage

$(TARGET): $(OBJECTS)
    $(LD) $(LDFLAGS) $< -o $@
    $(OBJ) -O ihex $@ $@.hex

$(BUILD_DIR)/%.o: $(SRC_DIR)/%.s
    $(AS) $(ASFLAGS) $< -o $@

upload:
	$(UPLOADER) JTAG $(TARGET).hex

clean:
    rm -rf $(BUILD_DIR)

.PHONY: clean

Обратите внимание на переменные LDSCRIPTS и LDFLAGS - это очень важная часть сборки. Дело в том, что контроллер ожидает определённым образом размеченную прошивку: секции кода, данных и т.п. должны находиться в определённых местах. Именно эти настройки указаны в файле eeprom.ld, который мы пока что возьмём из тулчейна для C. Давайте пока не будем погружаться в особенности настройки линкера, но обязательно вернёмся к этому вопросу чуть позже.

Теперь можно простыми командами make и make upload скомпилировать и прошить программу на плату. Все продукты сборки будут компактно уложены в директорию .build:

~/Developer/elbear/asm_blinker ❯ ls -lA .build
total 24K
-rwxrwxr-x 1 plushcube plushcube 6,2K окт 27 14:45 hello
-rw-rw-r-- 1 plushcube plushcube  403 окт 27 14:45 hello.hex
-rw-rw-r-- 1 plushcube plushcube 7,0K окт 27 14:45 hello.map
-rw-rw-r-- 1 plushcube plushcube 1,5K окт 27 14:45 main.o

Как видите, hex-файл, который мы используем для прошивки, почти в 6 раз меньше версии на С. Но давайте не делать пока поспешных выводов, т.к. у нашей программы есть один недостаток: она не учитывает требования программ сложнее мигалки, такие как наличие секций статических и динамических данных, использование стека для вызова сложных функций, а также обработку исключений.

Правильная подготовка рантайма

Любая программа сложнее мигалки светодиодом состоит из нескольких секций: как мниимум это секция исполняемого кода, но практически всегда ещё есть секции статических и динамических данных. Наш микроконтроллер умеет просто выполнять инструкции, которые найдёт по заранее определённому адресу загрузки, так что программа должна сама позаботиться о правильной подготовке памяти для своей работы.

К счастью, разработчики микроконтроллера об этом позаботились и написали для нас весь необходимый код - его можно найти в библиотеке mik32v2-shared, которую мы настраивали в предыдущей части.

Однако, в чистом виде эта библиотека нам уже не подходит, ведь она предназначена для поддержки программ на Си. Так что придётся адаптировать её под сборку ассемблером.

Создаём ассемблерную версию библиотеки поддержки mik32

Давайте создадим директорию mik32v2-asm рядом с mik32v2-shared и скопируем в неё директорию ldscripts из mik32v2-shared, чтобы полностью отделить нашу ассемблерную библиотеку от сишной. Также создадим директорию runtime - в ней будут находиться общие для всех программ файлы первичной инициализации. Можно по аналогии со структурой директорий mik32-shared создать также include, libs и periphery, но пока оставим их пустыми.

Теперь необходимо адаптировать crt0.s для компиляции утилитой as вместо gcc. Можно просто скопировать оригинальный файл и исправить все места, которые не понимает ассемблер, но я решил пойти немного дальше и разделил его на несколько частей.

Первым делом я вынес в подключаемый файл macro.inc все макросы:

.altmacro
.macro memcpy src_beg, src_end, dst, tmp_reg
    LOCAL memcpy_1, memcpy_2
    j     memcpy_2
memcpy_1:
    lw    \tmp_reg, (\src_beg)
    sw    \tmp_reg, (\dst)
    add   \src_beg, \src_beg, 4
    add   \dst, \dst, 4
memcpy_2:
    bltu  \src_beg, \src_end, memcpy_1
.endm

.macro memset dst_beg, dst_end, val_reg
    LOCAL memset_1, memset_2
    j     memset_2
memset_1:
    sw    \val_reg, (\dst_beg)
    add   \dst_beg, \dst_beg, 4
memset_2:
    bltu  \dst_beg, \dst_end, memset_1
.endm

.macro la_abs reg, address
    .option push
    .option norelax
    lui   \reg, %hi(\address)
    addi  \reg, \reg, %lo(\address)
    .option pop
.endm

.macro jalr_abs return_reg, address
    .option push
    .option norelax
    lui   \return_reg, %hi(\address)
    jalr  \return_reg, %lo(\address)(\return_reg)
    .option pop
.endm

Теперь файл crt0.s стал гораздо более лаконичным и понятным:

.globl _start, main
.weak SmallSystemInit, SystemInit

.include "macro.inc"

.section .startup
_start:
    li t0, 128000
start_loop_delay:
    addi t0, t0, -1
    bnez t0, start_loop_delay

    # Init stack and global pointer
    la_abs sp, __C_STACK_TOP__
    la_abs gp, __global_pointer$

    # Init data
    la_abs a1, __DATA_IMAGE_START__
    la_abs a2, __DATA_IMAGE_END__
    la_abs a3, __DATA_START__
    memcpy a1, a2, a3, t0

    # Clear bss
    la_abs a1, __SBSS_START__
    la_abs a2, __BSS_END__
    memset a1, a2, zero

    # Init mtvec
    la_abs t0, __TRAP_TEXT_START__
    csrw mtvec, t0

    jalr_abs ra, SmallSystemInit
    jalr_abs ra, SystemInit
    jalr_abs ra, main

infinite_loop:
    wfi
    j infinite_loop

.section .text

# Actions before main: none by default (weak symbol here - may be redefined)
SmallSystemInit:
    # Init ramfunc
    #
    la_abs a1, __RAM_TEXT_IMAGE_START__
    la_abs a2, __RAM_TEXT_IMAGE_END__
    la_abs a3, __RAM_TEXT_START__
    memcpy a1, a2, a3, t0

SystemInit:
    ret

Как видите, отличий от оригинала не так много: макросы подключаются из отдельного файла директивой .include, а секция обработки исключений вообще убрана. На самом деле, её я тоже вынес в отдельный файл exceptions.s - так будет возможность подключать его в проект по необходимости. Всё-равно в базовом варианте там ничего интересного нет, просто сохраняются все необходимые регистры процессора и вызывается пустая функция обработчика исключений.

Что тут происходит?

Первым делом выполняется небольшая задержка в 256000 тактов - она нужна, чтобы после включения платы питание успело разойтись по всем закоулкам периферии и все компоненты вышли на рабочие режимы.

Когда счётчик дошёл до нуля, выставляются значения регистров sp и gp – адрес стека и глобального указателя соответственно. Для их инициализации используются специальные символы __C_STACK_TOP__ и __global_pointer$, но откуда они берутся? В нашем Makefile мы указали путь до директории ldscripts и использовали файл eeprom.ld для указания линкеру как правильно размечать исполняемый файл на секции. Само описание карты секций находится в файле sections.lds, и если в него заглянуть, то там можно найти все используемые в crt0.s символы! То есть на этапе линковки вместо них просто подставятся правильные значения.

После инициализации указателей программа копирует свои данные в память с помощью макроса memcpy. Это нужно для того, чтобы иметь возможность изменять эти данные в процессе работы программы, потому что сейчас они лежат в EEPROM, куда доступ есть только на чтение. Ну и в целом это так себе идея, давать программам доступ на запись в ту область, где размещается код этих программ.

Теперь, когда все данные нашей программы скопированы в память, нам нужно позаботиться о блоке для секции BSS – здесь хранятся неинициализированные статические переменные. Размер этой секции вычисляется при компиляции и во время линковки мы уже будем его знать - останется только проинициализировать память нулями с помощью макроса memset.

И последний шаг инициализации - настройка обработчика прерываний и исключений с помощью команды csrw.

После этого последовательно вызываются переопределяемые функции предварительной инициализации SmallSystemInit и SystemInit, а в самом конце вызывается main. Я пока не разобрался зачем нужны функции предварительной инициализации, но похоже, что это сделано для унификации кодовой базы с другими платформами (нашёл упоминание этих функций на форумах по STM32).

Подключаем библиотеку к проекту

Итак, с предварительной настройкой рантайма разобрались, теперь давайте подключим библиотеку mik32v2-asm к нашей программе в Makefile. В раздел с объявлением путей к тулчейну добавляем путь до нашей бибилиотеки и файлов рантайма:

...
MIK32LIB = $(RISCV_TOOLS_PATH)/mik32/mik32v2-asm

RUNTIME = $(wildcard $(MIK32LIB)/runtime/*.s)
RUNTIME_OBJS = $(patsubst %.s,$(BUILD_DIR)/%.o,$(notdir $(RUNTIME)))

ALL_OBJECTS = $(OBJECTS) $(RUNTIME_OBJS)
...

Теперь для сборки главного таргета нужно использовать переменную $(ALL_OBJECTS), а также не забыть добавить правила для сборки рантайма:

...
$(TARGET): $(ALL_OBJECTS)
    $(LD) $(LDFLAGS) $(ALL_OBJECTS) -o $@
    $(OBJ) -O ihex $@ $@.hex

$(BUILD_DIR)/%.o: $(MIK32LIB)/runtime/%.s
    $(AS) $(DEBUGFLAGS) $(ASFLAGS) $< -o $@
...

Если вы сейчас попробуете скомпилировать программу, то произойдёт ошибка линковки, ведь теперь у нас дважды объявлен символ _start, обозначающий точку входа в программу. Линкер не может понять какой из них выбрать и начинает грустить. Давайте это исправим: открываем файл src/main.s и заменяем в нём _start на main. После этого команда make должна выполняться без ошибок.

Мигаем огонёчком!

Давайте посмотрим, что у нас получилось: подключаем нашу плату к компьютеру через JTAG и вызываем команду make upload.

mik32-uploader-v0.3.3
Using MIK32V2
Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : set servers polling period to 200ms
Info : clock speed 500 kHz
Info : JTAG tap: riscv.cpu tap/device found: 0xdeb11001 (mfg: 0x000 (<invalid>), part: 0xeb11, ver: 0xd)
Info : JTAG tap: riscv.sys tap/device found: 0xfffffffe (mfg: 0x7ff (<invalid>), part: 0xffff, ver: 0xf)
Info : datacount=2 progbufsize=6
Info : Examined RISC-V core; found 1 harts
Info :  hart 0: XLEN=32, misa=0x40001104
Info : starting gdb server for riscv.cpu on 3333
Info : Listening on port 3333 for gdb connections
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : accepting 'tcl' connection on tcp/6666
Clock init... OK!
MCU clock init...
Uploading driver... OK!
Uploading data...   OK!
Run driver...
EEPROM writing successfully completed!
[15:25:04] Wrote 384 bytes in 0.18 seconds (effective 2.1 kbyte/s)

Как видите, наша прошивка при передаче на устройство занимает всего 384 байта. Напомню, что сишная версия занимала в памяти контроллера 896 байт! Неплохая получилась оптимизация. Хотя, конечно, основной смысл всего происходящего был скорее в изучении внутренней кухни кросс-компиляции и настройки тулчейнов. Однако, раз уж начали гоняться за уменьшением размеров, давайте посмотрим и на артефакты компиляции:

~/Developer/elbear/asm_blinker ❯ ls -la .build
total 56K
-rw-rw-r-- 1 plushcube plushcube 2.1K Nov  3 22:52 crt0.o
-rw-rw-r-- 1 plushcube plushcube  988 Nov  3 22:52 exceptions.o
-rwxrwxr-x 1 plushcube plushcube  20K Nov  3 22:52 hello
-rw-rw-r-- 1 plushcube plushcube 1.3K Nov  3 22:52 hello.hex
-rw-rw-r-- 1 plushcube plushcube 8.0K Nov  3 22:52 hello.map
-rw-rw-r-- 1 plushcube plushcube  11K Nov  3 22:52 main.o

И хоть сам исполняемый файл у нас получился в 2 раза больше сишной версии, его hex-часть оказалась на 45% меньше!

Бонус!

Внимательный читатель, конечно же, заметил в листинге нашей мигалки странные константы адресов и смещений, которые в сишной версии предоставляются библиотекой mik32v2-shared. Подключить сишные заголовочные файлы к программе на ассемблере не получится, к тому же в ассемблере нет концепции структур, которые там активно используются. Так что единственный вариант получить такие же удобные константы - это вручную перевести сишные заголовочные файлы в понятный ассемблеру формат.

Хорошая новость в том, что их не так уж много и они довольно простые. Я уже переписал часть самых необходимых файлов и выложил их вместе с библиотекой на GitVerse и GitFlic, так что вам останется только стянуть библиотеку и прописать в Makefile пути до неё.

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