VGA 文本模式

VGA 文本模式是一种将文本打印到屏幕上的简单方法。 在本文章中,我们将封装一个对 VGA 操作的模块,使其使用变得安全而简单。

VGA 文本缓冲区

要在 VGA 文本模式下将字符打印到屏幕上,必须将其写入 VGA 硬件的文本缓冲区。 VGA 文本缓冲区是一个二维数组,通常有 25 行 80 列, 可直接渲染到屏幕上。 每个数组元素通过以下格式描述一个屏幕字符:

Bit(s)Value
0-7ASCII code point
8-11Foreground color
12-14Background color
15Blink
  • 第一个字节代表 ASCII 编码中应打印的字符。 更具体地说,它并不完全是 ASCII 编码,而是一个名为代码页 437 的字符集, 其中包含一些额外的字符和细微的修改。 为简单起见,我们在本篇文章中将继续称其为 ASCII 字符。
  • 第二个字节定义了字符的显示方式。 前四位定义前景色,后三位定义背景色,最后一位定义字符是否闪烁。 可选颜色如下

    NumberColorNumber + Bright BitBright Color
    0x0Black0x8Dark Gray
    0x1Blue0x9Light Blue
    0x2Green0xaLight Green
    0x3Cyan0xbLight Cyan
    0x4Red0xcLight Red
    0x5Magenta0xdPink
    0x6Brown0xeYellow
    0x7Light Gray0xfWhite

    第 4 位是亮色位,可将蓝色变为浅蓝色。 对于背景色,该位被重新用作闪烁位。

    VGA文本缓冲区通过映射到地址 0xb8000内存空间(Memory-Mapped I/O)访问。这意味着对这个地址进行读取和写入操作不会接触RAM,而是直接访问 VGA 硬件上的文本缓存。

    请注意,内存映射硬件可能不支持所有正常的 RAM 操作。 例如,设备可能只支持字节式读取,并且在读取 u64 时返回垃圾。 幸运的是,VGA 文本缓冲区支持正常读写,因此我们不必以特殊方式处理它。

一个 Zig 模块

  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
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
// File: vga.zig (lines 1-111)
const std = @import("std");
const fmt = std.fmt;
const mem = std.mem;

const VGA_WIDTH = 80;
const VGA_HEIGHT = 25;
const VGA_SIZE = VGA_WIDTH * VGA_HEIGHT;

/// Enumeration of VGA Text Mode supported colors.
pub const Colors = enum(u8) {
    Black = 0,
    Blue = 1,
    Green = 2,
    Cyan = 3,
    Red = 4,
    Magenta = 5,
    Brown = 6,
    LightGray = 7,
    DarkGray = 8,
    LightBlue = 9,
    LightGreen = 10,
    LightCyan = 11,
    LightRed = 12,
    LightMagenta = 13,
    LightBrown = 14,
    White = 15,
};

/// The current cursor row position.
var row: usize = 0;

/// The current cursor column position.
var column: usize = 0;

/// The current color active foreground and background colors.
var color = vgaEntryColor(Colors.LightGray, Colors.Black);

/// Direct memory access to the VGA Text buffer.
var vga_array: *volatile [VGA_HEIGHT][VGA_WIDTH]u16 = @ptrFromInt(0xB8000);

/// Create a VGA color from a foreground and background Colors enum.
fn vgaEntryColor(fg: Colors, bg: Colors) u8 {
    return @intFromEnum(fg) | (@intFromEnum(bg) << 4);
}

/// Create a VGA character entry from a character and a color
fn vgaEntry(uc: u8, newColor: u8) u16 {
    const c: u16 = newColor;
    return uc | (c << 8);
}

/// Set the active colors.
pub fn setColors(fg: Colors, bg: Colors) void {
    color = vgaEntryColor(fg, bg);
}

/// Set the active foreground color.
pub fn setForegroundColor(fg: Colors) void {
    color = (0xF0 & color) | @intFromEnum(fg);
}

/// Set the active background color.
pub fn setBackgroundColor(bg: Colors) void {
    color = (0x0F & color) | (@intFromEnum(bg) << 4);
}

/// Clear the screen using the active background color as the color to be painted.
pub fn clear() void {
    for (0..VGA_HEIGHT) |r| {
        for (0..VGA_WIDTH) |c| {
            vga_array[r][c] = vgaEntry(' ', color);
        }
    }
}

/// Sets the current cursor location.
pub fn setLocation(new_row: u8, new_col: u8) void {
    row = new_row;
    column = new_col;
}

/// Prints a single character
pub fn putChar(c: u8) void {
    vga_array[row][column] = vgaEntry(c, color);

    column += 1;
    if (column == VGA_WIDTH) {
        column = 0;
        row += 1;
        if (row == VGA_HEIGHT)
            row = 0;
    }
}

pub fn putString(data: []const u8) void {
    for (data) |c| {
        putChar(c);
    }
}

pub const writer = std.io.GenericWriter(void, error{}, callback){ .context = {} };

fn callback(_: void, string: []const u8) error{}!usize {
    putString(string);
    return string.len;
}

pub fn printf(comptime format: []const u8, args: anytype) void {
    fmt.format(writer, format, args) catch unreachable;
}

在上述代码中,我们首先通过 @ptrFromInt 将 VGA 对应的内存地址转为一个 80*25 二维数组的指针,之后定义了打印字符的方法 putChar ,在遇到行尾时会自动切换到下一行。 需要重点说的方法是 clear ,它会变量整个二维数组进行默认字符的赋值,由于一个个赋值可能比较低效,我们可以这么改进:

1
2
var ptr: [*]volatile u16 = @ptrCast(vga_array);
@memset(ptr[0..VGA_SIZE], vgaEntry(' ', color));

volatile 的作用是保证对 ptr 的读写不会被编译器优化掉,比如我们这里只对 ptr 进行赋值,但是从没读取过它,那么编译器就可以优化掉这个赋值。

自定义 Writer

为了方便调用,我们还定义了针对 VGA 的 Writer,这样我们就可以复用 fmt.format 来实现格式化打印。

1
pub const writer = std.io.GenericWriter(void, error{}, callback){ .context = {} };

初看 Write 的定义,可能会觉得有些复杂,但这种模式在 Zig 中其实非常常见,我们先来看 GenericWriter 的函数签名:

1
2
3
4
5
6
7
pub fn GenericWriter(
    comptime Context: type,
    comptime WriteError: type,
    comptime writeFn: fn (context: Context, bytes: []const u8) WriteError!usize,
) type {
   ...
}

它接受三个 comptime 参数,然后返回一个新类型:

  • Context 这里为 void ,表示空类型,大小为 0,这在基于 comptime 的编程中非常有用。比如 Zig 中没有提供 Set 数据类型, 但我们可以通过 std.AutoHashMap(i32, void) 来表示。
  • WriteError 这里为 error{} ,没有错误。这两个参数都是类型(type),因此首字母都是大写,普通的参数都是小写。
  • writeFn 里调用 putString 来进行字符串打印。

下面是对这个文件的使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// File: 03-01.zig (lines 33-49)
const vga = @import("./vga.zig");

fn main() void {
    vga.setColors(.White, .Blue);
    for (0..10) |i| {
        vga.printf("hello-{d}, ", .{i});
    }

    vga.setLocation(3, 0);
    vga.setForegroundColor(.LightRed);

    for (0..10) |i| {
        vga.printf("world-{d}, ", .{i});
    }
    while (true) {}
}

1
2
zig build-exe -target x86-freestanding -O ReleaseFast 03-01.zig -T linker.ld &&
qemu-system-x86_64 -kernel 03-01

采用 VGA 模块进行打印

采用 VGA 模块进行打印

注意:这里采用的是 ReleaseFast 模式编译,Debug 级别会有问题!具体原因还未知,如果读者知道原因,欢迎留言指出!

这是 Debug 模型下的 Godbolt

最后修改 April 17, 2025: fix links (49664e7)