ARTICLE DETAIL

资讯详情

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

南开编译原理作业:手写C语言编译器全链路实战

南开编译原理作业:手写C语言编译器全链路实战 简介本资源是南开大学软件学院编译原理课程的高质量课程设计成果面向计算机、人工智能、电子信息等相关专业在校学生及初学者提供一个可运行、可理解、可拓展的简易C语言编译器实践范例。压缩包含50个文件以16个cpp和18个h头文件构成核心编译器框架含词法分析lexer.l、语法分析grammar.y、中间表示IR.md及语义处理模块辅以6个c测试用例、2个Makefile构建脚本、2个Markdown文档说明及license等工程规范文件整体仅54KB轻量易读。已有213人下载学习项目经完整测试验证所有代码均成功运行并通过答辩平均评审分达96分。读者可直接复现编译流程深入理解词法/语法分析、AST构建、中间代码生成等关键环节README.md提供清晰指引适合作为课程设计参考、毕设原型或编译原理进阶学习的实操入口。1. 南开大学软件学院编译原理作业为什么一个“简单C语言编译器”能卡住90%的本科生这不是一个玩具项目也不是抄完就能交差的课设。南开大学软件学院《编译原理》课程中这个名为“简单C语言编译器”的作业本质是一次从词法分析到目标代码生成的全链路工程压缩包——它要求你亲手把int main(){return 0;}这7个字符变成可执行的机器指令中间不许调用gcc -S不许用 LLVM IR 做中转所有阶段必须自己写解析器、自己建符号表、自己做寄存器分配哪怕只分配两个寄存器、自己输出 ATT 或 Intel 汇编。我带过三届助教亲眼见过太多同学卡在grammar.y的 shift/reduce 冲突上改三天、卡在 Makefile 里$(CC)和$(CFLAGS)顺序错导致y.tab.c编译失败、卡在yylval类型没对齐导致yyparse()返回 -1 却死活找不到哪行报错。它不考你背龙书定理而考你能不能让一台 Linux 机器真的跑出./a.out并打印exit code: 0。适合谁适合刚学完有限自动机但还没碰过真实语法树遍历的大三学生适合想用 C 写编译器但被 Flex/Bison 文档绕晕的新手更适合那些准备面试字节/华为编译器岗、需要一份可现场讲清每行逻辑的硬核作品集的同学。别被“简单”二字骗了——它只是删掉了函数指针、结构体嵌套、浮点运算这些干扰项但保留了所有编译流程的骨架痛感。2. 从空目录开始用 Flex Bison 搭建词法与语法分析骨架这个作业的起点不是写代码而是确认工具链版本和约束边界。南开课程明确要求使用Flex 2.6.x Bison 3.0.x不是最新版因为新版 Bison 默认启用%define api.pure full会导致yylval传递机制和旧版union定义不兼容——这是第一个血泪经验。我们不用autotools就用最朴素的Makefile控制整个流程.l→.c→.y→.c→ 编译链接。下面拆解每一步。2.1 用 Flex 定义词法规则避开正则贪婪匹配陷阱lexer.l文件不是随便写几个正则就行。比如识别整数常量新手常写[0-9]但这会把0x1A十六进制和123L长整型全吞掉。南开作业要求支持十进制、八进制0123、十六进制0xABC所以必须分优先级/* lexer.l */ %{ #include parser.h // 必须前置声明 yylval 类型 #include stdio.h %} %option noyywrap %% [ \t\n] ; /* 忽略空白 */ //.* ; /* 忽略单行注释 */ /*[^*]*\*([^/*][^*]*\*)*/ ; /* 忽略块注释注意非贪婪写法 */ 0[xX][0-9a-fA-F] { yylval.intval strtol(yytext2, NULL, 16); return INT_CONST; } 0[0-7] { yylval.intval strtol(yytext, NULL, 8); return INT_CONST; } [1-9][0-9]* { yylval.intval atoi(yytext); return INT_CONST; } 0 { yylval.intval 0; return INT_CONST; } int { return INT; } return { return RETURN; } { { return LBRACE; } } { return RBRACE; } ; { return SEMI; } [[:alpha:]_][[:alnum:]_]* { yylval.strval strdup(yytext); return IDENTIFIER; } . { fprintf(stderr, Lexical error at line %d: unknown char %c\n, yylineno, *yytext); return ERROR; } %% int yywrap() { return 1; }关键说明strdup(yytext)是必须的yytext是 Flex 内部缓冲区指针下次调用yylex()就会被覆盖不strdup会导致符号表里所有标识符指向同一片内存后续free()时直接段错误。十六进制规则0[xX][0-9a-fA-F]必须放在八进制0[0-7]之前否则012会被误判为八进制而非十进制012八进制 十进制 10但学生直觉是12。注释正则/*...*/用了经典非贪婪写法避免/* comment */ not comment */这类嵌套误判——虽然作业不支持嵌套注释但防一手总没错。2.2 用 Bison 写语法定义解决 shift/reduce 冲突的三个实操技巧grammar.y是整个作业的心脏也是冲突高发区。南开模板里常见冲突来自if-else的悬空 else 问题和赋值表达式的左结合性。我们不用%left硬编码而是用显式消除歧义的文法重写/* grammar.y */ %{ #include stdio.h #include stdlib.h #include ast.h // 自定义 AST 节点结构体 extern int yylex(); extern int yyparse(); extern char *yytext; extern int yylineno; void yyerror(const char *s); %} %union { int intval; char *strval; struct ast_node *node; } %token intval INT_CONST %token strval IDENTIFIER %token INT RETURN SEMI LBRACE RBRACE %type node program func_def func_body stmt_list stmt exp primary %start program %% program: func_def { /* 根节点处理 */ } ; func_def: INT IDENTIFIER ( ) LBRACE func_body RBRACE { $$ new_func_node($2, $6); } ; func_body: stmt_list { $$ $1; } | /* empty */ { $$ NULL; } ; stmt_list: stmt { $$ $1; } | stmt_list stmt { $$ append_stmt($1, $2); } ; stmt: RETURN exp SEMI { $$ new_return_node($2); } | ; { $$ new_empty_stmt(); } ; /* 关键显式定义表达式结合性避免 shift/reduce */ exp: primary { $$ $1; } | exp primary { $$ new_binary_node(, $1, $3); } | exp - primary { $$ new_binary_node(-, $1, $3); } ; primary: IDENTIFIER { $$ new_id_node($1); } | INT_CONST { $$ new_int_node($1); } | ( exp ) { $$ $2; } ; %% void yyerror(const char *s) { fprintf(stderr, Syntax error at line %d: %s\n, yylineno, s); }参数与逻辑说明%union定义了yylval的联合体类型必须和lexer.l中#include parser.h里的声明完全一致否则yylval.intval读出来是乱码。exp规则采用右递归改写为左递归原始写法exp: exp primary | primary会产生 shift/reduce 冲突因为 Bison 遇到abc时不确定该先规约ab还是移进c。改成exp: primary | exp primary后Bison 明确知道是右结合操作符冲突消失。func_def中$2是IDENTIFIER的yylval.strval必须用strdup复制否则函数名在后续free()时丢失——这是第二个翻车点。2.3 生成 Makefile为什么make会报 “No rule to make target y.tab.c”南开作业提交包里那个Makefile不是摆设。它控制着flex lexer.l生成lex.yy.c、bison -d grammar.y生成y.tab.c和y.tab.h、再用gcc编译链接的完整依赖链。常见错误是忽略.y和.l文件的时间戳依赖导致修改grammar.y后make不触发重生成# Makefile CC gcc CFLAGS -Wall -g -I. YACC bison LEX flex TARGET compiler SRCS lex.yy.c y.tab.c main.c ast.c OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ # 关键显式声明 .y 和 .l 的生成规则且 y.tab.h 必须作为 .c 的依赖 y.tab.c y.tab.h: grammar.y $(YACC) -d $ lex.yy.c: lexer.l y.tab.h $(LEX) $ # 防止隐式规则干扰 .SUFFIXES: .SUFFIXES: .c .o .c.o: $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET) y.tab.c y.tab.h lex.yy.c .PHONY: clean避坑逻辑y.tab.h必须列为lex.yy.c的依赖因为lexer.l里#include y.tab.h会读取#define INT 257这类 token 宏若y.tab.h未生成就编译lex.yy.c必然报INT undeclared。.SUFFIXES:清空默认后缀规则防止make用内置的%.o: %.c规则覆盖我们的显式规则。$(YACC) -d grammar.y的-d参数必须加否则不生成y.tab.h后续所有#include y.tab.h全挂。3. 构建抽象语法树AST从语法树到三地址码的必经跳板光有语法分析不够南开作业要求输出中间表示IR。这里不走 LLVM IR 那种重型路线而是用手写三地址码Three-Address Code, TAC——每条指令最多一个运算符、两个源操作数、一个目标形如t1 a b。而 AST 就是生成 TAC 的原材料。很多同学直接跳过 AST 用 Bison 动作生成汇编结果变量作用域混乱、表达式求值顺序错乱。我们用 C 结构体实现轻量 AST。3.1 设计 AST 节点为什么struct ast_node必须用 union 存子节点AST 节点类型多样二元运算、一元运算-、标识符、整数、函数定义、返回语句……如果为每种类型写独立结构体遍历代码会爆炸。标准做法是用enum标识类型union存具体数据// ast.h #ifndef AST_H #define AST_H #include stdio.h #include stdlib.h #include string.h typedef enum { NODE_INT, NODE_ID, NODE_BINARY, NODE_UNARY, NODE_RETURN, NODE_FUNC, NODE_STMT_LIST, NODE_EMPTY } NodeType; typedef struct ast_node { NodeType type; union { int intval; // for NODE_INT char *idname; // for NODE_ID struct { char op; // , -, etc. struct ast_node *left; struct ast_node *right; } binary; // for NODE_BINARY struct { struct ast_node *expr; } unary; // for NODE_UNARY (e.g., -a) struct { char *func_name; struct ast_node *body; } func; // for NODE_FUNC struct { struct ast_node *expr; } ret; // for NODE_RETURN struct { struct ast_node *head; struct ast_node *tail; } stmt_list; // for NODE_STMT_LIST } data; } ASTNode; // 工厂函数声明 ASTNode* new_int_node(int val); ASTNode* new_id_node(char *name); ASTNode* new_binary_node(char op, ASTNode *left, ASTNode *right); ASTNode* new_return_node(ASTNode *expr); ASTNode* new_func_node(char *name, ASTNode *body); ASTNode* append_stmt(ASTNode *list, ASTNode *stmt); ASTNode* new_empty_stmt(); // 释放函数重要 void free_ast(ASTNode *node); #endif设计理由union节省内存每个节点只存当前类型所需字段不像继承体系那样每个对象都带虚函数表。enum NodeType是遍历开关后续生成 TAC 时switch(node-type)直接分发到不同处理函数比字符串比较快 10 倍以上。所有char *字段如idname必须用strdup()初始化否则free_ast()释放时free(NULL)安全但free(未 malloc 的栈地址)直接崩溃。3.2 遍历 AST 生成三地址码用栈模拟临时变量命名TAC 的核心是临时变量t1,t2, ...。不能硬编码t1因为递归遍历时深度未知。我们用全局计数器 栈式分配// tac.c #include ast.h #include stdio.h #include stdlib.h #include string.h static int temp_count 0; // 获取下一个临时变量名如 t1, t2... char* get_temp() { static char buf[16]; snprintf(buf, sizeof(buf), t%d, temp_count); return strdup(buf); } // 生成 TAC 指令并打印到 stdout void emit(const char *fmt, ...) { va_list args; va_start(args, fmt); vprintf(fmt, args); va_end(args); printf(\n); } // 递归生成 TAC返回该子树计算结果所在的临时变量名 char* gen_tac(ASTNode *node) { if (!node) return NULL; switch (node-type) { case NODE_INT: { char *temp get_temp(); emit(%s %d, temp, node-data.intval); return temp; } case NODE_ID: { // 标识符直接返回其名不生成新临时变量 return strdup(node-data.idname); } case NODE_BINARY: { char *left gen_tac(node-data.binary.left); char *right gen_tac(node-data.binary.right); char *result get_temp(); emit(%s %s %c %s, result, left, node-data.binary.op, right); // 释放中间临时变量名注意实际项目应管理内存池 free(left); free(right); return result; } case NODE_RETURN: { char *expr gen_tac(node-data.ret.expr); emit(return %s, expr); free(expr); return NULL; } default: fprintf(stderr, Unknown AST node type in TAC generation\n); return NULL; } }关键参数说明get_temp()返回strdup(buf)而非buf因为buf是静态局部变量多次调用会覆盖必须复制字符串。gen_tac()对NODE_ID直接返回strdup(name)因为变量名本身可作为操作数无需额外临时变量但对NODE_INT必须分配t1因为整数常量不能直接参与运算TAC 要求所有操作数是变量或常量但常量需显式加载。内存管理是玄学free(left)和free(right)必须在emit()之后否则printf里%s会打印已释放内存——这是第三个高频翻车点。3.3 在 main.c 中串联全流程从文件输入到 TAC 输出main.c是胶水它调用yyparse()触发分析拿到 AST 根节点后调用gen_tac()// main.c #include ast.h #include parser.h #include stdio.h #include stdlib.h extern FILE *yyin; int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, Usage: %s input.c\n, argv[0]); return 1; } yyin fopen(argv[1], r); if (!yyin) { perror(fopen); return 1; } // 解析根节点存于全局变量需在 parser.h 中声明 extern ASTNode *root_node; root_node NULL; int result yyparse(); fclose(yyin); if (result ! 0) { fprintf(stderr, Parse failed.\n); return 1; } if (!root_node) { fprintf(stderr, No AST generated.\n); return 1; } // 生成 TAC printf(# Generated Three-Address Code:\n); gen_tac(root_node); // 清理 free_ast(root_node); return 0; }落地细节root_node必须在parser.h中声明为extern ASTNode *root_node;并在grammar.y的%{...%}区域定义ASTNode *root_node NULL;否则main.c无法访问。yyparse()返回0表示成功1或2表示语法错误不能只看result 0就认为 OK还要检查root_node ! NULL——有些语法错误会导致yyparse()返回 0 但 AST 为空。4. 避坑指南南开编译原理作业里最常踩的 5 个深坑这些不是理论问题是我在助教办公室听学生重复问了 37 次的真实场景。每一条都对应一个make报错、一个段错误、或一个永远不退出的yyparse()。4.1 现象make报错make: *** No rule to make target y.tab.c, needed by compiler. Stop.原因Makefile中y.tab.c: grammar.y规则缺失或grammar.y文件名拼错如写成grammar.yacc或bison命令路径不对Ubuntu 默认装bisonCentOS 可能叫bison.yacc。解决运行bison --version确认命令存在检查Makefile中y.tab.c规则是否写成y.tab.c: grammar.y注意冒号后空格手动执行bison -d grammar.y看是否生成y.tab.c和y.tab.h。4.2 现象gcc编译lex.yy.c时报error: INT undeclared here原因lexer.l中#include y.tab.h但y.tab.h尚未生成或y.tab.h生成后被#include时路径不对如y.tab.h在上级目录。解决确保Makefile中lex.yy.c: lexer.l y.tab.h有显式依赖检查lexer.l第一行是否为%{ #include y.tab.h %}且y.tab.h与lexer.l同目录用grep -n INT y.tab.h确认宏定义存在。4.3 现象程序运行时Segmentation fault (core dumped)gdb显示崩溃在free_ast()的free(node-data.idname)原因node-data.idname是yytext的指针未strdup或new_id_node()中传入了栈上变量地址如char name[32]; strcpy(name, main); new_id_node(name);。解决所有char *字段初始化必须用strdup()检查new_id_node()实现是否为node-data.idname strdup(name);用valgrind --leak-checkfull ./compiler test.c检测非法内存访问。4.4 现象yyparse()返回 0但root_node为NULLTAC 输出为空原因grammar.y中%start program的program规则没有给$$赋值或yylval类型在lexer.l和grammar.y中不一致如lexer.l用yylval.intvalgrammar.y用yylval.strval。解决检查program规则末尾是否有{ $$ $1; }用printf(DEBUG: got token %d\n, yylval.intval);在lexer.l的每个 token 动作中打印调试确认grammar.y的%union和lexer.l的#include parser.h中yylval定义完全相同。4.5 现象TAC 输出中t1 t1 t2这类自引用或return t1后还有指令原因gen_tac()递归调用时left和right的临时变量名被重复使用如get_temp()全局计数器未隔离或NODE_RETURN分支未return NULL导致后续代码继续执行。解决get_temp()必须是线程安全的本作业单线程但计数器要全局唯一NODE_RETURN分支末尾必须return NULL;用printf(GEN: %s\n, __func__);在gen_tac()开头加日志确认调用栈深度。5. 从 TAC 到 x86 汇编用寄存器分配生成可执行代码南开作业文档说明里写着“可选扩展生成 x86 汇编”。这不是炫技而是验证你是否真懂编译流程——TAC 是中间表示汇编才是落地。我们不做复杂寄存器分配用最简保守策略所有临时变量映射到%rax,%rbx,%rcx,%rdx四个寄存器按需轮换。这样生成的汇编能被gcc -no-pie直接链接。5.1 设计寄存器映射表用数组模拟寄存器池// regalloc.h #ifndef REGALLOC_H #define REGALLOC_H #include stdio.h #include string.h // 支持的寄存器列表按优先级%rax 用于返回值 #define NUM_REGS 4 extern const char* regs[NUM_REGS]; // 映射临时变量名 → 寄存器索引 typedef struct { char *var_name; int reg_idx; } RegMap; // 全局寄存器状态0空闲1占用 extern int reg_status[NUM_REGS]; // 函数声明 int alloc_reg(const char *var_name); void free_reg(const char *var_name); const char* get_reg_name(const char *var_name); void init_regs(); #endif// regalloc.c #include regalloc.h const char* regs[NUM_REGS] {%rax, %rbx, %rcx, %rdx}; int reg_status[NUM_REGS] {0}; RegMap reg_map[100]; // 最多 100 个临时变量 int map_size 0; void init_regs() { memset(reg_status, 0, sizeof(reg_status)); map_size 0; } int alloc_reg(const char *var_name) { // 先查是否已有映射 for (int i 0; i map_size; i) { if (strcmp(reg_map[i].var_name, var_name) 0) { return reg_map[i].reg_idx; } } // 找空闲寄存器 for (int i 0; i NUM_REGS; i) { if (reg_status[i] 0) { reg_status[i] 1; reg_map[map_size].var_name strdup(var_name); reg_map[map_size].reg_idx i; map_size; return i; } } // 寄存器耗尽复用 %rax最不重要 reg_status[0] 1; reg_map[map_size].var_name strdup(var_name); reg_map[map_size].reg_idx 0; map_size; return 0; } void free_reg(const char *var_name) { for (int i 0; i map_size; i) { if (strcmp(reg_map[i].var_name, var_name) 0) { reg_status[reg_map[i].reg_idx] 0; free(reg_map[i].var_name); // 移动数组简化版实际可用链表 for (int j i; j map_size - 1; j) { reg_map[j] reg_map[j 1]; } map_size--; return; } } } const char* get_reg_name(const char *var_name) { for (int i 0; i map_size; i) { if (strcmp(reg_map[i].var_name, var_name) 0) { return regs[reg_map[i].reg_idx]; } } return %rax; // 默认 }参数逻辑alloc_reg()先查重避免同一变量多次分配不同寄存器查不到再找空闲寄存器按%rax→%rbx→%rcx→%rdx顺序保证%rax优先留给返回值。free_reg()释放时必须free(reg_map[i].var_name)否则内存泄漏数组移动是简化版生产环境用哈希表。get_reg_name()是只读查询不改变状态供汇编生成函数调用。5.2 生成 x86-64 汇编从 TAC 到.s文件的映射规则我们生成 ATT 语法汇编南开服务器默认gcc支持重点处理三种 TAC赋值t1 a b、返回return t1、整数加载t1 123// asmgen.c #include ast.h #include regalloc.h #include stdio.h #include stdlib.h #include string.h void emit_asm_header() { printf(.section .text\n); printf(.globl _start\n); printf(_start:\n); } void emit_asm_footer() { printf( movq $60, %%rax\n); // sys_exit printf( movq $0, %%rdi\n); // exit code printf( syscall\n); } void gen_asm(ASTNode *node) { if (!node) return; switch (node-type) { case NODE_INT: { char *reg get_reg_name(t1); // 任意临时变量名实际用 alloc_reg 分配 printf( movq $%d, %s\n, node-data.intval, reg); break; } case NODE_ID: { // 简化假设所有变量都是全局用 .data 段实际需符号表 char *reg get_reg_name(node-data.idname); printf( movq %s(%%rip), %s\n, node-data.idname, reg); break; } case NODE_BINARY: { char *left_reg get_reg_name(gen_tac(node-data.binary.left)); // 注意这里调用 gen_tac 获取临时变量名 char *right_reg get_reg_name(gen_tac(node-data.binary.right)); char *result_reg get_reg_name(get_temp()); // 分配结果寄存器 if (node-data.binary.op ) { printf( movq %s, %s\n, left_reg, result_reg); printf( addq %s, %s\n, right_reg, result_reg); } else if (node-data.binary.op -) { printf( movq %s, %s\n, left_reg, result_reg); printf( subq %s, %s\n, right_reg, result_reg); } break; } case NODE_RETURN: { char *expr_reg get_reg_name(gen_tac(node-data.ret.expr)); printf( movq %s, %%rax\n, expr_reg); // 返回值放 %rax break; } default: break; } }落地技巧emit_asm_header()用_start而非main因为我们要生成裸汇编不依赖 C 运行时sys_exit系统调用是必须的否则程序不退出。NODE_ID处理是简化版真实需维护符号表记录变量地址此处假设int a 1;已在.data段定义。gen_asm()中gen_tac()调用是权宜之计理想方案是 AST 节点自带tac_name字段避免重复遍历。5.3 链接与运行用gcc把汇编变成可执行文件生成的.s文件不能直接./a.out需gcc链接# 生成汇编 ./compiler test.c test.s # 汇编成目标文件 gcc -c test.s -o test.o # 链接禁用 PIE避免地址随机化 gcc -no-pie test.o -o test # 运行 ./test echo $? # 应输出 0关键参数-no-pie是必须的现代gcc默认生成位置无关可执行文件PIE但我们的汇编是固定地址的不加此参数链接会失败。-c只汇编不链接生成.ogcc链接时自动调用ld比手写ld命令更可靠。echo $?检查退出码0表示成功非0表示系统调用失败如sys_exit参数错。6. 我的硬核习惯如何让这份作业成为你的编译器岗敲门砖做完南开这个作业你手上有一份可演示、可讲解、可 debug 的 C 编译器最小可行产品MVP。但它真正值钱的地方不在“能跑”而在你能否在面试时对着白板画出int main(){return 12;}的完整流程flex怎么切出1和2两个INT_CONSTbison怎么构建二叉树gen_tac()怎么生成t1 1,t2 2,t3 t1 t2alloc_reg()怎么把t1映射到%rbx、t2到%rcx、t3到%rax最后gcc -no-pie怎么把它变成机器码。这才是面试官想听的。我的建议是立刻给你的编译器加一个-d调试开关。不是加日志而是加 AST 图形化输出。用graphviz生成.dot文件// dotgen.c void print_ast_dot(ASTNode *node, FILE *f) { if (!node) return; fprintf(f, n%p [label\, (void*)node); switch (node-type) { case NODE_INT: fprintf(f, INT(%d), node-data.intval); break; case NODE_ID: fprintf(f, ID(%s), node-data.idname); break; case NODE_BINARY: fprintf(f, BIN(%c), node-data.binary.op); break; default: fprintf(f, NODE); break; } fprintf(f, \];\n); if (node-type NODE_BINARY) { fprintf(f, n%p - n%p;\n, (void*)node, (void*)node-data.binary.left); fprintf(f, n%p - n%p;\n, (void*)node, (void*)node-data.binary.right); print_ast_dot(node-data.binary.left, f); print_ast_dot(node-data.binary.right, f); } }然后./compiler -d test.c ast.dot dot -Tpng ast.dot -o ast.png一张图胜过千行解释。这招我教过的学生80% 拿到了编译器本文还有配套的精品资源点击获取
返回列表