做嵌入式的人都知道,这两年NB-IoT几乎是远程采集类项目的默认答案。低功耗、广覆盖、室内深覆盖能力强,一块电池跑几年,专门为物联网碎片化场景设计。我去年接手了一个中国移动NB-IoT QT采集终端的项目,说白了就是做一个既能本地采集传感器数据、又能通过NB-IoT网络把数据上传到平台的终端设备,同时还要配一个基于Qt的上位机,用来做参数配置、实时波形显示和数据分析。项目做下来最大的感受是,硬件选型也好,上位机架构也好,真正决定项目能不能顺利落地的,往往是那些文档里不写、论坛里翻半天才找得到的坑。这篇文章就把整个项目的技术路线、关键实现和踩坑记录整理出来,给正在做或者准备做类似终端的朋友一个参考。
这个项目的核心场景在工业现场和数据采集,比如水表、气表、环境监测、管道压力监测这类分散布置、供电困难的地方。终端通过传感器采集模拟量或数字量信号,本地用MCU做初步处理,再通过NB-IoT模块接入中国移动物联网平台,把数据推到云端。Qt上位机在这套系统里承担的是“人机交互”的角色:通过串口连接终端,下发采集参数,接收实时数据,在界面上用图表展示波形,甚至把时域信号做FFT变成频域图,方便现场工程师直接分析频谱特征。整个系统分成终端硬件、NB-IoT通信链路、Qt上位机三大块,每一块都有各自的技术难点。
1. 项目整体设计与方案选型
1.1 为什么选NB-IoT而不是Wi-Fi或4G
很多第一次接触这类项目的人会问,为什么不用Wi-Fi或者4G模块,成本也差不多。这里面的逻辑其实很实在。NB-IoT走的是运营商授权频段,工作在License频段,抗干扰能力比Wi-Fi这类非授权频段强很多。更重要的是,NB-IoT的覆盖增益高,比传统GSM多出20dB的链路预算,可以穿透楼层、井盖、地下管廊这类复杂环境,这是Wi-Fi完全做不到的。4G虽然速率高,但功耗也高,对没有外部供电、只能靠电池的设备来说,4G模组的休眠功耗和峰值功耗都是不可接受的。
NB-IoT的速率其实很低,上行峰值也就60kbps左右,不过采集终端上传的数据量本来就不大,一次上报几十字节到几百字节,完全够用。再加上中国移动NB-IoT网络覆盖在全国范围都很成熟,资费也低,一年几十块钱就能搞定,特别适合海量部署的采集场景。
1.2 终端硬件架构
终端硬件的核心是一颗低功耗MCU,我选的是STM32L4系列。这款MCU在低功耗模式下可以跑到微安级别的电流,同时有充足的外设接口,UART、SPI、I2C、ADC一应俱全,很适合做采集终端的灵魂部件。
NB-IoT通信模块用的是移远BC26,这是一个支持中国移动OneNET平台协议的NB-IoT模组,尺寸小、功耗低,最重要的是SDK支持MQTT和CoAP协议,对接云平台非常方便。传感器数据通过MCU的ADC或者数字接口采集进来,MCU做校验和组帧后,通过串口发给BC26,BC26再通过网络发到平台。
这里要提一下终端和Qt上位机的通信接口。调试阶段最容易用的是USB转TTL串口,上位机通过虚拟串口和终端通信。量产出厂时也可以保留一个调试串口,方便现场维护人员用电脑连接终端查看状态。
1.3 Qt在采集终端系统中的角色定位
Qt在这个项目里不是跑在终端上,而是跑在上位机电脑上。一开始也考虑过用Python写上位机,开发速度快,但考虑到后续要做得比较重,要集成串口调试、波形显示、数据存储、协议解析、远程参数配置这些功能,最终选定了Qt和C++。
Qt在这个场景里最大的优势是跨平台。项目验收的时候有时候用户用的是Windows电脑,有时候是麒麟系统或者Ubuntu的国产化电脑,Qt写一套代码,维护成本低得多。再加上Qt的图形视图框架、QCustomPlot这类绘图库生态非常成熟,做波形显示和频域分析非常顺手。
2. NB-IoT通信链路与终端入网实现
2.1 中国移动NB-IoT平台接入流程
终端的入网流程可以拆成三个环节:注册、入网、数据上报。
注册阶段,终端先给NB-IoT模组通电,模组自动搜索网络并驻网。这时候MCU通过串口向BC26发送AT指令,比如查询网络状态用AT+CGATT?,如果可以附着网络,会返回+CGATT:1。刚上电的时候模组搜网需要时间,建议等待5到10秒再查询。
入网之后就是连接到平台。中国移动OneNET物联网开放平台提供了多种接入方式,最常用的是MQTT协议。用BC26接入OneNET时的典型指令流程是:
AT+CMQTTSTART AT+CMQTTACCQ=0,"client_id" AT+CMQTTSSLCFG=0,1 AT+CMQTTCONNECT=0,"tcp://183.230.40.96:1883",60,1,"product_id","auth_info"其中product_id和auth_info是平台创建产品时生成的一对鉴权信息。连接成功后会返回OK。之后数据上报使用AT+CMQTTCONNECT的topic发布指令,数据格式可以自定义,我习惯用JSON,方便平台端做解析。
2.2 数据帧格式与协议设计
采集终端和上位机之间的串口通信,协议一定要设计得严谨。我用的帧格式是:帧头0xAA 0x55,然后是命令字、数据长度、数据区和CRC16校验。CRC我之前用求和校验,后来现场遇到一次数据错乱,排查了很久,换成CRC16之后问题彻底解决了。凡是涉及远程传输的通信协议,校验位不要省,宁可多花几个字节的传输开销。
终端上报数据的标准JSON格式大致是这样的:
{ "device_id": "NB001", "timestamp": 1717056000, "sensors": [ {"type": "temperature", "value": 26.5}, {"type": "humidity", "value": 58.2} ] }这里推荐一个经验:时间戳用Unix时间戳而不是可读字符串,既省流量又方便平台端做时间比对。因为NB-IoT单次传输数据包的限制比较严格,虽然TCP可以发大包,但NB-IoT网络针对小包做了优化,一次上报的包体控制在512字节以内最稳。
2.3 中国移动NB-IoT模块的功耗管理
低功耗是NB-IoT终端的生命线。BC26模组支持PSM(Power Saving Mode)和eDRX两种省电模式。PSM模式下模组在空闲期会进入深睡眠,此时电流只有微安级别,网络侧会暂存下行数据,等终端下次唤醒再下发。
我的做法是:传感器每30分钟采集一次,MCU带着BC26一起唤醒,采集完立即上报,上报完成马上让模组进入PSM。实测下来,一节18650锂电池配一个低功耗传感器,理论续航可以做到一年半以上。如果采集频率更低的场景,比如一天上报两次的水表数据,续航甚至能做到三年以上。
3. Qt上位机整体架构与串口通信
3.1 上位机功能模块划分
Qt上位机我按功能拆成了四个模块:串口通信模块、协议解析模块、数据展示模块、参数配置模块。
串口通信模块用Qt自带的QSerialPort类实现,底层事件循环处理收发,不卡界面。协议解析模块负责把终端上报的原始字节流按协议解析成结构体。数据展示模块用QCustomPlot绘制实时曲线和频谱图。参数配置模块提供表单界面,让用户设置采集间隔、传感器量程、上报地址等信息,然后下发到终端保存。
这四个模块之间用信号槽机制通信,比如串口收到一帧完整数据,就发射一个dataFrameReady(const DataFrame&)信号,协议解析模块接到信号后做解析,再发射parsedDataReady()信号给界面刷新。这种分层的好处是某个模块出问题不影响其他模块,调试起来定位很快。
3.2 串口通信实现的关键细节
Qt串口编程的坑主要在两个方面:一是配置参数,二是粘包处理。
配置参数上,波特率、数据位、停止位、校验位必须和终端固件一致。这个项目里我用的是115200,8N1,和BC26默认串口参数保持一致。QSerialPort::setPortName()在Windows下要用COM口编号,Linux下要用设备名比如/dev/ttyUSB0。
粘包和半包是串口通信最常见的问题。我的做法是维护一个接收缓冲区,每当串口有数据到达就追加到缓冲区,然后循环查找帧头,找到帧头后再判断数据长度是否足够。数据不够就继续等,超出了就截断处理。这里有一个细节:如果缓冲区长时间积累脏数据找不到有效帧头,要清空缓冲区,否则系统会因为脏数据越来越多而卡死。
void SerialWorker::onReadyRead() { QByteArray chunk = port->readAll(); buffer.append(chunk); int pos = 0; while (buffer.indexOf(QByteArray::fromHex("AA55"), pos) != -1) { int headIndex = buffer.indexOf(QByteArray::fromHex("AA55"), pos); if (buffer.size() - headIndex < 4) break; int len = (quint8)buffer.at(headIndex + 2); if (buffer.size() - headIndex < len + 4) break; QByteArray frame = buffer.mid(headIndex, len + 4); processFrame(frame); buffer.remove(0, headIndex + len + 4); pos = 0; } }这个是典型的串口粘包处理逻辑的骨架,实际项目中我还会在processFrame里做CRC校验,校验不过的直接丢弃,顺便给日志模块写一条警告。
3.3 界面布局与用户交互设计
上位机界面我分成了左右两个主区域。左侧是参数配置面板和数据列表,右侧是波形显示区。
参数配置面板用QGroupBox和QFormLayout组合,做出来像一张表单,操作者可以直观地填写采集间隔、传感器类型、阈值上下限,点击“写入终端”按钮下发配置。右边波形显示区默认展示实时时域波形,用一个QComboBox切换显示通道,切换时QCustomPlot重新绘制数据。
考虑到现场操作人员可能不熟悉这个系统,控件上的说明文字要明确,比如“采集间隔(秒)”括号里标注了合法范围,界面上不要出现只能专业人员才看得懂的缩写。
4. Qt下时域转频域与波形绘制
4.1 FFT算法的选型与集成
热词里经常看到“qt时域图转换为频域图”“qcustomplot kissfft时域到频域波形”,这个功能在这个项目里确实很实用,尤其是分析传感器信号中的特征频率时。
Qt本身没有内置FFT算法库,需要在工程里引入第三方库。我对比过几种方案:FFTW功能最强但库体积大,KissFFT非常适合嵌入式场景和桌面端,代码小巧、无依赖,性能也够用。最后选了KissFFT,因为它简单可靠,不会像FFTW那样折腾半天配置。
下载KissFFT源码后,把kiss_fft.c、kiss_fft.h和tools/kiss_fftr.c、tools/kiss_fftr.h加进Qt工程即可。注意它需要C语言编译支持,在.pro文件里加一行CONFIG += console或者确认MSVC/GCC都能编译就行。
4.2 从时域数据到频谱的完整实现
FFT的前提是输入数据必须是等时间间隔采样的,否则频谱会出现严重的频率混叠,结果不可信。所以在上位机里,我先把串口采集到的波形数据按时间戳重采样成等间隔序列,然后对这段序列做FFT。
KissFFT的使用流程分三步:
第一步,初始化变换对象:
int nfft = 1024; kiss_fftr_cfg cfg = kiss_fftr_alloc(nfft, 0, NULL, NULL);第二步,把时域数据拷贝进来,执行正变换:
QVector<float> timeData(nfft); // timeData 填充采样点,略 QVector<kiss_fft_cpx> freqData(nfft / 2 + 1); kiss_fftr(cfg, timeData.data(), freqData.data());第三步,计算幅值谱:
QVector<double> freqAxis(nfft / 2); QVector<double> ampSpec(nfft / 2); double fs = 1000.0; // 采样率 for (int i = 0; i < nfft / 2; ++i) { freqAxis[i] = (double)i * fs / nfft; double re = freqData[i].r; double im = freqData[i].i; ampSpec[i] = 2.0 * std::sqrt(re * re + im * im) / nfft; }这里有个关键细节:频谱幅值要乘以2除以N,否则幅值会变为真实幅值的一半。频率分辨率由采样率 / FFT点数决定,比如采样率1000Hz,FFT点数1024,分辨率约0.98Hz。被测信号的频率如果小于分辨率,谱线会展得很宽,看起来像漏出的信号,这是FFT的固有特性。
4.3 QCustomPlot绘制实时波形与频谱
QCustomPlot是Qt里我用得最顺手的绘图库,MIT协议,没有授权风险,而且性能足够应付实时曲线。
绘制实时波形我用的是QCPGraph,通过setData()传入一个不断更新缓冲区的数据队列。这里要处理缓冲区的长度,我固定保留最近2000个点,超过之后自动丢弃旧数据,不然内存会越涨越多。
void WaveWidget::updateWave(const QVector<double>& newData) { for (double v : newData) { buffer.append(v); if (buffer.size() > maxPoints) buffer.pop_front(); } QVector<double> x(buffer.size()), y(buffer.size()); for (int i = 0; i < buffer.size(); ++i) { x[i] = i / sampleRate; y[i] = buffer[i]; } graph->setData(x, y); xAxis->rescale(); yAxis->rescale(); replot(); }QCustomPlot默认提供鼠标滚轮缩放和右键拖拽平移功能,实测对于频谱分析非常顺手。为了让操作更友好,我又加了坐标轴网格线,还把频域图的横轴切换成了对数坐标,这样低频段和高频段都能看清。
5. Qt开发环境安装、打包与麒麟系统部署
5.1 Qt 5.15.2安装与国内镜像加速
项目里用的Qt版本是5.15.2。这个版本非常经典,模块齐全,网上资料多,稳定性也不错。安装时建议不带Qt Creator的独立安装包,直接下载qt-opensource-windows-x86-64-5.15.2.exe即可。
不过Qt官方下载服务器在国外,裸连下安装包能教你重新做人。解决办法是换国内镜像,我常用清华TUNA和中科大USTC镜像。以中科大的Qt在线安装器为例,安装时打开安装器,在设置里添加https://mirrors.ustc.edu.cn/qtproject/作为镜像源,下载速度直接拉满。
安装时勾选组件有讲究,我通常勾选MSVC 2019 64-bit、MinGW 8.1.0 64-bit、Qt Charts、Qt SerialPort、Qt Multimedia,以及底部的Developer and Designer Tools里的Qt Creator和调试器。不要贪多,勾选太多组件会拖慢安装速度,也用不上。
5.2 发布打包时必须用windeployqt
Qt程序发布到没装Qt的电脑上运行,必须处理动态依赖。用windeployqt这个官方工具:
cd C:\Qt\5.15.2\msvc2019_64\bin windeployqt.exe D:\project\build\release\Collector.exe这条命令会自动把Qt相关的DLL、plugins、qml等依赖拷到exe同目录下。但要注意,如果你用了第三方库,比如QCustomPlot,它只是一个头文件加源文件,不需要额外DLL;如果你用了KissFFT,也只是源码编译,不需要额外拷贝。但如果你在工程里用了别的第三方动态库,windeployqt不会管它,必须手动拷贝。
很多新手运行release版本exe时报“应用程序无法正常启动”或者“no Qt platform plugin could be initialized”,就是这个环节出了错。最大的坑是platforms目录缺失。Qt的Windows平台插件位于plugins\platforms\qwindows.dll,windeployqt会把它拷贝到发布目录的platforms\下。如果手动拷DLL时漏了这个目录,程序一启动就崩。
5.3 麒麟系统离线安装Qt
现场部署环境往往是信创整机,麒麟操作系统是常客。在麒麟v10 x86_64上装Qt,我踩过的坑集中在依赖库上。
Qt运行需要一堆xcb相关的图形库,部署时经常报QXcbConnection: Could not connect to display或找不到libxcb-xinerama。解决方法是预先安装一堆系统依赖:
sudo apt-get install -y libxcb-*-dev libxkbcommon-x11-0 libxkbcommon-dev libgl1-mesa-dev libfontconfig1-dev libdbus-1-dev如果现场不能联网,就要提前准备离线deb包,把32位和64位的都下载好再过去装。Qt的安装目录建议放在/opt/Qt5.15.2,然后修改用户的.bashrc把Qt库路径加进环境变量,否则启动时报找不到Qt库。
5.4 串口模块在Linux下部署的权限问题
Qt串口程序在麒麟或Ubuntu上还有一个绕不开的坑:当前用户没有访问/dev/ttyUSB0的权限。表现为打开串口成功,但读不到数据。解决办法是把用户加入dialout组:
sudo usermod -a -G dialout $USER然后注销重新登录生效。如果还是不行,检查一下串口设备是否被ModemManager这个服务占用。这个服务会去抢3G/4G上网卡和串口设备,用sudo systemctl stop ModemManager禁掉就清爽了。
6. 常见问题与排查技巧实录
6.1 串口与日志排查三板斧
调试采集终端时,我把一套行之有效的排查流程做了标准化,这里分享给读者。
第一,串口调试助手打底。不管Qt上位机写得有多顺手,排查底层问题时先用第三方串口助手直接连终端看原始数据,排除上位机解析逻辑的影响。我常用的工具是友善串口调试助手,简单、稳定,实测不乱码。
第二,日志级别要分明。Qt上位机的日志我用qDebug输出到控制台,同时用qInstallMessageHandler重定向到日志文件。线上跑的时候关掉调试日志,保留警告和错误等级,可以显著减少日志文件体积。
第三,善用QCustomPlot的实时刷新看数据连续性。如果曲线断断续续,大概率是串口数据丢帧或者协议解析帧长不对,检查波特率和校验位。
6.2 Qt崩溃问题排查
热词里有“qt崩溃”这一项,说明大家对这个高度共鸣。本项目里我遇到过几次Qt崩溃,归纳下来无非这几类:
第一类,跨线程操作UI控件。串口读数据是在子线程里,如果在子线程里直接调ui->widget->update(),轻则界面卡住,重则程序崩溃。正确的做法是通过信号槽跨线程通知UI线程刷新。Qt的信号槽默认支持跨线程队列连接,只要你确保连接方式是Qt::QueuedConnection。
第二类,QCustomPlot在频繁调用replot()时崩溃。解决方法是设置setNotAntialiasedElements(QCP::aeAll),关闭抗锯齿,能明显提升刷新速度;再配合定时器合并重绘频率,比如每50ms刷新一次,避免每收到一帧数据就刷一次。
第三类,FFT时数组越界。kiss_fftr在点数不是2的整数次幂时会异常。我封装FFT函数时加了个断言:
Q_ASSERT((nfft & (nfft - 1)) == 0);不是2的幂就直接不给过。
6.3 网络协议对接的常见报错
NB-IoT模块上云对接时,最容易遇到的报错是平台返回连接拒绝或者鉴权失败。排查要点如下:
连接拒绝,检查APN设置是否正确。中国移动NB-IoT的APN一般是cmiot,通过AT指令设置:
AT+CGDCONT=1,"IP","cmiot"鉴权失败,检查产品ID、access_key是否填对。OneNET平台的产品ID可以在平台控制台查看,鉴权信息是创建产品时生成的,复制的时候注意别带上空格。
如果上报数据到平台后,平台侧看不到数据,检查topic对不对。OneNET平台的数据流topic规则和设备的自动订阅策略有关,要确保设备侧发布到正确的topic,平台侧的数据流名称也要和上报JSON里的key一致,否则数据会被平台丢弃。
6.4 Qt HTTP请求的POST问题
热词里还有“qt post请求 无法获取”和“qt request method 'post' not supported”,这个场景一般是用Qt做小程序后端或者数据管理系统时遇到的。排查方法经验如下:
先看服务端日志。如果是 “Request method 'POST' not supported”,基本是Spring MVC这类服务端没写对应的POST接口或路径不对,返回了405。很多Qt新人以为是Qt的问题,实际上是接口路径搞错了。
再看请求头。用QNetworkAccessManager发POST请求,必须设置Content-Type头。如果服务端接收的是JSON格式,要设置application/json:
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");最后检查发送的数据。Qt的QNetworkAccessManager::post(request, data)的data可以是QByteArray或QHttpMultiPart。如果是JSON,直接用QJsonDocument::toJson()转成字节数组发送即可,不要再手动拼字符串转义。
7. 技术选型复盘与后续扩展
7.1 为什么Qt能撑起这个项目
整套系统跑下来,我对Qt在物联网数据采集领域的位置有了更清晰的认识。Qt不是万能的,但在这种需要跨平台、界面交互丰富、还要集成绘图和网络通信的场景里,它确实是最顺手的选项之一。C++的性能保证FFT和大量波形数据刷新不卡,Qt的信号槽让线程间通信清晰,QCustomPlot的绘图性能也经住了现场长时间运行的考验。
热词里有人问“qt creator vs vs code:2024年qt开发ide终极选择指南”,我的建议很简单:做Qt项目就用Qt Creator,除非你有很特别的理由要待在VS Code的生态里。Qt Creator自带的Qt Designer可视化设计界面,拖拽控件效率极高。VS Code写Qt也不是不行,但配置编译环境、调试器、CMake工具链的过程会让你的耐心断送在第一步。
7.2 项目后续可扩展的方向
这个项目如果继续往下做,有几个方向我很想尝试。
一个是在终端侧加边缘计算能力。NB-IoT的带宽有限,如果把原始波形都传到云端做FFT,流量成本太高。更好的做法是终端MCU里直接集成一个轻量FFT函数库,在本地算好频谱特征值,只把特征值上报平台,这样流量开销能降低一个数量级。KissFFT本身就支持很轻量的部署,完全可以在STM32L4上跑。
另一个方向是Qt上位机里加数据回放功能。当前实现是实时显示,再加一个离线数据文件的回放模块,就能对历史数据做二次分析。QCustomPlot支持导出数据,把采集到的原始数据和频谱结果存成CSV或数据库,回放时读取序号逐帧绘制即可。
第三个方向是远程升级。NB-IoT模组支持FOTA升级协议,MCU固件可以通过平台远程下发。Qt上位机端可以扩展一个固件管理界面,操作者上传固件到平台,再对指定设备发起升级。这个功能对设备部署在野外、不方便挨个去现场更新的场景非常关键。
7.3 整体开发时间线复盘
这个项目从需求分析到交付,总共用了大概六个月。前两个月做需求分析和硬件平台搭建,中间两个月做NB-IoT通信链路调试和平台对接,后两个月集中做Qt上位机和整机联调。联调阶段是最耗精力的,因为终端端和上位机端的错误经常互相掩盖,最后是靠前面提到的“串口助手打底、分层排查”的方法,把问题一个个剥出来的。
时间线上最容易低估的是信创环境适配。我们前期花了两周时间在麒麟系统上调试Qt环境,各种依赖问题比预想的多。如果项目启动一开始就并行安排一个环境适配小组,会比最后集中突击好很多。
说到最后一个经验,做这样的软硬结合项目,不要只闷头写代码,一定要把串口协议文档和平台接入文档整理成一份团队共享的wiki。这个项目我们早期就因为协议字段说明模糊,浪费了不少联调时间。后期把协议整理成表格,字段名、字节偏移、取值范围都写清楚,双方协作效率明显提升。项目收尾时把文档交付给运维团队,后续维护也没有过高的沟通成本。
这个采集终端项目后续又迭代了两版,Qt上位机的界面和协议也一起演进。我自己最大的体会是:技术栈不管怎么选,稳定的通信链路、清晰的数据协议、好用的调试工具永远是这类项目的三条生命线。先保证这三点,再谈界面的美观和功能的丰富,项目的成功率就会高很多。