3. ARMv7 指令集基础:ARM 指令与 Thumb 指令切换、条件执行、常用数据处理指令

ARMv7 的指令集,说白了就是处理器的「母语」。你让它干活,就得用它能听懂的话。ARM 处理器最特别的地方,就是它有两套「母语」——ARM 指令和 Thumb 指令。我刚开始接触时也觉得奇怪,为什么要有两套?后来在项目里吃过亏才明白,这其实是性能和代码密度的平衡艺术。

3.1 ARM 指令 vs Thumb 指令:双面人生

ARM 指令是 32 位定长,每条指令能干很多事。Thumb 指令是 16 位定长(后来 Thumb-2 引入了 32 位混合),指令密度高,但功能相对受限。你想想看,同样的代码,用 Thumb 编译出来体积能小 30% 左右,这对嵌入式系统来说太重要了。

特性 ARM 指令 Thumb 指令
指令长度 固定 32 位 16 位 / 32 位混合
代码密度 较低 较高(节省 30% 空间)
性能 单周期执行更多操作 部分指令需多周期
寄存器访问 所有 16 个寄存器 受限(通常只访问低 8 个)
条件执行 几乎所有指令都支持 仅分支指令支持
我的经验:在内存紧张的 MCU 项目里,我通常用 Thumb 编译。但在需要极致性能的 DSP 算法或中断服务中,我会手动把关键函数切回 ARM 模式。别小看这个切换,有时候性能差距能达到 20%。

3.2 ARM/Thumb 状态切换:BX 指令的妙用

ARM 处理器通过 CPSR 的 T 位来标记当前状态:T=0 是 ARM 状态,T=1 是 Thumb 状态。切换的核心指令是 BX(Branch and Exchange)。

; 从 ARM 切换到 Thumb
LDR   R0, =thumb_func + 1   ; 注意:目标地址最低位写 1
BX    R0

; 从 Thumb 切换到 ARM
LDR   R0, =arm_func          ; 目标地址最低位写 0
BX    R0

这里有个坑,我踩过不止一次:目标地址的最低位必须正确设置。ARM 模式下地址最低位必须是 0,Thumb 模式必须是 1。处理器在跳转时会自动清除这个位来获取真实地址,同时根据这个位切换 T 标志。

我曾经犯过的错:在写 bootloader 时,忘记给 Thumb 函数地址加 1,结果跳过去直接进异常。查了两天才发现是 LSB 的问题。从那以后,我每次写 BX 都会在注释里标清楚当前状态和目标状态。

3.3 条件执行:ARM 指令的灵魂

ARM 指令最让我着迷的特性就是条件执行。几乎所有 ARM 指令都可以带条件后缀,根据 CPSR 中的标志位决定是否执行。这能省掉很多分支跳转,让代码更紧凑。

条件码 含义 标志位条件
EQ 等于 Z = 1
NE 不等于 Z = 0
GT 有符号大于 Z = 0 且 N = V
LT 有符号小于 N != V
HI 无符号大于 C = 1 且 Z = 0
LS 无符号小于等于 C = 0 或 Z = 1
; 条件执行示例:求两个数的最大值
CMP   R0, R1        ; 比较 R0 和 R1
MOVGT R2, R0        ; 如果 R0 > R1,R2 = R0
MOVLE R2, R1        ; 如果 R0 <= R1,R2 = R1

你看,没有跳转指令,没有标签,三行搞定。这在流水线处理器里能避免分支预测失败的开销。我个人习惯在状态机实现中大量使用条件执行,代码可读性反而更好。

核心要点:条件执行不是 Thumb 的强项。Thumb 指令只有 B(分支)指令支持条件后缀。所以如果你在 Thumb 模式下写条件代码,老老实实用 if-else 跳转吧。

3.4 常用数据处理指令:MOV、ADD、SUB

这些指令是 ARM 汇编的「三原色」。别看简单,用好了能写出非常高效的代码。

3.4.1 MOV 指令

MOV 用于寄存器间传值或加载立即数。但要注意,ARM 的立即数不是随便写的——它必须是 8 位值循环右移偶数位的结果。

MOV   R0, #0xFF        ; 合法:8 位值
MOV   R1, #0x100       ; 非法!0x100 超过 8 位
; 正确做法:
MOV   R1, #0x01
MOV   R1, R1, LSL #8   ; 通过移位得到 0x100

我记得刚学 ARM 时,被这个立即数规则折磨得不轻。后来我总结了一个规律:能用 8 位表示的立即数直接写,否则用 LDR 伪指令加载。

3.4.2 ADD 和 SUB 指令

加减法指令支持灵活的移位操作数,这是 ARM 的特色。

; 基本用法
ADD   R0, R1, R2       ; R0 = R1 + R2
SUB   R0, R1, #10      ; R0 = R1 - 10

; 带移位的用法
ADD   R0, R1, R2, LSL #2  ; R0 = R1 + (R2 << 2)
SUB   R0, R1, R2, ASR #3  ; R0 = R1 - (R2 >> 3) 算术右移

这种带移位的操作数,在数组索引、结构体成员访问时特别有用。比如你要访问一个 int 数组的第 n 个元素,直接用 LDR R0, [R1, R2, LSL #2] 就搞定了,不用先乘 4 再加。

3.5 LDR/STR:内存访问的核心

ARM 是加载/存储架构,所有数据处理都在寄存器里完成,内存访问只能通过 LDR 和 STR 指令。

指令 含义 示例
LDR 从内存加载到寄存器 LDR R0, [R1]
STR 从寄存器存储到内存 STR R0, [R1]
LDRB 加载一个字节 LDRB R0, [R1]
STRH 存储半个字(16 位) STRH R0, [R1]
LDM 批量加载多个寄存器 LDMIA R0!, {R1-R4}
STM 批量存储多个寄存器 STMDB R0!, {R1-R4}

寻址模式有三种:

  • 前索引:LDR R0, [R1, #4] — 先计算地址再访问
  • 后索引:LDR R0, [R1], #4 — 先访问再更新基址
  • 前索引并回写:LDR R0, [R1, #4]! — 先计算地址访问,再更新基址
避坑指南:后索引模式在栈操作中很常用。比如 STR R0, [SP, #-4]! 相当于压栈。但要注意,回写操作(带 !)不能用于 PC 寄存器,否则会出大问题。

3.6 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到 ARM 和 Thumb 是两条并行的指令集路径,通过 BX 指令切换。条件执行是 ARM 指令的「加速器」,而数据处理指令和内存访问指令构成了日常编程的基石。

ARMv7 指令集知识体系 ARMv7 指令集知识体系 ARM 指令集(32位) Thumb 指令集(16/32位) BX 指令切换(CPSR.T 位) 条件执行(EQ/NE/GT/LT...) 数据处理(MOV/ADD/SUB) 内存访问(LDR/STR) ARM:几乎所有指令支持条件执行 支持移位操作数、立即数规则 前索引/后索引/批量加载存储 性能与代码密度的平衡:ARM 模式跑关键路径,Thumb 模式存普通代码

3.7 实战要点总结

  • 状态切换:BX 指令的目标地址 LSB 决定切换方向,ARM 为 0,Thumb 为 1。别搞反了。
  • 条件执行:ARM 模式下多用条件执行减少分支,Thumb 模式下老老实实用跳转。
  • 立即数:MOV 只能加载 8 位立即数(循环右移偶数位),超范围用 LDR 伪指令。
  • 寻址模式:后索引适合栈操作,前索引适合结构体访问,批量指令适合上下文切换。
  • 对齐访问:LDR/STR 要求地址按字对齐(4 字节),LDRH/STRH 要求半字对齐(2 字节)。不对齐会触发异常。
再强调一次:我在一个项目中因为 LDR 访问未对齐地址,导致芯片在高温下随机死机。查了整整一周才发现是编译器优化后生成了未对齐访问指令。从那以后,我所有结构体都加了 __attribute__((aligned(4)))。

好了,ARM 指令集的基础就这些。记住:指令集是工具,怎么用好它取决于你对硬件的理解。多写多试,慢慢就会形成自己的风格。