想弄明白 ARM 汇编的人,可能是在调试一个诡异的崩溃栈,也可能是在做底层性能优化,又或者是想搞懂编译器生成的二进制到底是怎么跑起来的。这件事确实不该绕开——ARM 架构的手机、路由器、嵌入式设备遍地都是,而汇编是唯一能让你看清“机器到底在按什么规则运转”的视角。这篇内容会从寄存器模型、指令分类、寻址方式一路讲到函数调用约定和反汇编实操,配合具体指令序列和调试经验,帮你在头脑里建立一张 ARM 汇编的全景图。
1. 为什么 ARM 汇编跟 x86 完全不是一回事
先放下指令细节,说说思维模型上的差异。如果你之前只接触过 x86 汇编,上手 ARM 的第一感觉应该是“别扭”,但这种别扭恰恰是 ARM 设计哲学的直接体现:精简指令集,每条指令干一件简单明确的事,大量操作通过通用寄存器完成,而不是背着沉重的内存寻址包袱。
1.1 CISC 与 RISC 的分水岭
x86 属于 CISC(复杂指令集计算机),一条指令能完成加载、计算、存储好几个动作,指令长度不固定,历史包袱重。ARM 属于 RISC(精简指令集计算机),指令长度固定(ARM 模式 4 字节,Thumb 模式 2 字节),指令格式规整,绝大多数指令在一个周期内可以完成执行。
这个差异带来的直接后果是:x86 的汇编代码看起来“每条指令干很多事”,而 ARM 的汇编代码看起来“指令很多,但每一条都特别单纯”。举个例子,x86 里一条add eax, [ebx+4]既做了内存读取又做了加法,ARM 里你得先ldr r0, [r1, #4]把内存读到寄存器,再add r0, r0, r2做加法。
1.2 条件执行:ARM 最出名的差异化设计
ARM 处理器里有一个状态寄存器叫 CPSR(当前程序状态寄存器),其中 NZCV 四个标志位分别表示结果为负数(Negative)、为零(Zero)、产生进位(Carry)、发生溢出(oVerflow)。x86 也有标志位,但只能在少数跳转指令里用到。
ARM 不一样,绝大多数指令都可以带条件码。比如addeq r0, r1, r2,只有当上一次比较结果相等(Z=1)时才执行加法。这意味着编译器可以省掉很多分支跳转,把短小的if-else直接转换成条件执行指令序列,避免流水线冲刷,这也是 ARM 在低功耗场景下表现出色的原因之一。
1.3 理解移位操作的内置特性
ARM 的另一个特色是“操作数灵活”:数据处理指令的第二个操作数可以先经过桶形移位器再参与运算。mov r0, r1, lsl #3表示把 r1 逻辑左移 3 位后放入 r0,等效于乘以 8。这在做数组索引、位图处理时特别方便,一条指令完成“移位+运算”。
这种设计背后是硬件上的“桶形移位器”(barrel shifter),它可以在一个时钟周期内完成任意位数的移位。理解这一点,你就能明白为什么 ARM 汇编里经常看到ldr r0, [r1, r2, lsl #2]这种写法——它在读取内存的同时完成基址+偏置的计算,高效且省电。
2. 寄存器模型与处理器模式:先搞懂手里的牌
ARM 是加载/存储架构,运算都发生在寄存器里,所以你把寄存器模型背下来,汇编就算学会了一半。32 位 ARM 有 16 个通用寄存器(r0-r15),加上 CPSR,一共 17 个程序员可见的寄存器。名字上偶尔会看到别名写法,比如 r13 常叫 sp,r14 常叫 lr,r15 常叫 pc,但本质就是同一组物理寄存器。
2.1 通用寄存器的作用分工
| 寄存器 | 别名 | 主要用途 |
|---|---|---|
| r0-r3 | 无 | 传参、返回值,函数调用时前四个参数按顺序放入 r0-r3 |
| r4-r11 | 无 | 函数内部的局部变量、临时值,被调用方需保存(callee-saved) |
| r12 | ip | 函数内部的临时寄存器,也被称为 intra-procedure-call scratch register |
| r13 | sp | 栈指针,指向当前栈顶 |
| r14 | lr | 链接寄存器,保存函数返回地址 |
| r15 | pc | 程序计数器,指向当前取指地址 |
这里有一个特别容易让人困惑的细节:ARM 的 pc 在 ARM 模式下指向的是“当前指令 + 8”,也就是向后两条指令的地址。这是因为 ARM 采用三级流水线:取指、译码、执行并行推进,pc 读到的其实是取指阶段的地址。老版本的 ARM 文档专门强调过这个偏移,新的 ARMv7 文档说 pc 指向当前指令地址,但实际上读 pc 仍然会有 +8(ARM 状态)或 +4(Thumb 状态)的偏移量。写汇编时如果依赖 pc 做位置无关计算(PIC),必须把这个偏移算进去。
2.2 处理器模式与寄存器备份
ARM 处理器不止一种工作模式,常见的有用户模式(USR)、系统模式(SYS)、中断模式(IRQ)、快速中断模式(FIQ)、管理模式(SVC)等。除了用户模式和系统模式,其他特权模式都有自己独立的 sp 和 lr 备份。
为什么要做寄存器备份?假设内核运行在 SVC 模式下,外部中断来了,处理器切到 IRQ 模式,IRQ 模式有自己的 sp 和 lr,不会覆盖 SVC 模式正在使用的栈指针和返回地址。这种硬件级隔离让操作系统的上下文切换变得简单可靠。你在用户态写普通应用程序,接触到的通常只是用户模式,但如果做裸机开发或者写内核驱动,就必须理解模式切换时寄存器的变化。
2.3 CPSR 与程序状态控制
CPSR 的低 8 位是控制位,包含工作模式位(M[4:0])、中断屏蔽位(I 和 F)、Thumb 状态位(T)。NZCV 四个条件标志位在高四位区域。修改 CPSR 的指令通常是msr(把通用寄存器写入状态寄存器)和mrs(把状态寄存器读到通用寄存器)。
调试时最常用的操作是mrs r0, cpsr然后把 r0 打出来看标志位,特别是刚执行完比较指令后,通过检查 Z、C 标志位来确认跳转条件是否符合预期。
3. 指令系统拆解:数据处理、访存与分支
ARM 指令按功能可以大致分为数据处理指令、访存指令、分支指令、协处理器指令和异常相关指令。对初学者来说,先吃透前三类就够了,它们覆盖了 95% 以上的日常分析场景。
3.1 数据处理指令
这类指令的特点是“操作数来自寄存器或立即数,结果写回寄存器”,不访问内存。核心指令包括:
mov:赋值,mov r0, #0x10把立即数 0x10 装入 r0add/sub:加减法,add r0, r1, r2表示 r0 = r1 + r2and/orr/eor:按位与、或、异或cmp:比较,执行 r1 - r2 但不写回结果,只更新 CPSR 标志位tst:测试,执行 r1 & r2 但不写回结果,只更新标志位mul:乘法,注意 32 位 ARM 的乘法指令对操作数有限制,乘积寄存器不能跟第一个操作数相同
让我用实际的指令序列说明一下:
; 计算 r0 = (r1 * 8) + (r2 & 0xFF) mov r3, r1, lsl #3 ; r3 = r1 * 8,利用桶形移位器 and r4, r2, #0xFF ; r4 = r2 & 0xFF add r0, r3, r4 ; r0 = r3 + r4这里mov r3, r1, lsl #3就是 ARM 独有的风格,x86 里你得先移位再移动,这里一条指令搞定。
3.2 访存指令
ARM 的访存指令只有两类核心:ldr(load register,从内存读入寄存器)和str(store register,从寄存器写入内存)。按访问宽度细化有ldrb(读一个字节)、ldrh(读半个字,2 字节)、ldr(读一个字,4 字节),写内存对应的有strb、strh、str。
基本的寻址格式是ldr r0, [r1, #offset]。这里的含义是:有效地址 = r1 + offset,然后把该地址处的一个字读入 r0。偏移量可以是正数也可以是负数,比如ldr r0, [r1, #-4]就是读取 r1-4 处的内容。
一个新手常犯的错误是混淆ldr r0, =0x40000000和ldr r0, [r1, #0x10]。前者是“伪指令”,汇编器会把立即数放到常量池里,然后生成一条真正读取常量池的ldr;后者是标准的基于寄存器偏移的访存指令。两者的语法只差一个方括号,含义完全不同。
批量访存指令ldm/stm是一次操作多个寄存器的指令,多用于函数开头保存现场和结尾恢复现场。举个例子:
stmfd sp!, {r4-r11, lr} ; 把 r4 到 r11 以及 lr 压入栈 ldmfd sp!, {r4-r11, pc} ; 从栈弹出,同时把 lr 的值赋给 pc 实现返回fd是 full descending 的意思,表示栈从高地址向低地址增长,sp 指向栈顶已使用的位置。这是 ATPCS(ARM 的调用约定)里默认的栈规则。
3.3 分支指令与 IT 块
分支指令包括b(无条件跳转)、bl(跳转并保存返回地址到 lr)、bx(跳转并切换指令集)、blx(跳转、保存返回地址并切换指令集)。
b label是最基本的跳转;bl func是“调用函数”,等于mov lr, pc-8; b func的合体;bx lr是“从函数返回”,因为 lr 里存的就是调用方下一条指令的地址。
Thumb-2 指令集引入了it(if-then)指令块,用来在 Thumb 模式下实现条件执行。例如:
cmp r0, #0 itte eq ; IF-THEN-THEN-ELSE:接下来四条指令,前两条 eq 时执行,最后一条 ne 时执行 moveq r1, #1 ; 如果 r0 == 0,则 r1 = 1 addeq r2, r2, #1 ; 如果 r0 == 0,则 r2 = r2 + 1 movne r1, #0 ; 如果 r0 != 0,则 r1 = 0裸机开发里经常见这类代码,读反汇编的时候看到ittt、itete之类的毫秒级符号,别困惑,它的正确读法是“if-then”的缩写,后面跟的每个t代表一条条件执行指令,e代表 else 分支指令。
4. 寻址方式与常见陷阱:别在地址计算上翻车
ARM 的寻址方式不算多,但还是那句话,细节里全是坑。掌握寻址方式的核心在于:偏移量到底是加在访存之前还是之后。
4.1 前变址、后变址与自动回写
先看三类基础寻址:
- 前变址(pre-indexed):
ldr r0, [r1, #4],有效地址 = r1 + 4,然后访问内存。指令执行完后 r1 的值不变。 - 后变址(post-indexed):
ldr r0, [r1], #4,有效地址 = r1,访问内存,然后 r1 = r1 + 4。这种写法常用于数组遍历,一条指令既读取数据又移动指针。 - 自动回写(writeback):
ldr r0, [r1, #4]!,有效地址 = r1 + 4,访问内存,然后把 r1 更新为 r1 + 4。感叹号表示“回写基址寄存器”。
自动回写的底层的机制是:CPU 先算有效地址,再决定是否把计算结果写回基址寄存器,这个回写动作是硬件完成的,不需要额外指令。用!的写法能让代码更紧凑,但代价是语义不那么直观,读代码时要特别留神。
这三类寻址看起来简单,实际调试时很容易搞混。我见过很多次这样的错误:想遍历一个数据块,写成了ldr r0, [r1], #4,但后面又用ldr r0, [r1]重复读同一个位置,因为编译器生成的代码跟自己手写的不一致,行为就对不上。用调试器单步执行时,一定要盯住 sp、r1 这类基址寄存器的实时变化。
4.2 立即数的合法范围:一个让许多编译器新人懵圈的规则
ARM 的数据处理指令支持立即数,但立即数不是随意给的。ARM 立即数的编码格式是“8 位常数 + 4 位循环右移量”,也就是说,合法的立即数是通过把一个 8 位数值(0-255)循环右移任意偶数位得到的。
举例来说,0x3FC是合法的,因为 0xFF 循环右移 2 位就是 0x3FC(实际是 0x000003FC,移位逻辑上是 8 位循环右移加零扩展)。但0x12345678就不是合法的立即数,你需要先ldr r0, =0x12345678(汇编器自动放入常量池)或者用多条指令拼接。判断时有个速算法则:立即数的二进制表示中出现“1”的位宽跨度,如果超过了 8 位(在循环移位意义下),基本就得走常量池了。
这个规则对逆向分析影响很大:你在反汇编里看到ldr r0, [pc, #offset],往下翻数据段常能看到一个 32 位完整数值,这个就是常量池条目,不是真正的基于 pc 的访存指令。
4.3 PC 相对寻址与位置无关代码
ldr r0, [pc, #offset]这条指令用于读取与当前 pc 相对偏移处的数据。因为偏移量是相对固定的,代码被加载到任意内存地址都能正确运行,与它的加载地址无关。这就是位置无关代码(PIC)的基本原理,常用于共享库、启动代码。
计算偏移时要记住 pc 的 +8 偏移(ARM 状态)。假设某条指令地址是 0x8000,执行这条指令时 pc 实际值是 0x8008,如果偏移是 #4,那么读取的是 0x800C 处的数据。反汇编工具一般会帮你算好注释里的实际地址,但你自己写汇编或用脚本分析 raw binary 时,这个 +8 偏移就会冒出来坑你一次。
5. 函数调用约定:读懂 callee-saved 与 caller-saved
在 ARM 世界里,函数调用的规则被定义在 AAPCS(ARM Architecture Procedure Call Standard)里。理解这套规则,是读懂任何一段 ARM 反汇编代码的钥匙。
5.1 寄存器角色再梳理
AAPCS 把通用寄存器分成三个阵营:
- 参数寄存器 r0-r3:调用方把前四个参数放进 r0-r3,如果参数超过四个,多余参数按顺序压栈。返回值放在 r0(32 位值)或 r0-r1(64 位值)。
- 被调用方保存寄存器 r4-r11:被调用的函数如果要用 r4-r11,必须先保存原值(通常压栈),返回前恢复原值。对于调用方来说,调用完一个函数后,r4-r11 的值不会变。
- 临时寄存器 r12(ip)以及 r0-r3:调用方不保证这些寄存器在函数返回后保持不变,被调用方可以随意使用。
这个设计的本质是:谁用谁保存。参数寄存器是“易失的”,因为函数返回值本来就要覆盖 r0,而 r4-r11 是“非易失的”,因为被调用方有义务保持它们不被破坏。
5.2 一段典型函数的反汇编阅读
来看一段经典的 C 代码编译产物(简化):
int add_and_multiply(int a, int b) { return (a + b) * 2; }对应 ARM 汇编可能是:
add_and_multiply: add r0, r0, r1 ; r0 = a + b mov r0, r0, lsl #1 ; r0 = r0 * 2 bx lr ; 返回,lr 里是调用方的下一条指令地址没有栈操作!因为参数直接在 r0、r1 里,局部计算也用不上 r4-r11,所以不需要保存任何寄存器。这是 ARM 函数里最常见的形态:短小、无栈、直接返回。
再看一个需要栈操作的函数:
int complex_calc(int a, int b, int c, int d, int e) { int local = a + b; return local * c + d - e; }这是一个超过 4 个参数的函数,第 5 个参数 e 会被调用方压栈。汇编可能长这样:
complex_calc: push {r4, r5, lr} ; 保存 r4、r5 和返回地址 ldr r4, [sp, #12] ; 读取第 5 个参数 e(在栈上) add r5, r0, r1 ; local = a + b mul r5, r5, r2 ; local = local * c add r5, r5, r3 ; local = local + d sub r0, r5, r4 ; r0 = local - e,作为返回值 pop {r4, r5, pc} ; 恢复 r4、r5,同时把 lr 弹入 pc 返回注意这里push {r4, r5, lr}压入 3 个寄存器(12 字节),第 5 个参数 e 在调用方眼里位于“sp + 4 + 8”处——因为调用方执行bl complex_calc时,pc 压栈的关系和栈布局不同。实际调试时,第 5 个参数的位置要用 sp 偏移一点点推算,这是新手最容易算错的地方。
5.3 栈帧布局:高地址到低地址
AAPCS 要求栈是满递减的(full descending),sp 指向栈顶元素。函数入口push {r4-r11, lr}后,sp 向低地址移动,栈上的寄存器布局从高地址到低地址依次是:lr、r11、r10、...、r4。图 1 的思维模型是:越晚压栈的寄存器越靠近低地址(因为栈向下增长)。
注意:AAPCS 规定在函数入口处 sp 必须 8 字节对齐(因为要兼容某些依赖双字加载的指令)。如果函数只压栈了奇数个寄存器,编译器会在
push指令里多压一个不用的寄存器,或者用额外的sub sp, sp, #4保持对齐。反汇编时看到多压入一个看似没用的 r7,别觉得奇怪,这是对齐要求。
6. 实操:从 ARM 汇编到逆向调试的心法
纸上谈兵到此为止。接下来分享的是我平时看 ARM 反汇编和调崩溃的一些实际套路。
6.1 把 C 和汇对照着看的习惯
学习阶段最有用的方法,是把一段 C 代码编译成 ARM 汇编,再一行行对回去。在 Linux 下可以用:
# 编译成汇编,不链接 arm-linux-gnueabi-gcc -S -O2 -marm example.c -o example.s # 或者直接反汇编目标文件 arm-linux-gnueabi-objdump -d example.o如果用-O0优化关闭,生成的汇编会又长又啰嗦,不适合学习;用-O2优化开启的真实汇编才能真正反映 CPU 实际执行的指令。
一个很好的练习是把a[i] += 1这样的代码编译成汇编,观察索引计算(通常就是ldr加移位)、读入寄存器、自增、写回的过程。你会惊讶地发现,一次高级语言操作在底层要展开成 4-5 条指令,而每条指令都那么机械、那么清晰。
6.2 崩溃栈回溯的汇编视角
程序崩溃时,调试器给出的 backtrace 本质上是沿着栈帧里的“保存的 lr/pc 链”回溯调用历史。所以理解函数调用的汇编级实现,对你定位崩溃点有直接帮助。
比如在 GDB 里看到这样的输出:
#0 0x000103f8 in crash_func () #1 0x000104cc in caller_func ()你可以查看caller_func的汇编,看 0x000104cc 是哪一条bl调用的下一条指令。如果崩溃发生在crash_func内,把 PC 对应到具体指令上,再看这条指令访问了什么内存区域,往往就能定位是空指针、非法地址还是越界写。
6.3 我踩过的几个真实深坑
坑一:Thumb 与 ARM 状态混用。某些工具链默认生成 Thumb-2 指令,你反汇编时如果不指定指令集模式,会出现一堆乱码。用objdump -d -M force-thumb或-M force-arm可以强制切换,但最重要的是理解函数地址的最低有效位:如果函数指针 LSB 是 1,说明目标函数是 Thumb 代码,bx跳转时会自动切换状态。手工写汇编时,bx lr能正确返回就是因为 lr 的低位被bl指令正确写入了对应的状态位。
坑二:立即数溢出误判。在写mov r0, #300时,有些汇编器会报错,有些会静默改成ldr r0, [pc, #...]+ 常量池条目,不会报错。你以为是合法的立即数,其实生成的是内存读取指令。调试时单步执行,看到 pc 跳到自己写的数据区域,才反应过来是常量池在作怪。
坑三:用 sp 保存临时值,忘了恢复。新手写汇编容易图省事,直接str r0, [sp]把临时值放到栈顶,但在函数返回时忘记调整 sp,导致pop {r4, r5, pc}从错误的位置恢复寄存器,程序直接跑飞。正确做法是:push/pop严格配对,或用sub sp, sp, #N预留栈空间,并在返回前add sp, sp, #N恢复。
坑四:confusing 的ldr伪指令与常量池。我在前面提到过ldr r0, =0x12345678和ldr r0, [r1]的区别。还有一个边角情况是汇编器会让伪指令开辟的常量池自动对齐到 4 字节边界,在分析裸机固件时,看到.word 0x12345678出现在代码段里不要惊讶,那就是某个ldr =常量池条目。
7. 条件标志位与分支优化:编译器为什么这样写
阅读编译优化后的 ARM 代码,你经常会看到很多让人“迷糊”的条件执行序列。这里讲清楚背后的逻辑,能让你省去大量猜测。
7.1 条件码后缀速查
ARM 指令的条件后缀有几十个,但最常用的就那么几个:
| 后缀 | 条件 | 标志位判定 |
|---|---|---|
| eq | 相等 | Z = 1 |
| ne | 不相等 | Z = 0 |
| cs/hs | 无符号大于等于 | C = 1 |
| cc/lo | 无符号小于 | C = 0 |
| mi | 负数 | N = 1 |
| pl | 正数或零 | N = 0 |
| vs | 有符号溢出 | V = 1 |
| vc | 无溢出 | V = 0 |
| gt | 有符号大于 | Z = 0 且 N = V |
| le | 有符号小于等于 | Z = 1 或 N != V |
| ls | 无符号小于等于 | C = 0 或 Z = 1 |
| hi | 无符号大于 | C = 1 且 Z = 0 |
要特别注意有符号和无符号的区分:同样两个数比较,如果按无符号处理用cmphs,按有符号处理用cmpgt。ARM 的cmp指令本身并不知道操作数是“有符号”还是“无符号”,它只是做减法并更新标志位,后续用什么条件后缀完全由程序员/编译器决定。
7.2 条件执行与分支预测的权衡
现代 ARM 处理器普遍采用分支预测,无条件跳转的成本越来越低,但条件执行仍然有价值,尤其是在需要隐藏控制流信息的安全场景,以及极端短小的分支中能避免流水线冲刷。
举一个常见模式:
if (a > b) { c = 1; } else { c = 2; }编译器可能生成:
cmp r0, r1 movgt r2, #1 movle r2, #2两条mov都是条件执行,没有跳转指令,流水线完全不打断。但如果分支内部逻辑比较复杂,条件执行会白白浪费指令周期(因为条件失败的那些指令还是要走取指和译码阶段),编译器会改为传统跳转。
理解这个权衡,读优化代码时就多了一条线索:看到一串条件执行指令,说明编译器认为分支很短;看到常规跳转,说明分支体量大,或者预测成功率高。
7.3 标志位污染问题
条件执行有个隐含风险:两条连续的条件指令中,第一条执行指令可能改变标志位,影响后续条件判断。ARM 对此有精细的规定:数据处理指令默认更新标志位,但可以通过加s后缀控制更新的指令很少。实际上 ARM 的数据处理指令在带条件后缀时也能写标志位,这导致编译器必须精心编排指令顺序,避免标志位被意外覆盖。
比如:
cmp r0, r1 addeq r2, r3, #1 ; 这条指令会更新 NZCV 标志位 moveq r4, #5 ; 此时 eq 条件检查的是 addeq 的结果,不是 cmp 的结果这是新手最容易困惑的点。解决思路是:需要保留“比较结果”时要立刻使用,或者重新比较;如果后续条件跟当前计算无关,别在那两条指令之间塞一个会改标志位的指令。某些 ARM 汇编器(如高版本的 Gas)会对这种“标志位污染”的用法给警告,但默认情况它还是放行。
8. 常用工具链与上手建议
最后聊一下怎么把知识落地。工具选择上,不同平台各有侧重,但核心套路是一样的:写一段 C,编译成汇编,对照观察。
8.1 本机实验最快路径
如果你的开发机是 ARM 架构(比如树莓派或 ARM 云主机),直接用系统自带工具就行:
gcc -S -O2 example.c # 生成汇编 gcc -c example.c objdump -d example.o # 反汇编目标文件如果机器是 x86,则用交叉工具链:
sudo apt install gcc-arm-linux-gnueabi arm-linux-gnueabi-gcc -S -O2 example.c arm-linux-gnueabi-objdump -d example.o注意不同交叉工具链的默认指令集可能不一样,有些默认生成 Thumb,有些默认生成 ARM 模式,建议用-marm或-mthumb显式指定。
8.2 读反汇编的三个技巧
第一,看结构先看函数边界。反汇编器会用push和pop/bx lr来标记函数边界,找到匹配关系,一段代码的画风就清晰了。
第二,看数据流先找 str/ldr。一条str r0, [sp, #8]说明这里把计算结果写到了栈上的局部变量区,顺着这个地址找对应的ldr,能还原变量在内存中的位置。
第三,看控制流先找 bl。bl到哪个地址,就是调用哪个函数。结合符号表或字符串引用,能很快还原函数调用关系。
8.3 从汇编“逆推”C 代码的练习
一个高效的练习是把一段编译器生成的汇编尝试还原成等价的 C 代码,并注意保持语义一致但不苛求一模一样。举个例子,看到这段:
ldr r0, [r3, #4] cmp r0, #0 beq .L_false mov r0, #1 b .L_end .L_false: mov r0, #0 .L_end:基本可以还原成:
if (arr[1] != 0) { return 1; } else { return 0; }但更贴近原始代码的可能是一个布尔表达式return !!arr[1]或者return arr[1] != 0,具体形式取决于编译器优化选项和源码。多练几次这样的“逆推”,你会对高级语言到机器指令的映射形成肌肉记忆。
9. 结语之外:一份属于自己的速查卡
写到最后,我把自己平时放在手边的 ARM 汇编速查要点再整理一份,希望对你有用。
- ARM 是 RISC,指令定长,运算全在寄存器里进行,访存必须显式用
ldr/str。 - r0-r3 传参,r0 返回值,r4-r11 被调用方保存,r13 是 sp,r14 是 lr,r15 是 pc。
bl调用把返回地址写入 lr,返回用bx lr。- ARM 状态 pc = 当前指令 + 8,PIC 计算偏移时要算上这个偏移量。
- 数据处理指令的第二个操作数可以经过桶形移位器,一条指令完成移位+运算。
- 立即数必须是“8 位值循环右移偶数位”,否则放入常量池。
- 条件后缀加在指令尾部,
cmp不写回结果,只更新标志位。 ldr r0, [r1, #4]和ldr r0, [r1], #4的区别是基址回写时机,一个前变址一个后变址,调试时盯住 sp/r1 的变化。- Thumb-2 的
it块在反汇编里很常见,ittt eq后面的三条指令都要按 eq 条件解读。 - 编译器会为了 8 字节栈对齐“多压”一个寄存器,别误读成有额外变量。
从第一次在调试器里看清一条ldr的访存地址,到能流畅阅读整段函数的反汇编,这个过程不需要死记硬背全部指令,关键是抓住“数据从哪来、运算在哪做、结果存到哪、控制流怎么走”这条主线。ARM 汇编看着指令多,实际上你日常用到的类型就那十几种,反复接触几周,就能形成稳定的阅读直觉。我也建议你挑一个真实项目——比如自己写过的排序算法、CRC 校验函数——强制自己读一读对应的反汇编代码,这种感觉会比任何教程都直观。