ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++上位机调用TcAdsDll读写Beckhoff PLC变量指南

C++上位机调用TcAdsDll读写Beckhoff PLC变量指南 简介面向工业自动化开发人员的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 系统留给上位机的通信接口不止一个但只有 ADSAutomation Device Specification是贴着控制器内核走的。ADS 不是靠读几十个寄存器去猜业务它直接访问 TwinCAT 里的符号表PLC 程序里声明的变量上位机只要给出名字就能定位和读写。对 C 开发者来说接入 ADS 最直接的做法是调用 TwinCAT 自带的 TcAdsDll 原生动态库不依赖 .NET 封装也不需要额外装网关服务。这套方案适合两类人一类是产线软件工程师要把温度、速度、报警状态从 Beckhoff 控制系统同步到自研上位机另一类是刚接手工业项目的 C 开发者需要快速建立“读变量、写变量、订阅变化”这三个基础能力。这篇文章从 ADS 寻址模型讲起落到 TcAdsDll 的具体调用、批量读取和断线重连最后给几个能直接写进代码的稳定化技巧。读完以后你至少能独立搭出一个最小可用的 C 与 Beckhoff PLC 通信程序。2. ADS 寻址模型AMSNetId、端口号、IndexGroup/IndexOffset2.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 的差异端口号不同请求处理者就不同。项目里真正高频出现的端口是这几个端口服务使用场景851PLC Runtime 1访问 PLC 程序 MAIN 里的变量852PLC Runtime 2访问第二套独立 PLC 程序10000TwinCAT System Service查询系统状态、重启 Runtime10001传统 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 流程按名称寻址不是一次请求就能完成而是三次请求的标准流程。第一次用 IndexGroup0xF002请求按名称解析符号把变量名字符串发给路由器路由器返回一个 4 字节句柄第二次用 IndexGroup0xF003按句柄访问值配合句柄做实际读写第三次用 IndexGroup0xF004释放句柄把句柄还回去。第一次看到这三组数字的人会觉得像魔法数其实它们是 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.nCounterlong 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 的 DINTTwinCAT 的 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 回调。使用前先填充一个AdsNotificationAttribAdsNotificationAttrib 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 端口 nPortAdsPortClose 之后重新 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 任务周期设小了除了增加路由器开销并不会让数据更快到达。本文还有配套的精品资源点击获取
返回列表