- 下位机发送的数据结构:
帧头0x58 0x58 + 有效数据 ,不定数据长度 + 帧尾0x59 0x59 + CRC16 2B,大端
- CRC校验范围:
帧头 + 有效数据 + 帧尾头(0x
由于是TCP通信,不可避免粘包、半包、拆包。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PLC TCP 通信联调测试工具
========================
帧结构: [0x58 0x58] + [有效数据 N B] + [0x59 0x59] + [CRC16 2B大端]
CRC校验范围: 帧头 + 有效数据 + 帧尾
功能:
1. 作为TCP客户端连接S7-1500
2. 输入hex数据自动组帧发送
3. 原始字节发送(不组帧)
4. CRC16计算预览
5. 实时显示接收原始字节 + 完整解析过程(找帧头/试帧尾/CRC对比)
6. 自动处理粘包/拆包/假帧尾/错误帧头(跳帧恢复)
用法:
python plc_debug_tool.py # 默认 192.168.100.1:2000
python plc_debug_tool.py 192.168.1.10 2000
"""
import socket
import struct
import threading
import sys
# ═══════════════════════════════════════════════════════════
# CRC16 计算(必须与PLC侧完全一致)
# 默认: Modbus标准 --- 多项式0xA001, 初始值0xFFFF
# 如果PLC用的是其他标准(如59ITT 0x1021),修改这个函数
# ═══════════════════════════════════════════════════════════
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
def build_frame(payload: bytes) -> bytes:
"""组帧: 帧头 + 数据 + 帧尾 + CRC16(大端)"""
body = b'\x58\x58' + payload + b'\x59\x59'
crc = crc16_modbus(body)
return body + struct.pack('>H', crc)
def hex_str(data: bytes) -> str:
"""字节转空格分隔的hex字符串"""
return ' '.join(f'{b:02X}' for b in data)
# ═══════════════════════════════════════════════════════════
# 带详细调试输出的帧解析器(含错误帧头跳帧修复)
# ═══════════════════════════════════════════════════════════
class DebugFrameParser:
HEADER = b'\x58\x58'
TAIL = b'\x59\x59'
CRC_LEN = 2
MAX_FRAME = 1024 # 单帧最大字节数保护,根据实际调整
def __init__(self, debug: bool = True):
self.buffer = bytearray()
self.debug = debug
self.frame_count = 0
def log(self, msg: str):
if self.debug:
print(f' [解析] {msg}')
def feed(self, data: bytes):
"""喂入任意长度TCP数据,返回解析出的完整帧列表"""
if not data:
return []
self.log(f'喂入 {len(data)} 字节: {hex_str(data)}')
self.buffer.extend(data)
frames = []
while True:
frame = self._try_parse_one()
if frame is None:
break
frames.append(frame)
if not frames and self.debug:
self.log(f'缓冲区现有 {len(self.buffer)} 字节,暂未形成完整帧')
return frames
def _try_parse_one(self):
buf = self.buffer
# 外层循环: 支持跳帧后重新同步(从新帧头开始)
while True:
# ── 第1步: 找帧头 ──
header_pos = buf.find(self.HEADER)
if header_pos == -1:
# 没找到帧头,保留最后1字节(可能是0x58,下一包凑第二个0x58)
if len(buf) > 1:
discarded = bytes(buf[:-1])
del buf[:-1]
return None
if header_pos > 0:
discarded = bytes(buf[:header_pos])
self.log(f'帧头前有 {header_pos} 字节垃圾: {hex_str(discarded)},已丢弃')
del buf[:header_pos] # 丢弃帧头前的垃圾数据
# ── 第2步: 逐个尝试帧尾,用CRC验证真假 ──
search_pos = 2
attempt = 0
while True:
tail_pos = buf.find(self.TAIL, search_pos)
if tail_pos == -1:
# 缓冲区中所有帧尾 59 59 都已尝试完毕,全部 CRC 失败
# 检查是否存在下一个帧头且其后有完整帧结构
next_header = buf.find(self.HEADER, 2)
if next_header != -1:
next_tail = buf.find(self.TAIL, next_header + 2)
if next_tail != -1 and len(buf) >= next_tail + 2 + self.CRC_LEN:
# 判定当前帧头为错误帧,跳过至新帧头
del buf[:next_header]
break # 跳出内层,回到外层重新找帧头
# 没有下一个有效帧头,可能是正常拆包,等更多数据
if len(buf) > self.MAX_FRAME:
# 缓冲区超过{self.MAX_FRAME}字节仍无有效帧,判定当前帧头为假,跳过1字节重新同步
del buf[:1]
break # 跳出内层,回到外层重新找帧头
# 未找到有效帧尾,等待更多数据
return None
# 单帧长度保护
if tail_pos + 2 + self.CRC_LEN > self.MAX_FRAME:
# 帧尾候选@{tail_pos}超出最大帧长,当前帧头无效,跳过1字节
del buf[:1]
break # 跳出内层,回到外层重新找帧头
attempt += 1
frame_end = tail_pos + 2 + self.CRC_LEN
if len(buf) < frame_end:
# 帧尾候选{tail_pos},但CRC未到齐,需等待
return None
# ── 第3步: CRC校验 ──
crc_range = bytes(buf[:tail_pos + 2]) # 帧头+数据+帧尾
crc_received = struct.unpack('>H', buf[tail_pos + 2:frame_end])[0]
crc_calc = crc16_modbus(crc_range)
if crc_calc == crc_received:
payload = bytes(buf[2:tail_pos]) # 取出数据
raw = bytes(buf[:frame_end]) # 原始字节
del buf[:frame_end] # 删除本帧数据
self.frame_count += 1
print(f'★ 解析成功! 有效数据({len(payload)}B): {hex_str(payload)}')
return {'data': payload, 'raw': raw, 'seq': self.frame_count}
# CRC不通过 → 这个0x59 0x59是数据中的内容,继续找下一个
search_pos = tail_pos + 1
# ═══════════════════════════════════════════════════════════
# 后台接收线程
# ═══════════════════════════════════════════════════════════
def recv_loop(sock: socket.socket, parser: DebugFrameParser, stop_event: threading.Event):
while not stop_event.is_set():
try:
sock.settimeout(0.5)
data = sock.recv(4096)
if not data:
print('\n[连接] PLC 已断开连接')
stop_event.set()
break
print(f'\n [接收] 收到 {len(data)} 字节: {hex_str(data)}')
frames = parser.feed(data)
for f in frames:
print(f'\n {"─"*55}')
print(f' ★ 完整帧 #{f["seq"]} 总长 {len(f["raw"])} 字节')
print(f' 原始帧 : {hex_str(f["raw"])}')
print(f' 有效数据: {hex_str(f["data"])} ({len(f["data"])} 字节)')
print(f' {"─"*55}')
print('\n> ', end='', flush=True)
except socket.timeout:
continue
except Exception as e:
print(f'\n [接收错误] {e}')
stop_event.set()
break
# ═══════════════════════════════════════════════════════════
# 主程序(交互式命令行)
# ═══════════════════════════════════════════════════════════
def main():
print('=' * 60)
print(' PLC TCP 通信联调工具')
print(' 帧结构: [58 58] + [有效数据] + [59 59] + [CRC16大端]')
print('=' * 60)
ip = sys.argv[1] if len(sys.argv) >= 2 else '192.168.100.1'
port = int(sys.argv[2]) if len(sys.argv) >= 3 else 2000
print(f'\n 目标PLC: {ip}:{port}')
print(' 正在连接...')
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((ip, port))
sock.settimeout(None)
print(f' [连接] ✓ 已连接到 {ip}:{port}')
except Exception as e:
print(f' [连接失败] {e}')
print(f' 用法: python {sys.argv[0]} <IP> <端口>')
return
parser = DebugFrameParser(debug=True)
stop_event = threading.Event()
recv_thread = threading.Thread(
target=recv_loop, args=(sock, parser, stop_event), daemon=True
)
recv_thread.start()
print('''
┌─ 命令说明 ─────────────────────────────────────────┐
│ <hex数据> 自动组帧发送,如: 01 02 03 04 │
│ raw <hex> 发送原始字节(不组帧),如: raw 58 58 │
│ crc <hex> 仅计算CRC16预览(不发送) │
│ status 查看当前接收缓冲区 │
│ clear 清空接收缓冲区 │
│ quit 退出 │
└────────────────────────────────────────────────────┘
''')
try:
while not stop_event.is_set():
try:
cmd = input('> ').strip()
except EOFError:
break
if not cmd:
continue
low = cmd.lower()
if low in ('quit', 'exit', 'q'):
break
if low == 'clear':
parser.buffer.clear()
print(' [操作] 接收缓冲区已清空')
continue
if low == 'status':
print(f' [状态] 缓冲区 {len(parser.buffer)} 字节: '
f'{hex_str(bytes(parser.buffer))}')
continue
if low.startswith('crc '):
try:
payload = bytes.fromhex(cmd[4:].replace(' ', ''))
body = b'\x58\x58' + payload + b'\x59\x59'
crc = crc16_modbus(body)
full = body + struct.pack('>H', crc)
print(f' [CRC计算]')
print(f' 校验范围(帧头+数据+帧尾): {hex_str(body)}')
print(f' CRC16 = 0x{crc:04X} '
f'大端字节序: {crc >> 8:02X} {crc & 0xFF:02X}')
print(f' 完整帧({len(full)}B): {hex_str(full)}')
except ValueError:
print(' [错误] hex格式不正确')
continue
if low.startswith('raw '):
try:
raw = bytes.fromhex(cmd[4:].replace(' ', ''))
sock.sendall(raw)
print(f' [发送原始] {len(raw)} 字节: {hex_str(raw)}')
except ValueError:
print(' [错误] hex格式不正确')
continue
# 默认: 输入hex数据 → 自动组帧发送
try:
payload = bytes.fromhex(cmd.replace(' ', ''))
frame = build_frame(payload)
sock.sendall(frame)
print(f' [发送组帧] 有效数据({len(payload)}B): {hex_str(payload)}')
print(f' 完整帧 ({len(frame)}B): {hex_str(frame)}')
except ValueError:
print(' [错误] 无法识别的命令,请检查hex格式或命令')
except KeyboardInterrupt:
pass
finally:
stop_event.set()
sock.close()
print('\n [退出] 连接已关闭')
if __name__ == '__main__':
main()
- 功能测试:
| 测试场景 | 结果 |
|---|---|
| 错误帧头 + 正确帧粘连 | ✓ 跳过错误帧头,正确解析出后面的帧 |
| 两帧正常粘连 | ✓ 解析出 2 帧 |
| 有效数据中含假帧尾 | ✓ 正确识别真帧尾,数据完整保留 |
| 半帧拆包(分两次到达) | ✓ 第一次 0 帧,第二次拼出完整帧 |