从编译器角度来看,数组的引用是如何处理的?为什么不允许通过值传递(而不是衰减)?

6
我们知道,在C++中,我们可以像f(int (&[N])一样将数组的引用作为参数传递。是的,这是由ISO标准保证的语法,但我很好奇编译器是如何处理的。我在这个线程中找到了相关信息,但不幸的是,它没有回答我的问题——编译器如何实现这种语法?
然后我写了一个演示程序,希望从汇编语言中看到一些东西:
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

实时演示

我承认我不熟悉汇编语言,但很明显,这三个函数的汇编代码是相同的!所以,在汇编器代码之前一定发生了什么。无论如何,与数组不同,指针不知道长度,对吧?

问题:

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

对于问题2,我的猜测是因为早期的C ++和C代码复杂性。毕竟,在函数参数中,int []等同于int *已经成为了一种传统。也许一百年后,它会被淘汰?


2
如果 length == 3,那么 foo_rfoo_t 实际上是相同的。而且长度不需要传递,因为编译器会在编译时内部处理。 - Some programmer dude
2
使用-O3 -fno-inline-functions编译,或者使用__attribute__((noinline))-O3,来消除-O0带来的噪音。嗯,我应该在如何从GCC/clang汇编输出中移除“噪音”?的答案中更新这些建议。 - Peter Cordes
@PeterCordes -O3会完全消除这些调用。 - Chen Li
@陳力:这就是为什么你要使用__attribute__((noinline))而不仅仅是-O3。如果没有它,你还需要-fno-inline-small-functions。请参见我的答案中的更新。 - Peter Cordes
很棒的更新,帮助我学习了很多汇编语言!谢谢 ;) - Chen Li
3个回答

5

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

即使是 C99 的 int foo(int arr[static 3]) 在汇编中也只是一个指针。 static 语法 确保编译器可以安全地读取所有 3 个元素,即使 C 抽象机不访问某些元素,所以例如它可以使用无分支的 cmov 用于 if


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

您可以按值传递数组,但仅当它们在结构体或联合体内部时才能这样做。 在这种情况下,不同的调用约定具有不同的规则。 根据 AMD64 ABI,数组是什么类型的 C11 数据类型

您几乎永远不会想要按值传递数组,因此 C 没有为其提供语法,而 C++ 也从未发明过任何语法。 通过常量引用传递(即 const int *arr)更加有效率;只需要一个指针参数。


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

我将您的代码放在 Godbolt 编译器浏览器中,使用 gcc -O3 -fno-inline-functions -fno-inline-functions-called-once -fno-inline-small-functions 进行编译,以防止它内联函数调用。 这会消除所有 -O0 调试构建和帧指针样板文件的噪音。 (我只是搜索了 man 页面上的 inline 并禁用了内联选项,直到得到所需结果。)

您还可以在函数定义上使用 GNU C 的 __attribute__((noinline)) 来禁用特定函数的内联,即使它们是 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;
}

在Godbolt上,使用gcc7.3 -O3 -Wall -Wextra,并禁止函数内联:自从我消除了你代码中未使用参数的警告后,我们只会收到模板而非foo_r的警告。

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

汇编输出如下:
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()函数仍然被优化掉了,可能是因为它没有执行任何操作。(我没有禁用过程序间优化,即使使用了noinlinenoclone属性也无法阻止它。)向函数体中添加*arr=0;会导致从main调用它(像另外2个一样传递一个指针到rdi)。
注意解码后的函数名上的clone .isra.0注释:gcc定义了一个函数,该函数接受指向arr[4]而不是基本元素的指针。这就是为什么需要使用lea rdi, [rsp+20]来设置参数,并且为什么存储使用[rdi]对点进行解引用而没有位移的原因。__attribute__((noclone))可以防止这种情况发生。
这种程序间优化在这种情况下几乎是微不足道的,只能节省1个字节的代码大小(在克隆中的寻址模式中只有disp8),但在其他情况下可能会很有用。调用方需要知道它是修改版本的定义,例如void foo_clone(int *p) { *p = 42; },这就是为什么它需要在缩略符号名称中进行编码的原因。
如果您在一个文件中实例化模板并从另一个文件中调用它,则如果没有链接时优化,gcc将必须仅调用常规名称并像写的函数一样传递指向数组的指针。
我不知道为什么gcc对模板但不对引用执行此操作。可能与它对模板版本发出警告但对参考版本不发出警告有关。或者可能与main推断模板有关?
顺便说一下,使其运行速度稍微快一点的IPO是让main使用mov rdi, rsp而不是lea rdi, [rsp+4]。也就是说,将&arr[-1]作为函数参数,因此克隆将使用mov dword ptr [rdi+20], 42
但这只对像main这样分配了比rsp高4个字节的数组的调用者有帮助,我认为gcc只在寻找能够使函数本身更有效率的IPO,而不是特定调用者的调用序列。

那么,我们可以说“所有汇编代码都是为运行时服务的”吗? - Chen Li
1
@陳力:我不確定你在問什麼,但是當然 asm 只實現運行時的行為。數組大小只在編譯時檢查,因此如果編譯器可以靜態地證明發生了越界訪問,它可以發出警告。 - Peter Cordes
我还有一个问题:gcc对于函数的定义是采用指向arr[4]的指针而不是基本元素的指针,这个结论是如何得出的? - Chen Li
@陳力:它使用的寻址模式是[rdi],而不是[rdi + 4*4],所以就像我说的那样,它实现了foo_clone(int*p){ p[0] = 42; },而不是p[4] = 42;。它要求调用者传递一个指向实际存储元素的指针,我们可以看到这正是调用者使用lea所做的。 - Peter Cordes

4

这一切都与向后兼容有关。C++从C中获得了数组,而C是从B语言中获得的。在B语言中,数组变量实际上是一个指针。Dennis Ritchie已经写过这个内容

将数组参数衰减为指针有助于Ken Thompson在将UNIX移植到C时重用他的旧B源代码。:-)

当后来看到它可能不是最好的决定时,却被认为太晚改变C语言。因此,数组衰减被保留,但稍后添加的结构体则通过值传递。


结构体的引入也提供了一种解决方案,用于那些真正希望按值传递数组的情况:

为什么要在C中声明只包含数组的结构体?


1
啊,谢谢提供历史。所以原罪是B语言 ;P - Chen Li

2

关于:

我承认我不熟悉汇编语言,但显然这三个函数的汇编代码是相同的!

汇编代码可能完全相同,也可能不同——这取决于个人C++实现(以及您使用的选项)。 C++标准具有总体as-if规则,允许产生任何生成的机器代码,只要保持可观察行为(经过精心定义)。

问题中的不同语法仅仅是源代码级别和翻译过程中的一些语义差异。它们每个在标准中都有不同的定义——例如函数参数的确切类型将不同(如果您使用类似boost::type_index<T>() :: pretty_name() 你会得到不同的机器代码和可观察输出),但归根结底,针对您的示例程序必须生成的整体代码实际上只是main()return 0;语句。(严格来说,对于C ++中的main()函数,该语句也是多余的。)


感谢您的回答。我知道 as-if 规则(刚在 hours ago 中提到),并在多个不同的平台和汇编器上进行了测试。让我困惑的是,在生成汇编代码之前编译器在这里所做的事情(例如长度存储在哪里)。 - Chen Li
3
每个编译器都有自己的方法。其中一些编译器可以提供查看它们的中间步骤和状态的方式,例如GCC有GIMPLE,而Clang有LLVM IR - Tanz87
3
@陳力 在运行时根本不需要内部中间表示,因此如果你真的对编译器的内部机制很感兴趣,检查最终的机器码并没有什么意义,你必须在编译期间调试编译器,结果基本上已经从中间阶段的信息中完全剥离了。(顺便说一句,这是新手汇编程序员常犯的错误之一,通过检查结果验证他们的新代码的正确性,这经常会掩盖重大的错误 :) ...虽然这是完全不同的事情,但我还是决定提一下;) - Ped7g

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接