C / C++ Build Systems · Mental Model

从一条构建命令,走到二进制世界

一条 CMake 命令背后,编译器、ABI、标准库、CRT 与链接器各自参与其中。本文沿着源码变成程序的路径逐层拆开它们,再回到 Windows 上的真实组合:为什么 Clang 可以使用 Microsoft 运行时,哪些边界需要逐项验证。

CLANG-CLMSVC ABILIBC++UCRT / VCRUNTIME/MT · /MD下载配套 XMind ↓
ORCHESTRATECMake / Batch
BUILDNinja / MSBuild
COMPILEClang 17 · clang-cl
C++ LIBChromium libc++ · std::__Cr
C RUNTIMEMicrosoft CRT · /MT
INTERACTIVE MAP

C/C++ 构建全景思维导图

我曾经也习惯把 CMake、编译器、运行库统称为“工具链”,直到一次二进制兼容问题迫使我逐项核对,才发现这个词把太多责任压进了一个盒子。这里是原 XMind 的网页化重绘版:它保留“CMake → 原生构建系统 → 二进制工具链”的主干,并把最容易被一句“MSVC 工具链”掩盖的内部组件全部展开。

100%
点击含 ± 标记的节点可折叠;键盘聚焦后按 Enter/Space 亦可操作。下载可编辑 XMind 源文件 ↓
为什么不直接 iframe 嵌入 XMind

.xmind 本质上是包含 JSON、资源与元数据的 ZIP 文档格式,浏览器没有原生渲染器。网页重绘能离线工作、响应主题、可搜索且无需把文件上传到第三方服务;原文件仍作为可编辑附件保留。

CHAPTER 01

把“工具链”从一个盒子拆成零件

原来的三层模型——CMake、构建系统、工具链——是正确的,但最下面的“工具链”仍然太大。真正遇到二进制兼容问题时,必须继续拆。

META BUILD
元构建系统

读取 CMakeLists.txt,生成下一层认识的构建文件。

CMake
BUILD TOOL
构建工具

判断依赖和增量变化,按顺序调度编译、链接、复制、打包。

Ninja · MSBuild
COMPILER
编译器与驱动

预处理、语义分析、优化并生成目标文件;驱动还负责拼装默认参数。

clang-cl · cl
BINARY CONTRACT
目标格式与 ABI

规定目标文件格式、调用约定、类布局、名字修饰、异常模型。

COFF · MS ABI
LIBRARIES
标准库与运行时

提供 std::vector、malloc、异常展开、启动代码与编译器辅助函数。

libc++ · UCRT
LINKER
链接器

解析符号、重定位地址,把 .obj 与 .lib 组合为 EXE 或 DLL。

lld-link · link
新的判断方式

不要只问“这个库是不是 MSVC 编的”,而要分别问:目标架构、对象格式、ABI、标准库、CRT、编译选项和依赖是否兼容。

术语必须精确到组件

DRIVERclang-cl ≠ LLVM 全家桶

它是驱动程序:解析参数、选择 target、调用 Clang/LLVM 阶段并组织默认库。

TOOLSETMSVC 不只是 cl.exe

语境中可能指编译器、ABI、VC Tools、STL、VCRuntime、链接器或整套 Visual Studio 环境。

RUNTIME“运行时”是集合名

必须继续区分 UCRT、VCRuntime、C++ 标准库以及 compiler-rt builtins。

CHAPTER 02

一份源码如何变成可执行程序

01源文件 .cpp
02预处理与编译
03目标文件 .obj
04链接库与符号
05EXE / DLL
CMakeLists.txt
   │  CMake -G Ninja
   ▼
build.ninja
   │  Ninja 调度
   ├── clang-cl /c a.cpp  ──► a.obj
   ├── clang-cl /c b.cpp  ──► b.obj
   └── lld-link a.obj b.obj libc++.lib ... ──► app.exe

CMake 不编译代码,Ninja 也不理解 C++ 语义。CMake 生成规则,Ninja 执行规则,编译器生成目标文件,链接器才把它们组装起来。

CHAPTER 03

编译器负责翻译,运行时负责支撑

COMPILER

编译器

把语言结构翻译成机器指令。例如看到函数调用时,生成“按照某种调用约定跳过去”的代码。

RUNTIME

运行时

提供现成的函数和基础设施,例如内存分配、进程启动、异常支持和安全检查。

// 源代码
void* p = malloc(128);

// Clang 的任务(概念化)
// 1. 按 Microsoft x64 调用约定准备参数
// 2. 生成对 malloc 符号的调用

// 链接与运行时的任务
// 3. 从 Microsoft CRT 找到 malloc 的实现
// 4. 在程序运行时真正执行内存分配

编译器并不需要亲自实现 malloc。只要它生成的调用方式、符号和目标文件格式与运行时遵守同一份合同,就可以使用那个运行时。

CHAPTER 04

ABI:二进制世界里的合同

Clang 生成的 .objCALLER / PRODUCER
同一份
ABI 合同
MSVC 生态的库CALLEE / CONSUMER

API 是源码层面的接口,ABI 是编译以后仍必须一致的规则。ABI 通常包括:

ABI 项目不一致的结果
调用约定参数读错位置、栈失衡或直接崩溃
名称修饰链接器找不到函数,出现 unresolved external
类与结构体布局同一段内存被解释为不同字段
异常与 RTTI异常无法跨模块传播,类型识别失败
目标文件格式链接器无法读取目标文件

Clang 在 Windows 的 MSVC target 下会生成 COFF,并努力兼容 Microsoft C++ ABI;clang-cl 还提供与 cl.exe 相似的命令行界面。这就是混用成为可能的基础。

Target triple 决定“面向哪个二进制世界”

x86_64-pc-windows-msvc
│      │  │       └── ABI / 环境:Microsoft
│      │  └────────── OS:Windows
│      └───────────── Vendor:pc(通常不是兼容判断重点)
└──────────────────── Architecture:x86-64

x86_64-w64-windows-gnu   // 同样是 Windows,但属于 MinGW/GNU 环境

“都由 Clang 编译”或“都运行在 Windows”都不足以证明兼容。至少还需确定环境字段、对象格式、运行时选择以及库自身的 ABI 配置。

接口类型决定跨模块风险

跨模块内容主要契约风险判断
extern "C" int f(int)C 调用约定、整数宽度、导出名较低,仍需核对架构和 calling convention
固定布局 C 结构体字段宽度、对齐、packing、版本可控,建议携带 size/version
C++ 类、虚函数、RTTI完整 C++ ABI、编译器特性与布局高
异常跨 DLL异常对象、unwind、handler、CRT高,需同生态验证
std::string/vector标准库实现、ABI namespace、allocator、配置宏极高,不同 STL 不可直接互传
CHAPTER 05

“运行库”不是一个库:至少拆成三类

类别提供什么常见实现典型符号
C++ 标准库容器、算法、字符串、流、线程抽象MSVC STL、libc++、libstdc++std::vector、std::string
C / 平台运行时启动、堆、stdio、时间、locale、异常底座UCRT、VCRuntime、glibc、muslmalloc、printf
编译器运行时编译器生成的低层辅助操作、sanitizer 支撑compiler-rt、libgcc整数运算 helpers、builtins
最常见的概念误判

Clang 不等于 libc++。 Windows 上的 clang-cl 默认经常搭配 MSVC STL。更换编译器,并不自动更换 <string> 的实现。

现代 Microsoft CRT 的实际拆分

组件职责/MT 静态侧/MD 动态侧
UCRTISO C、POSIX/Microsoft 扩展、stdio、heap 等通用 C 能力libucrt.libucrt.lib → ucrtbase.dll
VCRuntime程序启动、C++ 异常、RTTI、运行时检查、编译器相关支撑libvcruntime.libvcruntime.lib → 版本化 DLL
MSVC STL微软的 C++ 标准库实现libcpmt.libmsvcprt.lib → msvcp*.dll
compiler-rtLLVM 可能隐式调用的 builtins、sanitizer 等编译器辅助能力clang_rt.*;它补充而不是替代 UCRT/VCRuntime

工程口语中的“MSVC runtime”有时泛指前三项,但排错时必须说出具体库名。尤其是 _CxxFrameHandler3 属于异常处理支撑,而 std::string 属于 C++ 标准库;它们不是同一层问题。

CHAPTER 06

Windows 上这些组件可以怎样组合

编译器C++ 标准库C / 平台运行时说明
cl.exeMSVC STLUCRT + VCRuntimeVisual Studio 默认组合
clang-clMSVC STLUCRT + VCRuntime常见组合,替换编译器前端与代码生成
clang-cllibc++UCRT + VCRuntime可行,但必须明确控制头文件、库和 ABI 配置
MinGW Clang/GCClibstdc++ 或 libc++MinGW-w64 CRT属于另一套 Windows GNU 生态,不能只看“也是 Windows”

构建组合实验台

切换四个维度,观察它们各自负责什么。结果是心智模型演示,不代替实际 ABI 验证。

CHAPTER 07

你的 Chromium / client.dll 案例

这套交付包不是“Clang 使用整套 MSVC C++ 库”,而是把多家组件拼成一套满足 Chromium 二进制约束的组合。

build_client.bat
      │
      ├── 编译器:clang-cl 17
      ├── 目标:Windows x86 / COFF / Microsoft ABI
      ├── C++ 标准库:Chromium 同版本 libc++
      │                 ABI namespace = std::__Cr
      ├── C / 平台运行时:Microsoft CRT,/MT
      ├── 编译器辅助运行时:clang_rt.builtins-i386.lib
      └── 链接器:lld-link
                         │
                         ▼
                  client.lib + client.dll

为什么 std::__Cr 很重要

标准只规定源码里写 std::string,并不规定某一家标准库内部必须怎样布局。libc++ 默认使用类似 std::__1 的 inline ABI namespace,vendor 可在构建时通过 LIBCXX_ABI_NAMESPACE 定制。Chromium 的配置表现为 std::__Cr::basic_string;它改变名称修饰,用于隔离不同 libc++ 实例,但名字隔离并不会自动证明布局和配置一致。

还必须匹配 __config_site 中的 ABI flags。LLVM 明确指出,部分 ABI flags 会改变 basic_string、容器、智能指针等可观察类型的布局;这些 flags 应由 vendor 在构建 libc++ 时确定,而不是由使用者临时拼凑。

从源文件到 client.dll 的符号责任链

client.cpp
  │ include libc++/string + Chromium __config_site
  ▼
clang-cl 17  ── 语义/模板实例化/LLVM CodeGen
  │            └─ 生成 i686-pc-windows-msvc 的 COFF .obj
  │
  ├─ 未定义 C++ 标准库符号 ───────► libc++.lib
  ├─ 未定义 C / heap / stdio 符号 ─► libucrt.lib       (/MT)
  ├─ 未定义 EH / RTTI / startup ───► libvcruntime.lib  (/MT)
  ├─ 未定义 LLVM builtins ─────────► clang_rt.builtins-i386.lib
  └─ Win32 API 导入符号 ───────────► Windows SDK import libraries
                                      │
                                      ▼
                                  lld-link
                                      │
                                      ▼
                              client.dll + client.lib
BROWSER SIDE

Chromium libc++

调用者按 Chromium 的 string 布局、内联实现和 ABI 配置生成代码。

OLD CLIENT SIDE

MSVC STL

被调用方按另一套 string 布局生成代码。名字、尺寸和成员语义都可能不同。

链接成功也不代表安全

如果接口暴露 std::string、std::vector 或模板类型,错误组合有时在链接期报错,有时却会把问题拖到运行期,表现为释放内存时崩溃或静默破坏堆。

CHAPTER 08

/MT 与 /MD 选择的不是编译器

选项含义典型结果关注点
/MT使用多线程静态 CRT运行时代码进入各自模块部署简单,但跨模块分配/释放尤其要小心
/MD使用 DLL 版本 CRT模块通过导入库使用共享 CRT DLL模块应统一运行时版本和配置
/MTd / /MDd相应的 Debug 运行时额外调试设施不要和 Release 运行时随意混链
clang-cl /MT source.cpp   // Clang 编译 + 静态 Microsoft CRT
clang-cl /MD source.cpp   // Clang 编译 + DLL 版 Microsoft CRT

// /MT 并不等于“用 cl.exe 编译”
// /MD 也不等于“整个程序只有一个堆”

Microsoft 文档要求同一次链接中的模块使用一致的运行时选项。工程上还要进一步约束 DLL 边界的所有权,因为独立构建的 DLL、插件与第三方分配器仍可能形成不同的内存域。

CHAPTER 09

稳定 DLL 边界:谁创建,谁销毁

client.dll 创建对象使用自己的 STL、allocator 与 CRT 配置
→
client.dll 提供销毁函数把释放动作送回同一个内存域
// 推荐:C ABI + 明确所有权
extern "C" {
  struct ClientResult {
    int code;
    const char* message;
  };

  ClientResult client_login(const char* username);
  void client_release_message(const char* message);
}

// 高风险:跨 DLL 暴露标准库实现细节
std::string client_login(std::vector<User> users);

C ABI 不是绝对无风险,但它显著缩小了兼容面。仍需固定结构体对齐、整数宽度、字符编码、调用约定、版本字段和内存所有权。

CHAPTER 10

CMake 在哪里表达这些选择

CMake 的 Generator 决定生成哪种原生构建文件;编译器、运行时和具体参数是另一组选择。以 Ninja + clang-cl 为例:

# 首次配置构建目录时选择 Generator 和编译器
cmake -S . -B build -G Ninja \
  -DCMAKE_C_COMPILER=clang-cl \
  -DCMAKE_CXX_COMPILER=clang-cl

# CMakeLists.txt:选择 MSVC ABI 风格工具链下的 CRT 模式
set(CMAKE_MSVC_RUNTIME_LIBRARY
    "MultiThreaded$<$<CONFIG:Debug>:Debug>")

# 使用非默认 libc++ 时,最好封装成专门的 toolchain/preset,
# 集中管理 include 顺序、库目录、ABI 宏、默认库抑制和链接依赖。
为什么应使用全新 build 目录

CMAKE_CXX_COMPILER 在一个构建树首次配置后便成为缓存的一部分。更换编译器、架构或关键运行时设置时,使用独立构建目录比在旧缓存上覆盖更可靠。

一个更准确的配置坐标

Build Configuration = {
  generator:      Ninja,
  compiler:       clang-cl 17,
  target:         i686-pc-windows-msvc,
  object_format:  COFF,
  cxx_abi:        Microsoft ABI,
  cxx_library:    Chromium libc++ / std::__Cr,
  c_runtime:      Microsoft CRT static (/MT),
  compiler_rt:    compiler-rt builtins,
  linker:         lld-link,
  sdk:            Windows SDK,
  flags_and_macros: exactly matched
}

默认库机制也是构建的一部分

在 MSVC 风格工具链中,编译器驱动与头文件可能通过对象文件的 .drectve 段写入 /DEFAULTLIB 指令,链接器据此自动选择库。使用 /NODEFAULTLIB、自定义 libc++ 或手写链接命令时,相当于接管这套责任,必须显式补齐 UCRT、VCRuntime、builtins、系统导入库及正确顺序。

LTO 是另一条兼容轴

普通 COFF 对象能被多个兼容链接器读取,不代表 LLVM bitcode/LTO 产物可被任意版本工具处理。启用 ThinLTO/LTO 后,还要匹配 LLVM bitcode 版本、链接器插件和优化配置。

CHAPTER 11

遇到二进制问题时,按证据排查

  1. 先确认真实命令。开启 verbose 构建,看到底调用了哪个编译器、链接器和参数,不依据 IDE 页面猜测。
  2. 确认头文件来源。使用 /showIncludes 检查 <string> 究竟来自 libc++ 还是 Visual Studio。
  3. 确认目标三元组与架构。x86、x64、ARM64 不能混;MSVC target 与 MinGW target 也不是同一生态。
  4. 检查符号风格。dumpbin /symbols、llvm-nm 可帮助识别 std::__Cr、MSVC 名字修饰和缺失符号。
  5. 检查默认链接库。/VERBOSE:LIB 可以看到链接器为何选中某个库、符号由谁提供。
  6. 核对 ABI 宏和配置头。标准库 ABI namespace、iterator debug、异常、RTTI、结构体对齐都可能改变合同。
  7. 审查模块边界。搜索跨 DLL 的 STL 类型、异常、new/delete、FILE*、locale 和回调调用约定。
  8. 最后做运行验证。符号检查只证明“看起来像”,不能代替真实调用路径、异常路径和内存释放测试。
关于 _CxxFrameHandler3

它属于 Microsoft C++ 异常处理运行时相关符号。出现 unresolved external 往往意味着相应 VCRuntime/default library 没有正确进入链接,不能简单解释成“因为使用了 MSVC 运行时”。要结合链接命令与 /VERBOSE:LIB 判断。

CHAPTER 12

关键概念速查表

概念一句话结论
CMake生成并调度构建,不亲自编译 C++。
Generator决定生成 Ninja、Make、Visual Studio 等哪一种原生构建描述。
clang-clClang 的 cl.exe 兼容驱动,面向 Windows/MSVC 生态。
MSVC ABIClang 与 MSVC 二进制互操作所遵守的关键合同。
MSVC STL微软的 C++ 标准库实现,不等于 MSVC 编译器,也不等于 CRT。
libc++LLVM 的 C++ 标准库实现;换用它需要同时控制头文件和链接库。
UCRT / VCRuntimeMicrosoft 的 C 与平台运行时组成部分,可被 clang-cl 使用。
compiler-rtLLVM 的编译器辅助运行时;可与平台运行时共同出现。
/MT选择静态 Microsoft CRT,不代表使用 cl.exe。
/MD选择 DLL 版 Microsoft CRT,不等于自动解决所有跨 DLL 内存问题。
std::__Cr当前 Chromium libc++ 配置的 ABI 特征,提醒头、库、宏必须成套匹配。
最稳 DLL 接口C ABI、固定宽度类型、显式版本与“谁分配谁释放”。

权威资料