超大结构如何返回到堆栈上?

nal*_*zok 6 c x86 struct abi calling-convention

据说从函数返回过大的值(而不是返回指向 的指针)会在堆栈上产生不必要的复制我所说的“超大”是指无法放入返回寄存器的值。structstructstruct

不过,引用维基百科

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

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

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

Pet*_*des 7

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

看起来至少在x86架构上,所讨论的结构体是由被调用者直接写入调用者指定的内存中,那么为什么会有一个副本呢?返回超大结构是否真的会在堆栈上产生复制?

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

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

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

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


长版,同样的内容,但有更多细节:

在 C 抽象机中,有一个“返回值对象”,并将return foo命名变量复制foo到该对象,即使它是一个大型结构。或者return (struct lg){1,2};复制一个匿名结构。返回值对象本身是匿名的;没有任何东西可以占据它的地址。(你不能int *p = &foo(123);)。这使得优化变得更加容易。

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

struct large retval = foo(...);
Run Code Online (Sandbox Code Playgroud)

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

struct large retval;
foo(&retval, ...);
Run Code Online (Sandbox Code Playgroud)

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

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


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

void caller2(struct large *lgp) {
    *lgp = foo();
}
Run Code Online (Sandbox Code Playgroud)

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

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

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


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

Godbolt 编译器浏览器上的所有源代码 + asm

// 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;
}
Run Code Online (Sandbox Code Playgroud)

(针对 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
Run Code Online (Sandbox Code Playgroud)

(-mregparm=1意味着在 EAX 中传递第一个参数,与在堆栈上传递相比,噪音更小,并且更容易在视觉上快速区分堆栈空间。有趣的事实:i386 Linux 使用 编译内核-mregparm=3。但有趣的事实 #2:如果在堆栈(即没有 regparm),该 arg 是被调用者弹出,与其余部分不同。该函数将ret 4在将返回地址弹出到 EIP 后执行 ESP+=4。)

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

int caller() {
    struct large lg = {4, 5};   // initializer is dead, foo can't read its retval object
    lg = foo();
    return lg.second;
}
Run Code Online (Sandbox Code Playgroud)
caller:
        sub     esp, 12
        mov     eax, esp
        call    foo
        mov     eax, dword ptr [esp + 4]
        add     esp, 12
        ret
Run Code Online (Sandbox Code Playgroud)

但对于一个不那么简单的调用者:

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;
}
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)

额外复印*lgp = foo();

int caller2(struct large *lgp) {
    global_ptr = &lgp->first;
    *lgp = foo();
    return lgp->second;
}
Run Code Online (Sandbox Code Playgroud)
# 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     
Run Code Online (Sandbox Code Playgroud)

需要进行复制*lgp,但从那里而不是从 重新加载有点错过了优化[esp+12]。(以更多延迟为代价节省一个字节的代码大小。)

Clang 使用两个 4 字节整数寄存器加载/存储进行复制mov,但其中之一进入 EAX,因此它已经准备好返回值。


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


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

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

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


Guy*_*ino 2

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

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

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

  // More intermediate code here

  if(condition) return var1;
  else return var2;
}
Run Code Online (Sandbox Code Playgroud)

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

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