电表通信协议 DLT645-1997:从帧结构到实战解析,一文打通“抄表“全流程

以为抄电表就是发个命令读个数?DLT645-1997 协议里藏着地址反序、数据加偏移、校验和截断三个连环坑,踩中一个,整条链路就断了。

问题现场

那年第一次做智能电表采集项目,自信满满地写好了串口通信代码。

对着协议文档,按部就班拼帧、发命令、等回复。

结果电表像哑了一样,串口收到的是一片乱码。

最后发现:地址域要低字节在前;数据标识发送前要加 0x33;校验和只取低 8 位。

这篇文章,就是想把 DL/T 645-1997 协议从头到尾讲清楚。


原理

1. 协议是什么?

DL/T 645-1997 是中国电力行业标准,全名叫"多功能电能表通信规约"。

它规定了主站(抄表终端/集中器)和从站(电表)之间怎么通过串口交换数据。

物理层通常是 RS-485,半双工、主从模式。

简单说就是:一问一答,你不问,它不说。

生活类比:就像去快递柜取件,必须先输入取件码(发命令),柜子才会弹出包裹(返回数据)。

2. 帧结构:一帧长什么样?

这是整个协议的地基。先记住这张表:

复制代码
| 前导码 | 起始符 | 地址域 | 起始符 | 控制码 | 数据长度 | 数据域 | 校验码 | 结束符 |
| FE FE..|  68    | 6字节  |  68    |  1字节 |  1字节   | N字节  | 1字节  |  16   |

逐字段说明:

前导码(0~4 字节 FE)

这不是协议强制要求的,但很多老电表需要它来"唤醒"。

收到 FE 后,接收端开始同步波特率。

实战中建议发 4 个 FE,兼容性最好。

解析接收数据时,前面若干个 FE 可以直接忽略。

起始符(68H)

帧的开头和地址域后面各有一个 0x68。

两个 0x68 把地址域夹在中间,方便软件在噪声中定位有效帧。

地址域(6 字节 BCD)

这是电表的通信地址,12 位 BCD 编码。

重点来了:低字节在前,高字节在后

比如电表地址是 000000000123,在帧里排列为 23 01 00 00 00 00

如果地址不足 6 字节,用 AAH 补齐。

广播地址是 999999999999(全 99),所有电表都会响应。

控制码(1 字节)

8 位二进制,每一位都有含义:

复制代码
D7:方向位(0=主站发出,1=从站应答)
D6:异常标志(0=正常,1=从站出错)
D5:后续帧标志(0=无后续,1=还有数据帧跟在后面)
D4~D0:功能码

常用功能码:

功能码 D7~D0 说明
读数据 00001 主站请求读取
读后续 00010 续读上一帧剩余数据
写数据 00100 主站写入参数
修改密码 01111 修改电表通信密码

从站正常应答时,D7 翻转为 1。

比如主站发读数据命令(控制码 = 0x01),从站正常应答(控制码 = 0x81)。

如果从站出错,D6 也置 1(控制码 = 0xC1)。

数据长度(1 字节)

数据域的字节数。

值为 0 表示没有数据域。

数据域(N 字节)

这是最容易被坑的地方。

发送前,数据域的每一个字节都要加 0x33

接收后,每一个字节都要减 0x33还原。

为什么要加偏移?因为防止数据域里的字节和帧定界符 0x68、0x16 冲突。

加了 0x33 之后,数据域里的字节范围从 0x00~0xFF 变成了 0x33~0xD2(加上进位溢出后是 0x330x32+33=0x330xFF,但关键是不会出现 0x68 和 0x16 的原始值)。

生活类比:就像快递单上不能写"炸弹"两个字,得把敏感词替换成编码。电表协议把可能和帧头帧尾撞车的字节,统一加了 0x33 的"伪装"。

校验码(1 字节)

计算方法:从第一个 0x68 开始,到校验码之前的所有字节,做模 256 的算术累加和。

就是简单的加法,溢出自然截断,只保留低 8 位。

不是 CRC,不是异或,就是加法。

这一点和 Modbus 的 CRC 校验完全不同,千万别搞混。

结束符(16H)

帧的尾巴,固定 0x16。

3. 数据标识:读的是哪个数据?

数据标识(DI)告诉你这一帧要读/写哪个数据项。

1997 版协议用的是 2 字节标识(DI1-DI0),比 2007 版的 4 字节简单。

四大类数据标识:

类别 DI1 范围 DI0 范围 说明
电能量 90~99 10~6E 正/反向有功/无功电能
最大需量 A0~A9 10~6E 最大需量及发生时间
变量数据 B2,B3,B6 10~15 电压、电流、功率、频率
参变量 C0~C5 10~AE 表号、密码、波特率等

几个最常用的标识:

  • 90-10:当前正向有功总电能(就是你家电费单上的那个数)
  • B6-11:A 相电压
  • B6-21:A 相电流
  • C0-10:电表通信地址

实战经验:数据标识在帧里也要加 0x33。比如读正向有功总电能,原始 DI 是 90 10,进帧之前变成 C3 43

4. 通信流程:一问一答的完整过程

来一个完整的实战例子:读取地址为 000000000123 的电表的当前正向有功总电能。

主站发命令帧:

复制代码
FE FE FE FE     -- 4字节前导码(唤醒)
68              -- 起始符
23 01 00 00 00 00  -- 地址域(低字节在前)
68              -- 第二个起始符
01              -- 控制码(读数据)
02              -- 数据长度(DI1+DI0 = 2字节)
C3 43           -- 数据标识(90+33=C3, 10+33=43)
XX              -- 校验码(前面所有字节累加和取低8位)
16              -- 结束符

从站应答帧:

复制代码
FE FE FE FE     -- 前导码
68              -- 起始符
23 01 00 00 00 00  -- 地址域
68              -- 第二个起始符
81              -- 控制码(应答:D7=1,正常)
04              -- 数据长度(DI1+DI0+2字节数据值 = 4字节)
C3 43           -- 数据标识(加33后)
YY YY           -- 电能值(BCD编码,加33后)
XX              -- 校验码
16              -- 结束符

解析应答数据的步骤:

  1. 校验帧起始符和结束符
  2. 验证地址域是否匹配
  3. 检查校验码
  4. 数据域每个字节减 0x33 还原
  5. 取出 DI1-DI0,确认数据类型
  6. 拿到数据值,按 BCD 解码
  7. 按数据标识对应的分辨率插入小数点

比如正向有功总电能的分辨率是 0.01 kWh,BCD 值 000123 解码后就是 1.23 kWh。

5. 校验和怎么算?给一个代码示例

c 复制代码
/**
 * @brief  计算 DLT645-1997 帧校验码
 * @param  frame: 帧缓冲区指针(从第一个68H开始)
 * @param  len:   从第一个68H到校验码之前的字节总数
 * @retval 校验码(模256累加和的低8位)
 */
uint8_t dlt645_calc_checksum(const uint8_t *frame, uint16_t len)
{
    uint32_t sum = 0;

    for (uint16_t i = 0; i < len; i++) {
        sum += frame[i];
    }

    return (uint8_t)(sum & 0xFF);  /* 模256截断 */
}

就这么简单。加起来,取低 8 位,完事。

6. 数据域加 0x33 / 减 0x33 的代码

c 复制代码
/**
 * @brief  数据域发送前加 0x33 处理
 * @param  raw:   原始数据指针
 * @param  raw_len: 原始数据长度
 * @param  out:   输出缓冲区(需预分配至少 raw_len 字节)
 */
void dlt645_encode_data(const uint8_t *raw, uint16_t raw_len, uint8_t *out)
{
    for (uint16_t i = 0; i < raw_len; i++) {
        out[i] = raw[i] + 0x33;
    }
}

/**
 * @brief  数据域接收后减 0x33 还原
 * @param  encoded: 接收到的加偏移数据指针
 * @param  len:     数据长度
 * @param  out:     输出缓冲区(还原后的原始数据)
 */
void dlt645_decode_data(const uint8_t *encoded, uint16_t len, uint8_t *out)
{
    for (uint16_t i = 0; i < len; i++) {
        out[i] = encoded[i] - 0x33;
    }
}

7. 地址域反序处理的代码

c 复制代码
/**
 * @brief  将 BCD 地址转为帧中的低字节在前格式
 * @param  addr_str: 12位BCD地址字符串,如 "000000000123"
 * @param  out:      输出6字节地址缓冲区
 * @note   地址域在帧中按低字节在前排列
 */
void dlt645_addr_to_frame(const char *addr_str, uint8_t *out)
{
    /* BCD地址共12位,每2位对应1字节,共6字节 */
    /* addr_str: "00 00 00 00 01 23" -> 原始字节: 00,00,00,00,01,23 */
    /* 帧中排列: 23,01,00,00,00,00 (低字节在前) */

    uint8_t raw[6] = {0};

    /* 先把地址字符串转为6字节BCD */
    for (int i = 0; i < 6; i++) {
        uint8_t hi = hex_char_to_val(addr_str[2 * i]);
        uint8_t lo = hex_char_to_val(addr_str[2 * i + 1]);
        raw[i] = (hi << 4) | lo;
    }

    /* 反序:低字节在前 */
    for (int i = 0; i < 6; i++) {
        out[i] = raw[5 - i];
    }
}

示例分析

发送报文:68 27 00 00 00 00 00 68 01 02 43 C3 00 16

字段 报文字节 校验结果 说明
帧起始符1 68H ✅ 正确 协议固定帧头
地址域(6字节) 27 00 00 00 00 00 ✅ 正确 低位在前BCD编码,对应表号000000000027,全部为合法BCD字符
帧起始符2 68H ✅ 正确 地址域后第二个固定帧头
控制码 01H ✅ 正确 二进制00000001,主站发起、无异常、无后续帧,功能为读数据
数据长度 02H ✅ 正确 读命令数据域为2字节数据标识,长度匹配
数据域(DI) 43 C3 ✅ 正确 减33H解码得10H 90H,低位在前对应数据标识9010H,即当前正向有功总电能
校验码 00H ✅ 正确 从首68H到数据域末尾累加和为200H,低8位为00H,计算匹配
结束符 16H ✅ 正确 协议固定帧尾

接收报文:FE 68 27 00 00 00 00 00 68 81 06 43 C3 9C 4B 9B 34 3A 16

字段 报文字节 校验结果 说明
前导字节 FEH ✅ 合法 RS485传输常用唤醒字节,可选,不参与帧校验
帧起始符1 68H ✅ 正确 固定帧头
地址域(6字节) 27 00 00 00 00 00 ✅ 正确 与请求帧地址完全一致,应答地址匹配
帧起始符2 68H ✅ 正确
控制码 81H ✅ 正确 二进制10000001,从站正常应答读数据命令(请求控制码01H
数据长度 06H ✅ 正确 应答数据域为2字节DI + 4字节电能数据,共6字节,长度匹配
数据域-DI 43 C3 ✅ 正确 解码后为9010H,与请求的数据标识完全一致
数据域-电能值 9C 4B 9B 34 ✅ 正确 减33H解码得69H 18H 68H 01H,均为合法BCD;低位在前解析为电能值16818.69 kWh
校验码 3AH ✅ 正确 从首68H到数据域末尾累加和为43AH,低8位为3AH,计算完全匹配
结束符 16H ✅ 正确 固定帧尾

电能值解码过程

  1. 数据域4字节电能原始值(加33H后):9C 4B 9B 34
  2. 每个字节减33H还原:69 18 68 01
  3. 按低位在前规则,字节顺序对应「小数位 → 个位十位 → 百位千位 → 万位十万位」
  4. 组合后数值:01 68 18 . 6916818.69 kWh

避坑指南 & 最佳实践

坑点 1:地址字节序搞反

错误做法 :地址 000000000123 按直觉写入帧为 00 00 00 00 01 23

正确做法 :低字节在前,写入帧为 23 01 00 00 00 00

这是最频繁出错的点。协议文档写得明明白白"低地址位在先",但人的直觉是高位在前。

建议:封装一个地址反序函数,统一处理,不要每次手动反转。

坑点 2:数据域忘记加 0x33

错误做法:直接把原始 DI 和数据值写入帧。

正确做法:发送前每个字节加 0x33,接收后每个字节减 0x33。

不加偏移的话,如果数据域恰好出现 0x68 或 0x16,接收端会误判为帧定界符,整帧解析直接崩溃。

建议:写统一的 encode/decode 函数,不要在拼帧逻辑里散落加偏移操作。

坑点 3:校验和用 CRC16 代替简单累加

错误做法:套用 Modbus 的 CRC16 校验逻辑。

正确做法:模 256 的算术累加和,只取低 8 位。

DL/T 645 的校验是最简单的加法校验,不是 CRC。

很多嵌入式工程师长期做 Modbus 开发,习惯性用 CRC,结果校验永远不通过。

建议:一开始就确认用的是加法校验,直接写累加和函数。

坑点 4:前导码 FE 数量不一致

不同厂家、不同型号的电表,前导码习惯不一样。

有的发 0 个 FE,有的发 4 个。

最佳做法:主站统一发 4 个 FE,兼容性最好;接收端忽略所有前导码 FE,从第一个 0x68 开始解析。

坑点 5:应答超时没处理好

电表的应答速度通常比较慢,尤其老表。

如果超时时间设得太短,容易误判为无应答。

建议:初始超时设为 500ms,实测后再调整。加上重发机制,建议重发间隔不少于 200ms,连续指令之间留 50~100ms 的间隔。

坑点 6:广播地址乱用

广播地址 999999999999 下发命令后,总线上所有电表都会尝试应答。

如果总线上挂了多块表,多个应答帧会在 RS-485 总线上撞车。

建议:广播地址只用于写密码、写地址等从站不需要应答的场景。读数据一定要用单表地址。


扩展思考 / 行业透视

DLT645-1997 vs 2007:两代协议的核心差异

1997 版是初代规约,2007 版做了大幅升级。主要差异:

对比项 1997版 2007版
数据标识长度 2字节(DI1-DI0) 4字节(DI3-DI0)
数据分类 4大类 7大类(新增事件记录、冻结量、负荷记录)
地址域填充 不足6字节用 AAH 补齐 不用 AAH,地址固定12位
安全机制 无加密 支持 ESAM 安全加密模块
数据长度限制 数据域最长256字节 读数据时L<=200,写数据时L<=50
控制码结构 D7方向/D6异常/D5后续/D4~D0功能 D7方向/D6正确应答/D5后续/D4~D0功能

现实情况:目前国内大量在运行的电表仍使用 1997 版协议,尤其在旧小区改造项目中。新装表基本已切换到 2007 版。做采集设备的工程师,两版都得会。

为什么数据域要加 0x33 而不是其他值?

这不是随便选的。

0x33 保证加偏移后,0x00 变成 0x33,0x16 变成 0x49,0x68 变成 0x9B。

这三个"敏感值"加偏移后都不会再和原始定界符冲突。

0x33 也足够小,不会让数据域字节溢出到高区间(最大值 0xFF + 0x33 = 0x32,截断后也是安全的)。

RS-485 总线上的注意事项

DL/T 645 的物理层是 RS-485,这就带来几个硬件层面的考量:

  • 总线挂接的表数量不能超过 64 块(标准建议值)
  • 波特率通常用 2400bps 或 9600bps,老表多为 2400bps
  • 485 转换器要加 120 欧终端电阻,长距离总线尤其需要
  • 波特率不一致时,前导码 FE 就是用来让接收端同步的关键

总结 & 行动建议

把 DLT645-1997 协议的关键点浓缩成这张清单:

  • 帧结构:前导码 + 68 + 地址(6B) + 68 + 控制码 + 长度 + 数据 + 校验 + 16
  • 地址反序:低字节在前,高字节在后
  • 数据加偏移:发送加 0x33,接收减 0x33
  • 校验是加法:模 256 累加和,不是 CRC
  • 前导码:发 4 个 FE,接收端忽略所有 FE
  • 控制码:D7 方向位,D6 异常位,D5 后续帧位
  • 数据标识:1997 版用 2 字节,2007 版用 4 字节
  • 超时和重发:初始超时 500ms,重发间隔 200ms 以上

参考链接


如果觉得不错,欢迎 点赞、收藏、转发 ,如果想第一时间收到推送,也可以给个星标~