|
| 1 | +# 数据区与符号表 |
| 2 | + |
| 3 | +我们知道,数据区里的数据,其最大的特点就是,能够给整个程序的任何一个地方使用。同时,数据区里的数据也是占静态的二进制可执行程序的体积的。所以,我们应该只将需要全程序使用的变量放在数据区中。而现代编程语言的经验告诉我们,这类全局静态变量应该越少越好。 |
| 4 | + |
| 5 | +同时,由于LLVM是面向多平台的,所以我们还需要考虑的是该怎么处理这些数据。一般来说,大多数平台的可执行程序格式中都会包含`.data`分区,用来存储这类的数据。但除此之外,每个平台还有专门的更加细致的分区,比如说,Linux的ELF格式中就有`.rodata`来存储只读的数据。因此,LLVM的策略是,让我们尽可能细致地定义一个全局变量,比如说注明其是否只读等,然后依据各个平台,如果平台的可执行程序格式支持相应的特性,就可以进行优化。 |
| 6 | + |
| 7 | +一般来说,在LLVM IR中定义一个存储在数据区中的全局变量,其格式为: |
| 8 | + |
| 9 | +```llvm |
| 10 | +@global_variable = global i32 0 |
| 11 | +``` |
| 12 | + |
| 13 | +这个语句定义了一个`i32`类型的全局变量`@global_variable`,并且将其初始化为`0`。 |
| 14 | + |
| 15 | +如果是只读的全局变量,也就是常量,我们可以用`constant`来代替`global`: |
| 16 | + |
| 17 | +```llvm |
| 18 | +@global_constant = constant i32 0 |
| 19 | +``` |
| 20 | + |
| 21 | +这个语句定义了一个`i32`类型的全局常量`@global_constant`,并将其初始化为`0`。 |
| 22 | + |
| 23 | +## 符号与符号表 |
| 24 | + |
| 25 | +关于在数据区的数据,有一个特别需要注意的,就是数据的名称与二进制文件中的符号表。在LLVM IR中,所有的全局变量的名称都需要用`@`开头。我们有一个这样的LLVM IR: |
| 26 | + |
| 27 | +```llvm |
| 28 | +; global_variable_test.ll |
| 29 | +@global_variable = global i32 0 |
| 30 | +
|
| 31 | +define i32 @main() { |
| 32 | +ret i32 0 |
| 33 | +} |
| 34 | +``` |
| 35 | + |
| 36 | +也就是说,在之前最基本的程序的基础上,新增了一个全局变量`@global_variable`。我们将其直接编译成可执行文件: |
| 37 | + |
| 38 | +```shell |
| 39 | +clang global_variable_test.ll -o global_variable_test |
| 40 | +``` |
| 41 | + |
| 42 | +然后,我们使用`nm`命令查看其符号表: |
| 43 | + |
| 44 | +```shell |
| 45 | +nm global_variable_test |
| 46 | +``` |
| 47 | + |
| 48 | +我们可以在输出中找到一行: |
| 49 | + |
| 50 | +```plaintext |
| 51 | +000000000000402c B global_variable |
| 52 | +``` |
| 53 | + |
| 54 | +我们注意到,出现了`global_variable`这个字段。这表明,直接定义的全局变量,其名称会出现在符号表之中。那么,怎么控制这个行为呢?首先,我们需要简单地了解一下符号与符号表。 |
| 55 | + |
| 56 | +在传统的C语言编译模型中,编译器将每个`.c`文件(也称为「编译单元」)编译为一个`.o`目标文件,然后链接器将这些`.o`文件链接为一个可执行文件。这么做的好处是,如果一个项目特别大,编译器就不需要将所有`.c`文件都读入内存中一起处理,而是可以并行地、高效地单独处理每个`.c`文件(这也是著名前端框架React不选择使用TypeScript的原因之一,参见[为什么 React 源码不用 TypeScript 来写? - Cat Chen的回答 - 知乎](https://www.zhihu.com/question/378470381/answer/1079675543))。对于动态链接的程序而言,在程序加载、运行时,也会由动态链接器将相应的库动态链接进程序之中。 |
| 57 | + |
| 58 | +也就是说,编译器生成的结果,需要给链接器和动态链接器进行处理。在这一过程中,就需要「符号表」出马了。在上述的过程中,编译器的输入是一个编译单元,而输出是一个目标文件。那么如果我们在源代码中,在一个`.c`文件中调用了别的文件中实现的函数,编译器并不知道别的函数在哪。因此,编译器选择的策略是将这个函数的调用用一个符号代替,在将来链接以及动态链接的时候,再进行替换。 |
| 59 | + |
| 60 | +粗略来讲,整体的符号处理的过程为: |
| 61 | + |
| 62 | +1. 编译器对源代码按文件进行编译。对于每个文件中的未知函数,记录其符号;对于这个文件中实现的函数,暴露其符号 |
| 63 | +2. 链接器收集所有的目标文件,对于每个文件而言,将其记录下的未知函数的符号,与其他文件中暴露出的符号相对比,如果找到匹配的,就成功地解析(resolve)了符号 |
| 64 | +3. 部分符号在程序加载、执行时,由动态链接库给出。动态链接器将在这些阶段,进行类似的符号解析 |
| 65 | + |
| 66 | +这一流程粗粒度地来看,非常的简单。但是仔细来看,就需要更多的处理。 |
| 67 | + |
| 68 | +一个符号本身,就是一个字符串。那么我们在写一个C语言的项目时,如果希望有的函数在别的文件中被调用,按照上述过程,似乎就是暴露一下符号就行。但是,一个C语言的项目,往往会链接很多第三方库。如果我们想暴露的函数名与其他第三方库里的函数名重复了,会怎样呢?如果不加处理,链接器会直接报错。那难道我们起一个名字,需要注意与别的所有的库里的函数都不重复吗?此外,一个程序会有成千上万个符号,一些简单的,只在一个文件里用到的符号,比如说`cmp`、`max`,难道也要放在符号表中吗? |
| 69 | + |
| 70 | +在LLVM IR中,解决这些问题,与两个概念密切相关:链接与可见性,LLVM IR也提供了[Linkage Type](https://llvm.org/docs/LangRef.html#linkage-types)和[Visibility Styles](https://llvm.org/docs/LangRef.html#visibility-styles)这两个修饰符来控制相应的行为。 |
| 71 | + |
| 72 | +### 链接类型 |
| 73 | + |
| 74 | +对于链接类型,我们常用的主要有什么都不加(默认为`external`)、`private`和`internal`。 |
| 75 | + |
| 76 | +什么都不加的话,就像我们刚刚那样,直接把全局变量的名字放在了符号表中。这样的话,这个函数可以在链接时被其他编译单元看到。 |
| 77 | + |
| 78 | +用`private`,则代表这个变量的名字不会出现在符号表中。我们将原来的代码改写成 |
| 79 | + |
| 80 | +```llvm |
| 81 | +@global_variable = private global i32 0 |
| 82 | +``` |
| 83 | + |
| 84 | +那么,用`nm`查看其编译出的可执行文件,这个变量的名字就消失了。 |
| 85 | + |
| 86 | +用`internal`则表示这个变量是以局部符号的身份出现(全局变量的局部符号,可以理解成C中的`static`关键词)。我们将原来的代码改写成 |
| 87 | + |
| 88 | +```llvm |
| 89 | +@global_variable = internal global i32 0 |
| 90 | +``` |
| 91 | + |
| 92 | +那么,再次将其编译成可执行程序,并用`nm`查看,可以看到这个符号。但是,在链接过程中,这个符号并不会参与符号解析。 |
| 93 | + |
| 94 | +### 可见性 |
| 95 | + |
| 96 | +可见性在实际使用中则比较少,主要分为三种`default`, `hidden`和`protected`,这里主要的区别在于符号能否被重载。`default`的符合可以被重载,而`protected`的符号则不可以;此外,`hidden`则不将变量放在动态符号表中,因此其它的模块不可以直接引用这个符号。 |
| 97 | + |
| 98 | +### 可抢占性 |
| 99 | + |
| 100 | +在我们日常看到的LLVM IR中,会经常见到`dso_local`这样的修饰符,在LLVM中被称作[运行时抢占性修饰符](https://llvm.org/docs/LangRef.html#runtime-preemption-specifiers)。如果需要深入理解这个概念,可以参考国人大神写的[ELF interposition and `-Bsymbolic`](https://maskray.me/blog/2021-05-16-elf-interposition-and-bsymbolic)、[`-fno-semantic-interposition`](https://maskray.me/blog/2021-05-09-fno-semantic-interposition)。简单来说,`dso_local`保证了程序像我们想象中的一样运行。 |
| 101 | + |
| 102 | +举个例子: |
| 103 | + |
| 104 | +```c |
| 105 | +// interposition1.c |
| 106 | +void f(void) { |
| 107 | + printf("From interposition1\n"); |
| 108 | +} |
| 109 | + |
| 110 | +// interposition2.c |
| 111 | +void f(void) { |
| 112 | + printf("From interposition2\n"); |
| 113 | +} |
| 114 | + |
| 115 | +void g(void) { |
| 116 | + f(); |
| 117 | +} |
| 118 | + |
| 119 | +// interposition-main.c |
| 120 | +void g(); |
| 121 | + |
| 122 | +int main() { |
| 123 | + g(); |
| 124 | + return 0; |
| 125 | +} |
| 126 | +``` |
| 127 | +
|
| 128 | +我们想把`interposition1.c`和`interposition2.c`分别编译为动态链接库,并给`main`来调用。最简单的做法是: |
| 129 | +
|
| 130 | +```shell |
| 131 | +clang -fPIC interposition1.c --shared -o libinterposition1.so |
| 132 | +clang -fPIC interposition2.c --shared -o libinterposition2.so |
| 133 | +clang -L. -linterposition1 -linterposition2 interposition-main.c -o interposition |
| 134 | +``` |
| 135 | + |
| 136 | +我们运行这个程序,常理告诉我们,正常来说,得是输出"From interposition2"吧?但是,我们真正运行这个程序,会输出"From interposition1"。 |
| 137 | + |
| 138 | +我们如果看`interposition2`的汇编代码,会发现,`g`函数的实现为 |
| 139 | + |
| 140 | +```x86asm |
| 141 | +g: |
| 142 | +pushq %rbp |
| 143 | +movq %rsp, %rbp |
| 144 | +callq f@PLT |
| 145 | +popq %rbp |
| 146 | +retq |
| 147 | +``` |
| 148 | + |
| 149 | +虽然`f`在同一个文件内,但它居然默认是去PLT表找实现!! |
| 150 | + |
| 151 | +这不仅极其愚蠢(可能仅仅对通过`LD_PRELOAD`来hook API有帮助),而且效率还低。 |
| 152 | + |
| 153 | +但如果我们这样子来编译: |
| 154 | + |
| 155 | +```shell |
| 156 | +clang -fPIC interposition1.c --shared -o libinterposition1.so |
| 157 | +clang -fPIC -fno-semantic-interposition interposition2.c --shared -o libinterposition2.so |
| 158 | +clang -L. -linterposition1 -linterposition2 interposition-main.c -o interposition |
| 159 | +``` |
| 160 | + |
| 161 | +仅仅在编译`libinterposition2.so`时增加了`-fno-semantic-interposition`这个选项,再运行程序,输出就对了! |
| 162 | + |
| 163 | +如果我们查看生成的`interposition2.ll`,可以发现: |
| 164 | + |
| 165 | +```llvm |
| 166 | +define dso_local void @f() { |
| 167 | + %1 = call i32 (ptr, ...) @printf(ptr noundef @.str) |
| 168 | + ret void |
| 169 | +} |
| 170 | +
|
| 171 | +define dso_local void @g() { |
| 172 | + call void @f() |
| 173 | + ret void |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +`f`和`g`都有了`dso_local`的修饰符。`dso_local`就是告诉链接器,这个不许抢占,在生成动态链接库的时候,直接调用,别去PLT表找了。 |
| 178 | + |
| 179 | +当我们使用`clang -O3`等级别进行优化编译时,不需要加`-fno-semantic-interposition`就可以达成一样的效果。 |
| 180 | + |
| 181 | +根据Fedora社区的尝试[Changes/PythonNoSemanticInterpositionSpeedup](https://fedoraproject.org/wiki/Changes/PythonNoSemanticInterpositionSpeedup)和cpython对应的issue [Compile libpython with -fno-semantic-interposition](https://github.com/python/cpython/issues/83161),仅仅是增加这个选项,就可以让cpython快1.3倍。 |
| 182 | + |
| 183 | +### C的例子 |
| 184 | + |
| 185 | +关于符号和符号表,这些还是挺抽象的,我们不如用一个具体的C语言的例子来看看效果: |
| 186 | + |
| 187 | +```c |
| 188 | +int a; |
| 189 | +extern int b; |
| 190 | +static int c; |
| 191 | +void d(void); |
| 192 | +void e(void) {} |
| 193 | +static void f(void) {} |
| 194 | +``` |
| 195 | +
|
| 196 | +首先我们先理解一下这个C语言代码各个符号的含义: |
| 197 | +
|
| 198 | +* `a` |
| 199 | +
|
| 200 | + 定义在当前文件中的全局变量,别的文件也可以使用这个符号 |
| 201 | +* `b` |
| 202 | +
|
| 203 | + 定义在别的文件中的全局变量,当前文件需要使用这个符号 |
| 204 | +* `c` |
| 205 | +
|
| 206 | + 定义在当前文件中的全局变量,别的文件不可以使用这个符号 |
| 207 | +* `d` |
| 208 | +
|
| 209 | + 定义在别的文件中的函数,当前文件需要使用这个符号 |
| 210 | +* `e` |
| 211 | +
|
| 212 | + 定义在当前文件中的函数,别的文件也可以使用这个符号 |
| 213 | +* `f` |
| 214 | +
|
| 215 | + 定义在当前文件中的函数,别的文件不可以使用这个符号 |
| 216 | +
|
| 217 | +以上六种,是我们在C语言编程中最常见的符号形式。 |
| 218 | +
|
| 219 | +我们使用Clang将其编译为LLVM IR,是什么样子的呢? |
| 220 | +
|
| 221 | +```llvm |
| 222 | +@a = dso_local global i32 0, align 4 |
| 223 | +@b = external global i32, align 4 |
| 224 | +@c = internal global i32 0, align 4 |
| 225 | +
|
| 226 | +declare void @d() |
| 227 | +
|
| 228 | +define dso_local void @e() { |
| 229 | + ret void |
| 230 | +} |
| 231 | +
|
| 232 | +define internal void @f() { |
| 233 | + ret void |
| 234 | +} |
| 235 | +``` |
| 236 | + |
| 237 | +我们可以发现几件事: |
| 238 | + |
| 239 | +* C语言中的`static`,也就是当前文件中定义,别的文件不可以用的,都会加上`internal`修饰符 |
| 240 | +* C语言中的`extern`,也就是别的文件中定义的,全局变量会加上`external`修饰符,函数会使用`declare` |
| 241 | +* C语言中定义的,可以给别的文件使用的全局变量或函数,不会加上链接类型修饰符,并且会加上`dso_local`保证不会被抢占 |
0 commit comments