简介这是一份关于自制编程语言的入门与实战文档适合想了解语言设计、词法/语法分析、解释器与编译器构建的开发者阅读也适合作为编译原理课程设计的参考资料。内容从编程语言的基本设计原则和实现流程切入结合 yacc/lex、bison/flex 等经典工具并围绕 Crowbar 和 Diksam 两个示例语言逐步讲解从 calc 原型、手工递归下降分析到完整解释器/编译器的迭代过程其中 Crowbar 是解释器实现Diksam 是编译器实现。资料细致整理了多个版本的设计取舍包括语法树构建、垃圾回收、语句与表达式的语义处理以及如何在 Linux 和 Windows 下借助 MinGW、Cygwin 和 make 脚本搭建编译环境。整份资源为 PDF 格式共 1 个文件压缩包大小 2.38MB内容集中且便于离线阅读目前已有 869 人浏览学习适合作为编程语言实现方向的自学笔记或参考资料。1. 《自制编程语言》相关资料1到底指什么这本书能带你走到哪一步搜索“《自制编程语言》相关资料1”的人多半不是想找电子书本身而是卡在“书看懂了代码却跑不起来”这个节点上。这本书从零写出一门能用的语言正文会用大量篇幅讲文法、讲求值、讲内存但完整源码往往被拆成片段放在资料里读者手上缺的就是这份配套资料。这篇笔记把“相关资料1”当作一个工程问题来处理先理清这本书要带你实现哪两条技术线再给出一套能照抄的环境构建、资料归档和排错路径最后用一个给虚拟机加指令的实操验证自己是不是真读懂了。适合正在啃这本书、准备拿它做练手项目或课程设计的开发者。2. 先把两条技术线分开树遍历解释器和字节码虚拟机资料1该重点补哪半2.1 为什么这本书要按“资料集”读而不是按章节读这本书最大的特点是正文为了控制篇幅把完整代码拆成了带讲解的片段。你看到的是一个函数的核心逻辑但调用它的入口函数、负责分配内存的辅助代码、打印调试信息的工具函数往往不在正文里。等你想把几个片段拼成一个能跑的程序时才发现缺的东西比书里写的东西还多。所以很多人会搜索“相关资料1”。这个词通常不是出版方规定的命名而是社区里整理资源的人按下载顺序编的号资料1是配套源码资料2是勘误表资料3是演示程序诸如此类。你的首要任务不是把资料1当普通压缩包解压完就扔一边而是把它当成“拼图补齐包”先对照目录摸清它补了哪些正文缺失的部分。我一般会先做三件事第一把资料1的源码目录结构和书的章节标题对一遍第二找到每章末尾那个“能跑的例子”对应的源文件确认它在哪个子目录第三检查有没有 README 或构建脚本如果有说明资料的版本比书新直接按脚本走。这三件事做完你才知道资料1是残缺的开胃菜还是完整的主菜。不要先打开代码文件从头读那是教科书读法工程读法是把入口、构建、测试程序这三个锚点先找出来。2.2 树遍历解释器从Token流到递归求值这本书前半部分实现的语言偏简单采用的执行模型是树遍历tree-walking。整条链路是词法分析把字符串切成Token流语法分析按文法把Token流组装成语法树AST然后执行器递归地访问语法树节点完成求值。没有中间指令没有虚拟机语法树本身就是“可运行的程序”。树遍历解释器最核心的逻辑可以用下面这段极简C代码概括// 极简树遍历求值器核心递归访问语法树节点 int eval(Node *n) { switch (n-kind) { case N_NUM: return n-value; // 数值节点直接返回值 case N_ADD: return eval(n-left) eval(n-right); // 先求左子树再求右子树 default: return 0; } }这段代码的逻辑非常直白遇到数值节点就返回它存着的整数遇到加法节点就递归求出左子树的值、右子树的值然后相加。参数上要注意的是Node结构里的kind字段决定节点类型value保存字面量left和right是左右子树指针。实际书里的节点类型远不止两种但递归结构是同一个套路。读前半部分时你把资料1里的源码打开对照会看到除了eval之外还有eval_binary、eval_if、eval_block这类函数它们都是同一个递归模式的不同实例。这个阶段最容易踩的坑是递归深度测试程序嵌套太深时栈会溢出但教学语言一般不处理这个问题你也不必过度设计。能跑通、能打印正确结果就是合格。2.3 字节码虚拟机先编译后执行的第二口气到这本书后半部分作者抛弃了树遍历换成更接近真实语言的字节码虚拟机方案。这里要理解核心变化语法树不再直接被求值而是先被“编译”成一条条扁平指令比如“把常量5压栈”“弹出两个数相加”“返回栈顶值”。指令存放在一个连续数组里执行器用一个循环不断取指令、解码、分发执行。// 虚拟机主循环取指令、解码、分发 while (1) { OpCode op *ip; // 从指令指针取一条操作码 switch (op) { case OP_CONST: // 把常量压栈 push(const_pool[*ip]); break; case OP_ADD: // 弹出两个数相加后压回 push(pop() pop()); break; case OP_RETURN: return pop(); default: error(未知指令); } }这里ip是指令指针每执行一条指令就往后移一位const_pool是常量表OP_CONST的操作数存的是常量在常量表里的下标push和pop是对操作数栈的封装。整段代码的逻辑是循环不结束除非遇到OP_RETURN。对比树遍历这种设计的性能更好结构上也更接近真实语言运行时。这两条技术线在资料1里的完整程度差异很大。我见过不少人是树遍历部分顺风顺水到字节码虚拟机部分突然断档。原因很简单字节码部分需要同时维护三张表——指令操作码表、常量池、全局变量区以及一个调试用的反汇编输出。正文只讲操作码表其他几样往往只在配套源码里完整出现。判断点树遍历解释器字节码虚拟机执行方式递归访问AST节点循环取指令 操作数栈实现难度低逻辑直白高需要同时管理指令和栈可读性贴近语法结构贴近硬件执行模型性能较差递归开销大较好指令扁平资料1的补全重点补齐被拆散的求值函数补齐指令表、反汇编器和运行时初始化你拿资料1对照时要格外注意后半部分的组织通常源码里会多出一个vm/或类似名称的子目录把指令定义、执行循环、内存管理分开。前端和后端的源码风格截然不同这不是作者水平不稳定而是两种实现模型的天然差异。资料1如果只把树遍历部分的完整代码补齐了后半部分需要你自己从指令表开始整理。3. 把资料1跑成最小工程编译器选型、构建脚本与第一次运行3.1 本地环境三件套编译器、调试器与构建工具动手之前先把工具链确认一遍。这本书的配套源码虽然是C语言写的但“能用IDE打开”和“能复现书里的构建过程”是两回事。我建议用命令行工具链因为资料1里的构建脚本通常面向命令行IDE的工程文件反而容易因为路径配置不同而失败。工具选型建议用途编译器系统自带的标准C编译器支持C99即可编译源码调试器配套的命令行调试器排查段错误、跟踪递归调用构建工具make 或直接用 shell 脚本按依赖关系编译并链接先检查这些工具是否就绪# 检查工具链是否齐备 gcc --version make --version逻辑说明gcc --version会输出编译器版本没输出说明没装或没进 PATHmake --version同理。参数上不需要额外参数这两个命令只是探路。如果你用的系统没有预装用系统的包管理器装一下即可。装好后不要急着编译整个项目先拿一个最小C文件确认编译器能出可执行文件这能帮你把“工具链问题”和“源码问题”分开。3.2 最小构建脚本从源码编译出第一个可执行文件资料1里的源码往往按章节分目录每个目录对应一个阶段性成果。你的目标是先跑通一个最小目标而不是一次编译整个仓库。我通常会写一个简单的Makefile把最常见的编译命令固化下来# Makefile编译资料1里的主程序 CC gcc CFLAGS -stdc99 -g -Wall -Wextra -I./src -I./src/include SRCS $(wildcard src/*.c) OBJS $(SRCS:.c.o) TARGET build/mylang $(TARGET): $(OBJS) mkdir -p build $(CC) $(CFLAGS) $(OBJS) -o $ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -rf build *.o参数说明CC指定编译器CFLAGS里的-stdc99要求按C99标准编译这本书的源码基本是按这个标准写的太老的默认标准可能报错-g生成调试信息后面用调试器排错时必须有它-Wall -Wextra打开常见警告教学代码里警告往往对应真实隐患不要忽略-I./src -I./src/include指定头文件搜索路径这一步是资料1常见的翻车点源码里的 include 路径是按作者的目录结构写的换到你的目录下必须用-I指回去。SRCS $(wildcard src/*.c)的意思是自动收集 src 目录下所有.c文件不用手动列文件名。如果你按书里的章节分阶段学习我建议不要一次编译所有文件而是改成只编译你当前需要的几个源文件比如SRCS src/lexer.c src/parser.c src/main.c。这样每次报错的范围小定位快。3.3 第一次运行用一段小程序验证前端链路编译通过只是第一步。接下来准备一个最小的测试程序内容越简单越好目的是验证“源码读入→词法分析→语法分析→求值”这条链路的每一环都没有问题。我自己习惯用带打印语句的算术表达式// 第一个测试程序声明变量并打印 print 1 2 * 3;然后用编译产物运行它# 运行自制语言解释器输入测试程序 ./build/mylang programs/hello.ml逻辑说明./build/mylang是上一步生成的解释器可执行文件programs/hello.ml是测试脚本路径。期望输出是7因为乘法优先级高于加法。实际输出可能有三种情况直接打印7说明整条链路通了打印出一串Token或语法树说明只走到前端某一步主程序默认开启了调试模式什么都不输出说明入口函数有问题。提示如果程序跑起来没有输出先检查主程序里有没有调用求值入口函数。这是最低级的错误也是资料1里最常见的“黑匣子”现象——不是你没写对而是入口被注释掉了。4. 资料归档实操勘误表、源码版本与读书笔记的整理方案4.1 先建目录一个可复用的资料库结构很多人拿到资料1就急着解压结果源码、勘误、笔记混在一个文件夹里三个月后自己都找不到哪个版本能跑。我建议从一开始就建立一个固定结构的目录把资料按类型归档目录存放内容src/资料的原始源码保持解压原貌不修改build/编译产物和构建脚本docs/笔记、章节结构梳理、代码阅读记录errata/勘误表及对照说明tests/自己写的测试程序创建这个结构的命令很简单# 创建资料库目录结构 mkdir -p mylang-notes/{src,build,docs,errata,tests}逻辑说明mkdir -p允许一次创建多级目录{src,build,docs,errata,tests}是大括号扩展等价于分别创建五个目录。参数上不需要额外调整注意目录名不要带空格后面写脚本时省心。建好之后把资料1解压进src/不要在那里直接改代码。需要改代码时复制到build/或者单独建一个work/目录保留原始版本作为对照。4.2 勘误表统一格式把散装勘误变成可检索索引资料里的勘误表往往格式混乱有的是PDF有的是文字段落有的只留一句“第某页某函数有问题”。等你读到那一页时再回头翻体验很差。我习惯把勘误统一整理成按章节排序的文本索引用一个小脚本半自动完成。# errata_index.py把散装勘误按章节生成索引文件 import re import pathlib raw pathlib.Path(raw_errata.txt).read_text(encodingutf-8) entries [] for line in raw.splitlines(): m re.match(r第?(\d)章.*?第?(\d)页, line) if m: entries.append((int(m.group(1)), line.strip())) entries.sort(keylambda x: (x[0], x[1])) pathlib.Path(errata_index.txt).write_text( \n.join(f第{ch}章: {text} for ch, text in entries), encodingutf-8 )逻辑说明脚本逐行读入原始勘误文本用正则第?(\d)章.*?第?(\d)页匹配包含章节号和页码的行提取章节号作为排序键最后生成按章节排序的errata_index.txt。参数上正则中的第?表示“第”字可有可无(\d)捕获数字.*?非贪婪匹配中间内容。如果你的勘误表里写的是“P123第4章”需要把正则改成匹配 “P” 开头的页码最简单的方式是加一个分支m re.search(r第(\d)章, line) or re.search(rP(\d), line)这个脚本的价值在于它能逼你检查勘误原文的格式差异早发现早处理。整理完的索引放在errata/下读书读到对应章节时先扫一眼这一章有没有勘误能避免很多“我按书上写为什么跑不对”的困惑。4.3 版本差异记录不同平台下的统一编译参数资料1的源码可能在 Linux、macOS、Windows 三种环境下编译表现各不相同。最常见的问题是 Windows 下的 C 运行时函数名称差异以及 macOS 下某些废弃函数产生的编译警告。不要为每个平台维护一份源码应该维护一份构建参数。#!/bin/bash # 按系统类型选择编译参数避免不同平台翻车 case $(uname -s) in Linux*) CFLAGS-stdc99 -g -Wall ;; Darwin*) CFLAGS-stdc99 -g -Wall -Wno-deprecated ;; MINGW*) CFLAGS-stdc99 -g -Wall -D_CRT_SECURE_NO_WARNINGS ;; esac echo CFLAGS$CFLAGS逻辑说明uname -s输出系统内核名称Linux 返回LinuxmacOS 返回DarwinWindows 上的 MinGW 工具链返回以MINGW开头的字符串。每个分支给不同的CFLAGSmacOS 加了-Wno-deprecated屏蔽废弃API警告Windows 加-D_CRT_SECURE_NO_WARNINGS关闭安全函数重命名提示。参数上-D是定义宏用来避开 Windows CRT 的警告机制。注意如果你在 Windows 上开发不要直接用IDE打开源码。IDE 会按它自己的工程配置编译和资料1里的目录结构对不上报错一堆。用上面的脚本加命令行编译问题范围小得多。5. 自制编程语言资料里的五个坑现象、原因与排查顺序5.1 坑一源码下载后缺头文件第一眼就编译不过现象解压资料1后第一次编译报错内容集中在“找不到某个头文件”比如fatal error: xxx.h: No such file or directory。原因资料1里的源码是按作者的目录结构组织 include 的头文件放在include/子目录而你的构建命令没有把该目录加入搜索路径。解决不修改源码在编译命令里加-I参数指定头文件路径。例如源码在src/头文件在src/include/就用-I./src -I./src/include。如果资料1自带 Makefile先看里面有没有INCLUDES变量有就直接沿用。还有一个小技巧用find . -name *.h列出所有头文件的实际位置和报错信息里的路径对比基本一眼就能看出相对路径错在哪。5.2 坑二中文注释乱码程序行为跟着变现象打开源码所有中文注释显示为乱码在某些环境下编译直接报invalid character或警告。原因源码是 UTF-8 编码而你的编辑器和编译器终端默认按本地编码解析两边编码不一致。这个问题看起来像玄学其实只是“编辑器、编译器、终端”三处的编码各说各话。解决把编辑器默认编码设为 UTF-8源码里配了中文提示字符串时不要拿它和硬编码字节做比较在 Windows 上编译时给编译器加一个按 UTF-8 解析源码的选项或者统一用脚本构建避免终端代码页干扰。你只需要保证“源码文件用UTF-8存、编译器按UTF-8读、终端按UTF-8显示”这三件事一致即可。5.3 坑三同一段代码结果和书里不一样现象照着书把示例代码敲进去输入同一个测试程序输出结果却和书里对不上有的差一个数有的直接崩溃。原因正文为了讲解清楚常常省略运行时初始化代码——比如操作数栈的初始容量、对象分配器的启动、全局变量表的创建。这些代码看起来不起眼但少了它们程序的起始状态就不对。解决先从资料1的完整源码里拷贝入口函数不要自己从零敲。对比自己敲的入口和资料1的入口差异重点检查“全局变量初始化”和“运行时启动”两个部分。三分钟自检顺序是入口函数调了几次初始化栈指针有没有重置常量表有没有加载这个顺序能覆盖九成“结果对不上”的情况。5.4 坑四实现到一半代码结构和书里越来越不像不敢继续现象你按自己的思路写递归求值写到 if 语句或多分支操作时发现和书上的函数签名对不上越写越慌最后卡住不敢动。原因书里是“教学版结构”它为了讲清楚一个概念会把函数拆得特别细函数名也偏教学化。你一旦先改结构就失去了对照物。解决第一遍老老实实照抄资料1不自己发挥先跑通再谈重构。这不是让你放弃思考而是确认你有一个“底线版本”能跑出正确结果后续改结构时拿它做对照基准。“先抄后改”的学习效率远高于“边想边抄”后者会把“我做错了”和“我还没理解”混在一起心态容易崩。5.5 坑五字节码虚拟机跑测试程序时段错误现象程序一运行就段错误或者打印到一半崩溃调试器显示崩溃位置在读某个内存地址。原因字节码虚拟机部分最常见的两个根因是操作数栈下溢和对象管理出错。栈下溢通常是函数调用约定没对上调用指令把返回地址压栈但函数没有正确弹出或者参数个数和函数签名不符。对象管理出错则多发生在垃圾回收标记阶段访问了已经释放的对象。解决用调试器跑一次看崩溃栈顶在哪。按“指令指针是否越界→栈顶指针是否小于栈底→对象头结构是否对齐”的顺序排查。# 用调试器定位虚拟机的段错误位置 gdb --args ./build/mylang programs/hello.ml run bt逻辑说明gdb --args让调试器加载程序和参数run启动执行bt打印崩溃时的调用栈。参数上不需要额外配置重点是看bt输出里第一个函数名——如果落在execute循环里基本是指令解码问题如果落在某个对象分配函数里则偏向内存管理问题。段错误不要凭感觉猜先把崩溃栈打出来再动手。6. 进阶验证给虚拟机加一条新指令实测你对资料的理解读完整本书、跑通资料1你还需要一个动作来确认自己不是“照着书敲了一遍”。我推荐的做法是给虚拟机加一条新指令比如一元负号运算。这个过程会逼你把指令集、解码、执行、测试四件事全部走一遍。三个修改点必须同时落在代码里// 加一条取负数指令 OP_NEG需要改三处位置 // 1. 操作码枚举 typedef enum { OP_CONST, OP_ADD, OP_NEG, OP_RETURN } OpCode; // 2. 反汇编/解码分支 case OP_NEG: printf(neg); break; // 3. 执行分支 case OP_NEG: push(-pop()); break;逻辑说明枚举里新增OP_NEG解码分支负责打印这条指令方便调试执行分支弹出栈顶值、取负后压回。关键是三处必须同步更新少一处都会出现“能编译但行为诡异”的问题。我自己的翻车经历是只改了执行分支忘了改反汇编分支结果跑测试时打印出的指令序列和实际执行的对不上花了一个晚上才定位到是反汇编器没同步。从此养成固定顺序先改枚举再改解码最后改执行然后跑一条最简单的测试用例。测试用例也很简单先跑print 3;确认基准正常再跑print -3;验证新指令。如果第二个输出是-3新指令就通了。再加一条print -(-3);确认连续取负没有破坏栈平衡。做完这一步你才算把资料1真正用起来了。我给自己的习惯是每读完一部分就加一个小特性哪怕只是加一个取模运算符也要走完“改三处测试”的完整流程。这样读书不是单向输入而是每一章都有东西在跑。希望帮到你。本文还有配套的精品资源点击获取