
简介《可变目标C编译器设计与实现》A Retargetable C Compiler Design and Implementation一书配套的lcc 4.2源码包面向编译原理课程学习者、准备深入理解编译器的程序员也适合需要在VC6等Windows环境下编译运行旧版C编译器的读者。整个资源共459个文件压缩包约924KB主体包括78个C源文件、72个头文件以及yacc文法文件、makefile、VC工程文件、测试用例等文件类型覆盖代码、工程配置、文档与生成物便于直接打开工程阅读或重新编译。已有222人学习下载。通过这套源码可以对照书籍观察经典可重定向编译器的整体结构前端负责词法、语法与语义分析后端针对不同目标机生成汇编代码中间表示清晰分隔前后端结合书中各章可逐个函数追踪符号表、类型检查、表达式翻译与指令选择过程也可修改源码自行验证扩展实验从而真正掌握一个完整C编译器从源码到目标代码的关键实现路径。1. 为什么还要折腾一个二十多年前的编译器源码先把这个标题拆开说清楚lcc42是lcc编译器的一个经典版本lcc的全称是Little C Compiler由克里斯·弗雷泽和戴维·汉森写了十几年4.2版本大概对应2001年前后的代码快照。最特别的一点是这个版本的源码对编译环境极其友好VC6这样老掉牙的工具链都能直接编过而且在Windows上编出来之后能编译出能在Windows本地运行的C程序。我先说结论如果你对编译原理感兴趣但又被龙书里的理论劝退过或者想自己动手写一个真正能用的C编译器lcc42是目前能找到的最适合“读源码改源码跑通全流程”的项目没有之一。它的代码量大概在2万行左右一个人完全可以读完比开源的GCC、LLVM动辄几百万行要亲民得多。再加上它本身就是为教学和嵌入式环境设计的整个架构干净得像是给人“解剖”用的。这篇文章我会从实际动手的角度把“用VC6编译lcc42源码”这件事完整走一遍解释每一步为什么要这样做并且把源码里最值得读的几个模块、最容易踩的坑一次性说透。无论你是计算机专业的学生、嵌入式开发者还是单纯对“编译器到底是怎么把代码变成机器指令”这件事好奇的爱好者这篇都能给你一个可操作的入口。提示我用的编译环境是Windows XP虚拟机里的VC6Win7以上系统跑VC6会有兼容性弹窗需要设置兼容模式。如果你手头没有VC6用Visual Studio 2010及以下版本带C编译器也可以但需要小改一下头文件兼容问题后面会专门说。2. 准备环境VC6的正确打开方式与工具链配置2.1 为什么偏偏是VC6能编译成功这其实是个历史遗留问题也是lcc42的特殊价值。写于2001年的lcc42源码在语法上用的是老式C89标准很多地方还有KR风格的函数声明就是那种int foo(a, b) int a; char *b; { ... }的写法。这种代码放到现代编译器比如GCC 9以上的默认标准、MSVC的新版本里会直接报错或者疯狂警告因为现代编译器默认按C99/C11的标准去解析KR风格的旧式声明虽然还保留兼容但很多警告被默认视为错误处理。VC6的编译器是1998年的产物它对C89的支持非常纯正几乎和lcc42源码是同时代的东西所以两边“臭味相投”编译通过几乎不需要改代码。另外lcc42的源码目录里本身就带了针对MSVC的makefile这说明原作者在发布时就已经用VC6验证过可编译。所以“lcc42源码可以用VC6编译”这句话不是玄学是官方支持的路径。2.2 拿到源码后的目录结构速览源码解压后你会看到一个很清爽的目录主要分成这么几块src/编译器的核心源码词法分析、语法分析、中间代码生成、优化都在这里是绝对的主战场。x86/、mips/、sparc/、alpha/各目标机器的代码生成器也就是把中间代码转换成具体汇编指令的后端。在Windows上真正用到的是x86这个目录。cpp/C预处理器源码。lcc把预处理单独拆成了一个可执行文件配合主编译器使用。etc/一些辅助工具和脚本比如lcc前端驱动程序的入口。makefile和makefile.nt前者是UNIX环境用的后者是Windows NT也就是VC6命令行环境用的。建议你按我下面这个顺序读源码先看src/scan.c理解词法分析器是怎么把字符流变成token流再看src/parse.c理解语法分析器怎么把token流变成语法树然后看src/sym.c和src/type.c理解符号表和类型系统最后看x86/x86.c理解一棵语法树是怎么变成x86汇编指令的。这个阅读路径就是一条完整的“从源代码到汇编代码”的主线。2.3 配置VC6的编译环境打开VC6后建议自己手动建立一个工作区而不是用官方makefile因为官方makefile是给命令行nmake用的交互性差一些不利于新手观察编译过程。我的做法是在VC6里新建一个Win32 Console Application空工程名字就叫lcc。把src/目录下的所有.c文件都添加进工程main.c、scan.c、parse.c、tree.c、dag.c、stmt.c、expr.c、sym.c、type.c、decl.c、string.c、bytecode.c、output.c、lex.c等等。把以下目录加入“工具→选项→目录→Include files”lcc42源码根目录src目录include目录在“工程→设置→C/C→预处理器→预处理器定义”里加上WIN32和NDEBUG注意千万别加_DEBUG否则链接时会找不到调试版本的运行时库。编译目标选择Release版本。VC6的Debug版本会默认引用一些调试库而lcc源码的运行时行为要求比较底层Debug下经常会出现指针断言这点后面还会细说。做好这些就能点编译了正常情况下一两次警告就能过。如果报头文件找不到的错误检查一下上一层的include目录路径是不是漏了。3. 编译过程与定制从vc6到lcc自己的工具链3.1 VC6编译lcc22的完整过程在VC6的IDE里按F7编译过程一般不会超过30秒因为整个工程也就两万行代码出头。编译完成后会在工程的Debug或Release目录下得到三个可执行文件lcc.exe编译器驱动负责调度预处理器和后端编译器。cpp.exelcc自己的C预处理器负责处理#include、#define、宏展开这些。rcc.exe真正干活的后端编译器输入语法树中间表示输出汇编文件。我自己第一次编译成功后那种感觉真的很奇妙你现在手上的这套工具链是用“上一代编译器”编译出的“编译器”而这个新编译器又能继续编译C代码这就算真正理解了“自举”这个概念在实践中的含义。接下来做一个小实验验证这套工具链能正常使用。写一个最简单的hello.c放到和lcc.exe相同的目录下打开命令行执行lcc hello.c正常情况下lcc驱动会按顺序做三件事调用cpp.exe预处理hello.c把#include stdio.h等指令展开成一个完整的中间文件解析这个预处理后的文件生成语法树然后生成x86汇编代码保存为hello.asm调用系统自带的汇编器和链接器VC6环境就是cl的底层工具最终链接生成hello.exe。运行一下看到输出hello就说明整条工具链完全打通了。3.2 定制你自己的lcc编译器既然源码都在手上了什么都不改直接编过那就太浪费了。最推荐新手做的一个小定制是修改关键字表。在src/lex.c里有一张keywords[]数组列出了所有保留关键字。你把一个不常用的关键字比如restrict在数组里删掉重新编译整个工程然后用新的lcc去尝试编译一个带restrict关键字的C文件就会得到一个语法错误。这就是你对编译器的第一次“动刀”虽然很初级但是能让你直观体会到“词法分析”在编译器中的作用关键字识别不靠魔法就是查表。进阶一点的定制是修改src/options.c里的默认优化级别。lcc默认是一个O2级别的优化-O参数你把默认参数改掉再编译同样的代码去比较生成的汇编指令条数就能直观理解编译器优化的意义。我实测过一个简单的for求和循环开启优化后指令数能减少大约20%部分冗余的内存访问会被直接消除掉。3.3 启动文件与运行路径的注意事项lcc驱动lcc.exe在运行时会去一个固定目录找cpp.exe和rcc.exe这个目录名在源码里写死了是/usr/local/lib/lccUNIX风格路径Windows下莫名其妙还带着这个路径导致如果你不在源码里改掉它直接运行lcc.exe一定会报“cannot find cpp.exe”之类的错误。解决办法很简单在src/main.c里搜索路径定义找到类似/usr/local/lib/lcc的字符串直接替换成你放可执行文件的完整路径比如C:/lcc42/bin。注意Windows路径里的反斜杠在C字符串里需要写成两个\\我建议直接用正斜杠/Windows底层的文件接口是兼容正斜杠的。改完重新编译然后再跑一次hello.c就能正常运行了。这一步是初学阶段踩坑率最高的地方网上很多帖子说“lcc编出来不能用”多半就是没改这个路径。4. 源码解剖lcc42里最值得反复读的三个模块4.1 词法分析器scan.c教科书级别的有限状态机很多人学编译原理时词法分析是最容易糊弄过去的部分总觉得就是“扫描一遍字符串而已”但真正看lcc的scan.c你会发现一个工业级哪怕教学级的词法分析器远不止“按空格切词”这么简单。scan.c的核心是一个next()函数每次调用就返回一个新的token。它的处理逻辑是一个典型的有限状态自旋结构读一个字符判断这个字符属于哪一类字母、数字、标点、引号、空白如果是字母开头就一直读下去直到遇到非字母数字字符形成一个标识符如果是数字开头需要判断是十进制、八进制0开头、十六进制0x开头还是浮点数每读一个字符都要更新状态如果是标点还要处理“双字符运算符”例如、-、你需要多向后看一个字符才能正确判定。我当时读这段代码时印象最深的是它对注释的处理。C语言有两种注释//和/* */scan.c里用了一个很简洁的状态位来记录当前是否处于块注释内这样即使是跨行注释也能保持状态不丢失。这个设计启发我在自己写一个小脚本语言时也用同样的方法处理了多行注释非常省事。4.2 语法分析器parse.c手写递归下降的典范与很多编译器教材中用yacc/bison生成解析器不同lcc的语法分析器是手写的递归下降解析器。这意味着你不用依赖代码生成工具就能看到每一个语法规则是如何对应到一段具体的代码的。对于想彻底搞懂解析原理的人来说这比看一个巨大的yacc模板文件要友好得多。比如对表达式解析parse.c里实现了经典的“优先级爬升”算法。它把表达式解析分成几个层级赋值表达式、逻辑或、逻辑与、位或、位与、相等比较、关系比较、移位、加法、乘法、一元、后缀每个解析函数只处理自己这一层的优先级然后调用下一层。因为lcc同时支持C语言里的逗号表达式、条件表达式、赋值表达式等复杂结构所以这个expr解析函数写得非常长但逻辑非常清晰。我在读这段代码时尝试自己先把表达式解析流程图画出来再跟代码对读效果奇佳两个下午就弄明白了优先级问题。lcc对和||短路求值的处理也是在这个模块里完成的。它会在生成语法树时特意把右操作数包装成一个带标签的三元节点只生成一个与节点但会在中间代码层转换为条件跳转这样汇编层面的“短路”语义就自然实现了。4.3 中间表示与指令选择从语法树到x86汇编这是lcc最厉害的地方也是它和其他教学用编译器拉开差距的地方。lcc把表达式解析为语法树之后会经过一个“规范化”过程把复杂的表达式节点转换为一个DAG有向无环图这样能识别出公共子表达式避免重复计算。然后后端模块x86/x86.c负责把这个DAG转换为x86汇编指令。它的核心是一个树匹配器每种语法树节点对应一种或几种汇编指令模板例如一个加法节点(a,b)在x86下通常对应addl指令但如果加数是一个常数就会选择立即数寻址的指令格式。我强烈建议你读x86/x86.c里的address()函数和expr()函数这两个函数加起来不到500行但你读完后就会真正理解“指令选择”这个编译原理中重要的后端步骤在真实编译器里到底是怎么落地的。很多人学编译原理时觉得“后端是不是很难”但实际上有了一份清晰的树匹配逻辑之后为一种新CPU移植编译器就有了一个可模仿的蓝本。5. 常见问题与排查技巧实录5.1 VC6环境下的编译问题速查表我先后在VMware虚拟机中的Win2000、WinXP、Win7上编译过lcc42也帮几个读者远程看过问题汇总一下高频故障症状原因处理方式编译报错cannot open include file: stdarg.hinclude路径没配好找不到VC6自带的标准头文件目录在VC6的“Tools → Options → Directories”里把C:\Program Files\Microsoft Visual Studio\VC98\Include加到Include files列表链接报错unresolved external symbol _main工程类型选错了选了Windows应用程序而不是控制台程序新建工程时选“Win32 Console Application”或者在Project Settings里把/SUBSYSTEM:WINDOWS改为/SUBSYSTEM:CONSOLE编译warning C4003: not enough actual parameters for macro assert源码里的assert宏展开有问题通常是NDEBUG没定义导致assert保留在预处理器定义里加上NDEBUG或者干脆接受这些警告不影响生成exe运行lcc.exe直接闪退控制台只闪了一下路径配置文件没改找不到cpp.exe或rcc.exe按上文3.3节修改src/main.c中的lib路径并确保三个exe在同一目录链接时大量LNK2005重复定义错误工程里重复添加了同一个.c文件或者混用了Debug/Release库检查工程的文件列表去掉重复项保证所有文件用同一种运行时库全部Release或全部Debug生成后运行hello.exe系统提示找不到MSCVR71.DLLVC6默认的动态运行时库路径问题在Project Settings里把代码生成的运行时库改成静态多线程版/MT重新编译即可5.2 调试编译器时的三类技巧第一类技巧是学会看现场。lcc的编译过程默认是静默的如果你想知道每一步发生了什么可以在运行lcc时加-v参数它会详细打印出每次调用cpp、rcc的命令行参数这个对排查路径配置问题特别有用。第二类技巧是善用中间产物。lcc加-S参数可以保留生成的汇编文件而不链接加上-emit-asm之类的选项不同版本选项名略有差异用-help查看可以保留语法树打印输出。我曾经为了观察短路求值的实现用把汇编文件dump出来然后逐行和源代码对照这种“看现场”的方式比空想一万遍都管用。第三类技巧是学会“模块化替换”。由于lcc把预处理器、编译器前端、后端分成了三个独立的可执行文件你完全可以把cpp.exe替换成系统自带的预处理工具或者只把某个阶段的产物保留下来输入给下一阶段。这种模块化设计让调试范围一下子缩小了很多每次实验只需要聚焦一个模块。5.3 这个项目还能怎么玩编译通过之后lcc42这棵小树可以继续往很多方向长学习二次开发这是最主流的玩法给lcc增加新的编译器警告选项甚至实现一个小众语言的子集方言。移植到新平台lcc的架构天然适合移植已经有爱好者把它移植到ARM和RISC-V模拟器上运行整个后端的替换工作集中在x86/对应目录。作为嵌入式开发的前端由于lcc生成的代码非常干净、体积小有些嵌入式开发者直接拿lcc配合自制汇编器/链接器来驱动裸机程序这也是为什么“嵌入式内核源码”这个热词会和lcc42关联在一起。用lcc学自举lcc官方源码包本身就包含它的全部源码你可以在编译好的lcc基础上尝试去掉一部分语法功能然后重新编译自己体会一下“编译器割尾巴”的感觉。操作层面我最后再分享一个体会读完lcc42的源码你对编译原理的理解会从一个抽象的理论变成一张清晰的地图。以后再遇到其他编译器的报错信息你至少能看出来是词法阶段、语法阶段还是语义阶段出的问题不会一头雾水。能在2020年代还能找到一个用VC6就能轻松编译的学习型编译器源码对每一个想入门编译原理的人来说都是一件很幸运的事希望你能认真用完这份幸运。本文还有配套的精品资源点击获取