简介面向电力自动化与SCADA系统开发者的IEC 104主站通信源码适合需要理解规约交互、搭建主站端或进行二次开发的中高级读者。内容围绕主站连接管理、心跳维持、数据请求、遥控命令、ASDU解析与异常处理等模块展开可对照源码梳理TCP/IP链路建立、重连、超时重传和日志记录等实现思路。压缩包共28个文件、约302KB以C/C源文件、头文件、工程配置与编译中间文件为主便于在Visual C或Linux环境下阅读和编译验证。已有834人学习可作为研究IEC 104报文结构、主站功能划分及电力通信程序组织方式的参考样例帮助读者积累协议解析与排错经验。1. 从“连上 TCP 却收不到一个字节”说起104 主站到底难在哪如果只把 104 主站当成一个普通 TCP 客户端来写十有八九会在第一次总召唤时卡住socket 显示连接成功日志却一条报文都没有子站一个 I 帧都不回。这不是协议解析的问题瓶颈在 STARTDT 链路启动、N(S)/N(R) 序号管理和 t1/t2/t3 三个定时器上——这三样没做对抓包看到的就是玄学。这份 iec104 主站源码把传输层、链路层 APCI、应用层 ASDU 拆成三个独立模块主站侧只负责发起连接、建链路、发总召唤、收遥测遥信、下发遥控。适合两类人手上一堆 104 子站设备等着接的工控方向开发者和想拿一份能直接跑的最小主站做二次开发的人。2. 目录与分层TCP、APCI、ASDU 三层怎么切拿到一份 iec104 主站源码先别急着找main。这类项目真正的骨架是按标准里的三层结构切开的网络层只管收发字节流链路层的 APCI 负责 I/S/U 帧和序号应用层的 ASDU 负责类型标识和点表映射。三层若混在一个文件里改一个参数会牵动全流程分开了之后你改点表、加类型、调超时各自都有自己的位置。2.1 目录结构与编译入口不同来源的源码包文件名会差一些但合格的分层基本长这样路径职责平时改动频率src/net/tcp_client.c建连、断线重连、非阻塞收低src/link/apci.cI/S/U 帧编解码、6 字节头拼装极低src/link/link_fsm.cSTARTDT/TESTFR、序号推进、k/w 流控低src/asdu/asdu.cASDU 头解析、TypeID 分发中src/asdu/asdu_codec.c各类型信息对象的编解码中src/app/app_master.c总召唤、时钟同步、遥控状态机高src/point/point_db.c点表IOA 到工程值高config/station.ini站地址、IP、k/w、t0~t3高config/points.ini点表配置高这个切法的好处很直接apci.c里几乎不出现“遥测”“遥控”这种词point_db.c里也不会出现0x68。配置文件里我一般把链路参数单独放一段因为k和w在调试现场调得最频繁[station] ca 1 ip 192.168.1.10 port 2404 [link] k 12 ; 未确认 I 帧上限超过就停发 w 8 ; 收到 w 个 I 帧后必须回一次 S 帧 [timers] t0 30 ; 建链超时 t1 15 ; 发送/测试超时 t2 10 ; 无数据时的确认超时 t3 20 ; 空闲发 TESTFR 的周期编译用 CMake 一把过-v打开报文打印第一次联调没这个参数基本寸步难行cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build -j4 ./build/iec104_master -c config/station.ini -p config/points.ini -v参数说明-c指定站和链路参数-p指定点表-v把收发的每一帧按十六进制打出来。跟子站对不上时先看-v里有没有68 xx开头的帧能立刻区分是“链路没建起来”还是“应用层回错”。注意k不要一上来就调大很多子站实现只认 12发超了直接不回复。2.2 一个 I 帧从 socket 到点表的完整链路主站侧每路连接一个会话上下文序号和定时器都挂在上面而不是用全局变量。这一点在多站并发的场景下是硬要求否则两路连接的 N(S) 会互相串表现就是子站莫名其妙断链typedef struct { int fd; uint16_t tx_seq; /* N(S)本地发送序号 0~32767 */ uint16_t rx_seq; /* N(R)期望收到的下一个序号 */ uint8_t rx_buf[2048]; /* 半包缓存TCP 不保证一次收全一帧 */ int rx_len; uint8_t unacked; /* 已发未确认的 I 帧数对应 k */ uint8_t ack_pending; /* 已收未确认的 I 帧数对应 w */ time_t t1_deadline; /* 最早一个未确认帧的超时点 */ time_t t2_deadline; time_t t3_deadline; int started; /* STARTDT 是否已确认 */ } ictx_t;收包入口先做长度校验再判帧类型。104 的 APCI 头固定 6 字节第 2 个字节aplen表示“控制域 4 字节 ASDU 长度”所以一帧总长是aplen 2static int on_link_data(ictx_t *c, const uint8_t *pkt, int len) { if (len 6 || pkt[0] ! 0x68) return -1; /* 启动字符不对直接丢 */ uint8_t aplen pkt[1]; if (aplen 4 || aplen 2 len) return -1; /* 长度自洽性检查防半包 */ const uint8_t *ctl pkt 2; if ((ctl[0] 0x01) 0) { return on_i_frame(c, pkt 6, aplen - 4); /* ASDU 从第 6 字节开始 */ } else if ((ctl[0] 0x03) 0x01) { return on_s_frame(c, ctl); /* S 帧只确认不推进业务 */ } else { return on_u_frame(c, ctl); /* STARTDT/TESTFR 等 */ } }帧类型的判定只看控制域第一个字节的最低位和次低位bit00是 I 帧bit01 且 bit10是 S 帧两位都是 1 就是 U 帧。这个判断写在收包入口处比在每个分支里重复解析要干净。on_i_frame里先推进rx_seq再把 ASDU 交给asdu_dispatch最后由point_db_update(ioa, value, quality)落到点表一次完整的“字节流到工程值”就走完了。写点表时记得带上品质位 QDS否则子站上送的“无效”“陈旧”标志会被吃掉界面上显示的是假数据。3. APCI 编解码三类帧的字节布局与序号推进链路层是整个主站里最容易翻车的地方也是调试中最值得先吃透的模块。它的活儿就三件拼 6 字节头、推进序号、按超时发 S 帧和 TESTFR。听起来简单但 N(S)/N(R) 是 15 位、要左移一位塞进控制域这个“左移”是无数人的血泪经验来源。3.1 I/S/U 帧的 6 字节头怎么拼先把三种帧的控制域摆在一起看对比着记比看标准原文快得多帧型字节0字节1字节2字节3说明I 帧N(S)1低 8 位N(S)7N(R)1低 8 位N(R)7最低位恒为 0S 帧0x010x00N(R)1低 8 位N(R)7只有接收序号U 帧0x030x00功能码0x00功能码决定动作I 帧的发送函数前 6 字节留给 APCIASDU 直接拷在后面。104 单帧 APDU 上限 255 字节所以 ASDU 最多 249 字节超了必须在应用层分包static int link_send_i(ictx_t *c, const uint8_t *asdu, int asdu_len) { uint8_t frame[6 249]; if (asdu_len 249) return -1; /* 超长交给上层分帧 */ frame[0] 0x68; frame[1] (uint8_t)(4 asdu_len); /* 控制域 ASDU */ uint16_t ns c-tx_seq 0x7FFF; /* 15 位超出回绕 */ frame[2] (uint8_t)((ns 1) 0xFE); /* 最低位清零 */ frame[3] (uint8_t)(ns 7); uint16_t nr c-rx_seq 0x7FFF; frame[4] (uint8_t)((nr 1) 0xFE); frame[5] (uint8_t)(nr 7); memcpy(frame 6, asdu, asdu_len); int n send(c-fd, frame, 6 asdu_len, 0); if (n 6 asdu_len) { c-tx_seq (c-tx_seq 1) 0x7FFF; /* 成功发出才推进 */ if (c-unacked 0) c-t1_deadline time(NULL) T1_SEC; c-unacked; } return n; /* 短写由上层缓冲重发 */ }参数说明c-tx_seq是本地发送计数器只在send完整返回后才1短写绝不能推进c-rx_seq是对方期望的下一个序号跟着收到的 I 帧走。T1_SEC从配置读默认 15 秒。注意send返回短写时不要把剩余字节直接丢掉工业现场链路抖动时短写并不罕见丢一个半帧子站就会判链路错。U 帧短得多功能码对齐就够#define U_STARTDT_ACT 0x07 #define U_STARTDT_CON 0x0B #define U_STOPDT_ACT 0x13 #define U_TESTFR_ACT 0x43 #define U_TESTFR_CON 0x83 static int link_send_u(ictx_t *c, uint8_t func) { uint8_t f[6] {0x68, 0x04, 0x03, 0x00, func, 0x00}; return send(c-fd, f, 6, 0); }这里有个细节U 帧不参与序号推进但它会占用 t1。发出TESTFR act之后同样要启动 t1超时没等到TESTFR con就判定链路失效、断开重连不要一直傻等。3.2 发送序号、接收序号与 k/w 流控序号校验是主站里最值得写严格的一段。收到 I 帧时先取出对方的 N(S) 和 N(R)N(S) 必须等于本地rx_seq不等就说明丢帧或乱序此时最稳妥的做法是断开重连并清零序号而不是“猜着补齐”。补序号在实验室能跑在现场只会把错误放大static int on_i_frame(ictx_t *c, const uint8_t *asdu, int asdu_len) { uint16_t ns (uint16_t)(((c-rx_buf[1] 8) | c-rx_buf[0]) 1); uint16_t nr (uint16_t)(((c-rx_buf[3] 8) | c-rx_buf[2]) 1); if (ns ! c-rx_seq) { log_warn(seq mismatch: expect %u got %u, c-rx_seq, ns); return -2; /* 上层收到 -2 直接重连 */ } c-rx_seq (c-rx_seq 1) 0x7FFF; c-unacked - (uint16_t)((c-tx_seq - nr) 0x7FFF); /* 对方已确认的帧数 */ if (c-unacked 0) c-t1_deadline 0; c-ack_pending; c-t2_deadline time(NULL) T2_SEC; /* 攒够 w 或 t2 到点再确认 */ if (c-ack_pending W_PARAM) { link_send_s(c); c-ack_pending 0; c-t2_deadline 0; } return asdu_dispatch(c, asdu, asdu_len); }这段里三个参数互相牵制k是unacked的上限达到之后必须停发新 I 帧w是ack_pending的阈值t2是“就算没攒够 w 也要确认”的保底时间。现场调试的顺序建议是先把w调到 1保证每收一帧就回 S 帧确认链路能跑通再逐步放大到 8观察子站有没有积压。总召唤时数据是成批上来的如果w设太大又赶上子站发送很快k窗口会被瞬间填满表现就是“召到一半不动了”——这个坑我在第 5 章单独展开。主循环里把三个定时器挂在一起判断逻辑比分散在各处清楚void link_tick(ictx_t *c, time_t now) { if (c-t1_deadline now c-t1_deadline) { log_error(t1 timeout, unacked%u, c-unacked); link_reset(c); /* 断链交给重连逻辑 */ return; } if (c-t2_deadline now c-t2_deadline) { link_send_s(c); c-ack_pending 0; c-t2_deadline 0; } if (c-started c-t3_deadline now c-t3_deadline) { link_send_u(c, U_TESTFR_ACT); /* 空闲保活 */ c-t3_deadline now T3_SEC; } }t3只在链路已启动started为真且长时间没有报文时才发。很多源码包把t3写成“定时无条件发 TESTFR”结果是空闲链路刚测通就被自己的测试帧刷屏可读性很差。t1超时直接link_reset而不是只重发一帧是因为 104 的 t1 超时通常意味着对端已经不响应了重发只会让序号更乱。4. ASDU 解析从类型标识到一次总召唤的收尾APCI 把字节安全送到应用层之后剩下的活全在 ASDU 上。ASDU 头固定 6 个字节后面跟着若干信息对象。主站 90% 的业务逻辑都在这一层什么类型、几个对象、传送原因是什么、要不要回应。4.1 ASDU 头六个字段与常见 TypeID 分发头部的六个字段里最容易写错的是传送原因 COT它占 2 个字节低 6 位才是原因值另外还压了 P/N 否定标志、T 测试标志和源发站地址typedef struct { uint8_t type; /* 类型标识 */ uint8_t sq; /* 可变结构限定词 bit70 逐个带地址1 连续地址 */ uint8_t num; /* 信息对象个数低 7 位 */ uint8_t cot; /* 传送原因低 6 位 */ uint8_t pn; /* 否定确认标志 */ uint8_t test; /* 测试标志 */ uint8_t oa; /* 源发站地址 */ uint16_t ca; /* 公共地址小端 */ } asdu_head_t; static int asdu_parse_head(const uint8_t *p, int len, asdu_head_t *h) { if (len 6) return -1; h-type p[0]; h-sq (p[1] 7) 0x01; h-num p[1] 0x7F; h-cot p[2] 0x3F; h-pn (p[2] 6) 0x01; h-test (p[2] 7) 0x01; h-oa p[3]; h-ca (uint16_t)(p[4] | (p[5] 8)); return 6; }ca和后面的信息对象地址 IOA 都是小端这一点在 x86 上直接memcpy到结构体有时能蒙对换到别的平台就全乱所以老老实实按位拼。h-pn 1表示否定确认这种报文不能推进任何状态机只记日志——这是新手最常忽略的一行判断。解析完头之后按 TypeID 分发主站侧真正要处理的类型并不多一份长度表基本能覆盖TypeID名称值字节数主站里的用途1M_SP_NA_11单点遥信3M_DP_NA_11双点遥信9M_ME_NA_13归一化遥测11M_ME_NB_13标度化遥测13M_ME_NC_15短浮点遥测45C_SC_NA_11单点遥控46C_DC_NA_11双点遥控100C_IC_NA_11总召唤有了这张表遍历信息对象就能写成统一循环不用为每个类型写一遍static int walk_objects(const uint8_t *p, int len, const asdu_head_t *h, void (*cb)(uint32_t ioa, const uint8_t *v, int vlen, void *u), void *user) { int off 6; /* 已跳过 ASDU 头 */ uint32_t ioa 0; int vlen type_value_len(h-type); if (vlen 0) return 0; for (int i 0; i h-num; i) { if (h-sq 0 || i 0) { /* SQ0 每个对象都带地址 */ if (off 3 len) return -1; ioa (uint32_t)p[off] | ((uint32_t)p[off 1] 8) | ((uint32_t)p[off 2] 16); off 3; } else { ioa 1; /* SQ1 时地址连续递增 */ } if (off vlen len) return -1; cb(ioa, p off, vlen, user); off vlen; } return off; }SQ位是这段的核心SQ0时每个信息对象前面都有 3 字节 IOASQ1时只有第一个对象带地址后面的按顺序1。总召唤回来的数据经常是SQ1的连续点如果把SQ1当成SQ0解析点位会整体错位界面上看到的就是“1 号点有值、2 号点是 0、3 号点又跳到 7 号”。type_value_len就是查上面那张表。4.2 总召唤的状态机与 COT 判定总召唤是主站启动后必做的第一件事也是最容易“召到一半停住”的地方。它的报文很短C_IC_NA_1 固定 10 字节 ASDUtypedef enum { GI_IDLE, GI_WAIT_CON, GI_WAIT_TERM } gi_state_t; static gi_state_t g_gi GI_IDLE; static time_t g_gi_deadline; static int g_gi_retry; int master_general_interrogation(ictx_t *c, uint16_t ca, int qoi) { uint8_t a[10]; a[0] 100; /* C_IC_NA_1 */ a[1] 0x01; /* SQ01 个对象 */ a[2] 0x06; /* COT6 激活 */ a[3] 0x00; /* 源发站地址 */ a[4] ca 0xFF; a[5] ca 8; a[6] a[7] a[8] 0x00; /* IOA0 */ a[9] (uint8_t)qoi; /* 20 站总召唤 */ if (link_send_i(c, a, 10) 0) return -1; g_gi GI_WAIT_CON; g_gi_deadline time(NULL) T1_SEC; g_gi_retry 0; return 0; }完整的帧在抓包里长这样N(S)0、N(R)0 时68 0E 00 00 00 00 64 01 06 00 01 00 00 00 00 14。第 2 字节0x0E就是4 10千万别写成 ASDU 长度。收报时按 COT 推状态机COT7是激活确认COT10是激活终止只有这两个才动状态中间夹着的大量COT20数据帧只更新点表不改状态static void on_gi_related(const asdu_head_t *h) { if (h-type ! 100 || h-pn) return; /* 否定确认不推进 */ if (g_gi GI_WAIT_CON h-cot 7) { g_gi GI_WAIT_TERM; /* 子站已接受等数据 */ g_gi_deadline time(NULL) GI_TIMEOUT; } else if (g_gi GI_WAIT_TERM h-cot 10) { g_gi GI_IDLE; g_gi_deadline 0; log_info(general interrogation done); } }GI_TIMEOUT要比t1大得多我一般给 60 秒因为一个站几百个点、子站又按优先级分批上送时一轮召唤跑几十秒很正常。等待期间如果t1到了还没收到COT7重发一次总召唤重发两次仍无响应就断链重连——不要在主站里无限重发会把子站的接收缓冲打满。4.3 遥控的 SELECT/EXECUTE 与确认等待遥控是主站里唯一会改变现场设备状态的操作逻辑必须收得比遥测紧。单点遥控 C_SC_NA_1 的 ASDU 是 11 字节IOA 3 字节 SCO 1 字节先选择后执行int master_single_command(ictx_t *c, uint16_t ca, uint32_t ioa, int on, int select) { uint8_t a[11]; a[0] 45; /* C_SC_NA_1 */ a[1] 0x01; a[2] 0x06; /* 选择和执行都是 COT6 */ a[3] 0x00; a[4] ca 0xFF; a[5] ca 8; a[6] ioa 0xFF; a[7] (ioa 8) 0xFF; a[8] (ioa 16) 0xFF; uint8_t sco 0; if (select) sco | 0x80; /* bit7S/E1执行 */ if (on) sco | 0x01; /* bit0SCS1合 */ a[9] sco; a[10] 0x00; return link_send_i(c, a, 11); }最容易踩的一条选择和执行的传送原因都是COT6靠 SCO 的 bit7 区分报文头里没有任何别的区别。如果主站误把执行的 S/E 位写成 0子站会认为这是一次新的“选择”返回COT7之后设备毫无动作日志上看一切正常。状态推进也要卡死顺序选择发出后必须等到COT7且pn0才能发执行执行之后等COT10才算真正成功。中间任何一步收到pn1立刻置该点“遥控失败”并结束流程不要重试——现场设备拒动通常有联锁条件重试只会重复触发。5. 避坑与排查连不上、召唤不回、序号错乱怎么定位这类问题在现场的复现率极高且大多不是协议理解错而是某个步骤被省略了。下面五条是我在实际联调里反复遇到的按“现象 → 原因 → 解决”记下来出问题时对着看能省不少时间。第一条TCP 连上了但一条 I 帧都收不到。现象是 socket 状态正常、-v里没有任何68 xx报文偶尔只看到68 04 0B 00 00 00。原因是主站没有发STARTDT act104 规定 TCP 建连只代表传输层通了必须由主站发 U 帧STARTDT act功能码0x07收到STARTDT con0x0B之后子站才开始上送数据。解决是把“建链成功”和“链路启动成功”当成两个状态TCP 连上只是第一个收到0x0B才把started置 1之后才允许发总召唤。第二条链路空闲一分钟左右必断。现象是刚测通时一切正常挂在那里不管一分钟后再看就断线重连了。原因是没有实现t3空闲保活中间的网络设备把长时间无数据的连接回收了。解决是在link_tick里判断“started为真且距上一帧超过t3”时发TESTFR act并给这个 U 帧同样挂t1超时。注意t3的计时基准是“最近一次收到或发出的任意帧”不是只看发送。第三条总召唤召到十几个点就停住之后再无数据。现象非常有辨识度前 12 条左右的 I 帧正常解析之后彻底安静过一会儿t1超时断链。原因正是k窗口满了——主站收帧之后没有及时回 S 帧ack_pending一直涨子站那边未确认帧数到 12 就停止发送了。排查方法是把w临时改成 1如果问题消失就能确认是确认帧发得太少。解决是把w调回 8 的同时把t2也实现上别只靠“攒够 w”这一个条件。第四条换了台子站就断链抓包显示序号对不上。现象是某几台设备正常、某几台一直重连日志里seq mismatch刷屏。原因通常是断链重连后tx_seq/rx_seq没有清零还接着上次的值发另一个常见来源是 N(S)/N(R) 拼装时少左移一位。解决是把link_reset写成“关闭 fd、清零所有序号、清空半包缓存、状态回 IDLE”的完整复位任何一条都不能漏。每次重连都是一次全新会话序号从 0 开始。第五条遥控合闸了但界面上点位还是分位。现象是现场设备动作正常主站界面不刷新或者刷新成一秒后又跳回去。原因是遥控成功后没有更新本地点表或者用COT7的确认帧直接改了点位状态。COT7只是“子站收到命令”真正的状态变化要靠子站随后上送的单点遥信COT3或COT11。解决是遥控流程结束后只更新“操作结果”字段点位实时值一律等遥信上送两条链路各管各的。6. 二次开发加一个新类型标识与点表热更新源码能跑通之后最常见的二次开发需求是“子站上送了一个表里没有的类型”。比如对方用了带时标的 M_ME_TF_1TypeID 36或者厂家私有类型。整个改造链条其实只有三处改完就能出数据改动位置具体动作备注asdu_codec.c长度表登记 TypeID 与值字节数36 对应 12 字节asdu.c分发分支增加解析回调与品质位提取复用walk_objectspoints.ini补上 IOA 到点名的映射不改代码也能加带时标的类型多出来的 7 个字节是 CP56Time2a解码时要注意“分、时、日、月、年”这几个字节的最高位是标志位取值之前必须先 0x7F否则月份会变成 200 多static void decode_cp56(const uint8_t *p, struct tm *tm, int *ms) { *ms p[0] | (p[1] 8); /* 毫秒0~59999 */ tm-tm_min p[2] 0x3F; /* 低 6 位是分钟 */ tm-tm_hour p[3] 0x1F; /* 低 5 位是小时 */ tm-tm_mday p[4] 0x1F; tm-tm_mon (p[5] 0x0F) - 1; /* 从 1 开始tm 从 0 开始 */ tm-tm_year (p[6] 0x7F) 100; /* 2000 年起算 */ }点表热更新是另一个高频需求现场加点不可能每次都重启主站。做法是把points.ini的修改时间记录下来主循环每秒stat一次变了就重新加载到一张新表再用指针原子切换。切换时要注意正在处理的那一帧还持有旧表指针所以旧表不能立刻释放我一般延迟两个 tick 再free。这个“后悔药”机制看着多余真上线之后基本每周都会用上。验证环节建议先别接真设备用脚本起一个最小从站对着打。收发顺序按“等STARTDT act→ 回con→ 等总召唤 → 回COT7→ 上送COT20→ 回COT10”跑一遍主站的状态机是否写对十分钟就能验完比在现场抓包猜要快得多。从那以后我每次改 ASDU 层都会强制先用这个模拟从站跑一遍全流程再上设备宁可多花这十分钟也不要在现场对着t1 timeout反复怀疑人生。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?