CNSH编译器 v1.0(Python版)是我最近抽了两个星期的晚上时间写完的一个小项目。它不算大,但完整走通了“源码 -> 词法分析 -> 语法分析 -> AST -> 字节码 -> 栈式虚拟机”这条编译链路,最终能运行一门自己定义的迷你语言。我把这门语言取名为 CNSH,内部代号叫“紧凑脚本”,不打算解释全称,纯粹是写到一半懒得改名的产物。文章很长,但不是一个教科书式的项目复盘,而是我踩过的坑、试过的办法,以及一些“早知道会节省不少时间”的经验总结。如果你正打算用 Python 写一个自己的DSL、脚本语言、或者干脆就是想理解编译器到底怎么工作,这篇应该对你有用。
1. 起因:我只是想给配置文件来点逻辑
1.1 一套配置需求把我推到编译器边缘
事情从一个很普通的诉求开始:我当时在维护一个小的批处理工具,它需要根据不同的输入文件跑不同的处理分支,条件组合还不少。一开始我用纯 Python 写规则,每加一个分支就改一次代码,改多了人就容易烦躁。后来换成 JSON 配置,条件是能表达一部分了,但只要出现循环、递归、或者稍微复杂的计算,JSON 就开始别扭——它只能描述数据,不能描述行为。那段时间我甚至想过直接让用户填 Python 表达式,又担心写错一行就把整个任务干掉,没有任何隔离边界。
后来我意识到,我要的其实不是一个功能完整的通用语言,而是一套“看起来像脚本、执行起来足够解释得清错误”的小型 DSL,专门用来描述规则和轻量逻辑。于是就有了 CNSH v1.0 的立项理由:用 Python 实现一个极简编译器,把自定义源码编译成字节码,再用自带虚拟机执行。这样规则被单独隔离在 CNSH 文件里,主程序只需要调用一次执行入口,错误信息也能限定在规则语言自身范围内。
1.2 v1.0画下的边界:不做通用语言,只做一个可读可写的迷你脚本
很多人一听“写编译器”就想到 C、Rust、指令集、优化器,但我的目标非常明确:不做优化,不做复杂类型系统,不做运行时的垃圾回收。CNSH v1.0 的全部代码(包括词法分析、语法分析、编译和虚拟机)总共只有一千多行 Python,它更像一个“带自定义语法的解释器骨架”,而不是生产级编译器。我把它定位成一种配置增强语言,能够写变量、算术运算、条件、循环、函数递归和打印输出,这就够了。
我给自己画了很严格的范围,把凡是可能无限膨胀的特性都拦在 v1.0 之外:
- 支持整数/浮点数、字符串、字符串插值,但不支持 list 和 dict 字面量。
- 支持自定义函数和递归调用,但不支持嵌套函数。
- 支持全局变量和函数局部变量,但不支持闭包捕获。
- 支持
if/else、while、for循环,不支持switch、异常处理。 - 源码以换行或分号作为语句分隔,块结构用
end关键字结束,不依赖缩进。
这样做的直接好处是词法分析和语法分析都能保持简单。如果一开始就把闭包、类型推断、异常处理全部塞进去,我大概要写三个月才能出一个能演示的版本,而不是两周。第一版本可以“语言丑一点”,但整套链路必须完整、可运行、可以加功能。这也是给所有想写语言的人一个建议:先做最小闭环,再做语法糖。
2. 词法分析:别急着上正则大法,先把手写扫描器说清楚
2.1 token的类型与源码规范
CNSH 的词法分析器(Lexer)把字符串源码拆成一串 token。每个 token 有类型、值、行号、列号。行号和列号不是为了好看,而是后面所有报错信息的地基。我见过不少初学者写 Lexer 时只用正则表达式从上到下匹配,匹配完了才发现自己不知道某个错误发生在哪一行,只能干瞪眼。所以我在设计第一个版本时,就把 line 和 col 作为 Token 的必填字段。
CNSH v1.0 支持的 token 类型大致如下:
| token 类型 | 示例 |
|---|---|
| 数字 | 123、3.14 |
| 字符串 | "hello" |
| 标识符 | x、factorial、echo |
| 关键字 | let、if、else、end、func、return、while、for、echo、to |
| 运算符 | +、-、*、/、<、<=、>、>=、==、!= |
| 符号 | (、)、=、,、; |
| EOF | 源码结束标记 |
这里稍微提一个反直觉的设计:我没有把echo当函数,而是直接设计成关键字,这样实现打印语句时语法分析器更省事,对普通用户也更直观。类似的,let被当作变量声明的关键字,CNSH 不搞隐式变量声明,所有变量必须先用let声明,这样符号表管理更干净,也不会发生“手滑拼错变量名却还能悄悄运行”的尴尬。
词法分析时,换行符本身不会被丢进 token 流,但每个语句的结束由换行或者分号决定。这里有个实现细节:源码里经常有连续多个空行,如果每个换行都产生法一个 NL token,语法分析器会很啰嗦。我的做法是只在遇到一个非空语句的结尾时才让 parser 认为语句结束了,相当于在 parser 内部忽略多余空行,只把 token 的行号列号保留下来作为报错位置。语言规范里我规定一行写多条语句时用分号分隔。
2.2 Lexer核心实现与常见细节
我写 Lexer 时没有使用一堆长正则来匹配整个语言,而是采用逐字符扫描加小状态判断。原因很简单:手写扫描器能精确控制每个 token 的边界,也能在遇到不可识别字符时马上抛出带行列号的错误。正则大法确实可以在一行里把整数、浮点数全配出来,但出了问题很难调试,性能也未必好。
核心结构长这样:
from dataclasses import dataclass @dataclass class Token: kind: str value: object line: int col: int class Lexer: def __init__(self, text: str): self.text = text self.pos = 0 self.line = 1 self.col = 1 def peek(self): if self.pos < len(self.text): return self.text[self.pos] return "" def advance(self): ch = self.text[self.pos] self.pos += 1 if ch == "\n": self.line += 1 self.col = 1 else: self.col += 1 return ch每个 token 的解析函数都比较短,比如数字读取时,我会同时记录小数点和整数部分,保证123变成整数,3.14变成浮点数。字符串读取则要处理转义字符\n、\t、\",否则用户写个带引号的字符串都费劲。标识符读取会顺手查关键字表,命中就返回关键字 token,否则返回标识符。这段代码初看很机械,但它是整个编译器最常用的部分,写完以后反而没什么 bug。真正需要花心思的是错误处理:遇到@、#这类不支持的字符时,我不会悄悄跳过,而是抛LexError(f"无法识别的字符 '{ch}' at line {line}, col {col}")。早期版本就是因为在 Lexer 阶段太宽容,导致后面 parser 报了很奇怪的错,查了半天才发现是一个非法字符混了进来。
2.3 报错体验:行列号比什么都值钱
我项目里定义了一个基础异常类:
class CNSHError(Exception): def __init__(self, msg, line=None, col=None): self.msg = msg self.line = line self.col = col super().__init__(msg) class LexError(CNSHError): pass class ParseError(CNSHError): pass class CompileError(CNSHError): pass class VMError(CNSHError): pass包括后面的语法分析、编译和虚拟机执行,所有错误都往这个基类上靠。实话说,最初写 Lexer 时我只报“无法识别字符”,没有带行列号,结果有一次拿到一个几千行配置,错误在中部,我只能靠二分法手动删内容定位,非常痛苦。后来我把行列号贯穿所有报错,调试效率一下子上来了。CNSH 目前的报错格式是line 5, col 12: 说明,这已经够在任意编辑器里快速定位了。如果你的 DSL 打算给别人用,建议从一开始就把错误信息和位置当核心功能,而不是等写完再补。
3. 递归下降Parser:为什么我用裸写而不是依赖依赖库
3.1 AST节点与语句定义
语法分析阶段要面对的经典选择是:用 Pyparsing、LARK 之类的生成器,还是直接手写递归下降。我选了后者,理由有两层:一是零依赖,这个项目我希望任何环境下拿个 Python 解释器就能跑,不想为了一个小语言引入一堆第三方包;二是想保持每个报错信息都对用户友好,手写解析器可以精确控制“什么时候报错、报什么内容”。
CNSH 的 AST 节点我用dataclass定义,每个节点代表一个语法结构。例如数字、二元运算、赋值语句、条件语句、函数定义:
from dataclasses import dataclass from typing import List, Optional @dataclass class Program: stmts: List[object] @dataclass class Number: value: float @dataclass class VarRef: name: str @dataclass class BinOp: op: str left: object right: object @dataclass class Assign: name: str value: object @dataclass class IfStmt: cond: object then_body: List[object] else_body: List[object] @dataclass class WhileStmt: cond: object body: List[object] @dataclass class ForStmt: var: str start: object end: object body: List[object] @dataclass class FuncDef: name: str params: List[str] body: List[object] @dataclass class CallExpr: func_name: str args: List[object] @dataclass class ReturnStmt: value: Optional[object] @dataclass class EchoStmt: value: objectAST 设计直接影响后面所有逻辑。我的原则是“节点尽可能贴近源码结构”,不要过早把它拍平成某种中间表示。比如ForStmt在 AST 里保留变量名、起始值、结束值,等到字节码生成阶段再去展开成条件跳转。这样方便我以后调试,也方便做源码位置回显。
3.2 优先级爬升:一个函数解决二元运算
手写表达式解析时,最土的办法是把每个优先级写一层函数:
parse_expr -> parse_add_sub -> parse_mul_div -> parse_primary这样没有错,但层数多了以后代码非常重复,加一个新的运算符得改好几处。CNSH v1.0 用了一个叫“优先级爬升”的写法,把所有二元运算符的优先级放在一张表里,用一个循环处理:
PRECEDENCE = { "or": 1, "and": 2, "<": 3, "<=": 3, ">": 3, ">=": 3, "==": 3, "!=": 3, "+": 4, "-": 4, "*": 5, "/": 5, } def parse_expr(self, min_prec=0): left = self.parse_primary() while True: if self.check_token("NL") or self.check_token("EOF"): break op = self.peek_value() if op not in PRECEDENCE: break prec = PRECEDENCE[op] if prec < min_prec: break self.advance() right = self.parse_expr(prec + 1 if op in ("*", "/", "==", "!=", "+", "-") else prec) left = BinOp(op, left, right) return left这段代码有一个细节:左结合运算符在递归右操作数时传入的优先级会加一,因此1 - 2 - 3会被解析成(1 - 2) - 3,而不是1 - (2 - 3)。二元比较符号我允许连续出现,但实际规约时还是按左结合处理,v1.0 里不强制禁止a < b < c,只是结果可能不符合直觉。如果要做得更严谨,应该单独设计链式比较,但这不在第一版范围内。
3.3 错误恢复:宁可错行,不可崩盘
递归下降最烦的问题是一旦遇到语法错误,解析器可能一口气吐出几百个错误,把用户淹没。更糟糕的是某些错误会导致无限循环。我设计了一套简单的“同步恢复”机制:当 parse 一个语句失败时,不是立刻抛错退出整个解析流程,而是记录第一个错误的行列号,然后不断丢弃 token,直到遇到一行新的起始 token 再继续尝试。由于 CNSH 语句以换行或分号结束,这在普通脚本语言里相对好处理。
我实现的原则是“一次只报一个致命错误”,语法错误信息格式为line x, col y: 预期是 'end',但实际是 'xxx'。如果恢复后继续报错,也不会产生太离谱的海量错误。这个设计在 v1.0.1 的试用反馈里被点名表扬过,因为很多用户第一次写脚本时容易丢end,如果错误能准确告诉他们是行 8 缺了end,他们很快就改对了。可见错误恢复不是锦上添花,而是一个 DSL 能否被正常使用的门槛。
4. AST到字节码:从遍历树到生成指令
4.1 作用域解析和符号表
AST 构建好以后,下一步是遍历它并生成字节码。这一步里最基础也最重要的数据结构是符号表。CNSH 没有把变量语法树里直接压栈,因为如果不先完成“声明 vs 使用”的检查,后续生成的指令很可能访问到不存在的变量。
我设计了一套带父指针的作用域结构:
class SymbolTable: def __init__(self, parent=None): self.vars = set() self.parent = parent def declare(self, name): self.vars.add(name) def is_local(self, name): if name in self.vars: return True if self.parent: return self.parent.is_local(name) return False在编译器遍历时,全局作用域在最外层,函数定义会创建新的子作用域。所有变量在编译阶段就会被标成“局部”或“全局”。我不做复杂的静态类型检查,也不区分整数和浮点数,真正的类型错误留到虚拟机运行时判断。有人可能会觉得这样太不“编译器”了,但对于一个配置型语言来说,“编译期运行期分离”并不重要,重要的是代码能快速跑起来,并且错误信息能告诉你哪儿写错了。
4.2 指令集设计:20条指令足以撑起一个前端
CNSH v1.0 使用栈式虚拟机,所以指令集非常小,主要分几组:
- 常量/变量加载:
PUSH、LOAD_VAR、STORE_VAR - 运算类:
ADD、SUB、MUL、DIV、LT、LE、GT、GE、EQ、NE - 控制流:
JUMP、JUMP_IF_FALSE - 函数:
CALL、MAKE_FUNCTION、RETURN - 输出:
ECHO
整组指令大约二十条。有些人可能觉得“这也太少了?”,但实际跑下来:条件分支、循环、递归、打印全都覆盖了。我特意把比较运算单独拆成不同的指令(LT、LE等),而不是像某些教程那样统一用一个COMPARE加参数,这样虚拟机的执行主循环可以写得更直观,每个算数操作符对应的处理函数一目了然。
实现时,码流我直接用(opname, arg)元组数组来表示。它虽然没有真正把操作码编码成二进制,但逻辑上已经是编译器后端了。如果你想把它变成真正的字节码文件,只需要给每个 opname 分配一个整数编号,再把 arg 序列化即可,这不是关键难点。
4.3 一个阶乘示例的编译产物
为了让上面的设计落地,我用阶乘函数作为贯穿示例:
func factorial(n) if n <= 1 return 1 end return n * factorial(n - 1) end let result = factorial(6) echo result这段代码经过词法、语法、语义分析后会生成类似下面的中间指令(我在这里加了缩进方便观察):
MAKE_FUNCTION ['n'] LOAD_VAR n PUSH 1 LE JUMP_IF_FALSE false_label PUSH 1 RETURN false_label: LOAD_VAR n LOAD_VAR factorial LOAD_VAR n PUSH 1 SUB CALL 1 MUL RETURN STORE_VAR factorial PUSH factorial PUSH 6 CALL 1 STORE_VAR result ECHO result这段指令序列有两点值得说明。第一,函数体在编译时已经生成了独立的指令片断,MAKE_FUNCTION指令把参数名列表和指令片断打包成一个函数对象,压入栈顶;STORE_VAR factorial再把函数对象绑定到全局变量。第二,递归调用其实只是普通的LOAD_VAR+CALL,不需要特殊指令,因为当前函数已经作为全局变量登记过了。符号表在这里的功劳是确保递归时n能正确判定为局部变量,不会和全局的n混淆。
从 AST 生成代码时,最难的不是单个语句,而是跳转目标的管理。我的编译器里维护了一个patch list,遇到JUMP_IF_FALSE时先留一个占位,后续把真正 label 的位置填充进去。这个写法和汇编器开发里的重定位很像。刚开始我嫌麻烦,想直接用字符串标签跳转,后来发现 Python 的标识符名称很容易冲突,还是数值地址更可靠。
5. 虚拟机的正确打开方式:调试追踪帮我找回了一个Bugs
5.1 执行循环与函数调用帧
编译器做完后,虚拟机反而是最让我头痛的一部分。它的核心是一个执行循环,按顺序读取指令并修改栈和程序计数器。这个循环本身不难,真正难的是函数调用语义。如果只是简单地把所有变量放到同一个字典里,函数内部和外部就会互相污染。
CNSH v1.0 的虚拟机使用了 Frame(调用帧)概念,每次函数调用都会创建新的局部作用域:
class Frame: def __init__(self, code, locals_map, globals_map): self.code = code self.pc = 0 self.locals = locals_map self.globals = globals_map self.stack = [] class VM: def __init__(self): self.frames = [] self.globals = {} self.funcs = {} def call(self, func_obj, args): locals_map = dict(zip(func_obj.params, args)) frame = Frame(func_obj.code, locals_map, self.globals) self.frames.append(frame) while frame.pc < len(frame.code): op, arg = frame.code[frame.pc] frame.pc += 1 self.execute(op, arg, frame) self.frames.pop()这里每个函数调用都会有一份独立的局部变量字典,参数通过zip和 key 列表生成。栈式逻辑让递归成为可能:factorial(5)会先压入factorial(4)的调用帧,再压factorial(3),这些帧互不干扰。注意我在子函数里读取全局变量时,是回到 Frame 里存的 globals 引用去查找,而不是沿父作用域链查找。这个取舍让 CNSH 的闭包语义非常简单,代价是不支持嵌套函数,但从 v1.0 的定位看可以接受。
5.2 踩坑:函数参数被全局变量串掉
我在第一版虚拟机里犯过一个典型错误:创建 Frame 时,局部变量表直接用了全局字典的引用,而不是新建一个空字典。这样每个函数的第一件事就是把参数塞进全局命名空间,整个程序的变量互相覆盖。当时我很难找到问题,因为简单的单次函数调用能正常跑,第二次调用参数变了,结果还是第一次的值。
定位这个 bug 花了我大概一个晚上。现象是:
call calc(2) -> 4 call calc(3) -> 4无论传什么参数,结果都保持不变。我一开始怀疑是 CALL 指令的参数数量传错了,反复打印参数列表,结果发现参数在调用侧是对的,但函数体里读到的值始终是第一次的值。直到我检查 VM 的全局字典,才发现里面被塞了n、result等一大堆函数局部变量。根因就是我在调用时把参数直接写进了全局字典,没有按帧隔离。
修复办法就是我上面写的locals_map独立创建,每个 Frame 持有自己的局部字典。这个教训让我真正理解了一句话:虚拟机最重要的不是能在纸上画出漂亮的栈模型,而是编码器里必须严格区分“当前执行环境”。如果没有调试追踪,这种跨函数共享状态的问题不会自己暴露,因为它往往是“偶发”的。
5.3 追踪输出和定位方法
调试这种 VM 问题最有效的手段是打印逐指令执行过程。我写了如下追踪逻辑:
def execute(self, op, arg, frame): if self.trace: print(f"[trace] pc={frame.pc} op={op} arg={arg!r} stack={frame.stack} locals={frame.locals}") # execute...当trace=True时,程序会把每一步的指令、当前栈内容、当前局部变量全打出来。运行一段简单代码:
let x = 2 echo x + 3拿到的关键输出类似:
[trace] op=STORE_VAR arg=x stack=[2] locals={} [trace] op=LOAD_VAR arg=x stack=[2] locals={x: 2} [trace] op=PUSH arg=3 stack=[2, 3] locals={x: 2} [trace] op=ADD arg=None stack=[5] locals={x: 2} [trace] op=ECHO arg=x stack=[5] locals={x: 2}如果栈顶出现一个不是期望类型的值、或者局部变量表里冒出一个不该存在的名字,几乎一眼就能看出来。这个功能对我来说比单元测试还管用,因为它能直观展示“语义错误发生的位置”。我建议任何写解释器或虚拟机的人都把 trace 开关内置进去,它不复杂,但能替你省下大量口算栈的精力。
6. 实测体会:它能用在哪,不能用在哪
6.1 拿它替换了一打if-else配置文件后
CNSH v1.0 真正实际使用是在一个批处理规则场景:我原来的 Python 程序里有几组规则,分别判断输入文件的行数、大小、后缀,再决定走哪个处理分支。这些规则用 if-else 写在主程序里,烦人是每次更改都要重启主进程。后来我把它们剥离到 CNSH 脚本里,主程序只提供一个执行入口,传入一个全局参数对象,CNSH 脚本读取参数后输出处理建议。简单示例:
let score = get_score() if score >= 90 echo "high" else if score >= 60 echo "mid" else echo "low" end其中get_score在主程序里注册成内置函数。这个方案运行得很顺利,修改规则不再需要碰主程序代码,也让非开发人员能读懂规则到底写了什么。不过 CNSH 目前的内置函数扩展机制非常原始,只是把 Python 函数按名字塞进全局字典,运行前也没有参数类型检查,所以真正面向外部用户前还需要加强 API 稳定性。
6.2 性能量级与已知缺陷
性能方面,我做过粗略测试:解析并编译一个 100 行的 CNSH 脚本一般在毫秒级,执行纯循环加计算一百万次操作大概几十毫秒到几百毫秒。这个量级在配置和规则场景里完全够用,你写一个 DSL 本来就不该去碰图像处理、大规模数值计算这类任务。
CNSH v1.0 也确实有很明显的问题:字符串没有内置拼接函数,只靠手动+运算符,但不同类型不能混加,这导致想输出“score=90”这类信息非常麻烦;函数定义时不检查return是否在所有分支都出现,缺return的函数会隐式返回空值,空值参与运算会直接报错,错误定位又不那么直观。这些问题我认为不值得在 v1.0 里硬修,因为一改“类型系统”味道就会变,宁可留到 v1.1 认真设计。
6.3 v1.1之前我想先把错误信息做好
回看这个项目,我最想优化的不是性能,也不是语法糖,而是错误提示。目前虚拟机运行时报错虽然带行列号,但缺少完整的调用栈,比如函数递归中途失败,用户很难看出是哪一层调用导致的问题。如果下一步要给别人用,我计划在 VM 执行遇到异常时打印当前函数调用链,也就是 frame 列表里每个函数名和调用位置。词法分析阶段也打算支持//行注释和/* */块注释,因为配置文件里写几行注释太常见了,现在的# 行注释有时候不够用。
还有一个小细节是内置input()交互和文件加载之间的边界没有做清楚。把 CNSH 当嵌入语言使用时,不应该允许脚本随意执行文件读取或其他危险操作。v1.1 我会加一个“沙箱开关”,默认拒绝对主机文件系统的访问,只允许通过宿主程序注册的安全函数与外部交互。安全边界早设计远比事后打补丁好。
写完这个编译器,我最深的体会是:造一门语言没那么玄,但想造一门“用起来顺手”的语言,难度全在细节里。忠于一个很小的语言核心,先让变量、分支、循环、函数递归这四样东西完整跑通,再考虑别的,这是整个项目能坚持收尾的关键。下一步我大概率会在错误提示、字符串处理和沙箱上继续打磨,让它成为真正能交付给同事使用的内部小工具,而不是只躺在仓库里自嗨的玩具。