车间里的 Modbus 设备读数,只能凑到现场看?本地起一个寄存器只读映射页,用 cpolar TCP 隧道让异地同事远程盯曲线
现场那台设备的读数,每次都要有人跑一趟车间才能看,这事你肯定不陌生。
我最近就撞上这么个场景:一台跑 Modbus 协议的设备,寄存器地址表写在纸质文档上,异地同事想核对"哪个地址对应哪个传感器、当前读数对不对",只能靠电话里念。念错了还得再跑一趟。折腾人,也没效率。
这篇不聊大系统,就做一个能在本机跑起来的小实验:用 Python 起一个本地 Modbus TCP 模拟从站产生寄存器数值,再写一个只读脚本把寄存器映射和实时读数渲染成网页,最后用 cpolar 的 TCP 隧道把端口暴露出去,让异地同事远程核对地址与曲线。
1 先说清楚:这篇能不能直接照做
需要先把边界说在前面,免得你看到一半发现对不上。
- 本文是一份隔离环境中的可执行实验提纲,用本机 Python 就能复现,不需要真实 PLC 或传感器。
- 本轮我没有实际安装、没有启动服务、没有开隧道、没有连任何真实设备,文中所有数值都是示例/模拟值,不是实测结果。
- 本文验证的是"寄存器只读映射 + 异地远程核对"这条链路,不是一个生产级的工业监控/SCADA 系统。
划重点:模拟从站是给实验用的,帮你把读取脚本、映射页、隧道这一整套链路先跑通。真上现场时,把127.0.0.1:5020换成现场设备的地址端口就行,代码逻辑不用大改。
2 环境准备:本机装好 Python 和 pymodbus
这事对硬件零要求,有 Python 就够了。
# 确认 Python 版本(3.9 以上都行,本机是 3.14.3) python3 --version # 建一个干净的虚拟环境,避免污染全局包 python3 -m venv ~/modbus-lab/venv source ~/modbus-lab/venv/bin/activate # 装两个依赖:pymodbus 负责模拟从站,Flask 负责渲染只读映射页 pip install pymodbus flask # 确认装上了 pip show pymodbus | head -n 2这里建议单独建虚拟环境,不要图省事直接pip install到系统里。后面万一要换 pymodbus 版本排错,删掉venv目录重来最干净,不会牵连别的项目。
pip show pymodbus能打印出版本号就说明装好了。如果报command not found,先检查虚拟环境有没有激活(命令行前面应该带(venv)字样)。
3 起一个本地 Modbus TCP 模拟从站
第一步先让"数据源"活起来。用 pymodbus 起一个 TCP 模拟从站,监听127.0.0.1:5020,预设几个寄存器的值。
# mock_slave.py —— 本地 Modbus TCP 模拟从站(骨架,来源:pymodbus 官方示例结构) from pymodbus.server import StartTcpServer from pymodbus.datastore import ( ModbusSequentialDataBlock, ModbusServerContext, ModbusSlaveContext, ) # 保持寄存器地址表(示例/模拟值): # 地址 0 -> 温度(×10),地址 1 -> 湿度(×10),地址 2 -> 转速(rpm),地址 3 -> 状态字 HOLDING_REGS = [235, 612, 1480, 1] def build_context(): # 0x00 起始,长度 100 的连续数据块,前 4 个位置填入示例值 block = ModbusSequentialDataBlock(0x00, HOLDING_REGS + [0] * 96) slave = ModbusSlaveContext(hr=block) return ModbusServerContext(slaves=slave, single=True) if __name__ == "__main__": context = build_context() print("Modbus TCP 模拟从站已监听 127.0.0.1:5020") StartTcpServer(context=context, address=("127.0.0.1", 5020))跑起来:
python3 mock_slave.py看到Modbus TCP 模拟从站已监听 127.0.0.1:5020这行输出,就说明从站起来了。注意这个进程会一直占着前台,别关它,另开一个终端做后面的操作。
为什么要用ModbusSequentialDataBlock:它是最省事的一种数据块,从你指定的起始地址开始连续铺开。实验阶段用连续块最容易核对——地址 0、1、2、3 直接对应列表前四项,不容易搞混。
如果起不来先查什么:一是看 5020 端口是不是被别的程序占了(lsof -i :5020);二是确认 pymodbus 装在了当前虚拟环境里。
4 写读取脚本:按功能码轮询寄存器,渲染只读映射页
数据源有了,现在写读取端。用 Modbus 功能码0x03(读保持寄存器)周期拉取数据,把"地址 - 名称 - 当前值 - 采集时间"四列渲染成一个网页,绑定127.0.0.1:8104。
# register_view.py —— 只读寄存器映射页(骨架) from datetime import datetime from flask import Flask from pymodbus.client import ModbusTcpClient APP = Flask(__name__) # 地址 -> 名称 的映射,和 mock_slave.py 里的顺序一致 REG_MAP = { 0: ("温度", 10.0), # 原始值 / 10 = 摄氏度 1: ("湿度", 10.0), # 原始值 / 10 = 百分比 2: ("转速", 1.0), # 原始值直接是 rpm 3: ("状态字", 1.0), } def read_registers(): client = ModbusTcpClient("127.0.0.1", port=5020) client.connect() # 功能码 0x03:从地址 0 起读 4 个保持寄存器 rr = client.read_holding_registers(address=0, count=4, slave=1) client.close() return rr.registers # 形如 [235, 612, 1480, 1] @APP.route("/") def index(): values = read_registers() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") rows = "" for addr, raw in enumerate(values): name, scale = REG_MAP.get(addr, ("未知", 1.0)) rows += f"<tr><td>{addr}</td><td>{name}</td><td>{raw / scale:.1f}</td><td>{now}</td></tr>" return f"""<html><head><meta charset="utf-8"><title>寄存器只读映射</title></head> <body><h3>寄存器只读映射(只读)</h3> <table border="1" cellpadding="6"> <tr><th>地址</th><th>名称</th><th>当前值</th><th>采集时间</th></tr> {rows} </table></body></html>""" if __name__ == "__main__": APP.run(host="127.0.0.1", port=8104)启动它:
python3 register_view.py浏览器打开http://127.0.0.1:8104,应该看到一张四列表格。这一步不是为了测试而测试,而是确认整条链路——从站产生数据、脚本读取、页面渲染——已经打通了。后面接隧道之前,先在本地把这一环看对。
几个关键点提前说:
- 页面只读。它只做
read_holding_registers读取,没有任何写线圈(功能码0x05)、写保持寄存器(功能码0x06)的入口。这是本文的硬边界。 slave=1是模拟从站的单元 ID,现场设备换成它实际的值。- 每刷新一次页面读一次数据。数据随采集时间变化,页面上的"采集时间"列会跟着走。
如果页面报错先查什么:八成是从站没起或者端口不对。先在另一个终端python3 mock_slave.py确认从站在跑,再回到这里刷新。如果连接超时,检查ModbusTcpClient里的端口是不是5020。
5 用 cpolar TCP 隧道暴露端口,给异地同事一个入口
本地能看了,接下来解决异地访问。Modbus 是 TCP 协议,不是网页,所以这里用的是cpolar 的 TCP 隧道,不是 HTTP 隧道——这点别搞混。
安装并登录 cpolar 后,直接:
cpolar tcp 8104终端会打印出一个公网地址(形如tcp://x.tcp.cpolar.cn:xxxxx)和转发信息。把这个公网地址和端口给异地同事,他那边用浏览器或 Modbus 客户端就能连到你这台机器上的只读映射页。
关于地址稳定性,口径要说准:
- 免费套餐给的是随机地址,24 小时内会变化,重启隧道地址就换。适合临时核对,不适合当作长期入口。
- 想要固定 TCP 端口,需要专业套餐及以上(专业套餐
149元/年,含 2 个保留 TCP 地址)。 - 如果你的场景其实是把映射页当成网页来看,也可以改成 HTTP 隧道
cpolar http 8104;想让二级子域名固定下来,需要基础套餐及以上。
这个区别很影响体验:现场核对如果只做一次,免费随机地址够用;如果是长期要有人随时看,那就得先想清楚用哪种套餐,免得下次地址变了又要重新发一遍。
安全边界必须强调:暴露出去的是只读映射页 / 只读数据。不要把可写通道暴露出去——写线圈、写保持寄存器这类操作一个都不要开。页面上没有写入口,隧道也只透传这一个只读端口,对方拿到的通道改不了任何设备状态。
如果公网地址打不开先查什么:先在本地确认http://127.0.0.1:8104自己能打开;再检查 cpolar 本地 Web UI(http://127.0.0.1:9200)里隧道是不是显示在线;最后才怀疑网络。
6 异地核对:对地址、看曲线
异地同事拿到入口后要做两件事:
- 核对地址与名称:拿着现场纸质文档,比对页面上的"地址 - 名称"两列。文档写地址 0 是温度,页面上地址 0 也是温度且数值合理,说明映射对了。
- 看读数变化:盯着"当前值"和"采集时间"两列,确认数值在随时间刷新,而不是卡死的静态值。
如果他看到数值一直不动,先回去查模拟从站里的值是不是本来就是常量——模拟从站预设的是固定值,除非你把HOLDING_REGS改成随时间变的逻辑,否则读数本身就是静止的。这点容易被误判成"页面坏了",其实是数据源不动。
7 验证越界写入被拒绝
只读这件事,得实际验证一下才算数。用功能码0x06(写单个保持寄存器)去试一次:
# try_write.py —— 验证写入类操作不被接受(骨架) from pymodbus.client import ModbusTcpClient client = ModbusTcpClient("127.0.0.1", port=5020) client.connect() # 尝试写入地址 0 的保持寄存器 wr = client.write_register(address=0, value=9999, slave=1) print("isError:", wr.isError()) client.close()注意:这一步测的是模拟从站对写入请求的响应,不是测映射页。映射页本身压根没有写入口,你从网页上是找不到任何写入按钮的。这条验证的意义在于确认"这条链路上不会悄悄接受写操作"。
如果wr.isError()打印False(即没有报错),说明模拟从站默认接受了这次写入——这恰恰提醒你:真实场景下,模拟从站和映射页都要把写入通道关掉,别把能写的端口一起暴露。这也是为什么本文坚持只暴露只读映射页,而不是直接暴露 5020 从站端口。
8 清理与收尾
实验做完,按顺序收干净:
# 停掉映射页和模拟从站:在各自终端 Ctrl+C # 关掉 cpolar 隧道:在隧道终端 Ctrl+C # 删掉整个实验目录和虚拟环境 rm -rf ~/modbus-lab隧道进程和从站进程都要关。尤其是隧道,别让它一直挂着——免费随机地址虽然会变,但在会话期间它就是一个对外的入口,用完就关更稳妥。
9 总结
到这一步,整套链路其实已经串起来了:本地用 pymodbus 起一个模拟从站产生寄存器数据,写个只读脚本按功能码轮询读取、渲染成映射页,再用 cpolar 的 TCP 隧道把端口暴露给异地同事核对地址和读数。核心的几个动作记住就行:
- 用
pymodbus起 Modbus TCP 模拟从站,监听127.0.0.1:5020,预设示例寄存器值; - 用
read_holding_registers(功能码0x03)读寄存器,渲染成绑定127.0.0.1:8104的只读映射页; - 用
cpolar tcp 8104暴露端口,并对写入类请求做验证,确保只读边界成立。
后面真要接到现场设备上,改动其实很小:把脚本里的127.0.0.1:5020换成现场设备的 IP 和端口,把地址映射表换成文档里的真实地址表就行。之所以先用模拟从站把这套流程跑通,是因为链路排查和映射核对这两件事最容易出错,先在本地确认好,上现场时才能一次对,而不是在车间里对着屏幕猜哪里出的问题。