资讯详情

自制编程语言:四代解释器源码带你穿透词法、语法到运行时

📅 2026/10/17 19:58:35 | 华诺云谱 👁 阅读
自制编程语言:四代解释器源码带你穿透词法、语法到运行时
简介《自制编程语言》相关资料以单个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.ccrowbar 则自带 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。这条链必须写进 Makefiley.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 优先级声明为什么 45*2 不会算成 18经典的四则运算文法天然是歧义的45*2可以解释成(45)*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。它告诉 bisonADD/SUB 这一组优先级低于 MUL/DIV当分析表里出现 shift/reduce 冲突时比较当前 token 和栈顶规则符号的优先级决定「是继续移进下一个运算符还是先把已经读到的表达式归约掉」。因为 MUL 高一级45*2读到*时会选择移进而不是归约加法乘法在语法树里就沉到了更深的层级。没有这些声明时会怎样bison 面对同样的冲突会按默认策略选择 shift所有运算符变成平级左结合45*2会被算成18。这种 bug 不报错只给错结果比缺 y.tab.h 隐蔽得多。写 yacc 语法时「优先级声明缺失」是最常见的静默错误验证方法很简单拿一个混合优先级表达式跑一遍看运行结果对不对。3.3 手写版翻车点llparser_ex 的缓冲区错误说明了什么手写递归下降的难点不在递归函数本身而在输入管理。资源里记录了一个非常典型的已知 BUGstatic 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垃圾回收GCmemory 子目录、string_pool.ccrowbar 0.3数组AST 新节点、eval 下标分支crowbar 0.4对象与正则支持native.c 绑定 onigurumaDiksam 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 内置的 bisonCygwin 的 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 下词法器把每行末尾的\r0x0d当成未定义字符默认规则直接报非法字符。源码文件如果是 CRLFflex/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看行首是不是^ITab 的可见形式。附带一条血泪经验不要在压缩包解压后凭感觉乱猜目录先确认你正站在含 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.4Diksam 从 0.1 到 0.4版本递进遵循同一个规律同一门语言、每个版本只加一个新特性。从教学角度这是为了控制篇幅从设计角度看这是最容易被低估的实验方法论。新特性对既有实现的冲击被限制在最小范围读 diff 时能看到一个特性「从文法到 AST 到运行时」的三层穿透路径。0.2 加入 GC 时string_pool.c 和 memory/ 目录的变化就是「内存管理权从语言层收归运行层」的完整证据0.3 的数组让 eval 多出下标分支到 0.4 的对象与正则绑定native.c 的规模明显膨胀。我一般会拿这个思路反推自己写的解释器一个特性如果同时改了语法文件、AST 结构、求值器三处还没有明显报错多半是特性根本没接通。验证是否读懂的步骤很直接——按版本顺序构建然后盯住相邻版本的 diffmake -C mycalc clean make -C mycalc # 工具生成路线 make -C llparser # 手写递归下降路线 make -C crowbar_book_0_1 # 最小可用的树遍历解释器 make -C crowbar_book_0_2 # 观察 GC 带来的改动面每跑通一个版本就把与上一版的diff -r打开只看一个新特性的改动面比通读整本编译教材更能建立「语法特性如何穿透三层」的直觉。这份资料的核心价值不是「又一个开源解释器」而是把语言设计最重要的决策过程——每次加一个特性、观察它破坏了什么——固化成了可以按版本回放的归档。从那以后我每次拿到这种演进式源码包都会先按版本号列一份「新增文件与改动面」清单再动手改任何一行代码。没有这份清单读多少遍源码都容易在符号表和求值器里迷路。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑