┌─────────────────────────────┐
   write→│ 3 │ 4 │ _ │ _ │ _ │ 1 │ 2 │←read
        └─────────────────────────────┘
              ring buffer · capacity 8

串口数据老丢字节?环形缓冲区 RingBuffer 设计与实现:读写下标、满空判断、取模回绕与 C# 完整代码

2026-09-25  //  纯技术  数据结构 · 串口接收 · C#
首页 / 行业资讯 / 环形缓冲区RingBuffer

一句话说清楚:环形缓冲区就是一块定长数组 + 两个下标——write 指向下一个可写位置,read 指向下一个可读位置,写到数组末尾靠取模绕回开头,逻辑首尾相连成一个环。写入、读取都是 O(1),不搬数据、不动态扩容。串口中断里只管往里写,主循环慢慢往外读,生产和消费就此解耦。

SEC 01串口接收为什么非要它不可?

先说个我早年在产线碰到的真实故障:一个扫码枪上报条码,偶发性少一两个字符,条码校验失败,一天误拦十几个合格品。查了一周,根因就是串口中断里做了字符串拼接——某次 GC 或解析稍慢,硬件接收寄存器里的字节没被及时取走,新字节直接覆盖旧字节,硬件层面静默丢失。

串口接收的三个硬约束决定了普通 List、Queue 不好使:

环形缓冲一开始就把内存定死,中断里只动两个下标,写完即走。这就是几乎所有串口驱动、网卡驱动内部都藏着一个 RingBuffer 的原因。

SEC 02基本结构:一块数组,两个下标

先看一个容量为 8 的环形缓冲,里面已经写入了 1、2、3、4 四个字节:

3[0]
4[1]
_[2]
_[3]
_[4]
1[5] read
2[6]
_[7] write

下标只朝一个方向走,走到 capacity 就绕回 0,所以逻辑上是环,物理上还是那一行平铺的数组,没有任何数据搬移。

SEC 03写入一个字节,下标怎么回绕?

写入操作就三步:判断满不满 → 往 _write 位置存 → 下标前进并取模:

Write — 单字节写入
public bool Write(byte value)
{
    if (_count >= _buffer.Length) return false;   // 满了,丢弃或由上层处理
    _buffer[_write] = value;
    _write = (_write + 1) % _buffer.Length;  // 末尾自动绕回0
    _count++;
    return true;
}

接着前面的状态(write 在 [7])写入字节 5:存进 [7],下标 (7+1)%8 = 0,绕到了数组头部;再写 6 就进 [0],把原来的旧值 3 覆盖掉——注意,旧值 3 此时早已被 read 取走,覆盖的是空槽,逻辑完全合法。这就是"环"的直观含义。

NOTE取模运算在嵌入式上有除法开销。容量取 2 的幂(如 128、256)时,可以写成 _write = (_write + 1) & (_capacity - 1),位与代替取模,中断里能省几十条指令。

SEC 04读取数据:取出、前进,不删除

读取同样简单。所谓"读出"并不需要清零,下标一走,那个槽位在逻辑上就是空的,等下次写回来覆盖:

Read — 单字节读取
public bool TryRead(out byte value)
{
    value = 0;
    if (_count == 0) return false;        // 空的
    value = _buffer[_read];
    _read = (_read + 1) % _buffer.Length;
    _count--;
    return true;
}

实际用得更多的是批量读:主循环一次 TryRead 到自己的工作数组里,能读多少读多少,然后在工作数组上做协议解析。这样既减少下标竞争,也避免一个字节一个事件地唤醒业务线程。写批量 Write 同理,串口驱动的 DMA 可以一次把半个环直接搬进 UART。

SEC 05满和空怎么区分?这是唯一的设计难点

你可能会问:read == write 时,到底是空还是满?两种状态下标完全重合,分不清。工程上两种主流解法:

解法判空判满代价
计数法_count == 0_count == capacity多维护一个变量(上面代码用的就是它)
牺牲一槽read == write(write+1)%cap == read容量 256 实际只能存 255

裸机、内核里常见牺牲一槽法,省一个变量;应用层 C# 我建议直接用计数法,容量实打实,语义也不容易绕晕后来维护的人。选哪种都要在团队内统一,最怕收发两侧一个按 count、一个按槽位。

CAUTION // 满了怎么办写满时 Write 返回 false,上层必须有策略:最常见是丢弃最旧数据(read 也往前顶一格,示波器波形类场景适用)或统计溢出次数。对丢字节敏感的指令流,则要靠流控让发送方慢下来。无论哪种,溢出次数一定要计数,现场排查时这个数字就是证据。

SEC 06C# 完整实现

RingBuffer.cs
public sealed class RingBuffer
{
    private readonly byte[] _buf;
    private int _read, _write, _count;

    public RingBuffer(int capacity)
        => _buf = new byte[capacity];

    public int Count => _count;
    public bool IsFull => _count == _buf.Length;

    public int Write(byte[] src, int offset, int count)
    {
        int n = Math.Min(count, _buf.Length - _count);
        for (int i = 0; i < n; i++)
        {
            _buf[_write] = src[offset + i];
            _write = (_write + 1) & (_buf.Length - 1); // 容量需为2的幂
        }
        _count += n;
        return n; // 实际写入数,<count 即发生溢出
    }

    public int Read(byte[] dst, int offset, int count)
    {
        int n = Math.Min(count, _count);
        for (int i = 0; i < n; i++)
        {
            dst[offset + i] = _buf[_read];
            _read = (_read + 1) & (_buf.Length - 1);
        }
        _count -= n;
        return n;
    }
}

用在串口上:DataReceived 事件(或底层回调)里把字节 Write 进环;解析线程定时 Read 出来找帧头帧尾。注意这里位与回绕要求容量是 2 的幂,构造函数可以加断言保护。

SEC 07单生产者单消费者,什么时候可以无锁?

串口场景天然是单生产者(中断/回调)+ 单消费者(解析线程)。这种严格 SPSC 模型下,环形缓冲可以做到无锁:write 只由生产者推进,read 只由消费者推进,双方通过对方下标的可见性来判断空满,连 _count 都可以不要,靠内存屏障保证下标读写的顺序即可。Linux 内核的 kfifo、C++ 的 boost::spsc_buffer、.NET 的 System.Threading.Channels(内部就是环形结构)都是这个路子。

NOTE // 无锁的前提很苛刻必须真的只有一个写者和一个读者。两个线程同时 Write,下标更新互相覆盖,数据瞬间错乱。多生产者/多消费者场景老老实实用锁,或者每线程一个环。另外"无锁"在 C# 里仍需 Volatile.Read/Write 或 Interlocked 保证可见性,删掉 lock 关键字不等于无锁正确。

对绝大多数上位机程序,我的建议是:环里读写各加一把 lock(或用 Channel),先正确后优化。串口那点数据量,锁竞争几乎为零;等你能证明它是瓶颈,再上 SPSC 无锁也不迟。

SEC 08常见坑与验证方法

这些年见过的 RingBuffer 事故,翻来覆去就这几个:

坑现象解法
容量不是 2 的幂却用位与回绕下标越界、死循环构造时向上取整到 2 的幂,或改回取模
批量读写没处理跨尾边界一次拷贝截断或越界分成"到末尾"和"绕回头部"两段拷
满了还静默写数据错位却无任何报警返回实际写入数 + 溢出计数
DMA 与 CPU 缓存不同步读到旧数据(嵌入式)环放在非缓存区或手动 invalidate
  1. 回绕测试:容量 8 的环连续写读几千次,断言 Count 永远在 0~8、读出顺序与写入一致;
  2. 突发测试:一次灌入 100 字节,再慢慢读,确认溢出计数和数据都符合预期;
  3. 真机压力:串口短接自发自收,高速跑一夜,比对收发内容逐字节相等;
  4. 观测指标:峰值占用、溢出次数、读写下标全部打到日志,容量够不够用数据说话。
CAUTION // 容量怎么定按"最大突发时长 × 最高速率"估算再翻倍,别按平均速率算——平均下来永远够用,挂都挂在设备一次性上报一整帧的那几十毫秒。串口 115200 速率下,1KB 的环能兜约 87ms,多数协议足够。

今天就可以动手:建一个容量 256 的 RingBuffer,先写"连续写读 10000 次"的回绕测试,再写"灌满后溢出计数"的边界测试。两个测试绿了,把它接到你的串口接收回调上,从此中断里只有一行 Write。说实话,把字节的归 RingBuffer、把协议的归解析线程,这种分层一旦做过,你再也回不到"Read 一次就解析"的老路。

// tags: ring-buffer · circular-queue · serialport · spsc · producer-consumer · dma · csharp