cmake之旅(4)

同系列文章:
cmake之旅(1):构建的过程
cmake之旅(2):CMakeLists.txt 核心语法
cmake之旅(3):多目录项目管理
cmake之旅(4):静态库与动态库
cmake之旅(5):函数、宏与 .cmake 模块
cmake之旅(6):查找和使用第三方库

静态库与动态库

上一篇我们学会了用 add_subdirectory 管理多目录项目,并且在子目录中用 add_library 把源文件编译成了库。但当时我们只是简单地用了一下,并没有深入思考:编译出来的到底是什么类型的库?静态库和动态库有什么区别?什么时候该用哪一种?

这一篇我们就来彻底搞清楚这些问题。

1 什么是库?

在正式开始之前,我们先理解一下"库"这个概念。

库(Library)就是一组已经编译好的代码,打包在一起供别人使用。 你可以把它想象成一个"工具箱"——别人不需要知道工具箱里的工具是怎么造出来的,只需要知道怎么用就行。

回忆一下我们之前的 add 模块:

// add.h —— 告诉别人"我有什么工具"
int add(int a, int b);

// add.cpp —— 工具的具体实现
int add(int a, int b)
{
    return a + b;
}

如果把 add.cpp 编译成库,别人只需要拿到 add.h(知道怎么用)和编译好的库文件(工具本身),就能使用 add 功能了,完全不需要拿到 add.cpp 的源码。

库分为两种:静态库(Static Library)动态库(Dynamic Library / Shared Library)

2 静态库与动态库的区别

在讲 CMake 的用法之前,我们先搞清楚这两种库的本质区别。

2.1 静态库

静态库在链接阶段会被完整地"复制"到可执行文件中。

打个比方:你要写一篇论文,引用了一本参考书中的一个章节。静态库的做法是——你把那个章节的内容完整地抄写到你的论文里面。这样你的论文"自包含"了所有内容,不依赖外部任何东西,但论文的篇幅也变大了。

在不同的操作系统上,静态库的文件格式不同:

系统 静态库文件 示例
Linux / macOS .a libadd.a
Windows .lib add.lib

2.2 动态库

动态库在链接阶段只是做了一个"标记",真正的库代码在程序运行时才被加载。

还是论文的比方:这次你不抄写内容了,只在论文中写了一个注释——“请参见《XXX》第 3 章”。读者(操作系统)在阅读你的论文时,会自己去找那本书、翻到第 3 章来看。这样你的论文变短了,但前提是读者手边必须有那本参考书。

系统 动态库文件 示例
Linux .so libadd.so
macOS .dylib libadd.dylib
Windows .dll + .lib add.dll + add.lib

2.3 对比总结

对比项 静态库 动态库
链接方式 编译时复制进可执行文件 运行时动态加载
可执行文件大小 较大(包含了库的代码) 较小(只包含引用信息)
运行时依赖 无,独立运行 需要库文件存在于系统中
更新库 需要重新编译可执行文件 替换库文件即可,无需重新编译
内存占用 每个程序各自一份副本 多个程序可共享同一份
部署难度 简单,只需要一个可执行文件 需要一起分发库文件

一句话总结:静态库"打包带走",动态库"现场借用"。

3 在 CMake 中构建静态库

我们继续使用上一篇的项目结构:

├── CMakeLists.txt
├── include
│   └── calc
│       ├── add.h
│       └── de.h
└── src
    ├── CMakeLists.txt
    ├── add
    │   ├── CMakeLists.txt
    │   └── add.cpp
    ├── de
    │   ├── CMakeLists.txt
    │   └── de.cpp
    └── main.cpp

源文件和上一篇完全一样,这里不再重复列出。

3.1 显式指定 STATIC

在上一篇中,我们的子模块 CMakeLists.txt 是这样写的:

add_library(add_lib add.cpp)

这里有一个细节:我们没有指定库的类型。 当不指定类型时,CMake 会根据 BUILD_SHARED_LIBS 变量来决定——如果这个变量为 True,就构建动态库;否则构建静态库。默认情况下 BUILD_SHARED_LIBS 未定义,所以默认构建的是静态库

但是"依赖默认值"不是好习惯。推荐显式指定库的类型:

src/add/CMakeLists.txt:

# 显式指定为静态库(STATIC)
add_library(add_lib STATIC add.cpp)

# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

src/ de /CMakeLists.txt:

# 显式指定为静态库(STATIC)
add_library(de_lib STATIC de.cpp)

# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

3.2 构建并观察

mkdir build && cd build
cmake ..
make

构建完成后,在 build 目录下你可以找到生成的静态库文件:

find . -name "*.a"

你会看到类似这样的输出:

./src/add/libadd_lib.a
./src/de/libde_lib.a

注意:CMake 自动给库名加上了 lib 前缀和 .a 后缀。也就是说,你在 CMakeLists.txt 中写的目标名是 add_lib,生成的实际文件名是 libadd_lib.a。这是 Linux 下的命名约定,CMake 会自动处理。

4 在 CMake 中构建动态库

STATIC 换成 SHARED 就行了。

src/add/CMakeLists.txt:

# 指定为动态库(SHARED)
add_library(add_lib SHARED add.cpp)

# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

src/de/CMakeLists.txt:

# 指定为动态库(SHARED)
add_library(de_lib SHARED de.cpp)

# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

重新构建后查看:

find . -name "*.so"

输出:

./src/add/libadd_lib.so
./src/de/libde_lib.so

可执行文件也能正常运行。但这里有一个关键区别——可执行文件现在依赖于这些 .so 文件。如果你把可执行文件拷贝到另一个目录单独运行,可能会报错:

error while loading shared libraries: libadd_lib.so: cannot open shared object file

这就是动态库的特点:运行时必须能找到库文件。

5 让用户选择库类型

有时候你希望把选择权交给使用者——让他自己决定构建静态库还是动态库。CMake 提供了一个内置变量 BUILD_SHARED_LIBS 来实现这个需求。

src/add/CMakeLists.txt:

# 不指定 STATIC 或 SHARED,由 BUILD_SHARED_LIBS 决定
add_library(add_lib add.cpp)

# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

使用者可以在构建时通过命令行参数来控制:

# 构建静态库(默认)
cmake ..

# 构建动态库
cmake -DBUILD_SHARED_LIBS=ON ..

这种方式在开源项目中很常见,让使用者根据自己的需求灵活选择。

6 同时构建静态库和动态库

有些项目希望同时提供静态库和动态库,让使用者按需取用。做法是定义两个不同的目标:

src/add/CMakeLists.txt:

# 静态库
add_library(add_static STATIC add.cpp)
target_include_directories(add_static PUBLIC ${PROJECT_SOURCE_DIR}/include)

# 动态库
add_library(add_shared SHARED add.cpp)
target_include_directories(add_shared PUBLIC ${PROJECT_SOURCE_DIR}/include)

这样构建后会同时生成 libadd_static.alibadd_shared.so

但这样写有个问题: 动态库和静态库的目标名不同(add_static vs add_shared),生成的文件名也不同。如果我们希望它们生成的文件名相同(都叫 libadd),可以用 set_target_properties 来修改输出名称:

# 静态库
add_library(add_static STATIC add.cpp)
target_include_directories(add_static PUBLIC ${PROJECT_SOURCE_DIR}/include)
set_target_properties(add_static PROPERTIES OUTPUT_NAME "add")

# 动态库
add_library(add_shared SHARED add.cpp)
target_include_directories(add_shared PUBLIC ${PROJECT_SOURCE_DIR}/include)
set_target_properties(add_shared PROPERTIES OUTPUT_NAME "add")

这样静态库会生成 libadd.a,动态库会生成 libadd.so,文件名统一,不会冲突(因为后缀不同)。

7 控制库的输出路径

默认情况下,CMake 会把生成的库文件放在与源文件对应的构建子目录中。比如 src/add/CMakeLists.txt 生成的库会出现在 build/src/add/ 下。

但在实际项目中,我们通常希望把所有的库和可执行文件集中输出到统一的目录中,比如 build/lib/build/bin/

在顶层 CMakeLists.txt 中添加:

# 设定可执行文件的输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)

# 设定静态库的输出目录
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

# 设定动态库的输出目录
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

构建后的目录结构就会变成:

build/
├── bin
│   └── Calculator          # 可执行文件
└── lib
    ├── libadd_lib.a        # 静态库(或 .so)
    └── libde_lib.a         # 静态库(或 .so)

这三个变量分别控制不同类型文件的输出位置:

变量 控制的文件类型 示例
CMAKE_RUNTIME_OUTPUT_DIRECTORY 可执行文件(.exe) bin/Calculator
CMAKE_ARCHIVE_OUTPUT_DIRECTORY 静态库(.a / .lib) lib/libadd.a
CMAKE_LIBRARY_OUTPUT_DIRECTORY 动态库(.so / .dylib) lib/libadd.so

注意: 在 Windows 上,.dll 文件被视为 RUNTIME(而不是 LIBRARY),所以 Windows 的动态库会输出到 CMAKE_RUNTIME_OUTPUT_DIRECTORY 指定的目录中。这是一个容易踩的坑。

8 设置库的版本号

对于动态库,设置版本号是一个好习惯。版本号可以帮助使用者区分不同版本的库,也有利于系统的库管理。

add_library(add_lib SHARED add.cpp)

# 设置版本号
set_target_properties(add_lib PROPERTIES
    VERSION 1.2.3       # 库的完整版本号(主版本.次版本.修订号)
    SOVERSION 1          # SO 版本号(通常等于主版本号,用于 ABI 兼容)
)

构建后,Linux 下会生成以下文件:

libadd_lib.so -> libadd_lib.so.1          # 符号链接,指向 SO 版本
libadd_lib.so.1 -> libadd_lib.so.1.2.3    # 符号链接,指向完整版本
libadd_lib.so.1.2.3                        # 真实的库文件

为什么要这样设计?

  • 程序链接时使用 libadd_lib.so(不带版本号),所以升级库时程序不需要重新编译
  • 运行时加载 libadd_lib.so.1(SO 版本),只要主版本号不变,程序就能兼容
  • 真实文件 libadd_lib.so.1.2.3 包含完整版本信息,方便管理多个版本共存

这套机制是 Linux 动态库版本管理的标准做法,CMake 通过 VERSIONSOVERSION 两个属性就能自动帮你生成。

9 OBJECT 库 —— 第三种选择

除了 STATIC 和 SHARED,CMake 还提供了一种特殊的库类型——OBJECT 库

add_library(add_obj OBJECT add.cpp)

OBJECT 库不会生成 .a 或 .so 文件,它只是把源文件编译成目标文件(.o),然后让其他目标直接引用这些目标文件。

什么时候用 OBJECT 库? 最典型的场景就是"同时构建静态库和动态库"。回想一下第 6 节,我们为了同时生成两种库,不得不写两次 add_library,源文件也被编译了两次。用 OBJECT 库可以避免这个问题:

# 源文件只编译一次,生成目标文件
add_library(add_obj OBJECT add.cpp)
target_include_directories(add_obj PUBLIC ${PROJECT_SOURCE_DIR}/include)

# 静态库和动态库都复用同一份目标文件
add_library(add_static STATIC $<TARGET_OBJECTS:add_obj>)
add_library(add_shared SHARED $<TARGET_OBJECTS:add_obj>)

$<TARGET_OBJECTS:add_obj> 是一个"生成器表达式"(Generator Expression),它的值是 add_obj 编译产生的所有 .o 文件。我们会在后续的文章中详细讲解生成器表达式,这里了解用法即可。

不过要注意: 动态库编译时通常需要 -fPIC 选项(Position Independent Code,位置无关代码)。如果你的 OBJECT 库要同时被静态库和动态库使用,需要加上这个属性:

add_library(add_obj OBJECT add.cpp)

# 启用位置无关代码,这样生成的目标文件既能用于静态库也能用于动态库
set_target_properties(add_obj PROPERTIES POSITION_INDEPENDENT_CODE ON)

10 完整示例

我们把这一篇的知识汇总,写一个完整的示例,项目结构如下:

├── CMakeLists.txt
├── include
│   └── calc
│       ├── add.h
│       └── de.h
└── src
    ├── CMakeLists.txt
    ├── add
    │   ├── CMakeLists.txt
    │   └── add.cpp
    ├── de
    │   ├── CMakeLists.txt
    │   └── de.cpp
    └── main.cpp

源文件与上一篇相同,这里只列出 CMakeLists.txt。

顶层 CMakeLists.txt:

# ============================================================
# 项目:Calculator
# 描述:cmake之旅(4)完整示例 —— 静态库与动态库
# ============================================================

# 设定 CMake 最低版本
cmake_minimum_required(VERSION 3.10)

# 定义项目信息
project(Calculator VERSION 1.0.0 LANGUAGES CXX)

# 设定 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 统一输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

# 提供选项:让使用者选择构建静态库还是动态库
option(CALC_BUILD_SHARED "构建动态库" OFF)

# 打印构建信息
if(CALC_BUILD_SHARED)
    message(STATUS "库类型: 动态库 (SHARED)")
else()
    message(STATUS "库类型: 静态库 (STATIC)")
endif()

# 添加 src 子目录
add_subdirectory(src)

这里用了 option 命令定义了一个开关 CALC_BUILD_SHARED,使用者可以通过 -DCALC_BUILD_SHARED=ON 来选择构建动态库。optionset(... CACHE BOOL ...) 类似,但语法更简洁,专门用于布尔类型的选项。

src/add/CMakeLists.txt:

# 根据选项决定库类型
if(CALC_BUILD_SHARED)
    add_library(add_lib SHARED add.cpp)
else()
    add_library(add_lib STATIC add.cpp)
endif()

# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

# 如果是动态库,设置版本号
if(CALC_BUILD_SHARED)
    set_target_properties(add_lib PROPERTIES
        VERSION ${PROJECT_VERSION}
        SOVERSION 1
    )
endif()

src/de/CMakeLists.txt:

# 根据选项决定库类型
if(CALC_BUILD_SHARED)
    add_library(de_lib SHARED de.cpp)
else()
    add_library(de_lib STATIC de.cpp)
endif()

# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

# 如果是动态库,设置版本号
if(CALC_BUILD_SHARED)
    set_target_properties(de_lib PROPERTIES
        VERSION ${PROJECT_VERSION}
        SOVERSION 1
    )
endif()

src/CMakeLists.txt:

# 添加各模块子目录
add_subdirectory(add)
add_subdirectory(de)

# 生成可执行文件
add_executable(${PROJECT_NAME} main.cpp)

# 链接所有模块
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)

构建和运行:

mkdir build && cd build

# 构建静态库版本(默认)
cmake ..
make
./bin/Calculator

# 或者构建动态库版本
cmake -DCALC_BUILD_SHARED=ON ..
make
./bin/Calculator

注意看,因为我们设置了 CMAKE_RUNTIME_OUTPUT_DIRECTORY,可执行文件现在在 bin/ 目录下,库文件在 lib/ 目录下,整齐多了。

11 什么时候用静态库,什么时候用动态库?

这个问题没有标准答案,取决于你的具体场景。以下是一些通用的建议:

优先选择静态库的场景:

部署环境不可控时静态库更可靠,因为所有代码都打包在可执行文件中,不怕目标机器上缺少库文件。嵌入式开发和跨平台发布工具通常优先使用静态库。对性能要求极高的场景也倾向静态库,因为它避免了运行时的动态链接开销(虽然这个开销通常很小)。

优先选择动态库的场景:

大型项目中多个程序共用同一个库时,动态库可以节省磁盘空间和内存。需要支持插件机制的程序必须使用动态库,因为插件需要在运行时加载。库本身更新频繁、但 API 不变的情况下,动态库可以单独替换而不需要重新编译所有依赖它的程序。

初学者建议:如果没有明确的需求,就用静态库。 它更简单、更不容易出问题。等到项目规模增大、有了明确的需求时,再考虑切换到动态库。

12 本篇命令速查表

命令 / 属性 作用 示例
add_library(name STATIC src) 构建静态库 add_library(mylib STATIC a.cpp)
add_library(name SHARED src) 构建动态库 add_library(mylib SHARED a.cpp)
add_library(name OBJECT src) 构建 OBJECT 库 add_library(myobj OBJECT a.cpp)
option(name desc val) 定义布尔选项 option(BUILD_TESTS "构建测试" OFF)
set_target_properties 设置目标属性 见下方
VERSION 库的完整版本号 VERSION 1.2.3
SOVERSION SO 版本号(ABI 兼容版本) SOVERSION 1
OUTPUT_NAME 自定义输出文件名 OUTPUT_NAME "add"
POSITION_INDEPENDENT_CODE 启用 -fPIC POSITION_INDEPENDENT_CODE ON

输出路径相关变量:

变量 控制的文件
CMAKE_RUNTIME_OUTPUT_DIRECTORY 可执行文件、Windows .dll
CMAKE_ARCHIVE_OUTPUT_DIRECTORY 静态库 .a / .lib
CMAKE_LIBRARY_OUTPUT_DIRECTORY 动态库 .so / .dylib

13 总结与下一篇预告

这一篇我们系统学习了静态库和动态库的区别、CMake 中如何构建它们、如何控制输出路径和版本号,还了解了 OBJECT 库这种特殊类型。

回头看一下我们目前写过的 CMakeLists.txt,你可能已经注意到一个问题:很多代码在重复。 比如 add 和 de 的 CMakeLists.txt 几乎一模一样,只是库名和源文件不同。如果有十个模块,就要写十份几乎相同的 CMakeLists.txt,改一个地方还要改十次。

有没有办法把这些重复的逻辑"封装"起来复用?就像 C++ 中的函数一样,定义一次,到处调用?

下一篇——cmake之旅(5):函数、宏与 .cmake 模块,我们来学习 CMake 的代码复用机制。

posted @ 2026-04-10 09:36  m晴朗  阅读(48)  评论(0)    收藏  举报