第30章:综合实战:从零构建一个完整的嵌入式项目

终于到了最后一章。说实话,写到这里我自己也有点感慨。

前面29章我们聊了Makefile的变量、函数、隐含规则,也聊了CMake的target、generator expression、toolchain file。但说句实在话,这些知识如果只是零散地学,你很难真正用起来。

这一章,我们就拿一个真实的嵌入式项目来串一遍。从需求分析开始,到目录结构设计,再到Makefile和CMake两套构建方案,最后加上持续集成配置。走完这一轮,你才算真正掌握了嵌入式项目构建的全流程。

30.1 项目需求分析

先说说项目背景。我之前带团队做过一个智能传感器节点,用在工业环境监测上。需求其实不复杂:

  • 主控:STM32F407,Cortex-M4内核
  • 传感器:温湿度(SHT30)、气压(BMP280)、光照(TSL2561)
  • 通信:通过UART输出数据,支持Modbus-RTU协议
  • 固件升级:支持通过UART进行YModem协议升级
  • 功耗:待机模式下低于50μA

嗯,这里要注意一点:需求分析阶段就要想清楚构建系统的要求。比如:

  • 需要支持Debug和Release两种配置
  • 单元测试要能独立编译运行
  • 固件升级的bootloader和app要分开构建
  • 要能生成hex、bin、map文件

这些需求直接决定了你的构建系统该怎么设计。我在项目中遇到过好几次,需求没想清楚就开干,结果写到一半发现Makefile结构不对,又得重来。

30.2 目录结构设计

我个人习惯的嵌入式项目目录结构是这样的:

sensor_node/
├── bootloader/          # 引导加载程序
│   ├── src/
│   ├── inc/
│   └── linker/
├── app/                 # 应用程序
│   ├── src/
│   ├── inc/
│   └── linker/
├── lib/                 # 公共库
│   ├── drivers/         # 外设驱动
│   ├── middleware/      # 中间件(Modbus, YModem)
│   └── bsp/            # 板级支持包
├── test/                # 单元测试
│   ├── unit/
│   └── integration/
├── tools/               # 工具脚本
├── docs/                # 文档
├── build/               # 构建输出(不提交)
├── CMakeLists.txt       # 顶层CMake
├── Makefile             # 顶层Makefile
└── .gitlab-ci.yml       # CI配置

为什么这么分?说白了就是解耦。bootloader和app分开,是因为它们的链接脚本不同,启动方式也不同。公共库独立出来,方便复用和测试。

核心原则:每个目录只做一件事,每个模块只依赖它真正需要的东西。

30.3 Makefile方案实现

先看Makefile方案。我选择用递归Makefile,但每个子目录的Makefile只负责编译自己的源文件,链接由顶层Makefile统一处理。

顶层Makefile的核心逻辑:

# 顶层Makefile
CROSS_COMPILE ?= arm-none-eabi-
CC := $(CROSS_COMPILE)gcc
LD := $(CROSS_COMPILE)gcc
OBJCOPY := $(CROSS_COMPILE)objcopy
SIZE := $(CROSS_COMPILE)size

# 项目配置
MCU := -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16
OPT := -Os -g3

# 目录
BUILD_DIR := build
SRC_DIRS := app/src lib/drivers lib/middleware lib/bsp

# 收集源文件
SRCS := $(wildcard $(addsuffix /*.c, $(SRC_DIRS)))
OBJS := $(patsubst %.c, $(BUILD_DIR)/%.o, $(SRCS))

# 包含路径
INC_DIRS := app/inc lib/inc
INC_FLAGS := $(addprefix -I, $(INC_DIRS))

# 编译规则
$(BUILD_DIR)/%.o: %.c
    @mkdir -p $(dir $@)
    $(CC) $(MCU) $(OPT) $(INC_FLAGS) -c $< -o $@

# 链接
$(BUILD_DIR)/app.elf: $(OBJS) app/linker/stm32f407.ld
    $(LD) $(MCU) $(OPT) -T app/linker/stm32f407.ld \
        -Wl,-Map=$(BUILD_DIR)/app.map \
        -o $@ $(OBJS) -lc -lm -lnosys

# 生成hex和bin
$(BUILD_DIR)/app.hex: $(BUILD_DIR)/app.elf
    $(OBJCOPY) -O ihex $< $@

$(BUILD_DIR)/app.bin: $(BUILD_DIR)/app.elf
    $(OBJCOPY) -O binary $< $@

这里有个坑,我曾经踩过:wildcard函数在递归Makefile中要注意路径问题。如果你在子目录的Makefile里用wildcard *.c,它只能匹配当前目录的文件。跨目录的源文件要在顶层统一收集。

小技巧:$(addsuffix /*.c, $(SRC_DIRS))可以一次性匹配多个目录下的源文件,省去写多个wildcard的麻烦。

30.4 CMake方案实现

CMake方案我用了现代CMake的target-based方式。说实话,CMake在嵌入式项目里比Makefile好用得多,尤其是依赖管理和跨平台支持。

顶层CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(sensor_node C ASM)

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

# 项目选项
option(BUILD_BOOTLOADER "Build bootloader" OFF)
option(ENABLE_UNIT_TESTS "Enable unit tests" OFF)

# 添加库
add_subdirectory(lib)

# 添加应用
if(BUILD_BOOTLOADER)
    add_subdirectory(bootloader)
else()
    add_subdirectory(app)
endif()

# 测试
if(ENABLE_UNIT_TESTS)
    enable_testing()
    add_subdirectory(test)
endif()

工具链文件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_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy)
set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size)

set(CMAKE_C_FLAGS "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16" CACHE STRING "")
set(CMAKE_C_FLAGS_DEBUG "-Og -g3" CACHE STRING "")
set(CMAKE_C_FLAGS_RELEASE "-Os -flto" CACHE STRING "")

你想想看,用CMake之后,Debug和Release的切换只需要改一个CMAKE_BUILD_TYPE变量,不用像Makefile那样手动维护两套编译选项。这就是工具带来的效率提升。

30.5 持续集成配置

持续集成我用的是GitLab CI。配置其实不复杂,核心就三步:编译、测试、生成产物。

# .gitlab-ci.yml
stages:
  - build
  - test
  - deploy

variables:
  BUILD_TYPE: "Release"

before_script:
  - apt-get update -qq
  - apt-get install -y -qq cmake gcc-arm-none-eabi

build:app:
  stage: build
  script:
    - mkdir -p build && cd build
    - cmake .. -DCMAKE_BUILD_TYPE=${BUILD_TYPE}
    - make -j$(nproc) app.hex app.bin
  artifacts:
    paths:
      - build/app.hex
      - build/app.bin
    expire_in: 1 week

build:bootloader:
  stage: build
  script:
    - mkdir -p build_bl && cd build_bl
    - cmake .. -DBUILD_BOOTLOADER=ON -DCMAKE_BUILD_TYPE=${BUILD_TYPE}
    - make -j$(nproc) bootloader.hex
  artifacts:
    paths:
      - build_bl/bootloader.hex
    expire_in: 1 week

test:unit:
  stage: test
  script:
    - mkdir -p build_test && cd build_test
    - cmake .. -DENABLE_UNIT_TESTS=ON -DCMAKE_BUILD_TYPE=Debug
    - make -j$(nproc)
    - ctest --output-on-failure

这里有个细节:bootloader和app的构建是分开的job,因为它们的配置不同。但它们的产物都要保存为artifacts,方便后续的集成测试。

注意:CI环境里的工具链版本要和开发环境保持一致。我曾经因为CI里用的是gcc-arm-none-eabi-9,而本地是10版本,导致生成的二进制文件有细微差异,排查了半天。

30.6 项目总结

走完这个完整的项目,我们来总结几个关键点:

  1. 需求先行:构建系统的设计要从项目需求出发,不要为了用某个工具而用。
  2. 目录结构要清晰:好的目录结构能让构建系统简单一半。模块化、层次化是核心。
  3. Makefile vs CMake:小项目用Makefile够用,中大型项目建议上CMake。我个人现在新项目都直接用CMake了。
  4. CI要早配:不要等项目写完了再配CI,一开始就配好,每次提交都能自动验证。
  5. 持续重构:构建系统不是一次写好的,随着项目演进要不断调整优化。

最后说一句:构建系统是工具,不是目的。它的价值在于让你更高效地开发嵌入式软件,而不是让你花大量时间在调试Makefile上。掌握好这个度,你才算真正入门了。

核心思想:好的构建系统是「无感」的——你只管写代码,剩下的交给它。

嵌入式项目构建全流程 需求分析 目录结构设计 Makefile方案 递归Makefile + 统一链接 CMake方案 Target-based + Toolchain 持续集成(CI)

这张图就是我们这一章走过的路。从需求分析出发,到目录结构设计,再到Makefile和CMake两套方案的实现,最后用CI把整个流程自动化起来。每一步都环环相扣,缺一不可。

好了,30章的内容到这里就结束了。希望这些实战经验能帮你在嵌入式构建的路上少踩一些坑。记住,工具是死的,思路是活的。理解了背后的设计思想,你就能灵活应对各种项目场景。

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