4. 汇编级启动代码:Reset_Handler实现、堆栈指针初始化、BSS段清零、数据段重定位

好,咱们进入正题。上一章我们把链接脚本搞定了,知道了芯片上电后第一条指令该去哪取。那这一章,我们就来写这条指令——也就是 Reset_Handler。

说白了,Reset_Handler 就是整个固件的“开国皇帝”。它干的事不多,但每件都关乎生死:把堆栈指针指对地方、把全局变量搬回家、把未初始化的变量清零。这三件事没做好,你的 C 语言代码连个 int i = 0; 都跑不对。

我个人习惯,写启动代码从来不用 C 编译器生成的 startup 文件——太臃肿,而且很多芯片厂商给的模板里藏着坑。我宁愿手写汇编,每一条指令都心里有数。

核心三件事:

  • 初始化堆栈指针(SP)——让 C 语言能调用函数、分配局部变量
  • 数据段重定位(.data)——把 ROM 里的初始值拷贝到 RAM
  • BSS 段清零(.bss)——把未初始化的全局变量全部置 0
Reset_Handler 启动流程 Reset_Handler 启动流程 上电复位 / 跳转到 Reset_Handler 初始化堆栈指针 SP 数据段重定位 (.data) BSS 段清零 跳转到 main() 💡 说明 SP 必须在调用任何 C 函数 之前设置好,否则函数调用 会直接跑飞。

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 节做的那样。