简介这份资源面向工业自动化领域的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、数组ArraySymbols则记录了变量的实例化信息比如某个变量属于哪个类型、在哪个任务下、地址偏移是多少。关键点在于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# 类型自定义类型则直接引用生成的类名。常见做法是建一个Dictionarystring, string键是 PLC 类型名值是 C# 类型名遇到未命中的键就当作自定义类型处理。3. 从 XML 到 C# 类代码生成器的核心逻辑3.1 类型映射表的设计与边界处理PLC 和 C# 的类型系统不是一一对应的。BOOL 对应 boolINT 对应 shortDINT 对应 intREAL 对应 floatLREAL 对应 doubleSTRING 对应 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忽略长度或加 MarshalAsARRAYT[]维度需展开注意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 INTC# 数组长度是 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 NameGeneratePlcTypes BeforeTargetsBeforeBuild Exec CommandTmcCodeGen.exe quot;$(ProjectDir)Plc\MyProject.tmcquot; quot;$(ProjectDir)Generated\PlcTypes.csquot; / /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 解析和类型校验再也没出现过变量名对不上的玄学问题。希望帮到你。本文还有配套的精品资源点击获取