首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >工业物联网采集开发:Modbus TCP传感器多并发连接的服务端实现

工业物联网采集开发:Modbus TCP传感器多并发连接的服务端实现

原创
作者头像
HONSOR盛世宏博
发布于 2026-10-26 17:23:17
发布于 2026-10-26 17:23:17
1370
举报

工业物联网采集开发:Modbus TCP传感器多并发连接的服务端实现

物联网 · 盛世宏博 · 工业物联网 · Modbus TCP · 多并发服务端 · 传感器采集

关键词:Modbus TCP服务端、socket并发、select/epoll、多线程/多进程、连接池管理、MBAP报文解析、功能码分发、事务标识符、Unit ID路由、TCP粘包拆包、心跳保活、连接数限制、优雅关闭、异常帧处理、并发安全、文件描述符限制、 backlog队列、SO_REUSEADDR、TCP_NODELAY、KEEPALIVE、读写分离、寄存器映射表、线圈/离散量/输入寄存器/保持寄存器、协议状态机 标签:#物联网 #Modbus #RS485 #TCP/IP #POE供电 #腾讯云 #Python #以太网温湿度传感器 #网口温湿度变送器 #机房监控

一、为什么需要自己写 Modbus TCP 服务端

在大多数动环项目中,采集端扮演的是 Modbus TCP 客户端(Master)​ 角色:主动连接传感器,发请求,收响应。但在以下场景中,你必须自己实现一个 Modbus TCP 服务端(Slave/Server):

  1. 传感器固件不支持主动上报,但你的边缘网关需要同时被多个上层平台轮询,网关要做协议转换和聚合
  2. 将 RS485 总线上的多台 Modbus RTU 设备,通过边缘网关虚拟成多个 Modbus TCP 服务端,让平台无感访问
  3. 模拟测试:开发阶段没有实体传感器,需要 Modbus TCP 模拟服务端来调试客户端代码
  4. 协议桥接:把非标协议设备的数据,通过自写服务端统一暴露为 Modbus TCP 接口
  5. 数据中转:采集网关作为服务端,接收多个平台客户端的并发读取,内部做缓存和聚合后返回

核心挑战不是"能监听 502 端口",而是:同时处理几十个 TCP 连接、正确解析 MBAP 报文、维护寄存器状态、保证并发读写安全、不泄漏文件描述符、不崩溃。


二、Modbus TCP 协议帧结构回顾

在写服务端之前,必须吃透帧格式:

代码语言:javascript
复制
MBAP 头(7 字节):
┌───────────────┬───────────────┬───────────────┬───────────────┐
│ 事务标识符    │ 协议标识符    │ 长度          │ 单元标识符    │
│ (2 bytes)     │ (2 bytes)     │ (2 bytes)     │ (1 byte)      │
│ Transaction   │ Protocol      │ Length        │ Unit ID       │
│ ID            │ ID (=0)       │               │               │
├───────────────┴───────────────┴───────────────┴───────────────┤
│ PDU(协议数据单元):功能码(1 byte) + 数据(N bytes)           │
└─────────────────────────────────────────────────────────────────┘

关键字段:

  • 事务标识符(Transaction ID):客户端生成,服务端原样返回。用于匹配请求和响应
  • 协议标识符:恒为 0(Modbus 协议)
  • 长度:后续字节数(Unit ID + PDU 的总长度)
  • 单元标识符(Unit ID):模拟 RTU 从站地址,路由到不同虚拟设备

常见功能码

功能码

名称

方向

说明

0x01

读线圈

客户端→服务端

读 DO 状态

0x02

读离散输入

客户端→服务端

读 DI 状态

0x03

读保持寄存器

客户端→服务端

读 HR(最常用)

0x04

读输入寄存器

客户端→服务端

读 IR(只读测量值)

0x05

写单个线圈

客户端→服务端

写 DO

0x06

写单个寄存器

客户端→服务端

写 HR

0x0F

写多个线圈

客户端→服务端

批量写 DO

0x10

写多个寄存器

客户端→服务端

批量写 HR

0x83~0x90

异常响应

服务端→客户端

功能码


三、服务端架构设计

3.1 整体架构

代码语言:javascript
复制
┌──────────────────────────────────────────────────────────┐
│                  主线程 / 事件循环                        │
│  ┌─────────┐  ┌──────────┐  ┌──────────────────────┐   │
│  │ Listener │  │ Acceptor │  │ 连接管理器           │   │
│  │ Socket   │─▶│ (accept) │─▶│ (fd表/超时/限流)     │   │
│  └─────────┘  └──────────┘  └──────────┬───────────┘   │
│                                         │               │
│  ┌──────────────────────────────────────▼───────────┐   │
│  │            I/O 多路复用层 (epoll/select)          │   │
│  │  监听所有已连接 socket 的读/写/异常事件            │   │
│  └──────────────────────────┬────────────────────────┘   │
│                             │                           │
│  ┌──────────────────────────▼────────────────────────┐   │
│  │           协议解析与分发层                         │   │
│  │  MBAP解析 → 功能码分发 → 寄存器操作 → 构造响应    │   │
│  └──────────────────────────┬────────────────────────┘   │
│                             │                           │
│  ┌──────────────────────────▼────────────────────────┐   │
│  │           寄存器映射层(共享内存/数据结构)          │   │
│  │  线圈表 / 离散输入表 / 输入寄存器 / 保持寄存器     │   │
│  │  读写锁保护,支持多线程并发访问                     │   │
│  └───────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────┘

3.2 核心数据结构

代码语言:javascript
复制
# modbus_server_core.py
import struct
import socket
import select
import threading
from collections import defaultdict
from enum import IntEnum

class FunctionCode(IntEnum):
    READ_COILS = 0x01
    READ_DISCRETE_INPUTS = 0x02
    READ_HOLDING_REGISTERS = 0x03
    READ_INPUT_REGISTERS = 0x04
    WRITE_SINGLE_COIL = 0x05
    WRITE_SINGLE_REGISTER = 0x06
    WRITE_MULTIPLE_COILS = 0x0F
    WRITE_MULTIPLE_REGISTERS = 0x10

class ExceptionCode(IntEnum):
    ILLEGAL_FUNCTION = 0x01
    ILLEGAL_DATA_ADDRESS = 0x02
    ILLEGAL_DATA_VALUE = 0x03
    SLAVE_DEVICE_FAILURE = 0x04
    ACKNOWLEDGE = 0x05
    SLAVE_DEVICE_BUSY = 0x06
    MEMORY_PARITY_ERROR = 0x08
    GATEWAY_PATH_UNAVAILABLE = 0x0A
    GATEWAY_TARGET_NO_RESPONSE = 0x0B

class RegisterMap:
    """寄存器映射表(线程安全)"""
    def __init__(self, unit_id=1):
        self.unit_id = unit_id
        self._coils = bytearray(1024)          # 1024 线圈 (DO)
        self._discrete_inputs = bytearray(1024) # 1024 离散输入 (DI)
        self._input_registers = [0] * 1024      # 1024 输入寄存器 (只读)
        self._holding_registers = [0] * 1024    # 1024 保持寄存器 (可读写)
        self._lock = threading.RLock()

    def read_coils(self, start_addr, count):
        with self._lock:
            if start_addr + count > len(self._coils):
                raise ValueError("Address out of range")
            result = []
            for i in range(start_addr, start_addr + count):
                byte_idx = i // 8
                bit_idx = i % 8
                result.append(bool((self._coils[byte_idx] >> bit_idx) & 1))
            return result

    def write_coil(self, addr, value):
        with self._lock:
            if addr >= len(self._coils) * 8:
                raise ValueError("Address out of range")
            byte_idx = addr // 8
            bit_idx = addr % 8
            if value:
                self._coils[byte_idx] |= (1 << bit_idx)
            else:
                self._coils[byte_idx] &= ~(1 << bit_idx)

    def read_holding_registers(self, start_addr, count):
        with self._lock:
            if start_addr + count > len(self._holding_registers):
                raise ValueError("Address out of range")
            return self._holding_registers[start_addr:start_addr + count]

    def write_holding_register(self, addr, value):
        with self._lock:
            if addr >= len(self._holding_registers):
                raise ValueError("Address out of range")
            self._holding_registers[addr] = value & 0xFFFF

    def write_holding_registers(self, start_addr, values):
        with self._lock:
            if start_addr + len(values) > len(self._holding_registers):
                raise ValueError("Address out of range")
            for i, v in enumerate(values):
                self._holding_registers[start_addr + i] = v & 0xFFFF

    def read_input_registers(self, start_addr, count):
        with self._lock:
            if start_addr + count > len(self._input_registers):
                raise ValueError("Address out of range")
            return self._input_registers[start_addr:start_addr + count]

    def update_input_register(self, addr, value):
        """更新输入寄存器(模拟传感器数据变化)"""
        with self._lock:
            if addr >= len(self._input_registers):
                raise ValueError("Address out of range")
            self._input_registers[addr] = value & 0xFFFF

四、基于 select 的多并发服务端实现

4.1 核心服务器类

代码语言:javascript
复制
# modbus_tcp_server_select.py
import select
import struct
import socket
import threading
import time
import logging
from modbus_server_core import FunctionCode, ExceptionCode, RegisterMap

logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)

class ModbusTCPServer:
    def __init__(self, host='0.0.0.0', port=502, max_connections=50):
        self.host = host
        self.port = port
        self.max_connections = max_connections
        self.register_maps = {}          # unit_id -> RegisterMap
        self._running = False
        self._server_sock = None
        self._connections = {}           # fd -> (socket, addr, last_active)
        self._recv_buffers = {}          # fd -> bytearray (处理粘包)
        self._lock = threading.Lock()

    def add_register_map(self, unit_id, reg_map):
        self.register_maps[unit_id] = reg_map

    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.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        self._server_sock.bind((self.host, self.port))
        self._server_sock.listen(self.max_connections)
        self._server_sock.setblocking(False)
        self._running = True

        logger.info(f"Modbus TCP Server listening on {self.host}:{self.port}")
        logger.info(f"Max connections: {self.max_connections}")

        self._event_loop()

    def stop(self):
        self._running = False
        if self._server_sock:
            self._server_sock.close()
        with self._lock:
            for fd, (sock, addr, _) in self._connections.items():
                try:
                    sock.close()
                except:
                    pass
            self._connections.clear()

    def _event_loop(self):
        """主事件循环,使用 select 多路复用"""
        while self._running:
            with self._lock:
                read_fds = [self._server_sock] + [sock for sock, _, _ in self._connections.values()]
                write_fds = []
                error_fds = read_fds[:]

            try:
                readable, writable, exceptional = select.select(read_fds, write_fds, error_fds, 1.0)
            except select.error as e:
                logger.error(f"select error: {e}")
                continue

            # 处理新连接
            if self._server_sock in readable:
                self._accept_new_connection()
                readable.remove(self._server_sock)

            # 处理已连接 socket 的数据
            for sock in readable:
                fd = sock.fileno()
                try:
                    self._handle_client_data(fd, sock)
                except Exception as e:
                    logger.error(f"Error handling client {fd}: {e}")
                    self._close_connection(fd)

            # 处理异常
            for sock in exceptional:
                fd = sock.fileno()
                logger.warning(f"Exception on fd {fd}")
                self._close_connection(fd)

            # 定期清理超时连接
            self._cleanup_stale_connections()

    def _accept_new_connection(self):
        try:
            client_sock, client_addr = self._server_sock.accept()
            fd = client_sock.fileno()

            if len(self._connections) >= self.max_connections:
                logger.warning(f"Connection limit reached, rejecting {client_addr}")
                client_sock.close()
                return

            client_sock.setblocking(False)
            # 设置 TCP Keepalive
            client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

            with self._lock:
                self._connections[fd] = (client_sock, client_addr, time.time())
                self._recv_buffers[fd] = bytearray()

            logger.info(f"New connection from {client_addr} (fd={fd})")

        except Exception as e:
            logger.error(f"Accept error: {e}")

    def _handle_client_data(self, fd, sock):
        """处理客户端数据,处理粘包和拆包"""
        try:
            data = sock.recv(4096)
            if not data:
                # 客户端关闭连接
                self._close_connection(fd)
                return

            with self._lock:
                self._recv_buffers[fd].extend(data)
                self._connections[fd] = (sock, self._connections[fd][1], time.time())

            # 尝试解析完整帧
            self._process_complete_frames(fd)

        except ConnectionResetError:
            self._close_connection(fd)
        except Exception as e:
            logger.error(f"Data handling error fd={fd}: {e}")
            self._close_connection(fd)

    def _process_complete_frames(self, fd):
        """从接收缓冲区中提取完整 Modbus TCP 帧"""
        while True:
            buf = self._recv_buffers[fd]
            if len(buf) < 7:
                break

            # 解析 MBAP 头
            trans_id, proto_id, length, unit_id = struct.unpack('>HHHB', buf[:7])

            if proto_id != 0:
                logger.warning(f"Invalid protocol ID {proto_id} from fd={fd}")
                self._close_connection(fd)
                return

            total_frame_len = 6 + length  # MBAP(6) + 后续字节(length)
            if len(buf) < total_frame_len:
                # 帧不完整,等待更多数据
                break

            # 提取完整帧
            frame = bytes(buf[:total_frame_len])
            del buf[:total_frame_len]

            # 处理帧
            response = self._handle_modbus_frame(frame)
            if response:
                try:
                    sock = self._connections[fd][0]
                    sock.send(response)
                except Exception as e:
                    logger.error(f"Send error fd={fd}: {e}")
                    self._close_connection(fd)
                    return

    def _handle_modbus_frame(self, frame):
        """解析 Modbus TCP 帧并生成响应"""
        if len(frame) < 8:
            return None

        trans_id, proto_id, length, unit_id = struct.unpack('>HHHB', frame[:7])
        pdu = frame[7:]

        if len(pdu) < 1:
            return None

        func_code = pdu[0]

        # 检查 Unit ID
        if unit_id not in self.register_maps:
            logger.warning(f"Unknown unit_id {unit_id}")
            return self._build_exception_response(trans_id, unit_id, func_code, ExceptionCode.SLAVE_DEVICE_FAILURE)

        reg_map = self.register_maps[unit_id]

        try:
            if func_code == FunctionCode.READ_HOLDING_REGISTERS:
                return self._handle_read_holding(trans_id, unit_id, pdu, reg_map)
            elif func_code == FunctionCode.READ_INPUT_REGISTERS:
                return self._handle_read_input(trans_id, unit_id, pdu, reg_map)
            elif func_code == FunctionCode.READ_COILS:
                return self._handle_read_coils(trans_id, unit_id, pdu, reg_map)
            elif func_code == FunctionCode.READ_DISCRETE_INPUTS:
                return self._handle_read_discrete(trans_id, unit_id, pdu, reg_map)
            elif func_code == FunctionCode.WRITE_SINGLE_REGISTER:
                return self._handle_write_single(trans_id, unit_id, pdu, reg_map)
            elif func_code == FunctionCode.WRITE_MULTIPLE_REGISTERS:
                return self._handle_write_multiple(trans_id, unit_id, pdu, reg_map)
            elif func_code == FunctionCode.WRITE_SINGLE_COIL:
                return self._handle_write_single_coil(trans_id, unit_id, pdu, reg_map)
            else:
                return self._build_exception_response(trans_id, unit_id, func_code, ExceptionCode.ILLEGAL_FUNCTION)

        except ValueError as e:
            if "Address out of range" in str(e):
                return self._build_exception_response(trans_id, unit_id, func_code, ExceptionCode.ILLEGAL_DATA_ADDRESS)
            elif "Illegal data" in str(e):
                return self._build_exception_response(trans_id, unit_id, func_code, ExceptionCode.ILLEGAL_DATA_VALUE)
            else:
                return self._build_exception_response(trans_id, unit_id, func_code, ExceptionCode.SLAVE_DEVICE_FAILURE)
        except Exception as e:
            logger.error(f"Frame handling error: {e}")
            return self._build_exception_response(trans_id, unit_id, func_code, ExceptionCode.SLAVE_DEVICE_FAILURE)

    def _handle_read_holding(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x03 读保持寄存器"""
        if len(pdu) < 5:
            raise ValueError("Illegal data value")
        start_addr = struct.unpack('>H', pdu[1:3])[0]
        count = struct.unpack('>H', pdu[3:5])[0]

        if count < 1 or count > 125:
            raise ValueError("Illegal data value")

        values = reg_map.read_holding_registers(start_addr, count)
        response_pdu = bytes([FunctionCode.READ_HOLDING_REGISTERS, len(values) * 2])
        for v in values:
            response_pdu += struct.pack('>H', v)

        return self._build_response(trans_id, unit_id, response_pdu)

    def _handle_read_input(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x04 读输入寄存器"""
        if len(pdu) < 5:
            raise ValueError("Illegal data value")
        start_addr = struct.unpack('>H', pdu[1:3])[0]
        count = struct.unpack('>H', pdu[3:5])[0]

        if count < 1 or count > 125:
            raise ValueError("Illegal data value")

        values = reg_map.read_input_registers(start_addr, count)
        response_pdu = bytes([FunctionCode.READ_INPUT_REGISTERS, len(values) * 2])
        for v in values:
            response_pdu += struct.pack('>H', v)

        return self._build_response(trans_id, unit_id, response_pdu)

    def _handle_read_coils(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x01 读线圈"""
        if len(pdu) < 5:
            raise ValueError("Illegal data value")
        start_addr = struct.unpack('>H', pdu[1:3])[0]
        count = struct.unpack('>H', pdu[3:5])[0]

        if count < 1 or count > 2000:
            raise ValueError("Illegal data value")

        coils = reg_map.read_coils(start_addr, count)
        # 打包为字节
        byte_count = (count + 7) // 8
        coil_bytes = bytearray(byte_count)
        for i, val in enumerate(coils):
            if val:
                coil_bytes[i // 8] |= (1 << (i % 8))

        response_pdu = bytes([FunctionCode.READ_COILS, byte_count]) + bytes(coil_bytes)
        return self._build_response(trans_id, unit_id, response_pdu)

    def _handle_read_discrete(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x02 读离散输入"""
        if len(pdu) < 5:
            raise ValueError("Illegal data value")
        start_addr = struct.unpack('>H', pdu[1:3])[0]
        count = struct.unpack('>H', pdu[3:5])[0]

        if count < 1 or count > 2000:
            raise ValueError("Illegal data value")

        # 离散输入和线圈共用存储结构
        inputs = reg_map.read_coils(start_addr, count)  # 复用方法
        byte_count = (count + 7) // 8
        input_bytes = bytearray(byte_count)
        for i, val in enumerate(inputs):
            if val:
                input_bytes[i // 8] |= (1 << (i % 8))

        response_pdu = bytes([FunctionCode.READ_DISCRETE_INPUTS, byte_count]) + bytes(input_bytes)
        return self._build_response(trans_id, unit_id, response_pdu)

    def _handle_write_single(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x06 写单个寄存器"""
        if len(pdu) < 5:
            raise ValueError("Illegal data value")
        addr = struct.unpack('>H', pdu[1:3])[0]
        value = struct.unpack('>H', pdu[3:5])[0]

        reg_map.write_holding_register(addr, value)

        # 响应:回显请求
        response_pdu = bytes([FunctionCode.WRITE_SINGLE_REGISTER]) + pdu[1:5]
        return self._build_response(trans_id, unit_id, response_pdu)

    def _handle_write_multiple(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x10 写多个寄存器"""
        if len(pdu) < 7:
            raise ValueError("Illegal data value")
        start_addr = struct.unpack('>H', pdu[1:3])[0]
        count = struct.unpack('>H', pdu[3:5])[0]
        byte_count = pdu[5]

        if count < 1 or count > 123 or byte_count != count * 2:
            raise ValueError("Illegal data value")

        values = []
        for i in range(count):
            offset = 6 + i * 2
            val = struct.unpack('>H', pdu[offset:offset+2])[0]
            values.append(val)

        reg_map.write_holding_registers(start_addr, values)

        # 响应:起始地址 + 写入数量
        response_pdu = bytes([FunctionCode.WRITE_MULTIPLE_REGISTERS]) + struct.pack('>HH', start_addr, count)
        return self._build_response(trans_id, unit_id, response_pdu)

    def _handle_write_single_coil(self, trans_id, unit_id, pdu, reg_map):
        """处理 0x05 写单个线圈"""
        if len(pdu) < 5:
            raise ValueError("Illegal data value")
        addr = struct.unpack('>H', pdu[1:3])[0]
        coil_value = struct.unpack('>H', pdu[3:5])[0]

        if coil_value not in (0x0000, 0xFF00):
            raise ValueError("Illegal data value")

        reg_map.write_coil(addr, bool(coil_value == 0xFF00))

        response_pdu = bytes([FunctionCode.WRITE_SINGLE_COIL]) + pdu[1:5]
        return self._build_response(trans_id, unit_id, response_pdu)

    def _build_response(self, trans_id, unit_id, response_pdu):
        """构造 Modbus TCP 响应帧"""
        length = len(response_pdu) + 1  # +1 for unit_id
        header = struct.pack('>HHHB', trans_id, 0, length, unit_id)
        return header + response_pdu

    def _build_exception_response(self, trans_id, unit_id, func_code, exception_code):
        """构造异常响应帧"""
        response_pdu = bytes([func_code | 0x80, exception_code])
        return self._build_response(trans_id, unit_id, response_pdu)

    def _close_connection(self, fd):
        """关闭指定连接"""
        with self._lock:
            if fd in self._connections:
                sock, addr, _ = self._connections[fd]
                try:
                    sock.close()
                except:
                    pass
                del self._connections[fd]
                if fd in self._recv_buffers:
                    del self._recv_buffers[fd]
                logger.info(f"Connection closed: {addr} (fd={fd})")

    def _cleanup_stale_connections(self, timeout=300):
        """清理超时连接"""
        now = time.time()
        stale_fds = []
        with self._lock:
            for fd, (sock, addr, last_active) in self._connections.items():
                if now - last_active > timeout:
                    stale_fds.append(fd)

        for fd in stale_fds:
            logger.info(f"Cleaning up stale connection fd={fd}")
            self._close_connection(fd)

4.2 服务端启动与寄存器初始化

代码语言:javascript
复制
# server_main.py
from modbus_tcp_server_select import ModbusTCPServer
from modbus_server_core import RegisterMap
import threading
import time
import random

def simulate_sensor_data(reg_map, stop_event):
    """模拟传感器数据更新(输入寄存器)"""
    while not stop_event.is_set():
        # 模拟温度 20~30℃,湿度 40~60%RH
        temp = int((20 + random.uniform(0, 10)) * 10)      # 0x0000: 温度 ×10
        hum = int((40 + random.uniform(0, 20)) * 10)       # 0x0001: 湿度 ×10
        dew = int((15 + random.uniform(0, 8)) * 10)        # 0x0002: 露点 ×10
        status = 0x0001                                    # 0x0003: 状态(正常)

        reg_map.update_input_register(0x0000, temp)
        reg_map.update_input_register(0x0001, hum)
        reg_map.update_input_register(0x0002, dew)
        reg_map.update_input_register(0x0003, status)

        # 模拟线圈状态(设备运行/报警)
        reg_map.write_coil(0, True)     # 设备运行
        reg_map.write_coil(1, False)    # 无报警

        time.sleep(1)

def main():
    # 创建寄存器映射
    reg_map_1 = RegisterMap(unit_id=1)
    reg_map_2 = RegisterMap(unit_id=2)

    # 初始化保持寄存器(配置参数)
    reg_map_1.write_holding_register(0x0000, 250)   # 温度上限 25.0℃
    reg_map_1.write_holding_register(0x0001, 180)   # 温度下限 18.0℃
    reg_map_1.write_holding_register(0x0002, 600)   # 湿度上限 60.0%
    reg_map_1.write_holding_register(0x0003, 450)   # 湿度下限 45.0%

    reg_map_2.write_holding_register(0x0000, 240)
    reg_map_2.write_holding_register(0x0001, 180)
    reg_map_2.write_holding_register(0x0002, 550)
    reg_map_2.write_holding_register(0x0003, 450)

    # 创建服务器
    server = ModbusTCPServer(host='0.0.0.0', port=502, max_connections=50)
    server.add_register_map(1, reg_map_1)
    server.add_register_map(2, reg_map_2)

    # 启动数据模拟线程
    stop_event = threading.Event()
    sim_thread = threading.Thread(target=simulate_sensor_data, args=(reg_map_1, stop_event), daemon=True)
    sim_thread.start()

    # 启动服务器
    try:
        server.start()
    except KeyboardInterrupt:
        print("\nShutting down...")
        stop_event.set()
        server.stop()

if __name__ == '__main__':
    main()

五、基于 epoll 的高性能实现(Linux 专用)

5.1 epoll vs select 对比

特性

select

epoll

最大连接数

1024(FD_SETSIZE)

无硬限制(受 ulimit 约束)

时间复杂度

O(n) 遍历所有 fd

O(1) 事件回调

触发模式

水平触发(LT)

水平触发 + 边缘触发(ET)

内核拷贝

每次调用拷贝 fd 集合

mmap 共享,零拷贝

适用场景

跨平台、连接少

Linux、高并发

5.2 epoll 服务端核心

代码语言:javascript
复制
# modbus_tcp_server_epoll.py
import select
import socket
import threading
import time
import struct
from modbus_tcp_server_select import ModbusTCPServer

class EpollModbusTCPServer(ModbusTCPServer):
    """基于 epoll 的高性能 Modbus TCP 服务端"""

    def _event_loop(self):
        """epoll 事件循环"""
        epoll_fd = select.epoll()
        epoll_fd.register(self._server_sock.fileno(), select.EPOLLIN | select.EPOLLET)

        # fd → socket 映射
        fd_to_sock = {self._server_sock.fileno(): self._server_sock}
        fd_to_addr = {}

        try:
            while self._running:
                events = epoll_fd.poll(timeout=1.0)

                for fd, event in events:
                    if fd == self._server_sock.fileno():
                        # 新连接
                        self._accept_new_connection_epoll(epoll_fd, fd_to_sock, fd_to_addr)
                    elif event & select.EPOLLIN:
                        # 可读
                        try:
                            self._handle_client_data(fd, fd_to_sock[fd])
                        except Exception as e:
                            logger.error(f"epoll read error fd={fd}: {e}")
                            self._close_connection_epoll(fd, epoll_fd, fd_to_sock, fd_to_addr)
                    elif event & (select.EPOLLHUP | select.EPOLLERR):
                        # 连接错误/挂断
                        logger.warning(f"epoll error/hup fd={fd}")
                        self._close_connection_epoll(fd, epoll_fd, fd_to_sock, fd_to_addr)

                # 清理超时连接
                self._cleanup_stale_connections()

        finally:
            epoll_fd.close()

    def _accept_new_connection_epoll(self, epoll_fd, fd_to_sock, fd_to_addr):
        """epoll 模式接受新连接"""
        while True:
            try:
                client_sock, client_addr = self._server_sock.accept()
            except socket.error:
                break  # 没有更多连接

            fd = client_sock.fileno()
            if len(self._connections) >= self.max_connections:
                client_sock.close()
                continue

            client_sock.setblocking(False)
            client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

            epoll_fd.register(fd, select.EPOLLIN | select.EPOLLET)
            fd_to_sock[fd] = client_sock
            fd_to_addr[fd] = client_addr

            with self._lock:
                self._connections[fd] = (client_sock, client_addr, time.time())
                self._recv_buffers[fd] = bytearray()

            logger.info(f"epoll: new connection {client_addr} fd={fd}")

    def _close_connection_epoll(self, fd, epoll_fd, fd_to_sock, fd_to_addr):
        """epoll 模式关闭连接"""
        epoll_fd.unregister(fd)
        if fd in fd_to_sock:
            fd_to_sock[fd].close()
            del fd_to_sock[fd]
        if fd in fd_to_addr:
            del fd_to_addr[fd]
        self._close_connection(fd)

六、多线程版本(每个连接一个线程)

6.1 线程池服务端

代码语言:javascript
复制
# modbus_tcp_server_threaded.py
import socket
import threading
import queue
import time
from modbus_server_core import RegisterMap

class ThreadedModbusTCPServer:
    """每个连接一个线程的 Modbus TCP 服务端"""

    def __init__(self, host='0.0.0.0', port=502, max_threads=100):
        self.host = host
        self.port = port
        self.max_threads = max_threads
        self.register_maps = {}
        self._running = False
        self._server_sock = None
        self._threads = {}
        self._thread_lock = threading.Lock()

    def add_register_map(self, unit_id, reg_map):
        self.register_maps[unit_id] = reg_map

    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.host, self.port))
        self._server_sock.listen(128)
        self._running = True

        logger.info(f"Threaded Modbus TCP Server on {self.host}:{self.port}")

        while self._running:
            try:
                client_sock, client_addr = self._server_sock.accept()
                with self._thread_lock:
                    if len(self._threads) >= self.max_threads:
                        logger.warning(f"Thread limit reached, rejecting {client_addr}")
                        client_sock.close()
                        continue

                t = threading.Thread(
                    target=self._handle_connection,
                    args=(client_sock, client_addr),
                    daemon=True
                )
                t.start()
                self._threads[t.ident] = t

            except Exception as e:
                logger.error(f"Accept error: {e}")

    def _handle_connection(self, sock, addr):
        """处理单个客户端连接"""
        logger.info(f"Thread {threading.current_thread().ident}: handling {addr}")
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

        recv_buffer = bytearray()
        last_active = time.time()

        try:
            while self._running:
                sock.settimeout(30.0)
                try:
                    data = sock.recv(4096)
                except socket.timeout:
                    # 检查是否超时
                    if time.time() - last_active > 300:
                        logger.info(f"Connection {addr} timed out")
                        break
                    continue

                if not data:
                    break

                last_active = time.time()
                recv_buffer.extend(data)

                # 处理完整帧
                while len(recv_buffer) >= 7:
                    trans_id, proto_id, length, unit_id = struct.unpack('>HHHB', recv_buffer[:7])
                    total_len = 6 + length
                    if len(recv_buffer) < total_len:
                        break

                    frame = bytes(recv_buffer[:total_len])
                    del recv_buffer[:total_len]

                    # 解析并响应
                    response = self._handle_frame(frame)
                    if response:
                        sock.send(response)

        except Exception as e:
            logger.error(f"Connection handler error {addr}: {e}")
        finally:
            sock.close()
            logger.info(f"Connection closed: {addr}")
            with self._thread_lock:
                self._threads.pop(threading.current_thread().ident, None)

    def _handle_frame(self, frame):
        """解析 Modbus TCP 帧(简化版,完整版同上)"""
        # 复用 select 版本的逻辑
        pass

    def stop(self):
        self._running = False
        if self._server_sock:
            self._server_sock.close()

七、并发安全深度分析

7.1 竞争条件场景

代码语言:javascript
复制
场景 1:两个客户端同时写同一寄存器
  客户端 A: 写 40001 = 250
  客户端 B: 写 40001 = 300
  结果:取决于线程调度,最终值不确定

场景 2:客户端读的同时模拟线程写输入寄存器
  读线程: 读 30001~30004
  写线程: 更新 30002
  结果:可能读到一半新值一半旧值

场景 3:连接管理并发修改
  主线程: 清理超时连接,删除 fd
  epoll: 返回该 fd 的事件
  结果:Use-after-free

7.2 解决方案

问题

方案

寄存器并发读写

threading.RLock 保护所有寄存器操作

连接表并发修改

threading.Lock 保护 connections 字典

epoll fd 生命周期

先 unregister 再 close,避免 Use-after-free

接收缓冲区

每连接独立 buffer,不共享

写后回读一致性

同一连接的写操作串行化(单线程处理)

7.3 读写锁优化(高读低写场景)

代码语言:javascript
复制
# rwlock.py
import threading

class ReadWriteLock:
    """读写锁,读多写少场景性能优于互斥锁"""
    def __init__(self):
        self._read_ready = threading.Condition(threading.Lock())
        self._readers = 0

    def acquire_read(self):
        self._read_ready.acquire()
        try:
            self._readers += 1
        finally:
            self._read_ready.release()

    def release_read(self):
        self._read_ready.acquire()
        try:
            self._readers -= 1
            if self._readers == 0:
                self._read_ready.notify_all()
        finally:
            self._read_ready.release()

    def acquire_write(self):
        self._read_ready.acquire()
        while self._readers > 0:
            self._read_ready.wait()

    def release_write(self):
        self._read_ready.release()

# 使用上下文管理器
class ReadLock:
    def __init__(self, rwlock):
        self.rwlock = rwlock
    def __enter__(self):
        self.rwlock.acquire_read()
        return self
    def __exit__(self, *args):
        self.rwlock.release_read()

class WriteLock:
    def __init__(self, rwlock):
        self.rwlock = rwlock
    def __enter__(self):
        self.rwlock.acquire_write()
        return self
    def __exit__(self, *args):
        self.rwlock.release_write()

八、系统调优

8.1 文件描述符限制

代码语言:javascript
复制
# 查看当前限制
ulimit -n

# 临时调整
ulimit -n 65535

# 永久调整(/etc/security/limits.conf)
* soft nofile 65535
* hard nofile 65535

# systemd 服务文件
[Service]
LimitNOFILE=65535

8.2 内核网络参数

代码语言:javascript
复制
# /etc/sysctl.conf
net.core.somaxconn = 4096        # listen backlog
net.core.netdev_max_backlog = 5000
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 60
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 30
net.ipv4.ip_local_port_range = 1024 65535

sysctl -p

8.3 Python 层面优化

代码语言:javascript
复制
# 1. 使用 bytes 而非 bytearray 做频繁拼接
# 2. struct 预编译格式字符串
import struct
_pack_hh = struct.Struct('>HH')
_pack_hhhb = struct.Struct('>HHHB')

# 3. 使用 __slots__ 减少对象内存
class ConnectionInfo:
    __slots__ = ['sock', 'addr', 'last_active', 'buffer']
    def __init__(self, sock, addr):
        self.sock = sock
        self.addr = addr
        self.last_active = time.time()
        self.buffer = bytearray()

# 4. 使用 memoryview 避免拷贝
buf = bytearray(4096)
view = memoryview(buf)

九、测试与验证

9.1 功能测试

代码语言:javascript
复制
# test_server.py
import socket
import struct
import threading
import time

def test_client(server_host, server_port, unit_id=1):
    """测试客户端"""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((server_host, server_port))
    sock.settimeout(5.0)

    trans_id = 0

    # 测试 1: 读输入寄存器
    trans_id += 1
    mbap = struct.pack('>HHHB', trans_id, 0, 6, unit_id)
    pdu = struct.pack('>BHH', 0x04, 0, 4)    # 读 4 个输入寄存器
    sock.send(mbap + pdu)

    response = sock.recv(1024)
    print(f"Test 1 - Read Input Registers: {len(response)} bytes")
    if len(response) >= 9:
        _, _, length, resp_unit = struct.unpack('>HHHB', response[:7])
        func_code = response[7]
        print(f"  Function: 0x{func_code:02X}, Length: {length}")

    # 测试 2: 写单个寄存器
    trans_id += 1
    mbap = struct.pack('>HHHB', trans_id, 0, 6, unit_id)
    pdu = struct.pack('>BHH', 0x06, 0x0000, 999)   # 写 40001 = 999
    sock.send(mbap + pdu)

    response = sock.recv(1024)
    print(f"Test 2 - Write Single Register: {len(response)} bytes")

    # 测试 3: 读保持寄存器验证写入
    trans_id += 1
    mbap = struct.pack('>HHHB', trans_id, 0, 6, unit_id)
    pdu = struct.pack('>BHH', 0x03, 0x0000, 1)    # 读 40001
    sock.send(mbap + pdu)

    response = sock.recv(1024)
    print(f"Test 3 - Read Back: {len(response)} bytes")
    if len(response) >= 11:
        value = struct.unpack('>H', response[9:11])[0]
        print(f"  Value: {value} (expected 999)")

    # 测试 4: 非法功能码
    trans_id += 1
    mbap = struct.pack('>HHHB', trans_id, 0, 6, unit_id)
    pdu = struct.pack('>BHH', 0xFF, 0, 0)    # 非法功能码
    sock.send(mbap + pdu)

    response = sock.recv(1024)
    print(f"Test 4 - Illegal Function: {len(response)} bytes")
    if len(response) >= 8:
        resp_func = response[7]
        if resp_func & 0x80:
            exc_code = response[8]
            print(f"  Exception: 0x{exc_code:02X}")

    sock.close()
    print("All tests completed")

if __name__ == '__main__':
    test_client('127.0.0.1', 502, unit_id=1)

9.2 压力测试

代码语言:javascript
复制
# stress_test.py
import socket
import struct
import threading
import time
import random

class StressClient:
    def __init__(self, server_host, server_port, client_id):
        self.host = server_host
        self.port = server_port
        self.client_id = client_id
        self.requests = 0
        self.errors = 0

    def run(self, duration=60):
        """运行压力测试"""
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect((self.host, self.port))
        sock.settimeout(2.0)

        start = time.time()
        trans_id = 0

        while time.time() - start < duration:
            try:
                trans_id += 1
                mbap = struct.pack('>HHHB', trans_id, 0, 6, 1)
                # 随机读 1~4 个输入寄存器
                count = random.randint(1, 4)
                start_addr = random.randint(0, 10)
                pdu = struct.pack('>BHH', 0x04, start_addr, count)
                sock.send(mbap + pdu)

                response = sock.recv(1024)
                if len(response) < 9:
                    self.errors += 1
                else:
                    self.requests += 1

            except Exception:
                self.errors += 1
                try:
                    sock.close()
                    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                    sock.connect((self.host, self.port))
                    sock.settimeout(2.0)
                except:
                    break

        sock.close()

def stress_test(server_host='127.0.0.1', server_port=502, clients=50, duration=60):
    """多客户端并发压力测试"""
    threads = []
    clients_list = []

    start = time.time()

    for i in range(clients):
        c = StressClient(server_host, server_port, i)
        clients_list.append(c)
        t = threading.Thread(target=c.run, args=(duration,))
        t.start()
        threads.append(t)

    for t in threads:
        t.join()

    elapsed = time.time() - start
    total_requests = sum(c.requests for c in clients_list)
    total_errors = sum(c.errors for c in clients_list)

    print(f"\n{'='*50}")
    print(f"压力测试结果")
    print(f"{'='*50}")
    print(f"客户端数: {clients}")
    print(f"持续时间: {elapsed:.1f}s")
    print(f"总请求数: {total_requests}")
    print(f"总错误数: {total_errors}")
    print(f"QPS: {total_requests/elapsed:.1f}")
    print(f"错误率: {total_errors/(total_requests+total_errors)*100:.2f}%" if (total_requests+total_errors) > 0 else "N/A")

if __name__ == '__main__':
    stress_test(clients=50, duration=60)

十、生产级特性补充

10.1 连接限流与黑白名单

代码语言:javascript
复制
class ConnectionLimiter:
    def __init__(self, max_per_ip=5, max_total=200):
        self.max_per_ip = max_per_ip
        self.max_total = max_total
        self._ip_count = defaultdict(int)
        self._lock = threading.Lock()

    def check(self, client_addr):
        ip = client_addr[0]
        with self._lock:
            total = sum(self._ip_count.values())
            if total >= self.max_total:
                return False, "Total connection limit exceeded"
            if self._ip_count[ip] >= self._max_per_ip:
                return False, f"Per-IP limit exceeded for {ip}"
            self._ip_count[ip] += 1
            return True, "OK"

    def release(self, client_addr):
        ip = client_addr[0]
        with self._lock:
            self._ip_count[ip] = max(0, self._ip_count[ip] - 1)
            if self._ip_count[ip] == 0:
                del self._ip_count[ip]

10.2 审计日志

代码语言:javascript
复制
class AuditLogger:
    def __init__(self, log_file="modbus_audit.log"):
        self.log_file = log_file

    def log_request(self, client_addr, unit_id, func_code, start_addr, count, result="OK"):
        timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
        with open(self.log_file, 'a') as f:
            f.write(f"{timestamp} | {client_addr[0]}:{client_addr[1]} | "
                    f"Unit={unit_id} | Func=0x{func_code:02X} | "
                    f"Addr=0x{start_addr:04X} | Count={count} | {result}\n")

10.3 优雅关闭

代码语言:javascript
复制
def graceful_shutdown(server, signum, frame):
    """信号处理函数"""
    logger.info(f"Received signal {signum}, shutting down gracefully...")
    server.stop()
    # 等待所有连接关闭
    time.sleep(2)
    logger.info("Server stopped")
    exit(0)

import signal
signal.signal(signal.SIGTERM, lambda s, f: graceful_shutdown(server, s, f))
signal.signal(signal.SIGINT, lambda s, f: graceful_shutdown(server, s, f))

十一、常见开发坑

  1. MBAP 头长度字段理解错误:length 是 Unit ID + PDU 的总字节数,不是后续全部
  2. 事务标识符不回传:客户端用 Transaction ID 匹配请求,服务端必须原样返回
  3. TCP 粘包不处理:多个 Modbus 帧一次到达,必须按 length 字段逐帧拆分
  4. 异常响应功能码不加 0x80:规范规定异常响应 = 原功能码 | 0x80
  5. 寄存器地址 0-based 和 1-based 混用:文档写 40001,代码里填 0
  6. 并发写同一寄存器无锁:多线程模式下数据竞争导致值错乱
  7. select 最大 1024 fd:超过 1024 个文件描述符 select 会崩溃,换 epoll
  8. 不设置 SO_REUSEADDR:服务器重启时 bind 失败(TIME_WAIT)
  9. 不设置 TCP_NODELAY:小包延迟累积,响应变慢
  10. 接收缓冲区不设上限:恶意客户端持续发数据不关闭,内存耗尽
  11. 不处理半关闭:客户端只关闭写端(FIN),服务端继续读返回 0 才该关
  12. Unit ID 路由遗漏:多设备场景下 Unit ID 不对,返回异常而非静默丢弃

十二、收尾

实现一个工业级的 Modbus TCP 服务端,从"能监听"到"能扛并发",需要逐层递进:

  • 协议层:MBAP 正确解析、功能码完整分发、异常码规范返回
  • I/O 层:select/epoll 多路复用、粘包拆包、超时管理
  • 并发层:连接表线程安全、寄存器读写锁、文件描述符不泄漏
  • 系统层:fd 限制调优、内核参数、TCP Keepalive
  • 运维层:连接限流、审计日志、黑白名单、优雅关闭

做到这些,你的 Modbus TCP 服务端才能在工业现场同时扛住几十个平台客户端的并发轮询、不丢帧、不乱数据、不崩溃——这才是工业物联网采集开发该有的基本功。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 工业物联网采集开发:Modbus TCP传感器多并发连接的服务端实现
    • 一、为什么需要自己写 Modbus TCP 服务端
    • 二、Modbus TCP 协议帧结构回顾
      • 常见功能码
    • 三、服务端架构设计
      • 3.1 整体架构
      • 3.2 核心数据结构
    • 四、基于 select 的多并发服务端实现
      • 4.1 核心服务器类
      • 4.2 服务端启动与寄存器初始化
    • 五、基于 epoll 的高性能实现(Linux 专用)
      • 5.1 epoll vs select 对比
      • 5.2 epoll 服务端核心
    • 六、多线程版本(每个连接一个线程)
      • 6.1 线程池服务端
    • 七、并发安全深度分析
      • 7.1 竞争条件场景
      • 7.2 解决方案
      • 7.3 读写锁优化(高读低写场景)
    • 八、系统调优
      • 8.1 文件描述符限制
      • 8.2 内核网络参数
      • 8.3 Python 层面优化
    • 九、测试与验证
      • 9.1 功能测试
      • 9.2 压力测试
    • 十、生产级特性补充
      • 10.1 连接限流与黑白名单
      • 10.2 审计日志
      • 10.3 优雅关闭
    • 十一、常见开发坑
    • 十二、收尾
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档