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.
Отсюда практическое правило: в обработчике нельзя портить LR, если он
выходит через bx lr. Компилятор это знает; ассемблерные обработчики —
нет. И это же число помогает при разборе отказа (раздел 35): бит 2 EXC_RETURN
говорит, с какого стека брать кадр.
Зачем два стека. Ядро и прерывания всегда работают на MSP. ОСРВ (раздел 24) переключает задачи на PSP — у каждой свой стек, — и прерывание кладёт кадр на стек прерванной задачи, а сам обработчик идёт уже по MSP. Переключение контекста — это подмена PSP между входом и выходом.
7а.4 С FPU: 108 байт, которые почти ничего не стоят
Если основной код пользовался 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 Сцепление и опоздавшие исключения
Два механизма из руководства по ядру, которые экономят такты:
- Сцепление (tail-chaining): обработчик закончился, а другое исключение уже ждёт — ядро не снимает кадр со стека и не кладёт его заново, а сразу переходит к следующему обработчику.
- Опоздавшее исключение (late arrival): пока ядро сохраняло кадр для одного исключения, пришло более важное — ядро продолжает сохранение (кадр тот же) и уходит в обработчик важного. Принимается опоздавшее, пока первая команда обработчика первого исключения не дошла до исполнения.
Эмулятор такты честно не считает, поэтому числа — из описания ядра: вход в исключение у 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 Итоги
- На входе ядро само кладёт 8 слов: R0–R3, R12, LR, PC, xPSR. Плюс слово-заполнитель, если стек не делился на 8 (бит 9 xPSR): в опыте — 36 байт.
- В LR — EXC_RETURN: 0xFFFFFFF9 (основной код, MSP), 0xFFFFFFFD (PSP), 0xFFFFFFE9 (с кадром FPU); все три получены в опыте.
- Кадр с FPU — 26 слов (104 байта), в опыте 108; признак — бит FPCA в CONTROL.
- Ленивое сохранение (по умолчанию): место в кадре зарезервировано, регистры FPU не записаны — пока обработчик сам не тронет FPU.
- Сцепление и опоздавшие исключения экономят сохранение и восстановление кадра.
Дальше: раздел 7б «Ассемблер Cortex-M: Thumb-2 глазами C».