hello kernel!

hello kernel!

introduction

最近被朋友推荐进入了一个vx群,是关于学长前辈们打造的一个项目,从零实现一个操作系统内核,目标是逐步迭代,以实现各种功能,我感觉还是很有意思的,遂打算实践一番,但是发现啊,lab1就把我干倒了,所以我先简化一些步骤,基于别的有详细教程的项目先熟悉一下原理再说

详情请参考: https://rcore-os.cn/rCore-Tutorial-Book-v3/

github仓库: https://github.com/rcore-os/rCore-Tutorial-v3

基于rust语言与risc-v架构在QEMU上,从零实现kernel的各种功能,构建一个小型操作系统!

学长的项目链接: https://github.com/YatSenOS/YatSenOS-Tutorial-Volume-2

指导教程: https://ysos.gzti.me/

先来看看什么是QEMU!

在这之前似乎需要先了解一下真实计算机的加电启动流程

在你打开电脑电源后

发生了什么?

首先,加电(自检)后CPU的PC寄存器被设置为计算机内部只读存储器(ROM Read-only Memory)的物理地址,随后CPU开始运行ROM内的软件,我们一般将该软件称为固件(Firmware),它的功能是对CPU进行一些初始化操作,将后续阶段的bootloader的代码,数据从硬盘载入到物理内存,最后跳转到适当的地址将计算机控制权转移给bootloader

随后,bootloader同样完成一些CPU的初始化工作,然后将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统

至此,操作系统彻底接管电脑,向下管理并控制计算机硬件和各种外设,同时向上管理应用软件并提供各种服务,使得计算机能够正确而高效地运行!

我正是被手写bootloader和ELF parse(elf解析)所击败,所以,先放一放,直接用现有的bootloader~

而且我才发现这个的是固件是BIOS,而学长提供的是UEFI,似乎UEFI目前为主流选择,不过依旧先忽略

知识点似乎有点密集,慢慢学吧…

吐槽: risc-v架构也不是我所熟悉的(x_x),不过从x86迁移过去还是很容易的,思路是通用的!

我们先聚焦于写kernel!


QEMU

现在来看QEMU!

它的功能为利用宿主机提供的资源,模拟一台64位risc-v架构的计算机,包含了CPU,物理内存以及若干I/O外设

运行指令参考:

1
2
3
4
5
$ qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000

在其模拟的这个virt硬件平台上,我们需要加载预编译好的rustsbi-qemu.bin,即现有的bootloader,以及os.bin,即内核镜像,我们先聚焦于此

与真实计算机类似,运行上述命令后,其会模拟这样一个过程

首先将必要的文件载入到QEMU的物理内存之后,QEMU CPU的程序计数器(PC)会被初始化为0x1000(固件firmware),因此QEMU实际执行的第一条指令位于物理地址0x1000,接下来它将执行寥寥数条指令并跳转到物理地址0x80000000对应的指令处,因此我们需要将负责bootloaderrustsbi-qemu.bin放在以物理地址0x80000000开头的物理内存中,这样就能保证0x80000000处正好保存bootloader的第一条指令!

随后bootloader负责对计算机进行一些初始化工作,并跳转到下一阶段软件的入口,在QEMU上即可实现将计算机控制权移交给我们的内核镜像os.bin,我们选用的RustSBI是将下一阶段的入口地址预先约定为固定的0x80200000,所以在RustSBI的初始化工作完成之后,它会跳转到该地址并将计算机控制权移交给下一阶段的软件,即我们的内核镜像

为了正确地和上一阶段的RustSBI对接,我们需要保证内核的第一条指令位于物理地址0x80200000处,为此,我们需要将内核镜像预先加载到QEMU物理内存以地址0x80200000开头的区域上,一旦CPU开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核!

这样其执行流就为我们所控了,现在我们可以开始编写内核代码了!

ps: 似乎有点啰嗦,见谅! ( ´•̥̥̥ω•̥̥̥` )

启动!

先从打印hello world开始!


先跑起来再说

众所周知,在c语言中这是很简单的事情,但是实际上这是因为有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序

但由于我们这个是建立在裸机(bare metal)上的执行环境,所以我们没有编译器,运行时库和操作系统的支持

一切都得从零开始…

应用程序执行环境栈

所以目前并没有println!宏供我们使用,我们先看看怎样至少能让应用跑起来,先注释掉println!("hello world")

1
2
3
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: `#[panic_handler]` function required, but not found

在使用rust编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误(panic),导致程序无法继续向下运行

这时手动或自动调用panic!宏来打印出错的位置,让软件能够意识到它的存在,并进行一些后续处理,在标准库std中提供了关于panic!宏的具体实现,然而我们也并没有

因此我们通过#[panic_handler]这种编译指导属性自己实现一个简陋的panic处理函数

1
2
3
4
5
6
7
// os/src/lang_items.rs
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
1
2
3
4
// os/src/main.rs
#![no_std]
mod lang_items;
// ... other code

再次编译,又产生了新的问题

1
2
3
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
error: requires `start` lang_item

编译器提醒我们缺少一个名为start的语义项

语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点(也就是跳转到我们编写的main函数)开始执行

事实上start语义项代表了标准库std在执行应用程序之前需要进行的一些初始化工作,由于我们禁用了标准库,编译器也就找不到这项功能的实现了

因此我们直接不让编译器使用这项功能

我们在main.rs的开头加入设置#![no_main]告诉编译器我们没有一般意义上的main函数,并将原来的main函数删除

在失去了main函数的情况下,编译器也就不需要完成所谓的初始化工作了~

1
2
3
$ cargo build
Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.06s

至此,编译成功…

我们脱离了标准库,通过了编译器的检验,但距离打印hello world似乎还有一定距离

接下来我们会以自己的方式来重塑这些基本功能,并最终完成我们的目标!


调试与验证

首先我们需要编写进入内核后的第一条指令,方便我们验证我们的内核镜像是否正确对接到QEMU上

1
2
3
4
5
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
li x1, 100

这可能需要一些汇编和内存布局的知识,就不展开了

然后在main.rs中嵌入这段汇编代码

1
2
3
4
5
6
7
8
// os/src/main.rs
#![no_std]
#![no_main]

mod lang_items;

use core::arch::global_asm;
global_asm!(include_str!("entry.asm"));

由于链接器默认的内存布局并不能符合我们的要求,为了实现与QEMU正确对接,我们可以通过链接脚本(Linker Script)调整链接器的行为,使得最终生成的可执行文件的内存布局符合QEMU的预期,即内核第一条指令的地址应该位于0x80200000

我们修改cargo的配置文件来使用我们自己的链接脚本os/src/linker.ld而非使用默认的内存布局!

1
2
3
4
5
6
7
8
// os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]

链接脚本os/src/linker.ld如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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)
}
}

我们将包含内核第一条指令的.text.entry段放在最终的.text段的最开头,同时由于在最终内存布局中代码段.text又是先于任何其它段的,因为所有的段都从BASE_ADDRESS也即0x80200000开始放置,这就能够保证内核的第一条指令正好放在0x80200000从而能够正确对接到QEMU上

随后便能生成内核可执行文件

1
2
$ cargo build --release
Finished release [optimized] target(s) in 0.10s

为了更直观,我们用gdb调试一下看一下这个过程

先启动QEMU

1
2
3
4
5
6
$ qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 \
-s -S

连接gdb

1
2
3
4
5
6
$ riscv64-unknown-elf-gdb \
-ex 'file target/riscv64gc-unknown-none-elf/release/os' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
[GDB output]
0x0000000000001000 in ?? ()

对上了吧,从0x1000开始执行,我们看一下具体的指令

1
2
3
4
5
6
7
8
9
10
11
$ (gdb) x/10i $pc
=> 0x1000: auipc t0,0x0
0x1004: addi a1,t0,32
0x1008: csrr a0,mhartid
0x100c: ld t0,24(t0)
0x1010: jr t0
0x1014: unimp
0x1016: unimp
0x1018: unimp
0x101a: 0x8000
0x101c: unimp

我们单步

1
2
3
4
5
6
7
8
9
10
11
12
$ (gdb) si
0x0000000000001004 in ?? ()
$ (gdb) si
0x0000000000001008 in ?? ()
$ (gdb) si
0x000000000000100c in ?? ()
$ (gdb) si
0x0000000000001010 in ?? ()
$ (gdb) p/x $t0
1 = 0x80000000
$ (gdb) si
0x0000000080000000 in ?? ()

ok啊来到了0x80000000,这意味着我们即将把控制权转交给RustSBI

大致看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ (gdb) x/10i $pc
=> 0x80000000: auipc sp,0x28
0x80000004: mv sp,sp
0x80000008: lui t0,0x4
0x8000000a: addi t1,a0,1
0x8000000e: add sp,sp,t0
0x80000010: addi t1,t1,-1
0x80000012: bnez t1,0x8000000e
0x80000016: j 0x8001125a
0x8000001a: unimp
0x8000001c: addi sp,sp,-48
$ (gdb) si
0x0000000080000004 in ?? ()
$ (gdb) si
0x0000000080000008 in ?? ()
$ (gdb) si
0x000000008000000a in ?? ()
$ (gdb) si
0x000000008000000e in ?? ()

随后我们在0x80200000打上断点,然后continue执行至此,检查控制权能否被移交给我们的内核

ps: 还好打pwn经常要用gdb 大概会一点 ρ(・ω・、)

1
2
3
4
5
6
$ (gdb) b *0x80200000
Breakpoint 1 at 0x80200000
$ (gdb) c
Continuing.

Breakpoint 1, 0x0000000080200000 in ?? ()

ok了拿下

1
2
3
4
5
6
7
8
9
10
11
12
$ (gdb) x/5i $pc
=> 0x80200000: li ra,100
0x80200004: unimp
0x80200006: unimp
0x80200008: unimp
0x8020000a: unimp
$ (gdb) si
0x0000000080200004 in ?? ()
$ (gdb) p/d $x1
2 = 100
$ (gdb) p/x $sp
3 = 0x0

一切正常!进入下一阶段…


asm to rust

先回顾一下

我们成功在QEMU上执行了内核的第一条指令,它是我们在entry.asm中手写汇编代码得到的

然而,我们无论如何也不想仅靠手写汇编代码的方式编写我们的内核,绝大部分功能我们都想使用rust语言来实现,不过为了将控制权转交给我们使用rust语言编写的内核入口函数,我们确实需要手写若干行汇编代码进行一定的初始化工作

和之前一样,这些汇编代码放在entry.asm中,并在控制权被转交给内核相关函数前最先被执行,但它们的功能会更加复杂

首先需要设置栈空间,来在内核内使能函数调用,随后直接调用使用rust编写的内核入口函数,从而控制权便被移交给rust代码~

此处需要一些关于栈和函数调用的背景知识,不过,嘿嘿,也只是pwn的基本功罢了…

我们在entry.asm中分配启动栈空间,并在控制权被转交给rust入口之前将栈指针sp设置为栈顶的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main

.section .bss.stack
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:

我们在内核的内存布局中预留了一块大小为4096 * 16字节也就是$64KiB$的空间用作接下来要运行的程序的栈空间

在risc-v架构上,栈是从高地址向低地址增长

因此,最开始的时候栈为空,栈顶和栈底位于相同的位置,我们用更高地址的符号boot_stack_top来标识栈顶的位置

同时,我们用更低地址的符号boot_stack_lower_bound来标识栈能够增长到的下限位置,它们都被设置为全局符号供其他目标文件使用~

如图所示

我们可以看到我们将这块空间放置在一个名为.bss.stack的段中,在链接脚本linker.ld中可以看到.bss.stack段最终会被汇集到.bss段中

1
2
3
4
5
6
7
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
}
ebss = .;

同时全局符号sbssebss分别指向.bss段除.bss.stack以外的起始和终止地址,我们在使用这部分数据之前需要将它们初始化为零

我们将栈指针sp设置为先前分配的启动栈栈顶地址,这样rust代码在进行函数调用和返回的时候就可以正常在启动栈上分配和回收栈帧了

然而如果发生栈溢出就会很诡异了,先不管 ( ´•̥̥̥ω•̥̥̥` )

至此,我们通过伪指令call调用rust编写的内核入口点rust_main将控制权转交给rust代码,该入口点在main.rs中实现

1
2
3
4
5
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
loop {}
}

这里需要注意的是需要通过宏将rust_main标记为#[no_mangle]以避免编译器对它的名字进行混淆,不然在链接的时候,entry.asm将找不到main.rs提供的外部符号rust_main从而导致链接失败

同时在rust_main函数的开场白中,我们将第一次在栈上分配栈帧并保存函数调用上下文,它也是内核运行全程中最底层的栈帧~

当然,在内核初始化中,需要先完成对.bss段的清零,这是内核很重要的一部分初始化工作,在使用任何被分配到.bss段的全局变量之前我们需要确保.bss段已被清零

我们就在rust_main的开头完成这一工作,由于控制权已经被转交给rust,我们终于不用手写汇编代码而是可以用rust来实现这一功能了! (≧∀≦)ゞ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
loop {}
}

fn clear_bss() {
extern "C" {
fn sbss();
fn ebss();
}
(sbss as usize..ebss as usize).for_each(|a| {
unsafe { (a as *mut u8).write_volatile(0) }
});
}

涉及的rust语法:

  • 外部符号引用

  • 迭代器与闭包

  • unsafe!

tips: 我们将.bss段内的一个地址转化为一个裸指针(Raw Pointer),并将它指向的值修改为0,这在c语言中是一种司空见惯的操作,但在rust中我们需要将其包裹在unsafe块中。这是因为,rust认为对于裸指针的解引用(Dereference)是一种unsafe行为。相比c语言,rust进行了更多的语义约束来保证安全性(内存安全/类型安全/并发安全),这在编译期和运行期都有所体现。但在某些时候,尤其是与底层硬件打交道的时候,在rust的语义约束之内没法满足我们的需求,这个时候我们就需要将超出了rust语义约束的行为包裹在unsafe块中,告知编译器不需要对它进行完整的约束检查,而是由程序员自己负责保证它的安全性。当代码不能正常运行的时候,我们往往也是最先去检查unsafe块中的代码,因为它没有受到编译器的保护,出错的概率更大。

至此,我们通过分配栈空间并正确设置栈指针在内核中使能了函数调用并成功将控制权转交给了rust代码,从此我们终于可以利用强大的rust语言来编写内核的各项功能了!


实现helloworld!

接下来我们终于迎来最后一步

基于RustSBI提供的服务成功在屏幕上打印Hello, world!

ps: 这也太艰难了 (╥﹏╥)

之前我们对RustSBI的了解仅限于它会在计算机启动时进行它所负责的环境初始化工作,并将计算机控制权移交给内核。但实际上作为内核的执行环境,它还有另一项职责:即在内核运行时响应内核的请求为内核提供服务。当内核发出请求时,计算机会转由RustSBI控制来响应内核的请求,待请求处理完毕后,计算机控制权会被交还给内核

我们将利用它实现打印字符串与退出程序的操作

先在Cargo.toml中引入sbi_rt依赖

1
2
3
// os/Cargo.toml
[dependencies]
sbi-rt = { version = "0.0.2", features = ["legacy"] }

随后在os/src/sbi.rs中,我们直接调用sbi_rt提供的接口来将输出字符,并实现关机功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#![allow(unused)]
/// 允许当前文件里存在未使用的函数、导入、变量
/// usize 64位

/// use sbi call to putchar in console (qemu uart handler)
pub fn console_putchar(c: usize) {
#[allow(deprecated)]
sbi_rt::legacy::console_putchar(c);
}

/// use sbi call to getchar from console (qemu uart handler)
pub fn console_getchar() -> usize {
#[allow(deprecated)]
sbi_rt::legacy::console_getchar()
}

/// use sbi call to shutdown the kernel
pub fn shutdown(failure: bool) -> ! {
use sbi_rt::{NoReason, Shutdown, SystemFailure, system_reset};
if !failure {
/// 正常退出
system_reset(Shutdown, NoReason);
} else {
/// 系统异常 / panic
system_reset(Shutdown, SystemFailure);
}
/// ! 永不返回
unreachable!()
}

但是console_putchar的功能过于受限,如果想打印一行Hello world!的话需要进行多次调用,因此我们将尝试自己编写基于console_putcharprintln!宏!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// os/src/main.rs
#[macro_use]
mod console;

// os/src/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)+)?));
}
}

语法比较神秘,不过原理很清晰!

这样我们就实现了直接调用println!就能实现打印字符串的操作!

Rust Tips:Rust Trait

在rust语言中,trait(中文翻译:特质,特征)是一种类型,用于描述一组方法的集合。trait可以用来定义接口(interface),并可以被其他类型实现。 举个例子,假设我们有一个简单的rust程序,其中有一个名为Shape的trait,用于描述形状:

1
2
3
trait Shape {
fn area(&self) -> f64;
}

我们可以使用这个trait来定义一个圆形类型:

1
2
3
4
5
6
7
8
9
struct Circle {
radius: f64,
}

impl Shape for Circle {
fn area(&self) -> f64 {
3.14 * self.radius * self.radius
}
}

这样,我们就可以使用Circle类型的实例调用area方法了

1
2
let c = Circle { radius: 1.0 };
println!("Circle area: {}", c.area()); // 输出: Circle area: 3.14

完善

错误处理是编程的重要一环,它能够保证程序的可靠性和可用性,使得程序能够从容应对更多突发状况而不至于过早崩溃,rust将错误分为可恢复和不可恢复错误两大类,这里我们主要关心不可恢复错误,在rust中遇到不可恢复错误,程序会直接报错退出

例如,使用panic!宏便会直接触发一个不可恢复错误并使程序退出

不过在我们的内核中,目前不可恢复错误的处理机制还不完善

现在我们借助前面实现的println!宏和shutdown函数,完善#[panic_handler],在panic函数中打印错误信息并关机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// os/src/main.rs
#![feature(panic_info_message)]

// os/src/lang_item.rs
use crate::sbi::shutdown;
use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
if let Some(location) = info.location() {
println!(
"Panicked at {}:{} {}",
location.file(),
location.line(),
info.message().unwrap()
);
} else {
println!("Panicked: {}", info.message().unwrap());
}
shutdown(true)
}

测试一下

1
2
3
4
5
6
7
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
println!("Hello, world!");
panic!("Shutdown machine!");
}

成功!

1
2
3
[RustSBI output]
Hello, world!
Panicked at src/main.rs:26 Shutdown machine!

Rust Tips:Rust 可恢复错误

在有可能出现错误时,rust函数的返回值可以属于一种特殊的类型,该类型可以涵盖两种情况:要么函数正常退出,则函数返回正常的返回值;要么函数执行过程中出错,则函数返回出错的类型。rust的类型系统保证这种返回值不会在程序员无意识的情况下被滥用,即程序员必须显式对其进行分支判断或者强制排除出错的情况。如果不进行任何处理,那么无法从中得到有意义的结果供后续使用或是无法通过编译。这样,就杜绝了很大一部分因程序员的疏忽产生的错误(如不加判断地使用某函数返回的空指针)

在rust中有两种这样的特殊类型,它们都属于枚举结构:

  • Option<T>既可以有值Option::Some<T>,也有可能没有值 Option::None

  • Result<T, E>既可以保存某个操作的返回值Result::Ok<T>,也可以表明操作过程中出现了错误Result::Err<E>

我们可以使用$Option/Result$来保存一个不能确定存在/不存在或是成功/失败的值。之后可以通过匹配if let或是在能够确定的场合直接通过unwrap将里面的值取出,详细的内容可以参考rust官方文档!

链接: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html

最后,我们再来完善一下日志输出,并实现彩色打印!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/*!
本模块利用 log crate 为你提供了日志功能,使用方式见 main.rs.
*/

use log::{self, Level, LevelFilter, Log, Metadata, Record};

struct SimpleLogger;

impl Log for SimpleLogger {
fn enabled(&self, _metadata: &Metadata) -> bool {
// whether
true
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
// 元信息
return;
}
let color = match record.level() {
Level::Error => 31, //Red
Level::Warn => 93, // BrightYellow
Level::Info => 34, //Blue
Level::Debug => 32, //Green
Level::Trace => 90, //BrightBlack
};
println!(
"\u{1B}[{}m[{:>5}] {}\u{1B}[0m", // 神秘
color,
record.level(), // 日志等级
record.args(), // 格式化内容
);
}
// 刷新
fn flush(&self) {}
}

pub fn init() {
static LOGGER: SimpleLogger = SimpleLogger;
log::set_logger(&LOGGER).unwrap();
log::set_max_level(match option_env!("LOG") {
Some("ERROR") => LevelFilter::Error,
Some("WARN") => LevelFilter::Warn,
Some("INFO") => LevelFilter::Info,
Some("DEBUG") => LevelFilter::Debug,
Some("TRACE") => LevelFilter::Trace,
_ => LevelFilter::Info,
});
}

结束

最后的最后,调整main函数,验证全过程!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
//! The main module and entrypoint
//!
//! The operating system and app also starts in this module. Kernel code starts
//! executing from `entry.asm`, after which [`rust_main()`] is called to
//! initialize various pieces of functionality [`clear_bss()`]. (See its source code for
//! details.)
//!
//! We then call [`println!`] to display `Hello, world!`.

#![deny(missing_docs)]
#![deny(warnings)]
#![no_main]
#![no_std]

use core::arch::global_asm;
use log::*;

#[macro_use]
mod console;
mod lang_items;
mod logging;
mod sbi;

global_asm!(include_str!("entry.asm"));

/// clear BSS segment
pub fn clear_bss() {
unsafe extern "C" {
safe fn sbss();
safe fn ebss();
}
(sbss as usize..ebss as usize).for_each(|a| unsafe { (a as *mut u8).write_volatile(0) });
}

/// the rust entry-point of os
#[unsafe(no_mangle)]
pub fn rust_main() -> ! {
unsafe extern "C" {
safe fn stext(); // begin addr of text segment
safe fn etext(); // end addr of text segment
safe fn srodata(); // start addr of Read-Only data segment
safe fn erodata(); // end addr of Read-Only data segment
safe fn sdata(); // start addr of data segment
safe fn edata(); // end addr of data segment
safe fn sbss(); // start addr of BSS segment
safe fn ebss(); // end addr of BSS segment
safe fn boot_stack_lower_bound(); // stack lower bound
safe fn boot_stack_top(); // stack top
}
clear_bss();
logging::init();
println!("[kernel] Hello World!!!!!");
trace!(
"[kernel] .text [{:#x}, {:#x})",
stext as usize, etext as usize
);
debug!(
"[kernel] .rodata [{:#x}, {:#x})",
srodata as usize, erodata as usize
);
info!(
"[kernel] .data [{:#x}, {:#x})",
sdata as usize, edata as usize
);
warn!(
"[kernel] boot_stack top=bottom={:#x}, lower_bound={:#x}",
boot_stack_top as usize, boot_stack_lower_bound as usize
);
error!("[kernel] .bss [{:#x}, {:#x})", sbss as usize, ebss as usize);

// CI autotest success: sbi::shutdown(false)
// CI autotest failed : sbi::shutdown(true)
// 优雅退场~
sbi::shutdown(false)
}

因为提供了Makefile文件,所以直接运行LOG=TRACE make run即可!

如图!

终于,我们从零开始,逐步实现了$kernel hello world$!!!

并且还拥有了基础的日志打印与错误处理功能~

有没有感到成就感满满呢 (。•ㅅ•。)♡


总结

最后再梳理一下这整个过程,如图所示

总结

下面是项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
./os/src
Rust 4 Files 119 Lines
Assembly 1 Files 11 Lines

├── bootloader(内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
│ └── rustsbi-qemu.bin(可运行在 qemu 虚拟机上的预编译二进制版本)
├── LICENSE
├── os(我们的内核实现放在 os 目录下)
│ ├── Cargo.toml(内核实现的一些配置文件)
│ ├── Makefile
│ └── src(所有内核的源代码放在 os/src 目录下)
│ ├── console.rs(将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
│ ├── entry.asm(设置内核执行环境的的一段汇编代码)
│ ├── lang_items.rs(需要我们提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
│ ├── linker-qemu.ld(控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
│ ├── main.rs(内核主函数)
│ └── sbi.rs(调用底层 SBI 实现提供的 SBI 接口)
├── README.md
└── rust-toolchain(控制整个项目的工具链版本)

这便是本篇blog的完整内容了…

感谢阅读!


补充

补充:

你可能需要一些前置知识:

  • API vs ABI
  • what is os and its function and history
  • the concept of abstract(抽象)
  • exception control flow trap & interrupt & exception
  • process 进程 线程 context CPU
  • I/O file system
  • execution environment(执行环境)
  • address space virtual address & physical address MMU memory
  • virtualization CPU & memory <=> time & space
  • concurrency 并发 & 并行
  • 异步性
  • 共享性
  • 持久性

畏惧了…


hello kernel!
https://roxy5201314.github.io/2026/02/23/hello-kernel/
作者
roxy
发布于
2026年2月23日
更新于
2026年2月26日
许可协议