简介:本资源是面向计算机专业本科生及编译原理初学者的实践型实验材料,聚焦词法分析器这一编译器前端核心模块的设计与实现,帮助学习者打通从理论(如Token分类、正则匹配、状态机建模)到C语言编码落地的关键环节。压缩包共3个文件,含1个可编译运行的C++源码文件(程序.cpp),用于完成字符流扫描、关键字/标识符/常量/运算符等标记识别及错误处理;1份结构完整的实验报告(.docx),涵盖实验目的、设计思路、测试用例输出与典型问题排错过程;1个配套测试文本(.txt),包含多类C语言语法片段,便于验证分析器对边界情况(如注释、转义、非法字符)的鲁棒性。资源大小仅86KB,轻量易用,已有2170人下载学习。读者可直接编译调试源码、对照报告理解设计逻辑、利用测试文件检验功能完整性,是掌握编译器第一阶段工作原理的扎实入门范例。
1. 为什么写个词法分析器要卡在isalnum()和状态跳转上三天?——C语言手写Lexer的硬核落地路径
你不是在学编译原理,你是在调试一个「字符流到token流」的黑匣子。当gcc -o lexer lexer.c成功,但输入"int a = 123;"却只吐出ID、ID、ERROR、ERROR时,问题不在语法,而在你没真正理解:词法分析器不是字符串切分器,而是带状态迁移的有限自动机(DFA)在C语言里的血肉实现。这个.zip包里没有现成可跑的exe,它是一份要求你亲手把《编译原理》第二章的NFA→DFA→代码映射走完的实操契约。适合刚啃完龙书第二章、正对着翁恺C语言课后题发懵、或被山科大/燕山大学编译原理实验报告 deadline 追着跑的本科生;也适合想用最轻量级方式验证自己是否真懂「识别」本质的嵌入式工程师——毕竟连单片机上跑的轻量脚本解释器,第一步也是手撸lexer。它不依赖Flex/Bison,不碰Java或Python,就用标准C库+手动状态机,把#include <ctype.h>用到骨子里。这不是玩具,是编译器地基的第一块砖:你得亲手夯平。
2. 从DFA图谱到C函数:手写状态机的三步落地方案
词法分析器的核心不是算法,是状态定义、转移判定、token组装三者的咬合。很多同学直接抄网上的“switch-case版lexer”,结果输入带小数点的浮点数就崩,因为没处理123.和123.45的状态歧义。我们按真实工程节奏拆解:先画图,再编码,最后验证。
2.1 状态图必须画到能覆盖所有实验要求的最小完备集
清华第三版第二章要求识别:关键字(if,else,while,return等)、标识符(字母开头+字母数字下划线)、整数常量(十进制,无前导零)、浮点数(带小数点或e/E)、运算符(+,-,*,/,=,==,!=,<,<=,>,>=)、分隔符(;,{,},(,),[,])、注释(//行注释)。注意:实验通常不要求处理块注释/* */或字符串字面量,这是后续实验内容,强行加会导致状态爆炸。我们据此设计12个核心状态(含start和error):
| 状态名 | 含义 | 关键转移条件 |
|---|---|---|
START | 初始态,跳过空白 | isspace(c)→ 自循环;isalpha(c)→ID_START;isdigit(c)→NUM_START;c=='/'→SLASH;c=='+'/'-'/'*'/'='/'<'/'>'→ 对应运算符起始态 |
ID_START | 标识符首字符已读 | `isalnum(c) |
IN_ID | 标识符中间字符 | 同上 → 继续;else→ 输出ID token并回退 |
NUM_START | 数字首字符已读 | isdigit(c)→IN_INT;c=='.'→DOT_AFTER_NUM;`c=='e' |
IN_INT | 整数主体 | isdigit(c)→ 继续;c=='.'→DOT_AFTER_INT;`c=='e' |
DOT_AFTER_INT | 123.中的点 | isdigit(c)→AFTER_DOT;else→ 输出FLOAT token(如123.) |
AFTER_DOT | 小数点后数字 | isdigit(c)→ 继续;`c=='e' |
提示:状态名用全大写宏定义,避免magic number。
#define START 0,#define ID_START 1… 比硬写数字可维护得多。状态数控制在15以内,否则switch嵌套易错。
2.2 C代码骨架:用enum+switch实现状态机主循环
不要用递归或复杂结构体。最稳的是:一个state变量存当前状态,一个char buffer[256]存当前token字符,一个int pos=0记buffer位置。每次读一个字符,根据state和c决定下一步。关键逻辑在while((c = fgetc(fp)) != EOF)主循环内:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #define MAX_TOKEN_LEN 256 typedef enum { START, ID_START, IN_ID, NUM_START, IN_INT, DOT_AFTER_INT, AFTER_DOT, EXP_START, IN_EXP, SLASH, COMMENT, OPERATOR_START, DONE, ERROR } State; int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "Usage: %s <source_file>\n", argv[0]); return 1; } FILE *fp = fopen(argv[1], "r"); if (!fp) { perror("fopen"); return 1; } int c; State state = START; char token[MAX_TOKEN_LEN]; int pos = 0; while ((c = fgetc(fp)) != EOF) { switch (state) { case START: if (isspace(c)) { // 跳过空格、tab、换行 continue; } else if (isalpha(c) || c == '_') { token[pos++] = c; state = ID_START; } else if (isdigit(c)) { token[pos++] = c; state = NUM_START; } else if (c == '/') { state = SLASH; } else if (strchr("+-*/=<>!&|", c)) { token[pos++] = c; state = OPERATOR_START; } else { // 单字符分隔符如 ; { } ( ) [ ] printf("SEPARATOR: %c\n", c); state = START; } break; case ID_START: if (isalnum(c) || c == '_') { token[pos++] = c; } else { token[pos] = '\0'; // 判断是否为关键字 if (strcmp(token, "if") == 0 || strcmp(token, "else") == 0 || strcmp(token, "while") == 0 || strcmp(token, "return") == 0) { printf("KEYWORD: %s\n", token); } else { printf("ID: %s\n", token); } // 回退一个字符(unget) ungetc(c, fp); state = START; pos = 0; } break; case NUM_START: if (isdigit(c)) { token[pos++] = c; state = IN_INT; } else if (c == '.') { token[pos++] = c; state = DOT_AFTER_INT; } else if (c == 'e' || c == 'E') { token[pos++] = c; state = EXP_START; } else { token[pos] = '\0'; printf("INT: %s\n", token); ungetc(c, fp); state = START; pos = 0; } break; // ... 其他状态case(IN_INT, DOT_AFTER_INT等)需补全 default: fprintf(stderr, "Unexpected state: %d\n", state); state = ERROR; } } fclose(fp); return 0; }这段代码不是最终版,但它是可运行的最小骨架。关键点:
ungetc(c, fp)是回退字符的唯一合法方式,比自己维护last_char变量更可靠;token[pos] = '\0'必须在输出前做,否则printf("%s")会越界;- 关键字判断用
strcmp而非==,C语言字符串比较陷阱; pos重置时机:每次成功输出token后设为0,避免残留。
2.3 token类型枚举与输出格式:对接实验报告要求
实验报告通常要求输出形如KEYWORD: if、ID: abc、INT: 123。定义清晰的token类型枚举,让后续语法分析器能直接消费:
typedef enum { TOKEN_KEYWORD, TOKEN_ID, TOKEN_INT, TOKEN_FLOAT, TOKEN_OPERATOR, TOKEN_SEPARATOR, TOKEN_COMMENT, TOKEN_ERROR } TokenType; // 输出函数封装 void print_token(TokenType type, const char* value) { switch (type) { case TOKEN_KEYWORD: printf("KEYWORD: %s\n", value); break; case TOKEN_ID: printf("ID: %s\n", value); break; case TOKEN_INT: printf("INT: %s\n", value); break; case TOKEN_FLOAT: printf("FLOAT: %s\n", value); break; case TOKEN_OPERATOR:printf("OPERATOR: %s\n", value);break; case TOKEN_SEPARATOR:printf("SEPARATOR: %c\n", *value);break; default: printf("ERROR: %s\n", value); } }这样,当识别到==时,调用print_token(TOKEN_OPERATOR, "=="),比散落的printf更易维护。所有输出必须严格匹配实验指导书的格式要求,否则自动批改系统会判错。
3. 运算符和分隔符的边界处理:为什么==总被切成两个=?
这是词法分析器最经典的翻车现场。学生常把==识别成两个独立的=,因为没实现「最长匹配原则」(Maximal Munch Rule)。编译器规定:当多个模式都能匹配时,选最长的那个。==必须整体识别为EQ,而不是=+=。
3.1 运算符状态机:用嵌套switch处理多字符运算符
不能简单遇到=就输出ASSIGN。要进入OPERATOR_START状态,读下一个字符再决策:
case OPERATOR_START: switch (token[0]) { case '=': if (c == '=') { // == printf("OPERATOR: ==\n"); state = START; pos = 0; } else if (c == '>') { // => (若支持)或单独= printf("OPERATOR: =\n"); ungetc(c, fp); state = START; pos = 0; } else { // 单独= printf("OPERATOR: =\n"); ungetc(c, fp); state = START; pos = 0; } break; case '!': if (c == '=') { printf("OPERATOR: !=\n"); state = START; pos = 0; } else { printf("OPERATOR: !\n"); ungetc(c, fp); state = START; pos = 0; } break; case '<': if (c == '=') { printf("OPERATOR: <=\n"); state = START; pos = 0; } else if (c == '<') { printf("OPERATOR: <<\n"); // 若支持位移 state = START; pos = 0; } else { printf("OPERATOR: <\n"); ungetc(c, fp); state = START; pos = 0; } break; // ... 其他运算符 default: printf("OPERATOR: %c\n", token[0]); ungetc(c, fp); state = START; pos = 0; } break;注意:
token[0]存的是第一个运算符字符(如=),c是下一个字符。这里用switch而非if-else if,提升可读性。每个分支末尾必须重置state和pos,否则状态残留。
3.2 分隔符表驱动:避免硬编码{,}等字符
与其在START状态里写一堆else if (c == '{'),不如建一张分隔符映射表:
typedef struct { char ch; const char* name; } SeparatorMap; SeparatorMap separators[] = { {';', "SEMICOLON"}, {'{', "LBRACE"}, {'}', "RBRACE"}, {'(', "LPAREN"}, {')', "RPAREN"}, {'[', "LBRACKET"}, {']', "RBRACKET"}, {'"', "QUOTE"}, {'\'', "APOSTROPHE"} }; #define NUM_SEPARATORS (sizeof(separators)/sizeof(separators[0])) // 在START状态中: for (int i = 0; i < NUM_SEPARATORS; i++) { if (c == separators[i].ch) { printf("%s: %c\n", separators[i].name, c); state = START; break; } }这样增删分隔符只需改数组,不用动逻辑。实验指导书若要求输出LBRACE而非{,此表就是刚需。
3.3 注释处理://行注释的终止条件陷阱
//后直到换行符\n或EOF都应忽略。常见错误是只读一个字符就跳出:
case SLASH: if (c == '/') { // 进入行注释 state = COMMENT; } else { // 单斜杠除法 printf("OPERATOR: /\n"); ungetc(c, fp); state = START; } break; case COMMENT: if (c == '\n' || c == EOF) { // 注释结束,回到START state = START; } // else: 丢弃c,继续COMMENT状态 break;关键:COMMENT状态里不存c到token,也不输出任何东西,纯粹消耗字符。ungetc在这里绝对禁止——注释内容不该被任何后续状态读到。
4. 避坑指南:那些让实验报告打零分的隐藏雷区
别怪老师扣分严,这些坑都是往届学生用血泪填平的。以下每一条都对应真实翻车案例,按「现象→原因→解决」给出可执行方案。
4.1 现象:输入123abc输出INT: 123和ID: abc,但实验要求ID: 123abc
原因:状态机在IN_INT状态遇到字母时,错误地切分了token,而非转入IN_ID。DFA设计缺陷:数字后接字母应视为标识符(如123abc是合法ID),而非数字+ID。
解决:修改IN_INT状态逻辑——遇到非数字字符时,不立即输出INT,而是检查该字符是否为isalnum(c)||c=='_'。若是,则将整个token(含数字部分)作为ID处理,并重置pos重新填充:
case IN_INT: if (isdigit(c)) { token[pos++] = c; } else if (isalnum(c) || c == '_') { // 数字开头的ID,如 123abc token[pos++] = c; // 把字母也加进去 state = IN_ID; // 转入ID状态 } else { token[pos] = '\0'; printf("INT: %s\n", token); ungetc(c, fp); state = START; pos = 0; } break;4.2 现象:a++被识别为ID: a和OPERATOR: +,漏掉第二个+
原因:++是双字符运算符,但状态机在OPERATOR_START中只处理了+后跟+的情况,却忘了+后跟其他字符(如空格)时,应输出单个+并回退。
解决:在OPERATOR_START的case '+'分支末尾,必须加else兜底:
case '+': if (c == '+') { printf("OPERATOR: ++\n"); state = START; pos = 0; } else if (c == '=') { printf("OPERATOR: +=\n"); state = START; pos = 0; } else { // 单个+ printf("OPERATOR: +\n"); ungetc(c, fp); // 把非+字符推回去 state = START; pos = 0; } break;4.3 现象:文件末尾无换行符时,最后一个token丢失
原因:主循环while((c = fgetc(fp)) != EOF)在读到EOF时退出,但此时c已是EOF,导致最后一个token来不及输出。
解决:循环结束后,检查state是否处于可输出状态(如IN_ID,IN_INT等),强制flush:
// 主循环结束后 if (state == IN_ID || state == ID_START) { token[pos] = '\0'; if (strcmp(token, "if") == 0 || /* ... */) { printf("KEYWORD: %s\n", token); } else { printf("ID: %s\n", token); } } else if (state == IN_INT || state == NUM_START) { token[pos] = '\0'; printf("INT: %s\n", token); } else if (state == AFTER_DOT) { token[pos] = '\0'; printf("FLOAT: %s\n", token); } // ... 其他可终结状态4.4 现象:中文注释或UTF-8文件里出现乱码,程序崩溃
原因:fgetc()返回int,但char可能为负值(如UTF-8多字节字符的高位字节),传给isalpha()等函数导致未定义行为。
解决:强制转换为unsigned char:
c = fgetc(fp); if (c == EOF) break; unsigned char uc = (unsigned char)c; // 关键! if (isalpha(uc)) { ... }同时,实验通常限定ASCII源码,务必用notepad++或vscode将测试文件保存为ANSI或UTF-8 without BOM,避免BOM头干扰。
4.5 现象:0123被识别为INT,但八进制常量应报错或特殊处理
原因:isdigit(c)对'0'返回true,0123被当作十进制读取。但C语言中前导零表示八进制,实验若要求严格遵循C规则,需单独处理。
解决:在NUM_START状态,若首字符为'0',则后续只能跟0-7:
case NUM_START: if (c == '0') { token[pos++] = c; state = IN_OCTAL; // 新增状态 } else if (isdigit(c)) { token[pos++] = c; state = IN_INT; } else if (c == '.') { ... } break; case IN_OCTAL: if (c >= '0' && c <= '7') { token[pos++] = c; } else { token[pos] = '\0'; printf("OCTAL: %s\n", token); // 或按实验要求报错 ungetc(c, fp); state = START; pos = 0; } break;5. 测试驱动开发:用5个关键测试用例验证lexer完备性
写完代码不等于完成,必须用边界用例锤炼。我一般用这5个文件做回归测试,每个都直击一个易错点。把它们放进test/目录,写个简单shell脚本批量跑:
#!/bin/bash echo "=== Running lexer tests ===" for f in test/*.c; do echo "--- Testing $f ---" ./lexer "$f" | head -n 10 # 只看前10行,防长输出 echo "" done5.1 测试用例设计表:覆盖所有状态迁移路径
| 用例文件 | 内容示例 | 验证重点 | 预期关键输出 |
|---|---|---|---|
test1_simple.c | int a = 123; | 基础关键字、ID、INT、分隔符 | KEYWORD: int,ID: a,OPERATOR: =,INT: 123,SEPARATOR: ; |
test2_float.c | float x = 3.14e-2; | 浮点数状态链(NUM_START→DOT_AFTER_INT→AFTER_DOT→EXP_START→IN_EXP) | KEYWORD: float,ID: x,OPERATOR: =,FLOAT: 3.14e-2 |
test3_operator.c | if (a == b && c != d) { a++; } | 多字符运算符(==,!=,&&,++)和括号匹配 | KEYWORD: if,SEPARATOR: (,OPERATOR: ==,OPERATOR: &&,OPERATOR: !=,OPERATOR: ++,SEPARATOR: {,SEPARATOR: } |
test4_edge.c | abc123 def_456 0x123 123abc | ID与数字混合、下划线、非法十六进制(0x) | ID: abc123,ID: def_456,ID: 0x123,ID: 123abc(若实验不要求十六进制,0x123应为ID) |
test5_comment.c | int x = 1; // this is comment\ny = 2; | 行注释吞掉中间内容 | KEYWORD: int,ID: x,OPERATOR: =,INT: 1,SEPARATOR: ;,ID: y,OPERATOR: =,INT: 2,SEPARATOR: ; |
注意:
test4_edge.c中0x123的处理取决于实验要求。若只要求十进制整数,则0x123是合法ID;若要求识别十六进制,需新增0x前缀状态。务必以实验指导书为准,别自行加需求。
5.2 自动化diff验证:用diff代替人眼比对
手动看输出太累。为每个测试用例准备黄金标准输出(golden output):
# 生成黄金标准 ./lexer test/test1_simple.c > test/test1_simple.golden # 运行当前版本并比对 ./lexer test/test1_simple.c > test/test1_simple.out diff test/test1_simple.golden test/test1_simple.out如果diff无输出,说明通过。把这逻辑写进Makefile,make test一键跑全:
TESTS = test1_simple test2_float test3_operator test4_edge test5_comment test: $(TESTS:%=test_%) @echo "All tests passed!" test_%: ./lexer test/$*.c > test/$*.out diff test/$*.golden test/$*.out .PHONY: test5.3 内存安全加固:防止buffer overflow的三道防线
char token[256]看着安全,但恶意输入可能溢出。加三道保险:
- 长度检查:每次
token[pos++] = c前,加if (pos >= MAX_TOKEN_LEN-1) { /* 错误处理 */ } - 强制截断:在输出前,
token[MAX_TOKEN_LEN-1] = '\0',确保printf("%s")安全 - 错误恢复:当token超长时,清空buffer,跳过当前token,进入
ERROR状态并打印警告:
if (pos >= MAX_TOKEN_LEN-1) { fprintf(stderr, "Warning: Token too long at line %d, truncated\n", line_no); pos = 0; // 重置 state = START; // 跳过剩余字符直到空白或分隔符 while ((c = fgetc(fp)) != EOF && !isspace(c) && strchr(";{}()[]", c) == NULL) {} if (c != EOF) ungetc(c, fp); }这比程序崩溃好一万倍——实验报告里写“已处理超长token”比“段错误”得分高得多。
6. 从实验到生产:如何把课堂lexer升级成可用的解析器前端
做完实验别急着删代码。这个lexer骨架,稍加改造就能变成真实项目的解析器前端。我去年给一个工业PLC配置脚本写的轻量解释器,lexer部分就脱胎于此,只是加了三处关键升级。
6.1 支持行号追踪:让错误定位不再靠猜
实验不考,但实际开发必备。在main里加int line_no = 1;,每次读到\n时line_no++。在print_token里加行号参数:
void print_token_with_line(TokenType type, const char* value, int line) { printf("%s: %s (line %d)\n", type_name[type], value, line); } // 在START状态中: if (c == '\n') line_no++;这样输出变成ID: a (line 5),配合gdb调试时能秒定位。别小看这行代码,它省下的debug时间够你多喝三杯咖啡。
6.2 token结构体升级:为语法分析器提供丰富元数据
实验只要求输出字符串,但真实parser需要更多:
typedef struct { TokenType type; char value[MAX_TOKEN_LEN]; int line; int col; // 列号,需在读字符时计数 int len; // 实际长度,避免strlen开销 } Token; Token current_token; // 全局token,lexer填,parser读然后print_token变成fill_token(¤t_token, TOKEN_ID, "abc", line_no, col)。这样parser拿到的是结构体,不是裸字符串,扩展性翻倍。
6.3 错误恢复策略:让lexer在错误后继续工作
实验允许遇到ERROR就退出,但生产环境必须容错。在ERROR状态里,跳过当前字符,尝试同步到下一个;或}:
case ERROR: fprintf(stderr, "Lexical error at line %d\n", line_no); // 同步:跳过直到分隔符 while ((c = fgetc(fp)) != EOF) { if (c == ';' || c == '}' || c == '\n') { ungetc(c, fp); break; } } state = START; pos = 0; break;这能让lexer在int a == b;这种错误后,继续识别后面的int c = 1;,而不是直接挂掉。
6.4 性能微优化:用查表替代ctype.h函数调用
isalpha(c)等函数有函数调用开销。对高频调用(如START状态),用静态查找表:
static char is_alpha[256] = {0}; static char is_digit[256] = {0}; void init_char_table() { for (int i = 'a'; i <= 'z'; i++) is_alpha[i] = 1; for (int i = 'A'; i <= 'Z'; i++) is_alpha[i] = 1; for (int i = '0'; i <= '9'; i++) is_digit[i] = 1; } // 使用时 if (is_alpha[(unsigned char)c]) { ... }在嵌入式或高频场景,这能省下几个周期。但对学生实验,ctype.h完全够用,别为了炫技增加复杂度。
我带过的实习生,第一版lexer总在ungetc和状态重置上栽跟头。后来我让他们先写个printf("DEBUG: state=%d, c=%c\n", state, c);埋点,跑test1_simple.c看状态流转,三分钟就找到ID_START没回退的bug。词法分析器不是魔法,是状态、字符、动作的确定性组合。你画对DFA,代码就只是翻译;你漏一个状态,调试就变成玄学。希望这篇笔记里那些踩过的坑、调过的参数、写废的测试用例,能帮你少熬两夜。希望帮到你。
本文还有配套的精品资源,点击获取