第二章:ARM指令集入门——指令切换、数据处理与分支控制

ARM处理器有个很有意思的特点——它其实是个“双面人”。

什么意思呢?它支持两套完全不同的指令集:ARM指令集(32位)和Thumb指令集(16位)。我刚开始做固件逆向时,经常被这个切换搞得晕头转向。后来摸清了门道,才发现这其实是ARM最精妙的设计之一。

2.1 ARM与Thumb指令集切换

ARM指令每条占32位,功能强,但代码密度低。Thumb指令每条占16位,代码密度高,但功能弱一些。你想想看,嵌入式设备Flash通常很小,用Thumb能省不少空间。

切换的核心指令是BXBLX。它们通过跳转地址的最低位来判断目标状态:

  • 最低位为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:跳转并保存返回地址到LR
  • BX:跳转并可能切换指令集

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的栈操作指令是PUSHPOP(在Thumb中)或STMDB/LDMIA(在ARM中)。但实际逆向时,你看到的基本都是PUSHPOP

; 函数入口保存寄存器
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指令集入门知识体系 ARM指令集 ARM ↔ Thumb 切换 (BX/BLX) 数据处理指令:MOV / ADD / SUB / LDR / STR 分支指令:B / BL / BX 条件执行:EQ/NE/GT/LT/HI/LS + CMP 栈操作:PUSH / POP 注:箭头表示知识递进关系,颜色区分不同指令类别

这张图把本章内容串起来了。从ARM指令集出发,先学会切换Thumb,再掌握数据处理、分支控制、条件执行,最后用栈操作收尾。每一步都是逆向分析中绕不开的基本功。

好了,这一章就到这里。记住,指令集是工具,多用多练才能形成肌肉记忆。下次你在IDA里看到一堆LDRSTR时,应该能一眼看出它们在干什么了。


公众号:蓝海资料掘金营,微信deep3321