4. 汇编级启动代码:Reset_Handler实现、堆栈指针初始化、BSS段清零、数据段重定位
好,咱们进入正题。上一章我们把链接脚本搞定了,知道了芯片上电后第一条指令该去哪取。那这一章,我们就来写这条指令——也就是 Reset_Handler。
说白了,Reset_Handler 就是整个固件的“开国皇帝”。它干的事不多,但每件都关乎生死:把堆栈指针指对地方、把全局变量搬回家、把未初始化的变量清零。这三件事没做好,你的 C 语言代码连个 int i = 0; 都跑不对。
我个人习惯,写启动代码从来不用 C 编译器生成的 startup 文件——太臃肿,而且很多芯片厂商给的模板里藏着坑。我宁愿手写汇编,每一条指令都心里有数。
核心三件事:
- 初始化堆栈指针(SP)——让 C 语言能调用函数、分配局部变量
- 数据段重定位(.data)——把 ROM 里的初始值拷贝到 RAM
- BSS 段清零(.bss)——把未初始化的全局变量全部置 0
4.1 堆栈指针初始化——先站稳脚跟
芯片上电后,SP 的值是未知的。有些内核会从向量表第一个字自动加载 SP,比如 Cortex-M 系列。但如果你用的是 Cortex-A、RISC-V 或者其他内核,就得手动设。
我遇到过最惨的一次:某款国产 MCU,手册上说“上电后 SP 自动初始化”,结果实际芯片有 bug,SP 指向了 0x00000000。我 debug 了整整两天,才发现是硬件问题。从那以后,不管芯片支不支持自动初始化,我都会在 Reset_Handler 第一行手动设一次 SP。
; Reset_Handler 入口
.global Reset_Handler
.section .text.Reset_Handler, "ax"
Reset_Handler:
; 1. 初始化堆栈指针
ldr sp, =_stack_top ; _stack_top 在链接脚本中定义
小技巧: 如果你的芯片支持双堆栈(MSP/PSP),建议在启动阶段先使用主堆栈(MSP),等 RTOS 启动后再切到进程堆栈。这样能避免早期中断嵌套时栈溢出。
4.2 数据段重定位——把“家当”搬进 RAM
全局变量如果赋了初值,比如 int count = 100;,这个“100”在编译后是放在 Flash 里的。但程序运行时,你得在 RAM 里有一份可读写的副本。所以启动代码必须把 Flash 里的初始值,拷贝到 RAM 里对应的位置。
链接脚本里会导出三个符号:
| 符号 | 含义 |
|---|---|
_sdata |
RAM 中 .data 段的起始地址 |
_edata |
RAM 中 .data 段的结束地址 |
_la_data |
Flash 中 .data 初始值的存放地址(load address) |
拷贝的逻辑很简单:从 _la_data 读,写到 _sdata,直到写满 _edata。
; 2. 数据段重定位
ldr r0, =_sdata ; RAM 目标起始
ldr r1, =_edata ; RAM 目标结束
ldr r2, =_la_data ; Flash 源起始
subs r3, r1, r0 ; 计算长度
beq .data_done ; 长度为 0 则跳过
bl memcpy ; 调用 memcpy(后面我们自己写)
.data_done:
注意: 有些链接脚本会把 _la_data 放在 _sdata 之后,如果你直接按地址顺序拷贝,可能会覆盖还没拷贝的数据。一定要确认 _la_data 指向的是 Flash 区域,不是 RAM。
4.3 BSS 段清零——给变量一个干净的起点
C 语言标准规定,未初始化的全局变量默认值为 0。这个“清零”操作,就是启动代码里 BSS 段清零干的事。
BSS 段在 RAM 里,链接脚本导出 _sbss 和 _ebss。我们只需要从 _sbss 开始,一直写到 _ebss,每个字节写 0。
; 3. BSS 段清零
ldr r0, =_sbss
ldr r1, =_ebss
subs r2, r1, r0
beq .bss_done
mov r3, #0
bl memset ; 调用 memset
.bss_done:
嗯,这里要注意:memset 和 memcpy 的实现。在启动代码阶段,你不能直接调用 C 库里的函数,因为库函数可能依赖全局变量,而全局变量还没初始化好。所以我一般会在启动汇编文件里直接写两个精简版的 memset 和 memcpy。
; 精简版 memcpy
memcpy:
subs r3, r1, r0
beq .memcpy_end
.memcpy_loop:
ldrb r4, [r2], #1
strb r4, [r0], #1
subs r3, r3, #1
bne .memcpy_loop
.memcpy_end:
bx lr
; 精简版 memset
memset:
subs r2, r1, r0
beq .memset_end
.memset_loop:
strb r3, [r0], #1
subs r2, r2, #1
bne .memset_loop
.memset_end:
bx lr
性能提示: 如果芯片支持字访问(32位),建议用 str 一次写 4 字节,速度能快 4 倍。但要注意地址对齐,否则某些内核会触发异常。
4.4 完整的 Reset_Handler 模板
把上面三段拼起来,再加上跳转到 main,就是一个完整的 Reset_Handler。我个人习惯在跳转 main 之前,还会做两件事:关闭全局中断(防止中断在初始化过程中乱入),以及初始化系统时钟(如果芯片需要)。
.global Reset_Handler
.section .text.Reset_Handler, "ax"
Reset_Handler:
; 1. 初始化堆栈指针
ldr sp, =_stack_top
; 2. 数据段重定位
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_la_data
subs r3, r1, r0
beq .data_done
bl memcpy
.data_done:
; 3. BSS 段清零
ldr r0, =_sbss
ldr r1, =_ebss
subs r2, r1, r0
beq .bss_done
mov r3, #0
bl memset
.bss_done:
; 4. 关闭全局中断(可选)
cpsid i
; 5. 初始化系统时钟(可选)
bl SystemInit
; 6. 跳转到 main
bl main
; 如果 main 返回,死循环
b .
避坑指南: 我曾经在某个项目里,把 bl main 写成了 b main,结果 main 函数返回后,程序直接跑飞。因为 b 不会保存返回地址,main 里的 bx lr 就跳到了未知地址。所以一定要用 bl,除非你确定 main 永远不会返回。
4.5 向量表与 Reset_Handler 的关联
最后提一句向量表。芯片上电后,硬件会从向量表的第二个字(偏移 4 字节)取出 Reset_Handler 的地址,然后跳转过去。所以你的向量表必须把 Reset_Handler 的地址放在正确的位置。
.section .isr_vector, "a"
.global _isr_vector
_isr_vector:
.word _stack_top ; 栈顶地址
.word Reset_Handler ; 复位入口
.word NMI_Handler ; NMI
.word HardFault_Handler ; 硬错误
; ... 其他中断向量
你看,_stack_top 放在第一个字,Reset_Handler 放在第二个字。这样硬件上电后,自动把 _stack_top 加载到 SP,然后跳转到 Reset_Handler。如果你的芯片不支持自动加载 SP,那就在 Reset_Handler 里手动设,就像我们 4.1 节做的那样。