Skip to content

串口数据帧分帧协议

解决串口通信中"设备连发导致粘包"和"无法识别完整帧"的核心问题

📖 目录


问题背景

粘包现象

串口通信中,设备常常连续发送多帧数据。Windows 串口驱动把所有收到的字节堆在接收缓冲区里,不区分帧边界。如果使用 50ms 轮询定时器读取:

时间线:
0ms      设备发送 Frame1 (10字节)
5ms      设备发送 Frame2 (8字节)      ← 与 Frame1 之间没有停顿
12ms     设备发送 Frame3 (12字节)
...
50ms     Timer 触发 Poll()
         缓冲区有 30 字节 → ReadExisting() 一次全读出
         结果:3 帧粘成一坨无法解析

轮询定时的局限

SetTimer 在 Windows 上的真实精度只有 ~15ms,即使把 PollInterval 设成 10ms 也无法做到真正实时。缩短轮询间隔只能缓解,不能根治粘包

解决思路

要识别每一帧的边界,需要明确的"分帧规则"。本类提供两种互补方案:

  • 方案 B:利用设备发完一帧后的短暂停顿作为天然分帧点
  • 方案 C:按设备协议格式精确切分(分隔符 / 起止标记 / 长度字段 / 固定长度)

分帧方案对比

方案实时性精确度是否需知协议适用场景
B. 帧间隔超时设备发完一帧会停顿(绝大多数场景)
C1. 分隔符最好文本协议,有固定分隔符(如 \r\n
C2. 起止标记最好二进制协议,有 STX/ETX 标记
C3. 长度前缀最好含长度字段的协议(如 Modbus 类)
C4. 固定长度最好帧长度固定的协议
B + C 组合最好最高高可靠性场景,先粗分再精切

方案 B:帧间隔超时分帧

原理

设备每次发完一帧后,必然有一个短暂停顿(哪怕几毫秒)才会发下一帧。这个停顿就是天然的帧边界:

Frame1(10B) |停顿| Frame2(8B) |停顿| Frame3(12B)
            ↑                  ↑
        分帧点             分帧点

cSerialPort 内部维护一个帧缓冲区,连续收到的数据视为同一帧;当超过 FrameInterval 毫秒没有新数据到达时,判定一帧结束,触发 FrameReceived 事件。

启用分帧

只需设置 FrameInterval 属性为非 0 值即可启用:

vb
Private WithEvents m_Port As cSerialPort

Private Sub Form_Load()
    Set m_Port = New cSerialPort
    m_Port.PortName = "COM3"
    m_Port.Config.BaudRate = br9600

    If m_Port.OpenPort() Then
        ' 启用分帧:30ms 无新数据 = 一帧结束
        m_Port.FrameInterval = 30

        ' 轮询间隔建议 <= FrameInterval / 2,保证及时性
        m_Port.StartMonitoring 20
    End If
End Sub

' 帧接收事件 - 每收到一个完整帧触发一次
Private Sub m_Port_FrameReceived(FrameData() As Byte)
    Dim s As String
    s = StrConv(FrameData, vbUnicode)
    Debug.Print "收到帧 (" & UBound(FrameData) + 1 & "字节): " & s
End Sub

关键属性

属性类型默认值说明
FrameIntervalLong0帧间隔超时(ms),0=禁用分帧(保持原始 DataReceived 模式)
MaxFrameSizeLong65536单帧最大字节数,超过则强制触发,防止异常数据导致内存溢出
FrameBufferLengthLong-只读,当前帧缓冲区中的字节数(已收到的未触发数据)

FrameInterval 取值建议

设备类型建议值说明
文本设备(GPS、温湿度等)20-50ms帧间停顿明显
工业仪表10-30ms响应快,停顿短
Modbus RTU-不推荐用 B,应使用方案 C3(详见下文)
慢速设备(电子秤等)50-100ms设备本身发送就慢

经验法则:先观察设备连续发数据时帧间的实际停顿(可用串口调试助手查看时间戳),FrameInterval 取停顿时间的一半左右最稳妥。

注意事项

  1. 轮询间隔与帧间隔的关系PollInterval 应 ≤ FrameInterval / 2,否则可能错过帧超时判定时机。
  2. 不破坏原模式FrameInterval = 0 时仍走原来的 DataReceived 路径,向后兼容。
  3. 最后一帧不丢StopMonitoringClosePort 时会自动 flush 缓冲区中剩余的未触发帧。
  4. 大帧保护:单帧超过 MaxFrameSize 时强制触发,避免异常数据耗尽内存。

方案 C:协议分帧解析器

cSerialFrameParser 是独立的帧解析类,支持 4 种协议分帧模式。它不直接读串口,而是接收 AppendData 传入的字节流,按协议规则切分出完整帧。

引用方式

vb
' 方式一:源码引用
' 将 src/SerialPort/cSerialFrameParser.cls 加入工程

' 方式二:DLL 引用
Dim parser As New VBMANLIB.cSerialFrameParser

C1. 分隔符模式

适用于有固定分隔符的协议(文本协议最常见):

[DATA1][DELIM][DATA2][DELIM][DATA3][DELIM]...
vb
Dim parser As New cSerialFrameParser

' 按 CRLF 分帧,返回的帧不包含分隔符
parser.SetDelimiterMode vbCrLf, False

' 也可以用其他分隔符
parser.SetDelimiterMode Chr(10), False        ' 仅 LF
parser.SetDelimiterMode "|", False            ' 自定义字符

' 注入数据
parser.AppendData someBytes

' 循环取出所有完整帧
Do While parser.HasFrame()
    Dim frame() As Byte
    frame = parser.GetFrame()
    Debug.Print StrConv(frame, vbUnicode)
Loop

参数说明:

参数说明
DelimiterStr分隔符字符串(可以是多字节,如 vbCrLf
IncludeDelimTrue=返回帧包含分隔符;False=不包含(默认)

C2. 起始结束标记模式

适用于有 STX/ETX 等定界符的二进制协议:

[STX][DATA1][ETX][STX][DATA2][ETX]...
vb
Dim parser As New cSerialFrameParser

' STX(0x02) ... ETX(0x03),返回帧包含标记
parser.SetStartEndMode Chr$(&H2), Chr$(&H3), True

' 也支持多字节标记
parser.SetStartEndMode "<<<", ">>>", True
参数说明
StartStr起始标记字符串
EndStr结束标记字符串
IncludeMarksTrue=返回帧含标记;False=只返回中间数据

容错特性:缓冲区开头不是 StartMark 时,解析器会自动跳过无效字节,从下一个 StartMark 开始解析。这能正确处理"半帧残留"问题(上次未收完的残帧)。

C3. 长度前缀模式

适用于含长度字段的二进制协议(如 Modbus、自定义帧头协议):

┌─────────┬───────────┬──────────┬─────────┐
│ Header  │ Length(n) │ Payload  │ Trailer │
│ (固定)  │ (1/2/4B)  │ (n字节)  │ (固定)  │
└─────────┴───────────┴──────────┴─────────┘

配置参数

参数说明
Offset长度字段在帧中的字节偏移(从 0 开始)
Size长度字段大小:1/2/4 字节
BaseSize帧基础大小(长度值代表的范围之外的字节数:Header + Length字段 + Trailer)
BigEndianTrue=大端序;False=小端序
Meaning长度值的含义(见下表)

LengthMeaning 枚举

含义帧总长度计算
lmPayload (0)长度值 = 数据负载长度FrameBaseSize + 长度值
lmPayloadAndTrailer (1)长度值 = 负载 + 帧尾FrameBaseSize + 长度值
lmTotalFrame (2)长度值 = 整个帧长度长度值

常见协议配置示例

示例 1:简单长度前缀

[LEN(1)][DATA(n)]

长度值 = n(数据长度),无 Header/Trailer。

vb
' Offset=0, Size=1, BaseSize=1(LEN本身), 小端, lmPayload
parser.SetLengthPrefixMode 0, 1, 1, False, lmPayload

示例 2:带 STX 和 CRC

[STX(1)][LEN(2,BE)][DATA(n)][CRC(1)]

长度值 = n,BaseSize = 1(STX) + 2(LEN) + 1(CRC) = 4。

vb
parser.SetLengthPrefixMode 1, 2, 4, True, lmPayload
' Offset=1(跳过STX), Size=2字节, BaseSize=4, 大端序

示例 3:长度值代表整帧

[HEADER(2)][LEN(2,LE)][DATA(n)]

长度值 = 2 + 2 + n(整帧长度)。

vb
parser.SetLengthPrefixMode 2, 2, 0, False, lmTotalFrame
' Offset=2(跳过HEADER), Size=2字节, BaseSize=0(用不上), 小端

示例 4:长度值包含 Trailer

[LEN(1)][DATA(n)][CRC(1)]

长度值 = n + 1(包含 CRC),BaseSize = 1(仅 LEN 字段)。

vb
parser.SetLengthPrefixMode 0, 1, 1, False, lmPayloadAndTrailer

Modbus RTU 特殊说明

Modbus RTU 没有长度字段,帧边界靠 3.5 字符时间的静默间隔识别(9600bps 下约 3.5ms)。这种情况下:

  • 推荐用 方案 BFrameInterval = 4 左右)实现"Modbus 风格分帧"
  • 或直接使用 VBManLib 的 cModbusMaster / cModbusSlave 类,它们已内置 RTU 分帧

C4. 固定长度模式

适用于帧长度固定的协议(如某些传感器每帧固定 16 字节):

vb
parser.SetFixedSizeMode 16   ' 每 16 字节切一帧

Do While parser.HasFrame()
    Dim frame() As Byte
    frame = parser.GetFrame()
    ' 处理 frame...
Loop

通用 API

无论哪种模式,对外接口统一:

方法/属性说明
AppendData(Data() As Byte)追加字节流到内部缓冲区
HasFrame() As Boolean检查缓冲区中是否有完整帧
GetFrame() As Byte()取出一帧并从缓冲区移除
GetAllFrames() As Collection取出所有可用帧(每元素为 Byte 数组)
Clear()清空内部缓冲区
BufferLength只读,缓冲区剩余未解析字节数
vb
' 一次性取出所有帧
Dim frames As Collection
Set frames = parser.GetAllFrames()

Dim v As Variant, frame() As Byte
For Each v In frames
    frame = v
    Debug.Print StrConv(frame, vbUnicode)
Next

B + C 组合实战

最高可靠性场景:先用方案 B 粗切(基于时间),再用方案 C 精切(基于协议)。这样即使设备帧间无停顿,也能正确分帧。

典型场景

设备协议:[STX][LEN][DATA][ETX],但设备可能连续发多帧无停顿。

完整代码

vb
Private WithEvents m_Port As cSerialPort
Private m_Parser As cSerialFrameParser

Private Sub Form_Load()
    Set m_Port = New cSerialPort
    Set m_Parser = New cSerialFrameParser

    ' 配置协议分帧(方案 C2:起止标记模式)
    m_Parser.SetStartEndMode Chr$(&H2), Chr$(&H3), True

    ' 配置串口
    m_Port.PortName = "COM3"
    m_Port.Config.BaudRate = br115200

    If m_Port.OpenPort() Then
        ' 启用时间分帧(方案 B)
        m_Port.FrameInterval = 50

        ' 启动监控
        m_Port.StartMonitoring 20
    End If
End Sub

' 方案 B 触发:每次"时间帧"到达
Private Sub m_Port_FrameReceived(FrameData() As Byte)
    ' 把时间帧喂给协议解析器
    m_Parser.AppendData FrameData

    ' 方案 C 精切:从一个时间帧中切出多个协议帧
    Do While m_Parser.HasFrame()
        Dim frame() As Byte
        frame = m_Parser.GetFrame()
        Debug.Print "完整协议帧: " & BytesToHex(frame)
    Loop
End Sub

Private Sub Form_Unload(Cancel As Integer)
    If Not m_Port Is Nothing Then
        If m_Port.IsOpen Then m_Port.ClosePort
        Set m_Port = Nothing
    End If
End Sub

' 辅助:字节数组转十六进制字符串
Private Function BytesToHex(buf() As Byte) As String
    Dim i As Long, s As String
    For i = 0 To UBound(buf)
        s = s & Right$("0" & Hex$(buf(i)), 2) & " "
    Next
    BytesToHex = Trim$(s)
End Function

数据流示意

设备发送:  [STX][LEN=3][A][B][C][ETX][STX][LEN=2][X][Y][ETX]
                              (两帧无停顿)

方案B (FrameInterval=50ms):
  └─ 触发 FrameReceived(12字节)

方案C (SetStartEndMode):
  └─ 切出两帧:
     1. [STX][LEN=3][A][B][C][ETX]
     2. [STX][LEN=2][X][Y][ETX]

方案选型建议

是否知道设备协议格式?
├── 否 ──→ 用方案 B(FrameInterval = 30~50ms)

└── 是 ──→ 协议有哪种特征?
          ├── 有分隔符(\r\n 等)        ──→ 方案 C1
          ├── 有 STX/ETX 起止标记        ──→ 方案 C2
          ├── 有长度字段                 ──→ 方案 C3
          ├── 固定长度                   ──→ 方案 C4
          └── 都没有,但帧间有停顿       ──→ 方案 B

                                            └── 可靠性要求高?
                                                ├── 是 ──→ B + C 组合
                                                └── 否 ──→ 仅 B

各方案适用场景速查

设备类型推荐方案配置示例
GPS 模块(NMEA 0183)C1SetDelimiterMode vbCrLf, False
温湿度传感器(文本输出)C1 或 B分隔符 "\r\n"FrameInterval=50
RFID 读卡器(STX/ETX 协议)C2SetStartEndMode Chr(2), Chr(3), True
Modbus RTUB 或专用类FrameInterval=4,或用 cModbusMaster
自定义二进制协议(带长度)C3见长度前缀示例
电子秤(固定长度输出)C4SetFixedSizeMode 16
工业仪表(不定时上报)BFrameInterval=30

调参与排错

常见问题

1. 帧被切成两半

现象:本应一帧的数据被分成两次 FrameReceived 触发。

原因FrameInterval 太小,设备发一帧中途有短暂停顿就被误判为帧结束。

解决:增大 FrameInterval。可用串口调试助手先观察设备单帧发送过程中的最大字节间隔。

2. 多帧粘连成一帧

现象:本应是两帧的数据被合并成一次 FrameReceived 触发。

原因FrameInterval 太大,设备发完一帧后停顿时间小于 FrameInterval

解决:减小 FrameInterval,或改用方案 C 按协议精确分帧。

3. 最后一帧丢失

现象:设备发完最后一帧后停止发送,但没有触发 FrameReceived

原因:最后一帧之后没有后续数据来"推动"超时判定。

解决:这是已修复的问题。StopMonitoringClosePort 时会自动 flush 剩余缓冲区。如果你手动管理生命周期,确保停止前调用 m_Port.FlushFrames

4. 方案 C 解析不出帧

排查步骤

vb
' 1. 打印原始数据确认
Debug.Print "原始数据: " & BytesToHex(rawBytes)

' 2. 检查 BufferLength 是否在增长
Debug.Print "缓冲区长度: " & parser.BufferLength

' 3. 检查配置是否正确
Debug.Print "模式: " & parser.Mode       ' 应为 1/2/3/4
Debug.Print "缓冲区: " & parser.BufferLength

' 4. 协议是否与配置匹配?
'    - 分隔符模式:检查分隔符字节是否正确
'    - 起止标记:确认 STX/ETX 字节值
'    - 长度前缀:用调试助手查看长度字段值,手算帧总长度是否匹配

调试辅助函数

vb
' 字节数组转十六进制字符串(调试用)
Private Function BytesToHex(buf() As Byte) As String
    Dim i As Long, s As String
    On Error Resume Next
    For i = LBound(buf) To UBound(buf)
        s = s & Right$("0" & Hex$(buf(i)), 2) & " "
    Next
    BytesToHex = Trim$(s)
End Function

' 字节数组转可见 ASCII(不可见字符显示为 .)
Private Function BytesToAscii(buf() As Byte) As String
    Dim i As Long, s As String, b As Byte
    On Error Resume Next
    For i = LBound(buf) To UBound(buf)
        b = buf(i)
        If b >= 32 And b < 127 Then
            s = s & Chr$(b)
        Else
            s = s & "."
        End If
    Next
    BytesToAscii = s
End Function

性能注意事项

  1. GetFrame() 不复制大块内存:内部使用 CopyMemory 高效搬运,单帧 MB 级也无压力。
  2. 缓冲区自动扩容AppendData 时自动按需扩容,无需手动管理。
  3. 避免在事件中耗时操作FrameReceived 事件回调中应尽快处理,不要做重计算或 UI 刷新,避免影响下一帧接收。

完整 API 速查

cSerialPort 分帧相关

成员类型说明
FrameInterval属性帧间隔超时(ms),0=禁用分帧
MaxFrameSize属性单帧最大字节数(默认 65536)
FrameBufferLength属性只读,当前帧缓冲区字节数
FrameReceived事件完整帧到达时触发(参数:FrameData() As Byte
FlushFrames方法手动触发缓冲区中剩余的帧

cSerialFrameParser 完整 API

成员类型说明
Mode属性分帧模式(fmNone/fmDelimiter/fmStartEnd/fmLengthPrefix/fmFixedSize
BufferLength属性只读,缓冲区剩余字节数
AppendData方法追加字节流
HasFrame方法检查是否有完整帧
GetFrame方法取出一帧
GetAllFrames方法取出所有帧
Clear方法清空缓冲区
SetDelimiterMode方法配置为分隔符模式
SetStartEndMode方法配置为起止标记模式
SetLengthPrefixMode方法配置为长度前缀模式
SetFixedSizeMode方法配置为固定长度模式

最后更新: 2026-07-05

VB6及其LOGO版权为微软公司所有