简介:这份资源为倍福PLC开发人员提供了一整套TMC文件转C#类的自动化工具与运行环境,解决了在C#应用程序中直接读写PLC结构体数据时需手工编写映射代码的痛点,显著降低上位机与控制器之间的对接成本。开发者在.NET环境下运行AutoCode工具,即可将TMC文件中的结构体定义、变量声明自动解析,批量生成对应的C#类源码及可复用的DLL动态库,再配合倍福官方的TwinCAT.Ads库快速实现与PLC的双向数据通信,做到一次生成、多项目复用。资源包共19个文件、压缩后约2.1MB,包含AutoCode主程序、TwinCAT.Ads与MySql.Data等核心依赖DLL、运行配置文件及示例应用程序清单,结构精简可直接部署使用。目前已有226人下载学习,适合正在用C#开发倍福上位机、需要提高PLC数据交互开发效率的自动化工程师参考。 做上位机的朋友应该都有过这种经历:项目里几十个轴,手动在C#代码里写结构体、定义变量名、维护地址偏移,改一次PLC工程就要对着文档吐血核对一遍。倍福PLC的TMC文件就是为了解决这个痛点而存在的——它把轴的配置、参数、输入输出变量都结构化地保存在XML里,我们完全可以把这个文件变成C#类,让代码自动生成,省去重复劳动。这篇文章我就从TMC文件的结构讲起,带你走一遍从XML到C#类的完整流程,包括生成器的核心实现,以及生成之后如何接入ADS通信真正跑起来。适合刚接触倍福上位机开发,或者已经被轴配置反复折磨的C#工程师参考。
1. 为什么要把TMC文件变成C#类——先看透你手头的痛点
1.1 手动维护轴变量的日子,真的过不下去了
先描述一个典型场景:TwinCAT工程里有8个伺服轴,每个轴需要读取实际位置、写入目标位置、读写控制字和状态字,还可能有速度、加速度、跟随误差、扭矩等几十个变量。按传统方式,你得在C#里做两件事:
- 在代码里定义一堆字符串常量,比如
"Axis1.NcToPlc.ActPos"、"Axis1.PlcToNc.SetPos",然后通过ADS库的CreateVariableHandle去绑定。一旦PLC工程里轴名改成Axis_01,或者某个变量换了个名字,C#这边不报错,但运行时就抓瞎。 - 或者用
ReadSymbolInfo去动态查询符号表,每次读变量前都要拼字符串、做类型转换,代码冗余不说,一个拼写错误就要调试半天。
我见过最夸张的项目,上位机里维护了一张700多行的“变量地址对照表”,每次PLC更新版本就要人肉核对一遍。这种方案不叫开发,叫维护石头。
1.2 TMC文件解决的是“离线类型安全”问题
TMC文件是TwinCAT工程导出的一份XML配置,里面保存了NC轴模块的完整信息:轴类型、周期时间、控制字状态字定义、参数列表、输入输出Symbol集合。它本身不是运行时动态变化的符号表,而是工程编译前就定好的“蓝图”。这意味着我们可以在开发期间把它解析成C#类,把这些信息变成编译期就能检查的类型。
比如一个轴的实际位置,TMC里定义的数据类型是LREAL,我们生成C#类后就是double类型的ActPos属性;控制字是WORD,生成后就是ushort的ControlWord。写错类型、写错名字,编译器直接给你报错,根本轮不到运行时才发现。
1.3 从TMC到C#类,最直接的三个收益
- 开发效率提升:几十上百个轴变量不需要手写,解析TMC一次生成,后面复用。
- 运行时稳定:所有ADS读写的地址来源于生成的常量或属性,不再依赖人手敲字符串,杜绝拼写错。
- 工程可追溯:TMC文件和C#类一一对应,PLC工程升级后重新生成一遍,对照diff就能知道变量变了什么。
2. TMC文件里藏着什么——关键节点与离线生成的理论基础
2.1 从哪里拿到TMC文件
在TwinCAT XAE环境中,找到NC轴配置(Motion或者NC-Task下方的轴),右键轴的模块条目,通常有“Export TMC File”之类的导出选项。导出的文件就是一个XML格式的.tmc文件。如果你手上只有别人给的一份TMC,也可以直接作为输入解析。还有一点:TwinCAT安装目录的Sample工程里也会附带一些TMC模板,适合先拿来做格式研究。
2.2 XML核心结构:DataTypes、DataAreas、Module
把TMC文件用文本编辑器打开,别看它动辄几百KB,核心结构其实非常清晰。
<TcModuleClass> <DataTypes> <!-- 定义结构体、枚举、别名等 --> </DataTypes> <DataAreas> <Area> <Symbols> <Symbol> <Name>Axis1.NcToPlc.ActPos</Name> <Type>LREAL</Type> <BitSize>64</BitSize> <IndexGroup>16#00010220</IndexGroup> <IndexOffset>16#00000000</IndexOffset> <Comment>Actual position</Comment> <SubItems>...</SubItems> </Symbol> </Symbols> </Area> </DataAreas> <Module> <Name>AXIS1</Name> <CLSID>...</CLSID> <ParameterList>...</ParameterList> </Module> </TcModuleClass>几个节点各干各的:
DataTypes:存放复杂类型定义,比如MC_AXIS_REF结构体、MC_CONTROL_WORD枚举等,里面有字段名和位宽。DataAreas/Area/Symbols:这是重头戏,每个Symbol节点对应一个ADS可访问的变量,包含变量名、数据类型、位宽、IndexGroup和IndexOffset。我们生成C#类时主要处理和利用这一部分。Module:模块级参数,一般用来拿轴名称、CLSID等元信息。
2.3 为什么TMC离线和ADS运行时能对上
有人会问:TMC是离线配置文件,它和PLC运行时内存里的符号表是一回事吗?答案是:TMC描述的是NC模块的接口配置,而ADS符号表是TwinCAT实时系统加载后暴露给上位机的运行时视图。两者在名字、数据类型、IndexGroup和IndexOffset上是严格对应的——因为TwinCAT在加载NC模块时,就是按照TMC的描述去创建这些变量的。所以我们可以用TMC离线生成C#类,等程序连上PLC后再通过ADS的ReadSymbolInfo或者Handle去访问,地址完全对得上。
这一点也是整个方案的基石:离线生成类型安全代码 + 运行时ADS动态绑定。
3. 两种主流落地路线:开发期生成强类型类,还是运行时解析
3.1 方案A:开发期生成C#类文件(我推荐)
写一个生成器程序,输入TMC文件,输出一个或多个.cs文件。生成结果编译进上位机项目,之后所有访问都走强类型代码。
优点很明显:
- 编译期类型检查,杜绝字符串拼错。
- 属性和字段可以直接在IDE里智能提示,写起来快。
- 生成过程可以接入CI或构建脚本,PLC工程更新后一键重新生成。
缺点也有:TMC一变,需要重新生成并编译上位机。但话说回来,PLC工程变化本来就该触发上位机联调,这算不上负担。
3.2 方案B:运行时解析XML,动态构建访问器
程序启动时读取TMC文件,用XDocument或者XmlSerializer解析,反射生成属性访问器,运行时调用ADS读取。
好处是部署灵活,PLC工程改了TMC文件,上位机不用重编译;坏处是把“类型安全”丢掉了,属性访问基本靠反射或者字典,维护起来反而麻烦。我见过有人用这个方案做通用监控面板,但那属于泛用工具,不适合做面向工艺的专用上位机。做项目我还是推荐方案A,下面展开的也是方案A。
3.3 还有一个变种:开发期生成器 + 运行时校验
折中方案是用生成器产出代码,但程序启动时用ReadSymbolInfo批量校验一遍所有变量是否存在、类型是否匹配。这样既有开发期的强类型体验,又保留运行时的快速反馈。我自己的项目基本都这么干,上线前能提前暴露变量漂移问题。
4. 生成器的完整拆解:从XML节点到C#代码的转换过程
4.1 生成器的整体流程
写生成器不是让你把整个XML翻译一遍,而是抽取有用的Symbol信息,输出一个方便C#使用的访问层。我的实现分三步:
- 读取TMC文件,遍历
DataAreas/Area/Symbols下所有Symbol节点。 - 对每个Symbol,提取Name、Type、BitSize、IndexGroup、IndexOffset。
- 按轴名分组,为每个轴生成一个C#类,同时生成一个静态符号常量类。
下面这段代码是生成器的核心骨架,用XDocument解析,简单直接。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Linq; namespace TmcCodeGenerator { public class SymbolInfo { public string Name { get; set; } public string Type { get; set; } public int BitSize { get; set; } public uint IndexGroup { get; set; } public uint IndexOffset { get; set; } public string Comment { get; set; } } public static class TmcParser { public static List<SymbolInfo> ParseSymbols(string tmcPath) { var doc = XDocument.Load(tmcPath); var symbols = new List<SymbolInfo>(); var symbolNodes = doc.Descendants("Symbol"); foreach (var node in symbolNodes) { var info = new SymbolInfo { Name = node.Element("Name")?.Value?.Trim(), Type = node.Element("Type")?.Value?.Trim(), BitSize = int.TryParse(node.Element("BitSize")?.Value, out var bits) ? bits : 0, Comment = node.Element("Comment")?.Value?.Trim(), }; // IndexGroup 在 TMC 里写作 16#00010220 这种格式 info.IndexGroup = ParseHexValue(node.Element("IndexGroup")?.Value); info.IndexOffset = ParseHexValue(node.Element("IndexOffset")?.Value); if (!string.IsNullOrEmpty(info.Name)) symbols.Add(info); } return symbols; } private static uint ParseHexValue(string raw) { if (string.IsNullOrWhiteSpace(raw)) return 0; var cleaned = raw.Replace("16#", "").Replace("0x", ""); return Convert.ToUInt32(cleaned, 16); } } }4.2 TMC类型到C#类型的映射规则
这一步是生成代码质量的关键。TMC里的类型名和C#原生类型不是一一对应,需要做映射。我整理了一份常用的映射表,基本覆盖NC轴里会出现的主要类型:
| TMC类型 | C#类型 | 说明 |
|---|---|---|
| BOOL | bool | 布尔 |
| BYTE | byte | 无符号8位 |
| WORD | ushort | 无符号16位 |
| DWORD | uint | 无符号32位 |
| SINT | sbyte | 有符号8位 |
| INT | short | 有符号16位 |
| DINT | int | 有符号32位 |
| REAL | float | 单精度浮点 |
| LREAL | double | 双精度浮点 |
| STRING(n) | string | 字符串 |
| 其他结构体 | 对应生成的C# struct | 按DataTypes递归生成 |
注意一点:TMC里经常出现的是MC_AXIS_REF、ST_AxisParameter这类结构体,它们在DataTypes节点里有字段级定义。如果轴参数全部绑定到某个结构体(比如把PID参数打包成一个结构体),你可以选择把它展开为扁平属性,也可以生成对应的C#结构体。展开为扁平属性对ADS读写更友好,因为ReadAny一次只能读连续内存;生成结构体则更适合整块读写。我一般混合用:常用状态变量展平,参数结构体保留为C# class或struct。
4.3 输出C#类文件的生成逻辑
生成器输出分两部分:一个McSymbols静态类保存全部符号的IndexGroup/IndexOffset,一个Axis类封装每个轴的读写属性。下面是一个生成器输出代码的简化示意:
public static class McSymbols { public const string Axis1_NcToPlc_ActPos = "Axis1.NcToPlc.ActPos"; public const string Axis1_PlcToNc_SetPos = "Axis1.PlcToNc.SetPos"; public const string Axis1_PlcToNc_ControlWord = "Axis1.PlcToNc.ControlWord"; public const string Axis1_NcToPlc_StatusWord = "Axis1.NcToPlc.StatusWord"; public const string Axis2_NcToPlc_ActPos = "Axis2.NcToPlc.ActPos"; }这里我只写常量,但实际我还会把每个Symbol的IndexGroup/IndexOffset放进一个字典,方便直接用索引地址访问。因为在某些高频率读取场景,直接用IndexGroup/IndexOffset比用字符串Handle更快,不用每次查句柄。
public class Axis { private readonly AdsClient _ads; private readonly string _prefix; public Axis(AdsClient ads, string prefix) { _ads = ads; _prefix = prefix; } public double ActPos => ReadDouble($"{_prefix}.NcToPlc.ActPos"); public double SetPos { set => WriteDouble($"{_prefix}.PlcToNc.SetPos", value); } public ushort ControlWord { set => WriteUInt16($"{_prefix}.PlcToNc.ControlWord", value); } private double ReadDouble(string symbolName) { var handle = _ads.CreateVariableHandle(symbolName); var value = (double)_ads.ReadAny(handle, typeof(double)); _ads.DeleteVariableHandle(handle); return value; } private void WriteDouble(string symbolName, double value) { var handle = _ads.CreateVariableHandle(symbolName); _ads.WriteAny(handle, value); _ads.DeleteVariableHandle(handle); } }如果你的项目轴特别多,遍历Symbols时按轴名分组,再拼出这样一个类就行。这里的AdsClient来自TwinCAT.Ads包,稍后第5章会讲怎么集成。
4.4 把生成器做成一个可复用工具
写生成器时不要目标定太窄。我建议把它做成命令行小工具,支持三个参数:TMC文件路径、输出目录、命名空间。这样每次PLC工程变更,只要跑一条命令就能重新生成所有代码。
TmcGen.exe --tmc D:\PlcProject\Axis.tmc --out D:\CsProject\Generated --ns MyPlc.NcAxes然后把这个命令挂到项目构建前事件里,或者CI流水线里,PLC工程师导出TMC文件后,上位机项目一编译就自动更新,从源头上杜绝两边不一致。
5. 生成的类怎么和ADS通信接上——把代码放回真实项目
5.1 引入TwinCAT.ADS库并建立连接
生成出来的类只是“骨架”,真正去PLC里读写数据还要靠ADS库。在NuGet里安装TwinCAT.Ads包,然后建立连接。ADS通信走TCP 48898端口,AmsNetId是PLC的AMS地址。
using TwinCAT.Ads; var client = new AdsClient(); client.Connect(new AmsNetId("192.168.0.1.1.1"), 851);如果你的PLC和上位机在同一个局域网,也可以直接用IP地址加端口连接,TwinCAT.Ads库会处理好AMS路由。连接建立后,把client传给生成的Axis类,就可以用属性读写轴数据了。
5.2 一个完整的轴读写示例
比如我们要做的动作是:读取1号轴当前位置,然后给1号轴写一个目标位置。用生成的类就是下面这样,干净、直白:
using (var ads = new AdsClient()) { ads.Connect(new AmsNetId("192.168.0.1.1.1"), 851); var axis1 = new Axis(ads, "Axis1"); double currentPos = axis1.ActPos; axis1.SetPos = 1000.5; axis1.ControlWord = 0x0006; // 使能 }你不需要在业务代码里再出现任何字符串类型的变量名。轴名变了、变量名变了,生成代码会反映出来,编译期就能发现。
5.3 高频读写的性能优化
上面示例里每次读写都创建和删除Handle,这在高频读取场景(比如1ms周期、多个轴同时读)会造成不小的开销。实操建议是:在Axis类的构造函数里把常用变量的Handle一次性建好,然后整个生命周期复用。
更进一步的优化是,用indexGroup/indexOffset直接读,不走符号名。低速场合无所谓,但如果要做示波器、曲线绘制这类每秒几千次采样的功能,直接把IndexGroup/IndexOffset交给AdsClient.ReadAny更稳。这也是我在4.3节强调要把地址存进字典的原因。
var actPosAddr = McSymbols.SymbolIndex["Axis1.NcToPlc.ActPos"]; double pos = ads.ReadAny(actPosAddr.IndexGroup, actPosAddr.IndexOffset, typeof(double));另外,TwinCAT.Ads库支持AdsNotification或者更上层的Notification机制,可以订阅变量变化事件,避免频繁轮询。订阅时同样用生成的符号名或地址,回调里拿到的数据就是强类型转换后的值。
6. 往后踩坑的预警:单位、多轴和版本更新的三个心法
6.1 位置单位:别被“double”迷惑
TMC文件里的数据类型告诉你这个变量是LREAL,所以C#里映射成double。但double不代表单位是毫米,倍福NC轴的位置单位完全取决于TwinCAT工程里的缩放配置。有的工程内部用mm,有的用µm,有的用inc。我见过不止一次联调现场两边对不上位置值,上位机显示1000,PLC那边实际走了1米——最后发现是单位看错了。
所以生成类时,我建议在类注释或者属性上加一个单位标记。最省事的方法是在Axis类里放一个公开的UnitScale字段,默认1.0,按实际工程配置调整。代码生成时可以从TMC的ParameterList里自动识别有没有单位配置,有就填上,没有就建议工程师手动确认。
6.2 多轴项目:抽象一个公共基类
几个轴还好说,轴一多你会发现每个轴的代码高度相似。我把生成的Axis类拆成两层:一个手写的AxisBase公共类,负责ADS读写、Handle缓存、单位换算等通用逻辑;一个由TMC生成的Axis1、Axis2等子类,只包含这个轴自己的符号属性和地址。这样生成代码很短,业务逻辑也很集中,将来加一个轴不会把所有代码都复制一遍。
我在实际项目中还加了一层简单的依赖注入,每个轴通过构造器注入AdsClient,这样调试时可以替换成模拟客户端,不用连PLC也能先跑通界面逻辑。
6.3 TwinCAT版本升级导致TMC结构漂移
TwinCAT 3.1版本迭代过程中,NC模块的Symbol命名和类型定义偶尔会调整,尤其是4024.4之后的一些版本。线上遇到过一次:老版本TMC里轴状态字叫StateWord,升级后变成了StatusWord,如果生成器写死变量名,升级后就会生成一堆空的或错误的属性。
我的应对方法是:TMC生成器的映射规则不做死,而是读取TMC里实际的Symbol名字和Comment来匹配用途,比如通过Comment里包含“status”关键字来识别状态字。更稳妥一点,维护一张“逻辑名到实际Symbol名”的映射配置表,生成器从配置驱动,而不是硬编码规则。这样PLC工程升级后,改改映射表,重新生成即可,代码主体不用动。
回到开头说的那个场景:以前维护700行地址对照表的日子,现在变成了一条命令重新生成。TMC文件生成的C#类并不神秘,它就是把XML里已经存在的轴信息,翻译成编译器能帮你把关的强类型代码。顺着这个思路,不只是轴配置,TwinCAT里其他模块的TMC文件也可以走同一套流程。你项目里若有这种机械重复的映射工作,值得花一个下午把生成器写出来,这笔时间投资不会亏。
本文还有配套的精品资源,点击获取