/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

从通电到 main:STM32 启动流程深度拆解



一、复位的本质:不是"重新跑一遍",而是状态机归零
很多人把复位理解为"程序从头开始执行",这是结果而非本质。
复位真正做的事情是:
让内核、总线矩阵和绝大多数外设的时序逻辑回到一个确定的初始状态,同时把时钟系统打回默认时钟源。
理解这一点,才能解释为什么复位后外设配置全部丢失、时钟变回 HSI、但后备域(RTC/备份寄存器)可以保持。
1.1 复位源全景
STM32 的复位源分两个层级,它们的作用范围不同:
| 系统复位 | NRST 引脚 | 外部低电平 | 内核 + 几乎全部外设 |
| 系统复位 | 看门狗 IWDG/WWDG | 计数器溢出未喂狗 | 同上 |
| 系统复位 | 软件复位 | NVIC_SystemReset() 置 SYSRESETREQ | 同上 |
| 系统复位 | CSS | HSE 失效时钟安全系统 | 同上 |
| 电源复位 | POR/PDR | 上电/掉电越过电源阈值 | 系统复位 + 后备域 |
| 电源复位 | BOR | 欠压 brown-out | 同上 |
| 域复位 | 退出 Standby 等 | 低功耗模式 | 视系列而定 |
所有复位源的"案底"都会锁存进 RCC_CSR 的标志位(POR、PIN、IWDG、WWDG、SFT、BOR、LPWR 等)。
启动代码可以据此判断本次启动原因——例如区分"正常上电"和"看门狗恢复",做不同的故障记录;判断完写 RMVF 位清标志。
1.2 NRST 电路与上电时序
NRST 是带内部弱上拉的施密特触发输入,低电平有效。
典型板级电路只需要一个 100 nF 电容和一个按键:
- 上电瞬间:电容两端电压不能突变,NRST 被钳在低电平,芯片保持复位;随着电源通过内部上拉电阻给电容充电,NRST 缓慢抬升,越过阈值后复位释放。这个延迟保证了 VDD 已经稳定、Flash 已唤醒。
- 运行中按键:按下即把 NRST 直接对地短路,电容迅速放电,产生干净的低脉冲。
上电的完整时间线其实是:
VDD 爬坡 → POR/BOR 比较器判定电压合格 → 内部复位释放 → HSI 启动稳定 → 内核开始取指。
所以"上电到第一条指令"的延迟由电源斜率、POR 延时和 HSI 启动时间共同决定,低功耗设计中这些参数都在数据手册电气章节给出。
1.3 看门狗为什么能"救场"
程序跑飞(死循环、指针乱跳、死锁)时,CPU 已经无法正常执行喂狗逻辑,IWDG 由独立低速时钟(LSI)驱动,不受主时钟和软件故障影响,溢出后直接拉系统复位——这是一个独立于内核的硬件逃生通道。
复位后一切重新开始,这就是看门狗作为最后可靠性保障的原理。
二、地址空间的障眼法:0x0 到底是什么

2.1 Cortex-M 的两条铁律
ARMv7-M 架构把复位定义为优先级最高(编号 –3)的特殊异常,并硬性规定复位序列的取指地址:
- 0x00000000:存放初始 MSP 值(一个 32 位地址);
- 0x00000004:存放复位向量(Reset_Handler 的地址)。
这是内核固化的行为,不可配置。但 STM32 的物理存储器地图是这样的:
| 主 Flash | 0x08000000 起 | 可读写(按页擦除)、非易失 |
| System Memory | 0x1FFF0000(F4)/0x1FFFF000(F1) | 出厂 ROM Bootloader,只读 |
| SRAM | 0x20000000 起 | 易失、可随机读写 |
| 外设区 | 0x40000000 起 | AHB/APB 寄存器 |
矛盾出现了:
内核要从 0x0 取向量,但物理上没有任何存储器在 0x0。
解决办法就是地址别名(Alias)/ 重映射。
2.2 别名机制的硬件实现
芯片内部的总线矩阵在地址解码阶段做了一层"转发":
0x00000000 开头的启动窗口并不对应独立的物理存储,而是根据复位时锁存的 BOOT 配置,把访问请求路由到三个物理区之一:
| 0 | x | 0x08000000 主 Flash | 用户程序 |
| 1 | 0 | System Memory | ST 出厂 Bootloader(串口/DFU 救援) |
| 1 | 1 | 0x20000000 SRAM | 调试代码(掉电丢失) |
注意三点深层事实:
2.3 易混点:MEMRMP 与 VTOR 不是一回事
这是工程中高频混淆的两个机制:
- MEMRMP(F4 起 SYSCFG,F1 在 AFIO_MAPR):操作的是总线级地址解码,改变 0x0 别名区的物理指向,影响所有主设备(包括 DMA)对该地址的访问;
- SCB->VTOR:操作的是内核取异常向量的基地址,只影响 CPU 响应异常时去哪里取向量,不改变总线映射。
典型组合用法:
Flash 启动后,应用程序在 SystemInit 里把 VTOR 设为 0x08000000,之后即使别名映射改变,中断也能正确找到向量表。
三、复位序列:硬件在第一条指令前替你做了什么
复位释放后,不需要任何软件指令,硬件自动完成以下动作,这在 ARMv7-M 手册里称为 Reset Sequence:
设计哲学:为什么硬件要代劳这些
对比经典的 ARM7TDMI(ARMv4T):
它复位后 PC 直接为 0,栈指针要软件初始化,IRQ 进入后软件读取向量、软件保存现场、手动判断中断源。
而 Cortex-M 把异常模型整体硬件化——向量表自带 MSP、NVIC 自动向量化、硬件自动压栈。
带来的收益是:
启动代码大幅简化、中断延迟确定且极短(无需软件保存现场)、C 程序可以"第一时间"安全运行。这是微控制器定位下对确定性和易用性的刻意设计。
四、启动文件深度剖析:硬件与 C 世界之间的契约
启动文件(startup_stm32xxxx.s)不是普通的汇编源码,它同时承担三个角色:
向链接器声明符号、向内核提供向量表、向 C 程序提供入口。
4.1 栈与堆的声明
Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
Heap_Size EQU 0x00000200
AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit
ALIGN=3 表示 2³=8 字节对齐;
NOINIT 表示上电不预初始化(反正 .bss 流程会处理/栈不需要初值);
__initial_sp 是链接器计算出的栈顶符号,正是向量表第一项引用的值。
4.2 向量表:DCD 构建的一张函数指针表
AREA RESET, DATA, READONLY
EXPORT __Vectors
__Vectors DCD __initial_sp ; 0x00: MSP 初值
DCD Reset_Handler ; 0x04: 复位向量
DCD NMI_Handler ; 0x08
DCD HardFault_Handler ; 0x0C
…
DCD SysTick_Handler ; 系统异常末尾
DCD WWDG_IRQHandler ; 外部中断开始
…
DCD 就是分配一个 4 字节常量。
从 C 视角看,向量表就是一张位于 Flash 起始处的函数指针数组,只不过第 0 项不是函数地址而是栈顶。
4.3 弱符号:没写中断函数为什么也能编译链接
每个中断向量都以 [WEAK] 声明别名:
Default_Handler PROC
EXPORT WWDG_IRQHandler [WEAK]
…
B . ; 死循环,停在原地
ENDP
[WEAK] 是弱定义:
你在 C 文件中写了同名函数,强符号覆盖弱符号,向量自动指向你的 ISR;没写,则所有未实现中断都落到 Default_Handler 的 B . 死循环。
这就是"中断没开函数也能烧录,但一旦触发就死机"的底层原因——调试时看到 PC 停在 Default_Handler,即说明触发了未实现的中断。
4.4 Reset_Handler 逐行拆解
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
整个复位处理程序短得惊人:
调 SystemInit(VTOR、底层时钟),然后 BX 跳进 C 库的 __main。
注意是 BX 而非 BL——不打算返回,没有返回地址需要保存。
4.5 两条工具链路径差异
| 数据搬运 | 隐藏在 C 库 __main 的分散加载中 | Reset_Handler 里手写拷贝/清零循环 |
| 库入口 | __main → __rt_entry | __libc_init_array 后直接 bl main |
| 链接描述 | 分散加载文件 .sct | 链接脚本 .ld |
| C++ 构造 | __rt_lib_init 自动调用 | __libc_init_array 遍历 init_array |
GCC 启动文件中的搬运逻辑本质是两段循环:
CopyDataInit:
LDR R4, [R2, R3] ; 从 _sidata 读
STR R4, [R0, R3] ; 写到 _sdata
ADDS R3, #4
LoopCopyDataInit:
ADDS R4, R0, R3
CMP R4, R1
BCC CopyDataInit
FillZerobss:
MOVS R3, #0
STR R3, [R2]
ADDS R2, #4
LoopFillZerobss:
CMP R2, R4
BCC FillZerobss
五、C 运行环境:镜像态与运行态的分离
5.1 为什么必须有"运行时初始化"
编译产物是一份静态镜像,躺在 Flash 里;
而 C 程序的运行需要一个动态执行态。
两者的根本矛盾在于:
- 全局变量必须可写,但 Flash 只能按页擦除、不能像 RAM 一样随机写;
- 函数运行需要栈,而栈在 RAM;
- 未初始化变量 C 标准保证初值为 0,但 RAM 上电内容是随机的。
所以启动期必须完成"镜像 → 运行态"的转换,这就是分散加载存在的意义。
5.2 段(Section)全景
| .isr_vector | Flash | Flash | 向量表 | 无需搬运 |
| .text | Flash | Flash | 机器指令 | 原地取指 |
| .rodata | Flash | Flash | const、字符串字面量 | 原地读取 |
| .data | Flash(初值) | SRAM | 有非零初值的全局/静态变量 | 拷贝 |
| .bss | 不占镜像空间 | SRAM | 零初始化全局/静态变量 | 清零 |
| heap | — | SRAM | malloc 动态分配 | 库初始化边界 |
| stack | — | SRAM | 栈帧 | MSP 已就位 |
5.3 链接脚本符号:软件与内存布局的接口
GCC .ld 中的关键符号必须和启动汇编严格对应:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶,RAM 最高地址 */
_sidata = LOADADDR(.data); /* .data 在 Flash 的加载地址 */
_sdata = .; … _edata = .; /* .data 在 RAM 的起止 */
_sbss = .; … _ebss = .; /* .bss 在 RAM 的起止 */
LOADADDR(.data) 取的是加载地址(LMA,在 Flash),而 .data 的运行地址(VMA)在 RAM——LMA/VMA 分离正是数据搬运的本质:同一份字节,烧录时在 Flash,运行时在 RAM。
5.4 __main 与 __rt_entry 内部
ARMCC 下 __main 不是一个函数,而是 C 库入口的统称,依次执行:
嵌入式工程中 main 绝不返回;
若真的返回,默认行为是进入库退出路径(裸机通常表现为死循环或 semihosting 退出)。
六、栈机制深度专题
6.1 满递减栈与寄存器分工
Cortex-M 实现的是满递减(Full Descending):
SP 始终指向最后一个已占用字。
PUSH 先 SP−4 再存储,POP 先读取再 SP+4。
两个栈指针通过 CONTROL 寄存器切换:
- MSP:复位默认;Handler 模式(任何异常/中断)下硬件强制使用;
- PSP:仅 Thread 模式可用;RTOS 中任务线程用 PSP,内核异常仍用 MSP,实现任务栈与内核栈隔离。
6.2 异常硬件栈帧
异常进入时,硬件按固定顺序把 8 个寄存器压当前栈(见信息图③):xPSR、PC、LR、R12、R3、R2、R1、R0。
设计上特意压 R0, R3 和 R12,是因为它们在 AAPCS 中是**调用者保存(caller-saved)**寄存器,而 R4R11 是被调用者保存,ISR 作为"被调用方"按需自行保存即可。
异常结束时硬件弹出栈帧,并依据 EXC_RETURN 值(如 0xFFFFFFF9 返回 MSP/Handler、0xFFFFFFFD 返回 PSP/Thread)决定返回哪个栈和模式。
CCR.STKALIGN 默认可保证异常时 SP 8 字节对齐,必要时插入一个 padding 字;带 FPU 的 M4/M7 启用惰性压栈后还会额外保存 S16~S31 与 FPSCR(约 66 字节)。
6.3 AAPCS 视角下的函数栈帧
` C 函数调用时,R0R3 传前 4 个参数、R0 返回结果;
超出的参数走栈;
R4R11 若被使用必须由函数自己 PUSH 保存。
一个典型栈帧从高到低包含:
调用者 R4~R7、局部变量、传给子函数的溢出参数。
这也是为什么"局部变量地址"一定落在 RAM 的栈区。
6.4 栈大小怎么定,溢出怎么查
工程估算法:
检测手段:
- 栈水印法:启动时把栈区填成固定魔术值(如 0xA5A5A5A5),运行后统计未被踩过的最深位置,实测高水位;
- MPU 保护:在栈底设一个不可访问区域,溢出即触发 MemManage Fault;
- 链接期检查:Keil 可生成 map 文件查看栈/堆与 .bss 是否撞上。
6.5 再谈 0x0 与 NULL
NULL 是 C 标准定义的空指针宏(通常 (void*)0),语义是"不指向任何对象",解引用它属于未定义行为。
而地址 0 在 STM32 上是有效别名区,存放向量表。
两者只是数值恰好相同,分属语言语义和硬件地址两个层面。
对 0 地址做 *(uint32_t*)0 读取在硬件上完全合法——启动硬件自己就是这么读 MSP 的。
七、进入 HAL:从库初始化到主循环

7.1 HAL_Init 做了什么
- 配置 Flash 预取指、ART Accelerator/I-Cache/D-Cache(视系列);
- 配置 SysTick 产生 1 ms 中断,提供 HAL 时基(HAL_GetTick、HAL_Delay、所有带 timeout 的 API);
- 设置 NVIC 优先级分组 4:4 位全部用于抢占优先级,无子优先级拆分。
7.2 SystemClock_Config:时钟树的重建
复位后系统跑 HSI(精度差、频率低)。
该函数完成:
选 HSE/HSI 作 PLL 源 → PLL 倍频 → AHB/APB1/APB2 分频 → 按新频率设置 Flash 等待周期(WS)(频率越高,Flash 读取需要的等待状态越多,否则取指出错)→ 切换系统时钟源并回读标志确认稳定。
7.3 外设初始化与主循环
MX_GPIO_Init、MX_USARTx_Init 等顺序执行:
先 __HAL_RCC_xxx_CLK_ENABLE() 开时钟,再写模式/复用/波特率等寄存器——顺序不能反,时钟未开时写外设寄存器无效。
最后进入 while(1),系统转为"主循环 + 中断驱动"模型:
后台跑轮询逻辑,前台由 NVIC 异步响应事件,ISR 用 MSP 压栈、执行、出栈返回。
八、进阶视角
8.1 Bootloader 跳转 App 的标准动作
typedef void (*p_jump_func)(void);
static void JumpToApp(uint32_t app_addr)
{
uint32_t app_sp = *(uint32_t *)app_addr;
p_jump_func app_entry = (p_jump_func)(*(uint32_t *)(app_addr + 4));
HAL_RCC_DeInit();
HAL_DeInit();
SysTick->CTRL = 0;
SysTick->LOAD = 0;
SysTick->VAL = 0;
__disable_irq();
SCB->VTOR = app_addr;
__set_MSP(app_sp);
__enable_irq();
app_entry();
}
关键点:
跳转前关中断并复位外设/SysTick(否则 App 里残留 ISR 会直接炸)、重设 VTOR、用向量表第一项重装 MSP、再按第二项跳转。
App 起始地址必须与它的链接脚本偏移(如 0x08008000)和中断向量偏移一致。
8.2 RTOS 启动时栈的切换
FreeRTOS 流程:
main 里创建任务后调 vTaskStartScheduler → 每个任务有独立栈,任务控制块保存 PSP 初值 → SVC/PendSV 中把 CONTROL.SPSEL 切到 PSP、加载任务栈帧 → 中断仍走 MSP。
所以裸机单栈模型在 RTOS 下变成"一内核栈 + N 任务栈",这正是 MSP/PSP 双栈设计的用武之地。
8.3 启动失败排查清单
| 调试器读不到 IDCODE | 接线/共地/SWCLK 太高/调试口被关 | 降频、查线、System Memory 救援 |
| 一启动就 HardFault | 向量 bit0 错、栈顶非法、启动期中断 | 查 SCB CFSR、向量表、栈大小 |
| 全局变量值随机 | .data 未搬运/链接符号不匹配 | 查 .ld 与启动文件符号 |
| 跑一会死机 | 栈溢出撞 .bss/heap | 栈水印、map 文件 |
| 主频上不去/取指异常 | Flash WS 未随频率设置 | 查 latency 配置 |
| 进中断出不来 | 未实现 ISR 落 Default_Handler | 看 PC 位置、补中断函数 |
九、高频面试题
1. 从上电到 main 的完整链路?
面试口述版
"整个过程分硬件自动和软件初始化两大阶段。
上电后首先由 POR/BOR 完成电源复位,HSI 时钟启动;
复位释放时硬件采样 BOOT 引脚确定启动模式,并把 0x0 地址别名映射到对应的物理存储器。
然后 CPU 硬件自动完成两件事:从 0x0 取栈顶值装入 MSP,从 0x4 取复位向量装入 PC,Thumb 位置 1。
接下来执行启动文件里的 Reset_Handler,先调 SystemInit 做 VTOR 和底层初始化,再跳进 C 库入口 __main;
__main 负责分散加载——把 .data 初值从 Flash 拷到 RAM、.bss 清零、初始化堆和 C 库,
最后才调用用户的 main,里面依次是 HAL_Init、SystemClock_Config、外设初始化和 while 循环。"
分点记忆版
| 1. 电源复位 | POR/BOR 判定电压 → 系统复位 → HSI 成为默认时钟 |
| 2. 硬件取指 | 采样 BOOT → 0x0 别名映射 → 硬件装 MSP(0x0)、装 PC(0x4) |
| 3. 启动文件 | Reset_Handler → SystemInit → __main(GCC 为手写搬运循环) |
| 4. C 库与用户 | 拷贝 .data、清零 .bss、建堆 → 调 main → HAL_Init/时钟/外设 |
回答时主动埋的 3 个加分点(引导面试官追问)
追问预案(大概率接着问)
- 为什么硬件先装 MSP? 第一条指令后的函数调用和中断都要压栈,SP 必须先有效;
- .data 和 .bss 区别? .data 有非零初值、从 Flash 拷贝;.bss 零初始化、启动清零;
- 复位为什么不压栈? 没有被中断的前一个上下文需要保存;
- BOOT 引脚作用? 决定 0x0 别名指向 Flash、System Memory 还是 SRAM。
2. 向量表第 0、1 项分别是什么?为什么第 0 项是栈顶而不是函数?
- 第 0 项:MSP 初值(__initial_sp,栈顶地址,如 0x20000400);
- 第 1 项:Reset_Handler 地址(复位向量)。
原因是 Cortex-M 的复位序列设计为"先建栈、后取指":
第一条指令执行后立刻会发生函数调用(BL 压 LR)和可能的中断(硬件压栈),所以 SP 必须在任何指令之前就有效。
硬件把向量表第 0 项直接加载进 MSP,不需要一条初始化指令。
它装的是数据地址(栈顶)而非可执行入口,所以不是函数指针。
3. 0x0 地址与 NULL 空指针的关系?
两者只是数值恰好相同,属于两个不同层面:
- NULL:C 语言语义,(void*)0 表示"不指向任何有效对象",解引用属于未定义行为;
- 0x0 物理地址:STM32 上是真实有效的别名区,复位后经 BOOT 映射到 Flash/System Memory/SRAM,存放向量表。硬件没有"空"概念,地址 0 和其他地址一样被解码访问,启动硬件自己就在读 0x0。
对比:
在带 MMU 的 Linux 上访问 0 地址会因页表无映射触发缺页异常,那是软件页表机制;MCU 无 MMU,0 地址物理上始终有效。
4. 地址别名(MEMRMP)与向量表重定位(VTOR)的区别?
| 所在层级 | 总线矩阵地址解码 | 内核异常控制 |
| 作用 | 改变 0x0 别名区指向的物理存储器 | 决定异常时去哪里取向量表 |
| 影响对象 | 所有总线主设备(CPU、DMA) | 仅 CPU 取向量动作 |
| 典型修改者 | 启动硬件/系统软件 | 应用程序、Bootloader |
记忆法:
MEMRMP 管"地址落到哪块物理区",VTOR 管"中断向量从哪张表取"。
两者互不替代,实际工程常同时使用——硬件映射保证启动取指,VTOR 设 0x08000000 保证运行后中断定位。
5. 复位后 CPU 的模式、特权级、栈、中断、时钟各是什么状态?
- 处理器模式:Thread(线程模式);
- 特权级别:Privileged(特权级);
- 栈指针:MSP(PSP 未启用);
- 指令集状态:Thumb(EPSR.T=1),只支持 Thumb/Thumb-2;
- 中断状态:NVIC 所有 ISER 为 0,普通中断全部禁用;PRIMASK、FAULTMASK、BASEPRI 均为 0;仅 NMI、HardFault 可响应;
- 时钟:系统时钟回退到 HSI(F1 为 8 MHz,F4 为 16 MHz),PLL 关闭;
- 全部外设寄存器回到数据手册标注的复位默认值。
6. 复位异常为什么不压栈?
压栈的唯一目的是保存"被打断的执行上下文",以便异常返回后恢复。
但复位发生时,根本不存在前一个正在运行的程序、也没有需要恢复的寄存器状态——所有内容都要从零初始化。
而且复位是优先级最高(–3)的异常,没有"返回被中断代码"这一说,硬件直接从向量表加载 MSP/PC 开始全新执行,所以不压栈。
7. MSP 与 PSP 的区别?RTOS 如何使用?
- MSP(主栈指针):复位后默认栈;Handler 模式(任何异常/中断)下硬件强制使用;
- PSP(进程栈指针):仅 Thread 模式可用,通过 CONTROL.SPSEL 切换。
RTOS 用法:
每个任务创建时分配独立任务栈并在 TCB 中记录其 PSP 初值;
任务运行在 Thread 模式、使用 PSP;
中断和内核代码仍走 MSP。
任务切换时 PendSV 把当前寄存器压入旧任务的 PSP 栈、从新任务的 PSP 栈恢复。
这样任务栈彼此隔离、大小可按任务定制,内核栈与任务栈形成故障域分离。
8. 异常进入时硬件压入哪 8 个寄存器?为什么是这 8 个?
按地址高到低:xPSR、PC、LR、R12、R3、R2、R1、R0,共 32 字节。
选择依据是 AAPCS 调用约定:
- **R0~R3、R12 是调用者保存(caller-saved)**寄存器,ISR 作为"被调用方"可以直接使用它们而不负责保存,所以硬件必须替被中断代码预先保存;
- R4~R11 是被调用者保存(callee-saved),ISR 若用到会自己 PUSH,硬件不必代劳;
- PC 保存异常返回地址,xPSR 保存异常前的 ALU 标志与状态,LR 保留调用链信息。
这套设计让 ISR 能用普通 C 函数约定直接编写,同时把硬件栈帧压到最小,缩短中断延迟。
9. EXC_RETURN 的作用?
异常进入时硬件把一个特殊值装入 LR,即 EXC_RETURN,异常返回(BX LR)时硬件靠它判断"回到什么模式、用哪个栈、是否含 FPU 帧",而不是把它当普通地址跳转。
常见值:
| 0xFFFFFFF1 | 返回 Handler 模式,MSP |
| 0xFFFFFFF9 | 返回 Thread 模式,MSP |
| 0xFFFFFFFD | 返回 Thread 模式,PSP |
| 0xFFFFFFE1/E9/ED | 对应三种组合的"非基础帧"(含 FPU 扩展栈) |
它解决了"异常返回路径有多种组合"的硬件识别问题,全部由内核自动生成、自动解析。
10. .data 和 .bss 的区别?谁在什么时候初始化?
| 变量类型 | 有非零初值的全局/静态变量 | 未初始化或初值为 0 的全局/静态变量 |
| 运行位置 | RAM | RAM |
| Flash 占用 | 初值镜像占 Flash(LMA) | 不占镜像,只预留 RAM |
| 启动动作 | 从 Flash 拷贝初值 | 整段写 0 |
执行时机都在 main 之前:
ARMCC 由 __main 的分散加载完成;
GCC 由 Reset_Handler 中的 CopyDataInit/FillZerobss 循环完成。
注意局部变量不属于这两个段,运行时在栈上动态分配,不参与启动初始化。
11. LMA 与 VMA 分别是什么?
- VMA(Virtual/Run Memory Address):段的运行地址,链接器按此解析符号、重定位,CPU 运行时访问的地址;
- LMA(Load Memory Address):段在镜像中的加载地址,即烧录到 Flash 的位置。
.text/.rodata 的 LMA=VMA(都在 Flash,原地执行);
.data 的 VMA 在 RAM、LMA 在 Flash(_sidata),二者分离正是启动搬运的原因——复位后代码把字节从 LMA 复制到 VMA,程序才能按链接地址正确访问变量。
12. __main 和用户 main 的区别?__rt_entry 做什么?
- __main(双下划线):ARM 编译器 C 库提供的入口,不是用户代码。负责执行分散加载:拷贝 RW(.data)、清零 ZI(.bss)、必要时解压镜像;
- 完成后进入 __rt_entry,它依次:初始化栈与堆(__user_initial_stackheap)→ 调 __rt_lib_init 初始化 C 库、执行 C++ 全局对象构造 → 注册退出处理 → 最后调用用户 main();
- main 返回后进入 exit() 路径。裸机 main 不应返回。
GCC 下没有 __main 符号,等价工作由启动汇编和 __libc_init_array 显式完成。
13. 弱符号 [WEAK] 机制如何工作?
弱符号是链接器规则:
同名的强符号会覆盖弱符号,没有强符号时弱符号生效。
启动文件把每个中断向量以 [WEAK] 导出并指向统一的 Default_Handler(B . 死循环)。
- 用户在 C 文件写了同名 ISR(强符号)→ 链接器选用强符号,向量表项自动指向用户函数;
- 没写 → 保留弱符号,中断触发后落入 Default_Handler 原地死循环。
价值:启动文件无需为每个用户定制,未实现的中断有确定性兜底,避免跳转到随机地址。
14. ARMCC 与 GCC 启动流程的核心差异?
功能完全等价,差异在"库代劳 vs 手写显式":
| 内存布局 | 分散加载文件 .sct | 链接脚本 .ld |
| 数据搬运/清零 | 隐藏在 C 库 __main 内 | Reset_Handler 手写两段循环 |
| 库/C++ 初始化 | __rt_entry、__rt_lib_init | __libc_init_array |
| 入口跳转 | BX __main | bl main |
15. 如何估算栈大小?栈溢出有哪些检测手段?
估算方法:
检测手段:
- 栈水印法:启动时把栈填魔术值(0xA5A5A5A5),运行后检查未被改写的位置,得到高水位;
- MPU 保护:栈底设不可访问区,溢出触发 MemManage Fault;
- 金丝雀(Canary):栈帧边界放哨兵值,返回前检查;
- RTOS 钩子:FreeRTOS 的 vApplicationStackOverflowHook;
- 静态 map 文件:查看栈、堆、.bss 的链接边界是否碰撞。
16. Bootloader 跳转 App 的完整步骤?为什么要重装 MSP?
步骤:
重装 MSP 的原因:
Bootloader 与 App 是分别独立链接的两个程序,各自有独立栈空间。
若沿用 Bootloader 的 MSP,App 的函数调用和中断压栈会写到 Bootloader 栈上,边界错误且可能与 Bootloader 残留数据互相破坏。
只有按 App 向量表重装 MSP,App 才运行在自己的合法栈域内。
17. RCC_CSR 能提供什么信息?
该寄存器锁存本次复位的来源标志,主要包括:
- PORRSTF:上电/掉电复位;
- PINRSTF:NRST 引脚复位;
- BORRSTF:欠压复位;
- SFTWRSTF:软件复位;
- IWDGRSTF / WWDGRSTF:独立/窗口看门狗复位;
- LPWRRSTF:低功耗复位。
上电时 PORRSTF 与 PINRSTF 常同时置位(NRST 在上电过程也经历低电平)。
写 RMVF 位可一次性清除全部标志。软件据此做复位原因统计、故障日志和差异化初始化。
18. 看门狗复位的硬件独立性体现在哪里?
以 IWDG 为例:
- 它由**独立的 LSI 低速时钟(约 32 kHz)**驱动,不依赖系统主时钟(HSE/HSI/PLL);
- 计数器是独立硬件时序逻辑,即使 HSE 损坏、PLL 失锁、CPU 取指跑飞或进入死循环,计数器仍照常递减;
- 溢出后直接产生系统复位信号,不需要 CPU 执行任何指令。
因此它是一条独立于内核和主时钟之外的"最后逃生通道",这正是软件看门狗(依赖内核调度)在高安全等级场景不被信任的原因。
19. 为什么时钟配置里要设 Flash 等待周期?
Flash 的物理访问速度远低于 CPU 最高频率(一次读取需要数十 ns,对应频率远低于 168 MHz 这类系统时钟)。
当 CPU 时钟周期短于 Flash 访问时间时,一个周期内 Flash 来不及输出有效指令/数据,必须插入若干等待状态(Wait State, WS/Latency),让总线等待 Flash 读完成。
WS 数量按"供电电压范围 + 目标频率"查手册表格确定(如 F4 在 3.3V、168 MHz 需 5 个 WS),并配合预取指、ART 加速器、I-Cache 补偿等待损失。
配错 WS 会取指/读数据错误,表现为随机 HardFault 或跑飞。
20. 触发未实现的中断会发生什么?
由于启动文件中所有未实现的中断向量都以弱符号指向 Default_Handler,其代码是 B .(跳转到自身的死循环),所以中断一旦触发:
排查方法:
实现对应 ISR 清除中断标志,或在调试器中查看 SCB->ICSR 和 NVIC IABR 定位活跃中断源。
整条启动链可以浓缩成三条主线:
21.为什么Flash 固定从 0x08000000 开始 ?
一、根源:Cortex-M 的 4GB 地址地图是架构固定的
ARMv7-M 把 32 位寻址空间(4GB)硬性划分成若干功能区,所有厂商一致:
| 0x00000000–0x07FFFFFF | Code 区·启动别名区(128MB) | 复位取指窗口,可重映射 |
| 0x08000000–0x1FFFFFFF | Code 区·Flash 原生区 | 片内 Flash 就挂在 0x08000000 |
| 0x20000000–0x3FFFFFFF | SRAM 区 | 片内 RAM(含 bit-band) |
| 0x40000000–0x5FFFFFFF | 外设区 | AHB/APB 寄存器 |
| 0x60000000–0x9FFFFFFF | 外部 RAM | FSMC/FMC 外接存储器 |
| 0xA0000000–0xBFFFFFFF | 外部设备 | 外部器件接口 |
| 0xE0000000–0xFFFFFFFF | 系统区 | NVIC、SCB、SysTick 等私有外设 |
也就是说,0x08000000 是架构表中"代码 Flash 区"的起跑线,厂商只能在这个区域里挂 Flash,不能自定地址。
二、为什么 Flash 不直接放在 0x0,而要偏移到 0x08000000
因为 0x0 开头的 128MB 必须承担一个特殊职责——可重映射的启动别名区:
- 复位时内核铁律要从 0x0 取 MSP、0x4 取复位向量;
- 但启动源有三种(主 Flash、System Memory、SRAM),需要根据 BOOT 引脚把 0x0 动态接到不同物理区;
- 如果某块 Flash 固定占死 0x0,就无法实现这种切换。
所以架构设计成:
0x0 是"会变的别名窗口",0x08000000 才是 Flash"不变的原生地址"。
Flash 启动模式下,硬件把 0x0 映射到 0x08000000,二者指向同一份内容。
三、澄清一个常见误区
严格说,复位时 CPU 逻辑上从 0x0 取指,但经过别名映射,物理上读到的就是 0x08000000 处的向量表。
而程序的链接地址(VMA)、SCB->VTOR、函数和全局变量的绝对地址,全部按 0x08000000 解析——因为这个地址不依赖 BOOT 映射,任何模式下都稳定有效,所以它才是程序真正的"出生地址"。
四、这样设计的好处
补充:
并非所有 Cortex-M 芯片都强制 0x08000000,例如 NXP LPC 部分系列把 Flash 原生放在 0x0;但 STM32 遵循 ARM 规范,Flash 固定从 0x08000000 开始。




