第26章 插补日志与数据记录:SD卡存储、FATFS文件系统、数据回放分析

做运动控制这么多年,我踩过最大的坑是什么?

不是算法写不对,也不是电机选型出问题。而是——出了问题,我拿不到现场数据

设备在客户现场跑,偶尔抖一下、偶尔丢步、偶尔过冲。你人不在,光靠客户描述「好像动了一下又没动」,这怎么排查?

所以后来我养成了一个习惯:所有插补任务,必须带日志记录功能。SD卡一插,FATFS一挂,数据一存。出了问题,拔卡回来分析,一目了然。

今天我们就聊聊,怎么在RTOS里把这件事做好。

26.1 为什么需要插补日志?

说白了,日志就是你的「黑匣子」。

飞机上有飞行数据记录仪,运动控制系统也该有。我见过太多工程师,调试时靠printf看波形,量产时把printf删了。结果现场一出问题,两眼一抹黑。

插补日志的核心价值有三个:

  • 故障复现:记录每个插补周期的实际位置、速度、加速度,和理论值对比,偏差一目了然
  • 性能分析:看看插补周期是否稳定,有没有丢步,加减速曲线是否平滑
  • 算法调优:不同参数下的插补效果,回放对比,哪个好哪个差,数据说话

我的经验:有一次客户反馈设备在高速运行时偶尔异响。我让现场插上SD卡跑了一天,回放数据发现,每运行约2000个插补周期,会有一个周期超时,导致速度突变。最终定位是某中断优先级配置问题。没有日志,这种偶发问题根本抓不到。

26.2 整体架构设计

先看看日志系统在整个RTOS任务中的位置:

插补日志系统架构图 插补任务 周期:1ms 环形缓冲区 双缓冲 / 循环队列 日志任务 优先级:低 FATFS 文件系统 SD卡驱动 (SPI/SDIO) SD 卡 PC端回放分析工具 插补任务 → 环形缓冲区(无锁) 日志任务 → 批量写入SD卡 PC工具 → CSV/二进制解析

这个架构的核心思路是:插补任务只管写,日志任务只管读。中间用环形缓冲区解耦,互不阻塞。

26.3 环形缓冲区:无锁设计

插补任务优先级高,不能被日志写入阻塞。所以缓冲区必须是无锁的。

我常用的方案是双缓冲

/* 双缓冲结构 */
typedef struct {
    uint8_t active_buf;          // 当前写入的缓冲区索引
    uint32_t write_idx;          // 写入位置
    uint32_t read_idx;           // 读取位置
    LogEntry buf[2][BUF_SIZE];   // 两个缓冲区
} LogBuffer;

/* 插补任务写入(中断/高优先级任务中调用) */
int LogBuffer_Write(LogBuffer *lb, LogEntry *entry) {
    uint32_t idx = lb->write_idx;
    if (idx >= BUF_SIZE) return -1;  // 缓冲区满
    memcpy(&lb->buf[lb->active_buf][idx], entry, sizeof(LogEntry));
    lb->write_idx = idx + 1;
    return 0;
}

/* 日志任务读取(低优先级任务中调用) */
int LogBuffer_Swap(LogBuffer *lb) {
    if (lb->write_idx == 0) return -1;  // 无数据
    lb->active_buf ^= 1;                // 切换缓冲区
    lb->read_idx = lb->write_idx;       // 记录本次数据量
    lb->write_idx = 0;                  // 新缓冲区清零
    return lb->read_idx;
}

避坑指南:我曾经在双缓冲切换时忘记关中断,导致写了一半被切换,数据错乱。后来加了临界区保护,虽然只有几条指令,但安全第一。

26.4 日志数据格式设计

存什么?怎么存?这得想清楚。

我个人习惯用二进制格式,体积小、写入快。CSV虽然方便看,但SD卡写入慢,插补周期又短,容易丢数据。

字段 类型 字节数 说明
timestamp uint32_t 4 系统滴答计数,单位1ms
axis_id uint8_t 1 轴号(0~7)
cmd_pos int32_t 4 理论位置(脉冲数)
actual_pos int32_t 4 实际位置(编码器反馈)
velocity int32_t 4 当前速度(脉冲/秒)
status uint16_t 2 状态标志(限位、报警等)
合计 19 每条记录19字节

1ms记录一条,1秒钟才19KB。一张2GB的SD卡,能连续记录30个小时。够用了。

26.5 FATFS文件系统集成

FATFS是个轻量级文件系统,很适合嵌入式。但要注意几点:

  • 挂载时机:系统初始化时挂载,但不要急着创建文件。等日志任务启动后再创建,避免文件系统占用太多启动时间
  • 文件命名:我习惯用日期+序号,比如 LOG_20250115_001.BIN,方便查找
  • 写入策略:不要每条记录都写一次文件。攒够一页(512字节)再写,效率高很多
/* 日志任务主循环 */
void LogTask(void *param) {
    FATFS fs;
    FIL file;
    uint8_t page_buf[512];
    uint32_t page_idx = 0;
    
    f_mount(&fs, "0:", 1);
    f_open(&file, "LOG_001.BIN", FA_CREATE_ALWAYS | FA_WRITE);
    
    while(1) {
        int count = LogBuffer_Swap(&log_buf);
        if (count > 0) {
            for (int i = 0; i < count; i++) {
                /* 将日志条目序列化到page_buf */
                SerializeEntry(&log_buf.buf[log_buf.active_buf ^ 1][i], 
                              &page_buf[page_idx]);
                page_idx += ENTRY_SIZE;
                
                /* 攒够一页就写入 */
                if (page_idx >= 512) {
                    UINT bw;
                    f_write(&file, page_buf, page_idx, &bw);
                    page_idx = 0;
                }
            }
        }
        vTaskDelay(pdMS_TO_TICKS(10));  /* 10ms轮询一次 */
    }
}

注意:SD卡写入时不要断电!FATFS在写入过程中掉电,文件系统可能损坏。如果设备可能突然断电,建议加个超级电容,或者用专用的掉电保护方案。

26.6 数据回放分析

数据存下来了,怎么分析?

我一般用Python写个小工具,把二进制文件解析成CSV,然后用matplotlib画图。

# 回放分析脚本(Python)
import struct
import matplotlib.pyplot as plt

def parse_log(filename):
    entries = []
    with open(filename, 'rb') as f:
        while True:
            data = f.read(19)  # 每条记录19字节
            if len(data) < 19:
                break
            ts, axis, cmd, actual, vel, status = struct.unpack('<IBiiIH', data)
            entries.append((ts, cmd, actual, vel, status))
    return entries

# 绘制位置跟随误差
entries = parse_log('LOG_001.BIN')
times = [e[0] for e in entries]
errors = [e[1] - e[2] for e in entries]  # 理论位置 - 实际位置

plt.figure(figsize=(12, 4))
plt.plot(times, errors)
plt.xlabel('Time (ms)')
plt.ylabel('Position Error (pulses)')
plt.title('插补跟随误差分析')
plt.grid(True)
plt.show()

你看,这样一画图,哪里有问题一目了然。如果误差曲线平稳,说明插补算法没问题。如果出现尖峰,那就是有异常。

我的经验:有一次分析数据,发现每隔约100ms出现一个误差尖峰。排查了很久,最后发现是某个低优先级任务偶尔占用了CPU,导致插补任务被延迟。后来把那个任务的优先级调低,问题解决。没有日志回放,这种微秒级的延迟根本发现不了。

26.7 性能优化与注意事项

最后总结几个关键点:

  • 缓冲区大小:根据SD卡写入速度来定。一般512字节一页,双缓冲各512页,总共1MB内存,够用
  • 写入频率:不要每条都写。攒够一页再写,写入次数减少512倍
  • 文件大小控制:单个文件不要太大,我一般限制在100MB以内。超过就新建一个文件
  • SD卡选择:工业级SD卡,Class 10以上。别用杂牌卡,写入速度不稳定,容易丢数据

小技巧:如果SD卡写入速度跟不上,可以降低日志采样率。比如每10ms记录一条,而不是每1ms。数据量减少10倍,但关键信息还在。

好了,插补日志这块就聊到这儿。数据记录这件事,看似简单,但做扎实了,能帮你省下大量排查问题的时间。下次你的设备出问题,别急着改代码,先看看日志再说。