简介:围绕自制编程语言主题的PDF资料,面向希望从语法设计、编译器实现到运行环境完整走通一遍的开发者与高年级学生,尤其适合已具备C语言基础、想理解解释器与编译器构造流程的读者。压缩包内共1个PDF文件,约2.38MB,以图文讲义形式集中呈现知识点,便于在单一文档中连续阅读与检索。内容围绕Crowbar、Diksam两款教学语言展开,覆盖yacc/lex与bison/flex生成词法与语法分析器、MinGW与GCC工具链配置,以及Linux和Windows下构建脚本的差异,并涉及垃圾回收、虚拟机与跨平台编译等实现细节。已有870人学习,可作为课程设计、毕业项目或自学编译原理的参考资料,帮助读者建立从语言设计原则、解释器与编译器实现到代码规范与可维护性评估的整体认知,并对照示例理解自制语言从早期版本到后续版本的演进思路。
1. 从一份 2013 年的压缩包说起:Crowbar 与 Diksam 到底能带你走多远
去年整理旧资料时翻到一个 2013 年的 ZIP,解压出来是五个目录:calc、mycalc、llparser、crowbar、diksam。前三个是面向编译原理课程的过渡练习,后两个是一套自制编程语言的完整实现——从词法、语法到 AST 遍历执行、GC、正则字面量,一层层往上搭。它的价值不在"能跑出多强的语言",而在于把"一个编程语言从零到能执行脚本"的每一步都留了可编译的版本快照:Crowbar 从 0.1 到 0.4,Diksam 从 0.1 到 0.4,每个版本都能单独make出可执行文件。适合两类人:想动手写解释器但卡在"语法树怎么和执行器对上"的;以及想读懂 bison/flex 生成物到底怎么嵌进 C 工程的。下面按我实际拆包、编译、改代码的顺序走一遍。
2. 先看地形:五个顶层目录与两条语言演进线
解压后别急着make,先把目录扫一遍。这个包的目录命名很克制,没有一坨多余的资源文件,所有目录都是"可以单独编译的小工程",这点对复现特别友好——任何一个目录编译失败,都不影响你继续看别的。
2.1 顶层五个目录对应三种难度
calc是最小可用集,用来讲"表达式求值"这件事本身;mycalc和mycalc_ex是同一份东西的两份:前者带注释、可以跟着改,后者是练手答案,走的是 yacc/lex 路线;llparser和llparser_ex同理,只是把语法分析换成了 LL 手工递归下降。这五个目录可以当作热身,代码量小,编译一次就能跑,非常适合在动 Crowbar 之前先把".l生成lex.yy.c、.y生成y.tab.c + y.tab.h"这套生成物关系摸熟。
真正的主线是crowbar_book_0_1到crowbar_book_0_4四个目录,以及diksam_book_0_1到diksam_book_0_4四个目录。八个目录摆在一起看,你能直观看到"同一个语言,随着章节推进,源码里多了哪些文件"——这比读一本讲编译原理的书要直观得多,因为你能diff出增量。
2.2 Crowbar 的四个版本:0.1 是骨架,0.2 之后开始长肉
crowbar_book_0_1是能跑通的最小解释器。从它的编译日志能反推出源文件构成:main.c、interface.c、create.c、execute.c、eval.c、string.c、string_pool.c、util.c、native.c、error.c、error_message.c,外加 bison/flex 生成的y.tab.c、lex.yy.c。这个文件划分本身就是一份架构图:interface负责和外界的输入输出打交道,create负责把语法树造出来,execute负责遍历执行语句,eval负责算表达式,native放内置函数,string_pool做字符串驻留。
到crowbar_book_0_2,引入了 GC。这是个关键分水岭:0.1 的解释器对象分配完就不管了,跑长脚本会一路涨内存;0.2 之后多了memory.c这一层,对象分配走统一的入口,GC 才能扫描到所有活对象。我在 0.1 上跑过一个循环创建字符串的脚本,内存曲线是直线上升的,换成 0.2 之后再跑,曲线就平了——这个对比实验值得自己动手做一遍,比看十页讲标记清除的文字管用。
crowbar_book_0_3和crowbar_book_0_4继续往上加特性。按这类书的常规推进节奏,中间版本会补上数组、结构体、闭包、异常处理之类的东西,具体每个版本加了什么,最可靠的办法是直接diff -r crowbar_book_0_2 crowbar_book_0_3,看create.c、execute.c里多了哪几个case分支,比猜快得多。
2.3 Diksam 的两个阶段:先只有 compiler,后来才有 main
diksam_book_0_1和diksam_book_0_2的目录结构里只有compiler,编译也是在这个子目录里make/gmake;到diksam_book_0_3、diksam_book_0_4,才多出main目录。这个结构变化很说明问题:早期的 Diksam 只做到"把源码编译成中间表示",还不能端到端执行;后面有了main,才是"编译 + 执行"的完整链路。
如果你只想快速看效果,建议直接从diksam_book_0_4入手;如果你想理解"一个编译器前端是怎么从零长出来的",就从diksam_book_0_1往上爬,因为compiler目录里的文件是逐个加进去的,增量最干净。
2.4 目录与关键文件对照
| 目录 | 主要内容 | 构建方式 | 适合谁 |
|---|---|---|---|
calc | 最小表达式求值 | 直接编译 | 想看最朴素的递归下降 |
mycalc/mycalc_ex | yacc/lex 版计算器 | make | 想搞懂.y.l生成物 |
llparser/llparser_ex | LL 手工分析器 | make | 想对比手写与工具生成 |
crowbar_book_0_1 | 可执行解释器骨架 | make/gmake | 第一次跑通全链路 |
crowbar_book_0_2 | 加了 GC | make/gmake | 想看对象生命周期 |
crowbar_book_0_3/0_4 | 特性递增 | make/gmake | 想学增量演进 |
diksam_book_0_1/0_2 | 只有compiler | make/gmake | 想看编译器前端 |
diksam_book_0_3/0_4 | 多了main | make/gmake | 想跑完整编译执行 |
提示:包里还带了一份 oniguruma 5.9.4 的正则库源码,Crowbar 后期的正则字面量依赖它。没有它,带
/.../字面量的脚本会直接链接失败。
3. 构建环境:MinGW、Cygwin 与 oniguruma 三条依赖线怎么搭
这个包的构建环境是 Windows 下最容易翻车的一环,因为它的脚本同时假设了三套东西:MinGW 的 GCC、GNUWin32 的 bison/flex、以及 Cygwin 的 shell 工具。任何一套装歪,报错信息都会指向一个和真实原因无关的地方。
3.1 MinGW 装 base 就够,但 make 得改名
Windows 下装 MinGW,跑mingw-get-setup.exe,安装管理器里只需要勾mingw32-base这一项就够编译 C 了。装完记得把D:\MinGW\bin加进PATH。这里有个小坑:MinGW 提供的 make 叫mingw32-make.exe,但很多目录里的make.bat和 Makefile 直接调make。两种解决办法,我一般选第二种:
:: 方式一:每次手敲全名 D:\MinGW\bin\mingw32-make.exe :: 方式二:复制一份改名为 make.exe,之后直接 make copy D:\MinGW\bin\mingw32-make.exe D:\MinGW\bin\make.exe第二种的好处是make.bat不用改一行,坏处是你以后在这台机器上跑的每个make实际都是 MinGW 的那个,和 Cygwin 的make混用时要留个心眼——后面 oniguruma 那节就是这个问题的重灾区。
3.2 bison/flex 的安装路径不能带空格
bison 和 flex 用的是 Windows 版安装包,装的时候默认目录通常是C:\Program Files (x86)\GnuWin32。路径里这个空格会在 bison 内部调用 m4 时炸掉,典型报错长这样:
m4: cannot open `Files': No such file or directory m4: cannot open `(x86)\GnuWin32\Bison/share/bison': No such file or directory m4: cannot open `D:\Program': No such file or directory原因不复杂:bison 把--prefix路径拼成命令行传给 m4,中间没加引号,空格就被拆成了两个参数。解决办法只有一个,把它装到没有空格的路径,比如C:\GnuWin32,然后把这个目录下的bin加进PATH。装完在 cmd 里验证:
bison --version flex --version能打出正常的版本信息,这一步才算过。m4: cannot open ...这一类报错一旦出现,后面y.tab.c、y.tab.h全部生不出来,接着就是 gcc 报y.tab.c: No such file or directory,看起来像编译问题,其实根子在 bison 的路径上。
3.3 oniguruma 5.9.4:Linux 一条路,Windows 另一条路
Linux 下是最顺的,标准三步:
tar -xvf onig-5.9.4.tar.gz cd onig-5.9.4 ./configure make sudo make install./configure会检查 alloca、memcmp、可变长参数这些平台特性,全绿之后make出libonig.a和libonig.so,make install装到/usr/local/lib。装完确认一下:
ls /usr/local/lib/libonig.* ls /usr/local/include/oniguruma.hWindows 下就麻烦得多。原因是configure是 shell 脚本,cmd 跑不了,得借 Cygwin 的 bash;但 Cygwin 的make编出来的东西会依赖cygwin.dll,和 MinGW 的 GCC 不是一个 ABI,混着链就出事。我踩过最典型的一次是make install阶段:
libtool: install: ranlib .libs/libonig.a D:\MinGW\bin\ranlib.exe: '/usr/local/lib/libonig.a': No such file Makefile:415: recipe for target 'install-libLTLIBRARIES' failed原因说穿了很简单:libtool 用的是 Cygwin 的路径视角,/usr/local/lib在 Cygwin 里是C:\cygwin\usr\local\lib;而ranlib是 MinGW 的,它看的是 Windows 绝对路径,同一个字符串在它眼里就是D:\usr\local\lib,当然不存在。
比较稳的做法是绕开make install,手动收尾:
# 在 Cygwin 的 bash 里跑完 configure 和 make ./configure make # 不要 make install,直接进 .libs 目录看产物 cd .libs ls libonig.a # 用 MinGW 的 ranlib 补一遍符号表,再手动拷到 MinGW 的库目录 ranlib libonig.a cp libonig.a /cygdrive/d/MinGW/lib/ cp ../oniguruma.h /cygdrive/d/MinGW/include/ranlib这一步不能省。有些环境下ar打完包没有生成符号索引,链接时会报undefined reference to onig_new之类,看着像函数没声明,其实是索引没建。拷完之后编译时加-lonig,就能链上。
3.4 一次完整的构建验证
以crowbar_book_0_1为例,Linux 下和 Windows 下的流程略有差别,但验证方式一致:编译出可执行文件,再拿自带的test/test.crb跑一遍。
# Linux / macOS cd crowbar_book_0_1 make ls -l crowbar ./crowbar test/test.crb # Windows(MinGW) cd D:\win_sjis\crowbar_book_0_1 gmake dir crowbar.exe crowbar.exe test\test.crb编译过程中你应该能看到一长串gcc -c -g ...逐文件编译的日志,最后一步链接出crowbar。如果只看到两三条就报错,大概率是 bison/flex 没生成y.tab.c、lex.yy.c;如果编译全过但链接报cannot find -lmsvcp60,那是源码里带了一条 Windows 专有的库依赖,在 Linux 下把-lmsvcp60从 Makefile 的LDFLAGS里删掉就行。
4. 核心链路拆解:一个 .crb 文件从 crowbar.l 走到 execute.c
这一章是整份资料里最值得反复看的部分。Crowbar 的价值就在于它把"脚本变成执行结果"这条路切成了清晰的三段:词法/语法生成前端、create.c造 AST、execute.c遍历执行。三段之间靠结构体指针传,没有中间文件,调试时可以直接在execute.c里打断点看树。
4.1 词法层:crowbar.l 的起始条件与 token 产出
.l文件负责把字符流切成 token。Crowbar 里比较有意思的是它要给注释和字符串字面量做特殊的起始条件处理,因为这两种状态里绝大部分字符都不该按普通 token 规则走。
%{ #include <stdio.h> #include "crowbar.h" %} %option noyywrap /* 排他起始条件:进入后只有显式列出的规则生效 */ %x COMMENT %x STRING_LITERAL_STATE %% "/*" { BEGIN COMMENT; } <COMMENT>"*/" { BEGIN INITIAL; } <COMMENT>. { /* 注释内容丢弃 */ } <COMMENT>\n { /* 换行也要吃掉,否则行号会漂 */ } \" { BEGIN STRING_LITERAL_STATE; } <STRING_LITERAL_STATE>\" { BEGIN INITIAL; return STRING_TOKEN; } <STRING_LITERAL_STATE>\\n { /* 转义处理 */ } <STRING_LITERAL_STATE>. { /* 累积到字符串缓冲区 */ }这几条规则的核心是%x声明的排他起始条件:一旦BEGIN COMMENT,只有<COMMENT>前缀的规则会被匹配,普通标识符规则全部失效。这比在一个巨型正则里做状态判断要干净得多。
注意:起始条件在 flex 里用
%x(排他)或%s(共享)声明,%start是 bison 的关键字,写进.l文件 flex 不认。我见过有人在.l顶部写%start COMMENT STRING_LITERAL_STATE,结果 flex 直接报语法错,改回%x立刻就好。
4.2 语法层:crowbar.y 与 y.tab.h 的生成关系
.y文件定义文法规则和对应的语义动作。bison 处理的顺序是:先分析文法、检查冲突,再生成y.tab.c(解析器主循环)和y.tab.h(token 常量定义)。这个顺序决定了你必须先跑 bison 再跑 gcc,否则.l里#include "y.tab.h"会直接失败。
Makefile 里通常写成显式依赖:
OBJS = main.o interface.o create.o execute.o eval.o \ string.o string_pool.o util.o native.o error.o error_message.o crowbar: y.tab.o lex.yy.o $(OBJS) $(CC) -o $@ $^ -lonig -lm y.tab.c y.tab.h: crowbar.y bison --yacc -dv crowbar.y lex.yy.c: crowbar.l y.tab.h flex crowbar.l--yacc让 bison 用yacc兼容模式,产物统一叫y.tab.c/y.tab.h;-dv是同时生成y.output和y.tab.h,其中y.output记录了完整的 LR 状态机,调冲突的时候就是靠它——你能看到 bison 到底在哪个状态上既想移进又想归约。
$(CC) -o $@ $^里的$@是目标名(crowbar),$^是所有依赖文件列表,这是 Makefile 的自动变量写法,比手敲一长串.o可靠。
4.3 中间表示:create.c 造树,execute.c 遍历
create.c的职责是把语法动作里收集到的 token 组装成节点。这类实现通常会有一个统一的分配入口,然后按语句类型和表达式类型各写一个创建函数。
/* 按节点类型分配,统一走 memory 层,方便 GC 扫描 */ Expression *crb_create_binary_expression(ExpressionType type, Expression *left, Expression *right) { Expression *expr = crb_malloc(sizeof(Expression)); expr->type = type; expr->u.binary_expression.left = left; expr->u.binary_expression.right = right; return expr; } /* 语句节点:表达式语句、if、while、return 各一个分支 */ Statement *crb_create_expression_statement(Expression *expr) { Statement *stmt = crb_malloc(sizeof(Statement)); stmt->type = EXPRESSION_STATEMENT; stmt->u.expression_s = expr; return stmt; }两个要点。第一,节点用type字段做标签,u是联合体,不同类型节点共享同一块内存布局——这是 C 里实现多态的经典手法,省内存也省类型转换。第二,分配统一走crb_malloc而不是裸malloc,因为 0.2 之后的 GC 需要在分配时把对象挂到可达图里,绕过这个入口分配的对象 GC 是扫不到的,这就是"用了 GC 还漏内存"最常见的原因。
execute.c则是一个大switch,按statement->type分发:
void crb_execute_statement(Statement *stmt) { switch (stmt->type) { case EXPRESSION_STATEMENT: crb_eval_expression(stmt->u.expression_s); break; case IF_STATEMENT: /* 条件求值结果按布尔语义转换,再选分支 */ if (crb_eval_expression_to_boolean(stmt->u.if_s.condition)) crb_execute_statement(stmt->u.if_s.then_block); else crb_execute_statement(stmt->u.if_s.else_block); break; case WHILE_STATEMENT: while (crb_eval_expression_to_boolean(stmt->u.while_s.condition)) crb_execute_statement(stmt->u.while_s.block); break; default: crb_error("unknown statement type"); } }这种"树遍历解释器"实现起来最快,缺点是每次执行都要走一遍指针跳转,比字节码慢,但对学习来说完全够用,而且调试极其直观——你在crb_execute_statement上打断点,就能看着执行流一步步走完整棵树。
4.4 运行时:eval.c、native.c 与 string_pool.c
eval.c负责表达式的值计算,返回的是一个带类型标签的值对象。这里的坑在于类型提升:整数和浮点混算时得先统一类型,字符串拼接则要先判类型再返回错误,否则运行到一半直接段错误。
native.c是内置函数注册表,通常维护一张"函数名 → 函数指针 + 参数个数"的表,词法分析时把标识符解析成IDENTIFIER,执行时先在变量表里找,找不到再查这张内置表。
string_pool.c是字符串驻留。所有字符串字面量在创建时先查池子,有就复用,没有才新建。好处是字符串比较可以退化成指针比较,坏处是 GC 时池子本身要当作根节点处理。改动这块的时候,池子的生命周期和 GC 的扫描顺序必须一起改,只改一边必出悬垂指针。
4.5 内存:memory.c 与 0.2 之后的 GC 分水岭
crowbar_book_0_2起多出来的memory.c是整套实现里最值得细读的文件。常见的做法是维护一个所有已分配对象的链表,GC 触发时从根集合(全局变量、当前调用栈上的局部变量)出发做标记,再清扫未标记对象。C 里没有对象图的反射能力,所以每个需要被 GC 管理的类型都得自己实现一个mark函数,遍历自己内部的指针字段。
验证 GC 有没有真的生效,可以写一个循环创建字符串的脚本,跑一分钟看内存曲线;或者在crb_malloc里加一个分配计数器,再在 GC 前后打印一次存活对象数。这两个办法我在 0.1 和 0.2 之间对比过,差距非常明显。
5. 排查手记:五类构建与运行报错的现象、原因和解决
这份资料的报错类型高度集中,基本就五类。我把每条按"现象 → 原因 → 解决"记下来,遇到时按顺序对号入座就能省下大半时间。
5.1gmake: No targets specified and no makefile found
现象:在crowbar_book_0_1目录下敲gmake,直接报找不到目标文件。
原因:九成是当前目录不对。Makefile 在crowbar_book_0_1里,但你的 cmd 还停在上一级。Windows 下用cd切换后如果路径带空格又没加引号,切换本身就会失败,人还以为是切过去了。
解决:切完目录先dir看一眼有没有Makefile,没有就是没切对。路径带空格时统一加引号:cd "D:\win_sjis\crowbar_book_0_1"。
5.2m4: cannot open 'Files': No such file or directory
现象:跑gmake后,bison 那一行直接失败,后面跟着一串m4: cannot open ...。
原因:bison 安装在C:\Program Files (x86)\GnuWin32这类带空格的目录,bison 拼 m4 命令行时没加引号,路径被空格切断。
解决:卸载重装到C:\GnuWin32,然后把C:\GnuWin32\bin提前到PATH最前面。装完bison --version能正常输出才算修好。这条修完,y.tab.c、y.tab.h的缺失问题通常一起消失。
5.3gcc: y.tab.c: No such file or directory/mycalc.l:3:19: error: y.tab.h: No such file
现象:编译阶段报源文件不存在,文件名是y.tab.c或y.tab.h。
原因:这两个文件是 bison/flex 的生成物,不是仓库里自带的。要么 bison 那一步失败了(见上一条),要么 Makefile 里缺依赖,导致 gcc 先于 bison 跑。
解决:手动按顺序跑一遍,确认每一步的产物:
bison --yacc -dv crowbar.y ls y.tab.c y.tab.h flex crowbar.l ls lex.yy.c哪一步没产物就修哪一步。手工跑通了再回头看 Makefile 的依赖行,通常是lex.yy.c没写成依赖y.tab.h。
5.4 Linux 下运行脚本报非法字符(0x0d)
现象:./crowbar test/test.crb跑到第 5 行就报非法字符0x0d。
原因:test.crb是在 Windows 下编辑保存的,行尾是 CRLF。Linux 版的词法规则只认\n,\r就变成不认识的字符了。
解决:转一下行尾,或者干脆在.l的空白规则里同时吃\r:
dos2unix test/test.crb # 或者批量处理 find . -name "*.crb" -exec dos2unix {} \;改词法规则是治本,但会影响所有版本的一致性;我的习惯是只转换测试文件,源码不动,这样和书的描述保持一致。
5.5ranlib: '/usr/local/lib/libonig.a': No such file
现象:oniguruma 在 Windows 下make install阶段失败,报找不到libonig.a。
原因:Cygwin 的 libtool 传递的是 POSIX 路径,MinGW 的ranlib按 Windows 绝对路径解析,两边对不上。
解决:跳过make install,进.libs目录手动ranlib libonig.a,再把.a和头文件分别拷到 MinGW 的lib、include下。另外diksam_book_0_4在 Linux 下如果报cannot find -lmsvcp60,把 Makefile 里这个库删掉即可,它是 Windows 平台的遗留依赖。
6. 二次开发:给 Crowbar 加一个语法结构和内置函数
改这套代码,最有效的入口不是加复杂特性,而是走一遍"从词法到执行"的完整改动链。我拿加一个print_line内置函数和一个+=复合赋值运算符练手,因为这两处几乎覆盖了所有需要改动的文件。
改动点对照如下,按这个顺序改,改完一次编译通过的概率最高:
| 改动点 | 文件 | 具体动作 |
|---|---|---|
| 新 token | crowbar.y | 在%token段加ADD_ASSIGN |
| 词法规则 | crowbar.l | 加"+=" { return ADD_ASSIGN; },同长度规则放前面 |
| 语义动作 | crowbar.y | 加assignment_expression: ... ADD_ASSIGN ...的产生式 |
| 节点创建 | create.c | 加crb_create_assignment_expression,复用已有二元表达式结构 |
| 执行分支 | execute.c、eval.c | 在EXPRESSION_TYPE分发里加一个case |
| 内置函数 | native.c | 在注册表里加一行{"print_line", NATIVE_PRINT_LINE, 1} |
改.l的时候有个顺序坑:flex 是按规则书写顺序匹配的,=和+=都能匹配前缀=的话,短的写前面就把长的挡了。所以+=这类多字符运算符必须写在=之前。改完先单独跑flex crowbar.l,确认没报 "rule cannot be matched" 再编译。
.y里加产生式后必须重新跑 bison,然后看y.output里有没有新增冲突。加+=这种产生式一般不会引入移进-归约冲突,但如果你的新规则和已有的赋值规则共享前缀,冲突告警一定要处理,别用%expect硬压下去——压下去的结果是运行时解析行为变得不可预测,这种玄学问题最难查。
验证方式是分层跑测试,别一上来就写复杂的脚本:
# 第一层:语法能过 ./crowbar -p test/assign.crb # 只解析不执行,看语法树 # 第二层:语义正确 ./crowbar test/assign.crb # 期望输出结果 # 第三层:回归 ./crowbar test/test.crb # 原来能跑的还得能跑如果项目里的可执行文件没带"只解析"的开关,那就在crb_execute_statement前面加一个打印 AST 的调试函数,自己给自己造一个。第三步的回归尤其重要:新增 token 会影响整个解析表的状态编号,原来跑通的脚本一定要重跑一遍。
我从这份资料里留下的最深一个习惯是:只要动了.y或.l,就先删掉y.tab.c、y.tab.h、lex.yy.c再重新生成,哪怕 Makefile 里的时间戳依赖看起来是对的。增量构建在这两个生成物上翻车太多次了,多花五秒钟全量重建,比对着一个诡异报错排查半小时划算得多。希望帮到你。
本文还有配套的精品资源,点击获取