自制编程语言构建链:从y.tab.h消失看Bison/Flex调度契约
简介:本资源是一套面向编程语言学习者与编译原理初学者的《自制编程语言》系统性入门资料,聚焦语言设计、实现与工程实践全过程。内容覆盖语法语义设计原则、基于bison/flex的词法/语法分析器开发、MinGW环境下的编译器构建、Crowbar与Diksam两款真实自制语言的完整演进版本(v0.1–v0.4)及配套源码、Linux/Windows双平台构建脚本(make.sh/make.bat)与运行指南,兼具理论框架与可编译实操案例。资源为单个PDF文件,大小2.38MB,结构清晰,含多版本书籍文档、工具链配置说明及跨平台编译实录,便于按需查阅与本地复现。目前已有870人学习下载,适合高校计算机专业学生、自学者及对编译器开发感兴趣的开发者,快速建立从语言规范到可执行解释器的完整认知链条。
1. 自制编程语言:为什么写个“Hello World”解释器要先搞懂y.tab.h怎么凭空消失?
你刚下载完《自制编程语言》资料包,解压进crowbar_book_0_1目录,满怀期待敲下mingw32-make——结果第一行就报错:No such file or directory: y.tab.h。你翻遍crowbar.y和crowbar.l,里面确实有#include "y.tab.h",但整个目录树里根本找不到这个文件。更玄学的是:有人在 Linux 下make一次成功,同份代码在 Windows 上却卡死在bison --yacc -dv crowbar.y这一步,连进程都启动不了,错误码e=2(系统找不到指定文件)像句冷笑话——bison 明明装好了,它到底在找谁?
这不是环境配置的偶然翻车,而是所有手工构建解释器绕不开的生成式依赖黑洞:y.tab.h不是人写的,是 bison 根据语法规则自动生成的;而它的生成,又强依赖于bison可执行文件能否被make正确调起、能否读取到正确的.y文件路径、能否避开 Windows 路径空格和中文字符的陷阱。这份资料包里混着 Crowbar(0.1–0.4)、Diksam(0.1–0.4)、llparser、mycalc 等多个演进版本,每个版本的 Makefile 都在试探不同平台对“生成器链”的容忍边界。它暴露的不是你的 make 技能短板,而是编译器构造中一个被教科书刻意弱化的真相:词法/语法分析器不是“写出来”的,是“调度出来”的——而调度本身,就是第一道运行时门槛。搞懂y.tab.h为何消失,等于拿到了打开自制语言黑匣子的第一把钥匙:它不指向语法,而指向构建流程的底层契约。
2. Bison/Flex 构建链:从.y/.l到可执行解释器的四层调度契约
要让crowbar.crb跑起来,必须让crowbar.exe编译成功;而crowbar.exe的源码,又由y.tab.c(语法分析器)、lex.yy.c(词法分析器)、main.c(驱动逻辑)等组成。这三者并非平级存在——y.tab.c和lex.yy.c是派生源码,它们的生成过程,构成了整条构建链最脆弱也最关键的调度层。这份资料包的实操价值,正在于它用真实失败日志还原了这四层契约如何逐层断裂。
2.1 第一层契约:bison 必须被make正确识别为可执行命令
Makefile中典型规则如下:
y.tab.h: crowbar.y bison --yacc -dv crowbar.y表面看是“用 bison 处理 .y 文件”,但make实际执行的是CreateProcess(NULL, "bison --yacc -dv crowbar.y", ...)。这里藏着三个致命假设:
- 路径解析假设:
make默认在PATH中搜索bison。Windows 下若安装的是GnuWin32\bison,其路径含空格(如C:\Program Files\GnuWin32\bison),make会将Program当作命令、Files\GnuWin32\bison当作参数,直接触发m4: cannot open 'Files': No such file or directory——这是日志里高频出现的e=2根源。 - 二进制兼容假设:MinGW 的
mingw32-make.exe期望调用 MinGW 兼容的bison。若混用 Cygwin 的bison(依赖cygwin.dll),在纯 MinGW 环境下会因 DLL 缺失而静默失败。 - 工作目录假设:
bison crowbar.y要求当前目录存在crowbar.y。资料包中多个版本(如crowbar_book_0_2)的Makefile写的是bison --yacc -dv crowbar.y,但实际crowbar.y可能在src/子目录——make执行时 pwd 错位,bison就真的“找不到文件”。
提示:验证方法不是
which bison,而是mingw32-make -d | grep bison查看 make 解析出的完整命令字符串。真正的调试起点,永远是 make 的调度日志,而非编译器输出。
2.2 第二层契约:bison 与 flex 的协同必须通过头文件精确对齐
crowbar.l(Flex 词法文件)开头有:
%{ #include "y.tab.h" // ← 关键依赖 %}而crowbar.y(Bison 语法文件)结尾有:
%{ #include "crowbar.h" %} %% // 语法规则这里形成一个隐式约定:bison生成的y.tab.h必须声明所有语法单元(token)的#define常量(如YYTOKENTYPE中的TOKEN_NUMBER),且这些常量名必须与crowbar.l中%token声明完全一致。资料包中crowbar_book_0_3的crowbar.y有:
%token TOKEN_NUMBER TOKEN_STRING TOKEN_IDENTIFIER但crowbar_book_0_1的crowbar.l却写:
{NUMBER} { yylval.number = atoi(yytext); return TOKEN_NUM; }TOKEN_NUM与TOKEN_NUMBER不匹配 →y.tab.h里没有TOKEN_NUM定义 →lex.yy.c编译时报unknown token。这种不一致在跨版本资料中普遍存在,因为作者在迭代中修改了 token 命名,却未同步更新所有.l文件。
2.3 第三层契约:生成代码必须与运行时环境 ABI 兼容
crowbar使用 MinGW 编译,目标是 Windows PE 格式可执行文件。但其依赖的oniguruma正则库(用于字符串处理)在crowbar_book_0_4中需手动编译:
./configure make make install # ← 这步在 Windows 下极易失败make install默认将libonig.a拷贝到/usr/local/lib,但 MinGW 的链接器gcc搜索路径是C:\MinGW\lib。若make install执行时 pwd 是C:\onig-5.9.4,而libonig.a实际生成在C:\onig-5.9.4\.libs\,ranlib命令又写成ranlib /usr/local/lib/libonig.a(Linux 路径),就会触发No such file错误。更隐蔽的是:onig-5.9.4的configure脚本检测到 Windows 环境后,会尝试链接msvcp60(VC6 运行时),但 MinGW 工具链根本不认识这个库,导致ld: cannot find -lmsvcp60——这是crowbar_book_0_4在 Linux 编译时的典型报错,根源是跨平台构建脚本未做 ABI 分支判断。
2.4 第四层契约:解释器主循环必须规避未初始化内存的幽灵指针
crowbar_book_0_1的lexicalanalyzer.c中有一段经典 BUG:
static char *st_line; // ← 未初始化!全局指针默认为 NULL while (fgets(st_line, LINE_BUF_SIZE, stdin) != NULL) { // ← 向 NULL 写入! parse_line(); }st_line是static变量,C 标准规定其初始值为NULL。fgets(NULL, ...)会导致段错误或静默崩溃。正确写法应是:
char buf[1024]; // 栈上分配确定大小缓冲区 while (fgets(buf, sizeof(buf), stdin) != NULL) { st_line = buf; // 让 st_line 指向有效内存 parse_line(); }这个 BUG 在crowbar_book_0_1中真实存在(见日志 “2014/9/15, lexicalanalyzer.c BUG”),它揭示了一个残酷事实:解释器的健壮性,不取决于语法设计多优雅,而取决于你是否亲手踩过每一块内存分配的坑。crowbar作为教学型解释器,故意保留这类低级错误,逼你直面 C 语言与解释器运行时的原始契约。
3. Crowbar 0.1–0.4 演进图谱:从手写递归下降到自动语法分析的范式迁移
这份资料包最珍贵的不是某个版本能跑通,而是它用 4 个连续版本(0.1→0.4)记录了一次真实的语言实现范式跃迁。Crowbar 并非一蹴而就的成熟项目,而是作者 AlloVince 用 3 年时间(2009–2013)完成的渐进式实验场。理解这个演进,才能明白为什么crowbar_book_0_1还在手写parse_expression(),而crowbar_book_0_4已全面拥抱 Bison/Flex。
3.1 Crowbar 0.1:纯手工解析器——用 C 函数模拟语法树构建
crowbar_book_0_1的核心是parse.c中的parse_statement()和parse_expression()。它采用经典的递归下降解析(Recursive Descent Parsing),例如解析加法表达式:
// crowbar_book_0_1/parse.c Node* parse_expression() { Node* left = parse_term(); // 先解析乘除 while (current_token == TOKEN_PLUS || current_token == TOKEN_MINUS) { Token op = current_token; next_token(); // 消耗 + 或 - Node* right = parse_term(); // 再解析乘除 left = new_binary_node(op, left, right); // 构建 AST 节点 } return left; }这种写法优点是逻辑透明、调试直观;缺点是维护成本指数级增长——每增加一个运算符优先级,就要修改parse_expression()的 while 循环条件。crowbar_book_0_1的Makefile极简(仅gcc *.c -o crowbar),因为它不需要任何生成器,所有解析逻辑都在.c文件里硬编码。
3.2 Crowbar 0.2:引入 Bison/Flex 的初步尝试——但生成器与手写代码并存
crowbar_book_0_2开始引入crowbar.y和crowbar.l,但并未完全放弃手写解析。其Makefile同时编译y.tab.c(Bison 生成)和parse.c(手写),main.c中通过函数指针混合调用:
// crowbar_book_0_2/main.c extern Node* yyparse(); // Bison 生成的入口 extern Node* parse_file(char* filename); // 手写文件解析器 int main(int argc, char** argv) { if (argc > 1) { parse_file(argv[1]); // 优先用手写解析器 } else { yyparse(); // 交互模式用 Bison } }这种混合模式暴露了早期工具链的不信任:作者不确定 Bison 是否能完美处理 Crowbar 的动态特性(如运行时定义函数),所以保留手写 fallback。这也导致crowbar_book_0_2的构建失败率最高——y.tab.h和手写parse.h的 token 定义常发生冲突。
3.3 Crowbar 0.3:Bison/Flex 全面接管——语法描述即实现
crowbar_book_0_3是分水岭。crowbar.y不再是简单计算器语法,而是完整定义了 Crowbar 的全部结构:
%union { struct node* node; int number; char* string; } %token <number> TOKEN_NUMBER %token <string> TOKEN_STRING %type <node> program statement_list statement expression %% program: statement_list { $$ = $1; } statement_list: /* empty */ { $$ = new_empty_list(); } | statement_list statement { $$ = append_to_list($1, $2); } ;Bison 根据%union和%type自动生成类型安全的 AST 构建代码,y.tab.c中的yyparse()直接返回struct node*。此时parse.c被彻底删除,crowbar_book_0_3的Makefile变得标准:
crowbar: y.tab.o lex.yy.o main.o gcc -o crowbar y.tab.o lex.yy.o main.o -lonig y.tab.o: y.tab.c gcc -c y.tab.c y.tab.c y.tab.h: crowbar.y bison -d crowbar.y # -d 生成 .h 文件这个版本的成功,标志着作者完成了从“用 C 实现语言”到“用语法描述定义语言”的认知升级。
3.4 Crowbar 0.4:运行时优化与 GC 引入——解释器进入生产级思考
crowbar_book_0_4的核心变化在memory/目录,引入了标记-清除垃圾回收器(Mark-Sweep GC)。其gc.c中的关键逻辑:
void gc_mark() { // 从根集合(全局变量、栈帧)开始遍历 mark_object(global_env); mark_object(current_frame); // 遍历所有已分配对象 for (Object* obj = heap_start; obj < heap_end; obj = obj->next) { if (obj->marked) { mark_object(obj->car); // 递归标记引用对象 mark_object(obj->cdr); } } } void gc_sweep() { for (Object* obj = heap_start; obj < heap_end; obj = obj->next) { if (!obj->marked) { free_object(obj); // 回收未标记对象 } else { obj->marked = 0; // 清除标记位 } } }GC 的引入,使 Crowbar 从“玩具解释器”迈向“可用语言”——用户不再需要手动free()字符串或闭包。但这也带来了新约束:crowbar_book_0_4的Makefile必须确保gc.c在main.c之前编译,因为main.c中的init_heap()依赖gc.c的全局变量heap_start。这种运行时依赖,比语法生成依赖更难调试,它要求你真正理解解释器的内存生命周期。
4. 跨平台构建避坑指南:Windows 下 MinGW/Cygwin 混合环境的 5 个血泪现场
这份资料包的构建日志,本质是一份 Windows 开发者在 2009–2015 年间与 POSIX 工具链搏斗的编年史。MinGW 和 Cygwin 的哲学差异,直接决定了你是在调试代码,还是在调试构建系统本身。以下是基于真实日志提炼的 5 个高频翻车点,每一条都对应一个具体 commit 或 issue。
4.1 现象:bison --yacc -dv crowbar.y报process_begin: CreateProcess failed. e=2
原因:mingw32-make.exe调用bison.exe时,bison.exe依赖m4.exe,而m4.exe的路径含空格(如C:\Program Files\GnuWin32\m4.exe)。WindowsCreateProcess将空格视为参数分隔符,导致m4启动失败,进而使bison静默退出。
解决:
- 方案 A(推荐):卸载 GnuWin32,改用 MinGW 自带的
bison(通过 MinGW Installation Manager 安装msys-bison包); - 方案 B:将
GnuWin32安装到无空格路径(如C:\tools\gnuwin32),并将其bin目录加入PATH; - 方案 C:在
Makefile中显式指定bison路径:BISON = "C:/tools/gnuwin32/bison/bin/bison.exe" y.tab.h: crowbar.y $(BISON) --yacc -dv crowbar.y
4.2 现象:mycalc.l:3:19: error: y.tab.h: No such file or directory
原因:flex生成lex.yy.c时,#include "y.tab.h"的相对路径错误。crowbar_book_0_1的Makefile假设y.tab.h与mycalc.l同目录,但bison默认将y.tab.h生成在当前目录,而flex执行时 pwd 可能是src/子目录。
解决:
- 在
mycalc.l头部添加-I.选项,强制flex在当前目录查找头文件:%option noyywrap %{ #include "y.tab.h" // 确保 y.tab.h 已生成 %} - 修改
Makefile,确保bison和flex在同一目录执行:y.tab.h: crowbar.y cd $(dir $<) && bison --yacc -dv $(notdir $<) lex.yy.c: mycalc.l y.tab.h cd $(dir $<) && flex $(notdir $<)
4.3 现象:/usr/bin/ld: cannot find -lmsvcp60(Linux 下编译 Crowbar 0.4)
原因:oniguruma库的configure脚本检测到gcc版本较旧,错误地认为需要链接 VC6 运行时(msvcp60),但 Linux 系统根本没有这个库。
解决:
- 在
onig-5.9.4/目录下,编辑configure文件,搜索msvcp60,注释掉相关链接逻辑; - 或跳过
make install,直接将onig-5.9.4/.libs/libonig.a拷贝到crowbar_book_0_4/lib/,并在crowbar_book_0_4/Makefile中指定:LDFLAGS = -L./lib -lonig
4.4 现象:debs/euc_jp.Plo:1: *** missing separator(Cygwin 下make install失败)
原因:oniguruma的Makefile.in模板使用 GNU Autotools 生成,其中debs/目录下的.Plo文件是依赖规则文件,其语法要求 TAB 字符缩进。Windows 编辑器保存的文件可能将 TAB 转为空格,导致make解析失败。
解决:
- 在 Cygwin 终端中,用
dos2unix转换所有.Plo文件:find . -name "*.Plo" -exec dos2unix {} \; - 或重新生成
Makefile:在onig-5.9.4/目录下执行autoreconf -fiv(需先安装autoconf、automake)。
4.5 现象:crowbar运行时报Error: invalid character (0x0d)(Windows 下读取.crb文件)
原因:Windows 文本文件行尾是CRLF(\r\n),而crowbar的词法分析器将\r(ASCII 0x0d)识别为非法字符。crowbar_book_0_1的lexer.c未过滤\r。
解决:
- 在
crowbar_book_0_1/lexer.c的next_char()函数中添加过滤:int next_char() { int c = fgetc(input_file); if (c == '\r') { // 跳过 Windows 回车符 c = fgetc(input_file); if (c != '\n') ungetc(c, input_file); // 若非 \n,则放回 } return c; } - 或统一用
dos2unix处理所有测试文件:dos2unix test/test.crb。
5. 从 Crowbar 到 Diksam:语法驱动与语义驱动的设计取舍之辩
当crowbar_book_0_4的 GC 机制让你开始思考对象生命周期时,diksam_book_0_1的出现,像一记清醒剂:它用完全不同的技术栈,实现了几乎相同的语言特性。Diksam 不是 Crowbar 的升级版,而是作者对“如何构建语言”这一问题的第二次独立求解。对比二者,你能看清两种根本不同的设计哲学——一种以语法为中心,一种以语义为中心。
5.1 Crowbar:Bison/Flex 优先——语法即权威,语义靠后置补丁
Crowbar 的整个架构,是围绕 Bison 生成的语法分析器展开的。crowbar.y定义了所有合法结构,yyparse()返回 AST,后续的execute.c和eval.c负责遍历 AST 执行。这种设计的好处是语法变更极其直观:想加个for循环?只需在crowbar.y里加一条规则,Bison 自动更新y.tab.h和y.tab.c。但代价是语义逻辑被割裂:变量作用域检查在create.c,函数调用在execute.c,错误处理在error.c。当你调试undefined variable错误时,要横跨 3 个文件追踪env参数的传递。
Diksam 则反其道而行之。diksam_book_0_1的核心是parser.c中的手写 LL(1) 解析器:
// diksam_book_0_1/parser.c Node* parse_statement() { switch (peek_token()) { case TOKEN_IF: return parse_if_statement(); case TOKEN_WHILE: return parse_while_statement(); case TOKEN_FOR: return parse_for_statement(); // ← for 语法在此处定义 default: return parse_expression_statement(); } }parse_for_statement()函数内部,不仅构建 AST,还立即进行语义检查:
Node* parse_for_statement() { expect(TOKEN_FOR); Node* init = parse_expression(); // 初始化表达式 expect(TOKEN_SEMICOLON); Node* cond = parse_expression(); // 条件表达式 expect(TOKEN_SEMICOLON); Node* step = parse_expression(); // 步进表达式 expect(TOKEN_LBRACE); Node* body = parse_block(); // 循环体 expect(TOKEN_RBRACE); // 语义检查:初始化表达式不能是声明(Diksam 不允许 for(int i=0;...)) if (init->type == NODE_VAR_DECL) { error("for loop initializer cannot be a variable declaration"); } return new_for_node(init, cond, step, body); }这里,语法解析(expect())和语义检查(error())交织在同一函数中。好处是错误定位精准:for循环的语义违规,在解析阶段就被捕获,无需等到执行时;坏处是语法扩展成本高:每加一个语法结构,都要手写解析+语义检查逻辑,无法复用 Bison 的自动化能力。
5.2 工具链选择:MinGW 的轻量与 Cygwin 的完备,本质是抽象层级之争
Crowbar 全系使用 MinGW,目标是生成原生 Windows PE 文件,不依赖任何第三方 DLL。这决定了它必须自己实现所有底层功能:crowbar_book_0_1的util.c里有手写的strdup()、strndup();string_pool.c实现了字符串驻留(String Interning);memory/目录下是完整的堆管理器。这种“造轮子”不是为了炫技,而是 MinGW 的哲学——最小化外部依赖,最大化对二进制的控制权。
Diksam 则拥抱 Cygwin。diksam_book_0_1的Makefile直接调用gcc,链接cygwin1.dll:
diksam: $(OBJECTS) gcc -o diksam $(OBJECTS) -lcrypt -lregex-lcrypt和-lregex是 Cygwin 提供的标准库。这意味着 Diksam 可以直接使用crypt()加密函数、regcomp()正则引擎,无需像 Crowbar 那样集成oniguruma。但代价是:生成的diksam.exe必须与cygwin1.dll共存,无法脱离 Cygwin 环境运行。这种选择,反映了作者对“开发效率”与“部署简洁性”的权衡——Crowbar 为部署而生,Diksam 为开发而生。
5.3 语言特性的实现差异:从“能跑”到“好用”的工程鸿沟
两者都支持闭包(Closure),但实现方式揭示了深层差异。Crowbar 的闭包在native.c中定义:
// crowbar_book_0_4/native.c typedef struct { Env* env; // 捕获的环境 Node* body; // 函数体 AST char* name; // 函数名 } Closure; Value* execute_closure(Closure* closure, Value** args) { Env* new_env = create_child_env(closure->env); // 创建新环境 bind_args(new_env, closure->body, args); // 绑定参数 return execute_node(closure->body, new_env); // 执行函数体 }这是一个典型的解释器风格闭包:运行时动态创建环境,AST 节点直接执行。它灵活,但慢——每次调用都要遍历 AST。
Diksam 的闭包在vm.c中,采用字节码风格:
// diksam_book_0_1/vm.c typedef struct { Env* env; int code_offset; // 字节码起始偏移 int num_locals; // 局部变量数 } DiksamClosure; Value* run_closure(DiksamClosure* cl, Value** args) { // 将闭包环境、参数压入虚拟机栈 push_stack(cl->env); for (int i = 0; i < cl->num_locals; i++) { push_stack(args[i]); } // 跳转到 code_offset 执行字节码 vm_execute(cl->code_offset); }Diksam 先将 AST 编译为字节码(compiler/目录),再由虚拟机执行。这增加了编译阶段复杂度,但换来执行时的性能提升。当你在diksam_book_0_2中看到compiler.c里大量emit_opcode(OP_LOAD_LOCAL)调用时,你就站在了从解释器到虚拟机的临界点上。
从 Crowbar 到 Diksam,不是版本迭代,而是设计范式的平行探索。Crowbar 教你如何用工具链快速验证语法想法,Diksam 教你如何用工程思维打磨语义细节。我第一次完整跑通crowbar_book_0_1时,兴奋地写了 20 行测试代码;但当我试图给diksam_book_0_1加一个try/catch语法时,花了整整三天才让parser.c和vm.c的异常传播逻辑对齐——那三天,我反复重读diksam_book_0_1的error.h,发现它的错误码设计竟与 Linux errno 完全一致(ERR_SYNTAX=1,ERR_RUNTIME=2),这种对 Unix 哲学的虔诚,远比语法糖深刻得多。
希望帮到你。
本文还有配套的精品资源,点击获取