4. 串口输出优化:串口初始化延迟消除,printf缓冲区策略与日志分级
串口输出,说白了就是 U-Boot 的“嘴巴”。你想想看,系统启动时,所有调试信息、状态打印、错误报告,全得靠它一张嘴往外说。但问题也出在这里——这张嘴如果太慢,整个启动流程都得等它。我在好几个项目里都遇到过,明明代码逻辑跑得飞快,结果被串口打印硬生生拖慢了几百毫秒。
这一章,咱们就专门来治治串口的“慢性子”。核心就三件事:初始化别磨蹭、输出要攒批、废话少说。
4.1 串口初始化延迟消除
很多工程师写串口驱动,习惯一上来就把波特率、数据位、校验位、流控全配一遍。这本身没错,但问题是——你配得太早了。
U-Boot 启动早期,时钟可能还没稳定,PLL 还在锁相,这时候你去配串口,硬件要么不响应,要么返回错误,驱动就得反复重试、超时等待。我见过一个项目,串口初始化里光是等待 TX 空闲就循环了 5 万多次,白白浪费了 30 毫秒。
怎么治?两个思路:
- 延迟初始化:把串口硬件配置推迟到第一次
putc()调用时。说白了,就是“懒加载”。 - 预配置缓存:在 board_init_f 阶段只保存配置参数,等时钟稳定后再真正写寄存器。
来看一段实际代码。这是我在一个 i.MX6 项目里改过的串口初始化:
// 传统写法:一上来就配硬件
static int early_uart_init(void)
{
/* 等待时钟稳定,可能耗时 10-20ms */
while (!(readl(&ccm->cdhip) & CLK_STABLE))
;
/* 配置串口寄存器 */
writel(BAUD_115200, &uart->ubir);
writel(0, &uart->umcr);
/* ... 更多配置 */
return 0;
}
// 优化写法:延迟到第一次输出
static int uart_hw_init_done = 0;
static void uart_lazy_init(void)
{
if (uart_hw_init_done)
return;
/* 先检查时钟是否就绪,没就绪就跳过,用默认值 */
if (readl(&ccm->cdhip) & CLK_STABLE) {
writel(BAUD_115200, &uart->ubir);
uart_hw_init_done = 1;
}
/* 时钟没稳?没关系,下次 putc 再试 */
}
void putc(const char c)
{
uart_lazy_init(); // 延迟初始化
/* 等待 TX 空,但加超时 */
int timeout = 1000;
while (!(readl(&uart->usr1) & USR1_TXEMPTY) && timeout--)
;
if (timeout > 0)
writel(c, &uart->utxd);
}
4.2 printf 缓冲区策略
U-Boot 默认的 printf() 是逐个字符输出的。每打一个字符,就要调用一次 putc(),每次 putc() 又要查状态、等 FIFO、写寄存器。你想想看,打印一行 80 个字符的日志,就得折腾 80 次硬件交互。
这效率太低了。解决方案就是——加缓冲区。
我习惯的做法是:在内存里开一个 256 字节或 512 字节的环形缓冲区。printf() 先把内容写到缓冲区里,等缓冲区满了,或者遇到换行符 \n,再一次性刷到串口。
来看实现框架:
#define PRINTF_BUF_SIZE 256
static char printf_buf[PRINTF_BUF_SIZE];
static int buf_head = 0;
static int buf_tail = 0;
static void buf_putc(char c)
{
int next = (buf_head + 1) % PRINTF_BUF_SIZE;
if (next != buf_tail) {
printf_buf[buf_head] = c;
buf_head = next;
}
/* 缓冲区满?立即刷出 */
if (next == buf_tail || c == '\n') {
uart_flush_buffer();
}
}
void uart_flush_buffer(void)
{
while (buf_tail != buf_head) {
uart_putc(printf_buf[buf_tail]);
buf_tail = (buf_tail + 1) % PRINTF_BUF_SIZE;
}
}
这个策略的好处很明显:
- 减少串口硬件访问次数,从 N 次降到 1 次
- 利用串口 FIFO 的硬件缓冲能力,连续发送效率更高
- 换行符触发刷出,保证关键日志实时可见
4.3 日志分级策略
U-Boot 的日志输出,很多时候是“一刀切”——所有信息全打出来。调试阶段没问题,但到了产品阶段,你根本不需要知道“初始化 GPIO 管脚 3.14”这种细节。
我的做法是引入日志分级。说白了,就是给每条日志贴个标签,告诉系统“这消息有多重要”。
常用的分级:
| 级别 | 名称 | 用途 | 产品阶段是否输出 |
|---|---|---|---|
| 0 | ERROR | 致命错误,系统无法继续 | 是 |
| 1 | WARNING | 异常但可恢复 | 是 |
| 2 | INFO | 关键流程信息 | 是 |
| 3 | DEBUG | 调试细节 | 否 |
| 4 | TRACE | 函数调用跟踪 | 否 |
实现起来也很简单。定义一个全局的日志级别变量,再写几个宏:
#define LOG_LEVEL_ERROR 0
#define LOG_LEVEL_WARN 1
#define LOG_LEVEL_INFO 2
#define LOG_LEVEL_DEBUG 3
#define LOG_LEVEL_TRACE 4
/* 当前日志级别,可以在配置文件中设置 */
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL LOG_LEVEL_INFO
#endif
#define log_error(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
printf("[ERR] " fmt, ##__VA_ARGS__); } while(0)
#define log_info(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) \
printf("[INF] " fmt, ##__VA_ARGS__); } while(0)
#define log_debug(fmt, ...) \
do { if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) \
printf("[DBG] " fmt, ##__VA_ARGS__); } while(0)
这样,在产品发布时,只要把 CURRENT_LOG_LEVEL 设为 LOG_LEVEL_INFO,所有 DEBUG 和 TRACE 级别的日志就自动消失了。串口输出量能减少 70% 以上。
LOG_LEVEL_TRACE,什么细节都打出来。到了提测阶段降到 LOG_LEVEL_DEBUG,只保留关键调试信息。发布时用 LOG_LEVEL_INFO,只输出错误和关键流程。这样一套组合拳下来,启动速度能快不少。
4.4 整体优化流程
这三招怎么组合到一起?我画了张图,你看一眼就明白了:
你看,这三步是递进关系。先把初始化磨蹭的问题解决掉,再优化输出方式,最后从源头上减少废话。每一步都能带来实实在在的提速。
我曾经在一个 ARMv8 平台上做过测试:优化前,串口输出占用了整个启动时间的 35%;优化后,这个比例降到了 5% 以下。启动总时间从 2.8 秒缩短到了 1.6 秒。嗯,这 1.2 秒的差距,就是串口优化带来的。