1. 模块化设计哲学:为什么需要模块化?

做嵌入式开发久了,你会发现一个规律:代码量越大,维护成本越高。我早年带团队时接过一个项目,前任工程师把所有功能都塞在一个 .c 文件里,足足 8000 行。改一个定时器参数,要翻十几处宏定义;加一个新传感器,得从头读到尾才能找到挂载点。那段时间,团队几乎每周都在修 bug,修完一个又冒出来两个。

后来我痛定思痛,把所有功能拆成独立模块。结果呢?开发周期缩短了 40%,bug 率下降了 60%。模块化不是花架子,它是实打实的生产力。

1.1 为什么需要模块化?

说白了,模块化就是把一个大问题拆成若干个小问题,每个小问题独立解决。你想想看,一个 10 万行的工程,如果拆成 100 个模块,每个模块平均 1000 行。调试时你只需要关注当前模块,不用被其他模块的细节干扰。

我个人习惯把模块化的好处归纳为四点:

维度 说明 我踩过的坑
可维护性 修改一个模块不影响其他模块 曾经改了一个全局变量,导致三个驱动同时崩溃
可复用性 模块可以在不同项目间直接移植 同一个 I2C 驱动写了五遍,每次都是复制粘贴改参数
可测试性 可以单独测试每个模块的功能 整系统联调时,定位一个问题要花三天
并行开发 多人同时开发不同模块,互不干扰 两个人改同一个文件,合并时冲突 200 处

核心观点:模块化的本质是控制复杂度。人的大脑一次只能处理 7±2 个信息块,模块化就是把信息块的数量控制在这个范围内。

1.2 高内聚低耦合原则

这是模块化设计的黄金法则。我见过太多人把模块化理解成「把代码分文件」,结果分出来的模块之间互相调用、全局变量满天飞。那叫「物理拆分」,不叫「模块化」。

高内聚:一个模块内部的元素应该紧密相关,共同完成一个明确的功能。比如一个 UART 驱动模块,它应该只做 UART 相关的事:初始化、发送、接收、中断处理。不要把 LED 闪烁的逻辑也塞进去。

低耦合:模块之间的依赖关系要尽可能弱。A 模块的修改,不应该导致 B 模块也要跟着改。我常用的判断标准是:能不能在不看其他模块代码的情况下,独立理解当前模块的逻辑?

我曾经接手过一个项目,模块 A 直接读取模块 B 的内部结构体成员。后来 B 的结构体改了字段名,A 编译直接报错。这就是典型的高耦合——两个模块绑死在一起,改一个就得改另一个。

我的经验:判断耦合度有个简单方法——看模块的接口函数数量。如果一个模块对外暴露了超过 10 个函数,大概率耦合过高。好的模块接口通常控制在 3~5 个。

1.3 接口隔离的核心理念

接口隔离,说白了就是「不要让你不需要的东西」。一个模块只暴露它必须暴露的接口,隐藏所有内部实现细节。

我习惯用「契约」来理解接口:模块 A 和模块 B 之间通过接口达成一个契约。A 承诺「只要你调用这个函数,传入这些参数,我就返回这些结果」。至于 A 内部是用中断轮询还是 DMA,B 完全不需要知道。

接口隔离的好处很明显:

  • 降低依赖风险:内部实现变了,只要接口不变,调用方就不用改
  • 提高可替换性:可以随时换掉底层实现,比如从软件模拟 I2C 换成硬件 I2C
  • 简化测试:可以 mock 掉接口,单独测试调用方的逻辑

注意:接口隔离不是「越少越好」。接口太少会导致功能不足,调用方不得不绕过接口直接操作内部数据。我见过有人为了「极致隔离」,一个模块只暴露一个 init() 函数,结果所有配置都通过全局变量传递——这比不隔离更糟糕。

1.4 模块化设计框架图

下面这张图展示了我常用的模块化分层结构。你可以看到,每一层只通过接口与相邻层通信,同层模块之间不直接调用。

嵌入式模块化分层架构 应用层(Application Layer) 任务调度 · 业务逻辑 · 状态机 服务层(Service Layer) 协议栈 · 数据管理 · 日志系统 · 参数存储 硬件抽象层(HAL) GPIO · UART · I2C · SPI · ADC · Timer 驱动层(Driver Layer) 寄存器操作 · 中断处理 · DMA 传输 接口隔离:每层只通过定义好的接口与相邻层通信,同层模块之间不直接调用

1.5 接口隔离的代码示例

光说理论不够,我拿一个实际例子来说明。下面是一个 I2C 驱动模块的接口设计,我刻意把「接口」和「实现」分开:

/* i2c_driver.h —— 接口头文件 */
#ifndef I2C_DRIVER_H
#define I2C_DRIVER_H

#include <stdint.h>

/* 模块初始化 */
int i2c_init(uint32_t bus_speed_hz);

/* 写操作 */
int i2c_write(uint8_t dev_addr, uint8_t reg_addr, 
              const uint8_t *data, uint16_t len);

/* 读操作 */
int i2c_read(uint8_t dev_addr, uint8_t reg_addr, 
             uint8_t *buffer, uint16_t len);

/* 模块去初始化 */
void i2c_deinit(void);

#endif /* I2C_DRIVER_H */
/* i2c_driver.c —— 实现文件 */
#include "i2c_driver.h"

/* 内部结构体,对外完全隐藏 */
typedef struct {
    uint32_t base_addr;
    uint32_t speed;
    uint8_t initialized;
} i2c_private_t;

static i2c_private_t i2c_inst = {0};

int i2c_init(uint32_t bus_speed_hz) {
    if (i2c_inst.initialized) return -1;
    /* 配置寄存器、设置时钟、使能外设 */
    i2c_inst.base_addr = I2C1_BASE;
    i2c_inst.speed = bus_speed_hz;
    i2c_inst.initialized = 1;
    return 0;
}

int i2c_write(uint8_t dev_addr, uint8_t reg_addr,
              const uint8_t *data, uint16_t len) {
    if (!i2c_inst.initialized) return -1;
    /* 发送起始条件、设备地址、寄存器地址、数据、停止条件 */
    return 0;
}

int i2c_read(uint8_t dev_addr, uint8_t reg_addr,
             uint8_t *buffer, uint16_t len) {
    if (!i2c_inst.initialized) return -1;
    /* 发送起始条件、设备地址、寄存器地址、重复起始、读取数据 */
    return 0;
}

void i2c_deinit(void) {
    if (!i2c_inst.initialized) return;
    /* 关闭外设时钟、复位寄存器 */
    i2c_inst.initialized = 0;
}

关键点:注意看,头文件里只暴露了 4 个函数。内部结构体 i2c_private_t 完全隐藏在 .c 文件中,调用方根本不知道它的存在。这就是接口隔离——你只需要知道「怎么用」,不需要知道「怎么实现」。

1.6 模块化设计的常见误区

我带过不少新人,发现大家容易犯这几个错误:

  • 过度拆分:一个文件只有 50 行代码,函数只有 3 行。拆得太碎反而增加阅读负担。我一般控制每个模块 200~500 行。
  • 接口膨胀:一个模块暴露 20 多个函数,调用方根本记不住。好的接口设计应该让调用方「看一眼就知道怎么用」。
  • 忽略错误处理:接口函数没有返回值检查,或者所有错误都返回 -1。我习惯为每个模块定义专用的错误码枚举。
  • 全局变量泛滥:模块之间通过全局变量传数据,绕过了接口。这是最隐蔽的耦合方式,比函数调用更难发现。

避坑指南:我曾经在一个项目中,模块 A 和模块 B 通过一个全局结构体共享数据。后来需求变更,需要加一个字段,结果 A 和 B 都要改,还牵连了模块 C 和 D。从那以后,我定了一条铁律:模块之间只通过函数接口通信,禁止直接访问其他模块的全局变量。

1.7 模块化设计的评估指标

怎么判断你的模块化设计好不好?我常用这几个指标:

指标 计算公式 理想值 我的经验
内聚度 模块内部函数调用次数 / 模块总函数数 > 0.7 低于 0.5 说明模块内部关联太弱
耦合度 模块间接口调用次数 / 模块总函数数 < 0.3 高于 0.5 说明耦合过紧
接口数量 头文件中声明的函数数 3~8 个 超过 10 个就要考虑拆分
可移植性 移植到新平台需要修改的代码行数 < 50 行 超过 100 行说明硬件抽象没做好

嗯,这些指标不是绝对的,但可以作为参考。我每次做完模块设计,都会拿这张表过一遍,基本能发现 80% 的问题。

最后提醒一句:模块化设计不是一蹴而就的。我见过有人花两周时间设计模块接口,结果写代码时发现根本用不上。我的习惯是:先写一个最小可用的版本,跑通后再重构。模块化是迭代出来的,不是设计出来的。