简介:基于C++与Qt框架的分布式智能AGV调度系统,面向物流仓储、智能制造等场景,提供完整可运行的调度工程源码。项目将多种AGV(叉车、牵引、举升、潜水、机械臂等)的控制逻辑集中管理,并通过协议栈实现与STM32、PLC设备通信,接入RFID基站读写模块,可帮助理解分布式设备协同与界面交互设计。适用于计算机、自动化、电子信息等专业学生完成毕业设计、课程设计,也适合有C++基础的开发者学习Qt项目架构。压缩包共31个文件,以13个cpp源码和12个h头文件为主,另含UI界面文件、Qt工程配置、通信协议文档、仓库平面布局图及项目模型文件,整体约2.63MB,结构清晰,便于按模块阅读与二次开发。已有255人学习下载,代码经过运行验证,下载后可直接打开工程查看效果,也可基于现有框架继续扩展新功能。
1. 不是多线程就能叫“分布式”:AGV 调度系统的协议与模型边界
拿到这份基于 C++&Qt 框架的分布式智能AGV调度系统源码,我先翻的不是调度算法,而是它怎么隔离不同厂家 AGV 的通信差异。实际仓库里,AGV 可能来自多个供应商:有的通过串口和 STM32 下位机通信,有的走 PLC 的 Modbus-TCP,RFID 读写器还要单独占用一个串口。如果代码里到处是 if(plc) else if(stm32),那不管 Qt 界面做得再漂亮,加一辆车就是一次灾难。这个项目的价值在于给出了一套可运行的 ProtocolBase、AgvBase 和 Qt 主窗口分层骨架。适合正在做课设、毕设,或刚转工业上位机开发的 C++ 工程师:能抄的不只是代码,而是一个多协议、多车型的调度系统切入点。注意,它不是微服务意义上的“分布式”,而是把不同控制器、不同协议、不同车型在逻辑上统一调度的分布式模型。
2. 从 .pro 到类继承:拆解 Qt 工程里的协议层与 AGV 基类
2.1 工程文件里的模块依赖与源码组织
打开IntelligentAGVSchedulingSystem.pro,第一眼就能看出这工程不是单文件堆 demo 的写法。ProtocolBase.cpp / ProtocolStm32.cpp / ProtocolPlc.cpp是一组通信协议实现,AgvBase.cpp / ForkAgv.cpp / PullAgv.cpp / TransferAgv.cpp / SubmersibleAgv.cpp / LiftingAgv.cpp / ArmAgv.cpp是车型模型,RfidBase.cpp单独负责读卡器,mainwindow.cpp / mainwindow.ui承载调度界面。这种分组意味着后续替换协议或车型,不需要动 UI 层。
// IntelligentAGVSchedulingSystem.pro(Qt 5 常见配置) QT += core gui network serialport sql greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = IntelligentAGVSchedulingSystem TEMPLATE = app SOURCES += main.cpp \ mainwindow.cpp \ ProtocolBase.cpp \ ProtocolStm32.cpp \ ProtocolPlc.cpp \ AgvBase.cpp \ ForkAgv.cpp \ PullAgv.cpp \ TransferAgv.cpp \ SubmersibleAgv.cpp \ LiftingAgv.cpp \ ArmAgv.cpp \ RfidBase.cpp HEADERS += mainwindow.h \ ProtocolBase.h \ ProtocolStm32.h \ ProtocolPlc.h \ AgvBase.h \ ForkAgv.h \ PullAgv.h \ TransferAgv.h \ SubmersibleAgv.h \ LiftingAgv.h \ ArmAgv.h \ RfidBase.h这里最关键的是QT += serialport。很多从 TCP 转到工业上位机的同学容易漏掉这个模块,导致QSerialPort编译报错,误以为是 Qt 安装不完整。另外sql模块在文件列表里没有直接看到数据库类,推测用于保存任务记录或站点表,可以在工程里搜一下QSqlDatabase确认实际用法。构建时如果提示缺模块,先返回 .pro 检查,而不是去重装 Qt。
对照文件列表,可以整理出一张职责表:
| 文件/目录 | 职责 | 关键接口/类 |
|---|---|---|
| ProtocolBase.cpp/h | 协议基类,定义帧解析与组帧接口 | open/close/write/parseFrame/buildFrame |
| ProtocolStm32.cpp/h | STM32 串口协议实现 | onDataReady, CRC16 |
| ProtocolPlc.cpp/h | PLC Modbus-TCP 协议实现 | buildReadRequest, buildWriteRequest |
| AgvBase.cpp/h | AGV 基类,持有任务队列和协议指针 | assignTask, executeAction, reportState |
| ForkAgv/PullAgv/TransferAgv 等 | 各车型执行机构实现 | executeAction, 动作回帧处理 |
| RfidBase.cpp/h | RFID 读写器解析 | cardRead 信号 |
| mainwindow.cpp/ui | 调度监控界面 | 定时扫描、任务分发、日志显示 |
这张表的价值在于,当你要往工程里加一辆新车型时,只需要在 AGV 子类和协议实现两个位置扩展,不需要重写调度逻辑。这是项目可维护性的关键。
2.2 ProtocolBase:把“收到一串字节”变成可替换的协议策略
ProtocolBase 这个类名很像策略模式。它把串口和 TCP 的差异藏到open/close里,把帧结构差异藏到parseFrame/buildFrame里。上位机调度层只需要跟 ProtocolBase 打交道,不需要知道当前协议是 STM32 串口还是 PLC 的 Modbus-TCP。
// ProtocolBase.h #pragma once #include <QObject> #include <QByteArray> #include <QVariantMap> class ProtocolBase : public QObject { Q_OBJECT public: explicit ProtocolBase(QObject *parent = nullptr); virtual ~ProtocolBase(); virtual bool open(const QVariantMap ¶ms) = 0; virtual void close() = 0; virtual qint64 write(const QByteArray &frame) = 0; virtual bool parseFrame(const QByteArray &chunk) = 0; virtual QByteArray buildFrame(const QVariantMap &data) = 0; protected: QByteArray m_recvBuffer; bool m_connected = false; };parseFrame只有一个返回 bool 的参数,这在最初版本可能够用,但一旦要上报“当前坐标、电量、故障码”就吃力了。我一般会在工程里改成bool parseFrame(const QByteArray &chunk, QVariantMap &result),让字节解析和业务数据解耦。这里不急着改,先保证现有逻辑能跑通,再做这个小重构。
2.2.1 为什么协议基类不直接写 QSerialPort 成员
ProtocolBase 只声明接口,不把QSerialPort*写死在基类里,是因为 PLC 走 TCP、STM32 走串口,两者的打开、读写关闭流程差异太大。如果基类持有QSerialPort,那 ProtocolPlc 就必须带着一个永远用不上的串口对象。用纯虚函数暴露操作,由子类各自持有QSerialPort*或QTcpSocket*,是这类多协议设备管理系统比较稳妥的做法。另一个原因是为了单元测试:给协议子类传入一个QByteArray模拟帧,就能脱离硬件验证解析逻辑。
2.3 AgvBase 与六个 AGV 子类的关系
源码里出现的 AGV 车型比一般教学项目多:ForkAgv、PullAgv、TransferAgv、SubmersibleAgv、LiftingAgv、ArmAgv。它们不光长相不同,执行机构也不同:叉车要升降货叉,牵引车要挂/卸料车,潜入式要顶升料架,机械臂式还要控制关节运动。AgvBase 把它们共用的“任务队列、实时位置、运行状态、协议指针”沉淀在基类里。
// AgvBase.h 核心成员 #include <QQueue> #include <QPointF> #include <QSharedPointer> #include "ProtocolBase.h" enum class AgvState { Idle, Moving, Waiting, Charging, Error }; class AgvBase : public QObject { Q_OBJECT public: explicit AgvBase(const QString &agvId, QObject *parent = nullptr); virtual ~AgvBase(); virtual bool assignTask(const QPointF &target, int priority) = 0; virtual void executeAction(const QVariantMap &action) = 0; void reportState(); signals: void stateChanged(const QString &agvId, AgvState state); void positionUpdated(const QString &agvId, const QPointF &pos); protected: QString m_agvId; AgvState m_state = AgvState::Idle; QPointF m_position; QQueue<QPointF> m_taskQueue; QSharedPointer<ProtocolBase> m_protocol; };assignTask用priority参数是因为调度层常有多辆空闲车对一个任务的竞争:不是先到先得,而是先看车辆位置、电量和当前任务优先级。项目里如果只想跑通基础演示,可按“离起点最近优先”排序,但要把优先级的插入逻辑留在任务队列里,否则后续扩成多车任务分配时很难改。
2.3.1 QObject 父子关系与协议替换的坑
QSharedPointer<ProtocolBase> m_protocol看上去很合理,但要注意:如果 ProtocolBase 的 parent 是某个 AGV 对象,那么用new ProtocolStm32(this)创建出来的对象既被 Qt 父子关系管理,又被智能指针管理,析构时可能 double free。常见做法是协议对象不指定 QObject parent,完全交给QSharedPointer管理;或者在 AGV 析构函数里手动delete m_protocol。这不是大问题,但能解释为什么很多老手在工业代码里反而更喜欢裸指针加注释:生命周期边界清晰。
3. 多协议解析实战:ProtocolStm32/ProtocolPlc 的参数设计与串口排错
3.1 协议文档先看三处:帧头、长度、校验
项目附带的《分布式智能AGV调度系统通信协议.docx》不是装饰。我拿到这类文档时只翻三处:帧结构定义、CRC 校验、控制字映射。STM32 下位机通常用串口,一帧里包含帧头、地址、功能码、长度、数据、校验;PLC 型 AGV 往往是 Modbus-TCP,报文里是事务标识、协议标识、长度、单元标识、功能码、寄存器数据。两种协议在 ProtocolBase 的接口下只影响两个函数的实现。
| 字段 | STM32 串口帧 | PLC Modbus-TCP 帧 |
|---|---|---|
| 帧头 | 0xAA 0x55 | 事务标识 2B + 协议标识 2B |
| 地址 | 1B 车辆地址 | 单元标识 1B |
| 功能码 | 1B(0x01 走行,0x02 举升…) | 0x03 读 / 0x06 写 |
| 长度 | 1B | 后续字节数 2B |
| 数据体 | 按功能码变化 | 寄存器地址 + 数量/值 |
| 校验 | CRC16-CCITT | Modbus CRC16 |
这张表最容易被忽略的是“长度”域:串口长度只统计数据体,Modbus-TCP 长度不统计事务标识和长度自身。我在现场见过工程师一条条对帧,最后发现是长度算错了一位导致整个报文被下位机丢弃。
3.2 半包与粘包:从 readyRead 到完整帧
QSerialPort::readyRead什么时候触发,取决于驱动缓冲区和数据到达时间,跟协议帧边界没有任何关系。所以 ProtocolStm32 必须在收到字节后先缓存,再按帧头帧尾切帧。
// ProtocolStm32.cpp 中的接收处理(简化) void ProtocolStm32::onDataReady() { m_recvBuffer.append(m_serial->readAll()); int start = 0; while ((start = m_recvBuffer.indexOf(QByteArray::fromHex("AA55"), start)) >= 0) { if (m_recvBuffer.size() - start < 7) { break; // 帧头到长度位至少 7 字节,等下一次 readyRead } int len = (quint8)m_recvBuffer.at(start + 4); // 假设第5字节是数据长度 int totalLen = 6 + len + 2; // 帧头2 + 地址1 + 功能码1 + len + CRC2 if (m_recvBuffer.size() - start < totalLen) { break; // 完整帧未到齐,继续等 } QByteArray frame = m_recvBuffer.mid(start, totalLen); parseFrame(frame); start += totalLen; } if (start > 0) { m_recvBuffer.remove(0, start); } }逻辑说明:外层while是为了处理粘包,一次收到三帧时能够循环切完。两个break都是“数据不足”,但处理方式不同:第一处说明连长度字段都不够,第二处说明长度有了但正文还没到。最后统一remove(0, start)把已消费的前缀清理掉。特别要注意indexOf(from, start)的第二个参数是搜索起点,如果写成了start + 1会跳过重叠帧头,安全做法是找到一帧后直接从totalLen后面继续找,不要从下一个字节开始,因为帧头AA55的第二个字节55也可能是下一帧的第一个字节,但这里start += totalLen已经跳过了完整帧,不会漏。
3.2.1 为什么不能用if (m_recvBuffer.size() > 10)来代替切帧
很多教学代码会简化成“缓冲区大于多少字节就当成一帧”,这在固定长度报文下能跑,但 AGV 通信里功能码不同,数据长度就不同,长度是动态的。用固定阈值会导致两帧拼成一帧,CRC 校验直接失败,然后调试半天以为是波特率不对。所以协议实现里一定要解析长度字段后再判断,而不是盲目等固定字节数。
3.3 Modbus-TCP 请求拼接与 PLC 连接边界
ProtocolPlc 一般持有QTcpSocket,每辆车一个连接。连接参数通常是 PLC IP、端口 502、单元标识。下面是一段读保持寄存器的请求拼接:
// ProtocolPlc.cpp 构建 Modbus-TCP 读请求 QByteArray ProtocolPlc::buildReadRequest(int slaveId, int regAddr, int regCount) { QByteArray req; req.append((char)0x00); req.append((char)0x01); // 事务标识符 = 0x0001 req.append((char)0x00); req.append((char)0x00); // 协议标识符 = 0x0000 req.append((char)0x00); req.append((char)0x06); // 长度 = 6,后面还跟着 6 字节 req.append((char)slaveId); // 单元标识符 req.append((char)0x03); // 功能码 03 读保持寄存器 req.append((char)((regAddr >> 8) & 0xFF)); req.append((char)(regAddr & 0xFF)); req.append((char)((regCount >> 8) & 0xFF)); req.append((char)(regCount & 0xFF)); return req; }这段代码的长度字段最值得讲:0x0006是 2(单元)+1(功能码)+2(寄存器地址)+2(寄存器数量),不包含前面 6 字节的 MBAP 头。如果长度写成 12,PLC 会一直等待剩余字节直到超时。还有事务标识0x0001不是必须从 1 递增,但建议每次请求自增,否则回包和请求无法对应。当一辆车同时发了读状态和写动作两个请求,事务标识就是区分回包的钥匙。
3.4 没有真实下位机时的串口排错步骤
项目到手后先别急着接 AGV。把协议参数先抽出来放到一个配置区,让波特率、数据位、停止位、校验位可以通过配置文件或界面修改。然后用串口助手看主动上报:如果下位机是主动上报模式,打开串口后应该能看到周期性的帧;如果只有倍受模式,就手动发一帧读状态指令,观察回帧是否符合文档。
常见失败现象:发出去有数据,但解析总是 CRC 错,先核对 CRC 算法是 CRC16-Modbus 还是 CRC16-CCITT,两者初始值和多项式不同;如果一打开串口就收到乱码,按 9600/115200/38400 逐个尝试波特率。不要在一个波特率上死磕。注意把setFlowControl(QSerialPort::NoFlowControl)写全,有些默认配置会自动打开硬件流控,导致没有接线 CTS/RTS 时收不到数据。
注意:修改波特率后必须 close 再 open,有些 Qt 版本在串口已打开时直接 setBaudRate 不会立即生效。
4. 叉车、牵引、潜入、升降:AGV 子类状态机与 RFID 触发逻辑
4.1 子类重写 executeAction 的职责边界
ForkAgv、PullAgv、TransferAgv、SubmersibleAgv、LiftingAgv、ArmAgv 本质上是 AgvBase 的“执行器差异”子类。调度层给车下发一个QVariantMap,里面包含动作类型和目标参数,子类负责把动作翻译成对应协议帧。这块很容易做过头:把协议帧的每个字节都暴露到上层,或者把动作判断全写成 if-else 堆在 MainWindow。项目里值得借鉴的是“AGV 自己消化动作,调度层只管目标点”。
// ForkAgv.cpp 执行叉举动作 bool ForkAgv::executeAction(const QVariantMap &action) { QString act = action.value("action").toString(); if (act == "lift") { m_state = AgvState::Waiting; QVariantMap data; data.insert("cmd", 0x02); // 功能码 0x02 对应举升 data.insert("height", action.value("height").toInt()); QByteArray frame = m_protocol->buildFrame(data); if (m_protocol->write(frame) < 0) { m_state = AgvState::Error; return false; } m_pendingAction = act; return true; } return false; }这里有一个时序问题:m_protocol->write()返回成功只说明字节交给了系统发送缓冲区,不表示下位机完成举升。真正的完成标志是回帧里的“动作完成”功能码。所以executeAction不能把状态改回 Idle,要等协议解析回调里确认。否则界面显示“任务完成”,车还没举到位,现场对不上账。
4.1.1 状态机放在 AGV 类里而不是 MainWindow
我曾在一个类似项目里见过调度表驱动状态机,所有车的状态都放在主窗口的 QHash 里,结果车型一多,每个槽函数里都是if (agvType == "Fork") ... else if...。AGV 子类状态机的好处是,车自己知道自己下一步该干什么。调度层只需要发“去哪个点、干什么动作”,出了异常车自己上报。这更贴近真实调度系统里“车属于自己”的模型,或者说,每一辆车都是一个自治代理。
4.2 状态迁移的条件与超时处理
下面的状态迁移表可以作为调试协议时对照:
| 当前状态 | 触发条件 | 下一个状态 | 加载点 |
|---|---|---|---|
| Idle | 收到 assignTask | Moving | 记录起点和终点 |
| Moving | 到达目标点且 RFID 读到站点码 | Waiting | 等待执行动作 |
| Waiting | 动作回帧确认完成 | Idle | 上报任务完成 |
| Waiting | 3 秒无回帧 | Error | 触发重发或人工介入 |
| Error | 收到复位指令 | Idle | 清理任务队列后复位 |
表里“RFID 读到站点码”是 Moving 到 Waiting 的重要佐证。不能只看里程或坐标判断到位,因为轮子打滑、被障碍物挡住都会让编码器失准。地面 RFID 标签是绝对定位锚点,AGV 经过标签时读到当前站点,才允许进入动作阶段。
4.3 RFID 卡号解析与独立串口
RfidBase 不是简单地把串口收到的数据原样转发,它要把读卡器上报的卡号换算成站点 ID。不同厂家读卡器帧格式不同,常见的一种是:帧头 0x02 0x00,数据长度 1B,之后是卡号,最后带一个校验字节。代码里解析时要注意数据缓冲区不能和 STM32 协议共用。
// RfidBase.cpp 解析 RFID 卡号 void RfidBase::onRfidData() { m_rfidBuffer.append(m_serial->readAll()); // 假设帧格式: 0x02 0x00 0x05 卡号4字节 校验1字节 if (m_rfidBuffer.size() >= 9 && m_rfidBuffer.at(0) == 0x02) { QByteArray card = m_rfidBuffer.mid(3, 4).toHex(); m_rfidBuffer.clear(); emit cardRead(card); } }这段代码用了固定>= 9,并且只检查第一字节 0x02,是比较粗糙的写法。更稳妥的是也检查第二字节 0x00,并解析长度字段再做切帧。但作为 RfidBase 的起点,它能解释“为什么 RFID 要单独成类”:帧格式、波特率、读取周期都和 AGV 车身协议无关。如果混在 ProtocolStm32 里,AGV 正常通信时 RFID 串口偶尔来一帧 0x02,就会污染 AGV 的解析状态,导致本来能跑的车突然报错。
4.4 多车路段的分布式占用与超时抢占
项目叫“分布式智能 AGV 调度系统”,这里的“分布式”体现在多个控制器、多辆车、多个串口/TCP 通道并存,而不是微服务集群。多车并发最常见的问题是同一路段被两辆车同时占用。经典解法是路段锁:每辆车移动前先申请路段,调度层把目标路段标记为占用,车离开后释放。
操作顺序是:锁定路段 -> 下发移动指令 -> AGV 上报到达 -> 执行动作 -> 上报离开 -> 解锁路段。如果 AGV 在路段中故障,锁必须超时释放。我在项目里会把超时设为 30 秒,超时后自动置为“异常占用”并告警,而不是一直死锁。这和网络检索里经常出现的 Redis 分布式锁思路是同一套思想,但工业现场更强调超时抢占和人工介入。
这个做法可以总结为:路段表 + 占用状态 + 超时扫描定时器,定时器 1 秒扫描一次,把超时占用改为异常,调度层收到异常后派另一辆车重新执行该任务。
5. 用 QTcpServer 搭一个调度模拟器:验证状态机与通信协议的快速方法
5.1 为什么需要模拟下位机
接不了真实 AGV 时,调度系统的状态机跑不起来,UI 界面也只能看到一堆“未连接”。常见做法是拿串口助手模拟,但串口助手只能手动发帧,没法按收到指令自动回帧。我们可以用 Qt 的QTcpServer写一个轻量模拟服务端,监听 5020 端口,收到调度系统发来的 Modbus-TCP 读/写请求后,自动回一帧合法的报文。这样不用改任何协议代码,就能验证 ProtocolPlc 的parseFrame是否正确。
5.2 模拟 PLC 的代码骨架
// SimAgvServer.cpp 模拟 PLC 自动回帧 #include <QTcpServer> #include <QTcpSocket> class SimAgvServer : public QObject { Q_OBJECT public: explicit SimAgvServer(quint16 port, QObject *parent = nullptr) : QObject(parent) { m_server.listen(QHostAddress::Any, port); connect(&m_server, &QTcpServer::newConnection, this, [this] { QTcpSocket *sock = m_server.nextPendingConnection(); connect(sock, &QTcpSocket::readyRead, this, [sock, this] { QByteArray req = sock->readAll(); // 仅处理读保持寄存器请求,原样返回 6 个寄存器数据 QByteArray resp; resp.append((char)0x00); resp.append((char)0x01); // 事务标识 resp.append((char)0x00); resp.append((char)0x00); // 协议标识 resp.append((char)0x00); resp.append((char)0x05); // 长度:单元1 + 功能码1 + 字节数1 + 数据2 resp.append(req.at(6)); // 单元标识,回显 resp.append((char)0x03); // 功能码 resp.append((char)0x02); // 字节数 resp.append((char)0x00); resp.append((char)0x01); // 寄存器值 = 1,表示空闲 sock->write(resp); }); }); } private: QTcpServer m_server; };这个模拟器回包时事务标识直接回显请求里的值,让客户端能把请求和回包对应上。注意长度字段写的是 0x0005,含义是单元标识 1 字节 + 功能码 1 字节 + 字节数 1 字节 + 数据 2 字节,共 5 字节。如果客户端解析后仍然报错,先检查事务标识是否一致、功能码是不是 0x03,再用 Wireshark 抓 127.0.0.1:5020 的包对比。
5.3 验证状态机的三个观察点
跑通模拟器后,在 MainWindow 里下发一个“从站点 1 到站点 2 举升”的任务,观察三个地方。第一,日志里协议层是否发出了“读状态”请求,收到回帧后有没有被parseFrame正确解析。第二,AgvState是否按 Idle → Moving → Waiting → Idle 变化,如果卡在 Waiting,说明动作完成帧的类型没有对上。第三,路段表是否在车进入时加锁、离开时释放,可以手动在模拟器里延迟回帧来制造超时,检验调度层的超时抢占逻辑是否生效。
这套模拟器同样适合串口:用QLocalSocket或虚拟串口工具创建一对串口,把调度系统指向 COM 对,模拟端写脚本按收到的指令回帧。先让模拟端回一帧合法的“空闲状态”,再逐步增加动作完成帧,直到状态机能连续三步不卡,再去接真车。
本文还有配套的精品资源,点击获取