STM32 + Freertos + FATFS 录音为何严重断续并伴有白噪声?
这段代码的 DMA 接收长度、PCM 数据格式和缓冲区交接都有问题,足以同时造成断续、噪声和播放时长异常。应先修正数据边界和格式,再测量 SD 卡写入延迟。
WAV 头声明的是单声道、24-bit PCM,当前流程却按每组 4 字节写入,__REV() 也无法完成所需的样本转换。在常见 STM32F4 HAL 的 24/32-bit 接收实现中,DMA 实际填充的范围还与回调使用的缓冲区边界不一致。具体长度规则要以工程中的 HAL_I2S_Receive_DMA() 源码为准。
1. 核对 DMA 的实际接收范围
当前定义为:
BUFFER_SIZE = 1024; // uint16_t 元素数
WRITE_SIZE_BYTES = 2048;
整个数组有 2048 个 uint16_t:
uint16_t aud_buf[2 * BUFFER_SIZE];
启动时传入的是:
HAL_I2S_Receive_DMA(&hi2s3, aud_buf, READ_SIZE); // READ_SIZE = 512
STM32F4 的 I2S 数据寄存器按 16-bit 访问。对于 24/32-bit 数据格式,常见 HAL 实现会将传入的 Size 乘以 2,作为实际 DMA 半字传输次数。需要在工程源码中核对 RxXferSize 的赋值,以及传给 HAL_DMA_Start_IT() 的长度。
如果工程采用这种实现,本次 DMA 循环总共只接收:
512 × 2 = 1024 个 uint16_t
| 回调 | 刚完成的区域 | 当前代码使用的起点 |
|---|---|---|
| Half complete | aud_buf[0..511] | aud_buf[0],但复制 1024 个半字 |
| Complete | aud_buf[512..1023] | aud_buf[1024],超出本次 DMA 的填充范围 |
由于数组分配得更大,这不一定触发数组越界,却会读到尚未接收完成、已经过期,或本次 DMA 从未更新的数据。因此,内存检查没有报错,也不能证明收到的是有效音频。
这里使用的是一个连续数组上的 Circular DMA,通过半传输和全传输回调分段处理。仅凭这些代码,无法判断是否启用了 DMA 硬件双缓冲模式。
2. 24-bit I2S 数据不能直接按 4 字节写入 24-bit WAV
需要区分以下四种计量单位:
- DMA 的一个半字:16 bit。
- 一个 I2S 声道时隙:此处为 32 bit。
- 一个完整左右声道帧:两个时隙,共 64 bit。
- WAV 中的一个单声道 24-bit PCM 样本:3 字节。
即使只连接一个麦克风,I2S 总线上通常仍有左右两个时隙。应根据 ICS43432 的声道选择引脚,提取它实际输出数据的时隙。另一个时隙不能作为第二个有效麦克风样本写入单声道文件。
当前 WAV 头声明:
BitsPerSample = 24;
NumChannels = 1;
BlockAlign = 3;
播放器会每 3 字节解释一个样本,当前处理流程却每组保留 4 字节,导致样本边界不断错位,通常会产生强烈噪声。
播放时长按以下公式计算:
播放时长 = dataSize / (SampleRate × NumChannels × BitsPerSample / 8)
假设其他条件都正确,仅将每个单声道样本的 4 字节容器写入声明为 24-bit 的文件,计算出的时长就会变为应有值的 4/3。如果左右时隙也一起写入,或缓冲区发生重复、漏块,时长还会进一步偏离。
SD_WriteWavBlock() 没有使用 samples 参数:
FRESULT res = f_write(file, data, bytes, &bw);
传入 READ_SIZE 不会触发采样转换。写入什么内容,取决于 data 指向的字节和 bytes。
为什么 __REV() 不适用
假设一个左对齐的 24-bit 样本为:
0x123456
DMA 按先高半字、后低半字的顺序接收到:
src[0] = 0x1234
src[1] = 0x56XX
其中 XX 不属于有效的 24-bit 数据,不能直接当作音频使用。
在 STM32 的小端内存中,这两个半字的字节顺序为:
34 12 XX 56
直接转换为 uint32_t * 后执行 __REV(),得到的内存字节是:
56 XX 12 34
packed 24-bit little-endian PCM 需要写入的则是:
56 34 12
因此,需要按半字重组数据,提取有效的 24 bit,再输出 3 字节。强制转换指针和整字节反转都无法完成这一步。
3. 用明确的帧数重新定义缓冲区
以下示例基于这些前提:
- I2S 使用左右两个 32-bit 时隙。
- DMA 外设和内存数据宽度均为 halfword,内存地址递增。
- HAL 在 24/32-bit 模式下将
Size乘以 2。 - 每个半缓冲保存 256 个完整左右声道帧。
#include <stdint.h>
#define FRAMES_PER_HALF 256u
#define SLOTS_PER_FRAME 2u
#define HALFWORDS_PER_SLOT 2u
#define HALF_HALFWORDS \
(FRAMES_PER_HALF * SLOTS_PER_FRAME * HALFWORDS_PER_SLOT)
#define DMA_HALFWORDS (2u * HALF_HALFWORDS)
/* 仅适用于 HAL 内部把 Size 乘以 2 的实现 */
#define HAL_RX_SIZE (DMA_HALFWORDS / 2u)
static uint16_t dmaRx[DMA_HALFWORDS];
HAL_StatusTypeDef Mic_StartDma(void)
{
return HAL_I2S_Receive_DMA(
&hi2s3,
dmaRx,
(uint16_t)HAL_RX_SIZE
);
}
此时的缓冲区范围为:
整个数组:2048 个 uint16_t
前半区: dmaRx[0..1023]
后半区: dmaRx[1024..2047]
两个回调分别交接前半区和后半区。启动前应准备好队列、缓冲池和接收任务,并检查 HAL_I2S_Receive_DMA() 的返回值。
在这些前提下,可以保留原数组的分配大小,将启动长度改为对应 1024 个 32-bit 时隙的值。PCM 格式和缓冲区所有权问题仍需分别处理。
提取有效声道并输出 24-bit PCM
下面的函数假设每个时隙在 DMA 内存中按高 16 bit、低 16 bit 的顺序排列。使用前应通过原始数据确认这一点。
#include <stddef.h>
#include <stdint.h>
/*
* src: 一个完整的 DMA 半缓冲
* dst: 至少 FRAMES_PER_HALF * 3 字节
* slot: 有效声道时隙,取 0 或 1
*/
static size_t PackMono24(const uint16_t *src,
uint8_t *dst,
unsigned slot)
{
size_t out = 0;
for (size_t frame = 0; frame < FRAMES_PER_HALF; ++frame) {
size_t i = frame * 4u + slot * 2u;
uint32_t word =
((uint32_t)src[i] << 16) |
(uint32_t)src[i + 1u];
/* 有效样本为 word[31:8],输出小端 24-bit PCM */
dst[out++] = (uint8_t)(word >> 8);
dst[out++] = (uint8_t)(word >> 16);
dst[out++] = (uint8_t)(word >> 24);
}
return out;
}
每个半缓冲生成:
256 × 3 = 768 字节
打包为 24-bit PCM 时,保留样本的补码位即可,无需额外做符号扩展。如果后续需要进行增益、滤波等有符号运算,再将有效的 24-bit 数值正确扩展到有符号整数。
4. 共享 BufPtr 加信号量无法保存音频块顺序
当前回调执行:
BufPtr = ...;
osSemaphoreRelease(RxAudioSemHandle);
这种交接方式有两个独立问题。
首先,通知没有携带缓冲区身份。任务阻塞在 f_write() 时,多个回调可能接连修改 BufPtr。任务恢复后,只能看到最后一次赋值。即使信号量保留了多次计数,也可能多次处理同一个地址。
如果信号量的最大计数为 1,尚未消费的多次通知还可能合并。需要检查信号量的创建参数和释放结果。
其次,localBuf 只能保护已经复制完成的数据。任务还在写 SD 卡时,可能来不及复制下一个半缓冲。DMA 会继续运行,并在绕回后覆盖旧数据。
下面这个声明中的 volatile 限定的是对所指数据的访问:
static volatile uint16_t *BufPtr;
即使改成 volatile 指针,也无法解决通知排队、块顺序或 DMA 覆盖问题。
建议按以下流程交接数据:
DMA 回调
↓ 发送半区编号和序号
采集任务
↓ 在 DMA 返回该半区之前,复制或转换到独立缓冲块
就绪队列
↓
文件写入任务
↓ f_write() 返回后归还缓冲块
空闲缓冲池
队列中仅保存 DMA 地址,无法阻止 DMA 覆盖对应的内容。必须明确复制何时完成,以及独立缓冲块何时可以复用。
使用原生 FreeRTOS API 时,ISR 应调用相应的 ...FromISR() 接口,并按需触发调度。如果继续使用 CMSIS-RTOS API,需要核对当前适配层是否允许在 ISR 中调用,并确认 DMA 中断优先级满足 FreeRTOS 的要求。
5. 根据最坏写入延迟配置缓冲
按前面的示例,每个半缓冲覆盖的时间为:
256 / 44100 ≈ 5.80 ms
一个半区接收完成后,DMA 正在接收另一个半区。采集任务必须在约 5.80 ms 内读完已完成的半区,并预留调度余量。
单声道 24-bit PCM 的输出数据率为:
44100 × 3 = 132300 字节/秒
平均写入速度超过这个值,只能说明长期吞吐量可能够用。一次较长的写入停顿仍可能耗尽缓冲。
建议记录以下数据:
- 每次
f_write()的耗时,尤其是最大值。 - 采集任务从收到通知到完成复制或转换的耗时。
- 缓冲池最大占用量、队列满次数和丢块次数。
- DMA 回调序号是否连续。
如果实测某次存储停顿达到 100 ms,仅这段时间新产生的 packed PCM 就有:
132300 × 0.1 = 13230 字节
缓冲容量还要计入现有积压和调度余量。这里的 100 ms 只是计算示例,不能视为 SD 卡最大延迟的保证。如果缓冲池保存的是包含两个时隙的原始 I2S 数据,就应按原始数据率计算容量。
可以尝试让写入任务合并多个 PCM 块后再调用 f_write(),也可以评估预分配文件是否有帮助,具体效果以测量结果为准。
6. 停止录音必须等待写入任务排空
当前停止流程有死锁风险:
HAL_I2S_DMAStop(&hi2s3);
osSemaphoreWait(RxAudioSemHandle, osWaitForever);
DMA 停止后,未必还有回调释放信号量。如果没有剩余计数,任务就会永久等待。
此外,MicTask 和停止任务都在等待同一个信号量,会争抢通知。停止时,systemState 又已经不再是 STATE_RECORDING,采集任务可能直接丢弃尚未处理的数据。
fsMutex 可以避免两个文件操作同时执行,但无法保证最后一个数据块在文件关闭前写入。
建议由一个文件写入任务独占 FIL,并使用独立的停止请求和完成通知:
- 停止 DMA,并确定最后一个有效采集边界。
- 交接所有已经完成的音频块。
- 文件写入任务排空就绪队列。
- 回填 WAV 长度并关闭文件。
- 通知控制任务停止完成。
如果要保留最后不足半缓冲的数据,需要根据停止后的 DMA 剩余传输计数计算有效长度,并截取到完整的 I2S 帧。初次调试时,可以先明确丢弃最后一个不完整半区,确保完整块的处理流程正确。
重新开始录音前,还要清理旧通知和旧队列项,避免将上一段录音的数据写入新文件。
7. WAV 头和文件操作也要检查
对于 44100 Hz、单声道、packed 24-bit PCM,头部参数应为:
AudioFormat = 1
NumChannels = 1
SampleRate = 44100
BitsPerSample = 24
BlockAlign = 3
ByteRate = 132300
当前头部字段的计算符合这种格式,实际写入的数据也必须改为每样本 3 字节。
代码假定头部固定为 44 字节,写入时却使用 sizeof(WAVHeader)。可以通过编译期检查确认这个假定:
#include <stddef.h>
_Static_assert(sizeof(WAVHeader) == 44,
"Unexpected WAVHeader size");
_Static_assert(offsetof(WAVHeader, Subchunk2Size) == 40,
"Unexpected WAVHeader layout");
如果需要更好的可移植性,可以显式生成小端字节头,避免依赖结构体布局。
文件操作还应补上以下检查和错误处理:
- 检查头部
f_write()的返回值和实际写入字节数。 - 检查
f_sync()、f_lseek()、长度回填和f_close()的结果。 - 出现短写或错误时,记录实际已写字节并进入受控停止流程。
- 确保最终数据长度与完整 PCM 样本边界一致。
filesize - 44 只适用于头部确实为 44 字节、没有额外 chunk,且正确处理 RIFF 填充字节的情况。如果 24-bit 单声道数据长度为奇数,需要在 data chunk 尾部补一个填充字节。该字节不计入 Subchunk2Size,但计入 RIFF 长度。
8. 按数据链逐段排查
先确认采样格式,再接入文件写入,更容易定位问题。
-
用逻辑分析仪确认总线时序,检查 WS、BCLK 和 SD。对于两个 32-bit 时隙、44100 Hz 的总线,BCLK 应为
64 × 44100 = 2.8224 MHz。同时核对 ICS43432 的协议时序和有效声道。MSB First只说明位的先后顺序,无法证明 STM32 的标准选择和 WS 对齐方式正确;标准 I2S 还涉及 WS 切换后一个时钟的延迟。 -
只捕获一小段原始数据到 RAM,暂时绕过 FATFS。检查相邻半字如何组成样本、哪个时隙有效,以及数据是否跨块连续。持续采样时应避免用断点观察,因为 CPU 停住期间 DMA 的行为可能破坏原来的时序关系。
-
在电脑端验证样本打包。将同一段 RAM 数据转换为单声道 24-bit PCM,检查波形和播放结果。如果此时仍有强噪声,就继续检查总线配置、声道和位布局。
-
接入缓冲池与 SD 写入,检查块序号是否连续、是否发生队列溢出,并比较最大写入耗时与可用缓冲时长。
-
核对录音时长。正常情况下,每秒单声道 24-bit 数据约增加 132300 字节。如果文件增长速度或播放时长不符,应先检查样本数量、重复块、丢块和实际 WS 频率。
还应确认 DMA 缓冲区位于 STM32F405 的 DMA 可访问 SRAM 中,不能放在 CCM RAM。保留栈溢出检查,并检查任务栈余量。
备注:内容仅供参考。
