1
1
# 小彭老师带你学 LLVM
2
2
3
+ [ TOC]
4
+
5
+ ## LLVM 介绍
6
+
3
7
LLVM 是一个跨平台的编译器基础设施,它不是一个单一的编译器,而是一系列工具和库的集合,其提供丰富的数据结构 (ADT) 和中间表示层 (IR),是实现编译器的最佳框架。
4
8
5
9
LLVM 是编译器的中后端,中端负责优化,后端负责最终汇编代码的生成,他并不在乎调用他的什么高级语言,只负责把抽象的代数运算,控制流,基本块,转化为计算机硬件可以直接执行的机器码。
6
10
7
11
Clang 只是 LLVM 项目中的一个前端,其负责编译 C/C++ 这类语言,还有用于编译 Fotran 的 Flang 前端。除此之外,诸如 Rust、Swift 之类的语言,也都在使用 LLVM 做后端。
8
12
9
- 举个例子,析构函数在 ` } ` 处调用,这是 C++ 的语法规则,在 Clang 前端中处理。当 Clang 完成 C++ 语法规则,语义规则的解析后,就会调用 LLVM,创建一种叫中间表示码(IR)的东西,IR 介于高级语言和汇编语言之间。IR 是为了统一来自不同语言,去往不同的一层抽象层。一是便于前端的统一实现,Clang 这样的前端只需要生成抽象的数学运算,控制流这些 IR 预先定义好的指令就可以了,不用去专门为每个硬件设计一套生成汇编的引擎;二是 LLVM IR 采用了对优化更友好的 SSA 格式,而不是糟糕的寄存器格式,大大方便了优化,等送到后端的末尾时才会开始将 IR 翻译为汇编代码,最终变成可执行的机器码。
13
+ > {{ icon.story }} 举个例子,析构函数在 ` } ` 处调用,这是 C++ 的语法规则,在 Clang 前端中处理。当 Clang 完成 C++ 语法规则,语义规则的解析后,就会调用 LLVM,创建一种叫中间表示码(IR)的东西,IR 介于高级语言和汇编语言之间。IR 是为了统一来自不同语言,去往不同的一层抽象层。一是便于前端的统一实现,Clang 这样的前端只需要生成抽象的数学运算,控制流这些 IR 预先定义好的指令就可以了,不用去专门为每个硬件设计一套生成汇编的引擎;二是 LLVM IR 采用了对优化更友好的 SSA 格式,而不是糟糕的寄存器格式,大大方便了优化,等送到后端的末尾时才会开始将 IR 翻译为汇编代码,最终变成可执行的机器码。
14
+
15
+ 如果没有 IR 会怎样?假设有 $M$ 种语言,$N$ 种硬件,就需要重复实现 $M \times N$ 个编译器!而 IR 作为中间表示层,令语言和硬件的具体细节解耦了,从而只需要写 $M + N$ 份代码就可以:语言的开发者只需要考虑语法如何变成数学运算和控制流,硬件厂商只需要考虑如何把数学和跳转指令变成自己特定的机器码。因此,不论是 LLVM/Clang 还是 GCC 家族,跨平台编译器内部都无一例外采用了 IR 做中间表示。
10
16
11
- > {{ icon.tip }} 如果没有 IR 会怎样?假设有 $M$ 种语言,$N$ 种硬件,就需要重复实现 $M \times N$ 个编译器!而 IR 作为中间表示层,令语言和硬件的具体细节解耦了,从而只需要写 $M + N$ 份代码就可以:语言的开发者只需要考虑语法如何变成数学运算和控制流,硬件厂商只需要考虑如何把数学和跳转指令变成自己特定的机器码。因此,不论是 LLVM/Clang 还是 GCC 家族,跨平台编译器内部都无一例外采用了 IR 做中间表示 。
17
+ > {{ icon.story }} 有了统一的抽象 IR 以后,不管你是 C++ 析构函数还是 C 语言普通函数,进了 IR 以后都是一样的函数调用,减轻了编译器中后端开发者的心智负担。要开发一种新语言,只管解析完语法生成 IR 输入 LLVM,他会替你包办好优化和汇编的事 。
12
18
13
19
### 参考资料
14
20
@@ -63,7 +69,7 @@ LLVM(和 Clang)的构建依赖项几乎没有,只需要安装了编译器
63
69
64
70
首先安装 Git、CMake、Ninja、GCC(或 Clang)。
65
71
66
- 其中 Ninja 可以不安装,只是因为 Ninja 构建速度比 Make 快,特别是当文件非常多,而你改动非常少时。所以大型项目,通常会尽量给 CMake 指定 ` -G Ninja ` 选项,让其使用 Ninja 后端构建。
72
+ > {{ icon.tip }} 其中 Ninja 可以不安装,只是因为 Ninja 构建速度比 Make 快,特别是当文件非常多,而你改动非常少时。而且 Ninja 默认就开启多核并行构建,所以大型项目通常会尽量给 CMake 指定 ` -G Ninja ` 选项,让其使用更高效的 Ninja 后端构建。
67
73
68
74
Arch Linux:
69
75
@@ -148,15 +154,19 @@ cd llvm-project
148
154
bash build.sh
149
155
```
150
156
151
- 注意, ` build.sh ` 的内容等价于 :
157
+ ` build.sh ` 脚本的内容等价于 :
152
158
153
159
``` bash
154
160
cmake -Sllvm -Bbuild -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS=" clang;clang-tools-extra" -GNinja
155
161
ninja -Cbuild
156
162
```
157
163
164
+ > {{ icon.tip }} 你在命令行手动输入这两条命令也是等价的,` build.sh ` 只是为了方便。
165
+
158
166
此处 ` -S llvm ` 选项表示指定源码路径为根目录下的 ` llvm ` 子项目文件夹,和 ` cd llvm && cmake -B build ` 等价,但是不用切换目录。
159
167
168
+ ` -G Ninja ` 表示使用 Ninja 后端,如果你没有 Ninja,可以去掉该选项,CMake 将会采用默认的 Makefile 后端(更慢)。
169
+
160
170
> {{ icon.fun }} 如果你是 Wendous 受害者,请自行用鼠标点击序列在 VS2022 中模拟以上代码之同等效果,祝您腱鞘愉快!
161
171
162
172
` -DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra" ` 表示启用 ` clang ` 和 ` clang-tools-extra ` 两个子项目。
@@ -165,19 +175,113 @@ ninja -Cbuild
165
175
166
176
> {{ icon.fun }} 如果你口味比较重,想研究 Fortran 前端,也可以定义该 CMake 变量为 ` -DLLVM_ENABLE_PROJECTS="flang" ` 。
167
177
178
+ ` build.sh ` 后,需要花费大约 10 分钟时间(取决于你的电脑配置),这段时间你可以先看下面的基本概念速览。等风扇停了以后,LLVM 和 Clang 就构建好了。
179
+
180
+ ### 运行试试
181
+
182
+ ``` bash
183
+ ls build/bin
184
+ ```
185
+
168
186
## 基本概念速览
169
187
188
+ 学过 C 语言的同学都知道,一个 C/C++ 源码文件到计算机实际可执行的 EXE 文件之间,主要有两步操作:编译(compile)和链接(link)。
189
+
190
+ ![ ] ( img/compile-3-stage.png )
191
+
192
+ 之所以把编译和链接分开,是因为一个项目常常由许多源码文件组成,而不只是单个文件。编译器把 C++ 源码编译成中间对象文件(.o 或 .obj 格式),如果有很多 .cpp 文件,就会得到很多 .o 文件,然后由链接器负责统一链接所有 .o 文件,就得到了最终的 .exe 或 .dll 目标文件。
193
+
194
+ 分离多 .cpp 文件的好处是,编译速度更快,可以并行编译。而且修改了其中一个 .cpp 文件,只需要重新编译那个 .cpp 对应的 .o 文件,然后重新链接最终的 .exe 即可,无需再重复编译其他 .cpp 文件的 .o 文件了。自动检测哪些 .cpp 文件更新了,需要重新编译 .o 文件,是 Makefile 和 Ninja 之类构建系统的职责。
195
+
196
+ 我们现在要来学习的就是其中的编译阶段,这也是大部分人关注的重点。
197
+
198
+ 编译器是如何将 .cpp 文件转换为充斥着机器指令码 .o 文件的?
199
+
200
+ > {{ icon.detail }} .o 文件里几乎全是完成的机器指令码,除了部分 call 到外部函数的一部分指令,会留白,等待链接阶段时,由链接器在其他 .o 文件中找到相同的符号时替换上正确的地址和偏移量。
201
+
202
+ 过去,我们把编译器看作黑箱,进去源码,出来机器码,只能认为是魔法。
203
+
204
+ 现在,有了 LLVM 和 Clang 源码在手,终于可以一探究竟了。
205
+
206
+ 实际上,编译这一过程,还可以进一步拆分成三个阶段。
207
+
170
208
### 编译器的前、中、后端
171
209
210
+ 编译器(Compiler)的工作流程可以分为三个阶段:
211
+
212
+ 1 . 前端(Front-end):负责接收源代码,解析出抽象语法树(AST),并进行语法和语义分析,生成中间表示码(IR)。
213
+ 2 . 中端(Middle-end):负责优化中间表示码。
214
+ 3 . 后端(Back-end):负责将优化完毕的中间表示码翻译成机器码。
215
+
216
+ > {{ icon.tip }} 链接阶段(Link)属于链接器,不算编译器的工作职责;前、中、后端只是对编译(Compile)这一阶段的进一步拆分。
217
+
218
+ - 如果你想要研究 C++ 语法规则,比如做个 C++ 语法高亮插件,那就需要看前端。libclang 和 clangd 可以帮助你解析 C++ 繁琐的语法,并以 AST 树的结构提供给你。不仅如此,如果你要设计一门新语言,甚至是 OpenGL 驱动(其需要实现 GLSL 编译器),实际上也就是为 LLVM 添加一个前端。
219
+ - 如果你对内存模型,性能优化感兴趣,那就去研究中端。这是目前学术研究比较活跃的领域,特别是多面体优化方向,可以尝试水两张 paper 或 PR。这部分都是基于 LLVM IR 操作的,有特别多的算法和数据结构。
220
+ - 如果你对汇编语言,机器指令,硬件架构感兴趣,那就去看后端。这里面有把中间表示码翻译成真正可执行的汇编指令的完整过程,自研芯片的大厂通常想要为 LLVM 添加后端。
221
+
222
+ 接下来,让我们走进 LLVM 这座开源工厂,一步步观察一段 C++ 代码被编译成汇编的全过程。
223
+
172
224
#### 语法树(AST)
173
225
226
+ 编译器的前端负责解析源代码,生成抽象语法树(Abstract Syntax Tree,AST)。AST 是源代码的一种抽象表示,其中每个节点代表源代码中的一个语法结构,例如 if、while、for、函数调用、运算符、变量声明等。每个 AST 节点都有自己的属性,例如类型、作用域、修饰符等。
227
+
228
+ 这部分在 clang 项目中。
229
+
230
+ clang 解析源码生成语法树的案例:
231
+
232
+ ``` cpp
233
+ #include < cstdio>
234
+
235
+ int main () {
236
+ printf ("Hello, world!");
237
+ return 0;
238
+ }
239
+ ```
240
+
241
+ 运行命令:
242
+
243
+ ``` bash
244
+ clang -fsyntax-only -Xclang -ast-dump test.cpp
245
+ ```
246
+
247
+ - ` -fsyntax-only ` 意味着只解析语法,不进行编译和链接;
248
+ - ` -Xclang ` 是指向 Clang 核心传递一个选项,也就是后面紧挨着的 ` -ast-dump ` ;
249
+ - ` -ast-dump ` 是 Clang 核心的选项,表示要求打印出语法树。
250
+
251
+ 输出:
252
+ ![ ] ( img/clang-ast-example.png )
253
+ > {{ icon.tip }} 已省略 ` <cstdio> ` 头文件部分的语法树节点,仅展示了 main 的部分,否则就太长了。
254
+
255
+ + 此处 FunctionDecl 就表明,该节点是一个函数(Function)的声明(Decleration)。注意到后面跟着许多和该函数定义有关的关键信息,让我们逐一分析:
256
+ - 这里的十六进制数 ` 0x567bdbf246d8 ` 是 AST 节点在编译器内存中的地址,每次都不一样,无意义。
257
+ - 后面的尖括号 ` <a.cpp:3:1> ` 里还好心提醒了函数定义的位置。
258
+ - 最后是函数名 ` main ` 和函数类型 ` int () ` 。
259
+
260
+ > {{ icon.story }} 该节点的类型是 FunctionDecl,翻译成中文就是函数声明。但是我们写的明明是一个函数的** 定义** 啊!为什么被 Clang AST 当作了** 声明** 呢?原来,C++ 官方的话语中,定义也是声明!但声明不都是定义。所以这里的 FunctionDecl 实际上是一个通用的节点,既可以是声明(后面直接接 ` ; ` 的),也可以是定义(后面接着 ` {} ` 的),要根据是否有子节点(花括号语句块)来判断。
261
+
262
+ > {{ icon.detail }} 总之,定义和声明是子集关系。当我们要强调一个声明只是声明,没有定义时,会用** 非定义声明** 这样严谨的律师说法。但日常提问时你说“声明”我也明白,你指的应该是非定义声明。更多相关概念请看[ 重新认识声明与定义] ( symbols.md ) 章节和[ 白律师的锐评] ( https://github.com/parallel101/cppguidebook/pull/23 ) ,用文氏图来画就是:![ ] ( img/decl-vs-def.png ) {width=150px}
263
+
264
+ + 函数定义节点又具有一个子节点,类型是 CompoundStmt。这个实际上就是我们所说的花括号语句块 ` {} ` 了。他本身也是一条语句,但里面由很多条子语句组成。规定函数声明 FunctionDecl 如果是定义,则其唯一子节点必须是语句块类型 CompoundStmt,也就是我们熟悉的函数声明后紧接着花括号,就能定义函数。如果是非定义声明(仅声明,不定义)那就没有这个子节点。
265
+
174
266
#### 中间表示码(IR)
175
267
268
+ ##### 轶事:LLVM IR 为什么不跨平台
269
+
270
+ Clang 编译时是什么平台就是什么平台了,不同目标平台的 IR 会有些微的不一样(但 IR 类型都是固定的那几个,除了部分特殊硬件 intrinsics),IR 永远只能变成指定目标平台的机器码。
271
+
272
+ 虽然 IR 是通用的中间表示层,但类型大小,矢量宽度等信息和硬件高度绑定,而且有时用户需要根据 ` #ifdef __x86_64__ ` 判断,针对不同的硬件,使用不同的 intrinsics。而 intrinsics 产生的 IR 节点是和硬件高度相关的,无法在其他平台通用。
273
+
274
+ 总之,因为这样那样的原因,LLVM IR 并不支持跨平台共用,不同平台上 Clang 编译出来的 IR 是不同的。
275
+
276
+ > {{ icon.story }} 也有一些支持跨平台的 IR,比如 SPIR-V 和 MLIR,适用于游戏客户端部署的场景。但显然 LLVM 作为追求极致优化的裸硬件编译器,其 LLVM IR 如果要求跨平台会很不利于 Clang 前端支持硬件 intrinsics,也不利于 LLVM 中端针对目标硬件特性做优化,也会无法支持内联汇编,所以就放弃了。所以现实中,人们会先把 Vulkan 着色器编译成跨平台的 SPIR-V 二进制发布,等部署到游戏玩家电脑上后,然后再输入显卡驱动中的 LLVM 得到 LLVM IR 后优化,编译生成最适合当前玩家显卡体质的 GPU 汇编。
277
+
176
278
#### IR 的二进制压缩版:字节码
177
279
178
280
字节码和 IR 的关系,正如汇编语言和机器二进制码的关系,之间是一一对应的翻译关系。只不过字节码是压缩的,对计算机友好;而 IR 是人类可读的 ASCII 字符,方便人类阅读和调试。
179
281
180
- > {{ icon.tip }} 注意字节码和机器码不同,他依然属于中间表示(只不过是压缩得人类看不懂的高效二进制版 IR),并不能直接在计算机中执行。字节码只能在 lli 虚拟机中解释执行;但和 Java 的字节码又不一样,LLVM 的字节码本来就是二进制的 IR,IR 并不跨平台,clang 编译时是什么平台就是什么平台了,未来永远只能变成这种平台的机器码;LLVM 团队提供 lli 工具主要是为了方便临时测试 IR,用于生产环境的肯定还是 llc 编译好产生真正的高效机器码。
282
+ > {{ icon.tip }} 注意字节码和机器码不同,他依然属于中间表示(只不过是压缩得人类看不懂的高效二进制版 IR),并不能直接在计算机中执行,LLVM 字节码只能在 lli 虚拟机中解释执行。
283
+
284
+ > {{ icon.story }} 但和 Java 的字节码又不一样,LLVM 的字节码本来就是二进制的 IR。而 IR 并不跨平台,所以字节码也不跨平台。LLVM 团队提供 lli 工具主要是为了方便临时测试 IR,用于生产环境的肯定还是 llc 编译好产生真正的高效机器码。
181
285
182
286
#### 汇编语言(ASM)
183
287
0 commit comments