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 本章知识结构图

下面这张图展示了状态机聚合和驱动分层聚合的核心关系,以及它们如何协同工作:

高内聚设计模式:状态机聚合 + 驱动分层聚合 状态机聚合模块 职责:业务逻辑状态迁移 事件输入接口 动作输出接口 状态迁移表(switch-case / 查表法) 所有迁移逻辑集中在一个函数 驱动分层聚合模块 职责:硬件操作封装 L2 设备接口层(与应用无关) L1 驱动服务层(协议/缓冲/超时) L0 HAL 层(寄存器操作) 调用驱动接口 协同工作方式: 状态机模块通过动作输出接口调用驱动模块的 L2 设备接口,驱动模块通过 L0 HAL 层操作硬件。 两者通过接口耦合,各自内部高度内聚,互不侵入。 内聚度评估标准 高内聚:加功能只改一个模块 可单独编译测试 一个文件看懂模块逻辑 对外接口 < 5 个 低内聚:加功能改 3+ 模块 测试必须搭完整硬件 需要同时打开 5+ 文件 对外接口 > 15 个

嗯,这一章的内容就到这里。状态机聚合和驱动分层聚合,是我在嵌入式项目中用得最多的两种高内聚模式。你可以在自己的项目里试试,把散落的状态逻辑收拢到一个模块里,把硬件操作按层级封装起来。你会发现,代码的可维护性和可测试性会有质的提升。