最近开始学习操作系统和机组的相关知识, 写一个学习进度的笔记作为鞭策, 其中的dayn不一定全是一天内完成的, 同时,大部分文字来源于学习资料rCore-Tutorial-Book 第三版。
DAY1-应用程序与基本执行环境
Hello World的执行过程
-
在Ubuntu上利用cargo创建并执行了最简单的rust程序![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\4680d12ff8ed6b042938d0539098a615.png)
可见图中成功输出了hello world
编程和执行程序的方便性并不是理所当然的,背后有着从硬件到软件多种机制的支持。特别是对于应用程序的运行,需要有一个强大的执行环境来帮助。
应用程序执行环境
白色块: 表示各级执行环境,
黑色块: 相邻两层执行环境之间的接口。
越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行
表示各级执行环境
从硬件的角度来看,它上面的一切都属于软件。硬件可以分为三种: 处理器 (Processor,也称CPU),内存 (Memory) 还有 I/O 设备
其中处理器无疑是其中最复杂,同时也最关键的一个。它与软件约定一套 指令集体系结构 (ISA, Instruction Set Architecture),使得软件可以通过 ISA 中提供的机器指令来访问各种硬件资源。软件当然也需要知道处理器会如何执行这些指令,以及指令执行后的结果
当然,实际的情况远比这个要复杂得多, 处理器还需要提供很多额外的机制来管理应用程序的执行过程,而不仅仅是让数据在 CPU 寄存器、内存和 I/O 设备三者之间流动。
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\ab8d03ccdf9829b2ffaa293c3c63cd70.png)
通过strace target/debug/os
可以看到在执行hello world时执行了很多系统调用,但真正与程序相关的只有最后几句(如果把代码换成//do nothing
, 很多操作仍要执行)
现在的操作系统,如 Linux ,为了通用性,而实现了大量的功能。但对于非常简单的程序而言,有很多的功能是多余的。
目标平台与目标三元组
对于一份用某种编程语言实现的应用程序源代码而言,编译器在将其通过编译、链接得到可执行文件的时候需要知道程序要在哪个 平台 (Platform) 上运行
平台主要是指 CPU 类型、操作系统类型和标准运行时库的组合, Rust编译器通过 目标三元组 (Target Triplet) 来描述一个软件运行的目标平台。
打印编译器 rustc 的默认配置信息:
$ rustc --version --verbose
rustc 1.57.0-nightly (e1e9319d9 2021-10-14)
binary: rustc
commit-hash: e1e9319d93aea755c444c8f8ff863b0936d7a4b6
commit-date: 2021-10-14
host: x86_64-unknown-linux-gnu
release: 1.57.0-nightly
LLVM version: 13.0.0
从其中的 host 一项可以看出默认的目标平台是 x86_64-unknown-linux-gnu
,其中 CPU 架构是 x86_64,CPU 厂商是 unknown,操作系统是 linux,运行时库是 GNU libc(封装了 Linux 系统调用,并提供 POSIX 接口为主的函数库)。
构建一个内核最小执行环境
1.移除对标准库的依赖
println!
宏所在的 Rust 标准库 std 需要通过系统调用获得操作系统的服务,而如果要构建运行在裸机上的操作系统,就不能再依赖标准库了。
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\e7536046c0ed38e7cfc8c4e7c53080f1.png)
对于 Cargo 工具在 os 目录下的行为进行调整:现在默认会使用 riscv64gc 作为目标平台而不是原先的默认 x86_64-unknown-linux-gnu。
在 main.rs
的开头加上一行 #![no_std]
来告诉 Rust 编译器不使用 Rust 标准库 std 转而使用核心库 core(core库不需要操作系统的支持)
加上no_std后出现了报错, 因为现在println!
宏的功能实现不了了。在注释掉println!
这行代码后, 仍然有报错:
error: `#[panic_handler]` function required, but not found
在使用 Rust 编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误(panic),导致程序无法继续向下运行。这时调用 panic!
宏来打印出错的位置,让软件能够意识到它的存在,并进行一些后续处理。
所以Rust编译器在编译程序时,从安全性考虑,需要有 panic!
宏的具体实现。
库 std 中提供了关于 panic!
宏的具体实现, 而当我们移除std后, 并未提供panic的实现, 所以需要先提供一个简易版的panic实现
创建一个新的子模块 lang_items.rs
实现panic函数:
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\dae3c8a5e68f30229174de59ddcc78ca.png)
可以看到lang_items.rs里面的代码, 其中使用 core
库中的 PanicInfo
结构体来获取 panic 相关信息,但在处理 panic 时,它只是进入了一个无限循环。这样做是为了在没有标准库的情况下进行基本的错误处理,确保程序在发生 panic 时不会继续运行。
在把 panic_handler
配置在单独的文件 os/src/lang_items.rs
后,需要在os/src/main.rs文件中添加mod lang_items;
才能正常编译
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\7204d4a9c08cb1a9e4eeb85f010e1d56.png)
解决了这个问题后, 出现了新的错误:
error: requires `start` lang_item
编译器提醒我们缺少一个名为 start
的语义项。
语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作, 而事实上 start
语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
由于目前处于初级阶段, 解决方法也很直接,
不让编译器使用这项功能即可, 在 main.rs
的开头加入设置 #![no_main]
告诉编译器没有一般意义上的 main
函数,并将原来的 main
函数删除。在失去了 main
函数的情况下,编译器也就不需要完成所谓的初始化工作了。
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\1451c1d8da2c4b4880a510503c6cbd46.png)
最终我们脱离了标准库,通过了编译器的检验,但其实删除或简化了许多功能
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\f009fcadddf71a6132faafc97f2a18a9.png)
2.Qemu内核
在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的是,该下标通常不以 0 开头,而通常以一个常数,如 0x80000000
开头。简言之,CPU 可以通过物理地址来寻址,并 逐字节 地访问物理内存中保存的数据。
-
启动 Qemu 并运行内核:
$ qemu-system-riscv64 \ -machine virt \ -nographic \ -bios ../bootloader/rustsbi-qemu.bin \ -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000
-
-machine virt
表示将模拟的 64 位 RISC-V 计算机设置为名为virt
的虚拟计算机我们知道,即使同属同一种指令集架构,也会有很多种不同的计算机配置,比如 CPU 的生产厂商和型号不同,支持的 I/O 外设种类也不同
-
-nographic
表示模拟器不需要提供图形界面,而只需要对外输出字符流。 -
通过
-bios
可以设置 Qemu 模拟器开机时用来初始化的引导加载程序(bootloader),这里我们使用预编译好的rustsbi-qemu.bin
,它需要被放在与os
同级的bootloader
目录下 -
通过虚拟设备
-device
中的loader
属性可以在 Qemu 模拟器开机之前将一个宿主机上的文件载入到 Qemu 的物理内存的指定位置中,file
和addr
属性分别可以设置待载入文件的路径以及将文件载入到的 Qemu 物理内存上的物理地址。这里我们载入的
os.bin
被称为 内核镜像 ,它会被载入到 Qemu 模拟器内存的0x80200000
地址处。那么内核镜像
os.bin
是怎么来的呢?上一节中我们移除标准库依赖后会得到一个内核可执行文件os
,将其进一步处理就能得到os.bin
,具体处理流程我们会在后面深入讨论。
-
3.Qemu内核的启动流程
在Qemu模拟的 virt
硬件平台上,物理内存的起始物理地址为 0x80000000
启动 Qemu ,在 Qemu 开始执行任何指令之前,首先把两个文件加载到 Qemu 的物理内存中:
- 把作为 bootloader 的
rustsbi-qemu.bin
加载到物理内存以物理地址0x80000000
开头的区域上 - 把内核镜像
os.bin
加载到以物理地址0x80200000
开头的区域上。
计算机加电之后的启动流程可以分成若干个阶段,每个阶段均由一层软件或固件负责,每一层软件或固件的功能是进行它应当承担的初始化工作
在此之后跳转到下一层软件或固件的入口地址,也就是将计算机的控制权移交给了下一层软件或固件。
Qemu 模拟的启动流程则可以分为三个阶段:第一个阶段由固化在 Qemu 内的一小段汇编程序负责;第二个阶段bootloader负责;第三个阶段则由内核镜像负责。
-
第一阶段:
将必要的文件载入到 Qemu 物理内存之后,Qemu CPU 的程序计数器(PC, Program Counter)会被初始化为
0x1000
,因此 Qemu 实际执行的第一条指令位于物理地址0x1000
接下来它将执行数条指令并跳转到物理地址
0x80000000
对应的指令处并进入第二阶段。该地址0x80000000
被固化在 Qemu 中,作为 Qemu 的使用者,我们在不触及 Qemu 源代码的情况下无法进行更改。 -
第二阶段:
由于 Qemu 的第一阶段固定跳转到
0x80000000
,我们需要将负责第二阶段的 bootloaderrustsbi-qemu.bin
放在以物理地址0x80000000
开头的物理内存中,这样就能保证0x80000000
处正好保存 bootloader 的第一条指令。在这一阶段,bootloader 负责对计算机进行一些初始化工作,并跳转到下一阶段软件的入口
在 Qemu 上即可实现将计算机控制权移交给我们的内核镜像
os.bin
。这里需要注意的是,对于不同的 bootloader 而言,下一阶段软件的入口不一定相同,而且获取这一信息的方式和时间点也不同:入口地址可能是一个预先约定好的固定的值,也有可能是在 bootloader 运行期间才动态获取到的值。我们选用的 RustSBI 则是将下一阶段的入口地址预先约定为固定的
0x80200000
,在 RustSBI 的初始化工作完成之后,它会跳转到该地址并将计算机控制权移交给下一阶段的软件——也即我们的内核镜像。 -
第三阶段:为了正确地和上一阶段的 RustSBI 对接,我们需要保证内核的第一条指令位于物理地址
0x80200000
处。为此,我们需要将内核镜像预先加载到 Qemu 物理内存以地址0x80200000
开头的区域上。一旦 CPU 开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核,也就达到了本节的目标。
为了让我们的内核镜像能够正确对接到 Qemu 和 RustSBI 上,我们提交给 Qemu 的内核镜像文件必须满足:该文件的开头即为内核待执行的第一条指令
但后面会讲到,在上一节中我们通过移除标准库依赖得到的可执行文件实际上并不满足该条件。因此,我们还需要对可执行文件进行一些操作才能得到可提交给 Qemu 的内核镜像。为了说明这些条件,首先我们需要了解一些关于程序内存布局和编译流程的知识。
4.程序内存布局
在我们将源代码编译为可执行文件之后,至少可以分成代码和数据两部分,在程序运行起来的时候它们的功能并不相同:
代码部分由一条条可以被 CPU 解码并执行的指令组成
数据部分只是被 CPU 视作可读写的内存空间
事实上我们还可以根据其功能进一步把两个部分划分为更小的单位: 段 (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。一种典型的程序相对内存布局如下所示:
在上图中可以看到,代码部分只有代码段 .text
一个段,存放程序的所有汇编代码。
数据部分则还可以继续细化:
已初始化数据段
保存程序中那些已初始化的全局数据,分为.rodata
(只读)和.data
(可修改) 两部分。- 未初始化数据段
.bss
保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零; - 堆 (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
- 栈 (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。
5.编译流程
从源代码得到可执行文件的编译流程可被细化为多个阶段 (虽然输入一条命令便可将它们全部完成)
-
编译器 (Compiler) :高级编程语言 => 汇编语言
注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;
-
汇编器 (Assembler) 源文件中文本格式的指令 => 为机器码
得到一个二进制的 目标文件 (Object File);
-
链接器 (Linker) 目标文件以及可能的外部目标文件链接在一起形成一个完整的可执行文件。
详细编译流程可以查看原文(主要讲了连接器的流程)
6.实际操作
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\0b47a81a5fd5d99946ab654f3ac329b7.png)
-
目标操作: 实际的指令位于第 5 行,也即
li x1, 100
。li
是 Load Immediate 的缩写,也即将一个立即数加载到某个寄存器,因此这条指令可以看做将寄存器x1
赋值为100
-
第 4 行我们声明了一个符号
_start
,该符号指向紧跟在符号后面的内容,因此符号_start
的地址即为第 5 行的指令所在的地址。 -
第 3 行我们告知编译器
_start
是一个全局符号,因此可以被其他目标文件使用 -
第 2 行表明我们希望将第 2 行后面的内容全部放到一个名为
.text.entry
的段中。一般情况下,所有的代码都被放到一个名为
.text
的代码段中, 这里我们将其命名为.text.entry
从而区别于其他.text
目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\54c58a308a0a69dcb8d111d4587a43eb.png)
通过 include_str!
宏将同目录下的汇编代码 entry.asm
转化为字符串并通过 global_asm!
宏嵌入到代码中。
由于链接器默认的内存布局并不能符合我们的要求,需要通过链接脚本调整链接器的行为
使得最终生成的可执行文件的内存布局符合Qemu的预期: 即内核第一条指令的地址应该位于 0x80200000 。![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\1cc55963b21bd38c6af09efc2d156791.png)
修改了Cargo 的配置文件, 这样就会使用链接脚本 os/src/linker.ld
而非使用默认的内存布局.
接下来, 编写链接脚本:
OUTPUT_ARCH(riscv) //设置目标平台为 riscv
ENTRY(_start) //设置整个程序的入口点为之前定义的全局符号 _start;
BASE_ADDRESS = 0x80200000;
//定义了一个常量 BASE_ADDRESS 为 0x80200000 ,也就是我们之前提到内核的初始化代码被放置的地址;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
//在最终可执行文件中各个常见的段 `.text, .rodata .data, .bss` 从低地址到高地址按顺序放置
//每个段里面都包括了所有输入目标文件的同名段
//每个段都有两个全局符号给出了它的开始和结束地址(比如 `.text` 段的开始和结束地址分别是 `stext` 和 `etext` )。
stext = .;
.text : {
*(.text.entry)
//将包含内核第一条指令的 .text.entry 段放在最终的 .text 段的最开头
//在最终内存布局中代码段 .text 又是先于任何其他段的。因为所有的段都从 BASE_ADDRESS 也即 0x80200000 开始放置,这就能够保证内核的第一条指令正好放在 0x80200000 从而能够正确对接到 Qemu 上。
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
//冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为 <ObjectFile>(SectionName),表示目标文件 ObjectFile 的名为 SectionName 的段需要被放进去。
. = 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)
}
}
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 .
表示当前地址,链接器会从它指向的位置开始往下放置从输入的目标文件中收集来的段。我们可以对 .
进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 .
从而记录这一时刻的位置。
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\da90e74759064317f1f598e110a07352.png)
以 release
模式生成了内核可执行文件
它的位置在 os/target/riscv64gc.../release/os
。
接着我们通过 file
工具查看它的属性,可以看到它是一个运行在 64 位 RISC-V 架构计算机上的可执行文件,它是静态链接得到的。
上面得到的内核可执行文件不能直接提交给 Qemu ,因为它还有一些多余的元数据,这些元数据无法被 Qemu 在加载文件时利用,且会使代码和数据段被加载到错误的位置。
图示的上半部分中,我们直接将内核可执行文件
os
提交给 Qemu ,而 Qemu 会将整个可执行文件不加处理的加载到 Qemu 内存的0x80200000
处,由于内核可执行文件的开头是一段元数据,这会导致 Qemu 内存0x80200000
处无法找到内核第一条指令,也就意味着 RustSBI 无法正常将计算机控制权转交给内核
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\f3f237572ad844fdd5de932f08ce844e.png)
丢弃元数据得到了内核镜像:
内核镜像的大小仅有 4 字节,这是因为它里面仅包含我们在 entry.asm
中编写的一条指令。一般情况下 RISC-V 架构的一条指令位宽即为 4 字节。而内核可执行文件由于包含了两部分元数据,其大小达到了 5432 字节。
这些元数据能够帮助我们更加灵活地加载并使用可执行文件,比如在加载时完成一些重定位工作或者动态链接。不过由于 Qemu 的加载功能过于简单,我们只能将这些元数据丢弃再交给 Qemu 。从某种意义上可以理解为我们手动帮助 Qemu 完成了可执行文件的加载。
7.函数调用
假如 CPU 依次执行的指令的物理地址序列为 {an},那么这个序列会符合怎样的模式呢?
-
CPU 一条条连续向下执行指令,也即满足递推公式 an+1=an+L
-
跳转指令: 当位于物理地址 an 的指令是一条跳转指令的时候,该模式就有可能被破坏。
跳转指令对应于我们在程序中构造的 控制流 的多种不同结构,比如分支结构(如 if/switch 语句)和循环结构(如 for/while 语句)
-
函数调用: 执行被调用函数的代码,等到它返回之后,我们会回到调用函数对应语句的下一行继续执行
比如,我们在两个不同的地方调用同一个函数,显然函数返回之后会回到不同的地址。
这带来一个难点: 其他控制流都只需要跳转到一个 编译期固定下来 的地址,而函数调用的返回跳转是跳转到一个 运行时确定 (确切地说是在函数调用发生的时候)的地址
-
函数调用指令:
rd←pc+4 //pc + 4:表示当前程序计数器值加上 4。这个加法操作通常用于计算下一条指令的地址 pc←pc+imm //将程序计数器的当前值与立即数 imm 相加,并将结果更新到程序计数器中。跳转到了函数
总结一下,在进行函数调用的时候,我们通过
jalr
指令保存返回地址并实现跳转;而在函数即将返回的时候,则通过ret
伪指令回到跳转之前的下一条指令继续执行。这样,RISC-V 的这两条指令就实现了函数调用流程的核心机制。 -
由于我们是在
ra
寄存器中保存返回地址的,我们要保证它在函数执行的全程不发生变化但遗憾的是,在实际编写代码的时候我们常常会遇到函数 多层嵌套调用 的情形, 该如何解决ra的覆盖问题呢?
我们必须通过某种方式保证:在一个函数调用子函数的前后,
ra
寄存器的值不能发生变化.由于函数调用,在控制流转移前后需要保持不变的寄存器集合称之为 函数调用上下文 (Function Call Context) 。
由于每个 CPU 只有一套寄存器,若想在子函数调用前后保持函数调用上下文不变,需要物理内存的帮助
调用子函数之前,我们需要在物理内存中的一个区域 保存函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并 恢复函数调用上下文中的寄存器。
-
这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成:
-
调用者保存寄存器 :
调用函数确保: 首先保存不希望在函数调用过程中发生变化的 调用者保存寄存器
即由发起调用的函数来保证在调用前后,这些寄存器保持不变
这种策略有时会导致调用者在函数调用之前执行额外的保存操作,但它允许被调用函数使用这些寄存器进行临时计算而不影响调用者的状态。
-
被调用者保存寄存器 :
被调用函数确保: 在被调用函数的起始,保存函数执行过程中被用到的被调用者保存寄存器
即由被调用的函数来保证在调用前后,这些寄存器保持不变;
这种策略使得被调用函数可以依赖于这些寄存器用于其内部计算,而不会干扰调用者的寄存器状态。调用者不需要担心这些寄存器的值被改变。
-
调用规范:调用规范 (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:
- 函数的输入参数和返回值如何传递;
- 函数调用上下文中调用者/被调用者保存寄存器的划分;
- 其他的在函数调用流程中对于寄存器的使用方法。
-
寄存器保存在栈上:
sp
寄存器常用来保存 栈指针 (Stack Pointer),它指向内存中栈顶地址。在一个函数中,作为起始的开场代码负责分配一块新的栈空间,即将
sp
的值减小相应的字节数即可在 RISC-V 架构中,栈是从高地址向低地址增长的。
于是物理地址区间 新旧[新sp,旧sp)
新<旧
对应的物理内存的一部分便可以被这个函数用来进行函数调用上下文的保存/恢复,这块物理内存被称为这个函数的 栈帧 (Stack Frame)同理,函数中的结尾代码负责将开场代码分配的栈帧回收:
仅仅需要将
sp
的值增加相同的字节数回到分配之前的状态。
-
在合适的编译选项设置之下,一个函数的栈帧内容可能如下图所示:
它的开头和结尾分别在 sp(x2) 和 fp(s0) 所指向的地址。按照地址从高到低分别有以下内容,它们都是通过 sp
加上一个偏移量来访问的:
ra
寄存器保存其返回之后的跳转地址,是一个被调用者保存寄存器;- 父亲栈帧的结束地址
fp
,是一个被调用者保存寄存器; - 其他被调用者保存寄存器
s1
~s11
; - 函数所使用到的局部变量。
使用 rust-objdump
工具反汇编内核或者应用程序可执行文件,并找到某个函数的入口。然后,我们能够看到在函数的开场和结尾阶段,编译器会生成类似的汇编代码:
# 开场
# 为当前函数分配 64 字节的栈帧
addi sp, sp, -64
# 将 ra 和 fp 压栈保存
sd ra, 56(sp)
sd s0, 48(sp)
# 更新 fp 为当前函数栈帧顶端地址
addi s0, sp, 64
# 函数执行
# 中间如果再调用了其他函数会修改 ra
# 结尾
# 恢复 ra 和 fp
ld ra, 56(sp)
ld s0, 48(sp)
# 退栈
addi sp, sp, 64
# 返回,使用 ret 指令或其他等价的实现方式
ret
我们在 entry.asm
中分配启动栈空间,并在控制权被转交给 Rust 入口之前将栈指针 sp
设置为栈顶的位置。
.section .text.entry
//这行定义了一个新的代码段(section)名为 .text.entry
globl _start
//这行声明 _start 为一个全局符号,意味着这个符号可以被其他文件或模块引用。
_start:
//定义一个标签 _start,它通常作为程序的入口点。在很多系统中,_start 是程序开始执行的地方。
la sp, boot_stack_top
//将 boot_stack_top 的地址加载到堆栈指针 sp 中。
//这实际上是初始化堆栈,设置栈顶为 boot_stack_top。
call rust_main
//调用一个 rust_main 函数。
.section .bss.stack
//定义了一个新的段 .bss.stack。
//.bss 段通常用于存放未初始化的数据。
.globl boot_stack_lower_bound
boot_stack_lower_bound://栈底标签
.space 4096 * 16
//分配了 64KB(4096 字节 * 16)的未初始化空间作为堆栈。这段空间位于 boot_stack_lower_bound 和 boot_stack_top 之间。
.globl boot_stack_top
boot_stack_top://栈顶标签
.bss
段一般放置需要被初始化为零的数据。然而栈并不需要在使用前被初始化为零,因为在函数调用的时候我们会插入栈帧覆盖已有的数据。我们尝试将其放置到全局数据.data
段中但最后未能成功,因此才决定将其放置到.bss
段中。全局符号sbss
和ebss
分别指向.bss
段除.bss.stack
以外的起始和终止地址,我们在使用这部分数据之前需要将它们初始化为零,这个过程将在下一节进行。
在内核初始化中,需要先完成对 .bss
段的清零。在 rust_main
的开头完成这一工作,由于控制权已经被转交给 Rust ,不用手写汇编代码而是可以用 Rust 来实现这一功能了:
// 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) }
});
}
8.实现输出功能
本章通过SBI实现输出等功能
![img](file:///C:\Users\范zs\Documents\Tencent Files\1017327044\nt_qq\nt_data\Pic\2024-08\Ori\c9cf876eb4f3a079c7eb3bf44087a02a.png)
引入SBI
在main.rs中用mod将sbi.rs加入项目
再编写sbi.rs文件, 直接调用 sbi_rt 提供的接口来将输出字符:
pub fn console_putchar(c: usize) {
#[allow(deprecated)]
sbi_rt::legacy::console_putchar(c);
}
现在, 可以使用console_putchar
来输出字符了
接下来实现关机功能, 也是调用的:
1// os/src/sbi.rs
2pub fn shutdown(failure: bool) -> ! {
3 use sbi_rt::{system_reset, NoReason, Shutdown, SystemFailure};
4 if !failure {
5 system_reset(Shutdown, NoReason);
6 } else {
7 system_reset(Shutdown, SystemFailure);
8 }
9 unreachable!()
10}
同时添加判断, 是否是由于错误推出的
// 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(())
}
}
//为 Stdout 实现了 core::fmt::Write trait,提供了一个 write_str 方法。
//这个方法将字符串s中的每个字符转换为usize类型并用console_putchar函数发送到控制台。
pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
//print 函数接受格式化的参数 fmt::Arguments,使用 Stdout 实例来格式化并打印这些参数。unwrap() 用于处理 write_fmt 方法可能返回的错误,确保任何错误都会导致程序崩溃。
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
}
}//print! 宏用于格式化字符串并调用 console::print 函数。宏的作用是将格式化字符串和参数传递给 print 函数。
#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}//println! 宏类似于 print!,但是在格式化的字符串末尾自动添加了一个换行符。它通过 concat! 宏将换行符与格式化字符串连接起来,然后调用 print 函数。
处理错误, 处理出错时的反应, 编写了一个panic函数
// os/src/lang_items.rs
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
// 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)
}
// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
clear_bss();
println!("Hello, world!");
panic!("Shutdown machine!");
}//最终main.rs正常运行
至此, 我们实现了输出helloworld的功能!
标签:操作系统,nt,指令,内存,os,实验,内核,Qemu,进度 From: https://www.cnblogs.com/Fgociallo/p/18362967