南开大学编译原理作业实战:简单C语言编译器从词法到代码生成
简介这份资源是南开大学软件学院编译原理课程的作业成果包含一个可运行的简单C语言编译器完整源码与配套文档面向计算机、人工智能、通信工程等专业的在校学生及自学者适合用作课程设计、毕业设计参考或编译原理入门实践。压缩包共50个文件约54KB以18个h头文件、16个cpp源文件、6个c文件为核心辅以lexer.l与grammar.y词法语法定义、Makefile构建脚本、汇编与测试用例等结构清晰覆盖词法分析、语法分析到代码生成的完整流程。目前已有215人学习下载。项目代码均经测试运行成功答辩评审平均分达96分读者可据此理解编译器各模块的协作方式并在此基础上修改扩展功能用于课设、毕设或立项演示具备较高的学习与参考价值。1. 从一份编译原理作业说起简单C语言编译器到底能跑通什么如果你正在搜「南开大学软件学院编译原理作业」大概率不是想抄一份交差而是想找一个能真正跑起来的参考实现把词法、语法、语义、代码生成这条链路一次性打通。这份作业的核心是一个简单C语言编译器配套源代码和文档说明目标是把一个C语言子集翻译成可执行的中间表示或汇编。它适合两类人一是正在上编译原理课、实验卡在某个阶段的学生二是想用一个小型编译器项目重新梳理前端流程的工程师。难点不在写代码而在把「源程序→词法单元→语法树→符号表→目标代码」这条链路的每一环都对齐任何一环的边界没处理好后面就会连环翻车。下面按实际复现路径拆开讲。2. 先定子集再动手简单C语言编译器的词法、语法与符号表设计2.1 为什么必须先锁死C语言子集很多人一上来就照着完整C语言标准写词法结果写到指针和结构体就卡住。常见做法是先把子集限定在整型与浮点变量声明、赋值、算术表达式、关系表达式、if-else、while、函数定义与调用、return。这个范围足够覆盖编译原理实验的全部核心考点又不会让代码量失控。文档说明里通常会给出一份文法你要做的是把这份文法转成可执行的词法规则和语法规则而不是自己另起一套。词法分析阶段要处理的坑集中在数字和标识符的边界。比如123abc应该报错还是拆成123和abc不同实现选择不同但必须在文档里写清楚。我一般会在词法里维护一个关键字表把int、float、if、else、while、return单独识别其余按标识符走。这样语法分析阶段就不用反复判断某个标识符是不是关键字。2.2 词法分析器的最小实现与参数说明下面是一个用 Python 写的词法分析核心片段输入是源程序字符串输出是 token 列表。选择 Python 是因为它写实验代码快调试方便适合先把逻辑跑通再考虑用 C 重写。import re # 关键字表顺序不影响匹配因为标识符规则会兜底 KEYWORDS {int, float, if, else, while, return} # token 正则优先级从高到低数字必须在标识符之前匹配 TOKEN_SPEC [ (NUMBER, r\d(\.\d)?), # 整数或浮点数 (ID, r[A-Za-z_]\w*), # 标识符可能后续被识别为关键字 (OP, r[\-*/!]), # 运算符简单合并处理 (PUNC, r[();{},]), # 标点符号 (SKIP, r[ \t\n]), # 空白字符直接跳过 (MISMATCH, r.), # 无法匹配的字符报错用 ] def tokenize(code): tokens [] for mo in re.finditer(|.join(f(?P{name}{pattern}) for name, pattern in TOKEN_SPEC), code): kind mo.lastgroup value mo.group() if kind SKIP: continue if kind MISMATCH: raise SyntaxError(f非法字符: {value}) if kind ID and value in KEYWORDS: kind KEYWORD tokens.append((kind, value)) return tokens这段代码的逻辑是用正则的命名分组一次性扫描按TOKEN_SPEC的顺序匹配NUMBER放在ID前面是为了让123不会被当成标识符。SKIP用来吞掉空白MISMATCH兜底报错。参数上\d(\.\d)?只支持简单小数不支持科学计数法这是子集限定的一部分。如果你要支持负数不要在词法里处理留给语法分析的一元运算符否则a-1会被错误拆成a和-1。2.3 语法分析与符号表的配合方式语法分析常见做法是递归下降因为手写递归下降和文法对应关系最直观调试时能直接定位到某个产生式。符号表用栈式结构进入作用域时压栈退出时弹栈。每个符号记录名字、类型、作用域层级。函数调用时先查符号表确认函数已声明再检查实参个数和类型。这里有一个容易忽略的点变量声明和使用的顺序。C语言要求先声明后使用所以符号表在语法分析过程中就要实时更新不能等整棵树建完再统一处理。我一般会在parse_declaration里解析完类型和名字后立刻插入符号表在parse_identifier里查表查不到就报「未声明标识符」。这样错误定位更准也避免后续语义分析阶段重复劳动。3. 从语法树到目标代码中间表示与代码生成的落地步骤3.1 选三地址码还是直接生成汇编中间表示的选择直接影响代码生成难度。常见做法是用三地址码每条指令最多一个运算符形如t1 a b。好处是后续优化和转汇编都简单坏处是多一层结构。如果作业文档没有强制要求我建议先做三地址码因为调试时可以把中间代码打印出来一眼看出表达式求值顺序对不对。三地址码的生成通常配合语法树的后续遍历。每个表达式节点返回一个临时变量名父节点用这个临时变量继续构造指令。比如a b * c先处理b * c得到t1再生成t2 a t1。临时变量用递增计数器生成避免重名。3.2 代码生成的关键参数与寄存器分配简化如果目标代码是 x86 汇编寄存器分配是最大的坑。实验级别不需要图着色常见做法是用栈式分配每个临时变量和局部变量都放在栈帧里需要计算时mov到eax算完再mov回去。这样生成的代码效率低但正确性容易保证。# 简化的三地址码到伪汇编生成片段 def gen_asm(three_addr_code): asm [] for line in three_addr_code: # line 形如 (ADD, t1, a, b) op, dst, src1, src2 line asm.append(fMOV eax, [{src1}]) # 把左操作数加载到 eax if op ADD: asm.append(fADD eax, [{src2}]) elif op SUB: asm.append(fSUB eax, [{src2}]) elif op MUL: asm.append(fIMUL eax, [{src2}]) asm.append(fMOV [{dst}], eax) # 结果写回目标变量 return asm这段代码假设所有变量都在栈上有固定偏移[src1]表示取该偏移处的值。参数上eax是累加器IMUL用于有符号乘法。实际作业里你需要把变量名映射到[ebp-4]、[ebp-8]这样的地址映射表在函数入口处统一分配。注意MOV和ADD的操作数顺序ATT 和 Intel 语法相反文档说明里一般会指定一种不要混用。3.3 文档说明里通常不会写全的测试用例设计文档说明往往只给几个示例但真正跑通需要自己补边界用例。我一般会准备这几类空函数、只有 return 的函数、多层嵌套 if、while 循环里改变量、递归调用、除零、未声明变量、类型不匹配。每类至少一个正例一个反例。反例用来验证报错信息是否可读正例用来验证生成代码能否正确执行。测试时先把三地址码打印出来人工核对再跑汇编这样出错时能快速定位是前端还是后端的问题。4. 避坑与排查编译原理实验里最容易翻车的五个地方4.1 词法分析把关键字当标识符现象if被识别成ID语法分析报「期望表达式却遇到标识符」。原因关键字表没建或者匹配顺序把ID放在关键字之前。解决在词法阶段单独维护关键字集合标识符匹配后立刻查表命中就改 token 类型。注意关键字表要覆盖文档说明里列出的全部关键字不要漏。4.2 符号表作用域没弹栈现象内层作用域声明的变量在外层还能查到导致「重复声明」不报错。原因进入{}时压栈退出时忘记弹栈或者弹栈时机放在语法树遍历之后。解决在递归下降的parse_block里进入时push_scope退出时pop_scope确保成对出现。调试时可以在每次压弹时打印当前作用域层级。4.3 三地址码临时变量重名现象嵌套表达式生成的临时变量互相覆盖计算结果错误。原因临时变量计数器在递归调用中被重置或者用了固定名字如t1。解决用全局递增计数器每次生成新临时变量就加一保证整个函数内唯一。如果支持多函数每个函数重置计数器或者加函数前缀。4.4 汇编生成时栈偏移算错现象程序能编译但运行时段错误或者变量值莫名其妙被改。原因局部变量和临时变量的栈偏移没有统一分配或者esp对齐没处理。解决在函数入口处一次性计算所有局部变量和临时变量的总大小按 4 字节对齐分配生成sub esp, N。每个变量固定偏移不要动态调整。4.5 文档说明与代码不一致现象照着文档说明的语法写测试用例编译器报错。原因文档说明可能是早期版本代码后来改了但文档没更新。解决以源代码为准把文档说明里的文法抄出来和词法、语法规则逐条对照不一致的地方在代码里找实际实现。如果作业要求提交文档记得把文档同步更新。5. 进阶技巧用差分测试验证编译器正确性跑通基本流程后怎么确认编译器没有隐藏 bug我一般用差分测试同一段C子集代码分别用你的编译器和系统gcc编译执行比较输出。如果输出一致说明至少语义正确如果不一致缩小代码范围定位差异。这个方法比人工写测试用例高效得多尤其适合递归和循环嵌套的场景。具体做法是准备一批.c文件每个文件只包含子集支持的语法用gcc编译成可执行文件记录标准输出再用你的编译器生成汇编汇编后链接执行记录输出。两者对比。注意gcc默认优化可能改变浮点结果编译时加-O0关闭优化。浮点比较用误差范围不要直接。另一个技巧是给编译器加一个--dump-tokens和--dump-ast开关调试时把中间结果打印出来。这个习惯在后续做任何编译器相关项目时都能救命因为编译器出错时往往表现为最终结果不对但根因可能在很早的阶段。我自己的习惯是每加一个新语法特性先跑一遍全部测试用例确认没有回归再提交代码。编译原理实验的代码量不大但状态多回归测试是唯一的后悔药。希望帮到你。本文还有配套的精品资源点击获取