深入浅出C/C++函数返回值传递原理

前言

众所周知,C/C++函数返回值会使用eax寄存器来传递,我们用以下代码验证一下

1
2
3
4
5
6
7
8
uint32_t foo() {
return 1;
}

int main() {
auto n = foo();
return 0;
}
1
2
3
    auto n = foo();
00F22A93 call foo (0F213E8h)
00F22A98 mov dword ptr [n],eax // ebp-4

可以看到,main函数调用完foo后,将eax的值传送到n中。证明了eax确实用于返回值传递

但eax本身只有4字节,那么当返回值大于4字节时是如何传递的呢?

5~8字节时

修改一下foo函数,返回一个8字节的值,观察一下反汇编

1
2
3
uint64_t foo() {
return 1;
}
1
2
3
4
    auto n = foo();
00D32A93 call fo (0D313EDh)
00D32A98 mov dword ptr [n],eax // ebp-8
00D32A9B mov dword ptr [ebp-4],edx

从汇编看出,当返回一个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
9
struct bar {
char buf[256];
};

bar foo() {
bar tmp;
tmp.buf[0] = '\0';
return tmp;
}

main函数分析

很明显,256字节的结构体,就算用上所有通用寄存器也无法直接传递返回值

我们观察一下main函数的反汇编

1
2
3
4
00771BC0  push        ebp  
00771BC1 mov ebp,esp
00771BC3 sub esp,280h
...

最一开始,保存了ebp后,将esp拉低,在栈上分配了280h个字节的空间

继续往下看

1
2
3
4
5
6
7
8
9
    auto n = foo();
00771BC6 lea eax,[ebp-280h]
00771BCC push eax
00771BCD call foo (07713C5h)
00771BD2 add esp,4
00771BD5 mov ecx,40h
00771BDA mov esi,eax
00771BDC lea edi,[n] // ebp-100h
00771BE2 rep movs dword ptr es:[edi],dword ptr [esi]

可以看到,在call的前面出现了两行奇怪的东西

第1行lea eax,[ebp-280h]将ebp-280h的地址存储到eax中,而ebp-280h正好指向刚才分配空间的末尾

接着push eax,将地址压入栈,然后紧接着调用foo函数。但foo函数没有任何参数呀,其实这里编译器帮我们隐式传递了一个参数,将刚才分配的栈上空间末尾指针传了进去

截至这里,我们可以写出伪代码如下:

1
2
char buf[0x280];
foo(buf); // 相当于foo(ebp-280h);

继续向下看,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赋值的语句,含义如下

  1. ecx是0x40,也就是拷贝0x40 * 4 = 256个字节的数据,和bar的大小一致
  2. esi是eax,也就是foo函数的返回值
  3. edi是n的地址(ebp-100h)

看完这段汇编,基本上也能还原出main函数剩下的伪代码了

1
2
foo(ebp-280h);
memcpy(&n, (void*)eax, sizeof(n));

foo函数分析

可见,foo依然靠eax传递返回值,只不过不是直接传递函数体,而是函数体指针。foo内部到底如何实现呢?继续看一下foo的反汇编

1
2
3
4
5
6
7
bar foo() {
...
bar tmp;
tmp.buf[0] = '\0';
00771C36 mov eax,1
00771C3B imul ecx,eax,0
00771C3E mov byte ptr tmp[ecx],0

前3行,是汇编访问数组的典型形式,相当于如下代码,这里不过多赘述

1
2
3
eax = sizeof(*tmp.buf); // 比例因子
ecx = eax * 0; // 计算与数组首地址的偏移量
*((char*)tmp + ecx) = 0; // 写入0

1
2
3
4
5
6
    return tmp;
00771C46 mov ecx,40h
00771C4B lea esi,[tmp] // ebp-100h
00771C51 mov edi,dword ptr [ebp+8]
00771C54 rep movs dword ptr es:[edi],dword ptr [esi]
00771C56 mov eax,dword ptr [ebp+8]

继续看,前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,这样一来,思路就清晰了

流程

  1. main函数先在栈上额外开辟了一片空间,并将这块空间的一部分用于传递返回值的临时对象
  2. 将临时对象地址作为参数传递给foo函数
  3. foo函数内将数据拷贝给临时对象,并将临时对象的地址用eax传出
  4. foo函数返回后,main函数将eax指向的临时对象的内容拷贝给n

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
void foo(void* ptr) {
bar tmp;
tmp.buf[0] = 0;
memcpy(ptr, &tmp, sizeof(bar));
eax = ptr;
}

int main() {
bar tmp;
bar n;
foo(&tmp);
memcpy(&n, eax, sizeof(bar));
}

总结

在x86架构下使用Debug模式MSVC编译得出以下结论:

返回值类型大小 传递方式
1~4字节 eax(ax/al)寄存器
4~8字节 eax和edx寄存器, eax存储返回值的低4字节,而edx存储返回值的高1~4字节
8+字节 使用栈空间中转,进行两次拷贝

综上所述,在没有开启任何优化的情况下,如果返回值类型过大,C/C++会使用一个临时的栈上空间做中转,进行两次拷贝。但实际上现代编译器在开启优化的情况下会使用ROV和NROV技术,以减少拷贝

关于ROV和NROV

待完成...