☰
STM32F103+OLED12864+FFT音乐频谱:低成本音频可视化的完整实现
2026/10/16 19:09:27 网站建设 项目流程

简介:面向嵌入式开发者和电子爱好者,这份STM32F103+OLED12864音乐频谱资料包以FFT频谱分析为核心,集成条形图、波形图等多种显示效果,并附硬件原理图,方便复现完整的音频可视化项目。压缩包共244个文件、约9.41MB,核心是STM32 HAL库源码(36个.c与65个.h)及Keil工程文件,另有编译中间文件、PDF原理图、DOCX说明文档、CubeMX配置和实物图片,结构清晰,便于对照学习。目前已有15089人学习,热度可观。资料最大价值在于从ADC采集、FFT运算到OLED刷新的完整代码链,配合原理图可快速定位硬件连接,适合想深入理解数字信号处理与嵌入式交互的中级开发者参考。

1. stm32f103 + OLED12864 + FFT 音乐频谱:小成本也能把声音画成动态画面

第一次看到 stm32f103+OLED12864+FFT 音乐频谱的视频时,我第一反应是网上又加了特效:一颗 72MHz 的 M3 要采样音频、算傅里叶、再刷新 I2C 的 OLED,听着就一定会卡。真把最小系统焊出来才发现,128 点定点 FFT 在 F103 上只占几毫秒,真正让画面糊掉、频谱乱跳的,九成是麦克风放大电路和 OLED 刷新策略。

这个标题背后是一条完整的信号链:拾音、偏置放大、ADC 采样、加窗 FFT、频率映射、多效果渲染。适合做桌面音频可视化、低成本频谱分析演示,也适合拿来做嵌入式信号处理入门。配套的“原理图”拿来不是直接抄的,先看三处:输入偏置、OLED 接口、电源去耦,看懂了再动手焊,不然出了问题很容易怀疑 FFT 代码,实际锅全在硬件。

2. 为什么用 FFT 做频谱,以及 128 像素的映射逻辑

2.1 时域波形不是不能画,而是画不出音色

直接让 ADC 采样结果上下跳,也能得到一个“会动的画面”,但它表达的是声音幅度随时间的变化,不是频率分布。你放一段鼓点和一段小提琴,时域波形可能都是“噼里啪啦”,看不出区别;只有把信号转到频域,才能看到低频能量在哪、中频能量在哪、高频泛音有多少。

音乐本质上是多个频率分量的混合,人耳感知的是这些分量在频谱上的分布。单片机做 FFT 就是把一段 ADC 时域数据变成频域数据,再按频率从低到高排列,映射到 OLED 的列上。所以整个项目里 FFT 不是最难的,最难的是“采样数据本身是不是干净、是不是足够均匀”,否则 FFT 算出来的只是一个错误的频域结果。

为什么选 FFT 而不是直接算 DFT?128 点 DFT 要 16384 次复数乘,虽然 F103 硬算也不是不能跑,但每帧都要算,再叠加 OLED 刷新就会吃紧。FFT 把复杂度降到 N log N 量级,1000 多次蝶形运算就能完成,嵌入式上常用而且成熟。直接用现成定点和库是最稳妥的路径。

2.2 定点 FFT、浮点 FFT 在 F103 上的实际取舍

F103 是 Cortex-M3 内核,72MHz,没有硬件浮点单元。如果用float做 FFT,所有浮点运算都会变成软浮点库函数调用,速度掉一个量级,还占很多 Flash。我一般直接用 Q15 定点格式:ADC 的 12 位采样值先移到 Q15 范围,再交给定点 FFT 库,最后把频谱幅度取出来做显示。

FFT 点数决定频率分辨率和一帧采集时间。常见搭配是采样率 8kHz、点数 128,这样频率分辨率是 8000/128 = 62.5Hz,一帧采集时间是 16ms,刚好适合做 20fps 以上的实时显示。如果再想保留更低频的细节,可以把点数加到 256,但采集时间变成 32ms,帧率会下降。OLED 只有 128 列,点数 128 的 64 个有效频点已经够用了,再多点数只增加低端分辨率,视觉上差别不大。

采样率 FsFFT 点数频率分辨率一帧采集时长频率上限适用场景
8000 Hz12862.5 Hz16 ms4 kHz语音、律动型音乐频谱
8000 Hz25631.25 Hz32 ms4 kHz想要低频细节,但帧率降低
16000 Hz25662.5 Hz16 ms8 kHz高频信息更多,M3 压力增大
16000 Hz128125 Hz8 ms8 kHz帧率高,但低频分辨率差

拿 128 点、8k 采样率来说,最低几个频点分别落在 0Hz、62.5Hz、125Hz、187.5Hz 附近。对音乐显示是够的;如果做精密测量,这个分辨率不够看,就得把采样率降低或点数加大。对 F103 这种定位,视觉体验优先,不要追求精度而牺牲帧率。

2.3 频点映射到 128 列:先算 bin,再算列

FFT 输出 N 个点,前 N/2 个是有效正频率,最后一个有效点是 Nyquist 频率 Fs/2。128 点 FFT 有 64 个有效频点,正好对应 OLED 的 128 列,所以最常见的映射是每个频点占两列,左边列和右边列取同一个幅度。

/* 128 列 OLED 对应 64 个 FFT 有效频点 */ static uint8_t col_to_bin[128]; for (int col = 0; col < 128; col++) { col_to_bin[col] = (uint8_t)(col >> 1); /* 0,0,1,1,2,2 ... */ } /* 使用时,第 col 列的显示高度来自 col_to_bin[col] 这个频点 */ uint16_t mag = spectrum[col_to_bin[col]];

这段代码的关键是把“屏幕的列坐标”和“FFT 的频点下标”解耦。这样后续想改成对数频率映射,或者只显示中间一段频带,只需要改这张表,不需要动 FFT 和显示主体逻辑。

有个容易踩的坑:FFT 结果第 0 个点对应直流分量。ADC 输入如果有 1.65V 偏置,FFT 后直流分量会非常大,表现为屏幕最左边一根柱子永远满格。所以在显示前直接把第 0 点高度置 0,或者加一个高通滤波,不要让直流进显示。另外,如果用的 OLED 模块是 128 列,而你想在左右两边留边距,可以最后把高度数组整体平移,不是把 FFT bin 平移。

3. 原理图上最值得先读的三个部分:输入调理、OLED 接口、电源

3.1 麦克风放大与直流偏置:决定频谱是跟着音乐跳还是永远贴地

拿到原理图先找麦克风输入这一级。驻极体麦克风需要上拉偏置电阻,常见的做法是给 MIC 一个 2.2k 到 10k 的偏置,经过耦合电容送到运放同相端。运放输出必须偏置在 ADC 满量程的一半,也就是 1.65V,这样声音信号才能在 0V 到 3.3V 之间摆动,只有正电压、没有负电源。

单电源运放的偏置常用两个 10k 电阻分压得到 1.65V,再串一个小电容和 100k 电阻进入同相端,避免偏置被前级影响。放大倍数不要盲目调大。常见增益在 20 到 50 倍之间,也就是 R2/R1 + 1 在这个范围。如果增益太大,说话声稍大一点 ADC 就削顶,频谱上会出现一堆虚假高频谐波;增益太小,FFT 幅度贴地,柱子跳不起来。

看原理图时重点确认三件事:麦克风有没有正确偏置、运放供电是不是单 3.3V 且输出偏置在 1.65V、ADC 输入引脚有没有超过 3.3V 的风险。很多“频谱只在低频跳”的问题,不是 FFT 算错,而是输入信号本身低频成分过大或偏置不对。

3.2 OLED12864 的 I2C、SPI、并口,选择对帧率影响很大

OLED12864 模块常见主控是 SSD1306 或 SH1106,接口有 I2C、SPI、并口。标题里只说 OLED12864,没限定接口,所以原理图上这部分要仔细认。如果是 I2C 接口,只有四根线,接线最简单,但传输一帧 1KB 图像要十几毫秒;如果是 SPI 接口,数据线多两根,但刷新速度快非常多。

接口类型需要引脚一帧 128×64 图像传输估计结论
I2C 400kHzSCL、SDA、RST、DC 较少约 20ms 级可玩,但刷新率受限
SPI 18MHzSCK、MOSI、CS、DC、RST约 1ms 级推荐,多效果不闪烁
8080 并口8 根数据 + 控制线最快,但占用引脚多效果最流畅,布线复杂

我建议新做硬件直接选 SPI 接口的 OLED,除非你手里只有 I2C 模块。I2C 做静态 UI 没问题,但做音乐频谱这类动态画面,每帧全屏刷新会明显吃力。如果你非要 I2C,就要用“整帧缓存 + 只更新变化列”的办法,否则屏幕闪得没法看。

原理图里如果已经有 OLED 插座的原理图,看它是否把 DC、CS、RST 接到了可用的 GPIO。很多现成模块把 CS 和 DC 固定接死,只能某些接口工作。先看模块背面的电阻配置,再对照原理图连线,否则焊上去要么白屏要么花屏。

3.3 电源和地:ADC 的噪声会直接变成频谱底噪

F103 的 ADC 是 12 位,但模拟和数字共用一个 3.3V 时,数字翻转耦合进 VREF 的噪声会到几个 mV,换算成频谱就是底噪抬高。原理图里常见的做法是给 ADC 的供电单独走一段线,串磁珠或小电感,再接 10uF 和 100nF 电容去耦。别把这部分当摆设。

OLED 背光和 F103 的 IO 翻转是板上最大的数字噪声源。如果 OLED 和运放共用一根长走线的 3.3V,每次刷屏时 ADC 采集都会叠上毛刺。我一般会让 OLED 供电走独立线,或者至少让电源从 USB 或稳压输出端先经过 10uF 电容,再分给模拟和数字两条支路,模拟支路串个 10 到 100 欧电阻做 RC 隔离。

地线比电源线更容易翻车。两层板做不到完整模拟地分割的时候,不要大面积把模拟地和数字地切开,反而在 ADC 的 VSSA 附近单点连接。原理图上如果标了 AGND 和 GND,先确认它们有没有在某个点汇合,别让所有信号都绕远路回流。

4. 软件实现:稳定采样、定点 FFT、多种显示效果切换

4.1 用定时器触发 ADC,而不是 delay 采样

采样间隔必须稳定。delay + ADC_Read这种方式受中断和 OLED 刷新影响,时间抖动很大,FFT 出来的谱线会变宽、底噪变高。正确做法是让定时器周期产生触发事件,ADC 被硬件触发后自动转换,再用 DMA 把结果搬到内存里,CPU 只在缓冲区满了以后处理数据。

/* 定时器触发 ADC,目标采样率 8kHz,F103 定时器时钟按 72MHz 算 */ TIM_TimeBaseInitTypeDef tim_base; tim_base.TIM_Prescaler = 71; /* 72MHz / (71+1) = 1MHz */ tim_base.TIM_Period = 124; /* 1MHz / (124+1) = 8kHz */ tim_base.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tim_base); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); /* TRGO 用于触发 ADC */

这里Prescaler=71和Period=124是常见组合,计算逻辑是 72MHz 先被分频到 1MHz,再数 125 个周期产生一次更新事件,最后就是 8000Hz。如果你想改成 16kHz,可以把Period改成 61,得到约 16.129kHz,误差不大。

ADC 配置成外部触发模式,触发源选 TIM2 TRGO,然后开 DMA 循环传输到缓冲区。注意 DMA 只搬连续的一批样本,不要在主循环里等单个样本。批量处理时用一个双缓冲:DMA 往 buf A 写,主循环处理 buf B,处理完再交换指针。这样采样和 FFT 不会互相等,画面更稳。

4.2 加窗、去直流、转 Q15:FFT 输入不是 ADC 裸数据

ADC 直接读到的 12 位数据带着直流偏置,如果不减去 2048,FFT 第一根柱子会顶满。另外,128 点 FFT 只截取了一小段时域信号,等效于给信号加了矩形窗,频谱会泄漏。处理方式是用汉宁窗,让帧头帧尾幅度平滑衰减。

#define FFT_N 128 #define ADC_MID 2048 __IO uint16_t adc_buf[FFT_N]; q15_t fft_buf[2 * FFT_N]; /* 实部虚部交替存放 */ q15_t hanning_q15[FFT_N]; /* 预计算好的汉宁窗系数 */ /* 从 ADC 缓冲区构造 FFT 输入 */ void prepare_fft_input(void) { for (uint16_t i = 0; i < FFT_N; i++) { q15_t x = (q15_t)(((int16_t)adc_buf[i] - ADC_MID) << 4); fft_buf[2 * i] = (q15_t)(((int32_t)x * hanning_q15[i]) >> 15); fft_buf[2 * i + 1] = 0; } /* 调用定点 FFT 库,以下为 CMSIS-DSP 风格示意 */ arm_cfft_q15(&fft_inst, fft_buf, 0, 1); arm_cmplx_mag_q15(fft_buf, spectrum_q15, FFT_N); }

这段代码有三个关键参数:ADC_MID是 12 位 ADC 的中间值;<<4是把 12 位采样范围搬进 Q15 满幅,避免输入幅度太小;汉宁窗系数需要提前算好,运行时直接查表,不要在每帧里现场算三角函数。arm_cfft_q15和arm_cmplx_mag_q15是 CMSIS-DSP 的常用函数名,不同库版本可能叫法不同,实际使用时按你工程里的头文件为准。

我一般不用float输出,直接把 Q15 幅度值用于显示。显示时做一次比例换算,把 32767 映射到 OLED 最大高度。如果发现频谱整体偏低,不要盲目放大显示系数,先检查输入幅度是不是太小,否则会把底噪也放大。

4.3 多种显示效果:柱状、镜像、峰值保持、抖动灰度

OLED 是单色屏,显示效果靠“刷新策略”撑起来。最简单的是柱状图:每列高度来自对应频点,高度越高点亮越多的点。可以做一个draw_mode状态机,按键切换显示效果。

uint8_t draw_mode = 0; uint8_t peak[128]; void update_spectrum_display(void) { switch (draw_mode) { case 0: /* 普通柱状图 */ for (uint8_t col = 0; col < 128; col++) { uint8_t h = mag_to_height(spectrum_q15[col >> 1]); draw_vertical_bar(col, h); } break; case 1: /* 镜像频谱 */ for (uint8_t col = 0; col < 128; col++) { uint8_t h = mag_to_height(spectrum_q15[col >> 1]); draw_mirror_bar(col, h); /* 上下对称 */ } break; case 2: /* 柱状图 + 峰值保持 */ for (uint8_t col = 0; col < 128; col++) { uint8_t h = mag_to_height(spectrum_q15[col >> 1]); if (h > peak[col]) peak[col] = h; else if (peak[col] > 0) peak[col]--; draw_vertical_bar(col, h); draw_pixel(col, OLED_H - 1 - peak[col]); /* 顶部一个亮点 */ } break; default: break; } }

这段代码里mag_to_height把 Q15 幅度换算成像素高度,peak[]是每列峰值保持数组。峰值保持线的关键不是上升快,而是下降慢。下降速度选每帧减 1 像素,大概 20 帧降 20 像素,看起来有“余晖”感,太慢会让画面拖沓。

镜像效果是把同一份频谱画到上下两侧,视觉冲击力强,但高度需要限制在 OLED 一半以下,否则上下会重叠。抖动灰度则是用 2x2 点阵模拟不同亮度,适合做渐变效果,但不要做全屏 4 级灰度,因为 OLED 像素切换需要额外时间,效果过度会变成闪烁。

5. OLED FFT 频谱常见问题与排查

5.1 I2C 扫描不到 OLED:上拉、地址、供电一个都不能少

现象是屏幕白屏,程序初始化 OLED 不报错也没有显示。用逻辑分析仪看就知道 SCL 上有规律波形,但 SDA 始终没有响应。原因多半是 OLED 模块的 I2C 地址不对,或者模块默认地址是 0x3D 而你代码写的是 0x3C。还有不少模块是 5V 供电但电平不转换,IO 通信不稳定。

解决方法是先只写一个 I2C 扫描函数,在总线上遍历 0x3A 到 0x3F,把响应的地址通过串口打印出来。同时确认 SCL、SDA 上有没有 4.7k 左右上拉电阻。如果模块自带上拉,F103 的 I2C 外设通常还能用;如果是 GPIO 模拟 I2C,上拉电阻更不能少。别一上来就调 FFT,先把屏点亮。

5.2 频谱只有最左边两根柱子跳,右边全部不动

现象是放音乐时屏幕左边两格会动,其他区域基本死寂。原因通常有两个:采样率被设得非常低,导致整个音乐频谱都挤在前几个频点;或者 ADC 直流分量没去干净,FFT 结果被直流和极低频主导。

解决方法是先用已知信号验证。让手机播放 1kHz 正弦波,如果 8kHz 采样、128 点 FFT,应该在第 16 个频点附近看到明显峰值,也就是大约第 32、33 列。如果算出来峰值在第 1、2 列,说明定时器分频配错,实际采样率远低于 8kHz。检查PSC和ARR是不是按定时器时钟 72MHz 算的,如果 APB1 分频配置不同,定时器时钟可能是 36MHz,公式差一倍。

5.3 OLED 刷新时整个屏幕闪,或者有滚动条纹

现象是普通柱状图能显示,但画面一直在闪,尤其切到镜像效果后更明显。原因是一次全屏清屏、再逐列绘制,OLED 驱动芯片接收到的数据和用户看到的显示画面之间存在“半更新”状态,I2C 模式下这个过程拉长变成肉眼可见的闪烁。

解决方法是不要用ClearScreen再画,直接在内存里维护一个 1024 字节的帧缓冲,所有绘制函数都写帧缓冲,最后把整帧缓冲通过一次或分几次写入 OLED。更省时间的做法是只写发生变化的页或列,但代价是代码复杂一些。如果你的 OLED 支持 SPI,我强烈建议这一版就换 SPI,刷新率翻几倍,闪烁问题会少很多。

5.4 ADC 采集到明显的工频干扰,频谱底噪高

现象是频谱底下一层“草”一直有,不放音乐也跳,或者有规律地上下波动。原因可能是板子供电来自 USB,工频通过地线耦合进来;也可能是 OLED 背光刷新导致电源波动,而 ADC 和背光共用了电源。

解决方法是先用电池或线性稳压供电,确认是否还有底噪。如果电池供电就干净,说明噪声从外部电源地进来。再看模拟部分有没有 10uF 大电容和 100nF 高频电容。ADC 采样尽量在背光比较稳定的时间段触发,不要把背光的 PWM 频率和 ADC 采样频率设置成接近的整数倍,否则会形成拍频。

5.5 声音一大频谱就“平头”,到处都顶格

现象是小声播放时频谱看着正常,声音一大,大量柱状图同时顶到最高点,像削平了一样。原因就是运放增益过高或 ADC 输入过幅,信号被削顶,波形变成方波,FFT 会产生很多高次谐波,结果整个频谱看起来都是平的。

解决方法是把运放增益降下来,或者换用更合适的驻极体偏置电阻。如果希望小声也能跳、大声不削顶,可以加自动增益控制电路,但 AGC 会让频谱幅度跟随时间变化,视觉上柱子会有“呼吸感”。我通常宁可留 30% 余量,也不做自动增益;嵌入式入门阶段先把静态增益调好,后面再谈动态范围。

6. 进阶验证:先让 1kHz 正弦波在固定柱上“动”,再谈多效果优化

改变显示效果之前,先做一次系统标定。我在调试这类频谱项目时,第一步永远是拿手机播放 1kHz 纯音,然后让程序把 FFT 原始幅度通过串口发出来。8kHz 采样、128 点 FFT,1kHz 应该落在第 16 个频点附近。如果峰值出现在第 15 或第 17 点,说明实际采样率和你配置的 8kHz 有偏差,需要调整定时器分频。这一步可以把“FFT 有问题”和“采样率不对”彻底分开。

标定完成后,再处理显示动态范围。线性幅度显示有一个问题:底噪在低端占很多像素,音乐峰值偶尔才顶到中上部,看上去整体偏低。常见的做法是显示比例用二次方或者分贝刻度,把低幅度区拉开。如果想要更接近“响度感知”,可以做一个 32 项分贝查表,把 -60dB 到 -6dB 映射到 OLED 高度。

/* 把 Q15 幅度换算成分贝,再做 60dB 动态范围映射 */ uint8_t dB_to_height(uint16_t mag_q15) { if (mag_q15 < 2) return 0; int32_t db = 20 * log10f((float)mag_q15 / 32767.0f); /* db 范围约 -90 到 0,只取 -60 以上部分 */ if (db < -60) db = -60; uint8_t h = (uint8_t)((db + 60) * 64 / 60); return h > 64 ? 64 : h; }

这个函数注意要把log10f改成适合单片机的实现,或者提前用脚本生成查表,否则每帧 128 次对数运算很快把 M3 拖垮。我的习惯是用 Python 生成 32768 个映射值太占空间,一般生成 256 个分段查表,误差完全够用。

调完显示再考虑效果切换。不要一上来就做五种效果,先写出普通柱状图和峰值保持,确认刷新率稳定,再叠加镜像和灰度。最后可以用串口打印每帧 FFT 处理耗时,做到心里有数。我后来每次做类似项目都会固定用“1kHz 标定 + 双缓冲 + 定点 FFT”这套流程,省掉了很多“玄学”排错时间。

希望这份拆解能帮到你少踩几个硬件和软件之间的坑。

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

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

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

立即咨询