Код запуска: что происходит от сброса до main()
Между сбросом микроконтроллера и первой строкой main() работает код запуска: он берёт начальный указатель стека и адрес обработчика сброса из таблицы векторов, копирует начальные значения переменных из флеш-памяти в ОЗУ, обнуляет неинициализированные переменные и вызывает конструкторы. Разбираем, что делает само ядро Cortex-M при сбросе, стартовый файл ST для STM32F103 и STM32F411, Reset_Handler на C, перенос таблицы векторов через VTOR, включение FPU и код запуска avr-libc с секциями .init0–.init9.
Обновлено Редакция GAW
Код запуска (startup code) — всё, что выполняется после сброса до первой строки main(). Его пишут производитель (стартовый файл startup_*.s для STM32) или библиотека (crt1 в avr-libc), и большую часть времени о нём можно не думать. Вспоминать приходится, когда переменная оказывается не той, что объявлена, прошивка после загрузчика не видит своих прерываний, первая же операция с float уводит в HardFault, или нужно что-то сделать раньше, чем запустится остальная программа. Ниже — по шагам, что делает ядро само, что делает код запуска и где в эту цепочку можно встроиться.
Коротко
- Cortex-M при сбросе сам берёт начальный указатель стека из слова 0 таблицы векторов и адрес обработчика сброса из слова 1; у адреса младший бит должен быть равен 1.
- Reset_Handler в шаблоне ST: SystemInit → копирование
.dataиз флеш-памяти в ОЗУ → обнуление.bss→ конструкторы →main(). - До копирования .data глобальные переменные содержат мусор, поэтому SystemInit не должна на них опираться.
- VTOR переносит таблицу векторов; её адрес выравнивают на степень двойки не меньше размера таблицы, минимум 128 байт.
- AVR и avr-libc: код запуска разложен по секциям
.init0–.init9; своё до инициализации переменных кладут в.init3.
Что делает ядро Cortex-M само
Сброс в архитектуре ARMv7-M описан псевдокодом TakeReset() (ARMv7-M ARM, B1.5.5). В переводе на обычный язык ядро после снятия сброса:
- переходит в режим Thread с привилегиями и основным стеком (MSP), сбрасывает маски прерываний PRIMASK, FAULTMASK и BASEPRI;
- если есть FPU — выключает доступ к нему: поля CP10 и CP11 регистра CPACR равны нулю;
- читает слово по адресу таблицы векторов и записывает его в SP, обнулив два младших бита;
- записывает в LR значение 0xFFFFFFFF — заведомо недопустимый адрес возврата;
- читает слово по адресу таблицы + 4, младший бит кладёт в бит T регистра состояния, а на остальные биты переходит.
Адрес таблицы при сбросе — значение регистра VTOR. Архитектура оставляет его на усмотрение производителя: либо задаётся входами ядра, либо равен нулю (B3.2.5). У STM32 он нулевой, а по адресу 0 отображается память, выбранная выводами BOOT: обычно флеш-память с адреса 0x08000000. Поэтому таблица векторов прошивки стоит в самом начале флеш-памяти — это обеспечивает скрипт компоновщика правилом KEEP(*(.isr_vector)), о котором подробно в статье о скриптах компоновщика.
Из этого следует важная вещь: на Cortex-M указатель стека готов ещё до первой команды. Обработчик сброса может быть обычной функцией на C, которая сразу пользуется стеком. На AVR и многих других архитектурах стек сначала надо настроить командами.
Бит T заслуживает отдельного внимания. Cortex-M выполняет только команды Thumb, и любой адрес в таблице векторов, кроме слова 0, должен быть нечётным. Если там окажется чётное число, первая же команда обработчика вызовет INVSTATE UsageFault (B1.5.3). Если обработчик UsageFault выключен, ошибка эскалирует в HardFault (B1.5.1) — а после сброса он выключен всегда, пока программа сама его не включит, поэтому на практике такая ошибка почти всегда выглядит как HardFault. Компоновщик ставит этот бит сам, если символ помечен как функция Thumb; ошибка появляется в самописных таблицах на ассемблере, где адрес взят у метки данных.
Таблица векторов
Порядок слов в таблице задан номерами исключений (ARMv7-M ARM, табл. B1-4 и B1-5):
| Смещение | Номер исключения | Содержимое |
|---|---|---|
| 0x00 | — | начальное значение MSP |
| 0x04 | 1 | Reset |
| 0x08 | 2 | NMI |
| 0x0C | 3 | HardFault |
| 0x10 | 4 | MemManage |
| 0x14 | 5 | BusFault |
| 0x18 | 6 | UsageFault |
| 0x1C–0x28 | 7–10 | зарезервировано |
| 0x2C | 11 | SVCall |
| 0x30 | 12 | DebugMonitor |
| 0x34 | 13 | зарезервировано |
| 0x38 | 14 | PendSV |
| 0x3C | 15 | SysTick |
| 0x40 + 4·n | 16 + n | внешнее прерывание n |
Номер внешнего прерывания — это IRQn из заголовка производителя. У STM32F103xB, например, USART1_IRQn = 37, значит адрес обработчика USART1 лежит по смещению 0x40 + 37 · 4 = 0xD4. Последнее прерывание этой линейки — USBWakeUp_IRQn = 42, всего в таблице 16 + 43 = 59 векторов.
В стартовом файле ST после последнего вектора есть ещё одно слово — BootRAM со значением 0xF108F85F по смещению 0x108, с комментарием «для загрузки из ОЗУ в устройствах средней плотности». Ядро его не читает как вектор; это особенность серии F1, переносить её в свои таблицы для других серий не нужно.
Слабые псевдонимы и Default_Handler
Все обработчики, кроме Reset_Handler, в стартовом файле объявлены слабыми псевдонимами одной функции:
.weak HardFault_Handler
.thumb_set HardFault_Handler,Default_Handler
Если в программе есть функция с тем же именем, компоновщик возьмёт её; если нет — вектор укажет на Default_Handler, который в шаблоне ST состоит из одной команды перехода на себя. Отсюда типичный симптом опечатки в имени обработчика: прерывание включено, флаг взводится, а программа «зависает». Остановив её отладчиком, видно, что она крутится в Default_Handler, а номер исключения подсказывает регистр IPSR (младшие 9 бит xPSR): вычтя 16, получаем IRQn. Подробнее о том, как не ошибиться с именем, — в статье о прерываниях STM32.
Таблица на C
Тот же механизм на C — массив указателей на функции, помещённый в секцию, которую скрипт компоновщика ставит в начало флеш-памяти:
#include <stdint.h>
typedef void (*vector_t)(void);
extern uint32_t _estack[]; /* top of stack, from the linker script */
void Reset_Handler(void);
void Default_Handler(void);
/* Weak aliases: a handler defined elsewhere overrides the default one. */
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));
void USART1_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
__attribute__((section(".isr_vector"), used))
const vector_t g_vectors[16 + 43] = {
[0] = (vector_t)(uintptr_t)_estack, /* initial MSP */
[1] = Reset_Handler,
[2] = NMI_Handler,
[3] = HardFault_Handler,
[15] = SysTick_Handler,
[16 + 37] = USART1_IRQHandler, /* USART1_IRQn = 37 */
/* the remaining entries must also point to handlers, not stay zero */
};
void Default_Handler(void)
{
for (;;) { }
}
Назначенные инициализаторы [16 + 37] = (C99 и C11, 6.7.9) избавляют от подсчёта позиций, но оставляют нулями незаполненные слова. Ноль — чётный адрес, и прерывание с таким вектором закончится HardFault, поэтому в настоящей таблице заполняют все позиции, хотя бы Default_Handler. Атрибут used не даёт компилятору выбросить массив, на который нет ссылок, а KEEP в скрипте компоновщика защищает секцию от --gc-sections. Преобразование адреса _estack в указатель на функцию формально выходит за рамки стандарта C, но GCC его выполняет как ожидается; ST в своих файлах обходит вопрос, записывая таблицу на ассемблере директивами .word.
Reset_Handler в стартовом файле ST
Обработчик сброса из шаблона startup_stm32f103xb.s (cmsis-device-f1, коммит c8e9a4a) по шагам:
| Шаг | Что делает | Откуда берёт адреса |
|---|---|---|
| 1 | bl SystemInit — функция из system_stm32f1xx.c | — |
| 2 | копирует слова из флеш-памяти в ОЗУ | от _sidata (LMA) в _sdata…_edata (VMA) |
| 3 | записывает нули | от _sbss до _ebss |
| 4 | bl __libc_init_array — вызов статических конструкторов | таблицы .preinit_array, .init_array |
| 5 | bl main | — |
Символы _sidata, _sdata, _edata, _sbss, _ebss и _estack определяет скрипт компоновщика. _sidata — адрес хранения (LMA) начальных значений во флеш-памяти, _sdata и _edata — границы секции .data в ОЗУ (VMA). Разница между этими адресами — главная причина, по которой код запуска вообще нужен: начальные значения переменных обязаны пережить выключение питания, а сами переменные должны быть в ОЗУ. Подробнее о двух адресах секции — в статье о файлах прошивки и в глоссарии: VMA и LMA, секция .bss.
Копирование и обнуление идут словами по 4 байта, поэтому скрипт компоновщика выравнивает границы этих секций на 4 (. = ALIGN(4);). Если кто-то удалит выравнивание, цикл скопирует на одно слово больше или меньше, и последняя переменная получит чужое значение.
В шаблоне для STM32F411 (cmsis-device-f4, коммит 9192c7b) перед вызовом SystemInit стоит ещё одна команда — ldr sp, =_estack. Ядро уже загрузило SP из таблицы, но запись нужна, когда в Reset_Handler попадают не через сброс: например, загрузчик или отладчик просто передаёт управление на его адрес, не трогая стек.
Почему SystemInit не должна трогать глобальные переменные
SystemInit вызывается до копирования .data и обнуления .bss. Всё, что она запишет в глобальную переменную, будет затёрто на следующих шагах, а всё, что прочитает, — мусор. Шаблоны ST это соблюдают: SystemInit() для F1 при определённом USER_VECT_TAB_ADDRESS только записывает адрес таблицы в SCB->VTOR, а без этого макроса не делает ничего, кроме настройки внешней памяти на старших моделях. Переменная SystemCoreClock объявлена с начальным значением 8000000 — частотой внутреннего генератора HSI — и получает его как раз при копировании .data. Тактирование от кварца и PLL настраивают уже в main(): в проектах CubeMX это функция SystemClock_Config(), а частоты и делители удобно посчитать в инструменте «Тактирование STM32».
Если в SystemInit всё же нужно что-то запомнить — например, флаги причины сброса из RCC->CSR, — переменную кладут в секцию без инициализации (.noinit), которую код запуска не трогает. Как её завести в скрипте компоновщика, показано в статье о скриптах компоновщика.
FPU на Cortex-M4 и M7
После сброса FPU выключен: TakeReset() обнуляет поля CP10 и CP11 регистра CPACR. Любая команда с плавающей точкой в таком состоянии вызывает UsageFault, который при выключенном обработчике UsageFault превращается в HardFault. Поэтому включение FPU — одна из немногих вещей, которые обязаны случиться до main(), ведь компилятор с -mfloat-abi=hard может применить регистры FPU в любой функции, даже в коде запуска библиотеки.
В шаблоне ST для F4 это делает SystemInit:
#if (__FPU_PRESENT == 1) && (__FPU_USED == 1)
SCB->CPACR |= ((3UL << 10*2) | (3UL << 11*2)); /* set CP10 and CP11 Full Access */
#endif
Это те же биты 20–23 регистра CPACR по адресу 0xE000ED88, что и в примере из руководства Cortex-M4 (DUI 0553B, 4.6.6). В руководстве после записи стоят барьеры DSB и ISB: первый дожидается завершения записи, второй сбрасывает конвейер, чтобы следующие команды выполнялись уже с включённым FPU. В своём коде запуска на C это выглядит так:
SCB->CPACR |= (0xFUL << 20); /* CP10 and CP11: full access */
__DSB();
__ISB();
Reset_Handler на C
Тот же порядок действий без ассемблера. Символы скрипта компоновщика — это адреса без значений (GNU ld, Source Code Reference), поэтому их объявляют массивами и берут только адрес:
#include <stddef.h>
#include <stdint.h>
extern uint32_t _sidata[]; /* LMA of .data in flash */
extern uint32_t _sdata[], _edata[]; /* VMA of .data in RAM */
extern uint32_t _sbss[], _ebss[];
void SystemInit(void);
void __libc_init_array(void);
int main(void);
void Reset_Handler(void)
{
SystemInit(); /* must not use .data or .bss */
size_t n = (size_t)((uintptr_t)_edata - (uintptr_t)_sdata) / sizeof(uint32_t);
for (size_t i = 0; i < n; i++)
_sdata[i] = _sidata[i];
n = (size_t)((uintptr_t)_ebss - (uintptr_t)_sbss) / sizeof(uint32_t);
for (size_t i = 0; i < n; i++)
_sbss[i] = 0u;
__libc_init_array(); /* static constructors (newlib) */
(void)main();
for (;;) { } /* main() must not return */
}
Несколько замечаний к этому коду.
- Длина через
uintptr_t. Сравнивать указатели&_sdata < &_edataна разные объекты стандарт C не разрешает (6.5.8), хотя GCC такой код компилирует. Разность целых адресов — честная операция. - Цикл может стать вызовом
memset. С-O2GCC включает-ftree-loop-distribute-patterns, который превращает цикл обнуления в вызовmemset. В коде запуска это не страшно —memsetиз библиотеки не пользуется.data, — но если вы пишете своюmemset, компилятор может превратить её тело в вызов самой себя. Для такого файла задают-fno-tree-loop-distribute-patterns. - Нельзя возвращаться из
main(). LR при входе в Reset_Handler равен 0xFFFFFFFF, возвращаться некуда. Стартовый файл ST послеbl mainвыполняетbx lr, то есть результат возврата изmain()там не определён; бесконечный цикл после вызова делает поведение предсказуемым. - Без стандартного кода запуска. Если свой Reset_Handler полностью заменяет стартовый файл, его убирают из проекта, а компоновщику передают
-nostartfiles, чтобы не подключались стандартные файлы запуска инструментария.
Перенос таблицы векторов: VTOR
Когда в начале флеш-памяти стоит загрузчик, приложение собирают с другого адреса, например 0x08004000, а таблицу векторов приложения нужно показать ядру. Для этого есть регистр VTOR (Vector Table Offset Register) по адресу 0xE000ED08, в CMSIS — SCB->VTOR. Поле TBLOFF занимает биты 31:7, младшие биты адреса всегда нулевые.
Выравнивание — частая ошибка. Архитектура требует, чтобы адрес таблицы был кратен степени двойки, не меньшей размера таблицы, и минимум 128 байтам (ARMv7-M ARM, B1.5.3). Руководство Cortex-M3 формулирует то же через слова: минимум 32 слова, хватает на 16 прерываний, а для 21 прерывания таблица занимает 37 слов, и граница — 64 слова (DUI 0552A, 4.3.4).
| Микроконтроллер | Векторов | Размер таблицы | Выравнивание адреса |
|---|---|---|---|
| ядро без внешних прерываний | 16 | 64 байта | 128 байт (минимум) |
| пример из руководства Cortex-M3 | 16 + 21 = 37 | 148 байт | 256 байт |
| STM32F103xB | 16 + 43 = 59 | 236 байт | 256 байт |
Адрес 0x08004000 кратен 256 и подходит. А 0x08004080 в поле TBLOFF записать можно, но он выровнен только на 128 байт, а таблица STM32F103xB занимает 236; такое размещение архитектура запрещает, и где ядро станет искать векторы старших прерываний, не определено.
В шаблоне ST перенос включают макросами: раскомментировать USER_VECT_TAB_ADDRESS и задать VECT_TAB_OFFSET в system_stm32f1xx.c, тогда SystemInit() запишет SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET. В шаблоне для F4 запись в VTOR выполняется всегда, без макроса — значением FLASH_BASE.
Сам переход из загрузчика в приложение выглядит так:
#include "stm32f1xx.h"
#define APP_BASE 0x08004000u
typedef void (*entry_t)(void);
void jump_to_app(void)
{
const uint32_t *app = (const uint32_t *)APP_BASE;
uint32_t sp = app[0]; /* initial MSP of the application */
entry_t entry = (entry_t)app[1]; /* its Reset_Handler, bit 0 set */
__disable_irq();
SysTick->CTRL = 0; /* stop the bootloader's tick */
for (uint32_t i = 0; i < 8; i++) {
NVIC->ICER[i] = 0xFFFFFFFFu; /* disable all IRQs */
NVIC->ICPR[i] = 0xFFFFFFFFu; /* clear pending ones */
}
SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk; /* SysTick is not in NVIC: clear it here */
SCB->VTOR = APP_BASE;
__set_MSP(sp);
entry(); /* PRIMASK stays 1: the app enables IRQs */
}
Перед прыжком загрузчик выключает всё, что включал сам: SysTick, прерывания периферии, по возможности возвращает тактирование к состоянию после сброса. Иначе первое же прерывание от его таймера придёт в приложение, где обработчика нет или он ждёт другого. SysTick — системное исключение, а не прерывание NVIC: регистры ICER и ICPR его не касаются, и уже взведённый запрос SysTick снимают битом PENDSTCLR регистра ICSR (ARMv7-M ARM, B1.5.1).
Прерывания перед entry() загрузчик не разрешает. Иначе любой запрос, который успеет появиться между __enable_irq() и первыми командами приложения, будет обслужен по таблице приложения, когда оно ещё не настроило ни стек, ни переменные, ни периферию для этого обработчика. Обратная сторона: после такого перехода PRIMASK остаётся равным 1, а не 0, как после настоящего сброса (B1.5.5). Приложение, которое полагается на состояние после сброса и само прерывания нигде не разрешает, останется с запрещёнными прерываниями. Поэтому приложение, рассчитанное на запуск из загрузчика, само вызывает __enable_irq() после своей инициализации, и это нужно оговорить в описании интерфейса между загрузчиком и приложением. Приложение, в свою очередь, не должно рассчитывать на то, что периферия в состоянии после сброса. Как устроены встроенный загрузчик и выбор области загрузки у STM32 — в статье о загрузчике и прошивке STM32.
AVR: код запуска avr-libc
У AVR нет аппаратной загрузки указателя стека из таблицы: по адресу 0 стоит обычная команда перехода на код запуска. В ATmega328P 26 векторов по два слова — команда JMP (DS40002061B, табл. 12-6): сброс по адресу 0x0000, внешнее прерывание INT0 — 0x0002 и так далее до SPM READY — 0x0032. Адреса в даташите — номера слов флеш-памяти, в байтах они вдвое больше. В avr-libc размер таблицы задан как _VECTORS_SIZE (26 * 4), то есть 104 байта.
Нумерация векторов в даташите и в avr-libc отличается на единицу: в таблице 12-6 приём USART — вектор 19, а в <avr/iom328p.h> — USART_RX_vect = _VECTOR(18), потому что avr-libc считает сброс нулевым вектором. Это нужно помнить, когда вектор ищут по номеру в листинге.
Секции .init0–.init9
Код запуска avr-libc разбит на секции .init0….init9, которые компоновщик выкладывает подряд, и выполнение проходит их одну за другой без вызовов и возвратов (sections.dox):
| Секция | Что выполняет | Чей код |
|---|---|---|
.init0 | метка __init, на неё ведёт вектор сброса | avr-libc |
.init1 | не используется | — |
.init2 | обнуляет регистр __zero_reg__ (r1) и SREG, записывает в SP значение __stack (по умолчанию RAMEND) | avr-libc |
.init3 | не обязателен: отображение флеш-памяти у новых AVR, раскраска ОЗУ для _get_ram_unused(); сюда же ставят свой код | avr-libc, пользователь |
.init4 | копирует .data из флеш-памяти, обнуляет .bss (__do_copy_data, __do_clear_bss) | libgcc |
.init5 | не используется | — |
.init6 | статические конструкторы C++ и функции с __attribute__((constructor)) | libgcc |
.init7, .init8 | не используются | — |
.init9 | вызывает main(), затем переходит на exit() | avr-libc |
Код из .init4 компилятор подключает только при необходимости: если в программе нет инициализированных переменных, копирования не будет. После exit() выполняются секции .fini9….fini0, и последняя запрещает прерывания и останавливает программу в бесконечном цикле.
Указатель стека у ATmega328P и так равен RAMEND сразу после сброса (DS40002061B, 7.5), но код запуска записывает его снова: в программу можно попасть и прыжком на адрес 0, а не только через сброс.
Свой код до инициализации переменных
Классический пример — сторожевой таймер. После сброса от него он остаётся включённым, по документации avr-libc — с самым коротким периодом, около 15 мс (в даташите это 2048 тактов генератора 128 кГц, типично 16 мс), и если код запуска с копированием большой секции .data и конструкторами работает дольше, микроконтроллер будет перезапускаться бесконечно. Поэтому его выключают в .init3 — после настройки __zero_reg__ и стека, но до инициализации переменных. Пример из документации <avr/wdt.h>:
#include <stdint.h>
#include <avr/io.h>
#include <avr/wdt.h>
uint8_t mcusr_mirror __attribute__((section(".noinit")));
__attribute__((used, unused, naked, section(".init3")))
static void get_mcusr(void)
{
mcusr_mirror = MCUSR; /* keep the reset cause for main() */
MCUSR = 0; /* WDRF must be cleared before WDE can be */
wdt_disable();
}
Атрибут naked убирает пролог и эпилог: функция не вызывается, а выполняется «на проходе» между .init2 и .init4, и команда возврата в ней была бы ошибкой. used не даёт компилятору выбросить функцию, на которую никто не ссылается. Переменная лежит в .noinit, иначе код из .init4 обнулил бы её сразу после записи. Как устроен сторожевой таймер и где его сбрасывать — в статье о сторожевых таймерах.
Непредусмотренное прерывание на AVR
Неиспользуемые векторы avr-libc направляет на __bad_interrupt, а тот — на __vector_default, который по умолчанию равен __vectors, то есть адресу 0. Прерывание, для которого забыли написать обработчик (или ошиблись в имени вектора), перезапускает программу. Это не аппаратный сброс: регистры периферии сохраняют настройки, флаги в MCUSR не меняются, и программа стартует в непривычном окружении. Определив свой обработчик ISR(BADISR_vect), такие случаи можно ловить явно. Как выглядит этот путь на уровне регистров, разобрано в уроке о таймерах и прерываниях курса «AVR с нуля».
Что делать до main(), а что в main()
Хорошее правило — в коде запуска только то, без чего не может работать C:
- до
main(): указатель стека,.data,.bss, конструкторы, FPU на Cortex-M4F и M7, отключение сторожевого таймера на AVR после сброса от него, внешняя память, если в ней лежат переменные; - в
main(): тактирование от кварца и PLL, задержки ожидания флеш-памяти, периферия, прерывания.
Причина простая: в main() работают все механизмы языка, отладчик останавливается на первой строке, а ошибку видно в обычном коде. Ошибка в коде запуска выглядит как «программа не доходит до main» и ищется пошаговым выполнением ассемблера. Готовые последовательности инициализации тактирования и периферии собирают инструменты «Инициализация STM32» и «Инициализация AVR».
Типичные ошибки
- Чтение глобальной переменной в SystemInit или в
.init3— значение ещё не скопировано. Нужные там переменные кладут в.noinit. - Чётный адрес в таблице векторов — HardFault при первом же исключении; обычно это пропущенная позиция в таблице на C или метка данных в таблице на ассемблере.
- Опечатка в имени обработчика — вектор остаётся на
Default_Handler(STM32) или__bad_interrupt(AVR): зависание или тихий перезапуск. - Неверное выравнивание VTOR после загрузчика — приложение получает векторы не из своей таблицы. Адрес таблицы кратен степени двойки не меньше её размера.
- FPU не включён на Cortex-M4F — HardFault на первой команде с плавающей точкой, иногда в библиотечной функции задолго до
main(). .dataбезAT> FLASHв скрипте компоновщика — секция есть, а начальных значений в прошивке нет. Проверяется по map-файлу: у.dataдолжны различаться VMA и LMA.- Возврат из
main()— на Cortex-M переход по LR = 0xFFFFFFFF или по случайному значению, на AVR — остановка в.fini0с запрещёнными прерываниями. - Долгий код до
main()при включённом сторожевом таймере — бесконечные перезапуски после первого же сброса от него.
Частые вопросы
Что делает Reset_Handler в STM32?
В стартовом файле ST для STM32F103 он вызывает SystemInit, копирует начальные значения переменных из флеш-памяти в ОЗУ (секция .data), обнуляет секцию .bss, вызывает __libc_init_array для статических конструкторов и только потом main(). Указатель стека к этому моменту уже загружен ядром из нулевого слова таблицы векторов; в файлах для STM32F4 его на всякий случай записывают ещё раз первой командой.
Почему глобальная переменная равна мусору, хотя в коде у неё есть начальное значение?
Значение хранится во флеш-памяти, а в ОЗУ его переносит код запуска. Если переменную прочитали раньше — в SystemInit, в функции из секции .init3 на AVR, в собственном Reset_Handler до цикла копирования, — там ещё лежит то, что было в ОЗУ до сброса. Вторая причина — скрипт компоновщика без AT> FLASH у секции .data: тогда начальные значения вообще не попадают в прошивку.
Как перенести таблицу векторов, если программа стоит после загрузчика?
Записать адрес новой таблицы в SCB->VTOR: в шаблоне ST для этого определяют USER_VECT_TAB_ADDRESS и смещение VECT_TAB_OFFSET. Адрес должен быть выровнен на степень двойки, не меньшую размера таблицы: у STM32F103xB 59 векторов, 236 байт, значит граница 256 байт. Скрипт компоновщика приложения при этом должен начинать флеш-память с того же адреса.
Что будет, если у прерывания нет обработчика?
На STM32 вектор указывает на Default_Handler — бесконечный цикл, программа зависает внутри него, а отладчик показывает, в каком исключении она стоит. В avr-libc вектор ведёт на __bad_interrupt, который прыгает на адрес 0, то есть программа начинается заново, но без аппаратного сброса: периферия сохраняет свои настройки.
Можно ли написать код запуска на C без ассемблера?
Для Cortex-M — да: ядро само загружает указатель стека из таблицы векторов, поэтому Reset_Handler может быть обычной функцией на C, а таблица — массивом указателей в отдельной секции. Нельзя только пользоваться глобальными переменными до копирования .data и обнуления .bss. На AVR код запуска обычно берут из avr-libc, а свои действия добавляют в секцию .init3.
Источники
- Arm. ARMv7-M Architecture Reference Manual, DDI 0403E.e, 2021 — B1.5.1 — неисправности MemManage, BusFault и UsageFault можно выключить, тогда они эскалируют в HardFault; SysTick управляется битами ICSR.PENDSTSET и PENDSTCLR; B1.5.3 — порядок векторов по номерам исключений, слово 0 — начальное значение SP_main, у остальных слов бит 0 должен быть равен 1 (он задаёт бит T), иначе первая команда вызывает INVSTATE UsageFault, а при сбросе — HardFault; выравнивание таблицы — степень двойки не меньше числа исключений × 4, минимум 128 байт; B1.5.5 — TakeReset(): режим Thread, PRIMASK, FAULTMASK и BASEPRI сброшены, CPACR.cp10 и cp11 = 00, SP_main = слово 0 AND 0xFFFFFFFC, LR = 0xFFFFFFFF, переход по слову 1 с очищенным битом 0; B3.2.5 — VTOR, поле TBLOFF в битах 31:7, значение после сброса задаёт производитель или ноль
- Arm. Cortex-M3 Devices Generic User Guide, DUI 0552A, 2010; Cortex-M4 Devices Generic User Guide, DUI 0553B — DUI 0552A, табл. 4-12 — VTOR по адресу 0xE000ED08; 4.3.4 — выравнивание таблицы при записи VTOR: минимум 32 слова (до 16 прерываний), иначе округление до степени двойки, пример: 21 прерывание → таблица 37 слов → граница 64 слов; DUI 0553B, 4.6.6 — FPU после сброса выключен, CPACR по адресу 0xE000ED88, биты 20–23 включают сопроцессоры CP10 и CP11, затем DSB и ISB
- STMicroelectronics. cmsis-device-f1, коммит c8e9a4a: startup_stm32f103xb.s (gcc) и system_stm32f1xx.c; cmsis-device-f4, коммит 9192c7b: startup_stm32f411xe.s и system_stm32f4xx.c — Reset_Handler: вызов SystemInit, копирование .data словами от _sidata в _sdata…_edata, обнуление .bss от _sbss до _ebss, __libc_init_array («Call static constructors»), main; у F411 первой командой ldr sp, =_estack; Default_Handler — бесконечный цикл, обработчики — слабые псевдонимы через .thumb_set; слово BootRAM = 0xF108F85F по смещению 0x108 у F103xB; SystemInit у F1 только переносит таблицу при USER_VECT_TAB_ADDRESS, SystemCoreClock = 8000000; SystemInit у F4 включает CP10 и CP11 при __FPU_PRESENT и __FPU_USED и записывает в SCB->VTOR адрес FLASH_BASE; в stm32f103xb.h USART1_IRQn = 37, последнее прерывание — USBWakeUp_IRQn = 42; в шаблоне скрипта STM32F103XB_FLASH.ld секции .preinit_array и .init_array с символами __init_array_start и __init_array_end
- AVR-LibC 2.3.2: crt1/gcrt1.S, Memory Sections (doc/api/sections.dox), <avr/wdt.h>, <avr/iom328p.h> — таблица .vectors: первый вектор — переход на __init, неиспользуемые — на __bad_interrupt, который переходит на __vector_default = __vectors, то есть на адрес 0; секции .init0–.init9 и .fini9–.fini0: .init2 — __zero_reg__, SREG = 0 и SP = __stack (по умолчанию RAMEND), .init3 — место для своего кода, .init4 — __do_copy_data и __do_clear_bss из libgcc, .init6 — конструкторы, .init9 — вызов main и переход на exit, .fini0 — cli и бесконечный цикл; wdt.h: после сброса от сторожевого таймера он остаётся включённым с самым коротким периодом (около 15 мс), пример чтения MCUSR и отключения таймера в .init3 с переменной в .noinit; interrupt.h — BADISR_vect; _VECTORS_SIZE = 26 × 4, USART_RX_vect = _VECTOR(18)
- Microchip. ATmega48A/PA/88A/PA/168A/PA/328/P Data Sheet, DS40002061B, 2020 — 7.5 — начальное значение SPH:SPL равно RAMEND; 12.4, табл. 12-6 — 26 векторов ATmega328P по два слова, сброс по адресу 0x0000, USART_RX — вектор 19 по адресу 0x0024; BOOTRST переносит вектор сброса в загрузчик, IVSEL — векторы прерываний; 11.8 — сторожевой таймер остаётся включённым после сброса от него
- GCC 16.2 Manual: Link Options (-nostartfiles), Optimize Options (-ftree-loop-distribute-patterns), Common Attributes (section, used); GNU ld 2.47: Source Code Reference — -nostartfiles не подключает стандартные файлы запуска; -ftree-loop-distribute-patterns включён с -O2 и заменяет циклы инициализации вызовом memset; символ скрипта компоновщика — адрес без значения, в C его объявляют как внешний массив или берут адрес
- ISO/IEC 9899:201x (C11), Committee Draft N1570, April 2011 — 6.5.8p5 — сравнение указателей на разные объекты не определено; 6.7.9 — назначенные инициализаторы массивов, неназванные элементы инициализируются нулём
Нашли ошибку?
Смотрите также
- Программирование МКC для МК, битовые операции, фиксированная точка, регистры
- Скрипт компоновщика GNU ldMEMORY, SECTIONS, VMA и LMA, KEEP, стек и куча, map-файл, переполнение
- Файлы прошивки: HEX, BIN, ELFELF, Intel HEX, S-record, BIN и UF2: адреса, секции, objcopy, size
- Прерывания и общие данныеISR на AVR и Cortex-M, атомарность, критические секции, кольцевой буфер
- Cortex-M3Регистры, карта памяти, bit-banding, NVIC, SysTick, MPU, старт, пример
- Архитектура AVRЯдро, конвейер, память, порты, прерывания, сброс, такты и сон
- Flash и загрузчик STM32Страницы и секторы, BOOT0, системный загрузчик, AN3155, IAP, option bytes, RDP
- Код запуска (startup)Что выполняется до main: стек, .data, .bss
- Таблица векторов прерыванийАдреса обработчиков прерываний в начале флеш-памяти
- Секции .data и .bssГде лежат глобальные переменные: копирование и обнуление при старте
- LMA и VMAАдрес загрузки секции и адрес, по которому она работает во время выполнения
- Генератор кода инициализации STM32Тактирование, USART, таймер и GPIO → код на CMSIS, LL или HAL