简介:面向工业自动化开发人员的Beckhoff控制器与C++ ADS通讯例程包,基于Twincat与Visual Studio 2008环境,解决上位机与倍福PLC之间数据交换的工程实现问题,适用于运动控制、数据采集及设备联调等场景。压缩包共58个文件,约8.75MB,其中包含8个C++头文件和6个源文件(cpp),以及Visual Studio工程配置(sln/vcproj)、编译过程文件(obj/pdb/ilk)、资源文件(res/lib)和说明文档(docx/txt),文件类型覆盖从源码编写、工程编译到使用说明的完整链路。资源内置通知、定时、异步等典型ADS通讯方式,并配有专门的ADS通讯讲解文档,从基础库文件到调用示例均有涉及,适合新手入门及有经验的开发人员对照学习。目前已有309人学习,对理解Twincat ADS接口调用、排查通讯问题以及快速搭建上位机通讯demo具有实用参考价值。
1. 为什么上位机程序要直接打开 Beckhoff 的 ADS 端口
Beckhoff 的 TwinCAT 系统留给上位机的通信接口不止一个,但只有 ADS(Automation Device Specification)是贴着控制器内核走的。ADS 不是靠读几十个寄存器去猜业务,它直接访问 TwinCAT 里的符号表:PLC 程序里声明的变量,上位机只要给出名字就能定位和读写。对 C++ 开发者来说,接入 ADS 最直接的做法是调用 TwinCAT 自带的 TcAdsDll 原生动态库,不依赖 .NET 封装,也不需要额外装网关服务。
这套方案适合两类人:一类是产线软件工程师,要把温度、速度、报警状态从 Beckhoff 控制系统同步到自研上位机;另一类是刚接手工业项目的 C++ 开发者,需要快速建立“读变量、写变量、订阅变化”这三个基础能力。这篇文章从 ADS 寻址模型讲起,落到 TcAdsDll 的具体调用、批量读取和断线重连,最后给几个能直接写进代码的稳定化技巧。读完以后,你至少能独立搭出一个最小可用的 C++ 与 Beckhoff PLC 通信程序。
2. ADS 寻址模型:AMSNetId、端口号、IndexGroup/IndexOffset
2.1 AMSNetId 与 AmsAddr 结构:先把连接对象钉死
ADS 通信的目标不是 IP 地址,而是一个叫 AMS 路由的抽象层。每一台参与通信的 TwinCAT 设备在 ADS 世界里都有一个 AMSNetId,形式上像 IP 地址,实际只是 6 个字节,显示成192.168.1.10.1.1这种格式。前半段通常与网卡 IP 对齐,后半段由 TwinCAT 安装时生成,可以手工改,但必须保证整个路由域内唯一。调用方填错任何一段,ADS 请求都会在路由器层面被丢弃,表现成超时而不是立即报错。
TcAdsDll 里用两个结构体描述这个目标:
typedef struct { unsigned char m_b[6]; // 6 字节 AMSNetId,如 {192,168,1,10,1,1} } AmsNetId; typedef struct { AmsNetId m_netId; // 对端设备地址 unsigned long m_port; // 对端 ADS 服务端口 } AmsAddr;发送任何 ADS 请求之前都要填一个 AmsAddr。这里新手容易忽略:m_port 不是 TCP/UDP 端口号,而是 ADS 服务端口,它决定请求最终交给 PLC Runtime、NC 还是 System Service。AMS Router 拿到 AmsAddr 后,会先查本机路由表,把请求转发到对应进程。网线拔掉再插上后进程还在,但路由表的在线状态可能已经过期,第一次重连失败往往就是路由状态过期导致的。
2.2 常用 ADS 端口号与 TwinCAT 2/3 的差异
端口号不同,请求处理者就不同。项目里真正高频出现的端口是这几个:
| 端口 | 服务 | 使用场景 |
|---|---|---|
| 851 | PLC Runtime 1 | 访问 PLC 程序 MAIN 里的变量 |
| 852 | PLC Runtime 2 | 访问第二套独立 PLC 程序 |
| 10000 | TwinCAT System Service | 查询系统状态、重启 Runtime |
| 10001 | 传统 TwinCAT I/O | 老项目操作 IO 映像时用到 |
TwinCAT 2 和 TwinCAT 3 在端口上保持兼容,但变量名组织方式不同。TwinCAT 3 的 PLC 程序以MAIN为根,变量必须写成MAIN.temperature、MAIN.bRun这种带程序名前缀的形式;TwinCAT 2 里只有单程序时,变量名可以直接写temperature。做通用通信模块时,变量名前缀要设计成可配置项,不能写死。
这里还牵出一个选型问题:ADS 请求可以直接按 IndexGroup/IndexOffset 寻址,也可以按符号名称寻址。按 IndexGroup 要求先通过 TwinCAT 的符号文件查变量的内部地址,版本一变地址就变;按名称寻址则把解析交给路由器,C++ 侧只维护变量名字符串,可维护性好得多,所以下面的代码全部走名称寻址。
2.3 按变量名寻址的 handle 流程
按名称寻址不是一次请求就能完成,而是三次请求的标准流程。第一次,用 IndexGroup=0xF002(请求按名称解析符号)把变量名字符串发给路由器,路由器返回一个 4 字节句柄;第二次,用 IndexGroup=0xF003(按句柄访问值)配合句柄做实际读写;第三次,用 IndexGroup=0xF004(释放句柄)把句柄还回去。
第一次看到这三组数字的人会觉得像魔法数,其实它们是 ADS 协议里固定定义的 IndexGroup 常量。习惯了之后会发现这个流程的好处:只要把“名字到句柄”的映射表在 C++ 里缓起来,后续读写全部用句柄,性能比每次传名字高一截,也避免在热循环里反复做字符串解析。多线程场景下,句柄表只需在重连时整体重建,平时读操作不必加锁,这是按名称寻址方案的一个隐形成本优势。
3. 用 TcAdsDll 从零跑通 C++ 与 TwinCAT 的 ADS 读写
3.1 把 TcAdsDll 接进 Visual Studio 工程的最小配置
TwinCAT 安装目录下的AdsApi\TcAdsDll文件夹里放着原生 C 接口:头文件TcAdsDef.h、TcAdsAPI.h,编译期用TcAdsDll.lib,运行期需要TcAdsDll.dll。64 位系统上这套库同时提供 32 位和 64 位版本,选型原则就一条:上位机程序编译成多少位,就使用同一位宽的 DLL。用 32 位 DLL 时工程必须把平台设为 x86,否则静态链接阶段就会报库文件不兼容。
工程配置里需要改三个位置:C/C++ 的“常规 → 附加包含目录”加入头文件路径;链接器的“常规 → 附加库目录”加入 lib 路径;“输入 → 附加依赖项”写入TcAdsDll.lib。代码要分发到没装 TwinCAT 的机器时,把TcAdsDll.dll放到 exe 同目录即可,依赖很小,不需要注册 COM。
提示:如果工作站上装了两套 TwinCAT 版本,注意环境变量
TWINCAT3DIR或TWINCATDIR指向的实际安装路径,别让旧的 AdsApi 头文件混进来,否则链接阶段会出现重复定义或函数签名不匹配。
3.2 打开 ADS 端口并取本机地址
调用任何 ADS 函数前,先要用 AdsPortOpen 建立客户端上下文。它在本机 AMS 路由器上注册一个客户端端口并返回端口号,这个返回值会出现在后续每个函数的第一参数位置,可以理解为“这个进程里的 ADS 会话句柄”。
#include <windows.h> #include <stdio.h> #include "TcAdsDef.h" #include "TcAdsAPI.h" int main() { long nPort = AdsPortOpen(); if (nPort == 0) { printf("AdsPortOpen failed\n"); return -1; } AmsAddr addr; if (AdsGetLocalAddress(&addr) != 0) { printf("GetLocalAddress failed\n"); AdsPortClose(); return -1; } // 跨设备通信时,手动覆盖成目标 PLC 的 AMSNetId addr.m_netId.m_b[0] = 192; addr.m_netId.m_b[1] = 168; addr.m_netId.m_b[2] = 0; addr.m_netId.m_b[3] = 10; addr.m_netId.m_b[4] = 1; addr.m_netId.m_b[5] = 1; addr.m_port = 851; // 后续读写全部使用 addr }AdsGetLocalAddress 返回的是本机路由器的 AMSNetId,适合本机自测;跨机器部署时按上面手动覆盖对端地址即可。那六行m_b赋值等价于目标地址192.168.0.10.1.1,从开发机换到生产机时,这段配置建议从配置文件读取,不要留在代码里。本机自测时也要注意:如果目标机器上有多个网卡,实际通信走的网卡可能不是你预期的那块,这会导致 AMSNetId 与物理链路对不上。
3.3 读取与写入一个变量的完整 C++ 代码
有了端口和目标地址,读一个 INT 就是三步:申请句柄、按句柄读值、释放句柄。把这三步封装成函数,后续所有变量操作都复用同一个骨架。
long ReadPlcVar(long nPort, AmsAddr* pAddr, const char* sName, void* pData, unsigned long nLen) { long nErr; unsigned long nHandle = 0; // 1. 按名称申请句柄,返回值存放在 nHandle nErr = AdsSyncReadWriteReq(nPort, pAddr, 0xF002, 0, sizeof(nHandle), &nHandle, (unsigned long)strlen(sName) + 1, (void*)sName); if (nErr != 0) return nErr; // 2. 用句柄读取数据 nErr = AdsSyncReadReq(nPort, pAddr, 0xF003, nHandle, nLen, pData); // 3. 释放句柄 AdsSyncWriteReq(nPort, pAddr, 0xF004, 0, sizeof(nHandle), &nHandle); return nErr; } long WritePlcVar(long nPort, AmsAddr* pAddr, const char* sName, void* pData, unsigned long nLen) { long nErr; unsigned long nHandle = 0; nErr = AdsSyncReadWriteReq(nPort, pAddr, 0xF002, 0, sizeof(nHandle), &nHandle, (unsigned long)strlen(sName) + 1, (void*)sName); if (nErr != 0) return nErr; nErr = AdsSyncWriteReq(nPort, pAddr, 0xF003, nHandle, nLen, pData); AdsSyncWriteReq(nPort, pAddr, 0xF004, 0, sizeof(nHandle), &nHandle); return nErr; }AdsSyncReadWriteReq 前两个长度参数容易看反:第一个是回读缓冲区长度,第二个是要写入的数据长度。这个函数名容易让人误解成“同时读写”,实际在这里扮演的角色是“请求携带输入参数并回收输出数据”:写入的输入是变量名字符串,读回的输出是 4 字节句柄。两个长度参数比值必须不小于各自缓冲区的实际大小,否则路由器会直接返回参数错误。
调用封装后的函数读写MAIN.nCounter:
long nCounter = 0; long nErr = ReadPlcVar(nPort, &addr, "MAIN.nCounter", &nCounter, sizeof(nCounter)); if (nErr == 0) { nCounter++; nErr = WritePlcVar(nPort, &addr, "MAIN.nCounter", &nCounter, sizeof(nCounter)); }注意数据类型对齐:Windows 上的long是 32 位,对应 TwinCAT 的 DINT;TwinCAT 的 BOOL 占一个字节,C++ 侧用bool在普通变量里没问题,但放进结构体时容易受对齐规则影响,统一用BYTE更省心。对多字节类型,ADS 使用小端序,x86/x64 平台上的 C++ 默认也是小端序,常规场景不需要做字节序转换。
3.4 返回值检查与常见错误码
TcAdsDll 函数统一返回long错误码,0 表示成功。联调阶段最常撞见的是下面几类:
| 错误码 | 常见场景 | 排查方向 |
|---|---|---|
| 0x98100106 | 目标设备或端口找不到 | 核对 AMSNetId、路由表在线状态 |
| 0x98100108 | 无效的 IndexGroup | 句柄流程顺序出错,或目标服务不支持 |
| 0x98100109 | 无效的 IndexOffset | 句柄值被改写,或未正确取得句柄 |
| 0x98100110 | 句柄资源耗尽 | 循环中漏掉了 0xF004 释放调用 |
排查这类错误时,先写一个记录函数名、错误码、变量名的日志函数。断点只能看到当前调用,日志才能看出是第一步申请句柄失败,还是第二步读值失败,这两种情况的修复方向完全不同。申请句柄失败优先查变量名和路由,读值失败优先查句柄缓存和数据类型长度。
4. 工程化落地:批量读取、通知机制与断线重连
4.1 用连续内存一次性读取多个变量
按名称逐个读变量,请求次数等于变量个数,单次往返 1 到 2 毫秒时,一百个变量就可能吃掉一两百毫秒。更高效的做法是把关联变量组织成结构体,PLC 侧用相同布局声明,一次 ADS 请求把整块内存搬回来。
#pragma pack(push, 1) typedef struct { double dTemp1; // MAIN.dTemp1 double dTemp2; // MAIN.dTemp2 long lPressure; // MAIN.lPressure BYTE bValveOpen; // MAIN.bValveOpen } T_MACHINE_STATE; #pragma pack(pop)#pragma pack(push, 1)去除结构体对齐填充,让 C++ 布局与 PLC 的紧凑结构对齐。如果变量全是 DOUBLE 和 LINT,默认对齐规则双方天然吻合,不用加;一旦混入 BYTE、INT、REAL,建议在 PLC 侧也按 C++ 结构体顺序排列字段,并在头文件注释里标出偏移量。读回来的数据用memcpy拷贝到结构体,不要做指针强转,避开别名和时序问题。
批量读和单变量读性能差异大的根本原因在于 ADS 请求的往返开销。ADS 的读写请求需要路由器转发、等待 PLC 任务周期处理,无论传输数据是 4 字节还是 4 千字节,这部分固定成本都存在。用结构体把固定成本摊到多个变量上,是收益最明显的优化手段。
4.2 notification 通知:让 PLC 主动推送变化
轮询会消耗 CPU,也会让数据出现滞后。ADS 提供设备通知机制,由 TwinCAT 在变量变化或固定周期内主动调用 C++ 回调。使用前先填充一个AdsNotificationAttrib:
AdsNotificationAttrib att; memset(&att, 0, sizeof(att)); att.nLength = sizeof(T_MACHINE_STATE); att.nTransMode = ADSTRANS_SERVERONCHA; // 值变化时触发 att.nCycleTime = 10; // 检测周期 10ms att.nMaxDelay = 50; // 触发延迟上限 50msADSTRANS_SERVERONCHA和ADSTRANS_SERVERCYCLE的语义容易混淆:前者只在值变化时回调,适合报警监控;后者每个周期都回调,适合连续采集。nMaxDelay 设得越大,回调触发得越晚,同时也会合并短时间内的多次变化,这是故意控制回调频率的手段,不是故障。
注册通知的调用方式:
unsigned long nNotifyHnd = 0; long nErr = AdsSyncAddDeviceNotificationReq( nPort, &addr, 0xF003, nHandle, // 按 nHandle 订阅 &att, NotificationHandler, &nNotifyHnd);回调函数的签名固定为 6 个参数,必须用__stdcall调用约定。
void __stdcall NotificationHandler(unsigned long nPort, unsigned long nUser, unsigned long nIndexGroup, unsigned long nIndexOffset, unsigned long nLength, void* pData) { // 把 pData 拷贝到自有缓冲区,标记事件,然后立刻返回 }ADS 回调运行在 TcAdsDll 内部线程,不要在回调里直接写 PLC、弹 UI、加锁,正确做法是把数据拷进环形队列,让主线程取走。程序退出前记得调用AdsSyncDelDeviceNotificationReq(nPort, &addr, nNotifyHnd),否则 PLC 侧会继续往这个端口推送数据,造成路由器侧的无效通信。
4.3 断开重连与工作线程模型
PLC 重启、网线瞬断都会让连接失效。ADS 没有心跳包,连接状态只能靠周期请求判断。工程上一般单独开一个监控线程,每 500 毫秒做一次最小读取,失败后走重建流程。
while (g_bRunning) { BYTE bAlive = 0; long nErr = ReadPlcVar(nPort, &addr, "MAIN.bKeepAlive", &bAlive, sizeof(bAlive)); if (nErr != 0) { // 清理旧端口和句柄缓存 AdsPortClose(); nPort = AdsPortOpen(); if (nPort != 0) { // 重新申请变量句柄、重新注册通知 } Sleep(1000); continue; } Sleep(500); }重连的关键不只是AdsPortOpen,还有句柄缓存。此前申请到的变量句柄和通知句柄会随连接重建而失效,必须重新走一遍句柄获取和通知注册流程。
| 资源 | 失效时机 | 重建动作 |
|---|---|---|
| ADS 端口 nPort | AdsPortClose 之后 | 重新 AdsPortOpen |
| 变量句柄 | 连接重建之后 | 重新按名称申请 |
| 通知句柄 | 连接重建之后 | 重新 AddDeviceNotification |
多线程下 TcAdsDll 的同步请求是线程安全的,同一个 nPort 可以由多个线程同时调用,底层有锁。但通知回调进入时,主线程若正在执行写入,两边的数据访问仍需要业务层互斥保护。习惯做法是回调只负责把数据压入环形队列,主线程统一管理所有 PLC 写入请求,避免两头抢锁导致优先级反转。
5. 几个让 ADS 通信更扎实的实操细节
5.1 路由不通时先查的两个地方
连接失败的排查顺序比命令本身更重要。第一步检查 TwinCAT 路由表:右键System → Routes,看目标设备是否已在列表里,不在就先添加静态路由,填对端 AMSNetId 和 IP。第二步检查 Windows 防火墙:AM 路由器依赖 TCP 48898 端口,防火墙不放行时 AdsPortOpen 能成功,但跨机器请求全部超时。两步都通过后,如果数据还是读不到,把 AmsAddr 的 m_port 从 851 试到 852,目标机器跑了两套 PLC Runtime 时,这个问题会表现得很隐蔽。另外这类路由是双向的:本机能访问 PLC 不代表 PLC 能访问本机,调试前把两边路由都确认一遍。
5.2 变量名编码别踩坑
TcAdsDll 默认按 ANSI 字符串处理变量名,中文变量名称在双方工程之间极易出现乱码。遇到这种情况,PLC 侧一律用 ASCII 命名,显示名另存为字符串变量,由上位机做名称映射。另一个坑是大小写敏感:PLC 里声明bRun就写bRun,写成BRun时句柄申请会直接失败。批量联调前先写个脚本,把 TwinCAT 符号表导出的变量名逐个在 C++ 侧试读一遍,把拼写类问题一次扫掉。
5.3 用 QueryPerformanceCounter 校准读写周期
轮询周期拍脑袋设成 10ms、50ms,往往和实际能力差很远。单次 ADS 往返时间受网卡驱动、路由表规模、PLC 任务周期共同影响,最好的办法是直接测。把 ReadPlcVar 包一层计时:
#include <windows.h> double MeasureOnce(long nPort, AmsAddr* pAddr, const char* sName) { LARGE_INTEGER freq, t1, t2; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&t1); long nVal = 0; ReadPlcVar(nPort, pAddr, sName, &nVal, sizeof(nVal)); QueryPerformanceCounter(&t2); return (t2.QuadPart - t1.QuadPart) * 1000.0 / freq.QuadPart; }连续跑一百次,取平均和 P99。轮询周期至少设为平均值的 3 倍,否则读写请求会在路由器上排队,延迟被越推越高。如果 P99 比平均值高一倍以上,说明存在调度抖动,优先削减业务线程数量,而不是继续把周期调大。测出来的均值同样可以指导 notification 的 nCycleTime:检测周期不能快于 PLC 任务周期,设小了除了增加路由器开销,并不会让数据更快到达。
本文还有配套的精品资源,点击获取