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 个,变长)。

block-beta columns 8 ASDU["ASDU"]:8 ID["数据单元标识(固定 6 字节)"]:4 BODY["信息体(1 到 N 个,变长)"]:4 TI["TI<br/>1B"] VSQ["VSQ<br/>1B"] COT["COT<br/>2B"] CA["公共地址<br/>2B"] I1["信息体 1"] I2["信息体 2"] D["…"] IN["信息体 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,平时没事,一总召唤数据量大就卡住,查了半天才发现窗口不一致。


四、一次完整通信长什么样

sequenceDiagram participant M as 主站/控制中心 participant S as 子站/RTU M->>S: TCP 连接建立(目标端口 2404) Note over M,S: TCP 三次握手完成 M->>S: U帧 STARTDT act S-->>M: U帧 STARTDT con Note over M,S: 数据通道打开,允许传输 I 帧 M->>S: I帧 总召唤命令(TI=0x64, COT=0x06) S-->>M: I帧 总召唤激活确认(COT=0x07) S-->>M: I帧 总召唤数据(COT=0x14,可多帧) M-->>S: S帧 确认 N(R)=下一个期望序号 Note over M,S: 总召唤数据可能分多个 I 帧上送,接收方按 w 参数发 S 帧确认 S->>M: I帧 自发/突发上送(COT=0x03,遥信变位/遥测越死区等) M-->>S: I帧本身也携带 N(R),可捎带确认;无反向 I 帧时用 S 帧确认 Note over M,S: 子站有变化,主动上送 M->>S: I帧 遥控选择(TI=0x2D, COT=0x06) S-->>M: I帧 选择确认(COT=0x07) M->>S: I帧 遥控执行(COT=0x06) S-->>M: I帧 执行确认(COT=0x07/0x0A) Note over M,S: 遥控通常采用“先选择、后执行”SBO 机制 M->>S: U帧 TESTFR act(链路空闲超过 t3 时) S-->>M: U帧 TESTFR con Note over M,S: 链路空闲超过 t3 发 TESTFR 保活 M->>S: U帧 STOPDT act S-->>M: U帧 STOPDT con Note over M,S: 停止数据传输 M->>S: TCP 连接关闭

关键点:

  1. 主站主动连子站 2404,TCP 三次握手。
  2. 主站发 STARTDT act,子站回 STARTDT con,数据通道才算打开。TCP 连上不等于能传 I 帧,这一步别省。
  3. 主站发总召唤,子站先回激活确认,再批量上送数据,最后发 ACT_TERM(COT=0x0A)表示本次总召唤结束。
  4. 正常运行期间子站有变化就主动上送 COT=0x03,主站用 S 帧或反向 I 帧里的 N(R) 确认。
  5. 遥控走"先选择、后执行"。
  6. 链路空闲超过 t3 发 TESTFR 保活。
  7. 要停就发 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 和对应的信息体类,流程是一样的。

posted @ 2026-11-15 21:33  Y00  阅读(42)  评论(0)    收藏  举报