串口数据帧分帧协议
解决串口通信中"设备连发导致粘包"和"无法识别完整帧"的核心问题
📖 目录
问题背景
粘包现象
串口通信中,设备常常连续发送多帧数据。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 值即可启用:
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关键属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
FrameInterval | Long | 0 | 帧间隔超时(ms),0=禁用分帧(保持原始 DataReceived 模式) |
MaxFrameSize | Long | 65536 | 单帧最大字节数,超过则强制触发,防止异常数据导致内存溢出 |
FrameBufferLength | Long | - | 只读,当前帧缓冲区中的字节数(已收到的未触发数据) |
FrameInterval 取值建议
| 设备类型 | 建议值 | 说明 |
|---|---|---|
| 文本设备(GPS、温湿度等) | 20-50ms | 帧间停顿明显 |
| 工业仪表 | 10-30ms | 响应快,停顿短 |
| Modbus RTU | - | 不推荐用 B,应使用方案 C3(详见下文) |
| 慢速设备(电子秤等) | 50-100ms | 设备本身发送就慢 |
经验法则:先观察设备连续发数据时帧间的实际停顿(可用串口调试助手查看时间戳),
FrameInterval取停顿时间的一半左右最稳妥。
注意事项
- 轮询间隔与帧间隔的关系:
PollInterval应 ≤FrameInterval / 2,否则可能错过帧超时判定时机。 - 不破坏原模式:
FrameInterval = 0时仍走原来的DataReceived路径,向后兼容。 - 最后一帧不丢:
StopMonitoring和ClosePort时会自动 flush 缓冲区中剩余的未触发帧。 - 大帧保护:单帧超过
MaxFrameSize时强制触发,避免异常数据耗尽内存。
方案 C:协议分帧解析器
cSerialFrameParser 是独立的帧解析类,支持 4 种协议分帧模式。它不直接读串口,而是接收 AppendData 传入的字节流,按协议规则切分出完整帧。
引用方式
' 方式一:源码引用
' 将 src/SerialPort/cSerialFrameParser.cls 加入工程
' 方式二:DLL 引用
Dim parser As New VBMANLIB.cSerialFrameParserC1. 分隔符模式
适用于有固定分隔符的协议(文本协议最常见):
[DATA1][DELIM][DATA2][DELIM][DATA3][DELIM]...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) |
IncludeDelim | True=返回帧包含分隔符;False=不包含(默认) |
C2. 起始结束标记模式
适用于有 STX/ETX 等定界符的二进制协议:
[STX][DATA1][ETX][STX][DATA2][ETX]...Dim parser As New cSerialFrameParser
' STX(0x02) ... ETX(0x03),返回帧包含标记
parser.SetStartEndMode Chr$(&H2), Chr$(&H3), True
' 也支持多字节标记
parser.SetStartEndMode "<<<", ">>>", True| 参数 | 说明 |
|---|---|
StartStr | 起始标记字符串 |
EndStr | 结束标记字符串 |
IncludeMarks | True=返回帧含标记;False=只返回中间数据 |
容错特性:缓冲区开头不是 StartMark 时,解析器会自动跳过无效字节,从下一个 StartMark 开始解析。这能正确处理"半帧残留"问题(上次未收完的残帧)。
C3. 长度前缀模式
适用于含长度字段的二进制协议(如 Modbus、自定义帧头协议):
┌─────────┬───────────┬──────────┬─────────┐
│ Header │ Length(n) │ Payload │ Trailer │
│ (固定) │ (1/2/4B) │ (n字节) │ (固定) │
└─────────┴───────────┴──────────┴─────────┘配置参数
| 参数 | 说明 |
|---|---|
Offset | 长度字段在帧中的字节偏移(从 0 开始) |
Size | 长度字段大小:1/2/4 字节 |
BaseSize | 帧基础大小(长度值代表的范围之外的字节数:Header + Length字段 + Trailer) |
BigEndian | True=大端序;False=小端序 |
Meaning | 长度值的含义(见下表) |
LengthMeaning 枚举
| 值 | 含义 | 帧总长度计算 |
|---|---|---|
lmPayload (0) | 长度值 = 数据负载长度 | FrameBaseSize + 长度值 |
lmPayloadAndTrailer (1) | 长度值 = 负载 + 帧尾 | FrameBaseSize + 长度值 |
lmTotalFrame (2) | 长度值 = 整个帧长度 | 长度值 |
常见协议配置示例
示例 1:简单长度前缀
[LEN(1)][DATA(n)]长度值 = n(数据长度),无 Header/Trailer。
' 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。
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(整帧长度)。
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 字段)。
parser.SetLengthPrefixMode 0, 1, 1, False, lmPayloadAndTrailerModbus RTU 特殊说明
Modbus RTU 没有长度字段,帧边界靠 3.5 字符时间的静默间隔识别(9600bps 下约 3.5ms)。这种情况下:
- 推荐用 方案 B(
FrameInterval = 4左右)实现"Modbus 风格分帧" - 或直接使用 VBManLib 的
cModbusMaster/cModbusSlave类,它们已内置 RTU 分帧
C4. 固定长度模式
适用于帧长度固定的协议(如某些传感器每帧固定 16 字节):
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 | 只读,缓冲区剩余未解析字节数 |
' 一次性取出所有帧
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)
NextB + C 组合实战
最高可靠性场景:先用方案 B 粗切(基于时间),再用方案 C 精切(基于协议)。这样即使设备帧间无停顿,也能正确分帧。
典型场景
设备协议:[STX][LEN][DATA][ETX],但设备可能连续发多帧无停顿。
完整代码
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) | C1 | SetDelimiterMode vbCrLf, False |
| 温湿度传感器(文本输出) | C1 或 B | 分隔符 "\r\n" 或 FrameInterval=50 |
| RFID 读卡器(STX/ETX 协议) | C2 | SetStartEndMode Chr(2), Chr(3), True |
| Modbus RTU | B 或专用类 | FrameInterval=4,或用 cModbusMaster |
| 自定义二进制协议(带长度) | C3 | 见长度前缀示例 |
| 电子秤(固定长度输出) | C4 | SetFixedSizeMode 16 |
| 工业仪表(不定时上报) | B | FrameInterval=30 |
调参与排错
常见问题
1. 帧被切成两半
现象:本应一帧的数据被分成两次 FrameReceived 触发。
原因:FrameInterval 太小,设备发一帧中途有短暂停顿就被误判为帧结束。
解决:增大 FrameInterval。可用串口调试助手先观察设备单帧发送过程中的最大字节间隔。
2. 多帧粘连成一帧
现象:本应是两帧的数据被合并成一次 FrameReceived 触发。
原因:FrameInterval 太大,设备发完一帧后停顿时间小于 FrameInterval。
解决:减小 FrameInterval,或改用方案 C 按协议精确分帧。
3. 最后一帧丢失
现象:设备发完最后一帧后停止发送,但没有触发 FrameReceived。
原因:最后一帧之后没有后续数据来"推动"超时判定。
解决:这是已修复的问题。StopMonitoring 和 ClosePort 时会自动 flush 剩余缓冲区。如果你手动管理生命周期,确保停止前调用 m_Port.FlushFrames。
4. 方案 C 解析不出帧
排查步骤:
' 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 字节值
' - 长度前缀:用调试助手查看长度字段值,手算帧总长度是否匹配调试辅助函数
' 字节数组转十六进制字符串(调试用)
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性能注意事项
GetFrame()不复制大块内存:内部使用CopyMemory高效搬运,单帧 MB 级也无压力。- 缓冲区自动扩容:
AppendData时自动按需扩容,无需手动管理。 - 避免在事件中耗时操作:
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