简介:《自制编程语言》相关资料1是一份面向编程语言爱好者、计算机专业学生及希望自研小型语言的开发者的入门与参考材料,重点回应语言从设计、实现到使用过程中的知识衔接问题。内容围绕语法与语义设计、可读性与可维护性权衡,以及词法分析、语法分析、编译器与解释器构建等主题展开,并结合示例语言介绍工具链与跨平台构建思路。压缩包共1个PDF文件,大小约2.38MB,便于集中阅读和检索。已有870人学习,说明该主题在自研语言与编译原理学习中具有一定关注度。读者可借助材料理解语言设计原则、yacc/lex与bison/flex等工具的作用,以及MinGW、make在Linux与Windows环境下的差异,为课程设计、小型语言原型或编译原理实践提供参考。
1. 从 crowbar 0.1 到 Diksam 0.4:一份能编译、能运行、能改的编程语言源码
学编译原理最难受的阶段不是看不懂 LL(1) 和 LR(1),而是合上书之后,面对一个空目录,不知道第一个.c文件该写什么。这份自制编程语言配套资料针对的就是这个空档:它不是一本电子书,而是八个能独立编译、独立运行的工程快照——crowbar 从 0.1 到 0.4,Diksam 从 0.1 到 0.4,外加 mycalc、mycalc_ex、llparser、llparser_ex 四个小工程。每个book目录都是一份完整源码,配make.sh和make.bat,改一行就能看到结果,适合想亲手写完词法、语法、解释执行全链路的人,也适合想把 yacc/lex 用起来的人。
2. 八个工程先分清:crowbar、Diksam、mycalc、llparser 各自练什么
拿到压缩包先不要直接进第一个目录敲 make。八个工程里有四组是「同一件事的递进版本」,两组是「同一件事的两种做法」,剩下两组是练习答案。搞清楚这层关系,后面编译报错时你才知道该怀疑工具链还是怀疑自己进错了目录。
2.1 crowbar 的四个快照:一本书的推进过程
crowbar_book_0_1到crowbar_book_0_4属于同一个解释器的四次递进,每个目录都是可以独立编译的完整工程,并非补丁。0.1 是能跑起来的最小流程——词法分析、语法分析、表达式求值、变量与赋值,一条test/test.crb能把整条链路走通。0.3 明确带上了 GC 相关的演进,这一点在目录名旁边的说明里就写着。0.4 是这一支的收尾。
判断每个版本多了什么,不用通读代码。用diff对比两个目录的文件列表,增量一目了然:
# -r 递归子目录,-q 只报差异文件名不打印内容 diff -rq crowbar_book_0_1 crowbar_book_0_2 diff -rq crowbar_book_0_2 crowbar_book_0_3 diff -rq crowbar_book_0_3 crowbar_book_0_4-q在这里是关键参数,不加的话会把整份源码刷屏。先看「Only in」那一行,新增的.c文件就是这一版扩出来的模块。
从 0.4 的编译日志能反推出 crowbar 的模块划分:interface.c负责交互入口和 REPL,create.c把语法树建起来,execute.c负责语句执行,eval.c负责表达式求值,string.c和string_pool.c管字符串与驻留池,native.c注册内置函数,error.c与error_message.c负责错误码到错误信息的映射。这套分工基本就是一门小语言解释器的标准骨架,熟手扫一眼文件名就知道该从哪下手改。string_pool那个模块尤其值得看——字符串驻留是很多教程跳过、但真实解释器绕不开的东西,它直接决定后面比较字符串是比指针还是比内容。
2.2 Diksam 的四个快照与两个构建入口
Diksam 是这套资料后半程的主角,语言本身的类型系统比 crowbar 更重。它的四个版本有个容易出错的地方:构建入口不统一。
diksam_book_0_1和diksam_book_0_2要进compiler目录执行 make/gmake;diksam_book_0_3和diksam_book_0_4要进main目录执行 make/gmake。很多人第一次跑 Diksam 报「No targets specified and no makefile found」,就是因为在版本根目录直接敲了命令,而根目录根本没有Makefile。
# 前两个版本:入口在 compiler 子目录 cd diksam_book_0_1/compiler && make # 后两个版本:入口在 main 子目录 cd diksam_book_0_3/main && make这条规则记不住也没关系,进版本根目录先ls一眼,看Makefile在哪一层就行。工程结构改过一次,说明 0.3 之后运行期的东西变多了,编译产物不再只是一个编译器前端。
2.3 mycalc 与 llparser:同一件事的两种做法
mycalc和mycalc_ex用 yacc/lex(实际工具是 bison/flex)生成解析器和词法分析器,源文件是.y和.l,构建时先生成y.tab.c、y.tab.h、lex.yy.c,再和手写部分一起编译。llparser和llparser_ex则完全不依赖生成工具,纯手写 LL(1) 递归下降。
这两组放在一起的价值在于对照:同一个文法,用工具生成是什么写法,手写又是什么写法,各自的代码量和可调试性差在哪。带_ex后缀的是练习答案版,先自己写再看答案,比对才有效果。llparser的lexicalanalyzer.c里有个经典的缓冲区问题,后面避坑那章会专门说,先别急着照抄。
2.4 目录、入口、依赖对齐表
| 工程目录 | 构建入口 | 关键依赖 | 说明 |
|---|---|---|---|
| crowbar_book_0_1 至 0_4 | 版本根目录,make.sh/make.bat | gcc、bison、flex | 解释器主线,逐版本递进 |
| diksam_book_0_1、0_2 | compiler/子目录 | gcc、bison、flex | 构建入口与前后的版本不同 |
| diksam_book_0_3、0_4 | main/子目录 | gcc、bison、flex | 运行期结构变化后换了入口 |
| mycalc | 工程根目录 | bison、flex、gcc | 生成式解析器示例 |
| mycalc_ex | 工程根目录 | bison、flex、gcc | 上一项的练习答案 |
| llparser | 工程根目录 | gcc | 手写递归下降,不依赖生成工具 |
| llparser_ex | 工程根目录 | gcc | 上一项的练习答案 |
表格里没有写版本号,因为这套源码的版本号就体现在目录名上,0_1到0_4就是全部,不需要另外去查。真正要查的是工具链版本——bison 和 flex 在 Windows 上的分发方式差异很大,这件事决定了你能不能顺利生成y.tab.c。
3. 工具链:MinGW、gmake、bison/flex、oniguruma 的最小可用组合
这套源码是纯 C 写的,理论上任何 C 编译器都能编。麻烦全在构建工具和生成工具上:Windows 默认没有 make,没有 bison,没有 flex,Makefile里还写死了gmake和bison --yacc。把这几样凑齐,后面基本就没什么阻碍;凑不齐,报错信息会一个比一个难懂。
3.1 MinGW 装什么、PATH 怎么加
MinGW 的安装分两步:先跑安装器,再进管理器勾组件。安装器负责把目录铺好,安装管理器负责识别要装哪些包。安装路径改到D:\MinGW这类没有空格的短路径下,能省掉后面一堆路径解析的麻烦。
进管理器之后只勾一项:mingw32-base。这一项带上了 gcc、binutils 和 make 的实现。勾完点 Installation 菜单里的 Apply Changes,等下载解压完成。
组件装完之后,D:\MinGW\bin必须在 PATH 里,否则gcc和gmake都找不到:
# Windows cmd:只在当前会话生效,不污染系统环境变量 set PATH=D:\MinGW\bin;%PATH% # Makefile 里调用的是 gmake,而 MinGW 只提供 mingw32-make.exe # 补一份同名副本,是最省事的兼容办法 copy D:\MinGW\bin\mingw32-make.exe D:\MinGW\bin\gmake.exe # 验证三件套是否都能被解析到 where gcc gmakeset PATH只影响当前 cmd 窗口,关掉就失效,调试阶段这么用最安全,确认没问题再写进系统变量。copy那一步的意义在于:MinGW 的 make 实现叫mingw32-make.exe,而源码里所有Makefile和.bat都按gmake来调,不改名就得每次手动敲全名,或者去改Makefile——后者会污染原始工程,不利于对照。改名是代价最小的做法。
3.2 bison 与 flex:装在哪个目录决定能不能编过
bison 和 flex 在 Windows 上有现成的 GnuWin32 安装包,一路 next 就能装完,包名选「Complete package, except sources」。问题出在默认安装位置:它会装到C:\Program Files (x86)\GnuWin32这种带空格和括号的路径下,而 bison 内部会调用 m4,m4 处理路径时不认引号,把空格当参数分隔符,于是路径被切成几段,报出一串m4: cannot open 'Files'和m4: cannot open '(x86)\GnuWin32\...'。
规避办法只有一个:装的时候手动把目录改成C:\GnuWin32,别用默认值。装完同样要把C:\GnuWin32\bin加进 PATH。bison 和 flex 各装一次,装完验证:
# Linux / Cygwin 下确认生成工具是否都在 PATH 里 which bison flex m4 make # 只打印版本首行,确认不是空壳 bison --version | head -n 1which一次查四个,缺哪个补哪个。head -n 1是为了避免版本信息刷屏,只看有没有输出。如果which bison有结果但bison --version报错,多半是安装包不完整,重装。
3.3 Linux 与 Cygwin:两条替代路径的取舍
在 Linux 上这三样基本都是包管理器一条命令的事,make.sh直接就能跑,不需要改名、不需要改路径,是这套源码最舒服的运行环境。如果你的目标是读懂代码,而不是非要在 Windows 上跑,装个双系统或者开个 Linux 环境,能省掉这一章一半的内容。
Cygwin 是另一条路。它能补上 bison、m4、make,安装器里在 Devel 分类下勾 make,在 Interpreters 分类下勾 m4,bison 同样能勾到。但它有个必须提前知道的代价:Cygwin 编出来的.exe依赖cygwin.dll,脱离 Cygwin 环境就跑不起来,拷给别人也用不了。MinGW 编出来的是原生 Windows 可执行文件,不依赖额外 DLL。所以工具链的选择标准很明确——要交付独立 exe 用 MinGW,只求在本机跑通、图省事可以用 Cygwin。
Cygwin 的安装器分 32 位和 64 位两个版本,选的时候跟系统对齐,装错了会出现编译出来的东西位数不匹配。安装模式选「Install from Internet」,Root Directory 和 Local Package Directory 用默认值即可,连接方式选 Direct Connection,然后在包选择界面勾上面说的几项。
3.4 oniguruma:configure 能过,make install 未必
这套资料里有版本在链接期需要正则库 oniguruma,素材里给的版本是 5.9.4。它在 Linux 下是标准三步,在 Windows 下则要绕一下。
# Linux:标准 autotools 流程,make install 会把头文件和静态库放进系统目录 tar -xvf onig-5.9.4.tar.gz cd onig-5.9.4 ./configure && make && make installWindows 下如果走 Cygwin 的 bash 跑同一套脚本,make能过,make install大概率过不去,典型报错是ranlib.exe: '/usr/local/lib/libonig.a': No such file。原因是 MinGW 的 ranlib 不认 Cygwin 的/usr/local路径映射。绕开的办法是不 install,手工归档再拷:
# Windows + Cygwin:跳过 make install,直接处理产物 cd /cygdrive/c/onig-5.9.4 ./configure && make cd .libs ranlib libonig.a # 给静态库建索引 cp libonig.a /cygdrive/d/MinGW/lib/ # 放进 MinGW 的库搜索路径ranlib这一步不能省,它的作用是为.a归档文件生成符号索引,缺了索引,链接时会报一堆 undefined reference,而错误信息里根本不会提到 ranlib。cp的目标目录要跟你的 MinGW 安装位置对上,路径写错了同样报找不到库。
4. 从 make.sh 到 make.bat:把第一个解释器跑起来
工具链齐了之后,编译本身很快。真正值得花时间的是搞清楚构建脚本在干什么——这套源码的make.sh和make.bat不只是几行命令,它把「生成」和「编译」两个阶段的顺序固定下来了,而这个顺序正是后面一堆报错的根源。
4.1 先读脚本再执行
进工程目录第一件事不是敲 make,是看脚本内容:
cd crowbar_book_0_1 cat make.sh # 先看清楚它调了哪些工具、按什么顺序 sh make.sh # 确认无误再执行make.sh里通常会先调 bison 从.y生成y.tab.c和y.tab.h,再调 flex 从.l生成lex.yy.c,最后才调 gcc 把生成的 C 文件和手写模块一起编译。这个先后顺序不是随便排的——lex.yy.c里会#include "y.tab.h",y.tab.h不存在,flex 的产物就编不过。理解了这一层,后面看到y.tab.h: No such file就知道该往前找 bison 那一步。
make.bat是同一套流程的 Windows 版本,区别在于它调的是gmake而不是make,并且用的是 cmd 的路径分隔符。两个脚本的职责完全对等,不需要都看,看你当前平台那个就行。
4.2 Windows 下 gmake 和 mingw32-make 什么时候不等价
前面把mingw32-make.exe复制成了gmake.exe,日常使用没问题。但有一种情况会出问题:Makefile里如果有递归调用子目录的写法,比如在某条规则里再调一次make,那 gmake 会去找 PATH 里叫make的可执行文件,找不到就报错。这类报错的特征是顶层编译成功,进到子目录就断。
# Windows cmd:进工程目录后用 gmake,不要直接敲 make cd /d D:\src\crowbar_book_0_1 gmake # 如果报找不到 make,再补一份同名副本 copy D:\MinGW\bin\mingw32-make.exe D:\MinGW\bin\make.execd /d的/d参数用于跨盘符切换,少了它从 C 盘切 D 盘会失败。gmake和make两份副本都留着,能覆盖绝大多数Makefile写法,代价只是几百 KB 的磁盘空间。
4.3 跑通第一个 .crb 脚本
编译成功会得到一个crowbar(Windows 下是crowbar.exe)可执行文件,这就是解释器本体。拿自带的测试脚本跑一遍:
# 从工程根目录执行,脚本路径相对于当前目录 ./crowbar test/test.crb能正常输出结果,说明词法、语法、求值、输出这条主链路是通的。如果报的是语法错误而不是崩溃,先确认脚本文件的编码和换行符——后面避坑那章会讲这个坑,它在这套源码里出现的频率相当高。
4.4 Diksam 的两个入口再确认一次
Diksam 的编译入口分裂问题值得单独再强调一遍,因为它浪费的时间最多:
# 0_1 / 0_2:进 compiler cd diksam_book_0_1/compiler gmake # 0_3 / 0_4:进 main cd diksam_book_0_3/main gmake进错目录的报错是gmake: *** No targets specified and no makefile found. Stop.,这条信息的意思是「当前目录没有 Makefile」,不是「工具没装好」。看到它,先ls确认所在层级,别急着去重装 MinGW。
5. 避坑:六类容易出错的情况与排查顺序
这套资料在读者手里出现的问题高度集中,基本就六类,而且有固定的排查顺序:先确认工具在不在 PATH,再确认生成文件有没有产出,最后才看链接和运行期。顺序反过来,会在错误的方向上耗掉大半天。
5.1 工具找不到:m4 空格路径、make 未安装、gmake 无目标
现象一:执行 bison 时刷出一串m4: cannot open 'Files': No such file or directory、m4: cannot open '(x86)\GnuWin32\Bison/share/bison': No such file or directory,最后 bison 以失败退出。
原因:bison 装在C:\Program Files (x86)\GnuWin32下,它调用 m4 时把安装路径拼进命令行,m4 按空格切分参数,Program和Files被当成两个独立参数,路径彻底散架。这不是版本问题,装哪个版本都一样。
解决:卸载重装到C:\GnuWin32,或者装完把整个目录移过去,再改 PATH。移动目录之后 bison 内部的路径记录也需要更新,最稳的做法还是重装。
现象二:'make' 不是内部或外部命令,或者'gmake' 不是内部或外部命令。
原因:MinGW 的 bin 目录没进 PATH,或者进了但顺序不对,被别的同名工具抢先。
解决:where gmake确认解析到的是不是D:\MinGW\bin\gmake.exe。如果解析到别处,把 MinGW 的 bin 放到 PATH 最前面。
现象三:gmake: *** No targets specified and no makefile found. Stop.
原因:当前目录没有Makefile。要么进错了层级(Diksam 最常见),要么工程根本没生成Makefile。
解决:ls或dir看当前目录,确认Makefile在不在此层。Diksam 按上一章的两条规则进对应子目录。
5.2 生成文件缺失:y.tab.c 与 y.tab.h 的依赖顺序
现象:编译报gcc: y.tab.c: No such file or directory,同时mycalc.l:3:19: error: y.tab.h: No such file or directory。在 macOS 上用cc lex.yy.c -o example -ll也会撞到同样的错。
原因:flex 生成的lex.yy.c里#include "y.tab.h",而y.tab.h是 bison 的产物。如果构建过程先跑 flex 的产物编译、后跑 bison,或者 bison 本身执行失败了(比如上一节说的 m4 问题),y.tab.h就永远不会出现。这两条报错经常同时出现,但根因只有一个:bison 没成功。
解决:单独手动跑一次 bison,看它自己的报错,而不是看 gcc 的报错。
# 单独执行生成步骤,把 bison 自己的报错暴露出来 bison --yacc -dv crowbar.y # 正常产出的三个文件:y.tab.c、y.tab.h、y.output ls y.tab.*--yacc让 bison 按 yacc 的命名约定输出,-d生成头文件,-v生成y.output便于调文法冲突。三个参数缺一个都可能导致后面的编译失败。bison 跑通之后,再回来看 gcc 的报错,往往就消失了。
5.3 链接期与运行期:-lmsvcp60、cygwin.dll、0x0d 换行
现象一:Linux 下链接报/usr/bin/ld: cannot find -lmsvcp60。
原因:-lmsvcp60是 Windows 那边的 C++ 运行库,Linux 上根本没有对应的.so。这条链接选项对 Linux 构建毫无意义,是从 Windows 工程的构建配置里带过来的。
解决:找到Makefile里的-lmsvcp60并删掉。这类平台专属的链接选项在同一份源码里跨平台构建时基本都要清理一遍,不用纠结有没有副作用,Linux 上它只会带来失败。
现象二:Windows 下编译出的 exe 换台机器就报缺cygwin.dll。
原因:用了 Cygwin 的 gcc 编出来的产物,链接的是 Cygwin 的运行库,不是原生 Windows 可执行文件。
解决:要独立分发就换 MinGW 工具链重新编译;只是本机跑,保证 Cygwin 的 bin 在 PATH 里即可。这个取舍前面说过,没有两全的办法。
现象三:解释器读脚本时报语法错误,错误里的字符码是0x0d。
原因:脚本文件是 Windows 换行(CRLF),词法分析器把\r当成非法字符。这套源码里的词法规则通常只认\n。
解决:转成 Unix 换行再跑。
# 批量把脚本文件从 CRLF 转成 LF dos2unix test/test.crb # 没有 dos2unix 时用 sed 兜底 sed -i 's/\r$//' test/test.crb\r$只匹配行尾的回车符,不会动到内容里的其他字符。转换前建议先备份原文件,或者至少确认是测试脚本而不是正在维护的业务文件。
5.4 源码自身的坑:llparser 里那个没分配缓冲区的 st_line
现象:llparser编译能过,运行时直接崩溃或者读到一堆乱码。看lexicalanalyzer.c里的读行循环:
static char *st_line; /* ... */ while (fgets(st_line, LINE_BUF_SIZE, stdin) != NULL) { parse_line(); }原因:st_line是个未初始化的指针,fgets往一个野指针指向的地址写数据。这属于典型的未定义行为,有时不崩只是运气——内存恰好可写。这段代码能编译通过,是因为 C 编译器不会对未初始化指针报错。
解决:给它一块确定的内存。同时要注意,fgets的第二个参数是缓冲区大小,声明为指针时sizeof(st_line)拿到的是指针大小而不是数组大小,这个陷阱在老代码里反复出现。
/* 改成确定大小的数组,或者用 malloc 分配后再传入 */ static char st_line[LINE_BUF_SIZE]; while (fgets(st_line, sizeof(st_line), stdin) != NULL) { parse_line(); }sizeof(st_line)在数组上算的是整个数组的字节数,这里才是想要的结果。如果你是在读这套资料学实现思路,这个例子值得单独记一笔:编译通过不等于代码正确,尤其是 C 里的指针和缓冲区。
6. 拿 crowbar 0.4 当模板改一门自己的小语言
把源码读通之后,最快的验证方式不是继续看,而是动手改一个能被观察到的行为。我一般从内置函数下手,因为改动面最小,能立刻验证「词法—语法—求值—调用」四层有没有贯通。
在native.c的函数注册表里加一行,指向自己写的实现:
/* native.c:把自定义函数挂进解释器的调用表 */ static NativeFunction native_functions[] = { {"print", nat_print}, {"my_len", nat_my_len}, /* 新增:返回字符串长度,验证调用链路 */ };对应的实现要注意参数类型检查,这套源码的参数传递是按值走的,字符串取长度直接读结构体字段:
/* nat_my_len:参数校验 + 返回长度,返回值放进解释器的返回值寄存器 */ static void nat_my_len(void) { char *s = get_string_arg(0); /* 取第 0 个实参并按字符串校验 */ push_int_value((int)strlen(s)); /* 压回返回值,交由求值器处理 */ }get_string_arg和push_int_value这两个函数名在你自己的版本里可能不同,但语义就这两件事:取参数、压返回值。改完之后重新编译,写一个测试脚本验证:
// test/mylang.crb my_len("hello"); // 期望输出 5跑出 5 之后,再试my_len(123),确认类型检查会报错而不是崩溃。这一步比成功调用更有价值,它验证了错误处理链路也是通的。
改完内置函数,可以再往前走一步:在词法规则里加一个关键字,看它会不会和已有标识符冲突。这是自学自制编程语言时最容易踩的隐形坑——新关键字和原有标识符的首字符集重叠,会导致原本能跑的脚本突然解析失败。
从那以后我每次拿到这类源码包,都会先只编译0_1跑通,再逐版本往上加,而不是直接冲最终版。中间任何一版失败,都能定位到那一版新增的模块上,比在最终版的几千行里翻要省太多时间。希望帮到你。
本文还有配套的精品资源,点击获取