MCU端 HMI Session 协议实现

Babel36acl 嵌入式实战 无~ 7 次阅读 预计阅读时间: 16 分钟 发布于 1 天前 最后更新于 1 小时前 3599 字


MCU端 HMI Session 协议实现

发表于 2026-05-29 · 分类:嵌入式实战

在嵌入式 HMI(人机交互)系统中,MCU 与触摸屏或上位机之间的通信协议设计直接影响系统的实时性、可靠性和可维护性。传统的逐变量轮询协议(如 DGUS 的 5A A5 帧格式)在参数数量激增后暴露出扩展性差、握手缺失、无流控等问题。

本文基于 INDL_CONTROLLER_BOARD 项目,分享我们如何用一套面向会话(Session-Oriented)的二进制协议替代遗留的 DGUS 逐变量协议,实现版本协商、批量参数操作、事件推送、订阅流等现代交互范式。

1. 背景:为什么放弃 DGUS 逐变量协议?

遗留模块 packer_dbus(dbus.c)基于 DGUS 的 5A A5 帧格式,每次只读写一个变量地址,帧结构简单但存在深层问题:

  • 无会话层:每次交互是孤立的,无法区分请求/响应/推送。
  • 无版本协商:MCU 固件和 HMI 工程版本不匹配时无法提前告警。
  • 无批量能力:几十个参数逐变量轮询,9600bps 下延迟不可接受。
  • 无事件推送:告警、状态变化只能等上位机轮询,响应滞后。

新模块 packer_hmi_proto 完全重写了通信层,运行在 USART1 / RS232 / 9600 8N1 上,所有业务逻辑在一个任务循环 packer_hmi_proto_task_once() 中完成。

2. 帧格式

每帧固定以 0x55AA 起始,CRC16 校验结尾,最大负载长度 128 字节:

/* HMI Session Frame Format
 * ┌────────┬─────┬──────┬─────┬─────┬──────┬──────┬─────────────┬───────┐
 * │  SOF   │ VER │ TYPE │ SEQ │ CMD │FLAGS │ LEN  │   PAYLOAD   │ CRC16 │
 * │(2 Byte)│(1B) │ (1B) │(2B) │ (1B)│ (1B) │ (2B) │ (0..128 B)  │ (2B)  │
 * └────────┴─────┴──────┴─────┴─────┴──────┴──────┴─────────────┴───────┘
 */

// 帧定义常量
#define HMI_FRAME_SOF_WORD       0x55AAU
#define HMI_FRAME_VERSION       0x01U
#define HMI_FRAME_HEADER_LEN    9U
#define HMI_FRAME_MAX_PAYLOAD   128U
#define HMI_FRAME_TAIL_LEN      2U     // CRC16
字段 长度 说明
SOF 2B 帧起始标志 0x55AA
VER 1B 协议版本号(当前 0x01)
TYPE 1B 帧类型:请求/响应/事件/日志/心跳
SEQ 2B 序列号,用于请求-响应配对
CMD 1B 命令字(25 个命令)
FLAGS 1B 标志位(保留扩展)
LEN 2B 负载长度(0-128)
PAYLOAD 0-128B 数据负载
CRC16 2B 从 SOF 到 PAYLOAD 的 CRC16-IBM 校验

3. 帧类型与命令体系

3.1 五种帧类型

// 帧类型枚举
typedef enum {
    HMI_FRAME_TYPE_REQUEST    = 0x01,  // 请求帧
    HMI_FRAME_TYPE_RESPONSE   = 0x02,  // 响应帧
    HMI_FRAME_TYPE_EVENT      = 0x03,  // 事件推送
    HMI_FRAME_TYPE_LOG        = 0x04,  // 日志推送
    HMI_FRAME_TYPE_HEARTBEAT  = 0x05,  // 心跳帧
} hmi_frame_type_t;

请求-响应配对:上位机发 0x01 请求,MCU 回 0x02 响应(SEQ 相同)。事件和日志由 MCU 主动推送,心跳由 MCU 按固定间隔发出(也可视作一种特殊的事件推送)。

3.2 25 个命令

命令字 名称 说明
0x01 HELLO 握手与版本协商
0x02 DEVICE_INFO 获取设备信息
0x03 HEARTBEAT 心跳
0x10 GET_GROUP_LIST 获取参数组列表
0x11 GET_PARAM_LIST 获取参数列表
0x12 GET_PARAM_VALUES_BATCH 批量读参数
0x13 SET_PARAM_VALUES_BATCH 批量写参数
0x14 SAVE_PARAMS 保存参数到非易失存储器
0x15 LOAD_PARAMS 从非易失存储器加载
0x16 DEFAULT_PARAMS 恢复出厂默认值
0x20 GET_DEVICE_STATUS 获取设备运行状态
0x21 GET_ALARM_STATUS 获取告警状态
0x22 SUBSCRIBE_STREAMS 订阅流(事件/日志/堆栈)
0x23 UNSUBSCRIBE_STREAMS 取消订阅流
0x30-0x37 CONTROL_BRIDGE 控制桥接(8 个通道)

4. Hello 握手与版本协商

上位机上线后发出的第一个请求必须是 HELLO(CMD=0x01)。MCU 检查协议版本兼容性,返回设备信息和协议能力位图。版本不匹配时返回错误码,上位机可据此决定是否降级或报错。

// HELLO 请求负载结构
typedef struct __attribute__((packed)) {
    uint8_t  protocol_version;       // 上位机期望的协议版本
    uint8_t  vendor_id[8];           // 厂商标识
    uint16_t capabilities;           // 能力位图
} hello_req_t;

// HELLO 响应负载结构
typedef struct __attribute__((packed)) {
    uint8_t  protocol_version;       // MCU 实际使用的版本
    uint8_t  error_code;             // 0=OK, 1=版本不兼容
    uint8_t  device_id[16];          // 设备唯一标识
    uint16_t capabilities;           // MCU 能力位图
} hello_resp_t;
关键设计:握手阶段完成前,MCU 不处理任何参数读写命令。通过 hmi_session_state_t 状态机确保协议遵循正确的时序。

5. 参数访问门控

参数读写命令(0x10-0x16)受状态门控保护:只有当系统处于 IDLE 状态、未运行、引导序列未激活时,才允许通过。

// 参数访问门控检查
static bool hmi_param_access_allowed(void) {
    // 仅在 IDLE、未运行、引导未激活时允许参数操作
    return (g_app_state.run_state == RUN_STATE_IDLE)
        && (g_app_state.flags & APP_FLAG_RUNNING) == 0
        && (g_app_state.boot_state == BOOT_STATE_COMPLETE);
}

这是重要的安全措施——设备正在运行时修改参数可能导致执行异常。上位机需要先发送停机指令或等待设备进入空闲状态。

6. 控制桥接(Bridge Pattern)

这是本模块最巧妙的设计点。遗留系统中有大量基于旧 20 字节协议(app_packer_proto)的业务逻辑——电机控制、IO 操作、固件升级等。完全重写它们成本极高。

Control Bridge 命令(0x30-0x37,8 个通道)解决此问题:

// 桥接调度:将 HMI 协议命令转换为 20B 遗留协议帧
static int app_packer_proto_bridge_dispatch(
    uint8_t  bridge_channel,   // 0x30-0x37
    uint8_t *payload,
    uint16_t len,
    uint8_t *resp_buf,
    uint16_t *resp_len)
{
    // 1. 构造遗留 20B 协议帧
    uint8_t legacy_frame[20];
    memset(legacy_frame, 0, sizeof(legacy_frame));
    legacy_frame[0] = LEGACY_SOF;
    legacy_frame[1] = bridge_channel & 0x0F;  // 通道号映射到命令字
    legacy_frame[2] = len > 17 ? 17 : len;    // 负载长度
    memcpy(&legacy_frame[3], payload,
           (len > 17) ? 17 : len);
    legacy_frame[19] = calc_legacy_checksum(legacy_frame, 19);

    // 2. 通过遗留分发器处理
    int ret = legacy_proto_dispatcher(legacy_frame,
                                        resp_buf, resp_len);

    // 3. 捕获响应,格式化为 HMI 协议响应帧
    return ret;
}

桥接的流程:

  1. 从 HMI 帧提取 桥接通道号(0-7)和负载数据。
  2. 构造一个 20 字节遗留协议帧,通道号映射到遗留命令字。
  3. 调用 legacy_proto_dispatcher() 走原有分发路径。
  4. 捕获响应(同步调用),格式化为 HMI 协议响应帧返回。
收益:新增 8 个桥接命令即可复用所有遗留控制逻辑,无需触及其内部实现。桥接是透明代理,对遗留代码完全无侵入。

7. 订阅流架构(Streaming)

为了让上位机实时感知设备状态变化,设计了三级订阅流:

7.1 事件队列(Event Queue)

深度 8,存储设备状态变化事件(运行状态切换、告警触发/恢复、标志位变化等)。

7.2 日志队列(Log Queue)

深度 6,每条日志最大 127 字节。存储运行时诊断日志。

7.3 堆栈快照(Stack Snapshot)

7 个任务各保留一个快照槽位,存储任务调用栈(用于死锁/看门狗分析)。

// 订阅流控制结构
typedef struct {
    bool     event_subscribed;      // 是否订阅事件推送
    bool     log_subscribed;        // 是否订阅日志推送
    bool     stack_subscribed;      // 是否订阅堆栈快照
    uint16_t event_queue_depth;     // 事件队列当前深度
    uint16_t log_queue_depth;       // 日志队列当前深度
} hmi_stream_subscription_t;

// 状态变化检测 — 缓存旧值,变化时推事件
static void hmi_detect_status_change(void) {
    static uint8_t  prev_run_state  = 0xFF;
    static uint16_t prev_flags      = 0xFFFF;
    static uint8_t  prev_boot_state = 0xFF;
    static uint16_t prev_alarm_code = 0xFFFF;
    static uint8_t  prev_latch      = 0xFF;

    if (g_app_state.run_state  != prev_run_state  ||
        g_app_state.flags       != prev_flags      ||
        g_app_state.boot_state  != prev_boot_state ||
        g_app_state.alarm_code  != prev_alarm_code ||
        g_app_state.latch       != prev_latch) {
        // 缓存快照,推入事件队列
        hmi_push_event(EVENT_STATUS_CHANGE,
                       &g_app_state, sizeof(app_state_t));
    }
}

7.4 速率控制

速率限制:每个 task_once 周期最多推送 1 条日志。命令响应优先级高于流推送——不能在处理命令时插入事件。保证在 9600bps 下不出现 TX FIFO 溢出。

8. 主循环:task_once 生命周期

所有协议处理都在 packer_hmi_proto_task_once() 中串行完成,由 CommTask 以固定周期调用:

void packer_hmi_proto_task_once(void) {
    // Phase 1: 接收与解码
    while (uart_bytes_available(HMI_USART)) {
        if (hmi_frame_decoder_feed(uart_read_byte(HMI_USART))
                == FRAME_DECODE_COMPLETE) {
            // 帧接收完成
            break;
        }
    }

    // Phase 2: 如果有完整帧,执行分发
    if (frame_decoded) {
        hmi_frame_dispatch(&decoded_frame);
    }

    // Phase 3: 状态变化检测 → 推送事件
    hmi_detect_status_change();
    hmi_flush_event_queue();

    // Phase 4: 推送堆栈快照(如订阅且就绪)
    hmi_flush_stack_snapshot();

    // Phase 5: 推送日志(速率限制:最多 1 条)
    hmi_flush_log_queue();
}

所有对外 TX 操作仅发生在 Phase 3-5 中。公共 API 不直接写 UART,而是将数据推入内部队列(临界区保护),由 task_once 在下一周期逐一发送:

// 公共 API — 仅入队,不发送
void packer_hmi_proto_push_event(hmi_event_t event) {
    taskENTER_CRITICAL();
    if (event_queue.count < EVENT_QUEUE_DEPTH) {
        event_queue.buf[event_queue.tail] = event;
        event_queue.tail = (event_queue.tail + 1) % EVENT_QUEUE_DEPTH;
        event_queue.count++;
    }
    taskEXIT_CRITICAL();
}

// 实际的 USART1 TX 发生在 task_once 上下文中
static void hmi_flush_event_queue(void) {
    while (event_queue.count > 0) {
        taskENTER_CRITICAL();
        hmi_event_t evt = event_queue.buf[event_queue.head];
        event_queue.head = (event_queue.head + 1) % EVENT_QUEUE_DEPTH;
        event_queue.count--;
        taskEXIT_CRITICAL();

        // 构造事件帧,写入 UART
        hmi_send_frame(HMI_FRAME_TYPE_EVENT, 0,
                       CMD_EVENT_PUSH, &evt, sizeof(evt));
    }
}

9. 与遗留 DGUS 模块的共存策略

遗留模块 packer_dbus(dbus.c)未被删除,但在新项目中已被 packer_hmi_proto 完全替代。两者独占同一 USART1 端口,不共存。

维度 packer_dbus (遗留) packer_hmi_proto (新)
帧格式 5A A5 逐变量 55AA 会话帧
交互模式 轮询 请求-响应 + 主动推送
批量操作 不支持 支持批量读写
握手 HELLO 版本协商
事件推送 三级订阅流
桥接遗留逻辑 N/A 8 通道控制桥接

10. 总结与心得

packer_hmi_proto 的设计经验可以提炼为几个通用原则:

  1. 明确的会话边界:用 SOF+SEQ+CRC 定义帧边界,为请求-响应配对提供可靠基础。
  2. 兼容性优先:HELLO 握手和版本协商在协议层解决固件-工程不匹配问题。
  3. 桥接而非重写:Control Bridge 模式证明,在协议升级中复用遗留逻辑比重写更经济、更可靠。
  4. 流式推送减轮询:事件/日志/堆栈三级订阅流将上位机从轮询的泥潭中解放出来。
  5. 速率意识:9600bps 的约束倒逼了速率控制和优先级设计,这些设计在高速率下同样有益。
完整的帧编解码、CRC 计算、队列管理代码见 packer_hmi_proto.cpacker_hmi_proto.h。本文展示的是脱敏后的架构设计片段——实际实现中还需处理超时重传、错误恢复、动态订阅管理等细节。
此作者没有提供个人介绍。
最后更新于 2026-05-30