第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用法的总结。你看一遍,基本就能理解整个构建系统的脉络。
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