IEC 104主站源码联调:APCI链路、ASDU解析与总召唤避坑
简介:面向电力自动化SCADA开发者的IEC 104主站通信源码,聚焦主站与远方子站之间的连接建立、数据采集与控制命令下发,适合已具备TCP/IP编程基础、希望深入电力通信协议细节的开发者研读。压缩包共28个文件、约302KB,以cpp与h源码为主体,配合预编译o文件、Windows下的vcproj/sln/dsw工程与user配置,以及linux、hpux两个mak编译脚本,另附ReadMe说明,跨平台工程结构较完整。源码按模块拆分:链路建立、心跳与重连管理,ASDU报文解析与构建,通道码处理,前台通信接口与表结构管理,并提供IEC 870应用层基类,便于理解104与101共用的应用层规范。目前已有835人学习下载,可作为掌握主站侧协议栈组织方式、报文处理流程与异常重连机制的参考实现,帮助读者把协议文本落到可调试的代码结构上。
1. 先别急着编译:一份 104 主站源码真正替你省掉的是哪段活
常见的联调现场是:从站模拟器已经开着,点表也给了,界面却一直停在“连接成功”,总召唤发不出去。IEC 60870-5-104 主站源码的价值不在“有个 TCP 客户端”,而在把 APCI 链路管理、k/w 窗口、t0/t1/t2/t3 定时器、ASDU 编解码和总召唤状态机这些脏活封装成可配置模块。它适合做配电自动化、工业网关、协议转换器的开发者,也适合要快速搭测试主站的人;但前提是知道它替你做了什么,以及它没替你做什么。
2. 链路先于业务:TCP 连接、STARTDT 与 APCI 参数的启动顺序
IEC 104 主站跑不起来,先别怀疑点表。大量“连上了但不通”的问题,根因在 APCI 层:TCP 只完成了字节流连接,真正让 104 会话进入数据交换的是 STARTDT 握手;I 帧里的发送序号、接收序号没有维护好,后面总召唤发得再对也会被从站丢掉。拿到源码后,合理的顺序是找协议入口、编译出可调试版本、核对第一组帧,再谈 ASDU。
2.1 先找协议入口,不要从界面按钮猜
源码包不管是 CMake 还是 Makefile,先定位三个东西:STARTDT 在哪里发、2404 端口在哪里配、总召唤类型 100 在哪里组包。用一条 grep 比从 UI 事件一路翻代码可靠得多。
# 在源码根目录执行,先定位协议关键路径 grep -RIn --include='*.c' --include='*.cpp' --include='*.h' \ -E 'STARTDT|TESTFR|C_IC_NA_1|2404|Interrogation' . # 常见 CMake 工程先编 Debug,保留符号和日志 cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j # 如果是纯 Makefile 工程,改用 # make -j这段命令不解决业务,但能快速判断源码结构:如果 STARTDT 只出现在注释里,说明链路管理没实现完;如果 2404 被硬编码在多个文件里,后面改端口一定漏;如果总召唤类型 100 只出现在 UI 层,说明协议层和业务层耦合较重。编译参数用 Debug 是为了让 t1 超时、序列号越界这类问题能在日志里看到调用栈,不要一上来编 Release。
2.2 启动顺序与第一组可核对帧
多数主站源码会把链路参数放在 ini、yaml 或数据库里,字段名可能不同,但核心项差不多。下面是一组常见起点,不是万能值,必须和从站侧手册、点表、工程规约对齐。
; 常见 104 主站链路配置起点,字段名按源码实际映射修改 [link] peer_host = 10.0.0.20 ; 从站 IP,按现场替换 peer_port = 2404 ; IEC 104 默认端口 common_address = 1 ; 公共地址,必须和从站一致 originator = 0 ; 源发地址,常用于区分控制源 k = 12 ; 未确认 I 帧发送窗口 w = 8 ; 收到多少 I 帧后必须发 S 帧确认 t0_ms = 30000 ; TCP 连接建立超时 t1_ms = 15000 ; 发送 I 帧后等待确认的超时 t2_ms = 10000 ; 无数据时发送 S 帧确认的超时 t3_ms = 20000 ; 空闲时发送 TESTFR 的超时 auto_startdt = true ; 连接后自动发 STARTDT_ACT参数里最容易翻车的是 k 和 w。k 表示主站最多允许多少个 I 帧未被确认,w 表示收到多少个 I 帧后要回 S 帧;常见默认是 k=12、w=8,但现场如果从站侧配了不同值,主站不能只改一个。t2 要小于 t1,否则 S 帧确认还没发出去,t1 已经先把链路判死。common_address不是 TCP 地址,它进的是 ASDU 公共地址字段,和 IP 完全两回事。
连接建立后,第一组帧应该能对上 U 帧格式。下面这组十六进制适合放在抓包工具或日志里做人工核对。
68 04 07 00 00 00 # STARTDT_ACT,主站请求启动数据传输 68 04 0B 00 00 00 # STARTDT_CON,从站确认 68 04 43 00 00 00 # TESTFR_ACT,链路空闲测试 68 04 83 00 00 00 # TESTFR_CON,测试确认68是启动字符,第二个字节是长度字段,表示控制域加 ASDU 的字节数。U 帧长度固定为 4,所以整帧是 6 字节;如果日志里出现68 04后面跟了 5 个以上控制字节,基本可以判定组包代码把长度算错了。STARTDT 没收到 CON 之前,不要发总召唤;有些实现会在auto_startdt后立刻发 I 帧,结果从站回STARTDT_CON后把前面的 I 帧丢掉,表现就是“第一次总召唤永远超时”。
2.3 用 APCI 解析脚本核对序列号
抓包工具能看到十六进制,但看不出 I 帧的发送序号和接收序号。写一个二十行的解析脚本,联调时比盯着日志猜快得多。
def parse_apdu(line: str): data = bytes.fromhex(line.replace(" ", "")) if len(data) < 6 or data[0] != 0x68: raise ValueError("not an IEC 104 APDU") length = data[1] ctrl = data[2:6] asdu = data[6:2 + length] first = ctrl[0] if (first & 0x01) == 0: kind = "I" send_seq = ((ctrl[0] | (ctrl[1] << 8)) >> 1) & 0x7FFF recv_seq = ((ctrl[2] | (ctrl[3] << 8)) >> 1) & 0x7FFF elif (first & 0x03) == 1: kind = "S" send_seq = None recv_seq = ((ctrl[2] | (ctrl[3] << 8)) >> 1) & 0x7FFF else: kind = "U" send_seq = recv_seq = None out = {"kind": kind, "len": length, "send": send_seq, "recv": recv_seq} if kind == "I" and len(asdu) >= 6: out.update({ "type": asdu[0], "count": asdu[1] & 0x7F, "sq": bool(asdu[1] & 0x80), "cot": asdu[2] | (asdu[3] << 8), "ca": asdu[4] | (asdu[5] << 8), }) return out for s in [ "68 04 07 00 00 00", "68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14", ]: print(parse_apdu(s))这段脚本的关键点在切片:data[6:2 + length]里的length包含 4 字节控制域和 ASDU,所以 ASDU 从第 6 个字节开始,到2 + length结束。I 帧判断看控制域第一个字节最低位是否为 0;S 帧是first & 0x03 == 1;U 帧最低两位为 3。序列号右移一位再取 15 位,因为第 0 位是格式位。cot里不只有原因码,还叠了 PN、TEST 和源发地址,业务判断原因码时用cot & 0x3F,不要直接拿整个 16 位和 6 比。
3. 把 ASDU 拆到字段级:总召唤、类型标识与信息对象地址的落地
链路通了以后,主站源码的核心工作落在 ASDU:类型标识决定后面跟几个字节,VSQ 决定对象数量和是否连续寻址,COT 决定这帧是激活、确认、数据还是终止。总召唤是最适合做第一条业务链路的入口,因为它把“发送命令、收确认、收数据、收终止”整条状态机都跑了一遍。
3.1 总召唤的状态机与 COT 变化
总召唤用类型标识 100,即C_IC_NA_1,QOI 常见取 20 表示站总召唤。主站先发 COT=6 的激活帧,从站回 COT=7 的激活确认,然后上送遥信、遥测等数据,数据的 COT 常见是 20,最后回 COT=10 的激活终止。很多实现只判断“收到类型 100 就完事”,结果把确认帧当数据帧,界面一直等不到终止。
一帧最简单的总召唤激活报文可以长这样:
68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14拆开看:68启动,0E长度 14,00 00 00 00是发送序号 0、接收序号 0 的 I 帧控制域,64是类型 100,01是 VSQ(SQ=0,1 个对象),06 00是 COT=6 激活,01 00是公共地址 1,00 00 00是信息对象地址 0,14是 QOI=20。总召唤的信息对象地址常用 0,但有些从站要求非 0,联调时以从站手册为准。
状态机至少要维护四个状态:未召唤、已发激活、已收确认、已收终止。收到 COT=7 之前不要重复发激活;收到 COT=10 之后才允许发起下一轮。t1 定时器要覆盖“激活发出后等确认”和“数据帧发出后等确认”两类场景,不能只用一个全局超时把所有等待混在一起。
3.2 类型标识、VSQ 与信息对象地址怎么拆
ASDU 头部固定 6 字节:类型标识 1 字节、VSQ 1 字节、COT 2 字节、公共地址 2 字节。后面才是信息对象。VSQ 的最高位是 SQ,低 7 位是对象数量;SQ=0 表示每个对象都带 3 字节信息对象地址,SQ=1 表示只带第一个地址,后续地址连续递增。类型不同,值字段长度完全不同。
| 类型标识 | 名称 | 常见值字段 | 解析注意 |
|---|---|---|---|
| 1 | M_SP_NA_1单点遥信 | SIQ 1 字节 | 最低位是状态,IV/NT/SB/BL 是品质 |
| 3 | M_DP_NA_1双点遥信 | DIQ 1 字节 | 低两位是状态,不能只取 0/1 |
| 9 | M_ME_NA_1归一化遥测 | 2 字节有符号 | 常见按 32768 归一化,不是原码 |
| 11 | M_ME_NB_1标度化遥测 | 2 字节有符号 | 量程和系数在点表里 |
| 13 | M_ME_NC_1短浮点遥测 | 4 字节 IEEE754 | 注意字节序和 QDS |
| 15 | M_IT_NA_1累计量 | 4 字节有符号 | 常带 CP56Time2a 或顺序号 |
| 30/31 | 带 CP56Time2a 的单点/双点 | SIQ/DIQ + 7 字节时标 | 时标年份要加 2000 |
信息对象地址是 3 字节小端,范围 1 到 16777215,0 常保留给总召唤这类命令。很多主站源码在回调里只给ioa和value,把品质位丢掉了,现场就会出现“值对但状态不对”的问题。遥信要保留 IV 无效、NT 非当前、SB 取代、BL 闭锁;遥测要保留 QDS 的溢出、坏值、取代、闭锁、无效。业务层可以不用全部展示,但协议层不能提前丢掉。
3.3 回调层不要直接写业务库
主站源码最常见的结构是:接收线程解 APCI,协议层解 ASDU,然后调用一个onAsdu或onPoint回调。回调里直接写数据库、刷新 UI、做远程调用,是后面卡顿和丢帧的源头。接收线程只做解析和入队,业务处理放到独立线程,队列满时记录丢弃数,比无限堆积更可控。
#include <cstring> #include <cstdint> static uint32_t readIoa(const uint8_t* p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16); } void onAsdu(const uint8_t* a, size_t n) { if (n < 6) return; uint8_t type = a[0]; bool sq = (a[1] & 0x80) != 0; int count = a[1] & 0x7F; uint16_t cot = (uint16_t)(a[2] | (a[3] << 8)); uint16_t ca = (uint16_t)(a[4] | (a[5] << 8)); size_t off = 6; (void)cot; (void)ca; switch (type) { case 1: { // M_SP_NA_1,单点遥信 uint32_t base = 0; if (sq && count > 0) { base = readIoa(a + off); off += 3; } for (int i = 0; i < count; ++i) { uint32_t ioa = sq ? base + (uint32_t)i : readIoa(a + off); if (!sq) off += 3; if (off >= n) return; uint8_t siq = a[off++]; bool on = (siq & 0x01) != 0; bool invalid = (siq & 0x80) != 0; // 这里只入队,不写数据库;ioa/on/invalid 交给业务线程 (void)ioa; (void)on; (void)invalid; } break; } case 13: { // M_ME_NC_1,短浮点遥测 uint32_t base = 0; if (sq && count > 0) { base = readIoa(a + off); off += 3; } for (int i = 0; i < count; ++i) { uint32_t ioa = sq ? base + (uint32_t)i : readIoa(a + off); if (!sq) off += 3; if (off + 5 > n) return; uint32_t bits = (uint32_t)a[off] | ((uint32_t)a[off + 1] << 8) | ((uint32_t)a[off + 2] << 16) | ((uint32_t)a[off + 3] << 24); float value; std::memcpy(&value, &bits, sizeof(value)); uint8_t qds = a[off + 4]; off += 5; // qds 的 IV/NT/SB/BL/OV 要一起入队,不要只留 value (void)ioa; (void)value; (void)qds; } break; } default: break; } }这段代码只演示两个高频类型,重点在三个边界。第一,SQ=1 时先读一个基地址,后面用base + i,不要再逐对象读地址;第二,SQ=0 时每次循环都要推进 3 字节地址,漏掉这一步会把值字段当地址;第三,浮点值用字节拼成uint32_t再memcpy到float,不要直接对未对齐指针做reinterpret_cast,在部分平台上会崩。count最多 127,但实际长度还要受 APDU 总长限制,解析前必须做off越界检查。
4. 避坑与排查:104 主站联调时最容易翻车的五个现场
104 的坑有个共同点:表面看是“数据不对”,根因却在链路状态、序号窗口或字节序。下面五个现象来自这类主站源码最常见的联调路径,按“现象、原因、解决”拆开看,比盲改点表有效。
4.1 链路建立阶段的三个异常
现象一:TCP 显示连接成功,但 STARTDT 一直等不到 CON。
原因通常是 U 帧控制域写错,或者长度字段把 4 字节控制域算成了 6 字节。STARTDT_ACT应该是68 04 07 00 00 00,有些实现写成68 06 07 00 00 00 00 00,从站直接丢弃。解决方法是先把发送缓冲打出来,对牢68 04后跟 4 字节控制域这个格式;再检查状态机是否在收到 CON 之前重复发 ACT,重复发也会让部分从站进入异常。
现象二:STARTDT 正常,总召唤发出后没有任何回应。
先看公共地址和信息对象地址。公共地址在 ASDU 第 5、6 字节,不是 TCP 端口;总召唤的 IOA 常见为 0,但有的从站要求 1 或其他值。再看 COT 是否被写成了06 80之类,原因码 6 被品质位污染。解决方法是把总召唤帧固定成可核对样例:68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14,逐字节对照,先排除组包错误。
现象三:连接几分钟后自动断开,日志里只有“t1 timeout”。
常见原因是 k 窗口和 w 参数不匹配,或者收到 I 帧后没有回 S 帧。主站连续发 I 帧,未确认数达到 k,发送被阻塞;从站等主站的 S 帧确认,主站却在等业务数据,最后 t1 超时。解决方法是把发送序号、接收序号、未确认计数做成三个独立变量,每发一个 I 帧递增发送序号,每收一个 I 帧递增接收序号并按 w 触发 S 帧,收到 S 帧或带接收序号的 I 帧后释放未确认队列。
4.2 总召唤与数据解析阶段的坑
现象四:总召唤收到了确认和终止,但遥测值全是 0 或明显离谱。
类型 9 的归一化值不是浮点,常见做法是按有符号 16 位除以 32768;类型 11 是标度化整数,要按点表系数换算;类型 13 才是 IEEE754 浮点。把类型 9 当浮点读,或者把 float 的字节序按大端拼,都会得到离谱值。解决方法是按类型分派解析函数,先打印原始字节和类型标识,再对照点表确认量程;品质位 IV 置位时不要参与业务计算。
现象五:总召唤过程中数据一多就丢帧,界面点位更新不全。
根因往往不在协议层,而在回调里做了同步数据库写入或 UI 刷新,接收线程被拖慢,TCP 缓冲区堆积后从站重传或断开。解决方法是把回调改成“解析后入队”,业务线程批量消费;队列要有界,满了记录丢弃点位和数量。总召唤期间可以把日志级别调低,只记录类型、COT、CA、IOA 和品质,不要每帧都打全量十六进制。
4.3 重连与部署时别把状态带进新连接
断线重连是另一个高频翻车点。新 TCP 连接建立后,APCI 的发送序号和接收序号要按新连接重新开始,不能沿用旧连接的窗口;否则从站收到的第一个 I 帧序号就对不上。重连时还要清空未确认队列、未完成的总召唤状态和待发送的 S 帧,重新走 STARTDT,再发起总召唤。现场部署时把“重连后自动总召唤”做成可配置项,有些从站要求人工触发,自动连发会被当成风暴。
提示:排查 104 问题时,先抓“连接后前 20 帧”,再看总召唤期间的前 50 帧。大多数链路和序号问题都在这两段里暴露。
5. 从能跑到能用:配置分层、日志与测试桩的工程化接法
源码能跑通总召唤,只说明协议主路径通了。真正放到网关或后台服务里,还要解决配置可追踪、日志可回放、测试可重复三件事。否则每次现场出问题都只能远程猜,改一个参数重新编一版,效率很低。
5.1 配置项要分三层,不要散落在代码里
链路参数、点表映射、业务策略最好分开。链路参数管 IP、端口、k/w、t0-t3;点表映射管公共地址、信息对象地址、类型、系数、工程单位;业务策略管总召唤周期、变化上送是否入库、告警死区。三层混在一个 ini 里,后面换一个从站就要复制整份配置,很容易漏改。
| 参数 | 常见起点 | 作用 | 修改边界 |
|---|---|---|---|
peer_port | 2404 | 从站 TCP 端口 | 以从站实际监听为准 |
common_address | 1 | ASDU 公共地址 | 必须和从站点表一致 |
k | 12 | 未确认 I 帧窗口 | 不能大于从站接收能力 |
w | 8 | 收到多少 I 帧回 S 帧 | 通常不大于 k 的三分之二 |
t0 | 30s | TCP 建立超时 | 无线链路可适当放宽 |
t1 | 15s | 发送确认超时 | 要大于现场最大往返时延 |
t2 | 10s | 无数据时 S 帧超时 | 必须小于 t1 |
t3 | 20s | 空闲 TESTFR 周期 | 不要短到让从站误判 |
点表里最容易漏的是类型和系数。同一个信息对象地址在不同从站上可能是类型 1,也可能是类型 30;同一个遥测可能是归一化,也可能是短浮点。配置加载时要做一次启动校验:类型是否在支持列表、IOA 是否重复、系数是否为 0、公共地址是否超出 16 位。校验失败直接拒绝启动,比运行到一半再报错好。
5.2 用 Python 测试桩构造可重复报文
联调不能只靠从站模拟器点按钮。把常用帧写成构造函数,改一个字段就能回放,才能稳定复现序号、COT、SQ 这些边界。
def i_frame(send_seq: int, recv_seq: int, asdu: bytes) -> bytes: # IEC 104 序列号是 15 位,左移一位后填入控制域 ctrl = bytes([ (send_seq << 1) & 0xFF, (send_seq >> 7) & 0xFF, (recv_seq << 1) & 0xFF, (recv_seq >> 7) & 0xFF, ]) body = ctrl + asdu if len(body) > 253: raise ValueError("APDU too long") return bytes([0x68, len(body)]) + body def total_interrogation(ca: int = 1, qoi: int = 20) -> bytes: asdu = bytes([ 100, # C_IC_NA_1 0x01, # SQ=0,1 个对象 0x06, 0x00, # COT=6,激活 ca & 0xFF, (ca >> 8) & 0xFF, 0x00, 0x00, 0x00, # IOA=0 qoi, ]) return i_frame(0, 0, asdu) def s_frame(recv_seq: int) -> bytes: ctrl = bytes([ 0x01, 0x00, (recv_seq << 1) & 0xFF, (recv_seq >> 7) & 0xFF, ]) return bytes([0x68, len(ctrl)]) + ctrl print(total_interrogation().hex(" ")) print(s_frame(1).hex(" "))i_frame里发送序号和接收序号都按 15 位处理,左移一位后低字节在前;s_frame的第一字节固定01 00,后两字节是接收序号左移后的值。测试桩不要只构造正确帧,还要故意构造序号跳变、COT 错误、SQ=1 但对象数超长、长度字段大于实际缓冲的帧,观察主站源码
本文还有配套的精品资源,点击获取