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;
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;
}
桥接的流程:
- 从 HMI 帧提取 桥接通道号(0-7)和负载数据。
- 构造一个 20 字节遗留协议帧,通道号映射到遗留命令字。
- 调用 legacy_proto_dispatcher() 走原有分发路径。
- 捕获响应(同步调用),格式化为 HMI 协议响应帧返回。
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 速率控制
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 的设计经验可以提炼为几个通用原则:
- 明确的会话边界:用 SOF+SEQ+CRC 定义帧边界,为请求-响应配对提供可靠基础。
- 兼容性优先:HELLO 握手和版本协商在协议层解决固件-工程不匹配问题。
- 桥接而非重写:Control Bridge 模式证明,在协议升级中复用遗留逻辑比重写更经济、更可靠。
- 流式推送减轮询:事件/日志/堆栈三级订阅流将上位机从轮询的泥潭中解放出来。
- 速率意识:9600bps 的约束倒逼了速率控制和优先级设计,这些设计在高速率下同样有益。
Comments NOTHING