C++的项目的构建可以说是所有语言当中最耀眼的舞台了, 其中存在多种构建方式, 例如:
Makefile
CMake
B2
WAF
SCons
Visual Studio
Autoconf/Automake/libtool
Bazel
xmake
工具虽多, 但是使用体验上却差强人意, 虽然有语言复杂度的原因, 但是笔者认为, 更多的是语言的维护者重视度不够导致.
本文将从C++项目的构建需求, 去尝试总结出什么样的构建系统是C++需要的, 以及如何将这些理念通过CMake来表达.
本文使用的示例代码位于这里(https://github.com/ethanvc/learn_cmake).
C++需要什么样的构建系统
C++需要什么样的构建系统? 为了得到这个问题的答案, 我们以一个输入法项目的例子来分析一下.
该项目包含如下几个模块:
ScheduleService: 计划任务服务. 定时执行任务的能力. 如定时更新客户端版本, 下载云控配置等.
CloudService: 云服务. 提供心跳等服务.
SecurityTransportService: 提供和后台的安全传输能力.
InputCore:/InputCoreBackend: 核心输入功能模块, InputCore需要注入到使用输入法的进程当中去. INputCoreBackend是单独的进程服务, 和InputCore配合, 执行复杂的查词逻辑. 由于InputCore的注入特殊性, 该模块需要全部静态链接其它依赖库, 避免对宿主进程进行影响.
InputCoreRpc: 这个模块是由Input CoreRpc.proto协议生成源文件构建出的库. 负责InputCore和Backend之间的数据序列化/反序列化工作.
模块之间的依赖图如下:
日常项目构建, 有如下一些需求:
构建所有模块的调试版本. 方便开发时定位问题. 调试版本主要是为了禁用优化, 方便看堆栈.
对于VC++的编译器, 调试版本最重要的参数就是
/Od /D "_DEBUG"
, 一个是禁用优化, 一个是定义了一个宏.对于GCC, 参数为
-g -o0
. -g表示生成符号信息, -o0表示禁用优化.
构建所有模块的发布版本并打包.
已经安装了发布版本的安装包, 但是有一个模块出现异常, 需要编一个调试版本的进行替换来定位问题. 或者是问题已经修复, 需要编译修复版本替换有bug的模块文件观察效果.
编译器升级了, 增加的更多检查异常代码的选项. 为了提高代码稳定性, 需要将这些检查参数给每个模块都加上.
为了满足上述需求, 构建系统能够满足如下功能:
设置全局编译/链接参数. 有些参数就需要全局设置才能保证能够正常构建, 例如, 很多开发者喜欢为DEBUG定义一些特殊的行为, 导致DEBUG和RELASE编译出来的二进制不兼容, 因此有些目标用DEBUG, 有些用RELEASE大概率出现崩溃. 还有, VC的
/MD /MDd /MT /MTd
四个参数, 需要所有源文件使用同样的设置, 不然也无法构建成功.单个目标自定义编译/链接参数. 比如一个动态链接库, 根据不同的宏构建出不同功能集合的输出, 此时可能需要为这个目标单独启用某些宏.
指定模块间的依赖关系. 对于C++, 假设A依赖模块B, 那么说明:
上述, 表现到参数上, 即, 在编译A的时候, 需要通过-I参数, 将B模块的头文件搜索路径指定好, 同时需要设置库搜索路径参数和库文件名给到链接器.
这里还有一个点需要注意, 基于模块话设计的考虑, 如果A依赖B, B依赖C, 那么, 编译A时, 原则C的头文件搜索路径不能添加进来, 除非显式指定.
A需要B提供的头文件.
A需要B提供的库文件(静态库OR动态库).
环境检测. 为了做到跨平台, 不少代码里面会根据操作系统, 通过宏编译不同的代码逻辑, 因此, 构建系统需要提供相关的能力来封装环境检测的逻辑, 提高C++构建的跨平台能力.
接下来, 我们来看一下如何通过CMake来完成上述构建需求.
CMake对构建的基本支持
设置全局编译/链接参数
cmake通过如下几个指令来设置全局配置:
add_compile_options: 增加全局编译参数.
add_definitions: 定义全局生效的宏. 其实也可以通过编译参数指定, 但是由于宏时C++语言的的一部分, 通过单独的指令可以屏蔽平台和编译器差异.
include_directories: 添加全局头文件搜索路径. 其实也可以通过编译参数指定.
通过预定义变量指定的参数.
CMAKE_CXX_STANDARD: 指定C++标准的版本.
CMAKE_RUNTIME_OUTPUT_DIRECTORY: 指定可执行文件的输出路径, 如果是WIndows系统, 则输出文件会有exe或dll后缀名.
CMAKE_LIBRARY_OUTPUT_DIRECTORY: linux下so文件的输出路径.
CMAKE_ARCHIVE_OUTPUT_DIRECTORY: lib或.a文件的输出路径.
示例配置如下:
# 通过命令make VERBOSE=1可以看执行的命令行参数, 验证编译选项等是否生效cmake_minimum_required(VERSION 3.16.0 FATAL_ERROR)# 输出cmake的版本信息message("CMAKE_VERSION=${CMAKE_VERSION}")project(testinput LANGUAGES CXX)# 输出信息用# message("dir=${CMAKE_BINARY_DIR}")# 使用C++17标准set(CMAKE_CXX_STANDARD 17)# 设置lib库或.a文件的输出路径set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/archive)# 设置linux下so文件的输出路径set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)# 设置dll/exe文件的输出路径set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)### 设置编译选项# 该项目下的每个源文件在编译时, 都会增加.作为头文件搜索路径# 在VC编译其下, 将通过/I directory添加到编译参数中去# 在gcc下, 将通过-I directory添加到编译参数中去include_directories(.)# 定义全局生效的宏, 全局生效add_definitions(-DPROJECT_GLOBAL_MACRO)# 指定全局编译参数, MSVC时一个cmake检测并设置的全局变量if (MSVC) add_compile_options(/W3 /WX)else() add_compile_options(-W -Wall -Werror)endif()# VC编译器下, 指定链接运行时库的方式message("CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}")if(MSVC) string(REPLACE /MDd /MTd CMAKE_CXX_FLAGS_DEBUG ${CMAKE_CXX_FLAGS_DEBUG}) # message("CMAKE_CXX_FLAGS_DEBUG=${CMAKE_CXX_FLAGS_DEBUG}") string(REPLACE /MD /MT CMAKE_CXX_FLAGS_RELEASE ${CMAKE_CXX_FLAGS_RELEASE}) message("CMAKE_CXX_FLAGS_RELEASE=${CMAKE_CXX_FLAGS_RELEASE}")endif()# 输出编译命令行参数, 用于观察实现细节, 比如默认有些什么参数开启了# cmake构建后, 结果输出在文件compile_commands.json中set(CMAKE_EXPORT_COMPILE_COMMANDS ON)add_subdirectory(http)add_subdirectory(inputcore)add_subdirectory(cloudservice)add_subdirectory(ipc)
目标之间依赖的问题
C++中, A依赖B, 则说明, A需要B的:
头文件. 用于调用函数, 使用B定义的变量. 头文件的依赖通过设置头文件搜索路径来实现.
库文件. 如果B不是纯头文件实现的, 那么在构建A的时候就需要的库支持, 才能链接通过. 并且, 如果有其他模块依赖A, B的静态库或导出库需要一直传递才能最终链接成功. 例如有个X可执行文件需要构建, 则X在链接的时候, 需要A和B两个的静态库.
一个简单的示例如下:
############ CMakeLists.txt开始# INTERRACE告诉构建系统, 这个目标没有编译项, 无需编译, 即实现全部是头文件add_library(inputconfig INTERFACE inputconfig.h)target_sources()########### CMakeLists.txt开始add_executable(cloudservice cloudservice.cpp cloudservice.h)# 添加依赖. PRIVATE表示, 相应的依赖性是本模块的实现相关, 无需传递给依赖cloudservice的模块# 这里不传递的仅头文件设置, 链接时库还时需要添加的.target_link_libraries(cloudservice PRIVATE http)target_link_libraries(cloudservice PRIVATE inputcore)target_link_libraries(cloudservice PRIVATE ipc)# 由于inputconfig只有头文件, 因此需要使用INTERFACE告诉构建系统不要将lib库加如链接target_link_libraries(cloudservice INTERFACE inputconfig)
外部模块的依赖处理
cmake按照project管理一个项目下的所有目标, 但是当一个project需要依赖其他项目时, 则首先需要找到库信息, 然后方可通过target_link_libraries进行依赖. 下面介绍一种处理外部依赖的方法.
将外部模块转换为内部模块
如果外部模块时通过CMake构建的, 那么这种方案将非常简单, 将外部模块的代码通过add_subdirectory加入当前工程, 然后就可以通过target_link_libraries进行引用了.
参考资料
Modern CMake for modular design
Craig Scott: Enhanced source file handling with target_sources()
CMake CookeBook中文版