简介:基于STM32F103与OLED12864的FFT音乐频谱项目完整资料,面向嵌入式初学者、电子爱好者和数字信号处理学习者。项目通过STM32F103采集音频信号,利用FFT算法将时域数据转为频域,并在OLED12864上呈现条形图、波形图等多种动态频谱效果,同时提供原理图,方便硬件连接与复现。压缩包共244个文件,约9.41MB,以C源码(.c/.h)、Keil工程文件(.uvprojx)、编译产物(.hex/.axf)、原理图(.pdf/.jpg)为主,辅以配置文件(.ioc/.sct)等,目录结构清晰,便于阅读和烧录。已有15063人学习/下载。资料涵盖STM32外设初始化、OLED驱动、FFT计算、实时显示刷新等完整实现流程,并附带设计文档与说明,既能辅助理解微控制器与数字信号处理结合的方法,也可直接作为课程设计或DIY音乐频谱仪的基础工程。
1. 项目整体设计思路
1.1 核心需求解析
手头攒了一批STM32F103最小系统板,正好朋友送了几块0.96寸OLED12864,就想做一个能"看得见声音"的小玩意。刚开始的想法很简单:用麦克风把环境声音采进来,经过FFT变换后在OLED上画出频谱柱状图,听音乐的时候能跟着节奏跳。做着做着发现,单纯的柱状图太单调了,于是陆续加了平滑曲线、峰值保持、对称蝴蝶效果等多种显示模式,整个项目就从"能亮"变成了"耐看"。
这个项目最核心的三个关键词其实对应了三条链路:STM32F103负责运算与控制,OLED12864负责图形化呈现,FFT则是连接音频信号与可视化结果之间的数学桥梁。如果你是想学单片机信号处理、想在STM32上跑DSP算法、或者单纯想做一个酷炫的桌面小玩意,这个项目都值得搭一遍。它不像纯点灯那样简单,但也不至于涉及操作系统或复杂通信协议,恰好处于一个很有学习价值的中间档位。
1.2 方案选型与组合逻辑
为什么选STM32F103而不是Arduino或者ESP32?说实话,如果是做原型,Arduino的FFT库移植起来更快,ESP32还能顺便连蓝牙。但STM32F103有一个不可替代的优势:它拥有一套完整且成熟的DSP运算库,CMSIS-DSP库里的FFT函数直接用,配合72MHz主频,做128点或256点的FFT运算耗时只有几毫秒,完全能支撑实时频谱显示。同时,它内部集成了ADC和DMA,采集音频时可以让DMA自动搬运数据到内存,CPU几乎不参与,这种"后台采集、前台计算"的架构在Arduino上模拟起来反而费劲。
OLED12864选的是SSD1306驱动芯片的0.96寸屏,I2C接口版本只需要一根SDA和一根SCL,接线极其简单。分辨率和响应速度上,128x64像素做频谱显示完全够用,而且I2C方式下刷屏速度虽然不如SPI,但配合精心设计的缓冲区刷新策略,实测帧率能稳定在25fps以上,视觉上已经非常流畅。
FFT部分用的是256点实数FFT,输入信号是ADC采集的单通道音频信号。选择256点而非512点或1024点,核心考量是刷新率和频率分辨率之间的平衡。256点FFT在8kHz采样率下,频率分辨率是8000/256=31.25Hz,人耳对低频段的分辨精度已经够用,而单次FFT计算时间约为1ms左右,整条采集、变换、显示流水线走完,能轻松支撑每秒30帧的显示刷新。
2. 核心原理:从音频信号到频谱显示
2.1 音频采集链路:从驻极体到ADC
音频采集是整个系统的起点,也最容易出错。最常用的方案是驻极体麦克风(MIC)+ 运算放大器。驻极体麦克风内部自带一个结型场效应管,工作时要给它提供偏置电压(通常通过一个2.2kΩ到10kΩ的电阻接到VCC)。不过麦克风输出的信号非常微弱,幅度大概只有几毫伏到几十毫伏,直接送给STM32的ADC去采样,分辨率会非常差,所以中间必须加一级放大。
我用的是一颗LM358运放搭建的同相放大电路,增益设置在50倍左右(Rf/Rg + 1)。实际测试时,50倍增益下,正常说话距离30厘米时ADC采样值的波动范围能到满量程的30%到60%,播放音乐时接近满量程。如果增益太大,信号容易削顶失真,频谱上会产生大量高次谐波,看起来就是"满屏都是柱子";增益太小,频谱图又萎靡不振,所以50倍算是一个比较均衡的起点值。
还有几个细节值得特别注意:第一,ADC的参考电压默认是VDD(3.3V),所以运放输出必须偏置在1.65V左右,通常用两个10kΩ电阻分压,给运放同相输入端提供一个VCC/2的偏置电压。第二,STM32F103的ADC输入阻抗不够高,如果运放的输出阻抗较大,会导致采样值不准确,最好在ADC引脚和地之间加一个0.1μF的电容做电荷保持。第三,必须在信号进入ADC前加一个一阶低通滤波器,截止频率设置在4kHz左右,防止高于奈奎斯特频率的高频成分混叠到频谱中。
2.2 FFT原理与STM32实现细节
FFT是"快速傅里叶变换"的英文缩写,它的作用是把一段时域信号拆解成一组不同频率的正弦波分量,简单理解就是告诉你"这段声音里,哪些频率的成分比较强"。这个原理性的东西很多文章都在讲,这里不铺开数学推导,重点说工程实现。
在STM32F103上做FFT有两条路:自己写代码或者用CMSIS-DSP库函数。自己写的FFT代码(基2时间抽取法)网上很多,移植不难,但有个致命问题——它用到大量三角函数和蝶形运算中间变量,如果对定点数处理没经验,很容易出现溢出或者精度丢失。CMSIS-DSP库是ARM官方出品的DSP函数库,里面提供了经过高度优化的FFT函数,用汇编指令和SIMD指令加速,编译后代码体积小且执行速度快。
在标准库工程里,我采用的是arm_cfft_radix4_q15函数,这个函数对应16位定点FFT运算,比较适合STM32F103这种不带FPU的单片机。当然也可以选 f32 浮点版本,但F103没有硬件浮点单元,浮点运算完全靠软件模拟,速度会慢很多,所以我最终选择了 q15 版本。使用流程是:
// FFT输入输出缓冲区 int16_t fft_input_buf[512]; // 实数部分+虚数部分,共256点,故长度512 int16_t fft_output_buf[256]; // 存放幅值 // 初始化4级基4 FFT结构体(256点) arm_cfft_radix4_instance_q15 fft_inst; arm_cfft_radix4_init_q15(&fft_inst, 256, 0, 1); // 将ADC采集的数据填入fft_input_buf实部,虚部填0 for (int i = 0; i < 256; i++) { fft_input_buf[2*i] = adc_data[i] - 2048; // 去掉直流分量 fft_input_buf[2*i+1] = 0; } // 执行FFT变换 arm_cfft_radix4_q15(&fft_inst, fft_input_buf); // 计算幅值 arm_cmplx_mag_q15(fft_input_buf, fft_output_buf, 256);实际使用中有几个非常关键的坑。第一,去直流这一步不能省。ADC采集到的音频信号叠加在1.65V直流偏置上,如果不减去2048(12位ADC的中值),直流分量会占据FFT输出的第0个频点,数值巨大,如果不做处理,整张频谱图会被这个巨型直流量拉扁。第二,q15格式的输入范围是[-32768, 32767],ADC数据范围是[0, 4095],减2048之后范围变成[-2048, 2047],为了让FFT输出更饱满,可以将信号左移4位,让它接近满量程。第三,arm_cmplx_mag_q15计算出的幅值会有一个由点数带来的增益(256点实FFT的幅值大约是真实的128倍),在软件中需要适当缩放或移位对齐,才能得到稳定的柱状图。
2.3 OLED显示与帧率控制
OLED12864屏的驱动芯片是SSD1306,它自带1KB的显存(128x64bit)。I2C模式下,每次刷新全屏需要连续发送1024字节数据,如果直接频繁全屏刷新,I2C总线的带宽会成为严重的瓶颈。假设I2C速率设为400kHz,理论上每传输一字节大约需要20μs,1024字节就是20ms,意味着全屏刷新频率极限只有50fps,还要算上协议开销和命令传输,实际会掉到30fps以下。
所以要想让显示流畅,光靠全屏重绘是不够的,得在刷新策略上做文章。我最开始的版本是全屏重绘逻辑,实测频谱跳动时画面闪烁感明显。后面改为区域刷新方案:频谱图本身只占屏幕下方0到40行的区域,上方则用来显示模式名称、帧率等信息。每次刷新时,我仅更新频谱区域的像素数据,其他区域保持不动。这个优化让实际刷新时间缩减了接近一半。
另一个重要细节是灰度与反色处理。SSD1306是单色屏,但你可以通过调整"是否点亮该像素"来做出层次感。比如频谱柱状图,底部(低频区)柱子可以画实心,顶部(高频区)只画外框,这样看起来就有高有低、有疏有密的节奏感。还可以做"镜像对称"效果:左侧画左声道频谱,右侧镜像画一份,屏幕利用率瞬间翻倍,视觉效果非常抓眼球。
3. 实操过程:原理图、代码与多显示效果实现
3.1 原理图设计与硬件注意事项
这个项目的原理图总共就三个模块:音频采集前端、MCU最小系统、OLED接口,不算复杂。
音频采集前端的核心器件是驻极体麦克风和运放。建议运放供电直接用3.3V,这样输出不会超过ADC量程。如果手头只有LM358,注意它并非轨到轨输出,3.3V供电时输出摆幅实际只能到约0.2V到2.8V,正好避开了ADC的上下限,实测问题不大。更优选是LMV358或SGM8532这类轨到轨运放,输出可以接近0V和3.3V,动态范围更大。
MCU最小系统不必重复画,直接用手头的STM32F103C8T6最小系统板,原理图上标注PA0为ADC输入、PB8/PB9为I2C引脚即可。OLED的I2C上拉电阻(4.7kΩ)必须要有,部分OLED模块板上虽然已经焊好,但如果是裸屏就要自己补上。还有电源滤波:麦克风电路对电源纹波比较敏感,建议在运放供电脚加一个10Ω电阻串一个10μF电容做RC滤波,能明显降低底噪。
原理图里值得留意的部分是参考电压电路。如果追求ADC采样精度,VREF+引脚最好用单独的参考电压源或者至少加一枚0.1μF去耦电容。实际测试中,如果VREF直接连3.3V电源而电源噪声较大,FFT频谱的低频段(0到100Hz)会出现明显的噪声底,看起来像一条抬升的曲线。这是因为电源噪声的直流成分和工频纹波被FFT拾取了。所以,给模拟部分单独滤波是值得多花几个元器件的。
3.2 代码架构与核心实现
整个工程分为三个模块:adc_audio.c(音频采集)、fft_process.c(频谱运算)、oled_display.c(显示驱动和特效),外加一个main.c做调度。
音频采集用定时器触发ADC + DMA搬运的方式,这是整个项目最关键的技术点。我配置TIM3更新事件作为ADC触发源,采样率设定为8kHz,每次触发采样1次,DMA把ADC转换结果自动搬运到一个256字节的缓冲区中。缓冲区设计成双缓冲,DMA采满256个点后进入中断,把"满缓冲指针"和"空缓冲指针"对调,CPU在空缓冲上做FFT运算,DMA同时在满缓冲上继续采集,两者互不干扰。
void ADC_DMA_Init(void) { // 配置TIM3更新事件频率 = 72MHz / (9000-1) / (1-1) = 8kHz TIM_TimeBaseInitStructure.TIM_Period = 8999; TIM_TimeBaseInitStructure.TIM_Prescaler = 0; TIM_TimeBaseInitStructure.TIM_ClockDivision = 0; TIM_TimeBaseInitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStructure); // 定时器输出触发ADC TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); // 配置ADC: 规则通道0, 采样时间55.5周期 ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); }之所以用定时器触发,而不是连续转换再软件读取,原因是采样间隔必须均匀。如果让ADC自由连续转换,每次循环读取时,循环代码的执行时间不确定,会引入抖动,频谱上就表现为噪声和泄漏。定时器触发的方式在硬件层面保证了采样时间间隔是精确恒定的,后续FFT结果才可靠。
主循环的调度逻辑也简单:DMA中断置一个全局标志位,主循环检测到标志后执行一次FFT运算,再调用不同模式的绘制函数。
3.3 多种显示效果的设计思路
这个项目名字里带着"多种显示效果",我在实际开发中一共实现了5种模式,用按键按键切换(按键接到PA0,外部中断触发):
模式一:经典柱状图。这是最基础的样式,把128列分成32个柱,每个柱对应一组频点(256点除去直流后,前后各取一部分,合并到32个区间)。采用对数频率轴映射,低频区每个柱包含较少的频点,高频区每个柱包含较多的频点。这很关键——人耳对频率的感知是对数关系,线性映射会让低频段挤成一团,高频段稀稀拉拉,显示效果非常不均衡。另外还要设置一个"最小高度阈值",防止无声音时频谱图全是噪点。
模式二:平滑曲线图。把每个频段的幅值作为纵坐标,用直线把各个点连接起来。这种模式下帧率是瓶颈,因为SD1306没有画线硬件,需要用Bresenham算法逐点绘制,128个点的连线运算量不小。我的优化办法是建立一个128字节的"上一帧行坐标数组",每帧只更新与上一帧不同的像素点,而不是全屏重绘,实测可以将绘制时间从18ms压缩到8ms以内。
模式三:峰值保持柱状图。在经典柱状图基础上,给每个柱子维护一个"峰值"值,只有当实时幅值超过峰值时才更新,否则每帧让峰值缓慢下降一个像素。实际效果是柱子上方有一条缓缓下落的"小尾巴",很有动态感,是五种模式里观感最好的一种。
模式四:对称蝴蝶图。把频段数据复制一份并左右镜像,右侧显示镜像后的数据,左侧正常显示,加上底部的一个对称中心线,视觉效果像一个张开的翅膀。这种模式对短数据的展示非常友好,建议频率轴只取前64个频点做映射,避免高频端稀疏导致两侧图形不对称。
模式五:环形频谱。这是最难的一种,需要把幅值映射到一个圆环的半径上,逐点计算极坐标到直角坐标的转换,涉及大量浮点运算。由于F103没有FPU,我改用查表法:把128个角度对应的cos和sin值提前算好存成数组,查询时直接index,运行速度提升了好几倍。这种模式在OLED上视觉冲击力最强,但绘制和闪烁控制的复杂度也最高,作为彩蛋模式保留。
4. 常见问题与排查技巧实录
4.1 频谱乱跳或显示异常的处理
做这个项目过程中,我整理了一些高频问题,列成表格方便你直接对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 频谱图整体向上偏移 | 未去除直流分量 | FFT输入前先减去ADC中值(约2048) |
| 频谱低频段噪声粗 | 电源纹波过大 | 模拟电路电源加RC滤波 |
| 柱状图跳动太剧烈 | 帧率不够、采样数据抖动 | 加大柱状图的高度平滑系数 |
| 完全没有频谱,只有底噪 | 音频增益过低 | 增大运放放大倍数或检查偏置电压 |
| 画面闪烁严重 | 全屏刷新频率过高 | 改用局部刷新算法 |
| 部分柱子始终低矮 | 频率映射区间设置错误 | 检查频点分组后的取最大值逻辑 |
4.2 两个容易踩的深坑
第一个坑是FFT输出幅值的归一化。我第一次做的时候,直接把arm_cmplx_mag_q15的输出值送去画图,结果屏幕上大部分区域都被塞满了——因为幅值随着FFT点数增加而增大,且低频分量天然很大。后来我加入了两级处理:先做一次移位缩放(右移4位),再经过一个log1p 风格的非线性映射(y = log(1 + x) 的查表近似),才让频谱图呈现出"低频高、高频低但整体均衡"的漂亮形态。很多教程没有提这一步,新手按部就班做出来,效果差就是差在这。
第二个坑是OLED的I2C通信不稳定。SPI接口的OLED偶尔出现花屏还勉强能忍,I2C如果初始化时序不对,直接整屏不亮。排查时用逻辑分析仪抓SDA/SCL波形能发现很多问题。另外STM32F103的I2C外设设计有一个众所周知的bug:EV5事件判断偶尔会卡死。我一开始用标准库的I2C_GenerateSTART + 事件轮询,结果跑十几分钟就死机一次。换用软件模拟I2C(GPIO翻转)之后,稳定运行几天都没事。对于这种小项目,软I2C的牺牲完全可以接受。
4.3 提升频谱效果的进阶技巧
如果你希望频谱看起来更"专业",有几点经验可以直接套用:
- 平滑滤波:对每个频段维护一个平均值,当前帧幅值 = 上一帧幅值 * 0.7 + 当前原始值 * 0.3,这个系数能让柱状图大幅减少上下抖动,看起来就像高级音响上的频谱显示器。
- 频率分段权重:音频信号的能量大多集中在中低频,直接把各频点幅值等权映射,会导致高频柱子几乎看不见。建议对每根柱子都乘以一个递增的增益系数(高频增益大),这样整张图显得更有层次感。
- 环境噪声门控:环境安静时ADC也会采到微弱的底噪,如果不加限制,频谱图会一直显示一排矮柱子。可以设计一个简单的门限:当当前帧总能量低于阈值时,清零频谱数据,让屏幕保持干净,等音乐响起再"苏醒"。
这些技巧虽然都是些小点,但对最终观感的影响非常大,建议边做边调整。
我在整个调试过程中最受用的体会是:FFT频谱显示这个项目,表面看是硬件和代码的问题,其实大部分时间都花在"把信号处理得干净、把显示调得耐看"上。信号链路里任何一处的噪声,都会在频谱图上被无情放大;显示逻辑里任何一个粗糙的数据处理,都会被观众一眼看穿。所以做这个项目别急,先让ADC采出来的波形干净,再让FFT算出来的幅值正确,最后才轮到考虑显示效果。另外,如果你想把这个项目扩展成双声道版本,只需要再加一个ADC通道,把左右声道数据分别做FFT,然后将两路幅值分别映射到左右半屏即可,思路完全一致,只是代码量会翻倍。做完这个项目再回头看,其实你已经掌握了一套完整的"模拟信号采集 + DSP运算 + 图形化输出"的嵌入式基本功,这套能力在后续做语音识别、振动监测、电机故障诊断时都能直接迁移。
本文还有配套的精品资源,点击获取