STM32播放器实验包解析:FatFs移植与DAC+DMA音频通路实现
简介面向嵌入式初学者的音乐播放器实验资源包以实验四十四为载体完整演示如何借助意法半导体STM32系列微控制器搭建支持常见音频格式的音乐播放设备。内容涵盖硬件接口设计、数字音频总线传输、外挂音频解码芯片驱动、文件系统读取与歌曲切换以及按键交互和低功耗电源管理策略适合正在学习嵌入式音频开发、准备课程设计或参与电子竞赛的学生参考使用。压缩包共有一百五十四个文件核心部分是六十四份C语言源码与六十二份头文件并附带工程配置、图片说明、文本资料及辅助脚本整体体积仅约八百九十千字节结构清晰且便于快速下载部署。目前已有超过一千五百人浏览学习通过这份资料可以深入理解从存储介质读取音频数据到数模转换输出模拟信号的完整数据链路同时掌握底层驱动库与直接内存存取的实际应用技巧为后续扩展功能与优化播放性能打下扎实基础。1. 不靠外置 Codec 也能出声这个 STM32 播放器实验包里藏着什么这套 STM32 音乐播放器实验包拿在手里光看文件名很容易低估它的分量keilkill.bat 是清理 Keil 临时文件的批处理test.uvgui.Administrator 只是窗口布局存档而真正干活的是 ff.c、tjpgd.c、ILI93xx.c 和一堆编码页文件。把这些串起来的其实是三件事用 FatFs 从 SD 卡里定位并读取音频文件用定时器配合 DMA 把 PCM 数据按采样率送到输出端再用 LCD 把当前歌曲的 JPEG 封面画出来。这套工程默认走 WAV/PCM 直通路径MP3 解码需要外挂 VS1053 这类 codec 芯片并不在这个实验包里想补上这部分得先弄懂它已有的模块是怎么咬合的。2. 播放器的地基FatFs 文件系统移植与 SD 卡扇区读取2.1 为什么播放器必须先解决文件系统STM32 读音频文件很多人第一反应是直接读 SD 卡扇区这在裸机环境下容易把自己带进坑里。SD 卡里存的是 FAT32 文件系统一首歌在盘上占用的往往不是连续扇区而是分布在不同的簇上。按扇区硬读的话拿到的数据顺序是乱的根本拼不出一段完整的音频流。FatFs 这类移植库的核心价值就是把文件名到簇链再到扇区偏移的映射关系处理好上层只要调 f_open、f_read就能按顺序拿到完整的音频字节流。播放器场景里文件系统常常和显示共用一条数据通路。这套工程把 ff.c 和 cc936.c 一起编译前者负责解析 FAT 表项、维护读写指针后者负责把 Windows 拷入 SD 卡时用的 GBK 中文文件名转换成目录项里的 Unicode 表示。两者配合f_open(晴天.wav) 才能命中正确的文件。如果只编 ff.c 不编编码页中文名文件会以乱码形式出现在目录里扫歌列表时白干半天。移植前有几个配置开关值得先确认配置宏推荐值说明_FS_READONLY1播放器只读文件剪掉写路径占用的代码段_USE_LFN2开长文件名值为 2 表示使用堆栈缓冲_MAX_LFN255最长文件名长度按 Windows 上限设_MAX_SS512SD 卡默认扇区大小配合 disk_ioctl 查询_USE_EXFAT0实验通常用 FAT32保持关闭更省内存2.2 移植 FatFs 实际是改五个底层函数FatFs 的移植惯例是保持 ff.c 本体不动把 diskio.c 里的五个接口按板子实现disk_status、disk_initialize、disk_read、disk_write、disk_ioctl。播放器是只读场景disk_write 直接返回 RES_OK 即可disk_ioctl 要根据 FatFs 下发的命令返回扇区数、块大小这类几何信息。真正费周折的是 disk_read 和 disk_initialize。/* diskio.c: SD 卡底层读扇区适配 SDIO 接口的常见写法 */ DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; /* block 大小按 512 字节处理SD 卡默认扇区尺寸 */ if (SD_ReadBlocks((uint32_t *)buff, sector, count) ! MSD_OK) return RES_ERROR; /* 等硬件退出忙态避免下一个命令挤掉本次传输 */ while (SD_GetCardState() ! SD_CARD_TRANSFER) ; return RES_OK; }这段代码里有四个参数要弄清。pdrv 是物理驱动号单卡场景固定为 0多卡时靠它区分介质buff 是 FatFs 传入的数据缓冲实际大小至少是 512×count 字节配小了会越界sector 是逻辑块地址 LBAFatFs 已经把 CHS 换算好了底层不要再做一次换算count 是连续扇区数FatFs 会根据 FAT 簇大小传值常见的是一次 8 或 16 扇区。末尾的 while 轮询是常见做法目的就是在 DMA 传输还没结束前不让后续的 SDIO 命令抢占总线。disk_initialize 里最典型的一个坑是时钟序列。SD 卡规范要求在初始化和识别阶段时钟不超过 400kHz卡进入传输模式后才能提高到默认的 25MHz 或高速模式的 50MHz。如果上电就以高速模式去发 CMD0很多卡会直接不回响应或者返回 0xFF。我一般会把时钟切换写成两步先慢速初始化等 CMD2/CMD3 拿到卡 CID 和 RCA 之后再重配 SDIO_CCR 里的时钟分频。2.3 cc936.c 和中文歌名的编码路径FatFs 默认支持的是短文件名长文件名必须靠 LFN 目录项扩展。启用 LFN 之后目录项里保存的是 UTF-16 编码的长文件名而 Windows 格式化并拷入文件时简体中文环境下文件名落在 FAT 表里的是 GBK 编码。cc936.c 干的事就是在这两种编码之间做双向映射f_open(晴天.wav) 时它先把 GBK 字符转成 Unicode 再和目录项比对反过来读目录时再把 Unicode 文件名转回 GBK 供界面显示。配置上只需要在 ffconf.h 里打开 _USE_LFN并把 _MAX_LFN 设成 255。如果实验要求不高把所有歌曲改名成 song01.wav 这样的 ASCII 名也可以关掉 LFN能省下一块静态缓冲区代价是彻底放弃中文名。实验包里同时出现 cc936、cc949、cc950、cc932 四个编码页文件分别对应简体中文、韩文、繁体中文、日文这是作者为多语言环境预留的实际做简体中文项目时编 cc936.c 就够了ccsbcs.c 是配套的单字节字符集辅助代码一并编入即可多编几个编码页只会平白增加 flash 占用。2.4 扫描目录把歌曲列表从卡里拉出来有了文件系统下一步是按歌名找文件。用 FatFs 的目录遍历接口逐项扫描根目录命中 WAV 扩展名就记进歌曲列表。DIR dir; FILINFO finfo; FRESULT fr; fr f_opendir(dir, /); /* 打开根目录 */ if (fr ! FR_OK) return fr; for (;;) { fr f_readdir(dir, finfo); /* 每次读一个目录项 */ if (fr ! FR_OK || finfo.fname[0] 0) break; if (!(finfo.fattrib AM_DIR) strstr(finfo.fname, .wav) ! NULL) { /* fname 在启用 LFN 后可能是长文件名 */ memcpy(song_list[song_cnt].name, finfo.fname, sizeof(finfo.fname)); song_cnt; } } f_closedir(dir);f_readdir 每次返回一个目录项fname 为空字符串表示目录已经读完。这里的 AM_DIR 位判断是必须的否则文件夹也会被当成歌曲混进列表。strstr 匹配的是小写 .wav实际拷进卡里的文件扩展名可能是 .WAV 或 .Wav我一般会在判断里用两个分支分别处理或者先把扩展名统一转小写再比较。song_list 这个数组要提前开好常见做法是定一个固定 64 首、每首歌名 64 字节的二维数组歌多了再换成分页加载避免一次遍历把 RAM 吃满。FatFs 这层移植完f_mount 能返回 FR_OK 只是起点真正的问题往往在细节里。常见情况有四种一是 SPI 或 SDIO 的引脚复用没配对SPI 模式下的 CS 片选必须软件拉低有人忘了配置 NSS 复用功能卡永远选不中二是卡是 exFAT 格式老版本 FatFs 默认不认识_USE_EXFAT 没开就只报 FR_NO_FILESYSTEM实验里直接格式化成 FAT32 更省事三是 _MAX_SS 配成 4096 而卡实际是 512 字节扇区如果没有在 disk_ioctl 里正确返回 GET_SECTOR_SIZEFatFs 按最大扇区去分缓冲和计算偏移读回来就是错位的四是上电时序规范要求先给卡至少 74 个空闲时钟周期再发 CMD0很多人上电后立刻发命令卡还在上电复位就直接超时。这四个方向逐一排查文件系统这层基本就通了。3. 音频数据通路定时器、DMA 与 WAV 解码3.1 先分清输出路径PWM、DAC 还是 I2S在动手写音频代码之前要先把输出路径定下来。文件列表里没有音频 codec 驱动这说明硬件 I2S 在这个实验包里没有被启用最贴合现有资源的实现是定时器按采样率产生更新事件DMA 把 PCM 样本从内存搬运到 DAC 数据寄存器经过内置 DAC 输出模拟信号再通过外部 RC 低通滤波和功放接耳机或喇叭。另一种常见做法是 PWM 直接输出占空比随时间变化再用二阶低通滤波器恢复波形但位深受定时器分辨率限制效果只能当实验音。输出路径依赖外设典型位深适合场景PWM RC 滤波定时器 任意 GPIO8 bit 左右音效提示、验证时序DAC 运放放大DAC DMA12 bit本实验包最常用方案I2S 外部 codecI2S VS1053 等16/24 bit产品级播放器选 DAC 方案还有一个现实原因STM32 的 DAC 是 12 位分辨率配合 DMA 循环传输时每个样本只占一次搬运CPU 几乎不参与而 PWM 方案要在更新中断里不停改占空比寄存器中断频率一高主循环里的按键扫描和 LCD 刷新就会被挤得抖动听起来就是节奏不稳。3.2 先从 WAV 头里拿到采样率和位深WAV 文件结构很直白RIFF 块带头加 fmt 块加 data 块。播放器需要从头部读出采样率、声道数、位深才能算出定时器周期和 DMA 每次搬运的长度。解析方式一般是用结构体直接映射文件头的前 24 个字节。typedef struct { uint8_t riff[4]; /* RIFF 标记 */ uint32_t file_size; /* 文件总长度不含前 8 字节 */ uint8_t wave[4]; /* WAVE 标记 */ uint8_t fmt_id[4]; /* fmt 块 ID */ uint32_t fmt_size; /* fmt 块长度通常为 16 */ uint16_t audio_fmt; /* 1 为 PCM其他为压缩格式 */ uint16_t channels; /* 声道数1 或 2 */ uint32_t sample_rate; /* 采样率单位 Hz */ } wav_header_t; wav_header_t wav; f_read(audio_file, wav, sizeof(wav), br); if (wav.audio_fmt ! 1) { /* 非 PCM 格式实验包里不支持直接报错返回 */ return PLAYER_ERR_UNSUPPORTED; }这段代码的关键字段是 audio_fmt、channels、sample_rate。audio_fmt 等于 1 才是未压缩的 PCM 数据等于 2 或 3 分别对应 ADPCM 和 IEEE Float实验里通常不碰。sample_rate 决定定时器 ARR 的计算基准channels 决定每个样本帧的字节数双声道时每个采样时刻要搬两个样本。WAV 是小端字节序Cortex-M 也是小端直接结构体映射即可不需要手动翻转字节。少数录音软件会在 RIFF/WAVE 之间插入一个 JUNK 块遇到解析出来的 file_size 对不上时按块 ID 顺序扫描而不是固定偏移读取更稳。3.3 定时器 DMA 搬运 PCM 样本确定了参数接下来是播放核心。常见做法是让定时器工作在更新事件触发模式每次溢出把 DAC 触发一次DMA 在触发信号下把样本写入 DAC 数据寄存器。只要 DMA 配成循环模式搬运完整个缓冲区会自动绕回速度完全由定时器频率决定。#define SAMPLE_RATE 22050u /* 以 72MHz 主频为例计算定时器计数周期 */ uint32_t arr SystemCoreClock / SAMPLE_RATE - 1; TIM2-PSC 0; /* 预分频为 1不加额外分频 */ TIM2-ARR arr; /* 更新事件频率 采样率 */ TIM2-CR1 | TIM_CR1_ARPE; /* 自动重载缓冲改 ARR 不产生毛刺 */ /* DMA 循环模式内存到 DAC 输出寄存器右对齐 12 位 */ HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)pcm_buf, BUFFER_SIZE, DAC_ALIGN_12B_R);这里的 PSC 和 ARR 是理解的难点。定时器时钟先经过 PSC 分频得到计数时钟PSC 为 0 表示不分频72MHz 直接作为计数时钟ARR 决定计到多少溢出一次所以 ARR 主频 / 采样率 - 1数值越大输出频率越低。22050Hz 采样率时 ARR 大约是 3265换成 44100Hz 时就减半。DAC_ALIGN_12B_R 表示 12 位右对齐如果 PCM 样本是 16 位需要在搬运前右移 4 位或者在解码时直接做成查表。还要注意 HAL_DAC_Start_DMA 默认由软件触发想让采样率严格跟随定时器得把 DAC 的触发源设成 TIM2 的 TRGO否则 DMA 连续灌数据声音音高完全不对。DMA 循环模式配合的是双缓冲。循环模式会自动回卷但回卷之后缓冲区会被新数据覆盖所以常见做法是把缓冲区切成前后两半在每半传输完成中断里用 f_read 读取下一段数据填进刚用完的那一半。这样解码和播放之间没有空白间隙。void HAL_DAC_ConvCpltCallback(DAC_HandleTypeDef *hdac) { uint16_t half; /* 让下一段数据落在刚搬完的半块上 */ cur_half !cur_half; half cur_half * HALF_SIZE; f_read(audio_file, pcm_buf[half], HALF_SIZE * sizeof(uint16_t), br); }这个回调要小心两点。第一f_read 在中断上下文执行如果 SD 卡响应慢整个中断会被拖长表现为播放时周期性卡顿。我一般会在回调里只置一个标志位把 f_read 挪到主循环里做牺牲一点实时性换来稳定。第二HALF_SIZE 要选成 SD 卡一次读取比较高效的数值例如 512 字节的整数倍否则底层每次读卡都要做扇区对齐处理效率会明显下降。4. 显示与交互ILI93xx 驱动、JPEG 封面与播放状态机4.1 ILI93xx 是播放器的“脸面”ILI93xx.c 驱动的是以 ILI9341 为代表的 TFT-LCD 控制器工作在 RGB565 颜色格式这套实验包里用它显示封面图、歌名和播放进度。ILI9341 内部本质是一块带 GRAM 的显示 RAMCPU 通过写命令 0x2C 把像素数据往 GRAM 里灌之后控制器负责把 GRAM 内容按扫描时序刷到屏上。对播放器来说显示路径的负载不小一张 240×320 的 16 位色 JPEG 封面解出来约 150KB如果不做窗口裁剪直接整幅渲染DMA 刷屏期间会和音频 DMA 抢总线声音出现周期性毛刺。实际工程里常见做法是把封面缩放到屏幕 1/4 区域用 ILI93xx_SetWindow 限定写入范围既能减少传输量也让 JPEG 解码的内存消耗下降。这就是为什么实验包里 ILI93xx.c 和 tjpgd.c 总是成对出现一个管显示窗口一个管把压缩位图变成像素。4.2 LCD 底层读写函数大多数 ILI93xx 驱动代码都是围绕写命令、写数据、设窗口这三个函数展开的。以 FSMC 并行接口为例void LCD_WriteReg(uint16_t reg, uint16_t value) { LCD-LCD_REG reg; /* 地址线选到命令寄存器 */ LCD-LCD_RAM value; /* 数据线写值 */ } void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteReg(0x2A, (x0 16) | x1); /* 列地址起止 */ LCD_WriteReg(0x2B, (y0 16) | y1); /* 行地址起止 */ LCD_WriteReg(0x2C, 0x0000); /* 激活写 GRAM */ }0x2A 和 0x2B 在 ILI9341 里是列地址和行地址设置命令参数是高位在前、低位在后的 16 位起止坐标所以代码里把 x0 移到高 16 位、x1 留在低 16 位。0x2C 是 Memory Write写入该命令后后续对数据寄存器的每次写入都会按窗口顺序推进。这里有一个常犯的错窗口设置后立即写第一个像素但忘了先发 0x2C 激活结果数据落在上一次的窗口位置上画出的封面是错位碎片。命令十六进制功能参数格式MADCTL0x36设置扫描方向8 位控制字CASET0x2A列地址设置x016RASET0x2B行地址设置y016RAMWR0x2C写 GRAM连续 RGB565DISPON0x29开启显示无参数4.3 tjpgd.c把封面 JPEG 解到屏幕上tjpgd.c 是一个适合单片机的小型 JPEG 解码库公共接口只有 jd_prepare 和 jd_decomp 两个。它不读文件、也不直接操作屏幕而是通过输入回调和输出回调与外界交互。输入回调负责提供 JPEG 字节流输出回调负责接收解码出来的像素矩形块。JDEC jdec; uint8_t work[3100]; /* 解码工作区TJPEG 例程常见的推荐规格 */ /* 输入回调从文件读 ndata 字节到 buf */ UINT jpg_input(JDEC *jd, uint8_t *buf, UINT ndata) { if (buf NULL) { /* 随机访问模式需要跳过 ndata 字节 */ f_lseek(jpg_file, f_tell(jpg_file) ndata); return ndata; } f_read(jpg_file, buf, ndata, br); return br; } /* 输出回调把矩形区域像素写进 LCD */ UINT jpg_output(JDEC *jd, void *bitmap, JRECT *rect) { LCD_SetWindow(rect-left, rect-top, rect-right, rect-bottom); LCD_WritePixels(bitmap, (rect-right - rect-left 1) * (rect-bottom - rect-top 1)); return 1; /* 1 表示继续解码 */ }输入回调里 buf 为 NULL 是比较容易被忽略的情况。tjpgd 在图像抽稀或者跳跃式解码时会需要随机跳过数据段这时候不是读进内存而是用 f_lseek 直接跨过去。输出回调的 bitmap 是 16 位 RGB565 还是 24 位 RGB888取决于 jd_prepare 时传入的 JD_FORMAT 参数。这个参数解码封面图时可以直接选 RGB565省掉一次颜色转换和 ILI9341 的像素格式正好对上。work 缓冲区的大小越低解码大图时的行数限制越明显3100 字节基本够用如果解不出完整封面优先检查是不是 JPEG 过大或采样格式导致工作区不足。4.4 按键控制与播放状态机播放器的交互逻辑通常是按键扫描加状态机。不建议在按键中断里直接操作播放器因为 f_open、f_read、JPEG 解码都是长耗时操作中断里调用会把定时器音频中断卡断造成明显爆音。正确做法是把按键事件放在主循环里处理状态切换逻辑写成有限状态机。typedef enum { ST_IDLE 0, /* 停止屏幕显示歌曲列表 */ ST_PLAYING, /* 播放中DMA 已启动 */ ST_PAUSED /* 暂停DMA 停止但文件指针保留 */ } player_state_t; void player_handle_key(uint8_t key) { switch (state) { case ST_IDLE: if (key KEY_PLAY) { player_open(song_current); /* 解析 WAV 头 */ player_start(); /* 启动定时器和 DMA */ } break; case ST_PLAYING: if (key KEY_PLAY) { player_pause(); /* 停 DMA保留读位置 */ } else if (key KEY_NEXT) { player_stop(); player_open(song_current 1); player_start(); } break; case ST_PAUSED: if (key KEY_PLAY) { player_resume(); /* 重新触发 DMA */ } break; } }这个状态机的关键在暂停和切歌的差异暂停只是在 DMA 层面停住FatFs 的文件指针不能丢所以暂停时不能 f_close恢复时直接重新启动 DMA文件指针还在原来的位置样本流就能接续。切歌则要先把当前文件关闭再打开下一个否则 FATFS 同时打开的句柄不够用。状态机的好处是这套逻辑下按键连按、快进快退等扩展操作不会把播放状态搞乱。当前状态按键动作目标状态IDLEPLAY打开文件、启动 DMAPLAYINGPLAYINGPLAY停 DMA、保留文件指针PAUSEDPLAYINGNEXT关闭文件、打开下一首PLAYINGPAUSEDPLAY重新启动 DMAPLAYINGPAUSEDNEXT关闭文件、打开下一首PLAYING5. 调不通这套播放器时先看这三个地方5.1 把 FatFs 返回码打印出来f_open、f_read 返回的 FRESULT 枚举往往比现象先出现。常见做法是封装一个很小的字符串映射函数在串口里直接打印出错环节前提是先把 printf 重定向到串口。const char *fr_errstr(FRESULT fr) { static const char *err[] { OK, DISK_ERR, INT_ERR, NOT_READY, NO_FILE, NO_PATH, INVALID_NAME, DENIED }; return err[fr 8 ? fr : 0]; } printf(f_open - %s\r\n, fr_errstr(fr));这套打印的价值在于快速区分问题层NOT_READY 是底层磁盘初始化失败先查 SDIO 时钟和引脚复用NO_FILE 意味着目录里确实没有匹配文件去查文件名编码和扩展名大小写DISK_ERR 则指向扇区读取本身重点检查单次读取是否跨了卡的分区边界。5.2 用 f_ftell 验证播放是否有进展播放不响原因可能是没声音输出也可能是文件指针根本没动。在 DMA 半传输回调里给计数器加一主循环里每秒打印一次 f_ftell 的增量。以 22050Hz 双声道 16 位为例每秒应该前进 22050 × 2 × 2 88200 字节。如果 f_ftell 增量明显小于这个值说明 f_read 的速度跟不上 DMA要么降低采样率要么把读取粒度增大到 512 的整数倍如果增量正常而喇叭无声问题就转移到 DAC 配置或低通滤波电路上了。5.3 开机的“啵”声输出先归零再开功放这个问题的根源是 DAC 上电时输出寄存器可能是非零值功放使能瞬间电容耦合把这段阶跃电压放大成爆音。常见做法是把音频路径的加电顺序固定成三步DAC 输出写 0 延时再启动 DMA 搬运真实样本最后才拉高功放使能引脚。切换歌曲时也一样先把 DAC 寄存器清零再换文件否则每次切歌都会砸一下耳朵。DAC-DHR12R1 0; /* 输出拉回静音电平 */ HAL_DAC_Stop_DMA(hdac, DAC_CHANNEL_1); player_open(song_next); /* 打开下一首并解析头部 */ HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)pcm_buf, BUFFER_SIZE, DAC_ALIGN_12B_R);这个收尾其实是一个通用的启动时序技巧凡是带功放或耳机放大器的音频系统使能顺序都是先信号后电源反过来必然在耳机里留下一声咔哒。采样率改动后也要重新计算 ARR直接用 22050 的参数去跑 44100 的文件声音会慢半拍确认计数周期有没有对用按键切歌加示波器看 DAC 输出波形就能判断出来。本文还有配套的精品资源点击获取