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 指向局部变量区中间

这段代码干了三件事:

  1. 保存现场——把调用者关心的寄存器压栈
  2. 分配空间——SP 下移,腾出局部变量区
  3. 建立帧指针——方便通过 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}
bx lr
不调用其他函数,无需保存 LR
Thumb 模式 push {r4-r7, lr} pop {r4-r7, pc} Thumb 指令集,寄存器范围受限
优化编译 stmdb sp!, {r4-r11, lr} ldmia sp!, {r4-r11, pc} 使用多寄存器加载/存储指令
栈探测 push {r4, lr}
sub sp, sp, #0x1000
...
同上 大栈帧时逐页探测,防止栈溢出到 guard page

我曾经在分析一个路由器固件的 httpd 服务时,遇到一个函数用了「混合模式」——Prologue 是 ARM 指令,函数体中间却切到了 Thumb。这种代码在逆向时特别容易看走眼,建议用 IDA 的「切换反汇编模式」功能仔细核对。

六、栈帧结构图

下面这张图展示了 ARM 函数调用过程中栈帧的完整布局:

ARM 函数调用栈帧结构图 调用者栈帧(Caller Frame) 局部变量 / 临时数据 参数 5..N(栈上传参) 保存的 LR(返回地址) 保存的 FP(旧帧指针) 保存的 r4 - r11 (被调用者保存寄存器) 局部变量区 SP 指向这里(低地址) SP → LR 保存处 FP →(如果启用) 栈生长方向(低地址) 💡 关键点: • 栈上保存的 LR 是 ROP 攻击的核心目标——覆盖它就能控制返回地址 • 局部变量区溢出会先覆盖保存的寄存器,最后才是 LR

七、Pwn 视角下的关键点

好了,理论讲完了。咱们从漏洞利用的角度,看看这些知识怎么用。

栈溢出利用路径:局部变量 → 保存的 r4-r11 → 保存的 LR → PC 控制

具体来说:

  1. 覆盖 LR:如果函数 Epilogue 是 pop {r4-r11, pc},你覆盖栈上 LR 的位置,就能控制 PC
  2. ROP 链构造:控制 r4-r11 后,可以设置后续 gadget 需要的参数
  3. 栈迁移:如果能控制 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 的时候,这些就是你的基本功。