Windows下x64反汇编参数传递约定
一句话,调用顺序为从左到右, Function( rcx, rdx, r8,r9, [rsp+0x20], [rsp+0x28], [rsp+0x30], [rsp+0x38], [rsp+0x40], [rsp+0x48], [rsp+0x50], [rsp+0x58], [rsp+0x60] ...)
注:本文资料收集于互联网。
x64 体系结构
x64 体系结构是 x86 的向后兼容扩展。 它提供与 x86 相同的旧 32 位模式,以及新的 64 位模式。
术语"x64"包括 AMD 64 和 Intel64。 指令集接近相同。
寄存 器
x64 将 x86 的 8 个常规用途寄存器扩展为 64 位,并添加了 8 个新的 64 位寄存器。 64 位寄存器的名称以"r"开头,因此例如, eax 的 64 位扩展名为 rax。 新寄存器的名称为 r8 到 r15。
每个寄存器的低 32 位、16 位和 8 位可直接在操作数中处理。 这包括寄存器,如 esi,其低 8 位以前不可处理。 下表为 64 位寄存器的下半部分指定汇编语言名称。
64 位寄存器 | 低 32 位 | 低 16 位 | 低 8 位 |
---|---|---|---|
rax |
eax |
ax |
铝 |
rbx |
ebx |
bx |
bl |
rcx |
ecx |
残雪 |
Cl |
rdx |
edx |
Dx |
Dl |
rsi |
Esi |
四 |
Sil |
rdi |
Edi |
di |
dil |
rbp |
Ebp |
Bp |
bpl |
粒子 |
Esp |
sp |
Spl |
r8 |
r8d |
r8w |
r8b |
r9 |
r9d |
r9w |
r9b |
r10 |
r10d |
r10w |
r10b |
r11 |
r11d |
r11w |
r11b |
r12 |
r12d |
r12w |
r12b |
r13 |
r13d |
r13w |
r13b |
r14 |
r14d |
r14w |
r14b |
r15 |
r15d |
r15w |
r15b |
输出到 32 位子注册的操作会自动零扩展为整个 64 位寄存器。 输出到 8 位或 16 位子注册的操作不是零扩展 (这是兼容的 x86 行为) 。
ax、bx、cx 和 dx 的高 8 位仍可作为 ah、bh、ch、dh 进行地址处理,但不能用于所有类型的操作数。
指令指针、eip 和标志寄存器已分别扩展到 (和 rflags) 64 位。
x64 处理器还提供多组浮点寄存器:
-
八个 80 位 x87 寄存器。
-
八个 64 位 MMX 寄存器。 (与 x87 registers.)
-
8 个 128 位 SSE 寄存器的原始集增加到 16 个。
调用约定
与 x86 不同,C/C++ 编译器仅支持 x64 上的一个调用约定。 此调用约定利用 x64 上可用的寄存器数增加:
-
前四个整数或指针参数在 rcx、 rdx、 r8 和 r9 寄存器中传递。
-
前四个浮点参数在前四个 SSE 寄存器 xmm0xmm3-中传递。
-
调用方在堆栈上为寄存器中传递的参数保留空间。 被调用的函数可以使用此空间将寄存器的内容溢出到堆栈。
-
任何其他参数在堆栈上传递。
-
在 rax 寄存器中返回整数或指针返回值,而浮点返回值在 xmm0 中返回。
-
rax、rcx、rdx、r8r11- 是可变的。
-
rbx、rbp、rdi、rsi、r12r15- 是非易失性。
C++ 的调用约定非常相似: 此 指针作为隐式第一个参数传递。 接下来的三个参数在剩余的寄存器中传递,其余参数在堆栈上传递。
寻址模式
64 位模式下的寻址模式类似于 x86,但不完全相同。
-
引用 64 位寄存器的说明以 64 位精度自动执行。 (例如 mov rax,[rbx] 从 rbx 开始将 8 个字节移动到 rax.)
-
为 64 位即时常量或常量地址添加了一种特殊形式的 mov 指令。 对于所有其他指令,即时常量或常量地址仍为 32 位。
-
x64 提供新的 与元寻址相关的寻址模式。 引用单个常量地址的指令编码为从进行翻录的 偏移量。 例如,mov rax,[addr] 指令从 addrrip + 开始将 8 个字节移动到 rax。
隐式引用指令指针的指令(如 jmp、 调用、 推送和 pop)和堆栈指针将它们视为 x64 上的 64 位寄存器。
在 Win64 下的 registers 用途
Register |
Status |
Use |
RAX | Volatile | Return value register |
RCX | Volatile | First integer argument |
RDX | Volatile | Second integer argument |
R8 | Volatile | Third integer argument |
R9 | Volatile | Fourth integer argument |
R10:R11 | Volatile | Must be preserved as needed by caller; used in syscall/sysret instructions |
R12:R15 | Nonvolatile | Must be preserved by callee |
RDI | Nonvolatile | Must be preserved by callee |
RSI | Nonvolatile | Must be preserved by callee |
RBX | Nonvolatile | Must be preserved by callee |
RBP | Nonvolatile | May be used as a frame pointer; must be preserved by callee |
RSP | Nonvolatile | Stack pointer |
XMM0 | Volatile | First FP argument |
XMM1 | Volatile | Second FP argument |
XMM2 | Volatile | Third FP argument |
XMM3 | Volatile | Fourth FP argument |
XMM4:XMM5 | Volatile | Must be preserved as needed by caller |
XMM6:XMM15 | Nonvolatile | Must be preserved as needed by callee. |
1. 传递参数
在 Win64 里使用下面寄存器来传递参数:
- rcx - 第 1 个参数
- rdx - 第 2 个参数
- r8 - 第 3 个参数
- r9 - 第 4 个参数
其它多出来的参数通过 stack 传递。
使用下面寄存器来传递浮数数:
- xmm0 - 第 1 个参数
- xmm1 - 第 2 个参数
- xmm2 - 第 3 个参数
- xmm3 - 第 4 个参数
下面的代码:
void EditTextFile(HWND hEdit, LPCTSTR szFileName) hFile = CreateFile(szFileName, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); ... ... } |
CreateFile() 的参数有 7 个,那么看看 VC 是怎样安排参数传递:
void EditTextFile(HWND hEdit, LPCTSTR szFileName) hFile = CreateFile(szFileName, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); ... ... |
上面已经对 7 个参数的传递进行了标注,前 4 个参数通过 rcx,rdx,r8 以及 r9 寄存器传递,后 3 个参数确实通过 stack 传递。
可是,事情并没有这么简单:
在 Win64 下,会为每个参数保留一份用来传递的 stack 空间,以便回写 caller 的 stack |
在上面的例子中:
- [rsp+20h] - 第 5 个参数
- [rsp+28h] - 第 6 个参数
- [rsp+30h] - 第 7 个参数
实际上已经为前面 4 个参数保留了 stack 空间,分别是:
- [rsp] - 第 1 个参数(使用 rcx 代替)
- [rsp+08h] - 第 2 个参数(使用 rdx 代替)
- [rsp+10h] - 第 3 个参数(使用 r8 代替)
- [rsp+18h] - 第 4 个参数(使用 r9 代替)
虽然是使用了 registers 来传递参数,然而还是保留了 stack 空间。接下着就是 [rsp+20h], [rsp+28h] 以及[rsp+30h] 对应的 4,5,6 个参数
2. 回写 caller stack
VC 使用了下面编译参数来实现回写 caller stack
/homeparams |
当使用了这个编译选项或者在 Debug 版下,它强制将 registers 里的值写回 stack 中
正如下面的代码:
CreateFileWImplementation: |
上面所显示的是 CreateFile() 在 kernel32.dll 模块里的实现代码,上面对回写机制进行了标注,回写的 stack 正好是 caller 调用时未参数所保留的 stack 空间,上面的代码并不是那么直观。
下面我演示一下使用 /homeparams 选项来编译代码。
上面的 EditTextFile() 函数结果如下:
void EditTextFile(HWND hEdit, LPCTSTR szFileName) hFile = CreateFile(szFileName, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); |
第 1 个参数回写 [rsp+8] 处,第 2 个参数回写 [rsp+10h] 处。
注意这里的 stack 就是对应 caller 调用时的 stack,经过调用后 [rsp] 是返回地址值,因此,在 callee 里设置:
- callee 写 [rsp+8] = caller 的 [rsp]
- callee 写 [rsp+10h] = caller 的 [rsp+8]
- callee 的 [rsp] = return address
上面很直观地显示了使用 /homeparams 选项时的效果,对比前一段没有使用 /homeparams 选项编译时的结果,很容易发现这个机制。
回写 caller stack 机制目的是为了 Debug 所需。
3. 由 callee 保存
在一个程序里应尽量使用 registers,在 x64 里有 16 个通用寄存器和 16 个 xmm 寄存器,可是一些 registers 在使用前必须保存原来值,以防丢失原来值。
因此,在 callee 使用它们时会将原值压入栈中保存,在 Win64 里,下面 registers 由 callee 负责保存:
- rbx, rbp, rsi, rdi
- r12 - r15
- xmm6 - xmm15
每进入一个 callee,在使用它们之前都保存起来,返回 caller 之前,恢复原来值。因此这些寄存器的值是保持恒定的。
void EditTextFile(HWND hEdit, LPCTSTR szFileName) ... ...
|
4. stack frame 结构
进入每个 callee 时,都会生成属于自己的 stack frame 结构,返回时会注销自己的 stack frame
- rbp
- rsp
由这两个 registers 来构造 stack frame 结构,rbp 是 stack frame pointer,rsp 是 stack pointer
可是,在 Win64 里,似乎不使用 stack frame 结构,VC 不会为每个函数创建 stack frame 结构 |
在 Win64 里,始终在使用动态使用 rsp 来维护 stack
void EditTextFile(HWND hEdit, LPCTSTR szFileName) ... ...
|
VC 不会生成 x86 下典型的 stack frame 结构,始终由 rsp 维护 stack,/Gd 编译选项在 Win64 下会被忽略,rbp 被保留起来
在 Win64 里,rdi 寄存器的角色变得很微妙,在某些场合下它充当了一部分 stack frame pointer 的角色。
5. r11 与 rcx 以及 r10
在 64 位模式下,在 sysret 指令返回时,将从 rcx 处得到返回地址,从 r11 处得到 rflags 值,因此在进入 system services routine(系统服务例程)前,或者在系统服务例程中的第1个任务是 rcx 与 r11 寄存器,以便 sysret 返回。
在 Win64 里,r10 寄存器充当保存 rcx 值的作用,如下:
NtCallbackReturn: |
在进入 system call 之前,保存 rcx 的值。
x86:又名 x32 ,表示 Intel x86 架构,即 Intel 的32位 80386 汇编指令集。
x64:表示 AMD64 和 Intel 的 EM64T ,而不包括 IA64 。至于三者间的区别,可自行搜索。
x64 跟 x86 相比寄存器的变化,如图:
从图上可以看到,X64架构相对于X86架构的主要变化,是将原来所有的寄存器都扩大了一倍,例如EAX现在扩充成RAX,同时,又新增加了从R8~R15这8个64位的寄位器,有点RISC的味道(RISC特点就是寄存器多)。
然后还有下面的一些改变:
- x64上面默认的函数调用约定是 fast call ,也就是 ABI 是 fast call ;
- 一个函数在调用时,前四个参数是从左至右依次存放于RCX、RDX、R8、R9寄存器里面,剩下的参数从左至右顺序入栈;
- 调用者负责在栈上分配32字节的“shadow space”,用于存放那四个存放调用参数的寄存器的值(亦即前四个调用参数);
- 小于64位(bit)的参数传递时高位并不填充零(例如只传递ecx),大于64位需要按照地址传递;
- 被调用函数的返回值是整数时,则返回值会被存放于RAX;
- 被调用函数不负责清栈,调用者负责清理栈;
- RAX,RCX,RDX,R8,R9,R10,R11是“易挥发”的,不用特别保护,其余寄存器需要保护。(x86下只有eax, ecx, edx是易挥发的)
- 栈需要16字节对齐,“call”指令会入栈一个8字节的返回值(注:即函数调用前原来的RIP指令寄存器的值),这样一来,栈就对不齐了(因为RCX、RDX、R8、R9四个寄存器刚好是32个字节,是16字节对齐的,现在多出来了8个字节)。所以,所有非叶子结点调用的函数,都必须调整栈RSP的地址为16n+8,来使栈对齐。
- 对于 R8~R15 寄存器,我们可以使用 r8, r8d, r8w, r8b 分别代表 r8 寄存器的64位、低32位、低16位和低8位。
一些其他要注意的小问题:
- 另外一些小问题要注意,AMD64不支持 push 32bit 寄存器的指令,最好的方法就是 push 和 pop 都用64位寄存器,即 push rbx ,不要使用 push ebx 。
- 另外要补充的一点是,在一般情况下,X64 平台的 RBP 栈基指针被废弃掉,只作为普通寄存器来用,所有的栈操作都通过 RSP 指针来完成。
遗留问题
以上都是关于 Windows 上的调用约定,即 Visual Studio 上使用的调用约定,至于 GCC 的函数调用约定是否一致,还不清楚,有知道的请指点一下,我从 asmlib 的64位汇编看,GCC 好像第一个参数用的是 rdi ,而不是 rcx 。
示例:
; 示例代码 1.asm ; 语法:GoASM DATA SECTION text db 'Hello x64!', caption db 'My First x64 Application', CODE SECTION START: sub rsp, 28h ; 堆栈预留 shadow space (40 + 8)字节 xor r9d, r9d ; r9 lea r8, caption ; r8 lea rdx, text ; rdx xor rcx, rcx ; rcx call MessageBoxA add rsp, 28h ; 调用者自己恢复堆栈 ret
一般编译器实现调用调用约定无外乎以下这几种:
- CDECL:C/C++默认的调用约定,调用方平栈,不定参数的函数可以使用,参数通过堆栈传递.
- STDCALL:被调方平栈,不定参数的函数无法使用,参数默认全部通过堆栈传递.
- FASTCALL32:被调方平栈,不定参数的函数无法使用,前两个参数放入(ECX, EDX),剩下的参数压栈保存.
- FASTCALL64:被调方平栈,不定参数的函数无法使用,前四个参数放入(RCX, RDX, R8, R9),剩下的参数压栈保存.
- System V:类Linux系统默认约定,前八个参数放入(RDI,RSI, RDX, RCX, R8, R9),剩下的参数压栈保存.
当栈顶指针esp小于栈底指针ebp时,就形成了栈帧,栈帧中可以寻址的数据有局部变量,函数返回地址,函数参数等。不同的两次函数调用,所形成的栈帧也不相同,当由一个函数进入另一个函数时,就会针对调用的函数开辟出其所需的栈空间,形成此函数的独有栈帧,而当调用结束时,则清除掉它所使用的栈空间,关闭栈帧,该过程通俗的讲叫做栈平衡。而如果栈在使用结束后没有恢复或过度恢复,则会造成栈的上溢或下溢,给程序带来致命错误。
cdecl 调用者平栈: cdecl是C/C++默认调用约定,该调用方式在函数内不进行任何平衡参数操作,而是在退出函数后对esp执行加4操作,从而实现栈平衡。
该约定会采用复写传播优化,将每次参数平衡的操作进行归并,在函数结束后一次性平衡栈顶指针esp,且不定参数函数可使用此约定。
stdcall 被调用者平栈: stdcall与cdecl只在参数平衡上有所不同,其余部分都一样,但该约定不定参数函数无法使用。
cdecl调用方式的函数在同一作用域内多次被调用,会在效率上比stdcall高一些,因为它可以使用复写传播优化,而stdcall在函数内平衡栈,无法使用复写传播优化。
fastcall 被调用者平栈: fastcall效率最高,它可利用寄存器传递参数,一般前两个或前四个参数用寄存器传递,其余参数传递则转换为栈传递,此约定不定参数函数无法使用。
对于32位来说使用ecx,edx传递前两个参数,后面的用堆栈传递。
对于64位则会使用RCX,RDX,R8,R9传递前四个参数,后面的用堆栈传递。
使用esp寻址: 在O2编译器选项中,为了提高程序执行效率,只要栈顶是稳定的,就可以不再使用ebp指针,而是利用esp指针直接访问局部变量,这样可节省一个寄存器资源。