第2章 启动流程与汇编基础:ARM复位流程、中断向量表、汇编指令基础(LDR/STR/MOV/BL)、C与汇编混合编程

好,咱们直接进入正题。上一章我们把开发环境搭好了,板子也点亮了。但很多同学会问:“上电之后,CPU到底干了什么?”

这个问题,说白了就是启动流程。不懂启动流程,你写的代码就是“玄学”——跑起来了不知道为什么,跑不起来更不知道为什么。我当年刚入行时,就吃过这个亏。

2.1 ARM复位流程:从Reset到main()

ARM Cortex-M系列(比如STM32)的复位流程,其实比你想的简单。它不像Linux那样要加载内核、挂载文件系统。裸机嘛,就三步:

  1. CPU从0x00000000取第一条指令(其实是取向量表中的Reset_Handler地址)
  2. 执行Reset_Handler:初始化堆栈、清零BSS段、拷贝数据段
  3. 跳转到main()

嗯,这里要注意:0x00000000存放的不是代码,而是栈顶指针。这是ARM设计的一个巧妙之处——上电后自动从0x00000000加载MSP(主栈指针),然后从0x00000004加载PC(程序计数器)。

核心记忆点:

  • 0x00000000 → 初始栈顶地址(MSP)
  • 0x00000004 → 复位中断向量(Reset_Handler)
  • 0x00000008 → NMI中断向量
  • ... 依次排列

我曾经在项目中遇到过一个问题:板子上电后死活不跑,调试器也连不上。查了半天,发现是Bootloader把向量表偏移设错了,CPU取到的Reset_Handler地址是0xFFFFFFFF。嗯,从那以后我每次写启动文件,都会先检查向量表首地址。

2.2 中断向量表:CPU的“紧急通讯录”

中断向量表是什么?我习惯把它比作“CPU的紧急通讯录”。每个中断源都有一个编号,CPU收到中断后,直接查表找到对应的处理函数地址,跳过去执行。

对于Cortex-M3/M4,向量表是这样的:

偏移地址 异常类型 优先级 描述
0x00 栈顶地址 - 初始MSP值
0x04 Reset -3(最高) 复位处理函数
0x08 NMI -2 不可屏蔽中断
0x0C HardFault -1 硬件错误
0x10 MemManage 可编程 内存管理错误
0x14 BusFault 可编程 总线错误
0x18 UsageFault 可编程 用法错误
... ... ... ...
0x40+ 外部中断 可编程 外设中断(GPIO、TIM、UART等)

避坑指南:我曾经在移植FreeRTOS时,忘记把向量表重定位到SRAM,结果一开中断就HardFault。记住:如果代码在0x08000000运行,必须设置SCB->VTOR = 0x08000000,否则CPU还是去0x00000000查表。

2.3 汇编指令基础:LDR/STR/MOV/BL

裸机开发离不开汇编。你不需要成为汇编大师,但看懂启动文件、会写关键操作是基本功。我挑四个最常用的指令讲:

2.3.1 MOV:数据搬运

MOV R0, #0x12      ; R0 = 0x12
MOV R1, R0         ; R1 = R0
MOV R2, #0xFF      ; R2 = 0xFF

限制:立即数只能使用8位循环移位后的值。比如0x12345678就不能直接用MOV,得用LDR伪指令。

2.3.2 LDR:从内存加载到寄存器

LDR R0, =0x40020000   ; 伪指令,加载32位立即数到R0
LDR R1, [R0]          ; R1 = *(uint32_t*)R0
LDR R2, [R0, #4]      ; R2 = *(uint32_t*)(R0+4)
LDR R3, [R0, R1]      ; R3 = *(uint32_t*)(R0+R1)

嗯,这里有个坑:LDR R0, =0x40020000是伪指令,汇编器会把它展开成LDR R0, [PC, #offset],从常量池加载。而LDR R1, [R0]才是真正的内存访问。

2.3.3 STR:从寄存器存储到内存

STR R0, [R1]          ; *(uint32_t*)R1 = R0
STR R2, [R3, #0x10]   ; *(uint32_t*)(R3+0x10) = R2
STR R4, [R5, R6]      ; *(uint32_t*)(R5+R6) = R4

STR和LDR是对称的。你想想看,操作外设寄存器时,本质上就是LDR读、STR写。

2.3.4 BL:带链接的跳转

BL Delay            ; 跳转到Delay,同时保存返回地址到LR
BL MyFunction       ; 跳转到MyFunction

BL是函数调用的核心。它会把当前PC+4保存到LR(R14),然后跳转到目标地址。被调函数返回时,执行BX LR即可。

个人习惯:我写启动文件时,会用LDR R0, =main; BLX R0来跳转到main()。为什么不用B?因为B指令跳转范围有限(±32MB),而LDR+BLX可以跳到任意地址。

2.4 C与汇编混合编程

实际项目中,我们不会全用汇编写应用代码。通常是C写逻辑,汇编写启动和关键操作。混合编程有三种方式:

2.4.1 在C中内联汇编

__asm void SystemReset(void)
{
    MOV R0, #0x05
    MSR FAULTMASK, R0
    LDR R0, =0xE000ED0C
    LDR R1, =0x05FA0004
    STR R1, [R0]
    BX LR
}

或者用__asm关键字嵌入单条指令:

void EnableIRQ(void)
{
    __asm("CPSIE I");   // 开中断
}

2.4.2 汇编调用C函数

在启动文件里,我们经常这样跳转到main():

Reset_Handler PROC
    EXPORT Reset_Handler
    IMPORT main
    
    LDR R0, =main
    BLX R0
    B .
    ENDP

注意:调用C函数前,必须初始化堆栈指针SP,否则函数内的局部变量会写飞。

2.4.3 C调用汇编函数

在汇编文件中导出函数名,C文件声明extern即可:

; startup.s
    EXPORT CPU_GetCoreID
CPU_GetCoreID PROC
    MRS R0, CPUID
    BX LR
    ENDP
// main.c
extern uint32_t CPU_GetCoreID(void);

int main(void)
{
    uint32_t id = CPU_GetCoreID();
    // ...
}

我曾经踩过的坑:汇编函数返回时,一定要用BX LR而不是B LR。B LR不会切换指令集(ARM/Thumb),而Cortex-M只支持Thumb模式,用B LR会导致HardFault。嗯,这个bug我调了整整一个下午。

2.5 本章知识体系总览

下面这张图,把本章的核心知识点串起来了。建议你保存下来,写启动文件时对照着看:

ARM启动流程与汇编基础知识体系 上电复位 加载MSP(0x00000000) 加载PC(0x00000004) 执行Reset_Handler 跳转到main() 中断向量表 0x00: 栈顶指针 0x04: Reset_Handler 0x08: NMI_Handler 0x0C: HardFault_Handler 0x40+: 外设中断 汇编指令基础 MOV: 数据搬运 LDR: 内存→寄存器 STR: 寄存器→内存 BL: 带链接跳转 C与汇编混合编程 内联汇编 汇编调C C调汇编

学习建议:别急着背指令集。先打开STM32的启动文件(startup_stm32f4xx.s),对照本章内容逐行看一遍。看不懂的指令查ARM手册。我当年就是这么学的——读代码、改代码、烧代码,三遍下来就熟了。