
做运维监控这块的朋友应该都有这种体会网管平台、动环系统、IoT平台基本都认SNMP设备上只要有标准MIB一套监控体系就能管到底。但偏偏在我们用电信息采集这一侧国内智能电表走的是DL/T 698.45协议这套协议在电力行业里很成熟、很可靠可它跟IT运维这边完全是两个世界。我去年就接手了一个活儿机房里有几十块DTL698协议的多功能电表但动环监控平台只认SNMP两边语言不通中间必须搭一座桥——把DTL698电表数据实时转成SNMP协议让监控平台直接走OID拉数据。这篇文章就是把这个项目从需求分析、协议拆解、架构选型到最终落地的全过程整理出来内容包括OBIS数据项和OID如何映射、采集频率怎么定、net-snmp扩展和自研Agent怎么选、实际部署中踩过的坑等等。不管你是做数据采集网关的、搞动环监控的还是单纯想把电力数据接进自家监控体系的都可以直接抄这份实践。1. 项目背景与实际需求拆解先说清楚这事儿的来龙去脉。很多机房、数据中心、工厂车间里都装了多功能电表用来统计市电总进线、各回路的分路电量。这些电表在电网侧是标准的DL/T 698.45协议设备支持RS485总线、电力载波或者微功率无线通信。但在运维室这边动环监控系统和网管平台普遍只支持SNMP协议你想在同一个大屏上同时看到网络设备状态、温湿度传感器和电表电量就必须把电表数据映射成SNMP的OID节点。说白了这是两个“语言不通”的系统之间的翻译工作。这个项目中我负责的方案本质上是一条单向数据通路电表侧是DL/T 698.45协议监控侧是SNMP GET/GETNEXT请求。中间需要有一个“翻译器”定时去读电表的电压、电流、功率、电能量等数据存到本地缓存等SNMP监控平台来查询时直接把缓存值返回。这里的关键点在于监控平台几乎不会关心你底层用的是698还是Modbus还是DL/T 645它只关心OID能不能通、数据准不准、刷新快不快。从需求清单来看这个项目主要包含了这几个硬性要求支持读取组合式有功电能、分费率电能尖峰平谷、A/B/C三相电压、三相电流、有功功率、无功功率、功率因数等常用电表数据点。数据刷新周期要求1~5分钟以内冻结数据电能量和实时数据电压电流要分开处理。SNMP侧采用v2c协议即可团体名可配置支持标准的GET和GETNEXT遍历MIB文件要能让监控平台直接导入。其中一台电表离线或通信超时的时候不能影响其他电表的正常返回也不能让监控平台产生大量误告警。采集设备本身要能长时间稳定运行掉电重启后能自动恢复采集任务。这里特别要说明一下标题里写的DTL698实际对应的规范是DL/T 698.45《电能信息采集与管理系统》中的面向对象数据交换协议。行业内口语念快了就成了“DTL698”反正大家都能听懂。这套协议跟以前常见的DL/T 645-2007相比最大的区别是它引入了面向对象建模数据项都有统一的对象标识OBIS读取方式也变成了应用层指令的方式而不是简单的高低字节地址。后面的核心实现里我会拿实际报文一步步拆。2. 协议分析与数据映射设计2.1 读懂DL/T 698.45的报文结构DL/T 698.45的帧格式其实很规整底层链路层长得跟常规的电力规约差不多但应用层使用了类似TLV的结构整体层次清晰。一个典型的数据读取请求帧固定部分大概是这么排的帧起始符68H、长度域L、控制域C、地址域A通常6个字节包含区号和电表逻辑地址、应用层APDU、校验和CS最后以16H结束。应用层APDU里最关键的是“对象属性描述OAD”一个OAD由几个字节组成具体的字节含义前两个字节是对象标识OBIS后面还有属性ID、数据选择等。读电表数据的请求通常会带上一个“读取请求”的操作码比如GET请求电表收到后回复GET-RESPONSE帧里面的数据区就是对应的值。你不一定需要把协议栈完整看懂但至少要知道这几个点帧头是68帧尾是16应用层最前面的字节决定操作类型0x30左右通常是请求/响应OBIS标识就藏在APDU的固定偏移里。如果只想快速验证链路通不通可以先发一个身份识别请求看电表有没有响应这个比直接读数据简单得多。实际项目中我不建议从零手写完整的698协议栈。现在很多采集器主控芯片的SDK里有698协议库或者可以直接找成熟的数据采集模块自己在上面做业务逻辑。如果一定要自己写重点放在帧校验、超时重传和响应解析这三块。2.2 OBIS数据项与SNMP OID的映射关系DL/T 698.45的数据项用OBIS标识来命名。比如“1-0:1.8.0”表示组合式正向有功电能单位是kWh“1-0:2.8.0”表示组合式反向有功电能。分费率电能则是把第三个数字换成费率号比如1-0:1.8.1代表尖时正向有功、1.8.2代表峰时、1.8.3代表平时、1.8.4代表谷时。三相电压则类似“1-0:32.7.0”A相电压三相电流对应“1-0:31.7.0”A相电流。SNMP这边又是另一套命名体系。监控平台通过OID来标识一个监控对象通常企业私有MIB放在1.3.6.1.4.1这个节点下面。我做映射的时候习惯是把OBIS的“1-0”省略直接用后面的数字排布成OID。比如企业号假设是9527那么根节点就是1.3.6.1.4.1.9527.dtl698。再往下我用一个二维结构第一层是表计编号第二层是数据点编号。映射表大概长这样数据点名称OBIS标识对应OID表计编号为1SNMP类型总正向有功电能1-0:1.8.01.3.6.1.4.1.9527.dtl698.1.1.0Counter64尖时正向有功电能1-0:1.8.11.3.6.1.4.1.9527.dtl698.1.2.0Counter64峰时正向有功电能1-0:1.8.21.3.6.1.4.1.9527.dtl698.1.3.0Counter64平时正向有功电能1-0:1.8.31.3.6.1.4.1.9527.dtl698.1.4.0Counter64谷时正向有功电能1-0:1.8.41.3.6.1.4.1.9527.dtl698.1.5.0Counter64A相电压1-0:32.7.01.3.6.1.4.1.9527.dtl698.1.6.0Gauge32B相电压1-0:52.7.01.3.6.1.4.1.9527.dtl698.1.7.0Gauge32C相电压1-0:72.7.01.3.6.1.4.1.9527.dtl698.1.8.0Gauge32A相电流1-0:31.7.01.3.6.1.4.1.9527.dtl698.1.9.0Gauge32总有功功率1-0:1.9.01.3.6.1.4.1.9527.dtl698.1.10.0Gauge32功率因数1-0:13.7.01.3.6.1.4.1.9527.dtl698.1.11.0Gauge32数据状态自定义1.3.6.1.4.1.9527.dtl698.1.12.0INTEGER这个表是项目里的核心资产后续MIB文件、采集程序、Agent应答全都要跟这个表保持一致。设计的时候有几个细节要提前想清楚电能累加值建议用Counter64因为电表的累计电量很容易超过32位整数范围电压、电流这些实时量用Gauge32就够了数据状态这个节点是我额外加的用来上报“正常、通信超时、无效数据”等状态监控平台可以基于它做告警屏蔽或者触发通知这一个节点在后面救了我好几次。2.3 数据刷新策略与时序考量频繁去读电表没有意义SNMP监控平台通常60秒轮询一次就够了。对于电能表数据我把数据项分成了两类一类是冻结数据电能量这类数据变化慢一般15分钟或者30分钟读一次就行读的是电表内部在整点时刻冻结的历史记录另一类是实时数据电压、电流、功率这类数据变化快按1分钟或者5分钟的周期读取。经验值是一台采集终端下挂32块表RS485总线波特率9600bps的情况下每块表轮询一遍大约需要5到15秒。如果所有数据项都要读采集周期会拉长到1分钟以上。所以我的做法是分轮次电压和功率这类实时数据每60秒读一轮电能量冻结数据每900秒读一轮两轮并发交错执行这样既不占用太多总线时间又能保证数据新鲜度。还有一个需要注意的点电表的“实时值”和“冻结值”读法完全不同。实时值直接GET请求就能返回当前时刻的瞬时值但冻结值要指定“数据选择”的时间标签比如请求读取最近一次日冻结或月冻结数据。如果你只是想看“当前累计电量”直接读实时电能值即可不用去翻冻结记录更容易实现。3. 整体架构与工具链选型3.1 三层架构采集、缓存、暴露这个系统的整体架构并不复杂核心思路就是分层解耦。第一层是采集层负责通过RS485/串口或者网络与电表通信向电表发送DL/T 698.45读数据请求解析响应帧提取出我们关心的数据点。第二层是缓存层把最近一轮采集到的数据连同时间戳、数据状态一起放进内存数据结构中同时写入SQLite做历史留存。第三层是暴露层实现SNMP Agent逻辑监听UDP 161端口响应监控平台的GET和GETNEXT请求从缓存层取数并返回。这样分层有一个明显的好处采集频率和SNMP查询频率完全解耦。就算监控平台发疯一样地高频轮询Agent也只是读内存缓存不会去骚扰电表。反过来电表通信慢、超时重试也不会阻塞SNMP的响应。我在最初的原型里曾经试图做到“来一个SNMP请求就去读一次电表”结果某品牌监控系统一次性批量GET几十个OID电表直接通信超时整个链路卡死。后来改成缓存模式问题就消失了。3.2 方案Anet-snmp的extend和pass_persist快速实现如果不想自己写完整的SNMP Agent最快的一条路是Linux下装net-snmpsnmpd利用它的extend或者pass_persist指令把数据暴露成OID。extend适合那种简单的、一次取一个值的脚本场景比如在snmpd.conf里写一行配置把某个OID对应到一条shell命令OID被查询时就执行命令并把输出当成SNMP值返回。这种方式优点是配置简单缺点是每个OID都要起一个进程或执行一次脚本32块表、每块表12个数据点就意味着几百次进程启动性能撑不住。更实用的是pass_persist方式它的逻辑是snmpd启动时拉起一个子进程子进程保持运行等待snmpd传过来的查询指令然后返回对应的OID和值。这样一来脚本只启动一次后续查询都是内存里的读写操作。我的快速原型就是这么做的采集程序独立跑把最新值写入临时文件pass_persist脚本收到SNMP查询时读文件、查映射表、返回结果。这个方案半小时就能搭起来适合验证OID映射正确性。不过pass_persist有它的局限它要求的输出格式是固定的“OID 类型 值”多行形式而且GETNEXT的实现逻辑要自己处理好不然遍历时会乱。另外snmpd本身是单进程多线程模型如果你的监控平台对同一批OID做了大量并发查询pass_persist脚本也会成为瓶颈。所以我最终在实际部署时没有采用pass_persist而是直接用Python实现了一个轻量级SNMP Agent原因下面说。3.3 方案B用Python自研SNMP Agent自研SNMP Agent听起来高大上其实并不复杂。用pysnmp这个库几十行代码就能起一个支持GET和GETNEXT的Agent核心要处理的无非是MIB节点注册、请求分发、OID字典序这3件事。我选择自研的原因有三点。第一Agent需要跟采集模块共用缓存数据直接写成同一个进程的两个线程用一份内存字典就完事了不用搞进程间通信和文件锁省去很多麻烦。第二数据状态节点可以做得很灵活电表通信失败时Agent能返回一个自定义错误码这在pass_persist里实现会绕。第三自研程序可以加一个HTTP调试接口随时用浏览器看每个电表的采集状态和最新值排查问题的时候太方便了。当然pysnmp库也不是没有坑。不同版本之间API有差异比如v2c的CommunityData设置、UDP传输域的写法会有细微变化。建议直接锁定一个自己熟悉的版本并且在代码里封装好Agent的启动逻辑后续升级库的时候只动一个文件。3.4 各方案对比参考我把两种方案放在一起对比一下方便你结合自己项目的情况选择对比项net-snmp extend/pass_persistPython自研Agent开发工作量低半小时可跑通中等需要写MIB和Agent逻辑性能上限受限于脚本通信和snmpd调度适合小规模内存直接读取更适合大规模或高频查询调试便利性依赖snmpd日志较难定位可加HTTP接口和日志调试方便部署依赖需要net-snmp环境几乎所有Linux都有需要Python环境和依赖库灵活度OID结构受pass_persist格式限制可完全自定义支持复杂逻辑适合场景快速原型、数据点少、临时项目正式部署、数据点较多、需要长期维护我的建议是如果你是第一次做类似项目先用方案A跑通全链路对MIB和OID有个感性认识等确认数据映射没问题了再上方案B做正式部署。我自己就是这么干的原型用了2天正式Agent又花了3天总共一周搞定。4. 核心实现与关键代码解析4.1 698串口采集模块的骨架采集模块是整个系统里最贴近硬件的一层。如果是RS485通信操作串口需要设置波特率、数据位、校验位、停止位常见配置是9600-8-E-1但不同厂家电表可能默认2400或者4800上线前最好用厂商工具读一下确认。下面是一段简化后的采集框架代码重点看思路不用纠结库的细节import serial import time from typing import Dict, Any class Meter698Reader: # 表地址、数据映射表、串口句柄等初始化略 def __init__(self, port: str, address: str): self.ser serial.Serial(port, baudrate9600, bytesize8, parityE, stopbits1, timeout2) self.address address def build_get_frame(self, oad: bytes) - bytes: # 按DL/T 698.45组装一个GET请求帧 # 这里只列出关键字节顺序实际代码需要加上控制域、地址域、校验和等 frame bytearray() frame.append(0x68) # length、control、address 部分省略 frame.append(0x40) # APCI: GET请求 frame.extend(oad) # 对象属性描述 # CS校验和与结束符0x16省略 return bytes(frame) def read_value(self, oad: bytes) - Dict[str, Any]: req self.build_get_frame(oad) self.ser.write(req) resp self.ser.read(128) # 解析响应帧提取APDU中的value部分 # 注意要校验CS并判断返回的操作码是0x30还是错误码 value self.parse_apdu_value(resp) return {value: value, status: ok} def read_energy(self): # 1-0:1.8.0 组合式正向有功电能 oad bytes([0x00, 0x01, 0x08, 0x00, 0x00]) # OAD示意实际需要再细化 return self.read_value(oad)这里要特意提醒上面代码只展示了结构不能直接拿去用。DL/T 698.45的帧组装和解析细节很多尤其是地址域的字节顺序、控制域的FCB翻转、APDU中的TLV长度编码这些地方出错的概率最高。我的建议是拿厂商提供的协议调试工具抓一帧真实报文对照协议文档逐字节核对比看任何代码都有效。4.2 协议响应解析与数据规约化电表返回的响应帧里数据部分可能是整数、BCD码、字符串或者带时标的复合结构。解析时最大的坑就是十进制倍率。比如某些电表的电压值存储的是实际值的10倍、100倍电能值则可能带了互感器变比直接读出来的数字要除以倍率才能得到真实电量。如果电表是经互感器接入的还需要在采集配置里单独填上变比比如800/5的互感器综合倍率是160。我遇到过一次非常隐蔽的问题两块同型号电表一块读出来的电压是2200另一块是0220带前导零的电表如果按整数解析会丢位。这类问题通常是因为电表返回的是字符串形式的数值解决办法是统一按二进制整数解析再根据OBIS表确定倍率不要依赖字符串长度。数据进缓存之前我还习惯先把所有值转换成统一规格电压存浮点单位V、电流存浮点单位A、功率存浮点单位W、电能存整型单位Wh或kWh带倍率说明。这样SNMP侧只需要做一次OID映射不需要每个数据点单独处理单位换算。4.3 共享缓存与状态标记缓存层我用了一个简单的环形字典键是“表地址数据点编号”值包含数值、采集时间戳、状态、倍率。采集线程每轮更新这个字典SNMP Agent线程只读这个字典因为Python的GIL保证了字典操作的原子性所以这里不需要额外加锁。状态标记这块值得多说几句。电表通信超时的时候缓存里的旧值仍然存在但状态要标记为“超时”。如果SNMP Agent直接返回旧值监控平台就会把旧值当作最新值很可能导致误判或者漏判。我在设计中增加了一个数据状态OID状态值定义如下0数据正常1当前无数据初始化状态2通信超时返回缓存旧值3数据校验错误监控平台可以通过阈值规则对状态值做告警当状态值等于2持续3次以上说明这条采集链路可能出问题了。这样即使SNMP返回的是缓存旧值监控平台也知道“这个数据不太可信”而不会直接当成真实值去触发告警。4.4 SNMP Agent的OID遍历实现用pysnmp实现Agent时最需要细心的地方是GETNEXT的字典序递增逻辑。SNMP协议规定OID在这个MIB树里必须是有序排列的客户端发出GETNEXT请求时Agent要返回比请求OID更大的下一个OID。很多自己实现的Agent在这一步会出现“遍历到一半就断了”或者“无限循环”的情况。核心逻辑是先把所有已注册的OID放在一个列表里排好序收到GETNEXT请求时用二分查找找到第一个大于请求OID的节点如果找到了就返回该节点的OID和值找不到就返回SNMP的endOfMibView。当监控平台遍历完整个表它就会自动停止。这个逻辑我单独封装成一个函数方便单测。from pysnmp.entity import engine, config from pysnmp.carrier.asyncore.dgram import udp from pysnmp.proto import rfc1902 import sortedcontainers # 或直接用列表二分 # 简化示例注册一个OID节点的响应回调 def get_next_oid(oid_tuple): # 在已排序的oid_list中找到下一个节点 # 返回 (next_oid, value)没有则返回endOfMibView pass def handle_get(oid): # 根据oid直接查mapping pass实际部署中我还加了一个启动参数监听端口、团体名、数据文件路径都做成可配置的避免为了改端口重新部署程序。团体名我建议不要用public内网监控虽然风险不大但写成自定义团体名能减少被无关扫描匹配到的概率。如果安全要求更高直接上SNMP v3的USM认证pysnmp也支持不过配置会复杂一些这个项目里没用到。5. 部署调试与踩坑记录5.1 硬件接线与通信参数核对现场部署的第一步永远是物理链路。RS485接线看起来简单但“A/B接反”是所有采集项目里的头号坑没有之一。A线接B口或者屏蔽层没接地会导致要么完全不通要么通信时好时坏。我的习惯是接线后先用串口调试工具直接发送一个698请求帧看电表有没有回复没有的话先对调A/B再试一次。波特率不匹配是第二高频问题。国内很多多功能电表出厂默认波特率是2400或者4800但采集模块可能习惯性配成9600。一旦波特率错了抓包看到的现象就是收到一堆乱码或者干脆没响应。所以部署时的检查顺序建议是先确认物理接线再确认波特率再确认数据位和校验位最后才是协议层面的调试。如果在项目里是通过集中器间接抄表那就更要注意了集中器的下行通信方式可能是载波或者微功率无线它抄表本身就需要时间而且时常会有个别电表掉线。这种场景下采集程序的重试机制和超时时间必须放宽不能按RS485直连的标准来。我的做法是把单次抄表超时设置为3秒每块表连续失败3次后就不再重试标记为离线等下一个周期再尝试而不是死磕某一台离线表导致整条总线阻塞。5.2 读数不对的排查思路数据读回来了但数值不对这是最让人抓狂的阶段。我记得有一次把所有电表的功率都读成了0排查了半天最后发现是OAD里的数据选择子结构没写正确导致电表返回了一个空值。所以排查读数问题我建议按这个顺序来先用厂商的调试工具读同一块表确认电表本身数据正常。用串口抓包工具看原始响应帧确认返回的数据字节到底是什么而不是直接信任解析代码。对照协议文档查看数据类型和长度确认没有解析错位。核实倍率和变比配置尤其是带互感器的回路。最后再查OAD映射表和MIB文件确认OID对应关系没串。这个过程听起来繁琐但实际上大多数问题都出在解析和倍率这两个环节。建议开发阶段就把原始帧日志保存下来每个响应都留一份hex dump这样排查问题的时候可以直接翻日志不用反复去现场补数据。5.3 监控平台告警风暴的规避监控平台通常会对每个OID设置阈值。如果电表离线时Agent仍然返回一个很大的旧值或者0值平台可能会触发“电压过低”或“功率突变”之类告警一告警就是几十台设备一起告形成告警风暴。规避手段就是前面说的数据状态节点。我在项目里实际做了两步。第一步电表离线时Agent对数据类OID返回缓存旧值但对状态OID返回2。第二步在监控平台上把“状态OID等于2”设置为所有数据类OID告警的屏蔽条件。这样离线时平台看到的还是最后一次正常值且不会因为超时连续产生误告警。等采集恢复了状态OID变回0平台自动恢复正常告警。这个设计当时跟监控平台的对接方确认了很久但上线后效果非常好几乎没人再因为“电表数据异常”半夜打电话。5.4 多费率电量的特殊处理分费率电量的OBIS跟总电量不一样每个费率时段单独累计这本身没什么难度。但要注意一点电表内部费率时段是通过当地电网的时段方案设置的比如尖峰平谷的时段划分可能随季节调整。你读数据时如果只关心“当前总电量”读组合式正向有功电能就行如果业务上需要分费率统计就要把尖峰平谷分别映射到不同的OID。还有一个实际场景需要注意电表在换费率方案或者校时之后分费率累计值可能短暂出现异常甚至清零。如果你对接的是计费或者报表系统建议除了实时值之外还要定时把冻结数据里的历史值读出来存档防止电表内部数据变动导致历史曲线断档。6. 常见问题速查表与容量参考6.1 高频问题排查一览现象可能原因解决方案串口完全无数据A/B接反、波特率错误、串口占用对调A/B核对波特率关闭其它串口调试工具收到乱码校验位/停止位配置错误、干扰严重配置8E1或8N1检查屏蔽层接地有响应但解析报错帧不完整、CS校验失败、地址域错误抓包核对确认长度域含义检查表地址字节序个别表读不到表离线、载波信号弱、表地址配置错误现场确认表状态按块表单独重试检查地址编码电能值明显偏大或偏小倍率/变比配置错误核实互感器变比统一数值缩放逻辑SNMP GET返回超时缓存未更新、端口被防火墙拦截检查采集线程状态测试UDP 161连通性GETNEXT遍历中断MIB树排序错误、未处理endOfMibView检查OID排序逻辑补齐结束标记监控平台导入MIB报错MIB语法不兼容、节点命名不规范用标准MIB编译器校验避免使用非法字符6.2 性能与容量参考我在现场测过一组数据一台采集终端通过RS485挂32块电表9600bps波特率轮询电压、电流、功率等实时数据一轮大约需要25~40秒叠加电能量冻结数据后30分钟一个循环完全能满足监控需求。SNMP侧监控平台60秒轮询一次每次批量GET大约200个OIDAgent的内存缓存模式下平均单次响应时间在10毫秒以内CPU占用可以忽略不计。如果数据规模更大比如上百块表建议不要把数据点全部展开成一个OID列表而是考虑在Agent里加一层“按表索引动态生成OID”的逻辑这样当表数量变化时只需要改配置文件不用改MIB。另外一个思路是用多个采集进程分担不同回路的电表Agent层做聚合这在高并发场景下会更稳。6.3 这个方案的扩展方向项目上线后团队还给它扩展了几个能力。一个是把采集到的数据同时写入本地时序数据库这样监控平台除了实时告警之外还能直接从数据库里拉取历史曲线避免完全依赖SNMP轮询历史数据。另一个是加了简单的HTTP接口运维人员直接访问网页就能看到每块表的通信状态和最近数值排查问题效率提升了很多。还有同事建议把数据上报到能源管理平台做能耗分析。因为底层的采集层已经是统一的“数据点字典”上层对接任何平台都只需要写一个适配器不需要改动采集逻辑。这个架构上的好处是我当初分层设计时没想到的算是无心插柳。最后分享几个实在的经验这项目做完我自己沉淀了几条经验。第一对接电力协议设备千万别迷信“通用协议库”每个厂家的实现细节都有差异现场调试工具和抓包能力是必须的没有这两样你会被各种玄学问题折磨到怀疑人生。第二做协议转换类项目数据状态的设计比数据值本身还重要。一个明确的“数据不可信”标记能省掉后期无数告警和误判的麻烦。第三能在内存里解决的事就不要加中间文件或者数据库解码和通信已经够复杂了能少一个变量就少一个变量。要说遗憾也有这项目里的电表没有支持SNMP的直连功能否则也不用折腾这个转换程序。听说现在已经有部分高端表计直接内置SNMP Agent但价格嘛懂的都懂。所以至少在很长一段时间里用软件做协议转换依然是最经济实惠的方案。你要是也在做类似的电表接入项目希望这篇实践记录能帮你少走点弯路。