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,编译报错还一脸懵。
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压栈保存。
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 .
这段代码我用了不下几十次。每次移植到新板子,改改栈地址和链接脚本就能用。
CMP R0, R1的判断,结果BSS段长度为0时循环条件直接反了,跑飞了。后来我养成了习惯:任何循环都要检查边界条件。
本章知识体系
下面这张图把ARM汇编的核心脉络梳理了一遍,你可以对照着复习:
这张图把ARM指令集、Thumb/Thumb-2、指令分类、典型指令和混合编程串在了一起。你顺着箭头走一遍,基本就能建立起知识框架。