之前在考虑编写一些个人的开源项目,不奢求成为什么明星项目,只希望把已有的技能和知识进行总结。一方面将来遇到类似需求时可以直接拿去用,另一方面也给遇到相同问题的人一些帮助。
最开始的想法是不管三七二十一先写起来,之后再逐步测试、修改和完善。没想到功能还没堆多少,那些觉得不会有问题懒得测的模块,总是写出低级 bug ;等想要添加单元测试时,面对的是写成一团的构建脚本;之后适配跨平台,每次提交代码都得在虚拟机之间来回切换、拉取代码和编译测试。
在总结失败教训并参考开源项目后,我决定编写一个适合自己需求的脚手架项目KRCppLibraryTemplate
:
- 要支持跨平台,支持动态库和静态库,不能 Linux 下跑得好好的,到 Windows 下因为动态库符号导出等问题迷惑半天;
-
尽可能让错误在在早期被发现:
- 集成单元测试框架;
- 严格的编译器检查;
- sanitizer ;
- 使用 github CI 和 Codecov 自动完成各个平台的测试和报告;
- 能够被其他项目轻松引入,包括 FetchContent 和 find_package ,后者要求项目支持安装。
天下苦构建久矣
就我个人经历而言,当初入门学习 C++时确实遇到了很多困难,但好在有很多优秀的书籍和资料(根本看不完),大部分问题也可以通过搜索找到答案。
没想到跨过这座大山后,迎面而来的是另一座大山——构建:
- 编译器为什么说找不到头文件?我要怎么告诉它正确的路径?
- 明明只写了一个函数定义,为什么告诉我符号重定义了?
- 链接器说找不到符号是什么意思,而且输出的符号还像乱码一样?
- 编译通过了,一运行就找不到动态库。
当你觉得学有所成,打算写个稍微像样点的项目时,就一定会被这些问题深深困扰。
这些问题都来自于 C++编译模型,以及平台和编译器实现细节,没办法三言两语概括,当初学习时主要参考了如下资料:
- 深入理解计算机系统 (原书第 3 版):第七章链接对编译和链接过程进行了简单明了的介绍。适合了解概念,但不足以运用于工程实践中;
- 程序员的自我修养:包含大量具体平台细节,以及对应的工具使用。可以说常见问题的排查方法,我都是从这上面学的,不过学习曲线陡峭,新手容易迷失自我;
- 陈硕的《 C++工程实践经验谈》:C++编译链接模型精要章节也对编译中各阶段进行了介绍,讲解了其中“不太人性化”的设计的历史背景。
如果想要新人消化这些内容,恐怕不太适合当今快节奏的职场环境,所以稍微划划重点,其他的就让 AI 来辅助吧:
- 搞懂
#include <...> 和#include "..." 导入头文件时搜索路径的差异,以及如何指定自己所需的搜索路径;
- 使用扩展名或 readelf 分辨动态库和静态库,以及如何让编译器去链接它们;
- 了解 C++的 name mangling ,以及链接 C 库时的
extern "C" ;
- 学会使用 nm 命令查看符号表( windows 下使用 dumpbin ),并设置 name demagling 选项将名称转换为人类可读格式;
- 用 ldd 和 readelf ( windows 用Dependencies)查看对动态库的依赖情况,SONAME 的概念;
- 了解程序启动时对动态库的搜索逻辑,除了系统的默认路径,linux 下需要搞懂 rpath 、LD_LIBRARY_PATH 和 LD_PRELOAD ,windows 下则是和 Path 相关;
构建系统
使用构建系统的目的,主要是避免重复的手动编写编译脚本,同时自动分析依赖变更,从而控制重新编译的范围,加速构建过程。实际上构建系统还用于处理安装和打包等工作,有的支持不同平台,从而简化跨平台开发的工作。
不过构建系统自身也会带来一些复杂性,比如 make ,它基于手写依赖规则,并根据文件时间戳判断是否发生变动,如果漏写了依赖(头文件尤其常见),或是修改的是编译器宏定义等不修改文件的选项,很可能会得到错误的结果。
我曾经尝试系统的学习 make ,但最终结论是,遇到老项目,要么重写,要么别动它,遇事不决全量重新编译。
类似的,CMake 也有问题:
- 依旧继承了早期脚本语言的一些特性,缺乏 OOP 支持,可读性和可维护性差;
- 实现常见需求要写一堆代码;
- 官方文档好像什么都说了,又好像什么都没说,宛若天书。
但话又说回来,涉及跨平台 C++开发,CMake 自身确实有问题,但选择 CMake 没啥问题。
CMake
比较系统和完善的参考资料如下:
现在的共识应该是 Modern CMake ,但我猜本就不多的 C++程序员里,懂得写 CMake 的人就更少了,更别提推动构建系统脚本的规范化。上面的知识从吸收到能够编写规范的代码需要很久的积累,所以这些就交给脚手架来完成吧。
现存优秀的项目
经常关注 CMake 的人应该都了解过这几个项目:
- ModernCppStarter:目前我看到过的 C++脚手架中最优秀的,功能完善,代码结构清晰,不管新手还是老手都推荐阅读和学习。不过注意它没有处理 windows 下动态库导出的问题,需要使用动态库的话必须自行修改和测试;
- CPM.cmake:包管理插件,本质上是对 FetchContent 的封装,但确实简化了 API ,尤其是对下载依赖的缓存支持。不过它内部使用 block 创建了新作用域,会导致某些包对外设置 CMAKE_MODULE_PATH 的操作被屏蔽(比如 Catch2 ),造成 include()找不到对应文件的错误;
- PackageProject.cmake:封装安装步骤的 API ,满足常见需求,但不支持安装 target_source 中添加的 FILE_SET ,这个功能在 PR 中已经搁置了 3 年。
最终我的选择是造轮子( C++程序员必经之路),从需求角度来说,是因为我希望脚手架应该尽可能封装平台差异,能够支持动态库和静态库,而不是在一开始就对用户强加限制。另一方面则是为了对 CMake 的工程实践有一个完整的了解。
动态库与静态库
从用户角度来说,根据需要选择动态库或静态库是很正常的需求。就算不考虑使用动态库实现平滑升级、功能插件等需求,对于被多个库或可执行程序依赖的情况,使用动态库也可以减少链接时间和空间占用(尤其是 FFmpeg )。真正应该避免的是在存在菱形依赖的情况下进行动态库和静态库的混编,windows 中经典的跨 DLL 内存问题便来源于此,详情可见Professional CMake: A Practical Guide的 Mixing Static And Shared Libraries 章节。
很多个人开发者不太乐于专门适配动态库(比如Catch2: issue 2895),主要原因应该是会增加额外的工作量。
同时支持两种库的话又产生了新的问题,文章Building a Dual Shared and Static Library with CMake中进行了一些讨论:
- 用户应该如何指定链接哪一个库;
- 是选择两个库单独编译,还是将静态库以-fPIC 编译后,再生成动态库;
- 如何处理 windows 平台的符号导出;
- 如何处理安装,以及只安装其中一个库的需求。
原文章中的解决方案不支持将动态库与静态库安装至同一个目录下,这里主要介绍我的解决方案。
指定库类型
用户可以通过 static 和 shared 两个别名目标显式链接至对应版本的库:
target_link_libraries(<app1> PRIVATE KRLibrary::static)
target_link_libraries(<app2> PRIVATE KRLibrary::shared)
也可以不显式链接,而是通过选项进行指定,当库依赖层次较深时,便于从外部进行控制:
# 不指定动态库和静态库,通过 BUILD_SHARED_LIBS 或 KRLibrary_USE_SHARED_LIBS 控制
# 后者优先级更高
set(KRLibrary_USE_SHARED_LIBS ON) # 比起硬编码,一般是通过命令行或 CMakeCache.txt 进行修改
find_package(KRLibrary REQUIRED)
target_link_libraries(<app> PRIVATE KRLibrary::KRLibrary)
库的编译
动态库和静态库总是独立编译,不使用-fPIC 编译静态库,因为会对静态库用户造成不必要的性能损耗。
当库作为顶层项目进行构建时,认为用户是库本身的开发者或打包人员,此时默认对两种类型的库都进行编译。
当库作为子项目被引入时,认为用户可能只需要其中一种类型的库,此时为两种类型的库目标设置EXCLUDE_FROM_ALL属性,只有被使用时才会进行编译,避免用户产生不必要的构建开销。
windows 下还需要处理动态库和静态库的.lib 同名的问题,如果使用的是 MSBuild ,同名库文件很可能就被静默覆盖掉了,使用 Ninja 才会提示错误。我的解决方案是为 windows 下的静态库基本名称添加_static 后缀,如果有不同的需求可以按需自行调整。
符号导出
MSVC 默认不导出动态库符号,需要手动通过dllexport 和 dllimport进行控制。GCC 和 Clang 默认导出全局符号,但也提供了对应的控制选项。对于上述属性,CMake 提供了<LANG>_VISIBILITY_PRESET和VISIBILITY_INLINES_HIDDEN进行控制。
从“让错误尽早被发现”的角度出发,我选择默认不导出符号:
CXX_VISIBILITY_PRESET "hidden"
VISIBILITY_INLINES_HIDDEN ON
而导出宏则交给GenerateExportHeader自动生成,开发过程中只需要引入生成的头文件,并为要导出的函数添加宏标记即可:
#pragma once
#include <krlibrary/export.hpp>
namespace krlibrary
{
KRLIBRARY_EXPORT void exported_hello();
} // namespace krlibrary
动态库和静态库共用一份头文件,因此使用静态库时,自动设置宏选项禁用符号导出属性,不需要用户手动处理:
target_compile_definitions(
"${static_target}"
PUBLIC "${project_name_uppercase}_STATIC_DEFINE"
)
安装
安装一般涉及三类文件:
- 动态库和静态库文件;
- 头文件,这里两者的头文件是相同的;
- 用于支持 find_package 导入的文件,例如
*Config.cmake 和*Targets.cmake 文件。
从打包人员的角度出发,则可以分为两种场景:
- Runtime:只需要包含可执行程序和动态库;
- Development:除了 Runtime 中的内容,还需要包括头文件、静态库和
*.cmake 文件。
这两个选项是通过 CMake 的install功能中的 Componet 提供的,安装时默认都安装,也可以指定安装:
$ cmake --install build/ --prefix install/ --component KRLibrary_Runtime
-- Install configuration: "Release"
-- Up-to-date: install/lib/libkrlibrary.so.1.0.0
-- Up-to-date: install/lib/libkrlibrary.so.1
这里额外说明一下,install 中的 component 与 find_package 中的没有任何关系,完全是正交的概念。这里的可以理解为简单的打了个标签方便安装时进行选择,名字也可以按照自己的意图编写,例如干脆细分成*_Headers 、*_Static 、*_Runtime 等,然后添加到对应组件的属性中即可。
另一种角度是从需求角度出发,例如只需要动态库或者静态库之一,那么可以在构建阶段进行设置:
cmake -S . -B build/ -DKRLibrary_ENABLE_INSTALL_STATIC=OFF
同理可以通过选项KRLibrary_ENABLE_INSTALL_SHARED 控制动态库的安装。这两个选项和前述 componets 也是正交的。
其他
提供了 Catch2 的集成,手动处理了它在构建中可能遇到的一些小问题。CI 方面则编写了比较通用的 Github Action 脚本,代码覆盖分析在 Linux 和 windows 平台分别使用gcovr和OpenCppCoverage,编译器警告选项适配了 GCC 、Clang 和 MSVC ,应该足够应付大部分开发场景了。
实际上还处理了一些比较细节的问题,但一方面篇幅所限,另一方面光是要介绍其应用场景可能就要想半天,需要的人就直接阅读源代码吧(懒)。
|