ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103C8T6音频信号分析仪:从ADC采样到OLED频谱显示

STM32F103C8T6音频信号分析仪:从ADC采样到OLED频谱显示 最近把用STM32F103C8T6实现的音频信号分析仪项目完整整理了一遍Proteus仿真跑通软件程序源代码调稳了OLED12864走SPI通讯实时绘制频谱柱状图。整套东西做下来最大的感受是“麻雀虽小五脏俱全”一块几十块钱的单片机演完了信号调理、ADC采样、定点FFT、图形显示整条链路。这篇文章会从方案选型、硬件电路、软件架构、仿真联调和踩坑记录几个方面完整复盘适合做课程设计、毕业设计的同学也适合想搞明白“单片机怎么处理音频信号”的嵌入式爱好者。先说清楚这东西能干什么。把手机耳机口、信号发生器或者麦克风拾音后的信号送进单片机单片机按固定采样率采集做FFT变换把时域波形变成频谱然后OLED12864上实时显示频率-幅值柱状图同时把峰值频率和大致幅值用数字打出来。给它一个1kHz正弦波屏上在1000Hz附近冒一根柱子换成方波基频加一串奇次谐波全都看得见。系统核心就是“采样—变换—显示”三板斧听起来简单但每一板都有不少细节。1. 先把需求拆清楚这台“音频分析仪”到底要做什么1.1 功能定义与三个核心环节很多人一听说“音频信号分析仪”就发怵以为是示波器、频谱仪那种精密仪器。实际上在STM32F103C8T6这个平台上做出来的是一台教学演示级的单通道频谱分析设备目标很明确把0到几kHz范围内的音频信号实时变成可视化的频谱图。整个项目可以拆成三个环节。第一是信号调理。麦克风或线路的音频信号是双极性交流电压幅度通常只有几十毫伏到几百毫伏而STM32的ADC只能采0到3.3V的正电压。所以不能把信号直接怼进单片机得先经过隔直、偏置、放大、低通滤波这一套前端处理把信号“抬”到1.65V中心附近再限制带宽防止混叠。第二是数字化与变换。STM32内置12位ADC在定时器触发下以固定采样率连续转换用DMA把数据搬到内存不占用CPU。攒够一帧后做FFT把时域数组变成频域数组每个数组元素对应一个频率“格子”的幅值。第三是显示。FFT结果不能直接扔到屏上要做峰值检测、归一化、画柱状图、更新文字这些都在OLED12864上完成。128x64像素的分辨率刚好对应128个频率点一列柱子一个bin天然匹配。这三个环节单独拿出来都不难但合在一起要跟20KB SRAM、72MHz主频较劲就需要精打细算。这也是这个项目最有价值的地方——它逼着你去理解资源分配和实时性平衡。1.2 为什么选STM32F103C8T6而不是51、Arduino或者F407选型理由排第一的永远是性价比和资料密度。STM32F103C8T6这颗Cortex-M3内核芯片72MHz主频20KB SRAM做256点FFT刚刚好64KB Flash装下整个工程加CMSIS-DSP库后还有富余12位ADC自带DMA和定时器触发阵容齐整。对比一下就更清楚。51单片机是8位机内存以字节计做一个256点FFT不是不行但耗时往往接近一秒刷新率完全没法看而且Proteus里仿真外设模型也不如STM32新。Arduino系列开发方便但多数型号主频、ADC精度、DMA能力都差一截教学深度也不够。往上选F407/K66之类的M4芯片带FPU跑浮点FFT确实爽但价格翻几倍对“把定点FFT和内存规划搞清楚”这个学习目标来说属于浪费。这里多说一句现在国产替代芯片GD32F103、APM32F103等可以做到Registers兼容、pin-to-pin替换代码几乎不用改。我做硬件方案时习惯把一个国产型号列入备选作为供应链冗余。对课程设计来说这不是必须的但工程意识可以早一点建立。1.3 为什么用OLED12864还指定SPI通讯显示部分如果选1602字符屏根本画不了频谱柱只能打印几个数字那这个项目就没灵魂了。OLED12864是图形点阵屏128列像素刚好对应FFT的128个频率bin一列一根柱子视觉效果就是一台“频谱仪”。为什么指定SPI而不是I2CSSD1306这块驱动IC两种接口都支持但I2C标准模式只有400kHz的通信速率刷一屏1024字节显存约需20毫秒以上SPI跑到9MHz以上同样1024字节一毫秒左右就能传完。频谱显示要求每秒刷新10到20帧SPI的余量明显更大。另外SPI只占用SCK、MOSI和几根控制线不需要开漏上拉驱动代码也简单。2. 硬件电路设计与Proteus仿真环境搭建2.1 引脚规划与最小系统连接F103C8T6是LQFP48封装资源有限但这次够用。我习惯先把引脚分配表写在纸上再画原理图避免做到一半发现引脚冲突。这个项目的分配如下功能引脚说明音频信号输入PA0ADC1通道0模拟输入OLED数据/命令选择PA1DC脚高电平数据、低电平命令OLED复位PA2RES脚低电平复位OLED片选PA3CS脚SPI从机选择OLED时钟PA5SPI1_SCK复用推挽输出OLED数据PA7SPI1_MOSI复用推挽输出调试串口PA9/PA10USART1可发频谱数据到虚拟终端最小系统部分BOOT0和BOOT1都接GNDBOOT1引脚编号是PB2表格里没列但它存在NRST接上拉10k和0.1uF电容OSC_IN/OSC_OUT之间接8MHz晶振加两个20pF负载电容。电源用3.3V单电源VDDA和VREF统一接3.3V每个电源引脚旁边放一个100nF去耦电容。仿真里不需要真的摆这些电容Proteus的模型不会跟你计较电源纹波但画原理图的时候还是按实际板子的规范来做图例才合格。实际动手时可以直接用现成的STM32F103C8T6最小系统板板上自带晶振、稳压芯片、下载口省掉大半硬件工作量。但不管用最小系统板还是自己画板引脚分配表一定最先定下来。2.2 音频输入调理电路别把信号直接怼进ADC这是整个硬件部分最容易翻车的地方。我不止一次看到有人把耳机放大器的输出直接接到PA0结果屏幕上要么是一条直线要么是削顶的方波最后才恍然大悟音频是双极性信号负半周被ADC当成0处理了。正确做法是先把信号经过隔直电容去掉直流成分再叠加一个1.65V左右的直流偏置让信号在0到3.3V范围内上下摆动。我这里给一套可以直接抄的电路参数信号源经过一个10uF电解电容C1耦合到节点A节点A上接R1和R2两个10k电阻分压从3.3V分得1.65V中点。节点A接到运放的同相输入端运放接成电压跟随器输出端再接一颗1k电阻R3和33nF电容C4对地组成一阶低通滤波滤除截止频率以上的高频成分。截止频率计算fc 1/(2πRC) 1/(2π × 1000 × 33×10^-9) ≈ 4.8kHz。这个频率配10kHz采样率是合理的奈奎斯特频率5kHz低通刚好把带外噪声压住又不至于把4kHz以内的音频衰减太多。运放选型上Proteus里用LM358也能跑通但LM358输出摆幅离电源轨还有一段距离3.3V供电下最大只能到2.8V左右信号幅度稍大就削波。我建议仿真里直接用MCP6002这种轨到轨运放输出可以逼近0到3.3V整个范围实测波形干净很多。如果手边只有LM358把输入信号幅度控制在±0.4V以内也能凑合。前置放大要不要做看信号源。如果只是把Proteus里的信号发生器接到电路上设置100mV到500mV的输出幅度跟随器就够用了。如果以后要接麦克风那还得加40dB左右的仪表放大这属于扩展方向后面再说。2.3 Proteus里的元件选择与OLED模型问题Proteus里建这个仿真工程主要元件就三个STM32F103C8T6、运放、信号源。元件库里搜STM32F103C8T6可以直接放置双击设置Program File时载入Keil或STM32CubeIDE生成的hex文件。运放用LM358或MCP6002都行信号源用VSINE把Frequency设成1kHz、Amplitude设成0.2V就可以。为了观察调理电路输出是否正确再拖一个虚拟示波器OSCILLOSCOPE进来接在运放输出节点上。这里必须说一个很多教程没讲透的坑标准Proteus元件库里没有SSD1306这块OLED的原生模型。我的处理方案是把显示驱动抽象成一层底层写OLED_WriteCmd、OLED_WriteData、OLED_SetPixel三个函数仿真时先对接Proteus自带的图形LCD模型验证采样、FFT、绘图逻辑等轮到真板子时只改底层三个函数上层代码一个字都不用动。这种“驱动接口隔离”的习惯值得养成后面换屏幕、换接口都只需要动一个小文件。Proteus仿真STM32时还有一个老生常谈的问题时钟。模型对HSE外部晶振的模拟行为跟真实芯片略有差异如果程序里配置了PLL倍频到72MHz但仿真里晶振参数不对外设节奏会明显变慢SPI刷屏速度、定时器触发采样率都不对。排查手段很简单在定时器中断里翻转一个GPIO用示波器量实际频率一比照就知道时钟树建得对不对。这个技巧下面还会反复用到。3. 软件程序核心实现采样、FFT、刷屏一条龙3.1 定时器触发ADC加DMA双缓冲采样采样的架构选择是整个软件部分最重要的决策。我直接给出结论用TIM3的更新事件触发ADC转换ADC结果通过DMA1自动搬运到内存缓冲区全程不打断CPU只有DMA传输完成时进一次中断做数据交接。为什么要用定时器触发而不是连续转换因为频谱分析要求等间隔采样连续转换模式下每次转换的时间受ADC时钟和采样周期影响虽然基本稳定但CPU介入时延不确定会导致采样点间隔抖动。定时器触发模式把采样时刻锁定在定时器更新事件上间隔严格均匀FFT结果才不会出现莫名其妙的杂散。采样率怎么算TIM3挂在APB1上主频72MHz。如果PSC设71分频得到1MHz计数频率再设置ARR等于99则1MHz/10010kHz。通用公式是Fs 72MHz / ((PSC1) × (ARR1))。代码片段如下void TIM3_Sample_Init(void) { TIM_TimeBaseInitTypeDef tim; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); tim.TIM_Prescaler 71; /* 72MHz/72 1MHz */ tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 99; /* 1MHz/100 10kHz */ tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, tim); TIM_SelectOutputTrigger(TIM3, TIM_TRGO_Update); TIM_Cmd(TIM3, ENABLE); }ADC配置的要点是关闭连续转换、关闭扫描模式外部触发源选TIM3的TRGO数据右对齐然后使能DMA请求ADC_InitTypeDef adc; 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); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE);DMA用循环模式数据长度HalfWord源地址是ADC1的数据寄存器目标地址是uint16_t数组adc_buf[256]。传输完成中断里把adc_buf的数据搬到另一个处理缓冲区fft_buf并置一个volatile标志位。主循环检测到标志就开始FFT处理。uint16_t adc_buf[256]; uint16_t fft_buf[256]; volatile uint8_t fft_ready 0; void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); memcpy(fft_buf, adc_buf, sizeof(fft_buf)); fft_ready 1; } }注意一个细节10kHz采样率下256个点攒满需要25.6毫秒而一次FFT加绘制大约5毫秒所以这个简单的“一帧拷贝一帧处理”方案完全来得及。中断里只做拷贝和标志操作绝对不在中断里跑FFT这是铁律——CMSIS的FFT执行时间在M3上虽然只有几个毫秒但中断里长时间运行会让采样错过无数个触发事件。3.2 定点FFT的资源配置与代码实现F103C8T6没有FPU直接用float做FFT效率低一大截。CMSIS-DSP提供了Q15格式的定点FFT乘法用单周期指令完成256点实序列FFT在72MHz下实测大约2到3毫秒完全够实时显示。先算一笔内存账。20KB SRAM看着不小但CMSIS的FFT实例、缓冲区、OLED显存都要往里塞。我的分配方案是数据大小说明adc_buf[256]512BDMA循环缓冲fft_buf[256]512B处理缓冲区与FFT输入共用fft_input_q15[256]512BQ15格式输入ADC数据转换而来fft_output[512]1KBFFT复数输出实部虚部交错fft_mag[128]256B幅度数组OLED显存镜像[8][128]1KB绘图需要后面会讲粗算下来不到5KB再加上CMSIS实例里的旋转因子表整体还是能在20KB里舒舒服服放下的。这也是选256点而不是512点的直接原因——512点虽然也能塞下但Twiddle表和输出缓冲区都要翻倍留给其他变量的余量就少了而且仿真运行速度会更慢。代码上用CMSIS-DSP的标准流程输入、变换、取幅度三步arm_rfft_instance_q15 fft_inst; arm_rfft_init_q15(fft_inst, 256, 0, 1); /* 12位ADC值先减2048变成带符号数再整体右移防溢出 */ for (i 0; i 256; i) { fft_input[i] ((int16_t)fft_buf[i] - 2048) 2; } arm_rfft_q15(fft_inst, fft_input, fft_output, 0); arm_cmplx_mag_q15(fft_output, fft_mag, 128);这里有个坑必须提醒Q15是定点数范围是±32767FFT中间级运算又可能放大数值输入如果不留余量第一轮蝶形计算就可能溢出后果就是频谱图出现莫名其妙的尖刺。我习惯先把ADC数据整体右移两三位把幅度压到满量程的八分之一左右跑出来结果最稳。这也是那些只教“照抄CMSIS例程”的教程永远不会告诉你的经验。3.3 SSD1306的SPI驱动与初始化时序SSD1306的SPI是4线制SCK、MOSI、DC、CS加上RES一共5根线。SCK空闲低电平MOSI在上升沿采样高位先发送对应SPI模式0。F103的SPI1配置如下SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1 | RCC_APB2Periph_GPIOA, ENABLE); spi.SPI_Direction SPI_Direction_1Line_TX; /* 只写不用MISO */ spi.SPI_Mode SPI_Mode_Master; spi.SPI_DataSize SPI_DataSize_8b; spi.SPI_CPOL SPI_CPOL_Low; spi.SPI_CPHA SPI_CPHA_1Edge; spi.SPI_NSS SPI_NSS_Soft; spi.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; /* 72/89MHz */ spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE);写字节时注意等TXE标志最后等BSY清掉保证数据真正发完再拉高CSvoid OLED_WR(uint8_t dat) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, dat); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) SET); } void OLED_WriteCmd(uint8_t cmd) { OLED_CS_L(); OLED_DC_L(); OLED_WR(cmd); OLED_CS_H(); } void OLED_WriteData(uint8_t dat) { OLED_CS_L(); OLED_DC_H(); OLED_WR(dat); OLED_CS_H(); }初始化时序是关键。SSD1306上电后要先给RES引脚一个低电平保持20毫秒再拉高然后按顺序发送初始化命令序列。我在STM32里用的完整序列如下OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); // 时钟分频 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 多路复用比64行 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 显示偏移 OLED_WriteCmd(0x40); // 起始行0 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 开电荷泵必须开 OLED_WriteCmd(0x20); OLED_WriteCmd(0x00); // 水平寻址模式 OLED_WriteCmd(0xA1); // 段重映射 OLED_WriteCmd(0xC8); // COM扫描方向 OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); // COM引脚配置 OLED_WriteCmd(0x81); OLED_WriteCmd(0xCF); // 对比度 OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); // 预充电周期 OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); // VCOMH设置 OLED_WriteCmd(0xA4); // 显示内容来自RAM OLED_WriteCmd(0xA6); // 正常显示 OLED_WriteCmd(0xAF); // 打开显示我最开始移植驱动时反复白屏最后定位到两个地方一是0x8D后的电荷泵命令没发屏幕怎么都不亮二是0xA1和0xC8这两个方向命令在Proteus的图形LCD模型和真SSD1306上表现略不同如果发现字是镜像的把0xA1改成0xA0、0xC8改成0xC0即可。3.4 频谱柱状图的绘制与峰值检测显示部分要解决两件事屏幕怎么画柱状图以及频率数怎么读出来。SSD1306的GRAM按页组织128列分为8页每页8个像素点所以写屏的最小单位是“某一页的某一列”一字节管8个纵向像素。为了绘图方便我在MCU里维护了一个1KB的显存镜像数组uint8_t gram[8][128]所有画点操作先改镜像再把改动的那一整页通过SPI刷到屏上。频谱柱状图的画法很直接FFT结果是128个频点正好对应128列。先找当前帧幅值最大的bin做归一化把柱子高度映射到0到56像素底部留几行给文字和刻度然后逐列把gram[page][x]对应的位填1。柱子更新时不要整屏清空重画那样闪得没法看正确做法是每次先擦掉上一帧的旧柱子再画新柱子只更新被修改的页。峰值检测就更简单了。遍历fft_mag数组跳过第0个bin直流分量找值最大的那个下标k频率直接用公式算uint32_t freq (uint32_t)peak_idx * 10000 / 256;这个公式的每一步都有含义peak_idx是频率bin的序号乘采样率10kHz得到以Hz为单位的绝对频率除以FFT点数256得到每个bin对应的频率宽度。10kHz采样率、256点FFT下频率分辨率是10kHz/256≈39Hz意思是屏幕上能区分的两根频率柱至少相差39Hz。你要测1kHz信号读出来可能是984也可能是1023这很正常是FFT分辨率固有特性不是代码写错了。4. Proteus联调与实测记录4.1 用虚拟信号源注入音频并校准仿真联调的第一步不是跑屏幕而是先用虚拟示波器验证信号调理电路。把VSINE频率设1kHz、幅度200mV接在C1之前示波器探头接运放输出应该看到一条以1.65V为中心的1kHz正弦波幅度和输入一致。如果波形顶底被削平说明信号幅度太大或运放不是轨到轨把VSINE幅度降到100mV再试。确认调理电路没问题后启动仿真OLED上应该在1kHz附近出现一根明显的柱子旁边数字显示大概在984到1023Hz之间跳动。这里有一个观察点柱子不是一根细线而是会和左右邻近bin一起冒头形成一个“山包”。这是没有加窗函数的频谱泄漏现象教学演示用够了想要更收敛可以加汉宁窗但主瓣也会相应变宽属于鱼和熊掌。方波测试是验证FFT正确性的黄金手段。把VSINE换成方波发生器频率还是1kHz你会看到屏上1kHz处一个大柱3kHz、5kHz、7kHz处逐级变矮的小柱——这正是方波由奇次谐波构成的频谱特征。如果谐波出现在偶数倍频那就要检查采样率配置或者信号调理电路了。再试烦琐一点的用两个VSINE串联叠成一个双音信号比如1.2kHz加3.4kHz幅度一大一小屏上两个峰都能分开显示。如果两峰挨太近比如只差30Hz会糊成一团这正好直观地演示了“频率分辨率采样率/FFT点数”这个概念。4.2 采样率、FFT点数与频率分辨率怎么算这一节把数值账彻底算明白。三个参数互相制约采样率Fs决定了可显示的最大频率奈奎斯特频率Fs/2FFT点数N决定了频率分辨率ΔfFs/N采样缓冲区攒满一帧数据需要的时间是N/Fs也就是每帧的采集周期。我的这套参数Fs10kHz所以最多显示到5kHzN256所以Δf39Hz一帧耗时25.6毫秒加上处理和绘制每秒能刷十几帧视觉上很流畅。如果你想显示到8kHz的音频频段就得把Fs提到20kHz但Δf会变成78Hz低频细节变差。想同时保住高频率范围和低分辨率唯一出路就是增加FFT点数到512或1024代价是内存和CPU时间翻倍。这个权衡没有标准答案取决于你的测试信号。语音分析把Fs降到8kHzΔf是31.25Hz低频段更精细音乐分析关心高频那就按上面的方式加点数。我把不同组合列在下面采样率FsFFT点数N可显示带宽频率分辨率Δf一帧时长8kHz2560-4kHz31.25Hz32ms10kHz2560-5kHz39.06Hz25.6ms20kHz2560-10kHz78.13Hz12.8ms20kHz5120-10kHz39.06Hz25.6ms表格里最后一行最均衡但512点FFT在F103C8T6上要把内存精简一下用双缓冲复用方案才放得下。教学演示我还是推荐第一行或第二行仿真跑起来也快。4.3 实测中的性能观察与优化仿真联调时我注意到一个和真板子截然不同的现象Proteus的STM32模型是软件模拟每条指令整体速度比真实芯片慢得多如果程序里每秒刷新20帧仿真交互就会卡。优化手段有三个一是把主循环里的每帧绘制降频到10帧左右用计数器的办法控制二是在Keil编译时开最高优化关调试信息三是关掉Proteus里多余的示波器、虚拟终端窗口它们也在抢仿真资源。代码层面的性能观察是另一回事。真机跑起来FFT只占几毫秒真正的大头在OLED刷新。我实测用9MHz SPI刷一整页128字节不到0.2毫秒一帧更新四五个页也就1毫秒上下所以性能不是问题。真正的风险是DMA采样和处理互相抢时间——我已经把处理控制在25.6毫秒的帧周期内单帧处理时间有5毫秒余量但如果以后加窗函数、加大FFT点数一定要回头检查这个预算。5. 常见问题排查与避坑实录5.1 问题速查表做这个项目的过程中我在不同的配置里踩了不少坑也帮同学排查过类似问题。下面这张表基本覆盖了最容易翻车的场景现象可能原因排查方向与处理OLED黑屏无显示初始化命令没发全、电荷泵没开、RES时序不对检查0x8D 0x14是否在序列里RES低电平至少20ms再拉高用逻辑分析仪看SPI是否真有数据显示内容是镜像/反色段重映射和COM方向配置反了0xA1改0xA0、0xC8改0xC0或0xA7反色改成0xA6频谱低频特别高、高频全是杂散输入信号没有隔直、偏置电压不对示波器看ADC引脚波形确认静态电平在1.65V而不是0V或3.3V频率读数系统性偏差定时器分频或时钟树不对用公式反推采样率在采样中断里翻转GPIO用示波器实测频率频谱图出现很多尖刺FFT定点溢出输入值先整体右移2位再进Q15或降低输入信号幅度Proteus运行极慢仿真指令级开销大、刷新率过高降帧率到10fps以内编译开优化关闭不用的示波器窗口换了一块OLED还是白屏驱动IC可能不是SSD1306确认IC型号对照数据手册改初始化序列和寻址命令5.2 几个不容易注意的细节和习惯先提一个低级但高发的错误DMA中断里忘记清标志就做大量计算。DMA的TC中断如果不及时清会反复触发主循环永远在忙处理采样数据持续被覆盖。我的习惯是中断函数第一行清标志最后一行置业务标志中间只做memcpy这种轻量操作。第二个习惯和volatile有关。fft_ready这个标志在主循环和中断之间共享必须声明成volatile否则编译器在开启优化时可能把它优化成死循环退出不来。这种问题在仿真里偶尔也能复现表现为程序“看起来卡死了”。遇到这种灵异事件先检查共享变量有没有加volatile。第三个经验是关于SPI时钟速率的。9MHz在短线连接下没有任何问题但如果用杜邦线把OLED模块和单片机连在一起线长超过20厘米高速SPI的过冲和串扰会非常明显症状是屏幕偶尔错乱、字符花掉。遇到这种问题别先怀疑代码把SPI预分频调大两档比如从8改到16降速到4.5MHz往往就好了。最后说ADC采样时间。我配置里用了55.5个ADC时钟周期的采样时间看起来很长但这是故意的。音频信号经过运放和RC滤波后源阻抗不算低给采样电容足够长的充电时间转换结果才稳定。尤其当采样率不高时稍微长一点的采样时间对性能几乎没有影响别为了省那几微秒牺牲精度。6. 顺着这个项目还能往哪走6.1 交互与功能扩展频谱柱状图画出来之后往上加交互是水到渠成的事。STM32F103C8T6还有几个闲置的GPIO接三个按键就能做模式切换按键一在“频谱柱状图”和“时域波形显示”之间切换按键二在峰值频率和总谐波失真估算之间切换按键三调节自动归一化的灵敏度。按键扫描用轮询就行10毫秒消抖不需要上状态机。串口的利用也值得做。现在PA9/PA10的USART1基本是空的把FFT结果按规定帧格式打包发出来接一个串口转USB模块电脑端用串口助手或Processing就能画出更高分辨率的频谱曲线。这样一套下来这个项目就从“单片机自娱自乐”变成了“嵌入式采集加上位机可视化”的完整数据链路拿去申个创新项目都够格。如果想在单片机本机上做得更花哨还可以加一个ADC通道采集电位器电压作为阈值超过阈值的频率点用反白显示或者把SPI2也利用起来接一片SD卡把FFT后的频谱数据落盘做一个简单的离线频谱记录仪。6.2 从教学演示走向实用的几个方向教学演示做到FFT出谱这一步就算圆满完成但真要往实用方向走还有三件事可以做。第一是对数坐标。真实的频谱分析仪横轴是频率对数刻度纵轴是dB刻度因为人耳对频率的感知本身是对数的。把现在线性的bin序号映射成对数频率坐标低频段占的屏幕可以更多高频段压缩到一起这样显示的频谱更接近真实仪表。实现不复杂写一个freq_to_x()映射函数就行但要记得把峰值频率数值显示保留住不能只画图。第二是峰值插值。FFT峰的bin序号是离散的直接换算出频率的误差最大可达半个分辨率就是39Hz的一半。用峰值bin和它左右两个bin做抛物线插值可以把单频估计精度提升到几Hz级别代码量不超过20行却能让频率读数稳定不少。这在做音准测量、乐器调音时很有用。第三是过采样和降噪。F103C8T6的ADC全部输入范围是3.3V量程如果只测几十毫伏的信号量化噪声比重很大。用16倍过采样加平均等效位数能提高两到四位小信号细节会清晰很多。代价是有效采样率降低但音频频段本来就不高这个交换很划算。如果这些还不够那就是换芯片的事了。同样的代码迁移到带FPU的Cortex-M4F比如STM32F407或国产的GD32F450系列float FFT直接起飞1024点都能做到实时还能顺手把DSP指令集的更多特性用起来。不过那就不是这个项目的范围了——先把F103C8T6这套麻雀五脏吃透比直接堆硬件有营养得多。最后说点个人体会。这个项目真正难的不是哪一行代码而是在20KB内存、72MHz主频的约束下让采样、变换、显示三件事有条不紊地协同。我踩过几次坑之后养成的习惯是动手写代码之前先拿一张纸把资源预算和帧时序画出来采样缓冲多大、FFT中间缓冲多大、一帧25毫秒里处理占多少、绘制占多少全部算清楚再动键盘。我见过太多同学先埋头写OLED驱动回头发现FFT内存不够又拆掉重来的例子。按照本文第三节那张内存表先做一遍账很多弯路是可以直接绕开的。
返回列表