4. 汇编启动代码:从复位向量到 main 函数

说实话,很多做嵌入式开发的朋友,写了几年 C 语言,却从来没认真看过启动代码。我刚开始带项目时也是这样,直到有一次芯片上电就跑飞,查了三天才发现是堆栈指针没初始化。嗯,从那以后,我养成了一个习惯——每次移植 Bootloader,第一件事就是手写汇编启动代码。

这一章,我们就来聊聊汇编级启动代码的核心四件事:复位向量、堆栈指针初始化、BSS 段清零、跳转到 main。说白了,就是芯片上电后,在 C 语言环境建立之前,CPU 必须完成的那些脏活累活。

4.1 复位向量:芯片上电的第一行代码

芯片上电后,CPU 会去一个固定的地址取第一条指令。这个地址,就是复位向量。以 ARM Cortex-M 系列为例,复位向量位于 0x00000004(向量表偏移 4 字节处)。

我个人习惯把向量表放在汇编文件的最前面,像这样:

; 向量表起始地址
.section .isr_vector, "a"
.global g_pfnVectors
.align 4

g_pfnVectors:
    .word _estack              ; 栈顶地址
    .word Reset_Handler        ; 复位向量
    .word NMI_Handler          ; NMI 中断
    .word HardFault_Handler    ; 硬错误中断
    ; ... 其他中断向量

这里要注意几点:

  • _estack 是栈顶地址,必须在链接脚本中定义
  • Reset_Handler 就是复位后执行的第一个函数
  • 向量表必须 4 字节对齐,否则 CPU 会跑飞
我曾经踩过的坑: 有一次我忘了在链接脚本里定义 _estack,结果编译器默认给了 0。芯片上电后,堆栈指针指向了 0 地址,一压栈就触发 HardFault。查了两天才发现是链接脚本的问题。所以,一定要确认 _estack 指向有效的 RAM 区域。

4.2 堆栈指针初始化:给 C 语言一个安身之所

堆栈指针(SP)是 C 语言运行的基础。局部变量、函数调用、中断现场保存,全都依赖它。你想想看,如果 SP 指向了未初始化的 RAM 区域,或者更糟——指向了 ROM,那程序一跑就崩。

在 Cortex-M 上,复位后 CPU 会自动从向量表的第一项加载 SP。但如果你用的是 Cortex-A 或 RISC-V,就得手动初始化:

; 手动初始化堆栈指针(以 ARM Cortex-A 为例)
.section .text.startup
.global _start

_start:
    ; 设置栈指针
    ldr sp, =_estack

    ; 或者分模式设置
    mrs r0, cpsr
    bic r0, r0, #0x1F          ; 清除模式位
    orr r0, r0, #0x12          ; IRQ 模式
    msr cpsr, r0
    ldr sp, =_irq_stack_top    ; IRQ 模式栈

    orr r0, r0, #0x11          ; FIQ 模式
    msr cpsr, r0
    ldr sp, =_fiq_stack_top    ; FIQ 模式栈

    ; 最后切回 SVC 模式
    orr r0, r0, #0x13
    msr cpsr, r0
    ldr sp, =_svc_stack_top    ; SVC 模式栈
我的建议: 多模式处理器(如 Cortex-A)一定要为每种模式分配独立的栈空间。我在一个项目中只给 IRQ 模式分配了 256 字节的栈,结果中断嵌套一深就溢出。后来我改成 1KB,问题就解决了。栈大小宁多勿少。

4.3 BSS 段清零:让全局变量从零开始

C 语言中未初始化的全局变量和静态变量,默认值应该是 0。但芯片上电后,RAM 里的数据是随机的。所以,启动代码必须把 BSS 段全部清零。

BSS 段的起始地址和结束地址,通常在链接脚本中定义:

; BSS 段清零
.section .text.startup
.global _start

_start:
    ; ... 前面是 SP 初始化 ...

    ; 清零 BSS 段
    ldr r0, =_sbss             ; BSS 起始地址
    ldr r1, =_ebss             ; BSS 结束地址
    mov r2, #0

bss_loop:
    cmp r0, r1
    bhs bss_done               ; 如果 r0 >= r1,跳转
    str r2, [r0], #4           ; 写入 0,地址加 4
    b bss_loop

bss_done:
    ; ... 后续跳转到 main ...

这里有个细节:为什么用 str 而不是 strb? 因为 32 位处理器一次写 4 字节效率更高。如果 BSS 段很大(比如几百 KB),用 strb 会慢 4 倍。

关键点: BSS 段清零必须在 任何 C 语言代码执行之前 完成。否则,你在 C 代码里访问一个未初始化的全局变量,拿到的可能是随机值。

4.4 数据段拷贝:把初始化数据搬到 RAM

除了 BSS 段,还有 数据段(.data)。它存放的是已初始化的全局变量和静态变量。这些变量的初始值存储在 Flash 中,启动时需要拷贝到 RAM 里。

; 数据段拷贝
    ldr r0, =_sdata            ; RAM 中的数据段起始地址
    ldr r1, =_edata            ; RAM 中的数据段结束地址
    ldr r2, =_sidata           ; Flash 中的初始值起始地址

data_loop:
    cmp r0, r1
    bhs data_done
    ldr r3, [r2], #4           ; 从 Flash 读取
    str r3, [r0], #4           ; 写入 RAM
    b data_loop

data_done:
    ; ... 跳转到 main ...

你可能会问:为什么不直接把 .data 段放在 RAM 里? 因为 RAM 掉电会丢失数据。初始值必须保存在非易失存储器(Flash)中,上电后再搬过去。

段名 存储位置 运行位置 启动操作
.text Flash Flash 无需操作
.rodata Flash Flash 无需操作
.data Flash RAM 从 Flash 拷贝到 RAM
.bss 无 RAM 清零

4.5 跳转到 main:最后的交接

做完上面所有准备工作,终于可以跳转到 C 语言的 main 函数了。但这里有个坑:直接跳转还是用 BL 指令?

我个人习惯用 BL(带链接的跳转),这样 main 函数返回后还能执行一些清理工作。不过,在嵌入式系统中,main 函数通常不会返回。所以,很多启动代码直接用 B(无条件跳转)来节省一点栈空间。

; 跳转到 main 函数
    bl main                     ; 带链接跳转
    ; 如果 main 返回,进入死循环
loop_forever:
    b loop_forever

这里还有一个容易被忽略的点:main 函数的参数。在标准 C 中,main 函数可以带 argc 和 argv。但在嵌入式系统中,我们通常用 void main(void)。如果你非要支持参数,可以在跳转前设置 r0 和 r1:

    mov r0, #0                  ; argc = 0
    mov r1, #0                  ; argv = NULL
    bl main
注意: 有些编译器(如 GCC)要求 main 函数的签名是 int main(void)。如果你用了 void main,编译器可能会报警告。我建议统一用 int main(void),并在最后 return 0。

4.6 完整的启动流程

把上面所有步骤串起来,就是一个完整的汇编启动流程。我画了一张图,帮你理清思路:

汇编启动代码完整流程 汇编启动代码完整流程 复位向量 初始化堆栈指针 拷贝 .data 段 清零 .bss 段 跳转到 main 函数 死循环(备份) 注意 .data 拷贝和 .bss 清零 顺序可以互换 但必须在跳转 main 之前完成

4.7 常见问题与避坑指南

写启动代码这么多年,我总结了一些常见问题,分享给你:

  • 栈溢出检测: 可以在栈底填充一个特殊值(如 0xDEADBEEF),然后在 main 函数中定期检查。如果这个值被覆盖了,说明栈溢出了。
  • 中断优先级: 在跳转到 main 之前,最好把所有中断都关掉。等 C 语言环境建立好之后,再逐个使能。否则,中断可能在半路触发,导致不可预知的行为。
  • 看门狗: 有些芯片上电后看门狗默认是开启的。如果启动代码执行时间太长,看门狗会复位芯片。我建议在启动代码最前面就把看门狗关掉。
  • 链接脚本: 启动代码和链接脚本是强耦合的。_sdata、_ebss 这些符号必须在链接脚本中正确定义。我见过太多人改了启动代码却忘了改链接脚本,结果程序跑飞。
我的一个小技巧: 在启动代码中加一个 LED 闪烁的调试代码。比如,在 SP 初始化完成后点亮 LED,在 BSS 清零完成后熄灭 LED。这样,你可以直观地看到启动代码执行到了哪一步。我在调试一个 RISC-V 芯片时,就是用这个方法找到了 BSS 清零代码的 bug。

好了,这一章的内容就到这里。启动代码虽然看起来简单,但它是整个系统的基石。写好了,后面的 C 语言代码就能安心运行;写不好,整个项目都会跟着遭殃。希望你能动手写一遍,哪怕是在模拟器上跑一跑,也比只看不练强得多。