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

标签 c++ arrays pointers assembly compiler-construction

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

然后我写了一个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

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

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


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

    最佳答案

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

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

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

    您可以按值传递数组,但前提是它们位于结构体或 union 体中。在这种情况下,不同的调用约定有不同的规则。 What kind of C11 data type is an array according to the AMD64 ABI

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

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

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

    您可以在函数定义上使用 GNU C -fno-inline-small-functions 来禁用特定函数的内联,而不是 __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;
    }
    

    gcc7.3 -O3 -Wall -Wextra without function inlining, on Godbolt :由于我从您的代码中消除了未使用的参数警告,我们得到的唯一警告来自模板,而不是来自 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 调用它(在 rdi 中传递一个指针,就像其他 2 )。

    请注意解散函数名称上的 clone .isra.0 注释: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 是让 main 使用 mov rdi, rsp 而不是 lea rdi, [rsp+4] 。即,将 &arr[-1] 作为函数 arg,因此克隆将使用 mov dword ptr [rdi+20], 42

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

    关于c++ - 从编译器的角度来看,如何处理数组的引用,以及为什么不允许按值传递(而不是衰减)?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/50775127/

    相关文章:

    objective-c - 通过指针运算与 C 中的下标访问数组值

    c++ - C++ 类的预先定义

    c++ - 如何概念化具有三个以上维度的数组?

    C++ - 将右值引用传递给函数?

    javascript - 如何使用保存数组名称的变量将对象文字添加到数组

    c++ - 定义具有任意跨度的指针

    c++ - 保护/etc/passwd 和/etc/shadow 不被并发访问

    c++ - 如何在 C++ 客户端实现在 DLL 中声明的方法

    c++ - 在 C++ 中使用外部文件创建数组

    arrays - 如果选择一定次数,Perl 从数组中删除项目