第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_librariestarget_include_directoriesPUBLIC/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_libdrivers_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}
)
我曾经踩过的坑:在嵌入式项目中,启动文件(.s文件)和链接脚本(.ld文件)的依赖关系很容易搞错。启动文件必须放在链接顺序的最前面,否则芯片上电后无法正确执行。我建议把启动文件单独作为一个库,并在顶层链接时把它放在第一个位置。

18.5 依赖管理的可视化

为了让你更直观地理解这个依赖关系,我画了一张图:

嵌入式项目CMake依赖关系图 stm32_demo.elf app_lib bsp_lib drivers_lib startup_lib 链接 PUBLIC 传递依赖 PUBLIC 链接(第一顺序) 可执行文件 应用库 BSP库 驱动库 启动库

从这张图可以清楚地看到:

  • stm32_demo.elf直接链接app_libstartup_lib
  • app_lib通过PUBLIC依赖bsp_lib,而bsp_lib又依赖drivers_lib
  • 最终,stm32_demo.elf会间接链接所有四个库

18.6 避坑指南:我踩过的那些坑

讲完了理论,我来分享几个实战中容易踩的坑:

坑1:循环依赖
我曾经在一个项目中,让app_lib依赖bsp_lib,而bsp_lib又反过来依赖app_lib。CMake会直接报错,告诉你存在循环依赖。解决办法是重新设计模块划分,或者把公共部分提取成一个新的库。
坑2:头文件路径遗漏
有时候编译通过了,但链接时报“未定义引用”。这通常是因为某个库的头文件路径没有正确传递。我建议在target_include_directories中尽量使用PUBLIC,除非你明确知道这个头文件只给当前模块用。
坑3:静态库链接顺序
在GCC中,静态库的链接顺序很重要。如果app_lib依赖bsp_lib,那么app_lib必须出现在bsp_lib之前。CMake的target_link_libraries会自动处理这个顺序,但如果你手动写链接命令,就很容易出错。

18.7 小结

好了,这一章的内容就到这里。我们聊了:

  • 为什么嵌入式项目需要多目录结构
  • 如何使用add_subdirectory管理子目录
  • 如何通过target_link_librariesPUBLIC/PRIVATE管理依赖
  • 一个完整的实战案例
  • 我踩过的几个坑

说实话,CMake的多目录管理并不复杂,但一旦用好了,项目的可维护性会提升一个档次。我个人现在接手任何嵌入式项目,第一件事就是先把目录结构和CMake搭好——磨刀不误砍柴工嘛。

如果你在实际项目中遇到了CMake相关的问题,欢迎随时交流。记住,构建系统也是代码的一部分,值得你认真对待。


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