Merge branch 'main' of github.com:LearningOS/uCore-Tutorial-Book into main

This commit is contained in:
zyr 2021-09-15 21:41:00 +08:00
commit e8a065d921
7 changed files with 145 additions and 344 deletions

View File

@ -35,6 +35,52 @@
│   └── types.h
├── README.md
OS是怎么跑起来的
-------------------------------
我们的OS的运行是要依赖著名的模拟器软件-qemu的。比较形象的比喻是我们的os就是一个内核软件qemu就类似一个主板它模拟了许多硬件比如CPUI/O串口等等。我们的OS会和qemu模拟出来的这些硬件打交道而qemu则把得到的指令分配给实际存在的硬件完成。
我们的OS启动的时候就像一个真正的操作系统启动一样。qemu使用我们提供的rustsbi的bin文件做为引导程序来启动OS。同时我们的内核做为运行在qemu中的虚拟机是无法直接和我们的外部host系统通信的因此我们OS自己实现的printf函数想要真正地输出到我们外部运行的shell上被我们看到是要经过qemu的。实际上在启动时sbi已经帮我们初始化好了经过qemu模拟出来的串口最终打印到我们外部的shell上的。之后从我们的shell之中读取输入也是同样的道理。sbi为我们内核提供的功能不止于输入输出在sbi.c文件的可以看到其他支持的功能比如关机。
.. note::
**RustSBI 是什么?**
SBI 是 RISC-V 的一种底层规范RustSBI 是它的一种实现。 操作系统内核与 RustSBI 的关系有点像应用与操作系统内核的关系后者向前者提供一定的服务。只是SBI提供的服务很少 比如关机,显示字符串,读入字符串等。
qemu是怎么跑起来的
-------------------------------
第0章大家配置好了qemu之后可能就没再打开过了。qemu做为模拟器用途很多操作也比较复杂。因此我们在makefile之中提供了具体运行qemu所需要的参数大家无需更改。
.. code-block:: makefile
QEMU = qemu-system-riscv64
QEMUOPTS = \
-nographic \
-smp $(CPUS) \
-machine virt \
-bios $(BOOTLOADER) \
-kernel kernel
run: $(BUILDDIR)/kernel
$(QEMU) $(QEMUOPTS)
这个就是最关键的地方:make run。我们查看这条指令的结构它首先执行上面 kernel 所需要的链接以及编译操作得到一个二进制的kernel。之后执行按照QEMUOPTS变量指定的参数启动qemu。QEMUOPTS意义如下
- nographic: 无图形界面
- smp 1: 单核 (默认值,可以省略)
- machine virt: 模拟硬件 RISC-V VirtIO Board
- bios $(bios): 使用制定 bios这里指向的是我们提供的 rustsbi 的bin文件。
- kernel 使用 elf 格式的 kernel。这里就是我们需要写的OS内核了。
make run这个指令应该会陪伴大家走过接下来所有的实验qaq。它完成了内核代码的编译生成kernel并按照QEMUOPTS变量指定的参数加载我们的kernel“加电”启动qemu。 此时CPU 的其它通用寄存器清零,而 PC 会指向 0x1000 的位置,这里有固化在硬件中的一小段引导代码,它会很快跳转到 0x80000000 的 RustSBI 处。 RustSBI完成硬件初始化后会跳转到 $(KERNEL_BIN) 所在内存位置 0x80200000 处, 执行我们操作系统的第一条指令。
.. image:: chap1-intro.png
:align: center
:name: function-call
那么知道了这些步骤之后关键就是怎么去写我们的OS了这也是我们接下来各个实验的内容~。我们OS的代码基本全部在os文件夹下。nfs文件夹下有一些文件系统相关的内容在第七章之前大家无需关注这个文件夹下的内容。
os文件夹
-------------------------------
@ -123,3 +169,5 @@ bootloader文件夹
这个文件夹是用来存放 bootloader(也就是 rustsbi) 的 bin 文件的,这一章以及之后都无需我们做任何修改。
硬件加电之后是处于M态而 rustsbi 帮助我们完成了 M 态的初始化,最终将 PC 移动至我们 os 开始执行的位置。同时它也会帮助S态的 os 完成一些基本管理,详情可以看 os/sbi.c 文件。

View File

@ -85,16 +85,8 @@ makefile 内部
run: $(BUILDDIR)/kernel
$(QEMU) $(QEMUOPTS)
这个就是最关键的地方:make run。我们查看这条指令的结构它首先执行上面 kernel 所需要的链接以及编译操作得到一个二进制的kernel。之后执行按照QEMUOPTS变量指定的参数启动qemu。QEMUOPTS意义如下
- nographic: 无图形界面
- smp 1: 单核 (默认值,可以省略)
- machine virt: 模拟硬件 RISC-V VirtIO Board
- bios $(bios): 使用制定 bios这里指向的是我们提供的 rustsbi 的bin文件。
- kernel 使用 elf 格式的 kernel。
这里和前面一致。大家不需要太关心qemu的更多细节我们涉及它的操作已经在makefile和sbi之中处理了。
因此qemu会按照上述的参数启动使用我们的rustsbi来进行一系列初始化并将程序计数器移动至0x80200000并开始执行我们的OS。我们之后所有执行测试都是使用的make run指令。
gdb 调试
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -1,325 +0,0 @@
理解应用程序和执行环境
==================================
.. toctree::
:hidden:
:maxdepth: 5
本节导读
-------------------------------
在前面几节,我们进行了大量的实验。接下来是要消化总结和归纳理解的时候了。
本节主要会进一步归纳总结执行程序和执行环境相关的基础知识:
- 物理内存与物理地址
- 函数调用与栈
- 调用规范
- 程序内存布局
- 执行环境
如果读者已经了解,可直接跳过,进入下一节。
.. _term-physical-address:
.. _term-physical-memory:
物理内存与物理地址
----------------------------
物理内存是计算机体系结构中一个重要的组成部分。在存储方面CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。
从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的
是,该下标通常不以 0 开头,而通常以 ``0x80000000`` 开头。总结一下的话就是, CPU 可以通过物理地址来 *逐字节* 访问物理内存中保存的
数据。
值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存)中的数据时,就有可能会引入端序和
地址对齐的问题。由于这并不是重点,我们在这里不展开说明。
.. _function-call-and-stack:
函数调用与栈
----------------------------
从汇编指令的级别看待一段程序的执行,假如 CPU 依次执行的指令的物理地址序列为 :math:`\{a_n\}`,那么这个序列会符合怎样的模式呢?
.. _term-control-flow:
其中最简单的无疑就是 CPU 一条条连续向下执行指令,也即满足递推式 :math:`a_{n+1}=a_n+L`,这里我们假设该平台的指令是定长的且均为
:math:`L` 字节(常见情况为 2/4 字节)。但是执行序列并不总是符合这种模式,当位于物理地址 :math:`a_n` 的指令是一条跳转指令的时候,
该模式就有可能被破坏。跳转指令对应于我们在程序中构造的 **控制流** (Control Flow) 的多种不同结构,比如分支结构(如 if/switch 语句)
和循环结构(如 for/while 语句)。用来实现上述两种结构的跳转指令,只需实现跳转功能,也就是将 pc 寄存器设置到一个指定的地址即可。
.. _term-function-call:
另一种控制流结构则显得更为复杂: **函数调用** (Function Call)。我们大概清楚调用函数整个过程中代码执行的顺序,如果是从源代码级的
视角来看,我们会去执行被调用函数的代码,等到它返回之后,我们会回到调用函数对应语句的下一行继续执行。那么我们如何用汇编指令来实现
这一过程?首先在调用的时候,需要有一条指令跳转到被调用函数的位置,这个看起来和其他控制结构没什么不同;但是在被调用函数返回的时候,我们
却需要返回那条跳转过来的指令的下一条继续执行。这次用来返回的跳转究竟跳转到何处,在对应的函数调用发生之前是不知道的。比如,我们在两个不同的
地方调用同一个函数,显然函数返回之后会回到不同的地址。这是一个很大的不同:其他控制流都只需要跳转到一个 *编译期固定下来* 的地址,而函数调用
的返回跳转是跳转到一个 *运行时确定* (确切地说是在函数调用发生的时候)的地址。
.. image:: function-call.png
:align: center
:name: function-call
对此,指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在 RISC-V 架构上,有两条指令即符合这样的特征:
.. list-table:: RISC-V 函数调用跳转指令
:widths: 20 30
:header-rows: 1
:align: center
* - 指令
- 指令功能
* - :math:`\text{jal}\ \text{rd},\ \text{imm}[20:1]`
- :math:`\text{rd}\leftarrow\text{pc}+4`
:math:`\text{pc}\leftarrow\text{pc}+\text{imm}`
* - :math:`\text{jalr}\ \text{rd},\ (\text{imm}[11:0])\text{rs}`
- :math:`\text{rd}\leftarrow\text{pc}+4`
:math:`\text{pc}\leftarrow\text{rs}+\text{imm}`
.. _term-source-register:
.. _term-immediate:
.. _term-destination-register:
.. note::
**RISC-V 指令各部分含义**
在大多数只与通用寄存器打交道的指令中, rs 表示 **源寄存器** (Source Register) imm 表示 **立即数** (Immediate)
是一个常数,二者构成了指令的输入部分;而 rd 表示 **目标寄存器** (Destination Register)它是指令的输出部分。rs 和 rd
可以在 32 个通用寄存器 x0~x31 中选取。但是这三个部分都不是必须的,某些指令只有一种输入类型,另一些指令则没有输出部分。
.. _term-pseudo-instruction:
从中可以看出,这两条指令除了设置 pc 寄存器完成跳转功能之外,还将当前跳转指令的下一条指令地址保存在 rd 寄存器中。
(这里假设所有指令的长度均为 4 字节,在不使用 C 标准指令集拓展的情况下成立)
在 RISC-V 架构中,
通常使用 ra(x1) 寄存器作为其中的 rd ,因此在函数返回的时候,只需跳转回 ra 所保存的地址即可。事实上在函数返回的时候我们常常使用一条
**伪指令** (Pseudo Instruction) 跳转回调用之前的位置: ``ret`` 。它会被汇编器翻译为 ``jalr x0, 0(x1)``,含义为跳转到寄存器
ra 保存的物理地址,由于 x0 是一个恒为 0 的寄存器,在 rd 中保存这一步被省略。
总结一下,在进行函数调用的时候,我们通过 jalr 指令
保存返回地址并实现跳转;而在函数即将返回的时候,则通过 ret 指令跳转之前的下一条指令继续执行。这两条指令实现了函数调用流程的核心机制。
由于我们是在 ra 寄存器中保存返回地址的,我们要保证它在函数执行的全程不发生变化,不然在 ret 之后就会跳转到错误的位置。事实上编译器
除了函数调用的相关指令之外确实基本上不使用 ra 寄存器。也就是说,如果在函数中没有调用其他函数,那 ra 的值不会变化,函数调用流程
能够正常工作。但遗憾的是,在实际编写代码的时候我们常常会遇到函数 **多层嵌套调用** 的情形。我们很容易想象,如果函数不支持嵌套调用,那么编程将会
变得多么复杂。如果我们试图在一个函数 :math:`f` 中调用一个子函数,在跳转到子函数 :math:`g` 的同时ra 会被覆盖成这条跳转指令的
下一条的地址,而 ra 之前所保存的函数 :math:`f` 的返回地址将会 `永久丢失`
.. _term-function-context:
.. _term-activation-record:
因此若想正确实现嵌套函数调用的控制流我们必须通过某种方式保证在一个函数调用子函数的前后ra 寄存器的值不能发生变化。但实际上,
这并不仅仅局限于 ra 一个寄存器,而是作用于所有的通用寄存器。这是因为,编译器是独立编译每个函数的,因此一个函数并不能知道它所调用的
子函数修改了哪些寄存器。而站在一个函数的视角,在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。因此这是必要的。
我们将由于函数调用,在控制流转移前后需要保持不变的寄存器集合称之为 **函数调用上下文** (Context) 或称 **活动记录** (Activation Record),利用这一概念
,则在函数调用前后需要保持不变的寄存器集合被称为函数调用上下文。
.. _term-save-restore:
由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在
内存中的一个区域 **保存** (Save) 函数调用上下文中的寄存器;而之后我们会从内存中同样的区域读取并 **恢复** (Restore) 函数调用上下文
中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
.. _term-callee-saved:
.. _term-caller-saved:
- **被调用者保存** (Callee-Saved) 寄存器,即被调用的函数保证调用它前后,这些寄存器保持不变;
- **调用者保存** (Caller-Saved) 寄存器,被调用的函数可能会覆盖这些寄存器。
从名字中可以看出,函数调用上下文由调用者和被调用者分别保存,其具体过程分别如下:
- 调用者:首先保存不希望在函数调用过程中发生变化的调用者保存寄存器,然后通过 jal/jalr 指令调用子函数,返回回来之后恢复这些寄存器。
- 被调用者:在函数开头保存函数执行过程中被用到的被调用者保存寄存器,然后执行函数,在退出之前恢复这些寄存器。
.. _term-prologue:
.. _term-epilogue:
我们发现无论是调用者还是被调用者,都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以分别将其称为 **开场白** (Prologue) 和
**收场白** (Epilogue),它们会由编译器帮我们自动插入。一个函数既有可能作为调用者调用其他函数,也有可能作为被调用者被其他函数调用。对于
它而言,如果在执行的时候需要修改被调用者保存寄存器,而必须在函数开头的开场白和结尾的收场白处进行保存;对于调用者保存寄存器则可以没有任何
顾虑的随便使用,因为它在约定中本就不需要承担保证调用者保存寄存器保持不变的义务。
.. note::
**寄存器保存与编译器优化**
这里值得说明的是,调用者和被调用者实际上只需分别按需保存调用者保存寄存器和被调用者保存寄存器的一个子集。对于调用者而言,那些内容
并不重要,即使在调用子函数的时候被覆盖也不影响函数执行的调用者保存寄存器不会被编译器保存;而对于被调用者而言,在其执行过程中没有
使用到的被调用者保存寄存器也无需保存。编译器作为寄存器的使用者自然知道在这两个场景中,分别有哪些值得保存的寄存器。
从这一角度也可以理解为何要将函数调用上下文分成两类:可以在尽可能早的时候优化掉一些无用的寄存器保存与恢复。
.. _term-calling-convention:
调用规范
----------------
**调用规范** (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:
1. 函数的输入参数和返回值如何传递;
2. 函数调用上下文中调用者/被调用者保存寄存器的划分;
3. 其他的在函数调用流程中对于寄存器的使用方法。
调用规范是对于一种确定的编程语言来说的,因为一般意义上的函数调用只会在编程语言的内部进行。当一种语言想要调用用另一门编程语言编写的函数
接口时,编译器就需要同时清楚两门语言的调用规范,并对寄存器的使用做出调整。
.. note::
**RISC-V 架构上的 C 语言调用规范**
RISC-V 架构上的 C 语言调用规范可以在 `这里 <https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf>`_ 找到。
它对通用寄存器的使用做出了如下约定:
.. list-table:: RISC-V 寄存器功能分类
:widths: 20 20 40
:align: center
:header-rows: 1
* - 寄存器组
- 保存者
- 功能
* - a0~a7
- 调用者保存
- 用来传递输入参数。特别的 a0 和 a1 用来保存返回值。
* - t0~t6
- 调用者保存
- 作为临时寄存器使用,在函数中可以随意使用无需保存。
* - s0~s11
- 被调用者保存
- 作为临时寄存器使用,保存后才能在函数中使用。
剩下的 5 个通用寄存器情况如下:
- zero(x0) 之前提到过,它恒为零,函数调用不会对它产生影响;
- ra(x1) 是调用者保存的,不过它并不会在每次调用子函数的时候都保存一次,而是在函数的开头和结尾保存/恢复即可,因为在执行期间即使被
覆盖也没有关系。看上去和被调用者保存寄存器保存的位置一样,但是它确实是调用者保存的。
- sp(x2) 是被调用者保存的。这个之后就会提到。
- gp(x3) 和 tp(x4) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。它们的用途在后面的章节会提到。
更加详细的内容可以参考 Cornell 的 `课件 <http://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/slides/10-calling-notes-bw.pdf>`_
.. _term-stack:
.. _term-stack-pointer:
.. _term-stack-frame:
之前我们讨论了函数调用上下文的保存/恢复时机以及寄存器的选择,但我们并没有详细说明这些寄存器保存在哪里,只是用“内存中的一块区域”草草带过。实际上,
它更确切的名字是 **栈** (Stack) 。 sp(x2) 常用来保存 **栈指针** (Stack Pointer),它是一个指向了内存中已经用过的位置的一个地址。在
RISC-V 架构中,栈是从高地址到低地址增长的。在一个函数中,作为起始的开场白负责分配一块新的栈空间,其实它只需要知道需要空间的大小,然后将 sp
的值减小相应的字节数即可,于是物理地址区间 :math:`[\text{新sp},\text{旧sp})` 对应的物理内存便可以被这个函数用来函数调用上下文的保存/恢复
以及其他工作,这块物理内存被称为这个函数的 **栈帧** (Stackframe)。同理,函数中作为结尾的收场白负责将开场白分配的栈帧回收,这也仅仅需要
将 sp 的值增加相同的字节数回到分配之前的状态。这也可以解释为什么 sp 是一个被调用者保存寄存器。
.. figure:: CallStack.png
:align: center
函数调用与栈帧:如图所示,我们能够看到在程序依次调用 a、调用 b、调用 c、c 返回、b 返回整个过程中栈帧的分配/回收以及 sp 寄存器的变化。
图中标有 a/b/c 的块分别代表函数 a/b/c 的栈帧。
.. _term-lifo:
.. note::
**数据结构中的栈与实现函数调用所需要的栈**
从数据结构的角度来看,栈是一个 **后入先出** (Last In First Out, LIFO) 的线性表,支持向栈顶压入一个元素以及从栈顶弹出一个元素
两种操作,分别被称为 push 和 pop。从它提供的接口来看它只支持访问栈顶附近的元素。因此在实现的时候需要维护一个指向栈顶
的指针来表示栈当前的状态。
我们这里的栈与数据结构中的栈原理相同,在很多方面可以一一对应。栈指针 sp 可以对应到指向栈顶的指针,对于栈帧的分配/回收可以分别
对应到 push/pop 操作。如果将我们的栈看成一个内存分配器,它之所以可以这么简单,是因为它回收的内存一定是 *最近一次分配* 的内存,
从而只需要类似 push/pop 的两种操作即可。
在合适的编译选项设置之下,一个函数的栈帧内容可能如下图所示:
.. figure:: StackFrame.png
:align: center
函数栈帧中的内容
它的开头和结尾分别在 sp(x2) 和 fp(s0) 所指向的地址。按照地址从高到低分别有以下内容,它们都是通过 sp 加上一个偏移量来访问的:
- ra 寄存器保存其返回之后的跳转地址,是一个调用者保存寄存器;
- 父亲栈帧的结束地址 fp是一个被调用者保存寄存器
- 其他被调用者保存寄存器 s1~s11
- 函数所使用到的局部变量。
因此,栈上实际上保存了一条完整的函数调用链,通过适当的方式我们可以实现对它的跟踪。
至此,我们基本上说明了函数调用是如何基于栈来实现的。不过我们可以暂时先忽略掉这些细节,因为我们现在只是需要在初始化阶段完成栈的设置,也就是
设置好栈指针 sp 寄存器,后面的函数调用相关机制编译器会帮我们自动完成。麻烦的是, sp 的值也不能随便设置。至少我们需要保证它仍在物理内存上,
而且不能与程序的其他代码、数据段相交,因为在函数调用的过程中,栈区域里面的内容会被修改。如何保证这一点呢?此外,之前我们还提到我们编写的
初始化代码必须放在物理地址 ``0x80020000`` 开头的内存上,这又如何做到呢?事实上,这两点都需要我们接下来讲到的程序内存布局的知识。
程序内存布局
----------------------------
.. _term-section:
.. _term-memory-layout:
在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在
程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可用的存储空间。事实上
我们还可以根据其功能进一步把两个部分划分为更小的单位: **段** (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的
**内存布局** (Memory Layout)。一种典型的程序相对内存布局如下:
.. figure:: MemoryLayout.png
:align: center
一种典型的程序相对内存布局
代码部分只有代码段 ``.text`` 一个段,存放程序的所有汇编代码。
数据部分则还可以继续细化:
.. _term-heap:
- 已初始化数据段保存程序中那些已初始化的全局数据,分为 ``.rodata````.data`` 两部分。前者存放只读的全局数据,通常是一些常数或者是
常量字符串等;而后者存放可修改的全局数据。
- 未初始化数据段 ``.bss`` 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,也即将这块区域逐字节清零;
- **堆** (heap) 区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
- 栈区域 stack 不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内。它向低地址增长。
.. note::
**局部变量与全局变量**
在一个函数的视角中,它能够访问的变量包括以下几种:
- 函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前 sp 加上一个偏移量来访问的;
- 全局变量:保存在数据段 ``.data````.bss`` 中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于
gp 加上一个偏移量来访问的。
- 堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 *直接* 访问栈上或者全局数据段中的 **编译期确定大小** 的变量。
因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量
放在栈帧里面,也可以作为全局变量放在全局数据段中。
我们可以将常说的编译流程细化为多个阶段(虽然输入一条命令便可将它们全部完成):
.. _term-compiler:
.. _term-assembler:
.. _term-linker:
.. _term-object-file:
1. **编译器** (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;
2. **汇编器** (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 **目标文件** (Object File)
3. **链接器** (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
每个目标文件都有着自己局部的内存布局,里面含有若干个段。在链接的时候,链接器会将这些内存布局合并起来形成一个整体的内存布局。此外,每个目标文件
都有一个符号表,里面记录着它需要从其他文件中寻找的外部符号和能够提供给其他文件的符号,通常是一些函数和全局变量等。在链接的时候汇编器会将
外部符号替换为实际的地址。
.. note::
本节内容部分参考自:
- `RISC-V C 语言调用规范 <https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf>`_
- `Notes from Cornell CS3410 2019Spring <http://www.cs.cornell.edu/courses/cs3410/2019sp/schedule/slides/10-calling-notes-bw.pdf>`_
- `Lecture from Berkeley CS61C 2018Spring <https://inst.eecs.berkeley.edu/~cs61c/sp18/lec/06/lec06.pdf>`_
- `Lecture from MIT 6.828 2020 <https://pdos.csail.mit.edu/6.828/2020/lec/l-riscv-slides.pdf>`_

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -9,7 +9,6 @@
0intro
1app-ee-platform
2remove-std
3understand-prog
4exercise
3exercise

View File

@ -72,7 +72,53 @@ ecall作为异常的一种操作系统和CPU对它的处理方式其实和其
/* 280 */ uint64 t6;
};
然后就跳转到了我们早先设定在 trapframe->kernel_trap 中的地址,也就是 trap.c 之中的 usertrap 函数。这个函数在main的初始化之中已经调用了。
由于涉及到直接操作寄存器因此这里只能使用汇编语言来编写。具体可以参考trampoline.S之中的代码:
.. code-block:: c
.section .text
.globl trampoline
trampoline:
.align 4
.globl uservec
uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# sscratch points to where the process's p->trapframe is
# mapped into user space, at TRAPFRAME.
#
# swap a0 and sscratch
# so that a0 is TRAPFRAME
csrrw a0, sscratch, a0
# save the user registers in TRAPFRAME
sd ra, 40(a0)
...
sd t6, 280(a0)
# save the user a0 in p->trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)
csrr t1, sepc
sd t1, 24(a0)
ld sp, 8(a0)
ld tp, 32(a0)
ld t1, 0(a0)
# csrw satp, t1
# sfence.vma zero, zero
ld t0, 16(a0)
jr t0
这里需要注意sscratch这个CSR寄存器的作用就是一个cache它只负责存某一个值这里它保存的就是TRAPFRAME结构体的位置。csrr和csrrw指令是RV特供的读写CSR寄存器的指令。我们取用它的值的时候实际把原来a0的值和其交换了因此返回时大家可以看到我们会再交换一次得到原来的a0。这里注释了两句代码大家可以不用管这是页表相关的处理我们在ch4会仔细了解它。
然后我们使用jr t0,就跳转到了我们早先设定在 trapframe->kernel_trap 中的地址,也就是 trap.c 之中的 usertrap 函数。这个函数在main的初始化之中已经调用了。
.. code-block:: c
@ -85,20 +131,18 @@ ecall作为异常的一种操作系统和CPU对它的处理方式其实和其
该函数完成异常中断处理与返回包括执行我们写好的syscall。
从S态返回U态是由 usertrapret 函数实现的。这里设置了返回地址sepc并调用另外一个 userret 汇编函数来恢复 trapframe 结构体之中的保存的U态执行流数据。最后执行sret指令从S态回到U态并将PC移动到sepc指定的位置。
从S态返回U态是由 usertrapret 函数实现的。这里设置了返回地址sepc并调用另外一个 userret 汇编函数来恢复 trapframe 结构体之中的保存的U态执行流数据。
.. code-block:: c
// os/trap.c
// 在这里回到U态继续执行用户程序。
void usertrapret(struct trapframe *trapframe, uint64 kstack)
{
trapframe->kernel_satp = r_satp(); // 本章无用
trapframe->kernel_satp = r_satp(); // kernel page table
trapframe->kernel_sp = kstack + PGSIZE; // process's kernel stack
trapframe->kernel_trap = (uint64)usertrap; // 设置了handler
trapframe->kernel_trap = (uint64)usertrap;
trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
w_sepc(trapframe->epc);
w_sepc(trapframe->epc); // 设置了sepc寄存器的值。
// set up the registers that trampoline.S's sret will use
// to get to user space.
@ -113,4 +157,47 @@ ecall作为异常的一种操作系统和CPU对它的处理方式其实和其
userret((uint64)trapframe);
}
这个过程中还有许多细节,大家将在课后习题中慢慢品味。
同样由于涉及寄存器的恢复以及未来页表satp寄存器的设置等userret也必须是一个汇编函数。它基本上就是uservec函数的镜像将保存在trapframe之中的数据依次读出用于恢复对应的寄存器实现恢复用户中断前的状态。
.. code-block:: c
.globl userret
userret:
# userret(TRAPFRAME, pagetable)
# switch from kernel to user.
# usertrapret() calls here.
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# switch to the user page table.在第四章才会有具体作用。
csrw satp, a1
sfence.vma zero, zero
# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
ld t0, 112(a0)
csrw sscratch, t0
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
ld t1, 80(a0)
ld t2, 88(a0)
...
ld t4, 264(a0)
ld t5, 272(a0)
ld t6, 280(a0)
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
需要注意最后执行的sret指令执行了2个事情从S态回到U态并将PC移动到sepc指定的位置继续执行用户程序。
这个过程中还有一些细节,大家将在课后习题中慢慢品味。