第30章 综合实战:从零构建一个完整的嵌入式项目(传感器+通信+控制)

终于到了最后一章。说实话,我写到这里还挺感慨的。前面29章我们聊了CMake的方方面面,从基础语法到交叉编译,从库管理到测试框架。但我知道,大家最想看的还是——这些东西到底怎么揉到一个真实项目里?

好,今天我们就来干这件事。从零开始,用CMake构建一个完整的嵌入式项目。这个项目包含传感器数据采集、通信协议解析、控制逻辑执行。说白了,就是一个能跑、能测、能部署的真实固件。

项目背景:一个温湿度传感器节点,通过UART上报数据,接收远程控制指令,驱动LED和风扇。硬件平台基于STM32F4,RTOS用FreeRTOS,通信协议用自定义的简单帧格式。

30.1 项目目录结构设计

我个人习惯,项目一开始先把目录搭好。别小看这一步,目录结构乱,后面CMakeLists.txt写得再漂亮也白搭。

sensor_control_project/
├── CMakeLists.txt              # 顶层CMake
├── cmake/
│   ├── toolchain_stm32f4.cmake # 工具链文件
│   ├── freertos_config.cmake   # FreeRTOS配置
│   └── options.cmake           # 编译选项
├── src/
│   ├── main.c
│   ├── sensor/
│   │   ├── CMakeLists.txt
│   │   ├── dht22.c
│   │   └── dht22.h
│   ├── comm/
│   │   ├── CMakeLists.txt
│   │   ├── uart_protocol.c
│   │   └── uart_protocol.h
│   ├── control/
│   │   ├── CMakeLists.txt
│   │   ├── actuator.c
│   │   └── actuator.h
│   └── os/
│       ├── CMakeLists.txt
│       ├── freertos_hooks.c
│       └── task_definitions.c
├── lib/
│   ├── FreeRTOS/               # 第三方源码
│   └── CMSIS/
├── test/
│   ├── CMakeLists.txt
│   ├── test_sensor.c
│   └── test_protocol.c
└── config/
    ├── sensor_config.h.in
    └── comm_config.h.in

你想想看,每个模块都有自己的CMakeLists.txt,顶层只负责组合。这样改一个传感器驱动,不会影响到通信模块。我在一个项目里见过所有人往一个CMakeLists.txt里塞东西,最后3000行,谁都不敢动。嗯,千万别学那个。

30.2 顶层CMakeLists.txt:项目骨架

顶层文件负责三件事:指定工具链、加载全局配置、添加子目录。我习惯把工具链文件单独放,这样换平台时只改一个文件。

cmake_minimum_required(VERSION 3.20)
project(sensor_control_project
    VERSION 1.0.0
    LANGUAGES C CXX ASM
)

# 设置工具链
set(CMAKE_TOOLCHAIN_FILE
    "${CMAKE_SOURCE_DIR}/cmake/toolchain_stm32f4.cmake")

# 加载全局编译选项
include(cmake/options.cmake)

# 添加子模块
add_subdirectory(src/sensor)
add_subdirectory(src/comm)
add_subdirectory(src/control)
add_subdirectory(src/os)
add_subdirectory(lib/FreeRTOS)
add_subdirectory(lib/CMSIS)

# 生成可执行文件
add_executable(sensor_firmware
    src/main.c
)

# 链接所有库
target_link_libraries(sensor_firmware
    PRIVATE
        sensor_lib
        comm_lib
        control_lib
        os_lib
        freertos_lib
        cmsis_lib
)

# 生成配置头文件
configure_file(
    config/sensor_config.h.in
    ${CMAKE_BINARY_DIR}/generated/sensor_config.h
)
target_include_directories(sensor_firmware PRIVATE
    ${CMAKE_BINARY_DIR}/generated
)

我的经验:target_link_libraries用PRIVATE,别用PUBLIC。PUBLIC会把依赖传递出去,有时候你根本不想让sensor_lib知道comm_lib的存在。我曾经因为PUBLIC滥用,导致一次改库接口,牵连了五个模块重新编译。血的教训。

30.3 工具链文件:交叉编译的灵魂

工具链文件说白了就是告诉CMake:你的编译器在哪、目标架构是什么、链接脚本用哪个。我见过有人把这些直接写在顶层CMakeLists.txt里,换平台时改得想哭。

# cmake/toolchain_stm32f4.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)

set(TOOLCHAIN_PREFIX arm-none-eabi-)
set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc)

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DUSE_HAL_DRIVER -DSTM32F407xx")

set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/config/STM32F407VGTx_FLASH.ld)
set(CMAKE_EXE_LINKER_FLAGS
    "${CMAKE_EXE_LINKER_FLAGS} -T ${LINKER_SCRIPT} --specs=nosys.specs")

set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

这里有个坑:CMAKE_FIND_ROOT_PATH_MODE这三个变量一定要设。不设的话,CMake可能会去宿主系统找头文件,结果找到一堆x86的stdio.h,编译直接崩。我刚开始做交叉编译时就栽在这上面,查了整整一天。

30.4 传感器模块:独立库的构建

每个模块都是一个独立的静态库。这样测试时可以直接链接测试框架,不用编译整个固件。

# src/sensor/CMakeLists.txt
add_library(sensor_lib STATIC
    dht22.c
)

target_include_directories(sensor_lib PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
)

target_include_directories(sensor_lib PRIVATE
    ${CMAKE_SOURCE_DIR}/lib/CMSIS/Include
    ${CMAKE_BINARY_DIR}/generated
)

# 添加编译选项
target_compile_definitions(sensor_lib PRIVATE
    SENSOR_DEBUG=$
)

# 设置优化等级
target_compile_options(sensor_lib PRIVATE
    $<$:-O0 -g3>
    $<$:-O2 -DNDEBUG>
)

关键点:$生成器表达式,实现Debug和Release的不同编译选项。别再用if-else了,生成器表达式是CMake的推荐做法。

30.5 通信模块:配置头文件生成

通信协议里有很多参数,比如波特率、帧头、超时时间。这些参数我习惯用configure_file生成,而不是硬编码。

# config/comm_config.h.in
#ifndef COMM_CONFIG_H
#define COMM_CONFIG_H

#define UART_BAUDRATE    @UART_BAUDRATE@
#define FRAME_HEADER     0xAA
#define FRAME_TIMEOUT_MS @FRAME_TIMEOUT_MS@
#define MAX_PAYLOAD_SIZE @MAX_PAYLOAD_SIZE@

#endif
# cmake/options.cmake
set(UART_BAUDRATE 115200 CACHE STRING "UART baud rate")
set(FRAME_TIMEOUT_MS 100 CACHE STRING "Frame timeout in ms")
set(MAX_PAYLOAD_SIZE 64 CACHE STRING "Max payload size")

# 可以在命令行覆盖:
# cmake -DUART_BAUDRATE=9600 ..

这样做的好处是,调试时改波特率不用改代码,重新cmake一下就行。我在一个客户现场调试时,对方硬件UART有问题,我现场改了波特率重新编译,十分钟搞定。要是硬编码,得改源码、提交、再编译,至少半小时。

30.6 测试模块:单元测试集成

嵌入式项目也要写测试。我用CMake的CTest加上一个轻量级的测试框架(比如Unity或Cmock)。

# test/CMakeLists.txt
enable_testing()

# 添加测试框架
add_subdirectory(${CMAKE_SOURCE_DIR}/test/unity unity_build)

# 传感器测试
add_executable(test_sensor
    test_sensor.c
    ${CMAKE_SOURCE_DIR}/src/sensor/dht22.c
)
target_include_directories(test_sensor PRIVATE
    ${CMAKE_SOURCE_DIR}/src/sensor
    ${CMAKE_SOURCE_DIR}/test/unity/src
)
target_link_libraries(test_sensor PRIVATE unity)

add_test(NAME sensor_test COMMAND test_sensor)

# 协议测试
add_executable(test_protocol
    test_protocol.c
    ${CMAKE_SOURCE_DIR}/src/comm/uart_protocol.c
)
target_include_directories(test_protocol PRIVATE
    ${CMAKE_SOURCE_DIR}/src/comm
    ${CMAKE_SOURCE_DIR}/test/unity/src
)
target_link_libraries(test_protocol PRIVATE unity)

add_test(NAME protocol_test COMMAND test_protocol)

# 注册测试
enable_testing()

运行测试很简单:cmake --build . && ctest --output-on-failure。我每次提交代码前都会跑一遍,确保没把老功能搞坏。

30.7 知识体系总览

下面这张图,是我对这个项目CMake用法的总结。你看一遍,基本就能理解整个构建系统的脉络。

顶层 CMakeLists.txt toolchain_stm32f4.cmake options.cmake configure_file() sensor_lib (STATIC) comm_lib (STATIC) control_lib (STATIC) freertos_lib sensor_firmware (可执行文件) CTest + Unity 单元测试

30.8 避坑指南与最佳实践

最后,我把自己这些年踩过的坑总结一下。你记住这几条,能少走很多弯路。

问题 表现 解决方案
工具链路径不对 编译时报找不到编译器 用绝对路径或设置环境变量,别用相对路径
头文件搜索顺序 意外包含了系统头文件 PRIVATE和PUBLIC要分清,别偷懒全用PUBLIC
链接顺序 undefined reference错误 被依赖的库放在后面,比如target_link_libraries(A PRIVATE B C)
配置头文件未更新 改了参数但编译没生效 clean后重新cmake,或者用CMAKE_CONFIGURE_DEPENDS
测试链接了固件库 测试跑不起来,因为依赖硬件 测试只链接纯逻辑模块,用mock替代硬件依赖

我曾经犯过的错:有一次我把所有模块都设为INTERFACE库,想着省事。结果依赖关系乱成一团,改一个头文件,整个项目重新编译。后来老老实实改成STATIC,编译时间从5分钟降到了30秒。别贪图一时方便。

好了,这就是一个完整的嵌入式项目CMake构建方案。从工具链配置到模块化构建,从配置生成到单元测试,每一步都有它的道理。你把这个项目结构吃透了,以后不管换什么芯片、什么RTOS,都能快速搭起来。

记住,CMake不是写一次就完事的。随着项目变大,你会不断调整。保持目录清晰、模块独立、配置可配,这才是长期维护的关键。


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