编译原理实验报告:构建可验证的编译器工程实践骨架
简介本资源是东北大学秦皇岛分校编译原理课程的词法分析实验报告面向计算机专业本科生及编译技术初学者聚焦PL/0语言词法分析器的设计与C实现解决从理论到代码落地的关键实践问题。压缩包为单个122KB的Word文档.doc完整包含实验目的、环境配置WindowsC、核心代码含isLetter/isDigit等字符判断函数及main主流程、关键词表定义、错误检测逻辑如识别2A类非法标识符及详细执行流程说明结构清晰、注释充分便于理解词法分析各环节衔接。已有98人学习下载读者可直接复现运行、对照源码调试、掌握单词分类规则与二元组输出机制并借鉴其模块化函数设计与文件读取处理方式为后续语法分析实验打下扎实基础。1. 编译原理实验报告不是交作业的文档而是你亲手造出“语言翻译官”的过程记录如果你刚上完编译原理课正对着“词法分析→语法分析→语义分析→中间代码生成→优化→目标代码生成”这条流水线发懵或者你已写完lex/yacc、手撸过递归下降解析器却卡在“怎么把实验串成一条可验证、可复现、能讲清每一步决策依据的完整证据链”——那这份《编译原理实验报告》就不是课程结业附件而是你第一次以工程师身份把抽象理论落地为可执行、可调试、可解释的系统性工程的“黑匣子拆解日志”。它解决的核心问题是当语法树长出来、四元式填满、寄存器分配完成你怎么证明每一步没走偏适合两类人一是需要靠报告体现工程能力、为后续系统编程/语言设计打基础的学生二是想用最小代价复现经典实验、跳过教学框架黑盒、直击核心机制的一线开发者。它不教“什么是FIRST集”但会告诉你“为什么这个FIRST集算出来后你的LL(1)分析表第3行第7列必须填error”。2. 从空目录开始搭建可验证的实验骨架拒绝“跑通即结束”编译原理实验最易翻车的起点是把所有代码塞进一个main.c里靠printf硬调。这种结构无法隔离模块、无法单步验证、更无法回溯错误源头。我一般会强制按编译流程分层建目录每个阶段输出明确中间产物并自带校验钩子。2.1 目录结构与文件职责让每一层都“看得见、摸得着”compiler-exp/ ├── src/ │ ├── lexer/ # 词法分析器输入源码输出token流含行号、位置 │ │ ├── scanner.l # flex定义或手写DFA实现 │ │ └── token.h # token类型枚举、token结构体定义 │ ├── parser/ # 语法分析器输入token流输出AST或语法树 │ │ ├── parser.y # bison定义或递归下降/LL(1)手写代码 │ │ └── ast.h # AST节点类型、构造函数、打印函数 │ ├── semantic/ # 语义分析遍历AST检查类型、作用域、未声明变量等 │ │ └── checker.c # 符号表管理、类型推导、错误收集 │ ├── irgen/ # 中间代码生成将AST转为三地址码/四元式 │ │ └── ir.c # 四元式结构体、生成规则、临时变量命名策略 │ └── codegen/ # 目标代码生成如x86-64汇编基于IR寄存器分配 │ └── gen.c # 寄存器分配算法图着色/线性扫描、指令选择 ├── test/ # 测试用例驱动每个test有源码、期望token流、期望AST、期望IR │ ├── valid/ │ │ ├── hello.c # 正确程序 │ │ └── hello.exp # 期望的token序列逐行比对 │ └── invalid/ │ └── undecl.c # 含未声明变量的错误程序 ├── build/ # 构建产物.o、可执行文件、中间文件存放地 ├── scripts/ # 自动化脚本一键运行全链路、对比输出、生成报告 │ └── run_all.sh └── report/ # 实验报告素材截图、数据表格、关键代码片段、错误分析提示test/valid/hello.exp不是随意写的文本而是由参考实现如已知正确的lexer生成的黄金标准golden output。每次修改lexer后必须重新生成并人工确认其合理性——这是避免“自证循环”的第一道防线。2.2 最小可验证链路用5行C代码跑通全流程我们以最简整数加法int a 1 2;为例验证骨架是否真正贯通// test/valid/add.c int main() { int a 1 2; return a; }执行以下命令应得到清晰分阶段输出# 1. 词法分析生成token流带位置信息 $ ./build/lexer test/valid/add.c build/add.tokens $ head -n 10 build/add.tokens KEYWORD:int LINE:1 COL:1 ID:main LINE:1 COL:5 LPAREN LINE:1 COL:9 RPAREN LINE:1 COL:10 LBRACE LINE:1 COL:12 KEYWORD:int LINE:2 COL:5 ID:a LINE:2 COL:9 ASSIGN LINE:2 COL:11 INT:1 LINE:2 COL:13 PLUS LINE:2 COL:15 INT:2 LINE:2 COL:17 SEMI LINE:2 COL:18 RBRACE LINE:3 COL:1 # 2. 语法分析生成AST文本化表示便于肉眼核对 $ ./build/parser build/add.tokens build/add.ast $ cat build/add.ast Program └── FunctionDef: main └── Block └── Decl: int a └── BinaryOp: ├── IntLiteral: 1 └── IntLiteral: 2 # 3. 语义分析输出符号表快照和错误日志 $ ./build/checker build/add.ast build/add.sem.log $ cat build/add.sem.log [INFO] Symbol table after main: {a - typeint, scopelocal} [INFO] No semantic errors found. # 4. 中间代码生成输出四元式序列标准格式可被后续阶段消费 $ ./build/irgen build/add.ast build/add.ir $ cat build/add.ir t1 1 t2 2 t3 t1 t2 a t3逻辑说明lexer读取源码按flex规则切分token必须携带LINE/COL否则后续报错无法定位parser输入是lexer输出的token流非原始源码输出AST结构体ast.h中需提供print_ast()函数将树转为缩进文本——这是调试语法树是否正确的唯一可靠方式checker接收AST构建符号表哈希表作用域栈对每个ID节点查表未查到则报错并记录位置irgen遍历AST为每个表达式生成临时变量t1, t2...命名规则必须全局唯一且可预测如按DFS序号否则后续寄存器分配会混乱。参数说明所有可执行文件lexer,parser等均从stdin读输入、向stdout写输出禁用文件路径硬编码——这保证了管道|串联的可靠性build/目录为构建产物专属区禁止源码中出现../build/路径所有I/O通过重定向完成test/下每个用例必须配.exp文件run_all.sh脚本会自动调用diff比对实际输出与期望输出任何一行不匹配即标为FAIL。3. 词法分析器避坑正则不是万能的状态机才是你的后悔药词法分析看似简单却是实验中最容易“表面跑通、深层崩坏”的环节。很多同学用flex写出能识别int、、123的规则却在处理注释嵌套、字符串转义、浮点数边界时集体翻车。以下是血泪经验总结的5个高频坑3.1 现象注释吞掉不该吞的内容导致后续token错位原因flex默认贪婪匹配/*[^*]**[^/*][^*]**/这类正则在遇到/* comment */ int x;时可能正确但遇到/* nested /* comment */ */或/* comment with */ inside */时因回溯失败而截断导致int被吃进注释里。解决放弃复杂正则改用flex的起始条件start conditions构建状态机。定义COMMENT状态在/*进入*/退出期间所有字符包括*和/均忽略。代码片段如下%{ #include token.h %} %x COMMENT %% /* { BEGIN COMMENT; } COMMENT*/ { BEGIN INITIAL; } COMMENT. { /* ignore all chars in comment */ } COMMENT\n { /* count line, but dont emit newline token */ } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str strdup(yytext); return ID; } [0-9] { yylval.num atoi(yytext); return INT; } { return PLUS; } ; { return SEMI; } \n { lineno; } [ \t] { /* skip whitespace */ } . { fprintf(stderr, Lexical error at line %d: unknown char %s\n, lineno, yytext); exit(1); } %%注意COMMENT.规则必须放在COMMENT*/之后否则*/会被.先匹配掉——flex规则按顺序匹配顺序即优先级。3.2 现象字符串字面量中的\n被识别为换行符导致行号错乱原因flex默认将\n视为行结束符并自动增lineno但字符串内\n是合法字符不应触发行号更新。解决在字符串状态中禁用自动换行计数手动处理。添加STRING状态\ { BEGIN STRING; } STRING\ { BEGIN INITIAL; return STRING_LITERAL; } STRING\\n { /* append \n to string buffer, do NOT increment lineno */ } STRING\n { /* ERROR: unescaped newline in string */ } STRING\\[0-7]{1,3} { /* octal escape, convert and append */ } STRING. { /* append char, except unescaped quote/backslash */ }3.3 现象浮点数字面量123.45e-6被切成123.45和e-6两个token原因正则[0-9]\.[0-9]([eE][-]?[0-9])?未覆盖整数部分为0的情况如.5也未处理e后无数字如1e的错误情形。解决拆分为两组规则用flex的yymore()拼接[0-9]\.([0-9])?([eE][-]?[0-9])? { /* handle 123., 123.45, 123.45e-6 */ } \.[0-9]([eE][-]?[0-9])? { /* handle .45, .45e2 */ } [0-9][eE][-]?[0-9] { /* handle 123e-6 */ }3.4 现象关键字与标识符冲突intx被误判为intx原因flex按最长匹配原则intx中int是关键字但intx本身更长若未将关键字规则置于标识符规则之前则intx被当作ID。解决关键字规则必须严格前置且显式终止int { return KEYWORD_INT; } if { return KEYWORD_IF; } /* ... all keywords ... */ [a-zA-Z_][a-zA-Z0-9_]* { yylval.str strdup(yytext); return ID; }3.5 现象预处理器指令#include stdio.h被当作普通代码解析原因实验通常不实现预处理但源码中存在#行若未特殊处理#会被当作文法错误。解决在INITIAL状态添加忽略行^#.*\n { lineno; /* skip preprocessor line, advance line counter */ }4. 语法分析器攻坚从Bison模板到手写递归下降的取舍逻辑语法分析是编译流程的中枢选型直接决定后续语义分析的难度。BisonYacc生成LR(1)分析器适合教学但真实项目中手写递归下降Recursive Descent因其可读性、可控性和调试友好性成为工业界首选。本节不争论优劣只说清楚什么场景该用Bison什么场景必须手写以及手写时如何避免经典陷阱。4.1 Bison不是银弹何时该果断放弃Bison适合语法高度规则、左递归可消除、冲突少的场景如PL/0、TINY语言。但一旦遇到以下情况强行用Bison会付出巨大调试成本右结合运算符如赋值a b cBison默认左结合需手动改写文法或用%right声明但复杂表达式易出错上下文相关语法如C语言中*p是解引用int *p是声明Bison纯文法层无法区分必须依赖词法分析器传入额外信息如TYPE_NAMEtoken增加耦合错误恢复弱Bison报错后常直接abort而手写解析器可在expect(SEMI)失败时跳过至下一个;继续解析后续语句。我的经验如果实验要求支持if-else悬空问题dangling else、或需在语法分析阶段做简单类型推导如auto x 1;立刻切换手写递归下降。Bison此时不是加速器而是枷锁。4.2 手写递归下降用C实现一个可调试的Parser以支持int a 1 2 * 3;的声明语句为例核心是运算符优先级的自然体现。我们不写BNF直接写函数// parser.h typedef struct ASTNode ASTNode; struct ASTNode { enum { NODE_DECL, NODE_BINARY, NODE_INT } type; union { struct { char* name; ASTNode* init; } decl; struct { char op; ASTNode* left; ASTNode* right; } binary; int value; }; }; ASTNode* parse_program(); ASTNode* parse_declaration(); ASTNode* parse_expression(int prec); // prec: current precedence level ASTNode* parse_primary(); // parser.c ASTNode* parse_program() { ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_PROGRAM; node-program.body parse_declaration(); // assume single decl for demo return node; } ASTNode* parse_declaration() { expect(KEYWORD_INT); // consume int char* name expect(ID)-str; // consume identifier expect(ASSIGN); ASTNode* init parse_expression(0); // start with lowest precedence expect(SEMI); ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_DECL; node-decl.name name; node-decl.init init; return node; } // Pratt parsing: precedence climbing ASTNode* parse_expression(int prec) { ASTNode* left parse_primary(); while (1) { int op_prec get_operator_precedence(peek_token().type); if (op_prec prec) break; // stop at lower precedence Token op consume_token(); // consume , -, etc. left make_binary_node(op.type, left, parse_expression(op_prec 1)); } return left; } ASTNode* parse_primary() { Token tok peek_token(); if (tok.type INT) { consume_token(); ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_INT; node-value tok.num; return node; } else if (tok.type LPAREN) { consume_token(); ASTNode* expr parse_expression(0); expect(RPAREN); return expr; } else { fprintf(stderr, Syntax error: expected primary at line %d\n, tok.line); exit(1); } }逻辑说明parse_expression(int prec)是核心采用Pratt解析法precedence climbing通过递归调用自身并传入更高precedence天然实现*优先于peek_token()不消耗token仅查看下一个consume_token()才真正移进expect(T)先peek再consume失败则报错make_binary_node()创建AST节点left/right指针明确指向子树为后续语义分析提供结构基础。参数说明prec参数初始为0和-设为10*和/设为20设为5右结合需特殊处理get_operator_precedence()返回当前token的优先级必须覆盖所有运算符未定义则返回0expect()函数内部必须检查peek_token().type不匹配时打印详细位置line/col并exit避免静默失败。5. 语义分析与中间代码生成用符号表和四元式打通“懂代码”到“会翻译”的最后一公里词法和语法分析解决“代码长什么样”语义分析解决“代码什么意思”中间代码生成解决“怎么把它变成机器能懂的步骤”。这三步环环相扣符号表是语义分析的基石四元式是中间代码的通用载体。本节聚焦如何让这两者真正协同工作而非各自为政。5.1 符号表设计不是哈希表而是作用域栈上的动态映射符号表不能是简单的char* - Type哈希表因为C语言有块作用域{ int x; ... int x; }合法。必须支持作用域嵌套、同名遮蔽、跨作用域查找。我采用“作用域栈哈希表”混合结构// symbol.h typedef struct Symbol Symbol; struct Symbol { char* name; Type type; // enum { TYPE_INT, TYPE_VOID, ... } int is_function; // 1 if function, 0 if variable int offset; // stack offset for local var, 0 for global Symbol* next; // for hash chain }; typedef struct Scope Scope; struct Scope { Symbol** table; // hash table (size 101) int size; // hash table size Scope* parent; // outer scope }; Scope* current_scope; Scope* create_scope(Scope* parent) { Scope* s malloc(sizeof(Scope)); s-table calloc(101, sizeof(Symbol*)); s-size 101; s-parent parent; return s; } void enter_scope() { current_scope create_scope(current_scope); } void exit_scope() { Scope* old current_scope; current_scope current_scope-parent; free_scope(old); // free all symbols in this scope } Symbol* lookup_symbol(char* name) { Scope* s current_scope; while (s) { unsigned int hash hash_string(name) % s-size; for (Symbol* sym s-table[hash]; sym; sym sym-next) { if (strcmp(sym-name, name) 0) return sym; } s s-parent; } return NULL; } void insert_symbol(char* name, Type type, int is_func) { if (lookup_symbol(name)) { fprintf(stderr, Semantic error: redefinition of %s at line %d\n, name, lineno); exit(1); } unsigned int hash hash_string(name) % current_scope-size; Symbol* sym malloc(sizeof(Symbol)); sym-name strdup(name); sym-type type; sym-is_function is_func; sym-offset 0; // will set in codegen sym-next current_scope-table[hash]; current_scope-table[hash] sym; }关键点enter_scope()/exit_scope()在遇到{和}时调用必须与语法分析器的AST节点生成同步lookup_symbol()从current_scope向上查找实现遮蔽insert_symbol()只在当前作用域插入不检查外层hash_string()用DJB2算法避免哈希碰撞过多。5.2 四元式生成从AST到(op, arg1, arg2, result)的确定性映射四元式是中间代码的“普通话”格式统一为(op, arg1, arg2, result)。生成规则必须与AST节点类型一一对应且临时变量命名必须全局唯一、可追溯。以BinaryOp节点为例// ir.c int temp_counter 0; char* new_temp() { char* name malloc(10); sprintf(name, t%d, temp_counter); return name; } char* generate_ir(ASTNode* node) { switch (node-type) { case NODE_INT: { char* t new_temp(); printf(%s %d\n, t, node-value); return t; } case NODE_BINARY: { char* left generate_ir(node-binary.left); char* right generate_ir(node-binary.right); char* result new_temp(); char* op_str; switch (node-binary.op) { case PLUS: op_str ; break; case MINUS: op_str -; break; case TIMES: op_str *; break; default: op_str ?; } printf(%s %s %s %s\n, result, left, op_str, right); return result; } case NODE_DECL: { // for int a expr;, generate IR for expr, then assign to a char* init generate_ir(node-decl.init); printf(%s %s\n, node-decl.name, init); return node-decl.name; } default: fprintf(stderr, IR generation error: unknown node type\n); exit(1); } }逻辑说明new_temp()返回t0,t1,t2...不重置计数器保证全局唯一generate_ir()递归遍历AST每个节点返回其计算结果的临时变量名如12返回t0t0*3返回t1对NODE_DECL先生成初始化表达式的IR再生成赋值语句不生成a的声明语句四元式不处理声明只处理计算。参数说明temp_counter为全局变量不可在函数内static声明否则多轮调用会重置printf输出必须严格按(result, op, arg1, arg2)顺序与标准四元式格式一致方便后续优化器读取NODE_BINARY中arg1和arg2顺序必须与AST中left/right一致乘法交换律不影响但减法/除法必须严格。6. 实验报告的终极验证用3个硬指标判断你的报告是否真有价值一份合格的《编译原理实验报告》不能止于“我实现了”而要回答“我实现的是否健壮是否可解释是否可演进” 我用三个可量化的硬指标来验收——它们不来自评分标准而来自真实工程场景的倒逼。6.1 指标一错误定位精度——报错行号与列号误差 ≤ 1编译器报错若只说“syntax error”等于没报。真正的价值在于当输入int a 1 ;缺少右操作数时你的parser必须精准指出line 1, column 12后的位置而非笼统的line 1。验证方法准备10个典型错误用例缺分号、括号不匹配、未闭合字符串、关键字拼错等运行parser捕获stderr输出用正则提取line (\d), column (\d)与人工标注的真实错误位置比对10个用例中9个以上误差≤1列才算达标。我的教训早期用Bison时错误位置常滞后1-2个token因为Bison的yyerror()在归约失败后才触发。改用手写递归下降后在expect(SEMI)失败时立即打印peek_token().line/col精度飙升。错误位置不是附属功能而是调试生命线。6.2 指标二中间产物可逆性——从IR反推源码的还原度 ≥ 80%四元式是编译的“中间态”但它不该是黑盒。一份高价值报告必须证明IR忠实反映了源码意图。验证方法选取5个复杂表达式如a (b c) * d - e / f生成其四元式序列手动将四元式“翻译”回C表达式按依赖关系拓扑排序用字符串相似度算法如Levenshtein距离比对还原表达式与原表达式平均相似度≥80%说明IR生成逻辑清晰、无歧义丢失。例如原式a b * c d生成t1 b * c t2 t1 d a t2还原为a b * c d相似度100%若生成a b * c d省略临时变量则丢失了计算顺序信息还原度暴跌。6.3 指标三模块解耦度——替换lexer后parser/semantic/irgen无需修改一行代码这是检验架构设计的终极压力测试。当你把flex生成的lexer换成手写DFA lexer或把递归下降parser换成LL(1) table-driven parser时其他模块的源码、编译命令、测试用例应完全不变。验证清单模块替换前依赖替换后检查点是否通过parser读取token.h中enum token_type新lexer是否输出相同token类型INT还是TK_INT✅semantic调用lookup_symbol(x)新符号表API是否兼容insert_symbol()参数是否一致✅irgenprintf(%s ...\n, temp_name)输出格式是否仍为t0 1空格/换行是否一致✅不通过即重构若发现parser.c里硬编码了KEYWORD_INT字符串说明lexer与parser耦合过深必须抽离为token.h常量。最后说一句我带过几届某高校编译原理实验课见过太多报告堆砌大段Bison语法却说不清%left为何能解决移进-归约冲突也见过手写parser的同学因没加lineno导致所有错误行号错位而通宵调试。编译原理实验的价值不在“造出一个能跑的玩具”而在“亲手拆开每一个齿轮看清它为何转动、何时卡死、怎样修复”。这份报告就是你拆解过程的全程录像带——帧帧可查步步可验。希望帮到你。本文还有配套的精品资源点击获取