ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103ZET6多功能音乐播放器与FFT频谱设计

STM32F103ZET6多功能音乐播放器与FFT频谱设计

简介:这份面向嵌入式学习者与课程设计、电子竞赛读者的技术文档,围绕基于STM32F103ZET6的多功能音乐播放器展开,重点解决从音频文件读取、解码播放到频谱显示与蓝牙接入的完整实现路径。文档以VS1053B音频解码芯片和FATFS文件系统为核心,讲解SD卡中MP3、WAV、WMA等格式的读取与解码流程,并给出利用FFT将音频信号转为频谱、配合OLED与小型GUI实时显示播放状态和频谱的设计方法;同时涵盖蓝牙4.0音频模块、74HC4052模拟通道切换、W25Q128字库存储及SPI/DMA/ADC等接口的硬件电路组织。压缩包仅含1个PDF文件,大小约1.81MB,适合按章节通读并对照原理图与程序结构理解系统方案。该文档已有2226人学习,可作为毕业设计、课程设计或嵌入式音频项目选型的参考,帮助读者快速把握主控、解码、显示、无线音频与文件系统之间的协作关系,并从中提炼可复用的硬件划分与软件模块设计思路。

1. 一块 0.96 寸 OLED 撑起的播放器:STM32 音乐播放器到底能干什么

插上 SD 卡按一下键,0.96 寸 OLED 先亮出主菜单;开始放歌后,屏幕下方还跟着音乐跳动一排频谱柱。这套基于 STM32F103ZET6 的多功能音乐播放器设计,核心可以用一句话概括:一颗 Cortex-M3 主控从 SD 卡里读出音频文件,喂给 VS1053B 解码出立体声,同时把歌名、时长、码率、音量和实时频谱画到屏上,另外还留了一路蓝牙 4.0 音频输入给手机使用。挂 FATFS 管文件、跑 FFT 做频谱、加uCGUI 做界面、再用 74HC4052 切换本地与蓝牙两路音源——听着像个小产品,其实整块板子的资源都压在 F103ZET6 这一颗芯片上。适合正在做嵌入式课设或毕设的开发者。它并不会把每个模块都做成黑匣子,但能提供一个足够完整的工程骨架。

2. 硬件架构拆解:一颗 M3 主控分配资源、承担主要任务

先把结论放在前面:这套方案能跑起来,重点在于外设数量刚好够用,性能并不是关键。SDIO 读卡、SPI 通讯解码芯片、SPI 挂字库、SPI 刷屏、ADC 采音频、DMA 搬数据、通用 IO 控蓝牙和按键——F103ZET6 的 3 个 SPI、1 个 SDIO、2 个 DMA 控制器(12 通道)、3 个 ADC、112 个 GPIO,刚好把这些任务全部承担下来,而且每个任务都还有余量。换小容量型号就得砍功能,这一点在实际选型时非常关键。

2.1 主控与外设预算:为什么必须上 ZET6 而不是 C8T6

芯片本身没什么玄学,72MHz 主频、512KB Flash、64KB SRAM,Cortex-M3 内核,带硬件除法和单周期乘法,跑 FATFS 加定点 FFT 完全够用。真正决定选型的是引脚数和外设条数,而不是主频。下面这张表列出了整个工程对外设的占用情况,看完就知道为什么不能用 48 脚的 C8T6:

外设用途关键配置备注
SDIO读写 Micro SD 卡4 位数据线 DAT0-DAT3 + CLK + CMD初始化先用 400kHz,握手成功后切高速
SPI1VS1053B 音频解码主机模式,分频后约 9MHzXCS/XDC5/RST/DREQ 走普通 GPIO
SPI2W25Q128 字库 Flash主机模式,18MHz存 GBK 点阵,占用前 4MB 左右
SPI3OLED 128×64主机模式,低速即可D0/D1/RES/DC/CS 五线
ADC1_IN0采集音频模拟信号12 位,外部触发经分压后接 VS1053 耳机输出
TIM2触发 ADC 采样10kHz 更新事件也用作频谱刷新的节拍
DMA1_Ch1ADC 数据搬运循环模式,256 个半字半满/全满中断二选一
GPIO按键、蓝牙状态、音频切换输入上拉 / 推挽输出蓝牙占 2 个 IO,74HC4052 占 3 个

这张表还说明另一个问题:三个 SPI 全部被占用,意味着你后期想加个无线模块或屏幕升级,几乎没有可用接口,只能改用软件模拟或复用。因此,二次开发时不要把扩展能力想得太乐观;第 6 章会专门讨论这个问题。

2.2 三组 SPI 的分工与片选规划

三组 SPI 共用同一套 SPI 时序代码很容易写错,关键是要把片选和总线配置分开管理。SPI1 接 VS1053B,速度不能太快,也不能太慢:太慢会导致喂数据跟不上,听觉上出现“哒哒”的爆音;太快则会让 SDI 时序超出芯片手册规定的上限。实际调试下来,9MHz 左右比较稳妥。SPI2 接 W25Q128,只在上电校验字库和刷歌名时用,速度可以拉满。SPI3 接 OLED,纯粹是画图,1MHz 到 4MHz 都行。

初始化我一般写成这样,把三组总线的差异做在一张表里,避免到处复制粘贴:

/* 三组 SPI 的公共初始化:只有分频和引脚不同 */ static void SPI_Config(SPI_TypeDef *SPIx, uint16_t prescaler) { SPI_InitTypeDef s; SPI_StructInit(&s); s.SPI_Direction = SPI_Direction_2Lines_FullDuplex; s.SPI_Mode = SPI_Mode_Master; s.SPI_DataSize = SPI_DataSize_8b; s.SPI_CPOL = SPI_CPOL_High; /* VS1053/W25Q128 均为模式 3 */ s.SPI_CPHA = SPI_CPHA_2Edge; s.SPI_NSS = SPI_NSS_Soft; /* 片选全部软件控制 */ s.SPI_BaudRatePrescaler = prescaler; s.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPIx, &s); SPI_Cmd(SPIx, ENABLE); } void BSP_SPI_Init(void) { GPIO_InitTypeDef g; /* 三个 SPI 的 SCK/MOSI 复用推挽,MISO 上拉输入,这里省略逐条配置 */ SPI_Config(SPI1, SPI_BaudRatePrescaler_8); /* 72M/8 = 9MHz -> VS1053 */ SPI_Config(SPI2, SPI_BaudRatePrescaler_4); /* 18MHz -> W25Q128 */ SPI_Config(SPI3, SPI_BaudRatePrescaler_32); /* 2.25MHz -> OLED */ }

这里有一个常被忽略的参数:SPI_CPOL/CPHA必须按从机手册来选。VS1053 在时钟上升沿采样,所以要配成模式 3(CPOL=1、CPHA=2Edge);如果配成模式 0,读回来的寄存器全是 0xFFFF,会让人误以为芯片坏了。另外,SPI_NSS_Soft一定要设置,片选用普通 GPIO 手动拉低,因为三组总线的片选脚完全不同,硬件 NSS 在这里帮不上忙。

2.3 74HC4052 音频切换:两路音源共用一只耳机口

VS1053 的耳机输出和蓝牙模块的左右声道输出最后要接到同一个 3.5mm 座子上,如果直接把两路输出硬连在一起,就会出现一路输出驱动另一路输出的情况,轻则音量变小,重则引入明显底噪和串音。74HC4052 就是用来解决这个问题的:它内部是两组四选一模拟开关,X 组和 Y 组各有一个公共端 Z,通过 A0、A1 两位选择信号同步切换,E 是低有效使能。

接线思路很直接:左声道接 X0(本地)、X1(蓝牙),右声道接 Y0、Y1,两个 Z 分别接耳机座的左右端子,A0、A1 由主控的两个普通 IO 控制。这样切一次就同时切了左右声道,不会出现一边有声一边没声的尴尬。

/* 音频通道切换:ch = 0 本地 VS1053,ch = 1 蓝牙 */ void Audio_SwitchChannel(uint8_t ch) { /* 先静音再切,避免切换瞬间的“噗”声 */ VS1053_WriteReg(SCI_VOL, 0xFEFE); if (ch == 0) { GPIO_ResetBits(GPIOB, GPIO_Pin_5); /* A0 = 0 */ GPIO_ResetBits(GPIOB, GPIO_Pin_6); /* A1 = 0 -> 选 X0/Y0 */ } else { GPIO_SetBits(GPIOB, GPIO_Pin_5); /* A0 = 1 */ GPIO_ResetBits(GPIOB, GPIO_Pin_6); /* A1 = 0 -> 选 X1/Y1 */ } Delay_ms(20); /* 等模拟开关稳定 */ if (ch == 0) VS1053_WriteReg(SCI_VOL, current_vol); }

参数说明:SCI_VOL的高字节是左声道衰减,低字节是右声道,0x00 表示 0dB、0xFE 表示几乎静音,所以0xFEFE就是临时全静音。切换后延时 20ms 再恢复音量,是因为模拟开关的导通建立和耳机端的容性负载需要一个短暂过渡期,这个时间太短仍会有轻微“咔”声。蓝牙模块那边还有 MUTE 引脚和状态指示引脚,主控用两个 IO,一个读连接状态,一个控制开关;进入蓝牙模式时先拉高使能、等状态脚变成已连接再切 4052,顺序不能反。

2.4 采样链路:定时器、ADC、DMA 如何配合

频谱显示的数据来源不能靠 CPU 轮询。10kHz 采样率意味着每 100 微秒就要采一次,任何一次函数调用抖动都会让频谱抖动。标准做法是让 TIM2 产生 10kHz 的更新事件,通过TIM_TRGO_Update直接触发 ADC1 转换,ADC 每次转换完成由 DMA1 搬进缓冲区,CPU 全程不参与,只在一整块 256 点采满后处理一次。

void AudioSample_Init(void) { /* TIM2: 72MHz / 72 = 1MHz,再 /100 = 10kHz 触发频率 */ TIM_TimeBaseInitTypeDef t; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructInit(&t); t.TIM_Period = 100 - 1; t.TIM_Prescaler = 72 - 1; TIM_TimeBaseInit(TIM2, &t); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); /* ADC1: 外部触发、单次转换、右对齐 */ ADC_InitTypeDef a; ADC_StructInit(&a); a.ADC_ContinuousConvMode = DISABLE; a.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC2; a.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &a); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_DMACmd(ADC1, ENABLE); /* DMA1_Channel1: 外设不自增、内存自增、循环模式 */ DMA_InitTypeDef d; DMA_DeInit(DMA1_Channel1); d.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; d.DMA_MemoryBaseAddr = (uint32_t)adc_buf; d.DMA_DIR = DMA_DIR_PeripheralSRC; d.DMA_BufferSize = 256; d.DMA_PeripheralInc = DMA_PeripheralInc_Disable; d.DMA_MemoryInc = DMA_MemoryInc_Enable; d.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; d.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; d.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel1, &d); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_Cmd(ADC1, ENABLE); TIM_Cmd(TIM2, ENABLE); }

adc_buf必须是uint16_t[256],因为 ADC 是 12 位的,半字搬运刚好。这里选了ADC_ExternalTrigConv_T2_CC2,图省事也可以用T2_TRGO,两者都能触发,区别只在于触发事件不同;只要前后代码一致即可。循环模式的好处是采集永不停歇,主循环里判断DMA1->ISR的传输完成标志,读走一整块数据再清标志,读的过程中 DMA 已经在写缓冲区下一轮了,不会丢点。

3. 软件分层:从 SD 卡扇区到耳机口的完整数据流

硬件跑通之后,真正难的是软件结构。这套工程的软件大致分四层:最底下是 SDIO 和 SPI 的驱动,往上挂 FATFS,再往上是 VS1053 的播放控制,最上面是 uCGUI 界面和按键任务。层次分清楚后,改任何一层都不会牵连另外三层。我最深的体会是:嵌入式项目不一定需要 RTOS,如果按状态机加主循环的结构组织,反而更容易调。

3.1 FATFS 移植:四个函数决定成败

FATFS 的设计原则就是“与硬件无关”,它把所有与硬件打交道的事都交给diskio.c里的几个函数。移植到这板子上,只需要实现disk_initialize、disk_status、disk_read、disk_write和disk_ioctl。其中读函数是性能瓶颈,写函数如果只做播放器可以返回RES_ERROR,因为播放过程中不写卡。

DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; /* 先用 400kHz 慢速完成 SDIO 握手,成功后再切高速 */ if (SD_Init() != SD_OK) return STA_NOINIT; SD_SetBusWidth(SD_BUS_4BIT); return 0; } DRESULT disk_read(BYTE pdrv, BYTE *buff, DWORD sector, UINT count) { if (pdrv != 0 || count == 0) return RES_PARERR; /* sector 是逻辑扇区号,FATFS 默认按 512 字节算 */ if (SD_ReadBlocks(buff, sector, count) != SD_OK) return RES_ERROR; return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != 0) return RES_PARERR; switch (cmd) { case CTRL_SYNC: return RES_OK; case GET_SECTOR_COUNT: *(DWORD *)buff = SD_CardCapacity(); break; case GET_SECTOR_SIZE: *(WORD *)buff = 512; break; case GET_BLOCK_SIZE: *(DWORD *)buff = 8; break; /* 擦除块 */ default: return RES_PARERR; } return RES_OK; }

GET_SECTOR_SIZE必须返回 512,和disk_read的单位保持一致,否则f_mount时会返回FR_NO_FILESYSTEM。GET_BLOCK_SIZE给 8 是比较保守的值,SD 卡典型擦除块是 8 个扇区,写性能优化用得上,只读场景影响不大。还有一点:disk_initialize里把 SDIO 从 400kHz 切到高速这一步,必须在f_mount之前完成,因为 FATFS 挂载时会连续读好几个扇区,如果仍用低速会很卡,但低速本身并不会导致挂载失败,只是慢。

挂载成功之后的目录遍历相对简单,把MUSIC目录下的音频文件加入播放列表:

FRESULT PlayList_Scan(const char *path) { DIR dir; FILINFO fno; FRESULT res; PlayList_Clear(); res = f_opendir(&dir, path); if (res != FR_OK) return res; while ((res = f_readdir(&dir, &fno)) == FR_OK && fno.fname[0]) { if (fno.fattrib & AM_DIR) continue; /* 跳过子目录 */ if (!IsAudioFile(fno.fname)) continue; /* 判断扩展名 */ PlayList_Add(fno.fname, fno.fsize); } f_closedir(&dir); return res; }

f_readdir返回fno.fname[0] == 0就表示目录读完了,这个判断条件比用_FS_RPATH里的索引可靠。fno.fsize顺手存下来,后面算总时长和进度条直接用,省得每次播放前再f_open查一次。

3.2 VS1053 驱动:SCI 和 SDI 两条总线别搞混

VS1053B 有两套接口:SCI 是控制接口,用来读写内部寄存器;SDI 是数据接口,用来灌音频流。两者都挂在同一组 SPI 上,靠 XCS(SCI 片选)和 XDCS(SDI 片选)区分,同一时刻只能拉低一个。这一点是新手最容易踩的地方:把音频数据当寄存器写,或者反过来,现象就是芯片一声不吭。

/* 写 SCI 寄存器 */ static void VS_WriteReg(uint8_t addr, uint16_t dat) { VS_XCS_LOW(); SPI1_RW(0x02); /* 0x02 = WRITE */ SPI1_RW(addr); SPI1_RW(dat >> 8); SPI1_RW(dat & 0xFF); VS_XCS_HIGH(); } /* 读 SCI 寄存器 */ static uint16_t VS_ReadReg(uint8_t addr) { uint16_t val; VS_XCS_LOW(); SPI1_RW(0x03); /* 0x03 = READ */ SPI1_RW(addr); val = (uint16_t)SPI1_RW(0xFF) << 8; val |= SPI1_RW(0xFF); VS_XCS_HIGH(); return val; } /* 灌音频数据,必须等 DREQ 为高 */ uint16_t VS_SendData(const uint8_t *buf, uint16_t len) { uint16_t sent = 0; while (sent < len) { if (VS_DREQ_READ()) { /* 内部 FIFO 还能收 32 字节 */ uint16_t n = (len - sent > 32) ? 32 : (len - sent); VS_XDCS_LOW(); for (uint16_t i = 0; i < n; i++) SPI1_RW(buf[sent + i]); VS_XDCS_HIGH(); sent += n; } } return sent; }

VS_SendData里的 32 字节不是随便写的,VS1053B 的 SDI 数据 FIFO 容量有限,手册建议每次最多写 32 字节,写多了会溢出丢包,表现出来就是播放中有规律地“咔”一下。等待DREQ时不要用while(!DREQ);死等,中间插按键扫描和看门狗喂狗,否则一旦解码芯片异常,整个系统就会卡住。

初始化流程的要点是“复位靠硬件、配置靠 SCI”:

void VS1053_Init(void) { VS_XCS_HIGH(); VS_XDCS_HIGH(); VS_RST_LOW(); Delay_ms(20); VS_RST_HIGH(); Delay_ms(20); /* 软复位并等状态位归位 */ VS_WriteReg(SCI_MODE, 0x0800); /* SM_RESET */ while (VS_ReadReg(SCI_MODE) != 0x4800); VS_WriteReg(SCI_CLOCKF, 0x9800); /* 3.5 倍频,需与晶振匹配 */ VS_WriteReg(SCI_BASS, 0x0000); /* 关掉重低音,避免破音 */ VS_WriteReg(SCI_VOL, 0x2020); /* 左右各衰减 0x20 */ }

SCI_CLOCKF这个参数影响最大:写大了音频会变速、变调(像快放),写小了会周期性卡顿。一般 12.288MHz 晶振配 3.5 倍频比较稳;如果听到声音明显变快,先怀疑这里,而不是文件本身。

3.3 播放状态机与按键:别在中断里干活

播放器有“停止/播放/暂停/切歌/切模式/调音量”这些状态,如果用一堆if堆在主循环里,很快就会乱。我习惯用一个状态机和一个小按键事件队列,主循环每轮读取事件、驱动状态迁移,中断里只负责把键值塞进队列。

typedef enum { ST_STOP, ST_PLAY, ST_PAUSE, ST_BT } play_state_t; static play_state_t state = ST_STOP; void App_Loop(void) { uint8_t key; while (1) { if (Key_GetEvent(&key)) { /* 环形队列取键值 */ switch (state) { case ST_PLAY: if (key == KEY_PAUSE) { VS_WriteReg(SCI_MODE, 0x0808); state = ST_PAUSE; } else if (key == KEY_NEXT) { PlayList_Next(); } else if (key == KEY_VOL_UP) { current_vol -= 4; VS_WriteReg(SCI_VOL, (current_vol<<8)|current_vol); } break; case ST_BT: /* 蓝牙模式下按键只做音源切换 */ if (key == KEY_MODE) { Audio_SwitchChannel(0); state = ST_PLAY; } break; default: break; } } if (state == ST_PLAY) Music_Feed(); /* 非阻塞地喂一段数据 */ GUI_Refresh(); /* 界面按需刷新 */ } }

Music_Feed()每次只喂一小段(比如 512 字节),喂完立即返回,靠主循环的高频调用来保证数据不断流。这种“时间片”式写法比开一个大缓冲一次性读满更稳,因为主循环还有按键和刷屏要跑,单次阻塞太久会让按键迟钝。

3.4 uCGUI 与 OLED:给界面留缓存、按需刷新

uCGUI 的架构是分层的,最底下是 LCD 驱动层,只认“画点、填充矩形、读点”这几个基础操作,上面的窗口、控件、字体全用标准 C 写,所以往 OLED 上移植只需要实现驱动层。128×64 单色屏按页组织,8 页 × 128 列,一页里一个字节代表竖着 8 个像素。

刷新的原则只有一条:不要每帧全屏重画。频谱那一块每 50ms 变一次,歌名和进度条几秒才变一次,分开区域、打脏标记,只重画脏区。全屏GUI_Clear()在 SPI 屏上能占到十几毫秒,频谱一快就露馅,表现为进度条卡顿。

4. 频谱显示的落地:FFT 从时域到 128 个点

FFT 是这套设计里最有“表演性”的部分,也是最容易做成花瓶的部分。要么算得慢,要么算出来波形乱跳,要么干脆屏幕上一片死。问题基本都出在采样参数和定点数格式这两件事上,算法本身反而没什么可改的。

4.1 采样参数推导:为什么是 10kHz 和 256 点

这几个数字不是随便定的,是一步步推出来的。屏幕宽度 128 列,FFT 结果具有对称性,只取前一半,所以要得到 128 个有效的频点,采样点数就得是 256。采样率则受采样定理约束:采样率必须大于信号最高频率的两倍。人耳听觉上限是 20kHz,理论上要 40kHz 以上,但 40kHz 采样对 72MHz 的 M3 加上实时刷屏来说负担太重,所以取 10kHz,覆盖 0 到 5kHz,主要展示中低频段,对“跟着音乐跳”这个视觉目标完全够用。

由此得到频率分辨率:10kHz / 256 = 39Hz。也就是说屏幕最左边一根柱子代表 0~39Hz,第二根代表 39~78Hz。音乐的低频鼓点基本落在前十几根柱子里,高频段大部分是空的,这也是很多同类作品频谱左边很活跃、右边一条平线的根本原因——问题不在 FFT,而在采样率设置得太低。

采样点数从 256 改成 512 能把分辨率提高一倍,但计算量和内存占用也翻倍,屏幕还是只有 128 列,多出来的点只能做平均,收益不大。

4.2 触发采集:TIM2 + ADC + DMA 的实际配置

前面 2.4 已经把这套链路初始化好了,这里补上怎么“取数”和怎么防止数据被改一半。循环模式下 DMA 一直在写adc_buf,主循环读到一半时 DMA 可能已经绕回开头覆盖了,所以要么用双缓冲,要么等传输完成标志再一次性memcpy出来。

volatile uint8_t fft_ready = 0; uint16_t adc_buf[256]; uint16_t fft_src[256]; /* DMA1_Channel1 传输完成中断 */ void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); memcpy(fft_src, adc_buf, sizeof(adc_buf)); /* 一次性拷走,避免撕裂 */ fft_ready = 1; } }

memcpy256 个半字在 72MHz 下大概几微秒,放中断里可以接受。拷完之后置标志,主循环看到标志再算 FFT,算的时候 DMA 还在采下一轮,两不耽误。fft_src必须是独立缓冲区,不能直接拿adc_buf去算,否则算到一半数据被覆盖,频谱会出现随机跳变。

4.3 定点 FFT 调用:数据怎么打包、结果怎么取模

M3 没有浮点单元,用浮点 FFT 会慢到没法看,所以用配套 DSP 库里的定点函数cr4_fft_256_stm32。这个函数的数据格式很特殊:输入输出都是 32 位无符号数,高 16 位放实部、低 16 位放虚部,而且都是 Q15 格式(-32768 到 32767 表示 -1 到 1)。

#define FFT_N 256 uint32_t fft_in[FFT_N]; uint32_t fft_out[FFT_N]; uint16_t mag[FFT_N / 2]; void Spectrum_Calc(void) { for (int i = 0; i < FFT_N; i++) { int16_t v = (int16_t)fft_src[i] - 2048; /* 12 位 ADC 去直流 */ fft_in[i] = ((uint32_t)(uint16_t)v) << 16; /* 实部在高位,虚部补 0 */ } cr4_fft_256_stm32(fft_out, fft_in, FFT_N); for (int i = 0; i < FFT_N / 2; i++) { int16_t re = (int16_t)(fft_out[i] >> 16); /* 取高 16 位实部 */ int16_t im = (int16_t)(fft_out[i] & 0xFFFF); /* 取低 16 位虚部 */ mag[i] = (uint16_t)sqrtf((float)re * re + (float)im * im); } }

几个必须注意的点:减 2048 是去直流,因为 ADC 输出在 0~4095 之间,对应 0V~3.3V,音频信号是围绕中点摆动的,不去直流的话mag[0]会大得离谱,屏幕上永远只有第一根柱子。取模这里图省事用了sqrtf,实际上可以换成abs(re) + abs(im)的近似,误差在 5% 以内,视觉上完全看不出来,但速度快好几倍。最后,mag[0]是直流分量,显示时通常直接丢掉。

4.4 从幅度值到屏幕柱子:对数映射和峰值保持

算出来的mag数组跨度极大,低频强的部分可能几千,高频弱的只有个位数,直接线性映射到 0~48 像素高度,结果就是低频顶到天花板、高频贴着地板。人耳对响度本来就是对数感知的,所以显示也要按对数来。

/* 把线性幅度压到 0~48 的柱高,近似 log2,一次移位约 6dB */ static uint8_t Mag_ToHeight(uint16_t m) { uint8_t db = 0; while (m > 0) { m >>= 1; db++; } /* 0~16 位约等于 0~96dB */ db = (db > 10) ? (db - 10) : 0; /* 砍掉底部噪声 */ return (db > 48) ? 48 : db; } void Spectrum_Draw(void) { for (int x = 0; x < 128; x++) { uint8_t h = Mag_ToHeight(mag[x]); if (h > peak[x]) peak[x] = h; /* 峰值保持 */ else if (peak[x] > 0) peak[x]--; /* 每帧慢慢回落 */ GUI_SetColor(GUI_BLACK); GUI_DrawVLine(x, 16, 63); /* 清掉本列 */ GUI_SetColor(GUI_WHITE); GUI_DrawVLine(x, 64 - h, 63); /* 画柱体 */ GUI_DrawPixel(x, 64 - peak[x]); /* 画峰值点 */ } }

峰值保持的作用是让频谱看起来更“稳”。没有它,柱子每帧跳十几个像素,视觉上像噪声;有了它,鼓点打下去能留下一条清晰的轨迹。回落速度peak[x]--每帧减 1,按 20fps 算大概 0.4 秒落到底,节奏感刚好。

5. 避坑清单:这台播放器最容易翻车的 5 个地方

这套方案在纸面上很通顺,实际调试时能遇到的问题几乎全部集中在“时序”和“配置不一致”上。按出现频率从高到低排,下面这 5 个问题我基本都遇到过,每一条都写清楚现象、原因和解决办法。

5.1 存储与文件系统类

第一条:f_mount一直返回FR_NO_FILESYSTEM。

现象是 SD 卡插进去、SDIO 初始化也成功了,但挂载就是失败,换成另一张卡又能过。原因大概有三种:卡被格式化成 exFAT 或 NTFS(FATFS 默认只认 FAT12/16/32);卡容量超过 32GB 且簇大小超出配置;GET_SECTOR_COUNT返回的容量和实际不符。解决办法是把卡重新格式化成 FAT32,分配单元 4KB,容量控制在 32GB 以内;同时在disk_ioctl里确认GET_SECTOR_SIZE返回 512,别返回 1024 之类。

第二条:能挂载,但播放中每隔几秒卡一下。

现象是声音正常几秒后“顿”一下,同时屏幕刷新也停一拍。原因是 FATFS 按扇区读,VS1053 要的是连续流,某次读扇区时正好赶上 SD 卡内部擦除,耗时从几百微秒涨到几十毫秒,缓冲区就空了。解决有三步:把f_read的单次读取量从 512 字节提到 4KB 以上、在 RAM 里做双缓冲,一边读一边喂;disk_read里用 4 线模式并开到 18MHz;播放列表里只放同一个目录下的文件,避免跨目录跳转。

第三条:中文歌名显示成方块或乱码。

现象是英文歌名正常,中文全是方块。原因分两层:一是字库没烧进 W25Q128,或者烧的位置和程序里读取的地址不一致;二是 FATFS 的长文件名没开,fno.fname拿到的是 8.3 短名(MUSIC~1.MP3这种),根本没有中文。解决方法是先把ffconf.h里的_USE_LFN改成 1 并给_MAX_LFN留够 255 的缓冲,编译选项里把源文件编码设成 GBK(或做 UTF-16 到 GBK 的转换);再上电时校验字库首地址的魔数,对不上就从 SD 卡重新烧一遍。

5.2 音频与显示类

第四条:一开频谱就出现“滋滋”底噪或播放变调。

现象是关掉频谱显示声音干净,打开频谱后耳机里多出高频噪声,严重时声音还会轻微变调。原因是 ADC 采样通道直接并在耳机输出上,采样瞬间的电荷注入对模拟信号产生了干扰,同时 FFT 在主循环里算太久,VS_SendData的调用间隔被拉长。解决办法:采样点前加一级 RC 低通加运放跟随做隔离,或者改从 VS1053 的 LINE 输出取信号而不是耳机口;FFT 计算按 50ms 一次限频,不要每轮主循环都算。变调的话先查SCI_CLOCKF,再查是不是 SPI1 被 OLED 刷新打断了。

第五条:切到蓝牙后本地音乐还在响,或者切回来没声音。

现象是模式切换后两路声音叠在一起,或者干脆全静音。原因基本是 74HC4052 的 A0/A1 只动了一位、或者忘了先静音再切,切换瞬间两个通道短暂同时导通。另一个可能是蓝牙模块的状态脚没接对,程序以为没连上所以没切。解决办法是严格按“静音 → 改 A0/A1 → 延时 20ms → 恢复音量”的顺序执行,把切换封装成一个函数,任何地方都调用它;蓝牙状态脚接上拉并读一次实际电平,确认模块确实输出高电平再切。硬件上再确认 4052 的 E 使能脚是拉低的,不接的话芯片根本不工作。

6. 二次开发:把这块板子改成你自己的东西

这套设计的骨架是清晰的,但扩展余地不大,动手改之前得先想好砍什么。三个 SPI 全占满,如果想让界面升级成彩屏再加个编码器旋钮,就只能把 W25Q128 这颗字库 Flash 去掉,改成把常用汉字的点阵按需取模放在内部 Flash 里,或者干脆只显示 ASCII 加拼音。这类取舍比写代码本身更耗时间。

最省事的改造方向是断点续播。播放列表和当前曲目索引存到 W25Q128 的后半段(避开字库占用区),上电先读出来,比每次开机都从头播体验好很多。存储结构可以用一个简单的定长记录:

/* 播放记忆:放在字库区之后的 4KB 扇区里 */ typedef struct { uint32_t magic; /* 0x504C4159,用来判断是否已初始化 */ uint16_t track_index; /* 当前曲目在播放列表中的序号 */ uint16_t volume; uint32_t offset; /* 曲目内的字节偏移,精确续播用 */ } play_memo_t; void Memo_Save(void) { play_memo_t m = { 0x504C4159, g_track, g_vol, g_offset }; W25Q128_EraseSector(MEMO_SECTOR); W25Q128_Write(MEMO_SECTOR * 4096, (uint8_t *)&m, sizeof(m)); }

第二个方向是替换解码方案。VS1053B 加外围电路成本不低,如果只放 WAV 和 MP3,可以用主控软件解码 MP3(有现成的定点解码库),再挂一颗 I2S 的 DAC 输出,硬件更简单也更好调,代价是占用一部分 CPU 和 Flash。改的时候把VS_SendData换成一个Audio_Output接口,上层播放状态机一行不动,这也是分层带来的好处。

第三个方向是频谱效果本身。现在的对数映射比较粗糙,可以加一个自动增益:统计最近若干帧的峰值,动态调整映射的上限,安静段落也能看出柱子动。蓝牙那条路也可以从“只能听”改成“能控制”,把蓝牙模块换成支持串口透传的型号,手机发指令切歌。

改完怎么验证?我一般按这套顺序走一遍,每一步不过就不往下走:

步骤验证内容通过标准
1上电自检屏幕点亮、字库校验通过、SD 卡挂载成功
2单曲播放连续播放 30 分钟不卡顿、不变调
3切歌与暂停切歌响应小于 200ms,暂停后恢复位置正确
4频谱打开后无明显底噪,鼓点能对应上柱子
5蓝牙切换来回切 20 次无爆音、无串音
6断电重启记忆的曲目和音量能正确恢复

那次我先改了 FFT 的采样率,把它从 10kHz 提到 20kHz,想看看高频段,结果整块屏幕的刷新被拖垮,按键响应慢得像死机,查了一整晚才定位到是主循环里的 FFT 没有限频。从那以后我每次动采样相关的参数,都强制先把主循环的耗时用 IO 翻转加示波器量一遍,确认单轮不超过 20ms 再往下做。希望这些内容能帮到你,少走一两个晚上。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进