
简介一套基于QT与C的无线电图形开发源码包面向需要实现频谱图、瀑布图、波形图等实时可视化的嵌入式或通信方向开发者。工程共117个文件包含68个头文件、37个C源文件、6个UI界面文件及2个工程配置等源码结构与类划分清晰便于二次扩展。压缩包仅7.27MB整体轻量适合快速阅读或直接集成。已有900人学习下载。内容覆盖WRSpectroControl、WRWaterfallControl、WROscilloControl等核心控件以及频谱分析、瀑布图数据视图、示波器轴绘制等模块可帮助理解基于QCustomPlot、QChart或QPainter的绘图思路并借鉴信号与槽机制、多线程处理下的实时数据刷新方案。对有基本QT基础、希望将无线电信号可视化落地到自研工具中的开发者是一份实用的参考实现。1. 频谱图、瀑布图、波形图用 QT 自绘无线电图形到底该怎么下手接手无线电相关项目时最头疼的不是算法而是“数据到手了画不出来”。SDR 接收机解调出的 IQ 数据、 spectrum analyzer 采集到的功率谱、甚至超声探伤仪的回波信号最终都要变成屏幕上能看的波形、频谱和瀑布图。市面上的无线电软件GNU Radio、SDR#、URho 等界面各异但核心画图部分大多脱不开 QT 的 QPainter 或 QCustomPlot。原因很简单QT 跨平台、刷新机制成熟、对嵌入式设备树莓派、RK3568 等有现成的交叉编译链。这套“无线电基本图形”方案适合做 SDR 上位机、频谱仪控制软件、雷达信号显示终端的人直接复用如果你只是想在现有界面里加个波形图这里的架构也能剪裁出一半直接用。这套东西的本质并不玄学频谱图是把 IQ 数据做 FFT 后把功率值映射成折线瀑布图是把一连串频谱按时间堆叠成像素条波形图是 I/Q 或解调后数据的实时趋势。难点不在数学而在“怎么保证 30 帧每秒不卡顿、缩放不崩、长时间跑不内存爆掉”。我接过的收音机基带监控项目里团队用 QWidget 硬绘不做任何缓存结果 CPU 占用常年 40% 以上动一下鼠标就掉到 10 帧。后来改成了“块状重绘 离屏像素图 局部 update()”同样的 4 路频谱CPU 从 40% 降到了 12%。下文整个落地路径都围绕这个目标转选对组件、理清线程、写好绘制函数最后用一套避坑清单把你拦在已知的坑外。2. 画图前必做的核心架构QT 绘图的坐标系与刷新机制2.1 用 QPainter 还是 QCustomPlot无线电图形选型依据无线电波形和频谱图跟普通业务报表图表的绘制需求有明显差异数据量极大屏幕宽度就那么多像素但底层数据是连续流、更新频率极高波形图每秒 30 到 60 帧刷新、需要持续滚动且伴随缩放和拖拽。QCustomPlot 用起来舒服因为它的 QCPGraph 和 QCPColorMap 已经帮你处理了坐标轴缩放、抗锯齿、数据重采样也能直接用 setData() 换数据。但它的坑也很明显QCPColorMap 做瀑布图时内存占用高、CPU 开销大尤其是长时间跑频谱显示颜色图的分辨率设置必须克制。做瀑布图我见过不止一次有人把 colorMap 宽度设成 4096结果跑两小时内存直接吃掉几个 GB —— 这是玩脱了不是设计如此。我一般会这么选波形图和频谱图用 QCustomPlot瀑布图绝不碰 QCPColorMap而是用 QPainter 自绘。原因是瀑布图本质是“整体左移一像素然后把最新一条频谱画在最右列”这种逻辑用 QImage 或 QPixmap 的 copy() 加 drawImage() 实现效率远高于 QCPColorMap 的逐点插值。另外QCustomPlot 对超大数据的折线做了 downsampling但也有时候会“自作聪明”地丢峰值导致无线电频点显示偏移。真到那个场景不如直接 QPainter 全权画三角函数频谱、Waterfall 用 QImage 自绘。所以我的选型结论是你项目的核心卖点是频谱细节就自绘核心卖点是多通道软解调那就 QCustomPlot 加一层封装。2.2 五个必调参数从 setRenderHint 到 setSampleRate用 QPainter 画频谱时有五个参数直接决定显示效果和性能。第一是抗锯齿开关setRenderHint(QPainter::Antialiasing) 按需打开缩放时才开平时关掉否则实时刷新会压垮 CPU。第二是 setCosmetic(true) 设置画笔cosmetic pen 不随视图缩放变宽适合画光标线、中心频点线若不设置你放大频谱时那条中心频点线会粗得像根棍子。第三是 viewport 设置QPainter::setWindow() 和 setViewport() 配合可以做到“逻辑坐标显示频率物理坐标显示像素”不改数据只改坐标映射就能完成缩放平移这是重绘效率的关键。第四是画布尺寸QPixmap 在创建时就按屏幕物理尺寸匹配不要每次 resize 就重新分配。第五是 setClipRect()只在 dirty region 内绘制。你会发现绘制代码里有一半工作是在防呆和兜底性能。这几个参数如果你不去动程序也能跑但当你把 16 路频谱图一开CPU 立刻到 100%、界面卡成幻灯片你就知道参数不是玄学是刚需。一个示例配置是这样的QPainter painter(widget); painter.setRenderHint(QPainter::Antialiasing, false); painter.setPen(QPen(QColor(255, 170, 0), 1, Qt::SolidLine)); painter.setWindow(loFreqMhz, -120, hiFreqMhz - loFreqMhz, 120); 其中 loFreqMhz 到 hiFreqMhz 是显示的频率范围-120 到 0 是幅度范围dBm。如果只是把固定数据画一遍这个配置就够了如果要缩放就改 setWindow 的范围再 update()。2.3 把数据流与刷新隔离生产者消费者模式与双缓冲很多人写实时波形图把数据接收、FFT、绘制全部塞在 QT 的 GUI 线程里结果数据稍多就“主线程阻塞、窗口白屏”。标准做法是生产者-消费者模式硬件数据如 UDP 包、串口、libiio 回调在线程 A 被接收放进 ring buffer线程 B或一个 QTimer 驱动的工作线程从 buffer 取数据做 FFT 并转换成绘图需要的 QVector UI 线程只做一件事——在 paintEvent 里把 QVector 画出来。这样三个环节各守一段天然分离。QT 里实现这个分离有两个注意点一是 QVector 跨线程传递时不要直接传引用要用 QSharedPointer 或深拷贝避免一方在写、另一方在画时崩溃二是触发重绘用 QTimer 的 setInterval(30) 或 33ms约 30FPS在超时信号里对 widget 调用 update()而不是每收到一包数据就更新因为数据包频率可能高达 2kHz而屏幕根本画不了那么快。其实代码结构很固定数据接收线程 emit 一个新数据信号绘图线程里用 QueuedConnection 连接把数据拷贝到“待绘制缓存”再更新一个标志位paintEvent 只读缓存。如果你用的是 QCustomPlot那就把数据传给 QCPGraph 的 setData()然后 replot()。当数据量超过一个阈值把所有数据拼接成一个 QVector 再 setData避免一千次小量 setData 调用拖垮绘制。3. 频谱图实现从 FFT 到频谱曲线绘制的完整链路3.1 FFT 长度、窗函数与分辨率的关系频谱图的基础是 FFT。无线电信号进来是 IQ 数据流采样率 sampleRate、FFT 长度 fftSize 和频谱分辨率之间的关系是教科书级的但落地时很容易出问题FFT 的频点间隔是 sampleRate / fftSize它决定了你能否分辨相邻的两个信号如果你要显示 2.4GHz 到 2.5GHz 上百兆带宽而 FFT 长度只有 1024那每个频点间隔约 100kHz窄带信号直接消失。所以第一步是算出需要的 fftSize我一般直接用 2 的幂次从 1024 到 16384 可选给用户一个下拉框。窗函数的选择更关键。矩形窗频率泄漏最大但幅度最精确Blackman-Harris 窗主瓣宽但旁瓣抑制好平坦窗FlatTop适合幅度校准但主瓣特别宽。实际做法是工程频谱分析默认用 Blackman-Harris 4 阶如果是要找弱信号就用 Kaiser 窗可调 beta 参数。这段逻辑应该在代码里留成常量或下拉选项不要写死。FFT 的库用 FFTW 或 KISS FFT 都行但 QT 里注意别在 GUI 线程里执行 FFT——即使 FFT 只要 1ms但 60 帧时每帧都算1ms 也已经占掉 6% 的 CPU 时间。建议在消费者线程里做完 FFT直接把频域结果传给界面。3.2 用 QPainter 绘制多条频谱曲线的实现代码假设你要画一个频谱 widget支持多条曲线叠加比如参考频谱、实时频谱、峰值保持。直接继承 QWidget 重写 paintEvent代码结构按下面这个模板走void SpectrumWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); // 实时刷新关抗锯齿 // 1. 绘制网格背景只在 resize 时清理一次 drawGrid(painter); // 2. 设置逻辑坐标X 轴为频率 MHzY 轴为幅度 dBm double freqStart m_centerFreq - m_span / 2.0; double freqEnd m_centerFreq m_span / 2.0; painter.setWindow(freqStart, m_ampMax, freqEnd - freqStart, m_ampMax - m_ampMin); // 3. 绘制频谱折线m_fftData 为浮点数组长度与屏幕宽度一致非 FFT 点数 QVectorQPointF points; points.reserve(m_fftData.size()); for (int i 0; i m_fftData.size(); i) { double freq freqStart (freqEnd - freqStart) * i / (m_fftData.size() - 1); points.append(QPointF(freq, m_fftData.at(i))); } // 4. 峰值保持曲线灰色虚线 painter.setPen(QPen(QColor(180, 180, 180, 120), 1, Qt::DashLine)); painter.drawPolyline(m_peakHoldPoints.constData(), m_peakHoldPoints.size()); // 5. 实时频谱亮黄色实线 painter.setPen(QPen(QColor(255, 200, 0, 220), 1.2, Qt::SolidLine)); painter.drawPolyline(points.constData(), points.size()); // 6. 中心频率光标线 painter.setPen(QPen(QColor(255, 255, 255, 100), 1, Qt::SolidLine)); painter.drawLine(QPointF(m_centerFreq, m_ampMin), QPointF(m_centerFreq, m_ampMax)); }代码逻辑分四层先画网格建立空间感再设逻辑坐标让后续数值与 GUI 事件坐标转换、鼠标拾取对齐然后按后画的在上层原则叠加曲线和光标线。这里的核心技巧是 m_fftData 的大小不要等于 FFT 点数而是等于画布宽度比如 1000 像素宽就存 1000 个点在数据线程里把 FFT 结果按像素桶取最大值不是平均值——峰值保留才能看到瞬态信号这样 drawPolyline 的开销恒定跟 FFT 长度无关。显示 16 条曲线时这个“像素桶取 max”的策略能省掉大量的重复绘制计算。3.3 频谱缩放的三个交互姿势鼠标滚轮、拖拽平移、峰值保持频谱缩放看起来很爽但实现不能拍脑袋。常见做法是这样鼠标滚轮缩放以鼠标位置为中心。先拿到鼠标在 widget 上的坐标然后转换成当前频率值因为设置了 setWindow直接用映射关系freq windowLeft (mouseX / width) * windowWidth缩放的逻辑是把 span 乘以一个 factor滚轮上 0.8滚轮下 1.25然后重新计算 centerFreq 保持鼠标指向的频率不动。拖拽平移更简单鼠标按下时记住旧的 centerFreq鼠标移动时根据水平位移换算频率增量更新 centerFreq 后 update()。峰值保持是频谱仪刚需做法是维护一个 m_peakArray大小跟像素宽度一样每次新数据画完曲线后把这个像素点上的值跟旧峰值比取最大值存入长按“清除”按钮时把整个数组清零重来。这里有个易踩的坑缩放和平移改的是 setWindow 参数而不是改数据但如果你同时用了 QCustomPlot它的缩放逻辑用的是 range 的缩放对 setWindow 不生效。所以代码里要么坚持以 setWindow 实现要么让 QCustomPlot 的 QCPAxisRect 接管交互。我的经验是如果是频谱图自绘 setWindow 灵活得多如果要加坐标轴刻度自适应QCustomPlot 的 QCPAxis 确实省事。两套方案不混用选一套走下去。4. 瀑布图绘制像素滚动与颜色映射的两种高效方案4.1 一张 QImage 实现时间轴滚动的原理瀑布图本质上是把时间维度的频谱颜色化堆叠横轴频率纵轴时间越靠下越老颜色的明暗表示该频率点的信号强度。最省内存的实现就是一张纵向的 QImage每来一帧频谱数据就把整张图向上滚动一像素行或者向左滚动一像素列取决于瀑布图布局是横滚还是竖滚然后把新数据映射成一条颜色线画到边缘。具体做法是创建一张 width等于频谱数据的像素桶数量x height可配置比如 512 行的 QImage格式用 QImage::Format_RGB32。新数据到来时先把 image 整体向下移动一行image.scroll(0, 1, image.rect())注意 scroll 方向取决于你希望新数据在上边还是下边习惯上是新数据在顶部旧数据往下走。然后通过image.setPixelColor(x, 0, color)或逐行写入像素把新频谱画到最上边。这种实现方式的效率极高因为 scroll() 只是内存拷贝不涉及任何绘制计算。如果你把 QImage 直接嵌入 QWidget在 paintEvent 里用 painter.drawImage(0, 0, m_waterfallImage) 画出来即可不需要做二次变换。4.2 颜色映射的三种模式线性、对数、航空地图色颜色映射是瀑布图观感的灵魂。常见做法有三种线性映射就是把频谱幅度线性映射到 0~255 的灰度适合范围小、噪声底的数据对数映射是 20*log10(value) 线性映射适合动态范围极大的射频信号——同样一个屏幕上要看 -120dBm 底噪和一个 -30dBm 强信号线性映射会把底噪压成全黑对数映射能同时看到两者的细节第三种是伪彩色比如类似航空地图的地形色用查表法把幅度映射成彩虹色或热力图色。QT 里做颜色映射最坑的是 setPixelColor 循环太慢你要用reinterpret_castQRgb*(scanLine(y))来直接写像素而不是调用慢速的 setPixelColor。一次映射要做 1000 次像素写入scanLine 版本只需一次编译优化就能跑满差异肉眼可见。瀑布图还有一个“行列方向”的选择逻辑。频谱仪显示器里瀑布图多是横向滚动最新数据在右侧旧数据向左滚Web 频谱图比如 WebSDR多是垂直滚动最新数据在底部旧数据向上滚。和 UI 布局匹配即可但你要从第一天就定好因为 QImage 的 scroll() 方向和像素写入方向都跟这个强相关中途改方向极易把数据镜像或滚错方向。附一个颜色映射查表的最小示例QRgb colorTable[1024]; // 预计算 1024 级颜色 for (int i 0; i 1024; i) { colorTable[i] qRgb(i * 255 / 1024, 0, 255 - i * 255 / 1024); } // 写入像素时按幅度索引查表 QRgb* line reinterpret_castQRgb*(m_waterfallImage.scanLine(0)); for (int x 0; x width; x) { int idx (m_fftData.at(x) m_ampMin) / (m_ampMax - m_ampMin) * 1023; line[x] colorTable[qBound(0, idx, 1023)]; }这里的参数注意 qBound 一定要做因为 IQ 数据偶尔会出现突刺导致越界不夹紧的话绘制代码会直接崩溃——这个问题尤其在你改幅度范围时突然爆发。4.3 瀑布图内存边界长度、波段切换与重新初始化策略瀑布图的滚动缓存到底该存多长我的建议是默认 512 行到 1024 行不要超过 2048 行因为 4K 分辨率的瀑布图 1024 行已经是 400 万像素加上主体频谱绘制内存占用已经不小。内存可控之外还要处理两个问题一是波段切换时只有当前频谱有意义历史数据全部作废这时候不要试图“平滑滚动清空”直接 resize 或 fill 成全黑归零二是长跑几个小时底噪的值可能漂移温度、AGC 变化如果颜色映射是固定范围那么瀑布图会慢慢变成全黑或全白。基本应对方案是周期性地按底噪中位数自适应调整映射范围每 60 秒在后台线程计算底噪统计值然后更新颜色表索引偏移量避免整个图失去对比度。自适应颜色映射不能每帧做否则强信号会频繁压扁底噪。5. 波形图与帧同步避坑实时波形显示最容易翻车的 4 个地方5.1 现象一帧率上不去CPU 被 drawLine 循环吃光这是“实时波形显示”最常见的问题现象是程序能跑但 CPU 占用极高尤其是把一条 1M 点的波形全用 for 循环 drawLine。原因有三个层面图层没有按需重绘drawLine 被调用的次数远大于屏幕像素数抗锯齿开启了过大的开销。解决方案是强制把数据降采样到屏幕宽度比如屏幕宽 1920那不管底层数据是 10000 点还是 100M 点只取 1920 个点来画具体取法是“每桶取最大最小值连成竖线”这样既不会漏掉毛刺又能让点数恒定。很多新手一开始就踩这个然后去调代码用 QPainter::drawPolyline——其实降采样没做对用什么 API 都没救。5.2 现象二波形拖尾重影刷新后残留旧曲线这算 QT 绘图的经典问题。如果你没有在 paintEvent 里先填充背景比如黑色旧帧的绘制结果会跟新帧叠加波形越来越花最后全屏都是线条看起来像是信号特别密集其实是重影。这个现象在自绘 QWidget 里最典型。解决就是 paintEvent 第一步调用painter.fillRect(rect(), QColor(10, 12, 16))清空画布或者利用setAutoFillBackground(true)让 QWidget 自动填充 QPalette 的背景色。如果你是做频谱和瀑布图混合界面给瀑布图 widget 设一个独立的背景色别让它继承频谱的背景否则内容全糊在一起。重影不是玄学是绝大多数人第一次自绘时忘记清屏。5.3 现象三跨线程 setData 导致崩溃QCustomPlot 的 setData 不是线程安全的。当你在数据接收线程里直接调用 curve-setData 并 replot而 GUI 线程同时在处理重绘崩溃就是概率事件。现象的典型表现是程序正常运行几秒到几分钟随机崩溃在 QCPGraph::draw 或者 QCPAxis::coordToPixel 中而且崩溃时间不可预测。原因是两个线程同时访问同一个 QCPGraph 对象QT 的事件循环根本没有保护这个内部数据结构。解决方法是数据线程只往环形缓冲区写数据通过信号槽触发 GUI 线程里的一个槽函数在槽函数里 setData 并 replot。连接方式需要选 Qt::QueuedConnection不能是默认的 AutoConnection——因为 AutoConnection 在跨线程时会自动转成队列连接但如果你把数据对象用指针传递队列连接在事件循环尚未处理时可能已经被释放。稳妥的做法是数据拷贝成 QVector 后通过信号传递槽函数里用 moveToThread 或者直接接收务必确认这个槽函数执行时没有任何其他线程持有同一份数据的写权限。5.4 现象四波形图 y 轴范围固定信号一进来就削顶这个现象发生频率极高默认的 y 轴范围是 -1 到 1IQ 数据归一化结果信号幅度是 ±0.99画出来基本看不见细节或者信号幅度是 ±5画出来直接被截断。你不会想手动去改每一个数据源的 scale。一个常见的解决方案是引入“自动标度”每次数据进来后对当前缓冲区的最大值、最小值做指数移动平均y 轴范围随信号动态调整。代码大概是这样m_ampScale qMax(m_ampScale * 0.95, maxAbsValue * 0.9);这样信号小的时候 y 轴自动收缩信号变大的时候 y 轴快速展开不会因为一两帧的尖峰导致界面剧烈跳动。自动标度有两种模式一是只扩大不缩小适合看频谱峰值保持二是双向自适应适合音频或解调后的波形。参数要区分开否则用户会被自动缩放的界面搞到晕车。6. 进阶把三个图整合进一个 SDR 控制面板的布局策略当频谱图、瀑布图、波形图都要显示时最容易遇到的坑是“QWidget 嵌套太多导致刷新效率指数级下降”。如果你的界面结构是一个 QMainWindow 里嵌了 QSplitter再嵌 QTabWidget再嵌三个自定义 QWidget那么任何一个控件的 update() 都可能触发整条父子链的部分重绘并且在 QSplitter 拖动时所有子控件同时在重绘帧率直接崩盘。我的经验做法有二一是只在 QWidget 的 paintEvent 里重绘自己绝不调用 parentWidget()-update()也不在 paintEvent 里做复杂的 layout 计算二是用 QTimer 统一以 30FPS 驱动三个图表的 update而不是三个图各用各的定时器——同步刷新视觉上更顺滑而且能避免三个定时器错峰触发产生的撕裂感。定时器槽函数里顺手检查一下当前 tab 是否可见不可见就不 update能省下不少无效重绘。另一个进阶技巧是“简易频谱仪模式”把鼠标悬停在频谱图上时显示一个十字光标和对应的频率/幅度读数鼠标拖拽选中一片区域松开后自动把主频谱缩放到选中区域。这套交互在自绘方案里其实不难在 QWidget 的 mouseMoveEvent 里把鼠标坐标映射成频率值在 paintEvent 里把画好的十字光标和文字画上去。需要留意的是光标读数要画在图上而不是图外坐标要用 painter 的 transform 映射好否则鼠标指向和光标位置会偏。这个功能加到界面里之后操作人员可以直接在软件里找干扰信号不需要额外一台频谱仪。最后说说帧率验证。我在交付 SDR 上位机时会加一个隐藏的 FPS 计数器在 paintEvent 里递增帧数每秒由 QTimer 读一次并显示在窗口标题栏或状态栏。验证标准是三个图同时刷新时 CPU 不超过一个核的 60%帧率稳定在 25 以上。如果帧率不够优先检查是不是开了抗锯齿或者数据线程在 GUI 线程里做了 FFT。我一般会在接收线程跟绘图线程之间加一个“丢弃旧数据”策略当绘图线程来不及处理时直接把最早的半帧数据丢弃保住实时性优先这种体验远好于累积延迟导致界面一直显示“过去的频谱”。这个习惯帮我少调试了很多“为什么界面越来越卡”的问题希望帮到你。本文还有配套的精品资源点击获取