环境配置方面已经在上一节说过了,见清华大学操作系统rCore实验-第零章-Lab环境搭建。本节开始,我们新创建一个项目,并一步一个脚印写出rcore操作系统。
我们使用cargo创建项目neos,输入cargo new neos --bin
,可以通过tree neos
看看这个项目的结构:
可以进入该文件目录,输入cargo run
直接运行,也可以cat /neos/src/main.rs
查看初始的源码:
因为使用qemu时需要一个引导加载程序(bootloader),这里我们使用预编译好的rustsbi-qemu.bin
,这个文件需要另外下载安装。
输入git clone https://gitee.com/rcore-os/rCore-Tutorial-v3.git
然后用mv
命令,将其中的bootloader移入我们的neos项目中。
我们输入rustc --version --verbose
,查看该项目默认运行的目标平台:
可以看到新项目的执行默认基于Linux,CPU架构是x86_64,CPU厂商是unknown(不清楚),运行时库是GNU libc(封装 Linux 系统调用,提供 POSIX 接口为主的函数库)。
rCore基于RISC-V64内核,我们需要将rCore的CPU架构从x86_64转换成RISC-V。
我们可以输入rustc --print target-list | grep riscv
,查看Rust 编译器支持哪些基于 RISC-V 的目标平台:
我们选择riscv64gc-unknown-none-elf
作为新项目的目标平台,其中riscv64gc是CPU架构,unknown是CPU厂商,none为空内核,elf为不带有运行时库并可以生成ELF格式的文件。这说明我们完全只基于riscv64gc编写操作系统,其余一切都是精简的空壳子,是一个裸机平台。
我们输入cargo run --target riscv64gc-unknown-none-elf
,将该项目以riscv64gc-unknown-none-elf
为目标平台运行:
可以看到出现了几个error,Rust没有针对该裸机平台的标准库-std,但是Rust有一个核心库-core,它是标准库-std的阉割版,虽然功能不丰富,但是不需要任何操作系统支持,并且也具备一部分的核心机制。
为了方便后续工作,我们需要使rustc编译器缺省生成RISC-V代码。
先输入rustup target add riscv64gc-unknown-none-elf
:
然后在/neos目录下新建/.cargo,在这个目录下创建config文件,并在里面输入配置内容:
现在cargo默认会使用riscv64gc-unknown-none-elf
作为目标平台而不是原先的默认x86_64-unknown-linux-gnu
,我们run或者build的时候就不需要添加--target riscv64gc-unknown-none-elf
了。
我们重新cargo build
:
现在,我们针对这几个error挨个解决。
println! 宏是由标准库-std提供的,且会使用到一个名为write的系统调用,而标准库-std本身就需要操作系统的支持。
现在项目转换到了一个什么都没有的裸机平台,我们就需要告诉 Rust 编译器不使用Rust
标准库-std转而使用上面提到的核心库-core(core库不需要操作系统的支持)。
在main.rs
的开头加上一行#![no_std]
即可:
这个时候可以看到,第一个error解决了。
至于接下来这个error,现在我们的代码功能还不足以自己实现println! 宏。由于程序使用了系统调用,但不能在核心库 core 中找到它,所以我们目前先通过将 println! 宏注释掉的简单粗暴方式,来暂时绕过这个问题。
我们继续cargo build
,就剩这一个error了:
panic!
宏是一个多种编程语言都会有的异常处理函数,大致功能是打印出错位置和原因并kill掉当前应用。
#[panic_handler]
是一种编译指导属性,用于标记核心库-core中的panic!
宏要对接的函数(该函数实现对致命错误的具体处理)。该编译指导属性所标记的函数需要具有fn(&PanicInfo) -> ! 函数签名,函数可通过PanicInfo
数据结构获取致命错误的相关信息。这样Rust编译器就可以把核心库-core中的panic!
宏定义与#[panic_handler]
指向的panic函数实现合并在一起,使得no_std程序具有类似std库的应对致命错误的功能。
核心库core中只有一个panic!宏的空壳,没有提供panic!宏的精简实现,故我们需要自己先实现一个简陋的panic处理函数,这样才能让我们的neos编译通过。
我们创建一个新的子模块文件lang_items.rs
实现panic函数,并通过#[panic_handler]
属性通知编译器用panic函数来对接panic!宏。为了将该模块添加到项目中,我们还需要在main.rs 的#![no_std]的下方加上mod lang_items
:
之后我们会从PanicInfo
解析出错位置并打印出来,然后kill应用程序,但目前只会在原地 loop。
重新编译,新出来了一个错误:
提醒我们缺少一个名为start的语义项, start语义项代表了标准库-std在执行应用程序之前需要进行的一些初始化工作,由于我们禁用了标准库,编译器也就找不到这项功能的实现。
解决方式很简单粗暴,我们在main.rs
的开头加入设置#![no_main]
告诉编译器我们没有一般意义上的main函数,并将原来的main函数删除。在失去了main函数的情况下,编译器也就不需要完成所谓的初始化工作了:
这个时候再度编译项目,
至此,我们成功伤筋动骨式地移除了标准库的依赖,并完成了构建裸机平台上新项目neos的第一步工作–通过编译器检查并生成执行码,虽然是一个空程序。
file target/riscv64gc-unknown-none-elf/debug/neos //查看文件格式
rust-readobj -h target/riscv64gc-unknown-none-elf/debug/neos //查看文件头信息
rust-objdump -S target/riscv64gc-unknown-none-elf/debug/neos //反汇编导出汇编程序
上面三条命令帮助我们分析程序,不过经过前面的操作,我们也能知道,这就是一个什么功能都没有的空程序。
首先,我们需要编写进入内核后的第一条指令,这样更方便我们验证我们的内核镜像是否正确对接到 Qemu 上,为此,我们先新创建一个汇编文件entry.asm
,并写入如下内容:
.section .text.entry
表明我们希望将其后面的代码全部放到一个名为.text.entry
的代码段中。
.global _start
说明是_start
一个全局符号,可以被其他目标文件使用;
_start
符号指向紧跟在其后面的内容,其地址为指令li x1, 100
所在的地址;
li x1, 100
表示给寄存器x1赋值100 ;
一般情况下,所有的代码都被放到一个名为.text
的代码段中,这里我们命名为.text.entry
的目的在于确保该段被放置在相比任何其他代码段更低的地址上。作为内核的入口点,这段指令可以被最先执行。
然后将这段代码导入main.rs
文件中:
由于链接器默认的内存布局并不能符合我们的要求,为了实现与Qemu正确对接,我们可以通过编写自己的链接脚本(Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合Qemu的预期。
编写如下链接脚本linker.ld
:
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}
. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
}
. = ALIGN(4K);
ebss = .;
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
}
}
然后修改之前的配置文件config
来使用我们自己的链接脚本neos/src/linker.ld
而非使用默认的内存布局:
此后我们便可以生成内核可执行文件,切换到neos目录下并进行以下操作:
可以查看刚刚生成文件的格式:
然后丢弃内核可执行文件中的元数据得到内核镜像:
可以使用stat命令比较内核可执行文件和内核镜像的大小:
在neos目录下通过以下命令启动Qemu并加载RustSBI和内核镜像:
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/neos.bin,addr=0x80200000 \
-s -S
打开另一个终端,启动一个 GDB 客户端连接到 Qemu :
riscv64-unknown-elf-gdb \
-ex 'file /home/kali/neos/target/riscv64gc-unknown-none-elf/release/neos' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
我们在 entry.asm 中分配启动栈空间,并在控制权被转交给Rust入口之前将栈指针sp设置为栈顶的位置。
call rust_main
表明我们通过伪指令call调用Rust编写的内核入口点rust_main将控制权转交给Rust代码,该入口点在 main.rs 中实现:
这里需要注意的是需要通过宏将rust_main
标记为#![no_mangle]
以避免编译器对它的名字进行混淆,不然在链接的时候,entry.asm
将找不到main.rs
提供的外部符号rust_main
从而导致链接失败。
在内核初始化中,需要先完成对 .bss 段的清零:
这里我们可以进行基于RustSBI提供的服务完成在屏幕上打印Hello world!和关机操作了。
首先,我们在Cargo.toml
中引入sbi_rt
依赖:
创建sbi.rs文件,调用sbi_rt提供的接口实现输出字符的功能:
在main.rs
中加入mod sbi
将该子模块加入项目;
同样,我们再来实现关机功能:
由于输出字符功能中的console_putchar
的功能受限,如果想打印一行 Hello world! 的话需要进行多次调用,因此我们尝试自己编写基于console_putchar
的println!
宏:
首先在main.rs
中引入一个新文件console.rs
,
然后编写console.rs
的代码:
use crate::sbi::console_putchar;
use core::fmt::{self, Write};
struct Stdout;
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
console_putchar(c as usize);
}
Ok(())
}
}
pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
}
}
#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}
接下来,我们需要对错误处理函数panic进行完善