002 编译器基础回顾从源代码到机器码的旅程去年调一个RISC-V交叉编译的bug折腾了三天。现象很诡异同样的C代码用-O2编译跑得好好的换成-Os就随机崩溃。反汇编一看编译器把某个循环里的数组访问优化成了寄存器溢出——它认为那个变量“死”了实际上后面还有一次隐式使用。这种问题不懂编译器后端怎么干活根本无从下手。今天这篇我们回到最基础的东西。不是教科书式的“词法分析-语法分析-语义分析”三段论而是从一个嵌入式工程师的视角重新走一遍从源代码到机器码的完整旅程。MLIR的很多设计思想其实就是在解决传统编译器这条流水线上积累的痛点。预处理你以为你写的C就是编译器看到的C很多人忽略这一步。实际上编译器看到的源代码和你写的可能完全不是一回事。// 你写的#defineBUFFER_SIZE1024staticintbuffer[BUFFER_SIZE];预处理器展开后变成staticintbuffer[1024];就这么简单不。如果你在头文件里嵌套了十几层宏定义或者用了条件编译#ifdef展开后的代码量可能膨胀几十倍。我见过一个项目一个.c文件预处理后生成的文件有8万行——因为每个.c文件都#include了同一个巨大的头文件树。这里踩过坑调试时发现某个变量值不对查了半天结果是宏定义被另一个头文件里的宏覆盖了。预处理器不报错它只是默默替换。所以我现在写嵌入式代码能用const或enum代替宏的地方绝不用#define。词法分析把字符流变成Token流这一步编译器把你的源代码当成一串字符逐个扫描识别出关键字、标识符、运算符、字面量等基本单元。比如int a b 3;会被拆成int - 关键字 a - 标识符 - 赋值运算符 b - 标识符 - 加法运算符 3 - 整数字面量 ; - 语句结束符每个Token还附带位置信息行号、列号这样报错时能告诉你“第42行第5列出错了”。别这样写ab。C标准说这是(a) b但不同编译器解析结果可能不同。这种代码在代码审查时会被直接打回。语法分析构建抽象语法树ASTToken流进来语法分析器根据语言的文法规则构建出一棵树——抽象语法树AST。比如a b 3 * cAST长这样 / \ a / \ b * / \ 3 c注意3 * c先结合因为乘法优先级高于加法。这个树结构已经体现了运算优先级和结合性。AST是编译器前端最重要的数据结构。后续的类型检查、作用域分析、早期优化都在这个树上操作。Clang的AST可以dump出来看非常直观clang-Xclang-ast-dump -fsyntax-only test.c个人习惯调试复杂宏展开问题时我会先看AST。如果AST里出现了预期之外的节点说明宏展开有问题不用浪费时间看后面的优化阶段。语义分析类型检查和作用域解析AST建好了但编译器还不知道a是int还是floatb有没有定义。语义分析阶段做两件事类型检查3 * c如果c是float需要隐式类型转换。如果c是指针编译器报错。作用域解析变量a是在当前函数内定义的还是全局的函数调用foo()对应的函数定义在哪里这个阶段会生成符号表——一张记录了所有变量、函数、类型信息的表格。符号表在后续的代码生成和优化中会被反复查询。踩坑记录C语言允许隐式函数声明不包含头文件直接调用函数编译器会假设返回int。这在嵌入式开发中特别危险——如果函数实际返回float返回值会被截断。所以我现在所有项目都强制开启-Werrorimplicit-function-declaration。中间表示生成从AST到IRAST是树形结构适合做前端分析但不适合做优化。优化需要更平坦、更规整的表示。于是编译器把AST转换成中间表示IR。LLVM IR是典型的三地址码形式每条指令最多三个操作数%1 load i32, i32* %b %2 mul i32 3, %1 %3 load i32, i32* %a %4 add i32 %3, %2 store i32 %4, i32* %a注意这里引入了虚拟寄存器%1,%2等没有物理寄存器概念。IR是**静态单赋值SSA**形式的——每个变量只赋值一次。这听起来很反直觉但正是SSA让很多优化变得简单。为什么MLIR要搞一套新的IR因为LLVM IR太“底层”了。它假设你已经完成了类型系统、控制流、内存模型的所有决策。但在深度学习编译场景下你需要在更高层次上做优化——比如融合两个矩阵乘法或者把一组操作映射到特定硬件单元。LLVM IR看不到这些“高层语义”所以MLIR引入了分层IR的概念允许你在不同抽象级别上表达和优化计算。优化IR的变形记优化是编译器最神奇的部分。它把IR读进来经过一系列变换输出更高效的IR。常见的优化包括常量折叠3 5直接变成8死代码消除删掉永远不会执行的代码循环不变式外提把循环内不变的表达式移到循环外内联把函数调用替换成函数体本身向量化把标量操作合并成SIMD指令每个优化都是一个pass按特定顺序执行。顺序很重要——比如先做常量折叠再做死代码消除效果更好。经验之谈不要迷信-O3。在嵌入式开发中-O2往往是最稳妥的选择。-O3会启用更多激进的优化比如函数内联和循环展开可能导致代码体积膨胀甚至引入bug。我见过-O3把一段正确的代码优化成死循环——因为编译器认为某个循环条件永远为真直接删掉了退出条件检查。指令选择从IR到目标指令优化后的IR还是与目标无关的。指令选择阶段把它映射到目标机器的具体指令。比如%1 add i32 %a, %b在ARM上可能变成ADD r0, r1, r2在x86上可能变成add eax, ebx这个过程涉及模式匹配——编译器维护一个指令模式库把IR操作匹配到最合适的机器指令。复杂的IR操作可能被拆成多条指令或者多条IR操作合并成一条指令比如乘加指令MADD。别这样写依赖编译器“猜”你的意图。比如你想做饱和加法溢出时取最大值直接写a b然后期望编译器识别出这个模式并生成饱和指令——大多数编译器不会这么做。应该用内建函数或intrinsic。寄存器分配虚拟寄存器到物理寄存器IR里用的虚拟寄存器是无限的但物理寄存器只有十几个x86-64有16个通用寄存器ARM有16个。寄存器分配器决定哪个虚拟寄存器放在哪个物理寄存器里放不下的就溢出到内存栈。这是编译器中最复杂的部分之一。好的分配算法如图着色法能显著减少内存访问。差的分配算法会让你的程序频繁读写栈性能一落千丈。调试技巧如果怀疑寄存器分配有问题看生成的汇编代码里有没有大量ldr/strARM或movx86指令操作栈地址。如果有说明寄存器压力太大可以考虑减少局部变量数量或者用register关键字提示编译器虽然现代编译器基本忽略这个关键字。指令调度重排指令提高流水线效率现代CPU是流水线架构——一条指令在执行时下一条指令已经开始译码。如果两条指令有数据依赖后一条需要前一条的结果流水线就会停顿。指令调度器重新排列指令顺序在不改变程序语义的前提下尽量让独立的指令穿插执行减少流水线停顿。比如ldr r0, [r1] ; 加载内存需要等待 add r2, r0, #1 ; 依赖r0必须等 ldr r3, [r4] ; 独立加载可以提前调度后可能变成ldr r0, [r1] ; 开始加载 ldr r3, [r4] ; 同时加载另一个 add r2, r0, #1 ; 此时r0已就绪个人经验手写汇编时我会刻意把独立的内存访问指令穿插在计算指令之间。编译器通常做得比人好但如果你在写关键循环的内联汇编这个技巧能挤出10%-20%的性能。代码发射生成目标文件最后一步把调度后的指令序列编码成机器码加上必要的重定位信息和符号表输出目标文件.o或.obj。目标文件还不是可执行文件。链接器会把多个目标文件和库文件合并解析符号引用分配最终地址生成可执行文件或共享库。踩坑记录链接时出现“undefined reference”错误90%的情况是忘记链接某个库或者链接顺序不对。GCC的链接器是单遍扫描的库应该放在引用它的目标文件之后。比如gcc main.o -lm正确gcc -lm main.o可能报错。回到MLIR的视角回顾整个旅程你会发现传统编译器有几个痛点IR层次单一LLVM IR是底层IR丢失了高层语义信息。做矩阵乘法融合时你得从底层指令反推“哦这几条指令原来是一个矩阵乘法”非常低效。pass顺序固化优化pass的顺序是写死的不同硬件可能需要不同的顺序。目标支持成本高每支持一个新硬件都要重写指令选择、寄存器分配、指令调度等后端模块。MLIR的解决思路是允许多层次IR共存。你可以用Linalg IR表达矩阵运算用Affine IR表达循环嵌套用SCF IR表达控制流最后逐步lower到LLVM IR。每个层次都有对应的优化pass而且pass可以灵活组合。这就像从“一条流水线生产所有产品”变成了“模块化生产线按需组装”。对于AI编译器这种需要频繁对接新硬件、新算子的场景MLIR的灵活性是革命性的。个人经验总结调试编译器问题时先确定问题出在哪个阶段。语法错误看AST类型错误看语义分析性能问题看优化后的IR和汇编。不要一上来就盯着机器码看。学会读IR。LLVM IR可读性很好clang -emit-llvm -S就能生成。MLIR的IR更丰富但基本思路一致。能读懂IR你就掌握了编译器优化的“中间语言”。不要过度优化。编译器在大多数情况下做得比人好。只有在性能关键路径比如视频编码的宏块处理、神经网络的核心算子上才值得手工调优。理解你的目标硬件。不同CPU的流水线深度、寄存器数量、缓存大小差异很大。同样的C代码在Cortex-M4和Cortex-A72上的优化策略完全不同。MLIR不是银弹。它解决了传统编译器在特定领域的痛点但引入了新的复杂性。如果你的项目只是写普通的嵌入式C代码LLVM/GCC完全够用。如果你在做AI编译器、领域特定语言或硬件描述语言MLIR值得深入研究。下一篇我们会深入MLIR的核心概念——Operation、Region、Dialect看看这些抽象如何支撑起“分层IR”的愿景。
002、编译器基础回顾:从源代码到机器码的旅程
发布时间:2026/6/4 1:14:06
002 编译器基础回顾从源代码到机器码的旅程去年调一个RISC-V交叉编译的bug折腾了三天。现象很诡异同样的C代码用-O2编译跑得好好的换成-Os就随机崩溃。反汇编一看编译器把某个循环里的数组访问优化成了寄存器溢出——它认为那个变量“死”了实际上后面还有一次隐式使用。这种问题不懂编译器后端怎么干活根本无从下手。今天这篇我们回到最基础的东西。不是教科书式的“词法分析-语法分析-语义分析”三段论而是从一个嵌入式工程师的视角重新走一遍从源代码到机器码的完整旅程。MLIR的很多设计思想其实就是在解决传统编译器这条流水线上积累的痛点。预处理你以为你写的C就是编译器看到的C很多人忽略这一步。实际上编译器看到的源代码和你写的可能完全不是一回事。// 你写的#defineBUFFER_SIZE1024staticintbuffer[BUFFER_SIZE];预处理器展开后变成staticintbuffer[1024];就这么简单不。如果你在头文件里嵌套了十几层宏定义或者用了条件编译#ifdef展开后的代码量可能膨胀几十倍。我见过一个项目一个.c文件预处理后生成的文件有8万行——因为每个.c文件都#include了同一个巨大的头文件树。这里踩过坑调试时发现某个变量值不对查了半天结果是宏定义被另一个头文件里的宏覆盖了。预处理器不报错它只是默默替换。所以我现在写嵌入式代码能用const或enum代替宏的地方绝不用#define。词法分析把字符流变成Token流这一步编译器把你的源代码当成一串字符逐个扫描识别出关键字、标识符、运算符、字面量等基本单元。比如int a b 3;会被拆成int - 关键字 a - 标识符 - 赋值运算符 b - 标识符 - 加法运算符 3 - 整数字面量 ; - 语句结束符每个Token还附带位置信息行号、列号这样报错时能告诉你“第42行第5列出错了”。别这样写ab。C标准说这是(a) b但不同编译器解析结果可能不同。这种代码在代码审查时会被直接打回。语法分析构建抽象语法树ASTToken流进来语法分析器根据语言的文法规则构建出一棵树——抽象语法树AST。比如a b 3 * cAST长这样 / \ a / \ b * / \ 3 c注意3 * c先结合因为乘法优先级高于加法。这个树结构已经体现了运算优先级和结合性。AST是编译器前端最重要的数据结构。后续的类型检查、作用域分析、早期优化都在这个树上操作。Clang的AST可以dump出来看非常直观clang-Xclang-ast-dump -fsyntax-only test.c个人习惯调试复杂宏展开问题时我会先看AST。如果AST里出现了预期之外的节点说明宏展开有问题不用浪费时间看后面的优化阶段。语义分析类型检查和作用域解析AST建好了但编译器还不知道a是int还是floatb有没有定义。语义分析阶段做两件事类型检查3 * c如果c是float需要隐式类型转换。如果c是指针编译器报错。作用域解析变量a是在当前函数内定义的还是全局的函数调用foo()对应的函数定义在哪里这个阶段会生成符号表——一张记录了所有变量、函数、类型信息的表格。符号表在后续的代码生成和优化中会被反复查询。踩坑记录C语言允许隐式函数声明不包含头文件直接调用函数编译器会假设返回int。这在嵌入式开发中特别危险——如果函数实际返回float返回值会被截断。所以我现在所有项目都强制开启-Werrorimplicit-function-declaration。中间表示生成从AST到IRAST是树形结构适合做前端分析但不适合做优化。优化需要更平坦、更规整的表示。于是编译器把AST转换成中间表示IR。LLVM IR是典型的三地址码形式每条指令最多三个操作数%1 load i32, i32* %b %2 mul i32 3, %1 %3 load i32, i32* %a %4 add i32 %3, %2 store i32 %4, i32* %a注意这里引入了虚拟寄存器%1,%2等没有物理寄存器概念。IR是**静态单赋值SSA**形式的——每个变量只赋值一次。这听起来很反直觉但正是SSA让很多优化变得简单。为什么MLIR要搞一套新的IR因为LLVM IR太“底层”了。它假设你已经完成了类型系统、控制流、内存模型的所有决策。但在深度学习编译场景下你需要在更高层次上做优化——比如融合两个矩阵乘法或者把一组操作映射到特定硬件单元。LLVM IR看不到这些“高层语义”所以MLIR引入了分层IR的概念允许你在不同抽象级别上表达和优化计算。优化IR的变形记优化是编译器最神奇的部分。它把IR读进来经过一系列变换输出更高效的IR。常见的优化包括常量折叠3 5直接变成8死代码消除删掉永远不会执行的代码循环不变式外提把循环内不变的表达式移到循环外内联把函数调用替换成函数体本身向量化把标量操作合并成SIMD指令每个优化都是一个pass按特定顺序执行。顺序很重要——比如先做常量折叠再做死代码消除效果更好。经验之谈不要迷信-O3。在嵌入式开发中-O2往往是最稳妥的选择。-O3会启用更多激进的优化比如函数内联和循环展开可能导致代码体积膨胀甚至引入bug。我见过-O3把一段正确的代码优化成死循环——因为编译器认为某个循环条件永远为真直接删掉了退出条件检查。指令选择从IR到目标指令优化后的IR还是与目标无关的。指令选择阶段把它映射到目标机器的具体指令。比如%1 add i32 %a, %b在ARM上可能变成ADD r0, r1, r2在x86上可能变成add eax, ebx这个过程涉及模式匹配——编译器维护一个指令模式库把IR操作匹配到最合适的机器指令。复杂的IR操作可能被拆成多条指令或者多条IR操作合并成一条指令比如乘加指令MADD。别这样写依赖编译器“猜”你的意图。比如你想做饱和加法溢出时取最大值直接写a b然后期望编译器识别出这个模式并生成饱和指令——大多数编译器不会这么做。应该用内建函数或intrinsic。寄存器分配虚拟寄存器到物理寄存器IR里用的虚拟寄存器是无限的但物理寄存器只有十几个x86-64有16个通用寄存器ARM有16个。寄存器分配器决定哪个虚拟寄存器放在哪个物理寄存器里放不下的就溢出到内存栈。这是编译器中最复杂的部分之一。好的分配算法如图着色法能显著减少内存访问。差的分配算法会让你的程序频繁读写栈性能一落千丈。调试技巧如果怀疑寄存器分配有问题看生成的汇编代码里有没有大量ldr/strARM或movx86指令操作栈地址。如果有说明寄存器压力太大可以考虑减少局部变量数量或者用register关键字提示编译器虽然现代编译器基本忽略这个关键字。指令调度重排指令提高流水线效率现代CPU是流水线架构——一条指令在执行时下一条指令已经开始译码。如果两条指令有数据依赖后一条需要前一条的结果流水线就会停顿。指令调度器重新排列指令顺序在不改变程序语义的前提下尽量让独立的指令穿插执行减少流水线停顿。比如ldr r0, [r1] ; 加载内存需要等待 add r2, r0, #1 ; 依赖r0必须等 ldr r3, [r4] ; 独立加载可以提前调度后可能变成ldr r0, [r1] ; 开始加载 ldr r3, [r4] ; 同时加载另一个 add r2, r0, #1 ; 此时r0已就绪个人经验手写汇编时我会刻意把独立的内存访问指令穿插在计算指令之间。编译器通常做得比人好但如果你在写关键循环的内联汇编这个技巧能挤出10%-20%的性能。代码发射生成目标文件最后一步把调度后的指令序列编码成机器码加上必要的重定位信息和符号表输出目标文件.o或.obj。目标文件还不是可执行文件。链接器会把多个目标文件和库文件合并解析符号引用分配最终地址生成可执行文件或共享库。踩坑记录链接时出现“undefined reference”错误90%的情况是忘记链接某个库或者链接顺序不对。GCC的链接器是单遍扫描的库应该放在引用它的目标文件之后。比如gcc main.o -lm正确gcc -lm main.o可能报错。回到MLIR的视角回顾整个旅程你会发现传统编译器有几个痛点IR层次单一LLVM IR是底层IR丢失了高层语义信息。做矩阵乘法融合时你得从底层指令反推“哦这几条指令原来是一个矩阵乘法”非常低效。pass顺序固化优化pass的顺序是写死的不同硬件可能需要不同的顺序。目标支持成本高每支持一个新硬件都要重写指令选择、寄存器分配、指令调度等后端模块。MLIR的解决思路是允许多层次IR共存。你可以用Linalg IR表达矩阵运算用Affine IR表达循环嵌套用SCF IR表达控制流最后逐步lower到LLVM IR。每个层次都有对应的优化pass而且pass可以灵活组合。这就像从“一条流水线生产所有产品”变成了“模块化生产线按需组装”。对于AI编译器这种需要频繁对接新硬件、新算子的场景MLIR的灵活性是革命性的。个人经验总结调试编译器问题时先确定问题出在哪个阶段。语法错误看AST类型错误看语义分析性能问题看优化后的IR和汇编。不要一上来就盯着机器码看。学会读IR。LLVM IR可读性很好clang -emit-llvm -S就能生成。MLIR的IR更丰富但基本思路一致。能读懂IR你就掌握了编译器优化的“中间语言”。不要过度优化。编译器在大多数情况下做得比人好。只有在性能关键路径比如视频编码的宏块处理、神经网络的核心算子上才值得手工调优。理解你的目标硬件。不同CPU的流水线深度、寄存器数量、缓存大小差异很大。同样的C代码在Cortex-M4和Cortex-A72上的优化策略完全不同。MLIR不是银弹。它解决了传统编译器在特定领域的痛点但引入了新的复杂性。如果你的项目只是写普通的嵌入式C代码LLVM/GCC完全够用。如果你在做AI编译器、领域特定语言或硬件描述语言MLIR值得深入研究。下一篇我们会深入MLIR的核心概念——Operation、Region、Dialect看看这些抽象如何支撑起“分层IR”的愿景。