[i] Работа со стеком. Выход из исключения

ID статьи: 48376
Дата последнего изменения: 29.11.2023 17:29:01
Материал из настоящей статьи, относящийся к микросхеме К1986ВЕ92QI, распространяется в том числе на микроконтроллеры К1986ВЕ92FI, К1986ВЕ92F1I и К1986ВЕ94GI

Пример приведен для микроконтроллера К1986ВЕ92QI (Cortex-M3). Проект доступен для загрузки в конце статьи, раздел "Файлы для скачивания".

Микроконтроллер позволяет обрабатывать различные некорректные манипуляции через выработку исключений. В общем случае, код обработчиков исключений, как и обработчиков прерываний, выглядит следующим образом

HardFault_Handler PROC HardFault_Handler [WEAK] B . ENDP Фрагмент кода 1

В этом коде ассемблерная строка "B ." означает зацикливание на текущей инструкции. То есть при возникновении исключения, ядро впадает в бесконечный цикл.

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

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

Чтобы реализовать такой выход, используется следующий код:

HardFault_Handler PROC
   HardFault_Handler [WEAK]
   TST LR, #4
   ITE EQ
   MRSEQ R0, MSP
   MRSNE R0, PSP
   LDR R1, [R0, #24]
   ADD R1, #4
   STR R1, [R0, #24]
   BX LR
   ENDP
Фрагмент кода 2

Назначение этого кода будет разобрано подробнее позже, но если коротко, то он изменяет значение регистра PC, сохраненное в стеке на момент возникновения исключения. А регистр PC задает адрес инструкции, которая будет считана из памяти и исполнена. Увеличивая значение PC, которое сохранено в стеке, контроллер при выходе из исключения (восстановлении регистров из стека), попадёт на следующую инструкцию, относительно той, что вызвала исключение. В противном случае при восстановлении из стека исполнялась бы та же самая инструкция, которая приводит к исключению, и тогда получилось бы зацикливание.

Важно отметить, что с выходом из обработчика может возникнуть проблема, заключающаяся в том, что в общем случае неизвестно, сколько байт занимает инструкция, которая привела к исключению. Набор инструкций Thumb2 содержит 32-битные инструкции, поэтому в Cortex-M3/M4 высока вероятность того, что инструкция окажется 4-х байтной. Тогда, действительно, для выхода на следующую инструкцию необходимо увеличить PC на +4. Но если инструкция, вызвавшая исключение, была 2-х байтной, тогда при модификации +4 процессор либо перескочит следующую 16-битную инструкцию, либо попадёт в середину следующей 32-битной инструкции. В Cortex-M0/M1 практически все инструкции 2-х байтные.

В рассматриваемом далее примере деление производится 32-битной инструкцией, поэтому для данного варианта оправдан выход по +4. Но при чтении памяти обычно компилятором используются 16-битные инструкции. Поэтому, например, для микроконтроллеров, в которых при чтении памяти, несогласованной с ECC, возникает исключение HardFault, выход из него на следующую инструкцию необходимо делать через +2.

Способ решения данной проблемы с +4 или +2 предложил на форуме prostoRoman.

Встроенный ассемблер

В сети достаточно много вариантов того, как обратиться к стеку. Например:

Обычно, этот код реализован как ассемблерная вставка в исходный файл на языке Си. Но при использовании легко столкнуться с тем, что эти коды утратили свою работоспособность. Вероятнее всего, встроенный ассемблер был сильно урезан в правах при развитии компиляторов. В частности, никакими ассемблерными командами на основе вышеперечисленных статей не удаётся прописать регистры R0-R4. Эти регистры, по соглашению языка Си, используются для передачи в функции первых 4-х входных значений, остальные значения, если необходимо, передаются через стек. Через R0 также возвращается значение из функции.

Вероятно, по этой причине встроенный ассемблер запрещает писать в регистры R0-R3.

Например, команда:

MOV r0, r1

Приводит к:

main.c(60): error: #1093: Must be a modifiable lvalue

Ближайший код, который был скомпилирован, выглядел так:

register uint32_t R0 __ASM("r0");
register uint32_t LR __ASM("lr");

void HardFault_Handler(void)
{
   __asm volatile
   {
      TST LR, #4
      ITE EQ
      MRSEQ R0, MSP
      MRSNE R0, PSP
      BL HardFault_Handler_C
   }
}
Фрагмент кода 3

Но при выполнении фрагмента кода 3 получается, что компилятор вместо записей в R0 вставляет везде NOP. Код, в итоге, оказывается нерабочим.

Ограничения на встроенный ассемблер прописаны на сайте ARM, и там же указано, что компилятор уполномочен делать с ассемблерными вставками все, что пожелает. Единственный выход - использовать ассемблер напрямую. То есть создавать ассемблерный файл *.s и писать код в нем. И линкеру совершенно не важно то, какие файлы он будет соединять.

Подробную интересную информацию про языки Си и ассемблер можно поискать в документе по следующей ссылке: Chapter 7 Using the Inline and Embedded Assemblers of the ARM Compiler

Ради маленького куска кода отдельный asm-файл создавать нерационально, поэтому код передачи указателя на стек был протестирован вставкой прямо в обработчик HardFault_Handler внутри startup_MDR32F9Qx.s, который все равно подключается по умолчанию.

; Комментарии в asm файле начинаются с ';'

HardFault_Handler\
   PROC EXPORT HardFault_Handler [WEAK]
   ; Вызов обработчика на языке C
   ; Импорт внешней функции
   IMPORT HardFault_Handler_C
   ; По LR определяем какой указатель стека активный
   TST lr, #4 ; Сравниваем 2-й бит
   ITE EQ ; ITE позволяет 4-м след. командам использовать флаги
   MRSEQ r0, MSP ; Копирование спец регистра в общий регистр если bit = 0
   MRSNE r0, PSP ; Копирование спец регистра в общий регистр если bit != 0
   LDR R1, =HardFault_Handler_C ; Копирование указателя на функцию в регистр R1
   BX R1 ; Переход на функцию без модификации LR ENDP
Фрагмент кода 4

В этом коде получается указатель на стек, и он передаётся во внешнюю функцию HardFault_Handler_C(), написанную на языке Си. Здесь используется особенность, что первый входной параметр уходит в функцию на Си через регистр R0.

  • MSP - это указатель стека в системном режиме работы (Операционная система).
  • PSP - это указатель стека в пользовательском режиме работы (Приложение).

Исключение при делении на ноль

Работа со стеком при возникновении исключения будет рассмотрена на примере деления на ноль. По умолчанию деление на ноль не генерирует прерывание в Cortex-M, поэтому генерацию такого исключения необходимо вначале разрешить. Далее будет произведено деление на ноль, которое приведет к вызову HardFault_Handler. В этом обработчике можно посмотреть состояние стека и вызвать обработчик на языке Си, в котором и будет произведен перевод регистра PC на следующую инструкцию.

Код примера:

#include <stdint.h>
#include <MDR32F9Qx_config.h>

void HardFault_TrapDivByZero(void)
{
   volatile uint32_t *confctrl = (uint32_t *) 0xE000ED14;
   *confctrl |= (1<<4);
}

uint32_t RiseDivZero(void)
{
   uint32_t b = 0;
   return 10 / b;
}

int main(void)
{
   volatile uint32_t result;

   HardFault_TrapDivByZero();

   // Call Exception
   result = RiseDivZero();
  
   // MainLoop
   while (1);
}

enum { r0, r1, r2, r3, r12, lr, pc, psr};

void HardFault_Handler_C(uint32_t stack[])
{
   // Изменяем значение регистра PC
   // на адрес инструкции на которую произойдет выход из исключения.
   stack[pc] = stack[pc] + 4;

// Обычно состояние стека выводят куда-нибудь для отладки
// printf("r0 = 0x%08x\n", stack[r0]);
// printf("r1 = 0x%08x\n", stack[r1]);
// printf("r2 = 0x%08x\n", stack[r2]);
// printf("r3 = 0x%08x\n", stack[r3]);
// printf("r12 = 0x%08x\n", stack[r12]);
// printf("lr = 0x%08x\n", stack[lr]);
// printf("pc = 0x%08x\n", stack[pc]);
// printf("psr = 0x%08x\n", stack[psr]);
}
Фрагмент кода 5

Стек при отработке исключения

Для того чтобы разобраться, как работают регистры и стек, рассмотрим следующие рисунки ниже с алгоритмом работы каждого шага.

Вызов функции RiseDivZero()

На рисунке 1 изображено начальное состояние в отладчике, что является точкой отсчёта. Следующей командой произойдет вход в функцию RiseDivZero(). Следует напомнить назначение регистров, на которые следует обратить внимание при разборе:

  • R13(SP) - Stack Pointer - Указатель стека, равен MSP (Main Stack Pointer);
  • R14(LR) - Link Register - Адрес возврата из текущей функции, либо код EXC_RETURN;
  • R15(PC) - Адрес исполняемой инструкции.


Рисунок 1 - Вызов функции RiseDivZero() при отладке в IDE Keil

Из рисунка 1 видно, что

  • PC содержит адрес ассемблерной инструкции, которая сейчас будет исполняться, - это переход в функцию RiseDivZero();
  • SP содержит адрес, откуда будет прирастать стек;
  • Значение LR - не интересует;
  • Данные в стеке - не интересуют.

Деление на 0 в RiseDivZero()

Далее в ассемблерном отладчике необходимо войти по шагам F11 в функцию RiseDivZero() и дойти до инструкции вызова деления, как это показано на рисунке 2. Следующий шаг произойдет в обработчике HardFault_Handler, следует обратить внимание на регистры до этого шага:

  • SP - указатель стека не изменился, так как не было в нем необходимости. Компилятор реализовал функцию без локальных переменных, целиком на регистрах.
  • PC - содержит адрес команды деления на 0. Именно сюда будет возвращаться исполнение из обработчика исключения, если не сделать манипуляции со стеком, которые были сделаны в HardFault_Handler_C: stack[pc] = stack[pc] + 4.
  • LR - содержит адрес возврата в функцию main(). Вызов RiseDivZero() произошел с адреса 0x0800_0326, а следующий за этим адрес - это 0x0800_032A. На рисунке 2 LR = 0x0800_032B, то есть младший бит установлен в 1 - на конце 0xB, а не 0xA. По соглашению ARM это обозначает, что при переходе по LR будут использоваться инструкции Thumb2.


Рисунок 2 - Вызов инструкции деления в функции RiseDivZero() при отладке в IDE Keil

Вход в HardFault_Handler

При входе в обработчик происходит в стек сохраняются регистры R0-R3, R12, R13(SP), R14(LR), R15(PSR). Сами регистры получают новые значения:

  • SP - указатель стека уменьшается на те 8 слов, в которые сохранились регистры.
  • LR - содержит код EXC_RETURN = 0xFFFFFFF9. Это значение не является допустимым адресом, поэтому логика ядра поймет, что надо восстановить регистры из стека. При восстановлении регистра PC из стека исполнение перейдет на адрес, указанный в PC.
  • PC - адрес обработчика исключения был взят из таблицы векторов и записан в PC, так началось исполнение этого обработчика.

Отладчиком в окне дизассемблера необходимо пройти шагами F11 до инструкции перехода на обработчик HardFault_Handler_C(). Состояние в отладчике выглядит так, как показано на рисунке 3:


Рисунок 3 - Переход в обработчик HardFault_Handler() при отладке в IDE Keil

Вход в обработчик на Си, HardFault_Handler_С

При входе в функцию важно обратить внимание, что регистр LR сохранил значение 0xFFFFFFF9. Это произошло потому, что выполнен прямой переход на адрес функции. Это аналог GoTo. По этой причине при выходе из обработчика HardFault_Handler_С возврат будет не в ассемблерный HardFault_Handler, а сразу к коду, вызвавшему исключение.

Варианты вызова перехода на обработчик:

// Вариант GoTo
   LDR R1, =HardFault_Handler_C
   BX R1
// Вариант Call
   BL HardFault_Handler_C
Фрагмент кода 6

На рисунке 4 выделено, какую ячейку стека меняет код, само значение этой ячейки будет также видно из рисунка.


Рисунок 4 - Обработчик на Си HardFault_Handler_C при отладке в IDE Keil

В ассемблерном окне видно команду выхода по LR. Но поскольку LR равно одному из значений EXC_RETURN, то выход произойдет не по адресу в LR, а будет восстановлено значение регистров из стека. То есть по факту произойдет переход исполнения по адресу, загруженному из стека в регистр PC.

Возврат в RiseDivZero()

Следуя далее по шагам F11, исполнение возвращается на инструкцию, следующую за той, с которой возникло исключение по делению на ноль. То есть происходит возврат из исключения назад. Наглядно показано на рисунке 5.

  • SP - Указатель стека вернулся назад. Место занятое под значения регистров освободилось.
  • LR - Вернул значение из стека и указывает на адрес возврата в main().
  • PC - Содержит то значение, что было модифицировано в HardFault_Handler_C. Это же значение видно и в стеке.


Рисунок 5 - Возврат в функцию RiseDivZero() при отладке в IDE Keil

Инструкция, которая теперь будет выполнена - это возврат в main() по адресу в LR

Выводы

Необходимо помнить, что:

  • при входе в функцию адрес возврата из нее сохраняется в LR, по этому же адресу происходит возврат;
  • если далее происходит вход еще в одну подфункцию, то значение LR вместе с другими регистрами сохраняется в стек и при выходе из подфункции восстанавливается, чтобы затем осуществить возврат;
  • значения в функции передаются через регистры, если регистров не хватает - через стек;
  • при входе в обработчик прерывания регистры сохраняются в стек, а в LR записывается код EXC_RETURN;
  • если из обработчика прерывания происходит вход в подфункцию, то поведение - как с обычной подфункцией.

Контактная информация

Сайт:https://support.milandr.ru
E-mail:support@milandr.ru
Телефон: +7 495 221-13-55