首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
开源项目
发新帖
0
0
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
发表于: 2026-8-31 14:06
158
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
Coder_shen
2026-8-31 14:06
158
# 基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树 > 不装博途、不连 PLC、不引用任何西门子运行时组件,纯托管 C# 直接解析 `.apXX` 工程文件,重建与 S7 在线浏览完全一致的符号变量树。 > > 关键词:TIA Portal、.ap14/.ap17/.ap20、PEData.plf、符号表、AccessSequence、零依赖 --- ## 目录 1. [问题背景](#1-问题背景) 2. [整体架构](#2-整体架构) 3. [文件格式逆向:从 .apXX 到对象图](#3-文件格式逆向从-apxx-到对象图) 4. [数据库对象模型与双流语义](#4-数据库对象模型与双流语义) 5. [DB 块发现与排序](#5-db-块发现与排序) 6. [接口解析与成员树](#6-接口解析与成员树) 7. [访问序列(AccessSequence)构造规则](#7-访问序列accesssequence构造规则) 8. [类型系统映射(Softdatatype)](#8-类型系统映射softdatatype) 9. [PLC 标签表(I/Q/M/C/T 区)](#9-plc-标签表iqmct-区) 10. [带点号名称的引号规则](#10-带点号名称的引号规则) 11. [树构建与离线浏览 GUI](#11-树构建与离线浏览-gui) 12. [PLC 信息提取:与在线驱动四字段对齐](#12-plc-信息提取与在线驱动四字段对齐) 13. [验证:双真值对拍与 25 工程矩阵](#13-验证双真值对拍与-25-工程矩阵) 14. [实战中踩过的坑](#14-实战中踩过的坑) 15. [最小可运行示例](#15-最小可运行示例) 16. [demo下载](#16-demo下载) 17. [总结](#17-总结) --- ## 1. 问题背景 在工业上位机场景中,我们经常需要一个能力:**拿到一个博途(TIA Portal)项目文件,在没有任何西门子软件环境的前提下,知道这个项目里有哪些 PLC、每个 PLC 有哪些 DB 块、每个块的接口长什么样、有哪些 I/Q/M 区标签**。典型诉求包括: - 离线浏览:出差现场没有博途授权,甚至没有电脑装博途,但要查工程里的变量定义; - 数据采集对接:上位机要读写 DB 块中的变量,必须先知道变量的**绝对访问地址**(如 `DB4` 中 `AxisUM01` 结构体的 `Velocity` 成员); - 文档生成、差异比对、版本归档:把工程文件解析成文本符号表,用 git 做工程变更追踪; - 与在线采集系统对拍:在线驱动(S7CommPlus)能浏览出 PLC 的符号表,离线侧要产出**逐字节一致**的结果,才能无缝切换。 西门子官方路线是 TIA Openness(导出 XML),但它要求**安装博途**,且需要授权;AGL/在线浏览组件则要求**连接 PLC** 或引用西门子运行时 DLL。本文介绍的路线完全不同: > **直接把 `.apXX` 工程文件当作数据文件来解析。** `.apXX` 本质是一个 ZIP 归档,里面存放着一个自描述的对象数据库。我们用一个纯托管的 .NET 库(1666 个源文件,目标框架 .NET Framework 4.0,C# 7.3,只依赖 `System.IO.Compression`)把它读成对象图,再在其上重建与 S7 在线浏览完全一致的符号变量树。 本文所有结论都经过了双重真值验证:一是与**在线浏览自己 PLC**(192.168.0.250)导出的真值表逐行对拍,二是与 AGL 文件加载组件在 25 个工程上的对拍,最终 **0 真实缺口**。文中会给出完整的判定规则、代码骨架和踩坑记录。 > 声明:本文内容仅面向自有工程文件与自有设备的技术研究,逆向结论基于对公开行为与自产文件的观察归纳,不涉及任何加密破解或授权绕过。 --- ## 2. 整体架构 系统分四层,自底向上: ``` ┌─────────────────────────────────────────────────────────────┐ │ 交付层 TiaSymbolExport(CLI 符号表) TiaOfflineBrowser │ │ (WinForms 离线浏览 GUI) PlcInfoExtractor(PLC 信息)│ ├─────────────────────────────────────────────────────────────┤ │ 枚举层 TiaSymbolEnumerator(块发现/接口走查/标签表/树钩子) │ │ ISymbolSink / TreeBuildingSink(行输出与树构建复用) │ ├─────────────────────────────────────────────────────────────┤ │ 业务层 TiaProjectExplorer(Facade:Open/OpenLazy/树走查/ │ │ 搜索/高层对象转换) HighLevelObjectConverter │ │ (StorageBusinessObject → DataBlock/标签表/…) │ ├─────────────────────────────────────────────────────────────┤ │ 文件层 TiaFileProvider(.apXX ZIP / 解包目录 / .plf 分派) │ │ TiaDatabaseFile(PLF 二进制数据库 + 惰性对象模型) │ └─────────────────────────────────────────────────────────────┘ ``` 关键设计决策: 1. **单一事实源**。符号枚举算法(`TiaSymbolEnumerator`)只有一份:CLI 导出文本符号表走它,GUI 建树也走它(通过 `ISymbolSink` 钩子挂树)。保证行输出与树显示永远一致。 2. **惰性加载 + 显式全量双模式**。`OpenLazy` 打开后不解析任何对象,浏览树按需解析单层;`ParseAllObjects()` 显式全量解析(大工程可能耗时数十秒,放后台线程)。 3. **全程容错**。工程文件是生产数据,任何字段都可能缺失。所有解析路径不抛异常,失败留空或跳过,保证 GUI 永不崩溃。 4. **零西门子依赖**。不引用 Openness、AGL 或任何西门子 DLL;对拍用的真值工具只存在于验证环节,不进入运行时链路。 --- ## 3. 文件格式逆向:从 .apXX 到对象图 ### 3.1 外层:.apXX 就是 ZIP 博途项目文件(`.ap14`、`.ap17`、`.ap20`…,扩展名数字即版本号)是一个标准 ZIP 归档。解包后最关键的入口是: ``` System/PEData.plf ← 主数据库(对象字典) System/PEData-01.plf … ← 大数据量工程的附加分片 ``` `TiaFileProvider` 负责输入分派:目录(已解包工程)、单文件(`.apXX`/`.zapXX` 归档)、单 `.plf` 流都能打开;判断方式很直白——先试 `ZipArchive` 能否打开,再按扩展名与内部条目名分类。核心代码骨架: ```csharp public static TiaFileProvider CreateFromSingleFile(string filePath) { var tiaFileProvider = new TiaFileProvider(); tiaFileProvider._filePath = filePath; tiaFileProvider.DetectType(); return tiaFileProvider; } private void DetectType() { _stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); bool flag = IsZipArchive(_stream); // new ZipArchive(...) 试探 _stream.Position = 0L; if (flag) { OpenAsArchive(); // 找 system/pedata.plf 条目定位数据库根 } else if (Path.GetExtension(_filePath).ToLower() == ".plf") { TiaFileProviderType = TiaFileProviderType.TiaPLF; } else { TiaFileProviderType = TiaFileProviderType.TiaProjectFile; // 解包目录中的单文件 } } public Stream GetDatabaseFileStream(int index) { if (index == 0) return GetStream("System/PEData.plf"); string file = "System/PEData-" + index.ToString().PadLeft(2, '0') + ".plf"; return GetStream(file); } ``` 两个工程细节: - **共享读**。所有 `FileStream` 都以 `FileShare.ReadWrite` 打开——工程文件可能正被博途占用,我们只读不锁。 - **非销毁流包装**。`NonDisposeableStream` 包一层,防止上层 `using` 提前把共享的底层流关掉。 ### 3.2 内层:PLF 二进制数据库 `PEData.plf` 是一个自描述的二进制数据库,头部记录产品版本,随后是分块的记录区。`FileHeader` 解析出的 `ProductVersion`(如 `1400.100.1201.1`)决定了后续格式分派——`TiaVersion` 刻度与博途大版本一一对应: | ProductVersion.Major | 博途版本 | 常见扩展名 | |---|---|---| | 1300 | TIA V13 | .ap13 | | 1400 | TIA V14 | .ap14 | | 1500 | TIA V15 | .ap15 | | 1600 | TIA V16 | .ap16 | | 1700 | TIA V17 | .ap17 | | 2000 | TIA V20 | .ap20 | > 实测同一套解析代码对 V13–V20 全部工程都能打开、解析、导出成功(见第 13 节的版本矩阵)。V13/V14 的容器头 XML 里没有 `<ProjectVersion>` 元素,工程版本直接回退到二进制文件头的 `ProductVersion`。 数据库加载后提供两个视图: - **`AllStorageObjects`**:全部存储对象的线性列表(按文件内顺序); - **`StorageObjectDictionary`**:以 `InstId`(实例 ID)为键的字典——同一 `InstId` 后写入的覆盖先写入的,这是"最新副本胜出"的物理基础(见第 4 节双流语义)。 ### 3.3 对象图:StorageObject → 业务对象 每个 `StorageObject` 有: ```csharp Header.StoreObjectId.InstId // 64 位实例 ID,全文件唯一(同对象多版本共用) Header.StoreObjectId.RelId // 关系 ID(DB 块的 RelId = 0x8A0E0000 | 块号,见第 7 节) Header.ObjectStates // 对象状态标志 ``` `StorageBusinessObject`(业务对象)在其上加了: - **`TiaTypeName`**:全限定类型名,如 `"Siemens.Simatic.Lang.Model.Blocks.DataBlockData"`——逆向时的第一抓手,按它做类型分派; - **`Children`**:一组 `BaseBusinessObject` 子属性对象: - `BaseExpando`:任意键值对(`Data` 字典)——工程里大量杂项信息存在这里; - `BaseRelationList`:指向其他对象的关系表(`Relations`,每条有 `Name` + `StoreObjectId`); - `InterfacePartData`:**接口载荷**,`Kind`(如 `DBSource`)+ `Payload.DataAsString`(XML 文本)——块接口、类型定义的原生载体; - **`Parent`**:父链(注意:父链来自**关系解析**,不一定是树结构上的父节点); - **`ProjectTreeChildren`**:项目树子节点(当前视图)。 关系是对象图的经脉。`GetRelationsWithNameResolved(name)` 按关系名解析目标对象;不同版本布局的差异几乎全部体现在"关系名 + 类型名"的组合上,这也让兼容 V13–V20 变得可行:**不要假设任何固定结构,全部按类型名/关系名动态探测,找不到就回退**。 ```csharp // 顶层门面:打开 + 全量解析 using (TiaProjectExplorer explorer = TiaProjectExplorer.Open(path)) // 自动 ParseAllObjects { // 或者懒加载:OpenLazy(path),然后按需 GetChildren / EnumerateTree foreach (StorageBusinessObject obj in explorer.EnumerateTree(explorer.ProjectRootObject, 12)) { Console.WriteLine(obj.TiaTypeName + " " + obj.Name); } IHighLevelObject highLevel = explorer.ConvertToHighLevel(obj); // → DataBlock 等高层对象 } ``` --- ## 4. 数据库对象模型与双流语义 这是整个逆向过程中**最反直觉、也最容易翻车**的一块,单独成节。 ### 4.1 双流:下载状态 vs 编辑索引 AP 数据库内部同时保存两个"流": | 流 | 含义 | |---|---| | **流 0** | 最后一次**下载到 PLC** 的版本(工程内时间戳可能很旧) | | **流 1** | 之后继续编辑的**最新索引**(含未下载的修改) | 同一个 `InstId` 在 `AllStorageObjects` 里可能出现多条记录,`StorageObjectDictionary` 后写入覆盖,**自然序下最新流胜出**。 > **实测案例(FB1DB vs Static_1)**:工程 `测试DB.ap18` 的 `SE_DB` 多实例数组成员,流 0(下载状态)里的旧接口名叫 `Static_1`(旧 FB 测试时的 5 输入版本),最新流里才是 `FB1DB`(新接口 2 输入 + ide/STA 区)。取错流,导出就变成 `PLC_2.SE_DB.Static_1[1].ind01`,与博途当前视图完全对不上。 因此默认导出语义 = **最新流**(博途当前视图)。而在线 PLC 运行的是下载状态,所以与在线真值对拍时切 `--gt` 模式,用流 0 语义: ```csharp /// 流 0 优先:反向重写字典,让流 0(下载状态)覆盖同名 InstId; /// 仅流 1 独有的对象保留流 1 版本。 public static void PreferStreamZeroObjects(TiaDatabaseFile db) { for (int i = db.AllStorageObjects.Count - 1; i >= 0; i--) { db.StorageObjectDictionary[db.AllStorageObjects[i].Header.StoreObjectId.InstId] = db.AllStorageObjects[i]; } } ``` ### 4.2 历史副本:文件里躺着"幽灵" 博途**不会物理删除**已删除的对象:块删除重建后 `InstId` 变化,旧副本残留在文件里;标签表被重建,旧表整表残留。实测: - tiaProject1(V17)的 `AllStorageObjects` 里有 **133 个 `Hymson_PLC` 历史副本**,全都不在当前项目树中; - `测试DB.ap18` 的某个标签表残留 **6189 × 3 份**副本,取旧副本会因 `TagTableData.Content` 关系缺失而**丢掉整表标签**; - `dbGen_1024` 曾被删重建:2026-08-02 之前是 10 号块,之后重编号为 66——旧副本的块号还是 10。 **结论:一切以"当前视图"为准,绝不相信 `AllStorageObjects` 的原始顺序或去重结果。** 两条权威路径: 1. **树走查**(`EnumerateTree`,沿 `ProjectTreeChildrenSorted` 前序遍历)——项目树的当前视图,天然剔除历史副本;PLC 发现、节点浏览走这里。 2. **按 InstId 取最新 + 按业务键再兜底去重**——符号导出走这里(因为符号枚举需要扫全部块/标签,且要容忍树异常),每 InstId 取最后一条,再按 `(PLC, 块号)` / `(PLC, 区, LID)` 二次去重,保留最后出现的副本。 --- ## 5. DB 块发现与排序 ### 5.1 类型过滤 DB 块的 `TiaTypeName` 有 4 种(两个名称空间 × 普通/工艺): ```csharp private static readonly HashSet<string> DbTypeNames = new HashSet<string>(StringComparer.Ordinal) { "Siemens.Simatic.Lang.Model.Blocks.DataBlockData", "Siemens.Simatic.PlcLanguages.Model.DataBlockData", "Siemens.Simatic.Lang.Model.Blocks.TechnologicalDataBlockData", "Siemens.Simatic.PlcLanguages.Model.TechnologicalDataBlockData" }; ``` ### 5.2 发现、去重、排序(与在线对象表顺序一致) 这一步直接决定导出顺序能否与在线浏览对拍,规则全部由对拍反推而来: ```csharp public List<DataBlockInfo> FindDataBlocks(TiaProjectExplorer explorer, int? onlyDb) { // ① 每 InstId 一条:默认(最新流)取最新副本;--gt 取流 0(已下载状态) Dictionary<long, StorageBusinessObject> perInstId = new Dictionary<long, StorageBusinessObject>(); foreach (StorageObject so in db.AllStorageObjects) { StorageBusinessObject sb = so as StorageBusinessObject; if (sb == null || !DbTypeNames.Contains(sb.TiaTypeName)) continue; long instId = sb.Header.StoreObjectId.InstId; if (LatestStream || !perInstId.ContainsKey(instId)) perInstId[instId] = sb; } // ② 转高层 DataBlock;解析所属 PLC(沿 Parent 父链上溯 S7ControllerTargetData) foreach (KeyValuePair<long, StorageBusinessObject> kv in perInstId) { DataBlock dataBlock = explorer.ConvertToHighLevel(kv.Value) as DataBlock; if (dataBlock == null) continue; DataBlockInfo info = new DataBlockInfo { InstId = ..., Number = dataBlock.Number, Name = dataBlock.Name, Block = dataBlock, Sb = kv.Value }; ResolvePlc(kv.Value, out info.PlcInstId, out info.PlcName); // 多 PLC 工程按 PLC 分组 result.Add(info); } // ③ 同一 (PLC, 块号) 多副本 → 保留最后出现的(最新流 = 博途当前视图,内容最全) Dictionary<long, Dictionary<int, DataBlockInfo>> byPlcNumber = ...; // 按 PLC 分组去重 // ④ 排序:PLC 按 InstId 升序;每 PLC 内主体按 InstId 升序(首次下载先后 = // 在线 GetObjects 枚举序),尾部块按块号升序 long fobOwner = FindFobGuidOwnerInstId(db); foreach (long plcKey in plcKeys) { foreach (DataBlockInfo info in byPlcNumber[plcKey].Values) { bool inTail = info.Block.InstanceOfName == "TO_PositioningAxis" || info.Block.InstanceOfName == "F_CTRL_1" || info.InstId == fobOwner; (inTail ? tail : main).Add(info); } main.Sort((a, b) => a.InstId.CompareTo(b.InstId)); tail.Sort((a, b) => a.Number.CompareTo(b.Number)); main.AddRange(tail); } return result; } ``` 值得单列的三个"硬骨头": - **尾部块判定**:轴工艺对象(`InstanceOfName == "TO_PositioningAxis"`)、`F_CTRL_1`(块 30000)以及携带 FOB 接口 guid 的 30043 块,在线对象表排在最后且按块号升序。其中 30043 与 30044 都是 SharedDB,**唯一区分信号**是 30043 的 `DBSource` 载荷(`<Root ...>` XML)携带 FOB(生成安全块)接口 guid `eadca34c-3962-43cd-a61c-e17423f8de4a`,全文件唯一。 - **F_SystemInfo_DB(30001)**:在线 PLC 内容是安全编译器**最新产物**(含签名结构),文件的流 0"下载状态"记录过时。解法:另找 `InterfaceVersionRootData` 且 `DBSource` 载荷含 `HardwareSignature` 成员、`UsedByBlock` 关系指向该块的那条(流 1 产物),用它的版本根覆盖接口解析。 - **多 PLC 工程**:`测试DB.ap18` 的 `PLC_2`/`PLC_OPCUA` 各有同号块,去重和排序都必须在 PLC 分组内做;行名恒加 PLC 名前缀(单 PLC 工程也加,输出格式统一)。 --- ## 6. 接口解析与成员树 ### 6.1 接口的物理形态 块的接口并不以直观的"成员列表"存储,而是挂在 `InterfaceVersionRootData` 上的 `InterfacePartData`(`Kind = DBSource`),载荷是**描述成员结构的 XML 文本**。库内 `InterfaceParserV14` 把它解析成强类型模型: ```csharp CodeBlockInterface └── MemberValues : List<MemberInstance> ├── Member : Member // Name / LID / RID / Section(节区) / BaseTiaDataType / │ // ParsedDataType(数组维度、原始类型名) / IsArray / │ // ArrayIndexes(元素下标) / IsAtChild(AT 视图) └── Children : List<MemberInstance> // 子成员(结构体/数组元素) ``` 每个 `Member` 携带一个 **LID**(逻辑 ID,Local ID)——这就是访问序列里那个十六进制段的来源。 ### 6.2 节区(Section)展平规则 块接口成员分节区存储:`Input / Output / InOut / Static / Temp / Constant / Base`。在线浏览的扁平列表对它们区别对待,`FlattenSections` 精确复刻: ```csharp /// Temp/Constant 跳过(在线不出现);Base 保留为成员级;其余节区子成员并入本层。 private List<MemberInstance> FlattenSections(List<MemberInstance> children, ...) { foreach (MemberInstance mi in children) { Section section = mi.Member as Section; if (section == null) { flat.Add(mi); continue; } if (section.Section == SectionType.Temp || section.Section == SectionType.Constant) continue; if (section.Section == SectionType.Base) { flat.Add(mi); continue; } flat.AddRange(FlattenSections(SafeChildren(mi), ...)); // Input/Output/… 透明并入 } return flat; } ``` 展平后**按 LID 升序**排序——在线分配的物理顺序。 ### 6.3 成员递归走查 `WalkMemberInstance` 是核心递归,每种成员形态有独立分支: ```csharp private void WalkMemberInstance(MemberInstance mi, string namePrefix, string accessPrefix, ...) { Member member = mi.Member; if (member is Section) // 只有 Base 区能走到这里:空名字组件 + 自身 LID 段 { string baseName = namePrefix + "."; // 名字加 ".." string baseAccess = accessPrefix + "." + mi.LID.ToString("X"); WalkLevel(SafeChildren(mi), baseName, baseAccess, ...); return; } if (member.IsAtChild && !LatestStream) return; // AT 视图成员:在线浏览不单独出现 if (!inBase && IsTechnologyObject(member)) return; // 技术对象(RID 0x0204 族)只在技术 DB 顶层展开 if (mi.IsArray && mi.ArrayIndexes == null) // 数组父实例:不产出行,逐个导出元素 { TreeSink?.OnContainer(...); // GUI:挂数组容器节点 foreach (MemberInstance element in mi.Children) WalkArrayElement(element, namePrefix, accessPrefix + "." + mi.LID.ToString("X"), ...); return; } string name = namePrefix + "." + mi.Name; string access = accessPrefix + "." + mi.LID.ToString("X"); if (LatestStream && IsDbReferenceMember(member) && member.Section == SectionType.InOut) { // InOut 区 DB 引用成员(RID 0x02080264):编译产物按引用传递, // AGL 文件加载输出 Pointer 平坦行,不展开被引用结构的子成员 EmitLeaf(name, access, 20 /* Pointer */, ...); return; } List<MemberInstance> kids = SafeChildren(mi); if (kids != null && kids.Count > 0) // 结构体:无行,子成员同层排序递归 { if (LatestStream && (TypeName(member) == "DTL" || TypeName(member) == "LDT")) EmitLeaf(name, access, TypeName(member) == "DTL" ? 67u : 66u, ...); // DTL/LDT 平坦行 WalkLevel(kids, name, access, ...); } else // 叶子 { uint type = LeafType(member); EmitLeaf(name, access, type, ...); } } ``` 数组元素走 `WalkArrayElement`:名字直接取 `element.Name`(`MemberInstance.Name` 已含 `[下标…]`),访问序列用**线性化元素 LID**;**结构体数组元素**在元素 LID 后追加常量 `.1` 再递归(与在线访问序列一致,见 7.4)。 --- ## 7. 访问序列(AccessSequence)构造规则 访问序列是与在线浏览对拍的核心,也是全项目最细的规则集。导出行的格式: ``` Name \t AccessSequence \t Softdatatype ``` 例如(DB4 `AxisUM01` 结构体的 `Velocity` 成员,经 Base 区): ``` AxisUM01..Velocity 8A0E0004.8.9 Real ``` 逐段拆解: ### 7.1 DB 块段:RelId ``` RelId = 0x8A0E0000 | 块号 (十六进制大写) ``` `8A0E0004` 就是 4 号块。 ### 7.2 成员段:LID 链 块下每深入一层追加一个 `.` + 成员 LID 的十六进制:`8A0E0004.8.9` 即"DB4 → Base 区(LID 8)→ Velocity(LID 9)"。 ### 7.3 Base 区的空名字组件 Base 区(UDT/FB 继承的基类型部分)在名字里表现为一个**空段**——`AxisUM01..Velocity` 中间是连续两个点。这正是 Base 区分支"名字加 `"."`"的原因。 ### 7.4 数组 - **元素 LID**:一维数组 = 线性化 0 基索引;**多维数组**不是简单线性化——在线侧按"末维字节对齐"计算 stride: ```csharp /// lid = Σ (idx_k - start_k) · S_k /// S_last = ceil(n_last · bitSize / 8) · 8 / bitSize (行按字节对齐) /// 更高维 stride 用原始元素数。 private static uint ArrayElementLid(MemberInstance element) { if (element.ArrayIndexes != null && element.ArrayIndexes.Length >= 2) { ParsedDataType parsed = element.Member.ParsedDataType; if (parsed != null && parsed.IsArray && parsed.ParsedDimensions != null) { int m = parsed.ParsedDimensions.Count; if (m == element.ArrayIndexes.Length && m >= 2) { int bitSize = ElementBitSize(parsed.BaseTiaDataType); if (bitSize > 0) { ulong lid = 0UL; for (int k = 0; k < m; k++) { ulong stride = 1UL; for (int j = k + 1; j < m; j++) { int countJ = endJ - startJ + 1; if (j == m - 1) stride *= (ulong)(((countJ * bitSize + 7) / 8) * 8 / bitSize); else stride *= (ulong)countJ; } lid += (ulong)(element.ArrayIndexes[k] - start) * stride; } return (uint)lid; } } } } return element.LID; } ``` 真值验证:`IOConfig.X[0..10,0..24,0..16]`(BBOOL,位宽 1)→ 末维 stride = `ceil(17·1/8)·8/1 = 24`,更高维 stride = 25 → `S2=24, S1=600`;`ProjectName[0..9,0..9]`(UInt)→ 线性化 0..99(末维 10 个 16 位元素恰好整字节对齐,两种算法一致)。 - **结构体数组元素**:元素 LID 之后追加常量 `.1`,再进入元素类型成员(例如 `8A0E0005.A.3.1.7`)。 ### 7.5 标签段:区 RelId 标签访问序列的头部是区的 RelId(第 9 节详述):`50.xxx`(I 区)、`51.xxx`(Q 区)、`52.xxx`(M 区)。 --- ## 8. 类型系统映射(Softdatatype) `Softdatatype` 是 S7 类型枚举(0–256),表与在线驱动 `Softdatatype.Types` 完全一致。摘录: | 值 | 名称 | 值 | 名称 | 值 | 名称 | |---|---|---|---|---|---| | 1 | Bool | 19 | String | 40 | BBOOL | | 2 | Byte | 20 | Pointer | 48 | LReal | | 4 | Word | 22 | Any | 63 | Variant | | 6 | DWord | 31 | IEC_TIMER | 66 | LDT | | 7 | DInt | 36 | Block_OB | 67 | DTL | | 8 | Real | 37 | Block_UDT | 208 | DB_ANY | ### 8.1 输出判定 只有在线浏览支持的类型才产出符号行(`SupportedTypes` 白名单),**不支持的类型自身无行,但其子成员照常递归**——所以 `Struct`/`Array`/`IEC_TIMER` 这些容器型"不出现"是正常的。 ### 8.2 优化块的 Bool → BBOOL ```csharp private void EmitLeaf(string name, string access, uint type, ...) { if (type == 1U && optimized && !insideIec) type = 40U; // 在线浏览:优化块中的 Bool → BBOOL if (type == 0 || !SupportedTypes.Contains(type)) { if (type != 63U || !LatestStream) return; // Variant 仅 AGL 语义下输出平坦行 } rows.Add(name + "\t" + access + "\t" + typeName); TreeSink?.Emit(treeName ?? name, access, type, typeName, depth); } ``` `insideIec` 标志来自 `IsIecFamily`:IEC 定时器/计数器族(`TON_TIME`/`IEC_TIMER`/`CTU`…按类型名判定,因为 `BaseTiaDataType` 都是 `Block_UDT` 分不出来)以及系统数据类型 `RDREC`/`WRREC`(SFB52/53)内部的 Bool,**在线导出仍是 Bool,优化块也不升级**。注意反向特例:安全定时器 F-TON 等**用户 FB** 同属 RID 0x0203 族,但其内部 Bool 在线是 BBOOL——所以不能按 RID 整族判定,必须按类型名。 ### 8.3 特殊平坦行(仅最新流/AGL 语义) - **DB 引用成员**(RID `0x02080264`,InOut 区):如 `Modbus_Comm_Load_DB` 的 `MB_DB`,编译产物按引用传递 → 输出 `Pointer` 平坦行,不展开(Static 区同名成员则内联展开)。 - **DTL/LDT**:除展开字段外还输出成员自身的平坦行(`timeMeasure.start` 等)。 - **Variant**:在线浏览不产出,AGL 文件加载输出平坦行(`RDREC.RECORD`、`BUFFER`、`CONNECT` 等)。 --- ## 9. PLC 标签表(I/Q/M/C/T 区) 标签表的物理形态与 DB 完全不同:`EAMTZTagTableData`(标签表)通过关系挂 `EAMTZTagData`(每条标签),标签再挂 `TagAddress` 子对象(`RuntimeIdentifier` = 区枚举,`LocalIdentifier` = LID): ``` 区枚举:Input=80, Output=81, Flags=82, Counters=83, Timers=84 区 RelId = (int)area - 30 → I=50, Q=51, M=52, C=53, T=54 ``` ```csharp foreach (StorageBusinessObject rel in table.GetAllRelationsResolved()) { if (rel == null || rel.TiaTypeName != "Siemens.Automation.DomainModel.EAMTZTagData") continue; TagAddress addr = rel.GetChild<TagAddress>(); if (addr == null) continue; Area area = (Area)addr.RuntimeIdentifier; if (area != Area.Input && area != Area.Output && area != Area.Flags && area != Area.Counters && area != Area.Timers) continue; byArea[(int)area - 80].Add(new TagEntry { Rel = rel, Area = area, LId = (int)addr.LocalIdentifier, PlcName = plcName }); } // 每 (PLC, 区):按 LID 去重(旧表残留 → 保留最后)+ LID 升序;区序 I→Q→M→C→T ``` 单条标签的输出分两种: 1. **平坦标签**:`dtRef.Name` 直接可解析为基类型(`ParseBaseType`,自动剥 `[N]` 长度后缀如 `String[254]`)→ 自身一行(`optimized=false`,Bool 不升级)。 2. **结构化标签**:经 `DataTypeRef` 关系取 UDT/系统数据类型定义,走库内标准转换路径(`ConvertToHighLevel` → `BaseBlock.Interface`)拿到 UDT 接口,再复用同一套 `WalkLevel` 递归导出成员(优化规则与块一致)。 两个工程细节: - **UDT 转换缓存**:同一 UDT 只转换一次。库内转换器对同一对象的重复 `Convert` 会触发解析器二次解析的 `StackOverflow`(转换选项值相等性失效导致包装器缓存永不命中),必须自己按 `InstId` 缓存接口。 - **命名**:`IArea`/`QArea`/`MArea`/`CArea`/`TArea` 前缀是**在线浏览命名**,与工程内标签表名(常为中文"默认变量表")无关;多 PLC 工程再叠 PLC 名前缀:`PLC_2.MArea.StartPB`。 --- ## 10. 带点号名称的引号规则 符号名里**带点号**很常见:`I0.0`、`Clock_2.5Hz`、`DB3.DBW0`。在"按 `.` 分层"的树路径里,这类名字必须作为**不可分割的一段**处理。规则与访问序列的转义一致: > 整段全为 `[0-9A-Za-z_]` 才不加引号;其余(含 `.`、`-`、中文、数字开头等)用双引号包裹。 ```csharp private static string TiaTreeSegment(string seg) { if (seg.Length > 0) { bool plain = true; foreach (char c in seg) { if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_')) { plain = false; break; } } if (plain) return seg; } return '"' + seg + '"'; } ``` 于是 `Clock_2.5Hz` 的树路径段写作 `"Clock_2.5Hz"`,切分路径时**引号内的 `.` 不算分隔符**: ```csharp private static List<string> SplitQuotedPath(string path) { bool inQuote = false; foreach (char c in path) { if (c == '"') { inQuote = !inQuote; continue; } if (c == '.' && !inQuote) { segments.Add(cur); cur.Clear(); continue; } cur.Append(c); } segments.Add(cur.ToString()); return segments; } ``` **注意:行输出(Name 列)不使用引号**——引号仅用于树路径分段,两者互不影响。 --- ## 11. 树构建与离线浏览 GUI ### 11.1 行输出与树构建共用一个遍历 枚举器的唯一改动点是一个可选钩子: ```csharp public interface ISymbolSink { void Emit(string name, string access, uint type, string typeName, int depth); void OnContainer(string name, string access, int depth, bool isArrayParent); } ``` CLI 路径 `TreeSink == null`,全部 no-op,行输出与挂树前**逐字节一致**(重构门槛:`cmp` 通过才放行)。GUI 设置 `TreeBuildingSink` 后,每个叶子行和数组父容器各触发一次回调。 ### 11.2 TreeBuildingSink:按绝对路径建树 不依赖 depth 的父栈(块路径与标签路径的 depth 都从 0 起,栈式挂接在两个入口并存时会错挂),**按名字路径本身建树**: - 名字按引号感知的 `.` 分段,中间缺失段自动补建(结构体容器由子行自动创建); - 空段(Base 区的 `..`)显示为 `Base`; - 数组元素段(`名[下标…]`)自动挂到先到的数组父容器(`OnContainer` 建的 `IsArrayParent` 节点)之下; - 块成员前缀(`PLC.块名.`)通过 `prefixToStrip` 从根下剥离——块节点已由 GUI 树建立。 ### 11.3 TiaOfflineBrowser WinForms(net472)离线浏览 GUI,形态对齐在线浏览器的树 + 数据区 + 状态栏布局: - **树**:根 = 工程(名字 + 版本)→ 每 PLC 一个节点 → 该 PLC 的 DB 块 + 5 个区节点(Inputs/Outputs/Merker/S7Timers/S7Counters);`TreeNode.Tag` 存 payload(InstId/RelId/访问序列); - **懒加载**:打开即显示(`OpenLazy`),块/区节点首次展开时后台 `Task` 解析,整块一次性挂出成员树;状态栏显示 `parsing...`; - **选中叶子** → 符号名(`escapeTiaString` 重建)+ 符号地址(访问序列)+ 类型名;选中 PLC 节点 → 状态栏显示 PLC 信息四字段(第 12 节); - **无头验证**:`--smoke` 模式 DFS 遍历全部叶子写 `smoke_report.txt`,CI 可读。 smoke 报告片段(ValveCtr.ap14,V14 工程): ``` # Project: ValveCtr Version: 1400.100.1201.1 # PLCs: 1 PLC PLC_1 (0x56F) Type=CPU 1214C DC/DC/DC MLFB=6ES7 214-1AG40-0XB0 Fw=V4.2 Family=S7-1200 Net IP=192.168.0.1 Mask=255.255.255.0 Router=192.168.0.1 MAC=0000010600080000 PN=plc_1 # Blocks: 19 DB15 Modbus_Master_DB nodes=3055 leaves=3022 ... # Tags tree: nodes=20 leaves=17 # Blocks total: nodes=15924 leaves=15715 SMOKE-OK ``` --- ## 12. PLC 信息提取:与在线驱动四字段对齐 在线驱动(SiemensCommDriver)的 `S7PlcInfo` 有四个字段:订货号 `S7PlcMLFB`、家族 `S7PlcFamily`、固件 `S7PlcFirmware`、类型名 `S7PlcTypeName`,通过 AGL `Symbolic_GetS7PlcFamily/Firmware/MLFB/TypeName` 获取。离线侧从工程文件取同样四字段(`PlcInfoExtractor`): ### 12.1 定位 PLC 对象 类型名 `"Siemens.Simatic.HwConfiguration.Model.S7ControllerTargetData"`(每 PLC 一个)。**权威路径是项目树走查**(`EnumerateTree` 前序收集 + 按 InstId 去重)——理由见 4.2 的历史副本陷阱;树不可用时回退 `AllStorageObjects` 按 InstId 取最后一条。 ### 12.2 四字段解析链(全程不抛,逐级回退) ```csharp // ① 设备:S7ControllerTargetData.Parent == DeviceData(实测 'S7-1200 station_1') // 回退 CoreObject.Target / BaseDeviceItemData.Parent 关系(不同版本布局) // ② CPU 设备项:target 自身即 CPU 设备项,其 child DeviceItemData 带权威值 DeviceItemData di = target.GetChild<DeviceItemData>(); rec.MLFB = di.OrderNumber; // 如 6ES7 517-3FP00-0AB0 rec.Firmware = di.FwVersion; // 如 V2.8 rec.TypeName = di.InvariantTypeName; // 如 CPU 1517F-3 PN/DP // 值全空时回退 target/设备 DeviceItems 内的 CPU 项(旧布局),再回退 DeviceData.InvariantTypeName // ③ Family:设备 CoreAttributes.Subtype 前缀 → CPU 项 subtype 前缀 → TypeName 内容 → "UNDEFINED" // 显示格式与在线 S7PlcFamily.ToString().Replace("_","-") 一致 ``` | Subtype 前缀 | Family | |---|---| | `S71500…` | S7-1500 | | `S71200…` | S7-1200 | | `S7300…` | S7-300 | | `S7400…` | S7-400 | ### 12.3 网络信息 `target` 的 `DeviceItems` 关系平铺含接口项,按 vendor 六种接口 subtype 过滤(`S71500.CPU.Interface.IE`、`S71200.CPU.IeInterface`、`PC.CPU.Interface.IE.Plus`、`HMI.Interface`、`S71500.CPU.Interface.DP`、`HMI.CP.PB`),逐项 `NetworkInformationConverter.ParseNetwork` 取 IP/掩码/路由/MAC/PN;接口项自身无 `NodeIPAddress` 载荷时(实测 ped 只有 `Pn*` 标志),IP 在关联的 `NodeData.DeviceItemNodes` 上。 实测样例:tiaProject1 → `Fw=V2.8 / 6ES7 517-3FP00-0AB0 / CPU 1517F-3 PN/DP`;tiaProject27 → `Fw=V4.7 / 6ES7 212-1AE40-0XB0 / CPU 1212C DC/DC/DC`。多 PLC 工程一等公民:`ExtractAll` 返回全部记录(InstId 升序),GUI 状态栏随选中 PLC 切换。 --- ## 13. 验证:双真值对拍与 25 工程矩阵 "解析得对不对"不能靠自圆其说,这个项目用了**两条独立真值链**交叉验证: ### 13.1 真值链 A:在线浏览(GT) 用 S7CommPlus 在线浏览组件连接用户自己的 PLC(192.168.0.250),导出 `plc_ground_truth.txt`。离线导出切 `--gt` 模式(流 0 已下载语义)与其逐行对拍: - **tiaProject1(8 台 PLC 的 V17 工程)**:GT-only = 0(在线有而离线没有的:零);OURS-only = 8217(8 个 PLC 的陈旧块 `8A0E0064/012C/0190/03F2/03FC/0406/0410/041A`——PLC 上运行的是旧版文件,这些块**离线不可修复**,属于已知差异);diff 8402 行全部可解释。 ### 13.2 真值链 B:AGL 文件加载 AGL 是西门子官方文件加载组件(我们只在验证环境用它做参照,不进入运行链路)。对拍脚本统一变换双方命名(AGL 去引号、`PLC_x.Blocks.`→`PLC_x.`、标签表名→区名),块与标签分开 diff: | 工程 | 规模 | 我方行数(块+标签) | AGL 对拍 | |---|---|---|---| | exp2(Test1.ap17) | 717 对象 | 322(316+6) | 0/0 | | exp3(Test2.ap17) | 1212 对象 | 12014(12012+2) | 0/0 | | exp4(20190425.ap15) | 2919 对象 | 4075(3724+351) | 0/0 | | exp5(测试DB.ap18) | 3764 对象 | 38488(38448+40) | 0/0 | | exp6(ValveCtr.ap14) | 1914 对象 | **15732(15715+17)** | 0/0 | - **版本矩阵(tiaProject7–15,V13×2 / V14×2 / V15×2 / V20×3)**:全部打开 + 解析 + 导出成功,AGL 对拍 0/0。**包括 V20**——连 2017 年版 AGL 参考组件都能加载 ap20,我们的解析器同样没问题。 - **25 工程全量矩阵**:0 真实缺口。AGL-only 未命中 100% 为 `S7_Pointer` 叶子(AGL 文件加载的已知行为差异);p18 的 CC1 安全子系统 AGL 漏读——我方导出更全。 - **V19 专项**:25 个 DB 的名号全对(优化块偏移 -1 是正确行为,非缺陷)。 ### 13.3 回归门槛(每次改动必跑) ``` dotnet msbuild sln -t:Rebuild → 0 错误 VS2017 MSBuild sln -t:Rebuild → 0 错误 TiaSymbolExport ValveCtr.ap14 → 15736 行,与基线 cmp 字节一致 TiaOfflineBrowser --smoke ValveCtr.ap14 → SMOKE-OK(15715+17=15732) ``` 字节级 `cmp` 是搬移/重构的硬门槛——任何"看起来等价"的重构都必须在导出结果上与基线**逐字节一致**才算通过。 --- ## 14. 实战中踩过的坑 按踩坑代价排序: 1. **双流选错**(4.1):默认取流 0 导致 `Static_1` 旧名导出。→ 默认最新流,`--gt` 显式切流 0 语义。 2. **历史副本**(4.2):133 个幽灵 PLC、6189×3 标签表副本、重编号的 dbGen_1024。→ 一切以当前视图为准;按 InstId 取最新后还要按业务键二次去重。 3. **接口关系缺失的旧副本**:取旧标签表副本会丢**整表**标签(`Content` 关系缺失),症状是"标签怎么少了"而非报错。 4. **多维数组元素 LID**:线性化在末维非字节对齐时全错(BBOOL 数组)。→ 末维按 `ceil(n·bits/8)·8/bits` 计算 stride,真值反推。 5. **结构体数组元素的 `.1` 后缀**:在线访问序列在元素 LID 后固定追加 `.1`,漏掉就是一片 diff。 6. **优化块 Bool→BBOOL 的例外**:IEC 定时器/计数器内部不升级;但 F-TON 等用户 FB(同 RID 0x0203 族)要升级——按 RID 整族判定必错,必须按类型名。 7. **UDT 重复转换 StackOverflow**:转换器缓存失效路径,自建 InstId 缓存。 8. **控制台 GBK 乱码**:中文 Windows 下 stdout 是 GBK,验证一律读**输出文件**不读控制台。 9. **V13/V14 无 `<ProjectVersion>`**:容器头 XML 缺元素,回退二进制文件头 `ProductVersion`(V13=1300.109.701.1、V14=1400.0.3101.1)。 10. **30001/30043 特殊块**:F_SystemInfo_DB 要流 1 版本根(含 HardwareSignature);30043 vs 30044 只能靠 DBSource 载荷里的 FOB guid 区分。 11. **反编译死字段的教训**(库维护):CS0169(从未使用)可安全删;CS0414(只写不读)纯赋值可中和删除;**CS0649(只读不写)绝不能删**——它被代码读取(读默认值),读取常发生在其他类里,按声明类扫描引用会漏检。 --- ## 15. 最小可运行示例 ```csharp using System; using System.Collections.Generic; using TiaFileFormat; using TiaFileFormat.Wrappers.CodeBlocks.Symbols; using TiaFileFormat.Wrappers.Controller; class Program { static int Main(string[] args) { using (TiaProjectExplorer explorer = TiaProjectExplorer.OpenLazy(args[0])) { Console.WriteLine("Project: " + explorer.ProjectName + " Version: " + explorer.ProjectVersion); explorer.ParseAllObjects(); // 全量解析(大工程可放后台线程) // ① DB 块符号表 TiaSymbolEnumerator en = new TiaSymbolEnumerator(); List<string> rows = new List<string>(); foreach (TiaSymbolEnumerator.DataBlockInfo block in en.FindDataBlocks(explorer, null)) en.ExportBlock(block, rows, null); // ② I/Q/M/C/T 标签表 en.ExportTagTables(explorer, rows, null); foreach (string row in rows) Console.WriteLine(row); // Name \t AccessSequence \t Softdatatype // ③ PLC 信息(对齐在线 S7PlcInfo 四字段) foreach (PlcInfoExtractor.PlcInfoRecord rec in PlcInfoExtractor.ExtractAll(explorer)) { Console.WriteLine(rec.Name + " | " + rec.MLFB + " | " + rec.Family + " | " + rec.Firmware + " | " + rec.TypeName); foreach (string net in rec.Networks) Console.WriteLine(" " + net); } } return 0; } } ``` 输出示例: ``` Project: ValveCtr Version: 1400.100.1201.1 PLC_1.Modbus_Comm_Load_DB.MB_DB 8A0E000A.14 Pointer PLC_1.Modbus_Master_DB.MB_DB.S_PORT 8A0E000F.17.9 Word PLC_1.MArea.System_Byte 52.9 Byte PLC_1.MArea.FirstScan 52.A Bool PLC_1 | 6ES7 214-1AG40-0XB0 | S7-1200 | V4.2 | CPU 1214C DC/DC/DC IP=192.168.0.1 Mask=255.255.255.0 Router=192.168.0.1 MAC=0000010600080000 PN=plc_1 ``` --- ## 16. demo下载 <a href="elink@39aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8W2k6g2)9J5k6h3y4G2L8g2)9J5c8X3I4A6M7X3y4&6i4K6u0r3g2r3W2S2f1s2u0G2K9X3g2U0N6q4m8S2M7Y4y4W2M7W2)9J5k6h3N6A6N6l9`.`.">demo下载</a> ## 17. 总结 这套方案的本质是**把博途工程文件当作一个自描述数据库来读**,在对象图上精确复刻在线浏览的符号语义。回顾几个关键判断: - **不依赖任何西门子运行时**:`.apXX` = ZIP + 二进制对象库,纯托管代码可读;兼容 V13–V20。 - **正确性的唯一标准是真值对拍**:在线浏览(GT)与 AGL 文件加载两条独立真值链,25 工程矩阵 0 真实缺口;字节级 `cmp` 作为每次重构的硬门槛。 - **最难的不是解析格式,而是语义**:双流、历史副本、数组 LID 的字节对齐、Base 区空段、`.1` 后缀、Bool→BBOOL 的例外族——每一个都是对拍逼出来的规则。 - **架构上值得复用的模式**:类型名/关系名动态探测 + 逐级回退(版本兼容的关键);单一枚举核心 + 可选树钩子(行输出与 GUI 共用事实源);全程容错(生产数据不保证完整)。 最终交付:一个 1666 文件的零依赖解析库、一个符号表 CLI 导出器(15732 行工程 0 缺口)、一个离线浏览 GUI,以及一套可放进 CI 的回归门控。 **代码仓库与更多细节**:本文对应的实现位于 `TiaFileFormat`(库)、`TiaSymbolExport`(CLI)、`TiaOfflineBrowser`(GUI)三个项目;文中全部规则均有真值案例支撑,探针模式(`--probe`/`--tagprobe`/`--plc`)可以输出每一条判定依据。 --- *本文所有真值数据均为自有工程与自有设备实测;`plc_ground_truth.txt` 由用户在线浏览自有 PLC(192.168.0.250)生成。*
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-9-2 16:52 被Coder_shen编辑 ,原因:
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Coder_shen
4
发帖
4
回帖
10
RANK
关注
私信
他的文章
基于C#实现CodesysV3符号变量访问
253
[原创]C#实现西门子S71200与S71500控制器优化DB块符号访问
5048
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
158
[原创]西门子 S7CommPlus 协议鉴权算法原理与流程详解
2333
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部