
简介面向电力自动化、工业物联网与协议栈开发人员这份 IEC60870-5-104 C 源码交付了一套基于 TCP/IP 的远动通信实现覆盖 RTU 与主站之间连接建立、数据上报、命令下发和状态采集的完整流程。压缩包共 56 个文件、约 5.76MB主体为 .cpp/.h 源码文件同时包含 Qt 工程配置 .pro、环境参数 .ini、动态链接库 .dll 与可执行演示程序 .exe配套 readme 便于直接编译和运行src 与 bin 目录分离也让源码定位更清晰。已有 852 人学习。源码内部按网络接口、协议编解码、RTU 模拟、事件与错误处理等模块组织详细解析了报文头、控制域、地址域、信息域及校验域的结构与编码逻辑开发者可在此基础上扩展多 RTU 地址解析与状态维护完成遥测、遥信、遥控等典型功能并结合说明文档进行功能验证、性能测试与协议兼容性联调。整体上这套实现兼具参考与工程价值适合有 C 基础并希望深入理解 IEC104 协议细节的工程师尤其适用于变电站自动化、配网监控等项目的快速原型开发。1. IEC60870-5-104 C 源码别把它当通信库要当状态机来读「IEC60870-5-104 C 源码」这个关键词网上能找到的更多是各厂家的参考实现、教育用协议栈片段以及被反复拷贝的基础框架。先纠正一个预期拿到源码不等于拿到能直接上线的模块104 的本质是「TCP 上的问答式远动规约」而 C 实现的核心矛盾在于怎么把链路握手、帧序号确认、ASDU 编解码和业务数据流拼成一个不丢帧、不卡死的小型协议栈。这篇笔记适合做 SCADA 接入、电力采集终端、继保测试装置、物联网网关的开发者也适合准备把设备接进调度系统的嵌入式工程师。我会按「协议骨架 → 本地跑通 → ASDU 编解码 → 集成避坑 → 自测验证」的顺序把 104 从站的 C 实现拆开讲重点放在能直接抄走的代码结构和参数设置上。2. 先从 C 源码里看清 104 的骨架链路状态机、收发队列和帧格式把一份 104 从站源码摊开你会发现它非常像一个小型 TCP 协议栈底层是 socket 收发中间是 APDU 拆包和链路状态管理上层是 ASDU 编解码。很多新人一上来就找「收发函数」结果在主站持续发送启动帧、从站却毫无反应的问题上卡了一周本质上是没先理清状态机。2.1 源码包打开后先认四个模块链路、队列、状态机、编解码我一般会先把源码按职责拆成下面四个模块再决定改哪里。几乎所有的 104 C 实现都逃不出这个结构。模块典型职责C 里的常见形态出错时的典型表现Connection监听、accept、socket 收发、重连Connection 类持 socket fd连接不稳定、收不到任何帧Transport从 TCP 字节流里拆出完整 APDU处理粘包半包Transport::readFrame()解析到乱码、帧长异常LinkStateMachine维护 STOPPED / STARTED 状态处理 U 帧和超时enum LinkState 状态迁移函数主站发 STARTDT 后从站不回CodecASDU 编解码、类型分发、点表映射一组 encode/decode 重载函数遥测数值对不上、遥信反了选型时先看这四层是否分开。很多裁剪过的源码把 socket 接收和 ASDU 解析写在同一个 while 循环里业务逻辑直接操作字节数组这种实现短期内能跑加需求之后会非常痛苦。还有一个容易被忽略的点104 协议栈不等于业务库它应该有清晰的回调接口把「收到总召唤」「收到时钟同步」「收到遥控命令」这些事件交给上层业务去处理而不是在协议层里绑死点位表。2.2 I/S/U 帧其实是一个循环里的三个分支104 的 APDU 由 APCI 和 ASDU 组成控制域的第一个字节决定了它是 I 帧带序号的应用数据、S 帧确认帧还是 U 帧控制帧。在 C 实现里接收循环的骨架就是先拆出完整 APDU再按帧类型分三个分支。// 从 socket 读到的原始字节流先全部塞进 recvBuf uint8_t buf[4096]; int len recv(fd, buf, sizeof(buf), 0); if (len 0) { /* 连接断开走重连逻辑 */ } rcvBuf.Append(buf, len); while (rcvBuf.GetAvailable() 2) { // 104 帧必须以 0x68 起始第二字节是 APDU 长度包含 4 字节控制域 if (rcvBuf.PeekByte(0) ! 0x68) { // 常见做法丢一个字节继续找防止历史脏数据导致死循环 rcvBuf.Consume(1); continue; } int apduLen rcvBuf.PeekByte(1); if (rcvBuf.GetAvailable() apduLen 2) { break; // 半包等下一次 recv } uint8_t frame[512]; rcvBuf.CopyOut(frame, apduLen 2); rcvBuf.Consume(apduLen 2); uint8_t ctrl1 frame[2]; if ((ctrl1 0x03) 0x03) { // U 帧STARTDT / STOPDT / TESTFR HandleUFrame(frame[2]); } else if ((ctrl1 0x01) 0x01) { // S 帧只含接收序号不需要应用层处理 HandleSFrame(frame); } else { // I 帧携带 ASDU需要维护发送/接收序号 HandleIFrame(frame); } }这里最关键的是拆帧逻辑一帧 APDU 的总长度是「第二字节 2」因为 0x68 和长度字节本身不在长度计数里。很多从站翻车在只用recv返回的长度去解析TCP 是流式传输一次recv可能包含半帧、一帧半甚至多个帧必须用缓冲区和循环拆包。U 帧是最容易被忽略的部分主站连上后第一件事是发 STARTDT act0x68 04 07 00 00 00从站必须回 STARTDT con0x68 04 0B 00 00 00之后链路才算进入 STARTED 状态才能收发 I 帧。如果从站代码没有处理 U 帧主站会一直重复发送启动命令看起来就像「网络通了但没有任何数据上来」。2.3 发送序号和接收序号源码里最像黑匣子的部分I 帧的控制域四个字节里前 14 位是发送序号后 14 位是接收序号因为序号按两个字节计数实际帧序号是原始值 1。S 帧则只携带接收序号表示「我已经收到了你发来的这么多帧」。C 实现里最常见的序号漏洞是只加不用、或者unsigned short溢出后不做回绕判断。从站侧维护的逻辑很简单sendSeq是下一次发送 I 帧时要填的序号recvSeq是期望从主站收到的下一个发送序号。收到一帧 I 帧时先检查帧里的发送序号是否等于recvSeq相等才处理然后把recvSeq加一并回一个 S 帧或随下一条 I 帧携带确认。这里产生的重复帧需要丢弃但要保证应答还是得发。2.4 拿源码改还是自己写三个判断标准面对「IEC60870-5-104 C 源码」这个搜索诉求真正要做的决策不是「下哪个包」而是「拿到的代码值不值得改」。我建议先按三个标准打分协议栈是否独立于业务点位表、回调接口、点号映射有没有彻底分开。如果连「收到总召唤」都要改协议栈内部代码后续每次加遥测都是灾难。常用类型是否齐全至少要有单点遥信1、双点遥信3、归一化遥测9、短浮点遥测11、整数遥测13、总召唤100、时钟同步103。缺这些意味着你还要自己补编解码。调试手段是否能用有没有配套的主站模拟端、抓包说明或至少日志开关。否则代码跑通了也不知道对不对出了问题基本靠猜。三个评分里如果只有一个达标我倾向于不硬改旧代码而是只保留它的编解码函数重新搭一个接收循环和状态机反而省时间。3. 在本地把 104 从站跑起来最小工程、启动流程和定时器参数不管源码里有多少封装最终都要落到「一个socket、一个循环、一组状态」上。这一章给出一个足以跑通握手和总召唤的最小从站工程结构以及参数怎么填不翻车。3.1 编译前先想清楚三件事线程模型、粘包和字节序工程落地前有三个前置决策直接影响代码怎么组织线程模型上常见做法是一连接一线程每个连接拥有独立的接收缓冲区、发送队列和状态机也可以用事件循环驱动所有连接。104 的从站通常连接数不多我习惯用「一连接一线程 发送队列」的模型简单直观链路状态天然隔离。粘包拆包在上文已经提过这里只强调一点整包边界必须先于字节序处理。多帧连在一起时如果没按长度拆干净后面所有解析都会错位而且这种错位不是每次出现属于典型的间歇性故障。字节序方面104 的 ASDU 里多字节字段按大端传输但很多 PC 上跑的例子直接用memcpy读结构体在小端机器上拿到的数值就反了。除非代码注释明确写了「仅限小端平台调试用」否则不要用结构体直接映射字节流。3.2 一个最小从站接收循环从启动握手到总召唤下面的代码是我习惯的最小从站骨架省略了具体 ASDU 编解码重点展示状态流转和帧边界处理。class SlaveConnection { public: bool Start(int port) { listenFd_ socket(AF_INET, SOCK_STREAM, 0); // bind listen端口默认 2404可配置 return Listen(port); } void AcceptLoop() { while (true) { int client accept(listenFd_, nullptr, nullptr); linkState_ STOPPED; // 新连接必须先等 STARTDT std::thread([this, client] { HandleClient(client); }).detach(); } } private: void HandleClient(int fd) { uint8_t buf[4096]; while (true) { int n recv(fd, buf, sizeof(buf), 0); if (n 0) break; rcvBuf_.Append(buf, n); if (!TryParseFrames(fd)) break; TrySendTestFrame(fd); // 按 t3 周期发送 TESTFR act } close(fd); } bool TryParseFrames(int fd) { while (rcvBuf_.Available() 6) { if (rcvBuf_.Peek(0) ! 0x68) { rcvBuf_.Consume(1); continue; } int apduLen rcvBuf_.Peek(1); if (rcvBuf_.Available() apduLen 2) break; // 等半包 std::vectoruint8_t frame rcvBuf_.Take(apduLen 2); uint8_t ctrl frame[2]; if ((ctrl 0x03) 0x03 ctrl 0x07) { // STARTDT act回复 STARTDT con SendUFrame(fd, 0x0B); linkState_ STARTED; } else if ((ctrl 0x03) 0x01) { // S 帧更新对端接收确认无业务处理 } else if (linkState_ STARTED) { SendSFrame(fd); // 立即确认 OnIFrame(frame); // 交给 ASDU 解析层 } } return true; } };代码里的核心设计是SendSFrame必须放在解析业务之前因为主站是以序号判断链路是否健康的拖到业务处理完再回确认遇上慢操作就会超时。OnIFrame里再根据类型标识分发到遥测、总召唤、时钟同步等处理函数协议层不关心点表。参数上apduLen是长度字段frame的总长度用apduLen 2参与拷贝如果按apduLen截帧所有后续解析都会差两个字节。这个坑出现的频率极高排查时先打印每帧的原始长度再对长度字段做加法验证。3.3 把 t0 到 t3 和 k/w 填对参数表与连不上的关系104 协议的可靠性靠一组定时器维持C 源码里通常把它们集中放在一个配置结构体里。填错任何一个都不会立即报错只会在特定场景下让链路反复重建。参数常见默认值含义填错的后果t010sTCP 连接建立超时连接慢时频繁判定超时t115s发送或 TESTFR 后等待应答超时主站稍微卡顿就断链t210s收到 I 帧后最迟确认时间必须小于 t1确认晚于主站重传窗口t320s空闲时发送 TESTFR act 的周期太短日志刷屏太长链路不被发现k12最大未确认发送帧数窗口上限超过后主站不再发送或从站不拒收导致序号错乱w8未确认接收帧数达到该值前至少要回一帧 S 帧接收确认不及时主站窗口耗尽t2 必须小于 t1这是 104 规约的硬性约束否则主站还没等到确认就先判定发送超时。窗口 k/w 在从站源码里一般不常用但如果从站做的是「软件模拟多个子站」的场景这些参数必须按主站的设置对齐否则抓包看全是重复帧。4. 手写 ASDU遥测、遥信和总召唤在源码里怎么落地模块拆完、连接能握手之后真正的工程量在 ASDU。它本质上是「类型标识 传送原因 公共地址 信息对象」的字节序列C 里最常见的做法是为每个类型写一个打包/解析函数再挂到类型分发表上。4.1 单点遥信和双点遥信把 bit 填进信息对象单点遥信类型标识 1M_SP_NA的每个信息对象由 3 字节 IOA信息对象地址加 1 字节 SIQ品质描述符组成。SIQ 的第 0 位是遥信值第 4~7 位是品质位。双点遥信类型标识 3M_DP_NA则用 DCO 的两个 bit 表示状态01表示合10表示分00和11是无效状态。// 打包一串单点遥信points: 点号-布尔值 std::vectoruint8_t EncodeSinglePoint( const std::vectorstd::pairint, bool points) { std::vectoruint8_t asdu; asdu.push_back(1); // 类型标识 M_SP_NA asdu.push_back((uint8_t)points.size()); // VSQ单帧内信息对象数量 asdu.push_back(3); asdu.push_back(0); // COT3突发 asdu.push_back(addrLow_); asdu.push_back(addrHigh_); // 公共地址 for (auto p : points) { int ioa p.first; // IOA 按低字节在前 asdu.push_back(ioa 0xFF); asdu.push_back((ioa 8) 0xFF); asdu.push_back((ioa 16) 0xFF); uint8_t siq p.second ? 1 : 0; // 第0位是值 siq | 0x00; // 品质位默认 00正常 asdu.push_back(siq); } return asdu; }这里要特别留意 IOA 的三字节顺序报文里是低字节在前但很多从站内部点表用的是「整数地址」转换时最容易把高字节写在前面。SIQ 的品质位里第 6 位IV无效在实际调试中最常遇到主站画面显示数值正常但标品质异常多半是解析侧把品质位当成了值位。双点遥信打包时需要把布尔值映射到两位 DCO代码里不能直接写dco value ? 1 : 0因为按规约合闸是01分闸是10写反了画面上的开关状态就是反的。4.2 遥测编码归一化值、短浮点和整数型的取舍104 的遥测有三个常用类型9 是归一化值NVA2 字节带符号11 是短浮点IEEE754 float4 字节13 是整数4 字节带符号。C 实现中选哪种取决于精度需求和点表定义。短浮点类型最简单直接把 float 按大端写入报文即可适合温度、电压这类带小数的模拟量归一化值需要把实际工程值换算乘一个 scale精度受限于-32768~32767适合流量、压力这种满量程固定的量。// 打包短浮点遥测类型 11val 单位是工程值 void AppendShortFloat(std::vectoruint8_t asdu, float val) { uint32_t bits; static_assert(sizeof(float) 4, float must be 32-bit); memcpy(bits, val, 4); // 大端输出先高字节 asdu.push_back((bits 24) 0xFF); asdu.push_back((bits 16) 0xFF); asdu.push_back((bits 8) 0xFF); asdu.push_back(bits 0xFF); }用memcpy是因为直接做位运算容易破坏 float 的符号位和指数结构先把 4 字节读成uint32_t再做大小端转换。归一化值的坑在于 scale 不统一同一个项目里不同遥测点的 scale 可能差几百倍打包时必须在点表配置里带出 scale不能用全局常量。4.3 总召唤应答里最容易漏掉的「结束标志」主站下发总召唤类型标识 100C_IC_NACOT6后从站要按「先回确认、再上数据、最后回结束」的三段式响应。很多源码只做了中间的数据上送主站侧就会永远停在「召唤中」没有任何报错但画面就是刷不出全量数据。// 收到总召唤 I 帧后的响应顺序 void OnTotalCall(int fd) { SendASDU(fd, BuildTotalCallConfirm()); // 类型100COT7确认已收到 // 遍历点表把所有遥信、遥测按分组上送多个 I 帧 for (auto grp : pointTable_.Groups()) { SendASDU(fd, EncodeSinglePoint(grp.Points())); SendASDU(fd, EncodeFloatMeasure(grp.Measures())); } // 最后送结束帧类型100COT10IOA0 SendASDU(fd, BuildTotalCallEnd()); // COT10 }确认帧和结束帧的区别只在 COT 上确认是07结束是0A。数据量大的时候结束帧必须等所有数据帧发送完毕后再发不能边发边把结束帧插进队列。有些主站实现很严格结束帧之前如果还有数据在途它会认为召唤未完成下一轮总召唤又重复下发。5. 避坑集成 104 C 源码时最常见的 5 个翻车现场前面章节的代码都是理想路径实际集成里遭遇的问题几乎都集中在序号、状态和时序上。以下 5 个坑是我在做模拟项目X和某跨平台系统接入时反复遇到过的按「现象 → 原因 → 解决」整理。5.1 现象主站一直在发启动帧从站毫无反应原因从站收到 STARTDT act 后没有把内部状态切换为 STARTED或者没有回复 STARTDT con。很多源码只在收到 I 帧时才检查状态把 U 帧处理漏掉了。解决在接收循环的帧类型判断里把ctrl 0x07作为独立分支优先处理先回0x0B再改状态。调试时打印收到的每个控制域字节确认 0x07 已到达、0x0B 已发出。5.2 现象总召唤之后主站提示数据不完整但抓包看到数据帧都发了原因漏发了总召唤结束帧类型 100、COT10。数据帧齐全不代表召唤流程闭合主站是按「确认 → 数据 → 结束」三段判断的。解决在数据分组全部发送完成后追加一个 IOA0 的结束 ASDU并且确认它排在发送队列的尾部。如果从站有多个线程同时写 socket要给结束帧加独立的队列顺序保证。5.3 现象S 帧被主站忽略链路反复重建原因S 帧里的接收序号写错。S 帧的接收序号应该等于「主站已发送的 I 帧个数 × 2」很多实现把本地发送序号填了进去主站解析后认为从站在乱答。解决发送 S 帧和控制帧前从recvSeq派生序号字段不要用sendSeq。代码里加一个断言S 帧控制域的第三、四字节必须来自接收计数而不是发送计数。5.4 现象从站重启后主站出现重复遥测点位值跳变原因主站可能重传了未确认的 I 帧从站没有按发送序号去重导致同一条遥测被处理两次。从站重启后接收序号清零更容易触发这类重传。解决维护一个lastRecvSeq只处理seq lastRecvSeq的帧序号小于当前值的直接丢弃但照常回确认。总召唤例外它可以在任意状态重发数据因为 COT6 明确要求全量重送。5.5 现象双点遥信在监控画面上合分颠倒原因DCO 的两位顺序和协议定义不一致常见于把布尔值直接映射到 bit没有做01/10的转换也有可能是打包时按小端处理导致位序反了。解决用位掩码读取原始字节再按「1 表示合、2 表示分」的逻辑映射为内部枚举。不要用struct { uint8_t val : 2; }这类位域直接读编译器对位域的顺序不做保证跨平台必翻车。6. 进阶用一个自测脚本把握手、召唤和序号验证做扎实最后这个技巧不需要真实主站也不需要专用测试装置用一段 Python 脚本模拟主站按 104 的时序把从站当黑匣子来测。它的价值在于集成阶段每次改动压缩编码或状态机后能在一分钟内发现「握手没回」「总召唤没结束」「序号不对」这三类基础问题。import socket, time s socket.create_connection((127.0.0.1, 2404), timeout5) print(connected) s.sendall(bytes.fromhex(68 04 07 00 00 00)) # STARTDT act time.sleep(0.2) resp s.recv(1024) print(startdt resp:, resp.hex()) assert resp[:6] bytes.fromhex(68 04 0B 00 00 00) # 总召唤 I 帧类型100COT6公共地址1IOA0 # 控制域发送序号0、接收序号0 total_call bytes.fromhex(68 0D 00 00 00 00 64 01 06 00 01 00 00 00 00) s.sendall(total_call) time.sleep(0.5) frames [] while True: try: s.settimeout(0.2) data s.recv(4096) if not data: break frames.append(data.hex()) except socket.timeout: break for f in frames: print(frame:, f) # 校验三段式确认、数据、结束 has_confirm any(64 01 07 in f for f in frames) has_data any(not (64 01 07 in f or 64 01 0A in f) for f in frames) has_end any(64 01 0A in f for f in frames) assert has_confirm and has_data and has_end, total call flow broken print(total call flow OK)脚本里最关键的是判定条件把64 01 07看作确认帧前缀64 01 0A看作结束帧前缀中间的 I 帧都算数据帧。如果has_data为 False说明从站收到总召唤后没有上送任何信息对象先回 5.2 查结束帧再查点表遍历逻辑。我最后的习惯是把这类脚本固化成一个selftest_104.py配合抓包数据看十六进制每次改动提交前跑一遍。别只用「主站能连上」当作测试通过的凭证——握手通了和数据完整是两回事。这套方法帮我省过很多次现场排查希望帮到你。本文还有配套的精品资源点击获取