4. 汇编基础与启动代码:ARM Cortex-M汇编基础、启动文件(startup.s)解析、堆栈初始化

好,咱们进入第四章。这一章是Bootloader开发的基石——汇编与启动代码。说实话,很多做嵌入式应用层开发的工程师,一看到汇编就头疼。但我跟你说,搞Bootloader,汇编这块绕不过去。你想想看,芯片上电时C环境还没建立,你总不能用printf去初始化堆栈吧?

我个人习惯把启动代码比作「芯片的产房」——CPU刚出生,啥都不会,得有人帮它把基本环境搭好。今天我们就来亲手搭建这个产房。

4.1 ARM Cortex-M汇编基础

先聊几句汇编。Cortex-M系列用的是Thumb-2指令集,它兼容16位和32位指令。我刚开始从ARM7转到Cortex-M时,最不习惯的就是指令长度不固定。但用久了你会发现,这其实是优点——代码密度高了不少。

4.1.1 常用指令速览

Bootloader里常用的汇编指令其实不多,我列个表给你看:

指令 功能 我常用的场景
LDR 从内存加载数据到寄存器 读取向量表地址、外设寄存器
STR 将寄存器数据存储到内存 设置系统控制寄存器
MOV 寄存器间数据传送 初始化栈指针、传递参数
BL 带链接的跳转(调用函数) 跳转到main()或Bootloader主逻辑
BX 分支并切换指令集 跳转到应用程序入口
CPS 修改处理器状态 开关全局中断
小技巧: 在Cortex-M上,BX指令的LSB必须为1,表示Thumb状态。如果LSB是0,CPU会触发异常。我曾经在跳转APP时忘了加1,结果芯片直接HardFault,查了半天才发现是地址对齐问题。

4.1.2 寄存器模型

Cortex-M有16个通用寄存器(R0-R15),其中几个有特殊用途:

  • R13(SP):栈指针,分MSP和PSP
  • R14(LR):链接寄存器,保存返回地址
  • R15(PC):程序计数器

嗯,这里要注意:Cortex-M的R13其实有两个物理寄存器——主栈指针(MSP)和进程栈指针(PSP)。复位后默认用MSP,这也是我们Bootloader里用的那个。

4.2 启动文件(startup.s)解析

启动文件,说白了就是芯片上电后执行的第一段代码。每个厂商的startup.s长得不太一样,但核心逻辑是一样的。我拆开给你看:

4.2.1 向量表定义

Cortex-M复位后,硬件会自动从地址0x00000000加载两个关键值:

  • 地址0x00000000 → 初始栈指针(MSP)
  • 地址0x00000004 → 复位向量(Reset_Handler)

这就是向量表的前两个条目。完整的向量表长这样:

; startup.s 片段
__Vectors       DCD     __initial_sp        ; 栈顶地址
                DCD     Reset_Handler       ; 复位中断
                DCD     NMI_Handler         ; NMI
                DCD     HardFault_Handler   ; 硬错误
                DCD     MemManage_Handler   ; 内存管理
                DCD     BusFault_Handler    ; 总线错误
                DCD     UsageFault_Handler  ; 用法错误
                ; ... 后面还有几十个中断向量
关键点: 向量表必须放在0地址(或者通过SCB->VTOR重映射后的基地址)。如果向量表位置不对,中断来了CPU找不到入口,直接HardFault。我在一个量产项目上遇到过这个问题——Bootloader升级后忘了更新VTOR,结果所有中断都跑飞了。

4.2.2 Reset_Handler:真正的入口

复位后,CPU跳转到Reset_Handler。这里要做三件大事:

  1. 初始化堆栈:设置MSP和堆栈边界
  2. 拷贝数据段:把.data从Flash搬到RAM
  3. 清零BSS段:把.bss段全部清0

代码实现大致如下:

Reset_Handler PROC
    EXPORT  Reset_Handler

    ; 1. 设置栈指针(其实硬件已经做了,但再设一次更保险)
    LDR     R0, =__initial_sp
    MSR     MSP, R0

    ; 2. 拷贝.data段
    LDR     R0, =__data_start
    LDR     R1, =__data_end
    LDR     R2, =__data_flash_start
    BL      memcpy

    ; 3. 清零.bss段
    LDR     R0, =__bss_start
    LDR     R1, =__bss_end
    MOV     R2, #0
    BL      memset

    ; 4. 跳转到C的main函数
    BL      main
    ENDP

你可能会问:为什么.data要拷贝?因为Flash不能写,而.data里的全局变量是可读写的,必须搬到RAM里。.bss段存的是未初始化的全局变量,C标准要求它们初始为0,所以得清零。

避坑指南: 我曾经在一个低功耗项目里,为了省电把Flash读时序调慢了。结果.data拷贝时因为Flash读取速度跟不上,数据搬错了,全局变量全乱套。排查了两天才发现是Flash等待周期没配对。所以,启动代码里如果涉及Flash读取,一定要确认时序配置。

4.3 堆栈初始化

堆栈初始化,说白了就是告诉CPU:「你的临时数据往哪放」。Cortex-M的堆栈是向下生长的——压栈时SP减小,出栈时SP增大。

4.3.1 堆栈布局

典型的堆栈布局如下:

高地址
+------------------+  ← __initial_sp(栈顶)
|   栈空间          |
|   (向下生长)      |
+------------------+  ← 当前SP
|   堆空间          |
|   (向上生长)      |
+------------------+  ← __heap_start
|   .bss段          |
+------------------+
|   .data段         |
+------------------+
低地址

堆和栈之间是自由区域。如果堆栈溢出,它们就会互相踩踏——这就是著名的「堆栈碰撞」。我见过一个案例,堆分配太多,把栈空间挤没了,函数调用时SP直接越界,数据写到了堆里,然后堆里的malloc指针被覆盖,整个系统像多米诺骨牌一样崩溃。

4.3.2 堆栈大小怎么定?

这个问题没有标准答案,但我给个参考:

场景 建议栈大小 建议堆大小
简单Bootloader(无RTOS) 512字节 - 1KB 0(不用malloc)
带文件系统的Bootloader 1KB - 2KB 512字节 - 1KB
带网络协议的Bootloader 2KB - 4KB 1KB - 2KB
我的经验: 在Bootloader里尽量别用动态内存分配(malloc)。原因很简单——Bootloader运行时间短,用完就跳APP了,动态分配容易产生碎片,而且万一分配失败,你连错误处理都很难写。我一般只用栈,配合全局数组做缓冲区。

4.4 本章知识体系

下面这张图把本章的核心逻辑串起来了:

启动代码核心流程 启动代码核心流程 芯片上电复位 硬件加载向量表 跳转 Reset_Handler 三阶段初始化 ① 设置栈指针 (MSP) ② 拷贝 .data 段 ③ 清零 .bss 段 跳转 main() 注意 向量表必须在0地址 或VTOR指向的地址 否则中断无法响应 堆栈碰撞风险 堆向下生长 栈向上生长 中间区域是雷区

这张图把从复位到main()的完整路径画清楚了。你写启动代码时,脑子里要有这个流程——每一步都环环相扣,少一个环节系统就起不来。

4.5 实战:手写一个最小启动文件

光说不练假把式。我给你一个最小可用的startup.s模板,你可以在自己的开发板上试试:

; 最小启动文件 startup.s
; 适用于STM32F103系列(Cortex-M3)

    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
          ; ... 其他向量按需添加
__Vectors_End

__Vectors_Size EQU  __Vectors_End - __Vectors

    AREA    |.text|, CODE, READONLY

; 复位处理函数
Reset_Handler PROC
    EXPORT  Reset_Handler
    ; 设置栈指针
    LDR     R0, =__initial_sp
    MSR     MSP, R0
    
    ; 拷贝.data
    LDR     R0, =__data_start
    LDR     R1, =__data_end
    LDR     R2, =__data_flash_start
    BL      memcpy
    
    ; 清零.bss
    LDR     R0, =__bss_start
    LDR     R1, =__bss_end
    MOV     R2, #0
    BL      memset
    
    ; 跳转main
    BL      main
    B       .
    ENDP

; 简单的memcpy实现
memcpy PROC
    CMP     R1, R0
    BXEQ    LR
loop
    LDRB    R3, [R2], #1
    STRB    R3, [R0], #1
    CMP     R0, R1
    BNE     loop
    BX      LR
    ENDP

; 简单的memset实现
memset PROC
    CMP     R1, R0
    BXEQ    LR
loop2
    STRB    R2, [R0], #1
    CMP     R0, R1
    BNE     loop2
    BX      LR
    ENDP

; 弱定义的中断处理(防止链接报错)
NMI_Handler     PROC
    B       .
    ENDP

HardFault_Handler PROC
    B       .
    ENDP

    END
关于弱定义: 上面代码里的NMI_Handler和HardFault_Handler都是死循环。实际项目中,你应该在C文件里重新定义这些函数,链接器会用强符号覆盖弱符号。这样既保证了编译通过,又保留了灵活性。

好了,这一章的内容就到这。汇编基础、启动文件结构、堆栈初始化——这三块是Bootloader的根基。你把这章吃透了,后面讲跳转APP、中断重定向的时候,你会觉得特别顺。记住一句话:启动代码写不好,后面所有代码都是空中楼阁。