-
-
深挖罗克韦尔 .acd 工程归档:不连 PLC,从二进制文件还原完整变量树
-
发表于: 12小时前 107
-
深挖罗克韦尔 .acd 工程归档:不连 PLC,从二进制文件还原完整变量树
本文详细拆解 RSLogix 5000 / Studio 5000 工程归档文件(.acd)的逆向解析过程:
文件容器结构、TagInfo.XML 变量树重建、Comps.Dat 二进制记录中的外部访问权限与
控制器信息,以及"离线树必须与在线树逐项一致"这一验收标准背后的工程决策。
实现为纯 C#(.NET 4.0,零第三方依赖),已在 SoftLogix5800 v21.11 工程
(1789-L60/A,785 个符号)上与在线 browse 逐项对比验证。
目录
- 为什么需要"离线变量树"
- 为什么是 .acd,而不是 TXT / L5X 导出
- .acd 容器格式解剖:它不是 ZIP
- 归档里有什么:TagInfo.XML 才是变量树的正主
- 变量树重建:类型展开的三条规则
- 反直觉的实测结论:维度只能信 XML
- 外部访问权限:藏在 Comps.Dat 二进制记录里
- 控制器信息也不能丢
- 验收标准:与在线树 785 符号逐项 diff 为空
- 实现与工程决策小结
1. 为什么需要"离线变量树"
开发 HMI / 上位机 / 数据采集程序时,有一个非常高频的场景:PLC 不在手边,但工程文件在。
- 出差/居家开发,控制器锁在客户现场机柜里;
- 产线停机窗口宝贵,不想为了"看一眼有哪些标签"去占连接;
- 做 HMI 画面布局,只需要标签的名称、类型、结构,根本不需要它的值。
如果通讯库只支持"连上 PLC → 在线列举符号",这些场景就全被卡死。所以变量树加载必须有两条路径:在线 browse(Symbol Object 0x55)与离线文件解析。而离线路径的数据源选择,直接决定了树的质量上限。
2. 为什么是 .acd,而不是 TXT / L5X 导出
离线树的数据源先后经历了三代,每一代都是信息完整性的升级:
| 数据源 | 获取方式 | 问题 |
|---|---|---|
| TXT 导出 | 从 PLC 在线导出或 RSLogix 另存 | 信息最少,格式随版本漂移,早已弃用 |
| L5X 导出 | RSLogix"另存为 L5X" | 本质是导出行为的快照:预定义类型(TIMER 等)和模块类型(AB: 前缀)被折叠成 STRUCT 叶子,隐藏成员丢失,与在线真实树形不一致 |
| .acd 工程归档 | RSLogix 直接保存的工程文件 | Rockwell 自己维护的权威元数据,成员/隐藏/位/维度全量在场 |
关键点在于:.acd 不是"又一种导出格式",它就是工程本身。RSLogix 每次保存都会在里面维护一份完整的 TagInfo.XML(Rockwell 内部使用,不在任何导出菜单里露脸),数据类型定义和标签列表的完整性高于任何用户可见的导出物。本文的主角就是它。
3. .acd 容器格式解剖:它不是 ZIP
第一个直觉是拿解压软件打开 .acd——失败。它不是 ZIP、不是 7z,而是 Rockwell 的私有归档,整体三段式:
┌─ 文本头 ────────────────────────────────┐
│ "\r\nThis file was generated by..." │ 纯文本声明段
├─ 文件数据区 ────────────────────────────┤
│ file_0 数据 │ 每个文件可能独立 gzip 压缩
│ file_1 数据 │ (魔数 1F 8B)
│ ... │
├─ 尾部文件表 ────────────────────────────┤
│ u32 文件数 | u32 未知 │ 固定 8 字节
│ 记录 × N,每条 528 字节: │
│ 520 字节 UTF-16LE 文件名(\0\0 截断)│
│ u32 数据长度 | u32 数据偏移 │
└──────────────────────────────────────────┘
解析顺序是从文件尾往前读:最后 8 字节取出文件数 N,文件表起点 = 文件大小 − N×528 − 8,然后顺序读 N 条 528 字节记录。每条记录定位逻辑很简单:
int noFiles = BitConverter.ToInt32(data, (int)fileSize - 8);
if (noFiles <= 0 || noFiles > 10000)
throw new CIPException("ACD 文件表损坏."); // 基本 Sanity Check
long recordOffset = fileSize - (long)noFiles * 528 - 8;
// 每条记录:520 字节 UTF-16LE 名(双 \0 结束)+ u32 长度 + u32 偏移
拿到(名称, 长度, 偏移)三元组后,按偏移取出数据区切片,检查前两个字节是不是 1F 8B——是则跳过 10 字节 gzip 头,用 DeflateStream 解 raw deflate 负载:
// 魔数 1F 8B → gzip;跳过 10 字节头后交给 DeflateStream(.NET 内置,零依赖)
using (DeflateStream deflate = new DeflateStream(input, CompressionMode.Decompress))
{
deflate.CopyTo(output);
}
一个细节:.NET 的 DeflateStream 吃的是 raw deflate(不带 zlib/gzip 包装),而 gzip 文件的 deflate 负载正好从第 10 字节开始,所以"跳过 10 字节头 + DeflateStream"是免费午餐,不需要引入 SharpZipLib 之类的第三方库——这对"零依赖"目标是决定性的。
至此我们得到了归档内的虚拟文件系统:TagInfo.XML、QuickInfo.XML、Comps.Dat、SbRegion.Dat、Comments.Dat、Nameless.Dat 等。按 hutcheb/acd 的逆向成果,Comps.Dat / SbRegion.Dat 等是 Rockwell 私有二进制数据库,存储标签、例程、注释等记录。
4. 归档里有什么:TagInfo.XML 才是变量树的正主
各文件分工:
| 文件 | 内容 | 本驱动是否使用 |
|---|---|---|
| TagInfo.XML(UTF-16) | 全部数据类型定义(成员/隐藏/位/数组维度)+ 控制器/程序标签列表 | 变量树主数据源 |
| QuickInfo.XML | 工程名、CIP 身份三元组、保存软件版本 | 控制器信息 |
| Comps.Dat | 二进制记录库:标签元数据、控制器根记录(序列号/时间戳)、外部访问权限 | 控制器信息 + 权限 |
| SbRegion.Dat / Comments.Dat / Nameless.Dat | 程序逻辑、注释等 | 不使用(通讯驱动不需要梯形图) |
注意 XML 的编码是 UTF-16(带 BOM)。C# 里不需要显式指定编码,XmlDocument.Load 会依 BOM 自动识别——但如果用 File.ReadAllText + 默认 UTF-8 去读就会全盘乱码,这是移植时第一个容易翻车的点。
5. 变量树重建:类型展开的三条规则
TagInfo.XML 里有两类信息:数据类型定义(<DataTypes>,用户 UDT 的成员列表,含嵌套与数组成员)和标签列表(控制器作用域 + 各程序作用域)。重建过程是按规则展开,而不是照抄:
规则一:凡有定义的类型一律展开成成员。 用户 UDT 递归展开(含嵌套结构体与数组成员)没有悬念;关键是两类"特殊类型"也要展开:
- 预定义指令类型(TIMER / COUNTER / ALARM 等 100+ 种)——XML 里有完整成员定义;
AB:前缀的模块类型(如AB:Embedded_Encoder:O:0)——同样展开。
我们早期版本曾对这两类"按 STRUCT 叶子处理、不展开",理由是 L5X 导出的树就是这么折叠的;结果与在线 browse 对比时两棵树对不上——在线 Symbol Object 返回的真实树就是展开的。结论:离线树的验收基准不是"像某个导出物",而是"像在线 browse"。
规则二:Hidden 成员保留在树中。 XML 里成员有 Hidden="true" 属性,典型是 BOOL 打包宿主(ZZZZZZZZZZ 前缀成员)和模块 Cfg 字段。直觉上"隐藏=删掉",但在线模板同样列出这些成员——删了树又不一致了。保留。
规则三:兼容 Rockwell 的历史命名债。 XML 与 CIP 线上命名不一致的地方要归一化:
- STRING 在 XML 里注册为原子类型名,但必须按结构展开为
LEN/DATA[n]成员; - TIMER 的 CTL 成员在 XML 里记为
Control/DINT,而 CIP 模板标准命名(在线所见)是CTL/DWORD——统一映射成后者,"在线所见即所得"。
数组按维度展开元素([x] / [x,y] / [x,y,z]),BOOL 数组按 DWORD 打包(与 PLC 实际存储一致,一个 DWORD 承载 32 个 BOOL 元素)。
6. 反直觉的实测结论:维度只能信 XML
这里有一个值得单独说的实测发现。Comps.Dat 里标签记录的数组维度字段,在 v21 工程文件里不填充——实测全部为 0。
这是二进制解析路径的"死刑判决":如果变量树依赖 Comps.Dat 记录取维度,ba(64 元素 BOOL 数组)、ut11([1,2] 二维结构体数组)这类标签会全部塌缩成标量。而 TagInfo.XML 给出的维度全部正确。
结论:变量树完全以 TagInfo.XML 为准,Comps.Dat 只用于 XML 里没有的两样东西——外部访问权限和控制器根记录信息。二进制解析链(Dat 头指针区 + FAFA/FDFD 两种记录头的现代/v20 遗留布局 + Rx 记录属性表)被刻意裁剪到最小,且全部做了防御性处理:解析失败不阻塞变量树,只是相应字段留空。
7. 外部访问权限:藏在 Comps.Dat 二进制记录里
RSLogix 里每个标签有 External Access 属性:Read/Write、Read Only、None——上位机程序靠它判断哪些标签可写。TagInfo.XML 不含这个字段(Rockwell 没往 XML 里放),只能从二进制记录里挖。
Comps.Dat 的标签是 RxTag 记录,权限字段的位置随文件版本有两种布局(移植自 hutcheb/acd 的 rx.py/elements.py 逆向成果):
| 布局 | 适用 | 权限字段位置 | 限定 |
|---|---|---|---|
| 现代 RxTag | 较新固件工程 | 主记录 u16@48(60 字节主记录内偏移 34) | 仅 cip_type 0x68/0x6B 的标签记录 |
| v20 遗留布局 | v20 及更早 | 元数据属性 0x01 内 u16@0x21E | 元数据长度 ≥ 0x220 |
实测用的 tsoft.ACD 恰好全部是遗留布局——39 条标签记录全部解析成功。取值与 CIP/RSLogix 一致:0 = Read/Write,2 = Read Only,3 = None;记录缺失时按 Rockwell 默认视为 Read/Write。
对外统一暴露为 ABExternalAccess 枚举(ReadWrite=0 / ReadOnly=2 / None=3 / Unknown=255),树节点和符号地址都能取到。结构体成员不携带独立权限,继承所属标签——这与 PLC 的行为一致。
一个容易误判的语义:External Access = None 的标签会被 Rockwell 对外整体隐藏——在线 0x55 列举和离线 XML 都不列出它。这不是丢数据,恰是 "None" 的本义(连看都不让你看)。它的值(3)仍能从 Comps.Dat 记录里解析到,用于"为什么我的标签不见了"的排查。验证方法很直接:把工程里 BOOL20 改成 None 重新保存,离线/在线两棵树同时"看不到"它,而 Comps.Dat 里它的权限值确实是 3。
8. 控制器信息也不能丢
离线加载的场景下,"这台工程对应什么控制器"同样是刚需(HMI 需要根据控制器型号切换画面逻辑)。信息拼自两处 + 一张内置表:
QuickInfo.XML:工程名(LogixQuickInfo 根元素 Name 属性)、保存软件版本(SWVersion,如 "RSLogix 5000 v21.00")、处理器 CIP 身份(vendor/product_type/product_code + 主次版本)。
Comps.Dat 根控制器记录:按记录类型定位根记录后解析其属性表——
- 属性 0x75:工程序列号(u32,格式化为
16#7024_4aa5风格); - 属性 0x65 / 0x66:工程创建 / 最后修改时间,FILETIME(Windows 100ns 纪元),
DateTime.FromFileTimeUtc直接转换。
内置 CIP 身份 → 目录号表:归档里只存身份三元组,人类可读的目录号要查表。表移植自 hutcheb/acd 的 catalog_numbers.py,并补充了实测条目 (1,14,15) = 1789-L60(SoftLogix5800,经在线 Identity Object 交叉验证)。表查不到时回退为 CIP-v-t-c 占位描述,不报错。
最终输出形如:
tsoft / 1789-L60 v21.11、SN 16#7024_4aa5、RSLogix 5000 v21.00
9. 验收标准:与在线树 785 符号逐项 diff 为空
离线树的最高验收标准只有一条,但要做到非常难:同一台控制器,在线 browse 出的树与离线 .acd 解析出的树,按"名称 + 类型 + 外部访问权限"逐项 diff 为空。
实测 SoftLogix5800 v21.11 工程:785 个符号(含模块结构体 Local:3:* 展开成员、STRING s1 展开为 LEN/DATA),DumpTree 工具双向对比 diff 为空。达成它依赖前面所有的决策:预定义类型与模块类型展开、Hidden 成员保留、命名归一化、维度以 XML 为准、权限从 Comps.Dat 补齐。
这个标准也反过来解释了为什么 L5X 导出路线被放弃——它的树形天然与在线不一致(折叠 STRUCT、丢隐藏成员),无论解析写得多正确,diff 都不可能为空。
10. 实现与工程决策小结
- 纯 C# / .NET 4.0 / 零第三方依赖:gzip 用内置 DeflateStream 解 raw deflate,XML 用内置 XmlDocument,FILETIME 用内置 FromFileTimeUtc——目标是能直接嵌入还在 .NET 4.0 时代的老产线软件。
- 数据源分层:变量树 = TagInfo.XML(权威);权限 = Comps.Dat 二进制(现代 u16@48 / 遗留 u16@0x21E 双布局);控制器信息 = QuickInfo.XML + Comps.Dat 根记录;目录号 = 内置 CIP 表。
- 防御性解析:二进制解析链裁剪到最小且全部 try-catch——权限或控制器信息解析失败不阻塞变量树,对应字段置 Unknown/空。
- 一致性优先于直觉:离线树对齐的是在线 browse 的真实行为,不是任何导出物——展开策略、隐藏成员、命名归一化全部服从这一原则。
- 验证闭环:785 符号在线/离线双向 diff 为空 + 离线符号在线抽验 50/50 可读 + 权限改动(ReadOnly / None)次日复测一致。
参考与致谢:容器与二进制记录格式基于 hutcheb/acd 的逆向成果移植;目录号表移植自其 catalog_numbers.py 并补充实测条目。
核心库源码与测试工程(含 DumpTree 树对比工具):f8dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8W2k6g2)9J5k6h3y4G2L8g2)9J5c8X3I4A6M7X3y4&6i4K6u0r3f1X3!0U0K9%4N6W2L8r3I4Q4x3X3c8m8L8r3I4W2L8V1u0J5j5h3c8D9k6i4W2q4K9i4m8p5M7X3W2$3k6i4u0Q4x3X3g2Y4K9i4b7`.
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。