首页 > 解决方案 > 从编译器的角度来看,如何处理数组的引用,为什么不允许按值传递(而不是衰减)?

问题描述

众所周知,在 C++ 中,我们可以将数组的引用作为参数传递,例如f(int (&[N]). 是的,它是由 iso 标准保证的语法,但我很好奇编译器在这里是如何工作的。我找到了这个线程,但不幸的是,这并没有回答我的问题——编译器如何实现这种语法?

然后我写了一个demo,希望能从汇编语言中看到一些东西:

void foo_p(int*arr) {}
void foo_r(int(&arr)[3]) {}
template<int length>
void foo_t(int(&arr)[length]) {}
int main(int argc, char** argv)
{
    int arr[] = {1, 2, 3};
    foo_p(arr);
    foo_r(arr);
    foo_t(arr);
   return 0;
}

最初,我它仍然会衰减到指针,但会通过寄存器隐式传递长度,然后转回函数体中的数组。但是汇编代码告诉我这不是真的

void foo_t<3>(int (&) [3]):
  push rbp #4.31
  mov rbp, rsp #4.31
  sub rsp, 16 #4.31
  mov QWORD PTR [-16+rbp], rdi #4.31
  leave #4.32
  ret #4.32

foo_p(int*):
  push rbp #1.21
  mov rbp, rsp #1.21
  sub rsp, 16 #1.21
  mov QWORD PTR [-16+rbp], rdi #1.21
  leave #1.22
  ret #1.22

foo_r(int (&) [3]):
  push rbp #2.26
  mov rbp, rsp #2.26
  sub rsp, 16 #2.26
  mov QWORD PTR [-16+rbp], rdi #2.26
  leave #2.27
  ret #2.27

main:
  push rbp #6.1
  mov rbp, rsp #6.1
  sub rsp, 32 #6.1
  mov DWORD PTR [-16+rbp], edi #6.1
  mov QWORD PTR [-8+rbp], rsi #6.1
  lea rax, QWORD PTR [-32+rbp] #7.15
  mov DWORD PTR [rax], 1 #7.15
  lea rax, QWORD PTR [-32+rbp] #7.15
  add rax, 4 #7.15
  mov DWORD PTR [rax], 2 #7.15
  lea rax, QWORD PTR [-32+rbp] #7.15
  add rax, 8 #7.15
  mov DWORD PTR [rax], 3 #7.15
  lea rax, QWORD PTR [-32+rbp] #8.5
  mov rdi, rax #8.5
  call foo_p(int*) #8.5
  lea rax, QWORD PTR [-32+rbp] #9.5
  mov rdi, rax #9.5
  call foo_r(int (&) [3]) #9.5
  lea rax, QWORD PTR [-32+rbp] #10.5
  mov rdi, rax #10.5
  call void foo_t<3>(int (&) [3]) #10.5
  mov eax, 0 #11.11
  leave #11.11
  ret #11.11

live demo

我承认我对汇编语言不熟悉,但是很明显,这三个函数的汇编代码是一样的!因此,在汇编代码之前必须发生一些事情。无论如何,与数组不同,指针对长度一无所知,对吧?

问题:

  1. 编译器如何在这里工作?
  2. 现在标准允许通过引用传递数组,这是否意味着实现起来很简单?如果是这样,为什么不允许按值传递?

对于 Q2,我的猜测是之前的 C++ 和 C 代码的复杂性。毕竟,在函数参数int[]中等int*于是一种传统。也许一百年后,它会被弃用?

标签: c++arrayspointersassemblycompiler-construction

解决方案


在汇编语言中,对数组的 C++ 引用与指向第一个元素的指针相同。

甚至 C99int foo(int arr[static 3])仍然只是 asm 中的一个指针。该static语法向编译器保证即使 C 抽象机器不访问某些元素,它也可以安全地读取所有 3 个元素,因此例如它可以使用无分支cmovif.


调用者不会在寄存器中传递长度,因为它是编译时常量,因此在运行时不需要。

您可以按值传递数组,但前提是它们位于结构或联合中。在这种情况下,不同的调用约定有不同的规则。 根据 AMD64 ABI,什么样的 C11 数据类型是数组

您几乎从不想按值传递数组,因此 C 没有语法是有道理的,而且 C++ 也从未发明过任何语法。通过常量引用(即const int *arr)传递效率更高;只是一个指针 arg。


通过启用优化来消除编译器噪音:

我把你的代码放在 Godbolt 编译器资源管理器上,用它编译gcc -O3 -fno-inline-functions -fno-inline-functions-called-once -fno-inline-small-functions以阻止它内联函数调用。这消除了-O0调试构建和帧指针样板的所有噪音。(我只是在手册页中搜索inline并禁用内联选项,直到我得到我想要的。)

而不是-fno-inline-small-functions等等,您可以__attribute__((noinline))在函数定义上使用 GNU C 来禁用特定函数的内联,即使它们是static.

我还添加了对没有定义的函数的调用,因此编译器需要arr[]在内存中具有正确的值,并在其中两个函数中添加了存储arr[4]。这让我们可以测试编译器是否警告超出数组边界。

__attribute__((noinline, noclone)) 
void foo_p(int*arr) {(void)arr;}
void foo_r(int(&arr)[3]) {arr[4] = 41;}

template<int length>
void foo_t(int(&arr)[length]) {arr[4] = 42;}

void usearg(int*); // stop main from optimizing away arr[] if foo_... inline

int main()
{
    int arr[] = {1, 2, 3};
    foo_p(arr);
    foo_r(arr);
    foo_t(arr);
    usearg(arr);
   return 0;
}

-Wall -Wextra在 Godbolt上没有函数内联的gcc7.3 -O3:由于我从您的代码中消除了未使用的参数警告,因此我们得到的唯一警告是来自模板,而不是来自foo_r

<source>: In function 'int main()':
<source>:14:10: warning: array subscript is above array bounds [-Warray-bounds]
     foo_t(arr);
     ~~~~~^~~~~

asm 输出为:

void foo_t<3>(int (&) [3]) [clone .isra.0]:
    mov     DWORD PTR [rdi], 42       # *ISRA.3_4(D),
    ret
foo_p(int*):
    rep ret
foo_r(int (&) [3]):
    mov     DWORD PTR [rdi+16], 41    # *arr_2(D),
    ret

main:
    sub     rsp, 24             # reserve space for the array and align the stack for calls
    movabs  rax, 8589934593     # this is 0x200000001: the first 2 elems
    lea     rdi, [rsp+4]
    mov     QWORD PTR [rsp+4], rax    # MEM[(int *)&arr],  first 2 elements
    mov     DWORD PTR [rsp+12], 3     # MEM[(int *)&arr + 8B],  3rd element as an imm32
    call    foo_r(int (&) [3])
    lea     rdi, [rsp+20]
    call    void foo_t<3>(int (&) [3]) [clone .isra.0]    #
    lea     rdi, [rsp+4]      # tmp97,
    call    usearg(int*)     #
    xor     eax, eax  #
    add     rsp, 24   #,
    ret

调用foo_p()仍然得到优化,可能是因为它没有做任何事情。(我没有禁用过程间优化,甚至noinlineandnoclone属性也没有阻止它。)添加*arr=0;到函数体会导致调用它(就像其他 2 一样main传递一个指针)。rdi

请注意clone .isra.0解构函数名称上的注释:gcc 对函数进行了定义,该函数接受一个指向arr[4]而不是指向基本元素的指针。这就是为什么lea rdi, [rsp+20]要设置 arg,以及为什么 store 使用[rdi]deref 没有位移的点。 __attribute__((noclone))会阻止的。

这种过程间优化非常简单,在这种情况下节省了 1 个字节的代码大小(只是disp8克隆中的寻址模式),但在其他情况下可能很有用。调用者需要知道它是函数的修改版本的定义,例如void foo_clone(int *p) { *p = 42; },这就是为什么它需要将它编码到损坏的符号名称中。

如果您在一个文件中实例化了模板并从另一个看不到定义的文件中调用它,那么如果没有链接时优化,gcc 将不得不调用常规名称并将指针传递给数组,就像函数一样书面。

IDK 为什么 gcc 为模板而不是参考这样做。这可能与它警告模板版本的事实有关,但与参考版本无关。或者可能与main推导模板有关?


顺便说一句,实际上会使其运行速度稍快的 IPO 将是 let mainusemov rdi, rsp而不是lea rdi, [rsp+4]. 即&arr[-1]作为函数 arg,所以克隆将使用mov dword ptr [rdi+20], 42.

但这仅对main那些在上面分配了 4 个字节的数组的调用者有帮助rsp,而且我认为 gcc 只是在寻找使函数本身更高效的 IPO,而不是在一个特定的调用者中的调用序列。


推荐阅读