模块接口设计:高内聚低耦合、接口与实现分离、模块间通信方式
做嵌入式开发久了,你会发现一个有意思的现象:代码写得烂不烂,往往不是看语法多熟练,而是看模块划分得清不清晰。我见过太多人,一个 .c 文件写两三千行,全局变量满天飞,改一个功能要动七八个文件。嗯,这种项目,维护起来真的想哭。
今天我们就聊聊模块接口设计的三个核心问题:高内聚低耦合、接口与实现分离、模块间通信方式。说白了,就是怎么让你的代码既好改又好用。
一、高内聚与低耦合:模块设计的黄金法则
先问个问题:你写一个温度传感器驱动,是把所有跟温度相关的函数都放在一个文件里,还是把读寄存器、计算温度、数据校验分别扔到不同文件里?
我个人习惯是:一个模块只做一件事,并且把这件事做好。这就是高内聚。
举个例子,一个典型的温度传感器模块:
// temp_sensor.h
#ifndef TEMP_SENSOR_H
#define TEMP_SENSOR_H
#include <stdint.h>
// 初始化传感器
int temp_sensor_init(void);
// 读取当前温度(单位:摄氏度)
float temp_sensor_read(void);
// 获取传感器状态
int temp_sensor_get_status(void);
#endif
// temp_sensor.c
#include "temp_sensor.h"
#include "i2c_driver.h" // 依赖底层I2C驱动
#define SENSOR_ADDR 0x48
#define TEMP_REG 0x00
static int sensor_initialized = 0;
int temp_sensor_init(void) {
// 初始化I2C总线
if (i2c_init() != 0) {
return -1;
}
sensor_initialized = 1;
return 0;
}
float temp_sensor_read(void) {
uint8_t buf[2];
if (!sensor_initialized) return -273.15f;
i2c_read(SENSOR_ADDR, TEMP_REG, buf, 2);
// 假设是12位精度,左对齐
int16_t raw = (buf[0] << 8) | buf[1];
return raw * 0.0625f;
}
int temp_sensor_get_status(void) {
return sensor_initialized ? 1 : 0;
}
你看,这个模块只做温度传感器相关的事。它不关心I2C怎么实现的,也不管数据最终显示在哪里。这就是高内聚。
那低耦合呢?模块之间依赖越少越好。我在项目中遇到过最头疼的事:改了一个底层驱动,结果上层三个模块都要跟着改。这就是耦合太紧的后果。
高内聚低耦合的核心思想:
- 一个模块内部的功能要紧密相关(高内聚)
- 模块之间的依赖要尽可能少(低耦合)
- 修改一个模块,尽量不影响其他模块
二、接口与实现分离:.h 文件的艺术
很多新手写代码,喜欢把函数定义直接写在 .h 文件里。你想想看,这样做的后果是什么?每次改实现,所有包含这个头文件的 .c 文件都要重新编译。项目大了,编译一次够你喝杯咖啡的。
正确的做法是:.h 文件只放声明,.c 文件放实现。这就是接口与实现分离。
我建议你遵循这几个原则:
- .h 文件只暴露必要的接口——内部函数用 static 隐藏
- 结构体定义尽量放在 .c 文件——用不透明指针(opaque pointer)对外暴露
- 全局变量永远不要出现在 .h 文件里——用 getter/setter 替代
来看一个不透明指针的例子:
// led_controller.h
#ifndef LED_CONTROLLER_H
#define LED_CONTROLLER_H
// 不透明指针:外部不知道内部结构
typedef struct led_controller* led_handle_t;
// 创建LED控制器
led_handle_t led_create(int pin, int active_high);
// 设置LED状态
void led_set(led_handle_t handle, int on);
// 销毁LED控制器
void led_destroy(led_handle_t handle);
#endif
// led_controller.c
#include "led_controller.h"
#include "gpio_driver.h"
struct led_controller {
int pin;
int active_high;
int current_state;
};
led_handle_t led_create(int pin, int active_high) {
led_handle_t led = malloc(sizeof(struct led_controller));
if (led) {
led->pin = pin;
led->active_high = active_high;
led->current_state = 0;
gpio_set_mode(pin, GPIO_OUTPUT);
}
return led;
}
void led_set(led_handle_t handle, int on) {
if (handle) {
handle->current_state = on;
gpio_write(handle->pin,
on ? handle->active_high : !handle->active_high);
}
}
void led_destroy(led_handle_t handle) {
if (handle) free(handle);
}
这样做的好处很明显:外部代码根本不知道 led_controller 内部有什么成员。你想改内部实现,只要接口不变,外部代码一行都不用动。
小技巧:.h 文件里尽量用 #ifndef 防止重复包含。我习惯在文件开头加一句注释,说明这个模块的职责和依赖关系。
三、模块间通信方式:三种主流方案
模块之间总要交换数据吧?怎么通信才合理?我总结下来,嵌入式C语言里就三种主流方式:
| 通信方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 函数调用 | 同步操作,模块间有明确调用关系 | 简单直接,效率高 | 耦合较紧,不适合跨任务 |
| 回调函数 | 事件驱动,异步通知 | 解耦好,灵活 | 回调嵌套容易混乱 |
| 消息队列 | 多任务/多线程环境 | 完全解耦,支持异步 | 需要操作系统支持,有开销 |
函数调用是最常用的方式。比如温度传感器模块调用I2C驱动:
// 模块A调用模块B
int result = i2c_write(addr, reg, data, len);
这种方式简单,但要注意:不要跨层调用。比如应用层直接调用硬件寄存器操作,那就乱套了。
回调函数是我个人比较喜欢的方式。它能让模块之间保持松耦合。我曾经在一个按键驱动里用过:
// button_driver.h
typedef void (*button_callback_t)(int key_code);
void button_register_callback(button_callback_t cb);
void button_process(void); // 在中断或主循环中调用
// main.c
void on_key_pressed(int key_code) {
if (key_code == KEY_UP) {
volume_up();
}
}
int main() {
button_register_callback(on_key_pressed);
while(1) {
button_process();
}
}
你看,按键驱动不知道谁会处理按键事件,应用层也不知道按键怎么检测的。这就是解耦。
消息队列适合更复杂的场景。比如一个传感器采集数据,另一个模块负责显示,中间用队列传递:
// 消息定义
typedef struct {
uint8_t msg_id;
float value;
} sensor_msg_t;
// 发送方
void sensor_task(void) {
sensor_msg_t msg = {.msg_id = 1, .value = 25.3f};
queue_send(&sensor_queue, &msg);
}
// 接收方
void display_task(void) {
sensor_msg_t msg;
if (queue_receive(&sensor_queue, &msg, 100) == 0) {
update_display(msg.value);
}
}
注意:消息队列在裸机环境下需要自己实现环形缓冲区。如果用了RTOS,一般都有现成的队列API。我曾经在STM32上自己手写过队列,踩过不少坑——比如队列满时丢数据、中断里用队列导致死锁。嗯,这些都要小心。
四、知识体系总览
说了这么多,我画了一张图帮你理清思路:
五、避坑指南
最后,分享几个我踩过的坑:
- 我曾经在一个项目里把结构体定义全放在 .h 文件里,结果改了一个成员,所有模块都要重新编译。后来改成不透明指针,编译时间从3分钟降到30秒。
- 我曾经在回调函数里直接调用了阻塞函数,导致整个系统卡死。记住:回调函数里不要做耗时操作,最好只发个信号或扔个消息。
- 我曾经为了追求低耦合,把每个函数都写成回调,结果代码跳来跳去,调试时根本找不到调用链。适度解耦就好,别过度设计。
嗯,模块接口设计这件事,说白了就是让代码像积木一样,能拼能拆。你写的时候多花点心思,后面维护的人会感谢你的——说不定那个「后面的人」就是三个月后的你自己。
公众号:蓝海资料掘金营,微信deep3321