简介:这份资料面向希望从零理解编程语言原理、并动手实现小型语言的开发者与学习者,围绕语言设计、编译与解释实现、工程化使用展开,适合具备一定 C 语言和数据结构基础、想补足编译器构造经验的中级读者。压缩包内仅有 1 个 PDF 文件,大小约 2.38MB,篇幅集中,便于通读和按主题查阅。内容把语法语义设计、词法/语法分析工具链、运行时环境与跨平台构建串成实践线索,并结合 Crowbar、Diksam 等教学型语言案例,说明从语言设计到生成可执行程序的完整路径;同时讨论 MinGW、bison/flex 在 Linux 与 Windows 下的环境差异及资源管理问题。已有 870 人学习,可作为编译原理课程辅助、自学案例读本,或为后续实现解释器、梳理构建流程提供参考。
1. 解压后最先撞见的是 .l 与 .y,而不是文档
解压一套自制编程语言的资料,最先出现在你眼前的是几个.l和.y文件。很多人对「造一门编程语言」的想象停留在语法设计,觉得把关键字、运算符、语句形式凑齐就算完成;真正动手才会发现,语法只占两成工作量,剩下八成全是词法状态机、执行树、字符串驻留、垃圾回收这些不显眼的机制。这套资料的价值在于它把每个阶段的快照都留了下来:同一门语言从只能跑算术表达式,到能定义函数、能自动回收内存,每一步都对应一个可独立编译的目录。读懂它,你手里就有了拆解任意解释器的螺丝刀。
2. 从文本到执行树:解释器的骨架怎么搭起来
2.1 .l 与 .y 的分界线:谁决定「词」,谁决定「句」
自制语言的工程结构里,crowbar.l和crowbar.y是最先要看懂的两个文件,编译日志里lex.yy.c与y.tab.c的出现顺序已经说明了它们的关系。.l文件交给 flex 生成词法分析器,它的职责是把字节流切成 token:这个字符序列是标识符、是数字字面量、还是一个==。.y文件交给 bison 生成语法分析器,它的职责是判断这一串 token 组成的形状是否合法,并把它们组装成树。两者的边界很清晰:词法层面能判定的东西,绝不要留给语法层面。比如1.5e3是一个浮点字面量,这是词法问题;而a + b * c的优先级,这是语法问题。
优先级是最容易出问题的地方。很多人第一次写 bison 规则会把优先级直接写进规则的嵌套层次里,靠规则的递归形状来强制结合顺序,结果规则数量增加得非常快,也很难调试。更常见的做法是用%left/%right声明,声明的先后顺序本身就是优先级从低到高:
/* crowbar.y 片段示意:优先级靠声明顺序表达,而不是靠规则嵌套 */ %token IF ELSE WHILE FOR RETURN %token EQ NE LE GE LOGICAL_AND LOGICAL_OR %left LOGICAL_OR /* 最低:同层左结合 */ %left LOGICAL_AND %left EQ NE /* 相等比较比逻辑运算紧 */ %left '<' '>' LE GE %left '+' '-' %left '*' '/' '%' %right UNARY_MINUS /* 假 token,只用来给一元负号定级 */ %% expression : expression '+' expression | expression '-' expression | expression '*' expression | '-' expression %prec UNARY_MINUS /* 关键:一元负号必须单独定级 */ | '(' expression ')' ;这段规则里有两个机制点需要解释。第一,%left的顺序决定了冲突时是归约还是移进,bison 在遇到a + b * c时会先看*的优先级高于+,于是把b * c先归约掉,不需要你写两条不同的规则。第二,一元负号必须用%prec手动指定优先级,否则-a * b会被解析成-(a * b)还是(-a) * b完全取决于默认策略,这类 bug 极难通过阅读代码发现,只会表现为算错数。
词法侧真正的难点是状态。crowbar.l里出现过%start COMMENT STRING_LITERAL_STATE这样的起始条件声明,意思是词法分析器不是一台永远处于同一状态的机器,它在读注释和读字符串时要切换到独占状态:
/* crowbar.l 片段示意:注释与字符串必须用起始条件隔离 */ %x COMMENT %x STRING_LITERAL_STATE %% "/*" { BEGIN COMMENT; } <COMMENT>"*/" { BEGIN INITIAL; } <COMMENT>.|\n { /* 注释内容整体丢弃,不产生 token */ } <COMMENT><<EOF>> { /* 未闭合注释:这里必须报错,不能静默回 INITIAL */ } \" { BEGIN STRING_LITERAL_STATE; } <STRING_LITERAL_STATE>\\n { /* \" 这类转义在这里处理,而不是在主状态 */ } <STRING_LITERAL_STATE>\" { BEGIN INITIAL; }%x声明的是独占起始条件,只有带<COMMENT>前缀的规则才会在注释状态里生效,主状态的规则全部失效。没有这一层隔离,"a /* b"这种字符串里的/*会被当成注释开头,整个文件后半段的内容会被静默丢弃;而<COMMENT><<EOF>>这条兜底规则经常被漏掉,导致文件末尾一个没闭合的注释被当成正常结束,编译器一声不响地接受了一份残缺的源码。这类问题属于典型的黑匣子故障:程序不崩、不报错,只是行为不对。
2.2 create.c:把语法树翻译成执行树,而不是就地执行
拿到语法树之后,最短路径是递归遍历边算边执行。不少教学实现就是这么做的,代码量小,但解释执行的每一层都要回头查变量名、查函数名。这份资料里的做法多走一步:create.c负责把语法树翻译成一套更适合执行的「执行树」,execute.c执行语句,eval.c求值表达式。三者分工的动机是「编译期能做完的事不要留到运行期」。
执行树节点的结构大致是这个形状:
/* 执行树节点示意:类型标签 + 联合体,避免运行时反复做字符串比较 */ typedef enum { INT_EXPRESSION, DOUBLE_EXPRESSION, STRING_EXPRESSION, IDENTIFIER_EXPRESSION, ASSIGN_EXPRESSION, BINARY_EXPRESSION } ExpressionType; typedef struct Expression { ExpressionType type; union { int int_value; double double_value; char *string_value; struct { char *name; int index; } identifier; /* index 是提前查好的槽位 */ struct { struct Expression *left, *right; } binary; } u; } Expression;关键在identifier里的index。在create.c阶段,解释器已经知道这个变量属于哪个作用域、在变量表里排第几位,于是把序号直接写进节点。到了eval.c执行时,取值变成一次数组下标访问,而不是在哈希表里按名字查。这是编译器领域最朴素也最有效的一条优化:把符号解析从运行期挪到编译期。type标签加union的组合也是刻意为之——动态类型语言在运行期必须知道「这个值现在是什么类型」,用标签判断比用strcmp比类型名快几个数量级。
分层还带来一个副作用上的好处:eval.c里的求值函数可以被execute.c调用,也可以被native.c里的原生函数回调。原生函数(比如打印、字符串拼接这类由 C 实现、暴露给脚本使用的函数)需要拿到参数值,也需要返回值给脚本,只有求值器独立成模块,这种双向调用才不会写成一团。
2.3 interface.c 与 main.c:为什么中间要隔一层
编译日志里能看到main.c、interface.c、native.c、error.c、util.c、memory/一串文件,几乎每个都对应一个明确的职责切面。main.c只做三件事:解析命令行参数、初始化运行时、决定进哪种模式。真正把「从文件读一整个脚本执行」和「从标准输入一行一行读、边读边执行」区分开的,是interface.c。
这层隔离的意义在排查问题时特别明显。同一份 lexer 和 parser,批处理模式下错误信息里应该带文件名和行号,交互模式下应该只带行号,甚至可以把出错的那一行原文打出来加个插入符。如果这两条路径写在一起,错误信息要么啰嗦要么缺失。更要紧的是交互模式下的状态复位:一次求值失败之后,词法分析器的起始条件、语法分析器的状态栈、变量表里半成品对象,都得回到干净状态,否则下一次输入必然连锁失败。interface.c存在的理由就是把这些「一次会话的生命周期管理」收在一处,让内核保持无状态。
顺带说一句error_message.c。把错误文案单独放一个文件,看起来是小事,实际上是把「错误信息也是产品的一部分」写进了工程结构。一个只会输出syntax error的解释器和一个能告诉你「第 12 行第 8 列,=左边不是可赋值的目标」的解释器,学习成本差一个量级。
3. 内存:字符串池和标记清除各自解决什么问题
3.1 string.c 与 string_pool.c 拆开写的理由
这两个文件被拆开,不是为了好看。string.c管单个字符串对象的创建、拼接、长度与内容访问;string_pool.c管的是一个「全局字符串登记表」,也就是字符串驻留(interning)。机制很简单:所有字符串字面量、所有由变量名转换来的字符串,都先在这个池子里查一遍,存在就复用同一块内存,只把引用计数加一。
/* 字符串池查找示意:先比 hash,再比长度,最后才比内容 */ typedef struct StringPoolEntry { unsigned int hash; /* 建表时算好,查找时直接比,避免重复扫描 */ int len; char *str; int ref_count; /* 常见的实现思路是用计数决定能否释放 */ struct StringPoolEntry *next; } StringPoolEntry; StringPoolEntry *pool_intern(StringPool *pool, const char *src, int len) { unsigned int h = calc_hash(src, len); StringPoolEntry *e = pool->buckets[h % pool->bucket_size]; for (; e != NULL; e = e->next) { if (e->hash == h && e->len == len && memcmp(e->str, src, len) == 0) { e->ref_count++; return e; /* 命中:复用同一块内存 */ } } return pool_add(pool, src, len, h); /* 未命中:新建并挂到桶上 */ }收益有两块。内存上,一个脚本里出现几十次同名变量、同样的字符串字面量,堆上只有一份。速度上,字符串相等判断退化成指针相等——因为是同一份对象,a == b只要比地址就行,不用逐字节memcmp。对于把变量名当键的语言实现来说,这一条几乎决定了整体性能。代价是字符串池里的对象天然是「长寿命」的,什么时候能回收就成了 GC 必须回答的问题。
3.2 标记清除:root 从哪里开始扫
GC 的实现藏在memory/这个子目录里,编译日志里那句cd ./memory; gmake.exe;说明它是作为独立模块被单独构建的。标记清除的核心不是标记算法本身,而是根集合怎么确定。对于一门跑在 C 上的解释器,根只有三类:全局变量表里的对象、当前调用栈上各层函数的局部变量、以及求值过程中还挂在执行树上、尚未写回变量的中间值。第三类最容易漏。
/* 标记阶段示意:必须知道每种节点里哪些字段是指针,才能往下递归 */ static void mark_expression(Expression *expr) { if (expr == NULL || expr->marked) return; expr->marked = 1; switch (expr->type) { case STRING_EXPRESSION: mark_string(expr->u.string_value); /* 堆对象,标记后才能保命 */ break; case BINARY_EXPRESSION: mark_expression(expr->u.binary.left); /* 子表达式也要往下走 */ mark_expression(expr->u.binary.right); break; case ASSIGN_EXPRESSION: mark_expression(expr->u.assign.right); break; default: break; /* 标量类型不需要标记 */ } }这段代码说明了 C 写 GC 的最大成本:没有反射,GC 不可能自己猜出「这个结构体的第二个字段是不是指针」,只能为每种节点手写一个mark分支。语言里每新增一种表达式、每新增一个持有引用的结构,都得回来补一个 case,漏一个就是悬垂指针,症状是随机崩溃或者输出乱码,属于最典型的后悔药没处买。
第二个设计点是什么时候触发。合理的做法是在对象分配器里累计分配字节数或对象数,超过阈值才启动一轮回收;而不是每次分配都扫一遍。原因很实际:标记清除要暂停整个解释执行,扫描时间和存活对象数量成正比,频繁触发会让一个纯计算脚本的运行时间翻几倍。
3.3 版本快照该怎么读:先看 diff,再看全文
crowbar_book_0_1到crowbar_book_0_4这四个目录不是四个不同的项目,而是同一门语言的四个演化阶段,每个都能独立编译出可执行文件。它们的正确读法是对比着读:先看 0.1 的create.c、execute.c、eval.c弄清执行主干,再直接 diff 0.1 和 0.2,你会发现memory/目录、string_pool.c里多出来的引用计数、create.c里多出来的对象登记调用,全是围绕着「对象什么时候能安全释放」这一个问题长出来的。
这种读法的好处是把「设计动机」还原出来。教材里写「这里需要一个 GC」是一句话,但 diff 会告诉你:GC 加进来之后,字符串池的引用计数逻辑要跟着改,原生函数返回的对象要显式登记成根,执行树的临时节点要保证在安全点上可达。这些连锁反应才是真正值钱的部分,也是通读单版本源码永远看不出来的东西。
4. Diksam:静态类型带来的连锁反应
4.1 类型检查挪到编译期,换来什么、付出什么
diksam_book_0_1到diksam_book_0_4对应的是另一门更完整的语言。和前面那门动态类型的相比,它最本质的差别是类型在编译期就确定了:变量有声明类型,函数有参数类型和返回类型,调用点要能匹配签名。这个决定会沿着整条流水线一路传导。
先是符号表。动态类型语言的作用域里存的是「名字 → 槽位号」,静态类型语言必须存「名字 → 槽位号 + 类型」。然后是create.c阶段的表达式检查:a + b里a是整数、b是字符串,这个组合在编译期就该被拒绝,而不是等到运行时抛出类型错误。常见的实现思路是给每种运算符配一张类型组合表,或者写一组check_binary_add()之类的函数,按左右操作数的类型分派。
/* 编译期类型检查示意:不通过就直接报错,不生成执行树节点 */ static Expression *create_binary_add(Expression *left, Expression *right) { if (left->type == INT_EXPRESSION && right->type == INT_EXPRESSION) { return new_int_binary(ADD_EXPRESSION, left, right); /* 整数加法 */ } if (is_numeric(left) && is_numeric(right)) { return new_double_binary(ADD_EXPRESSION, left, right); /* 提升为浮点 */ } if (left->type == STRING_EXPRESSION && right->type == STRING_EXPRESSION) { return new_string_concat(left, right); /* 字符串拼接是另一个节点 */ } compile_error("无法对 %s 和 %s 做加法", type_name(left), type_name(right)); return NULL; }开销是代码量翻倍:动态类型语言里一个eval_binary函数能处理所有组合,静态类型语言要在编译期把组合穷举一遍,再为每种组合生成专用节点。收益是运行时不用装箱猜类型,执行树的节点类型固定,execute阶段的分支预测更友好;更重要的是错误提前暴露,用户不必跑一遍全流程才发现某条分支写错了类型。
4.2 compiler/ 与 main/:目录命名泄露的架构分层
构建时的路径差异值得单独看一眼。早期版本的make在compiler目录里执行,后期版本挪到了main目录。这不是随意的目录改名,而是分层变化的外部表现:编译器主体(词法、语法、执行树、执行器)作为一个库存在,main只负责驱动程序——解析参数、读文件、调用编译器、报告错误。早期把驱动和主体混在一起,后期拆开,正是「让内核可复用」的一步。
# 版本不同,进入的目录不同,走错就是 "No targets specified and no makefile found" cd diksam_book_0_1/compiler && gmake # 0_1 / 0_2:编译主体与驱动混在一起 cd diksam_book_0_3/main && gmake # 0_3 / 0_4:驱动独立,主体作为库链接No targets specified and no makefile found这个报错听着像工具没装好,十有八九是你在错误的目录下敲了make。判断方法很简单:先ls看当前目录有没有Makefile,没有就往下一层找,别急着去改环境变量。
4.3 手写 LL 解析与生成式 LALR:两条路线的取舍
同一份资料里同时提供了llparser/llparser_ex和mycalc/mycalc_ex两条线,前者是手写递归下降,后者用 yacc/lex 生成。这个对照非常刻意,因为它让你在同一批语法上体验两种实现风格。
| 维度 | 手写递归下降(LL) | 生成式 LALR(yacc/bison) |
|---|---|---|
| 语法改动成本 | 改一个函数,随手调 | 改规则,重新生成表,可能引出新冲突 |
| 错误信息质量 | 可以精确到「期望),实际是,」 | 默认只有syntax error,要靠%error-verbose或自定义 |
| 左递归 | 不支持,要改写成循环 | 天然支持,反而是右递归更费栈 |
| 调试方式 | 断点单步,栈帧就是语法层级 | 看.output里的状态机,理解移进归约 |
| 适合场景 | 语句结构规整、要精细报错 | 表达式优先级复杂、规则多且频繁变 |
表格背后的判断标准其实只有一条:你更怕语法写错,还是更怕报错难懂。手写解析器的错误恢复可以做得很细,因为它知道自己在解析哪个语句的哪个部分;生成式解析器的错误恢复依赖插入假 token 重同步,写出好信息需要额外功夫。资料里两套并存,正好给你一个横向对照的机会。
5. 避坑与排查:编译期和运行期各有一批问题
5.1 工具链上的三个坑
现象一:gmake: *** No targets specified and no makefile found. stop,或者敲make毫无反应。原因通常不是没装工具,而是两件事叠加:一是 Windows 下 MinGW 提供的是mingw32-make.exe,名字不叫make,Makefile 里写的make找不到;二是当前目录根本没有Makefile,版本目录层层嵌套,很容易站错位置。解决分两步:先把mingw32-make.exe复制或重命名为make.exe放进bin目录,或者在脚本里显式调用mingw32-make;再用ls确认Makefile就在当前目录。
现象二:m4: cannot open 'Files': No such file or directory,后面还跟着cannot open '(x86)\GnuWin32\Bison/share/bison'。原因很明确:bison 装在D:\Program Files (x86)\GnuWin32\Bison这种带空格的路径下,它调用 m4 时拼出的命令行没有给路径加引号,空格把参数切成了两段。解决方式是把工具链装到无空格路径(比如D:\GnuWin32),并且确认PATH里没有残留旧路径。这是一个不看报错原文就永远猜不到的坑,因为报错文本里的Files、Program就是路径被切断的残骸。
现象三:ranlib.exe: '/usr/local/lib/libonig.a': No such file。这类失败发生在编译正则库的时候,configure用的是 Cygwin 的 shell 和 Makefile 约定,make install时却调到了 MinGW 的ranlib。两套环境的路径空间互不相通:Cygwin 眼里的/usr/local/lib是个虚拟根,MinGW 的ranlib完全不认这个路径。解决办法是一条构建链路只用一套工具链,要么全程 Cygwin,要么全程 MinGW,不要一边configure一边换编译器。混用最典型的后果是生成的可执行文件依赖一个额外的运行库,换台机器就启动不了。
5.2 运行期的两个坑
现象一:脚本第一行就报「非法字符 (0x0d)」,位置在第 5 行附近。原因在行尾符:文件是从 Windows 环境保存的 CRLF,词法分析器只把\n当行结束符,\r就成了一个无法识别的字符。这类问题在跨平台搬运测试用例时几乎必现,而且报错位置经常让人误以为问题在第 5 行。解决方式是用dos2unix test/test.crb转一遍行尾,或者在词法规则里把\r显式吃掉。后一种做法更稳妥,因为它让解释器对输入格式宽容,只是要注意别把\r在字符串字面量里也吃掉。
现象二:手写词法分析器一跑就崩,或者读进来的行内容全是乱码。看这段代码:
/* llparser_ex/lexicalanalyzer.c 里的问题写法:指针没有指向任何缓冲区 */ static char *st_line; void lexical_analyze(void) { while (fgets(st_line, LINE_BUF_SIZE, stdin) != NULL) { /* 往野指针里写 */ parse_line(); } } /* 正确写法:要么给静态数组,要么先 malloc 再检查返回值 */ static char st_line[LINE_BUF_SIZE]; void lexical_analyze(void) { while (fgets(st_line, LINE_BUF_SIZE, stdin) != NULL) { parse_line(); } }static char *st_line只是声明了一个指针,它初始化为 NULL,fgets把数据往地址 0 写,行为未定义。有时不崩、只是内容错乱,有时直接段错误,取决于编译器把这块内存放在了哪里——这正是它难查的原因。改成静态数组,或者在fgets之前malloc并判空,问题立刻消失。这个 bug 值得记住的点不是「忘了分配」,而是「C 里声明指针和分配缓冲区是两件事」。
6. 设计取舍:为什么这类实现值得逐行读
把前面几章连起来看,这份资料反复在做同一类决策:把复杂度放在哪个阶段。执行树而不是直接遍历语法树,是把符号查找的复杂度移到了创建期;静态类型而不是运行时判型,是把类型检查的复杂度移到了编译期;字符串驻留而不是每次新建,是把内存管理的复杂度移到了字符串池和 GC 身上。每一次移动都不是免费的,都有对应的代价:代码量增加、编译变慢、GC 需要知道每个结构体的指针布局。判断一个实现好不好,看的不是它选了什么,而是它是否清楚自己付了什么。
选 LALR 还是手写递归下降,也是一笔要算清楚的账。生成式解析器把语法规则和实现代码解耦了,改语法只是改几行产生式,代价是冲突调试要靠状态机输出文件,报错信息要靠额外机制补。手写解析器把两者绑在一起,改语法要动函数,换来的是一行行看得懂的控制流和精确到 token 的报错。教学场景下我偏向手写,因为读代码的人需要看懂「为什么这里要往前看两个 token」;工程场景下要看语法规模,规则一多,手写的维护成本会指数上升。
GC 时机的选择同样是个权衡题。分配阈值触发的标记清除,实现简单、可预测,缺点是会在某个时刻停顿一下,对于交互式解释器来说,用户可能感知到卡顿。换成引用计数能避免长停顿,但处理不了循环引用,还得配一个周期回收器当兜底。这份资料选了前者,是因为教学实现的清晰度优先于响应性,而且解释器本身跑的都是小脚本,停顿时间根本感觉不到。
错误信息的质量是这些取舍里最容易被忽略、却最影响使用体验的一项。把错误文案抽到error_message.c、让create.c在发现类型不匹配时带上「第几行第几列、期望什么、实际是什么」,这些设计在功能列表里一个字都不会提,但它决定了你第一次跑通脚本要用十分钟还是两小时。我一般这么理解:能不能报出好错误,是区分「玩具」和「实现」的分水岭。
如果你打算顺着这套资料往下走,我的建议是不要一上来通读四个版本。先挑一个最小版本把.l、.y、create.c、execute.c四个文件读通,跑一个带函数的脚本;然后直接 diff 到下一版,只看新增的那些与内存和类型相关的行。从那以后我每读一个解释器实现,都强制先把它的memory/目录和错误信息模块翻一遍——因为执行主干往往大同小异,真正体现设计者水平、也真正决定你会不会在里面陷一整天的地方,全在这两个角落。希望帮到你。
本文还有配套的精品资源,点击获取