ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言词法分析器与语法分析器课设指南:从Token到语法树

C语言词法分析器与语法分析器课设指南:从Token到语法树 简介面向编译原理课程设计的C语言词法分析器与C-语言语法分析器报告书适合计算机相关专业学生、备考期末或课程设计答辩的读者参考。报告系统梳理C语言词法特点涵盖保留字、符号、标识符、数字、字符与字符串等Token定义及Token类型代码并给出基于正则表达式和DFA的完整词法分析器设计思路状态转移与分类判断一目了然同时说明语法分析器的工作原理可帮助理解编译器前端从字符流到语法树的处理过程。资源为单一doc文档共1个文件体积379KB内容包含DFA状态图、TokenType枚举、词法规则、界面与实现流程等关键细节便于直接查阅、修改或用于报告扩展。已有274人学习下载适合需要完成同类课程设计、撰写实验报告或快速掌握词法/语法分析要点的读者。1. 拿到课设先别动手写代码先想清楚这门“C语言词法分析器”到底要交什么C语言词法分析器和C语言语法分析器编译原理课程设计报告书——这个标题看起来是“两个程序加一份文档”实际上是一条完整的交付链条字符流进词法分析器出来 Token 流Token 流进语法分析器得出“合法或不合法”的结论。很多人第一步就跑偏花两天从网上凑一个能跑的 C 语言词法分析器结果报告里的流程图跟代码对不上答辩时被一个问题问穿。这门课设真正考的不是“能不能跑通”而是“能不能把一个字符串变成 Token再把 Token 变成一棵语法树并用文档把每一步解释得足够诚实”。下面按我自己做课设和帮别人改课设的路线来讲先拆需求再分别实现词法和语法最后用测试用例表和文档把代码焊成一份能过答辩的报告。适合正在写这份课设、以及想把编译原理前端彻底搞懂的读者。2. 词法分析器从字符流到 Token用 C 语言写一个带缓冲区扫描的状态机2.1 先定 Token 集合词法分析器的输出协议词法分析器要做的事是把源代码这个长长的 C 语言字符串切成一个个有意义的词法单元也就是 Token。第一步不是写代码而是先定义 Token 有哪些类型。这个类型表就是词法分析器和语法分析器之间的接口协议语法分析器只认这个表里的东西。typedef enum { TOK_IDENT, // 标识符如 count TOK_NUMBER, // 整数常数如 2024 TOK_KEYWORD, // 保留字如 int、return TOK_OPERATOR, // 运算符如 、-、 TOK_DELIMITER, // 界符如 ; ( ) { } TOK_EOF, // 输入结束 TOK_ERROR // 无法识别的字符 } TokenType;Token 还要带上文本本身和所在行号。行号不是可选项语法分析器报错时要指出第几行报告里的测试截图也靠它。用结构体包装typedef struct { TokenType type; char text[128]; int line; } Token;text 数组的长度关系到后面的缓冲区边界先记住这个 128。Token 类型表和报告里的“详细设计”章节可以直接对应。画一张表格写进文档也行但更重要的是让代码跟表对得上。类型识别规则例子TOK_IDENT字母或下划线开头后跟字母/数字/下划线count, _tmpTOK_NUMBER连续数字串2024, 0TOK_KEYWORD标识符中属于保留字的那部分int, if, while, returnTOK_OPERATOR单字符运算符 - * / TOK_DELIMITER分隔符; ( ) { }TOK_ERROR其他不可识别字符 # $课程设计里通常不要求覆盖全部 C 语言运算符单字符运算符加一两个双字符运算符如 、已经算完整。关键是报告里写清楚“本词法分析器覆盖 C 语言子集具体范围见 Token 定义表”不要含糊地说“支持 C 语言”。2.2 用 fread 把源码装进缓冲区再用指针逐字符扫词法分析器最常见的翻车点是输入方式不统一。有人用 getchar 从 stdin 一个一个读有人用 fgets 读一行处理一行还有人到语法分析阶段又用 scanf结果缓冲区里的换行符、残留字符互相打架Token 流错位。常见做法是先把整个源码一次性读进内存缓冲区之后所有扫描都在字符串上进行不再碰输入流。#define SRC_BUF_SIZE 8192 #define MAX_TOKEN_LEN 127 static char src[SRC_BUF_SIZE]; static char* pos src; // 当前扫描位置 static int line 1; void initTokenizer(FILE* fp) { size_t n fread(src, 1, SRC_BUF_SIZE - 1, fp); src[n] \0; pos src; line 1; }这里用 fread 而不是 fgets是因为源码文件可能很长逐行 fgets 还要处理换行符拼接fread 一次读完更省事。如果你更习惯 fgets逐行读入后去掉末尾的 \n 再拼到 src 后面也可以效果一样只是要多维护一个偏移量。扫描位置用 char* 指针 pos 维护。pos 只往前移动需要向前看时最多多看一个字符。遇到换行符更新 line 计数所有 Token 都带上当前 line。这段代码不需要第三方库纯 C 语言标准库就能跑在 VS Code 里配好 gcc 环境后gcc 命令行直接编译就能用。缓冲区大小取 8192对课设实验程序完全够用。如果测试文件更大需要改成动态分配但报告里可以写明“本设计面向千行以内的源码输入”这是一个诚实的边界条件。2.3 getNextToken分支扫描与关键字查表核心函数是 getNextToken。每调用一次返回一个 Token。扫描逻辑按字符类型走分支空白跳过、字母进标识符分支、数字进数字分支、运算符直接返回。Token getNextToken(void) { Token tk; tk.text[0] \0; tk.line line; // 跳过空白与换行维护行号 while (*pos isspace((unsigned char)*pos)) { if (*pos \n) line; pos; } if (*pos \0) { tk.type TOK_EOF; return tk; } // 标识符或关键字先按标识符扫描再查关键字表 if (isalpha((unsigned char)*pos) || *pos _) { char* p tk.text; while ((isalnum((unsigned char)*pos) || *pos _) (p - tk.text) MAX_TOKEN_LEN) { *p *pos; } *p \0; tk.type lookupKeyword(tk.text) ? TOK_KEYWORD : TOK_IDENT; return tk; } // 整数常数 if (isdigit((unsigned char)*pos)) { char* p tk.text; while (isdigit((unsigned char)*pos) (p - tk.text) MAX_TOKEN_LEN) { *p *pos; } *p \0; tk.type TOK_NUMBER; return tk; } // 运算符与界符先按单字符返回这里保留扩展双字符运算符的口子 tk.text[0] *pos; tk.text[1] \0; tk.type TOK_OPERATOR; return tk; }这段代码有四个关键点。第一isspace、isalpha 这些 ctype 函数接收的是 int用 unsigned char 强转能避免 char 为负时出现未定义行为这一点在报告里写出来会很专业。第二标识符识别完整后调用 lookupKeyword 判断是不是保留字。判断逻辑是“先识别成标识符再查表”不是“先枚举关键字再硬分割”否则输入 intx 会被错误拆成关键字 int 和标识符 x。第三数字分支只处理整数。如果要支持小数、十六进制需要扩展状态课设一般用整数就可以。第四运算符分支目前只处理单字符双字符运算符的实现放在第 5 章讲但代码里这个分支的位置已经预留好往后面接 peek 逻辑不会伤筋动骨。查关键字表用线性查找就够了。课设里保留字一般就 int、if、else、while、return 这几个数量在 10 个以内线性查找的复杂度和哈希没有可见差距但代码简单得多static const char* keywords[] { int, if, else, while, return, for }; #define KEYWORD_COUNT (sizeof(keywords) / sizeof(keywords[0])) int lookupKeyword(const char* s) { for (int i 0; i KEYWORD_COUNT; i) { if (strcmp(s, keywords[i]) 0) return 1; } return 0; }2.4 状态转移表与代码的对应关系写报告时词法分析器部分一般会画状态转移图或状态转移表。图我不在这里画但状态转移表可以列而且它和 getNextToken 的每个分支一一对应当前状态输入字符类别下一动作输出初始空白/换行跳过更新行号无初始字母/_进入标识符扫描无标识符字母/数字/_继续累积无标识符其他字符查关键字表并返回TOK_IDENT / TOK_KEYWORD初始数字进入数字扫描无数字数字继续累积无数字其他字符返回TOK_NUMBER初始运算符/界符直接返回TOK_OPERATOR / TOK_DELIMITER这张表的每一行在 getNextToken 里都有一小段对应的分支。写报告时把这个对应关系点出来老师一看就知道代码不是抄来的。这是整个课设最容易被忽略、也最容易拉开差距的地方。3. 语法分析器选递归下降还是 LR用 C 实现表达式分析与错误同步3.1 三种主流方案课设为什么首选递归下降语法分析器吃掉词法分析器生成的 Token 流按文法检查 Token 序列是否合法。主流方案有三个递归下降、LL(1) 预测分析、LR(1) 分析。课程设计里我几乎总是推荐递归下降。方案实现思路代码量报告解释成本答辩风险递归下降每个非终结符写一个 C 函数互相调用100200 行低低LL(1) 预测分析算 First/Follow 集显式预测表 栈模拟200 行以上中中LR(1)构造状态族、ACTION/GOTO 表很高高高递归下降的核心思想是用 C 语言的函数调用关系去模拟文法产生式。E 调用 TT 调用 F调用链就是推导过程。这个方案摆在报告里也最好讲不需要额外引入算符和状态表老师问任何一个函数是干什么的你都答得出来。LR(1) 也不是不能做很多学校的编译原理实验要求用 Flex/Bison 生成。但那是另一个题目的故事了标题里写的是“C语言语法分析器”说明要靠手写 C 代码。手写 LR 状态表工作量巨大而且报告容易变成一张大表答辩时被问“这个状态为什么合并”很容易卡住。3.2 先消左递归文法里不能写 E - E T用递归下降实现表达式语法分析文法要先设计成无左递归形式。常见的错误写法是 E - E T这个产生式一旦翻译成函数expr() 第一行就调用 expr()形成无限递归程序直接爆栈。正确的写法是把左递归转成右递归或循环。这里给出一套足够覆盖课设的表达式文法E - T { (|-) T } T - F { (*|/) F } F - NUM | ID | ( E )花括号表示零个或多个在代码里对应 while 循环。E 的循环读到一个 或 -就再解析一个 T这样连加连减都能处理而且不会递归调用 E 本身。严格说这已经不属于最原始的递归下降写法而是把 E 的产生式折叠进了循环里报告里可以说“用迭代代替了尾递归”这是一个能在答辩时加分的细节。3.3 expr、term、factor 三个函数递归下降最小骨架Token 流先要有一个统一的推进函数。全局变量 look 保存当前正在看的 Tokennext() 取下一个。语法分析器里所有函数都是基于 look 来决策。typedef enum { SYM_NUM, SYM_ID, SYM_PLUS, SYM_MINUS, SYM_STAR, SYM_SLASH, SYM_LPAREN, SYM_RPAREN, SYM_SEMI, SYM_END } Sym; typedef struct { Sym sym; int numVal; } Token; static Token look; static Token tokens[512]; static int pos 0; static int tokenCount 0; void next(void) { if (pos tokenCount - 1) look tokens[pos]; else look tokens[tokenCount - 1]; }next() 的越界保护很重要。Token 数组末尾放一个 SYM_END 哨兵pos 走到最后一个就不再前移这样所有函数在读到 SYM_END 时都能安全停止不会数组越界。这一点很多参考代码都没做靠运气不崩课设测试一复杂就原形毕露。然后是实现三个核心函数// E - T { (|-) T } void expr(void) { term(); while (look.sym SYM_PLUS || look.sym SYM_MINUS) { next(); term(); } } // T - F { (*|/) F } void term(void) { factor(); while (look.sym SYM_STAR || look.sym SYM_SLASH) { next(); factor(); } } // F - NUM | ID | ( E ) void factor(void) { if (look.sym SYM_NUM || look.sym SYM_ID) { next(); } else if (look.sym SYM_LPAREN) { next(); expr(); if (look.sym ! SYM_RPAREN) { error(missing right parenthesis); } next(); } else { error(unexpected token); } }这个骨架值得仔细讲一遍。expr 调用 termterm 调用 factorfactor 遇到左括号时又调用 expr形成间接递归。每一次递归调用在 C 语言运行时里都对应一层函数调用栈。经常有人问“C语言没有堆栈吗”其实 C 语言有调用栈语法分析器正是靠这个调用栈来完成嵌套结构的推导。没有栈括号嵌套就无从处理。factor 里对 SYM_ID 只做了跳过没有查符号表。这是刻意简化的设计本课设的语法分析器只负责判断 Token 序列“合不合法”不做语义检查。符号表的部分在第 4 章补上。报告里要写清楚这一层边界否则答辩时会被人问“标识符重复声明为什么没报错”。3.4 让分析器吃掉整个 Token 流主循环与分号同步前面三个函数只处理一个表达式。要分析一整段程序还需要一个主循环不断调用 expr直到遇到 SYM_END。分号在这里作为语句结束的标志void parseProgram(void) { next(); // 预读第一个 Token while (look.sym ! SYM_END) { expr(); if (look.sym SYM_SEMI) { next(); } else { error(expected ; after expression); } } }注意 next() 要先调用一次让 look 持有第一个 Token这是递归下降的通用约定look 永远是“当前待处理的 Token”所有函数消耗完自己关心的 Token 后不主动多读交给上层判断。这个“预读一个 Token”的做法在报告里叫 lookahead写出来会显得你对原理有理解。目前的错误处理是遇到问题就报错停止。第 4 章会把它升级成“报错后跳过若干 Token 继续分析”的恐慌模式那才是报告里值得单独写一小节的错误恢复能力。4. 符号表与错误处理课程设计报告里值钱的两个模块4.1 符号表用数组加线性查找就够别急着上哈希符号表的作用是记录源程序里出现的变量名、类型、声明行号。词法分析器只负责把标识符切出来谁声明了谁那是语法和语义阶段的事。课程设计里符号表通常做得比较朴素一个结构体数组插入和查找都用线性扫描。#define MAX_SYMBOLS 128 typedef struct { char name[32]; int type; // 0:int, 1:char课设里够用 int line; // 声明所在行 } SymEntry; static SymEntry table[MAX_SYMBOLS]; static int symCount 0; int lookup(const char* name) { for (int i 0; i symCount; i) { if (strcmp(table[i].name, name) 0) return i; } return -1; } int insert(const char* name, int type, int line) { if (symCount MAX_SYMBOLS) return -1; strncpy(table[symCount].name, name, 31); table[symCount].name[31] \0; table[symCount].type type; table[symCount].line line; return symCount; }为什么不用哈希表课设程序通常几十个变量顶天了线性查找最坏情况也是几十次 strcmp性能上没有差别。哈希表的好处要到成千上万个符号才体现出来但它会引入哈希函数、冲突处理、装载因子一堆新概念报告写起来长答辩还可能被追问。把哈希表写进“改进方向”里比写进正文更合适。一个要注意的细节strcpy 换成 strncpy并手动在末尾置 \0。这是 C 语言字符串函数的经典边界坑课设报告里出现“防止缓冲区溢出”的字样是实打实的加分点。4.2 语法分析器如何和符号表协作有了符号表语法分析器就能在遇到声明时登记、在使用时查找。最简单的做法是在 parseProgram 里识别类似“int a;”的声明结构遇到 int 关键字后把下一个标识符登记进符号表。void parseDeclaration(void) { // 当前 look 是 SYM_INT先跳过 Token name look; if (name.sym SYM_ID) { if (lookup(name.text) -1) { insert(name.text, 0, name.line); } else { error(duplicate declaration); } } next(); // 期待分号结束声明 if (look.sym SYM_SEMI) next(); else error(expected ; in declaration); }这里有个取舍要说清楚如果你的课设只需覆盖表达式和简单变量不需要完整的声明语句这个函数可以不写在报告里注明“符号登记在词法分析阶段遇到标识符时完成”。但我建议至少做一个重复声明检查因为它是语义分析最简单的入口能让你报告里的“符号表”章节不悬空。在 factor 处理 SYM_ID 时就可以加一道“变量未声明”检查if (look.sym SYM_ID) { if (lookup(look.text) -1) { error(undefined identifier); } next(); }课设到这个程度已经超出平均水准。但注意一旦加上这个检查测试用例表里必须有一条“使用未声明变量”的用例否则逻辑就是死的。4.3 错误恢复从“报错即停”升级成恐慌模式编译原理课本里讲过 panic mode 错误恢复中文一般叫恐慌模式。思路很简单发现语法错误后不直接终止整个程序而是丢弃后续 Token直到遇到一个可靠的同步点比如分号或右括号再从那里继续分析。这样一次输入能报出多条错误比“遇到第一个错就死”实用得多。void panic(const char* msg, int line) { fprintf(stderr, syntax error at line %d: %s\n, line, msg); while (look.sym ! SYM_SEMI look.sym ! SYM_RPAREN look.sym ! SYM_END) { next(); } }调用方式是把 factor 里的 error 换成 panic并传入当前 Token 的行号} else { panic(unexpected token, look.line); }注意看 while 循环的终止条件SYM_END 必须在列表里否则文件到尾还没遇到分号next() 会一直推进到数组末尾靠 3.3 节 next() 里的越界保护停在哨兵 Token 上但 panic 会死循环。这是错误恢复实现里最容易忽略的边界写报告时如果画了恐慌模式的流程图一定要把 END 这个出口画上去。错误信息格式也值得规范一下。词法错误、语法错误统一用“类型 行号 具体描述”输出测试截图会非常清晰syntax error at line 3: expected ; after expression4.4 报告里这两个模块怎么摆写报告时符号表和错误处理不要放在最后当附赠内容它们应该是独立的两节放在“详细设计”里面。符号表讲数据结构、插入查找流程、重复声明处理错误处理讲恐慌模式的同步点选择、错误信息格式、一次输入多条错误的验证结果。这两节能直接把你和只会贴两段参考代码的人区分开。5. 课设避坑指南缓冲区、关键字表与错误恢复五个最容易翻车的位置下面这些坑都是从真实课设里反复看到的问题每条按“现象、原因、解决”讲清楚。它们有一个共同点都是那种“自己在机器上调一晚上调不出来、别人一看就知道问题在哪”的细节。5.1 输入与缓冲区的坑读函数混用Token 流一开始就是错的现象是词法分析器吐出的第一个 Token 是空串或者关键字后面永远多一个换行符。原因是有人用 fgets 逐行读源代码又用 getchar 测试某个函数两个读函数共享同一个输入流把缓冲区里的残留字符送进了词法分析。解决方式很简单源码一律用 fread 或 fgets 一次性读进内存字符串词法分析只操作这个字符串整个程序里不再出现从 stdin 逐字符读入的代码。这个约定写进报告的数据输入设计里能省掉一半测试期的玄学问题。补充一个环境相关的提醒很多人在 VS Code 里配置好 C 语言环境点运行能出结果就把课设当成完成了。VS Code 只是编辑器gcc 才是编译器。建议把代码拿到命令行窗口用 gcc -Wall 直接把所有 .c 文件编一遍把警告清零再谈功能正确。换一个环境就翻车的代码拿到答辩现场演示的风险很高。5.2 词法扫描的坑双字符运算符和关键字边界先说一个很隐蔽的坑报告状态图和代码对不上。现象是流程图里画了十几条状态转移箭头代码里却是一长串 if-else找不到对应的状态变量。原因是代码和流程图分别从两个来源拼出来的。解决方式是在写代码前先按 2.4 节那样列状态转移表然后让代码每个分支注释对应表里的一行。这样做不是为了好看而是为了答辩老师指着图问“这一步怎么实现”你能直接翻到代码里对应行比在脑子里现编可靠得多。然后是双字符运算符。现象是输入 ab词法分析器先返回一个 再返回一个 语法分析器看到连续两个运算符直接报错。原因是运算符分支只做了单字符匹配没有向前看一个字符。解决方式是在运算符分支做 peek读 后看一眼下一个字符是不是 是就合并成 再返回。if (*pos ) { pos; if (*pos ) { tk.text[0] ; tk.text[1] ; tk.text[2] \0; pos; } else { tk.text[0] ; tk.text[1] \0; } tk.type TOK_OPERATOR; return tk; }、、! 同理每个双字符运算符最多多写三行。报告中把“支持双字符运算符”写进功能列表测试用例表加一行 这个坑就变成了加分项。还有一个词法边界关键字和标识符的切分。现象是输入 intx程序却把 int 当关键字返回x 单独成标识符。原因是扫描时先判断了关键字看到 int 就立即返回。正确做法是先把完整字符串扫出来再查关键字表。intx 应该整个是标识符int x 才是关键字加标识符。这个用例一定要写进测试表很多参考代码都在这里有个隐藏的小毛病。5.3 错误处理的两个坑exit 终止和数组哨兵第一个坑递归下降遇到第一个错误就 exit报告却写“具备错误恢复能力”。现象是源文件里有两个错误程序只报第一个就退出截图和报告描述不符。原因是代码里直接调用了 exit(1)。解决方式是把 exit 换成 4.3 节的 panic跳过分号后继续分析。同时报告用词要准写“通过恐慌模式实现基本错误恢复”不要写“完善的错误处理”给自己留余地。第二个坑Token 数组末尾没有哨兵遇到意外 EOF 直接越界。现象是输入缺少分号或括号不匹配时程序崩溃或死循环而不是打印错误信息。原因是 next() 里没有做数组边界保护。解决方式是在 tokens 数组末尾放一个 SYM_END 哨兵tokens[tokenCount].sym SYM_END; tokenCount;next() 只推进到最后一个有效 Token 就不再移动。这个设计在 3.3 节已经写过到这里应该能看出它的价值所有错误处理路径都依赖这个哨兵稳定停在末尾。没有它panic 里那个 while 循环可能直接读到内存垃圾。6. 用一张测试用例表收尾把代码、报告和答辩对齐的最后一公里6.1 测试用例表就是报告里的“实验数据”报告中最容易被老师翻看的部分是测试与结果分析。与其贴十几张截图不如做一张有预期、有实际、有结论的测试用例表。表格做得好报告能厚三页答辩时也方便指着某一行讲。输入片段预期输出实际输出结论int a;KEYWORD(int), ID(a), SEMI一致通过a 1 2 * 3;ID(a), OP(), NUM(1), OP(), NUM(2), OP(*), NUM(3), SEMI一致通过if (a b) return 1;KEYWORD(if), LPAREN, ID(a), OP(), ID(b), RPAREN, KEYWORD(return), NUM(1), SEMI一致通过1 2 )语法错误报告第 1 行缺左括号一致通过intx;ID(intx), SEMI不切成 KEYWORD(int)ID(x)一致通过这五行的覆盖点值得说一下第一行验证关键字和声明第二行验证运算符优先级12*3 的结果不是重点Token 顺序才是第三行验证双字符运算符 第四行验证错误恢复第五行验证先识别标识符再查关键字表这是词法分析器最常见的错误场景一定要放。6.2 递归嵌套太深时C 语言的调用栈会先扛不住递归下降分析器靠 C 语言的函数调用栈工作这正好回答了一个常被问的问题C 语言没有堆栈吗有调用栈是运行时维护的它不体现在你写的代码里但每个函数调用都在压栈。expr、term、factor 互相调用括号嵌套一万层时栈会溢出程序崩溃。课设不需要处理一万层但可以做一个深度保护顺便在答辩时展示你对边界条件的思考static int depth 0; #define MAX_PARSE_DEPTH 1024 void factor(void) { if (depth MAX_PARSE_DEPTH) { panic(nesting too deep, look.line); depth--; return; } // 原有逻辑 depth--; }测试时写一个超过 1024 层括号的输入程序应正常报错而不是崩溃。把这个测试用例也加进 6.1 的表格里报告就多了一个有深度的边界测试项。6.3 提交前最后一遍对账报告章节可以按这个结构组织需求分析、总体设计、详细设计、测试报告、心得体会。其中详细设计对应本文第 2 到 4 章的内容测试报告对应 6.1 的表格总体设计画一张词法模块到语法模块的调用关系图就够了。我自己改课设时最常说的一句话是代码和报告必须互相印证。对着代码里的每个函数在报告目录里找对应的说明报告里出现的每个功能在代码里能翻到实现。不一致时以代码为准改报告而不是改代码去迁就一份抄来的文档。整个过程不复杂但对齐之后代码、测试用例表、流程图、答辩是一体的你不需要背报告因为每一行都是你自己写的。希望你也能用这个方式把这份课设收得干净利落希望帮到你。本文还有配套的精品资源点击获取
返回列表