资讯动态

IEC60870-5-104 C++源码实战:状态机与报文编解码全解析

发布时间:2026/10/14 11:35:56 来源:尧图企业网站定制
简介面向电力自动化远动通信的IEC60870-5-104 C源码包完整实现了基于TCP/IP的IEC104规约栈适合需要开发或维护远动通信客户端/服务器、进行RTU联调的嵌入式或上位机工程师。压缩包共56个文件约5.76MB包含C源文件与头文件、可直接运行的exe及配套dll、ini配置、readme说明文档等源码目录对应网络接口、协议栈编解码、RTU模拟、事件与错误处理等模块便于快速定位核心逻辑。目前已有852人学习浏览。配合readme中的编译运行说明可构建基本IEC104通信节点用于功能验证实际项目中还可参考其报文封装流程扩展多RTU地址管理与状态维护Qt界面与示例配置也为理解控制域、地址域、校验域交互提供了调试入口。开发者对照标准逐段研读代码可显著降低规约落地门槛并为自身项目复用这套通信框架这份源码对电力自动化初学者和协议二次开发者都较友好。1. IEC60870-5-104 C 源码拿到手先别盯着报文解析把状态机摆平再说很多人拿到 IEC60870-5-104 C 源码第一反应是找 ASDU 解析函数想赶紧把遥测、遥信、遥控的报文拆开。这个方向没错但真正让项目在现场翻车的从来不是解析本身而是连接状态机、序号窗口和 t0~t3 这套看不见的时序逻辑。IEC60870-5-104 是电力远动里主站与厂站之间的 TCP 通信规约默认用 2404 端口凡是做变电站接入、调度数据网、光伏储能监控系统接入的开发者最终都要和它打交道。这篇我打算把一套 C 实现拆开来讲先立起协议骨架再给出可编译的最小工程、报文的字节级解析与组帧、状态机和超时的落地写法最后是联调里真实踩过的几个坑。适合两种人刚接手 104 源码、不知道从哪读起的还有准备自己写一套 C 库怕在时序细节上栽跟头的。读完你会得到一个能跑的最小协议栈以及一套验证它的方法。2. 协议骨架2404 端口、APDU 与一份 C 源码的文件地图2.1 先立模型为什么 104 用 TCP以及 t0~t3 是干什么的IEC60870-5-104 是 IEC60870-5-101 的网络化变体101 走串口链路层有地址靠轮询维持主从关系104 走以太网 TCP链路层没有地址所有寻址都放在 ASDU 的公共地址里并且双方都能主动发数据也就是所谓“平衡传输”。这意味着你的 C 代码不能像串口程序那样只做从机应答而是随时要处理对端主动上送的报文。因为跑在 TCP 上很多人以为链路可靠就不用管超时这是最大的误解。104 在应用层自己维护了一套确认与心跳机制和 TCP 的确认是两层事。TCP 只保证字节不丢不乱不保证厂站应用层已经处理了你的总召唤。104 标准给这套机制定了四个超时参数源码里通常叫 t0、t1、t2、t3参数常见配置作用关键约束t010sTCP 建链等待连接迟迟建立不了时判定失败t115s发送 APDU 后等待确认超时判定链路故障并断链t210s收到 I 帧后回 S 帧的确认延迟必须小于 t1t320s无数据时发送 TESTFR 的心跳周期对端不回则断链这四个值在不同厂家的设备里不一定一致你拿到源码后第一件事就是把它们找出来确认默认值是否满足 t2 小于 t1。我在现场见过把 t2 配得比 t1 还长的结果链路每隔十几秒就断一次画面数据一直闪后来抓包才发现是配置问题。这在 104 联调里属于典型的“看着像网络问题其实是规约参数问题”。104 没有链路层地址意味着一个 C 实现里不该存在“串口地址”这样的配置项同一套协议栈可以同时挂多个厂站每个厂站只靠公共地址区分。这个设计影响源码的接口形态你看到的 Session 对象通常一个厂站一个实例厂站配置里只填 IP、端口、公共地址和四个超时参数。端口 2404 是 IEC 60870-5-104 的默认端口但不要写死在代码里现场有些调度主站用 2405 或其它端口映射配置项化最省事。2.2 拿到一份 C 源码先看哪几个文件一套常见的 C 104 源码不管用什么网络库分层基本逃不出下面这个结构src/ net/TcpSession.cpp // socket 生命周期、收发字节流 app/SessionManager.cpp // 会话状态机、总召唤与遥控调度 codec/FrameCodec.cpp // APCI 层的 U/I/S 帧编解码 codec/ASDUCodec.cpp // ASDU 编解码信息体按类型展开 timeout/TimerWheel.cpp // t0~t3 超时与心跳调度我一般会按这个顺序读先看 SessionManager再看 TimerWheel然后看 FrameCodec最后才碰 ASDUCodec。原因很简单ASDU 解析是纯函数输入一段字节输出几个结构体不存在状态看一遍就能理解真正复杂的是 SessionManager它决定何时允许发总召唤、何时必须回 S 帧、收到什么帧才迁移状态。很多源码把 ASDU 编解码做得非常工整但状态机写得绕这种包反而难用。判断一份源码质量有个取巧的方法看它怎么处理“收到 STARTDT con 之前就有数据要上送”的情况。弱实现会直接丢弃或报错强实现会放到发送队列等进入 STARTED 状态后自动补发。这个细节决定了你在现场会不会遇到“总召唤没响应”的玄学问题。2.3 最小可编译工程与一个建链例程把源码包拿回来后先在本地编译跑通一个最小示例。以 CMake 为例常见的最小构建文件长这样cmake_minimum_required(VERSION 3.16) project(iec104_core CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(iec104_core src/net/TcpSession.cpp src/app/SessionManager.cpp src/codec/FrameCodec.cpp src/codec/ASDUCodec.cpp src/timeout/TimerWheel.cpp ) target_include_directories(iec104_core PUBLIC include) add_executable(example examples/main.cpp) target_link_libraries(example iec104_core)构建命令mkdir -p build cd build cmake -DCMAKE_BUILD_TYPERelease .. make -j4 ./example这个构建里Release模式比Debug模式重要104 的收发是高频率小报文Debug 下的 STL 迭代器检查会让延迟明显变大模拟高频遥测时容易误判成性能问题。接下来是最小例程我习惯把它写成阻塞式逻辑一目了然#include iec104/Session.h #include chrono #include thread int main() { Iec104Session session; session.configure( 127.0.0.1, // 对端地址现场换成厂站IP 2404, // 104 默认 TCP 端口 TimeoutSet{10, 15, 10, 20} // t0, t1, t2, t3 ); session.connect(); // TCP 建链等待 t0 内完成 session.startDataTransfer(); // 发 STARTDT act等 con session.sendInterrogation(0x0001, 0); // 公共地址1总召唤 IOA0 std::this_thread::sleep_for(std::chrono::seconds(60)); return 0; }configure里的四个参数对应 2.1 的超时表顺序别记错我建议在接口定义里直接写成带名字的字段而不是裸数字。startDataTransfer是关键一步它发送的是 U 帧STARTDT act只有收到对端的STARTDT con后会话才进入可收发数据的状态总召唤必须放在这一句之后。sendInterrogation的第三个参数是总召唤的信息体地址标准做法填 0有些设备填错地址会不回包这是最早踩坑的点之一。提示不要用connect的返回值判断链路可用。TCP 连接建立成功只代表对端 socket 存在不代表 104 会话已经 ACTIVE必须等到STARTDT con。另外超时参数最好定义成一个结构体而不是裸数字防止调用点顺序写错。我在维护的代码里见过 t0 和 t1 反了导致现场反复断线的。3. 报文编解码在 C 里把 APDU 切成结构体再拼回遥控命令3.1 控制域三种帧的字节判定与 S 帧的构造104 的 APDU 由 APCI 和可选的 ASDU 组成APCI 前两个字节固定是 0x68 和长度长度字段等于控制域 4 字节加上 ASDU 字节数U 帧和 S 帧不含 ASDU所以长度固定是 4。控制域四个字节决定了这是 I 帧、S 帧还是 U 帧判定规则看第一个字节的低两位帧类型首字节低两位控制域示例用途U 帧1107 00 00 00链路控制STARTDT/STOPDT/TESTFRS 帧0101 00 0A 00确认收到的 I 帧序号I 帧0000 00 0A 00应用数据如遥测、遥控、总召唤注意 U 帧和 S 帧的二进制写法很多人会记混。STARTDT act是 07 00 00 00STARTDT con是 0B 00 00 00TESTFR act是 43 00 00 00。先记住 07 和 0B 这两个联调时抓包看到它们就知道握手走到了哪一步。帧类型的判定代码很简单enum class FrameKind { U, S, I }; FrameKind classifyFrame(const uint8_t* ctrl) { uint8_t first ctrl[0]; if ((first 0x02) ! 0) return FrameKind::U; // bit11 是U帧 if ((first 0x01) ! 0) return FrameKind::S; // bit01 是S帧 return FrameKind::I; // 否则是I帧 }这里0x02和0x01两个掩码是判定核心U 帧的 bit1 置位S 帧只有 bit0 置位I 帧两者都为 0。注意 I 帧的序号并不是从 1 递增而是每次加 2第一帧序号 0然后 2、4、6……所以 I 帧控制域首字节的 bit0 永远为 0这也是它和 S 帧区分的基础。S 帧的构造是所有实现里最容易写错的地方。S 帧要回的是“你发过来的 I 帧序号是多少”控制域四字节里第二个字节是接收序号的高位第一个字节固定 0x01。比如收到对端发的 I 帧序号 0下一帧序号 2 还没收到那就回01 00 00 00如果已经连续收到序号 0、2、4接收序号就是 6回01 00 03 00。这里接收序号同样要加 2不是加 1。3.2 解析遥测 ASDU从 NVA 原始值到带量程的工程值ASDU 的头部固定是类型标识、传送原因、公共地址三个部分。传送原因是一个字节加一个 bit低 6 位是原因值bit6 是 S 或 N 标志bit7 是 P/N 标志新手直接读整个字节会解析出 132 这种奇怪的传送原因实际值只是 4。公共地址解析时记得是两字节小端有的设备配成 0x0001有的配成 0x000A解析错了整包数据都会被丢进“公共地址不匹配”的告警里。以最常见的遥测类型标识 9M_ME_NA_1归一化遥测为例信息体固定 5 字节2 字节信息体地址、2 字节归一化值 NVA、1 字节品质描述符 QDS。解析代码如下double decodeNva(const uint8_t* p, double fullScale) { int16_t raw static_castint16_t(p[0] | (p[1] 8)); // NVA 范围映射到 -1.0 ~ 1.0再乘以量程得到工程值 return (static_castdouble(raw) / 32768.0) * fullScale; } void parseMeas(const uint8_t* asdu, size_t len, double scale, double fullScale) { uint8_t typeId asdu[0]; // 9 归一化遥测 uint8_t cot asdu[1] 0x3F; // 低6位才是传送原因 uint16_t ca asdu[2] | (asdu[3] 8); size_t off 4; // 跳过ASDU头进入信息体 while (off 5 len) { uint16_t ioa asdu[off] | (asdu[off 1] 8); double value decodeNva(asdu off 2, fullScale); uint8_t qds asdu[off 4]; if ((qds 0x01) 0) { // bit00 表示数据有效可以写入点表 printf(IOA%u val%.2f scale%.2f\n, ioa, value, scale); } off 5; } }参数fullScale是遥测的量程比如电流互感器二次侧满量程是 5A电压是 100V具体数值从点表配置里来解析代码本身不知道。NVA 的 32768 这个分母是标准定的-32768 到 32767 对应 -1.0 到 1.0不要自己改成 1000 之类的值改了之后所有遥测都会差一个固定倍数。cot 0x3F这个掩码一定要加我见过无数解析代码直接读整个字节然后把周期上送cot1当成突发cot3处理导致画面数据闪个不停。还有一点不要假设一个 ASDU 里的信息体地址是连续的。现场设备的点表经常跳号或者把两个不相邻的点塞进同一帧所以代码里必须按ioa查表而不是顺序取点。3.3 拼遥控报文C_SC_NA_1 的选择与执行字节如何填遥控是 104 里风险最高的操作拼帧错了轻则拒动重则误动。类型标识 45C_SC_NA_1单点遥控的信息体只有一个字节但这一字节同时携带命令值、限定词和选择/执行位。SCS 是 bit0QU 是 bit4~bit6S/E 是 bit7置 0 表示执行置 1 表示选择。C 拼帧时这样填uint8_t buildCommandByte(bool select, bool close, uint8_t qu) { // closetrue 表示合闸false 表示分闸 uint8_t b (close ? 0x01 : 0x00); b | (qu 0x07) 4; b | (select ? 0x80 : 0x00); return b; } void assembleSelectAndExecute(std::vectoruint8_t apdu, uint16_t ca, uint16_t ioa, bool close) { apdu.push_back(45); // C_SC_NA_1 apdu.push_back(0x06); // cot6 激活 apdu.push_back(ca 0xFF); apdu.push_back(ca 8); // 第一步选择 apdu.push_back(ioa 0xFF); apdu.push_back(ioa 8); apdu.push_back(buildCommandByte(true, close, 0)); // 第二步执行通常先等对端回 cot7 再发 // 这里为演示直接拼接实际代码要拆成两个发送间隔 apdu.push_back(45); apdu.push_back(0x06); apdu.push_back(ca 0xFF); apdu.push_back(ca 8); apdu.push_back(ioa 0xFF); apdu.push_back(ioa 8); apdu.push_back(buildCommandByte(false, close, 0)); }选择和执行必须分两步走这是 104 的硬性约定。第一步发S/E1对端确认后才发第二步S/E0很多设备直接收到执行命令会回 cot9 拒绝。QU限定词默认填 0 就可以只有需要“短脉冲”或“长脉冲”输出时才改。这只把 ASDU 拼了起来外面还要套 68 起始符、长度和控制域的 I 帧序号实际生产代码不要像注释里那样把两步塞进同一帧现场会出现选择还没确认就执行的竞态。提示遥控报文的公共地址、IOA 都要和点表严格一致任何一位错了设备端要么回 9 拒绝要么直接忽略。抓包时看到 45 类型标识出现而遥信不翻转先核对 IOA。4. 让会话活下来连接状态机、序号窗口与 t2/t3 调度4.1 从 TCP 连接到 STARTED状态机的最小实现104 的会话生命周期不是简单的“连接成功就可以发数据”中间隔着一个 STARTDT 握手。我习惯把状态拆成五档IDLE初始、TCP_CONNECTEDsocket 已通、ACTIVESTARTDT 已发出未确认或已停止、STARTEDSTARTDT con 收到可收发 I 帧、STOPPED收到 STOPDT 后的停止态。状态机核心就一个函数收到什么帧迁到什么状态enum class St { IDLE, TCP_CONNECTED, ACTIVE, STARTED, STOPPED }; void onControlFrame(St state, uint8_t c1) { if (state St::TCP_CONNECTED c1 0x0B) { // 收到 STARTDT con正式进入可收发状态 state St::ACTIVE; } else if (state St::ACTIVE c1 0x0B) { // 某些设备会重复回 con保持 ACTIVE 即可 state St::ACTIVE; } else if (c1 0x23) { // STOPDT con回到 ACTIVE 但不可发数据 state St::ACTIVE; } else { // 其他控制帧按原状态处理不迁移 } }这里的0x0B是 STARTDT con 的控制域首字节0x23是 STOPDT con。TCP_CONNECTED到ACTIVE的迁移必须等到 con不能收到 act 就迁移。很多 C 实现把“发送 STARTDT act”和“状态置为 ACTIVE”写在一个函数里这是错的act 只是请求对端可能回 con 也可能超时正确做法是发完 act 后挂一个 t1 超时收到了 con 再迁移。注意状态命名里的一个细节我把 STARTED 拆成了“ACTIVE”和“STARTED”两档。有些源码把收到 STARTDT con 后的状态直接叫 ACTIVE那没问题但你要保持一致。我这样分是为了处理 STOPDT对端可以随时发 STOPDT act 要求暂停数据这时候如果只是把状态改回 ACTIVE你还能回 S 帧确认但不能主动发 I 帧一档状态区分不开。在收到 con 的地方加一行日志打印帧类型与秒级时间戳是排查握手最直接的手段。4.2 k12、w8I 帧序号与 S 帧回包的滑动窗口104 的流量控制模仿了 HDLC 的窗口机制发送方最多可连续发送 k 个 I 帧而不用确认接收方连续收到 w 个 I 帧后必须回一个 S 帧。标准建议 k12、w8写在源码里就是两个常量。窗口逻辑的全部代码加在一起不过二十行struct SendWindow { uint16_t lastSent 0; // 最后发送的序号已乘以2 uint16_t lastAck 0; // 对端最后确认到的序号 }; struct RecvWindow { uint16_t lastRecv 0; // 最后收到的合法序号 int unacked 0; // 连续未回S帧的I帧计数 }; bool canSend(const SendWindow sw) { // 未确认帧数 (lastSent - lastAck) / 2不能超过 k12 return (sw.lastSent - sw.lastAck) / 2 12; } void noteReceived(RecvWindow rw, uint16_t seq) { if (seq rw.lastRecv 2) { rw.lastRecv seq; rw.unacked; } } bool shouldSendSFrame(const RecvWindow rw) { return rw.unacked 8; // w8 到达阈值立即回S帧 }canSend里面除以 2 是因为序号按 2 递增差值算出来是实际帧数的两倍。注意lastSent - lastAck是在 16 位无符号下做的减法序号回绕到 65535 再归零也能正确计算这是故意为之别改成有符号。noteReceived里只接受“等于上次序号加 2”的帧重复帧、乱序帧都直接丢弃不改变状态对端如果发现你丢帧会自己重发你不要在接收侧做复杂重排。重复帧和乱序帧的忽略是实现正确性的关键。104 的序号重传机制是发送侧驱动发送方发现未确认帧数超限或者 t1 超时会从上次确认点重发。接收方只需要记住 lastRecv 即可不需要像 TCP 那样维护接收缓冲重建顺序。如果你在接收侧做了重排反而可能把本来就合法的序号序列打乱产生重复上送。S 帧发送时机有两条一是unacked 8时立刻回二是 t2 超时触发补发。第二条经常被漏掉导致高频率但每帧信息体很少的链路看起来一直有数据却没有 S 帧回给对端对端到 t1 就断链。发送侧要同时记录 lastSent 与 lastAck未确认帧数超过 k 时先停发而不是丢弃。4.3 t3 心跳与 TESTFR无数据时怎么保活数据不是每时每刻都在跑负荷平稳时可能几十秒没有一条报文。这时候 TCP 连接虽然还活着但 104 应用层的 t1 超时逻辑会认为链路死了——因为 t1 只确认你发的报文你根本没发报文当然也没确认。解决办法是 t3 周期到了就发一个TESTFR act对端回TESTFR con链路就保住了。调度代码一般长这样void onTimer(TimerType type) { switch (type) { case T2: // 收到了I帧但没到w8t2到点也要补回S帧 if (recvWindow_.unacked 0) sendSFrame(recvWindow_.lastRecv); break; case T3: // 空闲期心跳 sendTestFrame(); // TESTFR act armTimer(T1); // 等 con超时则断链 break; case T1: // 发送后没等到确认 closeConnection(); break; } }T3 这个定时器有个容易忽略的复位条件任何有效数据帧到达或发送后都要把 T3 计时清零而不是只在 T3 触发时看一眼。否则高频数据链路上 T3 也会到点白白发一堆 TESTFR。T2 的复位则相反只要发过 S 帧就清零只有超过 t2 秒一直没回才补。TESTFR 的 con 是 83 00 00 00收到它时要把 T1 定时器撤掉。如果连续几个 TESTFR 都没收到 con不要一直发直接断链重连让对端设备重新走 STARTDT 流程。现场遇到过对端嵌入式设备死锁TCP 不关但应用层不回 TESTFR不发心跳的从站实现会把这种假死当成正常画面数据就停在最后时刻不变了。顺便说一句TCP 自带的 keepalive 和 104 的 TESTFR 不是一回事。TCP keepalive 默认以小时计且只在 IP 层探测对端主机是否可达104 的 TESTFR 探测的是对端应用层是否正常。所以不要靠 TCP keepalive 替代 t3 逻辑否则对端应用死锁不关 socket 时你的主站会一直以为链路正常。5. IEC104 C 实现避坑四个现场翻车点与排查步骤5.1 现象链路只建到 TCP主站一直显示链路中断现象抓包只见 TCP 三次握手没有 104 报文或者 TCP 反复重连。原因通常有两个一是只调用了 socket 连接就去读数据压根没发 STARTDT act二是发过 STARTDT act但把等待 con 的超时设成了 t010 秒太短对端稍慢就断。解决先确认日志里有没有打出 con 的接收记录没有就查代码里startDataTransfer是否在connect之后被调用以及状态机是否在TCP_CONNECTED等待 con。排查命令tcpdump -i any -s 0 -w 104_handshake.pcap port 2404打开抓包文件后看有没有07 00 00 00和0b 00 00 00这一对。只有发送没有回应是对端没进 104 处理流程两个都有但主站还报中断看时序间隔有没有超过 t1。另一个隐蔽点是有的 C 实现里收到 con 的分支没有把等待定时器撤掉t1 到点照常断链排查状态机里 armTimer 和 cancelTimer 是否配对。5.2 现象总召唤没有 cot10 收尾画面数据出不来现象收到类型标识 100、cot7 的激活确认然后也收到了一些遥测遥信但最后没有 cot10 的激活终止画面一直处于“召唤中”。原因总召唤要求整个响应有一个明确的收尾帧厂站把所有数据发完后回 cot10 表示结束。你的代码如果收到 cot7 就开始处理零星数据却把 socket 读取挂在了某个只会读一包的逻辑里后面的 cot10 就一直没被读到。解决总召唤之后要持续接收直到出现 cot10并且在此期间对每个 I 帧回 S 帧收到 cot10 再恢复常规上送流程。接收循环的骨架大致是bool end false; while (!end !timeout) { auto apdu socket.read(); if (isIFrame(apdu) asdu.cot 10) { end true; // 总召唤正式结束 } // 继续处理其他 I 帧并回 S 帧 }还有一个隐蔽问题总召唤的信息体地址填错比如填成了某个点号而不是 0厂站会回拒绝或不回。公共地址也必须匹配收到 cot7 的设备不一定就是你要召唤的设备核对 ASDU 的公共地址字段。5.3 现象遥控只执行不选择或是 cot9 被拒现象遥控命令发出现场开关不动抓包看到 cot9 否定确认。原因排名第一的是没做选择步骤直接发了S/E0的执行帧第二是选择帧的 IOA 或者公共地址和点表不一致第三是双点遥控填了单点的字节布局。解决严格执行“选择 cot6 S/E1 → 等待 cot7 → 执行 cot6 S/E0 → 等待返送或 cot9”的时序。现场调试时我习惯在日志里打印选择帧和执行帧各自的 IOA 与字节方便对照点表。别相信“我们设备不需要选择”这种说法即使单个设备支持直送做主站接入时也要兼容所有厂站直送在另一家设备上就会拒动。选择与执行之间还应当加一个 30 到 60 秒的监视窗口超时未确认要复位命令状态防止下次操作读到脏状态。5.4 现象高频遥测一上来帧延迟就暴涨现象平时几十毫秒的收发一开高频遥测上送延迟涨到几百毫秒甚至触发 t1 超时。原因最常见的是每个遥测点都单独封装成一个 ASDU 直接发网络层全是 4 字节控制域加 11 字节 ASDU 的小包加上代码里没有做发送窗口控制瞬间发出几十个 I 帧把对端打懵。解决把同一周期的多个信息体合并进同一个 ASDU一个 ASDU 塞几十个点控制域的开销分摊到每个点就很小了。第二个处理是发送侧用 4.2 的canSend做限流未确认帧数到 12 就不要再发。内存上还有个容易被忽略的点接收侧如果每帧都 new 一个缓冲区高频率下碎片和分配开销都很大提前开好环形缓冲池。发送窗口满时优先丢弃最新的变化遥测而不是退出线程等对端确认后再补发。6. 进阶自测用一段 Python 模拟厂站把你写好的 C 协议栈验证到字节级6.1 模拟厂站的 20 行脚本不接真实设备也能把 C 协议栈验证到字节级。用 Python 写一个最小厂站模拟端监听 2404完成 STARTDT 握手然后回一帧遥测和一个总召唤结束帧import socket, time srv socket.socket() srv.bind((127.0.0.1, 2404)) srv.listen(1) conn, _ srv.accept() conn.recv(1024) # 收到 STARTDT act 0x68 04 07 00 00 00 conn.sendall(bytes.fromhex(68 04 0b 00 00 00)) # 回 STARTDT con time.sleep(0.2) data conn.recv(1024) # 收到总召唤后先回一帧遥测 # 类型标识9 cot2 公共地址1IOA1NVA16384(0.5*满量程) conn.sendall(bytes.fromhex( 68 12 02 00 04 00 09 02 01 00 01 00 00 40 00 00 00 00 00 00 00)) time.sleep(0.2) # 再回 cot10 激活终止结束总召唤 conn.sendall(bytes.fromhex(68 0e 04 00 08 00 64 0a 01 00 00 00 00 00 00 00))脚本里第一帧控制域02 00 04 00表示这是 I 帧发送序号 2、接收序号 4对应收到的主站两帧 I 帧后发回第一帧。那个回包的 APDU 长度是 18也就是 4 字节控制域加 14 字节 ASDU9类型、02传送原因、01 00公共地址、01 00IOA、00 40NVA 16384、00QDS、后面是总召唤的第一组信息体填充。跑起来后你的 C 侧应该在日志里看到总召唤有响应且最终收到激活终止。6.2 字节级对照与三组必测报文自测时不要只看“收到数据了”要逐字节对照。我习惯把模拟端发的每一帧和 C 收到的字节做打印对比重点核对三类报文报文核对点常见错误STARTDT con第二个字节是否为 04第三个是否为 0B把 07 当 con 发回遥测 I 帧控制域序号、NVA 高低字节序大小端反了遥测值差 256 倍总召唤结束cot 是否为 10且和激活确认的 7 区分把 8 当结束逻辑一直等待NVA 的字节序是常见翻车点00 40解析成 16384如果你读成40 00就是 0.0078差了上百倍。逐字节核对时先盯这两字节。6.3 我养成的验证习惯我有两个习惯坚持了很久。一个是把帧编解码写成纯函数不碰任何全局状态这样模拟端的每一个用例都能单测另一个是日志里固定打印“帧类型 / 序号 / cot”三元组U 帧打 U、S 帧打 S、I 帧打出序号联调时只看日志就能定位到哪一步断的。最惨的一次现场联调一个厂站链路半夜反复中断查了一整夜最后发现是 t2 被配成 20 秒而 t1 只有 15 秒S 帧还没到对端就已经超时了。从那以后我拿到任何一份 104 C 源码第一件事就是把四个超时参数打印出来核对一遍再看代码。这个习惯帮我避开了很多“玄学”也希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑