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 | // 有些颜色用不到,关掉警告 |
#[repr(u8)]
是这里的枢纽:没有它,枚举的底层类型不由你决定,Color::Yellow as u8
拿到的值也不可控。ColorCode
那层包装解决另一个问题——给一个字节起个有语义的名字(篇一:newtype),同时靠
#[repr(transparent)]
保证它真的还只是一个字节,能直接塞进下一层的 struct 里。
1 | ColorCode::new(Yellow, Black) |
🧱 第二层:ScreenChar 与 Buffer——一个格子与整块屏幕
1 |
|
Volatile 是这一层唯一需要展开的地方。显存是内存映射
I/O(MMIO),它的内容会被硬件改动,而编译器看不到这件事;没有这层包装,编译器可能把连续的多次写合并掉,也可能认为刚写进去的值还在寄存器里、读取时不必回内存。第五层的
new_line
要整行读出来再写回去,正是这种读写都必须真的落在内存上的场景。Volatile<T>
自身也是 #[repr(transparent)] 包装,所以
Buffer 在内存里仍然严格是 25 × 80 × 2
字节,和显存逐格对应。
1 | Buffer = 25 × 80 个格子,每格 2 字节 |
🖋️ 第三层:Writer——写字的人
1 | pub struct Writer { // pub:模块外可见(篇一) |
三个字段凑起来就是一句话:光标在哪、用什么颜色、往哪写。后面所有方法都只是围绕这三件事操作。
✍️ 第四层:write_byte——往显存写一个字符
1 | pub fn write_byte(&mut self, byte: u8) { |
&mut self 是必需的:写一个字符要同时改
column_position 和显存内容,属于可变借用(篇二)。另外留意
row
固定取最后一行——这一版实现里光标不高亮也不移动,始终停在末行,写满一行就把整屏上滚,这也是第五层存在的理由。
📜 第五层:new_line 与 clear_row——屏幕滚动
1 | fn new_line(&mut self) { |
这段的语义是
把显存里的内容搬到显存里的另一个位置,所以它必须用
volatile
读写:只要中间某次读被缓存进寄存器,搬过去的就可能是旧值。另外这两个方法都没有
pub,滚动和清行是内部细节,只由 write_byte
调用(篇一:pub、私有)。
1 | new_line():整体上滚一行 |
🔤 第六层:write_string——从字符串到字节
1 | pub fn write_string(&mut self, s: &str) { |
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 | // core::fmt::Write 的简化示意,不是源码 |
这两个方法的职责不同:
write_str是唯一必须实现的方法。它拿到的s已经是最终要显示的一段文本,方法里只负责把这段文本写出去——它不解析{},也不涉及参数。“必须实现”的含义就是:想接入这套协议,只需回答一个问题——给你一段文本,你怎么把它写出去?write_fmt是调用方直接调用的那个方法。Arguments里装着格式串和各个参数的引用;它已有默认实现,负责按格式串把参数渲染成文本,再一段段交给write_str。占位符怎么拆、参数怎么转成文本,由它和core::fmt的内部例程完成,实现者不必关心。
于是对 Writer 来说,只需要自己提供
write_str——答案就是前面那套
write_string;write_fmt
由默认实现提供。实现只有五行:
1 | use core::fmt; // no_std 环境用 core,不是 std |
逐点看:
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 | use lazy_static::lazy_static; |
这段看起来像 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 | let buffer = 0xb8000 as *mut Buffer; // ① 整数地址 → 裸指针(篇二:as、*mut) |
(真实代码里没有这两行,它是内联写在 lazy_static!
里的一条表达式。)
1 | 0xb8000 硬件内存地址(VGA 显存起点) |
1 | *mut Buffer |
组合起来,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 | macro_rules! twice { // 定义宏:名字后面带 ! |
三个符号的读法:
macro_rules! 名字 { ... }里可以有一条或多条规则,每条写成(匹配的样子) => (展开的结果);$e:expr是捕获:$e是名字,expr说明”这里匹配一个表达式”;匹配到的东西之后用$e放回展开结果里$( ... )*表示重复:括号里的内容可以出现零次或多次
和函数比,三点不同:
| 函数 | 宏 | |
|---|---|---|
| 参数个数 | 固定 | $( )* 捕获的可以任意多个 |
| 收到的是什么 | 求值之后的值 | 代码片段(还没求值) |
| 调用写法 | f(x) |
m!(x),名字后面必须带 ! |
这三点合起来,就是 println!
必须是宏、不能是函数的原因。
宏在编译期接收代码片段并展开成别的代码。下面这两个宏,把 拿锁 → 格式化 → 写显存 包成了一行调用:
1 | // 导出到 crate 根部,别处也能用 |
逐点看:
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 | println!("Hello") |
上半段(从宏到锁)是 Rust 的抽象层,下半段(从
Volatile::write
到屏幕)是硬件事实。整篇文章拆的就是这两段之间的接缝。
🧅 学习方法:Rust 代码可以剥洋葱
看到类似的表达式不要当成一整坨语法,而是从右往左一层层拆:
1 | unsafe { &mut *(0xb8000 as *mut Buffer) } |
1 | unsafe 这里有 Rust 无法自动证明安全的操作 |
Rust 代码几乎都可以这样拆开读。学 Rust OS 时,最值得先打通的主线就一条:
对象 → 所有权 → 引用 → 借用 →
&mut→ 裸指针 →unsafe→ 生命周期
这条线弄明白后,后面
Writer、Volatile、lazy_static!、spin::Mutex、中断处理这些模块,基本都围绕同一套思想展开——只是包装越来越厚。这篇文章从第一层拆到第九层也是同一个道理:每一层单独看都不难,难的是把它们串成一条从
println! 到显存的通路。
✅ 总结
VGA 的
Writer把前三篇的语法串成了一条完整链路:repr(u8)的Color与 newtypeColorCode把颜色压进一个字节 →repr(C)的ScreenChar描述一个格子 →Volatile包装的Buffer描述整块显存 → 整数地址经as变裸指针 →unsafe加解引用变成&'static mut引用 →spin::Mutex与lazy_static!把它变成全局唯一实例 →fmt::Write、print!、println!一层层包装成安全接口。读这类代码的关键是剥洋葱:一层层拆开看每一步做了什么。
Happy Hacking! 🎉