在一个libc实现中,有时会调用自身其他translation unit定义的函数,如:
1 | #include <string.h> |
这个translation unit用-fPIC编译成.o时会生成一个memcpy函数调用,在部分架构上有少量PIC设置的开销。
在一个ELF文件格式的shared object中,一个定义的non-local STB_DEFAULT符号默认为preemptible(运行时可被替换)。
即使目标函数定义在同一个shared object中,链接器也要假设该函数被可执行文件或其他shared object在运行时替换。
因为,这个.o用-shared方式链接时会产生PLT。
在99.9%的情况下Procedure Linkage Table (PLT)的作用是调用一个定义在其他shared object或可执行文件中的函数。
(什么?你要问我剩下的0.1%是什么?是GNU indirect function一种接近失传、晦涩的技巧。)
–dynamic-list
libc中有大量库函数会被其他库函数调用。放任它们产生PLT会有可观的性能损失。链接器提供了-Bsymbolic
, -Bsymbolic-functions
, version script和--dynamic-list
等几种机制使部分符号non-preemptible。
musl采用的方法是用--dynamic-list
精细指定preemptible的符号列表:
1 | { |
大多数函数都不在这个列表中。定义任何标准库函数都是undefined behavior,但在实际中很多实现会放宽要求以允许可替代的malloc实现(最著名的是通过LD_PRELOAD
使用的jemalloc和tcmalloc)。
另外sanitizers也会preempt大量库函数。
musl 1.1.20起,少量malloc相关函数可以被替换,因此在dynamic list中。
在1.1.20之前,musl的malloc实现不可被替换。musl使用功能比--dynamic-list
弱的-Bsymbolic-functions
:所有的STT_FUNC
符号non-preemptible。
而所有STT_OBJECT
仍是preemptible的。-fno-PIC
方式编译的translation unit只能用于可执行文件。
传统上,-fno-PIC
会用absolute relocation或PC-relative relocation访问外部STT_OBJECT
符号,不用Global Offset Table (GOT)。
当访问的符号定义在一个shared object中时,就会产生copy relocation。此时,只有使符号preemptible才能维持程序的一致性。
倘若non-preemptible,就会产生可执行文件和shared object操作不同拷贝的情形。
PLT设置的开销
在一些缺乏PC relative访问数据的指令的架构上,一个需要PLT的外部函数调用会有更大开销。下面展示编译这段C程序得到的汇编指令:
1 | #ifdef HIDDEN |
i386
在i386上,ABI要求访问PLT时ebx指向GOT base,用于加载.got.plt(或罕见的.plt.got)的函数指针。外部函数调用会有若干额外指令。
1 | # ext is STV_DEFAULT |
1 | # ext is STV_HIDDEN |
PowerPC64
POWER10有PC-relative访问数据的指令。之前,ELFv2用TOC (Table Of Contents)降低缺乏PC-relative指令带来的性能开销。
ABI要求r2指向当前module (可执行文件或shared object)的TOC base。一个外部函数调用会修改r2,因此一个bl指令后需要恢复r2。
编译器会在每一条外部bl指令后放置一个nop(他们一定是受到了Mips delay slot的启发),链接时按需patch成ld指令。
1 | # ext is STV_DEFAULT |
1 | # ext is STV_HIDDEN |
意外地,Mips没有差别,可能是因为它们的指令序列已经很长了吧……
glibc采取的方式是
- 定义
STV_DEFAULT
的memcpy和一个hidden alias__GI_memcpy
- memcpy声明处用asm label指向
__GI_memcpy
- 刻意不使用
-fno-builtin-memcpy
,memcpy函数调用或者被内联,或者展开为__GI_memcpy
这样能避免PLT设置开销。
1 | extern void *memcpy(void *__restrict, const void *__restrict, unsigned long); |
所以,为什么不直接调用__GI_memcpy
呢?因为这样mangle函数名用户体验不好……
其实musl在很多地方用了__
开头的hidden alias,如:
1 |
|
asm label in Clang
对于大多数编译器不认识(没有内建知识,不能内联或替换成其他实现,不能合成)的函数,asm label的实现方式都是挺直接的。
在今天之前的Clang里,有不少库函数(包括最重要的memset/memcpy)的asm label没有效果。我今天修复了这个问题D88712。
这里的主要难点是如果C函数foo含有内建语义X,且符号foo含有内建语义X,那么拒绝编译C函数foo为符号foo是不合逻辑的。
换言之,下述三条不可同时成立。
- 如果frontend函数foo含有内建语义X
- 符号foo含有内建语义X
- C函数foo不能编译为符号foo
在glibc的场合下,第一条是需要的。如果编译器假装不认识memcpy,那么就无法展开n为常数的memcpy,可能会影响性能。这也表明-fno-builtin-memcpy
(或更强的-fno-builtin
和-ffreestanding
)不可接受。
第三条也是需要的,因为使用asm label的目的就是重命名啊……
这样我们就得驳斥第二条。换言之,Clang生成LLVM IR后,IR优化不可假设符号foo具有内建语义X。然而这在GCC和Clang中都无法做到。
Clang若想实现,得引入LLVM IR特性支持重命名。倘若不支持重命名,得知道会lower成符号foo的intrinsics不可生成。
这个功能目前是缺失的。
不能驳斥第二条给整个系统带来了一点不一致性。glibc的处理方式是加第二层重命名,给每个translation unit加一条asm("memcpy = __GI_memcpy;")
- 对于不
#include <string.h>
的translation unit,这个重命名是必要的 - 对于GCC优化过程中合成的memcpy,这行asm保证了GNU as会实施重命名。
我有另一个patch实现GNU as的这个逻辑。
另外,Clang支持继承自Sun Studio的另一种重命名语法:#pragma redefine_extname oldname newname
。内部这一功能是用asm label实现的。
GCC文档中提到了这个功能https://gcc.gnu.org/onlinedocs/gcc/Symbol-Renaming-Pragmas.html,但我测试不可用……