简介:这是一套基于Java开发的IEC 62056-21 C模式主站协议库,面向能源计量、智能家居与市政管理领域的开发者,用于通过串口或网络连接燃气表、水表、热量表、电表等计量装置,读取符合国际标准的能源数据。协议库封装了底层通信细节,开发者可专注业务逻辑,无需重复处理数据交换的兼容性问题。资源包共25个文件,约119KB,包含java源码、gradle构建脚本、properties配置、xml与txt说明文档、jar依赖及开源许可文件,结构清晰,便于快速集成与二次开发。目前已有45人学习下载。借助该库,读者可获得一套可直接复用的主站通信实现,理解C模式数据模型与交换格式,掌握串口与网络双通道读取方案,并参考README与变更日志完成环境搭建与调试,为远程抄表、能耗监测等场景提供可靠支撑。
1. 从一块燃气表说起:Java 主站协议库到底解决什么问题
手上有一块带 IEC 62056-21 光口或 RS485 口的燃气表,你想用 Java 后台每天定时抄一次标况累积流量,结果发现网上能搜到的几乎都是 C 写的嵌入式从站代码,或者某个厂商私有的 DLL。主站侧、Java 语言、同时支持串口和网络、还要能覆盖燃气表水表热量表电表这几类能源计量装置——这个组合就是标题里那个「IEC 62056-21 C 模式主站协议库」要填的坑。
它本质是一个跑在服务端的通信中间件:向下通过串口(RS232/RS485/USB 转串口)或 TCP 网络连到计量设备,向上给业务系统吐结构化数据。C 模式指的是协议里那条「先发请求报文、设备回显并应答」的交互模式,是 62056-21 里最常用、也最适合做定时抄表的一条路径。适合谁?做能源管理平台、远程抄表系统、能耗采集网关的 Java 后端,尤其是那些不想为每种表单独写一套驱动的团队。下面按「协议怎么立住 → 库怎么搭 → 串口网络怎么接 → 坑在哪 → 怎么验证」推下去。
2. 把 C 模式讲透:报文结构、握手时序与数据模型
2.1 C 模式的三段式交互到底长什么样
IEC 62056-21 的 C 模式,一次完整抄表分三段。第一段是唤醒与协商:主站发一个/?!或带设备地址的请求,设备回显自己的标识(厂商、型号、波特率能力),双方约定后续通信速率。第二段是读数据:主站发R5之类的读命令,设备回SOH ... STX ... ETX包裹的数据块。第三段是结束:主站发B或ACK收尾,设备释放线路。
很多人第一次写会翻车在「回显」上。C 模式里设备会把主站发过去的字符原样回显一遍,再发应答。如果你按普通一问一答去读,缓冲区里会先拿到自己的请求,解析直接错位。正确做法是读到回显后先丢弃,再等真正的应答帧。
报文帧结构大致是:起始符(/、!、SOH、STX)+ 数据 + 结束符(ETX、EOT)+ 校验(BCC,把帧内字节异或)。BCC 算错是新手最常见的「设备不回我」原因之一。
2.2 数据标识 OBIS 与单位换算
62056-21 的数据用 OBIS 码标识,形如1-0:1.8.0。它由 A-B:C.D.E 五段组成,A 是介质(1 电、6 水、7 燃气、9 热量),B 是通道,C 是物理量,D 是处理方式,E 是费率/历史。读燃气表标况累积流量,常见就是7-0:3.0.0这类。
拿到值之后别急着入库,单位要换算。设备返回的往往是带倍率的整数,比如12345*0.1 m3,你得把倍率解析出来再乘。下面这段是我一般会写的 OBIS 解析骨架:
// ObisValue: 承载一次读回的 OBIS 码、原始值、倍率、单位 public class ObisValue { private String obis; // 如 "1-0:1.8.0" private long rawValue; // 设备返回的整数 private double scale; // 倍率,如 0.1 private String unit; // 如 "kWh" / "m3" // 解析形如 "1-0:1.8.0(12345*0.1*kWh)" 的字段 public static ObisValue parse(String field) { int lp = field.indexOf('('); int rp = field.indexOf(')'); if (lp < 0 || rp < 0) { throw new IllegalArgumentException("非法字段: " + field); } String obis = field.substring(0, lp); String body = field.substring(lp + 1, rp); String[] parts = body.split("\\*"); // 值*倍率*单位 ObisValue v = new ObisValue(); v.obis = obis; v.rawValue = Long.parseLong(parts[0].trim()); v.scale = parts.length > 1 ? Double.parseDouble(parts[1].trim()) : 1.0; v.unit = parts.length > 2 ? parts[2].trim() : ""; return v; } public double realValue() { return rawValue * scale; } }逻辑说明:parse先按括号切出 OBIS 码和值体,值体再按*拆成「值、倍率、单位」三段。参数上,scale缺省给 1.0,避免某些设备不返回倍率时抛异常;rawValue用long是因为累积流量可能很大,int会溢出。真实设备里单位段有时带~前缀表示历史值,解析前建议先replace("~", "")。
2.3 为什么主站侧要抽象成「连接 + 会话 + 解析」三层
直接在一个类里既开串口又解析报文,短期能跑,长期必崩。我一般拆三层:连接层负责串口/TCP 的打开关闭和字节收发;会话层负责 C 模式的握手时序、回显丢弃、超时重试;解析层负责帧校验、OBIS 提取、单位换算。这样换一种表只动解析层,换串口转网络只动连接层。
选型理由很实在:能源计量现场设备型号杂,燃气表走 RS485、电表可能走红外或以太网,把变化点隔离出来,后面加一种表就是加一个解析器,而不是改一坨 if-else。这也是这个库值得自己搭而不是买现成的原因——现场适配的活,通用产品往往覆盖不全。
3. 用 Java 把主站库搭起来:连接抽象与最小可跑代码
3.1 连接层抽象:串口和 TCP 用同一套接口
串口在 Java 里没有标准库,常见做法是用 jSerialComm 或 purejavacomm。我一般定义一个Transport接口,串口和 TCP 各实现一份,上层会话完全不关心底层是线还是网。
// 统一的字节传输接口,串口与 TCP 都实现它 public interface Transport extends Closeable { void open() throws IOException; void write(byte[] data) throws IOException; // 读满 len 个字节或超时返回,返回实际读到的字节数 int read(byte[] buf, int off, int len, int timeoutMs) throws IOException; } // TCP 实现:适合网络型电表或串口服务器 public class TcpTransport implements Transport { private final String host; private final int port; private Socket socket; private InputStream in; private OutputStream out; public TcpTransport(String host, int port) { this.host = host; this.port = port; } @Override public void open() throws IOException { socket = new Socket(); socket.connect(new java.net.InetSocketAddress(host, port), 3000); socket.setSoTimeout(2000); // 读超时,防止卡死 in = socket.getInputStream(); out = socket.getOutputStream(); } @Override public void write(byte[] data) throws IOException { out.write(data); out.flush(); } @Override public int read(byte[] buf, int off, int len, int timeoutMs) throws IOException { socket.setSoTimeout(timeoutMs); return in.read(buf, off, len); } @Override public void close() throws IOException { if (socket != null) socket.close(); } }逻辑说明:open里设了连接超时 3000ms 和读超时 2000ms,这两个值在现场很关键——串口服务器掉线时,没有超时的read会永久阻塞,把抄表线程全占死。参数上,timeoutMs做成方法级可调,是因为握手阶段设备响应慢(有的要等 1~2 秒唤醒),而读数据阶段可以短一些。
串口实现同理,用 jSerialComm 打开SerialPort,设置波特率、数据位 8、停止位 1、校验位 even(62056-21 常用 7E1 或 8N1,看设备手册),read里用readBytes配合超时。注意串口参数配错的表现是「能打开但全是乱码」,别怀疑代码,先核对 7E1/8N1。
3.2 会话层:C 模式握手与回显处理
会话层是核心。下面是一个精简的读流程,重点看回显丢弃和 BCC 校验。
public class CModeSession { private final Transport transport; public CModeSession(Transport transport) { this.transport = transport; } // 读一组 OBIS,返回解析后的值 public List<ObisValue> read(List<String> obisList) throws IOException { // 1. 唤醒并协商:发 /?地址!,等设备回显+标识 byte[] wakeup = "/?000000000000!\r\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII); transport.write(wakeup); String ident = readUntil('\\n', 3000); // 读到换行,含回显 // 回显里包含我们发的请求,真正的标识在回显之后 String realIdent = stripEcho(ident, wakeup); // 2. 发读命令 R5,OBIS 用 ; 分隔 String cmd = "R5" + String.join(";", obisList) + "\r\n"; transport.write(cmd.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); // 3. 读应答帧,直到 EOT byte[] frame = readFrame(2000); verifyBcc(frame); // 校验失败直接抛,别硬解析 // 4. 发结束符 B,释放线路 transport.write("B\r\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); return parseFrame(frame); } // 丢弃设备回显:回显内容等于我们刚发出去的字节 private String stripEcho(String received, byte[] sent) { String sentStr = new String(sent, java.nio.charset.StandardCharsets.US_ASCII); if (received.startsWith(sentStr)) { return received.substring(sentStr.length()); } return received; } }逻辑说明:stripEcho是 C 模式能不能跑通的分水岭,设备把请求原样回显,不剥掉就会把回显当成应答解析。verifyBcc在解析前做,BCC 是把帧内字节异或,算错说明线路有干扰或波特率不对,此时重试比硬解析更靠谱。参数上,唤醒超时给 3000ms 是因为部分表要等光口对准或线路稳定;读帧超时 2000ms 是经验值,现场可调。
3.3 解析层:帧校验与 OBIS 提取
// 校验 BCC:对 SOH/STX 之后、ETX 之前的字节做异或 private void verifyBcc(byte[] frame) { int start = indexOf(frame, (byte) 0x01); // SOH int end = indexOf(frame, (byte) 0x03); // ETX if (start < 0 || end < 0 || end <= start) { throw new IllegalStateException("帧结构异常"); } byte bcc = 0; for (int i = start + 1; i < end; i++) { bcc ^= frame[i]; } byte actual = frame[end + 1]; // ETX 后一个字节是 BCC if (bcc != actual) { throw new IllegalStateException( String.format("BCC 校验失败: 计算=%02X 实际=%02X", bcc, actual)); } }逻辑说明:BCC 覆盖范围是 SOH 之后到 ETX 之前,不含 SOH 和 ETX 本身,这是最容易算错的地方。参数上,indexOf找的是字节值不是字符,别用String.indexOf。校验失败时把计算值和实际值都打出来,现场排查能一眼看出是干扰还是帧边界找错。
4. 串口与网络接入的现场细节:参数、时序与稳定性
4.1 串口参数怎么定:7E1 还是 8N1
62056-21 的 C 模式在协商阶段常用 300 波特 7E1(7 数据位、偶校验、1 停止位),协商成功后切到 9600 或 19200 的 8N1。很多设备在唤醒阶段和读数据阶段波特率不同,这是协议设计,不是设备坏了。
| 阶段 | 常见波特率 | 数据位 | 校验 | 停止位 |
|---|---|---|---|---|
| 唤醒协商 | 300 | 7 | Even | 1 |
| 数据读取 | 9600/19200 | 8 | None | 1 |
现场做法:先按 300/7E1 打开,发唤醒,读到设备标识里带的波特率能力(形如\2xxx里的数字),再关掉串口按新参数重开。别嫌麻烦,不切波特率读数据阶段会全是乱码。USB 转串口芯片(CH340、CP2102)在 300 波特下偶发丢字节,如果唤醒老失败,换 FT232 芯片的线试试,这是血泪经验。
4.2 网络接入:串口服务器与原生以太网表的区别
网络接入分两种。一种是设备本身带以太网口,直接 TCP 连它的 IP 和端口(常见 4059 或厂商自定义)。另一种是串口服务器,把 RS485 转成 TCP,主站连串口服务器的 IP,它再转发到表。后者要注意串口服务器有「透传」和「Modbus 网关」两种模式,62056-21 必须用透传模式,网关模式会改写报文。
TCP 连接要处理半包和粘包。62056-21 的帧有明确起止符,所以按起止符切帧比按长度切稳。我一般写一个readFrame,循环读字节直到遇到 EOT(0x04),中间设总超时。
// 按 EOT 结束符切帧,避免半包/粘包 private byte[] readFrame(int timeoutMs) throws IOException { java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); long deadline = System.currentTimeMillis() + timeoutMs; byte[] one = new byte[1]; while (System.currentTimeMillis() < deadline) { int n = transport.read(one, 0, 1, 200); if (n <= 0) continue; buf.write(one[0]); if (one[0] == 0x04) { // EOT return buf.toByteArray(); } } throw new IOException("读帧超时,已收 " + buf.size() + " 字节"); }逻辑说明:逐字节读到 EOT 为止,天然处理了半包;deadline控制总时长,防止设备一直不发 EOT 把线程挂死。参数上单次read超时给 200ms,是为了在总超时内能多次轮询,兼顾响应和退出。超时异常里带上已收字节数,现场能判断是「一个字节没收到」还是「收到一半断了」。
4.3 抄表调度与并发控制
一个网关可能挂几十上百块表,串口是独占资源,同一时刻只能有一个会话。做法是每路串口配一个单线程执行器,任务排队;TCP 表可以并发,但同一块表也要串行,避免两次抄表交叉。用Semaphore(1)给每块表加锁,比全局锁粒度细。
调度上,抄表周期别设太密。62056-21 一次完整交互加上唤醒,快的两三秒,慢的十几秒。100 块表串口轮询,一轮下来可能十几分钟。算好周期,别让任务堆积。失败重试我一般给两次,间隔 500ms,连续失败三次才标记设备离线,避免偶发干扰误判。
5. 避坑与排查:现场最常翻车的五个点
5.1 现象:设备完全没反应,一个字节都收不到
原因:串口线序接反(A/B 对调)、波特率或校验位不对、光口没对准、串口被别的程序占用。Windows 下用串口调试助手先确认能收到数据,再上 Java。Linux 下ls -l /dev/ttyUSB*看设备在不在,fuser /dev/ttyUSB0看谁占着。
解决:先用串口调试助手手动发/?!验证物理链路,通了再排查代码。线序问题在 RS485 上极常见,A 接 A、B 接 B,别想当然。
5.2 现象:能收到数据但全是乱码
原因:波特率或数据位/校验位不匹配,或者唤醒阶段和读数据阶段参数没切换。也有可能是串口服务器工作在网关模式改写了报文。
解决:核对设备手册的通信参数,确认唤醒后是否要切波特率。串口服务器改透传模式。乱码里如果能看到部分可读字符,说明波特率接近但不对,逐个试 9600/19200/38400。
5.3 现象:BCC 校验偶尔失败,重试就好
原因:线路干扰、接地不良、波特率偏高、USB 转串口芯片质量差。长距离 RS485 没加终端电阻也会这样。
解决:降低波特率到 9600 试试,加 120 欧终端电阻,检查屏蔽线接地。软件上把 BCC 失败纳入重试逻辑,但连续失败要告警,别无限重试掩盖硬件问题。
5.4 现象:回显和应答混在一起,解析错位
原因:没处理 C 模式的回显,或者回显和应答之间没有正确分隔。有的设备回显带\r\n,有的不带。
解决:按「先丢弃等于请求的回显,再读应答」处理,别用固定长度切。stripEcho里做前缀匹配,匹配不上就原样返回,兼容不回显的设备。
5.5 现象:跑一段时间后线程卡死,不再抄表
原因:read没有超时,设备掉线后线程永久阻塞;或者串口没关闭,句柄泄漏。
解决:所有read必须带超时,Transport用完在finally里close。加一个看门狗线程,定期检查抄表任务是否超时未完成,超时就强制关闭连接重建。这个后悔药,早加早省心。
6. 验证与进阶:用模拟从站和回归用例把库钉死
库写完不能只靠现场表验证,太慢也不可复现。我一般做两件事:写一个模拟从站,和一个报文回归用例集。
模拟从站用 Java 起一个 TCP Server 或虚拟串口(com0com 在 Windows 上建一对虚拟串口),按 C 模式时序回显请求、返回构造好的应答帧。这样主站库的握手、回显丢弃、BCC 校验、OBIS 解析全都能在本地跑通,不依赖真表。
// 极简模拟从站:收到请求后回显,再回一个构造的应答帧 public class MockSlave { public static void main(String[] args) throws IOException { try (ServerSocket server = new ServerSocket(4059)) { Socket client = server.accept(); InputStream in = client.getInputStream(); OutputStream out = client.getOutputStream(); byte[] buf = new byte[256]; int n = in.read(buf); if (n > 0) { out.write(buf, 0, n); // 回显 out.flush(); // 构造应答:SOH + 数据 + ETX + BCC + EOT byte[] payload = "1-0:1.8.0(12345*0.1*kWh)".getBytes( java.nio.charset.StandardCharsets.US_ASCII); java.io.ByteArrayOutputStream frame = new java.io.ByteArrayOutputStream(); frame.write(0x01); // SOH frame.write(payload); frame.write(0x03); // ETX byte bcc = 0; for (byte b : payload) bcc ^= b; frame.write(bcc); frame.write(0x04); // EOT out.write(frame.toByteArray()); out.flush(); } } } }逻辑说明:模拟从站先回显请求,再发一个带正确 BCC 的应答帧,主站库能完整走一遍流程。参数上端口用 4059 是常见约定,可改。BCC 计算和主站侧保持一致,否则主站会校验失败——这正好也能反向验证你的 BCC 实现对不对。
回归用例集就是把不同厂商的真实报文(脱敏后)存成文件,每条标注期望的 OBIS 和值,用 JUnit 跑。加一种新表,先加用例再改解析器,保证不破坏已有设备。这个习惯让我少踩很多「改 A 表坏了 B 表」的坑。
进阶方向有两个。一是批量抄表优化:把多块表的读命令合并成一次R5多 OBIS 请求,减少交互轮次,但要注意设备对单帧长度的限制,超了要拆。二是断点续抄:记录每块表上次成功抄到的位置,网络恢复后从断点继续,而不是全量重来。这两个都是现场跑久了才会想到的优化,先让基础流程稳,再谈这些。
我自己这些年做采集,最大的教训是:别急着上真表调,先把模拟从站和回归用例搭起来,本地能复现的问题才叫问题,现场偶发的多半是硬件和线路。协议库这东西,稳比快重要。希望帮到你。
本文还有配套的精品资源,点击获取