news 2026/9/10 11:27:20

基于Qt的AGV调度系统实战:信号槽、多线程与路径规划

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Qt的AGV调度系统实战:信号槽、多线程与路径规划

简介:这套源码是一个基于QT框架与C++开发的智能AGV调度系统,适合有一定C++基础并希望深入QT项目实战的开发者,也适用于工业自动化、仓储物流场景中的调度逻辑学习。压缩包共66个文件,核心由26个cpp、24个头文件、2个UI界面及2个pro工程文件组成,另含通信协议文档、UML建模文件、仓库平面图DWG和Python辅助脚本,类型覆盖代码、文档与设计图,包体仅5.4MB,便于快速下载。已有687人学习。通过学习这套工程,可以掌握QT的信号槽机制、QThread多线程处理、MVC模式在调度界面中的应用,并理解AGV路径规划、PLC/STM32通信协议设计、RFID及多种AGV车型(如潜入式、举升式、叉车式)的统一管理方式。配合文档规范说明与平面布局图,能够更完整地还原系统设计思路,适合作为课程设计或企业二次开发的参考。

1. 从一台潜伏式AGV的调度现场说起

车间里同时跑着潜伏式、举升式、牵引式、叉车式、机械臂式和转移式六类AGV,主控屏要实时显示每台的位置、电量、任务状态,还要把不同厂商的PLC、STM32控制器报文统一成内部消息。这套QT设计的智能AGV调度系统源码,做的就是这件事。它是一套完整的Qt 5 C++工程,包含AGV基类派生类、协议栈基类、RFID回调、路径搜索和Qt界面。花一个下午把.pro工程导入Qt Creator,逐个类过一遍,比看十篇Qt教程都有用。无论你做上位机、嵌入式还是刚转Qt开发,这套代码能让你看到工业调度软件如何把GUI、多线程、协议解析和调度算法焊在一起。

2. Qt信号槽、QThread与双缓冲绘图:调度界面的骨架

Qt开发里最容易被误解的是信号槽的“线程亲和性”。调度系统每天都在处理并发事件,如果直接把串口接收、PLC轮询和界面更新写在同一线程,界面会卡死,丢任务消息也察觉不到。这套源码把Qt的信号槽、QThread和QPainter组合成了三层骨架:主窗口负责状态展示,工作线程负责设备通信,定时器驱动调度计算。拆开看并不复杂。

2.1 信号槽把任务状态机串成事件链

在mainwindow.cpp里可以看到典型的信号槽连接方式:

// 调度轮询:每200ms触发一次任务分配 connect(&m_taskTimer, &QTimer::timeout, this, &MainWindow::onScheduleTick); // PLC协议帧到达时通知主窗口更新状态 connect(&m_plcProtocol, &ProtocolPlc::frameReady, this, &MainWindow::onPlcFrameReady); // 某个AGV完成搬运任务后,触发下一任务下发 connect(&m_agv, &AgvBase::taskFinished, this, &MainWindow::assignNextTask);

第一行是定时器驱动,保证调度算法周期性检查待分配任务。第二行把协议层的解析结果传给界面层,不关心帧是来自PLC还是STM32。第三行用“任务完成”事件串联下一个任务,避免在循环里手动轮询状态机。

信号槽的底层连接方式很关键。Qt 5里connect可以自动判断线程,如果发送者和接收者不在同一线程,会退化为QueuedConnection,参数通过事件循环投递到接收线程。需要注意的是,发射信号时传递的参数必须能拷贝,否则要使用自定义类型并调用qRegisterMetaType。这套源码里协议帧普遍用QByteArray传递,天然兼容跨线程信号,这是它结构能撑住的原因之一。

2.2 QThread:别把AGV轮询塞进GUI线程

AGV的状态上报频率一般是几十到几百毫秒一次,加上PLC的握手和RFID的读卡信号,如果都解析在UI线程,主窗口的重绘和鼠标事件都会受影响。常见做法是单独抽一个Worker对象,moveToThread后让它跑事件循环。

QThread workerThread; WorkWorker worker; worker.moveToThread(&workerThread); connect(&workerThread, &QThread::started, &worker, &WorkWorker::process); connect(&worker, &WorkWorker::resultReady, this, &MainWindow::onWorkerResult); workerThread.start();

第一行创建线程,第三行是关键:moveToThread之后,worker里所有槽函数都在新线程执行。第五行resultReady信号会从worker线程发给主窗口,由于接收者是主窗口,连接自动用队列方式投递,界面更新就在主线程完成。这里要特别强调的是,worker的process槽里不能直接操作MainWindow的成员,否则等于跨线程修改UI控件,轻则闪烁重则崩溃。源码里ProtocolBase和AgvBase都设计成纯数据对象,不持有界面指针,正是为了支持这种拆分。

2.3 QPainter与主窗口:把仓库平面图变成动态调度图

mainwindow.ui负责摆放控件,但真正的AGV动态位置是用QPainter在paintEvent里画出来的。Qt绘图是典型的主动重绘模型,界面收到设备数据后调用update(),Qt在下一次事件循环统一触发重绘,避免高频数据把窗口撕碎。

void MainWindow::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 先画仓库导轨和RFID站点 drawWarehouseRails(painter); drawRfidStations(painter); // 再画AGV,每个AGV是一张带旋转角度的图片 for (const AgvBase* agv : m_agvList) { painter.save(); painter.translate(agv->posX(), agv->posY()); painter.rotate(agv->heading()); painter.drawPixmap(-18, -18, 36, 36, agvPixmapByType(agv->type())); painter.restore(); } }

代码里save/restore保证每个AGV的坐标系独立,translate把原点移到AGV位置,rotate让图片朝当前航向。这套绘制方式用在调度系统里,天然支持放大缩小和命中检测。如果想扩展,可以托管给QGraphicsScene,但小规模调度场景直接重写paintEvent更轻。

Qt界面设计中的双缓冲是默认开启的,QPainter先画到离屏像素图,然后一次性贴出。在设备数据密集的场景下,重绘耗时可能达到几十毫秒。如果发现界面卡顿,优先检查paintEvent里是否做了数据库查询或文件IO,而不是急着加线程锁。

3. AGV类型体系与PLC/STM32协议栈的解耦设计

看目录清单时会发现这套源码的类名很规整:AgvBase下面是SubmersibleAgv、LiftingAgv、PullAgv、ForkAgv、ArmAgv、TransferAgv;ProtocolBase下面是ProtocolPlc和ProtocolStm32。这种设计不是为了凑继承关系,而是让调度逻辑不依赖具体AGV类型,让协议解析不依赖具体通信方式。

3.1 AgvBase:用模板方法收紧AGV行为差异

AgvBase是所有AGV的抽象基类,里面定义了导航状态、负载状态、任务ID这些公共属性,同时声明了纯虚函数doMove()doLift()doFork()等。子类的差异表现在“同一动作,不同执行方式”。比如SubmersibleAgv需要控制顶升机构,PullAgv需要拖着料车,ArmAgv还要处理机械臂的姿态,但调度层发出的moveToStation命令流程完全一致。

常见的实现是模板方法模式:基类定义executeTask(),里面依次调用doMove()doLoad()doUnload(),子类只需覆写各自的动作细节。这样在mainwindow里维护一个统一的QList<AgvBase*>,任务分配时只用指向基类的指针操作,不关心具体是哪台车。新增AGV类型时,也不需要改调度主逻辑。

派生类实际载荷形式需要实现的关键动作
SubmersibleAgv顶升潜入料车底部顶升、下降
LiftingAgv举升货架举升、保持
PullAgv挂载牵引料车挂钩、脱钩
ForkAgv叉取托盘叉臂升降、前移
ArmAgv机械臂抓取臂运动、夹爪开合
TransferAgv滑移或辊道转运输送带启停

调度层只关心“到达某站点后能不能完成装卸”,具体动作由Agent自己的协议栈下发给控制器。这也是没什么文档也能快速上手的原理——类名已经把职责说清楚了。

3.2 ProtocolBase与PLC/STM32协议栈

ProtocolBase是串口/网络帧的抽象层,它对外暴露两个接口:sendFrame(QByteArray)onDataReceived(QByteArray)。ProtocolPlc实现的是基于Modbus风格寄存器的读写,ProtocolStm32实现的是自定义定长/变长帧指令。区别只在底层字节解析,上层信号统一发frameReady,所以mainwindow不需要知道报文是从RS485还是以太网来的。

class ProtocolBase : public QObject { Q_OBJECT public: virtual bool sendFrame(const QByteArray& frame) = 0; virtual void onDataReceived(const QByteArray& chunk) = 0; signals: void frameReady(const QByteArray& payload); void linkError(const QString& reason); };

两个子类分别对应车间里最常见的AGV控制器形态:老设备走PLC硬接点信号,新设备走STM32串口/网口。如果现场还有第三种控制器,只要继承ProtocolBase,把onDataReceived里的粘包逻辑重写一次即可。

3.3 通信帧结构、粘包处理与CRC校验

实际项目中,设备返回的报文经常是半包、多包,所以onDataReceived内部要维护一个接收缓冲区,把完整帧切出来。典型的变长帧格式如下:

字段长度(字节)说明
帧头20xAA 0x55
地址1AGV编号
功能码10x01读状态,0x02发指令
数据长度1数据体字节数
数据体N状态/指令数据
CRC162从帧头到数据体所有字节的CRC

解析代码按这个结构逐段校验:

bool ProtocolStm32::tryParseFrame(const QByteArray& buffer) { if (buffer.size() < 7) return false; if ((quint8)buffer[0] != 0xAA || (quint8)buffer[1] != 0x55) return false; quint8 addr = buffer[2]; quint8 func = buffer[3]; quint8 len = buffer[4]; if (buffer.size() < 5 + len + 2) return false; quint16 crc = qFromLittleEndian<quint16>( buffer.mid(5 + len, 2).constData()); quint16 calc = crc16(buffer.mid(1, 4 + len)); if (crc != calc) return false; emit frameReady(buffer.mid(5, len)); return true; }

第一处长度判断保证至少能读完头部,第二处长度判断保证数据体和CRC都到位。CRC用从地址字节到数据体结尾的所有字节计算,这样帧头不用参与校验,防止干扰信号撞上帧头导致错判。实际调试时,常见的坑是CRC高低字节顺序不一致,STM32端多按小端发送,而PLC侧有的按大端。解析不出来时先抓原始字节流,手动算一遍CRC再比对。这类问题在协议联调里占了一半以上。

4. 任务分配、A*路径与RFID定位:调度算法的落地

GUI和协议层搭好后,调度算法的核心是“怎么把任务派给最合适的车,怎么让车走到目标站”。这套源码里你能看到典型的双循环结构:主窗口定时器触发任务预分配,AGV自己的状态机处理站点间移动,RFID基站用来纠正累积误差。

4.1 有向图与A*启发函数的标定

仓库平面布局图在源码里的呈现形式是节点和边,不是纯图片。每个站点是一个节点,边是允许通行的路径,边权表示这短路的花费时间或距离。代码里常见的是QHash<unsigned, QList<Edge>>结构:

struct Edge { unsigned to; double cost; }; QHash<unsigned, QList<Edge>> graph; double heuristic(unsigned from, unsigned to) { QPointF a = stationCoord(from); QPointF b = stationCoord(to); return (a - b).manhattanLength(); // 曼哈顿距离作为启发值 }

A*搜索时,g(n)是从起点到当前点的实际代价,h(n)是启发函数估计剩余代价。仓库里AGV只能走直线导轨,曼哈顿距离往往比欧氏距离更贴近真实路径长度,但需要注意转向代价没有算进去。如果小车转弯要额外减速,应该在边权里加入角度变化量,否则搜索结果看着最短,跑起来并不快。

struct AStarState { unsigned node; double gScore; double fScore; bool operator<(const AStarState& o) const { return fScore > o.fScore; // 小根堆 } }; QVector<unsigned> aStar(unsigned start, unsigned goal) { priority_queue<AStarState> open; QHash<unsigned, double> g; QHash<unsigned, unsigned> parent; open.push({start, 0, heuristic(start, goal)}); g[start] = 0; // ... 主循环弹出最小 f 值的节点展开邻居 }

A的停止条件是目标节点第一次从open表弹出,这时g值已经是最小。也有调度系统用Dijkstra做全图最短路径,适合小地图,但AGV数量多、任务密集时A的局部性更香。这里还要注意A*找到的路径可能包含重复节点、回头路,因为AGV是沿固定轨道移动,Floyd预计算所有站点间最短路径表,运行时查表比每次搜索更快,代价是地图变化时需要重新计算。

4.2 任务指派:从FCFS到代价最小化

最简单的任务派发是先进先出:来一个任务,找一辆空闲车去执行。当系统里同时有多台车多个任务,FCFS会导致最近的车被指派给早期的任务,整体效率不高。这套源码里的assignNextTask逻辑可以改造成代价矩阵匹配:计算每台空闲AGV到每个待分配任务起点的路径代价,取最小者派发。

void MainWindow::onScheduleTick() { if (pendingTasks.isEmpty()) return; double bestCost = std::numeric_limits<double>::max(); AgvBase* bestAgv = nullptr; Task* bestTask = nullptr; for (AgvBase* agv : idleAgvList) { for (Task& task : pendingTasks) { double cost = agv->pathCostTo(task.startStation); if (cost < bestCost) { bestCost = cost; bestAgv = agv; bestTask = &task; } } } if (bestAgv) { bestAgv->assignTask(*bestTask); pendingTasks.removeAll(*bestTask); idleAgvList.removeAll(bestAgv); } }

这里的“路径代价”要考虑AGV当前电量、是否正在充电、任务目的地是否在它的工作区。代码里如果用pathCostTo而不是实际派发路径,就只是一层近似。常见做法是给它乘以权重因子,比如电量低于20%的车对远距离任务返回一个很大的代价,把远任务留给电量的车。

4.3 RFID回调、站点缓存与去重

RFID的用途不是路径导航,而是位置校正。AGV轮子编码器有累积误差,跑几百米后可能偏几厘米。RFIDBase负责把读卡器返回的标签号映射到站点编号,一旦读到RFID卡片,就认为AGV到了某个精确位置,调度层同步修正坐标。

void RfidBase::onTagRead(const QString& tagId, int rssi) { unsigned stationId = m_stationByTag.value(tagId, 0); if (stationId == 0) { emit unknownTag(tagId); return; } // 同一张卡连续读到两次时只上报一次 if (m_lastStation == stationId) return; m_lastStation = stationId; emit stationEntered(m_agvId, stationId, rssi); }

去重条件不要简单对比上一次的tagId,因为AGV可能在同一张卡附近反复启停。如果读卡方向有正反,标签号不同但站点相同,用m_lastStation去重更稳。rssi信号强度可用于判断车离读卡器多远,但实际项目中作用有限,因为电磁环境差异大。代码里若需要记录日志,最好把原始标签ID和站点ID都落库,方便后续排查为什么某辆车在某个站点反复触发。

RFID数据通常低频,不用开线程,直接在收帧后调用即可。如果现场RFID读卡器走单独的串口,可以和Protocol类共用同一个接收缓冲区,但要注意不同设备的帧头冲突。我一般会把RFID协议也用ProtocolBase派生一个RfidBase子类,在onDataReceived里做独立解析,避免和AGV运动控制帧混在一起。

5. 把源码跑起来:qmake命令行、windeployqt打包与扩展新AGV

拿到源码先别急着双击.pro。确认Qt版本和套件再打开工程,否则满屏报错。推荐用Qt Creator打开,选择MSVC2019或MinGW套件,点击构建。如果只想命令行验证,这样做更干净:

mkdir build && cd build qmake ../IntelligentAGVSchedulingSystem.pro -spec linux-g++ CONFIG+=release make -j8 ./IntelligentAGVSchedulingSystem

qmake取自Qt安装目录下的bin。Windows上需要先确认编译器环境,用MinGW套件时make命令是mingw32-make,MSVC套件则要配合nmake。构建失败先看moc文件是否生成,QT框架的元对象编译器遇到类里声明Q_OBJECT会先展开moc,如果没装对应插件会报“unknown module”或“moc: Undefined”错误。

调试阶段建议在mainwindow.cpp入口处临时加几行日志,打印每台AGV的初始化坐标和状态,确认配置文件路径是对的。很多环境问题不是代码问题,而是工作目录不对,资源文件加载不到。

# Windows打包:把依赖的Qt运行库拷到exe同目录 windeployqt IntelligentAGVSchedulingSystem.exe

这条qt打包应用程序windeploy命令会把Qt的dll、样式插件、平台插件打进部署目录。部署后如果双击没反应,先看缺哪些dll,用Process Explorer或者直接命令行运行exe看依赖报错。特别要注意MSVC运行库不在windeployqt范围内,需要手动安装VC运行库。

扩展一台新的AGV类型时,不需要动协议层和调度层。新建一个类继承AgvBase,实现doMove和装载卸载纯虚函数,然后在mainwindow.cpp的初始化里创建并加入m_agvList。协议如果复用STM32控制协议,就不需要新建Protocol子类。

class ConveyorAgv : public AgvBase { Q_OBJECT public: void doMove(double dist) override { // 发送移动距离给STM32控制器 } void doLoad() override { // 启动辊道电机装载货物 } };

先进的生产系统很少一开始就有完整闭环,都是在跑起来之后逐步加规则。这套源码的可贵之处在于,它用类名和信号把事情摆放得足够清楚,你可以在不破坏原有结构的前提下,加入自己的协议帧、地图生成器或任务自动生成脚本。改完多跑几轮,记录每台车的里程和空闲率,平衡算法就在这些数据里迭代出来了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 11:25:56

GE图引擎SetInput API

SetInput 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlow 前端的…

作者头像 李华
网站建设 2026/9/10 11:24:25

AT89C51+MAX487 RS485自动收发时序设计与Proteus仿真

简介&#xff1a;本资源是一套面向嵌入式初学者与电子工程实践者的RS485自动收发通信完整仿真方案&#xff0c;聚焦AT89C51单片机与MAX487收发器协同实现半双工总线通信的核心技能训练&#xff0c;适用于工业通信、多节点传感网络等典型应用场景。压缩包共27个文件&#xff0c;…

作者头像 李华