c语言中函数如何返回结构体

如果我们需要函数返回结构体,一般会使用返回指针的方式。今天看到有库函数直接返回结构体,不禁很好奇这如何能够实现。因为X86平台上一般用eax/rax寄存器存放返回值,一个结构体可以很大,寄存器如何放的下?

要了解个中细节还得从汇编角度分析。

下面进一段C语言示例程序和对应的反汇编结果

C程序源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
typedef struct {
int a;
int b;
char c[3];
} foo_t;
foo_t global_a;
static foo_t
foo (void)
{
global_a.a = 1;
global_a.b = 2;
global_a.c[0] = 'a';
global_a.c[1] = 'b';
global_a.c[2] = 'c';
return global_a;
}
int main (void)
{
foo_t a;
a = foo();
return 0;
}

linux x86_64上反汇编后的(gcc -s 1.c 然后查看1.s文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
.type foo, @function
foo:
.LFB2:
pushq %rbp
.LCFI0:
movq %rsp, %rbp
.LCFI1:
movl $1, global_a(%rip)
movl $2, global_a+4(%rip)
movb $97, global_a+8(%rip)
movb $98, global_a+9(%rip)
movb $99, global_a+10(%rip)
movq global_a(%rip), %rax
movq %rax, -16(%rbp)
movl global_a+8(%rip), %eax
movl %eax, -8(%rbp)
movq -16(%rbp), %rax
movl -8(%rbp), %edx
leave
ret
.LFE2:
.size foo, .-foo
.globl main
.type main, @function
main:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $32, %rsp
.LCFI4:
call foo
movq %rax, -32(%rbp)
movl %edx, -24(%rbp)
movq -32(%rbp), %rax
movq %rax, -16(%rbp)
movl -24(%rbp), %eax
movl %eax, -8(%rbp)
movl $0, %eax
leave
ret
.LFE3:

上一段程序里面返回了foo_t这个结构,foo_t占12字节(考虑padding),所以一个寄存器(8字节)根本无法容纳。那编译器怎么处理的呢?用两个寄存器呗!

这里简单解释一下那段汇编。8-12行对应C里面12-16行的赋值。第13行把global_a结构开始的8字节内容存到rax内,然后紧接着14行再把rax的内容暂存到堆栈上。第15行将global_a结构的最后4字节内容存入eax,第16行再把eax内容同样暂存到堆栈上。第17,18行将之前暂存的数据放入rax和edx内做为返回数据。那为啥不直接拷贝到rax和edx里面?我怀疑编译器一根筋吧。。。

那么调用者怎么处理这些返回值的呢?第33行调用完毕之后,34/35行将rax/edx内的返回值暂存到堆栈中。然后36-39行把这些暂存值填到变量a里面。后面40-42行设置返回值,平衡堆栈,搞定收工。

这里多说两句36-39行代码,a的地址应该是-16(%rbp),要记住堆栈是逆序生长的,而变量的地址是内存区的起始地址。

上面演示的这段foo_t才12字节。那如果结构体很大,编译器怎么处理的?一切用代码说话。

这段c代码定义了一个巨大无比的结构(相对寄存器大小)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
typedef struct {
int a;
int b;
char c[128];
} foo_t;
foo_t global_a;
static foo_t
foo (void)
{
global_a.a = 1;
global_a.b = 2;
global_a.c[0] = 'a';
global_a.c[1] = 'b';
global_a.c[2] = 'c';
return global_a;
}
int main (void)
{
foo_t a;
a = foo();
return 0;
}

我们看看反汇编后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
foo:
.LFB2:
pushq %rbp
.LCFI0:
movq %rsp, %rbp
.LCFI1:
pushq %rbx
.LCFI2:
subq $8, %rsp
.LCFI3:
movq %rdi, %rbx
movl $1, global_a(%rip)
movl $2, global_a+4(%rip)
movb $97, global_a+8(%rip)
movb $98, global_a+9(%rip)
movb $99, global_a+10(%rip)
movq %rbx, %rdi
movl $global_a, %esi
movl $136, %edx
call memcpy
movq %rbx, %rax
addq $8, %rsp
popq %rbx
leave
ret
.LFE2:
.size foo, .-foo
.globl main
.type main, @function
main:
.LFB3:
pushq %rbp
.LCFI4:
movq %rsp, %rbp
.LCFI5:
subq $144, %rsp
.LCFI6:
leaq -144(%rbp), %rdi
call foo
movl $0, %eax
leave
ret

从汇编代码可以看到,编译器很机智,直接把结构体a的地址作为一个隐含参数来调用foo。具体来说,在39行调用foo之前,36行分配了堆栈来存放变量a(由于call指令会将紧着着的指令地址入栈,比如这里的第40行,144字节里面有8字节存放这个地址),38行把a的地址放入寄存器edi内。然后在函数foo内部,12-16行赋值。在11行会将rdi的值暂存rbx,17行再取出来,我估计为了防止12-16行之间的代码会用到rdi。什么?看了8遍没看出来哪行指令会用到rdi?还是那句话,编译器一根筋,这么循规蹈矩弄一下没啥坏处。然后18行设置目的地址寄存器(global_a的地址),19行设置待拷贝内存长度,20行直接memcpy,然后21行将返回值设置为变量a的地址。后面22-25行堆栈平衡,收工。

当然也可以在gdb里面跟踪上述汇编代码,简单说来,gcc加g选项添加调试信息。然后gdb调试对应程序,disassemble命令反汇编出当前函数,然后ni/si运行下一条指令,根据pc值跟之前用disassemble命令反汇编的对应起来。ni不进入函数跟踪,si进入函数跟踪。info reg rax查看rax寄存器的值。

最后来个收尾。简单一行调用居然隐藏了这么多细节。所以个人并不喜欢这种直接返回结构体的写法,还是用指针更加一目了然。