☰
编译原理课程设计实战:手写词法、语法与三地址码解释器
2026/10/12 6:25:26 网站建设 项目流程

简介:面向编译原理课程设计的完整资料包,以南航课程设计任务为主线,涵盖词法分析、语法分析等核心模块的完整程序源码,并附可执行程序及经检验无错误的运行与实现说明。压缩包约961KB,共32个文件,内含8个txt源码与注释、5个exe可运行程序、3个obj及2个pdb等工程编译中间文件,2个doc课程设计报告、1个ppt答辩演示,还包含c/cpp/h等源程序文件和dsp/opt等工程配置,目录按词法部分、语法部分、报告材料等方式组织,便于按需查找。已有749人学习下载。程序基于常见编译原理课程设计场景设计,词法分析器与语法分析器实现完整,可编译为可执行程序,配套报告与PPT便于理解抽象概念,适合计算机专业学生在编译原理课程设计中作为方案参考、代码移植或排错对照。

1. 编译原理课程设计不是“大作业改个名”:它到底在让你做什么

拿到某高校的编译原理课程设计任务时,很多人第一反应就是“写个能算表达式的程序而已”。等到验收要求发下来,才发现要交的是词法分析、语法分析、语义分析、中间代码生成,甚至还要一份像样的实验报告。它不是让你把教材第一章到第八章重新看一遍,而是要求你在一个限定语言子集上,走完一次真正的编译器前后端闭环。本文会按我在类似课程设计里用到的一套稳定方案,从任务拆解讲到词法语法,再到中间代码和避坑,最后给一个能帮你自查结果的模拟执行工具。适合刚学完编译原理、准备动手做课程设计,又怕被隐藏测试用例打得措手不及的读者。

2. 先拆需求再动手:一门编译器课程设计的评分点与交付物

2.1 从课程设计任务书里找隐藏分:测试用例和报告分值

课程设计任务书一般包含三样东西:语言定义、输入输出要求、验收方式。语言定义会给出支持的类型、语句、运算符优先级;输入输出要求会规定编译器读什么文件、打印什么结果;验收方式决定了你是交源码加报告,还是现场演示。常见做法是把任务书读三遍,第一遍圈出“必做”,第二遍圈出“加分”,第三遍确认“输出格式”。很多同学栽在输出格式上——代码写完了,打印的符号表和老师给的示例对不上,直接扣分。

我一般会先画一个矩阵,把任务书里的每个功能点列成行,把“示例是否覆盖、边界情况是否要求、异常是否有评分”列成列。这一步大概花半小时,但能避免后面整个模块返工。优先级上,表达式正确求值大于控制流大于函数调用。如果时间不够,宁可不做函数调用,也要保证 if 和 while 能跑对。因为异常处理和边界用例往往占课程设计评分的百分之三十以上,而这些地方恰恰是最容易出错的。

2.2 目标架构:AST + 三地址码 + 解释器

不少同学的做法是词法、语法、语义全缝在一个文件里,边读边算。短小的表达式可以,一旦加入变量声明和循环,代码就会失控。我建议的架构是前端(词法加语法)生成抽象语法树,语义分析在 AST 上做类型和作用域检查,然后生成三地址码,最后用一个简单的解释器执行三地址码。这个架构的好处是每一层都可以独立测试,出错时能定位到具体阶段。

为了课程设计能稳定交付,没必要引入寄存器分配、指令选择、优化这些重型后端。“能出结果”远比“结果高效”重要。三地址码的形式是x = y op z或if a goto L,后续解释执行非常直观。而且你会在写中间代码生成器时发现很多语义相关的问题,比如变量是否已声明、临时变量编号是否冲突,这些都会提前暴露。我见过有人试图直接生成 x86 汇编,最后卡在寻址模式和栈帧管理上,连功能演示都没跑完。

2.3 语言子集怎么选:能力边界决定工作量

要做的语言子集,建议严格限制:变量类型只做整数,不做数组和字符串;语句仅包括赋值、print、if/else、while;表达式支持加减乘除和括号、单目负号,支持比较运算;函数调用作为可选加分项,不做结构体、指针、类型转换。限制后的工作量能控制在三周左右。如果一开始就计划支持数组下标、多类型、函数递归,词法和语法都要做更多分支,对课程设计而言性价比很低。

比较好的推进顺序是:先让print 1+2*3输出 7,再加上变量赋值,再加 while,最后补 if/else。每一步都要能跑通测试再进入下一步。选型时还要注意一个隐藏点:赋值语句的左边只能是标识符,不能是表达式。比如a+1 = 2必须报错。所以语法层面就要区分左值和右值,不然语义分析阶段会很被动。

2.4 工程结构与构建命令

我习惯用 C 语言写,编译产物是命令行程序,读入一个 mini 源文件,依次输出 token 流、语法树(可选)、符号表、三地址码,以及解释执行的结果。目录结构大致是:

minic/ ├── Makefile ├── main.c ├── lexer.c / lexer.h ├── parser.c / parser.h ├── ast.h ├── semant.c / semant.h ├── ir.c / ir.h └── vm.c / vm.h

构建命令很简单,一个 Makefile 把全部 C 文件编译成 minic 可执行文件。这里不建议用 IDE 一键编译,因为课程设计验收现场如果只有命令行,你要能一句make把东西跑起来。如果老师要求源代码里有注释,我建议在每个文件开头写清该模块的输入、输出和关键函数职责,而不是临时补注释。

CC = gcc CFLAGS = -Wall -g OBJS = main.o lexer.o parser.o semant.o ir.o vm.o minic: $(OBJS) $(CC) $(CFLAGS) -o minic $(OBJS)

逻辑说明:Makefile 把六个模块的目标文件链接成最终可执行文件。-g选项保留调试信息,报告里也能体现你确实做了调试准备。如果换到 Linux 环境,注意默认编译器可能是 gcc;如果换到某实验室的旧机器,可能要先make clean再重新构建。

3. 词法分析落地:从正则到自动机的代码框架与参数

3.1 手写状态机的最小代码骨架

词法分析器的任务是把源文件字符流转成 Token 流。Token 至少需要种类、值、行号、列号四个字段。我常用的定义是 IIdentifier、INumber、IKeyword、IOperator、IDelimiter 等。实操中直接手写状态机比调用 lex 更稳,因为你能精确控制错误位置,也更容易在报告里画出状态转移图。

下面是一个简化但完整可用的扫描循环:

Token nextToken() { skipWhitespaceAndComments(); Token t = {TOK_UNKNOWN, "", line, col}; char c = getNextChar(); if (isAlpha(c)) { while (isAlpha(peekChar()) || isDigit(peekChar())) { appendToValue(c); c = getNextChar(); } appendToValue(c); t.type = lookupKeywordOrIdentifier(value); } else if (isDigit(c)) { while (isDigit(peekChar())) { appendToValue(c); c = getNextChar(); } appendToValue(c); t.type = TOK_NUMBER; t.ival = atoi(value); } else if (c == '=' || c == '!') { char n = peekChar(); if (n == '=') { getNextChar(); t.type = (c == '=') ? TOK_EQEQ : TOK_NEQ; } else { t.type = TOK_ASSIGN; } } else { // 处理 + - * / < > ; ( ) 等单字符 Token } return t; }

逻辑说明:这里最关键的是peekChar和getNextChar的配合。getNextChar会消费字符,peekChar只是看一眼下一个字符但不消费。识别==时先 peek 到第二个=,确认后再把它消费掉,否则就把这个字符留给下一次扫描。这个机制决定了所有双字符操作符能否被正确识别。

参数上需要关注三个地方:第一个是缓冲区大小,建议源码每行不超过 256 字符,但不要写死,用动态增长更保险;第二个是关键字表,关键词如if、else、while、print、int要在查表时优先于普通标识符;第三个是数字解析,课程设计只要求十进制整数,但如果想加分,可以支持十六进制0x前缀,注意别和十进制解析冲突。

3.2 符号表结构与作用域嵌套

词法分析虽然只是识别标识符,但课程设计通常要求输出符号表。我的做法是词法阶段只维护一个“关键字 vs 用户标识符”的查表逻辑,真正的符号表放在语义分析阶段建。但为了让报告更完整,我通常设计一张支持作用域的符号表结构:

typedef struct Symbol { char *name; int type; int scope; int offset; struct Symbol *next; } Symbol; typedef struct Scope { int scopeLevel; Symbol *symbols; struct Scope *parent; } Scope;

查找时从当前 Scope 出发,沿 parent 往上找,直到顶层作用域。这样写的好处是不用全局哈希表也能处理嵌套作用域,插入和查找都是 O(链长),课程设计的符号表规模完全够用。后面生成中间代码时,符号表里的 offset 会帮你把变量名映射成内存位置。

这里有一个容易忽略的参数:每个变量的 offset 是相对当前作用域基址的偏移。如果作用域嵌套没处理好,两个同名变量 offset 相同,中间代码解释器会把它们当同一个变量。我在做某跨平台系统时遇到过这个问题,当时三元运算符里临时变量和块内变量共享一个 offset,导致最终输出的值整段错乱。

3.3 行列号与错误信息

几乎每个课程设计任务书都会要求“报错信息包含位置”。我在设计 Token 时把 line 和 column 放在结构体开头,保证任何阶段出错都能拿得到。报错统一走一个函数:

void reportError(int line, int col, char *fmt, ...) { fprintf(stderr, "minic:%d:%d: error: ", line, col); // va_list 处理格式化消息 exit(1); }

这样所有模块的错误输出格式一致,报告里看起来也更专业。还有一个细节:遇到非法字符比如$时,不要因为怕出错就中断整个编译。课程设计验收时,老师经常会故意输入非法字符看你崩不崩。我的处理是报错后跳过该字符,继续扫描,尽量把后面的错误也列出来。这样现场演示时心理压力小很多。

4. 语法分析选型:递归下降和 LR 的现实选择

4.1 递归下降的代码骨架

课程设计最可控的语法分析方案是递归下降。虽然 LR 分析器能处理更多文法结构,但冲突调试对初学者不友好。递归下降的思路是每种非终结符写一个函数,遇到该非终结符就调用它,函数内部按产生式逐个匹配终结符或调用其他函数。

一个表达式非终结符的基本实现:

ASTNode *parseExpr() { ASTNode *left = parseTerm(); while (check(TOK_PLUS) || check(TOK_MINUS)) { int op = token.type; advance(); ASTNode *right = parseTerm(); left = makeBinOpNode(op, left, right); } return left; }

逻辑说明:这里用 while 循环处理expr -> expr + term的左递归,避免无限递归调用自身。我最早照着文法的直接递归写法,在parseExpr开头就调用parseExpr,一执行就栈溢出。后来改成循环才明白,手写递归下降时要把左递归翻译成循环或右递归,这是和教科书上的 BNF 最不一样的地方。

表达式优先级通过语法分层实现:表达式层调用项层,项层调用因子层,因子层处理括号和数字。层数越多,优先级越明确。一个常见的问题是减号和负号冲突——表达式里的a-b和因子里的-b如果都进 parseFactor,会导致解析出错。解决方法是让单目负号单独占一层,通常放在因子层里。这样-2*-3才能被正确解析成(-2) * (-3)。

4.2 用冲突报告反推文法问题

我自己第一版文法是用 C 手写的,但为了验证文法没有二义性,会拿 bison 跑一遍纯文法文件。bison 输出的 shift/reduce 冲突报告其实是免费调试器。比如经典的 if-else 悬挂问题,报告会告诉你哪个状态发生冲突,然后你可以在该处决定 shift 优先。这个方法不是为了让你 commit bison 代码,而是帮你理解文法结构问题。

当然,如果课程设计允许使用生成器,直接用 flex 加 bison 会很省力。但前提是你能解释清楚生成器的原理。我见过一些同学用 bison 通过了功能测试,答辩时被问“你的语法分析器怎么消除左递归”就答不上来。所以如果要用生成器,至少把 LALR(1) 的建表过程讲个大概,否则老师会认为你只是把自动生成工具跑了一遍。

4.3 AST 节点设计与后续语义的衔接

语法分析的产物是 AST,而不是直接生成中间代码,这是方便后续语义分析和代码生成的关键。一个简单的 AST 节点可以这样设计:

typedef enum { NODE_NUM, NODE_VAR, NODE_ASSIGN, NODE_BINOP, NODE_IF, NODE_WHILE, NODE_PRINT } NodeType; typedef struct ASTNode { NodeType type; int line, col; union { int ival; char *name; struct { struct ASTNode *left, *right; int op; } binop; struct { struct ASTNode *cond, *thenBody, *elseBody; } ifStmt; struct { struct ASTNode *cond, *body; } whileStmt; struct { struct ASTNode *expr; } printStmt; } u; struct ASTNode *next; } ASTNode;

这里用 union 来节省空间,每类节点只保留自己需要的字段。我建议在做 AST 时给每种节点标注 line/col,这样语义分析阶段报错仍可以指向源码位置,不会让用户看到一行 “segmentation fault”。AST 遍历可以统一用一个访问器函数,按 type 分支处理。当后续生成中间代码时,只需要在访问器里加一个分支即可。

如果 type 一开始设计得不好,后面加新语句类型会让代码越改越乱。所以设计节点类型时要预留“语句列表”节点,因为程序体本身是一个语句数组。我在第二次完整重写时就吃了这个亏:当时没有NODE_LIST,导致处理多个语句时要额外包一层,代码里到处是特判。

5. 避坑指南:编译原理课程设计里最容易翻车的5个地方

5.1 “==”被拆成两个“=”

现象:输入a == b,词法分析输出a = = b,语法分析直接报错,明明表达式很简单却跑不过。

原因:识别=时没有向后预读一个字符,导致==被当作两个独立的赋值符号处理。这是状态机贪心匹配没做好,属于词法分析里最典型的“短视”问题。

解决:遇到=时先peek下一个字符,如果下一个也是=,就合并成一个==Token,否则把下一个字符留给下一次扫描。代码里用peekChar和getNextChar两个函数控制,任何多读的字符都要能够回退。同样的处理也适用于<=、>=、!=,否则后面边界条件全会在语法层爆掉。

5.2 递归下降遇到左递归爆栈

现象:解析表达式1+2+3,函数递归层数没多少就直接段错误或栈溢出。有的同学还以为是 C 语言栈大小不够,试图用ulimit改系统参数。

原因:文法写成了左递归,比如expr -> expr + term,而手写代码直接parseExpr调用parseExpr,没有转换成迭代。这样每个运算符都会多一层递归,表达式稍微长一点就爆栈。

解决:把左递归的产生式改成右递归或用循环。常见做法是parseExpr先parseTerm,然后循环匹配+/-,每匹配一个运算符就parseTerm一次。这个改动一行代码就能解决,但要理解为什么教科书里要把表达式文法重写。记住:手写递归下降不是背文法,而是把文法翻译成控制流。

5.3 符号表作用域覆盖

现象:在 if 块里声明int x;之后,块外同样有x,解释执行时块里的x总是取到外面那个值,程序输出完全不符合预期。

原因:符号表用单层哈希表,后声明的变量覆盖了先声明的,没有作用域恢复机制。课程设计里最常见的错误就是所有变量塞进同一个哈希表,导致同名变量互相覆盖。

解决:用作用域栈。进入大括号时 push 一个新 scope,退出时 pop。查找时从当前 scope 向上逐层找。注意 C 语言里变量声明的规则:块内声明只在块内有效,块结束后符号表里要恢复到之前的状态。如果采用链表结构,pop 的时候只要把当前 scope 的指针指向 parent,原符号表节点会自然失联。

5.4 临时变量编号混乱

现象:三地址码输出t1 = x + y和另一个语句t1 = z * w,但运行时 t1 被覆盖,结果算错。编译过程不报错,值却是错的,排查起来非常头疼。

原因:两个生成点的计数器都从 1 开始,或者计数变量被局部重置。有的同学在函数里写int tmpCount = 0;,每次调用都清零,于是不同表达式产生了相同的临时变量名。

解决:把临时变量编号计数器设计成全局唯一,或者放在一个上下文结构体里。生成newTemp()时返回t%d并自增,而且这个计数器只在全局初始化一次。如果你在同一函数里多次调用 AST 遍历,也要确保计数器不是函数内的普通局部变量,否则每遍历一次就重置,代码生成会大面积覆盖。

5.5 报告里只贴源码不贴运行证据

现象:报告写了一百页源码,但老师关心的测试用例和结果截图只字未提,直接被退回。答辩的时候老师问“这个功能怎么证明能跑”,当场支支吾吾。

原因:错误理解了课程设计报告的评分权重。任务书要求的是“程序设计与实现过程”,不是代码附录。源码顺手贴进去只是参考,真正评分的是你对问题分析、模块设计、测试验证的表达。

解决:每个模块单独列一张表:输入、预期输出、实际输出、异常输入及处理方式。至少放两个正常测试用例、一个边界用例如嵌套循环或除零、一个错误用例如未声明变量。如果是控制台程序,直接贴带命令行的截图或文本输出,最好能体现make后运行的全过程。这个习惯我在后来的某图像处理 Demo 里也一直保留,几乎每次都能避免“代码写完但说不清怎么验证”的尴尬。

6. 用“模拟解释执行三地址码”验证你的课程设计:一个能救命的小工具

很多同学做完语义分析就不知道该怎么验算“对不对”。如果只打印 AST,肉眼很难看出1+2*3有没有被解析成正确的层级。我的习惯是写一个很小的虚拟机,直接执行三地址码。这个工具大约一百多行,但它让你从“代码写完”到“能证明代码结果正确”之间建立了通路。

三地址码的解释执行本质上是一个循环:取指令、解析操作数、执行、跳转。指令集可以精简为ASSIGN、BINOP、LABEL、GOTO、IF、PRINT几类。每条指令用字符串存变量名,执行时从符号表里取值,结果写回目标变量。临时变量也放进同一个符号表,但命名统一用t%d,避免和用户变量冲突。

typedef struct { IRKind kind; char *dest; char *src1; char *src2; int op; int ival; } IRInst; void runVM(IRInst *ir, int irCount) { int pc = 0; Table *vars = createTable(); while (pc < irCount) { IRInst *inst = &ir[pc]; switch (inst->kind) { case IR_ASSIGN: setValue(vars, inst->dest, inst->ival); break; case IR_BINOP: { int a = getValue(vars, inst->src1); int b = getValue(vars, inst->src2); setValue(vars, inst->dest, calc(a, b, inst->op)); break; } case IR_GOTO: { pc = findLabel(inst->dest); continue; } case IR_PRINT: printf("%d\n", getValue(vars, inst->src1)); break; } pc++; } }

逻辑说明:每执行完一条指令后 pc 自增。遇到GOTO时直接跳转到标签位置。IF指令可以拆成两条:先计算比较结果到临时变量,再判断该变量是否为真。比如if x < y goto L1可以写成t5 = x < y; if t5 == 1 goto L1。这样所有指令都遵循“取操作数、计算、写回”的统一格式,解释器实现起来更顺手。

验证方法上,我会准备一组 mini 程序,先手算出预期输出,再交给 minic 跑。这组用例放在 test/ 目录,写一个简单的 run_tests.sh 自动比对输出。发现某一项不匹配时,单步跟踪三地址码执行,看是语义检查问题还是临时变量编号问题。比在语法树里一层层猜快得多。

我第一次做课程设计时贪心选了多类型支持,结果卡在类型转换和数组下标上,最后两天几乎没睡,交付效果还很差。后来第二版老老实实做整数加控制流,用解释器验证,整个流程轻松不少。我的教训是:编译原理课程设计的技术深度可以往后放,完整度和可验证性才是评分的大头。这个解释器方法希望帮到你。

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

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

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

立即咨询