简介:面向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> <Name>ST_Tank</Name> <BitSize>64</BitSize> <SubItems> <SubItem> <Name>Level</Name> <Type>REAL</Type> <BitSize>32</BitSize> <BitOffs>0</BitOffs> </SubItem> <SubItem> <Name>TankID</Name> <Type>INT</Type> <BitSize>16</BitSize> <BitOffs>32</BitOffs> </SubItem> </SubItems> </DataType> </DataTypes> <DataAreas> <DataArea> <AreaNo>0</AreaNo> <Symbols> <Symbol> <Name>MAIN.Tank</Name> <Type>ST_Tank</Type> <BitSize>64</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类型(TMC) | C#类型 | 说明 |
|---|---|---|
| BOOL | bool | 注意Marshal时要用UnmanagedType.I1 |
| SINT | sbyte | 1字节有符号 |
| INT | short | 2字节 |
| DINT | int | 4字节 |
| LINT | long | 8字节 |
| USINT | byte | 1字节无符号 |
| UINT | ushort | 2字节 |
| UDINT | uint | 4字节 |
| ULINT | ulong | 8字节 |
| BYTE | byte | 与USINT同宽 |
| WORD | ushort | 2字节 |
| DWORD | uint | 4字节 |
| REAL | float | 4字节IEEE 754 |
| LREAL | double | 8字节 |
| STRING | string | 定长,需SizeConst |
| WSTRING | string | 宽字符,注意编码 |
| TIME | uint | 单位毫秒 |
| TOD | uint | 自零点起的毫秒 |
| DATE | uint | 自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 Dictionary<string, string> BasicMap = new Dictionary<string, 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(List<PlcSymbol> 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 ReadStruct<T>(string symbolName) where T : struct { ITcAdsSymbolInfo symbolInfo = _client.ReadSymbolInfo(symbolName); byte[] buffer = _client.Read( symbolInfo.IndexGroup, symbolInfo.IndexOffset, symbolInfo.Size) as byte[]; return BytesToStruct<T>(buffer); } public void WriteStruct<T>(string symbolName, T data) where T : struct { ITcAdsSymbolInfo symbolInfo = _client.ReadSymbolInfo(symbolName); _client.WriteAny(symbolInfo.IndexGroup, symbolInfo.IndexOffset, data); } private T BytesToStruct<T>(byte[] buffer) where T : struct { GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); try { return Marshal.PtrToStructure<T>(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.ReadStruct<ST_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、跑生成器、编译上位机工程、用实测点位读一次真实数据。全通过才算交接完成,也算是对自己挖过的坑有个交代。希望帮到你。
本文还有配套的精品资源,点击获取