Инструменты пользователя

Инструменты сайта


prog:debug:stack

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

Пример разобран для 1986ВЕ92У (Cortex-M3), проект - GitHub.

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

HardFault_Handler     PROC
      HardFault_Handler                [WEAK]
      B       .
      ENDP

В этом коде ассемблерная строка "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

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

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

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

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

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

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

Обычно, этот код реализован как ассемблерная вставка в исходный файл на Си. Но я столкнулся с тем, то этот код утратил свою работоспособность. Видимо встроенный ассемблер сильно урезали в правах при развитии компиляторов. В частности, никакими ассемблерными командами мне не удалось прописать регистры RO-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
  }
}

Но и тут оказалось, что компилятор вместо записей в 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

В этом коде мы получаем указатель на стек и передаем его во внешнюю функцию 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]);
}

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

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

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

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

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

На картинке видно, что

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

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

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

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

Вход в 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(). Вот как выглядит это состояние в отладчике:

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

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

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

//  Вариант GoTo
    LDR     R1, =HardFault_Handler_C
    BX      R1
    
//  Вариант Call
    BL HardFault_Handler_C

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

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

Возврат в RiseDivZero()

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

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

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

Резюме

Описанное поведение выглядит несколько запутанным, и, чтобы разобраться, рекомендую запустить проект, и пройти его по шагам. Либо открыть все скриншоты на одном окне друг за другом, чтобы понять взаимосвязь адресов.

В целом надо усвоить следующее:

  • При входе в функцию адрес возврата из нее сохраняется в LR, по этому же адресу происходит возврат.
  • Если далее происходит вход еще в одну подфункцию, то значение LR вместе с другими регистрами сохраняется в стек и при выходе из подфункции восстанавливается, чтобы затем осуществить возврат.
  • Значения в функции передаются через регистры, если регистров не хватает - через стек.
  • При входе в обработчик прерывания регистры сохраняются в стек, а в LR записывается код EXC_RETURN.
  • Если из обработчика прерывания происходит вход в подфункцию, то поведение - как с обычной подфункцией.
prog/debug/stack.txt · Последнее изменение: 2022/04/03 23:09 (внешнее изменение)