9.4 基础视频输出与显示驱动
在操作系统自举与初始化的早期阶段,硬件调试手段往往十分有限。在尚未建立网络协议栈、磁盘文件系统以及内核日志转储机制之前,直接向显示硬件写入字符或像素,是将内核内部运行状态、异常上下文与初始化轨迹直观呈现给开发者的关键通路。
在 x86 体系结构下,最经典且实现路径最直接的显示模式是 VGA 文本模式;而在现代 UEFI 或高分辨率图形环境下,内核则通常借由引导器移交的线性帧缓冲区(Framebuffer)实现图形渲染。
1. VGA 文本模式原理与字符终端
在个人计算机的兼容性设计中,IBM VGA(Video Graphics Array)规范为文本输出预留了标准的物理内存映射。当计算机处于传统的 80 列 × 25 行文本模式时,显卡控制器会将显存的基地址直接映射到物理地址 0xB8000 至 0xBFFFF 范围内。
在这一模式下,屏幕由 2000 个字符单元格构成,每个单元格固定占用 2 个字节的显存空间:
- 低字节(Byte 0):表示该单元格所呈现字符的 ASCII 编码;
- 高字节(Byte 1):表示字符的色彩属性(Attribute Byte),其中低 4 位控制前景色(0~15),高 4 位中的低 3 位控制背景色(0~7),最高位为闪烁或高亮控制位。
需要指出的是,在纯 UEFI 环境下,固件默认不再初始化兼容的 80x25 文本显存映射,而是统一通过图形输出协议(GOP)或 VESA BIOS 扩展提供线性帧缓冲区。
1.1 编写 VGA 文本驱动
驱动 VGA 文本模式的核心在于三点:一是维护显存地址指针与逻辑光标坐标;二是向显存写入编码与属性;三是通过向 CRT 控制器(CRTC)的 I/O 端口发送命令,同步屏幕上的硬件闪烁光标。
准备工作
c#include <stdint.h> // 导入头文件 uint16_t *terminal_buffer = (uint16_t*) 0xB8000; //定义显存基础地址 uint16_t cursor_x = 0, cursor_y = 0; // 光标位置 uint8_t terminal_color = 7 | 0 << 4; //黑底白字 static inline void outb(uint16_t port, uint8_t data) { //out 端口操作函数 (建议您放到 io.h 头文件中,io.h 专门用于 IO 端口操作) asm volatile("outb %b0, %w1" : : "a"(data), "Nd"(port)); }1
2
3
4
5
6
7
8
9- 关于
0xB8000: 在 OS 代码中一般用 16 进制表示一些内存地址,这样代码展现比较直观好辨认,相比十进制或者二进制, 代码长度也会有所缩减
- 关于
实现滚屏 光标位置设置
cstatic void scroll() { //滚屏代码实现 uint8_t attributeByte = (0 << 4) | (15 & 0x0F); uint16_t blank = 0x20 | (attributeByte << 8); if (cursor_y >= 25) { int i; for (i = 0 * 80; i < 24 * 80; i++) terminal_buffer[i] = terminal_buffer[i + 80]; for (i = 24 * 80; i < 25 * 80; i++) terminal_buffer[i] = blank; cursor_y = 24; } } void move_cursor() { //设置光标位置 uint16_t cursorLocation = cursor_y * 80 + cursor_x; outb(0x3D4, 14); outb(0x3D5, cursorLocation >> 8); outb(0x3D4, 15); outb(0x3D5, cursorLocation); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17实现 vga_putchar
cvoid vga_putchar(char c){ uint8_t attributeByte = terminal_color; // 黑底白字 uint16_t attribute = attributeByte << 8; uint16_t *location; if (c == 0x08 && cursor_x) { // 退格 cursor_x--; location = terminal_buffer + (cursor_y * 80 + cursor_x); *location = ' ' | attribute; } else if (c == 0x09) { location = terminal_buffer + (cursor_y * 80 + cursor_x); *location = ' ' | attribute; cursor_x = (cursor_x + 8) & ~(8 - 1); } else if (c == '\r') { // 将光标设置到行首 cursor_x = 0; } else if (c == '\n') { // 换行 cursor_x = 0; // 光标回首 cursor_y++; // 下一行 } else if (c >= ' ' && c <= '~') { location = terminal_buffer + (cursor_y * 80 + cursor_x); *location = c | attribute; cursor_x++; } if (cursor_x >= 80) { // x轴输出溢出自动换行 cursor_x = 0; cursor_y++; } scroll(); //滚屏 move_cursor(); //设置光标位置 }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32实现 writestring 函数,用于打印一行信息
cvoid vga_write(const char *data, size_t size) { for (size_t i = 0; i < size; i++) vga_putchar(data[i]); } void vga_writestring(const char *data) { vga_write(data, strlen(data)); }1
2
3
4
5
6
7
8
在具备了字符与定长字符串写入能力后,内核便拥有了最基本的屏显通道。若需在此基础上支持如 printf 风格的格式化输出,既可在内核中实现简易的状态机解析 %s、%d、%x 等转换规范,也可以引入如 stb_sprintf 等脱离宿主标准库的单头文件轻量实现,将其底层字符输出重定向至 vga_putchar。
2. VBE(VESA BIOS 扩展)与高分辨率图形
随着显示硬件与人机界面的发展,传统的 80×25 字符网格逐渐难以承载精细的视觉交互需求。各类图形显示芯片在硬件寄存器与命令格式上存在显著分歧,为了消除上层软件对专有显卡驱动的紧耦合,视频电子学标准协会(VESA)制定了 VESA BIOS 扩展(VESA BIOS Extension,简称 VBE)标准。
在实模式环境下,软件可通过调用 BIOS INT 10h 中断(设置寄存器 AH=4Fh)查询显卡支持的视频模式,并设置带有真彩色能力的线性帧缓冲区(Linear Framebuffer,LFB)。现代通用引导程序(如 GRUB 与 Limine)可以在跳转至内核入口前代为执行模式探测与切换,从而在内核接管时便直接提供一块映射完备的物理像素数组。
3. 帧缓冲区的配置与图形终端构建
在内核开发初期,VGA 文本模式由于结构简单、显存基地址固定且无须处理字模位图渲染,通常作为首选的调试通道。然而一旦进入 64 位长模式或纯 UEFI 平台,系统将不再保留兼容文本映射,必须直接面向帧缓冲区进行绘制。
当引导加载程序激活 VBE 或 UEFI GOP 图形模式后,会通过启动参数结构体向内核传递如下关键硬件参数:
- 屏幕水平与垂直分辨率(
width与height); - 每像素位数(Bits Per Pixel,通常为 32 位 RGB/BGR);
- 物理显存基地址(用于映射后读写像素);
- 每行扫描线跨度(
pitch,即屏幕每行像素在显存中占用的实际物理字节数,可能包含硬件对齐所需的尾部填充)。
在此模式下输出文字,需要内核内置或链接点阵字模,将每个字符的字形逐像素着色写入帧缓冲区。
开源图形虚拟终端组件
若暂未计划从底层编写点阵字形渲染与控制台换行滚动机制,可以借助社区沉淀的通用组件:
os_terminal:面向裸机与自制内核设计的轻量级虚拟终端库,完整支持 VT100 转义控制序列与高分辨率帧缓冲渲染。
项目主页与预编译库可参见:GitHub - plos-clan/os_terminal,将其作为独立静态库链接至内核映像即可快速获得完备的图形控制台能力。
4. 习题
VGA 文本模式下,显存里每个字符单元格占两个字节:低字节是字符码,高字节是属性。属性字节的第 0~3 位是前景色,第 4~6 位是背景色,第 7 位是闪烁。
(1) cursor_x = 40、cursor_y = 12 时,这个单元格在 terminal_buffer 里的下标是多少?对应的字节地址是多少?
(2) 用黑底亮白(前景 15、背景 0)写字符 'A',算出 16 位单元格的值和两个字节的十六进制。正文的 terminal_color = 7 | 0 << 4 对应哪种前景色?两者在屏幕上显示的区别是什么?
(3) 写 uint16_t vga_cell(char c, uint8_t fg, uint8_t bg),返回可直接写入显存的 16 位值。用 gcc -std=c17 -Wall -Wextra 编译下面这段测试,输出必须与注释一致:
#include <stdint.h>
#include <stdio.h>
uint16_t vga_cell(char c, uint8_t fg, uint8_t bg);
int main(void) {
printf("%04X\n", vga_cell('A', 15, 0)); /* 0F41 */
printf("%04X\n", vga_cell(' ', 0, 7)); /* 7020 */
printf("%04X\n", vga_cell('~', 7, 0)); /* 077E */
return 0;
}2
3
4
5
6
7
8
9
10
11
(4) 说明属性为什么放在高字节:x86 是小端,把一个 uint16_t 写进显存时,内存里的两个字节各是什么?如果把字符码与属性反过来拼,屏幕上会显示什么?
正文的 vga_putchar 处理制表符时只写了一个空格,然后直接把光标移到下一个 8 的倍数:
} else if (c == 0x09) {
location = terminal_buffer + (cursor_y * 80 + cursor_x);
*location = ' ' | attribute;
cursor_x = (cursor_x + 8) & ~(8 - 1);
}2
3
4
5
(1) 在第 0 行依次输出 abcdef、\r、x、\t、y,按这段代码推演这一行的内容。注意 \r 只把光标移回行首,不擦除已经写下的字符。
(2) 改成把光标当前位置到下一个制表位之间的单元格全部填成空格,给出完整的 vga_putchar。说明 ~(8 - 1) 的作用。
(3) 在主机上用一个 uint16_t screen[25][80] 模拟显存,把修正前后的两版函数各跑一遍上面那串输入,打印第 0 行的可见字符。两行输出分别是什么?修正后 x 与 y 之间有几个空格?
(4) cursor_x = 72 时收到 \t,修正后的函数应该怎么处理?写出你的做法。
正文的 scroll() 用逐元素循环把 24 行搬到上一行,新行用写死的亮白属性填充;move_cursor() 通过 0x3D4、0x3D5 两个端口设置光标。
(1) 把搬运改成一次 memmove(terminal_buffer, terminal_buffer + 80, 24 * 80 * sizeof(uint16_t))。源区间与目标区间重叠,说明为什么这样搬能得到正确结果。若手写循环改成从 i = 24 * 80 - 1 往下写到 0,屏幕上会留下什么。
(2) scroll() 里 attributeByte 写死为 (0 << 4) | (15 & 0x0F),与 terminal_color 的 0x07 不一致。滚屏一次之后,屏幕上原有各行与新出现的空行分别是什么颜色?改成跟随 terminal_color,并说明 blank 为什么要在函数里按当前颜色重新计算。
(3) move_cursor() 先写索引 14、再写高字节,再写索引 15、再写低字节。列出 cursor_x = 79、cursor_y = 24 时四次 outb 的实参。如果把两次写 0x3D4 的调用都挪到两次写 0x3D5 的调用之前,光标会到哪里?
(4) 写一个主机测试:用 25×80 的数组和两个静态对象替换显存与 0x3D5 端口,连续输出 30 行 line N 之后打印第 0 行、第 24 行与光标位置。期望第 0 行是 line 7,第 24 行是空行,光标位置是 1920。
引导器通过 VBE 给出帧缓冲:基地址、宽、高、每行字节数(pitch)和每像素位数。pitch 往往大于 width * 4,多出来的是行末填充。
初始代码(主机上用 malloc 的缓冲区代替显存):
#include <stdint.h>
struct fb {
uint8_t *pixels;
uint32_t width;
uint32_t height;
uint32_t pitch; /* 每行字节数,含填充 */
uint8_t bpp; /* 本例为 32 */
};
void fb_clear(struct fb *fb, uint32_t rgb);
void fb_fill_rect(struct fb *fb, uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t rgb);
void fb_scroll_up(struct fb *fb, uint32_t lines);2
3
4
5
6
7
8
9
10
11
12
13
要求:
(1) 三个函数都按 pitch 计算行首地址,像素地址写成 pixels + y * pitch + x * 4;颜色按 0x00RRGGBB 拆成 B、G、R 三个字节写入。
(2) fb_scroll_up(n) 用 memmove 搬 (height - n) * pitch 字节,再用 fb_fill_rect 清空末尾 n 行。说明为什么不能只搬 (height - n) * width * 4 字节。
(3) 主机测试取 width = 320、height = 200、pitch = 1408。清成黑色,画一个从 (10, 10) 起、200×100 的红色矩形,再向上滚 8 行。断言 (10, 2) 是红色、(10, 1) 是黑色、(209, 101) 是红色、(210, 101) 是黑色。贴出断言全部通过的输出,并写出 pitch - width * 4 的值。
(4) 把最后一帧写成 PPM(P6,每像素三个字节 RGB),用图片查看器打开确认矩形位置。说明写 PPM 时为什么必须逐行取 width * 3 字节,而不能把整块缓冲区直接写出。
(5) 如果把 pitch 当成 width * 4 来算行地址,矩形会变成什么形状?用一两句话说明。