日志系统:串口打印的陷阱、环形缓冲区设计、日志分级与格式化输出

调试嵌入式系统,说白了就是跟黑盒子打交道。你写了个控制算法,电机转得对不对?PID参数调得合不合适?不知道。这时候,日志系统就是你唯一的眼睛。

我见过太多工程师,上来就printf一把梭。结果呢?程序跑飞了,串口卡死了,关键信息被淹没了。嗯,今天我们就聊聊日志系统里那些容易踩的坑,以及怎么设计一个靠谱的日志模块。

串口打印的陷阱

先说说串口打印。很多人觉得,printf不就是往串口丢数据吗?有什么难的?

其实不然。我曾在项目中遇到过一个问题:电机控制周期是1ms,我在中断里加了个printf打印电流值。结果电机一启动,系统直接死机。为什么?因为printf是阻塞的!串口发送一个字节,如果缓冲区满了,它会一直等。1ms的控制周期,光打印就花了5ms,控制循环直接崩了。

警告:不要在中断服务函数(ISR)中直接调用printf或串口发送函数。这会导致中断响应延迟,甚至系统崩溃。

还有一个坑:printf的格式化开销。你想想看,printf要解析%d、%f这些格式符,还要做浮点数转换。在Cortex-M0这种低端MCU上,一个printf可能消耗几百微秒。对于实时控制来说,这简直是灾难。

我个人习惯的做法是:

  • 中断里只做标志位设置,不直接打印
  • 主循环里统一处理日志输出
  • 用DMA+串口,实现非阻塞发送

环形缓冲区设计

既然不能直接打印,那日志数据放哪儿?答案就是环形缓冲区(Ring Buffer)。

环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。写指针追着读指针跑,读指针追着写指针跑。就像两个人绕圈跑,谁也别超过谁。

我建议用这种结构体来定义:

typedef struct {
    uint8_t *buffer;    // 缓冲区指针
    uint32_t size;      // 缓冲区大小
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
} ring_buffer_t;

注意这里的volatile关键字。为什么?因为读写指针可能在中断和主循环中被同时访问。不加volatile,编译器优化后可能读到缓存值,导致数据错乱。我曾经因为这个bug排查了整整两天,最后发现是编译器把tail变量优化到寄存器里了。

提示:环形缓冲区的大小最好是2的幂次方,比如64、128、256。这样可以用位运算代替取模运算,速度更快。

写操作的核心代码:

bool ring_buffer_write(ring_buffer_t *rb, uint8_t data) {
    uint32_t next_head = (rb->head + 1) & (rb->size - 1);
    if (next_head == rb->tail) {
        return false;  // 缓冲区满了
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return true;
}

读操作类似。这里有个关键点:判断满和空的条件。满的条件是(head + 1) % size == tail,空的条件是head == tail。为什么要留一个空位?因为如果不留,满和空的条件就一样了,无法区分。

日志分级与格式化输出

日志不能一股脑全打印。你想想看,调试阶段你可能想看所有细节,但产品上线后,你只关心错误和警告。所以,日志分级是必须的。

我常用的分级方式:

级别 宏定义 说明
错误 LOG_ERROR 系统无法正常运行,必须处理
警告 LOG_WARN 可能出现问题,但不影响当前运行
信息 LOG_INFO 关键状态变化,如初始化完成
调试 LOG_DEBUG 详细调试信息,发布时关闭

实现方式很简单,用宏控制:

#define LOG_LEVEL LOG_DEBUG

#if LOG_LEVEL >= LOG_ERROR
    #define LOG_E(fmt, ...) printf("[ERR] " fmt "\r\n", ##__VA_ARGS__)
#else
    #define LOG_E(fmt, ...)
#endif

#if LOG_LEVEL >= LOG_DEBUG
    #define LOG_D(fmt, ...) printf("[DBG] " fmt "\r\n", ##__VA_ARGS__)
#else
    #define LOG_D(fmt, ...)
#endif

格式化输出方面,我建议统一格式:[时间戳][级别][模块] 消息。比如:

[12345][ERR][MOTOR] 过流保护触发,电流=3.2A

这样一看就知道什么时候、哪个模块、出了什么问题。

重点:时间戳最好用系统滴答计时器(SysTick)的计数值,而不是RTC时间。因为RTC时间需要解析,开销大。SysTick计数值直接读寄存器就行,快得很。

最后说一个我踩过的坑:日志缓冲区溢出。环形缓冲区写满了怎么办?我见过两种处理方式:

  • 丢弃新数据:简单粗暴,但可能丢失关键错误信息
  • 覆盖旧数据:保证最新数据,但可能丢失历史信息

我个人习惯的做法是:错误级别的日志永不丢弃,其他级别的日志可以覆盖。实现时,给环形缓冲区加一个优先级标记,错误日志写入时,即使满了也强制覆盖。

嗯,日志系统看起来简单,但设计好了,能帮你省下大量调试时间。记住一句话:好的日志系统,是嵌入式工程师最好的朋友。