☰
STM32F103手写FFT频谱仪:雷德算法+0.96 OLED波形显示
2026/10/13 12:44:44 网站建设 项目流程

简介:这是一份面向嵌入式初学者与电子类毕设学生的STM32F103开源项目,聚焦波形显示与频谱分析,采用个人原创的雷德(Radix)算法实现FFT。项目基于ST标准外设库开发,驱动0.96寸SPI接口OLED,ADC1以14MHz采样频率采集信号,适合作为信号处理、频谱仪类课程设计或毕业设计的参考模板。压缩包共281个文件,约6.14MB,包含44个c源文件与48个头文件构成核心逻辑,47个o与44个crf为编译中间产物,另有hex烧录文件、uvprojx工程、sct分散加载及bat批处理脚本,目录结构完整,可直接用Keil打开编译调试。目前已有1630人学习下载。读者可从中获取完整的ADC采样、C语言FFT运算、OLED波形与频谱绘制链路,理解雷德算法在资源受限MCU上的实现思路,并借助工程配置与排错细节快速复现实验,是入门嵌入式信号处理的实用素材。

1. 从一块 0.96 寸 OLED 说起:这套 STM32F103 波形显示与频谱仪到底能跑出什么效果

手头有一块 STM32F103 最小系统板,加上常见的 0.96 寸 OLED,很多人第一反应是点个屏、刷个字符就收工。但如果你正在做毕设,或者想找一个能同时展示 ADC 采样、FFT 频谱、波形刷新和雷德算法落地的完整参考,这套个人原创的开源工程值得拆开看。它把 STM32F103 的 ADC 采集、定时器触发、FFT 运算、OLED 波形绘制和频谱柱状显示串成了一条完整链路,核心亮点是雷德算法(Radix-2 FFT)的手写实现,不依赖 DSP 库,纯 C 代码就能在 F103 这种无 FPU 的 M3 内核上跑起来。适合谁?正在做毕设 stm32 方向、需要 fft 频谱显示、又不想直接抄库函数调用的同学;也适合想搞清楚定点 FFT 在 0.96oled 上怎么压缩显示、怎么控制刷新率的嵌入式新手。整套代码完全免费开源,个人原创,拿来就能编译下载,改改采样率和显示比例就能套到自己的板子上。

2. 雷德算法与 FFT 在 F103 上的定点实现:为什么不用 DSP 库

2.1 雷德算法的核心思路与选型理由

雷德算法本质上是按时间抽取的基-2 FFT,把 N 点序列按奇偶拆分成两个 N/2 点序列,递归下去直到 2 点 DFT,再用旋转因子合并。STM32F103 是 Cortex-M3 内核,没有硬件浮点单元,主频 72MHz,SRAM 只有 20KB,如果直接上浮点 FFT,一次 256 点运算就要吃掉大量周期,刷新率根本撑不住。这套工程选择定点 Q15 格式,把输入 ADC 值左移对齐到 16 位有符号整数范围,旋转因子用查表法预存成 Q15 常量数组,乘法后用移位还原。这样做的好处是:第一,避免浮点模拟带来的巨大开销;第二,查表法把 cos/sin 计算从运行时移到编译期,Flash 占用可控;第三,Q15 精度对于 0.96 寸 OLED 的 64 级柱状显示完全够用,肉眼根本看不出量化误差。

常见做法是直接调用 arm_math.h 里的 cr4_fft_256_stm32 或者 arm_cfft_q15,但那样你就被库绑死了,而且很多毕设答辩时老师会追问“FFT 怎么实现的”,你答“调库”就很难往下聊。这套代码把每一层蝶形运算都摊开写,旋转因子表怎么生成、位反转怎么处理、溢出怎么保护,全在源码里。

2.2 定点 FFT 的关键代码与参数说明

下面这段是雷德算法第一层蝶形运算的核心片段,我按工程里的写法整理出来,加了注释方便对照:

// N = 256, Q15 定点 FFT 第一层蝶形 // input: 实部数组,长度 N,已做位反转重排 // twiddle: 旋转因子表,Q15 格式,长度 N/2 #define FFT_N 256 #define FFT_LOG2N 8 void fft_radix2_q15(int16_t *real, int16_t *imag, const int16_t *twiddle) { uint16_t i, j, k, m; uint16_t step = FFT_N >> 1; // 第一层蝶形跨度 128 uint16_t group = 1; // 第一层有 1 组 int32_t tr, ti; // 临时变量用 32 位防溢出 for (m = 0; m < FFT_LOG2N; m++) { for (i = 0; i < group; i++) { for (j = 0; j < step; j++) { k = i * (step << 1) + j; // 蝶形:A = x[k], B = x[k+step] * W tr = (int32_t)real[k + step] * twiddle[j * group] - (int32_t)imag[k + step] * twiddle[FFT_N/2 + j * group]; ti = (int32_t)real[k + step] * twiddle[FFT_N/2 + j * group] + (int32_t)imag[k + step] * twiddle[j * group]; // Q15 还原,右移 15 位 tr >>= 15; ti >>= 15; // 原位运算 real[k + step] = real[k] - (int16_t)tr; imag[k + step] = imag[k] - (int16_t)ti; real[k] = real[k] + (int16_t)tr; imag[k] = imag[k] + (int16_t)ti; } } step >>= 1; group <<= 1; } }

逻辑说明:外层 m 控制蝶形级数,共 8 级(256 点);group 是当前级的组数,step 是组内跨度。旋转因子表 twiddle 前半段存 cos,后半段存 sin,索引 j*group 保证每级取到正确的因子。乘法用 int32_t 承接,右移 15 位完成 Q15 还原,最后原位加减。参数怎么改:如果你用 128 点,把 FFT_N 改成 128,FFT_LOG2N 改成 7,twiddle 表长度减半;采样率决定频率分辨率,比如定时器触发 ADC 采样 10kHz,256 点分辨率就是 39Hz 左右,OLED 上每根柱子代表一个频点。

2.3 位反转重排与旋转因子表的生成

位反转是雷德算法里最容易翻车的一步。输入 ADC 数据是按时间顺序存的,但 FFT 要求先按位反转顺序重排。工程里用了一个查表法:预存 256 个 uint8_t 的反转索引数组,运行时直接按索引搬数据。旋转因子表则是在 PC 上用 Python 脚本生成,导出成 C 数组,避免在 MCU 上跑三角函数。

# 生成 Q15 旋转因子表,N=256 import math N = 256 for i in range(N // 2): angle = -2 * math.pi * i / N cos_val = int(math.cos(angle) * 32767) sin_val = int(math.sin(angle) * 32767) print(f"{cos_val}, {sin_val},", end=" ")

这段脚本跑完把输出粘到 twiddle 数组里就行。注意 Q15 范围是 -32768 到 32767,乘的时候用 32767 而不是 32768,否则会溢出。位反转索引也可以用类似方式生成,或者用经典的“蝴蝶交换”循环现场算,但查表更快,Flash 多占 256 字节而已。

3. ADC 采样与定时器触发:怎么保证波形不抖、频谱不糊

3.1 定时器触发 ADC + DMA 的配置要点

波形显示最怕采样间隔不均匀,OLED 上画出来就是锯齿乱跳。这套工程用 TIM2 做触发源,ADC1 规则通道配成外部触发,DMA 循环搬运到缓冲区。关键参数:TIM2 预分频和重装载值决定采样率,比如 72MHz / (71+1) / (99+1) = 10kHz;ADC 采样时间设成 55.5 周期,保证 0.96 寸 OLED 刷新时不会因为采样太快而引入噪声。DMA 缓冲区长度设成 256,和 FFT 点数对齐,半满中断里做 FFT,全满中断里做显示,这样采集和运算互不打架。

// TIM2 配置:10kHz 触发 TIM_TimeBaseInitTypeDef TIM_Init; TIM_Init.TIM_Prescaler = 71; // 72MHz / 72 = 1MHz TIM_Init.TIM_Period = 99; // 1MHz / 100 = 10kHz TIM_Init.TIM_ClockDivision = 0; TIM_Init.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_Init); // ADC1 外部触发配置 ADC_InitTypeDef ADC_Init; ADC_Init.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC2; ADC_Init.ADC_ContinuousConvMode = DISABLE; ADC_Init.ADC_ScanConvMode = DISABLE; ADC_Init.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_Init); ADC_ExternalTrigConvCmd(ADC1, ENABLE);

逻辑说明:TIM2 的 CC2 事件作为 ADC 触发源,每次比较匹配启动一次转换,DMA 自动搬走结果。参数怎么改:想提高采样率就减小 TIM_Period,但注意 ADC 最快转换时间约 1us,别超过 1MHz 触发;想换通道就改 ADC_RegularChannelConfig 里的通道号。失败时先看 DMA 计数器有没有在动,再看 ADC 的 EOC 标志有没有置位,多半是触发源选错了。

3.2 双缓冲与显示刷新率的平衡

0.96 寸 OLED 通常是 I2C 或 SPI 接口,I2C 刷一整屏 128x64 大概要 20-30ms,SPI 快一些但也要 10ms 左右。如果每采完 256 点就刷一次屏,刷新率会被 OLED 拖死。工程里的做法是:DMA 缓冲区开两个 256 点空间,半满中断里对前半段做 FFT 并更新显示缓冲区,全满中断里对后半段做 FFT,主循环里只负责把显示缓冲区推到 OLED。这样采集和显示解耦,实测波形刷新能到 15-20fps,频谱柱状图看起来是连续跳动的。

提示:OLED 初始化时把对比度调低一点,刷新时残影会少很多;I2C 速率能上 400kHz 就别用 100kHz,省下来的时间够多算一轮 FFT。

4. 0.96 OLED 波形与频谱显示:坐标映射和柱状图压缩

4.1 波形显示:从 ADC 值到像素点的映射

OLED 分辨率 128x64,波形区通常占上半屏 128x32。ADC 是 12 位,范围 0-4095,映射到 32 个像素高度就是 value * 32 / 4096。但直接这样画会抖,工程里加了一个简单的滑动平均滤波,每 4 个点取一次显示,128 个像素刚好对应 512 个采样点,视觉上更稳。画线用 Bresenham 算法,避免浮点运算。

// 波形映射与画点 uint8_t wave_y[128]; for (uint8_t x = 0; x < 128; x++) { uint16_t adc_avg = 0; for (uint8_t k = 0; k < 4; k++) { adc_avg += adc_buf[x * 4 + k]; } adc_avg >>= 2; // 除以 4 wave_y[x] = 31 - (adc_avg * 31 / 4095); // 翻转 Y 轴 } OLED_DrawLine(0, wave_y[0], 127, wave_y[127]); // 实际逐点画

参数说明:adc_buf 是 DMA 搬来的原始数据,长度 512;wave_y 存映射后的 Y 坐标,范围 0-31。想放大波形就减小除数,想压缩就增大除数。注意 4095 是 12 位满量程,如果你用 10 位 ADC 就改成 1023。

4.2 频谱柱状图:64 级高度怎么分配

FFT 输出 256 点,但 OLED 只有 128 列,工程里只取前 64 个频点(对应 0 到采样率/4),每根柱子宽度 2 像素,高度按幅值开方后映射到 0-32。开方是为了让低幅值频点也能看见,不然小信号全被压成一条线。幅值计算用实部平方加虚部平方再开方,但开方在 M3 上慢,实际用查表近似。

// 频谱柱状显示,取前 64 点 for (uint8_t i = 0; i < 64; i++) { int32_t mag = (int32_t)fft_real[i] * fft_real[i] + (int32_t)fft_imag[i] * fft_imag[i]; uint8_t height = sqrt_approx(mag) >> 8; // 近似开方后缩放 if (height > 32) height = 32; OLED_DrawColumn(i * 2, 63 - height, height); }

逻辑说明:sqrt_approx 是一个分段线性查表函数,输入 32 位幅值平方,输出近似开方值。右移 8 位是经验缩放,具体值要根据你的信号幅度调。如果柱子全顶满就增大移位,全趴着就减小。注意 FFT 结果里第 0 点是直流分量,显示时最好跳过,从第 1 点开始画。

5. 避坑与排查:这套代码最容易翻车的五个地方

5.1 现象:OLED 花屏或只亮一半

原因:I2C 时序不对,或者初始化命令里对比度、扫描方向配错。F103 的硬件 I2C 有已知的锁死问题,很多人用软件模拟反而更稳。 解决:先跑一个最简单的 OLED 清屏测试,确认接线和地址(通常 0x78 或 0x7A);如果硬件 I2C 卡死,换成 GPIO 模拟 I2C,延时用 __NOP() 凑。

5.2 现象:FFT 结果全是噪声,频谱没有明显峰值

原因:ADC 采样率与 FFT 点数不匹配,或者输入信号没有加偏置。F103 的 ADC 只能测 0-3.3V,交流信号要抬到 1.65V 附近。 解决:用信号发生器输入 1kHz 正弦,先看波形显示是否正常;如果波形正常但频谱乱,检查 twiddle 表是不是 Q15 格式、位反转有没有做。

5.3 现象:编译报错 “undefined reference to arm_math”

原因:工程里混用了 DSP 库的调用,但没加库文件。这套代码是纯手写 FFT,不应该依赖 arm_math。 解决:全局搜索 arm_math,把相关 include 和调用删掉,换成工程自带的 fft_radix2_q15。

5.4 现象:波形刷新特别慢,OLED 像幻灯片

原因:每采一个点就刷一次屏,或者 FFT 放在主循环里阻塞执行。 解决:改成 DMA 双缓冲 + 中断里做 FFT,主循环只刷显示;OLED 用 SPI 接口,I2C 上拉电阻换成 4.7k。

5.5 现象:频谱柱状图只有左边几根有反应

原因:输入信号频率超过了采样率的一半,产生混叠;或者只取了前 64 点但信号在高频段。 解决:确认采样率至少是信号频率的 2.5 倍;如果信号频率高,把 FFT 点数增加到 512,或者取前 128 点显示。

6. 进阶技巧:把 FFT 点数扩到 512 并验证频率分辨率

如果你想让频谱更细,最直接的办法是把 FFT 点数从 256 扩到 512。改动点有四个:第一,FFT_N 改成 512,FFT_LOG2N 改成 9;第二,twiddle 表长度变成 256,重新用 Python 脚本生成;第三,DMA 缓冲区长度改成 512,TIM 触发频率不变的话频率分辨率会减半,比如原来 10kHz 采样 256 点分辨率 39Hz,现在 512 点就是 19.5Hz;第四,OLED 显示时取前 128 个频点,每根柱子宽度 1 像素,刚好铺满 128 列。

验证方法很简单:用信号发生器输入一个已知频率,比如 1kHz,看频谱峰值落在第几根柱子上。计算公式是 峰值索引 = 信号频率 / 采样率 * FFT_N。10kHz 采样、512 点、1kHz 信号,索引应该是 51.2,实际会落在 51 或 52 根柱子附近。如果偏差超过 2 根柱子,检查定时器实际触发频率是不是 10kHz,用示波器量一下 TIM2 的 CC2 输出。

// 512 点 FFT 后的频率索引验证 uint16_t peak_index = 0; int32_t peak_mag = 0; for (uint16_t i = 1; i < 256; i++) { // 跳过直流 int32_t mag = (int32_t)fft_real[i] * fft_real[i] + (int32_t)fft_imag[i] * fft_imag[i]; if (mag > peak_mag) { peak_mag = mag; peak_index = i; } } // 实际频率 = peak_index * 采样率 / FFT_N float freq = peak_index * 10000.0f / 512.0f;

这段代码跑完,把 freq 打印到串口,和信号发生器读数对比。误差在 1% 以内就算正常。注意 F103 的串口打印别用浮点 %f,会拉进一大堆库,改成整数打印再手动加小数点。

从那以后我每次改 FFT 点数,都强制走一遍“信号发生器输入已知频率 → 串口打印峰值索引 → 反算频率”这个流程,不然光看 OLED 柱子高低根本不知道对不对。希望帮到你。

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

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

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

立即咨询