crowbar 0.1 到 Diksam 0.4:可编译可运行源码实战 简介《自制编程语言》相关资料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.4Diksam 从 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负责交互入口和 REPLcreate.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/gmakediksam_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.batgcc、bison、flex解释器主线逐版本递进diksam_book_0_1、0_2compiler/子目录gcc、bison、flex构建入口与前后的版本不同diksam_book_0_3、0_4main/子目录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没有 flexMakefile里还写死了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 PATHD:\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 内部会调用 m4m4 处理路径时不认引号把空格当参数分隔符于是路径被切成几段报出一串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 分类下勾 m4bison 同样能勾到。但它有个必须提前知道的代价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 onigurumaconfigure 能过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.hy.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 脚本编译成功会得到一个crowbarWindows 下是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跑通再逐版本往上加而不是直接冲最终版。中间任何一版失败都能定位到那一版新增的模块上比在最终版的几千行里翻要省太多时间。希望帮到你。本文还有配套的精品资源点击获取