使用Rust编写操作系统 - 1.3 - VGA文本模式
VGA文本模式是一种简单的将文本打印到屏幕上的方法。在这篇文章中,我们将创建一个接口,通过将所有的非安全代码封装在一个单独的模块中,使其使用变得安全和简单。我们还将实现对Rust中格式化宏的支持。
这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-03分支中找到。
VGA文本缓冲区
在VGA文本模式下,要想把一个字符打印到屏幕上,则需要把它写入VGA硬件的文本缓冲区。VGA文本缓冲区是一个二维数组,通常有25行80列,将直接渲染在屏幕上。每个数组元素通过以下格式描述一个屏幕字符:
位 | 值 |
---|---|
0-7 | ASCII 码点 |
8-11 | 前景色 |
12-14 | 背景色 |
15 | 闪烁 |
第一个字节代表应该用ASCII编码打印的字符。准确地说,它并不完全是ASCII码,而是一个名为代码页437的字符集,并添加了一些额外的字符和轻微的修改。为了简单起见,我们在这篇文章中继续称它为ASCII字符。
第二个字节定义了字符的显示方式。前四位定义前景色,后三位定义背景色,最后一位定义字符是否应该闪烁。以下是可用的颜色:
代码 | 颜色 | 代码 + 高亮位 | 高亮色 |
---|---|---|---|
0x0 | Black | 0x8 | Dark Gray |
0x1 | Blue | 0x9 | Light Blue |
0x2 | Green | 0xa | Light Green |
0x3 | Cyan | 0xb | Light Cyan |
0x4 | Red | 0xc | Light Red |
0x5 | Magenta | 0xd | Pink |
0x6 | Brown | 0xe | Yellow |
0x7 | Light Gray | 0xf | White |
第4位是高亮位,例如它能将蓝色高亮变成了浅蓝色。对于背景色,该位被重新用作闪烁位。
VGA文本缓冲区可以通过内存映射I/O访问地址0xb8000
。这意味着对该地址的读写不访问RAM,而是直接访问VGA硬件上的文本缓冲区。这意味着我们可以通过正常的内存操作对该地址进行读写。
需要注意的是,内存映射的硬件可能不支持所有正常的RAM操作。例如,一个设备可能只支持按字节读取,当读取一个u64
时,就会返回垃圾。幸运的是,文本缓冲区支持正常读写,所以我们不必以特殊的方式对待它。
编写Rust模块
现在我们知道了VGA缓冲区的工作原理,我们可以创建一个Rust模块来处理打印。
1 | mod vga_buffer; |
我们创建一个新文件src/vga_buffer.rs
来编写这个模块。下面所有的代码都会在我们的新模块中编写(除非另有说明)。
颜色
首先,我们用一个枚举来表示不同的颜色:
1 |
|
我们在这里使用一个C型枚举来显式地指定每种颜色的值。由于repr(u8)属性,每个枚举变量都存储为u8
。其实4位就够了,但Rust没有u4
类型。
通常编译器会对每个未使用的变量发出警告。通过使用#[allow(dead_code)]
属性,我们可以禁用Color
枚举的这些警告。
通过派生Copy
、Clone
、Debug
、PartialEq
和Eq
五个trait,我们实现了类型的复制语义,并使其可打印、可比较。
为了表示指定前景色和背景色的全部色码,我们在u8
的基础上创建一个新类型:
1 |
|
ColorCode结构体包含完整的颜色字节——前景色和背景色。像之前一样,我们为它派生出Copy
和Debug
特征。为了确保ColorCode
的数据类型布局与u8
完全相同,我们使用repr(transparent)
属性。
(译者注:关于repr(transparent)
,可以参考repr_transparent
的解释。)
文本缓冲区
现在我们可以添加结构体来表示屏幕字符和文本缓冲区了:
1 |
|
由于Rust默认布局下结构体中的字段没有顺序,所以我们需要repr(C)
属性。它可以保证Rust结构体的字段布局和C结构体中的字段完全一样,从而保证字段排序的正确性。对于Buffer结构,我们再次使用repr(transparent)
来保证它的内存布局与其中的单字段相同。
为了实际写到屏幕上,我们现在创建一个写类型:
1 | pub struct Writer { |
写的方式总是写到最后一行,当一行满了的时候(或者遇到\n
),就会把行数往上移。column_position
字段会跟踪最后一行的实时位置;color_code
指定当前的前景色和背景色;buffer
中存储一个VGA缓冲区的引用。需要注意的是,我们在这里需要指定一个显式生命周期来告诉编译器这个引用的有效期是多久。'static
生命周期指定了引用在整个程序运行时间内都是有效的(这对VGA文本缓冲区来说是事实)。
打印
现在我们可以使用Writer
来修改缓冲区的字符。首先我们创建一个方法来写入一个ASCII字节:
1 | impl Writer { |
如果这个字节是换行字节\n
,那么writer就不会打印任何内容,而是调用new_line
方法,这个方法我们将在后面实现。在第二种匹配情况下,其他字节会被打印到屏幕上。
在打印字节时,writer会检查当前的行是否已满。若当前行已满,需要先调用new_line
来结束这一行。然后,它将一个新的ScreenChar
写入当前位置的缓冲区。最后,将当前列的位置前进一个字符。
要打印整个字符串,我们可以将它们转换成字节,然后逐一打印:
1 | impl Writer { |
VGA文本缓冲区只支持ASCII字符和代码页437的附加字符。Rust字符串默认为UTF-8
,所以它们可能包含VGA文本缓冲区不支持的字节。我们使用匹配来区分可打印的ASCII字节(换行或空格符和~
字符之间的任何字符)和不可打印的字节。对于不可打印的字节,我们打印一个■
字符,它在VGA硬件上的十六进制代码为0xfe
。
试试吧
要在屏幕上写一些字符,可以创建一个临时函数:
1 | pub fn print_something() { |
函数首先创建一个新的Writer,指向位于0xb8000
的VGA缓冲区,这里的语法看起来可能有点奇怪。首先,我们把整数0xb8000
作为一个可变的裸指针。然后我们通过解引用(使用*
运算符)将其转换为可变引用,并立即再次借用(通过&mut
)。这种转换需要一个unsafe
块,因为编译器不能保证裸指针是有效的。
然后它将字节b'H'
写入其中。b
前缀创建了一个文字字节,它代表一个ASCII字符。通过写入字符串"ello "
和"Wörld!"
,我们测试了我们的write_string
方法和对不可打印字符的处理。为了看到输出,我们需要从_start
函数中调用print_something
函数:
1 |
|
现在运行项目时,屏幕左下角应该会打印出一个黄色的Hello W■■rld!
。
注意到ö
被打印成两个■
字符。这是因为ö
在UTF-8中由两个字节表示,这两个字节都不属于可打印的ASCII范围。实际上这是UTF-8的一个基本属性:多字节值的单个字节永远不是有效的ASCII码。
易失性操作
我们刚刚看到信息被正确打印出来了。然而,它可能无法与未来更加积极优化的Rust编译器一起工作。
问题在于,是我们只对Buffer
进行写入,而没有再从中读取。编译器不知道我们的确访问了VGA缓冲区内存(而不是正常内存),也不知道一些字符已经出现在屏幕上的额外效果。所以编译器可能决定这些写入是不必要的,可以省略。为了避免这种错误的优化,我们需要将这些写入指定为易失的。这就告诉编译器,这个写有副作用,不应该被优化掉。
为了对VGA缓冲区使用易失性写,我们使用volatile
库。这个crate(在Rust世界中,包是这样称呼的)提供了一个带有read
和write
方法的Volatile
包装类型。这些方法在内部使用了核心库的read_volatile
和write_volatile
函数,从而保证了读/写操作不会被优化掉。
我们可以通过在Cargo.toml
的dependencies
部分添加一个对volatile
crate的依赖:
1 | [dependencies] |
确保指定volatile
版本为0.2.6
。新版本的crate不兼容本文。0.2.6是语义版本号。更多信息,请参见cargo文档的指定依赖指南。
让我们用它来进行VGA缓冲区的易失性写入,更新的Buffer类型如下:
1 | use volatile::Volatile; |
我们现在使用Volatile<ScreenChar>
来代替ScreenChar
,(Volatile
是泛型,几乎可以包装任何类型)。这确保了我们不能通过“普通”的写方法(译者注:如赋值)意外地写入到它。相反,我们现在必须调用write
方法。
这意味着我们必须更新Writer::write_byte
方法:
1 | impl Writer { |
我们现在使用的是write
方法,而不是使用=
的普通赋值。这样可以保证编译器永远不会优化掉这个写操作。
格式化宏
如果能支持Rust的格式化宏就更好了。这样一来,我们就可以轻松地打印不同的类型,如整数或浮点数。接下来,我们需要实现core::fmt::Write
trait。这个trait唯一需要实现方法是write_str
,它看起来和我们的write_string
方法很相似,只不过返回类型为fmt::Result
:
1 | use core::fmt; |
Ok(())
只是一个包含()
类型的Ok
Result。
现在我们可以使用Rust内置的write!
/writeln!
格式化宏了:
1 | pub fn print_something() { |
现在你应该在屏幕底部看到Hello! The numbers are 42 and 0.3333333333333333
。write!
会返回一个Result
,如果不使用会引起警告,所以我们在上面调用unwrap
函数,如果发生错误,它会panic。现阶段并没有这种问题,因为对VGA缓冲区的写入不会失败。
换行
截至上一节,我们都忽略了换行或字符超出一行容量的情况。遇到这种情况时,我们希望将每个字符向上移动一行(最上面的一行被删除),然后从最后一行的行首重新开始。要做到这一点,我们为Writer
的new_line
方法添加一个实现:
1 | impl Writer { |
我们对所有屏幕字符进行迭代,并将每个字符向上移动一行。请注意,区间范围(..
)是前开后闭的。此外,我们也不迭代第0
行(第一个循环从1
开始),因为它是被移出屏幕的那一行。
为了完成换行代码,我们再添加clear_row
方法:
1 | impl Writer { |
这个方法通过用空格字符覆盖所有字符来清除一行。
全局接口
提供一个全局Writer
作为接口,可以使其他模块免去自己实例化Writer
的麻烦,我们试着创建一个静态变量WRITER
:
1 | pub static WRITER: Writer = Writer { |
但是,如果我们现在尝试编译它,会出现以下错误:
1 | error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants |
为了理解这里发生的事情,我们需要知道,与普通变量在运行时初始化不同的是,静态变量是在编译时初始化的。Rust编译器中对此类初始化表达式进行求值的组件叫做 “常量求值器”。虽然它的功能现在还仍然有限,不过对其功能的扩展工作也在进行中,例如RFC“允许常量中的panic”。
关于ColorCode::new
的问题可以利用const
函数来解决,但这里的根本问题是Rust的常量求值器无法在编译时将原始指针转换为引用。也许未来的某一天将会支持该功能,但在那之前,我们必须找到另一个解决方案。
惰性静态变量
用非常函数一次性初始化静态变量是Rust中常见的问题。幸运的是,在一个名为lazy_static的crate中已经存在一个很好的解决方案。这个crate提供了一个lazy_static!
宏,定义了一个惰性初始化的static
。static
在编译时不计算其的值,而是在第一次被访问时进行惰性初始化。因此,初始化发生在运行时,这使得各种复杂初始化代码成为可能。
让我们在项目中添加lazy_static
crate。
1 | [dependencies.lazy_static] |
我们需要spin_no_std
特性,因为我们并没有链接标准库。
有了lazy_static
,我们便可以定义静态WRITER
:
1 | use lazy_static::lazy_static; |
然而,这个WRITER
并没有什么用,因为它是不可变的,这意味着我们不能向它写任何东西(因为所有的写方法都需要获取&mut self
)。一个可能的解决方案是使用一个可变静态变量。但是这样一来,对它的每一次读写都将是不安全的,因为这样很容易引入数据竞争和其他不好的东西。使用static mut
是非常不推荐的,以至于有人提议移除这一特性。那么,有没有什么替代方案呢?我们可以尝试使用一个不可变的静态变量加上诸如RefCell
甚至UnsafeCell
,以提供内部可突变性。但是这些类型不具有Sync
特性(有充分的理由),所以我们不能在静态变量中使用它们。
自旋锁
为了获得同步的(即具有Sync
trait的)内部可变性,标准库的用户可以使用互斥锁Mutex
。它在资源已经被锁定的情况下,通过阻塞线程来提供线程间互斥。但是我们的基本内核没有任何阻塞支持,甚至没有线程的概念,所以我们也无法使用它。然而在计算机科学中,有一种非常基础的互斥,它不需要操作系统的功能:自旋锁。线程不进行阻塞,而只是在一个循环中不停的尝试锁定它,从而消耗CPU时间,直到锁再次释放。
要使自旋锁,我们需要添加spin crate作为依赖:
1 | [dependencies] |
于是,我们可以使用自旋锁来为我们的静态WRITER添加安全的内部可变性。
1 | use spin::Mutex; |
现在我们可以删除临时函数print_something
,直接从_start
函数中打印:
1 |
|
我们需要导入fmt::Write
trait来使用它的功能。
安全性
请注意,在我们的代码中,我们只有一个不安全的块,即需要创建一个指向0xb8000
的裸指针作为Buffer
并进行可变引用。之后,所有的操作都是安全的。Rust默认对数组访问使用边界检查,所以我们不能意外地写到缓冲区之外。因此,我们在类型系统中对所需的条件进行了编码,并且能够向外部提供一个安全的接口。
println
打印宏
现在我们有了一个全局writer,可以再添加一个println
宏,使其可以在代码库的任何地方使用。Rust的宏语法有点奇怪,所以我们不会尝试从头开始写一个宏。让我们先看看标准库中println!
宏的源码:
1 |
|
宏是通过一个或多个规则来定义的,这些规则类似于match
的匹配分支。println
宏有两个规则:第一条规则是没有参数的调用(例如println!()
), 它被扩展为print!("\n")
, 因此只是换行。第二条规则是有参数的调用,例如println!("Hello")
或println!("Number: {}", 4)
。它也是扩展为调用print!
宏,传递所有参数,并在结尾处附加一个换行符\n
。
#[macro_export]
属性使宏可以被整个crate(而不仅仅是其定义所在的模块)和外部crate所使用。它还将宏置于crate根,这意味着我们必须通过使用std::println
而不是std::macros::println
来进行导入。
而print!
宏的定义为:
1 |
|
该宏扩展为调用io
模块中的_print
函数。$crate
变量保证了这个宏在其他模块中使用时,通过扩展到std
的方式,也能在std
模块之外工作。
format_args
宏使用其中传递的参数中新建一个fmt::Arguments
类型,并传递给_print
。libstd的_print
函数调用print_to
,这个函数相当复杂,因为它支持不同的Stdout
设备。我们并不需要那么复杂,因为我们只是想打印到VGA缓冲区。
要打印到VGA缓冲区,我们只需复制println!
和print!
宏,并修改它们以使用我们自己的_print
函数:
1 |
|
我们对原来的println
定义做了一个改动,就是在调用print!
宏的时候也添加$crate
前缀。这确保了如果我们只想使用println
时,不需要再导入print!
宏。
像标准库中的实现一样,我们为这两个宏都添加了#[macro_export]
属性,使它们在我们的crate中随处可用。请注意,此举将宏放在crate的根命名空间中,所以通过使用crate::vga_buffer::println
导入它们是行不通的。相反,我们必须使用crate::println
。
_print
函数获得静态变量WRITER
的锁,并对其调用write_fmt
方法。这个方法来自Write
trait,我们需要导入那个trait。如果打印不成功,结尾的unwrap()
就会panic。但是由于我们总是在write_str
中返回Ok
,所以这种情况应该不会发生。
由于宏需要能够从模块外部调用_print
,所以该函数必须是公共的。但是,由于我们认为这是一个私有的实现细节,所以我们添加了doc(hidden)属性,以在生成的文档中因此该函数的说明。
使用println
打印Hello World
现在我们可以在_start
函数中使用println
:
1 |
|
请注意,我们不必在main函数中导入宏,因为它已经存在于根命名空间中。
正如预期的那样,我们现在看到屏幕上出现了 “Hello World!”:
打印panic信息
现在我们已经有了一个println
宏,我们可以在我们的panic函数中使用它来打印panic信息和panic的位置:
1 | /// 函数在panic时被调用 |
当我们现在在_start
函数中插入panic!("Some panic message");
时,就会得到以下输出:
于是,我们不仅知道产生了panic,还知道panic信息以及它发生在代码的什么地方。
小结
在这篇文章中,我们了解了VGA文本缓冲区的结构,以及如何通过地址0xb8000
的内存映射进行写入。我们创建了一个Rust模块,封装了向这个内存映射缓冲区写入数据的非安全操作,并向外部提供了一个安全便捷的接口。
我们也看到了添加第三方库依赖关系是多么的简单,这要感谢cargo工具。我们添加的两个依赖,lazy_static
和spin
,这两个库在操作系统开发中非常有用,我们会在以后的文章中将有更多的地方用到它们。
下期预告
下一篇文章将解释如何设置Rust的内置单元测试框架,然后我们将为这篇文章中的VGA缓冲模块创建一些基本的单元测试。
支持本项目
创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。
支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有Patreon和Donorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。
感谢您的支持!