
简介这是一套面向欧姆龙PLC以太网通讯开发的C/C实例源码解决网上资料零散、难以直接运行的问题。作者将通讯功能封装为类开发者只需实例化并调用相关函数即可快速实现PLC以太网通信适合新手及有一定经验的工控上位机开发人员。压缩包共32个文件约2.28MB包含5个h头文件、4个cpp源文件以及obj、exe、pdb、rc等编译与调试辅助文件。其中头文件与源文件为核心逻辑exe为可直接运行验证的演示程序pdb保留调试符号便于在线排错。资源包结构清晰便于对照学习。目前已有1154人浏览学习表明其实用性获得一定认可。这套源码不仅给出了完整工程还附带可执行示例与调试信息读者可从中掌握欧姆龙PLC以太网通讯的类封装思路、函数调用流程和VC工程配置方法有效降低上位机开发门槛。1. 欧姆龙PLC以太网C C通讯实例源码解决的是什么问题产线数据采集项目里上位机跟欧姆龙PLC做以太网通讯时最早的方案总是CX-Programmer自带的ActiveX控件或者再套一层Kepware。但到了Linux工控机、ARM边缘网关或者需要把DM区数据直接喂给C/C算法模块时这些控件既不跨平台也不可控实际能落地的就是一份欧姆龙PLC以太网C C通讯实例源码。这套源码的核心是在UDP报文里按FINS协议拼帧、解析响应、处理超时代码量不大、不依赖任何厂商SDK在线调试的时候抓包和日志能把每一帧交互都看清楚。下面从协议帧开始把读、写、封装和联调排错完整过一遍。适合做设备监控、工艺参数下发、运动控制联动的C/C工程师。2. FINS协议是欧姆龙PLC以太网通讯绕不开的基础欧姆龙PLC的以太网单元和CPU单元内置以太网口都支持FINS协议FINS的全称是Factory Interface Network Service是一个应用层协议承载在UDP或TCP之上。默认情况下FINS/UDP监听UDP 9600端口FINS/TCP连接也建立在TCP 9600端口。通讯实例源码里绝大多数选UDP原因很直接FINS/UDP帧短一次内存读写请求只有十几个字节没有TCP握手和确认开销在局域网环境里丢包率极低响应时间稳定在1ms以内。TCP模式适合跨三层网络或需要可靠长连接的场景但帧格式要多一层FINS/TCP头调试复杂度略高。2.1 网络号、节点号和单元号决定了帧送给谁FINS帧头部的10个字节里有3个参数最容易被忽略也最容易让在线调试卡住DNA/DA1/DA2目标网络号、目标节点号、目标单元号。SNA/SA1/SA2源网络号、源节点号、源单元号。直连PLC时网络号固定写0x00单元号对CPU单元写0x00。其中最坑的是节点号。PLC侧的FINS节点号在CX-Programmer的“PLC设置-内置以太网设置”里配置默认情况下和IP地址最后一段对应例如IP是192.168.250.1节点号就是1。PC侧作为FINS客户端也需要一个节点号这个节点号不能和PLC相同也不能和网络里其他欧姆龙设备重复。常见做法是把PC节点号设成10或者一个大于PLC节点号的数。如果两边节点号撞了PLC会直接丢弃请求或者返回错误码0x0101这时候表现就是程序一直超时而不是立刻给出错误。2.2 0101读命令和0102写命令的帧结构FINS命令码用两个字节表示读出内存区域是0x01 0x01写入内存区域是0x01 0x02。读写命令的参数区结构基本相同1个字节的内存区域代码2个字节的起始地址高位在前2个字节的数据个数或长度。以读DM区为例一帧请求的完整布局如下字节偏移内容值举例说明0ICF0x80命令帧需要应答1RSV0x00保留2GCT0x02网关数直连固定3DNA0x00目标网络号本地网络4DA10x01PLC的FINS节点号5DA20x00CPU单元号6SNA0x00源网络号7SA10x0APC的FINS节点号8SA20x00源单元号9SID0x01服务ID建议自增10-11命令码0x01 0x01读内存区域12区域代码0x84DM区字访问13-14起始地址0x00 0x64DM10015-16读取个数0x00 0x011个字响应帧的头部结构和请求一致但ICF变成0xC0DA1和SA1会互换。响应里命令码后紧跟2字节完成码0x0000表示正常非零则对应具体错误。数据部分从完成码之后开始也是高位字节在前。这里一定要记住FINS的数据是网络字节序收到后需要自己拼成uint16_t直接按内存拷贝在很多小端架构上会得到反过来的值。2.3 用Wireshark确认一帧完整交互在线调试的第一步是让通讯先“看见”。在PC上打开Wireshark抓包过滤器直接填udp.port 9600。一次读DM100成功的往返应该是这样请求帧的UDP负载80 00 02 00 01 00 00 0A 00 01 01 01 84 00 64 00 01响应帧的UDP负载C0 00 02 00 0A 00 00 01 00 01 01 01 00 00 12 34响应里第10、11字节依然是0101第12、13字节的0x0000是完成码最后两个字节是读到的数据0x1234。如果响应里的DA1不是你的PC节点号说明PLC侧配置的FINS表或者地址映射有问题如果SID和你发出去的不一致多半是中间有网关设备改写了帧。这个对照关系记熟之后后面写代码就能少走很多弯路。3. C语言实现欧姆龙PLC通讯实例源码从socket到读写闭合C语言的实现重点不在语法而在把每一帧拼对、把每个返回值查清。下面这套代码基于Linux环境UDP socket实现完整覆盖连接初始化、读DM区、写DM区和超时处理可以直接移植到Linux工控机和大部分嵌入式平台上。3.1 先搭一个带超时的UDP收发基础层#include stdio.h #include stdint.h #include string.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include errno.h #define FINS_PORT 9600 typedef struct { int sock; struct sockaddr_in addr; uint8_t src_node; // PC侧FINS节点号 uint8_t dst_node; // PLC侧FINS节点号 uint8_t sid; // 服务ID每次请求后自增 } fins_conn_t; int fins_open(fins_conn_t *c, const char *ip, uint8_t src_node, uint8_t dst_node) { c-sock socket(AF_INET, SOCK_DGRAM, 0); if (c-sock 0) return -1; memset(c-addr, 0, sizeof(c-addr)); c-addr.sin_family AF_INET; c-addr.sin_port htons(FINS_PORT); inet_pton(AF_INET, ip, c-addr.sin_addr); c-src_node src_node; c-dst_node dst_node; c-sid 0; return 0; } static int fins_exchange(fins_conn_t *c, uint8_t *req, int req_len, uint8_t *resp, int *resp_len) { struct timeval tv {.tv_sec 1, .tv_usec 0}; setsockopt(c-sock, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); if (sendto(c-sock, req, req_len, 0, (struct sockaddr *)c-addr, sizeof(c-addr)) 0) return -1; int n recvfrom(c-sock, resp, 512, 0, NULL, NULL); if (n 0) return -1; *resp_len n; return 0; }这段代码的核心在fins_exchange里有两个关键取舍。一是SO_RCVTIMEO设1秒这样PLC不响应时程序不会卡死二是把请求和响应合并在一个函数里后续所有命令都复用这一套收发逻辑。实际项目里如果本单位有多个PLC可以把addr里的IP和dst_node拆出来做成独立的连接参数避免改一个参数影响其他连接。src_node和dst_node一定要通过外部传入不要写死因为现场真正花时间排错的往往是节点号冲突。3.2 读DM区0101命令的最小函数int fins_read_words(fins_conn_t *c, uint8_t area, uint16_t start, uint16_t count, uint16_t *out) { uint8_t req[32]; uint8_t resp[512]; int resp_len 0; req[0] 0x80; req[1] 0x00; req[2] 0x02; req[3] 0x00; req[4] c-dst_node; req[5] 0x00; req[6] 0x00; req[7] c-src_node; req[8] 0x00; req[9] c-sid; req[10] 0x01; // 0101读命令 req[11] 0x01; req[12] area; // 0x84DM区 req[13] (start 8) 0xFF; // 起始地址高字节 req[14] start 0xFF; // 起始地址低字节 req[15] (count 8) 0xFF; // 读取个数高字节 req[16] count 0xFF; if (fins_exchange(c, req, 17, resp, resp_len) ! 0) return -1; if (resp_len 14) return -2; if (resp[10] ! 0x01 || resp[11] ! 0x01) return -3; uint16_t err (resp[12] 8) | resp[13]; if (err ! 0) return -(int)err; for (int i 0; i count; i) { out[i] (resp[14 i * 2] 8) | resp[15 i * 2]; } c-sid; return count; }读函数里最容易写错的不是拼帧而是响应解析的偏移。请求是17字节响应头和请求一样长10字节加上2字节命令码和2字节完成码数据区从偏移14开始。很多刚接触这份源码的人会习惯性从偏移12开始取数据那样拿到的其实是完成码和数据的首字节数值必然不对。另外resp_len 14的判断是防止收到截断帧UDP虽然不常截断但被中间防火墙策略干扰时会出现短包这个保护值得保留。3.3 写DM区0102命令和返回码校验int fins_write_words(fins_conn_t *c, uint8_t area, uint16_t start, const uint16_t *data, uint16_t count) { uint8_t req[512]; uint8_t resp[64]; int resp_len 0; req[0] 0x80; req[1] 0x00; req[2] 0x02; req[3] 0x00; req[4] c-dst_node; req[5] 0x00; req[6] 0x00; req[7] c-src_node; req[8] 0x00; req[9] c-sid; req[10] 0x01; // 0102写命令 req[11] 0x02; req[12] area; req[13] (start 8) 0xFF; req[14] start 0xFF; req[15] (count 8) 0xFF; req[16] count 0xFF; for (int i 0; i count; i) { req[17 i * 2] (data[i] 8) 0xFF; req[18 i * 2] data[i] 0xFF; } if (fins_exchange(c, req, 17 count * 2, resp, resp_len) ! 0) return -1; if (resp_len 14) return -2; if (resp[10] ! 0x01 || resp[11] ! 0x02) return -3; uint16_t err (resp[12] 8) | resp[13]; if (err ! 0) return -(int)err; c-sid; return count; }写命令返回时没有数据区所以只需要检查完成码不需要拼数据。这里要特别提醒一个使用习惯向DM区写数据之前确认PLC本体运行模式。大多数欧姆龙PLC在RUN模式下允许FINS写DM区但在某些型号的扩展安全设置里可以关闭远程写入这时完成码不是超时而是0x1101或者0x2002看到这类错误先不要怀疑代码拼帧去PLC侧查安全策略和内存保护更高效。3.4 放进主循环在线调试的真实形态int main(void) { fins_conn_t conn; uint16_t value; if (fins_open(conn, 192.168.250.1, 10, 1) ! 0) { perror(fins_open); return 1; } while (1) { int ret fins_read_words(conn, 0x84, 100, 1, value); if (ret 1) { printf(DM100 %d (0x%04X)\n, value, value); } else { printf(read failed, ret%d, errno%d\n, ret, errno); } usleep(500 * 1000); } }主循环里每500ms读一次DM100这个频率对在线调试刚刚好太快日志刷屏太慢联调时等得着急。注意fins_read_words返回的是读取字数负数是对应的FINS错误码绝对值调用侧可以据此区分“网络不通”和“PLC报错”两种情况。实际写业务代码时这里建议把返回值转成可读字符串排错体验会好很多。4. C封装OmronPlc类把在线调试做成显式能力C语言版本能跑通但到了项目里要管多个PLC、多种内存区域、多线程采集时函数式的写法会越来越别扭。C封装不必做得像OPC UA那么重把连接生命周期、数据交换和调试输出收敛到一个类里就够了。4.1 一个最小但完整的OmronPlc类#include string #include cstdint #include cstring #include cstdio #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h class OmronPlc { public: OmronPlc() : sock_(-1), srcNode_(10), dstNode_(1), sid_(1) {} ~OmronPlc() { if (sock_ 0) close(sock_); } bool connectPlc(const std::string ip, uint16_t port 9600) { sock_ socket(AF_INET, SOCK_DGRAM, 0); if (sock_ 0) return false; memset(addr_, 0, sizeof(addr_)); addr_.sin_family AF_INET; addr_.sin_port htons(port); inet_pton(AF_INET, ip.c_str(), addr_.sin_addr); return true; } int readWords(uint8_t area, uint16_t start, uint16_t count, uint16_t *out) { return exchange(0x0101, area, start, count, nullptr, out); } int writeWords(uint8_t area, uint16_t start, const uint16_t *data, uint16_t count) { return exchange(0x0102, area, start, count, data, nullptr); } private: int exchange(uint16_t cmd, uint8_t area, uint16_t start, uint16_t count, const uint16_t *tx, uint16_t *rx) { uint8_t req[520], resp[520]; int reqLen 17; req[0] 0x80; req[1] 0x00; req[2] 0x02; req[3] 0x00; req[4] dstNode_; req[5] 0x00; req[6] 0x00; req[7] srcNode_; req[8] 0x00; req[9] sid_; req[10] cmd 8; req[11] cmd 0xFF; req[12] area; req[13] start 8; req[14] start 0xFF; req[15] count 8; req[16] count 0xFF; if (cmd 0x0102 tx) { for (int i 0; i count; i) { req[reqLen] tx[i] 8; req[reqLen] tx[i] 0xFF; } } struct timeval tv {1, 0}; setsockopt(sock_, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); sendto(sock_, req, reqLen, 0, (struct sockaddr *)addr_, sizeof(addr_)); int n recvfrom(sock_, resp, sizeof(resp), 0, nullptr, nullptr); if (n 14) return -1; uint16_t err (resp[12] 8) | resp[13]; if (err ! 0) return -(int)err; if (rx cmd 0x0101) { for (int i 0; i count; i) rx[i] (resp[14 i * 2] 8) | resp[15 i * 2]; } return count; } int sock_; struct sockaddr_in addr_; uint8_t srcNode_, dstNode_, sid_; };这个类把公共的帧交换逻辑收拢在exchange里读和写都变成一行调用。有一点要注意sid_在成员变量里自增所以同一连接上的多次交换不会出现SID重复。在线调试时如果看到PLC返回0x0103的完成码多半是SID没有递增导致PLC分不清请求和响应。UDP本身不保证请求和响应的一一对应SID就是FINS协议里做关联的手段。4.2 hexdump开关把在线调试变成标准能力很多欧姆龙PLC通讯实例源码能跑通但不好调缺的就是一层帧级日志。在exchange函数里加一个调试开关就能在不打断业务逻辑的情况下看到每次交互的完整帧。void setHexdump(bool en) { hexdump_ en; } // exchange函数开头 if (hexdump_) { printf([TX] len%d:, reqLen); for (int i 0; i reqLen; i) printf( %02X, req[i]); printf(\n); } // recvfrom之后 if (hexdump_) { printf([RX] len%d:, n); for (int i 0; i n; i) printf( %02X, resp[i]); printf(\n); }开启hexdump后把程序输出和Wireshark抓到的帧逐字节对比能快速定位问题在拼帧侧还是响应解析侧。实际联调中很多看起来莫名其妙的数值错误都是因为源节点号配置不一致帧头第7字节和第4字节在hexdump里一眼就能看出来。这个开关在正式部署时保留也没问题用环境变量控制或者写进配置文件比每次重新编译要省事得多。我一般会在程序启动参数里加一个-d选项来开这个功能而不是改代码再编译。4.3 周期轮询加变化率打印让调试过程看得见在线调试的真正价值不是看一帧对不对而是持续观察变量变化。在类外面封装一个轮询函数对比两次读到的值并打印变化是验证通讯实例源码可靠性的最好方式。void pollAndDiff(OmronPlc plc, uint16_t dmAddr, uint16_t *lastVal) { uint16_t cur 0; int ret plc.readWords(0x84, dmAddr, 1, cur); if (ret 1 cur ! *lastVal) { printf(DM%u changed: %u - %u\n, dmAddr, *lastVal, cur); *lastVal cur; } }配合CX-Programmer的在线监控窗口在PLC侧手动改DM区的值程序这边立刻打印变化值两边数据一致就证明通讯链路完全贯通。这种方法比单纯看日志更接近“在线调试很OK”的状态因为验证的是双向数据通路而不是单向的读写函数本身。5. 联调翻车点排查和欧姆龙PLC仿真软件验证源码写完了能不能在真机上跑通取决于排查顺序。以下是我在项目里验证这套通讯实例源码时固定会做的检查按这条路径走绝大多数问题能在十分钟内定位。5.1 PLC不回包时先看这四层PLC完全无响应的现象是程序打印read failed, ret-1伴随errno等于EAGAIN。按照概率从高到低排查物理链路和IP地址先ping PLC IP确认以太网线、交换机端口和IP配置都正常。PLC支持ping但有些安全设置会关闭ICMP响应所以ping通不代表FINS一定通ping不通却不代表FINS不通。PLC的FINS节点号在CX-Programmer的PLC设置里查“内置以太网设置”里的FINS节点号。PC侧代码里的dst_node必须和它一致。PC侧源节点号冲突把源码里的src_node改成10或更大避开PLC和现场其他设备的节点号。本机防火墙和路由策略Linux下检查iptables -L和firewalld是否放行UDP 9600出站和入站缺一条策略就是超时的表现。5.2 PLC返回错误码时对照这张表完成码含义常见原因0x0101本地节点错误FINS节点号冲突0x0103目标节点无响应节点号错误或PLC忙0x1101区域分类错误区域代码拼错0x1102超过最大地址DM区地址超出PLC最大范围0x1103地址越界起始地址加读取长度超出区域末端0x2002数据长度错误写命令里的数据和命令中声明的个数不符0x2003命令错误命令码或请求格式不正确错误码在响应帧偏移12和13的位置代码里已经做了负数映射。看到负返回值后先转成十六进制再对照上表判断。0x1102和0x1103是地址问题这两类错误最常发生在PLC型号不同导致DM区上限不同的情况。比如CJ2M的DM区最大32767而CP1H按单元编号不同上限也不同跨型号移植源码时一定要查对应手册的“内存区域分配”。5.3 先用欧姆龙PLC仿真软件跑通通讯流程没有实物PLC时可以用CX-Simulator这类欧姆龙PLC仿真模拟软件在PC上虚拟一个PLC。仿真软件会占用一个虚拟FINS节点IP绑定到本机回环或网卡上。测试代码时把连接IP改成仿真软件提示的地址节点号改成仿真环境里配置的节点号其余帧逻辑完全不用变。用仿真软件跑通的流程再换到真机上通常只有IP和节点号两个参数需要调整。这个步骤对验证源码的帧拼装特别有效因为它把PLC侧的变量变化也纳入了可控范围调试效率比直接在产线上试电高很多。5.4 最终验收双端对改同一个DM地址最后推荐一个我常用的验收技巧比单纯循环读一块区域更能证明通讯实例源码的完整性。先在PLC侧用CX-Programmer在线监视DM100再在PC程序里写一段循环每秒把DM100的值加1然后反过来用PC程序只读DM100在CX-Programmer里手动改写DM100。两个方向都能在对方窗口里实时看到变化时说明帧格式、节点号、区域代码、数据解析全部正确这份源码就可以直接进业务代码库了。这个双向对改的方法在排查写命令返回值正常但PLC侧数据不变的场景时尤其管用因为写命令的完成码只保证请求被PLC接收不保证数据写入了你期望的地址。本文还有配套的精品资源点击获取