Skip to content

ARM汇编基础

Assembly - ARM | CTF Wiki

ARM Assembly By Example

GNU Assembler: Using as

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 完成返回.

示例

lang/asm/add-aarch32 | virtualguard101/vglab

还是以在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 验证.

阅读汇编的速查顺序

  1. 确认是 AArch32 还是 AArch64, 并确认 ARM state / Thumb state.

  2. 找到入口标签, 标出 B、条件分支、BL 和返回位置.

  3. 根据 r0-r3 和 LR 判断参数、返回值与调用关系.

  4. 遇到 LDR / STR 时, 将寄存器中的地址与内存中的值分开追踪.

  5. 遇到 CMP 或带 S 的指令时, 检查条件标志及后续分支.

  6. 遇到 PUSH、POP、LDM、STM 时, 画出 SP 变化和内存访问顺序.

  7. 用目标架构一致的 objdump / GDB 输出验证猜测, 不把编译器某次生成结果当作唯一实现.