
这个每周更新的系列终于走到第二十四篇。前面聊过信息论、计算机组成、操作系统的部分细节这篇轮到编译器01讲编译原理3。说实话编译原理是我大学时最不想上、后来工作后补得最狠的一门课。越是靠近底层越发现“编译器”这个黑盒子无处不在你在VSCode里装好C/C插件点一下运行背后有编译器你用Java写代码javac把.java变成.class背后还是编译器你在Linux下敲gcc main.c -o main不用多说。这篇就带你把编译器的骨架拆开看看顺便把那些烦人的编译报错和不同平台工具链的配置问题一并说清。1. 编译原理到底在讲什么以及为什么值得学1.1 从源代码到可执行文件编译器到底干了什么很多人以为编译器就是把源代码“翻译”成机器码这个说法没毛病但太笼统。实际从按下编译开关到产出可执行文件中间要经过预处理、词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成最后还要链接库文件和启动代码才能变成你双击就能跑的程序。如果拿做菜类比源代码是菜谱编译器是厨师。菜谱里有文字描述有“适量”“少许”这种模糊说法厨师需要先读懂字面意思再理解做菜步骤是否符合逻辑还要把抽象步骤变成具体的切配、翻炒动作最后端出一盘菜。中途菜谱写错了厨师还要报错告诉你哪一步看不懂。编译器也是这么干的。第一步先读源代码字符流把int a 3;切分成int、a、、3、;这些单词记号这叫词法分析。接着分析这些记号能不能组成合法句子这叫语法分析。再进入语义分析检查类型对不对、变量有没有定义、运算是否合法。之后生成中间表示做各种优化最后映射到目标CPU的指令。整个过程环环相扣任何一个阶段出错你都会看到那些看起来莫名其妙的编译报错。1.2 编译器和编辑器真的是两个东西这可能是初学者最混淆的一点。以后不要再把VSCode叫编译器了它是编辑器职责是让你舒舒服服地写字符。编译器是另一套独立程序拿你写好的文件去生成目标代码。编辑器负责“录入”编译器负责“翻译”显然不是一回事。你在Windows下装好VSCode装了C/C插件点“运行”按钮实际上VSCode是在后台调用了编译器比如MinGW GCC 或者MSVC然后把编译输出显示在面板里。很多人把两者混在一起是因为现代IDE把编辑、编译、调试包装成一个按钮了让你感觉它们是一体的。理解这层区别后你遇到“为什么这里装了VSCode还是不能编译必须装Visual Studio Build Tools”这类问题时就释然了。顺带提一嘴现在不少人在VSCode里配合Claude Code这种AI工具写代码AI助手负责生成代码但最终能不能跑还是要靠编译器把关。AI给出的是文本编译器才决定这段文本是否有语义、能不能变成机器指令。这个定位想清楚踩坑会少很多。2. 编译器的五个阶段逐个拆开看2.1 词法分析与语法分析先读懂文本再理清结构词法分析干的事是把源代码字符流拆成一个一个“单词”也就是Token。比如score 100 bonus * 2词法分析器会识别出score是标识符100是整形常量、*是运算符;是语句结束符。这一步实现起来相对简单核心是写一堆正则表达式或者用工具生成一个自动机。我见过不少学校的“编译原理实验”要你自己手写词法分析器其实就是为了让你体会正则表达式到有限自动机的转换。真到了工程项目很少人从头写词法分析器直接上flex就行。语法分析则是把Token序列按照文法规则拼成抽象语法树。比如1 2 * 3语法分析器必须知道乘法优先级比加法高结果应该是1 (2 * 3)而不是(1 2) * 3。这个阶段可以用递归下降也可以用LR系列算法。递归下降好写、容易理解但需要手调优先级LR分析器强大且能覆盖更多文法但工具生成后调试起来很痛苦。你问编译器开发老手喜欢哪个八成会告诉你“别用Yacc写复杂语法快疯了”。写到这里提一下热词里的“编译器和编辑器的区别”。语法分析解决了“句子结构”问题而编辑器只负责让你输入的文本颜色好看、括号自动匹配。就算编辑器报红色波浪线也只是静态分析不是完整编译它没法保证链接后一定能跑。2.2 语义分析、中间代码与代码生成决定程序能不能跑语法树只能告诉你“结构正确”还不能说明程序意图是否合法。比如int a hello结构上是声明加等号加字符串但类型不匹配一查就知道有问题。语义分析就是干这个的它遍历语法树维护一张符号表检查变量作用域、类型转换、函数调用参数个数等。过了语义分析编译器会生成一种跟具体机器无关的中间表示常见的有三地址码、控制流图、SSA静态单赋值。SSA用得最广现代GCC和LLVM都在用。为什么要引入中间表示为了优化。如果直接在源代码层面优化信息太粗糙如果直接在汇编层面优化又受限于具体CPU。中间表示是“恰到好处”的抽象层方便你在这上面做各种推倒重来的优化例如把int x 3 4;直接简化成x 7这叫常量折叠。目标代码生成则把优化后的中间表示变成汇编再交给汇编器变成机器码。这一步要处理寄存器分配、指令选择、指令调度是最考验经验的阶段。比如同一个乘法有的CPU有乘法指令有的没有那就得用移位和加法模拟指令选择的策略完全不一样。学习编译原理时很多人卡在寄存器分配这里因为它像下棋每一步都要考虑后面怎么走。理解了这五个阶段你再看“编译器的堆空间不足”这个报错就大概能猜到了那多半是某个阶段生成了大量中间数据结构把机器内存撑爆了。跟程序自己运行时的堆溢出不是一回事。3. 主流编译器与工具链选型和踩坑记录3.1 GCC、MSVC、Clang怎么选不同平台的差异做C/C开发的躲不开三个经典编译器GCC、MSVC、Clang/Llvm。很多人一开始无所谓反正都能编译。但换平台后差异会让你头大。GCC是GNU工具链的老大哥Linux下默认自带sudo apt install gcc或者yum install gcc就能装上。它支持的语言广C/C、Fortran、Ada、Go都有对应前端。嵌入式领域更是统治级AVR、CH32V这些单片机开发环境基本都基于GCC。缺点是报错信息偶尔晦涩有些模板错误能滚出几百行。MSVC是微软Windows生态的编译器跟Visual Studio深度绑定。Windows下写Windows桌面程序、用MFC、调Win32 APIMSVC往往最省心。但它的扩展语法和陈旧代码兼容性也造就了一些“Windows程序员专属习惯”。如果你是从Linux过来的人刚用MSVC会特别不习惯它的编译选项格式比如/O2而不是-O2。Clang是LLVM的前端语法兼容GCC但报错更友好有彩色输出还有静态分析工具。macOS下Xcode默认用的就是Clang。许多人在Windows下用MSYS2安装MinGW-w64其实里面也是GCC只是被移植到了Windows能用的形式。如果你的目标是写跨平台代码建议都用Clang试试它能把很多隐藏未定义行为暴露出来。至于选哪个我给个朴素建议做Linux服务端就GCC做Windows桌面就MSVC做跨平台库就把三个都跑一遍CI。别指望一个编译器打天下拿“GCC下编译通过就以为没问题”的思路早晚会在MSVC下爆出一堆警告。3.2 MSYS2、Intel编译器、Fortran和XeLaTeX这些特殊工具怎么配Windows下装GCC最推荐的不是直接下个MinGW压缩包解压而是装MSYS2。MSYS2本质是一个软件包管理器你安装它之后用pacman -S mingw-w64-x86_64-gcc就能装到最新的GCC还能一起装上Make、CMake、gdb这些工具。配置好PATH环境变量把C:\msys64\mingw64\bin扔进去以后在VSCode或者终端里直接敲gcc --version就能验证。很多人卡在配置MSYS2的编译器这一步多数是因为装完GCC后VSCode的tasks.json或c_cpp_properties.json还是指向旧路径。索性用VSCode官方推荐的“C/C Extension Pack”让插件自动探测MSYS2的编译器。实在不自动识别就手动在compilerPath里填C:\\msys64\\mingw64\\bin\\gcc.exe。再说Fortran编译器。科学计算领域Fortran还有大量存量代码常用的是gfortran。Linux下sudo apt install gfortranWindows下可以通过MSYS2安装mingw-w64-x86_64-gcc-fortran。如果你需要用Intel的ifort那是Intel oneAPI工具链里的东西现在面向学生和开源开发者免费装的是基于LLVM的新版ifx。新旧接口有差异老项目的Makefile可能写死了ifort换ifx前自己看一下编译日志。还有XeLaTeX。LaTeX文档编译其实也有一套“编译器”链常见的有pdflatex、xelatex、lualatex。中文论文一般推荐XeLaTeX因为它原生支持UTF-8和系统字体你不再需要一堆转义宏包。装上TeX Live后xelatex是一个可执行文件VSCode里的LaTeX Workshop插件会调用它。要是遇到字体找不到或者中文字符变成乱码先确认你用的是XeLaTeX而不是默认的pdfLaTeX。顺带提一嘴英飞凌TC264这类汽车单片机。它属于AURIX三核架构不是普通的ARM工具链常用TASKING编译器也有人用HighTec或AURIX Development Studio里集成的GCC。连编译选项里都要区分当前编的是TriCore核心还是其他核心调试时还要处理多个程序地址空间。这类嵌入式项目建议直接把厂家推荐的IDE拿来用别自己折腾GCC命令行否则一个链接脚本就能让你怀疑人生。4. 实战中的编译器常见问题与排查技巧4.1 “编译器未包含main类型”到底错在哪这个报错在搜索引擎里高频出现英文常见undefined reference to main或者ld returned 1 exit status。很多新手看到“编译器未包含main类型”就以为是编译器坏了其实编译器委屈得很它明明把源文件编译出目标文件了链接器却找不到程序入口main函数。常见原因有三个。第一你写的函数名根本不是main比如拼成了mian这在中文社区太常见了。第二你确实写了main但源文件没有被编译进链接过程。你可能在编译命令里漏掉了包含main的那个源文件gcc a.c b.c只传了a.c而main在b.c里。第三用IDE开发时项目里有几个源文件但main所在的文件被排除了编译或者它属于另一个构建目标。排查方法很简单。先看编译日志里有没有生成.o或.obj文件再搜索代码里是不是存在int main(void)或int main(int argc, char *argv[])。对于Windows的MSVC环境入口也可能是wmain但资源被系统自动处理了。还有一个隐藏点C编译器对main返回值要求比C更严格C标准里main不写返回值其实是允许的C里很多编译器会警告或收紧。一句话总结报错主体是链接器不是编译器。别看到“compiler”字样就跑去重装母版先把源文件清单和函数签名检查一遍90%能解决。4.2 CH32V在GCC下定义中断函数以及堆空间不足怎么办这个热词应该是从单片机开发场景里来的。CH32V是沁恒基于RISC-V内核的MCU系列很多人用它入门RISC-V。在GCC下定义中断服务函数不能像普通函数一样随便写个void handler()就完事。编译器需要知道这是中断函数这样才能在函数入口保存足够上下文在出口正确执行mret返回指令而不是普通的ret。常见的写法是在函数前加属性void __attribute__((interrupt)) handler(void)。有的RISC-V工具链还支持__attribute__((interrupt(user)))表示用户模式中断具体要看芯片厂商提供的寄存器描述。CH32V的官方例程里经常用__attribute__((interrupt(WCH-Interrupt-fast)))这类厂商自定义属性是为了启用快速中断只保存部分寄存器来提速。所以如果你直接抄AURIX或者ARM Cortex-M的中断写法放到RISC-V GCC下基本编译不过。最稳的办法是去芯片厂家的SDK里找中断向量表按它的ITCM模板写。“编译器的堆空间不足”在嵌入式里有两层含义。一层是编译时堆不足也就是你开了-O2或者模板爆炸GCC自己内存不够了这时可以降低优化等级、减少并行编译任务比如用make -j1或者给系统加交换空间。另一层是链接时给程序分配的堆大小不足那是启动文件和链接脚本里的_heap_size定义太小并非“编译器空间不足”。如果你跑FreeRTOS或者用到malloc但程序一调用分配函数就死机你该改的是链接脚本里的堆栈大小而不是去重新装GCC。4.3 JVM里的编译器有几种Java的即时编译和AOT编译Java编译器和C/C编译器不一样它分成好几个层次。大家最熟悉的是javac它属于前端编译器把.java源码编译成.class字节码。注意字节码不是机器码JVM还得再解释或编译一次才能让CPU执行。JVM内部又有JIT即时编译器。HotSpot虚拟机里常见的C1编译器用于客户端模式或快速启动优化速度要求高的场景用C2编译器。C1叫Client CompilerC2叫Server Compiler名字是历史遗留别被误会。还有面向未来多语言支持的Graal JIT编译器以及JDK 9之后引入的AOT编译器jaotc它能把字节码直接编译成机器码存成共享库避免程序启动时的编译开销。用Java的人如果不去深挖JVM配置看到这些名字容易懵。实际上你最多会碰到这些参数-client、-server、-XX:TieredCompilation。从Java 8开始默认打开分层编译C1先做轻量优化热点方法再交给C2做深度优化。所以你写Java代码时性能和JIT优化有很强的关系一个函数被调用一万次和一次不会被同等对待。这就是“让Java变快”的隐藏机制。学Java的同时补一点编译原理你才能真正理解为什么有人说Java“预热后才快”。5. 编译器优化与AI编译器下一步怎么走5.1 编译器优化三板斧常量传播、死代码消除、内联编译器优化是编译原理里最能体现“收益”的部分。学校讲一大堆数据流分析工程项目里看的是几个核心优化点我管它叫三板斧。第一板斧是常量传播。代码里写了int x 10; int y x * 2;如果编译器能确定x在局部范围内永远是10就会把y直接算成20省掉运行时乘法。再配合常量折叠20代进后续运算循环里的固定表达式能被提前算完。这个优化背后是数据流分析说白了你得证明“x在这里只能等于这个值”。第二板斧是死代码消除。程序员写完代码后经常留下一堆没用的变量和分支比如if(false)块或者一个结果从没被用过的函数调用。编译器分析控制流和数据流发现这段代码对输出没有任何影响干脆删掉。这能直接减小二进制体积还可能让后续优化更顺。注意死代码不等于编译器误删它必须有严格的副作用分析。第三板斧是内联。把短函数调用展开到调用处省去函数调用开销让后续优化看到更大范围的代码。这就像一个团队里沟通成本太高干脆把几个小部门合并成一个部门减少信息传递。但内联不是越多越好滥用会让指令缓存不命中代码体积膨胀。GCC和LLVM内部几十年一直在调内联启发式算法谁不知道“该内联多少”谁的产品就慢。你日常用的-O1、-O2、-O3其实是一组优化pass的组合。-O0不做优化方便调试-O1做基本优化编译速度快-O2是生产环境常用档-O3追求极致性能但可能增加编译时间和代码体积甚至在某些场景反而变慢。嵌入式经常用-Os牺牲一点速度换空间。“编译器优化”不是越高级越好而是按场景选档。5.2 AI编译器是趋势还是噱头适合谁去学去玩“AI编译器”这个词有两层含义很容易被混为一谈。一层是“给AI算法做编译”就是编译神经网络模型、AI框架计算图最终生成CPU、GPU、NPU上的高效代码典型代表有TVM、MLIR、XLA、Triton。这些工具的核心不是传统编译器里那些“变量类型检查”而是把算子融合、内存布局转换、并行调度做到极致。因为AI模型层数多一个个算子单独跑性能损耗太大AI编译器要做的是把多个操作合并成一个大kernel减少内存读写。另一层是“用AI技术辅助编译器开发”比如用机器学习预测分支概率、做寄存器分配决策、优化自动调优。这个方向还在研究阶段但MLIR已经把很多编译器的公共基础设施重新抽象了一遍LLVM社区对AI辅助优化也越来越重视。如果你想入坑建议从TVM或者MLIR入手先学它是怎么把模型表达成计算图再做算子调度。纯粹的手写编译器层面AI编译器还不是替代品它更像是编译器技术换了一个应用场景。关于“AI编译器”的博客CSDN上很多文章其实是在写“大模型代码生成”或者“AI辅助编程”。这个跟真正的AI编译器是两个赛道。你看到“AI编译器“关键词先分辨作者说的是编译AI模型的框架还是用AI写代码的工具。前者是系统底层后者是开发效率工具。两者都很热但知识体系差别很大。写在最后编译原理不是背出来的是踩坑踩出来的我在实战里最深的体会是编译原理的知识点光看书永远记不住一定要开着编译器和终端去碰。遇到undefined reference to main遇到GCC在RISC-V下的中断属性问题遇到Windows下MSYS2路径配置错了遇到JVM明明跑得好好的但首次请求慢半拍这些都是学编译原理的最佳教材。从工作角度说你可能不会去写一个工业级编译器但你会用到编译器会被编译器折磨也会靠理解它的内部机制来排查问题。理解了中间表示你看编译器优化选项就不觉得黑魔法理解了语法分析你写复杂的宏和模板时会更谨慎理解了JIT你写Java性能优化时就有了方向。最后再分享一个小技巧。不管你是用GCC、Clang还是MSVC编译报错时别只看第一行要学会用-Wall、-Wextra、-Werror这些警告选项逼自己把隐患暴露出来然后顺着报错定位到预处理后的文件。我给C项目开-Wall -Wextra -Wpedantic之后编译时间上去了但运行时崩溃少了一大半。编译原理就是这样你在前面多花十分钟理解编译器编译器会在后面用几千小时的高效执行回报你。