简介:qtserialport源码是一套面向Qt 4.8.7环境的第三方串口通信类库源码,主要帮助老版本Qt项目实现串口收发与外部设备控制,适合正在维护或升级Qt4桌面及嵌入式应用的开发者。压缩包共148个文件,体积仅408KB,包含39个cpp和38个h核心源码与接口,22个pro及配套pri/prf工程配置用于模块构建,另有qdoc文档、png示意图、UI表单、qrc资源等辅助内容,目录与命名规范,便于按需检索。资源累计已有599人学习关注。源码内不仅实现串口打开、波特率与数据位设置、读写及错误处理等基础操作,还分别覆盖Unix与Windows平台差异,并提供单元测试用例、跨版本变更记录和示例配置,读者可直接编入Qt工程生成串口库,也可基于源码理解串口机制并按需二次开发。对工控上位机、物联网网关等需要与MCU或传感器通信的场景尤为适用。
1. 源码阅读前的准备:为什么要啃 qtserialport
如果只给新同事推荐一个最值得反复读的Qt模块源码,我大概率会把 qtserialport 源码拿出来。这个模块看起来不起眼,但它的代码量控制得非常好,既没有QTcpSocket那套复杂的状态机,也没有QProcess那一堆平台细节交织在一起的纠缠,正好是一个“麻雀虽小、五脏俱全”的范例。
我最初读它是因为一个实际需求:团队里有个跨平台的串口调试工具,跑在Windows和Linux上,但经常出现收包慢一拍、偶发丢字节的情况。大家第一反应是“串口驱动有问题”“USB转串口芯片不靠谱”,后来我抱着怀疑心态直接打开了Qt源码目录里的 qserialport 实现,才发现问题其实出在我们对 readBufferSize 和事件循环配合的理解上。从那之后,我就养成了一个习惯:凡是涉及串口通信的模块,先把源码层面ReadBuffer、Notifier、事件循环这三者的关系理清楚,再去写业务逻辑。
这个源码还特别适合三类人:
- 刚接触Qt的C++开发者,想找一个比HelloWorld有含金量但又不至于劝退的源码精读素材。
- 做嵌入式上位机、工业控制、设备联调的人,需要真正搞清楚串口收发机制,而不是只会调API。
- 想理解Qt平台抽象层的人,qtserialport在不同操作系统下的后端实现方式非常典型,看完基本就懂Qt怎么处理跨平台差异了。
网上直接搜 qtserialport 源码,能找到GitHub官方仓库,也能在Qt安装目录里的Src子目录找到一份。我建议直接看随Qt一起发布的源码版本,因为和你的Qt版本严格对应,不会出现文档与源码版本错位的问题。如果是在Linux下,装好qtbase5-dev和libqt5serialport5-dev后,源码一般在/usr/include/或/usr/src/下,Windows上默认安装在C:\Qt\<版本>\Src\qtserialport。
2. 代码地图与核心抽象:从 QSerialPort 到平台后端
2.1 类层次结构:API层、私有层、平台层
打开 qtserialport 源码,真正的核心文件其实就十几个,主要分布在src/serialport下。从类的关系上看,它是一个非常标准的“公共类 + 私有实现”结构:
QSerialPort:用户直接使用的公共类,对外暴露 open、close、read、write 等接口。QSerialPortPrivate:QSerialPort的内部实现类,处理大部分与平台无关的逻辑,比如错误状态、缓存管理、参数校验。QSerialPortPrivateData:存放各个平台后端共享的数据结构,比如配置参数、波特率、数据位、流控标志等。QSerialPortInfo:负责枚举可用串口,底层分别调用Windows注册表或Linux的sysfs。
我读这个源码时最先标注的就是这三层划分,因为后续所有问题都能归到某一层去解释:比如“为什么我在Windows上打开串口比Linux慢”,是平台层驱动枚举方式不同,不是QSerialPort的逻辑有问题。
值得留意的是,qtserialport在同一份代码里会通过预编译宏区分平台。Windows下编译走的是qserialport_win.cpp,Unix/Linux下走qserialport_unix.cpp。有些模块喜欢把平台差异埋在一堆#ifdef里,读起来很痛苦,但这个模块直接把文件拆成两个,代码清晰很多,也更好维护。
2.2 两个核心枚举与默认配置的坑
阅读源码时,建议先把QSerialPort::BaudRate、QSerialPort::DataBits、QSerialPort::Parity、QSerialPort::StopBits、QSerialPort::FlowControl这几个枚举过一遍,不需要背,但要知道它们并不是纯枚举值,部分枚举还承担了“扩展入口”的作用。
举个我栽过的例子:使用自定义波特率时,如果你直接给setBaudRate(250000),在qtserialport源码里,它会先查找标准枚举值,找不到时走自定义分支,然后把整数直接传给底层termios或DCB结构。但问题在于,不是所有USB转串口芯片在非标准波特率下都能稳定工作,源码不会替你验证这个值是否真的被硬件接受。所以如果你在业务方法上先判断“波特率必须大于1200”,那么在设置非标波特率时,未必能真正生效,必须再打开串口后去getBaudRate()回读确认。源码里确实有底层错误上报,但很多USB转串口驱动在设置失败时只返回一个通用错误,你根本看不出是波特率不支持。
2.3 源码目录里的几个重要文件
我整理了一份简化版“读源码路线图”,给想自己看代码的人一个参照:
| 文件 | 作用 | 阅读优先级 |
|---|---|---|
| qserialport.cpp | 公共接口实现,逻辑最直观 | 第一优先 |
| qserialport_p.h / qserialport.cpp | 私有类,处理公共逻辑 | 重点 |
| qserialport_win.cpp | Windows平台后端实现 | 按需 |
| qserialport_unix.cpp | Linux/macOS平台后端实现 | 按需 |
| qserialportinfo.cpp | 串口枚举逻辑 | 可后期看 |
| qserialportglobal.h | 导出宏和版本定义 | 扫一眼即可 |
先看qserialport.cpp里的构造函数和open(),因为它是用户接触最多的入口,工作方式最直观。私有类里有很多重载函数,不要被名字吓到,很多只是public方法包的壳,真正干活的是底层_q_canRead、_q_startAsyncWrite这一类私有槽函数。
3. 深入代码:打开串口、配置参数与平台差异
3.1 open() 到底做了什么
从源码角度看,调用QSerialPort::open()时,qtserialport先检查你是否设置了设备名,然后进入平台后端open()方法。
Windows后端主要做了这几件事:
- 调用
CreateFile()打开设备句柄,注意这里用了FILE_FLAG_OVERLAPPED,也就是异步I/O标志。 - 获取当前DCB结构,修改波特率、字节大小、校验位、停止位,再调用
SetCommState()应用配置。 - 设置超时时间,初始化
COMMTIMEOUTS。 - 创建
QWinEventNotifier,把串口句柄接到Qt事件循环上。
Linux/macOS后端则是:
open()系统调用打开设备文件。- 用
tcgetattr()获取当前termios配置。 - 设置
cfsetispeed()、cfsetospeed()等设置波特率,配置c_cflag。 - 调用
tcsetattr()应用配置。 - 创建
QSocketNotifier,把设备描述符注册到事件循环。
这块让我最意外的细节是:readBufferSize默认为0,表示“由系统决定”,但Qt内部读取逻辑并不会因为在Windows上设置了COMMTIMEOUTS就完事,它还会在_q_canRead里按你设定的readBufferSize决定单次读多大数据。如果readBufferSize为0,那它会退化成一次读所有可用数据,在高速数据流下可能造成缓冲区膨胀,这也是后文要讲的卡顿隐患之一。
注意:
setReadBufferSize(0)不是“不限制”,而是“不使用Qt内部缓冲”,改为依赖底层驱动策略。在Windows上,这个策略是“每次事件通知都尽可能多读”;在Linux上,它会按termios的VMIN/VTIME行为来。写代码时千万不要以为设0更高效,多数场景下设置合理上限才稳定。
3.2 参数配置的“假成功”现象
源码里setBaudRate、setDataBits、setParity、setStopBits、setFlowControl都走QSerialPortPrivate::set系列方法。它们不是直接调底层接口,而是先尝试打开设备,配置,如果设置失败会返回false,并置QSerialPort::NotSupportedError或UnsupportedOperationError。
但我在实际使用中发现,这些错误往往不会在调用setter时立刻暴露,而是在后续open()或write()时才冒出来。所以建议:配置完参数后,主动用get系列接口回读,并且对比期望值。源码里也是建议先open(),再setXxx(),因为有些平台在串口未打开时setter根本不生效,比如Windows下你必须先有设备句柄才能设置DCB,否则只是把参数暂存在私有数据里。
这里有一个代码层面的技巧:
// 先打开,再设置,最后回读校验 if (serial->open(QIODevice::ReadWrite)) { serial->setBaudRate(QSerialPort::Baud9600); serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::NoParity); serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl); // 回读确认 qDebug() << serial->baudRate() << serial->dataBits(); }如果你在open之前就设置参数,源码里的配置会先存放在SerialPortPrivateData的成员变量中,打开后由平台层尝试恢复,但如果硬件不支持,可能只在打开时失败,且报错不够直观。
3.3 关闭和重新打开的注意事项
qtserialport的close()并不是简单关设备,它会先终止底层的notifier、清空待写入数据、销毁读写缓冲,然后才关句柄。这块的关键点是:如果你在关闭后立即重新open(),必须保证上一次的notifier没有被事件循环里的残余事件再次触发。
源码里是通过QSerialPortPrivate::close()中先delete notifier再关闭句柄来避免这个竞态条件的。但如果你在业务代码里把close()放在槽函数里,而同一时刻还有未处理完的就绪事件,就可能存在一个极其隐蔽的问题:关闭期间收到旧句柄事件。多线程场景下尤其明显,需要在业务线程中做同步,不能只依靠Qt内部锁。
4. 异步读取、同步等待与用户态缓冲机制
4.1 读数据路径:notifier 和 _q_canRead
qtserialport在事件驱动模型中最核心的是私有槽函数_q_canRead()。当串口有新数据到达时,系统notifier触发,最终会调用这个槽函数:
// qserialport.cpp 内部简化逻辑 void QSerialPortPrivate::_q_canRead() { qint64 bytesToRead = readBufferSize ? readBufferSize : 1024 * 64; // 底层读取逻辑,根据平台不同走两套实现 }如果设置了readBufferSize,比如4096,那么每次最多读4096字节进用户态缓冲区。如果你不设置,qtserialport可能一次读很多,在低速串口设备上这没问题,但遇到高频收发或者对延迟敏感的场景,宁可使用QIODevice自带的缓冲机制配合间歇性读取。
最大的坑在于:notifier读数据是被动触发的,它只在事件循环空闲时响应。如果主线程被某个耗时的计算阻塞了,串口数据会在驱动缓冲区里堆积。qtserialport对此没有特殊处理,它不会自己起后台线程去读,事件循环被阻塞,数据就延迟。所以如果你要读高速数据、怕丢包,必须把串口对象搬到一个独立线程里,或者使用waitForReadyRead机制。
4.2 waitForReadyRead 与 waitForBytesWritten 的实现逻辑
waitForReadyRead()是一个同步阻塞API,内部实现是:
- 调用底层poll/等待函数,设置超时。
- 如果指定时间内没有数据,返回false。
- 一旦有数据,立即调用
_q_canRead()读取并返回true。
放到Windows上,它用的是WaitForSingleObject等待串口事件;Linux上用的是poll()。这个API非常有用,但在源码层面看一下就会发现:它只在“单次调用”时有效,不支持“多次连续调用”,且每次调用都要重新等待。如果你依赖while (waitForReadyRead(100))做循环读取,本质上就是轮询,效率低于notifier驱动。
我自己的实践习惯是:优先用notifier + 槽函数接收数据,只有特殊场景(比如命令行小工具一次性读回显)才用waitForReadyRead,否则容易写出逻辑正确但性能不达标的代码。
4.3 用户态缓冲区与 read() 的几次拷贝
QSerialPort继承自QIODevice,read() 是QIODevice的公共接口,它读取的不是设备底层句柄,而是QIODevice内部的字节缓冲区。这意味着你上层readAll()拿到的数据,其实已经经过了一次“驱动→Qt缓冲区→你的变量”的拷贝。源码里有几处隐蔽的memcpy,比如从平台临时缓冲复制到QIODevice缓冲、从QIODevice缓冲复制到用户提供的QByteArray。
这块对性能要求极高的场景就需要警惕:每次readAll都会分配内存。如果你在接收循环里频繁调用readAll,会有大量内存分配开销。更好的做法是复用QByteArray,或者设置readBufferSize后按固定大小read()避免反复分配。
5. 高频踩坑清单:写操作的细节、缓冲清除与错误处理
5.1 write() 不是立即写
源码中write()默认是异步写,数据会先进入写缓冲区,底层notifier在可写时再真正把数据交给操作系统。这个设计的好处是调用write不会阻塞线程,坏处是如果你连续write两次而后马上close,第二次写入的数据可能根本没发出去。
我曾经排查过一个设备偶发不上报的问题,最后定位到是业务代码中调用write()后立即调用flush(),而flush的文档和实现并不完全等价于“把所有数据立刻写入硬件”。在Windows上flush底层调用FlushFileBuffers或PurgeComm,但在某些驱动下这个调用会清掉未发送的数据。如果你必须确保数据发送完毕,建议用waitForBytesWritten()后续处理,或者写入后循环检查bytesToWrite()是否为0,不要直接用flush清理。
5.2 clear() 与 error() 的正确使用姿势
clear()能清空串口接收缓冲区和发送缓冲区,但它有两个副作用:清空时如果底层还有未完整接收的数据,这些数据直接丢失;同时如果读notifier已经触发但槽函数还没执行,clear之后旧事件仍可能把这个“已过期”的数据读进来。源码里实际上会做一层状态检查,但过期的notifier事件在某些平台上还是可能漏进来。
因此如果你的协议有“重置设备”的逻辑,尽量避免在事件循环正忙的时候调用clear,最好的做法是:先暂停读(断开信号连接或设置一个标志位),再clear,再重新连接信号。这个顺序问题容易在新手代码里出现,而且时序不符合预期时特别难复现。
5.3 错误处理:读源码才知道的几种返回值
QSerialPort的错误排查在文档里写得比较简略,但源码里的错误分类非常具体:
DeviceNotFoundError:设备路径不存在,或者权限不足。PermissionError:设备被占用,串口工具没关干净。ResourceError:设备被拔出,驱动出现FIFO溢出。TimeoutError:waitFor系列API超时。NotOpenError:在未打开状态下调用读写。
实际调试时,ResourceError出现的频率比我们想象的高,尤其使用CH340、CP2102这类USB转串口芯片时,休眠或热插拔之后会偶发这个错误。源码里一旦遇到ResourceError,QSerialPort会停止notifier,你需要主动重新open()才能恢复。很多人的代码只处理了读写错误,没有监听errorOccurred信号并执行重连逻辑,导致设备掉线后程序永久“假死”。
6. 源码精读经验:版本差异与后续扩展思路
读 qtserialport 源码时,不同Qt版本之间的差异不能忽略。比如 Qt5 和 Qt6 的枚举错误处理机制略有区别,Qt6 已经把error()信号替换为errorOccurred,底层平台代码也做了一些清理。如果你在网上搜到旧代码,建议先确认你用的版本,再把错误信号相关的部分做适配。
我后来在这个模块基础上做的扩展有两个方向,都可以作为你自己的练习:
- 在 QSerialPort 外层封装一层“串口状态机”,把“未打开、打开中、运行、异常、重连”几个状态统一管理,遇到ResourceError自动重连,这对于无人值守设备特别有用。
- 引入一个轻量的收发协议解析层,比如定长帧、CRC校验、分包重组,解析层放在notifier槽函数之前,保证业务代码只收到完整协议帧,而不是裸串口字节。
关于“源码还能怎么用”,我个人的建议是:不要只把它当工具,试着仿造它的结构写一个自定义设备通信模块。哪怕只写一个虚拟串口后端练手,也能从中学会如何设计一个兼顾跨平台和后端可替换的类结构。这个架构设计能力,比记住某个API参数值值钱得多。
最后分享一个小技巧:读这个源码时,如果用IDE的调试器直接在_q_canRead()里打断点,再配合一个虚拟串口工具发数据,你会把数据从驱动到用户缓冲的整个流向看得一清二楚。我当初就是靠这招彻底弄懂了notifier和事件循环之间的时序关系。建议你也动手试一次。
本文还有配套的精品资源,点击获取