基于cmake的大型工程组织和构建¶
在大型C++项目中,构建系统的选择直接影响到项目的可维护性、可扩展性以及第三方开发的友好度。一个成熟的工程不仅仅包含应用程序本身,还包括为其核心功能抽象出的库。这些库一方面服务于项目自身的模块化解耦,另一方面也可能作为插件化开发的基础库提供给第三方开发者。此外,项目还不可避免地依赖于众多第三方库。
一个典型的大型工程通常由以下几部分组成:
- 第三方库: 如
OCCT、VTK、Qt等。 - 自定义库: 项目自身抽象出的功能模块。
- 可执行程序: 如 GUI 程序、命令行工具等。
- 静态资源: 脚本、图片、配置文件等。
针对这些大型的工程,如果用一些简单的构建工具,是很难做到一键编译一键安装的,例如 qmake,缺少强大的安装和依赖管理功能,这也就是为什么 Qt6 弃用 qmake,全面转向 cmake。目前来说,在C++领域,最适合进行构建管理的还是cmake,虽然cmake 有非常非常多的缺点,但它凭借强大的功能,依然是当前 C++ 领域构建管理的事实标准。
通过 cmake,我们可以实现:
- 高效组织 庞大且复杂的工程结构。
- 自动化编译 第三方依赖库。
- 按依赖关系 自动构建项目所有组件。
- 一键式安装 部署整个项目。
- 生成 便于第三方集成的插件开发环境。
本文将结合实践经验,深入探讨如何利用 cmake 组织和构建一个大型工业级软件项目,最终生成一个可供第三方开发者一键引入、便捷地进行二次开发的完整环境。同时,本文也介绍了通过git submodule 来方便管理第三方库。
工程的目录结构¶
一个清晰、标准的目录结构是大型工程良好管理的开端,工程的顶层文件夹应该包含如下几个文件夹:
- src 文件夹,这个文件夹用来放置你所有的源代码
- docs 文件夹,这个文件夹用来放置你所有的文档
- 3rdparty 文件夹,这个文件夹用来放置你所有的第三方库,这个文件夹可以放在 src 文件夹里面,也可以放在外层目录
- 针对整个工程的
CMakeLists.txt文档 - cmake 文件夹,这个文件夹放置了一些封装好的 cmake 文件,用来方便你的 cmake 的集成
上面的这些文件夹和文件是一个工程比较通用的组织结构
一般而言,在工程的顶层目录下,还会有.clang-format用于规范编码,.clang-tidy和.clazy用于代码检查这些按需提供,但作为一个开源的项目,还是建议提供的
因此一个相对标准的源码目录如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
第三方库的管理¶
3rdparty 文件夹用来放置所有的第三方库的源代码,通常来讲,第三方库源代码不应该下载下来,放进 3rdparty 文件夹,而是通过 git 的 submodule 添加进去,通过 submodule 方式添加进去的源代码,可以随时更新到远程仓库上的最新版本,也可以指定这个第三方库是某个固定分支或者是某个 tag
例如我这里需要使用ribbon界面,添加了SARibbon作为第三方库
1 2 | |
注意,对于使用
submodule管理第三方库的方式,首次拉取项目之后,需要执行:
1git submodule update --init --recursive把所有库拉取下来
也可以clone的时候使用--recursive参数
1git clone --recursive
大部分的第三方库都提供了 cmake,如果不提供的话,我会 fork 一个,写一个带有 cmake 的版本,例如 qwt库,QtPropertyBroswer库,3rdparty 文件夹下会写一个 cmake 文件,用来集中编译所有的第三方库,一般我会在 cmake 中就指定安装目录,确保第三方库的安装目录和我的程序的安装目录是一致的,这样的好处是,如果你的程序需要给其他人进行二次开发的话,能保证你程序编译出来的库和第三方库是在一个安装环境下,这样可以解决第三方库和你自身程序库的依赖问题,不需要用户在编译你的程序之前先进行大量的第三方库的编译,只需要一次统一的编译即可把所有的第三方库安装到固定目录下,最后install后,形成一个完整的开发环境
连同第三方库一起发布的开发环境bin目录

连同第三方库一起发布的开发环境lib目录

作为第三方开发者,这个完整开发环境里面包含了所有的库,第三方开发者只需知道安装目录,就可以加载所有的依赖
下面就介绍一下,如何通过cmake实现这种大型工程的组织
大型工程的cmake写法¶
这里不会教你如何写cmake,而是着重讲讲大型工程的cmake要注意事项,工程顶层会有个CMakeLists.txt文件,这个文件定义了整个工程的信息、可选项、总体的安装步骤等,实现整个工程的构建,顶层的CMakeLists.txt通过add_subdirectory添加子目录,一般会添加src目录,以我自己的一个仿真集成平台data-workbench举例,介绍如何通过cmake组织一个大型的工程
上述的仿真集成平台不提供业务逻辑,所有业务逻辑都是通过插件实现,插件的开发就需要依赖此集成平台和所有第三方库
要驾驭大型工程的构建,必须深入理解和熟练运用 cmake 的 install 命令
install 命令的主要功能:
1. 复制文件/目录 到指定位置。
2. 导出目标,生成 {库名}Targets.cmake 文件,供其他 cmake 项目 find_package。
3. 为当前项目 的其他模块提供依赖支持。
cmake 强大的一个地方在于它能通过 $<BUILD_INTERFACE: 和 $<INSTALL_INTERFACE: 生成器表达式,优雅地区分 构建环境(源代码目录)和 安装环境(安装目录),确保头文件路径和依赖关系在不同场景下都能正确工作。
cmake的install用法是比较固定的,按照一个例子或者模板非常简单的就能实现自己的安装和部署,针对大型系统一个多组件的安装是必须的,类似于QT的包引入,能进行模块的划分,不需要整个QT所有库都一起引进工程里面,针对自己的大型系统也应该实现类似的引入,因此,下面将着重介绍如何进行模块化的install
规范的安装路径¶
使用规范的安装路径,能让你工程的库以及第三方库安装在同一个目录下,这样你的工程就很容易被第三方使用者集成起来进行二次开发,因此,安装路径尽量使用规范化的安装路径,而不是过于自由的进行定制,一般规范化的安装路径如下:
bin/: 存放可执行文件和 Windows 下的 DLL 文件。lib/: 存放静态库(.a, .lib)和动态库的导入库(.lib)。lib/cmake/<ProjectName>/: 存放项目的 CMake 配置文件(如*Config.cmake,*Targets.cmake)。include/<ProjectName>/: 存放项目的公共头文件。
通常不建议在cmake里硬编码上诉路径,GNUInstallDirs 模块定义的标准路径
使用 include(GNUInstallDirs) 后,你可以使用 CMAKE_INSTALL_BINDIR、CMAKE_INSTALL_LIBDIR、CMAKE_INSTALL_INCLUDEDIR 等变量,确保路径的规范性。
下面是常见的cmake安装后的文件夹

基本上大部分的第三方库都是按照这个目录结构进行安装,这样当你的工程包含了大量的第三方库,以及你自身的库的情况下,最终所有的dll都会安装在bin录下,所有的库文件都会安装在lib目录下,所有的头文件都会在include文件夹下面对应的自身库名的文件夹下面,所有cmake需要用的文件都在lib/cmake文件夹下对应的自身库名的文件夹下面
以这种标准化的形式构建,第三方开发者可以很方便的使用你的工程
这里举一个例子,假如你的库名叫SARibbonBar,那么它安装后在windows系统下应该生成如下结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
单模块库的 install 标准写法¶
如果你作为一个库开发者,这个库只有一个模块,那么写法相对固定,单一模块的install写法基本就是如下步骤:
1.定义库名和版本¶
这里定义一些基本信息,后续的步骤可使用这些变量
1 2 3 4 5 | |
2.配置目标属性(关键!)¶
使用 target_include_directories 并利用生成器表达式区分构建和安装环境。
1 2 3 4 5 6 | |
target_include_directories和target_compile_definitions这两个是cmake的核心函数,它告诉了cmake这个目标有哪些头文件和哪些预定义宏,并把信息传递给使用库的人
3.安装公共头文件¶
通过install可以复制任意内容,把你要提供的头文件、甚至脚本、资源都移动到指定安装目录下
1 2 3 4 | |
4.安装目标并导出(关键!)¶
EXPORT 关键字将目标的信息保存到一个名为 ${LIB_NAME}Targets 的导出集中。
1 2 3 4 5 6 7 | |
这里不得不吐槽cmake,把install命令赋予了太多功能,导致理解困难
5.生成 Config.cmake 文件¶
这里比较抽象但必不可少,会用到write_basic_package_version_file和configure_package_config_file两个函数,用于生成find_package所必须的Config.cmake文件
首先,创建一个模板文件 ${LIB_NAME}Config.cmake.in,一般内容可如下:
1 2 3 4 5 6 7 8 | |
上面的${LIB_NAME}Config.cmake.in是你为了生成Config.cmake文件使用的内嵌文件,具体位置视情况而定
然后,在主 CMakeLists.txt 中使用 CMakePackageConfigHelpers 模块生成最终文件,这里比较抽象但写法固定。
1 2 3 4 5 6 7 8 9 10 11 12 | |
6.安装生成的 CMake 文件¶
上面的文件会在编译过程生成在${CMAKE_CURRENT_BINARY_DIR}目录下面,你要在安装过程中把这个文件复制到lib/cmake/你的库名的配置目录下,通常写法如下:
1 2 3 4 5 6 7 8 9 10 | |
完成以上步骤后,其他项目就可以通过简单的 find_package(MyLib) 来使用你的库了。
使用这个库仅仅需要以下步骤:
1 2 | |
多模块的install写法¶
当一个项目包含多个库(如 Qt 的 Core、Gui、Widgets)时,我们需要将所有模块的导出信息合并到一个总的导出目标中,并提供一个顶层的 Config.cmake 文件
Qt就是一个多模块的例子,Qt模块的引入是这样写的:
1 2 3 4 5 | |
多模块和单模块的区别就是导出这一步(install(TARGETS xx EXPORT xxx ...)),多模块在每个模块的安装导出需要导出到同一个目标中,每个模块不需要再调用write_basic_package_version_file和configure_package_config_file
多模块的文件组织示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
为了更好的组织大型项目,一般会在项目的根目录下创建一个cmake文件夹,常用的cmake文件会统一放在此目录下
多模块的install写法有如下步骤:
1.顶层CMakeLists.txt写法¶
顶层的CMakeLists.txt里需要进行安装导出目标,它主要处理如下事情
- 定义总包名
MyPackage和总导出目标名MyPackageTargets。 - 负责生成和安装顶层的
MyPackageConfig.cmake和MyPackageConfigVersion.cmake文件。 - 不安装任何具体的目标,只导出所有子模块累积到
MyPackageTargets中的信息。
对于模块化的cmake,首先要有个总的进入文件,以MyPackage命名,像Qt5就叫Qt5Config.cmake,自己模块就叫{MyPackageName}Config.cmake
和单一模块类似,{MyPackageName}Config.cmake会通过{MyPackageName}Config.cmake.in模板生成,一个相对通用的写法如下:
1 2 3 4 5 6 7 8 | |
上面{MyPackageName}需要替换为你的包名
在顶层的CMakeLists.txt里需要进行安装导出目标,顶层CMakeLists.txt的安装写法如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
顶层的CMakeLists.txt负责导出Config文件,后续所有模块的安装都往这个目标添加
2.子模块CMakeLists.txt写法¶
多模块的子模块安装时不需要生成config文件,只需要将自己的目标追加到顶层定义的总导出集中(上诉例子的导出集名为MY_PACKAGE_TARGET_NAME)。
多模块的子模块安装示例
1 2 3 4 5 6 7 8 9 10 11 | |
MY_PACKAGE_TARGET_NAME是在顶层cmake定义的总的导出集,子模块的安装都导出到此导出集即可,这样就能像Qt一样,通过find_package(MyPackageName COMPONENTS ModuleA ModuleB)找到对应的库
工程的组织¶
至此,单模块和多模块的安装都已介绍完成,大型工程的组织和安装就是这两者的组合
工程各个模块安装到固定目录下,连同第三方库指定同一个安装路径,最终形成一个完整的开发环境
这里以实际例子举例,例子源码位于:
github:https://github.com/czyt1988/data-workbench
gitee镜像:https://gitee.com/czyt1988/data-workbench
源码目录结构(这里为了便于显示,文件夹用[]扩起):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
- 指定统一的安装目录
这一步可以使得第三方库和工程安装的位置一致,对于linux有比较规范的安装路径,但windows不一样,默认是在C:\Program Files\xxx这样的位置,没有统一放lib的地方,因此,windows下,个人习惯指定工程的自身目录下建立一个安装目录,以bin_{Debug/Release}_{x32/x64}的方式命名,如果有Qt,还会加上Qt的版本以作区分,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 | |
提示
这样操作对库开发还有个好处,可以有效区分不同版本qt,不同编译器的结果
- 第三方库
如前文所述,第三方库都在src/3rdparty下面,首先需要的是对第三方库的编译,3rdparty有个CMakeLists.txt文件夹用于编译安装所有第三方库,个人习惯不把3rdparty下的CMakeLists.txt纳入顶层工程的subdirectory中,因为不保证所有第三方库的cmake写的都正常,第三方库的CMakeLists.txt指定了CMAKE_INSTALL_PREFIX和顶层工程一致,确保安装路径一致
- 组织顶层工程
顶层工程CMakeLists主要负责做以下事情:
- 定义
option - 定义工程名称
- 做全局的编译设置,如c++版本要求,编译环境的POSTFIX设置
- 通过
add_subdirectory完成整个工程的组织 - 工程模块化的安装(见多模块的install写法)
- 工程的完整安装
第三方用户引入的方式¶
对于第三方插件开发者来说,首先需要clone你的工程,并进行编译,先编译第三方库,并进行安装(install),再编译工程,并进行安装(install),这时候,第三方开发者就可以有一个完整的开发环境了
