cmake之旅(3)

同系列文章:
cmake之旅(1):构建的过程
cmake之旅(2):CMakeLists.txt 核心语法
cmake之旅(3):多目录项目管理
cmake之旅(4):静态库与动态库

多目录项目管理

上一篇我们学习了 CMakeLists.txt 的核心语法,已经可以为简单项目编写构建文件了。但我们也在结尾留下了一个问题:所有源文件都平铺在一个 CMakeLists.txt 中,项目一旦变大就难以管理。

现实中的项目几乎不可能只有一个目录。想象一下,你正在开发一个计算器程序,它有加法模块、减法模块、乘法模块、除法模块,还有一个主程序。如果把所有东西都塞在一个 CMakeLists.txt 里面,随着模块数量的增加,这个文件会越来越臃肿,改一个模块还得翻遍整个文件,协作时也容易发生冲突。

这一篇我们就来解决这个问题——让每个模块拥有自己的 CMakeLists.txt,由顶层统一管理。

1 从一个"臃肿"的项目开始

先回顾一下上一篇结尾的项目结构:

├── add
│   ├── add.cpp
│   └── add.h
├── de
│   ├── de.cpp
│   └── de.h
├── main.cpp
└── CMakeLists.txt

上一篇的 CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(HelloCMake VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

set(SOURCES
    main.cpp
    add/add.cpp
    de/de.cpp
)

include_directories(add de)

add_executable(${PROJECT_NAME} ${SOURCES})

现在问题来了: 如果我再增加乘法模块 mul、除法模块 div、甚至更多的模块呢?每增加一个模块,我都要回到这个顶层 CMakeLists.txt 里面改三个地方——set(SOURCES ...) 中增加源文件、include_directories(...) 中增加路径。模块多了以后,这个文件会变成一个又长又脆弱的"大杂烩"。

更重要的是,如果 add 模块的开发者和 de 模块的开发者同时修改了这个 CMakeLists.txt,还容易产生冲突。

我们需要一种方式,让每个模块"管好自己的事",然后由顶层把它们组装起来。

2 add_subdirectory —— 添加子目录

add_subdirectory 是解决这个问题的关键命令。它的作用是:告诉 CMake “去这个子目录里面,执行那里的 CMakeLists.txt”。

语法很简单:

add_subdirectory(子目录名)

CMake 执行到这行时,会进入指定的子目录,找到其中的 CMakeLists.txt 并执行它,执行完毕后再回到当前目录继续执行后面的内容。你可以把它类比为 C++ 中的函数调用——调用子目录的"构建逻辑",执行完再返回。

3 改造项目结构

3.1 目标结构

我们要把项目改造成这样:

├── CMakeLists.txt          # 顶层 CMakeLists.txt(总指挥)
├── main.cpp
├── add
│   ├── CMakeLists.txt      # add 模块自己的 CMakeLists.txt
│   ├── add.cpp
│   └── add.h
└── de
    ├── CMakeLists.txt       # de 模块自己的 CMakeLists.txt
    ├── de.cpp
    └── de.h

每个模块目录下都有自己的 CMakeLists.txt,各管各的。

3.2 子目录的 CMakeLists.txt

先来看 add 模块的 CMakeLists.txt,它只需要做一件事:把自己编译成一个库。

add/CMakeLists.txt:

# 将 add 模块编译为静态库
add_library(add_lib add.cpp)

add_library 命令我们在后面的文章会详细讲解,这里你只需要知道:它把 add.cpp 编译成了一个叫 add_lib 的静态库(类似于把源文件打包成一个 .a 文件),供其他目标使用。

为什么库名叫 add_lib 而不是 add 因为 add 是 CMake 的保留关键字(用于数学运算),直接用 add 作为目标名会产生冲突。所以加个后缀区分一下。

de/CMakeLists.txt:

# 将 de 模块编译为静态库
add_library(de_lib de.cpp)

就这么简单。每个子目录只需要关心"把自己编译成什么",不需要关心其他模块的事情。

3.3 顶层 CMakeLists.txt

顶层 CMakeLists.txt 的职责是:添加子目录、生成可执行文件、把各模块链接在一起。

CMakeLists.txt:

# 设定 CMake 的最低版本要求
cmake_minimum_required(VERSION 3.10)

# 定义项目信息
project(HelloCMake VERSION 1.0.0 LANGUAGES CXX)

# 设定 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 添加子目录(CMake 会进入这些目录执行它们的 CMakeLists.txt)
add_subdirectory(add)
add_subdirectory(de)

# 生成可执行文件(只需要 main.cpp,模块的源文件由各自的库负责)
add_executable(${PROJECT_NAME} main.cpp)

# 指定头文件搜索路径
target_include_directories(${PROJECT_NAME} PRIVATE add de)

# 将模块的库链接到可执行文件
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)

注意看,这里出现了两个新面孔:target_include_directoriestarget_link_libraries。我们马上来讲解。

target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)

这条命令的含义是:add_libde_lib 这两个库链接到 HelloCMake 这个可执行文件上。

回忆一下上一篇手动构建时的链接步骤 g++ main.o add/add.o de/de.o -o maintarget_link_libraries 做的就是类似的事情,只不过现在 CMake 帮我们自动管理了。

中间那个 PRIVATE 是什么意思?先别急,看完下一节就明白了。

5 target_include_directories 与 include_directories 的区别

上一篇我们用的是 include_directories

include_directories(add de)

这一篇我们换成了 target_include_directories

target_include_directories(${PROJECT_NAME} PRIVATE add de)

它们的区别在哪?

5.1 include_directories —— 全局生效

include_directories 是一个"全局"命令。它一旦执行,当前 CMakeLists.txt 中后续定义的所有目标都会受影响。

include_directories(add de)

# target_a 能找到 add/ 和 de/ 下的头文件
add_executable(target_a a.cpp)

# target_b 也能找到,即使它根本不需要
add_executable(target_b b.cpp)

问题是什么? 假设 target_b 根本不依赖 add 和 de 模块,它也被"污染"了——编译器会多出不必要的搜索路径。在小项目中这无关紧要,但在大项目中会造成混乱:你根本分不清每个目标真正依赖了哪些头文件。

5.2 target_include_directories —— 精准控制

target_include_directories 只对指定的目标生效:

# 只有 target_a 能找到 add/ 和 de/ 下的头文件
target_include_directories(target_a PRIVATE add de)

# target_b 不受影响
add_executable(target_b b.cpp)

这样每个目标的依赖关系一目了然,互不干扰。

5.3 PRIVATE、PUBLIC、INTERFACE 是什么?

你可能已经注意到,target_include_directoriestarget_link_libraries 后面都跟了一个 PRIVATE。这是 CMake 的"可见性"关键字,用来控制依赖的传播范围。

我们用一个生活中的例子来理解:

假设你在做一道菜(目标 A),你用到了酱油(依赖 B)。

  • PRIVATE(私有):酱油只是你做菜时用的,吃你这道菜的人不需要知道你用了酱油,也不需要自己准备酱油。依赖不会传播给使用者。
  • PUBLIC(公开):酱油不仅做菜时用了,而且这道菜端上桌后还要蘸酱油吃。依赖既自己用,也传播给使用者。
  • INTERFACE(接口):你做菜时不需要酱油,但吃这道菜的人需要自己蘸酱油。依赖不自己用,只传播给使用者。

翻译成 CMake 的语言:

关键字 自己编译时用 链接自己的目标也能用
PRIVATE
PUBLIC
INTERFACE

举个具体的例子:

# add_lib 的头文件路径设为 PUBLIC
target_include_directories(add_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# 那么任何链接了 add_lib 的目标,都自动获得 add/ 目录的头文件搜索路径
target_link_libraries(main PRIVATE add_lib)
# main 不需要再单独写 target_include_directories 来找 add.h 了!

目前你可能觉得 PRIVATE 和 PUBLIC 差别不大。 确实,在当前这个小项目中感受不明显。但在后续的文章中,当我们开始构建库给别人使用时,这三个关键字的区别就至关重要了。现在只需要记住:如果不确定用哪个,先用 PRIVATE 就好

6 用 PUBLIC 简化顶层 CMakeLists.txt

理解了 PUBLIC 之后,我们可以让子目录的 CMakeLists.txt “更加自治”——把头文件路径的管理也交给子目录自己。

改造后的 add/CMakeLists.txt:

# 将 add 模块编译为静态库
add_library(add_lib add.cpp)

# 将当前目录设为 PUBLIC 头文件路径
# 这样任何链接了 add_lib 的目标都能自动找到 add.h
target_include_directories(add_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

${CMAKE_CURRENT_SOURCE_DIR} 是 CMake 内置变量,表示当前 CMakeLists.txt 所在的目录。在这里就是 add/ 目录。

改造后的 de/CMakeLists.txt:

# 将 de 模块编译为静态库
add_library(de_lib de.cpp)

# 将当前目录设为 PUBLIC 头文件路径
target_include_directories(de_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

改造后的顶层 CMakeLists.txt:

# 设定 CMake 的最低版本要求
cmake_minimum_required(VERSION 3.10)

# 定义项目信息
project(HelloCMake VERSION 1.0.0 LANGUAGES CXX)

# 设定 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 添加子目录
add_subdirectory(add)
add_subdirectory(de)

# 生成可执行文件
add_executable(${PROJECT_NAME} main.cpp)

# 链接模块库(头文件路径会通过 PUBLIC 自动传播,不需要手动写了)
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)

对比一下之前的版本,顶层不再需要 target_include_directories 了。因为 add_lib 和 de_lib 已经把自己的头文件路径通过 PUBLIC 暴露出去了,链接它们的目标会自动获得这些路径。

这就是"每个模块管好自己"的体现。 以后新增模块时,只需要在新模块目录下写好自己的 CMakeLists.txt,然后在顶层加一行 add_subdirectory 和一个链接即可,不需要到处改路径。

7 变量的作用域

使用 add_subdirectory 之后,会涉及到一个重要的概念——变量的作用域

CMake 中的变量遵循"父对子可见,子对父不可见"的规则。

# 顶层 CMakeLists.txt
set(MY_VAR "我是顶层变量")
add_subdirectory(add)
message(STATUS "顶层读取子目录变量: ${CHILD_VAR}")  # 空的!读不到
# add/CMakeLists.txt
message(STATUS "子目录读取顶层变量: ${MY_VAR}")  # 可以读到
set(CHILD_VAR "我是子目录变量")

执行后输出:

-- 子目录读取顶层变量: 我是顶层变量
-- 顶层读取子目录变量:

为什么子目录的变量在顶层读不到? 因为 add_subdirectory 会创建一个新的作用域(类似于 C++ 中的函数作用域),子目录中定义的变量在离开子目录后就"消失"了。

如果确实需要子目录向父目录传递变量呢? 可以使用 PARENT_SCOPE

# add/CMakeLists.txt
set(CHILD_VAR "我是子目录变量" PARENT_SCOPE)

加上 PARENT_SCOPE 后,这个变量会被设置到父目录的作用域中,父目录就能读到了。

不过要注意一个容易踩的坑: 使用 PARENT_SCOPE 设置变量后,当前作用域中这个变量的值并不会改变

# add/CMakeLists.txt
set(CHILD_VAR "hello" PARENT_SCOPE)
message(STATUS "子目录自己读: ${CHILD_VAR}")  # 空的!PARENT_SCOPE 只设置父作用域

如果子目录自己也需要这个变量,需要额外设置一次:

# add/CMakeLists.txt
set(CHILD_VAR "hello")                     # 设置当前作用域
set(CHILD_VAR "hello" PARENT_SCOPE)         # 设置父作用域

建议: 尽量避免子目录向父目录传递变量,这会增加耦合。更好的做法是通过 target 的属性(如 PUBLIC 的头文件路径、链接库等)来传递信息,这正是"Modern CMake"所推崇的方式。

8 更规范的项目结构

前面的例子中,头文件和源文件混在同一个目录下。在实际项目中,更常见的做法是将头文件和源文件分开存放,形成 includesrc 的分离结构。

├── CMakeLists.txt
├── include
│   └── calc
│       ├── add.h
│       └── de.h
├── src
│   ├── CMakeLists.txt
│   ├── add.cpp
│   ├── de.cpp
│   └── main.cpp
└── README.md

为什么要这样组织?

  1. 头文件集中在 include/ 目录下,如果将来要把这个项目作为库给别人用,直接把 include/ 目录提供出去就行
  2. 源文件集中在 src/ 目录下,内部实现和对外接口分离
  3. include/calc/ 多了一层以项目名命名的子目录,这样使用时写 #include "calc/add.h",不容易和其他项目的头文件冲突

先调整一下源文件:

include/calc/add.h:

#ifndef CALC_ADD_H
#define CALC_ADD_H
int add(int a, int b);
#endif

include/calc/de.h:

#ifndef CALC_DE_H
#define CALC_DE_H
int de(int a, int b);
#endif

src/add.cpp:

#include "calc/add.h"
int add(int a, int b)
{
    return a + b;
}

src/de.cpp:

#include "calc/de.h"
int de(int a, int b)
{
    return b - a;
}

src/main.cpp:

#include <iostream>
#include "calc/add.h"
#include "calc/de.h"

int main()
{
    std::cout << "10 + 2 = " << add(10, 2) << std::endl;
    std::cout << "de(2, 10) = " << de(2, 10) << std::endl;
    return 0;
}

注意看,现在 #include 的路径变成了 "calc/add.h" 而不是之前的 "add.h",这样更加规范,也不容易和其他库冲突。

再来写 CMakeLists.txt:

src/CMakeLists.txt:

# 定义源文件列表
set(LIB_SOURCES
    add.cpp
    de.cpp
)

# 将计算模块编译为静态库
add_library(calc_lib ${LIB_SOURCES})

# 设置头文件路径(PUBLIC:链接此库的目标也能找到这些头文件)
target_include_directories(calc_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

# 生成可执行文件
add_executable(${PROJECT_NAME} main.cpp)

# 链接计算库
target_link_libraries(${PROJECT_NAME} PRIVATE calc_lib)

这里用了 ${PROJECT_SOURCE_DIR} 而不是 ${CMAKE_CURRENT_SOURCE_DIR},因为 include 目录在项目根目录下,而当前 CMakeLists.txt 在 src/ 目录下。PROJECT_SOURCE_DIR 始终指向顶层项目目录。

顶层 CMakeLists.txt:

# 设定 CMake 的最低版本要求
cmake_minimum_required(VERSION 3.10)

# 定义项目信息
project(Calculator VERSION 1.0.0 LANGUAGES CXX)

# 设定 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 添加 src 子目录
add_subdirectory(src)

看到了吗?顶层 CMakeLists.txt 变得非常干净,只做两件事:定义项目信息和添加子目录。所有的构建细节都在子目录中完成。

构建和运行:

mkdir build
cd build
cmake ..
make
./Calculator

输出:

10 + 2 = 12
de(2, 10) = 8

9 再扩展:多个子模块各自独立

随着项目进一步增长,你可能会希望每个模块都有自己独立的目录和 CMakeLists.txt。我们来做一个更贴近真实项目的结构:

├── CMakeLists.txt
├── include
│   └── calc
│       ├── add.h
│       └── de.h
├── src
│   ├── CMakeLists.txt
│   ├── add
│   │   ├── CMakeLists.txt
│   │   └── add.cpp
│   ├── de
│   │   ├── CMakeLists.txt
│   │   └── de.cpp
│   └── main.cpp
└── README.md

src/add/CMakeLists.txt:

# 将 add 模块编译为静态库
add_library(add_lib add.cpp)

# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

src/de/CMakeLists.txt:

# 将 de 模块编译为静态库
add_library(de_lib de.cpp)

# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)

src/CMakeLists.txt:

# 添加各模块子目录
add_subdirectory(add)
add_subdirectory(de)

# 生成可执行文件
add_executable(${PROJECT_NAME} main.cpp)

# 链接所有模块
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)

顶层 CMakeLists.txt 保持不变:

cmake_minimum_required(VERSION 3.10)
project(Calculator VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

add_subdirectory(src)

这样形成了三层结构:顶层负责项目配置,src 负责组装,各模块负责自己的编译。以后增加新模块(比如乘法模块 mul),只需要三步:

  1. 创建 src/mul/ 目录,写好源文件和 CMakeLists.txt
  2. src/CMakeLists.txt 中加一行 add_subdirectory(mul)
  3. target_link_libraries 中加上 mul_lib

不需要改动顶层 CMakeLists.txt,也不需要改动其他模块,每个模块的独立性得到了很好的保证。

10 常见问题与注意事项

add_subdirectory 的顺序重要吗?

一般来说,add_subdirectory 的顺序不影响最终结果,因为 CMake 会自动解析目标之间的依赖关系。但有一种例外:如果你在子目录中使用了 PARENT_SCOPE 向上传递变量,那么 add_subdirectory 的顺序就决定了变量何时可用。所以建议不要依赖 add_subdirectory 的执行顺序,如果非要传递信息,优先通过 target 属性来实现。

add_subdirectory 能否添加项目外部的目录?

可以,但需要指定第二个参数作为构建输出目录:

# 第一个参数:源码目录(可以是绝对路径)
# 第二个参数:构建输出目录(必须指定,因为外部目录无法自动推导)
add_subdirectory(/path/to/external/module external_module_build)

不过这种用法不太常见,一般建议把依赖代码放到项目内部管理。

子目录中可以再嵌套 add_subdirectory 吗?

当然可以。我们在第 9 节的例子中已经演示了这一点:顶层添加了 src,src 中又添加了 add 和 de。CMake 的子目录可以无限嵌套,形成树状结构。但层级不宜过深,一般 2-3 层就够了,太深会增加理解成本。

11 本篇命令速查表

命令 作用 示例
add_subdirectory 添加子目录并执行其 CMakeLists.txt add_subdirectory(src)
add_library 将源文件编译为库 add_library(mylib src.cpp)
target_link_libraries 为目标链接库 target_link_libraries(app PRIVATE mylib)
target_include_directories 为目标指定头文件搜索路径 target_include_directories(mylib PUBLIC include/)
include_directories 全局指定头文件搜索路径(不推荐) include_directories(include/)

相关变量:

变量 含义
CMAKE_CURRENT_SOURCE_DIR 当前 CMakeLists.txt 所在的源码目录
CMAKE_CURRENT_BINARY_DIR 当前 CMakeLists.txt 对应的构建目录
PROJECT_SOURCE_DIR 最近一次 project() 命令所在的源码目录
CMAKE_SOURCE_DIR 顶层 CMakeLists.txt 所在的源码目录

小提示: PROJECT_SOURCE_DIRCMAKE_SOURCE_DIR 在大多数情况下是相同的,但如果你的项目被另一个项目作为子目录引入(通过 add_subdirectory),CMAKE_SOURCE_DIR 会指向最外层的顶层目录,而 PROJECT_SOURCE_DIR 指向你自己项目的顶层目录。所以在编写可能被复用的模块时,优先使用 PROJECT_SOURCE_DIR

12 总结与下一篇预告

这一篇我们学会了用 add_subdirectory 组织多目录项目,理解了 target_include_directoriesinclude_directories 的区别,认识了 PRIVATE、PUBLIC、INTERFACE 三个可见性关键字,还了解了变量的作用域规则。

我们在子目录的 CMakeLists.txt 中用到了 add_library 来把源文件编译成库。但目前我们只是简单地使用了一下这个命令,并没有深入探讨:静态库和动态库到底有什么区别?什么时候该用静态库,什么时候该用动态库?如何控制库的输出路径和版本号?

下一篇——cmake之旅(4):静态库与动态库,我们来彻底搞清楚这些问题。

posted @ 2026-04-09 09:00  m晴朗  阅读(36)  评论(0)    收藏  举报