12、状态机设计模式:单例模式、观察者模式、状态模式在C语言中的实现

说实话,很多嵌入式工程师写状态机,写着写着就成了一锅粥。全局变量满天飞,状态切换靠if-else堆砌,代码改一次就崩一次。我早年也踩过这个坑,后来才意识到——状态机本身也需要好的设计模式来约束

这一章,咱们就聊聊三种最实用的设计模式:单例模式观察者模式状态模式。它们不是C++的专利,C语言一样能玩得转。而且,在状态机场景下,这些模式能帮你写出更干净、更可维护的代码。

12.1 单例模式:确保状态机实例唯一

先问个问题:你的系统里,同一个状态机需要存在多个实例吗?

大多数情况下,不需要。比如一个LED控制状态机、一个通信协议状态机,全局只有一个就够了。如果谁都能new一个出来,那状态就乱了。

单例模式,说白了就是保证一个类只有一个实例,并提供一个全局访问点。在C语言里,我们通常用静态变量来实现。

12.1.1 经典实现:懒汉式

我个人习惯用懒汉式——第一次使用时才创建实例。这样能避免启动时不必要的初始化开销。

/* singleton_state_machine.h */
typedef struct {
    uint8_t current_state;
    uint8_t event_queue[16];
    uint8_t queue_head, queue_tail;
} StateMachine;

StateMachine* StateMachine_GetInstance(void);
/* singleton_state_machine.c */
static StateMachine instance = {0};
static uint8_t initialized = 0;

StateMachine* StateMachine_GetInstance(void) {
    if (!initialized) {
        instance.current_state = STATE_IDLE;
        instance.queue_head = 0;
        instance.queue_tail = 0;
        initialized = 1;
    }
    return &instance;
}

你看,代码很简单。但有个坑——多线程环境下,两个任务同时调用GetInstance,可能都发现initialized == 0,然后各自初始化一遍。我曾经在一个RTOS项目里就遇到过这个问题,调试了两天才找到原因。

避坑指南: 如果状态机可能被多个任务访问,记得加互斥锁。或者用「饿汉式」——在程序启动时就初始化好,一劳永逸。

12.1.2 饿汉式实现

/* 饿汉式:编译期就初始化 */
static StateMachine instance = {
    .current_state = STATE_IDLE,
    .queue_head = 0,
    .queue_tail = 0
};

StateMachine* StateMachine_GetInstance(void) {
    return &instance;
}

嗯,这样就没有竞态条件了。代价是——不管用不用,实例都占着内存。对于资源紧张的MCU,你得权衡一下。

12.2 观察者模式:状态变化通知机制

状态机跑着跑着,状态变了。谁需要知道这个变化?

可能是LED驱动要更新显示,可能是日志模块要记录,也可能是另一个状态机要联动。如果让状态机直接调用这些模块,耦合度就太高了。

观察者模式就是来解决这个问题的。状态机作为「被观察者」,只负责发布事件。其他模块作为「观察者」,订阅自己感兴趣的事件。双方互不依赖。

12.2.1 用函数指针实现观察者

C语言没有类,但函数指针就是我们的利器。

/* observer.h */
#define MAX_OBSERVERS 8

typedef void (*StateChangeHandler)(uint8_t old_state, uint8_t new_state, void* context);

typedef struct {
    StateChangeHandler handler;
    void* context;
} Observer;

typedef struct {
    Observer observers[MAX_OBSERVERS];
    uint8_t count;
} Observable;
/* observer.c */
void Observable_Init(Observable* obs) {
    obs->count = 0;
}

int Observable_Subscribe(Observable* obs, StateChangeHandler handler, void* context) {
    if (obs->count >= MAX_OBSERVERS) return -1;
    obs->observers[obs->count].handler = handler;
    obs->observers[obs->count].context = context;
    obs->count++;
    return 0;
}

void Observable_Notify(Observable* obs, uint8_t old_state, uint8_t new_state) {
    for (uint8_t i = 0; i < obs->count; i++) {
        obs->observers[i].handler(old_state, new_state, obs->observers[i].context);
    }
}

我在一个智能家居项目里用过这个模式。主状态机管理着「安防模式」「离家模式」「睡眠模式」,每次切换,窗帘、灯光、空调各自响应。如果不用观察者模式,状态机里得写一堆if-else,想想就头疼。

小技巧: 观察者回调里不要做耗时操作。如果某个观察者需要处理大量数据,让它把事件放入自己的队列,回头慢慢处理。

12.3 状态模式:把状态行为封装成对象

传统的switch-case状态机,状态越多,case分支越臃肿。而且,每个case里可能还嵌套着子switch,代码可读性直线下降。

状态模式的思路是:把每个状态的行为封装成一个独立的结构体,里面放上该状态下的处理函数指针。状态切换时,只需要换掉这个结构体指针就行。

12.3.1 状态接口定义

/* state.h */
typedef struct State State;

typedef struct {
    void (*on_entry)(State* self, void* context);
    void (*on_exit)(State* self, void* context);
    uint8_t (*handle_event)(State* self, uint8_t event, void* context);
} StateVtable;

struct State {
    const StateVtable* vtable;
    uint8_t state_id;
};

12.3.2 具体状态实现

/* idle_state.c */
static void Idle_OnEntry(State* self, void* context) {
    /* 进入空闲状态,关闭所有输出 */
    printf("Enter IDLE state\n");
}

static void Idle_OnExit(State* self, void* context) {
    printf("Exit IDLE state\n");
}

static uint8_t Idle_HandleEvent(State* self, uint8_t event, void* context) {
    switch (event) {
        case EVT_START:
            /* 切换到运行状态 */
            return STATE_RUNNING;
        case EVT_ERROR:
            return STATE_ERROR;
        default:
            return STATE_IDLE;  /* 不处理 */
    }
}

const StateVtable idle_vtable = {
    .on_entry = Idle_OnEntry,
    .on_exit = Idle_OnExit,
    .handle_event = Idle_HandleEvent
};

State idle_state = {
    .vtable = &idle_vtable,
    .state_id = STATE_IDLE
};

12.3.3 状态机上下文

/* state_machine.c */
typedef struct {
    State* current_state;
    void* context_data;
} StateMachineContext;

void StateMachine_Transition(StateMachineContext* ctx, State* new_state) {
    if (ctx->current_state && ctx->current_state->vtable->on_exit) {
        ctx->current_state->vtable->on_exit(ctx->current_state, ctx->context_data);
    }
    ctx->current_state = new_state;
    if (new_state && new_state->vtable->on_entry) {
        new_state->vtable->on_entry(new_state, ctx->context_data);
    }
}

uint8_t StateMachine_HandleEvent(StateMachineContext* ctx, uint8_t event) {
    if (!ctx->current_state) return 0;
    uint8_t next_id = ctx->current_state->vtable->handle_event(
        ctx->current_state, event, ctx->context_data
    );
    /* 根据next_id查找对应的State对象,然后切换 */
    /* ... */
}

你想想看,这样写的好处是什么?每个状态都是独立的模块,新增一个状态,只需要写一个新的xxx_state.c,不用动其他代码。我在一个通信协议栈里用了这个模式,状态从最初的5个扩展到15个,代码依然清晰。

核心要点: 状态模式把「状态切换逻辑」和「状态行为」解耦了。每个状态只关心自己该做什么,不关心其他状态怎么处理。这符合开闭原则——对扩展开放,对修改关闭。

12.4 三种模式的对比与选择

模式 核心思想 适用场景 典型问题
单例模式 确保唯一实例 全局唯一的状态机,如系统主控状态机 多线程竞态、全局变量污染
观察者模式 一对多通知 状态变化需要通知多个模块 回调执行时间过长、内存泄漏
状态模式 状态行为封装 状态多、行为复杂、频繁增删状态 代码量增加、函数指针开销

说实话,这三个模式不是互斥的。我经常把它们组合使用——单例模式保证状态机唯一,状态模式组织内部逻辑,观察者模式对外通知变化。三者配合,代码质量能上一个台阶。

12.5 知识体系总览

下面这张图,帮你理清这三种模式在状态机中的位置和关系。

状态机设计模式知识体系 状态机核心 单例模式 确保唯一实例 观察者模式 状态变化通知 状态模式 行为封装 静态变量 + 全局访问点 懒汉式 / 饿汉式 函数指针回调 订阅-通知机制 虚函数表 + 状态对象 on_entry / on_exit 组合使用:单例 + 状态模式 + 观察者 = 高内聚低耦合

嗯,这张图把三个模式的关系说清楚了。单例模式管「唯一性」,状态模式管「内部行为」,观察者模式管「外部通知」。各司其职,互不干扰。

我的建议: 别一上来就套用所有模式。先从单例模式开始,把状态机实例管好。等发现状态切换逻辑越来越复杂时,再引入状态模式。等发现通知代码散落各处时,再引入观察者模式。循序渐进,别过度设计。

好了,这一章的内容就到这里。三种模式,每个都有它的用武之地。你在实际项目中,可以根据状态机的复杂度和需求灵活选用。记住,模式是工具,不是目的。写出好维护的代码,才是我们的目标。