Keil MDK中.s启动文件详解:从复位到main的执行流程
搞单片机如果一直停留在“点灯、按键、串口打印”这种应用层,早晚会在硬件不稳定、程序跳飞、中断进不去这些问题上摔跟头。而这些问题,十有八九要回溯到芯片上电后的第一个动作——启动文件。在 Keil 的 MDK 工程里,启动文件就是那个后缀为.s的汇编文件,很多新手会直接忽略它,甚至不知道它在工程树里是干嘛的。这篇文章就把.s启动文件彻底讲透:它到底是什么、在 Keil 工程里怎么工作、上电后执行了什么、栈和堆在哪里配置、中断向量表怎么展开,以及常见的编译报错怎么排查。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 文件类型 | ARM Cortex-M 系列汇编源文件,后缀为.s |
| 运行位置 | Keil MDK 工程启动阶段,Reset_Handler 最先执行 |
| 核心职责 | 初始化栈指针、配置向量表、调用 SystemInit、跳转 __main |
| 适用芯片 | STM32F1/F4/F7/H7、GD32、MM32、N32 等 Cortex-M 内核芯片 |
| 工程配置项 | 栈大小 Stack_Size、堆大小 Heap_Size、向量表偏移 |
| 是否可裁剪 | 可以,但不建议在不懂后果的情况下删除向量表项 |
| 是否支持可视化调试 | 支持,MDK 调试模式下可单步执行启动流水线 |
| 是否有运行时 API | 没有,启动文件是在编译链接阶段固定的汇编源码 |
| 典型出错点 | 缺失启动文件、栈溢出、SCB->VTOR 未设置、WEAK 符号冲突 |
| 学习门槛 | 需要理解 ARM 汇编基本指令、链接脚本分散加载文件的基本概念 |
这里先给一个直接结论:.s启动文件并不是 Keil 自动帮你写好的黑盒,而是你在新建工程时手动添加进去的汇编文件。它决定了芯片上电之后的第一段代码,如果这一段错了,后面的 C 语言main()连跑起来的资格都没有。
2. 启动文件在 Keil 工程中的位置
2.1 工程树里那个.s文件
打开一个标准 STM32 工程,工程树里通常能看到一个以芯片型号命名的文件,例如startup_stm32f103xe.s。这个文件放在 Application 组或 Startup 组里,具体位置取决于你创建工程时选择的芯片型号和库版本。它不属于用户手写的业务代码,而是芯片厂商提供的启动引导代码。
2.2 启动文件是编译链接的一部分
在 Keil MDK 中,.s文件不是文档,也不是配置文件,它会被汇编器 armasm 或 armclang 的汇编阶段处理。编译时它生成目标文件,然后在链接阶段与其他 C 文件生成的目标文件一起被链接成最终的.axf文件。你可以把启动文件理解为一段“前置代码”,它在链接时被放在 Flash 的最开始区域,也就是芯片复位后取指的位置。
2.3 为什么缺了.s文件工程还能编译
有些工程使用 STM32CubeMX 自动生成,启动文件默认被选中并加入 Makefile 或 Keil 工程,看起来好像是“Keil 自动处理了”。实际上,CubeMX 只是把对应芯片的启动文件复制到了工程目录,然后加进工程配置。如果某次操作时没有勾选生成启动文件,或者手动清理工程时误删了startup_stm32xxx.s,编译仍可能通过,因为链接器可能从库或分散加载描述里找到一段默认入口,但烧录后大概率会跑飞。
3. 启动文件执行流程详解
这是全文最重要的一节。启动文件的本质是一段汇编代码,它把 Cortex-M 内核从复位状态引导到 C 语言环境。下面按顺序拆解。
3.1 向量表与初始栈指针
Cortex-M 芯片上电后,CPU 从 Flash 起始地址读取两个字:
- 第一个字是初始栈指针值
__initial_sp。 - 第二个字是复位向量
Reset_Handler的地址。
这两个字被写在向量表最前面。启动文件里先用Stack_Size开辟栈空间,然后用AREA STACK, DATA, READWRITE定义栈区段,用__initial_sp导出栈顶地址。如果你把启动文件删除或篡改,芯片上电时读到的第一个字无效,程序立刻进 HardFault。
3.2 Reset_Handler 做了什么
复位后 CPU 跳转到Reset_Handler,这个函数是启动文件的执行主体。标准流程如下:
Reset_Handler PROC EXPORT Reset_Handler IMPORT __main IMPORT SystemInit LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP这段代码做了三件事:
- 调用
SystemInit(),它是 C 语言实现的系统时钟初始化函数,通常位于system_stm32f1xx.c中。 - 跳转到
__main。 __main最终会调用 C 库初始化,再跳转到用户main()。
注意这里的__main不是用户写的main函数,它是 C 库的入口,负责初始化 ZI 段、RW 段、堆栈和标准库环境,然后才会调用用户main。如果启动文件里的IMPORT __main漏了,链接时会出现未解析符号。
3.3 SystemInit 是关键一步
很多初学者发现程序运行后时钟不对、定时器时间翻倍,问题往往出在SystemInit没有被正确调用。因为启动文件里调用了SystemInit,如果你的工程没有实现这个函数,链接器会因为找不到SystemInit符号而报错。如果工程中有两个文件同时定义了SystemInit,链接器也会报重复定义错误。
3.4 跳转到 __main 后的隐藏逻辑
在 C 库的__main流程中,启动文件还承担了数据段搬运的准备工作。它通过分散加载文件把 RO、RW、ZI 段信息交给 C 库启动代码。启动文件里定义的区域和分散加载文件的执行区域必须匹配,否则程序可能在跳转__main后进入异常。
下面给出一个最小化启动文件流程表格:
| 步骤 | 执行内容 | 关键符号 |
|---|---|---|
| 上电复位 | CPU 从 0x08000000 读取 SP 和 PC | __initial_sp、Reset_Handler |
| Reset_Handler | 调用 SystemInit、跳转 __main | SystemInit、__main |
| __main | 初始化 RW/ZI 段、调用用户 main | main |
| 异常/中断 | 通过向量表跳转到对应服务函数 | NMI_Handler、HardFault_Handler等 |
4. 栈与堆的配置
栈和堆是启动文件里最容易被忽视、但又最容易引发运行问题的配置。
4.1 栈大小配置
启动文件开头的典型配置如下:
Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_spStack_Size决定系统栈大小。这里0x400是 1KB。如果程序递归层级深、中断嵌套多或者局部变量很大,1KB 可能不够。栈溢出时程序不会立即崩溃,可能出现局部变量被意外修改、程序跳飞、HardFault 越来越频繁。把栈配置调大,例如0x00001000,可以缓解问题,但代价是 RAM 被静态占用。
4.2 堆大小配置
Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 __heap_base Heap_Mem SPACE Heap_Size __heap_limit如果你用了malloc、free或者 C 标准库的动态内存函数,堆大小必须大于实际申请量。嵌入式开发中默认建议少用动态内存,但在 JSON 解析、通信协议重组等场景中,堆配置依然重要。
4.3 线程模式与 MSP 的关系
在裸机环境下,复位后的主堆栈指针就是__initial_sp。如果启用了 RTOS,每个任务会创建自己的任务栈,启动文件里的 Stack_Size 可以被保留为中断和异常使用的主栈。此时,启动文件定义的栈大小影响的是裸机模式或 FreeRTOS 中断嵌套深度。
4.4 修改配置的两种方式
- 直接改
.s文件里的EQU值。 - 在分散加载文件
.sct中重定义启动区域的布局。
直接修改.s文件是最简单的做法,但要注意不要删除.s文件中的对齐指令。ALIGN=3表示 8 字节对齐,这是 ARM AAPCS 调用约定的基本要求。
5. 中断向量表与中断处理
5.1 向量表的结构
启动文件里会定义一个数据段,存放异常和中断向量。以 STM32F1 为例,前 16 个是系统异常向量,从第 16 个开始是外设中断向量。向量表的每一项都是一个函数地址。如果外设中断使能后,对应的向量表项没有正确填写,中断触发时 CPU 会跳到空地址,最终卡死在 HardFault。
AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp DCD Reset_Handler DCD NMI_Handler DCD HardFault_Handler DCD MemManage_Handler DCD BusFault_Handler DCD UsageFault_Handler5.2 WEAK 声明的意义
启动文件里大量使用WEAK声明,例如:
NMI_Handler PROC EXPORT NMI_Handler [WEAK] B . ENDP[WEAK]表示这个符号是弱定义。如果你在 C 文件里重新写了一个NMI_Handler,链接器会优先使用 C 文件里的强定义。这是启动文件给用户留的后门。很多库函数里写的中断回调函数,本质上就是把启动文件里的弱定义给覆盖掉。
一个常见误区是:在 C 文件里写了void NMI_Handler(void) {},但忘记在启动文件里保留对应向量项,结果链接时没有冲突,编译通过,运行后中断却进不了。原因就是向量表项是启动文件里的DCD NMI_Handler,它指向的是弱定义函数,而不是你新写的函数。
5.3 向量表偏移设置
当使用 Bootloader + App 架构时,App 的 Flash 起始地址不再是 0x08000000,需要在 App 工程里设置向量表偏移。Cortex-M3/M4 内核支持通过SCB->VTOR寄存器设置向量表偏移地址。在 SystemInit 之后、用户 main 之前设置:
SCB->VTOR = 0x08010000;如果启动文件或用户代码没有设置 VTOR,App 启动时会从 0x08000000 读取向量表,而那里是 Bootloader 的向量表,导致中断全部错乱。
6. Keil 工程环境准备与启动文件使用
6.1 新建工程时如何添加启动文件
以 Keil MDK 5 为例,新建 STM32 工程时,Device 选择芯片型号后,Keil 的 Manage Run-Time Environment 会列出 Startup 组件。正常情况下勾选 Startup 后,启动文件会被自动复制到工程文件夹。如果选择的是旧版标准外设库方式,需要从库文件夹里手动找到startup_stm32f10x_hd.s或类似文件,然后添加到工程组中。
6.2 启动文件与编译器的匹配
Keil 支持两种编译器:ARMCC v5 和 ARMCLANG v6。启动文件在这两种编译器下都能汇编,但语法细节略有差异。遇到.s文件编译报错时,可以先确认当前工程用的是哪个编译器版本。在 Options for Target -> Target 页面里可以看到编译器版本选择。有些从老工程复制过来的启动文件在 ARMCLANG v6 下会有告警,但通常不影响生成目标文件。
6.3 分散加载文件与启动文件配合
链接时,启动文件里的AREA RESET, DATA, READONLY决定了向量表被放在只读区域(Flash)。分散加载文件指定ER_IROM1的起始地址和大小,启动文件生成的对象会被链接到这个区域的起始位置。如果分散加载文件里把首地址改到了非 0x08000000 的偏移处,而启动文件又设置了向量表偏移,则启动文件里的__initial_sp和Reset_Handler必须与链接地址一致。这个环节最容易在 Bootloader 工程中出现混乱。
7. .s 启动文件在 STM32 不同系列中的差异
7.1 STM32F1 系列
F1 系列的启动文件按 Flash 容量分为startup_stm32f10x_ld.s、md.s、hd.s、xl.s等。不同文件里的向量表长度不同,因为外设中断数量不同。选错启动文件会导致某些外设中断无法响应。例如 HD 文件适用于 Flash 容量在 256KB 到 512KB 之间的芯片,如果你用的是 64KB 的芯片却选了 HD 文件,链接地址可能与实际 Flash 容量不匹配,下载后容易出现硬件错误。
7.2 STM32F4 系列
F4 的启动文件命名通常为startup_stm32f407xx.s或startup_stm32f429xx.s,F4 内核带有 FPU,启动文件对浮点单元的处理更明确。F4 系列向量表包含更多系统异常项,但总体流程与 M3 类似。
7.3 STM32H7 系列
H7 系列是 Cortex-M7 内核,启动文件里通常需要处理 Cache 初始化、Flash 接口配置等内容。H7 的启动流程比 F1 复杂,因为 M7 内核支持指令 Cache 和数据 Cache,如果启动阶段不配置,后续代码执行可能遇到性能问题,甚至出现外设数据不一致。此时启动文件里对SystemInit的依赖更强。
7.4 国产芯片与启动文件
GD32、MM32、N32 等国产 Cortex-M 芯片,启动文件结构与 STM32 高度相似,但中断向量表编号、外设中断数量可能与 STM32 不同。使用国产芯片时,尽量使用厂商提供的启动文件,不要直接拿 STM32 的启动文件改几个名字就套用,否则外设中断映射会错位。
8. 启动文件与 C 代码的交互接口
8.1 IMPORT 与 EXPORT 符号
启动文件通过IMPORT引入 C 语言实现的函数,比如SystemInit。通过EXPORT导出汇编符号,比如Reset_Handler、__initial_sp、__Vectors。从链接器角度看,这些就是全局符号,C 代码可以通过extern声明来访问它们。
8.2 C 代码如何访问栈顶地址
某些启动代码中会用到初始栈指针做内存布局判断,例如:
extern uint32_t __initial_sp; uint32_t get_initial_sp(void) { return (uint32_t)&__initial_sp; }注意__initial_sp是汇编标签,对它取地址得到的是栈顶地址,而不是这个变量自身的地址。这与普通 C 变量定义有区别,但可以在调试器中用来确认栈顶是否符合预期。
8.3 弱函数覆盖机制
启动文件里预设了Default_Handler作为所有中断的兜底。C 文件里实现具体的USART1_IRQHandler后,启动文件的向量表项依然存在,但地址被链接器解析为强定义函数。这个机制相当于给中断服务函数留了一个“注册接口”。如果你的中断服务函数名拼写错误,比如把USART1_IRQHandler写成USART1_IRQn_Handler,编译不会报错,但中断不会进入你的函数,而是落入Default_Handler死循环。
8.4 启动文件不是动态 API
需要特别说明:启动文件是一段静态汇编代码,没有运行时 API。它不会在运行期间被改写成可调用的函数。所有配置都必须发生在编译链接阶段。如果你想在运行时动态修改向量表,需要用户程序自己操作VTOR和中断向量表 RAM 镜像,这与启动文件无关。
9. 资源占用与性能观察
9.1 RAM 占用与栈大小的关系
启动文件里的Stack_Size和Heap_Size直接占用 RAM 静态空间。修改.s文件后,编译日志里.map映射文件会显示栈和堆区域的实际占用。如果 RAM 紧张,需要精确计算最大中断嵌套深度和最大局部变量占用,不要盲目调大栈。
9.2 Flash 占用与向量表长度
启动文件的向量表是只读数据,会占用 Flash。中断数量越多的芯片,启动文件占用的 Flash 越大。例如 STM32F103 的 HD 启动文件向量表大约占用几十个字节到几百字节不等,具体以链接后的 map 文件为准。对于 Flash 很小的芯片,选择合适容量的启动文件非常重要。
9.3 中断响应性能
启动文件本身不影响中断响应速度,但它决定的向量表位置会影响中断查找效率。Cortex-M 内核向量表是线性查找,理论上表越长,查表时间稍有增加,但实际影响极小。更重要的性能因素是SystemInit里设置的 Flash 等待周期,如果时钟频率提高但 Flash 等待周期没配够,程序执行会不稳定。
9.4 如何观察启动流程耗时
在 Keil 的调试模式下,可以在Reset_Handler和__main处打断点,用逻辑分析仪或 GPIO 翻转来测量启动时间。如果启动时间过长,重点检查SystemInit里的时钟配置和 Flash 擦写逻辑,因为启动文件本身只会执行几条跳转指令。
10. Keil 中常见 .s 启动文件报错与排查
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
编译报错Undefined symbol SystemInit | 启动文件引入了 SystemInit,但工程没有实现 | 搜索工程内是否包含 system_stm32xxx.c | 添加对应系统时钟文件,或者在启动文件中移除该调用 |
编译报错Duplicate symbol Reset_Handler | 有两个文件定义了相同强符号 | 查看 map 文件确认符号来源 | 删除重复定义文件,或修改其中一个的符号名 |
链接告警L6305W或类似堆栈超过区域 | Stack/Heap 设置过大,超出 RAM 区域 | 查看 map 文件中 RAM 区域使用率 | 调小栈或堆,或改用分散加载文件扩大 RAM 可用区域 |
| 烧录后程序卡死 | 向量表首字不是合法栈指针 | 用调试器查看 PC 和 SP 寄存器 | 确认启动文件是否正确链接到 Flash 起始地址 |
| 中断无法触发 | 启动文件与芯片型号不匹配 | 对比芯片中断数量与启动文件向量表 | 更换对应容量的启动文件 |
| App 内中断全部异常 | 向量表偏移未设置或启动文件未配合 | 检查 VTOR 是否设置 | 在 C 代码或启动文件中正确设置 VTOR |
| 编译通过但运行缓慢 | SystemInit 或时钟配置异常 | 调试运行至 main,查看 RCC 寄存器 | 检查外部晶振和 PLL 配置 |
| 使用 ARMCLANG v6 时启动文件告警 | 汇编语法与 clang 汇编器有差异 | 查看 Build Output 的具体告警行号 | 按编译器要求调整伪指令写法 |
11. 调试启动流程的实操方法
11.1 进入调试模式
在 Keil 中点击 Debug 按钮,连接 ST-Link 或 J-Link。程序会自动停在复位入口,也就是汇编代码的Reset_Handler或 C 代码的启动入口。这一步验证启动文件是否成功参与链接。
11.2 单步执行启动代码
在调试模式下打开 Disassembly 窗口,可以看到启动文件对应的汇编代码。执行LDR R0, =SystemInit后,可以查看 R0 的地址,然后确认这个地址对应的函数是否属于 system_stm32xxx.c。如果地址明显不对,说明链接符号冲突或启动文件使用了错误的系统时钟文件。
11.3 观察栈指针
在启动文件的__initial_sp处查看寄存器 SP。复位后 SP 应等于__initial_sp的值,例如 RAM 末尾地址。如果 SP 值不在 RAM 范围内,说明向量表加载失败。
11.4 验证 main 是否被调用
在用户main()函数入口打断点。如果程序能够从启动文件单步执行到main,说明启动流程基本正常。如果卡死在Default_Handler或HardFault_Handler,需要回溯是中断向量表错位、时钟配置异常还是栈溢出。
12. Keil 工程中启动文件的最佳实践
12.1 不要直接删除启动文件
即使你的工程从别的工程复制而来,也不要清理工程时顺手删掉.s文件。这是从复位到 main 的唯一桥梁。没有它,程序几乎没有机会正常运行。
12.2 保持启动文件与芯片型号一致
修改工程芯片型号后,第一件事就是同步更新启动文件。不要指望旧的启动文件能在新芯片上自动适配。对照芯片型号反查启动文件关键词,例如startup_stm32f103xe.s对应的是中等密度还是高密度,要做到心中有数。
12.3 修改栈堆时保留备份
如果调栈堆大小后出现奇怪问题,可以快速还原.s文件,避免在调试时混淆变量。建议把启动文件放到版本管理工具里,不要把它排除在代码仓库之外。
12.4 正确处理系统时钟文件
启动文件里的SystemInit调用是强依赖。使用的system_stm32xxx.c最好与芯片配套。手动改系统时钟时,要确认SystemInit的时钟配置不会被启动文件里的其他初始化逻辑覆盖。
12.5 进入 RTOS 后保留主栈
使用 FreeRTOS 时,启动文件里定义的栈依然作为中断主栈。任务栈在堆中分配或静态数组分配,不要为了让任务栈更大而把启动文件里的Stack_Size改成 0,否则中断嵌套时主栈不够用,会导致系统崩溃。
12.6 合规使用 Keil 软件
Keil MDK 是商业软件,建议通过正版授权使用。搜索到的所谓“注册机”“破解”相关内容并不适用于正式开发环境,工程代码、编译器和调试器之间若存在授权问题,可能影响后续产品交付。学习阶段可以使用 Keil 官方评估版或社区版,避免法律和工具链风险。
12.7 使用 Start 文件审计代码规模
在 Code Review 或项目交接时,把启动文件纳入审查范围。重点看三处:
- 向量表是否完整。
- 栈堆配置是否符合当前 RAM 大小。
- Reset_Handler 里的跳转目标是否正确。
13. 从启动文件到链接脚本的闭环理解
13.1 .s 文件与 .sct 文件的关系
.s文件定义了段,.sct分散加载文件定义了段的地址。两者的配合关系决定了程序下载到 Flash 后的运行视图。如果只修改.s文件不改.sct,可能出现段名匹配不上;如果只改.sct不改.s,可能出现地址错位。
下面是一个常见分散加载片段:
LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } }注意*.o (RESET, +First)。这一行指定启动文件中的RESET段必须放在 Flash 起始地址。如果这里没有+First,启动文件可被链接到其他位置,上电后 PC 无法执行复位代码。
13.2 启动文件如何影响 Bootloader 设计
Bootloader 模式下,App 工程的分散加载文件首地址会偏移,此时启动文件里的向量表仍然被链接到新的首地址。程序跳转前,Bootloader 需要确认 App 向量表首字和复位向量是合法的,否则不能直接跳转。判断条件通常为:
uint32_t app_sp = *(volatile uint32_t *)app_addr; uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); if ((app_sp & 0xFFF00000) != 0x20000000) return;这个检查依赖启动文件在地址app_addr处放置了正确的__initial_sp和Reset_Handler。所以启动文件不只是“上电自动跑”的一段代码,还是 Bootloader 判断 App 是否合法的数据基础。
14. 小节:用一张图理解启动文件生命周期
虽然不能用 Mermaid,但可以用文字和表格描述:
| 阶段 | 地址空间 | 执行内容 | 验证手段 |
|---|---|---|---|
| 复位取指 | Flash 起始地址 | 读取 SP、PC | 调试器查看 PC |
| Reset_Handler | Flash 代码区 | 调用 SystemInit | 单步执行 |
| __main | Flash 代码区 / RAM 搬运 | 初始化 RW/ZI | 调试器查看 RAM |
| 用户 main | Flash 代码区 | 业务逻辑 | 断点命中 |
这个生命周期从芯片复位开始,到用户 main 结束。只要中间任何一环断裂,程序行为都会变得不可控。
15. 写在最后
这篇内容没有讲复杂的写代码技巧,而是把 Keil 工程里那个最不起眼、也最关键的.s启动文件从执行流程、栈堆配置、中断向量、链接脚本配合到常见报错完整过了一遍。如果你平时主要靠复制粘贴别人的工程跑通功能,建议下次新建工程时,先打开启动文件看一遍Reset_Handler和Stack_Size再写业务代码。遇到中断进不去、栈溢出、程序跑飞、Bootloader 跳转失败这类问题,优先怀疑启动文件和相关链接配置,排查效率会高很多。下一步可以继续研究分散加载文件、SystemInit 时钟树和 RTOS 启动流程,这三者与启动文件的配合,才是嵌入式底层基本功的真正分水岭。建议先把自己的工程用调试器从复位开始单步跑一遍,再回头看这篇文章,很多细节会清晰很多。
