操作系统原理实验(2)实现 println 宏

这篇博客详细介绍了如何在Rust中实现VGA字符缓冲区,创建一个用于打印的Rust模块,以及实现println!宏。实验涵盖了内存映射、易失操作、格式化输出、自旋锁和全局接口等内容,最终实现了在操作系统中安全地打印和清屏功能。
摘要由CSDN通过智能技术生成

实验主要内容

VGA字符缓冲区

为了在VGA字符模式向屏幕打印字符,我们必须将它写入硬件提供的VGA字符缓冲区(VGA text buffer)。通常状况下,VGA字符缓冲区是一个25行、80列的二维数组,它的内容将被实时渲染到屏幕。这个数组的元素被称作字符单元(character cell),它使用下面的格式描述一个屏幕上的字符:
在这里插入图片描述
其中,前景色(foreground color)和背景色(background color)取值范围如下:
在这里插入图片描述
每个颜色的第四位称为加亮位(bright bit)。
要修改VGA字符缓冲区,我们可以通过存储器映射输入输出的方式,读取或写入地址0xb8000;这意味着,我们可以像操作普通的内存区域一样操作这个地址。

包装到Rust模块

既然我们已经知道VGA文字缓冲区如何工作,也是时候创建一个Rust模块来处理文字打印了。我们输入这样的代码:
在这里插入图片描述
这行代码定义了一个Rust模块,它的内容应当保存在src/vga_buffer.rs文件中。使用2018版次的Rust时,我们可以把模块的子模块(submodule)文件直接保存到src/vga_buffer/文件夹下,与vga_buffer.rs文件共存,而无需创建一个mod.rs文件。
我们的模块暂时不需要添加子模块,所以我们将它创建为src/vga_buffer.rs文件。
在这里插入图片描述
颜色
首先,我们使用Rust的枚举(enum)表示一种颜色:
在这里插入图片描述
我们使用类似于C语言的枚举(C-like enum),为每个颜色明确指定一个数字。在这里,每个用repr(u8)注记标注的枚举类型,都会以一个u8的形式存储——事实上4个二进制位就足够了,但Rust语言并不提供u4类型。
通常来说,编译器会对每个未使用的变量发出警告(warning);使用#[allow(dead_code)],我们可以对Color枚举类型禁用这个警告。
我们还生成(derive)了 Copy、Clone、Debug、PartialEq和Eq 这几个trait(特性):这让我们的类型遵循复制语义(copy semantics),也让它可以被比较、被调试打印。
为了描述包含前景色和背景色的、完整的颜色代码(color code),我们基于u8创建一个新类型:

在这里插入图片描述
这里,ColorCode类型包装了一个完整的颜色代码字节,它包含前景色和背景色信息。和Color类型类似,我们为它生成Copy和Debug等一系列trait。为了确保ColorCode和u8有完全相同的内存布局,我们添加repr(transparent)标记。

字符缓冲区

现在,我们可以添加更多的结构体,来描述屏幕上的字符和整个字符缓冲区:
在这里插入图片描述
在内存布局层面,Rust并不保证按顺序布局成员变量。因此,我们需要使用#[repr©]标记结构体;这将按C语言约定的顺序布局它的成员变量,让我们能正确地映射内存片段。对Buffer类型,我们再次使用repr(transparent),来确保类型和它的单个成员有相同的内存布局
为了输出字符到屏幕,我们来创建一个Writer类型:
在这里插入图片描述
我们将让这个Writer类型将字符写入屏幕的最后一行,并在一行写满或收到换行符\n的时候,将所有的字符向上位移一行
column_position变量将跟踪光标在最后一行的位置。当前字符的前景和背景色将由color_code变量指定;另外,我们存入一个VGA字符缓冲区的可变借用到buffer变量中。需要注意的是,这里我们对借用使用显式生命周期,告诉编译器这个借用在何时有效:我们使用’static生命周期,意味着这个借用应该在整个程序的运行期间有效;这对一个全局有效的VGA字符缓冲区来说,是非常合理的。

打印字符

现在我们可以使用Writer类型来更改缓冲区内的字符了。首先,为了写入一个ASCII码字节,我们创建这样的函数
在这里插入图片描述
如果这个字节是一个换行符字节\n,我们的Writer不应该打印新字符,相反,它将调用我们稍后会实现的new_line方法;其它的字节应该将在match语句的第二个分支中被打印到屏幕上。
当打印字节时,Writer将检查当前行是否已满。如果已满,它将首先调用new_line方法来将这一行字向上提升,再将一个新的ScreenChar写入到缓冲区,最终将当前的光标位置前进一位。
要打印整个字符串,我们把它转换为字节并依次输出
在这里插入图片描述
VGA字符缓冲区只支持ASCII码字节和代码页437定义的字节。Rust语言的字符串默认编码为UTF-8,也因此可能包含一些VGA字符缓冲区不支持的字节:我们使用match语句,来区别可打印的ASCII码或换行字节,和其它不可打印的字节。对每个不可打印的字节,我们打印一个■符号;这个符号在VGA硬件中被编码为十六进制的0xfe。
我们可以亲自试一试已经编写的代码。为了这样做,我们可以临时编写一个函数:
在这里插入图片描述
问题:编译错误,缺少函数
在这里插入图片描述
在main.rs中加入handle函数,即上个实验的这部分不应该删除
在这里插入图片描述
问题:编译错误
在这里插入图片描述
找不到Panicinfo函数,由于复制main.rs时把这些都删除了,导致出现了错误,所以需要补上
在这里插入图片描述
在这里插入图片描述
成功编译
print_something这个函数首先创建一个指向0xb8000地址VGA缓冲区的Writer。实现这一点,:首先,我们把整数0xb8000强制转换为一个可变的裸指针;之后,通过运算符*,我们将这个裸指针解引用;最后,我们再通过&mut,再次获得它的可变借用。这些转换需要unsafe语句块,因为编译器并不能保证这个裸指针是有效的。
然后它将字节 b’H’ 写入缓冲区内. 前缀 b创建了一个字节常量,表示单个ASCII码字符;通过尝试写入 "ello " 和 “Wörld!”,我们可以测试 write_string 方法和其后对无法打印字符的处理逻辑。为了观察输出,我们需要在_start函数中调用print_something方法:
在这里插入图片描述
编译运行后,黄色的Hello W■■rld!字符串将会被打印在屏幕的左下角:
在这里插入图片描述
因为我是先编译的,所以使用了较多的指令,这些指令只需要一句cargo xrun即可以实现
如果编译时正确的话
在这里插入图片描述
需要注意的是,ö字符被打印为两个■字符。这是因为在UTF-8编码下,字符ö是由两个字节表述的——而这两个字节并不处在可打印的ASCII码字节范围之内。事实上,这是UTF-8编码的基本特点之一:如果一个字符占用多个字节,那么每个组成它的独立字节都不是有效的ASCII码字节。

易失操作

我们刚才看到,自己想要输出的信息被正确地打印到屏幕上。
产生问题的原因在于,我们只向Buffer写入,却不再从它读出数据。此时,编译器不知道我们事实上已经在操作VGA缓冲区内存,而不是在操作普通的RAM——因此也不知道产生的副效应(side effect),即会有几个字符显示在屏幕上。这时,编译器也许会认为这些写入操作都没有必要,甚至会选择忽略这些操作!
为了避免这些并不正确的优化,这些写入操作应当被指定为易失操作。这将告诉编译器,这些写入可能会产生副效应,不应该被优化掉。
为了在我们的VGA缓冲区中使用易失的写入操作,我们使用volatile库。这个包(crate)提供一个名为Volatile的包装类型(wrapping type)和它的read、write方法;这些方法包装了core::ptr内的read_volatile和write_volatile 函数,从而保证读操作或写操作不会被编译器优化。
要添加volatile包为项目的依赖项(dependency),我们可以在Cargo.toml文件的dependencies中添加下面的代码:
在这里插入图片描述
0.2.3表示一个语义版本号
现在,我们使用它来完成VGA缓冲区的volatile写入操作。我们将Buffer类型的定义修改为下列代码:
在这里插入图片描述
在这里,我们不使用ScreenChar,而选择使用Volatile——在这里,Volatile类型是一个泛型(generic),可以包装几乎所有的类型——这确保了我们不会通过普通的写入操作,意外地向它写入数据;我们转而使用提供的write方法。
这意味着,我们必须要修改我们的Writer::write_byte方法:
在这里插入图片描述
正如代码所示,我们不再使用普通的=赋值,而使用了write方法:这能确保编译器不再优化这个写入操作。

格式化宏

支持Rust提供的格式化宏也是一个相当棒的主意。通过这种途径,我们可以轻松地打印不同类型的变量,如整数或浮点数。为了支持它们,我们需要实现core::fmt::Write trait(特性);要实现它,唯一需要提供的方法是write_str,它和我们先前编写的write_string方法差别不大,只是返回值类型变成了fmt::Result:
在这里插入图片描述
这里,Ok(())属于Result枚举类型中的Ok,包含一个值为()的变量。
现在我们就可以使用Rust内置的格式化宏write!和writeln!了:
在这里插入图片描述
在这里插入图片描述
问题:“…”不正确
在这里插入图片描述
修改writer函数,将省略的部分进行修补
在这里插入图片描述
问题:
对new_line函数重复定义
对clear_row函数重复定义

在这里插入图片描述
在这里插入图片描述
将重复的new_line函数和clear_row函数注释掉
在这里插入图片描述
在这里插入图片描述
编译运行一下
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
以上三行代码指令可以用cargo xrun一条指令代替
在这里插入图片描述
在这里插入图片描述
现在,你应该在屏幕下端看到一串Hello! The numbers are 42 and 0.3333333333333333。write!宏返回的Result类型必须被使用,所以我们调用它的unwrap方法,它将在错误发生时panic。这里的情况下应该不会发生这样的问题,因为写入VGA字符缓冲区并没有可能失败。

换行

当换行时,我们想要把每个字符向上移动一行——此时最顶上的一行将被删除——然后在最后一行的起始位置继续打印。要做到这一点,我们要为Writer实现一个新的new_line方法:
在这里插入图片描述
问题:编译错误。对于new_line函数有一个重复定义
在这里插入图片描述
在这里插入图片描述
如上图所示,即可以解决

我们遍历每个屏幕上的字符,把每个字符移动到它上方一行的相应位置。这里,…符号是区间标号的一种;它表示左闭右开的区间,因此不包含它的上界。在外层的枚举中,我们从第1行开始,省略了对第0行的枚举过程——因为这一行应该被移出屏幕,即它将被下一行的字符覆写。
所以我们实现的clear_row方法代码如下:

在这里插入图片描述
通过向对应的缓冲区写入空格字符,这个方法能清空一整行的字符位置。

全局接口

编写其它模块时,我们希望无需随身携带Writer实例,便能使用它的方法。我们尝试创建一个静态的WRITER变量:
在这里插入图片描述
问题:我们尝试编译这些代码,却发生了下面的编译错误:
在这里插入图片描述
解决办法:具体是将如下的代码加入到toml文件中
在这里插入图片描述
一般的变量在运行时初始化,而静态变量在编译时初始化。
Rust编译器规定了一个称为常量求值器的组件,它在编译时处理这样的初始化工作。

延迟初始化

使用非常函数初始化静态变量是Rust程序员普遍遇到的问题。幸运的是,有一个叫做lazy_static的包提供了一个很棒的解决方案:它提供了名为lazy_static!的宏,定义了一个延迟初始化(lazily initialized)的静态变量;这个变量的值将在第一次使用时计算,而非在编译时计算。这时,变量的初始化过程将在运行时执行,任意的初始化代码——无论简单或复杂——都是能够使用的。
现在,我们将lazy_static包导入到我们的项目:
在这里插入图片描述
在这里,由于程序不连接标准库,我们需要启用spin_no_std特性。
使用lazy_static我们就可以定义一个不出问题的WRITER变量:
在这里插入图片描述
这个WRITER没有什么用途,因为它目前还是不可变变量:这意味着我们无法向它写入数据,因为所有与写入数据相关的方法都需要实例的可变引用&mut self。

自旋锁

要定义同步的内部可变性,我们往往使用标准库提供的互斥锁类Mutex,它通过提供当资源被占用时将线程阻塞(block)的互斥条件(mutual exclusion)实现这一点;但我们初步的内核代码还没有线程和阻塞的概念,我们将不能使用这个类。不过,我们还有一种较为基础的互斥锁实现方式——自旋锁。自旋锁并不会调用阻塞逻辑,而是在一个小的无限循环中反复尝试获得这个锁,也因此会一直占用CPU时间,直到互斥锁被它的占用者释放。
为了使用自旋的互斥锁,我们添加spin包到项目的依赖项列表:
在这里插入图片描述
现在,我们能够使用自旋的互斥锁,为我们的WRITER类实现安全的内部可变性:
在这里插入图片描述
现在我们可以删除print_something函数,尝试直接在_start函数中打印字符:
在这里插入图片描述
在这里,我们需要导入名为fmt::Write的trait,来使用实现它的类的相应方法。

安全性

经过努力后,我们现在的代码只剩一个unsafe语句块,它用于创建一个指向0xb8000地址的Buffer类型引用;在这步之后,所有的操作都是安全的。Rust将为每个数组访问检查边界,所以我们不会在不经意间越界到缓冲区之外。因此,我们把需要的条件编码到Rust的类型系统,这之后,我们为外界提供的接口就符合内存安全原则了。

println!宏

现在我们有了一个全局的Writer实例,我们就可以基于它实现println!宏,这样它就能被任意地方的代码使用了。Rust提供的宏定义语法需要时间理解,所以我们将不从零开始编写这个宏。我们先看看标准库中println!宏的实现源码:
在这里插入图片描述
宏是通过一个或多个规则(rule)定义的,这就像match语句的多个分支。println!宏有两个规则:第一个规则不要求传入参数——就比如println!()——它将被扩展为print!("\n"),因此只会打印一个新行;第二个要求传入参数——好比println!(“Rust能够编写操作系统”)或println!(“我学习Rust已经{}年了”, 3)——它将使用print!宏扩展,传入它需求的所有参数,并在输出的字符串最后加入一个换行符\n。
这里,#[macro_export]属性让整个包(crate)和基于它的包都能访问这个宏,而不仅限于定义它的模块(module)。它还将把宏置于包的根模块(crate root)下,这意味着比如我们需要通过use std::println来导入这个宏,而不是通过std::macros::println。
print!宏是这样定义的:
在这里插入图片描述
==这个宏将扩展为一个对io模块中_print函数的调用。KaTeX parse error: Expected 'EOF', got '#' at position 532: …lor_FFFFFF,t_70#̲pic_center) 我们首…crate变量。这样我们在只需要使用println!时,不必也编写代码导入print!宏。
就像标准库做的那样,我们为两个宏都添加了#[macro_export]属性,这样在包的其它地方也可以使用它们。需要注意的是,这将占用包的根命名空间(root namespace),所以我们不能通过use crate::vga_buffer::println来导入它们;我们应该使用use crate::println。
另外,_print函数将占有静态变量WRITER的锁,并调用它的write_fmt方法。这个方法是从名为Write的trait中获得的,所以我们需要导入这个trait。额外的unwrap()函数将在打印不成功的时候panic;但既然我们的write_str总是返回Ok,这种情况不应该发生。
如果这个宏将能在模块外访问,它们也应当能访问_print函数,因此这个函数必须是公有的(public)。然而,考虑到这是一个私有的实现细节,我们添加一个doc(hidden)属性 ,防止它在生成的文档中出现。

使用println!的Hello World

现在,我们可以在_start里使用println!了:
在这里插入图片描述
要注意的是,我们在入口函数中不需要导入这个宏——因为它已经被置于包的根命名空间了。
运行这段代码,和我们预料的一样,一个 “Hello World!” 字符串被打印到了屏幕上
在这里插入图片描述
打印panic信息
既然我们已经有了println!宏,我们可以在panic处理函数中,使用它打印panic信息和panic产生的位置:
在这里插入图片描述
当我们在_start函数中插入一行panic!(“Some panic message”);后,我们得到了这样的输出:
在这里插入图片描述
所以,现在我们不仅能知道panic已经发生,还能够知道panic信息和产生panic的代码。

实现清屏(将屏幕上显示的字符全部清除)的功能

直接写一个函数
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
将清屏功能用宏来实现
在这里插入图片描述
未调用clear
在这里插入图片描述
在这里插入图片描述
调用clear宏
在这里插入图片描述
在这里插入图片描述

实验心得体会

操作系统概念1~5次实验报告:https://download.csdn.net/download/weixin_43979304/15321050?spm=1001.2014.3001.5503

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SIR怀特

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值