深入浅出C/C++函数返回值传递原理
前言
众所周知,C/C++函数返回值会使用eax寄存器来传递,我们用以下代码验证一下
1 | uint32_t foo() { |
1 | auto n = foo(); |
可以看到,main函数调用完foo后,将eax的值传送到n中。证明了eax确实用于返回值传递
但eax本身只有4字节,那么当返回值大于4字节时是如何传递的呢?
5~8字节时
修改一下foo函数,返回一个8字节的值,观察一下反汇编
1 | uint64_t foo() { |
1 | auto n = foo(); |
从汇编看出,当返回一个8字节的值时,会同时使用eax和edx一起返回,其中eax给到ebp-8,edx给到ebp-4
根据小端模式的特点(高存高,低存低),可以看出,eax存储返回值的低4字节,而edx存储返回值的高1~4字节
大于8字节时
再修改一下foo函数,让其返回一个长度为256字节的结构体 1
2
3
4
5
6
7
8
9struct bar {
char buf[256];
};
bar foo() {
bar tmp;
tmp.buf[0] = '\0';
return tmp;
}
main函数分析
很明显,256字节的结构体,就算用上所有通用寄存器也无法直接传递返回值
我们观察一下main函数的反汇编
1 | 00771BC0 push ebp |
最一开始,保存了ebp后,将esp拉低,在栈上分配了280h个字节的空间
继续往下看
1 | auto n = foo(); |
可以看到,在call的前面出现了两行奇怪的东西
第1行lea eax,[ebp-280h]
将ebp-280h的地址存储到eax中,而ebp-280h正好指向刚才分配空间的末尾
接着push eax
,将地址压入栈,然后紧接着调用foo函数。但foo函数没有任何参数呀,其实这里编译器帮我们隐式传递了一个参数,将刚才分配的栈上空间末尾指针传了进去
截至这里,我们可以写出伪代码如下:
1 | char buf[0x280]; |
继续向下看,add esp,4
,将esp抬高4,也证明了foo函数确实有一个参数,同时也是cedcl调用约定
剩下的4行要整体来看,先看最后一行,rep movs dword ptr es:[edi],dword ptr [esi]
指的是将esi指向位置上的数据,以DWORD的大小(4字节)拷贝到edi指向的位置上,拷贝ecx次
简单来说,最后一行就相当于memcpy(edi, esi, ecx * sizeof(DWORD))
而rep movs上面的三行,分别是给ecx、esi、edi赋值的语句,含义如下
- ecx是0x40,也就是拷贝0x40 * 4 = 256个字节的数据,和bar的大小一致
- esi是eax,也就是foo函数的返回值
- edi是n的地址(ebp-100h)
看完这段汇编,基本上也能还原出main函数剩下的伪代码了
1 | foo(ebp-280h); |
foo函数分析
可见,foo依然靠eax传递返回值,只不过不是直接传递函数体,而是函数体指针。foo内部到底如何实现呢?继续看一下foo的反汇编
1 | bar foo() { |
前3行,是汇编访问数组的典型形式,相当于如下代码,这里不过多赘述 1
2
3eax = sizeof(*tmp.buf); // 比例因子
ecx = eax * 0; // 计算与数组首地址的偏移量
*((char*)tmp + ecx) = 0; // 写入0
1 | return tmp; |
继续看,前4行依然是需要整体看,依旧是rep movs指令,根据刚才的解释,这四行可以翻译成memcpy([ebp+8], &tmp, 256)
接着是,mov eax,dword ptr [ebp+8]
,将ebp+8给到eax,返回给main函数

根据函数栈帧的知识,我们知道ebp指向保存的旧ebp,ebp+4指向call指令压入栈的返回地址,ebp+8则指向函数的第一个参数
而前面根据我们分析foo函数会隐式传递一个参数来,就是main函数里的epb-280h,这样一来,思路就清晰了
流程
- main函数先在栈上额外开辟了一片空间,并将这块空间的一部分用于传递返回值的临时对象
- 将临时对象地址作为参数传递给foo函数
- foo函数内将数据拷贝给临时对象,并将临时对象的地址用eax传出
- foo函数返回后,main函数将eax指向的临时对象的内容拷贝给n
伪代码如下:
1 | void foo(void* ptr) { |
总结
在x86架构下使用Debug模式MSVC编译得出以下结论:
返回值类型大小 | 传递方式 |
---|---|
1~4字节 | eax(ax/al)寄存器 |
4~8字节 | eax和edx寄存器, eax存储返回值的低4字节,而edx存储返回值的高1~4字节 |
8+字节 | 使用栈空间中转,进行两次拷贝 |
综上所述,在没有开启任何优化的情况下,如果返回值类型过大,C/C++会使用一个临时的栈上空间做中转,进行两次拷贝。但实际上现代编译器在开启优化的情况下会使用ROV和NROV技术,以减少拷贝
关于ROV和NROV
待完成...