Skip to content

导引:点亮什么、为什么、设计图

这一章我们要点亮什么

两件事,一软一硬地接上 013。

第一件,屏幕上不再只是一堆散乱的像素,而是一个会换行、会滚动的文本终端。你往里写 "Hello\n",它就在第一行画出来、光标移到第二行;写满了,整个画面往上滚一行,底下腾出新的一行继续写——就像你最熟悉的那个终端一样。这一切由 Console 这个类负责,它把 013 的 framebuffer + 字体捏成一个「字符网格」。

第二件,是这一章真正的重头戏:kprintf 接上屏幕。012 那时我们把 kprintf 的格式化逻辑抽成了一个只认回调的引擎 vkprintf_impl,当时只喂了串口一个 lambda,并预告「以后还会多一路屏幕」。这一章我们把「输出后端」从一个升级成一组——一个最多 8 路的 sink 表,串口和屏幕各占一路,格式化引擎一行不改,每次产出的字符同时分发给所有已注册的后端。这就是 012 那层回调解耦真正兑现的时刻。

为什么现在需要它

先回答一个直白的问题:013 已经能画字了,为什么还要单独搞个 Console,不能直接 kprintf 一个字符一个字符往屏幕上画吗?

能,但你会立刻撞上「光标在哪、写到屏幕边缘怎么办、写满了怎么办」这一连串问题。像素坐标是 (x, y),可人写日志是按「行和列」思考的——"Hello" 该从第几行第几列开始画?画完光标停哪?这些事 framebuffer 和字体都不管,它们只认像素。Console 就是填这层缝隙的:它在像素之上抽象出一个 行 × 列 的网格,记住光标的 (row, col),每写一个字符就把光标往前推、到行尾就换行、写满就滚动。有了它,kprintf 才能用最朴素的方式「逐字符写」,不用操心位置。

至于 kprintf 接屏幕,那就更不是「锦上添花」了。到这一步为止,内核所有诊断信息都只走串口——这意味着每次想看内核状态,你都得开个串口终端窗口、连上 QEMU 的串口。屏幕一直黑着,白白浪费。把 kprintf 接上屏幕,诊断信息就有了第二条、也是更直观的一条出口:开机就能看见 [BIG] GDT loaded. 一行行刷出来,不用任何额外工具。对一个正在成长的内核,这种「看得见」的反馈极其重要。

而且这一步几乎不花成本——因为 012 把路铺好了。回头看 012 的 kprintf:格式化引擎 vkprintf_impl 完全硬件无关,只认一个「输出单字符」的回调。当时它的回调是「喂串口」。现在我们要做的,仅仅是把「喂串口」换成「喂一组后端」,引擎本身一个字都不用动。这就是当初那层抽象的红利:加一个输出后端,不用碰最容易出错的格式化逻辑。

设计图

Console 是一个架在 framebuffer + 字体之上的状态机,核心就两个状态:row(当前第几行)和 col(当前第几列)。每写一个字符,根据它是控制符还是可打印字符,走不同分支:

text
   Console::putc(c)

        ├─ c == '\n'  →  new_line(): col=0; 已到最后一行? scroll() : row++
        ├─ c == '\r'  →  col = 0
        ├─ c == '\b'  →  回退: col>0 则 col--; 否则换到上一行末尾
        └─ 可打印字符
                ├─ col 已到右边界? → 先 new_line() 换行
                ├─ font.render_char(fb, c, col*字宽, row*字高, fg, bg)
                └─ col++

kprintf 那边,从 012 的「单回调喂串口」升级成「sink 表 fan-out」:

text
   012 (单后端):                        013 (多后端):
   kprintf(fmt,...)                     kprintf(fmt,...)
     └ vkprintf_impl(                   └ vkprintf_impl(
          [&](c){ serial.putc(c); },            [&](c){ 遍历 g_sinks[]:
          fmt, args)                                for 每个 enabled sink:
        )                                              sink.fn(c, sink.ctx); },
                                                fmt, args)

                                  g_sinks[0] = serial_sink_adapter   (串口)
                                  g_sinks[1] = console_sink_adapter   (屏幕)  ← 本章注册
                                  ... 最多 8 路

左半边是 012 的样子:一个 lambda,硬编码喂串口。右半边是这一章:同一个 lambda,改成遍历一张 sink 表,把字符扇出给所有后端。格式化引擎 vkprintf_impl 那个框,左右一模一样——这就是「引擎不动」的含义。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04