简介:这份代码包面向使用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 还是 TCP
FINS 协议同时支持 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 为例,帧头格式如下:
| 字节 | 名称 | 取值说明 |
|---|---|---|
| 0 | ICF | 信息控制字段,普通二进制通信请求一般设为 0x80 |
| 1 | RSV | 系统保留,固定填 0x00 |
| 2 | GCT | 网关计数,本地通信填 0x02 |
| 3 | DNA | 目标网络号,本地网络填 0x00 |
| 4 | DA1 | 目标节点号,即 PLC 的 FINS 节点号 |
| 5 | DA2 | 目标单元号,CPU 单元填 0x00 |
| 6 | SNA | 源网络号,本地网络填 0x00 |
| 7 | SA1 | 源节点号,即 PC 的 FINS 节点号 |
| 8 | SA2 | 源单元号,PC 端填 0x00 |
| 9 | SID | 服务标识,用于区分请求和响应,一般是自增序号 |
这里的“节点号”是 FINS 层面每个设备的编号,不是 IP 地址。PLC 侧需要在其网络配置里设置节点号,PC 侧我们也要选一个不与 PLC 冲突的节点号。比如 PLC 设节点号 1,PC 就设 2。很多第一次写 FINS 的人只记得填 IP,忘了节点号,结果一直收不到回包。
2.2 内存区代码与地址换算,最容易踩坑的地方
FINS 读数据,核心是告诉 PLC“我要读哪个区、从哪个地址开始、读多少个”。内存区代码就是用来指定“哪个区”的。常用的有:
| 内存区 | 字访问代码 | 常见 PLC 型号 |
|---|---|---|
| CIO 区(含 CIO0~CIO6143) | 0x30 | CP、CJ、NJ 等 |
| WR 区(W0~W511) | 0x31 | CP 系列 |
| HR 区(H0~H511) | 0x32 | CP、CJ 等 |
| DM 区(D0~D32767) | 0x82 | 全系列 |
地址字段是 2 字节,但不是简单把十进制转成十六进制。FINS 协议里地址是用 BCD 码表示的。举个例子:你想读 DM100,那么地址字段要填的是 0x0100,而不是十进制 100 对应的 0x0064。同理,读 DM123,要填 0x0123。
这个坑很多新手都会掉进去。一开始图省事,直接用 quint16 地址去组包,读 DM100 以下的数据可能没问题,因为有些 PLC 对地址的解析方式比较宽容;一旦地址上到两位数甚至三位数,比如 DM168,你要是填成十六进制 0x00A8,PLC 按 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 字节,按大端序解释 QVector<quint16> 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 节点是 1,PC 还是 1,PLC 收到包后会认为这是发给自己的,回包目标也会找节点 1,结果就是你什么也收不到。
- 第三,端口和网段是否一致。PLC 的 IP 要和电脑在同一网段,默认 UDP 端口 9600 不要改错,如果有路由或网关隔离,现场网络管理员得先确认端口放行。
我用实际数据写个典型配置:PLC IP 是 192.168.1.10,节点号 1;PC IP 是 192.168.1.100,节点号 2。这样帧头里 DA1=1,SA1=2,目标端口 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 地址、节点号、完成码。把这些啃下来,欧姆龙全系列的通信基本都能拿下了。
本文还有配套的精品资源,点击获取