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

陳 力*_*陳 力 4 c++ arrays compiler-construction assembly pointers

我们知道,在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;
}
Run Code Online (Sandbox Code Playgroud)

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

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

live demo

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

问题:

  1. 编译器如何在这里工作?
  2. 现在标准允许通过引用传递数组,这是否意味着实现它是微不足道的?如果是这样,为什么不允许通过价值?

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

Pet*_*des 5

对汇编语言的C++引用与指向第一个元素的指针相同.

甚至C99 int foo(int arr[static 3])仍然只是asm中的一个指针.该static语法担保,它可以安全地读取所有3个元素即使C抽象机器不访问某些元素,所以比如它可以使用网点编译器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;
}
Run Code Online (Sandbox Code Playgroud)

在Godbolt上-Wall -Wextra没有函数内联的gcc7.3 -O3:由于我从代码中清除了unused-args警告,我们得到的唯一警告来自模板,而不是来自foo_r:

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

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

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

注意clone .isra.0关于demangled函数名称的注释:gcc定义了一个函数的定义,该函数接受指针arr[4]而不是基本元素.这就是为什么有一个lea rdi, [rsp+20]设置arg的原因,以及为什么商店用它[rdi]来解除没有位移的点. __attribute__((noclone))会阻止的.

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

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

IDK为什么gcc为模板而不是引用做这个.它可能与它警告模板版本但不是参考版本的事实有关.或者它可能与main推断模板有关?


顺便说一下,实际上让它运行得稍微快一点的IPO就是让mainmov rdi, rsp而不是lea rdi, [rsp+4].即&arr[-1]作为函数arg,因此克隆将使用mov dword ptr [rdi+20], 42.

但是这对于main那些已经分配了4字节以上数组的调用者有用rsp,我认为gcc只寻找使函数本身更有效的IPO,而不是一个特定调用者中的调用序列.