大型项目组织:模块化设计、接口库、别名目标与导出目标

说实话,嵌入式项目一旦上了规模,CMakeLists.txt 就很容易变成一团乱麻。我见过不少团队,一个项目里堆了上百个源文件,所有编译选项全写在一个文件里。每次改点东西,都得翻半天。嗯,这其实是可以避免的。

今天咱们聊聊怎么把大型项目组织得井井有条。核心就四个东西:模块化设计接口库(INTERFACE)别名目标(ALIAS)导出目标(EXPORT)。你把这四个玩明白了,再大的项目也能理得清清楚楚。

为什么需要模块化?

我刚开始做嵌入式那会儿,项目小,一个 CMakeLists.txt 搞定所有。后来项目越来越大,一个文件里塞了上千行,找某个源文件的位置都得靠搜索。更别提多人协作时,改同一个文件经常冲突。

模块化的好处很明显:

  • 职责清晰:每个模块只管自己的事,比如驱动层、协议层、应用层分开
  • 复用方便:写好的模块可以直接搬到另一个项目里
  • 编译加速:改了一个模块,只重新编译这个模块,不用全量编译
  • 依赖明确:每个模块声明自己需要什么,CMake 自动帮你理清依赖关系

我个人习惯,每个模块单独建一个目录,里面放一个 CMakeLists.txt。根目录的 CMakeLists.txt 只负责调用 add_subdirectory()。这样结构一目了然。

核心原则:每个模块只做一件事,并且做好这件事。模块之间通过明确的接口通信,不要互相包含源文件。

接口库(INTERFACE)—— 纯头文件库的最佳拍档

嵌入式项目里,纯头文件的库很常见。比如一些硬件抽象层(HAL),或者一些配置宏定义。你想想看,这种库没有 .c 文件,只有 .h 文件。如果用 add_library() 创建普通库,CMake 会要求你至少提供一个源文件,否则报错。

这时候 INTERFACE 库就派上用场了。它不编译任何源文件,只传递编译选项、头文件路径和宏定义。

# 创建一个接口库
add_library(my_hal INTERFACE)

# 给它添加头文件路径
target_include_directories(my_hal INTERFACE
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# 给它添加编译宏
target_compile_definitions(my_hal INTERFACE
    USE_HAL_DRIVER
    STM32F407xx
)

# 其他目标链接这个接口库
target_link_libraries(my_app PRIVATE my_hal)

我在项目中遇到过这样的情况:一个传感器驱动库,全是头文件模板,不同芯片只需要定义不同的宏。用 INTERFACE 库,把宏定义和头文件路径打包好,链接到应用目标上,干净利落。

小技巧:接口库也可以依赖其他接口库。比如 target_link_libraries(my_hal INTERFACE common_defines),这样依赖关系可以层层传递。

别名目标(ALIAS)—— 给目标起个短名字

项目大了,目标名字容易变得很长。比如 stm32f4_drivers_usart,每次写都费劲。别名目标就是给目标起个简短、好记的别名。

# 创建一个普通库
add_library(stm32f4_drivers_usart STATIC
    src/usart.c
    src/usart_irq.c
)

# 给它起个别名
add_library(Drivers::USART ALIAS stm32f4_drivers_usart)

# 其他地方用别名链接
target_link_libraries(my_app PRIVATE Drivers::USART)

为什么要用别名?我个人习惯,别名通常带命名空间,比如 Drivers::USARTMiddleware::FreeRTOS。这样一看就知道这个目标属于哪个模块。而且别名是只读的,不能修改它的属性,防止误操作。

注意:别名目标只能用于 target_link_libraries()target_link_options(),不能用于 target_include_directories() 等修改属性的命令。如果你想修改,得操作原始目标。

导出目标(EXPORT)—— 让其他项目也能用你的库

这个功能我特别喜欢。你写了一个好用的驱动库,想给其他项目用。传统做法是把源码复制一份过去,或者手动设置路径。但这样维护起来很痛苦,改了一个地方,其他项目都得同步。

导出目标可以把你的库打包成一个“包”,其他项目通过 find_package() 就能直接引用。CMake 会自动帮你处理头文件路径、编译选项和依赖关系。

# 第一步:在库的 CMakeLists.txt 中安装并导出
install(TARGETS my_driver
    EXPORT MyDriverTargets
    ARCHIVE DESTINATION lib
    INCLUDES DESTINATION include
)

install(
    DIRECTORY include/
    DESTINATION include
)

install(
    EXPORT MyDriverTargets
    FILE MyDriverConfig.cmake
    DESTINATION lib/cmake/MyDriver
)

# 第二步:在其他项目中使用
find_package(MyDriver REQUIRED)
target_link_libraries(my_app PRIVATE MyDriver::my_driver)

我曾经给团队做了一个通用的传感器驱动库,用导出目标的方式发布。其他项目只需要 find_package 就能用,而且版本管理也方便。改了一个传感器的驱动,所有项目重新编译一下就行,不用到处复制代码。

关键点:导出目标的核心是 install(EXPORT ...) 命令。它会生成一个 CMake 配置文件,里面包含了目标的所有信息。其他项目通过 find_package() 加载这个配置文件,就能获得完整的目标定义。

实战:一个模块化项目的典型结构

说了这么多,咱们看一个实际例子。假设你有一个 STM32 项目,包含驱动层、中间件层和应用层。

project_root/
├── CMakeLists.txt              # 根目录
├── drivers/
│   ├── CMakeLists.txt          # 驱动模块
│   ├── include/
│   │   └── usart.h
│   └── src/
│       └── usart.c
├── middleware/
│   ├── CMakeLists.txt          # 中间件模块
│   ├── include/
│   │   └── freertos_wrapper.h
│   └── src/
│       └── freertos_wrapper.c
├── app/
│   ├── CMakeLists.txt          # 应用模块
│   └── src/
│       └── main.c
└── cmake/
    └── stm32f4_config.cmake    # 芯片配置

根目录的 CMakeLists.txt 大概长这样:

cmake_minimum_required(VERSION 3.16)
project(MyProject C ASM)

# 包含芯片配置
include(cmake/stm32f4_config.cmake)

# 添加子模块
add_subdirectory(drivers)
add_subdirectory(middleware)
add_subdirectory(app)

# 最终的可执行文件
add_executable(my_firmware app/src/main.c)
target_link_libraries(my_firmware PRIVATE
    Drivers::USART
    Middleware::FreeRTOS
)

每个子模块的 CMakeLists.txt 各自管理自己的源文件和依赖。比如 drivers/CMakeLists.txt

add_library(drivers_usart STATIC
    src/usart.c
)

target_include_directories(drivers_usart PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

target_link_libraries(drivers_usart PUBLIC
    stm32f4_hal  # 假设 HAL 库也是接口库
)

add_library(Drivers::USART ALIAS drivers_usart)

这样组织,每个模块的职责非常清晰。改驱动不会影响到中间件,改中间件不会影响到应用。而且编译的时候,CMake 会自动分析依赖,只编译修改过的部分。

SVG 结构图:模块化项目的依赖关系

模块化项目依赖关系图 应用层 (my_firmware) 驱动层 (Drivers::USART) 中间件层 (Middleware::FreeRTOS) HAL 接口库 (INTERFACE) 芯片配置 (stm32f4_config) 应用层 驱动层 中间件层 接口库 配置

避坑指南

我这些年踩过不少坑,分享几个常见的:

  • 接口库的依赖传递:接口库的 INTERFACE 属性会传递给链接它的目标。如果你不小心把 PRIVATE 写成 INTERFACE,可能会把内部依赖暴露出去。我曾经因为这个,导致一个模块的编译选项污染了其他模块,查了半天。
  • 别名目标的命名冲突:别名目标不能和已有目标重名。如果你用了 Drivers::USART,就不能再定义一个叫 Drivers::USART 的普通目标。建议统一用 命名空间::目标名 的格式。
  • 导出目标的路径问题:导出目标时,头文件路径最好用相对路径,或者用 $<INSTALL_INTERFACE:...> 生成器表达式。否则换一台机器编译,路径可能就不对了。
  • 不要滥用接口库:接口库虽然方便,但只适合纯头文件库。如果你有源文件要编译,还是用 STATICSHARED 库。强行用接口库加源文件,CMake 不会帮你编译的。

我的建议:刚开始做模块化时,别追求一步到位。先把项目按功能拆成几个大模块,每个模块一个 CMakeLists.txt。等熟悉了,再慢慢细化。一口吃不成胖子,对吧?

好了,模块化设计、接口库、别名目标、导出目标,这四个东西你掌握了,大型嵌入式项目的组织就不再是难题。说白了,就是让 CMake 帮你管理好依赖关系,你只管写代码就行。

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