Как уже неоднократно говорилось, специфика микроконтроллеров заключается в их скорости реакции на внешние события и большом разнообразии подключаемой периферии, но при этом не слишком большой вычислительной мощности. Чтобы повысить скорость реакции, можно чаще проверять биты статуса, но это существенно усложнит написание программ и замедлит выполнение. А начиная с некоторого количества периферии, вообще наступит физический предел: на опрос всех битов уйдет больше времени, чем допустимо в устройстве. Чтобы это обойти, для проверки битов придумали использовать не программный код, а аппаратный модуль — контроллер прерываний. Его задача заключается в том, чтобы отловить факт возникновения события, удостовериться, что данное событие разработчику интересно и что контроллер в данный момент готов его обрабатывать. После этого выполнение основного кода приостанавливается (прерывается), а управление передается на специальную подпрограмму — обработчик прерывания. Именно этот механизм мы сегодня и рассмотрим.
Для перехода на обработчик прерывания должны быть выполнены все связанные с ним условия:
- Прерывание от данного события должно быть разрешено. Если в устройстве используется только UART, возникновение прерываний от всяких таймеров нам не интересно. Более того, некоторые устройства (в основном, ножки ввода-вывода) генерируют прерывания не импульсно (один раз на событие), а непрерывно. Например, все время пока на ножке высокий уровень. Без возможности запрета таких прерываний контроллер будет постоянно висеть в обработчике.
- Контроллер прерываний вообще-то должен быть настроен. Выставлен адрес обработчика, разрешения и т.п.
- Прерывание должно быть разрешено глобально. Дело в том, что некоторые группы команд прерывать нельзя — собьются тайминги, возникнут нежелательные импульсы или что-то в этом роде. Такие операции называются атомарными (неделимыми) и в простейшем случае реализуются именно сбросом глобального разрешения прерываний и его последующим восстановлением. Также глобальное разрешение сбрасывается при заходе в прерывание, чтобы не было циклической обработки.
- При наличии системы приоритетов прерываний (в некоторых контроллерах, например AVR, ее нет) приоритет пытающегося запуститься прерывания должен быть выше того, что обрабатывается сейчас. "Приоритет" основного кода, естественно, ниже, чем у любого прерывания. Таким способом можно обойти предыдущий пункт и все-таки реализовать обработку прерывания в обработчике прерывания.
Поскольку прерывания генерируются внешними устройствами и не привязаны к выполняющейся в данный конкретный момент инструкции, обработчик должен быть "прозрачным", то есть не оказывать побочного влияния на остальной код. В частности, это означает, что он обязан после завершения работы вернуть контроллер ровно в то же состояние, что было до его вызова. В первую очередь — восстановить все регистры, включая временные, регистр возврата ra, стек. В случае архитектур с регистрами флагов (к RISC-V это почти¹ не относится) — и их тоже. При этом возникает два очевидных вопроса:
Как писать код, если все регистры заняты? В общем-то, примерно так же, как и для обычных подпрограмм: нужные регистры сохраняются на стеке, а перед выходом из обработчика восстанавливаются. В простейшем случае (на котором мы остановимся) стек будет общий как для пользовательского кода, так и для прерываний. Из этого, кстати, следует упомянутое в прошлой статье требование всегда сохранять стек в работоспособном состоянии. Сначала резервировать память, и только потом ей пользоваться. Не работать со стеком за пределами зарезервированной памяти. А то вдруг придет прерывание и запишет туда какие-то свои данные.
На самом деле в RISC-V можно реализовать сразу несколько стеков. Для "компьютерных" применений это очень важно. Все-таки ядро операционной системы не может доверять пользовательскому коду. Да и хранить свои данные там, где он может их случайно прочитать, тоже опасно. Поэтому существует специальный CSR-регистр mscratch, в который можно временно положить sp, подгрузить новую вершину стека уже из адресного пространства ядра, и спокойно работать.
Также в bumblebee (так называется ядро gd32vf103) предусмотрен нестандартный регистр mscratchcsw, позволяющий сохранять sp только при смене привилегий. То есть если прерывание вызвано из юзерского кода, sp будет сохранен, а если из ядерного — нет.
Кстати о CSR-регистрах (Control-status registers)
Это такие специальные регистры, специфичные для самого ядра, отображающие его состояние и позволяющие им управлять. Доступа к ним обычными средствами нет, зато есть специальные инструкцииcsrr, csrw
и подобные. У всех этих инструкций есть важное свойство: одновременно с изменением CSR-регистра, они возвращают его предыдущее значение. Это позволяет отследить, не изменилось ли оно прямо во время изменения. В некоторых случаях это важно. Как и периферия, адреса этих регистров прописаны в документации.
И второй вопрос — откуда брать адрес возврата из прерывания, если ra использовать нельзя? Для этого существует специальный CSR-регистр mepc. Возврат по его значению осуществляется специальной инструкцией mret. Помимо собственно возврата, она умеет переключать уровни привилегий, если контроллер настроен правильно.
¹). Исключения все же бывают. Например, CSR-регистр статуса FPU. Впрочем, использовать дробные числа в прерывании вообще странная идея.
6.1. ECLIC и его настройка (специфика GD32VF103)
Контроллер прерываний в нашем микроконтроллере называется ECLIC (Enhanced Core Local Interrupt Controller). Управляется он частично через CSR-регистры, частично через MMIO. Регистры у него следующие:
регистр | размер | смещение | описание |
---|---|---|---|
cliccfg | 4(8) | 0x0 | Глобальные настройки приоритетов |
clicinfo | 25(32) | 0x4 | Разнообразная информация о прерываниях конкретного контроллера |
mth | 8(8) | 0xB | Порог срабатывания прерываний |
clicintip[i] | 1(8) | 0x1000+4*i | Флаг ожидающего прерывания |
clicintie[i] | 1(8) | 0x1001+4*i | Флаг разрешения прерывания |
clicintattr[i] | 3(8) | 0x1002+4*i | Настрока фронта прерывания и режим |
clicintctl[i] | 8(8) | 0x1003+4*i | Приоритет |
Тут же встает вопрос относительно чего рассчитывается смещение и где про это написано. И тут у меня ответа к сожалению нет: я не нашел упоминаний этого адреса ни в одной документации. Только изучая примеры кода от производителя, был обнаружен базовый адрес 0xD200'0000.
Регистры clicintip, clicintie, clicintattr и clicintctl привязаны каждый к своему прерыванию, поэтому и объединены в массив. В нашем случае используется прерывание от USART0, за которым производителем закреплен номер 56, соответственно использоваться будут clicintip[56], clicintie[56], clicintattr[56] и clicintctl[56] с адресами (0x1000 + 4*56 =) 0x10E0, 0x10E1, 0x10E2 и 0x10E3. Посмотреть номера и список всех доступных в данном конкретном контроллере прерываний можно в его User Manual'е в разделе, посвященном прерываниям. В нашем случае это здоровенная табличка из 86 элементов.
Немного расшифрую что написано в таблице выше. Регистр mth: я пока точно не знаю за что он отвечает, поэтому подробностей не будет, изучайте документацию. Регистр clicintip содержит всего один значащий бит. Если прерывание уже готово выполниться, он выставляется в 1, что, при выполнении остальных условий, приводит к собственно переходу на прерывание. Регистр clicattr: некоторая периферия (особенно ножки ввода-вывода GPIO) умеет генерировать прерывание по высокому уровню (все время пока на ножке лог.1 будет вызываться прерывание), по нарастающему фронту (лог.0 -> лог.1) и по спадающему фронту (лог.1 -> лог.0). Для большей же части периферии эта настройка бесполезна. Также там указывается векторный / не-векторный режим работы, но его рассмотрим чуть позже. clicintctl, приоритет прерываний, отвечает за то, чтобы более приоритетное прерывание могло прервать менее приоритетное. Механизм вложенных прерываний в GD32 довольно интересный, но рассматривать его мы не будем.
Собственно настройка ECLIC для простейшего случая работы с прерываниями сводится всего лишь к разрешению прерываний от интересующей нас периферии, то есть выставлении clicintie[56] в 1:
li t0, ECLIC_CLICINT
li t1, 1
sb t1, (ECLIC_CLICINTIE + 4*USART_IRQn)(t0)
Обратите внимание, что для записи использована инструкция sb: размер регистров 8 бит, и трогать соседние мы не хотим.
6.2. Настройка периферии
Периферии мы пока изучили немного, поэтому работать будем с UART. Как мы уже выяснили, за все события, происходящие с UART'ом отвечает одно прерывание, с номером 56. Самих же событий может быть несколько, и прописаны они в регистре USART_CTL0 (приведу для сравнения еще от CH32V303 и STM32F103):
15, 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
GD32VF103 | Reserved | UEN | WL | WM | PCEN | PM | PERRIE | TBEIE | TCIE | RBNEIE | IDLEEIE | TEN | REN | RWU | SBKCMD |
CH32V303 | M_EXT | UE | M | WAKE | PCE | PS | PEIE | TXEIE | TCIE | RXNEIE | IDLEIE | TE | RE | RWU | SBK |
STM32F103 | Reserved | UE | M | WAKE | PCE | PS | PEIE | EXEIE | TCIE | RXNEIE | IDLEIE | TE | RX | RWU | SBK |
PERRIE, Parity error interrupt enable — прерывание по ошибке приема. У UART есть простенькая система защиты от сбоев при обмене, и это прерывание возникает при ее срабатывании.
TBEIE, Transmitter buffer empty interrupt enable — прерывание по опустошению буфера передачи
TCIE, Transmission complete interrupt enable — прерывание по фактическому окончанию передачи
RBNEIE, Read data buffer not empty interrupt and overrun error interrupt enable — прерывание по приему байта
IDLEIE, IDLE line detected interrupt enable — прерывание по таймауту. Если данные не приходили слишком долго.
Продемонстрировать работу прерываний будет проще всего на передаче. Контроллер передаст байт, после чего должно произойти событие и мы окажемся в обработчике. Но в регистре USART_CTL0 этих прерываний два. Дело в том, что передача байта происходит в два этапа: сначала байт записывается в USART_DATA, потом автоматически копируется во внутренний буфер, из которого бит за битом передается в линию TX. И пока он передается, в регистр USART_DATA можно положить еще один байт, он там будет лежать, пока предыдущий не освободит место во внутреннем регистре. Так вот, прерывание TBEIE возникает когда байт покинул регистр USART_DATA и начал передаваться. А TCIE — когда покинул внутренний регистр, и передача полностью завершилась. Соответственно, TBEIE надо использовать когда байты передаются потоком, один за другим, чтобы не было задержки пока железо завершит передачу, пока отработает прерывание, пока положат следующий байт и т.д. А TCIE — когда надо отключить модуль UART, то есть дождаться фактического окончания передачи. Поскольку отключать UART мы не будем, воспользуемся TBEIE, его нужно добавить к прочим флагам USART_CTL0.
6.3. Настройка контроллера
Итак, модуль ECLIC мы настроили, периферию настроили. Осталось написать собственно обработчик прерывания, положить его адрес в какой-нибудь регистр и разрешить прерывания глобально. Начнем, как ни странно, с регистра хранения адреса обработчика, mtvec:
Как видно из таблицы, младшие 6 битов отвечают за режим работы. Нас интересует режим ECLIC, которому соответствует комбинация 0b000011. Но из-за аж шести занятых битов (куда вам столько?!), данный регистр не может хранить шесть младших битов адреса. Поэтому придется обработчик прерывания располагать с выравниванием на 64:
.text
.align 6
trap_entry:
push t0, t1, a0, ra
li t0, GPIOB
lh t1, GPIO_OCTL(t0)
xori t1, t1, (1<<GLED)
sh t1, GPIO_OCTL(t0)
li a0, 1000000
call sleep
pop t0, t1, a0, ra
mret
Из кода довольно очевидно, что прерывание всего лишь мигает зеленым светодиодом и возвращается по адресу mepc при помощи команды mret. Вот именно адрес этой подпрограммы надо записать в mtvec:
la t0, trap_entry
ori t0, t0, 0b000011
csrw mtvec, t0
И разрешить прерывания глобально. Очевидно, что делать это надо когда периферия и прерывания уже настроены, то есть обычно перед бесконечным рабочим циклом. Впрочем, иногда разрешение прерываний делается еще в startup-коде, до начала основной программы. За глобальное разрешение прерываний отвечает регистр mstatus, а точнее его третий бит, MIE:
csrs mstatus, (1<<3)
Вот теперь прерывание работает: после выполнения кода передачи строки начинает мигать зеленый светодиод, а управление в основную программу не возвращается. В чем дело?
В том, что с прерыванием мы ничего не сделали: оно как ждало обработки, так и продолжает ждать. Вот и тыкается в обработчик в надежде, что хоть теперь его обработают. Но полноценно мы его обрабатывать пока не будем, просто скажем "хорошо, мы поняли, что данные переданы, можешь больше не следить за UART'ом". То есть просто запретим данное прерывание:
li t0, USART0
li t1, USART_CTL0_UEN | USART_CTL0_REN | USART_CTL0_TEN
sw t1, USART_CTL0(t0)
li t1, '+'
sw t1, USART_DATA(t0)
… ну и плюсик выведем, почему бы и нет. Вот теперь прерывание работает правильно: срабатывает, отключает само себя и возвращает управление основному коду.
6.4. Исключения
В микроконтроллерах источником неожиданных событий почти всегда оказывается именно периферия. Но в компьютерах, где программы куда больше и сложнее, чаще бывают и чисто программные ошибки. Плюс при наличии операционной системы появляется и необходимость к ней обращаться из пространства пользователя. Эти задачи также решаются контроллером прерываний. Тут я сразу вынужден уточнить терминологию: прерывания это события от внешних устройств; исключения — от выполнения определенных инструкций кода; исключительные ситуации — от обоих. Разница в том, что прерывание возникает когда ему хочется, и к коду не привязано. Поэтому после обработки надо вернуться в то же самое место, на котором оно случилось. Исключение же возникает в строго отведенных местах: код попытался выполнить несуществующую инструкцию; попытался обратиться к недоступному адресу; попытался работать с невыровненными данными (кстати, наш gd32vf103 к невыровненным данным равнодушен, и ошибок не выдает); попытался сделать системный вызов. Следовательно, при обработке исключений надо сначала определиться что же собственно произошло — штатное событие или ошибка. Если ошибка, то можно ли ее обработать, или лучше прибить процесс, пока хуже не стало.
Для примера напишем три инструкции, приводящие к исключениям:
.word 0xFFFFFFFF # несуществующая инструкция
ecall # системный вызов
ebreak # точка останова
При выполнении такого кода контроллер начинает яростно мигать зеленым светодиодом и спамить плюсики в UART. Логично, ведь при выполнении инструкции 0xFFFFFFFF возникает исключительная ситуация, обрабатывается нашим trap_entry, после чего управление передается опять на 0xFFFFFFFF. Что снова приводит к исключению.
В первую очередь надо отделить исключения от прерываний. Для этого служит регистр mcause, а точнее, его 31-й бит. Если он сброшен в 0, то перед нами исключение, а если выставлен в 1 — прерывание. Соответственно обработчик прерываний остается неизменным, но при обнаружении нуля в 31-м бите mcause надо перейти на обработчик исключения. Отличаться он будет тем, что мигать в нем будем красным диодом, а возвращаться не на ту же инструкцию, которая привела к исключению, а на следующую. Просто-напросто считаем mepc, увеличим на 4 (размер инструкции) и запишем обратно:
csrr t0, mepc
addi t0, t0, 4
csrw mepc, t0
Но тут из-под воды возникают интересные грабли: контроллер наш поддерживает расширение C (Compressed) — сжатые инструкции. То есть часть инструкций у него 32-битная, а часть — 16-битная. А перепрыгивать 16-битную инструкцию через 4 байта это плохая идея. К счастью, разработчики RISC-V предусмотрели замечательный способ определить длину инструкции. У 32-битных два младших бита всегда равны 0b11, а в 16-битных — любому другому числу. То есть нам надо всего лишь проверить эти два бита и в зависимости от этого решить, прибавлять 4 или все же 2:
csrr t0, mepc
lhu t1, 0(t0)
andi t1, t1, 0b11
addi t1, t1, -3
bnez t1, TRAP_INSTR_COMPR
addi t0, t0, 2
TRAP_INSTR_COMPR:
addi t0, t0, 2
csrw mepc, t0
Напоминаю, что по сути исключение мы не обработали, а всего лишь варварски проигнорировали. В реальности возвращаться таким образом из ошибки доступа к памяти или тем более неверной инструкции нельзя. Ведь если такое случилось, значит, мы выполняем не то, что прораммировали. Лучше всего в такой ситуации записать куда-нибудь лог ошибки (из какого места в коде сюда попали, что пытались сделать), возможно, вывести сообщение об ошибке куда-нибудь на дисплей и зависнуть в ожидании отладчика. Но в случае, например, ecall, исключение — вполне штатная ситуация системного вызова, которая никак не мешает работе.
Но, как я говорил в самом начале, исключения в контроллерах используются достаточно редко, поэтому пока на этом и остановимся.
6.5. Разделение прерываний и исключений
Но если исключения штука редкая, но возможная, можно ли убрать проверку mcause из обработчика прерываний? Оказывается, можно. Для этого используется еще один CSR-регистр mtvt2, который в стандарт RISC-V не входит, и является специфичным для нашего контроллера. Его младший бит отвечает за то, использовать ли его вообще, а оставшиеся биты хранят адрес обработчика прерываний. То есть в mtvec будет адрес обработчика исключений, а в mtvt2 — прерываний:
.equ mtvt2, 0x7EC
...
la t0, irq_entry
ori t0, t0, 1
csrw mtvt2, t0
Естественно, раз уж обработчиков теперь стало два, нужно каждый из них оформить как обработчик — персональная точка входа, работа со стеком, mret.
6.6. Векторный режим
Специфика контроллера вынуждает пойти еще дальше и вместо одного обработчика на все прерывания, в котором нужно было анализировать младшие биты mcause чтобы выяснить какое именно устройство вызвало прерывание, был придуман еще более хитрый механизм. Он заключается в том, что для каждого устройства пишут свой, персональный обработчик прерывания, а их адреса (иногда — прямо команды перехода по адресам) сводят в специальную таблицу — таблицу векторов прерываний. Например, если нас интересует 56-е прерывание, то в 56-ю ячейку надо записать адрес обработчика. Как и с отдельными обработчиками, для хранения адреса таблицы выделен отдельный CSR-регистр mtvt. Причем работа с таблицей реализована очень разумно: в регистре хранится старшая часть адреса таблицы, а вместо младшей подставляется номер прерывания. То есть если адрес самой таблицы равен 0x2000 1000 (где-то в оперативной памяти), и произошло прерывание 56 (поскольку инструкция 4-байтная, то смещение будет 224, оно же 0x0000 00E0), то адрес будет взят из ячейки (0x2000 1000 OR 0x0000 00E0) = 0x2000 10E0. Из этой реализации следует ограничение на выравнивание таблицы. В нашем случае, когда прерываний 86, фактический размер таблицы составляет 344 байта, что помещается в 512-байтную область. Это соответствует выравниванию .align 9.
Здесь надо не забыть, что векторный / не-векторный режим настраивается в регистре clicattr[i], причем для каждого прерывания независимо.
la t0, vector_table
csrw mtvt, t0
...
li t0, ECLIC_CLICINT
li t1, 1
sb t1, (ECLIC_CLICINTIE + 4*USART_IRQn)(t0)
sb t1, (ECLIC_CLICINTATTR+4*USART_IRQn)(t0)
Прописывать простыню из 86 адресов прерываний я здесь не буду, кому интересно может посмотреть в примерах кода.
Вот теперь мы познакомились со всеми основными способами обработки исключительных ситуаций, и можем выбирать тот или иной в зависимости от задач. Не стоит думать будто невекторный режим является устаревшим, просто для разных задач оптимальными будут разные подходы.
6.7. Расположение таблицы векторов прерываний
Как мы увидели раньше, расположить ее можно где угодно, лишь бы выравнивание соблюдалось. Можно даже хранить несколько таблиц по разным адресам и переключать их по желанию левой пятки. Но проще всего все же выделить для таблицы постоянное место — в начале прошивки. Мы ведь точно знаем, что адрес 0x0000 0000 (и даже реальный адрес 0x0800 0000) совершенно точно выровнены по 512-байтной границе. Разработчики даже сделали нам подарок, не став использовать 0-й адрес вектора прерываний, на который попадает управление при старте контроллера. В него можно записать безусловный переход на начало основного кода. Ну а чтобы таблица располагалась именно там, где надо, для нее можно выделить специальную секцию памяти, а в *.ld файле указать, что размещается она в самом начале.
6.8. Системные вызовы
Теперь, когда с самой сложной частью закончили, можно вернуться к исключениям. Например, реализовать системные вызовы из стандарта RARS (это такой визуальный эмулятор RISC-V) вроде ввода-вывода чисел, строк, символов и прочего.
Как говорит нам документация, причина исключения хранится в младших 12 битах регистра mcause. За вызовом ecall зарезервировано два кода: 11 (если вызов произошел на M-mode) и 8 (если на U-mode). Пока что будем обрабатывать только ecall, а остальные исключения игнорировать.
Номер ecall'а хранится в регистре a7, и из списка вызовов RARS'а (напоминаю, в других средах системные вызовы другие!) нас интересуют, например, 1 и 4 — вывод числа и вывод строки. Вот так может выглядеть обработчик исключений, поддерживающий эти два системных вызова:
.text
.align 6
trap_entry:
push t0, t1, ra
csrr t0, mcause
andi t0, t0, 0x7FF
addi t1, t0, -11
beqz t1, TRAP_ECALL
addi t1, t0, -8
beqz t1, TRAP_ECALL
j TRAP_END
TRAP_ECALL:
addi t0, a7, -1
bnez t0, TRAP_SKIP_PUTI
call uart_putx
j TRAP_END
TRAP_SKIP_PUTI:
addi t0, a7, -4
bnez t0, TRAP_SKIP_PUTS
call uart_puts
j TRAP_END
TRAP_SKIP_PUTS:
TRAP_END:
li t0, GPIOB
lh t1, GPIO_OCTL(t0)
xori t1, t1, (1<<RLED)
sh t1, GPIO_OCTL(t0)
csrr t0, mepc
lh t1, 0(t0)
andi t1, t1, 0b11
addi t1, t1, -3
bnez t1, TRAP_INSTR_COMPR
addi t0, t0, 2
TRAP_INSTR_COMPR:
addi t0, t0, 2
csrw mepc, t0
pop t0, t1, ra
mret
В результате наконец-то начали корректно работать участки кода
li a0, 0x321
li a7, 1
ecall
la a0, ECALL_STR
li a7, 4
ecall
Исходный код примера доступен на github
6.9. Прерывания в CH32V303
В CH32V303 система прерываний немного другая. Есть общий CSR-регистр mtvec
, в старших 30 битах которого указывается адрес обработчика или таблицы обработчиков, а в двух младших — режим работы. Бит 1 указывает, будет ли использоваться базовый RISC-V режим с единым обработчиком (если равен нулю) или, если там 1, табличный PFIC режим. И, если используется табличный режим, вступает в действие 0 бит: если он 0, то прерывание просто переходит по адресу mtvec
плюс смещение текущего прерывания. И в принципе, в таблице можно разместить любой код. Правда, размером всего 4 байта, поэтому обычно там не будет ничего интереснее джампа. А если 0-й бит выставлен в 1, прерывание читает из таблицы адрес, и по нему уже безусловно переходит. Таким образом, режимов обработки у нас три: 0b00
— невекторный, 0b10
— таблица переходов, 0b11
— таблица адресов. Начнем, как и раньше, с простого:
.equ PFIC_BASE, 0xE000E000
.equ PFIC_IENR1, 0x100
.equ USART1_IRQn, 53
...
### Настройка адреса обработчика прерывания
la t0, trap_entry
ori t0, t0, 0
csrw mtvec, t0
### Глобальное разрешение прерываний
csrs mstatus, (1<<3) # MIE
...
### Разрешаем прерывание UART в PFIC
li t0, PFIC_BASE
li t1, (1<<(USART1_IRQn & 31))
sw t1, (PFIC_IENR1+4)(t0) # адрес 53 попадает в диапазон [32 ... 63], то есть второй регистр массива
Сам обработчик принципиально от рассмотренного ранее не отличается (разве что номер прерывания не 56, а 53). Точно так же конкретный тип исключительной ситуации выясняется из mcause, точно так же старший бит выставляется в 1 для прерываний и в 0 для исключений, точно так же в остальных битах хранится номер события. Для примера, при ecall там будет 0x0B
, а при попытке чтения с адреса 0x20000001
— 0x04
(да, в CH32V303 все-таки есть исключения по доступу к памяти!). Ну а для UART будет 0x80000035
.
6.10. Таблица переходов
Модификация предыдущего кода получается минимальной: в mtvec
добавляется 1-й бит, а trap_entry
заменяется на _vector_base
, который ведет к чему-то вроде такого:
_vector_base:
.option norvc;
j _start
j 0
j NMI_Handler /* NMI */
j HardFault_Handler /* Hard Fault */
j 0
j trap_entry /* Ecall M Mode */
j 0
j 0
j Ecall_U_Mode_Handler /* Ecall U Mode */
j Break_Point_Handler /* Break Point */
j 0
j 0
j SysTick_Handler /* SysTick */
j 0
j SW_Handler /* SW */
j 0
/* External Interrupts */
j WWDG_IRQHandler /* Window Watchdog */
j PVD_IRQHandler /* PVD through EXTI Line detect */
...
j SPI1_IRQHandler /* SPI1 */
j SPI2_IRQHandler /* SPI2 */
j USART1_IRQHandler /* USART1 */
j USART2_IRQHandler /* USART2 */
...
Как видно, таблица общая: сначала идут исключения, а потом прерывания. И, как уже было сказано, операцию безусловного перехода теоретически можно заменить чем-то другим, вот только в 4 байта много кода не влезет. Также, поскольку j
— обычная операция перехода, на адрес у нее отводится всего 20 бит, то есть максимальная длина перехода ограничена двумя мегабайтами. Ужасное ограничение, учитывая, что для кода у нас всего 480 кБ. Зато джампы относительные, то есть такую таблицу вместе с ее обработчиками можно свободно перемещать по всему доступному коду, и это никак не повлияет на работоспособность. Можно даже в оперативку подгружать, опять же в произвольное место.
Также я немного изменил бесконечный цикл и обработчик UART-а:
MAIN_LOOP:
li t0, GPIOB
lw t1, GPIO_OUTDR(t0)
li t2, (1<<RLED)
xor t1, t1, t2
sw t1, GPIO_OUTDR(t0)
li t0, GPIOA
sw t5, GPIO_BSHR(t0)
li t0, USART1
li t1, USART_CTLR1_UE | USART_CTLR1_TE | USART_CTLR1_RE | USART_CTLR1_TXEIE
sw t1, USART_CTLR1(t0)
li a0, 5000000
call sleep
j MAIN_LOOP
...
USART1_IRQHandler:
push t0, t1
bnez t5, gled_off
gled_on:
li t5, (1<<GLED)
j gled_end
gled_off:
slli t5, t5, 16
gled_end:
li t0, USART1
li t1, USART_CTLR1_UE | USART_CTLR1_TE | USART_CTLR1_RE
sw t1, USART_CTLR1(t0)
li t1, '+'
sw t1, USART_DATAR(t0)
pop t0, t1
mret
Обратить внимание здесь стоит на регистр t5
: в main он нигде не модифицируется, и только выводится в GPIOA (на PA11 на отладочной плате висит зеленый светодиод). А в обработчике UART в этот же t5
варварски записывается либо (1<<11)
, либо (1<<11<<16)
. Регистры t0, t1
там модифицируются тоже, но за счет push-pop, основная программа об этом не знает. И, как мы помним, для обработчиков прерываний это единственно правильное поведение. А вот t5
оставлен за пределами push-pop, и будет влиять на основной код. В реальных программах так делать нельзя. Здесь же это заготовка к wch-специфичному способу обработки, к которому мы вернемся позднее.
6.11. Таблица адресов
Здесь модификаций еще меньше. В mtvec
теперь добавляется не 0b10
, а 0b11
, а сам _vector_base
выглядит так:
_vector_base:
.option norvc;
.word _start
.word 0
.word NMI_Handler /* NMI */
.word HardFault_Handler /* Hard Fault */
.word 0
.word trap_entry /* Ecall M Mode */
.word 0
.word 0
.word Ecall_U_Mode_Handler /* Ecall U Mode */
.word Break_Point_Handler /* Break Point */
.word 0
.word 0
.word SysTick_Handler /* SysTick */
.word 0
.word SW_Handler /* SW */
.word 0
/* External Interrupts */
.word WWDG_IRQHandler /* Window Watchdog */
...
.word I2C2_ER_IRQHandler /* I2C2 Error */
.word SPI1_IRQHandler /* SPI1 */
.word SPI2_IRQHandler /* SPI2 */
.word USART1_IRQHandler /* USART1 */
.word USART2_IRQHandler /* USART2 */
.word USART3_IRQHandler /* USART3 */
.word EXTI15_10_IRQHandler /* EXTI Line 15..10 */
Таким образом, в таблице хранятся уже абсолютные адреса обработчиков прерываний. И, в общем-то, больше ничего интересного в примере нет.
6.12. Аппаратный стек
Разработчики из WCH посчитали, что некоторые исключительные ситуации должны выполняться так быстро, как только возможно. В RISC-V вообще-то и так переход на обработчик достаточно быстрый, но ведь мало перейти, надо еще провести какие-то вычисления, а для этого нужны регистры. Причем, если вызывается сторонняя функция, для нее придется сохранять a
регистры, t
регистры… в общем, все, которые функция имеет право менять. Вот этот-то момент и было решено ускорить. В QingKeV4 (ядре нашего контроллера) выделено три аппаратных стека, на которые автоматически сохраняются все нужные регистры, а при возврате из прерывания — восстанавливаются. В других ядрах от WCH аппаратных стеков может быть другое количество.
### Разрешаем работу аппаратного стека
li t0, (1<<0 | 1<<1) # HWSTKEN | INESTEN
csrw intsyscr, t0
### Настраиваем первый стек для прерывания USART1_IRQn
li t0, PFIC_BASE
li t1, USART1_IRQn
sb t1, PFIC_VTFIDR(t0)
la t1, USART1_IRQHandler
ori t1, t1, (1<<0) # 0-й бит - разрашение работы данного стека
sw t1, PFIC_VTFADDRR1(t0)
Стеки здесь привязываются к конкретным номерам и конкретным адресам прерываний. В этом примере мы сначала настроили CSR-регистр intsyscr
(804-й), потом в обычный 8-битный регистр PFIC_VTFIDR
записали номер прерывания, USART1_IRQn
, потом в регистр PFIC_VTFADDRR1
— адрес его обработчика и флаг разрашения работы.
Здесь самое время вспомнить о t5
, который как не сохранялся со времен примера таблицы переходов, так и не сохраняется. Вот только светодиод на плате больше не мигает. Это означает, что железо CH32V303 аппаратно соханило этот регистр (как и все прочие, которые оно обязано сохранять), и что бы обработчик прерывания с ним не делал, снаружи этого видно не будет. Так что push-pop t0, t1
из обработчика тоже можно удалить.
А теперь ложка дегтя: на практике этим пользоваться почти невозможно. Дело в том, что обычный компилятор Си не поддерживает ни WCH-специфичного расширения, ни просто возможности настроить какие регистры надо сохранять. Разве что полностью писать обработчик на ассемблере и вручную следить за регистрами. Впрочем, если вы пользуетесь специально пропатченным WCH компилятором, можно оформить обработчик как __attribute__((interrupt("WCH-Interrupt-fast"))) void USART1_IRQHandler(void){...}
, и все будет работать. Наверное. Я не проверял. Теоретически, есть еще такой костыль:
__attribute__((naked))void USART1_IRQHandler(void){
...
asm volatile("mret");
}
Он отключает у функции участки сохранения — загрузки сохраняемых регистров. И учитывая, что разработчики WCH молодцы, и сохраняют именно эти регистры, в некоторых случаях такой костыль работает. Вот только в CH32V303 есть не только обычные регистры, но и FPU. Которые тоже делятся на сохраняемые и временные, и которые тоже могут использоваться. И которые, как вы понимаете, костыль не сохраняет. С другой стороны, если уж вы обозначили прерывание как супер-быстрое, вряд ли вы будете в нем возиться с числами с плавающей точкой. Но за этим уже придется следить.
Заключение
Вот так в микроконтроллерах gd32vf103 и ch32v303 настраиваются прерывания и исключения. Возможно, на таком простом примере, как UART, их польза не очевидна, но желающие могут изучить и другую периферию, поработать через polling и прерывания и оценить, что выгоднее для их задачи. Это, кстати, не шутка: не надо пихать прерывания куда попало, в ряде случаев именно опрос регистра является оптимальным решением.
Разумеется, это далеко не исчерпывающий материал по прерываниям. Скажем, варианты обработки вложенных прерываний или настройка их приоритетов не рассмотрены вовсе. А ведь решения там применены довольно интересные.
Ах да, чуть не забыл самое важное в работе с прерываниями: никогда не сидите в прерываниях дольше необходимого минимума! И уж точно в прерываниях нельзя делать длительные задержки или циклы ожидания флагов периферии. Ведь во время обработки прерывания не может обрабатываться ни основной код, ни другие прерывания. Включая отладочный вывод. Если уж обработка прерывания требует какой-то длительной задержки — можно сохранить необходимые данные в глобальную переменную и взвести флаг. А потом уже неспешно, из основного кода, все обработать, уже не боясь пропустить что-то важное.
И еще один важный момент: прерывание может возникнуть между любыми инструкциями основного кода. В том числе, между чтением двух половин 64-битного числа. И, если прерывание это число тоже модифицирует, могут быть проблемы. Но об этом поговорим возже, когда будем рассматривать таймеры. При программировании на языках высокого уровня (да хотя бы на Си) есть и еще одна проблема. Компиляторы очень любят проводить вычисления на регистрах, выгружая результаты в память только в крайнем случае. Но если переменная глобальная, и меняется как из прерывания, так и из основного кода, ее состояние всегда должно соответствовать ожидаемому из логики программы. Иначе говоря, ее нельзя кешировать в регистрах. Для этого в Си используется специальный модификатор: volatile int global_var;
. Попросту говоря, он сообщает компилятору, что значение переменной global_var
может меняться независимо от выполнения основного кода. Например, когда "переменная" — регистр периферии, где значения самозарождаются просто в результате ее деятельности. Или когда переменную использует то же прерывание. Или код многопоточный, и ее дергает второй поток. Но при этом не стоит использовать volatile
по делу и без: загрузка и выгрузка переменной — операция далеко не бесплатная.
Дополнительная информация
Видеоверсия на Ютубе (только по gd32)
mpa4b
Не очень понимаю, что им помешало аппаратно пушить только caller-save (согласно ABI) регистры? Тогда обработчик может быть обычной функцией с сигнатурой void name(void) и сам запушит callee-save регистры и всё будет тип-топ. В armv7-m так и сделано, например.
COKPOWEHEU Автор
Они так и делают. Слава китайским богам, что они сообразили привести свое расширение в соответствие со стандартом. Только благодаря этому вариант с naked работает не только в патченном gcc, но и в обычном. Но есть ведь FPU-регистры, которые и на аппаратном стеке сохранять невыгодно, и на обычном сложно. Ну и всякие пограничные случаи я не искал, там тоже могут быть проблемы.
Впрочем, если кому-то нужна действительно настолько быстрая реакция на прерывания, что даже десятка тактов на сохранение регистров жалко, он, наверное, перепроверит и машинный код.
netch80
Пусть даже у нас есть фиксированное ABI заложенное в железе. В случае RISC-V там: семь t$n регистров - временных, которые в результате точно так же caller-save; 8 a$n, которые аналогично по определению caller-save; это уже 15, то есть почти половина. Далее ra=x1 сохранять; sp=x2, gp=x3, tp=x4 могут меняться, если контекст ядра отличается от контекста пользователя; уже 19. Callee-saved (s$n) только 12. То есть вместо "только caller-save" по факту имеем ≈2/3 от всех. Выгоды от решения аппаратно пушить какие-то - нет.
Что в armv7-m не знаю, расскажите. Но насколько я помню общие принципы дла ARM/32, там не пушат, там переключают банки, и в переключаемые входят почти все.
COKPOWEHEU Автор
В ch32 аппаратно сохраняются ra, t0-t6, a0-a7, всего 16 штук, ровно половина. Все остальные вызываемая функция и так обязана сохранить-восстановить сама. Причем, если верить документации, сохраняются они за один такт все, а не по по одному такту на регистр, как в ARM. И, как я понимаю, именно поэтому аппаратных стеков ограниченное количество - это ведь не обычный стек, а набор специальных регистров PFIC.
Понятно, что когда весь код прерывания находится в одной единице трансляции, компилятор может распорядиться регистрами более эффективно и не сохранять те, которые не используются. И в обычном RISC-V на это и расчет: если хочешь супер-быстрое прерывание, можно не тратить такты на все 16. Но если вызывается сторонняя функция, то уже никуда не деться, прерывание ведь не знает какие регистры используются там.
Впрочем, обычно прерывания заметно длиннее и менее требовательны к времени выполнения, так что это wch-ное извращение можно оставить на совсем уж крайний случай. В gd32 вон подобного делать не стали.
netch80
Тогда это, мне кажется, не перемещение с сохранением, а изменение раутинга к регистрам. У каждого из таких регистров дешифратор разрешения доступа для шины получает дополнительные нужное количество бит режима (минимум 1: в прерывании или нет), и, например, 0:x1 и 1:x1 имеют разное физическое хранилище, 0:x1 активируется в обычном режиме, 1:x1 в прерывании. Это одна из стандартных схем организации такого доступа.
На уровне "архитектурного состояния" тут ограничения нет, и разные ARM могут использовать разные схемы, у ARM/32 бывало вроде до 6 режимов, в которых различаются r13-r15, и FIQ, в котором ещё и отдельные r8-r12. Как я понял, вы говорили про STM32.
Это слегка проблемно с точки зрения секьюрити - легко упустить какой-то регистр и сделать утечку внутренних данных. Но в полный рост это выстреливает не для аппаратных прерываний, а для сисколлов, где поэтому сохраняют все регистры, кроме возвращаемых данных, даже если ABI позволяет модификацию. Вероятно, в мелких embedded это можно игнорировать, "тут все свои".
COKPOWEHEU Автор
Не знаю. В документации подробностей вроде нет. Но называют они это аппаратным стеком.
Утечку куда? Если программисту надо влезть из одного куска своего кода в другой, зачем ему мешать.
Что прерывания, что сисколы это не функции, это именно исключительные ситуации, которые должны быть как можно более прозрачными. К прерываниям это вообще жесткое требование. Сисколы иногда, по соглашению, могут пользоваться a0,a1,a7, но не более.
А говорить о регистрах в контексте безопасности вообще странно: их же используют все подряд. Да и количество, всего 31 штука, можно и руками отследить. Нет, если говорить о безопасности, то в первую очередь это память и CSR-регистры.
Это вообще единая программа. Соглашения по безопасности это скорее "как не отстрелить себе ногу", а не "как не дать левой руке доступ к правому уху".