diff --git a/README.zh-CN.md b/README.zh-CN.md index b76f5fa..e3aedb9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,27 +1,49 @@ -# 基于RISC-V代理内核的操作系统课程实验与课程设计 - - - -[前言](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) - -[第二章. (实验1)非法指令的截获](chapter2.md) - -[第三章. (实验2)系统调用的实现](chapter3.md) - -[第四章. (实验3)物理内存管理](chapter4.md) - -[第五章. (实验4)缺页异常的处理](chapter5.md) - -[第六章. (实验5)进程的封装](chapter6.md) - -[课程设计](课程设计.md) - +# 基于RISC-V代理内核的操作系统课程实验与课程设计 + + + +[前言](preliminary.md) + +[第一章. 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) + +[第二章. 实验环境的安装与使用](chapter2_installation.md) + - [2.1 头歌平台](chapter2_installation.md#educoder) + - [2.2 Ubuntu环境](chapter2_installation.md#ubuntu) + - [2.3 openEuler操作系统](chapter2_installation.md#openeuler) + +[第三章. 实验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) + +[第四章. 实验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) + +[第五章. 实验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:文件系统 + + + diff --git a/chapter1.md b/chapter1_riscv.md similarity index 97% rename from chapter1.md rename to chapter1_riscv.md index 0468c6e..9daf805 100644 --- a/chapter1.md +++ b/chapter1_riscv.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(软硬协同)开发基础。 ### 1.7 相关工具软件 diff --git a/chapter2.md b/chapter2.md deleted file mode 100644 index a79c46b..0000000 --- a/chapter2.md +++ /dev/null @@ -1,589 +0,0 @@ -# 第二章.(实验1)非法指令的截获 - -## 2.1 实验环境搭建 - -实验环境我们推荐采用Ubuntu 16.04LTS或18.04LTS(x86_64)操作系统,我们未在其他系统(如arch,RHEL等)上做过测试,但理论上只要将实验中所涉及到的安装包替换成其他系统中的等效软件包,就可完成同样效果。另外,我们在EduCoder实验平台(网址:https://www.educoder.net )上创建了本书的同步课程,课程的终端环境中已完成实验所需软件工具的安装,所以如果读者是在EduCoder平台上选择的本课程,则可跳过本节的实验环境搭建过程,直接进入通过终端(命令行)进入实验环境。 - -PKE实验涉及到的软件工具有:RISC-V交叉编译器、spike模拟器,以及PKE源代码三个部分。假设读者拥有了Ubuntu 16.04LTS或18.04LTS(x86_64)操作系统的环境,以下分别介绍这三个部分的安装以及安装后的检验过程。需要说明的是,为了避免耗时耗资源的构建(build)过程,一个可能的方案是从https://toolchains.bootlin.com 下载,**但是要注意一些依赖包(如GCC)的版本号**。 - -**我们强烈建议读者在新装环境中完整构建(build)RISC-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文件夹了。 - -● 第三步,构建(build)RISC-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目录。 - -接下来构建(build)spike,并安装: - -`$ 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 实验内容 - -### 应用: ### - -应用1:helloworld.c代码如下: - - 1 #include - 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 - 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 - 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文件看做是一个连续顺序存放的数据块,则下图可以表明这样的一个文件的结构。 - - fig2_1 - -图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段。 - - fig2_2 - -图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个寄存器,其中x10(a0)寄存器与x11(a1)寄存器存储着从之前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的1,MPP设置为0;将mstatus寄存器的MIE域设置为MPIE,即方才所设置的表示中断关闭的0,MPIE设置为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], ¤t); - 156 - 157 run_loaded_program(argc, args.argv, kstack_top); - 158 } - - -​这个函数中,我们对应用程序的ELF文件进行解析,并且最终运行应用程序。 diff --git a/chapter2_installation.md b/chapter2_installation.md new file mode 100644 index 0000000..27dac55 --- /dev/null +++ b/chapter2_installation.md @@ -0,0 +1,169 @@ +# 第二章.实验环境的安装与使用 + +### 目录 +- [2.1 头歌平台](#educoder) +- [2.2 Ubuntu环境](#ubuntu) +- [2.3 openEuler操作系统](#openeuler) + + + +## 2.1 头歌平台 + +PKE实验在[头歌平台](https://www.educoder.net/)上进行了部署,但因为仍在测试阶段,所以没有开放全局选课(感兴趣的读者可以尝试邀请码:2T8MA)。PKE实验(2.0版本)将于2021年秋季在头歌平台重新上线,届时将开放全局选课。 + +fig2_install_1 + +图1.1 头歌课程界面。 + +头歌平台为每个选课的学生提供了一个docker虚拟机,该虚拟机环境中已经配置好了所有开发套件(包括交叉编译器、Spike模拟器等),用户可以通过shell选项(*详细使用方法将待2.0上线时更新*)进入该docker环境在该docker环境中完成实验任务。 + + + +## 2.2 Ubuntu环境 + +实验环境我们推荐采用Ubuntu 16.04LTS或18.04LTS(x86_64)操作系统,我们未在其他系统(如arch,RHEL等)上做过测试,但理论上只要将实验中所涉及到的安装包替换成其他系统中的等效软件包,就可完成同样效果。另外,我们在EduCoder实验平台(网址:https://www.educoder.net )上创建了本书的同步课程,课程的终端环境中已完成实验所需软件工具的安装,所以如果读者是在EduCoder平台上选择的本课程,则可跳过本节的实验环境搭建过程,直接进入通过终端(命令行)进入实验环境。 + +PKE实验涉及到的软件工具有:RISC-V交叉编译器、spike模拟器,以及PKE源代码三个部分。假设读者拥有了Ubuntu 16.04LTS或18.04LTS(x86_64)操作系统的环境,以下分别介绍这三个部分的安装以及安装后的检验过程。需要说明的是,为了避免耗时耗资源的构建(build)过程,一个可能的方案是从https://toolchains.bootlin.com 下载,**但是要注意一些依赖包(如GCC)的版本号**。 + +**我们强烈建议读者在新装环境中完整构建(build)RISC-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文件夹了。 + +● 第三步,构建(build)RISC-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目录。 + +接下来构建(build)spike,并安装: + +`$ 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)等等传统的完整操作系统“必须”具有的组件。在后续的实验中,我们将不断提升应用的复杂度,并不断完善代理内核。通过这个过程,读者将深刻体会操作系统内核对应用支持的机制,以及具体的实现细节。 + + +## 2.3 openEuler操作系统 + +PKE实验将提供基于华为openEuler操作系统的开发方法,*具体的华为云使用方法待续*,但在openEuler操作系统环境中的交叉编译器安装方法,以及其他环节都可参考[2.2 Ubuntu环境](#ubuntu)的命令进行。 diff --git a/chapter3.md b/chapter3.md deleted file mode 100644 index 28db8d0..0000000 --- a/chapter3.md +++ /dev/null @@ -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。由于用户进程只能在操作系统给它圈定好的“用户环境”中执行,但“用户环境”限制了用户进程能够执行的指令,即用户进程只能执行一般的指令,无法执行特权指令。如果用户进程想执行一些需要特权指令的任务,比如通过网卡发网络包等,只能让操作系统来代劳了。系统调用就是用户模式下请求操作系统执行某些特权指令的任务的机制。 - -相较于函数调用在普通的用户模式下运行,系统调用则运行在内核模式中。 - -见下图: - - fig3_1 - -图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个寄存器,其中x10(a0)寄存器与x11(a1)寄存器存储着从之前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的1,MPP设置为0;将mstatus寄存器的MIE域设置为MPIE,即方才所设置的表示中断关闭的0,MPIE设置为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], ¤t); -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的值为0,sp中存储的就是内核的堆栈地址。而当中断来源于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得到对应的函数,并最终执行系统调用。 - \ No newline at end of file diff --git a/chapter3_traps.md b/chapter3_traps.md new file mode 100644 index 0000000..18fc56b --- /dev/null +++ b/chapter3_traps.md @@ -0,0 +1,1681 @@ +# 第三章.实验1:系统调用、异常和外部中断 + + +### 目录 +- [3.1 实验1的基础知识](#fundamental) + - [3.1.1 获取riscv-pke代码](#subsec_preparecode) + - [3.1.2 RISC-V程序的编译和链接](#subsec_compileandlink) + - [3.1.3 指定符号的逻辑地址](#subsec_lds) + - [3.1.4 代理内核的构造过程](#subsec_building) + - [3.1.5 代理内核的启动过程](#subsec_booting) + - [3.1.6 ELF文件(app)的加载过程](#subsec_elf) + - [3.1.7 spike的HTIF接口](#subsec_htif) +- [3.2 lab1_1 系统调用](#syscall) + - [给定应用](#lab1_1_app) + - [实验内容](#lab1_1_content) + - [实验指导](#lab1_1_guide) +- [3.3 lab1_2 异常处理](#exception) + - [给定应用](#lab1_2_app) + - [实验内容](#lab1_2_content) + - [实验指导](#lab1_2_guide) +- [3.4 lab1_3(外部)中断](#irq) + - [给定应用](#lab1_3_app) + - [实验内容](#lab1_3_content) + - [实验指导](#lab1_3_guide) + + + +## 3.1 实验1的基础知识 + +本章我们将首先[获得代码](#subsec_preparecode),接下来介绍[程序的编译链接和ELF文件](#subsec_elfload)的基础知识,接着讲述riscv-pke操作系统内核的[启动原理](#subsec_booting),最后开始实验1的3个实验。 + + + +### 3.1.1 获取riscv-pke代码 + +获取riscv-pke代码前,需要首先确认你已经按照[第二章](chapter2_installation.md)的要求完成了开发环境的构建(这里,我们假设环境是基于Ubuntu或者openEular,头歌环境下更多的是界面操作,所以无需通过命令行获取代码)。 + +环境构建好后,通过以下命令下载实验1的代码: + +(克隆代码仓库) +`$ git clone https://gitee.com/hustos/riscv-pke.git` + +克隆完成后,将在当前目录应该能看到riscv-pke目录。这时,可以到riscv-pke目录下查看文件结构,例如: + +`$ cd riscv-pke` + +(切换到lab1_1_syscall分支) +`$ git checkout lab1_1_syscall` + +`$ tree -L 3` + +(将看到如下输出) + +``` +. +├── LICENSE.txt +├── Makefile +├── README.md +├── kernel +│   ├── config.h +│   ├── elf.c +│   ├── elf.h +│   ├── kernel.c +│   ├── kernel.lds +│   ├── machine +│   │   ├── mentry.S +│   │   └── minit.c +│   ├── process.c +│   ├── process.h +│   ├── riscv.h +│   ├── strap.c +│   ├── strap.h +│   ├── strap_vector.S +│   ├── syscall.c +│   └── syscall.h +├── spike_interface +│   ├── atomic.h +│   ├── dts_parse.c +│   ├── dts_parse.h +│   ├── spike_file.c +│   ├── spike_file.h +│   ├── spike_htif.c +│   ├── spike_htif.h +│   ├── spike_memory.c +│   ├── spike_memory.h +│   ├── spike_utils.c +│   └── spike_utils.h +├── user +│   ├── app_helloworld.c +│   ├── user.lds +│   ├── user_lib.c +│   └── user_lib.h +└── util + ├── functions.h + ├── load_store.S + ├── snprintf.c + ├── snprintf.h + ├── string.c + ├── string.h + └── types.h +``` + +在代码的根目录有以下文件: + +- Makefile文件,它是make命令即将使用的“自动化编译”脚本; + +- LICENSE.txt文件,即riscv-pke的版权文件,里面包含了所有参与开发的人员信息。riscv-pke是开源软件,你可以以任意方式自由地使用,前提是使用时包含LICENSE.txt文件即可; + +- README.md文件,一个简要的英文版代码说明。 + + +另外是一些子目录,其中: + +- kernel目录包含了riscv-pke的内核部分代码; +- spike_interface目录是riscv-pke内核与spike模拟器的接口代码(如设备树DTB、主机设备接口HTIF等),用于接口的初始化和调用; +- user目录包含了实验给定应用(如lab1_1中的app_helloworld.c),以及用户态的程序库文件(如lab1_1中的user_lib.c); +- util目录包含了一些内核和用户程序公用的代码,如字符串处理(string.c),类型定义(types.h)等。 + +在代码的根目录输入以下命令: +`$ make` +进行构造(build),在环境已配好(特别是交叉编译器已加入系统路径)的情况下,输出如下: + +``` +compiling util/snprintf.c +compiling util/string.c +linking obj/util.a ... +Util lib has been build into "obj/util.a" +compiling spike_interface/dts_parse.c +compiling spike_interface/spike_htif.c +compiling spike_interface/spike_utils.c +compiling spike_interface/spike_file.c +compiling spike_interface/spike_memory.c +linking obj/spike_interface.a ... +Spike lib has been build into "obj/spike_interface.a" +compiling kernel/syscall.c +compiling kernel/elf.c +compiling kernel/process.c +compiling kernel/strap.c +compiling kernel/kernel.c +compiling kernel/machine/minit.c +compiling kernel/strap_vector.S +compiling kernel/machine/mentry.S +linking obj/riscv-pke ... +PKE core has been built into "obj/riscv-pke" +compiling user/app_helloworld.c +compiling user/user_lib.c +linking obj/app_helloworld ... +User app has been built into "obj/app_helloworld" +``` + +构造完成后,在代码根目录会出现一个obj子目录,该子目录包含了构造过程中所生成的所有对象文件(.o)、编译依赖文件(.d)、静态库(.a)文件,和最终目标ELF文件(如./obj/riscv-pke和./obj/app_helloworld)。 + +这时,我们可以尝试借助riscv-pke内核运行app_helloworld的“Hello world!”程序: + +``` +$ spike ./obj/riscv-pke ./obj/app_helloworld +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_helloworld +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +call do_syscall to accomplish the syscall and lab1_1 here. + +System is shutting down with exit code -1. +``` + +自此,riscv-pke的代码获取(和验证)已完成。 + + + +### 3.1.2 RISC-V程序的编译和链接 + +下面,我们将简要介绍RISC-V程序的编译和链接相关知识。这里,我们仍然假设你已经按照[第二章](chapter2_installation.md)的要求完成了基于Ubuntu或者openEular的开发环境构建,如果是在头歌平台,可以通过他们提供的交互??进入终端使用(里面的交叉编译器已经安装且已加入系统路径)。在PKE实验的开发环境中,我们通过模拟器(spike)所构建的目标机是risc-v机器,而主机一般采用的是采用x86指令集的Intel处理器(openEular可能采用的是基于ARM指令集的华为鲲鹏处理器),在这种配置下我们的程序,包括PKE操作系统内核以及应用都通过交叉编译器所提供的工具进行编译和链接。虽然risc-v交叉编译器和主机环境下的GCC是同一套体系,但它的使用和输出还是有些细微的不同,所以有必要对它进行一定的了解。 + +实际上,采用RV64G指令集的RISC-V体系结构(参见[第一章](chapter1_riscv.md)的内容)跟传统的x86或者ARM体系结构非常类似(从采用精简指令集这个角度,跟ARM更加类似一些),从软件层面上来看也没有什么不同。所以,为RISC-V体系结构编写、编译和链接程序,以及ELF文件的结构这些基础知识,读者可以从[《计算机系统基础》课程](https://book.douban.com/subject/30295940/)的第四章(采用x86指令集)里找到,以下的讲解我们将更侧重用我们的PKE实验里可能碰到的问题。 + +我们知道,采用C语言编写一个应用的过程大概可以归纳为:首先,**编辑(edit)**.c的源文件;接下来,**编译(compile)**为对象文件.o;最后,将对象文件**链接(link)**为可执行程序文件。当然,在从源代码编译为对象文件的过程中,可能会出现语法错误;再链接过程中,也可能出现符号找不到或函数未定义的错误,这些错误都需要我们回到源代码或者修改链接命令行来进行修正,并最终得到符合预期的应用程序。 + +- 编辑 + +例如,我们有以下简单Hello world!程序(在当前目录编辑helloworld.c文件): + +``` + 1 #include + 2 + 3 int main() + 4 { + 5 printf( "Hello world!\n" ); + 6 return 0; + 7 } +``` + +- 编译 + +使用交叉编译器对以上程序进行编译: + +`$ riscv64-unknown-elf-gcc -c ./helloworld.c` + +以上命令中`-c`开关告诉riscv64-unknown-elf-gcc命令只对源代码做编译,即compile动作。如果不加该开关,gcc默认地将对源文件进行编译+链接动作,直接生成可执行程序。 + +该命令执行后,我们将在当前目录得到helloworld.o文件,使用file命令对该文件进行观察: + +``` +$ file ./helloworld.o +./helloworld.o: ELF 64-bit LSB relocatable, UCB RISC-V, version 1 (SYSV), not stripped +``` + +可以看到,helloworld.o文件的属性为: + +1. ELF 64-bit: 64位的ELF文件; +2. LSB: 低位有效,即低地址存放最低有效字节。关于LSB和MSB参见[百度知道的解释](https://zhidao.baidu.com/question/155072477.html); +3. relocatable:可浮动代码,意味着该ELF文件中的符号并无指定的逻辑地址; +4. UCB RISC-V:代码的目标指令集为RISC-V指令集; +5. version 1 (SYSV):说明该ELF文件是SYSV版本的,即ELF头中的e_ident [EI_OSABI]字段设置为0; +6. not stripped:说明该ELF文件保留了程序中的符号表(symbol table)。符号(symbol)广泛存在于我们编写的源程序中,编译器会把所有函数名、变量名处理为符号。例如,helloworld.c中的main就是一个符号。 + + +- 链接 + +最后,我们对生成的目标文件进行链接: + +`$ riscv64-unknown-elf-gcc -o ./helloworld ./helloworld.o` + +该命令将在当前目录生成helloworld文件,我们仍然用file命令查看该文件的信息: + +``` +$ file ./helloworld +./helloworld: ELF 64-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, not stripped +``` + +对比于helloworld.o文件,我们发现helloworld文件是可执行(executable)文件而不是可浮动代码,也就是说通过链接,已经给源代码中的符号指定好了逻辑地址。另外,statically linked说明helloworld程序是通过静态链接生成的。实际上,newlib版本的RISC-V交叉编译(也就是我们用的riscv64-unknown-elf-gcc)默认会将输入程序与它自带的静态库进行链接,从而生成直接可以在RISC-V机器上执行的可执行代码。具体到helloworld.c程序,它所调用的printf函数将从交叉编译器的静态库中获得,并在最终生成的./helloworld注入printf函数的实现。这样,./helloworld的执行将无需依赖其他动态库,避免二进制程序因缺少二进制库而无法执行的问题。PKE实验将全部采用静态链接的方法,生成所有二进制文件。 + +接下来,我们了解一下helloworld的结构。首先通过riscv64-unknown-elf-readelf -h命令,了解该ELF文件的文件头信息: + +``` +$ riscv64-unknown-elf-readelf -h ./helloworld +ELF Header: + Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 + Class: ELF64 + Data: 2's complement, little endian + Version: 1 (current) + OS/ABI: UNIX - System V + ABI Version: 0 + Type: EXEC (Executable file) + Machine: RISC-V + Version: 0x1 + Entry point address: 0x100c0 + Start of program headers: 64 (bytes into file) + Start of section headers: 19440 (bytes into file) + Flags: 0x5, RVC, double-float ABI + Size of this header: 64 (bytes) + Size of program headers: 56 (bytes) + Number of program headers: 2 + Size of section headers: 64 (bytes) + Number of section headers: 15 + Section header string table index: 14 +``` + +从以上输出我们可以看到,helloworld包含了2个程序段(segment),以及15个节(section),它的入口地址为0x100c0。 + +接下来,我们可以通过riscv64-unknown-elf-readelf -S命令了解helloworld可执行程序包含哪些程序节: + +``` +$ riscv64-unknown-elf-readelf -S ./helloworld +There are 15 section headers, starting at offset 0x4bf0: + +Section Headers: + [Nr] Name Type Address Offset + Size EntSize Flags Link Info Align + [ 0] NULL 0000000000000000 00000000 + 0000000000000000 0000000000000000 0 0 0 + [ 1] .text PROGBITS 00000000000100b0 000000b0 + 00000000000024ca 0000000000000000 AX 0 0 2 + [ 2] .rodata PROGBITS 0000000000012580 00002580 + 0000000000000012 0000000000000000 A 0 0 8 + [ 3] .eh_frame PROGBITS 0000000000013594 00002594 + 0000000000000004 0000000000000000 WA 0 0 4 + [ 4] .init_array INIT_ARRAY 0000000000013598 00002598 + 0000000000000010 0000000000000008 WA 0 0 8 + [ 5] .fini_array FINI_ARRAY 00000000000135a8 000025a8 + 0000000000000008 0000000000000008 WA 0 0 8 + [ 6] .data PROGBITS 00000000000135b0 000025b0 + 0000000000000f58 0000000000000000 WA 0 0 8 + [ 7] .sdata PROGBITS 0000000000014508 00003508 + 0000000000000040 0000000000000000 WA 0 0 8 + [ 8] .sbss NOBITS 0000000000014548 00003548 + 0000000000000020 0000000000000000 WA 0 0 8 + [ 9] .bss NOBITS 0000000000014568 00003548 + 0000000000000068 0000000000000000 WA 0 0 8 + [10] .comment PROGBITS 0000000000000000 00003548 + 0000000000000011 0000000000000001 MS 0 0 1 + [11] .riscv.attributes RISCV_ATTRIBUTE 0000000000000000 00003559 + 0000000000000035 0000000000000000 0 0 1 + [12] .symtab SYMTAB 0000000000000000 00003590 + 0000000000000f48 0000000000000018 13 78 8 + [13] .strtab STRTAB 0000000000000000 000044d8 + 0000000000000695 0000000000000000 0 0 1 + [14] .shstrtab STRTAB 0000000000000000 00004b6d + 000000000000007e 0000000000000000 0 0 1 +Key to Flags: + W (write), A (alloc), X (execute), M (merge), S (strings), I (info), + L (link order), O (extra OS processing required), G (group), T (TLS), + C (compressed), x (unknown), o (OS specific), E (exclude), + p (processor specific) +``` + +以上输出中比较重要的有:.text表示可执行节(section),.rodata节是只读数据节,.data,.sdata,.sbss以及.bss节都可以视为helloworld程序的数据段(.data是数据节,.sdata为精简数据节,.bss为未初始化数据节,.sbss则为精简未初始化数据节)。其他的节,如.symtab为符号节,即程序中出现的符号(如main函数)列表;.strtab节为程序中出现的字符串列表等。 + +由于helloworld是可执行程序,且根据riscv64-unknown-elf-readelf -h命令的输出,我们已知该程序有2个程序段(segment),接下来我们再通过riscv64-unknown-elf-readelf -l查看该可执行程序的程序段组成: + +``` +$ riscv64-unknown-elf-readelf -l ./helloworld + +Elf file type is EXEC (Executable file) +Entry point 0x100c0 +There are 2 program headers, starting at offset 64 + +Program Headers: + Type Offset VirtAddr PhysAddr + FileSiz MemSiz Flags Align + LOAD 0x0000000000000000 0x0000000000010000 0x0000000000010000 + 0x0000000000002592 0x0000000000002592 R E 0x1000 + LOAD 0x0000000000002594 0x0000000000013594 0x0000000000013594 + 0x0000000000000fb4 0x000000000000103c RW 0x1000 + + Section to Segment mapping: + Segment Sections... + 00 .text .rodata + 01 .eh_frame .init_array .fini_array .data .sdata .sbss .bss +``` + +以上输出表明,helloworld的两个段,其中一个(00)是由.text节和.rodata节所组成的(可执行代码段),另一个(01)是由.eh_frame,.init_array,.fini_arry,.data,.sdata,.sbss以及.bss节所组成的(数据段)。**这里,读者可以思考:helloworld文件中为什么没有出现堆栈(.stack)段呢?** + +为了对helloworld文件进行进一步理解,我们使用objdump命令将它进行反汇编处理(使用`-D`开关反汇编所有的段),并列出3个有效段(省略辅助段,也省略gcc加入的一些辅助函数和辅助数据结构): + +``` +$ riscv64-unknown-elf-objdump -D ./helloworld | less + +./helloworld: file format elf64-littleriscv + +Disassembly of section .text: + +000000000001014e
: + 1014e: 1141 addi sp,sp,-16 + 10150: e406 sd ra,8(sp) + 10152: e022 sd s0,0(sp) + 10154: 0800 addi s0,sp,16 + 10156: 67c9 lui a5,0x12 + 10158: 58078513 addi a0,a5,1408 # 12580 <__errno+0xc> + 1015c: 1c0000ef jal ra,1031c + 10160: 4781 li a5,0 + 10162: 853e mv a0,a5 + 10164: 60a2 ld ra,8(sp) + 10166: 6402 ld s0,0(sp) + 10168: 0141 addi sp,sp,16 + 1016a: 8082 ret + ... +``` + +实际上,由于helloworld是静态链接文件,里面的内容非常繁杂,而以上输出中的main函数是我们在`riscv64-unknown-elf-objdump -D ./helloworld`的输出中抽取出来的,可以观察从helloworld.c中C源程序到RISC-V汇编的转换。首先,我们可以看到main函数对应的逻辑地址为000000000001014e,这里读者可以思考下为什么它不是`riscv64-unknown-elf-readelf -h ./helloworld`命令输出中的程序入口地址0x100c0? + +另外,我们看到main函数中进行的printf调用实际上转换成了对puts函数的调用,而我们并未实现该函数。这是因为采用`riscv64-unknown-elf-gcc`对源程序进行的编译和链接,实际上是静态链接,也就是会将应用程序中的常用函数调用转换为编译器自带的程序库的调用,并在最终生成的ELF文件中带入自带程序库中的实现。对于printf,编译器在对其字符串参数进行转化后就自然转换为puts库函数调用了,这也是为什么我们在反汇编的代码中找不到printf的原因。 + +通过以上讲解,可以看到RISC-V程序的编译链接其实跟我们在x86平台上编写、编译和链接一个C语言程序的过程一摸一样,唯一的不同是将以上的命令加上了`riscv64-unknown-elf-`前缀而已,基本的过程是完全一样的。除此之外,我们生成的可执行代码(如以上的helloworld)采用是RISC-V指令集(跟准确的说,是RV64G指令集),是不能在我们的x86开发电脑上直接执行的!也就是说,我们不能通过`./helloworld`命令直接执行之前编写的C语言程序。 + +为了执行helloworld文件,首先我们需要:1)一个支持RISC-V指令集的机器;2)一个能够在该RISC-V机器上运行的操作系统。在PKE实验中,我们采用spike模拟RISC-V机器以满足第一个要求;通过实验设计操作系统内核,以满足第二个要求。实际上,PKE的实验设计正是从应用出发,考虑为给定应用设计“刚刚好”的内核,使得给定应用能够在spike模拟的RISC-V机器上正确地执行。 + + + + + +### 3.1.3 指定符号的逻辑地址 + +编译器在链接过程中,一个重要的任务就是为源程序中的符号(symbol)赋予逻辑地址。例如,在以上`sections`的例子中,我们通过`objdump`命令,得知helloworld.c源文件的main函数所对应的逻辑地址为0x000000000001014e,而且对于任意ELF中的段(segment)或节(section)而言,它的逻辑地址必然是从某个地址开始的一段连续地址空间。 + +那么,是否有办法指定某符号对应的逻辑地址呢?答案是可以,但只能指定符号所在的段的起始逻辑地址,方法是通过lds链接脚本。我们还是用以上的helloworld.c作为例子,另外创建和编辑一个lds链接脚本,即helloworld_lds.lds文件: + +``` + 1 OUTPUT_ARCH( "riscv" ) + 2 + 3 ENTRY(main) + 4 + 5 SECTIONS + 6 { + 7 . = 0x81000000; + 8 . = ALIGN(0x1000); + 9 .text : { *(.text) } + 10 . = ALIGN(16); + 11 .data : { *(.data) } + 12 . = ALIGN(16); + 13 .bss : { *(.bss) } + 14 } +``` + +在该脚本中,我们规定了程序的目标指令集为RISC-V(第1行);入口地址为main(第3行)。接下来,我们对程序的几个段地址进行了“规划”,其中代码段的首地址为0x81000000(如果目标平台是64位,编译器会对该逻辑地址的高地址位填0),.data段和.bss段跟在.text段之后;ALIGN(0x1000)表示按4KB对齐,ALIGN(16)表示按照16字节对齐。 + +为了避免库函数的“干扰”,我们将3.1.2中的helloworld.c代码进行了修改(helloworld_with_lds.c),去掉了printf的调用转用一个加法语句作为main函数的主体: + +``` + 1 #include + 2 + 3 int main() + 4 { + 5 int global_var=0; + 6 global_var = 1; + 7 return 0; + 8 } +``` + +采用以下命令对helloworld_with_lds.c进行编译: + +`$ riscv64-unknown-elf-gcc -nostartfiles helloworld_with_lds.c -T helloworld_lds.lds -o helloworld_with_lds` + +并对重新生成的helloworld_with_lds文件进行观察: + +``` +$ riscv64-unknown-elf-readelf -h ./helloworld_with_lds +ELF Header: + Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 + Class: ELF64 + Data: 2's complement, little endian + Version: 1 (current) + OS/ABI: UNIX - System V + ABI Version: 0 + Type: EXEC (Executable file) + Machine: RISC-V + Version: 0x1 + Entry point address: 0x81000000 + Start of program headers: 64 (bytes into file) + Start of section headers: 4424 (bytes into file) + Flags: 0x5, RVC, double-float ABI + Size of this header: 64 (bytes) + Size of program headers: 56 (bytes) + Number of program headers: 1 + Size of section headers: 64 (bytes) + Number of section headers: 7 + Section header string table index: 6 +``` + +以上的输出表明,它的入口地址变成了0x81000000(也就是由helloworld_lds.lds所指定的地址),另外采用`riscv64-unknown-elf-objdump -D ./helloworld_with_lds`命令观察helloworld_with_lds文件中符号地址main所对应的逻辑地址,有以下输出: + +``` +$ riscv64-unknown-elf-objdump -D ./helloworld_with_lds | less + +./helloworld_with_lds: file format elf64-littleriscv + +Disassembly of section .text: + +0000000081000000
: + 81000000: 1101 addi sp,sp,-32 + 81000002: ec22 sd s0,24(sp) + 81000004: 1000 addi s0,sp,32 + 81000006: fe042623 sw zero,-20(s0) + 8100000a: 4785 li a5,1 + 8100000c: fef42623 sw a5,-20(s0) + 81000010: 4781 li a5,0 + 81000012: 853e mv a0,a5 + 81000014: 6462 ld s0,24(sp) + 81000016: 6105 addi sp,sp,32 + 81000018: 8082 ret + ... +``` + +可以看到,main函数成为了helloworld_with_lds文件的真正入口(而不是3.1.2中的helloworld文件中main函数并不是该可执行程序的真正入口的情形)。 + +通过lds文件的办法控制程序中符号地址到逻辑地址的转换,对于PKE的第一组实验(lab1)而言,是一个很重要的知识点。因为在第一组实验中,我们将采用直接映射(Bare-mode mapping)的办法完成应用程序中虚地址到实际物理地址的转换(我们在第二组实验中才会打开RISC-V的SV39页式地址映射),所以需要提前对PKE的内核以及应用的逻辑地址进行“规划”。 + + + + + +### 3.1.4 代理内核的构造过程 + +这里我们讨论lab1_1中代理内核,以及其上运行的应用的构造(build)过程。PKE实验采用了Linux中广泛采用的make软件包完成内核、支撑库,以及应用的构造。关于Makefile的编写,我们建议读者阅读[这篇文章](https://blog.csdn.net/foryourface/article/details/34058577)了解make文件的基础知识,这里仅讨论lab1_1的Makefile以及对应的构造过程。PKE的后续实验实际上采用的Makefile跟lab1_1的非常类似,所以我们在后续章节中不再对它们的构建过程进行讨论。 + +我们首先观察lab1_1中位于根目录的Makefile文件(摘取其中我们认为重要的内容): + +``` + 8 CROSS_PREFIX := riscv64-unknown-elf- + 9 CC := $(CROSS_PREFIX)gcc + 10 AR := $(CROSS_PREFIX)ar + 11 RANLIB := $(CROSS_PREFIX)ranlib + 12 + 13 SRC_DIR := . + 14 OBJ_DIR := obj + 15 SPROJS_INCLUDE := -I. + 16 + ... + 26 #--------------------- utils ----------------------- + 27 UTIL_CPPS := util/*.c + 28 + 29 UTIL_CPPS := $(wildcard $(UTIL_CPPS)) + 30 UTIL_OBJS := $(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(UTIL_CPPS))) + 31 + 32 + 33 UTIL_LIB := $(OBJ_DIR)/util.a + 34 + 35 #--------------------- kernel ----------------------- + 36 KERNEL_LDS := kernel/kernel.lds + 37 KERNEL_CPPS := \ + 38 kernel/*.c \ + 39 kernel/machine/*.c \ + 40 kernel/util/*.c + 41 + 42 KERNEL_ASMS := \ + 43 kernel/*.S \ + 44 kernel/machine/*.S \ + 45 kernel/util/*.S + 46 + 47 KERNEL_CPPS := $(wildcard $(KERNEL_CPPS)) + 48 KERNEL_ASMS := $(wildcard $(KERNEL_ASMS)) + 49 KERNEL_OBJS := $(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(KERNEL_CPPS))) + 50 KERNEL_OBJS += $(addprefix $(OBJ_DIR)/, $(patsubst %.S,%.o,$(KERNEL_ASMS))) + 51 + 52 KERNEL_TARGET = $(OBJ_DIR)/riscv-pke + 53 + 54 + 55 #--------------------- spike interface library ----------------------- + 56 SPIKE_INF_CPPS := spike_interface/*.c + 57 + 58 SPIKE_INF_CPPS := $(wildcard $(SPIKE_INF_CPPS)) + 59 SPIKE_INF_OBJS := $(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(SPIKE_INF_CPPS))) + 60 + 61 + 62 SPIKE_INF_LIB := $(OBJ_DIR)/spike_interface.a + 63 + 64 + 65 #--------------------- user ----------------------- + 66 USER_LDS := user/user.lds + 67 USER_CPPS := user/*.c + 68 + 70 USER_OBJS := $(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(USER_CPPS))) + 71 + 72 USER_TARGET := $(OBJ_DIR)/app_helloworld + 73 + 74 #------------------------targets------------------------ + 75 $(OBJ_DIR): + 76 @-mkdir -p $(OBJ_DIR) + 77 @-mkdir -p $(dir $(UTIL_OBJS)) + 78 @-mkdir -p $(dir $(SPIKE_INF_OBJS)) + 79 @-mkdir -p $(dir $(KERNEL_OBJS)) + 80 @-mkdir -p $(dir $(USER_OBJS)) + 81 + 82 $(OBJ_DIR)/%.o : %.c + 83 @echo "compiling" $< + 84 @$(COMPILE) -c $< -o $@ + 85 + 86 $(OBJ_DIR)/%.o : %.S + 87 @echo "compiling" $< + 88 @$(COMPILE) -c $< -o $@ + 89 + 90 $(UTIL_LIB): $(OBJ_DIR) $(UTIL_OBJS) + 91 @echo "linking " $@ ... + 92 @$(AR) -rcs $@ $(UTIL_OBJS) + 93 @echo "Util lib has been build into" \"$@\" + 94 + 95 $(SPIKE_INF_LIB): $(OBJ_DIR) $(UTIL_OBJS) $(SPIKE_INF_OBJS) + 96 @echo "linking " $@ ... + 97 @$(AR) -rcs $@ $(SPIKE_INF_OBJS) $(UTIL_OBJS) + 98 @echo "Spike lib has been build into" \"$@\" + 99 +100 $(KERNEL_TARGET): $(OBJ_DIR) $(UTIL_LIB) $(SPIKE_INF_LIB) $(KERNEL_OBJS) $(KERNEL_LDS) +101 @echo "linking" $@ ... +102 @$(COMPILE) $(KERNEL_OBJS) $(UTIL_LIB) $(SPIKE_INF_LIB) -o $@ -T $(KERNEL_LDS) +103 @echo "PKE core has been built into" \"$@\" +104 +105 $(USER_TARGET): $(OBJ_DIR) $(UTIL_LIB) $(USER_OBJS) $(USER_LDS) +106 @echo "linking" $@ ... +107 @$(COMPILE) $(USER_OBJS) $(UTIL_LIB) -o $@ -T $(USER_LDS) +108 @echo "User app has been built into" \"$@\" +109 +... +113 .DEFAULT_GOAL := $(all) +114 +115 all: $(KERNEL_TARGET) $(USER_TARGET) +116 .PHONY:all +... +``` + +通过阅读该文件,我们看到构建过程的最终目标是第115行的all,而它有两个依赖目标:$(KERNEL_TARGET)和$(USER_TARGET)。我们从右往左看,$(USER_TARGET)的定义在72行,对应的是$(OBJ_DIR)/app_helloworld (即./obj/app_helloworld)。构建$(USER_TARGET)的规则在第105--108行,其中第105行说明$(USER_TARGET)的构建依赖$(OBJ_DIR),$(UTIL_LIB),$(USER_OBJS)以及$(USER_LDS)这4个目标。 + +- $(OBJ_DIR):它对应的动作是建立5个目录(第75--第80行):./obj,./obj/util,./obj/spike_interface,./obj/user以及./obj/kernel; +- $(UTIL_LIB):它的定义在第33行,它对应的是$(OBJ_DIR)/util.a,而它的编译规则在第90行--第93行,依赖$(UTIL_OBJS)的处理。而后者的定义在第30行,对应`$(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(UTIL_CPPS)))` ,需要将$(UTIL_CPPS)也就是util/*.c(见第27行)全部编译成.o文件,并链接为静态库(第92行的动作); +- $(USER_OBJS):它对应(第70行)的是`$(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(USER_CPPS)))`,需要将$(USER_CPPS)也就是user/*.c都编译为.o文件(具体的编译动作由82--84行完成); +- $(USER_LDS):它对应(见第66行)user/user.lds,由于后者已经存在于源代码树中,所以不会导致任何动作。 + +以上构造$(USER_TARGET)所依赖的目标全部完成后,构造过程将执行第105--108行的动作,即将user/目录下通过编译出来的.o文件与util中编译和链接出来的静态库文件一起链接,生成采用RISC-V指令集的可执行文件。同时,链接过程采用user/user.lds脚本以指定生成的可执行文件中的符号所对应的逻辑地址。 + +完成$(USER_TARGET)的构造后,我们回到第115行,继续$(KERNEL_TARGET)目标的构造。它的定义在100-103行,可以看到它又有5个依赖目标:$(OBJ_DIR),$(UTIL_LIB),$(SPIKE_INF_LIB),$(KERNEL_OBJS)和$(KERNEL_LDS)。其中$(OBJ_DIR)、$(UTIL_LIB)在构造$(USER_TARGET)目标时就已经顺带地实现了,剩下的$(KERNEL_LDS)也是已经存在的文件。这样,就剩下两个“新”目标: + +- $(SPIKE_INF_LIB):它的定义在第62行,对应$(OBJ_DIR)/spike_interface.a文件,对应的构造动作在第95--98行,又依赖$(OBJ_DIR)、$(UTIL_OBJS)和$(SPIKE_INF_OBJS)这3个目标。其中$(OBJ_DIR)和$(UTIL_OBJS)两个目标我们在之前构造$(USER_TARGET)时已经解决,剩下的$(SPIKE_INF_OBJS)目标对应的是`$(addprefix $(OBJ_DIR)/, $(patsubst %.c,%.o,$(SPIKE_INF_CPPS)))`将导致$(SPIKE_INF_CPPS),也就是spike_interface/*.c被对应地编译成.o文件。最后,第96--98行的动作,会将编译spike_interface目录下文件生成的.o文件链接为静态库($(OBJ_DIR)/spike_interface.a)文件; +- $(KERNEL_OBJS):它对应的定义在第49--50行,内容很简单,就是kernel下的所有汇编.S文件和所有的.c文件。处理该依赖构造目标,会将kernel子目录下的所有汇编和C源文件编译成对应的.o文件。 + +以上依赖目标全部构造完毕后,回到第101--103行的$(KERNEL_TARGET)目标所对应的动作,将编译kernel目录下的源文件所得到的.o文件与$(OBJ_DIR)/spike_interface.a进行链接,并最终生成我们的代理内核$(OBJ_DIR)/riscv-pke。至此,PKE实验所需要的代码构造完毕。总结一下,这个构造过程是: + +- 1)构造util目录下的静态库文件$(OBJ_DIR)/util.a; +- 2)构造应用程序,得到$(OBJ_DIR)/app_helloworld; +- 3)构造$(OBJ_DIR)/spike_interface.a,即spike所提供的工具库文件; +- 4)最后构造代理内核$(OBJ_DIR)/riscv-pke。 + + + + + +### 3.1.5 代理内核的启动过程 + + +在3.1.1中,我们获取riscv-pke的代码并完成构造步骤后,我们将通过以下命令开始lab1_1所给定的应用的执行: + +``` +$ spike ./obj/riscv-pke ./obj/app_helloworld +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_helloworld +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +call do_syscall to accomplish the syscall and lab1_1 here. + +System is shutting down with exit code -1. +``` + +可以看到,该命令有3个部分组成:spike、./obj/riscv-pke以及./obj/app_helloworld。其中spike是我们在[第二章](chapter2_installation.md)安装的RISC-V模拟器,执行它的效果是模拟出一个支持RV64G指令集的RISC-V机器;./obj/riscv-pke是我们的PKE代理内核;而./obj/app_helloworld是我们在lab1_1中给定的应用。 + +那么代理内核是如何在spike模拟的RISC-V机器上启动的呢?实际上,这个启动过程比我们实际的物理机的启动过程简单得多,*代理内核实际上是spike模拟器将其当作是一个标准ELF文件载入的*。那么既然是“可执行”的ELF文件,我们就可以用交叉编译器里提供的工具观察它的结构: + +``` +$ riscv64-unknown-elf-readelf -h ./obj/riscv-pke +ELF Header: + Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 + Class: ELF64 + Data: 2's complement, little endian + Version: 1 (current) + OS/ABI: UNIX - System V + ABI Version: 0 + Type: EXEC (Executable file) + Machine: RISC-V + Version: 0x1 + Entry point address: 0x80000548 + Start of program headers: 64 (bytes into file) + Start of section headers: 130760 (bytes into file) + Flags: 0x5, RVC, double-float ABI + Size of this header: 64 (bytes) + Size of program headers: 56 (bytes) + Number of program headers: 2 + Size of section headers: 64 (bytes) + Number of section headers: 18 + Section header string table index: 17 + +$ riscv64-unknown-elf-readelf -l ./obj/riscv-pke + +Elf file type is EXEC (Executable file) +Entry point 0x80000548 +There are 2 program headers, starting at offset 64 + +Program Headers: + Type Offset VirtAddr PhysAddr + FileSiz MemSiz Flags Align + LOAD 0x0000000000001000 0x0000000080000000 0x0000000080000000 + 0x0000000000003564 0x0000000000003564 R E 0x1000 + LOAD 0x0000000000005000 0x0000000080004000 0x0000000080004000 + 0x0000000000001411 0x00000000000098b8 RW 0x1000 + + Section to Segment mapping: + Segment Sections... + 00 .text .rodata + 01 .htif .data .bss +``` + +通过以上命令和输出,我们可以得知./obj/riscv-pke这个ELF文件的入口地址是0x80000548,且它有两个程序段(segment),分别是00号代码段和01号数据段。其中00号代码段的段首地址是0x80000000,长度是0x3564;01号数据段的段首地址是0x80004000,长度是0x98b8。 + +那么为什么./obj/riscv-pke的段首地址是0x80000000呢?这是问题通过阅读kernel/kernel.lds就一目了然了,因为后者规定了`. = 0x80000000`即所有虚地址从0x80000000开始。更进一步,为什么要通过将逻辑地址的开始固定在0x80000000这个位置呢?这是因为spike在模拟一个RISC-V机器时,会将其模拟的内存(可通过spike的-m开关指定大小)放置在0x80000000这个物理地址开始的地方。例如,默认情况下spike会为它创建的RISC-V机器模拟2GB(2^31,0x80000000)内存,此时,有效物理内存的范围就为[0x80000000, 0xffffffff]。这样,我们就发现了一个“巧合”,即代理内核的虚地址起始地址=物理内存的起始地址!有了这个巧合,spike就只需要将代理内核的两个段加载到RISC-V机器内存开始的地方,最后将控制权交给代理内核(将代理内核的入口地址写到epc中)即可。同时,由于代理内核在编译的时候默认的起始地址也是0x80000000,所以它的执行无需做任何虚拟地址到物理地址的转换,且不会发生错误。 + +另外,./obj/riscv-pke的入口地址0x80000548对应代码中的哪个函数呢?这给我们也可以通过阅读kernel/kernel.lds得知: + +``` + 1 /* See LICENSE for license details. */ + 2 + 3 OUTPUT_ARCH( "riscv" ) + 4 + 5 ENTRY( _mentry ) + 6 + 7 SECTIONS + 8 { + 9 + 10 /*--------------------------------------------------------------------*/ + 11 /* Code and read-only segment */ + 12 /*--------------------------------------------------------------------*/ + 13 + 14 /* Begining of code and text segment, starts from DRAM_BASE to be effective before enabling paging */ + 15 . = 0x80000000; + 16 _ftext = .; + 17 + 18 /* text: Program code section */ + 19 .text : + 20 { + 21 *(.text) + 22 *(.text.*) + 23 *(.gnu.linkonce.t.*) + 24 . = ALIGN(0x1000); + 25 + 26 _trap_sec_start = .; + 27 *(trapsec) + 28 . = ALIGN(0x1000); + 29 /* ASSERT(. - _trap_sec_start == 0x1000, "error: trap section larger than one page"); */ + 30 } +``` + +以上列出了部分kernel/kernel.lds的内容,从第5行可知内核的入口地址是_mentry 函数,通过: + +``` +$ riscv64-unknown-elf-objdump -D ./obj/riscv-pke | grep _mentry +0000000080000548 <_mentry>: +``` + +我们进一步确定了代理内核的入口,即_mentry函数。 + +实际上_mentry函数是在kernel/machine/mentry.S文件中定义的: + +``` + 10 .globl _mentry + 11 _mentry: + 12 csrw mscratch, x0 # [mscratch] = 0; mscratch points the stack bottom of machine mode computer + 13 + 14 # following codes allocate a 4096-byte stack for each HART, although we use only ONE HART in this lab. + 15 la sp, stack0 # stack0 is statically defined in kernel/machine/minit.c + 16 li a3, 4096 # 4096-byte stack + 17 csrr a4, mhartid # [mhartid] = core ID + 18 addi a4, a4, 1 + 19 mul a3, a3, a4 + 20 add sp, sp, a3 # re-arrange the stack points so that they don't overlap with each other + 21 + 22 # jump to mstart(), i.e., machine state start function in kernel/machine/minit.c + 23 call m_start +``` + +它的执行将机器复位(12行)为在不同处理器上(我们在lab1_1中只考虑单个内核)运行的内核分配大小为4KB的栈(15--20行),并在最后(23行)调用m_start函数。m_start函数是在kernel/machine/minit.c文件中定义的: + +``` + 88 void m_start(uintptr_t hartid, uintptr_t dtb) { + 89 // init the spike file interface (stdin,stdout,stderr) + 90 spike_file_init(); + 91 sprint("In m_start, hartid:%d\n", hartid); + 92 + 93 // init HTIF (Host-Target InterFace) and memory by using the Device Table Blob (DTB) + 94 init_dtb(dtb); + 95 + 96 setup_pmp(); + 97 + 98 // set previous privilege mode to S (Supervisor), and will enter S mode after 'mret' + 99 write_csr(mstatus, ((read_csr(mstatus) & ~MSTATUS_MPP_MASK) | MSTATUS_MPP_S)); +100 +101 // set M Exception Program Counter to sstart, for mret (requires gcc -mcmodel=medany) +102 write_csr(mepc, (uint64)s_start); +103 +104 // delegate all interrupts and exceptions to supervisor mode. +105 delegate_traps(); +106 +107 // switch to supervisor mode and jump to s_start(), i.e., set pc to mepc +108 asm volatile("mret"); +109 } +``` + +它的作用是首先初始化spike的客户机-主机接口(Host-Target InterFace,简称HTIF),以及承载于其上的文件接口(90-94行);其次,设置物理内存保护physical memory protection(简称pmp,第96行。pmp机制类似操作系统原理课程中学习的上下界保护法,原理上是采用上下界的办法将物理内存分为多个区间,规定软件在这些区间里的权限。);人为的将上一个状态(机器启动时的状态为M态,即Machine态)设置为S(Supervisor)态,并将“退回”到S态的函数指针s_start写到mepc寄存器中(99--102行);接下来,将中断异常处理“代理”给S态(105行);最后,执行返回动作(108行)。由于之前人为地将上一个状态设置为S态,所以108行的返回动作将“返回”S态,并进入s_start函数执行。 + +s_start函数在kernel/kernel.c文件中定义: + +``` + 28 int s_start(void) { + 29 process user_app; + 30 + 31 sprint("Enter supervisor mode...\n"); + 32 // Note: we use direct (i.e., Bare mode) for memory mapping in lab1. + 33 // which means: Virtual Address = Physical Address + 34 write_csr(satp, 0); + 35 + 36 // the application code (elf) is first loaded into memory, and then put into execution + 37 load_user_program(&user_app); + 38 + 39 sprint("Switching to user mode...\n"); + 40 switch_to(&user_app); + 41 + 42 return 0; + 43 } +``` + +该函数的动作也非常简单:首先将地址映射模式置为(34行)直映射模式(Bare mode),接下来调用(37行)load_user_program()函数,将应用(也就是最开始的命令行中的./obj/app_helloworld)载入内存,封装成一个最简单的“进程(process)”,最终调用switch_to()函数,将这个简单得不能再简单的进程投入运行。 + +以上过程中,load_user_program()函数的作用是将我们的给定应用(user/app_helloworld.c)所对应的可执行ELF文件(即./obj/app_helloworld文件),这个过程我们将在3.1.5中详细讨论。另一个函数是switch_to(),为了理解这个函数的行为,需要先对lab1中“进程”的定义有一定的了解(kernel/process.h): + +``` + 17 typedef struct process { + 18 // pointing to the stack used in trap handling. + 19 uint64 kstack; + 20 // trapframe storing the context of a (User mode) process. + 21 trapframe* trapframe; + 22 }process; +``` + +可以看到,lab1中定义的“进程”非常简单,它只包含了一个栈指针(kstack)以及一个指向trapframe结构的指针。trapframe结构也在kernel/process.h文件中被定义: + +``` + 6 typedef struct trapframe { + 7 /* 0 */ uint64 kernel_satp; // kernel page table(unused now) + 8 /* 8 */ uint64 kernel_sp; // top of process's kernel stack + 9 /* 16 */ uint64 kernel_trap; // usertrap() + 10 /* 24 */ uint64 epc; // saved user process counter + 11 /* 32 */ uint64 kernel_hartid; // saved kernel tp(unused now) + 12 // starting from here, space to store context (all common registers) + 13 riscv_regs regs; + 14 }trapframe; +``` + +该结构除了记录进程上下文的RISC-V机器的通用寄存器组(regs成员)外,还包括很少的其他成员(如指向内核态栈顶的kernel_sp,指向内核态trap处理函数入口的kernel_trap指针,进程执行的当前位置epc)。 + +回到switch_to()函数,它在kernel/process.c文件中定义: + +``` + 28 void switch_to(process* proc) { + 29 assert(proc); + 30 current = proc; + 31 + 32 write_csr(stvec, (uint64)smode_trap_vector); + 33 // set up trapframe values that smode_trap_vector will need when + 34 // the process next re-enters the kernel. + 35 proc->trapframe->kernel_sp = proc->kstack; // process's kernel stack + 36 proc->trapframe->kernel_trap = (uint64)smode_trap_handler; + 37 + 38 // set up the registers that strap_vector.S's sret will use + 39 // to get to user space. + 40 + 41 // set S Previous Privilege mode to User. + 42 unsigned long x = read_csr(sstatus); + 43 x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode + 44 x |= SSTATUS_SPIE; // enable interrupts in user mode + 45 + 46 write_csr(sstatus, x); + 47 + 48 // set S Exception Program Counter to the saved user pc. + 49 write_csr(sepc, proc->trapframe->epc); + 50 + 51 // switch to user mode with sret. + 52 return_to_user(proc->trapframe); + 53 } +``` + +可以看到,该函数的作用是初始化进程的process结构体,并最终调用return_to_user(proc->trapframe)函数将载入的应用(所封装的进程)投入运行。return_to_user()函数在kernel/strap_vector.S文件中定义: + +``` + 43 .globl return_to_user + 44 return_to_user: + 45 # save a0 in sscratch, so sscratch points to a trapframe now. + 46 csrw sscratch, a0 + 47 + 48 # bypass the first 5 members of structure trapframe + 49 addi t6, a0, 40 + 50 + 51 # restore all registers from trapframe, so as to resort the execution of a process + 52 restore_all_registers + 53 + 54 # return to user mode and user pc (using the first 5 members of structure trapframe). + 55 sret +``` + +其作用是恢复进程的上下文(52行)到RISC-V机器的所有寄存器,并调用sret指令,从S模式“返回”应用模式(即U模式)。这样,所载入的应用程序(即obj/app_helloworld所对应的“进程”)就投入运行了。 + + + + + +### 3.1.6 ELF文件(app)的加载过程 + +这里我们对load_user_program()函数进行讨论,它在kernel/kernel.c中定义: + +``` + 16 void load_user_program(process *proc) { + 17 proc->trapframe = (trapframe *)USER_TRAP_FRAME; + 18 memset(proc->trapframe, 0, sizeof(trapframe)); + 19 proc->kstack = USER_KSTACK; + 20 proc->trapframe->regs.sp = USER_STACK; + 21 + 22 load_bincode_from_host_elf(proc); + 23 } +``` + +我们看到,它的作用是首先对进程壳做了一定的初始化,最后调用load_bincode_from_host_elf()函数将应用程序对应的二进制代码实际地载入。load_bincode_from_host_elf()函数在kernel/elf.c文件中实际定义: + +``` +108 void load_bincode_from_host_elf(struct process *p) { +109 arg_buf arg_bug_msg; +110 +111 // retrieve command line arguements +112 size_t argc = parse_args(&arg_bug_msg); +113 if (!argc) panic("You need to specify the application program!\n"); +114 +115 sprint("Application: %s\n", arg_bug_msg.argv[0]); +116 +117 //elf loading +118 elf_ctx elfloader; +119 elf_info info; +120 +121 info.f = spike_file_open(arg_bug_msg.argv[0], O_RDONLY, 0); +122 info.p = p; +123 if (IS_ERR_VALUE(info.f)) panic("Fail on openning the input application program.\n"); +124 +125 // init elfloader +126 if (elf_init(&elfloader, &info) != EL_OK) +127 panic("fail to init elfloader.\n"); +128 +129 // load elf +130 if (elf_load(&elfloader) != EL_OK) panic("Fail on loading elf.\n"); +131 +132 // entry (virtual) address +133 p->trapframe->epc = elfloader.ehdr.entry; +134 +135 // close host file +136 spike_file_close( info.f ); +137 +138 sprint("Application program entry point (virtual address): 0x%lx\n", p->trapframe->epc); +139 } +``` + +该函数的大致过程是: + +- (112--115行)首先,解析命令行参数,获得需要加载的ELF文件文件名; +- (118--127行)接下来初始化ELF加载数据结构,并打开即将被加载的ELF文件; +- (130行)加载ELF文件; +- (133行)通过ELF文件提供的入口地址设置进程的trapframe->epc,保证“返回”用户态的时候,所加载的ELF文件被执行; +- (136--139行)关闭ELF文件并返回。 + +该函数用到了同文件中的诸多工具函数,这些函数的细节请读者自行阅读相关代码,这里我们只贴我们认为重要的代码: + +- spike_file_open/spike_file_close:这两个函数在spike_interface/spike_file.c文件中定义,它的作用是通过spike的HTIF接口打开/关闭位于主机上的ELF文件。spike的HTIF接口相关知识,我们将在3.1.7中做简要的讨论; +- elf_init:该函数的作用是初始化elf_ctx类型的elfloader结构体。该初始化过程将读取给定ELF的文件头,确保它是一个正确的ELF文件; +- elf_load:读入ELF文件中所包含的程序段(segment)到给定的内存地址中。elf_load的具体实现如下: + +``` + 51 elf_status elf_load(elf_ctx *ctx) { + 52 elf_prog_header ph_addr; + 53 int i, off; + 54 + 55 sprint( "elf_load: ctx->ehdr.phoff = %d, ctx->ehdr.phnum =%d, sizeof(ph_addr)=%d.\n", + 56 ctx->ehdr.phoff, ctx->ehdr.phnum, sizeof(ph_addr) ); + 57 // traverse the elf program segment headers + 58 for (i = 0, off = ctx->ehdr.phoff; i < ctx->ehdr.phnum; i++, off += sizeof(ph_addr)) { + 59 // read segment headers + 60 if (elf_fpread(ctx, (void *)&ph_addr, sizeof(ph_addr), off) != sizeof(ph_addr)) return EL_EIO; + 61 + 62 if (ph_addr.type != ELF_PROG_LOAD) continue; + 63 if (ph_addr.memsz < ph_addr.filesz) return EL_ERR; + 64 if (ph_addr.vaddr + ph_addr.memsz < ph_addr.vaddr) return EL_ERR; + 65 + 66 // allocate memory before loading + 67 void *dest = elf_alloccb(ctx, ph_addr.vaddr, ph_addr.vaddr, ph_addr.memsz); + 68 + 69 sprint( "elf_load: ph_addr.memsz = %d, ph_addr.off =%d.\n", ph_addr.memsz, ph_addr.off ); + 70 + 71 // actual loading + 72 if (elf_fpread(ctx, dest, ph_addr.memsz, ph_addr.off) != ph_addr.memsz) + 73 return EL_EIO; + 74 } + 75 + 76 return EL_OK; + 77 } +``` + +这个函数里,我们需要说一下elf_alloccb()函数,该函数返回代码段将要加载进入的地址dest。由于我们在lab1全面采用了直地址映射模式(Bare mode,也就是说:逻辑地址=物理地址),对于lab1全系列的实验来说,elf_alloccb()返回的装载地址实际上就是物理地址。 + +``` + 19 static void *elf_alloccb(elf_ctx *ctx, uint64 elf_pa, uint64 elf_va, uint64 size) { + 20 // directly returns the virtual address as we are in the Bare mode memory mapping in lab1 + 21 return (void *)elf_va; + 22 } +``` + +但是,到了实验二(lab2系列),我们将开启RISC-V的分页模式(sv39),届时elf_alloccb函数将发生变化。 + + + + + +### 3.1.7 spike的HTIF接口 + +spike提供的HTIF(Host-Target InterFace)接口的原理可以用下图说明: + +fig2_install_1 + +图2.1 HTIF的原理 + +我们知道spike是运行在主机(host)上的,在创建目标(target)机器的时候,spike模拟器将自身的一段内存(图中的HTIF内存段)提供给目标机。PKE操作系统内核在构造时引入了这段内存(参考kernel/kernel.lds文件),在启动时通过目标机器传给PKE操作系统内核的设备参数(DTS,Device Tree String)中发现这段内存;接下来通过spike_interface目录中的代码对这段内存进行初始化和封装动作,将其包装成一组可以由操作系统调用的函数调用,典型的如sprint、spike_file_open、spike_file_close这些;最后PKE操作系统内核就能够直接使用这些封装后的函数调用完成对模拟硬件的操纵,如打印字符串到屏幕、访问主机上的文件或设备这些动作。 + +spike基于HTIF内存的传递,定义了一组HTIF调用(类似于操作系统的系统调用的概念),每个HTIF调用实现一个既定的功能。在使用时,PKE内核可以通过设定往HTIF内存中写入特定的HTIF调用号,spike在从HTIF内存中获得调用号后就会执行调用号对应的动作,并传递返回值到HTIF内存中。最后,PKE操作系统内核通过读取HTIF中的信息获得返回值的内容。 + +对于PKE实验而言,利用HTIF接口可以屏蔽一些操作系统底层功能(如设备操纵)的实现,这些功能往往是跟具体的硬件实现有关(不同平台还不一样),往往用汇编语言书写,阅读起来非常枯燥也很难读懂。更重要的,这些实现细节跟操作系统可能并无关系!采用HTIF这种形式,用高级语言(C语言)来书写这段代码(spike_interface目录下的文件)会简单易懂,满足操作系统原理课程的教学要求。 + +另外需要注意的是,**HTIF机制并不是纯粹为满足教学要求而写的“玩具”**。我们在ZedBoard这种开发板的PL(Programmable Logic)部分部署我们开发的RISC-V处理器时,为了检验所开发的处理器对操作系统的支持能力,往往会在RISC-V处理器上启动代理内核(可以直接用我们在实验中开发的PKE代理内核),而后者就是通过HTIF的机制完成与主机(即在PS上运行的ARM Linux)的交互的。 + + + + + +## 3.2 lab1_1 系统调用 + + +#### **给定应用** +- user/app_helloworld.c + +``` + 1 /* + 2 * Below is the given application for lab1_1. + 3 * + 4 * You can build this app (as well as our PKE OS kernel) by command: + 5 * $ make + 6 * + 7 * Or run this app (with the support from PKE OS kernel) by command: + 8 * $ make run + 9 */ + 10 + 11 #include "user_lib.h" + 12 + 13 int main(void) { + 14 printu("Hello world!\n"); + 15 + 16 exit(0); + 17 } +``` + +应用实现的功能非常简单,即在屏幕上打印"Hello world!\n"。 + +- make后的直接运行结果: + +``` +$ spike ./obj/riscv-pke ./obj/app_helloworld +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_helloworld +elf_load: ctx->ehdr.phoff = 64, ctx->ehdr.phnum =1, sizeof(ph_addr)=56. +elf_load: ph_addr.memsz = 744, ph_addr.off =4096. +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +call do_syscall to accomplish the syscall and lab1_1 here. + +System is shutting down with exit code -1. +``` + +从结果上来看,并未达到我们的预期结果,即在屏幕上输出"Hello world!\n"。 + + + +#### **实验内容** + +如输出提示所表示的那样,需要找到并完成对do_syscall的调用,并获得以下预期结果: + +``` +$ spike ./obj/riscv-pke ./obj/app_helloworld +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_helloworld +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +Hello world! +User exit with code:0. +System is shutting down with exit code 0. +``` + + + +#### **实验指导** + +lab1_1实验需要读者了解和掌握操作系统中系统调用机制的实现原理。从应用出发,我们发现user/app_helloworld.c文件中有两个函数调用:printu和exit。对代码进行跟踪,我们发现这两个函数都在user/user_lib.c中进行了实现,同时,这两个函数最后都转换成了对do_user_call的调用。查看do_user_call函数的实现: + +``` + 13 int do_user_call(uint64 sysnum, uint64 a1, uint64 a2, uint64 a3, uint64 a4, uint64 a5, uint64 a6, + 14 uint64 a7) { + 15 int ret; + 16 + 17 // before invoking the syscall, arguments of do_user_call are already loaded into the argument + 18 // registers (a0-a7) of our (emulated) risc-v machine. + 19 asm volatile( + 20 "ecall\n" + 21 "sw a0, %0" // returns a 32-bit value + 22 : "=m"(ret) + 23 : + 24 : "memory"); + 25 + 26 return ret; + 27 } +``` + +我们发现,do_user_call函数是通过ecall指令完成系统调用的,且在执行ecall指令前,所有的参数(即do_user_call函数的8个参数)实际上都已经载入到RISC-V机器的a0到a7这8个寄存器中(这一步是我们的编译器生成的代码帮我们完成的)。ecall指令的执行将根据a0中的值获得系统调用号,并使RISC-V转到S模式(因为我们的操作系统内核启动时将所有的中断、异常、系统调用都代理给了S模式)的trap处理入口执行(在kernel/strap_vector.S文件中定义): + +``` + 14 .globl smode_trap_vector + 15 .align 4 + 16 smode_trap_vector: + 17 # swap a0 and sscratch, so that points a0 to the trapframe of current process + 18 csrrw a0, sscratch, a0 + 19 + 20 # save the user registers in the trapframe of current process, refers to kernel/process.h + 21 # bypass the first 5 members of structure trapframe + 22 addi t6, a0 , 40 + 23 store_all_registers + 24 + 25 # come back to save a0 register before entering trap handling in trapframe + 26 csrr t0, sscratch + 27 sd t0, 112(a0) + 28 + 29 # restore kernel stack pointer from p->trapframe->kernel_sp + 30 ld sp, 8(a0) + 31 + 32 # load the address of smode_trap_handler() from p->trapframe->kernel_trap + 33 ld t0, 16(a0) + 34 + 35 # jump to smode_trap_handler() in kernel/trap.c + 36 jr t0 +``` + +从以上代码我们可以看到,trap的入口处理函数首先将“进程”(即我们的obj/app_helloworld的运行现场)进行保存(第23行);接下来将a0寄存器中的系统调用号保存到内核堆栈(第26--27行),再将p->trapframe->kernel_sp指向的为应用进程分配的内核栈设置到sp寄存器(第30行,即切换堆栈,而不使用PKE内核自己的栈,**这里请读者思考为何要这样安排?**),后续的执行将使用应用进程所附带的内核栈来保存执行的上下文,如函数调用、临时变量这些;最后,将应用进程中的p->trapframe->kernel_trap写入t0寄存器(第33行),并最后(第36行)调用p->trapframe->kernel_trap所指向的smode_trap_handler()函数。 + +smode_trap_handler()函数的定义在kernel/strap.c文件中,采用C语言编写: + +``` + 30 void smode_trap_handler(void) { + 31 // make sure we are in User mode before entering the trap handling. + 32 // we will consider other previous case in lab1_3 (interrupt). + 33 if ((read_csr(sstatus) & SSTATUS_SPP) != 0) panic("usertrap: not from user mode"); + 34 + 35 assert(current); + 36 // save user process counter. + 37 current->trapframe->epc = read_csr(sepc); + 38 + 39 // if the cause of trap is syscall from user application + 40 if (read_csr(scause) == CAUSE_USER_ECALL) { + 41 handle_syscall(current->trapframe); + 42 } else { + 43 sprint("smode_trap_handler(): unexpected scause %p\n", read_csr(scause)); + 44 sprint(" sepc=%p stval=%p\n", read_csr(sepc), read_csr(stval)); + 45 } + 46 + 47 // continue the execution of process. but in lab1_1, we have only one process... + 48 switch_to(current); + 49 } +``` + +该函数首先在第33行,对进入当前特权级模式(S模式)之前的模式进行判断,确保进入前是用户模式(U模式);接下来在第37行,保存发生系统调用的指令地址;进一步判断(第40--45行的if...else...语句)导致进入当前模式的原因,如果是系统调用的话(read_csr(scause) == CAUSE_USER_ECALL)就执行handle_syscall()函数,但如果是其他原因(对于其他原因的处理,我们将在后续实验中进一步完善)的话,就打印出错信息并推出;最后,在第48行调用switch_to()函数返回当前进程。 + +handle_syscall()函数的定义也在kernel/strap.c文件中: + +``` + 15 static void handle_syscall(trapframe *tf) { + 16 // tf->epc points to the address that our computer will jump to after the trap handling. + 17 // for a syscall, we should return to the NEXT instruction after its handling. + 18 // in RV64G, each instruction occupies exactly 32 bits (i.e., 4 Bytes) + 19 tf->epc += 4; + 20 + 21 // TODO (lab1_1): remove the panic call below, and use do_syscall (defined in kernel/syscall.c) to + 22 // accomplish the syscall. return value should be written to a0 register in trapframe (regs.a0) + 23 panic( "call do_syscall to accomplish the syscall and lab1_1 here.\n" ); + 24 + 25 } +``` + +看到第23行,我们就应该明白为什么在make后的直接运行结果中出现`call do_syscall to accomplish the syscall and lab1_1 here.`这行的输出了,那是panic的输出结果。所以为了完成lab1_1,就应该把panic语句删掉,换成对do_syscall()函数的调用!其实完成这个实验非常简单,但需要读者完成以上所述的代码跟踪,了解PKE操作系统内核处理系统调用的流程。 + +那么do_syscall()函数是在哪里定义的呢?实际上这个函数在kernel/syscall.c文件中,已经帮大家写好了: + +``` + 39 long do_syscall(long a0, long a1, long a2, long a3, long a4, long a5, long a6, long a7) { + 40 switch (a0) { + 41 case SYS_user_print: + 42 return sys_user_print((const char*)a1, a2); + 43 case SYS_user_exit: + 44 return sys_user_exit(a1); + 45 default: + 46 panic("Unknown syscall %ld \n", a0); + 47 } + 48 } +``` + +但是,做实验的时候,需要读者思考在handle_syscall()函数中调用do_syscall()函数,后者的参数怎么办?毕竟有8个long类型(因为我们的机器是RV64G,long类型占据8个字节)的参数,另外,do_syscall()函数的返回值怎么处理?毕竟do_syscall()函数有一个long类型的返回值,而这个返回值是要通知应用程序它发出的系统调用是否成功的。 + +除了实验内容之外,在handle_syscall()函数的第19行,有一个`tf->epc += 4;`语句,**这里请读者思考为什么要将tf->epc的值进行加4处理?**这个问题请结合你对RISC-V指令集架构的理解,以及系统调用的原理回答。 + + + +完成以上实验后,就能够获得以下结果输出了: + +``` +$ spike ./obj/riscv-pke ./obj/app_helloworld +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_helloworld +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +Hello world! +User exit with code:0. +System is shutting down with exit code 0. +``` + + + +实验完毕后,记得提交修改(命令行中-m后的字符串可自行确定),以便在后续实验中继承lab1_1中所做的工作: + +``` +$ git commit -a -m "my work on lab1_1 is done." +``` + + + + + +## 3.3 lab1_2 异常处理 + + + +#### **给定应用** + +- user/app_illegal_instruction.c + +``` + 1 /* + 2 * Below is the given application for lab1_2. + 3 * This app attempts to issue M-mode instruction in U-mode, and consequently raises an exception. + 4 */ + 5 + 6 #include "user_lib.h" + 7 #include "util/types.h" + 8 + 9 int main(void) { + 10 printu("Going to hack the system by running privilege instructions.\n"); + 11 // we are now in U(user)-mode, but the "csrw" instruction requires M-mode privilege. + 12 // Attempting to execute such instruction will raise illegal instruction exception. + 13 asm volatile("csrw sscratch, 0"); + 14 exit(0); + 15 } +``` + +(在用户U模式下执行的)应用企图执行RISC-V的特权指令csrw sscratch, 0。该指令会修改S模式的栈指针,如果允许该指令的执行,执行的结果可能会导致系统崩溃。 + +- 切换到lab1_2、继承lab1_1中所做修改,并make后的直接运行结果: + +``` +//切换到lab1_2 +$ git checkout lab1_2_exception + +//继承lab1_1的答案 +$ git merge lab1_1_syscall -m "continue to work on lab1_2" + +//重新构造 +$ make clean; make + +//运行构造结果 +$ spike ./obj/riscv-pke ./obj/app_illegal_instruction +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_illegal_instruction +elf_load: ctx->ehdr.phoff = 64, ctx->ehdr.phnum =1, sizeof(ph_addr)=56. +elf_load: ph_addr.memsz = 800, ph_addr.off =4096. +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +Going to hack the system by running privilege instructions. +call handle_illegal_instruction to accomplish illegal instruction interception of lab1_2. + +System is shutting down with exit code -1. +``` + +以上输出中,由于篇幅的关系,我们隐藏了前三个命令的输出结果。 + + + +#### **实验内容** + +如输出所提示的那样,通过调用handle_illegal_instruction函数完成异常指令处理,阻止app_illegal_instruction的执行。 + +``` +$ spike ./obj/riscv-pke ./obj/app_illegal_instruction +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_illegal_instruction +Application program entry point (virtual address): 0x0000000081000000 +Switching to user mode... +Going to hack the system by running privilege instructions. +Illegal instruction! +System is shutting down with exit code -1. +``` + + + +#### **实验指导** + +lab1_2实验需要读者了解和掌握操作系统中异常(exception)的产生原理以及处理的原则。从应用出发,我们发现user/app_illegal_instruction.c文件中,我们的应用程序“企图”执行不能在用户模式(U模式)运行的特权级指令:`csrw sscratch, 0` + +显然,这种企图破坏了RISC-V机器以及操作系统的设计原则,对于机器而言该事件并不是它期望(它期望在用户模式下执行的都是用户模式的指令)发生的“异常事件”,需要介入和破坏该应用程序的执行。查找RISC-V体系结构的相关文档,我们知道,这类异常属于非法指令异常,即CAUSE_ILLEGAL_INSTRUCTION,它对应的异常码是02(见kernel/riscv.h中的定义)。那么,当RISC-V机器截获了这类异常后,该将它交付给谁来处理呢? + +通过[3.1.5](#subsec_booting)节的阅读,我们知道PKE操作系统内核在启动时会将部分异常和中断“代理”给S模式处理,但是它是否将CAUSE_ILLEGAL_INSTRUCTION这类异常也进行了代理呢?这就要研究m_start()函数在执行delegate_traps()函数时设置的代理规则了,我们先查看delegate_traps()函数的代码,在kernel/machine/minit.c文件中找到它对应的代码: + +``` + 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 } +``` + +在第58--61行的代码中,delegate_traps()函数确实将部分异常代理给了S模式处理,但是里面并没有我们关心的CAUSE_ILLEGAL_INSTRUCTION异常,这说明该异常的处理还是交给M模式来处理(实际上,对于spike模拟的RISC-V平台而言,CAUSE_ILLEGAL_INSTRUCTION异常*必须*在M态处理)!所以,我们需要了解M模式的trap处理入口,以便继续跟踪其后的处理过程。M模式的trap处理入口在kernel/machine/mtrap_vector.S文件中(PKE操作系统内核在启动时(kernel/machine/minit.c文件的第125行`write_csr(mtvec, (uint64)mtrapvec);`)已经将M模式的中断处理入口指向了该函数): + +``` + 8 mtrapvec: + 9 # swap a0 and mscratch + 10 # so that a0 points to interrupt frame + 11 csrrw a0, mscratch, a0 + 12 + 13 # save the registers in interrupt frame + 14 addi t6, a0, 0 + 15 store_all_registers + 16 # save the user a0 in itrframe->a0 + 17 csrr t0, mscratch + 18 sd t0, 72(a0) + 19 + 20 # use stack0 for sp + 21 la sp, stack0 + 22 li a3, 4096 + 23 csrr a4, mhartid + 24 addi a4, a4, 1 + 25 mul a3, a3, a4 + 26 add sp, sp, a3 + 27 + 28 // save the address of interrupt frame in the csr "mscratch" + 29 csrw mscratch, a0 + 30 + 31 call handle_mtrap + 32 + 33 // restore all registers + 34 csrr t6, mscratch + 35 restore_all_registers + 36 + 37 mret +``` + +可以看到,mtrapvec汇编函数首先会将a0和mscratch交换,而mscratch之前保护的是g_itrframe的地址(g_itrframe的定义在kernel/machine/minit.c的第27行`struct riscv_regs g_itrframe;`,也就是说g_itrframe是一个包含所有RISC-V通用寄存器的栈帧)。接下来,将t6赋值为a0的值(第14行),并将所有通用寄存器保存到t6寄存器所指定首地址的内存区域(该动作由第15行的store_all_registers完成)。这里因为t0=a0=mstratch,所以通用寄存器最终是保存到了mstratch所指向的内存区域,也就是g_itrframe中。第17-18行是保护进入中断处理前a0寄存器的值到g_itrframe。 + +接下来,mtrapvec汇编函数在第21--26行切换栈到stack0(即PKE内核启动时用过的栈),并在31行调用handle_mtrap()函数。handle_mtrap()函数在kernel/machine/mtrap.c文件中定义: + +``` + 20 void handle_mtrap() { + 21 uint64 mcause = read_csr(mcause); + 22 switch (mcause) { + 23 case CAUSE_FETCH_ACCESS: + 24 handle_instruction_access_fault(); + 25 break; + 26 case CAUSE_LOAD_ACCESS: + 27 handle_load_access_fault(); + 28 case CAUSE_STORE_ACCESS: + 29 handle_store_access_fault(); + 30 break; + 31 case CAUSE_ILLEGAL_INSTRUCTION: + 32 //TODO (lab1_2): call handle_illegal_instruction to implement illegal instruction interception + 33 // and finish lab1_2 + 34 panic( "call handle_illegal_instruction to accomplish illegal instruction interception of lab1_2.\n" ); + 35 + 36 break; + 37 case CAUSE_MISALIGNED_LOAD: + 38 handle_misaligned_load(); + 39 break; + 40 case CAUSE_MISALIGNED_STORE: + 41 handle_misaligned_store(); + 42 break; + 43 + 44 default: + 45 sprint("machine trap(): unexpected mscause %p\n", mcause); + 46 sprint(" mepc=%p mtval=%p\n", read_csr(mepc), read_csr(mtval)); + 47 break; + 48 } + 49 } +``` + +可以看到,handle_mtrap()函数对在M态处理的多项异常都进行了处理,处理的方式几乎全部是调用panic函数,让(模拟)机器停机。对于CAUSE_ILLEGAL_INSTRUCTION尚未处理,所以这里你可以将第34行的panic函数替换成对handle_illegal_instruction()函数的调用,已完成lab1_2。 + +需要注意的是,因为对于PKE而言,它只需要一次执行一个应用程序即可,所以我们可以调用panic让(模拟)RISC-V机器停机,但是如果是实际的硬件机器场景,就要想办法将发生被handle_mtrap()函数所处理异常的应用进程销毁掉。 + + + +## 3.4 lab1_3 (外部)中断 + + + +#### **给定应用** +- user/app_long_loop.c + +``` + 1 /* + 2 * Below is the given application for lab1_3. + 3 * This app performs a long loop, during which, timers are generated and pop messages to our screen. + 4 */ + 5 + 6 #include "user_lib.h" + 7 #include "util/types.h" + 8 + 9 int main(void) { + 10 printu("Hello world!\n"); + 11 int i; + 12 for (i = 0; i < 100000000; ++i) { + 13 if (i % 5000000 == 0) printu("wait %d\n", i); + 14 } + 15 + 16 exit(0); + 17 + 18 return 0; + 19 } +``` + +应用的程序逻辑包含一个长度为100000000次的循环,循环每次将整型变量i加一,当i的值是5000000的整数倍时,输出"wait i的值\n"。这个循环程序在我们的(模拟)RISC-V平台上运行,显然将消耗一定时间(实际上,你也可以把这个程序改成死循环,但并不会死机!**请读者做完lab1_3的实验后思考为什么死循环并不会导致死机**。)。 + + + +- 切换到lab1_3、继承lab1_2中所做修改,并make后的直接运行结果: + +``` +//切换到lab1_3 +$ git checkout lab1_3_irq + +//继承lab1_2以及之前的答案 +$ git merge lab1_2_exception -m "continue to work on lab1_3" + +//重新构造 +$ make clean; make + +//运行构造结果 +$ spike ./obj/riscv-pke ./obj/app_long_loop +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: ./obj/app_long_loop +Application program entry point (virtual address): 0x000000008100007e +Switching to user mode... +Hello world! +wait 0 +wait 5000000 +wait 10000000 +Ticks 0 +lab1_3: increase g_ticks by one, and clear SIP field in sip register. + +System is shutting down with exit code -1. +``` + +以上输出中,由于篇幅的关系,我们隐藏了前三个命令的输出结果。 + +从以上程序的运行结果来看,给定的程序并不是“不受干扰”地从开始运行到最终结束的,而是在运行过程中受到了系统的外部时钟中断(timer irq)的“干扰”!而我们在这个实验中给出的PKE操作系统内核,在时钟中断部分并未完全做好,导致(模拟)RISC-V机器碰到第一个时钟中断后就会出现崩溃。 + + + +#### 实验内容 + +完成PKE操作系统内核未完成的时钟中断处理过程,使得它能够完整地处理时钟中断。 + +实验完成后的运行结果: + +``` +$ spike ./obj/riscv-pke ./obj/app_long_loop +In m_start, hartid:0 +HTIF is available! +(Emulated) memory size: 2048 MB +Enter supervisor mode... +Application: obj/app_long_loop +Application program entry point (virtual address): 0x000000008100007e +Switching to user mode... +Hello world! +wait 0 +wait 5000000 +wait 10000000 +Ticks 0 +wait 15000000 +wait 20000000 +Ticks 1 +wait 25000000 +wait 30000000 +wait 35000000 +Ticks 2 +wait 40000000 +wait 45000000 +Ticks 3 +wait 50000000 +wait 55000000 +wait 60000000 +Ticks 4 +wait 65000000 +wait 70000000 +Ticks 5 +wait 75000000 +wait 80000000 +wait 85000000 +Ticks 6 +wait 90000000 +wait 95000000 +Ticks 7 +User exit with code:0. +System is shutting down with exit code 0. +``` + + + +#### 实验指导 + +在[上一个实验](#exception)中,我们已经接触了机器模式(M-mode)下的中断处理。在本实验中,我们接触的时钟中断也是在机器模式下触发的。为了处理时钟中断,我们的PKE代码在lab1_2的基础上,新增了以下内容: + +- 在m_start函数(也就是机器模式的初始化函数)中新增了timerinit()函数,后者的函数定义在kernel/machine/minit.c文件: + +``` + 92 void timerinit(uintptr_t hartid) { + 93 // fire timer irq after TIMER_INTERVAL from now. + 94 *(uint64*)CLINT_MTIMECMP(hartid) = *(uint64*)CLINT_MTIME + TIMER_INTERVAL; + 95 + 96 // enable machine-mode timer irq in MIE (Machine Interrupt Enable) csr. + 97 write_csr(mie, read_csr(mie) | MIE_MTIE); + 98 } +``` + +该函数首先在94行设置了下一次timer触发的时间,即当前时间的TIMER_INTERVAL(即1000000周期后,见kernel/config.h中的定义)之后。另外,在97行设置了MIE(Machine Interrupt Enable,见本书的第一章的[1.3节](chapter1_riscv.md#machinestates)和[1.4节](chapter1_riscv.md#traps))寄存器中的MIE_MTIE位,即允许我们的(模拟)RISC-V机器在M模式处理timer中断。 + +时钟中断触发后,kernel/machine/mtrap_vector.S文件中的mtrapvec函数将被调用: + +``` + 8 mtrapvec: + 9 # swap a0 and mscratch + 10 # so that a0 points to interrupt frame + 11 csrrw a0, mscratch, a0 + 12 + 13 # save the registers in interrupt frame + 14 addi t6, a0, 0 + 15 store_all_registers + 16 # save the user a0 in itrframe->a0 + 17 csrr t0, mscratch + 18 sd t0, 72(a0) + 19 + 20 # use stack0 for sp + 21 la sp, stack0 + 22 li a3, 4096 + 23 csrr a4, mhartid + 24 addi a4, a4, 1 + 25 mul a3, a3, a4 + 26 add sp, sp, a3 + 27 + 28 // save the address of interrupt frame in the csr "mscratch" + 29 csrw mscratch, a0 + 30 + 31 call handle_mtrap + 32 + 33 // restore all registers + 34 csrr t6, mscratch + 35 restore_all_registers + 36 + 37 mret +``` + +和lab1_2一样,最终将进入handle_mtrap函数继续处理。handle_mtrap函数将通过对mcause寄存器的值进行判断,确认是时钟中断(CAUSE_MTIMER)后,将调用handle_timer()函数进行进一步处理: + +``` + 17 static void handle_timer() { + 18 int cpuid = 0; + 19 // setup the timer fired at next time (TIMER_INTERVAL from now) + 20 *(uint64*)CLINT_MTIMECMP(cpuid) = *(uint64*)CLINT_MTIMECMP(cpuid) + TIMER_INTERVAL; + 21 + 22 // setup a soft interrupt in sip (S-mode Interrupt Pending) to be handled in S-mode + 23 write_csr(sip, SIP_SSIP); + 24 } + 25 + 26 // + 27 // handle_mtrap calls cooresponding functions to handle an exception of a given type. + 28 // + 29 void handle_mtrap() { + 30 uint64 mcause = read_csr(mcause); + 31 switch (mcause) { + 32 case CAUSE_MTIMER: + 33 handle_timer(); + 34 break; + 35 case CAUSE_FETCH_ACCESS: + 36 handle_instruction_access_fault(); + 37 break; + 38 case CAUSE_LOAD_ACCESS: + 39 handle_load_access_fault(); + 40 case CAUSE_STORE_ACCESS: + 41 handle_store_access_fault(); + 42 break; + 43 case CAUSE_ILLEGAL_INSTRUCTION: + 44 //TODO (lab1_2): call handle_illegal_instruction to implement illegal instruction interception + 45 // and finish lab1_2 + 46 panic( "call handle_illegal_instruction to accomplish illegal instruction interception of lab1_2.\n" ); + 47 + 48 break; + 49 case CAUSE_MISALIGNED_LOAD: + 50 handle_misaligned_load(); + 51 break; + 52 case CAUSE_MISALIGNED_STORE: + 53 handle_misaligned_store(); + 54 break; + 55 + 56 default: + 57 sprint("machine trap(): unexpected mscause %p\n", mcause); + 58 sprint(" mepc=%p mtval=%p\n", read_csr(mepc), read_csr(mtval)); + 59 break; + 60 } + 61 } +``` + +而handle_timer()函数会(在第20行)先设置下一次timer(再次)触发的时间为当前时间+TIMER_INTERVAL,并在23行对SIP(Supervisor Interrupt Pending,即S模式的中断等待寄存器)寄存器进行设置,将其中的SIP_SSIP位进行设置,完成后返回。至此,时钟中断在M态的处理就结束了,剩下的动作交给S态继续处理。而handle_timer()在第23行的动作,会导致PKE操作系统内核在S模式收到一个来自M态的时钟中断请求(CAUSE_MTIMER_S_TRAP)。 + +那么为什么操作系统内核不在M态就完成对时钟中断的处理,而一定要将它“接力”给S态呢?这是因为,对于一个操作系统来说,timer事件对它的意义在于,它是标记时间片的重要(甚至是唯一)手段,而将CPU事件分成若干时间片的作用很大程度上是为了做进程的调度(我们将在lab2_3中接触),同时,操作系统的功能大多数是在S态完成的。如果在M态处理时钟中断,虽然说特权级上允许这样的操作,但是处于M态的程序可能并不是非常清楚S态的操作系统的状态。如果贸然采取动作,可能会破坏操作系统本身的设计。 + +接下来,我们继续讨论时钟中断在S态的处理。我们直接来到S态的C处理函数,即位于kernel/strap.c中的 smode_trap_handler函数: + +``` + 42 void smode_trap_handler(void) { + 43 // make sure we are in User mode before entering the trap handling. + 44 // we will consider other previous case in lab1_3 (interrupt). + 45 if ((read_csr(sstatus) & SSTATUS_SPP) != 0) panic("usertrap: not from user mode"); + 46 + 47 assert(current); + 48 // save user process counter. + 49 current->trapframe->epc = read_csr(sepc); + 50 + 51 // if the cause of trap is syscall from user application + 52 uint64 cause = read_csr(scause); + 53 + 54 if (cause == CAUSE_USER_ECALL) { + 55 handle_syscall(current->trapframe); + 56 } else if (cause == CAUSE_MTIMER_S_TRAP) { //soft trap generated by timer interrupt in M mode + 57 handle_mtimer_trap(); + 58 } else { + 59 sprint("smode_trap_handler(): unexpected scause %p\n", read_csr(scause)); + 60 sprint(" sepc=%p stval=%p\n", read_csr(sepc), read_csr(stval)); + 61 } + 62 + 63 // continue the execution of process. but in lab1_1, we have only one process... + 64 switch_to(current); + 65 } +``` + +我们看到,该函数首先读取scause寄存器的内容,如果内容等于CAUSE_MTIMER_S_TRAP的话,说明是M态传递上来的时钟中断动作,就调用handle_mtimer_trap()函数进行处理,而handle_mtimer_trap()函数的定义为: + +``` + 29 static uint64 g_ticks = 0; + 30 void handle_mtimer_trap() { + 31 sprint("Ticks %d\n", g_ticks); + 32 //TODO (lab1_3): increase g_ticks to record this "tick", + 33 // and then clear the "SIP" field in sip register. + 34 // hint: use write_csr to disable the SIP_SSIP bit in sip. + 35 panic( "lab1_3: increase g_ticks by one, and clear SIP field in sip register.\n" ); + 36 + 37 } +``` + +至此,我们就知道为什么会在之前看到`lab1_3: increase g_ticks by one, and clear SIP field in sip register.`这样的输出了,显然这是因为handle_mtimer_trap()并未完成。 + +那么handle_mtimer_trap()需要完成哪些“后续动作”呢?首先,我们看到在该函数上面定义了一个全局变量g_ticks,用它来对时钟中断的次数进行计数,而第31行会输出该计数。为了确保我们的系统持续正常运行,该计数应每次都会完成加一操作。所以,handle_mtimer_trap()首先需要对g_ticks进行加一;其次,由于处理完中断后,SIP(Supervisor Interrupt Pending,即S模式的中断等待寄存器)寄存器中的SIP_SSIP位仍然为1(由M态的中断处理函数设置),如果该位持续为1的话会导致我们的模拟RISC-V机器始终处于中断状态。所以,handle_mtimer_trap()还需要对SIP的SIP_SSIP位清零,以保证下次再发生时钟中断时,M态的函数将该位置一会导致S模式的下一次中断。 + diff --git a/chapter4.md b/chapter4.md deleted file mode 100644 index af87d2d..0000000 --- a/chapter4.md +++ /dev/null @@ -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开始编制。例如,如果物理内存空间的大小为2GB(spike的默认值),则目标机的物理地址范围为:[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-6000,PHYMEM_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结构体同物理地址之间是如何转换的。首先,我们需要先了解一下物理地址。 - -fig4_1 - -图4.1 RISCV64 物理地址 - -总的来说,物理地址分为两部分:页号(PPN)和offset - -页号可以理解为物理页的编码,而offset则为页内偏移量。现在考虑一下12位的offset对应的内存大小是多少呢? - -2<<12=4096也就是4KB,还记得我们讲过的物PA理页大小是多少吗?没错是4KB。12位的offset设计便是由此而来。 - -有了物理地址(PA)这一概念,那PA和Pages结构体又是如何转换? - -实际上在初始化空闲页链表之前,系统会定义一个Page结构体的数组,而链表的节点也正是来自于这些数组,这个数组的每一项代表着一个物理页,而且它们的数组下标就代表着每一项具体代表的是哪一个物理页,就如下图所示: - - fig4_2 - - -**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的值为0,sp中存储的就是内核的堆栈地址。而当中断来源于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得到对应的函数,并最终执行系统调用。 diff --git a/chapter4_memory.md b/chapter4_memory.md new file mode 100644 index 0000000..bf17430 --- /dev/null +++ b/chapter4_memory.md @@ -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) + + + +## 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个基础实验加深读者对该管理机制的理解。 + + + +### 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],用于在512(2^9)个页目录(page directory)项中检索页目录项(page directory entry, PDE); +- [29,21]:共9位,图中的VPN[1],用于在512(2^9)个页中间目录(page medium directory)中检索PDE; +- [20,12]:共9位,图中的VPN[0],用于在512(2^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格式 + +其中的各个位的含意为: + +● V(Valid)位决定了该PDE/PTE是否有效(V=1时有效),即是否有对应的实页。 + +● R(Read)、W(Write)和X(eXecutable)位分别表示此页对应的实页是否可读、可写和可执行。这3个位只对PTE有意义,对于PDE而言这3个位都为0。 + +● U(User)位表示该页是不是一个用户模式页。如果U=1,表示用户模式下的代码可以访问该页,否则就表示不能访问。S模式下的代码对U=1页面的访问取决于sstatus寄存器中的SUM字段取值。 + +● G(Global)位表示该PDE/PTE是不是全局的。我们可以把操作系统中运行的一个进程,认为是一个独立的地址空间,有时会希望某个虚地址空间转换可以在一组进程中共享,这种情况下,就可以将某个PDE的G位设置为1,达到这种共享的效果。 + +● A(Access)位表示该页是否被访问过。 + +● D(Dirty)位表示该页的内容是否被修改。 + +● RSW位(2位)是保留位,一般由运行在S模式的代码(如操作系统)来使用。 + +● PPN(44位)是物理页号(Physical Page Number,简写为PPN)。 + +其中PPN为44位的原因是:对于物理地址,现有的RISC-V规范只用了其中的56位,同时,这56位中的低12位为页内位移。所以,PPN的长度=56-12=44(位)。 + + + +### 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中遇到的CLINT(Core Local Interrupter,timer中断的产生就是通过往这个地址写数据控制的),见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(即128M),g_mem_size的大小将为128MB。 + +另外,为了对空闲物理内存(地址范围为[_end,g_mem_size+DRAM_BASE(即PHYS_TOP)])进行有效管理,pmm_init()函数在81行通过调用create_freepage_list()函数定义了一个链表,用于对空闲物理内存的分配和回收。kernel/pmm.c文件中包含了所有对物理内存的初始化、分配和回收的例程,它们的实现非常的简单,感兴趣的读者请对里面的函数进行阅读理解。 + + + +### 4.1.3 PKE操作系统和应用进程的逻辑地址空间结构 + +通过4.1.2的讨论,我们知道对于PKE内核来说,有逻辑地址=物理地址的关系成立,这也是在实验一中我们可以采用Bare模式进行地址映射的原因。采用Bare模式的地址映射,在进行内存访问时,无需经过页表和硬件进行逻辑地址到物理地址的转换。然而,在实验二中我们将采用Sv39虚拟地址管理方案,通过页表和硬件(spike模拟的MMU)进行访存地址的转换。为实现这种转换,首先需要确定的就是将要被转换的逻辑地址空间,即需要对哪部分逻辑地址空间进行转换的问题。在PKE的实验二中,存在两个需要被转换的实体,一个是操作系统内核,另一个是我们的实验给定的应用程序所对应的进程。下面我们对它们分别讨论: + +#### 操作系统内核 + +操作系统内核的逻辑地址与物理地址存在一一对应的关系,但是在开启了Sv39虚拟内存管理方案后,所有的逻辑地址到物理地址的翻译都**必须**通过页表和MMU硬件进行,所以,为操作系统内核建立页表是必不可少的工作。操作系统的逻辑地址空间可以简单的认为是从内核代码段的起始(即KERN_BASE=0x80000000)到物理地址的顶端(也就是PHYS_TOP),因为操作系统是系统中拥有最高权限的软件,需要实现对所有物理内存空间的直接管理。这段逻辑地址空间,即[KERN_BASE,PHYS_TOP],所映射的物理地址空间也是[KERN_BASE,PHYS_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),即发生中断时保存用户进程执行上下文的内存空间。由于物理页面都是从位于物理地址范围[_end,PHYS_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文件,并将其中的代码段读取到新分配的内存空间(物理地址位于[_end,PHYS_TOP]区间)。 +- (65--66行)将内核中的S态trap入口函数所在的物理页一一映射到用户进程的逻辑地址空间。 + +通过以上load_user_program()函数,我们可以大致画出用户进程的逻辑地址空间,以及该地址空间到物理地址空间的映射。 + +![user_address_mapping.png](pictures/user_address_mapping.png) + + + +图4.5 用户进程的逻辑地址空间和到物理地址空间的映射 + +我们看到,用户进程在装入后,其逻辑地址空间有4个区间建立了和物理地址空间的映射。从上往下观察,“用户进程trapframe”和“trap入口页面”的逻辑地址大于0x80000000,且与承载它们的物理空间建立了一对一的映射关系。另外两个区间,即“用户态栈”和“用户代码段”的逻辑地址都低于0x80000000,它们所对应的物理空间则都位于实际空闲内存区域,同时,这种映射的逻辑地址显然不等于物理地址。 + + + +### 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所指向的根目录中,建立[va,va+size]到[pa,pa+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对应的物理地址。 + + + + + +## 4.2 lab2_1 虚实地址转换 + + + +#### **给定应用** + +- 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的代码,显然未实现这种转换。 + + + +#### **实验内容** + +实现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. +``` + + + +#### **实验指导** + +读者可以参考[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< + +## 4.3 lab2_2 简单内存分配和回收 + + + +#### **给定应用** + +- 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对应的功能并未完全做好。 + + + +#### **实验内容** + +如输出提示所表明的那样,需要完成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. +``` + + + +#### **实验指导** + +一般来说,应用程序执行过程中的动态内存分配和回收,是操作系统中的堆(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_START(4MB)开始的地址的。那么,这里的USER_FREE_ADDRESS_START对应图4.5中的用户进程的逻辑地址空间的哪个部分呢?**这一点请读者自行判断,并分析为什么是4MB,以及能不能用其他的逻辑地址?** + +以上了解了内存的分配过程后,我们就能够大概了解其反过程的回收应该怎么做了,大概分为以下步骤: + +- 找到一个给定va所对应的页表项PTE(查找[4.1.4节](pagetablecook),看哪个函数能满足此需求); +- 如果找到(过滤找不到的情形),通过该PTE的内容得知va所对应物理页的首地址pa; +- 回收pa对应的物理页,并将PTE中的Valid位置为0。 + + + + + +## 4.4 lab2_3 缺页异常 + + + +#### **给定应用** + +- 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值太大,把用户态栈“压爆”了。 + + + +#### **实验内容** + +在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. +``` + + + +#### **实验指导** + +本实验需要结合[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所对应的虚拟地址上。 + diff --git a/chapter5.md b/chapter5.md deleted file mode 100644 index 86f4b0c..0000000 --- a/chapter5.md +++ /dev/null @@ -1,297 +0,0 @@ -# 第五章.(实验4)缺页异常的处理 - -## 5.1 实验内容 - - -#### 应用: #### - - -app4_1.c源文件如下: - - - 1 #include - 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 - 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物理地址的定义: - -fig5_1 - -图5.1 RISCV64 物理地址 - -​可以看到,物理地址由PPN(物理页号)与Offset(偏移量)组成。这里的PPN就对应着上述的物理页。 - -​现在,我们来看RISCV虚拟地址的定义: - -fig5_2 - -图5.2 RISCV64 虚拟地址 - -​ 可以看到虚拟地址同样由页号和偏移量组成。而这二者之间是如何转换的呢?RV64支持多种分页方案,如Sv32、Sv39、Sv48,它们的原理相似,这里我们对pke中所使用的Sv39进行讲述。Sv39中维护着一个三级的页表,其页表项定义如下: - - fig1_7 - -图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); -``` - - - - fig5_4 - - 图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则对应着各级VPN,pt_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即虚拟地址的位数为39,RISCV_PGSHIFT即代表虚拟地址中Offset的位数,二者相减,剩下的就是VPN0、VPN1……VPNX的位数,在除以VPN的位数,得到就是VPN的数量。由于pke中式Sv39,故而VPN的数量为3,即VPN0、VPN1、VPN2。 - -接着我们使用pt_idx函数得到各级VPN的值,依据图5.2所示逐级查询,一直找到该虚拟地址对应的页表项,而该页表项中存着该虚拟地址所对应的物理页号,再加上虚拟地址中的偏离量,我们就可以找到最终的物理地址了!! - - - -**5.2.3** **缺页异常处理** - -``` - 1 #include - 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函数。 - -以上,就是本次实验需要大家完成的部分了! \ No newline at end of file diff --git a/chapter5_process.md b/chapter5_process.md new file mode 100644 index 0000000..327c7b5 --- /dev/null +++ b/chapter5_process.md @@ -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) + + + +## 5.1 实验3的基础知识 + +完成了实验1和实验2的读者,应该对PKE实验中的“进程”不会太陌生。因为实际上,我们从最开始的lab1_1开始就有了进程结构(struct process),只是在之前的实验中,进程结构中最重要的成员是trapframe和kstack,它们分别用来记录系统进入S模式前的进程上下文以及作为进入S模式后的操作系统栈。在实验3,我们将进入多任务环境,完成PKE实验环境下的进程创建、换入换出,以及进程调度相关实验。 + + + +### 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中实现循环轮转调度时使用。 + + + +### 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 ){ + 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; jinfo))->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()函数的调用,将选择系统中可能存在的其他处于就绪状态的进程投入运行,它的处理逻辑我们将在下一节讨论。 + + + + + +### 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; istatus == 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行),处理就简单得多:只需要将就绪队列队首的进程换入执行即可。对于换入的过程,需要注意的是,要将被选中的进程从就绪队列中摘掉。 + + + + + +## 5.2 lab3_1 进程创建(fork) + + + +#### **给定应用** + +- 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 } +``` + +以上程序 + + + + + +#### **实验内容** + + + + +#### **实验指导** + + + + +## 5.3 lab3_2 进程yield + + + +#### **给定应用** + + + +#### **实验内容** + + + + +#### **实验指导** + + + + + +## 5.4 lab3_3 循环轮转调度 + + + +#### **给定应用** + + + +#### **实验内容** + + + + +#### **实验指导** + + + diff --git a/chapter6.md b/chapter6.md deleted file mode 100644 index 93f6f16..0000000 --- a/chapter6.md +++ /dev/null @@ -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设置为x1(ra)寄存器的值,还记得我们在copy_thread中层将ra设置为forkret嘛?现在程序将从forkret继续执行: - -``` -160 static void -161 forkret(void) { -162 extern elf_info current; -163 load_elf(current.file_name,¤t); -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,程序就此由内核切换至用户程序执行!! - -​ \ No newline at end of file diff --git a/lab_figures/final/app5_1.png b/lab_figures/final/app5_1.png deleted file mode 100644 index ce3d730..0000000 Binary files a/lab_figures/final/app5_1.png and /dev/null differ diff --git a/lab_figures/final/app5_1.vsdx b/lab_figures/final/app5_1.vsdx deleted file mode 100644 index b09b588..0000000 Binary files a/lab_figures/final/app5_1.vsdx and /dev/null differ diff --git a/lab_figures/final/mem_map.png b/lab_figures/final/mem_map.png deleted file mode 100644 index 40c438c..0000000 Binary files a/lab_figures/final/mem_map.png and /dev/null differ diff --git a/lab_figures/final/wait_flow.png b/lab_figures/final/wait_flow.png deleted file mode 100644 index 70f223c..0000000 Binary files a/lab_figures/final/wait_flow.png and /dev/null differ diff --git a/lab_figures/final/wait_flow.vsdx b/lab_figures/final/wait_flow.vsdx deleted file mode 100644 index e1ea0ba..0000000 Binary files a/lab_figures/final/wait_flow.vsdx and /dev/null differ diff --git a/lab_figures/final/内存映射.vsdx b/lab_figures/final/内存映射.vsdx deleted file mode 100644 index b70195e..0000000 Binary files a/lab_figures/final/内存映射.vsdx and /dev/null differ diff --git a/license.txt b/license.txt deleted file mode 100644 index 5bec3cc..0000000 --- a/license.txt +++ /dev/null @@ -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. diff --git a/pictures/fig1_8.png b/pictures/fig1_8.png index cce03c7..09334b6 100644 Binary files a/pictures/fig1_8.png and b/pictures/fig1_8.png differ diff --git a/pictures/fig2_install_1.png b/pictures/fig2_install_1.png new file mode 100644 index 0000000..5dde44e Binary files /dev/null and b/pictures/fig2_install_1.png differ diff --git a/pictures/fig2_1.png b/pictures/fig3_traps_1.png similarity index 100% rename from pictures/fig2_1.png rename to pictures/fig3_traps_1.png diff --git a/pictures/fig2_2.png b/pictures/fig3_traps_2.png similarity index 100% rename from pictures/fig2_2.png rename to pictures/fig3_traps_2.png diff --git a/pictures/htif.png b/pictures/htif.png new file mode 100644 index 0000000..ba72836 Binary files /dev/null and b/pictures/htif.png differ diff --git a/pictures/kernel_address_mapping.png b/pictures/kernel_address_mapping.png new file mode 100644 index 0000000..de3a39e Binary files /dev/null and b/pictures/kernel_address_mapping.png differ diff --git a/pictures/physical_mem_layout.png b/pictures/physical_mem_layout.png new file mode 100644 index 0000000..9022380 Binary files /dev/null and b/pictures/physical_mem_layout.png differ diff --git a/pictures/user_address_mapping.png b/pictures/user_address_mapping.png new file mode 100644 index 0000000..f22f098 Binary files /dev/null and b/pictures/user_address_mapping.png differ diff --git a/课程设计.md b/课程设计.md deleted file mode 100644 index 88c7926..0000000 --- a/课程设计.md +++ /dev/null @@ -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是一段多进程的代码,我们可以结合下图进行分析:
-
-app5_1 -
- - -首先对app的内容进行分析,程序首先在父进程father1中调用了fork(),产生子进程child1,父进程father1进入等待状态。而子进程child1此时打印出a=9随机再次调用fork(),产生子进程child4,child4打印输出6后退出,此时child1结束等待输出5,随即father1被唤醒,输出8。Father1进行第二次fork,产生child3。Child3输出5后返回,父进程father1再次被唤醒,最后输出4。故而,正确的打印顺序为9、6、5、8、5、4。
- -现在,我们要支持上述app,就需要完善pke的进程支持。
- -在实验五的代码中,函数`do_fork()`中我们需要实现函数`copy_mm()`,即复制虚拟内存。该函数的本质实际上是对页表进程操作。由于实验五中只需要实现父子进程的切换,所以我们可以直接复制内核页表作为进程页表,并且再复制后的内核页表上为每个进程映射其用户地址空间。
- -
-app5_1 -
- - -上图为pke的虚实映射关系。其中内核空间采用对等映射,即令物理地址等于虚拟地址,这样做使我们在内核下操作虚拟地址时无需再进行地址转换,例如我们使用`__page_alloc`分配了一段物理内存,并且要使用memset函数设置这段内存的值时,我们可以直接将分配得到的物理地址传入memset函数,注意,此时memset中,这个地址会被当作虚拟地址。结合实验三的知识,我们知道,这里必然会经由页表进行地址转化,但由于内核页表中物理地址与虚拟地址采用了对等的映射,我们实际上得到了和这个虚拟地址相同的物理地址,从而完成了写的操作。
- -虚拟地址空间中用户地址空间从零开始到USER STACK TOP结束,RISCV中物理地址从0x80000000开始,这一部分用户地址自然不存在对等映射的物理地址空间。如图所示,它映射至first_free_paddr到Top Memory之间的内存。
- -我们知道每个用户进程都有它独立的用户代码,而所有用户都共享内核代码。所以,一种更为清晰的设计如下,每个用户进程维护一张属于该进程的用户页表,所有进程共享内核页表。我们为进程结构体`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`,我们可以对其内容进行简要的分析:
- - - .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`。
-在pk.c中的`boot_loader`函数中sscratch于内核态被设置为0。而在proc.c的forkret函数中,forkret函数模拟上次调用是由用户态进入内核态的假象,将ssctatch写为内核栈的栈帧。综上可以得出结论,从用户态进入`trap_entry`后,sscratch值为该进程内核栈顶trapframe的指针;而从内核态进入trapframe后,sscratch的值为0。
- -所以在交换sscratch与sp后,代码对sp(即原sscratch的值)的值进行判断,如果sp中的值为0,表示其从内核态进入trap,代码顺序向下执行后跳转1f。如果不是0,则表示其从用户态进入trap,代码跳转write_satp。
- -我们先看sp为0即内核态进入的情况,首先sp作为栈指针寄存器自然是不能为0的,我们需要将sscratch的值(即原sp的值)再次写回sp寄存器。此时sp指向内核栈,接着调用宏save_tf将当前trap的trapframe保存在该内核栈中。
- -接着是sp为1即用户态进入的情况。它直接使用当前sp(即原sscratch中的值)作为内核栈地址,注意,由于forkret在进入用户态时向sscratch中写入了即将进入用户态运行的进程的内核栈,此时我们从用户态进入内核态,从sscratch中得到内核栈的自然也同运行的进程所一致。
- -接着,无论是哪种进入内核的状态都为欲存储的栈帧分配了栈空间,且都调用了宏`save_tf`:
- - .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写入sscratch,sscratch置位为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`,进入中断处理:
- - 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中的值恢复特权模式。
-故而,在forkrets中,代码先判断了`SSTATUS_SPP`的值是否为零,若其为零,则表示异常发生之前是处于用户态,若不为零,则表示异常发生之前是处于内核态。对于用户态,我们重新将用户页表写入satp寄存器,并且将内核栈地址写入sscratch,以备下一次异常发生时使用。对于内核态,则直接进入start_user:
- - - - .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返回。 - -综上,为了用户态的代码能够跳转进入中断处理程序,上述由用户态进入内核态的代码需要在所有进程的用户页表中进行映射。
-同样每个用户页表中都应该维护自己的用户堆栈,需要对虚拟地址current.stack_top-RISCV_PGSIZE进行映射。
-以上功能均由`proc_pagetable`函数实现。
- -现在,我们已经完成了用户页表的创建,不过目前页表中除了我们映射的异常入口与用户栈还是一片空白,接下来我们需要对用户内存进程映射。
-还记得fork函数中的`copy_mm`吗?我们需要在该函数中将父进程的用户内存拷贝给子进程,或者说复制父进程的用户页表。对于用户进程空间中的虚拟地址,这里有几种情况需要考虑: - - -- 其一、该虚拟地址对应的页表项不存在,则无需在子进程中进程映射 -- 其二、该虚拟地址对应的页表项存在且`PTE_V`位有效,则需要为子进程分配内存,并复制父进程对应的物理页,最后将该虚拟地址与新分配的物理内存映射进子进程的用户页表 -- 其三、该虚拟地址对应的页表项存在且但`PTE_V`位无效,这是因为pke中采用了预映射的机制,此时父进程的页表项中所描述的地址不是真实的物理地址而是一个`vmr_t`结构体,该结构体描述着该段物理内存在对应文件中的位置,会在`page_fault`中被使用。在此,只需要将父进程页表项所描述的vmr_t结构体地址赋值给子页表项。 -- 其四、该虚拟地址对应着用户栈,由于我们在proc_pagetable函数中已近为其分配了内存,此时只需要复制父进程的用户栈。 - - - - -至此,我们进一步完善了对fork的支持。接下来,我们来看进程间的同步。
-wait是最为基础的同步操作。wait函数在进程列表中进行遍历,寻找该进程为尚未完成的任意子进程/或pid指定的子进程,若存在且子进程处于`PROC_ZOMBIE`状态,则释放该子进程资源并返回。若子进程不处于`PROC_ZOMBIE`状态,则将父进程的状态设为`PROC_SLEEPING`,将父进程的等待状态设为`WT_CHILD`,继而调用schedule。 - -从父进程调用wait开始,进程是如何调度与切换的呢?下面我们一起一探究竟。如下是一张wait调用的流程图:

- - -
-app5_1 -
- -首先,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 - #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一样用来组织链表的链接。 - -对于一个等待队列,封装了一系列的操作,我们对如下几个操作的代码进行阅读。
-首先,初始化`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`为信号量对应的等待队列。
-当用户程序中调用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 \ No newline at end of file