【逆向工程】一文搞懂:栈与调用约定
本文将会用一个实际例子结合IDA pro工具对程序运行中栈的特点进行分析。
字节序(Endianness)
在进行进一步学习之前 ,我们先了解一些基本概念。在计算机中,不同数据类型占用不同大小字节,例如一个int类型占用4字节,一个char类型仅占用1字节。可以想象一下,一个字节就是一个小盒子,里面存放了8个bit的数据。对于一个int类型,要连续的4个盒子才能表达出完整的信息,而对于char类型,只需要一个盒子。
计算机只认识0与1的二进制数据,但对于我们人类来说,只使用二进制表示数据会显得冗长,因此,我们通常喜欢使用十六进制(hex)表示数据。例如,若使用二进制表示十进制数256,就要写成000100000000,而十六进制表示的话只用写100。
占用多个字节的数据要存放在内存中,计算机就要决定低位字节和高位字节在内存中的排序方式。(也就是说计算机要决定堆放这些盒子的顺序)
常见的排序方式有两种
小端序(Little Endian)
规则:低位字节存放在低地址处,高位字节存放在高地址处
例如:整数 0x12345678 在内存中是 78 56 34 12
应用范围:Intel x86、AMD 架构都采用小端序,在使用IDA pro进行逆向分析时,查看程序的hex表示,也都是小端序。
大端序(Big Endian)
规则:高位字节存放在低地址处,低位字节存放在高地址处
例如:整数 0x12345678 在内存中是 12 34 56 78
应用:网络传输协议(TCP/IP)通常使用大端序
实例演示
创建一个.c文件并编译为.exe文件,随后在IDA pro中反编译。
#include <stdio.h>
int main() {
int a = 0x12345678;
printf("%d", a);
return 0;
}

进程与线程
进程(Process)
进程是程序的一次运行实例,是操作系统分配资源(如内存、文件句柄等)的基本单位。
-
每个进程有自己独立的内存空间和系统资源。
-
一个进程崩溃不会直接影响其他进程。
通俗来说,你打开的每一个软件(例如,QQ,Edge浏览器等等)都算是独立的进程
线程(Thread)
线程是进程中的最小执行单元,它运行在进程的资源环境中,是CPU调度的基本单位。
- 同一进程内的多个线程共享内存空间,但每个线程有自己的栈和寄存器状态。
多个线程在一个进程中完成任务
栈
为什么需要栈
调用函数时,程序需要保存开始调用函数时的环境,以便函数执行完毕后能够正确返回,继续程序执行的逻辑。
现在,我们来看一个嵌套调用的情况。
int function_3(int a,int b)
{
return a+b;
}
int function_2(int a,int b)
{
return function_3(a,b);
}
int function_1(int a,int b)
{
return function_2(a,b);
}
当我调用函数function_1,它需要执行function_2,function_2需要执行function_3,在函数function_3中执行a+b的逻辑,并开始返回。返回的时候,需要先返回到function_2,紧接着是function_1。
也就是说,function_1是第一个被调用的,但function_1也是最后一个返回的。我们需要管理这个过程,就需要一个先进后出的逻辑。因此,在程序中使用栈来管理函数调用相关工作。
当一个进程创建时,操作系统会为它分配一套独立的虚拟地址空间,其中就包括栈空间,每个进程的栈空间是独立的,互不干扰。
栈的作用
规范来说,栈的作用有如下
- 保存函数调用信息(包括返回地址、参数、局部变量)
- 调用函数时传递参数
栈的特征
- 先进后出(LIFO):最后压入栈的数据最先被弹出。
- 从高地址向低地址增长(在x86架构中):这是一种约定,方便硬件实现。
这一小节,主要介绍了为什么需要使用栈,下一小节将介绍栈的工作机制(重点)
栈帧
定义
当一个函数被调用时,系统会在栈上为它划出一片独立的区域,用来保存它执行所需的全部信息,这个区域就叫作 栈帧(Stack Frame)。
栈帧结构
push ebp
mov ebp, esp
....
mov esp, ebp
pop ebp
ret
这就是栈帧技术管理函数调用。
在函数调用过程中,CPU 需要知道“栈顶在哪里”“当前函数的栈范围有多大”,这两件事分别由两个寄存器负责:
- ESP(Stack Pointer,栈顶指针):
始终指向当前栈顶的位置,也就是下一次 push(入栈) 或 pop(出栈) 操作的目标位置。
栈是向低地址方向增长的,因此每次入栈(push)都会让 ESP 减小,出栈(pop)时 ESP 增大。 - EBP(Base Pointer,基址指针):
用来标记当前函数栈帧的起始位置。
它相对稳定,不会像 ESP 那样频繁变化,因此函数中的局部变量、参数都能通过固定的偏移量相对 EBP 来访问。
栈的工作机制
编写一个简单的文件
#include <stdio.h>
__attribute__((noinline)) int add(int a, int b) { return a + b; }
int main() {
int x = add(2, 3);
printf("%d", x);
return 0;
}
gcc -fno-omit-frame-pointer -O0 -g test.c -o test编译时关闭优化,保留完整的栈帧结构

这个就是add函数的栈帧结构
在一个进程中,每当我们调用一个函数时,就会在这个进程中的栈空间里开辟一个新的栈帧。
push rbp
mov rbp,rsp
这两条语句就是起到了建立新栈帧空间的作用
push rbp时,做了两件事。
第一件事,rsp=rsp-8,这是为保存数据挪出空间,因为栈是向低地址增长的,因此要减8。
第二件事,就是把rbp的值写入栈中。
注意,这里并不是把当前函数的返回地址写入,返回地址的写入是执行
call指令时,CPU自动就会将返回地址压入栈中。
这里其实隐含了一种“递归”的思想,我们来重新梳理一下,当我们开始执行任何一个函数时(当前是执行了add函数),就会重新建立一个新的栈帧,push rbp里的rbp就是上一个栈帧结构的基址,我们要在上一个栈帧结构的基础上建立一个新的栈帧。也就是说,我们不关心上一个栈帧到底是什么,只需要得到它的基址就行。
mov rsp,rbp
pop rbp
retn
这三条语句的效果就显而易见了,先将函数栈帧的起始地址返回到rsp中,此时rsp就指向了栈帧起始地址。
pop rbp也干了两件事
第一件事,先弹出rbp的值
第二件事,rsp向上移动8个字节
现在,调用者的栈环境完全恢复,RBP、RSP 都回到了函数调用前的状态。
retn是函数返回指令,由 CPU 自动完成以下动作:
-
从当前栈顶(
[rsp])取出 返回地址(就是调用时call自动压入的那一条); -
把这个地址加载到 指令指针寄存器(RIP),
让程序跳转回调用者的下一条指令处继续执行; -
同时
rsp再向上移动 8 字节(弹出返回地址)。
回到文章最初那个例子
int function_3(int a,int b)
{
return a+b;
}
int function_2(int a,int b)
{
return function_3(a,b);
}
int function_1(int a,int b)
{
return function_2(a,b);
}
当调用function_1时,它会在栈空间上开辟一个新的栈帧空间,而这个栈帧空间的起始地址就是上一个函数的基址(保底是main()函数,也可能是其他函数,不重要),然后function_1调用了function_2,就会继续开辟一个新的栈帧空间,此时起始地址就是function_1的基址,如此进行下去。这样最终返回的时候,就能保证按顺序返回。
函数调用约定
调用者与被调用者
- 调用者:调用函数的一方
- 被调用者:被调用的函数
例如,在main()函数中调用printf()函数,调用者就是main()函数,被调用者就是printf()函数。
细心的你也许注意到了前文反汇编图片中,add函数前面有一个cdecl,这其实是一种函数调用约定,规定函数调用时如何传递参数。这类约定有三种类型,分别是cdecl stacall fastcall
cdecl(C Declaration)
C 语言默认调用约定(大多数编译器默认)。
- 参数传递:通过栈从右到左依次压入。
- 栈清理者:调用者(Caller) 负责清理参数。
stdcall
Windows API 中的常见约定。
- 参数传递:同样通过栈从右到左压入。
- 栈清理者:被调用者(Callee) 清理参数。
fastcall
用于提高性能的调用方式。
- 参数传递:前两个参数通过寄存器传递(通常是
ECX,EDX),其余通过栈。 - 栈清理者:一般仍由被调用者清理。
至此,你已了解了栈的基本工作原理如果想更直观地理解,可以访问我的 GitHub和B站,我使用 Manim 制作了栈帧动画演示。觉得有帮助的话,欢迎给我一个 Star!✨
Github:https://github.com/wasim404/manim_animation.git
谢谢

被折叠的 条评论
为什么被折叠?



