以为抄电表就是发个命令读个数?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 -- 结束符
解析应答数据的步骤:
- 校验帧起始符和结束符
- 验证地址域是否匹配
- 检查校验码
- 数据域每个字节减 0x33 还原
- 取出 DI1-DI0,确认数据类型
- 拿到数据值,按 BCD 解码
- 按数据标识对应的分辨率插入小数点
比如正向有功总电能的分辨率是 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 |
✅ 正确 | 固定帧尾 |
电能值解码过程
- 数据域4字节电能原始值(加33H后):
9C 4B 9B 34 - 每个字节减33H还原:
69 18 68 01 - 按低位在前规则,字节顺序对应「小数位 → 个位十位 → 百位千位 → 万位十万位」
- 组合后数值:
01 68 18 . 69→ 16818.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 以上
参考链接
- "DL/T645-1997通信规约解读." https://blog.csdn.net/wangkai_123456/article/details/24271017
- "DL/T 645-1997." Neuron文档. EMQX: https://docs.emqx.com/zh/neuronex/latest/configuration/south-devices/dlt645-1997/dlt645-1997.html
- "DLT645 1997 协议解析." https://www.cnblogs.com/chen1880/p/11243135.html
- "DLT645协议实战:从帧解析到数据采集的C++实现." https://blog.csdn.net/sky77/article/details/150995084
- "DLT645协议深度解析:智能电网通信指南与实战应用." https://www.ebyte.com/news/4047.html
- "DLT645-2007 协议快速入门." 博客园 https://www.cnblogs.com/windowsxpxp/p/18467018
- "中华人民共和国电力行业标准多功能电能表通信协议." DLT698.COM https://www.dlt698.com/sg64507pro.pdf
如果觉得不错,欢迎 点赞、收藏、转发 ,如果想第一时间收到推送,也可以给个星标~