3、模块解耦核心思想:接口与实现分离,依赖倒置原则在嵌入式中的应用,回调函数与事件驱动机制

好,咱们进入第三章。这一章讲的是模块解耦的核心思想,说白了就是怎么让你的代码「活起来」,而不是一改就崩、一加功能就重构。

我刚开始做嵌入式那几年,写代码都是「想到哪写到哪」。一个 .c 文件里,传感器读取、数据处理、串口打印全揉在一起。后来项目越做越大,改一个传感器的时序,结果把整个系统的逻辑都带崩了。嗯,从那以后我才真正开始琢磨——到底怎么把模块拆开,让它们各干各的,互不干扰?

3.1 接口与实现分离:先定协议,再写代码

接口与实现分离,是模块解耦的第一把钥匙。你想想看,如果两个模块之间直接调用对方的函数,那它们就绑死了。一个改了,另一个也得跟着改。

正确的做法是:先定义接口(也就是协议),再分别实现。接口放在头文件里,实现放在源文件里。调用方只依赖接口,不依赖具体实现。

核心原则:头文件只放函数声明、结构体定义、宏定义,不放全局变量和具体实现细节。

举个例子,假设我们要设计一个温湿度传感器驱动。接口可以这样定义:

// sensor_interface.h
#ifndef SENSOR_INTERFACE_H
#define SENSOR_INTERFACE_H

#include <stdint.h>

typedef struct {
    float temperature;
    float humidity;
} sensor_data_t;

// 接口:初始化传感器
int32_t sensor_init(void);

// 接口:读取数据
int32_t sensor_read(sensor_data_t *data);

// 接口:进入低功耗模式
void sensor_sleep(void);

#endif

然后具体的传感器实现(比如 SHT30)放在另一个文件里:

// sht30_driver.c
#include "sensor_interface.h"
#include "sht30_regs.h"  // 芯片寄存器定义

int32_t sensor_init(void) {
    // 配置 I2C、复位芯片、校验 ID
    // 我在项目中遇到过,有些传感器上电后需要等待 100ms 才能稳定
    HAL_Delay(100);
    return 0;
}

int32_t sensor_read(sensor_data_t *data) {
    uint8_t buf[6];
    // 发送读取命令,接收 6 字节数据
    // 解析温度、湿度
    data->temperature = (buf[0] << 8 | buf[1]) * 175.0f / 65535.0f - 45.0f;
    data->humidity = (buf[3] << 8 | buf[4]) * 100.0f / 65535.0f;
    return 0;
}

void sensor_sleep(void) {
    // 发送休眠命令
}

调用方(比如应用层)只需要包含 sensor_interface.h,完全不知道底层用的是 SHT30 还是 DHT22。哪天要换传感器,只需要换一个 .c 文件,接口不变,上层代码一行都不用改。

我的习惯:接口头文件里不要包含任何硬件相关的头文件(比如 stm32f4xx_hal.h)。接口应该是纯 C 的、平台无关的。这样以后移植到其他 MCU 时,接口文件可以直接复用。

3.2 依赖倒置原则:让高层模块不依赖底层细节

依赖倒置原则(DIP)是 SOLID 原则中的一条。它的核心思想是:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。

你可能会问:「这不就是接口与实现分离吗?」其实不完全一样。接口与实现分离是「怎么拆」,依赖倒置是「谁依赖谁」。传统做法是应用层直接调用驱动层,驱动层直接调用 HAL 库。依赖倒置之后,应用层定义接口,驱动层去实现这个接口。依赖的方向反过来了。

我画了一张图,帮你理解这个关系:

依赖倒置前后对比 传统做法(紧耦合) 应用层 驱动层(SHT30) HAL 库 / 硬件 依赖倒置(松耦合) 应用层 抽象接口 驱动实现 HAL 库 / 硬件 左侧:应用层直接依赖驱动层,换驱动必须改应用层代码

你看,右侧的依赖方向是「应用层 → 抽象接口 ← 驱动实现」。应用层不关心驱动怎么实现,驱动实现也不关心应用层怎么用。两者通过接口解耦。

避坑指南:我曾经在一个项目中,把接口定义在驱动层头文件里,结果应用层还是间接依赖了驱动层。正确的做法是:接口头文件放在一个独立的目录(比如 interface/),不依赖任何硬件相关的代码。

3.3 回调函数:让模块主动通知,而不是被动轮询

回调函数是实现事件驱动的基础。说白了,就是「你告诉我该做什么,到了时机我通知你」。这样调用方不需要一直轮询状态,节省 CPU 资源,代码也更清晰。

在 STM32 HAL 库中,回调函数随处可见。比如串口接收完成回调、定时器溢出回调、外部中断回调。但很多人只是机械地往 HAL_UART_RxCpltCallback 里塞代码,没有意识到这其实是一种解耦手段。

我建议的做法是:在驱动层定义回调函数指针,让应用层注册自己的处理函数。这样驱动层不需要知道应用层的具体逻辑。

// sensor_interface.h(扩展)
typedef void (*sensor_data_ready_cb_t)(sensor_data_t *data);

int32_t sensor_register_callback(sensor_data_ready_cb_t cb);
void sensor_start_continuous_read(void);
// sht30_driver.c(扩展)
static sensor_data_ready_cb_t user_callback = NULL;

int32_t sensor_register_callback(sensor_data_ready_cb_t cb) {
    if (cb == NULL) return -1;
    user_callback = cb;
    return 0;
}

// 在定时器中断或 DMA 完成中断中调用
static void on_data_ready(sensor_data_t *data) {
    if (user_callback != NULL) {
        user_callback(data);  // 通知应用层
    }
}
// app_main.c
static void my_data_handler(sensor_data_t *data) {
    // 处理温湿度数据
    printf("Temp: %.2f, Humi: %.2f\n", data->temperature, data->humidity);
}

void app_init(void) {
    sensor_init();
    sensor_register_callback(my_data_handler);
    sensor_start_continuous_read();
}

你看,驱动层和应用层完全解耦。驱动层只管采集数据,应用层只管处理数据。中间通过回调函数连接。

我的经验:回调函数里不要做耗时操作。如果应用层处理数据需要时间(比如写 SD 卡),应该在回调里用消息队列或信号量通知一个任务去处理,而不是直接在中断上下文中执行。

3.4 事件驱动机制:用状态机管理复杂流程

回调函数解决了「谁通知谁」的问题,但实际项目中,一个模块可能同时处理多个事件。这时候就需要事件驱动机制。

事件驱动的核心是:模块内部维护一个状态机,外部事件(比如按键按下、数据到达、定时器超时)触发状态切换,状态切换时执行对应的动作。

我整理了一个简单的对比表格,帮你理解轮询和事件驱动的区别:

对比项 轮询方式 事件驱动方式
CPU 占用 高(一直循环检查) 低(有事件才处理)
响应延迟 取决于轮询周期 事件触发即响应
代码结构 线性、容易膨胀 模块化、状态清晰
扩展性 加功能要改主循环 加状态或事件即可
典型场景 简单传感器读取 按键、通信协议、多任务

举个例子,一个按键模块的状态机可以这样设计:

typedef enum {
    KEY_STATE_IDLE,
    KEY_STATE_DEBOUNCE,
    KEY_STATE_PRESSED,
    KEY_STATE_LONG_PRESS,
    KEY_STATE_RELEASED
} key_state_t;

typedef void (*key_event_cb_t)(uint8_t key_id, uint8_t event_type);

typedef struct {
    GPIO_TypeDef *port;
    uint16_t pin;
    key_state_t state;
    uint32_t press_time;
    key_event_cb_t callback;
} key_t;

然后在定时器中断中每 10ms 调用一次状态机处理函数:

void key_process(key_t *key) {
    uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin);
    switch (key->state) {
        case KEY_STATE_IDLE:
            if (level == 0) {  // 按下
                key->state = KEY_STATE_DEBOUNCE;
                key->press_time = HAL_GetTick();
            }
            break;
        case KEY_STATE_DEBOUNCE:
            if (level == 0 && (HAL_GetTick() - key->press_time) > 20) {
                key->state = KEY_STATE_PRESSED;
                if (key->callback) key->callback(key_id, KEY_EVENT_PRESS);
            } else if (level == 1) {
                key->state = KEY_STATE_IDLE;  // 抖动,复位
            }
            break;
        // 其他状态...
    }
}
关键点:状态机 + 回调函数 = 事件驱动的最小实现。状态机管理内部流程,回调函数通知外部模块。两者结合,模块内部和外部的耦合度降到最低。

3.5 总结:解耦三板斧

这一章的内容,我总结成三个核心动作,你可以直接用在项目中:

  1. 接口与实现分离:头文件只放声明,不放实现。调用方只依赖接口,不依赖具体驱动。
  2. 依赖倒置:让高层模块定义接口,低层模块去实现。依赖方向从「高层依赖低层」变成「双方依赖抽象」。
  3. 回调 + 事件驱动:用回调函数实现模块间的通知机制,用状态机管理模块内部的复杂流程。

嗯,这三板斧用好了,你的代码会变得非常「干净」。每个模块各司其职,改一个模块不会牵连其他模块。我在后来的项目中,几乎所有的驱动都按照这个思路来写,维护成本直线下降。

一个小建议:刚开始实践时,不要追求一步到位。可以先从一个小模块开始,比如把按键驱动改成事件驱动模式。跑通了,再逐步推广到其他模块。一口吃不成胖子,解耦也是。