3. 启动代码与链接脚本:芯片启动流程、汇编启动代码编写、链接脚本(.lds)配置
说实话,很多做嵌入式应用层的朋友,一听到「启动代码」和「链接脚本」就头大。我当年刚入行时也一样,觉得这玩意儿是硬件工程师的事,跟我BSP开发有什么关系?直到有一次,我调试一块新板子,CPU死活跑不起来,查了三天硬件,最后发现是链接脚本里RAM地址配错了——嗯,从那以后我再也不敢小看这两样东西了。
说白了,启动代码和链接脚本,就是芯片从复位到进入main()函数之前,那段「没人管」的真空地带。你写不好,CPU连C语言环境都没有,谈什么应用逻辑?
3.1 芯片启动流程:从复位向量到main()
先捋一遍典型ARM Cortex-M系列芯片的启动流程。我以STM32为例,其他架构大同小异。
- 上电复位:CPU从0x00000000地址读取栈顶指针(MSP),从0x00000004地址读取复位向量,跳转到复位处理函数。
- 系统初始化:关闭中断、配置时钟、初始化关键外设(如看门狗、PLL)。
- 数据段搬运:将.data段从Flash拷贝到RAM,将.bss段清零。
- 调用构造函数:对于C++工程,遍历.init_array段,调用全局对象的构造函数。
- 跳转main():准备好C运行时环境后,调用main()函数。
关键点:启动代码必须在链接脚本的配合下才能正确工作。链接脚本告诉启动代码「数据段在哪、BSS段多大、栈顶在哪」,启动代码则按照这些信息去执行初始化。
我见过不少新手,直接在启动代码里写死地址,结果换了Flash大小或者RAM布局,板子就挂了。记住:启动代码要读链接脚本的符号,不要写死地址。
3.2 汇编启动代码编写:手写Reset_Handler
虽然现在很多厂商提供现成的启动文件,但作为BSP开发者,你得能自己写。我习惯用汇编写启动代码,因为这时候C环境还没准备好,你没法调用任何C函数。
下面是一个精简的Cortex-M启动代码示例:
.syntax unified
.cpu cortex-m4
.fpu softvfp
.thumb
.global g_pfnVectors
.global Default_Handler
/* 栈顶地址,由链接脚本提供 */
.word _sstack
.word Reset_Handler
.word NMI_Handler
.word HardFault_Handler
/* ... 其他中断向量 ... */
.section .text.Reset_Handler
.weak Reset_Handler
.type Reset_Handler, %function
Reset_Handler:
/* 1. 关闭全局中断 */
cpsid i
/* 2. 搬运 .data 段:从 Flash 到 RAM */
ldr r0, =_sidata
ldr r1, =_sdata
ldr r2, =_edata
subs r2, r1
ble .L_loop_bss
.L_loop_data:
subs r2, #4
ldr r3, [r0, r2]
str r3, [r1, r2]
bgt .L_loop_data
/* 3. 清零 .bss 段 */
.L_loop_bss:
ldr r1, =_sbss
ldr r2, =_ebss
subs r2, r1
ble .L_call_main
movs r3, #0
.L_loop_bss_clear:
subs r2, #4
str r3, [r1, r2]
bgt .L_loop_bss_clear
/* 4. 使能全局中断,跳转 main */
.L_call_main:
cpsie i
bl main
/* main 返回后,进入死循环 */
.L_infinite:
b .L_infinite
我的习惯:在Reset_Handler开头加一段「栈指针检查」代码。曾经有一块板子,因为PCB布线问题导致栈顶地址读回来是0xFFFFFFFF,CPU直接跑飞。加个检查,至少能进HardFault,方便调试。
3.3 链接脚本(.lds)配置:内存布局的「宪法」
链接脚本,说白了就是告诉链接器:你的代码放哪、数据放哪、栈放哪。我把它比作「内存布局的宪法」——所有段都必须遵守它的规定。
一个典型的STM32链接脚本结构如下:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
/* 中断向量表:必须放在Flash起始地址 */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
/* 代码段 */
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.glue_7)
*(.glue_7t)
*(.rodata)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
/* 初始化数据段(在Flash中的镜像) */
_sidata = .;
/* 数据段:运行时在RAM,初始值从Flash拷贝 */
.data : AT(_sidata)
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
/* BSS段:运行时在RAM,初始值为0 */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
/* 栈段 */
.stack :
{
. = ALIGN(8);
_sstack = .;
. = . + 1024; /* 1KB栈空间 */
. = ALIGN(8);
_estack = .;
} > RAM
}
我曾经踩过的坑:.data段一定要用AT(_sidata)指定加载地址(LMA)和运行地址(VMA)。如果不写AT,链接器默认VMA等于LMA,启动代码就不知道该从哪拷贝数据。结果就是RAM里的全局变量全是垃圾值,程序跑起来各种诡异。
3.4 启动流程与链接脚本的协作关系
为了让你更直观地理解,我画了一张图,展示从芯片上电到main()的完整流程,以及链接脚本在其中扮演的角色。
3.5 常见问题与避坑指南
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 上电后程序跑飞,进入HardFault | 栈指针未正确初始化,或向量表地址不对 | 检查链接脚本中.isr_vector段是否放在起始地址,确认_sstack符号正确 |
| 全局变量初始值不对 | .data段搬运失败,或搬运地址错误 | 检查启动代码中_sidata、_sdata、_edata的读取逻辑,确认AT()指定正确 |
| BSS段变量不为0 | .bss段未清零,或清零范围不对 | 确认_sbss和_ebss符号正确,检查清零循环的边界条件 |
| 链接时报错「region RAM overflowed」 | RAM空间不足,或段布局不合理 | 调整链接脚本中段的顺序,或优化代码减少RAM占用 |
我曾经踩过的坑:有一次我移植一个RTOS到新平台,启动代码一直报HardFault。查了两天,最后发现是链接脚本里RAM的ORIGIN写错了——把0x20000000写成了0x20000001。地址没对齐,CPU直接罢工。从那以后,我每次改链接脚本都会用readelf -S检查各段的实际地址。
3.6 多核/多处理器系统的启动差异
如果你做的是多核芯片(比如Cortex-A系列+ Cortex-M系列),启动流程会更复杂。我简单提几个要点:
- 主核先启动:通常主核(如A核)先运行,负责初始化DDR、加载固件到从核。
- 从核等待唤醒:从核(如M核)启动后,通常先进入WFI(Wait For Interrupt)状态,等待主核通过核间通信唤醒。
- 每个核有自己的链接脚本:每个核的代码段、数据段、栈段必须独立配置,不能重叠。
我做过一个项目,主核跑Linux,从核跑裸机程序。从核的启动代码里,我特意加了一段「自检逻辑」——先检查主核是否已经把DDR初始化好,再决定是否继续。否则从核一上来就访问DDR,数据全是乱的。
3.7 小结
启动代码和链接脚本,是BSP开发中最基础也最容易出错的部分。我的建议是:
- 不要直接复制粘贴厂商的启动文件,自己手写一遍,理解每一行的作用。
- 链接脚本里的符号命名要规范,方便启动代码引用。
- 每次修改链接脚本后,用
objdump -h或readelf -S检查各段的实际地址。 - 在启动代码中加一些「自检」逻辑,比如检查栈指针是否合法、检查.data段搬运是否完成。
记住:启动代码是芯片的「第一口气」,这口气顺了,后面的C代码才能跑得稳。