首页
社区
课程
招聘
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
发表于: 14小时前 52

基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树

14小时前
52

基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树

不装博途、不连 PLC、不引用任何西门子运行时组件,纯托管 C# 直接解析 .apXX 工程文件,重建与 S7 在线浏览完全一致的符号变量树。

关键词:TIA Portal、.ap14/.ap17/.ap20、PEData.plf、符号表、AccessSequence、零依赖


目录

  1. 问题背景
  2. 整体架构
  3. 文件格式逆向:从 .apXX 到对象图
  4. 数据库对象模型与双流语义
  5. DB 块发现与排序
  6. 接口解析与成员树
  7. 访问序列(AccessSequence)构造规则
  8. 类型系统映射(Softdatatype)
  9. PLC 标签表(I/Q/M/C/T 区)
  10. 带点号名称的引号规则
  11. 树构建与离线浏览 GUI
  12. PLC 信息提取:与在线驱动四字段对齐
  13. 验证:双真值对拍与 25 工程矩阵
  14. 实战中踩过的坑
  15. 最小可运行示例
  16. 总结

1. 问题背景

在工业上位机场景中,我们经常需要一个能力:拿到一个博途(TIA Portal)项目文件,在没有任何西门子软件环境的前提下,知道这个项目里有哪些 PLC、每个 PLC 有哪些 DB 块、每个块的接口长什么样、有哪些 I/Q/M 区标签。典型诉求包括:

  • 离线浏览:出差现场没有博途授权,甚至没有电脑装博途,但要查工程里的变量定义;
  • 数据采集对接:上位机要读写 DB 块中的变量,必须先知道变量的绝对访问地址(如 DB4AxisUM01 结构体的 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 能否打开,再按扩展名与内部条目名分类。核心代码骨架:

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 有:

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 变得可行:不要假设任何固定结构,全部按类型名/关系名动态探测,找不到就回退

// 顶层门面:打开 + 全量解析
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 之后继续编辑的最新索引(含未下载的修改)

同一个 InstIdAllStorageObjects 里可能出现多条记录,StorageObjectDictionary 后写入覆盖,自然序下最新流胜出

实测案例(FB1DB vs Static_1):工程 测试DB.ap18SE_DB 多实例数组成员,流 0(下载状态)里的旧接口名叫 Static_1(旧 FB 测试时的 5 输入版本),最新流里才是 FB1DB(新接口 2 输入 + ide/STA 区)。取错流,导出就变成 PLC_2.SE_DB.Static_1[1].ind01,与博途当前视图完全对不上。

因此默认导出语义 = 最新流(博途当前视图)。而在线 PLC 运行的是下载状态,所以与在线真值对拍时切 --gt 模式,用流 0 语义:

/// 流 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 种(两个名称空间 × 普通/工艺):

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 发现、去重、排序(与在线对象表顺序一致)

这一步直接决定导出顺序能否与在线浏览对拍,规则全部由对拍反推而来:

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"下载状态"记录过时。解法:另找 InterfaceVersionRootDataDBSource 载荷含 HardwareSignature 成员、UsedByBlock 关系指向该块的那条(流 1 产物),用它的版本根覆盖接口解析。
  • 多 PLC 工程测试DB.ap18PLC_2/PLC_OPCUA 各有同号块,去重和排序都必须在 PLC 分组内做;行名恒加 PLC 名前缀(单 PLC 工程也加,输出格式统一)。

6. 接口解析与成员树

6.1 接口的物理形态

块的接口并不以直观的"成员列表"存储,而是挂在 InterfaceVersionRootData 上的 InterfacePartDataKind = DBSource),载荷是描述成员结构的 XML 文本。库内 InterfaceParserV14 把它解析成强类型模型:

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 精确复刻:

/// 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 是核心递归,每种成员形态有独立分支:

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.NameMemberInstance.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:
/// 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=600ProjectName[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

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_DBMB_DB,编译产物按引用传递 → 输出 Pointer 平坦行,不展开(Static 区同名成员则内联展开)。
  • DTL/LDT:除展开字段外还输出成员自身的平坦行(timeMeasure.start 等)。
  • Variant:在线浏览不产出,AGL 文件加载输出平坦行(RDREC.RECORDBUFFERCONNECT 等)。

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
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/系统数据类型定义,走库内标准转换路径(ConvertToHighLevelBaseBlock.Interface)拿到 UDT 接口,再复用同一套 WalkLevel 递归导出成员(优化规则与块一致)。

两个工程细节:

  • UDT 转换缓存:同一 UDT 只转换一次。库内转换器对同一对象的重复 Convert 会触发解析器二次解析的 StackOverflow(转换选项值相等性失效导致包装器缓存永不命中),必须自己按 InstId 缓存接口。
  • 命名IArea/QArea/MArea/CArea/TArea 前缀是在线浏览命名,与工程内标签表名(常为中文"默认变量表")无关;多 PLC 工程再叠 PLC 名前缀:PLC_2.MArea.StartPB

10. 带点号名称的引号规则

符号名里带点号很常见:I0.0Clock_2.5HzDB3.DBW0。在"按 . 分层"的树路径里,这类名字必须作为不可分割的一段处理。规则与访问序列的转义一致:

整段全为 [0-9A-Za-z_] 才不加引号;其余(含 .-、中文、数字开头等)用双引号包裹。

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",切分路径时引号内的 . 不算分隔符

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 行输出与树构建共用一个遍历

枚举器的唯一改动点是一个可选钩子:

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 四字段解析链(全程不抛,逐级回退)

// ① 设备: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 网络信息

targetDeviceItems 关系平铺含接口项,按 vendor 六种接口 subtype 过滤(S71500.CPU.Interface.IES71200.CPU.IeInterfacePC.CPU.Interface.IE.PlusHMI.InterfaceS71500.CPU.Interface.DPHMI.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. 最小可运行示例

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. 总结

这套方案的本质是把博途工程文件当作一个自描述数据库来读,在对象图上精确复刻在线浏览的符号语义。回顾几个关键判断:

  • 不依赖任何西门子运行时.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)生成。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回