embedded-host-platform

通信协议

上位机与设备之间的帧格式。设计目标是链路不可靠时仍能判断每一帧是否可信: 串口会丢字节、会把几帧粘成一次读取、会把一帧拆成两次读取,偶尔还会翻转比特。 帧格式要做到三件事——在任意字节流里找到帧的起点、知道帧在哪里结束、确认帧没被改过。

权威定义在代码里:src/protocol/encoder.py / decoder.py(PC 侧), firmware/stm32f407/Drivers/BSP/PROTOCOL/(固件侧)。本文与代码不一致时以代码为准。

帧格式

所有多字节整数为大端序。

+--------+-----------+--------------+--------+-----------+-------+
| 帧头    | 设备号     | 命令码        | 长度    | 载荷       | CRC   |
| 2 B    | 1 B       | 1 B          | 2 B    | 长度 B     | 4 B   |
+--------+-----------+--------------+--------+-----------+-------+
  AA 55                                        UTF-8 JSON   CRC-32
字段 说明
帧头 固定 AA 55,接收方据此在字节流中定位帧起点
设备号 0~255 的数值 id。与上层字符串设备 id 的映射由 application 维护
命令码 见下文
长度 载荷字节数,0~65535;为 0 时帧仍然完整
载荷 UTF-8 JSON 文本
CRC CRC-32(与 zlib.crc32 相同),校验范围是设备号到载荷末尾,不含帧头——帧头是固定常量,不携带需要保护的信息

一帧真实的温度上报,49 字节:

AA 55 01 01 00 27 7B 22 63 68 61 6E 6E 65 6C 22 3A 22 74 65 6D 70 65 72
61 74 75 72 65 22 2C 22 76 61 6C 75 65 22 3A 32 37 2E 34 33 7D D0 72 63 13

AA 55        帧头
01           设备号 1
01           命令码 DATA_REPORT
00 27        载荷 39 字节
7B … 7D      {"channel":"temperature","value":27.43}
D0 72 63 13  CRC-32

浏览器控制台的”串口链路”页可以点开任意一帧,把这些字段逐个拆开, 并把帧里声明的 CRC 与浏览器现场重算的 CRC-32 并列显示。

为什么载荷是 JSON

曾计划在真实设备上改用紧凑的二进制格式,后来没做:一小时 3489 帧零丢帧、零校验失败, JSON 没有构成瓶颈,而文本载荷在抓包、排障、写测试时都直接可读。 固件侧没有引入 JSON 解析器,只用一个扫键名取整数的小函数(它不理解嵌套、转义与字符串值, 函数注释里写明了)。将来要换格式,影响范围只在两端的编解码处,帧结构不变。

命令码

码 名称 方向 载荷
0x01 DATA_REPORT 设备 → 上位机 {"channel": "temperature", "value": 27.43}
0x02 COMMAND_ACK 设备 → 上位机 {"status": "success"} 或 "failed"
0x10 / 0x11 FAN_ON / FAN_OFF 上位机 → 设备 无
0x12~0x14 ALERT_TEMPERATURE / _HUMIDITY / _NOISE 上位机 → 设备 无;设备播放对应的预录语音(0x13 是”湿度过低”)
0x15 ALERT_STATE 上位机 → 设备 {"bits": N},bit0 温度、bit1 湿度、bit2 噪声,为 1 表示报警中
0x16 ANSWER_SHOW 上位机 → 设备 最近一次问答的结果,六个整数(见下)
0x17 ALERT_HUMIDITY_HIGH 上位机 → 设备 无;播放”湿度过高”
0x18~0x1F 保留    
0x20 起 动态分配 上位机 → 设备 设备只需回应答、不需理解含义的命令(如 PING)

两档编码:固定与动态

最初所有业务命令都按”首次使用的顺序”动态分配编号。设备只回应答、不解释命令时这没问题; 但风扇要求设备区分”开”与”关”,而一个取决于”这次运行里谁先被发送”的编号承载不了含义—— 同一条命令两次运行可能拿到不同的码。

所以规则是:设备需要解释的命令,在两端固定编码(0x10~0x1F);只需应答的继续动态分配, 从 0x20 起,两个区间永不冲突。

固定编码是两个独立编译的程序之间的契约,编译器发现不了不一致,而且不一致时不会崩溃—— 设备对不认识的命令照样回应答,表现只是某个功能安静地失效。所以有一条测试 (tests/application/test_command_code_contract.py)直接读固件头文件,逐条比对两侧的数值, 并检查无重复、固定码不越出保留区间。

为什么报警位图由上位机下发

LCD 要显示每个通道”正常/报警”,固件完全可以自己比较阈值。没这么做,是因为那样阈值就有两份: 上位机一份、固件一份,任何一次调整都可能让两边悄悄分叉,表现为”电脑报警了、屏幕还显示正常”。 阈值只在上位机一处定义,固件只负责显示。

ANSWER_SHOW 下发的不是文本

键 含义
kind 答案种类 1~7:当前值、最高、最低、平均、是否越限、阈值、风扇状态
channel 0 温度、1 湿度、2 噪声、255 与通道无关
value / limit 数值 ×10 的整数(24.7 传 247;这条链路上不解析浮点)
flags bit0 越限、bit1 风扇运行、bit2 自动模式、bit3 阈值是上限
source 0 桌面端提问、1 移动端提问

板载字库只是界面用到的几十个汉字,画不出任意句子,所以下发”哪一类答案 + 数字”,由固件套自己的模板。 这个限制带来一个并非被迫的好处:语言模型怎么改写措辞,都不会影响屏幕上的内容,屏上永远是事实。 负数不下发(固件侧字段无符号,-2.0 会变成一个很大的正数被画出来),屏幕保留上一页。

应答与超时

设备收到任何一条上位机命令,都必须回一条 COMMAND_ACK(设备号与命令相同)。 上位机等待 2 秒,超时判定投递失败,向调用方抛出可捕获的异常,不影响数据流继续接收。

在字节流里拼帧

decode() 只解析”恰好一帧”的字节,找帧边界不是它的职责。 src/application/frame_stream.py 的 FrameStreamBuffer 负责这件事,上行接收与命令应答等待共用这一份实现:

缓冲区里的情况 处理
开头不是帧头 丢到下一个帧头为止,计一次重同步
没有完整帧头,末尾是帧头的前半个字节 保留,等后半个字节——这是正常状态,不计错误
帧头完整,但长度字段之后的字节还没到齐 等待,不判错
一次读到多帧 逐帧切出

切出的帧交给 decode():

异常 处理
CRC 不符 整帧丢弃,计入 CRC 失败,不尝试修复或部分使用
结构错误 整帧丢弃,计入格式错误
设备号不是本机、或不是数据帧 忽略,计入忽略数
载荷不是合法读数 丢弃,计入载荷错误

每一种都有独立计数,经网关的 /link/statistics 与 WebSocket 链路事件对外可见。

已知局限

已知缺陷:长度字段被破坏

触发方式。 长度字段在 CRC 保护范围内,但 CRC 要等整帧到齐才能校验。如果一次比特翻转 恰好让长度字段变大,上位机的拼帧缓冲会按这个错误的长度一直等下去,期间到达的正常帧都被当作 “这一帧”的一部分吞掉,直到凑够字节、CRC 失败、重同步,数据才恢复。

影响有多大。 本链路约 50 B/s(每 3 s 三帧、每帧约 49 字节):

翻转落在 后果
长度变小 CRC 立即失败,丢 1~2 帧,自动恢复
长度低字节变大 最多多等 128 字节,约 3 秒
长度高字节变大 多等 256~32768 字节,约 5 秒到 11 分钟

停顿期间连接仍显示正常、界面上的读数不再刷新,不报错;浏览器控制台的”串口链路”页能看出来—— 接收字节数在涨,帧数不动。之后自动恢复;重启程序会立即清空缓冲。

实际发生的可能性。 开发板到 PC 的路径是 MCU 串口 → 板载 USB 转串口芯片(几厘米走线)→ USB, 而 USB 自带 CRC 与重传。两次一小时实测、各约 170 KB 数据,CRC 失败都是 0。 即使出现比特错误,也要恰好落在一帧 49 字节里的那个长度高字节上,才会造成较长的停顿。 --inject-faults --fault-length 可以在真实硬件上有意触发它(只翻转低字节,停顿控制在几秒),见 verification.md。

固件一侧没有这个问题。 固件的接收缓冲是固定的 256 字节,拼帧时声明长度超出缓冲就把这个帧头当假的、 丢掉一个字节重新同步(firmware/stm32f407/Drivers/BSP/PC_LINK/pc_link.c)。 上位机用的是 Python 的可增长缓冲,没有容量上的硬约束,也就没人想到加这道检查。

修法。 在 src/application/frame_stream.py 里照搬固件的做法:给声明长度设上限 (本项目单帧载荷不超过百字节,上限取 1 KB 足够宽松),超限按假帧头处理并计一次重同步; 故障注入里已有”翻转长度字段”一种,修复后可以把它的核对从”只看没有错帧被接受”升级为逐项精确。约五行代码与一条测试。 评估后暂不修:它在演示与日常运行中实际不可达,而那条接收路径已经过真机长时间验证。