ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

解析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是XML,Python标准库里的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得到字节偏移,再按字节偏移升序摆放字段,这样配合Pack=1才能保证内存对齐一致。

3.2 类型映射:把PLC类型翻译成C#类型

拿到字段名和类型名后,最核心的映射逻辑在这里。PLC有自己的类型体系,比如DINT是32位有符号整数,WORD通常被当成16位无符号整数,REAL对应C#的float,LREAL对应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_type

map_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, key=lambda 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, Pack=1)]", f"public struct {sanitize_type_name(type_name)}", "{", ] lines.extend(fields) lines.append("}") return "\n".join(lines)

这段代码里有几个关键点。字段用sorted按bit_offs排序,保证生成的字段顺序和PLC内存顺序一致,否则Sequential布局会错位。位偏移不是8的倍数时,说明字段是真正的位域,C#里不方便直接用,我在生成结果里保留一行注释,提醒你手动拆成布尔位。

Pack=1很重要。默认布局会对齐到4字节边界,结构体里的BOOL和BYTE字段会把后续的DINT顶到错误位置。Pack=1告诉CLR不做额外对齐,完全按字段顺序紧凑排布,这样TMC里的BitOffs才有意义。

3.4 嵌套结构体与数组的展开处理

嵌套结构体和数组是TMC里最容易绕晕的部分。嵌套结构体其实不需要展开,只要在映射时识别出该类型已在known_types中,直接引用即可。数组则要麻烦一些,TMC里的数组字段Type可能是ARRAY [0..9] OF REAL这种字符串,需要解析出维度和元素类型。

import re ARRAY_PATTERN = re.compile( r"ARRAY \[(?P<dims>[^\]]+)\] OF (?P<elem>.+)" ) 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 f"ArrayOf_{elem_type}_{total}", total, elem_type

handle_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.SizeOf<ST_MotorState>()); return BytesToStruct<ST_MotorState>(raw); } finally { _client.DeleteVariableHandle(handle); } } private static T BytesToStruct<T>(byte[] raw) where T : struct { GCHandle pinned = GCHandle.Alloc(raw, GCHandleType.Pinned); try { return Marshal.PtrToStructure<T>(pinned.AddrOfPinnedObject()); } finally { pinned.Free(); } } }

这段代码有几个参数要说明。Marshal.SizeOf<ST_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", encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {} def type_signature(dt_info): raw = json.dumps(dt_info, sort_keys=True, ensure_ascii=False) 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对拍”的流程,确认没有位域和重名问题再去处理全量类型。这个习惯帮我避开过好几次字符串长度和枚举类型的暗坑,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进