数据区与符号表

我们知道,数据区里的数据,其最大的特点就是,能够给整个程序的任何一个地方使用。同时,数据区里的数据也是占静态的二进制可执行程序的体积的。所以,我们应该只将需要全程序使用的变量放在数据区中。而现代编程语言的经验告诉我们,这类全局静态变量应该越少越好。

同时,由于LLVM是面向多平台的,所以我们还需要考虑的是该怎么处理这些数据。一般来说,大多数平台的可执行程序格式中都会包含.data分区,用来存储这类的数据。但除此之外,每个平台还有专门的更加细致的分区,比如说,Linux的ELF格式中就有.rodata来存储只读的数据。因此,LLVM的策略是,让我们尽可能细致地定义一个全局变量,比如说注明其是否只读等,然后依据各个平台,如果平台的可执行程序格式支持相应的特性,就可以进行优化。

一般来说,在LLVM IR中定义一个存储在数据区中的全局变量,其格式为:

@global_variable = global i32 0

这个语句定义了一个i32类型的全局变量@global_variable,并且将其初始化为0

如果是只读的全局变量,也就是常量,我们可以用constant来代替global

@global_constant = constant i32 0

这个语句定义了一个i32类型的全局常量@global_constant,并将其初始化为0

符号与符号表

讲到了数据区,就顺便讲讲符号表。在LLVM IR中,所有的全局变量的名称都需要用@开头。我们有一个这样的LLVM IR:

; global_variable_test.ll
@global_variable = global i32 0

define i32 @main() {
    ret i32 0
}

也就是说,在之前最基本的程序的基础上,新增了一个全局变量@global_variable。我们将其直接编译成可执行文件:

clang global_variable_test.ll -o global_variable_test

然后,我们使用nm命令查看其符号表:

nm global_variable_test

我们可以在输出中找到一行:

000000000000402c B global_variable

我们注意到,出现了global_variable这个字段。这表明,直接定义的全局变量,其名称会出现在符号表之中。那么,怎么控制这个行为呢?首先,我们需要简单地了解一下符号与符号表。

在传统的C语言编译模型中,编译器将每个.c文件(也称为「编译单元」)编译为一个.o目标文件,然后链接器将这些.o文件链接为一个可执行文件。这么做的好处是,如果一个项目特别大,编译器就不需要将所有.c文件都读入内存中一起处理,而是可以并行地、高效地单独处理每个.c文件(这也是著名前端框架React不选择使用TypeScript的原因之一,参见为什么 React 源码不用 TypeScript 来写? - Cat Chen的回答 - 知乎)。对于动态链接的程序而言,在程序加载、运行时,也会由动态链接器将相应的库动态链接进程序之中。

也就是说,编译器生成的结果,需要给链接器和动态链接器进行处理。在这一过程中,就需要「符号表」出马了。在上述的过程中,编译器的输入是一个编译单元,而输出是一个目标文件。那么如果我们在源代码中,在一个.c文件中调用了别的文件中实现的函数,编译器并不知道别的函数在哪。因此,编译器选择的策略是将这个函数的调用用一个符号代替,在将来链接以及动态链接的时候,再进行替换。

粗略来讲,整体的符号处理的过程为:

  1. 编译器对源代码按文件进行编译。对于每个文件中的未知函数,记录其符号;对于这个文件中实现的函数,暴露其符号
  2. 链接器收集所有的目标文件,对于每个文件而言,将其记录下的未知函数的符号,与其他文件中暴露出的符号相对比,如果找到匹配的,就成功地解析(resolve)了符号
  3. 部分符号在程序加载、执行时,由动态链接库给出。动态链接器将在这些阶段,进行类似的符号解析

这一流程粗粒度地来看,非常的简单。但是仔细来看,就需要更多的处理。

一个符号本身,就是一个字符串。那么我们在写一个C语言的项目时,如果希望有的函数在别的文件中被调用,按照上述过程,似乎就是暴露一下符号就行。但是,一个C语言的项目,往往会链接很多第三方库。如果我们想暴露的函数名与其他第三方库里的函数名重复了,会怎样呢?如果不加处理,链接器会直接报错。那难道我们起一个名字,需要注意与别的所有的库里的函数都不重复吗?此外,一个程序会有成千上万个符号,一些简单的,只在一个文件里用到的符号,比如说cmpmax,难道也要放在符号表中吗?

在LLVM IR中,解决这些问题,与两个概念密切相关:链接与可见性,LLVM IR也提供了Linkage TypeVisibility Styles这两个修饰符来控制相应的行为。

链接类型

对于链接类型,我们常用的主要有什么都不加(默认为external)、privateinternal

什么都不加的话,就像我们刚刚那样,直接把全局变量的名字放在了符号表中。这样的话,这个函数可以在链接时被其他编译单元看到。

private,则代表这个变量的名字不会出现在符号表中。我们将原来的代码改写成

@global_variable = private global i32 0

那么,用nm查看其编译出的可执行文件,这个变量的名字就消失了。

internal则表示这个变量是以局部符号的身份出现(全局变量的局部符号,可以理解成C中的static关键词)。我们将原来的代码改写成

@global_variable = internal global i32 0

那么,再次将其编译成可执行程序,并用nm查看,可以看到这个符号。但是,在链接过程中,这个符号并不会参与符号解析。

可见性

可见性在实际使用中则比较少,主要分为三种default, hiddenprotected,这里主要的区别在于符号能否被重载。default的符号可以被重载,而protected的符号则不可以;此外,hidden则不将变量放在动态符号表中,因此其它的模块不可以直接引用这个符号。

可抢占性

在我们日常看到的LLVM IR中,会经常见到dso_local这样的修饰符,在LLVM中被称作运行时抢占性修饰符。如果需要深入理解这个概念,可以参考国人大神写的ELF interposition and -Bsymbolic-fno-semantic-interposition。简单来说,dso_local保证了程序像我们想象中的一样运行。

举个例子:

// interposition1.c
void f(void) {
    printf("From interposition1\n");
}

// interposition2.c
void f(void) {
    printf("From interposition2\n");
}

void g(void) {
    f();
}

// interposition-main.c
void g();

int main() {
    g();
    return 0;
}

我们想把interposition1.cinterposition2.c分别编译为动态链接库,并给main来调用。最简单的做法是:

clang -fPIC interposition1.c --shared -o libinterposition1.so
clang -fPIC interposition2.c --shared -o libinterposition2.so
clang -L. -linterposition1 -linterposition2 interposition-main.c -o interposition

我们运行这个程序,常理告诉我们,正常来说,得是输出"From interposition2"吧?但是,我们真正运行这个程序,会输出"From interposition1"。

我们如果看interposition2的汇编代码,会发现,g函数的实现为

g:
    pushq    %rbp
    movq     %rsp, %rbp
    callq    f@PLT
    popq     %rbp
    retq

虽然f在同一个文件内,但它居然默认是去PLT表找实现!!

这不仅极其愚蠢(可能仅仅对通过LD_PRELOAD来hook API有帮助),而且效率还低。

但如果我们这样子来编译:

clang -fPIC interposition1.c --shared -o libinterposition1.so
clang -fPIC -fno-semantic-interposition interposition2.c --shared -o libinterposition2.so
clang -L. -linterposition1 -linterposition2 interposition-main.c -o interposition

仅仅在编译libinterposition2.so时增加了-fno-semantic-interposition这个选项,再运行程序,输出就对了!

如果我们查看生成的interposition2.ll,可以发现:

define dso_local void @f() {
  %1 = call i32 (ptr, ...) @printf(ptr noundef @.str)
  ret void
}

define dso_local void @g() {
  call void @f()
  ret void
}

fg都有了dso_local的修饰符。dso_local就是告诉链接器,这个不许抢占,在生成动态链接库的时候,直接调用,别去PLT表找了。

当我们使用clang -O3等级别进行优化编译时,不需要加-fno-semantic-interposition就可以达成一样的效果。

根据Fedora社区的尝试Changes/PythonNoSemanticInterpositionSpeedup和cpython对应的issue Compile libpython with -fno-semantic-interposition,仅仅是增加这个选项,就可以让cpython快1.3倍。

C的例子

关于符号和符号表,这些还是挺抽象的,我们不如用一个具体的C语言的例子来看看效果:

int a;
extern int b;
static int c;
void d(void);
void e(void) {}
static void f(void) {}

首先我们先理解一下这个C语言代码各个符号的含义:

  • a

    定义在当前文件中的全局变量,别的文件也可以使用这个符号

  • b

    定义在别的文件中的全局变量,当前文件需要使用这个符号

  • c

    定义在当前文件中的全局变量,别的文件不可以使用这个符号

  • d

    定义在别的文件中的函数,当前文件需要使用这个符号

  • e

    定义在当前文件中的函数,别的文件也可以使用这个符号

  • f

    定义在当前文件中的函数,别的文件不可以使用这个符号

以上六种,是我们在C语言编程中最常见的符号形式。

我们使用Clang将其编译为LLVM IR,是什么样子的呢?

@a = dso_local global i32 0, align 4
@b = external global i32, align 4
@c = internal global i32 0, align 4

declare void @d()

define dso_local void @e() {
  ret void
}

define internal void @f() {
  ret void
}

我们可以发现几件事(在默认的编译选项下):

  • C语言中的static,也就是当前文件中定义,别的文件不可以用的,都会加上internal修饰符
  • C语言中的extern,也就是别的文件中定义的,全局变量会加上external修饰符,函数会使用declare
  • C语言中定义的,可以给别的文件使用的全局变量或函数,不会加上链接类型修饰符,并且会加上dso_local保证不会被抢占