1. 先搞清楚这条抄表链路上每个角色在干什么
我给一个工业园区做能耗采集的时候,第一次把电能表的 485 口接到笔记本上,串口助手发了半小时报文,屏幕上一直是一片空白。后来查了半天,发现是把 A、B 两根线接反了——这事听起来很低级,但在现场它几乎是最常见的故障。485 通信远程抄表这件事,说穿了就是把电能表当成一个只认报文的小服务器,上位机按规约问它一句,它按规约回一句,回的这句话里就藏着我们想要的千瓦时数据。道理确实不复杂,但真正落地的时候,硬件、协议、字节序、数据换算、轮询调度,每一层都有坑。这篇东西我打算把整条链路从前到后拆一遍,包括 485 通信的电气层怎么接、DL/T 645 和 Modbus RTU 两种主流规约的报文怎么组、千瓦时数据里的倍率和小数位怎么算、上位机代码怎么写、现场排障怎么查。适合刚接手能耗采集项目的开发者,也适合做弱电施工、智能楼宇、配电房改造的同行参考,哪怕你之前完全没碰过串口,跟着走一遍也能跑通。
1.1 485 通信到底解决了什么问题
先讲个背景。电能表本身不是哑巴设备,它一直在自己算电量,累计的正向有功总电能就摆在内部寄存器里,只是它没有屏幕之外的"出口"。早年的人工抄表就是靠人眼看红外或者 LED 轮显,一个月抄一次,效率低还容易抄错。而 RS-485 干的事情,是把多台设备挂到同一对差分线上,用一根线把几十台表串起来,上位机挨个叫号,谁被叫到谁应答。它的电气特性决定了两个关键优势:一是差分传输抗干扰,工业现场变频器、接触器乱飞的电噪声对它的影响比单端信号小得多;二是总线型拓扑,一台主机最多能带 32 个标准负载(加中继或者用低功耗收发器可以到 128 甚至 256 个节点),布线成本被摊薄得很厉害。
我个人的经验是,园区场景里 485 依然是性价比最高的方案,不用给每台表配网络模块,不用组无线网,一根屏蔽双绞线走桥架,末端挂 120 欧终端电阻,几百米的距离稳稳的。当然它也有代价:半双工、单主机轮询、带宽只有几 kbps 到几十 kbps,你要是想秒级采集上千台表,那就得考虑分多条总线或者换成载波、无线方案,这个后面我会细说。
1.2 电能表的两种主流规约:DL/T 645 与 Modbus RTU
把物理链路打通之后,紧接着的问题是"说什么话"。国内的电能表基本跑两种规约:一种是 DL/T 645,这是电力行业标准,国内电表最常见的"母语",目前主流是 2007 版,老一点的现场还能碰到 1997 版;另一种是 Modbus RTU,属于通用工业协议,很多多功能电力仪表、导轨表、出口型号都支持。
这两种规约的差异非常实际。DL/T 645 的地址是 6 字节 BCD 表号(12 位十进制数字),一表一号,天然不会冲突,报文里还做了加 0x33 的偏移处理来规避帧头帧尾冲突,安全性和规范性更好;Modbus RTU 地址是 1 字节从站号,范围 1 到 247,需要你手动规划,注册表地址各家厂商自定义,读电能的寄存器位置各不相同,好处是通用,你手上一堆不同品牌的仪表可以统一用一套代码去读。选型上我的建议很直白:纯国网/南网口径的电表,优先按 DL/T 645 走;如果是配电柜里那种带液晶屏、支持多种协议的多功能表,用 Modbus 更省事,因为手册给得清楚,寄存器映射一目了然。
1.3 千瓦时数据里藏着的小数位和倍率
很多人第一次读到电能值时会懵:为什么读回来的数除以 100 才是对的?为什么有的表读回来要乘 10?这背后是数据格式约定。DL/T 645 里电能类数据的格式通常写成 XXXXXX.XX,也就是 6 位整数 2 位小数,单位是 kWh,报文里是 6 个字节的 BCD 码,低位在前。也就是说你读到的原始整数是 12345678,它代表的其实是 123456.78 kWh。而 Modbus 仪表更乱一些,有的用 32 位无符号整数、单位 0.01 kWh,有的用 0.1 kWh,还有的用 16 位整数、单位 1 kWh,甚至要配合 CT 变比再做一次乘法。
我踩过最深的坑就在这里:一个项目里三相表和单相表混装,单相表读回来的值看着正常,三相表读回来的数值小了 40 倍,查了两天才发现是 CT 变比没有参与换算,一次侧电流是 200A,二次侧是 5A,倍率 40 倍被漏掉了。所以千瓦时数据这一环,永远记住三件事:字节序、单位刻度、变比。这三样搞错任何一样,后台的电费单就是错的,而错的电费单比没有电费单更麻烦。
2. 硬件连接与串口参数:最容易翻车的地方
电气层是整个系统里最"物理"的一层,也是新手最容易忽略的一层。软件写错了可以改,线接错了、电阻装错了、屏蔽没接地,表现出来往往就是"时通时不通"这种最恶心的现象——白天好好的,晚上一开大功率设备就丢包。
2.1 线材选择、拓扑结构与终端电阻
485 总线必须是手拉手菊花链拓扑,绝对不能走星型或者树形。星型拓扑会让每个分支上的反射信号叠加,短距离可能没事,一旦超过几十米,通信错误率就上去了。我就见过一个现场图省事,从配电房引一根线出来,在每个表箱处"T"接一下,结果最远的那台表永远读不到数据,把拓扑改成串下去之后立刻就通了。
线材上建议用屏蔽双绞线,比如 RVSP 2×1.0 或者 2×0.75,绞距越密越好,屏蔽层单端接地(一般接主机侧),两端都接地反而容易形成地环流引入干扰。终端电阻这件事有个常见误区:120 欧不是每台设备都接,而是只在总线的两个物理端点各接一个。中间节点的表如果自带终端电阻拨码,一定要确认是关掉的,我见过三台表同时开了终端电阻,整条总线的等效阻抗掉到 40 欧,驱动能力直接崩掉,报文全是乱码。
| 项目 | 推荐做法 | 常见错误 |
|---|---|---|
| 拓扑 | 手拉手菊花链 | 星型、树形、T 型分支 |
| 线材 | 屏蔽双绞线 RVSP | 平行普通电线、网线单芯 |
| 终端电阻 | 仅两端各 120Ω | 每台设备都接、全都不接 |
| 屏蔽层 | 单端接地(主机侧) | 两端接地、悬空不接 |
| 布线 | 远离动力电缆 30cm 以上 | 与 380V 动力线同槽捆扎 |
注意:如果总线长度超过 500 米,或者节点数超过 32 个,建议加 485 中继器分段,不要硬撑。传输距离和波特率是反比关系,2400bps 能跑 1200 米,9600bps 通常只有 1200 米理论值的几分之一,实际工程里 800 米左右就该考虑中继了。
2.2 串口参数:2400 8E1 还是 9600 8N1
串口参数不对,一切白搭。DL/T 645-2007 规定电表默认通信速率是 2400bps,偶校验,8 位数据位,1 位停止位,也就是常说的 2400 8E1。但注意,这只是"默认",相当多的表支持 1200、2400、4800、9600 多档速率,出厂设置五花八门,尤其是改造项目里拆下来的旧表。Modbus 的仪表更灵活,9600 8N1 最常见,也有 19200、38400 的。
我的做法是先在笔记本上用串口调试工具,把常见参数组合各试一遍,找到能收到正常应答的那一组,再写进代码。这里有个小技巧:DL/T 645 的电表在收到正确地址但参数不符的报文时,往往不是完全不回,而是回一串乱码,这串乱码本身就是"我听到了但你速率不对"的信号,这时候就往下换一档速率试。如果完全没有回音,那更可能是接线或者地址的问题。
2.3 地址规划与共地问题
DL/T 645 的表号一般是出厂烧录的 12 位数字,通常和资产编号、出厂编号有关,你很难改,所以规划的重点是记录和映射,不是修改。建一个台账表,把表号、安装位置、用途、倍率、初始读数列清楚,这个台账在后面排查数据异常的时候能救命。Modbus 的从站号可以改,规划原则是同一总线上不重复,建议从 1 开始连续编,同时避开厂商保留号。
共地这个话题争议比较大。485 是差分信号,理论上不需要共地,A、B 之间的电压差就能表达逻辑。但实际工程中,如果两台设备的电源系统完全独立、地电位差超过收发器的共模范围(-7V 到 +12V),芯片就可能被击穿或者误判。所以跨配电房、跨楼栋的长距离布线,我建议用带隔离的 485 转换器或者隔离型采集器,成本多几十块,但能省掉一次炸芯片的麻烦。
3. 报文层面拆解:一次完整的抄表交互
链路通了,参数对了,接下来就是"话术"。这部分我拆得细一点,因为报文格式是抄表的核心,看不懂报文,后面的排障就是瞎猜。
3.1 DL/T 645-2007 读数据帧结构
一条完整的 DL/T 645 读数据请求帧,逐字节是这样组织的:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 1 | 68H | 帧起始符 |
| 2-7 | A0-A5 | 表号,6 字节 BCD,低位在前 |
| 8 | 68H | 第二个起始符 |
| 9 | 控制码 | 读数据为 11H |
| 10 | 数据域长度 | 通常为 04H |
| 11-14 | 数据标识 DI | 4 字节,低位在前,各字节加 33H |
| 15 | 校验和 | 从第一个 68H 到校验和之前,所有字节模 256 求和 |
| 16 | 16H | 帧结束符 |
举个具体例子,读表号 000000000001 的正向有功总电能,数据标识 00010000:
68 01 00 00 00 00 00 68 11 04 33 33 34 33 DF 16DI 的处理逻辑是:00010000 按字节低到高拆成 00 01 00 00,反转成 00 00 01 00,每个字节加 0x33 得到 33 33 34 33。这个加 33H 的操作在标准里叫"数据域加 33H 处理",目的就是防止数据内容里出现 68H 或 16H 造成帧边界误判。应答帧里数据域同样做了这层偏移,你收到之后必须先减 33H 才能还原真实数据。
3.2 异常应答与错误码
应答帧的控制码是请求控制码的最高位置 1,也就是 11H 变成 91H。如果表返回的是 D1H,说明它听懂了请求但拒绝了,这时候数据域里是 1 字节错误码。错误码是位标志,含义如下:
| 位 | 含义 |
|---|---|
| bit0 | 其他错误 |
| bit1 | 无请求数据 |
| bit2 | 密码错误/未授权 |
| bit3 | 通信速率不能更改 |
| bit4 | 年时区数超 |
| bit5 | 日时段数超 |
| bit6 | 费率数超 |
我遇到最多的是 bit1"无请求数据",一般有两种原因:一是数据标识写错了,比如你想读无功却发了个不存在的 DI;二是表的费率套数不支持你请求的费率号。这种情况不要去怀疑接线,直接翻表的手册核对 DI 表。
3.3 Modbus RTU 读保持寄存器示例
Modbus 这边的报文更紧凑。读保持寄存器用功能码 03H,请求帧:从站号 + 03 + 起始地址高 + 起始地址低 + 寄存器数量高 + 寄存器数量低 + CRC 低 + CRC 高。比如读 1 号从站、起始地址 0x001E、读 2 个寄存器:
01 03 00 1E 00 02 xx xxCRC16 的计算是多边形 0xA001 的循环冗余,初值 0xFFFF,最终结果低字节在前。应答帧是:从站号 + 03 + 字节数 + 数据 + CRC。电能寄存器通常是 32 位,占两个 16 位寄存器,高字在前,你拿到之后要拼成 32 位整数再乘刻度。
Modbus 的坑在于每家厂商的寄存器表都不一样。有的表把电能放在 0x0000,有的放在 0x001E,有的放在 0x0128,而且同一个地址在不同型号上含义可能完全不同。我见过有人从网上抄了一份寄存器表直接套,结果把电压值当成了电量,后台跑了一个月才发现,数据全部要重算。寄存器地址一定要以手上这批表的官方手册为准,这是没有捷径的。
3.4 两种规约的横向对比
| 对比项 | DL/T 645-2007 | Modbus RTU |
|---|---|---|
| 地址长度 | 6 字节 BCD 表号 | 1 字节,1-247 |
| 校验方式 | 累加和 | CRC16 |
| 数据编码 | BCD,加 33H 偏移 | 二进制 |
| 数据标识 | 标准化 DI 表 | 厂商自定义寄存器 |
| 默认速率 | 2400 8E1 | 9600 8N1 居多 |
| 帧长上限 | 约 200 字节 | 256 字节 |
| 适合场景 | 国网口径电表 | 通用工业仪表 |
实际项目里我的习惯是写一个抽象层,把两种规约封装成同一个read_energy(meter)接口,上层业务代码不关心底下走的是哪种协议。这样一来,一个项目里混用两种表也不用改业务逻辑,扩展性会好很多。
4. 代码落地:从串口到数据入库
理论讲完了,动手写点能跑的东西。下面的代码都是我用过的简化版本,Python 环境,依赖 pyserial,可以直接复制去改。
4.1 串口打开与基础收发封装
import serial import time class SerialPort: def __init__(self, port, baudrate=2400, bytesize=8, parity='E', stopbits=1, timeout=1.0): self.ser = serial.Serial( port=port, baudrate=baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=timeout, ) def send(self, frame: bytes, wait=0.3): self.ser.reset_input_buffer() self.ser.write(frame) self.ser.flush() time.sleep(wait) return self.ser.read(self.ser.in_waiting or 128)这里有个细节值得说:reset_input_buffer()必须放在发送之前,否则上一轮轮询残留的字节会混进本次应答里,解析的时候就会莫名其妙地失败。time.sleep(wait)是为了等表把整帧数据吐完,2400bps 下传输一个 16 字节的应答大概需要 70ms,留 300ms 比较稳妥。有些开发者为了追求轮询速度把这个值调到 50ms,结果就是随机丢帧,得不偿失。
4.2 DL/T 645 帧的构造与解析
def dl645_checksum(data: bytes) -> int: return sum(data) & 0xFF def build_dl645_read(addr: str, di: str = "00010000", ctrl: int = 0x11) -> bytes: addr_bytes = bytes.fromhex(addr)[::-1] # 表号低位在前 di_bytes = bytes.fromhex(di)[::-1] # DI 低位在前 di_shift = bytes((b + 0x33) & 0xFF for b in di_bytes) body = b"\x68" + addr_bytes + b"\x68" + bytes([ctrl, len(di_shift)]) + di_shift return body + bytes([dl645_checksum(body)]) + b"\x16" def bcd_le_to_int(data: bytes) -> int: """小端 BCD 转整数,每字节高半字节是十位,低半字节是个位""" val = 0 for i, b in enumerate(data): hi, lo = b >> 4, b & 0x0F if hi > 9 or lo > 9: raise ValueError(f"非法 BCD 字节: {b:02X}") val += (hi * 10 + lo) * (100 ** i) return val def parse_dl645_energy(resp: bytes, scale=100.0) -> float: if len(resp) < 16 or resp[0] != 0x68 or resp[7] != 0x68: raise ValueError("帧头不合法") ctrl = resp[8] length = resp[9] data = resp[10:10 + length] cs = resp[10 + length] if dl645_checksum(resp[:10 + length]) != cs: raise ValueError("校验和错误") if ctrl & 0x40: raise RuntimeError(f"电表异常应答,错误码 {data[0]:#04x}") payload = bytes((b - 0x33) & 0xFF for b in data) # 2007 版应答数据域一般是 DI(4字节) + 数据(N字节) value_bytes = payload[4:] if len(payload) >= 10 else payload raw = bcd_le_to_int(value_bytes) return raw / scale关于payload[4:]这个判断,我要特别说明一下:标准规定读数据应答的数据域是"数据标识 DI + 数据",但确实有部分厂商的表不返回 DI,直接给数据。所以代码里做了长度判断。如果你发现读出来的值明显错得离谱,先打印原始 payload 看看长度,就知道是哪一种了。刻度scale默认 100,对应 XXXXXX.XX 格式,也就是两位小数;如果表是 XXXX.XX(4 位整数 2 位小数),同样是 6 字节 BCD,scale 依然取 100,只是整数部分位数的差异。真正需要改 scale 的是那些单位不是 0.01 kWh 的表。
4.3 Modbus RTU 的 CRC 与读取实现
def crc16_modbus(data: bytes) -> bytes: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) def build_modbus_read(slave: int, start: int, count: int) -> bytes: body = bytes([slave, 0x03, start >> 8, start & 0xFF, count >> 8, count & 0xFF]) return body + crc16_modbus(body) def parse_modbus_regs(resp: bytes): if len(resp) < 5: raise ValueError("应答帧过短") slave, func, byte_count = resp[0], resp[1], resp[2] if func & 0x80: raise RuntimeError(f"Modbus 异常码 {resp[2]:#04x}") if crc16_modbus(resp[:-2]) != resp[-2:]: raise ValueError("CRC 校验失败") data = resp[3:3 + byte_count] regs = [int.from_bytes(data[i:i + 2], "big") for i in range(0, len(data), 2)] return regs def modbus_energy(resp: bytes, start_index=0, scale=100.0, ratio=1.0) -> float: regs = parse_modbus_regs(resp) raw = (regs[start_index] << 16) | regs[start_index + 1] return raw * scale * ratioratio这个参数就是 CT/PT 变比,一次侧 200A、二次侧 5A 的场景下它就是 40。我强烈建议把这个参数做成配置项而不是写死在代码里,因为同一个型号的表装在不同回路,变比可能完全不同。配置化的另一个好处是审计方便,出了问题能立刻定位是配置错了还是设备错了。
4.4 轮询调度与并发策略
单条 485 总线上的设备是共享介质,必须严格串行轮询,不能并发。所以"并发"这个词在 485 场景下的真正含义是多总线并行:每台表按安装位置分组挂到不同的物理总线上,上位机开多个线程,每个线程管一条总线,总线之间互不干扰,整体采集周期就能大幅压缩。
下面是我常用的轮询骨架:
import threading def poll_bus(bus_id, port, meters, interval=60): sp = SerialPort(port, baudrate=2400, parity='E') while True: for m in meters: for attempt in range(3): try: frame = build_dl645_read(m["addr"]) resp = sp.send(frame) kwh = parse_dl645_energy(resp) save_record(m["addr"], kwh, bus_id) break except Exception as e: log_warn(f"bus={bus_id} addr={m['addr']} 第{attempt+1}次失败: {e}") time.sleep(0.2) time.sleep(0.05) time.sleep(interval)三个要点:一是失败重试三次,这是应对瞬时干扰的必要手段,实测能挽回 80% 以上的偶发丢帧;二是每次重试之间 sleep 一小段,给总线留出恢复时间;三是表与表之间的间隔哪怕只有 50ms 也留着,因为有些表在连续被叫时会来不及处理,直接不响应。
数据入库我一般用最朴素的两张表:
CREATE TABLE meter_energy ( id BIGINT PRIMARY KEY AUTO_INCREMENT, meter_no VARCHAR(20) NOT NULL, kwh DECIMAL(14,2) NOT NULL, read_time DATETIME NOT NULL, bus_id INT, INDEX idx_meter_time (meter_no, read_time) ); CREATE TABLE meter_info ( meter_no VARCHAR(20) PRIMARY KEY, location VARCHAR(64), protocol VARCHAR(16), scale DECIMAL(6,2) DEFAULT 100, ratio DECIMAL(8,3) DEFAULT 1, init_kwh DECIMAL(14,2) DEFAULT 0 );init_kwh是换表或者首次接入时的初始读数,用来做增量计算。因为电能表是累计值,清零点不会自动归零,你算某个时间段的用电量必须用"末读数减初读数",而不是直接把读数当耗电量。这个逻辑写错,出来的日报表会是一个巨大的数字,一眼假但很常见。
5. 常见问题与排查实录
再好的方案在现场都会遇到问题。这一节我把这些年遇到过的典型故障整理出来,按症状归类,配上排查路径,你可以当成一张速查表用。
5.1 完全没响应:从物理层往上查
第一类症状是发出去没有任何回音。排查顺序一定是自下而上:先用万用表量 A、B 之间的电压,静态时正常应该在 200mV 到 1V 之间浮动,如果一直是 0,说明总线没供电或者断线;如果一直是 5V 以上且不动,可能是 A、B 接反或者被短路了。第二步确认转换器本身有没有问题,拿一台确定能通的表单独接上测试,排除表的问题。第三步核对表号和速率,表号在表壳铭牌上,12 位数字,注意有些表的铭牌号和后 6 位通信地址不是一回事,这种情况需要用广播地址 999999999999 配合读通信地址命令去问。第四步检查转换器的驱动,USB 转 485 的芯片型号很多,有的需要装专用驱动,设备管理器里能看到 COM 口才说明驱动正常。
注意:有相当一部分"完全不响应"最后追查下来是 A、B 接反。485 的 A 对应正、B 对应负,但不同厂商的标记方式不统一,有的标 A/B,有的标 D+/D-,有的干脆只标 485+ 和 485-。最稳的办法是不看标记,直接试两种接法,反正接反了也不会烧芯片(前提是没有强电串入)。
5.2 时通时不通:干扰与接地问题
第二类症状是通信成功率不稳定,白天好晚上差,或者开某个设备的时候必掉线。这基本都是干扰问题。排查方向:一是总线上有没有和动力线同槽走,如果有,重新布线或者加金属隔板;二是屏蔽层有没有正确单端接地,双端接地会形成地环流,反而加剧干扰;三是终端电阻是不是只接了两端,中间节点误接了要把拨码关掉;四是波特率是不是太高,2400 跑不通的现场换 1200 往往能立刻稳定。
还有一个隐蔽的原因是电源质量。有些表箱里的开关电源纹波很大,会导致表内部通信电路工作不正常。我遇到过一次,把表的工作电源换成一个质量好一点的模块后,丢包率从 30% 降到接近 0。
5.3 数据读回来了但数值不对
数值不对要分情况。如果数值量级差了几十倍,先怀疑 CT 变比和单位刻度;如果数值是随机乱数,怀疑字节序或者 BCD 解析写反了;如果数值正常但一直不动,怀疑你读的是某个冻结值或者读到了错误的 DI,很多表里"当前正向有功总电能"和"上 1 日正向有功总电能"的 DI 只差几个字节,读错了就会出现数值滞后一天的情况。
判断 BCD 字节序有没有写反,有个特别快的土办法:找一台电量比较大的表,读数应该在几千到几万之间。如果你的解析结果是一个天文数字或者一个极小的小数,那基本就是字节序反了。修正方法就是按前面bcd_le_to_int的逻辑,从低字节往高字节累乘 100 的幂。
| 症状 | 最可能原因 | 处理方式 |
|---|---|---|
| 数值差 40 倍、100 倍 | CT/PT 变比或单位刻度 | 核对配置,乘上 ratio |
| 数值随机跳动 | 字节序反了、BCD 解析错 | 调整解析顺序 |
| 数值长期不变 | DI 读错,读到冻结值 | 核对 DI 表 |
| 数值缓慢增加但偏小 | 读的是分相电量不是总电量 | 改成总电量 DI |
| 数值为 0 | 表未计量或数据域解析错位 | 打印原始报文核对 |
5.4 多表轮询中某台表总是超时
这种情况往往是那台表的物理位置最远,或者中间接头最多。排查思路是先用串口调试工具单独直连那台表,如果直连没问题,说明是总线的问题;如果直连也读不到,说明是表本身的问题。总线问题里最常见的是接头氧化或者螺丝没拧紧,尤其在潮湿、粉尘环境里,一年下来接线端子氧化得很厉害,重新压接一下就好了。另外一个可能是节点数超了负载能力,标准 485 收发器只能带 32 个单位负载,超标了会出现"谁都能通,就是随机通"的现象,加个中继器分段就能解决。
还有个小细节:不同厂商的表响应速度差异很大,有的 20ms 就回,有的要 200ms 才回。如果你的轮询代码超时设得过短,快的表能通,慢的表就永远超时。所以超时时间建议按整批表里最慢的那个来设,宁可整体周期长一点。
5.5 一个我踩过的大坑:地址冲突
有一次改造项目,新装了一批表,按说表号都是唯一的,但就是有几台数据始终对不上。折腾了很久才发现,施工队把两台表的地址设置成了同一个——那批表的表号是可以改的,他们为了图省事,复制了第一台表的参数到后面所有的表里。结果就是主机叫一次号,两台表同时应答,两个报文在总线上碰撞,主机收到的就是一堆乱码。同一总线上地址必须唯一,这个原则说起来是常识,但现场真的会发生。改造项目收工前,一定要用广播读一遍所有表号,确认没有重复。
6. 工程化部署中的几个实战经验
代码跑通只是第一步,真正让系统稳定跑上几年,还需要一些工程化的考虑。
6.1 采集服务的稳定性设计
采集服务的核心诉求是"不能死"。我的做法是把采集进程和业务进程分开,采集进程只负责读表和写原始数据,业务进程负责换算、统计、展示。采集进程用守护进程的方式常驻,崩溃了自动拉起。每次成功读取都写一条原始记录,包含原始报文的十六进制字符串,这样后面数据出问题的时候还能回溯。这个日志表会比较大,建议按月分表或者定期归档。
心跳机制也很重要。如果某台表连续 10 个周期读不到,就应该产生一条告警,而不是默默记在日志里。现场的经验是,一旦有表连续读不到,通常是接线松了或者表坏了,越早发现越好处理,拖到月底结算的时候再发现,就来不及补数据了。
6.2 数据补抄与异常标记
网络或者总线抖动导致的数据缺口是常态,必须有补抄机制。最简单的方式是在读取周期里,检查每台表最近一条记录的 read_time,如果超过了 3 个周期,就优先补读一次。更进一步,可以在业务层对每天的用电量做合理性校验:如果某天的用电量比历史均值高出 5 倍以上,或者出现负值(读数回退,通常是换表了),就打上异常标记,人工复核,而不是直接进入报表。
负值这一点要特别提一下:电能表是累计值,正常情况下不会减少,一旦出现末读数小于初读数,只有三种可能——表被换过、表被清零、读错了。前两种都需要在系统里做换表事件登记,登记完之后重新设置该表的初始读数,否则这条表的日用电量会一直算错。
6.3 后续可以扩展的方向
这套链路跑稳之后,扩展起来其实很自然。一是接入更多类型的仪表,比如水表、气表、温度传感器,只要它们支持 485 和标准规约,采集层几乎不用改,改的只是地址配置和解析映射表。二是做实时告警,把电量数据、电流数据、功率因数结合起来判断异常,比如三相不平衡、功率因数过低,这些在配电房运维里都很有价值。三是做能耗分析看板,把用电量按区域、按产线、按时段拆开,这也是园区和工厂最关心的东西。不过这些都属于上层业务,底层的 485 抄表如果一开始就做得扎实——地址台账清楚、变比配置化、原始报文可追溯——后面加什么功能都会轻松很多。
我在实际操作中的体会是,485 抄表这个活,技术上没有多少高深的东西,难点全在细节和现场。同一份代码在一个现场跑得稳如老狗,换个现场可能就满头包,原因往往就是那里的线接得不太规范,或者那批表的固件版本有点特殊。所以别指望一次就把参数配对所有东西,多留一点调试时间,多打印一点原始报文,多建一张台账表,这三件事看着笨,但在项目后期的回头率上,回报是最高的。