ARM 函数调用约定与栈帧结构
ARM 的函数调用,说白了就是一套「谁负责擦屁股」的规则。我刚开始从 x86 转 ARM 时,最不适应的就是参数传递方式——x86 习惯把参数往栈里一扔,ARM 却非要先塞寄存器。嗯,这里面的门道,咱们今天好好捋一捋。
一、ATPCS 调用约定
ARM 官方定义了一套标准,叫 ARM-Thumb Procedure Call Standard(ATPCS)。这套规则规定了:
- 参数怎么传——前 4 个参数用 r0-r3,多余的压栈
- 返回值放哪——32 位返回值放 r0,64 位用 r0+r1
- 谁保存寄存器——被调用者保存 r4-r11,调用者自己管 r0-r3
- 栈怎么对齐——AAPCS 要求 8 字节对齐
核心原则:ARM 调用约定是「调用者保存部分 + 被调用者保存部分」的混合模型。r0-r3 是易失的,函数返回后内容不确定;r4-r11 是非易失的,函数必须恢复原值。
我个人习惯在逆向分析时,先看函数开头保存了哪些寄存器——这直接告诉你它用了多少局部变量、调了多少层函数。
二、寄存器角色分配
| 寄存器 | 别名 | 角色 | 谁保存 |
|---|---|---|---|
| r0-r3 | a1-a4 | 参数传递 / 临时变量 | 调用者 |
| r4-r11 | v1-v8 | 局部变量 / 寄存器变量 | 被调用者 |
| r9 | SB / TR | 静态基址 / 线程本地存储 | 被调用者 |
| r10 | SL | 栈限制(某些系统) | 被调用者 |
| r11 | FP | 帧指针 | 被调用者 |
| r12 | IP | 过程内调用临时寄存器 | 调用者 |
| r13 | SP | 栈指针 | — |
| r14 | LR | 链接寄存器(返回地址) | 调用者 |
| r15 | PC | 程序计数器 | — |
你想想看,r14(LR)这个设计其实很巧妙——函数调用时硬件自动把返回地址塞进 LR,省了一次内存访问。但这也意味着,如果你要嵌套调用,必须先把 LR 压栈保存。
实战技巧:在 Pwn 利用中,如果能控制 LR 的值,就相当于控制了函数返回后的执行流。ARM 的 ret 指令本质上是 mov pc, lr 或 bx lr。
三、栈帧结构详解
ARM 的栈帧和 x86 最大的区别在于:ARM 的栈帧更「薄」。为什么?因为参数传递靠寄存器,栈上只放局部变量和保存的寄存器。
一个典型的 ARM 栈帧长这样:
高地址
+------------------+
| 调用者栈帧 |
+------------------+
| 参数 5..N(如果>4)| ← 调用者压入
+------------------+
| LR(返回地址) | ← 被调用者压入
+------------------+
| FP(旧帧指针) | ← 被调用者压入
+------------------+
| r4-r11(保存的) | ← 被调用者压入
+------------------+
| 局部变量 | ← SP 指向这里
+------------------+ ← SP(低地址)
低地址
我在分析 IoT 固件时经常遇到一种情况:函数没有帧指针(-fomit-frame-pointer 编译),这时候栈帧完全靠 SP 偏移来访问。逆向起来确实费劲一些,但利用时反而多了操作空间。
四、Prologue 与 Epilogue
函数的开头和结尾,是编译器自动生成的「模板代码」。理解它们,你就能快速判断一个函数的栈帧大小、局部变量布局、以及——最重要的——有没有栈溢出漏洞。
4.1 标准 Prologue
push {r4-r11, lr} ; 保存非易失寄存器 + LR
sub sp, sp, #0x20 ; 分配局部变量空间(32 字节)
; 可选:如果用了帧指针
add r11, sp, #0x10 ; 设置 FP 指向局部变量区中间
这段代码干了三件事:
- 保存现场——把调用者关心的寄存器压栈
- 分配空间——SP 下移,腾出局部变量区
- 建立帧指针——方便通过 FP 访问局部变量和参数
注意:ARM 的 push 指令可以一次压多个寄存器,压栈顺序是编号从高到低(r11 先压,r4 后压)。这意味着栈上 r11 在低地址,r4 在高地址——和直觉相反,别搞混了。
4.2 标准 Epilogue
; 方式一:直接恢复
add sp, sp, #0x20 ; 回收局部变量空间
pop {r4-r11, pc} ; 恢复寄存器,同时返回
; 方式二:通过帧指针恢复
sub sp, r11, #0x10 ; SP 回到保存的寄存器区底部
pop {r4-r11, pc} ; 恢复并返回
注意看,Epilogue 里 pop {r4-r11, pc} 这一行——它把 LR 的值直接弹到了 PC 里。这就是 ARM 的「返回」操作。如果你能控制栈上 LR 的位置,就能 hijack 执行流。
五、Prologue/Epilogue 的变体
实际编译出来的代码,不一定都按教科书来。我见过几种常见变体:
| 场景 | Prologue | Epilogue | 说明 |
|---|---|---|---|
| 叶子函数 | push {r4-r7} |
pop {r4-r7} |
不调用其他函数,无需保存 LR |
| Thumb 模式 | push {r4-r7, lr} |
pop {r4-r7, pc} |
Thumb 指令集,寄存器范围受限 |
| 优化编译 | stmdb sp!, {r4-r11, lr} |
ldmia sp!, {r4-r11, pc} |
使用多寄存器加载/存储指令 |
| 栈探测 | push {r4, lr} |
同上 | 大栈帧时逐页探测,防止栈溢出到 guard page |
我曾经在分析一个路由器固件的 httpd 服务时,遇到一个函数用了「混合模式」——Prologue 是 ARM 指令,函数体中间却切到了 Thumb。这种代码在逆向时特别容易看走眼,建议用 IDA 的「切换反汇编模式」功能仔细核对。
六、栈帧结构图
下面这张图展示了 ARM 函数调用过程中栈帧的完整布局:
七、Pwn 视角下的关键点
好了,理论讲完了。咱们从漏洞利用的角度,看看这些知识怎么用。
栈溢出利用路径:局部变量 → 保存的 r4-r11 → 保存的 LR → PC 控制
具体来说:
- 覆盖 LR:如果函数 Epilogue 是
pop {r4-r11, pc},你覆盖栈上 LR 的位置,就能控制 PC - ROP 链构造:控制 r4-r11 后,可以设置后续 gadget 需要的参数
- 栈迁移:如果能控制 SP(通过修改 r11 再
sub sp, r11, #offset),就能把栈移到可控内存区
避坑指南:我曾经在 exploit 一个 ARM 设备时,忽略了 Thumb 模式下 PC 的最低位必须为 1(表示 Thumb 状态)。结果跳转过去直接 crash。记住:ARM 模式下 PC 最低位为 0,Thumb 模式下为 1。
八、动手验证
我建议你找个 ARM 交叉编译器,写个简单的 C 函数:
int func(int a, int b, int c, int d, int e) {
int local[4] = {1, 2, 3, 4};
return a + b + c + d + e + local[0];
}
然后用 arm-linux-gnueabihf-gcc -S -O0 func.c 编译,看看生成的汇编。你会发现:
- 前 4 个参数在 r0-r3 里,第 5 个参数 e 从栈上取
- local 数组在栈上分配了 16 字节
- Prologue 保存了 r4-r11 和 LR
- Epilogue 用
pop {r4-r11, pc}返回
嗯,把这些看懂了,ARM 函数调用的底裤就彻底扒干净了。后面讲 ROP 的时候,这些就是你的基本功。