2. ARM体系结构基础

各位同学,咱们今天聊ARM汇编。说实话,很多做驱动的兄弟一听“汇编”两个字就头大,觉得这是底层中的底层,离自己很远。但我在实际项目中吃过亏——有一次Bootloader死活起不来,最后发现是一条汇编指令的立即数范围搞错了。从那以后,我老老实实把ARM指令集翻了个底朝天。

ARM指令集其实没那么可怕。它设计得很规整,指令数量不多,格式统一。你只要掌握几个核心分类和常用指令,写启动代码、做底层调试完全够用。

ARM指令集分类

ARM指令集按功能可以分成这几大类:

分类 说明 典型指令
数据处理指令 寄存器之间的算术、逻辑运算 MOV, ADD, SUB, AND, ORR
数据传输指令 内存与寄存器之间的数据搬移 LDR, STR, LDM, STM
分支指令 改变程序执行流 B, BL, BX, BLX
协处理器指令 操作系统控制、Cache/TLB管理 MCR, MRC
异常产生指令 软中断、断点 SWI/SVC, BKPT

我个人习惯把前两类称为“干活指令”,后三类叫“控制指令”。干活指令占了日常代码的80%以上。

常用指令详解

MOV指令——最基础的搬砖

MOV R0, #0x12      ; 把立即数0x12赋给R0
MOV R1, R2         ; 把R2的值赋给R1
MOV R0, R1, LSL #2 ; R0 = R1 << 2,移位后再赋值

这里有个坑:ARM的立即数不是随便写的。它只能是一个8位值循环移位偶数位得到的结果。比如0xFF可以,0x123就不行。我在项目里见过新手直接写MOV R0, #0x123,编译报错还一脸懵。

注意:ARM立即数范围有限。如果遇到无法直接表示的数值,可以用伪指令LDR R0, =0x12345678,编译器会自动处理。

LDR/STR——内存访问的核心

LDR R0, [R1]        ; 从R1指向的地址读4字节到R0
LDR R0, [R1, #4]    ; 从R1+4地址读
LDR R0, [R1, R2]    ; 从R1+R2地址读
STR R0, [R1]        ; 把R0的值写入R1指向的地址

嗯,这里要特别注意:LDR/STR的寻址模式有好几种。前索引、后索引、自动回写,搞混了很容易把指针改乱。我调试过一个USB驱动,就是STR指令用了后索引回写,导致DMA描述符指针飞了,查了两天才找到。

B/BL——跳转的艺术

B   label      ; 无条件跳转,不保存返回地址
BL  func       ; 跳转并保存返回地址到LR
BX  LR         ; 从函数返回
BLX R0        ; 跳转到R0指向的地址,并切换指令集

BL指令是函数调用的关键。它会把下一条指令的地址存入LR寄存器。返回时用BX LR就行。但要注意:如果函数里又调了别的函数,LR会被覆盖。这时候就得先把LR压栈保存。

经验之谈:我写Bootloader时,习惯在函数入口处用STMFD SP!, {LR}保存LR,出口用LDMFD SP!, {PC}恢复。这样嵌套调用不会出问题。

Thumb/Thumb-2指令集

ARM指令每条固定32位,代码密度不高。早期嵌入式设备ROM小,ARM推出了16位的Thumb指令集。Thumb指令只有ARM指令功能的子集,但代码体积能缩小30%-40%。

后来ARM搞了Thumb-2,这是个混合体——16位和32位指令混着用。既保留了Thumb的高密度,又补上了功能短板。现在的Cortex-M系列只支持Thumb-2,不支持ARM指令。

特性 ARM指令 Thumb Thumb-2
指令长度 32位固定 16位固定 16/32位混合
代码密度 低 高 高
功能完整度 完整 受限 接近完整
适用处理器 Cortex-A/R ARM7/9/11 Cortex-M全系列

你想想看,为什么Cortex-M只用Thumb-2?因为M系列面向深度嵌入式,Flash通常只有几十到几百KB。用Thumb-2能省不少空间。而A系列跑Linux,内存动辄几百MB,代码密度反而不那么重要了。

汇编与C混合编程

实际项目中,纯汇编写应用层代码太痛苦了。我们通常的做法是:C写业务逻辑,汇编写启动代码和关键操作。混合编程有几种方式:

内联汇编

在C代码里直接嵌入汇编,适合短小的操作:

__asm volatile(
    "MRS R0, CPSR\n\t"
    "BIC R0, R0, #0x80\n\t"
    "MSR CPSR_c, R0\n\t"
    :
    :
    : "r0"
);

这段代码用来开IRQ中断。volatile告诉编译器别优化掉。最后的"r0"是告诉编译器R0被改写了,让它自己处理寄存器保存。

关键点:内联汇编的约束条件一定要写对。我见过有人忘了写寄存器破坏列表,结果优化后的C代码用了那个寄存器,数据全乱了。

汇编函数与C互相调用

汇编函数可以被C调用,反过来也行。关键是遵守ATPCS(ARM-Thumb过程调用标准):

  • 参数通过R0-R3传递,多余的压栈
  • 返回值放在R0
  • R4-R11是保留寄存器,被调用方必须恢复
  • R12是临时寄存器,不用保存
  • R13是SP,R14是LR,R15是PC
; 汇编函数:两个数相加
.global asm_add
asm_add:
    ADD R0, R0, R1
    BX  LR

// C代码调用
extern int asm_add(int a, int b);
int result = asm_add(3, 5);  // result = 8

就这么简单。R0传第一个参数,R1传第二个,结果从R0拿回来。

启动代码中的混合编程

Bootloader的启动流程通常是:汇编做最底层的初始化(关中断、设栈指针、初始化BSS段),然后跳转到C语言的main函数。我写的一个典型启动代码片段:

reset_handler:
    ; 关中断
    MRS R0, CPSR
    ORR R0, R0, #0xC0
    MSR CPSR_c, R0

    ; 设栈指针
    LDR SP, =stack_top

    ; 清零BSS段
    LDR R0, =__bss_start
    LDR R1, =__bss_end
    MOV R2, #0
bss_loop:
    CMP R0, R1
    BGE bss_done
    STR R2, [R0], #4
    B bss_loop
bss_done:

    ; 跳转到C的main
    BL main
    B .

这段代码我用了不下几十次。每次移植到新板子,改改栈地址和链接脚本就能用。

避坑指南:我曾经在初始化BSS段时忘了加CMP R0, R1的判断,结果BSS段长度为0时循环条件直接反了,跑飞了。后来我养成了习惯:任何循环都要检查边界条件。

本章知识体系

下面这张图把ARM汇编的核心脉络梳理了一遍,你可以对照着复习:

ARM汇编基础核心脉络 ARM指令集 ARM指令(32位) Thumb指令(16位) Thumb-2(混合) 数据处理指令 数据传输指令 分支指令 协处理器指令 MOV ADD SUB LDR STR B BL BX MCR MRC 汇编与C混合编程 内联汇编 汇编函数调用C C函数调用汇编 核心原则 启动代码用汇编,业务逻辑用C;遵守ATPCS调用规范;注意指令集切换

这张图把ARM指令集、Thumb/Thumb-2、指令分类、典型指令和混合编程串在了一起。你顺着箭头走一遍,基本就能建立起知识框架。