如果编译器的后端用于多个编程语言前端,那么不同语言编译出来的目标代码是否相同?

6
我知道编译器可以有很多前端,每个前端将编程语言编写的代码翻译成内部数据结构。在该数据结构中,编译器进行一些优化。然后编译器的后端将该数据结构转换为汇编代码,然后在汇编阶段将汇编代码转换为目标代码。
我的问题是:
考虑到任何编程语言都被翻译成该内部数据结构,那么对于不同的编程语言但相同的程序逻辑,编译器输出的最终代码是否相同?

3
即使是同一种语言,相同的逻辑也不一定相同;但有可能相同。 - Jester
@jester:我认为这是这个问题唯一合理的答案。 - rici
2
就像不同的社区一样:即使他们说着相同的语言(汇编),由于不同的方言(名称重整)、基础设施(调用约定)或文化(编程语言概念,如指针),他们可能无法无缝合作。这是可能的,只是不总是立竿见影的。 - Margaret Bloom
@MargaretBloom 谢谢你的类比。很有趣的观点。 - user8613421
@MargaretBloom:关于名称混淆和其他问题,你说得很好。我只是在考虑在可能希望获得相同输出的情况下发出的实际指令。(即不同的结构布局)。 - Peter Cordes
2个回答

5

是的,很可能会出现这种情况。但是,语言之间微小的差异可能会导致看起来相似的源代码生成不同的汇编代码。前端很少能够完全按照后端的要求提供输入。对于简单函数,它们可能会被优化为相同的结果,并且通常会使用相同的策略。(例如,在x86上,值得使用多少LEA指令而不是乘法。)

例如,在C语言中,有符号溢出是未定义行为,因此

void foo(int *p, int n) {
    for (int i = 0; i <= n ; i++) {
         p[i] = i/4;
     }
}

可以假设对于所有可能的n(包括INT_MAX),最终都会终止,并且i为非负数。
对于一个语言的前端,其中i++被定义为2的补码环绕(或使用-fwrapv -fno-strict-overflow的gcc),i将从== INT_MAX变为一个大的负数,始终<= INT_MAX。编译器需要制作汇编代码,以忠实地实现源代码的行为,即使是对于传递n == INT_MAX的调用者,也要使其成为一个无限循环,其中i可以为负数。

但是由于在C和C++中这是未定义行为,编译器可以假设程序不包含任何UB,因此没有调用者可以传递INT_MAX。它可以假设循环内的i永远不会为负数,并且循环次数适合于int。参见每个C程序员都应该知道的未定义行为(clang博客)。


非负数的假设使得它可以使用简单的右移实现/ 4,而不是为负数实现C整数除法语义。
# the p[i] = i/4;  part of the inner loop from
# gcc -O3  -fno-tree-vectorize
    mov     edx, eax                        # copy the loop counter
    sar     edx, 2                          # i / 4 == i>>2
    mov     DWORD PTR [rdi+rax*4], edx      # store into the array

源代码和汇编输出 在Godbolt编译器探索者上查看

但是如果定义了有符号数的环绕行为,那么用一个常数进行有符号数除法需要更多的指令,并且数组索引必须考虑到可能的环绕情况。
# Again *just* the body of the inner loop, without the loop overhead
# gcc -fno-strict-overflow -fwrapv    -O3 -fno-tree-vectorize
    test    eax, eax           # set flags (including SF) according to i
    lea     edx, [rax+3]       # edx = i+3
    movsx   rcx, eax           # sign-extend for use in the addressing mode
    cmovns  edx, eax           # copy if !signbit_set(i)
    sar     edx, 2             # i/4 = i>=0 ? i>>2 : (i+3)>>2;
    mov     DWORD PTR [rdi+rcx*4], edx

C数组索引语法只是指针加整数的语法糖,并不要求索引为非负数。因此,调用者可以传递指向4GB数组中间的指针,这个函数最终必须写入该指针所在位置。(无限循环也是有问题的,但这里不讨论。)
正如你所看到的,语言规则上的微小差异要求编译器不进行优化。语言规则之间的差异通常比ISO C++和g++实现的定义符号包装C++之间的差异更大。
此外,如果"常见"类型在另一种语言中具有不同的宽度或符号性,则后端很可能会得到不同的输入,在某些情况下这将很重要。
如果我使用了unsigned,则在C和C++中,包装将是定义的溢出行为。但是,unsigned类型的定义是非负的,因此没有展开的情况下,溢出的可能性不会对优化产生如此明显的影响。如果循环从大于零的值开始,那么wraparound会导致回到0的可能性,如果这很重要(例如x / i是除以零),那么这将是一个问题。

1

是的,不同语言编译出的代码最终可能会产生相同的汇编代码。

相同或类似的代码

例如,如果两种不同语言的前端生成相同的中间代码和元数据1,并且应用了相同的优化阶段,则可以保证后端生成相同的代码。在C和C++等密切相关的语言中,往往相同或类似的代码会产生相同的代码,这一点非常容易理解。

以下是一个使用C代码递增指针和使用C++代码递增引用的简单示例。

C中的递增

源代码

void inc(int* p) {
    (*p)++;
}

最终装配

gcc中使用-O2

inc:
        add     DWORD PTR [rdi], 1
        ret

请在 gccclang 中自己尝试使用汇编 这里

C++

类似的代码,但使用 C++ 的引用特性而不是传递指针。

源代码

void inc(int& p) { p++; }

汇编

g++ 中使用 -O2

inc(int&):
        add     DWORD PTR [rdi], 1
        ret

这里使用godbolt进行玩耍。

无论是使用不同的语言还是不同的语言特性(例如C++中的引用,而在C++中不可用),产生的汇编代码都是相同的。

请注意,即使是完全独立的工具链clanggcc也会生成不同的代码-在使用inc而非add时,但生成的代码在C和C++之间保持一致。

不同的代码

更有趣的是,即使是使用不同语言的截然不同的代码,也可能产生相同的最终汇编代码。即使前端产生非常不同的中间代码,优化过程也可能将两个输入最终降至相同的输出。当然,并不能保证对于任何特定的输入都是如此,并且它将因编译器和平台而异。


1 通过元数据,我指的是除中间指令本身以外的任何可能影响代码生成的内容。例如,某些语言可能允许更少的优化,如内存重排序,或具有其他行为差异(彼得指出有符号溢出)。目前还不清楚所有这些差异是否直接编码在中间语言中,或者是否每个中间代码束都有相关的元数据描述特定的语义,优化阶段和后端必须遵守。


1
我猜不同的编译器有不同的方法将语言规则传递给后端。有些可能使用通用元数据格式中的显式内容,而其他一些则使用后端的开关/模式。顺便说一句,如果你想更加高级一些,Matt Godbolt的compiler-explorer网站还有其他语言,包括Rust和D,它们甚至比C和C++更加不同。 - Peter Cordes
哦,我没有包括其他语言,因为“godbolt”不支持它们,但我没有注意到新的语言已经添加了!谢谢@PeterCordes - BeeOnRope

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