
简介欧姆龙PLC以太网C/C通讯实例源码是一套面向工业自动化上位机开发的程序源代码包重点解决VC环境下与欧姆龙PLC的以太网通讯难题。源码将握手连接、数据读写等逻辑封装为独立类调用方实例化后按接口传入参数即可使用极大降低通讯调试门槛适合新手从零熟悉PLC以太网协议也适合有经验者直接复用。包内共32个文件核心包括h头文件与cpp源文件另含obj、pdb、exe等编译中间件与可执行程序便于对照运行效果整体仅2.28MB便于快速下载与部署。资源包已有1154人学习下载配套代码经过实测校正在线调试表现稳定。借助这份完整工程读者既能直接编译运行观察通讯过程也能按需修改类方法扩展功能甚至从中提炼出通用排错思路是一份兼具教学与实用的欧姆龙PLC以太网通讯参考源码。1. 欧姆龙PLC以太网C/C通讯实例源码一个能直接改来用的VC6工程接手欧姆龙PLC上位机通讯时最常见的问题不是找不到资料而是资料要么是VB控件代码要么只有几个FINS指令片段没整理成可用的C/C工程。这个实例源码把以太网通讯封装在LinkPLC类里实例化后调用读写函数就能与PLC交换DM区、CIO区数据。工程是VC6下的MFC对话框程序在线调试过适合新手理解FINS协议也适合老工程师直接提取封装逻辑。从文件列表看LinkPLC.cpp、testDlg.cpp、test.dsp构成完整工程你能看到类在界面线程里怎么调用、错误怎么处理不用自己猜上下文。2. FINS over UDP欧姆龙以太网通讯协议的帧结构拆解要正确使用这个类必须先明白它背后走的协议。欧姆龙PLC的以太网通讯广泛采用FINS协议支持UDP和TCP两种载体。这个实例选择的是UDP方式目标端口固定为9600。UDP模式不需要建立连接组帧直接发送延迟更低丢包则通过重试机制补偿这也是很多上位机采用UDP的原因。2.1 FINS应用层报文的字节布局在C/C编程视角我们不需要关心以太网头、IP头、UDP头这些由操作系统协议栈处理。我们需要组装的是UDP负载部分也就是FINS命令帧。一个典型的FINS命令帧应用层结构如下表所示字节偏移长度字段名说明04HeaderASCII字符“FINS”41ENQ固定0x0051ICF信息控制字段0x80表示需要响应61RSV保留071GCT网关允许数081DNA目标网络号通常为091DA1目标节点号PLC的IP地址末位101DA2目标单元号通常0111SNA源网络号121SA1源节点号本机IP末位131SA2源单元号141SID服务标识用于区分配对152指令码高字节在前如读是0x01,0x0117...参数随指令不同而变这里最容易犯的错是把Header写成字符串FINS后没补ENQ字节或者直接用SendMessage发了16字节实际PLC要求首字节必须是FINS的ASCII码。用结构体描述时推荐这样定义#pragma pack(push, 1) typedef struct _FINS_REQUEST { BYTE byHeader[4]; // F, I, N, S BYTE byENQ; // 0x00 BYTE byICF; // 0x80 BYTE byRSV; // 0x00 BYTE byGCT; // 0x00 BYTE byDNA; // 目标网络号 BYTE byDA1; // 目标节点号 BYTE byDA2; // 目标单元号 BYTE bySNA; // 源网络号 BYTE bySA1; // 源节点号 BYTE bySA2; // 源单元号 BYTE bySID; // 服务标识 WORD wCmd; // 指令码 } FINS_REQUEST; #pragma pack(pop)#pragma pack(push, 1)的作用是取消内存对齐让结构体连续紧凑。如果不开这个选项编译器会在WORD wCmd前自动填充两个字节导致整个帧多出两个无用字节PLC解析时直接报错。关键字顺序的四字节头与ENQ组合起来是很多通讯抓包时判断报文是否为FINS的关键特征。2.2 指令码与常用地址区代码FINS协议里读内存区使用0x0101写内存区使用0x0102。指令参数中必须给出内存区代码和地址。不同内存区在FINS地址空间里的代码区别很大我常用的一组映射如下#define FINS_AREA_DM 0x02 // DM区 #define FINS_AREA_CIO 0x30 // CIO区 #define FINS_AREA_WR 0x31 // W区 #define FINS_AREA_HR 0x32 // H区地址本身分两部分字地址和位地址。字地址高字节在前位地址只有在按位操作时才用到。例如读DM100区代码是0x02字地址是100换算成十六进制0x0064位地址为0x00。如果PLC的点表里有CIO 100.05这种带位号的地址需要把字地址和位号单独拆开不能直接拼成0x6405传进去。2.3 响应帧和错误码的初判响应帧结构比请求帧简单前面12字节的包头完全一样第15字节开始的指令码也相同只是第17字节之后是响应码和数据。响应码占两个字节为0x0000时表示正常。实际调试时如果收到非零响应码常见的有0x0101本地节点错误、0x1101内存区代码错误、0x1103地址越界。对照欧姆龙FINS手册能快速定位但在代码层面我们通常先记下这个值直接转成十六进制在日志里输出。3. LinkPLC类把协议组帧与收发拆成可复用接口这个类的核心设计目标是让MFC对话框代码里不出现任何socket和FINS帧字节按钮事件只需要调用ReadDM、WriteCIO这类语义明确的方法。内部实现分为初始化、组帧、收发、解析四段下面逐段拆解。3.1 构造函数与Init绑定本机IP并创建UDP SocketLinkPLC的构造函数需要接收PLC的IP地址、本机IP地址和端口。常见做法是IP直接作为参数传入端口默认9600。随后在Init里创建UDP socket并调用bind绑定本机IP这样recvfrom才能收到从PLC回发的响应。LinkPLC::LinkPLC(const char* pLocalIP, const char* pPlcIP, WORD wPort) { strcpy(m_szLocalIP, pLocalIP); strcpy(m_szPlcIP, pPlcIP); m_wPort wPort; m_sock INVALID_SOCKET; } BOOL LinkPLC::Init() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); m_sock socket(AF_INET, SOCK_DGRAM, 0); if (m_sock INVALID_SOCKET) return FALSE; sockaddr_in localAddr; localAddr.sin_family AF_INET; localAddr.sin_port htons(0); localAddr.sin_addr.s_addr inet_addr(m_szLocalIP); if (bind(m_sock, (sockaddr*)localAddr, sizeof(localAddr)) SOCKET_ERROR) return FALSE; // 设置接收超时, 避免阻塞在recvfrom int nTimeout 2000; setsockopt(m_sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)nTimeout, sizeof(nTimeout)); return TRUE; }注意这里bind的端口填的是0表示由系统自动分配一个临时端口。PLC发送响应时会发到这个临时端口所以不需要固定本机端口。SO_RCVTIMEO设成2000毫秒是为了避免PLC掉线时recvfrom永久阻塞这在后面的重试逻辑里是关键依赖。3.2 组帧函数把内存区、地址、长度填进FINS请求为了保证Read和Write两个方法不重复拼字节我一般把组帧抽成一个内部函数BuildRequest。它接收指令码、内存区代码、字地址、位地址和长度按2.1节的结构体填充并统一计算节点号。int LinkPLC::BuildRequest(BYTE byCmdType, BYTE byArea, WORD wAddress, BYTE byBitAddr, WORD wLength, BYTE* pOut) { FINS_REQUEST req; memset(req, 0, sizeof(req)); memcpy(req.byHeader, FINS, 4); req.byENQ 0x00; req.byICF 0x80; req.byDNA 0x00; req.byDA1 m_bPlcNode; // 由IP末位换算 req.byDA2 0x00; req.bySNA 0x00; req.bySA1 m_bLocalNode; // 本机IP末位 req.bySA2 0x00; req.bySID GetTickCount() 0xFF; req.wCmd (byCmdType 0) ? htons(0x0101) : htons(0x0102); // 先拷贝FINS头部指令码 memcpy(pOut, req, sizeof(req)); // 再填充命令参数area(1字节), 字地址(2字节高字节在前), 位地址(1字节), 长度(2字节) int nOffset sizeof(req); pOut[nOffset] byArea; pOut[nOffset1] HIBYTE(wAddress); pOut[nOffset2] LOBYTE(wAddress); pOut[nOffset3] byBitAddr; pOut[nOffset4] HIBYTE(wLength); pOut[nOffset5] LOBYTE(wLength); return nOffset 6; }这个函数返回总字节数。需要注意htons用于指令码转换因为FINS协议要求大端字节序而x86机器默认是小端。地址和长度参数同样要手动调成高字节在前不能直接强制类型转换否则在跨平台或不同编译器下行为不一致。3.3 Read/Write接口一次请求重试三次网络通讯不可避免有丢包我习惯在Read和Write外层做三次重试。每次重试前用Sleep(100)等待100毫秒降低收到上一次迟到响应的概率。在UDP通讯中如果上一次响应延迟下一次请求可能收到旧响应导致数据错位。BOOL LinkPLC::ReadDM(WORD wAddr, WORD wLen, WORD* pData) { BYTE req[64], resp[512]; int reqLen BuildRequest(0x00, FINS_AREA_DM, wAddr, 0, wLen, req); sockaddr_in dstAddr; dstAddr.sin_family AF_INET; dstAddr.sin_port htons(m_wPort); dstAddr.sin_addr.s_addr inet_addr(m_szPlcIP); for (int i 0; i 3; i) { sendto(m_sock, (const char*)req, reqLen, 0, (sockaddr*)dstAddr, sizeof(dstAddr)); int nRespLen recvfrom(m_sock, (char*)resp, sizeof(resp), 0, NULL, NULL); if (nRespLen 0 ParseResponse(resp, nRespLen, wLen, pData)) return TRUE; Sleep(100); // 重试前等待降低收到迟到响应的概率 } return FALSE; }ParseResponse中会先检查响应码是否为0x0000再从固定偏移处取数据。由于PLC返回的数据是高位字节在前取出来之后需要做一个WORD大小端转换否则在VC6里读到的16位数值位序是反的。3.4 清理资源时的注意事项Close方法中先closesocket再WSACleanup别把调用顺序搞反。另外LinkPLC类中如果保存了socket句柄析构函数里要判断是否为INVALID_SOCKET并自动关闭防止多次析构导致异常。4. 在MFC对话框中集成LinkPLC并完成在线调试拿到源码后最直接的路径是打开test.dsw编译成功后把对话框里的编辑框和按钮事件对应到类的方法上。这一节按照实际操作的顺序说清楚在哪几个环节容易出问题。4.1 在对话框初始化中创建对象并调用Init在testDlg.cpp的OnInitDialog中通常是取本机IP地址和PLC地址写入两个编辑框然后创建LinkPLC对象。对象可以用成员变量保存避免每次按钮事件临时创建。BOOL CTestDlg::OnInitDialog() { CDialog::OnInitDialog(); m_pPlc new LinkPLC(192.168.1.10, 192.168.1.200, 9600); if (!m_pPlc-Init()) { AfxMessageBox(初始化通讯失败); return FALSE; } return TRUE; }这里的IP地址要按实际环境修改。PLC侧需要在CX-Programmer里配置节点号为IP末位比如192.168.1.200对应的节点号就是200这个值会作为FINS帧中的DA1字段。如果PLC配置的节点号和实际IP末位不一致帧发出后会被PLC直接丢弃。4.2 读按钮和写按钮的具体调用方式读DM区的按钮事件可以做得非常简洁因为复杂逻辑都封装在类里void CTestDlg::OnBtnRead() { CString strAddr, strLen; GetDlgItemText(IDC_EDIT_ADDR, strAddr); GetDlgItemText(IDC_EDIT_LEN, strLen); WORD wAddr (WORD)atoi(strAddr); WORD wLen (WORD)atoi(strLen); WORD* pData new WORD[wLen]; if (m_pPlc-ReadDM(wAddr, wLen, pData)) { CString strResult; strResult.Format(读取成功首个数据0x%04X, pData[0]); SetDlgItemText(IDC_EDIT_RESULT, strResult); } else { SetDlgItemText(IDC_EDIT_RESULT, 通讯超时或响应错误); } delete[] pData; }这里把地址和长度都限制在WORD范围内实际项目中要对用户输入做范围校验比如DM区最大地址不能超过PLC型号上限。超时错误直接提示“通讯超时”有利于现场维护人员判断是网络问题还是PLC配置问题。4.3 在线调试的关键步骤抓包确认FINS帧使用真实PLC调试前建议先用Wireshark抓包确认发出的UDP报文长度和内容。下面是一套我常用的调试步骤启动Wireshark在捕获过滤器中输入udp.port 9600。回到程序界面点击“读”按钮发起一次请求。切回Wireshark查看最新一条源地址为本机IP、目的端口为9600的UDP报文。检查帧负载的前7个字节是否为46 49 4E 53 00 80 00这是FINS头和ICF字段的标准组合。如果帧头正确但PLC没有响应检查目标IP地址和PLC节点号是否匹配。没有PLC的情况下可以用欧姆龙仿真软件代替。推荐在调试阶段先用软件模拟PLC这样能随时修改节点号和数据区整个测试过程中不需要真实设备的限制。但需要注意仿真软件对UDP 9600的支持需要相关参数开启否则recvfrom一直收不到响应超时后返回FALSE。5. 响应码、超时和地址边界稳定通讯必须处理的三个细节通讯调通后距离真正可用还差最后一步——处理各种非正常情况。最常见的是响应码非零、接收超时和地址越界每类问题都有对应的检查点。5.1 非零响应码的快速定位调试时如果返回的响应码不是0x0000先将响应码连同读写地址一同打印。下面是我常查的几张表的摘录响应码含义排查方向0x0101本地节点错误检查FINS头部字节本机节点号是否与IP末位一致0x1101内存区代码错误检查AREA_XX宏是否定义正确0x1103地址越界或长度超限确认起始地址长度不超过PLC区的最大范围0x2002数据被破坏检查上位机发送长度与FINS帧实际长度是否一致正常情况下响应帧的第15字节起到第17字节是响应码。把响应码解析出来后再对照PLC手册比手动猜要快得多。5.2 收包超时的原因分析和重试参数权衡超时意味着recvfrom在设定时间内没有返回。可能的原因有四点PLC节点号配置不对、UDP端口不对、PLC里的FINS服务被防火墙阻断、上位机网卡没绑定到正确的IP。建议重试次数设置为3到5次间隔100至200毫秒。如果间隔太长整个读写周期会明显拖慢太短则容易收到上一次的迟到响应造成数据错乱。5.3 地址区边界校验读写DM区时请求的起始地址加长度不能超过PLC的DM区容量。例如CP1E的DM区最大地址是32767如果请求地址30000、长度5000就会产生越界。在LinkPLC的接口中增加一个简单的范围检查比等PLC返回错误码更省时间。因为返回错误码时你的重试机制可能会误以为网络问题徒增三次无谓的重试。收到0x1103后直接提示“地址长度越界”而不是继续重试。这个判断放在ParseResponse里比放在外层逻辑里更高效因为你不需要为了一个逻辑错误浪费3次UDP往返。本文还有配套的精品资源点击获取