大家晚上好。今天来讲讲调试程序的重要方法:打印日志。无论开发何种程序,单片机,手机APP,电脑客户端,还是服务器,日志都是最基础也是最重要的调试手段。
手机APP,电脑客户端和服务器的开发环境往往提供了功能丰富的日志接口。比如linux的syslog模块提供如下日志函数:
void openlog(const char *ident, int logopt, int facility);
void closelog(void);
void syslog(int priority, const char *message, arguments...);
其记录的每条日志包含时间,级别,来源和内容,可根据配置过滤低级别的日志。下图中的日志,红框是时间,黄框是来源,绿框是内容。 单片机的开发则大不相同,其开发环境中往往不会提供日志接口。单片机的驱动库往往只提供基础的操作外设的接口,如uart,i2c,spi,而不提供高层次的SDK。这就需要我们自己来设计。
单片机日志接口比较简单的实现,是将日志从串口输出,在电脑上可通过诸多串口软件来查看。向串口输出字符嘛,看着简单,其实大有文章。简陋的设计与精心周全的设计将使日后的代码开发和调试产生巨大差异。
简陋的设计
先看一个简陋至极的接口:
void drv_uart_send(uart_t *uart, void *buf, int len);
void log_print(char *buf , unsigned int len)
{
drv_uart_send(&uart1, buf, len);
}
log_print将buf的前len字节的内容输出到调试串口(uart1),其实就是对串口输出函数进行简单的封装,使用户在输出日志时不需要传入串口实例。
大家觉得这个日志接口有没有问题,好不好用呢?
该接口很简陋,这是毫无疑问的。不仅如此,它存在一个非常不合理的设计:既然是打印字符串,那就不需要len参数。因为字符串以’\0’结尾,log_print内部完全可以判断字符串的长度。如果要传字符串长度的话,在调用时会非常尴尬。比如,在网络初始化成功后,想打印"network initialize succeed",该怎么调用呢?
下面这种调用显然有问题,引入了重复代码。当需要修改日志内容时,需要修改两处,容易遗漏。
log_print("network initialize succeed\n", strlen("network initialize succeed\n");
要不这样?这简直就是掩耳盗铃。看似没有重复代码,实则并没有解决重复代码引发的问题。当内容长度变化时,还是得更新长度参数。
log_print("network initialize succeed\n", 26);
各位不要笑,上面这种调用方法绝非笔者杜撰出来,而是在笔者近期接手的一个项目中大量出现的。
那这样呗?这是没啥问题了,不过在打印日志前,还得定义一个变量,是不是相当麻烦?
const char *str = "network initialize succeed\n";
log_print(str, strlen(str));
上述三种调用方法,前两者均存在问题,第三种也很尴尬。问题的根源出在log_print接口的设计上,想要避免尴尬,还得从源头解决。其实很简单:
void drv_uart_send_str(uart_t *uart, const char *str)
{
drv_uart_send(uart, str, strlen(str));
}
void log_print(char *buf)
{
drv_uart_send_str(&uart1, buf);
drv_uart_send_str("\n");
}
做了两项修改:
- 在串口层面添加打印字符串的函数,从而使log_print不需要长度参数。
- log_print在输出日志内容之后自动换行,从而避免每条日志中都要加’\n’。
现在打印日志就舒服多了:
log_print("network initialize succeed");
非常简单的修改,就可极大改善调用体验。人生而懒惰,懒惰不一定有大问题,问题在于,要懒惰得恰到好处。如果在设计底层接口时偷懒,则日后写应用层代码时将堕入万劫不复之深渊。如果想在调用时舒服些,那就需要在设计底层时多花些心思。
使用printf
修改过后的log_print不再丑陋,但依然很简陋。如果想打印动态的内容,如数字时,还是比较麻烦。比如打印网络信号质量(rssi为int型变量,值为信号质量):
char buffer[64];
snprintf(buffer, sizeof(buffer), "rssi:%d", rssi);
log_print(buffer);
简单打印一个数字却需要三行代码,非常不便。这时我们可以呼叫printf函数:
printf("rssi:%d\n", rssi);
相信大家肯定对printf并不陌生,这可是学习C语言时经常用到的函数,其将内容打印到标准输出。单片机SDK并没有指定标准输出,若想将printf的内容输出到指定串口,则需要实现printf所依赖的底层函数。在gcc编译环境中(stm32cube,TRUEStudio,Code Compose Studio等均使用gcc编译器),需要实现_write函数:
int _write(int fd, char *str, int len)
{
drv_uart_send(&dbg_uart, str, len);
return len;
}
drv_uart_send为笔者封装的串口发送函数,dbg_uart指向调试串口。
_write为C库中定义的系统函数之一。_write实现的其实是将数据写入文件,fd为文件号。由printf调用_wirte时,传入的fd为1,即标准输出stdout。其他函数,如fprintf,fwrite也会调用_write,它们传入的fd指向相关文件。所以上述_write函数的实现并不严谨,它忽略了fd参数,不管是什么文件的写操作,均输出到调度串口。
严谨的设计应该这样:
int _write(int fd, char *str, int len)
{
if (fd == stdout->_file)
{
drv_uart_send(&dbg_uart, str, len);
}
return len;
}
不过在不使用文件系统的情况下,也不会操作其他文件,这个懒也可以偷。
添加更多的信息
使用printf已经比之前的log_print方便许多,不过仍然有改进的空间。
快速打开或关闭日志
在调试阶段,可能需要打印较多的日志来观察效果和跟踪问题。由于过多的日志可能降低程序的实时性,也暴露了实现的细节,所以在正式运行时(比如商用交付或者比赛验收)需要关闭全部或者大部分日志。如果写了大量的printf语句的话,一个个注释掉显然是件麻烦的事情。我们需要有一个能够快速打开或关闭日志的功能。
时间信息
有时我们不仅想要知道程序运行的结果,还想观察关键步骤执行的时间。比如某步执行了多长时间,某个定时任务执行的周期是否正确。如果日志中包含时间信息(比如从启动到现在所经过的毫秒)就会很方便观察。
[10.011] schedule task1
[12.011] schedule task1
[12.015] schedule task2
[14.011] schedule task1
[16.011] schedule task1
[16.015] schedule task2
来源和级别
当日志较多时,如果能打印日志的来源(文件名或模块名,函数名,代码行数),则更方便查看和定位问题。在多人合作开发时,这点尤为重要。
如果根据日志内容的重要性对日志分级,以不同的颜色来显示不同级别的内容,则更容易观察。
下图中:
- 白色为verbose级别,一般用于打印码流。
- 蓝色为debug级别,包含了大量的调试信息。
- 绿色为info级别,通常打印关键步骤的结果。
- 红色为error级别,打印错误信息。
下图红框中为日志来源,由模块名和代码行数组成。
正式运行时,往往不需要verbose和debug日志,保留info和error日志。
实现
实现上述功能会稍微复杂些,下面做一些简单的介绍。
定义日志级别:
#define LOG_LEVEL_FATAL 0
#define LOG_LEVEL_ERROR 1
#define LOG_LEVEL_WARN 2
#define LOG_LEVEL_INFO 3
#define LOG_LEVEL_DEBUG 4
#define LOG_LEVEL_VERBOSE 5
定义宏LOG_D以打印调试级别的日志:
#if LOG_LEVEL >= LOG_LEVEL_DEBUG
#define LOG_D(fmt, ...) logger_output(LOG_LEVEL_DEBUG, LOG_TAG, __LINE__, fmt, ##__VA_ARGS__)
#else
#define LOG_D(fmt, ...)
#endif
解释几点内容:
- LOG_LEVEL为当前日志级别,其可以控制是否打印调试级别的日志。此宏在用户代码中定义,而不是头文件这中。这样一来,不同的代码文件可以单独控制自己日志的级别。有些开发完毕的文件可调高日志级别(数值越小,级别越高)以精简日志,而开发中的文件可调低日志级别。
- …用于接收变长参数,编译器做宏替换时,会将…接收的参数集替换到__VA_ARGS__所在位置。
- ##为标识连接符,这里是用于处理变长参数集为空的场景。此时,##会吞噬前面的逗号。
- LOG_D在进行宏替换时,会加上日志级别(调试级别),标签名(LOG_TAG,同样在用户代码中定义),代码行数信息。
每一个用户代码在导入日志模块头文件时,需要指定标签名称和日志级别:
#define LOG_TAG "app"
#define LOG_LEVEL LOG_LEVEL_DEBUG
#include "logger.h"
使用起来很简单,当printf使用即可,比如:
LOG_D("this is a debug log");
LOG_D("rssi:%d", 30);
如果刚才没有理解…和##的话,这里结合示例再说明下。
上述示例中,LOG_D("rssi:%d", 30) 中的"rssi:%d" 是格式化字符串,对应于LOG_D(fmt, ...) 中的fmt参数,30则属于变长参数。为了便于理解,笔者给出中间替换结果,即LOG_LEVEL_DEBUG之类的不替换:
logger_output(LOG_LEVEL_DEBUG, LOG_TAG, __LINE__, "rssi:%d", 30);
至于LOG_D("this is a debug log") ,如果没有##的话,将被替换为:
logger_output(LOG_LEVEL_DEBUG, LOG_TAG, __LINE__, "this is a debug log", );
有没有注意最右边有一个单独的逗号?##的作用就是在这种情况下把逗号给吃掉。
logger_output的实现如下,先打印时间和来源,之后调用了va_list版本的snprintf,即vsnprintf,以打印用户传递的格式化字符串和变长参数。关于va_list,改天单独出篇文章讲解。
void logger_output(int level, const char *tag, int line_num, const char *fmt, ...)
{
static char buf[LOG_BUF_SIZE];
va_list args;
int idx = 0;
uint32_t tick = sys_get_time();
level = MATH_MAX(0, level);
level = MATH_MIN(level, ARRAY_SIZE(LEVEL_PREFIX) - 1);
idx += snprintf(buf + idx, sizeof(buf) - idx, "[%lu.%03lu] [%s:%d] %s",
tick / 1000, tick % 1000, tag, line_num, LEVEL_PREFIX[level]);
va_start(args, fmt);
idx += vsnprintf(buf + idx, sizeof(buf) - idx, fmt, args);
va_end(args);
idx += snprintf(buf + idx, sizeof(buf) - idx, "\r\n" NOR);
drv_uart_send(&dbg_uart, buf, idx);
}
其他级别的日志接口,如LOG_I,LOG_W等,日志接口的详细实现与使用示例,参见笔者基于stm32f407创建的demo工程:
地址:https://gitee.com/njupt_elec/demo 代码:utils/logger.h, utils/logger.c 调用示例:examples/01_log/example.c,使用时需要打开examples/examples.h中的EXAMPLE_SHOW_LOG。
下图为examples/01_log/example.c的效果,想要看到彩色的日志的话,可使用Tera Term或SecureCRT这两款串口软件。
|