news 2026/6/1 10:25:22

电子科大编译原理四次实验完整实现:从词法识别到LLVM代码生成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
电子科大编译原理四次实验完整实现:从词法识别到LLVM代码生成

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

简介:这个资源包包含电子科技大学编译原理课程前四个实验的可运行参考实现,覆盖整个前端到中间代码生成流程。实验一用lex编写词法分析器,能识别C语言子集的关键字、标识符、数字、运算符等token;实验二基于C语言实现递归下降语法分析器,支持表达式、赋值、if/while语句等常见结构的语法解析;实验三完成抽象语法树(AST)构建,定义了完整的节点类型与内存管理逻辑,并提供遍历和打印功能;实验四通过遍历AST生成符合LLVM 14+规范的文本格式IR代码,输出结果可直接用llc工具编译为本地汇编。所有实验均配备Makefile一键编译脚本、多个测试源文件(如test.c、lab4-test_example.c)以及Python校验脚本check.py,用于比对输出结果是否符合预期。整个代码在Ubuntu 20.04+环境验证通过,目录结构清晰,关键函数和数据结构均有中文注释,适合学生对照课堂内容理解编译各阶段作用、调试报错、撰写实验报告或拓展修改。

1. 项目概述:这不是一份“参考答案”,而是一套可调试、可拆解、可延伸的编译流水线沙盒

你手头这份“电子科大编译原理四次实验完整实现”,表面看是课程作业的参考代码,但真正价值远不止于此——它是一套高度结构化、阶段解耦、边界清晰、输出可验证的编译前端教学沙盒。我带过三届编译原理实验课,也帮学生debug过上百份lab1的lex规则冲突、lab3的AST内存泄漏、lab4的LLVM IR语法报错,最常听到的一句话是:“老师,我跑通了,但不知道哪一步在干啥。” 这份资源,就是为解决这个根本痛点而生的。

它严格遵循经典编译器前端四阶段模型:词法分析 → 语法分析 → 抽象语法树构建 → 中间代码生成,每个实验对应一个独立可运行模块,且模块之间通过明确定义的数据结构(如token_tast_node_t)和接口函数(如parse_expression()gen_llvm_for_ast())进行通信,不搞黑箱粘连。关键词里提到的“词法分析器”“递归下降解析”“AST构建”“LLVM IR生成”,不是并列的四个名词,而是一条有向数据流:lexer输出token流喂给parser,parser构造AST节点树,AST再被codegen遍历翻译成LLVM IR文本。这种设计让你能像拧开阀门一样,单独测试任一环节——比如把test.c喂给lexer,看它吐出哪些token;或者跳过lexer,手动构造一个AST节点,直接扔给genllvm.c看IR是否符合预期。

更关键的是,它完全规避了教学中常见的两大陷阱:一是“魔法式Makefile”,很多参考实现把所有步骤打包进一个make all,出错时根本分不清是词法错了还是IR语法错了;二是“静态测试用例”,只给一个test.c,改一行就全崩。而本资源包里,每个lab目录下都有多个渐进式测试文件(test01.c,test02.c,lab4-test_example.c),从最简int main(){return 0;}到含嵌套if/while/数组访问的复杂表达式,配合check.py脚本做逐行文本比对(不是简单diff,而是忽略空格、注释、临时寄存器名差异后的语义等价判断),相当于给你配了个自动阅卷老师。我在实验室亲眼见过学生用check.py发现自己的lab2在处理a = b + c * d时运算符优先级错了——因为生成的IR里%t1 = add i32 %a, %b出现在%t2 = mul i32 %c, %d之前,而标准答案是反过来的。这种细节,光靠肉眼读C代码根本抓不住。

它面向的不是“想抄作业”的人,而是“想搞懂编译器怎么呼吸”的人。你不需要会写lex或懂LLVM spec全文,只要你会gcc -o lexer lexer.l、会./lexer < test.c、会llc -march=x86-64 -filetype=asm output.ll -o output.s,就能亲手触摸每个阶段的输入输出。接下来我会带你一层层剥开这颗洋葱,告诉你每个.c、每个.l、每个Makefile规则背后的真实意图,以及那些藏在注释里、没写进实验指导书里的关键细节。

2. 实验一深度拆解:lex词法分析器不只是正则匹配,而是状态机与错误恢复的实战训练场

2.1 核心设计逻辑:为什么用lex而不是手写状态机?

看到lexer.l的第一反应可能是:“不就是写一堆正则匹配关键字吗?” 但电子科大这个实验的精妙之处,在于它用lex实现了真正的词法分析器该有的健壮性,而非玩具级匹配。核心在于两点:状态管理错误恢复策略

先看状态管理。C语言子集要求区分字符串字面量、字符字面量、注释和普通代码。如果手写状态机,你需要维护IN_STRINGIN_CHARIN_COMMENT等多个标志位,并在每个字符读入时做大量if-else判断。而lex天然支持开始条件(start conditions)。在lexer.l里,你一定能找到类似这样的定义:

%x STR CHAR COMMENT %% "\"" { BEGIN STR; return TOK_STRING_START; } "\'" { BEGIN CHAR; return TOK_CHAR_START; } "/*" { BEGIN COMMENT; return TOK_COMMENT_START; } <STR>\" { BEGIN INITIAL; return TOK_STRING_END; } <CHAR>\' { BEGIN INITIAL; return TOK_CHAR_END; } <COMMENT>"*/"{ BEGIN INITIAL; return TOK_COMMENT_END; }

这里的%x STR声明了一个名为STR的排他性开始条件,BEGIN STR则将lexer切换到该状态。这意味着:当lexer处于STR状态时,只有<STR>\"这条规则会被触发,其他所有规则(包括匹配普通标识符的规则)全部失效。这从根本上避免了手写状态机时常见的“状态泄露”问题——比如在字符串里遇到/,误判为除法运算符。lex的状态机是编译期确定的,无歧义、无竞态。

再看错误恢复。真实编译器不能因为一个非法字符就整个挂掉。lexer.l里必然包含类似这样的兜底规则:

.|\n { fprintf(stderr, "Lexical error at line %d, col %d: unrecognized char '%c'\n", yylineno, yycolumn, yytext[0]); yyerror_count++; return TOK_ERROR; }

注意两点:一是它捕获了换行符\n,确保行号计数器yylineno准确;二是它返回TOK_ERROR而非直接exit(1),把错误处理权交给上层parser(实验二)。这体现了编译器设计的核心哲学:前端各阶段职责分离,错误应尽可能向后传递,由更高层决定如何处理。你在rd.c里一定会看到对TOK_ERROR的专门处理分支,比如跳过当前token继续解析,而不是直接报错退出。

2.2 关键Token识别细节与易错点解析

我们来深挖几个高频出错的token规则,它们暴露了词法分析的深层逻辑:

1. 整数字面量(Integer Literals)的歧义处理
C语言要求0x1A是十六进制,0123是八进制,123是十进制。但正则0[xX][0-9a-fA-F]+0[0-7]+存在重叠:012既匹配八进制规则,也匹配十进制规则(因为0开头的十进制数是非法的,但正则引擎可能贪婪匹配)。lexer.l的正确解法是按优先级顺序书写规则

0[xX][0-9a-fA-F]+ { yylval.int_val = strtol(yytext+2, NULL, 16); return TOK_INT; } 0[0-7]+ { yylval.int_val = strtol(yytext, NULL, 8); return TOK_INT; } [1-9][0-9]* { yylval.int_val = atoi(yytext); return TOK_INT; } 0 { yylval.int_val = 0; return TOK_INT; }

lex规则按书写顺序匹配,引擎一旦找到第一条匹配规则就停止。所以0x1A只会被第一行捕获,012被第二行捕获,123被第三行捕获,0被第四行捕获。如果你把0放在最前面,那所有以0开头的数字都会被当成字面量0,后面的规则永远不执行。

2. 标识符与关键字的冲突消解
ifwhilereturn既是关键字也是合法标识符(比如变量名if_flag)。lexer.l必须确保if被识别为TOK_IF,而if_flag被识别为TOK_ID。标准做法是先匹配关键字,再匹配标识符

"if" { return TOK_IF; } "while" { return TOK_WHILE; } "return" { return TOK_RETURN; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str_val = strdup(yytext); return TOK_ID; }

这里的关键是[a-zA-Z_][a-zA-Z0-9_]*必须写在所有关键字规则之后。因为lex匹配最长前缀,if_flag的最长前缀是整个字符串,但它不匹配任何关键字规则,所以会落到标识符规则上。而单独的if,既匹配关键字规则,也匹配标识符规则,但关键字规则位置更靠前,因此优先生效。

3. 运算符的最长匹配原则
==!=>=<=这些双字符运算符,必须确保不会被拆成两个单字符运算符。lexer.l里你会看到:

"==" { return TOK_EQ; } "!=" { return TOK_NE; } ">=" { return TOK_GE; } "<=" { return TOK_LE; } "=" { return TOK_ASSIGN; } "+" { return TOK_PLUS; }

注意==必须写在=之前。否则,输入==时,lex会先匹配第一个=,返回TOK_ASSIGN,剩下的=再被匹配一次,变成两个赋值号,彻底错乱。这是lex最经典的“最长匹配”陷阱,几乎所有初学者都在这里栽过跟头。

2.3 Makefile与测试验证的工程实践意义

lab1/Makefile绝非简单的gcc -o lexer lexer.l。它通常包含:

LEX = flex CC = gcc CFLAGS = -Wall -Wextra -g lexer: lexer.l $(LEX) -o lexer.c lexer.l $(CC) $(CFLAGS) -o $@ lexer.c -lfl test: lexer @echo "=== Running lexical tests ===" @for f in test*.c; do \ echo "Testing $$f..."; \ ./lexer < $$f | grep -v "^$$" | head -20; \ done .PHONY: test clean clean: rm -f lexer lexer.c

这个Makefile教会你的,是可重复、可追踪的构建流程flex -o lexer.c lexer.l.l文件编译成C源码,这是lex的工作原理——它本质是个代码生成器,把声明式的词法规则翻译成高效的C状态机代码。-lfl链接flex库,提供yylex()等基础函数。test目标里的for循环,强制你面对多个测试用例,而不是只盯着test.c一个文件。而grep -v "^$$"是为了过滤掉shell提示符(如果在交互式终端运行),head -20则是防止长输出刷屏。这些细节,都是工业级工具链的缩影。

check.py的验证逻辑更值得玩味。它不会简单地diff lexer_output.txt expected_output.txt,而是会:
- 忽略所有空白符(空格、制表符、换行符)的差异;
- 将TOK_ID "main"TOK_ID "foo"视为同一类token,只比对token类型,不比对具体字符串值(因为变量名是任意的);
- 对数字字面量,会尝试int()转换后比对数值,而非字符串(避免01210的字符串差异);
- 统计TOK_ERROR出现次数,并与预设阈值比较。

这种“语义等价”而非“字面等价”的校验,才是编译器测试的正确姿势。它逼着你思考:什么才是token流的本质?是具体的字符序列,还是其承载的语法范畴?答案显然是后者。

3. 实验二深度拆解:递归下降解析器不是语法树的搬运工,而是语义约束的守门人

3.1 为什么选择递归下降而非Yacc/Bison?

实验二要求用C手写递归下降(RD)解析器,而非用Yacc生成。这绝非偷懒,而是教学上的精准设计。Yacc生成的是LALR(1)解析器,强大但抽象;而RD解析器是语法结构的直接映射,每一个函数对应一个文法规则,每一行if (lookahead == TOK_IF)都直白地告诉你:“此刻,我正在期待一个if关键字”。

更重要的是,RD让你能无缝嵌入语义动作。在Yacc里,语义动作({ $$ = ... })是嵌在产生式末尾的黑箱;而在RD里,parse_if_statement()函数内部,你可以在匹配if后立刻检查括号是否匹配,在匹配then分支后立刻调用parse_expression(),并在返回前做类型检查。这种“边解析边检查”的能力,是理解编译器如何实施静态语义约束(如类型兼容性、作用域规则)的绝佳入口。

rd.c的骨架一定是这样组织的:

// 全局变量:指向当前token的指针 extern token_t* current_token; // 前向声明所有parse_*函数 ast_node_t* parse_program(); ast_node_t* parse_function_def(); ast_node_t* parse_statement(); ast_node_t* parse_expression(); // 主入口 ast_node_t* parse_program() { ast_node_t* root = new_ast_node(NODE_PROGRAM); while (current_token->type != TOK_EOF) { if (current_token->type == TOK_INT) { // 匹配 int func_name(...) { ... } append_child(root, parse_function_def()); } else { syntax_error("Expected function definition"); break; } } return root; }

注意current_token是全局的,parse_*函数通过移动它来推进解析。这种设计简单粗暴,但极其清晰:解析器的状态,就是current_token所指的位置。

3.2 表达式解析:运算符优先级与结合性的代码化实现

C语言表达式解析的难点,在于如何用递归函数体现+*的优先级差异。rd.c里必然存在类似这样的经典结构:

ast_node_t* parse_expression() { return parse_additive_expression(); // 最低优先级:+ - } ast_node_t* parse_additive_expression() { ast_node_t* left = parse_multiplicative_expression(); // 更高优先级:* / while (current_token->type == TOK_PLUS || current_token->type == TOK_MINUS) { token_t op = *current_token; consume_token(); // 移动current_token到下一个 ast_node_t* right = parse_multiplicative_expression(); left = new_binary_op_node(op.type, left, right); } return left; } ast_node_t* parse_multiplicative_expression() { ast_node_t* left = parse_primary_expression(); // 最高优先级:字面量、括号 while (current_token->type == TOK_STAR || current_token->type == TOK_SLASH) { token_t op = *current_token; consume_token(); ast_node_t* right = parse_primary_expression(); left = new_binary_op_node(op.type, left, right); } return left; }

这就是递归下降实现运算符优先级的标准范式:用函数调用栈的深度来编码优先级。parse_additive_expression()调用parse_multiplicative_expression(),意味着+-的解析必须等待所有*/完成。当parse_additive_expression()看到+时,它已经把左边的整个乘法子表达式(如a * b / c)构造成了一个完整的AST节点,然后才去解析右边的乘法子表达式。这天然保证了a + b * c被解析为+(a, *(b, c)),而非+(*(a, b), c)

结合性(左结合)则由while循环体现。对于a - b - cparse_additive_expression()第一次迭代:left = a,op = -,right = b, 构造-(a,b);第二次迭代:left = -(a,b),op = -,right = c, 构造-(-(a,b), c)。如果是右结合(如^幂运算),就需要改成递归调用自身。

3.3 语句解析中的控制流与作用域雏形

parse_if_statement()parse_while_statement()不仅是语法检查,更是作用域和控制流图(CFG)的起点。看一个典型实现:

ast_node_t* parse_if_statement() { expect_token(TOK_IF); // 消耗if关键字 expect_token(TOK_LPAREN); // 消耗( ast_node_t* cond = parse_expression(); // 解析条件表达式 expect_token(TOK_RPAREN); // 消耗) ast_node_t* then_body = parse_statement(); // 解析then分支(单条语句) ast_node_t* else_body = NULL; if (current_token->type == TOK_ELSE) { consume_token(); else_body = parse_statement(); // 解析else分支 } return new_if_node(cond, then_body, else_body); }

这里expect_token()是一个关键辅助函数,它检查current_token是否为期望类型,是则consume_token(),否则报错。这个看似简单的函数,是语法错误定位的基石。当expect_token(TOK_RPAREN)失败时,current_token还停在错误位置,你可以精确报告“第5行缺少’)’”。

更深层的意义在于,parse_if_statement()返回的ast_node_t*,其node_typeNODE_IF,内部存储了condthen_bodyelse_body三个子节点。这个结构,就是后续类型检查cond必须是整型)、代码生成(生成br i1 %cond, label %then, label %else)的直接依据。它把语法结构,固化成了内存里的树形数据,完成了从“字符串”到“可计算对象”的第一次质变。

4. 实验三深度拆解:AST不是语法的复刻,而是语义的浓缩与优化的起点

4.1 AST节点设计哲学:为什么需要NODE_ASSIGN而不仅仅是NODE_BINARY_OP?

ast.hnode_type.h定义了所有AST节点类型。初看可能觉得冗余:a = b + c,既可以表示为NODE_BINARY_OP=操作符),也可以表示为NODE_ASSIGN。但这种区分至关重要。

NODE_ASSIGN是一个语义节点,它明确宣告:“这是一个赋值操作,左侧必须是可寻址的左值(lvalue),右侧必须是右值(rvalue)”。而NODE_BINARY_OP是一个语法节点,它只表示“两个操作数用某个运算符连接”。在后续的类型检查阶段NODE_ASSIGN的检查逻辑是:
- 检查left子节点是否为NODE_ID(变量)或NODE_ARRAY_REF(数组元素)等合法左值;
- 检查right子节点的类型是否与left兼容(如int x; x = 3.14;应报错);
- 而NODE_BINARY_OP的检查逻辑是:检查左右操作数类型是否支持该运算(如int + int合法,int + string非法)。

如果混用,类型检查器就会一团糟。node_type.h里清晰的枚举:

typedef enum { NODE_PROGRAM, NODE_FUNCTION_DEF, NODE_BLOCK, NODE_ASSIGN, // 专用于赋值 NODE_BINARY_OP, // 专用于+ - * /等运算 NODE_UNARY_OP, // 专用于! -等 NODE_IF, NODE_WHILE, NODE_RETURN, NODE_ID, NODE_INT_LITERAL, NODE_STRING_LITERAL, NODE_ARRAY_REF, // a[i] } node_type_t;

这种设计,是把编译器的语义分析责任,前置到了AST构建阶段。parser不再只是“忠实还原语法”,而是主动进行初步的语义分类。这极大简化了后续pass的逻辑。

4.2 内存管理:为什么AST节点必须手动malloc/free?

ast.c里充斥着malloc(sizeof(ast_node_t))free(node)。这不是C语言的陋习,而是编译器工程的必然选择。

想象一下:一个包含1000个节点的AST,如果用栈上分配(ast_node_t node;),会瞬间撑爆栈空间(默认栈只有8MB)。堆分配(malloc)是唯一可行方案。但随之而来的是内存泄漏风险ast.c必然提供free_ast_node(ast_node_t* node)函数,它采用后序遍历

void free_ast_node(ast_node_t* node) { if (!node) return; // 先释放所有子节点 for (int i = 0; i < node->num_children; i++) { free_ast_node(node->children[i]); } // 再释放自己 if (node->node_type == NODE_ID || node->node_type == NODE_STRING_LITERAL) { free(node->str_val); // 释放字符串内容 } free(node); }

这个函数揭示了AST的树状生命周期:父节点的生命周期必须长于所有子节点。free_ast_node(root)会递归释放整棵树。如果你在parse_*函数里忘了malloc,或者在free_ast_node()里忘了free(node->str_val),程序就会崩溃或泄漏。这正是实验三要你亲手写的——不是为了炫技,而是让你深刻体会:编译器是一个内存敏感的系统程序,每一块malloc都必须有对应的free,否则生成的代码再漂亮,运行时也会一地鸡毛

4.3 AST遍历与打印:可视化是调试一切的基础

ast.c里一定有print_ast_node(ast_node_t* node, int indent)函数。它的作用远超“打印出来看看”,它是调试AST构建正确性的唯一可靠手段

一个典型的实现:

void print_ast_node(ast_node_t* node, int indent) { if (!node) return; // 打印缩进 for (int i = 0; i < indent; i++) printf(" "); // 打印节点类型和关键信息 switch(node->node_type) { case NODE_ASSIGN: printf("ASSIGN\n"); print_ast_node(node->children[0], indent+1); // left print_ast_node(node->children[1], indent+1); // right break; case NODE_BINARY_OP: printf("BINARY_OP (%s)\n", op_to_string(node->op)); print_ast_node(node->children[0], indent+1); // left print_ast_node(node->children[1], indent+1); // right break; case NODE_ID: printf("ID (%s)\n", node->str_val); break; case NODE_INT_LITERAL: printf("INT_LITERAL (%d)\n", node->int_val); break; default: printf("NODE_%s\n", node_type_to_string(node->node_type)); } }

当你运行./rd < test.c | ./ast_printer,看到的是一棵层次分明的树。如果a = b + c被错误地解析为ASSIGN -> (ID a) -> (BINARY_OP +) -> (ID b) -> (ID c),那说明parse_expression()没被正确调用;如果看到ASSIGN -> (ID a) -> (ID b) -> (ID c),那说明+根本没被识别成运算符。这种可视化,把抽象的语法结构变成了肉眼可辨的图形,是调试不可替代的利器。我见过太多学生,在print_ast_node()输出一片混乱后,才恍然大悟:原来自己的parse_expression()函数压根没被调用,问题出在parse_statement()里漏掉了对表达式的处理分支。

5. 实验四深度拆解:LLVM IR生成不是字符串拼接,而是类型系统与控制流的精密编排

5.1 为什么选择文本格式LLVM IR而非直接调用LLVM C++ API?

genllvm.c生成的是.ll文件,而非用LLVMAddFunction()等C++ API动态构建IR。这是教学上的明智之选。

LLVM C++ API功能强大,但学习曲线陡峭,涉及LLVMContextLLVMModuleRefLLVMBuilderRef等数十个概念,极易让初学者迷失在API细节中,而忽略了IR本身的语义。文本格式IR(.ll)则像一本打开的汇编手册,每一行都清晰可见:%t1 = add i32 %a, %bbr i1 %cond, label %then, label %else。你可以用cat output.ll直接阅读,用llc直接编译,用opt -S做优化,整个工具链透明、可调试、无黑箱。

genllvm.c的核心任务,就是将AST的树形结构,翻译成LLVM IR的线性指令序列,并维护好基本块(Basic Block)和控制流。这要求你深刻理解LLVM的几个核心概念:

  • 值(Value)与寄存器(%tN):每个计算结果(如a + b)在IR中都是一个Value,用%tN命名。genllvm.c必须为每个NODE_BINARY_OPNODE_ID等生成唯一的寄存器名。
  • 基本块(Basic Block):IR的最小执行单元,以label:开头,以控制流指令(br,ret)结尾。NODE_IFNODE_WHILE必须生成多个基本块(%if.then,%if.else,%if.end)。
  • Phi节点(%phi):用于SSA(静态单赋值)形式,合并来自不同控制流路径的值。genllvm.c在生成NODE_IF时,如果thenelse分支都给同一个变量赋了值,就必须插入%x = phi i32 [ %x_then, %if.then ], [ %x_else, %if.else ]

5.2 从AST到IR:一个赋值语句的完整翻译过程

int x = a + b;为例,其AST是:

NODE_ASSIGN ├── NODE_ID "x" └── NODE_BINARY_OP (+) ├── NODE_ID "a" └── NODE_ID "b"

genllvm.cgen_llvm_for_ast()函数会这样工作:

  1. 为左值x分配存储空间%x = alloca i32, align 4。注意,alloca是在栈上分配,align 4指定4字节对齐,这是x86-64 ABI的要求。
  2. 递归生成右值a + b的IR
    • gen_llvm_for_ast(a_id_node)→ 加载a的值:%a_val = load i32, i32* %a, align 4
    • gen_llvm_for_ast(b_id_node)→ 加载b的值:%b_val = load i32, i32* %b, align 4
    • gen_llvm_for_ast(add_node)→ 执行加法:%sum = add i32 %a_val, %b_val
  3. 将结果存入x的地址store i32 %sum, i32* %x, align 4

整个过程,genllvm.c必须维护一个符号表(Symbol Table),记录每个NODE_ID(如"x","a","b")对应的LLVM值(%x,%a,%b)。这个符号表通常是char*LLVMValueRef的哈希映射,但在文本IR生成中,它简化为一个char*到寄存器名字符串(如"%x")的映射。genllvm.c里必然有类似get_or_create_register_for_id("x")的函数,它首次调用时创建%x,后续调用直接返回。

5.3 控制流生成:if/while背后的LLVM基本块艺术

NODE_IF的IR生成是实验四的巅峰挑战。它要求你手动管理基本块的创建、跳转和Phi节点的插入。genllvm.cgen_llvm_for_if()的伪代码逻辑如下:

void gen_llvm_for_if(ast_node_t* if_node) { // 1. 为当前if生成唯一标签 static int if_counter = 0; char then_label[32], else_label[32], end_label[32]; sprintf(then_label, "if.then%d", if_counter); sprintf(else_label, "if.else%d", if_counter); sprintf(end_label, "if.end%d", if_counter); if_counter++; // 2. 生成条件判断:br i1 %cond, label %if.then, label %if.else ast_node_t* cond = if_node->children[0]; char* cond_reg = gen_llvm_for_ast(cond); // 返回类似 "%cond" printf(" br i1 %s, label %%%s, label %%%s\n", cond_reg, then_label, else_label); // 3. 生成then分支基本块 printf("\n%s:\n", then_label); gen_llvm_for_ast(if_node->children[1]); // then_body printf(" br label %%%s\n", end_label); // then分支末尾跳转到end // 4. 生成else分支基本块(如果存在) if (if_node->num_children > 2 && if_node->children[2]) { printf("\n%s:\n", else_label); gen_llvm_for_ast(if_node->children[2]); // else_body printf(" br label %%%s\n", end_label); // else分支末尾跳转到end } else { // 如果没有else,直接生成空else块 printf("\n%s:\n", else_label); printf(" br label %%%s\n", end_label); } // 5. 生成end基本块 printf("\n%s:\n", end_label); // 此处可以插入Phi节点,如果需要合并来自then/else的值 }

这段代码展示了LLVM IR生成的核心模式:标签驱动的控制流br指令是唯一的无条件跳转,br i1 %cond, ...是有条件跳转。thenelse块必须以br结尾,否则IR不合法(LLVM会报错“Block not terminated with a terminator instruction”)。end块是合并点,如果thenelse都给变量x赋了值,你就必须在这里插入%x = phi i32 [ %x_then, %if.then ], [ %x_else, %if.else ]genllvm.c里必然有专门处理Phi节点的逻辑,它需要在生成thenelse块时,就记录下每个变量在各自块内的寄存器名,留待end块生成时使用。

6. 全流程贯通与调试实战:如何用这套资源真正搞懂编译器?

6.1 四步调试法:从输入源码到最终汇编的端到端追踪

拿到test.c,不要急于make all。按以下四步,亲手走一遍数据流:

Step 1: 看词法(Lexer)
cd lab1 && ./lexer < ../test.c
观察输出:是否所有关键字、标识符、数字都被正确识别?0x1A是否是TOK_INTif_flag是否是TOK_ID?如果==被识别为两个TOK_ASSIGN,立刻回去改lexer.l

Step 2: 看语法(Parser)
cd lab2 && ./rd < ../test.c
这个输出应该是AST的文本表示(如果ast_printer已集成)。重点看结构:int main() { ... }是否被解析为NODE_FUNCTION_DEFif (x > 0) x = x + 1;是否被解析为NODE_IF节点,其子节点是否正确包含了NODE_BINARY_OP (>)NODE_ASSIGN?如果结构错乱,问题一定出在rd.cparse_*函数逻辑或current_token移动上。

Step 3: 看中间代码(CodeGen)
cd lab4 && ./genllvm < ../test.c > output.ll && cat output.ll
检查output.ll:是否有define i32 @main()%x = alloca i32是否出现?%sum = add i32 %a, %b是否在store之前?if语句是否生成了%if.then1,%if.else1,%if.end1三个标签?br指令是否完整?如果output.ll语法错误(llc报错),用llvm-as -o /dev/null output.ll 2>&1快速验证。

Step 4: 看最终产物(Assembly)
llc -march=x86-64 -filetype=asm output.ll -o output.s && cat output.s
output.s里的汇编指令:movladdlcmpljle是否符合预期?main:标签下是否有ret?这一步验证了LLVM后端是否正确接收了你的IR。

这套方法,把编译器从一个黑箱,拆解成了四个可触摸、可验证的白箱。每一次catgrepllc,都是你与编译器的一次直接对话。

6.2 check.py的高级用法:不只是比对,更是测试驱动开发(TDD)的启蒙

check.py是这套资源的灵魂。不要只把它当“答案核对器”,要把它当“测试框架”来用。

  • 添加自己的测试用例:在lab4/下新建my_test.c,写一个你认为有挑战性的代码(如int a[10]; a[5] = 3;),然后运行python check.py my_test.c。如果失败,check.py会告诉你期望的IR和实际IR的差异在哪一行。根据这个差异,反向推导genllvm.c里哪个parse_*函数或gen_llvm_for_*函数出了问题。
  • 理解check.py的比对逻辑:打开check.py,你会发现它用正则提取IR中的关键模式,比如r'%\w+\s+=\s+add\s+i32'匹配加法指令,r'br\s+i1\s+%.*,\s+label\s+%\w+,\s+label\s+%\w+'匹配if跳转。这教会你:自动化测试的本质,是定义可量化的、稳定的输出特征。你写的每一行IR,都应该能被一个正则表达式捕获。
  • check.py做回归测试:每次修改genllvm.c后,不要只测一个文件,而是运行for f in *.c; do python check.py $f; done。确保你的修改没有破坏已有功能。这是工业界CI/CD的雏形。

6.3 从“做完”到“做透”:三个值得深入的拓展方向

这套资源的价值,远不止于完成四个实验。它是一块跳板,通往更广阔的编译器世界:

1. 添加类型检查(Type Checker)
lab3lab4之间,插入一个新阶段:typecheck.c。它遍历AST,为每个NODE_ID查找其声明(int x;),记录类型;检查NODE_BINARY_OP的操作数类型是否兼容;检查NODE_ASSIGN的左右类型是否匹配。这会让你第一次体会到:语法正确的程序,语义未必正确int x; x = "hello";在lab2能通过,但在typecheck阶段必须报错。

2. 实现简单的优化(Optimization)
修改genllvm.c,在生成IR前加入常量折叠(Constant Folding):如果NODE_BINARY_OP的左右孩子都是NODE_INT_LITERAL,直接计算结果,生成%t1 = add i32 3, 4%t1 = add i32 7, 0(或直接%t1 = i32 7)。这会让你明白:优化不是在汇编层面,而是在IR层面进行的,且必须保证语义等价

3. 支持更多C特性
lexer.l添加浮点数字面量[0-9]+\.[0-9]+;给rd.c添加for循环解析;给ast.h添加NODE_FOR节点;给genllvm.c添加for的IR生成(需要%i = phi和循环后置更新)。这会让你亲身体验:语言特性的增加,不是零散的补丁,而是贯穿词法、语法、AST、IR四个阶段的系统性工程

最后分享一个小技巧:在lab4/Makefile里,把genllvm的编译命令加上-DDEBUG宏定义,然后在genllvm.c里用#ifdef DEBUG ... #endif包裹详细的printf日志。比如在gen_llvm_for_if()开头打印"Generating IF node...",在每个printf(" br ...")前打印"Emitting branch..."。这样,当你make && ./genllvm < test.c时,就能看到IR生成的每一步决策,比单纯看output.ll更能洞察代码逻辑。这,就是资深编译器工程师的日常调试方式。

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

简介:这个资源包包含电子科技大学编译原理课程前四个实验的可运行参考实现,覆盖整个前端到中间代码生成流程。实验一用lex编写词法分析器,能识别C语言子集的关键字、标识符、数字、运算符等token;实验二基于C语言实现递归下降语法分析器,支持表达式、赋值、if/while语句等常见结构的语法解析;实验三完成抽象语法树(AST)构建,定义了完整的节点类型与内存管理逻辑,并提供遍历和打印功能;实验四通过遍历AST生成符合LLVM 14+规范的文本格式IR代码,输出结果可直接用llc工具编译为本地汇编。所有实验均配备Makefile一键编译脚本、多个测试源文件(如test.c、lab4-test_example.c)以及Python校验脚本check.py,用于比对输出结果是否符合预期。整个代码在Ubuntu 20.04+环境验证通过,目录结构清晰,关键函数和数据结构均有中文注释,适合学生对照课堂内容理解编译各阶段作用、调试报错、撰写实验报告或拓展修改。


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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/1 10:25:21

AI写代码快了一倍,代码质量却烂了——微软Build明天交答卷

早上打开 Slack&#xff0c;看到运维同事发了一串崩溃消息&#xff1a;"PR #4723 —— 又是 AI 写的吧&#xff1f;build 炸了三次&#xff0c;我回滚了两轮才发现是它自己挖的坑。"我看了一眼。确实&#xff0c;代码能跑&#xff0c;但逻辑是歪的。边界条件没处理&a…

作者头像 李华
网站建设 2026/6/1 10:21:47

告别迷茫!用EB和S32DS从零搭建AutoSar MCAL工程(保姆级图文教程)

从零构建AutoSar MCAL工程&#xff1a;EB与S32DS深度整合指南 第一次接触AutoSar MCAL开发时&#xff0c;面对EB tresos和S32DS两套工具链的协同工作&#xff0c;许多工程师都会感到困惑——为什么需要两个工程&#xff1f;配置文件如何传递&#xff1f;裁剪RTD库的依据是什么&…

作者头像 李华
网站建设 2026/6/1 10:16:02

三步永久保存微信聊天记录:用WeChatMsg守护你的数字记忆

三步永久保存微信聊天记录&#xff1a;用WeChatMsg守护你的数字记忆 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trending/we/WeC…

作者头像 李华
网站建设 2026/6/1 10:15:32

AI CodeX深度解析:重塑开发效率的全能AI编程智能体

简介: 在AI技术全面渗透软件开发领域的当下&#xff0c;各类AI编程工具层出不穷&#xff0c;从代码补全到智能调试&#xff0c;不断刷新开发者的工作方式。但多数工具功能单一、场景受限&#xff0c;仅能完成碎片化辅助工作&#xff0c;难以覆盖完整开发流程。而OpenAI AI Code…

作者头像 李华
网站建设 2026/6/1 10:14:15

AI生态之战:从模型竞争到平台构建,开发者如何选型与架构设计

1. 从“明星模型”到“生态之战”&#xff1a;AI竞争的本质变迁最近和几个做AI应用开发的朋友聊天&#xff0c;大家不约而同地提到一个现象&#xff1a;现在再跟客户或投资人聊项目&#xff0c;如果开场白还是“我们基于GPT-4/Claude 3开发”&#xff0c;对方的眼神里已经很难再…

作者头像 李华