模块化设计基础:高内聚、低耦合原则

好,我们正式开始第二讲。上一章聊了接口定义的重要性,这一章咱们得把地基打牢——模块化设计。

说白了,模块化就是把一个大系统拆成若干个小块。但怎么拆?拆到什么程度?拆完怎么保证它们不乱成一锅粥?这就是高内聚、低耦合要解决的问题。

我刚开始做嵌入式那几年,也写过那种几千行的单文件程序。嗯,现在回想起来,那简直是给自己挖坑。改一个bug,牵出三个新bug。后来带团队了,我定了一条死规矩:任何源文件超过500行,必须拆。

什么是高内聚?

内聚,衡量的是一个模块内部元素之间的关联程度。高内聚意味着模块里的函数、变量、数据结构都围绕同一个职责服务。

核心原则:一个模块只做一件事,并且把它做好。

我举个例子。你写一个温度传感器驱动,里面既包含I2C读写函数,又包含PID控制算法,还混着日志打印——这就是低内聚。正确的做法是:

  • 传感器驱动模块:只负责初始化、读取原始数据、配置寄存器
  • 控制算法模块:只负责PID计算
  • 日志模块:只负责格式化输出

内聚从低到高分七种,我列个表,你对照一下自己的代码:

内聚类型 描述 评价
偶然内聚 几个函数凑一起,只是因为它们在同一文件 ❌ 最差
逻辑内聚 根据外部条件决定执行哪个功能 ❌ 差
时间内聚 因为都在同一时间段执行而放一起 ⚠️ 一般
过程内聚 按处理流程顺序组合 ⚠️ 一般
通信内聚 操作同一数据结构的函数放一起 ✅ 较好
顺序内聚 一个函数的输出是另一个的输入 ✅ 好
功能内聚 所有元素共同完成一个单一功能 ✅ 最佳

我个人习惯,目标就是功能内聚。达不到?至少做到通信内聚或顺序内聚。逻辑内聚和偶然内聚,代码评审时直接打回。

什么是低耦合?

耦合,衡量的是模块之间的依赖程度。低耦合意味着一个模块改了,其他模块不需要跟着改。

耦合也分等级:

耦合类型 表现 评价
内容耦合 一个模块直接修改另一个模块的内部数据 ❌ 最差
公共耦合 多个模块共享全局变量 ❌ 差
控制耦合 传递控制标志,让被调用者决定行为 ⚠️ 一般
印记耦合 传递整个数据结构,但只用其中一部分 ⚠️ 一般
数据耦合 只传递必要的基本类型参数 ✅ 好
消息耦合 通过消息队列或事件驱动通信 ✅ 最佳

避坑指南:我曾经在一个项目里看到,两个模块通过全局结构体传递状态,结果A模块改了结构体字段名,B模块忘了更新,编译不报错,运行直接跑飞。从那以后,我要求团队禁止公共耦合,全局变量必须封装成模块内的静态变量,通过接口函数访问。

模块划分的粒度与边界

拆模块不是拆得越细越好。你想想看,如果每个函数都单独一个文件,那跟没拆有什么区别?反而多了很多接口文件。

我总结了一个粒度判断三问:

  1. 这个模块能否独立测试? 如果能单独写单元测试,粒度合适。
  2. 这个模块能否被替换? 如果换一个实现不影响其他模块,边界清晰。
  3. 这个模块的接口是否稳定? 如果接口经常变,说明边界没划对。

边界划分的常见模式:

  • 按硬件外设划分:UART驱动、SPI驱动、GPIO驱动
  • 按业务功能划分:数据采集、协议解析、命令处理
  • 按抽象层次划分:硬件抽象层(HAL)、中间件层、应用层

下面这张图展示了典型的嵌入式模块划分结构:

嵌入式系统模块划分结构图 应用层 (Application Layer) 用户界面模块 业务逻辑模块 状态管理模块 事件调度模块 中间件层 (Middleware Layer) 协议栈模块 文件系统模块 内存管理模块 日志服务模块 硬件抽象层 (HAL) GPIO驱动模块 UART驱动模块 SPI/I2C驱动模块 定时器驱动模块 各层之间通过明确定义的接口通信,同层模块之间低耦合,层间单向依赖

实战:将单文件程序拆分为模块

光说不练假把式。咱们拿一个实际的例子来拆。

假设你有一个单文件程序 main.c,里面混着LED控制、按键扫描、串口打印、定时器配置。代码大概长这样:

// 单文件程序(拆之前)
#include <stdio.h>
#include <stdint.h>

uint8_t led_state = 0;
uint32_t timer_count = 0;

void gpio_init(void) {
    // 初始化GPIO
}

void uart_send(char *data) {
    // 发送串口数据
}

void led_toggle(void) {
    led_state = !led_state;
    // 硬件操作
}

void key_scan(void) {
    // 扫描按键
    if (/* 按键按下 */) {
        led_toggle();
        uart_send("Key pressed\n");
    }
}

void timer_isr(void) {
    timer_count++;
    if (timer_count >= 1000) {
        led_toggle();
        timer_count = 0;
    }
}

int main(void) {
    gpio_init();
    while(1) {
        key_scan();
    }
    return 0;
}

这个文件有什么问题?

  • 全局变量满天飞,led_state 和 timer_count 谁都能改
  • LED控制、按键扫描、串口打印全耦合在一起
  • 没法单独测试任何一个功能

好,现在我们来拆。我建议分成四个模块:

模块名 头文件 源文件 职责
LED驱动 led.h led.c 初始化、开、关、翻转
按键驱动 key.h key.c 扫描、去抖、事件回调
串口驱动 uart.h uart.c 初始化、发送、接收
定时器驱动 timer.h timer.c 配置、中断处理

拆完之后,led.h 大概长这样:

// led.h
#ifndef LED_H
#define LED_H

#include <stdint.h>

void led_init(void);
void led_on(void);
void led_off(void);
void led_toggle(void);

#endif

led.c 的实现:

// led.c
#include "led.h"

static uint8_t led_state = 0;  // 静态全局变量,外部不可见

void led_init(void) {
    // 硬件初始化
    led_state = 0;
}

void led_on(void) {
    // 硬件操作
    led_state = 1;
}

void led_off(void) {
    // 硬件操作
    led_state = 0;
}

void led_toggle(void) {
    led_state = !led_state;
    // 硬件操作
}

关键点:注意我把 led_state 从全局变量改成了 static 静态变量。这样外部模块无法直接修改它,只能通过 led_on()、led_off() 这些接口操作。这就是信息隐藏——低耦合的核心手段。

同样,key.c 里把按键事件通过回调函数通知上层,而不是直接调用 led_toggle() 和 uart_send():

// key.h
#ifndef KEY_H
#define KEY_H

typedef void (*key_callback_t)(void);

void key_init(void);
void key_register_callback(key_callback_t cb);
void key_scan(void);

#endif

最后 main.c 变得非常清爽:

// main.c(拆之后)
#include "led.h"
#include "key.h"
#include "uart.h"
#include "timer.h"

void on_key_pressed(void) {
    led_toggle();
    uart_send("Key pressed\n");
}

int main(void) {
    led_init();
    key_init();
    uart_init();
    timer_init();
    
    key_register_callback(on_key_pressed);
    
    while(1) {
        key_scan();
    }
    return 0;
}

拆完的效果:

  • 每个模块可以独立测试——写个测试文件,调 led_on() 看引脚电平就行
  • 替换硬件不影响其他模块——换个LED驱动芯片,只改 led.c
  • 接口清晰——头文件就是契约,实现细节全在 .c 里藏着

嗯,这就是模块化设计的实战。你想想看,如果一开始就按这个结构写,后面维护会省多少事?

我曾经接手过一个项目,单文件3000多行,里面光全局变量就50多个。我花了整整两周才拆完,中间还引入了一个回归bug。所以我现在带项目,第一周就定好模块边界和接口,后面只往里填代码,绝不越界。