上位机与设备之间的帧格式。设计目标是链路不可靠时仍能判断每一帧是否可信: 串口会丢字节、会把几帧粘成一次读取、会把一帧拆成两次读取,偶尔还会翻转比特。 帧格式要做到三件事——在任意字节流里找到帧的起点、知道帧在哪里结束、确认帧没被改过。
权威定义在代码里: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 并列显示。
曾计划在真实设备上改用紧凑的二进制格式,后来没做:一小时 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 足够宽松),超限按假帧头处理并计一次重同步;
故障注入里已有”翻转长度字段”一种,修复后可以把它的核对从”只看没有错帧被接受”升级为逐项精确。约五行代码与一条测试。
评估后暂不修:它在演示与日常运行中实际不可达,而那条接收路径已经过真机长时间验证。