第18章:CMake实战:嵌入式C项目:使用CMake构建多目录嵌入式项目,add_subdirectory的使用,目标间依赖管理
说实话,很多嵌入式工程师一开始接触CMake时,都觉得它是个“大炮打蚊子”的工具。我当年也是这么想的——一个单片机项目,总共就几个.c文件,Makefile几行就搞定了,何必折腾CMake?
直到我接手了一个真正的“多目录”项目:驱动层、中间件、应用层、测试代码……目录结构七八层,文件上百个。Makefile维护起来简直是一场噩梦。每次加个文件,都要手动改Makefile,漏掉一个就链接失败。更别提不同芯片要切换编译器、不同板子要配置不同引脚……
嗯,从那时起,我就彻底倒向了CMake。今天这一章,我们就来聊聊CMake在嵌入式C项目中最核心的实战技巧:多目录管理和目标依赖。
18.1 为什么嵌入式项目需要多目录结构?
你想想看,一个正经的嵌入式项目,通常包含这些模块:
- 芯片驱动层:GPIO、UART、SPI、I2C……
- 板级支持包(BSP):板子上的外设初始化、时钟配置
- 中间件:RTOS、文件系统、协议栈
- 应用层:业务逻辑、状态机、通信协议
- 测试代码:单元测试、硬件测试
如果所有文件都堆在一个目录里,用不了多久,你就会发现:
- 找文件靠翻,改文件靠猜
- 不同模块的全局变量互相污染
- 想单独测试某个模块?得把整个工程编译一遍
所以,按功能模块分目录是嵌入式项目的标配。而CMake的add_subdirectory命令,就是用来优雅地管理这种多目录结构的。
18.2 add_subdirectory:把子目录“拉”进来
add_subdirectory的作用很简单:告诉CMake,去某个子目录里再执行一个CMakeLists.txt。这样,每个子目录都可以有自己的构建规则,互不干扰。
我个人的习惯是:每个子目录都放一个CMakeLists.txt,里面只定义这个目录自己的目标(库或可执行文件)。顶层CMakeLists.txt只负责“组装”这些子目标。
来看一个典型的嵌入式项目结构:
project_root/
├── CMakeLists.txt # 顶层
├── drivers/
│ ├── CMakeLists.txt # 驱动层
│ ├── gpio.c
│ ├── gpio.h
│ ├── uart.c
│ └── uart.h
├── bsp/
│ ├── CMakeLists.txt # 板级支持包
│ ├── board_init.c
│ └── board_init.h
├── app/
│ ├── CMakeLists.txt # 应用层
│ ├── main.c
│ └── task_manager.c
└── startup/
├── CMakeLists.txt # 启动文件
└── startup_stm32f4.s
顶层CMakeLists.txt这样写:
cmake_minimum_required(VERSION 3.16)
project(embedded_project C ASM)
# 设置芯片型号,后面会用到
set(MCU "STM32F407VG")
# 添加各个子目录
add_subdirectory(drivers)
add_subdirectory(bsp)
add_subdirectory(app)
add_subdirectory(startup)
# 最终生成可执行文件
add_executable(${PROJECT_NAME}.elf)
# 把所有子目录的库链接进来
target_link_libraries(${PROJECT_NAME}.elf
PRIVATE
drivers_lib
bsp_lib
app_lib
startup_lib
)
每个子目录的CMakeLists.txt只关心自己的事。比如drivers/CMakeLists.txt:
# 收集当前目录下的所有源文件
file(GLOB SOURCES "*.c" "*.h")
# 生成一个静态库
add_library(drivers_lib STATIC ${SOURCES})
# 指定头文件路径,供其他模块使用
target_include_directories(drivers_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
file(GLOB ...)收集源文件很方便,但要注意——CMake不会自动检测新加的文件。如果你在目录里新增了一个.c文件,需要重新运行cmake命令才能生效。我一般会在开发阶段用GLOB,发布前改成手动列举,确保构建的可重复性。
18.3 目标间依赖管理:谁依赖谁?
多目录结构建好了,接下来就是依赖管理。说白了,就是告诉CMake:编译这个模块之前,必须先编译好那个模块。
嵌入式项目里,依赖关系通常是这样:
- 应用层依赖BSP和驱动层
- BSP依赖驱动层
- 启动文件不依赖任何模块,但所有模块都依赖它(因为要链接启动代码)
CMake通过target_link_libraries和target_include_directories的PUBLIC/PRIVATE/INTERFACE关键字来管理依赖。
举个例子,bsp/CMakeLists.txt:
add_library(bsp_lib STATIC board_init.c)
# BSP需要用到驱动层的头文件,所以链接drivers_lib
target_link_libraries(bsp_lib
PUBLIC
drivers_lib # 公开依赖:谁链接bsp_lib,也会自动链接drivers_lib
)
target_include_directories(bsp_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
再看app/CMakeLists.txt:
add_library(app_lib STATIC main.c task_manager.c)
# 应用层需要BSP和驱动层
target_link_libraries(app_lib
PUBLIC
bsp_lib # 间接依赖drivers_lib(通过bsp_lib传递)
)
target_include_directories(app_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
这样,顶层只需要链接app_lib,CMake会自动把bsp_lib和drivers_lib也链接进来。这就是依赖传递的魅力。
PUBLIC:当前目标需要,链接当前目标的其他目标也需要PRIVATE:只有当前目标需要,不对外传递INTERFACE:当前目标不需要,但链接当前目标的其他目标需要(常用于纯头文件库)
18.4 实战:一个完整的嵌入式项目CMake配置
下面我给出一个完整的、可直接运行的嵌入式项目CMake配置。这个项目模拟了一个STM32F4的开发板,包含驱动、BSP、应用和启动文件。
顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(stm32_demo C ASM)
# 设置编译选项
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# 添加子目录
add_subdirectory(drivers)
add_subdirectory(bsp)
add_subdirectory(app)
add_subdirectory(startup)
# 生成最终的可执行文件
add_executable(${PROJECT_NAME}.elf)
# 链接所有子库
target_link_libraries(${PROJECT_NAME}.elf
PRIVATE
app_lib
startup_lib
)
# 设置链接脚本
target_link_options(${PROJECT_NAME}.elf
PRIVATE
-T ${CMAKE_SOURCE_DIR}/linker/stm32f4.ld
)
drivers/CMakeLists.txt
file(GLOB DRV_SOURCES "*.c")
add_library(drivers_lib STATIC ${DRV_SOURCES})
target_include_directories(drivers_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
# 驱动层可能需要硬件寄存器定义
target_include_directories(drivers_lib
PRIVATE
${CMAKE_SOURCE_DIR}/include
)
bsp/CMakeLists.txt
add_library(bsp_lib STATIC board_init.c clock_config.c)
target_link_libraries(bsp_lib
PUBLIC
drivers_lib
)
target_include_directories(bsp_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
app/CMakeLists.txt
add_library(app_lib STATIC main.c task_manager.c)
target_link_libraries(app_lib
PUBLIC
bsp_lib
)
target_include_directories(app_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
startup/CMakeLists.txt
# 汇编启动文件不需要编译,直接添加
add_library(startup_lib STATIC startup_stm32f4.s)
# 启动文件通常需要链接器脚本,但这里只做库
target_include_directories(startup_lib
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}
)
18.5 依赖管理的可视化
为了让你更直观地理解这个依赖关系,我画了一张图:
从这张图可以清楚地看到:
- stm32_demo.elf直接链接app_lib和startup_lib
- app_lib通过
PUBLIC依赖bsp_lib,而bsp_lib又依赖drivers_lib - 最终,stm32_demo.elf会间接链接所有四个库
18.6 避坑指南:我踩过的那些坑
讲完了理论,我来分享几个实战中容易踩的坑:
我曾经在一个项目中,让
app_lib依赖bsp_lib,而bsp_lib又反过来依赖app_lib。CMake会直接报错,告诉你存在循环依赖。解决办法是重新设计模块划分,或者把公共部分提取成一个新的库。
有时候编译通过了,但链接时报“未定义引用”。这通常是因为某个库的头文件路径没有正确传递。我建议在
target_include_directories中尽量使用PUBLIC,除非你明确知道这个头文件只给当前模块用。
在GCC中,静态库的链接顺序很重要。如果
app_lib依赖bsp_lib,那么app_lib必须出现在bsp_lib之前。CMake的target_link_libraries会自动处理这个顺序,但如果你手动写链接命令,就很容易出错。
18.7 小结
好了,这一章的内容就到这里。我们聊了:
- 为什么嵌入式项目需要多目录结构
- 如何使用
add_subdirectory管理子目录 - 如何通过
target_link_libraries和PUBLIC/PRIVATE管理依赖 - 一个完整的实战案例
- 我踩过的几个坑
说实话,CMake的多目录管理并不复杂,但一旦用好了,项目的可维护性会提升一个档次。我个人现在接手任何嵌入式项目,第一件事就是先把目录结构和CMake搭好——磨刀不误砍柴工嘛。
如果你在实际项目中遇到了CMake相关的问题,欢迎随时交流。记住,构建系统也是代码的一部分,值得你认真对待。