尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用C++从零实现C编译器:深入理解编译原理与系统编程

用C++从零实现C编译器:深入理解编译原理与系统编程 1. 项目概述为什么用C写C编译器用C来实现一个C语言编译器听起来像是一个“用更复杂的工具去制造一个更简单的工具”的悖论。很多刚接触这个想法的朋友第一反应是C语言编译器不是应该用汇编或者C语言本身来写吗用C是不是有点“杀鸡用牛刀”了作为一个在编译器和底层开发领域摸爬滚打了十多年的老码农我必须说这个项目恰恰是理解现代编译器设计、锻炼系统编程能力和深入计算机科学核心的绝佳路径。首先明确一点我们这里说的“C语言编译器”指的是一个能够将符合ANSI C比如C89/C90标准或经典C语法的源代码翻译成目标机器码或中间表示如汇编的完整程序。它不是一个玩具而是一个具备词法分析、语法分析、语义分析、中间代码生成与优化、目标代码生成等完整流程的实践项目。那么为什么选择C呢原因很实在抽象与工程化的平衡。C语言给了我们极致的控制和对内存、硬件的直接理解但在构建一个结构复杂、模块众多的编译器时缺乏原生面向对象、泛型、RAII等现代语言特性会让代码的组织和维护变得异常繁琐。C在保留C的底层能力的同时提供了类、模板、STL容器、智能指针等工具让我们能更清晰地建模编译器中的各种实体如符号、类型、抽象语法树节点更安全地管理资源并编写出更健壮、更易扩展的代码。这个项目适合谁它绝不仅仅是给编译原理课程的学生交作业用的。如果你是一个渴望深入理解程序如何从文本变成可执行文件的C/C开发者一个对系统软件、语言设计或代码分析工具感兴趣的后端工程师或者是一个想挑战自己、构建一个真正“有用”的复杂系统的编程爱好者那么这个指南就是为你准备的。通过亲手实现你会对“指针”、“内存布局”、“调用约定”、“类型系统”这些概念有刻骨铭心的认识这种认识是读十本书也无法替代的。2. 编译器核心架构与设计思路动手之前切忌埋头就写。一个清晰的架构设计能让你在后续复杂的编码中不至于迷失方向。一个经典的编译器前端流程可以概括为源代码 - 词法分析 - 语法分析 - 语义分析 - 中间代码生成。后端则涉及中间代码优化 - 目标代码生成。我们的项目将聚焦于实现一个完整的前端并生成一种简单的中间表示或直接的x86汇编32位保护模式简化版这已经足够体现一个编译器的核心思想。2.1 前端模块化设计我们将编译器前端严格划分为几个松耦合的模块这是用C的面向对象特性天然能实现的好处。词法分析器它的任务是把字符流源代码转换成有意义的词法单元序列。每个词法单元包含类型如标识符、关键字、整数常量、运算符和对应的文本值。在C中我们可以定义一个Token类包含TokenType枚举和std::string lexeme成员。词法分析器Lexer类则持有一个指向源代码字符串的迭代器或索引通过一个getNextToken()方法逐个识别并返回Token。语法分析器这是前端的核心负责根据C语言的语法规则将Token序列组织成一棵抽象语法树。我们通常会采用递归下降分析法因为它的逻辑直观与BNF语法规则几乎一一对应非常适合手工实现。我们需要为每一种语法结构如表达式、语句、函数定义定义一个对应的AST节点类它们都继承自一个公共的基类ASTNode。例如BinaryExprNode二元表达式节点可能包含左操作数、操作符和右操作数三个子节点指针。语义分析器AST只保证了语法结构正确但代码是否“有意义”需要它来检查。这包括变量在使用前是否已声明符号表管理、表达式中操作数的类型是否兼容类型检查、函数调用的参数个数和类型是否匹配等。我们将实现一个SemanticAnalyzer类它通过遍历AST构建并查询符号表来完成这些工作。符号表可以用std::unordered_map来实现键是标识符名值是一个包含类型、作用域等信息的Symbol对象。中间代码生成器经过语义分析的AST是“正确”的但还不是机器相关的。我们需要将其转换为一种更接近机器指令、但又不依赖具体CPU架构的中间表示。这里我们选择一种非常经典且简单的形式三地址码。每条三地址码指令形式如x y op z。生成器IRGenerator会再次遍历AST为每个可执行的节点生成对应的TAC指令序列。例如一个赋值语句a b 5;可能被翻译成两条TACt1 b 5和a t1。2.2 工具链与开发环境选型虽然我们的目标是“从零实现”但站在巨人的肩膀上能让我们更专注于核心逻辑。我们不会使用Lex/Yacc或ANTLR这类自动生成工具而是完全手写分析器以深入理解其原理。编译与构建毫无疑问使用GCC或Clang作为我们开发编译器本身的编译器。它们对现代C标准支持最好。构建工具推荐CMake它能很好地管理跨平台项目结构让你在Linux、macOS甚至Windows配合MinGW上都能轻松构建。调试与诊断GDB/LLDB是我们的最佳伙伴。编译器的调试常常需要深入查看复杂的数据结构如AST熟练掌握调试器至关重要。在代码中大量使用assert宏进行内部一致性检查能提前捕获许多隐蔽的错误。辅助工具graphviz的dot工具非常有用可以将我们生成的AST或中间代码可视化出来对于调试复杂表达式或程序流至关重要。写一个简单的Dump函数将AST以文本树状形式打印到控制台也是快速验证语法分析结果的必备手段。注意不要一开始就追求支持完整的C99或C11标准。从C89的一个严格子集开始比如只支持int、char类型基本的算术运算if-else和while语句以及函数定义和调用。实现一个能正确编译这个小子集的、完整的流水线其价值远大于一个支持众多特性但漏洞百出的半成品。3. 词法分析器从字符到单词词法分析是编译器的“眼睛”。它的实现相对直接但细节决定成败。3.1 Token设计与分类首先我们需要定义所有可能的词法单元类型。用一个枚举类TokenType来清晰地列出它们enum class TokenType { // 标识符和常量 IDENTIFIER, // variable_name, func_name INT_LITERAL, // 123, 0x1A CHAR_LITERAL, // a, \n STRING_LITERAL, // hello // 关键字 (C89主要关键字) KW_INT, KW_CHAR, KW_IF, KW_ELSE, KW_WHILE, KW_RETURN, KW_VOID, // 运算符 OP_PLUS, OP_MINUS, OP_MUL, OP_DIV, OP_MOD, // - * / % OP_ASSIGN, // OP_EQ, OP_NE, OP_LT, OP_LE, OP_GT, OP_GE, // ! OP_LOGIC_AND, OP_LOGIC_OR, OP_LOGIC_NOT, // || ! // 分隔符 SEMICOLON, // ; COMMA, // , LPAREN, RPAREN, // ( ) LBRACE, RBRACE, // { } LBRACKET, RBRACKET, // [ ] // 特殊 END_OF_FILE // 表示源代码结束 };对应的Token类则包含类型、原始字面值以及它在源代码中的位置行号、列号这在报错时极其有用。class Token { public: TokenType type; std::string lexeme; // 原始的字符串 int line; int column; Token(TokenType t, const std::string l, int ln, int col) : type(t), lexeme(l), line(ln), column(col) {} };3.2 手写扫描算法Lexer类的核心是一个状态机。它逐个读取字符根据当前字符决定下一个状态。跳过空白与注释遇到空格、制表符、换行符直接跳过并更新行号列号。遇到//则一直读到行尾遇到/*则进入块注释状态直到遇到*/。这是最容易出bug的地方之一特别是嵌套注释C不支持但你的分析器要能稳健地处理错误的嵌套。识别数字遇到第一个数字后进入“整数”状态。持续读取后续数字直到遇到非数字字符。这里可以简单支持十进制后续扩展十六进制0x前缀。识别标识符和关键字遇到字母或下划线进入“标识符”状态持续读取字母、数字或下划线。读取完成后将得到的字符串与一个预定义的关键字表std::unordered_mapstd::string, TokenType进行比较如果是关键字则返回对应的TokenType否则返回IDENTIFIER。识别运算符和分隔符很多运算符不止一个字符如,!,,,,||。这需要“向前看”一个字符。例如当读到时需要再读下一个字符如果是则构成OP_EQ否则就是单独的赋值运算符OP_ASSIGN。识别字符和字符串字面量遇到单引号进入“字符”状态读取下一个字符注意处理转义字符如\n,\t,\然后期望一个闭合的单引号。字符串类似但使用双引号并且需要读取多个字符直到闭合引号。这里必须处理转义字符和跨行字符串C中字符串字面量不能直接跨行但可以通过\续行。实操心得使用std::string_view如果源代码已经全部读入内存Token中的lexeme可以存储为std::string_view指向源代码中的一段避免大量的字符串拷贝性能提升显著。精心设计错误恢复词法分析阶段也会遇到错误如未终结的注释、字符串。好的错误处理不是直接崩溃而是尽可能报告错误位置和类型然后尝试恢复到下一个可能的安全点例如跳过未终结的字符串直到行尾继续分析以便在一次编译中发现多个错误。单元测试先行为词法分析器编写大量的单元测试是最高效的方法。准备一系列测试用例从简单标识符到复杂的运算符组合、带转义的字符串验证其输出Token序列是否正确。4. 语法分析器构建抽象语法树语法分析器是编译器的“大脑”它赋予字符流以结构。我们采用递归下降方法因为它直观、可控并且与C语言的语法规则高度契合。4.1 文法定义与消除左递归首先我们需要用近似BNF的形式定义我们子集C语言的文法。例如program - { function_definition } function_definition - type identifier ( parameters? ) compound_statement parameters - type identifier { , type identifier } type - int | char | void compound_statement - { { statement } } statement - expression_statement | selection_statement | iteration_statement | return_statement | compound_statement selection_statement - if ( expression ) statement [ else statement ] iteration_statement - while ( expression ) statement return_statement - return expression? ; expression_statement - expression? ; expression - assignment_expression assignment_expression - logical_or_expression [ assignment_expression ] logical_or_expression - logical_and_expression { || logical_and_expression } logical_and_expression - equality_expression { equality_expression } equality_expression - relational_expression { ( | !) relational_expression } relational_expression - additive_expression { ( | | | ) additive_expression } additive_expression - multiplicative_expression { ( | -) multiplicative_expression } multiplicative_expression - primary_expression { (* | / | %) primary_expression } primary_expression - identifier | integer_literal | character_literal | ( expression ) | function_call function_call - identifier ( argument_list? ) argument_list - expression { , expression }注意为了适应递归下降自上而下分析文法必须消除左递归并处理好运算符优先级上面文法中优先级从下往上递增primary_expression最高assignment_expression最低。4.2 递归下降函数的实现为文法中的每一个非终结符如expression,statement实现一个对应的解析函数。这些函数共同操作一个全局或成员Lexer实例消费Token并构建AST节点。class Parser { Lexer lexer; Token currentToken; public: Parser(Lexer l) : lexer(l) { currentToken lexer.getNextToken(); } // 工具函数消费当前Token并获取下一个Token void consume(TokenType expectedType) { if (currentToken.type ! expectedType) { throw ParseError(Expected token X, got Y, currentToken); } currentToken lexer.getNextToken(); } // 解析程序多个函数定义 std::unique_ptrProgramNode parseProgram() { auto program std::make_uniqueProgramNode(); while (currentToken.type ! TokenType::END_OF_FILE) { program-functions.push_back(parseFunctionDefinition()); } return program; } // 解析函数定义 std::unique_ptrFunctionDefNode parseFunctionDefinition() { auto retType parseType(); // 解析返回类型 auto nameToken currentToken; // 当前Token应该是函数名 consume(TokenType::IDENTIFIER); consume(TokenType::LPAREN); auto params parseParameters(); consume(TokenType::RPAREN); auto body parseCompoundStatement(); return std::make_uniqueFunctionDefNode(retType, nameToken.lexeme, std::move(params), std::move(body)); } // 解析表达式根据优先级从赋值表达式开始 std::unique_ptrExprNode parseExpression() { return parseAssignmentExpression(); } std::unique_ptrExprNode parseAssignmentExpression() { auto left parseLogicalOrExpression(); if (currentToken.type TokenType::OP_ASSIGN) { consume(TokenType::OP_ASSIGN); auto right parseAssignmentExpression(); // 右结合 return std::make_uniqueAssignExprNode(std::move(left), std::move(right)); } return left; } std::unique_ptrExprNode parseLogicalOrExpression() { auto left parseLogicalAndExpression(); while (currentToken.type TokenType::OP_LOGIC_OR) { auto opToken currentToken; consume(TokenType::OP_LOGIC_OR); auto right parseLogicalAndExpression(); left std::make_uniqueBinaryExprNode(opToken.type, std::move(left), std::move(right)); } return left; } // ... 类似的 parseLogicalAndExpression, parseEqualityExpression 等 };关键技巧预测与回溯递归下降有时需要“预读”一个Token来决定走哪个分支。例如在parseStatement()中看到TokenType::KW_IF就知道是if语句看到TokenType::KW_WHILE就是while循环。我们实现的文法足够简单通常只需要看当前TokenLL(1)文法。错误处理与同步当解析函数遇到不期望的Token时不能简单抛出异常结束。应该报告错误然后尝试将输入流“同步”到一个安全点。例如在解析语句时如果出错可以一直跳过Token直到遇到分号;或右大括号}然后尝试继续解析下一条语句。智能指针管理内存AST节点之间是树形关系使用std::unique_ptr来自动管理内存再合适不过。根节点如ProgramNode拥有其子节点的所有权当根节点析构时整棵树会被自动清理完全避免了手动new/delete带来的内存泄漏风险。5. 语义分析赋予代码意义语法正确的代码不一定是有效的代码。if (42)语法上没问题但语义上可能是个警告a b c;如果b是int而c是char*那就是类型错误。语义分析器就是做这个的。5.1 符号表的构建与管理符号表是语义分析的核心数据结构它记录了标识符变量、函数名的各种属性。由于C语言有作用域的概念主要是块作用域我们需要一个支持作用域嵌套的符号表。一种常见的实现是使用一个符号表栈。每进入一个新的作用域如函数体、复合语句块就压入一个新的符号表退出时弹出。查找符号时从栈顶向下查找这自然实现了“内层覆盖外层”的规则。class SymbolTable { std::vectorstd::unordered_mapstd::string, std::shared_ptrSymbol scopes; public: void enterScope() { scopes.push_back({}); } void exitScope() { scopes.pop_back(); } bool insert(const std::string name, std::shared_ptrSymbol sym) { if (scopes.empty() || scopes.back().count(name)) { return false; // 重复定义 } scopes.back()[name] sym; return true; } std::shared_ptrSymbol lookup(const std::string name) { // 从内向外查找 for (auto it scopes.rbegin(); it ! scopes.rend(); it) { if (it-count(name)) return (*it)[name]; } return nullptr; // 未找到 } };Symbol类需要包含标识符的类型信息是int还是char是否是指针等、种类变量、函数、以及声明的位置等。5.2 类型检查与推导类型检查贯穿于整个语义分析过程。我们需要为每一种表达式节点定义其求值结果的类型。基本类型兼容性对于二元运算符 - * /通常要求左右操作数都是算术类型int或char并且结果类型是intC语言中整数提升。char和int之间可以隐式转换。赋值兼容性赋值表达式要求右值可以隐式转换为左值的类型。在我们的子集中主要是int和char的相互赋值。函数调用检查检查函数名是否在符号表中已声明实参个数是否与形参个数一致每个实参的类型是否可转换为对应形参的类型。控制流检查if和while的条件表达式必须是标量类型在我们的子集中就是算术类型。语义分析器SemanticAnalyzer类会以访问者模式或直接递归遍历的方式访问AST。每进入一个作用域就调用enterScope()退出时调用exitScope()。遇到变量声明时将其插入当前作用域的符号表遇到变量使用时查找符号表如果找不到则报“未声明的标识符”错误如果找到则将其类型信息关联到AST的使用节点上供后续阶段使用。实操心得分离声明与定义为了支持函数调用在函数定义之前我们需要先进行一遍“声明收集”遍历将所有的函数声明和全局变量声明录入到全局作用域符号表中。然后再进行第二遍完整的语义分析。这模拟了C语言需要前向声明的特性。详细的错误信息语义错误信息要尽可能友好。不仅要说出错还要说出哪里错了为什么错。例如“第10行不能将类型 ‘char*’ 赋值给类型 ‘int’”。这需要我们在AST节点和Token中保存位置信息。为后续阶段准备语义分析完成后AST已经是一个被充分“注解”的数据结构。每个表达式节点都知道自己的类型每个标识符节点都指向了符号表中的条目。这为中间代码生成铺平了道路。6. 中间代码生成通往机器的桥梁有了一个经过语义检查、类型丰富的AST我们就可以将其转换为更接近机器执行的中间表示了。我们选择三地址码作为IR它简单、直观且易于后续优化和转换到汇编。6.1 三地址码设计一条三地址码指令通常包含一个操作符和最多三个操作数地址。操作数可以是临时变量由编译器生成如t1,t2、程序中的变量名、常量或标签用于跳转。我们可以定义一个TAC结构体来表示一条指令enum class TacOp { ASSIGN, // x y ADD, SUB, MUL, DIV, MOD, // x y op z NEG, // x -y (一元) EQ, NE, LT, LE, GT, GE, // x (y relop z), 结果通常为0或1 JMP, // goto L JZ, JNZ, // if x 0 goto L; if x ! 0 goto L LABEL, // L: PARAM, // param x (准备函数参数) CALL, // x call func, n (调用函数n个参数) RETURN // return x }; struct TAC { TacOp op; std::string result; // 结果变量可能为空如JMP std::string arg1; std::string arg2; // 对于跳转指令arg1可能是条件变量arg2可能是标签名 // 我们可以用额外的字段存储标签 std::string label; // 用于LABEL, JMP等指令 };6.2 从AST生成TACIRGenerator类遍历AST为每个节点生成TAC序列。这是一个递归的过程。生成表达式代码对于二元表达式a b * c需要先递归生成计算b * c的代码结果存到临时变量t1再生成计算a t1的代码结果存到另一个临时变量t2。整个表达式的“值”就存放在t2中。生成器需要维护一个临时变量计数器来生成唯一的临时变量名如t0,t1, ...。生成语句代码赋值语句生成计算右值表达式的代码得到结果变量然后生成一条ASSIGN指令将结果赋给左值变量。if语句为条件表达式生成代码得到一个条件值变量。生成一条JZ或JNZ指令条件为假时跳转到else分支的标签或if语句结束后的标签。然后分别生成then分支和else分支的代码块并在合适位置插入标签。while语句在循环开始处插入一个标签L_begin生成条件表达式代码和一条JZ指令条件为假时跳转到循环结束标签L_end。然后生成循环体代码最后生成一条无条件JMP指令跳回L_begin并在循环体后插入L_end标签。函数调用按顺序为每个实参生成表达式代码然后为每个实参生成一条PARAM指令。最后生成一条CALL指令指定函数名和参数个数。如果函数调用有返回值CALL指令的结果部分会指定一个临时变量来接收返回值。return语句生成计算返回值表达式的代码然后生成一条RETURN指令。关键实现细节短路求值对于和||逻辑运算符C语言规定短路求值。这意味着不能简单地像算术运算那样先计算左右操作数。例如对于a b生成代码的逻辑是计算a如果a为假则整个表达式结果为假跳过后面对b的计算否则继续计算b并以b的结果作为整个表达式的结果。这需要生成额外的标签和条件跳转指令。函数栈帧的抽象在中间代码层面我们暂时不处理栈指针、帧指针这些底层细节。我们将局部变量和临时变量都视为符号名。在后续的目标代码生成阶段再为它们分配具体的栈偏移地址或寄存器。基本块与控制流图生成的TAC序列是线性的但包含了跳转指令。可以在此基础上将指令序列划分成基本块并构建控制流图。基本块是只有一个入口点开头和一个出口点结尾的指令序列出口点通常是跳转或返回。CFG对于后续的优化如死代码删除、常量传播非常重要。虽然在我们这个初级实现中可能不做复杂优化但了解这个概念对理解编译器工作流程很有帮助。7. 目标代码生成从IR到x86汇编这是最后一步也是最贴近机器的一步。我们将三地址码翻译成x86汇编以32位保护模式、ATT语法为例。这一步需要了解目标平台的基本架构寄存器、内存寻址、指令集、调用约定。7.1 简单的栈帧管理我们采用最经典的栈帧结构。每个函数调用时会建立一个栈帧。高地址 ... 参数n ... ... 参数2 ... ... 参数1 ... -- 调用者压栈 返回地址 -- CALL指令压入 旧的ebp值 -- 被调用者保存 (pushl %ebp) ... 局部变量 ... ... 临时空间 ... -- 被调用者的栈帧 (movl %esp, %ebp) 低地址我们的编译器需要为每个局部变量和临时变量在栈帧内分配一个固定的偏移量。例如int a;可能分配在-4(%ebp)的位置下一个char b;分配在-8(%ebp)考虑对齐。在生成代码前我们需要先计算整个函数需要多少栈空间。7.2 将TAC映射到汇编指令这是一个模式匹配和资源分配的过程。我们假设所有变量包括临时变量都存放在栈上这是一种简单但低效的策略更高效的做法是寄存器分配但复杂得多。赋值x y如果y是常量生成movl $10, -4(%ebp)假设x在-4(%ebp)。如果y是另一个变量生成movl -8(%ebp), %eax然后movl %eax, -4(%ebp)。二元运算x y zmovl -8(%ebp), %eax # 加载y到eax addl -12(%ebp), %eax # eax eax z movl %eax, -4(%ebp) # 存回x比较与跳转对于if (a b)生成movl -4(%ebp), %eax # a cmpl -8(%ebp), %ebx # b, 比较 a 和 b jge .L_else # 如果 a b跳转到else分支 # then 分支代码 .L_else: # else 分支代码函数调用调用者按从右到左的顺序将参数压栈。执行call function_name。调用返回后调用者调整栈指针addl $12, %esp假设3个参数每个4字节来清理参数。被调用者函数体开始要保存旧的ebp并建立新栈帧pushl %ebp; movl %esp, %ebp结束时要恢复栈帧并返回movl %ebp, %esp; popl %ebp; ret。实操心得从简入手初期可以生成非常朴素的代码每条TAC都对应几条固定的、操作内存的汇编指令。这能保证正确性。引入寄存器分配当基本功能正确后可以尝试一个简单的寄存器分配策略比如将最频繁使用的几个临时变量分配到eax,ebx,ecx,edx这些通用寄存器中能显著减少内存访问提升生成代码的效率。这可以通过一个简单的图着色算法对于小规模函数或线性扫描算法来实现。使用GAS或NASM汇编器我们生成的汇编代码文本需要被汇编器如as和链接器如ld处理。确保生成的汇编语法与你选择的汇编器兼容。也可以选择生成更易读的汇编格式便于调试。测试与验证编写小的C程序用你自己的编译器编译然后与GCC编译相同程序产生的汇编进行对比使用gcc -S -m32 -O0。虽然不可能完全一样但主要逻辑和控制流应该是一致的。运行生成的可执行文件验证其行为是否正确。8. 集成、测试与常见问题将前面所有模块串联起来就形成了一个完整的编译器流水线。主函数可能像这样int main(int argc, char* argv[]) { if (argc ! 2) { /* 处理参数 */ } std::string sourceCode readFile(argv[1]); Lexer lexer(sourceCode); Parser parser(lexer); auto ast parser.parseProgram(); SemanticAnalyzer sema; sema.analyze(ast.get()); // 进行声明收集和语义检查 IRGenerator irGen; auto tacSeq irGen.generate(ast.get()); CodeGenerator codeGen; std::string asmCode codeGen.generate(tacSeq); writeFile(output.s, asmCode); // 可以在这里调用系统命令进行汇编和链接 as --32 -o output.o output.s ld -m elf_i386 -o prog output.o ... return 0; }8.1 系统化测试策略单元测试为词法分析器、语法分析器、语义分析器分别编写测试。使用Google Test或Catch2等框架。例如给词法分析器一段字符串断言它输出的Token序列符合预期。集成测试测试整个前端词法-语法-语义对一段完整代码的分析是否正确。可以检查生成的AST结构或者检查语义分析是否报告了预期的错误。端到端测试这是最关键的。编写一系列小的C程序测试用例用你的编译器编译、汇编、链接并运行将输出与用标准编译器如GCC编译运行的结果进行比对。测试用例应覆盖正常功能算术运算、控制流、函数调用递归等。边界情况整数溢出如果你的编译器不检查、数组边界如果支持数组、空函数等。错误处理包含语法错误、类型错误、未定义变量的代码检查你的编译器是否能给出准确清晰的错误信息而不是崩溃或生成错误代码。8.2 常见问题与调试实录在实现过程中你几乎一定会遇到下面这些问题内存泄漏或访问越界这是C/C项目的通病。严格使用std::unique_ptr管理AST节点生命周期。使用AddressSanitizer(-fsanitizeaddress) 和UndefinedBehaviorSanitizer(-fsanitizeundefined) 进行编译和测试它们能帮你快速定位大多数内存和未定义行为错误。无限递归或栈溢出特别是在递归下降解析器或递归遍历AST时如果文法存在左递归没有消除干净或者递归函数没有正确的终止条件就会导致栈溢出。确保你的文法正确并在递归函数中设置合理的深度限制或使用迭代算法。类型系统混乱这是语义分析中最棘手的部分。明确你的类型提升规则。例如char int结果是什么类型赋值时的隐式转换规则是什么最好在项目初期就写文档确定下来并在代码中用清晰的枚举和函数来实现类型检查和转换。生成的汇编无法汇编或链接仔细检查生成的汇编代码格式。常见的错误有标签命名不符合规范如以数字开头、跳转标签不存在、调用约定不一致比如栈平衡没做好、使用了未声明的全局符号如printf如果你链接了C库。使用as --32 output.s和ld -m elf_i386 output.o来单独汇编和链接看具体的错误信息。调试信息缺失当你的编译器崩溃或生成错误代码时如何定位在关键阶段如解析后、语义分析后输出AST的文本或图形化表示。在生成汇编时可以插入一些注释标明是哪条C语句产生的汇编。这些调试输出在后期可以通过编译选项来关闭。最后的建议实现一个编译器是一个庞大的工程不要试图一口气吃成胖子。采用迭代开发先实现一个只能编译return 0;的“最小可行产品”然后逐步添加对整数、变量、运算符、控制流、函数的支持。每完成一个特性就进行充分的测试。这个过程中你对编程语言、计算机体系结构的理解会以肉眼可见的速度加深。当你第一次看到自己编写的编译器成功编译并运行一个Hello, World!级别的程序时那种成就感是无与伦比的。这不仅仅是一个项目更是一次深刻的计算机科学修行。
返回列表