Configuring Transitive Dependencies with Modern CMake
Introduction
近日某个项目临近结束,书文档,写配置,发现网上的 CMake 教程颇旧,混乱不堪,且缺乏实际作用,难及需求。遂系统读了一些 Modern CMake 资料,撰文记录,以供参考。
实际项目包含上万行代码,依赖三四个第三方库,欲生成支持 find_package()
查找的动态库,并自动传递依赖,供用户直接使用。
下面将其简化为一最小示例,便于演示流程。示例项目结构为:
mylib/
├─ inc/
│ ├─ mylib/
│ │ ├─ lib.h
├─ src/
│ ├─ lib.cc
├─ CMakeLists.txt
lib.h
和 lib.cc
内容为:
// inc/mylib/lib.h
#ifndef LIB_H_
#define LIB_H_
namespace mylib {
void foo();
void bar();
} // namespace mylib
#endif // LIB_H_
// src/lib.cc
#include <mylib/lib.h>
#include <iostream>
#include <fmt/core.h>
#include <torch/torch.h>
namespace mylib {
void foo() {
std::cout << "hello torch\n";
torch::Tensor tensor = torch::rand({2, 3});
std::cout << tensor << std::endl;
}
void bar()
{
fmt::print("hello fmtlib\n");
}
} // namespace mylib
该库只包含两个函数,foo()
和 bar()
,分别依赖 libtorch
和 fmtlib
这两个第三方库。
现在需要编写 CMakeLists.txt
来配置依赖,生成动态库,并传递依赖,以使用户无需重复导入依赖的第三方库。
这可以分为三个步骤进行思考,先确保输入无误(Input),再确保依赖传递等逻辑无误 (Process),最后确保输出无误(Output),就是常用的系统分析 IPO 模型。
Input
先看第一步,输入。我们项目的源文件、头文件及第三方库的所有依赖就是这里所说的输入,该步要保证各库路径的正确性和 ABI 的一致性,否则后续可能会产生奇怪的错误。
首先是基本配置,包含项目名称、版本和语言等信息。这样编写即可:
cmake_minimum_required(VERSION 3.20)
project(
mylib
VERSION 1.0.1
DESCRIPTION "First release of mylib"
LANGUAGES CXX
)
其次,导入第三方库。如果库安装在标准路径(/usr/local/lib
etc.),find_package()
可以直接找到,而若是自定义路径,则需要手动指定。暂且这样写上:
find_package(Torch REQUIRED)
find_package(fmt REQUIRED)
message(STATUS "Found Torch: ${TORCH_FOUND}")
message(STATUS "Found fmtlib: ${fmt_FOUND}")
写 CMake 要步步为营,我们先运行一下以确保目前不存在任何问题,再继续前进。在 mylib/
目录下,尝试运行以下命令:
cmake -S . -B build/ -DCMAKE_PREFIX_PATH:STRING=/home/lkimuk/libraries/libtorch/
由于 libtorch
并未处于标准目录,故而需要通过 CMAKE_PREFIX_PATH
手动为其指定搜索路径。倘若输出为:
-- Found Torch: TRUE
-- Found fmtlib: 1
-- Configuring done
-- Generating done
即表示当前无误。libtorch
库的 CMake 配置写法不规范,是老式写法,而 fmtlib
是新式写法,是以输出形式也略有不同。本文是新式写法。
这个搜索路径也可以写在 CMake 中,存在两种方式,一种是 CMAKE_PREFIX_PATH
路径,另一种是 Torch_DIR
变量。
# 1.
list(APPEND CMAKE_PREFIX_PATH "/home/lkimuk/software/libtorch")
# 2.
set(Torch_DIR "/home/lkimuk/software/libtorch/share/cmake/Torch")
但不应写死,而是通过 CMake 命令指定,因为实际运行时,用户的路径不可能与你相同。
最后,处理源文件输入,并将依赖库附加至动态库。
# Found all source files
file(GLOB_RECURSE SOURCE_FILES "${PROJECT_SOURCE_DIR}/src/*.cc")
list(LENGTH SOURCE_FILES SRC_FILES_SIZE)
message(STATUS "Found ${SRC_FILES_SIZE} source files of mylib")
# Define a shared library target named `mylib`
add_library(mylib SHARED)
# Specify source files for target named `mylib`
target_sources(mylib PRIVATE ${SOURCE_FILES})
# Specify the include directories for the target named `mylib`
target_include_directories(mylib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/inc>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
# Specify the link directories for the target named `mylib`
target_link_libraries(mylib PUBLIC
fmt::fmt
${TORCH_LIBRARIES}
)
# Request compile features for target named `mylib`
target_compile_features(mylib PUBLIC cxx_std_20)
Modern CMake 是 target-oriented 的, target 就是编译的目标,可以是静态库、动态库和可执行文件,各类目录就是这个 target 的属性,权限就是 target 的依赖传递性。完全可以类比对象,若是想让用户在使用源码时,也可以使用 fmtlib
和 libtorch
的特性而无需再次链接,只需指定 PUBLIC
,对 mylib
所依赖的库进行传递。
但需要注意,find_package()
本身并不具备传递依赖的能力,依赖传递需要单独处理,否则用户侧不仅需要导入你的 mylib
库,还需要重复导入 fmtlib
和 libtorch
,而这些第三方库其实已经在你提供的 CMake 中导入过了。
此时,在 mylib/
目录下,运行以下命令:
cmake -S . -B build/ -DCMAKE_PREFIX_PATH:STRING=/home/lkimuk/libraries/libtorch/
cmake --build ./build
如无报错,则第一步结束。
Process
再看第二步,导出与安装。
首先,使我们的库能够安装。就是指定一些安装目录、导出头文件之类的操作,ARCHIVE
是静态库目录,LIBRARY
是动态库目录,INCLUDES
是头文件目录。
# Defines the ${CMAKE_INSTALL_INCLUDEDIR} and ${CMAKE_INSTALL_LIBDIR} variable.
include(GNUInstallDirs)
# Make executable target `mylib` installable
install(TARGETS mylib
EXPORT mylib-targets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
# Install the header files
install(
DIRECTORY ${PROJECT_SOURCE_DIR}/inc/
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
此处创建了一个导出对象 mylib-targets
,指定了一些导入信息,主要就是某些信息保存的目录,如库存入 ${CMAKE_INSTALL_LIBDIR}
(其实就是 lib
)下,头文件存入 ${CMAKE_INSTALL_INCLUDEDIR}
(其实就是 include
)下,这两个变量来自 GNUInstallDirs
。但此时尚未实际生成文件,只是指定一些信息而已。
install(DIRECTORY ...)
实际将头文件拷贝至 ${CMAKE_INSTALL_INCLUDEDIR}
目录,前面的命令不会自动复制这些内容,需要自己手动写。
接着,根据导出名称,实际导出 targets 文件。
# Generate the required import code for the content in <export name>
# into mylib-config.cmake CMake file.
install(EXPORT mylib-targets
NAMESPACE mylib::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/mylib/cmake
)
该部分会实际导出并生成 mylib-targets.cmake
文件,也可以命名为 mylibTargets
,则会生成 mylibTargets.cmake
文件,这里面包含着 find_packages()
查找库所需重要信息。
如果你的库不依赖第三方库,那么直接将上面的 mylib-targets
改为 mylib-config
文件就可以满足需求,find_packages()
实际需要的是 mylib-config.cmake
这个名称的文件。这里先生成 mylib-targets
是为了稍后处理依赖传递,后面 mylib-targets.cmake
依旧会包含在 mylib-config.cmake
文件中。
这里暂先不管依赖传递,放到下一节专门讲解。先为库生成版本信息:
# Defines write_basic_package_version_file
include(CMakePackageConfigHelpers)
# Create a package version file for the package.
write_basic_package_version_file(
"mylib-config-version.cmake"
# Package compatibility strategy. SameMajorVersion is essentially `semantic versioning`.
COMPATIBILITY SameMajorVersion
)
# Install command for deploying Config-file package files into the target system.
# It must be present in the same directory as `mylib-config.cmake` file.
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/mylib-config-version.cmake"
DESTINATION ${CMAKE_INSTALL_LIBDIR}/mylib/cmake
)
版本信息通过 write_basic_package_version_file
函数实现,SameMajorVersion
表示和 project(VERSION)
中指定的主版本相同。这将实际生成 mylib-config-version.cmake
文件,它必须和 mylib-config.cmake
(目前这个文件还未创建,我们将其命名为 mylib-targets.cmake
了)文件处于同一目录,后续可以导入不同版本的库。
至此,第二步结束,运行如下命令测试:
cmake -S . -B build/ -DCMAKE_PREFIX_PATH:STRING=/home/lkimuk/libraries/libtorch/
cmake --build ./build
cmake --install ./build --prefix /tmp/install-mylib
若无意外,将会生成以下文件:
/tmp/
├─ install-mylib/
│ ├─ include/
│ │ ├─ mylib/
│ │ │ ├─ lib.h
│ ├─ lib/
│ │ ├─ libmylib.so
│ │ ├─ mylib/
│ │ │ ├─ cmake/
│ │ │ │ ├─ mylib-targets.cmake
│ │ │ │ ├─ mylib-targets-noconfig.cmake
│ │ │ │ ├─ mylib-config-version.cmake
测试之时,指定到临时路径,以防止污染系统目录,待测试完毕,则不用指定目录,将安装到系统标准路径。
此时只剩下一个重要文件,mylib-config.cmake
,里面需要处理实际的依赖传递。
Output
这节专门讲依赖传递,完成最终输出。此节的内容请添加在 include(CMakePackageConfigHelpers)
(即生成版本导库) 代码的下方。
最后一部分,也是全文最核心的代码如下:
# Generate the config-file
set(LIB_INSTALL_DIR ${CMAKE_INSTALL_LIBDIR}/mylib)
configure_package_config_file(
${CMAKE_CURRENT_SOURCE_DIR}/mylib-config.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/mylib-config.cmake"
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/mylib/cmake
PATH_VARS LIB_INSTALL_DIR
)
# Found all the required sub-dependencies
file(APPEND "${CMAKE_CURRENT_BINARY_DIR}/mylib-config.cmake" "include(CMakeFindDependencyMacro)\nfind_dependency(fmt)\nfind_package(Torch)")
# Install mylib-config.cmake
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/mylib-config.cmake"
DESTINATION ${CMAKE_INSTALL_LIBDIR}/mylib/cmake
)
这将根据 mylib/mylib-config.cmake.in
模板文件生成 mylib-config.cmake
文件,该模板文件需要手动创建,内容如下:
@PACKAGE_INIT@
set_and_check(MYLIB_LIB_DIR "@PACKAGE_LIB_INSTALL_DIR@")
include("${MYLIB_LIB_DIR}/cmake/mylib-targets.cmake")
check_required_components(mylib)
这模板文件用来自动生成一些安装信息,并检查库所依赖的组件是否已经找到。
所有的子依赖皆需要我们手动处理(多么不可理喻!),即我们还需要手动处理 libtorch
和 fmtlib
的依赖传递,否则用户无法找到 mylib
依赖的这些库。这项工作可以通过 find_dependency
来实现,因此要进行文件读写,在 mylib-config.cmake
的文件末尾追加写入这些依赖,libtorch
库是老式实现,不支持 find_dependency
,还是写成 find_package(Torch)
。
最终生成的 mylib-config.cmake
文件内容为:
####### Expanded from @PACKAGE_INIT@ by configure_package_config_file() #######
####### Any changes to this file will be overwritten by the next CMake run ####
####### The input file was mylib-config.cmake.in ########
get_filename_component(PACKAGE_PREFIX_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../" ABSOLUTE)
macro(set_and_check _var _file)
set(${_var} "${_file}")
if(NOT EXISTS "${_file}")
message(FATAL_ERROR "File or directory ${_file} referenced by variable ${_var} does not exist !")
endif()
endmacro()
macro(check_required_components _NAME)
foreach(comp ${${_NAME}_FIND_COMPONENTS})
if(NOT ${_NAME}_${comp}_FOUND)
if(${_NAME}_FIND_REQUIRED_${comp})
set(${_NAME}_FOUND FALSE)
endif()
endif()
endforeach()
endmacro()
####################################################################################
set_and_check(MYLIB_LIB_DIR "${PACKAGE_PREFIX_DIR}/lib/mylib")
include("${MYLIB_LIB_DIR}/cmake/mylib-targets.cmake")
check_required_components(mylib)
include(CMakeFindDependencyMacro)
find_dependency(fmt)
find_package(Torch)
通过上节最后一段的运行指令,最后得到的文件结构应该如下:
/tmp//
├─ install-mylib/
│ ├─ include/
│ │ ├─ mylib/
│ │ │ ├─ lib.h
│ ├─ lib/
│ │ ├─ libmylib.so
│ │ ├─ mylib/
│ │ │ ├─ cmake/
│ │ │ │ ├─ mylib-targets.cmake
│ │ │ │ ├─ mylib-targets-noconfig.cmake
│ │ │ │ ├─ mylib-config.cmake
│ │ │ │ ├─ mylib-config-version.cmake
由此,万事具备。
Consumer
库已导毕,如今在用户方测试,创建的用户项目结构如下:
mylib-consumer/
├─ src/
│ ├─ main.cpp
├─ CMakeLists.txt
main.cpp
测试内容为:
#include <mylib/lib.h>
#include <torch/torch.h>
#include <fmt/core.h>
int main() {
mylib::foo();
mylib::bar();
fmt::print("fmt haha\n");
std::cout << "line----\n";
torch::Tensor tensor = torch::randn({2, 3});
std::cout << tensor;
}
CMakeLists.txt
这样写:
cmake_minimum_required(VERSION 3.20)
project(mylib_consumer)
find_package(mylib 1 CONFIG REQUIRED)
message(STATUS "Found mylib: ${mylib_FOUND}")
add_executable(mylib_consumer src/main.cpp)
target_link_libraries(mylib_consumer PRIVATE mylib::mylib)
在 ./mylib-consumer
路径下运行以下命令:
cmake -S . -B build -DCMAKE_PREFIX_PATH:STRING=/tmp/install-mylib
cmake --build ./build
./build/mylib_consumer
得到程序输出:
hello torch
0.0214 0.7529 0.8833
0.1588 0.4331 0.5239
[ CPUFloatType{2,3} ]
hello fmtlib
fmt haha
line----
1.4285 -1.4233 0.0241
0.7902 0.3571 1.7416
[ CPUFloatType{2,3} ]
测试程序中只导入了 mylib
,却可以直接使用 fmtlib
和 libtorch
,这表示依赖传递配置成功。
Conclusion
CMake 不易调试,报错亦难顾名思义,教程更是参差不齐,然而实现细节却不可赀计,教人头痛。
本人查询无数资料,调试多次,才跑通多级依赖间的传递问题,还遇到不规范的库和其他库存在 ABI 不一致的问题,追查许久方定位错误。
要让你的库支持 find_package()
,其实有两种做法,一种是 config file,另一种是 find module。本文属于前者,许多较新的库皆是采用此种做法,当然也有的库二者皆提供。
本文示例依赖的 libtorch
是较旧的做法,并不友好,用来遍地是坑,而 fmtlib
是本文这种新式做法,使用起来方便许多。要同时依赖这些不同手法配置的库,需要多加留心,依照文中做法,则可保证无误。