恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

解析TMC文件自动生成C#结构体,PLC上位机通信不再靠手抄

  • 首页
  • 资讯中心
  • /
  • 解析TMC文件自动生成C#结构体,PLC上位机通信不再靠手抄

相关资讯

2025 Agent 上下文工程实战:用 TaoToken 统一 Key 打通 Context Engineering 记忆链路,避坑指南! 2026/10/18 16:37:20
DockerDesktop容器化部署HermesAgent:TaoToken统一Key接入与本地验证 2026/10/18 16:37:20
UltraEdit 列编辑删除整列:Ctrl+C 选中列后一键清除的快捷键配置与验证 2026/10/18 16:37:20

最新资讯

BrowserSkill:基于DevTools协议的CLI浏览器自动化框架
Android 入门精要:Java 移动开发从环境搭建到网络请求实战
knowledge-work-plugins 插件实战:Claude Code 知识工作流标准化指南
WPS表格图片批量处理源码:Python自动化缩放、水印与单元格写入
Claude-Code规则引擎配置避坑指南:YAML语法、AST匹配与沙箱脚本
Hrcad平纵面设计模块实战:交点法、竖曲线与出图避坑指南

今日推荐

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本周热门

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

解析TMC文件自动生成C#结构体,PLC上位机通信不再靠手抄

发布时间:2026/10/18 16:37:20
解析TMC文件自动生成C#结构体,PLC上位机通信不再靠手抄 简介面向工业自动化领域C#开发人员这份工具包围绕倍福PLC的TMC文件自动生成C#类帮助开发者省去手工编写结构体映射的繁琐工作直接基于ADS协议在.NET环境中读写PLC变量。压缩包共19个文件以可执行程序、DLL动态库和配置文件为主体积仅2.1MB其中包含TwinCAT.Ads.dll等关键ADS通信库以及编译生成的exe与配套运行库解压后即可组合使用方便快速集成到项目中。已有228人学习下载。通过该工具可将TMC中的结构体解析为C#类源码并生成可直接引用的DLL让PLC变量以面向对象方式访问同时支持借助ADS库实现实时数据读写与监控适用于设备状态看板、上位机控制界面等常见场景。对于需要频繁对接倍福控制系统的工程师这是一套轻量且实用的代码生成与通信基础组件。1. 从TMC文件到C#类给工控上位机通信代码减负做过工控上位机开发的同事大概都有这种体验要读PLC里一个几十字段的电机状态结构体不敢用掉点模式只能照着PLC程序的变量声明把字段名、类型、嵌套层级一个个抄进C#类。抄完了还要担心BOOL的位偏移、STRING的长度、数组的上下标稍微看漏一行读出来的数据整个错位。这个项目解决的就是这件事它把PLC导出的TMC文件当作类型模型自动生成对应的C#结构体类让通信代码从“手抄”变成“生成”。适合正在写数据采集、设备监控上位机的人也适合PLC工程师想给自己省点重复劳动的场景。2. 技术背景与前置准备TMC文件里藏着PLC的类型“家底”2.1 TMC文件不是配置文件是类型模型TMC文件是PLC工程编译后自动生成的XML格式文件名字看起来像配置文件实际作用更像数据库的Schema。文件里除了版本信息主要分四个区DataTypes、Symbols、Instances、GlobalVars。DataTypes区里定义了PLC程序用到的全部结构化数据类型包括结构体、数组、枚举、字符串定义Symbols区把PLC中的全局变量实例与这些类型关联起来告诉你某个变量在内存里的起始偏移和总长度。很多人第一次打开TMC文件会失望因为里面没有一句注释全是BitOffs、BitSize、SubItem这类枯燥的属性。但恰恰是这些属性最值钱。BitOffs是成员字段在这个结构体内部的位偏移BitSize是字段的总位宽。只要按照这两个值把字段排好序生成的C#结构体内存布局就能和PLC侧完全对齐这是手抄代码很难保证的。生成器真正要处理的其实是DataTypes区。Symbols区可以留着做符号表也可以不用。因为上位机用ADS协议读写PLC时通信库可以通过变量句柄拿到内存地址我们只需要保证C#侧的类型布局和PLC侧一致不需要知道变量被放在PLC的哪个区域。2.2 开发环境与工具链选型这个生成器我选用Python来做原因很直接TMC是XMLPython标准库里的xml.etree.ElementTree就能解析不需要引入第三方依赖换一台没有网环境的工控机也能直接跑。输出目标是C#类文件生成的代码面向.NET Standard 2.0这样不管是.NET Framework的老项目还是.NET 6的新项目都能引用。为什么不直接用T4模板或者Xsd.exe因为TMC不是标准XSD文件它包含很多自定义规则比如STRING类型默认长度是81字节、BOOL数组可能按位打包、枚举类型没有SubItem而是独立的EnumInfo节点。这些规则在通用代码生成工具里没法表达还是自己写一个解析加生成的小工具最可控。生成器的整体流程很简单读TMC文件解析DataTypes区把PLC类型名映射成C#类型名按BitOffs排序生成结构体字段最后输出.cs文件。整个链路不超过四个函数每个函数只做一件事后续要加规则也方便。2.3 依赖库与运行时要求生成器本身只依赖Python标准库但生成的C#类最后要跟PLC通信所以运行期需要引用一个ADS通信库。ADS是PLC厂商常用的设备通信协议对应的动态库在安装PLC开发环境时会一并提供也可以单独从厂商发布的SDK包里找。我在下文示例中使用PlcAdsLib作为占位命名空间实际项目中把它替换成你本地的ADS库引用即可。生成的C#类还有一个隐含依赖结构体成员用到了StructLayout和MarshalAs这两个都在System.Runtime.InteropServices命名空间下属于.NET标准库自带内容不用额外装包。字符串数组字段用ByValTStr封送需要你在生成时把PLC字符串的最大长度算出来写进SizeConst所以生成器必须解析TMC里的字符串长度定义不能简单地把STRING映射成string就完事。3. 核心模块拆解一条从XML到C#类的完整生成链路3.1 解析DataType拿到PLC的数据类型清单第一步是把TMC文件里所有的结构体类型提取出来。下面这段代码是解析的入口我刻意保持了最简形态方便你在此基础上加字段。import xml.etree.ElementTree as ET def parse_data_types(root): types {} for dt in root.iter(DataType): name dt.findtext(Name, ).strip() if not name: continue # 跳过内部私有类型 if name.startswith(__) or name.endswith(_private): continue subs [] # 注意这里只用 findall不能 iter for item in dt.findall(SubItem): sub_name item.findtext(Name, ).strip() sub_type item.findtext(Type, ).strip() bit_offs int(item.findtext(BitOffs, 0) or 0) bit_size int(item.findtext(BitSize, 0) or 0) subs.append({ name: sub_name, type: sub_type, bit_offs: bit_offs, bit_size: bit_size, }) types[name] { bit_size: int(dt.findtext(BitSize, 0) or 0), sub_items: subs, } return types这里有一个我踩过的大坑root.iter(DataType)会连嵌套在别的节点里的DataType一起找出来而dt.iter(SubItem)会把数组元素内部展开的子项也当成顶层成员。必须用findall(SubItem)只取当前DataType节点的直接子项否则同一个结构体生成出来会多出一大堆重复字段编译直接报“成员已存在”。BitOffs的单位是位不是字节。比如一个REAL字段在结构体里偏移是32表示它从第4字节开始。生成结构体时我不直接用这个值去排字段顺序而是把它除以8得到字节偏移再按字节偏移升序摆放字段这样配合Pack1才能保证内存对齐一致。3.2 类型映射把PLC类型翻译成C#类型拿到字段名和类型名后最核心的映射逻辑在这里。PLC有自己的类型体系比如DINT是32位有符号整数WORD通常被当成16位无符号整数REAL对应C#的floatLREAL对应double。PLC_TYPE_MAP { BOOL: bool, BYTE: byte, SINT: sbyte, USINT: byte, INT: short, UINT: ushort, DINT: int, UDINT: uint, WORD: ushort, DWORD: uint, REAL: float, LREAL: double, STRING: string, } def map_plc_type(raw_type, known_types): raw_type raw_type.strip() # 数组单独处理 if raw_type.startswith(ARRAY): return handle_array(raw_type, known_types) # 去掉命名空间前缀比如 MAIN.ST_MotorState if . in raw_type: raw_type raw_type.split(.)[-1] if raw_type in PLC_TYPE_MAP: return PLC_TYPE_MAP[raw_type] if raw_type in known_types: return sanitize_type_name(raw_type) # 未知类型保守处理返回原字符串在生成阶段报错 return raw_typemap_plc_type前我加了一个known_types参数这个参数来自3.1节解析出来的types字典的键集合。如果字段的类型名能在已知类型里找到说明它是一个嵌套结构体生成时用它本身的类型名作为C#类型名如果找不到说明可能是PLC库自带类型或我没有解析到的东西直接原样返回会生成一个不存在的C#类型编译期会暴露问题。关于类型命名还要再补一刀。TMC里同一个结构体类型可能在不同库中重名直接拿原名会撞车。我的处理是把类型名前缀里的库名去掉但如果去重后发现还有冲突就按“短名称哈希后四位”的方式重命名避免两个库同名类型互相覆盖。3.3 代码生成拼接结构体模板与字段属性解析和映射都准备好之后生成器的主函数就是把类型信息拼成C#代码。下面这段代码展示了如何为一个结构体生成完整的C#声明并将位偏移转换为布局顺序。def emit_struct(type_name, sub_items, known_types): fields [] for item in sorted(sub_items, keylambda x: x[bit_offs]): csharp_type map_plc_type(item[type], known_types) field_name sanitize_member_name(item[name]) bit_offs item[bit_offs] if bit_offs % 8 ! 0: # 位域场景生成时标记出来 fields.append(f // WARNING: bit offset {bit_offs} is not byte aligned) continue if csharp_type string: size item.get(string_size, 81) fields.append( f [MarshalAs(UnmanagedType.ByValTStr, SizeConst{size})] ) fields.append(f public string {field_name};) elif csharp_type.startswith(ArrayOf): array_size item.get(array_size, 0) fields.append( f [MarshalAs(UnmanagedType.ByValArray, SizeConst{array_size})] ) fields.append(f public {item[array_elem_type]}[] {field_name};) else: fields.append(f public {csharp_type} {field_name};) fields.append() lines [ [StructLayout(LayoutKind.Sequential, Pack1)], fpublic struct {sanitize_type_name(type_name)}, {, ] lines.extend(fields) lines.append(}) return \n.join(lines)这段代码里有几个关键点。字段用sorted按bit_offs排序保证生成的字段顺序和PLC内存顺序一致否则Sequential布局会错位。位偏移不是8的倍数时说明字段是真正的位域C#里不方便直接用我在生成结果里保留一行注释提醒你手动拆成布尔位。Pack1很重要。默认布局会对齐到4字节边界结构体里的BOOL和BYTE字段会把后续的DINT顶到错误位置。Pack1告诉CLR不做额外对齐完全按字段顺序紧凑排布这样TMC里的BitOffs才有意义。3.4 嵌套结构体与数组的展开处理嵌套结构体和数组是TMC里最容易绕晕的部分。嵌套结构体其实不需要展开只要在映射时识别出该类型已在known_types中直接引用即可。数组则要麻烦一些TMC里的数组字段Type可能是ARRAY [0..9] OF REAL这种字符串需要解析出维度和元素类型。import re ARRAY_PATTERN re.compile( rARRAY \[(?Pdims[^\]])\] OF (?Pelem.) ) def handle_array(raw_type, known_types): m ARRAY_PATTERN.match(raw_type) if not m: return UnknownArray dims m.group(dims) elem m.group(elem).strip() sizes [] for part in dims.split(,): part part.strip() # 形如 0..9 或 1..100 low, high part.split(..) sizes.append(int(high) - int(low) 1) elem_type map_plc_type(elem, known_types) total 1 for s in sizes: total * s return fArrayOf_{elem_type}_{total}, total, elem_typehandle_array返回了一个复合值我实际使用时把数组元素类型、元素总数都传给了emit_struct生成ByValArray封送数组字段。这里要注意多维数组在C#里可以声明成多维数组但ByValArray封送时只会按总元素数连续排布所以我统一生成一维数组让长度等于各维度乘积。数组元素类型如果是BOOL比如ARRAY [0..7] OF BOOL每个BOOL在TMC里按一个字节算Total就是8生成的C#数组也是8个bool这跟PLC内存实际占用是一致的。但如果TMC里出现了按位打包的BOOL数组BitSize会小于元素个数乘以8这时候生成器只能把字段标记为warning由人工介入。4. 避坑指南生成器踩过的五个典型坑与现成方案4.1 字符串长度变成81读出来全是空格先从最常见的现象说起。第一次跑通生成器后我把PLC里的字符串变量读回来发现每个字符串后面都跟着一大串空格短字符串也占满了整个字段。原因很简单TMC的STRING类型默认预留了81字节其中第一个字节存长度后面80字节才是内容。我把STRING直接映射成C#的string封送时没指定长度通信库就按默认0字节处理了。解决方式是生成时解析TMC里字符串的实际长度。TMC的SubItem对于STRING字段会带一个额外的长度信息有的版本直接写在Type名里比如STRING(20)有的版本在ArrayInfo里。我统一做了一个提取函数把字符串长度从20到81全部显式写进SizeConst同时用UnmanagedType.ByValTStr封送。改完之后读出来的字符串干净利落不会再拖尾巴。4.2 嵌套结构体字段重复暴涨第二个坑出现在我改用findall之前。PLC里有一个结构体嵌套了另一个结构体数组生成出来的C#类里莫名其妙出现了几十个相同字段编译直接报重复定义。原因是我最初用iter(SubItem)遍历把内层结构体的字段也当成外层子项追加了一遍递归嵌套越深重复越严重。解决办法就是把iter改成findall只拿当前DataType的直接子项。嵌套结构体的引用关系不需要展开因为生成的C#结构体会引用内层结构体类型那个内层类型自己也会被生成器单独输出。如果你发现生成的类型数量看起来变少了不用慌这是正确的。4.3 枚举类型被生成成了结构体TMC里的枚举类型没有SubItem所以parse_data_types会把它当成一个零字段的空结构体生成出来的类既不能赋值也无法作为字段类型使用。现象是PLC侧定义了一个ENUM类型上位机读出来的数据永远不对只能手动改成int再强转。解决方法是单独解析EnumInfo节点。TMC中枚举类型的定义形如EnumInfo加Element列表每个Element有名称和值。我在解析函数里增加一个分支把枚举类型单独存入enums字典生成时走独立的C#枚举生成分支基础类型按TMC里定义的位宽选择byte、short或int。4.4 BOOL数组的内存占用对不上BOOL数组是另一个隐蔽工程。一开始我以为BOOL在PLC里占1位于是把ARRAY [0..7] OF BOOL生成成7个位打包的BSTR结果读出来的数组每个元素都错位。实际上在这类PLC的默认配置里BOOL数组的元素按字节存储也就是说数组长度多少内存里就占多少字节不是按位打包。后来我在生成器里加了一条规则数组元素类型为BOOL且TMC中BitSize等于元素个数乘以8时按标准数组生成如果BitSize明显小于这个值才按位域处理并输出warning。这样至少两种场景都能cover不会无声无息地错。4.5 类型重名导致CS0101编译冲突多个PLC库文件合并到一个TMC里时不同库可能都定义了叫ST_Result的结构体。生成器直接按类型名输出两个同名的C# struct编译报CS0101。我最初的处理是加库名前缀但如果两个库同名前缀也救不了。方案是把类型名做两级映射先看完整名称是否唯一不唯一就查它的库名还不行就在末尾拼一个四位哈希。字段内部引用类型的地方全部走这个映射函数保证所有引用都指向同一个生成的类名。这样虽然最终类型名不如原始名好读但至少编译能过数据结构也不乱。5. 运行与集成把生成类接进ADS通信读写链路5.1 生成器命令行参数与一次运行生成器我用argparse做了命令行入口用法固定方便集成到自动化构建脚本里。运行一次的最小命令是这样python tmc_class_gen.py -i PlcProject.tmc -o Generated -n MyPlcTypes参数含义如下表所示。参数说明-i输入的TMC文件路径-o输出目录不存在会自动创建-n生成的C#命名空间--prefix可选给生成的类名加统一前缀避免项目内冲突执行后Generated目录下会按PLC的库名生成多个.cs文件每个文件包含一个命名空间下的全部结构体、枚举和数组类型。如果PLC工程很大几千个类型生成也就一两秒因为只是解析XML拼字符串没有任何网络或数据库参与。首次运行建议把-o指定到一个空目录方便对比生成的类文件列表。我一般先看文件数量再抽查两三个结构体确认字段顺序和PLC侧变量声明一致再往项目里引用。5.2 生成的类在ADS通信链路里怎么用生成类本身只是一堆结构体定义真正的价值在ADS读写时体现。以读取PLC里一个名为MAIN.MotorState的结构体变量为例典型流程是建立ADS连接拿到变量句柄读取原始字节再用Marshal.PtrToStructure把字节块转成生成的结构体。using System; using System.Runtime.InteropServices; using PlcAdsLib; // 替换成本地ADS库引用 public class PlcDataReader { private readonly AdsClient _client; public PlcDataReader(string ip, int port) { _client new AdsClient(); _client.Connect(ip, port); } public ST_MotorState ReadMotorState() { uint handle _client.CreateVariableHandle(MAIN.MotorState); try { byte[] raw _client.ReadBytes(handle, Marshal.SizeOfST_MotorState()); return BytesToStructST_MotorState(raw); } finally { _client.DeleteVariableHandle(handle); } } private static T BytesToStructT(byte[] raw) where T : struct { GCHandle pinned GCHandle.Alloc(raw, GCHandleType.Pinned); try { return Marshal.PtrToStructureT(pinned.AddrOfPinnedObject()); } finally { pinned.Free(); } } }这段代码有几个参数要说明。Marshal.SizeOfST_MotorState()必须在生成的结构体上调用它返回的字节数应该和TMC里这个类型声明的BitSize除以8一致如果不一致基本可以断定字段对齐出了问题。CreateVariableHandle的字符串参数是PLC侧的完整变量路径路径里包括程序名MAIN和变量名MotorState中间用点分隔。GCHandle.Alloc把字节数组固定在托管堆上否则Marshal.PtrToStructure访问的地址可能在GC回收后失效。这个操作开销不大但如果你在高速轮询场景里每秒读几十次建议复用同一个GCHandle对象不要每次都分配。5.3 验证方法用PLC真实数据对拍一遍生成的代码能不能用我习惯用三步验证。第一步是静态校验把生成的结构体SizeOf结果和TMC里对应类型的BitSize除以8做脚本比较全部一致再进入下一步。第二步是动态对拍给PLC里写一个已知数值比如把MotorState.Speed固定为100.5上位机读出来核对是否等于100.5不等就说明某个字段的偏移或类型长度错了。第三步是边界测试专门挑字符串和数组字段塞入最长的文本和最大的数组下标确认没有越界或封送异常。千万不要只验证一个整数因为整数在内存里最不容易出问题出问题的往往是字符串长度、数组维度、布尔排列这些边缘场景。6. 二次开发思路把生成器改造成你的专属代码工人这套生成器最值得改的地方是增量生成。PLC工程每次编译后TMC都会变但实际改动的往往只有几个类型全量重新生成会带来不必要的git diff。我后来加了一个基于文件哈希的缓存把TMC文件的MD5和每个DataType的字段列表存进一份JSON生成时先读缓存只有字段签名变化的类型才重新输出。import hashlib, json def load_cache(cache_path): try: with open(cache_path, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {} def type_signature(dt_info): raw json.dumps(dt_info, sort_keysTrue, ensure_asciiFalse) return hashlib.md5(raw.encode(utf-8)).hexdigest()type_signature把结构体的字段名、类型名、偏移量全部序列化后做哈希只要PLC侧没改过这个类型哈希就不会变生成器可以直接跳过。这个技巧对大型PLC工程特别实用几百个类型的项目重生成时间从几秒降到毫秒级。另一个值得改的点是联合体支持。TMC里如果出现UNION类型直接按普通结构体生成会导致多个成员从偏移0开始互相覆盖Sequential布局无法表达这种语义。我一般让生成器检测到UNION节点后把目标改成LayoutKind.Explicit并给每个字段显式标注FieldOffset(0)同时输出warning提示使用者留意数据覆盖。从那以后我每次接手新的PLC工程都会强制走一遍“解析TMC小样、生成两个测试类型、跑一遍SizeOf对拍”的流程确认没有位域和重名问题再去处理全量类型。这个习惯帮我避开过好几次字符串长度和枚举类型的暗坑希望帮到你。本文还有配套的精品资源点击获取

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号