简介:面向电力自动化与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.c | I/S/U 帧编解码、6 字节头拼装 | 极低 |
src/link/link_fsm.c | STARTDT/TESTFR、序号推进、k/w 流控 | 低 |
src/asdu/asdu.c | ASDU 头解析、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_TYPE=Release 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 + 2:
static 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 等 */ } }帧类型的判定只看控制域第一个字节的最低位和次低位:bit0=0是 I 帧,bit0=1 且 bit1=0是 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)>>7 | N(R)<<1低 8 位 | N(R)>>7 | 最低位恒为 0 |
| S 帧 | 0x01 | 0x00 | N(R)<<1低 8 位 | N(R)>>7 | 只有接收序号 |
| U 帧 | 0x03 | 0x00 | 功能码 | 0x00 | 功能码决定动作 |
I 帧的发送函数,前 6 字节留给 APCI,ASDU 直接拷在后面。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; /* 可变结构限定词 bit7:0 逐个带地址,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 | 名称 | 值字节数 | 主站里的用途 |
|---|---|---|---|
| 1 | M_SP_NA_1 | 1 | 单点遥信 |
| 3 | M_DP_NA_1 | 1 | 双点遥信 |
| 9 | M_ME_NA_1 | 3 | 归一化遥测 |
| 11 | M_ME_NB_1 | 3 | 标度化遥测 |
| 13 | M_ME_NC_1 | 5 | 短浮点遥测 |
| 45 | C_SC_NA_1 | 1 | 单点遥控 |
| 46 | C_DC_NA_1 | 1 | 双点遥控 |
| 100 | C_IC_NA_1 | 1 | 总召唤 |
有了这张表,遍历信息对象就能写成统一循环,不用为每个类型写一遍:
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) { /* SQ=0 每个对象都带地址 */ 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; /* SQ=1 时地址连续递增 */ } if (off + vlen > len) return -1; cb(ioa, p + off, vlen, user); off += vlen; } return off; }SQ位是这段的核心:SQ=0时每个信息对象前面都有 3 字节 IOA,SQ=1时只有第一个对象带地址,后面的按顺序+1。总召唤回来的数据经常是SQ=1的连续点,如果把SQ=1当成SQ=0解析,点位会整体错位,界面上看到的就是“1 号点有值、2 号点是 0、3 号点又跳到 7 号”。type_value_len就是查上面那张表。
4.2 总召唤的状态机与 COT 判定
总召唤是主站启动后必做的第一件事,也是最容易“召到一半停住”的地方。它的报文很短,C_IC_NA_1 固定 10 字节 ASDU:
typedef 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; /* SQ=0,1 个对象 */ a[2] = 0x06; /* COT=6 激活 */ a[3] = 0x00; /* 源发站地址 */ a[4] = ca & 0xFF; a[5] = ca >> 8; a[6] = a[7] = a[8] = 0x00; /* IOA=0 */ 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 推状态机,COT=7是激活确认,COT=10是激活终止,只有这两个才动状态;中间夹着的大量COT=20数据帧只更新点表,不改状态:
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到了还没收到COT=7,重发一次总召唤,重发两次仍无响应就断链重连——不要在主站里无限重发,会把子站的接收缓冲打满。
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; /* 选择和执行都是 COT=6 */ 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; /* bit7:S/E,1=执行 */ if (on) sco |= 0x01; /* bit0:SCS,1=合 */ a[9] = sco; a[10] = 0x00; return link_send_i(c, a, 11); }最容易踩的一条:选择和执行的传送原因都是COT=6,靠 SCO 的 bit7 区分,报文头里没有任何别的区别。如果主站误把执行的 S/E 位写成 0,子站会认为这是一次新的“选择”,返回COT=7之后设备毫无动作,日志上看一切正常。
状态推进也要卡死顺序:选择发出后必须等到COT=7且pn=0,才能发执行;执行之后等COT=10才算真正成功。中间任何一步收到pn=1,立刻置该点“遥控失败”并结束流程,不要重试——现场设备拒动通常有联锁条件,重试只会重复触发。
5. 避坑与排查:连不上、召唤不回、序号错乱怎么定位
这类问题在现场的复现率极高,且大多不是协议理解错,而是某个步骤被省略了。下面五条是我在实际联调里反复遇到的,按“现象 → 原因 → 解决”记下来,出问题时对着看能省不少时间。
第一条:TCP 连上了,但一条 I 帧都收不到。现象是 socket 状态正常、-v里没有任何68 xx报文,偶尔只看到68 04 0B 00 00 00。原因是主站没有发STARTDT act,104 规定 TCP 建连只代表传输层通了,必须由主站发 U 帧STARTDT act(功能码0x07),收到STARTDT con(0x0B)之后子站才开始上送数据。解决是把“建链成功”和“链路启动成功”当成两个状态: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 开始。
第五条:遥控合闸了,但界面上点位还是分位。现象是现场设备动作正常,主站界面不刷新,或者刷新成一秒后又跳回去。原因是遥控成功后没有更新本地点表,或者用COT=7的确认帧直接改了点位状态。COT=7只是“子站收到命令”,真正的状态变化要靠子站随后上送的单点遥信(COT=3或COT=11)。解决是遥控流程结束后只更新“操作结果”字段,点位实时值一律等遥信上送,两条链路各管各的。
6. 二次开发:加一个新类型标识与点表热更新
源码能跑通之后,最常见的二次开发需求是“子站上送了一个表里没有的类型”。比如对方用了带时标的 M_ME_TF_1(TypeID 36),或者厂家私有类型。整个改造链条其实只有三处,改完就能出数据:
| 改动位置 | 具体动作 | 备注 |
|---|---|---|
asdu_codec.c长度表 | 登记 TypeID 与值字节数 | 36 对应 12 字节 |
asdu.c分发分支 | 增加解析回调与品质位提取 | 复用walk_objects |
points.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→ 等总召唤 → 回COT=7→ 上送COT=20→ 回COT=10”跑一遍,主站的状态机是否写对,十分钟就能验完,比在现场抓包猜要快得多。从那以后我每次改 ASDU 层,都会强制先用这个模拟从站跑一遍全流程再上设备,宁可多花这十分钟,也不要在现场对着t1 timeout反复怀疑人生。希望帮到你。
本文还有配套的精品资源,点击获取