ARTICLE DETAIL

资讯详情

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

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

STM32音频采样与存储:I2S+DMA双缓冲+FatFS实现高保真录音 简介该工程以STM32微控制器为核心围绕音频信号采集、处理与存储展开设计覆盖ADC模拟量采样、定时器触发、缓存管理和文件写入等实现细节非常适合课程设计、毕业设计或嵌入式进阶练习。资源压缩包内共包含297个文件整体约9.98MBC语言源码与头文件数量最多构成工程主体另含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 ReceiveMCLK 使能Audio Frequency 设为 16kHzData 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 总线和 DMA2I2S 的 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 为 64msDMA 模式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 bufferDMA 每填满半段触发一次中断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_size和data_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_written和elapsed_ms用blocks_written * block_len * 1000 / elapsed_ms算实际写盘吞吐。当 SD 卡接近满时写入速率可能下降到原来的 1/3。一旦低于音频比特率就应该切换到新分区或停止采集而不是继续写导致静默丢数据。只有这个数字稳定高于 32KB/s16bit 单声道 16kHz整个基于 STM32 音频采样与存储项目才算真的闭环。本文还有配套的精品资源点击获取
返回列表