串口调试最容易卡住人的地方,不是波特率算错,也不是接线接反 —— 那些一眼就能发现。真正磨人的是:程序跑起来,数据大部分时候是对的,偶尔少几个字节,或者两条报文粘在一起。

换台电脑好了,加个 Sleep 也好了,于是你以为修好了。下次换个波特率又坏。

现象:怎么都不对的三种错法

最后一条几乎是判据:加断点能好,说明问题出在数据到达的节奏上,不在数据内容上。

根因只有一句话

串口是字节流,不是消息流。硬件只保证字节的顺序,从不保证「一次读取 = 一条完整报文」。

这句话听起来像套话,但它的推论很硬:

所以「一次读取的内容」和「一条报文」之间没有对应关系。你的代码里只要存在这种假设,就一定会出错,只是频率高低取决于波特率、CPU 负载和发送方的行为。加 Sleep 之所以「修好」了,是因为它改变了你的读取时机,把概率降低了,而不是消除了。

错在哪:三个典型的写法

这三个我都写过,也都出过事。

错法一:一次事件当一帧

// ❌ 这是一切问题的源头
private void port_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
    int n = port.BytesToRead;
    byte[] buf = new byte[n];
    port.Read(buf, 0, n);
    ParseFrame(buf);        // buf 可能是半帧,也可能是两帧
}

错法二:在事件里直接解析、直接刷新界面

DataReceived 是在辅助线程上触发的,不是 UI 线程。在这里直接碰控件,轻则跨线程异常,重则界面直接崩。而且把耗时解析塞进这个线程,会拖慢接收,进一步加剧丢包。

错法三:拿 BytesToRead 当帧长

BytesToRead 只是「此刻接收缓冲区里还有多少字节」,它跟你的协议帧长度毫无关系。用它分配数组、用它判断帧结束,都是把「碰巧」当成了「必然」。

正确结构:搬运和解析分开

思路非常简单,但必须先想通:接收事件只做一件事 —— 把字节搬进自己的缓冲区。解析是另一个独立的过程,从缓冲区里按规则取。

串口事件线程  →  [ 接收缓冲区 ]  →  解析器(状态机)  →  完整帧回调
    只搬运            唯一的数据源         只关心字节          业务逻辑

这样拆开之后,无论数据怎么碎、怎么粘,都只是往缓冲区里多写几次、少写几次,解析器永远看到的是一个连续的字节序列。

完整代码:缓冲 + 状态机

先用一个例子把协议定死,后面都围绕它:

帧格式:0xAA | LEN | payload[LEN] | CS | 0x55
  LEN     : payload 字节数,0~250
  CS      : payload 各字节算术累加和(低 8 位)
  0x55    : 固定帧尾

第一步:接收事件只搬字节

private readonly object _lk = new object();
private readonly List<byte> _rx = new List<byte>(8192);

private void OnData(object sender, SerialDataReceivedEventArgs e)
{
    int n = _port.BytesToRead;
    if (n <= 0) return;

    byte[] tmp = new byte[n];
    int got = _port.Read(tmp, 0, n);
    if (got <= 0) return;

    lock (_lk)
    {
        for (int i = 0; i < got; i++) _rx.Add(tmp[i]);
    }

    // 就这些。解析不在这里做(这个线程只负责搬运)
}

这里用 List<byte> 加锁,简单可靠。数据量很大(每秒几十 KB 以上)时再换环形缓冲区,否则没必要为这点性能写一个容易出边界 bug 的结构。

补一个细节:DataReceived 的触发时机是「接收缓冲区里有数据」,所以一帧在跨多次事件到达时,你会看到 BytesToRead 忽大忽小 —— 这本身就是「粘包/半包」发生的地方。

第二步:状态机逐字节吃

状态机的好处是它不需要知道「这一帧什么时候到齐」,来一个字节吃一个,凑够了自然就出一帧。中途断了(丢字节、校验错)就退回等帧头,不会把脏数据带下去。

private enum St { Idle, Len, Body, Cs, Tail }

private St  _st;
private int _need, _got;
private byte _sum, _cs;
private readonly byte[] _frame = new byte[256];

private void Pump()
{
    while (true)
    {
        byte b;
        lock (_lk)
        {
            if (_rx.Count == 0) return;
            b = _rx[0];
            _rx.RemoveAt(0);
        }

        switch (_st)
        {
            case St.Idle:
                if (b == 0xAA) { _sum = 0; _got = 0; _st = St.Len; }
                // 不是帧头就丢掉,继续等
                break;

            case St.Len:
                _need = b;
                if (_need > 250) { _st = St.Idle; break; }   // 长度非法,直接丢弃
                _st = (_need == 0) ? St.Cs : St.Body;
                break;

            case St.Body:
                _frame[_got++] = b;
                _sum += b;
                if (_got >= _need) _st = St.Cs;
                break;

            case St.Cs:
                _cs = b;
                _st = St.Tail;
                break;

            case St.Tail:
                if (b == 0x55 && _cs == _sum)
                    OnFrame(_frame, _got);       // 校验通过,交出去
                _st = St.Idle;                   // 无论成败都回到等帧头
                break;
        }
    }
}

private void OnFrame(byte[] payload, int len)
{
    // 这里已经在自己的线程上,可以安全地把结果投递给 UI
    _ui.BeginInvoke(new Action(() => ShowFrame(payload, len)));
}

上面这个 Pump() 由一个 10ms 左右的定时器周期调用即可 —— 它会一次吃空缓冲区,缓冲区空了就返回,不会空转。

几个刻意的设计:

_rx.RemoveAt(0) 是 O(n) 操作(要移动整个列表)。每秒几百帧没问题,几十 KB/s 以上该换环形缓冲或 Queue<byte>。别一上来就上环形缓冲 —— 先把逻辑跑对,再按实测数据优化。

四种切帧方式怎么选

方式做法优点坑
定长每 N 字节一帧最简单,不用状态机一旦丢一个字节,后面全部错位,且永远恢复不了
帧头 + 帧尾0xAA … 0x55能自动重新同步payload 里可能出现帧尾 → 必须转义或加校验
帧头 + 长度字段0xAA LEN …最稳,一次读够就知道齐没齐LEN 自己错了会误判,需要范围校验
超时切帧静默一段时间就当一帧不要求协议配合时间阈值要按波特率算,主观性强

我自己写的协议一律用帧头 + 长度 + 校验 + 帧尾四条一起上:长度负责效率,校验和帧尾负责纠错,帧头负责重新同步。多这几个字节,换来的是「出错能自愈」。

没有协议时,用时间切帧

对接别人的设备,人家协议里没长度字段,甚至没有帧头,只能靠「静默间隔」判断一帧结束。Modbus RTU 就是这么定义的:3.5 个字符时间内没有新字节,就认为一帧结束。

换算一下:3.5 字符 × 11 位 / 波特率。

波特率3.5 字符静默时间(约)
9600≈ 4.0 ms
19200≈ 2.0 ms
115200≈ 0.33 ms

在代码里就是用 Stopwatch 记最后收到字节的时刻,定时检查:

private readonly Stopwatch _silence = Stopwatch.StartNew();
private int _lenAtLastByte;

// 每收到一批字节后
_silence.Restart();

// 定时器里(间隔取阈值的一半,例如 2ms)
private void OnTick(object sender, EventArgs e)
{
    int len;
    lock (_lk) len = _rx.Count;

    if (len > 0 && len == _lenAtLastByte && _silence.ElapsedMilliseconds >= 5)
    {
        byte[] frame;
        lock (_lk) { frame = _rx.ToArray(); _rx.Clear(); }
        _silence.Restart();
        OnFrame(frame, frame.Length);
    }
    _lenAtLastByte = len;
}

⚠️ 高波特率下这个办法很脆弱:115200 时 3.5 字符只有 0.33ms,Windows 的定时器和线程调度精度根本到不了这个量级。所以能争取到长度字段就一定要争取。

单片机侧也会制造碎包

上位机代码写得再对,如果发送方是一个字节一个字节往外吐、中间还夹着计算或延时,你收到的必然是碎包。这几种写法要改:

症状对得上:每帧都缺最后一个字节,八成是 485 方向切换早了;缺的字节位置随机,看线路和校验。

排查顺序:先看字节,再看解析

顺序搞反会浪费很多时间。按这个来:

  1. 用串口助手(十六进制模式)看原始数据。如果助手看到的帧是完整的,问题一定在接收侧的缓冲和切帧,跟协议、跟线路都没关系;
  2. 如果助手看到的也是碎的,往发送侧和物理层查:波特率误差(尤其用内部 RC 振荡器的单片机)、有没有共地、TTL 直连 RS232 设备(电平不匹配)、线太长、485 方向切换;
  3. 记录每次事件收到的字节数分布。一帧正常会跨 1~3 次事件,如果某次出现 1 字节,说明发送侧在逐字节吐;
  4. 最后才怀疑解析逻辑。而且要用真实抓下来的字节流做离线回放,别靠在线调试 —— 在线调试会改变时序。

我习惯把抓到的原始字节存成文本(每行一串 hex),写个小程序喂给解析器回放。这样回归测试可重复,也方便复现「偶尔出错」的现场。


回头看,这类问题的共同点是:错误不在某一行代码里,而在一个隐含假设里——「一次读取就是一帧」。把假设拆掉,改成「字节流 + 缓冲区 + 状态机」,问题就一次性消失了,不用再靠 Sleep 去调概率。

我接下来打算给那台 8 位计算机加一个串口,让它能跟电脑通信 —— 上位机这边就是按上面这套结构写的。