分类: 技术文章

经过整理的原理说明、源码分析与专题文章。

  • clang、opt、llc、llvm-mc、llvm-objdump、lld 分别做什么

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

    沿着一个可运行的 C++ 例子,检查 Clang、opt、llc、llvm-mc、llvm-objdump 与 LLD 的输入输出,区分驱动、IR 变换、代码生成、汇编和链接。

    先按输入、输出找工具

    前五期已经分清构建阶段、文件格式和编译器分层。真正开始排查问题时,还有一道坎:安装目录里有这么多程序,究竟该运行哪一个?先看手里的输入是什么,再决定停在哪里,比背一串工具名更管用。

    工具本例输入 → 输出要解决的问题
    clang / clang++C/C++ → IR、汇编、目标文件或可执行程序驱动工具链;停止位置由选项决定
    optLLVM IR → LLVM IR,或分析信息单独观察分析与变换
    llcLLVM IR → 目标汇编或目标文件观察目标代码生成
    llvm-mc目标汇编 → 原生目标文件(本例)单独检查汇编器这一段
    llvm-objdump目标文件 → 反汇编等检查结果查看已编码的指令和文件信息
    LLD目标文件、库等 → 链接结果解析符号、布局与重定位;本文使用ELF入口ld.lld

    这不是六个每次都要独立启动的步骤。Clang 驱动通常调用集成的编译、汇编功能,并组织链接;它不会为了完成普通编译而依次启动 opt、llc 和 llvm-mc。下面刻意拆开,是为了得到清楚的观察边界。

    让同一个函数穿过这些边界

    实验环境: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; }

    再准备 main.cpp:

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

    使用 unsigned,是为了让这里的整数回绕有明确定义。extern "C"便于识别符号名,并没有把这份源文件改成 C 语言。先记录工具版本:

    clang++-18 --version
    opt-18 --version
    llc-18 --version
    llvm-mc-18 --version
    llvm-objdump-18 --version

    下面以 LLD保存实际链接器路径。若已安装 Ubuntu 的 lld-18,可用下面的查找方式;否则把变量设为你已安装的 ld.lld绝对路径。本次用的是从 Ubuntu 官方 lld-18 软件包解压出的 18.1.3,不是系统原先就有的命令。

    LLD="$(command -v ld.lld-18)"
    test -n "$LLD" || { echo "请先配置 LLD 路径"; exit 1; }
    "$LLD" --version

    从源码生成 IR,再分别变换、生成汇编和汇编成目标文件:

    clang++-18 -std=c++17 -O1 -S -emit-llvm twice.cpp -o twice.ll
    opt-18 -passes=instcombine -S twice.ll -o checked.ll
    llc-18 -mtriple=x86_64-pc-linux-gnu -filetype=asm checked.ll -o twice.s
    llvm-mc-18 -triple=x86_64-pc-linux-gnu -filetype=obj twice.s -o twice.o
    llvm-objdump-18 -d twice.o
    readelf -h twice.o

    本次 twice.o 是 ELF 可重定位文件;函数反汇编中可以看到 leal (%rdi,%rdi), %eax和返回指令。不同版本、目标和选项可能生成不同指令,不能要求所有机器都与这两行一致。

    clang++-18 -c main.cpp -o main.o
    clang++-18 "-fuse-ld=$LLD" main.o twice.o -o demo
    ./demo
    printf 'exit=%s\n' "$?"
    readelf -p .comment demo

    本次程序退出码为 0,意味着入口函数的检查通过;.comment中也记录了 LLD 18.1.3。这里仍由 clang++ 组织链接,因为它知道怎样为当前平台补入启动文件、标准库和其他默认参数。直接调用 ld.lld 并非不允许,但需要自己提供这些条件。

    LLD 不是“只把字节拼在一起”。符号引用需要找到定义,重定位需要结合最终布局处理;缺库、架构不兼容、ABI 不匹配也不是换一个链接器名称就能自动解决的。LTO 还可能让链接过程参与 IR 优化与代码生成,本文没有把这一条路径混入普通目标文件实验。

    同一个选项,要结合它属于哪个工具理解

    命令停在哪里
    clang++ -E预处理文本
    clang++ -S目标汇编
    clang++ -S -emit-llvm文本 LLVM IR
    clang++ -c目标文件;若启用LTO,仍须检查实际格式
    opt -S把处理后的IR写成文本
    llc -filetype=obj直接生成原生目标文件

    尤其别把 opt -S和 clang++ -S当成同一件事:前者写文本 IR,后者通常写目标汇编。选项名相同,只说明命令行恰好复用了字母。

    clang++-18 -std=c++17 -O1 -### twice.cpp main.cpp -o planned

    -###只打印驱动准备执行的任务,不执行这些任务。本次检查了这个计划,但没有把它记成已经生成了 planned。若想验证产物,要真正运行构建并检查文件。

    三种失败,各有明确的边界

    有意构造的输入本次结果说明
    把 twice.o 交给 opt -S非零退出ELF原生目标文件不是LLVM IR输入
    把无效指令写入 bad.s,交给 llvm-mc非零退出汇编器不接受该目标指令
    链接时只给 main.o,省略 twice.o未定义符号 twice;非零退出声明存在不代表链接时已有定义

    这些失败应保留命令和诊断,不能剪成“工具坏了”。如果 Clang 已经报语法错误,就先修输入;如果只到链接才报未定义符号,就从链接输入、库顺序与符号定义查起。阶段边界能缩小范围,但不是在证据不足时给某一组件定责。

    对生态兼容的实际帮助

    当你接入另一种目标平台时,这张表能变成检查清单:前端能否生成所需 IR;目标后端能否接受它;生成的目标文件是否符合平台 ABI;链接器、运行库和加载环境能否配合。每一项都应有单独的输入、输出和失败例子。

    本次只验证一个 Linux x86-64 小程序。程序能运行不等于完整 C++ 生态兼容,更不代表国产 CPU、GPU 或 NPU 已经接入。它提供的是可以逐层定位问题的办法。

    把工具输出当证据,必须先知道它没有检查什么

    llvm-objdump解码出指令,不等于证明这些字节在运行路径上一定会执行;符号或节中也可能混有数据,反汇编还受目标与特性解释影响。readelf显示对象结构,不执行程序。opt接受IR,不意味着目标后端接受其中全部操作。

    llc输出原生目标文件可以省掉显式.s文件,但并未取消目标选择、编码与对象格式的责任。llvm-mc适合隔离汇编层问题,它也不会替你检查高级语言的类型和业务预期。工具选择的原则是把待检命题交给覆盖该命题的观察方式。

    例如本例反汇编出现lea,可以确认当前目标产物采用这一编码形式;要进一步声称“它比shift快”,还需要目标微架构、上下文与测量。一个助记符本身没有跨硬件通用的性能排名。

    因此建议在记录中同时写输入身份、工具版本、命令、输出与检查目的。只有“截图里出现了文件名”,不够证明命令消费的是正确版本的输入,也不够支持运行与性能结论。

    同一套命令移到另一台机器,先检查哪些依赖

    driver发现的GCC安装、标准库头文件、默认链接器与启动对象可能不同。即使clang++版本一致,sysroot、-target、-march/-mcpu、标准库和链接参数变化,也可能形成不同程序。记录版本号是必要条件,不是完整环境身份。

    编译输出还应明确哪些参数实际作用于前端、后端或链接器。-Wl,一类选项通过driver传给链接器,-Xclang面向编译核心;不能因为同一条命令接收它们,就假定所有阶段看到同样的参数。

    本例使用绝对LLD路径,是为了确定实际选中的链接器;它不保证该路径在别机存在。可复用交付应说明组件版本与发现方法,把本机绝对路径保留为原始日志,避免把路径写成知识本身。关于各工具的准确输入输出,可分别核对opt与llc命令参考。

    2026-10-18内容修订:补工具证据能力及跨机器依赖,不增加设备兼容结论。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

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

    本期实测采用18.1.3;下列版本化文档来自18.1系列的18.1.8文档站,命令输出以本文实测为准。持续更新的官方页面用于补充原理,不据此改写旧版运行结果。

    普通分段链路完成,程序退出0;LLD18.1.3已确认。三项预期失败均非零。-###仅检查计划。没有LTO或异构设备性能测试。

    上一篇 · 下一篇:LLVM IR 到底是什么?为什么需要中间表示 · 文章归档 · 知识体系

    勘误:2026-10-07,初版。发现问题时请附工具版本、命令与关键输出;确认后在此记录修改。

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

    作者: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++ 代码如何变成可执行程序|下一篇:预处理、编译、汇编、链接

  • 编译器到底是什么?一段 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、链接器与运行库的边界。