
简介面向C#开发者和倍福PLC工程师资源提供一套将倍福TMC文件映射为C#类的自动化工具与示例解决在.NET环境下直接通过ADS通信读写PLC结构体数据的难题。该工具支持解析TMC文件中的变量声明、结构体定义与通信配置自动生成对应的C#类源码并编译为DLL可显著提高开发效率。压缩包共19个文件包含10个DLL库如TwinCAT.Ads.dll、MySql.Data.dll等、2个可执行程序、配置文件及说明文档整体约2.1MB结构紧凑便于部署。目前已有228人学习下载适用于需要快速对接倍福控制系统的中高级开发者。资源内附TMC解析原理讲解、结构体到类的映射规则、源码生成流程以及基于ADS库的通信调用示例并给出了错误处理、批量读写和数据同步等实际工程注意事项可帮助读者在监控界面、MES集成等场景中直接复用少走弯路。1. 倍福PLC通讯里的黑匣子为什么我选择从TMC文件自动生成C#类做倍福PLC上位机开发的八成都有过这种经历PLC侧定义了一个结构体你在这边手写一个一模一样的C#类字段名、类型、偏移量一个一个对写完还要担心PLC定义一改这边全废。而PLC每次编译其实都会生成一个TMC文件里面把符号表、类型、位偏移全都写好了。这次拆的项目就是拿TMC文件当输入自动生成对应的C#类和ADS通讯辅助代码。跑通之后PLC那边一改结构体你这边重新生成一次就行字段对不齐的“黑匣子”基本被拆掉了。适合给倍福PLC写过HMI、工控机软件或者准备用ADS通讯却不想在类型定义上耗时间的开发者。2. TMC文件结构解剖先看懂PLC留下了什么解析TMC之前我一直以为它是某种私有二进制格式直到某次把PLC工程目录翻了个底朝天才发现它就是一份XML。TwinCAT在编译PLC项目时会把符号表、数据类型、位宽、偏移量全部算好写进TMC文件。换句话说你手写C#结构体时最头疼的那些数字PLC早就帮你算完了你要做的只是把它们读出来。2.1 生成器的整体架构这个项目我用的是.NET 6没有引额外的代码生成模板库核心就四个模块TmcParser读TMC文件把XML转成内存里的中间模型TypeMapper把PLC的IEC类型映射成C#类型ClassGenerator把中间模型拼成.cs文件文本AdsRuntime提供配合生成类的ADS读写工具类有人可能会问为什么不直接用T4模板我一开始也纠结过后来发现T4在命令行和CI环境里跑起来比较麻烦还得依赖Visual Studio那套东西。既然只是“读取XML 拼字符串”手动生成反而更直观生成的代码你直接拿记事本都能看懂出问题了也方便排查。别人接手你的工具时也不会被模板引擎的调试折磨到。2.2 TMC文件里到底有什么不同TwinCAT版本导出的TMC文件字段位置会略有差异但骨架基本一致。下面是我按常见结构简化后的例子实际解析时你需要先打开自己的TMC文件确认一下节点放在元素里还是属性里TcSmItem DataTypes DataType NameST_Tank/Name BitSize64/BitSize SubItems SubItem NameLevel/Name TypeREAL/Type BitSize32/BitSize BitOffs0/BitOffs /SubItem SubItem NameTankID/Name TypeINT/Type BitSize16/BitSize BitOffs32/BitOffs /SubItem /SubItems /DataType /DataTypes DataAreas DataArea AreaNo0/AreaNo Symbols Symbol NameMAIN.Tank/Name TypeST_Tank/Type BitSize64/BitSize /Symbol /Symbols /DataArea /DataAreas /TcSmItem这段XML里DataType描述的是类型定义包含字段名、类型、位偏移和位宽Symbol描述的是实际变量路径是MAIN.Tank类型指向ST_Tank。这两个节点就是生成器的主要输入一个负责生成C#结构体一个负责生成符号常量。BitOffs是字段在结构体里的位偏移BitSize是位宽生成代码时可以用来做对齐校验也能用来算STRING长度。TMC文件从哪来常见做法是编译完PLC工程后在TwinCAT XAE的项目树里找到PLC项目节点构建后会生成一个带.tmc后缀的子项右键导出到源码目录就行。有些版本也能在PLC的Boot目录里直接拷贝但那个目录结构每个项目都不一样我一般还是走导出这条路。2.3 从IEC类型到C#类型的映射规则类型映射表是整个生成器的地基也是最容易改错的地方。以下是我最终采用的映射记不清的时候直接查这张表PLC类型TMCC#类型说明BOOLbool注意Marshal时要用UnmanagedType.I1SINTsbyte1字节有符号INTshort2字节DINTint4字节LINTlong8字节USINTbyte1字节无符号UINTushort2字节UDINTuint4字节ULINTulong8字节BYTEbyte与USINT同宽WORDushort2字节DWORDuint4字节REALfloat4字节IEEE 754LREALdouble8字节STRINGstring定长需SizeConstWSTRINGstring宽字符注意编码TIMEuint单位毫秒TODuint自零点起的毫秒DATEuint自2000年起的天数表里DATE和TOD我选择了直接映射成uint而不是DateTime原因后面会在避坑里讲。TIME这类类型本质上就是无符号整数先在生成阶段把它记成uint具体业务里想转TimeSpan自己在代码里写扩展方法就行别在生成的model里塞太多逻辑。2.4 从数据类型到符号表的对应关系TMC里还有个容易忽略的点结构体字段的类型如果指向另一个DataType的Name那就是嵌套结构体。比如ST_Device字段出现在ST_Tank里它自己又在DataTypes节点下有独立的定义。生成器需要做递归解析先处理被引用的类型再处理外层结构体否则生成的C#代码根本无法编译。Symbol节点和DataType还不是一回事。DataType是模板Symbol是变量实例。同一个结构体类型可以被多个变量引用比如两台设备都用ST_Tank但符号路径一个是MAIN.Tank1一个是MAIN.Tank2。生成符号常量的意义就在这用字符串去ADS里查变量只要拼错一个字母就是运行时异常生成成常量后编译期就能发现问题。3. 核心拆解解析器与代码生成器的实现有了前面的结构分析代码就顺理成章了。整个生成器其实没多少玄学核心就三步读XML、建模型、拼字符串。3.1 用LINQ to XML加载TMC我先定义了PlcDataType和PlcSubItem两个中间模型然后写一个TmcParser类专门负责加载using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; public class TmcParser { public TmcModel Parse(string tmcPath) { XElement root XElement.Load(tmcPath); XNamespace ns GetNamespace(root); var dataTypes root.Descendants(ns DataType) .Select(dt new PlcDataType { Name dt.Element(ns Name)?.Value ?? , BitSize ReadInt(dt, BitSize), SubItems dt.Elements(ns SubItem) .Select(s new PlcSubItem { Name s.Element(ns Name)?.Value ?? , Type s.Element(ns Type)?.Value ?? , BitOffs ReadInt(s, BitOffs), BitSize ReadInt(s, BitSize) }).ToList() }).ToList(); var symbols root.Descendants(ns Symbol) .Select(s new PlcSymbol { Name s.Element(ns Name)?.Value ?? , Type s.Element(ns Type)?.Value ?? }).ToList(); return new TmcModel { DataTypes dataTypes, Symbols symbols }; } private static XNamespace GetNamespace(XElement root) { return root.Name.Namespace ?? XNamespace.None; } private static int ReadInt(XElement node, string name) { int value; if (int.TryParse(node.Attribute(name)?.Value, out value)) return value; if (int.TryParse(node.Element(name)?.Value, out value)) return value; return 0; } }这段代码里有几个细节值得说一下。GetNamespace是为了兼容带命名空间的TMC文件如果根节点有默认命名空间所有子元素的Name都会带着这个命名空间不取出来的话Descendants(DataType)会匹配不到任何节点。如果文件没有命名空间root.Name.Namespace是空的返回XNamespace.None就不会出错。ReadInt是我踩过坑之后补的兼容函数。有些TMC版本把BitOffs写成属性有些版本写成子元素用这个函数两边都能读到解析出错的可能性会小很多。最怕的是XML格式变了但生成器没变最后生成的C#字段全是0。3.2 生成C#结构体类型映射与字符串长度解析拿到PlcDataType之后下一步是把它翻译成C#的struct。我用struct而不是class是为了配合AdsRuntime里基于Marshal的字节解析struct的内存布局是连续的可以直接从ADS返回的字节数组转换。public class ClassGenerator { private static readonly Dictionarystring, string BasicMap new Dictionarystring, string(StringComparer.OrdinalIgnoreCase) { [BOOL] bool, [BYTE] byte, [WORD] ushort, [DWORD] uint, [SINT] sbyte, [INT] short, [DINT] int, [LINT] long, [USINT] byte, [UINT] ushort, [UDINT] uint, [ULINT] ulong, [REAL] float, [LREAL] double, [STRING] string, [WSTRING] string, [TIME] uint, [TOD] uint, [DATE] uint }; public string GenerateStruct(PlcDataType type, string typeNs) { var sb new StringBuilder(); sb.AppendLine($namespace {typeNs}); sb.AppendLine({); sb.AppendLine( [StructLayout(LayoutKind.Sequential)]); sb.AppendLine($ public struct {ToPascalCase(type.Name)}); sb.AppendLine( {); foreach (var sub in type.SubItems) { string fieldName ToPascalCase(sub.Name); string csharpType ResolveType(sub); if (csharpType string) { int size Math.Max(1, sub.BitSize / 8); sb.AppendLine($ [MarshalAs(UnmanagedType.ByValTStr, SizeConst {size})]); sb.AppendLine($ public string {fieldName};); } else if (csharpType.EndsWith([])) { int count GetArrayCount(sub); string elemType csharpType.Substring(0, csharpType.Length - 2); sb.AppendLine($ [MarshalAs(UnmanagedType.ByValArray, SizeConst {count})]); sb.AppendLine($ public {elemType}[] {fieldName};); } else { sb.AppendLine($ public {csharpType} {fieldName};); } } sb.AppendLine( }); sb.AppendLine(}); return sb.ToString(); } private string ResolveType(PlcSubItem sub) { if (BasicMap.TryGetValue(sub.Type, out string mapped)) return mapped; if (sub.Type.StartsWith(ARRAY, StringComparison.OrdinalIgnoreCase)) { string elemType ParseArrayElementType(sub.Type); return BasicMap.TryGetValue(elemType, out var cs) ? cs [] : elemType []; } return ToPascalCase(sub.Type); } }GenerateStruct里最关键的是对STRING的处理。PLC的STRING是定长的TMC里会明确给出位宽比如STRING(20)的BitSize通常是160除以8就是20字节。C#的string是引用类型直接Marshal无法解析所以必须用MarshalAs(UnmanagedType.ByValTStr, SizeConst x)告诉CLR按固定长度读取。这里大小如果算错轻则字段截断重则整个结构体解析跑偏。ResolveType的处理顺序也有讲究先查基础类型表再判断是不是数组最后当成嵌套结构体处理。数组类型的TMC写法通常是ARRAY[0..9] OF INT这种格式ParseArrayElementType就是提取OF后面的类型名然后拼上[]。嵌套结构体则直接拿类型名转Pascal命名生成的代码里引用到另一个struct名字时编译器自然会把它们串起来。3.3 一次性生成符号常量符号常量看着不起眼实际用起来非常解压。没有它之前我的代码里到处都是MAIN.Tank.Level这种字符串某次PLC工程师把变量从MAIN挪到了GVL我在几十个文件里找字符串引用找了一下午。之后我就在生成器里加了这一步public string GenerateSymbolConstants(ListPlcSymbol symbols) { var sb new StringBuilder(); sb.AppendLine(public static class PlcSymbols); sb.AppendLine({); foreach (var symbol in symbols) { string key symbol.Name.Replace(., _); sb.AppendLine($ public const string {key} \{symbol.Name}\;); } sb.AppendLine(}); return sb.ToString(); }方法逻辑很简单但有个细节值得注意PLC符号名里带点号C#常量名里不能有点号所以我用下划线替换点号MAIN.Tank就变成了MAIN_Tank。如果PLC符号名里有其他非法字符也要在这里提前做转义否则生成出来的C#文件编译不过。符号常量还有一个好处用它可以配合依赖注入做点位映射PLC点位表以后调整时只需要改生成的文件业务逻辑里的引用不用动。3.4 递归处理嵌套结构体与数组嵌套结构体和数组在生成时是个相对隐蔽的坑。比如ST_Tank里有一个ST_Device类型的字段ResolveType会返回ST_Device的Pascal命名但C#里这两个结构体谁先声明都无所谓所以生成顺序其实不用太费心。数组的麻烦在于Marshal固定长度。TMC里数组字段的BitSize是整个数组的总位宽比如ARRAY[0..9] OF INT的BitSize是320你还需要从类型描述里解析出维度然后除以单个元素位宽得到数组长度才能填进MarshalAs(UnmanagedType.ByValArray, SizeConst count)。这里我一般会额外打印一条校验日志解析出的count和总BitSize对不上就直接报错而不是生成一个错代码还让人满世界找原因。4. 跑通最小闭环从TMC生成到ADS实际读写代码生成器本身跑起来不难难的是让生成的代码在真实PLC环境下读写正确。这一章给你一条能直接抄的完整路径。4.1 准备环境与命令行参数环境方面需要三样东西.NET SDK、ADS客户端库的NuGet包以及一份TMC文件。ADS客户端库在NuGet上直接搜TwinCAT官方那一个就行引用后命名空间是TwinCAT.Ads。TMC文件还是那句话在TwinCAT XAE里编译PLC工程后从PLC项目节点下导出。生成器我用命令行方式调用参数固定为三个TmcClassGen.exe C:\Work\PlcProject.tmc -o .\Generated --namespace SimuProject.PlcC:\Work\PlcProject.tmc是TMC文件的完整路径-o指定输出目录不传就默认在当前目录下建一个Generated文件夹--namespace决定生成代码的命名空间建议用PLC项目名加.Plc后缀以后引用起来语义清楚。跑完之后Generated目录下会多出两个.cs文件一个是结构体定义一个是PlcSymbols符号常量类。把这两个文件直接拖进上位机项目里会看到生成的结构体字段名都是Pascal命名和PLC里的变量名一一对应。4.2 用AdsClient连接PLC并读取变量连接PLC和读写变量我封装了一个PlcConnector把ADS那层细节盖在下面业务代码只需要关心类型和符号名using System; using System.Runtime.InteropServices; using TwinCAT.Ads; public class PlcConnector { private readonly AdsClient _client; public PlcConnector(string amsNetId, int port 851) { _client new AdsClient(); _client.Connect(amsNetId, port); } public T ReadStructT(string symbolName) where T : struct { ITcAdsSymbolInfo symbolInfo _client.ReadSymbolInfo(symbolName); byte[] buffer _client.Read( symbolInfo.IndexGroup, symbolInfo.IndexOffset, symbolInfo.Size) as byte[]; return BytesToStructT(buffer); } public void WriteStructT(string symbolName, T data) where T : struct { ITcAdsSymbolInfo symbolInfo _client.ReadSymbolInfo(symbolName); _client.WriteAny(symbolInfo.IndexGroup, symbolInfo.IndexOffset, data); } private T BytesToStructT(byte[] buffer) where T : struct { GCHandle handle GCHandle.Alloc(buffer, GCHandleType.Pinned); try { return Marshal.PtrToStructureT(handle.AddrOfPinnedObject()); } finally { handle.Free(); } } }amsNetId是PLC在ADS网络里的地址格式类似192.168.0.1.1.1这个可以在TwinCAT的System Manager里查到不是随便填的。port默认851这是PLC运行时常用的AMS端口如果现场改过端口这里要跟着改。ReadSymbolInfo这一步是从ADS服务器拿到变量的内存信息包含IndexGroup、IndexOffset和字节大小然后Read按这三个参数把原始字节读回来。BytesToStruct用的Marshal方向前提就是前面生成的是struct而不是class并且结构体布局是LayoutKind.Sequential。如果你的PLC结构体里大量用了STRING或者数组生成的代码里已经加了MarshalAs转换时会按标好的长度逐个解析不会因为字段类型不对而跑飞。实际调用长这样var plc new PlcConnector(192.168.0.1.1.1); ST_Tank tank plc.ReadStructST_Tank(PlcSymbols.MAIN_Tank); Console.WriteLine($液位: {tank.Level}); tank.TankID 42; plc.WriteStruct(PlcSymbols.MAIN_Tank, tank);符号名里的点号已被替换成下划线所以MAIN.Tank对应常量MAIN_Tank看起来别扭但至少编译期就能发现变量名拼写错误。4.3 验证生成结果的方式验证是这套流程里最让我长记性的一环。没有PLC实体环境时可以用TwinCAT自带的仿真器先把ADS地址配通再跑上面那段代码。有真实PLC的环境建议第一件事先读原始字节打印出来和TMC文件里的BitOffset对照一遍ITcAdsSymbolInfo symbolInfo plc.ReadSymbolInfo(PlcSymbols.MAIN_Tank); byte[] raw plc.ReadRaw(symbolInfo.IndexGroup, symbolInfo.IndexOffset, symbolInfo.Size); Console.WriteLine(BitConverter.ToString(raw));如果生成的struct解析出来的字段和原始字节对不上先别怀疑ADS回到TMC文件里看BitOffs是不是从0开始再看你的字段顺序是不是按BitOffs升序排列。三分之二的踩坑都出在这里后面避坑章节会细说。5. 常见问题与避坑现场最容易翻车的五个点这一章写的都是我在模拟项目X里实际踩过的坑每一条都按“现象、原因、解决”的顺序记录希望能帮你少走几个来回。5.1 结构体读出来全是0现象用生成的struct去读PLC变量字段全是0但TwinCAT的在线监控里明明有值。原因大概率是TMC文件是旧的PLC工程后来改过结构体但导出TMC没有重新做符号的IndexOffset已经变了。另一种可能是你连接的是另一个端口或另一台设备ADS连接地址对上了但实际指向的不是这个PLC任务。解决每次PLC工程重新编译后强制导出一次最新的TMC文件再重新生成C#代码。现场排查时先读一个已知的int变量试试如果连基本类型都读不对问题就在连接地址而不是生成代码。5.2 BOOL字段Marshal后多出几个字节现象结构体里BOOL字段后面紧跟着的字段值不对整个结构体解析结果偏移了3个字节。原因C#的bool默认在Marshal时被当成32位处理而PLC里的BOOL只有1个字节。结构体字节流里一个BOOL占了4字节后续字段全错位。解决生成器里对BOOL类型加[MarshalAs(UnmanagedType.I1)]强制按1字节处理。如果你没走Marshal而是手工按偏移量读字节那就在生成时把BOOL换成byte需要bool的地方自己做转换。5.3 STRING字段乱码或长度对不上现象读出来的字符串带一堆\0或者只能读到前半段后半段丢字。原因PLC的STRING是定长的短字符串后面会填满空字符。C#端如果SizeConst配小了字符串截断如果配大了又会把相邻字段的字节一起拉进来。解决生成器里直接用TMC的BitSize / 8作为SizeConst不要自己猜长度。读取后在业务层做一次TrimEnd(\0)确保界面显示的时候干净。WSTRING的情况类似但它是宽字符编码SizeConst要按字节数除以2计算这一点我在代码里专门注释了。5.4 数组生成和PLC实际布局不一致现象数组字段解析出来的元素全是错的或者数组长度明显不对。原因TMC里数组的类型描述可能是ARRAY[0..9] OF INT也可能在子节点里给单独的元素类型定义。生成器如果只按sub.Type去查基础类型表遇到数组就直接返回null最后生成一个错误的字段类型。解决先看你的TMC里数组是怎么写的再改ResolveType里的ParseArrayElementType逻辑。数组长度要从维度信息算别拿总位宽直接当长度。生成后增加一条断言BitSize count * elementBitSize对不上就抛异常让问题暴露在生成阶段。5.5 注释和枚举信息丢失现象生成的C#代码没有注释PLC里写好的变量说明全丢了。原因TMC文件里其实带了注释信息但很多生成器只处理了Name和Type节点根本没读注释节点。解决解析TMC时把注释节点也拉进中间模型生成时拼成/// summary。工程大了之后你会发现没有注释的自动生成代码比没有注释的手写代码更让人绝望因为大家默认它是机器生成的没人会去补注释。6. 二次开发把生成器改造成你的代码工厂这个生成器已经能解决80%的场景剩下的20%需要你自己动刀。我给出两个最实用的改造方向。6.1 自定义类型映射与注释导出如果你项目里大量使用了自定义的类型别名比如把DINT定义成TIME_OF_DAY或者公司内部有统一的UDT命名规范直接在BasicMap字典里加一行就行。注意ResolveType的查询顺序基础类型表优先数组其次最后才是按嵌套结构体处理。如果你自定义的类型名和TMC里的DataType重名会被当成嵌套结构体这时就需要在字典里显式覆盖。注释导出我强烈建议做。TMC的注释节点在不同版本里位置不一样但逻辑都一样把注释文本取出来生成属性时拼成XML文档注释。改造量不大收益却很大团队里其他人看到自动生成的代码也知道每个字段是干嘛的不用再翻PLC源码。6.2 接入构建流程我现在的习惯是把它塞进MSBuild的预构建事件里TMC文件一变化编译上位机项目前自动重新生成一遍。如果你用CI可以在PLC工程出包后加一个步骤跑生成器、提交生成的.cs文件再触发上位机构建。这样PLC和上位机的版本永远绑定在一起谁改没改一眼就能看出来。从那以后我每次拿到新版PLC程序都会强制走一遍这套流程导出TMC、跑生成器、编译上位机工程、用实测点位读一次真实数据。全通过才算交接完成也算是对自己挖过的坑有个交代。希望帮到你。本文还有配套的精品资源点击获取