从零开始构建 Pluto SDR FM 收音机:一场软件定义无线电的深度实践
你能听到什么?
本文记录了一台完整可用的 FM 调频广播收音机的诞生过程——不是用现成的收音机芯片,而是用一台软件定义无线电(SDR)设备 + Python 代码,从 IQ 采样到音频输出,从频谱显示到静噪逻辑,全部手写。
最终效果:打开程序,你可以看到实时频谱跳动,听到 88-108 MHz 的 FM 广播,按空格暂停,按数字键切换电台——和一台真正的收音机一模一样,但你能看见信号。
一、什么是 SDR?为什么选 Pluto SDR?
传统收音机用模拟电路完成混频、检波、放大——一切都是硬件的。SDR 的思路不同:把天线收到的射频信号尽可能早地数字化,之后的一切——滤波、解调、去加重——都用软件算法完成。
这意味着同一块硬件,换一套代码就可以变成 FM 收音机、AM 航空波段接收机、ADS-B 飞机追踪器、频谱分析仪……这正是我们在做的事情。
ADALM-PLUTO SDR(以下简称 Pluto)是 Analog Devices 出品的入门级 SDR 开发板:
| 参数 | 规格 |
|---|---|
| 频率范围 | 70 MHz – 6 GHz |
| 带宽 | 最高 20 MHz(实际稳定 ~2.5 MHz) |
| ADC/DAC | 12-bit |
| 接口 | USB 2.0(RNDIS 虚拟以太网) |
| 价格 | 约 ¥700-1000(学生价更低) |
它通过 USB 供电并建立虚拟以太网连接,PC 端用 libiio 库收发 IQ 数据流——Python 里对应 pyadi-iio 包。
二、硬件准备
2.1 物料清单
ADALM-PLUTO SDR ×1
FM 天线(SMA 接口)×1 ← 一定要接!否则全是噪声
USB 线(Micro-USB) ×1
PC(Windows 10/11) ×1
2.2 天线
Pluto 自带一根很小的 2.4 GHz 天线,不适合 FM 波段(88-108 MHz)。FM 广播波长约 3 米,理想的 FM 天线应该是 75cm 左右的鞭状天线(1/4 波长),但随便拉一根导线都能收到强台。我用的是 SMA 转 BNC 转接头 + 一根拉杆天线。
2.3 网络配置(关键步骤)
Pluto 通过 USB 模拟 RNDIS 网卡,固定 IP 为 192.168.2.1。PC 需要配置同网段静态 IP:
# 以管理员身份运行 CMD
netsh interface ip show interfaces
# 找到 "以太网 X"(Pluto 对应的网卡)
netsh interface ip set address name="以太网 X" static 192.168.2.2 255.255.255.0
验证:
ping 192.168.2.1
能 ping 通即硬件连接就绪。这一步是新手最容易卡住的地方——如果 ping 不通,检查 USB 线是否支持数据(不是纯充电线)、RNDIS 驱动是否正常加载。
三、软件环境搭建
3.1 Python 和依赖
需要 Python 3.12 64-bit,安装时勾选 “Add Python to PATH”。
pip install numpy scipy matplotlib sounddevice pyadi-iio
各包职责:
| 包 | 担任角色 |
|---|---|
numpy | 数值计算引擎:复数运算、FFT、矩阵操作 |
scipy.signal | 信号处理工具箱:FIR 滤波器设计、多级抽取 |
matplotlib | 整个 GUI 界面:频谱图、瀑布图、按钮、滑块 |
sounddevice | PortAudio 的 Python 绑定:低延迟实时音频输出 |
pyadi-iio(adi) | 与 Pluto SDR 通信的二层封装 |
3.2 libiio DLL(占坑率最高的步骤)
pyadi-iio 依赖 C 语言的原生库 libiio.dll,需手动放入 Python 安装目录。
从 GitHub releases 下载 libiio-<version>-Windows.zip,解压 VS-2022-x64 文件夹中的所有 .dll 到:
C:\Program Files\Python312\
验证安装:
python -c "import adi; print(adi.Pluto('ip:192.168.2.1').sample_rate)"
如果输出一个数字(如 2000000.0),说明整个链路通了:Python → pyadi-iio → libiio.dll → 网络 → Pluto SDR。
四、FM 广播原理(你需要知道的极简版)
写解调器之前,必须先理解信号在空中的形态。
4.1 调制:信息如何"搭乘"载波?
FM(Frequency Modulation)的原理是让载波频率随音频信号幅度变化:
- 载波中心频率:
f_c(例如 97.4 MHz) - 音频信号幅度越大 → 载波频率偏离
f_c越远 - 频偏范围:±75 kHz(广播标准)
- 调制信号带宽:15 kHz(单声道)
在数学上,FM 信号可以写成:
s(t) = A · cos(2π·f_c·t + 2π·Δf·∫m(τ)dτ)
其中 m(t) 是音频信号,Δf = 75 kHz 是最大频偏。
4.2 去加重:发射端的"预加重"与接收端的"反操作"
FM 广播发射时会人为提升高频分量(预加重,Pre-emphasis),以提高高频信噪比。接收端必须做去加重(De-emphasis)还原平坦频谱。
不同地区的时间常数不同:
| 地区 | 时间常数 τ |
|---|---|
| 中国 / 欧洲 | 50 µs |
| 北美 | 75 µs |
本项目使用 50 µs。去加重本质上是一个一阶低通 IIR 滤波器,截止频率 f_c = 1/(2πτ) ≈ 3.18 kHz。
五、DSP 信号处理链:从空中波形到扬声器声音
这是整个项目的核心。一条完整的信号路径如下:
Pluto SDR (2 MSPS IQ)
│
▼
┌──────────────────────┐
│ IQ 缓冲队列 │ ← 接收线程不断从 Pluto 拉取
└──────┬───────────────┘
│
├──→ FFT ──→ 频谱图显示(128ms 刷新)
│
▼
┌──────────────────────┐
│ FM 解调 (arctan 鉴频) │ ← ∠(x[n]·x*[n-1]) = 瞬时频率
└──────┬───────────────┘
│ 2 MSPS,浮点
▼
┌──────────────────────┐
│ 多级抽取 2M → 48k │ ← 分级降采样,保护滤波器性能
└──────┬───────────────┘
│ 48 kSPS
▼
┌──────────────────────┐
│ 去加重 (50 µs, 1-pole) │ ← IIR 低通,补偿发射端预加重
└──────┬───────────────┘
│
▼
┌──────────────────────┐
│ 静噪门 (Squelch) │ ← SNR < 阈值 → 输出静音
└──────┬───────────────┘
│
▼
┌──────────────────────┐
│ 音量控制 & 限幅 │ ← clip(-1.2, 1.2)
└──────┬───────────────┘
│
▼
┌──────────────────────┐
│ Audio Ring Buffer │ ← thread-safe 环形缓冲
└──────┬───────────────┘
│
▼
┌──────────────────────┐
│ sounddevice 输出 │ ← 48 kHz 单声道,低延迟
└──────────────────────┘
5.1 IQ 采样与缓冲
SDR_URI = "ip:192.168.2.1"
SAMPLE_RATE = 2000000 # 2 MSPS
ACCUM_IQ = 131072 # 每次处理 131k 个采样点
Pluto 以 2 MSPS 采样率持续输出 12-bit 复基带采样(I/Q 各 12-bit,Python 中为 complex64)。一个独立的采集线程 _acq_loop() 不断调用 sdr.rx() 拉取数据,追加到 IQ 缓冲区。当缓冲区积攒够 ACCUM_IQ 个采样点(~65ms 的数据),触发一轮 _process()。
使用生产者-消费者模型,采集线程与 GUI 主线程解耦。
5.2 FM 解调:arctan 鉴频器
FM 解调的本质是提取瞬时频率。对于离散复信号,瞬时频率正比于相邻采样点的相位差:
demod = np.angle(iq[1:] * np.conj(iq[:-1]))
这里 iq[n] · conj(iq[n-1]) 的结果是一个复数,其角度等于前后两点的相位差。在 FM 调制中,相位差的变化反映了音频信号的幅度变化——这就是"鉴频"的全部秘密。
这份代码只有一行,但它背后是复数运算、共轭、反正切的高效 NumPy 矢量化实现。
5.3 多级抽取:从 2 MHz 到 48 kHz
FM 广播的音频带宽只有 15 kHz,2 MSPS 的原始采样率过于庞大。直接 decimate 到 48 kHz 会导致过渡带过窄,FIR 滤波器阶数极高。
解决方案:分两级降采样。
第1级:FIR 滤波 + 5倍抽取
2 MSPS → 400 kSPS
FIR: 127阶,截止频率 = 0.45/5 = 0.09 (归一化)
第2级:有理数重采样 3/25
400 kSPS → 400 × 3/25 = 48 kSPS
scipy.signal.resample_poly,内部默认 Kaiser 窗
DECIM_STAGE1 = 5
DECIM_STAGE2_UP = 3
DECIM_STAGE2_DOWN = 25
# 第1级
x = sig.upfirdn(self._filt, demod, down=DECIM_STAGE1)
# 第2级
x = sig.resample_poly(x, DECIM_STAGE2_UP, DECIM_STAGE2_DOWN, window="hann")
最终得到 48 kSPS 的音频流——正好是标准音频采样率。
5.4 归一化与去加重
# 相位差 (rad/sample @ 2M) → 频率偏移 → 音频
x *= SAMPLE_RATE / (2 * np.pi * 75000)
解调出的相位差单位是 rad/sample,需要换算为实际频偏。系数 SAMPLE_RATE / (2π × 75000) 将相位差映射到归一化音频幅度(±75 kHz 频偏对应 ±1.0)。
去加重用一个简单的一阶 IIR 低通滤波器实现:
tau = 50e-6 # 中国 FM 标准: 50µs
alpha = 1 - np.exp(-1 / (AUDIO_RATE * tau))
# y[n] = (1-α)·x[n] + α·y[n-1]
这是一个递归低通,截止频率 1/(2π·50µs) ≈ 3.18 kHz。每输出一个采样点,高频分量被适度衰减,恢复平坦的音频频谱。
5.5 静噪门(Squelch)
没有电台的频点全是噪声——沙沙声很刺耳。静噪门的逻辑很简单:
# 实时计算 SNR
self.signal_strength = float(np.max(spec) - np.median(spec))
# SNR 低于阈值 → 静音
if self.signal_strength < self.squelch:
x = np.zeros_like(x)
从频谱中取 peak 和 median floor,差值就是 SNR。当 SNR 低于用户设定的阈值时,输出全零,扬声器寂静。
六、软件架构:三线程协同
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 采集线程 │ │ GUI 主线程 │ │ 音频回调线程 │
│ _acq_loop() │ │ matplotlib │ │ sounddevice │
│ │ │ FuncAnimation │ │ PortAudio │
│ 循环: │ │ │ │ │
│ sdr.rx() │ │ _gui_update(): │ │ _audio_cb(): │
│ → iq_buf │ │ 读 latest_spec │ │ 读 audio_ring │
│ → _process() │ │ 画频谱/瀑布图 │ │ → outdata │
│ → audio_ring │ │ 每 80ms 刷新 │ │ 每 ~21ms 调用 │
└──────┬───────────┘ └──────────────────┘ └──────────────────┘
│ │ │
└───────────────────────┼────────────────────────┘
│
threading.Lock()
audio_ring (deque, 48k items max)
latest_spec (ndarray 512)
waterfall (ndarray 100×512)
线程安全是这里的设计关键。采集线程写入 audio_ring,音频回调读取 audio_ring——两者通过 threading.Lock() 保护。GUI 线程(FuncAnimation)只读不写,也加锁保证数据一致性。
选择 deque 作为音频缓冲是刻意之举:popleft() O(1),自动丢弃旧数据,容量上限 1 秒,保证延迟可控。
为什么不在音频回调里做解调?
音频回调要求快速返回(~21ms 内),不能有阻塞操作。把 DSP 放在采集线程做,音频回调只负责从 buffer 取数填输出——干净利落。
七、GUI 设计:matplotlib 能做什么?
很多人不知道,matplotlib 除了画图,还能构建完整的交互式界面。本项目没有用 PyQt/Tkinter,直接用 matplotlib widgets 搭了整个控制面板。
7.1 布局结构
┌─────────────────────────────────────────────────────────────┐
│ Pluto SDR — FM Radio 88-108 MHz │
├─────────────────────────────────────────────────────────────┤
│ │
│ 频谱图 / 瀑布图(主显示区) │
│ ×轴: 频率偏移 (kHz) │
│ y轴: Power (dB) / Frame │
│ │
├─────────────────────────────────────────────────────────────┤
│ 100.00 MHz [<<] [<] [>] [>>] ○ slow ○ fast ○ manual│
│ [Pause] [Tone] Vol ──●── SQL ──●── [Waterfall] │
│ [92.7] [107.8] [103.8] [101.8] [100.6] [104.6] │
├─────────────────────────────────────────────────────────────┤
│ SNR: 25.3 dB | Squelch: 12.0 | Buffer: 0.3s | LIVE │
│ ←→ ±0.1M ↑↓ ±1M 1-6 Presets Space=Pause T=Tone V=View │
└─────────────────────────────────────────────────────────────┘
7.2 关键控件
| 控件 | 类型 | 功能 |
|---|---|---|
| 频谱图/瀑布图 | FuncAnimation 驱动实时刷新 | 80ms/帧 |
Tune 按钮 << >> < > | Button | ±1 MHz / ±0.1 MHz 调谐 |
| Pause / Tone | Button | 音频播放控制 |
| Vol / SQL 滑块 | Slider | 音量 0-3x、静噪 0-30 dB |
| Gain Mode | RadioButtons | slow_attack / fast_attack / manual |
| Presets 1-6 | Button ×6 | 一键切换预设电台 |
| 键盘快捷键 | key_press_event | ←→↑↓ 调谐、数字键切换预设、Ctrl+数字保存 |
7.3 频谱图 vs 瀑布图
频谱图模式:实时显示当前 FFT 的幅度谱(蓝线),y 轴是 dB,x 轴是相对中心频率的偏移。
瀑布图模式:用 imshow() 渲染最近 100 帧的频谱叠加,颜色越亮 = 信号越强。横轴是频率,纵轴是时间(向下滚动)。一台强信号电台在瀑布图上会呈现为一条笔直的亮线——视觉上非常直观。
self.waterfall = np.roll(self.waterfall, -1, axis=0)
self.waterfall[-1] = wf_row
每次处理新数据时,瀑布图数组整体上移一行,新频谱填到最底部——形成向下滚动的效果。
7.4 频率切换的细节
切换频率时,本地振荡器(LO)立即改变,但 IQ 缓冲区里还有上一个频率的旧数据。如果不处理,会有短暂的声音错乱。解决方案:
def _set_freq(self, f):
self.current_freq = round(f, 2)
self.sdr.rx_lo = int(self.current_freq * 1e6)
# 清空所有旧数据
with self.lock:
self.iq_buf = np.array([], dtype=np.complex64)
self.audio_ring.clear()
一刀切清空缓冲区,保证新频率的数据"纯净"。
八、启动流程
程序启动到播放声音需要约 2-3 秒:
1. 连接 Pluto SDR,重试 5 次(网络偶尔抖动)
2. 设置采样率 2 MSPS、带宽 200 kHz、AGC 模式
3. "预热":采集 6 个 block 进行 DSP 处理,填满音频缓冲
4. 启动 sounddevice 音频流(低延迟模式)
5. 启动采集线程
6. 构建 GUI,启动 FuncAnimation
7. Ready → 用户可以听到声音、看到频谱
预填充(pre-fill)阶段确保音频缓冲有足够的样本,避免启动时卡顿或断流。
九、使用指南
9.1 基本操作
| 操作 | 按键 |
|---|---|
| 微调频率 ±0.1 MHz | ← → |
| 粗调频率 ±1 MHz | ↑ ↓ |
| 切换到预设 1-6 | 数字键 1-6 |
| 保存当前频率到预设 | Ctrl+1-6 |
| 暂停/恢复音频 | Space |
| 播放 1kHz 测试音 | T |
| 切换频谱/瀑布图 | V |
| 循环增益模式 | M |
9.2 收到一个清晰的电台
- 接好天线,放在窗边
- 把静噪(SQL)设为 0,先听到噪声确认通路正常
- 慢慢拨频率,看频谱上有没有"凸起"的峰——那就是电台
- 看到峰后,停在那个频率,把静噪调高到噪声消失但电台不中断的水平
- 音质不满意可切换增益模式(slow 适合稳定信号,fast 适合快速波动)
9.3 使用预设
预设存储在 fm_presets.json 中,一个简单的 JSON 数组。你可以直接编辑这个文件,也可以 Ctrl+数字保存:
[92.7, 107.8, 103.8, 101.8, 100.6, 104.6]
十、踩过的坑
10.1 libiio DLL 版本不匹配
如果 DLL 和 pyadi-iio 版本不匹配,import 直接报错。解决方案:去 GitHub 下载与 pyadi-iio 兼容的 libiio 版本。可以用 pip show pyadi-iio 查看版本。
10.2 Pluto 采样率上限
Pluto 标称 20 MHz 带宽,但 USB 2.0 实际数据吞吐量有限。稳定工作上限约 2-3 MSPS。本项目用 2 MSPS,正好覆盖 FM 波段 1 MHz 宽(88-108 MHz 需要 ~1 MHz 带宽)。
10.3 声音卡顿
音频缓冲不足导致 underflow。解决方案:增大 ACCUM_IQ(处理块大小),同时调大 audio_ring 容量。当前设置 131072 采样点/块、1 秒缓冲,实测稳定。
10.4 matplotlib 的 GUI 限制
matplotlib 的交互性能不如 PyQt,FuncAnimation 帧率波动时频谱会有轻微抖晃。但对于这个应用来说,80ms 刷新完全够用——你听的是声音,看的是频谱趋势。
10.5 不接天线的惨痛教训
Pluto 不接天线时虽然收不到真实信号,但板子本身会辐射数字噪声——频谱看起来像有信号,实际上是假象。一定接天线。
十一、这个项目的延展
FM 收音机只是 Pluto SDR 能力的冰山一角。同仓库中还有:
- FM Stereo — 同一条 FM 信号,提取 19 kHz 导频,解码 L-R 差信号,输出双声道立体声
- AM Radio — 108-137 MHz 航空波段(AM 调制),收听机场通播
- ADS-B — 1090 MHz 飞机位置广播,解码 ICAO 地址和高度
- 实时频谱分析仪 — 全频段扫频,峰值保持
- 频谱扫描 — 生成静态瀑布图和频谱图
硬件不变,软件可变——这就是 SDR 的魅力所在。
十二、总结
从零开始到今天,这条链路串起了:
物理层(天线、射频前端)
→ 数字层(ADC、IQ 流、libiio)
→ 信号处理层(FM 解调、抽取、滤波、去加重)
→ 应用层(音频播放、GUI 交互)
每一层都是自己写的,没有调用任何"收音机"库函数。你看到频谱上的每一个点、听到的每一帧声音,都是代码一行行算出来的。
这就是软件定义无线电。
硬件:ADALM-PLUTO SDR | 语言:Python 3.12 | 依赖:numpy, scipy, matplotlib, sounddevice, pyadi-iio
437

被折叠的 条评论
为什么被折叠?



