☰
手写编译器前端:从词法分析到四元式生成
2026/10/2 6:50:11 网站建设 项目流程

简介:本资源是一份面向计算机专业本科生与编译原理初学者的课程设计实践报告,聚焦编译器前端核心模块的完整实现,解决词法分析、语法分析与中间代码生成等关键问题。报告详细阐述了基于递归下降子程序法的语法语义一体化分析设计,涵盖Token生成机制、动态加载关键字/界符表、四元式中间代码生成,以及常量、数组、if-else、while等文法扩展方案,并附有递归子程序栈跟踪等调试设计亮点。资源为1个381KB的DOCX文档,内容结构完整,含摘要、任务要求、算法设计(含状态转换图)、模块实现说明、实验结果及收获体会,目录清晰便于分段研读。目前已有693人学习下载,适合课程设计参考、编译原理实验复现与系统级编程能力提升。

1. 一个能跑通的编译器前端:从program id; begin end.到四元式生成,不依赖任何 IDE 插件或黑盒框架

这不是一个“理论正确但跑不起来”的教学 demo,而是一份 2014 年本科生用纯 C++(少量 C 风格)手撸、带完整词法状态机、递归下降子程序栈可视化、支持con x: 10;常量定义和arr a[5] of integer;数组声明的真实可执行前端。它不调用 Flex/Bison,不依赖 LLVM 或 ANTLR,所有 Token 识别规则、关键字/界符映射、文法产生式都以文本文件驱动——这意味着你改一行.txt就能加新关键字,换一张状态转换表就能支持新字面量格式。它解决的不是“编译原理考什么”,而是“我怎么让一段if a > b then c := 1 else c := 0;真正变成(=, c, 1, _)和(=, c, 0, _)四元式,并在控制台逐帧打印出当前递归子程序调用栈深度、已读 Token 位置、正在匹配的非终结符”。适合想亲手拆解“语法分析器怎么知道a := b + c * d要先算乘法”的人,也适合需要快速验证自定义文法是否可被递归下降解析的嵌入式 DSL 开发者。如果你正卡在“写完词法分析器却不知道下一步怎么喂 Token 给语法分析器”,或者被教科书里抽象的ParseExp()函数绕晕,这份资源就是你的调试沙盒。


2. 词法分析器:状态机驱动的扫描器,Token 表与界符表全外置化

2.1 为什么不用正则引擎?手写状态机才是可控的起点

很多初学者一上来就想用 Python 的re模块或 Lex 工具生成词法分析器,结果发现错误定位难、状态跳转不可视、扩展新 token(比如支持十六进制整数0xFF)时要重写整个正则表达式。本设计采用显式状态转移表(state_transition.txt),每个状态对应一个整数编号,每行定义“当前状态 + 输入字符 → 下一状态 + 是否输出 Token”。例如识别整数常量的状态链:
1 d → 2(读到数字进入状态2)
2 d → 2(继续读数字,保持状态2)
2 . → 3(遇到小数点,转入浮点数处理)
2 # → output TOKEN_INT(遇到分隔符,输出整数 Token 并回退)
这种设计让调试变得直观:你在控制台打印current_state = 2, next_char = '5',就能立刻查表确认下一步该去哪。更重要的是,所有状态转移逻辑集中在单个scan()函数内,没有隐式回调或状态闭包,新手跟断点不会丢上下文。

2.2 关键数据结构:Token 类与外置配置文件

Token 不是简单字符串,而是带类型编码、行号、列号的结构体:

struct Token { int type; // 来自 keywords.txt 或 delimiters.txt 的编码 string value; // 原始字面量,如 "while" 或 "123" int line; // 在源文件中的行号(用于报错) int col; // 列号 };

所有关键字(program,var,if...)和界符(:=,+,[...)均存于外部文本文件:

  • keywords.txt:每行单词 编码,如program 0
  • delimiters.txt:每行界符 编码,如:= 4
  • state_transition.txt:每行当前状态 字符 下一状态 是否输出,如1 d 2 0

提示:state_transition.txt中的#表示“任意非字母数字字符”,d表示数字,l表示字母,b表示界符。这种符号约定让状态表紧凑可读,避免为每个 ASCII 字符单独写一行。

2.3 扫描器核心逻辑:字符流 → Token 流

主扫描循环如下(简化版,实际含行号计数和错误处理):

Token scan() { int state = 1; // 初始状态 string buffer; while (true) { char c = get_next_char(); // 从输入流读一个字符 if (c == EOF) break; // 根据 c 查状态转移表,得到 next_state 和 should_emit int next_state = get_next_state(state, c); bool should_emit = is_emit_state(next_state); if (should_emit && !buffer.empty()) { // 当前 buffer 已构成完整单词,查表生成 Token Token t = lookup_token(buffer); if (t.type == -1) t.type = TOKEN_IDENTIFIER; // 未命中关键字则为标识符 t.line = current_line; t.col = current_col - buffer.length(); return t; } else if (next_state == 0) { // 无匹配转移,说明非法字符 error("illegal character: " + string(1, c)); return Token{-1, "", 0, 0}; } else { buffer += c; state = next_state; } } return Token{TOKEN_EOF, "", 0, 0}; }

关键参数说明:

  • get_next_char():封装了换行计数(current_line++当读到\n),确保报错时能准确定位;
  • lookup_token(buffer):先查keywords.txt,再查delimiters.txt,最后默认为TOKEN_IDENTIFIER;
  • is_emit_state():判断该状态是否为“终态”(如状态2对整数、状态7对标识符),终态才触发 Token 输出;
  • buffer在每次成功转移后追加字符,失败时清空——这是手写扫描器最易错的点:忘记在非法字符处清空 buffer,会导致下一个合法单词被前缀污染。

3. 递归下降语法分析器:文法即代码,每个非终结符对应一个函数

3.1 文法到函数的直接映射:为什么PROGRAM → program id SUB_PROGRAM就是parseProgram()

本设计采用“文法产生式 = C++ 函数”一对一映射。查看报告中给出的文法:

PROGRAM → program id SUB_PROGRAM SUB_PROGRAM → VARIABLE COM_SENTENCE VARIABLE → var ID_SEQUENCE : TYPE ;

对应函数签名如下:

void parseProgram(); // 匹配 "program id ..." void parseSubProgram(); // 匹配 "var ... ; begin ... end" void parseVariable(); // 匹配 "var id, id : integer ;" void parseIdSequence(); // 匹配 "id, id, id" void parseType(); // 匹配 "integer | real | char | bool"

每个函数职责明确:

  • 读取预期 Token(如parseProgram()先 expectTOKEN_PROGRAM);
  • 调用子函数(如parseProgram()调用parseSubProgram());
  • 在语义动作点生成四元式(如parseEvaSentence()中遇到:=时调用genQuadruple());
  • 维护递归子程序栈(callStack.push("parseProgram"))。

这种设计让文法修改极其直接:想加for循环?只需在文法中加一条FOR_LOOP → for ( EXPRESSION ; EXPRESSION ; EXPRESSION ) COM_SENTENCE,然后写parseForLoop()函数,再在COM_SENTENCE的选择逻辑中加入对该函数的调用分支。

3.2 语义动作嵌入:四元式生成时机与栈管理

四元式不是等语法分析完再统一生成,而是在语法树下降过程中实时构建。例如赋值语句id := EXPRESSION的语义动作:

void parseEvaSentence() { Token idToken = expect(TOKEN_IDENTIFIER); // 读取左值 id expect(TOKEN_ASSIGN); // 读取 := string expAddr = parseExpression(); // 解析右值,返回地址(临时变量名) // 生成四元式:(:=, expAddr, _, idToken.value) genQuadruple("=", expAddr, "", idToken.value); }

genQuadruple()内部维护一个全局四元式列表quads,每条记录为struct Quad { string op; string arg1; string arg2; string result; };。关键细节:

  • expAddr是parseExpression()返回的“计算结果存放地址”,可能是临时变量名(如t1)、常量(如10)或标识符(如a);
  • result字段填入左值idToken.value,即目标存储位置;
  • arg2为空字符串表示单目运算(如=是双目,但此处arg2无意义,填"");
  • 所有地址命名由newTemp()函数生成,保证唯一性(t1,t2, ...)。

递归子程序栈通过vector<string> callStack实现,在每个parseXXX()函数开头push,结尾pop,并在关键节点(如parseExpression()进入/退出)打印栈内容,实现报告中要求的“某一时刻递归子程序栈情况”。

3.3 文法扩展的落地方式:常量、数组、if-else 的语法糖处理

扩展不是硬编码 if 判断,而是将新文法融入原有递归结构:

  • 常量定义con x: 10;:在VARIABLE产生式中增加分支| con ID_SEQUENCE : cons ;,parseVariable()中检测到TOKEN_CON后调用parseConDeclaration(),该函数生成const x = 10的符号表条目,并跳过后续类型检查;
  • 数组声明arr a[5] of integer;:新增ARRAY_DECL → arr id [ cons ] of TYPE ;,parseArrayDecl()解析维度常量5,生成符号表中a的类型为array[5] of integer,并为数组访问生成特殊四元式(@, a, t1, t2)(t1为下标,t2为基址);
  • if-else:IF_STMT → if ( EXPRESSION ) COM_SENTENCE [ else COM_SENTENCE ],parseIfStmt()在EXPRESSION后生成条件跳转四元式if t1 goto L1 else goto L2,并管理标签L1,L2的分配与回填。

注意:of关键字在数组文法中是硬编码的,parseArrayDecl()必须 expectTOKEN_OF,这与keywords.txt中of 13的编码严格对应——文法扩展必须同步更新关键字表,否则扫描器根本认不出of。


4. 避坑:五个让调试时间翻倍的典型问题与血泪解法

4.1 现象:扫描器卡死在while (true)循环,CPU 占用 100%

原因:get_next_char()在文件末尾未返回EOF,而是反复返回'\0'或-1,导致状态机永远找不到终态,buffer不断追加空字符。
解决:在get_next_char()中严格判断文件结束:

char get_next_char() { if (feof(input_file)) return EOF; // 必须用 feof(),不能只靠 fgetc() == EOF int c = fgetc(input_file); if (c == EOF) return EOF; if (c == '\n') { current_line++; current_col = 1; } else current_col++; return (char)c; }

4.2 现象:parseExpression()解析a + b * c时生成(+, a, b, t1)然后(+, t1, c, t2),乘法没优先算

原因:文法EXPRESSION → EXPRESSION + TERM | TERM和TERM → TERM * FACTOR | FACTOR本身已体现左递归和优先级,但parseExpression()函数未按此结构实现,而是写成parseExpression() { parseTerm(); while (next == '+') { ... } },漏掉了EXPRESSION + TERM的递归调用。
解决:严格按文法写两层函数:

string parseExpression() { string left = parseTerm(); // 先算乘除 while (lookahead.type == TOKEN_PLUS || lookahead.type == TOKEN_MINUS) { Token op = consume(); // 消费 + 或 - string right = parseTerm(); // 再算右侧的乘除 string temp = newTemp(); genQuadruple(op.value, left, right, temp); left = temp; } return left; }

4.3 现象:if a > b then c := 1;报错 “expect then, got c”

原因:then是关键字,但keywords.txt中未添加then 21,扫描器将其识别为TOKEN_IDENTIFIER,而parseIfStmt()的expect(TOKEN_THEN)失败。
解决:所有新关键字必须同时出现在keywords.txt和文法产生式中。检查keywords.txt是否有then 21,并在IF_STMT文法中明确写出if ( EXPRESSION ) then COM_SENTENCE。

4.4 现象:数组访问a[i]生成四元式(@, a, i, t1),但后续t1 := 5赋值时报错 “undefined symbol t1”

原因:@操作符生成的临时变量t1未注册到符号表,parseAssignment()在检查左值合法性时发现t1未声明。
解决:在genQuadruple()中,若result字段为新临时变量(以t开头),需调用insertSymbol(result, "temp", "integer")将其加入符号表,类型设为"temp"以区别于用户声明变量。

4.5 现象:递归子程序栈打印显示parseProgram → parseSubProgram → parseVariable → parseVariable,第二层parseVariable无限递归

原因:VARIABLE文法存在左递归VARIABLE → var ID_SEQUENCE : TYPE ; VARIABLE,但parseVariable()函数未用循环替代递归,而是直接调用自身,导致栈溢出。
解决:将左递归文法改写为右递归或使用循环:

void parseVariable() { while (lookahead.type == TOKEN_VAR || lookahead.type == TOKEN_CON || lookahead.type == TOKEN_ARR) { if (lookahead.type == TOKEN_VAR) { parseVarDeclaration(); } else if (lookahead.type == TOKEN_CON) { parseConDeclaration(); } else if (lookahead.type == TOKEN_ARR) { parseArrayDeclaration(); } } }

5. 四元式中间代码生成:从抽象语法树到可执行指令的桥梁

5.1 四元式设计原则:操作符、双操作数、结果地址、三地址代码本质

本设计采用标准三地址代码(Three-Address Code)的四元式表示:(op, arg1, arg2, result)。与之对比,三元式(op, arg1, arg2)无法直接支持优化(如公共子表达式删除),而间接三元式引入额外指针开销。四元式平衡了可读性与优化空间:

  • op:字符串操作符,如"+","=","jnz"(条件跳转);
  • arg1,arg2:操作数,可以是标识符(a)、常量(10)、临时变量(t1)或空("");
  • result:结果存放地址,必为标识符或临时变量,绝不为常量(10不能作为左值)。

例如while a < b do c := c + 1;编译后生成:

(<, a, b, t1) // t1 = a < b (jnz, t1, L1, _) // if t1 != 0 goto L1 (jmp, _, _, L2) // goto L2 (L1:) // 标签 L1 (+, c, 1, t2) // t2 = c + 1 (=, t2, _, c) // c = t2 (jmp, _, _, L0) // goto L0(回到 while 条件) (L2:) // 标签 L2

其中L0,L1,L2由newLabel()生成,jmp和jnz的arg2字段存放目标标签。

5.2 符号表与地址分配:变量生命周期与临时变量管理

符号表SymbolTable是map<string, SymbolEntry>,SymbolEntry包含:

struct SymbolEntry { string name; string type; // "integer", "real", "array[5] of integer", "temp" int offset; // 相对于栈帧基址的偏移(后端用) bool isConst; // true for 'con' declarations };

关键策略:

  • 用户变量:offset从-4开始递减(模拟栈向下增长),int a占 4 字节,arr b[10]占10*4=40字节;
  • 临时变量:t1,t2... 不分配栈偏移,仅在四元式中作为占位符,后端生成目标代码时替换为寄存器(如eax);
  • 常量:isConst=true,后端可将其直接嵌入指令(如mov eax, 10),无需内存分配。

parseVariable()中插入符号的逻辑:

void insertVariable(const vector<string>& ids, const string& type) { for (string id : ids) { if (symbolTable.find(id) != symbolTable.end()) { error("duplicate declaration: " + id); return; } SymbolEntry se{id, type, nextOffset, false}; symbolTable[id] = se; nextOffset -= getByteSize(type); // integer→4, real→8, array[n]→n*4 } }

5.3 四元式优化接口:为后端预留的钩子

虽然本课程设计未实现优化,但四元式结构天然支持:

  • 常量折叠:扫描所有(op, const1, const2, t),计算const1 op const2,替换为(=, result, _, t);
  • 公共子表达式消除:建立map<string, string>记录"(+, a, b)" → "t1",后续再出现相同表达式时复用t1;
  • 无用代码删除:遍历四元式,标记被result引用的arg1/arg2,未被引用的t变量对应四元式可删。

这些优化在optimizeQuads()函数中实现,调用时机在parseSubProgram()结束后、目标代码生成前。即使不实现优化,保留此接口能让后端开发者清晰看到“这里可以插优化模块”。


6. 实战技巧:用递归子程序栈反向定位语法错误,以及文法可分析性自查清单

6.1 用栈帧快照诊断“Unexpected token”类错误

当语法分析器报错unexpected token ';'时,传统做法是看报错行,但真正的问题往往在上文。本设计的递归子程序栈打印(printCallStack())是终极调试武器。例如:

[CALL STACK] parseProgram → parseSubProgram → parseVariable → parseIdSequence CURRENT TOKEN: ';' (line 3, col 15) EXPECTED: ',' or ':'

这说明parseIdSequence()正在期待逗号或冒号来分隔多个标识符(如var a, b, c : integer;),但遇到了分号。此时立刻检查:

  • 源代码第3行是否写了var a b c : integer;(漏了逗号)?
  • keywords.txt是否把b误设为关键字,导致扫描器输出TOKEN_BOOL而非TOKEN_IDENTIFIER?
  • parseIdSequence()的循环逻辑是否在读到第二个id后未正确处理','分隔符?

从那以后我每次遇到unexpected token,第一反应不是改源码,而是加一行printCallStack()在报错前,看栈顶函数在等什么。90% 的语法错误都能在栈帧里找到线索——因为递归下降的本质是“当前函数负责匹配某段文法”,栈顶函数名就是文法上下文。

6.2 文法可分析性自查:五条铁律过滤不可用文法

不是所有文法都适合递归下降。在扩展文法(如加for、switch)前,必须通过以下检查:

检查项合格标准违例示例修复方案
无左递归A → Aα形式必须消除EXP → EXP + TERM改为EXP → TERM EXP',EXP' → + TERM EXP' | ε
无公共前缀同一非终结符的多个产生式首符集不相交STMT → if (...) STMT | if (...) STMT else STMT提取公因式:STMT → if (...) STMT STMT',STMT' → else STMT | ε
FIRST/FOLLOW 无冲突对A → α | β,FIRST(α) ∩ FIRST(β) = ∅;若α ⇒* ε,则FIRST(β) ∩ FOLLOW(A) = ∅DECL → TYPE ID ; | TYPE ID [ NUM ] ;(TYPE可能为空)显式要求TYPE非空,或为TYPE定义FIRST集合(integer,real,char...)
终结符唯一性所有关键字、界符在keywords.txt/delimiters.txt中编码唯一=和:=编码同为 4=设为 25,:=保留 4
状态机覆盖性state_transition.txt必须定义所有状态对所有可能输入字符的转移状态2对e无定义,但源码含1e5为浮点数状态添加2 e 8 0(转入科学计数法状态)

6.3 一份能立即验证的测试用例模板

不要等写完全部再测试。从最简program test; begin end.开始,逐步增加复杂度:

// test0.pas:基础框架 program test; begin end. // test1.pas:变量与赋值 program test; var a, b: integer; begin a := 10; b := a + 5; end. // test2.pas:常量与数组 program test; con MAX: 10; arr data[MAX] of integer; begin data[0] := 1; end. // test3.pas:控制流 program test; var x: integer; begin x := 0; while x < 5 do begin x := x + 1; end; end.

运行时观察:

  • test0.pas应生成空四元式列表,栈深度最大为 3(parseProgram→parseSubProgram→parseComSentence);
  • test1.pas应有 2 条=四元式,test2.pas应出现@操作符;
  • test3.pas必须有jnz和jmp,且标签L0,L1成对出现。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询