错位、丢尾、永久失步:三态状态机把串口字节流拼成完整帧

EMK850+ 是一台低功耗分析仪:经 USB 虚拟串口(115200)连电脑,持续回传电流/电压采样流,nA 到 mA 自动换挡。低功耗测试的常态是让被测设备长时间挂机,看完整的功耗曲线,采样数据只能经配套上位机读取。

但这个上位机撑不住挂机:连续运行接近 1 小时就卡死。我反编译了它的接收代码定位缺陷,再重写了一个流式解析器替换。

串口字节流没有帧边界

串口物理层按字节连续传输,协议层把若干字节聚成帧。帧边界只能由帧头特征和长度字段给出:本协议帧头是 0x33,第 3 字节 len 声明载荷长度。接收端拿到的是不断流出的字节序列,线上没有"这条消息结束"的信号。

按固定长度切块因此是一种强假设:缓冲区起点恰好是帧起点。这个假设不由协议保证。接收端读得快、事件在帧边界触发,假设成立;读慢一拍、事件在帧中间触发,假设失效。

反编译取证:上位机接收的两类缺陷

反编译 EMK850+ 上位机(.NET 4.7.2,ilspycmd),主接收函数 MainWindow.SysReceiveData 按 64 字节整块读取:

while (serialPort.BytesToRead >= 64) {
    serialPort.Read(array, 0, 64);
    Protocol prot = (Protocol)Utils.BytesToStruct(array, typeof(Protocol));
    HandleProtocol(prot, array);
}
if (serialPort.BytesToRead > 0 && serialPort.BytesToRead < 64) {
    serialPort.Read(array, 0, serialPort.BytesToRead);   // 尾包直接丢弃
}

主路径上每次 Read(array, 0, 64) 都能凑满 64 字节,读取本身从不失败。真正的脆弱点是:这 64 字节未必等于完整一帧。字节流中间一旦出错,读到的 64 字节就变成跨帧的错位窗口,内容全是错的。

接收逻辑的缺陷分两类。第一类是错位:整块读取有三个具体缺陷点。

  1. 无帧头扫描、无重新同步。主接收路径拿到 64 字节直接按 cmd 分发,不校验 0x33;采样数据分支(cmd 33/132)同样不看帧头。个别配置响应分支里虽有 buffer[0]==51 的判断,失败仅静默丢帧,不构成重新同步。对齐一错,后面每帧都错。
  2. 尾包无条件丢弃。每个接收事件结束时缓冲被清空,不足 64 字节的残段直接丢掉(源码日志原文就是"抛掉 N 字节数据"),既丢数据又加重失步。
  3. 结构体盲解。Utils.BytesToStruct 直接 Marshal.Copy 64 字节;错位后 cmd 是垃圾,按它分发即静默误判。

第二类是挂死:无超时的阻塞读。calWindow 直接 Read(array, 0, 64) 不检查 BytesToRead,且全代码未设 ReadTimeout(.NET 默认无限超时)。USB 流中断、凑不满 64 字节时,该事件线程永久挂起。错位导致数据损坏,阻塞导致挂死,两类缺陷独立。

一次错位引发的永久失步

每次 Read(array, 0, 64) 都能凑满 64 字节。而且正常情况下对齐是白捡的:设备一帧正好 64 字节,经 USB 送达时一帧一个包;USB 自带校验和重传,坏包不会交付半个,丢也是整包丢,而整包丢失不破坏对齐,只是数据少一帧。但这只是观察到的行为,不是契约:虚拟串口对应用层就是一根字节流管道,操作系统和驱动从不承诺按包交付,经过驱动缓冲、集线器、隔离器之后,BytesToRead 出现任何字节数都合法,换个电脑、换个驱动版本行为就可能变。把解析正确性押在传输层的实现细节上,本身就是隐患。真正会坏的是字节流脱离 64 网格的情况:设备复位瞬态往流里吐杂讯、固件发送被截断,字节数不再是 64 的整数倍,流的对齐从此永久偏移。

错位时间线

读窗口固定是 64 字节,帧变短也不会让窗口调整位置。字节丢在某个帧的中间时,这一帧变短,它后面的所有帧整体前移。上位机仍按原来的 64 字节位置读,于是每个读窗口都跨在两帧之间:0x33 落在错误位置,cmd 读错,整帧报废。块读没有任何手段把偏移找回来,错位持续到重连。

错位一旦发生,代码里没有任何路径把同步找回来:不扫描帧头,不清空缓冲从下一字节继续。

实测复现:我搭的测试链路是 电脑 → USB 延长线 → USB 隔离线 → 分析仪,两级线缆叠加让 USB 信号衰减、链路更容易出错。设备经该链路连续挂机,官方上位机接近 1 小时卡死。该设备是 STM32 USB CDC 虚拟串口,对连续流没有硬件流控,链路劣化让设备反复复位:复位杂讯让字节流脱离 64 网格造成错位,断流撞上无超时的阻塞读造成挂起,错位无法自愈,挂起不可恢复,最终都表现为卡死。

要公允地说一句:正常链路、正常固件下,这套块读不会出错,它的对齐是传输层免费提供的。缺陷不在"会出错",在"出错时零防御、零感知":概率低,但现场挂机总会撞上,撞上就是永久失步,软件自己还毫无察觉。

三态状态机:等帧头、收头部、收载荷

流式解析的核心:任何时刻只维护少量状态,逐字节推进,校验失败只丢弃当前这一个字节,然后继续。

三态状态机

三个状态(括号里是代码里的状态名):

三条纪律:

  1. 每次只丢一个字节,绝不整帧丢弃。
  2. 校验失败不清空缓冲,从下一字节继续扫描。
  3. 收完整帧回到等帧头,处理流中下一帧。

状态机解决错位,超时解决挂起。串口读必须带超时:项目实现用 serial.Serial(port, 115200, timeout=0.2),读不到就超时返回,USB 断流时只是没有数据,不会永久阻塞。

项目实现见 tools/emk850_proto.py 两段式解析:FrameDecoder 逐字节状态机出 64 字节帧,ProtocolParsercmd 分发。实测高速数据流约 400 帧/秒持续采样,无失步。

流式解析的适用边界

整块读取在某些条件够用:设备固定节奏发满定长帧、接收端跟得上、错位后重连成本可接受。这类场景块读代码简单,是务实选择。

需要流式解析的场景:复位瞬态吐出杂讯、半帧、多帧交错,或数据速率高、接收端可能瞬时滞后。这类场景任何一次错位都会让块读永久失步。

本协议判断完整帧的要素是帧头字节 + 长度字段;累加和只用于配置大块数据的重组,帧级不校验。换其他协议,把等帧头状态的匹配字节换成对应帧头即可,三态结构不变。