简介:lcc42 是《可变目标C编译器设计与实现》一书的配套源码,面向计算机专业学生、编译器爱好者和系统软件开发者。源码包可在 VC6 环境下编译,方便读者对照教材理解编译器前后端与目标代码生成机制。包体仅 924KB,共 459 个文件,含 78 个 C 源文件、72 个头文件,并包括 YACC 语法文件、汇编文件及 dsp/dsw/vcproj/sln 等工程文件,附 makefile 与 readme 说明,目录结构清晰,Windows 下可直接打开工程编译。目前已有 222 人学习下载。借助教材与源码,读者能完整运行并跟踪解析、语义分析、中间代码生成与优化等关键流程,还可利用包内 wf、switch、array、paranoia 等示例验证编译器行为,适合作为深入学习编译器构造的可靠参考。 如果你手里正好有一份lcc42的源码,而且你还装着一个十多年没动过的VC6,那我建议你别急着删。把lcc42在VC6里完整编译一遍,能让你透彻理解“编译器到底是怎么做出来的”,甚至比看十遍《编译原理》教材都有用。lcc本身是Chris Fraser和David Hanson两位大佬写的轻量级C编译器,最终发布版本停在4.2,用不到两万行代码实现了预处理、词法分析、语法分析、中间代码生成、优化和多种后端,结构相当紧凑。这条实践路径特别适合正在啃编译原理的学生、维护老工具链的嵌入式开发者,以及所有希望通过源码构建来验证编译器设计思路的人。
1. lcc42源码到底是个什么结构
1.1 一份源码,三套可执行程序
我第一次拿到lcc的源码包时也很疑惑,为什么目录里既有src又有cpp和etc,好像东西很杂。实际理清楚之后就明白了,lcc在设计上就分三层:真正干活的预处理器cpp、编译器本体rcc,以及最外层的驱动lcc。驱动负责解析命令行参数、把源文件依次交给cpp和rcc,用户平常执行的lcc其实是这层包装;rcc才是做词法、语法、语义分析和代码生成的“幕后黑手”;cpp则单独负责宏展开和头文件包含。三者分别编译成三个exe,再通过路径搜索组合起来。
目录结构和关键文件大概是这样的:
| 目录 | 主要文件 | 在构建中的角色 |
|---|---|---|
| cpp/ | cpp.c、cpp.h | 预处理器源码,可单独编译为 cpp.exe |
| src/ | lex.c、parse.c、sym.c、expr.c、stmt.c、tree.c、gen.c、dag.c、output.c | 编译器本体源码,链接为 rcc.exe |
| src/x86/(部分版本) | win32.c、linux.c | 后端代码,负责生成目标平台汇编 |
| etc/ | lcc.c | 驱动程序源码,链接为 lcc.exe |
| include/ | stdarg.h、stddef.h 等 | lcc私有头文件集合 |
| lib/ | 运行时支持部分 | 一般不需要手工编译 |
这六个目录里,最核心的是src下那一堆C文件。它们之间不是平行关系,而是严格分层的:lex.c把字符流切分成token,parse.c按文法把token拼成语法树,sym.c和types.c维护符号表与类型信息,tree.c再生成中间表示树,最后gen.c和dag.c负责指令选择与寄存器分配,输出汇编代码。每一个文件对应编译过程的一个阶段,这种模块化程度在真实编译器里算是相当收敛的。
1.2 为什么说lcc是“教科书级编译器”
lcc的代码风格是典型的C89风格,这一点恰好和VC6的C编译能力互相匹配。阅读左边那一列文件时,其实就等于在读一遍龙书里的实现:lex.c负责词法分析,parse.c负责语法分析,sym.c和types.c负责符号表与类型系统,tree.c、gen.c、dag.c负责中间表示和指令选择,后端再根据平台输出汇编。整个流程是单向、无循环依赖的,阅读顺序非常友好。
更重要的是,lcc内部自带一套“自举”机制。它先由一个宿主编译器(这里就是VC6的cl.exe)把源码编成可执行文件,然后用这个可执行文件去重新编译自己的源码,如果两次编译出来的行为一致,就说明这个编译器能“自己生自己”。这一趟流程走下来,远比背下一堆Bison/Yacc语法更能理解编译器是如何闭环工作的。lcc选择C89作为自身实现语言,不是因为它保守,而是为了让编译器可以在最广泛的宿主环境下构建——这在今天依然是很聪明的设计。
2. 准备工作:让VC6能用的第一步
2.1 获取源码和目录要求
获取lcc42源码一般有两个途径:一个是官方在GitHub上维护的drh/lcc仓库里找4.2版本tag,另一个是各种老软件站上的源码包。拿到后先解压,我强烈建议你解压到纯英文且没有空格的路径下,比如D:\lcc42。不要放在C:\Program Files\...这种带空格的目录里,否则后面的驱动脚本和批处理命令都要被折磨,lcc这种老代码对带引号的路径处理得很不优雅。
解压后先花五分钟翻一下README和doc目录。编译前读文档这件事,很多人觉得是浪费时间,但lcc的构建顺序、平台宏定义、需要的命令行参数,文档里基本都有说明。当年我偷懒跳过了这一步,结果在#define上浪费了大半个下午。文档里还会明确写出当前版本支持哪些目标平台,像x86、MIPS、SPARC都有,但不同版本的构建入口略有差异,这些信息都藏在doc里。
2.2 把VC6的命令行工具装进PATH
VC6安装好后,默认不会把cl.exe、nmake.exe塞进系统的全局PATH。你需要先打开一个“命令行编译环境”。最标准的方法是找到VC6安装目录下的VCVARS32.BAT,用命令行执行它:
cd "C:\Program Files\Microsoft Visual Studio\VC98\Bin" VCVARS32.BAT如果你的机器上装的是VC6+SP6,一般路径就在C:\Program Files\Microsoft Visual Studio\VC98。执行完后,用cl命令验证一下:
cl /?能看到编译器版本信息就说明环境OK。如果提示“不是内部或外部命令”,说明脚本没跑成功,要检查路径是否正确。这一步搞定后,VC6的编译器、链接器、头文件路径就都进当前命令行了,下面编译lcc时就不用手动配INCLUDE和LIB。
注意:
VCVARS32.BAT只对当前命令行窗口生效,每开一个新窗口都要重跑一次。偷懒的办法是写一个vc6env.bat放在固定位置,内容就是一行call "C:\Program Files\Microsoft Visual Studio\VC98\Bin\VCVARS32.BAT",以后每次要编译前先执行它,这样能省下不少重复输入的麻烦。
2.3 构建顺序:先cpp,再rcc,后lcc
阅读完文档后你会发现,lcc的官方构建不是一把梭,而是讲究顺序的。必须先编译出预处理器cpp,因为后续编译scanf/printf等复杂头文件时要用到它;再编译编译器本体rcc;最后编译最外层的驱动lcc。把这三个exe都生成好,lcc才算能真正工作。
这个顺序不是硬性依赖——cpp并不参与编译rcc的源码——但先编译cpp会让你在验证环境时更早暴露问题,排查起来也简单。如果cpp都编译不过,说明你的命令行环境或者头文件路径配置有问题,这时候去调rcc只会更加混乱。先易后难,这是老程序员构建多组件项目时的通用策略。
3. 亲手把它编译出来
3.1 第一步:编译预处理器cpp
进入lcc源码包里的cpp目录,直接敲:
cd D:\lcc42\cpp cl /c /I..\include /Dwin32 cpp.c link /out:cpp.exe cpp.obj也可以用一行命令完成编译和链接:
cl /Fe:cpp.exe /I..\include /Dwin32 cpp.c这里面的几个参数要注意:/I..\include告诉cl去lcc的include目录下找头文件,/Dwin32是告诉预处理器当前目标平台是Windows 32位。如果编译时遇到unistd.h找不到或者某些POSIX函数未定义,不要慌,这是正常现象,lcc在Windows下本来就是靠条件编译切换到win32分支的。如果确实报错,优先检查宏名是win32还是_WIN32,以源码里实际判断的分支为准。
3.2 第二步:编译编译器本体rcc
回到src目录,老规矩:
cd D:\lcc42\src cl /c /I..\include /Dwin32 /D_WIN32 *.c link /out:rcc.exe *.obj如果想让链接参数更明确,也可以这样:
cl /Fe:rcc.exe /I..\include /Dwin32 /D_WIN32 *.cMSVC的cl.exe是会自行展开*.c通配符的,所以这里可以直接用通配符。如果你源码包的x86后端目录下有额外文件,比如src\x86\win32.c,在编译时把那几个文件也带上;但通常4.2版本在前端里已经做好了平台分支,win32.c会被条件编译包含,不需要额外指定。
提示:如果在编译时看到
warning C4013: 'xxx' undefined; assuming extern returning int,这通常不是致命错误,但要留意。如果它是一个库函数的声明缺失,会导致链接期出现“未解析的外部符号”,这时通常需要给cl加上/D_CRT_NONSTDC_NO_DEPRECATE之类的宏开关,或者手动声明函数原型。
3.3 第三步:编译驱动lcc
驱动在etc目录下,名字叫lcc.c,编译最简单:
cd D:\lcc42\etc cl /Fe:lcc.exe /I..\include lcc.c驱动本身并不包含复杂的编译逻辑,它的任务是解析命令行、定位cpp和rcc并调用它们。所以编译完这个exe之后,还要让它在运行时找得到另外两个程序。lcc驱动支持通过环境变量LCCDIR指定工具链根目录,你可以在命令行里这样设置:
set LCCDIR=D:\lcc42 set PATH=%LCCDIR%\cpp;%LCCDIR%\src;%PATH%具体变量名以你下载版本的源代码为准,但思路一致:让lcc.exe能找到cpp.exe和rcc.exe。如果你不想每次编译前都set一遍,也可以把这三行写进一个build_lcc.bat,以后一键加载。
3.4 自举验证:用lcc编译lcc
工具链齐了,最激动人心的环节来了——自举验证。随便写个hello.c,用lcc编译:
cd D:\lcc42 lcc -o hello.exe hello.c hello.exe能看到输出就说明基本链路由驱动到预处理器再到编译器本体都通了。更硬核一点的验证方式是,用新生成的lcc.exe把lcc源码再编译一遍:
lcc -o rcc_self.exe src\*.c如果这个rcc_self.exe也能正常编译一个测试文件,那就说明这套编译器已经从“被VC6编译”进化到“能自己编译自己的源码”,自举闭环达成。严格的构建流程还会把rcc和rcc_self做字节级对比,但Windows下由于时间戳和PDB等问题,跑功能测试就足够说明问题。
3.5 安装布局
如果你打算长期使用lcc42,建议把三个exe集中到一个目录,比如D:\lcc42\bin,然后把LCCDIR指向它。后续为lcc编写并安装标准库头文件、运行时库时,这一布局也更清晰。我自己的习惯是保留一份“原始编译日志”,把第一次成功构建的全部命令和输出保存为文本文件,后续一旦改坏某个文件,照着日志回滚就非常快。
4. 常见问题排查与避坑记录
4.1 cl命令不存在
现象:在CMD里敲cl提示“不是内部或外部命令”。
这八成是没有先执行VCVARS32.BAT。记住这个脚本只对当前命令行窗口生效,每开一个新窗口都要重跑一次。偷懒的办法是写一个vc6env.bat然后放在固定位置,但每次开新窗口手动执行其实也不费事。
4.2 头文件找不到
现象:编译时报fatal error C1083: Cannot open include file: 'stdio.h': No such file or directory。
检查两处:一是VC6的环境变量INCLUDE是否包含C:\Program Files\Microsoft Visual Studio\VC98\Include,二是lcc自带的D:\lcc42\include路径是否通过/I传给了cl。如果两处都对,把/I换成带引号写法再试,老式程序对路径中的空格确实容易出问题。
4.3 源码里有long long,VC6不认
现象:编译lcc源码时出现error C2061: syntax error : identifier 'long'之类的报错。
VC6时代的C编译器并不把long long当作标准类型。lcc自己的源码为了能在各种老编译器上构建,通常已经规避了这个问题;但在某些移植版本里会出现。最简单的替代方案是把源码里的long long统一替换成__int64,或者用typedef先行声明:
typedef __int64 long long;注意这类问题只出现在“用VC6编译lcc源码”的阶段,lcc所生成的代码是否支持long long是另一回事。
4.4 链接期一堆未解析外部符号
现象:编译通过,但链接时大量unresolved external symbol _xxx。
新手最容易忽略的是没有指定正确的运行时库。VC6字符界面链接时,默认需要匹配控制台程序的启动代码。如果rcc.exe链接失败,先确认是不是用了/SUBSYSTEM:CONSOLE;再确认源码里确实有main函数。lcc的编译器本体和驱动都有各自的main,编译时不要把它们都塞进同一个EXE,链接命令要按我前面给的rcc、lcc分组来,不要一股脑混在一起。
| 错误类型 | 典型提示 | 排查手法 |
|---|---|---|
| 环境变量缺失 | 'cl' 不是内部或外部命令 | 重新运行VCVARS32.BAT |
| 头文件路径 | cannot open include file | 检查INCLUDE与/I参数 |
| 语法错误 | C2059/C2061 | 优先检查平台宏与C89兼容性 |
| 链接错误 | unresolved external symbol | 检查入口、子系统、是否混用了main |
| 运行找不到工具 | can't find cpp | 设置LCCDIR,检查PATH |
4.5 lcc跑起来后提示找不到cpp或rcc
现象:编译命令敲下去了,但报错内容变成类似Can't open cpp: No such file or directory。
这是驱动lcc.exe编译好了,但它在运行时不了解工具路径。老版本lcc的驱动会通过环境变量或编译时写死的路径去搜cpp和rcc,最常见的原因是LCCDIR没有设置正确,或者三个exe放得太分散。把cpp.exe和rcc.exe放到与lcc.exe相同或明确的子目录下,再核对LCCDIR即可。
4.6 换行符/编码导致的怪异行为
如果你在Windows上用记事本打开过Linux换行的源码文件,编译报错行号与实际不符的情况就会发生。lcc源码本身多是Unix换行(LF),VC6一般也能读,但一旦源码被其他工具转成CRLF,个别字符串和宏拼接可能出现诡异报错。遇到这种情况,统一用VS Code或Notepad++把文件转成CRLF或保持LF,再重新编译,问题通常在十分钟内解决。
5. 编译成功之后的深入学习方向
到这里,lcc42在VC6下编译的完整闭环已经走完。说句实话,整个流程并不复杂,真正值钱的不是那几个命令,而是你看着一个编译器从零开始运行起来,再用它编译自己源码时的那个瞬间。
接下来如果你还想深入,我建议沿着两条线走。一条是“读源码”,把lex.c、parse.c、tree.c这三块串起来,对照龙书章节理解词法分析、自顶向下语法分析、中间代码生成,每看懂一个文件你就离“看懂编译器”更近一步。另一条是“改源码”,试着给lcc加一种新的语法糖,或者把x86后端换成ARM Thumb指令集的简化版本,这种改动哪怕只跑通一个测试程序,也比单纯做几道编译原理刷题更有成就感。
最后分享一个小技巧:编译这套源码时,建议全程打开一个日志文件,把每一步的命令和输出记录成文本。因为我后来尝试改后端时就发现,无论你把代码改成什么样,回滚到“VC6下能编译”的基线永远是最稳妥的排障方式。保存好这套编译日志,等于给你的老工具链备了一份保险。
本文还有配套的精品资源,点击获取