简介:欧姆龙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命令帧,应用层结构如下表所示:
| 字节偏移 | 长度 | 字段名 | 说明 |
|---|---|---|---|
| 0 | 4 | Header | ASCII字符“FINS” |
| 4 | 1 | ENQ | 固定0x00 |
| 5 | 1 | ICF | 信息控制字段,0x80表示需要响应 |
| 6 | 1 | RSV | 保留,0 |
| 7 | 1 | GCT | 网关允许数,0 |
| 8 | 1 | DNA | 目标网络号,通常为0 |
| 9 | 1 | DA1 | 目标节点号(PLC的IP地址末位) |
| 10 | 1 | DA2 | 目标单元号,通常0 |
| 11 | 1 | SNA | 源网络号 |
| 12 | 1 | SA1 | 源节点号(本机IP末位) |
| 13 | 1 | SA2 | 源单元号 |
| 14 | 1 | SID | 服务标识,用于区分配对 |
| 15 | 2 | 指令码 | 高字节在前,如读是0x01,0x01 |
| 17 | ... | 参数 | 随指令不同而变 |
这里最容易犯的错是把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 Socket
LinkPLC的构造函数需要接收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[nOffset+1] = HIBYTE(wAddress); pOut[nOffset+2] = LOBYTE(wAddress); pOut[nOffset+3] = byBitAddr; pOut[nOffset+4] = HIBYTE(wLength); pOut[nOffset+5] = 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往返。
本文还有配套的精品资源,点击获取