设备在现场报了一次过压保护,等工程师赶到,一切正常,日志里只有一行"OVP 触发"。
录波与日志的分工
两者记录的是不同维度的信息,不能互相替代。
| 维度 |
日志 |
录波 |
| 记录内容 |
离散事件、状态迁移、错误 |
连续采样点、模拟量波形 |
| 时间粒度 |
秒级、事件驱动 |
毫秒级、周期驱动 |
| 数据量 |
小,可长期保存 |
大,只保留有限条数 |
| 触发方式 |
代码主动调用 |
条件触发 + 预触发缓冲 |
| 典型用途 |
追溯流程、统计频次 |
复现瞬间过程、分析时序 |
日志回答"发生了什么",录波回答"发生的时候各个量是什么关系"。排查过压、过流、振荡、通信超时这类问题时,光看日志只能得到结论,看不到过程。
该录哪些通道
通道不是越多越好,每多一个通道,同样 RAM 能存的时间就短一截。选通道的原则是围绕故障机理:直接相关量必录,间接相关量按 RAM 预算补。
以逆变器过压保护为例,直接相关量是直流母线电压、交流输出电压、输出电流;间接相关量是控制环的输出占空比、电网频率估计值、故障状态字。温度、风扇转速这些慢变量对这次故障没有解释力,就不占通道。
#define WAVE_CH_MAX 8 // 最大通道数
#define WAVE_SAMPLE_MAX 512 // 单条记录的采样点数
#define WAVE_SLOT_MAX 4 // Flash 中保存的记录槽位数
typedef enum {
WAVE_CH_ANALOG = 0, // 模拟量,按 int32 存(定点或原始码值均可)
WAVE_CH_DIGIT // 数字量/状态字,按位解释
} wave_ch_type_t;
typedef struct {
const char *name; // 通道名,导出时作为列名
wave_ch_type_t type;
int32_t (*read)(void); // 采样回调,返回当前值
} wave_ch_t;
// 通道注册表:用常量表定义,避免运行期注册带来的顺序不确定性
static const wave_ch_t g_wave_ch[] = {
{ "vbus", WAVE_CH_ANALOG, adc_get_vbus_q12 },
{ "vac", WAVE_CH_ANALOG, adc_get_vac_q12 },
{ "iout", WAVE_CH_ANALOG, adc_get_iout_q12 },
{ "duty", WAVE_CH_ANALOG, ctrl_get_duty_q12 },
{ "fault", WAVE_CH_DIGIT, fault_get_word },
{ "state", WAVE_CH_DIGIT, fsm_get_state },
};
#define WAVE_CH_COUNT (sizeof(g_wave_ch) / sizeof(g_wave_ch[0]))
采样率要和被观测现象匹配。工频相关的故障,一个周波 20ms,按 1ms 采样能得到 20 个点,勉强够看趋势;按 250us 采样能得到 80 个点,能看出波形细节。开关噪声、驱动信号这类要看微秒级现象,就得用更高的采样率,代价是记录时长急剧缩短。
存储量与采样率的关系可以粗略估算:
单条记录字节数 = 通道数 × 4 字节 × 采样点数
6 通道 × 4 字节 × 512 点 = 12KB
在 1ms 采样率下 = 512ms 的时长
在 250us 采样率下 = 128ms 的时长
设计阶段先定"要看多长"和"要看多细",再倒推通道数和采样率。
预触发:故障之前的那段才关键
只有故障之后的波形,价值要打折。过压发生前母线电压是怎么爬升的、电流有没有先畸变、控制环输出有没有提前饱和,这些信息全在触发点之前。
实现方式是 RAM 里维护一个常写的环形缓冲,触发时冻结,把触发点之前的 N 个点作为记录的前半段,之后继续采样 M 个点作为后半段。
typedef struct {
uint32_t magic; // 记录头魔数,用于判断槽位是否有效
uint32_t version; // 格式版本,解析端据此兼容
uint64_t time_ms; // 触发时刻的绝对时间(RTC + 系统毫秒)
uint32_t period_us; // 采样周期
uint16_t ch_count; // 通道数
uint16_t sample_count; // 采样点数
uint32_t trigger_index; // 触发点在整个记录中的位置
uint32_t trigger_reason; // 触发原因编码
uint32_t crc32; // 数据区校验
} wave_header_t;
typedef struct {
wave_header_t hdr;
int32_t data[WAVE_CH_MAX][WAVE_SAMPLE_MAX]; // 按通道分块存,便于按列解析
} wave_record_t;
static wave_record_t g_wave_ram; // 运行期环形记录区
static uint16_t g_wave_wr; // 当前写指针
static uint8_t g_wave_full;
采样函数由定时中断或高优先级任务调用,只做取数和写入,不碰 Flash:
void wave_sample_tick(void)
{
for (uint16_t c = 0; c < WAVE_CH_COUNT; c++) {
g_wave_ram.data[c][g_wave_wr] = g_wave_ch[c].read();
}
g_wave_wr++;
if (g_wave_wr >= WAVE_SAMPLE_MAX) {
g_wave_wr = 0;
g_wave_full = 1; // 缓冲已绕圈,触发时可取满整段
}
}
绕圈之后,写指针所在位置就是最老的数据。触发时从当前写指针开始按时间顺序读出,得到的就是"最近的 WAVE_SAMPLE_MAX 个点",天然包含了触发前的一段。
环形缓冲与触发冻结的过程:
触发条件
触发判断要放在采样之后,判断逻辑本身要轻量,因为它每个采样周期都跑。
typedef struct {
uint16_t ch; // 监控通道
int32_t threshold; // 阈值(与通道量纲一致)
uint8_t dir; // 0: 大于阈值触发 1: 小于阈值触发
uint8_t enable;
} wave_trigger_t;
static const wave_trigger_t g_triggers[] = {
{ 0, Q12(820), 0, 1 }, // vbus > 820V 触发
{ 2, Q12(60), 0, 1 }, // iout > 60A 触发
{ 4, 0x0001, 0, 1 }, // fault 字的 bit0 置位触发(按位与不为0)
};
uint32_t wave_check_trigger(void)
{
uint32_t reason = 0;
for (uint16_t i = 0; i < sizeof(g_triggers) / sizeof(g_triggers[0]); i++) {
const wave_trigger_t *t = &g_triggers[i];
if (!t->enable) continue;
int32_t v = g_wave_ram.data[t->ch][(g_wave_wr == 0) ? (WAVE_SAMPLE_MAX - 1)
: (g_wave_wr - 1)];
int hit = (t->dir == 0) ? (v > t->threshold) : (v < t->threshold);
if (hit) reason |= (1u << i);
}
return reason;
}
阈值触发要处理抖动:电压在阈值附近来回穿越,会连续触发多次,把 Flash 写满还都是同一件事。加两个约束即可——触发后进入一段静默期(比如 5 秒内不再触发),或者要求条件连续满足若干个采样点才算成立。
// 连续满足计数,避免瞬时抖动误触发
static uint8_t g_hit_count[sizeof(g_triggers) / sizeof(g_triggers[0])];
#define WAVE_HIT_CONFIRM 3 // 连续 3 个点满足条件才认定触发
#define WAVE_MUTE_MS 5000 // 触发后 5 秒内不再记录
落盘:Flash 不能写在采样路径上
触发之后的动作顺序很重要:先冻结 RAM 数据,再交给后台任务写 Flash。写 Flash 期间采样继续,但写入的内容来自快照副本,不受影响。
typedef struct {
uint8_t pending; // 有待落盘的记录
uint32_t reason;
uint16_t wr_at_trigger;
uint64_t time_at_trigger;
} wave_pending_t;
static wave_pending_t g_pending;
void wave_on_trigger(uint32_t reason)
{
if (g_pending.pending) return; // 上一次还没写完,不覆盖
if (rtc_get_ms() - g_last_trigger_ms < WAVE_MUTE_MS) return;
g_pending.reason = reason;
g_pending.wr_at_trigger = g_wave_wr;
g_pending.time_at_trigger = rtc_get_ms();
g_pending.pending = 1;
g_last_trigger_ms = g_pending.time_at_trigger;
// 后触发段:继续采样这么多点之后才算记录完整
g_post_count = WAVE_POST_SAMPLES;
}
后台任务负责排序、填头、写介质:
void wave_task(void *arg)
{
while (1) {
if (g_pending.pending && g_post_count == 0) {
wave_record_t *rec = &g_wave_ram;
uint16_t wr = g_pending.wr_at_trigger;
uint16_t n = g_wave_full ? WAVE_SAMPLE_MAX : wr;
// 数据已按环形排列,直接按时间顺序重排到临时缓冲
wave_record_t out;
for (uint16_t c = 0; c < WAVE_CH_COUNT; c++) {
for (uint16_t k = 0; k < n; k++) {
uint16_t src = (g_wave_full)
? ((wr + k) % WAVE_SAMPLE_MAX)
: k;
out.data[c][k] = rec->data[c][src];
}
}
out.hdr.magic = WAVE_MAGIC;
out.hdr.version = WAVE_FORMAT_VERSION;
out.hdr.time_ms = g_pending.time_at_trigger;
out.hdr.period_us = g_sample_period_us;
out.hdr.ch_count = WAVE_CH_COUNT;
out.hdr.sample_count = n;
out.hdr.trigger_index = g_wave_full ? (WAVE_SAMPLE_MAX - WAVE_POST_SAMPLES) : n;
out.hdr.trigger_reason = g_pending.reason;
out.hdr.crc32 = crc32_calc(&out.data[0][0],
sizeof(int32_t) * WAVE_CH_MAX * n);
wave_store_write(&out); // 写入 Flash 槽位
g_pending.pending = 0;
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
存储介质的选择
| 介质 |
写入速度 |
擦除粒度 |
掉电安全 |
适用 |
| 内部 Flash |
慢(毫秒级每页) |
扇区/页 |
写入中掉电会丢该页 |
槽位少、记录小 |
| FRAM |
快(字节写) |
无 |
好 |
高频记录,成本敏感场景少用 |
| 片外 SPI Flash |
中(页编程毫秒级) |
4KB 扇区起 |
同内部 Flash |
槽位多、记录大 |
| SD 卡 |
快 |
块 |
文件系统有掉电风险 |
长时间连续记录 |
内部 Flash 是最省事的选择,但要算清楚写入耗时:一次 512 点的 6 通道记录约 12KB,片内 Flash 按页编程每页几毫秒,写完一整条可能要上百毫秒。这段时间不能在采样中断里完成,所以前面的"后台任务"结构不是可选项,是必须项。
掉电保护的处理:写之前不擦旧数据,先写新数据到备用槽位,写完后更新槽位索引。这样即使写入过程中掉电,旧记录仍然完整。
#define WAVE_SLOT_COUNT 4
typedef struct {
uint32_t seq; // 递增序号,取最大值作为最新
uint32_t valid; // 槽位有效标志
} wave_slot_meta_t;
// 槽位轮转:优先覆盖最老的,且先写数据后更新索引
uint32_t wave_pick_slot(void)
{
uint32_t oldest = 0, min_seq = 0xFFFFFFFF;
for (uint32_t i = 0; i < WAVE_SLOT_COUNT; i++) {
wave_slot_meta_t m;
flash_read(WAVE_SLOT_META_ADDR(i), &m, sizeof(m));
if (m.valid != WAVE_SLOT_VALID) return i; // 有空槽先用
if (m.seq < min_seq) { min_seq = m.seq; oldest = i; }
}
return oldest;
}
时间基准
录波数据要和其它系统的数据对齐,必须有可靠的时间戳。设备里通常有两套时间:RTC 提供的绝对时间和系统启动后的毫秒计数。
// 返回系统启动后的毫秒数,配合 RTC 快照换算绝对时间
uint64_t rtc_get_ms(void)
{
return ((uint64_t)rtc_get_unix() * 1000u) + (uint64_t)sys_tick_ms();
}
记录头里存绝对时间,采样点的时间由 period_us 推算。这里有个细节:采样周期如果受负载影响发生抖动(比如采样任务被高优先级任务抢占),按固定周期推算的时间轴会与实际有偏差。要求严格时,每个采样点附带一个微秒级时间戳,代价是数据量翻倍。
采样相位本身也要记录:故障发生在工频的哪个相位,对判断过流原因很关键。多数情况下,把电网电压作为一个通道录进去,就能从波形里读出相位,不需要额外字段。
导出与解析
记录存在设备里没人看得到,导出方式要提前设计。最省事的是在前面那套 CLI 里加一条命令,把指定槽位按文本格式吐出来。整条链路的参与者:
static int cmd_wave(int argc, char *argv[])
{
if (argc < 2) {
printf("usage: wave list | wave dump <slot>\r\n");
return -1;
}
if (strcmp(argv[1], "list") == 0) {
for (uint32_t i = 0; i < WAVE_SLOT_COUNT; i++) {
wave_header_t h;
if (wave_slot_read_header(i, &h) == 0) {
printf("[%lu] t=%llu reason=0x%08lX n=%u\r\n",
(unsigned long)i, (unsigned long long)h.time_ms,
(unsigned long)h.trigger_reason, h.sample_count);
}
}
return 0;
}
if (strcmp(argv[1], "dump") == 0 && argc >= 3) {
int32_t slot;
if (cli_parse_range(argv[2], &slot, 0, WAVE_SLOT_COUNT - 1) != 0) return -1;
return wave_dump_csv((uint32_t)slot);
}
return -1;
}
CLI_EXPORT(wave, "wave list | wave dump <slot>", "fault record access", cmd_wave);
CSV 输出便于直接用表格软件画图:
int wave_dump_csv(uint32_t slot)
{
wave_header_t h;
if (wave_slot_read_header(slot, &h) != 0) return -1;
// 表头:t_ms 加各通道名
printf("t_ms");
for (uint16_t c = 0; c < h.ch_count; c++) printf(",%s", g_wave_ch[c].name);
printf("\r\n");
for (uint16_t k = 0; k < h.sample_count; k++) {
printf("%lu", (unsigned long)(k * h.period_us / 1000));
for (uint16_t c = 0; c < h.ch_count; c++) {
printf(",%ld", (long)wave_read_sample(slot, c, k));
}
printf("\r\n");
}
return 0;
}
槽位里的记录可能很大,全部吐到串口会占用很长时间。加一个范围参数(wave dump 0 100 200 表示只看第 100~200 点),或者直接在设备上做简单的统计(最大值、最小值、触发点前后若干点)再输出。
配套的解析脚本做几件事:把固定点数值还原成工程量、画出曲线、标出触发点位置。脚本放在代码仓库里和格式定义一起维护,格式版本变了就同步更新。
格式版本与兼容
录波的解析工具与固件是分开演进的。通道增加、顺序调整、定点格式变化,都会让老工具解出新数据的乱码。记录头里的 version 字段就是为这个准备的:解析端按版本选择解析方式,读不懂的版本直接报错而不是静默解错。
#define WAVE_FORMAT_VERSION 2 // v2: 通道顺序调整,duty 量纲改为 Q12
版本升级时,最省事的做法是不兼容就弃用老记录——录波数据本身时效性强,老记录的价值随时间下降,为兼容老格式写的转换代码往往得不偿失。前提是解析端能明确识别出来,而不是解出一段看起来合理但实际错位的数据。