Linux上搭建Modbus TCP服务端:pymodbus 3.x实战与避坑指南

发布时间:2026/10/23 17:53:25
Linux上搭建Modbus TCP服务端:pymodbus 3.x实战与避坑指南

简介:针对Linux平台的TCPModbus服务端实现,面向工业自动化开发者、嵌入式工程师及Linux网络编程人员,解决工业现场中Modbus设备通过TCP/IP网络接入上位机或网关的问题。压缩包共4个文件,约10KB,包含C语言主程序tcpmodbusserver.c、线程封装头文件linux_thread.inc、Makefile构建脚本及编译好的可执行程序,结构精简,既可直接编译运行学习,也可作为模块集成到自有项目中。已有971人学习下载。代码实现了Modbus协议常用的三类寄存器操作:输入寄存器(0x04)和离散输入(0x02)为只读,保持寄存器支持读(0x03)、单写(0x06)和多写(0x10),并通过多线程并发处理客户端请求;其中输入寄存器对应传感器测量值,离散输入对应开关状态,保持寄存器用于设定参数,完整展示了从协议解析、地址映射到响应组装的工业通信流程。配合源代码和构建脚本,对理解Modbus TCP从站开发、并发服务设计及工业数据采集均有实际帮助,适合作为入门学习、课程设计或原型验证的基础。

1. Linux 上搭 Modbus TCP 服务端:版本坑比你想的多,先解决"装哪个"

某天我接到一个 PLC 联调任务,需要在 Linux 上起一个 Modbus TCP 从站服务端,模拟一批温湿度传感器,让上位机那头的 MES 系统先把流程跑起来。真正动手时才发现最坑的不是"能不能装",而是照着网上旧教程装完直接报错:同一个 pymodbus 库,2.x 和 3.x 的导入路径、服务端写法完全两码事,复制下来没一个能跑。这篇记录我最终落地的一套方案:Ubuntu 22.04 LTS、Python 3.10 虚拟环境、pymodbus 3.7.x,从环境准备、服务端脚本、systemd 托管,到寄存器映射和字节序那些容易翻车的细节,一直写到用客户端工具做读写回归验证。适合刚接触 Modbus 联调的新手,也适合被老教程坑过、想直接拿到能跑版本的老手。

2. 环境准备与版本选型:Ubuntu 22.04 + Python 3.10 + pymodbus 3.7

2.1 为什么我最后选了 pymodbus 而非自写 C 服务端

Linux 上实现 Modbus TCP 服务端,常见的路子有三条:用 C/C++ 直接调 libmodbus 自己写,用 libmodbus 自带的 diagslave 命令行工具模拟,或者用 Python 的 pymodbus 库写一个服务端进程。C 方案在性能和生产环境确实有优势,但要处理交叉编译、链接参数、内存释放这些杂事,为了一次模拟设备和联调任务去写 C,投入产出比不高。diagslave 虽然零代码就能跑,缺点是可编程能力太弱——寄存器初始化逻辑、按周期更新数值、多从站场景都不好扩展。最后我选了 pymodbus,理由很实际:部署快,数据块模型直观,改寄存器映射就是改一个 Python 列表,而且 3.x 版本支持同步和异步两种服务端,能覆盖大多数模拟和设备联调场景。

方案上手成本扩展能力适合场景
C/C++ + libmodbus高,要编译强生产环境嵌入式、高并发采集
diagslave 模拟器低,装完即用弱,难写逻辑临时测试连通性
Python + pymodbus低,pip 安装中,代码可扩展联调模拟、数据中台测试、教学 Demo

版本选型这里要单独说一句:pymodbus 2.x 已经停止维护,网上大量教程都是 2.x 时代的写法,比如from pymodbus.server.sync import StartTcpServer,这个路径在 3.x 里直接被删掉了。我装的时候 pip 默认就给装 3.7.x,所以除非你项目里有存量代码锁定在 2.x,否则新项目直接上 3.x,别再去看老教程的导入路径。3.x 里同步和异步的服务端入口统一收敛到了pymodbus.server包,后面写代码会用到。

2.2 创建虚拟环境并安装 pymodbus

环境准备的第一步是确认系统自带的 Python 版本。Ubuntu 22.04 默认带 Python 3.10,对 pymodbus 3.7.x 完全足够。先用 apt 把虚拟环境和 pip 组件补齐:

sudo apt update sudo apt install -y python3 python3-venv python3-pip python3 --version

这段命令先把发行版自带的 Python 三件套装好。Ubuntu 的 base 系统里默认有 python3,但python3-venv经常缺席,不装的话下一步创建虚拟环境会直接报ensurepip is not available。版本检查输出的应该是 Python 3.10.x,这个版本号后面排障时可以用来排除"Python 太老或太新"的问题。

接下来在统一工作目录里建虚拟环境,安装依赖:

sudo mkdir -p /opt/modbus-server sudo chown $USER:$USER /opt/modbus-server cd /opt/modbus-server python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install pymodbus==3.7.4

我把服务端放进/opt/modbus-server,这是 Linux 上放第三方自研服务的常用位置,和用户自己的家目录分开,后面配 systemd 也更整洁。python3 -m venv venv创建虚拟环境,source venv/bin/activate激活它,激活后命令行提示符前会出现(venv)前缀,这说明接下来的 pip 都装进隔离环境了。固定pymodbus==3.7.4是为了可复现——如果直接pip install pymodbus,装到 3.8 或更高版本时接口又有可能变。3.7.4 是我验证过 API 稳定的一代。

2.3 锁定依赖版本

pymodbus 安装时会自动拉入几个运行依赖,比如 pyserial 和 typing-extensions。多人协作或者换服务器重新部署时,光记一个主版本号是不够的,最好把所有依赖版本锁定进文件:

pip list pip freeze > requirements.txt cat requirements.txt

pip freeze输出的文件里应该能看到pymodbus==3.7.4、pyserial==3.5、typing-extensions==4.9.0这几项。以后换机器的时候,一行pip install -r requirements.txt就能装回完全一致的环境。我在实际部署时还会顺手把pip list的完整输出存一份,万一现场环境装不了内网源,拿着版本清单去手工找包也方便。

2.4 快速验证库能否正常导入

安装完先别急着写业务代码,用一行命令验证服务端入口能不能导入:

python -c "import pymodbus; print(pymodbus.__version__)" python -c "from pymodbus.server import StartTcpServer; print('server import ok')"

如果第一行打印出版本号但第二行报ModuleNotFoundError,说明你装的 pymodbus 版本不对,或者当前激活的 Python 解释器不是虚拟环境里的。这时候检查一下which python,确认在/opt/modbus-server/venv/bin/python下面。这一步是我每次装 Python 库之后都会做的"冒烟测试",十秒不到,能拦下大半个环境问题。

3. 部署最小服务端:一份能跑的 modbus_server.py

3.1 代码结构和四类数据块

Modbus 从站的核心是数据模型。一个完整的从站要维护四块数据区:离散输入 DI、线圈 CO、保持寄存器 HR、输入寄存器 IR。其中线圈和保持寄存器支持上位机读写,离散输入和输入寄存器是只读的。pymodbus 里这四块对应ModbusSequentialDataBlock,这个类只关心起始地址和初始值列表,协议层解析交给框架处理。服务端进程要做的就是把四块数据准备好,塞进ModbusSlaveContext,再包一层ModbusServerContext传给 TCP Server 启动函数。

这种分层结构和真实 PLC 内部的数据区模型是一致的。做传感器模拟时,采集侧的瞬时值放到 HR 或 IR,上位机通过功能码 0x03 读保持寄存器就能拿到;需要控制设备的场景,就用线圈 CO 接收上位机写来的开关命令。理解了这个模型,后面改寄存器映射就是改初始值列表的事,不动框架代码。

3.2 完整的服务端示例代码

#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Linux Modbus TCP 从站服务端 依赖: pymodbus 3.7.x 运行: python modbus_server.py """ from pymodbus.server import StartTcpServer from pymodbus.datastore import ( ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext, ) def build_context(): # 离散输入 DI: 从地址 0 开始, 10 个只读开关量 di = ModbusSequentialDataBlock(0, [0] * 10) # 线圈 CO: 从地址 0 开始, 10 个可读写开关量 co = ModbusSequentialDataBlock(0, [0] * 10) # 保持寄存器 HR: 从地址 0 开始, 100 个 16 位寄存器 hr = ModbusSequentialDataBlock(0, [0] * 100) # 输入寄存器 IR: 从地址 0 开始, 50 个只读寄存器 ir = ModbusSequentialDataBlock(0, [0] * 50) slave_store = ModbusSlaveContext( di=di, co=co, hr=hr, ir=ir, zero_mode=True ) # single=True 表示只模拟一个从站, 从站 ID 固定为 1 return ModbusServerContext(slaves=slave_store, single=True) if __name__ == "__main__": context = build_context() # 监听 0.0.0.0:5020; 换到标准端口 502 需要 root 权限或 setcap StartTcpServer(context=context, address=("0.0.0.0", 5020))

ModbusSequentialDataBlock(0, [0] * 100)的第一个参数是数据块起始地址,第二个参数是初始化值列表,长度决定了这个数据块有多少个点。我这里把 DI 和 CO 都开了 10 个点,HR 开了 100 个寄存器,IR 开了 50 个,足够模拟一台小型设备。zero_mode=True表示数据块内部地址从 0 开始,这是 pymodbus 的默认行为,保持默认就不会出现地址错位。single=True是单从站模式,上位机不管发什么从站 ID,服务端都响应,联调阶段最省事。

3.3 启动并确认监听端口

source /opt/modbus-server/venv/bin/activate cd /opt/modbus-server python modbus_server.py & ss -lntp | grep 5020

python modbus_server.py &让进程在后台跑,输出会打到当前终端。紧接的ss -lntp | grep 5020用来确认端口真的被监听,看到0.0.0.0:5020且进程名带python就说明服务端已经起来了。这里必须强调看一眼监听地址:如果显示127.0.0.1:5020,说明绑到了回环地址,只等于本机能访问,外部的 PLC 和上位机全都会连接失败,这一点后面避坑章节还会展开。后台用&跑只是临时验证,下一步立刻换 systemd 托管。

3.4 用 systemd 托管,开机自启

手动后台跑的服务一关终端就没了,生产联调时不能这么干。我一般用 systemd 把服务托管起来,保证崩了自动拉起、开机自启。先创建专用运行用户,再写 service 文件:

sudo useradd -r -s /usr/sbin/nologin modbus sudo chown -R modbus:modbus /opt/modbus-server
[Unit] Description=Modbus TCP Server After=network-online.target Wants=network-online.target [Service] Type=simple User=modbus Group=modbus WorkingDirectory=/opt/modbus-server ExecStart=/opt/modbus-server/venv/bin/python /opt/modbus-server/modbus_server.py Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target

这个 service 文件里有几个参数是踩坑总结出来的。User=modbus用普通用户跑服务,避免 Python 进程拿到 root 权限,万一被渗透也不至于直接控制整台机器。ExecStart必须写虚拟环境里 Python 解释器的绝对路径,写成python modbus_server.py的话 systemd 会在自己的 PATH 里找 python,大概率找到系统 Python,然后 import 不到 pymodbus。Restart=always配合RestartSec=5让服务在崩溃后 5 秒自动拉起,联调现场没人盯着服务器时,这个参数能救命。PYTHONUNBUFFERED=1让日志实时刷到 stdout,否则 systemctl 看日志会有几秒延迟。

把文件放进 systemd 并启动:

sudo tee /etc/systemd/system/modbus-server.service > /dev/null <<'EOF' (粘贴上面的 service 内容) EOF sudo systemctl daemon-reload sudo systemctl enable --now modbus-server systemctl status modbus-server

daemon-reload必须执行,systemd 才会重新读取新增的 service 文件。enable --now是"开机自启 + 立即启动"的合并写法。systemctl status输出里有Active: active (running)就说明托管成功。这个时候服务端已经是一个标准系统服务了,配合下一节的防火墙放行,外部设备就能连进来。

4. 配置与扩展:寄存器映射、从站 ID 与字节序

4.1 地址、端口和从站 ID 的改法

最小服务端跑起来后,下一步就是按实际设备改配置。地址元组里的("0.0.0.0", 5020),第一个元素是监听地址,第二个是端口。联调场景我无脑写0.0.0.0,让服务端接受任意网卡上的请求;如果服务端要同时跑多个实例做隔离,可以换成具体网卡 IP。端口这里有个矛盾点:Modbus TCP 标准端口是 502,但 502 属于特权端口(小于 1024),非 root 进程没权限监听。两个选择:要么端口继续用 5020 这类高位端口,让上位机连接时把端口改掉;要么用 root 跑服务,或者给 Python 解释器加cap_net_bind_service能力。我的习惯是测试阶段用 5020,进生产环境前再改成 502 并给解释器加能力位,避免服务以 root 身份长期运行。

多从站场景是联调里最常见的需求,比如同时模拟 4 台温湿度传感器,每台是一个从站 ID。把single=True换成多从站字典就能实现:

slaves = { 0x01: ModbusSlaveContext(di=di1, co=co1, hr=hr1, ir=ir1), 0x02: ModbusSlaveContext(di=di2, co=co2, hr=hr2, ir=ir2), } context = ModbusServerContext(slaves=slaves, single=False)

这里 1 号从站和 2 号从站的数据块要各自建一份,不能共用一个 DataBlock,否则上位机写 1 号从站时 2 号从站的数据也被改掉。多从站模式下,上位机发给哪个单元 ID,服务端就路由到哪个从站的存储区。实际联调时我会把设备地址表打印出来贴在工位上,例如 1 号传感器地址 0x01、2 号 0x02,省得现场猜地址。

4.2 四类数据区对照表

配置寄存器映射前,先把四类数据区对应的功能码和读写特性列清楚,照着表填数据不容易出幺蛾子:

数据区功能码读写方向典型用途pymodbus 参数
线圈 CO0x01 读、0x05 写单、0x0F 写多可读写开关命令、启停控制co=
离散输入 DI0x02 读只读限位开关、按钮状态di=
保持寄存器 HR0x03 读、0x06 写单、0x10 写多可读写设定值、参数配置、浮点数据hr=
输入寄存器 IR0x04 读只读传感器实时值、采集结果ir=

联调时最常见的组合是:传感器实时值放 IR,主站用功能码 0x04 去读;设备参数和阈值放 HR,主站既能读也能写;启停信号放 CO。如果只需要单向上报数据,IR 就够用,还能避免误写。

4.3 模拟真实设备:温湿度传感器寄存器

单纯的全 0 寄存器只能验证连通性,模拟真实设备必须把传感器特征值填进数据块。以 4 路温湿度传感器为例,我通常约定每路占 2 个保持寄存器:前一个存温度(乘以 10 后取整),后一个存湿度(乘以 10 后取整),全部从地址 0 开始排布:

hr_initial = [0] * 20 # 1 号通道: 温度 25.5℃ -> 255, 湿度 60.0% -> 600 hr_initial[0] = 255 hr_initial[1] = 600 # 2 号通道: 温度 26.2℃ -> 262, 湿度 58.5% -> 585 hr_initial[2] = 262 hr_initial[3] = 585 hr = ModbusSequentialDataBlock(0, hr_initial)

这里有个关键技术取舍:Modbus 寄存器是 16 位无符号整数,没有浮点原生类型,所以温度 25.5℃ 不能直接存,必须放大 10 倍变成 255 再存,客户端读到 255 后自己除以 10 还原。放大系数是协议的一部分,必须写进接口文档和上位机研发对齐——我见过好几次因为倍率没对齐,现场温度读出来是 2550℃ 的"重大事故",其实是 255 和 2550 的差距。

4.4 字节序:同一个寄存器,谁和谁看到的不一样

如果设备数据涉及 32 位浮点或者 32 位整数,字节序就是绕不过去的坑。比如温度值用 IEEE 754 单精度浮点表示,上位机要从连续两个 16 位寄存器里拼出这个浮点数,拼接顺序决定了最终数值。常见顺序有三种:大端序 ABCD(高字在前)、小端序 CDAB(低字在前)、还有 BADC 这种寄存器内部字节交换的变体。我的经验是:先约定寄存器协议为"H寄存器存高 16 位、L寄存器存低 16 位"(即大端序),服务端按这个约定填值,客户端按同样顺序解析。

import struct def float_to_regs(value): # 把 float 按大端序拆成两个 16 位寄存器值 raw = struct.pack(">f", value) return struct.unpack(">HH", raw) def regs_to_float(regs): # 两个 16 位寄存器按大端序拼回 float raw = struct.pack(">HH", regs[0], regs[1]) return struct.unpack(">f", raw)[0]

>f里的>表示大端序,f表示单精度浮点。服务端把值拆进寄存器时用float_to_regs,客户端解析回来用regs_to_float,两边只要都是大端序就不会出问题。如果现场设备是国产小端序的,把这个函数里的>改成<就行。字节序问题最磨人之处在于它不报错——数值看似正常但在小数点后几个零上差着量级,等到联调阶段才暴露就晚了。我一般会在写数据块时打印一份十六进制原始值,跟上位机返回值做对照,一眼就能看出顺序对不对。

5. 常见问题排查与避坑:五条真实踩坑记录

5.1 现象:import 直接报 ModuleNotFoundError

照着老教程写from pymodbus.server.sync import StartTcpServer,一运行就报ModuleNotFoundError: No module named 'pymodbus.server.sync'。原因是 pymodbus 3.x 重构后删掉了pymodbus.server.sync和pymodbus.server.async这两个子模块,同步和异步入口统一放到了pymodbus.server下;同样的迁移还发生在客户端,2.x 的from pymodbus.client.sync import ModbusTcpClient在 3.x 里改成了from pymodbus.client import ModbusTcpClient。解决方式是:直接改用 3.x 的统一导入路径,老代码要么迁移要么锁回 2.x,不存在兼容层。这个坑的根源是搜到的教程年代太久,装库之前先确认版本比什么都重要。

5.2 现象:外部设备连不上,本机自己却正常

现象很典型:服务器本机telnet 127.0.0.1 5020秒开,上位机通过网络访问192.168.x.x:5020就是连接超时。原因通常是两个叠加:服务端启动时监听地址写成了127.0.0.1,只对回环接口生效;或者监听地址是0.0.0.0,但防火墙把 5020 端口挡了。排查顺序是先用ss -lntp | grep 5020看监听地址,再看防火墙状态和规则:

# Ubuntu / Debian 系 sudo ufw allow 5020/tcp sudo ufw status # Rocky Linux / CentOS 系 sudo firewall-cmd --add-port=5020/tcp --permanent sudo firewall-cmd --reload sudo firewall-cmd --list-ports

解决之后一定要从外部机器再测一次,本机测试通过不代表网络路径通。我踩这个坑最狠的一次是监听地址和防火墙都没问题,最后发现是云平台安全组没放行,所以外部设备连不上时,按"服务端监听 -> 本机防火墙 -> 云平台安全组/物理网络ACL"这个顺序逐层排查。

5.3 现象:能连上,一读就超时

TCP 连接建立成功,但发请求后一直等到 timeout。这个现象大概率是从站 ID 不匹配。pymodbus 的single=True模式不校验从站 ID,谁来都响应;但如果改成single=False的多从站模式,上位机用从站 ID 3 去读,而服务端字典里只配了 1 和 2,请求就会被丢弃,表现就是连接正常但一轮询就超时。排查时先把日志级别调到 DEBUG:

import logging logging.basicConfig(level=logging.DEBUG)

服务端控制台或者 journalctl 里会打出收到的请求帧和单元 ID,看到unit=3而服务端没有对应从站就能锁定问题。解决方式是让上位机把单元 ID 改成服务端配置的从站 ID,或者在服务端把缺失的从站补上去。联调现场这种情况极多,我习惯把从站 ID 表写死在启动日志里,第一行就打出来。

5.4 现象:寄存器数值翻倍或者减半

写入一个浮点温度,上位机读回来数值是预期值的一半,或者干脆是一个天文数字。原因一是倍率没对上,服务端按 ×10 存储,上位机按 ×1 解析;原因二是前面第 4.4 节说的字节序问题,32 位浮点高低字拼接顺序不对。解决方式是把协议文档拿出来逐行核对倍率和字节序,最快的方法是写一个已知值:给服务端 HR 地址 0 写入一个特殊浮点数如 1.0(十六进制 0x3F800000),看上位机读回来是多少。如果读出来是一个数量级离谱的数,不用猜了,字节序反了。确定方案后统一改协议约定,改完重新跑读写回归。

5.5 现象:systemd 启动失败,journalctl 显示找不到模块

服务手动在终端跑没问题,systemctl start modbus-server却启动失败,journalctl -u modbus-server里报ModuleNotFoundError: No module named 'pymodbus'。最常见原因是 ExecStart 里写的是python modbus_server.py而不是虚拟环境解释器的绝对路径,systemd 调用时 PATH 里没有/opt/modbus-server/venv/bin,自然找到了系统 Python。还有一种隐蔽情况:WorkingDirectory写错了,Python 脚本里如果有相对路径读文件的逻辑会跟着一起炸。解决方式是把 ExecStart 改成/opt/modbus-server/venv/bin/python /opt/modbus-server/modbus_server.py,然后systemctl daemon-reload && systemctl restart modbus-server。看日志的时候用journalctl -u modbus-server -n 50只看最近 50 行,比翻老日志省时间。

6. 装完怎么验证:mbpoll 和 Python 客户端读写回归

6.1 安装 mbpoll 并读寄存器

服务端和配置都就位后,最后一步是验证。我习惯用 mbpoll 这个命令行工具做第一轮连通性验证,它相当于一个极简 Modbus 主站:

sudo apt install -y mbpoll mbpoll -a 1 -r 1 -c 10 -t 3 127.0.0.1 -p 5020

参数含义:-a 1指定从站 ID 为 1,-r 1是从起始地址 1 开始读(mbpoll 显示地址从 1 开始,对应协议地址 0),-c 10读 10 个寄存器,-t 3表示读保持寄存器,127.0.0.1 -p 5020是目标和端口。正常输出会列出地址和数值,如果读到的全是 0 而服务端初始化时填过数,优先检查地址偏移。用 mbpoll 而不是自己写客户端来验证的另一个好处是它不带缓存,每次发送的都是标准协议帧,能真实反映服务端响应情况。

6.2 自写 Python 客户端写读回读

mbpoll 测完读,还要测写。Modbus 服务端如果只支持读、写返回异常,联调时同样会翻车。我一般用 pymodbus 的客户端模式做写读回归:

from pymodbus.client import ModbusTcpClient client = ModbusTcpClient("127.0.0.1", port=5020, timeout=3) assert client.connect() # 写单个线圈: 地址 0, 置为 True client.write_coil(0, True, unit=1) # 回读线圈 rl = client.read_coils(0, 4, unit=1) print("coil bits:", rl.bits[:4]) # 写保持寄存器: 地址 0, 写入 1234 client.write_register(0, 1234, unit=1) # 回读保持寄存器 rr = client.read_holding_registers(0, 2, unit=1) print("hr registers:", rr.registers[:2]) client.close()

这段代码的验证逻辑是:写线圈地址 0 为 True,再读 4 个线圈,预期第一个是 True 其余 False;写保持寄存器地址 0 为 1234,再读 2 个寄存器,预期第一个是 1234。如果读写都满足预期,说明 CO 和 HR 两大数据区的写入通道是通的。我在实际联调任务里最后还会从外部上位机再发一次真实业务报文,比如让 MES 系统读一次温湿度并确认界面刷新,整条链路才算真正闭环。

从那以后,我每次搭完一个 Modbus TCP 从站,都会强制走一遍 mbpoll 读寄存器加自写客户端写读回读的回归流程,确认四类数据区、从站 ID、字节序全部对得上,才把地址和协议文档交给现场同事,这也让我躲开后续联调里大量的沟通成本。这篇安装笔记从环境选型、服务端脚本、systemd 托管一路写到这里,加上避坑那几条,覆盖了我自己在 Linux 上搭 Modbus TCP 服务端遇到的大部分问题,希望帮到你。

本文还有配套的精品资源,点击获取