☰
倍福TMC文件生成C#类:从XML解析到强类型代码的完整实现
2026/10/15 17:18:28 网站建设 项目流程

简介:这份资源面向工业自动化领域的C#开发者与倍福PLC工程师,提供将倍福TMC文件自动转换为C#类的完整工具方案。TMC文件承载着PLC的程序结构、变量定义与通信配置,借助该工具可将其中的结构体映射为C#类成员,让开发者以面向对象方式读写PLC变量,并配合TwinCAT.Ads等库实现ADS通信与实时监控。压缩包共19个文件,约2.1MB,以dll动态库为主,辅以exe可执行程序、config配置、manifest清单及xml、pdb等辅助文件,涵盖解析、代码生成与运行所需组件。目前已有228人学习下载,适合希望简化PLC数据通信流程、提升代码可读性与可维护性的开发者参考,可快速搭建从TMC解析到C#类生成、再到ADS通信的工程链路。

1. 倍福 TMC 文件生成 C# 类:从 XML 黑匣子到强类型代码的落地路径

如果你用过倍福 TwinCAT 3 做上位机开发,大概率经历过这种场景:在 PLC 里定义了一堆结构体、枚举、功能块接口,上位机 C# 程序要读写这些变量,结果只能靠手敲字符串地址,比如MAIN.fbAxis.nState。一旦 PLC 端改了变量名或数据类型,编译不报错,运行时直接抓瞎。TMC 文件就是解决这个问题的钥匙——它是 TwinCAT 的 XML 格式类型描述文件,完整记录了 PLC 工程的符号信息、数据类型定义和内存布局。把 TMC 自动转成 C# 类,等于给上位机装上了一套与 PLC 工程同步的类型系统。这套方案适合做设备控制、产线数据采集、运动控制上位机的 C# 开发者,尤其是那些被字符串地址坑过、想上强类型又不想手写几千行映射代码的人。

2. 拆解 TMC 的 XML 结构:先看懂再动手

2.1 TMC 文件里到底存了什么

TMC 全称 TwinCAT Module Class,本质是一个符合特定 Schema 的 XML 文件。用文本编辑器打开一个典型的 TMC,你会看到几个核心节点:<Modules>是根容器,下面挂<Module>,每个 Module 对应一个 PLC 库或工程;<DataTypes>里定义了所有自定义类型,包括结构体<Struct>、枚举<Enum>、数组<Array>;<Symbols>则记录了变量的实例化信息,比如某个变量属于哪个类型、在哪个任务下、地址偏移是多少。

关键点在于:TMC 里的类型定义是自描述的。一个<Struct>节点会列出所有成员的名字、类型引用和偏移量;一个<Enum>会列出所有枚举值的名称和数值。这意味着你不需要解析 PLC 源码,只需要解析 TMC 就能拿到完整的类型信息。常见做法是用 C# 的XDocument加载 TMC,然后按节点层级递归遍历。

提示:TMC 文件通常位于 TwinCAT 工程的_Boot目录或 PLC 项目的GLOBALS下,文件名一般是工程名加.tmc。不同 TwinCAT 版本生成的 TMC Schema 略有差异,解析前先确认根节点的xmlns命名空间。

2.2 用 XDocument 加载并定位关键节点

先写一段最小可运行的解析代码,把 TMC 里的模块和数据类型列出来。这段代码不依赖任何第三方库,纯 .NET 原生。

using System; using System.Linq; using System.Xml.Linq; class TmcParser { static void Main(string[] args) { // 加载 TMC 文件,保留空白以便后续调试 XDocument doc = XDocument.Load(@"C:\PlcProject\MyProject.tmc", LoadOptions.PreserveWhitespace); // 获取命名空间,TMC 的 Schema 通常带命名空间 XNamespace ns = doc.Root.Name.Namespace; // 定位所有 Module 节点 var modules = doc.Descendants(ns + "Module").ToList(); Console.WriteLine($"找到 {modules.Count} 个 Module"); foreach (var module in modules) { string moduleName = module.Element(ns + "Name")?.Value ?? "未命名"; Console.WriteLine($"Module: {moduleName}"); // 定位该 Module 下的 DataTypes var dataTypes = module.Descendants(ns + "DataType").ToList(); foreach (var dt in dataTypes) { string dtName = dt.Element(ns + "Name")?.Value ?? "未知类型"; string dtKind = dt.Element(ns + "Kind")?.Value ?? "未知种类"; Console.WriteLine($" 数据类型: {dtName}, 种类: {dtKind}"); } } } }

逻辑说明:XDocument.Load把整个 TMC 读进内存,Descendants按命名空间过滤节点。TMC 的命名空间通常是http://www.beckhoff.com/schemas/2012/07/TcModule这类固定值,直接用doc.Root.Name.Namespace获取最稳妥。参数方面,LoadOptions.PreserveWhitespace在调试阶段有用,正式生成代码时可以去掉以节省内存。

2.3 递归解析 Struct 与 Enum 的成员

拿到 DataType 节点后,下一步是提取成员。Struct 的成员在<SubItem>节点下,每个 SubItem 有<Name>、<Type>、<BitSize>、<Offset>等子节点。Enum 的成员在<EnumInfo>下的<Enum>节点里,包含名称和数值。

void ParseStruct(XElement structNode, XNamespace ns) { var subItems = structNode.Descendants(ns + "SubItem"); foreach (var item in subItems) { string name = item.Element(ns + "Name")?.Value; string type = item.Element(ns + "Type")?.Value; string bitSize = item.Element(ns + "BitSize")?.Value; string offset = item.Element(ns + "Offset")?.Value; // 类型可能是基础类型如 INT、REAL,也可能是自定义类型引用 Console.WriteLine($" 成员: {name}, 类型: {type}, 位宽: {bitSize}, 偏移: {offset}"); } } void ParseEnum(XElement enumNode, XNamespace ns) { var enumValues = enumNode.Descendants(ns + "Enum"); foreach (var ev in enumValues) { string name = ev.Element(ns + "Name")?.Value; string value = ev.Element(ns + "Value")?.Value; Console.WriteLine($" 枚举值: {name} = {value}"); } }

这里有个容易翻车的点:TMC 里的类型引用可能是INT、BOOL这类基础类型,也可能是MyStruct这种自定义类型名。生成 C# 代码时需要维护一张映射表,把 PLC 基础类型转成 C# 类型,自定义类型则直接引用生成的类名。常见做法是建一个Dictionary<string, string>,键是 PLC 类型名,值是 C# 类型名,遇到未命中的键就当作自定义类型处理。

3. 从 XML 到 C# 类:代码生成器的核心逻辑

3.1 类型映射表的设计与边界处理

PLC 和 C# 的类型系统不是一一对应的。BOOL 对应 bool,INT 对应 short,DINT 对应 int,REAL 对应 float,LREAL 对应 double,STRING 对应 string。但 PLC 的 STRING 有固定长度,比如STRING(80),C# 里可以用string加MarshalAs特性,也可以直接忽略长度用string。数组类型如ARRAY[0..9] OF INT需要转成short[],多维数组则要转成嵌套数组或扁平化处理。

PLC 类型C# 类型备注
BOOLbool直接映射
BYTEbyte无符号 8 位
INTshort有符号 16 位
DINTint有符号 32 位
REALfloat单精度浮点
LREALdouble双精度浮点
STRINGstring忽略长度或加 MarshalAs
ARRAYT[]维度需展开

注意:PLC 的WORD和UINT都是 16 位无符号,C# 里统一用ushort。DWORD和UDINT用uint。别把INT映射成int,否则内存布局对不上,读写 PLC 时会拿到错误的值。

3.2 用 StringBuilder 拼出完整的 C# 类文件

生成代码的核心是模板拼接。每个 Struct 生成一个public class,每个 Enum 生成一个public enum。成员用public属性或字段,字段名保持与 PLC 一致以便后续反射。下面是一个简化的生成器骨架。

using System.Text; StringBuilder sb = new StringBuilder(); // 生成文件头 sb.AppendLine("using System;"); sb.AppendLine("using System.Runtime.InteropServices;"); sb.AppendLine(); sb.AppendLine("namespace PlcGeneratedTypes"); sb.AppendLine("{"); // 遍历所有 Struct 类型 foreach (var structNode in structNodes) { string className = structNode.Element(ns + "Name")?.Value; sb.AppendLine($" [StructLayout(LayoutKind.Sequential, Pack = 1)]"); sb.AppendLine($" public class {className}"); sb.AppendLine(" {"); var subItems = structNode.Descendants(ns + "SubItem"); foreach (var item in subItems) { string memberName = item.Element(ns + "Name")?.Value; string plcType = item.Element(ns + "Type")?.Value; string csType = MapPlcTypeToCSharp(plcType); // 查表转换 sb.AppendLine($" public {csType} {memberName} {{ get; set; }}"); } sb.AppendLine(" }"); sb.AppendLine(); } sb.AppendLine("}"); // 写入文件 System.IO.File.WriteAllText(@"C:\Output\PlcTypes.cs", sb.ToString());

逻辑说明:StructLayout(LayoutKind.Sequential, Pack = 1)确保 C# 类的内存布局与 PLC 结构体一致,这对后续用Marshal直接读写内存至关重要。Pack = 1表示按 1 字节对齐,避免编译器插入填充字节。参数方面,MapPlcTypeToCSharp就是前面说的映射函数,遇到数组类型要额外解析ARRAY[0..N] OF语法,提取元素类型和长度。

3.3 处理数组与嵌套结构体

数组是生成器里最容易出 bug 的地方。PLC 的数组声明格式是ARRAY[0..9] OF INT,需要正则提取下界、上界和元素类型。嵌套结构体则要求生成顺序正确——被引用的类型必须先定义,否则 C# 编译报错。常见做法是先用拓扑排序对类型依赖关系排个序,或者干脆把所有类型都生成成partial class,让编译器自己解决顺序问题。

string MapPlcTypeToCSharp(string plcType) { // 处理数组 var arrayMatch = System.Text.RegularExpressions.Regex.Match(plcType, @"ARRAY\[(\d+)\.\.(\d+)\] OF (.+)"); if (arrayMatch.Success) { int lower = int.Parse(arrayMatch.Groups[1].Value); int upper = int.Parse(arrayMatch.Groups[2].Value); string elementType = arrayMatch.Groups[3].Value; int length = upper - lower + 1; return $"{MapPlcTypeToCSharp(elementType)}[{length}]"; } // 基础类型映射 switch (plcType.ToUpper()) { case "BOOL": return "bool"; case "INT": return "short"; case "DINT": return "int"; case "REAL": return "float"; case "LREAL": return "double"; case "STRING": return "string"; default: return plcType; // 自定义类型直接引用 } }

这段代码里,数组长度用upper - lower + 1计算,因为 PLC 数组下标可能不从 0 开始。如果 PLC 里写的是ARRAY[1..10] OF INT,C# 数组长度是 10,但下标从 0 开始,读写时要注意偏移。我一般会在生成的类里加一个Offset常量,记录 PLC 数组的起始下标,避免手动换算。

4. 避坑与排查:TMC 转 C# 的五个血泪经验

4.1 现象:生成的类编译报错“类型未定义”

原因:TMC 里引用了某个自定义类型,但该类型的定义在另一个 Module 或另一个 TMC 文件里,当前解析范围没覆盖到。解决:把所有相关 TMC 文件都加载进来,合并 DataTypes 集合后再生成。或者用partial class分文件生成,让编译器跨文件解析。

4.2 现象:读写 PLC 变量时值错位

原因:C# 类的内存布局与 PLC 不一致。常见于结构体里有BOOL后跟INT的情况,PLC 可能按位对齐,C# 默认按 4 字节对齐。解决:强制加[StructLayout(LayoutKind.Sequential, Pack = 1)],并且检查每个成员的BitSize和Offset,必要时手动插入占位字段。

4.3 现象:枚举值名称重复导致编译失败

原因:PLC 里不同枚举可能有同名成员,比如两个枚举都有Idle。C# 枚举成员不能重名。解决:生成时给枚举成员加前缀,比如AxisState_Idle,或者把枚举定义在各自的嵌套类里。

4.4 现象:TMC 文件解析时命名空间对不上

原因:不同 TwinCAT 版本生成的 TMC 命名空间不同,硬编码命名空间字符串会翻车。解决:始终用doc.Root.Name.Namespace动态获取,不要写死http://www.beckhoff.com/...。

4.5 现象:生成的代码文件过大,编译缓慢

原因:一个大型 PLC 工程可能有上千个结构体和几万个成员,生成的 C# 文件超过几万行。解决:按 Module 拆分成多个文件,每个文件只包含一个 Module 的类型。另外可以用#region折叠,但别用太多,否则 IDE 卡顿。

5. 进阶技巧:把生成器集成到构建流程里

5.1 用 MSBuild Target 自动触发代码生成

手动运行生成器容易忘,最好的做法是把它挂到 C# 项目的构建事件上。在.csproj里加一个Target,在BeforeBuild阶段调用生成器可执行文件,传入 TMC 路径和输出路径。

<Target Name="GeneratePlcTypes" BeforeTargets="BeforeBuild"> <Exec Command="TmcCodeGen.exe &quot;$(ProjectDir)Plc\MyProject.tmc&quot; &quot;$(ProjectDir)Generated\PlcTypes.cs&quot;" /> </Target>

这样每次编译前都会重新生成,PLC 改了变量名,C# 端编译时就能发现类型不匹配,而不是等到运行时。参数说明:$(ProjectDir)是项目根目录,Plc\MyProject.tmc是 TMC 的相对路径,按实际工程结构调整。

5.2 用反射做运行时校验

生成完类之后,可以在程序启动时用反射遍历所有生成的类型,检查字段数量、类型名称是否与 TMC 里的定义一致。这相当于给代码生成器加了一道后悔药——万一生成逻辑有 bug,启动时就能发现,不用等到读写 PLC 时才报错。

void ValidateGeneratedTypes() { var assembly = System.Reflection.Assembly.GetExecutingAssembly(); foreach (var type in assembly.GetTypes()) { if (type.Namespace != "PlcGeneratedTypes") continue; var fields = type.GetFields(); Console.WriteLine($"类型 {type.Name} 有 {fields.Length} 个字段"); // 可以在这里对比 TMC 里的预期字段数 // 如果不一致,抛出异常或记录日志 } }

5.3 一个具体技巧:用 Source Generator 替代外部 exe

.NET 5 以后可以用 Source Generator 在编译期直接生成代码,不需要外部 exe,也不需要 MSBuild Target。写一个ISourceGenerator实现,在Execute方法里解析 TMC 并AddSource。这样生成过程完全融入编译管线,IDE 里也能直接跳转到生成的代码。代价是 Source Generator 的调试比较麻烦,而且 TMC 文件路径需要作为AdditionalFiles传入。

我自己的习惯是:小工程用外部 exe 加 MSBuild Target,简单直接;大工程用 Source Generator,省去构建步骤。从那以后我每次新建上位机项目,都强制走一遍 TMC 解析和类型校验,再也没出现过变量名对不上的玄学问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询