在大多数高级语言中,数组(包括字符串)是通过引用传递的。 int foo(char*) 只是获取一个指针值作为 arg,而指针通常是一个机器字(即适合寄存器)。在良好的现代调用约定中,前几个整数/指针参数通常在寄存器中传递。
在 C/C++ 中,不能按值传递裸数组。给定int arr[16]; func(arr);,函数func 只获得一个指针(指向第一个元素)。
在其他一些更高级的语言中,数组可能更像 C++ std::vector,因此被调用者可能能够扩大/缩小数组并找出其长度而无需单独的 arg。这通常意味着有一个“控制块”。
在 C 和 C++ 中,您可以按值传递结构,然后由调用约定规则指定如何传递它们。
例如,x86-64 System V 将 16 字节或更小的结构体传递到最多 2 个整数寄存器中。较大的结构被复制到堆栈上,无论它们包含多大的数组成员 (What kind of C11 data type is an array according to the AMD64 ABI)。 (所以不要将巨型对象按值传递给非内联函数!)
Windows x64 调用约定通过隐藏引用传递大型结构。
示例:
typedef struct {
// too big makes the asm output cluttered with loops or memcpy
// int Big_McLargeHuge[1024*1024];
int arr[4];
long long a,b; //,c,d;
} bigobj;
// total 32 bytes with int=4, long long=8 bytes
int func(bigobj a);
int foo(bigobj a) {
a.arr[3]++;
return func(a);
}
source + asm output on the Godbolt compiler explorer.
您可以在 Godbolt 上尝试使用其标准调用约定的其他架构,例如 ARM 或 AArch64。我之所以选择 x86-64,是因为我碰巧知道在该平台上用于结构传递的两个主要调用约定之间存在一个有趣的差异。
x86-64 System V (gcc7.3 -O3):foo 有一个可以修改的 arg 的真实按值副本(由其调用者完成),因此它这样做并将其用作尾调用的 arg。 (如果不能tailcall,它就必须再做一个完整的副本。这个例子人为地让System V看起来很好)。
foo(bigobj):
add DWORD PTR [rsp+20], 1 # increment the struct member in the arg on the stack
jmp func(bigobj) # tailcall func(a)
x86-64 Windows (MSVC CL19 /Ox):注意我们通过 RCX 寻址 a.arr[3],第一个整数/指针 arg。所以有一个隐藏的引用,但它不是一个常量引用。 这个函数是按值调用的,但它正在修改它通过引用获得的数据。所以调用者必须复制,或者至少假设被调用者破坏了它得到的指针。 (如果对象在那之后死了,则不需要复制,但这仅适用于本地结构对象,而不是传递指向全局的指针或其他东西)。
$T1 = 32 ; offset of the tmp copy in this function's stack frame
foo PROC
sub rsp, 72 ; 00000048H ; 32B of shadow space + 32B bigobj + 8 to align
inc DWORD PTR [rcx+12]
movups xmm0, XMMWORD PTR [rcx] ; load modified `a`
movups xmm1, XMMWORD PTR [rcx+16] ; apparently alignment wasn't required
lea rcx, QWORD PTR $T1[rsp]
movaps XMMWORD PTR $T1[rsp], xmm0
movaps XMMWORD PTR $T1[rsp+16], xmm1 ; store a copy
call int __cdecl func(struct bigobj)
add rsp, 72 ; 00000048H
ret 0
foo ENDP
制作对象的另一个副本似乎是错过了优化。我认为对于相同的调用约定,这将是foo 的有效实现:
foo:
add DWORD PTR [rcx+12], 1 ; more efficient than INC because of the memory dst, on Intel CPUs
jmp func ; tailcall with pointer still in RCX
SysV ABI 的 x86-64 clang 也错过了 gcc7.3 发现的优化,并且确实像 MSVC 一样复制。
所以 ABI 的差异并没有我想象的那么有趣;在这两种情况下,被调用者都“拥有” arg,即使对于 Windows,它不能保证在堆栈上。我想这可以实现动态分配,以按值传递非常大的对象而不会出现堆栈溢出,但这有点毫无意义。只是一开始就不要这样做。
小物件:
x86-64 System V 传递打包到寄存器中的小对象。如果您注释掉 long long 成员,Clang 会找到一个巧妙的优化,这样您就可以拥有
typedef struct {
int arr[4];
// long long a,b; //,c,d;
} bigobj;
# clang6.0 -O3
foo(bigobj): # @foo(bigobj)
movabs rax, 4294967296 # 0x100000000 = 1ULL << 32
add rsi, rax
jmp func(bigobj) # TAILCALL
(arr[0..1] 被打包到 RDI 中,arr[2..3] 被打包到 RSI 中,即 x86-64 SysV ABI 中的前 2 个整数/指针参数传递寄存器)。
gcc 将arr[3] 解压到一个寄存器中,它可以在其中递增它。
但是 clang 不是解包和重新打包,而是通过添加 1ULL<<32 来增加 RSI 的高 32 位。
MSVC 仍然通过隐藏引用传递,并且仍然复制整个对象。