基于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、TransferAgvProtocolBase下面是ProtocolPlc和ProtocolStm32。这种设计不是为了凑继承关系而是让调度逻辑不依赖具体AGV类型让协议解析不依赖具体通信方式。3.1 AgvBase用模板方法收紧AGV行为差异AgvBase是所有AGV的抽象基类里面定义了导航状态、负载状态、任务ID这些公共属性同时声明了纯虚函数doMove()、doLift()、doFork()等。子类的差异表现在“同一动作不同执行方式”。比如SubmersibleAgv需要控制顶升机构PullAgv需要拖着料车ArmAgv还要处理机械臂的姿态但调度层发出的moveToStation命令流程完全一致。常见的实现是模板方法模式基类定义executeTask()里面依次调用doMove()、doLoad()、doUnload()子类只需覆写各自的动作细节。这样在mainwindow里维护一个统一的QListAgvBase*任务分配时只用指向基类的指针操作不关心具体是哪台车。新增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 qFromLittleEndianquint16( 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*启发函数的标定仓库平面布局图在源码里的呈现形式是节点和边不是纯图片。每个站点是一个节点边是允许通行的路径边权表示这短路的花费时间或距离。代码里常见的是QHashunsigned, QListEdge结构struct Edge { unsigned to; double cost; }; QHashunsigned, QListEdge 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; // 小根堆 } }; QVectorunsigned aStar(unsigned start, unsigned goal) { priority_queueAStarState open; QHashunsigned, double g; QHashunsigned, 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_limitsdouble::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 CONFIGrelease make -j8 ./IntelligentAGVSchedulingSystemqmake取自Qt安装目录下的bin。Windows上需要先确认编译器环境用MinGW套件时make命令是mingw32-makeMSVC套件则要配合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 { // 启动辊道电机装载货物 } };先进的生产系统很少一开始就有完整闭环都是在跑起来之后逐步加规则。这套源码的可贵之处在于它用类名和信号把事情摆放得足够清楚你可以在不破坏原有结构的前提下加入自己的协议帧、地图生成器或任务自动生成脚本。改完多跑几轮记录每台车的里程和空闲率平衡算法就在这些数据里迭代出来了。本文还有配套的精品资源点击获取