S6: bsp_log 日志系统

Babel36acl 方法与工具 无~ 6 次阅读 预计阅读时间: 14 分钟 发布于 1 天前 最后更新于 12 分钟前 3117 字


设计动机

早期版本的日志代码直接将 bsp_logbsp_uart 耦合在一起:日志格式化完直接调用 UART 的 TX 函数。这看似简单,却带来了循环依赖的问题 —— 当 UART 驱动自身需要输出调试日志时,就会形成递归调用死锁。

重构后的 bsp_log 采用 Dispatch 架构,彻底解耦格式输出与底层传输。日志模块只负责格式化,实际的字符输出通过注册的回调函数委托给下层驱动。这样,bsp_uart 可以被替换为任何输出通道(USB CDC、SPI 屏、RTT……),而日志模块本身不需要任何修改。

架构总览

整个日志系统的工作流如下:

应用程序调用宏    →    bsp_log_vprintf() 格式化    →    dispatch 回调    →    bsp_uart 实际发送
     │                        │                          │
     └── BSP_LOG_ERR(...)     └── 160B 缓冲区 (task)      └── bsp_log_dispatch_fn_t
     └── BSP_LOG_INF(...)     └── 128B 缓冲区 (ISR)

核心抽象是一个函数指针类型:

typedef void (*bsp_log_dispatch_fn_t)(const char *data, uint16_t len);

日志模块在初始化时通过 bsp_log_register_dispatch() 注册这个回调,之后所有格式化完成的字符串都会交给这个回调去处理。对于 GENERIC_DEVICE_CONTROLLER 项目,这个回调就是 bsp_uart_port_transmit()

日志级别定义

bsp_log 定义了 5 个日志级别,每个级别对应一个固定的 4 字符前缀标签:

typedef enum {
    BSP_LOG_LEVEL_OFF   = 0,   // 无前缀,完全关闭
    BSP_LOG_LEVEL_ERROR = 1,   // [E]
    BSP_LOG_LEVEL_WARN  = 2,   // [W]
    BSP_LOG_LEVEL_INFO  = 3,   // [I]
    BSP_LOG_LEVEL_DEBUG = 4,   // [D]
} bsp_log_level_t;

输出时,每个日志条目会自动添加对应的前缀、换行符号 \r\n,形如:

[I] System initialized, uptime=12345ms
[E] I2C timeout on bus 1, addr=0x3C
[W] Flash write retry #2, sector=7
[D] DMA buffer drained, bytes=512

编译期级别过滤

日志级别过滤在 编译期 完成,通过宏 BSP_LOG_LEVEL 控制。默认值为 BSP_LOG_LEVEL_INFO(3)。

bsp_log_cfg.h 或编译器命令行中定义:

#define BSP_LOG_LEVEL   BSP_LOG_LEVEL_INFO

其核心机制是:当日志宏的级别高于(数值大于)BSP_LOG_LEVEL 时,宏展开为 ((void)0),编译器会完全优化掉这些调用,不产生任何代码与运行时开销。这是嵌入式系统最关键的优化策略之一。

Release 构建通常设为 BSP_LOG_LEVEL_WARNBSP_LOG_LEVEL_ERROR,只保留错误与警告;Debug 构建设为 BSP_LOG_LEVEL_DEBUG,输出所有信息。

便捷宏 API

为了方便调用,系统提供了 4 个便捷宏(不含 OFF):

#define BSP_LOG_ERR(...)  bsp_log_write(BSP_LOG_LEVEL_ERROR, __VA_ARGS__)
#define BSP_LOG_WARN(...) bsp_log_write(BSP_LOG_LEVEL_WARN,  __VA_ARGS__)
#define BSP_LOG_INF(...)  bsp_log_write(BSP_LOG_LEVEL_INFO,  __VA_ARGS__)
#define BSP_LOG_DBG(...)  bsp_log_write(BSP_LOG_LEVEL_DEBUG, __VA_ARGS__)

当对应级别被编译期关闭时,宏展开为 ((void)0)

// 如果 BSP_LOG_LEVEL < BSP_LOG_LEVEL_DEBUG
BSP_LOG_DBG("Sensor reading: %d", val);
// 编译后变成 → ((void)0)
// 不产生任何二进制代码

用法示例:

BSP_LOG_INF("GENERIC_DEVICE_CONTROLLER starting, f_cpu=%lu", HAL_RCC_GetSysClockFreq());
BSP_LOG_ERR("SPI transaction failed, cs=%d, err=%d", cs_pin, status);
BSP_LOG_DBG("Encoder pulses: L=%ld R=%ld", left_enc, right_enc);

核心格式化函数

所有宏最终汇聚到 bsp_log_vprintf()bsp_log_write()

void bsp_log_write(bsp_log_level_t level, const char *fmt, ...);

void bsp_log_vprintf(bsp_log_level_t level, const char *fmt, va_list args);

这两个函数的工作流程:

  1. 检查 level 是否 ≤ BSP_LOG_LEVEL(运行时二次守卫)
  2. 将级别标签([E] / [W] / [I] / [D])写入格式化缓冲区头部
  3. 使用 vsnprintf 格式化用户消息,追加 \r\n
  4. 调用注册的 dispatch 回调,将完整字符串发送出去

中断安全的 ISR 接口

在中断上下文中调用标准 BSP_LOG_INF() 是不安全的 —— 它可能被更高优先级的中断打断。为此,bsp_log 提供了专用的 ISR 接口:

void bsp_log_irq_printf(bsp_log_level_t level, const char *fmt, ...);

它的特殊之处:

  • 使用独立的 128 字节缓冲区(ISR 中不允许阻塞等待大缓冲区)
  • 格式化完成后,直接调用 bsp_uart_port_transmit()timeout=0(轮询模式,不等待)
  • 自动追加 \r\n
  • 不经过 dispatch 回调,减少中间跳转

使用场景示例:

void EXTI15_10_IRQHandler(void)
{
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET) {
        bsp_log_irq_printf(BSP_LOG_LEVEL_WARN, "GPIO13 edge detected");
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);
    }
}

注意:传入 ISR 接口的级别标签仅做标记用,不会在 ISR 中再次过滤(因为 ISR 要求极低的延迟,不做额外判断)。但如果编译期 BSP_LOG_LEVEL 低于写入级别,整个调用在编译期就会被消除。

缓冲区策略

两个独立缓冲区:

  • Task 上下文:160 字节格式缓冲区 —— 适用于普通线程/主循环中的日志输出
  • ISR 上下文:128 字节格式缓冲区 —— 用于中断处理函数,减少栈占用

日志系统采用 即时发送 策略:格式化完成后立即通过 dispatch 回调发送,不设环形缓冲区(ring buffer),不做延迟输出。这意味着:

  • 优点:实现极简,无缓存溢出风险,日志实时性高
  • 代价:如果 dispatch 回调阻塞(例如 UART 发送队列满),调用方也会阻塞
  • 适用场景:日志量可控的嵌入式系统,日志主要用于调试而非高频数据记录

Hook 系统:输出复制到 DGUS 屏

日志系统支持注册一个额外的 hook 回调,用于将日志输出复制到其他介质。在 GENERIC_DEVICE_CONTROLLER 中,这个机制被用来将日志转发到 DGUS 串口屏显示。

typedef void (*bsp_log_hook_fn_t)(const char *data, uint16_t len);

void bsp_log_register_hook(bsp_log_hook_fn_t hook);

当 hook 注册后,每次 dispatch 完成后,日志内容会再次调用 hook 回调。DGUS 屏的驱动解析这些日志字符串,将其显示在屏幕的调试窗口中。这在不占用额外 UART 端口的情况下,实现了设备本地日志可视化。

// 初始化
bsp_log_register_dispatch(bsp_uart_port_transmit);
bsp_log_register_hook(dgus_log_display);

// 之后每次 BSP_LOG_INF(...) 都会:
//   1. 格式化 → " [I] xxx\r\n"
//   2. → bsp_uart_port_transmit()   // 输出到串口调试终端
//   3. → dgus_log_display()         // 同步显示到 DGUS 屏幕

与旧版的对比

特性 旧版(耦合) 新版(Dispatch)
与 bsp_uart 关系 直接调用,循环依赖 回调解耦,零依赖
输出通道扩展 需修改日志模块代码 注册不同 dispatch 即可
ISR 支持 不安全 专用 bsp_log_irq_printf()
Hook 扩展 支持注册 hook(如 DGUS 屏)
缓冲区 单一缓冲区 task 160B + ISR 128B

有意的设计取舍

bsp_log 明确不提供以下功能:

  • 不包含时间戳 —— 避免依赖 RTC 或 systick,保持模块纯净;需要时间戳时由调用方在消息中自行添加
  • 不注入文件名/行号 —— 减少 ROM 占用(__FILE__ 字符串在嵌入式上很昂贵)
  • 不支持运行时修改日志级别 —— 编译期固定,省去运行时判断的开销;确实需要时可通过条件编译在不同构建中切换
  • 无环形缓冲区 —— 即时发送,不缓存;适用日志量可控的场景

头文件接口总览

// ========== bsp_log.h ==========

typedef enum {
    BSP_LOG_LEVEL_OFF   = 0,
    BSP_LOG_LEVEL_ERROR = 1,
    BSP_LOG_LEVEL_WARN  = 2,
    BSP_LOG_LEVEL_INFO  = 3,
    BSP_LOG_LEVEL_DEBUG = 4,
} bsp_log_level_t;

typedef void (*bsp_log_dispatch_fn_t)(const char *data, uint16_t len);
typedef void (*bsp_log_hook_fn_t)(const char *data, uint16_t len);

void bsp_log_init(void);
void bsp_log_register_dispatch(bsp_log_dispatch_fn_t fn);
void bsp_log_register_hook(bsp_log_hook_fn_t fn);

void bsp_log_write(bsp_log_level_t level, const char *fmt, ...);
void bsp_log_vprintf(bsp_log_level_t level, const char *fmt, va_list args);
void bsp_log_irq_printf(bsp_log_level_t level, const char *fmt, ...);

#define BSP_LOG_ERR(...)  bsp_log_write(BSP_LOG_LEVEL_ERROR, __VA_ARGS__)
#define BSP_LOG_WARN(...) bsp_log_write(BSP_LOG_LEVEL_WARN,  __VA_ARGS__)
#define BSP_LOG_INF(...)  bsp_log_write(BSP_LOG_LEVEL_INFO,  __VA_ARGS__)
#define BSP_LOG_DBG(...)  bsp_log_write(BSP_LOG_LEVEL_DEBUG, __VA_ARGS__)

总结

bsp_log 是一个轻量、解耦、中断安全的嵌入式日志模块。它的核心设计哲学是:格式化与传输分离编译期开销归零不引入不必要的依赖。通过 Dispatch + Hook 的双回调机制,它既能输出到串口调试终端,也能同步显示到 DGUS 设备屏幕,而日志模块本身对输出通道一无所知 —— 这正是嵌入式软件工程中"关注点分离"的典型实践。

对于 GENERIC_DEVICE_CONTROLLER 这样的资源受限系统(STM32F103, 72MHz, 20KB RAM),这种设计在灵活性与资源消耗之间取得了良好的平衡。