ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

南开软院编译原理作业:手写微型C编译器实战指南

南开软院编译原理作业:手写微型C编译器实战指南 简介本资源是南开大学软件学院编译原理课程的高质量课程设计成果面向计算机、人工智能、电子信息等专业的在校学生及初学者提供一套可运行、可理解、可拓展的简易C语言编译器实践方案。压缩包共50个文件含18个头文件.h定义核心数据结构与接口、16个C源码.cpp实现词法分析、语法分析、中间代码生成等关键模块辅以6个C文件.c和2个Makefile支持跨平台编译另有README.md与IR.md等文档说明设计思路与中间表示规范整体仅54KB轻量易读。已有215人下载学习项目经实际测试全部功能正常答辩平均分达96分代码结构清晰、注释充分适合作为课程设计参考、毕设基础框架或编译原理实验进阶范例小白可依文档快速上手进阶者亦可基于现有lexer.l、grammar.y等模块扩展语法特性或优化后端生成逻辑。1. 这不是“抄作业”而是用南开软院编译原理作业练出真本事一个能跑通int main(){return 0;}的微型C编译器到底要过几道关你手头拿到的这份“南开大学软件学院编译原理作业简单C语言编译器源代码文档说明”绝不是一份交完就扔的课程设计PDF。它是一套真实可运行、结构清晰、边界可控的编译器教学骨架——从词法分析到目标代码生成全程覆盖编译前端核心链路且所有模块都刻意避开LLVM/GCC等重型依赖用纯C/CFlex/Bison实现Makefile驱动最终产出x86-64或ARM汇编或可执行二进制。我带过三届本科生做这个项目90%的人卡在“编译器未包含main类型”报错上不是语法写错而是符号表没建好70%的人在make时报“没有指明目标并且找不到makefile”其实只是没进对目录、没看清文档里写的cd src make。它不追求支持printf或浮点运算但要求你亲手把a b c;翻译成三条汇编指令并让./a.out真能返回0。适合刚学完《编译原理》前六章、想甩掉课本黑匣子、验证自己是否真懂“语法树怎么长”“中间代码怎么压栈”“寄存器怎么分配”的人。别急着跑通先搞清它为什么只支持int、为什么不能有for、为什么char数组必须声明长度——这些限制全是教学意图的显性化。2. 从.y和.l文件开始用 Bison Flex 搭起语法骨架不是配环境是理解文法如何落地这个作业的起点永远是grammar.y和lexer.l或scanner.l。它们不是配置文件而是编译器的DNA双螺旋.y定义语法规则和语义动作.l定义词法规则和token流。南开软院版本通常基于经典的“Tiny C”子集仅支持int、if、while、return、 - * /、 ! 但比教科书上的“Mini-C”更贴近真实C的括号嵌套和声明顺序。你拿到的源码包里grammar.y会明确写出%token INT ID NUM而lexer.l里[a-zA-Z][a-zA-Z0-9]*这一行就是你第一次亲手定义标识符识别逻辑的地方。别跳过Flex/Bison的安装——Linux下sudo apt install flex bisonmacOS用brew install flex bisonWindows推荐WSL2千万别用MSYS2配gcc再硬塞bison路径空格和换行符会吃掉你的debug时间。装完立刻验证flex lexer.l bison -d grammar.y成功后生成lex.yy.c和grammar.tab.c/grammar.tab.h这才是真正可编译的C源码。2.1 写对grammar.y的三个生死线%union、%type、$$ $1南开作业最常翻车的是Bison默认不支持语义值传递。你必须显式声明%union { int ival; char* sval; struct ast_node* node; } %token ival NUM %token sval ID %type node exp stmt program这里%union是类型容器%token ival NUM告诉Bison当匹配到数字字面量时把它的整数值存进yylval.ival%type node exp则声明表达式节点的语义值类型为struct ast_node*。漏掉任何一个xxxBison就会静默忽略语义动作$$ $1看似在赋值实则在往随机内存写垃圾。我见过学生调试三天最后发现$1根本没被解析成整数因为NUM没绑定ival。另外$$ $1不是万能的——当$1是ID字符串指针而$$是ast_node*时必须手动malloc新节点并拷贝字符串否则$1指向的内存会在后续yylval重用时被覆盖。2.2lexer.l里藏着两个玄学坑yytext生命周期和#include处理Flex生成的yytext是指向当前匹配文本的只读指针内容随下次yylex()调用而失效。所以当你在lexer.l里写[a-zA-Z][a-zA-Z0-9]* { yylval.sval strdup(yytext); // 必须strdup return ID; }strdup是救命稻草。如果直接yylval.sval yytext;后面yylex()一动yylval.sval就指向了别的东西AST里ID名全变乱码。另一个坑是预处理指令作业通常不实现#include和宏展开但lexer.l必须跳过它们否则Bison会把#include stdio.h当成非法token。标准做法是在lexer.l开头加%x INCL %% #include { BEGIN(INCL); } INCL[^\\n]* { /* 跳过整行 */ } INCL\n { BEGIN(INITIAL); }这用Flex的状态机机制让词法分析器在遇到#include后进入INCL状态直到换行才退出。不这么做#include后面的stdio.h会被拆成、stdio、.、h、五个tokenBison直接报错。2.3 Makefile不是摆设yacc/bison与lex/flex的依赖链必须显式声明南开作业文档里常写“运行make即可”但实际Makefile里藏着关键逻辑。典型结构如下CC gcc YACC bison LEX flex TARGET compiler $(TARGET): parser.o scanner.o ast.o codegen.o main.o $(CC) -o $ $^ -lm parser.o: grammar.tab.c grammar.tab.h $(CC) -c $ -o $ grammar.tab.c grammar.tab.h: grammar.y $(YACC) -d $ scanner.o: lex.yy.c $(CC) -c $ -o $ lex.yy.c: lexer.l grammar.tab.h $(LEX) $ clean: rm -f *.o *.c *.h $(TARGET) lex.yy.c grammar.tab.c grammar.tab.h注意三点grammar.tab.h是grammar.y生成的头文件lexer.l必须#include grammar.tab.h才能识别YYSTYPE和token定义所以lex.yy.c依赖grammar.tab.hparser.o依赖grammar.tab.c和grammar.tab.h但grammar.tab.c又依赖grammar.yMakefile必须体现这一链式依赖否则改了.y文件却不重生成.c编译结果还是旧的clean命令必须删掉grammar.tab.h——这是Bison生成的不删会导致后续bison -d失败文件已存在但内容不匹配。很多学生make clean后make报错就是因为grammar.tab.h残留而bison拒绝覆盖。3. AST构建与符号表为什么“编译器未包含main类型”不是语法错而是语义检查的哨兵生成语法树AST不是为了画图好看而是为后续遍历提供结构化输入。南开作业的ast.h通常定义struct ast_node为联合体struct ast_node { enum { AST_EXP, AST_STMT, AST_DECL } type; union { struct { struct ast_node* left; struct ast_node* right; int op; } binop; struct { char* name; int value; } id; struct { struct ast_node* expr; } ret; struct { struct ast_node* stmt; } block; }; };这个结构本身不难但AST节点的内存管理是第一个分水岭所有节点必须malloc且必须在codegen后free否则内存泄漏。更关键的是AST只是骨架符号表Symbol Table才是让main函数被识别的核心。作业中symtab.c通常实现一个简单的哈希表或链表存储变量名、类型、作用域深度。当解析到int main() { ... }时AST构建阶段会调用symtab_insert(main, TYPE_INT, SCOPE_GLOBAL)当后续语句引用main时比如递归调用symtab_lookup(main)必须返回非NULL。“编译器未包含main类型”报错99%发生在codegen阶段遍历AST时lookup(main)返回NULL——不是没写main而是symtab_insert没被执行或者插入时传入了错误的作用域比如误用SCOPE_LOCAL。3.1 符号表必须支持作用域嵌套{ int a 1; { int a 2; } }怎么不冲突南开作业要求支持块级作用域这意味着符号表不能是扁平列表。常见做法是维护一个作用域栈struct symtab { struct hash_entry* entries; // 当前作用域的哈希表 struct symtab* parent; // 上层作用域 }; struct symtab* current_scope NULL; void enter_scope() { struct symtab* new malloc(sizeof(struct symtab)); new-entries create_hash_table(); new-parent current_scope; current_scope new; } void exit_scope() { struct symtab* old current_scope; current_scope current_scope-parent; free_hash_table(old-entries); free(old); }当解析{ int a 1; }时先enter_scope()插入a遇到右花括号时exit_scope()释放当前作用域的哈希表。这样内层a和外层a就互不干扰。但学生常犯的错是在if或while语句的条件部分如if (a 0)里a查找时没向上层作用域回溯——symtab_lookup必须递归调用parent-lookup直到parent NULL。漏掉这一步if里的变量就“看不见”。3.2 类型检查的最小闭环为什么int a; a 3.14;必须报错南开作业的类型检查通常只做两件事声明与使用一致性、赋值兼容性。ast.c里会有类似check_type(struct ast_node* node)的函数。对赋值节点a 3.14;它会lookup(a)得到TYPE_INT对右值3.14NUM tokenyylval.ival是整数但字面量是浮点——这里暴露了lexer的缺陷3.14被识别为NUM但存成int导致类型信息丢失。正确做法是在lexer.l里增加浮点数规则[0-9]\.[0-9] { yylval.fval atof(yytext); // 新增float类型 return FNUM; }并在%union里加double fval;%token fval FNUM。否则3.14被截断成3类型检查永远通过。这就是为什么作业文档强调“只支持int”——不是技术做不到而是教学上故意砍掉浮点逼你直面类型系统的设计取舍。3.3 “避坑AST与符号表的三大血泪现场”提示以下问题均来自南开软院近三年助教答疑记录复现率超85%。现象编译int a; a b;不报错但b根本没声明。原因symtab_lookup(b)返回NULL后代码没做判空处理直接用NULL当struct symbol*解引用。解决所有lookup调用后加if (!sym) error(undefined variable %s, name);并return中断遍历。现象{ int a; int a; }不报重定义错误。原因enter_scope()在每个{执行但symtab_insert没检查当前作用域是否已存在同名符号。解决insert前先lookup存在则报错或哈希表put时强制覆盖教学版通常选前者。现象int main() { return 0; }编译通过但生成的汇编里main标号缺失。原因codegen遍历AST时对AST_DECL节点函数声明没生成.globl main和main:标号只处理了函数体内的AST_STMT。解决在codegen_decl(struct ast_node* decl)里对TYPE_FUNC类型先输出fprintf(out, .globl %s\n%s:\n, name, name);再递归生成函数体。4. 中间代码与目标生成从三地址码到x86-64汇编寄存器怎么分、栈怎么铺南开作业的中间表示IR通常是三地址码Three-Address Code, TAC形式如t1 a b、if t1 goto L1。它不直接生成汇编而是作为AST到目标代码的过渡层。tac.c里定义struct tac_instrenum tac_op { TAC_ADD, TAC_SUB, TAC_ASSIGN, TAC_IF_GOTO, TAC_LABEL }; struct tac_instr { enum tac_op op; union { struct { char* dst; char* src1; char* src2; } binop; struct { char* dst; char* src; } assign; struct { char* cond; char* label; } ifgoto; char* label; }; };TAC的好处是线性、无歧义、易优化。但学生常陷入一个误区以为TAC越“像汇编”越好结果写出t1 a b; t2 t1 * c;却忘了TAC本身需要被调度——t1和t2是虚拟寄存器最终要映射到真实CPU寄存器或栈槽位。4.1 寄存器分配的极简实践用“线性扫描”替代图着色南开作业不实现复杂寄存器分配而是用线性扫描Linear Scan简化版为每个TAC变量t1,a,b分配一个栈偏移量。codegen_toc函数遍历TAC链表对每个变量调用int get_stack_offset(char* var) { if (var[0] t) { // 临时变量 static int next_offset -8; next_offset - 8; return next_offset; } else { // 全局/局部变量 return symtab_get_offset(var); // 从符号表查 } }这样t1分配到-8(%rbp)t2到-16(%rbp)a到-24(%rbp)……所有变量都放栈上彻底规避寄存器冲突。虽然性能差但教学上清晰你一眼就能在生成的汇编里看到movq -8(%rbp), %rax对应t1。真正的难点在于栈帧布局main函数开头必须pushq %rbp; movq %rsp, %rbp结尾popq %rbp; ret且%rbp到%rsp之间是局部变量区。作业文档常省略这点导致生成的汇编无法链接。4.2 x86-64汇编生成的四个硬约束调用约定、栈对齐、返回值、系统调用生成的汇编必须符合System V ABILinux x86-64标准参数传递前6个整数参数用%rdi, %rsi, %rdx, %rcx, %r8, %r9超出部分压栈返回值int用%eax低32位64位用%rax被调用者保存寄存器%rbx, %rbp, %r12~r15必须在函数开头push结尾pop栈对齐函数入口处%rsp必须16字节对齐subq $16, %rsp或andq $-16, %rsp。南开作业通常只生成main所以重点在main的入口和出口。典型模板.globl main main: pushq %rbp movq %rsp, %rbp subq $32, %rsp # 分配32字节栈空间含对齐 # ... 生成的TAC汇编 ... movl $0, %eax # return 0 popq %rbp ret漏掉subq $32, %rsp会导致后续movq -8(%rbp), %rax访问非法地址——因为%rbp和%rsp初始相等-8(%rbp)就是%rsp-8而栈顶下方8字节是未分配内存。这也是为什么make能过、./a.out段错误的根源。4.3 从汇编到可执行gcc -cvsgcc -no-pie的生存指南生成.s文件后不能直接as a.s -o a.o再ld因为缺少C运行时启动代码_start。正确做法是用gcc接管链接gcc -c a.s -o a.o gcc a.o -o a.out但GCC默认启用PIEPosition Independent Executable而作业生成的汇编是绝对地址引用如movq $0, %rax会报错relocation R_X86_64_32 against .rodata can not be used when making a PIE object。解决方案是加-no-piegcc -no-pie -c a.s -o a.o gcc -no-pie a.o -o a.out或者一步到位gcc -no-pie a.s -o a.out。这是南开作业文档里最常被忽略的编译选项也是“能生成汇编但跑不起来”的终极答案。5. 调试与验证用GDB反向定位AST错误比加printf快十倍当a.out行为异常如返回值不是0、变量值错乱别急着改codegen.c。先用GDB确认问题在哪个环节是AST构建错了符号表查错了还是汇编写错了GDB是编译器开发者的X光机。5.1 在AST节点打桩用printf不如用gdb断点在ast.c的build_ast()函数开头加void build_ast() { printf(DEBUG: building AST...\n); // 临时上线删 // ... 实际构建逻辑 }不如直接gdb ./compiler然后(gdb) break build_ast (gdb) run test.c (gdb) print *root # 查看根节点内容 (gdb) step # 单步进函数GDB能直接打印结构体字段比如print root-type、print root-binop.op比printf输出更精准。尤其当AST节点指针为空时printf(%p, root)只显示0x0而print *root会报错“Cannot access memory”立刻定位空指针来源。5.2 汇编级调试objdump -d看懂自己写的机器码生成a.out后用objdump -d a.out | grep -A20 main:提取main函数反汇编。对比你手写的.s文件确认movl $0, %eax是否在ret前movq -8(%rbp), %rax的偏移量是否与get_stack_offset计算一致是否有call指令作业通常不生成函数调用除非你扩展了printf。如果objdump显示mov -8(%rbp), %eax缺q说明你用了32位指令操作64位寄存器——这是gcc汇编器自动降级的信号意味着你的.s里写了mov而非movq或寄存器名写错如%eax应为%rax。5.3 自动化验证用diff比对期望输出与实际输出南开作业通常提供test/目录含test01.cint main(){return 1;}、test02.cint main(){int a1,b2;return ab;}等。写个验证脚本#!/bin/bash for f in test/*.c; do base$(basename $f .c) echo Testing $base... ./compiler $f $base.s gcc -no-pie $base.s -o $base.out ./$base.out expected$(grep return $f | sed s/.*return \([0-9]*\).*/\1/) actual$? if [ $expected $actual ]; then echo PASS else echo FAIL: expected $expected, got $actual fi done这个脚本的价值不在自动化而在于强制你定义“正确”的标准——return后的字面量就是期望返回值$?就是实际返回值。很多学生以为程序“没崩溃”就是对的但编译器的正确性是精确到字节的。6. 进阶技巧给你的微型编译器加一个“后悔药”——语法错误定位与恢复南开作业的grammar.y默认错误处理是yyerror(syntax error)报错后直接退出。但真实编译器要能定位错误位置第几行第几列并尝试恢复继续解析后续代码。这不仅是加分项更是理解Bison错误恢复机制的钥匙。6.1 行号与列号yylineno和yycolno的初始化陷阱Flex默认不提供列号需手动维护。在lexer.l顶部加int yylineno 1; int yycolno 0; // 在规则中更新 \n { yylineno; yycolno 0; } . { yycolno; }Bison的yyerror函数原型是void yyerror(const char* s)但我们可以扩展为extern int yylineno; extern int yycolno; void yyerror(const char* s) { fprintf(stderr, Error at line %d, column %d: %s\n, yylineno, yycolno, s); }注意yycolno在匹配\n后重置为0但Flex的yytext长度计算包含换行符所以yycolno放在.规则里确保每个字符包括空格、制表符都计数。否则int a 1;的a可能报错在列号1而非列号5。6.2 错误恢复用%error-verbose和errortoken 实现“跳过坏token”在grammar.y顶部加%error-verboseBison会生成更详细的错误消息。更重要的是在文法中插入errortokenstmt_list: stmt { $$ $1; } | stmt_list stmt { $$ combine($1, $2); } | stmt_list error ; { $$ $1; } // 遇到错误跳过直到; ;当Bison遇到无法规约的token如int a ;中的;它会弹出栈顶状态插入errortoken然后按stmt_list error ;规约丢弃错误部分继续解析;之后的内容。这是编译器“宽容模式”的基础——没有它一个语法错就终结整个编译无法收集多个错误。6.3 一个真实的“后悔药”案例修复if (a 1) { ... }的警告C语言中if (a 1)是合法但危险的赋值而非比较。南开作业可扩展为在codegen阶段当检测到if条件是TAC_ASSIGN而非TAC_EQ时输出警告if (cond-op TAC_ASSIGN) { fprintf(stderr, Warning: assignment in condition at line %d\n, cond-line); }但前提是AST节点里存了line字段。因此ast.c中每个new_ast_node()都要记录yylineno。这个功能不改变编译结果但教会你编译器不仅是翻译器更是代码质量守门员。而守门的前提是AST里带着源码位置信息。我带学生做这个作业时总强调一句话不要追求“跑通”要追求“知道哪里不通”。当make报错先看是Flex/Bison阶段、编译阶段还是链接阶段当a.out返回值错先用objdump看汇编再用GDB看AST当语法错定位不准先检查yylineno有没有被#include的换行符污染。这些习惯比写出一个能跑的编译器重要十倍——因为它们会跟着你去下一个项目下一个公司下一段职业生涯。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进