找回密码
立即注册
搜索
发回帖 发新帖

6656

积分

0

好友

843

主题
发表于 昨天 03:31 | 查看: 22| 回复: 0

设备在现场报了一次过压保护,等工程师赶到,一切正常,日志里只有一行"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

版本升级时,最省事的做法是不兼容就弃用老记录——录波数据本身时效性强,老记录的价值随时间下降,为兼容老格式写的转换代码往往得不偿失。前提是解析端能明确识别出来,而不是解出一段看起来合理但实际错位的数据。




上一篇:嵌入式数据序列化:结构体直传的坑与TLV、Protobuf方案
下一篇:低轨卫星FPGA可靠性设计与Vivado DFX动态重构实践
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-29 14:51 , Processed in 0.081837 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表