☰
STM32F103实现波形显示与频谱仪:雷德算法与FFT实战
2026/10/14 0:06:38 网站建设 项目流程

简介:这是一份面向嵌入式学习者与电子类毕设同学的STM32F103开源项目,聚焦波形显示与频谱分析,采用标准库开发,配合0.96寸SPI接口OLED屏,FFT以C语言实现,ADC1采样频率达14MHz,并融入雷德算法,适合作为信号处理与显示驱动的综合练手案例。压缩包共281个文件,约6.14MB,以c与h源码、o与crf编译中间文件、d依赖文件为主,另含hex固件、uvprojx工程、sct分散加载、map映射及少量说明文档,覆盖从源码到可烧录文件的完整链路。目前已有1630人学习下载。读者可从中获取完整的工程目录结构、OLED驱动与FFT频谱实现思路、ADC高速采样配置参考,以及可直接编译调试的Keil工程,便于快速复现波形与频谱显示效果,并在此基础上做二次开发或毕设扩展。

1. 从一块“吃灰”的 F103 说起:波形显示、频谱仪与雷德算法到底能做什么

手头攒了几块 STM32F103 最小系统板,Flash 只有 128KB、RAM 20KB,主频 72MHz,跑个点灯、串口收发绰绰有余,但一提到“波形显示”“频谱仪”这种听起来该由 FPGA 或上位机干的活,很多人第一反应是“这破芯片算得过来吗”。我一开始也这么想,直到把 ADC 采样、FFT 和 TFT 刷屏这三件事拆开算了一遍账:1024 点采样在 72MHz 下用 DMA 搬运几乎不占 CPU,基 2 FFT 的运算量对 M3 内核来说也就几毫秒,真正卡脖子的反而是显示刷新和内存分配。标题里提到的“雷德算法”正是 FFT 里做位倒序重排的那一步,它决定了你能不能把输入序列按正确顺序喂给蝶形运算。这套东西做出来,本质上是一个完全免费开源、个人原创的嵌入式信号可视化方案:用最便宜的 F103 采集模拟信号,在片内做频谱分析,再把时域波形和频域柱状图直接画在 SPI 屏上。适合谁?适合手头有 F103 板子、想学数字信号处理落地、又不愿意一上来就啃 DSP 芯片手册的嵌入式开发者。它不追求专业仪器精度,但能把“采样—变换—显示”这条链路完整跑通,对理解频谱仪底层原理足够用了。

2. 硬件选型与信号链搭建:为什么 F103 加 SPI 屏是性价比最高的起点

2.1 核心器件清单与引脚分配

先把硬件定下来,不然后面代码没法写。我一般会按“最小可用”原则选型,避免一上来就堆外设。

器件型号/规格作用备注
主控STM32F103C8T6采样、运算、驱动显示72MHz,20KB RAM
显示屏1.8寸 SPI TFT,ST7735波形与频谱绘制128x160,SPI 接口
信号源板载电位器或函数发生器产生待测信号0~3.3V 范围内
晶振8MHz 无源 + 32.768kHz系统时钟与 RTC外部晶振更稳
调试SWD 接口下载与在线调试保留 4 根线

引脚分配上,ADC 用 PA0(ADC1_IN0),SPI1 用 PA5/PA6/PA7 接屏幕,CS、DC、RST 分别用 PA4、PA3、PA2。DMA1_Channel1 负责 ADC 搬运,TIM2 触发采样率。这样分配的好处是 SPI1 和 ADC1 不抢引脚,DMA 通道也不冲突。

2.2 采样前端与抗混叠的取舍

F103 的 ADC 是 12 位逐次逼近型,最大采样率 1MHz,但实际用 DMA 连续搬运时,受限于总线带宽和转换时间,我一般把采样率定在 200kHz 左右。根据奈奎斯特准则,能分析的信号频率上限是 100kHz,但为了留余量,实际只做到 40kHz 以内。

前端电路不能省。直接拿信号源接 PA0 会遇到两个问题:一是输入阻抗不匹配导致幅度衰减,二是高频噪声混叠进有用频段。常见做法是加一个 RC 低通,截止频率设在采样率的一半附近。比如采样率 200kHz,截止频率就取 100kHz,用 1kΩ 电阻和 1nF 电容组成一阶无源滤波。如果信号源输出阻抗较高,再加一级电压跟随器,用 LM358 搭个同相放大器,把信号抬到 ADC 量程的中间值 1.65V 附近,这样正负半周都能采到。

注意:F103 的 ADC 输入范围是 0~3.3V,信号超过这个范围会直接打坏引脚,前端一定要加钳位二极管。

2.3 定时器触发与 DMA 搬运的配置逻辑

采样率靠 TIM2 的更新事件触发 ADC,这样比软件循环采样准得多。TIM2 挂在 APB1 上,时钟 72MHz,预分频器设为 0,自动重装载值设为 359,这样更新频率就是 72MHz / (359+1) = 200kHz。ADC 配置为外部触发模式,触发源选 TIM2_TRGO,DMA 循环模式,缓冲区大小 1024。

// TIM2 配置:200kHz 触发 TIM_TimeBaseInitTypeDef TIM_InitStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStruct.TIM_Prescaler = 0; TIM_InitStruct.TIM_Period = 359; // 72MHz / 360 = 200kHz TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStruct); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); TIM_Cmd(TIM2, ENABLE); // ADC1 配置:外部触发 + DMA ADC_InitTypeDef ADC_InitStruct; ADC_InitStruct.ADC_Mode = ADC_Mode_Independent; ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; ADC_InitStruct.ADC_ContinuousConvMode = DISABLE; // 靠触发,不连续 ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStruct.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStruct); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE);

这段代码的关键参数是TIM_Period和ADC_SampleTime。TIM_Period决定采样间隔,改大就降采样率,改小就升采样率,但别超过 ADC 的转换时间上限。ADC_SampleTime选 55.5 个周期,是因为信号源内阻加上前端 RC 之后,采样保持电容需要足够时间充到稳定值,选太短会导致幅度测量偏小。DMA 缓冲区用uint16_t adc_buf[1024],搬满一半和搬满全部各产生一次中断,方便做双缓冲处理。

2.4 显示刷新率与内存的平衡

SPI 屏刷一帧 128x160 的 16 位色图,数据量是 1281602 = 40KB,按 SPI 18MHz 算,理论耗时约 18ms。但 F103 的 SPI1 最高只能到 36MHz,实际用 18MHz 比较稳。如果每采完 1024 点就全屏刷新,波形会卡顿。我的做法是只刷新波形区域,比如时域波形占屏幕上半部分 128x80,频域柱状图占下半部分 128x80,每次只更新变化的部分。内存方面,1024 点 ADC 数据占 2KB,FFT 需要实部和虚部各 1024 个 float,那就是 8KB,再加上显示缓冲区,20KB RAM 刚好够用,但不能再开大数组了。

3. 雷德算法与基 2 FFT 在 F103 上的实现细节

3.1 雷德算法到底在重排什么

FFT 的蝶形运算要求输入序列按位倒序排列,雷德算法就是干这个的。举个例子,8 点 FFT,原序列索引 0~7 的二进制是 000、001、010、011、100、101、110、111,位倒序之后变成 000、100、010、110、001、101、011、111,对应十进制就是 0、4、2、6、1、5、3、7。如果不做这一步,蝶形运算的输入顺序就错了,算出来的频谱是乱的。

实现上,雷德算法用双重循环:外层遍历所有索引,内层判断当前索引是否小于倒序后的索引,是就交换。这样每个元素只交换一次,不会重复。

// 雷德算法:位倒序重排 void bit_reverse(float *real, float *imag, uint16_t n) { uint16_t j = 0; for (uint16_t i = 0; i < n - 1; i++) { if (i < j) { // 交换实部和虚部 float temp = real[i]; real[i] = real[j]; real[j] = temp; temp = imag[i]; imag[i] = imag[j]; imag[j] = temp; } // 计算下一个 j uint16_t k = n >> 1; while (k <= j) { j -= k; k >>= 1; } j += k; } }

这段代码里n必须是 2 的整数次幂,比如 1024。j的更新逻辑是模拟二进制加法进位,从高位往低位找第一个 0 变成 1 的位置。如果n不是 2 的幂,这个算法会死循环,所以调用前一定要检查。

3.2 蝶形运算的定点与浮点选择

F103 没有硬件浮点单元,浮点运算靠软件模拟,一次乘法大概几十个周期。1024 点 FFT 需要 10 级蝶形,每级 512 个蝶形,总共 5120 个蝶形,每个蝶形包含一次复数乘法和两次复数加减。用浮点算下来大概 3~5ms,对 200kHz 采样率来说,采 1024 点耗时 5.12ms,运算再花 5ms,刚好能跟上,但余量不大。

如果嫌慢,可以改用定点 Q15 格式,把浮点乘法换成 32 位整数乘法再移位。代价是动态范围变小,小信号容易丢精度。我一般先用浮点跑通,确认算法没问题,再考虑定点优化。下面是一个基 2 FFT 的核心循环:

// 基 2 FFT:原地运算 void fft(float *real, float *imag, uint16_t n) { bit_reverse(real, imag, n); // 先做雷德重排 for (uint16_t s = 1; s <= log2(n); s++) { uint16_t m = 1 << s; // 当前级的点数 uint16_t m2 = m >> 1; // 半点数 float wr = 1.0f, wi = 0.0f; // 旋转因子初始值 float theta = -2.0f * PI / m; float wpr = cosf(theta); float wpi = sinf(theta); for (uint16_t j = 0; j < m2; j++) { for (uint16_t k = j; k < n; k += m) { uint16_t t = k + m2; float tr = real[t] * wr - imag[t] * wi; float ti = real[t] * wi + imag[t] * wr; real[t] = real[k] - tr; imag[t] = imag[k] - ti; real[k] += tr; imag[k] += ti; } // 更新旋转因子 float temp = wr; wr = wr * wpr - wi * wpi; wi = temp * wpi + wi * wpr; } } }

log2(n)可以用查表或者循环移位实现,避免调用 math.h 里的浮点 log。旋转因子递推比每次查表快,但误差会累积,跑 1024 点问题不大,再大就得定期用原始值校正。

3.3 窗函数与频谱泄漏的抑制

直接对 1024 点采样做 FFT,如果信号频率不是采样率除以 1024 的整数倍,频谱会泄漏,柱状图看起来就是一片糊。加窗能缓解这个问题。汉宁窗是最常用的,公式是 0.5 - 0.5cos(2pi*n/(N-1))。加窗之后主瓣变宽,但旁瓣压低,适合看单频信号。

// 汉宁窗 void apply_hanning(float *data, uint16_t n) { for (uint16_t i = 0; i < n; i++) { float window = 0.5f - 0.5f * cosf(2.0f * PI * i / (n - 1)); data[i] *= window; } }

加窗会损失一点幅度精度,测幅值时要乘个补偿系数,汉宁窗的相干增益是 0.5,所以幅度要乘 2。如果只看频率成分,不关心绝对幅度,可以不加补偿。

3.4 从 FFT 结果到屏幕柱状图

FFT 输出是复数,取模得到幅度谱。1024 点 FFT 输出 1024 个复数,但只有前 512 个是有效频率成分,后 512 个是镜像。屏幕只有 128 列,所以要把 512 个点压缩成 128 个柱。简单做法是每 4 个点取最大值,或者取平均。取最大值能保留峰值,适合看谐波;取平均更平滑,适合看噪声底。

// 幅度谱压缩到 128 列 uint16_t spectrum[128]; for (uint16_t i = 0; i < 128; i++) { float max_mag = 0; for (uint16_t j = 0; j < 4; j++) { uint16_t idx = i * 4 + j; float mag = sqrtf(real[idx] * real[idx] + imag[idx] * imag[idx]); if (mag > max_mag) max_mag = mag; } // 对数刻度映射到 0~80 像素高度 spectrum[i] = (uint16_t)(20 * log10f(max_mag + 1)); if (spectrum[i] > 80) spectrum[i] = 80; }

对数刻度是必须的,线性刻度下小信号根本看不见。log10f在 F103 上比较慢,可以查表或者用近似算法,但 128 次调用还能接受。

4. 避坑与排查:那些让我熬夜的翻车现场

4.1 采样率上不去,波形全是锯齿

现象:设置 200kHz 采样率,但示波器看 PA0 上的信号,发现实际采样间隔忽长忽短,重建的波形像被狗啃过。

原因:ADC 的采样时间设得太短,信号源内阻加上前端 RC 导致采样保持电容没充满。另外 DMA 搬运和 CPU 访问 Flash 抢总线,偶尔会丢一个触发。

解决:把ADC_SampleTime从 7.5 周期改成 55.5 周期,给电容足够充电时间。DMA 优先级设成最高,Flash 等待周期设为 2,因为 72MHz 下 Flash 需要 2 个等待周期。改完之后波形毛刺明显减少。

4.2 FFT 结果对称,但频率轴对不上

现象:频谱柱状图左右对称,但峰值位置和信号源频率差了一截。

原因:雷德算法里的n传错了,实际传了 1024,但 ADC 缓冲区只填了 512 个点,后面 512 个是零。零填充不会改变频谱形状,但会让峰值位置偏移。

解决:检查 DMA 传输数量寄存器和 FFT 调用时的n是否一致。我习惯在 FFT 函数入口加一句assert(n == ADC_BUF_SIZE),虽然 F103 上 assert 会占空间,但调试阶段很有用。

4.3 屏幕刷新撕裂,波形闪得眼睛疼

现象:波形刷新时上半屏和下半屏不同步,看起来像被撕成两半。

原因:SPI 刷屏没有等 DMA 传输完成就改了显示缓冲区,导致新旧数据混在一起。

解决:用双缓冲,一个缓冲区在刷屏时,另一个缓冲区在后台准备下一帧。刷屏用 DMA 传输,传输完成中断里再切换缓冲区。如果 RAM 不够,就只刷新变化区域,别全屏刷。

4.4 浮点运算导致 HardFault

现象:一调用 FFT 就进 HardFault,调试器显示栈溢出。

原因:FFT 函数里开了几个 1024 点的 float 数组作为局部变量,20KB RAM 根本放不下,栈直接撞到堆。

解决:把大数组改成全局变量或者static,让编译器分配到静态存储区。栈大小在启动文件里改,默认 1KB 不够,改成 4KB。但全局数组多了也占 RAM,得算着用。

4.5 频谱底噪太高,小信号被淹没

现象:没有输入信号时,频谱柱状图也有 20~30 像素的高度,小信号完全看不见。

原因:ADC 的参考电压不稳,或者前端没有加偏置,信号在 0V 附近被截断。另外 FFT 的浮点误差也会贡献底噪。

解决:前端加 1.65V 偏置,让信号在量程中间摆动。ADC 参考电压用外部 3.3V 基准,别用 VDDA 直接分压。FFT 之前减掉直流分量,也就是求 1024 个点的平均值再逐个减去。做完这三步,底噪能压到 5 像素以下。

5. 进阶技巧:让这套方案从“能跑”到“好用”

5.1 用查表法加速旋转因子计算

浮点cosf和sinf在 F103 上每次调用大概 10us,1024 点 FFT 要算 5120 次旋转因子,光三角函数就花 50ms,根本跟不上实时。我的做法是预先算好一张 512 点的旋转因子表,存在 Flash 里,运行时直接查表。表的大小是 512 * 2 * 4 字节 = 4KB,Flash 128KB 完全放得下。

// 预计算旋转因子表,放在 Flash 中 const float twiddle_real[512] = {1.0f, 0.9999f, ...}; // 省略具体数值 const float twiddle_imag[512] = {0.0f, -0.0061f, ...}; // FFT 循环里改用查表 float wr = twiddle_real[j * (n / m)]; float wi = twiddle_imag[j * (n / m)];

查表比递推更准,因为递推误差会累积,查表每次都是精确值。代价是占 4KB Flash,对 F103C8T6 来说可以接受。

5.2 自动量程与峰值保持

固定量程下,大信号会削顶,小信号看不见。加一个自动量程逻辑:每次采完 1024 点,找最大值和最小值,如果最大值接近 4095 或者最小值接近 0,就调整前端增益或者软件缩放系数。峰值保持更简单,就是每次刷新时比较当前柱高和上一帧柱高,取大的那个,这样瞬间信号不会一闪而过。

// 峰值保持 static uint16_t peak_hold[128] = {0}; for (uint16_t i = 0; i < 128; i++) { if (spectrum[i] > peak_hold[i]) { peak_hold[i] = spectrum[i]; } else { peak_hold[i] = (peak_hold[i] > 2) ? peak_hold[i] - 2 : 0; // 缓慢下降 } // 绘制 peak_hold[i] }

下降速度用-2是经验值,太快了峰值留不住,太慢了屏幕上一片残影。可以做成可调参数,根据信号特性改。

5.3 用定时器中断做双缓冲切换

双缓冲的关键是切换时机。我一般用 DMA 传输完成中断,在中断里把显示指针指向另一个缓冲区,然后重新启动 DMA。这样刷屏和计算完全并行,CPU 利用率能到 80% 以上。

// DMA 传输完成中断 void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3)) { DMA_ClearITPendingBit(DMA1_IT_TC3); // 切换显示缓冲区 if (disp_buf == buf_a) { disp_buf = buf_b; } else { disp_buf = buf_a; } // 重新启动 SPI DMA SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE); DMA_SetCurrDataCounter(DMA1_Channel3, DISP_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); } }

DISP_BUF_SIZE是显示区域的总字节数,比如 128802 = 20480。DMA 传输完这么多字节大概 1ms,期间 CPU 可以准备下一帧数据。

5.4 验证方法:用已知信号源校准

做完之后怎么知道准不准?找个函数发生器,输出 1kHz 正弦波,幅度 1Vpp,看时域波形周期是不是 1ms,频域峰值是不是在 1kHz 位置。再输出 10kHz 方波,看频谱里 1kHz、3kHz、5kHz 的谐波是不是依次递减。如果谐波位置对不上,检查采样率和 FFT 点数;如果幅度不对,检查窗函数补偿和 ADC 参考电压。

我习惯在代码里留一个测试模式,按一下按键就输出 1kHz 方波到 DAC 引脚,然后 ADC 采回来做 FFT,形成闭环。这样不用外部仪器也能验证链路通不通。

5.5 一个让我后悔没早做的习惯

早期我总想一次把功能做全,结果代码越写越乱,FFT 和显示耦合在一起,改一个参数就得重新编译烧录。后来学乖了,把采样、FFT、显示拆成三个独立模块,模块之间用环形缓冲区传递数据。这样调 FFT 的时候不用管显示,调显示的时候不用管采样。还有一个习惯是每次改完参数都在串口打印关键值,比如采样率、FFT 峰值索引、幅度最大值,不用盯着屏幕猜。这些习惯看着笨,但省下来的调试时间够你多跑好几轮实验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询