简介:面向软件无线电项目的实时频谱分析工程包,覆盖USRP扫频控制、数据采集与频谱可视化全流程,既适合通信工程研究者入门,也可供C/C++程序员参考,对于课设与实战项目均有参考价值。资源共336个文件,以C++源码为主体,含82个hpp、62个cpp、38个h头文件;另有36个sln、35个vcproj工程文件、多平台构建脚本、QCustomPlot图表库实现以及少量dll、exe、pdb等编译产物,压缩包约317MB,工程结构完整,已有225人学习。该工程的价值在于:从USRP设备初始化、采样率设置到频率步进扫频及功率谱密度计算均有清晰的程序组织线索,并包含单元测试、绘图集成、Cygwin/Windows调试批处理等可复用实现。对于需要搭建自定义频谱监测工具、排查多平台编译问题或学习软件无线电信号处理写法的开发者,可作为直接借鉴的工程参考。
1. 扫频信号放进USRP:为什么实时频谱分析不能只靠示波器
做频谱监测或者雷达信号侦测的人,早晚会遇到扫频信号:它的中心频率随时间线性或非线性变化,短促、宽带、难捕捉。把这类信号接到普通频谱仪上,显示窗口里往往只有一条被平均掉的扫迹,看不出瞬时频率轨迹,也拿不到时间-频率对应关系。用USRP接过这个任务后,很多人才意识到:实时频谱分析真正卡住的不是FFT算得多快,而是射频前端的调谐时间、数据搬运的缓冲策略,以及接收机帧率和扫频斜率的对齐。
这篇文章按一条能跑通的主线展开:先交代USRP采集链路里中心频率、采样率、增益这三个旋钮怎么配合,再讲扫频信号在算法侧怎么从IQ变成可判决的频谱曲线,最后落在两条实现路径上,一条用UHD Python写采集循环,另一条用GNU Radio搭流图。中间穿插最常翻车的几个现场,比如频谱图周期性出现整段噪声、瀑布图上信号呈锯齿状、检测漏报,都是我自己调过的坑。适合正在做频谱监测、跳频信号采集、雷达信号分析的从业者,也适合刚把USRP拿到手、想用Python把扫频信号实时画出来的同行。
2. 采集链路先立住:USRP扫频采样的参数设计和数据流形态
2.1 中心频率、采样率和增益三个旋钮怎么配合
USRP本身是宽带射频前端加高速ADC的组合,它不直接输出“频谱”,输出的是经过下变频后的复基带IQ数据。采集链路里三个最基础的参数决定了你后续能看见什么:中心频率决定你监听哪个频段,采样率决定你能观察多宽的瞬时带宽,增益决定信号进ADC之前被放大多少倍。
采样率的选择有一个原则:至少覆盖扫频信号的总带宽,最好留出20%的余量。比如一个扫频范围是2.4GHz到2.42GHz的信号,瞬时带宽20MHz,采样率就别设到正好20Msps,我一般设25Msps,理由是ADC前端的抗混叠滤波器不是理想矩形,边缘频点会有衰减,余量不足时扫到频带边缘的信号幅度会掉下去,检测门限跟着抖动。
增益这个参数最容易被忽视,也最容易变玄学。增益设小了,弱信号埋在噪声里,频谱底噪抬不起来;增益设大了,ADC前端饱和削波,频谱图上出现整段的杂散谐波,看上去像是来了很强的信号。一个可复现的做法是:先设一个中等增益,采集一段纯噪声,观察FFT平均底噪,再逐步增加增益,直到底噪不再明显上升为止。那个临界点就是ADC动态范围的甜点位。下表是我常用的起始参数组合,实际调的时候以检测目标强度为准:
| 参数 | 起始值 | 调整方向 |
|---|---|---|
| 中心频率 | 目标频段中心 | 按扫频信号中心偏移微调 |
| 采样率 | 扫频带宽×1.25 | 带宽不足时加大,观察窗不够时加大 |
| 增益 | 中等增益 | 底噪抬不动时加,出现谐波时减 |
| 天线 | 对应频段的天线 | 频段跨越较大时考虑多天线 |
2.2 扫频模式怎么喂给UHD:定频步进与连续扫的取舍
“扫频”在USRP场景里有两个完全不同的层次,很多人一开始没分清,导致链路设计走偏。第一个层次是接收机自己扫频:中心频率按步进跳变,依次扫描一段超宽频段,比如从100MHz扫到6GHz;第二个层次是目标信号本身在扫频,比如chirp信号、跳频信号,而接收机固定在一个中心频率上,靠采样带宽覆盖住信号扫过的瞬时范围。
如果目标是分析单个扫频信号的频率轨迹,最可靠的做法是定频步进的反面——接收机锁定不动,用一段足够宽的采样带宽把整个扫频范围装进来。比如信号在2.4G到2.42G之间扫,采样率25Msps,中心频率设在2.41G,这样一次采集就能看到完整的扫频轨迹,不需要等待调谐,实时性最好。只有当信号扫频范围超过单片RF前端能支持的瞬时带宽,才考虑把接收机变成步进扫描模式,每跳到一个中心频率就采集一小段,再拼接成宽带频谱。拼接方式在实时分析里有一个明显的坑:每次调谐之后射频前端会有一个短暂的稳定时间,这段时间里采集到的样本幅度和相位都在变化,直接拼进频谱图会出现接缝亮线。
UHD里通过set_rx_freq发起调谐时,调用会返回一个延时,这在同步场景里很重要。调谐命令发出后,本振需要锁定时间,通常几毫秒到几十毫秒,取决于频率跳变幅度。实时系统里要用流命令配合时戳,不能调完频率立刻读数据。我一般会在每次调谐后主动等待一段时间,再启动下一次采集,具体等待时长通过观察瀑布图上的接缝是否消失来标定。
2.3 数据的形态与时间戳:为什么频谱分析要同步射频状态
USRP一次采回来的数据是IQ样本块,数据类型通常是complex float32或者从硬件直接来的complex int16。频谱分析需要的不是单个样本,而是一段样本做FFT。这里有个常见误解:以为只要把采样率设高,就能看到更快的变化。实际上实时性取决于“采集一帧数据需要多长时间”,帧时长等于FFT点数除以采样率。比如FFT取4096点、采样率25Msps,一帧的物理时间只有163微秒,但如果你的显示刷新率只有10帧每秒,中间绝大多数帧都被丢掉了。
这就是时间戳的作用。在连续接收模式里,每一帧IQ数据都带一个接收时戳,时戳基于USRP内部时钟生成。做扫频信号分析时,发射端扫频斜率是已知的,接收端只要知道“这一帧对应的绝对时间”,就能推算当前中心频率位置,和多帧检测结果做时间对齐。我习惯在保存数据时把每帧的中段时间戳写进文件名或者元数据头,否则离线回放时根本对不上哪一段对应频率的哪一段轨迹。
一个实用技巧是:不要从图形界面上做时间对齐,而是直接从RXMetadata里取time_spec,换算成秒,再和扫频信号的理论斜率做差。差值的抖动如果超过一帧时长的一半,说明数据处理链路里存在排队延迟,需要优化消费速度而不是继续调FFT参数。
3. 实时频谱分析的算法侧:从IQ到瀑布图要过几道关
3.1 FFT窗口的选择:矩形窗和汉宁窗在扫频场景下的差异
扫频信号和单音信号不一样,它的能量在一个带宽内连续移动。做FFT时如果直接用矩形窗,相当于对截断信号不做加权,频谱主瓣旁瓣衰减只有13dB,扫频信号扫过时旁瓣会盖住临近频点上的弱信号,整个频谱图看起来像糊了一层噪底。换成汉宁窗之后,旁瓣衰减能到32dB左右,弱信号和强扫频信号同频出现时更容易区分。代价是主瓣变宽,瞬时频率分辨率略微下降。对于检测扫频轨迹这种任务,主瓣变宽是可以接受的,因为我们要的是轨迹连续,不是精确到一个频点。
在实时流水线里,窗口选择要和对齐逻辑一起考虑。每帧数据取中间一段加窗,如果窗口函数选择不当,扫频到帧边缘的信号幅度会被压低,检测门限在每一帧的两端都会抬升。我一般会把帧切分做成50%重叠,配合汉宁窗,这样帧边缘的衰减能和相邻帧的高权重部分相互补偿,轨迹拼接更光滑。
3.2 扫频检测的最小判决:门限、峰值保持和滑窗
FFT输出的是复数频谱,实时分析的第一步是取模得到幅度谱。判决一个频点是否有扫频信号通过,最朴素的做法是能量检测:幅度超过底噪均值加若干dB就判定为信号。这个做法在干净的加性高斯白噪声背景下有效,一旦底噪不平坦或者有窄带干扰,误检率立刻变高。更稳的做法是底噪估计加峰值保持,底噪用中值滤波器滑窗估计,峰值保持用于保留扫频信号在每一帧里的最大值,避免瞬时衰落导致轨迹断裂。
峰值保持不是无脑保存最大值,它需要带遗忘因子。如果设置成永久保持,频谱图上会出现一条从上一次信号开始的残留亮线,看起来像信号一直存在。遗忘因子的含义是每一帧把历史峰值乘以一个小于1的系数,比如0.9,这样扫频信号离开某个频点后,峰值会在一段时间内逐渐衰减,形成带余辉的轨迹效果。这个参数在调试里很有用:遗忘因子太小轨迹闪烁,太大轨迹拖尾。
3.3 时间-频率对齐:发射机扫频斜率与接收机帧率的匹配
扫频信号最典型的两种形式是线性调频和步进跳频。线性调频可以用斜率描述,比如从2.4G扫到2.42G耗时1毫秒,斜率就是20MHz/ms。接收机这边每帧数据对应一个时间窗口,窗口中心时刻对应一个预期瞬时频率。如果扫描周期和帧率不是整数倍关系,每一帧里扫频信号只会停留在一部分频点上,频谱图上表现为轨迹断裂或者强度忽高忽低。
匹配方法通常是控制接收帧率,让每帧时长远小于扫频斜率决定的时间分辨率。这里给一个快速估算:假设扫频信号希望频率分辨到100kHz以内,线性斜率是20MHz/ms,那么扫频信号在100kHz带宽内停留的时间只有5微秒,这要求采样帧短到能在这个时间内对信号完成有效观测,实际上是做不到的。我更常做的做法是降需求:只要检测到扫频出现,不追求逐频点精确,这时帧时长可以放宽到扫频周期的十分之一左右。能满足“发现扫频并估算中心频率范围”这种要求的参数组合,对绝大多数监测场景已经够用。
下面这段代码是单帧检测的小工具,输入一段IQ数据,输出检测到的瞬时频率和峰均比,可以直接嵌入采集循环:
# 单帧扫频信号检测:输入一段IQ样本,输出瞬时频率和峰均比 import numpy as np def detect_frame_peak(iq_frame, fs, fft_size=1024, thresh_db=6): # 取帧中间 fft_size 个点,避免帧首尾的瞬态影响 n = len(iq_frame) if n >= fft_size: seg = iq_frame[n // 2 - fft_size // 2 : n // 2 + fft_size // 2] else: seg = iq_frame # 汉宁窗抑制频谱泄漏,避免扫频旁瓣淹没邻近频点 win = np.hanning(len(seg)) spectrum = np.fft.fftshift(np.fft.fft(seg * win)) power = np.abs(spectrum) ** 2 # 只取正频部分做峰值检测 half = len(spectrum) // 2 pos_power = power[half:] bin_idx = np.argmax(pos_power) peak_db = 10 * np.log10(pos_power[bin_idx] + 1e-12) # 底噪用中位数估计,比均值更抗窄带干扰 noise_floor = 10 * np.log10(np.median(pos_power) + 1e-12) freq = (bin_idx / half) * (fs / 2) return freq, peak_db - noise_floor这段代码的关键是底噪估计用了中位数而不是均值,原因是扫频信号只占据少数频点,对中位数的影响远小于对均值的影响,门限更稳。实际嵌入流水线时,thresh_db要基于实测噪声调,信号强度变化大的场景下建议做成自适应:先用前32帧的平均底噪初始化,之后逐帧更新。fft_size越大频率分辨率越好,但是帧时长和计算量都上去,实时性随之下降,需要综合考虑。
4. 搭一套能跑的实时链路:UHD Python与GNU Radio两条路线
4.1 路线一:用UHD Python自写扫频-采集-FFT循环
自己用UHD Python写采集循环的最大好处是可控性高,每一步做了什么都知道。流程分四段:初始化设备、配置射频参数、创建接收流、循环采集并做FFT。下面是一个精简但能跑的版本,扫频范围通过center_freqs列表传入,每次调谐后等待一段时间再采集:
# USRP扫频采集循环:遍历中心频率列表,逐频点采集并做FFT import numpy as np import uhd def sweep_capture(center_freqs, sample_rate, gain, nsamps=8192, settle_time=0.02): usrp = uhd.usrp.MultiUSRP() usrp.set_rx_rate(sample_rate, 0) usrp.set_rx_gain(gain, 0) # fc32表示complex float32,sc16表示硬件原始int16格式 stream_args = uhd.usrp.StreamArgs("fc32", "sc16") streamer = usrp.get_rx_stream(stream_args) metadata = uhd.types.RXMetadata() results = {} for freq in center_freqs: # 调谐后本振需要时间锁相,等待settle_time再采集 usrp.set_rx_freq(freq, 0) time.sleep(settle_time) # 发起连续接收,采集nsamps个样本后停止 stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) stream_cmd.stream_now = True streamer.issue_stream_cmd(stream_cmd) samples = np.zeros(nsamps, dtype=np.complex64) streamer.recv(samples, metadata, 1.0) # 用前一节提到的窗口和FFT处理这帧数据 win = np.hanning(nsamps) spectrum = np.fft.fftshift(np.fft.fft(samples * win)) results[freq] = spectrum # 停止连续接收,避免缓冲堆积 stop_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont) streamer.issue_stream_cmd(stop_cmd) return results这段代码有两个参数很容易被调错。第一个是settle_time,它不是越大越好,太大会让扫频周期变长,太短会采到本振尚未锁定的样本,第一帧存在频率拖尾。第二个是recv的timeout参数,单位是秒,设为1.0表示最多等1秒;如果设备驱动问题导致数据不来,这里会阻塞住整个循环。注意代码里使用了stop_cont停止连续流,这个动作会清掉USRP内部缓冲,如果不做,下次启动连续接收时可能会先读到残留旧数据,频谱图上出现幽灵信号。
4.2 路线二:GNU Radio流图里做扫频和频谱显示
如果不想从零写采集循环,用GNU Radio搭流图会更省事,调试交互也直观。核心模块是UHD USRP Source,它负责从设备读IQ数据,后面接一个Window、FFT、Complex to Mag,再送到Waterfall Sink或者Frequency Sink显示。整个处理链是数据流式的,不需要自己在循环里管理调谐和采集。
用流图搭实时频谱分析,最常见的翻车点是采样率配置不一致。UHD USRP Source输出的采样率如果设置得和FFT Sink内部期望的采样率不一样,频谱图的横轴频率标定会整体错掉,显示出来的信号位置全偏。这里有一个经验习惯:先在UHD USRP Source里把采样率定下来,之后所有模块的采样率参数都引用同一个变量,不要每个模块单独填数字。GNU Radio支持变量引用,可以用samp_rate这个变量名统一管理。模块连接顺序是:USRP Source输出复信号,经Stream to Vector转成向量,再进FFT,最后取幅度和相位送给显示端。
流图做实时检测没有Python循环那么灵活,比如峰值保持和自适应门限需要额外加逻辑模块。不过我见的多数项目是混搭:流图负责采集和可视化,一旦检测到异常就把原始IQ数据落盘,落盘后的精细分析交给Python脚本离线做。这个组合兼顾了现场实时监看和事后深度分析,比单走一条路线更稳。
4.3 参数对照:实时帧率、FFT点数、带宽三者的预算
不管走哪条路线,最后都要回答一个问题:你的处理链每一秒能消化多少数据。如果不提前算预算,USRP会把数据按流模式源源不断推过来,处理不过来时UHD会报overrun,直观表现就是频谱图周期性跳变或者显示冻结。预算可以用下面这个关系估算:帧率乘以每帧样本数等于采样率。处理能力则取决于FFT耗时和数据拷贝耗时的总和。
| 参数 | 预算值参考 | 调整方向 |
|---|---|---|
| 采样率 | 25 Msps | 降低可减少数据量,但会收窄可观测带宽 |
| FFT点数 | 4096 | 增大分辨率更好,但帧率下降 |
| 帧率 | 10-50 fps | 超过处理能力时优先降FFT点数 |
| 缓冲大小 | 采样率/帧率 | 太小容易overrun,太大会增加延迟 |
一个我自己常用的配置:采样率25Msps、FFT 4096点、显示帧率25fps,处理链CPU占用大约在一个核心的60%左右,留有余量给检测算法。如果换成FFT 16384点,帧率掉到10fps以下,实时性明显变差。具体数值取决于你机器的CPU和内存带宽,建议跑起来之后用系统监视器确认不会持续打满处理核心。
5. USRP实时频谱分析的5个翻车点与排查记录
5.1 现象:频谱图周期性出现整段噪声,显示像呼吸一样起伏
原因:UHD层出现overrun,也就是处理速度跟不上采样速度,部分样本被设备端丢弃。表现是频谱图整体噪底周期性抬高,不是因为信号来了,而是因为数据流断流后缓冲区被重复填了同一段数据。解决:先降低帧率或FFT点数,减少每个处理周期的计算量;如果还不行,检查数据拷贝路径,避免在显示端做耗时的绘图操作。另一个容易忽略的原因是USB带宽被共享,设备插在低速总线上,瞬时速率满足不了采样率需求,这种情况只能降低采样率或者换接口。
5.2 现象:扫频信号在瀑布图上呈锯齿状,轨迹不连续
原因:接收机步进扫描时每次调谐后的等待时间不够,本振尚未完全锁住就开始采集,每一帧的频率位置都有一点偏移,拼起来就成了锯齿。解决:在每次set_rx_freq之后增加settle_time,具体值通过观察瀑布图微调。还有一个原因是步进间隔太大,相邻两次扫描之间信号已经扫过了一段频率,轨迹出现跳跃。这种情况要么加密扫描步进,要么改用连续宽带采集,让接收机固定不动,靠采样带宽覆盖整个扫频范围。
5.3 现象:FFT显示上有明显的扫频轨迹,但检测判决就是漏报
原因:门限参数和信号强度不匹配。FFT显示是取模后的幅度,人眼可以看到轨迹,但检测算法用的thresh_db设得太高,或者底噪估计窗口选得太宽,把弱信号算进了底噪里。解决:先用离线数据画出检测中间结果,确认峰值到底比底噪高多少,再决定门限。实际项目中,底噪估计窗口选得太宽是常见原因,窄带扫频信号能量集中,应该用较短的滑窗估计局部底噪,而不是用全帧中位数一刀切。
5.4 现象:接收机锁定中心频率时,频谱图上出现镜像信号,且和真信号幅度相当
原因:I/Q不平衡或者采样率低于扫频带宽导致混叠。扫频信号瞬时带宽如果超过奈奎斯特带宽,超出部分会折叠到频谱的另一侧,看起来像多出来一个镜像信号。解决:确认采样率确实大于扫频带宽,留20%余量;如果带宽已经够用,检查前端增益是否太高导致ADC非线性,降低增益看镜像是否消失。还有一种情况是中心频率设置偏离信号中心,导致信号部分进入镜像频带,把中心频率对准扫频信号的中心即可。
5.5 现象:连续采集一段时间后,瀑布图上的频率标定整体偏移,偏移量随时间缓慢增大
原因:发射端和接收端的时钟不同步。USRP的ADC采样时钟和信号源的DAC采样时钟各自独立,存在微小偏差,长时间采集后偏差会累积成明显的频率偏移。解决:如果系统能够接入共同的时钟参考,把两端的参考源接同一路10MHz信号,偏差会大幅缩小。如果无法共享参考,可在检测结果里加入连续频率校正:用已知的单音信号作为参考,实时计算频偏并在后处理中补偿。
6. 让实时性再进一步:一套回环验证与调参习惯
实时频谱链路搭完之后,第一件事不是调参,而是做回环验证。用一个信号源发射已知扫频信号,频率范围和斜率都事先定好,接收端经过整个处理链后,把检测到的瞬时频率和理论值做差。这个差值随时间变化的曲线,比任何单帧频谱图都更有说服力。差值如果稳定在一个较小的范围内,说明链路是可信的;如果差值周期性跳动,说明帧率和对齐逻辑还有问题。
一个更细的验证技巧是扫频斜率匹配测试。发射端设好斜率后,接收端用你自己写的检测脚本连续记录瞬时频率,算相邻帧频率差,再除以帧时长,得到的斜率估计值和设定值比较,偏差超过10%就要检查帧切分是否对齐。扫频方向也很值得测一次:发射端设置正斜率扫频,检测结果应该是一个从左到右的上升斜线;如果显示出来是下降斜线,多半是I/Q极性接反或者FFT正负频处理反了,这个坑只要测一次就能暴露。回环验证做足了,再开始调门限和遗忘因子,心里才有底。
我自己的习惯是,每次调参前先把三件套记录下来:中心频率、采样率、增益。这三个参数任何一个变了,频谱图的底噪、分辨率、信号强度都会跟着变,忘记录的话,后面查问题就像在猜谜一样,纯浪费几个小时。调参时只动一个变量,其余全部保持不变,记录每次调整后的检测结果,积累到一定量就知道哪个参数对系统影响最大。这套习惯帮我少踩了很多坑,希望帮到你。
本文还有配套的精品资源,点击获取