☰
TwinCAT3与C++上位机ADS异步通信:从原理到工程落地
2026/10/3 4:20:35 网站建设 项目流程

搞工控上位机的人,十有八九都会遇到这个问题:TwinCAT3的实时核跑得飞快,1毫秒甚至更短周期地在刷数据,但你的C++上位机却在Windows里忙着绘曲线、存数据库、处理UI,两边完全不是一个节拍。如果还用老办法去循环读取PLC变量,要么CPU烧得离谱,要么数据延迟大到没法看。我最早做这套东西的时候也踩过不少坑,后来把基于ADS的异步通信方式吃透了,才算真正把这层窗户纸捅破。

这里说的ADS,不是搞射频仿真那个ADS,而是倍福TwinCAT体系里的Automation Device Specification,这是Win32上位机与实时核之间交换数据的标准协议。TwinCAT3作为实时控制系统,C++作为上位机语言,ADS作为连接通道,异步通知作为数据流向核心机制,这套组合几乎是目前做非实时上位机与实时控制器通信最实用的方案之一。这篇东西适合正准备做TwinCAT3上位机开发的电气工程师、软件工程师,也适合明明照着官方例程写了一大堆代码,但通信还是卡顿、掉线、数据对不上的朋友。我把从通信模型到工程落地的完整思路,以及实际调试中遇见的那些坑,一次讲清楚。

1. 为什么要用异步通信:先从实时核和上位机的“时差”说起

1.1 单机周期和数据交换的本质

TwinCAT3实时核本质上是一个运行在Windows底层、由微软补丁授权调度的独立实时环境。实时任务不依赖Windows的线程调度机制,它有自己的任务周期,比如伺服轴控制通常是1ms或更短。这意味着PLC侧变量每一毫秒都在变化,而Windows侧进程的线程调度粒度一般在几毫秒甚至更差,两者天然存在时间尺度上的错位。

要把这两个世界连起来,ADS协议是中间的翻译官。ADS不是简单的网口透传,它基于AMS(Automation Message Specification)报文,在TwinCAT的Server端维护着一套符号表和句柄机制,上位机可以通过符号名拿到变量的访问权。通信链路一旦建立,数据就不只是“能通”,还要保证时序、保证一致性,甚至要在不同周期任务之间做安全隔离。

但很多人写ADS通信时第一个想法就是开个while循环,每隔几毫秒去读一次PLC数据。这种做法在变量少、实时性要求不高的Demo里确实能跑,一旦变量多了、刷新快了,问题就接踵而至。CPU占用大幅上升、Windows线程被卡住、通信偶尔超时,甚至因为频繁请求导致Ads服务端响应不过来。

1.2 同步轮询为什么不可靠

同步轮询的问题不在于“请求—响应”这个模式本身,而在于工业现场的数据变化往往是突发性的。你去查一个数据点时,它可能已经变了10次,也可能一次都没变,而你白白占用了带宽。更致命的是,同步请求会让Windows侧线程阻塞在等待响应的过程中,这时候如果TwinCAT实时核任务因为抖动或优先级问题响应慢了,你的上位机线程也会跟着一起卡。

举个例子,用AdsSyncReadReq去读一个1ms周期任务的变量,如果上位机每5ms轮询一次,理论上平均延迟在2.5ms左右,看起来可以接受。但实际上Windows不是实时系统,线程在内存访问、硬盘IO、GC(如果是C#)等情况下都可能被挂起,一旦发生调度延迟,轮询间隔可能从5ms变成15ms甚至50ms。实时核那边数据早就不知道刷到哪一帧了。

这里还不说多个变量顺序读取时,请求队列排队导致的累计延迟。所以用同步方式去追赶实时核的节奏,方向就是错的。正确思路应该是:让实时核在数据变化的时候主动通知你,而不是你反复去问。

1.3 异步通知模式:从“主动问”变成“被动听”

ADS异步通知(Notification)机制恰好解决了这个问题。你给ADS服务端注册一个通知,告诉它“我关心这个变量,你按照我指定的周期把数据发给我”。服务端收到注册请求后,会在实时环境或ADS中间层按照设定周期采集数据,然后通过异步消息通道推送给客户端。

这个模式的好处在于,客户端线程不用阻塞等待响应,只需要在回调里处理推过来的数据。回调触发时机由ADS服务端决定,数据到达时一定是“新鲜的”。客户端可以一边接收数据一边干其他事,完全没有同步等待的阻塞风险。等你真正把几个上千点数组的实时波形刷起来之后,会发现异步模式在CPU占用和延迟稳定性上几乎是碾压同步轮询的。

2. ADS通信模型拆解:Server/Client到底谁是谁

2.1 通信双方与信息走向

ADS协议里的Server和Client,和平时写Web服务时想的Server/Client不完全一样。TwinCAT实时系统这边是ADS Server,它维护实时变量、提供符号访问和通知功能。你的C++程序是ADS Client,通过调用TcAdsDll的API建立连接、声明变量、注册通知、接收回调。

整个通信链路可以简化成三层。底层是AMS路由器,负责把报文从Windows的TCP/IP路由到TwinCAT实时核;中间层是ADS服务端,负责解析请求并访问PLC符号表;顶层就是你的C++客户端程序,通过TCP端口连接AMS路由器,发送ADS指令。数据流向是双向的:Client可以读、可以写,也可以注册通知。写操作也可以做成异步,但实际项目中同步写更常见,因为写入频率通常比读取频率低很多。

这里有一个常见的误区,很多人以为用ADS通信就得装TwinCAT XAE开发环境或者至少装个运行时授权。其实对于纯上位机开发,只要目标机器上有TwinCAT 3.1 runtime(哪怕是当前开发电脑上启动的本地运行时),并且能通过路由访问,C++客户端程序就可以正常工作。你需要的最核心依赖就是TcAdsDll.dll以及对应的头文件和库文件。

2.2 AMS NetId、端口号和路由配置的关键性

ADS通信能不能建立,99%的坑都出在AMS NetId和端口号上。AMS NetId是TwinCAT节点的唯一标识,长得很像IP地址但每一段是独立的,比如192.168.250.1.1.1。你运行TwinCAT XAE时,系统会自动分配一个NetId,通常和本机第一个网卡IP有关,但并不是一回事。

客户端想要连接Server,必须知道两件事:目标机器的AMS NetId,以及目标ADS端口号。TwinCAT3系统中,端口851通常是PLC Runtime的ADS端口,852是NC(运动控制)的端口,853是NC IO的端口。如果你连接的是本机运行时,NetId就是本机的AMS NetId;如果连接远程工控机,还需要在TwinCAT路由设置里把远程设备的路由信息加进去,否则客户端的请求根本传不过去。

端口和NetId是通信的“门牌号”,写错一个字母都直接失败。我见过不少人拿着官方样例代码,把NetId写成了本机IP地址,或者没有在路由视图里添加Server地址,结果一直报“ADS Server not found”。排查这类问题,第一反应应该是检查这两个参数,而不是怀疑代码逻辑。

2.3 为什么C++适合做ADS客户端

C++做ADS客户端,相比C#和LabVIEW,最大的优势就是没有托管环境的管理开销。Windows下调用TcAdsDll直接走原生API,数据从到达网卡到回调函数触发,中间没有GC停顿、没有跨语言封送开销,延迟更可控。对于高频数据采集场景,比如几十个变量、1ms周期同时刷新,C++版本能稳扎稳打吃下来。

而且C++和TwinCAT3的底层亲和度很好。TwinCAT本身用C++开发ADS接口,C++客户端可以直接拿到接近底层的句柄操作方式,甚至能对TcAdsDll做二次封装。如果你团队同时有PLC工程师和软件工程师,C++上位机也方便维护和长期迭代。当然代价就是你得自己管理内存和句柄生命周期,但这些在工业软件场景里完全可以接受。

3. 核心环节实现:从PLC侧准备到C++客户端落地

3.1 PLC侧变量准备:实时任务的“数据窗口”

先看PLC侧。要给别人访问的数据,不能随手放在某个FB的局部变量里。最方便的做法是在一个全局变量列表(GVL)里定义所有需要交换的变量,并且取清晰、可预测的名字。比如:

VAR_GLOBAL bCommandStart : BOOL; // 启动命令 bRunning : BOOL; // 运行状态 nCurrentPos : DINT; // 当前位置 dTargetVelocity : LREAL; // 目标速度 arrWaveform : ARRAY[0..99] OF LREAL; // 波形数据 END_VAR

这些变量会在TwinCAT的符号表中生成对应的符号名,C++客户端就是通过类似GVL.bCommandStart这样的字符串来访问它们的。注意命名别带中文、别带奇怪的字符,ADS虽然支持Unicode符号,但跨平台和跨版本时容易出幺蛾子,老老实实用英文字母加下划线最稳妥。

同时在PLC侧要把任务周期控制好。如果你的变量是给上位机做监控的,通常放在一个10ms或者50ms周期的任务里就够了;如果是伺服轴位置跟踪,可能放到1ms任务里。这里的周期会直接关联到异步通知里你设置的NotificationCycleTime,两者不匹配时会有一层隐性同步损耗,后面参数部分再细讲。

3.2 VS2019工程配置与TcAdsDll接入

开发环境建议用Visual Studio 2019或2022,C++桌面开发组件。你先从TwinCAT安装目录找到TcAdsDll相关文件,一般在C:\TwinCAT\AdsApi\TcAdsDll\下,里面包含:

  • TcAdsDll.dll
  • TcAdsDll.lib
  • ads.h、tcadsdef.h等头文件

在VS工程里做三件事:项目属性->VC++目录->包含目录,添加头文件路径;链接器->常规->附加库目录,添加lib所在路径;链接器->输入->附加依赖项,添加TcAdsDll.lib。运行时记得把TcAdsDll.dll放到exe同目录,或者放到系统PATH里。

配置好之后,把工程字符集设为“使用多字节字符集”或统一用UTF-8,避免在传递变量名时出现字符编码不一致导致符号找不到。这一步看似简单,实际现场出错率极高,尤其在不同语言版本的Windows上,窄字符串和宽字符串混用会让符号名寻址失败,回头看整个代码没有任何语法问题,就是连不上。

3.3 异步通知监听:主程序代码骨架

下面给一个最简可用的异步通知读取示例,基于TcAdsDll的C接口风格,便于看清完整流程。实际项目里不建议裸写这么多API,但理解这个骨架比封装重要得多。

#include <windows.h> #include <stdio.h> #include "tcadsdef.h" #include "tcadsdll.h" // 通知回调:ADS服务端推送数据时触发 long __stdcall NotificationCallback(AmsAddr* pAddr, AmsNotificationHeader* pNotif, uint32_t userData) { if (pNotif && pNotif->nDataSize >= sizeof(double)) { double value = 0.0; memcpy(&value, (uint8_t*)pNotif + sizeof(AmsNotificationHeader), sizeof(double)); printf("CurrentSpeed: %.3f\n", value); } return 0; } int main() { long err = 0; AmsAddr serverAddr; uint32_t port = 0; // 1. 打开ADS端口 port = AdsPortOpenEx(); if (port == 0) { printf("AdsPortOpenEx failed\n"); return -1; } // 2. 设置服务器AMS NetId和端口 err = AdsGetLocalAddressEx(port, &serverAddr); serverAddr.port = 851; // PLC Runtime端口 // 3. 连接测试:获取服务器状态 uint16_t state = 0; err = AdsSyncReadStateReqEx(port, &serverAddr, &state, nullptr); if (err != ADSERR_NO_ERR) { printf("Server connect failed, err=0x%X\n", err); AdsPortCloseEx(port); return -1; } // 4. 获取符号句柄 uint32_t symHandle = 0; err = AdsSyncReadWriteReqEx(port, &serverAddr, ADSIGRP_SYM_HNDBYNAME, 0, sizeof(symHandle), &symHandle, "GVL.dTargetVelocity", 0, nullptr); if (err != ADSERR_NO_ERR) { printf("Symbol handle failed, err=0x%X\n", err); AdsPortCloseEx(port); return -1; } // 5. 注册异步通知 uint32_t notifyHandle = 0; AmsNotificationHeader notifHeader[BASE_NUM_NOTIFICATIONS]; err = AdsAddNotification(port, &serverAddr, ADSIGRP_SYM_VALBYHND, symHandle, ADSTRANS_SERVERCYCLE, 10, &notifyHandle, (AdsNotificationFuncEx)NotificationCallback, 0, &notifHeader); if (err != ADSERR_NO_ERR) { printf("AddNotification failed, err=0x%X\n", err); AdsPortCloseEx(port); return -1; } printf("Notification registered, waiting... Press ENTER to exit.\n"); getchar(); AdsDelNotification(port, notifyHandle); AdsPortCloseEx(port); return 0; }

注意这里用到了ADSIGRP_SYM_HNDBYNAME通过符号名获取句柄,再用ADSIGRP_SYM_VALBYHND对句柄做通知注册。这是相对底层但通用的做法。如果你用的是较新的TcAdsDll版本,官方还提供了AdsGetSymHandleByName这样的高层封装,底层原理是一样的。回调函数里要注意不能做重活,不要写文件、不要弹窗、不要调用阻塞API,否则数据推送会积压。正确姿势是把数据拷到自己管理的内存池或队列里,由另一个工作线程去消费。

3.4 数据写入与请求回调

除了读取,上位机经常还需要往PLC写命令和参数。写入建议用同步写,因为写入操作频率低、实时性要求相对宽松,而且同步写能立即拿到错误码,便于确认写入是否成功。核心代码如下:

// 写BOOL型变量 bool bStart = true; uint32_t hRoot = 0; err = AdsSyncReadWriteReqEx(port, &serverAddr, ADSIGRP_SYM_HNDBYNAME, 0, sizeof(hRoot), &hRoot, "GVL.bCommandStart", 0, nullptr); if (err == ADSERR_NO_ERR) { err = AdsSyncWriteReqEx(port, &serverAddr, ADSIGRP_SYM_VALBYHND, hRoot, sizeof(bool), &bStart); }

这里有个经验:对BOOL变量,写入时不要直接写一个bool类型就完事,要确认目标PLC变量类型。TwinCAT3的BOOL在内存中占1字节,但某些版本会做对齐,最好先读一遍再写,或者用和PLC类型严格对应的类型。写入数值类变量(INT、DINT、REAL、LREAL)时,对应好位宽,REAL是4字节,LREAL是8字节,写错会导致数据变成乱七八糟的数。

异步通知的回调线程是TcAdsDll内部管理的,线程上下文由DLL自动创建。如果你要在回调里更新GUI界面(比如MFC或Qt),不能直接操作UI控件,否则线程冲突轻则界面卡死,重则直接崩溃。标准做法是把回调里的数据丢到一个线程安全队列,UI线程通过定时器取数据来刷新。

4. 参数、性能与细节:决定通信质量的不只是代码

4.1 周期选择与抖动控制

ADS异步通知的周期参数放在AdsAddNotification的第三个参数(在示例里是ADSTRANS_SERVERCYCLE)和第四个参数(10,单位毫秒)里。ADSTRANS_SERVERCYCLE表示按Server的周期扫描来触发,ADSTRANS_CLIENTCYCLE表示按Client侧周期触发,还有ADSTRANS_ONCHANGE表示变量变化即触发。

实际使用中,周期值要大于或等于PLC任务周期,否则通知频率会超过实时任务刷新次数,产生大量重复数据。比如PLC任务周期是2ms,你设置通知周期1ms,那么至少有一半回调读到的是同一帧旧数据,白白浪费带宽。推荐把通知周期设置成PLC任务周期的整数倍,2倍或5倍甚至10倍,视监控需求而定。

抖动方面,如果发现回调触发时间间隔忽大忽小,先检查Windows侧有没有开启电源节能模式,再把网络优先级调高。对于本机通信,这种抖动一般很小;对于远程通信,走的是TCP/IP和AMS路由,抖动就取决于交换机和服务端负载了。如果要做精确波形采集,建议在PLC侧给数据打时间戳,上位机以时间戳对齐波形,而不是依赖回调到达时间。

4.2 符号映射与命名规范

用符号名访问变量确实方便,但每次通过ADSIGRP_SYM_HNDBYNAME获取句柄都会产生一次请求。如果变量很多,比如有上百个,建议程序启动时一次性获取所有句柄并缓存起来,运行过程中直接用句柄访问,避免频繁字符串解析。

还有一个容易被忽略的点:TwinCAT3符号名大小写敏感。PLC里定义的是nCurrentPos,你写成ncurrentpos,句柄查询会失败。符号名里的数组访问也有一套规则,比如读整个数组和读数组中某个元素,用到的符号名带下标方法不一样,官方文档里的写法是arrWaveform[0]这样。但建议只访问整个数组,再用指令集把需要的元素抠出来,效率更高。

另外,如果你在PLC里对某个结构体变量做了AT地址重映射,或者使用了带命名空间的库功能块,符号名可能会带上库前缀,比如LibName.FBName.VarName。这种情况下,最稳妥的方法是在TwinCAT XAE的符号表视图里查看准确的符号名,复制出来直接用,不要自己手拼。

4.3 线程亲和性与回调线程处理

TcAdsDll的回调线程由DLL内部创建,默认线程优先级不算高。如果你发现高频通知时数据丢失,可以尝试手动降低通知频率,而不是拼命提高回调处理速度。因为ADS通知机制本身在数据量过大时有内部队列,消费不过来时会丢包。工业控制场景,丢几帧监控数据通常可以接受,但如果数据是用于闭环控制,建议把关键控制逻辑放PLC,上位机只做监控和记录,千万不要用Windows侧做实时闭环。

如果你同时开了多个通知,注意回调线程可能被复用,不保证同一变量的两次回调必然在同一个线程里。因此回调里访问的共享数据必须加锁或用原子操作。我通常会在初始化时创建一个环形缓冲区,所有回调都往缓冲区写数据,消费者线程定时取数,这样既不阻塞回调,也避免频繁加解锁。

4.4 同步/异步混用场景

实际项目里很少只用一种通信模式。我的习惯是:命令类、参数类的写入用同步写,状态监控和波形采集用异步通知,周期性的慢速数据汇总用同步读。这样组合的好处是,关键写入能快速知道结果,高频采集不阻塞主流程,低频数据又不占通知资源。

混用时要特别注意句柄的生命周期管理。同一个变量的句柄可以同时用于同步读写和注册通知,但如果一个线程在写、另一个线程在回调里读同一块内存,需要自己做数据一致性保护。最简单的手段是把读取到的数据拷贝到独立结构体,保证每次回调都是完整的一份数据快照。

5. 常见问题与排查技巧实录

5.1 典型报错与解决速查表

错误码/现象可能原因解决办法
0x70000(ADSERR_CLIENT_PORTNOTOPEN)没有先调用AdsPortOpenEx检查端口初始化逻辑
0x70100(ADSERR_DEVICE_SRVNOTFOUND)AMS NetId或IP路由错误核对Server地址和路由配置
0x71001(ADSERR_DEVICE_SYMBOLNOTFOUND)符号名拼写错误或不带前缀从符号表复制准确符号名
0x9800(TCP连接失败)远程目标没有运行TwinCAT Runtime确认目标机器授权和运行状态
回调频率远低于设定通知队列溢出或回调处理过慢降低频率、提升回调处理速度
程序退出时崩溃没有删除通知就关闭端口先AdsDelNotification再AdsPortCloseEx

这个表我每次做新项目都会扫一遍,90%的通信问题都能在这里找到对应项。尤其是0x71001,新手最容易踩,因为TwinCAT3的符号名有时会在前面自动拼接一个MAIN.或其他任务名前缀,你自以为是全局变量,实际符号名是MAIN.GVL.nCurrentPos这样冗长的形式。

5.2 找不到服务器或端口占用

排查“Server not found”时,分三步走。第一步,确认本机和目标机的TwinCAT版本一致或兼容,3.1.4024和4022的某些API行为有差异;第二步,在TwinCAT System Manager的Routes视图里添加目标设备,把AMS NetId和IP地址填对;第三步,用Telnet或者TwinCAT自带的Router Diagnostics工具测试851端口的连通性。

如果是本机连接,最简单的方式是先用AdsGetLocalAddressEx拿到本机NetId,打印出来和PLC的AMS NetId对比。不要想当然地觉得本机的NetId一定是“127.0.0.1.1.1”,那只是默认安装时的一种情况。很多工控机装双网卡,NetId可能会绑定到业务网卡上,和你以为的本机IP完全不是一回事。

端口占用方面,851/852是固定端口,理论上不会冲突,但如果同一台机器上跑多个TwinCAT实例,或者有别的软件占用了TCP 48898端口(AMS路由器默认端口),就会连接失败。这类问题看Windows事件日志能快速定位。

5.3 数据不一致、偶发超时问题

数据不一致最常见的原因是数组数据跨帧更新。ADS通知一个数组变量时,底层会一次性发送整段数据,正常来说不存在撕裂。但如果你在回调里拿到的是大数组指针,PLC侧在同一个周期里又同时更新了这组数据,边缘可能受任务周期影响。真正要避免的是:读一个数组的某个元素,同时又读该数组的另一个元素,两个句柄在不同时间到达,组合出来的数据对应不上同一帧时刻。

偶发超时则通常和Windows电源管理有关。如果你用的是笔记本开发,默认的“平衡”电源模式会降低CPU频率和网络设备性能。做工业通信调试时,把电源模式设为“高性能”,网卡里关闭节能以太网,会极大减少超时次数。

还有一个高级技巧:如果超时发生在高强度数据写入时,可以适当降低写频率,把多次写请求合并成一次AdsSyncWriteReqEx写结构体。TwinCAT支持一次写入一段连续地址,把多个变量放入一个结构体里,写一个结构体相当于写一批变量,效率能提高一个数量级。

5.4 调试利器

最后分享几个调试工具,都是我把通信调通全靠它们撑起来的。

第一个是TwinCAT自带的信息系统(InfoSystem),可以实时查看ADS端口状态、通知数量和连接数。第二个是Wireshark的ADS协议解析插件,虽然不常用,但遇到诡异的报文问题时能帮大忙。第三个是TcAdsDll自带的示例代码,位于TwinCAT安装目录下的AdsApi\TcAdsDll\Samples,里面有几个现成的C++工程,哪怕不用它的代码结构,里面关于句柄和通知参数的写法也足够当字典查。

如果是远程通信,我还会在PLC里加一个简单的诊断变量,记录最近一次ADS通信的错误码和时间戳。这样当上位机那边报“通信超时”时,可以从PLC侧确认是根本没收到请求,还是请求到了但响应没有返回。很多模糊问题一瞬间就有答案了。

注意:ADS异步通知的注册者在退出程序时必须先删除通知再关闭端口,顺序反了极其容易出现回调函数访问已释放内存的问题。这种崩溃是偶发的,最恶心,上线前一定要重点验证退出逻辑。

从我实际操作的项目来看,ADS异步通信最适合的场景是“PLC做实时控制,上位机做监控和管理”,而不是拿C++去替代PLC做闭环。把周期设置成PLC任务的整数倍,回调里只做数据快照不做重计算,命令写入保持同步,退出时严格销毁通知,这套流程走下来基本不会出大问题。如果后续有跨平台需求,可以在Linux上用ADS over TCP的方式做类似通信,原理一样,只是TcAdsDll换成了第三方的ADS库,整体思路不用推翻重来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询