简介这份代码包面向使用Qt开发欧姆龙PLC上位机应用的工程师提供基于FINS协议通信的完整工程示例。包内以C源文件、头文件、界面布局文件及工程配置文件为主共18个文件压缩后823KB涵盖协议封装、PLC读写操作、界面设计等核心模块便于直接参考或二次开发。已有88人学习下载适合工业自动化领域开发者快速上手。通过自行封装FINS协议读者能深入理解报文构造、命令响应及异常处理等通信细节同时借助Qt跨平台特性减少多系统适配成本。工程中除核心源码外还包含UI界面工程与构建配置文件可辅助你快速搭建基于FINS的上位机通信框架并在此基础上扩展实际项目功能。 搞工业上位机的朋友应该都有过这种感觉现场丢过来一台欧姆龙 PLC领导的态度永远是“先通个信画面后面再说”。如果不了解协议细节很容易被各种收费组件牵着走。其实欧姆龙全线 PLC 都支持 FINS 协议而 Qt 自带 QUdpSocket自己实现一个不依赖第三方插件的 FINS 上位机比想象中要简单很多。这篇文章就从一个真实项目出发讲清楚用 Qt 开发欧姆龙 FINS 上位机的完整思路。你会看到 FINS 报文的每个字节是什么意思、内存区地址怎么换算、读写命令怎么组包以及在现场最容易踩的坑。适合刚接手上位机开发、有 C 基础、但没写过 PLC 通信的人如果你已经有 C# 上位机经验想把方案迁移到 Qt 上同样有参考价值。1. 项目整体思路与方案选型1.1 上位机和 PLC 的组合为什么这么常见现代产线上PLC 负责逻辑控制但人不能天天抱着编程软件去看数据。上位机就是为了把设备状态、产量、报警等信息汇总到一台工控机上方便操作员监控和操作。这个项目里目标就是一台欧姆龙 CP 系列或者类似型号的 PLC通过以太网连接我们每隔几百毫秒读取几个关键数据区的值并在界面上更新显示。所谓“FINS”其实是欧姆龙自己的工厂接口网络服务。它跟 Modbus 这类通用协议不太一样是欧姆龙设备之间、以及上位机与欧姆龙设备之间通信的“母语”。只要你的欧姆龙 PLC 带以太网口不管是 CP1H、CP2E还是 NJ/NX 系列基本都能用 FINS 做通信区别只是不同型号对内存区名称和节点号配置的细节略有差异。这个项目的核心需求可以拆成三块与 PLC 建立 UDP 通信通道发送 FINS 请求并可靠地接收响应正确解析 PLC 内存区数据把设备状态在上位机界面呈现出来需要时向下写入控制字或参数完成远程操作。搞清楚这三件事代码怎么写、界面怎么排心里就有了谱。很多人一上来就想去写界面结果通信层没搞通界面写得再漂亮也白搭。我的习惯是先做通信验证再做界面这个顺序在后面实操部分还会细说。1.2 为什么选 Qt 而不是 C# 或 MFC我见过不少工控项目用 C# 写上位机WinForms 或者 WPF 上手确实快。但我在这个项目里仍然选 Qt有几个很实际的理由跨平台。很多设备厂商的维护站会用 Linux 系统或者需要把上位机部署到嵌入式 ARM 板上Qt 一套代码可以编译到多个平台不用为每个平台单独重写一套界面。信号槽机制非常适合通信模块。UDP 数据到达以后只需要发一个信号界面槽函数就会自动更新不用像 MFC 那样手动管理线程消息。后续扩展方便。曲线图、报表、数据库、网页端联动Qt 都有现成模块后续想加功能不用推倒重来。不依赖厂商收费控件。FINS 协议本身就不复杂自己写 200 行代码搞定通信没必要花钱买专门的通信组件。当然如果项目里已经全是 .NET 资源或者团队更熟悉 C#那用 C# 也完全没问题。工具没有绝对优劣关键看你手里有什么牌。Qt 这条路的好处是协议实现完全透明出了问题能直接抓报文看不会被中间件挡住。1.3 FINS 用 UDP 还是 TCPFINS 协议同时支持 UDP 和 TCP 两种承载方式。默认情况下FINS/UDP 使用 9600 端口FINS/TCP 一般也用 9600 端口。就这个项目而言我建议用 UDP实现简单。UDP 通信不需要建立连接QUdpSocket 直接 writeDatagram 就能发收到回包后自行解析。足够可靠。现场以太网质量通常不错PLC 响应也很快UDP 丢包率很低。如果真丢了我们做超时重试即可。TCP 反而需要处理更多状态。连接断开、重连、帧打包长度等虽然也是常规操作但对一个数据监控型上位机来说没必要引入这些复杂度。注意如果你要做的上位机有大量连续写入操作或者对通信可靠性极其敏感建议评估一下 FINS/TCP 和重连机制。但常规数据监控场景UDP 配重试已经足够。2. FINS 协议拆解搞懂报文才能写出能跑的代码2.1 FINS 帧头结构一个字节都不能错FINS 报文由 10 字节的固定帧头加上命令区和数据区组成。这 10 个字节决定了“谁发给谁”以及“怎么回”。以以太网 FINS/UDP 为例帧头格式如下字节名称取值说明0ICF信息控制字段普通二进制通信请求一般设为 0x801RSV系统保留固定填 0x002GCT网关计数本地通信填 0x023DNA目标网络号本地网络填 0x004DA1目标节点号即 PLC 的 FINS 节点号5DA2目标单元号CPU 单元填 0x006SNA源网络号本地网络填 0x007SA1源节点号即 PC 的 FINS 节点号8SA2源单元号PC 端填 0x009SID服务标识用于区分请求和响应一般是自增序号这里的“节点号”是 FINS 层面每个设备的编号不是 IP 地址。PLC 侧需要在其网络配置里设置节点号PC 侧我们也要选一个不与 PLC 冲突的节点号。比如 PLC 设节点号 1PC 就设 2。很多第一次写 FINS 的人只记得填 IP忘了节点号结果一直收不到回包。2.2 内存区代码与地址换算最容易踩坑的地方FINS 读数据核心是告诉 PLC“我要读哪个区、从哪个地址开始、读多少个”。内存区代码就是用来指定“哪个区”的。常用的有内存区字访问代码常见 PLC 型号CIO 区含 CIO0~CIO61430x30CP、CJ、NJ 等WR 区W0~W5110x31CP 系列HR 区H0~H5110x32CP、CJ 等DM 区D0~D327670x82全系列地址字段是 2 字节但不是简单把十进制转成十六进制。FINS 协议里地址是用 BCD 码表示的。举个例子你想读 DM100那么地址字段要填的是 0x0100而不是十进制 100 对应的 0x0064。同理读 DM123要填 0x0123。这个坑很多新手都会掉进去。一开始图省事直接用 quint16 地址去组包读 DM100 以下的数据可能没问题因为有些 PLC 对地址的解析方式比较宽容一旦地址上到两位数甚至三位数比如 DM168你要是填成十六进制 0x00A8PLC 按 BCD 理解就成了 168 的“错误表达式”轻则返回地址越界重则读到完全错误的数据。提示如果不确定 PLC 型号是否按 BCD 解释地址最稳妥的办法是拿官方编程软件监控一块地址然后用网络调试助手手动发一帧 FINS 命令验证。这一步能帮你省下后面半天排查时间。2.3 读、写、运行控制三条最常用的命令读取内存区命令码 0101读取请求帧的组成为10 字节帧头 2 字节命令码 0x01 0x01 1 字节内存区代码 2 字节起始地址BCD 2 字节数据数量BCD。比如读取 DM100 起的 10 个字命令区数据就是82 01 00 00 10。注意数量字段也是两个字节并且按 BCD 表示10 要写成 00 10不要写成 00 0A。写入内存区命令码 0102写入请求帧为10 字节帧头 2 字节命令码 0x01 0x02 1 字节内存区代码 2 字节起始地址BCD 2 字节数据数量BCD 每个字 2 字节的数据。比如往 DM100 写入一个值为 50 的字数据部分就是 82 01 00 00 10 00 32。超过一个字的批量写入数据区就依次排列。运行控制命令码 0401 等除了数据读写有些场景需要远程切换 PLC 的运行模式比如远程停机。这类命令也可以封装但在产线上使用要格外小心。我的建议是除非有明确的安全流程否则先只做读操作把写操作和控制操作做成受保护的功能界面上一律二次确认。上位机的一个误操作可能直接让设备停下来这个责任谁都担不起。3. Qt 工程实操从建工程到跑通第一帧3.1 工程结构与通信模块划分一个清晰的项目结构能让你少走很多回头路。我习惯这样组织 Qt 工程FinsUpper/ ├── FinsUpper.pro ├── main.cpp ├── mainwindow.h / mainwindow.cpp ├── finsclient.h / finsclient.cpp └── datamodel.h / datamodel.cppFinsClient 负责所有 FINS 报文的组包、发送、收包和解析对外只暴露几个接口设置 PLC 的 IP、PLC 节点号和本地节点号然后提供 readWords、writeWords 之类的成员函数。MainWindow 只负责界面不直接碰网络字节流。这样换 PLC 型号或者改界面时改动的范围能控制得很小。.pro 文件也不需要额外加什么模块Qt Network 模块在 qmake 里加上 network 就行QT core gui network如果之后要用 QChart 画曲线再把 charts 模块加上。不过通信基础一定要先跑通再谈别的高级功能。3.2 用 QUdpSocket 封装一个 FINS 客户端QUdpSocket 是 Qt 网络模块里现成的类不需要额外安装库。使用时注意几个细节客户端不需要调用 connectToHost只需要 writeDatagram 指定目标 IP 和端口。接收数据会触发 readyRead 信号你也随时可以用 pendingDatagramSize 判断有没有数据。多个 PLC 或多个命令并发时建议在 FINS 帧里用 SID 字段区分是哪一条请求的响应。通常我在构造函数里就创建一个 QUdpSocket并把它绑定到一个本地空闲端口然后连接 readyRead 信号到解析槽函数。FinsClient::FinsClient(QObject *parent) : QObject(parent) { m_udp new QUdpSocket(this); m_udp-bind(QHostAddress::Any, 0); connect(m_udp, QUdpSocket::readyRead, this, FinsClient::onDataReceived); }3.3 核心代码实现组帧、收发、解析先写一个小函数把十进制地址转成 FINS 需要的 2 字节 BCD 格式static quint16 toBcd(int value) { quint16 result 0; int shift 0; while (value 0) { result | (quint16)((value % 10) shift); value / 10; shift 4; } return result; }然后是组读命令。下面这段代码构造一个“读内存区”的 UDP 数据报void FinsClient::readWords(int memCode, int startAddr, int count) { QByteArray frame; // 10 字节 FINS 帧头 frame.append((char)0x80); // ICF frame.append((char)0x00); // RSV frame.append((char)0x02); // GCT frame.append((char)0x00); // DNA frame.append((char)m_plcNode); // DA1 frame.append((char)0x00); // DA2 frame.append((char)0x00); // SNA frame.append((char)m_pcNode); // SA1 frame.append((char)0x00); // SA2 frame.append((char)(m_sid)); // SID // 命令码内存区读 0x0101 frame.append((char)0x01); frame.append((char)0x01); // 数据区 frame.append((char)memCode); quint16 addr toBcd(startAddr); frame.append((char)(addr 8)); frame.append((char)(addr 0xFF)); quint16 num toBcd(count); frame.append((char)(num 8)); frame.append((char)(num 0xFF)); m_udp-writeDatagram(frame, QHostAddress(m_plcIp), 9600); }响应解析是更关键的一块。UDP 收到数据后先判断这帧是不是回给本次请求的然后检查 FINS 响应头里的完成码。void FinsClient::onDataReceived() { while (m_udp-hasPendingDatagrams()) { QByteArray datagram; datagram.resize(m_udp-pendingDatagramSize()); m_udp-readDatagram(datagram.data(), datagram.size()); if (datagram.size() 14) continue; // FINS 帧头后 4 字节命令码(2) 完成码(2) quint16 mainCode (quint8)datagram.at(10) 8 | (quint8)datagram.at(11); quint16 endCode (quint8)datagram.at(12) 8 | (quint8)datagram.at(13); if (mainCode 0x0101 endCode 0x0000) { // 读取成功数据从索引 14 开始 // 每个字 2 字节按大端序解释 QVectorquint16 values; for (int i 14; i 1 datagram.size(); i 2) { quint16 v (quint8)datagram.at(i) 8 | (quint8)datagram.at(i 1); values.append(v); } emit dataReadReady(m_currentRequestId, values); } else { emit protocolError(m_currentRequestId, endCode); } } }这里有个很容易忽略的问题readyRead 可能一次性收到多包或者一包被拆成多次到达。所以我在 onDataReceived 里用 while 循环把缓冲区的数据全部读干净不要只读一次。对于本项目这种定时轮询模式一包通常就是一帧但代码里不能赌这个。3.4 界面刷新与多线程注意点很多人一上来就想开线程其实普通 PLC 点表轮询场景一个 QTimer 就够了。定时器每 500ms 调用一次 readWords收到响应后通过信号槽直接更新界面。因为 QUdpSocket 的 readyRead 信号是在主线程事件循环里触发的只要不在槽函数里做耗时操作界面不至于卡。如果遇到网络很差或者 PLC 响应很慢你可以把 FinsClient 放到一个 QThread 里或者在工程里改用 QFuture 加 async但无论如何都要记住界面 UI 更新必须回到主线程。用 Qt 的信号槽跨线程通信天然就会在接收对象所在线程执行用来更新控件正好合适。不要做的是在 UI 线程里调用一个阻塞的 waitForReadyRead 或者 QThread::msleep。那会让你的窗口变成“未响应”现场调试时非常难看。我亲眼见过有人为了图省事在按钮点击槽里直接 sleep 等响应结果整个界面卡死操作员疯狂点鼠标最后还是重启程序收场。4. 现场排错实录我在项目里遇到过的坑4.1 连不上 PLC先查这三件事我接到过不少类似问题最终排查下来大多数不是 Qt 代码的问题而是基础配置没对齐。第一IP 能不能 ping 通。首先排除网线、网卡、防火墙。工业现场经常有电脑开着防火墙导致 UDP 被丢弃给 PLC 通信程序所在网络加一条入站规则或者直接临时关闭防火墙验证。第二PLC 节点号和 PC 节点号是否冲突。FINS 报文里每个节点号必须唯一。如果 PLC 节点是 1PC 还是 1PLC 收到包后会认为这是发给自己的回包目标也会找节点 1结果就是你什么也收不到。第三端口和网段是否一致。PLC 的 IP 要和电脑在同一网段默认 UDP 端口 9600 不要改错如果有路由或网关隔离现场网络管理员得先确认端口放行。我用实际数据写个典型配置PLC IP 是 192.168.1.10节点号 1PC IP 是 192.168.1.100节点号 2。这样帧头里 DA11SA12目标端口 9600。4.2 报文返回错误码先看完成码FINS 响应里后 4 字节是“命令码 完成码”。完成码是 2 字节常见的错误码含义如下完成码含义0x0000正常完成0x1101命令格式错误帧长或数据格式不对0x1102目标存储区不存在或地址越界0x1103响应超时或无法访问目标节点0x2002目标节点处于不可通信状态遇到 0x1101先检查命令码、内存区代码、BCD 地址和数组长度是否都合法。遇到 0x1102多半是地址超出 PLC 实际范围比如写入一个不存在的 DM 区或者 CIO 区编号超出了 PLC 的硬件范围。调试时我会在协议解析里把完成码打印成十六进制配合官方手册对照。别看着一个错误码就瞎猜欧姆龙官方通信命令手册里有一张很长的错误码表按图索骥最快。4.3 数据错位、数量异常多半是字节序和协议长度问题有一次现场反馈“读出来的数据总感觉差一个位”。后来抓包一看是我把地址 BCD 转换写错了导致读取的起始地址偏了 1 个字。这类问题最隐蔽因为有时候恰好读到一个也是有效数据的区域看起来像正常实际已经错位了。另外写入时如果数据个数和实际数据长度不匹配PLC 会报错。比如你声明写 10 个字但数据区只给了 8 个字PLC 就会返回 0x1101。建议在 writeWords 函数里做一次长度校验防止低级错误。还有一点虽然 FINS 是面向大端的协议但不同 PLC 型号对数据内部的字节序处理有差异。遇到读出来的数值反了比如 0x1234 变成 0x3412不要急先查看官方手册里该型号的字节序说明再决定要不要交换高低位。遇到这种问题别硬改代码先去查手册往往有标准答案。4.4 实战心得从协议文档到稳定上线的经验这个项目做下来我最大的体会是协议类开发数据流比界面重要得多。先把通信层写稳再去美化 UI不然界面再好看数据一断就是废的。我建议新手按这个顺序走先用网络调试助手手动发一帧 FINS 报文PLC 有回应后再写 Qt 代码写代码时先做一个“测试按钮”点一下读一块固定地址能读出来再开始做定时轮询和业务逻辑。这样每步都可验证出问题也知道往哪查。另外把超时和重试做进去。UDP 通信最怕的就是发出去石沉大海界面一直转圈。我会给每次请求维护一个时间戳超过 500ms 没收到响应就标记超时最多重试 3 次还不行就在界面上显示“通信超时”而不是无限等下去。SID 字段也一定要用起来。每发一帧请求SID 加一收到响应后通过 SID 来对应是哪条请求。多页面、多线程同时读数据的时候这个字段能省掉很多不必要的互斥逻辑。我就是在同一个界面里同时监控十几个地址靠 SID 区分响应一次都没乱套。如果你准备在自己项目里做类似改造可以先从读 DM 区开始跑通之后再扩展到 CIO、HR 区。FINS 协议并不复杂真正麻烦的是那些细节BCD 地址、节点号、完成码。把这些啃下来欧姆龙全系列的通信基本都能拿下了。本文还有配套的精品资源点击获取