第四幕:帧缓冲控制台——让内核开口说话
绪
迄今为止,我们的内核还是一个"哑巴"。它能运行、能处理异常,却无法向外界传递任何信息。在裸机环境下,printf 早已灰飞烟灭,我们唯一能依靠的,就是 Limine 提供的线性帧缓冲(Linear Framebuffer)。
这一节,我们将从零实现一个帧缓冲控制台——让内核第一次开口说话。
线性帧缓冲是什么?
帧缓冲是一段可以直接写入的显存区域。每个像素对应显存中的一个或多个字节。Limine 在引导时会设置好显卡的分辨率和色彩模式,然后通过请求-响应机制把帧缓冲的地址、宽度、高度、每行像素数(pitch)等信息交给内核。
在我们的配置中,Limine 返回的是一个 32 位色的帧缓冲——每个像素 4 字节,格式为 0x00RRGGBB。这意味着我们只需向那个地址写入颜色值,屏幕上对应的像素就会亮起来。
初始化帧缓冲
首先,我们从 Limine 的响应中取出帧缓冲信息:
void fb_init() {
if (fb_request.response == NULL ||
fb_request.response->framebuffer_count < 1)
while (1);
struct limine_framebuffer *fb = fb_request.response->framebuffers[0];
fb_ptr = (uint32_t *)fb->address;
fb_width = fb->width;
fb_height = fb->height;
fb_pitch = fb->pitch;
}fb_pitch 是每行像素的字节跨度。注意:pitch 可能大于 width * 4,因为有些显卡会对每行做对齐填充。所以计算像素偏移时,一定要用 pitch / 4 而不是直接用 width。
绘制像素与字符
绘制单个像素很简单——在帧缓冲的对应位置写入颜色值即可:
static inline void put_pixel(uint32_t x, uint32_t y, uint32_t color) {
if (x >= fb_width || y >= fb_height) return;
fb_ptr[y * (fb_pitch / 4) + x] = color;
}但光会画像素还不够——我们需要的是画字符。这就需要一个字体文件。我们选择了 PSF2(PC Screen Font version 2)格式,这是一种位图字体格式,在 Linux 内核中广泛使用。
PSF2 字体格式
PSF2 的头部包含以下关键信息:
typedef struct {
uint32_t magic; // 魔数:0x864ab572
uint32_t version; // 版本号
uint32_t headersize; // 头部大小
uint32_t flags; // 标志
uint32_t numglyph; // 字形数量
uint32_t bytesperglyph; // 每个字形占多少字节
uint32_t height; // 字形高度
uint32_t width; // 字形宽度
} psf2_header_t;头部之后紧跟着字形的位图数据。每个字形的高度 × 宽度决定了它的像素矩阵,但由于使用位打包存储(bit-packed),每一行占用的字节数是 (width + 7) / 8。对于 8 像素宽的字体,每行正好占 1 字节;对于 16 像素宽的字体,每行占 2 字节。
渲染一个字符
fb_putchar 是控制台的核心函数。它从一个 PSF2 字体文件中取出指定字符的位图数据,然后逐像素地绘制到帧缓冲上:
void fb_putchar(char c) {
if (c == '\n') {
cursor_x = 0;
cursor_y += current_font->height;
return;
}
uint8_t *glyph = (uint8_t *)current_font + current_font->headersize +
((uint8_t)c * current_font->bytesperglyph);
uint32_t bytes_per_line = (current_font->width + 7) / 8;
for (uint32_t cy = 0; cy < current_font->height; ++cy) {
uint8_t *line_data = glyph + (cy * bytes_per_line);
for (uint32_t cx = 0; cx < current_font->width; ++cx) {
uint32_t byte_index = cx / 8;
uint8_t bit_mask = 0x80 >> (cx % 8);
if (line_data[byte_index] & bit_mask)
put_pixel(cursor_x + cx, cursor_y + cy, COLOR_WHITE);
else
put_pixel(cursor_x + cx, cursor_y + cy, COLOR_BLACK);
}
}
cursor_x += current_font->width;
}这里有一个关键的位操作:0x80 >> (cx % 8)。位图数据是按 MSB-first 排列的——每一行最左边的像素对应字节的最高位。所以我们在遍历列时,从 0x80 开始向右移位,逐位检测该像素是否应该点亮。
自动换行与滚屏
字符绘制完成后,光标向右移动。如果下一个字符会超出屏幕右边界,就自动换行:
if (cursor_x + current_font->width > fb_width) {
cursor_x = 0;
cursor_y += current_font->height;
}同理,如果光标到底了,就回到屏幕最上方——这是一种最简单的滚屏策略:
if (cursor_y + current_font->height > fb_height) {
cursor_y = 0;
cursor_x = 0;
}这不是真正的滚屏(没有移动已有内容),而是一种"环形覆盖",足够我们在早期调试时使用。
便捷输出函数
有了 fb_putchar,我们可以快速封装出常用的输出函数:
void fb_print_str(const char *str) {
if (!str) return;
while (*str) fb_putchar(*str++);
}
int fb_puts(const char *c) {
if (c == NULL) return EOF;
while (*c) fb_putchar(*c++);
fb_putchar('\n');
return 0;
}
void fb_print_hex(uint64_t val) {
fb_print_str("0x");
const char *hex_chars = "0123456789ABCDEF";
for (int i = 15; i >= 0; i--)
fb_putchar(hex_chars[(val >> (i * 4)) & 0xF]);
}fb_print_hex 在调试时尤其重要——寄存器的值、物理地址、错误码,这些都需要以十六进制的形式呈现,因为二进制直接映射到硬件状态。
小结
我们终于让内核开口说话了。从裸像素到 PSF2 字形渲染,从单个字符到完整的控制台输出,我们一步步搭建起了裸机环境下的"printf"替代品。
不过,现在的输出还是黑白的。我们使用固定的白色前景(0x00FFFFFF)和黑色背景(0x00000000)。未来可以扩展为彩色输出、更完善的滚屏机制,甚至多个虚拟控制台——但那是后话了。
有了这块"屏幕",我们接下来的内存管理调试将不再是在黑暗中摸索。每分配一页内存、每建立一次页表映射,我们都能在屏幕上看到反馈。
