gcc 的 `-fno-builtin` 和内建函数优化

一个比较有趣的行为

从一个无关的例子开始 #

HPC 课上提到,函数的副作用会阻碍编译优化。当时给了这样的一个例子:

void lower1(char* s) {
    size_t i;
    for (i = 0; i < my_strlen(s); i++) {
        if (s[i] >= 'A' && s[i] <= 'Z') {
            s[i] -= 'A' - 'a';
        }
    }
}

假定我们的 my_strlen 是自己造的轮子,大概长这样:

size_t my_strlen(const char* s) {
    size_t i = 0;
    while (s[i] != '0') {
        i++;
    }
    return i;
}

这个时候 lower1 的时间复杂度是 $O(n^2)$,因为每次循环条件判定的时候,都要调用 my_strlen

看一下生成的汇编(编译选项 -Og),确实是这样的。my_strlen 内部生成了一个循环,lower1 再循环调用 my_strlen

my_strlen(char const*):
        movl    $0, %eax
        jmp     .L2
.L3:
        addq    $1, %rax
.L2:
        cmpb    $48, (%rdi,%rax)
        jne     .L3
        ret
lower1(char*):
        pushq   %rbp
        pushq   %rbx
        movq    %rdi, %rbp
        movl    $0, %ebx
        jmp     .L5
.L6:
        addq    $1, %rbx
.L5:
        movq    %rbp, %rdi
        call    my_strlen(char const*)
        cmpq    %rax, %rbx
        jnb     .L9
        leaq    0(%rbp,%rbx), %rdx
        movzbl  (%rdx), %eax
        leal    -65(%rax), %ecx
        cmpb    $25, %cl
        ja      .L6
        addl    $32, %eax
        movb    %al, (%rdx)
        jmp     .L6
.L9:
        popq    %rbx
        popq    %rbp
        ret

假如编译器帮助我们把 my_strlen 的调用放到循环外面,用一个临时变量保存它的值,这样就能把时间复杂度优化到 $O(n)$。然而,即使是 -Ofast 优化,也仅仅是把我们的 my_strlen 内联到了 lower1 中。这是因为编译器无法确定 my_strlen 是否是有副作用的。假如我们自己造的 my_strlen 同时会修改一个全局变量,那么将其提到循环外面就会导致出错。

gcc 的内建函数 #

有了这样一个概念之后,很多人可能以为 gcc 对这样用户自己造轮子的场景就无事可做了。实际上,尽管 gcc 确实不会自作主张更改 lower1 的控制流,但在特定的情况下,它会对 my_strlen 做手脚。

改写 my_strlen 成下面这样(貌似会让 gcc 更好地理解它的语义,我也不知道为什么):

size_t my_strlen(const char* s) {
    size_t i;
    for (i = 0; s[i]; i++);
    return i;
}

打开 -O2 进行编译,我们会得到这样的汇编:

my_strlen:
        cmpb    $0, (%rdi)
        je      .L3
        subq    $8, %rsp
        addq    $1, %rdi
        call    strlen
        addq    $8, %rsp
        addq    $1, %rax
        ret
.L3:
        xorl    %eax, %eax
        ret

这里直接 call 了 strlen。这就是 gcc 的内建函数优化。gcc 识别到你写的函数等价于 strlen,并且认为你写的肯定不如 glibc 里有的厉害,于是这里直接调用 glibc 的 strlen 去了。编译成可执行文件然后 objdump -d 可以得到更清楚的视角:

0000000000001160 <_Z9my_strlenPKc>:
    1160:       80 3f 00                cmpb   $0x0,(%rdi)
    1163:       74 1b                   je     1180 <_Z9my_strlenPKc+0x20> # 直接返回咯
    1165:       48 83 ec 08             sub    $0x8,%rsp
    1169:       48 83 c7 01             add    $0x1,%rdi
    116d:       e8 be fe ff ff          call   1030 <strlen@plt> # 去 plt 找 strlen
    1172:       48 83 c4 08             add    $0x8,%rsp
    1176:       48 83 c0 01             add    $0x1,%rax
    117a:       c3                      ret
    117b:       0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
    1180:       31 c0                   xor    %eax,%eax
    1182:       c3                      ret
    1183:       66 66 2e 0f 1f 84 00    data16 cs nopw 0x0(%rax,%rax,1)
    118a:       00 00 00 00
    118e:       66 90                   xchg   %ax,%ax

plt 段如下:

Disassembly of section .plt:

0000000000001020 <strlen@plt-0x10>:
    1020:       ff 35 ca 2f 00 00       push   0x2fca(%rip)        # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
    1026:       ff 25 cc 2f 00 00       jmp    *0x2fcc(%rip)        # 3ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
    102c:       0f 1f 40 00             nopl   0x0(%rax)

0000000000001030 <strlen@plt>:
    1030:       ff 25 ca 2f 00 00       jmp    *0x2fca(%rip)        # 4000 <strlen@GLIBC_2.2.5>
    1036:       68 00 00 00 00          push   $0x0
    103b:       e9 e0 ff ff ff          jmp    1020 <_init+0x20>

可以很清楚地看到,跑到 glibc 里头去了。

另外经测试发现,即使 my_strlen 有副作用,一样会在函数中调用 glibc。懒得贴代码了。

隐患 #

这种随便跳到共享库里的行为其实挺不好的。假如我们编译出来的东西要在单片机这种没有 glibc 的环境下执行,直接就会出现找不到符号的错误。

另外假如自己造轮子的时候,直接就把 my_strlen 就叫 strlen,然后就会得到这种汇编:

strlen:
        cmpb    $0, (%rdi)
        je      .L3
        subq    $8, %rsp
        addq    $1, %rdi
        call    strlen
        addq    $8, %rsp
        addq    $1, %rax
        ret
.L3:
        xorl    %eax, %eax
        ret

由于符号名字相同,这里反而不会进到 glibc 里了,而是变成了递归调用自己。注意由于这里先给 rdi +1 的操作,并不会造成无限递归,也就是说代码的功能起码是正确的。

但假如开启了 -Os,让 gcc 优化一下生成的代码的大小,就会变成这样:

strlen:
        jmp     strlen

注意这里还看不出来到底会跳到哪里去,正常情况下还是会跳转到 glibc。但如果你同时还开了 -nostdlib,编译出来就会是:

Disassembly of section .text:

0000000000001000 <strlen>:
    1000:       e9 fb ff ff ff          jmp    1000 <strlen>

直接完蛋。

一种解决方法是加上 -fno-builtin 编译选项。这会阻止所有的内建函数优化。