跳转至

03-函数调用与栈帧

重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐⭐ 学习时间:2天 必须掌握:是


为什么学这一章?

函数调用是程序运行中最基础也最频繁的操作。每次函数调用背后,CPU和操作系统都在栈上创建一个栈帧(Stack Frame),管理参数、局部变量和返回地址。理解栈帧是理解递归、调试段错误、分析栈溢出的关键。

学完这一章,你将能够: - ✅ 理解函数调用的完整过程(调用约定、压栈、跳转) - ✅ 掌握栈帧结构和栈帧指针的作用 - ✅ 使用GDB观察栈帧和调用栈 - ✅ 理解缓冲区溢出攻击的原理


📖 核心概念

1. 函数调用的底层步骤

Text Only
┌─────────────────────────────────────────────────────────────┐
│              函数调用的完整流程(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. 栈帧结构

Text Only
┌─────────────────────────────────────────────────────────────┐
│              栈帧内存布局(高地址在上)                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  高地址                                                      │
│  ┌─────────────────┐                                       │
│  │  调用者的栈帧    │                                       │
│  │  ...             │                                       │
│  ├─────────────────┤                                       │
│  │  参数7(如有)   │  ← 超过6个参数时通过栈传递            │
│  ├─────────────────┤                                       │
│  │  返回地址        │  ← call指令自动压入                   │
│  ├─────────────────┤ ← rbp 指向这里(旧rbp的值)           │
│  │  保存的旧rbp     │                                       │
│  ├─────────────────┤                                       │
│  │  局部变量1       │                                       │
│  │  局部变量2       │                                       │
│  │  ...             │                                       │
│  ├─────────────────┤                                       │
│  │  保存的寄存器    │  ← callee-saved寄存器                 │
│  ├─────────────────┤ ← rsp 指向栈顶                        │
│  │  (空闲区域)    │                                       │
│  └─────────────────┘                                       │
│  低地址                                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3. 代码示例与汇编分析

C
// 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;
}
Bash
# 编译(不优化,保留栈帧信息)
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函数):

GAS
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观察栈帧

Bash
# 用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. 递归与栈

C
// 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;
}
Bash
gcc -g -O0 recursion_stack.c -o recursion_demo && ./recursion_demo  # &&前一个成功才执行后一个;||前一个失败才执行
# 观察每次递归调用时帧地址逐渐减小(栈向低地址增长)
Text Only
调用栈变化:
┌──────────────┐  高地址
│  main()      │
├──────────────┤
│ factorial(5) │  ← 每次递归调用
├──────────────┤     创建新的栈帧
│ factorial(4) │
├──────────────┤
│ factorial(3) │
├──────────────┤
│ factorial(2) │
├──────────────┤
│ factorial(1) │  ← 递归基,最深处
└──────────────┘  低地址(rsp)

6. 栈溢出与缓冲区溢出

C
// 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;
}
Bash
# 编译时关闭栈保护(仅供学习)
gcc -g -O0 -fno-stack-protector -no-pie buffer_overflow_demo.c -o overflow_demo

# 正常运行
./overflow_demo

栈保护机制

Text Only
┌─────────────────────────────────────────────────────────────┐
│              现代栈保护措施                                   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Stack Canary(栈金丝雀)                                │
│     在返回地址前放一个随机值,函数返回时检查是否被覆盖       │
│     gcc默认开启:-fstack-protector                          │
│                                                             │
│  2. ASLR(地址空间布局随机化)                              │
│     每次运行时栈、库的基地址随机,难以预测目标地址           │
│     系统级别开启                                            │
│                                                             │
│  3. NX/DEP(不可执行栈)                                    │
│     标记栈区域为不可执行,即使注入代码也无法运行             │
│     硬件支持(NX位)                                        │
│                                                             │
│  4. PIE(位置无关可执行文件)                               │
│     代码段地址也随机化                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

7. 尾调用优化

C
// 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;
}
Bash
# -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。这消除了为少量局部变量调整栈指针的开销。


📝 本章小结

Text Only
┌─────────────────────────────────────────────┐
│              本章核心知识点                    │
├─────────────────────────────────────────────┤
│                                             │
│  1. 函数调用=参数传递+保存上下文+跳转       │
│  2. 栈帧包含返回地址、旧rbp、局部变量      │
│  3. rbp为帧指针,rsp为栈指针               │
│  4. 递归每次调用创建新栈帧                  │
│  5. 缓冲区溢出可覆盖返回地址               │
│  6. 尾调用优化可复用栈帧                    │
│                                             │
└─────────────────────────────────────────────┘

下一章04-系统调用机制 - 用户态与内核态的桥梁