4. 日志输出方式:串口输出、文件输出、网络输出、环形缓冲区

日志怎么出来?这个问题看似简单,但选错了方式,排查问题时会让你抓狂。我见过不少团队,日志系统做得花里胡哨,结果关键时刻啥也捞不着。

今天咱们就把四种主流输出方式掰开揉碎讲清楚。每种方式都有它的脾气,你得知道什么时候该用谁。

4.1 串口输出——最原始的调试利器

串口输出,说白了就是通过 UART 把日志直接扔到 PC 的串口终端上。这是嵌入式协议栈开发最常用的方式,没有之一。

优点很明显:

  • 实时性高——日志打印和代码执行几乎同步
  • 实现简单——一个 printf 搞定
  • 不占存储——不需要 Flash 或 SD 卡

缺点也扎心:

  • 速度受限——115200 bps 是家常便饭,日志一多就丢
  • 无法回溯——屏幕一关,日志全没
  • 干扰时序——打印本身会占用 CPU 时间
注意:串口输出在低波特率下,如果日志量超过 1KB/s,丢数据的概率会急剧上升。我曾经在调试 NB-IoT 协议栈时,就因为串口打印太多,导致物理层定时器频繁超时——打印本身把时间片吃掉了。

代码示例,一个轻量级的串口日志输出实现:

// 简易串口日志输出
void log_serial(uint8_t level, const char *fmt, ...) {
    if (level < g_log_threshold) return;
    
    char buf[256];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);
    
    // 加时间戳
    uint32_t tick = get_system_tick();
    char out[320];
    snprintf(out, sizeof(out), "[%lu][%d] %s\r\n", tick, level, buf);
    
    // 直接往 UART 寄存器写
    for (char *p = out; *p; p++) {
        while (!(UART->SR & UART_SR_TXE));
        UART->DR = *p;
    }
}

我个人习惯在早期原型验证阶段用串口输出。等协议栈跑起来了,再切到其他方式。

4.2 文件输出——事后分析的底气

文件输出就是把日志写到存储介质里,比如 SD 卡、Flash、或者 PC 的硬盘。这种方式的好处是——你可以慢慢翻。

适用场景:

  • 长时间运行的设备(比如基站、网关)
  • 需要事后做深度分析的场景
  • 多模块并发日志,需要离线关联

但文件输出有个坑——写入速度。你想想看,协议栈在跑高频交互时,每秒可能产生几百条日志。如果文件系统写入跟不上,日志就会积压,甚至导致系统卡死。

我的经验:文件输出一定要用异步写入。日志先放内存缓冲区,后台任务再慢慢刷到磁盘。我曾经在一个 5G 基站项目里,就因为同步写入导致物理层中断响应延迟,直接掉了 3 个用户。

一个典型的异步文件日志架构:

// 异步文件日志写入
#define LOG_BUF_SIZE 4096
static char log_buffer[LOG_BUF_SIZE];
static uint32_t write_pos = 0;
static SemaphoreHandle_t log_sem;

void log_file_async(const char *msg) {
    xSemaphoreTake(log_sem, portMAX_DELAY);
    
    int len = strlen(msg);
    if (write_pos + len < LOG_BUF_SIZE) {
        memcpy(log_buffer + write_pos, msg, len);
        write_pos += len;
    }
    // 缓冲区满则触发刷盘
    if (write_pos >= LOG_BUF_SIZE - 128) {
        flush_log_to_file();
    }
    
    xSemaphoreGive(log_sem);
}

4.3 网络输出——远程调试的救星

设备在现场,你在办公室。怎么查日志?网络输出就是干这个的。

常见实现方式:

  • UDP 广播——简单粗暴,适合局域网
  • TCP 连接——可靠传输,适合跨网络
  • MQTT/WebSocket——适合云平台对接
方式 可靠性 实时性 资源开销 典型场景
UDP 广播 低 高 低 局域网调试
TCP 连接 高 中 中 远程运维
MQTT 高 低 高 云端日志
注意:网络输出最大的风险是——日志本身依赖网络协议栈。如果协议栈挂了,日志也发不出去。这就成了「先有鸡还是先有蛋」的问题。我建议网络输出只作为辅助手段,不要完全依赖它。

4.4 环形缓冲区——最后的救命稻草

环形缓冲区,英文叫 Ring Buffer 或 Circular Buffer。这东西在协议栈调试里,地位堪比氧气。

为什么?因为很多崩溃场景下,串口来不及打、文件来不及写、网络已经断了。唯一能救你的,就是内存里那个环。

核心原理:

  • 一块固定大小的内存区域
  • 写指针一直往前跑,到头了绕回开头
  • 新数据覆盖旧数据
  • 崩溃时,把整个环 dump 出来

一个标准的环形缓冲区实现:

typedef struct {
    uint8_t *buffer;
    uint32_t size;
    uint32_t head;  // 写位置
    uint32_t tail;  // 读位置
} ring_buffer_t;

void ring_buffer_write(ring_buffer_t *rb, uint8_t *data, uint32_t len) {
    for (uint32_t i = 0; i < len; i++) {
        rb->buffer[rb->head] = data[i];
        rb->head = (rb->head + 1) % rb->size;
        // 如果 head 追上 tail,说明覆盖了旧数据
        if (rb->head == rb->tail) {
            rb->tail = (rb->tail + 1) % rb->size;
        }
    }
}

关键点:环形缓冲区的大小要精心设计。太小了,关键日志被覆盖;太大了,浪费内存。我一般按「最大日志速率 × 崩溃前保留时间」来估算。比如每秒 100 条日志,每条 128 字节,想保留最后 10 秒,那就是 128KB。

4.5 四种方式怎么选?一张图说清楚

下面这张 SVG 图,是我自己总结的选型逻辑。你照着走,基本不会错。

日志输出方式选型决策图 需要哪种日志输出? 需要实时查看日志? 串口输出 文件输出 需要远程查看? 网络输出 环形缓冲区 建议:串口 + 环形缓冲区组合使用,兼顾实时调试和崩溃现场保留 网络输出作为补充,文件输出用于长时间运行场景

4.6 我的实战建议

说了这么多,最后给几条实在的:

  1. 开发阶段:串口输出为主,环形缓冲区为辅。串口看实时流程,环形缓冲区保底防崩溃。
  2. 测试阶段:文件输出 + 网络输出。测试用例跑完,直接拉日志文件分析。
  3. 量产阶段:环形缓冲区是标配。串口可以关掉,但环形缓冲区必须开着——万一现场出问题,这是唯一的线索。
一个小技巧:环形缓冲区里可以加一个「触发标记」。比如检测到某个错误码时,自动冻结缓冲区,不再覆盖旧数据。这样你看到的就不是「崩溃后的垃圾」,而是「崩溃前的完整现场」。