标签: 编译器

  • 前端、中端、后端:现代编译器为什么要分层

    作者:lihao技术文章发布:LLVM 与异构编译器 · 第 05 期

    源码写的是x * 2u + 0u,优化后的IR只剩移位和返回,目标汇编又随架构改变。为什么同一个函数会有这些不同写法?它们各自帮助编译器解决什么问题?本文沿着这段短函数,依次观察AST、LLVM IR和两种目标汇编。

    前端先确定源码的语言含义,中端在IR上分析和改写,后端结合目标机器选择实现。这些工作面对的约束不同,因此需要清楚的交接。理解分层以后,调试时就能选择合适的中间表示,先缩小问题,再核对后续输出。

    三层各自负责什么

    层次主要问题在Clang/LLVM中的观察入口
    前端名字指向谁,类型是否成立,语言结构是什么意思Clang诊断、AST、生成的LLVM IR
    中端在既定语义和约束下,怎样分析与改写程序LLVM IR、分析与优化Pass
    后端怎样利用目标指令、寄存器、调用约定和调度约束实现程序目标相关中间表示、汇编、机器指令

    这里的“中端”是常用架构术语,不一定对应一个独立可执行文件。LLVM的“目标无关优化”也不等于从来不看目标:数据布局、合法性、目标代价和目标特性都可能影响优化。三层有接口,也有信息往返。

    从同一个函数观察四种表示

    实验环境:Ubuntu 24.04.4(WSL2,x86-64),Clang/LLVM 18.1.3,GNU binutils 2.42。

    保存twice.cpp。它与第4期使用同一份源码:

    extern "C" unsigned twice(unsigned x) {
        return x * 2u + 0u;
    }

    使用无符号数,是为了让乘法溢出时的模运算规则明确。在本次目标上,unsigned是32位;文章讨论的是这一具体例子,不从它推导所有整数或浮点表达式都能同样改写。

    clang++ -std=c++17 -Xclang -ast-dump -fsyntax-only twice.cpp
    clang++ -std=c++17 -O0 -S -emit-llvm twice.cpp -o twice.O0.ll
    clang++ -std=c++17 -O2 -S -emit-llvm twice.cpp -o twice.O2.ll
    clang++ -std=c++17 -O2 -S twice.cpp -o twice.O2.s

    -fsyntax-only用于前端分析;-Xclang -ast-dump让Clang打印AST。后两种停止方式分别让我们看到优化后的LLVM IR和目标汇编。-O0与-O2的输出比较能展示效果,但不能单凭这一对输出断定某个变化一定由哪一个Pass完成;若要定位具体Pass,还要继续跟踪优化流水线。

    前端:把语言规则变成可处理的结构

    前端必须按C++规则理解x * 2u + 0u。它要解析声明,绑定名字,检查类型,处理隐式转换,再把源语言结构逐步转换为后续阶段可用的表示。AST保留了许多源级结构,因此适合回答“编译器怎样理解了这段源码”。

    本次AST输出中能找到以下节点关系。这里省略地址、位置和其他节点,只保留与计算有关的部分:

    FunctionDecl twice 'unsigned int (unsigned int)'
      ParmVarDecl x 'unsigned int'
      ReturnStmt
        BinaryOperator '+'
          BinaryOperator '*'
            ImplicitCastExpr <LValueToRValue>
              DeclRefExpr x
            IntegerLiteral 2
          IntegerLiteral 0

    这不是机器执行顺序,也不是最终指令清单。它反映的是源码中的加法、乘法、变量引用和转换。例如,源码里确实写了“加0”,AST保留这个结构并不意味着机器运行时一定要执行一条加0指令。

    在Clang中,AST到LLVM IR的生成也属于前端一侧的重要工作。C++重载、类、异常等语义需要按约定降低为IR能表达的形式。语言特性不能因为到了下一层就被忽略;必须在边界上保留其必要语义。

    中端:哪些改写可以共享

    LLVM IR让许多分析和变换不必分别重写一份C++版本、另一份其他源语言版本。它有明确的类型、指令和控制流语义。源语言的结构到达这里后,常已被改写成适合分析的形式。

    在本次-O2生成的IR里,函数体只剩以下两条指令。保留实际SSA编号,省略函数属性和模块信息:

    %2 = shl i32 %0, 1
    ret i32 %2

    %0是参数值,shl i32把32位值左移一位,返回结果。原来的“加0”已经消失。在本例无符号整数的语义下,这个结果与乘2一致,包括超出32位时的模运算行为。

    共享优化的前提是语义正确,而不是公式看起来相像。带符号溢出、浮点舍入、NaN、有副作用的调用、volatile访问和原子操作,都可能限制改写。读到某条IR时,还要注意它的标志和属性;只看指令助记符往往不够。

    中端也不是越优化越好。代码大小、编译时间、目标代价和运行分布之间存在取舍。本例只说明一种实际改写,不据此宣称-O2让整个程序快了多少。

    后端:把运算落实到目标机器

    IR中的左移不要求最终一定出现同名机器指令。后端还要处理指令选择、寄存器分配、调用约定、调度、指令编码等目标相关问题;这些工作也并非都在一个步骤中完成。

    本次x86-64的-O2函数核心是:

    twice:
        leal (%rdi,%rdi), %eax
        retq

    这条lea使用地址计算形式完成加法,不读取该地址中的内存。在当前调用约定和32位返回值语义下,它可以实现本例的乘2。IR里写shl,最终使用lea,恰好说明“IR指令到机器指令”不是简单的一对一翻译。

    再从同一份源码生成AArch64汇编:

    clang++ --target=aarch64-unknown-linux-gnu -std=c++17 -O2 -S twice.cpp -o twice.aarch64.s

    本次生成的核心为:

    twice:
        lsl w0, w0, #1
        ret

    这里仅完成交叉代码生成,没有在AArch64设备上执行,也没有链接完整AArch64程序。这个小函数不包含系统头文件和库调用;完整工程的交叉编译仍需要匹配的目标头文件、库、链接器、ABI与运行环境。不能从“生成了两条汇编”推出移植工作已经完成。

    分层能省掉什么,省不掉什么

    假设有多种源语言和多种目标机器。如果每种组合都独立实现整套编译器,重复工作会很多。共同的中间表示,让不同前端有机会复用一部分分析、优化及后端设施;新增目标也有机会沿用已有前端。

    但这个复用不是无条件拼接。前端与IR需要约定类型、内存、异常、调用等语义;后端与平台需要约定布局、调用约定、运行时和对象格式。IR里出现target triple、数据布局及目标属性,已经说明它并非脱离一切目标条件的“万能可移植文件”。

    在国产或其他异构硬件接入中,能生成一段指令只是其中一环。工具链、运行时、库、调试能力、模型或框架接入,以及正确性回归都影响使用成本。本期先把编译器内部的分工讲清楚,后续再逐层展开这些接入契约。

    四阶段和三层怎样对应

    前一组术语描述工具链构建过程,后一组术语描述编译器内部职责。把它们放在一起时,可以这样读:

    源码
      → 预处理
      → 语言分析 / AST → IR生成 → IR分析与优化 → 目标代码生成
           前端一侧                 中端             后端
      → 汇编 / 目标文件
      → 链接

    这是入门用的职责图,不是Clang进程树。集成汇编器可能与编译器同进程;后端能够直接输出目标文件,不一定先保存.s;LTO会把一部分优化和代码生成推迟到链接时。

    你想检查的问题先保存什么证据
    源码是否按预期解析和检查前端诊断、最小源码、AST
    某个计算在IR阶段是否已消失或改变相同版本与选项下的IR;必要时追踪Pass
    IR运算怎样变成指令和寄存器目标配置、优化IR、机器层表示或汇编
    文件间的定义和引用是否接上目标文件、符号/重定位、完整链接命令

    这张表是选择观察点,不是直接判定谁有错。最终汇编异常,既可能来自后端,也可能是上游已丢失语义,或源码本身违反语言规则。先找变化最早出现的边界,再缩小问题,比凭最终结果给某一层定责更可靠。

    共同IR的复用收益,受什么信息约束

    多前端、多后端共享IR,常被概括为把语言×目标的组合拆成语言侧与目标侧两组工作。这是解释复用机会的结构图,不是工程成本必然从乘法降为加法的定理。不同语言的异常、对象模型和并发语义仍可能要求额外降低与运行时支持。

    假设源语言明确要求某操作按特定精度舍入,前端若降低成允许任意重关联的表达式,中端就可能合法地执行一个对原语言不合法的变换。此时后端再精确,也救不回已经丢失的契约。正确接口不只是数据结构能连上,而是语义承诺能被后续保留。

    信息过早降低也可能损失优化机会。张量布局或跨算子关系若已经展开为难以恢复的标量访存,通用中端未必能重新推断。更高层表示可保留结构,但多一层也增加维护与验证成本;不能把增加IR层数本身当成先进性的证明。

    LLVM代码生成文档说明目标无关框架与目标特定接口共同工作。目标无关描述的是可复用组织,不意味着生成代码无需数据布局、合法类型或目标代价。

    怎样把一个“优化不对”缩成可核查问题

    先选一个有明确输入域的小函数,固定前端参数、目标与IR,再把疑问表述为“变换前后在某输入上的输出不同”或“目标实现不满足已知约束”。“编译器好像变笨了”不能直接构成可定位缺陷。

    若问题是功能错误,首先检查源码与IR是否定义良好、是否人为删掉属性;若问题是性能退化,先确认测量边界与噪声,再看IR和机器层变化。功能正确性与代价模型错误,所需证据不同。

    分层最大的实际价值,是为这些假设选择观察点。它不能保证任何层都可单独优化到局部最好:寄存器压力、内联和向量化可能相互作用。专家分析需要说明所观察边界能回答什么,以及为了回答下一问还缺哪类信息。

    2026-10-18内容修订:深化语义交接、信息保留和跨层取舍。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    来源、验证范围与阅读路线

    本次实际检查了Clang AST、O0/O2 LLVM IR、x86-64和AArch64汇编;第4期同源程序的普通与LTO链接结果已在WSL2的x86-64环境运行。AArch64仅生成汇编,未运行;未做性能、完整交叉工具链或其他前端的验证。原始产物与日志按期归档。

    版本:1.1 · 2026-09-29首次发布,2026-10-07优化开头的例子与分层说明;实验命令和结论不变。

  • 预处理、编译、汇编、链接:用同一个 C++ 程序看清四个阶段

    预处理、编译、汇编、链接:用同一个 C++ 程序看清四个阶段

    lihao · 发布: · 技术文章

    LLVM 与异构编译器 · S1 · 第 03 期

    适合能读基本 C++、会使用 Linux 终端的读者。视频制作中。

    演示范围:Linux x86-64、Clang/LLVM 21.1.8;具体环境与验证边界见文末说明。

    知识体系 / 编译器与 LLVM 全景

    执行下面这条命令时:

    clang++-21 hello.cpp -o hello

    终端只显示一条命令,driver 却组织了预处理、语言分析、代码生成、汇编和链接等工作。把它们全部叫作“编译”在日常交流中没有问题,但调试构建失败时,我们必须知道错误最早发生在哪个边界。

    本文使用同一个 hello.cpp,分别停在预处理、编译、汇编和链接之后,并用四个最小失败例子验证边界。

    一、先给出四阶段地图

    阶段 主要输入 典型输出 主要职责
    预处理 .cpp、头文件、宏、条件编译环境 .ii 展开包含与宏,处理预处理指令
    编译 预处理后的翻译单元 .s,也可观察 .ll/.bc 语言分析、IR、优化与目标代码生成
    汇编 .s 或内部机器码表示 .o 编码机器指令,生成节、符号和重定位
    链接 .o、库、启动文件、链接选项 主程序或库 解析符号、应用重定位、形成最终映像

    Clang driver 的停止选项对应这张地图:

    • -E:预处理后停止;
    • -S:生成汇编后停止;
    • -c:生成目标文件,不链接;
    • 不指定停止选项:通常继续链接。

    二、工具链四阶段不是 C++ 标准翻译阶段的同义词

    C++ 标准也定义“翻译阶段”,涉及源字符、行拼接、token、预处理、字符串字面量拼接和语义分析等语言规则。编译器文档中的四阶段则是工程工具链模型。

    两者相关,但不是一一对应。本文讨论四阶段,是为了观察命令、文件和错误边界,不是重新定义 C++ 标准中的翻译阶段。

    三、实验程序与环境

    #include <iostream>
    
    #ifndef MESSAGE
    #define MESSAGE "Hello"
    #endif
    
    int square(int x) { return x * x; }
    
    int main() {
      std::cout << MESSAGE << ", compiler! square(3)=" << square(3) << '\n';
      return square(3) == 9 ? 0 : 1;
    }

    本文证据包使用 Ubuntu 24.04.3、x86-64、Clang/LLVM 21.1.8 和 GNU binutils 2.42。命令显式指定 -std=c++17 -O0,不启用 LTO、Modules、PCH 或交叉编译。

    先保存环境:

    uname -a
    clang++-21 --version
    clang++-21 -dumpmachine
    ld --version | head -n 1

    版本不是装饰。它决定哪些具体输出可以被称为本次实测。

    四、预处理:从 .cpp 到 .ii

    clang++-21 -std=c++17 -E hello.cpp -o hello.ii

    预处理器处理 #include、#define、#if 等指令。上例中的 MESSAGE 会被替换,<iostream> 相关内容也会进入预处理结果,所以 hello.ii 往往比原文件大得多。

    可以检查:

    wc -l hello.cpp hello.ii
    rg -n "Hello|square" hello.ii | tail

    .ii 仍然是预处理后的 C++,不是 LLVM IR,更不是机器码。它适合排查宏展开、条件编译和包含路径问题。

    典型失败:找不到头文件

    #include "definitely_missing_header.hpp"
    int main() { return 0; }

    没有可用头文件时,预处理无法形成完整翻译单元。后面的语义分析、代码生成和链接不会开始。

    五、编译:语言分析与目标代码生成

    clang++-21 -std=c++17 -O0 -S hello.cpp -o hello.s

    这条命令会经过预处理、词法与语法分析、语义检查、LLVM IR 生成、必要优化和目标相关代码生成,最终输出汇编文本。

    汇编内容不是 C++ 语义的唯一写法。目标架构、ABI、优化级别、Clang 版本与 CPU 选项都会改变它。

    LLVM IR 是额外观察点

    clang++-21 -std=c++17 -O0 -S -emit-llvm hello.cpp -o hello.ll
    clang++-21 -std=c++17 -O0 -c -emit-llvm hello.cpp -o hello.bc
    llvm-as-21 hello.ll -o roundtrip.bc
    llvm-dis-21 roundtrip.bc -o roundtrip.ll

    .ll 是文本形式,.bc 是 bitcode 形式。它们属于 LLVM 工具链中的中间表示,不应被硬塞成 C/C++ 四阶段之外的“第五个标准阶段”。

    典型失败:语法或类型错误

    一个缺右括号或类型不成立的程序可以完成宏展开,但前端无法建立合法 AST。诊断文字会随版本变化,错误所属边界更稳定。

    六、汇编:从 .s 到 .o

    为了明确隔离汇编阶段,直接使用刚才的 .s:

    clang++-21 -c hello.s -o hello.o

    检查目标文件:

    file hello.o
    readelf -h hello.o
    readelf -S hello.o
    readelf -r hello.o
    nm -C hello.o

    在本次 Linux x86-64 环境中,hello.o 是可重定位 ELF。它包含机器指令,也可能包含尚未确定的地址、未解析的外部符号和重定位记录。

    正常执行 clang++-21 -c hello.cpp -o hello.o 时,Clang 可能使用集成汇编器,磁盘上不会先出现 hello.s。因此“汇编阶段存在”不等于“必须启动一个名为 as 的独立进程”。

    典型失败:非法汇编

    把明显无效的助记符交给 x86-64 汇编器,汇编阶段会直接失败。这个例子不经过 C++ 前端,可以清楚地隔离边界。

    七、链接:从目标文件到主程序

    clang++-21 hello.o -o hello

    链接器组合目标文件、启动文件和库,解析符号并应用重定位。使用 clang++ 而不是直接调用 ld 很重要,因为 C++ driver 知道怎样加入当前平台所需的标准库和运行时组件。

    检查最终文件:

    file hello
    readelf -h hello
    readelf -l hello
    readelf -d hello
    ldd hello
    ./hello
    printf 'exit=%s\n' "$?"

    现代 Ubuntu 默认可能生成 PIE,所以 readelf -h 中的类型可能是 DYN。这并不意味着它只是普通共享库。要结合程序头、解释器、入口和系统语义判断。

    典型失败:未定义符号

    int square(int);
    int main() { return square(3) == 9 ? 0 : 1; }

    这段代码中的声明足以让当前翻译单元通过前端检查并生成 main.o。如果链接命令没有提供 square 的定义,链接器才会报告未定义符号。

    这就是“编译通过,链接失败”的最小原因:单个翻译单元成立,不代表整个程序的跨文件符号依赖已经满足。

    补上定义后:

    clang++-21 -c main_link.cpp -o main_link.o
    clang++-21 -c square.cpp -o square.o
    clang++-21 main_link.o square.o -o link_ok
    ./link_ok

    八、错误定位表

    现象 最先检查的边界 进一步工具
    头文件不存在、宏条件异常 预处理 -E、-H、include 搜索路径
    语法、类型、模板实例化错误 前端编译 -fsyntax-only、最小化翻译单元
    非法指令或汇编语法 汇编 单独汇编 .s,确认目标架构
    undefined reference、duplicate symbol 链接 nm -C、readelf -s/-r、链接顺序与库
    程序启动后缺少共享库 装载/运行时 readelf -d、ldd、动态装载器诊断

    最后一行已经越过静态链接边界。把它与链接错误分开,排查会更快。

    九、阶段不一定对应独立程序和磁盘文件

    四阶段模型描述职责,不保证每一步都有独立进程:

    • Clang 可能在一个 -cc1 job 中完成预处理、前端和代码生成;
    • 集成汇编器可能直接生成 .o;
    • driver 根据平台选择系统链接器或 LLD;
    • 中间文件只有在停止或保存临时文件时才落盘。

    使用:

    clang++-21 -### hello.cpp -o hello

    可以观察本次环境的 job 计划,但它不是实际执行日志。要证明命令成功,还需要真正运行。

    十、四阶段模型何时需要扩展

    以下场景会让线性模型变复杂:

    • LTO 在链接时处理 IR,再次优化和生成目标代码;
    • C++ Modules、PCH 和 header units 改变前端输入;
    • 交叉编译引入 target、sysroot、目标头文件和目标库;
    • CUDA/HIP 等 offloading 同时存在 host 与 device 管线;
    • JIT 不以传统静态主程序为唯一终点。

    四阶段仍然是可靠的起点。关键是知道它的适用范围,并在证据出现时扩展模型。

    十一、结论

    预处理解决宏与包含,编译负责语言分析和目标代码生成,汇编把目标相关代码编码进目标文件,链接组合全程序并解决跨文件依赖。

    最实用的习惯是:保存版本,显式停在边界,检查真实文件,用最小失败例子确认错误属于哪里。这样学到的不只是四个名词,而是一套可以长期复用的构建问题定位方法。

    未定义符号:从声明走到重定位的完整解释

    声明int square(int)让前端知道调用表达式的参数与返回类型,却没有在当前翻译单元里提供函数体。编译器可生成调用,并在目标文件保留符号引用与需修补的位置;链接器随后寻找匹配定义。这解释了为什么前端通过与跨文件依赖未解决可以同时成立。

    排查时先比较调用方未定义符号与提供方定义。C++名字修饰通常编码部分类型信息,square(int)和square(double)不是可随意互换的定义;readelf显示原始符号,nm -C有助于阅读,但解码后的显示不是链接器实际按自然语言匹配。

    还要检查提供定义的目标文件是否真的进入链接,归档库成员是否被需要,以及可见性、架构与ABI是否一致。诊断阶段能缩小问题发生处,却不等于根因只在链接器本身。声明与定义签名写错,直到链接时才暴露,也仍是上游接口问题。

    关于重定位所承载的符号、加数和位置关系,可参照ELF通用ABI的Relocation章节;具体计算式还要以目标架构ABI为准。

    把本篇补定义步骤写完整

    前文链接失败例子的main_link.cpp与补回的square.cpp应分别保存,不能把两个main放在同一链接任务里。下面是对应文件的完整最小内容;用于补足阅读与复现步骤,不作为本轮新增运行日志。

    // main_link.cpp
    int square(int);
    int main() { return square(3) == 9 ? 0 : 1; }
    
    // square.cpp
    int square(int x) { return x * x; }

    调用只取3,乘法结果在int范围内;这不为任意输入的有符号平方承诺无溢出。若把测试扩展到大整数,必须先定义合法输入域或改用适当算术,不能让一次溢出把对工具链的比较变成对未定义行为的观察。

    四阶段观察中,重新从hello.cpp执行每个停止选项与显式消费hello.ii、hello.s,是两种不同控制方式。前者展示同一驱动的停止位置;后者更清楚地隔离下一阶段输入。两者若使用不同宏、语言模式或目标参数,产物差异也就不应直接归给某个阶段。

    2026-10-18内容修订:补缺失的square.cpp文件与符号依赖解释;不提升历史实测状态。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    来源与边界

    核心事实来自 Clang、GCC、GNU assembler、GNU ld 和 ELF 规范文档。本文实验限定为 Linux x86-64、Clang/LLVM 21.1.8、本机构建与非 LTO 路径。完整命令、原始日志与哈希随证据包保存。


    阅读路线

    系列导航:上一篇:GCC、Clang、LLVM 到底是什么关系

  • 编译器到底是什么?一段 C++ 代码如何变成可执行程序

    编译器到底是什么?一段 C++ 代码如何变成可执行程序

    编译器到底是什么?一段 C++ 代码如何变成可执行程序

    lihao · 发布: · 更新: · 技术文章

    LLVM 与异构编译器 · S1 · 第 01 期

    适合能读基本 C++、会使用 Linux 终端的读者。视频制作中。

    知识体系 / 编译器与 LLVM 全景

    我们经常用一句话解释编译器:把高级语言翻译成机器码。

    这句话适合作为直觉,却不足以解释真实的工具链。一条看似简单的命令:

    clang++ hello.cpp -o hello

    背后通常包含预处理、前端分析、中间表示、优化、代码生成、汇编和链接。程序真正开始执行之前,操作系统还要完成加载;如果程序使用共享库,动态链接器也会参与。

    编译器把程序转换为目标表示;工具链还包括汇编器、链接器等组件。clang++ 是组织这些工作的 Driver 入口。本文沿着这条命令观察各阶段的职责,运行时装载则单独说明。

    本文采用 Clang/LLVM、传统 C++ 头文件和 Linux 的本机 ELF 目标,不启用 LTO(链接时优化),也不讨论 JIT。其他平台可能采用 Mach-O、COFF 等格式。以下阶段按职责划分,并不要求它们各自运行一个进程。Clang 工具链说明

    读完你应该能回答什么

    1. clang++ 为什么既能报告语法错误,也能报告链接错误?
    2. .ii、.ll、.s、.o 和可执行文件分别是什么?
    3. 目标文件里已经有机器指令,为什么通常仍不能直接运行?
    4. undefined symbol 为什么通常属于链接问题?
    5. 一段 C++ 代码怎样从源码进入进程?

    从 hello.cpp 开始

    创建 hello.cpp:

    #include <iostream>
    
    int main() {
      std::cout << "Hello, compiler!\n";
      return 0;
    }

    构建并运行:

    clang++ hello.cpp -O0 -o hello
    ./hello

    输出:

    Hello, compiler!

    头文件提供声明,也可能包含模板和内联定义。标准库中另一些实现通过链接和运行环境提供。对这个小程序,源码、标准库、目标平台的 ABI 和操作系统都参与了最终结果。

    -O0 便于观察中间产物,但不要把它理解为“完全没有任何转换”。编译器仍然需要完成语义降低和目标代码生成。

    全景图:从 Source 到 Process

    职责 可观察的表示或产物
    预处理 展开后的源码文本 .ii
    前端解析和语义分析 AST
    生成 IR 与优化 LLVM IR,文本 .ll 或 bitcode .bc
    目标代码生成 目标汇编 .s,或直接送入集成汇编器
    汇编 本例的原生目标文件 .o
    链接 可执行文件;带 -shared 等选项时可生成共享库
    执行与装载 进程映像、运行时状态

    这个表是概念模型,不要求每个阶段都对应一个独立进程。Clang 常用集成汇编器直接生成目标文件;链接时也可能调用系统链接器或 LLD。默认构建通常不把 AST、IR 或汇编文本逐一保存到磁盘。Clang 阶段与集成汇编器

    Preprocess:决定前端实际接收哪些 Token

    预处理主要处理:

    • #include:把头文件内容纳入当前翻译单元;
    • #define:展开宏;
    • #if、#ifdef:按条件保留代码区域。

    观察预处理结果:

    clang++ -E hello.cpp -o hello.ii
    wc -l hello.cpp hello.ii
    sed -n '1,80p' hello.ii

    包含 <iostream> 后,输出会显著增大,因为大量声明进入当前翻译单元。不要尝试从头滚动到尾。录屏或调试时,更有价值的是确认宏是否展开、条件分支是否命中、声明来自哪个头文件。

    预处理阶段并不负责完整的 C++ 类型系统。宏展开后的代码仍要交给前端进行语法和语义检查。

    Front End:判断程序在 C++ 规则下是否成立

    词法处理从预处理阶段就已开始。Clang 的解析器请求预处理后的 Token,并与语义分析配合;下表描述职责,不是三个彼此独立的执行阶段:

    工作 输入与输出 主要问题
    Lexing 字符序列到 Token 哪些字符组成关键字、标识符、字面量和运算符
    Parsing Token 到 AST 结构 程序是否符合语法,以及表达式和声明怎样嵌套
    Semantic Analysis AST 与声明到类型化程序 名字查找、类型检查、重载决议、访问控制和模板规则

    只运行到语法和语义检查:

    clang++ -fsyntax-only hello.cpp

    观察 AST:

    clang++ -Xclang -ast-dump -fsyntax-only hello.cpp

    对于包含标准库的程序,AST 同样会很大。可改为观察下文 calc.cpp 的 AST,减少标准库内容。

    例如,将下面的构造例子保存为 broken.cpp:

    int main() {
      const char *text = "hello";
      return text + 1;
    }

    text + 1 指向字符串中的下一个字符,指针运算本身合法;问题是 main 的返回类型为 int,而返回表达式是 const char*,没有所需的隐式转换。这里应检查表达式类型与返回类型的关系,而不是把合法的指针运算当作语法错误。此例按 C++ 类型规则构造,未新增本轮运行记录。C++ 返回语句规则;指针加法规则;Clang 前端实现说明

    常见的 use of undeclared identifier、类型不兼容和重载歧义,通常来自这一层。

    LLVM IR:连接前端、优化器与后端的重要边界

    Clang 可以把 C++ 降低为 LLVM IR:

    clang++ -S -emit-llvm -O0 hello.cpp -o hello.ll

    为了观察简洁的 IR,建议使用不依赖 iostream 的小函数:

    int add(int a, int b) {
      return a + b;
    }

    下面只用于说明函数、加法和返回指令的形式,是手写教学简写,不是上面 -O0 命令的完整输出:

    define i32 @_Z3addii(i32 %a, i32 %b) {
    entry:
      %sum = add i32 %a, %b
      ret i32 %sum
    }

    实际输出通常还带属性、目标数据布局等信息;完整的源码调试信息一般需要 -g。C++ 函数名通常会修饰。本例的有符号加法在 Clang IR 中可能带 nsw,表示相应溢出产生 poison;这里省略它只为认读指令,不把简写作为等价实现或测试预期。LLVM IR 的 add 指令

    LLVM IR 不是编译过程中的唯一表示。它前面有 Token 与 AST,代码生成内部还有更接近目标机器的表示。LLVM IR 也不是要求程序必须解释执行的“虚拟机字节码”;它是一套具有精确定义、适合分析和变换的中间表示。

    Optimize:哪些工作可以在编译期完成

    优化器可能进行常量折叠、删除不可达代码、消除冗余计算、函数内联、循环变换和内存访问优化。

    calc.cpp 内容如下;它用于单独观察,不要和 add.cpp 一起链接:

    int add(int a, int b) {
      return a + b;
    }
    
    int folded() {
      int x = 2 + 3;
      return x * 4;
    }

    比较两个优化级别:

    clang++ -S -emit-llvm -O0 calc.cpp -o calc.O0.ll
    clang++ -S -emit-llvm -O2 calc.cpp -o calc.O2.ll
    diff -u calc.O0.ll calc.O2.ll || true

    diff 检测到差异会返回 1。folded() 的常量求值在 O0 也可能发生,不能据此声称“只有 O2 才会得到 20”。这里只观察代码变化,没有进行性能测量。

    两个语义约束:

    1. 对定义良好的程序,优化必须保持语言和 IR 语义要求的可观察行为。
    2. 如果源码触发未定义行为,编译器可以做出更激进、也更容易让人意外的推导。

    源码行和机器指令通常不会一一对应。代码更短也不必然更快;性能需要在目标机器和实际负载上测量。C++ 抽象机与可观察行为

    CodeGen:把通用表示落实到目标机器

    生成目标汇编:

    clang++ -S -O0 hello.cpp -o hello.s

    代码生成要结合:

    • Target Triple 与 CPU Features;
    • 指令集和合法数据类型;
    • 寄存器、调用约定与 ABI;
    • 指令成本和调度约束。

    LLVM 后端会经历操作合法化、指令选择、寄存器分配、指令调度以及函数序言和尾声等工作。同一份源代码面向 x86-64、AArch64 或 GPU 时,可能需要生成不同 IR。已生成的 IR 可能带有特定数据布局、调用约定和 intrinsic,不能保证原样换目标。

    交叉编译需要兼容目标的 IR、头文件、库和工具配置。Clang 交叉编译说明

    Assemble:目标文件已经含有代码,但仍不是完整程序

    生成目标文件:

    clang++ -c -O0 hello.cpp -o hello.o

    在常见 ELF 环境中,可以观察三类核心信息:

    file hello.o
    llvm-readelf -S hello.o
    llvm-nm -C hello.o
    llvm-readelf -r hello.o
    结构 作用
    Sections 保存代码、只读数据、可写数据、符号表和重定位等信息
    Symbols 记录函数和全局对象等名字,包括当前文件的定义与外部引用
    Relocations 描述需要按符号、位置等信息计算或修补的项,可能由静态或动态链接器处理

    这里的原生目标文件通常已有机器指令,还可能含有未定义符号和重定位。它按可重定位模块组织,不能作为本例的进程镜像直接装载。启用 LTO 时,.o 也可能承载 LLVM bitcode,后缀本身不能证明内容格式。LLVM 链接时优化

    Link:组合翻译单元、库和启动代码

    创建 add.h:

    #pragma once
    
    int add(int lhs, int rhs);

    创建 add.cpp:

    #include "add.h"
    
    int add(int lhs, int rhs) {
      return lhs + rhs;
    }

    创建 main.cpp:

    #include <iostream>
    #include "add.h"
    
    int main() {
      std::cout << "2 + 3 = " << add(2, 3) << '\n';
      return 0;
    }

    分别编译:

    clang++ -c main.cpp -o main.o
    clang++ -c add.cpp -o add.o
    llvm-nm -C main.o | grep add
    llvm-nm -C add.o  | grep add

    main.o 中通常可以看到对 add(int, int) 的未定义引用,add.o 中可以看到它的定义。

    故意漏掉 add.o:

    clang++ main.o -o app

    链接器会报告 undefined reference 或 undefined symbol。前端已经看到 add 的声明,因此允许生成调用;直到链接时,工具链才需要找到兼容定义。

    补回目标文件:

    clang++ main.o add.o -o app
    ./app

    这次运行输出 2 + 3 = 5。链接器读取目标文件和库,选择需要的归档成员、解析符号、安排布局,并应用重定位。

    严谨地说,动态链接程序在静态链接完成后仍可能保留动态重定位和对共享库符号的依赖。动态链接器在装载时处理依赖与重定位;采用延迟绑定时,一部分函数符号可能到首次调用才绑定。不是所有符号都能或都会延迟绑定。Linux 动态链接器说明因此,“成功生成可执行文件”不等于所有地址关系都已经永久写死。

    Load and Run:文件最终成为进程

    可执行文件描述了操作系统如何建立进程映像。以 ELF 为例,Program Header 描述需要映射的 Segment 及其权限。可以查看:

    readelf -l hello

    运行程序时,内核装载可执行文件;若存在程序解释器和共享库依赖,动态链接器继续装载所需对象并处理相关重定位。随后,控制流经过运行时启动代码,在正常的宿主 C++ 程序中最终进入 main;静态初始化也可能发生在 main 之前。

    这也是为什么“编译通过”“链接通过”和“程序能正确运行”是三个不同结论。

    clang++ 是 Driver:用选项观察停止位置

    Clang 官方文档把 clang/clang++ 描述为 Driver。它根据输入文件、目标平台和命令行选项组织底层阶段与工具。

    选项 停止位置 代表产物
    -E Preprocess .ii
    -S -emit-llvm LLVM IR .ll
    -S CodeGen .s
    -c Assemble .o
    本例无停止选项 Link executable;共享库需要 -shared 等选项

    打印 Driver 计划执行的命令,但不真正执行:

    clang++ -### hello.cpp -o hello

    具体输出取决于平台与 Clang 配置。你可能看到 -cc1、集成汇编选项和系统链接器,也可能看到不同的工具路径。这里观察的是 Driver 如何组织流程,不应把某一台机器上的绝对路径当作固定知识。

    错误阶段速查

    阶段 常见现象 优先检查
    Preprocess header not found、宏分支异常 include 路径、宏定义、条件编译
    Front End syntax error、type mismatch、ambiguous call Token、AST、类型、名字与重载
    CodeGen / Assemble unsupported instruction、constraint failure 目标三元组、CPU 特性、后端合法化与约束
    Link undefined symbol、multiple definition 输入目标文件、库顺序、符号签名、ABI
    Load / Run 缺少共享库、非法指令、崩溃或错误结果 动态依赖、部署目标、运行库、ABI 与程序状态

    undefined symbol 也可能由动态链接器报告。应结合执行的命令、发出诊断的工具以及完整上下文判断,不能只匹配报错中的几个词。诊断阶段也不总是根因所在,仍需回查输入与源码。

    代码 · 环境 · 验证范围

    实验复现入口

    环境
    Linux;Clang/LLVM、C++ 标准库及 ELF 查看工具。
    输入与步骤
    从本文 hello.cpp 开始,分别生成预处理文本、LLVM IR、汇编、目标文件与可执行文件。
    验证状态
    教学命令已整理;完整 Clang 流程待实机复核,尚无固定的已验证版本组合。
    复现时保留
    Clang 版本、Target Triple、操作系统、标准库、完整命令与实际输出。

    一组可复现的完整命令

    clang++ --version
    clang++ -dumpmachine
    uname -a
    
    clang++ -E hello.cpp -o hello.ii
    clang++ -S -emit-llvm -O0 hello.cpp -o hello.ll
    clang++ -S -O0 hello.cpp -o hello.s
    clang++ -c -O0 hello.cpp -o hello.o
    clang++ hello.o -o hello
    ./hello
    
    file hello.ii hello.ll hello.s hello.o hello
    llvm-readelf -S hello.o
    llvm-readelf -r hello.o
    llvm-nm -C hello.o
    readelf -l hello
    clang++ -### hello.cpp -o hello

    上述各个停止选项分别从 hello.cpp 生成相应产物,并不是逐行消费上一行文件。其中 clang++ hello.o -o hello 才把已经生成的目标文件交给链接阶段;最后一行 -### 从 hello.cpp 开始打印另一份驱动计划,并不执行。建议在单独目录执行这些命令,并记录工具版本与输出。

    录制或提交问题时,请同时保留 Clang 版本、Target Triple、操作系统、标准库和完整命令。不同环境出现不同输出,通常是工具链条件不同,而不是流水线失效。

    四个容易混淆的边界

    clang++ 与“编译器”

    日常交流中,把 clang++ 叫作编译器命令没有问题。分析系统时,要知道它是 Driver 入口,会组织多个阶段。

    LLVM 与 LLVM IR

    LLVM 是一组编译器和工具链基础设施。LLVM IR 是其中一种核心中间表示。两者范围不同。

    目标文件与可执行文件

    目标文件通常面向链接,仍保留未定义符号和重定位。可执行文件具有可装载布局,但动态链接程序仍可能依赖装载期工作。

    构建成功与运行正确

    编译、链接、加载、运行和结果正确性是不同层级。任何一层通过,都不能自动推出下一层通过。

    回到开头的问题

    clang++ 组织构建,前端理解 C++,后端生成目标代码,汇编器和链接器形成可执行文件。运行文件后,操作系统与运行时负责装载和执行。

    排查问题时,先看最后成功生成了什么,再确定下一步需要哪个组件和哪些输入。对这个例子,缺少 add.o 应先检查链接命令;它不能靠重写头文件中的声明来解决。

    下一期:GCC、Clang、LLVM 到底是什么关系?

    正确性约束的是行为,不是逐字翻译

    若把“翻译”理解成每条源码都对应一条机器指令,就很难解释常量求值、内联和死代码删除。更合适的视角是:在源程序满足语言条件的执行中,目标程序应保留语义要求的可观察行为。中间表示、指令数和控制流可以改变,承担的语义责任不能消失。

    构造一个纯整数例子:在32位无符号模运算下,x*2与x+x相等,因为两者都代表2x模2³²;但两个有副作用的函数调用不能仅凭返回值碰巧相等就合并。函数是否读取外部状态、是否修改内存、是否抛异常等信息,会改变合法变换的条件。

    对于含未定义行为的C++程序,源语言并未承诺所有直觉中的运行结果。排查时应先问输入是否满足程序前提,再比较实现;也不能反过来,用“可能有未定义行为”免除编译器解释证据的责任。应给出触发路径、相关语言规则或最小输入。

    本文手写IR只是认读形式,省略nsw等标志的简写不能作为原C++函数的完整语义证明。IR层的溢出标志、指针与属性是契约的一部分,后续读者可以沿LLVM 18.1的add语义核对;这份版本化参考不改写本文21.1.8历史产物。

    定位根因,要比较最早出现差异的边界

    假设最终程序输出错误,至少存在几类候选:输入本身不满足约定,源程序计算错误,前端降低丢失语义,某次IR变换不合法,后端生成错误,链接或运行环境与目标不匹配。最终汇编有异常,并不直接锁定后端。

    可先固定源码、输入与预期,保存编译参数与实际调用工具;再比较前端输出、变换前后IR与目标产物。若未优化IR执行正确、加入某条管线后错误,证据把范围缩到该管线及其触发条件,但仍需缩小输入并检查上游是否已经存在非法IR。

    对于本篇尚未补齐全命令日志的部分,最诚实的状态仍是教学步骤与已有历史证据并存。本文不把2026-10-18的资料审校登记为新一次21.1.8复跑;它新增的是判断框架。验证命令、输入与输出的缺口继续保留,而非靠更完整文字提高实测等级。

    2026-10-18内容修订与勘误:补行为语义、根因定位及完整的错误类型示例,修正链接命令的指代。首篇网页全命令日志待对齐状态保留;本次为文献与语义审校,未新增实验,首次发布日期、原网址及历史运行版本保持。

    References

    1. Clang Command Guide
    2. Clang Toolchain
    3. Introduction to the Clang AST
    4. LLVM Language Reference Manual
    5. LLVM Code Generator
    6. LLVM Passes
    7. LLD: The LLVM Linker
    8. The ELF, COFF and Wasm Linkers
    9. Executable and Linking Format specification
    10. Linux elf(5)
    11. llvm-readelf command guide
    12. llvm-nm command guide

    更新与勘误

    :新增阅读路线、实验复现入口和订阅导航。技术结论与命令未改动;完整 Clang 流程仍待实机复核。

    2026-09-15:统一环境说明,调整目录和正文排版。编译、链接、装载的职责边界及 LLVM IR 的目标约束已补充说明。

    发现错误时,欢迎在评论区指出具体段落;命令相关的问题请附工具版本、运行命令与关键输出。经确认的技术性修正会记录在这里。

    本文命令以 Linux 上的 Clang 为例,输出会随工具版本与目标平台变化;完整 Clang 流程尚待实机复核。

    阅读路线

    1. 阅读基础基本 C++ 与 Linux 终端

      能读懂 main、函数调用和一条编译命令即可开始。

    2. 当前 · S1 第 01 期编译器到底是什么?

      从源码到可执行程序,建立工具链全貌。

    3. 下一期 · 已发布GCC、Clang、LLVM 到底是什么关系?

      进入第二期,厘清项目、前端、后端、driver、链接器与运行库的边界。