欢迎光临
我们一直在努力

样章-第14讲-日志系统设计

第 14 讲 | 日志系统设计:RTT、串口日志与现场问题定位

【本讲要点】现场问题无法复现时,日志是你唯一的证据。这一讲设计一套"既不阻塞、又能掉电留痕"的日志系统。


一、日志是"第二双眼睛"

回到第 10 讲那个卡机案例。如果没有日志,我们的排查过程会是这样:

  • 现场说"按了按键就没反应";
  • 实验室复现不出来;
  • 只能靠猜,改一版发一版,来回耗上一个月。

有了日志,我们才拿到那条关键信息:hb missing: 0x6。

日志的价值,不在于"平时好看",而在于"出事时能说话"。 这决定了日志系统的设计目标:

  • 不能阻塞(第 6 讲:阻塞打印会让 WiFi 掉线);
  • 掉电前的话要留下(RAM 里的日志,掉电就没了);
  • 能被远程取回(现场设备不可能接调试器);
  • 按需开关(量产固件不能把调试信息全打开)。

  • 二、日志的四个层次

    按"成本"和"用途"分层,各司其职:

    层次载体掉电保留阻塞风险用途
    编译期开关 — — 无 量产版本砍掉调试日志
    RTT 调试接口 否 极低 开发调试,实时性最好
    串口 UART 否 中(需异步) 现场接串口调试
    Flash 环形日志 片内 Flash 是 低(异步) 现场问题取证
    云端 网络 是 低 批量统计与远程诊断

    关键设计:日志的输出目标是"可插拔"的。

    /* 日志后端注册机制 */
    typedef struct log_backend {
    void (*output)(const char *buf, uint16_t len);
    struct log_backend *next;
    } log_backend_t;

    void log_register_backend(log_backend_t *b)
    {
    b->next = s_backends;
    s_backends = b;
    }

    void log_output(const char *buf, uint16_t len)
    {
    for (log_backend_t *b = s_backends; b; b = b->next) {
    b->output(buf, len);
    }
    }

    RTT、串口、Flash、云端都只是"一个后端"。 开发时挂 RTT,现场挂 Flash + 云端,量产后只留云端。


    三、分级日志设计

    3.1 级别与编译开关

    #define LOG_NONE 0
    #define LOG_ERROR 1
    #define LOG_WARN 2
    #define LOG_INFO 3
    #define LOG_DEBUG 4
    #define LOG_VERBOSE 5

    /* 发布版本设为 LOG_WARN,调试版本 LOG_DEBUG */
    #ifndef LOG_LEVEL
    #define LOG_LEVEL LOG_INFO
    #endif

    #define log_error(...) LOG_EMIT(LOG_ERROR, "E", __VA_ARGS__)
    #define log_warn(...) LOG_EMIT(LOG_WARN, "W", __VA_ARGS__)
    #define log_info(...) LOG_EMIT(LOG_INFO, "I", __VA_ARGS__)
    #define log_debug(...) LOG_EMIT(LOG_DEBUG, "D", __VA_ARGS__)

    /* 编译期消除:低于等级的日志在编译后完全不存在,零开销 */
    #define LOG_EMIT(lvl, tag, ...) \\
    do { \\
    if ((lvl) <= LOG_LEVEL) { \\
    log_printf(tag, __VA_ARGS__); \\
    } \\
    } while (0)

    "编译期消除"比"运行时判断"重要得多: 前者让不需要的日志不占 Flash、不占 CPU;后者只是不输出,但代码还在。

    3.2 模块标签

    日志一多,分不清来源。加模块标签:

    /* 每个模块定义自己的标签 */
    #define LOG_TAG_WIFI "WIFI"
    #define LOG_TAG_MQTT "MQTT"
    #define LOG_TAG_MODBUS "MB"
    #define LOG_TAG_OTA "OTA"

    输出效果:

    [0012345][I][WIFI] connect ok, rssi=-52
    [0012468][I][MQTT] connack, session=0
    [0015000][W][MB] crc_err=12 frame=3

    有了时间戳 + 等级 + 模块,一眼就能看出问题在哪个模块、什么时候发生的。


    四、异步输出:绝不阻塞

    第 6 讲讲过原理,这里给完整的实现:

    /* ==================== log_async.c ==================== */
    #define LOG_RING_SIZE 512

    static char s_log_ring[LOG_RING_SIZE];
    static volatile uint16_t s_log_head = 0;
    static volatile uint16_t s_log_tail = 0;
    static volatile uint32_t s_log_drop = 0;

    /* 任何上下文都可以安全调用:只入队,绝不阻塞 */
    void log_printf(const char *tag, const char *fmt, ...)
    {
    char tmp[96];
    int n;

    /* 1. 先格式化到栈上小缓冲(注意栈开销) */
    n = snprintf(tmp, sizeof(tmp), "[%s] ", tag);
    if (n < 0) return;

    va_list ap;
    va_start(ap, fmt);
    n += vsnprintf(tmp + n, sizeof(tmp) – n, fmt, ap);
    va_end(ap);
    if (n < 0) return;

    /* 2. 追加换行 */
    if (n < (int)sizeof(tmp) – 1) {
    tmp[n++] = '\\n';
    }

    /* 3. 入队(满则丢弃并计数,绝不阻塞) */
    for (int i = 0; i < n; i++) {
    uint16_t next = (uint16_t)((s_log_head + 1) & (LOG_RING_SIZE – 1));
    if (next == s_log_tail) {
    s_log_drop++;
    return;
    }
    s_log_ring[s_log_head] = tmp[i];
    s_log_head = next;
    }
    }

    /* 低优先级任务:把环形缓冲的内容吐给所有后端 */
    void log_task(void *arg)
    {
    for (;;) {
    while (s_log_tail != s_log_head) {
    char c = s_log_ring[s_log_tail];
    s_log_tail = (uint16_t)((s_log_tail + 1) & (LOG_RING_SIZE – 1));
    log_output(&c, 1);
    }
    vTaskDelay(pdMS_TO_TICKS(20));
    }
    }

    三个注意点:

  • 格式化用的是栈上小缓冲(96 字节) —— 所以日志任务/调用方的栈要够。如果你在 512 字节栈的任务里调 log_printf,要考虑这 96 字节;
  • vsnprintf 本身开销不小,在极高频日志场景下,可以考虑"只记录固定字符串 + 数值"的轻量方案;
  • 缓冲满就丢弃并计数 —— 绝不能让日志把系统拖垮。

  • 五、Flash 环形日志:掉电留痕

    这是本讲最有价值的部分。

    设备在现场突然重启,RAM 里的日志全部消失。必须把关键日志写到 Flash 里。

    5.1 设计要点

    • 环形结构:定长槽位循环写,写满覆盖最旧的;
    • 固定长度条目:避免变长带来的管理复杂度;
    • 只在"关键事件"写:不能所有日志都落盘(Flash 寿命 + 速度);
    • 掉电时不留半条:用"序列号 + CRC"保证条目完整性。

    #define FLASH_LOG_SLOTS 64 /* 64 条 */
    #define FLASH_LOG_BASE 0x08016000 /* 从参数区划出 */
    #define FLASH_LOG_ENTRY_SZ 32 /* 每条 32 字节,必须对齐 */

    typedef struct {
    uint16_t seq; /* 序列号,用于判断新旧 */
    uint8_t level; /* 等级 */
    uint8_t tag_id; /* 模块 ID(用 ID 而不是字符串,省空间) */
    uint32_t tick; /* 系统 tick */
    uint32_t code; /* 事件码 / 关键数值 */
    uint8_t reserved[18];
    uint16_t crc; /* 本条 CRC */
    } __attribute__((packed)) flash_log_entry_t;

    只记录"事件码 + 数值",不记录字符串——32 字节存不下一句话,但能存下"事件 + 参数"。查问题时对照事件码表即可。

    5.2 写入

    void flash_log_write(uint8_t level, uint8_t tag_id, uint32_t code)
    {
    flash_log_entry_t e;
    static uint16_t s_seq = 0;

    e.seq = ++s_seq;
    e.level = level;
    e.tag_id = tag_id;
    e.tick = xTaskGetTickCount();
    e.code = code;
    memset(e.reserved, 0, sizeof(e.reserved));
    e.crc = crc16((uint8_t *)&e, sizeof(e) – 2);

    /* 环形定位到下一个槽位 */
    uint32_t slot = (s_seq % FLASH_LOG_SLOTS) * FLASH_LOG_ENTRY_SZ;
    flash_erase_page(FLASH_LOG_BASE + slot); /* 先擦 */
    flash_write(FLASH_LOG_BASE + slot, (uint8_t *)&e, sizeof(e));
    }

    注意:每写一条就擦一页,Flash 寿命消耗很快。 优化方案:

    • 用"页内顺序追加"(一页写满再擦),寿命提升几十倍;
    • 或者降低写入频率(只记关键事件)。

    5.3 读取(现场取证)

    /* 开机时或收到"上传日志"指令时,把 Flash 里的日志读出来 */
    void flash_log_dump(void)
    {
    flash_log_entry_t e;
    uint16_t max_seq = 0;
    int newest_slot = –1;

    /* 1. 先找出序列号最大的那条(环形的"头") */
    for (int i = 0; i < FLASH_LOG_SLOTS; i++) {
    if (flash_read(FLASH_LOG_BASE + i * FLASH_LOG_ENTRY_SZ,
    (uint8_t *)&e, sizeof(e)) != 0) {
    continue;
    }
    if (crc16((uint8_t *)&e, sizeof(e) – 2) != e.crc) {
    continue; /* 跳过损坏条目 */
    }
    if (e.seq >= max_seq) {
    max_seq = e.seq;
    newest_slot = i;
    }
    }
    if (newest_slot < 0) return;

    /* 2. 从最新的往回读,按时间顺序输出 */
    for (int k = 0; k < FLASH_LOG_SLOTS; k++) {
    int i = (newest_slot – k + FLASH_LOG_SLOTS) % FLASH_LOG_SLOTS;
    if (flash_read(FLASH_LOG_BASE + i * FLASH_LOG_ENTRY_SZ,
    (uint8_t *)&e, sizeof(e)) != 0) continue;
    if (crc16((uint8_t *)&e, sizeof(e) – 2) != e.crc) continue;
    log_printf("HIST", "seq=%u tick=%u tag=%u code=0x%x",
    e.seq, e.tick, e.tag_id, e.code);
    }
    }

    “先找序列号最大的那条,再往回读”——这是环形日志回溯的标准做法。


    六、现场取证流程

    有了上面三套机制,一个完整的现场取证流程是:

    1. 设备异常重启
    ↓
    2. 开机读取复位原因(第 10 讲)→ 记录到 Flash
    ↓
    3. 开机后 dump Flash 环形日志 → 得到"重启前最后 N 条"
    ↓
    4. 把 [复位原因 + 历史日志 + 当前状态] 打包上报云端
    ↓
    5. 工程师在云端看日志 → 定位问题

    第 4 步是关键:把日志"传上来",工程师就不用去现场。

    /* 上报诊断包 */
    void report_diag(void)
    {
    uint8_t buf[256];
    int n = 0;

    n += pack_u8(buf + n, boot_cause_get());
    n += pack_u32(buf + n, crash_pc_get());
    n += pack_u32(buf + n, xPortGetFreeHeapSize());
    n += pack_u16(buf + n, mb_stat_crc_err());
    n += pack_flash_logs(buf + n, 8); /* 带上最近 8 条历史日志 */

    iot_publish_diag(buf, n);
    }

    注意这 256 字节的诊断包,在 16KB RAM 里也是要精打细算的——所以用 pack_xxx 紧凑打包,而不是 JSON。 JSON 在 MCU 上又慢又占内存。


    七、日志系统清单

  • [ ] 输出走异步环形缓冲,任何路径都不阻塞
  • [ ] 分级 + 编译期开关,量产版本只留 WARN 以上
  • [ ] 带时间戳 + 等级 + 模块标签
  • [ ] 关键事件落 Flash 环形日志(掉电留痕)
  • [ ] 环形日志有序列号 + CRC,能识别损坏条目
  • [ ] 开机 dump 历史日志并上报云端
  • [ ] 诊断包用紧凑二进制,不用 JSON
  • [ ] 日志缓冲满会丢弃并计数(可观测)

  • 八、小结

    四句话:

    1. 日志的价值在"出事时能说话",所以第一要求是"不阻塞 + 掉电留痕 + 能远程取回"。
    2. 后端可插拔:开发用 RTT,现场用 Flash + 云端,量产只留云端。
    3. 编译期开关比运行时判断更重要——不输出的日志应该连代码都不存在。
    4. Flash 环形日志是现场取证的唯一手段;"序列号最大的那条往回读"是标准回溯法。

    下一讲(第 15 讲),我们把视角从"单个设备"抬到"一批设备":量产测试与老化。出厂前那 8 项测试,决定你的售后成本是 1% 还是 30%。

    赞(0)
    未经允许不得转载:171主机测评 » 样章-第14讲-日志系统设计
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址