首页 / 资讯中心 / 文章详情

基于C语言编译器开发实战:从词法分析到目标代码生成

基于C语言编译器开发实战:从词法分析到目标代码生成 ★ FEATURED ARTICLE
简介这是一份面向计算机专业学生与编译原理学习者的C语言编译器课程设计资源围绕词法分析、语法分析、中间代码生成与优化、目标代码生成等完整编译流程展开适合作为课程设计参考或编译原理实践项目。压缩包共54个文件、约5.1MB包含cpp与h源码、c与l/y语法词法文件、obj与pdb编译产物、txt说明文档、py脚本及asm汇编码等覆盖从源码到可执行文件的各阶段产物。项目使用lex与yacc完成词法分析与语法分析并生成语法树C实现语法树解析、中间代码生成及错误检测并完成中间代码优化随后借助Python处理中间代码生成MIPS汇编码可在PCSpim模拟器上成功运行。目前已有506人学习下载读者可据此理清编译器各模块的衔接关系参考错误检测与代码优化思路并对照汇编码验证运行结果适合需要完整实现方案与排错参考的进阶学习者。1. 从「只会写 C」到「能造编译器」这条路到底值不值得走很多人写了几年 C 语言能熟练用指针、能手撸链表、能看懂stdio.h里的函数声明但一提到「编译器」三个字脑子里立刻浮现出龙书、虎书、鲸书那几本厚得能砸死人的教材然后默默关掉页面。其实「基于 C 语言编译器」这件事落到实操层面并没有那么玄学——它本质上就是写一个程序输入是 C 源码文本输出是汇编或机器码中间经过词法分析、语法分析、语义检查、中间代码生成、优化和目标代码生成这几个阶段。你不需要一上来就支持全部 C99 标准也不需要立刻搞定结构体嵌套和函数指针的复杂声明完全可以先做一个能跑通int main(){return 0;}的最小闭环再逐步往里加东西。这篇文章面向的是那些已经会写 C、想真正理解「代码是怎么变成可执行文件」的从业者不管你是想补底层知识、想给嵌入式项目做定制编译流程还是单纯想搞明白gcc背后到底干了什么接下来的内容都会按「先立住理论、再动手复现、最后说清坑在哪」的顺序展开。编译器开发和编辑器开发是两回事前者处理的是语言规则和代码生成后者处理的是文本编辑和交互体验别搞混了方向。2. 编译器前端词法分析和语法分析怎么落地2.1 词法分析器的最小实现思路词法分析的任务是把连续的字符流切成一个个有意义的记号token比如关键字int、标识符main、符号(、数字0。常见做法是写一个状态机逐字符读取根据当前字符类别决定进入哪个状态。下面是一个能识别整数、标识符和基本符号的极简词法分析器用 C 语言实现方便你直接编译运行。#include stdio.h #include ctype.h #include string.h typedef enum { TOKEN_INT, // 整数常量 TOKEN_ID, // 标识符 TOKEN_SYMBOL, // 符号如 - * / ( ) { } ; TOKEN_EOF // 结束 } TokenType; typedef struct { TokenType type; char text[64]; // 存储记号原文 } Token; // 从 src 的 pos 位置开始扫描一个记号 Token next_token(const char *src, int *pos) { Token tok; memset(tok, 0, sizeof(tok)); // 跳过空白字符 while (src[*pos] isspace(src[*pos])) (*pos); if (!src[*pos]) { tok.type TOKEN_EOF; return tok; } // 识别整数 if (isdigit(src[*pos])) { int i 0; while (isdigit(src[*pos])) { tok.text[i] src[*pos]; (*pos); } tok.text[i] \0; tok.type TOKEN_INT; return tok; } // 识别标识符或关键字 if (isalpha(src[*pos]) || src[*pos] _) { int i 0; while (isalnum(src[*pos]) || src[*pos] _) { tok.text[i] src[*pos]; (*pos); } tok.text[i] \0; tok.type TOKEN_ID; return tok; } // 其他符号统一按单字符处理 tok.text[0] src[*pos]; tok.text[1] \0; tok.type TOKEN_SYMBOL; (*pos); return tok; } int main() { const char *code int main() { return 0; }; int pos 0; Token tok; do { tok next_token(code, pos); printf(type%d text%s\n, tok.type, tok.text); } while (tok.type ! TOKEN_EOF); return 0; }这段代码的逻辑很直白每次调用next_token时先跳过空白然后根据首字符判断是数字、字母还是符号分别走不同的分支。pos指针在整个扫描过程中持续前移保证不会重复读取。参数方面src是输入的源码字符串pos是当前读取位置的指针返回值是一个Token结构体包含类型和原文。实际工程中你会把text换成动态分配的字符串把TOKEN_ID进一步细分为关键字和普通标识符但核心思路不变。2.2 递归下降语法分析把 token 流变成语法树拿到 token 流之后下一步是验证它是否符合 C 语言的语法规则并构建抽象语法树AST。递归下降是最适合手写的方案每个非终结符对应一个函数函数内部按产生式右部依次调用其他函数或匹配 token。下面以解析return 表达式;为例展示如何把前面的词法分析器串起来。// 假设已有 Token 流和当前位置索引 Token tokens[256]; int token_count 0; int cur 0; // 匹配当前 token 是否为指定文本是则前进 int match(const char *expected) { if (cur token_count strcmp(tokens[cur].text, expected) 0) { cur; return 1; } return 0; } // 解析 return 语句 void parse_return() { if (!match(return)) { printf(error: expected return\n); return; } // 这里简化处理只读取一个整数或标识符作为返回值 if (cur token_count (tokens[cur].type TOKEN_INT || tokens[cur].type TOKEN_ID)) { printf(return value: %s\n, tokens[cur].text); cur; } else { printf(error: expected expression after return\n); } match(;); // 消耗分号 }这段代码里match函数负责检查当前 token 是否等于预期文本如果是就前进到下一个 token否则报错。parse_return先匹配return关键字再读取一个表达式这里简化成单个整数或标识符最后匹配分号。实际项目中表达式解析会复杂得多需要处理运算符优先级和结合性通常用优先级爬升法或为每个优先级写一个函数。参数方面tokens数组存储了词法分析阶段产出的所有 tokencur是当前解析位置token_count是 token 总数。失败时看cur指向的 token 是什么就能定位到语法错误的位置。2.3 语义检查类型和符号表怎么管语法树建好之后还需要做语义检查比如变量是否声明、类型是否匹配、函数调用参数个数对不对。这一步通常需要维护一个符号表记录每个标识符的类型、作用域和存储位置。常见做法是用哈希表或链表数组进入作用域时压栈离开时弹栈。对于「基于 C 语言编译器」这个目标你至少需要检查变量在使用前是否已声明、赋值时左右类型是否兼容、函数返回值类型是否与声明一致。如果这些检查不做生成的代码很可能在运行时崩溃而且调试起来非常痛苦。符号表的实现可以用简单的线性链表每个节点存名字、类型和指向下一个节点的指针查找时遍历链表即可。规模大了再换成哈希表但初期没必要过度设计。3. 编译器后端从中间代码到可执行文件3.1 中间代码生成三地址码和栈式虚拟机前端产出 AST 之后后端需要把它转换成一种更接近机器的中间表示IR。常见选择是三地址码每条指令最多有一个运算符和三个操作数比如t1 a b。这种形式便于后续优化和目标代码生成。下面是一个把简单表达式转成三地址码的示例用 C 语言实现输入是后缀表达式输出是三地址码序列。#include stdio.h #include string.h #include stdlib.h // 临时变量计数器 int temp_count 0; // 生成一个新的临时变量名 void new_temp(char *buf) { sprintf(buf, t%d, temp_count); } // 把后缀表达式转成三地址码 void gen_three_addr(const char *expr) { char stack[64][16]; // 操作数栈存变量名或临时变量名 int top -1; for (int i 0; expr[i]; i) { char c expr[i]; if (c ) continue; if (c a c z) { // 操作数直接入栈 top; sprintf(stack[top], %c, c); } else { // 运算符弹出两个操作数生成临时变量 char op2[16], op1[16], tmp[16]; strcpy(op2, stack[top--]); strcpy(op1, stack[top--]); new_temp(tmp); printf(%s %s %c %s\n, tmp, op1, c, op2); top; strcpy(stack[top], tmp); } } } int main() { // 后缀表达式a b c * gen_three_addr(abc*); return 0; }这段代码遍历后缀表达式遇到操作数就压栈遇到运算符就弹出两个操作数生成一条三地址码再把结果临时变量压回栈。最终栈顶就是整个表达式的结果。参数方面expr是后缀表达式字符串stack是操作数栈temp_count保证临时变量名不重复。实际编译器中三地址码会以结构体链表的形式存储方便后续遍历和优化。如果你想让生成的代码能真正运行还需要把三地址码翻译成目标机器的汇编指令或者用一个栈式虚拟机来解释执行。3.2 目标代码生成从三地址码到 x86 汇编目标代码生成是把 IR 映射到具体 CPU 指令的过程。以 x86-64 为例你需要了解寄存器分配、调用约定和指令选择。下面是一个把简单三地址码t1 a b转成 x86-64 汇编的示例假设a和b是全局变量t1是临时变量。# 假设 a 在 .data 段b 在 .data 段t1 在 .bss 段 movl a(%rip), %eax # 把 a 加载到 eax addl b(%rip), %eax # 把 b 加到 eax movl %eax, t1(%rip) # 把结果存回 t1这段汇编的逻辑是先把a的值从内存加载到寄存器eax然后用addl指令把b的值加到eax上最后把eax的内容存回t1。参数方面%rip是指令指针相对寻址用于访问全局变量%eax是 32 位累加寄存器。实际生成时你需要为每个临时变量分配栈空间或寄存器处理函数调用时的参数传递和返回值还要考虑对齐和重定位。如果目标平台是 ARM 或 RISC-V指令格式会完全不同但思路一致加载、运算、存储。3.3 用 GCC 和 GDB 验证你的编译器输出写完编译器后怎么验证生成的代码是对的最直接的办法是把你的编译器和 GCC 的输出做对比。比如你写了一个简单的 C 程序先用 GCC 编译成汇编看看 GCC 是怎么处理的再用你的编译器生成汇编逐条对比。下面是一个用 GCC 生成汇编并查看的命令序列。# 生成汇编文件不进行汇编和链接 gcc -S -O0 -o test.s test.c # 查看汇编内容 cat test.s # 用 GDB 调试可执行文件查看寄存器状态 gcc -g -o test test.c gdb ./test # 在 GDB 中执行 # break main # run # info registers # disassemble-S选项告诉 GCC 只生成汇编-O0关闭优化方便对照。-g选项在可执行文件中保留调试信息GDB 才能显示源码行号和变量名。在 GDB 里info registers查看当前寄存器值disassemble反汇编当前函数。如果你的编译器生成的汇编和 GCC 在逻辑上一致只是寄存器分配不同那说明你的实现基本正确。如果运行结果不对可以用 GDB 单步执行对比每一步的寄存器值和内存内容定位是哪条指令出了问题。4. 避坑指南编译器开发中常见的五个翻车现场4.1 词法分析时把关键字当成普通标识符现象解析int main时int被识别为TOKEN_ID而不是关键字导致语法分析阶段匹配失败。原因词法分析器只根据字符类别判断没有查关键字表。解决在识别出标识符后查一张关键字表如果命中就改成对应的关键字 token 类型。关键字表可以用静态数组或哈希表实现初始化时把所有 C 关键字加进去。4.2 递归下降解析器遇到左递归直接栈溢出现象解析表达式时程序崩溃报栈溢出。原因文法中存在左递归比如expr - expr term递归下降会无限调用自身。解决消除左递归把expr - expr term | term改写成expr - term exprexpr - term expr | ε。然后在代码里用循环处理expr避免递归深度过大。4.3 符号表作用域没处理好导致变量覆盖现象内层作用域声明的变量覆盖了外层同名变量退出内层后外层变量值丢失。原因符号表只有一张全局表没有按作用域分层。解决用栈式符号表进入作用域时压入新表离开时弹出。查找时从栈顶往下找找到第一个匹配的就返回。这样内层变量不会影响外层。4.4 生成的汇编没有遵循调用约定导致函数调用失败现象调用函数后返回值不对或者程序直接崩溃。原因没有按照 x86-64 System V 调用约定传递参数和返回值比如前六个整数参数应该放在rdi、rsi、rdx、rcx、r8、r9返回值放在rax。解决在生成函数调用代码时严格按照调用约定把参数放入对应寄存器调用完成后从rax读取返回值。如果参数超过六个还需要压栈。4.5 优化阶段把有副作用的代码优化没了现象开启优化后程序行为异常比如某个函数调用被删除了。原因优化时没有考虑副作用把看似无用的代码删掉了但实际上那个函数调用有副作用比如修改全局变量。解决在优化前做副作用分析标记有副作用的指令优化时保留这些指令。常见的副作用包括函数调用、赋值给全局变量、读写volatile变量。5. 进阶技巧用测试驱动的方式逐步扩展你的编译器当你跑通了最小闭环之后下一步就是扩展支持的语法特性。这时候最有效的方法不是闷头写代码而是用测试驱动的方式先写一个测试用例再实现让这个用例通过的功能。比如你想支持if语句就先写一个包含if的 C 程序然后手动写出期望的汇编输出再实现代码生成逻辑最后用脚本自动对比实际输出和期望输出。下面是一个简单的测试脚本示例用 Bash 编写遍历tests/目录下的所有.c文件编译并运行检查退出码是否符合预期。#!/bin/bash # 遍历测试用例 for test_file in tests/*.c; do base$(basename $test_file .c) expected_exit$(cat tests/$base.expected 2/dev/null || echo 0) # 用你的编译器编译 ./mycc $test_file -o $base.s if [ $? -ne 0 ]; then echo FAIL: $base (compilation error) continue fi # 汇编并链接 gcc $base.s -o $base if [ $? -ne 0 ]; then echo FAIL: $base (assembly error) continue fi # 运行并检查退出码 ./$base actual_exit$? if [ $actual_exit -eq $expected_exit ]; then echo PASS: $base else echo FAIL: $base (expected $expected_exit, got $actual_exit) fi done这个脚本的逻辑是对每个测试用例先用你的编译器生成汇编再用 GCC 汇编链接成可执行文件然后运行并检查退出码是否和预期一致。expected文件里存的是期望的退出码如果没有就默认 0。参数方面tests/目录存放测试用例mycc是你的编译器可执行文件。通过这种方式你可以逐步增加测试用例覆盖越来越多的语法特性每次修改编译器后跑一遍测试确保没有破坏已有功能。另一个实用技巧是给编译器加一个-dump-ast选项把语法树以文本形式打印出来。这样当解析出错时你可以直接看 AST 的结构判断是哪一步出了问题。实现方式是在 AST 节点结构体里加一个print函数指针每个节点类型实现自己的打印逻辑递归打印整棵树。这个功能在调试复杂表达式时特别有用比单步跟踪高效得多。我在实际写编译器的过程中最大的教训是不要一开始就追求大而全。先让return 0能跑通再加变量声明再加算术运算再加函数调用每一步都写测试验证。这样即使出了问题也能快速定位到最近添加的功能上。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站