第8章:目录与子项目:add_subdirectory()、子CMakeLists.txt、构建层级管理

说到嵌入式项目的CMake构建,我敢说,目录管理是新手最容易翻车的地方之一。你想想看,一个稍微像样点的嵌入式项目,少说也有几十个源文件,驱动层、中间件、应用层、测试代码……全堆在一个CMakeLists.txt里?那画面太美我不敢看。

我个人习惯,从第一个项目开始就用add_subdirectory()把代码拆开。这玩意儿说白了就是CMake的「文件夹跳转」指令——你告诉CMake:嘿,去这个子目录里,执行它自己的CMakeLists.txt。

8.1 为什么需要子目录?

我见过不少团队,一个CMakeLists.txt写了上千行,每次改个文件路径都得全局搜索。嗯,这其实不是CMake的问题,是组织方式的问题。

用子目录的好处,我总结三条:

  • 职责分离:每个模块只管自己的事,驱动层不用关心应用层怎么编译
  • 复用性:写好的子模块,拷到另一个项目里,CMakeLists.txt直接带走
  • 构建速度:改了一个文件,只重新编译受影响的部分,不用全量构建

我在一个车载项目中遇到过这种情况:项目初期所有代码都在一个目录下,后来要移植到另一个芯片平台,光是梳理文件依赖就花了两天。从那以后,我坚持每个功能模块一个子目录,每个子目录一个CMakeLists.txt。

8.2 add_subdirectory() 的基本用法

语法很简单:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])

参数说明:

参数含义
source_dir子目录路径,可以是相对路径或绝对路径
binary_dir(可选)构建输出目录,不指定则默认在source_dir下
EXCLUDE_FROM_ALL(可选)排除该子目录的目标,除非被其他目标依赖

举个例子,一个典型的嵌入式项目结构:

project_root/
├── CMakeLists.txt          # 顶层
├── drivers/
│   ├── CMakeLists.txt      # 驱动层
│   ├── uart.c
│   └── gpio.c
├── middleware/
│   ├── CMakeLists.txt      # 中间件
│   └── freertos/
│       ├── CMakeLists.txt  # 嵌套子目录
│       └── tasks.c
└── app/
    ├── CMakeLists.txt      # 应用层
    └── main.c

顶层的CMakeLists.txt里,我一般这样写:

cmake_minimum_required(VERSION 3.16)
project(my_embedded_project C ASM)

# 添加子目录
add_subdirectory(drivers)
add_subdirectory(middleware)
add_subdirectory(app)

# 最终链接成可执行文件
add_executable(${PROJECT_NAME}.elf
    ${DRIVERS_SRCS}
    ${MIDDLEWARE_SRCS}
    ${APP_SRCS}
)
我的小技巧binary_dir参数我很少用,除非有特殊需求。保持source_dir和binary_dir一致,调试时找构建产物更方便。

8.3 子CMakeLists.txt 怎么写?

每个子目录的CMakeLists.txt,核心任务就两个:收集源文件设置编译选项。别把链接逻辑放进来,那是顶层的事。

拿drivers目录举例:

# drivers/CMakeLists.txt
# 收集驱动层源文件
file(GLOB DRIVERS_SRCS
    ${CMAKE_CURRENT_SOURCE_DIR}/*.c
    ${CMAKE_CURRENT_SOURCE_DIR}/*.S
)

# 设置驱动层的头文件路径
target_include_directories(drivers INTERFACE
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# 把源文件列表暴露给父目录
set(DRIVERS_SRCS ${DRIVERS_SRCS} PARENT_SCOPE)

注意最后一行:PARENT_SCOPE。这是个坑,我刚开始用CMake时踩过。CMake的变量作用域是局部的,子目录里定义的变量,父目录看不到。必须用PARENT_SCOPE才能把变量传回去。

我曾经踩过的坑:忘记加PARENT_SCOPE,结果顶层链接时找不到任何源文件,报了一堆undefined reference。排查了半小时才发现是变量没传出来。所以,每次在子目录里定义变量,问自己一句:这个变量父目录需要吗?需要就加PARENT_SCOPE。

8.4 构建层级管理的最佳实践

项目大了,子目录嵌套三四层很常见。我总结了一套管理原则:

  • 每层只做一件事:顶层管链接和全局选项,中间层管模块组合,底层管源文件收集
  • 避免循环依赖:子目录A依赖子目录B,B又依赖A,CMake会报错。我一般用target_link_libraries显式声明依赖关系
  • 用target代替变量:新版本CMake推荐用add_library创建库目标,而不是用变量传递源文件列表。比如:
# drivers/CMakeLists.txt
add_library(drivers STATIC
    uart.c
    gpio.c
)
target_include_directories(drivers PUBLIC include)

然后在顶层直接:

target_link_libraries(my_app.elf PRIVATE drivers middleware app)

这种方式更优雅,依赖关系一目了然。不过要注意,嵌入式项目有时需要控制编译顺序,用add_dependencies可以显式指定。

8.5 知识体系图

下面这张图,是我自己梳理的目录管理核心逻辑,你看一眼就能明白整体脉络:

顶层 CMakeLists.txt add_subdirectory(drivers) add_subdirectory(middleware) add_subdirectory(app) 子CMakeLists.txt 收集源文件 + 设置包含路径 set(XXX_SRCS ... PARENT_SCOPE) 子CMakeLists.txt add_library(middleware STATIC ...) target_include_directories(...) 子CMakeLists.txt 收集应用层源文件 设置编译选项 顶层链接:target_link_libraries(...) 最终生成 .elf / .hex / .bin 图:嵌入式项目CMake目录层级管理示意 每层职责清晰,变量/目标逐级向上传递

8.6 几个实用技巧

最后分享几个我常用的技巧:

  1. GLOB要谨慎file(GLOB ...)虽然方便,但CMake不会自动检测新增文件。我一般只在子目录里用,并且配合CMAKE_CONFIGURE_DEPENDS
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
    ${CMAKE_CURRENT_SOURCE_DIR}/*.c
)
  1. 嵌套子目录的变量传递:如果子目录里还有子目录,记得每一层都要用PARENT_SCOPE。我习惯在中间层再包一层:
# middleware/CMakeLists.txt
add_subdirectory(freertos)
# freertos里的变量已经通过PARENT_SCOPE传到了这里
# 再往上传递一层
set(MIDDLEWARE_SRCS ${FREERTOS_SRCS} PARENT_SCOPE)
  1. EXCLUDE_FROM_ALL控制测试代码:测试目录不需要参与主构建,加上这个参数:
add_subdirectory(tests EXCLUDE_FROM_ALL)

这样,只有当你显式构建test目标时,测试代码才会编译。平时构建主项目,测试目录直接被跳过,省时间。

核心要点回顾

  • add_subdirectory()是CMake组织多目录项目的核心指令
  • 子CMakeLists.txt负责收集源文件和设置编译选项,通过PARENT_SCOPEadd_library向上传递
  • 构建层级管理要遵循「每层只做一件事」原则,避免循环依赖
  • 新项目推荐用add_library + target_link_libraries,比变量传递更清晰

嗯,目录管理这块其实不难,关键是养成好习惯。我见过太多项目,一开始图省事把所有代码塞一个目录,后期重构时痛不欲生。记住:好的目录结构,是项目长期健康的基石


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