Python-Socket TCP 通信实战:PLC 控制器报文收发封装

一、背景介绍

在工业自动化领域,上位机与 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 字节固定长度

💡 提示 :如果协议包含 shortint 等类型,请改用 HI 等格式符。

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\n0x03 结尾
定长接收原理
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 格式,快速适配不同设备。


如果这篇文章对你有帮助,欢迎点赞、收藏、转发! 😊

有任何问题或改进建议,欢迎在评论区留言交流。