第二章:ARM指令集入门——指令切换、数据处理与分支控制
ARM处理器有个很有意思的特点——它其实是个“双面人”。
什么意思呢?它支持两套完全不同的指令集:ARM指令集(32位)和Thumb指令集(16位)。我刚开始做固件逆向时,经常被这个切换搞得晕头转向。后来摸清了门道,才发现这其实是ARM最精妙的设计之一。
2.1 ARM与Thumb指令集切换
ARM指令每条占32位,功能强,但代码密度低。Thumb指令每条占16位,代码密度高,但功能弱一些。你想想看,嵌入式设备Flash通常很小,用Thumb能省不少空间。
切换的核心指令是BX和BLX。它们通过跳转地址的最低位来判断目标状态:
- 最低位为0:切换到ARM状态
- 最低位为1:切换到Thumb状态
关键点:ARM处理器在取指时,会忽略地址最低位。这个位只用来标记状态,不参与实际寻址。
; 从ARM切换到Thumb的例子
LDR R0, =thumb_func + 1 ; 地址+1,标记Thumb状态
BX R0 ; 跳转并切换
; 从Thumb切换回ARM
LDR R0, =arm_func ; 地址不变,标记ARM状态
BX R0 ; 跳转并切换
我在逆向一个IoT设备固件时,遇到过一段代码死活看不懂——反汇编出来全是乱码。后来发现是Thumb代码被当成了ARM来解析。嗯,这种坑踩过一次就记住了。
实战技巧:用IDA或Ghidra分析固件时,如果发现某段代码反汇编结果异常,先检查入口地址的最低位。很多新手在这里翻车。
2.2 常用数据处理指令
数据处理指令是ARM汇编的“基本功”。说白了,就是搬数据、做运算。
2.2.1 MOV指令
MOV用于数据传送,格式很简单:
MOV R0, #0x10 ; R0 = 0x10
MOV R1, R2 ; R1 = R2
MOV R0, R1, LSL #2 ; R0 = R1 << 2
注意,ARM的MOV可以带移位操作。这个特性在嵌入式开发中很实用,比如做位掩码时,一条指令就能搞定。
2.2.2 ADD和SUB指令
加减法,没什么花哨的:
ADD R0, R1, R2 ; R0 = R1 + R2
ADD R0, R1, #5 ; R0 = R1 + 5
SUB R0, R1, R2 ; R0 = R1 - R2
SUB R0, R1, #3 ; R0 = R1 - 3
我记得有一次调试一个电机控制程序,发现速度计算总是不对。查了半天,原来是SUB指令的立即数范围超了——ARM的立即数不是随便写的,它有个8位循环移位的编码规则。这个细节,很多教材上不会讲。
避坑指南:ARM的立即数不是任意32位数都能用。它由8位常数和4位循环移位值组成。比如MOV R0, #0x100是合法的,但MOV R0, #0x101可能就不行。遇到这种情况,需要用LDR R0, =0x101从内存加载。
2.2.3 LDR和STR指令
这两条是内存访问的核心指令:
LDR:从内存加载数据到寄存器STR:从寄存器存储数据到内存
LDR R0, [R1] ; R0 = *R1
LDR R0, [R1, #4] ; R0 = *(R1 + 4)
STR R0, [R1] ; *R1 = R0
STR R0, [R1, #-8] ; *(R1 - 8) = R0
这里有个容易混淆的地方:LDR既可以做内存加载,也可以做伪指令加载立即数(比如LDR R0, =0x12345678)。编译器会自动处理成PC相对加载。
2.3 分支指令
分支指令控制程序的执行流。ARM的分支指令设计得很优雅。
2.3.1 B指令(无条件跳转)
B label ; 跳转到label
B .+8 ; 跳转到当前PC+8的位置
B指令是相对跳转,范围±32MB。我见过有人用B指令做循环跳转,结果代码太长超出了范围——编译直接报错。
2.3.2 BL指令(带链接的跳转)
BL在跳转前会把返回地址保存到LR寄存器(R14)。这是函数调用的标准方式:
BL func ; 调用func,LR = 返回地址
2.3.3 BX指令(带状态切换的跳转)
前面讲过了,BX会根据地址最低位切换指令集。这是ARM和Thumb之间“对话”的桥梁。
核心区别:
B:纯跳转,不保存返回地址BL:跳转并保存返回地址到LRBX:跳转并可能切换指令集
2.4 条件执行
ARM指令集有个很酷的特性——几乎所有指令都可以条件执行。什么意思呢?就是指令前面可以加条件码,只有条件满足时才执行。
条件码有16种,常用的有:
| 条件码 | 含义 | 标志位条件 |
|---|---|---|
| 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, #5
ADDGT R1, R1, #1 ; 如果R0 > 5,则R1++
SUBLE R2, R2, #1 ; 如果R0 <= 5,则R2--
我个人特别喜欢条件执行这个特性。在逆向分析时,看到一串条件执行的指令,基本就能猜到是编译器优化后的结果。比如:
; 典型的条件执行优化
CMP R0, #0
MOVNE R1, #1
MOVEQ R1, #0
这比用分支指令跳来跳去高效得多。没有分支预测失败的开销,代码也更紧凑。
逆向小技巧:在IDA中看到大量条件执行指令时,可以尝试用“图形视图”看控制流。条件执行往往对应着C语言中的if-else或三元运算符。
2.5 栈操作(PUSH和POP)
栈在ARM中扮演着重要角色——保存局部变量、传递参数、保存寄存器现场。
ARM的栈操作指令是PUSH和POP(在Thumb中)或STMDB/LDMIA(在ARM中)。但实际逆向时,你看到的基本都是PUSH和POP。
; 函数入口保存寄存器
PUSH {R4-R7, LR} ; 保存R4-R7和返回地址
; 函数体...
; 函数出口恢复寄存器
POP {R4-R7, PC} ; 恢复R4-R7,同时将LR恢复到PC实现返回
注意最后一行——POP {R4-R7, PC}。这是ARM函数返回的标准写法。把LR的值直接弹出到PC,就完成了返回。
我曾经逆向过一个被混淆过的固件,函数入口和出口被故意打乱。正常的PUSH/POP配对被拆得到处都是。嗯,那是我加班最狠的一周。
避坑指南:ARM的栈是满递减栈(Full Descending)。栈指针SP指向栈顶元素,压栈时SP先减再存。这个方向不要搞反了,否则调试时会看到栈指针乱飞。
2.6 本章知识体系
下面这张图总结了本章的核心知识点和它们之间的关系:
这张图把本章内容串起来了。从ARM指令集出发,先学会切换Thumb,再掌握数据处理、分支控制、条件执行,最后用栈操作收尾。每一步都是逆向分析中绕不开的基本功。
好了,这一章就到这里。记住,指令集是工具,多用多练才能形成肌肉记忆。下次你在IDA里看到一堆LDR和STR时,应该能一眼看出它们在干什么了。
公众号:蓝海资料掘金营,微信deep3321