Издеваться мы будем над микросхемой 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 |
Обратите внимание, что на 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, с прерываниями и как стыковать код на ассемблере и Си.
GarryC
Вообще то, на С нормального программиста Ваша строка
будет выглядеть совсем по другому, что то вроде
хотя еще лучше
COKPOWEHEU Автор
там ниже будет замена магических чисел 0b1111 и 0b0011 на константы. А в следующей части будет файл макросов, где это делается еще проще:
GarryC
Вот с таким вариантом я склонен согласится, но то, что Вы написали сначала — «трэш, угар и содомия».
COKPOWEHEU Автор
Это попытка пошагового описания от констант, указанных в даташите, к более-менее нормальному коду. Оно все — переходные варианты, поэтому много внимания им не уделял. Но вы правы, раз уж заменил номер порта на константу, надо было и битовую константу оформить по-человечески
wigneddoom
Это сишка курильщика. Здоровые атлеты используют K&R и KNF.