Rust OS 前置:逐行拆解 VGA 的 Writer

前三篇的语法与思想,最终都落在一个真实的模块上:VGA 文本模式的 vga_buffer.rs。这篇不贴全文,而是把它拆成九块来读——从颜色怎么压进一个字节,一直讲到 println! 怎么变成对显存的一次写。

前置阅读:《从 C 到 Rust》《借用与生命周期》《属性与内存布局》,以及《Rust OS 入门:VGA 字符模式》。

📚 素材速读

要拆的部件和它们所属的层:

部件 职责 对应层
Color / ColorCode 16 色编号,以及把前景与背景压进一个字节 第一层
ScreenChar 一个屏幕格子:字符 + 颜色 第二层
Buffer 25 × 80 个格子,映射到 0xb8000 第二层
Writer 记录当前写到哪一列、用什么颜色,对外提供写屏接口 第三层
write_byte / new_line / clear_row 写一个字符、屏幕上滚一行、清空一行 第四、五层
write_string 把 &str 拆成字节逐个写出 第六层
fmt::Write 让 Writer 接进 Rust 的格式化体系 第七层
WRITER 全局唯一的 Mutex<Writer> 第八层
print! / println! 提供给内核其余部分使用的打印宏 第九层

🎨 第一层:Color 与 ColorCode——颜色怎么压进一个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[allow(dead_code)]                             // 有些颜色用不到,关掉警告
#[derive(Debug, Clone, Copy, PartialEq, Eq)] // 能打印、能复制、能比较(篇三)
#[repr(u8)] // 每个变体用一个 u8 表示
pub enum Color {
Black = 0,
Blue = 1,
// ... 共 16 个变体:8 个基础色 + 8 个亮色
White = 15,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)] // 布局与 u8 完全一致(篇三)
struct ColorCode(u8);

impl ColorCode {
fn new(foreground: Color, background: Color) -> ColorCode {
ColorCode((background as u8) << 4 | (foreground as u8))
// 背景移到高 4 位 | 前景留在低 4 位
}
}

#[repr(u8)] 是这里的枢纽:没有它,枚举的底层类型不由你决定,Color::Yellow as u8 拿到的值也不可控。ColorCode 那层包装解决另一个问题——给一个字节起个有语义的名字(篇一:newtype),同时靠 #[repr(transparent)] 保证它真的还只是一个字节,能直接塞进下一层的 struct 里。

1
2
3
4
5
6
7
8
ColorCode::new(Yellow, Black)
(background as u8) << 4 | (foreground as u8)
0000 0000 0000 1110
└─────── | ─────────┘
0000 1110
┌────────┬────────┐
│ 0000 │ 1110 │
└────────┴────────┘

🧱 第二层:ScreenChar 与 Buffer——一个格子与整块屏幕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)] // ASCII 在前、颜色在后,和硬件一致
struct ScreenChar {
ascii_character: u8, // 一个字符单元的第一字节
color_code: ColorCode, // 第二字节(布局等同 u8)
}

const BUFFER_HEIGHT: usize = 25; // 行数(篇一:const)
const BUFFER_WIDTH: usize = 80; // 列数

use volatile::Volatile; // 显存必须用 volatile 访问,理由见下

struct Buffer {
chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT], // 25 行 × 80 列
}

Volatile 是这一层唯一需要展开的地方。显存是内存映射 I/O(MMIO),它的内容会被硬件改动,而编译器看不到这件事;没有这层包装,编译器可能把连续的多次写合并掉,也可能认为刚写进去的值还在寄存器里、读取时不必回内存。第五层的 new_line 要整行读出来再写回去,正是这种读写都必须真的落在内存上的场景。Volatile<T> 自身也是 #[repr(transparent)] 包装,所以 Buffer 在内存里仍然严格是 25 × 80 × 2 字节,和显存逐格对应。

1
2
3
4
5
6
7
Buffer = 25 × 80 个格子,每格 2 字节
┌───────────────────┐
Row 0 │ . . . . . . . . . │
Row 1 │ . . . . . . . . . │
... │ ... │
Row 24 │ . . . . . . . . . │
└───────────────────┘

🖋️ 第三层:Writer——写字的人

1
2
3
4
5
pub struct Writer {                  // pub:模块外可见(篇一)
column_position: usize, // 当前写到第几列(0~79)
color_code: ColorCode, // 整屏统一的颜色,构造后不再改
buffer: &'static mut Buffer, // 可变:要往里写;'static:显存一直存在(篇二)
}

三个字段凑起来就是一句话:光标在哪、用什么颜色、往哪写。后面所有方法都只是围绕这三件事操作。

✍️ 第四层:write_byte——往显存写一个字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(), // 换行:单独一路
byte => {
if self.column_position >= BUFFER_WIDTH {
self.new_line(); // 这一行写满了,先换行
}

let row = BUFFER_HEIGHT - 1; // 始终写最后一行
let col = self.column_position;

let color_code = self.color_code;
self.buffer.chars[row][col].write(ScreenChar {
ascii_character: byte,
color_code,
}); // Volatile::write:一次真实的内存写
self.column_position += 1; // 光标推到下一列
}
}
}

&mut self 是必需的:写一个字符要同时改 column_position 和显存内容,属于可变借用(篇二)。另外留意 row 固定取最后一行——这一版实现里光标不高亮也不移动,始终停在末行,写满一行就把整屏上滚,这也是第五层存在的理由。

📜 第五层:new_line 与 clear_row——屏幕滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn new_line(&mut self) {
for row in 1..BUFFER_HEIGHT { // 1~24,第 0 行会被覆盖(篇一)
for col in 0..BUFFER_WIDTH {
let character = self.buffer.chars[row][col].read(); // volatile 读
self.buffer.chars[row - 1][col].write(character); // 写到上一行
}
}
self.clear_row(BUFFER_HEIGHT - 1); // 最后一行清空
self.column_position = 0; // 光标回到行首
}

fn clear_row(&mut self, row: usize) {
let blank = ScreenChar {
ascii_character: b' ', // 空格
color_code: self.color_code, // 带上当前颜色,清出来才干净
};
for col in 0..BUFFER_WIDTH {
self.buffer.chars[row][col].write(blank);
}
}

这段的语义是 把显存里的内容搬到显存里的另一个位置,所以它必须用 volatile 读写:只要中间某次读被缓存进寄存器,搬过去的就可能是旧值。另外这两个方法都没有 pub,滚动和清行是内部细节,只由 write_byte 调用(篇一:pub、私有)。

1
2
3
4
5
6
7
8
9
new_line():整体上滚一行
┌────────────────┐
│ Row 0 │ ← 被第 1 行覆盖
│ Row 1 │ ← 上移到第 0 行
│ ... │
│ Row 24 │ ← 清空成空格
└────────────────┘
↓
column_position = 0

🔤 第六层:write_string——从字符串到字节

1
2
3
4
5
6
7
8
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() { // 按 UTF-8 字节迭代(篇一)
match byte {
0x20..=0x7e | b'\n' => self.write_byte(byte), // 可打印 ASCII + 换行
_ => self.write_byte(0xfe), // 其余一律写实心方块
}
}
}

0x20..=0x7e 是可打印 ASCII(空格到 ~),加上换行符,这两类原样写屏;其余字节统一写成 0xfe,而它在 VGA 字体里是实心方块。中文每个字 3 个字节且都大于 0x7e,所以走的是这一路——屏幕上一串方块字的来历,也是这套 VGA 打印只适合英文的原因。

🖨️ 第七层:fmt::Write——把 Writer 接进格式化协议

前六层已经攒出一套能用的写屏能力,但只能这么调用:writer.write_string("Hello")。想要 println!("x = {}", x) 这种带占位符的写法,就得走 Rust 的格式化机制——{} 怎么变成最终文本、参数怎么拼进去,由 core::fmt 里的一套协议负责。这一层做的事,就是让 Writer 加入这套协议。

协议的载体是一个 trait(篇一)。trait 可以理解成接口或契约:它列出一组方法,某个类型声明实现它,就得把这组方法提供出来。core::fmt::Write 的简化样子是这样:

1
2
3
4
5
// core::fmt::Write 的简化示意,不是源码
trait Write {
fn write_str(&mut self, s: &str) -> fmt::Result; // 必须实现
fn write_fmt(&mut self, args: Arguments) -> Result; // 有默认实现,负责解析 {}
}

这两个方法的职责不同:

  • write_str 是唯一必须实现的方法。它拿到的 s 已经是最终要显示的一段文本,方法里只负责把这段文本写出去——它不解析 {},也不涉及参数。“必须实现”的含义就是:想接入这套协议,只需回答一个问题——给你一段文本,你怎么把它写出去?
  • write_fmt 是调用方直接调用的那个方法。Arguments 里装着格式串和各个参数的引用;它已有默认实现,负责按格式串把参数渲染成文本,再一段段交给 write_str。占位符怎么拆、参数怎么转成文本,由它和 core::fmt 的内部例程完成,实现者不必关心。

于是对 Writer 来说,只需要自己提供 write_str——答案就是前面那套 write_string;write_fmt 由默认实现提供。实现只有五行:

1
2
3
4
5
6
7
8
use core::fmt;                                   // no_std 环境用 core,不是 std

impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_string(s); // 唯一必须实现的方法
Ok(()) // 写显存不会失败
}
}

逐点看:

  • impl fmt::Write for Writer 是一句声明:Writer 实现 fmt::Write,下面就得把必选方法补齐。
  • write_str 就是那个必选方法,&mut self 因为要写显存(篇二)。
  • fmt::Result 是 Result<(), fmt::Error> 的别名;Ok(()) 表示写成功了。这里恒为成功,因为往显存写字不会失败。
  • write_fmt 不用写:impl 一完成,Writer 就自动带上了它的默认实现,所以上面那段没有它。
  • core::fmt 而不是 std::fmt:内核是 #![no_std],只有 core(《VGA 字符模式》里提过)。另外 use core::fmt; 写在文件中部——use 可以出现在模块里的任何位置,作用范围都是整个模块(篇一)。

🌍 第八层:lazy_static 与 Mutex——造一个全局 Writer

前面所有方法都要 &mut self,所以得先有 Writer 实例。屏幕只有一块,这个 Writer 也就应该只有一个,而且内核各处都要能拿到——结论是把它做成全局变量。这一层解决的就是怎么造出这个全局变量。

先把这层出场的三个新东西说清楚:

  • static 是 Rust 的全局变量(篇二),但有个硬要求:初值必须在编译期就能算出来。
  • Mutex<T> 是泛型(篇一),读作 一把锁,里面装着一个 T。Mutex<Writer> 就是 装着 Writer 的锁:谁要访问里面的值,先拿锁,同一时刻只有一个持有者。
  • lazy_static! 是宏。宏和函数接收的东西不同:函数收值,宏收代码片段,并在编译期把它展开成别的代码。
1
2
3
4
5
6
7
8
9
10
use lazy_static::lazy_static;
use spin::Mutex;

lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0, // 从行首开始
color_code: ColorCode::new(Color::Yellow, Color::Black), // 黄字黑底
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) }, // 显存地址 → 引用
});
}

这段看起来像 static 声明,其实整块都是 lazy_static! 的输入:它在编译期生成一个真正的全局变量,并把构造 Writer 这件事推迟到第一次访问 WRITER 时执行。三处设计都值得追问一句:

  • 为什么不直接写 static WRITER: Writer = ...:两个障碍。一是初值必须是编译期常量,而这里要把整数地址转成指针再解引用,编译期算不出来;二是 Writer 里带着 &'static mut Buffer,可变的全局变量立刻会违反别名规则(篇二)。
  • 为什么要套 Mutex:&'static mut 不允许出现第二个引用。自旋锁把对 Writer 的访问串成一条线,同一时刻只有一个持有者,也就不会同时存在两个 &mut。用 spin::Mutex 而不是 std::sync::Mutex,是因为后者要靠系统调用,而内核里还没有系统调用可用。
  • pub static ref 里的 ref 是什么:它不是 Rust 关键字,而是 lazy_static! 自己的语法,意思是 WRITER 这个名字背后是一个引用,指向下面那个值——构造被推迟了,所以拿到的不能是值本身。

(顺带一提,lazy_static 在 Cargo.toml 里开了 spin_no_std feature:惰性初始化本身也要一把锁来保证只初始化一次,而裸机环境没有 std 的 Once,所以让它改用 spin。)

再看最后那个 unsafe 表达式:

1
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },

教程习惯把它拆成两步来讲:

1
2
let buffer = 0xb8000 as *mut Buffer;   // ① 整数地址 → 裸指针(篇二:as、*mut)
let writer = unsafe { &mut *buffer }; // ② 裸指针 → 可变引用(篇二:unsafe、*、&mut)

(真实代码里没有这两行,它是内联写在 lazy_static! 里的一条表达式。)

1
2
3
0xb8000            硬件内存地址(VGA 显存起点)
↓ as
*mut Buffer 解释成指向 Buffer 的裸指针——只是一个地址
1
2
3
4
5
6
7
*mut Buffer
↓ *(解引用)
Buffer
↓ &mut(可变借用)
&'static mut Buffer
↓
交给 Writer

组合起来,unsafe { &mut *(0xb8000 as *mut Buffer) } 的意思是:

通过裸指针找到那块显存,把它借成一个可修改的 Rust 引用,交给 Writer 使用。

套 unsafe 是因为编译器无法证明 0xb8000 处真的有一个合法的 Buffer——这是硬件规定的事实,由程序员负责声明。unsafe 只把这道门打开一条缝:&mut 拿到手之后,后续操作仍受类型系统与借用检查约束(篇二)。同一个 unsafe 块还隐含另一个承诺:这块显存只会经由这一个引用访问,不会同时造出别名——这正是它被放进 Mutex 的原因。另外,这个地址能直接解引用,是因为此时 bootloader 已经做好分页映射,让这个虚拟地址正对着物理显存。

🔗 第九层:print! 与 println!——宏与 _print

WRITER 有了,但每次打印都写 WRITER.lock().write_fmt(format_args!(...)) 太啰嗦;而且想写出 println!("x = {}", x) 这种形式,参数个数必须能变——函数做不到:函数签名里的参数个数是固定的,{} 后面跟几个参数得由调用方现场决定。这正是宏的用武之地。

先用一个最小例子看清宏的骨架:

1
2
3
4
5
6
7
macro_rules! twice {          // 定义宏:名字后面带 !
($e:expr) => { // 左边:匹配的样子
$e * 2 // 右边:展开的结果
};
}

twice!(3 + 1) // 展开成 (3 + 1) * 2 → 8

三个符号的读法:

  • macro_rules! 名字 { ... } 里可以有一条或多条规则,每条写成 (匹配的样子) => (展开的结果);
  • $e:expr 是捕获:$e 是名字,expr 说明”这里匹配一个表达式”;匹配到的东西之后用 $e 放回展开结果里
  • $( ... )* 表示重复:括号里的内容可以出现零次或多次

和函数比,三点不同:

函数 宏
参数个数 固定 $( )* 捕获的可以任意多个
收到的是什么 求值之后的值 代码片段(还没求值)
调用写法 f(x) m!(x),名字后面必须带 !

这三点合起来,就是 println! 必须是宏、不能是函数的原因。

宏在编译期接收代码片段并展开成别的代码。下面这两个宏,把 拿锁 → 格式化 → 写显存 包成了一行调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[macro_export]                                  // 导出到 crate 根部,别处也能用
macro_rules! print {
($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
// 任意多个 token tree $crate:指回定义宏的 crate
}

#[macro_export]
macro_rules! println {
() => ($crate::print!("\n")); // 无参数:换行
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*))); // 有参数
}

#[doc(hidden)] // 内部函数,不进文档
pub fn _print(args: fmt::Arguments) {
use core::fmt::Write; // trait 在作用域内才能调用 write_fmt
WRITER.lock().write_fmt(args).unwrap(); // write_str 恒返回 Ok,不会 panic
}

逐点看:

  • macro_rules! print { ... } 定义了一个名叫 print 的宏,#[macro_export] 让它导出,别的模块或 crate 也能用。
  • $($arg:tt)* 是宏的参数表,拆开读:$arg 是一个捕获变量,tt 表示一个 token tree(一个词,或一对括号括起来的整体),$( ... )* 表示重复零次或多次。合起来就是 捕获任意多个 token——所以 println!()、println!("a")、println!("{} {}", a, b) 都落在这条规则里。
  • => 右边是展开结果:把捕获到的片段原样塞进 format_args!。这是编译器内置宏,它就地整理出一份 fmt::Arguments(格式串 + 各参数的引用),全程不分配内存,适合 no_std 内核。
  • $crate 是 定义这个宏的那个 crate 的占位符(篇一)。宏导出后可能在别的 crate 里展开,那时写死 crate::vga_buffer::_print 会解析到调用方去,用 $crate 才能保证路径指回这里。
  • println! 有两条规则:无参数时展开成 print!("\n"),有参数时补一个换行再转交 print!。宏规则自上而下匹配,两条不会打架。
  • _print 是普通函数,负责收尾:#[doc(hidden)] 只表示它不进文档,但仍必须是 pub——宏在别处展开时得能调到它。函数体第一行 use core::fmt::Write; 是把 trait 带进作用域,因为 write_fmt 是 trait 方法(第七层);WRITER.lock() 拿到锁的守卫 MutexGuard,write_fmt 拆出的一段段文本最终落到 write_str;unwrap() 不会 panic,因为 write_str 恒返回 Ok。

🧭 完整链路

把整条链画出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
println!("Hello")
↓ 展开为 print!("{}\n", format_args!("Hello"))
fmt::Arguments
↓ _print
WRITER.lock() → MutexGuard<Writer>
↓ write_fmt(fmt::Write 的默认实现)
Writer::write_str
↓ write_string
s.bytes() 逐个字节
↓ write_byte
Volatile::write(ScreenChar { ... })
↓ 一次真实的内存写
0xb8000 处对应的格子
↓ VGA 硬件读取这块内存
屏幕

上半段(从宏到锁)是 Rust 的抽象层,下半段(从 Volatile::write 到屏幕)是硬件事实。整篇文章拆的就是这两段之间的接缝。

🧅 学习方法:Rust 代码可以剥洋葱

看到类似的表达式不要当成一整坨语法,而是从右往左一层层拆:

1
unsafe { &mut *(0xb8000 as *mut Buffer) }
1
2
3
4
5
unsafe            这里有 Rust 无法自动证明安全的操作
0xb8000 一个整数地址
as *mut Buffer 把地址解释成 Buffer 的裸指针
* 解引用,找到 Buffer
&mut 把 Buffer 借成可修改引用

Rust 代码几乎都可以这样拆开读。学 Rust OS 时,最值得先打通的主线就一条:

对象 → 所有权 → 引用 → 借用 → &mut → 裸指针 → unsafe → 生命周期

这条线弄明白后,后面 Writer、Volatile、lazy_static!、spin::Mutex、中断处理这些模块,基本都围绕同一套思想展开——只是包装越来越厚。这篇文章从第一层拆到第九层也是同一个道理:每一层单独看都不难,难的是把它们串成一条从 println! 到显存的通路。

✅ 总结

VGA 的 Writer 把前三篇的语法串成了一条完整链路:repr(u8) 的 Color 与 newtype ColorCode 把颜色压进一个字节 → repr(C) 的 ScreenChar 描述一个格子 → Volatile 包装的 Buffer 描述整块显存 → 整数地址经 as 变裸指针 → unsafe 加解引用变成 &'static mut 引用 → spin::Mutex 与 lazy_static! 把它变成全局唯一实例 → fmt::Write、print!、println! 一层层包装成安全接口。读这类代码的关键是剥洋葱:一层层拆开看每一步做了什么。

Happy Hacking! 🎉