C语言手写编译器前端:词法分析到四元式生成实战解析
简介面向编译原理课程设计与综合实践的C语言源码资源包完整实现了一个小型编译程序核心目标是将高级语言源代码转换为四元式中间表示功能模块涵盖词法分析、语法分析、语义分析、代码生成等编译器关键阶段可帮助读者系统理解整个编译流程适合计算机专业学生、课程设计人员或编译原理自学者作为参考模板。包体共含四个文件以C/C源文件为主辅以Markdown格式的设计说明和License许可文件压缩包仅为14KB结构紧凑便于快速查阅与运行验证。目前已有287人学习下载实践思路具备参考价值。文档与源码相互呼应详细展示了词法规则、上下文无关文法及四元式生成算法学习者可直接复用基础框架也能在此基础上进行常量折叠、死代码消除等简单优化或对照教材深化对中间代码生成的理解。1. 先搞清楚这份小型编译程序到底在做什么这份课程设计跟市面上那些跑个 hello world 就交差的 C 语言练手完全不是一个路数它要你把一段类 C 的高级语言源码经过词法分析、语法分析、语义检查最终翻译成四元式序列。四元式长这样(, a, b, t1)是编译原理教材里三地址码的变种也是从源代码到目标机器码之间最关键的中间表示。换句话说这份资源不是「教你写一个解释器」而是「带你把编译器最核心的前半段跑通」。我拆完这套源码的感受是它把编译原理课上最劝退的三个部分——手写词法扫描器、递归下降语法分析、语义动作与四元式生成——全部用 C 语言落地了而且没有依赖 Flex / Yacc 这类工具自动生成全部手写。这意味着你能从字符指针一级一级看下去看到「一个变量是怎么从源代码变成符号表条目、再从符号表条目变成四元式操作数」的完整路径。适合谁正在做编译原理课程设计、需要代码参考的本科生想搞懂「编译器前端到底在干嘛」但啃不动龙书的开发者以及准备面试前想快速把编译流程捡起来的从业者。如果你只是想要一个能编译出 exe 的玩具这份资源不适合你如果你想弄明白 CST、AST、中间代码这些概念在 C 语言里是如何落地的这份资源值得下载。2. 整体架构与数据流从源码行到四元式表的完整管道2.1 编译器前端四条主线这份代码分别落在哪拿到这份源码包compilingtechnique目录下实际可用的核心文件是main.cpp和README.md外加一份LICENSE。main.cpp虽然是.cpp后缀但主体逻辑是纯 C 风格写的结构体、枚举和函数用 C 语言课程设计要求完全站得住脚。参考源码收藏的版本基本是编译原理课的经典设计整个程序的执行主线可以拆成四条。第一条是词法分析把源代码字符流切成 token。第二条是语法分析把 token 流按照文法规则组装成一棵语法树。第三条在语法分析的递归下降过程中顺带完成——每识别出一个语法单位就执行对应的语义动作做符号表的插入和查重。第四条是四元式生成语法分析走到 Expression / Assignment 这类节点时直接输出四元式到一张全局表里。这四条主线不是严格按照「先全部词法、再全部语法、再全部生成」的流水线跑的而是在一个循环里以「读一行 → 词法切词 → 语法分析 → 生成四元式」的节奏逐行推进。这样做的一个直接好处是内存占用低不需要把整个源文件一次性读进内存每行处理完后就释放临时状态坏处是跨行的语法结构处理起来麻烦这点在后面避坑章节我会讲到。2.2 一张表理清核心文件与数据结构的对应关系文件 / 代码区职责关键数据结构token 定义区定义 token 类型与关键字表enum TokenType、struct Tokenlexer 函数区词法分析识别标识符、数字、运算符char *lexeme、int lineparser 函数区递归下降语法分析构建语法树struct Node、struct ASTNodesymbol 表区符号表管理与查重struct SymbolEntry、数组或链表四元式生成区遍历语法树生成四元式并输出struct Quad、Quad code[]README.md我读过之后的建议是把它当成「项目说明 已知限制」来看不要当成实验报告模板照抄。里面提到这个版本主要支持赋值语句、算术表达式、条件判断和三目运算符等变量类型只做了 int / float 的区分不做函数调用和数组下标。也就是说它覆盖的是一个精简 C 子集的编译前端正好匹配课程设计「小型编译程序」的定位。2.3 一次完整调用链从 fgets 读行到打印四元式表我习惯先把主循环的骨架画出来再往里填细节。下面这个伪代码级别的 C 代码就是这套程序运行时的整体节奏。int main(int argc, char *argv[]) { char line[MAX_LINE_LEN]; FILE *fp fopen(argv[1], r); if (fp NULL) { fprintf(stderr, 无法打开源文件: %s\n, argv[1]); return 1; } while (fgets(line, MAX_LINE_LEN, fp) ! NULL) { line_no; // 全局行号用于报错定位 init_lexer(line, line_no); // 把当前行交给词法分析器初始化 parse_program(); // 语法分析入口内部调用 lexer 取 token } print_symbol_table(); // 遍历符号表打印变量与类型 print_quad_table(); // 遍历四元式表打印中间代码 fclose(fp); return 0; }这段主循环里最关键的是init_lexer(line, line_no)与parse_program()的配合方式。parse_program()不是预先读完所有 token 再分析的而是每调用一次next_token()就从当前行的字符缓冲里切出一个 token 返回语法分析函数拿到 token 才决定下一步要匹配什么。line_no是全局变量所以 token 里不用单独保存行号等报错时直接从全局line_no取即可。这里有三个参数值得你抄作业时注意。第一是MAX_LINE_LEN的取值代码里一般定 256意味着单行源码超过 255 个字符就会被fgets截断截断后后续 token 会错位这是第一个隐藏坑。第二是init_lexer里要重置行内扫描指针pos 0否则处理完一行后迭代器仍指向上一次的末尾。第三是parse_program的返回时机它必须在fgets读到文件末尾时妥善处理未闭合的语法结构否则最后一行缺少分号时会直接报错而不是优雅退出。3. 词法分析器手写扫描器把字符流切成 token3.1 token 设计类型、值、位置三要素缺一不可词法分析器的输出是 token 流每个 token 需要携带三类信息类型、取值、位置。类型用来告诉语法分析器「这是个标识符还是个运算符」取值用来让符号表登记或让四元式生成器提取操作数位置用来报错时指出「第几行第几列出问题」。在这份源码里token 类型用枚举定义下面这段是典型的写法。typedef enum { TOKEN_IDENT, // 标识符: 变量名 TOKEN_NUMBER, // 数字常量: 整数或浮点数 TOKEN_INT, // 关键字 int TOKEN_FLOAT, // 关键字 float TOKEN_IF, // 关键字 if TOKEN_ELSE, // 关键字 else TOKEN_WHILE, // 关键字 while TOKEN_ASSIGN, // 赋值运算符 TOKEN_PLUS, // 加 TOKEN_MINUS, // 减 - TOKEN_MUL, // 乘 * TOKEN_DIV, // 除 / TOKEN_LPAREN, // 左括号 ( TOKEN_RPAREN, // 右括号 ) TOKEN_SEMICOLON, // 分号 ; TOKEN_EOF // 输入结束 } TokenType; typedef struct { TokenType type; char lexeme[32]; int line; int col; } Token;lexeme[32]这个定长数组是这份代码容易被新手质疑的设计——如果标识符超过 31 个字符这里会发生缓冲溢出。但从课程设计的角度定长数组换来的是内存管理极简不需要 malloc 也不需要 free整个扫描器零动态内存分配这对考核「能否用纯 C 跑通编译流程」来说是个加分项。如果你要扩展建议把lexeme改成动态扩容的字符串或者在切分时用strndup截断并保留警告。token 的位置信息是line和col两个字段col的更新规则要在扫描器里手动维护因为回车换行的处理不一致会导致列号漂移。我的习惯是每处理一个字符col遇到\n就重置为 1。3.2 手写扫描器跳过空白、识别数字与标识符词法分析的核心逻辑集中在next_token()函数里。这个函数每次被调用就从当前行的字符缓冲中跳过空白然后按照第一个字符的类型分支处理。Token next_token() { Token tok {0}; while (line_buffer[pos] || line_buffer[pos] \t) { pos; col; } char c line_buffer[pos]; if (isalpha(c) || c _) { int len 0; while (isalnum(line_buffer[pos]) || line_buffer[pos] _) { tok.lexeme[len] line_buffer[pos]; col; } tok.lexeme[len] \0; tok.type lookup_keyword(tok.lexeme); if (tok.type TOKEN_IDENT) { insert_symbol(tok.lexeme, get_var_type()); } } else if (isdigit(c)) { int len 0; while (isdigit(line_buffer[pos]) || line_buffer[pos] .) { tok.lexeme[len] line_buffer[pos]; col; } tok.lexeme[len] \0; tok.type TOKEN_NUMBER; } else if (c ) { pos; col; tok.type TOKEN_PLUS; } else if (c ) { pos; col; tok.type TOKEN_ASSIGN; } // 其他单字符运算符同理 else if (c ;) { pos; col; tok.type TOKEN_SEMICOLON; } else if (c \0 || c \n) { tok.type TOKEN_EOF; } else { fprintf(stderr, 词法错误: 无法识别的字符 %c 在第 %d 行第 %d 列\n, c, line_no, col); exit(1); } tok.line line_no; tok.col col; return tok; }这段代码两个细节值得注意。第一lookup_keyword负责区分「关键字」和「普通标识符」实现方式是查一张预定义的字符串表匹配成功就返回对应的关键字 token 类型否则返回TOKEN_IDENT。这是把if、else、while与用户自定义变量名区分开的唯一手段顺序上是「先按字母切完整再查表定类型」而不是「逐个字符比对」。第二insert_symbol在词法阶段就被调用这看起来很像语义分析提前介入。原因很简单声明语句int a, b;里的a和b必须在表达式出现之前进入符号表否则后面a 1;查表会得到「未声明变量」的结论。变量类型这时候还不知道所以源码里常见做法是先用get_var_type()从语法分析器暂存的关键字状态里读取类型再登记符号表。这是词法分析和语法分析耦合的一个角落也是你改 bug 时最容易忽略的地方。3.3 数字识别里那个点号的处理最容易翻车数字扫描循环里写的是while (isdigit(line_buffer[pos]) || line_buffer[pos] .)这意味着1.2.3也会被当成一个完整数字 token 切出来。语法分析阶段做类型转换或用atof转浮点数时多余的第二个点不会报错而是被atof静默截断成1.2。这种输入在真实编译器里应该直接报「非法数字常量」但这份课程设计通常没做这个检查。我拆代码时看到这个分支的第一反应是加一个标志位遇到第一个点之后如果再次遇到点就立刻报错。实现成本不超过五行的修改但对「手写词法器是否健壮」这个考核点会有肉眼可见的提升。如果你要交这份课程设计建议把这条补上并在实验报告里提一句「数字词法增加了非法格式拦截」评分老师很喜欢这种细节。4. 语法分析递归下降构建语法树优先级全靠函数嵌套4.1 文法设计为什么表达式优先级用递归下降就能表达语法分析器要回答的核心问题是a b * 2里的乘号为什么比加号先算。用递归下降法做这件事不需要任何算符优先级表只靠函数嵌套层次就能实现嵌套越深的函数对应的语法成分优先级越高。处理算术表达式时经典做法是把文法改写成不包含左递归的形式。原始文法E - E T | T存在左递归直接递归下降会死循环所以必须改写为E - T { ( | -) T }* T - F { (* | /) F }* F - NUMBER | IDENT | ( E )用花括号表示「零次或多次」这个写法你可以直接放进课程设计的实验报告比手写 BNF 再解释消除左递归的过程更直观。4.2 parser 核心代码赋值语句到表达式的完整递归链语法分析器的入口处理赋值语句和声明语句表达式的递归下降则分成三个层次。void parse_statement() { Token tok peek_token(); if (tok.type TOKEN_INT || tok.type TOKEN_FLOAT) { parse_declaration(); // int a, b; } else if (tok.type TOKEN_IDENT) { parse_assignment(); // a expr; } else if (tok.type TOKEN_IF) { parse_if_statement(); } else { error(语句必须以声明、赋值或 if 开头); } } void parse_expression() { parse_term(); // 先解析最高层项 while (peek_token().type TOKEN_PLUS || peek_token().type TOKEN_MINUS) { Token op next_token(); parse_term(); generate_quad(op.type, NULL, NULL, NULL); // 生成加法或减法四元式 } } void parse_term() { parse_factor(); // 解析因子 while (peek_token().type TOKEN_MUL || peek_token().type TOKEN_DIV) { Token op next_token(); parse_factor(); generate_quad(op.type, NULL, NULL, NULL); } } void parse_factor() { Token tok next_token(); if (tok.type TOKEN_NUMBER || tok.type TOKEN_IDENT) { // 因子是数字或变量压栈等待操作符组合 } else if (tok.type TOKEN_LPAREN) { parse_expression(); // 括号内递归调用表达式 expect_token(TOKEN_RPAREN); } else { error(因子必须是数字、变量或括号表达式); } }这套递归函数的执行顺序就是四元式生成的顺序说的根源。看parse_expression的循环体每解析完一个term就调用generate_quad这意味着加法和减法在当前时刻立即产出四元式而不是等整棵语法树建完再后序遍历。a b * 2的生成顺序是先parse_term处理b * 2并生成(*, b, 2, t1)然后回到parse_expression的循环把a t1组合成(, a, t1, t2)。这个顺序是递归下降的天然结果不需要任何额外排序。4.3 前瞻 token 缓冲peek_token 的实现与边界peek_token()在 parser 里频繁出现它的作用是「偷看下一个 token 但不消费」。实现方式通常是在词法分析器里加一个 token 缓存区保存最近切出的一个 token第一次调用时切出并存起来第二次调用时直接返回缓存。Token g_peeked {0}; Token peek_token() { if (g_peeked.type TOKEN_EOF has_more_input()) { g_peeked next_token(); } return g_peeked; } Token next_token() { Token t; if (g_peeked.type ! TOKEN_EOF) { t g_peeked; g_peeked.type TOKEN_EOF; return t; } return lexer_next_token(); }这里的坑在于g_peeked.type TOKEN_EOF被当成了「缓存为空」的标志。如果源码里真的出现了 EOF 作为合法 token 的输入缓存逻辑就失效了。常见修法是单独用一个布尔变量has_peeked标记缓存是否有效不要用 EOF 来兼职。递归下降配上前瞻 token就能实现「根据开头的 token 决定走哪个分支」的预测分析。比如parse_statement里先peek看是int/float就走声明是标识符就走赋值。这种写法让语法分析器不需要回溯代价是文法必须是 LL(1) 的任何公共前缀都会导致分支冲突。你扩展语法时如果发现peek解决不了区分那就是文法需要提取公共因子了。5. 避坑四元式生成与符号表管理里的常见问题和排查方法5.1 临时变量编号从头开始四元式操作数互相覆盖现象输入a 1; b 2; c a b * 3;时前三行没问题到第四行开始四元式里的临时变量出现重复比如出现过t1指向的数值和前面的表达式有关联。原因临时变量计数器在每行处理完以后被重置为 1。四元式生成器里的new_temp()函数如果直接返回temp_count而temp_count又是parse_expression结束时的局部重置那么跨越多行的表达式生成就会重复使用相同的临时变量名。解决把临时变量计数器改成全局变量只增不减并且在main循环的开始做一次检查而不是清零。改成全局后每次生成t1, t2, t3...依次递增四元式之间操作数不会互相串扰。排查时可以打印temp_count的当前值对比四元式表观察它是否按单调递增。5.2 赋值语句的顺序反了生成的四元式把右值当左值写入现象输入i i 1;后打印四元式发现生成的序列是(, i, 1, i)看起来没问题但执行时i的旧值在计算前就被覆盖了。原因赋值语句的四元式生成时机不对。赋值是右结合操作必须先处理右值表达式然后把结果写入左值。如果代码这样写generate_quad(TOKEN_ASSIGN, left_operand, right_result, NULL);在执行left_operand取地址时用了符号表里的变量地址而right_result还在表达式栈里计算顺序就反了。解决赋值语句处理的正确顺序是先parse_expression得到右值所在临时变量再从符号表取左值变量最后生成(, right_temp, NULL, left_var)。我排查这类问题的方法是在generate_quad里加一个 printf把每次调用的四个参数打出来对比手算期望。赋值反了的时候打印里左操作数和右操作数刚好是交换的。5.3 变量未声明就使用报错信息让人一头雾水现象输入a 1;报「未知变量 a」但前面明明有int a;这一行。原因符号表插入发生在词法分析阶段但插入时机依赖语法分析器先处理声明。如果init_lexer把行缓冲重置了而符号表用的是行级局部变量存储那么声明语句int a;处理完以后符号表条目没有持久化到全局数组下一行表达式再查表就找不到了。解决确认符号表是全局结构体数组或链表插入函数对static的全局表操作不要放到栈上。另外检查声明语句的insert_symbol是否被调用了两次比如词法切int关键字时插入一次、切a标识符时又插入一次第二个插入因为变量名是a没问题但如果变量名跟某个关键字撞了比如int if;就会在词法阶段被当成关键字跳过符号表里没有条目后续赋值自然报未知变量。5.4 数字扫描里点号重复导致 atof 静默截断现象输入x 1.2.3;时程序不报错输出的四元式里操作数变成了1.2后面3被当成另一个 token 导致语法报错。原因数字识别循环把.和数字一并纳入 lexemeatof对非法格式字符串的解析是「能转换多少就转换多少」静默截断了第二个点后面的内容。解决在数字循环里记录点号出现次数超过一次直接报词法错误并停止编译。顺带检查数字的头一个字符是否可能为点比如.5应该不被当成数字。这类问题用边扫描边校验的方式处理不要依赖atof做合法性判断。5.5 表达式左递归没有消除运行直接堆栈溢出现象在parse_expression里如果写成E - E T不改写运行到a b时递归不停止最后栈溢出崩掉。原因递归下降的每个函数调用对应一种语法结构左递归会让函数在进入时无限调用自身永远匹配不到终结符。解决改写文法为E - T { (|-) T }*这种右递归形式。排查时看parse_expression的开头有没有先调用parse_term如果没有八成就是左递归没消除。这个坑在写课程设计时最容易犯因为从文法到代码的映射这一步会被忽略。6. 验证与进阶玩法跑通流程后还能加什么6.1 先做两小时的静态回归再谈新功能四元式生成器写完第一件事不是写花哨的测试程序而是手工计算一组表达式然后逐行对照输出。我给自己定了一套基准测试每次改动代码后先跑一遍推荐你直接抄。测试输入期望四元式输出考察点a 2 3;(, 2, 3, a)基本加法与赋值b 2 3 * 4;(*, 3, 4, t1)(, 2, t1, b)乘法优先级高于加法c (2 3) * 4;(, 2, 3, t1)(*, t1, 4, c)括号强制改变优先级d e 5;(, 5, NULL, e)(, e, NULL, d)赋值右结合性if (a 0) b 1;(, a, 0, t1)(jz, t1, NULL, label)条件跳转四元式这套测试跑通的意义在于它能一次性暴露临时变量编号重置、优先级顺序错误、括号栈不匹配三类问题。我每次改完符号表或语法分析函数就手动把这几条输进去看四元式表全程十分钟能省掉后面反复查 bug 的几个小时。6.2 再进一步常量折叠、标识符作用域和四元式转汇编如果你想把这份课程设计从「还行」做到「优秀」我强烈建议按下面的顺序加功能。第一个是常量折叠在generate_quad里检查两个操作数是否都是数字如果是就直接算出结果产出(, 5, NULL, a)而不是(, 2, 3, a)这一步极其简单但实验报告里可以写上「常量折叠优化」。第二个是标识符作用域目前符号表处理的是单层作用域你可以改为用栈区分嵌套块块结束弹出这样if块里声明的变量不会污染外层符号表。第三个是把四元式转成简单的 x86 汇编四元式结构跟汇编指令的映射非常直接比如(, a, b, t1)可以转成mov eax, a; add eax, b; mov t1, eax这一步能让你真正体会到中间表示的设计价值。6.3 我保留的最后一道调试习惯跟踪开关永远留着这套源码里我最后动的一处是一个全局trace_enabled开关。默认关闭打开后每次next_token()打印当前 token每次generate_quad()打印四元式的一行。有了它看a b * 2的生成顺序就像开了上帝视角——你能看到词法分析先切出a、、b、*、2然后语法分析按照「乘号先归约」的顺序生成(*, b, 2, t1)最后parse_expression再组合(, a, t1, t2)。从那以后我每次改文法都强制走一遍这个开关看传播顺序确认四元式没有越级生成。这个方法帮我躲过至少三次「临时变量还没分配就引用」的 bug希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →