简介:面向工控开发者的C#与倍福PLC通信实例源码,基于TwinCAT.Ads.dll库实现上位机与PLC的数据读写交互,适合新手了解ADS通信流程,也为有经验人员提供可直接改造的工程模板。压缩包共61个文件,约1.51MB,核心包含12个C#源文件、3个DLL动态库和3个EXE可执行程序,附带docx技术文档、PLC程序文件(DFR/SDB/TPY等)以及sln/csproj/settings等项目配置,目录结构清晰,便于按源码、文档、PLC侧分层学习。资源内有两份通讯说明文档,分别讲解TwinCAT ADS通讯原理与WINCE系统下的C#实现方式,同时PLC侧提供变量表、程序备份等文件,配合C#读写示例可深入理解通信细节。已有1519人学习下载,适合需要快速搭建C#与倍福PLC通信环境、进行项目二次开发或排查通信问题的工控技术人员。
1. TwinCAT.Ads.dll:用 C# 上位机跟倍福 PLC 通信,比想象中简单
做上位机的人迟早会碰到倍福:设备上用 Beckhoff PLC 做运动控制,上位机这边要读状态、写参数、采数据。很多人第一反应是 OPC UA,但现场工程师更常用的其实是 ADS——也就是 TwinCAT.Ads.dll 这套原生通信库。我最初也以为要配一堆路由和证书,结果发现 C# 里引用一个 DLL、填对 AMS NetId 和端口号,几十行代码就能把所有变量读上来。这份实例源码解决的就是这件事:用 C# 通过 TwinCAT.Ads.dll 跟倍福 PLC 通信,覆盖连接、读写、通知和断线重连,适合刚接触倍福的 C# 上位机开发,也适合老手拿来当通信层模板。
2. 先把地基打牢:ADS 协议选型、引用 DLL 与连接三要素
2.1 为什么是 ADS 而不是 OPC UA 或 Modbus
倍福的 TwinCAT 系统对外通信不止一条路:Modbus TCP 简单但只能读写寄存器,OPC UA 跨平台能力强但配置重,而 ADS(Automation Device Specification)是倍福的“母语”——它直接跑在 AMS 路由器上,能访问 PLC 变量、NC 轴、系统状态甚至实时任务,吞吐和延迟都优于 Modbus。
我一般的原则是:如果上位机就在同一台 IPC 上,或者只在厂区局域网内、不涉及多品牌设备统一采集,直接用 ADS 最省事。它不需要在 PLC 里额外加功能块,只要 TwinCAT 的 Router 在跑,变量就能被外部读到。而且 TwinCAT.Ads.dll 直接暴露给 .NET,底层 Native 调用被封好了,C# 里用起来跟操作普通类库没区别。
有个容易忽略的点:ADS 通信的数据类型是强绑定的。你读一个变量,C# 这边的类型必须和 PLC 侧严格对应,否则读出来的值就是乱的。这一点后面第 5 章会专门讲。
2.2 引用 TwinCAT.Ads.dll:两种方式,别混着用
第一种是通过 NuGet,适合新工程。Visual Studio 里打开包管理器,搜索并安装:
dotnet add package TwinCAT.Ads装完后确认引用里多出了TwinCAT.Ads这个程序集,命名空间是TwinCAT.Ads,核心类是TcAdsClient。NuGet 版本会跟随 TwinCAT 版本迭代,比如 TwinCAT 3 对应 4.x 以上的包,接口变化不大。
第二种是手动引用 DLL——老工程师更习惯这种方式,因为现场电脑可能没外网。倍福安装 TwinCAT 后,在安装目录下能找到TwinCAT.Ads.dll,一般路径类似于:
C:\TwinCAT\AdsApi\TwinCAT.Ads.dll把它复制到项目目录,然后在解决方案里右键“添加引用”→“浏览”选到这个 DLL。需要注意:如果目标框架是 .NET Framework 4.x,老版本 DLL 直接能用;如果是 .NET Core 或 .NET 5+,建议优先走 NuGet,手动引用的 DLL 可能缺少 Runtime 兼容层。
引用完成后的第一步,先创建一个客户端实例验证能 new 出来:
using TwinCAT.Ads; // 创建 ADS 客户端 TcAdsClient client = new TcAdsClient(); // 验证版本 Console.WriteLine("TwinCAT.Ads 版本: " + typeof(TcAdsClient).Assembly.GetName().Version);这里有个小坑:TcAdsClient内部封装了 Native 层,在 x64 系统上如果项目编译成 AnyCPU,运行时可能加载不了 32 位版 Native 组件。我一般直接把项目编译目标设为 x86,或者统一用 x64 的新版 NuGet 包,千万别在同一个进程里混用两套 DLL。
2.3 连接三要素:AMS NetId、端口号和路由表
跟普通 Socket 通信不一样,ADS 连接不是填 IP 加端口那么简单,它要求三个参数:AMS NetId、目标端口、以及本机到目标机的 AMS 路由关系。
AMS NetId 类似一台机器的“身份证”,格式是六个数字,例如192.168.1.1.1.1。在 TwinCAT 3 里打开 System Manager(或者 TwinCAT XAE 的 “TwinCAT”→“System”→“Real-Time”),可以找到当前 IPC 的 AMS NetId。如果是虚拟 PLC,NetId 可能与 IP 不同,要去看 TwinCAT 的路由配置。
端口号决定了你访问的是 PLC 里的哪个 Runtime。默认常用端口如下:
| 目标 | 端口号 | 说明 |
|---|---|---|
| PLC Runtime 1 | 851 | TwinCAT 3 最常见 |
| PLC Runtime 2 | 852 | 第二个 PLC 实例 |
| System Service | 10000 | 访问系统状态 |
| TwinCAT 2 PLC | 801 | 老项目的默认端口 |
连接的最小代码是:
// 本机连接 client.Connect("127.0.0.1.1.1", 851); // 远程 IPC 连接 // client.Connect("192.168.1.10.1.1", 851);注意远程连接不是填 IP 而是填 AMS NetId,IP 地址本身只用于路由发现。本机调试经常用127.0.0.1.1.1这个固定 NetId,指向 TwinCAT 在本机的 ADS 服务。
路由表是第三个隐性条件。AMS 路由不通,代码写得再对也连不上。TwinCAT 3 里要在“Tasks”或“Router”中 “Add Route”,填入目标机的 AMS NetId 和 IP。对于本机连接,路由通常是自动存在的;远程连接没配路由,会直接报0x10060超时。这个坑现场很常见,我在第 5 章会专门演示排查方式。
3. 读写变量的三种姿势:ReadSymbol、ReadAny 与通知回调
3.1 先理解 TwinCAT 符号表机制
ADS 读写不是按地址,而是按符号名。PLC 程序里声明过的全局变量,比如MAIN.nSpeed、MAIN.bRunning,只要在 TwinCAT 环境里激活了配置,符号表就生成了。C# 这边通过符号名直接访问,不用关心变量在内存里的具体地址,也不用管 PLC 是否重新编译过。
但有一个前提:PLC 程序在线变更之后,符号表的句柄(Handle)会变。如果上位机缓存了句柄,需要在每次重连或者检测到 Online Change 后重新获取。这一点新手容易忽略,表现出来就是连上了但读不到数据,或者报“符号未找到”。
3.2 ReadSymbol:按符号名直接读
最简单的方式是ReadSymbol,传变量名进去,返回object,再强转成对应类型。读一个 INT 变量示例:
// 读取 PLC 变量 MAIN.nSpeed(类型 INT) object raw = client.ReadSymbol("MAIN.nSpeed"); short speed = (short)raw; Console.WriteLine($"当前速度: {speed}");ReadSymbol内部会自动查句柄、读值、再释放句柄。优点是代码短、好懂,适合低频读取;缺点是每次调用都要走一遍符号查找和句柄分配,高频轮询时性能不够好。
高频场景我一般改用ReadAny,它接受一个Type参数,返回强类型对象:
// 使用 ReadAny 读取,减少一次强制转换 short speed = (short)client.ReadAny("MAIN.nSpeed", typeof(short), new int[] { });第三个参数new int[] { }是给数组或结构体用的维度信息,标量类型传空数组就行。如果读的是数组,比如MAIN.arrData是一个ARRAY[0..9] OF INT,写法变成:
int[] dims = new int[] { 1, 10 }; // 第一维长度 10 ushort[] data = (ushort[])client.ReadAny("MAIN.arrData", typeof(int), dims);这里typeof要和 PLC 的数组元素类型对应,PLC 的INT在 C# 里是short,WORD是ushort。下表是我常用的映射关系:
| PLC 类型 | C# 类型 | 字节数 | 注意事项 |
|---|---|---|---|
| BOOL | bool | 1 | 不能直接读成 byte |
| BYTE | byte | 1 | |
| WORD | ushort | 2 | 无符号 |
| INT | short | 2 | 有符号,易错 |
| DINT | int | 4 | |
| REAL | float | 4 | 注意精度 |
| LREAL | double | 8 | |
| STRING(n) | string | n+1 | 要匹配长度 |
3.3 写变量:WriteSymbol 与 WriteAny
写变量同样分两种。低频率、图省事用WriteSymbol:
// 写入一个 INT 变量 client.WriteSymbol("MAIN.nTargetSpeed", (short)1200);写入频率高或者要写数组,用WriteAny:
short[] targets = new short[] { 100, 200, 300 }; client.WriteAny("MAIN.arrTargets", targets, new int[] { 3 });写变量时最容易翻车的点是类型不匹配:比如 PLC 变量是INT,你在 C# 里传了int,底层数据宽度不同,写入后 PLC 侧看到的可能是错的,甚至写越界。建议每次写之前先看 PLC 变量声明类型,再对应 C# 类型,不要“看着像就写”。
3.4 通知回调:不想主动轮询时的替代方案
轮询的代价是 CPU 和网络包占用高。如果只是监控几个关键变量,可以用 ADS 的 Notification 机制,让 PLC 侧在变量变化时主动推给上位机。
// 创建通知:监控 MAIN.nSpeed,值变化且超过 1ms 时触发 TcAdsNotification notification = client.CreateNotification( "MAIN.nSpeed", // 符号名 typeof(short), // 数据类型 1, // 通知周期(ms) TcAdsNotificationMode.ValueChange // 触发模式 ); notification.Notification += OnSpeedNotification;回调函数里拿到的数据和轮询一样,但不要在里面做耗时操作:
private void OnSpeedNotification(object sender, TcAdsNotificationExEventArgs e) { // e.Data 是强类型数据 short speed = (short)e.Data; Console.WriteLine($"通知回调速度: {speed}"); }注意CreateNotification有重载,部分 DLL 版本里类名是NotificationClient,在 4.x 版本中统一为TcAdsNotification。通知周期设得太小(比如 1ms)而 PLC 刷新周期又达不到,会出现大量重复通知,用TcAdsNotificationMode.ValueChange模式可以挡掉连续同值的情况。
4. 一个可落地的通信实例:连接、心跳、断线重连与 UI 刷新
4.1 工程结构与线程模型
源码包里的实例是一个 WinForms 工程,文件组织大致是:
AdsDemo/ ├── AdsDemo.sln ├── MainForm.cs // 主界面 + 显示逻辑 ├── AdsClientManager.cs // 通信封装:连接、读写、心跳、重连 └── app.config // 配置 AMS NetId、端口号WinForms 里最忌讳在 UI 线程里做耗时网络操作。我一般把 ADS 连接和轮询丢到后台线程,UI 只负责显示。这样即使 PLC 断开,界面也不会卡死。
核心通信封装类AdsClientManager的骨架如下:
public class AdsClientManager { private TcAdsClient _client; private readonly string _netId; private readonly int _port; private System.Threading.Timer _heartbeatTimer; private bool _isConnected; public event Action<bool> ConnectionChanged; public event Action<short> SpeedUpdated; public AdsClientManager(string netId, int port) { _netId = netId; _port = port; _client = new TcAdsClient(); } public bool Connect() { try { _client.Connect(_netId, _port); _isConnected = true; StartHeartbeat(); ConnectionChanged?.Invoke(true); return true; } catch (Exception ex) { ConnectionChanged?.Invoke(false); return false; } } }Connect方法里_client.Connect是同步阻塞的,超时大约 5~10 秒。界面调用时应该放到 Task 里,否则点击“连接”按钮后界面会假死。
4.2 封装心跳与自动重连
ADS 连接有一个特点:物理断开后,客户端对象并不知道,要等下次读写才报异常。所以我加了一个 3 秒一次的“心跳”读操作,读一个几乎不变的变量(比如MAIN.bReady),读失败就触发重连。
private void HeartbeatTick(object state) { if (!_isConnected) return; try { // 读一个轻量级状态变量做健康检查 bool ready = (bool)_client.ReadSymbol("MAIN.bReady"); if (!ready) { // PLC 可能处于停止状态,不需要断线但记录日志 Console.WriteLine("PLC 未准备就绪"); } } catch (Exception) { _isConnected = false; ConnectionChanged?.Invoke(false); // 触发重连 Reconnect(); } }Reconnect要做几件事:先Dispose旧客户端,重新 new 一个,再次Connect,成功后通知界面更新状态。重连间隔建议至少 5 秒,不要写成死循环,否则 TwinCAT Router 会被频繁的连接请求刷出日志报错。
private void Reconnect() { Task.Run(async () => { await Task.Delay(5000); try { _client.Dispose(); _client = new TcAdsClient(); bool ok = Connect(); Console.WriteLine(ok ? "重连成功" : "重连失败,等待下轮"); } catch (Exception ex) { Console.WriteLine("重连异常: " + ex.Message); } }); }4.3 UI 线程刷新与数据绑定
后台读到的数据要安全地更新到界面。最简单的方式是 Control 的BeginInvoke:
public void UpdateSpeedUI(short speed) { if (this.InvokeRequired) this.BeginInvoke(new Action(() => labelSpeed.Text = speed.ToString())); else labelSpeed.Text = speed.ToString(); }高频刷新时BeginInvoke会把委托排队,导致界面反应越来越慢。解决方法是设一个刷新节流:比如积攒 5 个值再刷一次,或者只在值变化超过阈值时刷。我的习惯是数据采集显示用BindingSource+ 定时刷新,通信数据只写内存,UI 每秒取一次快照。这样 CPU 占用可以从 30% 降到 5% 以下,界面也不会抖。
实例工程里默认读取的是MAIN.nSpeed和MAIN.bRunning这两个变量,你拿到源码后先改成自己 PLC 里的变量名,再改 AMS NetId 和端口就能跑通。整个通信层和 UI 是分离的,直接替换到你的工控项目里也过得去。
5. 避坑:ADS 通信常见的五个翻车现场
以下每条都是我在现场或办公室实际踩过的坑,按“现象→原因→解决”整理。
5.1 连接超时:0x10060或10060
现象:调用Connect等待十几秒后抛异常,错误码是10060,英文提示 “Timeout”。
原因:绝大多数是 AMS 路由没配对,不是代码问题。
解决:用 TwinCAT 3 的“Add Route”功能,把上位机的 IP 和 AMS NetId 添加到目标 IPC 的路由表里。工控领域有个偏“玄学”的经验:先把两台机器在 Windows 防火墙里放行 TwinCAT 相关端口(比如 48898),再用命令pingAMS NetId 验证连通性,最后才回去查代码。
5.2 读出来的值是 0 或者完全不对
现象:代码没报错,但读到的数值和 PLC 侧不一致,比如 PLC 里是 100,C# 读到的是 0 或 25600。
原因:数据类型不匹配。PLC 的INT在 C# 里对应short,如果强转成int,高 16 位补零或截断,值自然不对。
解决:读之前先确认 PLC 变量类型,对照第 3 章的映射表选 C# 类型。如果实在不确定,先读成object再打印GetType()看一眼实际运行时类型。
5.3 符号未找到:0x710/ Error 1808
现象:原来能跑的代码,Online Change 之后突然报符号未找到。
原因:PLC 程序在线变更后,符号表的 Handle 被重建,但TcAdsClient内部缓存的 Handle 没更新。
解决:不要长期持有ReadAny的句柄返回值。如果用了client.GetHandleByComment或ReadByHandle,要在每次重连后重新获取句柄。最简单的办法是老老实实用ReadSymbol/WriteSymbol,让库内部每次自动查符号表,性能损失在高频场景之外可接受。
5.4 Notification 回调不触发
现象:CreateNotification成功,但回调一次都没有。
原因:最常见的是通知周期没对齐、目标 Runtime 没启动、或者变量没有变化。ADS 的 Notification 在变量不变时不会触发,这是设计行为;另外部分版本里TcAdsNotificationMode.ValueChange要求在 TwinCAT 侧打开 ADS 相应的 Notify 选项。
解决:先用轮询方式确认变量确实有变化,再检查通知周期(单位是 ms,不能为 0),最后把模式改成Cyclic试试。如果 Cyclic 能触发、ValueChange 不触发,就是变量刷新周期比通知周期还大,把通知周期拉大到 2 倍于 PLC 任务周期即可。
5.5 建了多个 TcAdsClient 实例导致 Router 卡顿
现象:程序开了多个窗口,每个窗口一个TcAdsClient,运行一段时间后 PLC 端点响应变慢。
原因:每个客户端都会在 TwinCAT Router 里建立一个独立会话,会话数太多会占用资源;另外部分客户端没有释放,连接泄漏。
解决:整个进程尽量只维护一个TcAdsClient全局实例,所有窗口共享它;每次Dispose后把引用置 null。检查有没有泄漏,可以看 TwinCAT 的“Router → Connections”面板,连接数会明显超标。
6. 进阶:把通信层封装成可复用的服务类
当项目超过三四个设备时,裸写TcAdsClient就不可维护了。我习惯在实例源码基础上再包一层IAdsService,把连接配置、心跳、重连、日志统一收口,业务代码只关心接口。
public interface IAdsService { bool Start(); void Stop(); T Read<T>(string symbolName); void Write<T>(string symbolName, T value); IObservable<T> Observable<T>(string symbolName, int cycle); }这样的好处是:上层代码不直接触碰 ADS,单元测试时可以注入 mock 实现。真实采集逻辑全部走接口,换协议栈时不用改业务代码,只是换一个实现类。
验证这层封装是否靠谱,我通常不直接连 PLC,而是用 TwinCAT XAE 里的“符号表监控”功能,把变量先强制改几个值,观察上位机是否收到、耗时多少。再配合代码里的日志时间戳,能清楚看到轮询周期是否稳定。
有一个我自己的铁律:每次改完 PLC 程序,上位机这边必须重启通信层一次,不重启就会踩 5.3 的符号未找到坑。从那以后我每次现场调试都会在界面上留一个“重置通信”按钮,任何诡异问题第一件事先点它,而不是反复断点调试。希望这招对你有用——这份源码里的封装思路和读写方式,拿到手改动即可用。
本文还有配套的精品资源,点击获取