第四章:固件工程结构
做光模块固件开发这些年,我见过太多「一个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 知识体系总览
下面这张图总结了固件工程结构的核心逻辑:
这张图展示了固件工程结构的四个层次。从目录组织到底层构建,每一层都建立在前一层的基础上。你想想看,如果目录乱糟糟的,模块化设计再好也白搭;如果模块耦合严重,头文件分离得再干净也没用。
避坑指南:我曾经接手过一个项目,所有代码都在一个文件夹里,头文件互相包含成「蜘蛛网」。改一个宏定义,编译报错30多个。后来花了整整一周重构,才把工程结构理顺。所以,一开始就规划好,比后期重构省心一百倍。
好了,这一章的内容就这些。工程结构看着是「体力活」,但决定了项目能走多远。记住:好的结构让代码「越写越顺」,坏的结构让代码「越改越烂」。
公众号:蓝海资料掘金营,微信deep3321