Windows平台X64函数调用约定与汇编代码分析

本文通过一个实际案例分析了X64架构下的函数调用约定,详细解释了参数如何传递、栈空间如何分配等问题,并揭示了即使函数参数发生变化也不会立即导致程序崩溃的原因。

原文 http://kelvinh.github.io/blog/2013/08/05/windows-x64-calling-conventions/

起因

整件事源自于公司的一个公共模块,有很多项目都依赖于这个公共模块,我们项目是其中之一。假定依赖的函数原型为:

int add(int a, int b, int c, int d, int e)

某一天,这个公共模块将 add 函数增加了一个参数,即原型变成了下面这样:

int add(int a, int b, int c, int d, int e, int f)

但是公共模块的负责人没有通知我们,所以我们并不知道这个变化。

如果是常规调用,即直接用 add(...) 来调用的话,在公共模块增加参数后,那么编译就会提示出错的。但是我们是采用的动态调用,如下:

typedef int (*FunctionPtr)(int a, int b, int c, int d, int e);

HMODULE h = ::LoadLibraryEx(L"CommonModule.dll", ...);
FunctionPtr pf = reinterpret_cast<FunctionPtr>(::GetProcAddress(h, "add"));
int ret = pf(1, 2, 3, 4, 5);
//...

因此,对这种调用方式来说,在编译阶段,调用者是无法知晓 CommonModule.dll 的改动的。

可是,按照常规讲,虽然编译阶段没有出错,那么在运行的时候,总会发生异常,例如crash之类的啊。可奇怪的就在这一点,运行时也一切正常,QA测试也没发现什么bug,于是项目就正常release了。

但在release之后,另一个使用这个公共模块的项目出了问题,具体什么问题我不太清楚,后来检查了之后才发现是公共模块的接口有变化。于是,我们项目的老大们对这个公共模块的人大发牢骚:改接口也不通知,现在产品都release了,如果因为这个原因而大面积出问题,你怎么跟客户解释!!

可是,我们QA测试的时候不是一点问题都没有么,到底是怎么回事呢?

分析

于是,我写了如下的一个例子,来分析一番:

#include <iostream>

int add(int a, int b, int c, int d, int e) {
    return a + b + c + d + e;
}

int main() {
    int r = add(1, 2, 3, 4, 5);
    std::cin.get();
}

上面例子中 main() 函数调用 std::cin.get() 是为了让程序阻塞在那里,方便我们能够来得及在程序退出之前将WinDbg给Attach到进程上去。下面的就是在WinDbg里面看到的 main() 函数和 add() 函数的汇编代码(能看到如下代码的前提是,在Visual Studio编译之前,把相关的优化选项以及安全检查选项给关掉,不然Visual Studio会插入一些安全检查的代码,同时会直接把 add() 函数给优化掉)(忘了说明,编译采用的是VS2012):

0:000> uf Project1!main
Project1!main [d:\codelabs\functest\c++\project1\source.cpp @ 7]:
    7 00000001`3ff21730 4883ec48         sub     rsp,48h
    8 00000001`3ff21734 c744242005000000 mov     dword ptr [rsp+20h],5
    8 00000001`3ff2173c 41b904000000     mov     r9d,4
    8 00000001`3ff21742 41b803000000     mov     r8d,3
    8 00000001`3ff21748 ba02000000       mov     edx,2
    8 00000001`3ff2174d b901000000       mov     ecx,1
    8 00000001`3ff21752 e8a9ffffff       call    Project1!add (00000001`3ff21700)
    8 00000001`3ff21757 89442430         mov     dword ptr [rsp+30h],eax
    9 00000001`3ff2175b 488b0dee180000   mov     rcx,qword ptr [Project1!_imp_?cinstd (00000001`3ff23050)]
    9 00000001`3ff21762 ff15f8180000     call    qword ptr [Project1!_imp_?get?$basic_istreamDU?$char_traitsDstdstdQEAAHXZ (00000001`3ff23060)]
   10 00000001`3ff21768 33c0             xor     eax,eax
   10 00000001`3ff2176a 4883c448         add     rsp,48h
   10 00000001`3ff2176e c3               ret
0:000> uf Project1!add
Project1!add [d:\codelabs\functest\c++\project1\source.cpp @ 3]:
    3 00000001`3ff21700 44894c2420       mov     dword ptr [rsp+20h],r9d
    3 00000001`3ff21705 4489442418       mov     dword ptr [rsp+18h],r8d
    3 00000001`3ff2170a 89542410         mov     dword ptr [rsp+10h],edx
    3 00000001`3ff2170e 894c2408         mov     dword ptr [rsp+8],ecx
    4 00000001`3ff21712 8b442410         mov     eax,dword ptr [rsp+10h]
    4 00000001`3ff21716 8b4c2408         mov     ecx,dword ptr [rsp+8]
    4 00000001`3ff2171a 03c8             add     ecx,eax
    4 00000001`3ff2171c 8bc1             mov     eax,ecx
    4 00000001`3ff2171e 03442418         add     eax,dword ptr [rsp+18h]
    4 00000001`3ff21722 03442420         add     eax,dword ptr [rsp+20h]
    4 00000001`3ff21726 03442428         add     eax,dword ptr [rsp+28h]
    5 00000001`3ff2172a c3               ret

如果对于x64的CPU结构不太熟悉的话,上面的汇编着实不太好理解,下面给出一张x64结构的CPU寄存器构造图1

X64 CPU Registers

从图上可以看到,X64架构相对于X32架构的主要变化,是将原来所有的寄存器都扩大了一倍,例如EAX现在扩充成RAX,同时,又新增加了从R8到R15这8个64位的寄位器。

知道了寄存器的结构,还是不足以理解上面的汇编函数调用,此外,还需要知道Intel X64汇编函数调用的一些约定2

  • RCX, RDX, R8, R9 are used for integer and pointer arguments in that order left to right.
  • XMM0, 1, 2, and 3 are used for floating point arguments.
  • Additional arguments are pushed on the stack left to right.
  • Parameters less than 64 bits long are not zero extended; the high bits contain garbage.
  • It is the caller's responsibility to allocate 32 bytes of "shadow space" (for storing RCX, RDX, R8, and R9 if needed) before calling the function.
  • It is the caller's responsibility to clean the stack after the call.
  • Integer return values (similar to x86) are returned in RAX if 64 bits or less.
  • Floating point return values are returned in XMM0.
  • Larger return values (structs) have space allocated on the stack by the caller, and RCX then contains a pointer to the return space when the callee is called. Register usage for integer parameters is then pushed one to the right. RAX returns this address to the caller.
  • The stack is 16-byte aligned. The "call" instruction pushes an 8-byte return value, so the all non-leaf functions must adjust the stack by a value of the form 16n+8 when allocating stack space.
  • Registers RAX, RCX, RDX, R8, R9, R10, and R11 are considered volatile and must be considered destroyed on function calls.
  • RBX, RBP, RDI, RSI, R12, R14, R14, and R15 must be saved in any function using them.
  • Note there is no calling convention for the floating point (and thus MMX) registers.
  • Further details (varargs, exception handling, stack unwinding) are at Microsoft's site.

上面这段话里面有几个关键点:1. 一个函数在调用时,前四个参数是从左至右依次存放于RCX、RDX、R8、R9寄存器里面;2. 剩下的参数从左至右顺序入栈;3. 调用者负责在栈上分配32字节的“shadow space”,用于存放那四个存放调用参数的寄存器的值(亦即前四个调用参数);4. 调用者负责清理栈;5. 被调用函数若是整数返回,则返回值会被存放于RAX;6. 栈是16字节对齐的,“call”指令会入栈一个8字节的返回值(注:即函数调用前原来的RIP指令寄存器的值),这样一来,栈就对不齐了,所以,所有非叶子结点调用的函数,都必须调整栈分配方式为16n+8,来使栈对齐。

这样一来,上面的汇编代码就好懂了:

  1. 主调函数(main)将栈指针RSP下移0x48,即分配栈空间;
  2. 将最后一个调用参数5入栈,存放于[RSP + 0x20]处,这样一来,栈上面空出的 0x48 - 0x20 = 0x28 = 40 = 32 + 8 的空间就用于存放本地变量,其中8字节应该是用来对齐栈的;
  3. 将前四个参数分别放入约定中的那四个寄存器;
  4. 调用 add 函数(在这个指令中,栈指针RSP又下移了8个字节,这8个字节用来存放RIP指针的值);
  5. 进入 add 函数,把在四个寄存器中的参数又放回栈上(栈上的用于存放这四个寄存器空间就是“shadow space”,如果需要,由被调用者负责将这四个寄存器的值放回栈3),然后执行加操作,最后的结果存放于RAX中;
  6. 返回 main 函数后,取出RAX的值,再放回本地变量的栈空间中;
  7. ...

上面的指令对栈操作比较多,我画了一个调用栈的分配情况图(用Window Paint画的,花的时间不比写这篇博客的时间短,中间还画错了一次。。 =_=#!):

X64 calling stack

另外,再附上一张MSDN上画的栈分配的示例图4(和上面我画的差不多,只不过我画的是针对于具体的例子):

X64 stack allocation

结论

按照分析中所指出的,在X64平台的函数调用中,函数前四个参数之外的参数会被入栈,如上面的栈分配图所示,实际上在主调函数中,分配的栈参数空间上面还有本地变量空间,所以,如果偶然多了一个参数的话,其实没有关系,不会导致crash,只不过这个参数的值会是无效的。既然参数值无效,程序运行应该会出bug才对,后来问了这个公共模块的负责人,才知道这个新加的参数没有使用。。擦,没有使用,你乱改接口,新加参数干嘛。。

<think> 首先,用户查询的是“X64函数调用约定及参数栈图”。我需要基于提供的参考引用和系统指令来回答。 参考用户的引用: - 引用[1]:讨论Linux/Unix下X64函数调用约定,提到Microsoft X64调用约定和寄存器传递参数。 - 引用[2]:描述X86/X64函数调用约定,特别是__cdecl调用约定,参数从右至左入栈,调用者清理栈。 - 引用[3]:解释函数调用约定和栈帧布局,分绿色(局部变量)和蓝色(函数调用准备)区域。 系统指令要求: - 所有行内数学表达式用$...$格式。 - 独立公式用$$...$$格式并单独成段。 - LaTeX语法正确。 - 使用中文回答。 - 生成相关问题。 - 在回答中引用的段落末尾自然地添加引用标识。 用户可见层指令: - 回答结构清晰,逐步解决问题。 - 保证真实可靠。 我的回答结构: 1. **引言**:简要解释X64函数调用约定。 2. **主要调用约定**:基于引用,讨论Microsoft和System V(Linux/Unix)调用约定。 3. **参数栈图**:描述栈帧布局,包括局部变量和参数传递区域。 4. **示例**:可能用代码或图示解释。 5. **结论**:总结关键点。 6. **相关问题**:生成相关问题。 关键点来自引用: - Microsoft X64调用约定Windows):前四个整数参数在RCX、RDX、R8、R9寄存器传递;浮点参数在XMM0-XMM3;剩余参数压栈;调用者保存寄存器;返回值在RAX [^1]。 - System V AMD64 ABI(Linux/Unix):前六个整数参数在RDI、RSI、RDX、RCX、R8、R9;浮点参数在XMM0-XMM7;剩余参数压栈;调用者保存寄存器;返回值在RAX或RDX:RAX [^1], [^2], [^3]。 - 栈帧布局:固定大小的局部变量区域(绿色),复用的参数准备区域(蓝色)[^3]。 我需要确保使用中文,并添加引用标识。例如,提到Microsoft约定时加[^1],提到栈图时加[^3]。 数学表达式:如果有数学内容(如内存偏移),用$...$格式。例如,地址偏移可以用$offset$。 参数栈图:我可以描述栈结构,但无法画图。可以用文本描述或用LaTeX表格模拟。 例如: - 栈顶:返回地址 - 往下:保存的寄存器 - 再往下:局部变量区域(固定) - 再往下:参数准备区域(复用) 独立公式:如果需要公式,用$$...$$。例如,描述内存对齐时。 生成相关问题:最后添加§§相关问题§§部分,列出2-4个相关问题。 完整回答大纲: 1. **介绍X64函数调用约定**:解释概念,重要性。 2. **常见调用约定**: - Microsoft X64调用约定。 - System V AMD64 ABI。 - 比较差异。 3. **参数栈图**:详细描述栈帧布局,基于引用[^3]。 4. **示例代码或伪代码**:可选,如有。 5. **总结**。 6. **相关问题**。 确保真实可靠:基于提供的引用,避免添加额外信息(除非通用知识)。 </think> ### X64函数调用约定及参数栈图 在X64架构中,函数调用约定定义了函数如何传递参数、返回值以及管理栈帧。这些约定确保不同模块(如调用者和被调函数)之间的兼容性。X64体系结构主要使用寄存器传递参数以提高效率,而非完全依赖栈。目前,有两种主流调用约定:**Microsoft X64调用约定**(用于Windows平台)和**System V AMD64 ABI调用约定**(用于Linux/Unix平台)。我将基于您的引用逐步解释这些约定,并描述参数栈图的布局。 #### 1. X64函数调用约定概述 X64调用约定利用寄存器传递前几个参数,减少栈操作开销。剩余参数通过栈传递,返回值通常存放在指定寄存器中。栈帧管理包括固定区域(用于局部变量)和复用区域(用于调用其他函数时的参数准备)[^3]。具体规则因平台而异: - **Microsoft X64调用约定**(Windows平台): - **参数传递**:前4个整型参数存储在寄存器RCX、RDX、R8、R9中;前4个浮点参数存储在XMM0-XMM3中。剩余参数从右至左压入栈中(例如,第五个参数先压栈)[^1]。 - **栈清理**:由调用者负责清理栈空间,这允许支持可变参数函数(如`printf`)。 - **返回值**:整型返回值存放在RAX寄存器,浮点返回值存放在XMM0;较大返回值(如结构体)通过隐藏指针传递。 - **寄存器保存**:调用者必须保存易失寄存器(如RAX, RCX, RDX, R8-R11, XMM0-XMM5),被调函数保存非易失寄存器(如RBX, RBP, RDI, RSI, R12-R15)[^1]。 例如,函数调用`func(a, b, c, d, e)`的寄存器分配:$a \to \text{RCX}$, $b \to \text{RDX}$, $c \to \text{R8}$, $d \to \text{R9}$, $e$ 压栈。 - **System V AMD64 ABI调用约定**(Linux/Unix平台): - **参数传递**:前6个整型参数存储在RDI、RSI、RDX、RCX、R8、R9中;前8个浮点参数存储在XMM0-XMM7中。剩余参数从右至左压入栈中[^2]。 - **栈清理**:由调用者负责清理栈空间,类似于Microsoft约定。 - **返回值**:整型返回值在RAX,浮点返回值在XMM0;大返回值使用RAX和RDX组合。 - **寄存器保存**:调用者保存易失寄存器,被调函数保存非易失寄存器。栈对齐通常要求16字节边界[^1], [^3]。 这两种约定的核心差异在于寄存器分配和平台实现,但都旨在优化性能。例如,Microsoft约定更适合Windows API兼容性,而System V ABI在开源系统中更常见[^1], [^2]。 #### 2. 参数栈图详解 参数栈图描述了函数调用时的栈帧布局。栈帧分为两个主要区域: - **固定区域(绿色区域)**:用于存储局部变量和保存的寄存器空间。大小由函数内部变量数量决定,在函数入口时分配。 - **复用区域(蓝色区域)**:用于调用其他函数时的参数准备和返回值存储。大小取决于调用函数时的最大参数需求(即被调函数的参数数量),在每次调用时动态复用[^3]。 栈帧布局从上到下(高地址到低地址)如下: - **调用者栈帧**: - 返回地址(由`call`指令压栈)。 - 保存的寄存器(如果调用者需保护非易失寄存器)。 - **被调函数栈帧**: 1. **参数区域(蓝色)**:用于传递剩余参数(超出寄存器数量的参数)。例如,在Microsoft约定中,第五个参数起始偏移为$8$字节(考虑对齐)。 2. **返回地址**:压入栈顶。 3. **保存的基指针(RBP)**:可选,用于栈回溯。 4. **局部变量区域(绿色)**:固定大小,分配后不变。 5. **对齐填充**:确保栈指针(RSP)满足16字节对齐要求(在System V ABI中常见)[^1], [^3]。 示例栈图(Microsoft X64约定,调用`func(a, b, c, d, e)`,假设所有参数为整型): - 寄存器传递:$a \to \text{RCX}$, $b \to \text{RDX}$, $c \to \text{R8}$, $d \to \text{R9}$。 - 栈传递:$e$ 压入栈中,偏移地址为$\text{RSP} + 8$(考虑返回地址占用)。 - 栈帧结构: ``` 高地址 -> | 调用者局部变量 | ... | e (参数5) | <- 蓝色区域(复用) | 返回地址 | | 保存的 RBP | | 局部变量1 | | 局部变量2 | <- 绿色区域(固定) 低地址 -> | ... | ``` 在代码中,栈偏移可通过汇编指令管理,例如`push`或`mov`操作[^3]。栈图的实际大小由编译时确定,对齐规则如$ \text{address} \mod 16 = 0$以确保性能[^1]。 #### 3. 关键区别注意事项 - **性能优化**:寄存器传递减少内存访问,适用于高频调用场景。但栈传递用于大对象或可变参数。 - **跨平台兼容性**:Windows程序需遵循Microsoft约定,Linux程序用System V ABI。混合调用可能导致错误(如使用`__attribute__((ms_abi))`在GCC中模拟Microsoft约定)[^1]。 - **可变参数处理**:调用者清理栈(如`__cdecl`风格)支持类似`sprintf`的函数[^2]。 - **栈对齐**:System V ABI严格要求16字节栈对齐,以提高SIMD指令效率;Microsoft约定较宽松但推荐对齐[^1]。 总之,X64函数调用约定提升了效率,参数栈图通过分离固定和复用区域优化内存使用。开发者应遵循目标平台的规范以避免兼容性问题[^1], [^2], [^3]。
评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值