IEC 104 协议笔记:从报文结构到基于 lib60870.NET 客户端/服务端实现
前段时间项目里要接一个变电站的 RTU,主站侧是我们自己写的上位机系统,子站那边设备通信只支持 IEC 104 协议。翻了一圈资料,发现 IEC 104 的中文材料不少,但要么是标准条文翻译,要么只讲概念不给能跑的代码。这篇就把我这次从看协议到把客户端、服务端跑通的整个过程记一下。
一、IEC 104 到底解决什么问题
一句话:把 IEC 101 的应用层数据,搬到 TCP/IP 上跑。
IEC 101 是串口时代的远动协议,RS-232/485,主站轮询,子站被动应答。站一多、点一多,轮询周期就压不下来,实时性很难看。IEC 104 保留了 101 那套 ASDU(应用服务数据单元)的定义,把底层换成 TCP,端口默认 2404。带来的变化不只是"能走网线":
- 通信模式从主从轮询变成事件驱动,子站检测到遥信变位、遥测越死区,可以主动上送,不用等主站来问;
- 传输距离和拓扑不再受串口限制;
- 但可靠性不能全甩给 TCP,应用层还是自己维护了序列号和确认机制。
所以看 IEC 104 的报文,会发现它既有 TCP 的壳,又有一套自己的"帧序号 + 确认"逻辑。
二、报文结构:APDU = APCI + ASDU
每个 IEC 104 报文叫一个 APDU,分成两截:
- APCI:固定 6 字节,管传输控制;
- ASDU:变长,装实际数据。只有 I 帧才带 ASDU。
APCI 这 6 个字节是固定的:
| 字节 | 内容 | 说明 |
|---|---|---|
| 0 | 0x68 |
启动字符,十进制就是 104 |
| 1 | APDU 长度 | 控制域 4 字节 + ASDU 长度,范围 4–253 |
| 2–5 | 控制域 | 决定帧类型和序列号 |
控制域最低两位直接决定帧类型,这是抓包时第一眼要看的东西:
I 帧:bit0 = 0。唯一携带 ASDU 的帧,遥测、遥信、总召唤、遥控都走它。控制域里带发送序号 N(S) 和接收序号 N(R),用来做顺序控制和丢帧检测。
S 帧:bit0 = 1、bit1 = 0。固定 6 字节,不带 ASDU,只干一件事——确认收到的 I 帧。N(R) 填的是"下一个期望收到的 I 帧序号"。
U 帧:bit0 = 1、bit1 = 1。固定 6 字节,不参与序号计数,负责链路控制。常用的就三个:STARTDT(启动数据传输)、STOPDT(停止)、TESTFR(链路测试)。
2.1 ASDU 的内部结构
APCI 之后就是 ASDU。ASDU 可以分成两段:数据单元标识(固定 6 字节)+ 信息体(1 到 N 个,变长)。
数据单元标识四个字段逐个说。
TI(类型标识,1 字节) :决定整个 ASDU 的信息体格式。一个 ASDU 里所有信息对象必须是同一个 TI,不同类型的数据必须拆成不同 ASDU 发。常用值:
| TI(十 / 十六进制) | 名称 | 方向 |
|---|---|---|
1 / 0x01 |
M_SP_NA_1,不带时标单点遥信 |
子站 → 主站 |
9 / 0x09 |
M_ME_NA_1,归一化遥测 |
子站 → 主站 |
13 / 0x0D |
M_ME_NC_1,短浮点遥测 |
子站 → 主站 |
45 / 0x2D |
C_SC_NA_1,单点遥控命令 |
主站 → 子站 |
100 / 0x64 |
C_IC_NA_1,总召唤命令 |
主站 → 子站 |
解析 ASDU 的第一步永远是看 TI,不知道 TI,后面的字节就无从解释。
VSQ(可变结构限定词,1 字节) :拆成两部分——bit 7 是 SQ,0 表示信息体地址离散,1 表示连续;bit 0–6 是信息对象个数 N(1–127)。
SQ 这个位直接决定信息体的字节布局,是解析时最容易翻车的地方。
- SQ = 0(离散) :每个信息体都自带 3 字节 IOA,结构是
[IOA 3B][值]重复 N 次; - SQ = 1(连续) :只有第一个信息体带 IOA,后面 N−1 个省略 IOA,地址由第一个 IOA 依次加 1 推出,结构是
[IOA 3B][值1][值2]...[值N]。
总召唤上送一批连续遥测点时通常用 SQ=1,100 个点能省下 99×3 = 297 字节的 IOA 开销。但前提是地址真的连续,强行用会解析错位。
COT(传送原因,2 字节) :真正有意义的是第一个字节,第二个字节是源发地址,工程上几乎总是填 0。第一个字节再拆三个子字段:
| 位 | 子字段 | 含义 |
|---|---|---|
| bit 0–5 | 原因码 | 1–47 标准定义,48–63 特殊用途 |
| bit 6 | P/N | 0 = 肯定确认,1 = 否定确认 |
| bit 7 | T | 0 = 非试验,1 = 试验(几乎不用) |
原因码高频出现的就是几个:0x03 自发/突发,0x06 激活,0x07 激活确认,0x0A 激活终止,0x14(十进制 20)被站召唤。P/N 位是遥控确认里真正有用的东西——子站回复原因码 7 且 P/N = 0 才是肯定确认,P/N = 1 表示否定(IOA 非法、命令被闭锁等),不要只看原因码是 7 就认为成功。
公共地址(2 字节) :标识这个 ASDU 属于哪个站,一个 TCP 连接下挂多个 RTU 时靠它分流。0 不用,1–65534 为站地址,65535 是广播地址。
2.2 信息体
信息体的通用结构是 [IOA 3B][信息元素 D B][可选时标 7B]。
- IOA(信息对象地址) :3 字节,站内唯一定位一个点。控制方向是目标地址,监视方向是源地址。总召唤、时钟同步这类系统命令的 IOA 填 0,没有实际点位含义。
- 信息元素:长度和内容由 TI 决定。
- 时标:只有带时标的 TI(如
M_SP_TB_1、M_ME_TF_1)才有,不带时标的 TI 没有这 7 个字节。
用到的几个 类型标识(TI)的信息体具体字节:
遥信 M_SP_NA_1(TI=1) :每个信息体是 IOA(3B) + SIQ(1B)。SIQ 里 bit 0 是 SPI(0 分 / 1 合),bit 4 是 BL(闭锁),bit 5 是 SB(取代),bit 6 是 NT(非当前值),bit 7 是 IV(无效)。
遥测 M_ME_NA_1(TI=9) :每个信息体是 IOA(3B) + NVA(2B) + QDS(1B)。NVA 是 2 字节归一化值,补码表示,具体物理量换算靠点表系数;QDS 是品质描述。
遥测 M_ME_NC_1(TI=13) :每个信息体是 IOA(3B) + 短浮点(4B) + QDS(1B),浮点遵循 IEEE 754 单精度。工程里用得最多,因为收到的 float 直接就是工程值,不用再配归一化系数。
遥控 C_SC_NA_1(TI=45) :每个信息体是 IOA(3B) + SCO(1B)。SCO 里 bit 0 是 SCS(0 分 / 1 合),bit 7 是 S/E(0 执行 / 1 选择)。子站回确认时镜像这个 SCO,主站就能对应上是哪一步的确认。
总召唤 C_IC_NA_1(TI=100) :系统命令,结构简单,[IOA 3B = 0][QOI 1B]。IOA 固定填 0,后面的 QOI 才是有效内容。
2.3 一个总召唤命令
主站发一帧站总召唤,ASDU 部分逐字节:
| 字节 | 值 | 字段 | 含义 |
|---|---|---|---|
| 0 | 0x64 |
TI = 100 | 总召唤命令 |
| 1 | 0x01 |
VSQ | SQ=0,N=1(1 个信息体) |
| 2 | 0x06 |
COT 低字节 | 原因码 6(激活),P/N=0,T=0 |
| 3 | 0x00 |
COT 高字节 | 源发地址 0 |
| 4–5 | 0x01 0x00 |
公共地址 | 站地址 = 1(小端序) |
| 6–8 | 0x00 0x00 0x00 |
IOA | 系统命令,固定 0 |
| 9 | 0x14 |
QOI | 20,站总召唤 |
ASDU 的解析顺序永远是:先看 TI 决定信息体格式,再看 VSQ 的 SQ 和 N 决定信息体怎么排,再看 COT 的原因码和 P/N 判断这次传输的性质,最后按 SQ 规则逐条取出 IOA 和数据值。
三、关键工程参数:k、w、t1、t2、t3
IEC 104 的可靠性不仅依赖 TCP,还依靠一套应用层的窗口与超时参数。这些参数在工程配置中必须两端一致,否则会导致通信异常。
| 参数 | 含义 | 常见值 |
|---|---|---|
| k(发送窗口) | 发送方在收到确认之前,最多可以连续发送的 I 帧数量。k 值限制了“已发未确认”的帧数,防止序号回绕和接收方缓冲区溢出。 | 12 |
| w(接收确认窗口) | 接收方每收到 w 个 I 帧,就必须发送一次 S 帧确认。w 值决定了确认的及时性;若 w 过大,发送方可能因等待确认而停顿。 | 8 |
| t1(发送超时) | 发送 I 帧或 TESTFR 后,等待对方确认/响应的最长时间。超时则判定链路异常,通常触发 TCP 连接断开。 | 15s |
| t2(确认超时) | 接收方收到 I 帧后,若在 t2 时间内没有反向 I 帧可“捎带”确认,则必须主动发送 S 帧确认。t2 必须小于 t1。 | 10s |
| t3(空闲测试超时) | 链路空闲超过 t3 时,主动发送 TESTFR 探测连接存活状态。 | 20s |
我们现场有一次主站配 k=12、子站配 k=8,平时没事,一总召唤数据量大就卡住,查了半天才发现窗口不一致。
四、一次完整通信长什么样
关键点:
- 主站主动连子站 2404,TCP 三次握手。
- 主站发
STARTDT act,子站回STARTDT con,数据通道才算打开。TCP 连上不等于能传 I 帧,这一步别省。 - 主站发总召唤,子站先回激活确认,再批量上送数据,最后发
ACT_TERM(COT=0x0A)表示本次总召唤结束。 - 正常运行期间子站有变化就主动上送 COT=0x03,主站用 S 帧或反向 I 帧里的 N(R) 确认。
- 遥控走"先选择、后执行"。
- 链路空闲超过 t3 发 TESTFR 保活。
- 要停就发 STOPDT,再关 TCP。
五、.NET 落地
5.1 选库
NuGet 直接装:
dotnet add package lib60870.NET
先把这个库的许可证说清楚:GPLv3,如果是闭源商业项目,要么走它的商业授权,要么换库。目前我还没找到比较适合的开源库,后期我打算用 AI 辅助开发一个简单的库给项目用。
5.2 服务端(子站/RTU)
服务端核心就三件事:监听、处理主站命令、主动上送。下面是主要骨架。
using lib60870;
using lib60870.CS101;
using lib60870.CS104;
public class Iec104ServerApp
{
private Server _server;
private readonly int _commonAddress = 1;
public void Start()
{
_server = new Server();
_server.DebugOutput = false;
_server.SetASDUReceivedHandler(OnAsduReceived, null);
_server.Start();
Console.WriteLine("IEC 104 服务端已启动,端口 2404");
}
private bool OnAsduReceived(object parameter, ASDU asdu)
{
switch (asdu.TypeId)
{
case TypeID.C_IC_NA_1:
HandleInterrogation(asdu);
return true;
case TypeID.C_SC_NA_1:
HandleSingleCommand(asdu);
return true;
default:
Console.WriteLine($"未处理类型: {asdu.TypeId}");
return true;
}
}
private void HandleInterrogation(ASDU request)
{
// 先回激活确认
var actCon = new ASDU(
request.TypeId,
CauseOfTransmission.ACTIVATION_CON,
false, false,
request.CommonAddress,
request.CauseOfTransmission,
false);
actCon.AddInformationObject(new InterrogationCommand(0, 20));
_server.EnqueueASDU(actCon);
// 上送遥信
var spiAsdu = new ASDU(
TypeID.M_SP_NA_1,
CauseOfTransmission.INTERROGATED_BY_STATION,
false, false,
_commonAddress,
0, false);
spiAsdu.AddInformationObject(
new SinglePointInformation(1001, true, QualityDescriptor.GOOD));
spiAsdu.AddInformationObject(
new SinglePointInformation(1002, false, QualityDescriptor.GOOD));
_server.EnqueueASDU(spiAsdu);
// 上送遥测
var measAsdu = new ASDU(
TypeID.M_ME_NC_1,
CauseOfTransmission.INTERROGATED_BY_STATION,
false, false,
_commonAddress,
0, false);
measAsdu.AddInformationObject(
new MeasuredValueShort(2001, 220.5f, QualityDescriptor.GOOD));
measAsdu.AddInformationObject(
new MeasuredValueShort(2002, 50.02f, QualityDescriptor.GOOD));
_server.EnqueueASDU(measAsdu);
// 总召唤结束,这个别漏
var actTerm = new ASDU(
request.TypeId,
CauseOfTransmission.ACTIVATION_TERMINATION,
false, false,
request.CommonAddress,
0, false);
actTerm.AddInformationObject(new InterrogationCommand(0, 20));
_server.EnqueueASDU(actTerm);
}
private void HandleSingleCommand(ASDU command)
{
var sc = (SingleCommand)command.GetElement(0);
bool isSelect = sc.Select;
var con = new ASDU(
TypeID.C_SC_NA_1,
CauseOfTransmission.ACTIVATION_CON,
false, false,
command.CommonAddress,
0, false);
con.AddInformationObject(
new SingleCommand(sc.ObjectAddress, sc.State, isSelect, 0));
_server.EnqueueASDU(con);
if (!isSelect)
{
SendSpontaneousSinglePoint(sc.ObjectAddress, sc.State);
}
}
public void SendSpontaneousSinglePoint(int ioa, bool value)
{
var asdu = new ASDU(
TypeID.M_SP_NA_1,
CauseOfTransmission.SPONTANEOUS,
false, false,
_commonAddress,
0, false);
asdu.AddInformationObject(
new SinglePointInformation(ioa, value, QualityDescriptor.GOOD));
_server.EnqueueASDU(asdu);
}
}
几个当时踩过的点:
- 总召唤的响应顺序必须是 激活确认 → 数据 → ACT_TERM。我一开始只发了数据没发 ACT_TERM,主站那边一直认为总召唤没结束,界面上数据一直转圈。
EnqueueASDU在多线程里调用要注意线程安全,实际项目里上送逻辑最好串到一个队列里,别在多个业务线程里直接怼。- 遥控的
Select字段区分选择和执行,服务端两次都要回确认,别只回执行。
5.3 客户端(主站/控制中心)
客户端用 Connection,Connect() 内部会把 TCP 和 STARTDT 握手一起做掉,不用自己拼 U 帧。
using lib60870;
using lib60870.CS101;
using lib60870.CS104;
public class Iec104ClientApp
{
private Connection _connection;
private readonly int _commonAddress = 1;
public void Connect()
{
_connection = new Connection("127.0.0.1", 2404);
_connection.DebugOutput = false;
_connection.SetASDUReceivedHandler(OnAsduReceived, null);
_connection.SendWindowSize = 12;
_connection.ReceiveWindowSize = 8;
_connection.TimeoutT1 = TimeSpan.FromSeconds(15);
_connection.TimeoutT2 = TimeSpan.FromSeconds(10);
_connection.TimeoutT3 = TimeSpan.FromSeconds(20);
_connection.Connect();
Console.WriteLine("已连接,数据通道已打开");
}
private bool OnAsduReceived(object parameter, ASDU asdu)
{
switch (asdu.TypeId)
{
case TypeID.M_SP_NA_1:
case TypeID.M_ME_NC_1:
if (asdu.Cot == CauseOfTransmission.INTERROGATED_BY_STATION)
{
for (int i = 0; i < asdu.ElementCount; i++)
{
var io = asdu.GetElement(i);
Console.WriteLine($"总召唤数据: IOA={io.ObjectAddress}");
}
}
else if (asdu.Cot == CauseOfTransmission.SPONTANEOUS)
{
for (int i = 0; i < asdu.ElementCount; i++)
{
var spi = (SinglePointInformation)asdu.GetElement(i);
Console.WriteLine($"自发遥信: IOA={spi.ObjectAddress}, 值={spi.Value}");
}
}
return true;
case TypeID.C_SC_NA_1:
var sc = (SingleCommand)asdu.GetElement(0);
Console.WriteLine($"遥控确认: IOA={sc.ObjectAddress}, 选择={sc.Select}, COT={asdu.Cot}");
return true;
default:
return true;
}
}
public void SendGeneralInterrogation()
{
_connection.SendInterrogationCommand(
CauseOfTransmission.ACTIVATION,
_commonAddress,
20); // QOI=20,站总召唤
}
public void SendSelectCommand(int ioa, bool value)
{
var sc = new SingleCommand(ioa, value, true, 0);
_connection.SendProcessCommand(
CauseOfTransmission.ACTIVATION,
_commonAddress,
sc);
}
public void SendExecuteCommand(int ioa, bool value)
{
var sc = new SingleCommand(ioa, value, false, 0);
_connection.SendProcessCommand(
CauseOfTransmission.ACTIVATION,
_commonAddress,
sc);
}
}
客户端这边,自发上送和遥控确认都从同一个 ASDUReceivedHandler 进来,按 TypeId 和 Cot 分流就行。总召唤数据可能分多个 I 帧上送,不要假设一帧收完。
六、两个容易被问到的细节
6.1 QOI 是什么
QOI 全称 Qualifier of Interrogation,召唤限定词,是总召唤命令 ASDU 里的 1 个字节,告诉子站"这次要哪个范围的数据"。
| 取值 | 含义 |
|---|---|
| 20 | 站总召唤,返回该公共地址下所有数据 |
| 21–36 | 组召唤 1–16,只返回对应组的数据 |
| 其他 | 保留 |
代码里 SendInterrogationCommand 第三个参数填 20,就是站总召唤。抓包看 QOI 是 0x14,十进制 20。
组召唤不是发个 21 就自动有数据,子站工程配置里得先把点分配到对应组,否则返回空或者报错。一般小系统只实现 QOI=20 就够了。
6.2 遥控选择确认和执行确认,COT 一样吗
一样,都是 7(激活确认)。
原因是协议把"选择"和"执行"当成两次独立的激活操作:主站发选择,COT=6;子站回选择确认,COT=7。主站再发执行,COT 又是 6;子站回执行确认,COT 还是 7。
既然 COT 一样,怎么区分?靠 P/N 位(肯定/否定)和 S/E 位(选择/执行)的镜像,再加上主站自己知道当前处于哪个阶段。主站发出选择后收到的 COT=7 就是选择确认,发出执行后收到的 COT=7 就是执行确认。有些实现会在执行确认之后再补一个 COT=10(激活终止)表示流程结束,但执行确认本身仍然是 7。
七、小结
IEC 104 本身不复杂,难的是细节:STARTDT 要先做、总召唤要有 ACT_TERM、遥控选择和执行是两次独立激活、ASDU 解析要先看 TI 再看 VSQ 的 SQ 位、k/w/t1/t2/t3 两端对齐。代码骨架上面这些跑通基本通信没问题,生产环境还要补异常处理、断线重连、上送队列和线程安全。
库的选择上,lib60870.NET 是目前 .NET 下比较省事的,但 GPLv3 这个事一定提前确认。如果只是学习或者内部工具,直接用没问题。后面如果要把遥测的归一化值、标度化值、带时标的 ASDU 也接进来,无非是换 TypeID 和对应的信息体类,流程是一样的。
本文来自博客园,作者:Y00,转载请注明原文链接:https://www.cnblogs.com/ayic/p/23591891
浙公网安备 33010602011771号