第四章:固件工程结构

做光模块固件开发这些年,我见过太多「一个main.c走天下」的项目了。刚开始确实爽,写到后面就变成灾难。今天聊聊怎么把工程组织得清爽、好维护。

4.1 工程目录组织

先说说目录结构。我个人习惯这样分:

project/
├── app/              # 应用层代码
│   ├── main.c
│   ├── task_manager.c
│   └── cli.c
├── driver/           # 硬件驱动层
│   ├── i2c.c
│   ├── spi.c
│   ├── gpio.c
│   └── dac.c
├── hal/              # 硬件抽象层
│   ├── mcu_hal.c
│   └── board.c
├── lib/              # 通用库
│   ├── crc.c
│   ├── fifo.c
│   └── ring_buffer.c
├── protocol/         # 协议层
│   ├── sff_8472.c
│   ├── sff_8636.c
│   └── cmis.c
├── config/           # 配置文件
│   ├── board_config.h
│   └── pin_mapping.h
├── test/             # 测试代码
│   └── unit_test.c
├── doc/              # 文档
├── build/            # 编译输出
├── Makefile
└── README.md

为什么要这么分?说白了就是「关注点分离」。驱动层只管怎么读写寄存器,协议层只管数据格式,应用层只管业务逻辑。各层之间通过接口通信,互不干扰。

核心原则:上层可以调用下层,下层绝不能反向依赖上层。这叫「单向依赖」,能避免很多循环引用的坑。

4.2 模块化设计思想

模块化不是把代码拆成多个文件就完事了。我见过有人把1000行的main.c拆成10个文件,每个文件100行,结果耦合更严重了。

真正的模块化,要满足三个条件:

  • 高内聚:一个模块只做一件事,并且做好
  • 低耦合:模块之间通过接口通信,不直接访问内部数据
  • 可替换:换一个模块不影响其他模块

举个例子,I2C驱动模块:

// i2c.h - 公开接口
typedef struct {
    void (*init)(void);
    int (*read)(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len);
    int (*write)(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len);
} i2c_driver_t;

extern i2c_driver_t i2c_driver;

// i2c.c - 内部实现
static void i2c_init(void) {
    // 初始化I2C外设
    // 配置GPIO、时钟、速率等
}

static int i2c_read(...) {
    // 实现I2C读操作
    // 处理起始条件、地址、数据、停止条件
}

i2c_driver_t i2c_driver = {
    .init = i2c_init,
    .read = i2c_read,
    .write = i2c_write
};

这样设计的好处是,上层代码只需要调用 i2c_driver.read(),根本不用关心底层是硬件I2C还是软件模拟。换平台时,只需要重新实现这个结构体里的函数指针就行。

我的经验:模块化设计时,先定义接口,再实现细节。接口就是模块之间的「合同」,合同定好了,各干各的,互不耽误。

4.3 头文件与源文件分离

这个问题看似简单,但坑不少。我曾经在一个项目里看到某个头文件被包含了200多次,每次编译都要重新解析,编译时间从10秒变成了3分钟。

头文件应该放什么?

  • 函数声明(接口)
  • 宏定义(常量)
  • 结构体定义(数据结构)
  • extern变量声明

头文件不应该放什么?

  • 函数实现(除非是static inline)
  • 变量定义(会引发多重定义错误)
  • 不相关的头文件包含

一个常见的保护措施:

#ifndef __I2C_H__
#define __I2C_H__

// 头文件内容

#endif /* __I2C_H__ */

这叫「头文件卫士」,防止同一个头文件被重复包含。嗯,这个基本是标配了,但新手容易忘。

注意:头文件之间的依赖要尽量少。如果a.h包含了b.h,b.h又包含了c.h,改c.h会导致a.h、b.h都重新编译。这叫「头文件依赖爆炸」,项目大了会非常痛苦。

4.4 Makefile/CMake构建系统

构建系统是工程组织的「骨架」。我早期用批处理脚本,后来用Makefile,现在主力用CMake。说说各自的优缺点。

4.4.1 Makefile

Makefile简单直接,适合小项目。一个典型的Makefile:

# 编译器
CC = arm-none-eabi-gcc

# 源文件
SRC = main.c \
      driver/i2c.c \
      driver/spi.c \
      protocol/sff_8472.c \
      app/task_manager.c

# 头文件路径
INC = -I. -Idriver -Iprotocol -Iapp

# 编译选项
CFLAGS = -mcpu=cortex-m4 -mthumb -O2 -Wall

# 链接选项
LDFLAGS = -Tlink.ld -nostartfiles

# 目标文件
OBJ = $(SRC:.c=.o)

# 最终目标
firmware.elf: $(OBJ)
    $(CC) $(LDFLAGS) -o $@ $^

%.o: %.c
    $(CC) $(CFLAGS) $(INC) -c -o $@ $<

clean:
    rm -f $(OBJ) firmware.elf

Makefile的问题在于,跨平台移植麻烦。Windows和Linux的路径分隔符、命令都不一样。而且Makefile语法比较「古老」,写复杂了容易出bug。

4.4.2 CMake

CMake是现代项目的首选。它不直接编译,而是生成Makefile或Ninja等构建文件。跨平台支持好,语法也更清晰。

cmake_minimum_required(VERSION 3.10)
project(firmware C ASM)

# 设置编译器
set(CMAKE_C_COMPILER arm-none-eabi-gcc)

# 收集源文件
file(GLOB_RECURSE SRC
    "src/*.c"
    "driver/*.c"
    "protocol/*.c"
    "app/*.c"
)

# 头文件路径
include_directories(
    src
    driver
    protocol
    app
)

# 编译选项
set(CMAKE_C_FLAGS "-mcpu=cortex-m4 -mthumb -O2 -Wall")

# 生成目标
add_executable(firmware.elf ${SRC})

# 链接脚本
target_link_options(firmware.elf PRIVATE -T${CMAKE_SOURCE_DIR}/link.ld)

CMake有几个好处:

  • 自动处理依赖关系
  • 支持条件编译(if/else)
  • 可以生成IDE项目文件(如VS、CLion)
  • 模块化管理(add_subdirectory)

我的建议:新项目直接用CMake。虽然学习曲线比Makefile陡一点,但长期来看省心很多。尤其是项目超过10个源文件后,CMake的优势就体现出来了。

4.5 知识体系总览

下面这张图总结了固件工程结构的核心逻辑:

固件工程结构知识体系 工程目录组织 app/ driver/ hal/ lib/ protocol/ config/ test/ doc/ build/ 关注点分离 · 单向依赖 · 各层独立 模块化设计思想 高内聚 · 低耦合 · 可替换 接口定义先行 · 实现细节隐藏 · 函数指针解耦 头文件与源文件分离 .h放声明 · .c放实现 · 头文件卫士 避免循环包含 · 减少依赖链 · 控制编译范围 Makefile / CMake 构建系统 Makefile:简单直接 · CMake:跨平台可扩展 自动依赖管理 · 条件编译 · 生成IDE项目

这张图展示了固件工程结构的四个层次。从目录组织到底层构建,每一层都建立在前一层的基础上。你想想看,如果目录乱糟糟的,模块化设计再好也白搭;如果模块耦合严重,头文件分离得再干净也没用。

避坑指南:我曾经接手过一个项目,所有代码都在一个文件夹里,头文件互相包含成「蜘蛛网」。改一个宏定义,编译报错30多个。后来花了整整一周重构,才把工程结构理顺。所以,一开始就规划好,比后期重构省心一百倍。

好了,这一章的内容就这些。工程结构看着是「体力活」,但决定了项目能走多远。记住:好的结构让代码「越写越顺」,坏的结构让代码「越改越烂」。


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