文章目录
- [1 概述](#1 概述)
- [2 具体案例](#2 具体案例)
-
- [2.1 代码编写](#2.1 代码编写)
- [2.2 构建配置](#2.2 构建配置)
- [2.3 依赖库配置](#2.3 依赖库配置)
- [3 构建结果](#3 构建结果)
1 概述
在前文中论述的都是如何使用CMake构建第三方依赖库,不过这些库都是别人的程序项目,那么如何使用CMake组织构建一个属于自己的C/C++程序项目呢?本文我们就来实现一个使用CMake组建的C/C++项目。
2 具体案例
2.1 代码编写
就不去写很简单的打印HelloWorld案例了,那种简单的案例实用的意义并不大,至少我们得使用调用一个第三方的依赖库的例子。正好笔者写过一个使用libzip压缩文件和文件夹的例子,源代码文件main.cpp如下所示:
cpp
#include <zip.h>
#include <filesystem>
#include <fstream>
#include <iostream>
using namespace std;
void CompressFile2Zip(std::filesystem::path unZipFilePath,
const char* relativeName, zip_t* zipArchive) {
std::ifstream file(unZipFilePath, std::ios::binary);
file.seekg(0, std::ios::end);
size_t bufferSize = file.tellg();
char* bufferData = (char*)malloc(bufferSize);
file.seekg(0, std::ios::beg);
file.read(bufferData, bufferSize);
//第四个参数如果非0,会自动托管申请的资源,直到zip_close之前自动销毁。
zip_source_t* source =
zip_source_buffer(zipArchive, bufferData, bufferSize, 1);
if (source) {
if (zip_file_add(zipArchive, relativeName, source, ZIP_FL_OVERWRITE) < 0) {
std::cerr << "Failed to add file " << unZipFilePath
<< " to zip: " << zip_strerror(zipArchive) << std::endl;
zip_source_free(source);
}
} else {
std::cerr << "Failed to create zip source for " << unZipFilePath << ": "
<< zip_strerror(zipArchive) << std::endl;
}
}
void CompressFile(std::filesystem::path unZipFilePath,
std::filesystem::path zipFilePath) {
int errorCode = 0;
zip_t* zipArchive = zip_open(zipFilePath.generic_u8string().c_str(),
ZIP_CREATE | ZIP_TRUNCATE, &errorCode);
if (zipArchive) {
CompressFile2Zip(unZipFilePath, unZipFilePath.filename().string().c_str(),
zipArchive);
errorCode = zip_close(zipArchive);
if (errorCode != 0) {
zip_error_t zipError;
zip_error_init_with_code(&zipError, errorCode);
std::cerr << zip_error_strerror(&zipError) << std::endl;
zip_error_fini(&zipError);
}
} else {
zip_error_t zipError;
zip_error_init_with_code(&zipError, errorCode);
std::cerr << "Failed to open output file " << zipFilePath << ": "
<< zip_error_strerror(&zipError) << std::endl;
zip_error_fini(&zipError);
}
}
void CompressDirectory2Zip(std::filesystem::path rootDirectoryPath,
std::filesystem::path directoryPath,
zip_t* zipArchive) {
if (rootDirectoryPath != directoryPath) {
if (zip_dir_add(zipArchive,
std::filesystem::relative(directoryPath, rootDirectoryPath)
.generic_u8string()
.c_str(),
ZIP_FL_ENC_UTF_8) < 0) {
std::cerr << "Failed to add directory " << directoryPath
<< " to zip: " << zip_strerror(zipArchive) << std::endl;
}
}
for (const auto& entry : std::filesystem::directory_iterator(directoryPath)) {
if (entry.is_regular_file()) {
CompressFile2Zip(
entry.path().generic_u8string(),
std::filesystem::relative(entry.path(), rootDirectoryPath)
.generic_u8string()
.c_str(),
zipArchive);
} else if (entry.is_directory()) {
CompressDirectory2Zip(rootDirectoryPath, entry.path().generic_u8string(),
zipArchive);
}
}
}
void CompressDirectory(std::filesystem::path directoryPath,
std::filesystem::path zipFilePath) {
int errorCode = 0;
zip_t* zipArchive = zip_open(zipFilePath.generic_u8string().c_str(),
ZIP_CREATE | ZIP_TRUNCATE, &errorCode);
if (zipArchive) {
CompressDirectory2Zip(directoryPath, directoryPath, zipArchive);
errorCode = zip_close(zipArchive);
if (errorCode != 0) {
zip_error_t zipError;
zip_error_init_with_code(&zipError, errorCode);
std::cerr << zip_error_strerror(&zipError) << std::endl;
zip_error_fini(&zipError);
}
} else {
zip_error_t zipError;
zip_error_init_with_code(&zipError, errorCode);
std::cerr << "Failed to open output file " << zipFilePath << ": "
<< zip_error_strerror(&zipError) << std::endl;
zip_error_fini(&zipError);
}
}
int main() {
//压缩文件
//CompressFile("C:/Data/Builder/Demo/view.tmp", "C:/Data/Builder/Demo/view.zip");
//压缩文件夹
CompressDirectory("C:/Data/Builder/Demo", "C:/Data/Builder/Demo.zip");
return 0;
}
接下来就开始编写CMake构建系统的核心配置文件CMakeLists.txt。都说CMake的语法比较烂,但其实编写一个CMakeLists.txt并算不太难。无论是在Windows下使用Microsoft Visual Studio创建MSVC工程,还是Linux下编写Makefile文件,无非也是定义了项目的源代码、库依赖、编译选项以及一些特别的构建细节,CMakeLists.txt中的内容也是如此。只不过CMakeLists.txt中的一些写法抹平的不同操作系统之间的差异,使得编译器和链接器能够相同的逻辑进行工作。你可以这样简单的理解,CMakeLists.txt是不同操作系统下不同构建平台定义的项目文件的再抽象,在进行构建工作的时候CMakeLists.txt会转译成相应平台下的程序项目。
这里CMakeLists.txt的内容如下所示:
cmake
# 输出cmake版本提示
message(STATUS "The CMAKE_VERSION is ${CMAKE_VERSION}.")
# cmake的最低版本要求
cmake_minimum_required (VERSION 3.9)
# 工程名称、版本、语言
project (ZipTest VERSION 0.1 LANGUAGES CXX)
# cpp17支持
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 查找依赖库
find_package(libzip REQUIRED)
# 将源代码添加到此项目的可执行文件。
add_executable (${PROJECT_NAME} "main.cpp")
# 链接依赖库
target_link_libraries(${PROJECT_NAME} PRIVATE libzip::zip)
可以看到内容并不多,逐行进行解析:
- message指令是用来在CMake构建的配置阶段输出的,这个指令非常有用,可以用来检查一些配置变量。
- cmake_minimum_required表示cmake的最低版本要求,CMake的很多特性是随着版本逐渐增加的,需要保证使用的CMake特性满足最低版本的要求。
- project定义工程名称、版本和编程语言。
- 一些构建配置已经被CMake给统一好了,例如是否使用std标准库,使用std标准库的版本,这里使用C++17的版本:
cpp
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
- find_package查找依赖库指令,这里查找的就是libzip库。只要libzip库按照我们前文中的方式正确安装,通过该指令就可以找到该依赖库。
- add_executable则是将源代码文件添加到项目中,这个指令具体定义了有哪些源代码文件。
- target_link_libraries指令的意思是链接依赖库,将libzip库链接到该程序中。
2.2 构建配置
一些构建的配置已经被CMake修改成通用性配置,例如上面提到了使用C++17的std标准库。但是如果有一些针对不同平台的特殊配置怎么办呢?其实也很简单,就像C/C++写跨平台代码一样,识别不同的平台进行处理。如下构建代码所示,可以先检测编译器是Clang、GUN、Intel还是MSVC;如果是MSVC平台的话,就去掉一些警告,增加一些预编译头。
cpp
# 判断编译器类型
message("CMAKE_CXX_COMPILER_ID: ${CMAKE_CXX_COMPILER_ID}")
# 判断编译器类型
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
message(">> using Clang")
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU")
message(">> using GCC")
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Intel")
message(">> using Intel C++")
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC")
message(">> using Visual Studio C++")
# 禁用特定警告
add_compile_options(/wd4996 /wd4251)
# 设置预编译宏
add_definitions("-DUNICODE" "-D_UNICODE" "-DNOMINMAX")
else()
message(">> unknow compiler.")
endif()
在上述构建代码中,4996、4251警告是MSVC经常提示的警告,但是作用并不是很大,因此很多MSVC项目会将其去掉;UNICODE和_UNICODE预处理宏是告诉MSVC使用Unicode字符集;NOMINMAX预处理宏则是取消Win32的最大最小函数,避免函数命令冲突。这些都是MSVC项目的常用配置,我们只需要识别到MSVC平台,并将其应用到CMake指令中即可。
其实,构建的最关键的步骤就在于编译和链接这两步,不同的编译器和链接器有不同的命令行参数,使用MSVC的GUI去设置工程的属性本质上也是取不同的命令行进行执行。也就是说,上述配置代码是一种通用的写法,剩下的我们就只用查找资料找到相应编译器和链接器的命令行参数即可。
2.3 依赖库配置
在上例中可以看到,我们引入依赖库libzip似乎很容易,find_package一下,target_link_libraries一下似乎就可以了。这是因为我们使用了CMake的目标链接(Target-based linking)机制,这也是目前现代CMake的最佳实践,Boost、Qt、OpenCV 等项目都提供了这种方式的支持。
不过,使用这种方式引入依赖库也是有一定条件的。具体来说,我们在使用CMake构建安装依赖库的时候,会生成诸如"XXXConfig.cmake"的配置文件到安装目录,文件中存在诸如add_library或add_executable等命令,就说明该依赖库的目标导出,支持这种目标链接机制。当然,这种方式比较新,不是所有的库项目都提供了这种机制。
如果没有提供目标链接的方式,那么就可以考虑使用传统的头文件和库文件的引入方式,最简单无脑的方式就是使用绝对路径了:
cmake
# 输出cmake版本提示
message(STATUS "The CMAKE_VERSION is ${CMAKE_VERSION}.")
# cmake的最低版本要求
cmake_minimum_required (VERSION 3.9)
# 工程名称、版本、语言
project (ZipTest VERSION 0.1 LANGUAGES CXX)
# cpp17支持
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 添加头文件的搜索路径
include_directories("C:/Work/3rdparty/include")
# 将源代码添加到此项目的可执行文件。
add_executable (${PROJECT_NAME} "main.cpp")
# 链接依赖库
target_link_libraries(${PROJECT_NAME} PRIVATE "C:/Work/3rdparty/lib/zip.lib")
其中include_directories是添加库的头文件所在的路径,target_link_libraries则直接链接到库的地址。不过这种使用绝对路径的方式实在太蠢了,不是支持跨平台,单平台的环境变化都不能支持。稍微方便的一点的方式是将依赖库的安装目录设置成环境变量,例如将"C:/Work/3rdparty"设置成环境变量GISBasic,那么就可以简写成:
cmake
# ...
# 添加头文件的搜索路径
include_directories($ENV{GISBasic}/include)
# 将源代码添加到此项目的可执行文件。
add_executable (${PROJECT_NAME} "main.cpp")
# 链接依赖库
target_link_libraries(${PROJECT_NAME} PRIVATE $ENV{GISBasic}/lib/zip.lib)
这样做至少可以做到配置的一致性,即使开发团队成员每个人的安装目录都不一样,也能保证工程正常构建,只要将GISBasic环境变量设置正确。但是这样做其实也不能保证跨平台,很显然Liunx环境下并不是.lib文件而是.so文件,而且通常有lib前缀。那么就可以根据不同操作系统使用不同的变量值进行构建就可以了,改进如下所示:
cmake
# 添加头文件的搜索路径
include_directories($ENV{GISBasic}/include)
# 将源代码添加到此项目的可执行文件。
add_executable (${PROJECT_NAME} "main.cpp")
# 动态库前缀与后缀
IF(CMAKE_SYSTEM_NAME MATCHES "Linux")
set(LibraryPrefix lib)
set(LibraryPostfix so)
ELSEIF(CMAKE_SYSTEM_NAME MATCHES "Windows")
set(LibraryPrefix )
set(LibraryPostfix lib)
ENDIF()
# 链接依赖库
target_link_libraries(${PROJECT_NAME} PRIVATE $ENV{GISBasic}/lib/${LibraryPrefix}zip.${LibraryPostfix})
可以看到使用链接目标的方式更加简洁一点,传统的使用传统的头文件和库文件的引入方式要达到跨平台的效果需要配置更多的内容。其实CMake的依赖库配置远不止这么一点内容,不过比较推荐的和比较底层的两种方式就以上两种了。其实不管是哪一种编程语言的项目,依赖库的配置永远是最麻烦的,以后有机会再开一章具体讲讲CMake关于依赖库的配置。
3 构建结果
上述简单的项目的代码结构如下所示:
ZipTest
│ main.cpp
│ CMakeLists.txt
还是使用之前构建依赖库的方式使用脚本进行构建,将构建脚本放置到ZipTest目录下,运行如下脚本:
shell
param(
[string]$SourceLocalPath = ".",
[string]$Generator = "Visual Studio 16 2019"
)
# 清除旧的构建目录
$BuildDir = $SourceLocalPath + "/build"
if (Test-Path $BuildDir) {
Remove-Item -Path $BuildDir -Recurse -Force
}
New-Item -ItemType Directory -Path $BuildDir
# 转到构建目录
Push-Location $BuildDir
try {
# 配置CMake
cmake .. -G "$Generator" -A x64 -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_INSTALL_PREFIX="$InstallDir"
# 构建阶段,指定构建类型
cmake --build . --config RelWithDebInfo
}
finally {
# 返回原始工作目录
Pop-Location
}
构建的exe成果就在Build目录的子目录中。其实现在已经可以用IDE可视化构建CMake组建的工程了,具体的过程我们放到下一篇再进行介绍,这一篇的关键在于我们要如何去写CMakeLists.txt文件。其实笔者也认为CMake的语法很繁琐,大写字母加上下划线的写法一点也不美观,初学的时候看到一堆的宏变量头都大了。不过正如本系列博文一开始就说的,其实可以不用去关注这些细节,也不用去系统的学习什么,CMake毕竟只是帮助我们进行构建工具而已。比如最重要的引用依赖库的功能,开始的时候我们只需要知道include_directories包含头文件,target_link_libraries链接库文件,哪怕写一堆条件语句,一堆绝对路径也没什么,我们在构建的过程中自然会思考如何让我们的构建过程更有效率,从而理解CMake的设计思路,就知道如何去写CMakeLists.txt文件了。
键在于我们要如何去写CMakeLists.txt文件。其实笔者也认为CMake的语法很繁琐,大写字母加上下划线的写法一点也不美观,初学的时候看到一堆的宏变量头都大了。不过正如本系列博文一开始就说的,其实可以不用去关注这些细节,也不用去系统的学习什么,CMake毕竟只是帮助我们进行构建工具而已。比如最重要的引用依赖库的功能,开始的时候我们只需要知道include_directories包含头文件,target_link_libraries链接库文件,哪怕写一堆条件语句,一堆绝对路径也没什么,我们在构建的过程中自然会思考如何让我们的构建过程更有效率,从而理解CMake的设计思路,就知道如何去写CMakeLists.txt文件了。