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,否则数据对齐和符号扩展会给你挖坑。
加载存储指令还有几种寻址模式,我建议你重点掌握这三种:
- 偏移寻址:LDR R0, [R1, #8] —— 不改变基址寄存器
- 前索引寻址:LDR R0, [R1, #8]! —— 先更新基址,再加载(感叹号表示回写)
- 后索引寻址: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指令集分成了三个大类。你在调试时,看到反汇编代码,先判断它属于哪一类,然后对照表格和示例去理解。时间长了,你看到LDR R0, [R1, #4]!这种指令,脑子里就能直接反应出它在干什么。
我的建议:初学者不要试图一次性记住所有指令。先掌握最常用的20条,比如LDR、STR、ADD、SUB、MOV、CMP、B、BL、BX。剩下的指令,遇到时再查手册。调试时,你80%的时间都在跟这20条指令打交道。