ARM汇编基础¶
Abstract
-
ARM 是一系列指令集架构的名称, 本文以AArch32为主, 介绍传统
r0-r15寄存器和 ARM / Thumb 指令状态. -
汇编指令直接操作寄存器、内存和条件标志. 读代码时先看操作数方向, 再跟踪数据如何流动.
-
函数之间如何传参、保存寄存器和使用栈由AAPCS规定, 并不是
BL指令自动完成的.
AArch32 与 AArch64¶
“ARM”既可能指处理器家族, 也常被用来泛指其汇编代码. 阅读代码前, 应先确认执行状态和寄存器命名:
| 名称 | 位宽与寄存器 | 说明 |
|---|---|---|
| AArch32 | 32 位通用寄存器, 常写作 r0-r15 | 本笔记的主要范围. 常见于较早的 ARM 处理器和一些兼容 AArch32 的 ARMv8-A 处理器. |
| ARM state | AArch32 的一种指令状态 | 传统 ARM 指令通常为 32 位编码. |
| Thumb state | AArch32 的另一种指令状态 | 指令使用 16 位编码, 或在 Thumb-2 中混用 16 / 32 位编码, 以减小代码体积. |
| AArch64 | 64 位执行状态, 常写作 x0-x30、sp | 是不同的指令集状态, 指令和 ABI 都与 AArch32 不同, 不应把 r0-r15 的规则直接套过来. |
AArch32 代码中, 同一个处理器可以在 ARM state 和 Thumb state 之间切换. BX / BLX 等分支指令可根据目标地址最低位选择状态. AArch64 则不是“把 r0 改名成 x0”这么简单, 而是使用另一套指令编码、寄存器和调用约定.
本文使用 Linux / ELF 上的 GNU 工具链和 GNU as 语法. 不同处理器版本、操作系统和 ABI 可能有额外差异, 阅读裸机、Android 或 Windows 代码时, 还要核对相应平台约定.
汇编文本结构概述¶
GNU 汇编文件由标签、指令和汇编器指令组成:
.syntax unified @ 汇编器指令: 使用统一语法
.text @ 汇编器指令: 后续内容放入代码节
.global add @ 导出符号 add, 供链接器使用
.type add, %function @ 标记 add 是函数
add: @ 标签: 表示当前位置的地址
add r0, r0, r1 @ CPU 指令: r0 = r0 + r1
bx lr @ CPU 指令: 返回调用者
-
.syntax unified、.text、.global、.type是汇编器指令, 用于组织和标注目标文件, 不会作为普通 CPU 指令执行. -
add:是标签, 后续指令可以通过它引用该位置. -
@用于 GNU AArch32 汇编的行内注释.#通常表示立即数前缀, 如#4. -
GNU
as支持不同语法风格..syntax unified是现代 GNU ARM 汇编常见写法, 同一份代码仍需匹配目标架构和执行状态.
AArch32 寄存器¶
AArch32 对通用寄存器使用 r0-r15 的编号, 其中部分编号有专用别名. 这些别名表示常见角色, 而调用约定会进一步规定跨函数调用时哪些值必须保留.
| 寄存器 | 别名 | 常见角色与 AAPCS32 约定 |
|---|---|---|
r0-r3 | a1-a4 | 传递前四个整数 / 指针参数; r0 用于返回一般结果, 64 位结果常使用 r0:r1. 调用者保存. |
r4-r8 | v1-v5 | 常用于保存局部数据; 被调用函数若改写, 必须恢复. |
r9 | v6 / sb / tr | 用途由平台 ABI 决定, 可能是被调用者保存寄存器、静态基址或线程寄存器. 不要无条件当普通临时寄存器. |
r10-r11 | v7-v8, r11 也常作 fp | 常用于保存局部数据; 被调用函数若改写, 必须恢复. r11 在部分平台或编译模式中作帧指针. |
r12 | ip | 过程内临时寄存器. 调用者保存; 链接器生成的跳板代码也可能使用它. |
r13 | sp | 栈指针, 指向当前栈顶. |
r14 | lr | 链接寄存器, BL 把返回地址写入这里. |
r15 | pc | 程序计数器, 表示指令执行位置; 读取其值在不同指令状态和架构版本下有细节差异. |
除 r0-r15 外, AArch32 还有 CPSR (Current Program Status Register), 保存条件标志和处理器状态等信息. 初学时重点关注其中的 N、Z、C、V:
| 标志 | 含义 |
|---|---|
N | 运算结果的最高位为 1, 常用于有符号数的负数判断. |
Z | 运算结果为 0. |
C | 加法进位; 减法中表示没有借位, 常用于无符号比较. |
V | 有符号运算发生溢出. |
标志通常由带 S 的数据处理指令或 CMP 等指令更新, 条件分支再读取它们. 不是每条指令都会更新标志.
指令读法¶
AArch32 常见数据处理指令的基本形式如下:
助记符{条件}{S} 目标寄存器, 源操作数1, 源操作数2
以统一语法为例:
mov r0, #7 @ r0 = 7
add r2, r0, r1 @ r2 = r0 + r1
sub r2, r2, #1 @ r2 = r2 - 1
adds r2, r0, r1 @ 相加并更新条件标志
cmp r0, r1 @ 按 r0 - r1 更新标志, 不保存差值
和 x86 的 GNU AT&T 语法不同, 这里通常把目标寄存器放在操作数前面. #7 是立即数, r0 则表示寄存器中的值.
条件码与分支¶
CMP 本身不保存结果, 只设置条件标志. 随后的条件分支根据这些标志决定是否跳转:
cmp r0, r1
beq equal @ 相等时跳转
bne not_equal @ 不相等时跳转
blt signed_less @ 有符号数比较: 小于
blo unsigned_lower @ 无符号数比较: 低于
常用条件码:
| 条件码 | 含义 |
|---|---|
EQ / NE | 相等 / 不相等, 对应 Z == 1 / Z == 0. |
GE / LT | 有符号大于等于 / 小于. |
GT / LE | 有符号大于 / 小于等于. |
CS / CC | 无符号大于等于 / 小于, 也可写作 HS / LO. |
HI / LS | 无符号大于 / 小于等于. |
因此比较整数时, 要先确认数据按有符号还是无符号解释, 再选 BLT 或 BLO 这类条件码. 条件后缀也可用于部分 ARM 指令, 但其适用范围和 Thumb 版本有关; 入门时优先把 CMP 与条件分支分开读.
常见控制流指令:
-
B label: 无条件分支. -
BEQ、BNE、BGT等: 条件成立时分支. -
BL function: 调用子程序, 将返回地址写入LR. -
BX reg: 跳转到寄存器所指地址, 并可根据地址状态位切换 ARM / Thumb 状态; 常用BX LR返回. -
BLX reg: 带链接的间接调用, 同时支持执行状态切换.
内存访问与寻址¶
AArch32 常见的 LDR / STR 用于在寄存器和内存之间搬运数据. 方括号表示访问内存:
ldr r0, [r1] @ r0 = *(uint32_t *)r1
str r0, [r1] @ *(uint32_t *)r1 = r0
ldr r2, [r1, #4] @ 从地址 r1 + 4 读取
str r2, [r1, #4] @ 写入地址 r1 + 4
注意 r1 是地址, [r1] 才是该地址所存的数据. 这与 C 中“指针值”和“解引用指针”相似.
部分寻址形式还可在访问前或访问后更新基址寄存器:
ldr r0, [r1, #4]! @ 先 r1 += 4, 再从新地址读取
ldr r0, [r1], #4 @ 先从旧地址读取, 再 r1 += 4
! 表示将更新后的地址写回基址寄存器. 不带 ! 的 [r1, #4] 只计算访问地址, 不改变 r1.
LDR r0, =0x12345678 是 GNU as 提供的伪指令写法, 用来把常量装入寄存器. 汇编器可能生成一条或多条真实指令, 也可能从 literal pool 读取常量; 它不保证对应一条硬件指令.
数组元素¶
假设 int 为 4 字节, r0 保存数组首地址, r1 保存下标, 则 a[i] 可按地址 r0 + r1 * 4 访问. 下面用移位计算乘 4:
lsl r2, r1, #2 @ r2 = i * 4
ldr r3, [r0, r2] @ r3 = a[i]
汇编器只看到地址运算, 不知道这里的内存属于 C 数组. 使用偏移时仍要自行保证地址有效、对齐和访问宽度符合预期.
多寄存器加载与存储¶
LDM / STM 可一次从连续内存中加载多个寄存器, 或把多个寄存器存到内存:
ldmia r0!, {r1-r3} @ 依次加载 3 个字, 并更新 r0
stmia r0!, {r1-r3} @ 依次存储 3 个字, 并更新 r0
-
IA: Increment After, 每次访问后地址递增. -
IB: Increment Before, 访问前先递增. -
DA/DB: 对应递减地址. -
!: 将最终地址写回基址寄存器.
寄存器列表写作 {r1-r3} 或 {r1, r2, r3}. 这类指令的具体地址顺序容易和寄存器列表书写顺序混淆, 初学时可用短程序配合反汇编器验证; 栈操作则优先用 PUSH / POP 读写.
栈与函数调用¶
AArch32 常见栈模型是full descending: 栈向低地址增长, SP 指向栈顶已有数据:
----- 高地址
|\\\| |
----- |
|\\\| | 已使用的栈空间
----- |
|\ \| | 当前栈顶数据 <- SP
----- |
| - | v 新的 PUSH 通常向低地址扩展
----- 低地址
GNU 汇编中常见:
push {r4, lr} @ 保存 r4 和返回地址, SP 向低地址移动
... @ 函数体
pop {r4, pc} @ 恢复 r4, 并通过加载 PC 返回
PUSH / POP 可视为多寄存器存取的栈别名. AAPCS32 要求栈在公共函数接口处满足 8 字节对齐; 只压入一个 4 字节寄存器可能破坏对齐, 如果随后要调用其他函数, 应相应调整保存的寄存器或栈空间.
AAPCS32 调用约定¶
AAPCS32 是 AArch32 程序之间常用的过程调用约定. 它规定调用方和被调用方如何配合:
| 项目 | 常见规则 |
|---|---|
| 整数 / 指针参数 | 前四个放在 r0-r3, 更多参数通过栈传递. |
| 整数返回值 | 通常放在 r0; 64 位整数通常占用 r0 和 r1. |
| 调用者保存 | r0-r3、r12、LR 等值可能被被调用函数或链接器跳板覆盖; 需要跨调用保留时由调用方先保存. |
| 被调用者保存 | r4-r8、r10、r11 及 SP 必须按约定保留; r9 依平台而定. |
| 浮点参数 | 使用 VFP 调用变体时可能通过浮点寄存器传递; 不能把所有平台都简化为只用 r0-r3. |
| 栈 | 栈向低地址增长, 公共接口处 SP 按 8 字节对齐. |
浮点寄存器的参数规则取决于 ABI 变体和平台选项. 例如 ARM GNU/Linux 的硬浮点与软浮点 ABI 不能只凭函数原型判断二进制兼容性.
返回地址保存¶
BL 把返回地址写入 LR, 但 LR 只有一个. 如果函数本身还要调用另一个函数, 第二次 BL 会覆盖原来的 LR, 所以非叶子函数通常要先保存它:
push {r4, lr} @ 保存调用者的 r4 和本函数的返回地址
mov r4, r0 @ 暂存一个需要跨调用保留的值
bl helper @ LR 被改写为 helper 的返回地址
mov r0, r4 @ 恢复需要的值
pop {r4, pc} @ 恢复 r4 并返回
r4 是被调用者保存寄存器, helper 必须维持它原来的值; 本函数若使用 r4, 则还要在返回前恢复入口时的值. 这里将原 LR 压栈, 再从栈中恢复到 PC 完成返回.
示例¶
还是以在x86_64中使用的 add.c 为例:
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int res = add(2, 3);
return 0;
}
使用以下命令执行交叉编译:
arm-linux-gnueabihf-gcc -S -O0 -marm add.c -o add.s
其汇编标记为 .arch armv6、.arm 和 .fpu vfp; 对应目标文件是 ELF32 little-endian, 并使用 VFP 参数传递约定. 若要显式复现这些目标属性, 可使用:
arm-linux-gnueabihf-gcc -S -O0 -march=armv6 -mfpu=vfp -mfloat-abi=hard -marm add.c -o add.s
不同交叉编译器的默认目标可能不同, 所以应以 add.s 中的 .arch / .fpu 和 readelf -h -A add.o 的实际输出为准.
add 函数¶
AAPCS32 将前两个整数参数放在 r0、r1, 普通 32 位整数返回值放在 r0. 注意参数 b 虽然从 r1 传入, 加法结果仍通过 r0 返回:
add:
str fp, [sp, #-4]! @ 保存调用者的 fp
add fp, sp, #0 @ 建立当前栈帧基准
sub sp, sp, #12 @ 为局部存储和对齐预留空间
str r0, [fp, #-8] @ 将参数 a 保存到栈上
str r1, [fp, #-12] @ 将参数 b 保存到栈上
ldr r2, [fp, #-8] @ 取回 a
ldr r3, [fp, #-12] @ 取回 b
add r3, r2, r3 @ r3 = a + b
mov r0, r3 @ 返回值放入 r0
add sp, fp, #0 @ 回收局部栈空间
ldr fp, [sp], #4 @ 恢复调用者的 fp, 并移动 sp
bx lr @ 返回到 lr 保存的地址
这里是 -O0 的编译结果. 编译器把参数先存到栈上, 再读出来参与运算, 便于调试时对应源码变量; 优化版本通常会省掉这些存取. add 是叶子函数, 函数体中没有再执行 BL, 所以 LR 不会被覆盖, 无需压栈保存. 这正是汇编注释 link register save eliminated 的意思.
此函数的栈帧可按 fp 周围的偏移理解:
较高地址
[fp] 保存的调用者 fp
[fp - 4] 对齐 / 保留空间
[fp - 8] 参数 a 的栈副本
[fp - 12] 参数 b 的栈副本 <- 函数体执行时 sp
较低地址
这是这份 -O0 输出的布局, 不是 AAPCS32 要求所有函数都使用的固定模板.
函数调用¶
main 的关键指令如下:
main:
push {fp, lr} @ 保存帧指针和 main 自己的返回地址
add fp, sp, #4
sub sp, sp, #8 @ 为局部变量和栈对齐预留空间
mov r1, #3 @ 第二个参数 b = 3
mov r0, #2 @ 第一个参数 a = 2
bl add(PLT) @ LR = 返回地址, 然后跳转到 add
str r0, [fp, #-8] @ 将 add 的返回值保存到局部变量 res
mov r3, #0
mov r0, r3 @ main 返回 0
sub sp, fp, #4 @ 恢复栈空间
pop {fp, pc} @ 恢复 fp, 并将返回地址装入 pc
调用过程可以简化成如下形式:
main: r0 = 2, r1 = 3
|
| BL add: 把返回地址写入 LR
v
add: 读取 r0 / r1 -> 相加 -> 把结果写入 r0 -> BX LR
|
v
main: 从 r0 取回结果, 保存到 res
main 不是叶子函数, 因为它调用了 add. 因此入口处用 push {fp, lr} 保存 main 原来的返回地址; BL add 会将新的返回地址写入 LR, 最后 pop {fp, pc} 从栈中恢复帧指针, 并通过 PC 返回.
目标文件反汇编¶
使用如下代码生成 C 目标文件并对其进行反汇编, 同时查看目标文件的 ELF 头信息:
arm-linux-gnueabihf-gcc -S -O0 -marm add.c -o add.s
arm-linux-gnueabihf-gcc -c add.s -o add.o
arm-linux-gnueabihf-objdump -d add.o
arm-linux-gnueabihf-readelf -h -A -s add.o
输出开头的 elf32-littlearm 表示目标文件是 32 位小端 ARM ELF. Disassembly of section .text 说明下面是代码节, 00000000 <add>: 和 00000030 <main>: 是符号及其在 .text 中的偏移. 因为是未链接的 .o, 地址从 0 开始, 不是运行时的最终地址.
每条指令一行, 格式为 偏移: 机器码 助记符 操作数:
1c: e0823003 add r3, r2, r3
2c: e12fff1e bx lr
ARM state 的指令定长 4 字节, 所以偏移每行加 4. add 从 0x00 到 0x2c 共 12 条指令, main 从 0x30 开始, 到 0x58 的 pop {fp, pc} 结束, 共 11 条, 与符号表中 add 大小 48 字节、main 大小 44 字节一致.
机器码对照¶
ARM 指令的最高 4 位是条件码, e (1110) 表示 AL, 即总是执行. 以几条指令为例拆分各字段:
| 偏移 | 机器码 | 类别 | 拆分 | 汇编 |
|---|---|---|---|---|
0x1c | e0823003 | 数据处理 | cond=e, opcode=0100 (ADD), Rn=2, Rd=3, Rm=3 | add r3, r2, r3 |
0x3c | e3a01003 | 数据处理, 立即数 | cond=e, I=1, opcode=1101 (MOV), Rd=1, imm=3 | mov r1, #3 |
0x0c | e50b0008 | 单寄存器存储 | P=1, U=0, W=0, L=0, Rn=11, Rd=0, offset=8 | str r0, [fp, #-8] |
0x14 | e51b2008 | 单寄存器加载 | P=1, U=0, W=0, L=1, Rn=11, Rd=2, offset=8 | ldr r2, [fp, #-8] |
0x2c | e12fff1e | 分支并交换 | BX, Rm=14 | bx lr |
-
寄存器在机器码里是编号, 汇编里常显示别名:
r11是fp,r13是sp,r14是lr,r15是pc. 可用objdump -M reg-names-std把fp显示成r11. -
str/ldr中U=0表示偏移取负, 所以编码里的8对应[fp, #-8];L位区分存储和加载. -
add r3, r2, r3的低 12 位003表示第二个操作数是寄存器r3且没有移位.mov r1, #3的I=1, 低 12 位003则按立即数解释. ARM 的立即数是 8 位值加 4 位旋转量, 所以并非任意 32 位常数都能直接写在mov里.
别名与多寄存器指令¶
objdump 会把某些指令显示成别名, 并用 @ 注释标出实际编码对应的形式:
0: e52db004 push {fp} @ (str fp, [sp, #-4]!)
28: e49db004 pop {fp} @ (ldr fp, [sp], #4)
30: e92d4800 push {fp, lr}
58: e8bd8800 pop {fp, pc}
-
push {fp}在机器码里就是前索引的str fp, [sp, #-4]!,pop {fp}是后索引的ldr fp, [sp], #4. 对单个寄存器, 汇编器 / 反汇编器把它们显示为push/pop. -
push {fp, lr}和pop {fp, pc}是多寄存器的STMDB sp!/LDMIA sp!. 它们的低 16 位是寄存器位图:e92d4800的0x4800置位了第 11 位 (fp) 和第 14 位 (lr);e8bd8800的0x8800置位了第 11 位 (fp) 和第 15 位 (pc). -
pop {fp, pc}把栈中保存的返回地址装入pc, 效果等同于bx lr, 不需要再单独返回.
BL 与重定位¶
main 中调用 add 的指令是:
44: ebfffffe bl 0 <add>
ebfffffe 中 eb 是 cond=AL 加 BL 的编码, 低 24 位 fffffe 是有符号偏移. 它只是汇编器写入的占位值, 里面没有 add 的最终地址: 因为 bl 在源汇编里写的是 bl add(PLT), 目标由链接器通过重定位决定. 加上 -r 可以看到重定位记录:
arm-linux-gnueabihf-objdump -dr add.o
在 0x44 处会多出 R_ARM_CALL add, 表示链接器要在这里填入 add 的位置. 在最终可执行文件里, 偏移值会被替换为真实的相对距离 (以字为单位). 与 RISC-V 的 call 展开成 auipc + jalr 两条指令不同, ARM 的 bl 只用单条指令, 但分支范围受 24 位偏移限制; 目标太远时, 链接器可能插入跳板代码 (veneer).
查看目标文件属性¶
disasm.s 后半部分是 readelf -h -A -s add.o 的结果:
| 字段 | 含义 |
|---|---|
类别: ELF32, 系统架构: ARM | 32 位 ARM 目标文件 |
数据: ... 小端序 | 小端字节序 |
类型: REL (可重定位文件) | 这是 .o, 尚未链接, 入口点地址为 0 |
标志: 0x5000000, Version5 EABI | 使用 ARM EABI 版本 5 |
Tag_CPU_arch: v6 | 目标架构 ARMv6 |
Tag_ARM_ISA_use / Tag_THUMB_ISA_use | 使用 ARM 指令, 并允许 Thumb-1 |
Tag_FP_arch: VFPv2, Tag_ABI_VFP_args: VFP registers | 使用 VFPv2 浮点, 参数通过 VFP 寄存器传递 (hard-float) |
Tag_ABI_align_needed: 8-byte | 栈需要 8 字节对齐 |
符号表里 add 与 main 都是 FUNC GLOBAL, 位于第 1 节 (.text), 值分别是 0x0 和 0x30. $a 是 ARM 的映射符号, 标记其后是 ARM 指令, 供反汇编器区分 ARM / Thumb 代码和数据. 这些信息用于确认文件确实面向哪个 ARM 目标, 不能仅凭 .o 扩展名判断.
交叉编译与调试¶
如果当前机器本身是 32 位 ARM Linux, 可以用本机 GCC 生成汇编. 在 x86-64 Linux 上, 需要安装目标为 ARM Linux 的交叉工具链. 不同发行版的包名可能不同.
常用命令如下(上面的示例用到的交叉编译工具链命令):
# 由 C 生成 AArch32 汇编, -marm 选择 ARM state
arm-linux-gnueabihf-gcc -S -O0 -marm add.c -o add.s
# 组装成 ELF 目标文件
arm-linux-gnueabihf-gcc -c add.s -o add.o
# 查看机器码和反汇编
arm-linux-gnueabihf-objdump -d add.o
# 查看 ELF 架构、属性和符号
arm-linux-gnueabihf-readelf -h -A -s add.o
如果改用 -mthumb, 编译器会生成 Thumb 指令; objdump 输出可能和 -marm 完全不同. 交叉编译器的 triplet、默认 ABI、目标架构和浮点选项都可能影响结果.
调试时可以使用支持 ARM 目标的 GDB. 例如:
break add
run
disassemble add
info registers r0 r1 r13 r14 r15 cpsr
display/i $pc
si
-
disassemble add: 查看add函数的机器指令. -
info registers: 检查参数寄存器、栈指针、链接寄存器和状态寄存器. -
display/i $pc: 每次暂停时显示当前指令. -
si: 单步执行一条机器指令; 遇到BL时会进入被调用函数.
若需要在 x86-64 主机上运行 ARM Linux 程序, 可进一步了解 QEMU user-mode 模拟; 模拟环境配置不属于本入门笔记的范围.
常见误区¶
-
把 AArch32 与 AArch64 混为一谈:
r0-r15和x0-x30属于不同执行状态, 指令与 ABI 也不同. -
把 ARM state 和 Thumb state 当成两套互不相关的架构: 它们是 AArch32 的不同执行状态, 调用和返回要考虑状态切换.
-
把
r1和[r1]看成同一个东西: 前者是寄存器值, 后者是从该地址访问的内存. -
以为
BL会自动保存所有寄存器: 它将返回地址写入LR, 其他寄存器由 ABI 约定决定是否保留. -
忘记嵌套调用会覆盖
LR: 函数再执行BL前, 若还需要原返回地址, 必须先保存. -
认为
LDR r0, =constant一定对应一条指令: 这是汇编器伪指令, 具体展开取决于常量和目标架构. -
把
r9当作平台无关的临时寄存器: AAPCS32 明确允许平台为它指定特殊角色. -
直接照抄网上的栈或多寄存器示例: 先核对目标 ARM 版本、指令状态、汇编器语法和 ABI; 对存疑的地址顺序用汇编器与
objdump验证.
阅读汇编的速查顺序¶
-
确认是 AArch32 还是 AArch64, 并确认 ARM state / Thumb state.
-
找到入口标签, 标出
B、条件分支、BL和返回位置. -
根据
r0-r3和LR判断参数、返回值与调用关系. -
遇到
LDR/STR时, 将寄存器中的地址与内存中的值分开追踪. -
遇到
CMP或带S的指令时, 检查条件标志及后续分支. -
遇到
PUSH、POP、LDM、STM时, 画出SP变化和内存访问顺序. -
用目标架构一致的
objdump/ GDB 输出验证猜测, 不把编译器某次生成结果当作唯一实现.