1. 先搞清楚:函数调用时参数到底去哪儿了
第一次调一个不带符号的二进制文件时,最容易懵的地方不是看不懂指令,而是明明知道某个函数要三个参数,反汇编里却只看到mov eax, [ebp+0x8]这种莫名其妙的寻址,或者mov edi, 0x1之后再也没了下文。我第一次碰这类问题是在读一段 MIPS 编译出来的程序,硬是盯着lw a0, 0x10(sp)想了半个小时,才意识到自己一直默认的"参数在栈上"这个前提,在那个架构上根本不成立。
寄存器传参这件事,说白了就是**函数调用约定(calling convention / ABI)**里最硬的一部分:谁负责把参数放进哪个容器里,谁负责在调用结束后清理,返回值放哪儿,栈要按什么粒度对齐。这套规则不是语言规定的,而是每个架构、每个操作系统、每种数据模型自己定下来的。同一份 C 代码,用 x86 编出来参数全在栈上,用 x86-64 编出来前六个参数全在寄存器里,用 ARM64 编出来前八个在寄存器里,用 MIPS 编出来参数虽然在寄存器里,但栈上还专门留了一块"影子区"给它们安家。
这篇只聊调用约定本身——怎么识别、怎么计算、怎么验证。不讲任何具体的利用手法,不构造任何载荷。原因很实在:连参数从哪个寄存器进来都没搞清楚就去分析栈溢出,最后得到的结论多半是错的。适合三类人看:刚接触二进制分析、想做逆向但被汇编劝退、以及写内联汇编时参数莫名其妙传错的。读完之后你应该能做到:拿到任意一个架构的反汇编片段,三分钟内判断出参数从哪儿进来、返回值从哪儿出去。
2. x86-32:参数默认住在栈上,但不是唯一选择
2.1 cdecl 的标准栈帧:一步算出参数偏移
32 位 x86 上最常见的约定叫cdecl,规则简单到近乎粗暴:所有参数从右向左压栈,调用者负责在返回后清理栈,返回值放在eax(64 位返回值用edx:eax)。这套约定之所以成为 Linux 上 32 位程序的默认,是因为它天然支持变参函数——调用者清楚自己压了几个参数,清理起来不会有歧义。
关键在于call指令的行为:它会把返回地址压栈,然后跳转。所以进入函数体的第一瞬间,esp指向的是返回地址,而不是第一个参数。编译器一般会紧接着执行push ebp; mov ebp, esp,这时候栈帧就固定下来了:
| 地址 | 内容 |
|---|---|
[ebp+0x4] | 返回地址(call压入的) |
[ebp+0x8] | 第一个参数 |
[ebp+0xc] | 第二个参数 |
[ebp+0x10] | 第三个参数 |
[ebp+0x0] | 保存的旧ebp |
这个表格值得记牢,因为它是所有 32 位逆向工作的地基。[ebp+8]起就是参数、往下递增、每 4 字节一个,这个规律在绝大多数不开优化编译的 32 位程序上不会错。如果你在[ebp+8]处读到的值看起来像一个指针或者一个很大的常数,那多半就是第一个参数。
反汇编不长这样就要小心了。我见过不少新手把[ebp-0x4]也当成参数,那是局部变量;也见过把[ebp+0x8]当成返回值的,那是把函数入口和出口的语义搞反了。
080484f4 <add_two>: 80484f4: 55 push ebp 80484f5: 89 e5 mov ebp, esp 80484f7: 8b 45 08 mov eax, DWORD PTR [ebp+0x8] 80484fa: 8b 55 0c mov edx, DWORD PTR [ebp+0xc] 80484fd: 01 d0 add eax, edx 80484ff: 5d pop ebp 8048500: c3 ret对应调用方:
push 0x5 push 0x3 call 80484f4 <add_two> add esp, 0x8 ; 调用者清理两个参数注意最后那句add esp, 0x8——它出现的频率极高,是判断cdecl的直接证据。看到ret后面紧跟这种esp加法,基本就能确认清理责任在调用方。
2.2 手算栈偏移:为什么不能只看[ebp+8]
真实函数里参数偏移不会永远是 8。如果函数开头压了多个寄存器,或者编译器把ebp挪开做了别的事,偏移就会被推后。比如一个函数开头是push ebp; push ebx; push esi; mov ebp, esp,那[ebp+0x8]其实对应的是第一个被保存的寄存器所在的槽位,参数要从[ebp+0x10]开始算。
这就是为什么我建议养成一个习惯:不要记死偏移,记公式。参数起始偏移 = 保存的旧 ebp(4 字节)+ 返回地址(4 字节)+ 前面额外压栈的字节数。额外压了几个 4 字节,就把 8 往上加几个 4。
还有一种情况是函数用了leave指令而不是pop ebp。leave等价于mov esp, ebp; pop ebp,功能一样,不影响参数位置。真正要警惕的是编译器把ebp当成通用寄存器用掉了(开了-fomit-frame-pointer),这时候整个函数里根本没有ebp参与的寻址,你得改为追踪esp在函数执行过程中的变化量,难度会上升一个档次。这也是为什么逆向老手看优化过的 32 位代码时会先找一遍esp有没有被sub过。
2.3 变体们:stdcall、fastcall 与 thunk 识别
cdecl不是唯一。stdcall的差别只有一条:谁清理栈。stdcall让被调用者用ret 8这样的形式自己弹栈,好处是每个调用点少一条指令,代价是变参函数没法用。看到ret 0x4、ret 0x8、ret 0x10这种带立即数的ret,几乎可以直接判定是stdcall或者其他"被调用者清理"的约定。
fastcall更有意思:它把参数塞进寄存器。传统版本是ecx、edx接前两个参数,剩下的从右往左压栈。这就是"寄存器传参"在 32 位上的第一次登场,但它是可选的、非标准的,不同编译器实现的寄存器顺序还不一样。你在 32 位逆向里看到mov ecx, 0x1之后立马call,就要考虑是不是走了fastcall。
还有一类和传参相关的坑来自 C++:thiscall把this指针放在ecx里,普通参数走栈。识别方法是——如果第一个参数看起来像是别的参数要用到的结构体地址,而且它一直待在ecx而没有出现在栈上,那基本就是了。
提示:32 位程序判断调用约定,先看
ret后面有没有立即数,再看call和ret中间有没有esp加法。这两个信号覆盖了八成情况。
3. x86-64:六个寄存器定天下,第七个开始回到栈
3.1 System V 的寄存器分工,以及浮点参数为什么"消失"
到了 64 位,主流的 System V AMD64 ABI 把规则整个重构了。整数和指针类型的参数依次放进rdi、rsi、rdx、rcx、r8、r9,浮点和double类型的参数依次放进xmm0到xmm7。注意这两组是独立计数的:一个函数如果第一个参数是int、第二个是double、第三个是int,那么两个整数参数分别占rdi和rsi,double占xmm0,互不干扰。
这个独立性是新手最容易踩的坑。很多人在逆向时看到rdi里是第一个参数、rsi里是第二个参数,然后按顺序去找第三个,结果发现第三个参数压根不在rdx里——因为它在xmm0里。更隐蔽的是调用printf这类变参函数:浮点参数全部走xmm寄存器,rdi里是格式串,rsi里是第一个整数参数,而浮点值藏在xmm0里,从反汇编的movsd xmm0, QWORD PTR [rip+0x2f0]才能看出来。
返回值方面,整数和指针放在rax;128 位整数用rdx:rax;float和double放在xmm0,复数或者小结构体可能用xmm0加xmm1。
0000000000401136 <add3>: 401136: 55 push rbp 401137: 48 89 e5 mov rbp, rsp 40113a: 89 7d fc mov DWORD PTR [rbp-0x4], edi 40113d: 89 75 f8 mov DWORD PTR [rbp-0x8], esi 401140: 89 55 f4 mov DWORD PTR [rbp-0xc], edx 401143: 8b 45 fc mov eax, DWORD PTR [rbp-0x4] 401146: 03 45 f8 add eax, DWORD PTR [rbp-0x8] 401149: 03 45 f4 add eax, DWORD PTR [rbp-0xc] 40114c: 5d pop rbp 40114d: c3 ret从edi、esi、edx三个寄存器读进来、立刻存到局部变量槽,这是未优化代码里最典型的"寄存器传参落栈"模式。这个模式在-O0下几乎不会缺席,识别成本极低。
3.2 第七个参数之后:栈的排布与压栈顺序
超过六个整数参数怎么办?回到栈上,但顺序和 32 位不太一样——从第七个开始,按从左到右的顺序依次放在栈上,而不是像cdecl那样从右往左压。这意味着在函数入口处,[rsp+0x8]是第七个参数(因为[rsp]是返回地址),[rsp+0x10]是第八个。
我实测过一段七参数函数的反汇编,第七个参数的读取长这样:
401120: 8b 45 10 mov eax, DWORD PTR [rbp+0x10]从[rbp+0x10]而不是[rbp+0x8]取值。为什么?因为函数入口push rbp之后rbp+0是旧rbp、rbp+0x8是返回地址,从rbp+0x10起才是栈上参数。而在更复杂的函数里,如果还压了rbx、r12这些被调用者保存寄存器,偏移会被继续推后。
判断"某个参数到底在寄存器还是在栈上",有个很省事的经验:看调用点。调用点在做call之前如果连续写了好几个寄存器,那多半是前六个参数;如果还看到sub rsp, 0x20之后往[rsp]、[rsp+0x8]里写值,那多出来的就是栈参数。
3.3 影子空间与 16 字节对齐:一个不对就会崩
这套 ABI 里有两个硬性要求,写错一个就会在运行时崩得莫名其妙。
第一个是栈对齐。System V 要求call之前rsp必须是 16 字节对齐的。由于call压入 8 字节返回地址,函数入口处的rsp + 8才满足 16 字节对齐。这解释了一个经典现象:函数开头经常看到sub rsp, 0x8这种"看起来什么都没干"的指令——那是在补对齐,因为前面push rbp之后又压了奇数次寄存器,导致rsp偏了 8 字节,必须补回来。
第二个是red zone,也就是rsp之下的 128 字节。叶子函数(不调用其他函数的函数)可以直接用这块区域存局部变量,不必调整rsp。这在正常的编译产物里很常见,但如果你手写汇编或者分析一段改动过栈的代码,就必须记得这 128 字节随时可能被覆盖。
对比一下 64 位的另一套约定,差异就很明显:
| 项目 | System V(类 Unix) | Win64 风格 |
|---|---|---|
| 前几个整数参数寄存器 | 6 个:rdi rsi rdx rcx r8 r9 | 4 个:rcx rdx r8 r9 |
| 浮点参数寄存器 | xmm0-xmm7,独立计数 | xmm0-xmm3,按位置对应 |
| 调用者预留空间 | 无 | 必须留 32 字节影子空间 |
| 第 5 个之后的参数 | 栈,从右到左 | 栈,位置固定 |
| 栈对齐 | 16 字节 | 16 字节 |
影子空间这一条是跨平台逆向最容易忘的。四个寄存器参数之外的地方,调用者必须先在栈上开一块至少 32 字节的空白区,被调用者可以直接往里写这四个寄存器的值做溢出保护。所以你在这类二进制里看到sub rsp, 0x28、然后mov [rsp+0x20], rcx这样的指令,不要以为是参数传错了,那是在给寄存器"备份"。
3.4 用 gdb 把参数抓个现行
静态看反汇编能推断,但验证还得靠动态。我常用的流程是三步:
# 1. 找到目标函数地址 objdump -d -M intel ./demo | grep -A 20 '<target>:' # 2. 在那条地址下断点 gdb -q ./demo (gdb) break *0x401136 (gdb) run# 3. 断下之后核对寄存器与栈 (gdb) info registers rdi rsi rdx rcx r8 r9 (gdb) x/6gx $rsp (gdb) p $rdi (gdb) p (char*)$rdi # 如果怀疑是指针,直接按字符串看第三步里那个(char*)$rdi特别有用。我遇到过一个案例,rdi里是个看着像随机数的值,按整数打印毫无意义,按指针一解引用就是一段可读字符串——那一刻才确认它真的是第一个参数,不是某种编码后的标志位。
再补一个细节:在断点处x/6gx $rsp,前两行分别是返回地址和第七、第八个参数(如果有的话)。如果栈上那几格的值和你预期的调用点对不上,那多半是尾调用(jmp代替call)导致的,栈上根本没有返回地址。这个坑我在下面第 7 节会专门说。
4. ARM32:r0 到 r3 撑起前四个参数
4.1 AAPCS 的基本盘:寄存器优先,栈是溢出通道
ARM 32 位用的是 AAPCS(ARM Architecture Procedure Call Standard)。核心规则:前四个字类型参数放r0、r1、r2、r3,返回值放r0(64 位返回值用r0:r1)。超过四个的参数按从右到左的顺序压栈,调用者负责清理,栈必须保持 8 字节对齐。
最直观的例子是一个三参数相加函数:
00010400 <add3>: 10400: e0800001 add r0, r0, r1 10404: e0800002 add r0, r0, r2 10408: e12fff1e bx lr三条指令,没有一个内存访问。参数直接在r0-r2,结果写回r0,bx lr返回(lr即r14,call时自动保存返回地址)。这种"零栈帧"的代码在 ARM 上非常常见,也是新手最容易看漏的地方——因为反汇编里完全没有push/pop这类熟悉的痕迹,只有一堆寄存器搬运。
lr这个寄存器值得单独记一下。ARM 不像 x86 那样把返回地址压栈,而是放进链接寄存器lr。所以一个函数如果还要调用别的函数,就必须先把lr存到栈上(通常是push {r4, lr}或stmdb sp!, {fp, lr}),否则返回地址会被覆盖。看到函数开头有push {..., lr},就说明它不是叶子函数。
4.2 8 字节对齐与双字参数的寄存器跳位
ARM32 上最容易算错的是 64 位参数。规则是:long long和double必须占用偶数编号的寄存器对,如果当前轮到奇数号,就跳过一个。举个例子,函数签名是void f(int a, long long b),a占r0,b需要从偶数号开始,r1是奇数,于是跳过r1,b落在r2:r3上。这时候r1是空着的,如果你在反汇编里看到r1没被赋值就直接用了r2:r3,不要以为是编译器出错。
栈上的参数同样要遵守 8 字节对齐。这意味着一个只接收五个int参数的函数,第五个参数会被单独放到栈上,但栈指针要按 8 字节调整,可能还留一个空洞。这种"空洞"在手工计算栈偏移时非常容易漏掉,一旦枚举错,后面所有参数的位置全错。
我踩过一个具体的坑:某段 ARM 代码里,函数在sp+0x0处读了一个值当第一个栈参数,但我按"前四个在寄存器、第五个在sp+0"算出来对不上。最后发现它前面还多留了 4 字节的填充,实际参数在sp+0x4。这件事让我养成了一个习惯——ARM 上算栈参数偏移,先确认sp是不是 8 字节对齐的,再决定加不加那 4 字节。
4.3 结构体传参:值传递和指针传递长得完全不一样
结构体在 ARM 上是个分水岭。小结构体(不超过 4 字节,比如只有一个int成员)会被直接塞进一个寄存器;大一点的结构体按字拆开,依次占用寄存器;再大的就退化成"调用者在栈上开一块临时区,把结构体拷进去,然后把地址当作参数传"。第三种情况在反汇编里长得像指针传参,区别在于那块内存是调用者在调用前刚写好的。
具体判断方法是回到调用点看:如果r0的值来自add r0, sp, #0x10这种基于sp的地址计算,而且之前有一串str指令往那个位置写过数据,那几乎可以确定是结构体值传递。如果r0来自一个全局符号或者某个函数返回值,那就是普通的指针。
注意:ARM 上还有 Thumb 模式的问题。函数地址的最低位是 1 时表示 Thumb,此时
bx或blx会切换指令集。反汇编时如果发现指令解码错乱,先检查目标地址的最低位。
5. ARM64:八个参数寄存器与 x8 的隐藏角色
5.1 x0 到 x7:容量翻倍带来的写法变化
AArch64(ARM64)把参数寄存器扩到了x0到x7共八个,返回值在x0,64 位返回值直接用x0(因为寄存器本身就是 64 位),128 位返回值用x0:x1。栈保持 16 字节对齐。
八个寄存器的直接后果是:绝大多数函数的参数根本不会碰到栈。所以你在 ARM64 反汇编里几乎看不到前几节那种"从栈上读参数"的模式,取而代之的是清一色的寄存器运算:
0000000000400580 <add3>: 400580: 0b010000 add w0, w0, w1 400584: 0b020000 add w0, w0, w2 400588: d65f03c0 ret注意这里用的是w0、w1、w2而不是x0、x1、x2。w是x的低 32 位视图,写w0会把x0的高 32 位清零。当参数类型是int时编译器用w寄存器,是long或指针时用x寄存器。这个细节在判断参数位宽时很有用:看到w就是 32 位,看到x就是 64 位。
我一开始很不适应这套命名,觉得x和w混用容易看花眼。用久了反而觉得清晰——它把"这个参数被当成多宽来用"直接写在了指令里,比 x86 那种edi/rdi混用还省事一点。
5.2 x8 与间接结果:返回大结构体时的秘密通道
ARM64 有个在别的架构上不太常见的机制:间接结果寄存器x8。当一个函数返回的结构体大到无法放进x0:x1时,调用者会在栈上或者堆上准备好一块内存,把地址放进x8,被调用者把结果写进去,然后在x0里返回这个地址。
这意味着如果你在分析一个函数时发现x8在入口处就被读取使用,而且函数体里有一堆往[x8]、[x8+0x8]写的指令,那它返回的是一个结构体,不是普通值。这个信息在还原函数签名时至关重要——你可能需要手动定义出结构体的字段布局。
寄存器对的规则和 ARM32 类似但更严格:128 位类型(比如__int128)必须放在偶数编号的寄存器对上。如果第一个参数占了x0,第二个参数是 128 位类型,它不能放x1:x2,必须跳到x2:x3,x1空着。这条规则在 AAPCS64 里写得很明确,但在实际逆向中经常被忽略,导致参数解析偏移一位。
还有一种容易混的情况:SIMD 参数走v0到v7(也写作q0/d0/s0,看位宽),和整数寄存器独立计数。函数签名里如果有float、double或者向量类型,第一个浮点参数在v0,第一个整数参数仍然在x0。这一点和 x86-64 的xmm0加rdi组合是同构的。
5.3 变参函数:一个不显眼但致命的约定差异
ARM64 处理变参函数的方式和普通函数不同,主要体现在浮点参数上。可变参数部分的浮点值必须通过通用寄存器传递(按位重新解释),或者通过栈传递,具体取决于编译器的实现细节和参数数量。这导致同一个printf调用,在"参数全是整数"和"参数里有double"两种情况下,反汇编差别很大。
实测下来的经验是:如果看到调用点附近有fmov x1, d0这类指令,那就是把浮点值挪到通用寄存器里准备走变参通道。如果直接str d0, [sp],那是走栈。两种都有,取决于优化级别和编译器版本。
这里也顺带说个通用规律:变参函数里寄存器参数的总数并不影响被调用者——被调用者不知道自己收到了几个参数,全靠格式串推断。所以在分析这类函数时,能读格式串就尽量读,那是最可靠的信息源。
6. MIPS 与 RISC-V:两种风格迥异但同样干净的传参
6.1 MIPS o32:a0 到 a3、延迟槽与栈上的参数保存区
MIPS 的 o32 约定用a0到a3传前四个参数,返回值在v0(64 位用v0:v1),返回地址在ra(即r31)。听起来和 ARM 差不多,但有两处非常不一样的地方。
第一处是延迟槽。MIPS 的分支指令后面紧跟的那条指令会先执行,然后才跳转。这直接影响了函数返回的读法:
004007a0 <add3>: 4007a0: 00851021 addu v0, a0, a1 4007a4: 03e00008 jr ra 4007a8: 00441021 addu v0, v0, a2 # 延迟槽,真正在跳转前执行看起来第三行在jr ra之后,实际上它在跳转发生之前就执行完了。所以这个函数的结果是正确的。如果你按顺序理解成"跳转之后不会执行",就会得出"第三个参数没被加进去"的错误结论。我见过有人在分析 MIPS 时因为这个特性把整个算子逻辑搞反,花了半天才反应过来。
第二处是参数保存区。MIPS o32 规定,调用者在自己的栈帧里必须给前四个参数寄存器留出 16 字节的空间(叫 argument save area),虽然参数本身是寄存器传的。被调用者可以往这 16 字节里写东西,也可以不管。这意味着你在栈上看到sp+0x10、sp+0x14附近有值,别急着当成第五、第六个参数——那可能只是被调用者把a0-a3备份下来的结果。
真正的栈参数从sp+0x10之后开始算(对 32 位 o32 来说)。这个偏移量是我在 MIPS 上最容易算错的地方,因为前 16 字节是"预留但可能为空"的区域,判断是否真的存了参数,得看调用点有没有sw指令往那里写过。
6.2 RISC-V:a0 到 a7 与极简的返回
RISC-V 的规则最接近直觉:a0到a7传前八个整数参数,返回值a0(64 位返回值用a0:a1),返回地址在ra(x1),栈 16 字节对齐。没有延迟槽,没有影子空间,指令定长解码。
000102b0 <add3>: 102b0: 00b50533 add a0, a0, a1 102b4: 00c50533 add a0, a0, a2 102b8: 8082 retret是jalr x0, 0(ra)的伪指令,跳回ra。整个函数三条指令,干净利落。RISC-V 的这种简洁对逆向很友好——不用考虑延迟槽,不用怀疑隐藏的备份区,指令长度固定,反汇编工具也不会解码错位。
不过它也有自己的坑:超过八个参数之后,栈参数的排布是从sp+0开始,第一个栈参数就在sp上,因为 RISC-V 不会在调用时压返回地址(ra是寄存器),所以栈顶直接就是第九个参数。这和 x86 的[esp+4]起步、ARM 的sp+0起步都不太一样,需要单独记。
6.3 一张表把六个架构摆在一起
我把上面这些规则压缩成一张对照表,实际用的时候扫一眼就够了:
| 项目 | x86-32 cdecl | x86-64 SysV | ARM32 AAPCS | ARM64 | MIPS o32 | RISC-V |
|---|---|---|---|---|---|---|
| 参数寄存器 | 无 | rdi rsi rdx rcx r8 r9 | r0-r3 | x0-x7 | a0-a3 | a0-a7 |
| 浮点参数 | 栈 | xmm0-xmm7 | s0-s15(VFP) | v0-v7 | 协处理器 | fa0-fa7 |
| 返回寄存器 | eax | rax/xmm0 | r0 | x0/x8间接 | v0 | a0 |
| 栈上首参数位置 | [ebp+8] | [rsp+8]/[rbp+0x10] | sp+0(8 对齐) | sp+0(16 对齐) | sp+0x10 | sp+0 |
| 栈清理方 | 调用者 | 调用者 | 调用者 | 调用者 | 调用者 | 调用者 |
| 特殊机制 | 无 | red zone、16 对齐 | lr、双字寄存器跳位 | x8间接结果 | 延迟槽、参数保存区 | 无 |
这张表里我特意把"栈上首参数位置"单列出来,因为它是实际算偏移时最常查的一格。要注意这些偏移都是相对"函数入口处的栈指针"说的,如果函数里还有额外的压栈,得自己往上加。
7. 从推断到验证:几个真实踩过的坑
7.1 参数被优化掉了,不是没传
开了-O2之后,最让人怀疑人生的现象是:明明调用点写了三个参数,函数体里只有一个寄存器被读。这时候别急着怀疑反汇编工具,先想想编译器干了什么。
常见的三种情况:一是参数被常量传播了,调用点直接把常量算出来,函数体里根本不需要再读参数;二是参数被合并进了一个复合运算;三是这个参数在函数里压根没被用到,编译器直接把它省了(是的,未使用的参数可以完全消失)。
判断方法很简单:看调用点。如果调用前确实往rsi里写了值,但被调用函数里从没读过rsi,那就是参数被丢弃了,函数签名里那个参数在机器码层面不存在。这种情况下纠结"第二个参数去哪了"是白费力气。
还有一种更隐蔽的:内联。如果函数被内联进调用者,你在反汇编里根本找不到那个函数的符号,参数变成了调用者栈帧里的局部变量。识别特征是调用者函数体突然变长,中间出现一段和某个已知函数逻辑完全一致的指令序列。
7.2 尾调用让栈帧"消失"
尾调用优化(tail call)会把call f; ret换成jmp f。后果是被调用的函数执行时,栈上没有新的返回地址,它直接复用调用者的返回地址。这时候如果你在f的入口断下,x/2gx $rsp看到的第一个值是调用者的返回地址,不是你以为的"上一层返回地址"。
这个坑在递归函数上尤其致命。一个尾递归函数编译后可能变成一个循环,栈深度完全不增长,而你按传统栈帧模型去数调用层数,会得出完全错误的结论。
应对办法:在调用点下一层断点,看rsp有没有变化。call会让rsp减 8(64 位)或者减 4(32 位),jmp不会。这个差值是最直接的判据。
7.3 一套可复现的验证流程
我现在的习惯是把静态推断和动态验证绑成一条流水线,避免"看着像"就下结论:
- 静态定位:
objdump -d或反汇编器里找到目标函数,看入口有没有保存参数寄存器的动作,把所有涉及参数寄存器的读取标出来。 - 枚举调用点:搜所有
call/bl/jal到这个地址的位置,逐个看调用前写了哪些寄存器、压了哪些栈。 - 交叉验证:如果函数有多个调用点,参数寄存器在各个调用点应该是一致的。不一致就说明你的推断错了,或者这个函数有多个重载版本。
- 动态确认:在函数入口下断点,
info registers看实际值,按指针解引用看内容是否能解释通。 - 反向确认:把推断出的参数个数和类型代回调用点,看能不能解释调用前的所有准备工作。解释不了,就回到第 1 步。
# 一个可直接套用的排查脚本骨架 objdump -d ./target | grep -B 8 'call.*<target_func>'第五步是我认为最关键的一步,也是最容易被跳过的一步。它本质上是在做"假设检验"——你提出的参数模型必须能解释所有观测到的指令,只要有一条指令解释不了,模型就是错的。
最后分享一个我自己反复验证有效的小技巧:先看返回值,再看参数。几乎所有架构的返回值寄存器都是固定的(eax/rax/r0/x0/v0/a0),从返回值倒推函数的用途,再倒推它需要什么输入,比从参数正着推要快得多。我在分析一个陌生的 ARM64 二进制时,就是靠"x0最后被赋了一个堆地址"这一点,判断出函数是个构造函数,进而把x0-x3四个参数全都认了出来。这个顺序上的小调整,省下来的时间比想象中多。