QT串口通信中C++20协程实现循环发送与状态控制

📅 2026/7/18 5:46:23
QT串口通信中C++20协程实现循环发送与状态控制
1. 项目概述当QT串口遇上C20协程最近在做一个工控上位机的项目核心需求之一就是通过串口与下位机比如PLC、单片机进行稳定可靠的数据通信。其中有一个典型的场景需要周期性地向设备发送查询指令比如每秒读取一次温度、压力值同时还要能随时暂停这个循环发送比如设备进入维护模式恢复发送以及彻底停止。这个需求听起来简单但用传统的多线程或定时器方式实现代码很容易变得支离破碎状态管理复杂尤其是处理暂停、恢复这种非线性的控制流时。我尝试过用QTimer配合标志位也试过开一个单独的QThread跑循环代码里到处都是if (m_isPaused) { QThread::msleep(100); continue; }这样的判断不仅难看还容易引入竞态条件。直到我把目光投向了C20引入的协程Coroutines。协程的本质是“可以暂停和恢复的函数”这和我们“循环发送、暂停、恢复、停止”的需求简直是天作之合。用协程来管理串口发送的状态机逻辑会清晰得多。于是我决定用QT的QSerialPort和C20的协程来重构这个循环发送模块。目标很明确写一个可读性强、易于维护、能优雅处理暂停/恢复/停止的串口通信组件。这不仅仅是语法糖更是对异步控制流编程范式的一次实践。如果你也在为QT中复杂的异步状态管理头疼或者对C20协程如何落地感到好奇那么这次实践的经验或许能给你一些启发。2. 核心思路与架构设计2.1 为何选择“QT串口 C20协程”组合首先我们需要明确为什么是这两个技术的组合。QT的QSerialPort提供了跨平台的串口抽象其信号槽机制是典型的异步事件驱动模型。当我们调用write发送数据后需要等待bytesWritten信号来确认数据已写入硬件缓冲区或者通过readyRead信号来异步读取数据。传统的做法是连接这些信号到对应的槽函数在槽函数里更新状态或进行下一步操作。对于循环发送任务这就意味着状态是否暂停、是否停止、下一个发送内容需要存储在类的成员变量中由来自不同信号槽的调用共同修改和读取心智负担很重。C20协程提供了一种看似同步、实为异步的代码编写方式。一个协程函数可以在某个co_await表达式处挂起暂停等待某个异步操作完成比如等待串口可写、等待一段时间而不会阻塞调用线程。操作系统线程的调度是抢占式的而协程的调度是协作式的由开发者显式控制挂起点。这正好契合我们的需求我们希望在“发送一次数据”后挂起协程等待一个代表“间隔时间到”或“暂停状态解除”的事件。当事件满足协程从挂起点恢复执行进行下一次发送或退出。将两者结合我们可以设计一个协程函数serialPortLoopSend其内部是一个while循环。在循环体内准备数据并发送。co_await一个代表“发送间隔”的等待器Awaitable。在等待器中我们不仅可以实现定时还可以检查“暂停”和“停止”标志。如果暂停则让等待器持续挂起直到恢复如果停止则让等待器返回一个指示协程结束的值。这样整个循环发送的生命周期运行、暂停、恢复、停止就被封装在了一个线性的协程函数里所有状态变迁都集中在协程的挂起与恢复机制中逻辑一目了然。2.2 整体架构与关键组件设计基于上述思路我设计了以下几个核心组件SerialPortManager(串口管理器)继承自QObject包装QSerialPort。负责串口的打开、关闭、基础配置波特率、数据位等以及提供异步写入的接口。它会持有一个QSerialPort实例并将bytesWritten、readyRead等信号转换为可供协程等待的“承诺”Promise或等待器。这是连接QT信号世界和C协程世界的桥梁。LoopSendController(循环发送控制器)这是整个功能的核心类同样继承自QObject。它包含以下关键成员std::atomicbool m_isStopped停止标志。为true时所有协程应立即退出。std::atomicbool m_isPaused暂停标志。为true时协程应在等待间隔时挂起不进行实际发送。std::chrono::milliseconds m_interval发送间隔。std::functionQByteArray() m_dataGenerator一个可调用对象用于在每次循环时生成要发送的数据。这提供了灵活性可以发送固定数据或动态生成的数据。std::jthread m_coroutineThread(或std::unique_ptrstd::jthread): 一个专门用于运行协程的线程。这是一个关键设计点。因为QT的主线程或UI线程需要处理界面事件如果我们让协程在等待时阻塞尽管是逻辑上的挂起但底层等待器可能涉及阻塞操作会冻结界面。因此我将协程的运行放在一个独立的后台线程中。自定义等待器Awaitable这是协程魔法的核心。我们需要创建一个IntervalAwaitable类或相关类型它知道如何与LoopSendController的暂停/停止状态交互。当协程执行到co_await interval_awaitable时会调用这个等待器的await_suspend和await_resume方法。在await_suspend中我们可以启动一个QTimer单次触发时长为m_interval并将定时器超时信号与恢复协程的句柄coroutine_handle连接。同时定时器触发前会持续检查暂停标志。如果暂停则推迟定时器如果停止则直接恢复协程并告知其停止。协程任务Coroutine Task一个返回std::futurevoid或自定义Task类型的协程函数。函数体内就是那个清晰的while循环依次执行“生成数据 - 异步写入串口 - 等待间隔/检查状态”。这个函数会被提交到m_coroutineThread中执行。整个控制流是用户点击“开始” -LoopSendController启动后台线程并在该线程中启动协程任务 - 协程进入循环 - 每次循环结束co_await自定义等待器 - 等待器内部处理定时、暂停、停止逻辑 - 根据结果决定恢复协程继续循环还是让协程退出 - 协程退出后台线程结束。3. 关键技术点实现与踩坑记录3.1 C20协程基础框架搭建要让一个函数成为协程它的返回值类型必须满足“Promise”协议。为了简化我选择使用cppcoro库第三方中的taskT或者直接使用std::future与std::async配合。但为了更深入地理解和控制我决定自己实现一个最简单的Task类型。这有助于我们看清协程的运作机制。#include coroutine #include exception #include utility struct Task { // Promise类型由编译器根据协程函数返回值查找 struct promise_type { Task get_return_object() { return Task{std::coroutine_handlepromise_type::from_promise(*this)}; } std::suspend_never initial_suspend() noexcept { return {}; } // 启动后立即执行 std::suspend_always final_suspend() noexcept { return {}; } // 结束后挂起便于外部清理 void unhandled_exception() { std::terminate(); } // 简单处理异常 void return_void() {} }; std::coroutine_handlepromise_type handle_; explicit Task(std::coroutine_handlepromise_type h) : handle_(h) {} ~Task() { if (handle_) handle_.destroy(); } // 恢复协程执行 void resume() { if (handle_ !handle_.done()) handle_.resume(); } // 判断是否完成 bool done() const { return !handle_ || handle_.done(); } };这个Task类型非常基础它只管理协程句柄的生命周期。协程函数可以这样声明Task myCoroutineFunction() { ... }。在函数内部我们就可以使用co_await了。注意自己实现生产级别的Task非常复杂需要考虑调度、异常传递、co_await在Task上的适配等。此处仅为教学演示。在实际项目中强烈建议使用成熟的库如cppcoro或者如果逻辑相对简单直接让协程返回std::future并使用std::async在后台线程启动它。3.2 串口异步写入的协程化封装QSerialPort::write是同步的但写入操作本身可能因为缓冲区满而阻塞。更“QT”的方式是异步写入调用write后立即返回通过bytesWritten(qint64)信号来得知写入完成。我们需要将这个基于信号槽的异步模型封装成一个可以被co_await的等待器。这里我采用了一种常见的模式定义一个AsyncWriteAwaiter它在构造时发起异步写入操作并保存一个用于通知完成的std::promise。class SerialPortManager : public QObject { Q_OBJECT public: // ... // 返回一个可被 co_await 的 Awaiter auto asyncWrite(const QByteArray data) { struct AsyncWriteAwaiter { QSerialPort* port; QByteArray dataToWrite; std::promisevoid writePromise; bool await_ready() const noexcept { return false; } // 总是不就绪需要挂起 void await_suspend(std::coroutine_handle awaitingCoroutine) { // 连接一次性的信号写入完成后恢复协程 QObject::connect(port, QSerialPort::bytesWritten, [this, awaitingCoroutine](qint64 bytes) mutable { if (bytes dataToWrite.size()) { writePromise.set_value(); awaitingCoroutine.resume(); // 恢复等待的协程 } }); // 发起异步写入 port-write(dataToWrite); } void await_resume() { // 可以检查 promise 的 future 是否有异常这里简单忽略 writePromise.get_future().get(); } }; return AsyncWriteAwaiter{m_serialPort.get(), data}; } private: std::unique_ptrQSerialPort m_serialPort; };在协程中我们可以这样使用co_await serialPortManager.asyncWrite(someData);。这行代码会挂起当前协程直到对应的bytesWritten信号被触发并且写入的字节数匹配协程才会恢复执行。这就将异步回调“拉直”成了同步形式的代码。踩坑记录1信号连接与生命周期上面的简化代码有一个严重问题QObject::connect连接了一个lambda这个lambda捕获了this指AsyncWriteAwaiter临时对象和awaitingCoroutine。AsyncWriteAwaiter在await_suspend函数返回后就被销毁了但lambda可能还在因为信号连接还存在。这会导致悬空引用程序崩溃。解决方案必须延长Awaiter的生命周期。一种方法是用std::shared_ptr来管理Awaiter让lambda持有它的共享指针。或者更QT风格的做法创建一个继承自QObject的辅助类将信号连接和协程句柄管理起来确保生命周期安全。这是实现中最容易出错的地方之一。3.3 集成暂停、恢复、停止的间隔等待器实现这是本项目的灵魂。我们需要一个等待器它不仅能等待一段时间还能在等待期间响应外部的暂停和停止命令。class IntervalAwaitable { public: IntervalAwaitable(LoopSendController controller, std::chrono::milliseconds interval) : m_controller(controller), m_interval(interval) {} bool await_ready() const noexcept { // 如果已经停止则无需等待直接让协程结束 return m_controller.isStopped(); } void await_suspend(std::coroutine_handle awaitingCoroutine) { m_coroutineHandle awaitingCoroutine; // 启动一个单次定时器超时后尝试恢复协程 m_timer new QTimer(); // 需要设置父对象或妥善管理生命周期 m_timer-setSingleShot(true); m_timer-setInterval(m_interval); QObject::connect(m_timer, QTimer::timeout, [this]() { onTimeout(); }); // 将定时器移到控制器所在的线程通常是主线程去运行 m_timer-moveToThread(m_controller.thread()); QMetaObject::invokeMethod(m_timer, start); } bool await_resume() const noexcept { // 返回false表示协程应该继续循环返回true表示应该退出循环 return m_controller.isStopped(); } private: void onTimeout() { if (m_controller.isStopped()) { // 如果已停止直接恢复协程await_resume会返回true m_coroutineHandle.resume(); } else if (m_controller.isPaused()) { // 如果暂停重新启动定时器继续等待 QMetaObject::invokeMethod(m_timer, start); } else { // 既没停止也没暂停正常恢复协程 m_coroutineHandle.resume(); } } LoopSendController m_controller; std::chrono::milliseconds m_interval; std::coroutine_handle m_coroutineHandle; QTimer* m_timer nullptr; // 注意生命周期管理 };在协程中的使用方式Task loopSendCoroutine(SerialPortManager serial, LoopSendController ctrl) { while (!ctrl.isStopped()) { auto data ctrl.generateData(); co_await serial.asyncWrite(data); // 等待写入完成 bool shouldStop co_await IntervalAwaitable(ctrl, ctrl.interval()); // 等待间隔并检查状态 if (shouldStop) { co_return; // 退出协程 } // 否则继续循环 } }踩坑记录2跨线程与对象生命周期这里引入了QTimer而QTimer必须在拥有事件循环的线程中运行。我们的IntervalAwaitable可能是在后台协程线程中构造的因此必须通过moveToThread和QMetaObject::invokeMethod将定时器的操作派发到正确的线程通常是控制对象所在的线程如主线程。同时QTimer对象的销毁也必须在其所属线程中进行这需要精心设计例如使用QObject::deleteLater。复杂的跨线程交互是QT编程的难点结合协程后更需要谨慎。踩坑记录3协程句柄的存储与恢复std::coroutine_handle是一个不拥有协程状态的轻量级句柄。在await_suspend中我们保存了它以便在未来的某个事件定时器超时中恢复它。必须确保在恢复句柄时协程帧coroutine frame仍然有效。如果协程已经因为外部停止而销毁再恢复句柄会导致未定义行为。因此LoopSendController在发起停止操作时除了设置标志位还需要有机制通知或取消等待器中的定时器并谨慎处理句柄的恢复。4. 完整实现流程与核心代码剖析4.1 LoopSendController 类的完整实现考虑到复杂性我们采用一个相对务实且安全的方案使用std::future和std::async来启动协程并将复杂的跨线程等待器逻辑简化为通过轮询标志位结合std::this_thread::sleep_for实现。这牺牲了一些“纯协程”的优雅但大幅降低了实现难度和风险更适合生产环境。// LoopSendController.h #pragma once #include QObject #include atomic #include chrono #include functional #include future #include memory #include QByteArray class SerialPortManager; // 前向声明 class LoopSendController : public QObject { Q_OBJECT public: explicit LoopSendController(SerialPortManager* serialManager, QObject* parent nullptr); ~LoopSendController(); void setInterval(std::chrono::milliseconds interval) { m_interval interval; } void setDataGenerator(std::functionQByteArray() generator) { m_dataGenerator generator; } public slots: void startLooping(); void pauseLooping(); void resumeLooping(); void stopLooping(); bool isRunning() const { return m_isRunning.load(); } bool isPaused() const { return m_isPaused.load(); } signals: void loopStarted(); void loopPaused(); void loopResumed(); void loopStopped(); void dataSent(const QByteArray data, qint64 timestamp); void errorOccurred(const QString errorString); private: // 协程函数运行在后台线程 void loopingCoroutine(); SerialPortManager* m_serialManager; std::atomicbool m_isRunning{false}; std::atomicbool m_isPaused{false}; std::atomicbool m_stopRequested{false}; std::chrono::milliseconds m_interval{1000}; std::functionQByteArray() m_dataGenerator; std::futurevoid m_coroutineFuture; std::promisevoid m_exitSignal; // 用于通知协程退出 std::futurevoid m_exitFuture; };// LoopSendController.cpp #include LoopSendController.h #include SerialPortManager.h #include thread #include QDebug LoopSendController::LoopSendController(SerialPortManager* serialManager, QObject* parent) : QObject(parent), m_serialManager(serialManager) { m_exitFuture m_exitSignal.get_future(); } LoopSendController::~LoopSendController() { stopLooping(); // 确保析构时停止后台任务 if (m_coroutineFuture.valid()) { m_coroutineFuture.wait(); } } void LoopSendController::startLooping() { if (m_isRunning.exchange(true)) { qWarning() Looping is already running.; return; } m_stopRequested.store(false); m_isPaused.store(false); // 启动后台线程运行协程函数 m_coroutineFuture std::async(std::launch::async, [this]() { loopingCoroutine(); }); emit loopStarted(); } void LoopSendController::pauseLooping() { if (!m_isRunning.load() || m_isPaused.exchange(true)) { return; } emit loopPaused(); } void LoopSendController::resumeLooping() { if (!m_isRunning.load() || !m_isPaused.exchange(false)) { return; } emit loopResumed(); } void LoopSendController::stopLooping() { if (!m_isRunning.exchange(false)) { return; } m_stopRequested.store(true); m_isPaused.store(false); m_exitSignal.set_value(); // 通知协程可能正在等待的future if (m_coroutineFuture.valid()) { m_coroutineFuture.wait(); } // 重置退出信号为下一次启动做准备 m_exitSignal std::promisevoid(); m_exitFuture m_exitSignal.get_future(); emit loopStopped(); } void LoopSendController::loopingCoroutine() { // 这是一个在后台线程中运行的“模拟协程”函数 // 它使用轮询的方式检查状态实现了类似协程暂停/恢复的控制流 try { while (!m_stopRequested.load()) { // 1. 检查暂停状态 while (m_isPaused.load() !m_stopRequested.load()) { // 暂停时短暂睡眠以避免CPU空转同时检查停止信号 std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 也可以使用条件变量进行更高效的等待这里为简化使用轮询 } if (m_stopRequested.load()) { break; } // 2. 生成并发送数据 QByteArray data; if (m_dataGenerator) { data m_dataGenerator(); } else { data QByteArray::fromHex(010300000001840A); // 示例Modbus读取指令 } if (!data.isEmpty() m_serialManager) { bool writeSuccess false; QMetaObject::invokeMethod(m_serialManager, [this, data, writeSuccess]() { writeSuccess m_serialManager-syncWrite(data); // 使用同步写入简化示例 }); // 等待invokeMethod执行完成对于syncWrite它是阻塞的 // 实际项目中这里应使用异步写入并等待但涉及更复杂的跨线程同步 if (writeSuccess) { auto now std::chrono::system_clock::now(); qint64 ts std::chrono::duration_caststd::chrono::milliseconds(now.time_since_epoch()).count(); emit dataSent(data, ts); // 注意发射信号到主线程 } else { emit errorOccurred(Failed to write to serial port); break; } } // 3. 等待间隔时间同时检查停止和暂停信号 auto waitStart std::chrono::steady_clock::now(); while (!m_stopRequested.load()) { auto elapsed std::chrono::steady_clock::now() - waitStart; if (elapsed m_interval) { break; // 间隔时间到退出等待进行下一次循环 } // 在等待期间如果被暂停则进入暂停等待循环 if (m_isPaused.load()) { // 计算剩余等待时间 auto remaining m_interval - elapsed; auto pauseStart std::chrono::steady_clock::now(); while (m_isPaused.load() !m_stopRequested.load()) { auto pauseElapsed std::chrono::steady_clock::now() - pauseStart; if (pauseElapsed remaining) { // 暂停期间消耗完了剩余间隔时间 break; } std::this_thread::sleep_for(std::chrono::milliseconds(50)); } // 退出暂停等待循环后要么是暂停结束要么是停止要么是时间到 // 需要重新计算是否满足总间隔 // 这里简化处理如果暂停后恢复且未停止则重新开始间隔等待符合“暂停即停止计时”的语义 // 另一种语义是“暂停不计时”那么需要更复杂的时间累计计算 waitStart std::chrono::steady_clock::now(); // 重置等待开始时间 continue; } // 未暂停也未到时间短暂睡眠 std::this_thread::sleep_for(std::chrono::milliseconds(10)); } } } catch (const std::exception e) { emit errorOccurred(QString(Coroutine error: %1).arg(e.what())); } m_isRunning.store(false); }这个实现的核心loopingCoroutine函数虽然没用co_await语法糖但通过状态标志和精细的循环睡眠控制模拟了协程“暂停”、“恢复”、“等待间隔”的行为。它将复杂的异步状态机收敛到了一个线性的函数流程中逻辑比分散在多个槽函数里清晰得多。4.2 与QT界面线程的安全交互上述代码中有一个关键点dataSent和errorOccurred信号是在后台线程中发射的。在QT中跨线程的信号发射是安全的得益于QueuedConnection机制但连接到这些信号的槽函数将在接收者对象所在的线程通常是主线程中被调用。这确保了UI更新总是在主线程进行避免了多线程UI访问冲突。对于串口操作syncWrite我们使用了QMetaObject::invokeMethod并阻塞等待结果。这是因为QSerialPort的方法不是线程安全的必须在创建它的对象线程通常是主线程中调用。invokeMethod将调用请求排队到主线程的事件循环并等待其执行完毕。这是一种同步的跨线程调用会阻塞后台线程但对于简单的同步写入操作是可以接受的。对于高性能场景仍然需要实现真正的异步写入等待器。5. 常见问题、调试技巧与优化方向5.1 编译与环境配置问题问题1编译器不支持C20协程。解决方案确保你的编译器版本足够新。MSVC需要2019年16.8版本以上/std:clatest或/std:c20GCC需要10.1以上-stdc20或-stdc2a并添加-fcoroutinesClang需要10.0以上-stdc20并添加-fcoroutines-ts但完整支持可能在更新版本。在QT的.pro文件中添加CONFIG c20。问题2链接错误找不到std::coroutine_handle等相关符号。解决方案某些编译器如GCC可能需要链接额外的库如-lstdcexp。但更常见的是编译器对协程的支持还不完全稳定。如果使用MSVC通常无需额外操作。如果遇到问题查阅编译器文档或考虑使用cppcoro等第三方库它们可能提供了更好的兼容性。问题3QT信号槽与协程混合编程时程序崩溃或信号无法触发。排查对象生命周期这是最大的坑。确保任何被lambda捕获的QT对象特别是this指针在lambda执行时仍然有效。对于协程中使用的等待器如果其内部使用了QT对象如QTimer必须管理好这些对象的生命周期确保它们在正确的线程被创建和销毁。线程亲和性QObject及其子类有线程亲和性。不能在没有事件循环的线程中创建QTimer也不能在非所属线程中直接调用其方法。使用moveToThread和QMetaObject::invokeMethod进行跨线程操作。事件循环确保对象所在的线程正在运行事件循环QThread::exec()。主线程默认有但手动创建的QThread需要自己启动事件循环。5.2 功能逻辑问题问题暂停后恢复发送间隔的计时不准确是接着暂停前的时间还是重新计时分析这是一个需求定义问题。上述示例代码采用的是“暂停即停止计时恢复后重新开始间隔”的语义在loopingCoroutine中一旦进入暂停循环我们重置了waitStart。另一种常见需求是“暂停不计时恢复后继续用完剩余时间”。解决方案根据你的业务需求选择。如果需要“暂停不计时”则需要在进入暂停状态时记录已经等待的时间elapsed并在恢复后只等待m_interval - elapsed的时间。这需要更精细的时间点记录和计算。问题停止操作后协程/后台线程没有立即退出。排查检查m_stopRequested标志是否被正确设置为true并且loopingCoroutine中的循环条件能及时看到这个变化std::atomic保证了可见性。检查协程是否阻塞在某个无法被中断的操作上比如一个长时间的同步串口写入或者一个没有检查停止标志的sleep。确保在所有的等待循环包括暂停等待和间隔等待中都频繁地检查m_stopRequested标志。使用std::future的wait_for配合停止标志而不是单纯的sleep。5.3 性能与资源优化避免忙等待Busy-waiting示例代码中使用了std::this_thread::sleep_for进行轮询这在低功耗场景下不理想。更好的方式是使用条件变量std::condition_variable或QT的信号槽。例如可以让IntervalAwaitable内部使用一个QTimer并通过QT的事件系统来唤醒而不是在循环中睡眠检查。这能显著降低CPU占用。串口写入性能同步写入syncWrite会阻塞后台线程。对于高速率循环发送这将成为瓶颈。必须实现真正的异步写入等待器如3.2节尝试的方案让协程在写入操作进行时挂起释放线程资源。协程堆栈内存每个协程都有独立的堆栈帧。如果创建大量并发的协程需要注意内存开销。在本场景中通常只有一个循环发送协程所以问题不大。使用更成熟的协程库如之前提到的自己管理协程生命周期和跨线程交互非常复杂。对于生产项目强烈建议使用cppcoro。它提供了taskT,sync_wait,when_all等高级抽象以及集成了std::future和线程池的调度器能极大地简化代码并提高健壮性。例如cppcoro的single_consumer_event可以很好地模拟我们“等待间隔/暂停/停止”的需求。5.4 调试技巧日志输出在协程函数的关键节点循环开始、发送数据前、等待前后、暂停恢复时添加详细的日志输出使用qDebug()注意线程安全。这是理解协程执行流程最直接的方法。断点调试在支持C20协程的调试器如VS2019中可以直接在协程函数内打断点。当协程挂起时调用栈会显示在await_suspend框架内恢复后又会回到协程函数中。这需要一些时间来适应。状态可视化在UI上实时显示m_isRunning,m_isPaused,m_stopRequested等标志的状态有助于确认控制逻辑是否正确。简化验证先在一个纯控制台项目中用std::cout和std::this_thread::sleep_for模拟串口操作验证协程的控制流逻辑。然后再集成复杂的QT和串口部分。最终这个项目让我深刻体会到C20协程并非银弹它引入了一套全新的、更低层次的异步编程抽象。在QT这种已有成熟事件循环模型的框架中使用它就像是引入了一种新的“乐高零件”需要精心设计适配器等待器才能与现有的“乐高体系”信号槽完美拼接。这个过程充满了挑战但一旦打通对于处理此类复杂的、状态驱动的异步流程代码的清晰度和可维护性提升是显著的。它让“暂停”和“恢复”这类操作从一堆散落的状态变量和条件判断变成了语言层面的一等公民这本身就是一种巨大的进步。