2. ARMv7指令集基础:ARM与Thumb-2指令集、数据处理指令、加载存储指令、分支指令

好,咱们正式开始啃指令集这块硬骨头。说实话,很多做调试的工程师,一看到汇编就头疼。但我个人的经验是——你不需要背指令,但必须能看懂。尤其是在定位一些诡异问题时,比如栈被踩了、PC指针飞了,不懂指令集,你连反汇编都看不懂。

ARMv7架构支持两套指令集:ARM指令集和Thumb-2指令集。这两套东西,说白了就是“性能”和“密度”的取舍。我当年调试一个低功耗设备时,就因为选错了指令集,导致代码体积超了Flash容量,折腾了两天才发现是编译选项没配对。

2.1 ARM指令集 vs Thumb-2指令集

ARM指令集是固定32位长度的。每条指令能干很多事,性能强,但占空间大。Thumb-2就不一样了,它是16位和32位混合的。你想想看,如果大部分指令用16位,关键路径用32位,那代码密度不就上去了吗?

特性 ARM指令集 Thumb-2指令集
指令长度 固定32位 16位/32位混合
代码密度 较低 较高(节省约30%空间)
性能 高(单周期执行多) 中(部分指令多周期)
条件执行 几乎所有指令都支持 仅分支指令支持
适用场景 性能敏感型代码 存储受限型代码

核心要点:ARMv7-A和ARMv7-R处理器通常同时支持ARM和Thumb-2。而ARMv7-M(比如Cortex-M3/M4)只支持Thumb-2。所以如果你做MCU开发,基本只跟Thumb-2打交道。

我记得有一次,客户反馈说他们的设备在某种极端情况下会死机。我拿到core dump一看,PC指针指向了一个奇数地址。嗯,这里要注意——ARM模式下PC必须是4字节对齐,Thumb模式下PC必须是2字节对齐。如果PC指向奇数地址,那肯定是栈被破坏了,或者函数指针被篡改了。这个经验帮我快速定位了好几个类似的bug。

2.2 数据处理指令

数据处理指令,说白了就是CPU的“算盘”。加减乘除、与或非、移位比较,全归它管。这类指令有个共同特点:操作数来自寄存器,结果也写回寄存器。它不访问内存。

常用的数据处理指令,我按功能分了四类:

  • 算术运算:ADD、SUB、RSB、MUL、UDIV/SDIV
  • 逻辑运算:AND、ORR、EOR、BIC
  • 移位运算:LSL、LSR、ASR、ROR
  • 比较运算:CMP、CMN、TST、TEQ

这里我特别想提一下BIC指令。BIC是“位清除”,它跟AND的区别在于:AND是保留为1的位,BIC是清除为1的位。我在项目中遇到过一个问题:一个外设的状态寄存器,某位需要写1来清除中断标志。结果同事用了AND指令去清位,怎么清都清不掉。换成BIC,一次搞定。

; 示例:BIC指令用法
; 假设 R0 = 0x000000FF,我们要清除bit 3
BIC R1, R0, #0x08    ; R1 = R0 & ~0x08 = 0x000000F7

; 对比AND指令
AND R2, R0, #0xFFFFFFF7  ; 效果一样,但立即数范围受限

调试小技巧:在GDB中,你可以用info registers查看所有寄存器值。如果发现某个寄存器的值不符合预期,先检查是不是数据处理指令的立即数范围出了问题。ARM的立即数不是随便写的,它要经过“8位常数+4位循环移位”的编码。超出范围的立即数,编译器会帮你拆成多条指令,但性能就下来了。

2.3 加载存储指令

ARM架构是典型的RISC(精简指令集计算机)。RISC的核心思想就是:内存访问和数据处理分开。你要算数据,先把数据从内存搬到寄存器(LDR),算完了再存回去(STR)。

加载存储指令的格式,我总结了一个口诀:“LDR/STR + 目标寄存器 + 基址寄存器 + 偏移”。

指令 含义 示例
LDR 加载32位字 LDR R0, [R1, #4]
LDRH 加载16位半字(零扩展) LDRH R0, [R1, R2]
LDRB 加载8位字节(零扩展) LDRB R0, [R1, #-2]
LDRSH 加载16位半字(符号扩展) LDRSH R0, [R1, #0x10]
LDRSB 加载8位字节(符号扩展) LDRSB R0, [R1]
STR 存储32位字 STR R0, [R1, #-4]!
STRH 存储16位半字 STRH R0, [R1, R2, LSL #1]
STRB 存储8位字节 STRB R0, [R1, #0x20]

曾经踩过的坑:我曾经调试过一个网络协议栈,数据包解析总是出错。反汇编一看,结构体里的一个16位字段,代码用了LDR(32位加载)去读,结果把相邻字段也读进来了。记住:结构体成员是什么类型,就用什么加载指令。别偷懒统一用LDR,否则数据对齐和符号扩展会给你挖坑。

加载存储指令还有几种寻址模式,我建议你重点掌握这三种:

  1. 偏移寻址:LDR R0, [R1, #8] —— 不改变基址寄存器
  2. 前索引寻址:LDR R0, [R1, #8]! —— 先更新基址,再加载(感叹号表示回写)
  3. 后索引寻址:LDR R0, [R1], #8 —— 先加载,再更新基址

后索引寻址在栈操作中特别常见。比如函数返回时,POP指令本质上就是LDR的后索引形式。你反汇编时看到LDR R0, [SP], #4,就知道这是在出栈。

2.4 分支指令

分支指令,就是改变程序执行流的指令。没有分支指令,CPU就只能一条道走到黑。ARM的分支指令主要有:B、BL、BX、BLX。

  • B(Branch):无条件跳转,范围±32MB
  • BL(Branch with Link):跳转并保存返回地址到LR寄存器
  • BX(Branch and Exchange):跳转并切换指令集(ARM↔Thumb)
  • BLX(Branch with Link and Exchange):跳转、保存返回地址、切换指令集

这里有个关键点:BX和BLX的跳转目标地址,最低位(bit 0)用来指示指令集。bit 0 = 0表示ARM模式,bit 0 = 1表示Thumb模式。所以你在反汇编里看到BX R0,如果R0 = 0x80001001,那CPU就会跳到0x80001000并以Thumb模式执行。

; 示例:函数调用与返回
main:
    BL  func1       ; 调用func1,LR = 返回地址
    ; ... 继续执行

func1:
    PUSH {LR}       ; 保存LR到栈
    BL  func2       ; 调用func2
    POP  {PC}       ; 恢复PC,等价于返回

调试视角:当程序跑飞时,我第一件事就是看PC和LR寄存器。PC告诉你现在在哪,LR告诉你之前从哪来的。如果LR的值看起来不像代码地址(比如指向了数据区),那基本可以断定是函数返回地址被破坏了。这种问题,十有八九是栈溢出或者野指针导致的。

2.5 知识体系总览

为了让你对本章内容有个整体把握,我画了一张结构图。你可以把它当作一个“指令集地图”,以后遇到具体问题时,知道该往哪个方向查。

ARMv7指令集知识体系 ARMv7指令集知识体系 ARMv7指令集 ARM指令集(32位) Thumb-2指令集(16/32位) 数据处理指令 加载存储指令 分支指令 算术运算 逻辑运算 移位运算 比较运算 单寄存器加载 单寄存器存储 多寄存器加载 多寄存器存储 B(无条件跳转) BL(带链接跳转) BX/BLX(切换指令集) 调试核心:反汇编时重点关注PC、LR、SP三个寄存器 PC → 当前执行位置 | LR → 函数返回地址 | SP → 栈指针完整性

这张图把ARMv7指令集分成了三个大类。你在调试时,看到反汇编代码,先判断它属于哪一类,然后对照表格和示例去理解。时间长了,你看到LDR R0, [R1, #4]!这种指令,脑子里就能直接反应出它在干什么。

我的建议:初学者不要试图一次性记住所有指令。先掌握最常用的20条,比如LDR、STR、ADD、SUB、MOV、CMP、B、BL、BX。剩下的指令,遇到时再查手册。调试时,你80%的时间都在跟这20条指令打交道。