burpow
第03节-函数调用和栈帧

第03节-函数调用和栈帧

第 3 节:函数调用和栈帧

所属课程:操作系统自学路线:面向网络安全、逆向工程与漏洞分析
所属周次:第 2 周
课程主题:C 语言、栈和汇编基础
本节目标:理解函数调用时 CPU、寄存器和栈大致发生了什么,掌握栈帧、返回地址、局部变量的基本概念,为后续学习汇编、调试器和栈溢出打基础。


1. 本节课你要学会什么

学完这一节,你应该能回答下面几个问题:

  1. 函数调用时,程序为什么能跳到另一个函数执行?
  2. 函数执行完后,CPU 怎么知道回到哪里?
  3. 栈是什么?为什么函数调用离不开栈?
  4. 栈帧是什么?
  5. 局部变量通常放在哪里?
  6. 返回地址是什么?
  7. callret 大致做了什么?
  8. 为什么缓冲区溢出可能影响程序控制流?
  9. GDB 中如何观察调用栈、寄存器和栈内存?

本节课的主线是:

1
函数调用 -> 参数传递 -> call -> 返回地址入栈 -> 新函数栈帧 -> 局部变量 -> ret -> 回到调用者

如果你以后学漏洞利用、逆向工程、恶意代码调试,这一节是非常核心的基础。


2. 为什么要理解函数调用

在 C 语言里,函数调用看起来很简单:

1
foo(10);

但从 CPU 和内存角度看,它背后至少涉及:

  • 跳转到函数代码地址
  • 保存返回位置
  • 传递参数
  • 分配局部变量空间
  • 执行函数体
  • 返回调用者
  • 恢复调用者继续执行

逆向工程中,你看到的不是:

1
foo(10);

而可能是:

1
2
mov edi, 10
call 0x401136

漏洞分析中,你关心的也不是“函数调用语法”,而是:

  • 参数放在哪里
  • 局部变量放在哪里
  • 返回地址放在哪里
  • 输入是否能覆盖返回地址
  • 崩溃时 rip 为什么变成异常值

所以理解函数调用,是从 C 语言走向汇编、逆向和漏洞分析的关键一步。


3. 一个简单的函数调用例子

先看这个程序:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

void foo(int x) {
int y = x + 1;
printf("y = %d\n", y);
}

int main() {
foo(10);
return 0;
}

从 C 语言视角看:

  1. 程序进入 main
  2. main 调用 foo(10)
  3. foo 中计算 y = 11
  4. 打印 y = 11
  5. foo 返回
  6. main 返回
  7. 程序结束

但是从底层视角看,还要问:

  • foo 的代码在哪里?
  • 10 这个参数怎么传过去?
  • y 存在哪里?
  • foo 执行完怎么回到 main

这些问题都和栈、寄存器、调用约定有关。


4. 栈是什么

栈是一种后进先出的数据结构。

英文是:

1
stack

后进先出可以理解为:

1
Last In, First Out

也就是:

1
最后放进去的,最先拿出来

现实类比:

1
一摞盘子

你最后放上去的盘子,通常最先拿下来。

在程序运行中,栈常用于管理函数调用。

每调用一个函数,就在栈上创建一块区域,用来保存这个函数运行所需的信息。

函数返回时,这块区域被释放。


5. 程序运行时的栈

每个线程通常都有自己的栈。

也就是说:

1
2
一个进程可以有多个线程
每个线程有自己的栈

栈里常见内容包括:

  • 函数返回地址
  • 上一个栈帧的信息
  • 局部变量
  • 临时数据
  • 部分函数参数
  • 保存的寄存器

在 x86-64 Linux 中,栈通常从高地址向低地址增长。

也就是说:

1
2
push 数据时,栈顶地址变小
pop 数据时,栈顶地址变大

可以粗略画成:

1
2
3
4
5
6
7
8
9
10
11
高地址
+----------------+
| 调用者栈帧 |
+----------------+
| 返回地址 |
+----------------+
| 当前函数栈帧 |
| 局部变量 |
| 临时数据 |
+----------------+
低地址

注意:

不同架构、系统、编译器优化选项会影响具体布局。

但“函数调用会使用栈保存上下文”这个思想非常重要。


6. 什么是栈帧

栈帧英文是:

1
stack frame

可以理解为:

一个函数在栈上拥有的那一块工作区域。

每次函数调用,通常都会有一个对应的栈帧。

例如:

1
main() -> foo() -> bar()

调用过程中,栈上可能依次出现:

1
2
3
main 的栈帧
foo 的栈帧
bar 的栈帧

当前正在执行 bar 时,调用链大致是:

1
2
3
main 调用了 foo
foo 调用了 bar
bar 正在执行

GDB 中的 bt 命令看到的就是调用栈,也就是当前函数调用链。


7. 返回地址是什么

函数调用有一个关键问题:

被调用函数执行完以后,CPU 怎么知道回到调用者的哪一行继续执行?

例如:

1
2
3
4
5
int main() {
foo(10);
printf("after foo\n");
return 0;
}

执行 foo(10) 时,CPU 会跳到 foo 的代码处执行。

foo 执行完以后,需要回到:

1
printf("after foo\n");

这个位置。

所以在调用 foo 时,程序需要保存一个位置:

1
foo 返回后应该继续执行的地址

这个位置就是返回地址。

在常见 x86-64 调用中,call 指令会把返回地址压入栈中,然后跳转到目标函数。


8. call 指令大致做了什么

汇编中的 call 可以先理解成做了两件事:

1
2
1. 把下一条指令的地址压入栈,作为返回地址
2. 跳转到被调用函数的地址执行

例如:

1
call foo

大致等价于:

1
2
push 返回地址
jump foo

这不是完全等价的真实机器实现,但作为入门理解足够。

例如:

1
2
0x401150: call 0x401136
0x401155: mov eax, 0

当执行 call 0x401136 时:

  • 返回地址是 0x401155
  • CPU 跳到 0x401136 执行
  • foo 执行完后应该回到 0x401155

9. ret 指令大致做了什么

ret 用于函数返回。

它大致做:

1
2
1. 从栈顶取出返回地址
2. 跳转到这个地址继续执行

也就是:

1
pop rip

其中 rip 是 x86-64 中的指令指针寄存器,表示 CPU 下一条要执行的指令地址。

所以:

1
2
call 负责保存返回地址并跳过去
ret 负责取出返回地址并跳回来

这也是为什么返回地址对漏洞分析非常重要。

如果栈上的返回地址被错误覆盖,ret 就可能跳到错误位置。


10. rsprbp

在 x86-64 中,和栈密切相关的两个寄存器是:

1
2
rsp
rbp

10.1 rsp

rsp 是栈指针寄存器。

它通常指向当前栈顶。

当执行 push 时:

1
2
rsp 变小
数据写入 rsp 指向的位置

当执行 pop 时:

1
2
从 rsp 指向的位置取数据
rsp 变大

10.2 rbp

rbp 常被用作栈帧基址。

在没有优化或保留帧指针的情况下,一个函数常见开头是:

1
2
3
push rbp
mov rbp, rsp
sub rsp, 0x20

大致含义:

1
2
3
push rbp        保存调用者的 rbp
mov rbp, rsp 让 rbp 指向当前函数栈帧基准位置
sub rsp, 0x20 为局部变量预留栈空间

函数结尾可能是:

1
2
leave
ret

leave 大致相当于:

1
2
mov rsp, rbp
pop rbp

然后 ret 返回调用者。


11. 一个典型栈帧长什么样

以没有优化的简单函数为例:

1
2
3
4
void foo(int x) {
int y = x + 1;
printf("%d\n", y);
}

栈帧可能大致像这样:

1
2
3
4
5
6
7
8
9
10
11
高地址
+--------------------+
| 返回地址 |
+--------------------+
| 调用者保存的 rbp | <- rbp
+--------------------+
| 局部变量 y |
+--------------------+
| 临时空间 |
+--------------------+
低地址 <- rsp

注意:

这只是教学简化图。

真实情况会受到:

  • 编译器
  • 优化等级
  • ABI 调用约定
  • 栈对齐
  • 是否开启保护机制
  • 是否使用 frame pointer

等因素影响。

但是你现在需要先掌握:

1
栈帧里可能有局部变量、保存的 rbp、返回地址等内容。

12. 参数是怎么传递的

函数调用需要传参数。

例如:

1
foo(10);

参数 10 要传给 foo

在 x86-64 Linux,也就是 System V AMD64 ABI 中,前几个整数或指针参数通常通过寄存器传递。

常见顺序:

1
2
3
4
5
6
第 1 个参数 -> rdi
第 2 个参数 -> rsi
第 3 个参数 -> rdx
第 4 个参数 -> rcx
第 5 个参数 -> r8
第 6 个参数 -> r9

所以:

1
foo(10);

可能对应:

1
2
mov edi, 10
call foo

Windows x64 调用约定不同。

Windows x64 常见前四个参数:

1
2
3
4
第 1 个参数 -> rcx
第 2 个参数 -> rdx
第 3 个参数 -> r8
第 4 个参数 -> r9

这就是为什么同一个 C 函数,在 Linux 和 Windows 下反汇编看起来会不同。


13. 为什么调用约定重要

调用约定规定:

  • 参数放在哪里
  • 返回值放在哪里
  • 哪些寄存器由调用者保存
  • 哪些寄存器由被调用者保存
  • 栈如何对齐
  • 函数返回时谁清理栈

逆向工程中,如果你不知道调用约定,就很难判断:

1
2
3
mov edi, 10
mov esi, 20
call 0x401136

到底是在调用:

1
foo(10, 20)

还是其他形式。

漏洞分析中,调用约定也会影响你判断:

  • 参数来源
  • 返回值位置
  • 栈布局
  • 崩溃现场

14. 返回值放在哪里

在 x86-64 中,整数或指针返回值通常放在:

1
rax

例如:

1
2
3
int add(int a, int b) {
return a + b;
}

调用:

1
int result = add(1, 2);

add 返回后,结果 3 通常会在 rax 中。

所以你在调试器中经常会看到:

1
2
call add
mov DWORD PTR [rbp-4], eax

意思大致是:

1
2
调用 add
把 eax 中的返回值保存到局部变量 result

15. 局部变量为什么函数结束后失效

看这个例子:

1
2
3
4
int* bad() {
int x = 123;
return &x;
}

这个函数返回了局部变量 x 的地址。

这是错误的。

因为 xbad 的栈帧中。

bad 返回后,它的栈帧就不再有效。

也就是说:

1
&x 指向的是一块已经不应该继续使用的栈空间

如果后续其他函数调用复用了这块栈空间,原来的值可能被覆盖。

这类问题属于悬垂指针的一种。

虽然它不一定每次立刻崩溃,但行为是未定义的。


16. 栈溢出的直觉理解

看这个程序:

1
2
3
4
5
6
#include <string.h>

void vuln(char *input) {
char buf[16];
strcpy(buf, input);
}

buf 是一个 16 字节的局部数组,通常位于栈上。

如果输入超过 16 字节:

1
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

strcpy 不会自动检查 buf 大小,会继续往后写。

这样就可能覆盖栈上的其他内容。

栈上可能有:

  • 其他局部变量
  • 保存的 rbp
  • 返回地址

如果返回地址被覆盖,函数执行 ret 时就可能跳到错误地址。

这就是经典栈缓冲区溢出的基础。

本节不要求你写 exploit,只要求你理解:

1
2
3
局部数组在栈上
越界写可能覆盖栈帧中的关键数据
返回地址一旦被破坏,控制流可能异常

17. 为什么现代系统不容易被简单栈溢出利用

早期栈溢出比较容易被利用。

现代系统有很多保护机制,例如:

  • Stack Canary
  • ASLR
  • DEP / NX
  • PIE
  • RELRO
  • CFG

这些机制会增加利用难度。

例如:

  • Canary 用来检测返回地址附近是否被覆盖
  • ASLR 让地址难以预测
  • NX 让栈上的数据不能直接执行
  • PIE 让程序代码基址随机化

所以学习栈溢出时,不要只背 payload。

应该先理解:

1
栈布局 -> 越界写 -> 返回地址 -> 控制流 -> 防护机制

18. Linux 实验:用 GDB 观察函数调用

创建 stack_demo.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

void bar(int z) {
int w = z + 1;
printf("w = %d\n", w);
}

void foo(int x) {
int y = x + 1;
bar(y);
}

int main() {
int a = 10;
foo(a);
return 0;
}

编译:

1
gcc -g -O0 stack_demo.c -o stack_demo

这里:

  • -g 表示生成调试信息
  • -O0 表示关闭优化,方便观察栈帧

启动 GDB:

1
gdb ./stack_demo

在 GDB 中执行:

1
2
3
4
5
6
break bar
run
bt
info registers
x/30gx $rsp
x/20gx $rbp-0x40

重点观察:

  • bt 输出中的调用链
  • 当前函数是不是 bar
  • rsprbp 的值
  • 栈上有哪些看起来像地址的数据
  • 是否能找到返回地址

19. GDB 常用命令解释

19.1 break

设置断点。

1
break bar

表示程序执行到 bar 时暂停。


19.2 run

运行程序。

1
run

19.3 bt

查看调用栈。

1
bt

可能输出:

1
2
3
#0  bar (z=11) at stack_demo.c:4
#1 foo (x=10) at stack_demo.c:10
#2 main () at stack_demo.c:15

这说明:

1
2
3
main 调用了 foo
foo 调用了 bar
当前停在 bar

19.4 info registers

查看寄存器。

1
info registers

重点看:

  • rip
  • rsp
  • rbp
  • rdi
  • rsi
  • rax

19.5 x/30gx $rsp

查看内存。

1
x/30gx $rsp

含义:

1
从 rsp 指向的地址开始,以 8 字节十六进制格式,显示 30 个单位

拆开看:

部分 含义
x examine,查看内存
30 显示 30 个单位
g giant word,8 字节
x 十六进制显示
$rsp 从 rsp 寄存器指向的地址开始

20. Linux 实验:观察反汇编

在 GDB 中执行:

1
2
3
disassemble main
disassemble foo
disassemble bar

你可能看到类似:

1
2
3
4
5
6
7
push   rbp
mov rbp,rsp
sub rsp,0x10
mov DWORD PTR [rbp-0x4],edi
...
leave
ret

重点观察:

  • 函数开头是否有 push rbp
  • 是否有 mov rbp, rsp
  • 是否有 sub rsp, ...
  • 函数结尾是否有 leave
  • 是否有 ret
  • 调用其他函数时是否有 call

如果你看到的汇编和示例不完全一样,不要紧。

编译器版本、优化选项、系统环境都会影响输出。

你要抓住核心模式。


21. Windows 对照:x64dbg 中观察函数调用

在 Windows 下,你可以写类似程序并编译成 exe。

用 x64dbg 打开后,重点观察:

  • CPU 指令窗口
  • 寄存器窗口
  • 栈窗口
  • 调用栈窗口

Windows x64 下参数传递和 Linux 不同。

前四个参数通常使用:

1
rcx, rdx, r8, r9

函数调用时,你可以观察:

1
2
mov ecx, 10
call foo

这表示把第一个参数放入 ecx/rcx,然后调用 foo

Windows x64 还要求调用者在栈上预留 shadow space。

入门阶段不需要深入 shadow space,只要知道:

1
2
Windows x64 的栈布局和 Linux x64 不完全一样
但 call、ret、返回地址、栈帧这些核心概念仍然存在

22. 从逆向工程角度看本节内容

逆向工程中,函数调用是最常见结构之一。

当你在反汇编中看到:

1
2
mov edi, 10
call 0x401136

你应该想到:

1
some_function(10);

当你看到:

1
2
3
4
call puts
call printf
call CreateFileW
call VirtualAlloc

你应该分析:

  • 这个函数调用了什么 API?
  • 参数是什么?
  • 返回值在哪里?
  • 调用之后程序如何分支?

很多逆向任务其实就是恢复函数调用关系:

1
2
3
4
谁调用了谁
传了什么参数
返回值怎么使用
关键逻辑在哪个函数中

23. 从漏洞分析角度看本节内容

栈漏洞的分析离不开函数调用。

你需要能判断:

  • 当前停在哪个函数
  • 调用链是什么
  • 哪个局部变量被覆盖
  • 返回地址在哪里
  • 崩溃时 rip 是什么
  • 输入数据是否出现在栈上

例如 GDB 中看到:

1
2
Program received signal SIGSEGV
RIP: 0x4141414141414141

0x41 是字符 A

如果 rip 变成:

1
0x4141414141414141

这通常说明程序控制流被输入的 A 影响了。

这就是漏洞分析中非常重要的信号。


24. 从恶意代码分析角度看本节内容

恶意代码分析中,函数调用同样重要。

你可能会关注:

  • 哪个函数调用了网络 API
  • 哪个函数解密字符串
  • 哪个函数创建文件
  • 哪个函数创建进程
  • 哪个函数申请可执行内存
  • 哪个函数启动新线程

如果你能看懂函数调用关系,就能更快梳理恶意代码行为。

例如:

1
2
3
4
5
main
-> decrypt_config
-> connect_server
-> download_payload
-> execute_payload

这样的调用关系,可能比单独看某一行汇编更有价值。


25. 本节重点总结

你需要记住这些核心结论:

  1. 栈是一种后进先出的结构。
  2. 程序运行时,每个线程通常有自己的栈。
  3. 函数调用时,通常会在栈上创建栈帧。
  4. 栈帧中可能包含局部变量、保存的寄存器、返回地址等信息。
  5. call 大致负责保存返回地址并跳转到被调用函数。
  6. ret 大致负责从栈中取出返回地址并跳回去。
  7. rsp 通常指向栈顶,rbp 常用于定位当前栈帧。
  8. Linux x64 和 Windows x64 的参数传递约定不同。
  9. 返回值通常会放在 rax 中。
  10. 栈溢出的本质是越界写破坏了栈帧中的其他数据。

26. 本节课后作业

作业 1:观察三层函数调用

写程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

void bar(int z) {
int w = z + 1;
printf("w = %d\n", w);
}

void foo(int x) {
int y = x + 1;
bar(y);
}

int main() {
int a = 10;
foo(a);
return 0;
}

编译:

1
gcc -g -O0 stack_demo.c -o stack_demo

GDB:

1
2
3
4
5
break bar
run
bt
info registers
x/30gx $rsp

提交内容:

1
2
3
4
5
1. 源代码
2. bt 输出
3. rsp、rbp、rip 的值
4. 你认为当前调用链是什么
5. 你在栈上看到了哪些像地址的值

作业 2:反汇编函数

在 GDB 中执行:

1
2
3
disassemble main
disassemble foo
disassemble bar

或在 shell 中执行:

1
objdump -d stack_demo

要求找到:

  • call
  • ret
  • push rbp
  • mov rbp, rsp
  • sub rsp, ...

回答:

1
2
3
4
1. main 中 call foo 的地址是多少?
2. foo 中 call bar 的地址是多少?
3. bar 函数结尾有没有 ret?
4. 函数开头是否建立了栈帧?

作业 3:理解返回地址

在 GDB 中:

1
2
3
4
5
6
7
break foo
run
disassemble main
si
si
info registers
x/10gx $rsp

尝试观察:

  • 执行 call 前后 rip 怎么变
  • 栈顶是否出现返回地址
  • foo 返回后程序回到哪里

提交一段解释:

1
call 指令做了什么?ret 指令做了什么?

27. 自测题

题 1

栈为什么叫后进先出?

题 2

什么是栈帧?

题 3

返回地址的作用是什么?

题 4

call 指令大致做哪两件事?

题 5

ret 指令大致做什么?

题 6

rsprbp 的区别是什么?

题 7

Linux x64 前 6 个整数参数通常放在哪些寄存器?

题 8

为什么局部变量的地址不能在函数返回后继续安全使用?

题 9

为什么栈缓冲区溢出可能影响程序控制流?


28. 自测题参考答案

答 1

因为最后压入栈的数据会最先被弹出,就像一摞盘子,最后放上去的通常最先拿下来。

答 2

栈帧是一个函数调用期间在栈上使用的一块工作区域,里面可能保存局部变量、返回地址、保存的寄存器等信息。

答 3

返回地址用于记录被调用函数执行完后,CPU 应该回到调用者的哪个位置继续执行。

答 4

call 大致会把下一条指令地址压入栈作为返回地址,然后跳转到被调用函数执行。

答 5

ret 大致会从栈顶取出返回地址,并跳转到该地址继续执行。

答 6

rsp 通常指向当前栈顶;rbp 常作为当前函数栈帧的基准位置,用于稳定访问局部变量和保存的数据。

答 7

Linux x64 System V ABI 中,前 6 个整数或指针参数通常依次放在 rdirsirdxrcxr8r9

答 8

因为局部变量通常位于当前函数的栈帧中。函数返回后,该栈帧失效,后续函数调用可能复用这块栈空间。

答 9

因为局部缓冲区通常在栈上,越界写可能覆盖栈帧中的保存数据,甚至覆盖返回地址。函数执行 ret 时可能跳到被破坏的地址,从而影响控制流。


29. 下一节预告

下一节课会讲:

1
x86-64 汇编入门

你会进一步学习:

  • 常见寄存器
  • 常见汇编指令
  • movaddsubcmpjmpcallret
  • Linux 和 Windows 调用约定对照
  • 如何用反汇编理解 C 程序
隐藏
换装
本文作者:burpow
本文链接:https://youthfulnesszxx.github.io/2026/05/28/第03节-函数调用和栈帧/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可