模块化设计原则:单一职责在嵌入式中的落地

模块化设计,说白了就是「拆」。但怎么拆,拆到什么粒度,才是真功夫。我见过太多项目,一开始图省事,把所有功能塞进一个文件里,结果后期改一个 bug 牵出一串问题。嗯,今天我们就聊聊模块化最核心的一条原则——单一职责原则,以及它在嵌入式里到底怎么落地。

1. 单一职责原则:一个模块只做一件事

单一职责原则(SRP)听起来很简单:一个模块、一个函数、一个类,只负责一个功能点。但实际做起来,很多人会跑偏。

举个例子。你写一个温度传感器驱动,它本该只负责「读取温度值」。但有人顺手把数据滤波、报警判断、甚至日志打印都塞进去了。结果呢?换一个传感器型号,整个文件都得重写。

核心判断标准:如果一个模块有超过一个「变化原因」,它就违反了单一职责。

我在项目中遇到过这样一个案例:一个 UART 驱动文件,既管数据收发,又管协议解析,还管电源管理。后来要换一个低功耗 MCU,电源管理逻辑全变了,结果收发和协议解析也跟着遭殃。这就是典型的「牵一发动全身」。

2. 嵌入式落地的三个层次

单一职责在嵌入式里,可以从三个层次去落地。我习惯把它们叫做:模块层、接口层、实现层。

层次 职责 典型文件
模块层 定义功能边界,一个 .c/.h 只做一件事 sensor.c, led.c, timer.c
接口层 暴露抽象接口,隐藏实现细节 sensor_if.h, led_if.h
实现层 具体硬件操作,可替换、可测试 sensor_stm32.c, sensor_esp32.c

你想想看,如果每个模块只做一件事,那么当需求变化时,你只需要改一个模块。其他模块根本不用动。这就是低耦合的起点。

3. 接口与实现的分离:真正的解耦

光有单一职责还不够。模块之间怎么通信?直接调用函数?那耦合度还是高。真正的做法是:接口与实现分离。

接口,就是模块对外承诺的「我能做什么」。实现,就是「我怎么做到的」。两者分开,你才能换实现而不影响调用者。

我的习惯:接口头文件里只放函数声明和抽象结构体,不放任何硬件寄存器、宏定义、全局变量。调用者只看接口,不看实现。

来看一个实际例子。这是一个 LED 控制模块的接口定义:

// led_if.h —— 接口层,只暴露抽象
#ifndef LED_IF_H
#define LED_IF_H

#include <stdint.h>

typedef enum {
    LED_OFF = 0,
    LED_ON,
    LED_BLINK_SLOW,
    LED_BLINK_FAST
} led_state_t;

typedef struct {
    void (*set)(uint8_t id, led_state_t state);
    led_state_t (*get)(uint8_t id);
} led_ops_t;

void led_register_ops(const led_ops_t *ops);
void led_set(uint8_t id, led_state_t state);
led_state_t led_get(uint8_t id);

#endif

然后是实现层,针对不同硬件:

// led_stm32.c —— 实现层,STM32 版本
#include "led_if.h"
#include "stm32_gpio.h"

static void stm32_set(uint8_t id, led_state_t state) {
    // 操作 STM32 的 GPIO 寄存器
    switch (state) {
        case LED_ON:    GPIO_SetBits(GPIOB, 1 << id); break;
        case LED_OFF:   GPIO_ResetBits(GPIOB, 1 << id); break;
        default: break;
    }
}

static led_state_t stm32_get(uint8_t id) {
    return (GPIO_ReadOutputDataBit(GPIOB, 1 << id)) ? LED_ON : LED_OFF;
}

static const led_ops_t stm32_ops = {
    .set = stm32_set,
    .get = stm32_get
};

void led_hw_init(void) {
    led_register_ops(&stm32_ops);
}

看到了吗?上层应用调用 led_set() 时,根本不知道底层是 STM32 还是 ESP32。换平台?只要重新实现 led_ops_t 里的两个函数就行。其他代码一行不改。

我曾经踩过的坑:接口头文件里不小心暴露了一个硬件宏定义,结果换平台时,所有包含这个头文件的模块都要重新编译。后来我定了一条铁律:接口头文件里只放纯 C 抽象,任何硬件相关的东西都不准出现。

4. 知识结构图:单一职责与接口分离的核心逻辑

下面这张图,是我自己总结的模块化设计核心逻辑。你可以把它当作一个检查清单:

单一职责与接口分离核心逻辑 单一职责 + 接口分离 核心逻辑 模块划分:每个 .c/.h 只做一件事 接口定义:只暴露抽象函数声明和结构体 实现 A:STM32 硬件操作 实现 B:ESP32 应用层:只调用接口,不关心实现 变化原因只有一个 换实现不改调用方 接口稳定 实现可替换

5. 避坑指南:我踩过的三个坑

光讲理论不够,我把自己踩过的坑列出来,你遇到了可以绕开:

  • 坑一:接口头文件里放全局变量。我曾经在一个接口头文件里定义了一个全局状态变量,结果两个模块同时修改它,调试了三天才发现。后来我规定:接口头文件里只放函数声明和 const 常量,不放变量。
  • 坑二:一个 .c 文件太长。我见过一个驱动文件写了 3000 行,里面既有 I2C 通信,又有数据解析,还有错误处理。后来我把它拆成 5 个文件,每个文件不超过 300 行。维护成本直线下降。
  • 坑三:接口参数太具体。比如一个函数参数直接传 GPIO 端口号,换平台就废了。应该传抽象的设备 ID,让实现层自己去映射。

我的检查清单:每次写完一个模块,问自己三个问题——1. 这个模块的变化原因有几个?2. 换一个硬件平台,要改几个文件?3. 接口头文件里有没有硬件相关的东西?如果答案不理想,就重构。

6. 小结

单一职责原则,说白了就是「各司其职」。接口与实现分离,就是「各管各的」。这两条做到了,你的代码就有了模块化的骨架。剩下的,就是在这个骨架上填充血肉。

嗯,记住一句话:好的模块化设计,是改一个功能时,你只需要改一个文件。 如果做不到,说明你的模块划分还不够细,或者接口还不够抽象。