简介DL/T 645-2007《多功能电能表通信协议》最终版完整文档由电力行业电测量标准化技术委员会归口发布替代了1997年旧版面向电能表研发、用电信息采集系统开发、计量运维及规约测试人员。文档系统规定了多功能电能表与数据采集终端之间在物理层、数据链路层、数据标识及应用层的通信规范包含RS-485、红外等物理接口要求并详细定义了帧格式、CRC校验、4字节数据标识编码、防冲突机制以及读通信地址、冻结、电表清零等控制命令可用于远程抄表、参数设置、负荷记录读取等典型场景。压缩包内为单个doc格式文档大小5.08MB除协议正文外还附有数据编码、负荷记录结构、状态字定义及铜损铁损算法等附录。目前已有131人学习下载。阅读后可快速建立对DL/T 645规约的整体认知掌握数据帧解析、命令交互和异常处理等核心细节为设备联调、跨厂商互联提供明确依据。1. DLT645-2007 多功能电能表通信协议先看这几张表再动手串口抓包一打开满屏都是FE FE FE偶尔蹦出几个68这就是我当年第一次接触 DLT645-2007 多功能电能表通信协议时的真实画面。这份“最终版-2007”通讯规约文档说白了就是把主站、采集终端和多功能电能表之间的对话规则定死帧怎么组、地址怎么填、数据标识怎么查、校验怎么算。别被 PDF 里上百页的附录吓住你日常联调真正高频用到的也就十来张表。做能源管理平台、充电桩计费、光伏并网采集、工厂能耗监控的开发者只要涉及电表抄读最后都会回到这份文档上。我的建议是先抓一帧报文再对着协议看比从头到尾啃文档快得多。2. 报文结构地址域、控制码与 CS 校验范围2.1 一帧报文的骨架与三个最容易看错的字节DLT645-2007 的帧结构非常固定搞清楚这一张表等于读懂了协议一半。完整报文从前往后依次是前导字节、帧起始符、地址域、帧起始符、控制码、数据域长度、数据域、校验字、结束符具体如下字段字节数值/说明前导字节≥4FE让表计做好接收准备帧起始符168H地址域 A0~A56表号 BCD 码低字节在前帧起始符168H控制码 C1方向、广播、功能一次说清数据域长度 L1后面 DATA 的字节数0~200数据域 DATAL数据标识 数据本体校验 CS1累加和模 256结束符116H控制码这一个字节值得展开。它的 D7 位决定传输方向主站发给表计时为 0表计应答时为 1D6 位标记是否还有后续帧数据超过 200 字节时这个位就会置 1D5 位是广播标志置 1 表示所有表计都要执行但不应答。剩下 D4~D0 才是真正的功能码读数据是0x01读后续数据是0x02写数据是0x04广播校时是0x08。我见过不少新手把控制码当成固定值直接写死0x01去发遇到多帧续传就懵了。控制码必须动态组装D7 和 D6 位跟着场景变。数据域长度 L 最容易埋雷。L 只表示“数据域”的长度不含控制码、不含 CS。读数据请求的 L 恒等于 4因为数据域里只有那 4 个字节的数据标识。有些表计应答时会在数据域里塞一个状态字L 就会比你想的多 1 字节解析时如果按固定偏移取数后面的数据全错位。2.2 地址域表号为什么是倒着发的地址域是 DLT645 报文里最容易让新人血压升高的地方。规约规定地址域由 6 个字节组成每个字节存 2 位 BCD 码一共表达 12 位十进制表号传输时低字节在前。这里多说一句不是每个厂家都严格遵守但绝大多数表计以这个为准。从代码实现角度看把 12 位数字字符串转成 6 字节地址域最可靠的做法是从右往左每两位装一个字节def addr_to_bytes(addr_str: str) - bytes: 把 12 位十进制表号转成 DLT645 地址域 6 字节。 规则表号从低位开始每两位一组装入一个字节 每组内高半字节在前、低半字节在后。 例表号末两位是 12A0 就是 0x12。 addr_str addr_str.zfill(12)[-12:] # 只保留末 12 位 out [] for byte_idx in range(6): hi int(addr_str[10 - byte_idx * 2]) # 当前字节的高位 BCD 数字 lo int(addr_str[11 - byte_idx * 2]) # 当前字节的低位 BCD 数字 out.append((hi 4) | lo) return bytes(out)这段代码里zfill(12)是给不足 12 位的表号补前导零比如拿到的是 11 位老表号补位后逻辑就统一了。循环里byte_idx从 0 到 5每次从字符串右侧往前取两位所以表号123456789012的地址域是12 90 78 56 34 12。这里有个坑有些集中器厂商的开发板示例代码看起来是“正序”展示地址域抓包对比时会吓你一跳我一般以电表厂家出厂测试报文的地址域顺序为准先确认再写业务逻辑。广播地址不用转换直接 6 个字节全0xAA即b\xAA * 6。广播校时、广播冻结这类命令才用它普通点对点抄表如果误发了广播地址所有表都会响应总线直接乱掉。2.3 CS 校验算错一位连不上表CS 校验的算法很简单从第一个68H帧起始符开始到数据域最后一个字节结束把所有字节累加丢弃溢出位也就是和对 256 取余。前导字节FE不参与结束符16H也不参与。def calc_cs(body: bytes) - int: 计算 DLT645 CS 校验字。 body 必须是从帧起始符 68H 开始、 到数据域最后一个字节为止的完整内容。 cs 0 for b in body: cs (cs b) 0xFF # 模 256 累加 return cs def build_frame(addr: bytes, ctrl: int, data: bytes) - bytes: 组一帧完整的 DLT645 报文自动附加 CS 与结束符。 addr: 6 字节地址域 ctrl: 控制码如 0x01 读数据 data: 数据域内容 body b\x68 addr b\x68 bytes([ctrl, len(data)]) data cs calc_cs(body) return body bytes([cs, 0x16])build_frame返回的是不带前导字节的完整帧。实际发送时前面要补几个FE我一般补 4 个遇到老表会补到 8 个多补不坏事但少补可能丢帧。校验字部分有两条血泪经验一是别把16H算进去CS 只负责保护到数据域末尾二是别把前导FE算进去否则 CS 永远对不上。另外len(data)最大不超过 200超过就得走后续帧机制这个话题第 6 章再展开。3. 读数据实战从 0x01 请求帧到 0x81 应答帧3.1 组装一帧读请求先拿正向有功练手读数据是最常用的操作控制码0x01数据域只放 4 字节数据标识。以读正向有功总电能量为例数据标识是00 00 00 00组装代码如下ADDR addr_to_bytes(123456789012) # 目标表号 DI bytes.fromhex(00 00 00 00) # DI000 电能量 req build_frame(ADDR, 0x01, DI) # 控制码 0x01 读数据 frame_lines .join(f{b:02X} for b in req) print(frame_lines)输出里能看到完整的请求帧68 12 90 78 56 34 12 68 01 04 00 00 00 00 8B 16。解析一下第一段68是帧起始符12 90 78 56 34 12是地址域中间的68是第二个帧起始符01是控制码04是数据域长度四个00是数据标识8B是 CS16收尾。发送时直接通过串口写出去就行。这里有两个细节数据域长度必须等于数据标识的长度读请求永远是04数据标识四字节的发送顺序是 DI0、DI1、DI2、DI3别和文档里表格的展示顺序搞反了。我遇到过有人按文档表格从左到右发 DI3 DI2 DI1 DI0结果是电表返回无效数据标识。3.2 应答帧解析先找 68 再找 16别信固定偏移表计收到正确请求后应答控制码是0x81D7 位置 1 表示这是从站发给主站的帧。但串口缓存里不只有这一条数据可能有半截残帧、前一帧的尾巴所以解析一定要做“搜帧”而不是“按位置取字节”。def extract_frame(buf: bytes): 从串口缓存里提取一帧完整 DLT645 报文。 跳过前导 FE找到 68 后校验第二个 68、长度和结束符 16H。 返回完整帧拿不到就返回 None。 i 0 while i len(buf) and buf[i] 0xFE: i 1 if i 11 len(buf): return None if buf[i] ! 0x68 or buf[i 7] ! 0x68: return None n buf[i 9] if n 200 or i 10 n 2 len(buf): return None if buf[i 10 n 1] ! 0x16: return None return buf[i:i 10 n 2] raw extract_frame(b\xFE\xFE\xFE data_from_uart) if raw and calc_cs(raw[:-2]) raw[-2]: ctrl raw[8] length raw[9] payload raw[10:10 length] print(fcontrol0x{ctrl:02X}, payload{payload.hex()})extract_frame的逻辑是串口解析的标准姿势先跳过所有前导字节再验证两个68H是否存在从buf[i 9]取长度字最后确认结束符。校验时把raw[:-2]交给calc_cs这个切片正好是从68H到数据域末尾算出来的值和raw[-2]比对。应答帧里控制码0x81表示这一次读成功了0xC0表示异常应答数据域第一字节就是错误字。很多采集程序只判断“有没有收到帧”不判断控制码结果把异常应答当成正常数据处理这是很隐蔽的数据污染源。3.3 数据标识分类与 BCD 精度读懂那四字节数据标识是整个协议最庞大的部分但框架分得很清楚。DI0 决定大类DI1~DI3 在大类里细化。掌握了这个分类查附录快很多DI0数据类型典型用途00H电能量正反向有功、无功、四象限01H最大需量各费率需量及发生时间02H变量电压、电流、功率、频率、功率因数03H事件记录掉电、清零、编程事件04H参变量时间、费率、密码、表底设置05H冻结量整点冻结、日冻结数据读电能量时 DI000常见读法是从“正向有功总”开始也就是00 00 00 00。读变量里 A 相电压DI002不少表计接受02 01 01 01这种写法。注意变量的具体标识在不同厂家版本里略有出入我通常先用协议附录确认再拿表计手动抄读一次对拍。电能量数据的返回体一般长这样数据标识 4 字节 数据长度 1 字节 BCD 码数据 状态字。解析 BCD 码时得把数据域和地址域的字节序区分开——地址域是低字节在前但数据域里的数值是高位在前。如下def bcd_to_int(b: int) - int: return (b 4) * 10 (b 0x0F) def bcd_bytes_to_number(data: bytes) - int: 数据域中的 BCD 数值按高位在前解析。 如 0x12 0x34 0x56 0x78 解析为 12345678。 v 0 for b in data: v v * 100 bcd_to_int(b) return v电能值默认精度是 0.01也就是说读出来12345678对应的实际电能量是123456.78 kWh。如果状态字也混在数据后面记得先按长度字截干净别把状态字当成电能的一部分去算差几个数量级都是它引起的。4. 写参数与广播校时密码域、操作者代码与 0x04 控制码4.1 写数据帧结构密码 操作者代码是硬门槛写参数比读数据多两道门槛密码验证和操作者代码。DLT645-2007 里写数据主站发的控制码是0x04数据域结构固定为数据标识 4 字节 密码 4 字节 BCD 操作者代码 2 字节 BCD 待写数据。出厂表计默认密码一般是00000000操作者代码常见0000但通电前最好问厂家要默认值免得连写密码都踩坑。def build_write_frame(addr: bytes, di: bytes, password: bytes, operator: bytes, value: bytes) - bytes: 组装写参数请求。 di: 4 字节数据标识 password: 4 字节 BCD 密码 operator: 2 字节 BCD 操作者代码 value: 要写入的参数数据字节序见协议附录 if len(password) ! 4 or len(operator) ! 2: raise ValueError(password must be 4 bytes, operator must be 2 bytes) data di password operator value return build_frame(addr, 0x04, data)写之前有个前提必须确认表计的编程开关是不是打开了。很多表计软件编程开关和硬件端子是“与”的关系只开软件不开硬件端子照样拒绝写。我在现场吃过亏软件侧发了编程允许命令但表计端子盖板里的拨片没拨写命令全被当成垃圾帧。确认编程开关状态后再发写命令顺序不能反。4.2 广播校时一次喊话全员执行校时是集采项目里最头疼的事表多、时间乱、每天漂移。DLT645 的广播校时控制码是0x08地址域用全0xAA数据域放 6 字节时间。广播校时的特点在于不期待应答、所有表计同时执行所以不能拿普通读表的超时重发逻辑去套它。broadcast_addr b\xAA * 6 # 广播地址 now_t time.localtime() # 时间域按规约顺序拼装不同厂家字节序略有差异 # 我一般先参考协议附录确认秒/分/时/日/月/年的排列再填。 time_bytes bytes([ now_t.tm_sec 0xFF, now_t.tm_min 0xFF, now_t.tm_hour 0xFF, now_t.tm_mday 0xFF, now_t.tm_mon 0xFF, (now_t.tm_year % 100) 0xFF, ]) req build_frame(broadcast_addr, 0x08, time_bytes) ser.write(b\xFE\xFE\xFE req)注意时间域的填充顺序不同厂家对这个 6 字节包的排列方式不完全一致有的按“秒分时日月年”有的按“年月日时分秒”。我在项目里吃过一次亏按某个厂家的排列写了一个校时功能换了一批表后时间被写成 2052 年。从那以后我把时间域字节序的确认动作放进了联调 checklist每个厂家第一批样表必须先用读命令把时间读出来对比本地位后再写。单表校时不用广播地址。如果要逐表校时控制码用0x04写数据数据标识指向“时间参数”数据域同样带密码和操作者代码。相比广播校时单表校时能收到应答适合要求严格校时成功率的场景。4.3 异常应答 0xC0错误字一张表查完写数据失败时表计会回0xC0异常应答帧数据域第一字节是错误字。这张表建议打印出来贴在工位上错误字含义常见触发原因0x01校验错误CS 算错或前导字节混进数据域0x02无效数据标识标识不在协议附录或 DI 顺序发反0x03数据长度错误数据域长度 L 和实际内容不匹配0x04密码错误默认密码被改过或 BCD 密码拼错0x80其他错误编程开关关闭、参数超范围解析异常帧时不要只打印“收到 0xC0”就完事。把错误字解析出来联调效率能翻倍比如看到 0x04 就不用重新检查报文组帧了直接去问密码管理员。raw extract_frame(buf) if raw and calc_cs(raw[:-2]) raw[-2]: ctrl raw[8] if ctrl 0xC0: err raw[10] # 数据域第一字节 err_map {0x01: CS error, 0x02: invalid DI, 0x03: length error, 0x04: password err, 0x80: other err} print(meter rejected:, err_map.get(err, unknown))写数据还有一个原则失败不要立刻重发同一条命令。先读一次目标参数确认表内当前值很多支持“预写入”的表计第一次写失败可能只是编程开关抖动直接重发可能撞上表计正在忙。先读后写写后读回校验这套流程下来基本不会出幺蛾子。5. 联调避坑串口收不到帧、CS 不过、精度差 100 倍的排查5.1 现象串口工具收到一堆 FE正常报文总是闪一下就消失原因波特率、校验位不匹配。DLT645-2007 默认波特率是 2400bps偶校验8 数据位1 停止位也就是串口参数里的2400 E 8 1。但很多 USB 转 485 工具出厂默认是 9600 8 N 1正好把表计发出来的帧读成乱码或只有前导字节。解决把串口参数改成2400 E 8 1再抓包。如果还是全FE检查 RS485 的 A/B 线是不是接反了反接的时候总线电平完全错乱表现就是主站发出去没反应、收回来全是 0xFE。用万用表量 A/B 之间的静态电压正常应在 2V~6V 之间低于这个值就查供电和终端电阻。现场如果有多台表还要确认总线没有环路、手拉手接线确实到最后一台表。5.2 现象CS 校验永远不过用串口助手看报文又觉得哪都对原因把前导字节FE或结束符16H算进 CS 范围了。CS 的计算起点是帧起始符68H终点是数据域最后一个字节。很多新手用手工计算器从第一个FE开始累加结果当然对不上。解决代码里严格按raw[:-2]这个切片去算 CS也就是完整帧去掉最后的 CS 和结束符。我一般还在组帧函数里加一个自检assert calc_cs(body) body[-2]这样发的每一帧都先内部验证一遍CS 问题在源头就堵死了。5.3 现象读上来的表号是倒序的或者对不上原因地址域低字节在前没做处理。你按字符串顺序“从左往右”装字节和规约要求的“从右往左每两位一组”完全相反解析时也一样收到的 6 字节地址按正序拼接就反了。解决封装一套addr_to_bytes和它的逆函数bytes_to_addr所有进出的地址都走同一套转换不要散落在各个模块里手工切片。抓包时看到一个示例帧先把地址域解出来对照表计铭牌确认无误再继续。另一点13 位表号通常只取后 12 位遇到头位是 0 的也先zfill到 12 位再转别自己脑补截断规则。5.4 现象电能值读回来了但和表计屏幕显示差 10 倍或 100 倍原因BC D 小数位约定不统一。DLT645-2007 对电能量类数据默认按两位小数处理读12345678是123456.78 kWh。但有些表厂把数据按四位小数上报新装的采集程序却按两位解析数值直接差 100 倍。差 10 倍的情况通常是状态字混进了数据长度里多算了一个字节。解决联调第一件事读一帧00 00 00 00正向有功总和表计液晶屏上的底数对拍。差 100 倍就改数据精度系数差 10 倍就检查数据域切片是不是多了 1 字节。用代码写死精度之前先确认协议附录里该数据标识对应的小数位数别拿现场表的屏幕显示当唯一标准因为液晶屏上的显示精度可能和通讯数据精度不是同一套配置。5.5 现象写参数返回成功但表计掉电或过几分钟又变回原值原因编程开关没合上或者写的是运行参数区而不是固化存储区。部分表计在编程开关关闭时会假装写成功实际数据只活在 RAM 里。另一部分表计则需要先写“允许编程”的参变量再写目标参数顺序反了照样丢数据。解决写之前先查询编程开关状态确认是“允许”再发作。写完之后立刻读回该参数和写入值比对不等掉电就验证。掉电后再读一次如果又变回去了直接找厂家要固化流程。这个坑在批量换费率时段时特别烧钱几百台表写完了巡检发现半数没存住等于白干一晚上。6. 把抄表流程做成状态机轮询、超时与重试技巧现场抄表不是发一帧收一帧那么理想。总线上一堆表每台表响应时间不同有的 100ms 就回有的要 300ms偶发干扰还可能丢帧。我习惯把抄表写成一个小状态机而不是顺序读代码里硬等。class Dlt645Poller: def __init__(self, ser, addr_str, di_hex, retry3, timeout0.8): self.ser ser self.addr addr_to_bytes(addr_str) self.di bytes.fromhex(di_hex) self.retry retry self.timeout timeout def read_once(self): req build_frame(self.addr, 0x01, self.di) for _ in range(self.retry): self.ser.write(b\xFE\xFE\xFE req) raw self._wait_frame() if raw and calc_cs(raw[:-2]) raw[-2]: return raw return None def _wait_frame(self): buf b deadline time.time() self.timeout while time.time() deadline: chunk self.ser.read(self.ser.in_waiting or 1) if chunk: buf chunk raw extract_frame(buf) if raw: return raw if len(buf) 1024: buf buf[-32:] # 防止垃圾数据无限堆积 return None这个状态机的核心动作只有三个发请求、等完整帧、校验。retry控制重试次数timeout控制单次等待上限。缓冲区裁剪这里有个小技巧只保留末尾 32 字节因为一帧最长也就 200 多字节保留太多反而把旧数据带进下一次匹配。串口读取用in_waiting or 1避免空读忙等这是串口编程里最常见的性能隐形杀手。状态动作超时/异常处理空闲组帧、加前导、发送发送异常直接复位串口等待应答循环收字节、搜帧超时重发连续失败记录点位解析校验验 CS、判控制码CS 失败丢弃0xC0 解析错误字数据上送BCD 转数值、按倍率换算数值非法打日志不覆盖旧值轮询多块表时我会把每块表单独封装一个Dlt645Poller实例逐表按顺序调用read_once。如果某块表连续三次读失败不要死等把它标记为异常点位继续下一块等整个总线扫完再补采。这样单表故障不会拖垮整站抄读周期。还有一点关于多帧续传当数据域超过 200 字节表计会分多帧发第一帧控制码的 D6 位置 1后面需要用控制码0x02发“读后续数据”。状态机里识别到 D61 时要把第一帧的帧序号和数据块序号保存下来紧接着组0x02请求继续读。我见过有人不管 D6只取第一帧前 200 字节结果最大需量、冻结量这类大块数据永远读不全。后来我给自己定了个死规矩接任何新厂家、新批次的表第一件事不是写业务逻辑而是组一帧00 00 00 00把正向有功总读回来抓包确认地址字节序、CS 算法和电能精度都对得上再往上层铺业务。这套“最小验证”流程替我挡掉了至少五成联调事故。希望帮到你。本文还有配套的精品资源点击获取