知识全景图/ 软件工程与系统/ 编译器与运行时底座/ 如何设计编译器

如何设计编译器

从 LedgerRule 规则语言出发,把源码、Token、AST、类型检查、IR、优化、代码生成和错误诊断逐步设计出来

阅读定位: 这页不追求立刻写出 LLVM 或 JVM 级别的工业编译器,而是用一个小型规则语言做训练。我们先让 LedgerRule 能被机器读懂、检查、优化并执行,再把这条链路回收到通用编译器设计。

一句话原则: 编译器不是“把代码翻译成机器码”这么窄,它的核心是把人写的文本逐步变成可信的结构、可验证的语义、可执行的计划和可理解的错误提示。
一、先决定:这个编译器要解决什么问题
问题如果没有编译器会怎样编译器要承担什么
财务人员写了一条规则系统只能把它当普通字符串保存把文本解析成结构化规则
规则里写错字段名上线后执行到那条规则才发现编译期发现 unknown field,并给出建议
金额字段被当字符串比较结果可能错得很隐蔽类型检查阻止不合法操作
规则要在后端、批处理、审计里复用每个系统各写一套解释逻辑输出统一 IR 或字节码,让不同运行端共享语义
规则越来越多执行慢、错误难定位、版本难审计优化、诊断、源位置映射和编译产物版本管理
二、贯穿案例:LedgerRule 编译器
源文本 Token AST 符号表 类型检查 IR 优化 代码生成 诊断
rule meal_limit: when category == "meal" and amount > 200 then require_approval("manager") rule taxi_auto_tag: when merchant contains "Taxi" or category == "transport" then tag("transport")
输入 / 输出具体内容验收问题
输入规则源文件、账目字段 schema、动作白名单编译器是否知道 amount 是金额、category 是字符串
输出可执行规则计划、错误列表、源位置映射、规则元数据运行时能否执行,用户能否看懂错误
边界第一版只支持条件表达式和白名单动作是否避免把规则语言做成任意脚本
三、词法分析:先把字符切成有意义的小块
源码片段Token为什么需要
rule meal_limit:RULE IDENT COLON后续 parser 不再逐字符猜意思
amount > 200IDENT GT NUMBER操作符、字段名和字面量要分清
"meal"STRING字符串里的内容不能被继续当关键字解析
and / orAND / OR逻辑操作符会影响表达式结构和优先级
常见坑: 词法分析不只是 split 空格。字符串转义、行列号、注释、中文字符、错误字符和源位置都要保留,否则后面的错误提示会很粗糙。
四、语法分析:把 Token 组织成 AST
Rule( name = "meal_limit", condition = And( Eq(Field("category"), String("meal")), Gt(Field("amount"), Number(200)) ), action = Call("require_approval", [String("manager")]) )
设计问题LedgerRule 的选择背后的编译器概念
andor 谁优先and 高于 or表达式优先级和结合性
规则边界怎么识别rule name: 开始,下一条 rule 前结束语法结构和错误恢复
括号怎么处理括号显式改变表达式结构递归下降或 Pratt parser 的典型场景
语法错了怎么办尽量恢复到下一条规则继续解析错误恢复,而不是遇错全文件失败
五、符号表:编译器必须知道名字指向什么
名字来自哪里编译器记录什么能发现什么错误
amount账目字段 schema类型 Money / Number,可比较大小字段拼写错误、类型不匹配
category账目字段 schema类型 String,可相等比较把字符串字段当数字比较
require_approval动作白名单参数签名 (role: String)参数数量或类型错误
meal_limit规则声明规则名、位置、版本重复规则名、审计定位困难
六、类型检查:提前阻止错误规则
错误规则检查逻辑好的错误提示
amount contains "20"contains 只接受 String 字段amount 是金额,不能使用 contains,可改用 amount > 20
category > 100> 只接受 Number / Money / Datecategory 是字符串,不能和数字比较大小
tag()动作签名要求 1 个 String 参数tag 缺少标签参数,例如 tag("transport")
merchant == 10左右类型不兼容merchant 是字符串,不能和数字 10 比较
七、IR:把语言细节降成更稳定的执行计划
rule meal_limit load_field category push_string "meal" eq load_field amount push_number 200 gt and if_true call require_approval "manager"
为什么不直接执行 ASTIR 带来的好处代价
AST 更接近语法,不一定适合执行IR 更接近机器或解释器的执行步骤需要维护从 AST 到 IR 的转换
不同语法可以表达同一语义统一成 IR 后,优化和运行时更简单错误映射必须保留源位置
未来可能输出 SQL、Java 或字节码IR 可以作为多个后端的共同输入IR 设计太早复杂化会拖慢第一版
八、优化:只优化明确有价值的部分
优化例子价值风险
常量折叠amount > 100 + 100 变成 amount > 200减少运行时计算金额、日期、时区等语义必须清楚
短路执行A and B 中 A 为 false 时不执行 B少访问字段,少做动作前判断条件表达式不能有副作用
重复子表达式消除同一字段读取只做一次批量执行时减少开销字段值必须在一次规则执行中稳定
规则预筛选先按 category 粗分命中候选规则规则很多时减少扫描索引条件和完整条件不能不一致
九、代码生成:编译器最终交付什么
后端选择适合什么代价LedgerRule 第一版选择
解释执行 IR规则经常变化,需要快速上线和调试性能上限较低第一版采用
生成字节码需要更高性能,但仍想保留 VM 控制需要设计 VM、指令集和调试映射第二版考虑
生成 Java 代码希望接入现有 Java 服务和类型系统编译、加载、版本隔离和安全边界复杂只作为生产化方向
生成 SQL 条件批量筛选大量历史账目动作语义难映射,数据库差异明显只允许条件部分下推
十、错误诊断:编译器好不好用,常常败在这里
差的错误好的错误需要编译器保存什么
Parse error第 2 行 amount > 后缺少数字,例如 amount > 200Token 的行列号和上下文
Unknown identifier未找到字段 merchent,你是否想写 merchant符号表和相似度建议
Type mismatchcategory 是字符串,不能和数字比较大小表达式类型和源位置
Compilation failed规则 meal_limit 的动作 require_approval 参数必须是角色名字符串规则名、动作签名、参数位置
十一、编译器工程化:不只是 pipeline
增量编译
只重新编译变更规则,避免规则文件一大就每次全量处理。
源映射
运行时报错能回到源文件、规则名、行列号,而不是只看到 IR 指令。
编译缓存
同一版本规则可以复用编译产物,减少启动和发布成本。
版本治理
规则语法、字段 schema、动作白名单和编译器版本都要可追踪。
测试夹具
每条规则能用样例账目测试命中结果、动作和错误提示。
安全边界
生成代码或执行 IR 时必须限制动作能力,不能让规则绕过权限。
十二、从 LedgerRule 回收到通用编译器
通用问题LedgerRule 里的对应物扩展到通用语言会变成什么
源码怎样变成结构Token 和 Rule AST词法分析、语法分析、AST、错误恢复
名字怎样解析字段 schema、动作白名单、规则名作用域、符号表、模块、命名空间
类型怎样约束错误Money、String、Bool、Action 签名静态类型、泛型、类型推导、借用 / 所有权
怎样生成可执行形式IR、解释器、SQL 下推字节码、机器码、JIT、AOT、链接和加载
怎样优化常量折叠、短路、规则预筛选SSA、内联、逃逸分析、向量化、寄存器分配
怎样诊断规则名、行列号、字段建议编译器诊断、LSP、调试信息、source map
十三、常见误区
误区 1: 写 parser 就等于写编译器。
Parser 只是把文本变成结构,真正让语言可靠的是语义检查、类型检查、IR、执行后端和诊断。
误区 2: 编译器一定要生成机器码。
很多编译器输出 AST、IR、字节码、SQL、JavaScript、配置计划或解释器指令,关键是输出可执行语义。
误区 3: 优化越多越好。
优化会增加复杂度和错误风险。第一版应该优先保证语义正确、错误清楚、执行稳定。
误区 4: 错误提示是最后再做的 UI 细节。
错误提示依赖源位置、符号表、类型信息和恢复策略,必须从 pipeline 一开始就设计。
误区 5: 生成代码就天然安全。
代码生成会放大权限和注入风险,必须限制动作、隔离执行、记录版本和审计。
十四、设计练习
练习要输出什么验收问题
写 LedgerRule 的 Token 表关键字、标识符、字符串、数字、操作符、行列号能否给出准确错误位置
画 AST 节点Rule、And、Or、Compare、Field、Literal、Call能否表示括号、优先级和动作调用
设计符号表字段类型、动作签名、规则名和源位置能否发现未知字段和重复规则
写 5 条类型错误字段、操作符、动作参数、左右类型、缺失值错误提示是否能让用户自己修正
设计 IRload_field、push、eq、gt、and、or、call 等指令解释器是否能按 IR 执行同样语义
选择一个后端解释执行、字节码、Java 代码或 SQL 下推选择是否匹配性能、调试、安全和规则变化频率
十五、回到主线
如何设计一门编程语言
如果你还没确定语言要表达什么、语法和语义怎么定,先回到 如何设计一门编程语言
编译器与运行时底座
如果你想继续理解 IR、优化、字节码、JIT、GC、对象布局和运行时差异,继续看 编译器与运行时底座
数据结构与算法底座
如果你要补编译器里常见的树、图、栈、哈希表、自动机和复杂度基础,继续看 数据结构与算法底座
Java 多语言对比实验
如果你正在用多语言理解 Java 的编译、运行时和类型边界,可以回到 Java 多语言对比实验
总结: 设计编译器的吸收顺序是:先明确输入和输出,再把源码切成 Token、组织成 AST、建立符号表、做类型检查、降成 IR、选择后端,并从第一天就保留源位置和错误诊断。编译器真正训练的是“把模糊文本变成可信执行计划”的能力。