模块化设计:编写自己的CMake模块
说实话,我刚开始用CMake的时候,总觉得把所有东西塞进一个CMakeLists.txt里挺爽的。直到项目越来越大,那个文件膨胀到上千行,每次找某个函数定义都要翻半天……嗯,那感觉就像在乱糟糟的抽屉里找钥匙。
后来我学乖了。模块化设计,说白了就是把你的构建逻辑拆成可复用的零件。今天我们就聊聊怎么写自己的CMake模块,以及那些绕不开的include()、函数和宏。
为什么需要模块化?
你想想看,一个项目里可能有多个子目录,每个子目录都要找第三方库、设置编译选项、定义测试。如果每个地方都复制粘贴同一段代码,那维护起来就是噩梦。我在一个嵌入式项目里就吃过这个亏——改了库的路径,结果漏掉了三个子目录的CMakeLists.txt,编译直接崩了。
模块化能帮你做到三件事:
- 复用:写一次,到处用
- 隔离:每个模块只关心自己的事
- 测试:模块可以单独验证
include() 与模块路径
CMake里加载模块用的是 include() 命令。它的语法很简单:
include(<module-name> [OPTIONAL] [RESULT_VARIABLE <var>])
但这里有个坑——CMake去哪里找你的模块?它会在 CMAKE_MODULE_PATH 这个变量指定的目录列表里搜索。如果找不到,就会报错。
我个人习惯在项目根目录的CMakeLists.txt里这样设置:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
然后在 cmake/ 目录下放一堆 .cmake 文件。比如 cmake/FindMyLib.cmake、cmake/Utils.cmake。这样项目结构就清晰多了。
.cmake 结尾。调用时不用写后缀,CMake会自动补全。比如 include(Utils) 会加载 cmake/Utils.cmake。
函数与宏:function vs macro
这是新手最容易搞混的地方。函数和宏都能封装逻辑,但行为完全不同。
| 特性 | function | macro |
|---|---|---|
| 作用域 | 创建新作用域 | 在调用者作用域内展开 |
| 变量修改 | 默认不影响外部变量 | 直接修改调用者变量 |
| 返回值 | 通过 set(PARENT_SCOPE) 返回 | 直接赋值即可 |
| 性能 | 略慢(有函数调用开销) | 快(文本替换) |
举个例子你就明白了。假设我们写一个模块 cmake/Utils.cmake:
# 函数版本
function(add_my_library target)
add_library(${target} STATIC ${ARGN})
target_include_directories(${target} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
message(STATUS "Created library: ${target}")
endfunction()
# 宏版本
macro(add_my_library_macro target)
add_library(${target} STATIC ${ARGN})
target_include_directories(${target} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
message(STATUS "Created library: ${target}")
endmacro()
调用时:
include(Utils)
add_my_library(mylib src/a.cpp src/b.cpp)
# 函数内部修改的变量不会影响外部
add_my_library_macro(mylib2 src/c.cpp src/d.cpp)
# 宏内部修改的变量会泄漏到外部
我在一个大型项目中就踩过宏的坑。当时在宏里定义了一个临时变量 _tmp,结果不小心覆盖了调用者同名的变量,排查了半天。从那以后,我默认都用函数,除非真的需要宏那种“直接修改外部变量”的特性。
${ARGN} 是文本替换,不是函数参数。如果你在宏里写 foreach(arg ${ARGN}),当 ARGN 包含列表时可能会展开成多个参数。我曾经因为这个在循环里多跑了几次,血的教训。
实战:写一个Find模块
假设我们要写一个查找 libfoo 库的模块 cmake/FindFoo.cmake:
# 查找头文件
find_path(FOO_INCLUDE_DIR
NAMES foo.h
PATHS /usr/include /usr/local/include
)
# 查找库文件
find_library(FOO_LIBRARY
NAMES foo
PATHS /usr/lib /usr/local/lib
)
# 处理找到/未找到
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(Foo
REQUIRED_VARS FOO_LIBRARY FOO_INCLUDE_DIR
)
# 创建导入目标
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_package(Foo REQUIRED)
target_link_libraries(myapp Foo::Foo)
这样写的好处是:如果系统里没有 libfoo,CMake会在配置阶段就报错,而不是等到链接时。我习惯把这种模块放在 cmake/ 目录下,用 include() 或 find_package() 加载。
模块化设计的核心逻辑
下面这张图展示了模块化设计的整体流程:
从这张图可以看出,模块化设计的核心就是:把可复用的逻辑从CMakeLists.txt中抽离出来,放到独立的.cmake文件中。然后通过 include() 或 find_package() 按需加载。
一些实用建议
- 命名规范:模块名用大驼峰,比如
FindBoost.cmake、Utils.cmake。变量名用全大写加下划线,比如MY_PROJECT_VERSION。 - 文档注释:每个模块开头写清楚用途、参数、返回值。我习惯用
#写多行注释,方便后来人(包括未来的自己)理解。 - 版本检查:如果模块依赖特定CMake版本,用
cmake_minimum_required()检查。我在一个老旧项目里遇到过CMake 2.8不支持某些函数的问题。 - 测试模块:单独写一个测试用的CMakeLists.txt,用
include()加载模块,验证功能正常。我一般放在tests/cmake/目录下。
CMAKE_MODULE_PATH 控制,别写死路径。
好了,模块化设计这块就聊这么多。记住一句话:好的构建系统,就像好的代码,读起来是一种享受。下次你打开一个CMakeLists.txt,如果一眼能看出它在干什么,那模块化就做到位了。