CMake构建实战:项目开发卷

978-7-115-61664-7
作者: 许宏旭
译者:
编辑: 郭泳泽
分类: C++

图书目录:

详情

本书主要介绍C和C++程序构建的相关知识,包括直接调用C和C++编译器完成构建的基础内容,以及使用CMake完成项目构建的相关内容。全书先介绍市面上C和C++的主流编译器及其相关命令行工具、Makefile等的使用,抽象出与项目构建相关的概念模型,再介绍CMake脚本语言的基础语法、常用命令等,最后介绍与CMake项目构建相关的内容。本书含有两个实践章节,借助CMake脚本语言,分别构建了快速排序算法程序、手写数字识别库及配套命令行工具。 本书适合有C和C++编程基础,希望了解或应用CMake工具的人阅读,也适合想要构建跨平台C和C++程序的开发人员阅读。

图书摘要

版权信息

书名:CMake构建实战:项目开发卷

ISBN:978-7-115-61664-7

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

版  权

著    许宏旭

责任编辑 郭泳泽

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

内 容 提 要

本书主要介绍C和C++程序构建的相关知识,包括直接调用C和C++编译器完成构建的基础内容,以及使用CMake完成项目构建的相关内容。全书先介绍市面上C和C++的主流编译器及其相关命令行工具、Makefile等的使用,抽象出与项目构建相关的概念模型,再介绍CMake脚本语言的基础语法、常用命令等,最后介绍与CMake项目构建相关的内容。本书含有两个实践章节,借助CMake脚本语言,分别构建了快速排序算法程序、手写数字识别库及配套命令行工具。

本书适合有C和C++编程基础,希望了解或应用CMake工具的人阅读,也适合想要构建跨平台C和C++程序的开发人员阅读。

前  言

如今,CMake几乎已经成为构建C和C++项目的业界标准工具。众多开源项目,甚至商业项目都已经或逐渐转向采用CMake作为构建工具。例如,Qt就已经在其6.0版本采用CMake作为首选构建工具了。

然而,尽管CMake被广泛采用,却很少有图书对它进行系统介绍。网上的随笔教程质量参差不齐,不成体系,尤其是很少有教程会完整地介绍CMake的语法,这不利于技术的学习与传播。另外,CMake的3.0版本更新了很多内容,但现存资料有的年代久远,有的可能会混杂新旧知识,同样不利于学习最新技术。

因此,笔者产生了创作本书的想法。本书会从基础的C和C++程序构建讲起,帮助读者建立良好的概念模型,然后仿照编程语言图书的一般结构介绍CMake脚本语言,最后深入讲解CMake作为构建工具的种种用法。本书知识体系相对完备,可以帮助读者更加系统地学习CMake。同时,本书基本摒弃了过时的CMake程序编写方式,全面拥抱“现代CMake”。

笔者在学习CMake的过程中,也常常苦于官方文档艰涩难懂,缺乏实例辅助理解。因此笔者在创作本书时,编写了大量实例代码,对于重要的基础语法、常用命令等,均配有实例展示,希望能够帮助读者更高效地理解和学习。

本书主要介绍C和C++程序构建的相关知识,包括直接调用C和C++编译器完成项目构建的基础内容,以及使用CMake完成项目构建的相关内容。本书暂不涉及CMake中与测试集成(包括CTest和CDash)、安装、打包(包括CPack)等相关的内容。

本书整体上分为三个部分。

第1章介绍C和C++程序构建的相关基础知识。该章不会涉及CMake,主要讲解C和C++的主流编译器及其相关命令行工具、Makefile等的使用,同时会抽象出与项目构建相关的概念模型。读者如果对构建C和C++程序的工具与流程相当熟悉,可以跳过这一部分。

第2章到第5章将带领读者初步认识CMake,并按照一般编程语言入门图书的惯例介绍CMake脚本语言的基础语法、常用命令等。该部分几乎只介绍脚本语言,很少涉及与构建相关的内容,相信能够帮助读者更专注地夯实基础。

第6章到第11章介绍CMake项目构建的相关内容。第7章介绍CMake从配置生成项目、构建项目,到项目被用户使用的完整生命周期,以帮助读者对CMake的功能使用建立宏观认知。后续各章则会深入探究与CMake项目构建相关的内容。

本书第5章和第11章是两个实践章节。第5章应用CMake脚本语言的相关知识,实现了一个快速排序算法程序;第11章则综合应用了本书介绍的知识,基于onnxruntime第三方机器学习推理运行时库,实现了一个手写数字识别库及配套命令行工具。希望这两个实践项目能够帮助读者更快更好地将所学知识应用于实践中。

本书读者

本书面向已经掌握C和C++编程语言基础,同时希望了解或应用C和C++程序的构建原理及CMake构建工具的读者。如果读者正处于下列情境之一,那么阅读本书应该会有所帮助:

希望构建跨平台C和C++程序,却苦于没有合适的构建工具;

希望为C和C++开源界贡献力量,却苦于不了解项目构建的原理;

已经采用了其他构建工具,但苦于没有良好的集成开发环境(Integrated Development Environment,IDE)支持,希望切换到被广泛支持的构建工具。

资源与支持

资源获取

本书提供如下资源:

配套代码文件;

本书思维导图;

异步社区7天VIP会员。

要获得以上资源,您可以扫描下方二维码,根据指引领取。

提交勘误

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区(www.epubit.com),按书名搜索,进入本书页面,点击“发表勘误”,输入勘误信息,点击“提交勘误”按钮即可(见下图)。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

与我们联系

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们。

如果您所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

关于异步社区和异步图书

异步社区”是由人民邮电出版社创办的IT专业图书社区,于2015年8月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作者与读者在线交流互动,以及传统出版与数字出版的融合发展。

异步图书”是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域30余年的发展与积淀。异步图书面向IT行业以及各行业使用IT技术的用户。

第1章 构建之旅

大部分图书在介绍一门技术的时候,第1章往往是简介,颇为无趣。本章先不提主角CMake,毕竟它“又丑又怪”,谁第一次见了都想离它远远的。笔者恐怕是见到它不下十次,才真正下定决心去学习。为了避免读者也痛苦十次,本章先从基础入手,带领大家一起体会构建旅程的艰辛,这样更容易感受CMake的可爱之处。

本章介绍的C和C++程序的构建基础是学习CMake的重中之重,其中涉及:对编译器命令行工具的参数介绍,有助于读者将来在CMake程序中配置编译选项;对Makefile等配置工具的介绍,有助于读者感受CMake与它们的相似和不同之处,体会CMake的优势;对动态链接等原理的介绍,有助于读者理解CMake中为动态库等构建目标提供的特殊属性,如POSITION_INDEPENDENT_ CODE等。

那么,不妨一起踏上构建之旅,重新熟悉一下构建C和C++各类程序的方法吧!在此之前,请确保已经安装好了C和C++程序的基本开发环境,包括MSVC、GCC或Clang编译器、make或NMake构建工具等。

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

啊哈,本书并不能脱俗。让我们来编写这个程序吧!如代码清单1.1所示。

代码清单1.1 ch001/您好世界.c

#include <stdio.h>
int main() {
    printf("您好,世界!\n");
    return 0;
}

那么如何构建这个单源文件的“您好,世界”程序呢?

使用Microsoft Visual C++构建

Microsoft Visual C++(简称MSVC)广义上讲是一个集成开发环境,包含了Windows C和C++编程各个环节所需的功能组件;狭义上讲,则指微软的C和C++编译器。为了更清楚地了解构建的细节,本书在演示构建过程时不会使用集成开发环境,而是仅通过调用编译器命令来完成构建。

在“开始”菜单中,找到“x64 Native Tools Command Prompt for VS 2019 Preview”工具,如图1.1所示。这是Visual Studio(简称VS)的命令行工具,它预设了与开发相关的环境变量等,开发者可以方便地直接在其中调用内置的命令行工具。

图1.1 在“开始”菜单中搜索Visual Studio x64 命令行工具

在命令行中运行MSVC编译器cl.exe:

> cd CMake-Book\src\ch001
> cl 您好世界.c
> 您好世界.exe
您好,世界!

使用GCC/Clang构建

运行系统终端,直接调用gcc或clang命令完成编译即可。由于Clang编译器大部分参数都与GCC编译器兼容,本书将仅展示GCC编译器的使用:

$ cd CMake-Book/src/ch001
$ gcc 01.您好世界.c
$ ./a.out
您好,世界!

可以看到,对于最简单的编译任务而言,GCC的使用与MSVC并无分别。

1.2 构建多源程序

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

本例由1.1节的“您好,世界”程序修改而成。新建源文件msg.c,在其中定义输出的字符串内容,如代码清单1.2所示。

代码清单1.2 ch001/多源程序/msg.c

const char *msg = "您好,我来自msg.c!";

然后将“您好,世界”主程序中输出的内容修改为变量msg的值,同时将msg声明为一个外部变量,如代码清单1.3所示。

代码清单1.3 ch001/多源程序/main.c

#include <stdio.h>
 
extern const char *msg;
 
int main() {
    printf("%s\n", msg);
    return 0;
}

至此,本例的两个源程序就已经编写好了,那么该如何构建它们呢?既然只是多了一个源文件,想必直接罗列在编译器命令行参数的后面就可以了吧!

MSVC的构建过程如下:

> cd CMake-Book\src\ch001\多源程序
> cl main.c msg.c
> main.exe
您好,我来自msg.c!

GCC的构建过程如下:

$ cd CMake-Book/src/ch001/多源程序
$ gcc main.c msg.c
$ ./a.out
您好,我来自msg.c!

果然如此简单,甚至二者的命令行参数都毫无分别!接下来,我们让情况变得复杂一些。

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

当程序体量逐渐变得庞大,编译时间也会越来越长。C++更是经常因为编译速度慢而被大家诟病。因此,我们在工程上常采取很多手段优化编译时间。其中,最简单直接的手段就是避免不必要的编译。简言之,当多次编译多份源文件时,编译器应当聪明地只把修改过的源程序重新编译,而复用其他未修改的已经编译好的程序。

为了演示这一策略的有效性,我们需要一个非常耗时的编译过程用于对比。当然,读者肯定不会乐意在第1章就接触一个庞大的工程案例。因此,本书在这里采用了C++“黑魔法”,也就是一段糟糕的模板编程来模拟需要长时间编译的源程序。本书不会讲解该程序细节,相关实现细节感兴趣的读者可自行参考本书配套资源。

本例将会输出斐波那契数列第25项的值。主程序main.cpp如代码清单1.4所示。

代码清单1.4 ch001/漫长等待/main.cpp

#include <iostream>
 
extern int fib25;
 
int main() {
    std::cout << "斐波那契数列第25项为:" << fib25 << std::endl;
    return 0;
}

主程序中声明的外部变量fib25被定义在另一个源程序slow.cpp中。这个源程序就是模拟长耗时编译的源程序,由于有“黑魔法”的存在,我们暂且不去关心它的写法。

使用MSVC构建本例

> cd CMake-Book\src\ch001\漫长等待
> cl main.cpp slow.cpp /EHsc
> .\main.exe
斐波那契数列第25项为:75025

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

这个编译过程在笔者的移动工作站上运行了超过20秒!

使用GCC构建本例

GCC编译器编译该项目也需要十几秒的时间,过程如下:

$ cd CMake-Book/src/ch001/漫长等待
$ g++ main.cpp slow.cpp
$ ./a.out
斐波那契数列第25项为:75025

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

假设需要修改主程序输出的字符串,修改后的程序如代码清单1.5所示。

代码清单1.5 ch001/按需编译/main.cpp

#include <iostream>
 
extern int fib25;
 
int main() {
    std::cout << "The 25th item of Fibonacci Sequence is:" << fib25
              << std::endl;
    return 0;
}

然后,重新构建该项目。等等!难道又是几十秒的等待吗?我们根本没有修改slow.cpp,其中计算出的斐波那契数列第25项数值并不会有任何改变。既然如此,为什么不复用上次编译的结果?

复用当然是可行的。程序构建过程并非只涉及编译,还有链接的过程。事实上,在运行编译器的时候,笔者会尽量采用最简单的方式,一步到位生成可执行文件,链接这一步就由编译器隐式地代劳了。

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

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

再回顾一下代码清单1.4,程序中声明了一个外部变量fib25。当编译器编译主程序main.cpp的时候,并不知道这个fib25的变量到底定义在哪里。因此,可以说对fib25的引用就是一个未定义的符号引用。这个未定义的符号引用也会存在于编译器生成的目标文件main.obj中。而编译器编译slow.cpp的时候,则会将fib25 的定义编译到目标文件slow.obj中。最后,链接器会将main.obj与slow.obj两个目标文件链接在一起,从而完成未定义符号fib25的解析。因此,按需编译的关键,就是分别编译各个源程序到目标文件。当源程序发生修改时,只需将变更的源程序重新编译到目标文件,然后重新与其他目标文件链接,如图1.2所示。

图1.2 按需编译示意图

使用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

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

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

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

一切正常!下面修改主程序中的输出(实例中通过复制并覆盖来完成修改)并重新编译main.cpp到目标文件:

> copy /Y ..\按需编译\main.cpp main.cpp
> cl -c main.cpp /EHsc

如果读者正跟着我一起实践,应该感受到了main.cpp飞快的编译过程!最后,再次链接两个目标文件,验证我们的变更:

> cl main.obj slow.obj
> main.exe
The 25th item of Fibonacci Sequence is:75025

变更生效了,而且第二次编译也无须漫长的等待。

使用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作为扩展名。

人生中宝贵的十几秒又消逝了,话不多说,同样修改(实例中通过复制并覆盖的方式修改)并重新编译main.cpp,然后重新链接并测试运行[3]

$ cp -f ../按需编译/main.cpp ./main.cpp
$ g++ -c main.cpp
$ g++ main.o slow.o
$ ./a.out
The 25th item of Fibonacci Sequence is:75025

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

1.2.4 使用Makefile简化构建

尽管我们在1.2.3节中掌握了如何避免不必要的编译,但真到了实践中却会发现很难操作。前面的实例还仅仅只有两个源程序,我们尚且能够判断修改了哪一个源程序。如果源程序更多,该怎么办呢?

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

使用make工具

make构建工具会根据Makefile规则文件来进行构建。简言之,Makefile规则文件是由一系列面向目标的规则构成的。用于1.2.3小节实例的Makefile如代码清单1.6所示,注意Makefile中的缩进必须使用制表符(Tab键)而非空格。

代码清单1.6 ch001/Makefile/Makefile

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编译全部目标,第一次构建需要花费较长时间:

$ cd CMake-Book/src/ch001/Makefile
$ cp ../漫长等待/*.cpp ./
$ make
g++ -c main.cpp -o main.o
g++ -c slow.cpp -o slow.o
g++ -o main main.o slow.o
$ ./main
斐波那契数列第25项为:75025

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

修改主程序main.cpp(通过复制并覆盖文件的方式),再次调用make命令构建项目,可以看到 make只按需编译了变更的main.cpp:

$ cp -f ../按需编译/main.cpp
$ make
g++ -c main.cpp -o main.o
g++ -o main main.o slow.o
$ ./main
The 25th item of Fibonacci Sequence is:75025

第三次构建,make没有做任何操作,并友好地提示我们main已经是最新的了:

$ make
make: 'main' is up to date.

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

$ make CXX=clang++

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

使用NMake工具

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

代码清单1.7 ch001/Makefile/NMakefile

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。

首先,使用NMake第一次构建项目,这同样需要漫长的等待:

> cd CMake-Book\src\ch001\Makefile
> copy ..\漫长等待\*.cpp .\
> nmake /F NMakefile
> main.exe
斐波那契数列第25项为:75025

然后,修改主程序后再次构建项目。注意,这里没有使用copy命令来修改源文件,因为Windows中的copy命令会保留被复制文件的修改时间,因而NMake不会认为这个main.cpp比main.exe更新,也就不会重新编译它了。这里改用type命令和重定向输出文件来模拟对文件的修改。当然,本书是为了方便演示才通过复制文件来修改源程序。正常开发中肯定会使用编辑器直接修改源程序,也就不会存在这种问题。

> type ..\按需编译\main.cpp > main.cpp
> nmake /F NMakefile
> main.exe
The 25th item of Fibonacci Sequence is:75025

第二次构建同样只会按需编译变更的文件,耗时很短。

make工具小结

make简约而不简单。它实在太灵活了,有时候会让人无所适从。尤其是面临跨平台需求时,其不足就很明显了。

与其说make是构建工具,倒不如说它是一个面向目标规则的命令行工具。归根结底,它只是根据规则推导出执行命令的顺序罢了,并非是一个专门针对构建某类程序的工具。换句话说,即使能够使得make在Windows操作系统上运行[4],从而避免NMake与之语法不同的问题,我们也不能够用简单的一份Makefile 来完成跨平台的C和C++程序构建。这里最明显的问题就是MSVC编译器的参数写法和GCC/Clang并不兼容,另外还有其他更多的与平台相关的问题。很多问题也许有一些解决方法,如使用Cygwin、MinGW等,但终究受限很多。总而言之,make并不是一个适合跨平台程序构建的工具。

[4]GnuWin32就是make的一个Windows构建版本。另外,也可以通过WSL在Windows上使用make调用MSVC编译器,WSL是可以同时调用Windows和 Linux应用程序的。

1.3 构建静态库

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

该静态库包含两个源程序,分别提供了不同的功能函数,如代码清单1.8和代码清单1.9所示。

代码清单1.8 ch001/静态库/a.c

#include <stdio.h>
 
void a() { printf("a\n"); }

代码清单1.9 ch001/静态库/b.c

#include <stdio.h>
 
void b() { printf("b\n"); }

a.c源程序提供了函数a,可以输出“a”; b.c源程序则提供了函数b,可以输出“b”。 a和b这两个函数构成了静态库的全部功能。

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

代码清单1.10 ch001/静态库/libab.h

void a();
void b();

最后,编写主程序main.c,链接构建好的静态库,并调用上述两个函数来完成相应的功能,如代码清单1.11所示。

代码清单1.11 ch001/静态库/main.c

#include "libab.h"
#include <stdio.h>
 
int main() {
    a();
    b();
    return 0;
}

使用MSVC和NMake构建

鉴于我们已经了解过Makefile的书写方式,本章的后续实例将使用Makefile规则文件来整理构建过程中所需调用的命令,这样更加方便阅读和调用。本实例对应的NMake Makefile如代码清单1.12所示。

代码清单1.12 ch001/静态库/NMakefile

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"

我们从下往上看。最后两条规则用于生成a.c和b.c两份源程序对应的目标文件。

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

第二条规则用于生成main.c源程序对应的目标文件。

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

> cd CMake-Book\src\ch001\静态库
> nmake /F NMakefile
> main.exe
a
b

使用GCC和make构建

Makefile如代码清单1.13所示。

代码清单1.13 ch001/静态库/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”去 搜索[5]

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

事实上,GCC也可以按照类似MSVC的写法来链接静态库,即gcc main.o libab.a -omain。之所以在Makefile中选择了-l参数的写法,是因为这种写法还能同时用于链接动态库。统一采用这种写法,可以不必关注用到的链接库具体以什么形式链接(动态链接会在1.4节讲到)。当然了,这种写法也能够让我们少输入几个字母(“lib”和“.a”)。

1.4 构建动态库

既然静态库已经非常简单易用了,为什么还需要动态库呢?显然,静态库有它的缺点。

难以维护。如果想要修复静态库中的一个错误,我们必须重新编译(链接)所有使用该静态库的程序。

浪费空间。因为静态库的目标文件会在编译过程中被链接到最终的程序中,所以每一个链接了静态库的程序都相当于将静态库的目标代码复制了一份。如果某个静态库相当通用而被很多程序静态链接,将是对空间的巨大浪费。这里不仅是指编译后的程序文件的体积,更重要的是指程序运行时占用的内存空间。

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

[6]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特性的“随机化”,对于动态链接库而言,只发生在计算机重启后[7]

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

现在基本了解了Windows操作系统中动态链接的原理,那么我们就着手构建一个动态库吧!

使用MSVC和NMake构建

前面讲了这么多,现在如果只是演示一下构建过程就太无趣了!因此本例要构建的这个动态库不仅仅演示构建过程本身,还能够印证前面提到的部分原理。程序会输出一些变量和函数的内存地址,用于辅助验证。

首先,动态库的源程序a.c中有一个变量x,以及一个函数a,函数的功能是输出变量x的内存地址。其代码如代码清单1.14所示。

代码清单1.14 ch001/动态库/a.c

#include <stdio.h>
 
int x = 1;
 
void a() { printf("&x: %llx\n", (unsigned long long)&x); }

动态库的头文件liba.h只需声明函数a,如代码清单1.15所示。

代码清单1.15 ch001/动态库/liba.h

void a();

最后是主程序main.c,它会调用动态库中的函数a,同时输出函数a的内存地址。另外,主程序也有一个变量y和函数b,它们的内存地址也会被输出。因此,运行主程序后应该输出四个内存地址。主程序代码如代码清单1.16所示。

代码清单1.16 ch001/动态库/main.c

#include "liba.h"
#include <stdio.h>
 
void b() {}
int y = 3;
 
int main() {
    a();
    printf("&a: %llx\n", (unsigned long long)&a);
    printf("&b: %llx\n", (unsigned long long)&b);
    printf("&y: %llx\n", (unsigned long long)&y);
    getchar();
    return 0;
}

主程序最后还调用了getchar()函数,这是为了避免程序执行完后立刻退出,便于同时运行多个程序,以观察每一个程序输出的内存地址。当然,在运行之前需要先把动态库和主程序都构建出来。

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

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

代码清单1.17 ch001/动态库/liba.def

EXPORTS 
    a

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

代码清单1.18 ch001/动态库/NMakefile(第7行、第8行)

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操作系统中,一个程序如果想链接一个动态库,就必须在编译时链接动态库对应的导入库[8]。我们可以简单地把“.lib”导入库看作一种接口定义,在链接时提供必要信息;而“.dll”动态库则包含运行时程序逻辑的目标代码。因此,编译链接时,只导入库提供的链接信息就够了;只有程序运行时,才需要动态库的存在。

[8]这里指在编译的链接阶段进行动态链接需要导入库。如果是运行时动态装载链接,则不需要。

该实例的完整Makefile如代码清单1.19所示。

代码清单1.19 ch001/动态库/NMakefile

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指令,就会删除工作目录中所有的目标文件、库文件和可执行文件等。

那么,现在开始构建吧:

> cd CMake-Book\src\ch001\动态库
> nmake /F NMakefile
> main.exe
&x: 7ff87abcb000
&a: 7ff678e51117
&b: 7ff678e51000
&y: 7ff678e6d000

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

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

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

[9]这个类比并不准确,工作集实际上指进程的那些已被加载到物理内存中的虚拟内存页。

图1.3 VMMap内存分析工具

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构建

同样为了验证原理,本节实例的源程序直接复用前面在Windows中编写的实例源程序。与MSVC相比,GCC构建动态库的方法可以说大同小异,最主要的区别就是刚刚在原理中提到的用于启用地址无关代码的-fPIC编译选项,以及用于表示生成动态库的-shared编译选项。Makefile如代码清单1.20所示。

代码清单1.20 ch001/动态库/Makefile0

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.系统配置文件/etc/ld.so.conf指定的路径;

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

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

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

代码清单1.21 ch001/动态库/Makefile(第1行、第2行)

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就不会被当作对环境变量的引用,而是将其本身的字面量作为参数进行传递。总而言之,这就是向链接器传递了一个-R参数,其值为$ORIGIN。

链接器参数-R正是用于设置RUNPATH,$ORIGIN则是程序所在目录。之所以设置为程序所在目录$ORIGIN,而非当前工作目录“.”,是因为用户通常不会以动态库所在的目录作为当前工作目录来运行程序,但动态库通常会在可执行文件的同一目录下。当然,动态库也可以与可执行文件保持一个相对位置,这样RUNPATH也就应该设置为相对$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,属于代码段。动态库被多个进程共享的部分应是代码段,所以着重观察第一部分。

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

$ 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则没有变化。也就是说,代码段确实在物理内存中共享。

读者如果怀疑这只是巧合,不妨亲自尝试一下启动更多进程时,分摊的内存空间是否刚好成比例变小。当然2 KB实在太小,这里只显示整数,分摊多了就会变成0。有兴趣的读者也可以向动态库的程序中多写入一些函数代码等,让代码段所需的内存空间增加一些,再来做这个实验。

1.5 引用第三方库

我们的构建之旅已经涵盖了主要的构建目标类型,也快要接近尾声了。本节会介绍引用第三方库的方法。毕竟,使用C和C++编程的一大优势就是可以利用其丰富的生态,让我们站在巨人的肩膀上。说到C++引用第三方库,想必都绕不开Boost库。本节就将以Boost库的使用为例,演示如何引用第三方库。

1.5.1 下载Boost C++库

读者可以在Boost官方网站中找到针对UNIX和Windows的下载链接。如果使用的是Linux和macOS,那么也可以通过针对UNIX平台的下载链接下载。

下载压缩包并解压后,可以找到名称以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如代码清单1.22所示。

代码清单1.22 ch001/头文件库/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如代码清单1.23所示。

代码清单1.23 ch001/头文件库/NMakefile

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如代码清单1.24所示。

代码清单1.24 ch001/头文件库/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静态库或动态库的二进制文件。接下来将借助它们完成更复杂的功能!本小节将使用Boost Regex库提取一段文本中出现的所有URL。

使用Boost Regex库提取URL

主程序main.cpp如代码清单1.25所示。

代码清单1.25 ch001/链接Boost/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如代码清单1.26所示。

代码清单1.26 ch001/链接Boost/NMakefile

# 自行构建的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_DIR和BOOST_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静态库的文件名。这与之前在代码清单1.12中静态链接自己编写的静态库几乎是一样的,仅仅是增加了指定搜索目录的参数。

动态链接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如代码清单1.27所示。

代码清单1.27 ch001/链接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目录绝对路径的环境变量。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 旅行笔记

我们的旅途伴随了不同的开发环境,经历了不同的构建目标类型,书写了不同的命令参数,构建了二进制不同的程序。放眼望去,处处不同。然而,即便是环境不同,类型不同,命令不同,二进制不同,只要代码相同,运行结果就相同。多么不可思议呀!

这就是可移植代码的魅力,它让构建跨平台程序成为可能:一次编写,到处编译。

可移植代码的“一次编写”其实未必很难:只需尽量使用标准库和成熟的可移植性强的跨平台第三方库,尽量避免直接调用平台相关的API,转而采用条件编译等技巧。然而,“到处编译”听起来就是一件麻烦的事情,这也是本书的焦点所在。

在这段旅行接近尾声的时候,不妨一起来整理一下“到处编译”的共同需求,总结出共通之处,形成通用的构建模型。

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.19的Makefile中构建动态库的具体命令。其中,最后两条规则是与构建动态库相关的规则,构建要求自然也应该在这里体现,如代码清单1.28所示。

代码清单1.28 ch001/动态库/NMakefile(第7行~第11行)

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这个动态库构建目标的构建要求。

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

代码清单1.29 ch001/动态库/NMakefile(第1行~第5行)

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一同链接。这里也体现了动态库构建目标的使用要求会被传递给主程序,作为主程序构建要求的一部分。图1.4应该能够更直观地展示这一点。

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

图1.4 Windows中动态库目标要求示意图

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

在Linux中构建动态库的Makefile参见代码清单1.20。这里关注最后两条规则,如代码清单1.30所示。

代码清单1.30 ch001/动态库/Makefile0(第7行~第11行)

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参数将目标文件链接成最终的动态库。

再来看第一条构建主程序的规则,如代码清单1.31所示。

代码清单1.31 ch001/动态库/Makefile0(第1行~第5行)

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.5所示,动态库的使用要求会传递到主程序的构建要求中。

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

图1.5 Linux中动态库目标要求示意图

1.6.4 使用要求的传递性

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


如图1.6所示,如果一个库A被另一个库B链接,那么很显然,库A的使用要求应当传递到库B的构建要求中;如果库 B又被可执行文件main链接,那么同样地,库B的使用要求也应当传递到main的构建要求中。以上陈述都没有什么问题,那么问题来了:库A的使用要求是否也应传递到main的构建要求中呢?

图1.6 传递性问题的示意图

对于这个问题,我们先建立一个共识:如果可执行文件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的头文件和源文件分别如代码清单1.32和代码清单1.33所示。其中,定义了一个类A,提供对其私有整型成员变量的取值和写值函数。

代码清单1.32 ch001/无须传递/liba/a.h

struct A {
    void set(int val);
    int get();
 
  private:
    int f;
};

代码清单1.33 ch001/无须传递/liba/a.cpp

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

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

代码清单1.34 ch001/无须传递/libb/b.h

void f();

代码清单1.35 ch001/无须传递/libb/b.cpp

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

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

代码清单1.36 ch001/无须传递/main.cpp

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

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

图1.7 “无须传递”例程的目标要求示意图(*标记的要求为传递的要求)

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

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

现在,先来手动完成这项翻译任务吧。

使用MSVC和NMake构建

NMake Makefile如代码清单1.37所示。

代码清单1.37 ch001/无须传递/NMakefile

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如代码清单1.38所示。

代码清单1.38 ch001/无须传递/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的代码做一些修改,其修改后的头文件和源文件如代码清单1.39和代码清单1.40所示。

代码清单1.39 ch001/间接引用/libb/b.h

#include <a.h>
 
A f();

代码清单1.40 ch001/间接引用/libb/b.cpp

#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同时作为其构建要求和使用要求,如图1.8所示。当库B的使用要求传递到可执行文件main时,库A所要求的头文件搜索目录会一同传递到可执行文件的构建要求中。当然,在编写Makefile时,需要为main目标的构建规则增加设定头文件搜索目录的编译器选项。

图1.8 “间接引用”例程的目标要求示意图(*标记的要求为传递的要求,
突出显示了不同之处)

传递方式总结

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

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

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

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

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

举个另类但还算实用的例子:当希望引用一个接口库就可以自动链接多个库时,实际上就是要将多个链接库的使用要求传递给这个接口库的使用要求。接口库是伪构建目标,不需要编译,也就不存在构建要求。因此,这正是仅传递给使用者的使用要求的情形。如图1.9所示,这里的接口库AB就相当于库A和库B的集合的别名。

图1.9 仅传递到接口库使用要求的目标要求示意图

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

1.6.5 目录属性

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

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

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

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

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

1.6.6 自定义构建规则

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

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

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

清理构建文件等。

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

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

1.6.7 尾声

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

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

充满冒险的构建之旅就要结束了,但我们的CMake之旅即将启程,敬请期待吧!

相关图书

代码审计——C/C++实践
代码审计——C/C++实践
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南
21天学通C++(第9版)
21天学通C++(第9版)

相关文章

相关课程