STM32 高频写入 microSD 时出现 FR_DISK_ERR
结论
FR_DISK_ERR 与 cluster size 和 2048 字节缓冲区之间不存在所谓的“共振”。当前代码有明确的缓冲区并发问题:生产者可能覆盖尚未写完的缓冲区,buffersReady++ 和 buffersReady-- 也可能因竞争而丢失更新。增加到三个缓冲区,只能延长系统承受 SD 卡写入停顿的时间,解决不了队列管理上的缺陷。
缓冲区被覆盖通常会导致记录损坏或丢失,却不一定直接触发 FR_DISK_ERR。这个错误说明 FatFs 底层的 disk_write()、disk_read() 或相关磁盘操作失败。排查时还要检查 SPI 时钟、供电、SD 驱动超时、扇区配置以及总线访问冲突。
处理时需要兼顾两方面:
- 使用带状态的环形缓冲区,防止中断修改正在写入 SD 的内存。
- 记录并分析
disk_write()的底层错误,不能只看最外层的f_write()。
当前代码中的缓冲区问题
volatile 不能解决竞争条件
buffersReady 会被中断和主循环同时修改:
buffersReady++;
buffersReady--;
即使声明为 volatile,自增和自减仍包含“读取、修改、写回”三个步骤。例如可能出现以下时序:
- 主循环读取
buffersReady == 1。 - 定时器中断执行
buffersReady++,结果变成 2。 - 主循环继续完成之前的减法,把结果写成 0。
这样就丢失了一个刚产生的就绪缓冲区。volatile 只要求编译器实际访问内存,既不保证操作的原子性,也不能建立缓冲区所有权。
生产者可能追上消费者
中断填满一个缓冲区后,会直接切换到下一个:
fillIdx = (fillIdx + 1) % 3;
currentArray = bufferPool[fillIdx];
buffersReady++;
这段代码没有检查下一个缓冲区是否还在等待写入,也没有判断它是否正在被 f_write() 读取。
SD 卡会定期执行内部擦除、垃圾回收和映射表更新。即使平均写入速度足够,某一次写操作仍可能停顿较长时间。如果三个缓冲区在停顿期间全部填满,fillIdx 就会绕回,开始修改 f_write() 正在读取的区域。
f_write() 是阻塞函数,但它执行时,10 kHz 定时器中断仍会继续运行,所以缓冲区确实可能被覆盖。
写入长度与有效数据长度不一致
TimToStr() 生成的内容是:
HH:MM:SS.mmmm\n
索引 0 到 13 是有效文本,索引 14 是 '\0'。每条记录的文本长度应为 14 字节,而不是 15 字节:
iso8601[13] = '\n';
iso8601[14] = '\0';
return 15; // 此处不正确
当前代码会把字符串结束符一并写入文件,导致 TXT 文件中夹杂大量 NUL 字节。
此外,2048 不能被 15 整除。切换缓冲区时,末尾会有一部分字节没有被本轮数据填充,主循环却仍按固定的 BUF_SIZE 写出整个缓冲区:
f_write(&fil, bufferPool[saveIdx], BUF_SIZE, &bw);
这些字节可能没有初始化,也可能残留着上次使用该缓冲区时的数据。应记录每个缓冲区的实际长度,或者选择能同时适配记录长度和扇区长度的缓冲区大小。
如果每行保持 14 字节,可以使用 3584 字节的缓冲区:
3584 = 14 × 256 = 512 × 7
它能容纳 256 条完整记录,也是 512 字节的整数倍,管理起来比较方便。这与 cluster resonance 无关。
建议的环形缓冲区设计
每个缓冲区都应有明确的状态:
FREE -> FILLING -> READY -> WRITING -> FREE
中断只能写入处于 FILLING 状态的缓冲区。主循环应先把 READY 缓冲区标记为 WRITING,然后再调用 f_write()。缓冲区处于 WRITING 状态时,中断不能使用这块内存。
下面是一个简化框架。该示例只向 SD 写入完整缓冲区,停止记录时还需要另行提交最后一个未填满的缓冲区。
#include <stdbool.h>
#include <string.h>
#define LINE_SIZE 14U
#define BUF_SIZE 3584U
#define BUF_COUNT 8U
typedef enum {
BUF_FREE = 0,
BUF_FILLING,
BUF_READY,
BUF_WRITING
} BufferState;
static uint8_t logBuffer[BUF_COUNT][BUF_SIZE]
__attribute__((aligned(32)));
static volatile BufferState bufferState[BUF_COUNT];
static uint8_t producerSlot = BUF_COUNT - 1U;
static uint16_t producerPos = 0U;
static bool producerActive = false;
static uint8_t consumerSlot = 0U;
static volatile uint32_t droppedRecords = 0U;
static bool producer_open_buffer(void)
{
uint8_t next = (uint8_t)((producerSlot + 1U) % BUF_COUNT);
if (bufferState[next] != BUF_FREE) {
return false;
}
producerSlot = next;
producerPos = 0U;
bufferState[producerSlot] = BUF_FILLING;
producerActive = true;
return true;
}
static void log_line_from_isr(const uint8_t line[LINE_SIZE])
{
if (!producerActive) {
if (!producer_open_buffer()) {
droppedRecords++;
return;
}
}
memcpy(&logBuffer[producerSlot][producerPos], line, LINE_SIZE);
producerPos += LINE_SIZE;
if (producerPos == BUF_SIZE) {
/*
* 确保缓冲区内容先写入内存,再发布 BUF_READY 状态。
*/
__DMB();
bufferState[producerSlot] = BUF_READY;
producerActive = false;
}
}
static bool claim_ready_buffer(uint8_t *slot)
{
bool available = false;
uint32_t primask = __get_PRIMASK();
__disable_irq();
if (bufferState[consumerSlot] == BUF_READY) {
*slot = consumerSlot;
bufferState[consumerSlot] = BUF_WRITING;
consumerSlot = (uint8_t)((consumerSlot + 1U) % BUF_COUNT);
available = true;
}
__set_PRIMASK(primask);
return available;
}
static void release_buffer(uint8_t slot)
{
uint32_t primask = __get_PRIMASK();
__disable_irq();
bufferState[slot] = BUF_FREE;
__set_PRIMASK(primask);
}
主循环可以按以下方式处理:
uint8_t slot;
UINT bytesWritten;
FRESULT fr;
if (claim_ready_buffer(&slot)) {
fr = f_write(&fil,
logBuffer[slot],
BUF_SIZE,
&bytesWritten);
if ((fr != FR_OK) || (bytesWritten != BUF_SIZE)) {
HAL_TIM_Base_Stop_IT(&htim2);
/*
* 在这里保存:
* fr
* bytesWritten
* disk_write() 的返回值
* HAL_SPI_GetError()
* 最近一次 SD 命令、响应和超时位置
*/
log_error(fr, bytesWritten);
} else {
release_buffer(slot);
}
}
这里只在转移缓冲区所有权时短暂关闭中断,不能在整个 f_write() 调用期间关闭。否则 SD 卡一次较长的停顿就可能让系统长时间失去响应。
TimToStr() 也应改为返回 14:
uint8_t TimToStr(char *iso8601, Datetime time)
{
iso8601[0] = time.hour / 10 + '0';
iso8601[1] = time.hour % 10 + '0';
iso8601[2] = ':';
iso8601[3] = time.minute / 10 + '0';
iso8601[4] = time.minute % 10 + '0';
iso8601[5] = ':';
iso8601[6] = time.second / 10 + '0';
iso8601[7] = time.second % 10 + '0';
iso8601[8] = '.';
iso8601[9] = time.millis / 100 + '0';
iso8601[10] = (time.millis % 100) / 10 + '0';
iso8601[11] = time.millis % 10 + '0';
iso8601[12] = time.micro100 + '0';
iso8601[13] = '\n';
return 14;
}
如果其他代码仍需要 C 字符串,可以继续使用 15 字节的临时数组,并在末尾保留 '\0'。复制到日志缓冲区时,只复制前 14 字节。
按最坏停顿时间计算缓冲区容量
以 10 kHz 生成 14 字节记录时,数据率约为:
14 × 10000 = 140000 byte/s
三个 2048 字节缓冲区总共只能容纳约:
3 × 2048 / 140000 ≈ 43.9 ms
实际能用于吸收停顿的容量还要扣除正在填充和正在写出的缓冲区。一旦 SD 卡的单次停顿超过这个范围,系统就会丢失数据。
可以用下面的公式估算所需容量:
所需缓存字节数 >= 数据率 × 可接受的最大写入停顿时间
最大停顿时间需要实测,不能只参考平均吞吐量。可以为每次 f_write() 记录时间戳,并统计最大耗时、队列最高水位和 droppedRecords。如果 RAM 足够,可以先使用几十 KB 的环形队列,再根据测量结果调整。
如果长期平均数据产生速度高于长期平均写入速度,任何有限容量的缓冲区最终都会填满。此时必须事先确定处理策略,例如丢弃最新记录、覆盖最旧记录、降低采样率,或停止记录并报告溢出。
从底层定位 FR_DISK_ERR
FatFs 返回 FR_DISK_ERR,通常说明底层磁盘函数返回了 RES_ERROR,或者文件对象此前已经遇到过底层 I/O 错误。只记录 FRESULT 无法确认具体原因,还应在 disk_write() 和 SD SPI 驱动中记录:
- 失败的扇区号和扇区数量;
- SD 命令编号及 R1/R2 响应;
- 数据响应 token;
- 等待卡退出 busy 状态时是否超时;
HAL_SPI_Transmit()、HAL_SPI_Receive()的返回值;HAL_SPI_GetError();- 卡是否意外进入
STA_NOINIT; - 写入失败前一次操作的耗时。
除此之外,还需要逐项检查下面的问题。
SPI 速率的单位
SPI 时钟通常以 MHz 表示,吞吐量则以 MB/s 表示。假设 SCK 为 18 MHz,理论原始速率上限为:
18 Mbit/s ÷ 8 = 2.25 MB/s
这个数值还没有扣除命令、响应、等待以及文件系统开销,不能把 18 MHz 当成 18 MB/s。
初始化阶段必须使用 SD 卡允许的低速时钟,完成初始化后才能提高 SCK。排查时可以先将运行时 SPI 时钟降低一档。如果错误明显减少,应先检查信号完整性、连线长度、CS 时序、电平转换器和 PCB 布局,继续增加缓冲区通常解决不了这些问题。
电源完整性
microSD 写入时的瞬时电流可能明显高于空闲状态。供电压降、去耦不足或稳压器动态响应不够,都可能造成随机命令超时或错误响应。应在卡座附近测量电源波形,并确认:
- 3.3 V 电源有足够余量;
- 卡座附近有合适的去耦电容;
- 地线回路较短;
- CS 在整个命令和数据传输期间保持正确状态;
- 同一 SPI 总线上的其他设备不会在 SD 操作期间切换总线。
驱动超时
SD 卡返回 busy,不代表 SPI 外设正忙,而是卡内部还在编程 Flash。驱动需要继续提供时钟,直到卡释放 DO。如果底层驱动设置的 busy timeout 太短,卡在执行内部整理时就可能触发错误。
DMA 不能直接解决这类问题。底层驱动先要正确实现命令、token、busy 检测和多扇区传输,之后 DMA 才能减少 CPU 搬运数据的开销。
扇区大小与 cluster size
FatFs 的 sector size 和 FAT 的 cluster size 是两个不同的概念。
microSD 的块设备接口通常使用 512 字节逻辑扇区。FF_MIN_SS、FF_MAX_SS 和 disk_ioctl(GET_SECTOR_SIZE) 必须与底层驱动报告的逻辑扇区大小一致。如果固定扇区大小被直接配置为 4096,而驱动仍按 512 字节寻址,地址和缓冲区计算就可能出现严重错误。
如果使用可变扇区大小,例如最小值为 512、最大值为 4096,GET_SECTOR_SIZE 必须返回实际扇区大小。如果只有把最大值改成 4096 后程序才能运行,通常说明存在以下问题之一:
disk_ioctl()没有正确实现;- FatFs 配置与 SD 驱动不匹配;
- 工作区或扇区缓冲区越界;
- DMA 对齐或缓存维护有问题;
- 其他内存破坏被更大的工作区暂时掩盖。
应先确认 GET_SECTOR_SIZE 的实际返回值,再核对 ffconf.h。不能因为某个缓冲区大小“碰巧能运行”,就用它来决定扇区配置。
cluster size 表示文件系统每次分配多少个扇区。它会影响文件扩展和 FAT 更新频率,但应用层缓冲区不需要与 cluster 大小一致。
减少文件系统引起的长时间停顿
可以从以下方面着手:
- 开始采集前先创建文件,并预分配预计需要的空间。如果当前 FatFs 配置支持
f_expand(),可以用它申请连续空间。 - 尽量批量写入,并让写入长度保持为 512 字节的整数倍。
- 所有 FatFs 调用都应放在主循环或单独的日志任务中,不要从定时器 ISR 调用。
- 检查
f_write()返回值时,也要检查bw。即使返回FR_OK,也不能忽略短写。 - 检查
f_mount()、f_open()、f_lseek()、f_puts()和f_sync()的每个返回值。 - 正常结束时调用
f_sync()和f_close()。发生错误后,先停止生产者并保存诊断信息,再决定是否重新挂载。 f_sync()也可能造成明显停顿,因此环形缓冲区要能容纳同步期间产生的数据。
例如:
res = f_mount(&fs, "", 1);
if (res != FR_OK) {
handle_fatfs_error(res);
}
res = f_open(&fil, "timer_test.bin",
FA_CREATE_ALWAYS | FA_WRITE);
if (res != FR_OK) {
handle_fatfs_error(res);
}
/* 仅在当前 FatFs 配置支持 f_expand() 时使用 */
res = f_expand(&fil, expectedFileSize, 1);
if (res != FR_OK) {
handle_fatfs_error(res);
}
更适合 10 kHz 记录的格式
在 10 kHz 中断中格式化 ASCII 时间戳会占用不少 CPU,还会把原本可以用几个字节表示的记录扩展到 14 字节。可以让 ISR 只保存固定长度的二进制记录:
typedef struct __attribute__((packed)) {
uint32_t sequence;
uint32_t timestamp_100us;
uint16_t sensor_id;
int16_t value;
} LogRecord;
ISR 只读取硬件时间戳或递增计数器,再把结构体复制到队列中。SD 写入、文件同步和后续文本转换都在非中断上下文中完成。通过 sequence 可以直接判断丢失了多少条记录,比分析文本时间戳更可靠。
NanoPB 适用于需要兼容 Protobuf 的场景。不过在连续高速采样中,还要考虑消息边界、编码开销以及数据损坏后的重新同步。如果日志只在自己的工具链中使用,固定长度的二进制记录通常更简单。
三缓冲和多缓冲环形队列都是常见做法。当前实现的问题是缺少缓冲区所有权、满队列检测和原子状态切换。修复队列后,还要从 disk_write() 开始追踪 FR_DISK_ERR 的具体失败位置,并检查 SPI 时钟、供电、超时和扇区配置,分别处理数据丢失与磁盘错误。
备注:内容仅供参考。
