Embedded HandbookПриложения
Все разделы › II. Ядро и системные механизмы
STM32F407, STM32F405 · уровень продвинутый · 7 мин чтения
Открыть в интерактивном справочнике

7а. Вход в исключение изнутри: кадр стека, EXC_RETURN и FPU

В разделе 7 обработчик прерывания выглядел обычной функцией на C. Так и задумано: ядро Cortex-M само сохраняет то, что функция на C обязана сохранить, и само же возвращает всё назад. Этот раздел — что именно происходит на входе и выходе: какие регистры попадают на стек и в каком порядке, что за странное число оказывается в LR, как ядро выбирает стек и почему с включённым FPU кадр втрое больше, но почти ничего не стоит.

Всё показанное — прогон в QEMU (машина netduinoplus2, ядро Cortex-M4F): программа _verify/exc7a/exc7a.c кладёт в регистры приметные числа, поднимает прерывание через NVIC, а обработчик на ассемблере запоминает LR и оба указателя стека; затем кадр читается прямо со стека. Правила сверены с руководством по ядру Cortex-M4 из пакета. Сверяет _verify/exc7a_check.py.

Как это называется в документации

Оригинал Перевод
stacking, stack frame сохранение состояния на стек, кадр
EXC_RETURN особое значение в LR, по которому ядро узнаёт выход из исключения
MSP, PSP основной и процессный указатели стека
Thread mode, Handler mode режим потока и режим обработчика
STKALIGN, aligner выравнивание кадра по 8 байтам и слово-заполнитель
lazy stacking, FPCCR.LSPEN ленивое сохранение регистров FPU
FPCAR адрес места под регистры FPU в кадре
late-arriving exception опоздавшее исключение

7а.1 Что ядро кладёт на стек

Перед первой командой обработчика ядро само сохраняет восемь слов:

Смещение от SP в обработчике Слово В опыте
+0x00 R0 0x11111111
+0x04 R1 0x22222222
+0x08 R2 0x33333333
+0x0C R3 0x44444444
+0x10 R12 0xCCCCCCCC
+0x14 LR адрес возврата из прерванной функции
+0x18 PC куда вернуться: следующая команда прерванного кода
+0x1C xPSR флаги и номер исключения; бит 24 — Thumb

Именно эти регистры функция на C не обязана сохранять — поэтому обработчик и может быть обычной функцией: остальное (R4–R11) сохранит сам компилятор, если они понадобятся.

Куда именно вернёмся. В опыте прерывание поднимается командой str в регистр NVIC, за ней dsb и isb. В кадре PC = 0x0800011C — адрес nop после isb: в QEMU исключение принято именно там.


7а.2 Выравнивание: 32 байта, которые оказались 36

Руководство по ядру описывает кадр из восьми слов — 32 байта. Опыт намерил 36. Причина — выравнивание: при входе ядро выравнивает кадр по 8 байтам (бит STKALIGN). Если указатель стека до входа делился на 8 только с остатком 4, ядро вставляет слово-заполнитель и отмечает это в бите 9 xPSR, чтобы убрать его при выходе.

В опыте xPSR в кадре — 0x21000200: бит 24 (Thumb) и бит 9 — заполнитель был. Выравнивание по 8 байтам ожидает от стека соглашение о вызовах ARM, а прерывание может прийти где угодно — поэтому ядро выравнивает само.

⚠️ У STM32F1 этого выравнивания после сброса нет. Там ядро Cortex-M3 ревизии r1p1 (раздел 2а), и бит STKALIGN после сброса равен 0: кадр выравнивается лишь по 4 байтам. С ревизии r2p0 бит включён сразу. На F1 его включают первой строкой программы: SCB->CCR |= SCB_CCR_STKALIGN_Msk; — иначе обработчик, работающий с double или 64-битными числами, получит невыровненный стек (раздел 7в.3).


7а.3 EXC_RETURN: число в LR

Обычный вызов функции кладёт в LR адрес возврата. На входе в исключение ядро кладёт туда EXC_RETURN — число вида 0xFFFFFFxx, которого не бывает среди адресов кода. Когда обработчик делает bx lr (или pop {pc}), ядро видит это число и выполняет выход из исключения: снимает кадр со стека. Младшие биты говорят, какой стек и какой кадр:

EXC_RETURN Вернуться в Стек Кадр В опыте
0xFFFFFFF1 обработчик (вложенное) MSP без FPU —
0xFFFFFFF9 основной код MSP без FPU основной код на MSP
0xFFFFFFFD основной код PSP без FPU основной код на PSP
0xFFFFFFE1 обработчик MSP с FPU —
0xFFFFFFE9 основной код MSP с FPU после команды FPU
0xFFFFFFED основной код PSP с FPU —

Три значения из шести опыт получил сам: основной код на MSP — 0xFFFFFFF9; переключили основной код на процессный стек (CONTROL.SPSEL = 1) — 0xFFFFFFFD, и кадр лёг на PSP; выполнили одну команду FPU — 0xFFFFFFE9.

Младший байт EXC_RETURN по битам: бит 4 — кадр без FPU или с FPU, бит 3 — вернуться в основной код или в обработчик, бит 2 — стек PSP или MSP; шесть значений от 0xFFFFFFF1 до 0xFFFFFFED разложены по этим битам, три из них получены в опыте — F9 на MSP, FD после переключения на PSP, E9 после команды FPU

Отсюда практическое правило: в обработчике нельзя портить LR, если он выходит через bx lr. Компилятор это знает; ассемблерные обработчики — нет. И это же число помогает при разборе отказа (раздел 35): бит 2 EXC_RETURN говорит, с какого стека брать кадр.

Зачем два стека. Ядро и прерывания всегда работают на MSP. ОСРВ (раздел 24) переключает задачи на PSP — у каждой свой стек, — и прерывание кладёт кадр на стек прерванной задачи, а сам обработчик идёт уже по MSP. Переключение контекста — это подмена PSP между входом и выходом.


7а.4 С FPU: 108 байт, которые почти ничего не стоят

Кадр исключения без FPU и с FPU: восемь слов, S0–S15 и FPSCR, заполнитель; ленивое сохранение

Если основной код пользовался FPU, в кадре нужно место и для его регистров: S0–S15, FPSCR и одно резервное слово — ещё 18 слов, всего 26 слов = 104 байта. Опыт намерил 108 — те же 4 байта заполнителя.

Как ядро узнаёт, что FPU использовался: первая же команда FPU ставит бит FPCA в CONTROL — опыт прочитал CONTROL = 0x00000004 сразу после vmov. Без него кадр обычный, 32 байта, даже если FPU включён.

Ленивое сохранение. Сохранять 17 регистров на каждом прерывании дорого, а обработчик чаще всего FPU не трогает. Поэтому по умолчанию (FPCCR = 0xC0000000: биты ASPEN и LSPEN) ядро только резервирует место в кадре и запоминает его адрес в FPCAR; сами регистры пишутся туда, лишь если обработчик выполнит команду FPU.

Опыт это показал. Память под стеком заранее залили числом 0xDEADBEEF:

Режим S0 в кадре S15 в кадре FPSCR в кадре
ленивое (LSPEN = 1, по умолчанию) 0xDEADBEEF — не записан 0xDEADBEEF 0xDEADBEEF
сразу (LSPEN = 0) 0x3F800000 = 1,0 0x40000000 = 2,0 0x00000000

При ленивом сохранении место есть, а записи нет: обработчик не пользовался FPU. FPCAR = 0x2001FF90, указатель стека в обработчике — 0x2001FF70: разница 0x20 = 32 байта, то есть место под S0 начинается сразу за восемью словами.

Итог для практики: FPU в основном коде почти не удорожает прерывания, пока сами обработчики обходятся без плавающей точки. Стоит в обработчике сделать одно float-умножение — и ядро допишет 17 регистров.


7а.5 Сцепление и опоздавшие исключения

Два механизма из руководства по ядру, которые экономят такты:

Эмулятор такты честно не считает, поэтому числа — из описания ядра: вход в исключение у Cortex-M3/M4 занимает 12 тактов (у M0 — 16, раздел 7.1), а сцепление — 6 тактов вместо выхода и нового входа; ожидание Flash добавляет своё. Важно следствие: несколько прерываний подряд обходятся дешевле, чем столько же по отдельности, — и измерять задержку обработчика нужно под нагрузкой, а не по одному.


7а.6 Грабли

Ассемблерный обработчик вернулся «не туда». Портился LR — в нём EXC_RETURN, а не адрес.

Разбор HardFault даёт мусор. Кадр взяли со MSP, а исключение пришло из задачи на PSP. Смотреть бит 2 EXC_RETURN.

Кадр «съехал» на слово. Не учли заполнитель: бит 9 xPSR в кадре.

На F1 printf("%f") в прерывании иногда печатает мусор. Ядро r1p1: STKALIGN после сброса выключен, кадр выровнен лишь по 4 байтам. Включить бит в начале программы (7а.2).

Прерывания замедлились после включения FPU. Обработчик (или функция, которую он вызывает) использует float — ленивое сохранение срабатывает на каждом прерывании. Вынести вычисления из обработчика.

Отключили ленивое сохранение «для надёжности». Теперь 17 регистров пишутся на каждом прерывании, даже если FPU в обработчике не нужен.

Стек переполнился только с FPU. Кадр 104–108 байт вместо 32–36 — на каждом уровне вложенности.


7а.7 Итоги

Дальше: раздел 7б «Ассемблер Cortex-M: Thumb-2 глазами C».

Открыть в интерактивном справочнике