最近看反汇编时经常遇到
RAX、EAX、XMM0这一堆名字,同一个函数在Windows和Linux下面,参数使用的寄存器居然还不一样,实在有点混乱,故写一篇文章整理一下。
- 先来看一个函数
- 寄存器是什么
RAX、EAX、AX和AL是什么关系- 几个有固定职责的寄存器
- 浮点数为什么使用另一组寄存器
- 函数调用约定
- Windows x64的参数传递
- Linux等系统上的参数传递
- 哪些寄存器要自己保存
- 回到最开始的例子
- 总结
- 参考资料
先来看一个函数
我们先写一个很简单的C++函数:
long long Add(long long a, long long b) {
return a + b;
}在Windows x64环境下,它可能被编译成:
lea rax, [rcx + rdx]
ret只有两条指令,看起来却一下冒出了三个寄存器。
实际上这里发生的事情非常简单:
- 第一个参数
a放在RCX - 第二个参数
b放在RDX - 返回值放在
RAX lea在这里完成加法ret返回调用者
为什么参数一定放在这些位置?这不是函数自己决定的,而是Windows x64的函数调用规则。我们先从寄存器本身说起,后面再回到这个例子。
寄存器是什么
可以把寄存器理解成CPU内部数量很少、速度很快的临时工作台。
程序中的数据大部分放在内存里。CPU要计算a+b时,通常会先把数据放入寄存器,执行运算,再把结果写回内存或者继续交给下一条指令。
在64位x86架构下,最常见的是下面16个通用寄存器:
RAX RBX RCX RDX
RSI RDI RBP RSP
R8 R9 R10 R11
R12 R13 R14 R15之所以叫“通用”,是因为大部分整数计算、地址计算和临时数据都可以放在里面。不过其中一些寄存器仍然有约定俗成或者硬件规定的用途。
| 寄存器 | 经常见到的用途 |
|---|---|
RAX | 函数返回值、乘除法中的累加器 |
RCX、RDX | Windows函数的前两个参数,也常作临时变量 |
RSI、RDI | Linux等系统上的前两个参数,也用于字符串指令 |
RSP | 指向当前栈顶,不能随便改 |
RBP | 有些函数用它定位栈帧,优化后也可能当普通寄存器使用 |
R8到R15 | 64位模式新增的通用寄存器 |
表里的用途不是说RAX只能放返回值,而是编译器和函数调用规则经常这样使用它。
RAX、EAX、AX和AL是什么关系
同一个寄存器可以按不同宽度访问。以RAX为例:
RAX:完整64位
└─ EAX:低32位
└─ AX:低16位
├─ AL:最低8位
└─ AH:第8到15位代码里经常会看到:
mov rax, 1 ; 写入完整64位
mov eax, 1 ; 只写低32位
mov ax, 1 ; 只写低16位
mov al, 1 ; 只写最低8位这里有一个很重要的不对称规则:
写入32位子寄存器时,高32位会自动清零;写入16位或8位时,高位会保留。
例如:
mov rax, 0xffffffffffffffff
mov eax, 1第二条指令执行后,RAX不是0xffffffff00000001,而是:
0x0000000000000001编译器经常利用这个特性,用写EAX的方式同时完成赋值和高位清零。
其他通用寄存器也有类似的命名:
| 64位 | 32位 | 16位 | 8位 |
|---|---|---|---|
RBX | EBX | BX | BL |
RCX | ECX | CX | CL |
RDX | EDX | DX | DL |
R8 | R8D | R8W | R8B |
所以看到ECX时,不要把它理解成另一个独立寄存器,它只是RCX的低32位。
几个有固定职责的寄存器
RIP:下一条指令在哪里
RIP保存接下来要执行的指令位置。普通代码不能像通用寄存器那样直接给它赋值,而是通过jmp、call、ret等控制流指令改变它。
64位程序也经常用RIP相对寻址访问全局数据:
mov eax, [rip + global_value]这样代码加载到不同地址后仍然能够正确找到全局变量,是地址随机化和位置无关代码的重要基础。
RSP:当前栈顶在哪里
RSP指向当前栈顶。push、pop、call、ret都会直接或间接修改它。
push rbx ; RSP减8,把RBX保存到栈上
pop rbx ; 从栈中恢复RBX,RSP加8如果手写汇编时把RSP改错,函数返回地址和局部变量的位置都会一起出问题,因此它虽然属于通用寄存器,实际使用时一点也不“通用”。
RFLAGS:上一次运算发生了什么
RFLAGS里保存零、进位、溢出、符号等状态。cmp、add、sub等指令会更新其中的标志位,后面的条件跳转再读取这些结果。
例如:
cmp ecx, edx
je equalcmp内部相当于计算ECX-EDX但不保存结果,只更新标志位。如果结果为0,je就跳转到equal。
浮点数为什么使用另一组寄存器
通用寄存器主要处理整数和地址。浮点数以及一次处理多份数据的向量运算,通常放在XMM寄存器中:
XMM0 ~ XMM15:每个128位后来扩展出的YMM和ZMM并不是完全独立的新寄存器,而是把同一组寄存器向上扩宽:
ZMM0:512位
└─ YMM0:低256位
└─ XMM0:低128位一个XMM寄存器可以放一个浮点数,也可以同时放4个32位浮点数。后者就是常说的“单条指令同时处理多份数据”。
例如Windows x64函数返回float或double时,结果通常放在XMM0,而不是RAX。
函数调用约定
现在回到最开始的问题:编译器为什么知道参数a在RCX、参数b在RDX?
函数调用前,调用者和被调用者必须先约定好下面这些事情:
- 参数放在哪些寄存器里
- 多出来的参数放在栈的什么位置
- 返回值放在哪里
- 哪些寄存器可以随便改
- 哪些寄存器修改前必须保存
- 栈要按照多少字节对齐
这套规则就叫调用约定。调用约定属于应用程序二进制接口的一部分,英文常缩写为ABI。这个名字看着很大,其实在本文里主要就是上面这些函数如何交接数据的规则。
Windows x64的参数传递
Windows x64下,前四个整数或指针参数依次放在:
RCX → RDX → R8 → R9返回值通常放在RAX。
例如:
long long Sum4(long long a, long long b,
long long c, long long d) {
return a + b + c + d;
}刚进入函数时可以这样理解:
RCX = a
RDX = b
R8 = c
R9 = d浮点参数使用位置对应的XMM0到XMM3,更多参数则放到栈上。
Windows还要求调用者在调用函数前预留32字节空间,这块区域通常叫“影子空间”。即使函数只有一个参数,这32字节也要留出来,方便被调用函数保存前四个参数寄存器或完成调试、栈展开等工作。
一个常见调用现场如下:
sub rsp, 40 ; 32字节影子空间 + 8字节对齐
mov ecx, 1 ; 第一个参数
mov edx, 2 ; 第二个参数
call Add
add rsp, 40call还会自动把返回地址压到栈上,ret再从栈中取出这个地址并跳回调用者。
Linux等系统上的参数传递
Linux、macOS等系统通常使用另一套64位调用规则,前六个整数或指针参数依次放在:
RDI → RSI → RDX → RCX → R8 → R9返回值同样通常放在RAX。
所以同一个Add(a,b)函数,在这套规则下更可能看到:
lea rax, [rdi + rsi]
ret代码逻辑没有变化,只是参数交接的位置换了。这也是为什么不能只看见RCX就断言它一定是第一个参数,必须先知道程序使用的是哪套调用约定。
两套规则的主要区别可以简单整理成:
| 项目 | Linux/macOS常用规则 | Windows x64 |
|---|---|---|
| 前几个整数参数 | RDI, RSI, RDX, RCX, R8, R9 | RCX, RDX, R8, R9 |
| 整数返回值 | RAX | RAX |
| 浮点参数 | XMM0到XMM7 | 位置对应的XMM0到XMM3 |
| 调用者预留影子空间 | 没有 | 32字节 |
| 栈顶下方临时区域 | 有128字节Red Zone | 没有 |
表里的Red Zone是指:在不调用其他函数的叶子函数中,Linux等系统允许暂时使用RSP下方128字节而不移动栈顶;Windows没有这条规则。
哪些寄存器要自己保存
假设调用者把一个重要变量放在RBX,然后调用了另一个函数。如果对方随手把RBX改掉,调用者回来后数据就丢了。
为了解决这个问题,调用约定把寄存器分成两类。
调用者负责保存
这类寄存器允许被调用函数自由修改。调用者如果还想保留原值,就要在call之前自己保存。
例如Windows x64中的RAX、RCX、RDX、R8到R11都属于这一类。它们常用来传参数和放临时值。
被调用者负责保存
这类寄存器如果要修改,被调用函数必须先保存,返回前再恢复。
Windows和Linux常见规则都要求保持RBX、RBP、R12到R15。RSP也必须在函数返回前恢复到调用者预期的位置。
常见代码形态如下:
push rbx ; 先保存调用者的RBX
mov rbx, rcx ; 函数内部使用RBX
...
pop rbx ; 返回前恢复
ret因此阅读反汇编时,看见函数开头的一串push,很可能就是在保存后面要使用的寄存器。
回到最开始的例子
现在再看这两条指令:
lea rax, [rcx + rdx]
ret就不再神秘了:
- 程序使用Windows x64调用约定,所以前两个整数参数在
RCX和RDX。 lea把两个参数相加并写入RAX。- 整数返回值约定放在
RAX。 - 函数没有局部变量,也没有调用其他函数,所以不需要建立复杂栈帧。
ret从栈中取出返回地址,回到调用者。
总结
- 寄存器是CPU内部用于计算和临时保存数据的小块高速存储。
RAX、EAX、AX、AL是同一个寄存器的不同宽度视图。- 写32位子寄存器会清零高32位,写16位或8位不会。
RIP控制下一条指令位置,RSP指向栈顶,RFLAGS保存运算状态。- 浮点和向量运算通常使用
XMM、YMM、ZMM寄存器。 - 调用约定决定参数、返回值和寄存器保存规则。
- Windows x64前四个整数参数使用
RCX、RDX、R8、R9,Linux等系统通常使用RDI、RSI、RDX、RCX、R8、R9。 - 阅读反汇编时一定要先确认平台和调用约定,否则很容易把参数位置看错。
参考资料
- System V AMD64 ABI
- Microsoft x64 calling convention
- Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 1
