如何在堆栈上返回超大结构? 显示早期存储到返回值对象而不是复制的示例

问题描述

said函数返回超大的 struct 值(而不是返回指向 struct 的指针)会导致堆栈上不必要的复制。 “过大”是指无法放入返回寄存器的 struct

然而,引用 Wikipedia

当需要一个超大的结构体返回时,另一个指向调用者提供的空间的指针作为第一个参数,将所有其他参数向右移动一个位置。

在返回结构体/类时,调用代码分配空间并通过堆栈上的隐藏参数传递指向该空间的指针。被调用函数将返回值写入该地址。

看来至少在 x86 架构上,有问题的 struct 是由被调用者直接写入调用者指定的内存中的,那为什么会有副本呢?返回超大的 struct 真的会在堆栈上产生副本吗?

解决方法

这实际上取决于您的编译器,但通常的工作方式是调用者为结构体返回值分配内存,但被调用者也分配堆栈该结构的任何中间值的空间。这个中间分配在函数运行时使用,然后在函数返回时将结构复制到调用者的内存中。

有关为什么您的解决方案并不总是有效的参考,请考虑一个具有两个相同结构并根据某些条件返回一个的程序:

large_t returntype(int condition) {
  large_t var1 = {5};
  large_t var2 = {6};

  // More intermediate code here

  if(condition) return var1;
  else return var2;
}

在这种情况下,中间代码可能需要两者,但在编译时不知道返回值,因此编译器不知道在调用者的堆栈空间上初始化哪个。将其保留在本地并在返回时复制更容易。

编辑:您的解决方案可能适用于简单函数,但这实际上取决于每个编译器执行的优化。如果您真的对此感兴趣,请查看 https://godbolt.org/

,

如果函数内联,通过返回值对象的复制可以完全优化掉。否则,也许不会,并且 arg 复制绝对不可能。

看来至少在 x86 架构上,有问题的 struct 是由被调用者直接写入调用者指定的内存中的,那为什么会有副本呢?返回超大结构真的会导致堆栈上的复制吗?

这取决于调用者如何处理返回值,;如果它被分配给一个可证明的私有对象(转义分析),该对象可以作为返回值对象,作为隐藏指针传递。
但是如果调用者真的想将返回值分配给其他内存,那么它确实需要一个临时的。

struct large retval = some_func();   // no extra copying at all

*p = some_func()       // caller will make space for a local return-value object & copy.

(除非编译器知道 p 只是指向局部 struct large tmp;,并且转义分析可以证明某些全局变量不可能有指向同一个 tmp 的指针变种)


长版,同样的东西,更多细节:

在 C 抽象机中,有一个“返回值对象”,return foo 将命名变量 foo 复制到该对象,即使它是一个大结构。或者 return (struct lg){1,2}; 复制一个匿名结构。返回值对象本身是匿名的;没有什么可以取它的地址。 (你不能int *p = &foo(123);)。这样可以更轻松地进行优化。

在调用者中,该匿名返回值对象可以分配给您想要的任何内容,如果编译器没有优化任何内容,这将是另一个副本。 (所有这些都适用于任何类型,甚至 int)。当然,并非完全垃圾的编译器会避免部分(理想情况下是全部)复制,因为这样做不可能改变可观察到的结果。这取决于调用约定的设计。正如你所说,大多数约定,包括所有主流的 x86 和 x86-64 约定,都会传递一个“隐藏指针”arg 来表示他们出于任何原因选择不在寄存器中返回的返回值(大小、C++ 具有非平凡的构造函数)。

struct large retval = foo(...);

对于这样的调用约定,上面的代码有效转化为

struct large retval;
foo(&retval,...);

所以它的 C 返回值对象实际上 在其调用者的堆栈帧中是一个局部的。 foo() 可以在执行期间随时存储到该返回值对象中,包括在读取其他对象之前。这也允许在被调用者 (foo) 内进行优化,因此可以优化 struct large tmp = ... / return tmp 以仅存储到返回值对象中。

因此,当调用者只想将函数返回值分配给新声明的局部变量时,额外的复制为零。 (或者通过转义分析可以证明它仍然是私有的本地变量。即没有被任何全局变量指向)。


但是如果调用者想要将返回值存储在其他的地方怎么办?

void caller2(struct large *lgp) {
    *lgp = foo();
}

*lgp 可以作为返回值对象,还是需要引入一个本地临时对象?

void caller2(struct large *lgp) {
    // foo_asm(lgp);                        // nope,possibly unsafe
    struct large retval;  foo(&retval);  *lgp = retval;    // safe
}

如果您希望函数能够将大型结构写入任意位置,您必须通过在源代码中显示该效果来“签署”它。


显示早期存储到返回值对象(而不是复制)的示例

(所有源 + asm on the Godbolt compiler explorer)

// more or less extra size will get compilers to copy it around with SSE2 or not
struct large { int first,second; char pad[0];};

int *global_ptr;
extern int a;
NOINLINE                 // __attribute__((noinline))
struct large foo() {
    struct large tmp = {1,2};
    if (a)
        tmp.second = *global_ptr;
    return tmp;
}

(针对 GNU/Linux)clang -m32 -O3 -mregparm=1 创建了一个实现,在它完成读取其他所有内容之前写入其返回值对象,正是这种情况会使调用者不安全传递一个指向某些全局可访问内存的指针。

asm 清楚地表明 tmp 已完全优化掉,或者 retval 对象。

# clang -O3 -m32 -mregparm=1
foo:
        mov     dword ptr [eax + 4],2
        mov     dword ptr [eax],1         # store tmp into the retval object
        cmp     dword ptr [a],0
        je      .LBB0_2                   # if (a == 0) goto ret
        mov     ecx,dword ptr [global_ptr]      # load the global
        mov     ecx,dword ptr [ecx]             # deref it
        mov     dword ptr [eax + 4],ecx         # and store to the retval object
.LBB0_2:
        ret

(-mregparm=1 表示传递 EAX 中的第一个参数,less noisy 并且比传递堆栈更容易快速直观地从堆栈空间中区分。有趣的事实:i386 Linux 使用 {{1} 编译内核但是有趣的事实 #2:如果在堆栈上传递了一个隐藏的指针(即没有 regparm),则该 arg 是被调用者弹出,与其余的不同。该函数将在弹出后使用 -mregparm=3 执行 ESP+=4将地址返回到 EIP。)

在一个简单的调用者中,编译器只保留一些堆栈空间,传递一个指向它的指针,然后可以从该空间加载成员变量。

ret 4
int caller() {
    struct large lg = {4,5};   // initializer is dead,foo can't read its retval object
    lg = foo();
    return lg.second;
}

但是有一个不那么琐碎的调用者:

caller:
        sub     esp,12
        mov     eax,esp
        call    foo
        mov     eax,dword ptr [esp + 4]
        add     esp,12
        ret
int caller() {
    struct large lg = {4,5};
    global_ptr = &lg.first;
    // unknown(&lg);       // or this: as a side effect,might set global_ptr = &tmp->first;
    lg = foo();          // (except by inlining) the compiler can't know if foo() looks at global_ptr
    return lg.second;
}

使用 caller: sub esp,28 # reserve space for 2 structs,and alignment mov dword ptr [esp + 12],5 mov dword ptr [esp + 8],4 # materialize lg lea eax,[esp + 8] mov dword ptr [global_ptr],eax # point global_ptr at it lea eax,[esp + 16] # hidden first arg *not* pointing to lg call foo mov eax,dword ptr [esp + 20] # reload from the retval object add esp,28 ret 进行额外复制

*lgp = foo();
int caller2(struct large *lgp) {
    global_ptr = &lgp->first;
    *lgp = foo();
    return lgp->second;
}

复制到 # with GCC11.1 this time,SSE2 8-byte copying unlike clang caller2: # incoming arg: struct large *lgp in EAX push ebx # mov ebx,eax # lgp,tmp89 # lgp needed after foo returns sub esp,24 # reserve space for a retval object (and waste 16 bytes) mov DWORD PTR global_ptr,eax # global_ptr,lgp lea eax,[esp+8] # hidden pointer to the retval object call foo # movq xmm0,QWORD PTR [esp+8] # 8-byte copy of both halves movq QWORD PTR [ebx],xmm0 # *lgp_2(D),tmp86 mov eax,DWORD PTR [ebx+4] # lgp_2(D)->second,lgp_2(D)->second # reload int return value add esp,24 pop ebx ret 需要发生,但从那里重新加载,而不是从 *lgp 重新加载有点错过了优化。 (以更多延迟为代价节省了一个字节的代码大小。)

Clang 使用两个 4 字节整数寄存器 [esp+12] 加载/存储进行复制,但其中一个是到 EAX 中,因此它已经准备好返回值。


您可能还想查看分配给使用 malloc 新分配的内存的结果。编译器知道没有其他东西可以(合法地)指向新分配的内存:这将是释放后使用的未定义行为。因此,如果尚未将其传递给其他任何对象,则它们可能允许将来自 mov 的指针作为返回值对象传递。


相关有趣的事实:按值传递大型结构总是需要一个副本(如果函数没有内联)。但正如评论中所讨论的,细节取决于调用约定。 Windows 不同于 i386 / x86-64 System V 调用约定(所有非 Windows 操作系统):

  • SysV 调用约定将整个结构复制到堆栈中。 (如果它们太大而无法放入一对 x86-64 寄存器中)
  • Windows x64 生成一个副本并传递(像普通 arg 一样)指向该副本的指针。被调用者“拥有” arg 并且可以修改它,因此仍然需要一个 tmp 副本。 (不,malloc 没有效果。)

https://godbolt.org/z/ThMrE9rqT 显示了针对 Linux 的 x86-64 GCC 与针对 Windows 的 x64 MSVC。