Skip to content

Commit 90f8cc0

Browse files
committed
upd llvm intro
1 parent b9e406a commit 90f8cc0

5 files changed

+170
-7
lines changed

docs/img/clang-ast-example.png

127 KB
Loading

docs/img/compile-3-stage.png

62.9 KB
Loading

docs/img/decl-vs-def.png

11.8 KB
Loading

docs/llvm_intro.md

+109-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
# 小彭老师带你学 LLVM
22

3+
[TOC]
4+
5+
## LLVM 介绍
6+
37
LLVM 是一个跨平台的编译器基础设施,它不是一个单一的编译器,而是一系列工具和库的集合,其提供丰富的数据结构 (ADT) 和中间表示层 (IR),是实现编译器的最佳框架。
48

59
LLVM 是编译器的中后端,中端负责优化,后端负责最终汇编代码的生成,他并不在乎调用他的什么高级语言,只负责把抽象的代数运算,控制流,基本块,转化为计算机硬件可以直接执行的机器码。
610

711
Clang 只是 LLVM 项目中的一个前端,其负责编译 C/C++ 这类语言,还有用于编译 Fotran 的 Flang 前端。除此之外,诸如 Rust、Swift 之类的语言,也都在使用 LLVM 做后端。
812

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 做中间表示。
1016

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,他会替你包办好优化和汇编的事
1218
1319
### 参考资料
1420

@@ -63,7 +69,7 @@ LLVM(和 Clang)的构建依赖项几乎没有,只需要安装了编译器
6369

6470
首先安装 Git、CMake、Ninja、GCC(或 Clang)。
6571

66-
其中 Ninja 可以不安装,只是因为 Ninja 构建速度比 Make 快,特别是当文件非常多,而你改动非常少时。所以大型项目,通常会尽量给 CMake 指定 `-G Ninja` 选项,让其使用 Ninja 后端构建。
72+
> {{ icon.tip }} 其中 Ninja 可以不安装,只是因为 Ninja 构建速度比 Make 快,特别是当文件非常多,而你改动非常少时。而且 Ninja 默认就开启多核并行构建,所以大型项目通常会尽量给 CMake 指定 `-G Ninja` 选项,让其使用更高效的 Ninja 后端构建。
6773
6874
Arch Linux:
6975

@@ -148,15 +154,19 @@ cd llvm-project
148154
bash build.sh
149155
```
150156

151-
注意,`build.sh` 的内容等价于
157+
`build.sh` 脚本的内容等价于
152158

153159
```bash
154160
cmake -Sllvm -Bbuild -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra" -GNinja
155161
ninja -Cbuild
156162
```
157163

164+
> {{ icon.tip }} 你在命令行手动输入这两条命令也是等价的,`build.sh` 只是为了方便。
165+
158166
此处 `-S llvm` 选项表示指定源码路径为根目录下的 `llvm` 子项目文件夹,和 `cd llvm && cmake -B build` 等价,但是不用切换目录。
159167

168+
`-G Ninja` 表示使用 Ninja 后端,如果你没有 Ninja,可以去掉该选项,CMake 将会采用默认的 Makefile 后端(更慢)。
169+
160170
> {{ icon.fun }} 如果你是 Wendous 受害者,请自行用鼠标点击序列在 VS2022 中模拟以上代码之同等效果,祝您腱鞘愉快!
161171
162172
`-DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra"` 表示启用 `clang``clang-tools-extra` 两个子项目。
@@ -165,19 +175,113 @@ ninja -Cbuild
165175

166176
> {{ icon.fun }} 如果你口味比较重,想研究 Fortran 前端,也可以定义该 CMake 变量为 `-DLLVM_ENABLE_PROJECTS="flang"`
167177
178+
`build.sh` 后,需要花费大约 10 分钟时间(取决于你的电脑配置),这段时间你可以先看下面的基本概念速览。等风扇停了以后,LLVM 和 Clang 就构建好了。
179+
180+
### 运行试试
181+
182+
```bash
183+
ls build/bin
184+
```
185+
168186
## 基本概念速览
169187

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+
170208
### 编译器的前、中、后端
171209

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+
172224
#### 语法树(AST)
173225

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+
174266
#### 中间表示码(IR)
175267

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+
176278
#### IR 的二进制压缩版:字节码
177279

178280
字节码和 IR 的关系,正如汇编语言和机器二进制码的关系,之间是一一对应的翻译关系。只不过字节码是压缩的,对计算机友好;而 IR 是人类可读的 ASCII 字符,方便人类阅读和调试。
179281

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 编译好产生真正的高效机器码。
181285
182286
#### 汇编语言(ASM)
183287

docs/symbols.md

+61-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
# 重新认识声明与定义
1+
# 重新认识声明与定义(未完工)
2+
3+
[TOC]
4+
5+
## 我们要牢记白指导说的道理
26

37
> {{ icon.fun }} mq 白在[川上](https://github.com/parallel101/cppguidebook/pull/23)曰:
48
>
5-
> 非定义声明,因为 Game 在此处为不完整类型
9+
> > 非定义声明,因为 Game 在此处为不完整类型
610
>
711
> 我能明白其意思,定义一定是声明,声明却不一定是定义。所以用了:“非定义声明”这个词语,很专业的措辞。
812
>
@@ -11,3 +15,58 @@
1115
> 在他们眼里声明和定义是两种东西,此处如果直接用声明它们可能就不会有理解问题了。例如:“只是声明,不是定义”之类的措辞。
1216
>
1317
> 或许我们应该考虑在保证专业以及严谨的情况下,稍微补充解释一下“非定义声明”这个用词。
18+
19+
## 多文件编译的必要性
20+
21+
## 翻译单元 (TU)
22+
23+
## 符号的链接类型 (linkage)
24+
25+
函数和变量,在对外的可见性这方面,有以下几种类型:
26+
27+
- 外部链接 (ODR external linkage):对其他翻译单元可见
28+
- 共享链接 (non-ODR external linkage)
29+
- 内部链接 (internal linkage)
30+
- 无链接 (no linkage)
31+
32+
函数和变量的可见性这一属性,被 C++ 官方称为链接(linkage),是因为符号的可见性处理通常是链接器(ld)负责的,不同类型链接(linkage)的效果,在链接(link)的时候才会生效。
33+
34+
定义在全局(名字空间)中的情况:
35+
36+
```cpp
37+
int i; // 变量声明并定义为“外部链接”
38+
int f(int x); // 函数声明为“外部链接”
39+
int f(int x) {} // 函数声明并定义为“外部链接”
40+
41+
extern int i; // 变量声明为“外部链接”
42+
extern int f(int x); // 函数声明为“外部链接”
43+
extern int f(int x) {} // 函数声明并定义为“外部链接”
44+
45+
inline int i; // 变量声明并定义为“共享链接”
46+
inline int f(int x); // 函数声明为“共享链接”
47+
inline int f(int x) {} // 函数声明并定义为“共享链接”
48+
49+
static int i; // 变量声明并定义为“内部链接”
50+
static int f(int x); // 函数声明为“内部链接”
51+
static int f(int x) {} // 函数声明并定义为“内部链接”
52+
```
53+
54+
定义在类(class)中的情况:
55+
56+
```cpp
57+
struct Class {
58+
59+
int i; // 变量声明并定义为“无链接”
60+
int f(int x); // 函数声明为“外部链接”
61+
int f(int x) {} // 函数声明并定义为“共享链接”
62+
63+
inline static int i; // 变量声明并定义为“共享链接”
64+
inline static int f(int x); // 函数声明为“共享链接”
65+
inline static int f(int x) {} // 函数声明并定义为“共享链接”
66+
67+
static int i; // 变量声明并定义为“外部链接”
68+
static int f(int x); // 函数声明为“外部链接”
69+
static int f(int x) {} // 函数声明并定义为“外部链接”
70+
71+
};
72+
```

0 commit comments

Comments
 (0)