每次接手一个新的ARM平台项目,我都会先花一点时间把反汇编输出和指令手册对着看一遍。别嫌这一步多余,很多蹊跷的崩溃、奇怪的性能瓶颈,最后都卡在对一条指令编码格式的理解上——比如你看到e3a00001这种十六进制,知道它是mov r0, #1,但如果我追问一句:为什么同样是MOV指令,E3A00001和E3A004FF的编码结构完全不一样?立即数跑到哪个位段里去了?条件码又在哪儿放着?很多人就答不上来了。
这篇文章就把ARM指令编码格式从头到尾拆一遍。内容包括指令位段的顶层划分、条件码机制、数据处理指令里"灵活操作数2"的编码规则、B/BL分支为什么要基于PC+8计算目标地址、LDR/STR和LDM/STM的位图设计,最后带你把汇编指令人工翻译成机器码,再用反汇编工具验证。适合刚接触底层开发的读者,也适合做嵌入式面试准备、或者常年只看C代码想补一补汇编基础的人。
1. 为什么说读懂ARM指令编码格式是嵌入式调试的第一道门槛
1.1 一条ARM指令的本质:32位二进制,所有语义都藏在位段里
ARM处理器(这里指经典的AArch32 ARM指令集)的每条指令在ARM模式下都是固定32位长度。这句话听起来简单,但它的含义是:你看到的所有"助记符 + 寄存器 + 立即数",最终都必须被压缩进这32位里。寄存器编号占4位,条件码占4位,操作数、偏移量、寄存器列表这些信息统统要从剩下的位段里分。所以ARM指令编码无法像x86那样搞出变长指令和许多隐含操作数,它必须设计得规整、对称、尽量可预测。
实际调试中的价值在这里:当一个段错误或者未定义指令异常发生时,程序计数器指向的地址往往对应一条反汇编出来的机器码。如果你能直接看懂这32位里每一位是什么含义,你就能在不开模拟器、不看符号表的情况下,快速判断这条指令是访问了非法地址、还是编码本身就是错的。我在某次排查启动代码时,定位到一句乱码一样的0xFFFFFFFF,反汇编器直接报"undefined instruction",就是因为Flash里烧进去的数据根本不是指令——这种一眼看穿编码问题的能力,靠的就是对位段结构的肌肉记忆。
1.2 编码格式带来的三个独特设计:条件执行、桶式移位器、PC+8基准
ARM指令编码和很多其他指令集最大的差别,在于它的顶层设计里有三个"历史遗产",至今仍深刻影响你写汇编的方式。
第一个是条件执行。ARM指令的高4位是条件码,几乎每条指令都可以"有条件地执行"。这相当于把比较和分支的一部分工作做进了指令本身。编译器和汇编器可以用条件执行消灭大量短跳转,代价是每条指令都要开销4位条件码——这在地球上绝大多数指令集里是不肯给的。
第二个是桶式移位器(Barrel Shifter)。数据处理指令的操作数不是简单的寄存器,而是可以经过一次移位/循环移位后再参与运算。比如add r0, r1, r2, lsl #4这种写法,在其他指令集里要拆成"先左移,再加"两条指令,在ARM里由硬件转移单元的桶式移位器一步完成。
第三个是PC+8基准。ARM的经典三级流水线(取指、译码、执行)导致执行阶段读到的PC值,其实是当前指令地址加8的"预取值"。所有PC相对寻址(比如B分支指令)都基于这个基准计算。很多初学者在这里栽跟头:为什么b label的偏移量不是直接从当前地址算,而是要把PC加8之后再算?
这三个设计不是割裂的,它们共同反映在指令编码的位段划分上。弄懂了编码格式,这三个机制自然也就理解了。
2. 顶层编码骨架:条件域、指令类别域与位段总览
2.1 32位ARM指令的位段全局图
先把一张"地图"铺开。ARM模式下的32位指令,从最高位到最低位,大致可以划成这么几块。
| 位段 | 位宽 | 作用 |
|---|---|---|
| [31:28] | 4 | 条件码(Condition) |
| [27:25] | 3 | 指令类别(Instruction Class) |
| [24:20] | 5 | 类别内功能修饰位 |
| [19:16] | 4 | 第一个操作数寄存器Rn(或基址寄存器) |
| [15:12] | 4 | 目标寄存器Rd(或第二操作数) |
| [11:0] | 12 | 操作数2/偏移量/寄存器列表 |
强调一下,这张表只是"全局骨架",具体到某一大类指令时,[24:20]这5位和[11:0]这12位的含义会完全不同。比如同一个[11:0]位段,在数据处理指令里是"灵活操作数2",在LDR/STR里是12位立即数偏移,在LDM/STM里就变成了16位寄存器列表——反正都是那12个低位,换个指令类别,解读方式就变。这也是ARM指令编码最需要建立"位段上下文"意识的地方。
2.2 条件码域:4位编码决定这20多条指令是否执行
条件码是ARM编码里最容易被忽视、但实际用处极大的一个高位段。它占用[31:28]四位,可以编码16种状态,其中14种有效条件加上两个特殊值。
| 条件码 | 助记符后缀 | 含义 | 标志位条件 |
|---|---|---|---|
| 0000 | EQ | 相等 | Z=1 |
| 0001 | NE | 不相等 | Z=0 |
| 0010 | CS | 无符号大于等于 | C=1 |
| 0011 | CC | 无符号小于 | C=0 |
| 0100 | MI | 负数 | N=1 |
| 0101 | PL | 非负数 | N=0 |
| 0110 | VS | 溢出 | V=1 |
| 0111 | VC | 无溢出 | V=0 |
| 1000 | HI | 无符号大于 | C=1且Z=0 |
| 1001 | LS | 无符号小于等于 | C=0或Z=1 |
| 1010 | GE | 有符号大于等于 | N=V |
| 1011 | LT | 有符号小于 | N≠V |
| 1100 | GT | 有符号大于 | Z=0且N=V |
| 1101 | LE | 有符号小于等于 | Z=1或N≠V |
| 1110 | AL | 无条件执行 | 忽略标志 |
| 1111 | — | 保留/特殊指令 | 通常不用作条件码 |
这里特别注意:无条件执行的编码是1110,也就是0xE,不是0xF。0xF开头的指令往往是比较特殊的,比如软件中断指令SVC的编码方式是0xFF开头。很多初学者反汇编时看到0xEA、0xEB以为这是"某种神秘指令",其实0xEAxxxxxx就是无条件分支B,0xEBxxxxxx是无条件分支BL——高的0xE就是条件码AL。
条件码的实际作用,用一段典型代码就能看明白。传统写法:
if (r0 == 0) r1 = 1;对应普通分支写法需要:
cmp r0, #0 bne skip mov r1, #1 skip:而有了条件执行,可以直接:
cmp r0, #0 moveq r1, #1moveq的编码里,条件码位段取0001(NE的反面就是EQ),处理器译码时发现条件不满足,就直接让这条MOV不写寄存器、不产生副作用,但指令仍然占用一个周期。对于短小分支来说,这比跳转后冲刷流水线要划算得多。条件码的存在让编译器可以生成更紧凑的代码,代价就是每条指令都要白白带4位"条件信息"——也算是ARM编码哲学的一大特色。
2.3 指令类别域:bits[27:25]三位的分类逻辑
再看[27:25]这三位,它是整个ARM指令空间的"总分类号"。DOC里叫它指令类别域,它决定了剩下二十多位怎么解释。
| bits[27:25] | 指令类别 |
|---|---|
| 000 | 数据处理(操作数为寄存器) |
| 001 | 数据处理(操作数为立即数) |
| 010 | 单寄存器Load/Store(LDR/STR) |
| 011 | 未定义/保留 |
| 100 | 多寄存器Load/Store(LDM/STM) |
| 101 | 分支B/BL |
| 110 | 协处理器数据传送(LDC/STC) |
| 111 | 协处理器数据运算/寄存器传送(CDP/MRC/MCR) |
这个划分本身就是设计取舍的体现。000和001都归数据处理,区别只在第25位:这一位代表操作数2到底是"寄存器"还是"立即数"。010是单寄存器访存,100是多寄存器访存,101是分支,剩下110/111全部留给协处理器——当年ARM生态里协处理器是正儿八经的一等公民,FPU、调试单元都靠这个编码空间挂载。
看一个具体例子:mov r0, #1的完整编码是0xE3A00001。最高字节0xE3对应二进制1110 0011,拆开看:条件码1110=AL,[27:25]=001,说明这是"立即数形式的数据处理指令",[24:21]=1101,查opcode表就是MOV。所以哪怕一个初学者第一次看到E3A00001,只要能把位段拆出来,也能读出"无条件执行、立即数、MOV"三层信息。
3. 数据处理指令编码:以MOV、ADD为例拆解"灵活操作数2"
3.1 数据处理指令的标准位段布局与opcode映射
数据处理指令是ARM汇编里出现频率最高的一类,它的编码布局非常规律:
[31:28] cond | 00 | I | opcode | S | Rn | Rd | operand2 4位 2位 1位 4位 1位 4位 4位 12位其中opcode是[24:21]这四位,它直接映射到ARM的16种基本操作。常见对应关系如下:
| opcode | 助记符 | 操作 |
|---|---|---|
| 0000 | AND | Rd = Rn AND op2 |
| 0001 | EOR | Rd = Rn EOR op2 |
| 0010 | SUB | Rd = Rn - op2 |
| 0011 | RSB | Rd = op2 - Rn |
| 0100 | ADD | Rd = Rn + op2 |
| 0101 | ADC | Rd = Rn + op2 + C |
| 0110 | SBC | Rd = Rn - op2 - !C |
| 0111 | RSC | Rd = op2 - Rn - !C |
| 1000 | TST | Rn AND op2,更新标志 |
| 1001 | TEQ | Rn EOR op2,更新标志 |
| 1010 | CMP | Rn - op2,更新标志 |
| 1011 | CMN | Rn + op2,更新标志 |
| 1100 | ORR | Rd = Rn OR op2 |
| 1101 | MOV | Rd = op2 |
| 1110 | BIC | Rd = Rn AND NOT op2 |
| 1111 | MVN | Rd = NOT op2 |
这里面有两个容易混淆的细节。
一是TST/CMP/TEQ/CMN这四类本质上不写结果寄存器,它们只更新CPSR标志位,所以编码时Rd字段通常设为0,S位在汇编器里也总是置1。如果你手工编写机器码,这四类指令的S位必须写1,否则行为不符合预期。
二是S位表示"是否更新条件标志"。同一个opcode、同样的操作数,带不带S,编码就差第20位这一位。add r0, r1, r2和adds r0, r1, r2的区别,仅仅是把S位从0改成1。这一位很重要,因为CMP之类的标志更新正是后续条件执行的基础。
3.2 操作数2的三种形态:立即数旋转、普通寄存器、移位寄存器
操作数2是[11:0]这12位,但它可以表达三种完全不同的东西。这是ARM数据处理指令最精巧也最容易出错的部分。
第一种形态:立即数。当指令编码里I位(第25位)为1时,[11:0]这12位被拆成两部分:高4位[11:8]是rotate旋转量,低8位[7:0]是imm8立即数。实际使用的数值是:imm8逻辑循环右移(2×rotate)位。举个例子,mov r0, #0xFF000000的编码是立即数0xFF,rotate=4,因为0xFF循环右移8位得到0xFF000000。注意rotate字段的粒度是2位,也就是说实际旋转量只能是0、2、4……30这些偶数。
第二种形态:普通寄存器。当I位为0且操作数2的[11:4]全部为0时,[3:0]就是一个寄存器编号,操作数就是该寄存器的值,不做任何移位。此时完整编码看起来就像mov r0, r1的E1A00001。
第三种形态:带移位的寄存器操作数。当I位为0,[11:4]不全为0时,操作数2还可以是"寄存器+立即数移位量"或者"寄存器+另一个寄存器控制的移位量"。这种编码进一步细分:
- [11:7]是5位立即数移位量,[6:5]是移位类型,[4]固定为0,[3:0]是寄存器编号。这样可以得到
lsl #n、lsr #n、asr #n、ror #n。 - [11:8]是移位量寄存器的编号,[6:5]是移位类型,[4]固定为1,[3:0]是被移位的寄存器。这样可以得到
lsl rs、lsr rs等动态移位。
移位类型编码[6:5]:00=LSL逻辑左移,01=LSR逻辑右移,10=ASR算术右移,11=ROR循环右移。特别地,当[6:5]=11且移位量为0时,表示RRX(带进位循环右移一位)。
桶式移位器在编码层面就藏在操作数2里。这意味着一条add r0, r1, r2, lsl #4并不需要额外的指令,它的操作数2形态是:I=0,[11:7]=00100,[6:5]=00,[4]=0,[3:0]=R2编号。整个12位操作数2被这个移位信息填满。
3.3 立即数合法性手工判定:8位加偶数循环右移的边界条件
初学者经常在这里被"合法立即数"难住。因为12位操作数2并不直接就是12位立即数,而是"8位值+4位旋转量",所以不是所有32位常数都能在一条ARM数据处理指令里表达。
怎么判断一个数是否合法?我的经验是反过来看:把这个32位数写成十六进制,然后尝试把它循环右移偶数个位,看能否在某个位置得到一个只有低8位非零的数(即0x000000FF形式)。
几个真实例子能说明问题:
0xFF:直接就是8位,合法,k=0。0xFF00:0xFF循环右移24位得到0x0000FF00,旋转量24是偶数,合法,rotate字段=12。0xFF000000:0xFF循环右移8位得到,合法,rotate字段=4。0xFFF:二进制位0到11全为1,一共12位连续的1,没有任何一个8位窗口能包住,非法。0x102:位1和位9都被置1,循环窗口至少要覆盖9个位位置,超过8位窗口,非法。0x104:位2和位8置1,循环窗口覆盖位2到位8,只需要7个位置,可以放在8位窗口里,实际就是0x41循环右移30位,合法。
这个判定可以用一个简单口诀概括:在一个32位循环空间里,看所有有效位能否被一个连续的8位窗口包住,且窗口起始位置对应偶数循环右移。日常开发中,如果你不确定某个立即数合不合法,最快的办法就是直接写进汇编试试,汇编器会果断报"invalid constant"。
从编码格式的角度看,ARM的这种设计是有历史原因的。8位立即数可以覆盖大部分小常量的使用场景,配合偶数旋转又可以表达许多位掩码,而省出来的4位交给了rotate,让一条32位指令在"表达常数能力"和"其他字段的空间需求"之间取得平衡。代价就是立即数表达范围不连续,这也是后来Thumb-2里引入更大立即数格式的原因之一。
4. 分支与访存指令编码:B/BL的PC+8基准,LDR/STR与LDM/STM位图
4.1 B/BL分支编码与±32MB跳转范围
分支指令B和BL的编码布局相对简单,固定为:
[31:28] cond | 101 | L | offset24 4位 3位 1位 24位L位为0是B,为1是BL。后面24位是有符号偏移量,但这个偏移量不是字节数,而是"以指令为单位的偏移",实际使用时必须左移2位。最终目标地址计算公式是:
目标地址 = 当前指令地址 + 8 + (符号扩展的offset24 << 2)那个+8就是上面提到的PC+8基准。为什么要把基准设在PC+8而不是PC+4或PC?因为ARM经典流水线里,指令在"执行"阶段时,PC寄存器其实已经指向当前指令地址+8的位置了——它已经多预取了两条指令(每条4字节)。硬件上的PC读出来就是这个值,设计者干脆让B指令直接拿这个PC去算偏移。这也是为什么B指令的跳转范围是±32MB:24位有符号数左移2位后,变成26位有效地址范围,正好对应±32MB。
BL还有个特点:它除了跳转,还会把返回地址写入LR(R14)。这个返回地址不是当前指令地址+8,而是当前BL指令的下一条指令地址,即当前地址+4。硬件实现上是把PC值减4存入LR,因为BL执行时读到的PC已经是+8了。如果你在调试手工构造BL指令,一定要记得:LR保存的是"BL下面的那条指令地址",而不是"BL本身地址+8"。
条件执行在分支指令里也有体现。bne label的编码,cond域取0001(NE),L位为0。所以分支和控制流并不冲突——ARM允许你给几乎所有指令加条件码,跳转也可以带条件。
4.2 单寄存器Load/Store编码:12个控制位的含义
LDR/STR单寄存器访存指令的编码,可能是ARM编码格式里信息密度最高的一种:
[31:28] cond | 01 | I | P | U | B | W | L | Rn | Rd | offset12 4位 2位 1位 1位 1位 1位 1位 1位 4位 4位 12位其中[27:25]=010,后面的每一位都有明确作用:
- I(第25位):0表示offset是12位无符号立即数,1表示offset来自寄存器(还可带移位)。
- P(第24位):预变址还是后变址。P=1表示先加上偏移再访存,即
[Rn, #imm];P=0表示先访存后加偏移,即[Rn], #imm。 - U(第23位):加偏移还是减偏移。U=1是加,U=0是减,对应
[Rn, #-imm]。 - B(第22位):字节访问还是字访问。B=1是
LDRB/STRB按字节,B=0是LDR/STR按字。 - W(第21位):是否回写基址寄存器。W=1表示访存之后把新的地址写回Rn(在预变址形式里,这相当于
LDR Rd, [Rn, #imm]!)。 - L(第20位):装载还是存储。L=1是LDR,L=0是STR。
- Rn是基址寄存器,Rd是数据寄存器,offset12是12位无符号偏移,可以是0到4095。
我举个例子来展示这些位如何组合。ldr r1, [r2, #4]的机器码是0xE5921004,拆开看:
E 5 9 2 1 0 0 4 1110 0101 1001 0010 0001 0000 0000 0100 cond=1110 AL [27:25]=010 单寄存器访存 I=0 立即数偏移 P=1 预变址 U=1 加偏移 B=0 字访问 W=0 不回写 L=1 装载 Rn=0010 R2 Rd=0001 R1 offset=0x004这样一条指令就完整描述了"从R2+4地址读一个字到R1"。你在反汇编里看到E5921004,如果能把这个位段拆分过程逆过来,就能直接确定它的语义。
后变址形式要特别留意:当P=0(后变址)时,W位必须置1,硬件手册就要求这样编码。你如果手写机器码把P和W配成P=0、W=0,指令行为是未定义的,调试时会踩一个很大的坑。实际开发中我遇到过通过自制指令编码工具生成后变址LDR的情况,最初就忘了W必须为1,结果一堆奇怪的存储访问异常。
4.3 多寄存器传送LDM/STM:寄存器位图
多寄存器传送指令LDM/STM的编码里,最独特的要数那16位寄存器列表。布局如下:
[31:28] cond | 100 | P | U | S | W | L | Rn | register_list(16) 4位 3位 1位 1位 1位 1位 1位 4位 16位register_list这16位,每一位对应一个寄存器。bit0对应R0,bit1对应R1,……bit15对应R15。这种"位图式"编码非常直观:要传送{R0, R3, R7},就把bit0、bit3、bit7置1,翻译成十六进制就是0x0089。
P、U位的含义和LDR/STR类似,组合起来可以形成IA(每次传送后地址加4)、IB(先加地址再传送)、DA(每次传送后地址减4)、DB(先减地址再传送)。W位控制是否回写基址寄存器,在POP和PUSH这类伪指令展开时,本质上就是LDM/STM再加一个回写标志。
LDM/STM有一个经典用途是函数进入和退出。stmfd sp!, {r4-r11, lr}这条指令展开成机器码后,register_list里包含了R4到R11、LR的对应位,W=1表示sp要回写更新,L=0表示存储方向。这个"位图+回写"的编码设计,让一条指令就能完成一次栈帧的保存和恢复,无需逐条push/pop。
调试LDM/STM时有一个常见误区:寄存器列表的位序和编号不匹配。bit0对应R0,bit15对应R15,但如果你在列表里写{r0-r3},编码里的低4位全1,很容易算错成0x0F之外的数。建议手工计算时,先把寄存器列表写成"低位到高位"的二进制,再转十六进制,不要跳步。
5. 手把手拆解机器码:从汇编指令到二进制再到objdump验证
5.1 人工编码全过程:4条指令逐步翻译
理论讲再多,不如亲手拆几条。我准备了一套在ARM Linux环境或者裸机环境下都能跑的汇编片段,逐条人工翻译成机器码。
假设我们有一段代码如下:
.arm _start: mov r0, #1 subs r3, r3, #1 ldr r1, [r2, #4] bne loop loop: nop一条条来。
第一条:mov r0, #1
- 条件码:无条件执行,cond=1110
- [27:25]=001:立即数形式的数据处理指令
- opcode=1101:MOV
- S=0:不更新标志
- Rn=0000:MOV里Rn不用,置0
- Rd=0000:R0
- 操作数2:立即数1,rotate=0,imm8=0x01,合并为0x001
拼接起来:
1110 0011 1010 0000 0000 0000 0000 0001转换成十六进制,从高字节开始:E3 A0 00 01,完整就是0xE3A00001。这跟实际汇编结果完全一致。
第二条:subs r3, r3, #1
- cond=1110
- [27:25]=001:立即数形式
- opcode=0010:SUB
- S=1:subs带S,更新标志
- Rn=0011:R3
- Rd=0011:R3
- 操作数2:立即数1,rotate=0,imm8=0x01
拼接:
1110 0010 0101 0011 0011 0000 0000 0001即0xE2533001。注意这里第20位(S位)是1,所以指令英文写出来是subs r3, r3, #1。
第三条:ldr r1, [r2, #4]
这个在前面已经拆解过,编码是0xE5921004。
第四条:bne loop
假设bne loop位于地址0x0C,而loop标签在0x08。bne的目标是0x08,当前指令地址是0x0C。利用公式:
offset = (目标地址 - PC - 8) / 4 = (0x08 - 0x0C - 8) / 4 = (-12) / 4 = -324位有符号偏移为-3,即0xFFFFFD。指令的cond取NE=0001,[27:25]=101,L=0:
0001 1010 1111 1111 1111 1111 1111 1101即0x1AFFFFFD。这里可以看到,因为目标地址小于当前地址,偏移量是个负数,编码里所有高位都是F,这正是24位偏移量按补码表示的体现。
5.2 反汇编验证与常见偏差
我习惯用GNU工具链验证手工编码结果。假设把上面这段汇编保存成test.s,执行:
arm-none-eabi-as -o test.o test.s arm-none-eabi-objdump -d test.o输出的反汇编结果大概是这样:
00000000 <_start>: 0: e3a00001 mov r0, #1 4: e2533001 subs r3, r3, #1 8: e5921004 ldr r1, [r2, #4] c: 1afffffd bne 8 <_start-0x4> 10: e1a00000 nop对照一下:前三条完全一致,第四条bne的目标反汇编显示为8 <_start-0x4>,跟我们手算的0x08完全吻合。这种"自己算→工具验证"的过程,是建立ARM编码直觉最有效的方法。
不过我在这里遇到过一个典型偏差:地址数值的基准。反汇编器显示的地址是链接后的虚拟地址,而我们手工写汇编片段时用的是段内偏移。如果你把段地址换成0x8000,偏移计算里PC+8的基准就变成0x8000+8,不能直接用段内偏移套。这也是为什么我建议刚开始练习时用-Ttext=0x8000这类链接选项固定一下地址,否则容易把PC+8算错。
另一个常见偏差是ARM模式与Thumb模式的符号混用。如果一个文件里用了.thumb指令切换,反汇编器会以16位为单位解释后续字节,这时你再拿32位ARM编码去解读,整段的指令边界都会错位。手动指定.arm和.thumb伪指令,可以避免这种混乱。
6. 编码格式实战避坑:Thumb切换、条件码AL、立即数与偏移的坑
6.1 Thumb模式与ARM模式的编码差异
ARM处理器还有一个Thumb指令集状态,这是很多调试事故的策源地。Thumb模式下指令是16位宽度(Thumb-2扩展后可以是32位),和ARM模式的32位固定编码完全不同。
通俗理解:ARM模式是"所有指令都是32位,每条都带条件码,功能全面";Thumb模式是"大部分指令16位,条件码被裁剪,只有少数分支指令能带条件,目的是节省存储空间"。
这两套编码在同一个进程里是可以互相切换的。切换指令通常是bx、blx,切换依据是目标地址的最低比特位(LSB):LSB为1表示切到Thumb模式,LSB为0表示保持在ARM模式。这就带来一个经典坑:看反汇编时,目标地址的最后一位不是真实地址,而是一个模式标志。如果你把一个Thumb函数入口的地址拿去手动加偏移,忘记把LSB去掉,计算出的地址永远差1,跳转就会莫名其妙错位。
编码格式视角下,Thumb和ARM是两套完全独立的指令编码空间,不能靠"猜测"互相翻译。碰到混合代码时,我建议先在反汇编器里确认当前段的<.arm>还是<.thumb>注释,再决定用哪套位段规则去解析。
6.2 我踩过的三个具体错误
第一个错误是把条件码AL当成0xF。有一次我调试一份启动代码,为了在某个分支处手动打补丁,需要把一条B指令改成无条件B。我当时想当然地把高位改成0xF,结果从指令看起来是0xFAxxxxxx,执行时直接进入未定义指令异常。原因就是条件码AL的正确编码是0xE,不是0xF。0xF开头的很多是特殊指令或保留编码。这个错误几乎每个手写过ARM机器码的人都犯过,我后来养成了一个习惯:任何无条件指令,先默念一遍"AL等于E"。
第二个错误是立即数合法性判断失误。我曾经想在一段热代码里快速把一个常数0xFFF加载进寄存器,直接写mov r0, #0xFFF,汇编器报错。我当时还不服气,觉得0xFFF这么"整齐"的数怎么会不行。后来对照编码才明白,0xFFF需要12位连续1,但立即数操作数2的8位窗口根本包不住12位。正确做法是先mov r0, #0xFFF拆成mov r0, #0xFF和orr r0, r0, #0xF00,或者干脆用ldr r0, =0xFFF让汇编器帮你构造文字池。
第三个错误是B指令偏移忘了乘以4。有一次我模仿一个跳转逻辑,手写了一个B指令,偏移量直接填了字节差值,结果跳转目标差了3条指令。原因就是B编码里的24位偏移是以"条"为单位,实际地址偏移必须左移2位。我在调PC+8的时候把"基准"和"单位"两个概念混在一起了。建议在实际工程里尽量少手写机器码,真要写的话,先用汇编器生成正确版本,再反过来看我写的差异在哪。
6.3 查证与自查工具链建议
编码格式的东西,光靠背很容易忘,也容易记串。我现在的做法是建立一条"手册→工具→验证"的快速链路。
首先,准备一份权威的ARM架构参考手册,不需要整本读,重点看指令编码那几章就行。然后准备一套GNU交叉工具链,arm-none-eabi-as和arm-none-eabi-objdump是手头上最趁手的检查工具。写一小段汇编,用objdump -d把机器码和反汇编对照,再手动拆开位段核对手册,这是我认为最高效的记忆方式。
如果不想搭本地工具链,也可以使用在线汇编网站快速验证单条指令的编码,但注意有些线上工具不会给你完整的objdump风格输出,只能看到十六进制机器码,这时候更要懂得自己拆位段,否则验证也不到位。
调试软件时,gdb的x/iw命令可以按32位指令格式查看内存内容,再用x/5i反汇编当前PC附近的几条指令,这在分析crash现场时特别有用。至于查表,我强烈建议把条件码表打印出来放在显示器旁边,或者设成手机壁纸,看多了自然就记住了。
最后一个实用心得:不要从零去背整张指令编码表,先记住三类核心指令的骨架——数据处理、LDR/STR、B/BL,剩下的比如协处理器、MRS/MSR、多寄存器传送都可以在遇到时再查。编码格式的关键不是"背下所有细节",而是"遇到一段十六进制时知道每个位段去哪儿查"。掌握了这个能力,再复杂的ARM二进制在你眼里也就是一张张结构清晰的表格。