做了几年QT相关的桌面应用开发,接手过不少传感器的上位机项目,最近趁着手头一个机器人项目,把思岚激光雷达和Qt的整套集成流程又完整走了一遍。说实话,思岚的SDK本身不算复杂,但真要把它和Qt的界面、线程、绘制机制流畅地跑起来,中间还是有不少坑的。这篇就把我的实操过程记录下来,从环境搭建到数据可视化,再到性能优化,希望给准备做雷达上位机或者AGV调试工具的朋友一些参考。
1. 项目整体设计与需求拆解
1.1 核心需求:Qt+思岚雷达能做什么
思岚的激光雷达产品线不少,最常见的像RPLIDAR A1、A2、A3,以及S系列如S1、S2、S2L。它们基本都是单线机械式雷达,通过三角测距原理获取周围360度的距离信息,输出的是极坐标格式的点云数据。纯硬件本身只负责扫描,真正要让数据“有用”,必须有一个上位机去完成三件事:
- 建立雷达与控制板之间的串口通信,正确地启动扫描、停止扫描
- 把雷达返回的原始字节流解析成结构化的“角度-距离”数据
- 对数据进行坐标变换与可视化呈现,做成实时地图、避障点云或调试界面
用Qt来做这套上位机有几个天然优势:跨平台、QSerialPort模块稳定、QPainter绘图机制简单直接,而且信号槽机制很适合处理硬件数据的异步推送。整个项目的核心目标就是:打开雷达、出图、能跑、不掉帧。
1.2 硬件连接与型号选型思路
我这次用的主要测试硬件是RPLIDAR A1M8,频率跑在10Hz左右(转速可调),测距半径约12米。选它做demo是因为性价比高、市面上资料多,而且它的协议和A2基本一致,代码逻辑可以平滑迁移到其他型号。如果是更远距离的户外场景,可能就需要A3或者S2这种级别的产品。
连接方式很简单,雷达底部是6Pin接口,分别是5V、GND、串口TX、串口RX,以及MOTOCTL(电机PWM控制)和MOTORPWM(部分型号是MOTOCTL用于控制电机启停)。实际接USB转串口模块就可以了。注意一点:雷达的串口TX接USB转串口的RX,是交叉接法。
1.3 整体架构:模块划分与数据流向
一套完整的Qt雷达上位机,架构上我习惯分成四层:
- 设备层:封装对雷达的指令操作,包括PWM控制、启动扫描、停止
- 数据层:协议解析、数据缓存、坐标变换
- 业务层:避障判断、区域检测、地图构建入口
- 表现层:QPainter绘制的实时扫描界面、参数配置面板、日志输出
数据流向就是典型的“硬件产生数据 → 串口逐字节到达 → 解析线程拼装完整帧 → 发送给主界面 → 绘制线程刷新界面”。串口底层是随时可能有数据来的,所以和UI的交互上,绝对不能直接在串口readyRead信号里干活,否则界面卡到怀疑人生,这个后面细说。
2. Qt开发环境准备与串口通信基础
2.1 开发环境选型建议(含避坑)
关于Qt版本,网上搜索结果里出现大量Qt 5.15.2、MSVC2019、MinGW这些关键词,说明很多人卡在了环境搭建上。我的建议是:在做工业设备和传感器项目时,首选Qt 5.15.x LTS版本,不要一上来就直接用Qt 6。原因很现实——思岚官方SDK从代码风格上偏传统C++,和Qt 6的兼容本身没问题,但很多第三方库、旧工程和教程都是基于Qt 5的,遇到问题查资料时,Qt 5的案例要丰富得多。
编译器选择上,Windows下可以用MSVC2019或者MinGW。你要是需要联动第三方库(比如PCL、OpenCV、CUDA),老老实实用MSVC。如果只是纯Qt程序,不依赖其他乱七八糟的库,用MinGW省心一些。Linux下(Ubuntu 20.04)直接用系统自带的gcc即可,也可以加装QtCreator或VS Code搭配CMake。
安装Qt时有一点很多人会忽略:默认安装不会全选模块。雷达上位机至少需要Qt Charts模块(如果要用曲线图显示距离)和SerialPort模块。用在线安装器时,展开对应版本的Qt条目,一定要勾上Qt Serial Port,否则编译时找不到QSerialPort头文件。
2.2 使用QSerialPort建立与雷达的连接
QSerialPort是Qt提供的跨平台串口库,使用非常直观。和雷达建立连接,核心代码分四步:枚举端口、打开串口、设置参数、发送指令。
#include <QSerialPort> #include <QSerialPortInfo> QSerialPort *serial = new QSerialPort(this); // 枚举所有可用串口,可以用在界面下拉框里 foreach (const QSerialPortInfo &info, QSerialPortInfo::availablePorts()) { qDebug() << info.portName() << info.description(); }打开串口,思岚A1的默认波特率是115200,A2是256000,S系列有不用的波特率。打开失败时不要盲目拔插,先检查串口号和权限,Linux下经常出现Permission denied,需要把你的用户加入dialout组:
sudo usermod -a -G dialout $USERserial->setPortName("COM3"); // Linux下为 /dev/ttyUSB0 serial->setBaudRate(115200); serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::NoParity); serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl); if (!serial->open(QIODevice::ReadWrite)) { qWarning() << "串口打开失败:" << serial->errorString(); }连接建立后,雷达还不会自动发射数据,你必须先通过串口发送指令,让它进入扫描状态。这就是思岚协议里最核心的部分。
2.3 思岚雷达协议基础:启动扫描与数据返回
思岚雷达的通信协议设计得很精炼,所有指令都是通过串口发送16进制字节流。启动扫描的指令很固定,根协议版本不同略有差异。A1/A2常见的启动扫描指令是:
- Stop:
0xA5 0x25 - Reset:
0xA5 0x40 - Scan:
0xA5 0x20 - Express Scan(高速扫描):
0xA5 0x82
发送后雷达首先会返回一个4字节的描述符,格式是:
0xA5 0x5A, 响应长度低字节, 响应长度高字节比如收到A5 5A 05 00,意思是接下来的响应类型长度是5个字节。然后雷达会持续不断地向外推送数据帧。A1的标准Scan模式,每个数据点占用5字节:
- 第1个字节:品质检测值(Quality),高两位是S位,标志当前采样信息是否有效
- 第2-3字节:角度值低字节、高字节,单位是1/64度
- 第4-5字节:距离值低字节、高字节,单位是毫米
所以收到一串字节流01 C8 00 80 0D,解析出来就是:质量1,角度0xC8/64=3.125度,距离0x0D80=3456毫米。
角度是0到360度范围内以0.014度(1/64度)为精度的整数,所以一定要在解析时转成浮点度再参与后续计算。质量位的意义不能忽略:如果S两位为0,表明这一点的测量无效,很可能是因为超出量程或物体表面太黑导致回波太弱,这时要做过滤,不然地图上会出现一堆虚假的“黑洞点”。
3. 思岚SDK集成与数据解析
3.1 官方SDK的获取与编译
思岚官方提供了rplidar_sdk,支持Windows、Linux和macOS,下载后在协议代码里已经封装好了RPlidarDriver类,直接用即可,省去手动处理字节流的麻烦。但SDK本身不强制绑定Qt,它是一套独立的C++库,所以要在Qt里集成需要做两步:编译出库文件,然后在Qt工程里引用头和库。
从GitHub仓库clone下代码后,Linux下编译:
cd rplidar_sdk make编译产物会在output/Linux/Release/下,包括libsl_lidar_sdk.a(或.so)和头文件目录。Windows下用VS打开工程编译,或者用make命令配合MSVC环境也可以。
Qt的.pro文件里这样组织:
INCLUDEPATH += $$PWD/third_party/rplidar_sdk/include \ $$PWD/third_party/rplidar_sdk/include/sl_lidar_driver LIBS += $$PWD/third_party/rplidar_sdk/lib/libsl_lidar_sdk.a这里有一个容易踩的坑:SDK默认的构建目标可能是动态库或者静态库,如果你在Qt里用了RPlidarDriver::CreateDriver(),而链接的是动态库,运行时一定要把对应的.dll或.so文件放在可执行文件目录下。以Windows为例,忘了放sl_lidar_sdk.dll,程序一运行就报找不到入口点。
3.2 用SDK还是自己解析串口数据
这里必须分享一个选择经验。思岚SDK的直接好处是省事,驱动类里面已经实现好了串口通信、指令封装、数据缓存,你调一个grabScanData()函数就能拿到一帧完整的扫描数据。坏处是SDK的API风格比较传统,数据是直接操作结构体指针,和Qt的SIGNAL/SLOT结合起来反而要多做一层拷贝。
我的建议是:如果你的项目主要是面向产品交付,用SDK没错,稳定性和兼容性比自己造的轮子强;如果你是学习研究,或者雷达型号比较特殊想深入协议层,我强烈建议你手动解析一版。原因很简单——只有理解了字节流是怎么拼成帧的,后面遇到数据错乱、角度跳变这类问题才有排查方向。我平时两个方案都维护,产品线用SDK,实验性工具就自己解。
3.3 核心解析与坐标变换实现
不管用哪种方案,最终解析出的数据要转换成方便Qt展示的结构体。建模如下:
struct LaserPoint { float angle; // 角度, 单位: 度 float distance; // 距离, 单位: 毫米 quint8 quality; // 质量 }; struct LaserScan { quint32 timestamp; QList<LaserPoint> points; };手动解析时,收到串口数据后按状态机处理:先找描述符,再进入数据接收。代码逻辑可以封装成一个LidarParser类,核心部分:
bool LidarParser::processBuffer(const QByteArray &buffer) { // 同步字节0xA5 0x5A for (int i = 0; i < buffer.size() - 1; ++i) { if ((quint8)buffer[i] == 0xA5 && (quint8)buffer[i+1] == 0x5A) { // 读取后续4字节描述符... } } // 数据点解析 // pointQuality = raw[0]; // pointAngle = ((raw[2] << 8) | raw[1]) / 64.0f; // pointDistance = ((raw[4] << 8) | raw[3]); // mm }坐标变换就是极坐标转直角坐标,一句话的事:
float radian = qDegreesToRadians(point.angle); float x = point.distance * qCos(radian); float y = point.distance * qSin(radian);注意这里要分清极坐标的0度方向。一般雷达的0度指向雷达正前方,在我这个项目中,雷达是朝机器人正前方安装的,所以直接换算即可。如果安装角度有偏差,需要在转换前加一个固定的角度偏移量。
4. 激光数据可视化——不卡顿的实时绘制
4.1 使用QPainter绘制扫描点云
绘制层是雷达上位机最直观的部分,也是最有成就感的部分。QPainter是Qt里最基础、最轻量的2D绘图方案,和Qt Charts、QGraphicsView相比,它没有额外的场景开销,最适合高频繁刷新的一帧一帧重绘。
绘制思路很简单:
- 重写QWidget的
paintEvent函数 - 对每一帧LaserScan里的所有点,做坐标变换后画一个像素点
- 用
QPainter::drawPoint或drawRect绘制
代码类似这样:
void RadarWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.fillRect(rect(), Qt::black); // 背景网格 drawGrid(painter); painter.setPen(Qt::green); painter.setBrush(Qt::NoBrush); // 地图中心偏置到控件中央 painter.translate(width() / 2, height() / 2); // 比例尺, 假设 1px = 1cm const float scale = 0.1f; painter.scale(scale, -scale); for (const LaserPoint &pt : currentScan.points) { if (pt.distance <= 0 || pt.quality == 0) continue; float ang = qDegreesToRadians(pt.angle); qreal x = pt.distance * qCos(ang); qreal y = pt.distance * qSin(ang); painter.drawPoint(QPointF(x, y)); } }这里用了painter.scale(scale, -scale),把毫米坐标直接映射到控件坐标,而且Y轴翻转了——屏幕坐标系Y轴向下,雷达直角坐标Y轴向上,翻转后画面才是正常的。这一点新手经常忘了,画出来的地图上下颠倒。
4.2 坐标变换:设备坐标、极坐标与窗体坐标的对应关系
再往深一层说,绘制的时候要区分三套坐标系:
- 雷达极坐标系:以雷达为原点,角度(0~360)+距离(毫米)
- 雷达到机器人的直角坐标系:x正前方,y左侧(或者右侧,取决于安装朝向约定)
- Qt Widget窗体坐标系:原点在左上角,x向右,y向下
在paintEvent里,先把极坐标转成雷达直角坐标,再通过translate和scale映射到窗体。如果雷达安装在机器人上,而且机器人本身有自转/位移(比如AGV导航),你还需要一个机器人位姿变换矩阵,把雷达坐标转到全局地图坐标。不过那是SLAM里的事了,做上位机监控时,通常显示雷达相对自身周围的环境即可。
4.3 绘图性能调优:重绘策略与点集缓存
最影响帧率的往往是两处:一是把点转成QPainter的绘制指令太慢,二是QWidget重绘面积太大导致CPU占用高。
QPainter的drawPoint本身是很快的,但如果你一帧有上千个点,而且每个点都走一次drawPoint,性能也不理想。优化办法:把所有点组合成QPainterPath,用painter.drawPoints(points)批量画。QPainterPath的效率要比单个绘制高很多。还可以预先把这一帧的点坐标算好,存成QPolygonF或QPointF数组,规避重复的三角函数计算。
QPainterPath pointPath; pointPath.setFillRule(Qt::WindingFill); for (const LaserPoint &pt : validPoints) { pointPath.addRect(QRectF(x - 0.5, y - 0.5, 1, 1)); } painter.fillPath(pointPath, QColor(0, 255, 0, 180));另外,动态刷新时不要在paintEvent里直接读取还在被其他线程写的共享数据。我常用的做法是把最新一帧的LaserScan拷贝到UI线程的currentScan成员变量中,然后调用update()触发重绘。拷贝整个scan在雷达频率10Hz、每帧360个点时,开销可以忽略不计。
5. 多线程架构设计与界面稳定性
5.1 为什么串口数据不能直接在UI线程处理
这是一个很多Qt新手绕不过去的坎。串口的readyRead信号是随时可能触发的,如果直接在槽函数里做解析、发信号、更新界面,一旦雷达数据量大或者界面有其他耗时操作(比如调整参数、打开对话框),界面就会卡顿。卡顿的根源是UI线程被数据处理占用了,没有及时处理paintEvent和鼠标事件。
我自己调试过最典型的案例:不把串口放子线程,10Hz扫描频率直接导致界面掉帧,点击按钮响应延迟超过2秒;把解析挪到子线程后,界面稳定在60帧,CPU占用还降了。
所以架构上,至少把串口读写和数据解析放到一个独立的worker线程。线程内通过QSerialPort::readyRead或SDK的轮询接口不断拉取数据,解析完一帧之后用信号抛给界面线程。
5.2 线程模型设计:Producer-Consumer模式
我用的是标准的Producer-Consumer模式:
- Producer线程:串口数据到达后,不断读取、拼帧、解析,产生
LaserScan之后通过signal发送 - Consumer线程:通常是主UI线程,收到信号后更新缓存并
update()重绘
如果后续要加SLAM、避障计算,这些重计算的逻辑应该放在第三个线程或线程池中,不要在UI线程里做。
Qt信号槽在这里有个隐含优势:跨线程信号默认是队列连接,会自动把任务投递到接收线程的事件循环中,天然线程安全。所以线程间通信不用加锁,核心数据不共享,只通过信号传递即可。
生产者的关键代码:
class LidarWorker : public QObject { Q_OBJECT public slots: void startScan() { // 启动雷达、死循环读数据 while (m_running) { if (m_driver->grabScanData(&nodes, &count)) { LaserScan scan = convertToLaserScan(nodes, count); emit scanReady(scan); } } } signals: void scanReady(const LaserScan &scan); };UI线程这边:
connect(workerThread, &LidarWorker::scanReady, radarWidget, [=](const LaserScan &scan) { radarWidget->setLatestScan(scan); });5.3 关于QTimer刷新与信号刷新的取舍
有些人会用QTimer每30ms调一次update(),然后在paintEvent里读取最新scan数据。这个方案其实也能用,但有一个隐患:如果雷达频率是10Hz,而QTimer是33Hz,那么有一大半的刷新是无意义的重复绘制;如果雷达是40Hz,而QTimer只有10Hz,那么会丢失数据。所以更准确的做法是“数据来了才刷新”,用信号触发update()。我认为这个方案的实时性更好,可以避免多余绘制。
不过我目前是两者结合:由于10Hz雷达的数据更新率不高,我让UI线程的刷新timer固定25ms一次,保证平滑绘制,同时数据到达时立即更新缓存。这样即使雷达偶尔出现数据抖动,界面也能保持稳定重绘。
6. 常见问题与排查技巧实录
6.1 雷达有输出但Qt界面无数据
这个排在所有问题里的第一名。首先确认雷达在串口调试工具下是否有输出;如果有,就说明问题出在Qt和SDK的集成上。最常见原因依次是:串口波特率设置错误(A1是115200,A2是256000)、没有正确发送启动扫描指令、串口读写模式只打开了只读模式。记得用ReadWrite模式。
另一个隐蔽问题:你调用了SDK的grabScanData(),但没有先检查返回值。SDK返回RESULT_OPERATION_TIMEOUT时代表超时,此时不要中断扫描循环,因为雷达可能在下一次就能正常返回数据。
6.2 串口被占用导致打开失败
Windows下经常出现COM口被占用的情况。关闭上一次调试程序后,COM口并没有被立刻释放,这时再打开串口会报AccessError。解决方法是等待几秒,或者用系统属性里的端口管理确认占用情况。另外,思岚雷达固件版本不同,有些老款需要先上电再打开串口,否则会导致设备枚举异常,重新插拔USB即可解决。
6.3 Qt版本与SDK不兼容引发的编译错误
有人报过“cannot mix incompatible Qt library (5.15.3) with this library (5.15.2)”这类错误,本质是不同版本的Qt模块混用。解决方案很粗暴:把编译SDK时用的Qt工具链和当前项目的Qt工具链保持一致。用MSVC编译SDK就一定要MSVC的Qt库,用MinGW就统一MinGW。同时注意运行时dll目录不要混有多个Qt版本的dll,程序会找不到正确的库版本。
6.4 绘制时地图反向或角度偏转
地图上下左右反了,多半是Y轴翻转没有做,或者是极坐标旋转方向和平常习惯不一样。思岚雷达的角度是按顺时针增加的(从顶部看),如果你希望屏幕上显示的是逆时针方向,可以在坐标变换中取y = -distance * sin(angle)。建议做一个“角度偏移”参数暴露在界面上,方便现场微调安装角度问题。
调试时可以用一个简单方法:拿一个卷尺放在雷达正前方1米处,看屏幕上对应的点是否准确出现在0度方向1米位置。如果差得远,检查角度是否除以了64。
6.5 帧数据出现间断或角度跳变
这通常不是代码问题,而是雷达扫描环境问题。当雷达周围360度里有反光强烈的物体(比如玻璃、黑色吸光物体),某些角度上采样质量会很差,甚至丢失。协议里S位标记的就是这个信息。处理策略是把quality低于阈值的数据过滤掉,或用上一次有效的数据做简单插值。产品级代码中还会做多帧融合去噪。
6.6 Qt的发布与部署
开发完成之后,发布是另一关。Qt程序发布需要带上动态库和插件。Windows下用windeployqt工具,它会把Qt的dll、plugins目录一并拷到发布目录:
windeployqt AppName.exe如果使用了第三方静态库(比如思岚SDK),记得一并拷贝对应的dll。加上串口调试工具、雷达驱动、README,才算一个完整的交付包。
7. 后续扩展思路
7.1 加入区域检测与安全预警
基础数据可视化只是第一步,很多场景需要的是“判断”。可以在雷达扫描数据基础上加一个环形区域检测框:当一个或多个激光点在设定半径内出现时,判定有物体靠近,触发报警。这在AGV防撞、无人机起降场地净空检测等场景里很实用。实现上就是把每个points的distance和预设阈值比较,有连续多帧触发才报警,防误报。
7.2 串口转发与多机联动
有时候雷达安装在机器人上,上位机通过无线与机器人通信。这时可以直接把雷达数据通过TCP/UDP转发给多个客户端。Qt的QUdpSocket很适合做这种广播,一个雷达数据包广播给调度中心、遥控器、监控大屏。数据可以用JSON或者protobuf封装,注意数据大小要控制在网络MTU内,UDP分片会带来乱序问题。
7.3 集成SLAM轻量建图
如果你不想引入庞大的ROS,只用Qt也可以实现一个轻量的栅格地图构建器。思路是把每次扫描的点云按机器人里程计数据进行拼接,对每个点对应的栅格执行命中累加,更新栅格占用概率。虽然达不到工业级Cartographer的效果,但做一个室内的演示性SLAM应用绰绰有余。
最后再多说一句
这套Qt思岚激光雷达的项目,看起来不大,但它把硬件通信、协议解析、界面绘制、异步编程这些桌面软件开发的基本功串了起来。如果你能把这里的每个环节都吃透,以后换任何其他传感器做上位机,思路大同小异。个人体会是:上手之初先别着急上SDK,手写一个最小粒度的串口解析demo,把协议吃透,再往工程里加线程、加绘制,会顺手很多。