1 函数的基本概念
在C语言中,函数是程序的基本执行单元。一个函数的定义包括返回类型、函数名、参数列表和函数体。例如:
int add(int x, int y) { // 函数定义int z = x + y;return z;
}
在使用函数前,通常需要声明( declaration)其类型,让编译器知道函数的存在和格式:
int add(int, int); // 函数声明
调用(Call)函数时,需要传递参数(Argument)。C语言默认采用传值调用(Pass by Value),即函数内部操作的是实参值的一份临时拷贝(形参),修改形参不会影响原始实参。
2 栈与栈帧的核心概念
2.1 什么是栈 (Stack)
栈是程序运行时内存中的一块特殊区域,遵循 “后进先出” (LIFO, Last In First Out) 的原则。它主要用于支持函数调用。栈的生长方向是从高地址向低地址扩展。
2.2 什么是栈帧 (Stack Frame)
每次函数调用,操作系统都会在栈上为其分配一块独立的连续内存区域,称为栈帧 (Stack Frame) 或活动记录 (Activation Record)。函数执行结束后,其对应的栈帧被销毁。
栈帧的核心作用包括:
- 存储函数的参数
- 保存返回地址:函数执行完后需要回到调用者的位置
- 存储函数的局部变量
- 保存调用函数的栈帧基址:以便被调函数返回后能恢复调用函数的栈帧
- 提供临时数据的存储空间:如表达式计算的中间结果
2.3 寄存器的作用
在x86架构下,有两个关键寄存器用于管理栈帧:
- ESP (Extended Stack Pointer):栈指针寄存器,始终指向当前栈帧的顶部。
- EBP (Extended Base Pointer):基指针寄存器,指向当前函数栈帧的底部。通过EBP,可以稳定地访问栈帧内的参数和局部变量(因为ESP在函数执行过程中会随着push/pop操作而变化)。
3 函数调用过程中栈帧的详细布局与变化
我们以下面的简单代码为例,深入剖析栈帧的创建和销毁过程:
#include <stdio.h>int Add(int x, int y) {int z = 0;z = x + y;return z;
}int main() {int a = 10;int b = 20;int c = 0;c = Add(a, b);printf("%d\n", c);return 0;
}
3.1 main
函数栈帧的创建
在 main
函数被调用之初(其内部代码执行前),会先建立自己的栈帧:
push ebp ; 将调用main函数的函数(如invoke_main)的ebp压栈保存
mov ebp, esp ; 将当前esp的值赋给ebp,此刻ebp成为main函数栈帧的新基址
sub esp, 0E4h ; 为main函数的局部变量、临时数据等预留空间(0E4h字节)
此时栈空间的布局大致如下(地址从高到低增长):
地址 | 内容 | 说明 |
---|---|---|
… | … | … |
ebp + 4 | … | |
ebp | 旧的ebp值 | 调用者的栈基址 |
ebp - 4 | 可能保存的寄存器(如edi) | |
ebp - 8 | int a (值为10) | main的局部变量 |
ebp - 14h | int b (值为20) | main的局部变量 |
ebp - 20h | int c (值为0) | main的局部变量 |
… | … (可能包含对齐填充) | |
esp | 栈顶 |
3.2 调用 Add
函数:参数传递与栈帧变化
执行 c = Add(a, b);
时,会发生以下步骤:
-
参数从右向左压栈:
mov eax, dword ptr [ebp-14h] ; 将变量b的值(20)存入eax push eax ; 将参数y (b的值) 压栈 mov ecx, dword ptr [ebp-8] ; 将变量a的值(10)存入ecx push ecx ; 将参数x (a的值) 压栈
此时栈顶附近增加了两个新的值(
y
和x
),ESP 随之减小。 -
执行
call
指令:call 00D110B9 ; 调用Add函数(地址由编译器决定)
call
指令做了两件事:- 将返回地址(即
call
指令下一条指令的地址,例如00D11917
)压入栈。 - 跳转到
Add
函数的地址开始执行。
此时,在参数之上又压入了返回地址,ESP 再次更新。
此时栈的布局变为:
地址 内容 说明 - 将返回地址(即
| … | … | … |
| ebp + 4 | … | |
| ebp | 旧的ebp值 | 调用者的栈基址 |
| … | … | |
| ebp - 20h | c
(0) | |
| … | … | |
| ← ESP指向这里 | 返回地址 | 00D11917
|
| | 参数 x (10) | a
的拷贝 |
| | 参数 y (20) | b
的拷贝 |
3.3 Add
函数栈帧的创建与执行
进入 Add
函数后,它会立即建立自己的栈帧:
push ebp ; 将main函数的ebp压栈保存
mov ebp, esp ; 将当前esp设为Add函数栈帧的基址(ebp)
sub esp, 0CCh ; 为Add的局部变量等开辟空间
现在栈的布局是:
地址 | 内容 | 说明 |
---|---|---|
… | … | … |
ebp + 8 | 参数 x (10) | 通过[ebp+8] 访问 |
ebp + 0Ch | 参数 y (20) | 通过[ebp+0Ch] 访问 |
ebp + 4 | 返回地址 | 00D11917 |
ebp | main的ebp | 保存的main函数基址 |
ebp - 4 | 可能保存的寄存器 | |
ebp - 8 | int z (0) | Add的局部变量 |
… | … | |
ESP | Add栈帧顶部 |
接着执行 Add
函数体:
mov dword ptr [ebp-8], 0 ; int z = 0;
mov eax, dword ptr [ebp+8] ; 将参数x的值(10)存入eax
add eax, dword ptr [ebp+0Ch] ; 加上参数y的值(20),结果在eax
mov dword ptr [ebp-8], eax ; z = x + y; (结果30存入z)
mov eax, dword ptr [ebp-8] ; 将z的值(30)存入eax,作为返回值
返回值通常存放在EAX寄存器中。
3.4 Add
函数栈帧的销毁与返回
函数返回前,需要销毁其栈帧:
mov esp, ebp ; 将esp移回ebp处,释放Add函数的所有局部变量空间
pop ebp ; 弹出栈顶值到ebp,此时栈顶值正是之前保存的main函数的ebp,从而恢复main函数的栈帧基址
ret ; 从栈顶弹出返回地址(00D11917)并跳转回去
ret
指令执行后,ESP 会指向之前压入的参数 x
和 y
所在的位置。回到 main
函数后,它会清理栈上的参数:
add esp, 8 ; 将栈顶指针esp向上移动8字节,清理掉两个4字节的参数
mov dword ptr [ebp-20h], eax ; 将eax中的返回值(30)存储到变量c中
4 使用GDB调试器观察栈帧
理论结合实践是关键。你可以使用GDB(GNU Debugger)来动态跟踪函数调用过程,直观地观察栈帧、寄存器和内存的变化。
4.1 准备工作
- 编译带调试信息的程序:使用
gcc
的-g
选项编译你的C程序,以便GDB能显示源代码和调试信息。gcc -g -o my_program my_program.c
- 启动GDB:
gdb my_program
4.2 基本调试命令与观察点
在GDB中,你可以使用以下命令来观察栈帧(以分析上面的 Add
函数调用为例):
命令 | 说明 |
---|---|
break main 或 b main | 在 main 函数入口处设置断点 |
break add 或 b add | 在 add 函数入口处设置断点 |
run 或 r | 开始运行程序,直到遇到第一个断点 |
nexti 或 ni | 执行一条汇编指令(Step instruction) |
stepi 或 si | 执行一条汇编指令,如果是call指令则会进入函数(Step into instruction) |
info registers | 显示所有寄存器的当前值(重点关注 eax, ebp, esp, eip) |
print $ebp | 打印ebp寄存器的值 |
print $esp | 打印esp寄存器的值 |
x/20xw $esp | 以十六进制字(4字节)格式检查栈指针($esp )附近20个字的内存内容 |
disassemble 或 disas | 反汇编当前函数的汇编代码 |
backtrace 或 bt | 显示当前的函数调用栈回溯(call stack) |
continue 或 c | 继续运行程序,直到下一个断点或程序结束 |
4.3 实践观察步骤
- 在
main
函数开始和Add
函数入口处设置断点:(gdb) b main
,(gdb) b add
- 运行程序:
(gdb) run
- 程序会在
main
开始处停下。使用(gdb) disas
查看main
函数的反汇编代码。 - 单步执行(
ni
)直到call add
指令之前。 - 记录下此时 ESP 和 EBP 的值:
(gdb) info registers esp ebp
- 查看此时栈的内容(例如,ESP指向的位置):
(gdb) x/10xw $esp
- 执行
call
指令(ni
)。观察 ESP 的变化(减少了4字节,因为压入了返回地址),并再次查看栈顶内容,应该能看到返回地址。 - 现在进入
Add
函数。再次查看反汇编(gdb) disas
。 - 单步执行
Add
函数开头的push ebp
、mov ebp, esp
、sub esp, ...
等指令。每执行一条,观察 ESP 和 EBP 寄存器值的变化,并结合x/xw $esp
或x/xw $ebp
查看栈内存内容。 - 重点关注
mov eax, dword ptr [ebp+8]
这样的指令,它正是在通过 EBP + 偏移 来访问参数。你可以用(gdb) print *(int*)($ebp+8)
来验证是否拿到了参数x的值。 - 当执行到
ret
指令前,观察 EAX 寄存器的值,它应该保存着返回值。 - 执行
ret
后,注意程序流是否跳回了main
函数,以及 ESP 如何变化(指向参数所在位置)。 - 回到
main
后,执行add esp, 8
,再次观察 ESP 的变化,确认参数被清理。
通过以上步骤,你可以非常直观地看到栈帧的创建、使用和销毁全过程,以及参数和返回值是如何传递的。
5 C代码与汇编代码对比
理解汇编代码是逆向工程的基石。下面是一个简单的对比,展示了C代码和它可能对应的x86汇编代码(使用GCC风格):
C代码 | 汇编代码 (x86) | 说明 |
---|---|---|
int a = 10; | mov DWORD PTR [ebp-0x4], 0xa | 将立即数10(0xa)存入[ebp-4]的地址(局部变量a) |
int b = 20; | mov DWORD PTR [ebp-0x8], 0x14 | 将立即数20(0x14)存入[ebp-8]的地址(局部变量b) |
c = add(a, b); | mov eax, DWORD PTR [ebp-0x8]``push eax``mov eax, DWORD PTR [ebp-0x4]``push eax``call <add>``add esp, 0x8``mov DWORD PTR [ebp-0xc], eax | 参数从右向左(b then a)压栈,调用函数,清理栈空间,保存返回值到c |
int z = x + y; (在add函数内) | mov eax, DWORD PTR [ebp+0x8]``add eax, DWORD PTR [ebp+0xc]``mov DWORD PTR [ebp-0x4], eax | 通过EBP+偏移访问参数(x在[ebp+8], y在[ebp+12]),结果存入局部变量z([ebp-4]) |
return z; | mov eax, DWORD PTR [ebp-0x4] | 将返回值放入eax寄存器 |
这种对比能帮助你建立高级语言与低级机器指令之间的直观联系,是逆向分析中不可或缺的能力。