Configuring Transitive Dependencies with Modern CMake

科技   2024-05-20 09:20   浙江  

Introduction

近日某个项目临近结束,书文档,写配置,发现网上的 CMake 教程颇旧,混乱不堪,且缺乏实际作用,难及需求。遂系统读了一些 Modern CMake 资料,撰文记录,以供参考。
实际项目包含上万行代码,依赖三四个第三方库,欲生成支持 find_package() 查找的动态库,并自动传递依赖,以使用户能够直接使用。
下面将其简化为一最小示例,便于演示流程。示例项目结构为:
mylib/
├─ inc/
│  ├─ mylib/
│  │  ├─ lib.h
├─ src/
│  ├─ lib.cc
├─ CMakeLists.txt
lib.hlib.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({23});
  std::cout << tensor << std::endl;
}

void bar()
{
  fmt::print("hello fmtlib\n");
}

// namespace mylib
该库只包含两个函数,foo()bar(),分别依赖 libtorchfmtlib 这两个第三方库。
现在需要编写 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 的依赖传递性。完全可以类比对象,若是想让用户在使用源码时,也可以使用 fmtliblibtorch 的特性而无需再次链接,只需指定 PUBLIC,对 mylib 所依赖的库进行传递。
但需要注意,find_package() 本身并不具备传递依赖的能力,依赖传递需要单独处理,否则用户侧不仅需要导入你的 mylib 库,还需要重复导入 fmtliblibtorch,而这些第三方库其实已经在你提供的 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)
这模板文件用来自动生成一些安装信息,并检查库所依赖的组件是否已经找到。
所有的子依赖皆需要我们手动处理(多么不可理喻!),即我们还需要手动处理 libtorchfmtlib 的依赖传递,否则用户无法找到 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({23});
    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,却可以直接使用 fmtliblibtorch,这表示依赖传递配置成功。

Conclusion

CMake 不易调试,报错亦难顾名思义,教程更是参差不齐,然而实现细节却不可赀计,教人头痛。
本人查询无数资料,调试多次,才跑通多级依赖间的传递问题,还遇到不规范的库和其他库存在 ABI 不一致的问题,追查许久方定位错误。
要让你的库支持 find_package(),其实有两种做法,一种是 config file,另一种是 find module。本文属于前者,许多较新的库皆是采用此种做法,当然也有的库二者皆提供。
本文示例依赖的 libtorch 是较旧的做法,并不友好,用来遍地是坑,而 fmtlib 是本文这种新式做法,使用起来方便许多。要同时依赖这些不同手法配置的库,需多加留心,依照文中做法,可保证无误。
推荐阅读  点击标题可跳转

1、性能大杀器:std::move 和 std::forward

2、从示例入手了解惯用法之PIMPL

3、Mastering Placeholder Type Deduction






CPP开发者
我们在 Github 维护着 9000+ star 的C语言/C++开发资源。日常分享 C语言 和 C++ 开发相关技术文章,每篇文章都经过精心筛选,一篇文章讲透一个知识点,让读者读有所获~
 最新文章