资讯动态

用Python构建Modbus从站模拟器:从零实现数据模拟与联调实战

发布时间:2026/10/14 1:07:52 来源:尧图企业网站定制
做工控的朋友应该都有过这种经历程序写好了上位机画面也组态完了就等现场仪表到位才能联调。仪表没到、传感器被占用、或者设备还在路上调试进度只能卡住。Modbus数据模拟就是为了解决这种“没设备也能调”的问题——用软件生成符合Modbus协议的数据代替真实仪表完成通讯测试、逻辑验证和画面调试。这篇文章会从协议基础讲起一步步教你用Python从零搭一个Modbus从站模拟器还会把我在实际调试中踩过的坑一并整理出来适合刚接触Modbus的新人也适合想把手动模拟升级成自动化模拟的工程师参考。1. 先把Modbus协议的关键概念捋清楚1.1 Modbus通讯模型与两种主流实现方式Modbus本质上是一种主从式通讯协议。一个主站比如PLC、上位机、触摸屏发请求从站比如仪表、变送器、变频器收到请求后返回对应数据。在整个链路里每次通讯都是主站主动发起从站不会主动往外冒数据这一点在模拟数据时非常重要——你做的模拟器本质上就是一台“假的从站”等着别人来问问什么答什么。Modbus常见的物理实现有两条路一条走串口叫Modbus RTU另一条走以太网叫Modbus TCP。RTU模式下的数据帧由地址码、功能码、数据和CRC校验组成帧里所有数据都是二进制格式一帧消息通常也就8到16个字节。TCP模式下则把Modbus报文封装在TCP/IP协议里报文前多了6个字节的MBAP头用来做事务标识和长度标识整体通讯可靠性更高也省去了CRC校验的环节。RTU和TCP怎么选如果你的设备挂在串口总线上比如通过RS485转USB接到电脑就用RTU模式如果你的设备有网口或者你想用网线直连调试那就用TCP模式。实际现场调试中用TCP的情况越来越多因为网口调试不用关注串口波特率、数据位、停止位这些参数节省很多排查时间。1.2 模拟数据前必须理解的寄存器映射Modbus协议里最核心的概念是四个数据区域理解这四个区域你才能真正明白“模拟数据”到底在模拟什么。这四个区域分别是线圈Coil、离散输入Discrete Input、输入寄存器Input Register和保持寄存器Holding Register。线圈对应的是开关量输出可以读也可以写通常用来控制继电器的通断离散输入对应开关量输入只能读比如接近开关、限位开关的信号输入寄存器是只读的模拟量数据区比如电流、电压、温度值保持寄存器是可读可写的模拟量数据区设备参数、设定值通常都放这里。用一张表格把四类数据区的特性对应起来会比翻协议标准方便得多数据区功能码位/字读写属性典型用途线圈01读/05写/15写多位可读可写开关量输出离散输入02读位只读开关量输入输入寄存器04读16位字只读模拟量输入保持寄存器03读/06写/16写多16位字可读可写参数设定和模拟量输出做数据模拟时90%的场景只需要关注保持寄存器和输入寄存器。比如模拟一块温度变送器你只需要在保持寄存器里填充温度值然后在PLC或上位机侧用功能码03去读取如果要模拟一个只能被读取的流量计那就用输入寄存器用功能码04去读。寄存器地址有个行业里的习惯需要注意组态软件和PLC程序里常写的40001、40002对应的是保持寄存器从地址0开始的第1、2个寄存器30001、30002对应输入寄存器。这个“1”和“0”的偏移问题坑了无数刚上手Modbus的人后面专门讲。1.3 数据模拟要解决的真实痛点我之所以反复强调“真实痛点”是因为很多人觉得模拟数据不过是“改几个地址数值”而已。等真的到现场调试会发现事情远没有那么简单。第一个典型场景设备未到场你需要先调程序。现场仪表采购周期长但PLC程序、上位机画面却不能等到设备到了再做。这时候用模拟器顶替仪表先跑通通讯链路验证逻辑和数据映射等真设备上线时直接把通讯参数一改就能用。第二个典型场景模拟故障复现生产问题。现场设备偶发故障很难复现——比如温度偶尔越限、仪表偶尔返回异常值。用模拟器可以精准地制造这些边界数据让程序里的报警逻辑和故障处理逻辑被完整地测试到。第三个典型场景压力测试和整定调试。当你的上位系统需要同时采集几十甚至上百个设备的Modbus数据时模拟器可以给你造出一大堆虚拟设备检验主站的轮询效率、通讯超时设置是否合理。抓住这三个方向你就能明白为什么光有“能读能写”的模拟工具还不够你还需要能灵活控制数据变化规则的模拟器。2. 模拟方案选型现成工具还是自己写代码2.1 现成的Modbus从站模拟工具好用在哪里市面上有很多现成的Modbus从站模拟工具下载打开就能用界面直观能设置任意寄存器地址的数值支持RTU和TCP两种方式。对临时调试、单点验证的场景来说这些工具确实很顺手基本上30秒就能让电脑变成一台虚拟从站。但这类工具的问题也明确大多数只能手动修改寄存器数值做不到按规律自动变化。你模拟一个温度测点时就得每分钟手动改一次数值或者用工具自带的计数器增量功能但变化模式非常有限。另外如果要从站按特定时序返回数据比如先正常一小时后变异常多数图形化工具根本做不到。所以在我的工作流里图形化工具只用来做快速验证重活都交给代码。2.2 为什么推荐用代码方案控制模拟数据选用代码方案的核心原因有三个。第一数据变化规则完全可控。你可以在代码里写任何逻辑比如用正弦函数模拟温度振荡用随机数模拟波动信号用条件判断让数据在某个阈值附近跳变这些在图形化工具里几乎无法优雅实现。第二可以自动化批量模拟。假设你要模拟一个风场里几十台风机的数据每台风机有风速、功率、转速等十几个寄存器数据图形化工具一台一台配置下来半天就交代了。而写一个循环几十台虚拟从站一次启动全部就位。第三便于做自动化回归测试。把模拟器做成脚本插入到测试流程里每次提交代码后自动跑一遍通讯测试。模拟器在Test Automation这套体系里的位置和我后面要讲到的用法是一致的——你把模拟器当成一个可编程的测试工具来用而不只是调试时期的一次性道具。2.3 环境准备与依赖安装代码方案我用Python实现选它的理由是生态好、协议库成熟、跨平台方便。核心依赖有两个一个是用pymodbus搭建从站服务另一个是pyserial如果只走TCP可以不用串口库但RTU场景下必须装。安装过程非常简单一条pip命令就能完成。pip install pymodbus pyserial需要提醒一点pymodbus这个库的版本迭代很快不同版本之间的API有差异。我用的是较新版本的习惯写法构建的代码结构如果你在运行中出现“模块找不到”或“函数参数不匹配”之类的报错大概率是新版本API变了。遇到这种情况优先去官方文档查一下对应函数的签名或者直接指定安装一个维护稳定的老版本等代码跑通后再考虑升级。代码方案还有一个前置条件电脑上得留出TCP端口供从站监听。默认Modbus TCP端口是502但有些操作系统上绑定1024以下端口需要管理员权限而且现场如果已经有一个程序占用了502端口后续从站就起不来。我建议你在调试阶段用一个高位端口比如15020避开权限和冲突问题。3. 核心实操用代码构建一个Modbus TCP从站模拟器3.1 最小实现让电脑变成一台“假仪表”先从一个最简版本开始目标是启动一个监听在本地端口上的Modbus TCP从站并让主站能读到一组保持寄存器的值。下面这段代码就是完整的最小可用版本。from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSlaveContext, ModbusDeviceContext from pymodbus.datastore import ModbusSequentialDataBlock # 初始化数据存储定义保持寄存器的起始地址和值 store ModbusSlaveContext( zero_modeTrue, diModbusSequentialDataBlock(0, [0] * 10), # 离散输入 coModbusSequentialDataBlock(0, [0] * 10), # 线圈 hrModbusSequentialDataBlock(0, [20, 25, 30, 50]), # 保持寄存器 irModbusSequentialDataBlock(0, [0] * 10) # 输入寄存器 ) context ModbusDeviceContext(slaves{1: store}, singleTrue) if __name__ __main__: print(Modbus TCP Server started on port 15020, unit id 1) StartTcpServer(contextcontext, address(0.0.0.0, 15020))拆开解读。ModbusSequentialDataBlock这个类定义了寄存器的数值块第一个参数是起始地址第二个参数是一个列表列表里的每一个数值对应一个寄存器。我这里保持寄存器起始地址设成了0填充了[20, 25, 30, 50]表示地址0、1、2、3上的值分别是20、25、30、50。ModbusSlaveContext把四个数据区聚合在一起形成一台设备的完整数据映射。zero_modeTrue这个参数很关键它表示地址从0开始计算和绝大多数PLC的Modbus从站地址习惯一致如果设置成False地址会从1开始导致主站地址和你设置的存储地址永远差1。实际工程里从站的协议地址一般从0开始所以我建议都用zero_modeTrue。ModbusDeviceContext里slaves{1: store}定义了从站地址Unit ID为1。这是Modbus TCP里从站标识主站请求时也会带这个ID。如果你的主站工具默认Unit ID是255或0那就会读不到数据——这个问题在后面的排查章节会再出现。启动后用主站工具读一下保持寄存器。连接地址填127.0.0.1端口填15020从站地址填1起始地址填0数量填4功能码选03。能正确读到20、25、30、50这4个值说明这个从站已经能工作了。3.2 让数据活起来用曲线和随机扰动模拟真实信号静态数据只能做最基础的通讯联调真正的数据模拟必须让数值“动”起来。仪表采集上来的温度、压力、流量数据都是有趋势变化的模拟器也应该表现出这种特征。下面这段代码在最小版本的基础上增加了后台线程定时更新寄存器值模拟一路温度信号的动态过程。import time import threading import math import random from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSlaveContext, ModbusDeviceContext from pymodbus.datastore import ModbusSequentialDataBlock store ModbusSlaveContext( zero_modeTrue, diModbusSequentialDataBlock(0, [0] * 10), coModbusSequentialDataBlock(0, [0] * 10), hrModbusSequentialDataBlock(0, [0] * 100), irModbusSequentialDataBlock(0, [0] * 100) ) context ModbusDeviceContext(slaves{1: store}, singleTrue) def update_temperature(): t 0.0 while True: # 基础温度用正弦波模拟昼夜变化叠加随机扰动模拟干扰 base 50.0 10.0 * math.sin(t / 300) value int(base random.uniform(-1.5, 1.5)) store.setValues(3, 0, [value]) # 功能码3对应的保持寄存器区 store.setValues(3, 1, [int((base 5) * 10)]) # 模拟第二路数据 t 1 time.sleep(1) threading.Thread(targetupdate_temperature, daemonTrue).start() if __name__ __main__: print(Dynamic Modbus TCP Server started on port 15020) StartTcpServer(contextcontext, address(0.0.0.0, 15020))需要解释一下store.setValues这一步为什么是这么写的。setValues的第一个参数是区域标识3代表保持寄存器区域这里用的是pymodbus内部约定的常量0是线圈区、1是离散输入区、3是输入寄存器区、4是保持寄存器区——这个编号逻辑和前面提到的功能码编号不是一回事别搞混。第二个参数是起始地址第三个参数是要写入的值列表。温度变化模型用正弦波加随机扰动这是我试过的最实用的方式。正弦波保证数据在合理的物理范围内连续变化模拟昼夜温度波动或生产过程缓慢调整叠加随机扰动则让数据更加逼真不会出现一条完美光滑曲线引起的视觉怀疑。实测下来这种“主趋势小扰动”的模拟方式可以骗过绝大多数人的眼睛甚至直接拿来做画面演示。3.3 用Modbus主站工具验证模拟数据是否正常模拟器启动后最好再用独立的Modbus主站工具读一遍数据确认它对外展现的状态和你预期一致。我把这个习惯称为“第三方视角验证”——自己写的代码自己读自己的容易忽略问题换一个工具站在“外部用户”的角度去读才能真正检查从站的行为是否正确。打开主站工具添加连接协议选TCPIP填127.0.0.1端口15020Unit ID填1。然后分别用功能码03读保持寄存器起始地址0长度10功能码04读输入寄存器起始地址0长度10验证线圈区用功能码01去读确认数据返回正常。我有一个很推荐的做法把主站工具的轮询周期设置成500毫秒即每秒钟读2次然后观察温度值的变化曲线。如果曲线连续、波动自然说明模拟器工作正常如果读到的值纹丝不动检查后台更新线程有没有启动或者是不是往错误的寄存器区域写了值。如果读到的值忽大忽小跳动那要考虑是不是主站和从站之间的时序问题——通常把轮询周期调慢一点就能看到更稳定的现象。4. 进阶玩法让模拟数据更接近真实现场4.1 模拟故障数据越限、突变和坏值完成了基本的动态数据模拟下一步要做的就是模拟各种故障状态。因为通常需要模拟故障的时机恰恰是设备还没真正出现故障的时候——你得先把逻辑调好等真出事时才能接得住。我用一个具体案例来说明模拟一台温度变送器正常工作范围是0-100摄氏度报警上限是80摄氏度。第一种故障场景是越限让模拟数据逐步逼近并超过报警阈值。你不需要突然把值从50跳到85那样的数据没有过程真实仪表也不可能瞬间跳变。更合理的做法是让数据以一个较大的趋势斜率爬升——比如每秒钟上升2-3度从50度爬到85度观察上位机什么时候触发一级报警、什么时候触发二级报警、什么时候联锁停机这些联动逻辑只有在数据“慢慢逼近”的过程中才能被完整验证。第二种场景是突变模拟传感器断线或受到干扰时数值瞬间变为上限值或某个固定坏值。我把寄存器值直接改成65535或-32768看PLC端的工程量转换代码能不能正确处理这种极端值。很多项目里的工程量换算只用简单的量程比例公式遇到坏值会计算出离谱的物理量甚至导致误动作这种问题只能在模拟阶段发现。第三种场景是状态字很多仪表的寄存器里有一个状态位用Bit标记通讯是否正常、数据是否有效。你可以在模拟器的寄存器更新逻辑里专门控制某一位的0和1翻转验证上位机是否根据状态位屏蔽无效数据。这比直接操作浮点数有效得多因为状态位的处置在很多控制逻辑里属于被遗忘的死角。4.2 模拟Modbus异常响应错误码与非法请求正常通讯的模拟只是第一步Modbus协议里还有一类必须模拟的场景是从站返回异常响应。当主站请求一个超出从站数据范围的地址时比如从站只定义了100个保持寄存器主站非要读地址200标准Modbus从站应该返回一个异常响应码。功能码3读保持寄存器对应的异常码是2表示非法数据地址。在pymodbus里对超范围请求的响应通常是自动的因为数据块定义范围已经限制了可访问区域。但如果需要主动返回其他异常码比如模拟“从站忙”或“非法功能”你需要自定义异常处理逻辑。一个我经常用来做测试的模拟场景是让某几个特定寄存器在某个时段内不可访问模拟设备正在进行固件升级或参数自整定的状态。具体做法是在处理请求的回调函数里检查寄存器地址是否落在禁用区间如果是则返回异常码。这样做的好处是上位机程序对Modbus异常码的解析逻辑——比如超时重试、显示通讯故障、切换到备用设备——都能被真实地触发和测试。异常响应这块很多工程师在项目里压根没做处理觉得“设备一定会正常响应”。但实际现场由于接线虚接、干扰等导致通讯故障的概率不小上位机的容错逻辑必须在模拟阶段就验证过。等现场出了问题再回来改上位机程序那就被动了。4.3 把模拟器接入你的完整联调流程数据模拟的最高级用法是把模拟器变成一个“虚拟现场”联调整个系统而不是单点通讯。我去年调试一个某跨平台监控系统时就同时启动了20个Modbus TCP模拟器实例每个实例模拟一台独立的现场仪表分别监听不同的端口上位机系统通过配置20个IP地址和端口去采集这些数据。这样做的好处是整个采集链路的负载能力和参数配置都能得到真实考验比只模拟一台设备能发现更多问题。具体操作流程是这样的第一步把每台模拟器的数据模板和寄存器映射整理成一张配置表包括设备名称、IP、端口、Unit ID、寄存器地址、数据格式、变化规则。第二步写一个脚本批量启动所有模拟器实例让每台设备的数据按各自预设的规则独立运行。第三步启动上位机组态软件把设备表配置进去开始验收集成监控界面。这个过程里最容易出问题的点在于不同模拟器里同一类物理量如果都用同一个随机波动范围那所有趋势曲线会高度相似一眼看出是假的。给每台模拟器配置不同的基线温度和不同的波动幅度画面就会自然得多。这个细节虽然不影响通讯验证功能但如果你要给甲方做演示效果差别很大。5. 模拟过程中必然遇到的高频问题与排查心得5.1 能ping通但读不到数据单元ID和端口不匹配我见过的第一类高频问题是主站和从站明明在同一网段网络能ping通但Modbus主站工具读不到任何数据。这时优先检查两个参数端口号和Unit ID。端口号不一致很直观一个监听15020一个去连502肯定不通。Unit ID的问题更隐蔽——很多主站工具在读Modbus TCP时默认使用Unit ID 255或0但从站配置的是1两个对不上就会一直超时。排查思路很简单在主站工具里把Unit ID改成和从站配置一致反过来如果从站代码里slaves配置成{1: store}主站工具填1试试。第二个容易踩的坑是防火墙拦截了TCP端口尤其Windows系统首次运行Python服务时系统会弹出网络访问提示如果不点允许外部设备死活连不上。直接在Windows安全中心的防火墙规则里把对应端口或Python进程加进白名单省得后面理不清。5.2 数值对不上字节序和数据类型的问题Modbus协议里一个16位寄存器就是两个字节能表示的整数。如果你要传输温度20.5摄氏度这种带小数的数据就得先把浮点转成两个连续的16位寄存器来存放。不同设备对这两个寄存器的顺序解释不同有的高位在前Big-Endian有的低位在前Little-Endian于是同样的原始字节在不同设备上解析出来的数值可能完全两样。模拟数据时要想清楚你的目标仪表在真实情况下是怎么处理字节序的。主流Modbus设备大多使用高位在前但工业现场总有特例。我在某次调试中遇到一台国产流量计用的恰好是低位在前协议文档里只在不起眼的位置提了一句结果上位机读出来的值大了256倍排查了半天才发现是字节序问题。还有一个常见的数据类型问题是32位浮点的解析顺序。Modbus中一个float占用两个寄存器这两个寄存器的组合顺序加上每个寄存器内字节的顺序排列组合下来有好几种可能性。遇到浮点读不对时除了检查字节序还要看寄存器组合顺序——是低地址存高位还是高地址存低位。最有效的办法是先用一个已知数值去确认组合规则比如在模拟器中固定写入一个20.5度然后尝试不同的解析顺序对上了再批量读取。5.3 地址偏移40001和0000永远差一位地址偏移问题在我的排查记录里出现频率排第二。PLC编程里常写40001表示保持寄存器第1个但Modbus报文里实际的协议地址是0000PLC里写40011协议地址是0010。中间始终差1。这意味着如果你的模拟器里存储数据块的起始地址是0数值放在第0个寄存器那么PLC程序里要读40001才能对上如果你的模拟器为了“方便”把寄存器索引从1开始那PLC里也得读40001才能匹配但如果主站工具里把起始地址填1协议地址就变成0001读出来的实际是第1个寄存器。这个偏移坑在跨品牌联调时尤为扎眼。不同品牌PLC对Modbus地址的映射习惯不同有的把4xxxx映射到协议地址0有的映射到1。我的经验是模拟器统一用协议地址0起始各个主站设备的地址映射表单独适配。不要让模拟器去迁就不同主站的“方言”那样只会改来改去永远改不干净。可以在模拟器的配置里加一个地址偏移开关联调时根据目标主站类型快速切换既不用改代码逻辑又能适配不同品牌的地址习惯。我实际用下来这个小技巧能把联调时间压缩至少一半。5.4 通讯时断时续轮询周期和超时设置模拟器通讯正常但上位机里显示的数据一会儿有一会儿没有这种问题看起来像网络不稳其实很多时候是轮询周期和超时设置不匹配导致的。Modbus主站通常是循环轮询一整块寄存器如果整块数据达到几百个寄存器轮询一圈的耗时就会很长。模拟器处理每个请求也需要时间虽然通常远小于1毫秒但当同时连接的主站数量增多、且每个主站轮询周期短于50毫秒时请求就会排队超时就可能发生。我调试中遇到过一个具体案例上位机采集程序把轮询超时设置成300毫秒同时以100毫秒周期轮询一个数据量很大的模拟器结果大量请求超时重发现象就是数据在画面上不连续。最终把轮询周期调大到500毫秒超时设置调成1秒问题才消除。如果你在做模拟数据时遇到类似的通讯不稳定可以先从这几个参数入手轮询周期、响应超时、重试次数。优先调整这几个参数比怀疑模拟器本身稳定性和网络丢包更接近问题本质。5.5 高频问题速查表把上面几类问题整理成一个表格方便现场调试时快速对照排查问题现象排查要点解决思路通讯超时读不到数据端口号、Unit ID、防火墙端口改成一致Unit ID填从站实际值防火墙放行Python进程数值很大或为负数字节序、数据类型固定一个已知值测试不同解析顺序确认后统一处理数据整体偏移一位地址起始不一致确认PLC用40001还是协议地址0模拟器统一用0起始用偏移开关适配数据时有时无轮询周期、超时设置调大轮询周期和超时时间避免大量请求排队写寄存器不生效功能码、数据块写权限检查主站用的是功能码06还是16确认模拟器允许写对应区域多个主站同时读时偶发异常服务并发能力限制检查从站是否支持并发连接必要时加大请求队列或改用更轻量的数据块6. 一个值得养成的习惯把模拟器做成你的调试工具箱数据模拟这事做得粗和做得细效果天差地别。做了很多次模拟器之后我现在习惯把模拟器沉淀成一个带配置文件的小工具箱而不是每次调试都现场写脚本。配置里定义了每台虚拟设备的寄存器映射、数据类型、变化规则、异常注入方案启动时读配置直接实例化。我实际体会中这一步的投入回报非常大。因为你在一个项目里写好的模拟规则换一个项目照样能复用最多改改寄存器地址和量程范围。特别是那些你自己总结出来的“数据变化模板”——正弦波温度趋势、阶梯式压力变化、随机跳变干扰——整理成模板后新项目调试的启动时间能压缩到原来的三分之一。最后再分享一个小技巧模拟器最好留一个“数据打印开关”。启动时带上打印参数每收到一次请求、每更新一次寄存器值都把关键帧信息输出到终端或日志文件里。正常状态下这个开关是关闭的因为大量打印会拖慢通讯但遇到疑难杂症时打开它你能看到主站到底在请求什么地址、返回了什么值多数通讯问题在帧级别一比对就暴露了。这个习惯我保持了很多年是排查Modbus通讯问题最直接、最有效的手段。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑