本文将以极粗的粒度概述编译原理相关知识及其流程
约定:
- 若未专门指定, "代码" 均指由现代高级语言书写的代码
- 若未专门制定, "编译" 均指会直接产生最终目标代码的翻译方式, 即 "Compiler"
编译概述
语言分类
以下分类按抽象程度进行
- 高级语言: 大部分程序员每天接触的语言, 抽象程度最高, 大部分现代语言都几乎完全屏蔽了底层细节, 大部分高级语言都有相对高的可读性和可移植性, 如
Js,Java,C++,Rust等 - 汇编语言: 大部分程序员听说过但几乎没见过的语言, 抽象程度居中, 几乎可以直接接触到硬件底层, 如直接操作寄存器、PC 等, 大部分汇编语言都有很强的平台相关性, 移植性很差, 通常是针对不同的 CPU 设置不同的指令集, 如
ARM,Intel - 机器语言: 大部分现代程序员一辈子都不会看到的语言, 能被计算机直接识别并执行, 几乎没有可读性和可移植性, 也几乎没有抽象可言, 每一条机器指令都是对具体硬件的极为具体的操作
代码怎么运作起来的
代码是任务执行流程的高级抽象, 程序员通过书写代码, 来指示计算机执行自己期望执行的任务, 举个现实生活中的例子就类似我们是主人, 而计算机是仆人, 主人需要通过语言来指挥仆人执行自己的指令
而这个过程中一个很尴尬的点在于, 这个仆人往往只能听懂机器语言, 而现代主人通常都是说高级语言, 这就意味着语言不通
解决方案就是引入编译器作为翻译官, 编译器会将程序员书写的高级语言翻译为计算机能够直接执行的机器语言
关于以上这句话进行注解
编译器其实通常不会直接编译到机器语言, 而是产生对应平台的汇编, 再借助操作系统自带的汇编器进行执行 汇编器本质上也就是编译器, 能够将汇编语言编译到机器语言 此外也有其他方案, 比如编译到 C, 再借助几乎大部分系统都自带的 C 编译环境去直接编译 但是究其根本, 也还是需要编译到机器语言计算机才能够直接执行
编译器分类
以下分类粗略的按照是否直接产生编译产物为标准分类为编译器和解释器, 不考虑其他分类方式
编译器(Compiler)
此处的编译器是真·编译器, 会将输入的源代码编译后产生可以直接执行的目标代码文件, 如 gcc
解释器(Interpret)
此处的解释器通常对应脚本语言, 严谨的说解释器应该是一边解释一边运行, 逐行分块的将源代码翻译并执行, 换言之解释器是走一步算一步, 翻译一句执行一句, 不会产生额外产物
但是个人认为若是按照这个定义的话, 只有 shell 这样的纯纯脚本语言才算得上是真正的解释执行, 因为程序员们常用的脚本语言诸如 python Js 等, 都会在执行之前预先扫描一遍整体文件, 并产生一个平台无关的中间代码形式, 这个中间代码形式无法被计算机直接执行, 需要在运行时借助虚拟机来进行解释
Compiler & Interpret
此处举个例子来描述两者的区别, 假设场景为某个只会英语的人想读一篇完全由中文撰写的文章
- Compiler: 文书翻译, 翻译人员拿到中文文章, 一次性将所有内容翻译完, 产生翻译过后的产物, 即一篇由英文编写的文章
- Interpret: 实时翻译, 翻译人员先大致看一眼中文文章内容, 大致熟悉文章, 接着看一行翻译一行, 实时进行翻译工作, 不会产生英文译本
从上面例子也可以看出两者的特点, 通常情况下, 相对的:
- Compiler: 执行高效, 平台相关性强, 可移植性弱(运行时虚拟机开销小, 直接产生的机器码平台相关性太强, 不便于移植)
- Interpret: 执行低效, 平台相关性弱, 可移植性强(运行时虚拟机开销大, 可以由虚拟机来兼容不同 CPU 架构, 便于移植 )
编译流程
以下将编译流程简单截至生成目标代码为止, 省略后续链接等步骤
预处理
- 输入: 字符流(源文件)
- 输出: 字符流
预处理进行的只是简单的文本替换工作, 会进行一些简单工作, 诸如宏展开, 注释删除等等
词法分析
- 输入: 字符流
- 输出: Token 流
词法分析阶段需要从源文件字符流中识别出一个一个的 Token 并组成 Token 流供下一阶段使用, Token 指的是在语法中不可进一步分割的语法单元, 具体说明就是若是再分割就会丧失语义
如 Hello World 分割为 H,e,l,l,o,(空格) 等字符, 每个单元都丧失了语义, 无法理解是什么意思, 因此应该将每个单词作为一个 Token, 分割为 Hello,(空格),World 三个 Token, 这样就可以得知语义是 你好 世界
语法分析
- 输入: Token 流
- 输出: IR
语法分析阶段会尝试将 Token 流处理为能够表示语义的某种中间结果, 作为后续处理的基础, 在这个过程中会进行语法检查, 若是不符合语法要求的非法输入, 则会直接报错
这里的 IR 是"中间表示" Intermediate Representation 的缩写, 不过语法分析的输出通常都是 AST(Abstract Syntax Tree) 抽象语法树
例如, 表达式 1 + 2 + 3, 其 AST 如下
值得一提的是, 貌似很多语言的一些有意思的 feature 都是在这一步完成后进行的
比如 Rust 的生命周期检查和所有权检查, 还有大部分语言的静态类型检查
因为这一步通常解析出 AST, 会保留最多的语义信息, 到后面进行优化时, 若转化成某些特殊的 IR 可能会造成语义的丧失
代码优化
- 输入: IR
- 输出: IR
这一阶段会进行大量大量的代码优化工作, 也是目前学术界主要研究方向之一, 毕竟这是个无底洞, 几乎可以无止尽的优化下去, 这一部分可能会产生大量不同种类的 IR, 可能会针对某种特定的优化手段产生 IR
举例如
- 常量折叠
- 循环优化
- 尾递归优化
这里是天坑, 目前本人了解较少, 且项目不包含这一步骤, 因此就简单略过
目标代码生成
- 输入: IR
- 输出: 字符流(编译最终产物)
这一阶段的目标就是产生目标代码, 根据不同的需求, 这里产生的所谓"目标代码"存在很大区别
比如这个专栏要实现的 tiny-expr-parser 最终目标代码是格式化过后的表达式字符串, 而像 gcc 在编译阶段产生汇编代码, 在汇编阶段产生二进制机器代码
在这个阶段通常会进行一些平台相关性很强的优化工作, 比如寄存器分配优化, 流水线控制等等, 这里也是目前的主要研究方向之一, 水很深
这里常见的 IR 有经典的三地址编码TAC(Three Address Code) 和 LLVM IR
编译应用
编译作为高抽象->低抽象的垂直电梯, 配合各种强大的 IDE , 工作过程一般是无感的, 用计算机领域的词汇来形容的话就是"对程序员透明", 但实际上编译原理的应用不仅在于"实现一个编译器", 在日常生产的各种地方都可以发现他的应用
- 代码分析工具
- 对代码进行静态检查, 能够在正式运行之前提前知晓部分问题, 比如编码格式上的问题或部分 bug
- 如
Js的ESlint,Java的FindBugs等
- 格式化工具
- 对代码进行格式化
- 如
Prettier
- DSL(Domain Specific Language)
- 为某些领域专门实现的语言
- 如
SQL,Regex,JSX等