【CMake】《CMake构建实战:项目开发卷》笔记-Chapter1-构建之旅

第1章 构建之旅

1.1 单源文件程序:您好,世界

略。

1.2 构建多源程序

1.2.1 输出另一源程序的字符串

略。

1.2.2 一个需要漫长编译过程的程序

MSVC编译器的/EHsc参数用于启用C++异常处理的展开语义,如果不指定会产生警告。其具体用途请参考其官方文档。

1.2.3 按需编译:快速构建变更

MSVC编译器在编译生成可执行文件的同时,在同一目录下还生成了一些.obj文件,这就是编译生成的目标文件。链接器的作用,就是把这些目标文件链接在一起,解析其中未定义的符号引用。GCC等编译器其实也是一样的,只不过可能并没有将目标文件输出到工作目录中。

符号一般指函数、变量、类等可被链接的对象的名称。

因此,按需编译的关键,就是分别编译各个源程序到目标文件。当源程序发生修改时,只需将变更的源程序重新编译到目标文件,然后重新与其他目标文件链接。

使用MSVC按需构建

MSVC编译器的/c参数,可以使编译器仅将源程序编译为目标文件,而不进行链接过程。

首先,借助该参数将原始的main.cpp和slow.cpp编译好。当然,这一步骤仍然耗时:

> cd CMake-Book\src\漫长等待
> cl /c main.cpp slow.cpp /EHsc
> dir
main.cpp  main.obj  slow.cpp  slow.obj

接着,尝试链接一下刚生成的两个目标文件,看看是否可以生成最终的可执行文件:

> cl main.obj slow.obj
> main.exe
斐波那契数列第25项为:75025

实际上,link.exe才是MSVC的链接器,但MSVC编译器cl.exe本身支持调用链接器,因此可以直接调用cl.exe来完成链接。

使用GCC按需构建

构建原理是相通的,因此不同编译器的构建过程也都是相似的,甚至用于编译为目标文件的参数都采用了字母c:

$ cd CMake-Book/src/漫长等待
$ g++ -c main.cpp slow.cpp
$ ls
main.cpp  main.o  slow.cpp  slow.o

不同于Windows平台的.obj文件,Linux中的目标文件一般使用.o作为扩展名。

GCC使用的是GNU链接器ld。类似于MSVC编译器,GCC本身也可以调用链接器,因此这里直接通过 GCC编译器完成链接过程。

1.2.4 使用Makefile简化构建

GNU make(简称make)是Linux中一个常见的构建工具。Windows上也有类似的工具,称为NMake,语法与make不尽相同,也并不常用,因为大家往往更倾向于直接使用与Visual Studio集成度更高、功能更强大的MSBuild构建工具。那么不妨先重点看一下make的用法。

使用make工具

make构建工具会根据Makefile规则文件来进行构建。简言之,Makefile规则文件是由一系列面向目标的规则构成的。注意Makefile中的缩进必须使用制表符(Tab键)而非空格。

main: main.o slow.o
    g++ -o main main.o slow.o
 
main.o: main.cpp
    g++ -c main.cpp -o main.o
 
slow.o: slow.cpp
    g++ -c slow.cpp -o slow.o

其中,冒号前面的是构建目标,冒号后面的是依赖目标。这里会建立构建目标对依赖目标的依赖关系,make能够据此安排好各个目标构建的次序。每个规则下面缩进的部分,就是构建这一目标所需要执行的命令。

另外,Makefile中对GCC/G++编译器指定的-o参数可以指定生成的目标文件的名称。

make会将当前工作目录中的名为Makefile的文件作为默认规则文件。因此,这里调用make时不必指定 Makefile的文件名。如果需要指定自定义的Makefile文件名,可以使用-f参数。

如果想使用Clang编译器而非GCC,也可以在调用make的时候加上参数:

$ make CXX=clang++

那么,make到底是怎么知道哪些源程序做了修改的呢?其实这里没有使用什么奇技淫巧,它只是简单地对比了一下构建目标与依赖目标的修改日期。但凡有一条构建规则中的依赖目标比构建目标更新,这一规则对应的命令就会被重新执行。

使用NMake工具

接下来简要介绍一下Windows平台中NMake的使用。首先是规则文件Makefile的书写方式,如下所示。

main.exe: main.obj slow.obj
    cl /Fe"main.exe" main.obj slow.obj
 
main.obj: main.cpp
    cl /c main.cpp /Fo"main.obj" /EHsc
 
slow.obj: slow.cpp
    cl /c slow.cpp /Fo"slow.obj"

其与前Makefile最直观的不同还是编译器参数不同:

  • /Fe指生成可执行文件的名称;

  • /Fo指生成目标文件的名称;

  • MSVC的目标文件扩展名一般为.obj而不是.o。

另外在后面的示例代码中,为了与make的Makefile做区分,NMake的Makefile文件均命名为NMakefile。由于NMake同样会将Makefile作为默认文件名,这里需要使用/F参数指定自定义文件名为NMakefile。

1.3 构建静态库

静态库(static library),也称为静态链接库(statically-linked library),可以看作最简单直接的一种复用代码的形式。静态库可以被视作一系列目标文件的集合,甚至可以被解包软件打开。在静态库中,除了目标文件,可能还有一些文本文件,它们是静态库的符号索引。

另外,因为库总是要被其他开发者使用的,所以提供一个声明了全部功能函数的头文件十分有必要。这样,开发者只需引用提供的头文件,然后链接静态库,就可以使用该库开发好的实用功能了!头文件相当于对接口的声明,而静态库则在接口之下封装了功能的具体实现。

使用MSVC和NMake构建

NMake Makefile如下所示。

main.exe: main.obj libab.lib
    cl main.obj libab.lib /Fe"main.exe"
 
main.obj: main.c
    cl -c main.c /Fo"main.obj"
 
libab.lib: a.obj b.obj
    lib /out:libab.lib a.obj b.obj
 
a.obj: a.c
    cl /c a.c /Fo"a.obj"
 
b.obj: b.c
    cl /c b.c /Fo"b.obj"

第三条是用于生成libab.lib静态库的规则,需要依赖目标文件a.obj和b.obj。这两个目标文件中包含了静态库所需功能函数a和b的目标代码。这条构建规则的命令部分通过调用MSVC的lib.exe来生成静态库。/out:参数后紧跟的是静态库名,然后是罗列的静态库所需的目标文件。lib.exe会对罗列的目标文件建立索引,并将索引文件与目标文件一起打包成指定名称的静态库libab.lib。当静态库被使用时,编译器就能通过索引文件高效地了解静态库提供了哪些符号。

第一条规则依赖main.obj(即主程序的目标文件)和libab.lib(即静态库)。该规则把这两个文件都作为参数输入MSVC编译器,并设置输出的可执行文件的名称,编译器会调用链接器将二者链接起来,并最终生成可执行文件。

使用GCC和make构建

Makefile如下所示。

main: main.o libab.a
    gcc main.o -o main -L. -lab
 
main.o: main.c
    gcc -c main.c -o main.o
 
libab.a: a.o b.o
    ar rcs libab.a a.o b.o
 
a.o: a.c
    gcc -c a.c -o a.o
 
b.o: b.c
    gcc -c b.c -o b.o

我们先来看第三条规则。它通过ar归档命令,将目标文件a.o和 b.o打包为静态库。这里的ar命令有三组参数,分别是rcs、输出的静态库(归档)文件名和输入的目标文件名。其中,rcs是三个参数的开关:r代表将目标文件归档,c代表创建新归档文件时不输出警告信息,s代表要为归档创建索引。

接着看第一条链接静态库并生成主程序的规则。编译主程序时,将链接器参数设置为-L.,就可以将当前目录作为链接库的搜索路径。-lab指链接名为ab 的库。此处并没有写静态库的完整文件名,因为GCC编译器会自动根据这个基本名称,加上前缀“lib”和扩展名“.a”去搜索。

如果想指定链接库的完整名称,可以在名称前加一个冒号,如-l:libab.a。

事实上,GCC也可以按照类似MSVC的写法来链接静态库,即gcc main.o libab.a -omain。之所以在Makefile中选择了-l参数的写法,是因为这种写法还能同时用于链接动态库。统一采用这种写法,可以不必关注用到的链接库具体以什么形式链接。

1.4 构建动态库

动态库,就是为了解决静态库的维护问题和空间利用问题而产生的。动态库(dynamic library),也称为动态链接库(Dynamically-Linked Library,DLL)或共享库(shared library)。与静态库不同,动态库的目标代码是在程序装载时或运行时被动态链接的,而非在编译过程中静态链接的。这样,动态库与使用动态库的程序就在编译期做到了解耦。如果想更新动态库,那么只需分发新版动态库,并让用户替换掉旧版动态库。程序运行时自然会链接新版的动态库。同时,多个程序也可以共享一个动态库,换句话说,任何程序都能够在运行时将同一个动态库的目标代码动态链接到自己的程序中执行,而且这份动态库的代码在内存中可以只装载一份。这样,空间利用效率就大大提高了。这也是动态库也称为共享库的原因。

Windows中一般称为动态链接库,Linux中一般称为共享库。

Windows和Linux操作系统的动态链接机制有些差异,这也导致其构建过程会有一点不同。

1.4.1 Windows中动态链接的原理

当启动进程时,Windows操作系统会装载进程所需的动态链接库,并调用动态链接库的入口函数。由于64位Windows操作系统默认启用地址空间布局随机化(Address Space Layout Randomization,ASLR)特性,动态链接库被装载时,会根据特定规则随机选取一个虚拟内存地址进行装载。ASLR特性是一个计算机安全特性,主要用于防范内存被恶意破坏并利用。它的存在使得动态链接库装载的内存地址是不固定的,这就意味着其编译后的机器代码中,凡是访问内存某一绝对位置的代码,在装载时都需要被改写。这就是重定位(relocation)。

在32位Windows操作系统中,ASLR没有默认开启。此时,动态链接库将会被装载到偏好基地址(preferred base address)这里。偏好基地址是编译时指定的。不过在装载时,这个地址未必总是可用的:当多个动态链接库都设置了同一个偏好基地址(如均采用默认值),然后被同时装载到同一个进程时,就会出现冲突。这时,后装载的动态链接库就不得不改变装载的内存位置,也就同样需要重定位了。

回想之前提到动态链接库的一大优势,就是复用内存以节约空间。如果Windows操作系统对每个进程装载的动态链接库都重定位到了不同的内存地址,那么装载好的动态链接库该如何被复用呢?

事实上,Windows操作系统并没有总是对动态链接库进行重定位。一旦确定了某一动态链接库装载的虚拟内存地址,后面任何进程再用到同一个动态链接库时,都会将它装载到同一虚拟内存地址中。换句话说,Windows操作系统中的ASLR特性的“随机化”,对于动态链接库而言,只发生在计算机重启后。

事实上,当动态链接库不被所有进程使用后,它会被操作系统从内存中卸载;当它又被重新使用并装载时,其装载位置有可能发生变化,但操作系统并不保证这一点。所以,重启操作系统是唯一能够保证动态链接库装载地址发生随机改变的方法。

使用MSVC和NMake构建

MSVC构建动态库需要提供一个模块定义文件(扩展名为.def),用于指定导出的符号名称(函数或变量的名称)。开发者可以决定动态库暴露给用户使用的函数或变量有哪些,并隐藏其他符号,避免外部用户使用。这也是动态库的一个特点,相比静态库而言,动态库能够提供更好的封装性。

对于liba.dll动态库来说,只需导出函数a。其模块定义文件liba.def如下所示。

EXPORTS 
    a

有了模块定义文件,就可以构建动态库了。构建命令与构建静态库非常类似:输入参数多了一个模块定义文件,输出参数要指定动态库的文件名,然后由参数指定构建目标的类型是动态库,另外还多了一个/link参数。Makefile如下所示。

liba.lib liba.dll: a.obj liba.def
    cl a.obj /link /dll /out:liba.dll /def:liba.def

/link参数用于分隔编译器参数和链接器参数,即/link后面的参数都将传递给链接器。与可执行文件类似,动态库也是将编译好的目标文件链接后的产物,因此/dll、/out和/def这些参数实质上是传递给链接器的,它们分别用于设置构建类型为动态库、输出的动态库文件名及输入的模块定义文件名。

Makefile中构建动态库的这一行规则,构建目标不止一个:除了liba.dll外,还有一个liba.lib。这怎么会有一个静态库呢?

其实这并非一个静态库。“.lib”文件还可以是动态库的导入库文件,也就是这里的情况。在Windows操作系统中,一个程序如果想链接一个动态库,就必须在编译时链接动态库对应的导入库(这里指在编译的链接阶段进行动态链接需要导入库。如果是运行时动态装载链接,则不需要)。我们可以简单地把“.lib”导入库看作一种接口定义,在链接时提供必要信息;而“.dll”动态库则包含运行时程序逻辑的目标代码。因此,编译链接时,只导入库提供的链接信息就够了;只有程序运行时,才需要动态库的存在。

该实例的完整Makefile如下所示。

main.exe: main.obj liba.lib
    cl main.obj liba.lib /Fe"main.exe"
 
main.obj: main.c
    cl -c main.c /Fo"main.obj"
 
liba.lib liba.dll: a.obj liba.def
    cl a.obj /link /dll /out:liba.dll /def:liba.def
 
a.obj: a.c
    cl /c a.c /Fo"a.obj"
 
clean:
    del /q *.obj *.dll *.lib *.exp *.ilk *.pdb main.exe

由于导入库文件和静态库文件的扩展名都是“.lib”,第一条主程序链接动态库的构建规则看起来和链接静态库时的规则完全一致。

Makefile最后增加了一条清理构建文件的规则。执行make clean指令,就会删除工作目录中所有的目标文件、库文件和可执行文件等。

为了验证前面提到的原理,不妨同时运行多个主程序实例,观察它们各自输出的内存地址:同时运行两个main.exe,它们输出的内存地址将是相同的;重启计算机后,再次运行 main.exe,它输出的内存地址就发生了变化,但此时再运行一个main.exe,它又会输出同样的内存地址。这个现象印证了Windows操作系统中动态库会被装载到同一虚拟内存地址的说法,而且重启计算机后装载地址会被重新随机计算。

当然,目前只能证明动态库被装载到了同一虚拟内存地址中。为了进一步证明它在物理内存中也是被共享的,可以借助VMMap工具查看主程序main.exe进程的虚拟内存,观察动态库liba.dll虚拟内存空间的使用情况。

VMMap工具的数据所示,liba.dll的专用工作集(private working set)只占用了的虚拟内存空间(12 KB),而共享工作集(shared working set)则占用了更多的虚拟内存空间(80 KB)。对于工作集(Working Set,WS)这个概念,本书不做过多解释,读者只需将其类比为占用的内存(这个类比并不准确,工作集实际上指进程的那些已被加载到物理内存中的虚拟内存页)。 “专用”指只能被当前进程访问,“共享”则指能够被多个进程访问。由此可见,动态库liba.dll 被装载到虚拟内存中的大部分空间,都是在物理内存中共享的。

1.4.2 Linux中动态链接的原理

Linux操作系统同样具有ASLR特性:通常情况下,每一个进程被创建时,都会将其可执行文件及其链接的动态库装载到不同的随机虚拟地址。这相比Windows操作系统更为激进,也提供了更好的安全性。

不过,如果每一个进程都对代码中访问绝对地址的部分进行重定位,由于其装载地址不同,这些绝对地址也就不同,重定位后的访存的代码就不可能一致,从而无法在物理内存中共享代码段。Linux中通常将动态库称为共享库,要是连共享都不支持,又怎么会这么称呼呢?显然,这是能做到的——不访问内存绝对地址不就可以了嘛!

地址无关代码(Position-Independent Code,PIC)就是指这种不访问内存绝对地址的代码。如果想让GCC编译器和Clang编译器生成地址无关代码,必须指定一个编译器参数-fPIC。

既然地址无关代码这么方便,编译器为什么不直接默认启用它呢?这是因为它往往是有额外代价的。当启用了地址无关代码之后,目标代码访问全局变量、调用全局函数时,都会使用全局偏移表(Global Offset Table,GOT)做一次中转。也就是说,目标代码中访问的内存地址实际上对应GOT的某个位置,这个位置记录了要访问的变量或调用的函数的实际内存地址。由于ASLR特性的存在,动态链接库会在运行时被装载到随机的内存地址中,则GOT各个表项的值只能在运行时被替换——这就是动态重定位。

可见,GOT是作为一个跳板存在的,启用地址无关代码会导致访存次数增多,指令数增多,也就在一定程度上影响性能;另外,由于多了这些记录内存地址的条目,目标代码的体积也不可避免地要大一些。

事实上,由于x64 CPU指令集支持相对当前指令地址寻址(Relative Instruction Pointer Addressing,RIP Addressing),在实现地址无关代码时,相比x86 CPU指令集可以减少很多指令。尽管如此,由于指令数和访存次数终究比直接重定位的程序要多,性能自然还是有所损失,只不过x86平台损失的会更多。因此,编译器并不会默认开启地址无关代码的编译选项。

那么,Linux操作系统为什么不直接像Windows操作系统一样直接对代码中的访存地址进行重定位,而是一定要加一个跳板呢?别忘了,Linux操作系统的ASLR特性提供了更好的安全性,每次启动进程时,动态库的装载地址都是随机的。如果直接对代码中的访存地址进行重定位,这段代码就不能被共享了。另外,Linux操作系统在进行动态重定位时,可以只修改数据段中的GOT,而且每一条目只修改对应的一处数据段的位置。这样,比起修改代码段每一处访存位置要轻松得多,同时也避免了修改代码段这种比较危险的行为。

实际上,Linux确实也支持类似Windows操作系统中通过静态重定位实现动态链接的方式,不过如果此时ASLR特性也是启用的,动态库就确实不能在物理内存中共享了。

使用GCC和make构建

与MSVC相比,GCC构建动态库的方法可以说大同小异,最主要的区别就是刚刚在原理中提到的用于启用地址无关代码的-fPIC编译选项,以及用于表示生成动态库的-shared编译选项。Makefile如下所示。

main: main.o liba.so
    gcc main.o -o main -L. -la
 
main.o: main.c
    gcc -c main.c -o main.o
 
liba.so: a.o
    gcc -shared a.o -o liba.so
 
a.o: a.c
    gcc -fPIC -c .a.c -o a.o
 
clean:
    rm *.o *.so main || true

Makefile中也加入了一个clean目标,以便清理构建文件。使用make构建该实例并运行主程序:

$ cd CMake-Book/src/ch001/动态库
$ make -f Makefile0
$ ./main
./main: error while loading shared libraries: liba.so: cannot open shared object file: No such file or directory
$ ls *.so
liba.so

运行主程序会报错,提示找不到动态库liba.so,可它明明就在当前目录呀!

当运行主程序时,系统的动态链接器必须能够找到主程序所需的动态库,但它默认只会在系统配置的一些目录下搜索动态库,而不会考虑当前目录。包含搜索路径的配置文件位于/etc/ld.so.conf。当然,为了运行程序就去修改系统配置显然是不合理的。动态链接器还可以根据环境变量LD_LIBRARY_PATH的值来搜索动态库,因此可以通过设置环境变量来提示链接器:

$ LD_LIBRARY_PATH=. ./main
&x: 7fdce6ff1028
&a: 7fdce6df063a
&b: 7fdce740078a
&y: 7fdce7601010

主程序运行成功!不过,不管是修改配置文件还是修改环境变量,都需要用户来操作,这未免太不方便了。程序的作者是否有办法告诉链接器去哪里搜索动态库呢?

当然可以,程序既然有能力告诉动态链接器它需要链接哪些动态库,就也应该有本事提醒动态链接器去哪里搜索动态库。这些信息存储在程序的动态节(dynamic section)中,我们可以通过readelf 命令查看:

$ readelf -d ./main
 
Dynamic section at offset 0xda8 contains 28 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [liba.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 ...

其中,-d参数就是指查看动态节的内容。主程序的动态节前两项是NEEDED项,记录了它所依赖的动态库的名称。那么该如何把动态库的搜索路径也存进去呢?

Linux可执行文件的动态节中有两个与动态库搜索路径相关的条目,一个是RPATH,一个是 RUNPATH。二者的区别在于优先级,动态链接器会按照下面列举的顺序依次搜索:

1.动态节中的RPATH项指定的路径;

2.环境变量LD_LIBRARY_PATH指定的路径;

3.动态节中的RUNPATH项指定的路径。

4.系统配置文件/etc/ld.so.conf指定的路径等;

如果程序中写死了RPATH,就相当于堵死了用户去覆盖搜索路径的可能。因此,RPATH已经被废弃,但由于它还有一定的实用性,实际上仍然很常用。例如,程序依赖某一特定版本的系统库,并将这一系统库与程序一同打包发布,希望程序使用打包提供的这一个版本的系统库,而不是去系统搜索路径中搜索系统自带的版本。此时,就可以通过设置RPATH来实现该需求。这样,就可以避免一些版本不一致造成的兼容性问题了。

当然,如果是类似现在所遇到的找不到库的情况,指定RUNPATH就是推荐的方法,因为这样可以把链接库存放位置的决定权留给用户。我们可以通过修改链接器参数向程序中写入 RUNPATH,如下所示。

main: main.o liba.so
    gcc main.o -o main -L. '-Wl,-R$$ORIGIN' -la

Makefile在构建主程序时为编译器加上了参数’-Wl,-R$$ORIGIN’。逗号前的部分-Wl类似MSVC中的编译器参数/link,用于在编译器的命令行中向链接器传递参数。不过MSVC中的/link是将所有跟随其后的参数作为链接器的参数,而GCC 编译器中的-Wl会将其逗号后的一个参数当作链接器参数进行传递。所以,这里实质上是为链接器传递了一个-R参数。

Makefile中的一般用于引用变量,当确实需要一般用于引用变量,当确实需要一般用于引用变量,当确实需要这个字符时,可以通过两个符号来转义。因此,这里的符号来转义。因此,这里的符号来转义。因此,这里的ORIGIN实际上是字面量ORIGIN实际上是字面量ORIGIN实际上是字面量ORIGIN。另外,整个链接器参数是夹在单引号间的,这样ORIGIN就不会被当作对环境变量的引用,而是将其本身的字面量作为参数进行传递。总而言之,这就是向链接器传递了一个−R参数,其值为ORIGIN就不会被当作对环境变量的引用,而是将其本身的字面量作为参数进行传递。总而言之,这就是向链接器传递了一个-R参数,其值为ORIGIN就不会被当作对环境变量的引用,而是将其本身的字面量作为参数进行传递。总而言之,这就是向链接器传递了一个R参数,其值为ORIGIN。

链接器参数-R正是用于设置RUNPATH,ORIGIN则是程序所在目录。之所以设置为程序所在目录ORIGIN则是程序所在目录。之所以设置为程序所在目录ORIGIN则是程序所在目录。之所以设置为程序所在目录ORIGIN,而非当前工作目录“.”,是因为用户通常不会以动态库所在的目录作为当前工作目录来运行程序,但动态库通常会在可执行文件的同一目录下。当然,动态库也可以与可执行文件保持一个相对位置,这样RUNPATH也就应该设置为相对ORIGIN的路径,如ORIGIN的路径,如ORIGIN的路径,如ORIGIN/lib。

使用修改后的Makefile重新构建该实例:

$ make clean
rm *.o *.so main || true
$ make
...
$ ./main
&x: 7f5b97ff1028
&a: 7f5b97df063a
&b: 7f5b9840078a
&y: 7f5b98601010

终于可以直接运行主程序main,而不必设置任何环境变量了。除了替换RUNPATH外,我们也可以通过替换RPATH来解决问题,但不推荐采用这种方法。二者方法基本一致,只需将参数改为 ‘-Wl,-rpath=$$ORIGIN’。

现在不妨同时运行多个实例,回顾一下前面提到的原理。在终端中运行主程序main:

$ ./main
&x: 7fcf7bff1028
&a: 7fcf7bdf063a
&b: 7fcf7c40078a
&y: 7fcf7c601010

目前主程序停在getchar()函数中等待输入,先不要中断它。与此同时,再打开一个终端运行主程序:

$ ./main
&x: 7f2a883f1028
&a: 7f2a881f063a
&b: 7f2a8880078a
&y: 7f2a88a01010

啊哈,二者输出的地址都不一样!这确实可以反映Linux中较为激进的ASLR特性。下面再观察一下动态库是否真的在物理内存中共享。我们可以借助进程的内存使用记录表来证明这一点。再打开一个新的终端(不要关闭之前运行中的两个主程序):

$ ps aux | grep main
...      15521  ...   ./main
...      15571  ...   ./main
...
$ cat /proc/15521/smaps
...
7fcf7bdf0000-7fcf7bdf1000 r-xp 00000000 00:00 1057893    .../liba.so
Pss:                   1 kB
...
7fcf7bff1000-7fcf7bff2000 rw-p 00001000 00:00 1057893    .../liba.so
Pss:                   4 kB
...
 
$ cat /proc/15571/smaps
...
7f2a881f0000-7f2a881f1000 r-xp 00000000 00:00 1057893    .../liba.so
Pss:                   1 kB
...
7f2a883f1000-7f2a883f2000 rw-p 00001000 00:00 1057893    .../liba.so
Pss:                   4 kB
...

smaps中包含程序虚拟内存空间的使用情况,其中的Pss指分摊内存(Proportional Set Size,PSS),代表了这部分内存空间被共享进程平均分摊后的大小。或者说,用总占用内存空间除以共享这部分内存的进程的数量得出的结果。

观察程序输出的&x和&a,它们分别位于动态库的代码段和数据段中。例如,&x: 7fcf7bff1028对应的smaps表就位于最后一部分7fcf7bff1000-7fcf7bff2000中,可见这部分对应于动态库的数据段。同理,&a: 7fcf7bdf063a对应第一部分的7fcf7bdf0000-7fcf7bdf1000,属于代码段。动态库被多个进程共享的部分应是代码段,所以着重观察第一部分。

目前对于动态库的第一部分(代码段)的内存空间,在两个主程序进程中都占用了1KB的空间。关闭一个终端中的程序,再次观察:

$ kill 15571
$ cat /proc/15521/smaps
...
7fcf7bdf0000-7fcf7bdf1000 r-xp 00000000 00:00 1057893    .../liba.so
Pss:                   2 kB
...
7fcf7bff1000-7fcf7bff2000 rw-p 00001000 00:00 1057893    .../liba.so
Pss:                   4 kB
...

果然,剩下的唯一主程序进程中,动态库所在内存空间的第一部分,也就是代码段的Pss上涨到了2 KB,而最后一部分对应的数据段的Pss则没有变化。也就是说,代码段确实在物理内存中共享。

1.5 引用第三方库

1.5.1 下载Boost C++库

读者可以在Boost官方网站中找到针对UNIX和Windows的下载链接。

下载压缩包并解压后,可以找到名称以boost开头的文件夹,boost后面的数字代表版本号,如1_74_0代表1.74.0版本。下载版本不同,文件夹名称也有所不同。本书将以1.74.0版本为例进行讲解。

解压文件夹以备后续使用。本书为了避免使用的Boost库版本与读者使用的不同从而造成指定目录的麻烦,假定解压后的boost_1_74_0文件夹被重命名为boost,不再体现版本号。该文件夹在Windows操作系统中被解压到C盘根目录,即C:\boost;在Linux操作系统中则被解压到Home目录中,即~/boost。

Boost库中有一些源程序,需要被编译成动态库或静态库来使用。但我们暂时不会用到这些编译后的库文件,因此Boost库的安装构建会在后续章节介绍。

1.5.2 引用Boost C++头文件库

首先来尝试使用Boost中的头文件库。头文件库(header-only library)指只包含头文件(.h、.hpp等)的程序库。使用这种库非常方便,只需在程序中引用它的头文件,无须对库本身进行额外的编译。源程序引用头文件,相当于复制了头文件的内容,这样头文件库实际上也就成为了引用它的程序的一部分。所以使用头文件库只需编译引用它的程序,头文件库代码会自动被编译。

除了用起来简单,头文件库在性能方面也更具有优势。这是因为它能够直接被程序以源代码的形式引用,编译器能够更好地进行代码优化,如实现更多的函数内联,有助于提升程序的整体性能。

但其缺点也很明显,那就是影响编译时间。因为头文件库本身没有源程序,无法独立编译成目标文件,再被链接到使用它的程序中,这就不可避免地需要反复编译头文件中的程序。另外,分发头文件库也意味着开源是必需的了,毕竟需要用户来编译。这反映了头文件库的封装性相对较差。

总而言之,对于较为常用且简单的库,尤其是追求极致性能的库,使用头文件库的形式非常合适。最典型的例子可能就是C++的标准模板库(Standard Template Library,STL)了。

Boost中也有很多头文件库,本小节将使用Boost字符串算法库(Boost string algorithms library)来编写例程。主程序main.cpp如下所示。

#include <boost/algorithm/string.hpp>
#include <iostream>
 
using namespace std;
using namespace boost;
 
int main() {
    string str = "  hello world!";
    cout << str << endl;
 
    to_upper(str);
    cout << str << endl;
 
    trim(str);
    cout << str << endl;
 
    return 0;
}

引用boost/algorithm/string.hpp头文件即可使用Boost字符串算法库。它提供了很多方便操作字符串的函数。主程序中使用to_upper函数将str转换为大写,使用trim函数去除str首尾的空白字符。

使用MSVC/NMake构建本例

Makefile如下所示。

main.exe: main.cpp
    cl main.cpp /I "C:\boost" /EHsc /Fe"main.exe"
 
clean:
    del *.obj *.exe

这里为编译器提供了参数/I “C:\boost”,表示将C:\boost添加到编译器的头文件搜索目录中,以便找到Boost头文件。

使用GCC/make构建本例

Makefile如下所示。

main: main.cpp
    g++ main.cpp -I ~/boost -o main
 
clean:
    rm main

GCC设定头文件搜索目录的参数是-I,其他设置与NMake Makefile几乎一样。

1.5.3 安装Boost C++库

刚刚我们简单尝试了Boost的头文件库,这并不需要对Boost库本身进行编译。而后面的小节将链接 Boost的静态库,需要提前准备已经编译好的Boost库文件。我们可以自行构建Boost库,或者下载安装预编译的二进制文件。

在Windows中构建Boost库

打开Visual Studio的命令行工具,执行下列命令即可完成Boost库的构建:

> cd C:\boost
> bootstrap
> .\b2

构建过程较为耗时,请耐心等待。构建完成后,可以在C:\boost\stage\lib目录中看到所有构建好的Boost静态库。另外,在b2命令后追加参数link=shared,static即可同时构建动态库。此时,C:\boost\stage\lib目录中会同时存在静态库、动态库和导入库。由于静态库和导入库的扩展名都是.lib,Boost通过文件名前缀来辨别二者:lib开头的.lib文件是静态库,而那些与动态库文件名完全匹配的则是动态库的导入库。

在Linux中构建Boost库

在Linux中,构建Boost库的步骤几乎与Windows中一致,打开终端执行以下命令:

$ cd ~/boost
$ ./bootstrap.sh
$ ./b2

喝一杯茶再回来,应该就能在~/boost/stage/lib目录中看到所有构建好的Boost动态库及静态库了!

在Windows中安装预编译的Boost库

由于C++标准不保证编译后的应用程序二进制接口(Application Binary Interface,ABI)稳定性,不同版本的编译器编译出的程序无法保证相互引用而不出错。所以,我们必须根据微软C++工具集(Microsoft C++ Toolset)的版本号来决定安装哪个版本的预编译Boost库。读者如果不确定正在使用哪个版本的工具集,可以打开Visual Studio命令行工具,输出环境变量 VCToolsVersion:

> echo %VCToolsVersion%
14.27.29016

其中,主版本号14和次版本号的第一个数字2唯一决定其ABI稳定性。也就是说,如果Boost库是通过14.2*版本的工具集构建的,就能被上述“14.27.29016”版本的工具集引用。网络上有很多针对各个版本的工具集预编译好的Boost库二进制文件,下载时,一定要注意挑选匹配的版本,还要区分一下32位和64位的版本。

因为笔者用的是14.27.29016版本的工具集,所以下载的安装包是boost_1_74_0-msvc- 14.2-64.exe。下载完成后,运行安装程序将其安装到某一目录即可。本书假定预编译Boost库的安装根目录为C:\boost_prebuilt(注意区分自行构建的Boost库根目录C:\boost,后面会分别演示)。

在Ubuntu中安装预编译的Boost库

在Ubuntu发行版中可以直接通过包管理器直接安装预编译的Boost库:

$ sudo apt install libboost-all-dev

不过这样安装的只有头文件和动态库,分别位于/usr/include和/lib/x86_64- linux-gnu目录中。由于这些都是系统目录,即使不向编译器提供-I或-L参数,编译器也会默认在这里搜索头文件和库,非常方便。实际上,自行构建Boost库时,也可以通过./b2 install将Boost库安装到系统目录中。

在CentOS中安装预编译的Boost库

在CentOS发行版中同样可以通过包管理器安装预编译的Boost动态库:

$ sudo dnf install boost-devel

此时,安装好的头文件和动态库分别位于/usr/include和/usr/lib64系统目录中。

1.5.4 链接Boost C++库

本小节将使用Boost Regex库提取一段文本中出现的所有URL。

使用Boost Regex库提取URL

主程序main.cpp如下所示。

#include <boost/regex.hpp>
#include <iostream>
#include <string>
 
using namespace std;
using namespace boost;
 
int main() {
    string s = R"(
Search Engines: http://baidu.com https://google.com
About Me: https://xuhongxu.com/about/
    )";
    regex e(R"(([a-zA-Z]*)://[a-zA-Z0-9./]+)");
 
    for (sregex_iterator m(s.begin(), s.end(), e), end; m != end; ++m) {
        cout << "URL: " << (*m)[0].str() << endl;
        cout << "Scheme: " << (*m)[1].str() << endl;
        cout << endl;
    }
 
    return 0;
}

其中,首先引用了头文件boost/regex.hpp,然后在主程序中初始化了一个boost::Regex类型的变量,即用于提取URL的正则表达式:

([a-zA-Z]*)://[a-zA-Z0-9./]+

注意:由于该表达式仅用于演示,刻意写得较为简短,并不能准确提取URL。

for循环起始条件中,初始化了sregex_iterator迭代器m,用于遍历字符串中匹配到的全部结果;还有一个空迭代器end,用于指示迭代器的终止位置。迭代器的值,也就是匹配结果,采用类似数组的形式,可以通过索引访问。第0项为完全匹配的结果,后续索引项则依次是各个捕获组的结果。

使用MSVC/NMake构建本例

这里将构建两次主程序main.cpp,分别演示对Boost库的静态链接和动态链接。其中,静态链接Boost库的可执行文件名为static_boost.exe,动态链接Boost库的可执行文件名为shared_boost.exe。 Makefile如下所示。

# 自行构建的Boost库
 
BOOST_DIR=C:\boost
BOOST_LIB_DIR=$(BOOST_DIR)\stage\lib
 
# 下载安装的预编译Boost库
 
# BOOST_DIR=C:\boost_prebuilt
# BOOST_LIB_DIR=$(BOOST_DIR)\lib64-msvc-14.2
 
CXXFLAGS=/I $(BOOST_DIR) /MD /EHsc
LINKFLAGS=/LIBPATH:$(BOOST_LIB_DIR)
 
all: static_boost.exe shared_boost.exe
 
static_boost.exe: main.cpp 
    cl libboost_regex-vc142-mt-x64-1_74.lib \
        main.cpp $(CXXFLAGS) /Fe"static_boost.exe" /link $(LINKFLAGS)
 
shared_boost.exe: main.cpp 
    cl boost_regex-vc142-mt-x64-1_74.lib /DBOOST_ALL_NO_LIB \
        main.cpp $(CXXFLAGS) /Fe"shared_boost.exe" /link $(LINKFLAGS)
 
clean:
    del *.obj *.exe

其中定义了BOOST_DIRBOOST_LIB_DIR两个变量,分别代表Boost的根目录和库文件所在的目录。这里有两组变量的定义,其中第二组被注释掉了。第一组的目录是自行构建的Boost库所在的目录,第二组则是预编译库的安装目录。读者可以自行切换,构建结果是相同的。

CXXFLAGS变量用于向编译器传递公共参数。/I用于指定头文件搜索目录,这里直接设置为Boost的根目录即可。/MD参数代表程序将会动态链接C++运行时库,与之相对地,MSVC还有一个/MT参数,表示程序将会静态链接C++运行时库。由于Boost库的构建过程会默认指定/MD,这里引用Boost库的主程序也应该使用匹配的方式。

LINKFLAGS变量定义了向链接器传递的公共参数LIBPATH,即链接库的搜索目录。

下面是构建目标规则。由于本例将构建两个可执行文件,所以第一条规则将构建目标写为 all,同时依赖这两个可执行文件。这样,执行nmake all可以同时构建二者。另外,Makefile 的第一条规则是默认规则,当不提供目标参数执行nmake时会默认执行,因此执行nmake就相当于执行nmake all(不过对于本例来说,记得指定/F NMakefile参数)。

静态链接Boost库的主程序static_boost.exe的构建规则中,除了将CXXFLAGS和LINKFLAGS变量中定义的参数传递给编译器和链接器外,还向编译器传递了Boost Regex静态库的文件名。

动态链接Boost库的主程序shared_boost.exe的构建规则稍微复杂。与静态库相似但不同的是它所链接的.lib库是动态库对应的导入库。另外还多了一个宏的定义:BOOST_ALL_NO_LIB。这个宏用于指示Boost库不要试图寻找静态库进行链接,当动态链接Boost库时,都应该定义这个宏。

执行NMake构建该项目:

> cd CMake-Book\src\ch001\链接Boost
> nmake /F Makefile
> static_boost
URL: http://baidu.com
Scheme: http
 
URL: https://google.com
Scheme: https
 
URL: https://xuhongxu.com/about/
Scheme: https
 
> shared_boost # 无法启动

静态链接Boost库的主程序一切正常!但是,动态链接Boost库的主程序在运行时会抱怨找不到Boost 的动态库。这也是意料之中的事情,毕竟Boost的动态库与主程序并不在同一目录,而且Windows中也没有RUNPATH和RPATH,我们需要先复制动态库boost_regex-vc142-mt- x64-1_74.dll再运行。

使用GCC/GNU make构建本例

为了更好地对比,在Linux操作系统中,这里仍然以静态和动态两种链接Boost库的形式来构建本例。 Makefile如下所示。

# 自行构建的Boost库
 
BOOST_DIR = $${HOME}/boost
BOOST_LIB_DIR = ${BOOST_DIR}/stage/lib
 
CXXFLAGS = -I $(BOOST_DIR)
LDFLAGS = -L $(BOOST_LIB_DIR) -Wl,-R$(BOOST_LIB_DIR)
 
# 将以上几行全部注释,即可使用安装的预编译Boost库
 
all: static_boost shared_boost
 
static_boost: main.cpp
    g++ main.cpp $(CXXFLAGS) $(LDFLAGS) -l:libboost_regex.a -o static_boost
 
shared_boost: main.cpp
    g++ main.cpp $(CXXFLAGS) $(LDFLAGS) -lboost_regex -o shared_boost
 
clean:
    rm *_boost

首先定义与Boost库目录相关的变量。BOOST_DIR是自行构建的Boost库所在的根目录,也就是~/boost;但由于RUNPATH需要使用绝对路径,我们将它写作$HOME。两个{HOME}。两个HOME。两个代表的转义,因此这里实际上引用了的转义,因此这里实际上引用了的转义,因此这里实际上引用了{HOME},它是代表Home目录绝对路径的环境变量。BOOST_LIB_DIR变量,与Windows中一样,定义了Boost库的库文件目录。

不过这里为什么不像NMake Makefile中一样,提供预编译库的路径变量呢?答案很简单,因为GCC会主动搜索系统的头文件目录和库文件目录,而系统包管理器安装的Boost预编译库正是安装在系统目录中。如果想让构建的程序直接链接它们,只需将Makefile 中前面这四个变量的定义注释掉,让GCC自动去默认的目录搜索头文件和库文件。

CXXFLAGS和LDFLAGS变量分别代表公共的编译和链接参数。编译参数-I指定了头文件库搜索目录,链接参数-L指定了链接库文件搜索目录,链接参数-Wl,-R 指定了RUNPATH的值。

最后,构建主程序的规则:无论是静态链接Boost库,还是动态链接Boost库,调用GCC的方式都是一样的,区别仅仅在于链接库的名称。由于链接库时,-l参数默认接受的是库的名称,而非文件名。所以,链接静态库libboost_regex.a或动态库libboost_regex.so时,应该指定参数-lboost_regex,这就冲突了,此时 GCC会优先链接动态库。为了能够实现对Boost静态库的链接,这里需要使用-l:加静态库文件全名的参数形式。

执行make构建该项目:

$ cd CMake-Book/src/ch001/链接Boost
$ make
$ ./static_boost
...
$ ./shared_boost
...

Linux中的程序可以指定RUNPATH,因此无须复制Boost动态库文件就可以运行shared_boost。

1.6 旅行笔记

1.6.1 构建的基本单元:源程序

如果不把头文件当作源程序,则可以说,源程序就是会被编译器编译成目标文件的文件。源程序可以看作构建过程中最基本的组成单元。构建时,应当根据源程序所采用的编程语言,使用对应的编译器;同时,还要根据一些特殊的构建要求,确定编译时传递的参数,例如:

  • 头文件搜索目录;

  • 链接库文件搜索目录;

  • 宏定义;

  • 其他编译链接参数等(如编译优化选项等)。

这些可以称为源程序的属性。构建系统会根据源程序的属性设定参数并调用编译器,从而正确生成目标文件。

1.6.2 核心的抽象概念:构建目标

目标文件虽然名叫“目标”,但终究不是我们最终想要的目标。因此有构建目标(target,简称目标)这个概念。构建目标是建立在源程序之上的更高层抽象。当我们将一系列源程序组织成一个构建目标,就相当于为这些源程序指定了一些共同的编译和链接参数。

一般来说,我们会将一些目标文件打包或链接成库文件或可执行文件,这样这些库文件和可执行文件就可以称作构建目标了。当然,具体一点的话,它们是二进制构建目标(binary target)——多了个“二进制”的前缀。一是因为构建产生的库文件和可执行文件都是二进制文件,二是为了区分不产生二进制文件的构建目标,也就是后面会提到的伪构建目标(pseudo-target)。

二进制构建目标

二进制构建目标基本上包括以下类型:

  • 可执行文件;

  • 一般库(包含静态库和动态库);

  • 目标文件库。

目标文件库(object library)是个新概念,但非常好理解——它就是目标文件的集合。它类似静态库,只不过省去了索引和打包的步骤。因此,构建目标文件库并不会产生一个库文件,而只是将其包含的源程序编译成目标文件。

我们引入这样一个概念同样是为了实现更灵活的代码复用。例如,当我们想复用源程序,但不愿产生额外的静态库文件时,就可以使用目标文件库。可以说,目标文件库并非是一个传统意义上的库,它更像是一个逻辑上的概念。但它毕竟包含一系列源程序,并指导编译器将它们编译为目标文件。也就是说,它终究还是产生了一系列二进制文件,所以我们仍将其看作二进制构建目标中的一种类型。

伪构建目标

在介绍构建目标时说过,伪构建目标不会产生二进制文件。那么,我们为什么还需要它呢?

还记得头文件库吗?头文件库本身不需要编译或链接,那么如果将它当作一个构建目标的话,不正是一种不会产生二进制文件的构建目标嘛!可是既然不需要构建,为什么还把它当作一个构建目标呢?这是一个好问题。目前为止,我们在理解构建目标时,总想着它是如何被构建的,但实际上构建目标这个抽象概念还有另一大作用,那就是声明它应当如何被使用。

以头文件库为例。如果利用构建目标抽象表示一个头文件库,那么其他程序在使用头文件库时,只需引用这个构建目标,并不需要知道头文件库具体的存储位置。可见,这个构建目标本身隐含了对使用者的要求:请在编译参数中指定头文件搜索目录为本目标代表的头文件库所在的目录。

将头文件库这种伪构建目标推广一下:自身不需要编译,但对使用者有一定要求的构建目标。这个推广后的伪构建目标称作接口库(interface library)。

当然,伪构建目标不止接口库这一种类型,它包含以下三种类型:

  • 接口库;

  • 导入目标;

  • 别名目标。

导入目标(imported target)一般用于抽象第三方库中的构建目标。第三方库要么是我们自己提前构建好的,要么是直接安装的预编译库,总之无须在使用它的时候再来构建。因此,导入目标尽管可能代表了某些二进制文件,但并不需要构建产生二进制文件,当然也是伪构建目标中的一种。与接口库类似,它自身无须编译,但对使用者提供了编译和链接的要求。

别名目标(alias target)就更加抽象了。顾名思义,它就是另一个构建目标的别名。既然是别名,也就没有必要再构建一次了,所以它同样是一种伪构建目标。别名目标通常用于隐藏实现细节。假设现在有一个自行构建的Boost库目标“boost”,一个预编译Boost库的导入目标“boost_prebuilt”,还有很多程序会链接Boost库。我们这时希望有一个开关能够切换这些程序是链接“boost”还是“boost_prebuilt”目标,那么可以创建一个别名目标“boost_alias”,根据设定作为“boost”“boost_prebuilt”的别名。其他程序则无须关心设定,直接链接到“boost_alias”别名目标即可。

1.6.3 目标属性

前面提到源程序的属性可以用于确定调用的编译器及传递的参数,构建目标也应当拥有一些属性。对于伪构建目标而言,属性主要用于表示它应该被如何使用,即确定使用者的编译和链接参数;对于二进制构建目标来说,属性不仅用于表示它应该被如何使用,还用于确定自身源程序编译和链接时所需的参数。

构建要求和使用要求

与构建目标自身源程序相关的属性,确定了构建目标的构建要求(build specification);而与其使用者相关的属性,则决定了构建目标的使用要求(usage requirements)。目标的使用要求,实际上会被传递到该目标使用者的构建要求中。正是这两种需求赋予了构建目标这个概念丰富的内涵,使其称为最核心的抽象概念。

构建要求和使用要求的区别在于要求所作用的对象,其要求本身并无区别——这也很好理解,毕竟这要求最终体现在源程序的编译和链接上,不论作用于谁,这一点都不会有变化。因此,常见的要求也就是之前提到的那些:

  • 头文件搜索目录;

  • 链接库文件搜索目录;

  • 宏定义;

  • 其他编译链接参数等。

下面以构建动态库为例,带领大家大致感受一下构建要求和使用要求,二者之间又有何种联系。

Windows中动态库构建目标的要求

回顾1.4.1 Windows中动态链接的原理 使用MSVC和NMake构建的Makefile中构建动态库的具体命令。其中,最后两条规则是与构建动态库相关的规则,构建要求自然也应该在这里体现,如下所示。

liba.lib liba.dll: a.obj liba.def
    cl a.obj /link /dll /out:liba.dll /def:liba.def
 
a.obj: a.c
    cl /c a.c /Fo"a.obj"

编译构建目标的源程序a.c到目标文件a.obj,并通过/link、/dll、/out:liba.dll和/def:liba.def等参数链接当前构建目标所对应的目标文件a.obj——这就是liba.dll这个动态库构建目标的构建要求。

使用要求自然应该在使用动态库的主程序的构建规则中体现,如下所示。

main.exe: main.obj liba.lib
    cl main.obj liba.lib /Fe"main.exe"
 
main.obj: main.c
    cl -c main.c /Fo"main.obj"

指定liba.lib导入库文件名作为链接参数,就是liba.dll动态库构建目标的使用要求。main.exe作为一个该动态库的使用者,会将liba.lib与主程序编译后的目标文件main.obj一同链接。这里也体现了动态库构建目标的使用要求会被传递给主程序,作为主程序构建要求的一部分。

这种构建要求和使用要求的模型能够将各部分的构建解耦。编写主程序时无须操心它所链接的各个库应当如何被构建和使用,各个库会主动告知这一切。

Linux中动态库构建目标的要求

在Linux中构建动态库的Makefile参见1.4.2 Linux中动态链接的原理 使用GCC和make构建。这里关注最后两条规则,如下所示。

liba.so: a.o
    gcc -shared a.o -o liba.so
 
a.o: a.c
    gcc -fPIC -c .a.c -o a.o

这两条规则实际上声明了动态库liba.so这个构建目标的构建要求:使用-fPIC参数编译构建目标的源程序a.c到目标文件a.o,使用-shared参数将目标文件链接成最终的动态库。

再来看第一条构建主程序的规则,如下所示。

main: main.o liba.so
    gcc main.o -o main -L. -la
 
main.o: main.c
    gcc -c main.c -o main.o

主程序的构建要求包括在链接过程中通过-L.参数指定链接库搜索目录,并通过-la参数指定链接库的名称。这同时也是动态库构建目标的使用要求。动态库的使用要求会传递到主程序的构建要求中。

我们通过重温动态库在不同平台的构建和使用过程,了解了动态库在对应平台的构建要求和使用要求。其他二进制构建目标类型(如静态库、可执行文件)与之类似,但伪构建目标会有些不同,因为它们不需要被构建,自然也就不存在对应的构建要求,而只存在使用要求。

1.6.4 使用要求的传递性

1.6.3小节说明了构建要求和使用要求之间存在一定的传递性,从而使得构建目标这个抽象概念变得十分实用。本小节将继续深入探索有关传递性的问题。首先请思考以下问题。

对于这个问题,我们先建立一个共识:如果可执行文件main本身使用了库A,那么库A的使用要求肯定应该传递给main的构建要求。这样一来,问题就变成了:main怎样才算使用了库A?一定是引用了库所对应的头文件,并调用了其中的函数或类吗?

当然未必。比如库B中的某个函数可能会返回一个在库A中定义的类型,main又调用了库B中的该函数,这就意味着main间接使用了库A。具体来说,main一定是引用了库B的某个头文件才能调用其中的函数,而这个库B的头文件又一定直接或间接地引用了库A中的头文件,否则它返回的库A中定义的类型就是未定义类型了。

既然main间接地引用了库A的头文件,也就意味着main应该根据库A的使用要求来链接它。这种情形称作“递归传递”。然而,如果库B不会在接口处暴露库A中定义的符号,而且main本身也不存在对库A的直接引用,那么,库A的使用要求自然也就不必递归传递给main了。

下面一起来看一下这两种情况的具体例程。

无须递归传递的例程

为了更好地演示构建要求和使用要求的传递性,这里会将库A和库B分别放在不同的子目录中。这样在编译时就必须指定头文件搜索目录,也就是形成了一个强制的要求。另外,为了方便起见,我们会将库A和库B作为静态库来构建。

库A的头文件和源文件如下所示。其中,定义了一个类A,提供对其私有整型成员变量的取值和写值函数。

struct A {
    void set(int val);
    int get();
 
  private:
    int f;
};
#include "a.h"
 
void A::set(int val) { f = val; }
 
int A::get() { return f; }

库B的头文件和源文件如下所示。其中,定义了一个函数f,用于操作库A中的类A并输出取值结果。

void f();
#include "b.h"
#include <a.h>
#include <cstdio>
 
void f() {
    A a;
    a.set(10);
    printf("%d\n", a.get());
}

主程序的代码则直接调用库B中的函数f,如下所示。

#include <b.h>
 
int main() {
    f();
    return 0;
}

需要注意的是,在构建静态库时没有链接这一步,因此静态库A有关链接的使用要求需要传递到静态库B的使用要求中,从而保证最终链接为可执行文件时能够同时链接这两个静态库。

在“静态库A的使用要求传递到静态库B的使用要求”过程中,静态库直到构建最终的可执行文件或动态库时才会被链接,因此构建静态库B时无须链接静态库A,“链接库A”这个使用要求将会传递到B的使用要求中。

另外,不同于在前面分别为Windows和Linux平台绘制了不同的目标要求示意图,这里绘制的是一个“平台无关”的示意图。在构建要求和使用要求的描述中,我们没有使用任何具体的命令和参数。可以说,这样一个示意图所展示的结构,是一个跨平台构建系统应该能够处理的构建拓扑。就像编译器处理“抽象语法树”或“中间表示”一样,跨平台构建系统有责任将这个构建拓扑的“表示”翻译成所需平台环境中支持的构建命令和参数。这也是后面介绍的CMake能够完成的工作。

使用MSVC和NMake构建

NMake Makefile如下所示。

main.exe: main.obj a.lib b.lib
    cl main.obj a.lib b.lib /Fe"main.exe"
 
a.lib: a.obj
    lib /out:a.lib a.obj
 
b.lib: b.obj
    lib /out:b.lib b.obj
 
a.obj: liba/a.cpp
    cl /c liba/a.cpp /Fo"a.obj"
 
b.obj: libb/b.cpp
    cl /c libb/b.cpp /I liba /Fo"b.obj"
 
main.obj: main.cpp
    cl /c main.cpp /I libb /Fo"main.obj"
    
clean:
    del *.obj *.lib *.exe
使用GCC和make构建

Makefile如下所示。

main: main.o liba.a libb.a
    g++ main.o -o main -L. -la -lb
 
liba.a: a.o
    ar rcs liba.a a.o
 
libb.a: b.o
    ar rcs libb.a b.o
 
a.o: liba/a.cpp
    g++ -c liba/a.cpp -o a.o
 
b.o: libb/b.cpp
    g++ -Iliba -c libb/b.cpp -o b.o
 
main.o: main.cpp
    g++ -Iliba -Ilibb -c main.cpp -o main.o
    
clean:
    rm *.o *.a *.so main 

尝试执行make,发现有错误产生:

$ cd CMake-Book/src/ch001/无须传递
$ make
...
g++ main.o -o main -L. -la -lb
./libb.a(b.o): In function `f()':
b.cpp:(.text+0x24): undefined reference to `A::set(int)'
b.cpp:(.text+0x30): undefined reference to `A::get()'
collect2: error: ld returned 1 exit status
Makefile0:2: recipe for target 'main' failed
make: *** [main] Error 1

在最后的链接过程中,链接器无法解析libb.a,也就是静态库B的函数f中引用的两个符号:A::set(int)和A::get()。这两个符号应该在静态库A中定义过了,链接器却没有找到,这是为什么呢?

对于GCC来说,提供的链接库的参数-la和-lb的顺序对链接过程存在重要影响。链接器会根据参数指定的链接库顺序依次解析之前遇到过的未定义的符号,不走回头路。也就是说,静态库B中未定义的符号,链接器不会再回到A中去检索了。

为了避免这个问题,我们应当根据依赖关系,先链接有依赖的库,再链接被依赖的库。这样,有依赖的库中遇到的未定义的符号,总能被链接器从被依赖的库中找到。因此,对于该例程而言, Makefile的第二行命令应当做一点修改,即调换参数-la和-lb的顺序。

MSVC中不存在这个问题,因为MSVC链接器会尝试在所有参数指定的链接库中检索并解析未定义的符号。不过,当多个库中同时定义了一个相同的符号(符号重名)时, MSVC链接器也会根据参数指定的顺序来决定到底将符号解析为哪一个库中的定义。

存在间接引用的例程

接下来看一下另一种情况的例程——存在间接引用,也就是需要将使用要求递归传递到最终的可执行文件的构建要求中。本例基本上会复用前面的例程代码,只对库B的代码做一些修改,其修改后的头文件和源文件如下所示。

#include <a.h>
 
A f();
#include "b.h"
#include <cstdio>
 
A f() {
    A a;
    a.set(10);
    return a;
}

这里将库B中的函数f的返回值类型从void 改为了类A。类A是定义在库A中的类型,所以库B的头文件b.h中也必须先引用库A的头文件a.h。可执行文件代码 main.cpp中引用了头文件b.h,这也就意味着间接引用了库A。

对于本例来说,库A的头文件搜索目录这个使用要求,会被传递到库B同时作为其构建要求和使用要求。当库B的使用要求传递到可执行文件main时,库A所要求的头文件搜索目录会一同传递到可执行文件的构建要求中。当然,在编写Makefile时,需要为main目标的构建规则增加设定头文件搜索目录的编译器选项。

传递方式总结

结合前两个例程能够发现,使用要求在被传递时存在多种可能性:

1.传递到使用者的构建要求;

2.传递到使用者的使用要求;

3.同时传递到使用者的构建要求和使用要求。

前面两个例程分别对应第一种情况和第三种情况。第二种情况一般在当头文件(接口)使用了某个库,而源程序(实现)中并没有使用这个库时才会用到,多见于伪构建目标。

举个另类但还算实用的例子:当希望引用一个接口库就可以自动链接多个库时,实际上就是要将多个链接库的使用要求传递给这个接口库的使用要求。接口库是伪构建目标,不需要编译,也就不存在构建要求。因此,这正是仅传递给使用者的使用要求的情形。

至此,构建目标最重要的两类属性“构建要求”和“使用要求”基本介绍完毕。笔者通过多个实例展示了二者的表现形式和作用原理,体现了抽象出这几个概念的动机——分离关注点,面向目标解耦构建参数,这样更容易厘清大型复杂工程的各部分关系,轻松搞定构建过程。另外,通过这些属性,我们也能够用统一的方式描述在不同平台中构建各部分程序的拓扑结构和具体要求,并最终将其翻译成不同平台中具体的构建命令和参数。这也是一个合格的跨平台构建系统应当具备的能力。

1.6.5 目录属性

严格来说,将目录引入构建模型似乎缺乏逻辑性:一个构建目标的源程序可能位于多个目录中,而一个目录中也有可能存在多个构建目标的定义。但事实上,我们肯定会按照一定的逻辑组织程序的目录结构,很多目录都有着特殊的用途。因此,按照目录为源程序统一设置属性,往往能够带来极大的便利。下面列举几个涉及目录属性的例子。

  • 对整个代码仓库设置“将警告作为错误”编译选项。

  • 需要构建的第三方库代码一般会放到thirdparty目录中,而这些“别人写的代码”可能在构建过程中产生大量的警告信息,我们需要对thirdparty目录中的代码禁用“将警告作为错误”这个编译选项。

  • 某些库的源程序分别位于不同目录,但头文件都在include目录中。我们希望能够为它们统一设定头文件搜索目录。

针对第一个例子,可以对整个代码仓库的顶层目录设置编译选项相关的属性;针对第二个例子,则只需对thirdparty这个目录进行相关设置;针对第三个例子,同样只需对这些库的源程序所在目录的父目录设置头文件搜索目录的属性,就可使其子目录中的每一个库都统一使用该属性。

1.6.6 自定义构建规则

本章中可能并没有太多需要自定义构建规则的情况,但清理构建文件的clean可以算作其一。自定义构建规则是构建过程中的一个非常常见的需求,例如:

  • 在构建完成后,复制一些数据文件到构建好的二进制目录中,以便调试运行可执行文件时在相对目录中加载这些数据文件;

  • 通过一些命令执行外部脚本(如Python脚本),完成一些构建前的准备工作或构建后的扫尾工作;

  • 清理构建文件等。

任何一个构建工具都应该支持执行自定义构建规则中的一系列命令。如果使用Makefile,实现自定义构建规则非常简单:只需在Makefile中定义新的构建目标,并将所要执行的命令罗列在其构建规则中。

另外,很多自定义构建规则都与特定的某个构建目标相关,如复制数据文件的例子就与加载这个数据文件的可执行文件构建目标相关。所以,自定义构建规则往往与构建目标绑定在一起。除此之外,绑定的自定义构建规则还应有不同的执行时机,如构建前和构建后。

1.6.7 尾声

实际上,本节的内容正是基于CMake构建系统的概念编排的。不过,笔者反而不希望读者关注这一点。最理想的情况,应当是能够通过构建之旅顺理成章地总结抽象出本节介绍的概念。这样,我们就能够在将来自然地明白CMake为何是那样的设计,也会感受到CMake果然是解决“到处编译”这个问题的利器。

应该说,有了构建模型之后,我们就不必再专注于不同平台的编译器的差异,而是将重心放在如何组织项目中的不同组件的依赖关系、构建要求和使用要求等。这种抽象模型大大降低了构建项目时的心智负担。只有在真正实施构建时,我们才需要将该概念模型的拓扑结构翻译成对应平台的编译链接命令,而这一步骤完全可以由CMake代劳。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值