Издеваться мы будем над микросхемой GD32VF103CBT6, являющейся аналогом широко известной STM32F103, с небольшим, но важным отличием: вместо ядра ARM там используется ядро RISC-V. Чем это грозит нам, как программистам, попробуем разобраться.


Кратко перечислю характеристики контроллера:


  • Напряжение питания: 2.6 — 3.6 В
    • Максимальная тактовая частота: 108 МГц
    • Объем ПЗУ (flash): 128 кБ
    • Объем ОЗУ (ram): 32 кБ
    • Объем Backup регистров (сохраняемых после сброса): 42 х 16 бит = 84 байта.
    • АЦП+ЦАП: 2 штуки АЦП по 10 каналов и 12 бит каждый плюс 2 ЦАП по 12 бит.
    • Разумеется, куча прочей периферии вроде таймеров, SPI, I2C, UART и т. д.

Итак, разбираться с контроллером предлагаю с самого нуля — разработки платы и программирования ячеек памяти наэлектризованной иглой… Хотя нет, это уж слишком, обойдемся ассемблером.


Все исходники, включая схему отладочной платы и примеры кода, можно найти здесь: https://github.com/COKPOWEHEU/GD32VF103_tutor


1. Печатная плата



Первым делом — почему именно самодельная плата. Во-первых, из спортивного интереса: если заказывать разработку, сборку и все остальное в Китае, то где же твой собственный вклад? Да и просто удовольствие от ручной работы никто не отменял. Во-вторых, на самодельной плате можно вывести все нужные разъемы и элементы управления. Скажем, очень удобно когда в наличии всегда есть хотя бы пара кнопок и пара светодиодов плюс отладочный разъем UART. А вот сложная периферия вроде энкодеров, датчиков или дисплеев на подобной плате не нужна, ее лучше подключать к разъемам.


В целом ничего особенно нового моя плата не представляет — разведены кварцы, кнопки, светодиоды, разъемы (на угловом висят PA0 — PA7 плюс пятивольтовое питание и земля, на двухрядном PB8 — PB15 плюс трехвольтовое питание и земля). Наличие пятивольтового питания на разъеме позволяет как запитывать плату от внешнего источника в обход usb (например, от переходника usb-uart), так и наоборот, запитывать от самой платы внешние схемы, которым недостаточно 3.3 В.


Некоторое внимание заострю на разъеме UART, точнее на его распиновке. В отличие от большинства «фирменных» плат он у меня симметричный, то есть в середине земля, а по краям Rx и Tx. Таким образом можно не запоминать «единственно верную» распиновку, и соединять любую пару устройств простым шлейфом без необходимости перекрещивать провода в нем.
Естественно, ноги Boot0 и Boot1 выведены на джамперы.


Больше ничего интересного на плате нет.


0. Настройка программного окружения


Разработчики данного контроллера предлагают скачать с их сайта некую IDE. Но мы этого делать не будем: только консоль, текстовый редактор и хардкор.


Вот краткий список используемого софта. Что приятно, весь софт присутствует в репозитории, ничего качать с сайта GigaDevice не пришлось.


софт описание
gcc-riscv64-unknown-elf компилятор
stm32flash, dfu-util Прошивальщики через bootloader
kicad Трассировка плат
screen Отладка по UART

Отдельно остановлюсь на прошивке контроллера. Основных способов три:


(1). JTAG — теоретически, самый правильный способ. Вот только подобрать правильное заклинание для него мне так и не удалось
(2). Bootloader.UART — замыкаем вывод Boot0 на питание, ресетим контроллер (можно по питанию, можно вывести кнопку), после чего через stm32flash (да, прошивать можно утилитой, предназначенной для другого семейства!) прошиваем


$ stm32flash /dev/ttyUSB0 -w firmware.bin

Ну и наконец притягиваем Boot0 обратно к земле, снова ресетим и смотрим как работает (или как именно не работает) программа
(3). Bootloader.USB — аналогичный предыдущему вариант, только вместо stm32flash используется dfu-util:


$ dfu-util -a 0 -d 28e9:0189 -s 0x08000000 -D firmware.bin

Только надо помнить, что для USB важна стабильность тактовой частоты, поэтому если для наших первых опытов хватит встроенного RC-генератора, для USB придется поставить внешний кварц.


Внимательный читатель может заметить, что утилите dfu-util передается некий адрес. Он соответствует началу реальной флеш-памяти контроллера. В нормальном режиме работы этот адрес отображается также и на нулевой адрес, и оттуда же начинается выполнение кода. Если же замкнуть Boot0 на питание, то на тот же нулевой адрес отображается либо Bootloader, либо оперативная память в зависимости от Boot1. В результате работать с контроллером можно вообще не задействуя его флеш, только из оперативки.


0,5. Как можно обойтись без небольшого извращения?


Совместимость с stm32f103 по выводам и части периферии дает некую надежду на возможность портирования кода оттуда без полной переработки. И действительно, простая периферия вроде SPI или DMA (без прерываний!) вполне успешно запустилась после небольших танцев с бубном.
В работе с регистрами стоит отметить, что GigaDevice предпочитают использовать макросы, в отличие от STMicroelectronics, которые использовали структуры. Плюс нумерация с нуля вместо единицы. Приведу пару примеров:


GD32VF103 STM32F103
RCU_APB2EN |= RCU_APB2EN_SPI0EN; RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
SPI_DATA(SPI_NAME) = data; SPI1->DR = data;
DMA_CHCNT(LCD_DMA, LCD_DMA_CHAN) = size; DMA1_Channel3->CNDTR = size;

Здесь сразу бросается в глаза что в RISCV регистр SPI_DATA представлен макросом, в который можно подставить номер используемого модуля SPI. И это очень классно! Можно где-нибудь в заголовочнике объявить что используем SPI0, что он висит на DMA0 на канале 2 и препроцессор сам все подставит без всяких накладных расходов.


В результате на основе вот этого проекта (https://habr.com/ru/post/496046/ исходный код тут: https://github.com/COKPOWEHEU/stm32f103_ili9341_models3D) получилась такая демка:



Исходный код тут: https://github.com/COKPOWEHEU/RISCV-ili9341-3D


1. Первый проект


Стартует контроллер сразу после подачи питания, но за неимением стандартных средств ввода-вывода, привычных любому программисту — экрана, клавиатуры или хотя бы терминала — придется приложить некоторые усилия чтобы вообще определить живой камень или нет. Для этого традиционно используется мигание светодиодом (привет отладочным платам, на которых его нет!).


Однако и это не так просто. В целях экономии энергии сразу при включении вся периферия, включая логику портов ввода-вывода, отключена. Чтобы ее включить надо выставить в регистре RCU_APB2EN (адрес 0x40021018) бит RCU_APB2EN_PxEN, где x — буква порта. В моем случае, поскольку светодиоды висят на PB5 — PB7 это бит RCU_APB2EN_PBEN (3-й бит, он же битовая маска 0x8). Причем было бы неплохо сохранить состояния всех остальных битов.


la  a5, 0x40021018
lw  a4, 0(a5)
  ori   a4, a4, 8
sw  a4, 0(a5)

Регистры a4, a5 взял просто от балды, никакого хитрого умысла тут нет. Можно было взять любые другие.


Но если вы покажете такой код программисту, он тут же захочет дать вам по рукам за использование магических констант. Чтож, исправим это:


.equ RCU_APB2EN, 0x40021018
.equ RCU_APB2EN_PBEN, (1<<3)

//RCU_APB2EN |= RCU_APB2EN_PBEN
  la a5, RCU_APB2EN
  lw    a4, 0(a5)
    ori a4, a4, RCU_APB2EN_PBEN
  sw    a4, 0(a5)

Вот теперь код вполне пригоден для чтения. Но он по-прежнему не делает ничего полезного, ведь недостаточно логику портов ввода-вывода включить, ее еще нужно настроить. Согласно документации, режим работы порта задается четырьмя битами регистра GPIOх_CTL. Но поскольку ножек ввода-вывода у нас 16, получается 64 бита, а регистры всего 32-битные. Поэтому вслед за STmicroelectronics разработчики нашего контроллера разбили эту группу битов на два регистра. Для порта B это будут GPIOB_CTL0 и GPIOB_CTL1: в первом настраиваются порты PB0 — PB7, во втором PB8 — PB15. Четыре бита соответствуют 16 возможным состояниям, из которых нас пока интересует только обычный выход на максимальной скорости (на отладочной плате нет смысла экономить энергию). Также сразу укажем, что светодиоды висят на 5 — 7 выводах, а кнопки на 0 и 1:


.equ GPIOB_CTL0,        0x40010C00
.equ GPIO_MASK,     0b1111
.equ GPIO_PP_50MHz, 0b0011

.equ RLED, 5
.equ YLED, 6
.equ GLED, 7
.equ SBTN, 0
.equ RBTN, 1

Как было сказано раньше, нулевому выводу соответствуют 4 бита регистра GPIOB_CTL0: [0, 1, 2, 3], первому биту [4, 5, 6, 7]. Соответственно, за интересующий нас 5-й бит, на котором висит красный светодиод, отвечают [20, 21, 22, 23]. Ну а чтобы не высчитывать биты вручную, заставим заниматься этим препроцессор. Если бы мы писали на Си, этот код выглядел бы так:


GPIOB_CTL0 = (GPIOB_CTL0 &~(0b1111<<(RLED*4))) | 0b0011 << (RLED*4);

То есть сначала нужные нам 4 биты затираются нулями, а потом на их место побитовым ИЛИ записывается новое значение. Но мы пока пишем не на Си, а на ассемблере, тут эта строчка получается чуть длиннее:


la a5, GPIOB_CTL0
lw  a4, 0(a5)
  la  a6, ~(GPIO_MASK << (RLED*4))
  and a3, a4, a6
  la  a4, (GPIO_PP_50MHz << (RLED*4))
  or    a4, a4, a3
sw  a4, 0(a5)

Но и этого пока недостаточно для мигающего диода. Мы включили порт, настроили его. Осталось записать туда 0 или 1, подождать какое-то время и записать другое значение и так по кругу. За выходное значение порта отвечает регистр GPIOB_OCTL, который мы будем читать, XOR`ить 5-й бит и записывать обратно. Ну а задержку реализуем тупо вычитанием единицы из регистра счетчика.


Собственно, вот весь код:
.equ RCU_APB2EN, 0x40021018
.equ RCU_APB2EN_PBEN, (1<<3)
.equ GPIOB_CTL0, 0x40010C00
.equ GPIO_MASK, 0b1111
.equ GPIO_PP_50MHz, 0b0011
.equ GPIOB_OCTL, 0x40010C0C

.equ RLED, 5
.equ YLED, 6
.equ GLED, 7
.equ SBTN, 0
.equ RBTN, 1

.text
.global _start
_start:
  //RCU_APB2EN |= RCU_APB2EN_PBEN
  la a5, RCU_APB2EN
  lw    a4, 0(a5)
    ori a4, a4, RCU_APB2EN_PBEN
  sw    a4, 0(a5)

  //GPIOB_CTL0 = (GPIOB_CTL0 & (0b1111<<RLED*4)) | 0b0011 << (RLED*4)
  la a5, GPIOB_CTL0
  lw    a4, 0(a5)
    la  a6, ~(GPIO_MASK << (RLED*4))
    and a3, a4, a6
    la  a4, (GPIO_PP_50MHz << (RLED*4))
    or    a4, a4, a3
  sw    a4, 0(a5)

MAIN_LOOP:
  //GPIO_OCTL(GPIOB) ^= (1<<RLED)
  la a5, GPIOB_OCTL
  lw    a4, 0(a5)
    xori    a4, a4, (1<<RLED)
  sw    a4, 0(a5)

  //sleep
  la a5, 200000
sleep:
  addi  a5, a5, -1
  bnez a5, sleep

  j MAIN_LOOP

Число 200000 в цикле задержки было подобрано экспериментально. Именно столько итераций нужно чтобы светодиод мигал не слишком быстро и не слишком медленно.


Сразу же отмечу, что при ручном управлении ножками порта использовать OCTL не рекомендуется, поскольку работа с ним возможна только в режиме чтение-модификация-запись. Плюс в середину может вклиниться прерывание (о чем поговорим позже), но для отладочной мигалки сойдет. Правильным же способом является использование регистра GPIOx_BOP: старшие 16 бит отвечают за стирание битов OCTL в 0, а младшие — за выставление в 1. Есть еще регистр GPIOx_BC, эквивалентный старшим битам BOP, так что я не слишком понимаю зачем он нужен. Для оптимизации разве что. Причем важно отметить, что влияние на эти регистры оказывает только запись единиц. Запись нулей ни на что не влияет. То есть если мы запишем


.equ GPIOB_BOP, 0x40010C10
…
la a5, GPIOB_BOP
  la a4, (1<<YLED) | (1<<RLED*16)
sw a4, 0(a5)

то желтый светодиод загорится, а красный погаснет.
Но повторюсь, в ассемблерных примерах мы этого делать не будем, тут хватит OCTL`а.
Скомпилировать полученный код можно стандартным компилятором gcc:


$ riscv64-unknown-elf-gcc -march=rv32imac -mabi=ilp32 -mcmodel=medany -nostdlib main.S -o main.elf

Флаги в начале указывают точную архитектуру и расширения нашего ядра, флаг -nostdlib специфичен для компилятора Си (а не ассемблера) и говорит не подставлять стандартный Си`шный код инициализации памяти. Теперь полученного эльфа надо сконвертировать в бинарный формат чтобы утилита прошивки могла с ним работать. Также дизассемблируем его чтобы посмотреть, как препроцессор развернет наши инструкции, адреса и константы (это более актуально для кода на Си, но и нам сгодится), ну и собственно прошить:


$ riscv64-unknown-elf-objcopy -O binary main.elf main.bin
$ riscv64-unknown-elf-objdump -D -S main.elf > main.lss
$ stm32flash /dev/ttyUSB0 -w main.bin

Для удобства я позволил себе оформить все эти команды в общий makefile.


Не забывайте, что в современных системах доступ к COM и USB портам считается опасным действием и разрешен только руту. Впрочем, повседневное написание прошивок для платки под рутом еще опаснее, поэтому лучше добавить своего пользователя в группу dialout. Если же вы предпочтете пользоваться прошивкой по USB через dfu-utils, нужно прописать правило udev для устройства 28e9:0189.


2. Работа с кнопкой


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


Заголовок спойлера
.equ GPIO_MASK,     0b1111 # маска для стирания ненужных битов
#input
.equ GPIO_ANALOG,       0b0000 # аналоговый вход
.equ GPIO_HIZ,      0b0100 # цифровой вход
.equ GPIO_PULL,     0b1000 # вход с подтяжкой к питанию или земле
.equ GPIO_RESERVED, 0b1100 # зарезервировано, не использовать
#output, GPIO, ручное управление
.equ GPIO_PP10,     0b0001 # push-pull выход, максимальная частота 10 МГц
.equ GPIO_PP2,      0b0010 # -//- частота 2 МГц
.equ GPIO_PP50,     0b0011 # -//- частота 50 МГц
.equ GPIO_OD10,     0b0101 # open-drain выход, максимальная частота 10 МГц
.equ GPIO_OD2,      0b0110 # -//- частота 2 МГц
.equ GPIO_OD50,     0b0111 # -//- частота 50 МГц
#output, AFIO — альтернативная функция, портом управляют аппаратные модули
.equ GPIO_APP10,        0b1001 # push-pull выход, максимальная частота 10 МГц
.equ GPIO_APP2,     0b1010 # -//- частота 2 МГц
.equ GPIO_APP50,        0b1011 # -//- частота 50 МГц
.equ GPIO_AOD10,        0b1101 # open-drain выход, максимальная частота 10 МГц
.equ GPIO_AOD2,     0b1110 # -//- частота 2 МГц
.equ GPIO_AOD50,        0b1111 # -//- частота 50 МГц

push-pull это режим порта, при котором внутренняя схема замыкает вывод либо на землю, либо на питание. То есть на выходе порта всегда либо 0, либо 1 в зависимости от содержимого регистра GPIOx_OCTL.

open-drain это режим, при котором внутренняя схема может замыкать только на землю, но не на питание. То есть на выходе либо 0, либо неизвестно что. Такой режим используется для соединения выводов в «монтажное И» либо, скажем, для I2C шины. Управляется регистром OCTL.

pull-up, pull-down это дополнительные подтягивающие резисторы, подключаемые либо между выводом и питанием (pull-up), либо между выводом и землей (pull-down). Они также управляются регистром GPIOx_OCTL.

На что влияет частота порта я не особенно особенно. Наверное, на максимальную частоту переключения и соответственно на потребляемый ток. То есть если хотите сделать устройство более экономичным, частоту снижаем. Я же здесь этого делать не буду.

3. Регистры и функции


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


псевдоним имя назначение сохранение
zero x0 Вечный и неизменный ноль n/a
ra x1 Адрес возврата нет
sp x2 Stack pointer, указатель стека да
gp, tp x3, x4 Регистры для нужд компилятора. Лучше их вообще не использовать n/a
t0-t6 x5-x7, x28-x31 Временные регистры нет
s0-s11 x8, x9, x18-x27 Рабочие регистры да
a0-a7 x10-x17 Аргументы функции нет
a0, a1 x10, x11 Возвращаемое значение функции нет

Регистр zero предназначен для получения нуля, либо для сбрасывания в него результата вычисления, которое нам не нужно. Аналог /dev/zero и /dev/null в одном лице
О регистрах ra и sp поговорим чуть-чуть позже.


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


Временные регистры t0 — t6 предназначены для хранения промежуточных результатов вычислений и не обязаны сохраняться при вызове функций. Это сделано для того чтобы простые функции не заморачивались сохранением всех регистров на стеке, а потом еще и восстановлением.


Рабочие регистры s0 — s11 напротив сохраняются при вызове функций. Они нужны для обратной задачи — воспользоваться ранее вычисленным значением и как-то объединить его с результатом функции и при этом опять же обойтись без лишнего использования стека.
Регистры обмена a0 — a7 используются для передачи параметров в функцию и обратно.
Очевидно, функция их должна менять, так что после вызова функции их значения не сохраняются. Интересно, что для возвращаемого значения используются только a0 и a1, а портить функции разрешено все.


О соглашениях использования регистров поговорили, пора функцию написать, а потом и вызвать. Примером функции будет задержка в 200`000 циклов, которая пока что вписана прямо в основной цикл программы. Давайте ее оформим как функцию. Принимать она должна время (в циклах) и ничего не возвращать. Отлично, значит аргумент будет храниться в a0. Помимо него можно как угодно портить a1 — a7 а также t0 — t6, но нам это пока без надобности. А вот остальные регистры портить нельзя, не забываем об этом.


Главное особенностью функций является то, что их код находится только в одном месте, но может вызываться из разных. Как же нам узнать в какую именно из точек вызова вернуться? Для этого соглашением предусмотрен специальный регистр ra, в который при выполнении соответствующей инструкции (jal, jalr или псевдоинструкции call, которая разворачивается в одну из предыдущих) происходит сохранение текущего адреса выполнения, после чего выполнение переходит на функцию. Соответственно, когда функция завершается, ей достаточно перейти по адресу, хранящемуся в ra при помощи инструкции jr ra или обертки ret. Так и запишем, не забыв заменить в теле функции регистр a5 на a0:


...  
  la a0, 200000
  call sleep
...
sleep:
  addi  a0, a0, -1
  bnez a0, sleep
ret

4. Стек


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


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


Из соображений стандартизации была выработана определенная логика работы стека: начинаться он должен с максимально доступного адреса и расти вниз, то есть в сторону меньших адресов. Адрес последнего элемента хранится в специальном регистре sp. В нашем GD32VF103 оперативная память начинается с адреса 0x2000'0000 и насчитывает 32 килобайта, то есть максимально доступный адрес 0x2000'8000, от него и будет расти наш стек.


Предположим, мы хотим положить в него байты 0x12, потом 0x34 и 0x56, а потом снять последний элемент со стека. Тогда логика их распределения будет следующей:


адрес Шаг 0 Шаг 1 Шаг 2 Шаг 3 Шаг 4
0x2000`8000 < sp
0x2000`7FFF 0x12 < sp 0x12 0x12 0x12
0x2000`7FFE 0x34 < sp 0x34 0x34 < sp
0x2000`7FFD 0x56 < sp 0x56

Обратите внимание, что на 4 шаге значение 0x56 никуда не делось, просто оно «вывалилось» за пределы стека и стало обычным мусором.


Примерно так работает стек в идеальном мире, но реальность накладывает свои ограничения. Так, у RISC-V имеются проблемы в работе с невыровненными данными, то есть адрес каждой ячейки должен быть кратен ее размеру. Нельзя, например, записать 4-байтное число по адресу 0x2000'0002 — только 0x2000'0000 или 0x2000'0004. Впрочем, основное для чего используется стек — хранение регистров при вызове функций, а регистры у нас как раз 4-байтные, так что просто сделаем так чтобы и стек принимал только 4-байтные значения.


Вторая проблема — прерывания. Мы до них пока не добрались, но рано или поздно доберемся и не хотелось бы получить граблями по лбу на ровном месте. Дело в том, что прерывания, как следует из названия, прерывают нормальный ход программы и заставляют контроллер в спешном порядке прыгать на специальную функцию обработчика прерываний. А произойти это может в любой момент времени, например между записью значения на стек и изменением sp. Что хуже всего, в системах без разделения прав доступа (а мы занимаемся именно такой) стек у основного кода и обработчиков прерываний общий. Это значит, что основной код должен быть написан так, чтобы прерывание, где бы оно ни возникло, не помешало его работе. В случае стека для этого достаточно всего лишь правильно определить порядок операций: мы сначала резервируем место на стеке (уменьшаем sp) и только потом записываем туда данные. С чтением аналогично: сначала данные читаем и только потом освобождаем память — увеличиваем sp.


Поскольку операции это частые, оформим их в виде макросов:


.macro push val
  addi sp, sp, -4
  sw \val, 0(sp)
.endm

.macro pop val
  lw \val, 0(sp)
  addi sp, sp, 4
.endm

Ах да, и не забудем в начале программы инициализировать значение sp верхней границей памяти:


la sp, 0x20008000

Теперь, если мы хотим оформить нашу функцию sleep совсем по фун-шую, можно сделать так:


sleep:
  push ra
  push s0

  mv s0, a0
sleep_loop:
  addi  s0, s0, -1
    bnez s0, sleep_loop

  pop s0
  pop ra
ret

Правда, смысла в этом именно для функции sleep немного: ей хватает регистра a0. Поэтому для демонстрации давайте сделаем вычисление через рекурсию факториа… НЕТ, нормальные люди факториал через рекурсию не вычисляют! Вместо этого сделаем какой-нибудь световой эффект на доступных нам диодах. Честно говоря, я сам не знаю по какому именно алгоритму они будут переключаться, но тем не менее рекурсия там используется. Пример получился немного странный, так что приводить его здесь я не буду.


Если развернуть макросы push и pop, можно увидеть, что в начале и конце функции регистр sp меняется по 4 раза. Налицо бесполезный расход машинного времени! Еще больше он станет если мы захотим положить на стек какой-нибудь большой массив данных. Но ведь нас никто не обязывает пользоваться именно этими макросами, мы можем сразу зарезервировать нужный объем памяти, даже с запасом, а потом класть туда данные, не заботясь об sp. Например, это можно сделать так:


func:
  addi sp, sp, -16
  sw ra, 12(sp)
  sw s0, 8(sp)
  sw s1, 4(sp)
  sw s2, 0(sp)
…
  lw s2, 0(sp)
  lw s1, 4(sp)
  lw s0, 8(sp)
  lw ra, 12(sp)
  addi sp, sp, 16
ret

Стоит отметить, что в отличие от «идеального» стека, которому можно либо положить значение на вершину, либо снять оттуда, но нельзя влезть в середину, наш стек реализован поверх обычной оперативной памяти, то есть мы можем там хранить обычные локальные переменные. Единственная проблема — их адрес зависит от значения sp на момент вызова функции, да плюс еще сама функция этот sp меняет. Чтобы уменьшить риск ошибки при подобном относительном доступе, соглашением предусматривается еще один специальный регистр fp — frame pointer (он же s0, так что сохранять его придется). При входе в функцию в него сохраняют значение sp, после чего больше не трогают. Сам sp, как и раньше, используется для работы со стеком, а вот fp служит опорной точкой, относительно которой вычисляются адреса локальных переменных.
Допустим, нам нужно выделить на стеке 5 переменных плюс регистры ra, fp и, например, s1, s2 и s3. Тогда код сохранения и восстановления может выглядеть так:


func:
  addi sp, sp, -10*4
  sw fp, 0(sp)
  addi fp, sp, 10*4
  sw ra, -9*4(fp)
  sw s1, -8*4(fp)
  sw s2, -7*4(fp)
  sw s3, -6*4(fp)
  sw zero, -5*4(fp) # — data[0]
  sw zero, -4*4(fp) # — data[1]
  sw zero, -3*4(fp) # — data[2]
  sw zero, -2*4(fp) # — data[3]
  sw zero, -1*4(fp) # — data[4]
…
  lw s3, -6*4(fp)
  lw s2, -7*4(fp)
  lw s1, -8*4(fp)
  lw ra, -9*4(fp)
  addi sp, fp, -10*4
  lw fp, 0(sp)
  addi sp, sp, 10*4
ret

Обратите внимание что не-регистровые данные мы просто инициализировали нулями (в реальном коде вместо этого можно использовать более осмысленные числа или не инициализировать их вообще), но явным образом освобождать не стали. Вместо этого мы сначала сохранили значение sp в fp, а в конце восстановили. Приятным бонусом оказывается то, что количество push'ей может даже не равняться количеству pop'ов и, если повезет, никто не пострадает.


Впрочем, работа через fp полезна скорее для людей. Компилятору же не составляет никакого труда все высчитывать через sp, а регистр fp использовать как обычный s0.


5. Храним данные на флешке


Использование стека отлично подходит для локальных переменных, которые не будут сохраняться между вызовами функций. Но иногда возникает необходимость использовать глобальные переменные и константы, доступные любой функции.


Начнем с простого — хранения массива констант. Для этого используется та же флеш-память, что и для исполняемого кода. Для удобства ее иногда выделяют в отдельный сегмент .rodata, но пока мы этим заниматься не будем. Просто объявим в конце нашей программы массив из 4 значений:


.text
led_arr:
  .short (0<<GLED | 0<<YLED | 1<<RLED)
  .short (0<<GLED | 1<<YLED | 0<<RLED)
  .short (1<<GLED | 0<<YLED | 0<<RLED)
  .short (0<<GLED | 1<<YLED | 0<<RLED)
led_arr_end:

Директива .short означает, что элемент памяти — короткое целое размером 2 байта. О других директивах резервирования места я расскажу чуть позже.


Ну и заменяем предыдущую рекурсивную мигалку на последовательное чтение из этого массива с выводом на светодиоды:


MAIN_LOOP:
  la s0, GPIOB_OCTL
  lh s1, 0(s0)
  la s2, ~(1<<GLED | 1<<YLED | 1<<RLED)

  la s3, led_arr
  la s4, led_arr_end
led_loop:
  lh t0, 0(s3)
  and s1, s1, s2
  or s1, s1, t0
    sh s1, 0(s0)

  la a0, 300000
  call sleep

  addi s3, s3, 2
  bltu s3, s4, led_loop

  j MAIN_LOOP

Здесь стоит отметить две вещи. Во-первых, замена lw на lh при работе с GPIOB_OCTL. Поскольку элементы данных в массиве 2-байтные, как и регистр GPIOB_OCTL, старшие байты вполне можно не писать, это немного сэкономит память. Во-вторых, увеличение адреса в массиве не на 1, а на размер элемента. Если бы мы использовали 32-битные константы, увеличивать пришлось бы на 4 байта, а если байтовые — то на 1.


6. Переход к оперативке


В прошлой главе я обмолвился о сегменте .rodata, еще раньше без объяснений ввел сегмент .text. Теперь введем еще два сегмента: .data и .bss. Они оба предназначены для хранения глобальных переменных, но первый инициализируется при включении заранее заданными данными, а второй — нет. Причем с .bss есть еще некая неопределенность: в некоторых источниках его инициализировать и не надо вообще, в других — надо обязательно, причем нулями. Хотя и не хочется заниматься бесполезным копированием нулей, для совместимости с Си сделать это придется.


Итак, берем предыдущий пример и вместо .text указываем .data, но не спешим прошивать контроллер. Для начала заглянем в дизассемблерный файл res/firmware.lss чтобы убедиться что массив начинается именно из начала оперативной памяти, 0x2000'0000:


000110ea <__DATA_BEGIN__>:
   110ea:   0020

Упс, что-то пошло не так. Очевидно, ассемблер не знает где у нашего контроллера начало оперативной памяти. Чтобы ему это указать, создадим файл lib/gd32vf103cbt6.ld, в котором пропишем следующее:


MEMORY{
    flash (rxai!w) : ORIGIN = 0x00000000, LENGTH = 128K
    ram (wxa!ri) : ORIGIN = 0x20000000, LENGTH = 32K
}

SECTIONS{
  .text : {
  } > flash

  .data : {
  } > ram

  .bss : {
  } > ram
}

То есть сначала мы указываем начало определенной памяти и ее размер, а потом принадлежность секций к той или иной памяти. Теперь этот файл нужно подсунуть компилятору (точнее, линкеру) при помощи ключа -T:


riscv64-unknown-elf-gcc -march=rv32imac -mabi=ilp32 -mcmodel=medany -nostdlib -T lib/gd32vf103cbt6.ld src/main.S -o res/main.elf

Вот теперь данные попали именно туда, куда надо:


20000000 <led_arr>:
20000000:   0020

Но прошивать полученным кодом контроллер все еще рано, ведь мы знаем, что оперативная память тем и отличается от постоянной, что может не сохраняться при отключении питания. Это значит, что перед работой основного кода нам в эту память надо сначала скопировать данные. Для этого компилятор заботливо сохранил наши константы в безымянном сегменте сразу после .text, это можно увидеть если посмотреть непосредственно res/firmware.hex файл.
:08 0000 00 2000 4000 8000 4000 D8


Для большего удобства доступа к этим данным добавим в .ld-файл немного магии


Заголовок спойлера
MEMORY{
    flash (rxai!w) : ORIGIN = 0x00000000, LENGTH = 128K
    ram (wxa!ri) : ORIGIN = 0x20000000, LENGTH = 32K
}

SECTIONS{
  .text : {
    *(.text*)
    *(.rodata*)
    . = ALIGN(4);
  } > flash

  .data : AT(ADDR(.text) + SIZEOF(.text)){
    _data_start = .;
    *(.data*)
    . = ALIGN(4);
    _data_end = .;
  } > ram

  .bss : {
    _bss_start = .;
    *(.bss*)
    . = ALIGN(4);
    _bss_end = .;
  } > ram
}

PROVIDE(_stack_end = ORIGIN(ram) + LENGTH(ram));
PROVIDE(_data_load = LOADADDR(.data));

Теперь мы можем использовать область флеш-памяти начиная с _data_load чтобы инициализировать собственно оперативку. Ах да, раз уж у нас есть внешний файл с адресами памяти, вынесем туда же стек:


_start:
  la sp, _stack_end
#copy data section
  la a0, _data_load
  la a1, _data_start
  la a2, _data_end
  bgeu a1, a2, copy_data_end
copy_data_loop:
  lw t0, (a0)
  sw t0, (a1)
  addi a0, a0, 4
  addi a1, a1, 4
    bltu a1, a2, copy_data_loop
copy_data_end:
# Clear [bss] section
  la a0, _bss_start
  la a1, _bss_end
  bgeu a0, a1, clear_bss_end
clear_bss_loop:
  sw zero, (a0)
  addi a0, a0, 4
    bltu a0, a1, clear_bss_loop
clear_bss_end:

Вот теперь наконец наш массив будет корректно читаться из оперативной памяти.


При создании переменных в секции .bss было бы странно присваивать им какие-то значения (хотя никто не запрещает, просто использованы они не будут). Вместо этого можно использовать директиву-заполнитель .comm arr, 10 (для переменной arr размером 10 байт). Стоит отметить, что использовать ее можно в любой секции, причем резервировать данные она будет только в .bss. Ниже приведены еще примеры объявления переменных различных размеров:


.byte 1, 2, 3 # три однобайтные переменные со значениями 0x01, 0x02 и 0x03
.short 4, 5 # две двухбайтные переменные со значениями 0x0004 и 0x0005
.word 6, 7 # две четырехбайтные переменные 0x0000'0006 и 0x0000'0007
.quad 100500 # одна восьмибайтная переменная 0x0000'0000'0001'8894
.ascii "abcd", "efgh" # две переменные по 4 символа (обратите внимание! Терминирующий ноль не добавляется)
.asciz "1234" # строка "1234\0" - с терминирующим нулем на конце. Обратите внимание что в имени директивы только одна буква 'i'
.space 10, 20 # ОДНА переменная размером 10 байт, каждый из которых равен 20. Если второй аргумент опущен, переменная по умолчанию заполняется нулями

Приложение: описания использованных директив ассемблера и его инструкций


директива аргументы описание
.align N выравнивание по 2^N. Например, .align 9 это выравнивание на 2^9 = 512 байт
.bss секция нулевых данных в ОЗУ
.data секция ОЗУ
.equ name, val присвоить макроконстанте name значение val. Например, .equ RLED, 5 заменит везде в тексте RLED на 5
.global name глобально видимое имя для стыковки с другими модулями
.macro / .endm name создание макроса по имени name
.section name войти в подсекцию name
.short N[, N[, N…]] объявить одну или несколько переменных размером 2 байта с заданными значениями
.text секция кода
.weak name “слабое” имя, которое может быть перекрыто другим
.word N[, N[, N…]] см. .short, только размер 4 байта

инструкция аргументы описание
add rd, r1, r2 rd = r1 + r2
addi rd, r1, N rd = r1 + N
and rd, r1, r2 rd = r1 & r2
andi rd, r1, N rd = r1 & N
beq r1, r2, addr if(r1==r2)goto addr
beqz r1, addr if(r1==0)goto addr
bgeu r1, r2, addr if(r1>=r2)goto addr
bgtu r1, r2, addr if(r1> r2)goto addr
bltu r1, r2, addr if(r1< r2)goto addr
bne r1, r2, addr if(r1!=r2)goto addr
bnez r1, addr if(r1!=0)goto addr
call func вызов функции func
csrr rd, csr rd = csr
csrrs rd, csr, N rd = csr; csr |= N, атомарно
csrs scr, rs csr |= rs
csrs scr, N csr |= N
csrw csr, rs csr = rs
ecall провоцирование исключения для входа в ловушку
j addr goto addr
la rd, addr rd = addr
lb rd, N(r1) считать 1 байт по адресу r1+N
lh rd, N(r1) считать 2 байта по адресу r1+N
li rd, N rd = N
lw rd, N(r1) считать 4 байта по адресу r1+N
mret возврат из обработчика исключения
mv rd, rs rd = rs
or rd, r1, r2 rd = r1 | r2
ori rd, r1, N rd = r1 | N
ret возврат из функции
sb rs, N(r1) записать 1 байт по адресу r1+N
sh rs, N(r1) записать 2 байта по адресу r1+N
slli rd, r1, N rd = r1 << N
srli rd, r1, N rd = r1 >> N
sw rs, N(r1) записать 4 байта по адресу r1+N
xor rd, r1, r2 rd = r1 ^ r2
xori rd, r1, N rd = r1 ^ N

Также checkpoint в комментариях дал ссылку на шпаргалку по инструкциям ассемблера.


Внезапный конец


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