第2章 分层架构核心思想:高内聚低耦合、模块化设计、接口与实现分离
好,咱们直接进入正题。很多工程师写单片机代码,一开始都是「需求来了,开干」。一个 main 函数里塞几百行,全局变量满天飞。嗯,我当年也这么干过。结果呢?改一个功能,崩三个地方。调试到凌晨三点,最后发现是某个全局变量被中断服务程序改了。
说白了,这就是架构没想清楚。这一章,我就把分层架构的核心思想掰开揉碎讲给你听。
2.1 高内聚:一个模块只干一件事
什么叫高内聚?我个人的理解是:一个模块内部的元素,应该紧密围绕同一个职责。比如你写一个 LED 驱动模块,它只负责控制 LED 的亮灭、闪烁、PWM 调光。它不应该去管按键扫描,更不应该去处理网络协议栈。
内聚程度从低到高(我常用的评判标准):
- 偶然内聚:几个不相关的功能硬凑在一起。比如一个函数叫 `do_stuff()`,里面既算 CRC 又翻转 GPIO。这种代码我见一个拆一个。
- 功能内聚:模块内所有元素共同完成一个单一功能。比如 `led_set_brightness(uint8_t level)`,只做一件事:设置亮度。这是理想状态。
我在项目中遇到过最典型的反面教材:一个 `system.c` 文件,里面包含了延时函数、串口打印、看门狗喂狗、甚至还有 EEPROM 读写。后来要换 MCU,光剥离这个文件就花了两天。你想想看,如果一开始就按功能内聚拆开,换平台时只需要替换底层驱动文件,上层逻辑完全不用动。
2.2 低耦合:模块之间只通过接口说话
低耦合,就是模块之间的依赖关系要尽可能弱。一个模块改了内部实现,其他模块不应该受影响。
耦合的几种常见形式(按危害程度排序):
| 耦合类型 | 表现 | 我的评价 |
|---|---|---|
| 内容耦合 | 一个模块直接修改另一个模块的内部数据 | 灾难。我曾经见过一个模块直接 `extern` 了另一个模块的静态变量来改值 |
| 公共耦合 | 多个模块共享同一个全局变量 | 能不用就不用。实在要用,加访问锁 |
| 控制耦合 | 传递控制标志让模块改变行为 | 偶尔可以,但尽量用回调或状态机替代 |
| 数据耦合 | 只通过参数传递数据 | 这是理想状态,尽量追求 |
我曾经踩过的坑: 在一个电机控制项目中,我让 `motor.c` 直接读取了 `adc.c` 里的全局变量 `adc_raw_value`。后来 ADC 模块改了采样率,变量名也换了。结果电机控制逻辑全乱套。从那以后,我强制要求:所有模块间通信必须通过接口函数,禁止直接访问其他模块的全局变量。
2.3 模块化设计:拆分的艺术
模块化不是把代码随便分几个文件就完事了。它有一套方法论。
我一般按这个步骤来拆分:
- 识别职责边界:把系统功能画成框图,每个框就是一个潜在模块。
- 定义接口:每个模块对外暴露什么函数、什么数据结构。
- 隐藏实现:模块内部的变量、辅助函数全部 `static`。
- 单向依赖:上层模块依赖下层模块,下层模块绝不反向依赖上层。
举个例子,一个典型的单片机项目模块划分:
project/
├── app/ # 应用层
│ ├── task_led.c
│ ├── task_key.c
│ └── task_comm.c
├── hal/ # 硬件抽象层
│ ├── hal_gpio.c
│ ├── hal_uart.c
│ └── hal_timer.c
├── mcal/ # 微控制器抽象层
│ ├── mcal_gpio_stm32.c
│ └── mcal_uart_stm32.c
├── common/ # 公共模块
│ ├── ring_buffer.c
│ ├── crc.c
│ └── assert.c
└── main.c
我的个人习惯: 每个模块的 .h 文件只放接口声明,不放实现细节。.c 文件里的静态函数和变量,用 `static` 关键字锁死。这样别人看你的头文件,一眼就知道这个模块能干什么,不需要关心它怎么干的。
2.4 接口与实现分离:面向接口编程
这是分层架构里最核心的一环。接口是契约,实现是细节。只要契约不变,实现随便换。
在 C 语言里,接口通常用结构体函数指针来实现。我举个例子:
// interface.h - 定义一个串口驱动接口
typedef struct {
void (*init)(uint32_t baud);
void (*send)(uint8_t *data, uint16_t len);
uint16_t (*receive)(uint8_t *buf, uint16_t max_len);
} uart_driver_t;
// 外部声明两个实现
extern uart_driver_t uart_stm32;
extern uart_driver_t uart_esp32;
然后在应用层,你只需要:
// app_comm.c
#include "interface.h"
static uart_driver_t *drv;
void comm_init(uart_driver_t *driver) {
drv = driver;
drv->init(115200);
}
void comm_send_data(uint8_t *data, uint16_t len) {
drv->send(data, len);
}
你看,应用层根本不知道底层是 STM32 还是 ESP32。换平台时,只需要在 main 函数里把 `comm_init(&uart_stm32)` 改成 `comm_init(&uart_esp32)` 就行了。
为什么这样做? 因为硬件是会变的。你今天用 STM32F103,明天可能换成 GD32,后天可能用沁恒的 CH32。如果代码里到处是 `USART1->DR` 这种寄存器操作,换 MCU 等于重写。但如果你面向接口编程,换 MCU 只需要重新实现一套 HAL 接口。
2.5 从裸机到 RTOS:架构的演进
很多初学者觉得:裸机就是简单,RTOS 就是复杂。其实不是。裸机也可以有很好的架构,RTOS 也可能写得一团糟。
我画了一张图,展示架构的演进路径:
我个人建议:不要一上来就上 RTOS。先学会在裸机上把架构做好。等你发现裸机确实搞不定复杂的任务调度了,再引入 RTOS。那时候你会发现,RTOS 只是把你的模块化架构里的「调度器」换成了一个更强大的版本,其他设计原则完全不变。
我的经验: 一个项目是否需要用 RTOS,我一般看三个指标:
- 是否有超过 5 个需要独立运行的任务?
- 是否有严格的时间约束(比如 1ms 内必须响应)?
- 是否有多个任务需要共享资源(比如同一个串口)?
如果三个都是「否」,裸机+状态机完全够用。如果有一个「是」,可以考虑 RTOS。
2.6 小结:架构是设计出来的,不是写出来的
这一章的内容,说白了就是三句话:
- 高内聚:一个模块只干一件事,干好一件事。
- 低耦合:模块之间只通过接口说话,别互相扒衣服看内部。
- 接口与实现分离:面向接口编程,而不是面向寄存器编程。