4. 高内聚设计模式:状态机聚合、驱动分层聚合
高内聚这个词,很多工程师都听过。但落到代码里,到底怎么才算「高」?我见过不少项目,模块划分得挺清楚,可一打开源文件,里面什么都有——状态切换、硬件操作、数据处理全揉在一起。说白了,就是内聚度不够。
这一章,我重点讲两种实战中最高频的高内聚模式:状态机聚合和驱动分层聚合。这两种模式几乎覆盖了嵌入式系统中80%的业务逻辑和硬件交互场景。
4.1 状态机聚合:把状态逻辑关进「笼子」里
先问一个问题:你写状态机时,是不是经常这样?
// 反面案例:状态逻辑散落在各处
void button_handler(void) {
if (system_state == STATE_IDLE) {
if (button_pressed()) {
system_state = STATE_ACTIVE;
start_timer();
led_on();
}
} else if (system_state == STATE_ACTIVE) {
if (button_pressed()) {
system_state = STATE_IDLE;
stop_timer();
led_off();
}
}
}
void timer_handler(void) {
if (system_state == STATE_ACTIVE) {
system_state = STATE_TIMEOUT;
buzzer_on();
}
}
这段代码有什么问题?状态变量 system_state 是全局的,任何函数都能改。状态迁移的逻辑散落在各个中断处理、回调函数里。你想理清整个状态机,得翻遍所有源文件。
我在一个智能门锁项目里就吃过这个亏。当时状态逻辑分散在5个文件中,加一个新状态,改了3处还漏了1处,导致门锁在特定条件下死机。后来我重构了,把所有状态逻辑聚合到一个模块里。
状态机聚合的核心原则:
- 一个状态机,一个模块(一个 .c/.h 文件对)
- 状态变量对外只读,对内可写
- 所有状态迁移逻辑集中在同一个函数或同一组函数中
- 状态机的输入(事件)和输出(动作)通过接口隔离
来看看聚合后的样子:
// 状态机模块:sm_door_lock.h
typedef enum {
SM_IDLE,
SM_ACTIVE,
SM_TIMEOUT,
SM_ERROR
} sm_state_t;
// 对外接口:只暴露事件驱动函数
void sm_door_lock_init(void);
void sm_door_lock_on_button_pressed(void);
void sm_door_lock_on_timer_expired(void);
// 状态变量对外只读
sm_state_t sm_door_lock_get_state(void);
// 状态机模块:sm_door_lock.c
static sm_state_t current_state = SM_IDLE;
// 所有状态迁移集中在这里
static void sm_transit(sm_state_t new_state) {
// 退出旧状态
switch (current_state) {
case SM_ACTIVE: stop_timer(); break;
case SM_TIMEOUT: buzzer_off(); break;
default: break;
}
// 进入新状态
switch (new_state) {
case SM_ACTIVE: start_timer(); led_on(); break;
case SM_TIMEOUT: buzzer_on(); break;
case SM_IDLE: led_off(); break;
default: break;
}
current_state = new_state;
}
void sm_door_lock_on_button_pressed(void) {
switch (current_state) {
case SM_IDLE: sm_transit(SM_ACTIVE); break;
case SM_ACTIVE: sm_transit(SM_IDLE); break;
default: break; // 其他状态忽略该事件
}
}
void sm_door_lock_on_timer_expired(void) {
if (current_state == SM_ACTIVE) {
sm_transit(SM_TIMEOUT);
}
}
你看,所有状态迁移逻辑都集中在 sm_transit() 函数里。加一个新状态,只需要改这一个文件。事件处理函数里只有简单的判断和调用,没有业务逻辑。
我的习惯:状态机模块的 .h 文件只暴露事件驱动接口和状态查询接口。外部模块永远不需要知道状态机内部有多少个状态、怎么迁移。这样,你换状态机实现(比如从 switch-case 换成查表法),外部代码一行都不用改。
4.2 驱动分层聚合:把硬件操作「包」起来
驱动代码是嵌入式系统里最容易「散」的地方。很多工程师习惯在业务逻辑里直接操作寄存器:
// 反面案例:业务逻辑直接操作硬件
void send_data(uint8_t *data, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
while (!(UART1->SR & (1 << 7))); // 等待发送就绪
UART1->DR = data[i]; // 直接写数据寄存器
}
}
这段代码有什么问题?它把硬件细节暴露给了上层。如果换一个芯片,UART 寄存器地址变了,你得改所有调用 send_data 的地方。更麻烦的是,如果硬件有 bug 需要加 workaround,你得在每一处操作硬件的地方加补丁。
驱动分层聚合,就是把硬件操作按照「抽象层级」组织到不同的模块里。我一般分三层:
| 层级 | 模块名 | 职责 | 依赖 |
|---|---|---|---|
| L0 | 硬件抽象层 (HAL) | 封装寄存器操作,提供原子化 API | 芯片手册 |
| L1 | 驱动服务层 | 实现协议、缓冲、超时等逻辑 | HAL 层 |
| L2 | 设备接口层 | 提供与应用无关的设备操作接口 | 驱动服务层 |
举个例子,UART 驱动的分层实现:
// L0: HAL 层 - 只做寄存器操作
// hal_uart.h
void hal_uart_init(uint32_t baud);
void hal_uart_send_byte(uint8_t data);
uint8_t hal_uart_receive_byte(void);
uint8_t hal_uart_is_tx_ready(void);
uint8_t hal_uart_is_rx_ready(void);
// L1: 驱动服务层 - 实现协议逻辑
// drv_uart.h
void drv_uart_send_buffer(uint8_t *data, uint32_t len);
uint32_t drv_uart_receive_buffer(uint8_t *buf, uint32_t max_len);
void drv_uart_set_timeout(uint32_t ms);
// L2: 设备接口层 - 与应用无关
// dev_uart.h
typedef struct {
void (*send)(uint8_t *data, uint32_t len);
uint32_t (*receive)(uint8_t *buf, uint32_t max_len);
void (*set_baud)(uint32_t baud);
} uart_device_t;
extern const uart_device_t uart1_device;
extern const uart_device_t uart2_device;
这样分层后,业务代码只跟 L2 的设备接口打交道。换芯片时,只需要重写 L0 的 HAL 层,L1 和 L2 基本不用动。
我曾经踩过的坑:在一个项目里,我把 HAL 层和驱动服务层写在了同一个文件中。后来芯片换型号,寄存器地址变了,我不得不把整个文件重写。更惨的是,驱动服务层里的协议逻辑也被我改出了 bug。从那以后,我坚持 HAL 层单独一个文件,驱动服务层单独一个文件,绝不混在一起。
4.3 两种模式的协同:状态机 + 驱动分层
实际项目中,状态机聚合和驱动分层聚合经常一起用。状态机负责业务逻辑的流转,驱动分层负责硬件操作的封装。两者通过接口交互,互不侵入。
来看一个完整的例子:智能温控器。
// 状态机模块:sm_thermostat.c
// 负责温度控制的状态迁移
void sm_thermostat_on_temperature_changed(float temp) {
switch (current_state) {
case SM_HEATING:
if (temp > target_temp + hysteresis) {
sm_transit(SM_IDLE);
heater_control_off(); // 调用驱动接口
}
break;
case SM_COOLING:
if (temp < target_temp - hysteresis) {
sm_transit(SM_IDLE);
cooler_control_off(); // 调用驱动接口
}
break;
case SM_IDLE:
if (temp < target_temp - hysteresis) {
sm_transit(SM_HEATING);
heater_control_on(); // 调用驱动接口
} else if (temp > target_temp + hysteresis) {
sm_transit(SM_COOLING);
cooler_control_on(); // 调用驱动接口
}
break;
}
}
// 驱动模块:drv_heater.c
// 封装加热器的硬件操作
void heater_control_on(void) {
hal_gpio_set_pin(HEATER_PIN, 1); // L0 HAL 层
drv_pwm_start(HEATER_PWM_CH); // L1 驱动服务层
}
void heater_control_off(void) {
hal_gpio_set_pin(HEATER_PIN, 0);
drv_pwm_stop(HEATER_PWM_CH);
}
状态机不知道加热器是怎么控制的,它只调用 heater_control_on() 和 heater_control_off()。驱动模块也不知道状态机内部有多少个状态,它只提供硬件操作接口。两者通过函数调用耦合,但各自内部高度内聚。
关键收益:
- 状态机模块可以独立测试:mock 掉驱动接口,纯逻辑测试
- 驱动模块可以独立测试:用固定输入验证硬件操作时序
- 换硬件时,只改驱动模块,状态机逻辑不受影响
- 加新功能时,只改状态机模块,驱动模块不受影响
4.4 内聚度评估:你的模块够「聚」吗?
我给自己定了个简单的评估标准,分享给你:
| 评估维度 | 高内聚表现 | 低内聚表现 |
|---|---|---|
| 修改范围 | 加一个功能,只改一个模块 | 加一个功能,改3个以上模块 |
| 测试难度 | 模块可单独编译测试 | 测试必须搭完整硬件环境 |
| 代码阅读 | 一个文件就能看懂模块逻辑 | 需要同时打开5个文件才能理清 |
| 接口数量 | 对外接口 < 5 个 | 对外接口 > 15 个 |
如果你的模块在「修改范围」这一项上总是要改多个文件,那大概率是内聚度不够。我建议你停下来,重新审视模块的职责划分。
一个小技巧:写代码前,先在纸上画出模块的职责边界。一个模块只做「一件事」,这件事的描述用一句话能说完。如果说不完,说明职责太杂,需要拆分。
4.5 本章知识结构图
下面这张图展示了状态机聚合和驱动分层聚合的核心关系,以及它们如何协同工作:
嗯,这一章的内容就到这里。状态机聚合和驱动分层聚合,是我在嵌入式项目中用得最多的两种高内聚模式。你可以在自己的项目里试试,把散落的状态逻辑收拢到一个模块里,把硬件操作按层级封装起来。你会发现,代码的可维护性和可测试性会有质的提升。