3.13 过程与栈帧
过程(函数)调用是程序组织的基本单元。每次函数调用时,系统在栈上分配一个栈帧(stack frame)来存储该次调用的上下文信息。
栈帧概述
栈帧是函数调用时在栈上分配的一片连续内存区域,用于存储:
- 返回地址:从被调函数返回后继续执行的位置
- 保存的寄存器:被调用者需要恢复的寄存器旧值
- 局部变量:函数内部定义的自动变量
- 参数传递:第 7 个及以后的参数
运行时栈结构
高地址
┌──────────────────────────┐
│ 调用者栈帧 │
│ ┌──────────────────┐ │
│ │ 参数 7+ (if any) │ │ ← 调用者分配
│ │ 返回地址 │ │ ← call 指令压入
├───┴──────────────────┴───┤
│ 被调用者栈帧 │
│ ┌──────────────────┐ │
│ │ 保存的 %rbp │ │ ← pushq %rbp
│ │ 局部变量区域 │ │ ← subq $N, %rsp
│ │ 保存的寄存器 │ │ ← pushq %rbx, ...
│ │ │ │
└───┴──────────────────┴───┤ ← %rsp
低地址
栈帧的建立与销毁
典型栈帧建立序列
example_func:
# 栈帧建立(prologue)
pushq %rbp # 保存调用者的 %rbp
movq %rsp, %rbp # 设置当前函数自己的 %rbp
subq $32, %rsp # 分配 32 字节的局部变量空间
# 函数体
# ... 使用 -8(%rbp), -16(%rbp) 等访问局部变量 ...
# 栈帧销毁(epilogue)
movq %rbp, %rsp # 恢复 %rsp(回收局部变量空间)
popq %rbp # 恢复调用者的 %rbp
ret # 返回(弹出返回地址)省略帧指针
现代编译器在优化级别 (-O2) 会省略 %rbp 作为帧指针,直接使用 %rsp 访问局部变量。这样可以节省一个寄存器:
# -O2 优化后的栈帧(无 %rbp)
example_func_opt:
subq $40, %rsp # 分配 40 字节(包含可能的参数区)
# 使用 %rsp + 偏移量访问局部变量
movq $100, 8(%rsp) # 局部变量在 rsp+8
movq $200, 16(%rsp) # 局部变量在 rsp+16
addq $40, %rsp # 回收栈空间
ret栈帧内容详解
1. 返回地址
call 指令将返回地址压入栈,ret 指令弹出:
call func # pushq 返回地址; %rsp -= 8
func:
# %rsp 指向返回地址
...
ret # popq 返回地址; %rsp += 8详见 。
2. 保存的寄存器
被调用者如果使用 ,必须保存原始值:
func:
pushq %rbx # 保存 %rbx(被调用者保存)
pushq %r12 # 保存 %r12
# 函数体中使用 %rbx 和 %r12 ...
movq (%rdi), %rbx
movq %rbx, %r12
popq %r12 # 恢复 %r12
popq %rbx # 恢复 %rbx
ret3. 局部变量
以下情况局部变量会被分配到栈上而非寄存器:
- 变量数量超过寄存器数量
- 变量需要取地址(
&操作符) - 变量是数组或结构体
void func() {
int local_var = 42; // 可能在栈上或寄存器中
int arr[4] = {1, 2, 3, 4}; // 数组必须在栈上(支持指针运算)
int *p = &local_var; // 取地址后,local_var 必须在栈上
}func:
subq $32, %rsp # 分配 32 字节
movl $42, 28(%rsp) # local_var = 42 (在 rsp+28)
movl $1, (%rsp) # arr[0] = 1
movl $2, 4(%rsp) # arr[1] = 2
movl $3, 8(%rsp) # arr[2] = 3
movl $4, 12(%rsp) # arr[3] = 4
leaq 28(%rsp), %rax # p = &local_var
movq %rax, 16(%rsp) # 存储 p
addq $32, %rsp
ret4. 参数传递(栈参数)
当参数超过 6 个时,第 7 个及以后的参数通过栈传递。详见 。
long func(long a1, long a2, long a3, long a4, long a5, long a6,
long a7, long a8) {
return a1 + a7;
}func:
movq %rdi, %rax # a1
addq 16(%rsp), %rax # a7 (在返回地址上方 16 字节)
ret
# 调用者视角:
# %rdi = a1, %rsi = a2, ..., %r9 = a6
# 栈顶之上: a7, a8栈帧大小
栈帧大小在编译时确定(变长数组 VLAs 除外):
void fixed_size() {
int arr[10]; // 固定大小,编译时确定
}
void variable_size(int n) {
int arr[n]; // 变长数组 VLA,运行时确定
}VLA 需要动态调整栈指针:
variable_size:
pushq %rbp
movq %rsp, %rbp
# 计算数组大小 N = n * 4
leaq (%rdi, %rdi, 4), %rax # 不对,这只是示意
salq $2, %rax # 实际大小计算
subq %rax, %rsp # 动态分配栈空间
# ... 初始化 arr 为 0 等 ...
movq %rbp, %rsp
popq %rbp
ret栈帧与递归
每次递归调用创建独立的新栈帧:
long factorial(long n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}factorial:
pushq %rbx # 保存 %rbx (被调用者保存)
movq %rdi, %rbx # rbx = n
cmpq $1, %rdi # if (n <= 1)
jle .Lbase # 跳转到基本情况
leaq -1(%rdi), %rdi # n - 1
call factorial # 递归调用(创建新栈帧)
imulq %rbx, %rax # n * factorial(n-1)
popq %rbx
ret
.Lbase:
movq $1, %rax # return 1
popq %rbx
ret每次递归调用向栈压入返回地址和保存的寄存器,深度递归可能导致栈溢出(stack overflow)。
相关笔记
-
- call/ret 与栈帧的关系
-
- 函数参数的传递方式
-
- 寄存器的保存策略
-
- 栈的基本操作
-
- 栈在进程内存中的位置
链接到
- 上一个知识点:3.12 switch语句
- 下一个知识点:3.14 控制转移