调试与监控:日志系统设计、性能指标采集、事件追踪、内存泄漏检测

说实话,嵌入式系统里最让人头疼的,不是写代码,而是出了问题之后怎么找到问题。我见过太多同事,代码写得飞快,结果一跑起来就懵了——不知道程序在干什么,不知道内存去哪了,不知道哪个事件丢了。嗯,这就是我们今天要聊的:怎么给你的系统装上「仪表盘」和「黑匣子」。

日志系统设计:别让printf毁了你

很多人一开始做嵌入式,习惯用printf来调试。我个人觉得,这玩意儿在原型阶段还行,一旦进入正式产品,它就是灾难。为什么?因为printf是阻塞的,而且没有分级,没有时间戳,没有模块标记。

我建议你从一开始就设计一个分层日志系统。说白了,就是给每条日志打上「等级标签」和「模块标签」。

/* 日志等级定义 */
typedef enum {
    LOG_LEVEL_ERROR = 0,   /* 致命错误,必须处理 */
    LOG_LEVEL_WARN  = 1,   /* 警告,不影响运行但需关注 */
    LOG_LEVEL_INFO  = 2,   /* 重要信息,如状态切换 */
    LOG_LEVEL_DEBUG = 3,   /* 调试信息,开发阶段使用 */
    LOG_LEVEL_TRACE = 4    /* 详细追踪,慎用 */
} log_level_t;

/* 日志模块定义 */
typedef enum {
    MOD_SYSTEM = 0,
    MOD_TASK_A,
    MOD_TASK_B,
    MOD_QUEUE,
    MOD_MEMORY,
    MOD_MAX
} log_module_t;

/* 日志输出接口 */
void log_write(log_level_t level, log_module_t mod, 
               const char *fmt, ...);
我的经验:日志缓冲区一定要用环形队列。我曾经在一个项目中用了线性缓冲区,结果日志一多就把前面的覆盖了,关键信息全丢了。环形队列至少能保证最新的日志还在。

日志输出目标也要灵活。我习惯的做法是:开发阶段输出到串口,正式版输出到Flash或SD卡。你想想看,产品交付后用户不可能给你接串口线吧?

性能指标采集:别靠猜

性能问题,最怕「我觉得这里慢」。我见过有人花了两周优化一个根本不慢的函数,真正慢的地方反而没人管。所以,你得用数据说话。

性能指标采集,核心就三样:CPU占用率任务执行时间队列深度

指标 采集方法 注意事项
CPU占用率 空闲任务计数 / 总时间 采样周期要固定,别用延时
任务执行时间 GPIO翻转 + 示波器 / 时间戳 注意中断对时间戳的影响
队列深度 记录队列中消息数量 峰值比平均值更重要
/* CPU占用率采集示例 */
static uint32_t idle_count = 0;
static uint32_t total_count = 0;

void idle_task(void *param) {
    while(1) {
        idle_count++;
        /* 空闲任务,啥也不干 */
    }
}

void monitor_task(void *param) {
    while(1) {
        total_count = idle_count;
        vTaskDelay(pdMS_TO_TICKS(1000));
        uint32_t delta = idle_count - total_count;
        float cpu_usage = 100.0f - (delta * 100.0f / total_count);
        log_write(LOG_LEVEL_INFO, MOD_SYSTEM, 
                  "CPU usage: %.1f%%", cpu_usage);
    }
}
注意:采集代码本身也会消耗CPU资源。我曾经在一个低功耗项目里开了全量性能采集,结果采集本身占了5%的CPU——这数据就没意义了。建议只在调试模式下开启,正式版关闭或降频采集。

事件追踪:还原现场

事件追踪,说白了就是给系统装一个「黑匣子」。系统崩溃了,你能回放崩溃前发生了什么。我做过一个项目,设备偶尔死机,查了两个月没找到原因。后来加了事件追踪,发现是某个中断里调用了malloc——嗯,中断里不能用malloc,这是铁律。

事件追踪的核心是:记录关键事件的时间戳和上下文。每个事件就是一个结构体,塞进环形缓冲区里。

/* 事件记录结构 */
typedef struct {
    uint32_t timestamp;   /* 系统滴答计数 */
    uint16_t event_id;    /* 事件ID */
    uint8_t  task_id;     /* 任务ID */
    uint8_t  param;       /* 附加参数 */
} trace_event_t;

#define TRACE_BUF_SIZE 256
static trace_event_t trace_buf[TRACE_BUF_SIZE];
static uint32_t trace_index = 0;

void trace_record(uint16_t event_id, uint8_t param) {
    uint32_t idx = trace_index % TRACE_BUF_SIZE;
    trace_buf[idx].timestamp = get_sys_tick();
    trace_buf[idx].event_id = event_id;
    trace_buf[idx].task_id = get_current_task_id();
    trace_buf[idx].param = param;
    trace_index++;
}

事件ID最好用枚举定义清楚。我习惯把事件分成几类:任务切换、消息收发、内存操作、错误发生。这样分析的时候一眼就能看出问题在哪。

关键点:事件追踪缓冲区要足够大,至少能记录最近几百个事件。太小的话,崩溃前的关键信息可能已经被覆盖了。我一般设512或1024,具体看内存大小。

内存泄漏检测:看不见的杀手

内存泄漏在嵌入式系统里特别隐蔽。你想想看,一个设备跑几天、几个月才出问题,你怎么复现?我遇到过最离谱的一次,设备运行了47天后突然死机,查到最后是一个定时器回调里每次申请了16字节没释放。

检测内存泄漏,我常用的方法有三种:

  1. 统计法:记录malloc和free的调用次数,差值就是当前分配数。如果这个值一直增长,肯定有泄漏。
  2. 栈回溯法:每次malloc时记录调用栈,泄漏后能定位到具体代码行。
  3. 内存池法:固定大小内存块,统计使用率。这个最可靠,但灵活性差一些。
/* 内存泄漏检测 - 统计法 */
static uint32_t malloc_count = 0;
static uint32_t free_count = 0;

void *debug_malloc(size_t size) {
    void *ptr = malloc(size);
    if(ptr) {
        malloc_count++;
        log_write(LOG_LEVEL_TRACE, MOD_MEMORY,
                  "malloc: %p, size=%u, total=%u", 
                  ptr, size, malloc_count - free_count);
    }
    return ptr;
}

void debug_free(void *ptr) {
    free(ptr);
    free_count++;
    log_write(LOG_LEVEL_TRACE, MOD_MEMORY,
              "free: %p, total=%u", 
              ptr, malloc_count - free_count);
}

/* 定期检查泄漏 */
void mem_leak_check(void) {
    uint32_t leaked = malloc_count - free_count;
    if(leaked > 0) {
        log_write(LOG_LEVEL_WARN, MOD_MEMORY,
                  "Possible memory leak: %u blocks not freed", 
                  leaked);
    }
}
避坑指南:我曾经在FreeRTOS项目里直接用malloc/free,结果发现任务栈溢出时malloc会失败,但返回值没检查,程序直接跑飞了。后来我改用heap_4.c的pvPortMalloc,并且每次分配后都检查返回值。记住:嵌入式里,malloc失败不是异常,是常态。

知识体系总览

下面这张图,是我对调试与监控体系的整体理解。你可以把它当作一个检查清单,看看自己的系统还缺什么。

调试与监控体系 📋 日志系统 • 分级输出:ERROR/WARN/INFO/DEBUG/TRACE • 模块标记:区分不同功能模块 • 环形缓冲区:不丢日志,不阻塞 • 多目标输出:串口/Flash/SD卡 📊 性能指标 • CPU占用率:空闲任务计数法 • 任务执行时间:GPIO翻转+示波器 • 队列深度:峰值比均值更重要 • 采集开销控制:调试版/发布版 🔍 事件追踪 • 时间戳+上下文:还原现场 • 环形缓冲区:至少512条记录 • 事件分类:任务/消息/内存/错误 • 崩溃后回放:定位根因 💾 内存泄漏检测 • 统计法:malloc/free计数差值 • 栈回溯法:定位泄漏代码行 • 内存池法:固定块使用率统计 • 定期检查:发现泄漏及时告警

这四个模块,说白了就是一套组合拳。日志系统告诉你「发生了什么」,性能指标告诉你「有多快」,事件追踪告诉你「怎么发生的」,内存泄漏检测告诉你「资源去哪了」。缺了任何一个,调试起来都会很痛苦。

我个人习惯是在项目启动阶段就把这些基础设施搭好。别等到出问题了再补,那时候压力大,容易出错。你想想看,系统都跑飞了,你再去加日志,那不是刻舟求剑吗?

最后说一句:调试工具再好,也不如写代码时多留个心眼。每次malloc都要想清楚谁来free,每个事件都要想清楚谁来处理。但人总会犯错,所以这些监控手段就是你的最后一道防线。

公众号:蓝海资料掘金营,微信deep3321