17、CMake目标属性:target_include_directories、target_link_libraries、target_compile_definitions、target_compile_options

说实话,CMake 里最核心的概念就是「目标」(target)。

一个目标可以是一个可执行文件,也可以是一个库。而我们要控制这个目标怎么编译、怎么链接,靠的就是那四个核心命令:target_include_directoriestarget_link_librariestarget_compile_definitionstarget_compile_options

我个人习惯把这四个命令称为「目标四件套」。你只要搞懂了它们,CMake 就算入门了。

17.1 为什么是「目标属性」?

在 CMake 的旧时代(2.x 版本),大家用的是 include_directories()link_directories() 这种全局命令。什么意思呢?就是一旦你调用了,整个 CMake 工程里所有目标都会受影响。

这有什么问题?我举个例子你就明白了。

我曾经接手过一个项目,里面有两个库:一个叫 lib_a,一个叫 lib_blib_a 需要链接 OpenSSL,lib_b 不需要。但原来的工程师用了全局的 link_libraries(ssl crypto),结果 lib_b 也被强行链接了 OpenSSL。编译倒是能过,但最终的可执行文件体积大了不少,而且部署时还得带上 OpenSSL 的动态库。

这就是全局命令的坑——污染了所有目标

而目标属性命令,只影响你指定的那个目标。干净、可控、可维护。

核心原则:能用目标属性命令,就别用全局命令。这是现代 CMake 的黄金法则。

17.2 target_include_directories:告诉编译器去哪找头文件

这个命令说白了就是给编译器传递 -I 参数。你想想看,编译器在编译某个源文件时,遇到 #include "xxx.h",它得知道去哪找这个头文件吧?target_include_directories 就是干这个的。

add_library(my_lib STATIC src/my_lib.c)
target_include_directories(my_lib
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

这里有个关键概念:PUBLIC、PRIVATE、INTERFACE

  • PRIVATE:这个目录只对当前目标自己有用。其他链接了 my_lib 的目标,不会自动加上这个头文件路径。
  • PUBLIC:当前目标自己用,而且任何链接了 my_lib 的目标也会继承这个头文件路径。
  • INTERFACE:当前目标自己不用,但链接了它的目标会继承。这个通常用于纯头文件库。

嗯,这里要注意:PUBLIC 和 INTERFACE 的区别。PUBLIC 是「我自己用,你也用」;INTERFACE 是「我自己不用,但你得用」。我见过不少新手把 INTERFACE 和 PUBLIC 搞混,结果编译报错找不到头文件。

我的习惯:对于库的内部实现头文件,一律用 PRIVATE。对外暴露的 API 头文件路径,用 PUBLIC。这样外部使用者只需要 target_link_libraries 就能自动获得正确的头文件路径,省心。

17.3 target_link_libraries:告诉链接器去哪找库

这个命令大家应该最熟悉。它负责传递 -l-L 参数给链接器。

add_executable(my_app main.c)
target_link_libraries(my_app
    PRIVATE
        my_lib
        pthread
)

这里同样有 PUBLIC、PRIVATE、INTERFACE 的概念。但实际项目中,target_link_libraries 的 PRIVATE 用得最多。为什么?因为链接关系通常是「内部实现细节」,不需要暴露给外部。

举个例子:你的库内部用了 pthread 做多线程,但外部使用者根本不需要知道这个细节。所以用 PRIVATE 就对了。

我曾经踩过一个坑:把一个库的链接依赖写成了 PUBLIC,结果所有链接了这个库的可执行文件,都被迫链接了 pthread、dl、rt 等一堆系统库。虽然不影响功能,但编译命令行变得又臭又长,而且万一某个系统库版本不兼容,排查起来非常痛苦。

警告:不要滥用 PUBLIC 链接依赖。只有当你确定外部使用者必须链接某个库才能正常使用你的库时,才用 PUBLIC。否则一律 PRIVATE。

17.4 target_compile_definitions:给编译器传递宏定义

这个命令对应编译器的 -D 参数。用来定义预处理宏。

add_library(my_lib STATIC src/my_lib.c)
target_compile_definitions(my_lib
    PRIVATE
        MY_LIB_BUILDING
    PUBLIC
        MY_LIB_VERSION="1.0.0"
)

这里有个典型场景:

  • PRIVATE 宏:比如 MY_LIB_BUILDING,只在编译库本身时定义。库的源文件里可以用 #ifdef MY_LIB_BUILDING 来控制某些内部符号的导出。
  • PUBLIC 宏:比如版本号 MY_LIB_VERSION,外部使用者也想知道这个库的版本,所以用 PUBLIC 暴露出去。

我个人习惯把「构建时需要的宏」和「接口中需要的宏」分开。构建宏用 PRIVATE,接口宏用 PUBLIC。这样外部使用者不会看到一堆莫名其妙的内部宏。

17.5 target_compile_options:给编译器传递额外参数

这个命令最灵活,也最容易出问题。它直接传递编译选项给编译器。

add_executable(my_app main.c)
target_compile_options(my_app
    PRIVATE
        -Wall -Wextra -Werror
)

为什么说它容易出问题?因为编译选项跟编译器强相关。GCC 的 -Wall 在 Clang 下可能含义不同,在 MSVC 下甚至不识别。

我曾经在跨平台项目里吃过这个亏:在 Linux 上用 GCC 编译好好的,到了 Windows 上用 MSVC,-Wall 直接报错说不认识这个选项。后来我改用 target_compile_options 配合生成器表达式来解决:

target_compile_options(my_app
    PRIVATE
        $<$<C_COMPILER_ID:GNU>:-Wall -Wextra>
        $<$<C_COMPILER_ID:MSVC>:/W4>
)

嗯,生成器表达式是 CMake 的高级特性,后面章节会详细讲。这里先提一嘴,让你知道 target_compile_options 不是随便用的,得考虑编译器兼容性。

建议:能用 target_compile_definitionstarget_include_directories 解决的问题,就别用 target_compile_options。后者是最后的手段,用来传递那些没有专门命令支持的编译选项。

17.6 四个命令的协作关系

这四个命令不是孤立的。它们共同描述了一个目标的完整编译和链接行为。

下面这张图展示了它们之间的关系:

目标 (Target) target_include_directories 头文件路径 (-I) target_link_libraries 链接库 (-l -L) target_compile_definitions 宏定义 (-D) target_compile_options 编译选项 四个命令共同描述一个目标的编译和链接行为

从图中可以看出,这四个命令都指向同一个目标。它们各自负责一个维度:头文件路径、链接库、宏定义、编译选项。组合起来,就构成了一个目标的完整编译配置。

17.7 实战:一个完整的库项目

说了这么多,我们来写一个完整的例子。假设我们要创建一个数学库 math_utils,它对外提供加法函数,内部用到了 pthread 做并行计算。

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MathUtils VERSION 1.0.0 LANGUAGES C)

# 创建库目标
add_library(math_utils
    STATIC
    src/math_utils.c
    src/parallel_impl.c
)

# 头文件路径
target_include_directories(math_utils
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

# 编译宏定义
target_compile_definitions(math_utils
    PRIVATE
        MATH_UTILS_BUILDING
    PUBLIC
        MATH_UTILS_VERSION="${PROJECT_VERSION}"
)

# 编译选项
target_compile_options(math_utils
    PRIVATE
        $<$<C_COMPILER_ID:GNU>:-Wall -Wextra>
)

# 链接库
target_link_libraries(math_utils
    PRIVATE
        pthread
)

# 创建可执行文件测试
add_executable(test_math test/test_math.c)
target_link_libraries(test_math
    PRIVATE
        math_utils
)

你看,test_math 只需要 target_link_libraries 一个命令,就能自动获得:

  • math_utils 的 PUBLIC 头文件路径
  • math_utils 的 PUBLIC 宏定义(版本号)
  • 不需要知道 pthread 的存在(因为是 PRIVATE)

这就是现代 CMake 的魅力——依赖关系自动传递,但只传递该传递的

17.8 避坑指南

最后,分享几个我踩过的坑:

  • 路径写死了:我曾经把 target_include_directories 的路径写成绝对路径,结果换到另一台机器上就编译不过。后来改用 ${CMAKE_CURRENT_SOURCE_DIR} 这种 CMake 变量,问题解决。
  • PUBLIC 滥用:前面说过,PUBLIC 会传递依赖。如果你不确定某个依赖是否需要暴露,先用 PRIVATE。等发现外部使用者确实需要时,再改成 PUBLIC 也不迟。
  • 生成器表达式缺失:在 target_compile_options 里直接写 -Wall,在 Windows 上会报错。养成习惯,跨平台项目一定要用生成器表达式做条件编译。
  • 忘记 BUILD_INTERFACEINSTALL_INTERFACE:这个稍微高级一点。如果你的库会被安装到系统目录,头文件路径在构建时和安装后是不一样的。这时候需要用生成器表达式来区分:$<BUILD_INTERFACE:...>$<INSTALL_INTERFACE:...>。这个后面章节会细讲。

最后提醒:这四个命令的 PRIVATE/PUBLIC/INTERFACE 关键字,是 CMake 3.0 之后才引入的。如果你还在用老版本,建议升级到 3.16 以上。CMake 3.16 是目前最稳妥的版本,兼容性好,功能也够用。

好了,目标属性就讲到这里。这四个命令你只要多用几次,就会形成肌肉记忆。下次写 CMakeLists.txt 时,记得问自己一句:这个配置是该用 PRIVATE 还是 PUBLIC?想清楚再写,能省很多麻烦。


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