☰
自制编程语言:四代解释器源码带你穿透词法、语法到运行时
2026/10/17 19:58:32 网站建设 项目流程

简介:《自制编程语言》相关资料以单个PDF文件形式提供,面向编译技术入门者、语言设计师及计算机专业学生,聚焦从零设计并实现一门可运行编程语言的完整路径。PDF系统阐述了语言设计的基本原则与实现要点,涵盖语法语义、可读性、可维护性,并结合Crowbar、Diksam两个自制语言项目,完整展示解析器、编译器、解释器的开发流程。针对工具链,资料详细说明了MinGW、bison/flex、Cygwin的安装与集成用法,兼顾Linux与Windows双平台,且内容预览中出现calc、mycalc、llparser等阶梯式练习模块,读者可跟随示例从简单计算器一步步推进到完整语言实现。整个资源仅包含1个PDF文件,体积2.38MB,便于离线下载与移动阅读;已有870人浏览学习,对想亲手打造编程语言的开发者具有较强参考价值,也可作为编译原理课程的辅助材料。

1. 自制编程语言:为什么四代解释器源码比「啃概念」更能建立底层认知

写编程语言这事儿,最反直觉的部分是:语法反而是最好解决的。真正把大多数开发者挡在门外的,是「从源码到能跑」这条链路上十几个环环相扣的决策点——token 怎么切、优先级怎么消解、AST 怎么建、字符串谁分配谁释放、GC 什么时候介入。这份资料把一条完整路径摊开了:从 yacc/lex 写计算器,到手写递归下降解析器,再到 crowbar 和 Diksam 两套解释器各四代源码,每个版本只加一个新特性,横跨多年。读它的价值不在于学会某个工具,而在于你能亲手摸到「一个语法特性从文法、AST 到运行时」是怎么穿透三层结构的。理解这条路径,才算对编程语言有了真正的底层认知。

2. 词法分析:crowbar.l 里的状态机和那条总是断掉的 y.tab.h 依赖链

2.1 flex 三段式:词法规则为什么是「顺序敏感」的程序

任何.l文件都由三段组成:定义区、规则区、用户代码区。mycalc 用 flex 生成 lex.yy.c,crowbar 则自带 crowbar.l,结构一脉相承。定义区里写%{ %}包裹的 C 代码和正则别名,规则区每一行是「正则 + 动作」,用户代码区放 main、yywrap 这类辅助函数。示意如下:

%{ #include "y.tab.h" #include <stdio.h> %} DIGIT [0-9] %% {DIGIT}+ { yylval.int_value = atoi(yytext); return INT_LITERAL; } "+" { return PLUS; } "*" { return MULTIPLY; } [ \t\n]+ { /* 跳过空白 */ } %% int yywrap(void) { return 1; }

flex 的匹配规则有两条硬约束:最长匹配优先,同长度时取最先出现的规则。这两条决定了规则区里几乎每一行的顺序都不能乱。保留字必须写在标识符规则前面:关键字和普通变量同样匹配[a-zA-Z_][a-zA-Z0-9_]*时,只有把if、while这类规则放在前面,lexer 才会在长度相等时选中它们。token 通过return交给 bison 生成的 yyparse,语义值放在 yylval 里,具体类型由%union决定。还有一个容易被忽略的细节:yytext 指向的内部缓冲区在下一次匹配后就会失效,需要长期保存的字符串必须立刻拷贝出自己的一份内存,后面 string_pool.c 里大量字符串拷贝代码的根源就在这里。

2.2 从正则到状态机:%start 让词法器拥有了「当前在哪段代码里」的记忆

正则表达式有个天然天花板:它描述不了跨行字符串,也描述不了需要配对的注释定界符。crowbar.l 里把注释和字符串字面量各自声明成独立词法状态,含义等价于下面的写法:

%x COMMENT %x STRING_LITERAL_STATE %% \" { BEGIN STRING_LITERAL_STATE; } <STRING_LITERAL_STATE>\" { BEGIN INITIAL; return STRING_LITERAL; } <STRING_LITERAL_STATE>. { /* 累积字符串内容 */ } "/*" { BEGIN COMMENT; } <COMMENT>"*/" { BEGIN INITIAL; } <COMMENT>. { /* 跳过注释体 */ }

BEGIN宏在匹配动作里切换当前状态,只有带<状态>前缀的规则在该状态下生效。没有这套机制,「字符串里的双引号该不该结束字符串」「注释里的*/是除号还是结束符」这类问题只能靠穷举转义形式硬撑,而且一旦遇到多行注释就彻底无解。状态把词法规则从一维的「正则集合」升级成二维的「正则 × 当前上下文」查找。代价是每个状态都要自检会不会漏规则:漏掉一条,lexer 就会把本应吞掉的字符吐回给语法分析器,报错位置离真正的问题十万八千里。

我一般会这么理解:start condition 是解释器第一次意识到「上下文很重要」的地方,之后的语法分析、符号表、类型检查全是同一个认知在不同层面的延伸。这也是为什么注释和字符串字面量会被单独拆成状态——它们是语言里最早出现的「不按行规整」的词法区域,任何一个自制语言的词法器都会在这里撞墙一次。

2.3 依赖链断点:y.tab.h 不存在,为什么像玄学

编译 mycalc 或 crowbar 时最常见的报错是y.tab.h file not found,或者 gcc 直接说y.tab.c: No such file or directory。第一反应往往是「头文件路径配错了」,但真正的原因是一条构建依赖链断了:flex 生成的 lex.yy.c 第一行就#include "y.tab.h",而 y.tab.h 是 bison 根据.y语法文件生成的 token 枚举。先有鸡还是先有蛋在这里答案是确定的——必须先跑 bison,再跑 flex,最后才轮到 gcc。这条链必须写进 Makefile:

y.tab.c y.tab.h: crowbar.y bison --yacc -dv crowbar.y lex.yy.c: crowbar.l y.tab.h flex crowbar.l crowbar: lex.yy.c y.tab.c main.c interface.c execute.c eval.c gcc -o crowbar lex.yy.c y.tab.c main.c interface.c execute.c eval.c

--yacc让 bison 沿用 yacc 时代的命名习惯输出 y.tab.c,-d负责生成配套的 y.tab.h,-v额外产出.output状态机描述文件。任何一次.y改动都会让 y.tab.h 时间戳更新,连带触发.l重新编译;反之,手工编译时跳过 bison 直接编 lex.yy.c,就会看到那两行经典报错。这条依赖链是这套资料里出现频率最高的「玄学」故障点,玄在报错信息完全不说「你忘了跑 bison」,只说「文件不存在」。

注意:flex/bison 生成的文件名是固定的 y.tab.c、lex.yy.c。不要手动改名,Makefile 里依赖的就是这些默认名。

3. 语法分析:LALR(1) 与手写递归下降,两种路线各自要付什么代价

3.1 双轨设计的动机:资源为什么同时放 yacc 版和手写版

这套资料里有一个刻意的安排:mycalc 是 yacc/lex 的「工具生成」示范,llparser 和 llparser_ex 是「手写递归下降」示范,而 crowbar 用 bison 实现。作者把两条路线都摆在读者面前,本质上是在回答一个问题:写语法分析器,到底该不该用生成器?

工具生成路线把「文法 → 分析表」这件最机械的事交给 bison,你用 BNF 描述语法,它给你一个能跑的表驱动解析器,表达式密集的语法用它非常省事。手写递归下降则把「下一个 token 应该是什么」直接写进代码里,每个非终结符对应一个函数,优点是报错位置精确、可以随时插入自定义语义动作,缺点是优先级和结合性要从代码结构里手工体现,稍不留神就写出左递归死循环。两种路线产出同样的 AST,但心智负担完全不同。

3.2 优先级声明:为什么 4+5*2 不会算成 18

经典的四则运算文法天然是歧义的:4+5*2可以解释成(4+5)*2,也可以解释成4+(5*2)。mycalc 的.y文件里处理这个问题只用了几行声明:

%token <int_value> INT_LITERAL %left ADD SUB %left MUL DIV PERCENT %% expression : expression ADD expression | expression MUL expression | '(' expression ')' | INT_LITERAL ;

关键就在%left。它告诉 bison:ADD/SUB 这一组优先级低于 MUL/DIV;当分析表里出现 shift/reduce 冲突时,比较当前 token 和栈顶规则符号的优先级,决定「是继续移进下一个运算符,还是先把已经读到的表达式归约掉」。因为 MUL 高一级,4+5*2读到*时会选择移进而不是归约加法,乘法在语法树里就沉到了更深的层级。

没有这些声明时会怎样?bison 面对同样的冲突会按默认策略选择 shift,所有运算符变成平级左结合,4+5*2会被算成18。这种 bug 不报错,只给错结果,比缺 y.tab.h 隐蔽得多。写 yacc 语法时「优先级声明缺失」是最常见的静默错误,验证方法很简单:拿一个混合优先级表达式跑一遍,看运行结果对不对。

3.3 手写版翻车点:llparser_ex 的缓冲区错误说明了什么

手写递归下降的难点不在递归函数本身,而在输入管理。资源里记录了一个非常典型的已知 BUG:

static char *st_line; ... while (fgets(st_line, LINE_BUF_SIZE, stdin) != NULL) { parse_line(); }

st_line 声明成了指针,却没有分配任何内存,fgets 直接往一个未知地址写入——运气好是段错误,运气不好是悄悄踩坏堆上的其他数据,跑到好几轮之后才炸。正确写法是char buf[LINE_BUF_SIZE]这样的栈数组,或者用 malloc 之后记得 free。

这个 BUG 值得单独立一条:写手写解析器时,大家把精力都放在「下降函数怎么写、左递归怎么消除」,实际最容易翻车的是「读入一行的缓冲区以及它的生命周期」。递归下降解析器每个函数拿到的 token 都来自同一个词法接口,词法接口的缓冲区一旦是野指针,后面的语法树组装得再漂亮也白搭。除此之外,还有一类状态复位问题:注释或字符串状态机遇到文件末尾没有关闭时,会把后续所有代码吞掉;错误恢复机制(error token)经常被跳过。这些边界条件才是手写路线的真实成本。

4. 解释执行:从 AST 到输出,crowbar 与 Diksam 的模块边界

4.1 树遍历解释器:create、execute、eval 的分工

从构建日志里的编译命令能看到一整套模块名:create.c、execute.c、eval.c、string.c、string_pool.c、native.c、error.c、error_message.c,还有个独立的 memory 子目录。文件名本身就是架构图:create.c 提供 AST 节点构造器,供 bison 的动作代码调用;execute.c 负责遍历语句节点、处理流程控制;eval.c 对表达式树递归求值;native.c 注册内建函数;error.c 和 error_message.c 统一错误消息;string_pool.c 做字符串驻留。AST 节点的常见实现思路是类型 tag 加联合体:

typedef enum { INT_EXPRESSION, ADD_EXPRESSION, SUB_EXPRESSION, ASSIGN_EXPRESSION } ExprKind; typedef struct Expression { ExprKind type; int int_value; struct Expression *left; struct Expression *right; } Expression;

每个节点一个类型 tag,遍历时 switch(type) 分发;二叉树结构让每个运算符天然是「左操作数 右操作数」的递归求值。bison 的动作里写$$ = create_expression(ADD_EXPRESSION, $1, $3);,就是把归约结果组装成树的关键一步——这是教科书和源码里最常见的实现方式,create.c 的名字就是从这来的。execute.c 按语句类型逐条执行,eval.c 则一路递归算到叶子节点。树遍历解释器的性能瓶颈也在这里:每个运算符至少两三次函数调用,解释器的大部分时间耗在函数调用开销而不是真正的计算上。这个代价 Diksam 之后用另一种架构换掉了。

4.2 版本演进:GC、数组、对象是怎么逼解释器长大的

crowbar 和 Diksam 各自从 0.1 走到 0.4,版本号递进里藏着语言设计者的工作方式,下表依据资源内构建说明与目录命名推断:

版本新特性主要波及模块
crowbar 0.1基础语法与执行create/execute/eval 雏形
crowbar 0.2垃圾回收(GC)memory 子目录、string_pool.c
crowbar 0.3数组AST 新节点、eval 下标分支
crowbar 0.4对象与正则支持native.c 绑定 oniguruma
Diksam 0.1静态变量符号表、声明语义
Diksam 0.3+继承方法查找与运行时类型

0.2 的 GC 来临时,string_pool.c 的字符串驻留就变得关键了:驻留让「两个字符串是否相等」从逐字节比较退化成指针比较,也让 GC 标记阶段能统一遍历所有字符串根。0.3 的数组让 eval 必须支持可变长容器和下标越界检查,0.4 的对象则要求成员访问机制。每个特性都不是局部补丁,它顺着「语法 → AST → 求值」一路改下去——改.y加产生式,改 create.c 加节点类型,改 eval.c 加求值分支。把相邻两个版本的源码摊开 diff,就是一次完整的语言演化现场。

4.3 Diksam 的定位:从脚本解释器到更「正式」的语言

Diksam 引入静态变量后,「变量」不再只是栈帧上的局部量,需要一个独立于调用栈的静态存储区,以及管理它的符号表。继承则让方法查找从「查当前类」变成「沿继承链向上查」。这两点都指向同一个方向:运行时数据结构从「遍历树时的临时状态」变成「有明确作用域和查找链的结构」。

对于这类语言,常见的实现思路是先编译成中间表示,再交给虚拟机执行,换取更好的执行效率和静态检查能力。资源把 Diksam 与 crowbar 并列各放四代,正是为了展示「解释器」和「编译器」之间的过渡带。crowbar 是解剖学标本,结构清晰但性能朴素;Diksam 试着把标本做成能上手术台的活体,代价是架构复杂度全面提升。读 Diksam 0.1 到 0.4 的 diff,能看到一个语言如何一步步告别「玩具」定位。

5. 构建与排查:MinGW、Cygwin、bison 的经典翻车现场

四套语言源码的配套构建记录,几乎就是一份 Windows/Linux 双平台工具链避坑合集。所有问题的共同规律:故障不出在语言代码本身,而在「工具链边界」——路径、换行符、Makefile 语法、库链接。

5.1 路径空格:bison 报 m4: cannot open `Files'

现象:Windows 下运行 make,报错m4: cannot open 'Files': No such file or directory,紧接着还有一串(x86)\GnuWin32\Bison/share/bison/m4sugar/m4sugar.m4无法打开,构建停在「生成 y.tab.h」这一步。

原因:bison 生成分析表时要调用 m4 展开 skeleton 文件。如果 bison 安装在C:\Program Files (x86)\GnuWin32,带空格的路径在参数传递中被从空格处切断,m4 拿到的是被截断的路径。这是 GnuWin32 版 bison 的已知问题,和语言源码无关。

解决:把 bison/flex 装到无空格路径,比如C:\GnuWin32;或者改用 Cygwin 内置的 bison,Cygwin 的 POSIX 层按/usr/bin风格处理路径,天然避开空格问题。装完后先bison --version确认路径里没有空格。

注意:Makefile 里手工写死 bison 的绝对路径时,同样要避开带空格的目录。

5.2 生成顺序:为什么 bison 没跑,y.tab.h 就一定缺失

现象:gcc 报y.tab.c: No such file or directory,或者mycalc.l:3:19: fatal error: y.tab.h file not found。更隐蔽的版本是 make 报了 bison 执行失败,但整个构建还在继续往下走。

原因:flex 生成的 lex.yy.c 里#include "y.tab.h",而 y.tab.h 必须由 bison 先生成。手工编译时只跑了 flex 或只跑了 gcc、跳过了 bison,依赖链断在源头。另一个诱发因素是工具名不匹配:MinGW 自带的 make 实际叫 mingw32-make.exe,文档里写的 gmake 并不存在,导致「看起来执行了 make,实际规则根本没跑」。

解决:先确认 bison 和 flex 都可用,分别执行bison --version、flex --version;然后重新 make。如果目录里有上一次失败的半成品,先 make clean 或手动删除 y.tab.c、y.tab.h、lex.yy.c,避免 make 按时间戳判断「文件已经是最新」而跳过生成。

5.3 换行符:Linux 下 test.crb 第 5 行报 0x0d

现象:在 Linux 下运行./crowbar test/test.crb,报错明确指向第 5 行的(0x0d);同一份压缩包在 Windows 下却一切正常。

原因:测试脚本是 Windows 的 CRLF 行尾。Linux 下词法器把每行末尾的\r(0x0d)当成未定义字符,默认规则直接报非法字符。源码文件如果是 CRLF,flex/bison 有时能容忍,但运行时读入的测试脚本必须修正。

解决:对测试脚本做行尾转换,dos2unix 或 sed 都可以:

dos2unix test/test.crb # 不想装新工具时 sed -i 's/\r$//' test/test.crb

处理完再跑一遍,报错行号会立刻消失。这是「同一份源码跨平台跑」最容易踩的静默陷阱:文件用编辑器看毫无区别,二进制里每一行末尾却多了一个字节。

5.4 make 找不到目标与 missing separator

现象:进入 crowbar_book_0_1 执行 gmake,报gmake: *** No targets specified and no makefile found. stop;另一个典型案例是编译第三方正则库时 make install 报.deps/euc_jp.Plo:1: *** missing separator。

原因:前者是 make 程序名不匹配或目录不对——GNU make 默认按GNUmakefile、makefile、Makefile三个名字查找,一个都没有就报 no targets;而 MinGW 的 make 叫 mingw32-make.exe,文档语境里的 gmake 在 PATH 里并不存在。后者是 Makefile 的规则行必须以 Tab 开头,解压工具或编辑器把 Tab 换成空格后,make 就报 missing separator。

解决:先ls | grep -i makefile看文件到底叫什么;MinGW 环境用D:\MinGW\bin\mingw32-make.exe,或复制一份为 gmake.exe 对齐文档;Makefile 报 missing separator 时用cat -A Makefile看行首是不是^I(Tab 的可见形式)。附带一条血泪经验:不要在压缩包解压后凭感觉乱猜目录,先确认你正站在含 Makefile 的源码根目录里。

5.5 跨平台库链接:cannot find -lmsvcp60

现象:Linux 下编译 crowbar_book_0_4,链接阶段报/usr/bin/ld: cannot find -lmsvcp60。

原因:Makefile 为了兼容旧 Windows 工具链保留了 msvcp60 运行库的链接项,这个库只存在于 Windows 的旧版 C 运行环境,Linux 上没有对应实现。这是跨平台源码包最常见的问题:平台相关的 LIBS 没有按条件分支隔离,一股脑写在同一个链接变量里。

解决:编辑 Makefile,把-lmsvcp60从 LIBS 里删掉,或改造成按平台分支的写法,形如ifeq ($(OS),Windows_NT) ... endif,然后重新 make。这类链接错误有个共同特征——报错信息里的库名可以直接在 Makefile 里搜到出处,定位比想象中快。

6. 设计取舍:每代只加一个新特性,是这套源码最值钱的地方

crowbar 从 0.1 到 0.4,Diksam 从 0.1 到 0.4,版本递进遵循同一个规律:同一门语言、每个版本只加一个新特性。从教学角度这是为了控制篇幅;从设计角度看,这是最容易被低估的实验方法论。新特性对既有实现的冲击被限制在最小范围,读 diff 时能看到一个特性「从文法到 AST 到运行时」的三层穿透路径。0.2 加入 GC 时,string_pool.c 和 memory/ 目录的变化就是「内存管理权从语言层收归运行层」的完整证据;0.3 的数组让 eval 多出下标分支;到 0.4 的对象与正则绑定,native.c 的规模明显膨胀。我一般会拿这个思路反推自己写的解释器:一个特性如果同时改了语法文件、AST 结构、求值器三处还没有明显报错,多半是特性根本没接通。

验证是否读懂的步骤很直接——按版本顺序构建,然后盯住相邻版本的 diff:

make -C mycalc clean && make -C mycalc # 工具生成路线 make -C llparser # 手写递归下降路线 make -C crowbar_book_0_1 # 最小可用的树遍历解释器 make -C crowbar_book_0_2 # 观察 GC 带来的改动面

每跑通一个版本,就把与上一版的diff -r打开,只看一个新特性的改动面,比通读整本编译教材更能建立「语法特性如何穿透三层」的直觉。这份资料的核心价值不是「又一个开源解释器」,而是把语言设计最重要的决策过程——每次加一个特性、观察它破坏了什么——固化成了可以按版本回放的归档。

从那以后,我每次拿到这种演进式源码包,都会先按版本号列一份「新增文件与改动面」清单,再动手改任何一行代码。没有这份清单,读多少遍源码都容易在符号表和求值器里迷路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询