启动文件:main 之前的世界是怎么立起来的(以 Cortex-M4 为例)
每个 STM32 工程里都有一个你从未打开过的文件:startup_stm32f407xx.s。几百行汇编,从工程模板生成的那天起就没人多看它一眼。大多数人第一次被迫打开它,是因为一个诡异的 bug——比如这个真实案例:串口收不到数据,单步调试,程序卡死在一个叫 Default_Handler 的死循环里。检查代码,中断服务函数明明写了。再检查一遍,发现了:
1
void USART1_IRQHandel(void) // 少写了一个 r,应为 IRQHandler
编译零 error,链接零 error,甚至零 warning——然后中断一使能,板子就安静地死掉。为什么一个拼错的函数名能无声无息地搞死一块板子?答案全在启动文件里。
再往大了说:你以为 main 是程序的起点。其实 main 开跑的那一刻,世界已经被安排好了——栈立好了,全局变量的初值到位了,时钟配好了,C 库就绪了。谁干的?还是它。这篇文章就把这个”main 之前的黑匣子”拆开。读完你会发现,寄存器篇结尾埋的那句伏笔——”打开启动文件,那些行会突然全部 readable”——兑现了。
先交代一下已有地基。《Cortex-M4 与 MCU 开发》讲过上电后的两步:硬件从地址 0x00000000 读一个数装进 SP,从 0x00000004 读一个数当 PC 跳过去。当时给的是一个简化版的 Reset_Handler 伪代码。本篇把那个简化版展开成真实的启动文件,一层层看它到底干了什么、为什么非这么干不可。
L1:先让它跑起来——把几百行读薄成四块
你在第一层。目标:下次打开启动文件,不再当成注释般的存在,能一眼认出它的骨架。
不管你用的是 ST 的 GCC 版 .s 文件还是 Keil 版,剥掉符号和细节,任何一个 Cortex-M 启动文件都是同样的四块:
| 块 | 干什么 | 一句话比喻 |
|---|---|---|
| 空间预留 | 划出栈和堆的地界 | 一间还没进货的仓库 |
| 向量表 | 近百个函数地址排成数组 | 电话簿 |
| Reset_Handler | 搬数据、清零、调 main | 搬运工 |
| 一群 weak 替身 | 给所有中断函数兜底 | 替身演员 |
四块按顺序过一遍。
第一块:仓库。 文件开头通常是这两个数:
Stack_Size EQU 0x400 ; 1KB 栈
Heap_Size EQU 0x200 ; 512B 堆
(这两行是 Keil 方言;GCC 版写作 .equ Stack_Size, 0x400,或干脆把大小移进链接脚本,所以有些 GCC 启动文件里找不到它们——两种写的是同一件事。)
注意:这里没有搬任何东西,只是两个数字加上 SPACE(在 RAM 里保留一段不初始化的区域)。栈和堆是”地界”,画在地图上,里面此刻还是空的。真正的边界是链接脚本根据这些数算出来的(下文细说)。
第二块:电话簿。 向量表是一张函数地址数组,前几项是固定的:
.section .isr_vector, "a", %progbits
g_pfnVectors:
.word _estack ; 第 0 项:初始栈顶(不是函数!)
.word Reset_Handler ; 第 1 项:复位入口
.word NMI_Handler ; 第 2 项起:异常和中断
.word HardFault_Handler
...
.word USART1_IRQHandler ; 到第几十项才是外设中断
.word 就是”在这里放一个 32 位的数”。所以向量表不是代码,是数据——一张存放地址的电话簿。第 0 项放的是 _estack(栈顶地址),第 1 项起才是函数地址。硬件上电那两步”读 SP、跳 PC”,读的就是这张表的前两项。
第三块:搬运工。 Reset_Handler 是上电后 CPU 执行的第一段代码,《Cortex-M4 与 MCU 开发》给过它的任务清单,剥掉枝节后主干长这样:
Reset_Handler:
ldr r0, =_sdata ; .data 段在 RAM 里的起点
ldr r1, =_edata ; 终点
ldr r2, =_sidata ; 初值在 Flash 里的存放处
movs r3, #0 ; 偏移量清零
b LoopCopyDataInit
CopyDataInit: ; 循环体:从 Flash 抄 4 字节到 RAM
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3 ; 当前抄到哪了?
cmp r4, r1
bcc CopyDataInit ; 没抄到终点就继续
ldr r2, =_sbss ; .bss 段起点
ldr r4, =_ebss ; 终点
movs r3, #0
b LoopFillZerobss ; 先去判断,空段一个字都不写
FillZerobss: ; 循环:把 .bss 清零
str r3, [r2], #4
LoopFillZerobss:
cmp r2, r4
bcc FillZerobss ; 没清到终点就继续
bl SystemInit ; 配时钟(以及开 FPU)
bl __libc_init_array ; C/C++ 运行时收尾(构造函数等)
bl main ; 终于轮到你写的代码
b Infinite_Loop ; main 不该返回;万一返回了,兜底死循环
两个循环、三个调用,没了。但每一行都有讲究,L2 再说。
第四块:替身演员。 向量表里列了近百个 handler(F407 是 98 项:16 个系统异常加 82 个外设中断),但你的工程大概只写了五六个。其余的位置填的其实是同一个函数——Default_Handler,内容是一个死循环。实现方式是 weak 别名:
.weak USART1_IRQHandler
.thumb_set USART1_IRQHandler, Default_Handler
Default_Handler:
Infinite_Loop:
b Infinite_Loop
.weak 的意思是:这个符号很弱,如果别人(你的 .c 文件)定义了同名函数,就听别人的;没人定义,就用它。于是同一张电话簿,既服务只点一个灯的裸机程序,也服务用满全部中断的复杂工程——没写的那些,一律落到替身 Default_Handler 的死循环里。
现在开头的 bug 有答案了:USART1_IRQHandel 拼错了,等于没有定义 USART1_IRQHandler。链接器毫无怨言地用了 weak 替身。串口中断一来,硬件照着电话簿拨号,接电话的是 Default_Handler,一个 b 自己跳自己的死循环——程序就这么安静地死了。链接器没骗你:它只认名字,不认意图。(编译器也帮不上忙——-Wunused-function 只对 static 函数生效,一个拼错名字的全局函数连这条警告都拿不到;链接器看来,它就是个合法定义、恰好没人引用的普通符号。)
到这里,几百行已经读薄成四块了。但一堆”为什么”还悬着:为什么栈顶要放在向量表第 0 项、让硬件去装?_estack、_sdata 这些下划线符号是谁定的、值从哪来?为什么拷数据要用软件循环,CPU 厂就不能把这事做进硬件?
L2:懂原理——每一行底下发生了什么
到这一层,问题变了:不再问”哪块干什么”,而是问”每个决定为什么这样下”。
栈顶为什么在向量表里
先看一个容易滑过去的细节:向量表第 0 项放的不是函数,是栈顶地址。为什么硬件上电要先装 SP 再跳 PC?《Cortex-M4 与 MCU 开发》说过:C 程序一刻也离不开栈,Reset_Handler 自己如果用 C 写,第一条 push 就得有地方压。硬件把”装 SP”做死在复位序列里,等于承诺:任何代码执行前,栈永远就绪。这是个把契约焊进硅片的设计。
顺便认一个老朋友:向量表里那些函数地址,反汇编出来全是奇数——Reset_Handler 显示成 0x08000189 而不是 0x08000188。这是 寄存器篇 讲过的 Thumb 位:Cortex-M4 只跑 Thumb 指令,地址最低位是”这是 Thumb 代码”的标记,硬件跳转时会自动剥掉它。函数指针必须是奇数,这是 ARM 世界跨架构对质时的高频坑。
_estack 们从哪来:启动文件和链接脚本是一对搭档
Reset_Handler 里那些 _sdata、_edata、_sidata、_estack,启动文件自己一个都没定义——它只是引用。定义在链接脚本(STM32F407.ld 之类)里:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶 = RAM 顶端,栈向下长 */
.data : {
_sdata = .; /* RAM 里的起点 */
*(.data)
_edata = .;
} > RAM AT> FLASH /* ← 关键行,见下节 */
_sidata = LOADADDR(.data); /* 初值在 Flash 里的地址 */
所以启动文件和链接脚本是一对搭档:链接脚本划定”什么数据住哪、地界在哪”,启动文件负责”把地图变成现实”。两者靠这几个下划线符号握手。改了链接脚本忘了改启动文件(或反过来换了不配套的启动文件),握手失败,轻则变量初值错乱,重则一上电就 HardFault——这也是为什么换芯片型号时这两个文件必须成对换。
_estack = ORIGIN(RAM) + LENGTH(RAM) 还藏着 《FreeRTOS 核心原理》提过的布局常识:主栈放在 RAM 顶,向下长;.data、.bss 从 RAM 底往上排。两头往中间挤,中间的空当就是两者的安全余量。所谓”栈溢出”,就是栈一路长进了 .bss 的地界——把别人的变量踩了,症状千奇百怪。
.data 的双重生活:一个段,两个地址
> RAM AT> FLASH 是整个故事最妙的一行。它告诉链接器:.data 段运行时住在 RAM(VMA,虚拟/链接内存地址),但内容存放在 Flash(LMA,加载内存地址)。为什么必须两头存在?想想这个变量:
1
int counter = 42;
它有初值。RAM 断电即忘,初值必须存在掉电不丢的 Flash 里;但程序运行时它要被反复读写,又必须住在 RAM。于是它过双重生活:本体(初值 42)躺在 Flash 里,上电后由 Reset_Handler 的第一个循环抄写到 RAM,从此 RAM 里的那份才是”活的变量”。这就是启动文件里那个 CopyDataInit 循环在干的事——搬运工把货从仓库(Flash)摆上货架(RAM)。
.bss 则是那些没初值的变量(int count;)和初值为零的变量。C 标准规定它们上电后必须是 0,但 Flash 里一个字节都不用为它们花——FillZerobss 循环直接往 RAM 里写零就行。这解释了一个高频面试题:为什么把大数组初始化为全零不占 Flash,初始化为全 0xFF 就占?前者编译器归入 .bss(只存”长度”),后者必须归入 .data(每个字节都要存)。改一个字符,Flash 占用几 KB 几 KB地涨,账算在这。
顺便注意 FillZerobss 循环的写法:str r3, [r2], #4——存储后自动把 r2 加 4(后变址寻址)。寄存器篇的”格子”视角下,这就是搬数时顺手把手指往前挪一格,一条指令干两件事,循环体压到最紧。启动代码跑在所有优化之前,必须自己保证自己足够短。
bl main 之前的那三个调用
SystemInit:芯片厂提供的 C 函数,配时钟树(把 16MHz 内部 RC 换成 168MHz 外部晶振 PLL 输出之类)。它在搬运之前还是之后被调用,各家启动文件版本并不统一(老版官方 GCC 文件放在搬运前,新版放在搬运后)——两种顺序都能跑,本文取搬运之后的顺序:全局变量先就位,SystemInit 里的 C 代码读它们更稳。寄存器篇提过它还兼职开 FPU——复位后协处理器是禁用的,CPACR |= 0xF << 20 那段就在这(或直接在 Reset_Handler 汇编里)。为什么放在 main 前?因为你的代码里随便一个 float 乘法都得 FPU 先活着。
__libc_init_array:GCC 世界的 C/C++ 运行时初始化,C++ 的全局对象构造函数就靠它逐个调用。纯 C 工程也调它无害——C 标准库自己的初始化钩子也挂在这。Keil/ARM Compiler 世界对应的角色叫 __main,它更贪心,连搬运 .data 都由它按 scatter 文件自动干(所以 ARMCC 的启动文件里你可能看不到搬运循环——不是没有,是换了人干)。
b Infinite_Loop 兜底:main 的返回类型是 int,但裸机上它无处可返回——没有操作系统在等这个返回值。万一 main 真的跑到末尾返回了,与其让 PC 接着执行 .text 段后面的随机内容,不如明确地死循环。嵌入式代码对”意外”的态度从来是:宁可可观察地卡死,不要不可控地乱跑。
BOOT 引脚与 0x00000000 的替身戏
《Cortex-M4 与 MCU 开发》说硬件从 0x00000000 取初始 SP 和 PC,但链接脚本里 Flash 明明在 0x08000000——矛盾吗?不。Cortex-M4 的地址 0x00000000 是个别名窗口:STM32F4 的 BOOT 引脚决定这块窗口映射到谁——正常启动映射到 Flash(0x08000000)、串口下载模式映射到系统 ROM(芯片出厂内置的 bootloader)、还有一种映射到 SRAM(调试用)。所以你的程序物理上在 0x08000000,上电那一刻却又”看起来”在 0x00000000。同一份向量表,被硬件演了一出替身戏。
映射到 SRAM 的那个模式还牵出一个寄存器:VTOR(向量表偏移寄存器)。如果程序真的搬到 RAM 里跑,或者固件升级后 app 搬到了 0x08010000,向量表就不在 0 号窗口里了,得把新地址写进 VTOR,中断才找得到电话簿。做 bootloader(IAP)的人对它最熟——跳转到 app 前忘了设 VTOR,是 bootloader 三大经典翻车点之一。
到这里,整个启动流程从硬件到软件已经串起来了。但退一步看,还有几个”设计层”的问题:为什么非得用汇编写?为什么硬件只管装 SP 和 PC 两个字,搬 .data 这种活不一起干了?为什么向量表放地址、不放跳转指令?
L3:想得透——为什么非得是这副骨架
到这一层,问题变成:换个设计行不行?代价是什么?
本质:启动文件到底是什么
去掉所有术语,启动文件是C 语言与裸硬件之间的契约兑现机。
你写 C 的时候,语言替你做了一堆你从没签字却一直享受的假设:全局变量有初值、未初始化的变量是零、函数随时能调(栈就绪)、C 标准库能用、(C++ 的)全局对象已构造。这五条假设没有一条是硬件天然保证的——裸机刚上电,RAM 里全是随机数。启动文件的工作,就是在 main 之前把这五条逐一兑现。
这个视角下,开头的所有零件都有了统一的名字:搬运循环兑现”变量有值”,向量表第 0 项兑现”栈就绪”,__libc_init_array 兑现”运行时可用”。main 不是程序的开始,是契约全部兑现后的交付仪式。
为什么”必须是汇编”是个误会
标准答案会说”启动代码必须汇编,因为 C 的运行环境还没立起来”。这话半对半错,值得拆穿。
真的非要汇编的部分其实很少:向量表是个函数指针数组(C 完全能写:__attribute__((section(".isr_vector"))) 的数组),搬运循环里全是栈上/寄存器里的局部变量(C 也能写),清零循环同理。证据是 ST 官方已经这么干了——新版 STM32Cube 工程的启动文件是 startup_stm32f407xx.c,一个纯 C 文件。你没有看错:启动文件可以是 C。
那为什么几百行 .s 还统治了十几年?三个真实原因:一是鸡生蛋的边界确实存在——SP 得有人立(虽然 Cortex-M 硬件代劳了,但 ARM7 那代没有,见下节历史),第一个 C 函数调用前栈必须就绪,而”调用前”这三个字在汇编里能精确到指令;二是老工具链对 section 属性、链接顺序的控制不如今天可靠,汇编是唯一保底;三是行业惯性——模板代码没人有动机重写。所以正确的说法是:启动代码里只有”必须早于任何 C 代码执行的部分”才必须是汇编,而 Cortex-M 的硬件把这部分压缩到了几乎为零。硬件替你干的越多,启动文件就越能上移到 C。
硬件为什么只装两个字,不把搬运也干了
上电复位序列只读两个词:SP 和 PC。为什么不做多些——比如让硬件顺便把 .data 抄了、把 .bss 清了,main 直接一步到位?这个方案真的存在,而且有量产案例:
- ESP32:片上掩膜 ROM 里固化了一整只 bootloader,上电后它自己从 Flash 的固定位置读二级 bootloader、校验、搬运 image,两步走才轮到你的代码;
- TI MSP430 FRAM 系列:更绝,程序和变量同住一块 FRAM(非易失、可写),
.data压根不需要”从 Flash 抄到 RAM”——同一个地址既是仓库又是货架,搬运这个概念直接消失。
那 Cortex-M 为什么选了”只装两个字 + 剩下你软件自己想办法”?因为通用。硬件搬运意味着固件布局(段的顺序、地址、大小)被硅片焊死了:ROM bootloader 得认识固定的 image 格式,FRAM 方案依赖特定的内存技术。而 Cortex-M 是要卖给几百个芯片厂、塞进几千种产品的内核——它只保证最小公倍数(”给你 SP 和 PC,剩下的你的地盘你做主”),把启动策略的裁量权留给芯片厂和用户。代价是每个工程都拖着一份启动文件;收益是同一个内核既能当 Flash 独占的裸机,也能当带二级 bootloader 的 OTA 设备。这不是懒,是把不变量做进硬件,把策略留在软件——你在 中断篇 见过同一手法的另一个变体:硬件只做 12 周期的最小压栈,剩下的策略全留给软件。
向量表为什么是地址簿,不是跳转指令表
对照一个真实反例:AVR(Arduino 那颗)的中断向量表里放的是一条条 jmp 指令——中断来了,CPU 直接把向量槽里的指令当指令执行。而 Cortex-M 放的是地址,硬件多走一步”查表取地址再跳”。放指令看起来更直接,代价却很深:
- 地址是数据,数据就能在运行时改写——整套 VTOR 机制(运行时重定位向量表)因此成为可能。跳转指令要想改,得去改代码,改代码得先能写代码所在的内存;
- 同一张表既能烧在只读 Flash 里,也能被 bootloader 搬进 RAM 改完再用。跳转指令表做不到,除非代码区可写。
这不是臆想出来的失败场景,是 ARM 亲身淌过的河。ARM7TDMI 时代,向量表固定在地址 0、里面放的是跳转指令,可 Flash 做不到地址 0 还能随时改写。想让中断处理函数在运行时可换(早期调试器、动态加载都想要),工程师只好搞出一套痛苦的 hack:把一小块 RAM “重映射”(remap)到地址 0,向量表抄进去再改。VTOR 就是这段黑历史的官方道歉——一个寄存器写个新地址,向量表搬家,齐活。今天你做 bootloader 跳转 app、做 FreeRTOS 时把 SysTick/PendSV 的处理权交给内核,底层都在吃这份红利。
weak 替身为什么是对的默认值
向量表近百项,你的工程用五六项。另一个极端设计是:要求用户显式实现每一项,不写就链接报错。听起来更”严谨”,实际是灾难——点个灯的工程逼你手写 100 个空函数;而一旦大家都开始复制粘贴一份”空函数全家桶”,问题换个形式回来了:拼错名字照样有人发现不了,因为那一项被复制的空函数占着,比死循环还难查(Default_Handler 至少能让调试器停在显眼的符号上)。
weak 方案本质是把向量表做成带默认实现的接口:你不实现,用默认;你实现了,无缝替换,接口(电话簿)一个字不用改。这和面向对象里”接口 + 默认实现”的取舍是同一道题——强制全实现的接口换来的是表面的严谨,付出的是每个用户的样板代码,而样板代码正是 bug 的温床(开头的拼写错误就是被样板代码的”总有一份能抄”掩盖的)。
动手环节:亲手摸到这些机制
光看不算数,来两件事。
实验一(有交叉编译环境就能做):看见 .data 的双重生活。 找一个能构建的 STM32 工程(或自己在 GPIO 篇的裸机代码基础上配个 arm-none-eabi-gcc 工程),在代码里加一个带初值的全局变量:
1
int magic = 0x12345678; /* 会进 .data */
构建出 .elf 后,先猜:objdump -h 列出的 .data 段会显示几个地址?它住在 Flash 还是 RAM?
猜完执行:
1
arm-none-eabi-objdump -h firmware.elf | grep -E '\.data|\.bss'
预期输出类似:
1
2
3
Idx Name Size VMA LMA ...
4 .data 00000004 20000000 08000430 ...
5 .bss 00000218 20000004 20000004 ...
看 .data 那行:两个地址不一样——VMA 是 0x20000000(RAM,运行时住处),LMA 是 0x08000430(Flash,初值仓库存放处)。_sidata 和 _sdata 就是这两个数,Reset_Handler 的搬运循环就是从 LMA 抄到 VMA。而 .bss 的 VMA 和 LMA 相同——它没有初值要搬,自然不需要两处房产。顺手把 magic 改成 = 0 再构建,看它从 .data 消失、.bss 长大 4 字节——上一节的面试题,亲眼看一次就忘不掉了。
实验二(板子上做,复现开头的死法):亲手杀死一块板子。 在任意一个能跑的中断工程里,把某个已经注册的中断服务函数名改错一个字母(比如把 SysTick_Handler 写成 SysTick_Handl),编译链接——观察:一个 error 都没有。烧录运行,开调试器暂停,看 PC——停在 Default_Handler 的 b Infinite_Loop。对照启动文件里的 weak 声明确认:你拼错的函数成了没人引用的孤魂,电话簿上那一格接电话的是替身。然后把名字改对,一切复活。花十分钟主动踩一次这个坑,胜过将来在产线上追三天。
自测两题,能答出来说明真懂了:一是向量表第 0 项为什么必须是栈顶而不是第一段代码?(提示:想想”C 代码执行前硬件必须兑现哪条假设”,答案在 L2 第一节。)二是 main 返回了会发生什么、启动文件对此的态度是什么——这个设计哲学在本文哪个更大的决策里还出现过?(提示:找两处”宁可明确死,不要随便跑”。)
收尾地图
把整篇收进一张速查表——出问题时去哪看:
| 症状 / 问题 | 去哪看 | 一句话理由 |
|---|---|---|
| 变量初值是随机数 | Reset_Handler 的搬运循环 | .data 没搬成(启动/链接脚本不配套) |
| 未初始化变量不是零 | FillZerobss 循环、.bss 边界符号 | C 标准的零由这段兑现 |
| 中断一开就卡死在死循环 | weak 替身 Default_Handler | 九成是 handler 名字拼错 |
| main 一返回就跑飞 | b Infinite_Loop 兜底 | 裸机没有”返回”这个概念 |
| 栈溢出踩坏变量 | 链接脚本 _estack 布局 | 栈从 RAM 顶向下长进 .bss |
| float 运算直接 HardFault | SystemInit 里的 CPACR | FPU 复位后默认是关的 |
| bootloader 跳 app 后中断全失灵 | VTOR 寄存器 | 中断还在旧电话簿里找 |
| 想知道变量到底住哪 | objdump -h、.map 文件 | VMA 是住处,LMA 是仓库 |
下一站,两条路任选。往链接器里走:启动文件是”契约的执行者”,链接脚本才是”契约的起草者”——打开你工程的 .ld,MEMORY、AT>、各个 . 赋值,每一行现在都对着本文某个零件,看懂它你就掌握了整个内存布局的话语权;再学读 .map 文件,”程序为什么超了 2KB”这类问题从此有据可查。往系统里走:给 FreeRTOS 换个角度重看——它的可移植层(portable/GCC/ARM_CM4F/)本质上是一份”第二启动文件”:它接管了电话簿里的 PendSV 项、给每个任务安排了自己的 _estack,本文每块零件都能在里面找到对应物。main 之前的世界看清了,你对这块板子的理解,才真正从”写应用”上移到了”懂系统”。