Skip to content
xiaoyang 的头像xiaoyang发布于 2026-09-2713,329 字阅读约 44 分钟浏览加载中

CMake 构建指南 ​

在现代 C/C++ 开发中,源码变为二进制可执行程序需要经过项目描述、构建规则生成、依赖调度、编译链接等阶段。CMake 并非编译器,而是一个元构建工具(Meta-Build System)。

为什么要用CMake? ​

学习 C/C++ 时,你是否也产生过这样的困惑:明明敲一句 gcc main.c -o app 就能跑通代码,为什么还要折腾复杂的 CMake?

这就好比在 Java 开发中,难道真的有人会在企业级项目里只靠 javac 一条条去编译源码吗? 显然不可能,我们必然会用 Maven 或 Gradle 来管理项目。

同样的逻辑放在 C/C++ 领域也完全成立。单文件时 gcc 确实方便,但当项目膨胀到数十个模块、依赖多个第三方库、还需要同时在 Windows 和 Linux 上运行时,直接裸敲编译器会让你瞬间陷入命令行参数膨胀、每次修改都全量重编译、以及跨平台命令不兼容的噩梦。而 CMake 恰恰就是 C/C++ 世界里的那个“跨平台构建指挥官”——它不直接干编译的活,而是让你用一份统一的 CMakeLists.txt 声明项目规则,自动在不同操作系统上生成原生的构建工程,彻底把开发者从繁琐的编译命令中解放出来。

NOTE

需要强调的是,C/C++ 的跨平台不是 Java 那种“一次编译,处处运行”,而是“源码级跨平台。

CMake 解决的正是这个“处处编译”的痛点——你不需要为了在 Linux 上编译去手写 Makefile,也不需要为了在 Windows 上开发去手动配置 Visual Studio 项目,而是用同一套 CMakeLists.txt 源码脚本,在目标平台上自动生成对应的原生构建工程,最终编译出特定系统专属的二进制文件。

Cmake的构建过程 ​

我们通过一个极简的 C++ 项目,直观感受 CMake 的实际工作机制。

hello/
├── CMakeLists.txt     # 项目构建描述文件
├── main.cpp           # 程序入口
└── add.cpp            # 业务逻辑实现

add.cpp

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

main.cpp

c++
#include <iostream>

int add(int a, int b);

int main() {
    std::cout << "10 + 20 = " << add(10, 20) << std::endl;
    return 0;
}

CMakeLists.txt

cmake
cmake_minimum_required(VERSION 3.20)
project(hello VERSION 1.0 LANGUAGES CXX)

# 设置 C++ 标准
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 定义构建目标:将 main.cpp 和 add.cpp 编译并链接为名为 hello 的可执行文件
add_executable(hello
    main.cpp
    add.cpp
)

配置与生成(Configure & Generate Stage) ​

在现代 CMake 中,配置与生成阶段通常通过一条简单的命令自动完成:

cmake
cmake -S . -B build
  • -S .:指定源码根目录(Source Directory,. 代表当前目录)。
  • -B build:指定构建输出目录(Build Directory),不存在则会自动创建。

幕后发生了什么?

当你敲下这条命令时,CMake 在后台连续完成了两件事:

  1. 配置阶段(Configure):读取 CMakeLists.txt,检测系统环境(自动寻找 g++、clang++ 或 cl.exe 编译器)、检查 C++20 标准支持情况,并将相关配置写入 build/CMakeCache.txt。
  2. 生成阶段(Generate):根据探测到的环境和系统默认生成器(如 Linux 下的 Unix Makefiles、Windows 下的 Visual Studio,或通过 -G Ninja 指定的 Ninja),在 build/ 目录下生成底层的工程文件(如 Makefile 或 build.ninja)。

最佳实践:源码外构建(Out-of-source Build)

永远不要在源码根目录下直接运行裸 cmake .(这叫源码内构建)。通过 -B build 将所有编译中间文件、缓存和目标脚本隔离在独立的 build/ 目录中,既能保持源码目录干净,又能随时清理(直接删除 build 文件夹即可),还能方便地切换 Debug/Release 等不同构建版本。

编译与链接(Build Stage) ​

生成底层构建脚本后,我们不需要手动进入 build 目录去敲 make 或 ninja,直接使用 CMake 提供的跨平台编译命令:

cmake
cmake --build build

幕后发生了什么?

  • CMake 会自动识别 build/ 目录下使用的是哪种生成器(Makefile、Ninja 还是 VS 工程),并调用对应的底层工具(如 make 或 ninja)。
  • 底层工具分析源码依赖,调度编译器(如 g++)将 main.cpp 和 add.cpp 分别编译为 .o 目标文件,最后调用链接器合并生成最终的可执行文件 hello。

运行编译好的程序:

sh
./build/hello
# 输出:10 + 20 = 30

构建目录核心产物 ​

执行完 cmake -S . -B build 配置生成以及 cmake --build build 编译链接后,build/ 目录下会生成如下典型结构:

build/
├── CMakeCache.txt       # 配置缓存文件
├── CMakeFiles/          # 过程日志与中间目标文件(.o)
├── cmake_install.cmake  # (可选)安装规则脚本
├── Makefile             # (或 build.ninja)底层构建工具调用的脚本
└── hello                # 最终编译链接生成的可执行程序
  • CMakeCache.txt(配置缓存)

    记录配置阶段探测到的编译器路径、系统环境及通过 -D 传入的自定义参数。再次运行 cmake 时会优先读取该文件,避免重复进行耗时的系统探测。

  • CMakeFiles/(临时与中间过程目录)

    存放 CMake 在测试编译器特性、搜索依赖时生成的日志,以及编译过程中产生的可重定位目标文件(.o)。

  • Makefile 或 build.ninja(底层构建脚本)

    由 CMake 根据当前的生成器(Generator)直接导出的工程配置文件,作为底层构建引擎(如 make 或 ninja)调度的直接依据。

  • hello(最终二进制产物)

    将 main.o 和 add.o 进行符号重定位并链接标准库后导出的最终 ELF 二进制文件,可以直接载入内存运行。

编译过程 ​

img

平时我们执行 gcc main.c -o main 时,GCC 会在后台自动完成这 4 个步骤。这段代码通过不同的命令行参数(-E、-S、-c),把内部隐藏的过程一步步拆解展示了出来:

1. 预处理 (Preprocessing) ​

  • 命令: gcc -E main.c -o main.i
  • 输入输出: main.c (源码) → main.i (预处理文件)
  • 实质: 纯文本级别的替换与清理。
    • 展开头文件: 把 #include <stdio.h> 等包含的内容完整复制粘贴进文件。
    • 宏替换: 将 #define MAX 100 这类宏定义全部替换为具体的值。
    • 删除注释: 清除所有的 // 和 /* */ 注释。
    • 条件编译: 处理 #ifdef、#ifndef 等分支逻辑。

2. 编译 (Compilation) ​

  • 命令: gcc -S main.i -o main.s
  • 输入输出: main.i → main.s (汇编代码)
  • 实质: 语法分析与指令翻译。
    • 检查代码是否有语法错误。
    • 将 C 语言的逻辑(如 if、for、变量加减)翻译成特定的机器 CPU 指令集对应的汇编语言(如 mov, add, push)。

3. 汇编 (Assemble) ​

  • 命令: gcc -c main.s -o main.o
  • 输入输出: main.s → main.o (二进制目标文件)
  • 实质: 汇编代码转机器码。
    • 将文本格式的汇编指令翻译成计算机真正能理解的 0 和 1 二进制二进制机器码(Object File)。
    • 注意: 此时的文件虽然是二进制,但还不能独立运行,因为像 printf 这类标准库函数的具体地址还没有绑定。

4. 链接 (Linking) ​

  • 命令: gcc main.o -o main
  • 输入输出: main.o(及其他目标文件或 .so/.a 库文件) → main (可执行文件)
  • 实质: 打包拼装与地址重定位。
    • 将你写的多个 .o 文件与系统提供的 C 运行时库(如 libc.so)打包拼接在一起。
    • 解决外部符号引用(找到 printf 在系统库里的实际入口地址),生成操作系统可以直接加载运行的程序。

编译连接原理 ​

为了彻底搞懂构建过程,我们抛开 CMake,直接用最基础的命令行,顺着“源码 → 目标文件 → 链接 → 装载”的主线,解剖 main.cpp 和 add.cpp 的真实命运。

c++
// add.cpp
int add(int a, int b) {
    return a + b;
}

// main.cpp
#include <iostream>
int add(int a, int b); // 声明

int main() {
    std::cout << "10 + 20 = " << add(10, 20) << std::endl;
    return 0;
}

main.cpp 中 int add(int a, int b); 是一句声明,它向编译器保证:“别管去哪找,这个函数肯定存在,它的参数和返回值是这样的,你先放行。” 真正的定义(机器逻辑)则躺在 add.cpp 里。

编译阶段 ​

执行以下命令,加上 -c 参数告诉编译器:只编译,不链接。

bash
g++ -c main.cpp -o main.o
g++ -c add.cpp -o add.o

此时生成了两个可重定位目标文件(.o)。它们已经是机器码,但还不能运行。我们可以用 nm 命令偷窥一下 main.o 的内部符号表:

bash
nm -C main.o

核心输出只有两种状态(T 和 U):

  • 0000000000000000 T main:T (Text) 代表“我这里有这个函数的实现代码”。
  • U add(int, int):U (Undefined) 代表“我需要调用这个函数,但我不知道它在哪”。

同理,查看 add.o:

  • 0000000000000000 T add(int, int):明确表示“我有 add 的代码”。

阶段总结:编译阶段,各个 .cpp 文件各自为战。main.o 是个带着空缺的拼图,它知道自己需要 add,但不知道 add 的真实内存地址,只能先留下一个 U 的记号。

链接阶段 ​

执行链接命令:

c++
g++ main.o add.o -o hello

链接器(Linker)此时登场,它主要干了三件惊天动地的事:

  1. 符号解析(Symbol Resolution):

    链接器看到 main.o 喊着 U add(我缺 add),立刻去其他文件里找,发现 add.o 举手说 T add(我有 add),于是成功将它们匹配。

  2. 段合并(Section Merging):

    .o 文件内部划分了不同的数据区(.text 存代码,.rodata 存常量)。链接器会把 main.o 的 .text 和 add.o 的 .text 拼接到一起,形成最终可执行文件的大 .text 段。

  3. 地址重定位(Relocation):

    在 .o 文件中,main 和 add 的初始地址都是相对地址 0x0000(各自在自己的小地图里)。链接器把它们拼成大地图后,赋予了真实的全局地址(例如 main 分配到 0x1189,add 分配到 0x11e8),并回头把 main.o 中调用 add 的占位符替换成真正的 0x11e8。

静态链接与动态链接 ​

如果你此时查看最终生成的 main 文件:

nm -C hello

你会惊讶地发现,虽然 add 变成了 T(已解决),但里面依然存在大量未解决的 U:

  • U std::cout
  • U __libc_start_main

既然是最终可执行文件,为什么还有 U?

这就自然引出了链接的两种终极形态:

  • 静态链接(刚才 add.o 的做法):我们自己写的 add.o 被完整拷贝进了 hello 里,这种物理打包就是静态链接(对应的库叫 .a 或 .lib)。
  • 动态链接(std::cout 的做法):C++ 标准库和系统底层接口太庞大了,如果全塞进程序里,文件会大得离谱。所以链接器允许 hello 保留这些 U。当你在终端敲下 ./hello 时,操作系统的动态链接器会在程序载入内存的瞬间,去系统目录里找到 libstdc++.so 和 libc.so,映射进内存,填补最后这些缺失的地址。

目标文件 ​

目标文件由汇编器和链接器创建,是文本程序的二进制形式,可以直接在处理器上运行。那些需要虚拟机才能够执行的程序 (Java) 不属于这一范围。

可重定位目标文件(Relocatable Object File,*.o) ​

  • 由源文件(如 .c、.cpp)经过编译生成。
  • 包含已经编译好的代码和数据,但其中部分符号的地址尚未确定。
  • 可以与其他可重定位目标文件组合,通过链接形成可执行目标文件或共享目标文件。

可执行目标文件(Executable Object File,a.out) ​

  • 经过链接器将一个或多个可重定位目标文件组合生成。
  • 代码和数据已经完成相应的地址分配,可以由操作系统加载到内存并执行。
  • Linux 下通常为 ELF 可执行文件,Windows 下通常为 PE 格式的 .exe 文件。

共享目标文件(Shared Object File,*.so) ​

  • 一种特殊的可重定位目标文件。
  • 可以在程序加载时或运行时被加载到内存,并与程序进行动态链接。
  • Linux 下通常使用 .so 作为文件扩展名,例如 libc.so。
  • Windows 中对应的概念通常称为动态链接库(DLL,Dynamic Link Library),文件扩展名为 .dll。

ELF(Executable and Linkable Format)主要是 Linux/Unix 世界使用的二进制文件格式。macOS 和 Windows 通常不用 ELF,而是各自有自己的格式。可以先记住这一张表:

操作系统主要二进制文件格式常见后缀/形式
LinuxELF无后缀、.o、.so
macOSMach-O无后缀、.o、.dylib、.bundle
WindowsPE/COFF.exe、.dll、.obj

ELF文件格式 ​

我们以linux下的文件格式ELF作为介绍,andorid系统也会用到,我们可以看到这三种文件均是ELF文件

image-20261006145140983

目标文件既会参与程序链接又会参与程序执行。出于方便性和效率考虑,根据过程的不同,目标文件格式提供了其内容的两种并行视图,如下

image-20261006195241126

执行视图:当文件装载进内存时,操作系统出于内存对齐和权限防护(如可读、可写、可执行)的考虑,将多个权限相同的 Section 归类合并为粒度较大的 Segment(段),这套内存映射蓝图则存放在 Program Header Table(程序头表) 中。

链接视图:文件由众多粒度较细的 Section(节) 构成,用于按功能(如代码、只读数据、BSS)区分内容,其索引信息存放在 Section Header Table(节头表) 中。

ELF Header ​

ELF 文件头(ELF Header)位于整个 ELF 文件的最前端,物理位置固定占用文件前 64 字节(64 位架构)。它是操作系统的“文件身份证”与“导航蓝图”,包含了校验文件有效性、确定目标硬件架构、定位程序入口以及寻址节头表(Section Header Table)和程序头表(Program Header Table)所需的一切元数据。

通过执行 readelf -h 命令,我们可以直接提取 ELF 文件头的内容。以下针对未链接的中间产物 add.o(可重定位文件)与链接后的最终产物 hello(可执行文件)进行对比解读。

可执行文件的文件头 ​

shell
ubuntu@VM-0-5-ubuntu:~/code/hello$ readelf -h hello
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # ELF魔数与系统标识(固定16字节,用于确定文件规范与体系)
  Class:                             ELF64              # 架构类别:64位系统
  Data:                              2's complement, little endian # 数据编码:二进制补码,小端序(Little-Endian)
  Version:                           1 (current)        # ELF规范主版本号(当前恒定为1)
  OS/ABI:                            UNIX - System V    # 目标操作系统及应用二进制接口(System V ABI)
  ABI Version:                       0                  # ABI具体版本号
  Type:                              DYN (Position-Independent Executable file) # 文件类型:位置无关可执行文件(PIE)
  Machine:                           Advanced Micro Devices X86-64              # 硬件平台:x86_64 架构
  Version:                           0x1                # 文件版本号
  Entry point address:               0x10a0             # 程序执行入口(内存虚拟地址,指向 _start)
  Start of program headers:          64 (bytes into file) # 程序头表(PHT)在物理文件中的偏移量(紧跟文件头后)
  Start of section headers:          14624 (bytes into file) # 节头表(SHT)在物理文件中的偏移量(位于文件末尾附近)
  Flags:                             0x0                # 处理器特定标志位(x86架构通常为0)
  Size of this header:               64 (bytes)         # ELF文件头自身固定的物理尺寸(64字节)
  Size of program headers:           56 (bytes)         # 程序头表单个表项(Segment Entry)的大小
  Number of program headers:         13                 # 程序头表项数量(即包含 13 个 Segment)
  Size of section headers:           64 (bytes)         # 节头表单个表项(Section Entry)的大小
  Number of section headers:         31                 # 节头表项数量(即包含 31 个 Section)
  Section header string table index: 30                 # 存储节名字符串表(.shstrtab)的Section索引号

可重定位文件头 ​

shell
ubuntu@VM-0-5-ubuntu:~/code/hello$ readelf -h add.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # 魔数与hello完全相同,证明遵循相同的底层文件规范
  Class:                             ELF64              # 同样编译为 64位 格式
  Data:                              2's complement, little endian # 小端序
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file) # 文件类型:可重定位文件(未链接的中间目标文件)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0                # 无入口地址(未完成符号解析与重定位,无法独立执行)
  Start of program headers:          0 (bytes into file)# 无程序头表(物理偏移量为0,不存在执行视图)
  Start of section headers:          472 (bytes into file) # 节头表(SHT)在物理文件中的起始偏移量
  Flags:                             0x0
  Size of this header:               64 (bytes)         # 文件头固定占用64字节
  Size of program headers:           0 (bytes)          # 程序头表表项尺寸为0
  Number of program headers:         0                  # 程序头表表项数量为0(无Segment概念)
  Size of section headers:           64 (bytes)         # 节头表单个表项的大小(64字节)
  Number of section headers:         12                 # 节头表项数量(包含 12 个 Section)
  Section header string table index: 11                 # 节名字符串表(.shstrtab)索引号为11

其实上面我们通过nm命令就看了ELF文件的内部的信息了ELF 文件全是 0 和 1 的二进制数据,你用普通的文本编辑器(比如记事本)打开只会看到一堆乱码。nm 命令看的就是 ELF 文件内部的特定内容——符号表(Symbol Table)。

在 Linux 世界里,探究 ELF 文件内部内容通常有三个经典的工具,它们各司其职:

  • nm:专门看符号表(是谁?在哪?缺谁?)。
  • objdump(尤其是 objdump -d):专门看 .text 代码段,它能把机器码反汇编成人类能看懂的汇编语言。
  • readelf:这是最全能的 ELF 解剖刀。比如 readelf -S 可以列出 ELF 里面的所有“房间”(Section),readelf -h 可以看文件头(比如它是为 x86 还是 ARM 编译的)。

所以,你前面看到 U add 和 T main,就是 nm 从 ELF 文件的符号表中读取并为你呈现的底层真相。

接下来我们就用这几个命令来看看ELF的格式

Section ​

shell
ubuntu@VM-0-5-ubuntu:~/code/hello$ readelf -S hello 
There are 31 section headers, starting at offset 0x3920:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000000318  00000318
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.gnu.pr[...] NOTE             0000000000000338  00000338
       0000000000000030  0000000000000000   A       0     0     8
  [ 3] .note.gnu.bu[...] NOTE             0000000000000368  00000368
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .note.ABI-tag     NOTE             000000000000038c  0000038c
       0000000000000020  0000000000000000   A       0     0     4
  [ 5] .gnu.hash         GNU_HASH         00000000000003b0  000003b0
       0000000000000028  0000000000000000   A       6     0     8
  [ 6] .dynsym           DYNSYM           00000000000003d8  000003d8
       0000000000000120  0000000000000018   A       7     1     8
  [ 7] .dynstr           STRTAB           00000000000004f8  000004f8
       0000000000000167  0000000000000000   A       0     0     1
  [ 8] .gnu.version      VERSYM           0000000000000660  00000660
       0000000000000018  0000000000000002   A       6     0     2
  [ 9] .gnu.version_r    VERNEED          0000000000000678  00000678
       0000000000000060  0000000000000000   A       7     2     8
  [10] .rela.dyn         RELA             00000000000006d8  000006d8
       00000000000000f0  0000000000000018   A       6     0     8
  [11] .rela.plt         RELA             00000000000007c8  000007c8
       0000000000000048  0000000000000018  AI       6    24     8
  [12] .init             PROGBITS         0000000000001000  00001000
       000000000000001b  0000000000000000  AX       0     0     4
  [13] .plt              PROGBITS         0000000000001020  00001020
       0000000000000040  0000000000000010  AX       0     0     16
  [14] .plt.got          PROGBITS         0000000000001060  00001060
       0000000000000010  0000000000000010  AX       0     0     16
  [15] .plt.sec          PROGBITS         0000000000001070  00001070
       0000000000000030  0000000000000010  AX       0     0     16
  [16] .text             PROGBITS         00000000000010a0  000010a0
       0000000000000160  0000000000000000  AX       0     0     16
  [17] .fini             PROGBITS         0000000000001200  00001200
       000000000000000d  0000000000000000  AX       0     0     4
  [18] .rodata           PROGBITS         0000000000002000  00002000
       0000000000000012  0000000000000000   A       0     0     4
  [19] .eh_frame_hdr     PROGBITS         0000000000002014  00002014
       000000000000003c  0000000000000000   A       0     0     4
  [20] .eh_frame         PROGBITS         0000000000002050  00002050
       00000000000000d4  0000000000000000   A       0     0     8
  [21] .init_array       INIT_ARRAY       0000000000003d90  00002d90
       0000000000000008  0000000000000008  WA       0     0     8
  [22] .fini_array       FINI_ARRAY       0000000000003d98  00002d98
       0000000000000008  0000000000000008  WA       0     0     8
  [23] .dynamic          DYNAMIC          0000000000003da0  00002da0
       0000000000000200  0000000000000010  WA       7     0     8
  [24] .got              PROGBITS         0000000000003fa0  00002fa0
       0000000000000060  0000000000000008  WA       0     0     8
  [25] .data             PROGBITS         0000000000004000  00003000
       0000000000000010  0000000000000000  WA       0     0     8
  [26] .bss              NOBITS           0000000000004040  00003010
       0000000000000118  0000000000000000  WA       0     0     64
  [27] .comment          PROGBITS         0000000000000000  00003010
       000000000000002d  0000000000000001  MS       0     0     1
  [28] .symtab           SYMTAB           0000000000000000  00003040
       0000000000000450  0000000000000018          29    22     8
  [29] .strtab           STRTAB           0000000000000000  00003490
       0000000000000375  0000000000000000           0     0     1
  [30] .shstrtab         STRTAB           0000000000000000  00003805
       000000000000011a  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)

最核心的 ELF Section 只有以下 6 个:

Section 名称核心作用内存权限磁盘占用
.text存放编译后的 CPU 机器指令(如 main 函数代码)。可读、可执行占用
.rodata存放只读常量(如字符串 "Hello World"、const 全局变量)。只读占用
.data存放显式初始化且值不为 0 的全局/静态变量(如 int a = 10;)。可读、可写占用
.bss存放未初始化或初始化为 0 的全局/静态变量(如 int b[100];)。可读、可写不占用(仅标记所需内存大小,加载时自动清零)
.got / .plt动态链接表,负责运行时跳转并调用 C 标准库函数(如 printf)。混合占用
.symtab符号表,记录函数名、变量名及其内存地址对应关系,供 gdb 调试使用。不装载占用

一句话记忆口诀

  • 代码看 .text:程序怎么跑。
  • 常量看 .rodata:只读不改。
  • 变量分两家:有初值存 .data,没初值存 .bss(只占内存不占体积)。
  • 调库靠 .got/.plt:找到 printf 的真实地址。
  • 调试找 .symtab:记录所有函数和变量的名字(执行 strip 命令删掉的就是它)。
DISCUSSION

评论区

欢迎分享你的思考、补充或问题。

基于 VitePress 搭建 · 聚焦技术深度与极致体验