03-函数调用与栈帧¶
重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐⭐ 学习时间:2天 必须掌握:是
为什么学这一章?¶
函数调用是程序运行中最基础也最频繁的操作。每次函数调用背后,CPU和操作系统都在栈上创建一个栈帧(Stack Frame),管理参数、局部变量和返回地址。理解栈帧是理解递归、调试段错误、分析栈溢出的关键。
学完这一章,你将能够: - ✅ 理解函数调用的完整过程(调用约定、压栈、跳转) - ✅ 掌握栈帧结构和栈帧指针的作用 - ✅ 使用GDB观察栈帧和调用栈 - ✅ 理解缓冲区溢出攻击的原理
📖 核心概念¶
1. 函数调用的底层步骤¶
┌─────────────────────────────────────────────────────────────┐
│ 函数调用的完整流程(x86-64) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 调用方(caller)操作: │
│ 1. 将超出寄存器的参数压入栈(从右到左) │
│ 2. 前6个整数参数放入寄存器 rdi,rsi,rdx,rcx,r8,r9 │
│ 3. 执行 call 指令 → 压入返回地址,跳转到被调用函数 │
│ │
│ 被调用方(callee)操作: │
│ 4. push rbp → 保存旧帧指针 │
│ 5. mov rbp, rsp → 建立新帧指针 │
│ 6. sub rsp, N → 为局部变量分配栈空间 │
│ 7. 保存callee-saved寄存器(如用到rbx, r12等) │
│ 8. 执行函数体 │
│ │
│ 函数返回: │
│ 9. 将返回值放入rax │
│ 10. 恢复callee-saved寄存器 │
│ 11. mov rsp, rbp → 释放局部变量空间 │
│ 12. pop rbp → 恢复旧帧指针 │
│ 13. ret → 弹出返回地址,跳转回caller │
│ │
└─────────────────────────────────────────────────────────────┘
2. 栈帧结构¶
┌─────────────────────────────────────────────────────────────┐
│ 栈帧内存布局(高地址在上) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 高地址 │
│ ┌─────────────────┐ │
│ │ 调用者的栈帧 │ │
│ │ ... │ │
│ ├─────────────────┤ │
│ │ 参数7(如有) │ ← 超过6个参数时通过栈传递 │
│ ├─────────────────┤ │
│ │ 返回地址 │ ← call指令自动压入 │
│ ├─────────────────┤ ← rbp 指向这里(旧rbp的值) │
│ │ 保存的旧rbp │ │
│ ├─────────────────┤ │
│ │ 局部变量1 │ │
│ │ 局部变量2 │ │
│ │ ... │ │
│ ├─────────────────┤ │
│ │ 保存的寄存器 │ ← callee-saved寄存器 │
│ ├─────────────────┤ ← rsp 指向栈顶 │
│ │ (空闲区域) │ │
│ └─────────────────┘ │
│ 低地址 │
│ │
└─────────────────────────────────────────────────────────────┘
3. 代码示例与汇编分析¶
// stack_frame_demo.c
#include <stdio.h>
int add(int a, int b) {
int result = a + b;
return result;
}
int compute(int x, int y, int z) {
int sum = add(x, y);
int final_result = sum + z;
return final_result;
}
int main() {
int answer = compute(10, 20, 30);
printf("answer = %d\n", answer);
return 0;
}
# 编译(不优化,保留栈帧信息)
gcc -g -O0 -fno-omit-frame-pointer stack_frame_demo.c -o stack_demo
# 生成汇编查看栈帧操作
gcc -S -O0 -masm=intel -fno-omit-frame-pointer stack_frame_demo.c -o stack_demo.s
预期汇编片段(add函数):
add:
push rbp ; 保存旧帧指针
mov rbp, rsp ; 建立新帧
mov DWORD PTR [rbp-20], edi ; 参数a存到栈
mov DWORD PTR [rbp-24], esi ; 参数b存到栈
mov edx, DWORD PTR [rbp-20]
mov eax, DWORD PTR [rbp-24]
add eax, edx ; a + b
mov DWORD PTR [rbp-4], eax ; 存到局部变量result
mov eax, DWORD PTR [rbp-4] ; 返回值放入eax
pop rbp ; 恢复旧帧指针
ret ; 返回
4. 用GDB观察栈帧¶
# 用GDB调试
gdb ./stack_demo
# 在add函数设置断点
(gdb) break add
(gdb) run
# 查看调用栈
(gdb) backtrace
# #0 add (a=10, b=20) at stack_frame_demo.c:4
# #1 compute (x=10, y=20, z=30) at stack_frame_demo.c:9
# #2 main () at stack_frame_demo.c:14
# 查看当前栈帧信息
(gdb) info frame
# Stack level 0, frame at 0x7fffffffe430:
# rip = 0x401136 in add; saved rip = 0x401160
# called by frame at 0x7fffffffe460
# 查看寄存器
(gdb) info registers rbp rsp
# rbp 0x7fffffffe420
# rsp 0x7fffffffe400
# 查看栈内容
(gdb) x/8xg $rsp
# 打印从rsp开始的8个64位值
# 查看局部变量
(gdb) info locals
# 切换栈帧
(gdb) frame 1
(gdb) info locals
5. 递归与栈¶
// recursion_stack.c - 递归调用的栈变化
#include <stdio.h>
int factorial(int n) {
printf("进入 factorial(%d), rbp近似位置: %p\n",
n, __builtin_frame_address(0));
if (n <= 1) return 1;
int result = n * factorial(n - 1);
printf("返回 factorial(%d) = %d\n", n, result);
return result;
}
int main() {
printf("main 的帧地址: %p\n", __builtin_frame_address(0));
int r = factorial(5);
printf("\n5! = %d\n", r);
return 0;
}
gcc -g -O0 recursion_stack.c -o recursion_demo && ./recursion_demo # &&前一个成功才执行后一个;||前一个失败才执行
# 观察每次递归调用时帧地址逐渐减小(栈向低地址增长)
调用栈变化:
┌──────────────┐ 高地址
│ main() │
├──────────────┤
│ factorial(5) │ ← 每次递归调用
├──────────────┤ 创建新的栈帧
│ factorial(4) │
├──────────────┤
│ factorial(3) │
├──────────────┤
│ factorial(2) │
├──────────────┤
│ factorial(1) │ ← 递归基,最深处
└──────────────┘ 低地址(rsp)
6. 栈溢出与缓冲区溢出¶
// buffer_overflow_demo.c - 缓冲区溢出原理演示
#include <stdio.h>
#include <string.h>
void vulnerable_function(const char *input) { // 指针:存储变量的内存地址
char buffer[16]; // 只有16字节的缓冲区
printf("buffer地址: %p\n", buffer);
printf("返回地址存储位置: %p\n",
(void*)((char*)__builtin_frame_address(0) + 8));
// 危险操作:没有长度检查
strcpy(buffer, input); // 如果input > 16字节,溢出!
printf("buffer内容: %s\n", buffer);
}
void secret_function() {
printf("=== 不应该被调用的秘密函数!===\n");
}
int main() {
printf("secret_function地址: %p\n", secret_function);
// 正常输入
printf("\n--- 正常输入 ---\n");
vulnerable_function("Hello");
// 长输入(实际攻击中会精心构造来覆盖返回地址)
printf("\n--- 超长输入(演示溢出概念)---\n");
// vulnerable_function("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
// 取消注释会导致段错误(返回地址被覆盖)
return 0;
}
# 编译时关闭栈保护(仅供学习)
gcc -g -O0 -fno-stack-protector -no-pie buffer_overflow_demo.c -o overflow_demo
# 正常运行
./overflow_demo
栈保护机制¶
┌─────────────────────────────────────────────────────────────┐
│ 现代栈保护措施 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Stack Canary(栈金丝雀) │
│ 在返回地址前放一个随机值,函数返回时检查是否被覆盖 │
│ gcc默认开启:-fstack-protector │
│ │
│ 2. ASLR(地址空间布局随机化) │
│ 每次运行时栈、库的基地址随机,难以预测目标地址 │
│ 系统级别开启 │
│ │
│ 3. NX/DEP(不可执行栈) │
│ 标记栈区域为不可执行,即使注入代码也无法运行 │
│ 硬件支持(NX位) │
│ │
│ 4. PIE(位置无关可执行文件) │
│ 代码段地址也随机化 │
│ │
└─────────────────────────────────────────────────────────────┘
7. 尾调用优化¶
// tail_call.c
#include <stdio.h> // 引入头文件
// 非尾递归:递归调用后还要做乘法
int factorial_normal(int n) {
if (n <= 1) return 1;
return n * factorial_normal(n - 1); // 不是尾调用
}
// 尾递归:递归调用是最后一个操作
int factorial_tail(int n, int acc) {
if (n <= 1) return acc;
return factorial_tail(n - 1, acc * n); // 尾调用
// 编译器可以将其优化为循环,复用当前栈帧
}
int main() {
printf("normal: %d\n", factorial_normal(10));
printf("tail: %d\n", factorial_tail(10, 1));
return 0;
}
# -O2优化会启用尾调用优化
gcc -S -O2 -masm=intel tail_call.c -o tail_call.s
# 查看factorial_tail是否被优化为循环(jmp代替call)
💡 面试常见问题¶
Q1:函数调用时栈上依次存放了什么?¶
答:从高地址到低地址依次是:①调用者传递的多余参数(超过6个的部分);②返回地址(call指令压入);③保存的旧rbp(帧指针);④局部变量;⑤保存的callee-saved寄存器。rsp始终指向栈顶。
Q2:什么是栈溢出(Stack Overflow)?如何避免?¶
答:栈空间有限(通常默认8MB),过深的递归或过大的局部变量数组会耗尽栈空间。避免方法:①使用迭代代替递归;②将大数组分配在堆上(malloc/new);③使用尾递归让编译器优化;④必要时增大栈大小(ulimit -s)。
Q3:为什么说 -fomit-frame-pointer 可以多一个可用寄存器?¶
答:默认情况下rbp用作帧指针,指向当前栈帧的底部。启用-fomit-frame-pointer后,编译器不再维护rbp为帧指针,rbp可作为通用寄存器使用(多一个寄存器分配),但调试时backtrace可能不完整。GCC在-O1及以上默认开启。
Q4:解释缓冲区溢出攻击的基本原理。¶
答:攻击者通过向函数的局部缓冲区写入超出大小的数据,覆盖栈上的返回地址,将其改为恶意代码的地址。函数返回时跳转到恶意代码执行。现代防御措施包括Stack Canary、ASLR、NX位、PIE等。
Q5:什么是红区(Red Zone)?¶
答:在System V AMD64 ABI中,rsp下方的128字节称为红区,信号处理程序和中断不会修改这个区域。叶函数(不调用其他函数)可以直接使用红区存储临时数据,不需要调整rsp。这消除了为少量局部变量调整栈指针的开销。
📝 本章小结¶
┌─────────────────────────────────────────────┐
│ 本章核心知识点 │
├─────────────────────────────────────────────┤
│ │
│ 1. 函数调用=参数传递+保存上下文+跳转 │
│ 2. 栈帧包含返回地址、旧rbp、局部变量 │
│ 3. rbp为帧指针,rsp为栈指针 │
│ 4. 递归每次调用创建新栈帧 │
│ 5. 缓冲区溢出可覆盖返回地址 │
│ 6. 尾调用优化可复用栈帧 │
│ │
└─────────────────────────────────────────────┘
下一章:04-系统调用机制 - 用户态与内核态的桥梁