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, ...) 只认 write 和 read 两个方法。以后加一种连接方式,协议层一个字都不用动。
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 自己管。
教训就两条,但都值钱:
- 绑定 tkinter 事件,不想覆盖已有处理器,必须加
add="+"; - 动手 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 导出
- 真实硬件上长时间运行零通讯错误
还欠着的(也是下一步):
- 数据不落盘------都在内存里,关程序就没了,得加数据库(SQLite 就够);
- 没有告警------超限判断、记录、推送都还没有,这才是监测系统的核心价值;
- 设备档案写死了------目前硬编码了 OHR-215 的寄存器表,总线上再挂别的设备(比如 16 路采集器)得做成配置化;
- 没做心跳包剔除------现在靠 CRC 兜底,遇到心跳字节就丢一帧然后自动恢复,长期运行应该主动识别剔除。
8. 下一步:从"监测"到"控温"
这个项目最终想做的不是监测,是控制------根据实测温度调水阀,把温度(尤其是升降温速率)控在规范限值内。
难点在于:FEM 模型的参数不准,实测的传感器也可能故障,两边都不可全信。我的思路是用数据同化(贝叶斯融合)让两边互相校核,输出带置信区间的温度场,同时反演模型里不准的参数,再用带约束的 MPC 做决策。这部分还在进行中,等有结果了单独写一篇。
附:技术栈
- Python 3.11 + tkinter + matplotlib + pyserial
- 硬件:OHR-215 温度变送器(RS485/Modbus RTU)、TAS-IT-692F1 DTU
- 代码:单文件约 1400 行,无协议库依赖
如果你也在做类似的东西,欢迎交流。踩过的坑,就别再踩一遍了。