第26章 插补日志与数据记录:SD卡存储、FATFS文件系统、数据回放分析
做运动控制这么多年,我踩过最大的坑是什么?
不是算法写不对,也不是电机选型出问题。而是——出了问题,我拿不到现场数据。
设备在客户现场跑,偶尔抖一下、偶尔丢步、偶尔过冲。你人不在,光靠客户描述「好像动了一下又没动」,这怎么排查?
所以后来我养成了一个习惯:所有插补任务,必须带日志记录功能。SD卡一插,FATFS一挂,数据一存。出了问题,拔卡回来分析,一目了然。
今天我们就聊聊,怎么在RTOS里把这件事做好。
26.1 为什么需要插补日志?
说白了,日志就是你的「黑匣子」。
飞机上有飞行数据记录仪,运动控制系统也该有。我见过太多工程师,调试时靠printf看波形,量产时把printf删了。结果现场一出问题,两眼一抹黑。
插补日志的核心价值有三个:
- 故障复现:记录每个插补周期的实际位置、速度、加速度,和理论值对比,偏差一目了然
- 性能分析:看看插补周期是否稳定,有没有丢步,加减速曲线是否平滑
- 算法调优:不同参数下的插补效果,回放对比,哪个好哪个差,数据说话
我的经验:有一次客户反馈设备在高速运行时偶尔异响。我让现场插上SD卡跑了一天,回放数据发现,每运行约2000个插补周期,会有一个周期超时,导致速度突变。最终定位是某中断优先级配置问题。没有日志,这种偶发问题根本抓不到。
26.2 整体架构设计
先看看日志系统在整个RTOS任务中的位置:
这个架构的核心思路是:插补任务只管写,日志任务只管读。中间用环形缓冲区解耦,互不阻塞。
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倍,但关键信息还在。
好了,插补日志这块就聊到这儿。数据记录这件事,看似简单,但做扎实了,能帮你省下大量排查问题的时间。下次你的设备出问题,别急着改代码,先看看日志再说。