第1章:日志系统设计——串口打印原理、日志等级划分、格式化输出、时间戳添加、日志缓存机制

各位同学,大家好。我是你们的老朋友,一个在嵌入式飞控领域摸爬滚打了十几年的工程师。今天咱们开始第一课,聊聊日志系统。

你可能会问,日志有什么好讲的?不就是printf吗?嗯,我当年也是这么想的。直到有一次,我调试一架六旋翼无人机,飞着飞着突然空中断电,黑匣子里啥也没留下。那叫一个抓瞎。从那以后,我发誓要把日志系统做到极致。

说白了,日志就是飞控的“黑匣子”。它记录着系统运行的每一个关键瞬间。没有它,你就像在黑暗中摸索。今天,我就把压箱底的经验掏出来,跟你好好聊聊怎么设计一套靠谱的日志系统。

1.1 串口打印原理:数据是怎么“说”出来的?

先看最底层的东西。串口打印,本质上是把内存里的数据,通过UART外设,一位一位地发出去。

你想想看,CPU是并行处理的,一次能处理32位甚至64位数据。但串口呢?它一次只能发一个bit。所以,我们需要一个“翻译官”——UART模块。它把并行的数据转成串行的比特流,再加上起始位、停止位,甚至校验位,然后通过TX引脚送出去。

我习惯把串口打印的流程拆成三步:

  1. 应用层调用:比如你写了个 printf("Hello\n")
  2. 格式化与缓冲:printf内部会把字符串格式化,然后塞到一个环形缓冲区里。
  3. 中断或轮询发送:底层驱动要么通过中断,要么通过轮询,从缓冲区取一个字节,塞进UART的发送寄存器,硬件自动发出去。

核心要点:串口打印的速度,取决于波特率。115200波特率,每秒只能发约11.5KB的数据。如果你的日志量太大,CPU就会被“卡死”在发送上。我在项目中遇到过,有人每秒打印10KB日志,结果飞控PWM输出都抖动了。

1.2 日志等级划分:别让废话淹没了真相

日志不是越多越好。我见过最夸张的,一个循环里打印了20行调试信息。结果真正出问题时,关键的错误信息早就被刷屏刷没了。

所以,我们要给日志分等级。我个人习惯用这四级:

等级 宏定义 颜色(终端) 使用场景
DEBUG LOG_DEBUG 白色/灰色 开发阶段,打印变量值、函数入口出口
INFO LOG_INFO 绿色 系统状态变化,如“GPS锁定”、“起飞完成”
WARN LOG_WARN 黄色 异常但不致命,如“电池电压偏低”、“磁力计校准异常”
ERROR LOG_ERROR 红色 致命错误,如“IMU初始化失败”、“看门狗复位”

为什么要分等级?因为我们可以通过一个全局变量,动态控制日志的输出级别。比如,飞行时只输出WARN和ERROR,避免INFO刷屏。调试时再打开DEBUG。

我的小技巧:在代码里定义一个全局变量 uint8_t g_log_level = LOG_LEVEL_INFO;。然后在每个日志宏里判断一下,如果当前等级低于全局等级,就直接return。这样零开销。

1.3 格式化输出:printf的“坑”与“填坑”

printf好用,但它在嵌入式里是个“巨无霸”。它占用的代码空间,有时候比你的主逻辑还大。为什么会这样?因为printf支持浮点数、支持各种复杂的格式控制,这些代码都被链接进来了。

我曾经在一个STM32F103项目里,就因为用了printf,Flash直接爆了。后来我换成了自己写的轻量级格式化函数,只支持%d、%x、%s,体积缩小了80%。

这里给你一个参考实现:

// 轻量级格式化输出,只支持 %d, %x, %s, %c
void my_printf(const char *fmt, ...) {
    char buf[128];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);  // 用vsnprintf更安全
    va_end(args);
    // 然后调用串口发送函数,逐字节发送buf
    uart_send_string(buf);
}

注意:vsnprintf虽然比sprintf安全,但它仍然依赖标准库。如果你追求极致,可以自己写一个整数转字符串的函数。我在飞控里就是这么干的,一个循环搞定,不依赖任何库。

1.4 时间戳添加:让日志“说话”有先后

没有时间戳的日志,就像没有日期的日记。你只知道发生了错误,但不知道是起飞前还是降落时发生的。

时间戳怎么加?最简单的方法,就是每次打印时,把系统滴答计时器(SysTick)的值打出来。比如:

[1234567] [INFO]  GPS 3D Fix, satellites: 10

这个数字是系统上电以来的毫秒数。有了它,你就能精确还原事件发生的顺序。

我建议把时间戳放在最前面,用方括号括起来。这样在终端里,一眼就能看到时间线。另外,如果你有RTC,也可以打印绝对时间,比如 [12:34:56.789]。但要注意,RTC在飞控里不一定准,所以SysTick更可靠。

避坑指南:我曾经在日志里打印了完整的年月日时分秒,结果发现每次打印要花好几毫秒。后来我改成只打印相对毫秒数,速度提升了一个数量级。记住,飞控是实时系统,时间戳的精度和速度要平衡。

1.5 日志缓存机制:别让打印拖慢系统

这是最容易被忽视的一环。很多人直接往串口写数据,写完了才返回。如果串口速度慢,或者对方没来得及收,CPU就被阻塞了。

正确的做法是:先缓存,后发送

我常用的方案是环形缓冲区(Ring Buffer)。日志先写到缓冲区里,然后由中断或者DMA在后台慢慢发送。这样,打印函数几乎是零等待的。

一个简单的环形缓冲区实现:

#define LOG_BUF_SIZE 1024
static char log_buf[LOG_BUF_SIZE];
static volatile uint16_t head = 0, tail = 0;

void log_putchar(char c) {
    uint16_t next = (head + 1) % LOG_BUF_SIZE;
    if (next != tail) {  // 缓冲区未满
        log_buf[head] = c;
        head = next;
    }
    // 如果满了,直接丢弃,避免阻塞
}

char log_getchar(void) {
    if (head == tail) return 0;  // 缓冲区空
    char c = log_buf[tail];
    tail = (tail + 1) % LOG_BUF_SIZE;
    return c;
}

你看,写操作只用了几个指令,几乎不耗时。然后你在UART的中断服务函数里,调用 log_getchar() 取出字符,发送出去。

我的经验:缓冲区大小要合理。太小了容易丢日志,太大了浪费内存。对于飞控,1KB到4KB足够了。另外,记得在缓冲区满时,不要阻塞,直接丢弃旧数据。飞控的实时性比日志完整性更重要。

小结

好了,今天的内容就到这里。我们聊了串口打印的原理、日志等级划分、格式化输出的陷阱、时间戳的添加,还有缓存机制。这些都是我多年踩坑换来的经验。

下一章,我们会深入讲讲如何用DMA来加速串口打印,以及如何实现日志的远程传输。到时候,你的飞控日志就能像流水一样顺畅了。

记住,日志系统不是可有可无的装饰品,它是你调试飞控的“第三只眼”。好好设计它,它会回报你无数个不眠之夜。