104报文解析实战:从TCP字节流到变电站事件链

发布时间:2026/10/6 13:59:57
104报文解析实战:从TCP字节流到变电站事件链 简介这是一款面向电力自动化、SCADA系统调试与协议分析人员的104规约报文解析工具。工具支持文件解析、抓包解析与高级分析可帮助用户快速解读IEC 60870-5-104报文定位辅控序号异常、链路通信问题等实际故障适用于变电站在线监测、辅控系统联调、规约一致性检查等场景。压缩包共20个文件约11.68MB包含主程序exe、运行所需dll库、pcap抓包样例、csv配置与日志、txt说明及rtf帮助文档等各类型文件分工清晰兼顾直接使用与二次分析目前已有437人学习下载适合具有一定规约基础的调试工程师或需要深入理解104报文细节的技术人员。压缩包内除主程序与运行库外还配备可直接加载的抓包文件、配置样例和运行日志便于用户在脱离真实报文场景时自行演练通过解析结果与原始报文对照可加深对104规约控制域、传送原因、信息体地址等字段的理解对协议二次开发和现场问题排查均具有实用参考价值。1. 104报文解析工具从十六进制抓包到变电站事件链中间缺什么变电站现场调试最常见的翻车场景不是装置没发报文而是发了但你接不住抓包软件显示TCP连接正常主站就是上不来遥信。这时候对着一屏0x68开头的十六进制手动翻译ASDU字段十分钟就能把人逼疯。IEC 60870-5-104报文解析工具要解决的就是这件事——把原始报文按帧边界切干净把控制域和ASDU按协议字典解出来再做文件解析和事件级高级分析让主站和厂站双方能对同一帧说同一句话。这篇笔记写给刚接触调度自动化的调试工程师也写给想把协议解析能力沉淀成内部工具的运维团队。2. 拆开104报文帧APCI控制域与ASDU的字节级映射2.1 起始字节与APDU长度域0x68后面不是直接就能读数据每一个完整的104帧无论承载数据还是控制指令都以起始字符0x68开头紧跟一个字节的APDU长度。注意这个长度不是整个TCP分段的长度而是从控制域第一个字节起算到ASDU最后一个字节为止的总字节数。如果这一帧是不带ASDU的U格式帧长度值就只有2因为控制域占两个字节。真正写解析器时必须用“2加长度域值”来切帧而不是按TCP段边界直接切。常见误解是把抓包里每一次TCP载荷都当成一个完整的APDU。TCP层可能把一个APDU拆成两个包也可能一个包里塞三帧这两个行为都是合法的。所以解析器必须维护一个按字节累计的缓冲逐帧对齐后才返回可解析的完整帧。常见做法是写一个状态机查起始符、读长度、等攒够字节、切帧、回到起始状态。Wireshark里看到的帧边界和TCP段边界不一致不代表协议异常只是TCP分段机制在工作。从控制域第一个字节就能分辨三种帧格式格式判定位作用I格式第1字节bit0 0信息传输帧携带ASDU含发送/接收序号S格式第1字节低两位 01监督帧纯确认不带ASDUU格式第1字节低两位 11未编号控制帧用于STARTDT/STOPDT/TESTFR握手这里有个容易让人混淆的点S格式和U格式的控制域标准长度是两字节但不少厂商在实现时统一补齐四个字节。你写解析器时最好对二和四字节的版本都兼容按格式位判断而不是按固定长度硬切。调试中看到控制域长度不一致时别急着报协议异常先确认对方用的是哪种填充约定。2.2 类型标识符和结构限定词一张表认出九成现场报文ASDU的第一个字节是类型标识符也就是这一帧里装的到底是什么信息。综自系统里九成报文集中在这么十几个类型上类型标识符名称说明1单点遥信一个bit表示一个开关或刀闸位置2单点遥信带时标SOE事件记录最常见的载体3双点遥信两位编码00/11为无效态9遥测归一化值带符号整数按归一化系数换算10遥测标度值带符号整数有量纲系数11遥测短浮点IEEE 754浮点现场最常见的遥测类型13带时标短浮点遥测加CP56Time2a时间戳45单点遥控主站下发的分合命令46双点遥控双位置遥控命令100总召唤主站请求全站数据124~127文件传输录波文件、事件报告文件紧接着类型标识符的是结构限定词。它的bit7是SQ位决定后续信息对象是按连续顺序寻址还是各自携带独立地址低7位是这一帧里信息对象的个数N。读取时得把整字节拆开用别顺手把整个字节当成个数。顺序寻址模式下只需要在第一个信息对象处写起始地址后续对象地址递增独立寻址模式下每个对象都带三字节地址容错性好但占用带宽。传送原因字段COT说明这个ASDU的用途常见值有1周期上传、3突发变位、6激活命令、7激活确认、20站响应总召唤等。我一般会先把COT做一个粗分类把遥控命令、遥信变位、周期遥测分到不同统计通道这一步对后面的高级分析很有价值。2.3 信息体地址和三字节小端一个不小心的坑信息体地址IOA固定三字节采用小端序传输也就是低字节在前。地址范围从1开始0x000000到0xFFFFFF之间具体范围由工程点表约定。这里有个高频翻车点遥信点表的设备侧地址和主站侧地址经常差1。设备厂商习惯从0开始编号主站画面习惯从1开始编号中间差的一个点会让所有遥信位置错位。单点遥信的信息对象是1字节值bit0是状态bit1到bit3分别是品质描述位IV无效、NT不刷新、SB被取代bit4是BL封锁。解析时不能只读状态位就把其余全当垃圾扔掉品质位直接决定这条遥信能不能送进画面。双点遥信只取bit0和bit101表示合10表示分00和11都算无效态直接显示会误导值班员。带时标的类型还要额外读7字节的CP56Time2a时间戳毫秒占前两个字节且低字节在前后面依次是分、时、日、月、年。这个时间戳是相对时标的不是绝对UTC转换时需要注意时区和夏令时问题。还有一点容易看漏CP56Time2a里的“日”字节bit7是星期几编码“时”字节bit7是夏令时标志位做历史数据回放时这些位会干扰时间对齐。3. 抓包解析一条龙用Wireshark和tshark把pcap转成可统计的JSON3.1 Wireshark自带104解码器过滤器和显示字段怎么配Wireshark在2.x之后自带IEC 60870-5-104解析插件抓包后在上方过滤栏输入iec104就能只显示104帧。这里有个细节过滤条件是协议名iec104过滤的是APDU被识别为104协议的帧TCP握手包不会包含在内。如果你关心握手过程要改成tcp.port 2404 tcp2404是104协议默认端口。Wireshark虽然能解析但它的显示字段名用得是否顺手是另一回事。不同版本对ASDU字段的命名有差异。我每次换环境第一件事是跑这条命令确认当前版本的字段名长什么样tshark -G fields | grep -i iec104 | head -50这条命令输出当前Wireshark版本支持的iec104协议字段全集。常见字段名如iec104.type、iec104.cot、iec104.ioa都在里面但具体拼写可能随版本变化。拿到清单后再写抓包脚本才不会出现字段名写错导致导出的文件全空。显示过滤还有一个实用组合关注遥控命令时用iec104 iec104.type 45配合COT过滤关注SOE变位时用iec104 (iec104.type 2 || iec104.type 4)。过滤条件先粗筛协议再细筛ASDU类型比直接在全部流量里翻快得多。3.2 用tshark批量导出字段离线分析的基本姿势现场抓包文件动辄几百兆在Wireshark图形界面里逐帧点开不现实。tshark的价值在于把pcap变成机器可读的结构化数据。最小批量导出命令是这样tshark -r site-104.pcap -Y iec104 -T json iec104_all.json-Y指定显示过滤条件-T json表示输出JSON格式。这个命令每次跑完都生成全量JSON文件可能很大但可以先跑通流程。实际工作中我更常用-T fields配合-e参数挑字段输出CSV或制表符分隔文本tshark -r site-104.pcap -Y iec104 \ -T fields \ -e frame.number \ -e frame.time_epoch \ -e ip.src \ -e ip.dst \ -e tcp.srcport \ -e tcp.dstport \ -e iec104.type \ -e iec104.cot \ -E headery -E separator, frame_list.csv参数说明-e frame.time_epoch输出Unix时间戳后面分析时序时方便直接排序。-E headery让第一行带列名-E separator,指定逗号分隔Excel和Python都能直接读。导出前先把字段名用前面那条tshark -G fields命令核对一遍字段拼错时tshark不会报错只会输出一堆空字段。这段做法还有个进阶用途把同一份pcap分别导出成“帧级摘要”和“ASDU级明细”两者用frame.number关联既能快速查看某类变位分布也能随时下钻到具体字节。3.3 抓包后先做流量统计别一上来就扑进报文细节拿到pcap文件后我一般先看整体统计再决定要不要逐帧解析。tshark的统计能力是内建的tshark -r site-104.pcap -q -z io,stat,30 -z tcp,tree-q是安静模式只输出统计结果-z io,stat,30按30秒间隔分段统计流量后面的tcp,tree输出TCP连接维度明细能看出主站和从站之间建立了几条连接每条连接上传多少、下发多少。这一步的价值在抓隔离故障时体现得最明显。如果统计发现某条TCP连接只有下行流量没有上行问题大概率在从站侧通信板或前置机配置。先看总量和连接分布再进入报文细节能少走很多弯路。有个习惯我要提醒抓包时如果交换机做了镜像口抓到的包带不带VLAN标签会直接影响过滤条件VLAN场景下过滤要写成vlan iec104。4. 自己写一个104报文解析工具Python状态机和文件段重组4.1 帧缓冲状态机先把TCP字节流对齐成帧抓包解析是外部工具自己手写解析器则是把这套能力沉淀成内部工具的基础。Python 3环境下第一步是解决TCP流重组问题。下面这个类是我在多个现场项目里用过的最小实现import struct class IEC104FrameBuffer: 把TCP字节流对齐成完整APDU的帧缓冲 def __init__(self): self.buf bytearray() def feed(self, data: bytes): 喂入一段TCP载荷返回切好的APDU列表 self.buf.extend(data) frames [] while True: idx self.buf.find(b\x68) if idx 0: # 当前缓冲区里没有起始符整个丢弃 self.buf.clear() break if idx 0: # 把起始符前面的残余字节丢掉 del self.buf[:idx] if len(self.buf) 2: # 长度域还没收全继续等下一个TCP段 break apdu_len self.buf[1] if len(self.buf) 2 apdu_len: # 帧没收全耐心等 break # 切出一个完整APDU不含0x68和长度字节 apdu bytes(self.buf[2:2 apdu_len]) del self.buf[:2 apdu_len] frames.append(apdu) return frames逻辑说明这个类维护一个字节数组feed方法把TCP段塞进去后循环查起始符0x68。找到起始符但后面的长度域还没凑齐时跳出循环等下一段数据。二和三两种break场景的返回值语义不同调用方要知道“返回空列表不代表无帧可能只是缓冲未满”否则调试时会误判丢帧。参数说明这里固定用1字节公共地址长度域按标准模型处理没有做2字节公共地址扩展。遇到大站址配置时需要在类构造器里加pub_addr_len参数解析时按该长度跳过地址字段。U/S格式和I格式混用时控制域长度不一致但这里的切帧逻辑只看长度域本身天然兼容两种填充方式。4.2 解析ASDU从类型标识符到信息对象的完整拆包拿到完整APDU后第二步是解析ASDU。这个函数按标准字段顺序拆包def parse_apdu(apdu: bytes): 解析一个APDU返回分类和分析结果 if len(apdu) 4: return {error: APDU长度异常} ctrl apdu[0] if ctrl 0x01 0: kind I elif (ctrl 0x03) 0x01: kind S elif (ctrl 0x03) 0x03: kind U else: kind UNKNOWN if kind ! I: return {kind: kind, hex: apdu.hex()} asdu apdu[4:] # 控制域4字节ASDU从第5字节开始 type_id asdu[0] sq_n asdu[1] sq (sq_n 0x80) 7 count sq_n 0x7f cot asdu[2] pub_addr asdu[3] pos 4 infos [] ioa 0 for i in range(count): if sq: # 顺序寻址只有第一个对象带IOA后续递增 if i 0: ioa int.from_bytes(asdu[pos:pos 3], little) pos 3 else: ioa 1 else: # 独立寻址每个对象都带三字节IOA ioa int.from_bytes(asdu[pos:pos 3], little) pos 3 if type_id in (1, 2): value asdu[pos] 0x01 quality asdu[pos] pos 1 infos.append((ioa, value, quality)) elif type_id in (3, 4): value asdu[pos] 0x03 quality asdu[pos] pos 1 infos.append((ioa, value, quality)) elif type_id in (9, 10): value struct.unpack(h, asdu[pos:pos 2])[0] pos 2 infos.append((ioa, value)) elif type_id 11: value struct.unpack(f, asdu[pos:pos 4])[0] pos 4 infos.append((ioa, value)) else: # 其它类型按协议字典补充 value asdu[pos] pos 1 infos.append((ioa, value)) return { kind: kind, type_id: type_id, sq: sq, count: count, cot: cot, pub_addr: pub_addr, infos: infos }逻辑说明函数先判断帧类型只有I格式才带ASDU。S和U格式直接返回分类结果。ASDU的拆包顺序完全对应前文讲的字节布局——类型标识符、结构限定词、传送原因、公共地址然后进入信息对象区。顺序寻址和独立寻址两个分支的区别在IOA的处理方式上顺序寻址只读一次地址然后递增独立寻址每个对象都读三字节。参数说明类型9归一化值和类型10标度值都按带符号短整型读取区别在工程里乘以的系数不同。类型11按IEEE 754-32位浮点读取struct.unpack(f)里的表示小端序。品质位用整个值字节的其它位表达现场的典型用法是把品质位也存下来后续分析时判断这条遥信是否可靠。4.3 文件解析从分帧的文件段重组完整文件104的文件传输在变电站里通常对应录波文件和SOE事件导出用到的类型标识符是124到127。这四个类型分别是文件准备就绪、文件段、文件结束和文件确认。文件段的重组逻辑可以独立成类class FileAssembler: 按文件ID缓存并重组文件段 def __init__(self): self.files {} # file_id - (length, {seg_no: payload}) self.ready_list [] def on_segment(self, file_id: int, seg_no: int, payload: bytes): if file_id not in self.files: self.files[file_id] (0, {}) length, seg_dict self.files[file_id] seg_dict[seg_no] payload def on_end(self, file_id: int, total_len: int): length, seg_dict self.files.get(file_id, (0, {})) missing [i for i in range(length // 256 1) if i not in seg_dict] if not missing: data b.join(seg_dict[i] for i in sorted(seg_dict.keys())) self.ready_list.append((file_id, data)) del self.files[file_id] else: # 文件段缺失记录异常 print(f文件 {file_id} 缺段: {missing})逻辑说明这个类按file_id维护一个缓存字典每来一个文件段就按段号存进去。on_end触发完整性检查确认所有段都到齐后按段号排序拼接成完整文件。文件长度由标准中的文件长字段给出这里用length // 256 1估算段数是因为每段通常最多255字节实际段数要以文件长字段为准。参数说明这里的file_id解析在类型125的ASDU里做现场厂商对文件ID的字节宽度不统一有2字节也有3字节按你对接设备的规约文档确认后再实例化。缺失段的命名规则不强制但建议把缺失段列表落盘方便回放时判断是抓包漏了还是从站没发。5. 104报文解析避坑指南五个让调试通宵的现场问题5.1 遥信点号全部错位地址基准差1现象解析出来的遥信变位都对但工程图上的点号差1所有开关位置错开一个位号。原因设备侧程序从0开始编号信息体地址而主站侧点表从1开始两边没有统一基准。104的IOA地址是1基的但不少装置的内部链表索引是0基导出报文时直接拿索引当IOA就会整体偏移一个点。解决先在工程点表里确认两端基准约定再在解析配置里加一个IOA偏移量参数。我一般在解析器里预留offset字段解析时对IOA做统一加偏移而不是改代码。这个参数应该进配置文件而不是写死在脚本里否则下个工程又要改一遍。5.2 遥测值全是天文数字或NaN类型标识符没走对分支现象控制变量显示正常但所有遥测都变成几千甚至NaN。原因类型9归一化值和类型11浮点值的解析分支没分开。类型9是带符号短整型占2字节类型11是IEEE 754浮点占4字节。把类型9当浮点读会把两个相邻的信息体合并成一个连字节数都对不上,后面全部错位。解决解析ASDU时严格按type_id分派解析函数类型9和10走int16分支类型11和13走float32分支。调试时先在抓包里手工数一个信息体的字节数确认字节长度匹配再让解析代码接管。5.3 SOE时间标签错乱CP56Time2a的毫秒域是小端现象SOE事件的时间对不上秒和分都对毫秒部分乱跳。原因CP56Time2a的前两个字节是毫秒值传输时低字节在前高字节在后。有的解析代码直接用int.from_bytes(ms_bytes, big)读毫秒就变成了几百甚至上千的错乱值。解决毫秒域一律用小端序读取。检查方法很简单拿一条已知时间标签的报文手工加一次把前两字节按小端拼成整数再和标准时间换算对比。这条坑特别隐蔽因为秒和分都在后面的单字节上读法不会错只有毫秒挂掉时最不容易察觉。5.4 解析看着“丢帧”其实是TCP分段与缓冲未攒齐现象抓包文件里有5000帧解析结果只有4800条检查发现分区间的帧消失不少。原因解析器没做帧缓冲直接把每个TCP段当一帧处理。TCP层会把一个完整APDU拆到两个包或者把三帧合进一个包任何一端的处理不当都会造成计数丢失。解决改进方法是使用前文那套帧缓冲状态机先把字节流对齐成完整APDU再解析。不自研的话可以换用scapy或tshark的协议解析能力这些工具已经处理了重组问题。注意两者对TCP重组的前提条件不同tshark依赖序列号跟踪scapy需要显式调用重组函数。5.5 遥控命令发不出去STARTDT/STOPDT握手时序现象TCP连接正常建立主站下发遥控命令从站没有任何响应。原因104连接的会话层要先走U格式的STARTDT激活从站回STARTDT激活确认后才能传I格式数据。抓包图里如果只有TCP三次握手和SYN包没有0x81开始的U格式帧问题就出在主站侧前置机没有正确发起104握手。解决抓包后用过滤条件“tcp.port 2404 (iec104 || tcp.flags.syn 1)”确认握手帧存在。手工测试时可以按先收到的方向逐帧确认顺序主站发STA从站回STA确认然后才允许I格式数据。如果前置机软件配置里打开了自动重建会话还要检查重建周期是否过短导致主站频繁打断正常会话。6. 高级分析把事件序列还原成故障链路并复用这套思路解析更多协议6.1 把遥信变位和SOE拼成时间线解析出结构化的报文记录只是开始高级分析的价值在于把一堆离散帧还原成有因果关系的运行过程。现场一个典型需求是“开关跳闸后主站为什么没收到事故总信号”。处理办法是把解析结果按时间线和设备维度做关联分析先按CTOT分类把遥信变位、SOE事件、遥控命令分别抽出来再按IOA地址合并成每个间隔的时间线。常见的做法是解析后落一张宽表字段包括时间戳、IOA、事件类型、分类用SQL或Pandas做窗口聚合。比如查找某个间隔在1秒窗口内的“开关变位到保护动作SOE”的先后顺序判断信号链路是否存在反向。这一步在抓包排故时帮我把两小时的排障压缩到二十分钟。6.2 一套解析方法论复用到CAN报文和HART DD文件CAN协议报文解析和104报文解析的思路其实高度一致先抓原始帧再按定义文件映射字段语义最后还原成事件链。CANoe这类工具做CAN报文解析时也是这样——建通道、加载DBC文件、按信号名解码、输出到trace窗口。差别只在104需要面向电力远动场景的时标和品质位CAN面向车载总线更关注周期抖动和错误帧统计。HART DD文件解析则是另一个方向的启发DD文件本身是描述仪表参数的二进制描述文件解析的入口不是抓包而是描述文件。和104报文解析互为镜像——104解析器靠规约文档和点表配置驱动HART解析器靠DD文件驱动。理解了这一层协议工具就能抽象成一个“抓包、过滤器、解码字典、事件映射”四段式框架换协议只是换字典。我在实际项目里的习惯是每接一个陌生协议先按这四段拆解抓包确认帧边界过滤器确定目标报文范围解码字典对应协议规约事件映射对应业务点表。这个习惯救过我很多次第四次遇到CANoe工程解析时直接用同一套思路十分钟就定位到DBC文件里两处信号方向定义反了。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询