ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用QT开发欧姆龙FINS上位机:从协议解析到实机调试

用QT开发欧姆龙FINS上位机:从协议解析到实机调试 简介这是一套基于Qt框架开发欧姆龙FINS协议上位机通信的源码工程面向工业自动化领域需要实现PC与欧姆龙PLC数据交互的开发者。代码包完整演示了FINS协议帧封装、读写命令构造、通信流程处理以及Qt界面与PLC联动等核心环节适合正在学习Qt网络通信、或需要快速搭建PLC上位机原型的工程师参考。压缩包内共18个文件包含cpp源文件、h头文件、ui界面文件、pro工程文件及Makefile等项目构建配置整体约823KB结构紧凑便于直接导入Qt Creator查看运行。该资源已有88人学习浏览虽然不保证一定能在当前环境直接一键编译但协议封装思路和代码组织方式具有一定的工程参考价值尤其适合结合博文说明阅读帮助理解FINS通信细节与Qt上位机整体架构。 车间里那台欧姆龙CP1H的D100寄存器里存着当前产线的节拍数据而你坐在办公室电脑前想实时看到这个数值甚至远程修改某个运行参数——最直接的做法就是写一个FINS上位机。FINS是欧姆龙PLC的看家通信协议QT则是我在试过几套备选方案后觉得最顺手的界面工具。这篇文章记录的是从零开始用QT开发欧姆龙FINS上位机的完整过程为什么放弃C#、FINS报文到底怎么拼、QT通信类怎么写、界面层怎么串起来以及实机调试中那些查了一下午才发现的神坑。适合正在用QT做欧姆龙PLC监控项目、或者准备上位机开发面试的读者看完可以直接拿去落地。1. 为什么这个上位机选择QT而不是C#或LabVIEW1.1 三个备选方案的取舍做欧姆龙PLC的上位机市面上最常见的方案有三个C#的WinForm/WPF、LabVIEW、QT。我一开始也是用C#写毕竟资料多、上手快串口和Socket用起来都方便。但真正做到第二个项目时问题就来了客户那边有Linux工控机C#的跨平台能力始终隔着一层WPF界面虽然漂亮部署时的.NET环境依赖也够折腾。LabVIEW则更适合测试测量场景做数据采集面板很爽但要嵌进公司自己的生产管理系统和其他模块集成起来反倒别扭。QT的优势在于三点第一C写的性能完全不用担心几百个地址的定时轮询对它来说是小场面第二信号槽机制天生适合通信类应用数据包来了自动触发界面更新不用像C#事件那样反复关心线程和委托第三一套代码在Windows和Linux上都能编译现场工控机换个系统不用重写。如果你熟悉Python或者对C有心理障碍也可以考虑用PySide2/PyQt做同样的事接口几乎是平移到Python但性能上限和部署便利性不如C版本。1.2 上位机的分层架构通信层、协议层、界面层写上位机最忌讳的就是把所有代码堆在一个MainWindow里。我的习惯是分成三层底层是Socket连接管理负责TCP或UDP的建立、断开、收发中间是协议层专门负责FINS帧的构造和解析把字节流变成读DM100返回了1234这种业务数据上层才是界面只管显示和交互。这三层之间用QT的信号槽连接通信层收到完整响应后发一个信号协议层解析完再发信号界面层接收后刷新控件。这样分层的直接好处是如果客户现场摄像头挂了要换成PPI或Modbus协议只需要替换中间那一层界面和通信层完全不用动。而且调试阶段可以直接在协议层写单元测试把一个固定报文喂进去看解析结果是否正确不需要每次都在PLC上改程序验证。这个习惯省了我大量时间强烈建议从第一个版本就坚持。2. FINS协议不是天书从一帧报文看懂欧姆龙PLC通信2.1 FINS帧头与命令体结构FINSFactory Interface Network Service是欧姆龙PLC的开放式网络协议它不关心底层是串口、以太网还是Controller Link帧结构始终一致。一帧完整的FINS报文由10字节的FINS头加上命令体组成。FINS头里最重要的字段是目标节点号DA1、源节点号SA1、服务标识SID。DA1是PLC的FINS节点号SA1是你上位机的节点号SID则是用来匹配请求和响应的——它本质上是每次通信的流水号收到响应时核对SID避免反应错乱。命令体最前面是2字节命令码。读写内存区分别用0101读和0102写后面跟着具体的参数内存区代码、起始地址、读取个数或写入数据。整个协议非常固定本质上就是按字节往缓冲区里填数然后交给Socket发送。理解了这一点扒开协议文档的恐惧感就消除了一大半。2.2 用表格拆解读DM区请求和响应以读取D100开始连续10个字为例TCP模式下完整请求帧是这么拼的字段字节数值说明ICF10x80命令帧标识RSV10x00保留字段GCT10x02网关计数DNA10x00目标网络0本地DA110x00目标节点号需和PLC一致DA210x00目标单元号SNA10x00源网络0本地SA110x1F源节点号上位机自己定SA210x00源单元号SID10x01服务标识自增命令码20x0101内存区读内存区代码10x8585DM区起始地址20x0064D100起始位10x000表示字单位读取字数20x000A10个字正常响应则是FINS头 0x0101 完成码2字节0x0000表示正常 数据区。比如数据区是0x000A 0x000B那D100的值就是100x000AD101的值是110x000B。所有多字节字段都按大端序排列高字节在前这个细节非常重要解析时一旦用错字节序读出来的数字夸张到没法用。2.3 TCP、UDP、串口怎么选FINS三种通信方式我都走通过选择依据并不复杂。串口适合老设备升级CP1H这类PLC要挂CIF41模块才能走以太网否则只能用串口Host Link模式跑FINS波特率只能到115200量一大就吃力。UDP模式结构最简单不用维持连接但丢包完全靠应用层重试。TCP模式有连接状态管理数据可靠最推荐用于上位机监控这也是我用得最多的方式。需要注意UDP模式下报文前面要加8字节的FINS UDP头内容是ASCII码的FINS加四个保留字节TCP模式则不需要这8字节。开发时不少人直接把UDP抓包的报文原样用TCP发出去结果PLC完全不理你就是这个原因。另外不同PLC家族对FINS的支持程度不一样CP1H、CJ2M、NJ/NX都有差异但走TCP时命令格式基本一致拿CP1H调试好的类接到CJ2M上通常不用改。3. QT里的FinsClient通信类代码级实现3.1 类结构状态、连接与帧构建通信类我命名为FinsClient继承QObject内部维护一个QTcpSocket指针和一个状态枚举。核心接口有三个connectToPlc(ip, port)、disconnectFromPlc()、以及读写命令readWords(areaCode, startAddr, count)和writeWords(...)。这个类只负责通信和协议组装不掺任何界面逻辑。class FinsClient : public QObject { Q_OBJECT public: explicit FinsClient(QObject *parent nullptr); enum AreaCode { CIO 0x82, WR 0x83, HR 0x84, DM 0x85 }; void connectToPlc(const QString ip, quint16 port 9600); void disconnectFromPlc(); void readWords(quint8 areaCode, quint16 startAddr, quint16 count); void writeWords(quint8 areaCode, quint16 startAddr, const QListquint16 values); signals: void connected(); void disconnected(); void errorOccurred(const QString msg); void dataReceived(const QByteArray finsResponse); private slots: void onSocketReadyRead(); void onSocketError(QAbstractSocket::SocketError err); private: QByteArray buildReadFrame(quint8 areaCode, quint16 startAddr, quint16 count); QByteArray buildWriteFrame(quint8 areaCode, quint16 startAddr, const QListquint16 values); QTcpSocket *socket; quint8 sa1 0x1F; quint8 sid 0x01; };sid每次发送后加一确保每条请求的标识不同这样即使响应乱序也能识别。sa1是源节点号一般上位机可以固定为0x1F或0x64但必须确保和PLC里其他网内节点不冲突否则会收到目标节点错误。3.2 读写请求的组装细节构建读帧是最考验耐心的地方因为每个字节都不能错。下面这段代码是实际项目里用的buildReadFrameQByteArray FinsClient::buildReadFrame(quint8 areaCode, quint16 startAddr, quint16 count) { QByteArray frame; frame.append(char(0x80)); // ICF 命令帧 frame.append(char(0x00)); // RSV frame.append(char(0x02)); // GCT frame.append(char(0x00)); // DNA frame.append(char(0x00)); // DA1 目标节点需要按PLC实际配置修改 frame.append(char(0x00)); // DA2 frame.append(char(0x00)); // SNA frame.append(char(sa1)); // SA1 源节点 frame.append(char(0x00)); // SA2 frame.append(char(sid)); // SID frame.append(char(0x01)); // 命令码0101 frame.append(char(0x01)); frame.append(char(areaCode)); // 内存区代码 0x85DM frame.append(char((startAddr 8) 0xFF)); frame.append(char(startAddr 0xFF)); frame.append(char(0x00)); // 字单位 frame.append(char((count 8) 0xFF)); frame.append(char(count 0xFF)); return frame; }有个小细节startAddr 8和 0xFF是为了把16位地址拆成高8位和低8位很多新手直接frame.append(startAddr)会把两个字节压成一个字符报文长度直接短了PLC收到后基本就是超时或帧错误。写完帧以后通过socket-write(frame)发送即可。写帧的结构类似命令码换成0x0102参数部分多一段要写入的数据。每个字拆成高字节和低字节依次放入写完后PLC会回复一个带完成码但与读响应不带数据区的帧。注意写操作如果不带数据直接发PLC会返回完成码0x1103数据个数错误这点我在调试时吃过亏。3.3 响应解析完成码先看数据后读处理响应我全部放在onSocketReadyRead里收到数据后先缓存到QByteArray buffer再用一个自定义的粘包处理器判断长度是否足够。FINS的TCP响应没有固定长度读多少个字就有多长所以更稳妥的做法是先解析出FINS头中的命令码和完成码如果完成码不是0x0000就直接抛错误如果正常再根据请求时的读取数量切出真正的数据区。void FinsClient::onSocketReadyRead() { QByteArray data socket-readAll(); buffer.append(data); while (buffer.size() 14) { // FINS头(10) 命令码(2) 完成码(2) quint8 cmdHigh quint8(buffer.at(10)); quint8 cmdLow quint8(buffer.at(11)); quint16 cmd (cmdHigh 8) | cmdLow; quint16 endCode (quint8(buffer.at(12)) 8) | quint8(buffer.at(13)); if (cmd 0x0101 endCode ! 0x0000) { emit errorOccurred(QString(读内存区失败完成码: %1).arg(endCode, 4, 16, QLatin1Char(0))); buffer.clear(); return; } // 从这里开始解析数据把buffer裁剪成一条完整响应后发出 emit dataReceived(buffer); buffer.clear(); } }完成码是整个排查过程中最重要的信息比如0x1101表示内存区代码错误0x1102表示起始地址格式错误0x1103表示读取数量错误。每次通信失败先别急着怀疑网线和IP把完成码打出来看一眼十有八九能直接定位问题。3.4 定时轮询与异步通信的配合上位机监控通常用QTimer驱动轮询每隔500ms读取一批需要刷新的地址。由于readWords只是发送请求不会阻塞界面所以定时器一开界面依然可以流畅操作。这比在子线程里跑while(true)循环要简单得多也避免了线程安全的各种坑。如果轮询地址很多建议把读请求拆成几个批次每轮定时器只发一批下一轮再发下一批。这样每一条响应回来时界面只需处理当前这批数据不会出现上一批响应还没处理完下一批又到了导致的堆积问题。实际测试中200个字的DM区数据500ms周期完全够用。4. 界面与业务逻辑把报文变成看板4.1 布局与控件设计界面不需要多炫但信息层级要清楚。我的主窗口分为三个区域顶部是连接配置区放PLC的IP地址输入框、端口号默认9600、连接/断开按钮中间是数据监控区用QTableWidget按行显示地址、当前值、更新时间便于观察多通道数据底部是操作按钮和状态栏按钮用来触发写操作状态栏显示当前连接状态和最后一条错误信息。table new QTableWidget(this); table-setColumnCount(3); table-setHorizontalHeaderLabels({地址, 值(十进制), 更新时间}); table-horizontalHeader()-setStretchLastSection(true);如果监控点数固定可以在初始化时一次性把行数建好刷新时只更新item的text避免频繁insertRow和removeRow造成的闪烁。数据变化频率高的话还可以给刷新的单元格配上颜色变化提醒一眼就能看出哪个值跳动了这在上位机现场调试时非常实用。4.2 刷新策略定时轮询的节奏控制QTimer的间隔设置要根据PLC扫描周期和网络环境综合考虑。设得太短比如50ms欧姆龙PLC的FINS服务未必来得及响应反而容易造成报文堆积设得太长数据实时性差监控看板就失去了意义。我一般是先把间隔设成500ms观察CPU负载和数据更新效果再根据现场情况微调。对于普通的工艺参数监控500ms到1s完全足够。另外轮询和手动操作之间的冲突要先想清楚。如果界面上允许操作员写入某个参数就要在写入期间暂停自动轮询否则可能出现写入请求还没发完下一次自动读取又插进来的情况。我采用的是简单的互斥标志位一旦进行写操作先停掉QTimer写完收到完成码再重新启动。4.3 配置文件的导入导出QFileDialog的基本用法现场工程师最烦的事情之一就是每换一台电脑就要重新输入PLC的IP和一堆地址。所以我把连接信息和监控地址表做成了JSON配置文件支持导入和导出。在工具栏放两个按钮点击后调用QFileDialog选择文件路径QString fileName QFileDialog::getOpenFileName(this, 选择配置文件, QDir::homePath(), 配置文件 (*.json)); if (fileName.isEmpty()) return; QFile file(fileName); if (!file.open(QIODevice::ReadOnly)) { QMessageBox::warning(this, 提示, 无法打开配置文件); return; } QJsonDocument doc QJsonDocument::fromJson(file.readAll()); // 解析JSON填充IP输入框和监控地址表文件名后缀过滤、默认路径设为用户目录这些细节看起来小但现场用起来特别顺手。另外导出时建议用QFileInfo取一下文件名避免用户手输后缀导致保存格式不对。导出的JSON还可以带上最后的连接时间下次打开自动显示最近配置这些小功能会让整套软件很快被现场人员接受。5. 实机调试踩坑记录从连不上到数据错位的完整排查5.1 连不上PLC的第一排查链路第一次用自己写的上位机连PLC十有八九是先卡在连接这一步。我的排查顺序是固定的先用ping确认物理网络通不通再用抓包工具确认PLC是否在9600端口监听如果抓包能看到PLC的响应但上位机报超时那问题基本在报文内容上。常见的报文错误有DA1目标节点号和PLC实际设置不一致、源节点号冲突、FINS头中GCT字段被填成了0x00而不是0x02这些都是抓包后逐字节比对才能发现的。有些PLC型号在TCP连接建立后必须先发送一条节点注册指令命令码0000才能正常收发FINS命令否则PLC会一直不响应。具体到你的PLC型号建议先查官方通信手册或者用CX-Programmer的FINS工具做一次连通性测试把工具发出的报文抓下来和自己的逐字节对齐差异之处往往就是问题所在。5.2 waitForReadyRead vs 信号槽阻塞UI的教训早期版本我图省事在读写函数里直接用了waitForReadyRead(1000)想着同步等1秒挺安全。实际跑起来发现点一下读取按钮界面卡死一秒多连续点击几次整个窗口就无响应了。而且现场工程师看着卡住的界面就会反复点每个请求都排队累加最终连接直接崩溃。教训就是QT里做上位机通信要坚定地拥抱异步信号槽。connectToPlc和readWords都只负责发指令所有结果都通过信号返回。界面层用连接状态信号去切换按钮可用性用数据接收信号去刷新表格。虽然代码分散了一些但界面始终流畅。后来为了实时性好一点我还把Socket相关操作放到了子线程中通过信号槽跨线程传递数据但那是点数上了上千之后的事了。5.3 数据错位、字节序与SCAN冲突有一次读WR区的开关状态读出来全是乱码排查半天发现是地址偏移算错了。FINS地址是按字编址的WR0对应字地址0但我在界面里写的是WR1客户原以为WR1表示第1号继电器结果协议层实际访问的是第二个字的地址数据自然全错。遇到这种问题我现在的做法是先在PLC里给每个要读的字赋一个独一无二的值比如D100写100、D101写101上位机读上来一看就知道地址有没有对齐这个办法虽然笨但定位问题特别快。字节序问题也很隐蔽。FINS协议里一个字的数据是高字节在前比如0x1234在报文里是12 34我解析的时候一开始用了(quint8(data.at(i)) | (quint8(data.at(i1)) 8))整个数字翻了16倍还带符号错乱。后来统一封装了一个readBigEndianUint16函数所有解析都走它再没出过类似问题。上位机开发中这类低级但致命的细节非常多建议把字节读写都封装好别在业务代码里裸操作。踩过几次坑之后我还养成一个习惯每次拿到新的PLC型号先不写界面专门写一个命令行小工具能连接、能读、能写、能打印原始报文的十六进制确认通信完全正常后再开始搭界面。这个工具帮我避开了很多界面写了一大半结果连不上PLC的尴尬局面。如果你也在做欧姆龙上位机建议先从这样一个精简的FINS调试工具开始等底层稳定了界面层反而是最不需要操心的部分。本文还有配套的精品资源点击获取
返回列表