遇到复位就跑飞、调试器连不上、中断莫名其妙不进的板子,我第一反应通常不是翻原理图,而是打开启动文件。
startup_stm32f103xe.s 在不少人眼里是工程模板里“绝对不能动”的乱码,可在熟悉它的人看来,它恰恰是这颗芯片从上电复位到 main 函数呼吸第一口气之前唯一的真相。这个文件只有一种用途:把硬件从“不知道该干什么”变成“可以开始跑 C 代码”。我的观点是,一个用 STM32 做产品的人,可以不会手写汇编,但不能不读懂这几百行启动汇编写了什么。
这篇文章以 MDK 环境下最常用的原厂 startup_stm32f103xe.s 为例,先把它从头到尾过一遍,再讲上电后 CPU 到底先跑哪几行,然后用三个真实事故和一个动手实验,说明怎么把对启动文件的理解变成排障能力。适合刚接触 STM32、看到 .s 文件就发慌的人,也适合那些程序能跑但一直没搞明白“上电后发生了什么”的工程师。
1. 为什么每个STM32工程里都躺着一个几百行的汇编文件
1.1 从一次“程序死活不进main”的调试经历说起
有一回我用一块 F103 的板子调串口通信代码,烧录很顺利,复位之后串口却一个字符都不发。我进调试,在 main 第一行打断点,程序确实到了。于是我把外设配置翻来覆去查,怀疑是某段初始化代码改坏了寄存器,结果花了大半天没找到问题。
最后实在没辙,把断点往前挪,放在 Reset_Handler 入口,单步走。走了十几步,发现程序停在 SystemInit 的一个循环里。原来外部晶振没起振,复位时时钟初始化一直在等 HSE ready,死在那里。这是我第一次意识到:很多诡异问题不但在 main 之前就发生了,而且发生时 C 运行环境都还没有建立。
后来再遇到 HardFault、中断不响应、仿真器连不上,我的检查顺序就固定下来了:先看启动过程。不是说启动文件多容易出问题,而是它界定了事件发生的先后顺序。上电最先发生的事,如果不先排除,后面查再多外设配置都是在绕弯路。
1.2 启动文件到底替我们管了哪几件事
一句话概括:启动文件负责把芯片从“拿到复位向量开始执行”带到“可以安稳运行 C 代码”。具体拆开,它做五件事:
| 序号 | 事情 | 对应文件中的位置 |
|---|---|---|
| 1 | 定义栈空间,给出初始栈指针 | Stack 段,__initial_sp |
| 2 | 定义堆空间,供 malloc 使用 | Heap 段 |
| 3 | 建立并放置中断向量表 | __Vectors 段 |
| 4 | 复位后跳转 SystemInit 做硬件时钟初始化 | Reset_Handler |
| 5 | 调用 C 库 __main,完成 RW 段搬运、ZI 段清零,最后进 main | Reset_Handler 末尾 |
少了这个文件,编译器编译能过,但链接时找不到 __initial_sp、Reset_Handler、向量表这些最基础符号,工程根本起不来。更重要的是,C 语言里的“第一行 main”并不是程序起点。main 只是前面一大段“给程序搭台子”的代码执行完以后,轮到演员出场而已。启动文件就是那个搭台子的工人。
1.3 会写C语言的人,为什么还得能看汇编
我不主张所有人都去背 ARM 汇编指令表。对绝大多数应用开发来说,启动文件里出现的汇编指令不超过二十种,大多只是“声明一个段、定义一个数据、跳转一下”这种基本动作。真正要求你能看懂的场景,是这几类:
- 程序一上电就 HardFault,或者死在 main 之前;
- 某个中断不响应,想确认向量表顺序和中断函数名是否匹配;
- 做 Bootloader/IAP,跳转到 App 前必须理解 Reset_Handler 的入口行为;
- 想把栈改大、把堆改小,或者给启动阶段加一段自定义初始化;
- 换芯片型号时发现中断向量表数量不对,要判断启动文件选得对不对。
这些场景有一个共同点:它们都发生在 C 语言“罩不到”的边界上。C 代码能帮你做的事,启动文件都在更早的时候做完了。你平时可以无视它,但在边界问题上,它是唯一的钥匙。
2. 逐段读文件:从栈、堆、向量表到复位入口
2.1 先认清这几个高频汇编指令和伪指令
打开 startup_stm32f103xe.s,第一眼会看到大段注释,接着是一堆大写英文单词:EQU、AREA、SPACE、DCD、PROC、ENDP。只写过 C 的人看到这里很容易被劝退。其实把表列出来,几分钟就能认全。
| 指令/伪指令 | 含义 | 类比 |
|---|---|---|
| EQU | 定义一个常量 | 相当于 #define |
| AREA | 声明一个“段”,并说明属性(只读/读写、代码/数据) | 告诉链接器这块地皮什么用途 |
| SPACE | 在当前段预留一段空间并填 0 | 相当于一个大数组 |
| EXPORT | 把符号导出,供其他文件引用 | extern 的“对外发布”版 |
| IMPORT | 声明一个其他地方定义的符号 | extern 声明 |
| DCD | 定义一个 32 位数据并初始化 | 一个 const uint32_t 数组元素 |
| PROC / ENDP | 标记一个函数过程的开始和结束 | 函数的一对大括号 |
| ALIGN | 强制对齐到指定边界 | 让数据按地址对齐 |
| WEAK | 弱定义,用户定义了同名函数就用用户的 | 可被覆盖的默认回调 |
| END | 整个汇编文件结束 | 文件尾 |
这些词本身没什么难度,真正的门道在于“段”的属性组合。比如栈段一般写成:
AREA STACK, NOINIT, READWRITE, ALIGN=3NOINIT 表示不需要在启动时清零,READWRITE 表示可读可写,ALIGN=3 表示按 2^3=8 字节对齐。栈这个区域不要求初始值,所以 NOINIT 是对的,链接器只要在 RAM 中给它划块地方就行。文件开头往往还有一句 PRESERVE8,这会强制后续代码保持 8 字节栈对齐。别小看对齐这件事——如果栈指针在进入中断前没有按 8 字节对齐,某些库函数和位带操作可能会踩到未对齐访问的坑里。
2.2 栈和堆的大小怎么定:0x400与0x200背后的问题
启动文件顶部附近是:
Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_spStack_Size = 0x400 = 1KB。这 1KB 是系统复位后的默认栈,__initial_sp 是栈顶,同时也是中断向量表第一项的值。很多人误以为 F103 的栈顶永远是 0x2000xxxx 这种固定地址,其实那是链接器根据 RAM 布局、堆栈段位置算出来的,启动文件只负责把 __initial_sp 这个符号放进去。改 RAM 大小或堆栈顺序,__initial_sp 会自动落到新位置。
1KB 对 F103 裸机工程来说稍显紧张。如果中断里有较大的局部变量,或者用到多级中断嵌套,栈很容易溢出让程序 HardFault。我一般把 F103 的 Stack_Size 放到 0x800 到 0x1000,改完以后必须重新编译,然后在 map 文件里确认栈段没有和堆段、其他变量重叠。
Heap_Size=0x200=512B,这个值给比较简单的动态内存需求够用。不过要注意,标准 C 库在 F103 上做堆管理会占一些资源,如果开了 MicroLIB,malloc 行为会简化很多,但也更“朴素”。频繁 malloc 的项目建议根据实际压力测试去调整堆大小,而不是一直停留在 0x200。
还有一个容易忽略的点:栈是向下生长的,初始 SP 指向栈段最高地址,压栈时 SP 减小。启动文件里通常是先定义栈、再定义堆,所以整体布局往往是栈在上、堆在下。如果你想调整 RAM 利用率,记得同步看 map 文件里 __initial_sp 的新位置,不要靠猜。
2.3 中断向量表为什么必须是这个顺序
紧跟在堆栈定义后面的,是一串让人眼花缭乱的数据:
__Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler ; Reset Handler DCD NMI_Handler ; NMI Handler DCD HardFault_Handler ; Hard Fault Handler ...DCD 在这里做的事,就是按固定顺序写下一串地址。Cortex-M3 内核规定,从 0x00000000 开始,第一个字必须是初始栈指针,第二个字必须是复位入口,然后依次是 NMI、HardFault、MemManage、BusFault、UsageFault、SVCall、PendSV、SysTick,再往后才是芯片厂商定义的外设中断。
| 偏移 | 表项 |
|---|---|
| 0x00 | __initial_sp |
| 0x04 | Reset_Handler |
| 0x08 | NMI_Handler |
| 0x0C | HardFault_Handler |
| 0x10-0x38 | MemManage / BusFault / UsageFault / SVCall / DebugMon / PendSV / SysTick |
| 0x40 起 | 各外设中断,如 WWDG、PVD、TAMPER、RTC、FLASH、RCC、EXTI... |
这个顺序不是软件规定,是芯片硬件在出厂时就定死的。CPU 一上电就机械地从 0x00 取栈顶、从 0x04 取复位地址,根本不问这张表合理不合理。所以启动文件里的向量表顺序一个都不能乱,也不建议手工往里面随意加项,除非你非常清楚自己在做什么。
链接器会把向量表放在 Flash 起始地址 0x08000000。在正常 BOOT0 配置下,Flash 起始地址又被映射到 0x00000000,所以 CPU 实际读到的和链接器安排的是同一份数据。还有个细节:向量表里每一个地址的最低 bit 必须是 1,表示 Thumb 代码。Cortex-M3 只执行 Thumb,PC 加载时会自动把最低位置 1,所以调试时看到的向量地址可能比实际入口多出 1,这是正常现象。
F103 不同容量的启动文件还不太一样。文件名里的 xe 对应高密度型号,这套向量表覆盖的外设中断个数比较多。如果给中容量或低容量芯片强行用高密度启动文件,程序大都能编译,但中断映射就不对了,运行到特定外设时会出现“IRQn 对不上”的影子问题。换型号时,先确认启动文件选对没有,能省掉很多莫名其妙的中断 bug。
3. 上电后CPU先跑哪几行:Reset_Handler逐行走读
3.1 那条 LDR 指令其实是“伪指令”
真正让程序开始运行的代码,在启动文件里非常短。原厂模板通常就这几行核心:
Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP重点看两条 LDR。很多初学 ARM 汇编的人会误以为 LDR R0, =SystemInit 是把 SystemInit 函数的“内容”加载到 R0。实际上,这是 ARM 汇编里最常见的伪指令。等号写法表示“把后面的地址放入寄存器”,也就是 R0 拿到的是 SystemInit 的函数地址,而不是从函数地址取数据。汇编器会在附近生成一个字面量池(literal pool),再把它翻译成真正的 LDR R0, [PC, #offset]。
在反汇编窗口里,你会看到它被展开成类似这样:
0x08000014 LDR R0, [PC, #16] ... 0x08000020 DCD SystemInit那个 DCD 就是字面量池里的函数地址。这种写法比用 MOV 直接加载 32 位地址更省指令,因为 MOV 一条指令装不下完整的 32 位常数,拆成两三条反而麻烦。
3.2 Reset_Handler到SystemInit再到__main的完整链路
弄懂伪指令后,Reset_Handler 五行代码的逻辑就很直白:
- 把 SystemInit 的地址放进 R0;
- BLX R0 跳转执行 SystemInit,同时把下一条指令地址存到 LR,等 SystemInit 返回;
- 把 __main 的地址放进 R0;
- BX R0 直接跳到 C 库入口,不再返回 Reset_Handler。
为什么第一个跳转用 BLX 而不是 B?因为它希望 SystemInit 干完活以后回到 Reset_Handler,继续执行后面的指令。SystemInit 是用 C 写的配置函数,编译成 Thumb 代码,最后会执行 BX LR 返回。第二个跳转用 BX 就够,因为 __main 一旦接管,控制权就不再回 Reset_Handler,调用者不需要返回地址。
注意,启动文件跳转目标通常是 __main,而不是 main。__main 是 C 库提供的入口函数,它内部还要做几件事:把已初始化非零的全局变量从 Flash 拷到 RAM(RW 段搬运)、把未初始化的全局变量清零(ZI 段清零)、设置堆栈环境,然后才调用你写的 main。在 GNU/GCC 工具链里,这个职责由 C 库的启动代码承担,名字不一定是 __main,但工作内容殊途同归。
F103 上比较典型的执行序列是:
上电复位 → 取 SP → 取 PC → Reset_Handler → SystemInit(配时钟、Flash 等待、VTOR) → __main(RW 段搬运 + ZI 清零) → main。
这串流程里,唯一能让你完整看到每个环节的方法,是在 Reset_Handler 入口打断点单步走。后面第 6 章会专门讲怎么做。
3.3 SystemInit里偷偷改了什么:时钟、Flash等待、向量表偏移
SystemInit 的代码在 system_stm32f10x.c 里,不属于 .s 文件,但讲启动文件必须提它,因为 Reset_Handler 调 SystemInit 这个动作,决定了复位后几百微秒芯片处于什么状态,缺了这段,后面代码跑不快,甚至跑不对。
SystemInit 做的事主要有四类:
- 把 RCC 寄存器复位到默认状态;
- 配置 Flash 预取缓冲和等待周期。HCLK 提高后,CPU 读 Flash 需要插入等待周期,配置错了会出现执行异常;
- 打开外部高速晶振 HSE(或使用内部 HSI),配置 PLL 倍频,把 SYSCLK 切换到 PLL 输出,同时设置 AHB、APB1、APB2 的分频;
- 设置 SCB->VTOR,把向量表指到 0x08000000 | VECT_TAB_OFFSET。
一个特别常见的失败场景是:外部晶振没焊好、负载电容不对或起振偏慢,SystemInit 里的“等 HSE ready”循环一直出不来。从调试器看,程序好像“卡死在启动文件里”,因为 PC 一直停在 SystemInit 的循环语句上。很多人会把责任推给启动文件,其实病根在硬件时钟源。
有些人图省事,把 SystemInit 里的 HSE 配置改成 HSI,跳过外部晶振。程序确实能跑,但后续做 USB、RTC 或高精度定时时,时钟源不匹配的坑会一个接一个。我的原则是:先用示波器确认晶体起振,再决定要不要改代码;绝不能靠改代码去掩盖硬件问题。
4. WEAK弱定义与向量表重映射:两个容易让新手懵的机制
4.1 为什么所有中断函数都要标记为WEAK
往下翻启动文件,你会看到一大段长得几乎一样的默认处理函数:
NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP它的意思是:默认提供一个 NMI 中断处理函数,但它是“弱定义”。如果用户在自己的 C 文件里写一个同名全局函数 NMI_Handler,链接器会优先选择用户的强定义,启动文件里的默认函数就被丢弃;如果用户没写,就用这个死循环版本兜底。
这就是为什么你只写了一个 GPIO 中断回调函数就能正常工作的根本原因:启动文件帮你准备了所有中断的默认入口,你只需要覆盖想用的那一个。
反过来,这也是个非常隐蔽的坑。如果把 EXTI0_IRQHandler 错写成 EXTI0_Handler,链接器不会报错,因为启动文件里本来就有 EXTI0_IRQHandler 这个弱定义,编译器会给它兜底成一个死循环。你的真正逻辑根本不会被调用,中断触发后 CPU 就执行那个 B .,表现就是“程序死机,停在某个地址”。
遇到这类问题,第一件事是在 Disassembly 窗口看 PC 停在哪,第二件事是把中断函数名和启动文件里的符号逐个字母比对。符号名错一位,故意查三天都找不到,这种事我见过太多次。
4.2 跳转到App前为什么要手动重映射向量表
Cortex-M3 内核永远从 0x00000000 读取向量表,这是硬件动作,跟你把程序放在哪里无关。裸机工程里程序烧在 0x08000000,刚好又通过 BOOT 配置把 Flash 映射到了 0x00000000,所以时钟、中断向量都顺理成章。
一旦引入 Bootloader,情况就变了。如果 Bootloader 在 0x08000000,App 放在 0x08010000,App 自己的向量表不再落在 0x00000000 对应的位置。此时若不重映射向量表,任何中断发生,CPU 都还是去读 Flash 起始处的 Bootloader 向量,跳进 Bootloader 的中断处理函数,App 里注册的 IRQHandler 根本不会被调用。表现就是“App 的 main 跑得动,串口中断、外部中断全没反应”。
F103 通过 SCB->VTOR 寄存器重映射。典型代码:
SCB->VTOR = 0x08000000 | VECT_TAB_OFFSET;VECT_TAB_OFFSET 是 App 相对 Flash 基址的偏移。App 在 0x08010000,偏移就是 0x10000。App 复位后,SystemInit 里通常也会设置 VTOR,所以它会自动把向量表指到自己所在位置。但要注意:如果跳转前没有关闭外设中断,或者在跳转后、SystemInit 设置 VTOR 之前就来了中断,CPU 还是可能读取旧向量,从而卡进 Bootloader 的默认中断处理。这就是为什么跳转代码通常要先关闭全局中断。
向量表不只是给上电用的,也是给中断机制用的。它的位置、内容、顺序,直接决定中断去哪找人。
4.3 中断完全不响应时,先从启动文件里查什么
我建议把排查顺序固定为三步:查向量表地址 → 查函数名 → 查 NVIC 使能。
- 查向量表地址:确认 App 的 VTOR 是否指向当前程序所在地址。如果 App 不在 0x08000000 执行,而 VTOR 还是默认值,中断一定乱。
- 查函数名:在 map 文件或工程里搜索你写的中断函数名,看是否和启动文件向量表里的符号完全一致,注意大小写和下划线。
- 查 NVIC:向量表只是“门牌”,外设中断事件产生后,必须过 NVIC 的使能开关才能到达处理器。门牌对但没使能,同样不会触发。
实际调试里,很多人会在第三步折腾很久,却忘了第一步和第二步才是锅。一个快速的验证方式:在启动文件默认的中断处理函数里打断点,比如在某个 EXTI 默认函数里打断点。中断触发后如果停在那里,说明中断产生了、向量表也对了,问题就是函数名没对上。
5. 启动阶段的三类真实事故:现象、原因、恢复方法
5.1 栈指针不对导致SWD都连不上
启动文件里的 __initial_sp 存放在向量表第一项。上电第一时间,CPU 会把它装进 SP,然后才去执行后续代码。如果第一个字不是合法的 RAM 地址,程序从第一条指令开始就错乱,内核甚至可能无法正常响应调试请求,表现就是 SWD 连接失败、烧录失败、校验不过。
这类故障经常出现在两种操作之后:一是手误改了启动文件里第一个 DCD,把 __initial_sp 写成了某个固定地址;二是换芯片型号时 RAM 变小,堆栈段却按大容量配置,栈顶越界。现象都一样:仿真器连不上,或者能连上也看不到正常复位流程。
恢复方法按下面顺序来:
- 优先用 Connect under Reset 方式连接。调试器会在 CPU 未完全启动时强制拉复位,抢到控制权;
- 连上后先看 PC、SP 的值,再整体擦除 Flash,恢复正常可烧录状态;
- 长期解决的办法是核对芯片 RAM 大小、启动文件型号,别让链接器把栈放到 RAM 之外;
- 同时检查硬件复位电路和电源,排除非软件因素。
顺便提一个容易混淆的场景:F1 的 SWD 引脚如果被代码配置成了普通 GPIO,后面也会连不上调试器,处理方法同样是 Connect under Reset 后擦除。这个问题和启动文件关系不大,但排查顺序上经常和栈指针问题撞车,先看一眼 PC/SP 就能分离。
5.2 Bootloader跳转后App中断全乱
这是做 IAP 的人最常踩的坑。Bootloader 能正常把 PC 跳到 App 的 main,串口打印也出来了,但只要一开中断,整个系统就死或者功能错乱。原因几乎都是跳转前漏做了三件事:
- 关闭全局中断,避免跳转过程中执行任何 ISR;
- 在 App 的 Reset_Handler 真正接管之前,确保 VTOR 指向 App 的向量表;
- 跳转时重新设置 SP 为 App 的栈顶、PC 为 App 的 Reset_Handler 入口,而不是直接跳到 main。
只跳到 main 的工程会留下巨大隐患:跳过了 App 的 Reset_Handler,等于跳过了 SystemInit 和 C 库初始化,时钟可能还是 Bootloader 的配置,全局变量没有被正确装载/清零,中断向量表也没有重映射。正确的跳转目标是 App 的 Reset_Handler,不是 main。
我常用的一段极简跳转代码,核心思路是四步:
__disable_irq(); SCB->VTOR = 0x08000000 | APP_OFFSET; JumpToApp = (void (*)(void))(*(volatile uint32_t *)(APP_ADDR + 4)); __set_MSP(*(volatile uint32_t *)APP_ADDR); JumpToApp();跳过去以后,App 会像第一次上电一样,从自己的 Reset_Handler 开始完整启动。这样就把所有“跳过初始化”的坑都绕过去了。这个方案里,你仍然需要理解启动文件做了什么,因为跳转例程里的每一步,都在手动模拟上电时启动文件会做的事。
5.3 SystemInit卡死:看似启动文件问题,其实是晶振没起振
我在 3.3 提过这个场景,这里展开成完整排查链路。现象:程序下载正常,复位后调试器停在 SystemInit 的某一行不再前进,单步一直走不出来;PC 所在位置通常是 while 循环,等待 PLL 或 HSE ready;打开 RCC_CR 寄存器看,HSERDY 标志一直为 0。
很多人第一反应是“启动文件写错了”。其实启动文件只负责把 SystemInit 跳过去,真正卡住的循环在时钟配置代码里。病根通常出在硬件:
- 外部晶振没有焊接,或接触不良;
- 晶振两端的负载电容不合适;
- 晶振起振时间特别长;
- 用了有源时钟源,却配成了无源晶体模式。
排查时不要急着改代码跳过 HSE,先把示波器或逻辑分析仪接到晶振输出脚,看起振波形。确认硬件正常后,再检查 SystemInit 里的 PLL 倍频分频配置。如果你确实要在无外部晶振的环境下先调软件,把 SystemInit 里的 HSE 相关代码临时改成 HSI 是可以的,但产品上不能这么干,USB、以太网这类对时钟误差敏感的外设会不稳定。
| 故障现象 | 高概率原因 | 最快定位手段 | 恢复方式 |
|---|---|---|---|
| 仿真器连不上 | 向量表第一项栈指针异常,或 SWD 脚被复用 | Connect under Reset 看 PC/SP | 整体擦除,核对启动文件 |
| App 中断乱 | 跳转前未关中断、未重映射 VTOR、跳到 main | 查看 VTOR 与 PC 入口地址 | 跳 App 的 Reset_Handler |
| 复位后卡死 | HSE 未起振、PLL 锁不定 | 看 RCC_CR 的 HSERDY | 查晶体/负载电容,再改配置 |
6. 验证自己是否真的读懂:三个动手实验
6.1 用调试器单步走一遍Reset_Handler
打开一个能正常跑的 F103 工程,进入调试会话,在 Reset_Handler 入口设一个断点。注意把调试工具里的“自动运行到 main”选项关掉,否则调试器会在你还没观察时直接跑过启动阶段。
复位后停在断点处,你会看到这些现象:
- SP 已经是一个位于 RAM 高地址附近的值。因为 CPU 早就用向量表第一项完成了栈指针初始化;
- PC 停在 0x0800xxxx 的 Reset_Handler 第一条指令;
- 单步一次,能看到 LDR R0, [PC, #n] 从字面量池取到 SystemInit 地址;
- 步入 BLX R0,会进入 system_stm32f10x.c 里的 SystemInit;
- 在 SystemInit 里走几圈后返回 Reset_Handler;
- 再单步,看到 BX R0 跳到 __main。
如果全程盯着 Disassembly 窗口,还能看到 __main 内部搬运数据、清 BSS 的过程。跑完这一遍,你对“main 之前到底发生了什么”会有一种非常踏实的确认感,比背十遍流程都管用。
6.2 用map文件把启动符号逐个对上
这一步用来检验自己会不会看链接产物。编译后打开 .map 文件,搜索这些符号:__Vectors、__Vectors_End、__initial_sp,以及栈段和堆段的名字。重点关注四类信息:
- __Vectors 的起始地址,应该和 Flash 起始地址一致,通常是 0x08000000;
- __initial_sp 的地址,应该落在芯片 RAM 范围内,并且接近 RAM 顶端;
- 栈段的名字和大小,比如 STACK 0x1000,确认你改的 Stack_Size 生效了;
- 堆段的位置,确认它和栈段没有交叉。
用 map 文件核对内存布局,比在调试器里猜准确得多。改完 Stack_Size 后如果不看 map,你根本不知道新栈落在哪里。把这个习惯养成之后,排查内存越界、栈堆重叠这类问题会快很多。
6.3 故意把栈改小,逼出一个HardFault再救回来
主动制造故障,是理解启动文件最快的方法。选一个工程,把 Stack_Size 从 0x400 改成 0x40,也就是 64 字节,重新编译烧录。然后在 main 里写一个深度递归,或者开一个中断嵌套,很快就能触发 HardFault。
进入 HardFault_Handler 默认死循环后,打开调试器的 Fault Reports 或寄存器窗口,能直接看到出错原因,PC 也大概率停在 HardFault 相关地址。修复方法很简单:把 Stack_Size 改回一个合理值,比如 0x800,重新编译烧录。
经历这一遍,你会真正理解“栈顶在向量表第一项、栈大小在启动文件里”意味着什么,以及为什么每次改完启动参数都要去 map 里再确认一次。之后遇到真项目的栈溢出,你的第一反应就会变成“先看启动文件里的栈容量”,而不是漫无目的地翻应用代码。
我自己就是靠这种“故意改坏再救回来”的实验,才彻底不再怕这个 .s 文件。启动文件里真正需要理解的东西其实不多,无非是段、向量表、复位处理、弱定义这几件事。平时别乱动它;真到需要动的时候,先想清楚自己改的是哪一段、影响的是哪个阶段,再下手。这个文件不会成为你产品里的主角,但它能告诉你:一块芯片每次从复位开始,究竟是怎么样一步一步走到你的 C 代码面前的。