4. Zephyr日志系统
日志系统,说白了就是嵌入式系统的“黑匣子”。你想想看,代码跑飞了、外设没响应、内存泄漏了——没有日志,你只能靠猜。我做了这么多年嵌入式,最怕的就是那种“看起来一切正常,但就是不对”的bug。这时候,日志就是你的第一道防线。
Zephyr的日志模块设计得相当灵活。它不是简单地把printf包装一下,而是有一套完整的架构。咱们今天就把这个架构拆开看看,再聊聊怎么配置、怎么定制后端。
4.1 日志模块架构:三层分离设计
Zephyr的日志系统,核心思路是“采集”和“输出”分离。我刚开始看源码的时候,也觉得绕。但搞明白之后,你会发现这设计确实巧妙。
核心架构:日志模块分为三层——前端API层、核心处理层、后端输出层。前端只管“我要打日志”,核心层负责过滤和格式化,后端负责“把日志送到哪里去”。
咱们直接看这张图,一目了然:
我个人习惯把这种架构叫做“漏斗模型”。上层只管往里倒,下层决定怎么流出去。好处很明显——你换后端,上层代码一行都不用改。
4.2 日志级别配置:从编译期到运行期
日志级别这事儿,看着简单,但坑不少。Zephyr支持两级过滤:编译期过滤和运行期过滤。
4.2.1 编译期级别
编译期级别是在 prj.conf 里配的。这个级别决定了“哪些日志能被编译进固件”。低于这个级别的日志,连代码都不会生成。
# 设置全局日志级别
CONFIG_LOG_DEFAULT_LEVEL=3
# 单独设置某个模块的级别
CONFIG_LOG_MODULE_LEVEL_MY_MODULE=4
级别数字的含义:
| 级别 | 宏 | 说明 |
|---|---|---|
| 0 | LOG_LEVEL_NONE | 关闭所有日志 |
| 1 | LOG_LEVEL_ERR | 仅错误 |
| 2 | LOG_LEVEL_WRN | 错误 + 警告 |
| 3 | LOG_LEVEL_INF | 错误 + 警告 + 信息 |
| 4 | LOG_LEVEL_DBG | 全部(含调试) |
我的经验:开发阶段我习惯把全局级别设成4,所有调试信息都打开。发布前再降到2或3。但要注意——如果某个模块的日志特别多,单独给它降级,别影响其他模块。
4.2.2 运行期动态控制
运行期控制才是真正好用的地方。你可以在系统运行时,通过shell命令动态调整日志级别,不用重新编译。
# 查看当前所有模块的日志级别
uart:~$ log status
# 动态设置某个模块的级别
uart:~$ log enable 1 my_module
uart:~$ log disable 1 my_module
这里有个关键点:运行期控制只能“降低”级别,不能“升高”超过编译期设定的上限。比如编译期设了3,运行期最多调到3,调不到4。
我曾经踩过的坑:有一次在产线上排查问题,想打开某个模块的调试日志,结果发现怎么调都没输出。查了半天,原来是编译期级别设成了2,而我想看的是DBG级别的信息。嗯,从那以后我养成了习惯——在调试固件里统一把编译期级别设成4,运行期再按需关闭。
4.3 日志后端定制:写一个自己的输出通道
Zephyr内置了好几种后端:UART、RTT、文件系统、网络等。但有时候,这些都不够用。比如你想把日志通过自定义的SPI接口发出去,或者存到特定的外部存储器里。这时候就需要定制后端了。
4.3.1 后端接口结构
定制后端,本质上就是实现 log_backend 结构体里的几个回调函数。核心接口就三个:
#include <logging/log_backend.h>
const struct log_backend_api my_backend_api = {
.put = my_backend_put,
.put_sync_string = my_backend_put_sync,
.put_sync_raw = my_backend_put_raw,
.panic = my_backend_panic,
};
LOG_BACKEND_DEFINE(my_backend, my_backend_api, true);
我来解释一下这几个回调:
- put:异步输出,日志先入队列,后端慢慢处理。适合高性能场景。
- put_sync_string:同步输出字符串,直接打印。适合简单场景。
- put_sync_raw:同步输出原始二进制数据。适合协议调试。
- panic:系统崩溃时调用,这时候要确保日志能强制输出。
4.3.2 一个简单的UART后端示例
咱们写一个最简单的UART后端,直接把日志通过串口打印出去。虽然Zephyr自带UART后端,但这个例子能让你看清整个流程。
#include <zephyr.h>
#include <logging/log_backend.h>
#include <uart.h>
static const struct device *uart_dev;
static void uart_backend_put(const struct log_backend *backend,
struct log_msg *msg)
{
char buf[256];
int len = log_msg_get_snprint(&msg, buf, sizeof(buf));
if (len > 0) {
uart_fifo_fill(uart_dev, (const uint8_t *)buf, len);
}
}
static void uart_backend_panic(const struct log_backend *backend)
{
/* 紧急模式:轮询发送,不依赖中断 */
uart_irq_rx_disable(uart_dev);
uart_irq_tx_disable(uart_dev);
}
const struct log_backend_api uart_backend_api = {
.put = uart_backend_put,
.panic = uart_backend_panic,
};
LOG_BACKEND_DEFINE(uart_backend, uart_backend_api, true);
关键点:LOG_BACKEND_DEFINE 宏的第三个参数是 auto_start。设为 true 表示系统启动时自动注册这个后端。设为 false 则需要手动调用 log_backend_enable() 来激活。
4.3.3 后端注册与激活流程
后端写好了,怎么让它跑起来?有两种方式:
- 自动注册:
LOG_BACKEND_DEFINE时auto_start = true,系统初始化时自动调用log_backend_enable()。 - 手动注册:在应用代码里调用
log_backend_enable(),可以控制激活时机。
/* 手动注册示例 */
void main(void)
{
/* 初始化硬件 */
uart_dev = device_get_binding("UART_0");
/* 激活自定义后端 */
log_backend_enable(&uart_backend, NULL, LOG_LEVEL_DBG);
/* 现在可以打日志了 */
LOG_INF("自定义后端已激活");
}
我建议:如果你的后端依赖某个外设初始化完成才能工作,那就用手动注册。否则用自动注册更省事。我在一个项目里同时用了三个后端——UART用于实时查看,RTT用于调试器交互,文件系统用于记录历史日志。每个后端的激活时机都不一样,手动注册给了你完全的控制权。
4.4 日志性能与资源考量
日志不是免费的。每一条日志都有开销。我见过有人把日志打得太猛,结果系统响应时间直接翻倍。
| 后端类型 | 延迟 | 内存占用 | 适用场景 |
|---|---|---|---|
| UART(同步) | 高(等待发送) | 低 | 调试阶段 |
| UART(异步) | 低(队列缓冲) | 中(队列内存) | 生产环境 |
| RTT | 极低 | 低 | 调试器连接时 |
| 文件系统 | 高(写Flash) | 高(缓冲区) | 离线分析 |
| 网络 | 中 | 中 | 远程监控 |
注意:在中断上下文里打日志要特别小心。默认情况下,Zephyr的日志系统在中断中会切换到同步模式,避免死锁。但如果你在后端的 put 函数里调用了可能引起阻塞的操作,那就等着系统挂掉吧。我曾经在一个定时器中断里打了条日志,结果日志后端去拿锁,直接死锁——嗯,血的教训。
4.5 小结
Zephyr的日志系统,说白了就是“前端喊话,后端干活”。你只需要记住三点:
- 架构上:三层分离,前端只管打,后端只管出,核心层做过滤和调度。
- 配置上:编译期定上限,运行期动态调。别把编译期级别设太低,否则运行期想调都调不出来。
- 定制上:实现
log_backend_api的三个回调,用LOG_BACKEND_DEFINE注册。注意同步/异步的选择,别在中断里搞阻塞操作。
日志这东西,平时觉得无所谓,出问题的时候才知道它的好。把日志系统玩透了,调试效率能翻一倍。