摘自:一枚嵌入式码农
链接:https://mp.weixin.qq.com/s/F8dGogR8D77bUVw6Z94Jhw

这是「嵌入式设计模式」系列的第 10 篇,也是最后一篇。我们将用观察者模式为这段旅程画上句号。

一、改不完的 UI 刷新逻辑

老张最近有点烦。

作为公司里写嵌入式写了十年的"老油条",他负责维护一个带 LCD 屏幕的温控器项目。说起来也不复杂,核心就是那么几个功能:显示当前温度、PID 调节、参数存储、LED 状态指示。

问题出在一个全局变量上:current_temp。

这个变量太"热门"了——PID 模块要改它,串口接收到的远程指令要改它,用户按键校准也要改它。而每次改完,老张都得小心翼翼地确保:

// 某处修改了温度...
current_temp = new_value;

// 然后你得记住调用这些
LCD_UpdateTemp(current_temp);      // 刷新屏幕
Flash_SaveTemp(current_temp);       // 写入存储
Led_UpdateColor(current_temp);      // 更新指示灯颜色

漏掉一个?恭喜你,Bug 来了。

上周新来的小王写了个手机远程控制功能,代码逻辑没问题,就是忘了调 LCD_UpdateTemp()。结果呢?手机 APP 上显示温度变了,设备屏幕上还是老数字。客户打电话来问:“你们这设备是不是坏了?”

老张一边改 Bug 一边叹气:“这代码,改一处得牵挂八处,我是在写程序还是在扫雷?”

二、你正在做的,叫"Push 模式"

老张遇到的问题,本质上是数据同步的问题。

传统做法是这样的:谁修改数据,谁负责通知全世界。你改了 current_temp,那你就得负责告诉 LCD、告诉 Flash、告诉 LED。这种模式叫 Push 模式——主动把变化"推"给所有相关方。

听起来很合理,但问题是:

  1. 耦合度极高:修改数据的代码必须知道"谁关心这个数据"。PID 模块为什么要知道 LCD 的存在?
  2. 容易遗漏:新增一个功能(比如蓝牙同步),你得去翻遍所有修改 current_temp 的地方,挨个加上通知代码。
  3. 维护噩梦:代码散落在各处,逻辑耦合像蜘蛛网一样,改一处崩一片。

在这里插入图片描述
说白了,Push 模式把"通知责任"分散到了每一个修改数据的地方。人总会犯错,代码越多,遗漏的概率就越大。

三、换个思路:让数据"被盯着"

如果我们换个角度想这个问题呢?

与其让修改者负责通知,不如让关心这个数据的人主动去"盯着"它。

这就是**观察者模式(Observer Pattern)**的核心思想:

• 数据(Subject):是被动的,它不需要知道谁在关心它。
• 观察者(Observer):主动注册,表示"我对这个数据感兴趣"。
• 当数据变化时:自动通知所有已注册的观察者。

用大白话说:数据变成了一个"公告板",你想知道消息就去订阅,公告板更新了自动给你推送。

在这里插入图片描述

这样一来:

• PID 模块不需要知道 LCD 的存在,它只管修改数据。
• LCD 也不需要知道谁在改数据,它只管订阅、等通知、刷新显示。
• 新增功能? 只需要写一个新的观察者,注册到数据上即可。完全不用动原有代码。

这就是解耦的魅力。

四、从观察者到 MVC:一张图看懂架构演进

如果你写过前端(Vue、React),或者用过 GUI 框架(LVGL、emWin),你一定听过 MVC 或 MVVM 这些词。

别被这些缩写吓到,它们的核心思想其实就是观察者模式的延伸:

在这里插入图片描述
Model、View、Controller 三者的关系:

在这里插入图片描述
为什么 Model 不应该直接调用 View?

因为 Model 是"数据",它应该是纯粹的、可复用的。今天你用 LCD 显示,明天换成 OLED,后天可能要加个手机 APP。如果 Model 里写死了 LCD_Update(),换屏幕就得改核心代码——这就是耦合的代价。

观察者模式让 Model 保持"无知",View 自己去订阅。换屏幕?写个新的 Observer 就行,Model 一行代码不用改。

五、灵魂拷问:这不就是回调函数列表吗?

聪明的你可能已经想到了:

“所谓的观察者模式,不就是维护一个回调函数的链表吗?数据变了就遍历调用?”

没错,实现层面确实是这样。但"观察者模式"这个名字强调的是设计意图,而不是实现细节。

就像"单例模式"本质上就是一个全局变量,但我们用"单例"这个词来强调"全局唯一、受控访问"的设计意图。

不过,真正的问题来了:

在 C 语言里,怎么把一个普通的 int 变量,变成一个"有感知能力"的被观察对象?

你可能会想:

• 直接用一个结构体包装 int,加上一个回调链表?
• 每次赋值都调用一个 Set() 函数?
• 怎么处理"值没变,但还是调用了 Set()"的情况?(比如连续设置 temp = 25; temp = 25;,要不要通知两次?)

还有更进阶的问题:

• 如何实现智能去重? 只有值真正改变时才触发通知,避免无效刷屏浪费 CPU。
• 如何优雅地管理观察者生命周期? 观察者销毁了(比如关掉一个窗口),怎么自动从链表里移除?
• 有没有可能用宏,让定义一个观察者像写声明一样简单?

【付费内容预告】
这是本系列的最后一篇,我们将压轴登场。

接下来的付费内容,我将带你从零实现一个嵌入式 MVVM 雏形框架。你将学到:

  1. Observable 封装
    将普通变量包装成支持 Attach/Detach 的被观察对象,代码可参考应用到你的项目。

  2. 智能通知机制
    实现 Subject_Set() 函数,自动比对新旧值,只有变化时才触发回调链,极大地节省 CPU 算力。

  3. 多态观察者实现
    利用第 9 篇讲的 OOP 技巧,让 LCD_Observer、Flash_Observer、Motor_Observer 共享同一套接口。

  4. GUI 框架实战
    结合 LVGL,实现滑动条拖动 → 数据变化 → 文本更新 → 电机转速调整的完整链路。

  5. 大结局彩蛋
    盘点这一路走来的 10 个设计模式,送你一张"嵌入式架构师成长路线图"。

六、核心实战 A:定义"被观察者"(Subject)

好,现在我们撸起袖子开始写代码。

第一步,我们需要把一个普通的数据变量"包装"成一个可以被观察的对象。在面向对象语言里,这叫 Observable;在我们的 C 语言实现里,我们叫它 Subject。

6.1 观察者结构体

首先定义观察者的接口。每个观察者都需要:

  1. 一个回调函数(当数据变化时被调用)
  2. 一个 next 指针(用于链表串联)
/* observer.h */

#ifndef OBSERVER_H
#define OBSERVER_H

/* 前向声明 */
typedef struct Subject Subject_t;
typedef struct Observer Observer_t;

/* 观察者回调函数类型 */
typedef void (*ObserverCallback)(Observer_t *self, int new_value);

/* 观察者结构体 */
struct Observer {
    ObserverCallback on_update;    /* 数据变化时的回调 */
    Observer_t *next;              /* 链表下一个节点 */
    void *user_data;               /* 用户自定义数据(可选) */
};

#endif /* OBSERVER_H */

为什么用链表而不是数组?

嵌入式开发中,观察者的数量往往是动态的:用户打开一个设置页面,页面上的控件注册为观察者;用户关闭页面,观察者需要被移除。链表可以灵活地增删节点,不需要预先知道最大观察者数量。

当然,如果你的场景观察者数量固定且很少(比如就 3 个),用数组也完全可以。

6.2 被观察者(Subject)结构体

被观察者需要:

  1. 存储实际的数据值
  2. 维护一个观察者链表
  3. 提供注册/注销观察者的方法
  4. 提供设置数据的方法(这是触发通知的入口)
/* subject.h */

#ifndef SUBJECT_H
#define SUBJECT_H

#include "observer.h"

/* 被观察者结构体 */
struct Subject {
    int value;               /* 真实数据 */
    Observer_t *head;        /* 观察者链表头 */
};

/* 初始化被观察者 */
void Subject_Init(Subject_t *sub, int initial_value);

/* 注册观察者 */
void Subject_Attach(Subject_t *sub, Observer_t *obs);

/* 注销观察者 */
void Subject_Detach(Subject_t *sub, Observer_t *obs);

/* 设置数据值(核心!) */
void Subject_Set(Subject_t *sub, int new_value);

/* 获取当前值 */
int Subject_Get(Subject_t *sub);

#endif /* SUBJECT_H */

6.3 核心实现:智能通知

下面是最关键的部分——Subject_Set() 函数的实现:

/* subject.c */

#include "subject.h"
#include <stddef.h>

/* 初始化 */
void Subject_Init(Subject_t *sub, int initial_value) {
    sub->value = initial_value;
    sub->head = NULL;
}

/* 注册观察者(头插法) */
void Subject_Attach(Subject_t *sub, Observer_t *obs) {
    obs->next = sub->head;
    sub->head = obs;
}

/* 注销观察者 */
void Subject_Detach(Subject_t *sub, Observer_t *obs) {
    Observer_t **curr = &sub->head;

    while (*curr != NULL) {
        if (*curr == obs) {
            *curr = obs->next;  /* 跳过这个节点 */
            obs->next = NULL;
            return;
        }
        curr = &(*curr)->next;
    }
}

/* 通知所有观察者 */
static void Subject_NotifyAll(Subject_t *sub) {
    Observer_t *obs = sub->head;

    while (obs != NULL) {
        if (obs->on_update != NULL) {
            obs->on_update(obs, sub->value);
        }
        obs = obs->next;
    }
}

/* 设置新值 —— 关键逻辑! */
void Subject_Set(Subject_t *sub, int new_value) {
    /* ★ 智能去重:只有值真正改变时才通知 ★ */
    if (sub->value != new_value) {
        sub->value = new_value;
        Subject_NotifyAll(sub);
    }
}

/* 获取当前值 */
int Subject_Get(Subject_t *sub) {
    return sub->value;
}

智能去重的威力:

看那个 if (sub->value != new_value) 判断。这一行代码看似简单,却能带来巨大的性能收益。

想象一个场景:你的温度传感器每 100ms 采样一次,但温度稳定时数值基本不变。如果没有这个判断,LCD 每 100ms 就要刷新一次——明明显示的是同样的数字,却在反复重绘,白白浪费 CPU 和电量。

有了去重,只有温度真正变化时才刷新。在电池供电的设备上,这可能意味着续航时间翻倍。

在这里插入图片描述

七、核心实战 B:定义"观察者"(Observer)

有了被观察者,接下来我们需要实现具体的观察者。

这里要用到我们在第 9 篇讲过的 C 语言多态 技巧:让不同的观察者(LCD、Flash、LED)共享同一个 Observer 接口,但各自实现不同的行为。

7.1 LCD 观察者

当温度变化时,刷新 LCD 显示:

/* lcd_observer.h */

#include "observer.h"

/* LCD 观察者 —— "继承"自 Observer */
typedef struct {
    Observer_t base;       /* 基类,必须放在第一个位置! */
    /* LCD 特有的数据 */
    int screen_id;         /* 屏幕 ID(如果有多个屏幕) */
} LCD_Observer_t;

/* 初始化 LCD 观察者 */
void LCD_Observer_Init(LCD_Observer_t *self, int screen_id);
/* lcd_observer.c */

#include "lcd_observer.h"
#include "lcd_driver.h"  /* 假设这是你的 LCD 驱动头文件 */

/* LCD 的 OnUpdate 实现 */
static void LCD_OnUpdate(Observer_t *base, int new_value) {
    /* 通过基类指针找到派生类 */
    LCD_Observer_t *self = (LCD_Observer_t *)base;

    /* 调用 LCD 驱动刷新显示 */
    LCD_ShowNumber(self->screen_id, new_value);
}

/* 初始化 */
void LCD_Observer_Init(LCD_Observer_t *self, int screen_id) {
    self->base.on_update = LCD_OnUpdate;  /* 绑定回调 */
    self->base.next = NULL;
    self->base.user_data = NULL;
    self->screen_id = screen_id;
}

C 语言多态的精髓:

注意 LCD_Observer_t 结构体的布局——Observer_t base 必须放在第一个位置。这样,LCD_Observer_t * 和 Observer_t * 可以互相转换,就像 C++ 的继承一样。

7.2 Flash 观察者

当温度变化时,保存到 Flash:

/* flash_observer.c */

#include "observer.h"
#include "flash_driver.h"

typedef struct {
    Observer_t base;
    uint32_t flash_addr;   /* 存储地址 */
} Flash_Observer_t;

static void Flash_OnUpdate(Observer_t *base, int new_value) {
    Flash_Observer_t *self = (Flash_Observer_t *)base;

    /* 写入 Flash */
    Flash_Write(self->flash_addr, &new_value, sizeof(new_value));
}

void Flash_Observer_Init(Flash_Observer_t *self, uint32_t addr) {
    self->base.on_update = Flash_OnUpdate;
    self->base.next = NULL;
    self->flash_addr = addr;
}

7.3 LED 观察者

根据温度值改变 LED 颜色:

/* led_observer.c */

#include "observer.h"
#include "led_driver.h"

typedef struct {
    Observer_t base;
    int led_id;
} LED_Observer_t;

static void LED_OnUpdate(Observer_t *base, int new_value) {
    LED_Observer_t *self = (LED_Observer_t *)base;

    /* 根据温度设置颜色 */
    if (new_value < 20) {
        LED_SetColor(self->led_id, LED_BLUE);    /* 低温:蓝色 */
    } else if (new_value < 30) {
        LED_SetColor(self->led_id, LED_GREEN);   /* 适中:绿色 */
    } else {
        LED_SetColor(self->led_id, LED_RED);     /* 高温:红色 */
    }
}

void LED_Observer_Init(LED_Observer_t *self, int led_id) {
    self->base.on_update = LED_OnUpdate;
    self->base.next = NULL;
    self->led_id = led_id;
}

7.4 把它们组装起来

现在,让我们看看完整的使用示例:

/* main.c */

#include "subject.h"
#include "lcd_observer.h"
#include "flash_observer.h"
#include "led_observer.h"

/* 定义被观察的温度数据 */
static Subject_t temperature;

/* 定义三个观察者 */
static LCD_Observer_t   lcd_obs;
static Flash_Observer_t flash_obs;
static LED_Observer_t   led_obs;

void System_Init(void) {
    /* 初始化被观察者 */
    Subject_Init(&temperature, 25);  /* 初始温度 25°C */

    /* 初始化观察者 */
    LCD_Observer_Init(&lcd_obs, 0);
    Flash_Observer_Init(&flash_obs, 0x08010000);
    LED_Observer_Init(&led_obs, 0);

    /* 注册观察者 —— 从此它们就"盯上"温度了 */
    Subject_Attach(&temperature, (Observer_t *)&lcd_obs);
    Subject_Attach(&temperature, (Observer_t *)&flash_obs);
    Subject_Attach(&temperature, (Observer_t *)&led_obs);
}

void PID_Calculate(void) {
    int new_temp = Read_Temperature_Sensor();

    /* 只需要这一行!LCD、Flash、LED 全部自动更新 */
    Subject_Set(&temperature, new_temp);
}

void UART_CommandHandler(uint8_t *data) {
    if (data[0] == CMD_SET_TEMP) {
        int target_temp = data[1];

        /* 只需要这一行!其他模块自动响应 */
        Subject_Set(&temperature, target_temp);
    }
}

看到优雅之处了吗?

• PID_Calculate() 不需要知道 LCD 的存在
• UART_CommandHandler() 不需要知道 Flash 的存在
• 它们都只管修改数据,剩下的事情自动发生

在这里插入图片描述

八、进阶场景:GUI 框架中的应用

如果你用过 LVGL 或 emWin 这类嵌入式 GUI 框架,你会发现观察者模式简直是为它们量身定做的。

8.1 实战场景:滑动条控制电机转速

假设你有这样一个界面:

• 一个滑动条(Slider),用户可以拖动来设置目标转速
• 一个文本标签(Label),实时显示当前转速值
• 一个电机驱动模块,根据设置值调整 PWM 输出
传统做法是:滑动条的回调函数里,既要更新 Label,又要调用电机驱动。但如果以后要加个远程监控功能呢?又得改滑动条回调。

用观察者模式,代码可以这样组织:

/* 被观察的数据:目标转速 */
static Subject_t motor_speed;

/* 观察者 1:文本标签 */
typedef struct {
    Observer_t base;
    lv_obj_t *label;    /* LVGL 的 Label 对象 */
} Label_Observer_t;

static void Label_OnUpdate(Observer_t *base, int new_value) {
    Label_Observer_t *self = (Label_Observer_t *)base;
    char buf[32];
    sprintf(buf, "Speed: %d RPM", new_value);
    lv_label_set_text(self->label, buf);
}

/* 观察者 2:电机驱动 */
typedef struct {
    Observer_t base;
    int pwm_channel;
} Motor_Observer_t;

static void Motor_OnUpdate(Observer_t *base, int new_value) {
    Motor_Observer_t *self = (Motor_Observer_t *)base;
    /* 转速映射到 PWM 占空比 */
    int duty = (new_value * 100) / MAX_SPEED;
    PWM_SetDuty(self->pwm_channel, duty);
}

/* 滑动条的事件回调 */
static void slider_event_cb(lv_event_t *e) {
    lv_obj_t *slider = lv_event_get_target(e);
    int value = lv_slider_get_value(slider);

    /* 只需要这一行!Label 自动更新,电机自动调速 */
    Subject_Set(&motor_speed, value);
}

在这里插入图片描述

8.2 为什么这很重要?

扩展性: 如果以后要加蓝牙远程控制,只需要:

  1. 写一个 Bluetooth_Observer,在 OnUpdate 里发送蓝牙数据
  2. 用 Subject_Attach() 注册到 motor_speed
  3. 完毕。Slider、Label、Motor 的代码都不用改。

可测试性: 测试电机控制逻辑时,不需要真的有一个 Slider 控件。直接调用 Subject_Set() 就能模拟用户输入。

九、避坑指南

观察者模式虽好,但用不好也会翻车。这里列出两个最常见的坑:

9.1 坑一:级联死循环

场景: A 变化通知 B,B 变化又通知 A,无限循环,栈溢出崩溃。

/* 危险示例! */
static Subject_t temp_celsius;   /* 摄氏温度 */
static Subject_t temp_fahrenheit; /* 华氏温度 */

/* 摄氏变化 → 更新华氏 */
static void Celsius_OnUpdate(Observer_t *base, int new_value) {
    int fahrenheit = new_value * 9 / 5 + 32;
    Subject_Set(&temp_fahrenheit, fahrenheit);  /* 触发华氏观察者 */
}

/* 华氏变化 → 更新摄氏 */
static void Fahrenheit_OnUpdate(Observer_t *base, int new_value) {
    int celsius = (new_value - 32) * 5 / 9;
    Subject_Set(&temp_celsius, celsius);  /* 触发摄氏观察者 → 死循环! */
}

解法:

  1. 去重判断救你一命: 我们前面实现的 Subject_Set() 有 if (old != new) 判断。如果转换后的值和原值相同,就不会触发二次通知。但这依赖于数值恰好相等,不够可靠。
  2. 增加重入锁(推荐):
void Subject_Set(Subject_t *sub, int new_value) {
    /* 防止重入 */
    static int is_notifying = 0;

    if (is_notifying) {
        sub->value = new_value;  /* 只更新值,不通知 */
        return;
    }

    if (sub->value != new_value) {
        is_notifying = 1;
        sub->value = new_value;
        Subject_NotifyAll(sub);
        is_notifying = 0;
    }
}
  1. 更好的设计: 避免双向绑定。只保留一个"真相源"(Single Source of Truth),其他都是派生值。

9.2 坑二:野指针崩溃

场景: 观察者被销毁了(比如关闭了一个窗口),但没有从 Subject 的链表里移除。下次数据变化时,调用了野指针。

void Window_Close(void) {
    /* 窗口关闭,销毁控件 */
    lv_obj_del(my_label);

    /* 忘了这一步!label_observer 还在 subject 的链表里! */
    // Subject_Detach(&temperature, &label_observer);
}

/* 之后温度变化时... */
Subject_Set(&temperature, 30);
/* → 调用 label_observer.on_update() */
/* → 访问已销毁的 my_label */
/* → 崩溃! */

解法:

  1. 养成习惯: 销毁观察者前,必须先 Detach。
void Window_Close(void) {
    /* 先解除绑定! */
    Subject_Detach(&temperature, (Observer_t *)&label_observer);

    /* 再销毁控件 */
    lv_obj_del(my_label);
}
  1. RAII 风格封装(如果你用 C++): 在观察者的析构函数里自动 Detach。
  2. 弱引用检查: 在 on_update 回调里检查目标对象是否有效。这需要额外的标志位,增加复杂度。

在这里插入图片描述

十、全系列大结局

走到这里,我们的「嵌入式设计模式」系列就要画上句号了。

十篇文章,十个设计模式,从最基础的封装思想,到今天的观察者模式。让我们用一张图回顾这段旅程:

在这里插入图片描述

每个模式解决什么问题?

在这里插入图片描述

十一、架构师的心法

写到最后,我想分享几点这些年踩坑的心得:

11.1 不要为了模式而模式

设计模式是工具,不是目的。

在一个 2KB Flash 的 51 单片机上,搞出几十层虚函数调用是犯罪——代码膨胀、栈溢出、调试困难,得不偿失。

但在百万行代码的 Linux 项目里,写面条式的全局变量满天飞是自杀——可维护性为零,改一处崩十处。

核心原则: 用最简单的方案解决问题。只有当简单方案遇到瓶颈时,才引入更复杂的模式。

11.2 六字真言:高内聚,低耦合

这六个字,我们讲了十篇文章。

高内聚: 一个模块只干一件事,把相关的代码放在一起。LCD 驱动就只管显示,别掺杂业务逻辑。

低耦合: 模块之间的依赖越少越好。修改 LCD 驱动,不应该影响 PID 算法;增加蓝牙功能,不应该改动 Flash 存储。

在这里插入图片描述

11.3 嵌入式架构师成长路线图

最后,送你一张我总结的成长路线图:

在这里插入图片描述

十二、写在最后

十篇文章,断断续续写了很久。

最开始写这个系列,是因为看到太多嵌入式工程师——包括曾经的我——被困在"能跑就行"的思维里。

我们可以把寄存器配得滚瓜烂熟,可以在示波器上抓各种时序,却在面对稍微复杂一点的业务逻辑时手足无措。代码越写越乱,Bug 越改越多,最后不是项目推翻重来,就是带着一身"技术债"艰难维护。

设计模式不是银弹,学了也不能立刻变成架构大师。但它提供的是一种思维方式:

• 遇到问题,先想想有没有现成的套路
• 写代码时,多问自己一句"以后好改吗"
• 模块划分时,时刻牢记"高内聚,低耦合"

这些思维习惯,会在日积月累中重塑你写代码的方式。

代码之路漫漫,设计模式只是随身的兵器。

愿诸位在嵌入式江湖,既能手搓寄存器,也能架构百万行。

我们下一季再见。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐