type
status
slug
date
summary
tags
category
password
icon
从ch1开始
以前写bss清空的操作时拿汇编写的,后面了解到有拿c同样能实现,不过麻烦一点,这里又学到了rust来实现:
c的实现方式
Rust实现
摘录自评论区:
我们想要达到的效果是:在
extern "C"
里面提到的sbss
和ebss
就只是两个在其他位置(链接脚本linker-qemu.ld
中)声明的全局符号,我们期望在链接的时候这两个符号能正确被修改为它们所在的地址,进而才能知道.bss段的位置并完成初始化。那我们怎么做呢?目前只能想到用FFI的方式来引入,根据官方文档,在extern "C"
块中似乎只能引用ABI接口,也就是一个函数签名,需要有函数名、参数列表和返回值。好像不能像C语言那样extern int c;
这样做。引入之后sbss
和ebss
都变成函数了,所以有as usize
将其转换成函数入口地址也就是符号自身的地址。
不对,其实是能类似于
extern int c;
这样用的,我刚看到这样的例子。有空改代码。
对比2024的训练营文档和rcore v3的,发现sbi.rs写的不一样,看了下v3的评论区,应该是rustsbi的接口更新了,最新的rustsbi的bootloader不支持原来的
sbi-rt = { version = "0.0.2", features = ["legacy"] }
的legacy内容,估计是这样才改的。ANSI转义序列
最开始就是看不懂这个:
这一段打印的内容看起来好怪,之前只是浅浅接触过ANSI转义序列(为了美化linux的用户名的终端显示)
ANSI转义序列,就是利用了 ASCII 中的十进制27,十六进制1B,八进制033所定义的那个字符 --
ESC
(退出键)。下面是 ESC 在 javascript 中的字符串表达:
所以可拆开:
\u{1B}
[{}m[{:>5}] {} \u{1B}
[0mCSI (Control Sequence Introducer) sequences
CSI序列 由 ESC [、若干个(包括0个)“参数字节”、若干个“中间字节”,以及一个“最终字节”组成。
wiki 解释:For Control Sequence Introducer, or CSI, commands, the
ESC [
(written as \e[
or \033[
in several programming languages) is followed by any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?
), then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./
), then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~
).
我把原来error的红色改成亮品红色:解释一下make run LOG=TRACE传入的环境变量的作用:
根据参数设置”最大“日志等级,这里最大指的是底线,只会展示比设置的等级更高的日志内容
#[panic_handler]
#[panic_handler]
是一种编译指导属性,用于标记核心库core中的 panic!
宏要对接的函数(该函数实现对致命错误的具体处理)。该编译指导属性所标记的函数需要具有 fn(&PanicInfo) -> !
函数签名,函数可通过 PanicInfo
数据结构获取致命错误的相关信息。这样Rust编译器就可以把核心库core中的 panic!
宏定义与 #[panic_handler]
指向的panic函数实现合并在一起,使得no_std程序具有类似std库的应对致命错误的功能。Q:这个意思是不是我的函数名不需要是panic,只要用了这个指导属性即可?
A:试试;的确如此
Rust Tips:Rust 模块化编程
将一个软件工程项目划分为多个子模块分别进行实现是一种被广泛应用的编程技巧,它有助于促进复用代码,并显著提升代码的可读性和可维护性。因此,众多编程语言均对模块化编程提供了支持,Rust 语言也不例外。
每个通过 Cargo 工具创建的 Rust 项目均是一个模块,取决于 Rust 项目类型的不同,模块的根所在的位置也不同。当使用
--bin
创建一个可执行的 Rust 项目时,模块的根是 src/main.rs
文件;而当使用 --lib
创建一个 Rust 库项目时,模块的根是 src/lib.rs
文件。在模块的根文件中,我们需要声明所有可能会用到的子模块。如果不声明的话,即使子模块对应的文件存在,Rust 编译器也不会用到它们。如上面的代码片段中,我们就在根文件 src/main.rs
中通过 mod lang_items;
声明了子模块 lang_items
,该子模块实现在文件 src/lang_item.rs
中,我们将项目中所有的语义项放在该模块中。
当一个子模块比较复杂的时候,它往往不会被放在一个独立的文件中,而是放在一个 src
目录下与子模块同名的子目录之下,在后面的章节中我们常会用到这种方法。例如第二章代码(参见代码仓库的 ch2
分支)中的 syscall
子模块就放在 src/syscall
目录下。对于这样的子模块,其所在目录下的 mod.rs
为该模块的根,其中可以进而声明它的子模块。同样,这些子模块既可以放在一个文件中,也可以放在一个目录下。
每个模块可能会对其他模块公开一些变量、类型或函数,而该模块的其他内容则是对其他模块不可见的,也即其他模块不允许引用或访问这些内容。在模块内,仅有被显式声明为 pub
的内容才会对其他模块公开。Rust 类内部声明的属性域和方法也可以对其他类公开或是不对其他类公开,这取决于它们是否被声明为 pub
。我们在 C++/Java 语言中能够找到相同功能的关键字:即 public/private
。提供上述可见性机制的原因在于让其他类/模块能够访问当前类/模块公开提供的内容而无需关心它们是如何实现的,它们实际上也无法看到这些具体实现,因为这些具体实现并未向它们公开。编译器会对可见性进行检查,例如,当一个类/模块试图访问其他类/模块未公开的方法时,将无法通过编译。
我们可以使用绝对路径或相对路径来引用其他模块或当前模块的内容。参考上面的 use core::panic::PanicInfo;
,类似 C++ ,我们将模块的名字按照层级由浅到深排列,并在相邻层级之间使用分隔符 ::
进行分隔。路径的最后一级(如 PanicInfo
)则表示我们具体要引用或访问的内容,可能是变量、类型或者方法名。当通过绝对路径进行引用时,路径最开头可能是项目依赖的一个外部库的名字,或者是 crate
表示项目自身的根模块。在后面的章节中,我们会多次用到它们。
模块小抄
qemu的命令
• 通过虚拟设备
-device
中的 loader
属性可以在 Qemu 模拟器开机之前将一个宿主机上的文件载入到 Qemu 的物理内存的指定位置中, file
和 addr
属性分别可以设置待载入文件的路径以及将文件载入到的 Qemu 物理内存上的物理地址。这里我们载入的 os.bin
被称为 内核镜像 ,它会被载入到 Qemu 模拟器内存的 0x80200000
地址处。 那么内核镜像 os.bin
是怎么来的呢?上一节中我们移除标准库依赖后会得到一个内核可执行文件 os
,将其进一步处理就能得到 os.bin
,具体处理流程我们会在后面深入讨论。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 开始执行内核的第一条指令,证明计算机的控制权已经被移交给我们的内核,也就达到了本节的目标。
注解
真实计算机的加电启动流程
真实计算机的启动流程大致上也可以分为三个阶段:
第一阶段:加电后 CPU 的 PC 寄存器被设置为计算机内部只读存储器(ROM,Read-only Memory)的物理地址,随后 CPU 开始运行 ROM 内的软件。我们一般将该软件称为固件(Firmware),它的功能是对 CPU 进行一些初始化操作,将后续阶段的 bootloader 的代码、数据从硬盘载入到物理内存,最后跳转到适当的地址将计算机控制权转移给 bootloader 。它大致对应于 Qemu 启动的第一阶段,即在物理地址 0x1000 处放置的若干条指令。可以看到 Qemu 上的固件非常简单,因为它并不需要负责将 bootloader 从硬盘加载到物理内存中,这个任务此前已经由 Qemu 自身完成了。 第二阶段:bootloader 同样完成一些 CPU 的初始化工作,将操作系统镜像从硬盘加载到物理内存中,最后跳转到适当地址将控制权转移给操作系统。可以看到一般情况下 bootloader 需要完成一些数据加载工作,这也就是它名字中 loader 的来源。它对应于 Qemu 启动的第二阶段。在 Qemu 中,我们使用的 RustSBI 功能较弱,它并没有能力完成加载的工作,内核镜像实际上是和 bootloader 一起在 Qemu 启动之前加载到物理内存中的。 第三阶段:控制权被转移给操作系统。由于篇幅所限后面我们就不再赘述了。值得一提的是,为了让计算机的启动更加灵活,bootloader 目前可能非常复杂:它可能也分为多个阶段,并且能管理一些硬件资源,从复杂性上它已接近一个传统意义上的操作系统。
makefile的一些小补充
在 Makefile 中,
:=
和 =
用于赋值,但它们的行为是不同的。=
是延迟赋值或者说是递归赋值。当使用=
赋值时,右侧的表达式在赋值时并不会立即求值,而是在每次使用这个变量时求值。这意味着如果你在赋值后改变了其他变量的值,这个变量的值也会改变。
:=
是立即赋值或者说是简单赋值。当使用:=
赋值时,右侧的表达式会立即求值,并将结果赋值给变量。这意味着无论你在赋值后如何改变其他变量的值,这个变量的值都不会改变。
这是一个例子来说明这两者的区别:
在这个例子中,如果你打印
x
的值,你会得到 Hello
,因为 x
的值在每次使用时都会重新计算。但是,如果你使用
:=
:在这个例子中,如果你打印
x
的值,你会得到空字符串,因为在赋值时 y
还没有被定义。有个小坑关于makefile写的过程中(源自qemu的使用参数):
注意这个shell执行会报错:
qemu-system-riscv64: ,addr=0x80200000: Could not open ',addr=0x80200000': No such file or directory
因为一个空格!!!,在os.bin后面一定不要打空格,你检查下makefile这个变量不要后面有空格了
函数跳转
由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,就需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在物理内存中的一个区域 保存 (Save) 函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并 恢复 (Restore) 函数调用上下文中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
- 被调用者保存(Callee-Saved) 寄存器 :被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,这些寄存器保持不变;
- 调用者保存(Caller-Saved) 寄存器 :被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数来保证在调用前后,这些寄存器保持不变。
- 被调用函数:在被调用函数的起始,先保存函数执行过程中被用到的 被调用者保存寄存器 ,然后执行函数,最后在函数退出之前恢复这些寄存器。
- 调用函数:首先保存不希望在函数调用过程中发生变化的 调用者保存寄存器 ,然后通过 jal/jalr 指令调用子函数,返回之后恢复这些寄存器。
我们发现无论是调用函数还是被调用函数,都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以分别将其称为 开场 (Prologue) 和 结尾 (Epilogue),它们会由编译器帮我们自动插入,来完成相关寄存器的保存与恢复。一个函数既有可能作为调用者调用其他函数,也有可能作为被调用者被其他函数调用。
寄存器组 | 保存者 | 功能 |
a0~a7( x10~x17 ) | 调用者保存 | 用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。 |
t0~t6( x5~x7,x28~x31 ) | 调用者保存 | 作为临时寄存器使用,在被调函数中可以随意使用无需保存。 |
s0~s11( x8~x9,x18~x27 ) | 被调用者保存 | 作为临时寄存器使用,被调函数保存后才能在被调函数中使用。 |
剩下的 5 个通用寄存器情况如下:
- zero(
x0
) 之前提到过,它恒为零,函数调用不会对它产生影响;
- ra(
x1
) 是被调用者保存的。被调用者函数可能也会调用函数,在调用之前就需要修改ra
使得这次调用能正确返回。因此,每个函数都需要在开头保存ra
到自己的栈帧中,并在结尾使用ret
返回之前将其恢复。栈帧是当前执行函数用于存储局部变量和函数返回信息的内存结构。
- sp(
x2
) 是被调用者保存的。这个是之后就会提到的栈指针 (Stack Pointer) 寄存器,它指向下一个将要被存储的栈顶位置。
- fp(
s0
),它既可作为s0临时寄存器,也可作为栈帧指针(Frame Pointer)寄存器,表示当前栈帧的起始位置,是一个被调用者保存寄存器。fp 指向的栈帧起始位置 和 sp 指向的栈帧的当前栈顶位置形成了所对应函数栈帧的空间范围。
- gp(
x3
) 和 tp(x4
) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。它们的用途在后面的章节会提到。
遗留问题
这段代码的具体含义会在lab2说明
关于链接脚本的部分
OUTPUT_ARCH(riscv)
是链接脚本中的一个指令,它指定了目标架构为 RISC-V。在链接过程中,链接器需要知道目标架构的信息,以便正确地处理输入的对象文件和生成正确的输出文件。
OUTPUT_ARCH
指令就是用来提供这个信息的。在你给出的链接脚本中,
OUTPUT_ARCH(riscv)
指定了目标架构为 RISC-V。这意味着链接器将会按照 RISC-V 架构的规则来处理输入的对象文件和生成输出的可执行文件。想着顺便“复习”马上的操作系统半期考试,并且第二阶段还有段时间开始,就跳着看看,先看并发的部分:
并发、线程、进程
在线程的具体运行过程中,需要有程序计数器寄存器来记录当前的执行位置,需要有一组通用寄存器记录当前的指令的操作数据,需要有一个栈作为线程执行过程的函数调用栈保存局部变量等内容,这就形成了线程上下文的主体部分。这样如果两个线程运行在一个处理器上,就需要采用类似两个进程运行在一个处理器上的调度/切换管理机制,即需要在一定时刻进行线程切换,并进行线程上下文的保存与恢复。这样在一个进程中的多线程可以独立运行,取代了进程,成为操作系统调度的基本单位。
这里我们关注 UNIX 操作系统中与进程(Process)相关的一些设计实现思路。简单地说,UNIX 操作系统中的进程实现充分吸取了 MULTICS 中关于进程的设计思想,实现了
fork exec wait exit
四个精巧的系统调用来支持对进程的灵活管理。父进程进程通过 fork
系统调用创建自身的副本(子进程);称为“子进程”的副本可调用 exec
系统调用用另一个程序覆盖其内存空间,这样就可以执行新程序了;子进程执行完毕后,可通过调用 exit
系统调用来退出并通知父进程;父进程通过调用 wait
系统调用来等待子进程的退出。
打一个比方,可执行文件本身可以看成一张编译器解析源代码之后总结出的一张记载如何利用各种硬件资源进行一轮生产流程的 蓝图 。而内核的一大功能便是作为一个硬件资源管理器,它可以随时启动一轮生产流程(即执行任意一个应用),这需要选中一张蓝图(此时确定执行哪个可执行文件),接下来就需要内核按照蓝图上所记载的对资源的需求来对应的将各类资源分配给它,让这轮生产流程得以顺利进行。当按照蓝图上的记载生产流程完成(应用退出)之后,内核还需要将对应的硬件资源回收以便后续的重复利用。
因此,进程就是操作系统选取某个可执行文件并对其进行一次动态执行的过程。相比可执行文件,它的动态性主要体现在:
- 它是一个过程,从时间上来看有开始也有结束;
- 在该过程中对于可执行文件中给出的需求要相应对 硬件/虚拟资源 进行 动态绑定和解绑 。
这里需要指出的是,两个进程可以选择同一个可执行文件执行,然而它们却是截然不同的进程:它们的启动时间、占据的硬件资源、输入数据均有可能是不同的,这些条件均会导致它们是不一样的执行过程。在某些情况下,我们可以看到它们的输出是不同的——这是其中一种可能的直观表象。
随着计算机的发展,对计算机系统性能的要求越来越高,而进程之间的切换开销相对较大,于是计算机科学家就提出了线程。线程是程序执行中一个单一的顺序控制流程,线程是进程的一部分,一个进程可以包含一个或多个线程。各个线程之间共享进程的地址空间,但线程要有自己独立的栈(用于函数访问,局部变量等)和独立的控制流。且线程是处理器调度和分派的基本单位。对于线程的调度和管理,可以在操作系统层面完成,也可以在用户态的线程库中完成。用户态线程也称为绿色线程(GreenThread)。如果是在用户态的线程库中完成,操作系统是“看不到”这样的线程的,也就谈不上对这样线程的管理了。
协程(Coroutines,也称纤程(Fiber)),也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又是比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善。在具体实现上,协程可以在用户态运行时库这一层面通过函数调用来实现;也可在语言级支持协程,比如 Rust 借鉴自其他语言的的async
、await
关键字等,通过编译器和运行时库二者配合来简化程序员编程的负担并提高整体的性能。
- 作者:liamY
- 链接:https://liamy.clovy.top/article/OS_Tutorial/lab1
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。