第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 知识体系图
下面这张图,是我自己梳理的目录管理核心逻辑,你看一眼就能明白整体脉络:
8.6 几个实用技巧
最后分享几个我常用的技巧:
- 用
GLOB要谨慎:file(GLOB ...)虽然方便,但CMake不会自动检测新增文件。我一般只在子目录里用,并且配合CMAKE_CONFIGURE_DEPENDS:
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/*.c
)
- 嵌套子目录的变量传递:如果子目录里还有子目录,记得每一层都要用
PARENT_SCOPE。我习惯在中间层再包一层:
# middleware/CMakeLists.txt
add_subdirectory(freertos)
# freertos里的变量已经通过PARENT_SCOPE传到了这里
# 再往上传递一层
set(MIDDLEWARE_SRCS ${FREERTOS_SRCS} PARENT_SCOPE)
- 用
EXCLUDE_FROM_ALL控制测试代码:测试目录不需要参与主构建,加上这个参数:
add_subdirectory(tests EXCLUDE_FROM_ALL)
这样,只有当你显式构建test目标时,测试代码才会编译。平时构建主项目,测试目录直接被跳过,省时间。
核心要点回顾:
add_subdirectory()是CMake组织多目录项目的核心指令- 子CMakeLists.txt负责收集源文件和设置编译选项,通过
PARENT_SCOPE或add_library向上传递 - 构建层级管理要遵循「每层只做一件事」原则,避免循环依赖
- 新项目推荐用
add_library+target_link_libraries,比变量传递更清晰
嗯,目录管理这块其实不难,关键是养成好习惯。我见过太多项目,一开始图省事把所有代码塞一个目录,后期重构时痛不欲生。记住:好的目录结构,是项目长期健康的基石。
公众号:蓝海资料掘金营,微信deep3321