ARTICLE DETAIL

资讯详情

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

Cminusf编译器实验全链路:词法、语法、语义与中间代码生成实战

Cminusf编译器实验全链路:词法、语法、语义与中间代码生成实战 简介这份资源是重庆大学计算机学院编译原理课程的实验项目集合面向正在学习编译原理、需要动手实现Cminusf语言编译器的本科生与自学者帮助打通从词法分析、语法分析、语义分析到中间代码生成的完整链路。压缩包共361个文件约1.52MB以out、sy、tk、json等测试与配置数据为主辅以h、cpp源码文件及少量py脚本、md说明和pdf文档覆盖实验代码、测试用例与调试记录。目前已有78人学习下载。资源不仅给出实验一至实验三的完整实现还保留了各阶段的调试过程与得分记录读者可据此理解符号表构建、语法树生成、类型检查与中间代码设计等关键环节并借助说明文件与主目录快速搭建环境、复现实验、对照排错适合作为课程作业参考与编译器入门实践范本。1. 从 Cminusf 编译器实验说起一套能跑通的词法、语法、语义与中间代码生成链路长什么样如果你正在搜「编译原理实验」或者「Cminusf 词法分析」大概率是课程实验压到 deadline 了或者想找一个能真正跑通的参考实现来对照自己的代码。Cminusf 是编译原理课程里非常经典的教学语言——它是 C 的一个极小子集支持整型、数组、函数定义与调用、if-else、while 循环语法规则清晰但足够覆盖编译器前端的所有核心环节。重庆大学计算机学院这套实验项目集合把实验一到实验三完整串了起来词法分析、语法分析、语义分析加中间代码生成每个实验都有代码实现和调试记录。这篇文章不讲空泛的编译原理概念而是把这条链路拆成可复现的步骤——词法分析器怎么用 Flex 写、语法分析器怎么用 Bison 搭、语义分析怎么建符号表、中间代码怎么生成四元式以及每一步最容易翻车的地方在哪。适合正在做编译原理实验的本科生也适合想快速回顾编译器前端实现路径的开发者。2. Cminusf 词法分析用 Flex 把字符流切成 Token 序列2.1 词法分析器到底在做什么词法分析是编译器的第一道工序输入是源程序的字符流输出是 Token 序列。Cminusf 的 Token 类型不多但每类都有细节关键字int、void、if、else、while、return、标识符、整数字面量、运算符、-、*、/、、、、、、!、、分隔符;、,、(、)、[、]、{、}以及注释。看起来简单但注释处理、最大匹配原则、行号追踪这三件事是实验里最容易丢分的地方。我一般用 Flex 来做词法分析原因是它的规则写起来直观正则匹配和动作代码分离得干净。下面是一个能直接跑的最小 Flex 规则文件结构%{ #include globals.h #include util.h #include scan.h int lineno 0; %} %option noyywrap DIGIT [0-9] ID [a-zA-Z][a-zA-Z0-9]* WHITESPACE [ \t] %% /* { /* 进入注释处理 */ } */ { /* 退出注释处理 */ } //.* { /* 单行注释直接忽略 */ } {WHITESPACE} { /* 忽略空白 */ } \n { lineno; } int { return INT; } void { return VOID; } if { return IF; } else { return ELSE; } while { return WHILE; } return { return RETURN; } {ID} { return ID; } {DIGIT} { return NUM; } { return LE; } { return GE; } { return EQ; } ! { return NE; } { return LT; } { return GT; } { return ASSIGN; } { return PLUS; } - { return MINUS; } * { return TIMES; } / { return OVER; } ; { return SEMI; } , { return COMMA; } ( { return LPAREN; } ) { return RPAREN; } [ { return LBRACK; } ] { return RBRACK; } { { return LBRACE; } } { return RBRACE; } %% int main(int argc, char **argv) { if (argc ! 2) { fprintf(stderr, Usage: %s input_file\n, argv[0]); return 1; } yyin fopen(argv[1], r); if (!yyin) { fprintf(stderr, Cannot open file %s\n, argv[1]); return 1; } TokenType token; while ((token yylex()) ! 0) { // 输出 token 类型和行号 fprintf(stdout, Token: %d, Line: %d\n, token, lineno); } fclose(yyin); return 0; }这段代码的逻辑很直接Flex 按规则顺序做最长匹配匹配到关键字就返回对应 Token 类型匹配到标识符返回 ID匹配到数字返回 NUM。lineno在遇到换行时自增用于后续语法分析报错时定位行号。参数方面%option noyywrap告诉 Flex 不需要 yywrap 函数单文件输入时直接返回 0 表示 EOF。编译命令是flex scanner.l gcc lex.yy.c -o scanner然后./scanner test.cminus就能看到 Token 输出。2.2 注释处理和最大匹配两个必踩的坑注释处理是词法分析里第一个容易翻车的地方。Cminusf 支持/* */块注释但 Flex 本身不擅长处理跨行的嵌套结构。常见做法是用状态机定义%x COMMENT状态遇到/*进入注释状态在注释状态里忽略所有字符直到遇到*/退出。如果你直接在规则里写/*([^*]|\*[^*/])*\*/这种正则短注释没问题但遇到长注释或者注释里有特殊字符时容易匹配失败。最大匹配原则是第二个坑。比如输入Flex 会优先匹配还是答案是因为 Flex 默认采用最长匹配。但如果你把的规则写在前面Flex 仍然会选更长的那个。真正的问题出在和上如果源程序里写了a b而你的规则里在前面Flex 依然会正确匹配因为最长匹配优先。但如果你手写词法分析器而不是用 Flex就必须自己实现向前看一个字符的逻辑否则会被切成两个。提示实验里词法分析的得分点通常包括能否正确处理所有 Token 类型、行号是否准确、注释是否被正确跳过、非法字符是否有报错。建议写一个包含所有 Token 类型的测试文件逐行核对输出。3. Cminusf 语法分析用 Bison 构建 AST 并处理优先级3.1 语法分析的核心任务与 Bison 规则编写语法分析接收词法分析输出的 Token 序列按照 Cminusf 的文法规则构建抽象语法树AST。Cminusf 的文法不算复杂但有几个地方需要特别注意表达式的优先级和结合性、if-else 的悬挂问题、数组下标和函数调用的区分。用 Bison 写语法分析器核心是定义%token、%type和文法规则。下面是一个简化但可运行的 Bison 框架%{ #include stdio.h #include stdlib.h #include globals.h #include util.h #include scan.h #include parse.h #include tree.h static TreeNode *savedTree; %} %token INT VOID IF ELSE WHILE RETURN %token ID NUM %token LE GE EQ NE LT GT ASSIGN %token PLUS MINUS TIMES OVER %token SEMI COMMA LPAREN RPAREN LBRACK RBRACK LBRACE RBRACE %token ERROR %nonassoc LOWER_THAN_ELSE %nonassoc ELSE %right ASSIGN %left EQ NE LT GT LE GE %left PLUS MINUS %left TIMES OVER %left LBRACK RBRACK %left LPAREN RPAREN %% program : declaration_list { savedTree $1; } ; declaration_list: declaration_list declaration { $$ newStmtNode(DeclK); $$-child[0] $1; $$-child[1] $2; } | declaration { $$ $1; } ; declaration : var_declaration { $$ $1; } | fun_declaration { $$ $1; } ; var_declaration : type_specifier ID SEMI { $$ newDeclNode(VarK); $$-attr.name $2; $$-type $1; } | type_specifier ID LBRACK NUM RBRACK SEMI { $$ newDeclNode(ArrayK); $$-attr.name $2; $$-type $1; $$-attr.value $4; } ; type_specifier : INT { $$ Integer; } | VOID { $$ Void; } ; fun_declaration : type_specifier ID LPAREN params RPAREN compound_stmt { $$ newDeclNode(FuncK); $$-attr.name $2; $$-type $1; $$-child[0] $4; $$-child[1] $6; } ; params : param_list { $$ $1; } | VOID { $$ NULL; } ; param_list : param_list COMMA param { $$ newStmtNode(ParamK); $$-child[0] $1; $$-child[1] $3; } | param { $$ $1; } ; param : type_specifier ID { $$ newDeclNode(ParamK); $$-attr.name $2; $$-type $1; } | type_specifier ID LBRACK RBRACK { $$ newDeclNode(ArrayK); $$-attr.name $2; $$-type $1; } ; compound_stmt : LBRACE local_declarations statement_list RBRACE { $$ newStmtNode(CompoundK); $$-child[0] $2; $$-child[1] $3; } ; local_declarations : local_declarations var_declaration { $$ newStmtNode(DeclK); $$-child[0] $1; $$-child[1] $2; } | /* empty */ { $$ NULL; } ; statement_list : statement_list statement { $$ newStmtNode(StmtK); $$-child[0] $1; $$-child[1] $2; } | /* empty */ { $$ NULL; } ; statement : expression_stmt { $$ $1; } | compound_stmt { $$ $1; } | selection_stmt { $$ $1; } | iteration_stmt { $$ $1; } | return_stmt { $$ $1; } ; expression_stmt : expression SEMI { $$ $1; } | SEMI { $$ NULL; } ; selection_stmt : IF LPAREN expression RPAREN statement %prec LOWER_THAN_ELSE { $$ newStmtNode(IfK); $$-child[0] $3; $$-child[1] $5; } | IF LPAREN expression RPAREN statement ELSE statement { $$ newStmtNode(IfK); $$-child[0] $3; $$-child[1] $5; $$-child[2] $7; } ; iteration_stmt : WHILE LPAREN expression RPAREN statement { $$ newStmtNode(WhileK); $$-child[0] $3; $$-child[1] $5; } ; return_stmt : RETURN SEMI { $$ newStmtNode(ReturnK); } | RETURN expression SEMI { $$ newStmtNode(ReturnK); $$-child[0] $2; } ; expression : var ASSIGN expression { $$ newExpNode(AssignK); $$-child[0] $1; $$-child[1] $3; } | simple_expression { $$ $1; } ; var : ID { $$ newExpNode(IdK); $$-attr.name $1; } | ID LBRACK expression RBRACK { $$ newExpNode(IdK); $$-attr.name $1; $$-child[0] $3; } ; simple_expression : additive_expression relop additive_expression { $$ newExpNode(OpK); $$-attr.op $2; $$-child[0] $1; $$-child[1] $3; } | additive_expression { $$ $1; } ; relop : LE { $$ LE; } | LT { $$ LT; } | GT { $$ GT; } | GE { $$ GE; } | EQ { $$ EQ; } | NE { $$ NE; } ; additive_expression : additive_expression addop term { $$ newExpNode(OpK); $$-attr.op $2; $$-child[0] $1; $$-child[1] $3; } | term { $$ $1; } ; addop : PLUS { $$ PLUS; } | MINUS { $$ MINUS; } ; term : term mulop factor { $$ newExpNode(OpK); $$-attr.op $2; $$-child[0] $1; $$-child[1] $3; } | factor { $$ $1; } ; mulop : TIMES { $$ TIMES; } | OVER { $$ OVER; } ; factor : LPAREN expression RPAREN { $$ $2; } | var { $$ $1; } | call { $$ $1; } | NUM { $$ newExpNode(ConstK); $$-attr.val atoi(yytext); } ; call : ID LPAREN args RPAREN { $$ newExpNode(CallK); $$-attr.name $1; $$-child[0] $3; } ; args : arg_list { $$ $1; } | /* empty */ { $$ NULL; } ; arg_list : arg_list COMMA expression { $$ newStmtNode(ArgK); $$-child[0] $1; $$-child[1] $3; } | expression { $$ $1; } ; %% int yyerror(char *message) { fprintf(stderr, Syntax error at line %d: %s\n, lineno, message); return 0; }这段 Bison 代码的关键点在于优先级声明。%right ASSIGN让赋值右结合%left PLUS MINUS让加减左结合%left TIMES OVER让乘除优先级高于加减。%nonassoc LOWER_THAN_ELSE和%nonassoc ELSE配合%prec LOWER_THAN_ELSE解决 if-else 的悬挂问题——当if (x) if (y) a; else b;出现时else 会绑定到最近的 if 上。3.2 AST 节点设计与构建逻辑AST 的节点设计直接决定了后续语义分析和代码生成的难度。我一般把节点分成三类声明节点DeclK、语句节点StmtK、表达式节点ExpK。每个节点包含节点类型、子节点指针数组、属性名字、值、操作符和类型信息。typedef enum { StmtK, ExpK, DeclK } NodeKind; typedef enum { IfK, WhileK, ReturnK, CompoundK, DeclK, ParamK, ArgK } StmtKind; typedef enum { OpK, ConstK, IdK, AssignK, CallK } ExpKind; typedef enum { Void, Integer, Boolean, Array } ExpType; typedef struct treeNode { struct treeNode *child[MAXCHILDREN]; struct treeNode *sibling; int lineno; NodeKind nodekind; union { StmtKind stmt; ExpKind exp; } kind; union { int op; int val; char *name; } attr; ExpType type; } TreeNode;构建 AST 时newStmtNode、newExpNode、newDeclNode分别分配对应类型的节点并初始化。子节点通过child[0]、child[1]挂载兄弟节点通过sibling指针连接。这种设计的好处是遍历时可以用统一的递归函数处理缺点是子节点数量固定扩展性一般但对 Cminusf 来说够用了。注意Bison 的%union如果没定义默认所有语义值都是 int 类型。如果你需要传递字符串比如标识符名字必须在%union里定义char *name并在%token和%type里指定对应类型。否则编译时会出现类型不匹配的警告运行时可能直接段错误。4. 语义分析与中间代码生成符号表、类型检查和四元式输出4.1 符号表的建立与作用域管理语义分析阶段的核心任务是类型检查和符号表管理。Cminusf 的作用域规则是块级作用域全局变量和函数在最外层函数参数在函数体内可见局部变量在复合语句块内可见。符号表我一般用哈希表加栈式作用域来实现——每进入一个复合语句就压入一个新作用域退出时弹出。#define SIZE 211 #define SHIFT 4 typedef struct SymbolTable { char *name; ExpType type; int isArray; int arraySize; int isFunction; int paramCount; struct SymbolTable *next; } SymbolTable; static SymbolTable *hashTable[SIZE]; static ScopeStack *scopeStack; void pushScope() { ScopeStack *newScope (ScopeStack *)malloc(sizeof(ScopeStack)); newScope-table (SymbolTable **)calloc(SIZE, sizeof(SymbolTable *)); newScope-next scopeStack; scopeStack newScope; } void popScope() { if (scopeStack NULL) return; ScopeStack *old scopeStack; scopeStack scopeStack-next; // 释放 old-table 中的节点 free(old-table); free(old); } void insertSymbol(char *name, ExpType type, int isArray, int arraySize, int isFunction, int paramCount) { int index hash(name); SymbolTable *newSym (SymbolTable *)malloc(sizeof(SymbolTable)); newSym-name strdup(name); newSym-type type; newSym-isArray isArray; newSym-arraySize arraySize; newSym-isFunction isFunction; newSym-paramCount paramCount; newSym-next scopeStack-table[index]; scopeStack-table[index] newSym; } SymbolTable *lookupSymbol(char *name) { ScopeStack *current scopeStack; while (current ! NULL) { int index hash(name); SymbolTable *sym current-table[index]; while (sym ! NULL) { if (strcmp(sym-name, name) 0) return sym; sym sym-next; } current current-next; } return NULL; }这段代码的逻辑是pushScope在进入新块时创建一张新哈希表并压栈popScope在退出块时弹栈并释放。insertSymbol把符号插入当前作用域的哈希表lookupSymbol从当前作用域往外层逐层查找。参数说明hash函数用简单的移位加法实现SIZE取 211 是质数减少哈希冲突。4.2 类型检查与四元式生成类型检查主要覆盖几个场景赋值语句左右类型是否匹配、函数调用参数个数和类型是否匹配、数组下标是否用在数组上、返回值类型是否和函数声明一致。每发现一个错误就输出错误信息并记录行号。中间代码生成我一般用四元式格式是(op, arg1, arg2, result)。比如a b c生成(, b, c, t1)和(, t1, _, a)。四元式的好处是结构统一后续做优化或者转目标代码都方便。typedef struct Quadruple { char *op; char *arg1; char *arg2; char *result; struct Quadruple *next; } Quadruple; static Quadruple *quadHead NULL; static Quadruple *quadTail NULL; static int tempCount 0; char *newTemp() { char *temp (char *)malloc(10); sprintf(temp, t%d, tempCount); return temp; } void emitQuad(char *op, char *arg1, char *arg2, char *result) { Quadruple *q (Quadruple *)malloc(sizeof(Quadruple)); q-op strdup(op); q-arg1 arg1 ? strdup(arg1) : NULL; q-arg2 arg2 ? strdup(arg2) : NULL; q-result result ? strdup(result) : NULL; q-next NULL; if (quadHead NULL) { quadHead quadTail q; } else { quadTail-next q; quadTail q; } } void genExpression(TreeNode *tree) { if (tree NULL) return; switch (tree-kind.exp) { case OpK: { genExpression(tree-child[0]); char *left tree-child[0]-attr.name ? tree-child[0]-attr.name : temp; genExpression(tree-child[1]); char *right tree-child[1]-attr.name ? tree-child[1]-attr.name : temp; char *temp newTemp(); emitQuad(opToString(tree-attr.op), left, right, temp); tree-attr.name temp; break; } case ConstK: { char *val (char *)malloc(10); sprintf(val, %d, tree-attr.val); tree-attr.name val; break; } case IdK: { // 标识符直接使用名字 break; } case AssignK: { genExpression(tree-child[1]); char *right tree-child[1]-attr.name; emitQuad(, right, NULL, tree-child[0]-attr.name); break; } case CallK: { // 处理函数调用参数 TreeNode *arg tree-child[0]; while (arg ! NULL) { genExpression(arg); emitQuad(param, arg-attr.name, NULL, NULL); arg arg-sibling; } char *temp newTemp(); emitQuad(call, tree-attr.name, NULL, temp); tree-attr.name temp; break; } } }这段代码的逻辑是递归遍历 AST遇到操作符节点就先生成左右子表达式的代码然后生成一条四元式。newTemp每次生成一个新的临时变量名emitQuad把四元式追加到链表尾部。参数方面opToString把 Token 类型转成字符串表示tempCount是全局计数器保证临时变量名不重复。提示语义分析的得分点通常包括符号表是否正确处理作用域嵌套、类型检查是否覆盖所有错误场景、四元式输出是否和预期一致。建议用几个包含错误未声明变量、参数不匹配、数组下标越界的测试用例来验证。5. 调试记录里反复出现的坑从段错误到四元式顺序错乱5.1 段错误符号表空指针和 AST 递归越界现象程序在语义分析阶段直接崩溃报 Segmentation fault。原因通常是符号表查找返回 NULL 后没有检查就直接访问成员或者 AST 递归时子节点为空但代码没有判空。解决方法是所有lookupSymbol的返回值在使用前必须判空AST 遍历函数入口先检查tree NULL直接返回。5.2 四元式顺序错乱递归顺序和临时变量分配时机现象生成的中间代码里操作数的临时变量名对不上或者四元式顺序和预期不一致。原因通常是递归生成表达式代码时先递归了右子树再递归左子树或者临时变量在递归之前就分配了。解决方法是严格按照「先左后右」的顺序递归临时变量在左右子表达式都生成完毕后再分配。5.3 类型检查漏报数组和整型混用现象int a[10]; a 5;这种明显类型错误没有被报出来。原因是符号表里数组和整型都存成 Integer 类型没有区分isArray标志。解决方法是在类型检查时同时检查isArray字段数组只能用于下标访问或作为函数参数传递。5.4 Bison 冲突移进-归约冲突导致解析失败现象Bison 编译时报conflicts: 3 shift/reduce运行时某些语句解析出错。原因通常是 if-else 的悬挂问题或者表达式优先级声明不完整。解决方法是补全%nonassoc和%left声明用%prec显式指定规则优先级。如果冲突数量不多且不影响功能也可以暂时忽略但最好还是消除。5.5 行号错位Flex 里换行计数位置不对现象语法报错时行号总是差一行。原因是 Flex 规则里\n的动作放在其他规则后面或者注释里的换行没有被计数。解决方法是把\n的规则放在最前面并且在注释处理状态里也要对换行做计数。6. 用测试用例反推实现正确性一套可复用的验证方法最后一章不讲大道理讲一个我反复用的验证技巧用「最小可复现测试集」反推每个模块的正确性。具体做法是准备三组测试文件——第一组只包含词法元素各种 Token 和注释第二组只包含语法结构if-else、while、函数定义第三组包含语义错误未声明变量、类型不匹配、参数个数不对。每组文件跑完之后对照预期输出逐项核对。测试组输入特征预期输出常见失败表现词法组包含所有 Token 类型和注释Token 序列正确行号准确注释未跳过行号偏移语法组嵌套 if-else、while、函数调用AST 结构正确无语法错误悬挂 else 绑定错误语义组未声明变量、类型不匹配报错信息含行号和错误类型漏报或误报代码生成组简单表达式和赋值四元式顺序和操作数正确临时变量名冲突我一般会写一个 shell 脚本自动跑这三组测试把输出和预期结果做 diff。这样每次改完代码跑一遍脚本就知道有没有引入回归。#!/bin/bash # run_tests.sh - 编译原理实验自动化测试脚本 TEST_DIR./tests EXPECTED_DIR./expected OUTPUT_DIR./output mkdir -p $OUTPUT_DIR for test_file in $TEST_DIR/*.cminus; do base_name$(basename $test_file .cminus) echo Running test: $base_name # 词法分析 ./scanner $test_file $OUTPUT_DIR/$base_name.tokens 21 # 语法分析 语义分析 代码生成 ./parser $test_file $OUTPUT_DIR/$base_name.quads 21 # 对比预期结果 if diff -q $EXPECTED_DIR/$base_name.tokens $OUTPUT_DIR/$base_name.tokens /dev/null; then echo Token test: PASS else echo Token test: FAIL diff $EXPECTED_DIR/$base_name.tokens $OUTPUT_DIR/$base_name.tokens fi if diff -q $EXPECTED_DIR/$base_name.quads $OUTPUT_DIR/$base_name.quads /dev/null; then echo Quad test: PASS else echo Quad test: FAIL diff $EXPECTED_DIR/$base_name.quads $OUTPUT_DIR/$base_name.quads fi done这个脚本的逻辑是遍历tests目录下所有.cminus文件分别用scanner和parser处理输出到output目录然后和expected目录下的预期结果做 diff。参数方面TEST_DIR、EXPECTED_DIR、OUTPUT_DIR可以根据实际目录结构调整。如果某个测试失败diff 会直接打印差异行方便定位问题。这套方法最大的好处是你不需要每次手动敲命令、肉眼比对输出。改完代码跑一遍脚本几分钟内就知道有没有引入回归。我自己的习惯是每完成一个模块就先写测试用例再写实现代码这样调试的时候目标非常明确——不是「为什么不对」而是「哪一行和预期不一样」。希望帮到你。本文还有配套的精品资源点击获取
返回列表