news 2026/9/16 14:38:14

STM32音频采样与存储:I2S+DMA双缓冲+FatFS实现高保真录音

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32音频采样与存储:I2S+DMA双缓冲+FatFS实现高保真录音

简介:该工程以STM32微控制器为核心,围绕音频信号采集、处理与存储展开设计,覆盖ADC模拟量采样、定时器触发、缓存管理和文件写入等实现细节,非常适合课程设计、毕业设计或嵌入式进阶练习。资源压缩包内共包含297个文件,整体约9.98MB;C语言源码与头文件数量最多,构成工程主体,另含Keil工程文件、编译中间产物、PNG图片和文本说明,目录结构便于按模块查看。工程还集成了FatFs文件系统、CC936等多语言编码转换模块,并保留清理脚本与工程备份文件,便于调试与二次开发。当前已有110人浏览学习,通过对照源码和工程配置,可以掌握从AD采样到SD卡存储的数据链路,以及嵌入式文件系统移植与调用的完整方法。整个项目同时可作为基于Keil MDK的工程模板,便于学习外设驱动组织、文件系统集成与工程管理方法。

1. 基于 STM32 音频采样与存储项目:先解决“落盘速度追不上采样率”的问题

做一个基于 STM32 音频采样与存储项目,最典型的场景是:麦克风采集环境声,STM32 通过 I2S 接收 PCM 数据,再写入 SD 卡或 SPI Flash。硬件链路不复杂,但真正到了连续录音 10 分钟往 MicroSD 卡上写文件时,问题会接连出现:DMA 缓冲覆盖、FatFS 写耗时超过音频块时间、掉电后 WAV 文件头损坏。很多人误以为采样率越高越好,实际上,如果存储端没有做双缓冲和块对齐,44.1kHz/16bit 立体声产生的 176.4KB/s 数据流,足以让一个 64MHz 的 MCU 主循环崩溃。这里顺着“I2S 采样 → DMA 双缓冲 → FatFS 落盘”这条线,给出能直接照用的配置参数、C 代码和常见性能瓶颈。适合刚接触 STM32 的毕业设计学生,也适合做环境声监测项目的在职工程师。

2. STM32 音频采样前端选型:I2S 外部 Codec 和内置 ADC 怎么取舍

2.1 内置 ADC 能采集音频,但别做 16bit 高保真

STM32 内置 ADC 是逐次逼近型,多数 F1/F4/F7 系列分辨率 12bit,采样率可以配置到数 MHz。用它对麦克风前置放大信号采样,做 8kHz 语音识别、环境噪声强度统计完全可行,但要注意两个问题。

一是精度。12bit ADC 在音频频段实际有效位数(ENOB)往往只有 10bit 左右,换算成 16bit PCM 时低位全是噪声,强行左对齐会放大底噪。二是采样时钟。内置 ADC 虽然能用定时器精确触发,但连续采样时没有独立的字时钟,做多通道同步采集时声道偏移不可控。因此,单声道低频环境音可以直接使用内置 ADC;需要 16bit 标准 WAV 或双声道,应改用 I2S 接口外接音频 Codec。

如果项目决定用内置 ADC,另一个容易踩的坑是采样时间配置。不要为追求采样率把 ADC Sample Time 设成 1.5 cycles,麦克风信号源阻抗稍高就会导致采保电容充电不完全,波形边缘出现毛刺。常见做法是设成 13.5 cycles 以上,配合运放做低阻抗缓冲后再进 ADC。DMA 把采样结果循环传送到 uint16_t 数组,应用层再统一转换成带符号的 16bit PCM,这个转换过程会占用 CPU,所以主循环里不要同时做高开销的浮点计算。

2.2 I2S DMA 双缓冲配置:半满/全满回调是核心

使用 STM32CubeMX 时,把 I2S2 设为 Master Receive,MCLK 使能,Audio Frequency 设为 16kHz,Data Format 设为 16bit。DMA 选择 SPI2_RX,方向 PeripheralToMemory,模式 Circular,数据宽度 Half Word。这时的 DMA 需要一块双倍长度的 buffer:前半段和后半段分别作为一个缓冲块。DMA 传输完前半段触发 HalfTransfer 中断,传完后半段触发 TransferComplete 中断,这就是双缓冲。

实际代码用 HAL 回调即可,示例如下:

#define HALF_BUF_SAMPLES 1024 // 1024 个采样,16bit 单声道为 2KB #define SAMPLE_RATE 16000 static int16_t pcm_buf[HALF_BUF_SAMPLES * 2]; // DMA 直接写入的总缓冲 static volatile uint8_t ready[2] = {0, 0}; // ready[0] 前半段可消费, ready[1] 后半段可消费 // 启动 I2S 接收,使用 DMA circular 模式 void audio_start(void) { HAL_I2S_Receive_DMA(&hi2s2, (uint8_t *)pcm_buf, HALF_BUF_SAMPLES * 2); } // DMA 前半段完成 void HAL_I2S_RxHalfCpltCallback(I2S_HandleTypeDef *hi2s) { if (hi2s->Instance == SPI2) { ready[0] = 1; } } // DMA 后半段完成 void HAL_I2S_RxCpltCallback(I2S_HandleTypeDef *hi2s) { if (hi2s->Instance == SPI2) { ready[1] = 1; } }

这段代码的关键参数:pcm_buf长度是HALF_BUF_SAMPLES * 2,因为 circular 模式下 DMA 会连续回绕,前半段和后半段必须是连续内存。HALF_BUF_SAMPLES决定一次存储消费多少数据,1024 个采样点在 16kHz 下正好是 64ms 音频。如果设备上同时有 FSMC 总线和 DMA2,I2S 的 DMA 通道要选择不冲突的一条流,具体查芯片的 DMA mapping 表,否则会出现周期性数据错位。

ready[]标志只是裸机演示,真实工程应替换成信号量或事件组,并用临界区保护标志的写入。否则主循环刚好在读取ready[1]时,DMA 回调又修改ready[0],多数情况下不会出错,但会被编译器和缓存优化造成偶发丢数据。尤其在 Cortex-M7 带 D-Cache 的型号上,还要对 DMA buffer 做SCB_InvalidateDCache_by_Addr

2.3 采样参数与缓冲时长速查

参数数值说明
采样率8k / 16k / 44.1k16k 足够语音有效带宽,44.1k 用于音乐级录音
位深16bitI2S 标准格式,WAV 不用转换;内置 ADC 需要对齐转换
声道mono / stereo数据量按声道数翻倍
半缓冲采样点512 / 1024 / 4096对应时长 = 采样点 / 采样率;1024 @16k 为 64ms
DMA 模式Circular否则半满/全满中断后需要手动重新启动

选择缓冲大小时,主要看 RAM。1024 点 16bit 单声道一个半块占 2KB,两个半块占 4KB,对 STM32F103 这种 64KB RAM 的芯片很轻松。如果要做 44.1kHz 立体声,一个半块 4096 点就是 16KB,两个半块 32KB,这时要谨慎评估剩余 RAM 是否够放 FatFS 缓冲和协议栈。

3. 存储介质和文件系统:从 WAV 头结构体到 FatFS 连续写盘

3.1 三种可落盘介质的取舍

存储环节取决于“要存多久”和“能否断电续存”。针对 STM32 音频采样与存储项目,常见的介质有三种。

介质容量范围写入速度擦写寿命典型用法
SPI Flash (W25Q64/128)8–256MB块擦除 0.2–2s,页写 0.1–10ms10 万次擦写分段录音、日志型循环录放
MicroSD/TF + FatFS512MB–64GB2–10MB/s,块写较快卡内部磨损均衡连续录音,拔卡后电脑直接播放
内部 Flash256KB–2MB快,但擦除影响运行1 万次存启动标志、参数,不适合音频数据

如果只做几分钟录音,SPI Flash 是成本最低的选择。问题是持续写入时,W25Q 的擦除延迟不稳定,而且没有成熟的坏块管理,建议配合 LittleFS 使用。想要录完拔卡插电脑直接看,用 MicroSD + FatFS 更省事。要注意这里说的是 MCU 本地落盘,不是“分布式存储”或“NAS 存储池”那种磁盘阵列概念;如果项目后面加 Wi-Fi 模块把音频上传到对象存储服务,实时性瓶颈仍然在本地这一端,不在网络上传。

3.2 用结构体定义 WAV 头:写盘前先把头占位

WAV 文件头部长度固定 44 字节,适合用结构体定义。这样比手写字节偏移更直观,也和“结构体链式存储”那种动态链表不同,这里只需要一个固定节点,一次 memset 初始化后整体写入文件。

#include <stdint.h> typedef struct { char riff[4]; // 'R','I','F','F' uint32_t riff_size; // 文件总长度 - 8,先写0,最后回填 char wave[4]; // 'W','A','V','E' char fmt[4]; // 'f','m','t',' ' uint32_t fmt_size; // 16 uint16_t audio_format; // 1 = PCM uint16_t num_channels; // 1 = 单声道 uint32_t sample_rate; // 16000 uint32_t byte_rate; // sample_rate * block_align uint16_t block_align; // channels * bits_per_sample/8 uint16_t bits_per_sample; // 16 char data[4]; // 'd','a','t','a' uint32_t data_size; // 实际数据字节数,先写0 } wav_header_t;

这个结构体在 GCC 默认对齐下正好 44 字节,但不同编译器可能有 padding,稳妥做法是代码里加static_assert(sizeof(wav_header_t) == 44, "WAV header size");,并且在写入前对整个变量 memset 清零。头部初始化代码如下:

wav_header_t hdr; memset(&hdr, 0, sizeof(hdr)); memcpy(hdr.riff, "RIFF", 4); memcpy(hdr.wave, "WAVE", 4); memcpy(hdr.fmt, "fmt ", 4); memcpy(hdr.data, "data", 4); hdr.fmt_size = 16; hdr.audio_format = 1; hdr.num_channels = 1; hdr.sample_rate = SAMPLE_RATE; // 16000 hdr.block_align = 2; hdr.bits_per_sample = 16; hdr.byte_rate = hdr.sample_rate * hdr.block_align; f_write(&file, &hdr, sizeof(hdr), &written);

注意 STM32 和 x86 一样是小端,直接写结构体没有字节序问题。如果后续从大端平台读文件,才需要统一转成小端。

3.3 FatFS 连续写 PCM:块大小、f_sync 频率和写延迟

FatFS 的f_write内部会把数据拷贝到窗口缓冲,再通过disk_write写入 SD 卡。写入块大小最好对齐到扇区,512B、4KB 都行。实际录制循环如下:

FIL file; FRESULT res; UINT written; uint8_t *data_ptr; uint32_t blocks_written = 0; res = f_open(&file, "rec0001.wav", FA_CREATE_ALWAYS | FA_WRITE); if (res != FR_OK) { show_error(res); // 打印 FR_DISK_ERR / FR_NOT_READY 等 return; } audio_start(); for (;;) { if (wait_audio_block(&data_ptr, &block_len) == TIMEOUT) { break; } res = f_write(&file, data_ptr, block_len, &written); if (res != FR_OK || written != block_len) { stop_audio(); break; } blocks_written++; if (blocks_written % 64 == 0) { f_sync(&file); // 每秒左右同步一次,避免 FAT 表丢太多 } }

这里是几个容易翻车的地方:f_write不能在 DMA 中断回调里调用,FatFS 不是中断安全库,在中断里写会产生死文件和 SD 卡逻辑坏块。f_sync也不要每块都调用,它会强制写 FAT 表,一次耗时可能从几十微秒到几毫秒,夹在采集循环里会打乱节拍,每 64 块同步一次即可。

如果写卡失败,不要只记录一个错误码后继续写,因为卡可能已经进入坏死区域。常见做法是关闭当前文件,用递增文件名创建新文件继续录,并把失败事件通过串口或日志文件记录。

4. DMA 双缓冲 + 环形状态机:让采样不被存储拖死

4.1 为什么直接调用 f_write 会让音频中断掉数

宏观上看,数据流是:I2S 外设持续把 PCM 位流移入 DMA buffer,DMA 每填满半段触发一次中断,CPU 得到“数据已可用”信号后调用 FatFS 写入 SD 卡。问题在于 SD 卡写入时间不是恒定值:空闲卡块写 0.3ms,正在做垃圾回收的卡可能达到 10ms 甚至 50ms。如果刚好在 DMA 准备覆盖某个缓冲区时f_write还没结束,这个半块就会被新数据覆盖,回放时表现为周期性“咔嗒声”。

解决方式有两个方向:扩大块间隔,以及保证消费指针永远追不上 DMA 写指针。把半缓冲从 512 采样扩大到 4096 采样,16kHz 下单块时长是 256ms,为写卡留出足够时间。同时必须让存储逻辑一次消费一个完整半块,不要一有数据就写一个字节。下面用信号量把这两件事串起来。

4.2 用信号量替代轮询标志,避免漏块

在裸机主循环里轮询ready[]没问题,如果引入 FreeRTOS,回调中用信号量通知存储任务更自然:

static osSemaphoreId_t block_sem; static volatile uint8_t ready_map; // 回调中统一通知 void audio_block_ready(uint8_t half) { ready_map |= (1 << half); osSemaphoreRelease(block_sem); } // 存储任务 void storage_task(void *arg) { uint8_t half; int16_t *p; UINT written; for (;;) { osSemaphoreAcquire(block_sem, osWaitForever); // 找到当前 ready 的那一段 half = (ready_map & 1) ? 0 : 1; ready_map &= ~(1 << half); p = &pcm_buf[half * HALF_BUF_SAMPLES]; f_write(&file, p, HALF_BUF_SAMPLES * sizeof(int16_t), &written); total_samples += HALF_BUF_SAMPLES; } }

这个写法的关键点:信号量计数和ready_map必须配套。如果 DMA 回调连续触发两次,任务只消费了一个信号量,另一个信号量会堆积,导致之后多消费一跳。更稳妥的方式是在回调中直接翻转生产者索引:

static volatile uint8_t producer_half = 0; // DMA 回调中执行 producer_half ^= 1; osSemaphoreRelease(block_sem); // 任务中获取“刚完成的那一半” uint8_t owned_half = producer_half ^ 1;

producer_half的访问存在竞态,真实项目中要关中断一两个指令周期再处理。对于单核 Cortex-M,用__disable_irq()搭配短临界区可以接受,不要在临界区里做 f_write。

4.3 数据量与块时长计算:一个必须写进设计文档的表格

以 16bit 单声道为例,缓冲参数直接影响存储任务的可容忍延迟。下表的“允许上限”按半块时长的一半保守估计,更稳妥的方案是留出 1/4 余量。

块采样点数块时长@16kHz块数据量建议写盘完成时间
51232ms1KB20ms 内
102464ms2KB100ms 内
4096256ms8KB500ms 内
8192512ms16KB1s 内

选择 4096 采样点的半缓冲,两个半块占 16KB RAM,对 STM32F407 这类 128KB RAM 的芯片足够。如果必须用 STM32F103 的 64KB RAM,就选 1024 点,并接受 SD 卡偶尔卡顿带来的丢块风险。另一种思路是增加一层块队列,见下一节。

4.4 存储端也可以做链式块缓冲结构

如果不想让 PCM 缓冲和 FatFS 直接耦合,可以用一个固定长度的静态链式队列。每个节点保存指针和数据长度,采集回调把节点挂到队尾,写盘任务从队头取节点。这样允许写盘任务一次批量提交多个块,也为将来接入 RTOS 的消息队列留好扩展。

和通用结构体链式存储不同,这里不要用 malloc,否则长时间运行会产生内存碎片。常见做法是定义 4 个静态节点,初始化时串成空闲链表,需要时从空闲链表取节点,写完后归还。这样链式缓冲的长度有上界,DMA 覆盖风险可以提前在编译期发现。

5. 掉电恢复与采样率自检:把 WAV 文件变成可稳定回放的产物

5.1 掉电后回填 WAV 头:末尾 4 字节救回整个文件

录音最常见的现场故障是:正在写 SD 卡时突然断电,riff_sizedata_size始终是 0,电脑播放器拒绝打开文件。我的做法是从一开始就在文件末尾额外写 4 字节的“实际数据长度”。下次上电扫描目录,发现data_size为 0 的文件,就用末尾 4 字节回填头部并截断。

uint32_t recover_wav_len(const char *path) { FIL f; uint32_t tail = 0; UINT rd; uint32_t total; if (f_open(&f, path, FA_READ | FA_WRITE) != FR_OK) return 0; if (f_size(&f) < 48) { f_close(&f); return 0; } f_lseek(&f, f_size(&f) - 4); f_read(&f, &tail, 4, &rd); f_lseek(&f, 40); // WAV 的 data_size 字段偏移 f_write(&f, &tail, 4, &rd); total = tail + 44 - 8; // RIFF size = 文件总长度 - 8 f_lseek(&f, 4); f_write(&f, &total, 4, &rd); f_close(&f); return tail; }

注意f_write之前要f_lseek到需要回填的位置,FatFS 的写指针不会因为f_read自动回到开头。每次开始新录音前,把“当前文件序列号”和“音频长度”写到内部 Flash 或 RTC backup 寄存器,比上电扫描整个目录快得多。

5.2 采样率自检与写盘速率验证

采样率配置错误会直接导致回放变调,而且要等录完才发现。快速自检方法:用定时器输出一个 1kHz 方波到 ADC 前端,录制 5 秒,把 WAV 文件拿到电脑上做频谱分析,能量峰应该在 1kHz。如果没有频谱工具,可以让 STM32 在录音的同时用另一个定时器统计 10 秒内的采样中断次数,正常应等于10 * SAMPLE_RATE,误差超过 10 就说明时钟树算错。

最后还要验证写盘速率是否跟得上采集速率。在录制循环里累计blocks_writtenelapsed_ms,用blocks_written * block_len * 1000 / elapsed_ms算实际写盘吞吐。当 SD 卡接近满时,写入速率可能下降到原来的 1/3。一旦低于音频比特率,就应该切换到新分区或停止采集,而不是继续写导致静默丢数据。只有这个数字稳定高于 32KB/s(16bit 单声道 @16kHz),整个基于 STM32 音频采样与存储项目才算真的闭环。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 14:37:24

Spring Boot无人超市全链路实战:支付验签、库存并发与订单状态机

简介&#xff1a;本资源是一套完整的毕业设计级无人超市管理系统实现方案&#xff0c;面向计算机专业本科生及Spring Boot初学者&#xff0c;解决无人零售场景下的用户管理、商品运营、智能监控与安全支付等核心业务闭环问题。压缩包共49.98MB&#xff0c;含Spring Boot后端源码…

作者头像 李华
网站建设 2026/9/16 14:33:56

SpringBoot酒店管理系统实战:从数据库设计到答辩讲解

简介&#xff1a;这是一份基于SpringBoot构建的酒店管理系统完整项目&#xff0c;面向正在完成课程设计、期末大作业或毕业设计的计算机专业学生&#xff0c;也可作为Java Web实战练手的参考案例。项目曾获导师指导并认可&#xff0c;属于98分的高分作业&#xff0c;涵盖系统源…

作者头像 李华
网站建设 2026/9/16 14:33:47

STM32F407移植NES模拟器:Cortex-M4实战指南

简介&#xff1a;STM32F407移植infoNES是一份将任天堂NES模拟器完整搬到ARM Cortex-M4平台的嵌入式实战工程。资源面向嵌入式开发者、STM32F4初学者及复古游戏爱好者&#xff0c;重点解决在资源受限MCU上实现游戏画面渲染、音频输出、手柄输入与ROM加载的协同调度&#xff0c;并…

作者头像 李华