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。我画了张图,你看下它们的关系:
2.4 避坑指南
最后,分享几个我踩过的坑:
- 过度拆分:我曾经把一个GPIO操作拆成4个文件——初始化、读、写、中断。结果接口调用链太长,性能下降。SRP不是越细越好,粒度以“一个文件能独立完成一个逻辑功能”为准。
- 接口膨胀:接口隔离不是让你给每个函数单独建一个头文件。那样管理成本太高。我一般按“外设类型”或“业务功能”分组,每组一个接口头文件,函数不超过5个。
- 忽略编译依赖:接口隔离最大的收益是减少编译时间。记得用
#ifndef防止重复包含,用前置声明代替#include。
快速自检:改一个模块的.c文件,如果导致其他模块重新编译超过3个,说明接口隔离没做好。