☰
C++与TwinCAT 3 ADS通讯实战:从原理到排错的全指南
2026/10/5 1:26:33 网站建设 项目流程

先说个真实场景:现场PLC程序跑得好好的,双击Twincat 3.0里的Activate Configuration也没报错,轴能转、IO能采,但你用C++写了个上位机想读几个变量,连了半天返回一个0x1024(十进制4132),一脸懵。这个错误几乎每个做倍福二次开发的人都遇到过,网上中文资料零散得很,官方文档又是英文且偏难啃。我做了几年倍福上位机开发,ADS通讯这块踩过不少坑,这次把我整理过的东西一次性写清楚,包含了从连接原理、工程配置到变量寻址、类型映射、高频读写性能优化,最后还有排查链路,希望能帮你少走弯路。

ADS全称Automation Device Specification,是倍福自己的通讯协议,TwinCAT 3和外部程序打交道基本都走它。C++要跟倍福通讯,绕不开ADS。下面从原理讲到代码,再讲到坑。

1. ADS通讯到底在解决什么问题

1.1 为什么选C++而不全用倍福自家方案

倍福生态里,跟PLC交换数据不止ADS一条路,常见的还有OPC UA、Modbus TCP,以及TwinCAT自带的ADS。OPC UA的好处是跨平台、跨厂商,但你要在C++里集成一个OPC UA客户端库,配置节点、证书、命名空间,复杂度并不低。Modbus TCP简单,但数据组织太原始,一次要搬一整块寄存器,和PLC里结构体变量对应起来非常痛苦。ADS不一样,它是倍福的亲儿子,TwinCAT 3对外通讯优先支持,能直接按变量名读写PLC内部变量,权限控制、实时性、效率都拉满。

选择C++而不是C#,主要看场景。TcAdsDll提供的C接口是底层最基础的API,C#和VB.NET都是对这层API的封装。如果你做的是测量系统、视觉系统、运动控制里的上位机逻辑,需要追求低延迟和精细的内存控制,或者现有的代码库就是C++写的,那直接用C++调TcAdsDll是最合理的路径。另一个原因是调试方便,C接口做的事情很直白,返回值、错误码一目了然,出问题容易定位,不会像高级封装那样把底层错误包起来。

1.2 认识两个关键身份信息:AMS NetId和Port

ADS能够通讯的前提是你能唯一定位到目标设备上的目标程序。这里牵扯两个概念:AMS NetId和AMS Port。

AMS NetId长什么样?类似192.168.0.1.1.1,一共六组数字,前四组通常是设备IP地址,后两组是设备内部分配的编号。注意,AMS NetId和你网卡的IP可以不一样,它是倍福设备在AMS路由器里的标识,是逻辑地址。你在TwinCAT开发环境里能看到本机NetId,在Windows命令行执行TwinCAT.exe /getnetid也能拿到。

AMS Port则是设备内部程序监听的端口号。TwinCAT 3里最常用的是851,对应PLC运行时;10000是System Service,用来做系统级操作;还有900是IO设备等。不同端口代表不同的服务,通讯的时候必须指定对。很多人连接失败,就是把851写成了48898之类的东西——48898是ADS over TCP/IP的TCP端口号,跟AMS Port完全是两码事。

清楚这两个信息之后,你在上位机里构造一个AmsAddr结构体,把NetId和Port填对,剩下的就是通过TcAdsDll的接口把请求发给Windows上的Router(也就是TwincAT Router服务),Router再通过TCP 48898端口转发给目标设备。这句话基本概括了C++与TwinCAT 3 ADS通讯的完整路径。

2. 环境准备:TcAdsDll与C++工程配置

2.1 在哪里找到正确的Dll版本

你要在C++里调ADS,需要三个东西:TcAdsDll.lib、TcAdsDll.dll以及头文件TcAdsAPI.h(有的版本叫AdsClient.h)。装了TwinCAT 3或者TwinCAT XAE开发环境之后,这些文件通常在C:\TwinCAT\3.1\Components\Ads\目录下。具体位置会因为安装版本略不一样,我用的版本中文件分布在C:\TwinCAT\3.1\Components\Ads\TcAdsDll\和SDK目录里,网上也有人从倍福官网的ADS SDK单独下载。

这里要特别提醒第一坑:32位和64位的Dll版本必须和你的编译目标平台一致。你工程是x64就加载64位版TcAdsDll.dll,是Win32就加载32位版。混着用的话,调用AdsPortOpen时可能直接崩溃,或者返回一个莫名其妙的错误码。我之前在Visual Studio里默认x86编译,连的是64位的Dll,结果一跑就崩,查了半天才反应过来。VS工程里链接器设置的附加库目录和运行时的PATH都要对应上。

2.2 工程配置三个容易翻车的地方

第一个是头文件路径。TcAdsAPI.h用到了#include <windows.h>,你在Win32控制台程序里没问题,但如果用了某些严格警告级别的项目配置,可能因为头文件里的结构体对齐方式差异报警。建议把ADS头文件放在项目include目录里,单独管理,不要和系统头混在一起。

第二个是字符编码。TcAdsAPI.h里有一部分函数是窄字符版本,一部分参数类型跟宽字符无关,但和字符串相关的函数在C++下容易和你工程的Unicode设置打架。我的经验是用多字节字符集(Multi-Byte Character Set)编译,或者干脆所有字符串都用char*并保持工程设置一致,别在宽窄字符之间来回转。

第三个是依赖项。TcAdsDll.lib不要用#pragma comment(lib, "TcAdsDll.lib")硬编码,除非你把lib放在工程目录里且位数匹配。我建议在工程属性里配置好附加包含目录和附加库目录,这样切Debug/Release、切x86/x64的时候路径不会错。这个细节能省掉后续很多奇怪的无头绪报错。

配置完这些,你的C++工程就具备了调用ADS接口的条件。下面写第一段实际代码。

3. 第一个C++读写程序:从连接成功到读到变量

3.1 核心API的调用链路

TcAdsDll的C接口核心就几个函数,链路非常清晰:

  1. AdsPortOpen():打开本机的ADS端口,得到一个端口句柄。
  2. AdsGetLocalAddress(AmsAddr*):获取本机AMS地址,这一步通常用来方便地构建本机通讯地址。
  3. AdsSyncReadWriteReq()、AdsSyncReadReq()、AdsSyncWriteReq():同步读写请求。
  4. AdsPortClose():关闭端口。

读和写的逻辑都有点像你给某个服务发一个RPC请求:构造目标地址AmsAddr,指定功能码,传数据缓冲区,函数返回ADS错误码。0表示成功,非0就是出错了。

3.2 读一个INT变量的最小实现

一个典型的最小程序长得像这样:

#include <windows.h> #include <TcAdsAPI.h> #include <iostream> #include <cstring> int main() { long nPort = AdsPortOpen(); if (nPort == 0) { std::cerr << "AdsPortOpen failed" << std::endl; return -1; } AmsAddr addr; memset(&addr, 0, sizeof(addr)); addr.port = 851; // 本地调试时直接用本机地址 AdsGetLocalAddress(&addr); unsigned short value = 0; unsigned long bytesRead = 0; // 假设PLC里有一个全局变量 nTestValue,类型是INT long err = AdsSyncReadWriteReq( nPort, &addr, ADSSYMB_REQ, // 服务ID,读符号变量 0, 0, &value, // 返回数据缓冲区 sizeof(value), (void*)"nTestValue", // 变量名字符串 10 // 变量名长度 ); if (err == 0) { std::cout << "Read value: " << value << std::endl; } else { std::cerr << "Error: 0x" << std::hex << err << std::dec << std::endl; } AdsPortClose(); return 0; }

别急着运行,有几个点说一下。ADSSYMB_REQ是读取符号变量时用的服务ID,它配合indexGroup=0, indexOffset=0表示我不通过地址方式读,而是通过变量名方式读。AdsSyncReadWriteReq这个名字有点绕——它实际上是“先写请求数据,再读响应数据”,在这里写入的是变量名字符串,读出的就是变量值。很多人第一次用会疑惑为什么读变量要用ReadWrite而不是Read,因为你需要把变量名传给ADS服务端,这是由ADS协议决定的。

3.3 写变量同样套路

写变量的代码逻辑类似,用AdsSyncWriteReq:

unsigned short newValue = 66; long err = AdsSyncWriteReq( nPort, &addr, ADSSYMB_REQ, 0, 0, &newValue, sizeof(newValue) );

注意AdsSyncWriteReq没有ReadWrite的“写入请求数据”那一步,因为请求数据就是你要写的值本身,直接放在缓冲区里传过去就行。但变量名怎么传?这就微妙了——AdsSyncWriteReq在indexGroup=0, indexOffset=0时,内部其实不接受变量名。你要通过符号方式写单个变量,最保险的是用AdsSyncReadWriteReq,把变量名作为写的那一部分数据,把目标值放在读缓冲区里。等等,这样不对。重新理一下。

AdsSyncReadWriteReq的签名大致是:

long AdsSyncReadWriteReq( long nPort, AmsAddr* pAddr, unsigned long nIndexGroup, unsigned long nIndexOffset, unsigned long nLength, // 读缓冲区长度 void* pData, // 读缓冲区(存放结果) unsigned long nWriteLength, // 写入数据长度 void* pWriteData // 写入数据(请求参数) );

对于读符号变量:nIndexGroup=ADSSYMB_REQ(0x0000F003),nIndexOffset=0,pWriteData写变量名,pData接收值。

对于写符号变量:仍然用AdsSyncReadWriteReq,但pWriteData写变量名,pData不用来接收结果而是放要写的值?这看起来别扭,但ADS就是这样的机制:通过这个服务,可以同时携带“参数”和“返回值”,写变量时参数是变量名,返回值缓冲区用来承载要写入的值。一些官方示例里确实这么干。

实际上更常规的做法是先通过AdsSyncReadWriteReq拿到符号句柄(symbol handle),然后用句柄去重复读写,这个我们放到第4节详细讲。这里先把最简单的读法跑通,重点理解接口的请求/响应模型。

跑通上述程序后,如果返回值不是0,恭喜你,正式踏进ADS踩坑大门。第一个拦截你的大概率就是0x1024(4132),这个错误值得专门拿出来说,后面第7节会展开。

4. 变量寻址的两套体系:符号句柄与索引寄存器

4.1 符号方式:最省事的做法

用变量名直接读写(类似上面例子),这是最直观的方式。但每次都传变量名字符串,ADS服务端需要动态解析名字到地址,性能上会有损耗,对于高频读写(比如1ms周期)是不可接受的。解决办法是先解析一次,拿到“符号句柄”,之后用句柄读写。

获取符号句柄的核心代码:

unsigned long handle = 0; unsigned long bytesRead = 0; long err = AdsSyncReadWriteReq( nPort, &addr, ADSSYMB_REQ, 0, 0, &handle, sizeof(handle), (void*)"nTestValue", 10 ); if (err == 0) { // 拿到句柄后,后续读写都走这个句柄 unsigned long readReq = 0x0000F005; // ADSSYMB_VALBYHANDLE err = AdsSyncReadReq( nPort, &addr, readReq, handle, sizeof(value), &value ); }

这里的ADSSYMB_VALBYHANDLE(0x0000F005)是“通过句柄读取变量值”的服务ID,你把句柄放在indexOffset的位置,一次调用就是一次读数。这种方式比每次传名字快得多,而且语义清晰。

句柄有个特性:每次PLC重新激活配置或重启后,句柄可能失效。所以在程序里不要缓存句柄到本地文件,每次连接建立后重新获取。我见过有人把句柄写进配置文件,结果PLC重启后上位机怎么读都返回错误,折腾了很久。

4.2 索引组/索引偏移方式:适合大块数据的做法

第二种寻址方式是基于IndexGroup和IndexOffset的地址访问,这其实就是ADS协议“原始”的寻址方式。你可以把它理解成直接访问PLC内存区域。

常见的IndexGroup有两类。一类是IO区域,比如ADSDAT_IO16(0x00001020)对应16位IO区域,ADSDAT_IO32(0x00001040)对应32位IO区域。你在Twincat里看到的IW(输入字)、QW(输出字)就是这些区域的一部分,用偏移量定位到具体通道。

另一类是数据区域,比如ADSDAT_IO16上偏移到某个地址,或者在Twincat 3里默认的Data Area。IndexGroup和IndexOffset的组合,可以精确到PLC变量在内存中的偏移。但问题是你很难手工计算出某个全局变量在内存区里的准确偏移,所以这个方式更多用于读取固定地址的光谱数据、IO映射数据,或者通过Twincat的TCNCO工具提前算好偏移量。

实际项目中,我的经验是:

  • 读一些零散的、需要“一眼看懂”的变量,用符号方式(字符串或句柄);
  • 读一批固定结构的数组、例如几百个轴的位置数据、一批传感器采集结果,用地址方式,一次性读一个大缓冲,效率明显更高。

4.3 两套体系的适用场景

两套体系不是互斥的,可以混合用。一个工程里,我通常会在初始化阶段把关键的几十个变量都用符号方式解析成句柄,后续循环里全部走句柄;对于周期性批量上传的数据,单独用地址方式开一块内存读取。混合使用的时候注意别搞混服务ID,不然返回的要么是错误码,要么是乱数据。

5. 数据类型的跨语言映射:内存布局决定生死

5.1 基本类型对照表

C++和TwinCAT 3的PLC类型不是一一对应的,尤其要注意位宽和符号性。我常用的一张对照表如下:

Twincat 3类型位宽(bit)C++对应类型说明
BOOL8bool / BYTE一个字节,别用1bit位域去读
BYTE8uint8_t无符号字节
WORD16uint16_t无符号16位
DWORD32uint32_t无符号32位
SINT8int8_t有符号8位
INT16int16_t有符号16位,最容易搞错的是它不等于C++ int
DINT32int32_t有符号32位,C++的int在多数平台是它
LINT64int64_t有符号64位
REAL32float单精度浮点
LREAL64double双精度浮点
STRING可变char[]通常固定长度,包含'\0'

这里最大的坑是TwinCAT的INT是16位,而C++的int通常是32位。你用AdsSyncReadWriteReq读一个INT变量,却传给函数一个int的缓冲区指针,结果只写入了半个int,导致高16位是未初始化的野数据。轻则数值不对,重则破坏栈上其他数据。我的规矩是:所有ADS相关数据定义,一律使用stdint.h里的定长类型,比如int16_t、uint32_t,从源头上根治位宽问题。

5.2 结构体、数组和字符串的坑

结构体在ADS通讯里是按内存连续排布的,PLC侧的结构体成员会按PLC编译器的对齐规则排内存。C++侧定义对应的结构体时,如果对齐方式不一致,读出来的数据会整体错位。

比如PLC侧结构体:

TYPE ST_AxisData : STRUCT nPosition : DINT; nVelocity : DINT; fAcc : REAL; bEnable : BOOL; END_STRUCT END_TYPE

C++侧如果直接写:

struct AxisData { int32_t nPosition; int32_t nVelocity; float fAcc; bool bEnable; };

大概率是对的,因为成员都是4字节或1字节,默认对齐下没有空洞。但如果成员里有WORD或者BYTE混合排列,双方编译器的对齐规则就可能产生不同的空洞填充,读出来数据就飘了。

解决方式是两边都明确指定对齐,或者在C++侧把结构体按1字节或4字节对齐声明:

#pragma pack(push, 1) struct AxisData { int32_t nPosition; int32_t nVelocity; float fAcc; uint8_t bEnable; }; #pragma pack(pop)

注意,#pragma pack(1)能保证C++侧不做填充,但PLC侧内存布局是它自己定的,你不能让PLC配合你改对齐,所以要对齐的是C++侧去贴合PLC侧。PLC侧默认成员之间不填充(倍福的ST结构体不太倾向于加padding),但为了稳妥,你只要定义C++结构体时用pack(1)并且成员顺序和PLC完全一致,就不会错。

字符串方面,PLC的STRING(20)是固定20字节的字符数组,最后带一个隐含的结束符,实际能存19个字符。C++侧读取时用char buf[20]就行。中文场景下要注意编码,倍福PLC内部字符串一般按Windows本地代码页存储,你在C++侧读出来打印到控制台可能正常,一旦上位机是Linux或者Qt环境,中文会乱码。乱码不一定是通讯错了,是编码不一致。稳妥做法是PLC侧字符串统一用英文/ASCII,或者上位机做编码转换,别指望PLC配合UTF-8。

数据对齐和类型映射这块,属于那种报错少、出错隐蔽的“软坑”,程序能跑,数值不对,排查起来最费劲。第7节我会具体讲排查链路,这里先记住一个原则:C++侧每个读缓冲区的类型、大小必须精确匹配PLC侧变量,别图省事用默认的int、long。

6. 高频场景的通讯架构:轮询、通知与多线程

6.1 轮询周期的取舍

很多项目一上来就是写个while循环,每10ms读一次轴位置。这在变量少、周期不敏感的场景没问题,但要注意几个副作用。

第一,ADS请求本身有网络开销。同步请求从发起等待响应到数据返回,在本地连接下通常几百微秒,跨网段可能几毫秒。如果你的控制周期是1ms,同步轮询根本跑不动。第二,同步轮询遇到偶发网络延迟会阻塞你整个线程,如果上位机还承担UI刷新,界面会卡。第三,请求频率太高会把Router和PLC侧的通信负载拉高,占用PLC循环时间。

我的工程经验是:

  • 100ms以上周期,随便同步读;
  • 10ms~50ms周期,用句柄方式读,尽量避免字符串解析;
  • 1ms~5ms周期,别用ADS做高频点对点读写,优先选EtherCAT的分布时钟或者把数据缓冲好在PLC里整块传;
  • 大数据量的块传输,优先地址方式整读,而不是一条条变量名读。

6.2 通知(Notification)机制的使用

如果不想让上位机疯狂轮询,ADS有通知机制:你告诉PLC“这个变量一变就通知我”,数据变化时Router主动推送给上位机。使用通知需要调用AdsSyncAddDeviceNotificationReq,注册一个回调,这个机制在TwinCAT 3里依然有效。

但实际用下来,我觉得通知机制更适合变量变化频率低、事件驱动的场景,比如报警状态、按钮信号。对于几毫秒就变化一次的高速测量值,通知回报会产生非常密集的消息,处理不过来反而丢数据。认真的高频系统还是得靠“PLC缓存一批数据,上位机整块取走”的模式。

6.3 线程模型与生命周期管理

C++上位机用ADS通讯,多线程是绕不开的。我的建议是单开一个专门的通讯线程,里面做循环读写,通过原子变量或消息队列和UI线程交互,不要让UI线程直接调同步的ADS接口,除非你的UI对卡顿无感。

另一个细节是关闭顺序:先停止读写循环,再AdsPortClose()。如果读写线程还在跑,主线程就把端口关了,轻则异常退出,重则直接崩溃。这个问题在程序退出时最容易踩,我的做法是加一个退出标志,读写线程循环里检查标志,确认退出后再关端口。

ADS接口本身是线程安全的,可以多线程同时调用,但我不建议你多个线程并发读同一个端口。因为底层Router的机制在并发请求下,响应和请求的对应依赖调用顺序,并发高时极端情况可能串包。如果你有很多变量要读,在一个通讯线程里顺序读,一定比多线程并发读更稳。

7. 踩坑实录:从4132到数据错位的完整排查链路

7.1 错误0x1024(4132)的几种真面目

回到开头的0x1024。这个十进制4132的错误码在ADS世界里出现频率极高,但含义在不同上下文里有细微差别。

ADS错误码是32位的,高位16位标识错误来源组件,低位16位是具体错误号。0x1024的低16位0x1024就是4132。在TwinCAT系统层面,0x1024常被解读为“实时任务看门狗超时”或“设备不可用”之类;在ADS通讯请求返回时,它更多指向“目标AMSNetId或Port不可达”,意思是Router根本不知道该把请求投递到哪里,或者目标设备不存在、程序没运行。

排查顺序请严格照这张表来:

排查步骤检查内容常见原因
1Router是否运行右键托盘TwincAT图标,看Router是否启动
2AMS NetId是否填对本机执行TwinCAT.exe /getnetid,比对程序里的AmsAddr
3Port是否正确PLC程序运行时用851,System Service用10000
4目标设备是否在Router路由表打开TwinCAT Router配置,查看静态路由表是否添加了目标设备
5防火墙是否拦截放行UDP/TCP 48898端口(TCP用于ADS通讯)

这五步走完,绝大多数4132都能解决。其中最容易忽略的是第4步——如果你连接的是远程倍福控制器(比如CX5140),本地电脑的Router必须添加一条指向远程设备IP的静态路由,否则就算NetId写对了,Router也不知道把包往哪送。添加路由不能用CMD的route命令,要在TwincAT Router配置界面里加,填远程设备的IP和AMS NetId。

7.2 远程连接失败:Router与防火墙

远程连接倍福控制器时,除了静态路由,还有几个细节。

控制器端的防火墙要放行TwincAT相关端口。CX系列默认情况WinCE/Windows系统的防火墙可能阻止连接,你要手动允许。另外,有些客户现场的交换机开了端口隔离策略,导致UDP广播和TCP请求不通,这种网络层面的问题最隐蔽,因为从电脑上ping设备IP是通的,但实际上TCP 48898端口是不通的。我遇到过现场把交换机VLAN配错了,上位机跟PLC的IP能互ping,但ADS就是连不上,最后用telnet检查48898端口才发现是端口不通。

如果你在TwinCAT 3开发环境里能用“Choose Target System”扫到并激活设备,但你的C++程序连不上,那问题多半出在你的程序里NetId或者Port配置上。反过来,如果你在开发环境里都扫不到设备,那优先排查网络,别折腾代码。

7.3 数据错位的三板斧排查

数据错位这类“软问题”比硬错误更难查,我的排查套路固定三板斧。

第一板斧是确认字节序。倍福PLC数据在内存中是小端模式,这和绝大多数x86/ARM小端处理器一致,所以一般不用转换。但如果你上位机跑的是某些网络设备或大端模式系统(比如某些嵌入式板子),就要做字节序转换,否则读出的REAL会变成天文数字。

第二板斧是打印原始十六进制。读回来的数据先别急着转成float、int,把它以十六进制字节数组打出来,和PLC侧在线监视的内存数据比对。如果字节相同但数值解释不对,就是类型定义错误;如果字节本身就不同,那就是通讯寻址或句柄的问题。这一步能快速区分是“读错了”还是“翻译错了”。

第三板斧是核对偏移量。PLC侧结构体里如果加了新变量,所有后续成员的偏移都会变。C++侧结构体忘改的话,前面的数值正常,后面的全部错乱。我的做法是给C++侧结构体写一个静态断言,用offsetof检查关键成员的偏移是否符合预期,编译期就能抓出一批问题。

这三板斧下来,基本没有排查不了的数据错位问题。

8. 最后再分享一个实用技巧:用TwinCAT Development环境辅助调试ADS

很多人写C++ ADS通讯时,习惯在TwinCAT里放几个变量来回试,然后在自己程序里打日志。其实TwinCAT Development环境自带的ADS监视工具很方便,只是大家不常用。

TwinCAT XAE里有一个“ADS Monitor”窗口(在System Manager里可以添加),能实时监视ADS路由器的连接、各个端口的通讯负载、错误计数。当你怀疑自己的上位机请求是不是有问题时,打开这个窗口操作一下,能看到请求是否到达、错误码是什么。这是个很好的辅助手段,比单靠C++侧打印错误码直观得多。

还有一个实践技巧是先用TwincAT自带的“TcXaeShell”里用AdsClient测试连接。TwinCAT XAE自带一个简单的ADS测试客户端,命令行里输入TC3ADS相关命令或者用系统管理器里的Device Editor直接操作,能快速确认NetId和Port是否OK。确认了通迅链路是通的,再回到你C++代码里找问题,能节省大量时间。

我个人做了这么久倍福上位机开发,最大的体会是:ADS通讯本身并不难,难的是各种配置和环境的细节。编程接口就那么几个函数,真正让你抓狂的永远是Router路由、NetId、端口、防火墙、数据对齐这些东西。所以我把这篇的重点放在这些不容易从例程里直接学会的地方,希望你在现场少走点弯路。如果你在跑通上述示例后还有别的报错,先按第7节的排查链路走一遍,80%的问题能自己解决。

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

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

立即咨询