接手一个老项目,新换的国产传感器寄存器接口和原框架对不上,改一处牵动半个驱动层;多任务抢同一根 I2C 总线......
这些场景的共同特征是:代码本身能跑,但"结构"扛不住了。功能没问题,可扩展性、可维护性已经透支。
GoF 把这类"如何把类/对象组合成更大的结构"的问题,归到结构型模式(Structural Patterns,共 7 种) 。核心就是:在不改原有代码的前提下,搭出更灵活的骨架。
做嵌入式 C 的同学常觉得"设计模式是 Java/C++ 的玩意儿"。其实不然------Linux 内核的 device/driver 模型、FreeRTOS 的设备 API、RT-Thread 的 rt_device 框架,全是结构型模式的教科书级实现。区别只在于:C 语言用结构体 + 函数指针来模拟"接口与多态"。
一、结构型模式总览
结构型模式表如下:
| 模式 | 一句话本质 | 嵌入式典型场景 | 解决的痛点 |
|---|---|---|---|
| 适配器 Adapter | 转换接口,让不兼容的能合作 | 新传感器适配旧框架、第三方 SDK 封装 | 接口不匹配 |
| 桥接 Bridge | 抽象与实现两个维度各自独立变化 | LCD 屏幕 × 总线、图形 API × 底层总线 | 组合爆炸、类爆炸 |
| 组合 Composite | 树形"部分-整体",统一处理叶子与分支 | 多级菜单树、文件系统 | 节点类型多、遍历逻辑分支 |
| 装饰器 Decorator | 动态套娃式叠加职责 | 数据流加 CRC/加密/日志 | 继承爆炸、功能要灵活拼装 |
| 外观 Facade | 给复杂子系统一个简单入口 | BSP 对外 API、AT 指令模组 | 子系统太复杂、调用方耦合 |
| 享元 Flyweight | 共享细粒度对象省内存 | 字模共享、棋盘格、图标缓存 | RAM 吃紧、对象海量重复 |
| 代理 Proxy | 替身控制对原对象的访问 | I2C 互斥、Flash 写保护、懒加载 | 访问控制、线程安全 |
选择:接口对不上→适配器;两维组合爆炸→桥接;树形统一处理→组合;功能要拼装→装饰器;子系统要简化→外观;海量对象要省内存→享元;访问要管控→代理。
二、统一代码风格:C 语言怎么做"面向对象"
7 段代码都遵循同一种风格------这也是 Linux 驱动模型、RT-Thread 设备框架的核心套路。
c
/* "接口"= 函数指针虚表(ops);"对象"= 结构体,持有 ops + 私有上下文 ctx */
typedef struct device device_t;
typedef struct {
int (*read)(device_t *self, uint8_t *buf, uint16_t len); /* 虚函数 */
int (*ioctl)(device_t *self, uint32_t cmd, void *arg);
} device_ops_t;
struct device {
const device_ops_t *ops; /* 虚函数表(指向 const,常驻 Flash) */
void *ctx; /* 具体实现的私有数据(适配器/装饰器/代理各塞各的) */
};
/* 多态调用入口:上层只认 device_t,不关心背后是谁 */
static inline int dev_read(device_t *d, uint8_t *buf, uint16_t len)
{
return d->ops->read(d, buf); /* 通过 ops 分发到具体实现 */
}
记住:
ops指向const函数指针表,多个同类对象共享一份表,省 RAM(这本身就是享元思想)。ctx装私有数据,适配器装被适配者的参数、装饰器装内层对象、代理装真实对象+锁。self作为第一个参数传回 ,相当于 C++ 的this,让实现能拿到自己的上下文。
三、适配器模式(Adapter Pattern)
说明
把一个类的接口转换成客户端期望的另一个接口,让原本不兼容的类能一起工作。只做接口转换,不掺业务逻辑。
用途
- 新换的传感器芯片是"读寄存器"式接口,旧框架期望的是"直接读温度值"------中间加个适配器。
- 第三方 SDK 风格各异,统一封装成自家接口。
C 代码
c
#include <stdint.h>
#include <stdbool.h>
/* ====== 目标接口(Target):上层期望的统一传感器抽象 ====== */
typedef struct sensor_dev sensor_dev_t;
typedef struct {
bool (*read_temp)(sensor_dev_t *self, float *out_c);
bool (*read_humi)(sensor_dev_t *self, float *out_rh);
} sensor_ops_t;
struct sensor_dev {
const sensor_ops_t *ops;
void *ctx;
};
/* ====== 被适配者(Adaptee):新芯片 BSP 自带的"寄存器式"接口 ====== */
/* 这俩是新芯片厂商给的,接口和统一抽象对不上 */
extern uint16_t NewChip_ReadReg(uint8_t dev_addr, uint8_t reg);
#define NEWCHIP_REG_TEMP 0x00U
#define NEWCHIP_REG_HUMI 0x02U
#define NEWCHIP_TEMP_SCALE 0.01f
#define NEWCHIP_TEMP_BIAS 40.0f
/* 适配器的私有上下文:记录被适配者需要的参数 */
typedef struct {
uint8_t dev_addr;
} newchip_ctx_t;
/* ====== 适配器(Adapter):把"读寄存器"翻译成"读温湿度值" ====== */
static bool newchip_read_temp(sensor_dev_t *self, float *out_c)
{
newchip_ctx_t *ctx = (newchip_ctx_t *)self->ctx; /* 取回自己的上下文 */
uint16_t raw = NewChip_ReadReg(ctx->dev_addr, NEWCHIP_REG_TEMP);
*out_c = (float)raw * NEWCHIP_TEMP_SCALE - NEWCHIP_TEMP_BIAS; /* 原始值 → 摄氏度 */
return true;
}
static bool newchip_read_humi(sensor_dev_t *self, float *out_rh)
{
newchip_ctx_t *ctx = (newchip_ctx_t *)self->ctx;
uint16_t raw = NewChip_ReadReg(ctx->dev_addr, NEWCHIP_REG_HUMI);
*out_rh = (float)raw * NEWCHIP_TEMP_SCALE;
return true;
}
/* 虚表:常量,全局唯一一份 */
static const sensor_ops_t g_newchip_ops = {
.read_temp = newchip_read_temp,
.read_humi = newchip_read_humi,
};
/* 适配器构造函数:返回一个"看起来像统一传感器"的设备 */
sensor_dev_t NewChip_Adapt(uint8_t dev_addr)
{
static newchip_ctx_t ctx; /* 实际项目按需静态分配或动态分配 */
ctx.dev_addr = dev_addr;
sensor_dev_t dev = { .ops = &g_newchip_ops, .ctx = &ctx };
return dev;
}
/* ====== 上层使用:完全不知道背后是新芯片 ====== */
void app_poll_sensor(void)
{
sensor_dev_t sensor = NewChip_Adapt(0x48U);
float t;
if (sensor.ops->read_temp(&sensor, &t)) {
/* 上报温度 t,无需关心是哪个芯片、读的哪个寄存器 */
}
}
容易踩的坑
- ⚠️ 适配器只做"接口转换",别把业务逻辑(滤波、标定、上报)塞进来。发现适配器里写了几百行,说明越界了------业务逻辑抽到上层。
- ⚠️ 数值换算的精度 :
raw * 0.01f在低端 MCU(无 FPU)上会触发软浮点库,几十倍慢。资源紧张时改用定点数(如 0.01℃)。 - ⚠️ 对象适配器 vs 类适配器:C 没有"多继承",所以 C 里基本只能写"对象适配器"(持有一个被适配者对象的引用),这反而是更推荐的方式。
四、桥接模式(Bridge Pattern)
说明
把"抽象"和"实现"两个维度分开,让它们各自独立变化,用组合替代多层继承。
用途
- LCD 驱动:4 种屏幕 × 3 种总线(SPI / 8080 并口 / I2C)。用继承要写 12 个类,桥接只要 4+3=7 个,且加一种总线不用动屏幕代码。
- 通信协议栈:协议层(Modbus/自定义)× 传输层(UART/CAN/TCP)。
C 代码
c
#include <stdint.h>
/* ====== 实现端(Implementor):底层总线抽象 ====== */
typedef struct lcd_bus lcd_bus_t;
typedef struct {
void (*write_cmd) (lcd_bus_t *self, uint8_t cmd);
void (*write_data) (lcd_bus_t *self, uint8_t data);
void (*write_block)(lcd_bus_t *self, const uint8_t *buf, uint16_t len);
} lcd_bus_ops_t;
struct lcd_bus {
const lcd_bus_ops_t *ops;
void *ctx; /* 总线私有参数:SPI 句柄、并口端口组等 */
};
/* 具体实现 A:SPI 总线 */
typedef struct { void *spi_handle; } spi_ctx_t;
static void spi_write_cmd (lcd_bus_t *self, uint8_t cmd) { /* HAL_SPI_Send(self->ctx, &cmd, 1); */ }
static void spi_write_data (lcd_bus_t *self, uint8_t data) { /* ... */ }
static void spi_write_block(lcd_bus_t *self, const uint8_t *buf, uint16_t len) { /* DMA 发送 */ }
static const lcd_bus_ops_t g_spi_bus_ops = {
.write_cmd = spi_write_cmd, .write_data = spi_write_data, .write_block = spi_write_block,
};
/* 具体实现 B:8080 并口(实现省略,结构同上) */
static const lcd_bus_ops_t g_i80_bus_ops = { /* ... */ };
/* ====== 抽象端(Abstraction):图形 API,通过"桥"持有总线 ====== */
typedef struct {
lcd_bus_t *bus; /* ← 这就是"桥":抽象端持有实现端的引用 */
uint16_t width;
uint16_t height;
} lcd_dev_t;
/* 设置显存写入窗口,桥接到具体总线发指令 */
static void lcd_set_window(lcd_dev_t *lcd, uint16_t x1, uint16_t y1,
uint16_t x2, uint16_t y2)
{
lcd->bus->ops->write_cmd(lcd->bus, 0x2AU); /* 0x2A: 列地址设置 */
lcd->bus->ops->write_data(lcd->bus, (uint8_t)(x1 >> 8));
lcd->bus->ops->write_data(lcd->bus, (uint8_t)(x1 & 0xFFU));
lcd->bus->ops->write_cmd(lcd->bus, 0x2BU); /* 0x2B: 行地址设置 */
lcd->bus->ops->write_data(lcd->bus, (uint8_t)(y1 >> 8));
lcd->bus->ops->write_data(lcd->bus, (uint8_t)(y1 & 0xFFU));
}
/* 画点:抽象端 API,完全不关心底层是 SPI 还是并口 */
void Lcd_DrawPoint(lcd_dev_t *lcd, uint16_t x, uint16_t y, uint16_t color)
{
lcd_set_window(lcd, x, y, x, y);
lcd->bus->ops->write_cmd(lcd->bus, 0x2CU); /* 0x2C: 写 GRAM */
lcd->bus->ops->write_data(lcd->bus, (uint8_t)(color >> 8));
lcd->bus->ops->write_data(lcd->bus, (uint8_t)(color & 0xFFU));
}
/* ====== 使用:同一段图形代码,换总线只改 bus 指针 ====== */
void app_lcd_demo(void)
{
lcd_bus_t spi_bus = { .ops = &g_spi_bus_ops, .ctx = /*&hspi1*/ NULL };
lcd_dev_t lcd = { .bus = &spi_bus, .width = 240, .height = 320 };
Lcd_DrawPoint(&lcd, 10, 10, 0xF800U); /* 画个红点,背后走 SPI */
/* 换成并口屏?只改 bus,图形 API 一行不动 */
lcd_bus_t i80_bus = { .ops = &g_i80_bus_ops, .ctx = /*&gpio*/ NULL };
lcd.bus = &i80_bus;
Lcd_DrawPoint(&lcd, 10, 10, 0xF800U);
}
容易踩的坑
- ⚠️ 判断是否该上桥接:只有"两个维度真的会各自独立扩展"才值得。屏幕就一种、总线就一种,别上桥接,直接函数调用。
- ⚠️ 桥接 vs 策略:策略模式是"运行时换算法",桥接是"两个维度都要独立扩展"。区别在于桥接的两侧都是长期存在、各自演进的体系,不是临时切换。
- 💡 新手提示:这个模式最抽象,不用强求一上来就懂。等真被"组合爆炸"折磨过,回来再看。
五、组合模式(Composite Pattern)
说明
把对象组织成树形结构,让"单个对象"(叶子)和"组合对象"(容器)用同一套接口处理。客户端遍历时不用区分是叶子还是分支。
用途
- 多级菜单系统:菜单项(叶子,点了执行动作)、子菜单(容器,点了展开下一级)、分隔线------都是"节点",遍历渲染统一处理。
- 文件系统(文件 + 文件夹)、UI 控件树、配置树。
C 代码
c
#include <stdint.h>
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
/* ====== 统一组件接口(Component):叶子与容器共用一套操作 ====== */
typedef struct menu_node menu_node_t;
typedef struct {
void (*render)(menu_node_t *self, uint8_t depth); /* 渲染自身 */
void (*on_enter)(menu_node_t *self); /* 选中/进入回调 */
uint16_t (*count)(menu_node_t *self); /* 统计节点总数(叶子=1)*/
} menu_ops_t;
typedef enum {
MENU_LEAF, /* 叶子:可执行菜单项 */
MENU_CONTAINER, /* 容器:子菜单 */
} menu_type_t;
struct menu_node {
const menu_ops_t *ops;
const char *name;
menu_type_t type;
menu_node_t **children; /* 容器才有,叶子为 NULL */
uint8_t child_cnt;
};
/* ====== 叶子节点实现 ====== */
static void leaf_render(menu_node_t *self, uint8_t depth)
{
/* depth 控制缩进,实际项目画到 LCD 对应位置 */
for (uint8_t i = 0; i < depth; i++) { /* 打印缩进 */ }
/* LCD_DrawString(indent_x, line_y, self->name); */
}
static void leaf_on_enter(menu_node_t *self)
{
/* 叶子节点:执行具体动作,如跳转设置页、切换开关 */
}
static uint16_t leaf_count(menu_node_t *self) { (void)self; return 1U; }
static const menu_ops_t g_leaf_ops = {
.render = leaf_render, .on_enter = leaf_on_enter, .count = leaf_count,
};
/* ====== 容器节点实现(核心:递归处理子节点)====== */
static void container_render(menu_node_t *self, uint8_t depth)
{
leaf_render(self, depth); /* 先渲染自己(复用叶子逻辑) */
for (uint8_t i = 0; i < self->child_cnt; i++) {
/* 递归渲染每个子节点,深度+1 ------ 不用管子节点是叶子还是容器 */
self->children[i]->ops->render(self->children[i], depth + 1U);
}
}
static void container_on_enter(menu_node_t *self)
{
/* 容器:展开/折叠子菜单,或进入下一级 */
}
static uint16_t container_count(menu_node_t *self)
{
uint16_t n = 1U; /* 算上自己 */
for (uint8_t i = 0; i < self->child_cnt; i++) {
n += self->children[i]->ops->count(self->children[i]); /* 递归统计 */
}
return n;
}
static const menu_ops_t g_container_ops = {
.render = container_render, .on_enter = container_on_enter, .count = container_count,
};
/* ====== 构建一棵菜单树 ====== */
/* 叶子:"日期时间""系统信息""恢复出厂" */
static menu_node_t node_datetime = { .ops = &g_leaf_ops, .name = "日期时间", .type = MENU_LEAF };
static menu_node_t node_sysinfo = { .ops = &g_leaf_ops, .name = "系统信息", .type = MENU_LEAF };
static menu_node_t node_factory = { .ops = &g_leaf_ops, .name = "恢复出厂", .type = MENU_LEAF };
static menu_node_t node_bright = { .ops = &g_leaf_ops, .name = "背光亮度", .type = MENU_LEAF };
/* 容器:"设置"包含上面几个叶子 */
static menu_node_t *settings_children[] = { &node_bright, &node_datetime, &node_sysinfo, &node_factory };
static menu_node_t node_settings = {
.ops = &g_container_ops, .name = "设置",
.type = MENU_CONTAINER, .children = settings_children, .child_cnt = ARRAY_SIZE(settings_children),
};
/* ====== 客户端:统一遍历,完全不区分叶子/容器 ====== */
void app_menu_demo(void)
{
/* 从根节点"设置"开始渲染整棵树,递归细节被封装在 ops 里 */
node_settings.ops->render(&node_settings, 0U);
/* 统计整棵树有多少个节点(含根),同样一个调用搞定 */
uint16_t total = node_settings.ops->count(&node_settings); /* 结果:5 */
}
容易踩的坑
- ⚠️ 树只有两层、节点类型就一种,别上组合模式 。一个结构体数组 + 一个
for循环就够了,强行上模式是过度设计。 - ⚠️ 递归深度:菜单理论上能无限嵌套,但 MCU 栈有限。超过 4-5 层的菜单本身就该重新设计(用户也找不到东西了)。深度可控时递归没问题;不可控时改用迭代 + 显式栈。
- ⚠️ 父子回指 :如果要做"返回上级"导航,节点要加
parent指针,注意别在静态初始化时形成环形引用导致初始化顺序坑。
六、装饰器模式(Decorator Pattern)
说明
动态地给对象套上一层职责,可以层层嵌套("套娃"),比继承更灵活地扩展功能。
用途
- 串口发送:原始发送 → 套 CRC → 套加密 → 套日志,按需拼装。
- 数据采集:原始读数 → 套滑动平均滤波 → 量化处理。
- Java IO 流体系(
BufferedInputStream(FileInputStream(...)))是经典范例。
C 代码
c
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
/* ====== 统一接口(Component):数据发送者 ====== */
typedef struct data_sender data_sender_t;
typedef struct {
bool (*send)(data_sender_t *self, const uint8_t *data, uint16_t len);
} sender_ops_t;
struct data_sender {
const sender_ops_t *ops;
void *ctx;
};
/* ====== 具体组件:原始串口发送(最内层)====== */
extern bool Uart_RawSend(const uint8_t *data, uint16_t len);
static bool raw_send(data_sender_t *self, const uint8_t *data, uint16_t len)
{
(void)self;
return Uart_RawSend(data, len);
}
static const sender_ops_t g_raw_ops = { .send = raw_send };
/* ====== 装饰器基类:持有一个被包裹的内层对象 ====== */
typedef struct {
data_sender_t *inner; /* 被装饰的内层对象,这是"套娃"的关键 */
} decorator_ctx_t;
extern void AppendCrc(uint8_t *buf, uint16_t *len);
extern void Log_Debug(const char *fmt, ...);
/* 装饰器 A:CRC 校验 ------ 在数据尾部追加 CRC,再交给内层发送 */
static bool crc_send(data_sender_t *self, const uint8_t *data, uint16_t len)
{
decorator_ctx_t *ctx = (decorator_ctx_t *)self->ctx;
uint8_t buf[256];
uint16_t n = len;
memcpy(buf, data, len);
AppendCrc(buf, &n); /* 叠加 CRC 职责 */
return ctx->inner->ops->send(ctx->inner, buf, n); /* 交给下一层(内层)发送 */
}
static const sender_ops_t g_crc_ops = { .send = crc_send };
/* 装饰器 B:日志 ------ 打日志后原样交给内层 */
static bool log_send(data_sender_t *self, const uint8_t *data, uint16_t len)
{
decorator_ctx_t *ctx = (decorator_ctx_t *)self->ctx;
Log_Debug("send %u bytes", len); /* 叠加日志职责 */
return ctx->inner->ops->send(ctx->inner, data, len);
}
static const sender_ops_t g_log_ops = { .send = log_send };
/* ====== 使用:套娃式组装 raw → crc → log ====== */
void app_send_demo(void)
{
/* 内层:原始发送 */
static data_sender_t raw = { .ops = &g_raw_ops };
/* 中层:CRC 装饰器,包裹 raw */
static decorator_ctx_t crc_ctx;
static data_sender_t crc = { .ops = &g_crc_ops, .ctx = &crc_ctx };
crc_ctx.inner = &raw;
/* 外层:日志装饰器,包裹 crc */
static decorator_ctx_t log_ctx;
static data_sender_t log = { .ops = &g_log_ops, .ctx = &log_ctx };
log_ctx.inner = &crc;
/* 对外只暴露最外层 log;调用 send 时:
先打日志 → 再加 CRC → 最后真正发出去,三层职责层层透传 */
uint8_t payload[] = { 0x01U, 0x02U, 0x03U };
log.ops->send(&log, payload, sizeof(payload));
}
容易踩的坑
- ⚠️ 套的层数一多,调试时根本不知道数据被哪层改坏了。建议每层都打日志,或尽量限制最多套 2-3 层。
- ⚠️ 缓冲区拷贝开销 :每层
memcpy一次,对高吞吐场景(几 Mbps)是负担。可让装饰器传偏移 + 原地修改,避免拷贝。 - ⚠️ 顺序敏感 :
log(crc(raw))和crc(log(raw))行为不同------前者日志记录的是不含 CRC 的长度,后者记录含 CRC 的长度。组装时想清楚"谁包谁"。
七、外观模式(Facade Pattern)
说明
给复杂的子系统提供一个统一、简单的高层入口,屏蔽子系统内部复杂度,降低调用方与子系统的耦合。
用途
- BSP(板级支持包)就是最经典的外观 :
LED_On()背后是一堆 RCC 时钟使能、GPIO 模式配置、速率设置,但业务层只看到一个函数。 - AT 指令模组:背后是几十条 AT 指令 + 状态机 + 超时重传,对外只给
Wifi_Connect(ssid, pwd)。
C 代码
c
#include <stdint.h>
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
/* ====== 复杂子系统(被封装的部分):一堆底层寄存器操作 ====== */
extern void RCC_EnableClock(uint32_t port);
extern void GPIO_ConfigOut(uint32_t port, uint8_t pin, uint8_t speed, uint8_t pull);
extern void GPIO_SetHigh(uint32_t port, uint8_t pin);
extern void GPIO_SetLow (uint32_t port, uint8_t pin);
/* ... 实际项目里还有中断配置、复用映射、防抖初始化等几十个细节 ... */
#define PORT_A 0x40020000UL
#define PORT_B 0x40020400UL
#define GPIO_SPEED_HIGH 2U
#define GPIO_PULL_NONE 0U
/* LED 引脚定义表:板上低电平点亮 */
typedef struct { uint32_t port; uint8_t pin; } led_pin_t;
static const led_pin_t g_leds[] = {
{ PORT_A, 5U }, /* LED0 */
{ PORT_B, 0U }, /* LED1 */
};
/* ====== 外观(Facade):极简对外 API ====== */
void Board_Init(void)
{
/* 把"使能时钟 + 配置 GPIO + 默认熄灭"这套繁琐流程藏起来 */
for (uint8_t i = 0; i < ARRAY_SIZE(g_leds); i++) {
RCC_EnableClock(g_leds[i].port);
GPIO_ConfigOut(g_leds[i].port, g_leds[i].pin, GPIO_SPEED_HIGH, GPIO_PULL_NONE);
GPIO_SetHigh(g_leds[i].port, g_leds[i].pin); /* 高电平 = 熄灭 */
}
}
void LED_On(uint8_t idx)
{
if (idx < ARRAY_SIZE(g_leds)) {
GPIO_SetLow(g_leds[idx].port, g_leds[idx].pin); /* 低电平点亮 */
}
}
void LED_Off(uint8_t idx)
{
if (idx < ARRAY_SIZE(g_leds)) {
GPIO_SetHigh(g_leds[idx].port, g_leds[idx].pin);
}
}
void LED_Toggle(uint8_t idx)
{
/* 实际项目里读回 ODR 寄存器取反,这里示意 */
static uint8_t state[2] = {0};
if (idx < ARRAY_SIZE(g_leds)) {
state[idx] ^= 1U;
(state[idx] ? LED_On(idx) : LED_Off(idx));
}
}
/* ====== 上层使用:完全看不到任何寄存器 ====== */
void app_blink_demo(void)
{
Board_Init();
while (1) {
LED_Toggle(0);
/* delay 500ms */
}
}
容易踩的坑
- ⚠️ 外观层别变成"上帝对象" 。什么都往
Board里塞(LED、按键、串口、SPI、网络......)会膨胀成几千行的怪物。按功能拆几个外观:led_facade、key_facade、net_facade。 - ⚠️ 外观不是"禁止直接访问子系统"。需要精细控制时,允许调用方绕过外观直接调底层;外观只是"常用快捷方式",不是牢笼。
- 💡 这个模式几乎零缺点,新手最该多用 。把一堆零散的初始化函数包成一个
Board_Init(),立刻就能感受到分层的好处。
八、享元模式(Flyweight Pattern)
说明
通过共享技术复用大量细粒度对象,区分内部状态 (可共享,如字模数据本身)和外部状态(不共享,如显示坐标),大幅降低内存占用。
用途
- 字模存储:128×64 LCD 要显示一堆汉字,每个字模几十字节。把字模做成共享享元,按编码查表复用,而不是每个字、每帧都拷一份。
- 图标缓存、棋盘格子、GUI 控件样式表。
C 代码
c
#include <stdint.h>
#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0]))
/* ====== 享元对象(Flyweight):字模,可共享 ====== */
typedef struct {
uint16_t encoding; /* 字符编码(内部状态:决定字模内容)*/
uint8_t width; /* 字宽 */
uint8_t height; /* 字高 */
const uint8_t *bitmap; /* 指向 Flash 中的字模数据,只读、天然共享 */
} glyph_t;
/* ====== 享元工厂:维护共享池 ====== */
/* 字模表存于 Flash(const),所有字符共用这一份,绝不拷贝到 RAM */
static const uint8_t font_H[] = { 0x18, 0x24, 0x42, 0x7E, 0x42, 0x42, 0x00 }; /* 'H' 7 行 */
static const uint8_t font_e[] = { 0x00, 0x42, 0x7E, 0x40, 0x40, 0x40, 0x00 }; /* 'e' */
static const uint8_t font_l[] = { 0x7E, 0x40, 0x40, 0x40, 0x40, 0x40, 0x00 }; /* 'l' */
static const uint8_t font_o[] = { 0x00, 0x3C, 0x42, 0x42, 0x42, 0x3C, 0x00 }; /* 'o' */
static const glyph_t g_glyph_pool[] = {
{ 'H', 7U, 7U, font_H },
{ 'e', 7U, 7U, font_e },
{ 'l', 7U, 7U, font_l },
{ 'o', 7U, 7U, font_o },
/* ... 其余字模,整张表常驻 Flash,零 RAM 占用 ... */
};
/* 查表返回共享字模指针(找不到返回 NULL)*/
const glyph_t *Glyph_Get(uint16_t encoding)
{
for (uint16_t i = 0; i < ARRAY_SIZE(g_glyph_pool); i++) {
if (g_glyph_pool[i].encoding == encoding) {
return &g_glyph_pool[i]; /* 共享返回,不拷贝 */
}
}
return NULL;
}
/* ====== 外部状态:显示坐标,每次绘制时传入 ====== */
/* 注意:这里用独立的像素绘制原语,与桥接模式的 Lcd_DrawPoint(lcd_dev_t*, ...) 区分,
实际项目里通常由 GUI 上下文统一管理当前 LCD 设备。 */
extern void Lcd_PutPixel(int x, int y, uint16_t color);
void Font_DrawChar(uint16_t encoding, uint16_t x, uint16_t y)
{
const glyph_t *g = Glyph_Get(encoding); /* 取共享字模(内部状态)*/
if (g == NULL) {
return; /* 无此字模,跳过 */
}
/* x, y 是外部状态------同一份字模可画在屏幕任意位置,互不影响 */
for (uint8_t row = 0; row < g->height; row++) {
uint8_t bits = g->bitmap[row];
for (uint8_t col = 0; col < g->width; col++) {
if ((bits & (0x80U >> col)) != 0U) {
Lcd_PutPixel((int)(x + col), (int)(y + row), 0xFFFFU); /* 画前景 */
}
}
}
}
/* ====== 使用:画 "Hello",无论画多少遍,字模内存始终一份 ====== */
void app_font_demo(void)
{
const char *str = "Hello";
uint16_t x = 0;
for (uint8_t i = 0; str[i] != '\0'; i++) {
Font_DrawChar((uint16_t)str[i], x, 0); /* 同一份字模,不同坐标 */
x += 7U; /* 字宽 7,横移 */
}
/* 关键收益:l 出现了两次,但 font_l 在 Flash 里只有一份,RAM 零增长 */
}
容易踩的坑
- ⚠️ 享元对象必须是只读/不可变的。一旦共享,任何一方修改都会影响所有引用方。字模天然只读没问题;如果享元带可变状态,务必只把不可变部分共享,可变部分作为外部状态传入。
- ⚠️ 查表方式:示例用线性查找便于理解,实际汉字库用 GB2312 编码做偏移直接定位,O(1)。别在循环里用线性查找几千个字的字模。
- ⚠️ 别为了享元而享元:对象总共就几十个、内存够用,硬上享元工厂反而增加复杂度。享元的价值在"海量重复对象 + 内存紧张"时才凸显。
九、代理模式(Proxy Pattern)
说明
给目标对象套一个"替身",由替身控制对原对象的访问,可在访问前后附加额外逻辑(加锁、校验、缓存、懒加载)。
用途
- I2C 总线互斥代理:多任务共用一根 I2C,直接调底层会打架,代理自动加互斥量。
- Flash 写保护代理:写前校验权限/解锁,防止误擦关键区。
- 懒加载代理:大资源(字库、图片)用到才初始化。
C 代码
c
#include <stdint.h>
/* ====== 目标接口(Subject):I2C 读写 ====== */
typedef struct i2c_dev i2c_dev_t;
typedef struct {
int8_t (*write)(i2c_dev_t *self, uint8_t addr, const uint8_t *data, uint16_t len);
int8_t (*read) (i2c_dev_t *self, uint8_t addr, uint8_t *buf, uint16_t len);
} i2c_ops_t;
struct i2c_dev {
const i2c_ops_t *ops;
void *ctx;
};
/* ====== 真实对象(RealSubject):裸 HAL 调用,非线程安全 ====== */
extern int8_t HAL_I2C_Write(uint8_t addr, const uint8_t *data, uint16_t len);
extern int8_t HAL_I2C_Read (uint8_t addr, uint8_t *buf, uint16_t len);
static int8_t raw_i2c_write(i2c_dev_t *self, uint8_t addr,
const uint8_t *data, uint16_t len)
{
(void)self;
return HAL_I2C_Write(addr, data, len);
}
static int8_t raw_i2c_read(i2c_dev_t *self, uint8_t addr,
uint8_t *buf, uint16_t len)
{
(void)self;
return HAL_I2C_Read(addr, buf, len);
}
static const i2c_ops_t g_raw_i2c_ops = {
.write = raw_i2c_write, .read = raw_i2c_read,
};
/* ====== 代理(Proxy):加互斥锁,控制对真实对象的访问 ====== */
/* 假设在 FreeRTOS 环境 */
extern void *xSemaphoreCreateMutex(void);
extern int xSemaphoreTake(void *mux, uint32_t ticks);
extern void xSemaphoreGive(void *mux);
#define portMAX_DELAY 0xFFFFFFFFUL
typedef struct {
i2c_dev_t *real; /* 持有真实对象 */
void *mutex; /* 互斥量,保证总线独占 */
} i2c_proxy_ctx_t;
static int8_t proxy_i2c_write(i2c_dev_t *self, uint8_t addr,
const uint8_t *data, uint16_t len)
{
i2c_proxy_ctx_t *ctx = (i2c_proxy_ctx_t *)self->ctx;
int8_t ret;
xSemaphoreTake(ctx->mutex, portMAX_DELAY); /* 进临界区:拿锁 */
ret = ctx->real->ops->write(ctx->real, addr, data, len); /* 委托真实对象 */
xSemaphoreGive(ctx->mutex); /* 出临界区:放锁 */
return ret;
}
static int8_t proxy_i2c_read(i2c_dev_t *self, uint8_t addr,
uint8_t *buf, uint16_t len)
{
i2c_proxy_ctx_t *ctx = (i2c_proxy_ctx_t *)self->ctx;
int8_t ret;
xSemaphoreTake(ctx->mutex, portMAX_DELAY);
ret = ctx->real->ops->read(ctx->real, addr, buf, len);
xSemaphoreGive(ctx->mutex);
return ret;
}
static const i2c_ops_t g_proxy_i2c_ops = {
.write = proxy_i2c_write, .read = proxy_i2c_read,
};
/* ====== 构造:真实对象套上代理,上层拿到的是代理 ====== */
i2c_dev_t I2C_CreateProxy(void)
{
static i2c_dev_t real = { .ops = &g_raw_i2c_ops };
static i2c_proxy_ctx_t ctx;
static i2c_dev_t proxy;
ctx.real = ℜ
ctx.mutex = xSemaphoreCreateMutex();
proxy.ops = &g_proxy_i2c_ops;
proxy.ctx = &ctx;
return proxy; /* 返回给上层的是 proxy,调用方对"自动加锁"无感 */
}
/* ====== 变体:Flash 写保护代理(示意)====== */
/* 写前校验调用方权限/地址范围,防止误擦 Bootloader 区 */
typedef struct { uint32_t base; uint32_t size; } flash_region_t;
extern bool Auth_CheckWritePermission(uint32_t addr, uint16_t len);
extern int8_t Flash_Unlock(void);
extern int8_t Flash_EraseWrite(uint32_t addr, const uint8_t *data, uint16_t len);
extern void Flash_Lock(void);
int8_t FlashProxy_Write(uint32_t addr, const uint8_t *data, uint16_t len)
{
/* 保护代理:先校验权限和地址范围 */
if (!Auth_CheckWritePermission(addr, len)) {
return -1; /* 拒绝越权写 */
}
Flash_Unlock(); /* 解锁 */
int8_t ret = Flash_EraseWrite(addr, data, len);
Flash_Lock(); /* 重新上锁,防止误写 */
return ret;
}
容易踩的坑
- ⚠️ 代理 vs 装饰器 :两者结构几乎一样(都持有内层对象并转发),但意图不同------代理强调"控制访问"(加锁、校验、懒加载),装饰器强调"叠加职责"(加 CRC、加日志)。别混用语义。
- ⚠️ 加锁代理的死锁:代理内拿锁后,被代理的回调又回头调代理,会重复拿锁死锁。递归互斥量或重新设计调用链可解。
- ⚠️ 所有访问入口都要走代理:留一个直接调底层的口子,代理的锁就形同虚设。
十、总结:7 种模式怎么选?
讲完 7 种,回到工程现实------怎么选型。给一张决策表,遇到问题对号入座:
| 遇到的问题 | 优先考虑 | 不要混淆于 |
|---|---|---|
| 新旧接口对不上 | 适配器 | 外观(外观是简化入口,不是接口转换) |
| 两个维度组合爆炸(N×M) | 桥接 | 策略(策略是临时换算法,不是两维扩展) |
| 树形结构要统一遍历 | 组合 | 装饰器(装饰器是链式,不是树形) |
| 功能要灵活拼装(CRC/加密/日志) | 装饰器 | 代理(代理是控制访问,不是叠加职责) |
| 子系统太复杂,想要简单入口 | 外观 | 适配器 |
| 海量相似对象,RAM 不够 | 享元 | 单例 |
| 要加锁/校验/懒加载,控制访问 | 代理 | 装饰器 |
- 新手重点掌握:外观、适配器、代理。这三个在嵌入式驱动里几乎天天见,用熟了代码分层就立起来了。
- 模式不是越多越好 。一个三层菜单给它套"组合 + 装饰器 + 访问者",那不是工程,是炫技。简单优先,等真感觉耦合痛了再上模式。
- C 语言的"面向对象"有代价:函数指针虚表比直接调用慢一点(多一次间接跳转)、Flash 多占一点(虚表本身)、可读性下降一点(追代码要跳 ops 表)。在中断高频路径或极致性能场景,直接函数调用更合适。
- 好的代码不是用满了 7 种模式,而是看起来"平平无奇" ------每层各管各的,改一个功能不用满世界找。模式只是工具,不是目标本身。
参考链接
- GoF 原书 :Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software . Addison-Wesley, 1994.(中文版:《设计模式:可复用面向对象软件的基础》,机械工业出版社)。维基百科条目:Design Patterns (book)
- refactoring.guru 设计模式中文站 (图文并茂,强烈推荐入门):https://refactoringguru.cn/design-patterns
- 维基百科:软件设计模式总览 :Software design pattern
- RT-Thread 源码仓库 (
rt_device设备框架是外观 + 策略 + 适配器的典范,rt_i2c总线驱动可对照桥接/适配器阅读):https://github.com/RT-Thread/rt-thread - FreeRTOS 源码仓库 (
Queue/StreamBuffer/MessageBufferAPI 是对链表 + 临界区细节的外观封装;MessageBuffer在StreamBuffer上的封装接近装饰器思路):https://github.com/FreeRTOS/FreeRTOS - SourceMaking 设计模式参考 (含大量结构图与代码):https://sourcemaking.com/design_patterns