现代C++ CMake 指南 – 8 链接可执行文件与库文件

您可能认为,一旦源代码成功编译成二进制文件,我们作为构建工程师的工作就完成了。然而事实并非完全如此。虽然二进制文件确实包含了 CPU 执行所需的所有代码,但这些代码可能以复杂的方式分布在多个文件中。我们肯定不希望 CPU 在不同文件中搜寻零散的代码片段。相反,我们的目标是将这些分散的单元整合成单个文件。为此,我们需要使用一个称为链接的过程。

乍看之下,CMake 的链接命令寥寥无几,其中 target_link_libraries() 是主要的一个。那么为何要为单一命令专设整章篇幅?遗憾的是,计算机科学中鲜有简单之事,链接亦不例外:要获得正确结果,我们必须理解完整机制——需要确切知晓链接器工作原理并掌握基础要点。我们将探讨目标文件的内部结构、重定位与引用解析机制的运作方式及其作用,分析最终可执行文件与其组件的差异,以及系统在将程序加载到内存时如何构建进程映像。

接着,我们将介绍各类库文件:静态库、共享库以及共享模块。尽管它们都被称为”库”,实则差异显著。构建良好链接的可执行文件需要正确配置,并处理诸如位置无关代码 PIC)等特定细节。

我们将了解链接过程中的另一个棘手问题—— 单一定义规则 ODR)。保持定义数量精确至关重要,处理重复符号时尤为困难,特别是在使用共享库的情况下。此外,我们还将探究为何链接器有时无法找到外部符号,即使可执行文件已正确链接到相关库。

最后,我们将学习如何高效使用链接器,为在特定框架内测试我们的解决方案做好准备。

本章将涵盖以下主要内容:

掌握链接的基础要点
构建不同类型的库
使用 ODR 解决问题
链接顺序与未解析符号
分离 main() 用于测试

掌握链接的基本原理

我们在第 7 章 《使用 CMake 编译 C++源代码》中讨论了 C++程序的生命周期,它包括五个主要阶段——编写、编译、链接、加载和执行。正确编译所有源代码后,我们需要将它们组合成可执行文件。我们提到编译生成的目标文件无法直接被处理器执行,但这是为什么呢?

要回答这个问题,我们需要了解目标文件是广泛使用的可执行与可链接格式 (ELF)的一种变体,这种格式在类 Unix 系统和许多其他系统中很常见。虽然 Windows 或 macOS 等系统有自己的格式,但我们将重点通过 ELF 来解释其原理。 图 8.1 展示了编译器如何组织这些文件的结构:

 

编译器会为每个翻译单元(每个 .cpp 文件)准备一个目标文件。这些文件将用于构建程序的内存映像。目标文件包含:

一个 ELF 头 ,用于标识目标操作系统 OS)、文件类型、目标指令集架构,以及 ELF 文件中两个头表的位置和大小详情: 程序头表 (目标文件中不存在)和节头表 
按类型分组信息的二进制节。
一个节头表 ,包含有关名称、类型、标志、内存中的目标地址、文件中的偏移量及其他杂项信息。它用于了解文件中包含哪些节及其位置,就像目录一样。

编译器处理源代码时,会将收集到的信息分类到不同的节区。这些节区构成了 ELF 文件的核心部分,位于 ELF 头节区头之间。以下是这些节区的几个示例:

.text 节区包含供处理器执行的机器代码指令。
.data 节区存储已初始化的全局变量和静态变量的值。
.bss 节区为未初始化的全局变量和静态变量预留空间,这些变量在程序启动时会被初始化为零。
.rodata 段用于存储常量值,因此是一个只读数据段。
.strtab 段是一个字符串表,包含常量字符串,比如基础 hello.cpp 示例中的”Hello World”。
.shstrtab 段是一个字符串表,保存着所有其他段的名称。

这些段与最终加载到 RAM 中运行应用程序的可执行文件版本高度相似。然而,我们不能简单地将目标文件拼接后直接加载到内存中。未经谨慎的合并会导致一系列问题。首先,这会浪费空间和时间,消耗过多的 RAM 页面。将指令和数据传输到 CPU 缓存也会变得困难。整个系统将不得不处理增加的复杂性,消耗宝贵的时钟周期,在执行过程中在无数个 .text 、 .data 和其他段之间跳转。

我们将采用更有条理的方法:每个目标文件的节区将与其它目标文件中相同类型的节区归为一组。这一过程称为重定位 ,这也是为什么目标文件的 ELF 文件类型被标记为”可重定位”。但重定位不仅仅是组装匹配的节区,它还涉及更新文件中的内部引用,如变量地址、函数地址、符号表索引和字符串表索引。这些值在每个目标文件中都是局部编号,从零开始计数。因此,在合并文件时,必须调整这些值以确保它们引用合并后文件中的正确地址。

图 8.2 展示了重定位的实际操作—— .text 节区已完成重定位, .data 节区正从所有链接文件中进行组装, .rodata 和 .strtab 节区将遵循相同流程(为简化起见,图中未包含文件头):

接下来,链接器需要解析引用。当一个翻译单元中的代码通过包含头文件或使用 extern 关键字引用另一个单元中定义的符号时,编译器会确认该声明,并假设其定义将在后续提供。链接器的主要作用就是收集这些未解决的外部符号引用,然后在合并后的可执行文件中确定并填充它们所属的地址。 图 8.3 展示了这个引用解析过程的简单示例:

 

如果程序员不了解链接过程的工作原理,这部分可能会成为问题的源头。我们可能会遇到无法解析的引用,这些引用找不到对应的外部符号。或者相反的情况:我们提供了过多的定义,导致链接器不知道选择哪一个。

最终生成的可执行文件与目标文件非常相似,它包含已完成重定位的节区(包含已解析的引用)、 节区头表 ,以及描述整个文件的 ELF 头 。主要区别在于可执行文件包含下图所示的程序头 

 

程序头表紧接在 ELF 头之后。操作系统的加载器会读取这个程序头表来设置程序、配置内存布局并创建进程映像。 程序头表中的条目指定了哪些节区将被复制、以何种顺序复制以及复制到虚拟内存中的哪些地址。它们还包含关于访问控制标志(读、写或执行)的信息以及其他一些有用的细节。每个命名的节区将在创建的进程中表现为一个内存片段,这样的片段被称为段 

目标文件也可以打包成库,这是一种中间产物,可用于最终可执行文件或其他库中。

既然我们已经从原理上理解了链接的工作方式,接下来让我们进入下一节,讨论三种不同类型的库。

构建不同类型的库

编译源代码后,通常希望避免为同一平台重新编译,甚至希望将编译输出共享给外部项目。虽然可以分发最初生成的单个目标文件,但这种方式存在诸多挑战。分发多个文件并逐一将它们集成到构建系统中可能相当麻烦,尤其是处理大量文件时。更高效的方法是将所有目标文件合并为一个整体进行共享。CMake 极大地简化了这一任务,我们只需使用简单的`add_library`命令(配合`target_sources`命令)即可生成这些库文件。

按照惯例,所有库都有一个共同的前缀 lib ,并使用系统特定的扩展名来标识其类型:

在类 Unix 系统上,静态库的扩展名为 .a ,而在 Windows 系统上则为 .lib 。
共享库(及模块)在某些类 Unix 系统(如 Linux)上的扩展名为 .so ,在其他系统(如 macOS)上则为 .dylib 。在 Windows 系统上,其扩展名为 .dll 。
共享模块通常使用与共享库相同的扩展名,但并非总是如此。在 macOS 系统上,它们可能使用 .so 扩展名,特别是当模块是从其他 Unix 平台移植过来时。

按照惯例,构建库(静态库、共享库或共享模块)的过程被称为”链接”,这可以从 ch08/01-libraries 项目的构建输出中看出:

 

[ 33%] Linking CXX static library libmy_static.a
[ 66%] Linking CXX shared library libmy_shared.so
[100%] Linking CXX shared module libmy_module.so
[100%] Built target module_gui

然而,并非所有上述库的创建过程都必须使用链接器。对于某些库,该过程可能会跳过重定位和引用解析等步骤。

让我们深入探讨每种库类型,以了解它们各自的工作原理。

静态库

静态库本质上是一个存储在归档文件中的原始目标文件集合。有时会通过添加索引来加速链接过程。在类 Unix 系统中,这类归档文件可通过 ar 工具创建,并使用 ranlib 建立索引。

在构建过程中,只有静态库中必要的符号会被导入最终的可执行文件,从而优化其大小和内存使用。这种选择性集成确保了可执行文件的自包含性,无需在运行时依赖外部文件。

要创建静态库,我们可以直接使用前面章节中已经介绍过的命令:

add_library(<name> [<source>...])

默认情况下,这段简写代码会生成静态库。通过将 BUILD_SHARED_LIBS 变量设置为 ON 可覆盖此行为。若需强制构建静态库,可提供显式关键字:

add_library(<name> STATIC [<source>...])

使用静态库未必总是理想选择,特别是当我们需要在同一台机器上运行的多个应用间共享已编译代码时。

共享库

共享库与静态库存在显著差异。它们通过链接器构建,该链接器会完成链接的两个阶段,最终生成包含节头、节区及节头表的完整文件,如图 8.1 所示。

共享库(通常称为共享对象)可被多个不同应用程序同时使用。当首个程序调用共享库时,操作系统会将其单个实例加载到内存中。随后调用的程序将通过操作系统复杂的虚拟内存机制获得相同的地址空间。但每个使用该库的进程都会独立实例化库的 .data 和 .bss 段,从而确保各进程能修改变量而不影响其他进程。

得益于这种机制,系统整体内存使用得到了优化。如果我们使用的是广为人知的库,可能无需将其打包进程序,因为目标机器上很可能已预装该库。但若未预装,用户需在运行应用前手动安装,这可能导致库版本与预期不符的问题,此类问题被称为”依赖地狱”。更多细节可参阅本章延伸阅读部分。

我们可以通过显式使用 SHARED 关键字来构建共享库:

add_library(<name> SHARED [<source>...])

由于共享库是在程序初始化时加载的,执行程序与磁盘上的实际库文件之间并不存在直接关联,而是通过间接方式进行链接。在类 Unix 系统中,这一机制通过共享对象名称 SONAME)实现,该名称可理解为库的”逻辑名称”。

这为库版本管理提供了灵活性,并确保对库进行的向后兼容更改不会立即破坏依赖的应用程序。

我们可以使用生成器表达式查询生成的 SONAME 文件的某些路径属性(请确保将 target 替换为你的目标名称):

$<TARGET_SONAME_FILE:target> 返回完整路径( .so.3 )。
$<TARGET_SONAME_FILE_NAME:target> 仅返回文件名。
$<TARGET_SONAME_FILE_DIR:target> 返回目录路径。

这些功能在本书后面将介绍的高级场景中非常实用,包括:

在打包和安装过程中正确使用生成的库。
为依赖管理编写自定义 CMake 规则。
在测试过程中利用 SONAME 功能。
在构建后命令中复制或重命名生成的库。

对于其他操作系统特定的构件,您可能有类似的需求;为此,CMake 提供了两组生成器表达式,它们提供与 SONAME 相同的后缀。对于 Windows 系统,我们提供:

$<TARGET_LINKER_FILE:target> 返回与生成的动态链接库 DLL)相关联的 .lib 导入库的完整路径。请注意, .lib 扩展名与静态 Windows 库相同,但它们的用途并不相同。
$<TARGET_RUNTIME_DLLS:target> 返回目标在运行时依赖的 DLL 列表。
$<TARGET_PDB_FILE:target> 返回 .pdb 程序数据库文件的完整路径(用于调试目的)。

由于共享库在程序初始化时被加载到操作系统的内存中,因此它们适用于预先知道程序将使用哪些库的情况。那么需要在运行时确定使用哪些库的场景又该如何处理呢?

共享模块

共享模块,或称模块库,是共享库的一种变体,设计用作运行时加载的插件。与程序启动时自动加载的标准共享库不同,共享模块仅在程序显式请求时才会加载。这可以通过系统调用来实现:

LoadLibrary 在 Windows 上
dlopen() 后接 dlsym() 在 Linux 和 macOS 上

采用这种方法的主要目的是节省内存。许多软件应用具备的高级功能并非在每个进程的生命周期中都会被使用。每次都将这些功能加载到内存中会降低效率。

或者,我们可能希望提供一种扩展主程序的方式,使其能够加载单独销售和交付的专用功能模块。

要构建共享模块,我们需要使用 MODULE 关键字:

add_library(<name> MODULE [<source>...])

你不应尝试将可执行文件与模块链接,因为该模块设计为与使用它的可执行文件分开部署。

位置无关代码(PIC)

由于虚拟内存的使用,现代程序本身具有一定程度的位置无关性。这项技术抽象了物理地址。调用函数时,CPU 通过内存管理单元 MMU)将虚拟地址(每个进程从 0 开始)转换为对应的物理地址(在分配时确定)。有趣的是,这些映射并不总是遵循特定顺序。

编译库时会引入不确定性:无法预知哪些进程会使用该库,也无法确定它在虚拟内存中的位置。我们同样无法预测符号地址或它们相对于库机器代码的位置。为此,我们需要引入另一层间接寻址机制。

PIC(位置无关代码)的引入是为了将符号(如函数和全局变量的引用)映射到它们的运行时地址。PIC 在二进制文件中新增了一个段: 全局偏移表 GOT)。在链接过程中,会计算 GOT 段相对于 .text 段(程序代码)的相对位置。所有符号引用都将通过一个偏移量指向 GOT 中的占位符。

程序加载时,GOT 段会转换为内存段。随着时间的推移,该段会累积符号的运行时地址。这种方法被称为”惰性加载”,确保加载器仅在需要时填充特定的 GOT 条目。

共享库和模块的所有源代码必须在启用 PIC 标志的情况下编译。通过将 POSITION_INDEPENDENT_CODE 目标属性设置为 ON ,我们将指示 CMake 正确添加编译器特定的标志,例如 GCC 或 Clang 的 -fPIC 。

此属性对共享库会自动启用。但如果共享库依赖于另一个目标(如静态库或对象库),则必须同时将此属性应用于依赖目标:

set_target_properties(dependency
                      PROPERTIES POSITION_INDEPENDENT_CODE ON)

忽略此步骤将导致 CMake 出现冲突,因为它会检查此属性是否存在不一致。您可以在第 5 章处理目标一节中解决传播属性冲突部分找到更详细的探讨。

接下来我们将讨论符号这一主题。具体而言,后续章节将探讨名称冲突带来的挑战,这些问题可能导致歧义和定义不一致的情况。

使用 ODR 解决问题

网景公司首席技术专家兼技术远见者菲尔·卡尔顿说得没错:

计算机科学中有两大难题:缓存失效和命名。

命名之所以困难有几个原因。名称必须精确而简洁,简短而富有表现力。这不仅赋予它们意义,还能让程序员理解底层实现的概念。C++和许多其他语言还附加了一个要求:大多数名称必须是唯一的。

这一要求体现在 ODR(单一定义规则)中:在单个翻译单元(即单个源文件)范围内,你必须准确定义一个符号一次,即使同一个名称(无论是变量、函数、类类型、枚举、概念还是模板)被多次声明。需要澄清的是,”声明”引入符号,而”定义”则提供所有细节,比如变量的值或函数的主体。

在链接过程中,这一规则将扩展到整个程序范围,涵盖您代码中实际使用的所有非内联函数和变量。请看以下由三个源文件组成的示例:

// shared.h
int i;
//one.cpp

#include <iostream>
#include "shared.h"
int main() {
  std::cout << i << std::endl;
}
// two.cpp

#include "shared.h"
CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(ODR CXX)
set(CMAKE_CXX_STANDARD 20)
add_executable(odr one.cpp two.cpp)

如你所见,这个例子非常简单——我们创建了一个 shared.h 头文件,其中定义了 i 变量,该变量在两个独立的翻译单元中使用:

one.cpp 仅将 i 打印到屏幕上
two.cpp 仅包含该头文件

但当我们尝试构建该示例时,链接器产生了以下错误:

/usr/bin/ld:
CMakeFiles/odr.dir/two.cpp.o:(.bss+0x0): multiple definition of 'i';
CMakeFiles/odr.dir/one.cpp.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

符号不能被多次定义。然而,存在一个重要例外:类型、模板和 extern 内联函数可以在多个翻译单元中重复定义,但前提是这些定义必须完全相同(即具有完全相同的标记序列)。

为了演示这一点,让我们用一个类型定义替换变量定义

// shared.h

struct shared {
  static inline int i = 1;
};

然后,我们这样使用它:

// one.cpp

#include <iostream>
#include "shared.h"
int main() {
  std::cout << shared::i << std::endl;
}

另外两个文件, two.cpp 和 CMakeLists.txt ,与 02-odr-fail 示例中的保持一致。这样的改动将使链接成功:

[ 33%] Building CXX object CMakeFiles/odr.dir/one.cpp.o
[ 66%] Building CXX object CMakeFiles/odr.dir/two.cpp.o
[100%] Linking CXX executable odr
[100%] Built target odr

或者,我们可以将该变量标记为仅限翻译单元内部使用(不会导出到目标文件之外)。为此,我们将使用 static 关键字(此关键字具有上下文特定性,因此不要与类中的 static 关键字混淆),如下所示:

// shared.h

static int i;

若尝试链接此示例,你会发现它能正常工作,这意味着静态变量在每个翻译单元中是独立存储的。因此,修改其中一个不会影响另一个。

ODR 规则对静态库的处理方式与目标文件完全相同,但当我们使用共享库构建代码时,情况就不那么清晰了——让我们来一探究竟。

动态链接重复符号的整理

链接器在此处允许符号重复。在以下示例中,我们将创建两个共享库 A 和 B ,其中包含一个 duplicated() 函数以及两个独特的 a() 和 b() 函数:

// a.cpp

#include <iostream>
void a() {
  std::cout << "A" << std::endl;
}
void duplicated() {
  std::cout << "duplicated A" << std::endl;
}

第二个实现文件几乎是第一个文件的精确复制:

// b.cpp
#include <iostream>
void b() {
  std::cout << "B" << std::endl;
}
void duplicated() {
  std::cout << "duplicated B" << std::endl;
}

现在,让我们使用每个函数来看看会发生什么(为简化起见,我们将在本地用 extern 声明它们):

// main.cpp

extern void a();
extern void b();
extern void duplicated();
int main() {
  a();
  b();
  duplicated();
}

上述代码将分别运行每个库中的独特函数,然后调用在两个动态库中定义的具有相同签名的函数。你认为会发生什么?在这种情况下,链接顺序会有影响吗?我们来测试两种情况:

main_1 目标将首先与 a 库链接
main_2 目标将首先与 b 库链接

列表文件内容如下:

cmake_minimum_required(VERSION 3.26)
project(Dynamic CXX)
add_library(a SHARED a.cpp)
add_library(b SHARED b.cpp)
add_executable(main_1 main.cpp)
target_link_libraries(main_1 a b)
add_executable(main_2 main.cpp)
target_link_libraries(main_2 b a)

构建并运行两个可执行文件后,我们将看到以下输出:

root@ce492a7cd64b:/root/examples/ch08/05-dynamic# b/main_1
A
B
duplicated A
root@ce492a7cd64b:/root/examples/ch08/05-dynamic# b/main_2
A
B
duplicated B

啊哈!显然,库的链接顺序对链接器来说至关重要。如果我们不够警惕,这可能会导致混淆。与人们想象的相反,在实际操作中命名冲突并不罕见。

如果我们定义了局部可见的符号,它们将优先于 DLL 中可用的符号。在 main.cpp 中定义 duplicated() 函数将覆盖两个目标的行为。

从库中导出名称时务必格外小心,因为你迟早会遇到名称冲突的情况。

使用命名空间——不要依赖链接器

C++命名空间的引入正是为了避免这类奇怪问题并更有效地处理 ODR(单一定义规则)。最佳实践是将库代码封装在与库同名的命名空间中,这一策略有助于防止因符号重复而引发的复杂问题。

在我们的项目中,可能会遇到一个共享库链接到另一个库,形成长链的情况。这种情形并不像表面看起来那么罕见,尤其是在复杂配置中。但关键要明白,单纯将一个库链接到另一个库并不会引入任何形式的命名空间继承。这条链上每个环节的符号都保留在它们编译时的原始命名空间中。

虽然链接器的复杂性既引人入胜又偶尔至关重要,但另一个紧迫问题常常突然出现:明确定义的符号神秘消失。我们将在下一节深入探讨这个问题。

链接顺序与未解析符号

链接器的行为有时显得反复无常,看似毫无缘由地抛出错误。这对不熟悉该工具复杂性的新手程序员来说,往往成为特别恼人的挑战。可以理解的是,他们通常会尽可能避开构建配置。但总有需要做出改变的时候——比如集成他们开发的某个库——然后一切就乱套了。

试想这样一个场景:一个相对简单的依赖链中,主可执行文件依赖于某个”外部”库,而这个外部库又依赖于包含关键 int b 变量的”嵌套”库。突然间,程序员面前弹出一条晦涩难懂的错误信息:

outer.cpp:(.text+0x1f): undefined reference to 'b'

这类错误并不特别罕见。通常,它们表明链接器中遗漏了某个库文件。然而,在当前情况下,该库似乎已正确添加到 target_link_libraries() 命令中:

cmake_minimum_required(VERSION 3.26)
project(Order CXX)
add_library(outer outer.cpp)
add_library(nested nested.cpp)
add_executable(main main.cpp)
target_link_libraries(main nested outer)

这到底是怎么回事!?很少有错误能像这样令人抓狂又难以调试理解。我们现在遇到的是链接顺序不正确的问题。让我们深入源码一探究竟:

// main.cpp

#include <iostream>
extern int a;
int main() {
  std::cout << a << std::endl;
}

这段代码看起来很简单——我们将打印一个外部变量 a ,它可以在 outer 库中找到。我们提前用 extern 关键字声明了它。以下是该库的源代码:

// outer.cpp

extern int b;
int a = b;

这也很简单—— outer 依赖于 nested 库来提供外部变量 b ,该变量被赋值给 a 变量。让我们查看 nested 的源代码以确认我们没有遗漏定义:

// nested.cpp

int b = 123;

确实,我们已经为 b 提供了定义,由于它未被 static 关键字标记为局部变量,因此它正确地由 nested 目标导出。正如我们之前所见,该目标与 CMakeLists.txt 中的 main 可执行文件相关联。

target_link_libraries(main nested outer)

那么, undefined reference to 'b' 错误是从哪里来的呢?

解析未定义符号的过程是这样的——链接器从左到右处理二进制文件。当链接器遍历二进制文件时,它会执行以下操作:

收集该二进制文件导出的所有未定义符号并存储起来供后续使用。
尝试用当前二进制文件中定义的符号来解析(从已处理的所有二进制文件中收集到的)未定义符号。
对下一个二进制文件重复此过程。

如果整个操作完成后仍有符号未定义,则链接失败。在我们的示例中就是这种情况(CMake 会将可执行目标的对象文件放在库文件之前):

链接器在处理 main.o, 时发现对 a 变量的未定义引用,已将其收集以备后续解析。
链接器处理了 libnested.a ,未发现未定义的引用,也无需进行解析。
链接器处理了 libouter.a ,发现对变量 b 的未定义引用,并将其解析为变量 a 。

我们确实正确解析了对变量 a 的引用,但未解析对变量 b 的引用。要修正此问题,需要反转链接顺序,使嵌套项位于外部项之后:

target_link_libraries(main outer nested)

有时我们会遇到循环引用的情况,即翻译单元相互为对方定义符号,此时不存在一个能满足所有引用的有效顺序。解决此问题的唯一方法是多次处理某些目标:

target_link_libraries(main nested outer nested)

这是一种常见做法,但在使用上略显不够优雅。若您有幸使用 CMake 3.24 或更高版本,可利用` $<LINK_GROUP> `生成器表达式配合` RESCAN `功能——该功能可添加链接器专用标志(如` --start-group `或` --end-group `)来确保所有符号都被解析:

target_link_libraries(main "$<LINK_GROUP:RESCAN,nested,outer>")

请注意,此机制会引入额外的处理步骤,应仅在必要时使用。极少数情况下才需要(且合理)使用循环引用。遇到此问题通常意味着设计存在缺陷。该功能支持 Linux、BSD、SunOS 及使用 GNU 工具链的 Windows 平台。

我们现在已准备好处理 ODR 问题。还会遇到哪些其他问题呢?链接时可疑的符号缺失。让我们来探究一下这究竟是怎么回事。

处理未引用的符号

在创建库文件,尤其是静态库时,它们本质上是由多个目标文件打包而成的归档文件。我们曾提到,某些归档工具可能会创建符号索引以加速链接过程。这些索引提供了每个符号与其所在目标文件之间的映射关系。当解析某个符号时,包含该符号的目标文件会被整合到最终生成的二进制文件中(部分链接器会进一步优化,仅包含文件的特定段)。如果静态库中某个目标文件的所有符号都未被引用,则该目标文件可能会被完全忽略。因此,最终二进制文件中可能仅包含静态库实际被使用的部分。

然而,在某些场景下您可能需要保留未引用的符号:

静态初始化 :如果您的库包含需要在 main() 之前初始化的全局对象(即需要执行其构造函数),而这些对象未被其他代码直接引用,链接器可能会将它们从最终二进制文件中排除。
插件架构 :当开发插件系统(使用模块库)时,某些代码需要在运行时被识别和加载,而无需直接引用。
静态库中的未使用代码 :当开发包含工具函数或非直接引用代码的静态库时,若仍希望这些内容出现在最终二进制文件中。
模板实例化 :对于严重依赖模板的库,如果在链接过程中未明确提及,某些模板实例化可能会被忽略。
链接问题 :特别是在复杂的构建系统或庞大的代码库中,链接过程可能会产生不可预测的结果,某些符号或代码段似乎缺失。

在这些情况下,强制在链接过程中包含所有目标文件可能有所帮助。这通常通过一种称为 whole-archive 链接的模式实现。

特定的编译器链接标志包括:

--whole-archive 针对 GCC
--force-load 用于 Clang
/WHOLEARCHIVE 用于 MSVC

为此,我们可以使用 target_link_options() 命令:

target_link_options(tgt INTERFACE
  -Wl,--whole-archive $<TARGET_FILE:lib1> -Wl,--no-whole-archive
)

然而,该命令与链接器相关,因此必须结合生成器表达式来检测不同编译器并提供相应的标志。幸运的是,CMake 3.24 为此引入了一个新的生成器表达式:

target_link_libraries(tgt INTERFACE
  "$<LINK_LIBRARY:WHOLE_ARCHIVE,lib1>"
)

使用此方法可确保 tgt 目标包含来自 lib1 库的所有目标文件。

尽管如此,仍需考虑几个潜在的缺点:

二进制文件体积增大 :该标志会显著增加最终二进制文件的大小,因为无论是否使用,指定库中的所有对象都会被包含进来。
符号冲突风险 :引入所有符号可能导致与其他符号冲突,从而引发链接器错误。
维护成本增加 :过度依赖此类标志可能掩盖代码设计或结构中存在的潜在问题。

了解了如何解决常见的链接问题后,我们现在可以着手准备项目的测试环境。

分离 main() 以便测试

如前所述,链接器会强制执行 ODR(单一定义规则),并确保所有外部符号在链接过程中提供其定义。我们可能面临的另一个与链接器相关的挑战是如何优雅高效地进行项目测试。

在理想情况下,我们应该测试与生产环境完全相同的源代码。一个完善的测试流程应包括:构建源代码、对生成的二进制文件运行测试、然后打包分发可执行文件(可选择性地排除测试代码本身)。

但我们该如何实现这一点呢?可执行文件通常具有精确的执行流程,往往涉及读取命令行参数。C++的编译特性并不直接支持可临时注入二进制文件进行测试的插件化单元。这表明我们可能需要采取一种细致的方法来应对这一挑战。

幸运的是,我们可以使用链接器以优雅的方式处理这个问题。考虑将程序中的 main() 所有逻辑提取到一个外部函数 start_program() 中,如下所示:

// main.cpp

extern int start_program(int, const char**);
int main(int argc, const char** argv) {
  return start_program(argc, argv);
}

当以这种形式编写时,跳过测试这个新的 main() 函数是合理的;它只是将参数转发到其他地方(另一个文件中)定义的函数。然后我们可以创建一个库,包含来自 main() 的原始源代码,并将其包装在一个新函数中—— start_program() 。在这个例子中,代码检查命令行参数计数是否高于 1 :

// program.cpp

#include <iostream>
int start_program(int argc, const char** argv) {
  if (argc <= 1) {
    std::cout << "Not enough arguments" << std::endl;
    return 1;
  }
  return 0;
}

我们现在可以准备一个项目来构建此应用程序,并将这两个翻译单元链接在一起:

cmake_minimum_required(VERSION 3.26)
project(Testing CXX)
add_library(program program.cpp)
add_executable(main main.cpp)
target_link_libraries(main program)

 

main 目标仅提供所需的 main() 功能。命令行参数验证逻辑包含在 program 目标中。现在我们可以通过创建另一个带有自己的 main() 函数的可执行文件来测试它,该函数将托管测试用例。

在实际场景中,诸如 GoogleTest 或 Catch2 等框架会提供自己的 main() 方法,可用于替换程序的入口点并运行所有定义的测试。我们将在第 11 章测试框架中深入探讨实际测试的主题。现在,让我们专注于通用原则,直接在 main() 函数中编写自己的测试用例:

// test.cpp

#include <iostream>
extern int start_program(int, const char**);
using namespace std;
int main()
{
  cout << "Test 1: Passing zero arguments to start_program:
";
  auto exit_code = start_program(0, nullptr);
  if (exit_code == 0)
    cout << "Test FAILED: Unexpected zero exit code.
";
  else
    cout << "Test PASSED: Non-zero exit code returned.
"; 
  cout << endl;
  cout << "Test 2: Passing 2 arguments to start_program:
";
  const char *arguments[2] = {"hello", "world"};
  exit_code = start_program(2, arguments);
  if (exit_code != 0)
    cout << "Test FAILED: Unexpected non-zero exit code
";
  else
    cout << "Test PASSED
";
}

上述代码将两次调用 start_program ,分别带参数和不带参数,并检查返回的退出码是否正确。如果测试执行正确,您将看到如下输出:

./test
Test 1: Passing zero arguments to start_program:
Not enough arguments
Test PASSED: Non-zero exit code returned
Test 2: Passing 2 arguments to start_program:
Test PASSED

Not enough arguments 行来自 start_program() ,这是一条预期的错误消息(我们正在检查程序是否正确失败)。

这个单元测试在整洁代码和优雅测试实践方面还有很大改进空间,但总算开了个头。

我们现在已经两次定义了 main() :

在生产环境中使用的 main.cpp
用于测试目的的 test.cpp

现在让我们在 CMakeLists.txt 底部定义测试可执行文件:

add_executable(test test.cpp)
target_link_libraries(test program)

这一新增内容创建了一个新目标,该目标链接到与生产代码相同的二进制代码。但它为我们提供了根据需要调用所有导出函数的灵活性。得益于此,我们可以自动运行所有代码路径并检查它们是否按预期工作。太棒了!

总结

CMake 中的链接最初看起来可能很简单,但当我们深入研究时,会发现表面之下还有更多内容。毕竟,链接可执行文件并不像拼图那样简单。当我们深入探究目标文件和库的结构时,很明显存储各种类型数据、指令、符号名称等的部分需要进行一些重新排序。在程序可运行之前,这些部分会经历所谓的重定位过程。

解析符号同样至关重要。链接器必须梳理所有编译单元中的引用关系,确保没有遗漏。完成这一步骤后,链接器会创建程序头并将其置入最终可执行文件。这个程序头为系统加载器提供指令,详细说明如何将合并后的节区转换为构成进程运行时内存映像的段。我们还探讨了三种库类型:静态库、共享库和共享模块,分析了它们之间的差异以及各自更适合的应用场景。此外,我们提及了位置无关代码(PIC)——这一强大概念实现了符号的延迟绑定机制。

ODR(单一定义规则)是 C++的概念,但正如我们所看到的,它被链接器严格强制执行。我们探讨了如何处理静态库和动态库中最基本的符号重复问题。同时,我们强调了尽可能使用命名空间的价值,并建议不要过度依赖链接器来防止符号冲突。

对于一个看似简单的步骤(考虑到 CMake 专门用于链接的命令有限),它确实有其复杂性。其中一个较棘手的方面是链接顺序,尤其是在处理具有嵌套和循环依赖关系的库时。我们现在已经理解了链接器如何选择最终二进制文件中的符号,以及如何在需要时覆盖此行为。

最后,我们研究了如何利用链接器为程序测试做准备——通过将 main() 函数分离到另一个翻译单元中。这使我们能够引入另一个可执行文件,该文件针对将在生产中执行的完全相同的机器代码运行测试。

凭借我们新获得的链接知识,我们已经准备好将外部库引入 CMake 项目。在下一章中,我们将探讨如何在 CMake 中管理依赖关系。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容