编译原理
这篇解决什么问题
当你按下「运行」按钮时,源代码并不会被 CPU 直接理解。CPU 只执行机器指令,编程语言需要经过编译器、解释器、虚拟机或 JIT 引擎,才能变成可执行的指令或运行时动作。
编译原理要回答的是:源代码如何从一段文本,变成结构化语法树、中间表示、优化后的代码,最后变成机器码或字节码。
理解编译原理,不是为了每个人都去写编译器,而是为了看懂这些日常问题:
- 为什么有些报错发生在编译期,有些发生在运行时。
- 为什么 TypeScript 能提前发现类型问题。
- 为什么 Babel、SWC、ESLint、Prettier 都离不开 AST。
- 为什么 Vite / Webpack 构建失败时要看转换链路。
- 为什么 JavaScript 明明是动态语言,V8 仍然能跑得很快。
学完你会掌握
- 编译器的六步流水线:词法、语法、语义、IR、优化、代码生成。
- Token、AST、IR 分别是什么,解决什么问题。
- 类型检查、作用域检查、常量折叠、死代码消除等机制如何工作。
- 编译型、解释型、JIT 三种执行模型的差异。
- 编译原理如何落到前端工程、后端构建、代码审查和 AI Coding 中。
总览:代码的翻译之旅
编译器可以理解成一个分阶段工作的翻译系统:
源代码字符流
-> 词法分析 Lexical Analysis
-> Token 流
-> 语法分析 Syntax Analysis
-> AST 抽象语法树
-> 语义分析 Semantic Analysis
-> 带类型和作用域信息的 AST
-> 中间代码生成 IR Generation
-> IR 中间表示
-> 代码优化 Optimization
-> 优化后的 IR
-> 目标代码生成 Code Generation
-> 机器码 / 字节码 / 目标平台代码
这条流水线的关键思想是:每一步都把信息变得更结构化、更接近执行。
| 阶段 | 输入 | 输出 | 解决的问题 |
|---|---|---|---|
| 词法分析 | 字符流 | Token 流 | 识别单词 |
| 语法分析 | Token 流 | AST | 识别结构和优先级 |
| 语义分析 | AST | 带语义信息的 AST | 检查含义是否合法 |
| IR 生成 | AST | 中间表示 | 脱离具体源码语法 |
| 优化 | IR | 优化后的 IR | 减少无效计算和冗余代码 |
| 代码生成 | IR | 机器码或字节码 | 面向目标平台执行 |
词法分析:把字符流切成 Token
词法分析器从左到右扫描源码,把连续字符组合成有意义的词法单元,也就是 Token。
例如:
int age = 25 + 1;
会被拆成:
[int] [age] [=] [25] [+] [1] [;]
| | | | | | |
关键字 标识符 运算符 数字 运算符 数字 分隔符
常见 Token 类型:
| Token 类型 | 示例 | 含义 |
|---|---|---|
| 关键字 | if、for、int、return |
语言保留字 |
| 标识符 | age、userName |
变量、函数、类等名字 |
| 字面量 | 123、"hello"、true |
直接写在代码里的值 |
| 运算符 | +、==、&&、= |
表达计算或赋值关系 |
| 分隔符 | ;、,、(、)、{、} |
分隔语句和结构 |
| 注释 / 空白 | // comment、空格、换行 |
通常会被过滤或保留为调试信息 |
词法分析阶段能发现的错误通常是「词不合法」,例如字符串没有闭合、数字格式错误、出现语言不认识的字符。
语法分析:把 Token 组织成 AST
Token 只是孤立的单词。语法分析要根据语言文法,把 Token 组织成抽象语法树(AST, Abstract Syntax Tree)。
例如:
const result = 1 + 2 * 3;
不是简单从左到右算成 (1 + 2) * 3,而是要识别乘法优先级更高:
VariableDeclaration
name: result
value:
BinaryExpression(+)
left: NumericLiteral(1)
right:
BinaryExpression(*)
left: NumericLiteral(2)
right: NumericLiteral(3)
AST 省略了很多源码表面的细节,例如空格、换行、部分括号,但保留了代码结构。它是后续分析和转换的核心数据结构。
常见语法结构和 AST 节点:
| 代码结构 | AST 节点思路 |
|---|---|
a + b |
二元表达式 BinaryExpression |
foo() |
函数调用 CallExpression |
if (...) {} |
条件语句 IfStatement |
const x = 1 |
变量声明 VariableDeclaration |
import a from 'x' |
模块导入 ImportDeclaration |
语法分析阶段能发现的是「句子结构不合法」,例如括号不匹配、if 后没有条件、表达式缺少操作数。
AST 为什么是现代工具链的核心
你可能不写编译器,但每天都在使用基于 AST 的工具:
- ESLint:解析 AST,检查潜在问题和团队规则。
- Prettier:解析 AST,再按统一规则重新生成代码。
- Babel / SWC:解析 AST,转换语法,再生成兼容代码。
- TypeScript:在 AST 上做类型检查和声明分析。
- IDE 重构:基于 AST 安全重命名变量、提取函数、查找引用。
- Tree Shaking:分析
import/export,删除未使用代码。
这也是为什么复杂代码改造不要靠字符串替换。字符串替换不知道「这个 user 是变量名、字符串内容,还是注释里的单词」,AST 知道。
可以用 AST Explorer 观察真实代码的 AST。看几次之后你会发现:代码不是一行行文本,而是一棵有层级、有类型、有作用域的树。
语义分析:结构正确不等于含义正确
语法分析只保证「句子长得像合法代码」,语义分析要检查「这段代码在语言规则下是否有意义」。
常见语义检查:
| 检查 | 示例 | 问题 |
|---|---|---|
| 类型检查 | let n: number = "hello" |
字符串不能赋给数字 |
| 作用域检查 | console.log(x),但 x 未声明 |
变量不存在 |
| 重复声明 | 同一作用域重复定义不允许的名字 | 名字冲突 |
| 可见性检查 | 访问 private 字段 | 权限不允许 |
| 返回值检查 | 函数声明返回 number,却返回 string | 返回类型不匹配 |
| 控制流检查 | 某些分支没有返回值 | 路径不完整 |
很多你熟悉的错误,本质上来自语义分析:
- TypeScript 的类型报错。
- Java 的泛型、返回值、异常签名检查。
- Rust 的所有权、借用、生命周期检查。
- Go 的未使用变量检查。
不同语言的「硬核程度」差异,很大一部分体现在语义分析能检查多少东西。
中间表示 IR:为什么不直接生成机器码
大型编译器通常不会从 AST 直接生成机器码,而是先转成中间表示(IR, Intermediate Representation)。
IR 的价值是把「前端语言」和「后端平台」解耦:
C / C++ / Rust / Swift ...
|
前端
|
IR
|
后端
|
x86 / ARM / WebAssembly ...
这样一来:
- 新语言只要能生成同一套 IR,就能复用后端优化和代码生成能力。
- 新 CPU 架构只要支持这套 IR 后端,就能运行多种语言。
- 优化器可以在统一表示上工作,不必为每门语言重写一遍。
LLVM 就是典型的编译器基础设施。Clang、Rust、Swift 等都大量受益于 LLVM 的 IR、优化器和后端。
前端工程里也有类似思想:Babel 把 JS 解析成 AST,再做插件转换,最后生成目标版本 JavaScript。虽然它不一定生成机器码,但「统一中间结构 + 多阶段转换」的思想是一致的。
代码优化:让机器少做无意义工作
优化阶段通常作用在 IR 或 AST 上,目标是保持语义不变,但让代码更快、更小或更适合目标平台。
常见优化:
| 优化技术 | 优化前 | 优化后 | 原理 |
|---|---|---|---|
| 常量折叠 | x = 10 + 5 |
x = 15 |
编译时能算的就不留到运行时 |
| 常量传播 | x = 15; y = x * 2 |
y = 30 |
已知值沿使用链传播 |
| 死代码消除 | if (false) {...} |
删除分支 | 永远不会执行的代码没必要保留 |
| 函数内联 | foo() |
展开函数体 | 减少调用开销,暴露更多优化机会 |
| 循环不变量外提 | 循环内重复计算固定值 | 移到循环外 | 减少重复计算 |
| 公共子表达式消除 | 多次计算同一表达式 | 计算一次复用 | 减少冗余计算 |
例如:
const width = 10;
const height = 20;
const area = width * height;
如果编译器能确定 width 和 height 都不会变,它就可能把 width * height 折叠为 200。
理解优化有两个实际价值:
- 写出更容易被优化的代码,比如减少动态形状变化、避免
eval、让数据结构稳定。 - 看懂性能分析结果,知道「源码看起来短」不等于「机器执行更少」。
目标代码生成:落到具体机器
代码生成阶段把优化后的 IR 转成目标平台能执行的形式。
目标可能是:
- x86 / ARM 机器码。
- JVM 字节码。
- .NET IL。
- WebAssembly。
- 旧版本 JavaScript。
- 某个虚拟机或解释器的字节码。
这里会涉及更底层的问题:
- 寄存器分配:哪些变量放寄存器,哪些溢出到栈。
- 指令选择:用目标 CPU 的哪些指令完成操作。
- 调用约定:函数参数、返回值、栈帧如何安排。
- 链接:多个目标文件、库函数和符号如何合并。
所以「编译成功」不只是语法通过,还包括生成目标平台可接受的产物。
编译型、解释型与 JIT
语言执行模型常被粗略分成三类:
| 模型 | 过程 | 优点 | 代价 | 代表 |
|---|---|---|---|---|
| 编译型 | 源码先编译成机器码,再执行 | 运行快、部署产物直接 | 编译等待、跨平台需重新构建 | C、C++、Rust、Go |
| 解释型 | 源码由解释器边读边执行 | 开发快、灵活、跨平台 | 运行时开销较大 | Python、Ruby、PHP |
| JIT | 先解释或执行字节码,热点代码即时编译 | 热点性能高,兼顾灵活性 | 需要预热,运行时复杂 | JavaScript V8、JVM、.NET |
现实语言往往不是单一模型。例如 Java 通常先编译成字节码,再由 JVM 解释或 JIT;JavaScript 在 V8 中会先解析、生成字节码,再对热点函数做优化编译。
V8 的基本思路是:
JavaScript 源码
-> Parser 生成 AST
-> Ignition 生成字节码并执行
-> 收集运行时反馈
-> TurboFan 对热点代码优化编译成机器码
-> 如果假设失效,反优化回退
这解释了为什么 JavaScript 性能有时很好,也解释了为什么某些动态写法会触发反优化。
前端工程里的编译原理
前端工具链几乎是编译原理的大型应用现场:
| 工具 | 编译原理对应环节 |
|---|---|
| TypeScript | 解析、类型检查、转译 |
| Babel | AST 转换、代码生成 |
| SWC / esbuild | 高性能解析、转换、压缩 |
| Vite / Webpack | 模块依赖分析、打包、Tree Shaking |
| Vue / Svelte | 模板编译成渲染函数或更底层代码 |
| JSX | 语法扩展转成函数调用或运行时结构 |
| Sourcemap | 把生成代码映射回源代码 |
所以构建失败时,要沿着链路问:
- 是源码语法错了?
- 是 TypeScript 类型错了?
- 是 Babel 插件顺序错了?
- 是模块解析别名错了?
- 是目标浏览器不支持生成代码?
- 是压缩或 Tree Shaking 改变了副作用?
只看最后一行错误,经常会误判。
AI Coding 时代怎么用
AI 很擅长生成代码转换、配置和脚手架,但编译链路越复杂,越不能让它自由发挥。
让 AI 修改编译相关内容时,建议要求它说明:
- 输入语言是什么,语法范围到哪里。
- 输出目标是什么:浏览器、Node、字节码、机器码还是某个框架运行时。
- 转换链路有哪些阶段,每个插件或 loader 的顺序为什么这样排。
- 类型检查和运行时校验分别在哪一层完成。
- sourcemap 是否保留,线上问题如何定位回源码。
- 优化是否可能影响副作用、动态 import、tree shaking 或 polyfill。
更好的提问方式:
先根据项目配置画出 TypeScript -> Babel/SWC -> Bundler -> Minifier 的转换链路,
再说明这个报错可能发生在哪一层,最后给出最小改动修复方案。
常见误区
误区一:认为编译只属于 C、C++、Java。现代前端、移动端、模板框架、低代码平台、AI 工具链都大量依赖编译和转换。
误区二:把 AST 当成高级黑魔法。AST 本质上只是代码结构化后的树,真正难的是文法、作用域、类型和转换正确性。
误区三:以为语法正确就代表程序正确。语法只是结构,语义分析才处理类型、作用域、返回值和权限等问题。
误区四:认为编译器优化会自动解决所有性能问题。优化器只能在它能证明安全的范围内工作,糟糕的数据结构和 I/O 设计不会凭空消失。
误区五:认为 sourcemap 可有可无。没有 sourcemap,线上压缩代码的错误定位会非常痛苦。
误区六:用字符串替换做复杂代码改造。只要涉及语法结构、作用域或 import/export,优先使用 AST 工具。
小结
编译原理的核心不是「把代码变成机器码」这么一句话,而是一条完整流水线:字符变 Token,Token 变 AST,AST 经过语义分析变成更可靠的结构,再生成 IR、优化,最后落到目标平台。
掌握这条链路后,你会更容易理解报错信息、构建工具、类型系统、性能优化、JIT 行为和前端工程化。AI 可以帮你生成配置和转换代码,但你需要能判断它改的是哪一层,以及如何验证这层真的工作。