1. 读懂Sample01之前,先弄明白ADS在干嘛
1.1 一个协议撑起一台“软PLC”的通讯基座
做自动化上位机开发的人,早晚都会碰上倍福这套东西。不管是TwinCAT 2还是TwinCAT 3,上位机要读写PLC里的变量,基本都绕不开ADS协议。ADS全称Automation Device Specification,是Beckhoff定义的一套设备间通讯协议规范,核心作用就是让外部应用程序能够访问TwinCAT实时运行环境里的数据。
这里有个背景要先理清楚。TwinCAT本质上是一套运行在Windows上的实时软件系统,PLC程序并不是直接跑在Windows内核里的,而是跑在一个独立的实时子系统里。PLC里用ST语言或FBD图定义的变量,都存在于这个实时子系统的符号表中。普通进程想直接通过内存地址去拿这些数据是拿不到的,必须由ADS路由做“翻译”和“转发”。所以你可以把ADS理解为一把钥匙,上位机程序拿着它去敲TwinCAT实时内核的门,然后按变量名把数据取出来。
很多人会把ADS和S7通讯类比,但有个关键差异一定要记住:S7通讯通常面向寄存器地址,而ADS允许直接通过符号名访问变量。也就是说,上位机不需要关心PLC内部怎么分配地址,只要变量名没错,ADS运行时就会自动完成符号解析和内存定位。这就是Sample01里代码看着这么短、却能把PLC数组完整读回来的底层原因。
1.2 为什么偏偏是.NET Framework而不是新版.NET
现在.NET已经迭代到8、9了,TwinCAT的ADS官方库也提供了对应版本。那为什么Sample01这类基础示例还是基于.NET Framework?真实原因有两个。
第一,工厂里的上位机项目大多是历史工程,常年跑在Windows 7或较老的Windows Server上,系统里稳定运行着.NET Framework 4.x甚至3.5。迁移到.NET Core可能牵扯到第三方UI库、报表组件、老式COM接口调用,这些组件在新框架下未必有替代品。所以最务实的做法,就是把ADS通讯模块加到老框架项目里。
第二,Beckhoff官方示例库中,Sample01这类入门示例最早就是基于.NET Framework 4.x写的,官方DLL(TwinCAT.Ads.dll)在4.x下运行没有任何兼容问题,文档和社区讨论也最齐全。你拿新版库去连同一个PLC,核心逻辑其实差不多,但Framework版本报错在网络上一搜一大片,反而是旧版最稳。
2. 环境准备:引用库和AMSNetId,两个最容易被绊倒的地方
2.1 NuGet引入TwinCAT.ADS的两种方式
把环境跑通,第一步是引入ADS通讯库。主要有两种方式。
第一种是NuGet包管理器。在Visual Studio里右键项目,选择“管理NuGet程序包”,搜索“TwinCAT.Ads”,一般会看到两个结果:一个是Beckhoff.TwinCAT.Ads(对应老命名空间,核心类是AdsClient),另一个是TwinCAT.Ads(新版命名空间,Api和用法有差异)。Sample01多数情况使用的是老命名空间下的DLL。我的建议是看一下你拿到的示例代码里using的是哪个命名空间,就按那个来引。
第二种方式是手动添加引用。很多车间电脑不允许连外网,NuGet根本用不了,这时候需要把DLL拷到本地。路径一般在TwinCAT安装目录下:C:\TwinCAT\AdsApi\或者安装包解压后的ADSNET文件夹里。引用的时候有个细节容易踩坑:老版本TwinCAT.Ads.dll同时存在x86和x64版本。如果你的上位机程序还需要访问西门子、三菱等其他PLC的通讯库,而这些库经常是x86的,为了整个进程统一,你可能得把整个项目编译目标调成x86,否则进程启动时会直接报BadImageFormatException。
2.2 AMSNetId和端口号到底填什么
这是Sample01里第一个让新手挠头的地方。连接代码通常长这样:
using TwinCAT.Ads; AdsClient client = new AdsClient(); client.Connect("192.168.0.12.1.1", 851);“192.168.0.12.1.1”不是普通的IP地址,它是AMSNetId,一共6个字节,写成点分形式就是6段数字。前4段习惯上跟PLC的IP地址保持一致,后2段是系统代号,常见的是1.1。查看方法很简单:在TwinCAT XAE环境中双击SYSTEM节点,在“AMS Router”页面查看路由表,每个安装TwinCAT的电脑或运行中的PLC目标都会有自己的AMSNetId。
端口号851对应PLC运行时(PLC Runtime)的标准端口。如果TwinCAT运行在同一台电脑上,也就是本机连接,AMSNetId一般填写本机ID,比如127.0.0.1.1.1这种形式。有些特殊场景会改用801(系统服务端口)或自定义端口,但Sample01最标准的组合就是目标AMSNetId加851。
提示:连接不上时不要急着改代码,先用TwinCAT自带的Router诊断工具查看对方路由是否在线。很多时候问题出在路由表配置,而不是端口号。
3. Sample01逐行拆解:连接、读数组、收尾
3.1 先连上再说话:AdsClient的Connect
Sample01整体流程分四段:创建客户端对象、建立连接、读取数据、清理资源。前两段的核心代码是:
class Program { static void Main(string[] args) { string amsNetId = "192.168.0.12.1.1"; int port = 851; AdsClient client = new AdsClient(); try { client.Connect(amsNetId, port); Console.WriteLine("Connected to {0}:{1}", amsNetId, port); // 读取操作 } catch (AdsErrorException ex) { Console.WriteLine("ADS error: " + ex.Message); } finally { client.Dispose(); } } }Connect方法接收两个参数:AMSNetId和Port。如果目标是本机,可以调用无参的client.Connect(),ADS库会自动连接本机默认PLC端口。但示例代码里通常显式传参,目的就是让初学者清楚自己连的是什么。
还有一个细节值得注意:AdsClient实现了IDisposable,用完之后一定要释放。很多人第一次跑通就不管了,结果程序多次连接之后,本地路由表里攒了一堆僵尸连接,后面再连就会间歇性失败。我写项目时坚持用try-finally或using,这个习惯救了我不止一次。
3.2 读取数组的三种写法与区别
读取数组是重头戏。Sample01里最常见的写法是:
// 方式一:ReadAny直接按类型读 short[] buffer = new short[10]; int result = client.ReadAny("MAIN.arrTest", typeof(short[]), buffer);这句话的含义是:从PLC符号表中定位MAIN.arrTest这个变量,按照short[]类型把它读取到buffer中。返回的result是ADS对本次请求的状态码,0表示成功,非0可以在错误码表中查到具体原因。
第二种写法是先用变量句柄(Handle)再读:
uint handle = client.CreateVariableHandle("MAIN.arrTest"); try { short[] buffer = new short[10]; client.ReadAny(handle, typeof(short[]), buffer); } finally { client.DeleteVariableHandle(handle); }为什么要多此一举用句柄?因为ADS每次按符号名读取时,都需要在符号表中做一次名称到地址的解析。如果程序高频读取同一个变量,比如每秒几十次,这个查找开销就很可观。句柄相当于提前把“名字到地址”的映射关系建立好,之后每次读取都直接走地址,省去了符号解析的时间。在要求实时性的上位机程序里,句柄几乎成了标配。
第三种方式适合数据量大的场景:使用ADS通知(Notification)。数据变化时ADS会自动把新值推送到上位机,而不是上位机每次发请求去拉。Sample01一般不会这么深入,但如果你读数组只是为了刷新界面,用轮询反而白白浪费带宽,用Notification才是正路。通知机制在ADS库里对应AdsNotification以及client.Notification事件,代码复杂一些,但收益明显。
3.3 收尾动作:释放句柄和连接
读数组这种看似简单的操作,坑往往都在尾部。句柄创建了不删,会持续占用路由资源;客户端连接不断开,下次连接可能报“连接已被占用”。官方示例写得很朴素,但我在项目里都会牢记:句柄用完就删,客户端用完就Dispose,顺序上先删句柄再断开连接。
下面是一段比较完整的Sample01风格示例,把读取和清理都放在一起:
using System; using TwinCAT.Ads; class Sample01 { static void Main() { string amsNetId = "192.168.0.12.1.1"; int port = 851; AdsClient client = new AdsClient(); try { client.Connect(amsNetId, port); uint handle = client.CreateVariableHandle("MAIN.arrTest"); try { short[] data = new short[10]; int status = client.ReadAny(handle, typeof(short[]), data); if (status == 0) { foreach (short val in data) { Console.WriteLine(val); } } } finally { client.DeleteVariableHandle(handle); } } catch (AdsErrorException ex) { Console.WriteLine("ADS通信错误: " + ex.Message); } finally { client.Dispose(); } } }4. 数组读回来的那点事:长度、类型、性能
4.1 类型映射:PLC的INT对应.NET的什么
数组能否正确读回,第一关就是类型映射要对。TwinCAT的PLC类型和.NET类型不是一一对应的,尤其是整型长度差异,最容易坑人:
| PLC类型 | 字节数 | .NET类型 | 备注 |
|---|---|---|---|
| BOOL | 1 | bool | 数组读取时按字节排列 |
| BYTE / USINT | 1 | byte | |
| INT | 2 | short | 特别注意,不是int |
| DINT | 4 | int | |
| WORD | 2 | ushort | |
| REAL | 4 | float | |
| LREAL | 8 | double | |
| STRING | 可变 | string | 数组读场景较少 |
这是非常容易踩的坑。如果你在PLC里定义的是INT,也就是16位有符号整数,在C#里却用int[]去接收,ReadAny要么抛异常,要么返回一堆错位的数据。乍一看毫无头绪,本质上就是类型字节长度对不上。
4.2 数组长度校验与半途返回
比如PLC里定义arrTest : ARRAY[1..10] OF INT;,那在.NET这边至少要准备short[10]的缓冲区。ReadAny会把你传入的buffer长度当作期望读取的长度。如果buffer长度小于实际数组长度,它可能只读前几个元素,也可能直接判定请求非法,取决于ADS库的具体实现。
更保险的做法是,先用client.ReadAny("MAIN.arrTest", typeof(short), out short single)读取单个元素,确认格式和变量名没问题;调试时把返回的状态码打印出来,ADS的错误码非常精细,比瞎猜变量名高效得多。连不上时错误码往往是路由问题,读不到时错误码往往是符号名或类型问题,排查起来有的放矢。
4.3 2000个元素的数组,轮询速度如何优化
数组越大,单次读取的报文越长,耗时也呈线性增长。如果只是每秒刷新一次界面,2000个元素问题不大;但如果上位机要做高速采集或参与控制,一次全读就不划算了。我的做法通常是分块读取:
// 按偏移分块读取,每块256个short short[] chunk = new short[256]; for (int i = 0; i < 2000; i += 256) { int remaining = Math.Min(256, 2000 - i); client.ReadAny(handle, typeof(short[]), chunk, new int[] { i }, new int[] { remaining }); // 处理chunk }ADS库提供了带数组偏移和读取长度的重载,这在协议层面对应“按索引访问数组段”的能力。对于几千个元素的数组,分块读取比一次性拉全量数据更稳定,也更容易做超时控制。如果PLC侧数组是允许动态变化长度的,分块读还能顺带校验当前实际长度,避免越界。
5. 实测翻车记录:从连接失败到数据错位
5.1 连不上的三种原因和定位手段
我实际遇到的连接失败主要集中在三种:路由不通、端口错、防火墙拦截。
路由不通最常见。PLC那侧必须把上位机的AMSNetId添加到路由表中,操作位置在TwinCAT XAE的SYSTEM -> AMS Router,右键添加。很多PLC项目只在控制器侧配了到上位机的路由,反向没配,导致上位机连PLC时直接超时。定位手段很简单:在TwinCAT的命令行里敲route命令查看路由表,或者打开TC3的Router Diagnose工具,看AMS路由状态是不是ACTIVE。
端口错的典型场景是目标不是标准PLC运行时端口。如果PLC侧用的是801系统端口、852 NC端口,或者用户自定义端口,你用851去连当然失败。检查目标端口即可确认。
防火墙拦截比较隐蔽,因为ADS报文依赖TCP/UDP动态端口分配,即使AMSNetId明确,Windows防火墙还是可能拦。最快的测试办法是:目标电脑上临时禁用防火墙,如果马上能连上,说明问题就在这。之后在防火墙里放行TwinCAT相关进程即可,主要涉及TcSysService.exe、Router.exe等。
5.2 “有效执照”报错、端口被占用的处理
不少刚接触倍福的人会在网络上看到“ADS无法找到有效执照”的报错,这通常不是代码问题,而是TwinCAT授权没有正确部署。PLC运行ADS服务是需要许可证授权的,LIC文件要安装到目标电脑,且授权绑定的System ID必须与当前电脑一致。文件放在C:\TwinCAT\3.1\License目录下,然后用系统托盘里的TwinCAT License Client激活。评估模式下TwinCAT有试用期,到期后ADS服务会停止响应,代码层面表现为连接超时或没有数据。
还有一种情况是端口被占用。本机运行多个TwinCAT实例,或者自己开发的服务占了851端口,底层Socket会直接报错。用netstat -ano查一下851被谁占用,把多余实例关掉就能解决。
5.3 数据错位和缓存刷新问题
数据错位往往不是通讯错,而是类型映射错。结构体布局不一致、字段偏移看错,都会导致数组读回来之后前几个元素对,后面全乱。这里有个很实用的调试技巧:在PLC里固定给数组赋值已知序列,比如arrTest[1] := 100; arrTest[2] := 200; ...,上位机读回来后逐项对照。数值一旦对不上,很快就能判断出是类型长度不对,还是符号名指向了错误的变量。
至于缓存刷新,ADS本身每次读写都是实时访问TwinCAT运行时,并不存在协议层的缓存滞后。如果你连续读同一个数组,发现数值老是旧的,多半是自己在上位机内存里缓存了一份没有真正更新的结果。修改这个误区,只需要检查程序逻辑里是不是自己把数据存到了静态变量里。
6. 从Sample01延伸出去:项目里的实用小习惯
6.1 把ADS通讯封装成一个小类
Sample01演示的是控制台里一把梭的写法,但真实上位机项目里,我通常不会让业务代码直接操作AdsClient。一般会封装成一个通讯服务类,把连接参数、重连逻辑、日志都放进去。基本骨架长这样:
public class AdsService : IDisposable { private AdsClient _client; private readonly string _amsNetId; private readonly int _port; public AdsService(string amsNetId, int port) { _amsNetId = amsNetId; _port = port; } public bool Connect() { try { _client = new AdsClient(); _client.Connect(_amsNetId, _port); return true; } catch { return false; } } public T[] ReadArray<T>(string symbolName, int length) { T[] buffer = new T[length]; _client.ReadAny(symbolName, typeof(T[]), buffer); return buffer; } public void Dispose() { _client?.Dispose(); } }这样调度层、界面层只关心业务数据,ADS的细节被隔离在服务类里。以后换了PLC的IP,只要改配置文件,不用动任何业务代码。项目越大,这个习惯越值钱。
6.2 轮询、通知、手写心跳的取舍
最后说一点我自己的取舍经验。项目里如果只是做数据采集、展示,我倾向于用Notification,用一次连接订阅多个变量,减少通讯量和CPU占用。如果是和PLC做握手式的任务交互,比如上位机发指令后等PLC返回完成标志,我倾向于轮询加超时,因为逻辑最直观,出问题也好排查。
还有一个细节:长时间运行的上位机程序,我会每30秒发一次心跳读取,比如读一个系统时间变量。这样既能及时发现连接断开,也能让路由保持活跃状态,避免被操作系统或防火墙误判为无活动连接而切断。这个经验不在Sample01的官方注释里,但在我跑过的项目里,确实避免了好几次莫名其妙的掉线问题。
如果让我总结这次从官方示例学到的东西,其实就一句话:先把最基础的连接和读取跑通,再谈优化。Sample01就是把这条路铺平的最小工程,理解它,再去看Notification、结构体读写、跨网段路由这些高级内容,都会顺手很多。