COKPOWEHEU

View on GitHub

Практическое применение RISC-V при программировании микроконтроллеров

(Оглавление)

7. Переход на Си

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

7.1 Вызов функции на Си

При программировании на Си, языком описания алгоритма становится именно он, тогда как ассемблер выполняет исключительно служебные задачи - инициализацию секций памяти, настройку аппаратно-зависимых регистров и тому подобного. В соответствии с назначением, главный Си-шный файл будет называться main.c, а стартовый ассемблерный - startup.S. Как и принято в Си, точкой входа является функция main(), в которой будет расположен какой-то “полезный” код. Например, мигалка светодиодом. Доступ к периферии по сравнению с ассемблером ничуть не изменился: пишем нужные чиселки по нужным адресам. В нашем примере это будет выглядеть примерно так:

#include <stdint.h>
int main(){
  while(1){
    *((uint32_t*)(0x40010C0C)) ^= (1<<5); //GPIOB_OCTL ^= (1<<RLED);
    for(uint32_t i=0; i<100000; i++)asm volatile("nop"); //цикл задержки, чтобы не слишком быстро мигало
  }
}

Эту функцию ассемблерный код и должен вызвать после окончания инициализации. Согласно стандарту, ее прототип выглядит как int main(int argc, char *argv[]), то есть функция принимает из окружения два параметра и возвращает один. Вот только окружения у нас нет: код стартует сразу при подаче питания. Ну чтож, значит там будут нули. И возврата в окружение также не предвидится, поэтому вместо call обойдемся j:

li a0, 0
li a1, 0
j main

Осталось правильно настроить заклинание компиляции. Для файлов на Си оно, естественно, будет отличаться от ассемблерных. Приводить его здесь смысла не вижу, проще посмотреть в makefile. Ну и разумеется, добавить второй исходник: не только startup (бывший main), но и новый main.

На этом этапе мы получили возможность вызывать “обычный” Си-шный код из ассемблера. Перейдем к “необычному”.

7.2 Обработчик прерывания

Как мы помним, в RISC-V конвенции для обычных подпрограмм и исключительных ситуаций сильно отличаются. Подпрограммы имеют право не восстанавливать регистры t0 - t6, a0 - a7¹ и возвращаются по ret, тогда как прерывания не имеют права портить вообще никакие регистры общего назначения, а возвращаются по mret².

Чтобы дать компилятору понять, что данная функция является именно прерыванием, в gcc используется атрибут __attribute__((interrupt)), его и используем для описания обработчика прерывания. Логика его работы не будет отличаться от ассемблерного, ну разве что работа с регистрами снова будет в стиле Си.


1) в видеоматериале я оговорился будто процедура портит сохраняемый регистр. Нет, она портит регистр аргумента, и имеет на это полное право. Точнее, имела бы, будь она процедурой, а не прерыванием

2) кстати, в ARM работа с прерываниями организована по-другому. При входе контроллер аппаратно сохраняет все временные регистры на стеке, а при выходе восстанавливает. Таким образом, различия между подпрограммой и обработчиком прерывания на уровне ассемблера там вообще нет

7.3 Человеко-читаемые имена регистров

Здесь самое время вспомнить, что контроллеры предназначены для решения реальных задач, а не изучения ядра RISC-V. Соответственно, производитель заинтересован в как можно более простом написании кода под свою железку. Увы, некоторые доходят до невменяемости: “вот вам готовые библиотеки, а как они устроены внутри, вас не волнует”, или даже “вот вам скриптовый язык, из которого можете дергать наши подпрограммы”. К счастью, библиотека для gd32vf103 к таким не относится. Скачать ее можно прямо на официальном сайте производителя в разделе документации по интересующему контроллеру. Причем помимо собственно библиотеки, там находятся еще и примеры, и шаблоны проектов для нескольких IDE (я, правда, не слишком разобрался, как их запускать, да и не слишком-то пытался: makefile и консоль проще). Из них нас интересует каталог Firmware, в котором собственно и прописаны регистры. Причем прописаны достаточно интересным способом. Например, GPIOB_OCTL выглядит как

GPIO_OCTL(GPIOB)

Если сравнить с записью от stm32, GPIOB->OCTL, видна некоторая избыточность, но зато gd-шная больше соответствует стилю Си (нет натягивания структуры на сырые бинарные данные), да и на макросы она ложится лучше.

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

ВАЖНО В видеоматериале я прописал макроконстанту -DGD32VF103C_START, это неправильно. Такая запись служит указанием, что мы используем отладочную плату от производителя. То есть этот макрос описывает всю обвязку. Для самодельной платы лучше указать только частоту внешнего кварца (хм, а что делать, если его нет?). В нашем случае это флаг -DHXTAL_VALUE=8000000

7.4 Функции ECLIC

Поскольку мы хотим оставить в ассемблерном файле только общую инициализацию, надо перенести в Си-шный как настройки каждой конкретной периферии, так и настройки относящихся к ней eclic. Обращаю внимание: переносить имеет смысл только регистры, относящиеся к данной конкретной периферии - разрешение прерывания, векторный/не-векторный режим, приоритет и т.п. Описаны они в Firmware/RISCV/drivers/n200_func.h, причем в довесок к заголовочнику идет еще и n200_func.c, который тоже придется прописывать в makefile.

Помните, нам пришлось прописывать для каждого обработчика прерываний __attribute__((interrupt))? Это чревато ошибками (а вдруг забудешь?), поэтому имеет смысл заранее прописать для них всех прототипы. Как ни странно, готового заголовочника с этой функциональностью я не нашел, так что пришлось писать свой - interrupt_util.h.

Заодно можно причесать под одну гребенку и универсальные обработчики - ловушек, не-векторизованных и немаскируемых прерываний. И тут же добавить страховку от ошибок. Если обработка прерывания разрешена, но собственно обработчика нет, программа должна суметь самостоятельно узнать о проблеме. В принципе, устройство таблицы векторов само по себе неплохо решает эту задачу, ведь вместо неиспользуемых обработчиков там записаны нули. То есть при переходе туда произойдет переход по нулевому адресу, который эквивалентен ресету. Но можно и усложнить такую систему, явным образом указав специальный обработчик (у меня он называется UnhandledInterruptHandler), поведение которого по умолчанию еще более суровое: вместо сброса он зацикливается сам на себе. В реальных проектах туда можно положить, например, запись логов или переход в отладочный режим.

ВАЖНО В видеоматериале это реализовано неправильно. Также там ошибка с адресом mmisc_ctl, его битом способа реакции на прерывание и csrs mnvec, t0 вместо csrw mnvec, t0

7.5 Порядок расположения секций памяти

По большому счету критичным является только расположение таблицы векторов прерываний, ведь именно там прописан адрес начала выполнения, и то вместо него допустимо расположить начало ассемблерного кода. Выполнение начнется с нулевого адреса, а далее будет управляться явными или неявными переходами. Тем не менее, лучше будет явно задать порядок расположения секций в памяти: сначала таблица векторов прерываний, потом стартап, основной код, и в самом конце константы для rodata и инициализации data. И почти все это в нашем проекте уже реализовано, надо только вынести стартап в отдельную секцию и расположить ее сразу после векторами прерываний. Это делается правкой *.ld файла.

Кстати, такой подход кажется гораздо более удачным, чем в stm32. Там таблица векторов обязательно должна быть расположена строго в начале выполнения, поскольку ее первое машинное слово - адрес начала кода, а не первая инструкция, как у нас. Более того, второе машинное слово там тоже играет особую роль, это начальное значение sp. Иначе говоря, в ARM нельзя просто взять и начать писать машинные инструкции с нулевого адреса и пока память не кончится. А в RISC-V можно. Упомянутое мной ограничение, что лишь бы не Си-шный код обусловлено только тем, что он не самодостаточен и сам нуждается в инициализации.

Ну и для полной красоты, теперь, когда в startup.S не осталось ничего интересного, можно спрятать его куда-нибудь в lib/, чтобы не отвлекал.

7.6 Препроцессорные извращения

ВАЖНО Далее описывается лично мой подход к написанию кода, нигде больше вы его не увидите. Следовать ли ему или нет, личное дело каждого. Я его использую потому, что он достаточно прост, выразителен и компактен, в отличие от ST-шных аналогов вроде ST-HAL или ST-CMSIS. А может, это говорит моя тяга к велосипедизму…

Прямая настройка портов через регистры выглядит примерно так:

uint32_t temp = GPIO_CTL1(GPIOA);
temp &=~((GPIO_MASK<<(4*(USART_TX-8))) | (GPIO_MASK<<(4*(USART_RX-8))));
temp |= (GPIO_ALT<<(4*(USART_TX-8))) | (GPIO_INP<<(4*(USART_RX-8)));
GPIO_CTL1(GPIOA) = temp;

Эта запись довольно громоздка и плохо поддается настройке. Ну, например, на отладочной плате у меня светодиод на PB5, а в финальном устройстве на PC13 - это ведь придется весь код перелопачивать в поисках каждого упоминания. Поэтому я давным-давно, еще для AVR, написал библиотеку работы с портами pinmacro.h. Думаю, пример ее использования будет достаточно нагляден:

#define LED	B,5,1,GPIO_PP50 //PB5, активный уровень лог.1, режим push-pull 50 МГц
#define BTN	B,0,0,GPIO_HIZ //PB0, активный уровень лог.0, режим Hi-Z (высокоомный вход)
...
GPIO_config(LED);
GPIO_config(BTN);
while(1){
  if(GPI_ON(BTN))GPO_ON(LED); else GPO_OFF(LED);
}

Люди, знакомые с макросами, могут заметить, что здесь работа с регистрами портов идет для каждого вывода независимо, то есть по сути многократно дублируется. Но, во-первых, инициализация выполняется единственный раз при старте контроллера, то есть в том месте, где задержки не критичны. А во-вторых, ручная работа с отдельными линиями (так называемый “ногодрыг”) был основным занятием слабых 8-битных контроллеров. А 32-битные уже обладают настолько развесистой периферией, что огромный класс задач способны решать вообще без участия ядра и явной работы с портами. В любом случае, никто не мешает для конкретного применения от макроса отказаться, и дергать регистры вручную. Кстати, я это делал при разработке “каракатицы”: у нее наружу торчит 16-битная “шина”, составленная из PA0-PA7 и PB8-PB15, так вот, работа с ней ведется именно прямой записью в регистры ODR (это stm-ный аналог OCTL), а не макросами.

Заключение

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

Также считаю важным предостеречь от использования стандартной библиотеки Си: похоже, та версия, что находится в репозитории, заточена исключительно под 64-битные процессоры, и реализации даже базовых функций для 32-биток там попросту нет. Впрочем, если найдете или напишете альтернативную реализацию - флаг в руки!

Исходный код примера доступен на github

UPD:

Уже разбираясь с другим примером обнаружил, что глобальные переменные не инициализируются. Дело в том, что компилятор Си располагает их не в секции .data, а в .sdata. Соответственно, надо ее добавить в скрипт сборки:

UPD2:

Разбираясь дальше обнаружил, что объявлять main как naked нельзя: в таком состоянии она выделяет локальные переменные над доступным стеком.

_data_start = .;
*(.data*)
*(.sdata)
. = ALIGN(4);
_data_end = .;

Д/З

  1. Воспроизвести сделанное ранее для UART - неблокирующий ввод-вывод на прерывании. Хорошо бы вынести его в отдельную библиотеку, поскольку в реальных устройствах он используется постоянно, и проще перенести uart.h, чем каждый раз копипастить куски из исходника.
  2. Прочитать про ассемблерные вставки (включая передачу параметров, разумеется) и реализовать их на Си. Например, периодически посылать на UART содержимое регистров sp, ra и mepc чтобы убедиться, что сохранения и восстановления работают исправно.
  3. Восстановить работу обработчика исключений, в частности проверку длины инструкции, на которой произошло исключение.

CC BY 4.0