命令模式:命令封装、命令队列、撤销与重做

命令模式,说白了就是把「请求」打包成一个对象。你想想看,在嵌入式系统里,我们经常要处理各种操作——按键按下、串口收到指令、定时器超时……传统做法是写一堆 if-else 或者 switch-case,代码越堆越臃肿。命令模式就是来解决这个问题的。

我个人习惯把命令模式理解为「任务票据」。就像你去餐厅点餐,服务员不会直接冲进厨房喊「宫保鸡丁!」,而是写一张小票交给厨师。这张小票就是命令对象,它包含了所有必要的信息。厨师只管按小票做菜,不用管是谁点的、什么时候点的。

命令模式的核心结构

先看一个最精简的命令模式骨架。我当年第一次接触这个模式时,就被它的简洁震撼到了——就四个角色:

  • Command(命令接口):定义执行操作的统一接口,通常就一个 execute() 方法
  • ConcreteCommand(具体命令):实现命令接口,持有接收者对象的引用
  • Receiver(接收者):真正干活的对象,知道如何执行具体的业务逻辑
  • Invoker(调用者):持有命令对象,在合适的时机调用命令的 execute()

核心思想:调用者不关心命令怎么执行,接收者不关心命令什么时候被调用。两者通过命令对象解耦。

来看代码,我习惯用 LED 控制来举例,因为这是嵌入式里最常见的场景:

// 命令接口
typedef struct Command {
    void (*execute)(void *receiver);
    void (*undo)(void *receiver);
    void *receiver;
} Command_t;

// 接收者——LED
typedef struct {
    uint8_t pin;
    uint8_t state;
} LED_t;

// 具体命令——开灯
void led_on_execute(void *receiver) {
    LED_t *led = (LED_t *)receiver;
    led->state = 1;
    HAL_GPIO_WritePin(led->pin, GPIO_PIN_SET);
}

void led_on_undo(void *receiver) {
    LED_t *led = (LED_t *)receiver;
    led->state = 0;
    HAL_GPIO_WritePin(led->pin, GPIO_PIN_RESET);
}

// 调用者——按键
typedef struct {
    Command_t *cmd;
} Button_t;

void button_press(Button_t *btn) {
    if (btn->cmd) {
        btn->cmd->execute(btn->cmd->receiver);
    }
}

嗯,这里要注意:命令对象里我特意加了一个 undo() 方法。为什么?因为撤销功能在嵌入式里太常用了——用户误操作、调试时想回退状态、甚至故障恢复都需要它。

命令队列:让系统不再手忙脚乱

我在项目中遇到过一个问题:一个传感器中断服务函数里,要同时处理数据解析、状态更新、UI刷新、日志记录……结果中断执行时间过长,系统直接崩了。

命令队列就是解决这个问题的。说白了,就是把紧急的事(中断里)和耗时的事(主循环里)分开。中断里只做一件事:把命令塞进队列。主循环里再慢慢执行。

#define CMD_QUEUE_SIZE 16

typedef struct {
    Command_t *buffer[CMD_QUEUE_SIZE];
    uint8_t head;
    uint8_t tail;
    uint8_t count;
} CommandQueue_t;

// 入队——可以在中断中调用
int cmd_queue_push(CommandQueue_t *q, Command_t *cmd) {
    if (q->count >= CMD_QUEUE_SIZE) return -1;  // 队列满
    q->buffer[q->head] = cmd;
    q->head = (q->head + 1) % CMD_QUEUE_SIZE;
    q->count++;
    return 0;
}

// 出队——在主循环中调用
Command_t* cmd_queue_pop(CommandQueue_t *q) {
    if (q->count == 0) return NULL;
    Command_t *cmd = q->buffer[q->tail];
    q->tail = (q->tail + 1) % CMD_QUEUE_SIZE;
    q->count--;
    return cmd;
}

// 主循环
void main_loop(void) {
    CommandQueue_t queue = {0};
    while (1) {
        Command_t *cmd = cmd_queue_pop(&queue);
        if (cmd) {
            cmd->execute(cmd->receiver);
        }
        // 其他任务...
    }
}

经验之谈:队列大小要按最坏情况估算。我曾经因为队列设小了,在高频中断下丢命令,查了两天才发现是队列溢出。建议留 2 倍余量,并在调试阶段加一个溢出计数器。

撤销与重做:给用户一个后悔药

撤销功能在嵌入式里其实比想象中更常用。比如工业设备的参数调整、医疗设备的操作回退、甚至智能家居的场景切换。实现撤销的核心就是「状态快照」或者「逆操作」。

我比较推荐「逆操作」的方式,因为嵌入式内存有限,存快照太奢侈了。每个命令都实现 undo() 方法,撤销时调用它就行。

#define UNDO_STACK_SIZE 32

typedef struct {
    Command_t *stack[UNDO_STACK_SIZE];
    int top;      // 当前栈顶
    int redo_top; // 重做栈顶
} UndoRedoManager_t;

// 执行命令并压入撤销栈
void execute_and_push(UndoRedoManager_t *mgr, Command_t *cmd) {
    cmd->execute(cmd->receiver);
    if (mgr->top < UNDO_STACK_SIZE - 1) {
        mgr->stack[++mgr->top] = cmd;
        mgr->redo_top = mgr->top;  // 新命令执行后,清空重做栈
    }
}

// 撤销
void undo(UndoRedoManager_t *mgr) {
    if (mgr->top < 0) return;
    Command_t *cmd = mgr->stack[mgr->top--];
    cmd->undo(cmd->receiver);
}

// 重做
void redo(UndoRedoManager_t *mgr) {
    if (mgr->top >= mgr->redo_top) return;
    Command_t *cmd = mgr->stack[++mgr->top];
    cmd->execute(cmd->receiver);
}

注意:撤销栈的深度要谨慎设置。我曾经在一个项目中设了 64 层撤销,结果内存爆了。后来改成 16 层,用户反馈完全够用。另外,有些操作是不可撤销的——比如「格式化存储卡」,这种命令就不要压入撤销栈了。

命令模式在嵌入式中的典型应用

我整理了一张表,列出我见过的几个真实场景:

应用场景 命令对象 接收者 调用者
按键处理 按键命令 LED、蜂鸣器、电机 按键中断/扫描
串口指令 协议解析命令 传感器、执行器 串口接收中断
定时任务 延时执行命令 系统状态机 定时器回调
GUI操作 界面交互命令 显示驱动、存储 触摸屏事件
调试工具 调试命令 内存、寄存器 命令行解析器

命令模式的核心流程图

下面这张图展示了命令模式的完整工作流程,从命令创建到执行再到撤销:

命令模式工作流程 调用者 (Invoker) 按键/中断/主循环 命令队列 FIFO 缓冲区 命令对象 execute() / undo() 接收者 (Receiver) LED/电机/传感器 撤销栈 LIFO 历史记录 入队 出队执行 调用 压栈 撤销 图例 调用者 命令队列 命令对象 接收者 撤销栈

避坑指南与最佳实践

做了这么多年嵌入式,我用命令模式踩过不少坑,分享几个最深刻的:

  • 命令对象的内存管理:我曾经在堆上动态分配命令对象,结果频繁分配释放导致内存碎片。后来改用静态池 + 引用计数,问题就解决了。嵌入式里尽量别用 malloc。
  • 中断中的命令入队:队列操作必须是原子性的。我习惯关中断入队,或者用无锁环形缓冲区。千万别在中断里执行命令,只入队。
  • 撤销操作的幂等性:有些命令的 undo() 可能被多次调用。比如「开灯」的撤销是「关灯」,但如果灯已经关了,再关一次会不会出问题?要保证 undo() 是安全的。
  • 命令的优先级:我做过一个项目,紧急停止命令必须插队到队列最前面。后来我在命令结构体里加了一个优先级字段,队列支持按优先级排序。

我的习惯:每个命令对象都加一个 timestamp 字段,记录命令创建的时间。调试时特别有用——可以追踪命令的执行延迟,定位性能瓶颈。

命令模式在嵌入式里最大的价值,就是让代码变得「可插拔」。你可以随时添加新命令,不用改调用者的代码。你想想看,如果产品要增加一个新功能,你只需要写一个新的命令类,注册到系统里就行了。其他代码完全不用动——这才是真正的「开闭原则」。

好了,关于命令模式就聊这么多。记住三个关键词:封装请求、队列缓冲、可撤销。下次写代码时,遇到需要「排队执行」或者「支持回退」的场景,不妨试试命令模式。


公众号:蓝海资料掘金营,微信deep3321