Продолжаем погружаться в строение контроллера GD32VF103CBT6. Теперь рассмотрим как он может обрабатывать прерывания работать под управлением высокоуровневого кода.
Первая часть здесь При выборе задачки на рекурсию я столкнулся с проблемой, что трех светодиодов немного недостаточно для отладки сложных алгоритмов. Поэтому давайте добавим полноценный отладочный интерфейс, к которому можно было бы прицепиться терминальной программой вроде screen и общаться с контроллером при помощи обычного текста. Для этого воспользуемся тем же USART0, по которому происходит прошивка. Небольшое отступление, связанное с терминологией: USART (универсальный синхронно-асинхронный приемо-передатчик), как следует из названия, умеет работать как в синхронном, так и в асинхронном режимах. А еще в куче других, но они нам пока не интересны. На практике я ни разу не видел его работу в синхронном режиме. Поэтому наравне с USART буду использовать обозначение UART, подразумевая именно асинхронный режим. Как и с портами, первым делом надо разрешить работу данного модуля. Смотрим в документации, какому биту RCU он соответствует и видим 14-й бит RCU_APB2EN_USART0EN. Следующая особенность GD32VF103 вслед за STM, это необходимость переключения режима работы ножки вывода с обычного GPIO на альтернативную функцию, активируемую значением GPIO_APP50 = 0b1011. Причем только на выход: входная ножка остается обычным GPIO_HIZ. Ах да, в RCU саму возможность работы альтернативных функций тоже придется включить. Делается это 0-м битом, он же RCU_APB2EN_AFEN. А вот сама настройка UART не представляет ничего сложного: в регистре USART0_CTL0 мы просто разрешаем его работу (USART_CTL0_UEN), включаем передатчик (USART_CTL0_TEN) и приемник (USART_CTL0_REN), после чего в регистре USART0_BAUD задаем скорость обмена как делитель тактовой частоты. Если точнее, не тактовой частоты, а только частоты шины APB2, но пока мы не разбирались с тактированием, частоты всех шин у нас одинаковые и равны 8 МГц: Что интересно, в документации установка делителя сделана довольно сложным способом с разделением регистра на две части… но по сути это всего лишь дробное число с фиксированной точкой, так что простое деление тоже работает. Ну а отправка байта осуществляется просто записью его в USART0_DATA. Как и в предыдущих случаях, прошиваем контроллер, но перед проверкой работы запускаем на компьютере чтобы убедиться что буква 'S' принимается. Для выхода из screen, надо нажать ctrl+a, потом k, потом y. Обычно передача данных между устройствами не ограничивается одним байтом. А в случае UART можно даже еще больше конкретизировать: обмен идет строками. Значит, и функции обмена будем затачивать на работу со строками. Проблема тут в том, что UART штука медленная: в предыдущем примере мы выставляли всего 9600 бит в секунду, в жизни еще часто встречается 115200. По сравнению даже с 8 МГц частоты ядра, а тем более с 108 МГц это очень долго, значит нам придется подождать пока модуль передаст один байт, чтобы тут же подложить ему следующий. Для этого служит флаг USART_STAT_TBE (Transmit data buffer empty) регистра USART0_STAT. Таким образом псевдокод на Си будет выглядеть следующим образом: На ассемблере он выглядит аналогично, единственное что займет немножко больше места, у меня вышло 14 строк. Таким же способом пишется функция чтения строки, только флаг будем проверять USART_STAT_RBNE (Read data buffer not empty), а конец ввода определять по одному из символов '\r' или '\n' либо по переполнению буфера. В качестве примера работы с UART можно ввести с терминала строку, преобразовать все строчные буквы с прописные, после чего отправить обратно. Проблема только что описанного способа работы с периферией в том, что на время передачи или приема блокируется вообще все. Причем если на передачу мы можем хотя бы предсказать, какое время это займет, то на прием не можем даже этого. Мало ли, вдруг пользователь уснул за клавиатурой? Простейший способ это исправить — переписать функции, чтобы они при неготовности интерфейса возвращали управление основной программе, а не крутились в цикле. Такой подход называется опросом (polling) и вполне себе используется для медленных и низкоприоритетных устройств. То есть тех, которые могут потерпеть пока до них дойдет очередь опроса. Реализация такого подхода для UART сложности не представляет, поэтому я ее приводить не буду, а вместо этого рассмотрю более сложный способ — использование прерываний. Этот способ заключается в том, что при наступлении определенного события, от ошибки деления на ноль до получения здоровенного пакета по USB, генерируется сигнал прерывания. И если это прерывание разрешено локально, разрешено глобально, разрешено на данной периферии и не перекрыто более приоритетным прерыванием (да-да, выполнены должны быть все эти условия), то контроллер спешно бросает выполнение основного кода и переходит к выполнению обработчика прерываний. К чему это может привести? Самый очевидный плюс — скорость реакции на внешнее событие, которая может составлять единицы тактов. Но рядом со вкусным сыром прячутся грабли: если прерываний слишком много, у контроллера просто не останется ресурсов на выполнение основного кода. Впрочем, такая ситуация в любом случае не должна происходить в грамотно спроектированной системе.
Особенность RISC-V в отличие от многих других контроллеров: поскольку ядро знать не знает что такое стек, оно не может аппаратно сохранить туда контекст выполнения, то есть адрес возврата (нам же нужно знать откуда продолжить работу после завершения обработки прерывания), используемые регистры и все остальное. Поэтому программисту приходится самому думать, куда это все сохранить и при этом не попортить данные основной программы. По большому счету варианта три: либо использовать общий стек, либо раздельный, либо специальные регистры. Специальных регистров архитектурой не предусмотрено, вводить еще одну область памяти и способы работы с ней сложно, поэтому будем пользоваться общим стеком. Нет, в более сложных системах с разделением прав доступа, с планировщиком задач и вообще операционной системой, выделение специального ядерного стека встречается часто. Ну правда, если кривая пользовательская программа убьет себе стек и данные, это плохо, но только для нее. Но если она убьет стек и данные не только себе, но и ядру, пострадают все.
Еще раз напоминаю, что при использовании общего стека надо следить за его целостностью, то есть чтобы запись и чтение никогда не шли ниже sp. Переходим собственно к прерываниям, а также ловушкам и другим исключительным ситуациям. В контроллере GD32VF103 за них отвечает модуль eclic (Enhanced Core Local Interrupt Controller). Он позволяет гибко настроить способ обработки прерываний, приоритеты и многое другое. Мы начнем, как всегда, с простого варианта, но не упустим шанс все себе усложнить. Собственно исключительные ситуации делятся на три типа: немаскируемые (NMI), ловушки (traps) и прерывания (interrupts). Немаскируемые исключения это слишком страшная штука, трогать мы их не будем. Ловушки срабатывают либо если они были заранее поставлены в нужном месте (точка останова breakpoint или специальные инструкции ecall и ebreak), либо если ядро попыталось выполнить невыполнимое и допустить недопустимое. Скажем, прочитать или записать по невыровненному адресу (не кратному размеру переменной) или выполнить незнакомую инструкцию. Ну а прерывания это события от внешних устройств вроде того же UART`а. Настройка контроллера прерываний eclic осуществляется при помощи кучи специальных регистров. Причем они настолько специальные, что даже не отображаются на память. Доступ к ним возможен при помощи специальных команд вроде scrr (чтение) или scrw (запись). Первым из таких регистров, необходимых для настройки, является mtvec. Старшие 26 битов его отвечают за хранение адреса обработчика прерывания, а младшие 6 — за режим работы: волшебное число 3 включает eclic, а любое другое — не включает, то есть заставляет использовать предыдущую версию обработчика прерываний clic (для совместимости ее оставили что ли?): Зануление младших битов означает, что при размещении обработчика исключений в памяти надо удостовериться, что в шести младших битах его реального адреса также находятся нули, то есть он выровнен по 64-битной сетке. Это делается директивой ассемблера .align 6: Пока мы хотим работать только с прерываниями от UART`а, можно не заморачиваться с разбором что же произошло на самом деле. Считаем, что в обработчик мы могли попасть только из-за него. Прерывание, которое проще всего проверить — по опустошению буфера передатчика. За его работу в модуле UART отвечает бит USART_CTL0_TBEIE, а как видно из кода, в прерывании мы его снимаем. То есть при входе в прерывание оно тут же запрещает само себя чтобы не уйти в бесконечный цикл. Ну и отправляет символ 'I' чтобы изобразить бурную деятельность. И зеленым светодиодиком мигнет с той же целью. В реальной жизни, естественно, программисты вписывают туда что-то более осмысленное. Но если мы просто выставим бит USART_CTL0_TBEIE в регистре USART0_CTL0, мы выполним только одно из условий срабатывания прерывания. Помимо этого надо разрешить это же прерывание внутри контроллера eclic и разрешить обработку прерываний глобально. Это делается следующим кодом: Рассмотрим что здесь происходит. По адресам ECLIC_ADDR_BASE + ECLIC_INT_IP_OFFSET находится массив четверок регистров, отвечающих за каждый номер прерывания. В виде псевдокода на Си это можно представить так: Обратите внимание, что каждый из регистров 8-битный, то есть записывать в него нужно инструкцией sb. Ну то, что в коде это написано как-то сложно связано с тем, как это описано в документации. Это я так понял, что данные регистры прекрасно ложатся на структуру, но у разработчиков, похоже, другое мнение. В любом случае, подобная настройка происходит только во время инициализации, так что неоптимальный подход тут вполне простителен. За прерывание USART0_IRQ отвечает 56-й элемент массива, точнее его поле clicintie, которое нужно выставить в 1. Остается только добавить обработчик кнопки, чтобы по нажатию выставлялся флаг USART_CTL0_TBEIE, который разрешит прерывание. И, естественно, сам обработчик прерывания, который этот флаг сбросит. UPD: В комментариях подняли интересную тему: вот мы рассматриваем подход с общим стеком. Но что делать тем, кто хочет разделить привилегии? То есть основной код выполняется в пользовательском режиме, а исключения — в ядерном, мы ведь не имеем права лезть ни в стек (вдруг юзер его испортил?), ни в регистры. Для этого в нашем контроллере (не уверен что это специфика RISC-V в целом) предусмотрен спецрегистр mscratchcsw. Он служит для обмена значениями между указателем ядерного стека и пользовательского: Поскольку прерывания провоцируются периферией, а не конкретными инструкциями, возврат из них должен осуществляться ровно в то же место, где им довелось возникнуть. Другое дело ловушки. Они срабатывают обычно не по асинхронным внешним событиям, а по вполне конкретным командам вроде упоминавшегося ранее ecall. И если в конце обработчика ловушки вернуться на ту же инструкцию, ловушка тут же сработает снова. Поэтому сначала адрес возврата нужно увеличить на длину команды, что в нашем случае непросто, ведь команды бывают как 16-битные, так и 32-битные. Но пока не будем заострять на этом внимание, все равно ловушками пользоваться практически не придется. Пока что считаем любую команду 32-битной. Это значит что надо считать значение из местного аналога регистра ra, который тут называется mepc, увеличить на 4 и записать обратно. Но ведь этот же обработчик служит и для прерываний, где увеличивать регистр не надо. Значит придется сначала определить, что послужило причиной нашего попадания сюда. Для этого служит регистр mcause, у которого есть 31-й бит, ровно за это отвечающий. Если он равен 1, то мы в прерывании, если 0 — в ловушке. Не менее эффективно он рассказывает и подробности исключения. Биты 0-11 хранят код ловушки (если 31-й бит равен 0) либо прерывания (если 1).
Кодов ловушек не слишком много: Из них проще всего проверить коды 2, 3 и 11. Для выполнения неверной инструкции (код 2) достаточно всего лишь встроить кусок данных между инструкциями. Например, константу 0xFFFF'FFFF (в коде примера закомментирована).
Команда ebreak (код 3) не так проста как кажется. Я в начале сказал что размер инструкции полагаю 32-битным. Так вот, команда ebreak занимает всего 2 байта. Можно обрабатывать ее отдельно, но я предпочту просто ей не пользоваться. Инструкция ecall в нашем случае генерирует 11-ю ловушку, а не 8-ю как можно было ожидать. Дело в том, что мы не настраивали разграничение доступа, так что фактически весь наш код считается ядерным. То есть нам можно все и отовсюду. Для проверки напишем обработчик кнопки чтобы по нажатии программа выполняла либо несуществующую инструкцию (ту самую 0xFFFF'FFFF), либо нормальную ecall. А в обработчике ловушки будем мигать красным светодиодом и правильно двигать адрес возврата. С прерываниями все аналогично. Помните, как мы настраивали биты разрешения прерываний в eclic? Этот же номер нам приедет в младших битах регистра mcause. Достаточно обрезать его по маске и сравнить с интересующим нас номером. В результате мы почти корректно обрабатываем прерывание от UART`а и никак не реагируем на остальные. "Почти" потому что обрабатываем только опустошение буфера передачи, а всего событий на этом векторе висит немного больше.
В общем, вот код обработчика ловушек: В предыдущем примере мы анализировали один бит регистра чтобы разделить две принципиально разные ситуации: сбой алгоритма и внешнее событие. К счастью, контроллер прерываний умеет делать это за нас. Для этого воспользуемся регистром mtvt2: его 30 старших битов хранят адрес обработчика прерываний, 1-й бит не отвечает ни за что, а младший бит является переключателем между раздельными обработчиками (mtvt2 + mtvec) при равенстве 1, либо совмещенном при равенстве нулю. Как и в предыдущем случае, использование для адреса обработчика только старших битов намекает нам на выравнивание по 4-байтной сетке. Так что инициализируем: и пишем обработчик. Как и раньше, номер прерывания хранится в младших битах регистра mcause: Также несколько упростился обработчик ловушек, но его я здесь приводить уже не буду. И вот наконец пришел момент окончательно облениться и заставить контроллер самостоятельно выбирать обработчик в зависимости от произошедшего события. Для этого используется такая структура как таблица векторов прерываний. По не вполне очевидным причинам ее адрес должен быть выровнен на 64, 128, 256, 512, 1024, 2048, 4096, 8192 или 16384 байта в зависимости от размера таблицы. Вероятнее всего, это сделано для упрощения арифметики: номер прерывания просто-напросто побитово складывается (OR) с адресом начала таблицы. У нашего контроллера 86 прерываний, по 4 байта на каждое, то есть 344 байта. Ближайшая степень двойки, в которую они влезают — 512, этому соответствует выравнивание .align 9. В любом случае таблицу прерываний обычно располагают в самом начале кода, то есть по нулевому адресу, так что проблем не возникает. Но на всякий случай пропишем выравнивание явно. Приводить таблицу прерываний полностью я здесь не буду: она очень длинная. Кому надо, посмотрит в документации или примере кода. Отмечу только, что первые 4 байта в ней разработчики предусмотрительно зарезервировали для размещения инструкции прыжка в начало исполняемого кода: В таблицу мы вписываем именованные константы для адресов всех доступных прерываний. Именно так будут называться и наши метки обработчиков. Скажем, для UART это будет Адрес таблицы нужно положить в регистр mtvt: И не забываем настроить поле clicintattr уже знакомого нам массива. Оно состоит из двух частей: биты 1 и 2 отвечают за фронты прерывания: 0b00, 0b01 — по уровню, то есть флаг clicintip выставляется постоянно когда на проводке события высокий уровень
0b10 — по фронту, то есть флаг выставляется при переходе проводка из состояния 0 в 1
0b11 — по спаду, то есть при переходе из 1 в 0.
Насколько я понял, это актуально только для внешних прерываний EXTI, когда проводок события непосредственно связан с ножкой контроллера. Для внутренней периферии это безразлично. Ну а бит 0 отвечает за векторный или не-векторный режим работы. Когда он равен 0 (по умолчанию) прерывание работает в не-векторном режиме, а когда 1 — в векторном. В общем-то вот и все, отдельное прерывание для UART работает. Для полного фен-шуя можно еще прописать обработку немаскируемых прерываний, хотя я понятия не имею как их проверять. Смею надеяться, с основами ассемблера и архитектуры контроллера мы познакомились. Теперь можно перейти к языкам высокого уровня. Первым делом разберемся с иерархией. При программировании на Си основной код пишется именно на нем, а ассемблер используется лишь для служебных целей вроде инициализации памяти или прерываний, ну плюс низкоуровневые функции или вставки. Следовательно, наш ассемблерный код переименовывается в startup.S, а на его место создаем main.c. В этот Си`шный файл переносим инициализацию портов, UART'а и все остального, оставляем только копирование секций памяти, стек и инициализацию контроллера прерываний. Также я предлагаю переработать работу с отдельными линиями ввода-вывода. На ассемблере это было сделать сложно, но Си и его препроцессоре несколько удобнее. На них я написал файл pinmacro.h чтобы можно было работать с выводами например так: В конце ассемблерного файла нужно вызвать main как обычную функцию. Можно даже передать ей argc и argv. Но после нее добавим бесконечный цикл ну случай если кто-то по глупости сделает return. Ничего сложного в этом примере делать не будем, просто снова помигаем светодиодами. Даже прерывания пока что удалим, оставив вместо них заглушку: Как видно, любое внешнее событие приводит к бесконечному зависанию. Хорошо бы добавить сюда еще и индикацию ошибки, но в общем случае мы не знаем какая периферия подключена к контроллеру и как через нее вывести код ошибки. Поэтому предоставим возможность обработки высокоуровневому коду. Пусть программист объявит обработчик прерываний UnhandledInterruptHandler и сам разбирается что же пошло не так. Директивы .weak, с которыми вы могли познакомиться и раньше, если анализировали ассемблерный код таблицы векторов прерываний означают, что если данный символ (адрес, константа, ...) объявлен где-то еще, то использовать надо именно то, другое объявление, а если ничего подобного в коде нет, то здешнее, "слабое". Если присмотреться к выхлопу дизассемблера, станет понятно, что наш код инициализации может оказаться после Си'шного. Это не проблема, поскольку и туда и обратно мы перемещаемся прыжками по адресам, но для красоты выделим специальную подсекцию .start, которая будет располагаться после таблицы прерываний, но перед основным кодом. Также если в ассемблерном коде мы сами прописывали адреса всех регистров и битов, то в Си'шном коде принято использовать готовые наработки производителя. Я не уверен что нашел правильные, но пока что особых ошибок там не видел. Пусть лежат рядом с нашим скриптом линковщика в каталоге lib. Поскольку мы уже знаем какие регистры задействовать, можно было все это написать самостоятельно. Но, как я уже говорил раньше, в Си так не принято. Поэтому воспользуемся готовым кодом, который лежит в lib/Firmware/RISCV/drivers/n200_func.c. Он отвечает за настройку контроллера прерываний и предоставляет функции eclic_set_vmode (переклюить в векторный режим) и eclic_enable_interrupt (разрешить данное прерывание). А вот глобального разрешения и запрета прерываний там почему-то нет. Ладно, пишем вручную: Для самого файла n200_func.c имеет смысл описать персональное правило в makefile чтобы не копировать его в src. Если немного его поизучать, можно найти функцию eclic_init, отвечающую за инициализацию настроек всех прерываний (но не их адресов!). Не то чтобы вызывать ее было обязательно, но ведь и лишним не будет. Довольно неприятным побочным эффектом оказалось то, что этот файл требует наличия глобальной переменной SystemCoreClock. Ничего не поделаешь, придется ее объявить. Как мы видели раньше, внутреннее устройство обработчиков прерываний отличается от обычных функций: приходится сохранять вообще все регистры (в том числе t0-t6), а возврат происходит инструкцией mret вместо обычной ret. Чтобы подсказать Си'шному компилятору что вот эта функция является именно прерыванием, используется специальный атрибут, например так: Прерываний у нас много, а писать этот атрибут каждый раз лень, поэтому вынесем в файл lib/interrupt_util.h все прототипы обработчиков прерываний с этим атрибутом. Туда же унесем код разрешения и запрета прерываний, про который говорили раньше. Если посмотреть в дизассемблерный код еще раз, можно увидеть, что main ведет себя не как главный цикл программы, а как обычная функция — сохраняет регистры на стеке, при выходе восстанавливает. Поскольку нам это не нужно, объявим и ее прототип перед обработчиками прерываний, только с другим атрибутом: В качестве примера работы прерываний на Си отлично подойдет наш предыдущий код, по кнопке включающий прерывание, а в обработчике посылающий байт и отключающий себя. Вот и все, что я хотел себе рассказать о первых шагах в освоении данного контроллера. RISC-V довольно интересная архитектура, изучать которую после тесного общения с AVR и поверхностного с ARM было необычно. Некоторые технические решения показались странными, но не лишенными внутренней логики, как, например, отказ от стека или от статусных регистров. Красиво решена работа с прерываниями: хочешь используй один общий обработчик, а хочешь — каждому устройству свой. Таблицу можно положить не только в начало основного кода или бутлоадер, а вообще куда угодно, лишь бы влезла. Решение с макросами вроде USART_DATA( USART0 ) или вскользь упомянутых в начале DMA гораздо удобнее, чем структуры в stm32. Нумерация периферии с нуля мне тоже понравилась больше. Хорошо бы сравнить ассемблер RISC-V с ассемблером ARM, но с последним я почти не знаком, так что много сказать не смогу. Разве что RISC-V несколько проще для понимания: там почти нет инструкций-комбайнов (если не считать li, которая, согласно документации, разворачивается в "Myriad sequences"). С академической точки зрения это прекрасно, но для реальной работы может обернуться небольшим замедлением кода. Рекомендую ли я эту архитектуру вообще и GD32VF103 в частности к практическому применению? И да и нет. Основной аргумент против это малая распространенность подобных контроллеров. Те же GigaDevice производят по сути только клон stm32f103, то есть довольно слабого контроллера с высоким потреблением. На мой взгляд, лучше бы скопировали stm32l151.  Ну а из плюсов — опять же приятный ассемблер и получение общего представления об устройстве контроллеров, которое пригодится и в работе с контроллерами вообще, и особенно с теми же stm32f103. Опять же существуют компьютерные процессоры, основанные на RISC-V и еще неизвестно за кем будущее — x86, ARM, RISC-V или еще какой-то архитектурой. https://habr.com/ru/post/516006/
https://www.youtube.com/watch?v=M0dEugoU8PM&list=PL6kSdcHYB3x4okfkIMYgVzmo3ll6a9dPZ
https://www.youtube.com/watch?v=LyQcTmNcSpY&list=PL6kSdcHYB3x6KOBxEP1YZAzR8hkMQoEva
https://doc.nucleisys.com/nuclei_spec/isa/eclic.html
http://www.gd32mcu.com/en/download/0?kw=GD32VF1