PAYATHON 2026

STM32 高频写入 microSD 时出现 FR_DISK_ERR

排障赵师傅

结论

FR_DISK_ERR 与 cluster size 和 2048 字节缓冲区之间不存在所谓的“共振”。当前代码有明确的缓冲区并发问题:生产者可能覆盖尚未写完的缓冲区,buffersReady++ 和 buffersReady-- 也可能因竞争而丢失更新。增加到三个缓冲区,只能延长系统承受 SD 卡写入停顿的时间,解决不了队列管理上的缺陷。

缓冲区被覆盖通常会导致记录损坏或丢失,却不一定直接触发 FR_DISK_ERR。这个错误说明 FatFs 底层的 disk_write()、disk_read() 或相关磁盘操作失败。排查时还要检查 SPI 时钟、供电、SD 驱动超时、扇区配置以及总线访问冲突。

处理时需要兼顾两方面:

  1. 使用带状态的环形缓冲区,防止中断修改正在写入 SD 的内存。
  2. 记录并分析 disk_write() 的底层错误,不能只看最外层的 f_write()。

当前代码中的缓冲区问题

volatile 不能解决竞争条件

buffersReady 会被中断和主循环同时修改:

buffersReady++;
buffersReady--;

即使声明为 volatile,自增和自减仍包含“读取、修改、写回”三个步骤。例如可能出现以下时序:

  1. 主循环读取 buffersReady == 1。
  2. 定时器中断执行 buffersReady++,结果变成 2。
  3. 主循环继续完成之前的减法,把结果写成 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 大小一致。

减少文件系统引起的长时间停顿

可以从以下方面着手:

  1. 开始采集前先创建文件,并预分配预计需要的空间。如果当前 FatFs 配置支持 f_expand(),可以用它申请连续空间。
  2. 尽量批量写入,并让写入长度保持为 512 字节的整数倍。
  3. 所有 FatFs 调用都应放在主循环或单独的日志任务中,不要从定时器 ISR 调用。
  4. 检查 f_write() 返回值时,也要检查 bw。即使返回 FR_OK,也不能忽略短写。
  5. 检查 f_mount()、f_open()、f_lseek()、f_puts() 和 f_sync() 的每个返回值。
  6. 正常结束时调用 f_sync() 和 f_close()。发生错误后,先停止生产者并保存诊断信息,再决定是否重新挂载。
  7. 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 时钟、供电、超时和扇区配置,分别处理数据丢失与磁盘错误。

备注:内容仅供参考。