Skip to content

Instantly share code, notes, and snippets.

@FrankHB
Created April 13, 2019 17:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save FrankHB/d87e8d294db57ba8b1935b9808e82131 to your computer and use it in GitHub Desktop.
Save FrankHB/d87e8d294db57ba8b1935b9808e82131 to your computer and use it in GitHub Desktop.
Compiling notes
模块化设计的工具链(toolchain) 能适配多种源语言和多种目标语言。其中把源代码翻译成目标代码的工具统称为编译器(compiler) 。
对现代的优化编译器(optimizing comipler) 来讲,基本原理是管道-过滤器(pipe-filter) 模式的代码变换(code transformation) 。
典型简单情况下,从源语言的输入到目标语言的输出的流程是线性的,有相对固定的步骤。
除了输入输出,这些步骤处理的形式统称 IR (intermediate representation,中间表示)。
源语言一般是高级语言。适配源语言的编译器组件统称前端(frontend) 。前端把源语言翻译成能让编译器剩余流程接受的 HIR (high-level IR,高级 IR ) 。
适配目标语言的编译器组件统称后端(backend) 。后端接受 LIR (low-level IR),输出目标语言代码。
目标语言也可以是高级语言,不过更常见的,翻译成体系结构相关的硬件 ISA (instruction-set architecture ,指令集架构)支持的本机代码(native code) 。因为是硬件实现的, ISA 支持的二进制代码一般也叫机器码(machine code) 。
粗略地说, ISA 指定了各种不同的硬件指令集,如 IA-32(x86) 、x86-64(Intel 64/AMD64)、IA-64(Itanium) 、ARM 、MIPS 等(实际上经常得适配指令集扩展)。
编译器前端和后端之间的步骤有很大部分可以重用,不依赖前端也不依赖后端的逻辑独立出来成为单独的组件,称为中端(middle end) 。
GCC 一开始是给 C 编译器 GNU C compiler ,不过后来逐渐支持不同的语言,因此需要不同的前端,改叫 GNU Compiler Collection ,里面是前端中端后端都有。 GCC 使用的 IR 有 GENERIC 和 GIMPLE 等。GCC 4 以来使用 SSA(static single assignment) 形式的 IR 进行优化。
LLVM 则是围绕 SSA 形式的 LLVM IR 的中端和后端集合。和 GCC 不同的是它不包含前端,更模块化,并且使用宽松的许可证。原来是配合 GCC 的 C 和 C++ 前端用,不过后来还是因为许可证以及 ObjC 支持优先等原因另外搞了个前端 cfe ,通称 Clang 。
还有 SML/NJ 等函数式编译器,使用 CPS 或者 ANF 形式的 IR ,可以直接带有非局域的跨过程(inter-procedural) 变换。更高层次特定语义允许针对具体语言在前端进一步优化(不像 GCC/LLVM 这样基本只能依赖中端)。
实际开发使用的所谓本机语言(native language) 也就是使用这些编译器输出本机代码的实现,常见的有 C 和 C++ 等。(不过, ISO C 和 ISO C++ 实际上不限制必须要编译。)
对开发者来说,安装编译器工具链,一般只调用编译器驱动程序(compiler driver) 这个命令行工具。编译器驱动程序会分拆不同的命令调用不同的组件。(一般会调用命令行的可执行程序,如 cc1 是 C 的前端; MSVC 的话是 dll 。)有时候也调用编译器外的程序,比如 GCC LTO 会调用环境变量 MAKE 。
调用编译器完输出是目标代码文件(object file) ,通过工具链中的链接器(linker) 打包成可执行文件或者动态库。静态库用归档器(archiver) 打包,不一定进行链接。
上面这种简单方式把代码翻译和目标代码执行完全分开,称为 AOT(ahead-of-time) 编译。
与此相对, JIT(just-in-time) 编译是不和执行分离的编译。但是一般也不只是时刻在编译,因为通常没有必要。
因为 IR 设计等原因,适应 AOT 的工具链通常不能很好地适应 JIT 的需求。(所以 LLVM 想吞并 libjit 没成功……)不过也不是说不能提供 JIT 。
也有把通常 AOT 编译的语言实现成 REPL(read-evla-print loop) 的解释(interpreted) 型的,例如 LLVM 上的 Cling 可以直接把 C++ 当脚本语言用。
对 Java 这样的语言,编译的实现相对复杂。
和 C/C++ 不同, JLS(Java Language Specification) 要求 Java 是编译到 JVMS(Java Virtual Machine Specification) 规定的字节码(bytecode) 的。(虽然当年 Dalvik 不符合 JVMS ,不过也是编译到字节码。)
所以 Java 是确凿的编译型语言(反而 ISO C/ISO C++ 不是),只不过明确要求的不是一般的本机代码而已。(实话说,以 JVM 字节码作为 ISA 设计的硬件已经过气好多年了……)
统一的字节码规范也给可移植性提供了更强的保证,因为发布的 Java 程序中也是字节码的形式。这种设计也被 .NET 等使用。
这些字节码再被语言的运行时(runtime) 执行(对 Java 通常就是虚拟机,对 .NET 语言来讲是 CLR(common language runtime) )。
应该注意,所谓的 Java 的解释执行,在虚拟机中被解释的源语言的是字节码,并不是 Java 语言的源代码。( Java 后来有 REPL 那是另一回事。)
解释执行通常被认为慢,是因为有解释开销(interpretation overhead) 。执行时会重复判断在 AOT 编译时能被一次性确定而优化掉的内容,因此普遍慢。不过这不是绝对的,和语言的设计、编译器以及运行时的实现质量以及程序的写法都有关系。
而使用字节码代替源代码的解释已经省掉了源程序语法分析(parse) 的一部分开销,相对直接跑脚本会慢)。
为了进一步消除字节码的解释开销提升性能,对虚拟机中执行的字节码可以进行 JIT 编译成特定体系结构的机器码。
考虑到 JIT 的编译本身也要消耗时间和空间,所以什么时候进行 JIT 的时机是需要选择的;不当的 JIT 可能会使程序性能下降。一般运行时的设计策略是对频繁使用的热点代码进行 JIT 。
以上是比较常见的套路策略。实际还有更普遍的黑科技:元编译(meta-compiling) 。
这允许写一个解释器,然后通过特化(specialization) 优化成对应的编译器。
这在最近才有比较实用的例子:
使用部分求值(partial evaluation) 实现第一层二村射影(Futamura projection) 特化 Trufulle 编写的解释器的 Graal JIT ;
使用 RPython 特化 Python 子集编写的解释器实现基于元跟踪(meta-tracing) JIT ,如 PyPy (普遍比 CPython 快)。
这类科技大概可以减少死磕编译器后端的加班人士的工时,不过要推广大概还需要不少时间。
@o2njxa05jsa
Copy link

然而别人在贴吧发贴只是想体验装逼的快感,你写这么长别人大概看都不会看。

@hilbert-yaa
Copy link

有人看的(认真)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment