Modbus RTU 协议解析中的粘包与半包处理:从实际代码到通用解决方案

前言

在工业物联网领域,底层串口/TCP 通讯是字节流传输,粘包与半包是协议解析绕不过去的问题。

本文以新疆油田油田,A11标准升级改造 RTU 采集服务器项目中的 Modbus RTU 协议解析模块为例,看看粘包/半包到底是怎么处理的,问题在哪,以及怎么改。
A11改造的资料和代码


一、Modbus RTU 协议帧结构

在讨论粘包之前,先要简单了解 Modbus RTU 的帧结构:

从站地址 功能码 数据域 CRC低字节 CRC高字节
1 Byte 1 Byte N Bytes 1 Byte 1 Byte

其中数据域的长度由功能码决定:

  • 功能码 03/04(读保持寄存器/读输入寄存器):数据域第 1 字节为字节计数 N,后跟 N 字节的寄存器数据
  • 功能码 06/16(写单个/多个寄存器):数据域长度固定

注意:Modbus RTU 帧没有帧头/帧尾标识符(不像 Modbus TCP 有 MBAP 头),帧间靠 3.5 字符静默时间分隔。TCP 转发场景下这个时间分隔丢失,粘包问题特别明显。


二、当前项目的粘包处理机制

2.1 整体架构

项目采用分层设计,通讯层与协议解析层通过事件机制解耦:

  • TCP/串口通讯层:负责底层数据收发,通过 ReceivedData 事件将原始字节数据上报
  • 通讯管理层(如 TCPServerComm):维护 StringBuilder 缓冲区,累积数据并调用协议解析层
  • 协议解析层IAnalytical 接口):由具体协议驱动实现(如 Moudbs),负责帧识别、校验和数据解码

2.2 通讯层的缓冲区管理

通讯管理层 TCPServerComm 使用 StringBuilder 作为接收缓冲区:

protected void AnalysisData(OnlineEquipment equipment, string clientid, byte[] received)
{
    lock (equipment.Real_DATA)
    {
        string hexValue = ConvertEx.ByteArrayToHex(received);
        
        // 防止缓冲区无限增长
        if (received.Length * 2 + equipment.Receiveddata.Length > 9000)
        {
            equipment.Receiveddata.Length = 0;
        }
        
        // 新数据追加到缓冲区尾部
        equipment.Receiveddata.Append(hexValue);
        
        var args = new ReceivedDataEventArgs(
            equipment.Receiveddata.ToString(), this.id, clientid);
        
        // 调用协议解析层
        this.AnalyticalReceivedData(ref args);
        
        if (args.Startlength == 0)
        {
            // 未识别到有效帧,累计无效次数
            equipment.CalcCount++;
            if (equipment.CalcCount >= 6)
            {
                equipment.Receiveddata.Length = 0; // 清空脏数据
            }
            return;
        }
        
        // 成功解析后,从缓冲区移除已处理的数据
        equipment.Receiveddata.Remove(args.Startindex, args.Startlength);
        equipment.Receiveddata.Remove(0, args.Startindex);
        equipment.CalcCount = 0;
    }
}

代码解释说明:

设计点 说明
缓冲区累积 每次收到的数据追加到 StringBuilder,等待拼接成完整帧
帧裁剪 解析成功后,从缓冲区移除已处理的帧数据
溢出保护 缓冲区超过 9000 字符时强制清空
脏数据清理 连续 6 次未能识别有效帧时清空缓冲区

2.3 协议解析层的帧识别

JudgeAnalytical 这个方法从缓冲区里找有效帧:

public bool JudgeAnalytical(
    string clientid, string receivedData,
    ref int staIdx, ref int staLen, ref bool validcrc,
    ref int rtuAddress, ref int code, ref object extend,
    ref string deviceId, ref string deviceName,
    ref string monitorId, ref string monitorName,
    ref string errorStr,
    MonitorMana monitor, DeviceMana device)
{
    const int MIN_FRAME_LENGTH = 10; // 最小帧长度(hex字符数)
    bool found = false;
    int offset = 0;

    if (receivedData.Length < MIN_FRAME_LENGTH)
        return false;

    do
    {
        // 读取数据域长度字段(第3字节,即hex偏移4处)
        int dataFieldLength = ConvertEx.ToInt(receivedData.Substring(offset + 4, 2));
        
        // 计算完整帧长度:地址(2) + 功能码(2) + 长度字段(2) + 数据(2*N) + CRC(4)
        int frameLength = 6 + dataFieldLength * 2 + 4;
        
        // 检查缓冲区是否有足够数据
        if (receivedData.Length >= offset + frameLength)
        {
            // 提取CRC并校验
            string crc = receivedData.Substring(offset + 6 + dataFieldLength * 2, 4);
            bool crcValid = VerifyCRC16(receivedData, offset, frameLength - 4, crc);
            
            if (crcValid)
            {
                // 解析帧头信息
                validcrc = true;
                staIdx = offset;
                staLen = frameLength;
                rtuAddress = ConvertEx.ToByte(receivedData.Substring(offset, 2));
                code = ConvertEx.ToByte(receivedData.Substring(offset + 2, 2));
                
                // 匹配设备和监控点
                Device dev = device.FindDevice(clientid, rtuAddress);
                List<Monitor> monitors = monitor.FindMonitors(
                    code, this.agreementId, dataFieldLength, false);
                
                // ... 设备与监控点匹配逻辑 ...
                
                // 跳到下一帧
                offset += frameLength - 2;
            }
        }
        
        offset += 2; // 每次步进1字节(2个hex字符)
        
    } while (receivedData.Length - offset >= MIN_FRAME_LENGTH);
    
    return found;
}

2.4 处理流程

流程说明:

  1. 收到新数据后,追加到 StringBuilder 缓冲区尾部
  2. 检查缓冲区长度是否 >= 最小帧长度(10 个 hex 字符 = 5 字节)
  3. 从当前偏移位置读取数据域长度字段 N,计算帧总长度 = 6 + 2*N + 4
  4. 判断缓冲区剩余数据是否够一帧——不够则为半包,等待下次数据
  5. 数据够则进行 CRC16 校验:失败则步进 1 字节继续尝试
  6. 校验通过后匹配设备和监控点,记录帧信息
  7. 跳到下一帧起始位置,循环处理(粘包场景)
  8. 最终返回最后成功解析的帧信息

2.5 粘包场景示例

假设缓冲区中收到了两条粘在一起的 Modbus 响应:

帧序号 起始偏移 数据域长度 帧总长度 说明
帧1 0 4 字节(04 18 字符 01 03 04 00 64 00 0A XX XX
帧2 18 6 字节(06 22 字符 01 03 06 00 32 00 64 00 C8 YY YY

解析过程:

  1. offset = 0,读取数据域长度 = 04(4字节),帧长度 = 6 + 8 + 4 = 18 字符
  2. 校验帧1 CRC 通过,记录帧1信息
  3. offset += 16(帧长度-2),再加 2,此时 offset = 18
  4. 读取帧2数据域长度 = 06(6字节),帧长度 = 6 + 12 + 4 = 22 字符
  5. 校验帧2 CRC 通过,记录帧2信息
  6. 返回最后一帧(帧2)的解析结果,帧1的信息被覆盖

三、当前方案的局限性

3.1 半包数据丢失——无缓存拼接机制

JudgeAnalytical 方法本身不缓存数据,只是尝试从当前缓冲区解析完整帧。数据不够一帧时直接跳过,通讯层缓冲区虽然保留了数据,但存在以下隐患:

时间 事件 缓冲区状态 结果
T1 收到 “01 03 04 00 64”(半包) 0103040064(10字符) 帧长度=18,不够 → CalcCount++
T2 收到 “00 0A XX XX”(帧剩余部分) 010304006400 0AXXXX(18字符) 帧长度=18,够了 → 解析成功

正常情况下能跑,但如果 T1 和 T2 之间有其他设备数据插入同一个 equipment,或者 CalcCount >= 6 触发缓冲区清空,半包数据就直接丢了。

3.2 只返回最后一帧——粘包中多帧数据丢失

当缓冲区中有多条有效帧时,JudgeAnalytical 的循环会遍历所有帧,但输出参数会被不断覆盖,最终只保留最后一帧:

// 每次成功解析都会覆盖之前的值
if (crcValid)
{
    staIdx = offset;        // 被覆盖
    staLen = frameLength;   // 被覆盖
    rtuAddress = ...;       // 被覆盖
    offset += frameLength - 2;
}

通讯层拿到结果后只移除最后一帧的数据:

equipment.Receiveddata.Remove(args.Startindex, args.Startlength);
equipment.Receiveddata.Remove(0, args.Startindex);

帧1 和帧2 虽然被识别了,但数据没有被处理,直接从缓冲区丢弃。下次调用时这些帧的数据又会被新数据覆盖。

3.3 CRC 失败时的回退策略过于简单

CRC 校验失败时,只步进 1 字节重新尝试:

offset += 2; // 步进1字节(2个hex字符)

遇到干扰数据时,算法会在错误位置反复尝试。随机数据中 CRC 碰巧通过的概率约 1/65536,单帧看很低,但高吞吐场景下累积概率不能不考虑。

3.4 缓冲区溢出保护过于粗暴

if (received.Length * 2 + equipment.Receiveddata.Length > 9000)
{
    equipment.Receiveddata.Length = 0; // 直接清空
}

直接清空缓冲区,所有未处理数据全部丢失,通讯繁忙时可能导致数据大面积缺失。

3.5 局限性总结

局限性 影响
粘包只返回最后一帧 中间帧数据静默丢失,采集数据不完整
半包依赖外层缓冲区 特殊时序下半包数据可能被清空
CRC失败逐字节回退 存在误匹配风险,性能差
缓冲区溢出直接清空 通讯繁忙时数据大面积丢失
无帧头特征识别 无法快速定位帧起始位置

四、改进方案

4.1 方案一:协议解析层返回多帧结果

JudgeAnalytical 改为返回所有识别到的有效帧,而非只返回最后一帧。

/// <summary>
/// 帧解析结果
/// </summary>
public class FrameResult
{
    public int StartIndex { get; set; }
    public int Length { get; set; }
    public int RtuAddress { get; set; }
    public int FunctionCode { get; set; }
    public bool CrcValid { get; set; }
    public string DeviceId { get; set; }
    public string DeviceName { get; set; }
    public string MonitorId { get; set; }
    public string MonitorName { get; set; }
    public object Extend { get; set; }
}

/// <summary>
/// 解析缓冲区中的所有有效帧
/// </summary>
public List<FrameResult> ParseAllFrames(string receivedData, 
    MonitorMana monitor, DeviceMana device, string clientId)
{
    var results = new List<FrameResult>();
    const int MIN_FRAME_LENGTH = 10;
    int offset = 0;

    while (receivedData.Length - offset >= MIN_FRAME_LENGTH)
    {
        // 读取数据域长度,计算帧总长度
        int dataFieldLen = ConvertEx.ToInt(receivedData.Substring(offset + 4, 2));
        int frameLength = 6 + dataFieldLen * 2 + 4;

        // 半包检查:数据不够则退出,等待下次数据
        if (receivedData.Length < offset + frameLength)
            break;

        // CRC校验
        string crc = receivedData.Substring(offset + 6 + dataFieldLen * 2, 4);
        if (VerifyCRC16(receivedData, offset, frameLength - 4, crc))
        {
            var frame = new FrameResult
            {
                StartIndex = offset,
                Length = frameLength,
                RtuAddress = ConvertEx.ToByte(receivedData.Substring(offset, 2)),
                FunctionCode = ConvertEx.ToByte(receivedData.Substring(offset + 2, 2)),
                CrcValid = true
            };

            // 匹配设备和监控点...
            results.Add(frame);
        }

        offset += frameLength; // 直接跳到下一帧起始位置
    }

    return results;
}

通讯层对应修改:

var frames = analytical.ParseAllFrames(
    equipment.Receiveddata.ToString(), 
    this.Config.monitorMana, this.Config.deviceMana, clientId);

foreach (var frame in frames)
{
    // 逐帧处理:存入任务队列或触发回调
    ProcessFrame(equipment, frame);
}

// 统一移除所有已处理帧的数据
if (frames.Count > 0)
{
    var lastFrame = frames.Last();
    int totalProcessed = lastFrame.StartIndex + lastFrame.Length;
    equipment.Receiveddata.Remove(0, totalProcessed);
}

4.2 方案二:引入状态机实现字节级精确解析

更复杂的场景可以用状态机方式逐字节解析,粘包/半包都能处理:

/// <summary>
/// 基于状态机的 Modbus RTU 帧解析器
/// </summary>
public class ModbusFrameParser
{
    private readonly List<byte> _buffer = new List<byte>();
    
    enum State
    {
        WaitAddress,
        WaitFunctionCode,
        WaitDataLength,
        WaitData,
        WaitCrcLow,
        WaitCrcHigh
    }

    /// <summary>
    /// 向解析器追加数据,返回已解析出的完整帧
    /// </summary>
    public List<byte[]> Feed(byte[] newData)
    {
        _buffer.AddRange(newData);
        var frames = new List<byte[]>();
        int consumed = 0;

        while (consumed < _buffer.Count)
        {
            var frame = TryParseFrame(consumed, out int frameLength);
            if (frame != null)
            {
                frames.Add(frame);
                consumed += frameLength;
            }
            else if (frameLength == 0)
            {
                // 数据不够,等待更多数据
                break;
            }
            else
            {
                // CRC校验失败,跳过1字节重新尝试
                consumed++;
            }
        }

        // 移除已消费的数据
        _buffer.RemoveRange(0, consumed);
        return frames;
    }

    private byte[] TryParseFrame(int start, out int frameLength)
    {
        frameLength = 0;
        int pos = start;

        // 至少需要:地址(1) + 功能码(1) + 长度(1) + CRC(2) = 5字节
        if (_buffer.Count - pos < 5)
            return null;

        byte dataLen = _buffer[pos + 2]; // 数据域字节数
        int totalLength = 3 + dataLen + 2; // 地址+功能码+长度+数据+CRC

        if (_buffer.Count - pos < totalLength)
        {
            frameLength = 0; // 半包
            return null;
        }

        // 提取完整帧
        byte[] frame = _buffer.GetRange(pos, totalLength).ToArray();

        // CRC校验
        byte[] expectedCrc = CRC16(frame, 0, totalLength - 2);
        if (frame[totalLength - 2] == expectedCrc[0] && 
            frame[totalLength - 1] == expectedCrc[1])
        {
            frameLength = totalLength;
            return frame;
        }

        frameLength = -1; // CRC失败
        return null;
    }
}

状态机解析流程:

阶段 操作 条件 结果
数据输入 收到新数据,追加到内部字节缓冲区 缓冲区增长
步骤 1 读取地址字节 缓冲区不足 1 字节 返回 null,等待更多数据
步骤 2 读取功能码字节 缓冲区不足 1 字节 返回 null,等待更多数据
步骤 3 读取数据长度 N 缓冲区不足 1 字节 返回 null,等待更多数据
步骤 4 检查剩余数据是否 >= N + 2 不够 返回 null(半包),等待更多数据
步骤 5 CRC 校验 通过 返回完整帧
步骤 6 CRC 校验 失败 跳过 1 字节,回到步骤 1 重新尝试
输出 移除已消费数据,返回所有解析出的帧 缓冲区保留未处理的半包数据

4.3 方案三:帧头特征 + 长度预检(适用于有明确帧头的协议)

对比项目中其他协议驱动的做法,例如 FEHRtu 协议使用 "beg""end" 作为帧头帧尾标识:

// FEHRtu 的做法:通过帧头帧尾标识定位
string frameStart = ConvertEx.ToHex("beg");  // 帧头
string frameEnd = ConvertEx.ToHex("end");    // 帧尾

int endPos = receivedData.LastIndexOf(frameEnd);
int startPos = receivedData.LastIndexOf(frameStart, endPos);

这种方式粘包处理更可靠,但 Modbus RTU 本身没有帧头帧尾标识。TCP 转发场景下可以加自定义封装层:

层级 结构
TCP封装帧 FEFEFEFE(帧头4字节) + Modbus RTU 原始帧
原始帧 地址(1) + 功能码(1) + 数据(N) + CRC(2)

项目中 TCPServerComm 已经有类似的处理:

private List<string> GetAnalyResponseHexs(string strHex)
{
    // 以 "FEFEFEFE" 为分隔符拆分多条响应
    strHex = strHex.Replace("FEFEFEFE", ",FEFEFEFE");
    strHex = strHex.Trim(',');
    return new List<string>(strHex.Split(','));
}

五、方案对比

特性 方案一:多帧返回 方案二:状态机 方案三:帧头封装
改动范围 中等 较大 需要硬件配合
粘包处理 优秀 优秀
半包处理 优秀(内置缓存)
误匹配风险 极低 极低
协议兼容性 需修改接口 需重写解析层 需修改硬件固件
适用场景 快速改进 高可靠性场景 可控硬件场景

六、总结与经验

  1. 分层解耦要做到位。通过 IAnalytical 接口将协议解析与通讯层分离,不同协议(Modbus、SCDMA 等)才能独立开发,互不干扰。

  2. 解析规则用配置驱动。通过 MonitorResponse 等配置对象定义解析规则,而非硬编码——新增监控点不用改代码。

  3. 缓冲区管理要有兜底策略。溢出保护、脏数据清理、超时清空,缺一不可。

  4. 粘包处理的三条原则:

    • 能识别每一帧的边界(通过长度字段、帧头帧尾或时间间隔)
    • 处理所有识别到的帧,不只是最后一帧
    • 半包数据缓存等待拼接,不能丢弃
  5. CRC 校验是最后防线,但不要用它来纠正帧边界。正确的姿势是帧边界识别 + CRC 校验双保险,单靠 CRC 兜底迟早出问题。

七、联系我们

如果您有类似的数据采集需求,或想了解更多技术细节,欢迎联系我们。


本文基于实际项目经验编写,代码已脱敏处理。如需完整源码或技术咨询,请联系我们。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐