2. 模块化设计原则:单一职责与接口隔离在嵌入式C中的体现

模块化设计,说白了就是“分而治之”。把一个大系统拆成若干小模块,每个模块各司其职。但怎么拆?拆到什么粒度?这里头有门道。

我早年做车载ECU项目时,就吃过“模块职责不清”的亏。一个.c文件写了三千行,中断、协议解析、IO控制全揉在一起。后来要加个新传感器,改一处崩三处。嗯,从那以后,我老老实实把单一职责和接口隔离这两个原则刻进了编码规范里。

2.1 单一职责原则(SRP)

单一职责原则(Single Responsibility Principle)的核心就一句话:一个模块只做一件事,并且把它做好。在嵌入式C里,这个“模块”可以是一个.c/.h文件,也可以是一个函数。

判断标准:如果你无法用一句话说清这个模块的职责,那它大概率违反了SRP。

2.1.1 函数级别的SRP

先看个反面例子。我接手过一个温控项目,里面有这么个函数:

// 反面教材:一个函数干了三件事
void process_temperature(void)
{
    // 1. 读取ADC值
    uint16_t adc_val = adc_read(ADC_CH_TEMP);
    
    // 2. 计算温度(含滤波和查表)
    static uint16_t filter_buf[4];
    // ... 滑动平均滤波逻辑 ...
    float temp = lookup_table(adc_val);
    
    // 3. 判断是否超温并控制风扇
    if (temp > TEMP_THRESHOLD) {
        gpio_set(FAN_PIN, 1);
    } else {
        gpio_set(FAN_PIN, 0);
    }
}

这个函数干了三件事:采集、计算、控制。测试时很痛苦——我想单独测滤波算法,得先模拟ADC;想测风扇逻辑,又得先跑完整流程。

按SRP重构后:

// 职责1:只负责采集原始数据
uint16_t temp_adc_read(void)
{
    return adc_read(ADC_CH_TEMP);
}

// 职责2:只负责信号处理
float temp_calculate(uint16_t adc_val)
{
    // 滤波 + 查表,纯算法逻辑
    return lookup_table(adc_val);
}

// 职责3:只负责控制决策
void temp_control(float temperature)
{
    gpio_set(FAN_PIN, temperature > TEMP_THRESHOLD);
}

你看,每个函数都能独立测试。我习惯在单元测试里给temp_calculate直接喂ADC值,验证查表精度,不用管硬件。

2.1.2 文件级别的SRP

文件级别的SRP,我建议按“硬件抽象层(HAL)”和“业务逻辑层”来切。举个例子:

文件 职责 依赖
adc_driver.c 封装ADC寄存器操作 MCU手册
temp_sensor.c 温度传感器协议(如DS18B20时序) gpio_driver.c
temp_controller.c 温度控制策略(PID/阈值) temp_sensor.c

每个文件只关心自己的事。换MCU时,只改adc_driver.c,业务代码不动。

我的习惯:每个.c文件不超过200行。超过就拆。函数不超过40行。超过就抽子函数。

2.2 接口隔离原则(ISP)

接口隔离原则(Interface Segregation Principle)说的是:客户端不应该被迫依赖它不使用的接口。在嵌入式C里,接口就是头文件里暴露的函数声明。

你想想看,一个头文件里塞了20个函数声明,但你的模块只需要其中3个。这3个函数改了,你得重新编译;另外17个改了,你也得重新编译。这就是耦合。

2.2.1 头文件拆分

我曾经维护过一个system.h,里面包含了定时器、GPIO、UART、SPI、I2C、RTC等所有外设的接口。每个.c文件都#include "system.h"。结果改个RTC的初始化参数,全工程重新编译——20分钟。

按ISP拆分后:

// timer_interface.h —— 只暴露定时器接口
void timer_init(uint32_t period_us);
void timer_start(void);
void timer_stop(void);
void timer_set_callback(void (*cb)(void));

// gpio_interface.h —— 只暴露GPIO接口
void gpio_set_pin(uint8_t pin, uint8_t level);
uint8_t gpio_read_pin(uint8_t pin);
void gpio_toggle_pin(uint8_t pin);

// uart_interface.h —— 只暴露UART接口
void uart_send_byte(uint8_t data);
uint8_t uart_receive_byte(void);
uint8_t uart_data_available(void);

现在,temp_sensor.c只需要#include "gpio_interface.h"。改UART代码,跟它没关系。

注意:接口文件里不要放结构体定义、宏定义等实现细节。只放函数声明和必要的类型前置声明。这叫“最小暴露原则”。

2.2.2 用回调函数实现接口隔离

嵌入式里有个经典场景:一个模块(比如按键扫描)需要通知另一个模块(比如LED控制)。如果直接调用,就产生了耦合。

我建议用回调函数解耦:

// key_scanner.h —— 按键扫描模块的接口
typedef void (*key_callback_t)(uint8_t key_id, uint8_t event);

void key_scanner_init(key_callback_t callback);
void key_scanner_process(void);

// main.c —— 注册回调,实现解耦
static void on_key_event(uint8_t key_id, uint8_t event)
{
    if (event == KEY_PRESSED) {
        led_control_toggle(key_id);
    }
}

void main(void)
{
    key_scanner_init(on_key_event);
    while (1) {
        key_scanner_process();
    }
}

按键模块不知道LED的存在。它只调用回调。LED模块也不知道按键的存在。它只实现回调。这就是接口隔离——双方只依赖一个函数指针类型,而不是对方的头文件。

2.3 SRP + ISP 的协同效果

这两个原则一起用,效果是1+1>2。我画了张图,你看下它们的关系:

SRP + ISP 协同架构 SRP:模块职责单一 adc_driver.c(只读ADC) temp_sensor.c(只算温度) temp_controller.c(只做控制) ISP:接口最小暴露 adc_interface.h(3个函数) temp_interface.h(2个函数) ctrl_interface.h(1个函数) 效果:低耦合高内聚 可独立测试 可单独替换 编译隔离 实践要点 • SRP决定“拆成几个模块”,ISP决定“每个模块暴露多少接口” • 先按SRP拆模块,再按ISP设计头文件——顺序不能反

2.4 避坑指南

最后,分享几个我踩过的坑:

  • 过度拆分:我曾经把一个GPIO操作拆成4个文件——初始化、读、写、中断。结果接口调用链太长,性能下降。SRP不是越细越好,粒度以“一个文件能独立完成一个逻辑功能”为准。
  • 接口膨胀:接口隔离不是让你给每个函数单独建一个头文件。那样管理成本太高。我一般按“外设类型”或“业务功能”分组,每组一个接口头文件,函数不超过5个。
  • 忽略编译依赖:接口隔离最大的收益是减少编译时间。记得用#ifndef防止重复包含,用前置声明代替#include。

快速自检:改一个模块的.c文件,如果导致其他模块重新编译超过3个,说明接口隔离没做好。