日志系统:串口打印的陷阱、环形缓冲区设计、日志分级与格式化输出
调试嵌入式系统,说白了就是跟黑盒子打交道。你写了个控制算法,电机转得对不对?PID参数调得合不合适?不知道。这时候,日志系统就是你唯一的眼睛。
我见过太多工程师,上来就printf一把梭。结果呢?程序跑飞了,串口卡死了,关键信息被淹没了。嗯,今天我们就聊聊日志系统里那些容易踩的坑,以及怎么设计一个靠谱的日志模块。
串口打印的陷阱
先说说串口打印。很多人觉得,printf不就是往串口丢数据吗?有什么难的?
其实不然。我曾在项目中遇到过一个问题:电机控制周期是1ms,我在中断里加了个printf打印电流值。结果电机一启动,系统直接死机。为什么?因为printf是阻塞的!串口发送一个字节,如果缓冲区满了,它会一直等。1ms的控制周期,光打印就花了5ms,控制循环直接崩了。
还有一个坑: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变量优化到寄存器里了。
写操作的核心代码:
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
这样一看就知道什么时候、哪个模块、出了什么问题。
最后说一个我踩过的坑:日志缓冲区溢出。环形缓冲区写满了怎么办?我见过两种处理方式:
- 丢弃新数据:简单粗暴,但可能丢失关键错误信息
- 覆盖旧数据:保证最新数据,但可能丢失历史信息
我个人习惯的做法是:错误级别的日志永不丢弃,其他级别的日志可以覆盖。实现时,给环形缓冲区加一个优先级标记,错误日志写入时,即使满了也强制覆盖。
嗯,日志系统看起来简单,但设计好了,能帮你省下大量调试时间。记住一句话:好的日志系统,是嵌入式工程师最好的朋友。