今年接了个挺有意思的改造任务:某单位机房新装了一批电表,要求把电表数据接进统一的运维监控平台。电表侧走的是电力行业常见的 DL/T 698.45 规约(大家常说的 DTL698),而监控平台那边的南向接口只认 SNMP。两边就像一个人讲方言、一个人讲普通话,信息倒不是没有,就是互相听不懂。于是就有了这个“DTL698电表数据转SNMP协议”的小项目。整个项目做完,我最大的感受是:协议转换类需求看着不起眼,真正做起来要趟的坑不少——帧拆分、编码转换、数据类型映射、轮询时序,每一环都能让人折腾好几天。这篇文章把完整思路、关键实现和实测踩坑记录整理出来,给后面要做类似网关的同学一个参考。1. 为什么电表和网管之间需要一座“翻译桥”1.1 任务背景:一份“看不懂”的数据先说说需求和背景。某单位机房配电柜里安装了一批智能电表,负责监测市电进线、UPS出线、空调回路等几个关键节点的电压、电流、功率和电量。这些数据本身电表都有,问题在于要让运维平台能统一看到。电表厂家给的是集抄方案,数据通过 DL/T 698.45 规约上送到电力集抄系统,人可以在专用软件里看,但没法直接进网管平台。我们这边监控平台统一用的是 SNMP 协议,支持通过 OID 去轮询设备状态和数据。两边协议体系完全不同——698 规约是面向电能计量场景的分层报文协议,SNMP 则是面向 IT 设备的树状管理模型,中间没有任何现成的网关。方案有几条路:一是让运维同事去集抄系统里手工导数据,不现实;二是在电表旁边放协议转换模块,让电表去“说”SNMP,但很多电表固件不支持改协议;三是自己做一个协议转换网关,一边当 698 客户端去读电表,一边当 SNMP Agent 给网管平台回数据。我选了第三条,可控性最强,也方便后续扩展其他规约。1.2 链路架构:数据从电表到网管系统要过几道坎这个项目的整体数据流是这样的:电表 -> RS485总线/电力线载波 -> 集中器(或直接进网关串口) -> 网关进程 -> SNMP Agent -> 网管平台轮询实际项目里,机房里的电表已经接到一台集中器的 RS485 口上。集中器负责把多块电表的数据汇聚,对外提供以太网口,支持 TCP 方式访问。所以我的网关进程不直接接触物理串口,而是通过网络 TCP 连接去和集中器通信,再由集中器转成 RS485 命令下发电表。如果电表没有经过集中器、直接是串口线的话,思路也一样,只是把 TCP 客户端换成串口客户端,串口参数按电表手册配置。网关侧需要多线程:一个线程负责采集和缓存数据,另一个线程跑 SNMP Agent,响应网管平台的 get 请求。两侧通过共享缓存解耦,避免采集慢影响 SNMP 响应速度。1.3 选型理由:为什么用 Python、为什么走 SNMPv2c技术栈最终选了 Python,原因有几个:Python 生态里有 pysnmp 库,实现 SNMP Agent 的 get 响应很直接。DL/T 698.45 的报文解析是典型的字节流处理,Python 的 struct、bytes 操作很顺手,抓包调试也方便。网关部署在机房的一台小工控机上,数据量不大(几十块表)、轮询周期固定,Python 性能完全够用。如果用 Go 或 C++,性能确实更好,但开发周期会明显拉长,对于这种“先跑通、再优化”的项目不划算。SNMP 版本选了 v2c。v3 安全特性更好,但配置复杂,和监控平台的对接成本高。内部网络环境本身做了一层隔离,v2c 的 community(只读)够用,团队里运维也早就习惯了。2. DL/T 698.45 报文解析:从字节流到可读数据点2.1 帧结构:先解决“从串口怎么读到一坨完整数据”DL/T 698.45 的报文虽然分层多,但底层链路帧格式有明确的起始和结束标记。一个典型的数据帧是这样的:字段说明备注启动字符68H帧起始长度域报文长度1~2字节,小端控制域C区分固定帧/可变帧、请求/响应地址域表号/逻辑地址多字节链路用户数据应用层APDU核心业务数据帧校验CS从启动字符到数据末字节的累加和结束字符16H帧结束解析的第一步不是解析内容,而是“怎么从连续的字节流里切出一整帧”。TCP 连接不会保证一次 recv 刚好收到完整一帧,串口更是可能出现半帧、粘帧。所以我的接收缓冲区必须做重组逻辑:不断往缓冲区追加字节,每次循环查找以 68H 开头、以 16H 结尾的完整区域,如果没有就继续等;如果一帧里包含了多帧数据,就一次切好几帧出来。这个切帧逻辑简单,但很容易出错。原因是 698 报文里,应用层数据体也可能出现 68H 或 16H 这样的字节,如果不通过长度字段来定位帧尾,而是直接按“出现 16H 就切”,必然切错。正确的判断方式应该是:先按帧头的长度字段算出整帧总长,再检查对应位置是不是 16H。这样不管数据体里出现什么字节都不会混淆。2.2 应用层与数据标识:真正读懂电表在说什么链路层切出的是“信封”,应用层 APDU 才是“信”。在 DL/T 698.45 里,业务数据以“对象属性 + 数据标识”的形式组织。对于读数据来说,最关键的几个概念:调用标识:区分一次请求和对应的响应。服务类型:比如读请求、读响应、写请求、上报等。数据标识(DI):描述你要读的数据对象,比如组合有功总电能、A相电压、A相电流等。时标:很多数据附带时间戳,用于说明数据是什么时刻采集的。一开始我以为有完整的官方 DI 对照表就行,实际上还要结合“电表生产厂家的数据点表”。同一类电表,协议文档给出的是 DI 的规范含义,但每一个 DI 的数据类型可能是 BCD 编码、可能是无符号整数、可能是浮点数,字段顺序也各有差异。这块没法纯靠标准猜测,必须拿真实报文比对。举个例子,读电压时我要下发读请求,请求里带上“数据标识 + 属性ID”,电表返回的是这样一个结构:数据内容字节数示例含义数据标识4字节表示“电压数据标识”数据长度1字节后续数据的长度数据体依长度而定可能含电压实数值、单位、时标2.3 解析流程:拆帧、校验、编码转换一个都不能少在实际项目里,我把解析流程整理成了五步:按 68H 起始、长度域定位、16H 结束,从缓冲区切出完整一帧。计算 CS 校验和,如果校验不过,丢弃这一帧并记录日志。解析控制域和地址域,确认这一帧是发给谁的、是请求还是响应。解出应用层 APDU,根据服务类型跳到对应处理分支。对数据体按字节长度逐个解码,常见的是 BCD 码转十进制、无符号整数恢复数值、浮点数按厂家定义缩放。这里面最容易出问题的是 BCD 码。BCD 是一字节表示两位十进制数,比如字节 0x12 代表数值 12,而不是十六进制的 18。很多第一次写 698 解析的同学,直接把字节当成十进制整数读,结果电压变成了几十倍、几百倍,排查半天才发现是 BCD 解码写错了。2.4 请求-响应时序与超时机制698 是标准的请求-响应模式,不会主动往网关推数据。所以要拿到电表的最新值,必须定时去“问”。时序上有两个坑:一是请求之间的间隔不能太短,电表端接收缓冲区不会无限快,连续高频请求可能导致集中器或电表无响应;二是响应超时时间不能太长,否则一块表超时 10 秒,几十块表轮询一圈下来,数据新鲜度就没法保证。我最终把单表请求超时设为 3 秒、重试 2 次,轮询周期设为 15 秒一轮。每次请求只读一个数据点组的多个属性(比如一次把 A、B、C 三相电压都读回来),这样比逐属性挨个读省一半时间。3. SNMP侧设计:私有MIB、OID表与agent回值逻辑3.1 私有MIB与OID规划SNMP 的数据模型是一棵 MIB 树,所有数据都能映射成一个 OID。公网设备有自己的 OID,私有业务则统一放到 enterprises 分支下面。我这个项目没有申请独立企业号,直接规划了一個私有的子树结构,形如:1.3.6.1.4.1.<企业号>.5.1在这个根节点下,我按“表结构 + 数据类型”做了 OID 规划:OID 后缀含义数据类型.1.1表号(电表地址)OCTET STRING.1.2通信状态INTEGER(0在线/1离线).2.1 到 .2.9A/B/C 相电压OCTET STRING(以字符串返回数值和单位).3.1 到 .3.9A/B/C 相电流OCTET STRING.4.1有功功率OCTET STRING.5.1组合有功总电能OCTET STRING没有用 INTEGER 而是用 OCTET STRING,是实测后做出的选择。698 返回的数值经过 BCD 和缩放后,可能需要保留两位小数,比如电压是 225.60V。如果直接用 INTEGER 返回,要么丢失小数,要么需要额外定义缩放系数。OCTET STRING 直接把格式化好的字符串“225.60V”交给网管,虽然不够“标准”,但监控平台显示和告警阈值配置反而最简单。真要追求规整,可以在 MIB 里再用 Counter32/DisplayString 做二次映射。3.2 MIB文件编写:让网管平台认得你的数据为了让网管平台能配置这些 OID,我需要提供一个 MIB 文件。核心片段长这样:dtl698MIB MODULE-IDENTITY LAST-UPDATED "202401010000Z" ORGANIZATION "Project Team" CONTACT-INFO "local engineer" DESCRIPTION "DL/T 698.45 meter data to SNMP private MIB" ::= { enterprises 123456 5 1 } meterTable OBJECT IDENTIFIER ::= { dtl698MIB 1 } powerData OBJECT IDENTIFIER ::= { dtl698MIB 2 } meterNo OBJECT-TYPE SYNTAX OCTET STRING MAX-ACCESS read-only STATUS current DESCRIPTION "meter logic address" ::= { meterTable 1 } aVoltage OBJECT-TYPE SYNTAX DisplayString MAX-ACCESS read-only STATUS current DESCRIPTION "A phase voltage" ::= { powerData 1 }注意 MIB 文件里定义的 OID 必须和 agent 内部注册的 OID 完全一致,否则平台加载 MIB 后能看见名字,但真正 get 的时候会返回 noSuchObject。这点我一开始没对上,排查了好久。3.3 Agent实现选择:pysnmp 还是脚本 passthruSNMP Agent 实现方式有两种主流选择:用 pysnmp 的库直接在 Python 进程里实现 Agent,好处是数据缓存和协议转换在同一个程序里,状态好管理。用 net-snmp 的 pass_persist 机制,让 SNMP 请求转发给外部脚本,脚本从缓存文件里读数据返回,好处是 Agent 进程本身很稳定,但多了一层进程通信,调试稍微麻烦。我选了 pysnmp 直接做 Agent。原因很简单:这个项目的 Agent 和采集进程要共享同一份缓存,拆成两个进程还要处理文件锁和进程通信,没必要。pysnmp 里只需要注册一个回调函数,网管请求到达时,从缓存里查出这个 OID 对应的最新值返回即可。一个很关键的点:pysnmp 的 Agent 注册 OID 时,默认支持的是 MIB 中定义的对象,如果自定义 OID 没有加载 MIB,返回会给 noSuchObject。所以我干脆不依赖自动加载 MIB,而是手动注册一张“OID 到取值函数”的字典。这样反而透明可控,也方便后续加入告警状态。4. 网关主程序:采集轮询、缓存与并发模型4.1 主循环:采集器怎么安排轮询顺序网关启动后,主线程就是一个定时轮询循环。流程如下:从配置表读取电表地址清单。逐个电表建立 698 会话,发送读请求。等待响应,成功则解析并写入缓存,失败则按超时退避重试。一轮结束后 sleep 到下一个周期,开始下一轮。第一步的“建立会话”其实不是传统意义的 TCP 三次握手,而是指在集中器连接上发送 698 链路帧,并用地址域区分不同电表。因为集中器对外只有一条 TCP 连接,所有电表的数据都在这条连接里传输,所以主循环必须保证同一时刻只有一个读请求在等待响应,避免多线程并发请求导致集中器返回错乱。4.2 数据缓存:为什么必须自己hold住最新值SNMP 网管平台轮询设备时,get 请求随时可能进来。如果每次收到 get 请求才去读电表,整个 SNMP 响应会非常慢,而且电表链路一旦被占用,还可能互相阻塞。所以我单独维护了一个“最新值缓存”:meter_cache = { "meter_001": { "a_voltage": ("225.60V", 1712332800), "b_voltage": ("225.30V", 1712332800), "online": 1, ... } }采集线程每 15 秒更新一次 key,SNMP 线程只读不写,两边用一个简单的线程锁保护。网管轮询间隔通常 30 秒到 5 分钟,缓存里 15 秒内的数据完全够用,这个设计也把“采集偶发超时”造成的影响和 SNMP 侧隔离开来。4.3 核心代码骨架:一半是采集,一半是SNMP给大家看几个关键模块的骨架代码,不贴完整实现,但思路可以直接抄。# meter_reader.py # 负责建立集中器连接、发送698读请求、解析响应 import socket def read_meter_data(conn, meter_addr, di_list): frame = build_dlt698_read_request(meter_addr, di_list) conn.send(frame) raw = recv_full_frame(conn) # 切帧处理 check_cs(raw) # 校验 body = parse_apdu(raw) # 解析应用层 return decode_data(body, di_list) # BCD/数值解码# snmp_service.py # 用pysnmp注册OID,返回缓存中的最新值 from pysnmp.entity import engine, config from pysnmp.entity.rfc3413 import cmdrsp def handle_get_request(snmp_engine, req): value = meter_cache.lookup(str(req.oid)) return (req.oid, value) config.add_v1_system(snmp_engine, "public") config.add_v2c_system(snmp_engine, "public") config.add_transport(snmp_engine, udp_domain.snmpUDPTransportEndpoint(("0.0.0.0", 161)))# main.py while True: for meter_addr in meter_list: try: data = read_meter_data(conn, meter_addr, di_list) meter_cache.update(meter_addr, data) except Exception as e: log.warning("read meter %s failed: %s", meter_addr, e) time.sleep(READ_INTERVAL) time.sleep(POLL_PERIOD - used_time)要说踩过的一个代码层面的坑:pysnmp 的回调函数必须保证线程安全,不能在回调里直接去读电表或做重 IO 操作。我第一次图省事在回调里动态查电表,结果网管一界面刷新就是几十个 get 串行请求,直接把 698 采集会话拖死了。后来的实现里,回调只查缓存字典,非常快,再没出过问题。5. 上线后遇到的三类问题:掉帧、超时、数据错位5.1 问题一:回包被TCP分割,解帧器看起来是对的空包上线第一天就碰上奇怪现象:程序偶尔能读到数据,偶尔直接空手而归,看日志像是“没等到完整帧”就超时了。排查链路是这样的:第一步,关闭 SNMP 服务,单独跑 698 采集,问题仍在,说明和 SNMP 无关。 第二步,在 recv 后打印收到的原始十六进制。发现同一个 698 响应,有时一次 recv 能收到 60 字节完整帧,有时只收到前 20 字节,剩下的要下一次 recv 才到。问题找到了:TCP 不会按“应用帧边界”给你数据,必须自己做缓冲重组。我的修复方案是在连接对象外部再加一层接收缓冲区,每次 recv 都先 append 到缓冲区,再从缓冲区尝试切帧。切帧时按照长度字段定位帧尾,而不是死等一次 recv。加上这个逻辑后,掉帧问题彻底消失。这个坑不算难,但很容易被忽视,尤其是做惯了 HTTP 接口的人,习惯了整包整包发,一遇到裸 TCP 流就蒙。5.2 问题二:SNMP取到的电压值小数点飞了把 698 数据成功解析之后,我用 snmpwalk 手工验证,发现 OID 虽然能返回,但数值明显不对——225 显示成了 2250,像是被放大十倍。我第一反应是 698 解析错了,于是打印了解析后的原始值,确认是 225.0V,没有错。那就只能是 SNMP 编码层的问题。再看 MIB 文件,当时的写法是:aVoltage OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current ::= { powerData 1 }而我在 Python 缓存里存的是字符串"225.0V",用 INTEGER 类型返回一个带单位的字符串,pysnmp 内部编码时自然要强制转换,小数点干脆被当成分隔符丢掉了,剩下的“2250”是个意外结果。修复方式有两个:一是把 MIB 改成 DisplayString;二是在缓存里改成单独的数值字段和单位字段,再把数值乘上缩放系数转成 INTEGER 返回。考虑到监控平台确实需要做阈值告警,我只在 MIB 里定义数字型字段,同时在描述里注明单位,例如:aVoltageValue OBJECT-TYPE SYNTAX INTEGER DESCRIPTION "A phase voltage, unit: 0.1V" ::= { powerData 1 }这样平台侧显示的时候直接除以 10 即可,更符合 SNMP 的取值规范。5.3 问题三:多表轮询时,电表偶发不应答项目接入的表从 5 块扩展到 30 块之后,发现轮询一轮的时间越拉越长,偶尔还有电表没有任何响应。排查后发现是两个原因叠加:一是集中器和电表之间的 RS485 半双工总线,本身就有通信时延。电表收到请求后如果线路忙,会延迟应答;如果集中器收到请求后又停顿太多,电表可能认为超时直接丢弃。二是我把所有电表放在一个 while 循环里连续请求,没有在每表之间加间隔。A表还没应答完,B表的请求就发出去了,总线冲突导致偶发丢包。解决办法很朴素:在请求之间增加 200ms 间隔,同时把每表超时从 3 秒调整到 5 秒。结果虽然一轮轮询变长了一些,但整体成功率从不到 90% 提升到了 100%。这也让我意识到:协议转换网关的瓶颈往往不在 CPU,而在底层的物理总线调度。5.4 排障方法小结:从现象到根因的排查流程这个项目我经历过不少排查,也总结出一套方法:现象先查哪里常见根因采集随机失败打印原始hex,看帧是否被拆缓冲重组缺失数据值异常大/小检查解析后的数据BCD解码、缩放系数错误SNMP返回错误MIB类型 vs 实际类型类型不匹配多表轮询慢看总线间隔和超时请求排队不合理偶发掉线翻日志确认是集中器还是电表侧TCP连接被重置这套流程的核心思想是:先区分问题出现在协议解析层、数据缓存层还是 SNMP 出口层,不要一上来就怀疑某个具体函数。用原始 hex 日志说话,基本上一小时能定位大多数问题。6. 复盘:还能怎么改,以及同类项目复用点6.1 换一种玩法:SNMP trap主动上报现在的方案是网管平台轮询 Agent,简单有效,但有一个短板:电表发生告警时,平台必须等下一个轮询周期才能发现。如果机房对告警实时性要求高,可以增加 SNMP trap 主动上报。具体思路是:在 698 采集线程里除了轮询数据,再定时读取电表的告警事件(比如电压越限、断电)。一旦发现有新告警,立即用 SNMP trap 发往网管平台的 trap 接收端口。这样平台的告警响应时间可以从分钟级缩短到秒级。代码上需要在缓存中记录“最近一次告警事件时间”,每次读到新的告警记录就和缓存的做对比,变化了就调用 pysnmp 的 NotificationOriginator 发送。要提醒的是,trap 的 OID 要单独规划,不要在数据 OID 下面乱放,否则平台无法区分。6.2 复用与扩展:给其他规约的启示这个项目的思路不只是 DL/T 698.45 能用。其他电表规约,比如 DL/T 645、Modbus RTU,本质都是一样:一边是串口/网络字节流、二进制数据点;另一边是 SNMP 或 MQTT 这样的管理协议。做成协议转换网关后,只需要替换“左侧”的解析模块,右边 SNMP Agent 和缓存架构完全不用动。如果后续要接入更多厂家电表,可以把 MIB 按“表结构”扩展成二维表格,用“表索引 + 数据列”的方式管理,这样平台侧可以通过 SNMP Table 来遍历所有电表数据,而不是给每块表单独定义几十个 OID。这个改造工作量不大,但收益很明显——平台配置时少了很多重复操作。6.3 如果重做一次,我会怎么优化回过头看这个项目,如果重做一次,我会在开始时就把三件事做扎实:一是提前和电表厂家确认完整的数据点表,把所有 DI、数据类型、缩放系数整理成 Excel 清单,再对着清单写解析代码,而不是边解析边猜。二是建立自动化回归测试。准备一批抓好的典型报文样本,每次解析代码有改动就跑一遍,防止 BD 码解析、缩放系数这种低级问题上线。三是在设计 MIB 时,就直接采用标准 DisplayString 或 INTEGER+ 单位描述,不求“让界面显示像一个电表”,而是求“让平台能稳定告警”。这几个教训其实都不是技术难点,而是工程习惯问题。协议转换项目的日常工作大部分时间不是写新代码,而是在修数据边界条件——但恰恰是这些条件,决定了网关能不能真正常年稳定跑下去。最后聊一点个人感受:做这种集成类的网关项目,最容易被低估的就是“数据语义”的转换。单纯把电压从 698 报文读出来再塞进 SNMP,只是表面功夫;真正值钱的是你懂得电表的 BCD 编码、集中器的时序、SNMP 的类型限制,并且能在中间做一层可靠的数据治理。这个能力,做过一次之后,后面接什么协议都会顺手很多。
今年接了个挺有意思的改造任务:某单位机房新装了一批电表,要求把电表数据接进统一的运维监控平台。电表侧走的是电力行业常见的 DL/T 698.45 规约(大家常说的 DTL698),而监控平台那边的南向接口只认 SNMP。两边就像一个人讲方言、一个人讲普通话,信息倒不是没有,就是互相听不懂。于是就有了这个“DTL698电表数据转SNMP协议”的小项目。整个项目做完,我最大的感受是:协议转换类需求看着不起眼,真正做起来要趟的坑不少——帧拆分、编码转换、数据类型映射、轮询时序,每一环都能让人折腾好几天。这篇文章把完整思路、关键实现和实测踩坑记录整理出来,给后面要做类似网关的同学一个参考。1. 为什么电表和网管之间需要一座“翻译桥”1.1 任务背景:一份“看不懂”的数据先说说需求和背景。某单位机房配电柜里安装了一批智能电表,负责监测市电进线、UPS出线、空调回路等几个关键节点的电压、电流、功率和电量。这些数据本身电表都有,问题在于要让运维平台能统一看到。电表厂家给的是集抄方案,数据通过 DL/T 698.45 规约上送到电力集抄系统,人可以在专用软件里看,但没法直接进网管平台。我们这边监控平台统一用的是 SNMP 协议,支持通过 OID 去轮询设备状态和数据。两边协议体系完全不同——698 规约是面向电能计量场景的分层报文协议,SNMP 则是面向 IT 设备的树状管理模型,中间没有任何现成的网关。方案有几条路:一是让运维同事去集抄系统里手工导数据,不现实;二是在电表旁边放协议转换模块,让电表去“说”SNMP,但很多电表固件不支持改协议;三是自己做一个协议转换网关,一边当 698 客户端去读电表,一边当 SNMP Agent 给网管平台回数据。我选了第三条,可控性最强,也方便后续扩展其他规约。1.2 链路架构:数据从电表到网管系统要过几道坎这个项目的整体数据流是这样的:电表 -> RS485总线/电力线载波 -> 集中器(或直接进网关串口) -> 网关进程 -> SNMP Agent -> 网管平台轮询实际项目里,机房里的电表已经接到一台集中器的 RS485 口上。集中器负责把多块电表的数据汇聚,对外提供以太网口,支持 TCP 方式访问。所以我的网关进程不直接接触物理串口,而是通过网络 TCP 连接去和集中器通信,再由集中器转成 RS485 命令下发电表。如果电表没有经过集中器、直接是串口线的话,思路也一样,只是把 TCP 客户端换成串口客户端,串口参数按电表手册配置。网关侧需要多线程:一个线程负责采集和缓存数据,另一个线程跑 SNMP Agent,响应网管平台的 get 请求。两侧通过共享缓存解耦,避免采集慢影响 SNMP 响应速度。1.3 选型理由:为什么用 Python、为什么走 SNMPv2c技术栈最终选了 Python,原因有几个:Python 生态里有 pysnmp 库,实现 SNMP Agent 的 get 响应很直接。DL/T 698.45 的报文解析是典型的字节流处理,Python 的 struct、bytes 操作很顺手,抓包调试也方便。网关部署在机房的一台小工控机上,数据量不大(几十块表)、轮询周期固定,Python 性能完全够用。如果用 Go 或 C++,性能确实更好,但开发周期会明显拉长,对于这种“先跑通、再优化”的项目不划算。SNMP 版本选了 v2c。v3 安全特性更好,但配置复杂,和监控平台的对接成本高。内部网络环境本身做了一层隔离,v2c 的 community(只读)够用,团队里运维也早就习惯了。2. DL/T 698.45 报文解析:从字节流到可读数据点2.1 帧结构:先解决“从串口怎么读到一坨完整数据”DL/T 698.45 的报文虽然分层多,但底层链路帧格式有明确的起始和结束标记。一个典型的数据帧是这样的:字段说明备注启动字符68H帧起始长度域报文长度1~2字节,小端控制域C区分固定帧/可变帧、请求/响应地址域表号/逻辑地址多字节链路用户数据应用层APDU核心业务数据帧校验CS从启动字符到数据末字节的累加和结束字符16H帧结束解析的第一步不是解析内容,而是“怎么从连续的字节流里切出一整帧”。TCP 连接不会保证一次 recv 刚好收到完整一帧,串口更是可能出现半帧、粘帧。所以我的接收缓冲区必须做重组逻辑:不断往缓冲区追加字节,每次循环查找以 68H 开头、以 16H 结尾的完整区域,如果没有就继续等;如果一帧里包含了多帧数据,就一次切好几帧出来。这个切帧逻辑简单,但很容易出错。原因是 698 报文里,应用层数据体也可能出现 68H 或 16H 这样的字节,如果不通过长度字段来定位帧尾,而是直接按“出现 16H 就切”,必然切错。正确的判断方式应该是:先按帧头的长度字段算出整帧总长,再检查对应位置是不是 16H。这样不管数据体里出现什么字节都不会混淆。2.2 应用层与数据标识:真正读懂电表在说什么链路层切出的是“信封”,应用层 APDU 才是“信”。在 DL/T 698.45 里,业务数据以“对象属性 + 数据标识”的形式组织。对于读数据来说,最关键的几个概念:调用标识:区分一次请求和对应的响应。服务类型:比如读请求、读响应、写请求、上报等。数据标识(DI):描述你要读的数据对象,比如组合有功总电能、A相电压、A相电流等。时标:很多数据附带时间戳,用于说明数据是什么时刻采集的。一开始我以为有完整的官方 DI 对照表就行,实际上还要结合“电表生产厂家的数据点表”。同一类电表,协议文档给出的是 DI 的规范含义,但每一个 DI 的数据类型可能是 BCD 编码、可能是无符号整数、可能是浮点数,字段顺序也各有差异。这块没法纯靠标准猜测,必须拿真实报文比对。举个例子,读电压时我要下发读请求,请求里带上“数据标识 + 属性ID”,电表返回的是这样一个结构:数据内容字节数示例含义数据标识4字节表示“电压数据标识”数据长度1字节后续数据的长度数据体依长度而定可能含电压实数值、单位、时标2.3 解析流程:拆帧、校验、编码转换一个都不能少在实际项目里,我把解析流程整理成了五步:按 68H 起始、长度域定位、16H 结束,从缓冲区切出完整一帧。计算 CS 校验和,如果校验不过,丢弃这一帧并记录日志。解析控制域和地址域,确认这一帧是发给谁的、是请求还是响应。解出应用层 APDU,根据服务类型跳到对应处理分支。对数据体按字节长度逐个解码,常见的是 BCD 码转十进制、无符号整数恢复数值、浮点数按厂家定义缩放。这里面最容易出问题的是 BCD 码。BCD 是一字节表示两位十进制数,比如字节 0x12 代表数值 12,而不是十六进制的 18。很多第一次写 698 解析的同学,直接把字节当成十进制整数读,结果电压变成了几十倍、几百倍,排查半天才发现是 BCD 解码写错了。2.4 请求-响应时序与超时机制698 是标准的请求-响应模式,不会主动往网关推数据。所以要拿到电表的最新值,必须定时去“问”。时序上有两个坑:一是请求之间的间隔不能太短,电表端接收缓冲区不会无限快,连续高频请求可能导致集中器或电表无响应;二是响应超时时间不能太长,否则一块表超时 10 秒,几十块表轮询一圈下来,数据新鲜度就没法保证。我最终把单表请求超时设为 3 秒、重试 2 次,轮询周期设为 15 秒一轮。每次请求只读一个数据点组的多个属性(比如一次把 A、B、C 三相电压都读回来),这样比逐属性挨个读省一半时间。3. SNMP侧设计:私有MIB、OID表与agent回值逻辑3.1 私有MIB与OID规划SNMP 的数据模型是一棵 MIB 树,所有数据都能映射成一个 OID。公网设备有自己的 OID,私有业务则统一放到 enterprises 分支下面。我这个项目没有申请独立企业号,直接规划了一個私有的子树结构,形如:1.3.6.1.4.1.<企业号>.5.1在这个根节点下,我按“表结构 + 数据类型”做了 OID 规划:OID 后缀含义数据类型.1.1表号(电表地址)OCTET STRING.1.2通信状态INTEGER(0在线/1离线).2.1 到 .2.9A/B/C 相电压OCTET STRING(以字符串返回数值和单位).3.1 到 .3.9A/B/C 相电流OCTET STRING.4.1有功功率OCTET STRING.5.1组合有功总电能OCTET STRING没有用 INTEGER 而是用 OCTET STRING,是实测后做出的选择。698 返回的数值经过 BCD 和缩放后,可能需要保留两位小数,比如电压是 225.60V。如果直接用 INTEGER 返回,要么丢失小数,要么需要额外定义缩放系数。OCTET STRING 直接把格式化好的字符串“225.60V”交给网管,虽然不够“标准”,但监控平台显示和告警阈值配置反而最简单。真要追求规整,可以在 MIB 里再用 Counter32/DisplayString 做二次映射。3.2 MIB文件编写:让网管平台认得你的数据为了让网管平台能配置这些 OID,我需要提供一个 MIB 文件。核心片段长这样:dtl698MIB MODULE-IDENTITY LAST-UPDATED "202401010000Z" ORGANIZATION "Project Team" CONTACT-INFO "local engineer" DESCRIPTION "DL/T 698.45 meter data to SNMP private MIB" ::= { enterprises 123456 5 1 } meterTable OBJECT IDENTIFIER ::= { dtl698MIB 1 } powerData OBJECT IDENTIFIER ::= { dtl698MIB 2 } meterNo OBJECT-TYPE SYNTAX OCTET STRING MAX-ACCESS read-only STATUS current DESCRIPTION "meter logic address" ::= { meterTable 1 } aVoltage OBJECT-TYPE SYNTAX DisplayString MAX-ACCESS read-only STATUS current DESCRIPTION "A phase voltage" ::= { powerData 1 }注意 MIB 文件里定义的 OID 必须和 agent 内部注册的 OID 完全一致,否则平台加载 MIB 后能看见名字,但真正 get 的时候会返回 noSuchObject。这点我一开始没对上,排查了好久。3.3 Agent实现选择:pysnmp 还是脚本 passthruSNMP Agent 实现方式有两种主流选择:用 pysnmp 的库直接在 Python 进程里实现 Agent,好处是数据缓存和协议转换在同一个程序里,状态好管理。用 net-snmp 的 pass_persist 机制,让 SNMP 请求转发给外部脚本,脚本从缓存文件里读数据返回,好处是 Agent 进程本身很稳定,但多了一层进程通信,调试稍微麻烦。我选了 pysnmp 直接做 Agent。原因很简单:这个项目的 Agent 和采集进程要共享同一份缓存,拆成两个进程还要处理文件锁和进程通信,没必要。pysnmp 里只需要注册一个回调函数,网管请求到达时,从缓存里查出这个 OID 对应的最新值返回即可。一个很关键的点:pysnmp 的 Agent 注册 OID 时,默认支持的是 MIB 中定义的对象,如果自定义 OID 没有加载 MIB,返回会给 noSuchObject。所以我干脆不依赖自动加载 MIB,而是手动注册一张“OID 到取值函数”的字典。这样反而透明可控,也方便后续加入告警状态。4. 网关主程序:采集轮询、缓存与并发模型4.1 主循环:采集器怎么安排轮询顺序网关启动后,主线程就是一个定时轮询循环。流程如下:从配置表读取电表地址清单。逐个电表建立 698 会话,发送读请求。等待响应,成功则解析并写入缓存,失败则按超时退避重试。一轮结束后 sleep 到下一个周期,开始下一轮。第一步的“建立会话”其实不是传统意义的 TCP 三次握手,而是指在集中器连接上发送 698 链路帧,并用地址域区分不同电表。因为集中器对外只有一条 TCP 连接,所有电表的数据都在这条连接里传输,所以主循环必须保证同一时刻只有一个读请求在等待响应,避免多线程并发请求导致集中器返回错乱。4.2 数据缓存:为什么必须自己hold住最新值SNMP 网管平台轮询设备时,get 请求随时可能进来。如果每次收到 get 请求才去读电表,整个 SNMP 响应会非常慢,而且电表链路一旦被占用,还可能互相阻塞。所以我单独维护了一个“最新值缓存”:meter_cache = { "meter_001": { "a_voltage": ("225.60V", 1712332800), "b_voltage": ("225.30V", 1712332800), "online": 1, ... } }采集线程每 15 秒更新一次 key,SNMP 线程只读不写,两边用一个简单的线程锁保护。网管轮询间隔通常 30 秒到 5 分钟,缓存里 15 秒内的数据完全够用,这个设计也把“采集偶发超时”造成的影响和 SNMP 侧隔离开来。4.3 核心代码骨架:一半是采集,一半是SNMP给大家看几个关键模块的骨架代码,不贴完整实现,但思路可以直接抄。# meter_reader.py # 负责建立集中器连接、发送698读请求、解析响应 import socket def read_meter_data(conn, meter_addr, di_list): frame = build_dlt698_read_request(meter_addr, di_list) conn.send(frame) raw = recv_full_frame(conn) # 切帧处理 check_cs(raw) # 校验 body = parse_apdu(raw) # 解析应用层 return decode_data(body, di_list) # BCD/数值解码# snmp_service.py # 用pysnmp注册OID,返回缓存中的最新值 from pysnmp.entity import engine, config from pysnmp.entity.rfc3413 import cmdrsp def handle_get_request(snmp_engine, req): value = meter_cache.lookup(str(req.oid)) return (req.oid, value) config.add_v1_system(snmp_engine, "public") config.add_v2c_system(snmp_engine, "public") config.add_transport(snmp_engine, udp_domain.snmpUDPTransportEndpoint(("0.0.0.0", 161)))# main.py while True: for meter_addr in meter_list: try: data = read_meter_data(conn, meter_addr, di_list) meter_cache.update(meter_addr, data) except Exception as e: log.warning("read meter %s failed: %s", meter_addr, e) time.sleep(READ_INTERVAL) time.sleep(POLL_PERIOD - used_time)要说踩过的一个代码层面的坑:pysnmp 的回调函数必须保证线程安全,不能在回调里直接去读电表或做重 IO 操作。我第一次图省事在回调里动态查电表,结果网管一界面刷新就是几十个 get 串行请求,直接把 698 采集会话拖死了。后来的实现里,回调只查缓存字典,非常快,再没出过问题。5. 上线后遇到的三类问题:掉帧、超时、数据错位5.1 问题一:回包被TCP分割,解帧器看起来是对的空包上线第一天就碰上奇怪现象:程序偶尔能读到数据,偶尔直接空手而归,看日志像是“没等到完整帧”就超时了。排查链路是这样的:第一步,关闭 SNMP 服务,单独跑 698 采集,问题仍在,说明和 SNMP 无关。 第二步,在 recv 后打印收到的原始十六进制。发现同一个 698 响应,有时一次 recv 能收到 60 字节完整帧,有时只收到前 20 字节,剩下的要下一次 recv 才到。问题找到了:TCP 不会按“应用帧边界”给你数据,必须自己做缓冲重组。我的修复方案是在连接对象外部再加一层接收缓冲区,每次 recv 都先 append 到缓冲区,再从缓冲区尝试切帧。切帧时按照长度字段定位帧尾,而不是死等一次 recv。加上这个逻辑后,掉帧问题彻底消失。这个坑不算难,但很容易被忽视,尤其是做惯了 HTTP 接口的人,习惯了整包整包发,一遇到裸 TCP 流就蒙。5.2 问题二:SNMP取到的电压值小数点飞了把 698 数据成功解析之后,我用 snmpwalk 手工验证,发现 OID 虽然能返回,但数值明显不对——225 显示成了 2250,像是被放大十倍。我第一反应是 698 解析错了,于是打印了解析后的原始值,确认是 225.0V,没有错。那就只能是 SNMP 编码层的问题。再看 MIB 文件,当时的写法是:aVoltage OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current ::= { powerData 1 }而我在 Python 缓存里存的是字符串"225.0V",用 INTEGER 类型返回一个带单位的字符串,pysnmp 内部编码时自然要强制转换,小数点干脆被当成分隔符丢掉了,剩下的“2250”是个意外结果。修复方式有两个:一是把 MIB 改成 DisplayString;二是在缓存里改成单独的数值字段和单位字段,再把数值乘上缩放系数转成 INTEGER 返回。考虑到监控平台确实需要做阈值告警,我只在 MIB 里定义数字型字段,同时在描述里注明单位,例如:aVoltageValue OBJECT-TYPE SYNTAX INTEGER DESCRIPTION "A phase voltage, unit: 0.1V" ::= { powerData 1 }这样平台侧显示的时候直接除以 10 即可,更符合 SNMP 的取值规范。5.3 问题三:多表轮询时,电表偶发不应答项目接入的表从 5 块扩展到 30 块之后,发现轮询一轮的时间越拉越长,偶尔还有电表没有任何响应。排查后发现是两个原因叠加:一是集中器和电表之间的 RS485 半双工总线,本身就有通信时延。电表收到请求后如果线路忙,会延迟应答;如果集中器收到请求后又停顿太多,电表可能认为超时直接丢弃。二是我把所有电表放在一个 while 循环里连续请求,没有在每表之间加间隔。A表还没应答完,B表的请求就发出去了,总线冲突导致偶发丢包。解决办法很朴素:在请求之间增加 200ms 间隔,同时把每表超时从 3 秒调整到 5 秒。结果虽然一轮轮询变长了一些,但整体成功率从不到 90% 提升到了 100%。这也让我意识到:协议转换网关的瓶颈往往不在 CPU,而在底层的物理总线调度。5.4 排障方法小结:从现象到根因的排查流程这个项目我经历过不少排查,也总结出一套方法:现象先查哪里常见根因采集随机失败打印原始hex,看帧是否被拆缓冲重组缺失数据值异常大/小检查解析后的数据BCD解码、缩放系数错误SNMP返回错误MIB类型 vs 实际类型类型不匹配多表轮询慢看总线间隔和超时请求排队不合理偶发掉线翻日志确认是集中器还是电表侧TCP连接被重置这套流程的核心思想是:先区分问题出现在协议解析层、数据缓存层还是 SNMP 出口层,不要一上来就怀疑某个具体函数。用原始 hex 日志说话,基本上一小时能定位大多数问题。6. 复盘:还能怎么改,以及同类项目复用点6.1 换一种玩法:SNMP trap主动上报现在的方案是网管平台轮询 Agent,简单有效,但有一个短板:电表发生告警时,平台必须等下一个轮询周期才能发现。如果机房对告警实时性要求高,可以增加 SNMP trap 主动上报。具体思路是:在 698 采集线程里除了轮询数据,再定时读取电表的告警事件(比如电压越限、断电)。一旦发现有新告警,立即用 SNMP trap 发往网管平台的 trap 接收端口。这样平台的告警响应时间可以从分钟级缩短到秒级。代码上需要在缓存中记录“最近一次告警事件时间”,每次读到新的告警记录就和缓存的做对比,变化了就调用 pysnmp 的 NotificationOriginator 发送。要提醒的是,trap 的 OID 要单独规划,不要在数据 OID 下面乱放,否则平台无法区分。6.2 复用与扩展:给其他规约的启示这个项目的思路不只是 DL/T 698.45 能用。其他电表规约,比如 DL/T 645、Modbus RTU,本质都是一样:一边是串口/网络字节流、二进制数据点;另一边是 SNMP 或 MQTT 这样的管理协议。做成协议转换网关后,只需要替换“左侧”的解析模块,右边 SNMP Agent 和缓存架构完全不用动。如果后续要接入更多厂家电表,可以把 MIB 按“表结构”扩展成二维表格,用“表索引 + 数据列”的方式管理,这样平台侧可以通过 SNMP Table 来遍历所有电表数据,而不是给每块表单独定义几十个 OID。这个改造工作量不大,但收益很明显——平台配置时少了很多重复操作。6.3 如果重做一次,我会怎么优化回过头看这个项目,如果重做一次,我会在开始时就把三件事做扎实:一是提前和电表厂家确认完整的数据点表,把所有 DI、数据类型、缩放系数整理成 Excel 清单,再对着清单写解析代码,而不是边解析边猜。二是建立自动化回归测试。准备一批抓好的典型报文样本,每次解析代码有改动就跑一遍,防止 BD 码解析、缩放系数这种低级问题上线。三是在设计 MIB 时,就直接采用标准 DisplayString 或 INTEGER+ 单位描述,不求“让界面显示像一个电表”,而是求“让平台能稳定告警”。这几个教训其实都不是技术难点,而是工程习惯问题。协议转换项目的日常工作大部分时间不是写新代码,而是在修数据边界条件——但恰恰是这些条件,决定了网关能不能真正常年稳定跑下去。最后聊一点个人感受:做这种集成类的网关项目,最容易被低估的就是“数据语义”的转换。单纯把电压从 698 报文读出来再塞进 SNMP,只是表面功夫;真正值钱的是你懂得电表的 BCD 编码、集中器的时序、SNMP 的类型限制,并且能在中间做一层可靠的数据治理。这个能力,做过一次之后,后面接什么协议都会顺手很多。