new version

pull/1/head
Zhiyuan Shao 4 years ago
parent 03bbd5ce68
commit f2f011d7fe

@ -4,24 +4,46 @@
[前言](preliminary.md)
[第一章. RISC-V体系结构](chapter1.md)
- [1.1 RISC-V发展历史](chapter1.md#history)
- [1.2 RISC-V汇编语言](chapter1.md#assembly)
- [1.3 机器的特权状态](chapter1.md#machinestates)
- [1.4 中断和中断处理](chapter1.md#traps)
- [1.5 页式虚存管理](chapter1.md#paging)
- [1.6 什么是代理内核](chapter1.md#proxykernel)
- [1.7 相关工具软件](chapter1.md#toolsoftware)
[第一章. RISC-V体系结构](chapter1_riscv.md)
- [1.1 RISC-V发展历史](chapter1_riscv.md#history)
- [1.2 RISC-V汇编语言](chapter1_riscv.md#assembly)
- [1.3 机器的特权状态](chapter1_riscv.md#machinestates)
- [1.4 中断和中断处理](chapter1_riscv.md#traps)
- [1.5 页式虚存管理](chapter1_riscv.md#paging)
- [1.6 什么是代理内核](chapter1_riscv.md#proxykernel)
- [1.7 相关工具软件](chapter1_riscv.md#toolsoftware)
[第二章. 实验1非法指令的截获](chapter2.md)
[第二章. 实验环境的安装与使用](chapter2_installation.md)
- [2.1 头歌平台](chapter2_installation.md#educoder)
- [2.2 Ubuntu环境](chapter2_installation.md#ubuntu)
- [2.3 openEuler操作系统](chapter2_installation.md#openeuler)
[第三章. 实验2系统调用的实现](chapter3.md)
[第三章. 实验1系统调用、异常和外部中断](chapter3_traps.md)
- [3.1 实验1的基础知识](chapter3_traps.md#fundamental)
- [3.2 lab1_1 系统调用](chapter3_traps.md#syscall)
- [3.3 lab1_2 异常处理](chapter3_traps.md#exception)
- [3.4 lab1_3外部中断](chapter3_traps.md#irq)
[第四章. 实验3物理内存管理](chapter4.md)
[第四章. 实验2内存管理](chapter4_memory.md)
- [4.1 实验2的基础知识](chapter4_memory.md#fundamental)
- [4.2 lab2_1 虚实地址转换](chapter4_memory.md#lab2_1_pagetable)
- [4.3 lab2_2 简单内存分配和回收](chapter4_memory.md#lab2_2_allocatepage)
- [4.4 lab2_3 缺页异常](chapter4_memory.md#lab2_3_pagefault)
[第五章. 实验4缺页异常的处理](chapter5.md)
[第五章. 实验3进程管理](chapter5_process.md)
- [5.1 实验3的基础知识](chapter5_process.md#fundamental)
- [5.2 lab3_1 进程创建](chapter5_process.md#lab3_1_naive_fork)
- [5.3 lab3_2 进程yield](chapter5_process.md#lab3_2_yield)
- [5.4 lab3_3 循环轮转调度](chapter5_process.md#ilab3_3_rrsched)
第六章. 实验4设备管理
第七章. 实验5文件系统
[第六章. 实验5进程的封装](chapter6.md)
[课程设计](课程设计.md)

@ -704,8 +704,9 @@ satp寄存器包含一个44位的PPN它指向了一个页表的根目录所
我们认为代理内核除了它的实用特性如以上讨论的对RISC-V的开发和验证具有很好的教学用途。在开发代理内核的过程中学生只需要将精力放在对计算机的“核心资产”即处理器和内存的管理和通过各种数据结构实现的逻辑到物理的映射上而无须过多考虑对IO设备控制的细节。这些都有助于降低操作系统开发的复杂度尽量降低学生开发的难度和复杂度。**同时,代理内核的目标是支撑给定目标应用,无需搞个“大而全”的内核,去运行各种可能的应用**。
一个显而易见的事实是:**比起复杂的多进程网络服务器(如[Apache HTTP Server](https://httpd.apache.org/)所需要的操作系统支持而言简单的Hello world!程序所需要的操作系统支持显然要少得多基于该事实采用代理内核思想设计操作系统实验的一个更Strong的理由是我们可以通过对输入的目标应用的迭代从易到难从简单到复杂反推代理操作系统内核本身的“进化”学生在进化代理内核的过程中完成对操作系统课程知识点的学习。
一个显而易见的事实是:**比起复杂的多进程网络服务器(如[Apache HTTP Server](https://httpd.apache.org/)所需要的操作系统支持而言简单的Hello world!程序所需要的操作系统支持显然要少得多!**例如PKE的第一个实验lab1_1所就采用了Hello world!程序作为目标应用而支撑该应用的PKE内核代码只有500多行这可能是世界上**最小**的“操作系统”了!基于该事实采用代理内核思想设计操作系统实验的一个更Strong的理由是我们可以通过对输入的目标应用的迭代从易到难从简单到复杂反推代理操作系统内核本身的“进化”学生在进化代理内核的过程中完成对操作系统课程知识点的学习。
需要说明的是我们的PKE实验代码即可以满足操作系统课程实验的开发需求同时所开发的代理内核可以无缝地在Zedboard开发板上运行。这样PKE实验实际上是同时兼顾了教学用途采用Spike模拟RISC-V环境和实际工程开发在Zedboard开发板的PL上搭载需要验证的RISC-V处理器软核用途的。这样就为选择PKE实验的学生打下了一定的RISC-V软硬协同开发基础。
<a name="toolsoftware"></a>
### 1.7 相关工具软件

@ -1,589 +0,0 @@
# 第二章实验1非法指令的截获
## 2.1 实验环境搭建
实验环境我们推荐采用Ubuntu 16.04LTS或18.04LTSx86_64操作系统我们未在其他系统如archRHEL等上做过测试但理论上只要将实验中所涉及到的安装包替换成其他系统中的等效软件包就可完成同样效果。另外我们在EduCoder实验平台网址https://www.educoder.net 上创建了本书的同步课程课程的终端环境中已完成实验所需软件工具的安装所以如果读者是在EduCoder平台上选择的本课程则可跳过本节的实验环境搭建过程直接进入通过终端命令行进入实验环境。
PKE实验涉及到的软件工具有RISC-V交叉编译器、spike模拟器以及PKE源代码三个部分。假设读者拥有了Ubuntu 16.04LTS或18.04LTSx86_64操作系统的环境以下分别介绍这三个部分的安装以及安装后的检验过程。需要说明的是为了避免耗时耗资源的构建build过程一个可能的方案是从https://toolchains.bootlin.com 下载,**但是要注意一些依赖包如GCC的版本号**。
**我们强烈建议读者在新装环境中完整构建buildRISC-V交叉编译器以及spike模拟器**。如果强调环境的可移植性,可以考虑在虚拟机中安装完整系统和环境,之后将虚拟机进行克隆和迁移。
### 2.1.1 RISC-V交叉编译器
RISC-V交叉编译器是与Linux自带的GCC编译器类似的一套工具软件集合不同的是x86_64平台上Linux自带的GCC编译器会将源代码编译、链接成为适合在x86_64平台上运行的二进制代码称为native code而RISC-V交叉编译器则会将源代码编译、链接成为在RISC-V平台上运行的代码。后者RISC-V交叉编译器生成的二进制代码是无法在x86_64平台即x86_64架构的Ubuntu环境下直接运行的它的运行需要模拟器我们采用的spike的支持。
一般情况下我们称x86_64架构的Ubuntu环境为host而在host上执行spike后所虚拟出来的RISC-V环境则被称为target。RISC-V交叉编译器的构建build、安装过程如下
● 第一步,安装依赖库
RISC-V交叉编译器的构建需要一些本地支撑软件包可使用以下命令安装
`$ sudo apt-get install autoconf automake autotools-dev curl libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat-dev device-tree-compiler`
● 第二步获取RISC-V交叉编译器的源代码
有两种方式获得RISC-V交叉编译器的源代码一种是通过源代码仓库获取使用以下命令
`$ git clone --recursive https://github.com/riscv/riscv-gnu-toolchain.git`
但由于RISC-V交叉编译器的仓库包含了Qemu模拟器的代码下载后的目录占用的磁盘空间大小约为4.8GB,(从国内下载)整体下载所需的时间较长。为了方便国内用户,我们提供了另一种方式就是通过百度云盘获取源代码压缩包,链接和提取码如下:
`链接: https://pan.baidu.com/s/1cMGt0zWhRidnw7vNUGcZhg 提取码: qbjh`
从百度云盘下载RISCV-packages/riscv-gnu-toolchain-clean.tar.gz文件大小为2.7GB再在Ubuntu环境下解压这个.tar.gz文件采用如下命令行
`$ tar xf riscv-gnu-toolchain-clean.tar.gz`
之后就能够看到和进入当前目录下的riscv-gnu-toolchain文件夹了。
● 第三步构建buildRISC-V交叉编译器
`$ cd riscv-gnu-toolchain`
`$ ./configure --prefix=[your.RISCV.install.path]`
`$ make`
以上命令中,[your.RISCV.install.path]指向的是你的RISC-V交叉编译器安装目录。如果安装是你home目录下的一个子目录如~/riscv-install-dir则最后的make install无需sudoer权限。但如果安装目录是系统目录如/opt/riscv-install-dir则需要sudoer权限即在make install命令前加上sudo
● 第四步,设置环境变量
`$ export RISCV=[your.RISCV.install.path]`
`$ export PATH=$PATH:$RISCV/bin`
以上命令设置了RISCV环境变量指向在第三步中的安装目录并且将交叉编译器的可执行文件所在的目录加入到了系统路径中。这样我们就可以在PKE的工作目录调用RISC-V交叉编译器所包含的工具软件了。
### 2.1.2 spike模拟器
接下来安装spkie模拟器。首先取得spike的源代码有两个途径一个是从github代码仓库中获取
`$ git clone https://github.com/riscv/riscv-isa-sim.git`
也可以从百度云盘中下载spike-riscv-isa-sim.tar.gz文件约4.2MB然后用tar命令解压缩。百度云盘的地址以及tar命令解压缩可以参考2.1.1节RISC-V交叉编译器的安装过程。获得spike源代码或解压后将在本地目录看到一个riscv-isa-sim目录。
接下来构建buildspike并安装
`$ cd riscv-isa-sim`
`$ ./configure --prefix=$RISCV`
`$ make`
`$ make install`
在以上命令中我们假设RISCV环境变量已经指向了RISC-V交叉编译器的安装目录。如果未建立关联可以将$RISCV替换为2.1.1节中的[your.RISCV.install.path]。
### 2.1.3 PKE
到github下载课程仓库
`$ git clone https://github.com/MrShawCode/riscv-pke.git`
克隆完成后将在当前目录看到pke目录。这时可以到pke目录下查看pke的代码结构例如
`$ cd pke`
`$ ls`
你可以看到当前目录下有如下(部分)内容
.
├── app
├── gradelib.py
├── machine
├── Makefile
├── pk
├── pke-lab1
├── pke.lds
└── util
● 首先是app目录里面存放的是实验的测试用例也就是运行在User模式的应用程序例如之前helloworld.c。
● gradelib.py、与pke-lab1是测试用的python脚本。
● machine目录里面存放的是机器模式相关的代码由于本课程的重点在于操作系统在这里你可以无需详细研究。
● Makefile文件它定义的整个工程的编译规则。
● pke.lds是工程的链接文件。
● util目录下是各模块会用到的工具函数。
● pk目录里面存放的是pke的主要代码。
即使未开始做PKE的实验我们的pke代码也是可构建的可以采用以下命令在pke目录下生成pke代理内核
`$ make`
以上命令完成后会在当前目录下会生产obj子目录里面就包含了我们的pke代理内核。pke代理内核的构建过程将在2.2节中详细讨论。
### 2.1.4 环境测试
全部安装完毕后你可以对环境进行测试在pke目录下输入
`$ spike ./obj/pke ./app/elf/app1_2`
将得到如下输出:
```
PKE IS RUNNING
user mode test illegal instruction!
you need add your code!
```
以上命令的作用是首先采用spike模拟一个RISC-V机器该机器支持RV64G指令集并在该机器上运行./app/elf/app1_2应用它的源代码在./app/app1_2.c中。我们知道应用是无法在“裸机”上运行的所以测试命令使用./obj/pke作为应用的代理内核。代理内核的作用是对spike模拟出来的RISC-V机器做简单的“包装”使其能够在spike模拟出来的机器上顺利运行。
这里,代理内核的作用是只为特定的应用服务(如本例中的./app/elf/app1_2应用所以可以做到“看菜吃饭”的效果。因为我们这里的应用非常简单所以pke就可以做得极其精简它没有文件系统、没有物理内存管理、没有进程调度、没有操作终端shell等等传统的完整操作系统“必须”具有的组件。在后续的实验中我们将不断提升应用的复杂度并不断完善代理内核。通过这个过程读者将深刻体会操作系统内核对应用支持的机制以及具体的实现细节。
## 2.2 实验内容
### 应用: ###
应用1helloworld.c代码如下
1 #include <stdio.h>
2 int global_init=1;
3 int global_uninit;
4 int main(){
5 int tmp;
6 printf("hello world!\n");
7 return 0;
8 }
应用说明以上应用通过调用库函数printf再标准输出上打印出字符串`hello world`。
应用2源代码见pke/app/elf/app1_1.c代码如下
1 #include<stdio.h>
2
3 long unsigned int addr_line=0x7f7ea000;
4 int main()
5 {
6
7 long unsigned int addr_u = 0x7f7ecc00;
8 long unsigned int addr_m = 0x8f000000;
9
10 //在用户模式下访问用户空间地址
11 printf("APP: addr_u 0x%016lx\n",addr_u);
12 *(int *)(addr_u)=1;
13
14 //用户模式下访问内核空间地址,会引发段错误
15 printf("APP: addr_m 0x%016lx\n",addr_m);
16 *(int *)(addr_m)=1;
17
18 return 0;
19 }
应用说明以上应用在第七行、第八行分别指定了两个地址其中第七行的地址属于用户空间第八行的地址属于内核空间。随即再分别对这两个地址进行访问在用户模式APP里对内核空间地址进行访问属于非法内存访问这会导致异常的产生。
应用3源代码见pke/app/elf/app1_2.c代码如下
1 #include<stdio.h>
2
3 #define test_csrw() ({ \
4 asm volatile ("csrw sscratch, 0"); })
5
6 int main()
7 {
8 printf("user mode test illegal instruction!\n");
9 // 在用户态调用内核态的指令属于非法,会引发非法指令错误!
10 test_csrw();
11 return 0;
12 }
应用说明:以上应用,使用内联汇编`csrw`对`sscratch`CSR寄存器进行写操作在用户模式APP里调用非法指令S级别的指令会抛出异常`illegal instruction`。
----------
### 任务1 打印hello world(编程) ###
任务描述: 使用编辑器将以上的helloworld.c的代码输入并存放在pke/app目录中。
确认以上交叉编译器、spike、pke已安装成功接下来进入pke/app目录编译helloworld.c执行以下命令
`$riscv64-unknown-elf-gcc helloworld.c -o elf/helloworld`
接下来回到上一级目录使用spike+pke来运行helloworld二进制文件执行以下命令
`$spike obj/pke app/elf/helloworld`
​你可以得到以下输出:
PKE IS RUNNING
hello world!
### 任务2 : 中断入口探寻(理解) ###
任务描述: CPU 运行到一些情况下会产生异常exception 例如访问无效的内存地址、执行非法指令除零、发生缺页等。用户程序进行系统调用syscall 或程序运行到断点breakpoint 时,也会主动触发异常。
当发生中断或异常时CPU 会立即跳转到一个预先设置好的地址执行中断处理程序最后恢复原程序的执行。这个地址。我们称为中断入口地址。在RISC-V中设有专门的CSR寄存器保存这个地址即stvec寄存器。
任务目标: 请你阅读pk/pk.c文件找出pk中设置中断入口函数的位置。
### 任务3 : 中断过程详究(理解) ###
任务描述: 中断的处理过程可以分为3步骤
- 保存当前环境寄存器
- 进入具体的中断异常处理函数
- 恢复中断异常前环境的寄存器
pk中使用trapframe_t结构体pk/pk.h来保存中断发生时常用的32个寄存器及部分特殊寄存器的值其结构如下。
typedef struct
{
long gpr[32];
long status;
long epc;
long badvaddr;
long cause;
long insn;
} trapframe_t;
任务目标: 阅读pk/entry.S详细分析同上述三个过程相对应的代码。
### 任务4: 中断的具体处理 (编程) ###
任务描述: 当中断异常发生后中断帧将会被传递给pk/handlers.c中的handle_trap函数。接着通过trapframe中的scause寄存器的值可以判断属于哪种中断异常从而选择相对应的中断处理函数。
在pk/handlers.c中的各种中断处理函数的实现其中segfault段错误的处理函数与illegal_instruction的处理函数并不完善。请你在pk/handlers.c中找到并完善segfault与handle_illegal_instruction两个函数。
预期输出:
当完成segfault代码后重新make pke然后输入如下命令
`$riscv64-unknown-elf-gcc ../app/app1_1.c -o ../app/elf/app1_1`
`$spike ./obj/pke app/elf/app1_1`
得到以下预期的输出
PKE IS RUNNING
APP: addr_u 0x7f7ecc00
APP: addr_m 0x8f000000
z 0000000000000000 ra 0000000000010192 sp 000000007f7ecb30 gp 000000000001da10
tp 0000000000000000 t0 0000000000000000 t1 000000007f7ec9f0 t2 0000219000080017
s0 000000007f7ecb50 s1 0000000000000000 a0 0000000000000017 a1 000000000001e220
a2 0000000000000017 a3 0000000000000000 a4 0000000000000001 a5 000000008f000000
a6 8080808080808080 a7 0000000000000040 s2 0000000000000000 s3 0000000000000000
s4 0000000000000000 s5 0000000000000000 s6 0000000000000000 s7 0000000000000000
s8 0000000000000000 s9 0000000000000000 sA 0000000000000000 sB 0000000000000000
t3 0000000000000000 t4 0000000000000078 t5 0000000000000000 t6 0000000000000000
pc 0000000000010198 va 000000008f000000 insn ffffffff sr 8000000200046020
User store segfault @ 0x000000008f000000
接着当你完成handle_illegal_instruction函数后输入如下命令
`$ riscv64-unknown-elf-gcc ../app/app1_2.c -o ../app/elf/app1_2`
`$ spike ./obj/pke app/elf/app1_2`
得到以下预期的输出:
PKE IS RUNNING
user mode test illegal instruction!
z 0000000000000000 ra 0000000000010162 sp 000000007f7ecb40 gp 0000000000013de8
tp 0000000000000000 t0 8805000503e80001 t1 0000000000000007 t2 0000219000080017
s0 000000007f7ecb50 s1 0000000000000000 a0 000000000000000a a1 0000000000014600
a2 0000000000000024 a3 0000000000000000 a4 0000000000000000 a5 0000000000000001
a6 0000000000000003 a7 0000000000000040 s2 0000000000000000 s3 0000000000000000
s4 0000000000000000 s5 0000000000000000 s6 0000000000000000 s7 0000000000000000
s8 0000000000000000 s9 0000000000000000 sA 0000000000000000 sB 0000000000000000
t3 0000000000000000 t4 000000005f195e48 t5 0000000000000000 t6 0000000000000000
pc 0000000000010162 va 0000000014005073 insn 14005073 sr 8000000200046020
An illegal instruction was executed!
如果你的两个测试app都可以以上输出的话可以接着运行检查的python脚本
`$./pke-lab1`
若得到如下输出,那么恭喜你,你已经成功完成了实验一。
build pk : OK
running app1 : OK
test1 : OK
running app2 : OK
test2 : OK
Score: 30/30
## 2.3 实验指导
**2.3.1 程序编译连接与ELF文件**
ELF的全称为Executable and Linkable Format是一种可执行二进制文件。
在这里我们仅仅之需要简单的了解一下ELF文件的基本组成原理以便之后能够很好的理解内核可执行文件以及其它的一些ELF文件加载到内存的过程。首先ELF文件可以分为这样几个部分ELF文件头、程序头表program header table、节头表section header table和文件内容。而其中文件内容部分又可以分为这样的几个节.text节、.rodata节、.stab节、.stabstr节、.data节、.bss节、.comment节。如果我们把ELF文件看做是一个连续顺序存放的数据块则下图可以表明这样的一个文件的结构。
<img src="pictures/fig2_1.png" alt="fig2_1" style="zoom:50%;" />
图2.1 ELF文件结构
从图2.1中可以看出ELF文件中需要读到内存的部分都集中在文件的中间下面我们首先就介绍一下中间的这几个节的具体含义
l .text节可执行指令的部分。
l .rodata节只读全局变量部分。
l .stab节符号表部分。
l .stabstr节符号表字符串部分具体的也会在第三章做详细的介绍。
l .data节可读可写的全局变量部分。
l .bss节未初始化的全局变量部分这一部分不会在磁盘有存储空间因为这些变量并没有被初始化因此全部默认为0于是在将这节装入到内存的时候程序需要为其分配相应大小的初始值为0的内存空间。
l .comment节注释部分这一部分不会被加载到内存。
结合刚才的hellowrold.c文件分析global_init作为初始化之后的全局变量, 存储在.data段中而global_uninit作为为初始化的全局变量存储在.bss段。函数中的临时变量tmp则不会被存储在ELF文件的数据段中。
在pke中ELF文件头结构的定义如下
typedef struct {
uint8_t e_ident[16]; //ELF文件标识包含用以表示ELF文件的字符
uint16_t e_type; //文件类型
uint16_t e_machine; //体系结构信息
uint32_t e_version; //版本信息
uint64_t e_entry; //程序入口点
uint64_t e_phoff; //程序头表偏移量
uint64_t e_shoff; //节头表偏移量
uint32_t e_flags; //处理器特定标志
uint16_t e_ehsize; //文件头长度
uint16_t e_phentsize; //程序头部长度
uint16_t e_phnum; //程序头部个数
uint16_t e_shentsize; //节头部长度
uint16_t e_shnum; //节头部个数
uint16_t e_shstrndx; //节头部字符索引
} Elf64_Ehdr;
ELF文件头比较重要的几个结构体成员是e_entry、e_phoff、e_phnum、e_shoff、e_shnum。其中e_entry是可执行程序的入口地址即从内存的这个位置开始执行在这里入口地址是虚拟地址也就是链接地址e_phoff和e_phnum可以用来找到所有的程序头表项e_phoff是程序头表的第一项相对于ELF文件的开始位置的偏移而e_phnum则是表项的个数同理e_ shoff和e_ shnum可以用来找到所有的节头表项。
以例1.1为例我们可以使用riscv64-unknown-elf-objdump工具查看该ELF文件。
首先,使用-x选项查看显示整体的头部内容。
`$riscv64-unknown-elf-objdump -x hellowrold >> helloworld.txt`
得到的输入文件helloworld.txt的主要内容如下
hellowrold: file format elf64-littleriscv
architecture: riscv:rv64, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x00000000000100c2
Program Header:
LOAD off 0x0000000000000000 vaddr 0x0000000000010000 paddr 0x0000000000010000 align 2**12 filesz 0x000000000000258a memsz 0x000000000000258a flags r-x
LOAD off 0x000000000000258c vaddr 0x000000000001358c paddr 0x000000000001358c align 2**12 filesz 0x0000000000000fb4 memsz 0x000000000000103c flags rw-
hellowrold: file format elf64-littleriscv
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 000024cc 00000000000100b0 00000000000100b0 000000b0 2**1
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 0000000a 0000000000012580 0000000000012580 00002580 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .eh_frame 00000004 000000000001358c 000000000001358c 0000258c 2**2
CONTENTS, ALLOC, LOAD, DATA
3 .init_array 00000010 0000000000013590 0000000000013590 00002590 2**3
CONTENTS, ALLOC, LOAD, DATA
4 .fini_array 00000008 00000000000135a0 00000000000135a0 000025a0 2**3
CONTENTS, ALLOC, LOAD, DATA
5 .data 00000f58 00000000000135a8 00000000000135a8 000025a8 2**3
CONTENTS, ALLOC, LOAD, DATA
6 .sdata 00000040 0000000000014500 0000000000014500 00003500 2**3
CONTENTS, ALLOC, LOAD, DATA
7 .sbss 00000020 0000000000014540 0000000000014540 00003540 2**3
ALLOC
8 .bss 00000068 0000000000014560 0000000000014560 00003540 2**3
ALLOC
9 .comment 00000011 0000000000000000 0000000000000000 00003540 2**0
CONTENTS, READONLY
10 .riscv.attributes 00000035 0000000000000000 0000000000000000 00003551 2**0
CONTENTS, READONLY
可以看到解析出来的文件结构与图2.1的结构相对应。其中值得注意的是,.bss节与.comment节在文件中的偏移是一样的这就说明.bss在硬盘中式不占用空间的仅仅只是记载了它的长度。
Program Header程序头表实际上是将文件的内容分成了好几个段而每个表项就代表了一个段这里的段是不同于之前节的概念有可能就是同时几个节包含在同一个段里。程序头表项的数据结构如下所示
typedef struct {
uint32_t p_type; //段类型
uint32_t p_flags; //段标志
uint64_t p_offset; //段相对于文件开始处的偏移量
uint64_t p_vaddr; //段在内存中地址(虚拟地址)
uint64_t p_paddr; //段的物理地址
uint64_t p_filesz; //段在文件中的长度
uint64_t p_memsz; //段在内存中的长度
uint64_t p_align; //段在内存中的对齐标志
} Elf64_Phdr;
下面我们通过一个图来看看用ELF文件头与程序头表项如何找到文件的第i段。
<img src="pictures/fig2_2.png" alt="fig2_2" style="zoom:50%;" />
图2.2 找到文件第i段的过程
Sections而另一个节头表的功能则是让程序能够找到特定的某一节其中节头表项的数据结构如下所示
typedef struct {
uint32_t sh_name; //节名称
uint32_t sh_type; //节类型
uint64_t sh_flags; //节标志
uint64_t sh_addr; //节在内存中的虚拟地址
uint64_t sh_offset; //相对于文件首部的偏移
uint64_t sh_size; //节大小
uint32_t sh_link; //与其他节的关系
uint32_t sh_info; //其他信息
uint64_t sh_addralign; //字节对齐标志
uint64_t sh_entsize; //表项大小
} Elf64_Shdr;
而通过ELF文件头与节头表找到文件的某一节的方式和之前所说的找到某一段的方式是类似的。
**2.3.2 代理内核与应用程序的加载**
阅读pke.lds文件可以看到整个PK程序的入口为reset_vector函数
3 OUTPUT_ARCH( "riscv" )
4
5 ENTRY( reset_vector )
我们在machine/mentry.S中找的这个符号。
36 reset_vector:
37 j do_reset
首先初始化x0~x31共32个寄存器其中x10a0寄存器与x11a1寄存器存储着从之前boot loader中传来的参数而不复位。
223 do_reset:
224 li x1, 0
.....
255 li x31, 0
将mscratch寄存器置0
256 csrw mscratch, x0
将trap_vector的地址写入t0寄存器trap_vector是mechine模式下异常处理的入口地址。再将t0的值写入mtvec寄存器中。然后读取mtvec寄存器中的地址到t1寄存器。比较t0于t1。
259 la t0, trap_vector
260 mtvec, t0
261 rr t1, mtvec
262 1:bne t0, t1, 1b
正常情况下t1自然是的等于t0的于是程序顺序执行将栈地址写入sp寄存器中
264 la sp, stacks + RISCV_PGSIZE - MENTRY_FRAME_SIZE
读取mhartid到a3寄存器调整sp
266 csrr a3, mhartid
267 slli a2, a3, RISCV_PGSHIFT
268 add sp, sp, a2
当a3不等于0时跳转到 init_first_hart
270 # Boot on the first hart
271 beqz a3, init_first_hart
此时进入"machine/minit.c"文件在init_first_hart中对外设进行初始化
154 void init_first_hart(uintptr_t hartid, uintptr_t dtb)
155 {
…… //初始化外设
180 boot_loader(dtb);
181 }
在init_first_hart的最后一行调用boot_loader函数
160 void boot_loader(uintptr_t dtb)
161 {
……. //CSR寄存器设置
169 enter_supervisor_mode(rest_of_boot_loader, pk_vm_init(), 0);
170 }
在boot_loader中经历设置中断入口地址清零sscratch寄存器关中断等一系列操作后。最后会调用enter_supervisor_mode函数正式切换至Supervisor模式。
204 void enter_supervisor_mode(void (*fn)(uintptr_t), uintptr_t arg0, uintptr_t arg1)
205 {
206 uintptr_t mstatus = read_csr(mstatus);
207 mstatus = INSERT_FIELD(mstatus, MSTATUS_MPP, PRV_S);
208 mstatus = INSERT_FIELD(mstatus, MSTATUS_MPIE, 0);
209 write_csr(mstatus, mstatus);
210 write_csr(mscratch, MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE);
211 #ifndef __riscv_flen
212 uintptr_t *p_fcsr = MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE; // the x0's save slot
213 *p_fcsr = 0;
214 #endif
215 write_csr(mepc, fn);
216
217 register uintptr_t a0 asm ("a0") = arg0;
218 register uintptr_t a1 asm ("a1") = arg1;
219 asm volatile ("mret" : : "r" (a0), "r" (a1));
220 __builtin_unreachable();
221 }
在enter_supervisor_mode函数中将 mstatus的MPP域设置为1表示中断发生之前的模式是Supervisor将mstatus的MPIE域设置为0表示中段发生前MIE的值为0。随即将机器模式的内核栈顶写入mscratch寄存器中设置mepc为rest_of_boot_loader的地址并将kernel_stack_top与0作为参数存入a0和a1。
最后执行mret指令该指令执行时程序从机器模式的异常返回将程序计数器pc设置为mepc即rest_of_boot_loader的地址将特权级设置为mstatus寄存器的MPP域即方才所设置的代表Supervisor的1MPP设置为0将mstatus寄存器的MIE域设置为MPIE即方才所设置的表示中断关闭的0MPIE设置为1。
于是当mret指令执行完毕程序将从rest_of_boot_loader继续执行。
144 static void rest_of_boot_loader(uintptr_t kstack_top)
145 {
146 arg_buf args;
147 size_t argc = parse_args(&args);
148 if (!argc)
149 panic("tell me what ELF to load!");
150
151 // load program named by argv[0]
152 long phdrs[128];
153 current.phdr = (uintptr_t)phdrs;
154 current.phdr_size = sizeof(phdrs);
155 load_elf(args.argv[0], &current);
156
157 run_loaded_program(argc, args.argv, kstack_top);
158 }
这个函数中我们对应用程序的ELF文件进行解析并且最终运行应用程序。

@ -0,0 +1,169 @@
# 第二章.实验环境的安装与使用
### 目录
- [2.1 头歌平台](#educoder)
- [2.2 Ubuntu环境](#ubuntu)
- [2.3 openEuler操作系统](#openeuler)
<a name="educoder"></a>
## 2.1 头歌平台
PKE实验在[头歌平台](https://www.educoder.net/)上进行了部署但因为仍在测试阶段所以没有开放全局选课感兴趣的读者可以尝试邀请码2T8MA。PKE实验2.0版本将于2021年秋季在头歌平台重新上线届时将开放全局选课。
<img src="pictures/fig2_install_1.png" alt="fig2_install_1" style="zoom:80%;" />
图1.1 头歌课程界面。
头歌平台为每个选课的学生提供了一个docker虚拟机该虚拟机环境中已经配置好了所有开发套件包括交叉编译器、Spike模拟器等用户可以通过shell选项*详细使用方法将待2.0上线时更新*进入该docker环境在该docker环境中完成实验任务。
<a name="ubuntu"></a>
## 2.2 Ubuntu环境
实验环境我们推荐采用Ubuntu 16.04LTS或18.04LTSx86_64操作系统我们未在其他系统如archRHEL等上做过测试但理论上只要将实验中所涉及到的安装包替换成其他系统中的等效软件包就可完成同样效果。另外我们在EduCoder实验平台网址https://www.educoder.net 上创建了本书的同步课程课程的终端环境中已完成实验所需软件工具的安装所以如果读者是在EduCoder平台上选择的本课程则可跳过本节的实验环境搭建过程直接进入通过终端命令行进入实验环境。
PKE实验涉及到的软件工具有RISC-V交叉编译器、spike模拟器以及PKE源代码三个部分。假设读者拥有了Ubuntu 16.04LTS或18.04LTSx86_64操作系统的环境以下分别介绍这三个部分的安装以及安装后的检验过程。需要说明的是为了避免耗时耗资源的构建build过程一个可能的方案是从https://toolchains.bootlin.com 下载,**但是要注意一些依赖包如GCC的版本号**。
**我们强烈建议读者在新装环境中完整构建buildRISC-V交叉编译器以及spike模拟器**。如果强调环境的可移植性,可以考虑在虚拟机中安装完整系统和环境,之后将虚拟机进行克隆和迁移。
### 2.1.1 RISC-V交叉编译器
RISC-V交叉编译器是与Linux自带的GCC编译器类似的一套工具软件集合不同的是x86_64平台上Linux自带的GCC编译器会将源代码编译、链接成为适合在x86_64平台上运行的二进制代码称为native code而RISC-V交叉编译器则会将源代码编译、链接成为在RISC-V平台上运行的代码。后者RISC-V交叉编译器生成的二进制代码是无法在x86_64平台即x86_64架构的Ubuntu环境下直接运行的它的运行需要模拟器我们采用的spike的支持。
一般情况下我们称x86_64架构的Ubuntu环境为host而在host上执行spike后所虚拟出来的RISC-V环境则被称为target。RISC-V交叉编译器的构建build、安装过程如下
● 第一步,安装依赖库
RISC-V交叉编译器的构建需要一些本地支撑软件包可使用以下命令安装
`$ sudo apt-get install autoconf automake autotools-dev curl libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat-dev device-tree-compiler`
● 第二步获取RISC-V交叉编译器的源代码
有两种方式获得RISC-V交叉编译器的源代码一种是通过源代码仓库获取使用以下命令
`$ git clone --recursive https://github.com/riscv/riscv-gnu-toolchain.git`
但由于RISC-V交叉编译器的仓库包含了Qemu模拟器的代码下载后的目录占用的磁盘空间大小约为4.8GB,(从国内下载)整体下载所需的时间较长。为了方便国内用户,我们提供了另一种方式就是通过百度云盘获取源代码压缩包,链接和提取码如下:
`链接: https://pan.baidu.com/s/1cMGt0zWhRidnw7vNUGcZhg 提取码: qbjh`
从百度云盘下载RISCV-packages/riscv-gnu-toolchain-clean.tar.gz文件大小为2.7GB再在Ubuntu环境下解压这个.tar.gz文件采用如下命令行
`$ tar xf riscv-gnu-toolchain-clean.tar.gz`
之后就能够看到和进入当前目录下的riscv-gnu-toolchain文件夹了。
● 第三步构建buildRISC-V交叉编译器
`$ cd riscv-gnu-toolchain`
`$ ./configure --prefix=[your.RISCV.install.path]`
`$ make`
以上命令中,[your.RISCV.install.path]指向的是你的RISC-V交叉编译器安装目录。如果安装是你home目录下的一个子目录如~/riscv-install-dir则最后的make install无需sudoer权限。但如果安装目录是系统目录如/opt/riscv-install-dir则需要sudoer权限即在make install命令前加上sudo
● 第四步,设置环境变量
`$ export RISCV=[your.RISCV.install.path]`
`$ export PATH=$PATH:$RISCV/bin`
以上命令设置了RISCV环境变量指向在第三步中的安装目录并且将交叉编译器的可执行文件所在的目录加入到了系统路径中。这样我们就可以在PKE的工作目录调用RISC-V交叉编译器所包含的工具软件了。
### 2.1.2 spike模拟器
接下来安装spkie模拟器。首先取得spike的源代码有两个途径一个是从github代码仓库中获取
`$ git clone https://github.com/riscv/riscv-isa-sim.git`
也可以从百度云盘中下载spike-riscv-isa-sim.tar.gz文件约4.2MB然后用tar命令解压缩。百度云盘的地址以及tar命令解压缩可以参考2.1.1节RISC-V交叉编译器的安装过程。获得spike源代码或解压后将在本地目录看到一个riscv-isa-sim目录。
接下来构建buildspike并安装
`$ cd riscv-isa-sim`
`$ ./configure --prefix=$RISCV`
`$ make`
`$ make install`
在以上命令中我们假设RISCV环境变量已经指向了RISC-V交叉编译器的安装目录。如果未建立关联可以将$RISCV替换为2.1.1节中的[your.RISCV.install.path]。
### 2.1.3 PKE
到github下载课程仓库
`$ git clone https://github.com/MrShawCode/pke.git`
克隆完成后将在当前目录看到pke目录。这时可以到pke目录下查看pke的代码结构例如
`$ cd pke`
`$ ls`
你可以看到当前目录下有如下(部分)内容
.
├── app
├── gradelib.py
├── machine
├── Makefile
├── pk
├── pke-lab1
├── pke.lds
└── util
● 首先是app目录里面存放的是实验的测试用例也就是运行在User模式的应用程序例如之前helloworld.c。
● gradelib.py、与pke-lab1是测试用的python脚本。
● machine目录里面存放的是机器模式相关的代码由于本课程的重点在于操作系统在这里你可以无需详细研究。
● Makefile文件它定义的整个工程的编译规则。
● pke.lds是工程的链接文件。
● util目录下是各模块会用到的工具函数。
● pk目录里面存放的是pke的主要代码。
即使未开始做PKE的实验我们的pke代码也是可构建的可以采用以下命令在pke目录下生成pke代理内核
`$ make`
以上命令完成后会在当前目录下会生产obj子目录里面就包含了我们的pke代理内核。pke代理内核的构建过程将在2.2节中详细讨论。
### 2.1.4 环境测试
全部安装完毕后你可以对环境进行测试在pke目录下输入
`$ spike ./obj/pke ./app/elf/app1_2`
将得到如下输出:
```
PKE IS RUNNING
user mode test illegal instruction!
you need add your code!
```
以上命令的作用是首先采用spike模拟一个RISC-V机器该机器支持RV64G指令集并在该机器上运行./app/elf/app1_2应用它的源代码在./app/app1_2.c中。我们知道应用是无法在“裸机”上运行的所以测试命令使用./obj/pke作为应用的代理内核。代理内核的作用是对spike模拟出来的RISC-V机器做简单的“包装”使其能够在spike模拟出来的机器上顺利运行。
这里,代理内核的作用是只为特定的应用服务(如本例中的./app/elf/app1_2应用所以可以做到“看菜吃饭”的效果。因为我们这里的应用非常简单所以pke就可以做得极其精简它没有文件系统、没有物理内存管理、没有进程调度、没有操作终端shell等等传统的完整操作系统“必须”具有的组件。在后续的实验中我们将不断提升应用的复杂度并不断完善代理内核。通过这个过程读者将深刻体会操作系统内核对应用支持的机制以及具体的实现细节。
<a name="openeuler"></a>
## 2.3 openEuler操作系统
PKE实验将提供基于华为openEuler操作系统的开发方法*具体的华为云使用方法待续*但在openEuler操作系统环境中的交叉编译器安装方法以及其他环节都可参考[2.2 Ubuntu环境](#ubuntu)的命令进行。

@ -1,430 +0,0 @@
# 第三章实验2系统调用的实现
## 3.1 实验环境搭建
实验2需要在实验1的基础之上完成所以首先你需要切换到lab2_small的branch然后commit你的代码。
首先查看本地拥有的branch输入如下命令
`$ git branch`
如果有如下输出:
```
lab1_small
lab2_small
lab3_small
lab4_small
lab5_small
```
则你本地就有实验二的branch那么可以直接切换分支到lab2输入如下命令
`$ git checkout lab2_small`
当然如果本地没有lab2的相关代码也可以直接从远端拉取
`$ git checkout -b lab2_small origin/ lab2_small`
然后merge实验一中你的代码
`$ git merge -m "merge lab1" lab1_small`
完成一切后,我们就可以正式进入实验二了!
## 3.2 实验内容
#### 应用: ####
app2_1.c源文件如下
1 #define ecall() ({\
2 asm volatile(\
3 "li x17,81\n"\
4 "ecall");\
5 })
6
7 int main(void){
8 //调用自定义的81号系统调用
9 ecall();
10 return 0;
11 }
以上代码中我们将系统调用号81通过x17寄存器传给内核再通过ecall指令进行系统调用当然目前代理内核中并没有81号系统调用的实现这需要我们在后面的实验中完成。
#### 任务一 : 在app中使用系统调用理解 ####
任务描述:
系统调用的英文名字是System Call用户通过系统调用来执行一些需要特权的任务那么我们具体在app中是如何使用内核所提供的系统调用的呢
RISC-V中提供了ecall指令用于向运行时环境发出请求我们可以使用内联汇编使用该指令进行系统调用。
预期输出:
理解app2_1的调用过程。
#### 任务二 : 系统调用过程跟踪(理解) ####
任务描述:
在我们执行了ecall指令后代理内核中又是如何执行的呢
在第一章的表1.7中我们可以看到Environment call from U-mode是exception(trap)的一种其对应的code是8。
预期输出:
我们在实验二中已经讨论过中断入口函数位置的设置,现在继续跟踪中断入口函数,找出系统调用的执行过程。
#### 任务三 : 自定义系统调用(编程) ####
任务描述:
阅读pk目录syscall.c文件增加一个系统调用sys_get_memsize()系统调用返回spike设置的内存空间大小, 系统调用号为81。
预期输出:
在pk目录下的mmap.c文件中函数pk_vm_init中定义了代理内核的内存空间大小。
spike 通过-m来指定分配的物理内存单位是MiB默认为2048。如
`$ spike obj/pke app/elf/app2_1`
得到的输出如下:
```
PKE IS RUNNING
physical mem_size = 0x80000000
```
可以看到默认情况下spike的物理内存是2GB
你可以修改-m选项运行app3观察输出。
`$ spike -m1024 obj/pke app/elf/app2_1`
预计得到输出格式:
```
PKE IS RUNNING
physical mem_size = 0x40000000
```
如果你的app可以正确输出的话那么运行检查的python脚本
`$ ./pke-lab2`
若得到如下输出,那么恭喜你,你已经成功完成了实验二!!!
```
build pk : OK
running app3 m2048 : OK
test3_m2048 : OK
running app3 m1024 : OK
test3_m1024 : OK
Score: 20/20
```
## 3.3 实验指导
**3.3.1 系统调用**
​首先,我们要知道什么是系统调用。
例如读文件(read)、写文件(write)等,其实我们已经接触过形形色色的系统调用。系统调用和函数调用的外形相似,但他们有着本质的不同。
系统调用的英文名字是System Call。由于用户进程只能在操作系统给它圈定好的“用户环境”中执行但“用户环境”限制了用户进程能够执行的指令即用户进程只能执行一般的指令无法执行特权指令。如果用户进程想执行一些需要特权指令的任务比如通过网卡发网络包等只能让操作系统来代劳了。系统调用就是用户模式下请求操作系统执行某些特权指令的任务的机制。
相较于函数调用在普通的用户模式下运行,系统调用则运行在内核模式中。
见下图:
<img src="pictures/fig3_1.png" alt="fig3_1" style="zoom:80%;" />
图3.1系统调用过程
系统调用属于一种中断,当用户申请系统调用时,系统会从用户态陷入到内核态,完成相应的服务后,再回到原来的用户态上下文中。
**3.3.2 代理内核与应用程序的加载**
阅读pke.lds文件可以看到整个PK程序的入口为reset_vector函数
```
3 OUTPUT_ARCH( "riscv" )
4
5 ENTRY( reset_vector )
```
我们在machine/mentry.S中找的这个符号。
```
36 reset_vector:
37 j do_reset
```
首先初始化x0~x31共32个寄存器其中x10a0寄存器与x11a1寄存器存储着从之前boot loader中传来的参数而不复位。
```
223 do_reset:
224 li x1, 0
.....
255 li x31, 0
```
将mscratch寄存器置0
```
256 csrw mscratch, x0
```
将trap_vector的地址写入t0寄存器trap_vector是mechine模式下异常处理的入口地址。再将t0的值写入mtvec寄存器中。然后读取mtvec寄存器中的地址到t1寄存器。比较t0于t1。
```
259 la t0, trap_vector
260 mtvec, t0
261 rr t1, mtvec
262 1:bne t0, t1, 1b
```
正常情况下t1自然是的等于t0的于是程序顺序执行将栈地址写入sp寄存器中
```
264 la sp, stacks + RISCV_PGSIZE - MENTRY_FRAME_SIZE
```
读取mhartid到a3寄存器调整sp
```
266 csrr a3, mhartid
267 slli a2, a3, RISCV_PGSHIFT
268 add sp, sp, a2
```
当a3不等于0时跳转到 init_first_hart
```
270 # Boot on the first hart
271 beqz a3, init_first_hart
```
此时进入"machine/minit.c"文件在init_first_hart中对外设进行初始化
```
154 void init_first_hart(uintptr_t hartid, uintptr_t dtb)
155 {
…… //初始化外设
180 boot_loader(dtb);
181 }
```
在init_first_hart的最后一行调用boot_loader函数
```
160 void boot_loader(uintptr_t dtb)
161 {
……. //CSR寄存器设置
169 enter_supervisor_mode(rest_of_boot_loader, pk_vm_init(), 0);
170 }
```
在boot_loader中经历设置中断入口地址清零sscratch寄存器关中断等一系列操作后。最后会调用enter_supervisor_mode函数正式切换至Supervisor模式。
```
204 void enter_supervisor_mode(void (*fn)(uintptr_t), uintptr_t arg0, uintptr_t arg1)
205 {
206 uintptr_t mstatus = read_csr(mstatus);
207 mstatus = INSERT_FIELD(mstatus, MSTATUS_MPP, PRV_S);
208 mstatus = INSERT_FIELD(mstatus, MSTATUS_MPIE, 0);
209 write_csr(mstatus, mstatus);
210 write_csr(mscratch, MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE);
211 #ifndef __riscv_flen
212 uintptr_t *p_fcsr = MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE; // the x0's save slot
213 *p_fcsr = 0;
214 #endif
215 write_csr(mepc, fn);
216
217 register uintptr_t a0 asm ("a0") = arg0;
218 register uintptr_t a1 asm ("a1") = arg1;
219 asm volatile ("mret" : : "r" (a0), "r" (a1));
220 __builtin_unreachable();
221 }
```
在enter_supervisor_mode函数中将 mstatus的MPP域设置为1表示中断发生之前的模式是Superior将mstatus的MPIE域设置为0表示中段发生前MIE的值为0。随机将机器模式的内核栈顶写入mscratch寄存器中设置mepc为rest_of_boot_loader的地址并将kernel_stack_top与0作为参数存入a0和a1。
最后执行mret指令该指令执行时程序从机器模式的异常返回将程序计数器pc设置为mepc即rest_of_boot_loader的地址将特权级设置为mstatus寄存器的MPP域即方才所设置的代表Superior的1MPP设置为0将mstatus寄存器的MIE域设置为MPIE即方才所设置的表示中断关闭的0MPIE设置为1。
于是当mret指令执行完毕程序将从rest_of_boot_loader继续执行。
```
144 static void rest_of_boot_loader(uintptr_t kstack_top)
145 {
146 arg_buf args;
147 size_t argc = parse_args(&args);
148 if (!argc)
149 panic("tell me what ELF to load!");
150
151 // load program named by argv[0]
152 long phdrs[128];
153 current.phdr = (uintptr_t)phdrs;
154 current.phdr_size = sizeof(phdrs);
155 load_elf(args.argv[0], &current);
156
157 run_loaded_program(argc, args.argv, kstack_top);
158 }
```
这个函数中我们对应用程序的ELF文件进行解析并且最终运行应用程序。
**3.3.3 中断的处理过程**
考虑一下当程序执行到中断之前程序是有自己的运行状态的例如寄存器里保持的上下文数据。当中断发生硬件在自动设置完中断原因和中断地址后就会调转到中断处理程序而中断处理程序同样会使用寄存器于是当程序从中断处理程序返回时需要保存需要被调用者保存的寄存器我们称之为callee-saved寄存器。
在PK的machine/minit.c中间中便通过delegate_traps()将部分中断及同步异常委托给S模式。同学们可以查看具体是哪些中断及同步异常
```
43 // send S-mode interrupts and most exceptions straight to S-mode
44 static void delegate_traps()
45 {
46 if (!supports_extension('S'))
47 return;
48
49 uintptr_t interrupts = MIP_SSIP | MIP_STIP | MIP_SEIP;
50 uintptr_t exceptions =
51 (1U << CAUSE_MISALIGNED_FETCH) |
52 (1U << CAUSE_FETCH_PAGE_FAULT) |
53 (1U << CAUSE_BREAKPOINT) |
54 (1U << CAUSE_LOAD_PAGE_FAULT) |
55 (1U << CAUSE_STORE_PAGE_FAULT) |
56 (1U << CAUSE_USER_ECALL);
57
58 write_csr(mideleg, interrupts);
59 write_csr(medeleg, exceptions);
60 assert(read_csr(mideleg) == interrupts);
61 assert(read_csr(medeleg) == exceptions);
62 }
```
这里介绍一下RISCV的中断委托机制在默认的情况下所有的异常都会被交由机器模式处理。但正如我们知道的那样大部分的系统调用都是在S模式下处理的因此RISCV提供了这一委托机制可以选择性的将中断交由S模式处理从而完全绕过M模式。
接下我们继续看S模式下的中断处理。在pk目录下的pk.c文件中的boot_loader函数中将&trap_entry写入了stvec寄存器中stvec保存着发生异常时处理器需要跳转到的地址也就是说当中断发生我们将跳转至trap_entry现在我们继续跟踪trap_entry。trap_entry在pk目录下的entry.S中其代码如下
```
60 trap_entry:
61 csrrw sp, sscratch, sp
62 bnez sp, 1f
63 csrr sp, sscratch
64 1:addi sp,sp,-320
65 save_tf
66 move a0,sp
67 jal handle_trap
```
在61行交换了sp与sscratch的值这里是为了根据sscratch的值判断该中断是来源于U模式还是S模式。
如果sp也就是传入的sscratch值不为零则跳转至64行若sscratch的值为零则恢复原sp中的值。这是因为当中断来源于S模式是sscratch的值为0sp中存储的就是内核的堆栈地址。而当中断来源于U模式时sp中存储的是用户的堆栈地址sscratch中存储的则是内核的堆栈地址需要交换二者是sp指向内核的堆栈地址。
接着在64,65行保存上下文最后跳转至67行处理trap。handle_trap在pk目录下的handlers.c文件中代码如下
```
112 void handle_trap(trapframe_t* tf)
113 {
114 if ((intptr_t)tf->cause < 0)
115 return handle_interrupt(tf);
116
117 typedef void (*trap_handler)(trapframe_t*);
118
119 const static trap_handler trap_handlers[] = {
120 [CAUSE_MISALIGNED_FETCH] = handle_misaligned_fetch,
121 [CAUSE_FETCH_ACCESS] = handle_instruction_access_fault,
122 [CAUSE_LOAD_ACCESS] = handle_load_access_fault,
123 [CAUSE_STORE_ACCESS] = handle_store_access_fault,
124 [CAUSE_FETCH_PAGE_FAULT] = handle_fault_fetch,
125 [CAUSE_ILLEGAL_INSTRUCTION] = handle_illegal_instruction,
126 [CAUSE_USER_ECALL] = handle_syscall,
127 [CAUSE_BREAKPOINT] = handle_breakpoint,
128 [CAUSE_MISALIGNED_LOAD] = handle_misaligned_load,
129 [CAUSE_MISALIGNED_STORE] = handle_misaligned_store,
130 [CAUSE_LOAD_PAGE_FAULT] = handle_fault_load,
131 [CAUSE_STORE_PAGE_FAULT] = handle_fault_store,
132 };
```
handle_trap函数中实现了S模式下各类中断的处理。可以看到代码的126行就对应着系统调用的处理handle_syscall的实现如下
```
100 static void handle_syscall(trapframe_t* tf)
101 {
102 tf->gpr[10] = do_syscall(tf->gpr[10], tf->gpr[11], tf->gpr[12], tf->gpr[13],
103 tf->gpr[14], tf->gpr[15], tf->gpr[17]);
104 tf->epc += 4;
105 }
```
还记得我们在例3.1中是将中断号写入x17寄存器嘛其对应的就是这里do_syscall的最后一个参数我们跟踪进入do_syscall函数其代码如下
```
313 long do_syscall(long a0, long a1, long a2, long a3, long a4, long a5, unsigned long n)
314 {
315 const static void* syscall_table[] = {
316 // your code here:
317 // add get_init_memsize syscall
318 [SYS_init_memsize ] = sys_get_init_memsize,
319 [SYS_exit] = sys_exit,
320 [SYS_exit_group] = sys_exit,
321 [SYS_read] = sys_read,
322 [SYS_pread] = sys_pread,
323 [SYS_write] = sys_write,
324 [SYS_openat] = sys_openat,
325 [SYS_close] = sys_close,
326 [SYS_fstat] = sys_fstat,
327 [SYS_lseek] = sys_lseek,
328 [SYS_renameat] = sys_renameat,
329 [SYS_mkdirat] = sys_mkdirat,
330 [SYS_getcwd] = sys_getcwd,
331 [SYS_brk] = sys_brk,
332 [SYS_uname] = sys_uname,
333 [SYS_prlimit64] = sys_stub_nosys,
334 [SYS_rt_sigaction] = sys_rt_sigaction,
335 [SYS_times] = sys_times,
336 [SYS_writev] = sys_writev,
337 [SYS_readlinkat] = sys_stub_nosys,
338 [SYS_rt_sigprocmask] = sys_stub_success,
339 [SYS_ioctl] = sys_stub_nosys,
340 [SYS_getrusage] = sys_stub_nosys,
341 [SYS_getrlimit] = sys_stub_nosys,
342 [SYS_setrlimit] = sys_stub_nosys,
343 [SYS_set_tid_address] = sys_stub_nosys,
344 [SYS_set_robust_list] = sys_stub_nosys,
345 };
346
347 syscall_t f = 0;
348
349 if (n < ARRAY_SIZE(syscall_table))
350 f = syscall_table[n];
351 if (!f)
352 panic("bad syscall #%ld!",n);
353
354 return f(a0, a1, a2, a3, a4, a5, n);
355 }
```
do_syscall中通过传入的系统调用号n查询syscall_table得到对应的函数并最终执行系统调用。

File diff suppressed because it is too large Load Diff

@ -1,405 +0,0 @@
# 第四章实验3物理内存管理
## 4.1 实验内容
#### 应用: ####
app3_1.c源文件如下
1 #define ecall() ({\
2 asm volatile(\
3 "li x17,81\n"\
4 "ecall");\
5 })
6
7 int main(void){
8 //调用自定义的81号系统调用
9 ecall();
10 return 0;
11 }
对于操作系统来说内存分配的过程需要对应用层透明故而实验三的app同实验二相同并在内核中对于的内存分配单元做如下校验
static void
basic_check(void) {
struct Page *p0, *p1, *p2;
p0 = p1 = p2 = NULL;
assert((p0 = alloc_page()) != NULL);
assert((p1 = alloc_page()) != NULL);
assert((p2 = alloc_page()) != NULL);
assert(p0 != p1 && p0 != p2 && p1 != p2);
assert(p0->ref == 0 && p1->ref == 0 && p2->ref == 0);
list_entry_t free_list_store = free_list;
list_init(&free_list);
assert(list_empty(&free_list));
unsigned int nr_free_store = nr_free;
nr_free = 0;
free_page(p0);
free_page(p1);
free_page(p2);
assert(nr_free == 3);
assert((p0 = alloc_page()) != NULL);
assert((p1 = alloc_page()) != NULL);
assert((p2 = alloc_page()) != NULL);
assert(alloc_page() == NULL);
free_page(p0);
assert(!list_empty(&free_list));
struct Page *p;
assert((p = alloc_page()) == p0);
assert(alloc_page() == NULL);
assert(nr_free == 0);
free_list = free_list_store;
nr_free = nr_free_store;
free_page(p);
free_page(p1);
free_page(p2);
}
#### 任务一 : OS内存的初始化过程理解 ####
任务描述:
在"pk/mmap.c"内有 pk_vm_init()函数阅读该函数了解OS内存初始化的过程。
预期输出:
```
364 uintptr_t pk_vm_init()
365 {
366 // HTIF address signedness and va2pa macro both cap memory size to 2 GiB
//设置物理内存大小
367 mem_size = MIN(mem_size, 1U << 31);
//计算物理页的数量
368 size_t mem_pages = mem_size >> RISCV_PGSHIFT;
369 free_pages = MAX(8, mem_pages >> (RISCV_PGLEVEL_BITS-1));
370
//_end为内核结束地址
371 extern char _end;
372 first_free_page = ROUNDUP((uintptr_t)&_end, RISCV_PGSIZE);
373 first_free_paddr = first_free_page + free_pages * RISCV_PGSIZE;
374
//映射内核的物理空间
375 root_page_table = (void*)__page_alloc();
376 __map_kernel_range(DRAM_BASE, DRAM_BASE, first_free_paddr - DRAM_BASE, PROT_READ|PROT_WRITE|PROT_EXEC);
377
//crrent.mmap_max: 0x000000007f7ea000
378 current.mmap_max = current.brk_max =
379 MIN(DRAM_BASE, mem_size - (first_free_paddr - DRAM_BASE));
380
//映射用户栈
381 size_t stack_size = MIN(mem_pages >> 5, 2048) * RISCV_PGSIZE;
382 size_t stack_bottom = __do_mmap(current.mmap_max - stack_size, stack_size, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED , 0, 0);
383 kassert(stack_bottom != (uintptr_t)-1);
384 current.stack_top = stack_bottom + stack_size;
385
//开启分页
386 flush_tlb();
387 write_csr(sptbr, ((uintptr_t)root_page_table >> RISCV_PGSHIFT) | SATP_MODE_CHOICE);
388
//分配内核栈空间,
389 uintptr_t kernel_stack_top = __page_alloc() + RISCV_PGSIZE;
390 return kernel_stack_top;
391 }
```
以上代码中我们给出了大体的注释请根据以上代码读者可以尝试画一下PK的逻辑地址空间结构图以及逻辑地址空间到物理地址空间的映射关系。
#### 任务二 : first_fit内存页分配算法编程 ####
任务描述:
在"pk/pmm.c" 中,我们实现了对物理内存的管理。
构建了物理内存页管理器框架struct pmm_manager结构如下
```
135 const struct pmm_manager default_pmm_manager = {
136 .name = "default_pmm_manager",
137 .init = default_init,
138 .init_memmap = default_init_memmap,
139 .alloc_pages = default_alloc_pages,
140 .free_pages = default_free_pages,
141 .nr_free_pages = default_nr_free_pages,
142 .pmm_check = basic_check,
143 };
```
默认的内存管理器有如下属性:
l name:内存管理器的名字
l init:对内存管理算法所使用的数据结构进行初始化
l init_ memmap:根据物理内存设置内存管理算法的数据结构
l alloc_pages分配物理页
l free_pages释放物理页
l nr_free_pages空闲物理页的数量
l pmm_check :检查校验函数
参考已经实现的函数完成default_alloc_pages()和default_free_pages(),实现first_fit内存页分配算法。
first_fit分配算法需要维护一个查找有序地址按从小到大排列空闲块以页为最小单位的连续地址空间的数据结构而双向链表是一个很好的选择。pk/list.h定义了可挂接任意元素的通用双向链表结构和对应的操作所以需要了解如何使用这个文件提供的各种函数从而可以完成对双向链表的初始化/插入/删除等。
预期输出:
我们在实验二中已经讨论过中断入口函数位置的设置,现在继续跟踪中断入口函数,找出系统调用的执行过程。
你可以使用python脚本检查你的输出
`./pke-lab3`
若得到如下输出,你就已经成功完成了实验三!!!
```
build pk : OK
running app3 m2048 : OK
test3_m2048 : OK
running app3 m1024 : OK
test3_m1024 : OK
Score: 20/20
```
## 4.2 实验指导
**4.2.1 物理内存空间与编址**
计算机的存储结构可以抽象的看做由N个连续的字节组成的数组。想一想在数组中我们如何找到一个元素对了是下标那么我们如何在内存中如何找打一个元素呢自然也是下标。这个下标的起始位置和位数由机器本身决定我们称之为“物理地址”。
至于物理内存的大小由于我们的RISC-V目标机也就是我们的pke以及app运行的环境这里我们假设目标机为64位机即用到了56位的物理内存编址虚拟地址采用Sv39方案参见[第一章RISC-V体系结构的内容](chapter1.md/#paging)是由spike模拟器构造的构造过程中可以通过命令行的-m选项来指定物理内存的大小。而且spike会将目标机的物理内存地址从0x8000-0000开始编制。例如如果物理内存空间的大小为2GBspike的默认值则目标机的物理地址范围为[0x8000-0000, 0x10000-0000]其中0x10000-0000已经超过32位能够表达的范围了但是我们目标机是64位机再例如如果目标机物理内存空间大小为1GB启动spike时带入-m1024m参数则目标机的物理地址范围为[0x8000-0000, 0xC000-0000]。在以下的讨论中我们用符号PHYMEM_TOP代表物理内存空间的高地址部分在以上的两个例子中PHYMEM_TOP分别为0x10000-0000和0xC000-0000。在定义了PHYMEM_TOP符号后物理内存的范围就可以表示为[0x8000-0000, PHYMEM_TOP]。
我们的PK内核的逻辑编址可以通过查看pke.lds得知pke.lds有以下规则
```
14 /* Begining of code and text segment */
15 . = 0x80000000;
```
可见PK内核的逻辑地址的起始也是0x8000-0000这也就意味着PK内核实际上采用的是直接地址映射的办法保证在未打开分页情况下逻辑地址到物理地址的映射的。代理内核的本质也是一段程序他本身是需要内存空间的而这一段空间在PK的设计中是静态分配给内核使用的不能被再分配给任何应用。那么静态分配给代理内核的内存空间具体是哪一段内存区域呢
通过阅读PK的代码我们可知PK内核占据了以下这一段
```
KERNTOP------->+---------------------------------+ 0x80816000
(first_free_paddr)| |
| Kern Physical Memory |
| | 8M 2048pages
(first_free_page)| |
DRAM_BASE----> +---------------------------------+ 0x80016000
| Kern Text/Data/BBS |
KERN------>+---------------------------------+ 0x80000000
```
也就是说,[0x8000-0000, 0x8081-6000]这段物理内存空间是被PK内核所“保留”的余下的物理内存空间为[0x8081-6000PHYMEM_TOP]也就是下图中的Empty Memory*)部分,这部分内存将会是我们的操作系统需要真正用于动态分配(给应用程序)的空间,**而本实验就是要管理这部分物理内存空间**。
```
PHYMEM_TOP ----> +-------------------------------------------------+
| |
| Empty Memory (*) |
| |
KERNTOP ---> +-------------------------------------------------+ 0x80816000
(first_free_paddr)| |
| PK kernel resevered |
| |
| |
KERN ----> +-------------------------------------------------+ 0x80000000
```
最后我们来看物理内存分配的单位操作系统中物理页是物理内存分配的基本单位。一个物理页的大小是4KB我们使用结构体Page来表示其结构如图
```
struct Page {
sint_t ref;
uint_t flags;
uint_t property;
list_entry_t page_link;
};
```
l ref表示这样页被页表的引用记数
l flags表示此物理页的状态标记
l property用来记录某连续内存空闲块的大小即地址连续的空闲页的个数
l page_link是维持空闲物理页链表的重要结构。
Page结构体对应着物理页我们来看Page结构体同物理地址之间是如何转换的。首先我们需要先了解一下物理地址。
<img src="pictures/fig4_1.png" alt="fig4_1" style="zoom:80%;" />
图4.1 RISCV64 物理地址
总的来说物理地址分为两部分页号PPN和offset
页号可以理解为物理页的编码而offset则为页内偏移量。现在考虑一下12位的offset对应的内存大小是多少呢
2<<12=40964KBPA4KB12offset便
有了物理地址PA这一概念那PA和Pages结构体又是如何转换
实际上在初始化空闲页链表之前系统会定义一个Page结构体的数组而链表的节点也正是来自于这些数组这个数组的每一项代表着一个物理页而且它们的数组下标就代表着每一项具体代表的是哪一个物理页就如下图所示
<img src="pictures/fig4_2.png" alt="fig4_2" style="zoom:80%;" />
**3.2.2** **中断的处理过程**
当程序执行到中断之前程序是有自己的运行状态的例如寄存器里保持的上下文数据。当中断发生硬件在自动设置完中断原因和中断地址后就会调转到中断处理程序而中断处理程序同样会使用寄存器于是当程序从中断处理程序返回时需要保存需要被调用者保存的寄存器我们称之为callee-saved寄存器。
在PK的machine/minit.c中间中便通过delegate_traps()将部分中断及同步异常委托给S模式。同学们可以查看具体是哪些中断及同步异常
```
43 // send S-mode interrupts and most exceptions straight to S-mode
44 static void delegate_traps()
45 {
46 if (!supports_extension('S'))
47 return;
48
49 uintptr_t interrupts = MIP_SSIP | MIP_STIP | MIP_SEIP;
50 uintptr_t exceptions =
51 (1U << CAUSE_MISALIGNED_FETCH) |
52 (1U << CAUSE_FETCH_PAGE_FAULT) |
53 (1U << CAUSE_BREAKPOINT) |
54 (1U << CAUSE_LOAD_PAGE_FAULT) |
55 (1U << CAUSE_STORE_PAGE_FAULT) |
56 (1U << CAUSE_USER_ECALL);
57
58 write_csr(mideleg, interrupts);
59 write_csr(medeleg, exceptions);
60 assert(read_csr(mideleg) == interrupts);
61 assert(read_csr(medeleg) == exceptions);
62 }
```
这里介绍一下RISCV的中断委托机制在默认的情况下所有的异常都会被交由机器模式处理。但正如我们知道的那样大部分的系统调用都是在S模式下处理的因此RISCV提供了这一委托机制可以选择性的将中断交由S模式处理从而完全绕过M模式。
接下我们继续看S模式下的中断处理。在pk目录下的pk.c文件中的boot_loader函数中将&trap_entry写入了stvec寄存器中stvec保存着发生异常时处理器需要跳转到的地址也就是说当中断发生我们将跳转至trap_entry现在我们继续跟踪trap_entry。trap_entry在pk目录下的entry.S中其代码如下
```
60 trap_entry:
61 csrrw sp, sscratch, sp
62 bnez sp, 1f
63 csrr sp, sscratch
64 1:addi sp,sp,-320
65 save_tf
66 move a0,sp
67 jal handle_trap
```
在61行交换了sp与sscratch的值这里是为了根据sscratch的值判断该中断是来源于U模式还是S模式。
如果sp也就是传入的sscratch值不为零则跳转至64行若sscratch的值为零则恢复原sp中的值。这是因为当中断来源于S模式是sscratch的值为0sp中存储的就是内核的堆栈地址。而当中断来源于U模式时sp中存储的是用户的堆栈地址sscratch中存储的则是内核的堆栈地址需要交换二者是sp指向内核的堆栈地址。
接着在64,65行保存上下文最后跳转至67行处理trap。handle_trap在pk目录下的handlers.c文件中代码如下
```
112 void handle_trap(trapframe_t* tf)
113 {
114 if ((intptr_t)tf->cause < 0)
115 return handle_interrupt(tf);
116
117 typedef void (*trap_handler)(trapframe_t*);
118
119 const static trap_handler trap_handlers[] = {
120 [CAUSE_MISALIGNED_FETCH] = handle_misaligned_fetch,
121 [CAUSE_FETCH_ACCESS] = handle_instruction_access_fault,
122 [CAUSE_LOAD_ACCESS] = handle_load_access_fault,
123 [CAUSE_STORE_ACCESS] = handle_store_access_fault,
124 [CAUSE_FETCH_PAGE_FAULT] = handle_fault_fetch,
125 [CAUSE_ILLEGAL_INSTRUCTION] = handle_illegal_instruction,
126 [CAUSE_USER_ECALL] = handle_syscall,
127 [CAUSE_BREAKPOINT] = handle_breakpoint,
128 [CAUSE_MISALIGNED_LOAD] = handle_misaligned_load,
129 [CAUSE_MISALIGNED_STORE] = handle_misaligned_store,
130 [CAUSE_LOAD_PAGE_FAULT] = handle_fault_load,
131 [CAUSE_STORE_PAGE_FAULT] = handle_fault_store,
132 };
```
handle_trap函数中实现了S模式下各类中断的处理。可以看到代码的126行就对应着系统调用的处理handle_syscall的实现如下
```
100 static void handle_syscall(trapframe_t* tf)
101 {
102 tf->gpr[10] = do_syscall(tf->gpr[10], tf->gpr[11], tf->gpr[12], tf->gpr[13],
103 tf->gpr[14], tf->gpr[15], tf->gpr[17]);
104 tf->epc += 4;
105 }
```
还记得我们在例3.1中是将中断号写入x17寄存器嘛其对应的就是这里do_syscall的最后一个参数我们跟踪进入do_syscall函数其代码如下
```
313 long do_syscall(long a0, long a1, long a2, long a3, long a4, long a5, unsigned long n)
314 {
315 const static void* syscall_table[] = {
316 // your code here:
317 // add get_init_memsize syscall
318 [SYS_init_memsize ] = sys_get_init_memsize,
319 [SYS_exit] = sys_exit,
320 [SYS_exit_group] = sys_exit,
321 [SYS_read] = sys_read,
322 [SYS_pread] = sys_pread,
323 [SYS_write] = sys_write,
324 [SYS_openat] = sys_openat,
325 [SYS_close] = sys_close,
326 [SYS_fstat] = sys_fstat,
327 [SYS_lseek] = sys_lseek,
328 [SYS_renameat] = sys_renameat,
329 [SYS_mkdirat] = sys_mkdirat,
330 [SYS_getcwd] = sys_getcwd,
331 [SYS_brk] = sys_brk,
332 [SYS_uname] = sys_uname,
333 [SYS_prlimit64] = sys_stub_nosys,
334 [SYS_rt_sigaction] = sys_rt_sigaction,
335 [SYS_times] = sys_times,
336 [SYS_writev] = sys_writev,
337 [SYS_readlinkat] = sys_stub_nosys,
338 [SYS_rt_sigprocmask] = sys_stub_success,
339 [SYS_ioctl] = sys_stub_nosys,
340 [SYS_getrusage] = sys_stub_nosys,
341 [SYS_getrlimit] = sys_stub_nosys,
342 [SYS_setrlimit] = sys_stub_nosys,
343 [SYS_set_tid_address] = sys_stub_nosys,
344 [SYS_set_robust_list] = sys_stub_nosys,
345 };
346
347 syscall_t f = 0;
348
349 if (n < ARRAY_SIZE(syscall_table))
350 f = syscall_table[n];
351 if (!f)
352 panic("bad syscall #%ld!",n);
353
354 return f(a0, a1, a2, a3, a4, a5, n);
355 }
```
do_syscall中通过传入的系统调用号n查询syscall_table得到对应的函数并最终执行系统调用。

@ -0,0 +1,744 @@
# 第四章实验2内存管理
### 目录
- [4.1 实验2的基础知识](#fundamental)
- [4.1.1 Sv39虚地址管理方案回顾](#sv39)
- [4.1.2 物理内存布局与规划](#physicalmemory)
- [4.1.3 PKE操作系统和应用进程的逻辑地址空间结构](#virtualaddressspace)
- [4.1.4 与页表操作相关的重要函数](#pagetablecook)
- [4.2 lab2_1 虚实地址转换](#lab2_1_pagetable)
- [给定应用](#lab2_1_app)
- [实验内容](#lab2_1_content)
- [实验指导](#lab2_1_guide)
- [4.3 lab2_2 简单内存分配和回收](#lab2_2_allocatepage)
- [给定应用](#lab2_2_app)
- [实验内容](#lab2_2_content)
- [实验指导](#lab2_2_guide)
- [4.4 lab2_3 缺页异常](#lab2_3_pagefault)
- [给定应用](#lab2_3_app)
- [实验内容](#lab2_3_content)
- [实验指导](#lab2_3_guide)
<a name="fundamental"></a>
## 4.1 实验2的基础知识
在过去的[第一组实验(lab1)](chapter3_traps.md)中为了简化设计我们采用了Bare模式来完成虚拟地址到物理地址的转换实际上就是不转换认为虚拟地址=物理地址也未开启模拟RISC-V机器的分页功能。在本组实验实验2我们将开启和使用Sv39页式虚拟内存管理无论是操作系统内核还是应用都通过页表来实现逻辑地址到物理地址的转换。
实际上,我们在本书的[第一章的1.5节](chapter1_riscv.md#paging)曾介绍过RISC-V的Sv39页式虚拟内存的管理方式在本章我们将尽量结合PKE的实验代码讲解RISC-V的Sv39虚拟内存管理机制并通过3个基础实验加深读者对该管理机制的理解。
<a name="sv39"></a>
### 4.1.1 Sv39虚地址管理方案回顾
我们先回顾一下RISC-V的sv39虚地址管理方案在该方案中逻辑地址就是我们的程序中各个符号在链接时被赋予的地址通过页表转换为其对应的物理地址。由于我们考虑的机器采用了RV64G指令集意味着逻辑地址和物理地址理论上都是64位的。然而对于逻辑地址实际上我们的应用规模还用不到全部64位的寻找空间所以Sv39方案中只使用了64位虚地址中的低39位Sv48方案使用了低48位意味着我们的应用程序的地址空间可以到512GB对于物理地址目前的RISC-V设计只用到了其中的低56位。
Sv39将39位虚拟地址“划分”为4个段如下图所示
- [38,30]共9位图中的VPN[2]用于在5122^9个页目录page directory项中检索页目录项page directory entry, PDE
- [29,21]共9位图中的VPN[1]用于在5122^9个页中间目录page medium directory中检索PDE
- [20,12]共9位图中的VPN[0]用于在5122^9个页表page medium directory中检索PTE
- [11,0]共12位图中的offset充当4KB页的页内位移。
![fig1_8](pictures/fig1_8.png)
图4.1 Sv39中虚拟地址到物理地址的转换过程
由于每个物理页的大小为4KB同时每个目录项PDE或页表项PTE占据8个字节所以一个物理页能够容纳的PDE或PTE的数量为4KB/8B=512这也是为什么VPN[2]=VPN[1]=VPN[0]=512的原因。
8字节的PDE或者PTE的格式如下
![fig1_7](pictures/fig1_7.png)
图4.2 Sv39中PDE/PTE格式
其中的各个位的含意为:
● VValid位决定了该PDE/PTE是否有效V=1时有效即是否有对应的实页。
● RRead、WWrite和XeXecutable位分别表示此页对应的实页是否可读、可写和可执行。这3个位只对PTE有意义对于PDE而言这3个位都为0。
● UUser位表示该页是不是一个用户模式页。如果U=1表示用户模式下的代码可以访问该页否则就表示不能访问。S模式下的代码对U=1页面的访问取决于sstatus寄存器中的SUM字段取值。
● GGlobal位表示该PDE/PTE是不是全局的。我们可以把操作系统中运行的一个进程认为是一个独立的地址空间有时会希望某个虚地址空间转换可以在一组进程中共享这种情况下就可以将某个PDE的G位设置为1达到这种共享的效果。
● AAccess位表示该页是否被访问过。
● DDirty位表示该页的内容是否被修改。
● RSW位2位是保留位一般由运行在S模式的代码如操作系统来使用。
● PPN44位是物理页号Physical Page Number简写为PPN
其中PPN为44位的原因是对于物理地址现有的RISC-V规范只用了其中的56位同时这56位中的低12位为页内位移。所以PPN的长度=56-12=44
<a name="physicalmemory"></a>
### 4.1.2 物理内存布局与规划
PKE实验用到的RISC-V机器实际上是spike模拟出来的例如采用以下命令
```
$ spike ./obj/riscv-pke ./obj/app_helloworld
```
spike将创建一个模拟的RISC-V机器该机器拥有一个支持RV64G指令集的处理器2GB的模拟物理内存。实际上我们可以通过在spike命令行中使用`-m`开关指定模拟机器的物理内存大小,如使用`-m512`即可获得拥有512MB物理内存的模拟机器。默认的2GB物理内存配置等效于`-m2048`在之后对模拟RISC-V机器物理内存布局的讨论中我们将只考虑默认配置以简化论述。另外也可以在spike命令行中使用`-p`开关指定模拟机器中处理器的个数这样就可以模拟出一个多核的RISC-V平台了。
需要注意的是,**对于我们的模拟RISC-V机器而言2GB的物理内存并不是从0地址开始编址而是从0x80000000见kernel/memlayout.h文件中的DRAM_BASE宏定义开始编址的**。这样做的理由是,部分低物理地址[0x0, 0x80000000]并无物理内存与之对应该空间留作了MMIO的用途。例如我们在lab1_3中遇到的CLINTCore Local Interruptertimer中断的产生就是通过往这个地址写数据控制的见kernel/riscv.h文件中的CLINT定义的地址是0x2000000就位于这一空间。从0x80000000开始对物理内存进行编址的好处是避免类似x86平台那样产生内存空洞memory hole如640KB~1MB的BIOS空间从而导致内存的浪费和管理上的复杂性。
我们的代理内核(构造出来的./obj/riscv-pke文件的逻辑地址也是从0x80000000开始的见kernel/kernel.lds文件中的内容spike将代理内核载入模拟物理内存时也是将该代理内核的代码段、数据段载入到0x80000000开始的内存空间如图4.3所示。
![physical_mem_layout.png](pictures/physical_mem_layout.png)
图4.3 初始内存布局和载入操作系统内核后的内存布局
这样操作系统内核的逻辑地址和物理地址就有了一一对应的关系这也是我们在lab1中采用直模式Bare mode虚拟地址翻译机制也不会出错的原因。这里需要解释的是对内核的机器模式栈的处理。通过实验一我们知道机器模式栈是一个4KB的空间它位于内核数据段而不是专门分配一个额外的页面。这样简单处理的原因是PKE上运行的应用往往只有一个算是非常简单的多任务环境且操作系统利用机器模式栈的时机只有特殊的异常如lab1_2中的非法指令异常以及一些外部中断如lab1_3中的时钟中断
如图4.3b所示在spike将操作系统内核装入物理内存后剩余的内存空间应该是从内核数据段的结束_end符号到0xffffffff即4GB-1的地址。但是由于PKE操作系统内核的特殊性它只需要支持给定应用的运行lab2的代码将操作系统管理的空间进一步缩减定义了一个操作系统需要管理的最大内存空间kernel/config.h文件从而提升实验代码的执行速度
```
10 // the maximum memory space that PKE is allowed to manage
11 #define PKE_MAX_ALLOWABLE_RAM 128 * 1024 * 1024
12
13 // the ending physical address that PKE observes
14 #define PHYS_TOP (DRAM_BASE + PKE_MAX_ALLOWABLE_RAM)
```
可以看到实验代码“人为”地将PKE操作系统所能管理的内存空间限制到了128MB即PKE_MAX_ALLOWABLE_RAM的定义同时定义了PHYS_TOP为新的内存物理地址上限。实际上kernel/pmm.c文件所定义的pmm_init()函数包含了PKE对物理内存进行管理的逻辑
```
60 void pmm_init() {
61 // start of kernel program segment
62 uint64 g_kernel_start = KERN_BASE;
63 uint64 g_kernel_end = (uint64)&_end;
64
65 uint64 pke_kernel_size = g_kernel_end - g_kernel_start;
66 sprint("PKE kernel start 0x%lx, PKE kernel end: 0x%lx, PKE kernel size: 0x%lx .\n", g_kernel_start, g_kernel_end, pke_kernel_size);
67
68 // free memory starts from the end of PKE kernel and must be page-aligined
69 free_mem_start_addr = ROUNDUP(g_kernel_end , PGSIZE);
70
71 // recompute g_mem_size to limit the physical memory space that PKE kernel needs to manage
72 g_mem_size = MIN(PKE_MAX_ALLOWABLE_RAM, g_mem_size);
73 if( g_mem_size < pke_kernel_size )
74 panic( "Error when recomputing physical memory size (g_mem_size).\n" );
75
76 free_mem_end_addr = g_mem_size + DRAM_BASE;
77 sprint("free physical memory address: [0x%lx, 0x%lx] \n", free_mem_start_addr, free_mem_end_addr - 1);
78
79 sprint("kernel memory manager is initializing ...\n");
80 // create the list of free pages
81 create_freepage_list(free_mem_start_addr, free_mem_end_addr);
82 }
```
在72行pmm_init()函数会计算g_mem_size其值在PKE_MAX_ALLOWABLE_RAM和spike所模拟的物理内存大小中取最小值也就是说除非spike命令行参数中-m参数后面所带的数字小于128即128Mg_mem_size的大小将为128MB。
另外,为了对空闲物理内存(地址范围为[_endg_mem_size+DRAM_BASE(即PHYS_TOP)]进行有效管理pmm_init()函数在81行通过调用create_freepage_list()函数定义了一个链表用于对空闲物理内存的分配和回收。kernel/pmm.c文件中包含了所有对物理内存的初始化、分配和回收的例程它们的实现非常的简单感兴趣的读者请对里面的函数进行阅读理解。
<a name="virtualaddressspace"></a>
### 4.1.3 PKE操作系统和应用进程的逻辑地址空间结构
通过4.1.2的讨论我们知道对于PKE内核来说有逻辑地址=物理地址的关系成立这也是在实验一中我们可以采用Bare模式进行地址映射的原因。采用Bare模式的地址映射在进行内存访问时无需经过页表和硬件进行逻辑地址到物理地址的转换。然而在实验二中我们将采用Sv39虚拟地址管理方案通过页表和硬件spike模拟的MMU进行访存地址的转换。为实现这种转换首先需要确定的就是将要被转换的逻辑地址空间即需要对哪部分逻辑地址空间进行转换的问题。在PKE的实验二中存在两个需要被转换的实体一个是操作系统内核另一个是我们的实验给定的应用程序所对应的进程。下面我们对它们分别讨论
#### 操作系统内核
操作系统内核的逻辑地址与物理地址存在一一对应的关系但是在开启了Sv39虚拟内存管理方案后所有的逻辑地址到物理地址的翻译都**必须**通过页表和MMU硬件进行所以为操作系统内核建立页表是必不可少的工作。操作系统的逻辑地址空间可以简单的认为是从内核代码段的起始即KERN_BASE=0x80000000到物理地址的顶端也就是PHYS_TOP因为操作系统是系统中拥有最高权限的软件需要实现对所有物理内存空间的直接管理。这段逻辑地址空间即[KERN_BASEPHYS_TOP],所映射的物理地址空间也是[KERN_BASEPHYS_TOP]。也就是说对于操作系统内核我们在实验二中通过Sv39的页表仍然保持和实验一一样的逻辑地址到物理地址的一一对应关系。在权限方面对于内核代码段所对应的页面来说是可读可执行对于数据段以及空闲内存空间其权限为可读可写。
![kernel_address_mapping.png](pictures/kernel_address_mapping.png)
图4.4 PKE操作系统内核的逻辑地址空间和它到物理地址空间的映射
操作系统内核建立页表的过程可以参考kernel/vmm.c文件中的kern_vm_init()函数的实现需要说明的是kern_vm_init()函数在PKE操作系统内核的S态初始化过程s_start函数中被调用
```
114 void kern_vm_init(void) {
115 pagetable_t t_page_dir;
116
117 // allocate a page (t_page_dir) to be the page directory for kernel
118 t_page_dir = (pagetable_t)alloc_page();
119 memset(t_page_dir, 0, PGSIZE);
120
121 // map virtual address [KERN_BASE, _etext] to physical address [DRAM_BASE, DRAM_BASE+(_etext - KERN_BASE)],
122 // to maintain (direct) text section kernel address mapping.
123 kern_vm_map(t_page_dir, KERN_BASE, DRAM_BASE, (uint64)_etext - KERN_BASE,
124 prot_to_type(PROT_READ | PROT_EXEC, 0));
125
126 sprint("KERN_BASE 0x%lx\n", lookup_pa(t_page_dir, KERN_BASE));
127
128 // also (direct) map remaining address space, to make them accessable from kernel.
129 // this is important when kernel needs to access the memory content of user's app without copying pages
130 // between kernel and user spaces.
131 kern_vm_map(t_page_dir, (uint64)_etext, (uint64)_etext, PHYS_TOP - (uint64)_etext,
132 prot_to_type(PROT_READ | PROT_WRITE, 0));
133
134 sprint("physical address of _etext is: 0x%lx\n", lookup_pa(t_page_dir, (uint64)_etext));
135
136 g_kernel_pagetable = t_page_dir;
137 }
```
我们看到kern_vm_init()函数会首先118行从空闲物理内存中获取分配一个t_page_dir指针所指向的物理页该页将作为内核页表的根目录page directory对应图4.1中的VPN[2]。接下来将该页的内容清零119行、映射代码段到它对应的物理地址123--124行、映射数据段的起始到PHYS_TOP到它对应的物理地址空间131--132行最后记录内核页表的根目录页136行
#### 应用进程
对于实验一的所有应用,我们通过指定应用程序中所有的符号地址对应的逻辑地址的方法(参见[第三章的3.1.3节](chapter3_traps.md#subsec_lds)将应用程序中的逻辑地址“强行”对应到图4.3中的“实际空闲内存”空间并在ELF加载时将程序段加载到了这块内存空间中的对应位置从而使得应用程序所对应的进程也可以采用类似操作系统内核那样的直映射Bare模式方式。然而这样做是因为实验一中的应用都是单线程应用它们的执行并不会产生新的执行体如子进程所以可以采用指定逻辑地址的办法进行简化。但是实际情况是我们在代理内核上是有可能执行多进程应用的特别是在开发板上验证多核RISC-V处理器的场景我们在实验三中也将开始讨论在PKE实验中实现和完善进程的管理。在这种场景下由于无法保证应用所要求的逻辑地址空间“恰好”能找到对应的物理地址空间且后者还未被占据。
这里我们可以观察一下在未指定逻辑地址的情况下的应用对应的逻辑地址。首先切换到lab2_1_pagetable然后构造内核和应用
```
// 切换到lab2_1_pagetable分支
$ git checkout lab2_1_pagetable
// 构造内核和应用
$ make
// 显示应用程序中将被加载的程序段
$ riscv64-unknown-elf-readelf -l ./obj/app_helloworld_no_lds
Elf file type is EXEC (Executable file)
Entry point 0x100f6
There is 1 program header, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000010000 0x0000000000010000
0x0000000000000360 0x0000000000000360 R E 0x1000
Section to Segment mapping:
Segment Sections...
00 .text .rodata
```
通过以上结果我们看到lab2_1的应用app_helloworld_no_lds实际上就是lab1_1中的app_helloworld不同的地方在于没有用到lab1_1中的user/user.lds来约束逻辑地址只包含一个代码段它的起始地址为0x0000000000010000即0x10000
对比[4.1.2节](#physicalmemory)中讨论的物理内存布局我们知道spike模拟的RISC-V机器并无处于0x10000的物理地址空间与其对应。这样我们就需要通过Sv39虚地址管理方案将0x10000开始的代码段映射到app_helloworld_no_lds中代码段**实际**被加载到的物理内存显然位于图4.3中的“实际内存空间”所标识的区域)区域。
PKE实验二中的应用加载是通过kernel/kernel.c文件中的load_user_program函数来完成的
```
37 void load_user_program(process *proc) {
38 sprint("User application is loading.\n");
39 proc->trapframe = (trapframe *)alloc_page(); //trapframe
40 memset(proc->trapframe, 0, sizeof(trapframe));
41
42 //user pagetable
43 proc->pagetable = (pagetable_t)alloc_page();
44 memset((void *)proc->pagetable, 0, PGSIZE);
45
46 proc->kstack = (uint64)alloc_page() + PGSIZE; //user kernel stack top
47 uint64 user_stack = (uint64)alloc_page(); //phisical address of user stack bottom
48 proc->trapframe->regs.sp = USER_STACK_TOP; //virtual address of user stack top
49
50 sprint("user frame 0x%lx, user stack 0x%lx, user kstack 0x%lx \n", proc->trapframe,
51 proc->trapframe->regs.sp, proc->kstack);
52
53 load_bincode_from_host_elf(proc);
54
55 // map user stack in userspace
56 user_vm_map((pagetable_t)proc->pagetable, USER_STACK_TOP - PGSIZE, PGSIZE, user_stack,
57 prot_to_type(PROT_WRITE | PROT_READ, 1));
58
59 // map trapframe in user space (direct mapping as in kernel space).
60 user_vm_map((pagetable_t)proc->pagetable, (uint64)proc->trapframe, PGSIZE, (uint64)proc->trapframe,
61 prot_to_type(PROT_WRITE | PROT_READ, 0));
62
63 // map S-mode trap vector section in user space (direct mapping as in kernel space)
64 // we assume that the size of usertrap.S is smaller than a page.
65 user_vm_map((pagetable_t)proc->pagetable, (uint64)trap_sec_start, PGSIZE, (uint64)trap_sec_start,
66 prot_to_type(PROT_READ | PROT_EXEC, 0));
67 }
```
load_user_program()函数对于应用进程逻辑空间的操作可以分成以下几个部分:
- 39--40行分配一个物理页面将其作为栈帧trapframe即发生中断时保存用户进程执行上下文的内存空间。由于物理页面都是从位于物理地址范围[_endPHYS_TOP]的空间中分配的它的首地址也将位于该区间。所以第60--61行的映射也是做一个proc->trapframe到所分配页面的直映射逻辑地址=物理地址)。
- 43--44行分配一个物理页面作为存放进程页表根目录page directory对应图4.1中的VPN[2])的空间。
- 46行分配了一个物理页面作为用户进程的内核态栈该栈将在用户进程进入中断处理时用作S模式内核处理函数使用的栈。然而这个栈并未映射到用户进程的逻辑地址空间而是将其首地址保存在proc->kstack中。
- 47--48行再次分配一个物理页面作为用户进程的用户态栈该栈供应用在用户模式下使用并在第56--57行映射到用户进程的逻辑地址USER_STACK_TOP。
- 53行调用load_bincode_from_host_elf()函数该函数将读取应用所对应的ELF文件并将其中的代码段读取到新分配的内存空间物理地址位于[_endPHYS_TOP]区间)。
- 65--66行将内核中的S态trap入口函数所在的物理页一一映射到用户进程的逻辑地址空间。
通过以上load_user_program()函数,我们可以大致画出用户进程的逻辑地址空间,以及该地址空间到物理地址空间的映射。
![user_address_mapping.png](pictures/user_address_mapping.png)
<a name="user_vm_space"></a>
图4.5 用户进程的逻辑地址空间和到物理地址空间的映射
我们看到用户进程在装入后其逻辑地址空间有4个区间建立了和物理地址空间的映射。从上往下观察“用户进程trapframe”和“trap入口页面”的逻辑地址大于0x80000000且与承载它们的物理空间建立了一对一的映射关系。另外两个区间即“用户态栈”和“用户代码段”的逻辑地址都低于0x80000000它们所对应的物理空间则都位于实际空闲内存区域同时这种映射的逻辑地址显然不等于物理地址。
<a name="pagetablecook"></a>
### 4.1.4 与页表操作相关的重要函数
实验二与页表操作相关的函数都放在kernel/vmm.c文件中其中比较重要的函数有
- int map_pages(pagetable_t page_dir, uint64 va, uint64 size, uint64 pa, int perm);
该函数的第一个输入参数page_dir为根目录所在物理页面的首地址第二个参数va则是将要被映射的逻辑地址第三个参数size为所要建立映射的区间的长度第四个参数pa为逻辑地址va所要被映射到的物理地址首地址最后第五个的参数perm为映射建立后页面访问的权限。
总的来说该函数将在给定的page_dir所指向的根目录中建立[vava+size]到[papa+size]的映射。
- pte_t *page_walk(pagetable_t page_dir, uint64 va, int alloc);
该函数的第一个输入参数page_dir为根目录所在物理页面的首地址第二个参数va为所要查找walk的逻辑地址第三个参数实际上是一个bool类型当它为1时如果它所要查找的逻辑地址并未建立与物理地址的映射图4.1中的Page Medium Directory不存在则通过分配内存空间建立从根目录到页表的完整映射并最终返回va所对应的页表项当它为0时如果它所要查找的逻辑地址并未建立与物理地址的映射则返回NULL否则返回va所对应的页表项。
- uint64 lookup_pa(pagetable_t pagetable, uint64 va);
查找逻辑地址va所对应的物理地址pa。如果没有与va对应的物理页面则返回NULL否则返回va对应的物理地址。
<a name="lab2_1_pagetable"></a>
## 4.2 lab2_1 虚实地址转换
<a name="lab2_1_app"></a>
#### **给定应用**
- user/app_helloworld_no_lds.c
```
1 /*
2 * Below is the given application for lab2_1.
3 * This app runs in its own address space, in contrast with in direct mapping.
4 */
5
6 #include "user_lib.h"
7 #include "util/types.h"
8
9 int main(void) {
10 printu("Hello world!\n");
11 exit(0);
12 }
```
该应用的代码跟lab1_1是一样的。但是不同的地方在于它的编译和链接并未指定程序中符号的逻辑地址。
- 切换到lab2_1继承lab1_3中所做的修改并make后的直接运行结果
```
//切换到lab2_1
$ git checkout lab2_1_pagetable
//继承lab1_3以及之前的答案
$ git merge lab1_3_irq -m "continue to work on lab2_1"
//重新构造
$ make clean; make
//运行构造结果
$ spike ./obj/riscv-pke ./obj/app_helloworld_no_lds
In m_start, hartid:0
HTIF is available!
(Emulated) memory size: 2048 MB
Enter supervisor mode...
PKE kernel start 0x0000000080000000, PKE kernel end: 0x000000008000e000, PKE kernel size: 0x000000000000e000 .
free physical memory address: [0x000000008000e000, 0x0000000087ffffff]
kernel memory manager is initializing ...
KERN_BASE 0x0000000080000000
physical address of _etext is: 0x0000000080004000
kernel page table is on
User application is loading.
user frame 0x0000000087fbc000, user stack 0x000000007ffff000, user kstack 0x0000000087fbb000
Application: ./obj/app_helloworld_no_lds
Application program entry point (virtual address): 0x00000000000100f6
Switching to user mode...
You have to implement user_va_to_pa (convert user va to pa) to print messages in lab2_1.
System is shutting down with exit code -1.
```
从以上运行结果来看我们的应用app_helloworld_no_lds并未如愿地打印出“Hello world!\n”这是因为user/app_helloworld_no_lds.c的第10行`printu("Hello world!\n");`中的“Hello world!\n”字符串本质上是存储在.rodata段它被和代码段.text一起被装入内存。从逻辑地址结构来看它的逻辑地址就应该位于图4.5中的“用户代码段”显然低于0x80000000。
而printu是一个典型的系统调用参考[lab1_1](chapter3_traps.md#syscall)的内容它的执行逻辑是通过ecall指令陷入到内核S模式完成到屏幕的输出。然而对于内核而言显然不能继续使用“Hello world!\n”的逻辑地址对它进行访问而必须将其转换成物理地址因为如图4.4所示操作系统内核已建立了到“实际空闲内存”的直映射。而lab2_1的代码显然未实现这种转换。
<a name="lab2_1_content"></a>
#### **实验内容**
实现user_va_to_pa()函数,完成给定逻辑地址到物理地址的转换,并获得以下预期结果:
```
$ spike ./obj/riscv-pke ./obj/app_helloworld_no_lds
In m_start, hartid:0
HTIF is available!
(Emulated) memory size: 2048 MB
Enter supervisor mode...
PKE kernel start 0x0000000080000000, PKE kernel end: 0x000000008000e000, PKE kernel size: 0x000000000000e000 .
free physical memory address: [0x000000008000e000, 0x0000000087ffffff]
kernel memory manager is initializing ...
KERN_BASE 0x0000000080000000
physical address of _etext is: 0x0000000080004000
kernel page table is on
User application is loading.
user frame 0x0000000087fbc000, user stack 0x000000007ffff000, user kstack 0x0000000087fbb000
Application: ./obj/app_helloworld_no_lds
Application program entry point (virtual address): 0x00000000000100f6
Switching to user mode...
Hello world!
User exit with code:0.
System is shutting down with exit code 0.
```
<a name="lab2_1_guide"></a>
#### **实验指导**
读者可以参考[lab1_1](chapter3_traps.md#syscall)的内容重走从应用的printu到S态的系统调用的完整路径最终来到kernel/syscall.c文件的sys_user_print()函数:
```
21 ssize_t sys_user_print(const char* buf, size_t n) {
22 //buf is an address in user space on user stack,
23 //so we have to transfer it into phisical address (kernel is running in direct mapping).
24 assert( current );
25 char* pa = (char*)user_va_to_pa((pagetable_t)(current->pagetable), (void*)buf);
26 sprint(pa);
27 return 0;
28 }
```
该函数最终在第26行通过调用sprint将结果输出但是在输出前需要将buf地址转换为物理地址传递给sprint这一转换是通过user_va_to_pa()函数完成的。而user_va_to_pa()函数的定义在kernel/vmm.c文件中定义
```
144 void *user_va_to_pa(pagetable_t page_dir, void *va) {
145 //TODO: implement user_va_to_pa to convert a given user virtual address "va" to its corresponding
146 // physical address, i.e., "pa". To do it, we need to walk through the page table, starting from its
147 // directory "page_dir", to locate the PTE that maps "va". If found, returns the "pa" by using:
148 // pa = PYHS_ADDR(PTE) + (va - va & (1<<PGSHIFT -1))
149 // Here, PYHS_ADDR() means retrieving the starting address (4KB aligned), and (va - va & (1<<PGSHIFT -1))
150 // means computing the offset of "va" in its page.
151 // Also, it is possible that "va" is not mapped at all. in such case, we can find invalid PTE, and
152 // should return NULL.
153 panic( "You have to implement user_va_to_pa (convert user va to pa) to print messages in lab2_1.\n" );
154
155 }
```
如注释中的提示为了在page_dir所指向的页表中查找逻辑地址va就必须通过调用[页表操作相关函数](#pagetablecook)找到包含va的页表项PTE通过该PTE的内容得知va所在的物理页面的首地址最后再通过计算va在页内的位移得到va最终对应的物理地址。
<a name="lab2_2_allocatepage"></a>
## 4.3 lab2_2 简单内存分配和回收
<a name="lab2_2_app"></a>
#### **给定应用**
- user/app_naive_malloc.c
```
1 /*
2 * Below is the given application for lab2_2.
3 */
4
5 #include "user_lib.h"
6 #include "util/types.h"
7
8 struct my_structure {
9 char c;
10 int n;
11 };
12
13 int main(void) {
14 struct my_structure* s = (struct my_structure*)naive_malloc();
15 s->c = 'a';
16 s->n = 1;
17
18 printu("s: %lx, {%c %d}\n", s, s->c, s->n);
19
20 naive_free(s);
21
22 exit(0);
23 }
```
该应用的逻辑非常简单首先分配一个空间内存页面来存放my_structure结构往my_structure结构的实例中存储信息打印信息并最终将之前所分配的空间释放掉。这里新定义了两个用户态函数naive_malloc()和naive_free(),它们最终会转换成系统调用,完成内存的分配和回收操作。
- 切换到lab2_2继承lab2_1以及之前实验所做的修改并make后的直接运行结果
```
//切换到lab2_2
$ git checkout lab2_2_allocatepage
//继承lab2_1以及之前的答案
$ git merge lab2_1_pagetable -m "continue to work on lab2_2"
//重新构造
$ make clean; make
//运行构造结果
$ spike ./obj/riscv-pke ./obj/app_naive_malloc
In m_start, hartid:0
HTIF is available!
(Emulated) memory size: 2048 MB
Enter supervisor mode...
PKE kernel start 0x0000000080000000, PKE kernel end: 0x000000008000e000, PKE kernel size: 0x000000000000e000 .
free physical memory address: [0x000000008000e000, 0x0000000087ffffff]
kernel memory manager is initializing ...
KERN_BASE 0x0000000080000000
physical address of _etext is: 0x0000000080004000
kernel page table is on
User application is loading.
user frame 0x0000000087fbc000, user stack 0x000000007ffff000, user kstack 0x0000000087fbb000
Application: ./obj/app_naive_malloc
Application program entry point (virtual address): 0x0000000000010078
Switching to user mode...
s: 0000000000400000, {a 1}
You have to implement user_vm_unmap to free pages using naive_free in lab2_2.
System is shutting down with exit code -1.
```
从输出结果来看,`s: 0000000000400000, {a 1}`的输出说明分配内存已经做好也就是说naive_malloc函数及其内核功能的实现已完成且打印出了我们预期的结果。但是naive_free对应的功能并未完全做好。
<a name="lab2_2_content"></a>
#### **实验内容**
如输出提示所表明的那样需要完成naive_free对应的功能并获得以下预期的结果输出
```
$ spike ./obj/riscv-pke ./obj/app_naive_malloc
In m_start, hartid:0
HTIF is available!
(Emulated) memory size: 2048 MB
Enter supervisor mode...
PKE kernel start 0x0000000080000000, PKE kernel end: 0x000000008000e000, PKE kernel size: 0x000000000000e000 .
free physical memory address: [0x000000008000e000, 0x0000000087ffffff]
kernel memory manager is initializing ...
KERN_BASE 0x0000000080000000
physical address of _etext is: 0x0000000080004000
kernel page table is on
User application is loading.
user frame 0x0000000087fbc000, user stack 0x000000007ffff000, user kstack 0x0000000087fbb000
Application: ./obj/app_naive_malloc
Application program entry point (virtual address): 0x0000000000010078
Switching to user mode...
s: 0000000000400000, {a 1}
User exit with code:0.
System is shutting down with exit code 0.
```
<a name="lab2_2_guide"></a>
#### **实验指导**
一般来说应用程序执行过程中的动态内存分配和回收是操作系统中的堆Heap管理的内容。在本实验中我们实际上是为PKE操作系统内核实现一个简单到不能再简单的“堆”。为实现naive_free()的内存回收过程我们需要了解其对偶过程即内存是如何“分配”给应用程序并供后者使用的。为此我们先阅读kernel/syscall.c文件中的naive_malloc()函数的底层实现sys_user_allocate_page()
```
43 uint64 sys_user_allocate_page() {
44 void* pa = alloc_page();
45 uint64 va = g_ufree_page;
46 g_ufree_page += PGSIZE;
47 user_vm_map((pagetable_t)current->pagetable, va, PGSIZE, (uint64)pa,
48 prot_to_type(PROT_WRITE | PROT_READ, 1));
49
50 return va;
51 }
```
这个函数在44行分配了一个首地址为pa的物理页面这个物理页面要以何种方式映射给应用进程使用呢第55行给出了pa对应的逻辑地址va = g_ufree_page并在56行对g_ufree_page进行了递增操作。最后在47--48行将pa映射给了va地址。这个过程中g_ufree_page是如何定义的呢我们可以找到它在kernel/process.c文件中的定义
```
27 // start virtual address of our simple heap.
28 uint64 g_ufree_page = USER_FREE_ADDRESS_START;
```
而USER_FREE_ADDRESS_START的定义在kernel/memlayout.h文件
```
17 // simple heap bottom, virtual address starts from 4MB
18 #define USER_FREE_ADDRESS_START 0x00000000 + PGSIZE * 1024
```
可以看到在我们的PKE操作系统内核中应用程序执行过程中所动态分配类似malloc的内存是被映射到USER_FREE_ADDRESS_START4MB开始的地址的。那么这里的USER_FREE_ADDRESS_START对应图4.5中的用户进程的逻辑地址空间的哪个部分呢?**这一点请读者自行判断并分析为什么是4MB以及能不能用其他的逻辑地址**
以上了解了内存的分配过程后,我们就能够大概了解其反过程的回收应该怎么做了,大概分为以下步骤:
- 找到一个给定va所对应的页表项PTE查找[4.1.4节](pagetablecook),看哪个函数能满足此需求);
- 如果找到过滤找不到的情形通过该PTE的内容得知va所对应物理页的首地址pa
- 回收pa对应的物理页并将PTE中的Valid位置为0。
<a name="lab2_3_pagefault"></a>
## 4.4 lab2_3 缺页异常
<a name="lab2_3_app"></a>
#### **给定应用**
- user/app_sum_sequence.c
```
1 /*
2 * The application of lab2_3.
3 */
4
5 #include "user_lib.h"
6 #include "util/types.h"
7
8 //
9 // compute the summation of an arithmetic sequence. for a given "n", compute
10 // result = n + (n-1) + (n-2) + ... + 0
11 // sum_sequence() calls itself recursively till 0. The recursive call, however,
12 // may consume more memory (from stack) than a physical 4KB page, leading to a page fault.
13 // PKE kernel needs to improved to handle such page fault by expanding the stack.
14 //
15 uint64 sum_sequence(uint64 n) {
16 if (n == 0)
17 return 0;
18 else
19 return sum_sequence( n-1 ) + n;
20 }
21
22 int main(void) {
23 // we need a large enough "n" to trigger pagefaults in the user stack
24 uint64 n = 1000;
25
26 printu("Summation of an arithmetic sequence from 0 to %ld is: %ld \n", n, sum_sequence(1000) );
27 exit(0);
28 }
```
给定一个递增的等差数列:`0, 1, 2, ..., n`如何求该数列的和以上的应用给出了它的递归recursive解法。通过定义一个函数sum_sequence(n)将求和问题转换为sum_sequence(n-1) + n的问题。问题中n依次递减直至为0时令sum_sequence(0)=0。
- 切换到lab2_3、继承lab2_2及以前所做修改并make后的直接运行结果
```
//切换到lab2_3
$ git checkout lab2_3_pagefault
//继承lab2_2以及之前的答案
$ git merge lab2_2_allocatepage -m "continue to work on lab2_3"
//重新构造
$ make clean; make
//运行构造结果
$ spike ./obj/riscv-pke ./obj/app_sum_sequence
In m_start, hartid:0
HTIF is available!
(Emulated) memory size: 2048 MB
Enter supervisor mode...
PKE kernel start 0x0000000080000000, PKE kernel end: 0x000000008000e000, PKE kernel size: 0x000000000000e000 .
free physical memory address: [0x000000008000e000, 0x0000000087ffffff]
kernel memory manager is initializing ...
KERN_BASE 0x0000000080000000
physical address of _etext is: 0x0000000080004000
kernel page table is on
User application is loading.
user frame 0x0000000087fbc000, user stack 0x000000007ffff000, user kstack 0x0000000087fbb000
Application: ./obj/app_sum_sequence
Application program entry point (virtual address): 0x0000000000010096
Switching to user mode...
handle_page_fault: 000000007fffdff8
You need to implement the operations that actually handle the page fault in lab2_3.
System is shutting down with exit code -1.
```
以上执行结果为什么会出现handle_page_fault呢这就跟我们给出的应用程序递归求解等差数列的和有关了。
递归解法的特点是函数调用的路径会被完整地保存在栈stack也就是说函数的下一次调用会将上次一调用的现场包括参数压栈直到n=0时依次返回到最开始给定的n值从而得到最终的计算结果。显然在以上计算等差数列的和的程序中n值给得越大就会导致越深的栈而栈越深需要的内存空间也就越多。
通过[4.1.3节](#virtualaddressspace)中对用户进程逻辑地址空间的讨论以及图4.5的图示我们知道应用程序最开始被载入并装配为用户进程它的用户态栈空间栈底在0x7ffff000即USER_STACK_TOP仅有1个4KB的页面。显然只要以上的程序给出的n值“足够”大就一定会“压爆”用户态栈。而以上运行结果中出问题的地方即handle_page_fault后出现的地址0x7fffdff8也恰恰在用户态栈所对应的空间。
以上分析表明,之所以运行./obj/app_sum_sequence会出现错误handle_page_fault是因为给sum_sequence()函数的n值太大把用户态栈“压爆”了。
<a name="lab2_3_content"></a>
#### **实验内容**
在PKE操作系统内核中完善用户态栈空间的管理使得它能够正确处理用户进程的“压栈”请求。
实验完成后的运行结果:
```
$ spike ./obj/riscv-pke ./obj/app_sum_sequence
In m_start, hartid:0
HTIF is available!
(Emulated) memory size: 2048 MB
Enter supervisor mode...
PKE kernel start 0x0000000080000000, PKE kernel end: 0x000000008000e000, PKE kernel size: 0x000000000000e000 .
free physical memory address: [0x000000008000e000, 0x0000000087ffffff]
kernel memory manager is initializing ...
KERN_BASE 0x0000000080000000
physical address of _etext is: 0x0000000080004000
kernel page table is on
User application is loading.
user frame 0x0000000087fbc000, user stack 0x000000007ffff000, user kstack 0x0000000087fbb000
Application: ./obj/app_sum_sequence
Application program entry point (virtual address): 0x0000000000010096
Switching to user mode...
handle_page_fault: 000000007fffdff8
handle_page_fault: 000000007fffcff8
handle_page_fault: 000000007fffbff8
Summation of an arithmetic sequence from 0 to 1000 is: 500500
User exit with code:0.
System is shutting down with exit code 0.
```
<a name="lab2_3_guide"></a>
#### **实验指导**
本实验需要结合[lab1_2](chapter3_traps.md#exception)中的异常处理知识但要注意的是lab1_2中我们处理的是非法指令异常对该异常的处理足够操作系统将应用进程“杀死”。本实验中我们处理的是缺页异常app_sum_sequence.c执行的显然是“合法”操作不能也不应该将应用进程杀死。正确的做法是首先通过异常的类型判断我们处理的确实是缺页异常接下来判断发生缺页的是不是用户栈空间如果是则分配一个物理页空间最后将该空间通过vm_map“粘”到用户栈上以扩充用户栈空间。
另外lab1_2中处理的非法指令异常是在M模式下处理的原因是我们根本没有将该异常代理给S模式。但是对于本实验中的缺页异常是不是也是需要在M模式处理呢我们先回顾以下kernel/machine/minit.c文件中的delegate_traps()函数:
```
51 static void delegate_traps() {
52 if (!supports_extension('S')) {
53 // confirm that our processor supports supervisor mode. abort if not.
54 sprint("s mode is not supported.\n");
55 return;
56 }
57
58 uintptr_t interrupts = MIP_SSIP | MIP_STIP | MIP_SEIP;
59 uintptr_t exceptions = (1U << CAUSE_MISALIGNED_FETCH) | (1U << CAUSE_FETCH_PAGE_FAULT) |
60 (1U << CAUSE_BREAKPOINT) | (1U << CAUSE_LOAD_PAGE_FAULT) |
61 (1U << CAUSE_STORE_PAGE_FAULT) | (1U << CAUSE_USER_ECALL);
62
63 write_csr(mideleg, interrupts);
64 write_csr(medeleg, exceptions);
65 assert(read_csr(mideleg) == interrupts);
66 assert(read_csr(medeleg) == exceptions);
67 }
```
而在本实验的应用中产生缺页异常的本质还是应用往未被映射的内存空间“写”以及后续的访问所导致的所以CAUSE_STORE_PAGE_FAULT是我们应该关注的异常。通过阅读delegate_traps()函数我们看到该函数显然已将缺页异常CAUSE_STORE_PAGE_FAULT代理给了S模式所以接下来我们就应阅读kernel/strap.c文件中对于这类异常的处理
```
42 void handle_user_page_fault(uint64 mcause, uint64 sepc, uint64 stval) {
43 sprint("handle_page_fault: %lx\n", stval);
44 switch (mcause) {
45 case CAUSE_STORE_PAGE_FAULT:
46 // TODO: implement the operations that solve the page fault to dynamically increase application stack.
47 // hint: first allocate a new physical page, and then, maps the new page to the virtual address that
48 // causes the page fault.
49 panic( "You need to implement the operations that actually handle the page fault in lab2_3.\n" );
50
51 break;
52 default:
53 sprint("unknown page fault.\n");
54 break;
55 }
56 }
```
这里,我们找到了之前运行./obj/app_sum_sequence出错的地方我们只需要改正这一错误实现缺页处理使得程序获得正确的输出就好。实现缺页处理的思路如下
- 通过输入的参数stval存放的是发生缺页异常时程序想要访问的逻辑地址判断缺页的逻辑地址在用户进程逻辑地址空间中的位置看是不是比USER_STACK_TOP更大且比我们预设的可能的用户栈最大空间小这里我们可以给用户栈一个上限例如20个4KB的页面
- 分配一个物理页将所分配的物理页面映射到stval所对应的虚拟地址上。

@ -1,297 +0,0 @@
# 第五章实验4缺页异常的处理
## 5.1 实验内容
#### 应用: ####
app4_1.c源文件如下
1 #include<stdio.h>
2
3 int main()
4 {
5
6 uintptr_t addr = 0x7f000000;
7 *(int *)(addr)=1;
8
9 uintptr_t addr1_same_page = 0x7f000010;
10 uintptr_t addr2_same_page = 0x7f000fff;
11 *(int *)(addr1_same_page)=2;
12 *(int *)(addr2_same_page)=3;
13
14 uintptr_t addr1_another_page = 0x7f001000;
15 uintptr_t addr2_another_page = 0x7f001ff0;
16 *(int *)(addr1_another_page)=4;
17 *(int *)(addr2_another_page)=5;
18
19
20 return 0;
21 }
以上代码中对地址0x7f000000进行访问将触发缺页异常。随后对同一页内的地址0x7f000010、0x7f000fff进行访问此时由于页0x7f000000已近完成映射故而不会发生异常。最后有对新的一页进行访问将再次引发缺页异常。
app4_2.c源文件如下
1 #include <stdio.h>
2 void fun(int num){
3 if(num==0){
4 return;
5 }
6 fun(num-1);
7 }
8 int main(){
9 int num=10000;
10 fun(num);
11 printf("end \n");
12 return 0;
13 }
以上代码中进行了大量递归,这将产生缺页。
#### 任务一 : 缺页中断实例的完善(编程) ####
任务描述:
**在**"pk/mmap.c"内有__handle_page_fault()函数,完善该函数,实现缺页中的处理。
```
202 static int __handle_page_fault(uintptr_t vaddr, int prot)
203 {
204 printk("page fault vaddr:0x%lx\n", vaddr);
205 //your code here
206 //start------------>
207 pte_t* pte =0;
208
209 //<-----------end
210 if (pte == 0 || *pte == 0 || !__valid_user_range(vaddr, 1))
211 return -1;
212 else if (!(*pte & PTE_V))
213 {
214
215 //your code here
216 //start--------->
217
218 uintptr_t ppn =0;
219 vmr_t* v = NULL;
220
221 //<----------end
```
预期输出:
当你完成__handle_page_fault()函数后,可进行如下测试:
编译app目录下实验四相关文件
`$ riscv64-unknown-elf-gcc app/app4_1.c -o app/elf/app4_1`
`$ riscv64-unknown-elf-gcc app/app4_2.c -o app/elf/app4_2`
使用spike运行预期输出如下
```
spike obj/pke app/elf/app4_1
PKE IS RUNNING
page fault vaddr:0x0000000000011000
page fault vaddr:0x000000007f7ecc00
page fault vaddr:0x00000000000100c0
page fault vaddr:0x000000007f000000
page fault vaddr:0x000000007f001000
```
`$ spike obj/pke app/elf/app4_1`
//递归程序可正常运行
如果你的两个测试app都可以正确输出的话那么运行检查的python脚本
`$ ./pke-lab4`
若得到如下输出,那么恭喜你,你已经成功完成了实验四!!!
```
build pk : OK
running app4_1 : OK
test4_1 : OK
running app4_2 : OK
test4_2 : OK
```
## 5.2 基础知识
**5.2.1 虚拟地址空间**
物理地址唯一,且有限的,但现在的操作系统上往往有不止一个的程序在运行。如果只有物理地址,那对于程序员来说无疑是相当繁琐的。程序不知道那些内存可用,那些内存已经被其他程序占有,这就意味着操作系统必须管理所有的物理地址,并且所有所有代码都是共用的。
为了解决上述问题,操作系统引入了虚拟地址的概念。每个进程拥有着独立的虚拟地址空间,这个空间是连续的,一个虚拟页可以映射到不同或者相同的物理页。这就是我们所说的虚拟地址。在程序中,我们所使用的变量的地址均为虚拟地址。
**5.2.2 虚拟地址同物理地址之间的转换**
虚拟地址只是一个逻辑上的概念在计算机中最后正真被访问的地址仍是物理地址。所以我们需要在一个虚拟地址访问内存之前将它翻译成物理地址这个过程称为地址翻译。CPU上的内存管理单元MMU会利用存放在主存的页表完成这一过程。
RISCV的S模式下提供了基于页面的虚拟内存管理机制内存被划分为固定大小的页。我们使用物理地址对这些页进行描述我们在此回顾上一章所讲到的RISCV物理地址的定义
<img src="pictures/fig5_1.png" alt="fig5_1" style="zoom:80%;" />
图5.1 RISCV64 物理地址
可以看到物理地址由PPN物理页号与Offset偏移量组成。这里的PPN就对应着上述的物理页。
现在我们来看RISCV虚拟地址的定义
<img src="pictures/fig5_2.png" alt="fig5_2" style="zoom:80%;" />
图5.2 RISCV64 虚拟地址
可以看到虚拟地址同样由页号和偏移量组成。而这二者之间是如何转换的呢RV64支持多种分页方案如Sv32、Sv39、Sv48它们的原理相似这里我们对pke中所使用的Sv39进行讲述。Sv39中维护着一个三级的页表其页表项定义如下
<img src="pictures/fig1_7.png" alt="fig1_7" style="zoom:80%;" />
图5.3 Sv39页表项
当启动分页后MMU会对每个虚拟地址进行页表查询页表的地址由satp寄存器保存。在"pk/mmap.c"中的pk_vm_init函数中有如下一行代码其中sptbr即为satp的曾用名在这行代码中我们将页表地址写入satp寄存器。
```
458 write_csr(sptbr, ((uintptr_t)root_page_table >> RISCV_PGSHIFT) | SATP_MODE_CHOICE);
```
<img src="pictures/fig5_4.png" alt="fig5_4" style="zoom:80%;" />
图5.4 地址转换
于是当需要进行页表转换时我们变从satp所存储的页表地址开始逐级的转换。
在pke中位于"pk/mmap.c"中的转换代码如下:
```
112 static size_t pt_idx(uintptr_t addr, int level)
113 {
114 size_t idx = addr >> (RISCV_PGLEVEL_BITS*level + RISCV_PGSHIFT);
115 return idx & ((1 << RISCV_PGLEVEL_BITS) - 1);
116 }
```
首先我们来看pt_idx函数函数中将虚拟地址addr右移RISCV_PGLEVEL_BITS*level + RISCV_PGSHIFT位其中RISCV_PGSHIFT对应着VPN中的Offset而level则对应着各级VPNpt_idx通过level取出指定的VPN。当level = 2, 得到vpn[2]即页目录项在一级页表的序号当level = 1, 得到vpn[1]即页目录项在二级页表的序号同理当level = 0, 则得到vpn[0],即页表项在三级页表的序号。
```
125 static pte_t* __walk_internal(uintptr_t addr, int create)
126 {
127 pte_t* t = root_page_table;
128 for (int i = (VA_BITS - RISCV_PGSHIFT) / RISCV_PGLEVEL_BITS - 1; i > 0; i--) {
129 size_t idx = pt_idx(addr, i);
130 if (unlikely(!(t[idx] & PTE_V)))
131 return create ? __continue_walk_create(addr, &t[idx]) : 0;
132 t = (pte_t*)(pte_ppn(t[idx]) << RISCV_PGSHIFT);
133 }
134 return &t[pt_idx(addr, 0)];
135 }
```
接着我们进一步分析__walk_internal函数首先VA_BITS即虚拟地址的位数为39RISCV_PGSHIFT即代表虚拟地址中Offset的位数二者相减剩下的就是VPN0、VPN1……VPNX的位数在除以VPN的位数得到就是VPN的数量。由于pke中式Sv39故而VPN的数量为3即VPN0、VPN1、VPN2。
接着我们使用pt_idx函数得到各级VPN的值依据图5.2所示逐级查询,一直找到该虚拟地址对应的页表项,而该页表项中存着该虚拟地址所对应的物理页号,再加上虚拟地址中的偏离量,我们就可以找到最终的物理地址了!!
**5.2.3** **缺页异常处理**
```
1 #include<stdio.h>
2
3 int main()
4 {
5
6 uintptr_t addr = 0x7f000000;
7 *(int *)(addr)=1;
8
9 uintptr_t addr1_same_page = 0x7f000010;
10 uintptr_t addr2_same_page = 0x7f000fff;
11 *(int *)(addr1_same_page)=2;
12 *(int *)(addr2_same_page)=3;
13
14 uintptr_t addr1_another_page = 0x7f001000;
15 uintptr_t addr2_another_page = 0x7f001ff0;
16 *(int *)(addr1_another_page)=4;
17 *(int *)(addr2_another_page)=5;
18
19
20 return 0;
21 }
```
以上程序中我们人为的访问虚拟地址0x7f000000与虚拟地址0x7f001000所对应的物理页由于操作系统并没有事先加载这些页面于是会出发缺页中断异常。进入pk/mmap.c文件下的__handle_page_fault函数中其代码如下
```
203 static int __handle_page_fault(uintptr_t vaddr, int prot)
204 {
205 printk("page fault vaddr:0x%lx\n", vaddr);
206 //your code here
207 //start------------>
208 pte_t* pte =0;
209
210 //<-----------end
211 if (pte == 0 || *pte == 0 || !__valid_user_range(vaddr, 1))
212 return -1;
213 else if (!(*pte & PTE_V))
214 {
215
216 //your code here
217 //start--------->
218
219 uintptr_t ppn =0;
220 vmr_t* v = NULL;
221
222 //<----------end
223
224 if (v->file)
225 {
226 size_t flen = MIN(RISCV_PGSIZE, v->length - (vaddr - v->addr));
227 // ssize_t ret = file_pread(v->file, (void*)vaddr, flen, vaddr - v->addr + v->offset);
228 ssize_t ret = file_pread_pnn(v->file, (void*)vaddr, flen, ppn, vaddr - v->addr + v->offset);
229 kassert(ret > 0);
230 if (ret < RISCV_PGSIZE)
231 memset((void*)vaddr + ret, 0, RISCV_PGSIZE - ret);
232 }
233 else
234 memset((void*)vaddr, 0, RISCV_PGSIZE);
235 __vmr_decref(v, 1);
236 *pte = pte_create(ppn, prot_to_type(v->prot, 1));
237 }
238
239 pte_t perms = pte_create(0, prot_to_type(prot, 1));
240 if ((*pte & perms) != perms)
241 return -1;
242
243 flush_tlb();
244 return 0;
245 }
```
对于一个没有对应物理地址的虚拟地址我们需要进行如下的处理。首先找到该物理地址所对应的pte这里你可能会使用到__walk函数__walk中调用了上文中我们讨论过的__walk_internal函数对于一个给定的虚拟地址返回其对于的pte其定义如下
```
138 pte_t* __walk(uintptr_t addr)
139 {
140 return __walk_internal(addr, 0);
141 }
```
其次使用操作系统为该虚拟地址分配一个相对应的物理页还记得物理内存管理中的内存分配嘛现在它有用武之地了最后将该物理地址写入第一步的得到的pte中这里你会用到page2ppn和pte_create函数。
以上,就是本次实验需要大家完成的部分了!

@ -0,0 +1,442 @@
# 第五章实验3进程管理
### 目录
- [5.1 实验3的基础知识](#fundamental)
- [5.1.1 多任务环境下进程的封装](#subsec_process_structure)
- [5.1.2 进程的换入与换出](#subsec_switch)
- [5.1.3 就绪进程的管理与调度](#subsec_management)
- [5.2 lab3_1 进程创建fork](#lab3_1_naive_fork)
- [给定应用](#lab3_1_app)
- [实验内容](#lab3_1_content)
- [实验指导](#lab3_1_guide)
- [5.3 lab3_2 进程yield](#lab3_2_yield)
- [给定应用](#lab3_2_app)
- [实验内容](#lab3_2_content)
- [实验指导](#lab3_2_guide)
- [5.4 lab3_3 循环轮转调度](#lab3_3_rrsched)
- [给定应用](#lab3_3_app)
- [实验内容](#lab3_3_content)
- [实验指导](#lab3_3_guide)
<a name="fundamental"></a>
## 5.1 实验3的基础知识
完成了实验1和实验2的读者应该对PKE实验中的“进程”不会太陌生。因为实际上我们从最开始的lab1_1开始就有了进程结构struct process只是在之前的实验中进程结构中最重要的成员是trapframe和kstack它们分别用来记录系统进入S模式前的进程上下文以及作为进入S模式后的操作系统栈。在实验3我们将进入多任务环境完成PKE实验环境下的进程创建、换入换出以及进程调度相关实验。
<a name="subsec_process_structure"></a>
### 5.1.1 多任务环境下进程的封装
实验3跟之前的两个实验最大的不同在于在实验3的3个基本实验中PKE操作系统将需要支持多个进程的执行。为了对多任务环境进行支撑PKE操作系统定义了一个“进程池”见kernel/process.c文件
```
34 process procs[NPROC];
```
实际上这个进程池就是一个包含NPROC=32见kernel/process.h文件个process结构的数组。
接下来PKE操作系统对进程的结构进行了扩充见kernel/process.h文件
```
53 // points to a page that contains mapped_regions
54 mapped_region *mapped_info;
55 // next free mapped region in mapped_info
56 int total_mapped_region;
57
58 // process id
59 uint64 pid;
60 // process status
61 int status;
62 // parent process
63 struct process *parent;
64 // next queue element
65 struct process *queue_next;
66
67 // accounting
68 int tick_count;
```
- 前两项mapped_info和total_mapped_region用于对进程的虚拟地址空间中的代码段、堆栈段等进行跟踪这些虚拟地址空间在进程创建fork将发挥重要作用。同时这也是lab3_1的内容。PKE将进程可能拥有的段分为以下几个类型
```
29 enum segment_type {
30 CODE_SEGMENT, // ELF segment
31 DATA_SEGMENT, // ELF segment
32 STACK_SEGMENT, // runtime segment
33 CONTEXT_SEGMENT, // trapframe segment
34 SYSTEM_SEGMENT, // system segment
35 };
```
其中CODE_SEGMENT表示该段是从可执行ELF文件中加载的代码段DATA_SEGMENT为从ELF文件中加载的数据段STACK_SEGMENT为进程自身的栈段CONTEXT_SEGMENT为保存进程上下文的trapframe所对应的段SYSTEM_SEGMENT为进程的系统段如所映射的异常处理段。
- pid是进程的ID号具有唯一性
- status记录了进程的状态PKE操作系统在实验3给进程规定了以下几种状态
```
20 enum proc_status {
21 FREE, // unused state
22 READY, // ready state
23 RUNNING, // currently running
24 BLOCKED, // waiting for something
25 ZOMBIE, // terminated but not reclaimed yet
26 };
```
其中FREE为自由态表示进程结构可用READY为就绪态即进程所需的资源都已准备好可以被调度执行RUNNING表示该进程处于正在运行的状态BLOCKED表示进程处于阻塞状态ZOMBIE表示进程处于“僵尸”状态进程的资源可以被释放和回收。
- parent用于记录进程的父进程
- queue_next用于将进程链接进各类队列比如就绪队列
- tick_count用于对进程进行记账即记录它的执行经历了多少次的timer事件将在lab3_3中实现循环轮转调度时使用。
<a name="subsec_switch"></a>
### 5.1.2 进程的启动与终止
PKE实验中创建一个进程需要先调用kernel/process.c文件中的alloc_process()函数:
```
88 process* alloc_process() {
89 // locate the first usable process structure
90 int i;
91
92 for( i=0; i<NPROC; i++ )
93 if( procs[i].status == FREE ) break;
94
95 if( i>=NPROC ){
96 panic( "cannot find any free process structure.\n" );
97 return 0;
98 }
99
100 // init proc[i]'s vm space
101 procs[i].trapframe = (trapframe *)alloc_page(); //trapframe, used to save context
102 memset(procs[i].trapframe, 0, sizeof(trapframe));
103
104 // page directory
105 procs[i].pagetable = (pagetable_t)alloc_page();
106 memset((void *)procs[i].pagetable, 0, PGSIZE);
107
108 procs[i].kstack = (uint64)alloc_page() + PGSIZE; //user kernel stack top
109 uint64 user_stack = (uint64)alloc_page(); //phisical address of user stack bottom
110 procs[i].trapframe->regs.sp = USER_STACK_TOP; //virtual address of user stack top
111
112 // allocates a page to record memory regions (segments)
113 procs[i].mapped_info = (mapped_region*)alloc_page();
114 memset( procs[i].mapped_info, 0, PGSIZE );
115
116 // map user stack in userspace
117 user_vm_map((pagetable_t)procs[i].pagetable, USER_STACK_TOP - PGSIZE, PGSIZE,
118 user_stack, prot_to_type(PROT_WRITE | PROT_READ, 1));
119 procs[i].mapped_info[0].va = USER_STACK_TOP - PGSIZE;
120 procs[i].mapped_info[0].npages = 1;
121 procs[i].mapped_info[0].seg_type = STACK_SEGMENT;
122
123 // map trapframe in user space (direct mapping as in kernel space).
124 user_vm_map((pagetable_t)procs[i].pagetable, (uint64)procs[i].trapframe, PGSIZE,
125 (uint64)procs[i].trapframe, prot_to_type(PROT_WRITE | PROT_READ, 0));
126 procs[i].mapped_info[1].va = (uint64)procs[i].trapframe;
127 procs[i].mapped_info[1].npages = 1;
128 procs[i].mapped_info[1].seg_type = CONTEXT_SEGMENT;
129
130 // map S-mode trap vector section in user space (direct mapping as in kernel space)
131 // we assume that the size of usertrap.S is smaller than a page.
132 user_vm_map((pagetable_t)procs[i].pagetable, (uint64)trap_sec_start, PGSIZE,
133 (uint64)trap_sec_start, prot_to_type(PROT_READ | PROT_EXEC, 0));
134 procs[i].mapped_info[2].va = (uint64)trap_sec_start;
135 procs[i].mapped_info[2].npages = 1;
136 procs[i].mapped_info[2].seg_type = SYSTEM_SEGMENT;
137
138 sprint("in alloc_proc. user frame 0x%lx, user stack 0x%lx, user kstack 0x%lx \n",
139 procs[i].trapframe, procs[i].trapframe->regs.sp, procs[i].kstack);
140
141 procs[i].total_mapped_region = 3;
142 // return after initialization.
143 return &procs[i];
144 }
```
通过以上代码可以发现alloc_process()函数除了找到一个空的进程结构外还为新创建的进程建立了KERN_BASE以上逻辑地址的映射这段代码在实验3之前位于kernel/kernel.c文件的load_user_program()函数中),并将映射信息保存到了进程结构中。
对于给定应用PKE将通过调用load_bincode_from_host_elf()函数载入给定应用对应的ELF文件的各个段。之后被调用的elf_load()函数在载入段后,将对被载入的段进行判断,以记录它们的虚地址映射:
```
62 elf_status elf_load(elf_ctx *ctx) {
63 elf_prog_header ph_addr;
64 int i, off;
65 // traverse the elf program segment headers
66 for (i = 0, off = ctx->ehdr.phoff; i < ctx->ehdr.phnum; i++, off += sizeof(ph_addr)) {
67 // read segment headers
68 if (elf_fpread(ctx, (void *)&ph_addr, sizeof(ph_addr), off) != sizeof(ph_addr)) return EL_EIO;
69
70 if (ph_addr.type != ELF_PROG_LOAD) continue;
71 if (ph_addr.memsz < ph_addr.filesz) return EL_ERR;
72 if (ph_addr.vaddr + ph_addr.memsz < ph_addr.vaddr) return EL_ERR;
73
74 // allocate memory before loading
75 void *dest = elf_alloccb(ctx, ph_addr.vaddr, ph_addr.vaddr, ph_addr.memsz);
76
77 // actual loading
78 if (elf_fpread(ctx, dest, ph_addr.memsz, ph_addr.off) != ph_addr.memsz)
79 return EL_EIO;
80
81 // record the vm region in proc->mapped_info
82 int j;
83 for( j=0; j<PGSIZE/sizeof(mapped_region); j++ )
84 if( (process*)(((elf_info*)(ctx->info))->p)->mapped_info[j].va == 0x0 ) break;
85
86 ((process*)(((elf_info*)(ctx->info))->p))->mapped_info[j].va = ph_addr.vaddr;
87 ((process*)(((elf_info*)(ctx->info))->p))->mapped_info[j].npages = 1;
88 if( ph_addr.flags == (SEGMENT_READABLE|SEGMENT_EXECUTABLE) ){
89 ((process*)(((elf_info*)(ctx->info))->p))->mapped_info[j].seg_type = CODE_SEGMENT;
90 sprint( "CODE_SEGMENT added at mapped info offset:%d\n", j );
91 }else if ( ph_addr.flags == (SEGMENT_READABLE|SEGMENT_WRITABLE) ){
92 ((process*)(((elf_info*)(ctx->info))->p))->mapped_info[j].seg_type = DATA_SEGMENT;
93 sprint( "DATA_SEGMENT added at mapped info offset:%d\n", j );
94 }else
95 panic( "unknown program segment encountered, segment flag:%d.\n", ph_addr.flags );
96
97 ((process*)(((elf_info*)(ctx->info))->p))->total_mapped_region ++;
98 }
99
100 return EL_OK;
101 }
```
以上代码段中第86--97行将对被载入的段的类型ph_addr.flags进行判断以确定它是代码段还是数据段。完成以上的虚地址空间到物理地址空间的映射后将形成用户进程的虚地址空间结构如[图4.5](chapter4_memory.md#user_vm_space)所示)。
接下来将通过switch_to()函数将所构造的进程投入执行:
```
42 void switch_to(process *proc) {
43 assert(proc);
44 current = proc;
45
46 write_csr(stvec, (uint64)smode_trap_vector);
47 // set up trapframe values that smode_trap_vector will need when
48 // the process next re-enters the kernel.
49 proc->trapframe->kernel_sp = proc->kstack; // process's kernel stack
50 proc->trapframe->kernel_satp = read_csr(satp); // kernel page table
51 proc->trapframe->kernel_trap = (uint64)smode_trap_handler;
52
53 // set up the registers that strap_vector.S's sret will use
54 // to get to user space.
55
56 // set S Previous Privilege mode to User.
57 unsigned long x = read_csr(sstatus);
58 x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
59 x |= SSTATUS_SPIE; // enable interrupts in user mode
60
61 write_csr(sstatus, x);
62
63 // set S Exception Program Counter to the saved user pc.
64 write_csr(sepc, proc->trapframe->epc);
65
66 //make user page table
67 uint64 user_satp = MAKE_SATP(proc->pagetable);
68
69 // switch to user mode with sret.
70 return_to_user(proc->trapframe, user_satp);
71 }
```
实际上,以上函数在[实验1](chapter3_traps.md)就有所涉及它的作用是将进程结构中的trapframe作为进程上下文恢复到RISC-V机器的通用寄存器中并最后调用sret指令通过return_to_user()函数)将进程投入执行。
不同于实验1和实验2实验3的exit系统调用不能够直接将系统shutdown因为一个进程的结束并不一定意味着系统中所有进程的完成。以下是实验3中exit系统调用的实现
```
34 ssize_t sys_user_exit(uint64 code) {
35 sprint("User exit with code:%d.\n", code);
36 // in lab3 now, we should reclaim the current process, and reschedule.
37 free_process( current );
38 schedule();
39 return 0;
40 }
```
可以看到如果某进程调用了exit()系统调用操作系统的处理方法是调用free_process()函数将当前进程也就是调用者进行“释放”然后转进程调度。其中free_process()函数的实现非常简单:
```
149 int free_process( process* proc ) {
150 // we set the status to ZOMBIE, but cannot destruct its vm space immediately.
151 // since proc can be current process, and its user kernel stack is currently in use!
152 // but for proxy kernel, it (memory leaking) may NOT be a really serious issue,
153 // as it is different from regular OS, which needs to run 7x24.
154 proc->status = ZOMBIE;
155
156 return 0;
157 }
```
可以看到,**free_process()函数仅是将进程设为ZOMBIE状态而不会将进程所占用的资源全部释放**这是因为free_process()函数的调用说明操作系统当前是在S模式下运行而按照PKE的设计思想S态的运行将使用当前进程的用户系统栈user kernel stack。此时如果将当前进程的内存空间进行释放将导致操作系统本身的崩溃。所以释放进程时PKE采用的是折衷的办法即只将其设置为僵尸ZOMBIE状态而不是立即将它所占用的资源进行释放。最后schedule()函数的调用,将选择系统中可能存在的其他处于就绪状态的进程投入运行,它的处理逻辑我们将在下一节讨论。
<a name="subsec_management"></a>
### 5.1.3 就绪进程的管理与调度
PKE的操作系统设计了一个非常简单的就绪队列管理因为实验3的基础实验并未涉及进程的阻塞所以未设计阻塞队列队列头在kernel/sched.c文件中定义
```
8 process* ready_queue_head = NULL;
```
将一个进程加入就绪队列可以调用insert_to_ready_queue()函数:
```
13 void insert_to_ready_queue( process* proc ) {
14 sprint( "going to insert process %d to ready queue.\n", proc->pid );
15 // if the queue is empty in the beginning
16 if( ready_queue_head == NULL ){
17 proc->status = READY;
18 proc->queue_next = NULL;
19 ready_queue_head = proc;
20 return;
21 }
22
23 // ready queue is not empty
24 process *p;
25 // browse the ready queue to see if proc is already in-queue
26 for( p=ready_queue_head; p->queue_next!=NULL; p=p->queue_next )
27 if( p == proc ) return; //already in queue
28
29 // p points to the last element of the ready queue
30 if( p==proc ) return;
31 p->queue_next = proc;
32 proc->status = READY;
33 proc->queue_next = NULL;
34
35 return;
36 }
```
该函数首先第16--21行处理ready_queue_head为空初始状态的情况如果就绪队列不为空则将进程加入到队尾第26--33行
PKE操作系统内核通过调用schedule()函数来完成进程的选择和换入:
```
45 void schedule() {
46 if ( !ready_queue_head ){
47 // by default, if there are no ready process, and all processes are in the status of
48 // FREE and ZOMBIE, we should shutdown the emulated RISC-V machine.
49 int should_shutdown = 1;
50
51 for( int i=0; i<NPROC; i++ )
52 if( (procs[i].status != FREE) && (procs[i].status != ZOMBIE) ){
53 should_shutdown = 0;
54 sprint( "ready queue empty, but process %d is not in free/zombie state:%d\n",
55 i, procs[i].status );
56 }
57
58 if( should_shutdown ){
59 sprint( "no more ready processes, system shutdown now.\n" );
60 shutdown( 0 );
61 }else{
62 panic( "Not handled: we should let system wait for unfinished processes.\n" );
63 }
64 }
65
66 current = ready_queue_head;
67 assert( current->status == READY );
68 ready_queue_head = ready_queue_head->queue_next;
69
70 current->status == RUNNING;
71 sprint( "going to schedule process %d to run.\n", current->pid );
72 switch_to( current );
73 }
```
可以看到schedule()函数首先判断就绪队列ready_queue_head是否为空对于为空的情况第46--64行schedule()函数将判断系统中所有的进程是否全部都处于被释放FREE状态或者僵尸ZOMBIE状态。如果是则启动关模拟RISC-V机程序否则应进入等待系统中进程结束的状态。但是由于实验3的基础实验并无可能进入这样的状态所以我们在这里调用了panic等后续实验有可能进入这种状态后再进一步处理。
对于就绪队列非空的情况第66--72行处理就简单得多只需要将就绪队列队首的进程换入执行即可。对于换入的过程需要注意的是要将被选中的进程从就绪队列中摘掉。
<a name="lab3_1_naive_fork"></a>
## 5.2 lab3_1 进程创建fork
<a name="lab3_1_app"></a>
#### **给定应用**
- user/app_naive_fork.c
```
1 /*
2 * Below is the given application for lab3_1.
3 * It forks a child process to run .
4 * Parent process will continue to run after child exits
5 * So it is a naive "fork". we will implement a better one in later lab.
6 *
7 */
8
9 #include "user/user_lib.h"
10 #include "util/types.h"
11
12 int main(void) {
13 uint64 pid = fork();
14 if (pid == 0) {
15 printu("Child: Hello world!\n");
16 } else {
17 printu("Parent: Hello world! child id %ld\n", pid);
18 }
19
20 exit(0);
21 }
```
以上程序
<a name="lab3_1_content"></a>
#### **实验内容**
<a name="lab3_1_guide"></a>
#### **实验指导**
<a name="lab3_2_yield"></a>
## 5.3 lab3_2 进程yield
<a name="lab3_2_app"></a>
#### **给定应用**
<a name="lab3_2_content"></a>
#### **实验内容**
<a name="lab3_2_guide"></a>
#### **实验指导**
<a name="lab3_3_rrsched"></a>
## 5.4 lab3_3 循环轮转调度
<a name="lab3_3_app"></a>
#### **给定应用**
<a name="lab3_3_content"></a>
#### **实验内容**
<a name="lab3_3_guide"></a>
#### **实验指导**

@ -1,488 +0,0 @@
# 第六章实验5进程的封装
## 6.1 实验内容
#### 应用: ####
app5.c源文件如下
int main(){
if(fork() == 0) {
printf("this is child process;my pid = %d\n",getpid());
}else {
printf("this is farther process;my pid = %d\n",getpid());
}
return 0;
}
以上代码中进行了fork调用其执行过程将fork出一个子进程。
#### 任务一 : alloc_proc编程 ####
任务描述:
完善"pk/proc.c"中的alloc_proc(),你需要对以下属性进行初始化:
l enum proc_state state;
l int pid;
l int runs;
l uintptr_t kstack;
l volatile bool need_resched;
l struct proc_struct *parent;
l struct mm_struct *mm;
l struct context context;
l struct trapframe *tf;
l uintptr_t pagetable;
l uint32_t flags;
l char name[PROC_NAME_LEN + 1];
#### 任务二 : do_fork编程 ####
任务描述:
l 调用alloc_proc()来为子进程创建进程控制块
l 调用setup_kstack来设置栈空间
l 用copy_mm来拷贝页表
l 调用copy_thread来拷贝进程
l 为子进程设置pid
l 设置子进程状态为就绪
l 将子进程加入到链表中
预期输出:
完成以上代码后,你可以进行如下测试,然后输入如下命令:
`$ riscv64-unknown-elf-gcc ../app/app5.c -o ../app/elf/app5`
`$ spike ./obj/pke app/elf/app5`
预期的输出如下:
```
PKE IS RUNNING
page fault vaddr:0x00000000000100c2
page fault vaddr:0x000000000001e17f
page fault vaddr:0x0000000000018d5a
page fault vaddr:0x000000000001a8ba
page fault vaddr:0x000000000001d218
page fault vaddr:0x000000007f7e8bf0
page fault vaddr:0x0000000000014a68
page fault vaddr:0x00000000000162ce
page fault vaddr:0x000000000001c6e0
page fault vaddr:0x0000000000012572
page fault vaddr:0x0000000000011fa6
page fault vaddr:0x0000000000019064
page fault vaddr:0x0000000000015304
page fault vaddr:0x0000000000017fd4
this is farther process;my pid = 1
sys_exit pid=1
page fault vaddr:0x0000000000010166
page fault vaddr:0x000000000001e160
page fault vaddr:0x000000000001d030
page fault vaddr:0x0000000000014a68
page fault vaddr:0x00000000000162ce
page fault vaddr:0x000000000001c6e0
page fault vaddr:0x0000000000012572
page fault vaddr:0x0000000000011fa6
page fault vaddr:0x0000000000019064
page fault vaddr:0x000000000001abb6
page fault vaddr:0x0000000000015304
page fault vaddr:0x0000000000017fd4
page fault vaddr:0x0000000000018cd4
this is child process;my pid = 2
sys_exit pid=2
```
如果你的app可以正确输出的话那么运行检查的python脚本
`./pke-lab5`
若得到如下输出,那么恭喜你,你已经成功完成了实验六!!!
```
build pk : OK
running app5 : OK
test fork : OK
Score: 20/20
```
## 6.2 实验指导
**6.2.1 进程结构**
在pk/proc.h中我们定义进程的结构如下
```
42 struct proc_struct {
43 enum proc_state state;
44 int pid;
45 int runs;
46 uintptr_t kstack;
47 volatile bool need_resched;
48 struct proc_struct *parent;
50 struct context context;
51 trapframe_t *tf;
52 uintptr_t pagetable;
53 uint32_t flags;
54 char name[PROC_NAME_LEN + 1];
55 list_entry_t list_link;
56 list_entry_t hash_link;
57 };
```
可以看到在41行的枚举中我们定义了进程的四种状态其定义如下
```
11 enum proc_state {
12 PROC_UNINIT = 0,
13 PROC_SLEEPING,
14 PROC_RUNNABLE,
15 PROC_ZOMBIE,
16 };
```
四种状态分别为未初始化(PROC_UNINIT)、睡眠PROC_SLEEPING、可运行PROC_RUNNABLE以及僵死PROC_ZOMBIE状态。
除却状态,进程还有以下重要属性:
l pid进程id是进程的标识符
l runs进程已经运行的时间
l kstack进程的内核栈空间
l need_resched是否需要释放CPU
l parent进程的父进程
l context进程的上下文
l tf当前中断的栈帧
l pagetable进程的页表地址
l name进程名
除了上述属性可以看到在55、56行还维护了两个进程的链表这是操作系统内进程的组织方式系统维护一个进程链表以组织要管理的进程。
**6.2.2 设置第一个内核进程idleproc**
在"pk/pk.c"的rest_of_boot_loader函数中调用了proc_init来设置第一个内核进程
```
317 void
318 proc_init() {
319 int i;
320 extern uintptr_t kernel_stack_top;
321
322 list_init(&proc_list);
323 for (i = 0; i < HASH_LIST_SIZE; i ++) {
324 list_init(hash_list + i);
325 }
326
327 if ((idleproc = alloc_proc()) == NULL) {
328 panic("cannot alloc idleproc.\n");
329 }
330
331 idleproc->pid = 0;
332 idleproc->state = PROC_RUNNABLE;
333 idleproc->kstack = kernel_stack_top;
334 idleproc->need_resched = 1;
335 set_proc_name(idleproc, "idle");
336 nr_process ++;
337
338 currentproc = idleproc;
339
340 }
```
322行的proc_list是系统所维护的进程链表324行的hash_list是一个大小为1024的list_entry_t的hash数组。在对系统所维护的两个list都初始化完成后系统为idleproc分配进程结构体。然后对idleproc的各个属性进行设置最终将currentproc改为idleproc。
在上述代码中我们只是为idleproc分配了进程控制块但并没有切换到idleproc真正的切换代码在proc_init函数后面的run_loaded_program以及cpu_idle函数中进行。
**6.2.3 do_fork**
在run_loaded_program中有如下代码
```
140 trapframe_t tf;
141 init_tf(&tf, current.entry, stack_top);
142 __clear_cache(0, 0);
143 do_fork(0,stack_top,&tf);
144 write_csr(sscratch, kstack_top);
```
在这里声明了一个trapframe并且将它的gpr[2]sp设置为内核栈指针将它的epc设置为current.entry其中current.entry是elf文件的入口地址也就是app的起始执行位置随即我们调用了do_frok函数其中传入参数stack为0表示我们正在fork一个内核进程。
在do_frok函数中你会调用alloc_proc()来为子进程创建进程控制块、调用setup_kstack来设置栈空间调用copy_mm来拷贝页表调用copy_thread来拷贝进程。现在我们来对以上函数进行分析。
setup_kstack函数代码如下在函数中我们为进程分配栈空间然后返回
```
210 static int
211 setup_kstack(struct proc_struct *proc) {
212 proc->kstack = (uintptr_t)__page_alloc();
213 return 0;
214 }
```
copy_mm k函数代码如下在函数中我们对页表进行拷贝。
```
228 static int
229 copy_mm(uint32_t clone_flags, struct proc_struct *proc) {
230 //assert(currentproc->mm == NULL);
231 /* do nothing in this project */
232 uintptr_t pagetable=(uintptr_t)__page_alloc();
233 memcpy((void *)pagetable,(void *)proc->pagetable,RISCV_PGSIZE);
234 proc->pagetable=pagetable;
235 return 0;
236 }
```
最后是copy_thread函数
```
240 static void
241 copy_thread(struct proc_struct *proc, uintptr_t esp, trapframe_t *tf) {
242 proc->tf = (trapframe_t *)(proc->kstack + KSTACKSIZE - sizeof(trapframe_t));
243 *(proc->tf) = *tf;
244
245 proc->tf->gpr[10] = 0;
246 proc->tf->gpr[2] = (esp == 0) ? (uintptr_t)proc->tf -4 : esp;
247
248 proc->context.ra = (uintptr_t)forkret;
249 proc->context.sp = (uintptr_t)(proc->tf);
250 }
```
在函数中首先对传入的栈帧进行拷贝并且将上下文中的ra设置为地址forkret将sp设置为该栈帧。
完成以上几步后我们为子进程设置pid将其加入到进程链表当中并且设置其状态为就绪。
**6.2.3 上下文切换**
每个进程都有着自己的上下文,在进程间切换时,需要对上下文一并切换。
在pk/proc.c的cpu_idle中有以下代码
```
374 void
375 cpu_idle(void) {
376 while (1) {
377 if (currentproc->need_resched) {
378 schedule();
379 }
380 }
381 }
```
在当前进程处于need_resched状态时会执行调度算法schedule其代码如下
```
16 void
17 schedule(void) {
18 list_entry_t *le, *last;
19 struct proc_struct *next = NULL;
20 {
21 currentproc->need_resched = 0;
22 last = (currentproc == idleproc) ? &proc_list : &(currentproc->list_link);
23 le = last;
24 do {
25 if ((le = list_next(le)) != &proc_list) {
26 next = le2proc(le, list_link);
27 if (next->state == PROC_RUNNABLE) {
28 break;
29 }
30 }
31 } while (le != last);
32 if (next == NULL || next->state != PROC_RUNNABLE) {
33 next = idleproc;
34 }
35 next->runs ++;
36 if (next != currentproc) {
37 proc_run(next);
38 }
39 }
40 }
```
在schedule函数中找到下一个需要执行的进程并执行执行代码proc_run如下
```
145 void
146 proc_run(struct proc_struct *proc) {
147 if (proc != currentproc) {
148 bool intr_flag;
149 struct proc_struct *prev = currentproc, *next = proc;
150 currentproc = proc;
151 write_csr(sptbr, ((uintptr_t)next->pagetable >> RISCV_PGSHIFT) | SATP_MODE_CHOICE);
152 switch_to(&(prev->context), &(next->context));
153
154 }
155 }
```
当传入的proc不为当前进程时执行切换操作
```
7 switch_to:
8 # save from's registers
9 STORE ra, 0*REGBYTES(a0)
10 STORE sp, 1*REGBYTES(a0)
11 STORE s0, 2*REGBYTES(a0)
12 STORE s1, 3*REGBYTES(a0)
13 STORE s2, 4*REGBYTES(a0)
14 STORE s3, 5*REGBYTES(a0)
15 STORE s4, 6*REGBYTES(a0)
16 STORE s5, 7*REGBYTES(a0)
17 STORE s6, 8*REGBYTES(a0)
18 STORE s7, 9*REGBYTES(a0)
19 STORE s8, 10*REGBYTES(a0)
20 STORE s9, 11*REGBYTES(a0)
21 STORE s10, 12*REGBYTES(a0)
22 STORE s11, 13*REGBYTES(a0)
23
24 # restore to's registers
25 LOAD ra, 0*REGBYTES(a1)
26 LOAD sp, 1*REGBYTES(a1)
27 LOAD s0, 2*REGBYTES(a1)
28 LOAD s1, 3*REGBYTES(a1)
29 LOAD s2, 4*REGBYTES(a1)
30 LOAD s3, 5*REGBYTES(a1)
31 LOAD s4, 6*REGBYTES(a1)
32 LOAD s5, 7*REGBYTES(a1)
33 LOAD s6, 8*REGBYTES(a1)
34 LOAD s7, 9*REGBYTES(a1)
35 LOAD s8, 10*REGBYTES(a1)
36 LOAD s9, 11*REGBYTES(a1)
37 LOAD s10, 12*REGBYTES(a1)
38 LOAD s11, 13*REGBYTES(a1)
39
40 ret
```
可以看到在switch_to中我们正真执行了上一个进程的上下文保存以及下一个进程的上下文加载。在switch_to的最后一行我们执行ret指令该指令是一条从子过程返回的伪指令会将pc设置为x1ra寄存器的值还记得我们在copy_thread中层将ra设置为forkret嘛现在程序将从forkret继续执行
```
160 static void
161 forkret(void) {
162 extern elf_info current;
163 load_elf(current.file_name,&current);
164
165 int pid=currentproc->pid;
166 struct proc_struct * proc=find_proc(pid);
167 write_csr(sscratch, proc->tf);
168 set_csr(sstatus, SSTATUS_SUM | SSTATUS_FS);
169 currentproc->tf->status = (read_csr(sstatus) &~ SSTATUS_SPP &~ SSTATUS_SIE) | SSTATUS_SPIE;
170 forkrets(currentproc->tf);
171 }
```
我们进入forkrets
```
121 forkrets:
122 # set stack to this new process's trapframe
123 move sp, a0
124 addi sp,sp,320
125 csrw sscratch,sp
126 j start_user
```
```
76 .globl start_user
77 start_user:
78 LOAD t0, 32*REGBYTES(a0)
79 LOAD t1, 33*REGBYTES(a0)
80 csrw sstatus, t0
81 csrw sepc, t1
82
83 # restore x registers
84 LOAD x1,1*REGBYTES(a0)
85 LOAD x2,2*REGBYTES(a0)
86 LOAD x3,3*REGBYTES(a0)
87 LOAD x4,4*REGBYTES(a0)
88 LOAD x5,5*REGBYTES(a0)
89 LOAD x6,6*REGBYTES(a0)
90 LOAD x7,7*REGBYTES(a0)
91 LOAD x8,8*REGBYTES(a0)
92 LOAD x9,9*REGBYTES(a0)
93 LOAD x11,11*REGBYTES(a0)
94 LOAD x12,12*REGBYTES(a0)
95 LOAD x13,13*REGBYTES(a0)
96 LOAD x14,14*REGBYTES(a0)
97 LOAD x15,15*REGBYTES(a0)
98 LOAD x16,16*REGBYTES(a0)
99 LOAD x17,17*REGBYTES(a0)
100 LOAD x18,18*REGBYTES(a0)
101 LOAD x19,19*REGBYTES(a0)
102 LOAD x20,20*REGBYTES(a0)
103 LOAD x21,21*REGBYTES(a0)
104 LOAD x22,22*REGBYTES(a0)
105 LOAD x23,23*REGBYTES(a0)
106 LOAD x24,24*REGBYTES(a0)
107 LOAD x25,25*REGBYTES(a0)
108 LOAD x26,26*REGBYTES(a0)
109 LOAD x27,27*REGBYTES(a0)
110 LOAD x28,28*REGBYTES(a0)
111 LOAD x29,29*REGBYTES(a0)
112 LOAD x30,30*REGBYTES(a0)
113 LOAD x31,31*REGBYTES(a0)
114 # restore a0 last
115 LOAD x10,10*REGBYTES(a0)
116
117 # gtfo
118 sret
```
可以看到在forkrets最后执行了sret程序就此由内核切换至用户程序执行

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

@ -1,33 +0,0 @@
==========================================================================
Copyright License
==========================================================================
Copyright (c) 2008, Zhiyuan Shao邵志远&Yan Jiao焦妍
Huazhong University of Science and Technology华中科技大学
Contact to: zyshao AT hust DOT edu DOT cn
All rights reserved.
Redistribution and use in source and documentation forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of documentation must retain the above copyright
notice, this list of conditions, and the following disclaimer.
* Redistributions must reproduce the above copyright
notice, this list of conditions, and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS DOCUMENTATION IS PROVIDED BY Zhiyuan Shao and Yan Jiao ''AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Zhiyuan Shao or Yan Jiao BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@ -1,674 +0,0 @@
## 操作系统原理课程设计
> 扩充proxy kernel的代码使你的内核可以支持以下应用程序
## 实验一 多进程支持 ##
### 应用: ###
App5_1的代码如下
int main(){
int a=10;
int ret=-1;
printf("print proc app5_1\n");
if((ret=fork()) == 0) {
printf("print proc this is child process;my pid = %d\n",getpid());
a=a-1;
printf("print proc a=%d\n",a);
}else {
int wait_ret = -1;
wait_ret=wait(ret);
a=a-2;
printf("print proc this is farther process;my pid = %d\n",getpid());
printf("print proc a=%d\n",a);
}
if((ret=fork()) == 0) {
a=a-3;
printf("print proc this is child process;my pid = %d\n",getpid());
printf("print proc a=%d\n",a);
}else {
a=a-4;
int wait_ret = -1;
wait_ret=wait(ret);
printf("print proc this is farther process;my pid = %d\n",getpid());
printf("print proc a=%d\n",a);
}
return 0;
}
### 实验一任务: ###
#### 任务一 : proc_pagetable/ vmcopy ####
实验任务描述:
实现pk/proc.c中的proc_pagetable与vmcopy函数为进程创建用户页表并映射用户内存确保你的代码仍可以保证app5的正确运行。
实验预期输出:
$ spike obj/pke app/elf/app5
得到输出:
PKE IS RUNNING
to host 10
from host 0
elf name app/elf/app5
sched class: RR_scheduler
++ setup timer interrupts
log: proc init
father process
this is father process;my pid = 1
this is child process;my pid = 2
#### 任务二do_wait ####
实验任务描述:
实现`do_wait`函数支持app5_1.c的运行
$ spike obj/pke app/elf/app5_1
预期得到输出:
PKE IS RUNNING
to host 10
from host 0
elf name app/elf/app5_1
sched class: RR_scheduler
++ setup timer interrupts
log: proc init
print proc app5_1
print proc this is child process;my pid = 2
print proc a=9
print proc this is child process;my pid = 3
print proc a=6
print proc this is farther process;my pid = 2
print proc a=5
print proc this is farther process;my pid = 1
print proc a=8
print proc this is child process;my pid = 4
print proc a=5
print proc this is farther process;my pid = 1
此时运行测试脚本:
$ python3 ./pke-final-1
预期得到输出:
build pk : OK
running app5_1 : OK
test fork : OK
Score: 20/20
### 实验一提示: ###
app5_1.c是一段多进程的代码我们可以结合下图进行分析</br>
<div style="text-align: center; width: 1000px; ">
<img src="lab_figures/final/app5_1.png" alt="app5_1" style="margin: 0 auto;" />
</div>
首先对app的内容进行分析程序首先在父进程father1中调用了fork()产生子进程child1父进程father1进入等待状态。而子进程child1此时打印出a=9随机再次调用fork(),产生子进程child4child4打印输出6后退出此时child1结束等待输出5随即father1被唤醒输出8。Father1进行第二次fork产生child3。Child3输出5后返回父进程father1再次被唤醒最后输出4。故而正确的打印顺序为9、6、5、8、5、4。</br>
现在我们要支持上述app就需要完善pke的进程支持。</br>
在实验五的代码中,函数`do_fork()`中我们需要实现函数`copy_mm()`,即复制虚拟内存。该函数的本质实际上是对页表进程操作。由于实验五中只需要实现父子进程的切换,所以我们可以直接复制内核页表作为进程页表,并且再复制后的内核页表上为每个进程映射其用户地址空间。</br>
<div style="text-align: center; width: 1000px; ">
<img src="lab_figures/final/mem_map.png" alt="app5_1" style="margin: 0 auto;" />
</div>
上图为pke的虚实映射关系。其中内核空间采用对等映射即令物理地址等于虚拟地址这样做使我们在内核下操作虚拟地址时无需再进行地址转换例如我们使用`__page_alloc`分配了一段物理内存并且要使用memset函数设置这段内存的值时我们可以直接将分配得到的物理地址传入memset函数注意此时memset中这个地址会被当作虚拟地址。结合实验三的知识我们知道这里必然会经由页表进行地址转化但由于内核页表中物理地址与虚拟地址采用了对等的映射我们实际上得到了和这个虚拟地址相同的物理地址从而完成了写的操作。<br/>
虚拟地址空间中用户地址空间从零开始到USER STACK TOP结束RISCV中物理地址从0x80000000开始这一部分用户地址自然不存在对等映射的物理地址空间。如图所示它映射至first_free_paddr到Top Memory之间的内存。<br/>
我们知道每个用户进程都有它独立的用户代码,而所有用户都共享内核代码。所以,一种更为清晰的设计如下,每个用户进程维护一张属于该进程的用户页表,所有进程共享内核页表。我们为进程结构体`proc_struct`添加upagetable属性用以维护用户页表其结构如下
struct proc_struct {
list_entry_t run_link;
struct run_queue *rq;
int time_slice;
enum proc_state state; // Process state
int pid; // Process ID
int runs; // the running times of Proces
int exit_code;
uintptr_t kstack; // Process kernel stack
volatile bool need_resched; // bool value: need to be rescheduled to release CPU?
struct proc_struct *parent; // the parent process
struct context context; // Switch here to run process
trapframe_t *tf; // Trap frame for current interrupt
++ uintptr_t upagetable; // the base addr of Page Directroy Table
uint32_t flags; // Process flag
char name[PROC_NAME_LEN + 1]; // Process name
list_entry_t list_link; // Process link list
uint32_t wait_state; // waiting state
struct proc_struct *cptr, *yptr, *optr; // relations between processes
list_entry_t hash_link; // Process hash list
};
这样我们拥有了一张属于每个进程的用户页表现在我们来看如何对其进行分配与维护。首先是进程间共性的部分我们知道在用户态发生异常时代码会跳转到stvec控制状态寄存器所指向的位置继续进行所以所有用户页表中都需要对这部分代码进行映射。
pke中stvec指向`trap_entry`,我们可以对其内容进行简要的分析:<br/>
.global trap_entry
trap_entry:
#将sp与sscratch中的值互换
csrrw sp, sscratch, sp
bnez sp, write_stap
csrr sp, sscratch
addi sp,sp,-320
save_tf
jal 1f
write_stap:
addi sp,sp,-320
save_tf
进入`trap_entry`后首先交换sp和sscratch寄存器此时分为两个情况第一是由内核空间跳转至`trap_entry`,第二种则是由用户空间跳转至`trap_entry`。</br>
在pk.c中的`boot_loader`函数中sscratch于内核态被设置为0。而在proc.c的forkret函数中forkret函数模拟上次调用是由用户态进入内核态的假象将ssctatch写为内核栈的栈帧。综上可以得出结论从用户态进入`trap_entry`后sscratch值为该进程内核栈顶trapframe的指针而从内核态进入trapframe后sscratch的值为0。<br/>
所以在交换sscratch与sp后代码对sp即原sscratch的值的值进行判断如果sp中的值为0表示其从内核态进入trap代码顺序向下执行后跳转1f。如果不是0则表示其从用户态进入trap代码跳转write_satp。<br/>
我们先看sp为0即内核态进入的情况首先sp作为栈指针寄存器自然是不能为0的我们需要将sscratch的值即原sp的值再次写回sp寄存器。此时sp指向内核栈接着调用宏save_tf将当前trap的trapframe保存在该内核栈中。 <br/>
接着是sp为1即用户态进入的情况。它直接使用当前sp即原sscratch中的值作为内核栈地址注意由于forkret在进入用户态时向sscratch中写入了即将进入用户态运行的进程的内核栈此时我们从用户态进入内核态从sscratch中得到内核栈的自然也同运行的进程所一致。<br/>
接着,无论是哪种进入内核的状态都为欲存储的栈帧分配了栈空间,且都调用了宏`save_tf`</br>
.macro save_tf
# save gprs
STORE x1,1*REGBYTES(x2)
STORE x3,3*REGBYTES(x2)
STORE x4,4*REGBYTES(x2)
STORE x5,5*REGBYTES(x2)
STORE x6,6*REGBYTES(x2)
STORE x7,7*REGBYTES(x2)
STORE x8,8*REGBYTES(x2)
STORE x9,9*REGBYTES(x2)
STORE x10,10*REGBYTES(x2)
STORE x11,11*REGBYTES(x2)
STORE x12,12*REGBYTES(x2)
STORE x13,13*REGBYTES(x2)
STORE x14,14*REGBYTES(x2)
STORE x15,15*REGBYTES(x2)
STORE x16,16*REGBYTES(x2)
STORE x17,17*REGBYTES(x2)
STORE x18,18*REGBYTES(x2)
STORE x19,19*REGBYTES(x2)
STORE x20,20*REGBYTES(x2)
STORE x21,21*REGBYTES(x2)
STORE x22,22*REGBYTES(x2)
STORE x23,23*REGBYTES(x2)
STORE x24,24*REGBYTES(x2)
STORE x25,25*REGBYTES(x2)
STORE x26,26*REGBYTES(x2)
STORE x27,27*REGBYTES(x2)
STORE x28,28*REGBYTES(x2)
STORE x29,29*REGBYTES(x2)
STORE x30,30*REGBYTES(x2)
STORE x31,31*REGBYTES(x2)
csrrw t0,sscratch,x0
csrr s0,sstatus
csrr t1,sepc
csrr t2,sbadaddr
csrr t3,scause
STORE t0,2*REGBYTES(x2)
STORE s0,32*REGBYTES(x2)
STORE t1,33*REGBYTES(x2)
STORE t2,34*REGBYTES(x2)
STORE t3,35*REGBYTES(x2)
# get faulting insn, if it wasn't a fetch-related trap
li x5,-1
STORE x5,36*REGBYTES(x2)
简单来说它用来保存栈帧。X2即sp寄存器此时存储的是进程内核栈的指针。故而save_tf在预留出的栈帧中首先存储了除sp以外的31个通用寄存器接着将sscratch中的值即用户栈指针存入t0、sstatus存入s0、sepc存入t1、sbadaddr存入t2、scause存入t3并保存。注意由于csrrw 将x0写入sscratchsscratch置位为0。
接下来由用户态进入的trap需要将在用户态时使用的用户页表更换为内核态的内核页表
write_stap:
addi sp,sp,-320
save_tf
move a0,sp
ld t1, 37*REGBYTES(a0)
csrw satp, t1
sfence.vma zero, zero
这里内核页表被加载入t1中然后将t1写入satp寄存器并刷新当前CPU的TLB。在RISCV中架构下每个CPU都会将页表条目缓冲在转译后备缓冲区Translation Lookaside Buffer当页表更改时必须告诉CPU使缓存的TLB条目无效。如果没有这样做那么在以后的某个时间TLB可能依旧会使用旧的缓存映射这将可能导致某个进程在某些页面上乱写其他进程的内存。RISC-V提供指令sfence.vma刷新当前CPU的TLB。故而每次更换页表时调用sfence.vma指令是必要的。
最后将sp作为参数传递给`handle_trap`,进入中断处理:</br>
move a0,sp
j al handle_trap
同样当代码从forkrets返回时则执行了相反的逻辑
forkrets:
andi s0,s0,SSTATUS_SPP
bnez s0,start_user
move sp, a0
csrw sptbr, a1
sfence.vma zero, zero
addi sp,sp,320
csrw sscratch,sp
j start_user
这里我们首先对sstatus寄存器进行介绍sstatus作为状态控制寄存器其中的SIE位控制是否允许设备中断。如果内核清除了SIE则RISC-V将推迟设备中断直到内核设置SIE。SPP位指示tarp是来自用户模式还是主管模式并且sret将返回该种模式。当异常发生时硬件将自动将sstatus的SIE位置零以禁用中断并且将发生异常之前的特权模式保存在SPP中。而当调用sret时机器会将SPIE的值写入SIE来恢复异常发生之前的中断使能情况并且按照SPP中的值恢复特权模式。</br>
故而在forkrets中代码先判断了`SSTATUS_SPP`的值是否为零若其为零则表示异常发生之前是处于用户态若不为零则表示异常发生之前是处于内核态。对于用户态我们重新将用户页表写入satp寄存器并且将内核栈地址写入sscratch以备下一次异常发生时使用。对于内核态则直接进入start_user</br>
.globl start_user
start_user:
LOAD t0, 32*REGBYTES(a0)
LOAD t1, 33*REGBYTES(a0)
csrw sstatus, t0
csrw sepc, t1
# restore x registers
LOAD x1,1*REGBYTES(a0)
LOAD x2,2*REGBYTES(a0)
LOAD x3,3*REGBYTES(a0)
LOAD x4,4*REGBYTES(a0)
LOAD x5,5*REGBYTES(a0)
LOAD x6,6*REGBYTES(a0)
LOAD x7,7*REGBYTES(a0)
LOAD x8,8*REGBYTES(a0)
LOAD x9,9*REGBYTES(a0)
LOAD x11,11*REGBYTES(a0)
LOAD x12,12*REGBYTES(a0)
LOAD x13,13*REGBYTES(a0)
LOAD x14,14*REGBYTES(a0)
LOAD x15,15*REGBYTES(a0)
LOAD x16,16*REGBYTES(a0)
LOAD x17,17*REGBYTES(a0)
LOAD x18,18*REGBYTES(a0)
LOAD x19,19*REGBYTES(a0)
LOAD x20,20*REGBYTES(a0)
LOAD x21,21*REGBYTES(a0)
LOAD x22,22*REGBYTES(a0)
LOAD x23,23*REGBYTES(a0)
LOAD x24,24*REGBYTES(a0)
LOAD x25,25*REGBYTES(a0)
LOAD x26,26*REGBYTES(a0)
LOAD x27,27*REGBYTES(a0)
LOAD x28,28*REGBYTES(a0)
LOAD x29,29*REGBYTES(a0)
LOAD x30,30*REGBYTES(a0)
LOAD x31,31*REGBYTES(a0)
# restore a0 last
LOAD x10,10*REGBYTES(a0)
# gtfo
sret
start_user中首先恢复sstatus以及sepc然后加载32个通用寄存器并调用sret返回。
综上,为了用户态的代码能够跳转进入中断处理程序,上述由用户态进入内核态的代码需要在所有进程的用户页表中进行映射。</br>
同样每个用户页表中都应该维护自己的用户堆栈需要对虚拟地址current.stack_top-RISCV_PGSIZE进行映射。</br>
以上功能均由`proc_pagetable`函数实现。</br>
现在,我们已经完成了用户页表的创建,不过目前页表中除了我们映射的异常入口与用户栈还是一片空白,接下来我们需要对用户内存进程映射。</br>
还记得fork函数中的`copy_mm`吗?我们需要在该函数中将父进程的用户内存拷贝给子进程,或者说复制父进程的用户页表。对于用户进程空间中的虚拟地址,这里有几种情况需要考虑:
- 其一、该虚拟地址对应的页表项不存在,则无需在子进程中进程映射
- 其二、该虚拟地址对应的页表项存在且`PTE_V`位有效,则需要为子进程分配内存,并复制父进程对应的物理页,最后将该虚拟地址与新分配的物理内存映射进子进程的用户页表
- 其三、该虚拟地址对应的页表项存在且但`PTE_V`位无效这是因为pke中采用了预映射的机制此时父进程的页表项中所描述的地址不是真实的物理地址而是一个`vmr_t`结构体,该结构体描述着该段物理内存在对应文件中的位置,会在`page_fault`中被使用。在此只需要将父进程页表项所描述的vmr_t结构体地址赋值给子页表项。
- 其四、该虚拟地址对应着用户栈由于我们在proc_pagetable函数中已近为其分配了内存此时只需要复制父进程的用户栈。
至此我们进一步完善了对fork的支持。接下来我们来看进程间的同步。</br>
wait是最为基础的同步操作。wait函数在进程列表中进行遍历寻找该进程为尚未完成的任意子进程/或pid指定的子进程若存在且子进程处于`PROC_ZOMBIE`状态,则释放该子进程资源并返回。若子进程不处于`PROC_ZOMBIE`状态,则将父进程的状态设为`PROC_SLEEPING`,将父进程的等待状态设为`WT_CHILD`继而调用schedule。
从父进程调用wait开始进程是如何调度与切换的呢下面我们一起一探究竟。如下是一张wait调用的流程图</br></br>
<div style="text-align: center; width: 1000px; ">
<img src="lab_figures/final/wait_flow.png" alt="app5_1" style="margin: 0 auto;" />
</div>
首先wait的本质仍是系统调用父进程会进入trap_entry并在属于他的内核栈中保存trapframe在上文中我们对该段代码进行过讨论这里需要记住的是该trapframe中保存了父进程的sepc而当使用sret从管理员模式下返回时pc会被设置为sepc的值。
接着进入`do_wait`的代码这里不妨假其子进程状态不处于PROC_ZOMBIE故而在将父进程状态设置完成后进入schedule代码
void
schedule(void) {
bool intr_flag;
struct proc_struct *next;
local_intr_save(intr_flag);
{
currentproc->need_resched = 0;
if (currentproc->state == PROC_RUNNABLE) {
sched_class_enqueue(currentproc);
}
if ((next = sched_class_pick_next()) != NULL) {
sched_class_dequeue(next);
}
if (next == NULL) {
next = idleproc;
shutdown(0);
}
next->runs ++;
if (next != currentproc) {
proc_run(next);
}
}
local_intr_restore(intr_flag);
}
Schedule会选取下一个进程随即进入switch_to这又是一段汇编代码
# void switch_to(struct proc_struct* from, struct proc_struct* to)
.globl switch_to
switch_to:
# save from's registers
STORE ra, 0*REGBYTES(a0)
STORE sp, 1*REGBYTES(a0)
STORE s0, 2*REGBYTES(a0)
STORE s1, 3*REGBYTES(a0)
STORE s2, 4*REGBYTES(a0)
STORE s3, 5*REGBYTES(a0)
STORE s4, 6*REGBYTES(a0)
STORE s5, 7*REGBYTES(a0)
STORE s6, 8*REGBYTES(a0)
STORE s7, 9*REGBYTES(a0)
STORE s8, 10*REGBYTES(a0)
STORE s9, 11*REGBYTES(a0)
STORE s10, 12*REGBYTES(a0)
STORE s11, 13*REGBYTES(a0)
# restore to's registers
LOAD ra, 0*REGBYTES(a1)
LOAD sp, 1*REGBYTES(a1)
LOAD s0, 2*REGBYTES(a1)
LOAD s1, 3*REGBYTES(a1)
LOAD s2, 4*REGBYTES(a1)
LOAD s3, 5*REGBYTES(a1)
LOAD s4, 6*REGBYTES(a1)
LOAD s5, 7*REGBYTES(a1)
LOAD s6, 8*REGBYTES(a1)
LOAD s7, 9*REGBYTES(a1)
LOAD s8, 10*REGBYTES(a1)
LOAD s9, 11*REGBYTES(a1)
LOAD s10, 12*REGBYTES(a1)
LOAD s11, 13*REGBYTES(a1)
ret
这段代码保存了切换所前运行进程的14个寄存器到当前进程的上下文context此时当前进程的ra中存储的返回地址为函数schedule中`proc_run`下一行的地址。同时将下一个需要运行的进程的上下文装入各个寄存器中。若下一个进程为才被fork出来的子进程则由于在之前`copy_thread`中所设置的proc->context.ra = (uintptr_t)forkret;被加载的进程将进入函数forkret就此切换至另一进程运行。
当子进程执行完毕,它将执行`do_exit`,在`do_exit`中会将当前子进程的状态设置为PROC_ZOMBIE并且对其父进程的等待状态进行判断若父进程处于等待状态则使用`wakeup_proc`函数唤醒父进程。若当前执行exit进程存在子进程需要为其重新指定父进程。
当父进程被唤醒后,进入`wakeup_proc`的代码,在`wakeup_proc`函数中恢复了父进程的运行状态随即再次调用schedule函数。如上文所述schedule再次加载父进程的上下文还记的方才父进程上下文中的ra寄存器的值吗父进程将根据此值返回schedule中`proc_run`的下一行地址继续上次的代码并返回schedule的调用函数`do_wai`t。`do_wait`里我们将再次查看子进程的state若为PROC_ZOMBIE则返回。至此wait系统调用执行完毕。
----------
## 实验二 信号量 ##
### 应用: ###
到目前为止我们已近实现了进程间的简单同步。接下来我们更进一步考虑信号量的实现。App5_2的代码如下
#include<stdio.h>
#include "libuser.h"
#define N 2
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
int items=0;
void producer(void)
{
while(1)
{
if(items==5*N) break;
produce_item();
down(&empty); //空槽数目减1相当于P(empty)
down(&mutex); //进入临界区相当于P(mutex)
insert_item(); //将新数据放到缓冲区中
up(&mutex); //离开临界区相当于V(mutex)
up(&full); //满槽数目加1相当于V(full)
}
}
void consumer()
{
while(1)
{
down(&full); //将满槽数目减1相当于P(full)
down(&mutex); //进入临界区相当于P(mutex)
remove_item(); //从缓冲区中取出数据
up(&mutex); //离开临界区相当于V(mutex)
up(&empty); //将空槽数目加1 相当于V(empty)
consume_item(); //处理取出的数据项
}
}
int main(){
int pid;
int i;
for(i=0; i<2; i++){
pid=fork();
if(pid==0||pid==-1) //子进程或创建进程失败均退出
{
break;
}
}
if(pid==0) {
//
printf("printf 5_2 child %d\n",getpid());
consumer(getpid());
}else{
//
printf("printf 5_2 father %d \n",getpid());
producer();
}
return 0;
}
上述代码是一个简单的生产者消费者程序由一个父进程创建两个子进程父进程作为producer而子进程作为consumer。这里维护了三个信号量mutex作为互斥信号量为临界区提供互斥访问empty用来维护空闲缓冲区full则用来维护被填充的缓存区。
### 实验二任务: ###
实验二任务描述:
实现`__down`函数支持app5_2.c的运行
实验二预期输出:
$ spike obj/pke app/elf/app5_2
预期得到输出中,父进程生产的数量等于两个子进程消费的数量之和。运行脚本:
$ python3 ./pke-final-2
预期得到输出:
build pk : OK
running app5_2 : OK
test sema : OK
Score: 20/20
### 实验二提示: ###
在程序执行的过程中任务常常会因为某一条件没有达成而进入等待状态具到的上述的例子当producer发现没有空闲的缓存区即empty不足时或者consumer发现full不足时二者均会进入等待状态。等待条件得到满足然后继续运行。这种机制我们可以使用等待队列来实现等待某一条件的进程在条件未满足时加入到等待队列当中当条件满足时在遍历对应的队列唤醒进程并且将进程从队列中删除。
在此我们定义结构体wait_t如下:
typedef struct {
struct proc_struct *proc;
uint64_t wakeup_flags;
wait_queue_t * wait_queue;
list_entry_t wait_link;
}wait_t;
proc指向因条件不满足而被加入队列的进程wakeup_flags对该未满足的条件进行描述wait_queue指向此wait_t单元所属于的等待队列wait_link同之前实验中的各类link一样用来组织链表的链接。
对于一个等待队列,封装了一系列的操作,我们对如下几个操作的代码进行阅读。</br>
首先,初始化`wait_t`,将一个进程封装入`wait_t`结构体:
void
wait_init(wait_t *wait, struct proc_struct *proc) {
wait->proc = proc;
wait->wakeup_flags = WT_INTERRUPTED;
list_init(&(wait->wait_link));
}
然后是`wakeup_wait`首先将指定的wait单元从等待队列中删除为其赋值新的wakeup_flags最后唤醒进程。
void
wakeup_wait(wait_queue_t *queue, wait_t *wait, uint64_t wakeup_flags, bool del) {
if (del) {
wait_queue_del(queue, wait);
}
wait->wakeup_flags = wakeup_flags;
wakeup_proc(wait->proc);
}
`wait_current_set`,修改当前进程的状态,并加入等待队列。
void
wait_current_set(wait_queue_t *queue, wait_t *wait, uint64_t wait_state) {
kassert(currentproc != NULL);
wait_init(wait, currentproc);
currentproc->state = PROC_SLEEPING;
currentproc->wait_state = wait_state;
wait_queue_add(queue, wait);
}
有了这些基本操作,我们就能在等待队列的基础上实现信号量,对于信号量,我们定义如下:
typedef struct semaphore{
intptr_t vaddr;
int value;
wait_queue_t wait_queue;
} semaphore_t;
vaddr是信号量在用户空间的地址&empty的值。这里我们将信号量的数组`sema_q`维护在内核空间中并通过vaddr将它与用户变量唯一关联。value为信号量的值`wait_queue`为信号量对应的等待队列。</br>
当用户程序中调用down/up时会调用至系统调用`sys_sema_down/sys_sema_up`,以`sys_sema_down`为例:
void sys_sema_down(intptr_t sema_va){
semaphore_t * se;
if((se=find_sema(sema_va))==NULL){
se=alloc_sema(sema_va);
}
down(se);
}
首先它会查看该用户变量是否已经有对应的信号量若不存在则为其分配信号量。alloc_sema的代码如下
semaphore_t* alloc_sema(intptr_t sema_va){
int found=0;
semaphore_t * se;
int value=0;
copyin((pte_t *)currentproc->upagetable,(char *)&value,sema_va,sizeof(int));
for(se=sema_q;se<&sema_q[NSEMA];se++){
if(se->vaddr==0){
se->vaddr=sema_va;
sem_init(se,value);
found=1;
break;
}else
{
continue;
}
}
if(found==0){
panic("no sema alloc\n");
}
return se;
}
它使用函数copyin利用用户页表得到用户变量所对应的值并用该值初始化信号量。至此完成信号量的分配。
接下来我们来关注对于信号量的操作up的实现
static __noinline void __up(semaphore_t *sem, uint64_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
{
wait_t *wait;
if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) {
sem->value ++;
}
else {
kassert(wait->proc->wait_state == wait_state);
wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);
}
}
local_intr_restore(intr_flag);
}
首先,它从等待队列中取出头节点,若不存在,则说明没有进程在等待,那么直接增加信号量的值。若取出的头节点不为空,则判断该节点是否处于等待状态,然后唤醒该节点。
相应的在down的代码中需要执行相反的逻辑首先需要判断value的值是否大于0若value值大于0则可以直接进行减操作。若否则需要将当前进程加入等待队列并设置其state与wait_state。接着需要调用schedule。当schedule返回时需要对唤醒标准进行判断并将进程从等待队列中删除。
----------
### 提交课设 ###
至此为止,你已经完成了本实验的所有代码,运行脚本./pke-final
$ python3 ./pke-final
预期得到如下输出,然后提交你的代码吧!
build pk : OK
running app5_1 : OK
test fork : OK
running app5_2 : OK
test sema : OK
Score: 40/40
Loading…
Cancel
Save