Издеваться мы будем над микросхемой GD32VF103CBT6, являющейся аналогом широко известной STM32F103, с небольшим, но важным отличием: вместо ядра ARM там используется ядро RISC-V. Чем это грозит нам, как программистам, попробуем разобраться. Кратко перечислю характеристики контроллера: Итак, разбираться с контроллером предлагаю с самого нуля — разработки платы и программирования ячеек памяти наэлектризованной иглой… Хотя нет, это уж слишком, обойдемся ассемблером. Все исходники, включая схему отладочной платы и примеры кода, можно найти здесь: https://github.com/COKPOWEHEU/GD32VF103_tutor  Первым делом — почему именно самодельная плата. Во-первых, из спортивного интереса: если заказывать разработку, сборку и все остальное в Китае, то где же твой собственный вклад? Да и просто удовольствие от ручной работы никто не отменял. Во-вторых, на самодельной плате можно вывести все нужные разъемы и элементы управления. Скажем, очень удобно когда в наличии всегда есть хотя бы пара кнопок и пара светодиодов плюс отладочный разъем UART. А вот сложная периферия вроде энкодеров, датчиков или дисплеев на подобной плате не нужна, ее лучше подключать к разъемам. В целом ничего особенно нового моя плата не представляет — разведены кварцы, кнопки, светодиоды, разъемы (на угловом висят PA0 — PA7 плюс пятивольтовое питание и земля, на двухрядном PB8 — PB15 плюс трехвольтовое питание и земля). Наличие пятивольтового питания на разъеме позволяет как запитывать плату от внешнего источника в обход usb (например, от переходника usb-uart), так и наоборот, запитывать от самой платы внешние схемы, которым недостаточно 3.3 В.  Некоторое внимание заострю на разъеме UART, точнее на его распиновке. В отличие от большинства «фирменных» плат он у меня симметричный, то есть в середине земля, а по краям Rx и Tx. Таким образом можно не запоминать «единственно верную» распиновку, и соединять любую пару устройств простым шлейфом без необходимости перекрещивать провода в нем.
Естественно, ноги Boot0 и Boot1 выведены на джамперы. Больше ничего интересного на плате нет. Разработчики данного контроллера предлагают скачать с их сайта некую IDE. Но мы этого делать не будем: только консоль, текстовый редактор и хардкор. Вот краткий список используемого софта. Что приятно, весь софт присутствует в репозитории, ничего качать с сайта GigaDevice не пришлось. Отдельно остановлюсь на прошивке контроллера. Основных способов три: (1). JTAG — теоретически, самый правильный способ. Вот только подобрать правильное заклинание для него мне так и не удалось
(2). Bootloader.UART — замыкаем вывод Boot0 на питание, ресетим контроллер (можно по питанию, можно вывести кнопку), после чего через stm32flash (да, прошивать можно утилитой, предназначенной для другого семейства!) прошиваем Ну и наконец притягиваем Boot0 обратно к земле, снова ресетим и смотрим как работает (или как именно не работает) программа
(3). Bootloader.USB — аналогичный предыдущему вариант, только вместо stm32flash используется dfu-util: Только надо помнить, что для USB важна стабильность тактовой частоты, поэтому если для наших первых опытов хватит встроенного RC-генератора, для USB придется поставить внешний кварц. Внимательный читатель может заметить, что утилите dfu-util передается некий адрес. Он соответствует началу реальной флеш-памяти контроллера. В нормальном режиме работы этот адрес отображается также и на нулевой адрес, и оттуда же начинается выполнение кода. Если же замкнуть Boot0 на питание, то на тот же нулевой адрес отображается либо Bootloader, либо оперативная память в зависимости от Boot1. В результате работать с контроллером можно вообще не задействуя его флеш, только из оперативки.  Совместимость с stm32f103 по выводам и части периферии дает некую надежду на возможность портирования кода оттуда без полной переработки. И действительно, простая периферия вроде SPI или DMA (без прерываний!) вполне успешно запустилась после небольших танцев с бубном.
В работе с регистрами стоит отметить, что GigaDevice предпочитают использовать макросы, в отличие от STMicroelectronics, которые использовали структуры. Плюс нумерация с нуля вместо единицы. Приведу пару примеров: Здесь сразу бросается в глаза что в 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 Стартует контроллер сразу после подачи питания, но за неимением стандартных средств ввода-вывода, привычных любому программисту — экрана, клавиатуры или хотя бы терминала — придется приложить некоторые усилия чтобы вообще определить живой камень или нет. Для этого традиционно используется мигание светодиодом (привет отладочным платам, на которых его нет!). Однако и это не так просто. В целях экономии энергии сразу при включении вся периферия, включая логику портов ввода-вывода, отключена. Чтобы ее включить надо выставить в регистре RCU_APB2EN (адрес 0x40021018) бит RCU_APB2EN_PxEN, где x — буква порта. В моем случае, поскольку светодиоды висят на PB5 — PB7 это бит RCU_APB2EN_PBEN (3-й бит, он же битовая маска 0x8). Причем было бы неплохо сохранить состояния всех остальных битов. Регистры a4, a5 взял просто от балды, никакого хитрого умысла тут нет. Можно было взять любые другие. Но если вы покажете такой код программисту, он тут же захочет дать вам по рукам за использование магических констант. Чтож, исправим это: Вот теперь код вполне пригоден для чтения. Но он по-прежнему не делает ничего полезного, ведь недостаточно логику портов ввода-вывода включить, ее еще нужно настроить. Согласно документации, режим работы порта задается четырьмя битами регистра GPIOх_CTL. Но поскольку ножек ввода-вывода у нас 16, получается 64 бита, а регистры всего 32-битные. Поэтому вслед за STmicroelectronics разработчики нашего контроллера разбили эту группу битов на два регистра. Для порта B это будут GPIOB_CTL0 и GPIOB_CTL1: в первом настраиваются порты PB0 — PB7, во втором PB8 — PB15. Четыре бита соответствуют 16 возможным состояниям, из которых нас пока интересует только обычный выход на максимальной скорости (на отладочной плате нет смысла экономить энергию). Также сразу укажем, что светодиоды висят на 5 — 7 выводах, а кнопки на 0 и 1: Как было сказано раньше, нулевому выводу соответствуют 4 бита регистра GPIOB_CTL0: [0, 1, 2, 3], первому биту [4, 5, 6, 7]. Соответственно, за интересующий нас 5-й бит, на котором висит красный светодиод, отвечают [20, 21, 22, 23]. Ну а чтобы не высчитывать биты вручную, заставим заниматься этим препроцессор. Если бы мы писали на Си, этот код выглядел бы так: То есть сначала нужные нам 4 биты затираются нулями, а потом на их место побитовым ИЛИ записывается новое значение. Но мы пока пишем не на Си, а на ассемблере, тут эта строчка получается чуть длиннее: Но и этого пока недостаточно для мигающего диода. Мы включили порт, настроили его. Осталось записать туда 0 или 1, подождать какое-то время и записать другое значение и так по кругу. За выходное значение порта отвечает регистр GPIOB_OCTL, который мы будем читать, XOR`ить 5-й бит и записывать обратно. Ну а задержку реализуем тупо вычитанием единицы из регистра счетчика. Число 200000 в цикле задержки было подобрано экспериментально. Именно столько итераций нужно чтобы светодиод мигал не слишком быстро и не слишком медленно. Сразу же отмечу, что при ручном управлении ножками порта использовать OCTL не рекомендуется, поскольку работа с ним возможна только в режиме чтение-модификация-запись. Плюс в середину может вклиниться прерывание (о чем поговорим позже), но для отладочной мигалки сойдет. Правильным же способом является использование регистра GPIOx_BOP: старшие 16 бит отвечают за стирание битов OCTL в 0, а младшие — за выставление в 1. Есть еще регистр GPIOx_BC, эквивалентный старшим битам BOP, так что я не слишком понимаю зачем он нужен. Для оптимизации разве что. Причем важно отметить, что влияние на эти регистры оказывает только запись единиц. Запись нулей ни на что не влияет. То есть если мы запишем то желтый светодиод загорится, а красный погаснет.
Но повторюсь, в ассемблерных примерах мы этого делать не будем, тут хватит OCTL`а.
Скомпилировать полученный код можно стандартным компилятором gcc: Флаги в начале указывают точную архитектуру и расширения нашего ядра, флаг -nostdlib специфичен для компилятора Си (а не ассемблера) и говорит не подставлять стандартный Си`шный код инициализации памяти. Теперь полученного эльфа надо сконвертировать в бинарный формат чтобы утилита прошивки могла с ним работать. Также дизассемблируем его чтобы посмотреть, как препроцессор развернет наши инструкции, адреса и константы (это более актуально для кода на Си, но и нам сгодится), ну и собственно прошить: Для удобства я позволил себе оформить все эти команды в общий makefile. Не забывайте, что в современных системах доступ к COM и USB портам считается опасным действием и разрешен только руту. Впрочем, повседневное написание прошивок для платки под рутом еще опаснее, поэтому лучше добавить своего пользователя в группу dialout. Если же вы предпочтете пользоваться прошивкой по USB через dfu-utils, нужно прописать правило udev для устройства 28e9:0189. В общем-то тут ничего особенно нового нет, просто добавляется регистр чтения состояния порта GPIOB_ISTAT, каждый бит которого равен логическому уровню соответствующей ножки, и немного логики для работы с ним. Полный код приводить здесь уже не буду. Но чтобы раздел не получился совсем уж пустым, приведу таблицу режимов работы порта: До сего момента мы пользовались регистрами как попало. Настало время поговорить об их роли в нашем контроллере. С одной стороны все они равноправны, для любой цели можно использовать любой. Но с другой, явное назначение некоторым из них специальных функций упрощает стыковку отдельных подпрограмм. Итак: Регистр 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: Но что же делать если мы пишем рекурсивную функцию, которая должна вызывать сама себя? Ведь все копии будут пользоваться одними и теми же регистрами, что явно не пойдет алгоритму на пользу. Или что делать если функция использует большой объем временных данных, который в регистрах просто не помещается? Для решения обеих этих проблем умными людьми была придумана концепция стека, то есть специальным образом организованной оперативной памяти, в которой каждой функции передается начало свободного участка оперативки, и она резервирует непрерывный кусок данных под свои потребности. Когда она вызывает другую функцию, она передает ей начало оставшейся свободной памяти, та резервирует что-то себе и так далее. В результате распределение памяти напоминает матрешку: внешней функции доступна вся память, функциям первого уровня вложенности вся, кроме блока, занятого внешней, функциям второго — то, что осталось от первых и так далее. После завершения работы каждая функция возвращает использованную память вызывающей стороне. Такой подход очень эффективен по скорости и памяти, но обладает рядом ограничений, которые нам пока мешать не будут.
Помимо абстрактных данных на стек можно сбрасывать регистры, которые могут быть испорчены вызываемыми функциями. Из соображений стандартизации была выработана определенная логика работы стека: начинаться он должен с максимально доступного адреса и расти вниз, то есть в сторону меньших адресов. Адрес последнего элемента хранится в специальном регистре sp. В нашем GD32VF103 оперативная память начинается с адреса 0x2000'0000 и насчитывает 32 килобайта, то есть максимально доступный адрес 0x2000'8000, от него и будет расти наш стек. Предположим, мы хотим положить в него байты 0x12, потом 0x34 и 0x56, а потом снять последний элемент со стека. Тогда логика их распределения будет следующей: Обратите внимание, что на 4 шаге значение 0x56 никуда не делось, просто оно «вывалилось» за пределы стека и стало обычным мусором. Примерно так работает стек в идеальном мире, но реальность накладывает свои ограничения. Так, у RISC-V имеются проблемы в работе с невыровненными данными, то есть адрес каждой ячейки должен быть кратен ее размеру. Нельзя, например, записать 4-байтное число по адресу 0x2000'0002 — только 0x2000'0000 или 0x2000'0004. Впрочем, основное для чего используется стек — хранение регистров при вызове функций, а регистры у нас как раз 4-байтные, так что просто сделаем так чтобы и стек принимал только 4-байтные значения. Вторая проблема — прерывания. Мы до них пока не добрались, но рано или поздно доберемся и не хотелось бы получить граблями по лбу на ровном месте. Дело в том, что прерывания, как следует из названия, прерывают нормальный ход программы и заставляют контроллер в спешном порядке прыгать на специальную функцию обработчика прерываний. А произойти это может в любой момент времени, например между записью значения на стек и изменением sp. Что хуже всего, в системах без разделения прав доступа (а мы занимаемся именно такой) стек у основного кода и обработчиков прерываний общий. Это значит, что основной код должен быть написан так, чтобы прерывание, где бы оно ни возникло, не помешало его работе. В случае стека для этого достаточно всего лишь правильно определить порядок операций: мы сначала резервируем место на стеке (уменьшаем sp) и только потом записываем туда данные. С чтением аналогично: сначала данные читаем и только потом освобождаем память — увеличиваем sp. Поскольку операции это частые, оформим их в виде макросов: Ах да, и не забудем в начале программы инициализировать значение sp верхней границей памяти: Теперь, если мы хотим оформить нашу функцию sleep совсем по фун-шую, можно сделать так: Правда, смысла в этом именно для функции sleep немного: ей хватает регистра a0. Поэтому для демонстрации давайте сделаем вычисление через рекурсию факториа… НЕТ, нормальные люди факториал через рекурсию не вычисляют! Вместо этого сделаем какой-нибудь световой эффект на доступных нам диодах. Честно говоря, я сам не знаю по какому именно алгоритму они будут переключаться, но тем не менее рекурсия там используется. Пример получился немного странный, так что приводить его здесь я не буду. Если развернуть макросы push и pop, можно увидеть, что в начале и конце функции регистр sp меняется по 4 раза. Налицо бесполезный расход машинного времени! Еще больше он станет если мы захотим положить на стек какой-нибудь большой массив данных. Но ведь нас никто не обязывает пользоваться именно этими макросами, мы можем сразу зарезервировать нужный объем памяти, даже с запасом, а потом класть туда данные, не заботясь об sp. Например, это можно сделать так: Стоит отметить, что в отличие от «идеального» стека, которому можно либо положить значение на вершину, либо снять оттуда, но нельзя влезть в середину, наш стек реализован поверх обычной оперативной памяти, то есть мы можем там хранить обычные локальные переменные. Единственная проблема — их адрес зависит от значения sp на момент вызова функции, да плюс еще сама функция этот sp меняет. Чтобы уменьшить риск ошибки при подобном относительном доступе, соглашением предусматривается еще один специальный регистр fp — frame pointer (он же s0, так что сохранять его придется). При входе в функцию в него сохраняют значение sp, после чего больше не трогают. Сам sp, как и раньше, используется для работы со стеком, а вот fp служит опорной точкой, относительно которой вычисляются адреса локальных переменных.
Допустим, нам нужно выделить на стеке 5 переменных плюс регистры ra, fp и, например, s1, s2 и s3. Тогда код сохранения и восстановления может выглядеть так: Обратите внимание что не-регистровые данные мы просто инициализировали нулями (в реальном коде вместо этого можно использовать более осмысленные числа или не инициализировать их вообще), но явным образом освобождать не стали. Вместо этого мы сначала сохранили значение sp в fp, а в конце восстановили. Приятным бонусом оказывается то, что количество push'ей может даже не равняться количеству pop'ов и, если повезет, никто не пострадает. Впрочем, работа через fp полезна скорее для людей. Компилятору же не составляет никакого труда все высчитывать через sp, а регистр fp использовать как обычный s0. Использование стека отлично подходит для локальных переменных, которые не будут сохраняться между вызовами функций. Но иногда возникает необходимость использовать глобальные переменные и константы, доступные любой функции. Начнем с простого — хранения массива констант. Для этого используется та же флеш-память, что и для исполняемого кода. Для удобства ее иногда выделяют в отдельный сегмент .rodata, но пока мы этим заниматься не будем. Просто объявим в конце нашей программы массив из 4 значений: Директива .short означает, что элемент памяти — короткое целое размером 2 байта. О других директивах резервирования места я расскажу чуть позже. Ну и заменяем предыдущую рекурсивную мигалку на последовательное чтение из этого массива с выводом на светодиоды: Здесь стоит отметить две вещи. Во-первых, замена lw на lh при работе с GPIOB_OCTL. Поскольку элементы данных в массиве 2-байтные, как и регистр GPIOB_OCTL, старшие байты вполне можно не писать, это немного сэкономит память. Во-вторых, увеличение адреса в массиве не на 1, а на размер элемента. Если бы мы использовали 32-битные константы, увеличивать пришлось бы на 4 байта, а если байтовые — то на 1. В прошлой главе я обмолвился о сегменте .rodata, еще раньше без объяснений ввел сегмент .text. Теперь введем еще два сегмента: .data и .bss. Они оба предназначены для хранения глобальных переменных, но первый инициализируется при включении заранее заданными данными, а второй — нет. Причем с .bss есть еще некая неопределенность: в некоторых источниках его инициализировать и не надо вообще, в других — надо обязательно, причем нулями. Хотя и не хочется заниматься бесполезным копированием нулей, для совместимости с Си сделать это придется. Итак, берем предыдущий пример и вместо .text указываем .data, но не спешим прошивать контроллер. Для начала заглянем в дизассемблерный файл res/firmware.lss чтобы убедиться что массив начинается именно из начала оперативной памяти, 0x2000'0000: Упс, что-то пошло не так. Очевидно, ассемблер не знает где у нашего контроллера начало оперативной памяти. Чтобы ему это указать, создадим файл lib/gd32vf103cbt6.ld, в котором пропишем следующее: То есть сначала мы указываем начало определенной памяти и ее размер, а потом принадлежность секций к той или иной памяти. Теперь этот файл нужно подсунуть компилятору (точнее, линкеру) при помощи ключа -T: Вот теперь данные попали именно туда, куда надо: Но прошивать полученным кодом контроллер все еще рано, ведь мы знаем, что оперативная память тем и отличается от постоянной, что может не сохраняться при отключении питания. Это значит, что перед работой основного кода нам в эту память надо сначала скопировать данные. Для этого компилятор заботливо сохранил наши константы в безымянном сегменте сразу после .text, это можно увидеть если посмотреть непосредственно res/firmware.hex файл.
:08 0000 00 2000 4000 8000 4000 D8 Для большего удобства доступа к этим данным добавим в .ld-файл немного магии Теперь мы можем использовать область флеш-памяти начиная с _data_load чтобы инициализировать собственно оперативку. Ах да, раз уж у нас есть внешний файл с адресами памяти, вынесем туда же стек: Вот теперь наконец наш массив будет корректно читаться из оперативной памяти. При создании переменных в секции .bss было бы странно присваивать им какие-то значения (хотя никто не запрещает, просто использованы они не будут). Вместо этого можно использовать директиву-заполнитель .comm arr, 10 (для переменной arr размером 10 байт). Стоит отметить, что использовать ее можно в любой секции, причем резервировать данные она будет только в .bss. Ниже приведены еще примеры объявления переменных различных размеров: Также checkpoint в комментариях дал ссылку на шпаргалку по инструкциям ассемблера. Не хотелось разбивать статью на две, но что поделать. В следующей части рассмотрим как работать с отладочным портом UART, с прерываниями и как стыковать код на ассемблере и Си.