ARMv7-A 指令体系全解:数据处理、访存、分支与同步
I.MX6ULL 的核心是 ARM Cortex-A7,它基于 ARMv7-A 架构。
要真正理解指令集,就不能只是背诵汇编手册,而是要回答一个核心问题:如果不看代码,只看电路,CPU 在做什么?
本质上,每一条指令都是对 CPU 内部寄存器状态或外部总线(内存/外设)的一次原子操作。
总线(Bus)通俗理解就是主板上的 “高速公路”。它的硬件本质则是一组并排的导线,通过这组导线,连接 CPU 内部各部分或连接 CPU 和外设。
总线分为数据总线(传数据)、地址总线(告诉内存我要读哪一个单元)和控制总线(读还是写)。
原子操作(Atomic Operation)通俗理解就是,要么做完,要么完全不做,中间绝不允许被打断的操作。
在 ARM 中,单条读写指令通常是原子的,但 “读-改-写” 三步操作默认不是原子的,需要特殊指令(ldrex / strex) 保证。
1. 物理本质
在 ARM 状态下(Cortex-A7 上电默认状态),每一条指令都是严格的 32 位(4 字节)二进制码。
与 x86(变长指令)不同,ARM 的定长指令让译码器设计更加简洁高效。一条典型的 ARM 指令在物理电路上被切割为以下结构:
Instruction = [Codition Code (4 bits)] + [OpCode & Flags] + [Operands (Registers/Immediates)]
1.1 核心差异:条件执行(Conditional Execution)
这是 ARM 架构最吸引人的设计之一。几乎每一条指令的高 4 位(Bits 31-28)都是条件码。这意味着:任何指令都可以是条件跳转指令的变体。
| 条件后缀 | 标志位要求(CPSR) | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|---|
| eq | Z == 1 |
Equal(相等) |
addeq r0, r1, r2(如果相等,则相加) |
if (a == b) |
| ne | Z == 0 |
Not Equal(不想等) |
movne r0, #1(如果不等,则赋值) |
if (a != b) |
| al | Ignored | Always(默认) |
add r0, r1, r2(指令不写后缀默认就是 AL) |
(无条件执行) |
| gt/lt | 大于 / 小于 | subgt ... |
> 或 <
|
2. 寄存器模型
在执行指令前,必须知道指令操作的对象。I.MX6ULL 是 Load/Store 架构。
- 原则:ALU(算术逻辑单元)只能处理通用寄存器中的数据。
- 即不能对内存里的变量做运算,必须:搬运到寄存器 $\rightarrow$ 计算 $\rightarrow$ 搬回内存。
I.MX6ULL 有 16 个核心寄存器(R0-R15):
- R0-R12:通用寄存器。
- R13(SP):栈指针(当前的内存栈顶)。
- R14(LR):链接寄存器(保存函数调用的返回地址)。
- R15(PC):程序计数器(指向当前正在取指的地址)。修改 PC 就是跳转。
在 ARM 开发中,寄存器最重要的属性是它的 AAPCS 角色(ARM Architecture Procedure Call Standard)。这决定了编译器如何使用它们。
| 寄存器 | 别名 | 硬件功能 | AAPCS | 谁负责保存? |
|---|---|---|---|---|
| R0 | a1 | 通用 | 参数 1 / 返回值 | 调用者(Caller) |
| R1 | a2 | 通用 | 参数 2 / 返回值(64 位时) | 调用者(Caller) |
| R2 | a3 | 通用 | 参数 3 | 调用者(Caller) |
| R3 | a4 | 通用 | 参数 4 | 调用者(Caller) |
| R4 | v1 | 通用 | 局部变量 | 被调用者(Callee) |
| R5 | v2 | 通用 | 局部变量 | 被调用者(Callee) |
| R6 | v3 | 通用 | 局部变量 | 被调用者(Callee) |
| R7 | v4 | 通用 | 局部变量(在 Thumb 模式下常用做栈帧指针) | 被调用者(Callee) |
| R8 | v5 | 通用 | 局部变量 | 被调用者(Callee) |
| R9 | v6/SB | 通用 | 局部变量(静态基址,平台相关) | 被调用者(Callee) |
| R10 | v7 | 通用 | 局部变量 | 被调用者(Callee) |
| R11 | v8/FP | 通用 | 帧指针(Frame Pointer) | 被调用者(Callee) |
| R12 | IP | 通用 | 过程调用中间寄存器(Intra-Procedure-call scratch) | 调用者(Caller) |
| R13 | SP | 栈指针 | 指向当前栈顶(Stach Pointer) | - |
| R14 | LR | 链接寄存器 | 保存函数返回地址(Link Register) | - |
| R15 | PC | 程序计数器 | 指向取指地址(Program Counter) | - |
除此之外还有两个非常重要的特殊状态寄存器 CPSR 和 SPSR。
| 寄存器 | 全称 | 解释 | 关键位域 |
|---|---|---|---|
| CPSR | Current Program Status Register | 当前程序状态寄存器 | N, Z, C, V(条件标志位) I, F(中断屏蔽位) Mode(运行模式位:User, SVC, IRQ 等) |
| SPSR | Saved Program Status Register | 备份程序状态寄存器 | 仅在异常模式下存在 当中断发生时,硬件自动把 CPSR 里的值拷贝到 SPSR 当中断返回时,再从 SPSR 恢复到 CPSR |
ARMv7-A 实际上不止上述的 18 个寄存器,还有影子寄存器(Banked Registers)。
2.1 为什么要区分调用者保存和被调用者保存?
简单来说,这是 ARM 官方制定的规范,叫做 AAPCS(Procedure Call Standard for the ARM Architecture,ARM 架构过程调用标准)。ARM 公司不仅设计 CPU 硬件架构,还定义了软件应如何与这些硬件交互的 ABI(Application Binary Interface,应用程序二进制接口)。
制定这一规范的核心目的在于统一标准。它确保了二进制层面的兼容性——无论代码是使用 GCC 还是 ARM Compiler 编译的,只要遵循 AAPCS,它们就能无缝协作。你无需担心不同编译器在参数传递或堆栈管理上的差异,从而可以放心地调用第三方提供的预编译库。
当 Caller 调用 Callee 时,参数通过 R0-R3 传递。调用结束后,返回值通常保存在 R0 或 R0-R1 中。这就带来了一个问题:Callee 执行时不可避免地会使用寄存器,Caller 如何确保自己的数据不被覆盖?
AAPCS 为此制定了明确的规范:
- 易失性寄存器(Volatile Registers, R0-R3, R12):AAPCS 规定 Callee 可以随意修改它们。因此,如果 Caller 在 R0-R3 中存放了重要数据且在调用后仍需要使用,Caller 必须自己负责在调用前将它们压栈保存(Caller-saved)。
- 非易失性寄存器(Non-volatile Registers, R4-R11):AAPCS 规定 Callee 必须保证函数返回时,这些寄存器的值与进入函数时完全一致。如果 Callee 内部需要使用 R4-R11,Callee 必须负责先将其压栈保存,并在返回前恢复(Callee-saved)。这样 Caller 就可以放心地将关键数据放在这里,无需担心被篡改。
上述规则解决了函数间的协作问题,但中断(Interrupt)打破了这种默契。中断是异步突发的,主程序(Caller)根本不知道何时会被打断,因此无法预先保存 R0-R3 等易失性寄存器。
为了解决这个问题,ARM Cortex-M 架构引入了硬件压栈(Hardware Stacking)机制。当 CPU 响应中断时,硬件会自动将 Caller 上下文(包括 R0-R3、R12、LR、PC 和 xPSR)。这一过程无需软件干预。
因此,进入中断服务函数(ISR)后,寄存器已经被硬件保护好了。ISR 此时只需像普通函数一样,遵守 AAPCS 规则处理 R4-R11(即如果用到则保存)即可。这种硬件机制极大降低了中断延迟,也简化了软件设计。
2.2 R12(IP)是干什么的?
它是一个被很多人容易忽略的寄存器,主要用于链接器(Linker)插入的代码。当你的程序非常大,bl target 跳转的目标太远(超过 $\pm 32\text{MB}$)时,bl 指令够不着。此时,链接器会自动插入一段小代码(Veneer/Trampoline),先跳转到 R12 指向的中转站,再由 R12 赋值给 PC 进行长跳转。
2.3 R15(PC)为什么有 +8 现象?
在 ARM 状态(32 位指令)下,读取 PC 的值,通常是当前指令地址 + 8。这则是因为 CPU 的流水线(Pipeline)设计,当 CPU 执行当前指令时,取指单元已经在取下下条指令了(当前指令地址 +8 地址处)。
3. 指令集讲解
3.1 数据处理指令(Data Processing)
核心逻辑:ALU 运算,结果写回寄存器,并可选更新 CPSR(如果指令后加 s,如 adds)
3.1.1 算术类
| 指令 | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|
add(Add) |
加法。 |
add r0, r1, r2( R0 = R1 + R2) |
a = b + c; |
sub(Subtract) |
减法。 |
sub r0, r1, #1( R0 = R1 - 1) |
a = b - 1; |
rsb(Reverse Substract) |
逆向减法。 用于 R0 = 常数 - Rx 的场景。 |
rsb r0, r1, #0( R0 = 0 - R1) |
a = -b; |
adc(Add with Carry) |
带进位加法。 用于实现 64 位加法的高 32 位运算。 |
adc r0, r1, r2( R0 = R1 + R2 + Carry) |
(long long )a + (long long)b 的高位部分 |
sbc(Subtract with Carry) |
带借位减法。 用于 64 位减法。 |
sbc r0, r1, r2( R0 = R1 - R2 - !Carry) |
64 位减法的高位部分 |
mul(Multiply) |
32 位乘法(结果取低 32 位) |
mul r0, r1, r2( R0 = R1 * R2) |
int a = b * c; |
mla(Multiply Accumulate) |
乘法运算。 DSP 算法核心指令。 |
mul r0, r1, r2, r3( R0 = R1 * R2 + R3) |
a = (b * c) + d; |
udiv(Unsigned Divide) |
无符号除法。 | udiv r0, r1, r2 |
unsigned a = b / c; |
sdiv(Signed Divided) |
有符号除法。 | sdiv r0, r1, r2 |
int a = b / c; |
3.1.2 逻辑类
| 指令 | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|
and(Logical AND) |
按位与。 常用于提取某几位。 |
and r0, r1, #0xff |
a = b & 0xff; |
orr(Logical OR) |
按位或。 常用于置位(设为 1)。 |
orr r0, r0, #(1<<3) |
r0 | = (1 << 3); |
eor(Exclusive OR) |
按位异或。 常用于翻转某几位。 |
eor r0, r0, #1 |
a = b ^ 1; |
bic(Bit Clear) |
位清除。and 的反向操作:dest = op1 and (not op2)
|
bic r0, r0, #(1<<3) |
r0 &= ~(1 << 3); |
3.1.3 数据搬运
| 指令 | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|
mov(Move) |
寄存器间传值,或立即数赋值。 | mov r0, r1 |
a = b; |
mvn(Move Not) |
按位取反后传送。 |
mvn r0, #0( r0 = 0xffffffff) |
a = ~0; |
ARM 指令是定长 32 位的。除去操作码和条件码,留给立即数的空间只有 12 位。并且这 12 位还不是直接存数字,而是被拆分位 8 位常数 + 4 位循环右移。
这就意味着在向寄存器写入某些立即数时,例如 mov r0, #0x12345678 是会失败的,因为 0x12345678 这个立即数太乱了,无法通过 8 位常数 + 4 位循环右移凑出来。
解决办法是使用 ldr r0, =0x12345678。此时,汇编器会悄悄在代码段的末尾(Literal Pool)找个地方放 0x12345678,然后把这条指令翻译成去那个地址读数据。
3.1.4 比较指令
不保存运算结果,只更新 CPSR 的标志位(N, Z, C, V),为后面的条件执行做铺垫。
| 指令 | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|
cmp(Compare) |
本质是 sub。比较大小。 |
cmp r0, #5( r0 - 5,更新 Z 标志位) |
if (a >= 5) |
tst(Test) |
本质是 and。测试某位是否为 1。 |
tst r0, #(1<<3)( r0 & 0x8,更新 Z 标志位) |
if (a & (1<<3)) |
teq(Test Equivalence) |
本质是 eor。测试两个数是否相等(不影响进位标志)。 |
teq r0, r1 |
常用于汇编手写检查 |
3.1.5 桶形移位器(Barrel Shifter)
在 ARM 中,移位操作不是独立指令,而是数据进入 ALU 之前的预处理。它是免费的。指令 add r0, r1, r2, lsl #2 的物理含义是:
$$ \text{R0} = \text{R1} + (\text{R2} \ll 2) $$
这在单周期内完成,这就是为什么 ARM 处理数组索引(地址 = 基址 + 索引 $\times$ 4)特别快。
| 模式 | 物理动作 | C 标志位更新 | 数学含义 | C 语言映射 | 典型应用场景 |
|---|---|---|---|---|---|
lsl(Logical Shift Left) |
左移。 低位补 0,高位移出 |
最后移出的位 | $\times 2^n$ | x << n |
乘法优化、位字段对齐、生成掩码 |
lsr(Logical Shift Right) |
右移。 高位补 0,低位移出 |
最后移出的位 | $/2^n$ (无符号) |
(unsigned)x >> n |
无符号数除法、读取特定位段 |
asr(Arithmetic Shift Right) |
算术右移。 高位补符号位,低位移出 |
最后移出的位 | $/2^n$ (有符号) |
(signed)x >> n |
有符号数除法 |
ror(Rotate Right) |
循环右移。 低位移出后,填补到高位 |
最后移出的位 | - | (x >> n) | (x << (32-n)) |
加密算法、哈希函数、位图操作 |
rrx(Rotate Right with Extend) |
带扩展循环右移。 33 位循环 (32 位寄存器 + 1 位 Carry) |
寄存器移出的 LSB | - | 无 | 大数运算、多精度算术 |
3.2 内存访问指令(Memory Access)
这是 CPU 与外部世界(DDR 内存、GPIO 控制器等)交互的唯一桥梁。
3.2.1 单寄存器
| 指令 | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|
ldr(Load Register) |
从内存读 4 字节到寄存器。 | ldr r0, [r1] |
r0 = *r1; |
str(Store Register) |
把寄存器的值写入内存 4 字节。 | str r0, [r1] |
*r1 = r0; |
ldrb (8 bit)(Load Byte) |
读 1 字节,高 24 位补零。 | ldrb r0, [r1] |
r0 = (unsigned char)*r1; |
ldrh (16 bit)(Load Halfword) |
读 2 字节,高 16 位补零。 | ldrh r0, [r1] |
r0 = (unsigned short)*r1; |
ldrsb(Load Signed Byte) |
读 1 字节,高 24 位补符号位。 | ldrsb r0, [r1] |
r0 = (char)*r1; |
ldrsh(Load Signed Halfword) |
读 2 字节,高 16 位补符号位。 | ldrsh r0, [r1] |
r0 = (short)*r1; |
3.2.2 寻址模式
| 寻址模式 | 汇编举例 | C 语言映射 | 状态 |
|---|---|---|---|
| 立即数偏移 (Offset) |
ldr r0, [r1, #4] |
r0 = *(r1 + 1); |
r1 保持不变 |
| 前索引 (Pre-indexed) |
ldr r0, [r1, #4]! |
r0 = *(++ptr); |
r1 在访问内存前更新为 r1 + 4
|
| 后索引 (Post-indexed) |
ldr r0, [r1], #4 |
r0 = *(ptr++); |
访问内存后,r1 更新为 r1 + 4
|
3.2.3 多寄存器
这是 ARM 实现栈(Stack)和上下文切换(Context Switch)的基石。
| 指令 | 解释 | 汇编举例 | 场景 |
|---|---|---|---|
ldm(Load Multiple) |
内存 $\rightarrow$ 多个寄存器。 | ldm r0, {r4-r6, pc} |
恢复现场并返回 |
stm(Store Multiple) |
多个寄存器 $\rightarrow$ 内存。 | stm r0, {r4-r6, lr} |
保存现场 |
说明:
-
ldm r0, {r4-r6, pc}:从以r0为基址的内存地址开始,依次加载数据到寄存器r4、r5、r6和pc。 -
stm r0, {r4-r6, lr}:从寄存器r4、r5、r6和lr中,依次将数据存储到以r0为基址的内存地址。
后缀模式
虽然有 ia、ib、da 和 db 四种,但 ARM 官方推荐在操作栈时使用别名,方便记忆:
-
stmfd(Full Descending) =stmdb(Decrement Before):入栈(压栈)。 -
ldmfd(Full Descending) =ldmia(Increment After):出栈。 -
push {reglist}等价于stmdb sp!, {reglist}。 -
pop {reglist}等价于ldmia sp!, {reglist}。
3.3 分支与控制流(Branch & Control)
打破顺序执行,实现 if、for、function call。
| 指令 | 解释 | 汇编举例 | C 语言映射 |
|---|---|---|---|
b(Branch) |
相对跳转(范围 $\pm 32\text{MB}$) | b label |
goto label; |
bl(Branch with Link) |
带连接跳转。 保存下一条指令地址到 lr。 |
bl func |
func(); |
bx(Branch Exchange) |
跳转并根据目标地址最低位切换状态 (ARM $\rightarrow$ Thumb)。 |
bx lr |
return;(函数返回) |
blx(Branch Link Exchange) |
bl + bx 的结合。调用不同指令集的函数。 |
blx r0 |
函数指针调用 |
还可以直接修改 PC 寄存器实现跳转,例如 mov pc, lr,这是函数返回的底层实现。
3.4 系统与状态指令(System & Status)
这部分在使用上层语言实现业务逻辑时几乎看不到,但是对于操作系统而言至关重要。
3.4.1 状态寄存器访问
| 指令 | 解释 | 汇编举例 | 场景 |
|---|---|---|---|
mrs(Move Register from Status) |
读取 CPSR/SPSR 到通用寄存器。 | mrs r0, cpsr |
读取当前中断状态 |
msr(Move Status from Register) |
将通用寄存器写会 CPSR/SPSR。 | msr cpsr_c, r0 |
开/关中断 |
3.4.2 异常与中断生成
| 指令 | 解释 | 场景 |
|---|---|---|
svc(Supervisor Call) |
软中断。 CPU 进入 SVC 模式。 Linux 中用于从用户态(User)陷入内核态(Kernel)。 |
系统调用(open,read 等)。 |
bkpt(Breakpoint) |
断点指令。 执行到此时 CPU 暂停并通知调试器。 |
JTAG/GDB 调试。 |
3.4.3 协处理器指令
I.MX6ULL 中,CP15 协处理器控制着 MMU、Cache 和中断向量表基地址。
| 指令 | 解释 | 助记法 |
|---|---|---|
mcr(Move to Coprocessor from Register) |
CPU $\rightarrow$ CP15 (写配置) |
R(Register)在最后,表示源 |
mrc(Move to Register from Coprocessor) |
CP15 $\rightarrow$ CPU (读状态) |
C(Coprocessor)在最后,表示源 |
例如:mcr p15, 0, r0, c1, c0, 0(开启 MMU)。
协处理器(Coprocessor)时依附于 CPU 主核的独立电路单元。ARM 主核(ALU)只管算逻辑和搬砖,像 MMU(内存映射管理)、Cache 配置这些脏活累活,都丢给 CP15 协处理器;浮点运算丢给 CP10/CP11。
3.4.4 内存屏障(Memory Barriers)
| 指令 | 解释 | 场景 |
|---|---|---|
dmb(Data Memory Barrier) |
确保指令前的内存访问在指令后的内存访问之前完成。 | 操作 DMA 描述符时 |
dsb(Data Synchronization Barrier) |
比 DMB 更强。 确保前面的访存全部完成,CPU 才会执行下一条指令。 |
修改页表后 |
isb(Instruction Synchronization Barrier) |
冲刷流水线。确保重新取指。 | 修改了代码段或中断向量表后 |
3.5 同步与原子操作(Synchronization)
对于多任务操作系统,需要处理竞态条件。
| 指令 | 解释 | C 语言映射 |
|---|---|---|
ldrex(Load Register Exclusive) |
读取并设置独占监视器 |
atomic_load (C11) |
strex(Store Register Exclusive) |
如果监视状态未被破坏,则写入。 返回 0 成功,1 失败。 |
atomic_compare_exchange (C11) |
clrex(Clear Exclusive) |
清除监视器状态。 | 上下文切换时清理残余状态 |
ARM 没有 x86 的 lock 总线锁,它使用 “独占监视器” 硬件。
-
ldrex读取数据的同时标记该地址为 “被我监视”。 -
strex尝试写入。如果期间没有其他核心 / DMA 修改过该地址,写入成功;否则写入失败。
这会实现 mutex(互斥锁)和 semaphore(信号量)的基石。
互斥锁(Mutex)& 信号量(Semaphore):
- 互斥锁(厕所钥匙):只有一个资源(厕所)。你拿了钥匙进去锁门,别惹只能在门口排队(阻塞),直到你出来还钥匙。用户保护临界区。
- 信号量(餐厅叫号):有 $N$ 个资源(空桌子)。来一个人 $N-1$,走一个人 $N+1$。当 $N=0$ 时,新来的人必须等到。
这两个都是软件概念,但底层必须由原子指令支持。
Comments