STM32F103手写FFT频谱仪:雷德算法+0.96 OLED波形显示
简介这是一份面向嵌入式初学者与电子类毕设学生的STM32F103开源项目聚焦波形显示与频谱分析采用个人原创的雷德Radix算法实现FFT。项目基于ST标准外设库开发驱动0.96寸SPI接口OLEDADC1以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 内核没有硬件浮点单元主频 72MHzSRAM 只有 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[kstep] * 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 改成 128FFT_LOG2N 改成 7twiddle 表长度减半采样率决定频率分辨率比如定时器触发 ADC 采样 10kHz256 点分辨率就是 39Hz 左右OLED 上每根柱子代表一个频点。2.3 位反转重排与旋转因子表的生成位反转是雷德算法里最容易翻车的一步。输入 ADC 数据是按时间顺序存的但 FFT 要求先按位反转顺序重排。工程里用了一个查表法预存 256 个 uint8_t 的反转索引数组运行时直接按索引搬数据。旋转因子表则是在 PC 上用 Python 脚本生成导出成 C 数组避免在 MCU 上跑三角函数。# 生成 Q15 旋转因子表N256 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 / (711) / (991) 10kHzADC 采样时间设成 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-30msSPI 快一些但也要 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 搬来的原始数据长度 512wave_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 改成 512FFT_LOG2N 改成 9第二twiddle 表长度变成 256重新用 Python 脚本生成第三DMA 缓冲区长度改成 512TIM 触发频率不变的话频率分辨率会减半比如原来 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 柱子高低根本不知道对不对。希望帮到你。本文还有配套的精品资源点击获取