Qt 串口连接
使用 Qt 开发上位机程序时,经常需要用到串口,在 Qt 中访问串口比较简单,因为 Qt 已经提供了 QSerialPort 和 QSerialPortInfo 这两个类用于访问串口。
使用 QSerialPort
Qt 提供的 QSerialPort 类继承于 QIODevice,也就是说,除了少数几个串口特有的属性需要单独设置外,可以像一般的 IO 设备(最常见的是文件)一样访问串口。
在项目中加入对串口的支持,先在 .pro 项目工程文件中加入
QT += serialport
然后在程序中包含 QSerialPort 的头文件,即可使用该串口类:
// file name: comm.cpp
// class: Comm
#include "comm.h"
#include <QSerialPort>
...
QSerialPort port;
connect( port, &QIODevice::readyRead, this, &Comm::onRead ); // 异步方法,连接 readyRead 信号和数据响应处理槽函数
port.setPortName( "COM1" ); // 设置串口
port.open( QIODevice::ReadWrite ); //读写方式打开
成功打开串口后,当上位机从串口接收到数据时就会发出 readyRead 信号,由 Qt 的事件派遣机制调用响应的 onRead 槽函数,在 onRead 函数中对接收到的数据进行处理即可。
出现问题
Qt 中使用串口比较简单,上下位机通讯是采用的 RS485 协议,当下位机发送数据到上位机时,上位机能够正常接收并处理。
但是在实际使用过程中却发现一些问题:
但如果此时对上位机程序进行操作,如向下位机发送一些指令,将会导致 RS485 线上的电平信号出现异常,导致上位机无法正常收到一些数据,而下位机也没有正确接收到上位机的指令,因此不会回复上位机。
问题的原因在于 RS485 串行协议是半双工协议,即通讯双方无法同时发送数据,若同时使用数据通道,将会导致通道上的电平异常,进而导致数据异常(乱码)。
半双工协议不支持通讯双方同时发送数据,那么只要在发送数据前检测当前串口线路是否被另一方占用,等待串口线路空闲时再发送数据就不会发生冲突。
检测线路是否被占用,在上位机这边处理要方便些,具体做法是实时检测串口,当串口中接收数据时,认为此时不能发送数据;而超过若干 ms 后仍未接收到下位机的数据时,认为此时可以发送数据。
解决方法是,在上述代码中添加一个计时器,超过 5ms (波特率为9600时,5ms 大约可以发送 4 个字节)未接收到数据时认为 RS485 线路空闲。
然而添加计时器后仍然无法获取准确的串口线路状态。并且在示波器上观察的结果显示,上位机发送下位机指令的时机有时会在下位机停止发送数据后 3ms 开始发送数据,有时会在下位机停止发送数据后十多毫秒内开始发送数据,总之上位机发送数据的时机是不受控的。
最开始猜测可能是 Qt 的事件循环机制对于事件处理不及时导致无法实时检测串口线路状态,查阅 QSerialPort (QIODevice)的 API 后,发现接收数据有阻塞的 API waitForReadyRead
,因此尝试使用多线程+阻塞式方式检测串口状态。
多线程与阻塞式
Qt 的多线程较其他的编程语言有些不同,用起来其实非常方便,用法如下:
// file name: commmgr.cpp
// class CommMgr
#include <QObject>
#include <QThread>
...
// obj 必须是 QObject 或其子类的实例指针,并且不能传入 parent 参数,Comm 是
// QObject 的子类并使用了 Q_OBJECT 宏
Comm *obj = new Comm;
// 实例化 thread 时可以传入 parent 参数。
QThread *thread = new QThread( this );
obj->moveToThread( thread )
// 不能直接在主线程中调用 obj 的方法,若直接调用,则该方法将会在主线程中执行
// obj->init();
// 要使 init 方法在子线程 thread 中执行,可以通过 Qt 的元对象提供的 invokeMethod 调用
// 该方法,或者连接某个信号到 obj 的 "init" 槽中,通过发射信号调用 init 槽函数。
QMetaObject::invokeMethod( obj, "init" );
// connect( this, &CommMgr::init, obj, &Comm::init );
需要注意的是要置入子线程的对象 obj
不能设置 parent,否则在运行时就会出现错误提示,另外要注意的是 QIODevice 及其子类只能在例化它的线程中使用,如果 Comm 的构造函数中就例化了串口,那么在运行时也会得到错误提示。所以这里用到了一个 init 函数,在 obj
例化并移动至子线程之后,在子线程中执行 init
方法,这样就能避免运行时出错。
为了控制子线程,这里引入了一个线程管理器类 CommMgr
,并在该管理器中增加与 Comm
相应的信号和槽,以便转发其它组件的信号到 Comm
或从 Comm
中接收数据转发给其它组件。
接下来修改 onRead 槽函数,使用阻塞式方法读取串口数据:
// file name: comm.cpp
// class: Comm
void Comm::onRead() {
QByteArray data;
while( m_serial->waitForReadyRead( m_waitTime ) ) {
data = m_serial->readAll();
emit receiveRawData( data );
data.clear();
}
// 未接收到数据时,认为串口处于空闲状态,可以向串口发送数据
handleQuery();
// 调用 QCoreApplication 的事件处理方法,用于分发其他线程发送的信号
QCoreApplication::processEvents();
}
只要能够从串口中接收到数据,就认为串口被占用,那么就会读取串口中收到的所有数据并发送给其它组件。
只有当 waitFroReadyRead 方法超时后才可以执行查询,向串口发送数据,即 handleQuery()
方法。
对 onRead()
方法的调用在打开串口后,使用 while 循环进行重复调用,因此每次执行完一遍 onRead 方法后,需要调用 Qt 的事件处理方法,否则子线程可能就无法结束。
采用多线程+阻塞式方法对串口后,观察单片机处的串口线路电平发现仍然会出现上下位机通讯时发生冲突的情况。
解决问题
半双工通讯的模式失败后,又采用了 Windows 提供的串口 API 队串口进行监测,并在接受到数据时,打印出时间。调试后发现不论从串口中接收到多少个字节的数据,每一行数据都会相差 16ms 才会被上位机接收到。因此断定,问题的原因既不在于 Windows 系统,也不在于 Qt 的事件循环机制。
由于采用的 USB-RS485 转接线中是将 RS485 中的数据转接到 USB,猜测可能是由于转接线上有延迟导致无法对串口进行实时监测。
查看 Windows 设备管理器,发现 USB-RS485 转接器采用的驱动是 FT232R 驱动,搜索后找到一篇关于该转接器的介绍,notes-on-ftdi-latency-with-arduino 详细的描述了这一问题并且给出了解决方法,其中一种是:进入设备管理器,找到 USB Serial Port 属性 ==> 高级。
将图中红色方框内的延迟计时器的值设为 1即可。
当把串口的延时计时器设为1ms时,实时检测串口的类在发送数据时就不会与下位机冲突。
总结
Qt 的串口类使用起来很方便,一般不需要用到阻塞式的方法 waitForReadyRead,它已经提供了异步非阻塞的信号 readyRead,因此实际上采用 信号+定时器
方式对串口进行实时监测也是有可能的。
为了方便,将适用于全双工模式的串口类和适用于半双工模式的串口类进行抽象,提取了抽象的串口操作基类,最开始认为好像多做了很多事情,后来发现对代码的整体结构和可扩展性都有很大的帮助。增加虚拟串口类用于模拟下位机发送数据时,只需要让虚拟串口类继承抽象通讯基类,实现基类中定义的纯虚方法,在工厂模式下添加虚拟串口类的例化即可。增加网络通讯类时,也可以方便的继承抽象通讯基类(用到的 QTcpSocket 也是继承自 QIODevice),并且不用修改现有代码。
另外值得一提的是 Qt 的多线程机制,虽然和大多数编程语言的多线程有所区别,但是使用起来非常方便,需要注意的是 QThread 的功能更像是一个线程管理句柄,有这个句柄才能对其它线程作出调度。