一、背景介绍
在工业自动化领域,上位机与 PLC(可编程逻辑控制器)之间经常需要通过 TCP 协议进行数据交互。不同于普通的 HTTP 请求,PLC 通信通常采用自定义二进制协议 ,报文格式固定,且需要处理粘包/半包问题。
本文将分享一个通用的 TCP 控制器类 Controller,它封装了:
-
TCP 连接管理
-
固定格式报文打包(使用
struct) -
报文发送与序列号自增
-
三种接收模式(基础接收、定长接收、分隔符接收)
代码已在实际项目中验证,可直接复用或二次开发。
二、协议说明
假设我们的 PLC 使用 16 字节固定长度报文,格式如下:
| 字节索引 | 字段名 | 说明 |
|---|---|---|
| 0 | header | 帧头(固定值) |
| 1 | rows | 行数 |
| 2 | cols | 列数 |
| 3 | module_cnt | 模块数量 |
| 4-9 | speed1~speed6 | 6 个速度值 |
| 10 | seq_num | 序列号(每次发送自增) |
| 11-12 | admit1~admit2 | 预留字段 |
| 13 | end_char | 结束字符 |
| 14-15 | tail1~tail2 | 帧尾 |
实际项目中请根据具体 PLC 协议调整字段定义。
三、完整代码实现
python
import socket
import struct
import time
class Controller:
""" 具备tcp/ip与plc建立连接,收发报文功能
"""
def __init__(self, ip, port, data, recv_timeout=0.5):
"""
:param ip:tcp服务端IP地址;
:param port:tcp服务端端口;
:param data:发送报文字典
:param recv_timeout: 接收超时时间(秒),非阻塞接收用
"""
self.ip = ip
self.port = port
self.data = data
self.recv_timeout = recv_timeout
self.sock = None
self.connected = False
def connect(self):
"""建立TCP连接,返回连接状态"""
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(2)
self.sock.connect((self.ip, self.port))
self.connected = True
print("TCP 连接成功")
return True
except Exception as e:
print(f"TCP 连接失败: {e}")
self.connected = False
return False
def build_message(self):
"""组包16字节固定格式报文,返回bytes"""
try:
return struct.pack('!BBBBBBBBBBBBBBBB',
self.data["header"],
self.data["rows"],
self.data["cols"],
self.data["module_cnt"],
self.data["speed1"],
self.data["speed2"],
self.data["speed3"],
self.data["speed4"],
self.data["speed5"],
self.data["speed6"],
self.data["seq_num"],
self.data["admit1"],
self.data["admit2"],
self.data["end_char"],
self.data["tail1"],
self.data["tail2"]
)
except Exception as e:
print(f"报文打包错误: {e}")
return None
def send(self):
"""发送打包后的报文,发送成功返回True,失败False"""
if not self.connected or not self.sock:
return False
msg = self.build_message()
if not msg:
return False
try:
self.sock.sendall(msg)
# 自增序列号
self.data["seq_num"] = (self.data["seq_num"] + 1) % 256
return True
except Exception as e:
print(f"发送失败: {e}")
self.connected = False
return False
# ===================== 新增接收相关方法 =====================
def recv_data(self, buf_len=1024):
"""
基础接收:单次读取最多 buf_len 字节原始数据
:param buf_len: 单次最大接收字节数
:return: bytes 收到的数据 / None 异常/断开
"""
if not self.connected or not self.sock:
return None
try:
self.sock.settimeout(self.recv_timeout)
recv_buf = self.sock.recv(buf_len)
if not recv_buf:
# 空数据 = 对端主动关闭连接
print("PLC端主动断开连接")
self.connected = False
return None
return recv_buf
except socket.timeout:
# 接收超时,无数据,不算断开
return b""
except Exception as e:
print(f"接收数据异常: {e}")
self.connected = False
return None
def recv_fixed_len(self, expect_len):
"""
精准接收指定长度数据包(解决TCP粘包半包)
:param expect_len: 需要接收的总字节长度
:return: 完整bytes数据包 / None 失败
"""
if not self.connected or not self.sock:
return None
recv_total = b""
while len(recv_total) < expect_len:
chunk = self.recv_data(expect_len - len(recv_total))
if chunk is None:
return None
if chunk == b"":
return b""
recv_total += chunk
return recv_total
def recv_until_delimiter(self, delimiter: bytes, max_buf=4096):
"""
按结束分隔符接收一帧数据(适合带头尾标识的PLC协议)
:param delimiter: 结束字节标识,例 b'\x03\r\n'
:param max_buf: 最大缓冲区防止死循环
:return: 截取到分隔符的完整报文 / None 异常
"""
if not self.connected or not self.sock:
return None
recv_total = b""
while len(recv_total) < max_buf:
chunk = self.recv_data(256)
if chunk is None:
return None
if chunk == b"":
return b""
recv_total += chunk
if delimiter in recv_total:
data, _ = recv_total.split(delimiter, 1)
return data + delimiter
print("接收缓冲区溢出,未找到分隔符")
return None
def close(self):
"""关闭TCP连接,释放套接字"""
if self.sock:
try:
self.sock.shutdown(socket.SHUT_RDWR)
self.sock.close()
except Exception:
pass
self.connected = False
if __name__ == "__main__":
# 初始发送报文字典
send_msg = {
"header": 0x02, "rows": 0x08, "cols": 0x08, "module_cnt": 0x06,
"speed1": 0x64, "speed2": 0x64, "speed3": 0x64,
"speed4": 0x64, "speed5": 0x64, "speed6": 0x64,
"seq_num": 0x00, "admit1": 0x01, "admit2": 0x01,
"end_char": 0x03, "tail1": 0x0D, "tail2": 0x0A
}
plc = Controller(ip="192.168.0.1", port=2000, data=send_msg)
if plc.connect():
while True:
# 发送报文
plc.send()
# 方式1:原始不定长接收
res = plc.recv_data()
if res:
print("收到原始数据:", res.hex())
# 方式2:固定长度接收,例如收8字节应答
# res_fix = plc.recv_fixed_len(8)
# 方式3:按结束符接收(匹配报文尾 0x03 0D 0A)
# res_del = plc.recv_until_delimiter(delimiter=b'\x03\r\n')
time.sleep(0.05)
plc.close()
四、核心功能详解
1. 报文打包 ------ struct.pack
使用 Python 内置的 struct 模块将字典数据打包为二进制字节流:
python
struct.pack('!BBBBBBBBBBBBBBBB', ...)
-
!表示网络字节序(大端) -
B表示无符号字符(1字节) -
16个
B对应 16 字节固定长度
💡 提示 :如果协议包含
short、int等类型,请改用H、I等格式符。
2. 发送与序列号自增
每次发送成功后,seq_num 自动加 1,并在达到 255 后归零:
python
self.data["seq_num"] = (self.data["seq_num"] + 1) % 256
这在多帧连续通信中用于报文标识和防重放。
3. 三种接收模式
| 方法 | 适用场景 |
|---|---|
recv_data(buf_len) |
基础接收,适合调试或不定长数据 |
recv_fixed_len(expect_len) |
固定长度报文,自动处理粘包 |
recv_until_delimiter(delimiter) |
分隔符报文 ,如以 \r\n 或 0x03 结尾 |
定长接收原理
python
while len(recv_total) < expect_len:
chunk = self.recv_data(expect_len - len(recv_total))
recv_total += chunk
即使 TCP 分包到达,也能拼凑出完整报文。
五、使用示例
西门子1500这边发送16bytes的报文

示例1:接收完整报文
python
if __name__ == "__main__":
# 初始发送报文字典
send_msg = {
"header": 0x02, "rows": 0x08, "cols": 0x08, "module_cnt": 0x06,
"speed1": 0x64, "speed2": 0x64, "speed3": 0x64,
"speed4": 0x64, "speed5": 0x64, "speed6": 0x64,
"seq_num": 0x00, "admit1": 0x01, "admit2": 0x01,
"end_char": 0x03, "tail1": 0x0D, "tail2": 0x0A
}
plc = Controller(ip="192.168.0.1", port=2000, data=send_msg)
if plc.connect():
while True:
# 发送报文
plc.send()
# 方式1:原始不定长接收
res = plc.recv_data()
if res:
print("收到原始数据:", res.hex())
# 方式2:固定长度接收,例如收8字节应答
# res_fix = plc.recv_fixed_len(8)
# 方式3:按结束符接收(匹配报文尾 0x03 0D 0A)
# res_del = plc.recv_until_delimiter(delimiter=b'\x03\r\n')
time.sleep(0.05)
plc.close()
程序这边每50ms打印一次接收到的报文

示例2:接收固定长度报文
python
if __name__ == "__main__":
# 初始发送报文字典
send_msg = {
"header": 0x02, "rows": 0x08, "cols": 0x08, "module_cnt": 0x06,
"speed1": 0x64, "speed2": 0x64, "speed3": 0x64,
"speed4": 0x64, "speed5": 0x64, "speed6": 0x64,
"seq_num": 0x00, "admit1": 0x01, "admit2": 0x01,
"end_char": 0x03, "tail1": 0x0D, "tail2": 0x0A
}
plc = Controller(ip="192.168.0.1", port=2000, data=send_msg)
if plc.connect():
while True:
# 发送报文
plc.send()
# 方式1:原始不定长接收
res = plc.recv_data()
if res:
print("收到原始数据:", res.hex())
# 方式2:固定长度接收,例如收8字节应答
"""
res_fix = plc.recv_fixed_len(8)
if res_fix:
print("收到原始数据:", res_fix.hex())
"""
# 方式3:按结束符接收(匹配报文尾 0x03 0D 0A)
"""
res_del = plc.recv_until_delimiter(delimiter=b'\x03\r\n')
if res_del:
print("收到原始数据:", res_del.hex())
"""
time.sleep(0.05)
plc.close()
程序这边每50ms打印一次接收到的报文,把16个字节的拆分成两个8字节的报文

示例3:接收固定格式报文
python
if __name__ == "__main__":
# 初始发送报文字典
send_msg = {
"header": 0x02, "rows": 0x08, "cols": 0x08, "module_cnt": 0x06,
"speed1": 0x64, "speed2": 0x64, "speed3": 0x64,
"speed4": 0x64, "speed5": 0x64, "speed6": 0x64,
"seq_num": 0x00, "admit1": 0x01, "admit2": 0x01,
"end_char": 0x03, "tail1": 0x0D, "tail2": 0x0A
}
plc = Controller(ip="192.168.0.1", port=2000, data=send_msg)
if plc.connect():
while True:
# 发送报文
plc.send()
# 方式1:原始不定长接收
"""
res = plc.recv_data()
if res:
print("收到原始数据:", res.hex())
"""
# 方式2:固定长度接收,例如收8字节应答
"""
res_fix = plc.recv_fixed_len(8)
if res_fix:
print("收到原始数据:", res_fix.hex())
"""
# 方式3:按结束符接收(匹配报文尾 0x03 0D 0A)
res_del = plc.recv_until_delimiter(delimiter=b'\x03\r\n')
if res_del:
print("收到原始数据:", res_del.hex())
time.sleep(0.05)
plc.close()
程序这边每50ms打印一次接收到的报文,格式正确就会收到,格式错误就会过滤掉

示例:发送报文
程序这边发送的报文
python
send_msg = {
"header": 0x02, "rows": 0x08, "cols": 0x08, "module_cnt": 0x06,
"speed1": 0x64, "speed2": 0x64, "speed3": 0x64,
"speed4": 0x64, "speed5": 0x64, "speed6": 0x64,
"seq_num": 0x00, "admit1": 0x01, "admit2": 0x01,
"end_char": 0x03, "tail1": 0x0D, "tail2": 0x0A
}
西门子1500这边接收16bytes的报文

六、常见问题与解决方案
问题1:TCP 粘包
现象:多次发送的数据被一次性接收,或一次发送的数据被分多次接收。
解决:
-
使用
recv_fixed_len()按固定长度接收 -
使用
recv_until_delimiter()按分隔符截取
问题2:接收超时导致误判断开
本实现区分了 socket.timeout(无数据)和 空字节(对端关闭),避免将超时误判为断开。
问题3:连接意外断开
所有 except 中均设置 self.connected = False,上层可通过检查该状态决定是否重连。
七、博途组态扩展
如图所示,PLC每1hz发送一次报文,接收报文是持续接收信号

涉及到的数据块,在属性中选择不勾选优化的块访问

tcp组态,选择伙伴来主动连接

八、总结
本文实现了一个通用的 TCP 控制器类,涵盖:
-
✅ 连接管理
-
✅ struct 二进制打包
-
✅ 序列号自动维护
-
✅ 三种接收模式(基础/定长/分隔符)
-
✅ 粘包处理
-
✅ 超时与断连判断
该代码已用于多个工业上位机项目,稳定可靠。你可以根据实际 PLC 协议调整 data 字典和 struct 格式,快速适配不同设备。
如果这篇文章对你有帮助,欢迎点赞、收藏、转发! 😊
有任何问题或改进建议,欢迎在评论区留言交流。