手写 Modbus 上位机我踩过的 7 个工业级坑

0. 缘起

这个项目的需求一句话就能说完:把桥上的几根 PT100 温度传感器接到电脑上,画一条时间-温度曲线。

听起来简单得不像个正经项目。我当时也是这么想的。结果从读协议文档到界面跑顺,前后折腾了挺长时间。中间有三个坑,我翻遍了 CSDN、StackOverflow 和知乎,没一个人提到过------所以有了这篇文章。

最终交付的东西:一个单文件 Python 程序,约 1400 行(含内嵌图标数据),不依赖任何协议库;支持串口和网络两种连接方式;能画实时曲线、点一下读温度、断线自动重连、导出 CSV。

图1 上位机采集界面(实时曲线、读数光标、设备信息与统计)


1. 先把数据流看清楚

动手写代码前,得先知道数据是怎么从现场跑到屏幕上的:

复制代码
[PT100 传感器] → [温度变送器 OHR-215] ──RS485──┐
                                             ├→ [DTU 网关] ──网线/4G──→ [上位机程序]
[16 路采集器]  ──────────────RS485───────────┘

图2 系统硬件接线与数据流程(传感器 → 485 总线 → DTU → 上位机)

三层各干各的:

干什么 记住一件事就行
传感器/变送器 把温度变成数字 挂同一条 485 总线,靠从站地址区分彼此
DTU 网关 把串口字节流搬到网络上 透传------它不认 Modbus,只是个搬运工
上位机程序 发问、解析、画图 真正的 Modbus 主站,本文主角

一个省事的关键认知:DTU 只是把串口"延长"到了网上,Modbus 帧一个字节都没变。所以协议层写好之后,串口和网络两种连接方式可以共用同一套解析代码------这个后面会具体讲。


2. 手写 Modbus RTU

2.1 为什么不直接上 pymodbus

Python 下有 pymodbus,但我只需要读几个寄存器,自己写的账其实更划算:

  • 零依赖:除了 pyserial 什么都没引,以后打包成 exe 也干净;
  • 看得见:每一帧收发都能打印出来,现场出了怪帧、脏帧,能精确控制怎么处理。

2.2 读寄存器帧就 8 个字节

复制代码
[从站地址 1B][功能码 1B][起始寄存器 2B][寄存器数量 2B][CRC 2B]

CRC16 是这个协议唯一的"花样",多项式 0xA001低字节在前

python 复制代码
def crc16(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc

def build_read_frame(slave, start, count):
    body = bytes([slave, 0x03]) + struct.pack(">HH", start, count)
    return body + struct.pack("<H", crc16(body))   # CRC 是小端

写完怎么确认自己没写错?Modbus 规范里有个经典测试向量:对 11 03 006B 0003 这一帧,CRC 应该是 76 87。拿它一测,过了就说明 CRC 没问题:

python 复制代码
assert build_read_frame(0x11, 0x6B, 3).hex() == "1103006b00037687"

2.3 响应帧解析:坑都在"单位"上

响应帧长这样:

复制代码
[地址 1B][功能码 1B][字节数 1B][数据 N B][CRC 2B]

真正费神的是数据怎么解释。厂家的寄存器表通常是这么写的:

先说一个 Modbus 最著名的坑------地址从 0 还是从 1 开始 。协议在线路上传的是 0 起始地址,但厂家文档有好几种写法:有的写 0、1、2(0 起始),有的写 1、2、3(1 起始),还有 PLC 风格的 40001、40002(也是 1 起始)。偏移抄错了,读回来的全是错位的数据。 本文这台 OHR-215 是 0 起始,下面"寄存器 1、2"指的就是线上地址 0x0001、0x0002。

寄存器 内容 说明
1, 2 测量值 32位有符号长整型,先高后低,带 2 位小数
6 输出电流 16位有符号,3 位小数
8 冷端温度 16位有符号,1 位小数

顺带说个设计上的小决定:程序里我 一个寄存器一个寄存器地读,而是一次把 0~13 共 14 个寄存器 全读回来(OHR-215 手册规定单次最多读 24 个------注意这是设备自己的限制 ,Modbus 标准允许一次读 125 个)------一条帧就把温度、输出电流、冷端温度、传感器类型、单位、软件版本号全拿齐了。能用一次轮询解决的,就别发两次请求。

三个坑,一个比一个隐蔽:

① "先高后低"是说高字在前。 Modbus 寄存器是 16 位的,32 位的数要占两个寄存器。有些设备高字在前,有些低字在前,文档不写清楚能把你逼疯:

python 复制代码
# 先高后低:regs[1] 是高位,regs[2] 是低位
raw = struct.pack(">2H", regs[1], regs[2])
temp = struct.unpack(">i", raw)[0] / 100.0

② "带 2 位小数"是缩放,不是真小数。 寄存器里传的是整数 2563,真实温度是 25.63℃。而且负温度 必须按有符号数解包,0xFFFFFE0C-500,也就是 -5.00℃------用无符号解出来你会得到一个巨大的正数。

③ 16 位数据的补码要自己转

python 复制代码
def signed16(v):
    return v - 0x10000 if v >= 0x8000 else v

2.4 异常帧

从站干不了你的请求时,回的不是正常帧,而是功能码最高位置 1

python 复制代码
if resp[1] == 0x03 | 0x80:      # 0x83
    raise CommError(f"仪表返回错误 {resp[2]}")

错误码这里有个容易踩的混淆点:Modbus 标准 的异常码是 01=非法功能码、02=非法数据地址、03=非法数据值、04=从站故障;但 OHR-215 自己定义了一套------1=寄存器长度超限、2=地址超限、3=密码保护、4=读写不允许。别拿标准的 01~04 去套设备自己的表,一切以设备手册为准。

2.5 值得养成的好习惯:没有硬件先自检

这是我最想推荐的一个实践 。协议层写完时硬件还没到,我写了个 --selftest 模式,用假串口喂预置的响应帧,把整条解析链路先测一遍:

python 复制代码
class FakePort:
    def __init__(self, resp): self._resp = resp
    def write(self, data): pass
    def read(self, n): return self._resp[:n]

覆盖正常帧、25.00℃、-5.00℃(负温度)、CRC 错误、超时无响应这五种情况。好处立竿见影:硬件到货之后第一次连线就通了,因为协议层已经过了自检;真出了问题也能立刻排除"是不是我代码写错了"。

2.6 不知道从站地址怎么办:扫描

现场设备有时是二手的、或被人改过地址,文档写的是 1,实际却是 23。与其瞎猜,不如让程序自己扫:从 1 到 247 挨个发"读软件版本号"的请求(读寄存器 0,1 个),谁回应谁就是它的地址。串口和 TCP 客户端模式下都能扫,几十秒跑完 247 个地址。

其实这份协议文档里还留了个更快的后门------固定栈址 249 :当不确定仪表地址时,可以直接用地址 249 读/写它的"设备地址"寄存器(地址 24),等于官方给的捷径。不过有个前提:总线上只挂这一台设备时才能这么干,挂多台会一起应答、互相冲突。


3. DTU 组网:最容易配错的一层

3.1 透传到底是什么意思

DTU 不解析 Modbus。它干的事就一件:串口收到什么就往 TCP 发什么,TCP 收到什么就往串口发什么。 所以 Modbus 主站的角色,仍然由你的上位机承担。

3.2 角色配对,一正一反

DTU 和上位机都有"客户端/服务器"两种角色,必须一个主动、一个等待

场景 DTU 上位机
局域网直连 TCP Server(监听) TCP Client(主动连)
4G 远程 TCP Client(外连) TCP Server(监听)

为什么 4G 必须让 DTU 当客户端:4G 设备躲在运营商内网里,没有公网可达的地址------别人敲不到它的门,它只能自己出门去敲别人。所以远程场景下,上位机监听、DTU 主动连入,这个方向是死的。

但这只是问题的一半。另一半是:上位机那一侧也得能从公网被访问到。 如果你的电脑也在办公网/家庭宽带后面(没有公网 IP),DTU 出门了照样找不到你。要么申请公网 IP,要么用内网穿透(frp 之类),要么租台云服务器当中转------别以为"DTU 设成客户端"远程就通了。

3.3 WinError 10061,新手必踩

配好之后点连接,报:

复制代码
[WinError 10061] 由于目标计算机积极拒绝,无法连接。

原因蠢得我想笑:我把两头都配成了客户端。 DTU 出门找人了,程序也出门找人了,9000 端口后面根本没人监听,操作系统直接 RST 拒绝。改成"程序当服务器、DTU 当客户端",立马就通。

怎么记住这个配对?把它想成打电话:服务器是开店等人上门的,客户端是拿着地址上门找人的。两个都上门的,永远碰不上。

3.4 还有两个小坑

  • 防火墙 :程序当服务器时,Windows 第一次会弹窗,必须点允许。要是不小心点了取消就很隐蔽了------DTU 显示连不上、程序显示等待接入,两头都看不出是防火墙在作怪,手动放行端口才能解决。
  • 电脑 IP 会变:DTU 当客户端时,服务器地址填的是电脑 IP。电脑的 IP 是路由器 DHCP 分的,重启就可能变,一变 DTU 就连不上。长期跑要么给电脑设静态 IP,要么在路由器做 IP-MAC 绑定。

4. 上位机架构

4.1 传输层抽象:一套协议代码,两种连接

既然 Modbus 帧在串口和 TCP 上长得一样,那就把"怎么收发字节"抽出来:

python 复制代码
class SerialTransport:       # pyserial
    def write(self, data): self.ser.write(data)
    def read(self, n):     return self.ser.read(n)

class TcpClientTransport:    # socket,主动连
    def write(self, data): self.sock.sendall(data)
    def read(self, n):     ...   # 带缓冲的定长读取

协议层的 read_registers(io, ...) 只认 writeread 两个方法。以后加一种连接方式,协议层一个字都不用动。

4.2 TCP 为什么必须自己做缓冲

串口的 read(n) 会老实等你凑够 n 个字节(或超时)。但 TCP 是recv() 没有边界概念------它可能只给你半个帧,也可能一次给你一帧半(这就是常说的粘包/拆包)。所以要自己维护一个接收缓冲区:

python 复制代码
def read(self, n):
    deadline = time.monotonic() + self.timeout
    while len(self.rxbuf) < n:
        remain = deadline - time.monotonic()
        if remain <= 0:
            break                          # 超时:返回已收到的部分
        self.sock.settimeout(min(remain, 0.5))
        try:
            chunk = self.sock.recv(4096)
        except socket.timeout:
            continue                        # 还没到 deadline,继续等
        if not chunk:
            raise ConnectionError("对端已断开")
        self.rxbuf += chunk
    out, self.rxbuf = self.rxbuf[:n], self.rxbuf[n:]
    return out

两个细节别漏:① recv 可能抛 socket.timeout,要接住继续等;② 真到 deadline 了,就返回已收到的部分 (短读),交给上层的 read_registers 判断"响应不完整"再报错------这样和 pyserial 的 read(n) 行为保持一致。

4.3 采集线程 + 队列,别跨线程碰 tkinter

采集跑后台线程,UI 在主线程。tkinter 不是线程安全的,后台线程直接改界面会随机崩。标准做法是队列:

python 复制代码
# 后台线程:只往队列塞数据
self.q.put(("data", record))

# 主线程:定时把队列里的东西消费掉
def _drain_queue(self):
    try:
        while True:
            msg = self.msg_q.get_nowait()
            ...
    except queue.Empty:        # 队列空了就退出循环
        pass
    self.after(100, self._drain_queue)

get_nowait() 在队列空时会抛 queue.Empty------while True 正是靠这个异常退出的,别忘了接住。

我自己就踩过:扫描地址的线程里调了 self.tcp_var.get() 去读输入框,直接报 main thread is not in main loop。修法是在主线程先把参数取好,再传给工作线程。记住一条:tkinter 的变量只能在主线程碰。

整个多线程的时序关系,画成图是这样:

图3 上位机多线程采集流程(采集线程 → 队列 → 主线程 UI 刷新)

4.4 读数光标

用起来很舒服的一个功能:点曲线上任意位置,出现竖线 + 交点圆点 + 温度标注;按住拖动能连续移动;曲线滚动时光标跟着数据走;点视口外就消失。

有个实现细节值得记:光标锚定的是"数据点在数组里的绝对序号",不是时间坐标。 因为数据会滚动、旧点会被截断,用时间锚定会漂。用绝对序号再加一个记录截断偏移的 _base,追踪就稳了,被截断淘汰的点会自动清掉光标。


5. 踩坑合集:tkinter + matplotlib 的深水区

这部分是写这篇博客的主要原因------这几个坑中文资料几乎为零。

坑 1(最硬):图不占满画布,坐标轴缩在角落

现象:窗口拉大后,曲线区四周一大片空白,坐标轴缩在角落,时间标签被裁掉。

我当时的排查过程,堪称反面教材 :第一反应是"布局没算对",于是开始折腾 subplots_adjust、换成 constrained_layout、自己写尺寸同步代码......全都没用。更气人的是------我这边测试怎么都对,一上目标机器就崩。

最后翻开 matplotlib 源码才看明白。TkAgg 后端在初始化时,自己绑定过画布尺寸变化的处理器:

python 复制代码
# matplotlib/backends/_backend_tk.py
self._tkcanvas.bind("<Configure>", self.resize)

它的 resize() 干的是三件事:

python 复制代码
def resize(self, event):
    self.figure.set_size_inches(width/dpi, height/dpi, forward=False)
    self._tkcanvas.delete(self._tkcanvas_image_region)
    self._tkphoto.configure(width=int(width), height=int(height))   # 重建位图!
    self._tkcanvas.create_image(...)

而我为了"修复"尺寸不同步,自己加了一个绑定:

python 复制代码
# 错误示范
self.canvas.get_tk_widget().bind("<Configure>", self._on_fig_resize)

没加 add="+",这行直接把 matplotlib 自己的处理器覆盖掉了。 我自己的处理器只做了第一步(设 figure 尺寸),没重建 _tkphoto 位图------于是位图停在初始尺寸,figure 逻辑尺寸和画布错位,constrained 按错误尺寸算边距,坐标轴就被挤进了角落。

修复:删掉这个绑定,让 TkAgg 自己管。

教训就两条,但都值钱:

  1. 绑定 tkinter 事件,不想覆盖已有处理器,必须加 add="+"
  2. 动手 bind 之前先想想:框架是不是已经在处理这件事了?官方代码放在那儿,通常有它的道理。

坑 2:多显示器缩放,渲染尺寸错位

现象 :同样"图不占满画布"。诊断时发现 fig.dpi = 125,画布却只有 815 逻辑像素------位图比画布大了 25%,多出来的部分被裁掉。

原因 :我主屏 125%、副屏 100%,而 Tk 8.6 只认系统级 DPI(主屏的 125%),把它用到了所有窗口上。

修复两步:

python 复制代码
# 1. 启用"逐显示器 DPI 感知"
ctypes.windll.shcore.SetProcessDpiAwareness(2)

# 2. 按窗口所在显示器的真实 DPI 校正 Tk 缩放系数
hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
dpi = ctypes.windll.user32.GetDpiForWindow(hwnd)
self.tk.call("tk", "scaling", dpi / 72.0)    # Tk 的 scaling 以 72 为基准

坑 3:pack 顺序决定谁被挤没

现象:加了底部状态栏,结果看不见。

原因 :tkinter 的 pack 是先到先得 。我把状态栏写在了图表区之后,而图表区带 expand=True 会吃掉全部剩余空间------轮到状态栏时已经没有高度可分,高度为 0。

修复 :把状态栏的 pack() 挪到图表区之前,先占住底部那一行。

同类坑 :工具栏上我按顺序 pack 了"串口框 → TCP 框 → 按钮",结果中途 pack_forget() 之后再 pack 的 TCP 框跑到了按钮右边。因为 pack(side="left")按调用顺序排列的,重新 pack 会追加到末尾。修法是引入一个"槽位容器",把三种连接模式的控件组都放进去。

坑 4:工具栏超宽被裁掉

窗口宽度固定,控件一多就超出可视区,右边按钮直接看不见。

修复思路不是压缩控件,而是重新分配职责 :把"暂停采集 / 清空数据 / 导出 CSV"这三个数据操作 按钮从工具栏挪到右侧信息面板,工具栏只留连接相关的控件。职责分开之后,任何模式下都放得下。

坑 5:Tk 不会自动居中窗口

geometry("1180x720") 只给了尺寸没给坐标,Windows 就按自己的默认规则摆在左上区域。

修复:启动时算工作区中心,显式设坐标。注意用 SPI_GETWORKAREA去掉任务栏后的可用区域,不然窗口会被任务栏遮一条边:

python 复制代码
ok = ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0)

6. 没有硬件怎么测:写一个假 DTU

说实话,写个"假 DTU"是我这半年最值的 20 行代码。原理很简单:一个 TCP 服务端,收到 Modbus 请求帧就按协议构造一个合法响应发回去:

python 复制代码
def make_response(req, temp):
    slave, fc, start, count = req[0], req[1], *struct.unpack(">HH", req[2:6])
    regs = [...]                    # 按寄存器表填
    payload = struct.pack(f">{count}H", *regs)
    resp = bytes([slave, 3, count*2]) + payload
    return resp + struct.pack("<H", crc16(resp))

有了它,我在没有传感器、没有 DTU 的情况下端到端测完了:TCP 客户端采集、拔线重连、服务器模式等待接入、DTU 掉线后重新接入、扫描从站地址。真实硬件到货之前,整个软件骨架已经验证过了。


7. 现状与局限

已经跑通的

  • 协议层:Modbus RTU 手写实现,自检全过
  • 连接层:串口 / TCP客户端 / TCP服务器,断线自动重连
  • 界面:实时曲线、读数光标、设备信息、统计、CSV 导出
  • 真实硬件上长时间运行零通讯错误

还欠着的(也是下一步):

  1. 数据不落盘------都在内存里,关程序就没了,得加数据库(SQLite 就够);
  2. 没有告警------超限判断、记录、推送都还没有,这才是监测系统的核心价值;
  3. 设备档案写死了------目前硬编码了 OHR-215 的寄存器表,总线上再挂别的设备(比如 16 路采集器)得做成配置化;
  4. 没做心跳包剔除------现在靠 CRC 兜底,遇到心跳字节就丢一帧然后自动恢复,长期运行应该主动识别剔除。

8. 下一步:从"监测"到"控温"

这个项目最终想做的不是监测,是控制------根据实测温度调水阀,把温度(尤其是升降温速率)控在规范限值内。

难点在于:FEM 模型的参数不准,实测的传感器也可能故障,两边都不可全信。我的思路是用数据同化(贝叶斯融合)让两边互相校核,输出带置信区间的温度场,同时反演模型里不准的参数,再用带约束的 MPC 做决策。这部分还在进行中,等有结果了单独写一篇。


附:技术栈

  • Python 3.11 + tkinter + matplotlib + pyserial
  • 硬件:OHR-215 温度变送器(RS485/Modbus RTU)、TAS-IT-692F1 DTU
  • 代码:单文件约 1400 行,无协议库依赖

如果你也在做类似的东西,欢迎交流。踩过的坑,就别再踩一遍了。

相关推荐
dalong101 天前
WPF:Modbus 状态监控
wpf·modbus
AlanBruce7 天前
摩尔信使MThings EdgeWeb使用指南
上位机·plc·modbus·mthings·摩尔信使
AlanBruce7 天前
摩尔信使MThings逻辑控制各组件使用指南
自动化·上位机·modbus·mthings·摩尔信使
AlanBruce7 天前
摩尔信使MThings DL/T 698.45数据配置使用指南
上位机·plc·modbus·mthings·摩尔信使
AlanBruce7 天前
摩尔信使MThings CJ/T 188数据配置使用指南
自动化·modbus·摩尔信使
合天网安实验室11 天前
Modbus协议及其取证的学习笔记
ctf·modbus·通信协议·取证
2601_9623818611 天前
Modbus地址40001就是0x0000?我干了8年,发现80%工程师都搞错
错误·modbus·工程师·地址·工业协议
仰科网关18 天前
采集opc da 服务器数据 转 EthernetIP项目案例
网关·modbus·协议转换·规约转换器
@嵌入式扫地僧18 天前
RK2108 实现 Modbus 异常流量 100μs 级检测全流程
tcp·modbus·嵌入式ai·边缘ai·tcn-tiny