-
-
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
-
发表于: 14小时前 52
-
基于 C# 零依赖西门子 API 实现从博途工程文件解析符号变量树
不装博途、不连 PLC、不引用任何西门子运行时组件,纯托管 C# 直接解析
.apXX工程文件,重建与 S7 在线浏览完全一致的符号变量树。关键词:TIA Portal、.ap14/.ap17/.ap20、PEData.plf、符号表、AccessSequence、零依赖
目录
- 问题背景
- 整体架构
- 文件格式逆向:从 .apXX 到对象图
- 数据库对象模型与双流语义
- DB 块发现与排序
- 接口解析与成员树
- 访问序列(AccessSequence)构造规则
- 类型系统映射(Softdatatype)
- PLC 标签表(I/Q/M/C/T 区)
- 带点号名称的引号规则
- 树构建与离线浏览 GUI
- PLC 信息提取:与在线驱动四字段对齐
- 验证:双真值对拍与 25 工程矩阵
- 实战中踩过的坑
- 最小可运行示例
- 总结
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 二进制数据库 + 惰性对象模型) │
└─────────────────────────────────────────────────────────────┘
关键设计决策:
- 单一事实源。符号枚举算法(
TiaSymbolEnumerator)只有一份:CLI 导出文本符号表走它,GUI 建树也走它(通过ISymbolSink钩子挂树)。保证行输出与树显示永远一致。 - 惰性加载 + 显式全量双模式。
OpenLazy打开后不解析任何对象,浏览树按需解析单层;ParseAllObjects()显式全量解析(大工程可能耗时数十秒,放后台线程)。 - 全程容错。工程文件是生产数据,任何字段都可能缺失。所有解析路径不抛异常,失败留空或跳过,保证 GUI 永不崩溃。
- 零西门子依赖。不引用 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 | 之后继续编辑的最新索引(含未下载的修改) |
同一个 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 语义:
/// 流 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 的原始顺序或去重结果。 两条权威路径:
- 树走查(
EnumerateTree,沿ProjectTreeChildrenSorted前序遍历)——项目树的当前视图,天然剔除历史副本;PLC 发现、节点浏览走这里。 - 按 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(生成安全块)接口 guideadca34c-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 把它解析成强类型模型:
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.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:
/// 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
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
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
单条标签的输出分两种:
- 平坦标签:
dtRef.Name直接可解析为基类型(ParseBaseType,自动剥[N]长度后缀如String[254])→ 自身一行(optimized=false,Bool 不升级)。 - 结构化标签:经
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_]才不加引号;其余(含.、-、中文、数字开头等)用双引号包裹。
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 网络信息
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. 实战中踩过的坑
按踩坑代价排序:
- 双流选错(4.1):默认取流 0 导致
Static_1旧名导出。→ 默认最新流,--gt显式切流 0 语义。 - 历史副本(4.2):133 个幽灵 PLC、6189×3 标签表副本、重编号的 dbGen_1024。→ 一切以当前视图为准;按 InstId 取最新后还要按业务键二次去重。
- 接口关系缺失的旧副本:取旧标签表副本会丢整表标签(
Content关系缺失),症状是"标签怎么少了"而非报错。 - 多维数组元素 LID:线性化在末维非字节对齐时全错(BBOOL 数组)。→ 末维按
ceil(n·bits/8)·8/bits计算 stride,真值反推。 - 结构体数组元素的
.1后缀:在线访问序列在元素 LID 后固定追加.1,漏掉就是一片 diff。 - 优化块 Bool→BBOOL 的例外:IEC 定时器/计数器内部不升级;但 F-TON 等用户 FB(同 RID 0x0203 族)要升级——按 RID 整族判定必错,必须按类型名。
- UDT 重复转换 StackOverflow:转换器缓存失效路径,自建 InstId 缓存。
- 控制台 GBK 乱码:中文 Windows 下 stdout 是 GBK,验证一律读输出文件不读控制台。
- V13/V14 无
<ProjectVersion>:容器头 XML 缺元素,回退二进制文件头ProductVersion(V13=1300.109.701.1、V14=1400.0.3101.1)。 - 30001/30043 特殊块:F_SystemInfo_DB 要流 1 版本根(含 HardwareSignature);30043 vs 30044 只能靠 DBSource 载荷里的 FOB guid 区分。
- 反编译死字段的教训(库维护):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)生成。