16、模块化设计:编写自己的Find模块、模块搜索路径、模块复用技巧、模块版本管理

说实话,CMake 的模块化设计,是我在项目中踩坑最多、但也是收获最大的部分。刚开始用 CMake 那会儿,我总觉得 find_package 是个黑盒子——敲个命令,库就找到了,找不到就报错。直到有一次,项目里用了个内部自研的算法库,死活找不到,我才被迫打开这个黑盒子看了看。

嗯,看完之后我发现,其实没那么神秘。今天我就把这块掰开了讲给你听。

16.1 为什么要自己写 Find 模块?

你想想看,find_package 的本质是什么?它就是在执行一个名为 FindXXX.cmake 的脚本。这个脚本的任务很简单:找到库的头文件路径、库文件路径,然后设置好变量。

那什么时候需要自己写?

  • 第三方库没有提供 CMake 支持——很多老牌 C++ 库,比如一些工业软件 SDK,压根没有 .cmake 文件
  • 库的安装路径不标准——比如公司内部编译的库,放在 /opt/company/lib 这种地方
  • 需要定制查找逻辑——比如根据编译类型(Debug/Release)链接不同版本的库

核心原则:一个好的 Find 模块,应该让使用者只需要写一行 find_package(MyLib REQUIRED),剩下的全自动搞定。

16.2 手写一个 Find 模块:从零开始

我记得第一次写 Find 模块时,犯了个低级错误——忘了设置 XXX_FOUND 变量。结果 CMake 一直报错说找不到包,我排查了半天才发现。

下面是一个标准的 Find 模块骨架。假设我们要找的库叫 Foo,它包含 foo.hlibfoo.so

# FindFoo.cmake
# 查找 Foo 库的 CMake 模块

# 第一步:检查用户是否已经指定了路径
if(NOT Foo_ROOT)
  set(Foo_ROOT "" CACHE PATH "Foo 库的根目录")
endif()

# 第二步:查找头文件
find_path(Foo_INCLUDE_DIR
  NAMES foo.h
  PATHS ${Foo_ROOT}
  PATH_SUFFIXES include
  NO_DEFAULT_PATH
)

# 如果没找到,用默认路径再试一次
if(NOT Foo_INCLUDE_DIR)
  find_path(Foo_INCLUDE_DIR
    NAMES foo.h
    PATH_SUFFIXES include
  )
endif()

# 第三步:查找库文件
find_library(Foo_LIBRARY
  NAMES foo libfoo
  PATHS ${Foo_ROOT}
  PATH_SUFFIXES lib
  NO_DEFAULT_PATH
)

if(NOT Foo_LIBRARY)
  find_library(Foo_LIBRARY
    NAMES foo libfoo
    PATH_SUFFIXES lib
  )
endif()

# 第四步:处理结果
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(Foo
  REQUIRED_VARS Foo_INCLUDE_DIR Foo_LIBRARY
)

# 第五步:设置导入目标(现代 CMake 推荐做法)
if(Foo_FOUND AND NOT TARGET Foo::Foo)
  add_library(Foo::Foo UNKNOWN IMPORTED)
  set_target_properties(Foo::Foo PROPERTIES
    IMPORTED_LOCATION "${Foo_LIBRARY}"
    INTERFACE_INCLUDE_DIRECTORIES "${Foo_INCLUDE_DIR}"
  )
endif()

# 第六步:清理临时变量
mark_as_advanced(Foo_INCLUDE_DIR Foo_LIBRARY)

我的习惯:我一般会在 Find 模块里同时支持 Foo_INCLUDE_DIRSFoo_LIBRARIES 这种传统变量,以及 Foo::Foo 这种导入目标。这样不管项目用的是老式写法还是现代 CMake,都能兼容。

16.3 模块搜索路径:CMake 去哪儿找你的模块?

写好了 Find 模块,你得告诉 CMake 去哪儿找它。这里有个优先级顺序,我画了张图帮你理解:

CMake 模块搜索路径优先级 最高优先级:CMAKE_MODULE_PATH set(CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake/Modules") 次高优先级:CMake 安装目录 /usr/share/cmake-3.xx/Modules/ 最低优先级:系统默认路径 /usr/local/share/cmake/Modules/ 查找顺序:CMAKE_MODULE_PATH → CMake 自带目录 → 系统默认目录 一旦找到匹配的 FindXXX.cmake,立即停止搜索

说白了,最靠谱的做法就是在你的 CMakeLists.txt 顶部加上:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Modules")

然后把你的 FindFoo.cmake 扔进 cmake/Modules/ 目录。这样不管项目移到哪台机器上编译,都能找到你的模块。

我曾经踩过的坑:有一次我把 Find 模块放在了项目根目录,然后用了相对路径 set(CMAKE_MODULE_PATH "./cmake")。结果在子目录里调用 find_package 时,CMake 的当前工作目录变了,路径就失效了。后来我改成 ${CMAKE_CURRENT_SOURCE_DIR} 才解决。记住:永远用绝对路径或 ${} 变量,别用相对路径。

16.4 模块复用技巧:别重复造轮子

项目做多了你会发现,很多 Find 模块的查找逻辑是相似的。比如找头文件、找库文件、设置导入目标,这三板斧几乎每个模块都有。

我的做法是写一个 CommonFind.cmake 工具模块,把通用逻辑抽出来:

# CommonFind.cmake
# 通用查找函数

function(common_find_library TARGET_NAME HEADER_NAME LIB_NAME)
  # 查找头文件
  find_path(${TARGET_NAME}_INCLUDE_DIR
    NAMES ${HEADER_NAME}
    PATH_SUFFIXES include
  )

  # 查找库文件
  find_library(${TARGET_NAME}_LIBRARY
    NAMES ${LIB_NAME}
    PATH_SUFFIXES lib
  )

  # 处理结果
  include(FindPackageHandleStandardArgs)
  find_package_handle_standard_args(${TARGET_NAME}
    REQUIRED_VARS ${TARGET_NAME}_INCLUDE_DIR ${TARGET_NAME}_LIBRARY
  )

  # 设置导入目标
  if(${TARGET_NAME}_FOUND AND NOT TARGET ${TARGET_NAME}::${TARGET_NAME})
    add_library(${TARGET_NAME}::${TARGET_NAME} UNKNOWN IMPORTED)
    set_target_properties(${TARGET_NAME}::${TARGET_NAME} PROPERTIES
      IMPORTED_LOCATION "${${TARGET_NAME}_LIBRARY}"
      INTERFACE_INCLUDE_DIRECTORIES "${${TARGET_NAME}_INCLUDE_DIR}"
    )
  endif()
endfunction()

然后每个具体的 Find 模块就变得很简洁:

# FindBar.cmake
include(CommonFind)
common_find_library(Bar bar.h bar)

这样写的好处是:如果以后要修改查找逻辑(比如增加对静态库的支持),只需要改 CommonFind.cmake 一处,所有模块都跟着更新。

16.5 模块版本管理:让 find_package 支持版本号

你肯定见过这样的写法:find_package(OpenCV 4.5 REQUIRED)。CMake 是怎么知道版本号的?答案就在 FindXXX.cmake 里。

要让你的模块支持版本检测,需要做两件事:

  1. 在 Find 模块中设置版本变量
  2. 使用 find_package_handle_standard_args 的版本参数

举个例子:

# FindFoo.cmake 带版本支持

# 假设我们通过读取一个版本文件来获取版本号
if(EXISTS "${Foo_INCLUDE_DIR}/foo_version.h")
  file(STRINGS "${Foo_INCLUDE_DIR}/foo_version.h" _version_line
    REGEX "#define FOO_VERSION"
  )
  if(_version_line)
    string(REGEX REPLACE ".*FOO_VERSION \"([0-9.]+)\".*" "\\1"
      Foo_VERSION "${_version_line}"
    )
  endif()
endif()

# 如果没有版本文件,可以硬编码(不推荐)
if(NOT Foo_VERSION)
  set(Foo_VERSION "1.0.0")
endif()

# 关键:将版本号传给 find_package_handle_standard_args
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(Foo
  REQUIRED_VARS Foo_INCLUDE_DIR Foo_LIBRARY
  VERSION_VAR Foo_VERSION
)

这样,当用户写 find_package(Foo 1.2.0 REQUIRED) 时,CMake 会自动比较 Foo_VERSION 和要求的版本。如果版本不匹配,会给出清晰的错误信息。

我的建议:版本号最好从库本身的头文件或配置文件中读取,而不是硬编码在 Find 模块里。否则库升级了,Find 模块还得跟着改,容易忘。

16.6 实战:一个完整的 Find 模块模板

最后,我把自己常用的 Find 模块模板分享给你。它包含了上面讲的所有要点:

# Find${LIB_NAME}.cmake 模板
# 使用方法:将 LIB_NAME 替换为实际的库名

# 1. 版本检测(可选)
if(NOT ${LIB_NAME}_FIND_VERSION)
  set(${LIB_NAME}_FIND_VERSION "0.0.0")
endif()

# 2. 查找路径
find_path(${LIB_NAME}_INCLUDE_DIR
  NAMES ${LIB_NAME}.h
  PATH_SUFFIXES include
)

find_library(${LIB_NAME}_LIBRARY
  NAMES ${LIB_NAME}
  PATH_SUFFIXES lib
)

# 3. 版本信息(从头文件读取)
if(${LIB_NAME}_INCLUDE_DIR AND EXISTS "${${LIB_NAME}_INCLUDE_DIR}/${LIB_NAME}/version.h")
  file(STRINGS "${${LIB_NAME}_INCLUDE_DIR}/${LIB_NAME}/version.h"
    _ver REGEX "#define ${LIB_NAME}_VERSION"
  )
  if(_ver)
    string(REGEX REPLACE ".*${LIB_NAME}_VERSION \"([0-9.]+)\".*" "\\1"
      ${LIB_NAME}_VERSION "${_ver}"
    )
  endif()
endif()

# 4. 处理结果
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(${LIB_NAME}
  REQUIRED_VARS ${LIB_NAME}_INCLUDE_DIR ${LIB_NAME}_LIBRARY
  VERSION_VAR ${LIB_NAME}_VERSION
)

# 5. 创建导入目标
if(${LIB_NAME}_FOUND AND NOT TARGET ${LIB_NAME}::${LIB_NAME})
  add_library(${LIB_NAME}::${LIB_NAME} UNKNOWN IMPORTED)
  set_target_properties(${LIB_NAME}::${LIB_NAME} PROPERTIES
    IMPORTED_LOCATION "${${LIB_NAME}_LIBRARY}"
    INTERFACE_INCLUDE_DIRECTORIES "${${LIB_NAME}_INCLUDE_DIR}"
  )
endif()

# 6. 设置兼容变量
set(${LIB_NAME}_INCLUDE_DIRS ${${LIB_NAME}_INCLUDE_DIR})
set(${LIB_NAME}_LIBRARIES ${${LIB_NAME}_LIBRARY})

# 7. 清理
mark_as_advanced(${LIB_NAME}_INCLUDE_DIR ${LIB_NAME}_LIBRARY)

把这个模板保存下来,以后遇到需要查找的库,直接复制一份改几个名字就行。我在团队里推广这个模板后,新模块的开发时间从半天缩短到了半小时。

好了,模块化设计这块就聊到这儿。记住一句话:好的 Find 模块,是让使用者感觉不到它的存在。你写好了,别人用起来就爽了。


公众号:蓝海资料掘金营,微信deep3321