
干过 DL/T645-2007 电表采集项目的人可能都经历过这种抓狂时刻串口助手发了一帧自己反复核对过的读电压命令对面却一点反应都没有。查波特率、查校验位、查 RS-485 接线全都没问题最后发现是 6 字节的电表地址解析顺序搞错了。这篇文章就把从电表地址解析到三相电压读取的 5 个关键步骤完整过一遍适合正在做能耗采集、自动化抄表或设备接入的开发者参考。1. 协议地基帧结构、控制码和数据标识的底层逻辑不管是做工厂能耗平台、园区分表计量还是智能配电项目只要涉及电能表数据采集绕不开 DL/T645-2007 这套协议。它定义了主站采集器、网关、电脑和从站电能表之间通过 RS-485 串口通信的数据格式。说白了一句话你按什么格式发指令表怎么回你全在这份标准里。1.1 一帧数据长什么样DL/T645-2007 的报文格式非常固定从主站请求到从站应答结构都是一样的。我用一张表把帧结构说清楚。部分字节数内容帧起始符168H固定不变地址域6A0~A5共 6 字节BCD 码低字节在前帧起始符1又一次出现 68H固定不变控制码1标识这是读数据、写数据还是其他操作数据域长度1L表示后面数据域的字节数数据域L数据标识 具体数据校验和1CS从第一个 68H 到数据域最后一个字节累加取低 8 位结束符116H固定不变这里有个容易困惑的点为什么 68H 出现两次这是协议故意设计的帧头标志解析时按顺序找两个连续的 68H 即可中间夹着 6 字节地址。另外数据域里也可能出现 16H 这种看起来像结束符的字节所以实际解析时千万不能“见 16H 就断帧”必须根据数据域长度 L 来切帧。这点在后续代码实现部分会再强调。1.2 控制码主站和从站怎么对话控制码是理解通信流程的关键。我常跟同事说控制码就是“这帧命令要干什么”的身份牌。最常用的一组是读数据主站发 11H从站正常应答 91H异常应答 D1H。控制码方向含义11H主站→从站读数据91H从站→主站正常应答数据域里带着数据标识和具体数据D1H从站→主站异常应答数据域只有一个字节的错误码 ERR12H主站→从站读后续数据用于数据量大的场景92H从站→主站读后续数据的正常应答13H主站→从站读通信地址14H主站→从站写数据15H主站→从站写通信地址16H主站→从站广播校时从这个表格能看出规律从站正常应答就是在主站控制码基础上加 0x80异常应答则加 0xC0。比如 11H 变成 91H 是正常变成 D1H 是异常。这个规律知道后看到 D1H 心里就有数了不是“没收到”而是“收到了但没办成”。1.3 数据标识找到电压数据的地图控制码解决了“干什么”数据标识解决的是“读哪个数据”。DL/T645-2007 用 4 字节的 DI0、DI1、DI2、DI3 来唯一定位一个数据项比如当前 A 相电压、B 相电压、当前有功功率、电量等。这里先记一个结论读 A/B/C 三相电压数据标识分别是 02010101、02010102、02010103发送顺序就是 DI002、DI101、DI201、DI301/02/03。这个是我在多个品牌电表上验证过的通用写法。数据块的概念也提一下协议允许部分数据项用“块读”方式一次取回多组数据。比如某些表支持 02010100 读三相电压块一次返回 ABC 三相电压。但块读的兼容性不如分相读稳定后面我会详细分析怎么选。2. 第1个关键步骤电表地址解析电表地址解析是整个 DL/T645-2007 调试里最容易翻车的一步没有之一。很多人拿着表号直接按字符串顺序转字节发出去结果就是没响应。先搞清楚协议对地址的定义再动手写代码。2.1 表号转地址字节的正确姿势DL/T645-2007 的地址域是 6 个字节每个字节是一个两位 BCD 码一共可以表达 12 位十进制数。这个 12 位十进制数就是电能表的通信地址通常就是表号。传输时低字节在前。举个例子。表号是 000012345678从右边开始每两位一组78、56、34、12、00、00。发送顺序就是 78 56 34 12 00 00。注意是先把最后两位放最前面。再看一个容易混淆的例子。表号 123456789012分组是 12、34、56、78、90、12发送顺序为 12 90 78 56 34 12。肉眼看起来和表号字符串“完全不对应”这就是协议反序设计的坑。有些刚入门的开发者会直接用一个循环从头到尾两个两个取那发出去的就是 12 34 56 78 90 12电表根本不会理你。还有一个隐含坑BCD 码转换。分组后的字符串“12”要转成字节 0x12不是十进制 12。0x12 在十六进制里是 18但 BCD 码 0x12 代表十进制数 12。区别在哪如果你用 Python 写bytes([int(12)])得到的是 0x0C不是 0x12。正确做法是把高四位和低四位分别赋值0x10 0x02或者直接int(12, 16)。def meter_no_to_addr(no: str) - bytes: no no.zfill(12) # 不足12位前面补零 addr bytearray() for i in range(len(no) - 2, -1, -2): hi int(no[i]) # 十位 lo int(no[i 1]) # 个位 addr.append((hi 4) | lo) return bytes(addr)这段代码做了两件事从表号右侧开始分组把每组两位数字拼成一个 BCD 字节。实测表号 000012345678 出来是78 56 34 12 00 00正确。2.2 地址未知时怎么探测有时候手头没有表号或者表号被贴纸盖住了这时候可以用协议里的“读通信地址”命令。命令很特殊地址域填 6 个 AAH控制码 13H数据域长度为 0。68 AA AA AA AA AA AA 68 13 00 DF 16DF 是校验和。这条命令发出去如果总线上只有一块表它会把自己的地址回给你。但要注意如果总线上挂了好几块表所有表都会同时应答数据就冲突了。所以这个命令只适合离线调试单表或者确认单表通信参数的场景。还有一种更土但有效的办法很多电表的铭牌上直接印表号通讯地址默认等于表号。可以先用铭牌表号转地址试一下如果没响应再去电表面板按键翻到“通信地址”那项看因为有些厂家把通信地址设置成了和表号不一样的数值。2.3 地址相关的两个高频坑第一个坑表号前面的零不能丢。表号是 12 位数字前面可能会补零。比如实际表号是 12345678通信地址就是 0012345678但协议要求 12 位就需要补成 000012345678。代码里必须先 zfill(12)再转地址。漏了补零地址直接错位。第二个坑广播地址不是全 FF也不是全 00。DL/T645-2007 标准里广播地址是 6 个 99H但广播命令只用于校时这类不要求应答的场景。有不少人以为发广播地址可以“读所有表的数据”实际效果就是没人理你或者一堆表同时回包导致总线冲突。读数据永远是单地址一对一。3. 第2个关键步骤构造读三相电压请求帧地址搞对了通信链路就通了一半。接下来要把读三相电压的命令完整拼出来。这一步看着简单但数据标识、控制码、校验和任何一个环节出错结果都不会对。3.1 三相电压的数据标识DL/T645-2007 中电压类数据标识以 02 开头。具体到三相电压分相读取的标识如下数据项数据标识十六进制说明A 相电压02 01 01 01一般单位 V带 1 位小数B 相电压02 01 01 02同上C 相电压02 01 01 03同上数据标识在帧里的发送顺序就是从左到右即 DI0、DI1、DI2、DI3。这个顺序和很多人的直觉一致所以相对不易出错。真正容易错的是后面拼接数据域时把长度算错。3.2 手写一帧读A相电压命令假设表号 000012345678地址域就是 78 56 34 12 00 00。读 A 相电压的完整帧是68 78 56 34 12 00 00 68 11 04 02 01 01 01 CS 16逐字节解释一下第一个 68H 是帧头78 56 34 12 00 00 是地址第二个 68H 是帧头11H 是读数据控制码04H 表示数据域长度是 4 字节02 01 01 01 是数据标识CS 是校验和16H 是结束符。注意这里 L04 是固定的因为读单相电压时数据域里就只有 4 个字节的数据标识不携带额外参数。读 B 相就把最后一个字节改成 02读 C 相改成 03。3.3 校验和的计算过程校验和 CS 的计算规则从第一个 68H 开始一直到数据域最后一个字节所有字节累加超出 8 位的部分丢弃也就是取低 8 位。结束符 16H 不参与计算。我手动算一遍上面那个例子。参与计算的字节是68 78 56 34 12 00 00 68 11 04 02 01 01 01累加过程68 78 E0E0 56 136取低 8 位为 3636 34 6A6A 12 7C7C 00 7C7C 00 7C7C 68 E4E4 11 F5F5 04 F9F9 02 FBFB 01 FCFC 01 FDFD 01 FE所以 CSFE。完整请求帧是68 78 56 34 12 00 00 68 11 04 02 01 01 01 FE 16这段计算过程建议自己手推一遍因为写代码时就是一模一样的逻辑sum(frame_bytes) 0xFF。校验和错了表会直接丢弃整帧不会给任何提示。4. 第3个关键步骤响应帧解析请求帧发出去后电表正常情况下会回一帧。解析响应帧时要分清楚几种情况正常应答、异常应答、数据解析。这里面坑也不少。4.1 先判断是正常应答还是异常应答还是用表号 000012345678 的例子读 A 相电压假设电表返回68 78 56 34 12 00 00 68 91 06 02 01 01 01 53 22 F5 16看控制码是 91H说明是正常应答。数据域长度 L06H也就是后面 6 个字节02 01 01 01 53 22。其中 02 01 01 01 是数据标识53 22 是电压原始数据。如果返回的是 D1H那就是异常应答。异常应答的数据域只有 1 个字节的错误码比如 ERR02H 表示无请求数据。常见错误码后面排查章节会列。4.2 从数据域还原三相电压电压数据是 2 字节 BCD 码低字节在前。刚才应答帧里的原始数据是 53 22要还原成电压值先把字节倒过来变成 22 53再按 BCD 码解读为十进制数 2253。BCD 码解读方法每个字节的高 4 位是一个 0~9 的数字低 4 位也是一个 0~9 的数字。0x22 就是 220x53 就是 53拼起来就是 2253。这里就要说到小数位。DL/T645-2007 里电压类数据通常带 1 位小数所以数值 2253 对应的实际电压是 225.3V。如果有些表的数据格式不带小数那 2253 就代表 2253V这明显不合理。所以实践里要学会看合理性正常相电压通常在一百多到两百多伏之间如果解析出几千伏或者十几伏首先怀疑小数位或者字节序搞错了。最后校验和要核对一遍。响应帧的 CS 计算范围和请求帧一样从 68H 到数据域最后一个字节不包括结束符。算出来的值和 F5 对上帧才是完整的。def parse_voltage_frame(frame: bytes): if len(frame) 14: return None ctrl frame[7] length frame[8] if ctrl 0xD1: err frame[9] print(f异常应答, ERR0x{err:02X}) return None data_area frame[9:9 length] # 前4字节是数据标识 value_raw data_area[4:] # 低字节在前 # 反转后按BCD解读 bcd_bytes value_raw[::-1] digits for b in bcd_bytes: digits f{(b 4) 0x0F}{b 0x0F} value int(digits) voltage value / 10 # 电压常见格式带1位小数 return voltage这段代码就是一个最小可用的电压解析逻辑。实际工程里还要加上帧校验、粘包处理等但核心思路就是上面这几步。4.3 数据块方式一次读完三相电压分相读三次每次一帧逻辑清晰兼容性最好。但如果表计数量多、轮询周期紧可以考虑一次读取三相电压块。数据块读取的请求帧格式和分相读一样只是数据标识改成 02010100部分表型支持68 78 56 34 12 00 00 68 11 04 02 01 01 00 CS 16响应帧的数据域结构就是数据标识 02 01 01 00 6 字节数据依次是 A 相、B 相、C 相电压每相 2 字节。解析时把 6 个字节按 2 字节一组拆开各自做低字节在前的 BCD 反转即可。要注意的是这个“三相电压块读”的标识在不同厂家表计上支持程度不一样。我建议的选型顺序是先做分相读保证能通如果手头的表支持块读再在代码里增加这个分支。不要一上来就赌块读容易在现场翻车。5. 第4个关键步骤代码落地与串口参数协议层面理清楚后就要落到代码上了。电表通信本质是串口收发但串口参数、半双工切换、粘包处理这些工程细节直接决定你的程序在项目里能不能稳定跑。5.1 串口参数比想象中更容易翻车DL/T645-2007 默认串口参数是波特率 1200bps、数据位 8、停止位 1、偶校验EVEN。这条一定要单独拎出来说因为太多项目死在校验位上。很多开发者用惯了“8N1”也就是无校验拿来直接连电表结果怎么发都没响应。DL/T645-2007 标准里明确是偶校验所以调试阶段建议先按“1200 8 E 1”来试。也有部分电表支持 2400、4800、9600 等波特率但具体是多少要看电表面板或厂家配置。我的习惯是先用串口助手试 1200 偶校验通了再去代码里调整。5.2 一个最小可用的Python读取流程下面的代码实现了从表号转地址、构造请求帧、发送接收和解析三相电压的完整流程。我用的是 pyserial实际项目里换串口库时逻辑一样。import serial class Meter645: def __init__(self, port: str, baudrate: int 1200): self.ser serial.Serial( portport, baudratebaudrate, bytesize8, parityserial.PARITY_EVEN, stopbits1, timeout0.3 ) staticmethod def meter_no_to_addr(no: str) - bytes: no no.zfill(12) addr bytearray() for i in range(len(no) - 2, -1, -2): hi int(no[i]) lo int(no[i 1]) addr.append((hi 4) | lo) return bytes(addr) staticmethod def checksum(payload: bytes) - int: return sum(payload) 0xFF def build_read_frame(self, no: str, di: bytes) - bytes: addr self.meter_no_to_addr(no) data di length len(data) base b\x68 addr b\x68 b\x11 bytes([length]) data cs self.checksum(base) return base bytes([cs, 0x16]) def read_voltage(self, no: str, phase: str): di_map {A: bytes([0x02, 0x01, 0x01, 0x01]), B: bytes([0x02, 0x01, 0x01, 0x02]), C: bytes([0x02, 0x01, 0x01, 0x03])} di di_map[phase] frame self.build_read_frame(no, di) self.ser.flush_input() self.ser.write(frame) resp self.ser.read(64) if len(resp) 14: raise RuntimeError(响应帧过短) return parse_voltage_frame(resp)重点看三个地方表号转地址用的 BCD 高位低位拼接校验和用sum(payload) 0xFF发送前flush_input清理串口缓存避免收到上一次残留数据。5.3 代码里最容易错的两个地方第一个是 BCD 转换。前面已经提过int(12)转出来是 0x0C 不是 0x12。这是个很隐蔽的 bug表号里一旦出现 0~9 之外的数还好出现“10”“11”这种就直接错位。建议封装函数的时候加上单测把 000012345678 这种典型输入跑一遍。第二个是 RS-485 半双工方向控制。如果你用的是 USB 转 485 模块绝大多数能自动控制收发方向和切换代码不需要管。但如果是嵌入式设备直接接 MAX485 这类芯片就必须自己控制 DE/RE 引脚发送前拉高 DE发完稍等再拉低切回接收。切换太早会吞掉自己发的尾巴切换太晚会漏掉从站开头几个字节都很难查。6. 第5个关键步骤现场调试与问题排查代码写完不是终点现场调通才是。电表采集项目绝大多数问题不是协议不会写而是现场环境各种幺蛾子。这里把排查思路和常见问题整理成一套流程照着走能省大量时间。6.1 从串口助手开始不要直接写代码我的调试习惯是到了现场先打开电脑上的串口调试助手挂一个 USB 转 485 模块直接手工发包。为什么因为串口助手能最快定位问题是出在链路层还是应用层。具体流程是先发一条读电压命令用前面写的完整帧68 78 56 34 12 00 00 68 11 04 02 01 01 01 FE 16如果串口助手能收到正常应答说明 RS-485 接线、波特率、校验位、地址都没问题后面代码怎么写都只是时间问题。如果收不到任何字节那就是链路层问题去查接线和串口参数。如果收到 D1H 异常帧那是协议层问题去查数据标识是否被表型支持。6.2 常见问题速查表现象可能原因排查方向命令发出后完全无响应地址字节顺序错、串口参数不对、485 A/B 接反用串口助手逐项排除返回 D1H 异常帧ERR02H数据标识不支持或无数据确认表型支持的数据项换分相读返回 D1H 异常帧ERR03H密码错误或未授权检查是否需要写密码或权限等级不够有返回但校验和总不对通信线路干扰、波特率不匹配缩短线缆检查接地降低波特率电压值明显不合理字节序没反转、小数位不对、BCD 解析错对照厂家点表核实数据格式总线上多块表某块总读不到地址重复、该表处于异常状态给每块表重新设唯一通信地址这里特别说一下 ERR02H。当你发 02010100 想块读电压但表不支持时很多型号会回 D1H 和 ERR02H。这说明命令到了表但表说你问的东西它没有。解决办法就是改成发 02010101、02010102、02010103 三个分相命令大概率能通。6.3 几条从实践中磨出来的经验第一给电表上电后不要立刻发命令。表刚上电的几百毫秒里内部可能在做自检或初始化这时发命令大概率石沉大海。批量采集程序里建议在设备启动后等待 1~2 秒再开始轮询。第二电压没有输入时读出来的值会非常离谱。用三相电表读三相电压前先确认有没有接电压线、空气开关有没有合闸。不然你在那纠结半天解析逻辑实际上表压根没测到电压。这种问题在项目调试第一天特别常见。第三长线传输时给 485 总线加终端电阻和偏置电阻。线路超过几十米或者现场变频器干扰严重时偶发校验错和漏帧会明显增加。简单做法是在总线两端各接一个 120Ω 终端电阻A/B 线之间加偏置。不要等到现场全乱了才想起这一条。第四写代码时一定要做响应帧超时处理和最大重试次数。电表不是网络服务器偶尔丢一帧很正常。单次通信失败就报错程序跑不了两天就会挂。常见做法是每帧超时 300ms失败重试 3 次连续失败再做告警。至于三相电压读取的最终效果我通常会在程序中把 A/B/C 三相依次读完解析成字典返回顺便做个范围校验。比如if not 190 voltage 260: log.warning(...)这种数据合理性检查能帮你提前发现接线反了、电压缺相之类的问题。我个人经历里用这套流程解决过的现场问题比协议本身的问题多得多所以别小看这些不起眼的边界处理。