C语言逆向基础:函数参数传递与返回值陷阱全解析
2026/9/13 15:52:19 网站建设 项目流程

干逆向这行的朋友应该都有过一个体会:C语言写了好几年,指针、结构体、内存管理都门儿清,但一拿到反汇编代码就发懵。尤其是函数调用这块,一段看着挺规整的C代码,编译出来汇编却绕来绕去,参数一会儿在寄存器里,一会儿又跑到栈上,返回值更是玄乎,一个eax走到哪跟到哪。这其实就是典型的“正向写得熟,逆向看不透”。

今天这节C语言逆向学习基础课,咱们就把函数参数传递与返回值陷阱单独拎出来掰碎讲清楚。这堂课解决的就是一个核心问题:当你面对一段没有符号信息的汇编代码时,怎么判断它是个函数、参数有几个、参数是什么类型、返回值又是什么。搞清楚这些,你才算真正开始“逆向”而不是“看汇编”。适合刚接触逆向、被反汇编代码劝退的读者,也适合想搞明白编译器后端到底干了啥的C语言开发者。

1. 函数调用的全景图:参数和返回值是如何在底层流动的

1.1 一次函数调用,CPU到底在做什么

先拨开迷雾,把函数调用这件事拉回到CPU视角。很多C语言学习者对函数理解的终点就是“调用时把参数传进去,函数算完把结果传回来”,但底层的问题是:函数之间怎么传递数据?寄存器还是内存?要是都用栈,栈又怎么分配和回收?

其实函数调用的底层动作并不复杂,核心就是三步。第一步,调用方(caller)按照某种约定把参数放到指定位置;第二步,执行call指令,这个指令会先把下一条指令的地址(也就是返回地址)压栈,然后跳转到被调用函数(callee)的入口;第三步,被调用函数执行完后,通过ret指令把栈顶的返回地址弹出来,CPU接着回到调用方继续跑。

这三步里的关键在于“某种约定”,它决定了逆向分析时你看什么、怎么猜。如果不了解约定,你看到mov edi, 5这种指令根本不知道它是在传参还是随便写了个数。在x86-64平台,这个约定叫System V AMD64 ABI,Windows上有另一套,但核心思想一致:能用寄存器就用寄存器,寄存器不够了再上栈。

用生活化的类比来说,函数调用就像你去柜台办业务。参数就是你要提交的材料,有些小材料你直接拿在手上(寄存器),材料太多了手上拿不下就放包里(栈)。柜台人员(callee)接到材料后先放下自己的私人物品(保存栈帧),办完业务再把自己的东西收拾好,最后告诉你结果(返回值)。你知道吗?这个“结果”一般也放在一个固定位置,就像业务回执单永远从同一个窗口递出来。

1.2 调用约定:C语言编译器与你心照不宣的契约

不同架构、不同操作系统下的调用约定直接影响逆向分析的难度。我最初学逆向时,最头疼的就是分不清哪些寄存器是参数的临时搬运工,哪些是函数自己的“私人财产”。后来我把常见约定整理成一张表,基本就再没犯过迷糊。

以最常用的x86-64 Linux/macOS环境为例:

参数顺序整数/指针参数浮点参数备注
第1个参数RDIXMM0额外参数使用栈
第2个参数RSIXMM1方向:从左到右
第3个参数RDXXMM2
第4个参数RCXXMM3
第5个参数R8XMM4
第6个参数R9XMM5
第7个及以后栈上栈上入栈顺序从右到左

返回值约定则相对简单:

返回类型存放位置
整数/指针(≤64位)RAX
浮点数XMM0
128位整数或小结构体RAX:RDX 组合
大结构体调用者分配缓冲区,地址作为隐藏参数传入,函数将结果写入该缓冲区

这里有个关键点,也是很多新手踩坑的地方:32位x86平台用的是cdecl调用约定,参数全部压栈,而且是从右往左压。所以你在分析老程序时,看到一堆push指令接一个call,别慌,那些push就是在传参。

记住一个实用结论:在x86-64逆向里,看到被调函数开头用rdirsiedx这些寄存器做初始化,基本可以断定它们就是函数参数。这就相当于白纸黑字告诉你,这个函数有几个参数、每个参数从哪来。

2. 参数传递的底层拆解:寄存器与栈的接力

2.1 整型、指针参数的默认路线

我最喜欢在逆向课上让学生干一件事:写一个简单的加法函数,编译成汇编看看。比如下面这个C语言程序,正常运行会输出3:

#include <stdio.h> int add(int a, int b) { return a + b; } int main(void) { int result = add(1, 2); printf("%d\n", result); return 0; }

用gcc编译并输出汇编:

gcc -S -O0 -o add.s add.c

-O0表示不优化,能最大化保留C语言原本的逻辑结构,最适合初学者对照学习。生成的汇编关键片段,去掉一堆符号修饰后是这样:

add: push rbp mov rbp, rsp mov DWORD PTR [rbp-4], edi mov DWORD PTR [rbp-8], esi mov eax, DWORD PTR [rbp-4] add eax, DWORD PTR [rbp-8] pop rbp ret main: push rbp mov rbp, rsp sub rsp, 16 mov edi, 1 mov esi, 2 call add mov DWORD PTR [rbp-4], eax mov eax, DWORD PTR [rbp-4] mov edi, eax call printf mov eax, 0 leave ret

看到这段汇编,你应该能总结出几条真正的规律。

第一,main函数调用add之前,把参数1放进了edi,把参数2放进了esi,这就是System V约定里第一、第二个整型参数的位置。这里有个细节值得注意:C代码里是int,汇编用的是edi/esi而非rdi/rsi,因为32位int只占32位,操作低32位寄存器足够,还能顺带把高32位清零。

第二,add函数内部做的第一件事是把参数从寄存器存到栈上。mov DWORD PTR [rbp-4], edi就是把edi里的值存入局部变量区。为什么从不优化代码里能看到这种操作?因为编译器为了调试方便,会把寄存器里的参数“落地”到栈上,生成一个可寻址的局部变量副本。这样你在gdb里就能print出参数的值。

第三,add计算完a + b后,结果放在eax中。这就是返回值的默认存放位置。mainmov DWORD PTR [rbp-4], eax把返回值又从eax取走存到局部变量result中。整个数据流动路径清晰得像是被标记了路牌。

2.2 浮点参数、结构体参数的特殊通道

整数和指针参数走RDI、RSI、RDX这几个寄存器,那浮点数呢?System V约定给了浮点参数独立的通道:XMM0到XMM7。这意味着一个函数如果参数是double类型,你会看到指令操作的是xmm0而不是rdi

我踩过一次实实在在的坑。早期逆向一个数值计算程序时,看到一个函数传入一个整数和一个浮点数,我本能地认为整数参数在rdi,浮点数在xmm1,结果完全对不上。后来才意识到,这个函数签名大概是double func(int x, double y),整数用了edi,浮点用的其实是xmm0,因为第一个参数的寄存器是按参数类型序列分配的,浮点参数和整型参数各自有一组编号。

结构体参数则是另一个容易翻车的地方。如果结构体特别大,超过16字节,编译器不会一股脑压到寄存器,而是把整个结构体复制到栈上,调用方和被调用方都通过栈偏移来访问。要是结构体足够小(例如两个int拼成的8字节结构体),它会被拆开放进寄存器,一个结构体字段对应一个寄存器槽位。

这一点对逆向特别有提示作用:当你反汇编看到一个函数开头频繁访问rbp附近的栈地址,而且偏移量很大,它很可能是在接收一个打包传进来的结构体参数。别急着把它当成局部变量,先看调用方传参的代码。

2.3 参数传递中最容易被忽略的坑

理论说多了容易飘,落地时总有几个坑是所有人都得挨一遍的。

第一个坑是数组参数退化成指针。你写void func(int arr[10]),看着像是传了10个int进去,但实际上编译后形参就是一个int *,汇编里只传了一个地址到rdi。所以你反汇编时根本看不到那10个数被复制进栈,只看到一个指针。这也是为什么sizeof(arr)在函数内部永远是8(64位指针大小),而不是40。逆向时你要是按10个int去栈上找数据,方向从一开始就错了。

第二个坑是可变参数函数(如printf)的寄存器使用。System V ABI里,可变参数函数需要额外设置al寄存器来表示使用了多少个向量寄存器。反汇编printf的调用点,经常能看到mov eax, 0这种指令,就是在告诉被调函数:“我没用XMM寄存器”。如果你在分析时忽略al的设置,遇到浮点格式串时就会莫名奇妙地输出错误值。

第三个坑是参数求值顺序。C语言标准从未规定函数参数的求值顺序是左到右还是右到左,编译器可以自由决定。反汇编时如果你妄图根据push参数的顺序反推代码书写顺序,很容易得出完全相反的结论。在x86-64下,参数进寄存器一般是从左到右依次放,但求值的顺序可能完全不同,比如最右的参数先被计算并缓存,然后再填靠左的寄存器。这就要求你在逆向时必须把“谁先执行”当成未定义事件,不要被源代码习惯带偏。

3. 返回值陷阱:EAX是个“共享车位”

3.1 返回值的默认存放位置及扩展

前面提到过,整数返回值放在eax/rax中。听上去简单,但逆向里的麻烦在于:rax不只是用来看函数结果的,它还是大量算术指令的默认操作数,是系统调用的号码寄存器,是函数内部临时运算的常客。所以你不能看到一个指令操作rax就说它跟返回值有关,要结合上下文判断这到底是不是函数的出口位置。

从逆向角度,判断函数返回值最可靠的方法是看ret前后的寄存器状态。一个函数在ret之前,如果往eax里放了一个明确计算出来的值,且之后的调用方立刻读取eax,那基本可以确定这就是返回值。相反,如果一个函数ret前没碰eax,那它大概率是个void函数,返回值无意义。

这里还有一个容易被带偏的细节:64位程序的返回值,如果类型是long或指针,会用完整的rax返回;但如果类型是int,编译器通常只保证eax有效,高32位可能是任何值。有些优化情况下,编译器会在写eax时自动把高32位清零(因为x86-64写32位寄存器有这个硬件特性),但不要依赖这个行为。

3.2 大结构体返回的隐藏指针

比单个返回值更特殊的是“返回一个结构体”。我在分析一个图形库时遇到过这样一个函数,反汇编看到调用方先sub rsp, 80,然后mov rdi, rsp,接着又设置了rsirdx,我当时猜测这个函数有三个参数,但怎么都对不上号。后来查了ABI文档才算彻底明白,这里第一个参数rdi根本不是业务参数,而是编译器偷偷分配的返回缓冲区地址。

具体机制是这样的:当一个函数要返回一个大于16字节的结构体时,调用方会在自己的栈帧上分配一块足够大的空间,然后把这块空间的地址作为第一个参数(隐藏参数)传给被调用函数。被调函数把要返回的结构体数据写进这块缓冲区,然后在rax中返回这个缓冲区的地址。从C语言源码看来是“返回结构体”,但从汇编看来是“调用方提供地址,被调函数帮你把数据填进去”。

这个陷阱我称之为“隐藏的第一参数”。一旦你没意识到rdi指向返回缓冲区,整个函数参数分析都会崩盘。比如一个业务上需要两个参数的函数,在反汇编里可能看到三个寄存器被赋值,你以为源码里肯定有三个参数,实际上中间藏了一个编译器自己加的返回地址。逆向工程中识别这种隐藏参数,是判断函数原型准确性的分水岭。

3.3 三种典型的返回值陷阱现场

先看第一种:返回值被无视。调用方调用了函数但完全不用返回值,比如你没把scanf的返回值当回事。在逆向里,这种调用的特征是call之后没有任何读取rax的指令,函数返回值白白丢在寄存器里。反过来说,如果你看到某个函数调完后紧接着对rax做了条件判断,那么那个返回值通常承载着成功/失败信息,可能是错误码或指针有效性标志。

再看第二种:返回指针指向栈空间。C语言里最常见的“悬空指针”bug:函数返回一个指向局部变量的指针。这种代码在开优化时经常整出匪夷所思的行为,因为函数返回后栈帧销毁,那块内存随时可能被后续调用覆盖。逆向中遇到这种情况更难缠,你看到函数返回了一个地址,顺着地址查数据,发现数据在函数返回后一会儿对一会儿错,因为栈上同一位置被另一个函数占用了。这并不一定是编译器bug,可能源码本身就是个未定义行为大户。

第三种是返回值的高8位残留。有些平台ABI规定,小于int的类型(如charshort)作为返回值时,只保证低几位有效。你要是执着地读取整个rax,就会把一堆垃圾高位当成数据。正确的逆向做法是只关心与类型宽度对应的位,或者看调用方是否对eax做了符号扩展或零扩展操作,通过这个扩展指令也能反向推断出返回值的类型。

4. 一个实例:从反汇编反向推导函数原型

4.1 准备环境与反汇编

光说不练假把式。我准备了一个小例子,目标是让你体验一下“拿到汇编,反推出C语言原型”的全过程。我们待会儿要分析的是一个编译后的二进制片段,假设你没有任何源文件和符号信息。

先用一个简单的C程序生成汇编:

int calculate(int a, int b, int c) { int sum = a + b; return sum * c; }

编译命令:

gcc -S -O1 -o calc.s calc.c

这次用-O1,让编译器稍微做点优化,更贴近真实逆向遇到的代码风格。生成的汇编核心部分如下:

calculate: lea eax, [rdi+rsi] imul eax, edx ret

你没看错,优化后的函数就这三条指令。怎么从这几条指令反推出原来的C代码?这是真正的逆向思维训练。

4.2 逐条解读汇编

第一条指令lea eax, [rdi+rsi],它的意思是把rdi + rsi计算出来存入eax,但不访问内存。在x86-64上,lea经常被编译器用来做算术运算。因为rdi是第一个整型参数,rsi是第二个整型参数,edi/esi是它们低32位的别名,所以这条指令实际在算int + int

第二条指令imul eax, edx,把eaxedx相乘,结果存回eaxedx是第三个参数的32位寄存器,所以这一步是在做乘法。

第三条指令ret,返回时eax就是运算结果。

把这三条串成C语言逻辑:函数接收三个整型参数,先做加法,再做乘法。所以原函数原型大概是:

int calculate(int a, int b, int c) { return (a + b) * c; }

看到没有,从汇编反推C代码的关键不是逐行翻译,而是理解寄存器的角色:rdi/rsi/rdx分别是第一、第二、第三个参数,计算过程体现在寄存器间的数据流动,最后eax作为结果输出。整个推断过程就像拼图,每一块寄存器都有它固定的身份。

4.3 实战技巧:没有源码时怎么判断函数原型

实战中你遇到的情况肯定比这复杂,函数可能几百行,参数可能五六个。这时候我建议你按下面四步走。

第一步,找函数入口。在现代编译产物里,函数入口通常有endbr64(针对CET保护)或push rbp; mov rbp, rsp这样的序列。找到入口后立刻注意它操作了哪些寄存器。如果函数开头就把rbxr12这些被调用者保存的寄存器压栈,说明函数内部要使用它们,这往往意味着程序逻辑比较复杂。

第二步,记录参数寄存器。从上到下扫描函数的汇编指令,凡是第一次以rdirsirdxrcxr8r9的32位或64位形式出现在movleaaddsubimul等指令中的,极大概率就是参数。如果这些寄存器只出现为指针解引用的基地址,那参数类型多半是指针。

第三步,观察栈帧布局。sub rsp, 0x20之类的指令决定了局部变量区大小。如果一个函数开头大量分配栈空间,很可能是参数里包含了结构体传值,或者是局部变量太多需要临时存储。栈上偏移较大的位置,往往对应被保存的寄存器或传入的栈参数。

第四步,锁定返回路径。找到所有ret指令,看执行到ret之前在做什么操作。常见的返回值准备指令包括mov eax, 常量(返回固定值)、mov rax, [rbp-8](返回局部变量)、xor eax, eax(返回0,也可能是错误码或假值)。注意,如果函数既有错误分支又有成功分支,每个分支在跳转前都会设置不同的eax值,这就构成了一张错误码表,逆向时可以用它反推函数的业务逻辑。

5. 常见问题速查表与排查技巧实录

5.1 常见问题速查表

这一节我把实际操作中反复遇到的高频问题整理成了一张速查表,方便你以后逆向时直接对照。

现象可能原因破解思路
函数参数在栈上,不在寄存器参数超过6个,或二进制是32位程序重点看call之前的push指令顺序,从右往左看
函数开头的第一个操作是mov rdi, rsp返回值是大结构体,RDI是隐藏的返回缓冲区地址不要把这个指针当成业务参数
参数类型是浮点,但编译产物中看不到XMM寄存器代码可能被优化掉了,或参数实际是整型被当作浮点读取检查调用方是否向浮点寄存器写入值
ret前连续多条指令操作rax返回值被多个分支分别设置从函数结构找分支判断点
调用函数后接着test rax, raxjz返回值是标志值,用于错误处理或空指针判断提取判断分支,对应枚举错误处理逻辑
函数用eax返回了结构体首地址是返回大结构体的隐藏指针模式,或返回了堆内存指针查调用方是否对该指针继续解引用

5.2 排查技巧:假设-验证法

实际操作中,逆向一个函数原型很少一次就能对上,我自己经常用的是“假设-验证”的迭代思路。

假设阶段:根据你看到的前几条指令,先猜一个函数签名。比如看到rdirsi都被当作地址使用,就假设函数是两个指针参数的函数。

验证阶段:找调用方的代码,看看调用这个函数之前,它往rdirsi里放的是什么。如果放的是栈变量的地址,而那个栈变量是个字符串缓冲区,那你的假设成立一半。如果放的是整数值,那你之前“当作地址”的假设就要推翻重来。

这种思路还有一个特别实用的场景:判断返回值的真正用途。看到一个函数返回后rax立刻被存进局部变量,后面过了一大段才用,你可以暂时忽略它的具体值,先观察它被哪些指令消费。如果最终被当成指针解引用了,它大概率是malloc或字符串查找类的函数;如果被当成容量参数和另一个数做比较,那它很可能返回的是长度或计数。这种方式被我称为“出口反推法”,从事后使用场景倒推出入口类型,比死抠指令含义高效得多。

5.3 几条实践心得

讲到最后,分享几条我自己总结的体会。

第一条,别一开始就上动态调试。先把静态汇编读明白,再去用gdb验证寄存器值,效率会高很多。很多人一上来就断点、单步,结果被一堆中间状态搅浑了思路。静态分析能帮你建立“预期的执行路径”,动态调试只负责验证和纠偏。

第二条,优化等级不同,汇编风格完全不同。-O0的代码啰嗦得像老太太的裹脚布,-O2的代码精简到你可能认不出原逻辑。我建议在学习阶段,同一个C函数,分别用-O0-O1-O2三个等级编译,对照着看,你会很快摸清编译器在不同优化策略下的思考方式,对参数传递和返回值的理解也会更立体。

第三条,善用工具但别迷信工具。反汇编器能帮你把AA55这种机器码变成push rbp,但你拿来直接分析的永远是符号化之后的汇编,而不是机器码。像objdumprizinGhidra都可以用,但它们只会机械地解码,不会替你思考参数类型和业务语义。真正决定分析质量的,还是你对ABI和编译原理的理解。

第四条,看别人的反汇编输出时,先确认平台和调用约定。我之前在Windows x64的可执行文件里套用Linux的参数寄存器规则,结果一路分析全错位。两者的参数寄存器是完全不同的,Windows x64用rcxrdxr8r9,第一参数不在rdi里。这种低级错误,一次就够你长记性。

函数参数和返回值在底层其实没那么玄乎,翻来覆去就是寄存器、栈和调用约定这三样东西的组合。把这堂课里的规则记牢,再上手几个实际二进制练一练,你会发现自己读反汇编代码的速度能快上一截。下次遇到一个陌生函数,至少能一眼看出它吃几个参数、大概返回个什么东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询