Qt硬件交互实战:饮料售货机系统设计与实现

发布时间:2026/9/10 16:06:01
Qt硬件交互实战:饮料售货机系统设计与实现 简介本资源是一套面向计算机类本科生的Qt智能售货系统毕业设计与课程作业完整实现聚焦饮料售卖机场景融合GUI开发、客户端-服务器架构与轻量级AI应用如商品识别逻辑或销售行为分析雏形助力学生掌握跨平台桌面应用开发全流程。压缩包共36个文件含9个.ui界面设计文件、8个.cpp核心逻辑文件、7个.h头文件、2个.pro工程配置文件及配套qrc资源、user工程配置、SQLite数据库UserDatabase.db、README.md说明文档与JPG素材图总大小1.92MB结构清晰模块划分明确含登录、商品管理、用户注册、广告展示等子系统。已有160人学习下载提供可直接编译运行的双端源码vendingsystem_server与vendingsystem_client、完整数据库支持、多UI界面交互逻辑及基础信号槽机制实践案例是深入理解Qt事件驱动编程、多线程协同与小型嵌入式系统软件架构的优质教学范例。1. 用 Qt 做一个能真实投币、出货、显示库存的饮料售卖机不是 Demo是能接硬件跑通的毕设级系统很多同学拿到“智能售货系统”毕设题时第一反应是画几个按钮加个 QLabel 显示“已出货”再配段文字说明“本系统支持扫码支付”。但答辩老师一问“硬币识别模块怎么对接”“电机驱动信号从哪个 GPIO 口发出去”“断电后库存数据存在哪”——当场卡壳。这个标题里的“饮料售卖机”不是 UI 模拟器它隐含三层刚性需求物理交互闭环投币→验证→扣库存→驱动电机→反馈、状态持久化断电不丢库存/交易记录、人机界面可维护售货员能改价格、补货、查日志。Qt 正是少数能同时扛住这三重压力的桌面 GUI 框架QSerialPort 天然支持串口与硬币器/电机控制器通信QSqlDatabase 内嵌 SQLite无需额外服务即可存取千条交易记录QWidget 的信号槽机制让“按下补货键→弹出商品选择窗→写入新库存”逻辑清晰到一行一动作。本文不讲 Designer 拖控件只聚焦如何让 Qt 程序真正咬合硬件节拍、扛住学生实验室环境下的反复插拔和断电冲击。2. 用 QSerialPort 自定义协议实现硬币识别与电机控制的最小可靠链路2.1 为什么不用 USB 虚拟串口或 HID直连 RS232 更稳市面上主流硬币识别器如 CH-880、GMC-2000和步进电机驱动板如 TB6600默认提供 RS232 接口而非 USB CDC 或 HID 设备。学生实验箱常配 USB 转 RS232 小板PL2303 或 CH340 芯片但 Windows 下驱动兼容性差Linux 下权限配置易错。直接使用 Qt 的 QSerialPort 类绕过操作系统 HID 层用原始字节流通信失败时能精准定位是帧头丢失、校验错误还是超时。关键不是“能不能通”而是“通了之后断线重连是否自动恢复”——这决定了你的系统在答辩现场插拔三次线缆后还能不能继续卖可乐。2.2 定义硬币识别器通信协议固定帧长 异或校验假设硬币器返回格式为0xAA 0x01 0x05 0x00 0xFF5 字节其中0xAA帧头0x01设备地址多台设备时区分0x05面值单位分即 0.05 元0x00保留位0xFF前 4 字节异或校验值0xAA ^ 0x01 ^ 0x05 ^ 0x00 0xFFQt 中解析代码如下// coinparser.h class CoinParser : public QObject { Q_OBJECT public: explicit CoinParser(QObject *parent nullptr); void processData(const QByteArray data); signals: void coinDetected(int valueCents); // 发射信号给主窗口更新余额 void coinError(QString errorMsg); private: QByteArray m_buffer; static const int FRAME_LENGTH 5; }; // coinparser.cpp void CoinParser::processData(const QByteArray data) { m_buffer.append(data); while (m_buffer.size() FRAME_LENGTH) { // 查找帧头 0xAA int headPos m_buffer.indexOf(0xAA); if (headPos 0) { m_buffer.clear(); return; } if (m_buffer.size() headPos FRAME_LENGTH) break; QByteArray frame m_buffer.mid(headPos, FRAME_LENGTH); m_buffer m_buffer.mid(headPos FRAME_LENGTH); // 截断已处理帧 // 校验前4字节异或 第5字节 quint8 checksum 0; for (int i 0; i 4; i) checksum ^ static_castquint8(frame[i]); if (checksum ! static_castquint8(frame[4])) { emit coinError(QString(校验失败: %1 vs %2) .arg(checksum, 2, 16, QChar(0)) .arg(static_castquint8(frame[4]), 2, 16, QChar(0))); continue; } int value static_castquint8(frame[2]); // 第3字节为面值 emit coinDetected(value); } }提示processData()必须在串口readyRead()信号槽中调用且不能在槽函数内直接处理业务逻辑如扣库存因为串口数据到达是异步的而库存操作需加锁。此处只做协议解析并发射信号由主窗口的on_coinDetected()槽函数统一调度。2.3 控制电机出货发送 ASCII 指令 等待硬件应答电机驱动板通常接受 ASCII 指令例如M100\n表示“驱动电机1转动100步”。注意\n是必须的结束符且指令后需等待驱动板返回OK\r\n才算执行成功。Qt 中实现带超时等待的同步发送// motorcontroller.h class MotorController : public QObject { Q_OBJECT public: explicit MotorController(QSerialPort *port, QObject *parent nullptr); bool deliverItem(int motorId, int steps, int timeoutMs 2000); private slots: void onReadyRead(); private: QSerialPort *m_port; QEventLoop m_loop; // 用于阻塞等待应答 bool m_responseReceived; QString m_lastResponse; }; // motorcontroller.cpp bool MotorController::deliverItem(int motorId, int steps, int timeoutMs) { QString cmd QString(M%1%2\n).arg(motorId).arg(steps); m_port-write(cmd.toLatin1()); m_port-flush(); // 强制发出 m_responseReceived false; connect(m_port, QSerialPort::readyRead, this, MotorController::onReadyRead); QTimer timer; timer.setSingleShot(true); connect(timer, QTimer::timeout, m_loop, QEventLoop::quit); timer.start(timeoutMs); m_loop.exec(); // 阻塞等待 disconnect(m_port, QSerialPort::readyRead, this, MotorController::onReadyRead); if (!m_responseReceived || !m_lastResponse.contains(OK)) { qWarning() 电机指令超时或未收到OK m_lastResponse; return false; } return true; } void MotorController::onReadyRead() { QByteArray data m_port-readAll(); m_lastResponse QString::fromLatin1(data); if (m_lastResponse.contains(OK)) { m_responseReceived true; m_loop.quit(); } }注意QEventLoop::exec()在主线程中调用会阻塞 GUI因此该类必须在子线程中创建并运行见 3.2 节。此处仅展示协议逻辑实际部署需用moveToThread()将MotorController移入专用线程。3. 用 SQLite 实现断电不丢的库存与交易双表结构设计3.1 两张表够用products存商品元数据transactions记每次出货学生项目常见错误是把所有数据塞进一个 JSON 文件或单张表导致并发写入时崩溃。SQLite 原生支持 WAL 模式Write-Ahead Logging在频繁小写入场景下比文件 I/O 更可靠。建表语句必须包含严格约束-- products 表商品基础信息ID、名称、单价、当前库存、补货阈值 CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_cents INTEGER NOT NULL CHECK(price_cents 0), stock INTEGER NOT NULL DEFAULT 0 CHECK(stock 0), min_stock INTEGER NOT NULL DEFAULT 5 -- 库存低于此值触发告警 ); -- transactions 表每次成功交易时间、商品ID、金额、操作员ID CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT (datetime(now, localtime)), product_id INTEGER NOT NULL, amount_cents INTEGER NOT NULL, operator_id TEXT DEFAULT student, FOREIGN KEY(product_id) REFERENCES products(id) );提示CHECK(stock 0)防止程序 bug 导致库存为负FOREIGN KEY确保交易记录不会指向不存在的商品DEFAULT (datetime(now, localtime))让 SQLite 自动填入本地时间避免 QtQDateTime::currentDateTime()因时区设置错误导致日志时间错乱。3.2 使用 QSqlQuery 绑定参数防 SQL 注入且显式开启事务Qt 的QSqlQuery支持占位符绑定比字符串拼接安全十倍。更重要的是每次出货必须包裹在事务中否则“扣库存成功但写交易失败”会导致钱货两空bool VendingMachine::sellProduct(int productId, int amountPaid) { QSqlDatabase db QSqlDatabase::database(vending); QSqlQuery query(db); // 开启事务 if (!db.transaction()) { qCritical() 无法开启数据库事务; return false; } // 1. 查询商品价格与库存 query.prepare(SELECT price_cents, stock FROM products WHERE id ?); query.addBindValue(productId); if (!query.exec() || !query.next()) { db.rollback(); return false; } int price query.value(0).toInt(); int currentStock query.value(1).toInt(); if (currentStock 0) { db.rollback(); emit outOfStock(productId); return false; } // 2. 扣减库存 query.prepare(UPDATE products SET stock stock - 1 WHERE id ?); query.addBindValue(productId); if (!query.exec()) { db.rollback(); return false; } // 3. 写入交易记录 query.prepare(INSERT INTO transactions (product_id, amount_cents) VALUES (?, ?)); query.addBindValue(productId); query.addBindValue(amountPaid); if (!query.exec()) { db.rollback(); return false; } // 4. 提交事务 if (!db.commit()) { qCritical() 事务提交失败; return false; } emit productSold(productId, price); return true; }注意QSqlDatabase::database(vending)中的vending是连接名必须在程序启动时用QSqlDatabase::addDatabase(QSQLITE, vending)注册且每个线程只能有一个同名连接。若在子线程中访问数据库需在该线程中重新addDatabase并open()。3.3 用 QSettings 存储非结构化配置避开数据库锁竞争价格、管理员密码、串口端口号这些不常变的配置不应塞进 SQLite。QSettings 使用系统原生存储Windows 注册表 / Linux ini 文件读写极快且无锁// 读取串口配置 QString portName QSettings().value(serial/port, COM3).toString(); int baudRate QSettings().value(serial/baudrate, 9600).toInt(); // 写入管理员密码明文存储仅限毕设实际项目必须哈希 QSettings().setValue(admin/password, vending2024);4. 构建可离线运行的 Windows 发布包windeployqt 手动补全 DLL4.1 用 windeployqt 自动拷贝 Qt 运行时但必须指定平台插件路径Qt 5.15.2 MSVC2019_64 版本编译的程序在另一台没装 Qt 的电脑上启动报错Failed to load platform plugin windows本质是qwindows.dll没找到。windeployqt工具能自动复制但需明确告诉它插件位置# 假设 Qt 安装在 D:\Qt\5.15.2\msvc2019_64 D:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe ^ --dir ./dist ^ --plugindir D:\Qt\5.15.2\msvc2019_64\plugins ^ --libdir D:\Qt\5.15.2\msvc2019_64\lib ^ ./build/vendingmachine.exe提示--dir ./dist指定输出目录--plugindir必须指向plugins\platforms\的父目录即plugins否则qwindows.dll会被放到错误路径--libdir确保Qt5Core.dll等核心库被复制。执行后检查./dist/platforms/qwindows.dll是否存在。4.2 手动添加 Visual C 运行时和串口驱动 DLLwindeployqt不会复制 VC 运行时vcruntime140.dll,msvcp140.dll和 USB 转串口芯片驱动如CH341SER.EXE对应的CH341SYS.DLL。必须手动下载并放入./dist目录文件名来源说明vcruntime140.dllVisual Studio 2019 Redistributable 安装目录从C:\Windows\System32复制需管理员权限或从微软官网下载完整安装包解压msvcp140.dll同上C 标准库CH341SYS.DLL南京沁恒 CH341 官网驱动包若用 CH340 转串口需此 DLLPL2303 则需PL2303HXA.dll注意不要复制System32下的 64 位 DLL 到 32 位程序目录用dumpbin /headers yourapp.exe查看程序位数再匹配 DLL 位数。学生常用 MSVC2019_64 编译故需 64 位 DLL。4.3 创建一键启动脚本自动设置 QT_QPA_PLATFORM_PLUGIN_PATH即使 DLL 都放对了Qt 仍可能因找不到平台插件而崩溃。最稳妥方式是启动前设置环境变量echo off set QT_QPA_PLATFORM_PLUGIN_PATH%~dp0platforms start %~dp0vendingmachine.exe将此脚本保存为run.bat与vendingmachine.exe和platforms\目录同级。%~dp0表示批处理所在目录确保路径可移植。双击run.bat即可启动无需用户手动配置环境变量。5. 真实硬件联调必踩的 3 个坑及绕过方案5.1 串口接收丢字节关闭 Windows 快速启动 设置缓冲区超时Windows 10/11 默认开启“快速启动”会导致 USB 设备在休眠唤醒后串口句柄异常表现为QSerialPort::readAll()返回空或截断数据。解决方案控制面板 → 电源选项 → 选择电源按钮的功能 → 更改当前不可用的设置 → 取消勾选“启用快速启动”在 Qt 代码中显式设置串口参数serial-setBaudRate(9600); serial-setDataBits(QSerialPort::Data8); serial-setParity(QSerialPort::NoParity); serial-setStopBits(QSerialPort::OneStop); serial-setFlowControl(QSerialPort::NoFlowControl); serial-setReadBufferSize(1024); // 扩大接收缓冲 serial-setTimeout(100); // 设置 read() 超时避免阻塞5.2 电机驱动板无响应用示波器确认电平Qt 中加 500ms 硬件复位延时TB6600 等驱动板上电后需 200ms 稳定时间若 Qt 程序启动立即发指令驱动板可能未就绪。简单粗暴但有效的方法在MotorController构造函数中插入延时MotorController::MotorController(QSerialPort *port, QObject *parent) : QObject(parent), m_port(port) { // 等待驱动板上电稳定 QThread::msleep(500); }更优方案是发送?指令查询状态但多数教学用驱动板不支持故硬件级延时是毕设阶段最可靠的兜底策略。5.3 SQLite 数据库被占用用 QMutex 保护跨线程访问禁用 GUI 线程直接操作 DB学生常犯错误在QTimer::timeout()槽函数GUI 线程中直接执行QSqlQuery::exec()同时又在串口接收线程中写交易记录导致database is locked错误。正确做法是所有数据库操作封装进QSqlDatabase对象并用QMutex加锁或更彻底将数据库访问封装为QThread子类所有QSqlQuery调用都在该线程中完成GUI 线程通过信号传递请求。class DatabaseWorker : public QObject { Q_OBJECT public slots: void executeQuery(const QString sql, const QVariantList binds) { QMutexLocker locker(m_mutex); QSqlQuery query(QSqlDatabase::database(vending)); query.prepare(sql); for (const auto v : binds) query.addBindValue(v); query.exec(); } signals: void queryFinished(); private: QMutex m_mutex; };最后一行技术内容在main()中创建DatabaseWorker实例moveToThread()至专用线程并用QMetaObject::invokeMethod()从 GUI 线程安全调用其槽函数彻底规避锁冲突。本文还有配套的精品资源点击获取