☰
编译原理实验代码:从词法分析到中间代码生成的完整实现与调试指南
2026/10/17 2:54:05 网站建设 项目流程

简介:这份资源是电子科技大学编译原理课程的实验代码合集,面向正在学习编译原理、需要完成词法分析与语法分析实验的高校学生及自学者。内容围绕编译器前端核心模块展开,涵盖词法分析器如何借助正则表达式或有限状态自动机识别关键字、标识符、常量与运算符等token,以及语法分析器如何接收token流并按LL、LR等策略构建抽象语法树,同时涉及输入处理、语义检查与代码生成等环节的代码组织方式。压缩包共21个文件,约203KB,以cpp与h源文件为主,另含pas示例、vcxproj与sln工程文件、docx运行说明及exe可执行程序,便于直接编译验证。目前已有2112人学习下载,适合作为课程实验的参考实现,帮助读者对照运行说明理解分词与解析流程,掌握编译器前端开发的关键技术与排错思路。

1. 从一份编译原理实验代码说起:为什么它值得你花时间跑一遍

如果你正在搜“编译原理实验代码”,大概率是两种情况:要么课程实验卡在某个环节,词法分析器写完了但语法分析器不知道怎么接;要么想找一个能跑通的完整参考,看看一个迷你编译器从源码到目标代码到底要经过哪些步骤。这份资源就是围绕编译原理课程实验整理的一套代码,覆盖了从词法分析、语法分析、语义处理到中间代码生成的主要环节。它适合正在上编译原理课、需要交实验的学生,也适合想通过动手写一遍来真正理解编译器前端工作流程的开发者。编译原理这门课光看龙书容易陷入“每个字都认识但连起来不知道在干嘛”的状态,把代码跑起来、改一改、看输出怎么变,比死磕理论快得多。

2. 先看清全貌:这份实验代码的模块划分与技术选型

2.1 编译器前端流水线的整体结构

编译原理实验通常不会让你写一个完整的工业级编译器,而是把前端流水线拆成几个独立的实验阶段。这份代码的组织方式也遵循这个思路,核心模块大致分为四层:词法分析层负责把源程序的字符流切分成有意义的单词符号;语法分析层负责根据文法规则把单词序列组织成语法树;语义分析层负责在语法树基础上做类型检查、符号表管理;中间代码生成层负责把语法树翻译成四元式或三地址码。每个模块之间有明确的输入输出约定,前一个模块的输出就是后一个模块的输入,这种流水线式的设计让每个实验可以独立调试,也方便你只替换其中某一层来对比效果。

常见做法是用一个统一的 Token 结构体在模块之间传递数据,Token 里包含单词类别、原始字符串和行号信息。语法分析器拿到 Token 流之后,根据文法产生式进行推导或归约,最终产出一棵语法树。语义分析阶段遍历这棵树,维护符号表并做类型检查。中间代码生成阶段再遍历一次,把树节点翻译成四元式序列。整个流程走下来,你会对“编译器前端到底在干什么”有一个非常具体的认识,而不是停留在“词法分析就是正则表达式”这种模糊印象上。

2.2 技术栈与目录结构

这份代码大概率是用 C 或 C++ 写的,这是编译原理实验最常见的语言选择,因为需要手动管理内存和指针操作,能更清楚地看到数据结构的变化。也有部分实验会用 Java 或 Python,取决于课程要求。从代码组织来看,目录结构通常长这样:

compiler-lab/ ├── src/ │ ├── lexer.c # 词法分析器实现 │ ├── parser.c # 语法分析器实现 │ ├── semantic.c # 语义分析与符号表 │ ├── codegen.c # 中间代码生成 │ └── main.c # 主入口,串联各阶段 ├── include/ │ ├── token.h # Token 定义 │ ├── ast.h # 语法树节点定义 │ └── symbol_table.h # 符号表结构 ├── test/ │ ├── case1.pas # 测试用例(类 Pascal 或 C 子集) │ └── case2.pas ├── Makefile └── README.md

这个结构的好处是每个模块的职责边界很清楚。lexer.c只负责产出 Token 流,不关心语法;parser.c只消费 Token 流并构建语法树,不关心后续怎么处理;semantic.c和codegen.c各自独立。你调试的时候可以单独编译某个模块,用桩函数替换上下游,快速定位问题出在哪一层。

2.3 各模块的输入输出约定

理解模块之间的接口是跑通这份代码的关键。词法分析器的输入是源程序文件路径,输出是一个 Token 数组或链表,每个 Token 包含类别(关键字、标识符、常量、运算符、界符)和值。语法分析器的输入是 Token 流,输出是语法树根节点指针。语义分析的输入是语法树,输出是带类型标注的语法树和符号表。中间代码生成的输入是带标注的语法树,输出是四元式序列。

这些接口在头文件里定义得很清楚,你改任何一个模块的时候,只要保证输入输出格式不变,其他模块就不用动。这种设计对二次开发非常友好,比如你想把递归下降的语法分析换成 LL(1) 表驱动的方式,只需要重写parser.c,其他文件一行不用改。

3. 跑起来才算数:环境准备与编译执行全流程

3.1 环境准备与依赖检查

这份代码对环境的依赖很低,C/C++ 版本只需要一个支持 C99 或 C++11 的编译器。Linux 下用 gcc 或 clang,Windows 下用 MinGW 或 MSVC 都可以。如果你拿到的是 Java 版本,需要 JDK 8 以上;Python 版本则需要 Python 3.6 以上。先确认你的环境里有没有 make 工具,因为项目通常带 Makefile,用 make 编译比手动敲 gcc 命令省事得多。

# 检查编译器版本 gcc --version # 检查 make 是否可用 make --version # 进入项目目录 cd compiler-lab # 查看 Makefile 里的编译目标 cat Makefile

Makefile 里一般会定义all、clean、test几个目标。all负责编译出可执行文件,clean清理中间产物,test跑测试用例。先看一遍 Makefile 里的编译选项,确认有没有开启调试信息(-g)和警告(-Wall),调试阶段这两个选项很有用。

3.2 编译与运行:从源码到可执行文件

编译过程本身不复杂,但有几个细节容易翻车。如果你的代码里用了strdup或getline这类函数,在严格 C99 模式下可能会报隐式声明警告,需要在编译选项里加上-D_GNU_SOURCE或者用-std=gnu99。另外,如果项目分多个源文件,链接顺序有时候会影响结果,把main.c放在最后链接通常比较稳妥。

# 编译项目 make # 如果 make 报错,可以手动编译看看具体问题 gcc -g -Wall -std=gnu99 -Iinclude src/*.c -o compiler # 运行编译器,传入测试用例 ./compiler test/case1.pas # 查看输出结果 cat output.txt

运行之后,程序通常会输出几个阶段的结果:Token 序列、语法树(缩进形式或 DOT 格式)、符号表内容、四元式序列。如果程序崩溃或者输出为空,先检查测试用例文件的路径对不对,再看源程序里有没有词法分析器不支持的字符。常见的情况是测试用例里用了中文标点或者全角空格,词法分析器直接卡死或者报未识别字符。

3.3 输出结果解读:怎么判断跑对了

跑通的标准不是“没报错”,而是输出结果符合预期。Token 序列应该和源程序里的单词一一对应,关键字、标识符、常量的分类要正确。语法树的结构应该反映源程序的嵌套关系,比如if语句的条件和分支应该挂在同一个父节点下。符号表里应该包含所有声明的变量和函数,作用域信息要正确。四元式序列应该能还原出源程序的逻辑,比如赋值语句对应一个=四元式,加法对应一个+四元式。

如果你发现某个阶段的输出不对,先定位是哪个模块的问题。比如 Token 序列里标识符被识别成了关键字,那就是词法分析器的关键字表有问题;语法树结构混乱,可能是语法分析器的产生式写错了;四元式里变量名对不上,可能是符号表的作用域管理有 bug。逐层排查比一上来就改代码效率高得多。

4. 核心模块拆解:词法、语法、语义与代码生成的关键实现

4.1 词法分析器:有限自动机与 Token 识别

词法分析器的核心是一个有限自动机,根据当前字符和状态决定下一步跳转。这份代码里大概率是用switch-case或者状态转移表实现的。关键函数通常叫nextToken或scan,每次调用返回一个 Token。识别标识符和关键字的时候,先按标识符规则读入一串字母数字下划线,然后查关键字表,如果在表里就返回对应的关键字类别,否则返回标识符类别。

// 词法分析器核心循环(简化版) Token nextToken() { skipWhitespace(); // 跳过空白字符和注释 Token tok; tok.line = currentLine; if (isalpha(currentChar) || currentChar == '_') { // 读取标识符或关键字 char buffer[MAX_ID_LEN]; int len = 0; while (isalnum(currentChar) || currentChar == '_') { buffer[len++] = currentChar; advance(); } buffer[len] = '\0'; // 查关键字表 int kw = lookupKeyword(buffer); if (kw != -1) { tok.type = kw; } else { tok.type = TOKEN_ID; strcpy(tok.value, buffer); } } else if (isdigit(currentChar)) { // 读取数字常量 // ... } else { // 处理运算符和界符 // ... } return tok; }

这段代码的逻辑很直白:先跳过空白,然后根据当前字符判断是标识符、数字还是符号。标识符和关键字共用一套读取逻辑,读完再查表区分。数字常量的处理类似,但要注意处理小数点、指数符号等情况。运算符和界符的处理通常是一个大的switch,每个符号对应一个 Token 类别。

参数方面,MAX_ID_LEN控制标识符最大长度,一般设 64 或 128 就够了。currentLine用于记录行号,报错的时候能定位到具体行。advance()函数负责读取下一个字符并更新行号。如果你要扩展词法分析器支持新的符号,比如+=或->,需要在符号处理分支里加对应的识别逻辑,同时更新 Token 类别定义。

4.2 语法分析器:递归下降与语法树构建

语法分析器通常用递归下降的方式实现,每个非终结符对应一个函数。比如parseExpression处理表达式,parseStatement处理语句,parseProgram处理整个程序。递归下降的好处是代码结构清晰,和文法产生式一一对应,改起来也方便。缺点是遇到左递归文法需要改写,不过实验用的文法一般都已经处理过了。

// 解析赋值语句:ID = expression ; ASTNode* parseAssign() { ASTNode* node = createNode(NODE_ASSIGN); // 左部必须是标识符 if (currentToken.type != TOKEN_ID) { error("赋值语句左侧必须是标识符"); return NULL; } node->left = createLeaf(currentToken.value); advanceToken(); // 消费标识符 if (currentToken.type != TOKEN_ASSIGN) { error("缺少赋值符号"); return NULL; } advanceToken(); // 消费 '=' // 解析右侧表达式 node->right = parseExpression(); if (currentToken.type != TOKEN_SEMI) { error("缺少分号"); return NULL; } advanceToken(); // 消费 ';' return node; }

这段代码展示了递归下降解析器的典型写法:先检查当前 Token 是否符合预期,符合就消费掉并继续,不符合就报错。createNode创建语法树节点,createLeaf创建叶子节点。advanceToken从 Token 流里取下一个 Token。解析表达式的时候会递归调用parseExpression,最终形成一棵完整的语法树。

这里的关键参数是 Token 流的读取方式。常见做法是用一个全局的 Token 数组加一个索引,advanceToken就是索引加一。也可以用链表,每次取下一个节点。数组方式更简单,但需要提前把整个文件的 Token 都读出来。链表方式更灵活,但代码稍微复杂一点。实验代码一般用数组就够了。

4.3 语义分析与符号表:类型检查与作用域管理

语义分析阶段的核心是符号表。符号表本质上是一个哈希表或者有序表,存储变量名、类型、作用域层级等信息。进入一个新的作用域(比如函数体或复合语句)时,压入一个新的符号表层级;离开时弹出。查找变量的时候从当前层级往外找,找到就返回,找不到就报未声明错误。

// 符号表结构(简化版) typedef struct Symbol { char name[MAX_ID_LEN]; DataType type; // 变量类型 int scopeLevel; // 作用域层级 struct Symbol* next; // 哈希冲突链 } Symbol; // 插入符号 void insertSymbol(char* name, DataType type) { Symbol* sym = (Symbol*)malloc(sizeof(Symbol)); strcpy(sym->name, name); sym->type = type; sym->scopeLevel = currentScope; // 计算哈希值并插入对应桶 int hash = hashFunc(name); sym->next = symbolTable[hash]; symbolTable[hash] = sym; } // 查找符号 Symbol* lookupSymbol(char* name) { int hash = hashFunc(name); Symbol* sym = symbolTable[hash]; while (sym != NULL) { if (strcmp(sym->name, name) == 0 && sym->scopeLevel <= currentScope) { return sym; } sym = sym->next; } return NULL; // 未找到 }

符号表的设计直接影响语义分析的准确性。scopeLevel用来区分不同作用域的变量,查找的时候只返回当前作用域或外层作用域的符号。hashFunc可以用简单的除留余数法,表的大小取一个质数。插入的时候如果发现同名符号在同一作用域已经存在,就报重复声明错误。

类型检查的部分通常是在遍历语法树的时候做的。比如赋值语句左右两边的类型要兼容,算术运算的操作数必须是数值类型,函数调用的参数个数和类型要匹配。这些检查逻辑分散在各个check函数里,每个函数负责一种节点类型。

4.4 中间代码生成:四元式与三地址码

中间代码生成阶段把语法树翻译成四元式序列。四元式的格式是(op, arg1, arg2, result),比如(+, a, b, t1)表示把 a 和 b 相加的结果存入 t1。生成过程中需要维护一个临时变量计数器,每产生一个新临时变量就递增。

// 生成四元式 void emit(char* op, char* arg1, char* arg2, char* result) { Quadruple q; strcpy(q.op, op); strcpy(q.arg1, arg1); strcpy(q.arg2, arg2); strcpy(q.result, result); quadList[quadCount++] = q; } // 遍历表达式节点生成四元式 char* genExpression(ASTNode* node) { if (node->type == NODE_ID) { return node->value; // 标识符直接返回名字 } if (node->type == NODE_NUM) { return node->value; // 常量直接返回字面量 } if (node->type == NODE_BINOP) { char* left = genExpression(node->left); char* right = genExpression(node->right); char* temp = newTemp(); // 生成新临时变量 emit(node->op, left, right, temp); return temp; } return NULL; }

emit函数负责把一条四元式追加到全局列表里。genExpression递归遍历表达式树,遇到二元运算就先生成左右子表达式的代码,再生成当前运算的四元式,最后返回临时变量名。newTemp每次调用返回一个类似t1、t2的新名字。

四元式的优势是结构统一,后续优化和生成目标代码都比较方便。实验里一般只要求生成四元式序列并输出,不要求进一步翻译成汇编。如果你要扩展,可以在四元式基础上做常量折叠、公共子表达式消除等优化,然后再生成目标代码。

5. 避坑与排查:跑这份代码时最容易翻车的几个地方

5.1 词法分析器卡死或报未识别字符

现象是程序运行后没有任何输出,或者输出到一半停住。原因通常是测试用例里包含了词法分析器不支持的字符,比如中文标点、全角空格、特殊符号。词法分析器的default分支如果没有正确处理未知字符,可能会陷入死循环。解决方法是先在default分支里加打印语句,把出问题的字符和行号打出来,然后要么修改测试用例,要么扩展词法分析器支持该字符。

5.2 语法分析器报“意外的 Token”但 Token 序列看起来正常

现象是语法分析器在某个位置报错,但你检查 Token 序列发现那个位置的 Token 类型是对的。原因可能是语法分析器的向前看(lookahead)逻辑有问题,或者某个产生式的 FIRST 集计算错了。常见做法是在报错的地方把当前 Token 和期望的 Token 类型都打印出来,对比一下就能发现是哪个产生式匹配错了。另外检查一下advanceToken有没有在错误的分支里被多调用了一次,导致 Token 流位置偏移。

5.3 符号表查找失败导致误报未声明

现象是变量明明声明了,但语义分析阶段报“未声明标识符”。原因通常是作用域层级管理有问题,比如进入新作用域时没有正确增加currentScope,或者查找的时候条件写成了sym->scopeLevel == currentScope而不是<=。解决方法是打印符号表的所有内容,看看变量的scopeLevel和当前currentScope是否匹配。另外注意函数参数的作用域处理,参数通常应该属于函数体的作用域,而不是外层作用域。

5.4 四元式生成顺序错乱

现象是输出的四元式序列逻辑不对,比如先算了加法再算乘法,但源程序里是乘法优先级更高。原因通常是表达式树的遍历顺序有问题,或者genExpression在递归的时候没有正确处理运算符优先级。解决方法是先打印语法树的结构,确认树的形状是否正确。如果树是对的但四元式顺序不对,检查genExpression里左右子树的递归顺序,确保先处理优先级高的运算。

5.5 内存泄漏导致程序跑多个用例后崩溃

现象是跑第一个测试用例正常,跑第二个就崩溃或者输出异常。原因是词法分析器或语法分析器里分配的内存没有释放,累积到一定程度就耗尽内存。常见做法是在每个测试用例结束后调用一个cleanup函数,释放 Token 数组、语法树节点、符号表节点和四元式列表。如果用的是 C++,可以用智能指针或者容器来管理内存,减少手动释放的负担。

6. 二次开发与改造:从能跑到能改的进阶思路

6.1 扩展语言特性:加一个新语句类型

如果你想在这份代码基础上加一个新语句,比如for循环或者switch语句,需要改三个地方。第一,在词法分析器的关键字表里加上新关键字,比如for和switch。第二,在语法分析器里加对应的解析函数,比如parseFor和parseSwitch,并在parseStatement的分发逻辑里加上新的分支。第三,在语义分析和代码生成阶段加对应的处理逻辑,比如for循环需要检查循环变量的类型,生成四元式的时候需要生成条件跳转和回跳指令。

// 在 parseStatement 里加分支 ASTNode* parseStatement() { switch (currentToken.type) { case TOKEN_IF: return parseIf(); case TOKEN_WHILE: return parseWhile(); case TOKEN_FOR: return parseFor(); // 新增 case TOKEN_SWITCH: return parseSwitch(); // 新增 case TOKEN_ID: return parseAssign(); // ... } }

加新语句的时候,建议先在纸上画出语法树的结构,确定每个节点需要哪些字段,然后再动手写代码。语法树节点定义在ast.h里,加新节点类型的时候记得更新节点类型的枚举值,并在创建节点的函数里初始化新字段。

6.2 替换语法分析算法:从递归下降到 LL(1) 表驱动

递归下降的优点是直观,缺点是文法稍微复杂一点就要改写左递归,而且代码量大。如果你想换成 LL(1) 表驱动的方式,需要先计算文法的 FIRST 集和 FOLLOW 集,然后构造预测分析表。解析的时候用一个栈来模拟递归调用,根据栈顶非终结符和当前 Token 查表决定下一步动作。

这种改造的工作量主要在构造分析表上,代码本身反而比递归下降短。改造完之后,语法分析器的行为更容易预测,也方便做自动化测试。不过 LL(1) 对文法要求比较严格,如果原文法有左递归或者公共左因子,需要先改写。改造之前建议先把原文法的产生式整理出来,用工具或者手算的方式构造分析表,确认没有冲突之后再写代码。

6.3 增加中间代码优化:常量折叠与公共子表达式消除

四元式生成之后,可以在中间代码层面做优化。最简单的优化是常量折叠,比如(+, 2, 3, t1)可以直接替换成(=, 5, -, t1)。实现方式是在生成四元式之前检查左右操作数是否都是常量,如果是就直接计算结果,不生成运算指令。

公共子表达式消除稍微复杂一点,需要分析四元式之间的依赖关系,找出重复计算的表达式。常见做法是维护一个表达式表,每次生成新四元式之前先查表,如果已经存在相同的表达式就直接复用结果临时变量。这两种优化在实验里通常作为加分项,实现难度不大但效果很明显。

6.4 验证改造效果:对比测试与回归测试

改完代码之后怎么确认没改坏?最直接的办法是准备一组测试用例,覆盖所有已有的语言特性,每次改完都跑一遍,对比输出结果和修改前是否一致。如果输出变了,检查是预期的变化还是引入了 bug。常见做法是把修改前的输出保存成基准文件,修改后用diff对比。

# 保存基准输出 ./compiler test/case1.pas > baseline_case1.txt # 修改代码后重新编译运行 make && ./compiler test/case1.pas > new_case1.txt # 对比差异 diff baseline_case1.txt new_case1.txt

如果差异只在新增功能相关的部分,说明改造是成功的。如果差异出现在已有功能的输出里,说明改造影响了原有逻辑,需要回退检查。我一般会在改代码之前先跑一遍所有测试用例,把输出存下来,改完之后再跑一遍对比。这个习惯帮我省了很多次“改一个 bug 引入三个新 bug”的麻烦。从那以后我每次动核心模块之前都强制走一遍基准测试,确认改动范围可控再继续。希望这份代码和这些改造思路能帮到你,跑起来、改起来,编译原理就没那么玄学了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询