标签: Clang

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

    作者: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优化开头的例子与分层说明;实验命令和结论不变。

  • .ll、.bc、.s、.o、ELF 分别是什么?它们处在构建流程的哪里

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

    两个文件都以.o结尾,一个能反汇编出机器指令,另一个却能还原成LLVM IR。它们为什么都可能是合法产物?在上一篇观察构建阶段以后,这次我们用同一份源码生成几种文件,逐个核对它们保存了什么、下一步交给谁。

    先分清两个维度:文件在构建中承担什么角色,以及它用什么格式保存内容。.o通常表示目标文件的用途;ELF是一种文件格式。可重定位目标文件可以是ELF,最终可执行程序也可以是ELF。因此,不能把“.ll → .bc → .s → .o → ELF”画成五个必经步骤。

    先把几种产物放到一张图上

    名称本文中的内容先用什么检查
    .llLLVM IR的文本表示文本编辑器;llvm-as检查并转为bitcode
    .bcLLVM IR的bitcode表示llvm-dis;不能按普通文本阅读
    .s目标相关的汇编文本文本编辑器;交给相应目标的汇编器
    .o通常是待链接的目标文件;本例普通编译产物为ELF可重定位文件先用file识别;确认为ELF后用readelf、objdump
    ELF组织头、节、符号、程序段等信息的一种格式readelf;结合文件类型与具体内容判断用途
    twice.cpp
      ├─ 输出 LLVM IR:twice.ll 或 twice.bc
      │                    .ll ⇄ .bc
      └─ 生成目标代码:twice.s → twice.o
                                      + main.o → demo
    
    本例中的 twice.o、main.o、demo 都使用 ELF 格式。

    这张图展示观察点,不要求每个文件都在一次普通构建中落盘。集成汇编器可以直接生成目标文件;IR也常留在编译器内存里。只有要求输出或保存中间结果,才会得到相应文件。

    生成一套可以检查的文件

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

    这里把计算和入口拆成两个文件,目的是在后面看到跨文件符号,而不是引入复杂程序。保存为twice.cpp:

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

    extern "C"给这个函数指定C语言链接,便于在符号表中找到名字twice;函数本身仍由C++编译器处理。再保存main.cpp:

    extern "C" unsigned twice(unsigned);
    
    int main() {
        return twice(21u) == 42u ? 0 : 1;
    }

    先记录工具版本,再生成各类产物:

    clang++ --version
    clang++ -dumpmachine
    readelf --version
    
    clang++ -std=c++17 -O0 -S -emit-llvm twice.cpp -o twice.ll
    clang++ -std=c++17 -O0 -c -emit-llvm twice.cpp -o twice.bc
    clang++ -std=c++17 -O0 -S twice.cpp -o twice.s
    clang++ -std=c++17 -O0 -c twice.cpp -o twice.o
    clang++ -std=c++17 -O0 -c main.cpp -o main.o
    clang++ twice.o main.o -o demo
    ./demo
    printf 'exit=%s\n' "$?"

    本次demo没有终端输出,退出码为0,表示twice(21u)确实得到42。它不是性能测试。前四条编译命令分别从源码生成观察对象,不是让文件依次穿过四次编译。

    file twice.ll twice.bc twice.s twice.o demo

    本次识别结果的关键字段如下;路径、Build ID和其他附加字段未列出:

    文件file识别的关键结果
    twice.llASCII text
    twice.bcLLVM IR bitcode
    twice.sassembler source, ASCII text
    twice.oELF 64-bit LSB relocatable, x86-64
    demoELF 64-bit LSB pie executable, x86-64

    怎样查看.ll和.bc

    .ll适合读和比较。你可以看到函数定义、指令、类型,以及target triple、target datalayout等目标信息。.bc则把IR编码成bitcode,适合LLVM工具传递和保存。它不是“已经编译成CPU机器码的.ll”。

    用与生成文件相匹配的LLVM工具转换。本文安装路径为/usr/lib/llvm-18/bin;其他安装方式请换成实际路径:

    /usr/lib/llvm-18/bin/llvm-dis twice.bc -o twice.from-bc.ll
    /usr/lib/llvm-18/bin/llvm-as twice.ll -o twice.from-ll.bc

    这两条命令本次均成功。它们转换表示形式,不执行程序,也不是把IR转换成汇编。文本往返后注释、编号或打印形式可能变化,不能把文本逐字相同当成唯一正确性标准。

    LLVM IR保留的主要是编译器所需的语义,不是原C++文件的备份。注释通常不会保留,源级结构也可能被降低或改写。版本间读取能力还受bitcode兼容规则限制;分析陌生文件前,先查生成它的LLVM版本。

    .o里除了指令还有什么

    本例普通编译生成的twice.o包含机器指令,但整个文件并不是一串可以从头执行到尾的指令。ELF头和节表告诉工具怎样找到代码、符号以及其他信息。

    readelf -h twice.o
    readelf -S twice.o
    objdump -d twice.o

    本次readelf -h显示类型REL (Relocatable file);节表中能看到.text和.symtab;objdump -d则把代码节中的指令解码成汇编。.text是节名,.s是汇编文本文件的常用扩展名,两者不要混用。

    再看调用方main.o:

    readelf -s main.o
    readelf -r main.o

    符号表里,main有定义,twice的节索引是UND,表示这个目标文件还没有给出它的定义。本次还看到一条指向twice的R_X86_64_PLT32重定位记录。它告诉后续链接过程需要怎样修补相应位置;记录的具体类型与目标架构、调用方式和选项有关。

    因此,main.o编译成功完全可以与twice尚未解析同时成立。等两个目标文件交给链接器,跨文件的定义和引用才能结合起来处理。这也是目标文件通常不能直接当作主程序运行的原因之一。

    ELF不等于可执行程序

    ELF是格式,文件头里的类型才进一步说明角色。常见的ET_REL用于可重定位文件,ET_EXEC用于可执行文件,ET_DYN用于共享对象;现代Linux的位置无关可执行程序(PIE)也采用ET_DYN。

    readelf -h twice.o
    readelf -h demo

    这次twice.o显示REL,而已经成功运行的demo显示DYN (Position-Independent Executable file)。所以,看到DYN就断言“这只能是普通共享库”也不成立。需要结合程序头、入口、解释器及装载方式判断。

    ELF也不是所有平台的统一目标文件格式。Windows原生工具链常用COFF/PE,macOS常用Mach-O。本机虽运行Windows,但上述命令在WSL2的Linux环境执行,所选目标是x86_64-pc-linux-gnu。决定输出不能只看宿主系统的名字,还要看工具链的目标配置。

    同样是.o,LTO会改变什么

    启用Clang的链接时优化后,再生成一个文件:

    clang++ -std=c++17 -O0 -flto -c twice.cpp -o twice.lto.o
    file twice.lto.o
    /usr/lib/llvm-18/bin/llvm-dis twice.lto.o -o twice.lto.ll

    本次file识别为LLVM IR bitcode,llvm-dis也成功读出IR。它名为.o,却不是前面的原生ELF可重定位文件。对它运行readelf -h得到:

    readelf: Error: This is a LLVM bitcode file - try using llvm-bcanalyzer

    这是工具与格式不匹配的预期结果,不足以说明文件损坏。链接时还需要能处理LLVM bitcode的工具链。本次使用现有GNU链接器及可用的LLVM LTO插件完成了以下链接:

    clang++ -flto twice.lto.o main.o -o demo-lto
    ./demo-lto
    printf 'exit=%s\n' "$?"

    退出码同样为0。别的环境若缺少相应插件或使用不匹配的链接器,可能在这里失败。本文不把这一条Clang路径外推为所有编译器、所有LTO模式的统一文件格式。

    实际排查时,可以按这个顺序做:找生成命令和版本 → 用file识别 → 选择对应格式的工具 → 检查符号与链接要求。重命名文件只改名字,不会把bitcode变成ELF,也不会把可重定位文件变成可执行程序。

    符号与重定位:为什么“已经有指令”仍不够

    构造一个PC相对引用的概念例子:目标符号最终地址为S,重定位位置为P,记录给定的加数为A。某类重定位要求写入与S+A−P相关的值。对象文件生成时,S或最终布局尚未确定,因此保留一项待后续处理的请求。

    这个式子帮助区分三件事:符号表描述名字与定义,重定位记录描述哪个位置按何种规则处理,指令字节是被编码或待修补的对象。它们协作,却不是同一个表。具体位宽、溢出检查、PLT/GOT规则由架构ABI确定,不能把概念式视为所有重定位的统一公式。

    ELF通用ABI在Relocation章节给出记录的通用结构。本文x86-64的R_X86_64_PLT32观察应按对应架构语义理解,不外推为其他平台的调用编码。

    这也解释了为何反汇编器显示的调用目标可能只是暂定形式,真正含义需要与重定位一起读。只截取objdump中的一行,忽略对象类型和重定位,就可能把尚未完成的地址解释成错误机器码。

    格式能读取,与产物能互用,中间还有哪些条件

    llvm-dis能够读取bitcode,只证明该读取器理解其编码与对应IR;它不保证目标后端支持模块里所有操作。readelf能够列出ELF节,也不保证当前机器具备目标ISA或运行库。每一种工具都只检查它负责的边界。

    位码还可能带目标数据布局、地址空间与intrinsic依赖。把扩展名从.o改成.bc,最多帮助某些工具选择入口,不会清除这些约束;把bitcode包进原生对象容器,也不会自动让普通链接器完成其中的代码生成。

    实际交付可附一个最小产物清单:生成工具与参数、识别格式、目标、预期消费者、必需插件或运行时、可复现检查。它比只有文件名更能帮助另一台机器恢复正确路径。本文普通编译与LTO的既有运行结果保持;本轮只补充对产物契约的解释。

    2026-10-18内容修订:深化重定位及消费者契约,避免后缀/可读/可运行之间跳跃。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

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

    本文实测包括普通编译产物、IR/bitcode转换、ELF头/节/符号/重定位观察、LTO文件识别,以及普通和LTO两种链接结果运行。保存了源码、命令、完整日志与产物哈希。没有测量性能,也没有在Windows原生目标或其他架构上运行本例。

    版本:1.1 · 2026-09-29首次发布,2026-10-07以两种.o产物补清开头问题;实验命令和结论不变。

    独立问题:为什么 .o 文件不一定是机器码?(知乎,围绕普通编译与LTO展开)。

  • 预处理、编译、汇编、链接:用同一个 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 到底是什么关系

  • GCC、Clang、LLVM 到底是什么关系

    GCC、Clang、LLVM 到底是什么关系

    lihao · 发布: · 技术文章

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

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

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

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

    如果把 GCC、Clang、LLVM 简单画成“GCC 是编译器,Clang 是前端,LLVM 是后端”,很快就会遇到矛盾:GCC 难道没有后端?LLVM 只有后端吗?clang++ 为什么还能负责链接?使用 Clang 时,为什么错误信息里会出现 GCC 安装路径?

    问题不在这些名字太复杂,而在于我们混合了几个不同层次:项目、命令行 driver、语言前端、优化与代码生成基础设施、链接器,以及标准库和运行时。

    本文先厘清组件关系,再结合命令观察其边界。计划输出、历史实测与本次资料补充分别说明。

    一、先给出最短答案

    • GCC 是 GNU Compiler Collection,一套完整的编译器系统。它有多语言前端、自己的中间表示与优化,也有多目标代码生成后端。
    • LLVM 是一个大型编译器与工具项目。LLVM Core 提供 LLVM IR、分析、优化和目标代码生成等基础设施;整个 LLVM Project 还包含 Clang、LLD、libc++、compiler-rt、MLIR 等子项目。
    • Clang 是 LLVM Project 中面向 C 语言家族的前端与工具基础设施,同时提供 clang、clang++ 和 clang-cl 等 compiler driver。

    所以,GCC 与“Clang + LLVM Core”常常是可以比较的两套编译器实现;Clang 与 LLVM 则是同一项目生态中不同边界的组件。这个说法仍是简化,但已经避免了最常见的错误。

    二、五个层次不能混在一起

    层次 GCC 生态中的常见对象 LLVM 生态中的常见对象
    项目 GNU Compiler Collection LLVM Project
    命令行 driver gcc、g++ clang、clang++、clang-cl
    语言前端 GCC 的 C、C++、Fortran 等前端 Clang、Flang 等
    优化与代码生成 GCC 自身 IR、优化 pass 与后端 LLVM IR、优化 pass 与目标后端
    链接器、库与运行时 GNU ld、libstdc++、libgcc 等 LLD、libc++、compiler-rt、libunwind 等

    这张表表达归属,不代表工具必须整套绑定。Linux 上常见的一种组合是:Clang 解析 C++,LLVM 完成主要优化和代码生成,Clang 使用集成汇编器生成目标文件,最后调用系统链接器,并链接 GNU libstdc++。

    这不是异常组合,而是工具链可组合性的正常结果。

    三、GCC 不只是一个 C 编译器

    GCC 最初的名字与 C 编译器有关,后来扩展为 GNU Compiler Collection。GCC 官方首页列出的范围包括多种语言前端与相应的库。更重要的是,GCC 自己拥有从语言前端到目标代码生成的完整内部体系。

    gcc 和 g++ 命令也首先表现为 driver。它们解析选项,根据输入文件后缀和停止选项安排预处理、编译、汇编与链接。GCC 手册明确说明:

    • -E:完成预处理后停止;
    • -S:完成编译并输出汇编后停止;
    • -c:生成目标文件,但不链接;
    • 不指定这些停止选项时,driver 通常继续链接。

    因此,“GCC 是前端,LLVM 是后端”并不是 GCC 的实际结构。GCC 有自己的前端、中间表示、优化器和目标后端。

    四、LLVM 不等于一个叫作 LLVM 的编译器命令

    LLVM 官网把 LLVM 描述为一组模块化、可复用的编译器与工具链技术,并列出多个子项目。日常对话里的“LLVM”可能指两个范围:

    1. LLVM Project:整个项目与社区,包括 Clang、LLD、libc++、compiler-rt、MLIR 等;
    2. LLVM Core:LLVM IR、分析与优化库、代码生成后端、目标描述和相关工具。

    这也是为什么“LLVM 是后端”只适合作为非常粗糙的入门口号。LLVM Core 不只有最终机器码生成;LLVM Project 的范围更不止后端。

    另外,LLVM 这个名字今天是项目名称,不应继续按早期缩写把它解释成一台传统“低级虚拟机”。LLVM IR 可以用于静态编译、JIT、分析和变换,但它不是 Java 虚拟机那种运行时产品的同义词。

    五、Clang 既是前端项目,也提供 driver

    Clang 官网给出的定位很清楚:它为 C、C++、Objective-C、OpenCL 和 CUDA 等 C 语言家族提供语言前端与工具基础设施,并服务于 LLVM Project。

    作为前端,Clang 负责:

    • 词法、语法和语义分析;
    • 建立 AST;
    • 诊断语言错误;
    • 向 LLVM IR 等低层表示降低;
    • 支撑 clangd、clang-tidy、格式化、重构和静态分析工具。

    作为用户命令,clang 与 clang++ 又是 driver。Driver 决定要运行哪些 job,把参数传给编译核心、汇编器和链接器,并选择目标平台所需的启动文件与库。

    所以“Clang 是前端”与“clang++ 会调用链接器”并不矛盾。前一句描述项目和编译阶段,后一句描述 driver 命令。

    六、使用 Clang,不代表全部组件都来自 LLVM

    下面的命令很有价值:

    clang++-21 -v -x c++ -E /dev/null -o /dev/null

    它会显示 Clang 版本、目标 triple、候选或选中的 GCC 安装,以及头文件搜索路径。在很多 Linux 发行版上,Clang 默认复用 GNU 工具链提供的 C++ 头文件和 libstdc++。

    再看链接计划:

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

    -### 只打印计划,不执行。输出可以回答:Clang 使用集成汇编器还是外部汇编器?准备调用哪个链接器?加入了哪些启动文件和库?这些答案与发行版、安装方式和命令参数有关。

    如果明确选择 LLD,可以写:

    clang++-21 -std=c++17 -O0 -fuse-ld=lld hello.cpp -o hello-lld

    类似地,-stdlib=libc++ 和 -rtlib=compiler-rt 可以改变标准库或 compiler runtime 的选择,但前提是相应组件已经正确安装。不要把一个选项名称当成环境已经满足的证据。

    七、一个更准确的职责图

    C++ source
        │
        ▼
    clang++ driver ── plans jobs, forwards options, selects tools and libraries
        │
        ├── Clang front end ── tokens, AST, semantic checks
        │
        ├── LLVM Core ── LLVM IR, optimization, target code generation
        │
        ├── integrated assembler or external assembler ── object file
        │
        └── system linker or LLD ── executable/shared library
                               │
                               └── libstdc++ or libc++, runtimes, system libraries

    GCC 也有对应的完整路径,只是内部表示、组件边界和实现不同。它不是这张图左边缺失的部分。

    八、到底应该比较谁

    工程中常见的“GCC vs LLVM”其实不够精确。应先确认比较对象:

    • 比较 C++ 编译器实现:GCC 与 Clang/LLVM;
    • 比较链接器:GNU ld、gold、LLD 或 mold;
    • 比较 C++ 标准库:libstdc++ 与 libc++;
    • 比较 compiler runtime:libgcc 与 compiler-rt;
    • 比较调试器:GDB 与 LLDB;
    • 比较 IR 或优化框架:GIMPLE/RTL 与 LLVM IR/LLVM pass。

    再根据语言支持、目标架构、ABI、诊断、构建速度、生成代码、工具集成、许可证和维护成本做选择。

    九、常见误解

    误解一:Clang 就是 LLVM

    Clang 属于 LLVM Project,但 Clang 与 LLVM Core 不是同一个组件。

    误解二:LLVM 只有后端

    LLVM Core 包含 IR、分析、优化和代码生成;LLVM Project 还包含大量独立子项目。

    误解三:GCC 只负责前端

    GCC 是完整编译器系统,拥有自己的中间表示、优化和后端。

    误解四:使用 Clang 就一定使用 libc++ 和 LLD

    不一定。Linux 上常见默认组合是 Clang 加 GNU libstdc++ 和系统链接器。必须看 -v、-### 和实际文件。

    误解五:GCC 兼容意味着行为完全相同

    Clang driver 的重要目标是兼容 GCC 命令行和构建系统,但这不等于所有扩展、诊断、ABI 细节或未定义行为上的逐项一致。

    十、结论

    区分 GCC、Clang 和 LLVM 的最好方法不是背一句口号,而是先问当前讨论处在哪一层:项目、driver、前端、优化与代码生成、链接器,还是库与运行时。

    GCC 是完整编译器系统。LLVM 是包含核心基础设施和多个子项目的工程。Clang 是其中的 C 语言家族前端与工具基础设施,也提供用户直接调用的 compiler driver。实际工具链可以跨项目组合,最终选择必须以真实命令和环境为准。

    下一篇将使用同一个 hello.cpp,逐步生成预处理结果、汇编、目标文件和最终程序,并用四个失败例子把阶段边界真正钉牢。

    工具链可组合,为什么不等于任意二进制都兼容

    Clang能使用GNU提供的头文件与库,只说明某些组件边界有共同约定。真正的可组合性还要覆盖目标架构、对象格式、符号命名、调用约定、类型布局、异常处理和运行时依赖。任何一个名称相同,都不足以证明整条链路兼容。

    例如C++类布局或异常跨模块传播,不能只靠extern “C”解决。后者影响所声明实体的语言链接,不会把类对象布局、内存分配器和异常运行时变成统一的C契约。即便链接器找到了同名函数,调用两端对参数与对象的解释仍可能不同。

    还要区分标准库实现与ABI支持组件。libstdc++和libc++提供C++标准库;异常、栈展开与编译器辅助运算涉及其他运行时组件。Clang工具链说明将这些职责分别列出,具体组合仍需按平台核对。

    因此一份兼容声明应列明编译器、标准库、ABI/展开组件、链接器、目标及版本,说明支持哪些跨模块场景。仅有“Clang编译成功”,还没有覆盖与另一工具链产物混合、部署到另一系统或升级运行库的风险。

    怎样设计一份有意义的GCC与Clang比较

    先固定问题:比较诊断质量、编译耗时、产物大小还是运行性能?若每项都用不同源码、选项与库,就无法把差异归因于编译器实现。公平比较不是机械采用同名优化选项,而是说明语义要求、目标能力与允许的优化范围。

    一个可执行的比较记录应包含相同源码提交、输入分布、语言模式、目标CPU特性、标准库与链接器,另列各工具的完整参数。编译时间区分冷缓存、增量构建与链接阶段;运行表现区分启动、稳态和测量噪声。一个小函数的汇编好看,不足以替代应用基准。

    若目的是测试头文件选择,用-v的预处理输出即可;若目的是测试链接组合,需要实际产物与运行;若目的是性能,则必须测量。这些是不同实验,不能把其中一项成功登记为整体胜出。本文没有新增GCC/Clang性能排名。

    2026-10-18内容修订:补二进制兼容契约及可比实验设计,避免项目名比较代替机制比较。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    适用范围与来源

    本文以 Linux、x86-64、GCC 13.3.0 与 Clang/LLVM 21.1.8 为演示环境。具体路径与默认选择不具有跨发行版保证。核心事实来源于 GCC、LLVM 与 Clang 官方文档;完整来源与原始实测日志随本文证据包保存。

    知乎独立文章:GCC、Clang、LLVM 到底是什么关系?一文理清编译器工具链


    阅读路线

    系列导航:上一篇:编译器到底是什么?一段 C++ 代码如何变成可执行程序|下一篇:预处理、编译、汇编、链接