简介:西南交大编译原理课程设计报告围绕词法分析器和语法分析器展开,完整展示了一个C语言实现的词法分析器从结构设计到编码调试的全过程。报告首先画出词法分析器总体框图,说明源程序输入缓冲区、扫描缓冲区、数据预处理及状态转换图之间的协作关系;随后基于保留字表与种别码设计,给出GetChar、GetBC、ConCat、Reserve、Retract等核心子程序的功能说明与源码实现。针对标识符、常数、运算符、界符的识别流程,报告还提供了详细流程图和注释清晰的完整程序,便于读者直接运行验证;语法分析部分则介绍如何基于词法单元构建抽象语法树,帮助理解递归下降或算符优先分析的基本思想。资源为单个docx文档,共1份文件,压缩包仅443KB,内容紧凑便携。从西南交大课程设计视角出发,适合计算机专业本科生、编译原理课程学习者以及准备相关实验与答辩的学生参考,已有147人学习使用,是一份能直接用于课程报告撰写和实验复现的参考范本。
1. 编译原理课程设计在考什么:字符流怎么变成一棵语法树
西南交大编译原理课程设计(词法分析器和语法分析器)这个题目,挂在课程页上只有十几个字,但等你真正打开实现就会发现,它考察的是两件完全不同的事:词法分析器要把源代码的字符流切成一个个有类型的 token,语法分析器再把这些 token 按文法拼成一棵合法的语法树。也就是说,这个课设不是让你背正则表达式或者 FIRST 集定义,而是让你亲手写两个能跑的程序,把一个源文件从“字符串”变成“结构”。它适合正在准备验收的学生,也适合那些想把编译原理前端原理真正串起来的人。一个反直觉结论是:别迷信 flex / bison,手写版本在答辩时反而更容易讲清楚,因为每一行代码对应什么规则,你一掀开就能说出来。
2. 词法分析器怎么落地:一张 Token 表和状态分支写出最小实现
词法分析器的工作范围其实非常窄:读字符、跳过空白、识别单词、返回带类型的 token。它不需要理解语义,只需要回答“这一串字符是什么类别的词”。课设文档通常不规定你要实现完整的 C 语言,而是实现一个子集,常见的就是关键字、标识符、整数常数、运算符和界符这五类。子集没给全时,我一般会自己定一份并写进报告,这样验收时对方至少知道边界在哪。
2.1 先定义 Token 分类:把要识别的单词列成一张表
动手写代码之前,第一件事是把语言子集里所有单词分类列出来。这个表既是后续代码的骨架,也是报告里最值得放的一张表。我通常按“分类 / 单词例子 / 模式”三列来列:
| 分类 | 单词例子 | 匹配模式 |
|---|---|---|
| 关键字 | int char if else while return | 按单词表精确匹配 |
| 标识符 | count _tmp var2 | [a-zA-Z_][a-zA-Z0-9_]* |
| 整数常数 | 0 123 999 | [0-9]+ |
| 运算符 | + - * / = == != < <= > >= | 最长匹配,先试两字符再试单字符 |
| 界符 | ; , ( ) { } | 单字符匹配 |
这个表里有一个容易忽略的点:种别码不需要一个单词编一个码。关键字可以一类一个码,比如 TK_INT 和 TK_CHAR 分开,但所有界符合成一个 TK_SEMI 就有点偷懒;建议每个符号独立一个枚举值,后续语法分析器写起来会清爽很多。我自己吃过这个亏:一开始把(和)都算作 TK_PAREN,结果语法分析器里被迫再比对 lexeme 才能区分左右括号,代码一下子变丑。
2.2 手写词法分析器:核心循环与关键字查表
确定了 Token 分类之后,就可以写一个最小实现。下面是 C 版本的骨架,核心思路是“每调用一次 get_token() 返回一个 token”,词法分析器本身不保存状态,调用方通过循环反复拿下一个 token。代码里把关键字查表集中放在一个函数里,避免在识别分支里写一堆 if 比较。
#include <stdio.h> #include <ctype.h> #include <string.h> typedef enum { TK_ID, TK_NUM, TK_INT, TK_CHAR, TK_IF, TK_ELSE, TK_WHILE, TK_RETURN, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_ASSIGN, TK_EQ, TK_NE, TK_LT, TK_LE, TK_GT, TK_GE, TK_SEMI, TK_COMMA, TK_LPAREN, TK_RPAREN, TK_LBRACE, TK_RBRACE, TK_EOF, TK_ERROR } TokenType; typedef struct { TokenType type; char lexeme[64]; int line; } Token; static int line_no = 1; static TokenType keyword_type(const char *s) { if (!strcmp(s, "int")) return TK_INT; if (!strcmp(s, "char")) return TK_CHAR; if (!strcmp(s, "if")) return TK_IF; if (!strcmp(s, "else")) return TK_ELSE; if (!strcmp(s, "while")) return TK_WHILE; if (!strcmp(s, "return")) return TK_RETURN; return TK_ID; } Token get_token(void) { Token tok; int c; int i = 0; tok.line = line_no; tok.lexeme[0] = '\0'; while ((c = getchar()) == ' ' || c == '\t') {} if (c == '\n') { line_no++; return get_token(); } if (c == EOF) { tok.type = TK_EOF; return tok; } if (isalpha(c) || c == '_') { while (isalnum(c) || c == '_') { if (i < 63) tok.lexeme[i++] = (char)c; c = getchar(); } ungetc(c, stdin); tok.lexeme[i] = '\0'; tok.type = keyword_type(tok.lexeme); return tok; } if (isdigit(c)) { while (isdigit(c)) { if (i < 63) tok.lexeme[i++] = (char)c; c = getchar(); } ungetc(c, stdin); tok.lexeme[i] = '\0'; tok.type = TK_NUM; return tok; } tok.lexeme[0] = (char)c; tok.lexeme[1] = '\0'; switch (c) { case '+': tok.type = TK_PLUS; return tok; case '-': tok.type = TK_MINUS; return tok; case '*': tok.type = TK_STAR; return tok; case '/': tok.type = TK_SLASH; return tok; case ';': tok.type = TK_SEMI; return tok; case ',': tok.type = TK_COMMA; return tok; case '(': tok.type = TK_LPAREN; return tok; case ')': tok.type = TK_RPAREN; return tok; case '{': tok.type = TK_LBRACE; return tok; case '}': tok.type = TK_RBRACE; return tok; case '=': if ((c = getchar()) == '=') { tok.lexeme[1] = '='; tok.lexeme[2] = '\0'; tok.type = TK_EQ; } else { ungetc(c, stdin); tok.type = TK_ASSIGN; } return tok; case '!': if ((c = getchar()) == '=') { tok.lexeme[1] = '='; tok.lexeme[2] = '\0'; tok.type = TK_NE; } else { ungetc(c, stdin); tok.type = TK_ERROR; } return tok; case '<': if ((c = getchar()) == '=') { tok.lexeme[1] = '='; tok.lexeme[2] = '\0'; tok.type = TK_LE; } else { ungetc(c, stdin); tok.type = TK_LT; } return tok; case '>': if ((c = getchar()) == '=') { tok.lexeme[1] = '='; tok.lexeme[2] = '\0'; tok.type = TK_GE; } else { ungetc(c, stdin); tok.type = TK_GT; } return tok; default: tok.type = TK_ERROR; return tok; } }这段代码的核心设计有两个。第一,关键字和标识符走同一条识别路径:先按[a-zA-Z_][a-zA-Z0-9_]*把完整单词读进 lexeme,再交给 keyword_type 查表。这个顺序是词法分析器里最关键的约定,先识别标识符再查关键字表,能保证int不会被当成普通变量名。第二,运算符分支统一使用“先读下一个字符尝试两字符运算符,不匹配就 ungetc 退回”的写法,保证>=不会裂成>和=。lexeme 长度限制 64,对课设足够,溢出时直接截断,但你要知道这是个隐藏边界:如果后面做符号表比较,截断后的名字可能撞车。
2.3 处理注释与非法字符:词法错误也要有行号
很多课设文档不会强制要求处理注释,但测试用例里大概率会放一两个带注释的样例。我的建议是支持/* ... */块注释,因为它能体现你在状态机上的考虑。实现不复杂:在 get_token 的case '/'分支里,读到下一个字符是*时,进入一个循环不断读字符,直到遇到*/或 EOF;如果遇到 EOF 说明注释没闭合,返回一个带行号的 TK_ERROR。这里有一个容易翻车的点:注释里的换行也要计入 line_no,否则后续所有报错行号都会偏。
非法字符的处理更简单,任何不在识别表里的符号(比如@、#)都返回 TK_ERROR,由上层语法分析器统一报告“第几行出现非法字符”。不要在词法分析器里 printf 直接输出错误,把错误信息留给上层统一管理,这样词法分析和语法分析的报错风格才能保持一致。
2.4 正则、DFA 和手写状态分支的关系:报告里怎么写才不露怯
课设报告里通常要求写“词法分析器的设计原理”,如果你直接说自己手写了一个分支循环,老师可能会追问 DFA 的事。常见做法是:在报告里把这张 Token 分类表映射成一张状态转换图,然后说明手写分支就是状态转换图的直接翻译。比如标识符状态、数字状态、运算符状态各对应一个分支,ungetc对应状态的“不消耗下一个字符”返回值。这样即没有用 flex 生成代码,也能把词法理论和你手写实现之间的对应关系讲清楚。
3. 语法分析器怎么选型:递归下降比 LL(1) 表驱动更适合课设
语法分析器的任务很简单:拿到词法分析器给的 token 流,判断它们是否满足文法。但这个“判断”有两种主流做法:递归下降和 LL(1) 表驱动。我的结论是课设场景优先递归下降,除非你的文档里明确要求必须提交预测分析表。递归下降的每个函数对应一个非终结符,错误定位和调试都直观;表驱动的好处是形式化味道更浓,但查表、维护分析表的代码量不小,一个测试样例挂掉,你很难一眼看出是表算错了还是驱动代码写错了。
3.1 课设语言的文法:用 EBNF 定义,天然避开左递归
定义一个课设子集语言的文法,我建议直接用 EBNF 风格而不是教科书式的 BNF,原因很实际:EBNF 里的*和?直接对应代码里的循环和条件,而 BNF 里的左递归需要先改写才能翻译成递归下降函数。下面是我常用的一套最小文法:
program -> stmt_list stmt_list -> stmt stmt_list | ε stmt -> if_stmt | while_stmt | decl_stmt | expr_stmt if_stmt -> if '(' expr ')' '{' stmt_list '}' while_stmt -> while '(' expr ')' '{' stmt_list '}' decl_stmt -> (int | char) id ';' expr_stmt -> expr ';' expr -> term (( '+' | '-' ) term)* term -> factor (( '*' | '/' ) factor)* factor -> id | num | '(' expr ')'注意expr和term的写法。教科书上常见expr -> expr + term这种左递归文法,直接翻译成代码会在函数第一行就无限调用自己,栈溢出翻车。EBNF 里的(term (('+' | '-') term)*)改写成了循环,递归下降函数里对应一个 while 循环,完全没有左递归问题。报告里建议把两版文法都写出来:先写 BNF 版本,再写消除左递归后的 EBNF 版本,这正好是“语法分析器设计”这一节需要的内容。
3.2 一个能跑的递归下降解析器:match 与 advance 的配合
递归下降的核心是三个函数:advance() 负责推进 token,match() 负责比对并报错,每个非终结符一个函数。以下是一个最小可跑的解析结构:
Token lookahead; void advance(void) { lookahead = get_token(); } void match(TokenType t) { if (lookahead.type == t) { advance(); } else { printf("line %d: syntax error, expect token %d but got %d\n", lookahead.line, t, lookahead.type); synchronize(); // 错误恢复,在第 4 章 4.3 展开 } } void parse_expr(void) { parse_term(); while (lookahead.type == TK_PLUS || lookahead.type == TK_MINUS) { advance(); parse_term(); } } void parse_term(void) { parse_factor(); while (lookahead.type == TK_STAR || lookahead.type == TK_SLASH) { advance(); parse_factor(); } } void parse_factor(void) { if (lookahead.type == TK_NUM || lookahead.type == TK_ID) { advance(); } else if (lookahead.type == TK_LPAREN) { advance(); parse_expr(); match(TK_RPAREN); } else { printf("line %d: unexpected token in factor\n", lookahead.line); synchronize(); } } void parse_stmt(void) { switch (lookahead.type) { case TK_IF: advance(); match(TK_LPAREN); parse_expr(); match(TK_RPAREN); match(TK_LBRACE); parse_stmt_list(); match(TK_RBRACE); break; case TK_WHILE: advance(); match(TK_LPAREN); parse_expr(); match(TK_RPAREN); match(TK_LBRACE); parse_stmt_list(); match(TK_RBRACE); break; case TK_INT: case TK_CHAR: advance(); match(TK_ID); match(TK_SEMI); break; default: parse_expr(); match(TK_SEMI); break; } } void parse_stmt_list(void) { while (lookahead.type != TK_EOF && lookahead.type != TK_RBRACE) { parse_stmt(); } } int main(void) { advance(); while (lookahead.type != TK_EOF) { parse_stmt_list(); if (lookahead.type == TK_EOF) break; } return 0; }这套代码有一个设计要点:parse_stmt_list 没有用递归实现stmt_list -> stmt stmt_list | ε,而是用 while 循环,效果和 EBNF 的*完全一致,又规避了递归深度问题。每个函数只向前看一个 token 就决定走哪个分支,这就是“预测分析”的含义。如果某一个 token 能同时进入两个分支,文法就是有冲突的,递归下降会写得很别扭,这就是下面要说的 LL(1) 冲突检查。
3.3 LL(1) 冲突检查:为什么我的文法不会回溯
递归下降能顺利写出来,前提是文法是 LL(1) 的:每个非终结符的每个候选产生式,FIRST 集互不相交。以我上面的文法为例,stmt 的四个候选分别以 TK_IF、TK_WHILE、TK_INT/TK_CHAR、以及 expr 的首个 token(TK_ID/TK_NUM/TK_LPAREN)开头,两两没有交集,所以 parse_stmt 里一个 switch 就能区分。如果不做这个检查,你就会写出那种“先试一个分支,不行再回头试另一个分支”的带回溯解析器,运行慢且错误定位混乱。
课设报告里建议手算一遍这组 FIRST 集,列一个小表。比如expr的 FIRST 是 {TK_ID, TK_NUM, TK_LPAREN},stmt的 FIRST 是 {TK_IF, TK_WHILE, TK_INT, TK_CHAR, TK_ID, TK_NUM, TK_LPAREN}。如果将来扩展语言,比如加一个for语句,要重新检查它和现有候选是否冲突;如果两个候选都以同一 token 开头,就需要提取左公因子,把公共前缀提到外面。
3.4 表驱动 LL(1) 与递归下降怎么选:答辩被追问时的答案
如果你在文档里看到“用 LL(1) 分析法”的字样,千万别慌。一种很稳妥的做法是:报告里写 LL(1) 分析表(预测分析表),代码里用递归下降实现,然后在文档里画出两者对应关系。表格驱动需要维护分析表二维数组和栈,代码量反而更大,而且表驱动的错误定位不如递归下降直观。真正被问到“为什么不用表驱动”时,我一般这样答:递归下降是预测分析的一种实现方式,每个非终结符函数本质上就是分析表中的一行,方向是等价的,选择它是为了代码可维护性。这个回答比单纯说“好写”要硬气得多。
4. 词法与语法怎么对接:符号表、超前读与错误恢复的接口设计
词法分析器和语法分析器单独写都很容易,难的是对接。我第一次做这个课设时,把词法分析器得到的 token 全部存进一个数组,再交给语法分析器解析,结果一个两百行的测试文件就把内存占了一大块,而且报错行号全是乱的。后来我换成了“边读边解析”的方式:语法分析器需要 token 时,现场调用 get_token() 拿下一个。
4.1 边读边解析:一个 lookahead 就够用
递归下降解析器永远只需要“当前 token”和“下一个 token”,所以全局变量 lookahead 就足够。main 里第一步 advance() 把第一个 token 读进来,之后每个 parse 函数通过 match 和 advance 消费 token。语法分析器从不需要回头重新看已经消费的 token,这是递归下降的天然特性,也让接口变得非常简单:词法分析器暴露一个 get_token(),语法分析器负责维护 lookahead。
这种做法的好处是单遍扫描,源文件再大内存也不怕;代价是如果你想同时打印“token 流”和“语法树”,就得在解析过程中边做边打印,而不是先全部 token 化再慢慢分析。课设答辩时演示“输入一行代码,输出 token 流和语法树”,用边读边解析完全够。
4.2 符号表:登记时机、作用域和查找顺序
符号表是热搜词里出现最多的概念,也是词法分析和语法分析交汇的地方。课设里的符号表不需要做成复杂的哈希表,一个数组加一个作用域深度标志就够了。常见的实现是“进入花括号块时 depth 加一,退出时 depth 减一,但符号条目不清除,查找时从后往前找并且只认 depth 不超过当前深度的条目”。这样做的原因是:同一个名字在内层作用域可以重新声明,但查找时要先看到内层的;而退出作用域后,外层同名变量重新可见。
#define MAX_SYMBOLS 256 #define MAX_NAME 64 typedef struct { char name[MAX_NAME]; int depth; } Symbol; static Symbol symtab[MAX_SYMBOLS]; static int sym_count = 0; static int current_depth = 0; void enter_scope(void) { current_depth++; } void leave_scope(void) { current_depth--; } static int lookup_current(const char *name) { int i; for (i = sym_count - 1; i >= 0; i--) { if (strcmp(symtab[i].name, name) == 0 && symtab[i].depth == current_depth) { return 1; } } return 0; } int lookup(const char *name) { int i; for (i = sym_count - 1; i >= 0; i--) { if (strcmp(symtab[i].name, name) == 0 && symtab[i].depth <= current_depth) { return 1; } } return 0; } void declare(const char *name) { if (lookup_current(name)) { printf("line %d: variable %s redeclared\n", lookahead.line, name); return; } strcpy(symtab[sym_count].name, name); symtab[sym_count].depth = current_depth; sym_count++; }登记时机要和语法分析器联动:在 parse_stmt 的 TK_INT/TK_CHAR 分支里,match(TK_ID) 之后立刻调用 declare(lexeme);在 parse_factor 里遇到 TK_ID 时调用 lookup,查不到就报“未声明标识符”。这里的坑是:词法分析器的 lexeme 是静态缓冲区,下一次 get_token() 就会覆盖它,所以声明和查找必须在拿到 token 的当下立刻使用 lexeme,不要存到后面再用。
4.3 错误恢复:panic mode 是最简单的后悔药
递归下降解析器最怕的就是遇到一个错误直接退出。测试脚本经常一次性喂十几个样例,第一个样例挂了程序就停,等于后面全白测。正确做法是“报错不退出,跳到下一个安全位置继续解析”,术语叫 panic mode。对语句级别的错误,安全位置就是分号和右花括号,它们是语句或块的自然边界。
void synchronize(void) { while (lookahead.type != TK_EOF) { if (lookahead.type == TK_SEMI) { advance(); return; } if (lookahead.type == TK_RBRACE) { advance(); return; } advance(); } }这里有一个容易忽略的细节:遇到右花括号时要把这个 token 消费掉再返回。如果不消费,外层块的 match(TK_RBRACE) 会看到同样的右花括号并再次消费,导致块的边界错乱,后续所有语句解析全部错位。还要在全局设置一个错误计数器,连续报错超过比如 20 次就强制退出,防止在极端坏输入下同步逻辑自身陷入死循环。
4.4 调试输出:把 token 流和行号对齐,问题立刻少一半
课设调试时最有用的是一个打印 token 的小函数。遇到语义错误时,先打印当前 token 的类型和行号,再打印它的 lexeme,能快速定位是词法切错了还是语法规则写错了。我习惯在 main 里加一个命令行参数,传-t就只打印 token 流,不启动作语法分析;传-p才进入解析。这样调试词法时不被打扰,调试语法时又能随时看词法结果。
5. 课设避坑与常见问题:5 个让编译原理实验翻车的细节
这里的每一条都是我实际写过之后才明白的。有些问题在课本习题里根本不会出现,但测试用例一多就全暴露了。
5.1 关键字被识别成标识符
现象:输入int a;,词法分析器输出TK_ID("int") TK_ID("a") TK_SEMI,语法分析器直接把 int 当成变量名,后面的声明全乱套。
原因:识别标识符时,先按字母规则读完整单词,然后没有查关键字表就直接返回 TK_ID。
解决:在完成单词读取后,必须调用 keyword_type 去查关键字表,查到了就返回对应的关键字类型,查不到才是 TK_ID。这个顺序千万不能颠倒。
5.2 两字符运算符被拆成两个单字符
现象:输入a >= 1;,词法输出变成TK_ID("a") TK_GT(">") TK_ASSIGN("=") TK_NUM("1"),语法分析器在 factor 之后直接看到一个>,报文法错误。
原因:运算符分支只写了一个字符的 case,遇到>就立刻返回,没有去尝试读下一个字符看是不是=。
解决:在>分支里先 getchar 试探下一个字符,如果是=就返回 TK_GE,否则 ungetc 退回去返回 TK_GT。<、=、!三个符号都要同样处理。
5.3 文法左递归导致递归下降栈溢出
现象:输入一个简单的表达式1+2;,程序直接段错误。
原因:如果你把文法写成expr -> expr + term,那 parse_expr 的第一行就会再次调用 parse_expr,永远到不了终止条件,栈直接爆掉。
解决:用前面写的 EBNF 循环版本,或者先手工消除左递归。expr改成term (( '+' | '-' ) term)*,对应代码里的 while 循环。这个坑在换语言写 Java 版时也一样会出现,Java 的栈深度虽然比 C 大,但同样经不起无限递归。
5.4 EOF 处理不当导致最后一条语句报错
现象:合法程序int a;被报告“缺少分号”。
原因:词法分析器在读到 EOF 后,如果每次调用 get_token 都返回同一个 TK_EOF,语法分析器可能在 EOF 之后再调用一次 advance(),又拿到一个 TK_EOF;如果 match 逻辑在 TK_EOF 上继续报错,最后一条语句就会莫名其妙失败。
解决:get_token 里遇到 EOF 只返回一次 TK_EOF,后续调用不再读文件。语法分析器里,parse_stmt_list 的循环条件是lookahead.type != TK_EOF && lookahead.type != TK_RBRACE,main 里也要在 TK_EOF 处停止解析。
5.5 报错后直接退出,测试脚本只过了第一个用例
现象:测试文件里有 10 个样例,第一个样例有语法错误,程序立即退出,后面 9 个正确样例一个都没跑到,测试结果非常难看。
原因:错误处理函数里直接调用了 exit()。
解决:把错误处理改成打印信息并计数,然后调用 synchronize() 跳过错乱区域继续解析。只有错误数超过阈值(比如 20)才退出。这个简单策略能让单次运行覆盖几乎全部测试用例,是课设验收时最加分的“健壮性”体现。
6. 把课设从“能跑”做到“能答辩”:三种自测与一个调试习惯
6.1 准备五类回归样例,用脚本一键跑完
课设交之前,我建议准备五类输入:完全合法的程序、含非法字符的样例、运算符写错的样例、括号不匹配的样例、变量重复声明的样例。把这些样例存成独立文件,再用一个 shell 脚本循环运行,把输出和预期文本对比。每次改动词法或语法代码之后都跑一遍这个脚本,实际效果比手动敲十条输入可靠得多。
6.2 加一个调试开关,让 token 流和语法树不再是黑匣子
在 main 里用参数控制输出:传-t时只打印 token 流,传-p时打印“每次进入非终结符函数的名称”,这相当于一个缩进版的语法树先序序列。答辩演示时,先对同一段代码打印 token 流,再打印解析过程,整个分析过程一目了然,老师就不需要盯着代码脑补程序在干什么了。
代码组织上,把 lexer、parser、symtab 拆成三个文件,头文件里只暴露必要接口。报告里放 token 分类表、EBNF 文法和递归下降函数的对应关系,这三样东西占一页纸就能讲清楚整个课设。我自己养成的一个习惯是:每次只改一个文件,然后立刻跑全部回归样例,语法改动不碰词法用例,词法改动不碰语法用例,等全绿了再验证两者联调。这个习惯帮我少翻车很多次,也希望帮到你。
本文还有配套的精品资源,点击获取