
TCP是面向字节流的协议,没有消息边界概念。发送端调用send()两次,接收端recv()一次可能拿到两个包拼在一起的数据,也可能只拿到半个包。这不是bug,是TCP的设计特性。
网口温湿度记录仪通过TCP持续上报数据帧时,常见现象:
现象 | 描述 |
|---|---|
粘包 | 两次上报的数据帧拼接在一起,一次recv()返回 |
半包 | 一次recv()只返回数据帧的前半部分 |
拆包 | 一个完整数据帧被拆成两次recv()返回 |
乱序 | 多台记录仪并发上报时,不同设备的数据交错 |
根因链条:
发送端 网络 接收端
├─ send(frame1) ──┐
├─ send(frame2) ──┼── TCP分段 ──▶ 路由器分片 ──▶ 网卡DMA ──▶ 内核socket缓冲区
│ │
└── 合并写入接收缓冲区 ◀── 无消息边界标记 ◀────┘
│
recv() 一次读出
可能包含 frame1+frame2
也可能只有 frame1 的一半方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
固定长度 | 每帧长度固定,按长度截取 | 实现最简单 | 浪费带宽或不够用 | 数据格式极其固定 |
分隔符 | 帧之间用特殊字符/序列分隔 | 实现简单,可读性好 | 数据体中不能出现分隔符 | 文本协议 |
长度前缀 | 帧头携带后续数据长度 | 通用、高效 | 需要处理不完整帧 | 二进制协议(最常用) |
自定义协议头 | 包头含魔数+长度+类型+校验 | 最健壮 | 实现复杂 | 工业设备、多设备并发 |
网口温湿度记录仪绝大多数采用长度前缀或自定义协议头方案,本文聚焦这两种的工程实现。
┌──────────────────────────────────────────────────────┐
│ 帧结构(长度前缀方案) │
│ │
│ ┌──────────┬──────────┬──────────┬────────────┐ │
│ │ 帧长度(2B)│ 设备ID(4B)│ 数据类型(1B)│ 数据载荷(NB) │ │
│ │ 0x000A │ 0x00000001│ 0x01 │ ... │ │
│ └──────────┴──────────┴──────────┴────────────┘ │
│ │
│ 帧长度 = 设备ID(4) + 数据类型(1) + 数据载荷(N) │
│ 不包含帧长度字段自身 │
└──────────────────────────────────────────────────────────┘# length_prefix_parser.py
import socket
import struct
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class SensorFrame:
device_id: int
data_type: int
payload: bytes
timestamp: float
class LengthPrefixParser:
"""长度前缀协议解析器——处理粘包/半包"""
def __init__(self, sock: socket.socket, max_frame_size: int = 4096):
self.sock = sock
self.max_frame_size = max_frame_size
self.recv_buffer = bytearray() # 接收缓冲区
def recv_frames(self) -> List[SensorFrame]:
"""从socket接收数据并解析出完整帧"""
frames = []
# 1. 读取socket数据追加到缓冲区
try:
data = self.sock.recv(4096)
if not data:
return frames # 连接关闭
self.recv_buffer.extend(data)
except socket.error as e:
return frames
# 2. 循环解析缓冲区中的完整帧
while len(self.recv_buffer) >= 7: # 最小帧长:2+4+1=7字节
# 读取帧长度(前2字节,大端)
frame_len = struct.unpack('>H', self.recv_buffer[0:2])[0]
if frame_len < 5 or frame_len > self.max_frame_size:
# 长度异常,丢弃1字节尝试同步
self.recv_buffer.pop(0)
continue
total_len = 2 + frame_len # 帧总长度 = 长度字段 + 载荷
if len(self.recv_buffer) < total_len:
# 数据不够,等待下次接收
break
# 提取完整帧
frame_data = self.recv_buffer[2:total_len]
self.recv_buffer = self.recv_buffer[total_len:]
# 解析帧内容
if len(frame_data) >= 5:
device_id = struct.unpack('>I', frame_data[0:4])[0]
data_type = frame_data[4]
payload = frame_data[5:]
frames.append(SensorFrame(
device_id=device_id,
data_type=data_type,
payload=payload,
timestamp=__import__('time').time()
))
return frames
def parse_payload(self, frame: SensorFrame) -> dict:
"""解析数据载荷(温湿度值)"""
if frame.data_type == 0x01: # 温湿度数据
if len(frame.payload) >= 4:
temp_raw, humi_raw = struct.unpack('>HH', frame.payload[0:4])
return {
"device_id": frame.device_id,
"temperature": temp_raw / 10.0,
"humidity": humi_raw / 10.0,
"timestamp": frame.timestamp
}
return {"device_id": frame.device_id, "raw": frame.payload.hex()}bytearray累积数据,不依赖单次recv()返回完整帧 while循环全部提取 break,等下次recv()追加后继续解析 ┌──────────────────────────────────────────────────────────────────┐
│ 帧结构(自定义协议头方案) │
│ │
│ ┌──────┬──────┬──────┬──────┬──────┬──────┬────────┬──────┐ │
│ │ 魔数 │ 版本 │ 长度 │ 设备 │ 类型 │ 序号 │ 数据 │ CRC16│ │
│ │ 2B │ 1B │ 2B │ 4B │ 1B │ 2B │ NB │ 2B │ │
│ │ 0xAA │ 0x01 │ 0x00 │ 0x00 │ 0x01 │ 0x00 │ ... │ 0xXX │ │
│ │ 55 │ │ 14 │ 0001 │ │ 01 │ │ │ │
│ └──────┴──────┴──────┴──────┴──────┴──────┴────────┴──────┘ │
│ │
│ 魔数:帧起始标志,用于快速同步 │
│ 长度:从版本到CRC16的总字节数 │
│ CRC16:对整个帧(不含CRC字段)校验 │
└──────────────────────────────────────────────────────────────────┘# protocol_header_parser.py
import socket
import struct
import crcmod
from typing import List, Optional
from dataclasses import dataclass
@dataclass
class ProtocolFrame:
magic: int
version: int
length: int
device_id: int
frame_type: int
sequence: int
payload: bytes
crc: int
timestamp: float
class ProtocolHeaderParser:
"""自定义协议头解析器"""
HEADER_SIZE = 12 # 魔数(2)+版本(1)+长度(2)+设备ID(4)+类型(1)+序号(2)
MAGIC = 0xAA55
CRC_FUNC = crcmod.predefined.mkPredefinedCrcFun('modbus')
def __init__(self, sock: socket.socket, max_frame_size: int = 4096):
self.sock = sock
self.max_frame_size = max_frame_size
self.recv_buffer = bytearray()
def recv_frames(self) -> List[ProtocolFrame]:
"""接收并解析完整帧"""
frames = []
try:
data = self.sock.recv(4096)
if not data:
return frames
self.recv_buffer.extend(data)
except socket.error:
return frames
while True:
# 查找魔数
magic_pos = self._find_magic()
if magic_pos is None:
# 没有找到魔数,清空缓冲区(保留最后1字节以防魔数被截断)
if len(self.recv_buffer) > 1:
self.recv_buffer = self.recv_buffer[-1:]
break
# 丢弃魔数之前的垃圾数据
if magic_pos > 0:
self.recv_buffer = self.recv_buffer[magic_pos:]
# 检查是否有足够数据读取头部
if len(self.recv_buffer) < self.HEADER_SIZE + 2: # +2 for CRC
break
# 解析头部
header = self.recv_buffer[0:self.HEADER_SIZE]
magic = struct.unpack('>H', header[0:2])[0]
version = header[2]
length = struct.unpack('>H', header[3:5])[0]
device_id = struct.unpack('>I', header[5:9])[0]
frame_type = header[9]
sequence = struct.unpack('>H', header[10:12])[0]
total_frame_len = self.HEADER_SIZE + length
if length > self.max_frame_size:
# 长度异常,跳过这个魔数继续查找
self.recv_buffer = self.recv_buffer[2:]
continue
if len(self.recv_buffer) < total_frame_len + 2: # +2 CRC
break
# 提取完整帧
frame_data = self.recv_buffer[:total_frame_len + 2]
self.recv_buffer = self.recv_buffer[total_frame_len + 2:]
# 分离载荷和CRC
payload_end = self.HEADER_SIZE + length - 4 - 2 # 减去设备ID(4)+类型(1)+序号(2)
payload = frame_data[self.HEADER_SIZE:payload_end]
crc_received = struct.unpack('>H', frame_data[-2:])[0]
# CRC校验
crc_calculated = self.CRC_FUNC(bytes(frame_data[:-2]))
if crc_calculated != crc_received:
continue # CRC校验失败,丢弃
frames.append(ProtocolFrame(
magic=magic,
version=version,
length=length,
device_id=device_id,
frame_type=frame_type,
sequence=sequence,
payload=payload,
crc=crc_received,
timestamp=__import__('time').time()
))
return frames
def _find_magic(self) -> Optional[int]:
"""在缓冲区中查找魔数0xAA55"""
for i in range(len(self.recv_buffer) - 1):
if self.recv_buffer[i] == 0xAA and self.recv_buffer[i + 1] == 0x55:
return i
return None┌──────────────────────────────────────────────────────────────────┐
│ 多设备并发架构 │
│ │
│ 记录仪A ──┐ │
│ 记录仪B ──┼──▶ 交换机 ──▶ 采集服务器(单端口监听) │
│ 记录仪C ──┘ │
│ │
│ 问题:多台设备的数据帧在采集端TCP连接中交错 │
│ 解决:每个设备独立TCP连接 + 独立解析器实例 │
└──────────────────────────────────────────────────────────────────┘# multi_device_collector.py
import socket
import threading
from typing import Dict, List
from length_prefix_parser import LengthPrefixParser
from protocol_header_parser import ProtocolHeaderParser
class MultiDeviceCollector:
"""多设备并发采集管理器"""
def __init__(self, listen_host: str = "0.0.0.0", listen_port: int = 9000):
self.listen_host = listen_host
self.listen_port = listen_port
self.server_sock = None
self.clients: Dict[str, dict] = {} # {client_addr: {"socket": sock, "parser": parser}}
self.running = False
def start(self):
"""启动监听"""
self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server_sock.bind((self.listen_host, self.listen_port))
self.server_sock.listen(50)
self.running = True
print(f"监听 {self.listen_host}:{self.listen_port}")
while self.running:
try:
client_sock, addr = self.server_sock.accept()
client_key = f"{addr[0]}:{addr[1]}"
print(f"新连接: {client_key}")
# 为每个客户端创建独立解析器
parser = LengthPrefixParser(client_sock)
self.clients[client_key] = {
"socket": client_sock,
"parser": parser,
"addr": addr
}
# 启动独立线程处理该客户端
thread = threading.Thread(
target=self._handle_client,
args=(client_key,),
daemon=True
)
thread.start()
except Exception as e:
print(f"接受连接异常: {e}")
def _handle_client(self, client_key: str):
"""处理单个客户端连接"""
client = self.clients.get(client_key)
if not client:
return
sock = client["socket"]
parser = client["parser"]
while self.running:
try:
frames = parser.recv_frames()
for frame in frames:
self._process_frame(frame, client_key)
if not frames:
# 没有数据,短暂休眠
__import__('time').sleep(0.01)
except ConnectionError:
print(f"连接断开: {client_key}")
break
except Exception as e:
print(f"处理异常 {client_key}: {e}")
break
# 清理
sock.close()
if client_key in self.clients:
del self.clients[client_key]
print(f"连接关闭: {client_key}")
def _process_frame(self, frame, client_key: str):
"""处理解析出的数据帧"""
# 解析温湿度值
if frame.data_type == 0x01:
temp_raw, humi_raw = struct.unpack('>HH', frame.payload[0:4])
temp = temp_raw / 10.0
humi = humi_raw / 10.0
print(f"[{client_key}] 设备{frame.device_id}: {temp}°C, {humi}%RH")
# 写入数据库/转发平台
# self._save_to_db(frame.device_id, temp, humi, frame.timestamp)# test_sticky_packet.py
"""模拟粘包场景的测试工具"""
import socket
import struct
import time
def simulate_sticky_packet(host: str, port: int, frame_count: int = 5):
"""模拟粘包:连续发送多个帧不等待"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
# 构造多个帧
frames = []
for i in range(frame_count):
device_id = 1001 + i
temp = 220 + i * 5 # 22.0°C + i*0.5
humi = 500 + i * 10 # 50.0% + i*1.0
payload = struct.pack('>HH', temp, humi)
frame_len = 4 + 1 + len(payload) # device_id(4) + type(1) + payload
frame = struct.pack('>H', frame_len) + struct.pack('>I', device_id) + struct.pack('>B', 0x01) + payload
frames.append(frame)
# 方式1:逐个发送(模拟正常情况)
print("方式1:逐个发送")
for frame in frames:
sock.send(frame)
time.sleep(0.1)
time.sleep(1)
# 方式2:合并发送(模拟粘包)
print("方式2:合并发送(粘包)")
combined = b''.join(frames)
sock.send(combined)
time.sleep(1)
# 方式3:分片发送(模拟拆包)
print("方式3:分片发送(拆包)")
combined = b''.join(frames)
chunk_size = 5 # 每次只发5字节
for i in range(0, len(combined), chunk_size):
chunk = combined[i:i+chunk_size]
sock.send(chunk)
time.sleep(0.02)
sock.close()
print("测试完成")问题 | 原因 | 解决 |
|---|---|---|
解析出乱码数据 | 缓冲区未清理,残留旧数据 | 增加魔数同步机制 |
偶尔丢帧 | 缓冲区满但未处理 | 增大缓冲区,优化解析速度 |
CPU占用高 | 频繁查找魔数 | 优化查找算法,使用状态机 |
多设备数据混淆 | 共用解析器实例 | 每个连接独立解析器 |
CRC校验频繁失败 | 字节序错误 | 确认大端/小端,统一协议定义 |
recv()返回一个完整帧。这是粘包问题最常见的新手错误。 max_frame_size并丢弃超限数据。 关键词:TCP粘包,网口温湿度记录仪,数据帧解析,长度前缀,协议头,接收缓冲区,多设备并发,CRC校验,拆包,半包
标签:#TCP粘包 #网口温湿度记录仪 #数据帧解析 #长度前缀 #协议头 #接收缓冲区 #多设备并发 #CRC校验 #拆包 #半包
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。