MIT6.1810の学习笔记
webliuのmit.6.828学习笔记
写在前面
- 本文基于mit/6.828课程,附官方网址。
- 本文采用的实验环境为2020年版的xv6系统,需要wsl,vscode,docker工具。附环境配置教程
- 曾尝试在wsl中自己配置环境,但gdb的一些设置没有搞定。。。
- 本文参考书为mit官网的xv6讲义,xv6系统源码,可以在官网自行下载,另附中文文档(老旧版本)
- 附b站mit/6.S081课程视频
- 本文依据xv6的讲义的顺序,每章节给出自己的一些理解,并附带实验过程记录。
- 本意是作为个人学习笔记用,但也希望能对他人有所帮助,如有出错请留言指正。(我的第一篇博客)
- webliuのmit.6.828学习笔记
- 写在前面
- 正文
- Chapter 0 Operating system interfaces
- Lab0: Xv6 and Unix utilities
- Chapter 1 Operating system organization
- Lab1: system calls
- Chapter 2 Page tables
- Lab2: page tables
- Chapter 3 Traps, interrupts, and drivers
- Lab3: Traps
- Lab4: Copy-on-Write Fork for xv6
- Chapter 4 Locking
- Chapter 5 Scheduling
- Chapter 6 File system
- Chapter 7 Summary
- Appendix A PC hardware
- Appendix B The boot loader
- define V2P_WO(x) ((x) − KERNBASE) // same as V2P, but without casts
- define BSIZE 512 // block size
正文
接下来是正文部分
Chapter 0 Operating system interfaces
本章是介绍操作系统的一些基础内容
Processes and memory
这一节主要了解一下基础的xv6中的system call
参照上表,一些简单的system call将不做介绍,只重点理解两种system call,一个为fork,一个为exec。
其中fork是对进程本身进行操作的 它复制当前进程的全部内容以及当前进程的fd表 也就是说子进程会做和原进程相同的事且对相同的file进行操作。且子进程从fork的下一行开始执行(因为子进程同样复制了PC)。
- 需要注意,子进程对其复制过来的fd表进行更改,不会影响原进程的fd表,也就是说是在一份copy上进行更改。同时file也是一种广义的file。
但fork会返回pid(process identification,即进程识别号),显然原进程和子进程的pid不会相同,故我们可以使用一个条件语句来让原进程和子进程做不同的事,这样我们可以实现一个进程多个输出。在我的理解中,fork给原来一路向下的进程开了一个分支,两边在fork call的位置分开,分别开始向下走,这也就解释了利用fork实现pipeline的并行的原理。
另外一个值得注意的call为exec,它对程序进行操作,读入一个程序的可执行文件和参数表,然后如果成功执行,exec将不会返回到原程序而是在读入的程序中结束进程,否则会带着一个错误返回值返回到原程序,在xv6中会导致print(exec ???? failed).
在操作系统中常常会使用类 shell 程序,这类程序能够执行多种任务,但实际上我们在设计操作系统时,只需要定义上述的两个 system call 就可以简单地实现这种功能的高拓展性。具体做法是 shell 程序在执行过程中会先读入命令行,然后 fork 一个子进程,子进程根据输入,通过 exec 执行程序。这种结构实际上把执行程序的任务交给了子进程,子进程把输入交给 exec 实现,这样 exec 退出会结束子进程而不会结束 shell ,同时 exec 可以利用保存在文件系统中的可执行文件来完成多种任务,操作系统的设计者只需要给出一个执行的 system call 即可而不需要为每种程序都设计接口。
大家可能注意到上述的shell几乎在执行fork call之后就立马进行exec call,换句话说我们刚复制完原程序的内容就立马用一个新的程序取代了它,这可能构成一种浪费。操作系统的设计者为什么不设计一种类似于forkexec的call来集成这两种功能呢?其中一个原因就是我们在fork之后可能会对fd表的copy进行操作,将fork和exec分开给予了这种自由。另外我们可以再优化设计fork复制的内容来避免这种浪费,当然这是后话。
I/O and File descriptors
fd(file descriptor)是一个小整数,表示进程可以从中读取或写入的内核管理对象。
每个进程内部都有一个从零开始的fd表,其中一般约定0指示标准输入,1指示标准输出,2指示标准错误,保存程序的错误信息。所以对于shell来说,0 ,1,2指示的文件一定都是已经open了的。
- 值得注意的是,当执行open()时,它会打开某个文件并给他分配一个最小未被使用的fd。close()会释放某一文件的fd,让它变为未被使用。实际使用中可以close(0)紧接着open(XXXX)来实现I/O redirection。
fd的使用和0,1,2的约定使得system call不用考虑操作的是何种文件,只要是涉及文件的操作所用的接口都是fd,且0指示某个文件那么就认为其内容是输入,1,2与之类似。这种设计同样可以简化操作系统的设计,增强其功能的多样性。
Pipes
pipe是一对fd,一个用于read,一个用于write。向一端写数据会让另一端能够读相应的数据,可以看出pipe实际上是单向的,pipe给予了进程之间联系的途径。一般地,父进程关闭读端(pipe ),子进程关闭写端pipe ,则此时父进程可以往管道中进行写操作,子进程可以从管道中读,从而实现了通过管道的进程间通信,同时当管道不再使用时也要关闭。这里可以参考这篇相关博客这是链接。
- *pipe的read端只有指示write端的所有fd(可以通过dup call让多个fd指示write端)被close才会停止读入数据并返回0,正如read()读到一个file的EOF时的返回值。
这一点尤为重要。例如在下面的例子中
在子进程中fd 0被dup到了p[0],这时0和p[0]指示相同的file,也就是说子进程中pipe的read端等同于输入,之后就可以关闭p[0](原进程中写入到write端的''hello world\n''将会作为输入)。
如果我们在exec前没有关闭p[1],也就是write端,那么read端将永远处于读入数据的状态。
原文中还有当被execute的程序使用了write端对应的fd这一条件,但此处存疑,可能不一定必要)
pipeline是一种xv6系统程序功能的表达形式,例如grep fork sh.c | wc -l ,符号"|"左边是一个程序,右边也是一个程序,shell在执行它时,会在子进程中建立一个pipe,之后分别fork并执行左右两边的程序,并且左右两边的程序可能本身也是一个pipeline,所以同样会fork两个子进程。这样就构成了一个树形结构,其中叶子节点最终执行程序,内部节点wait()它的两个子进程全部结束。当然你也可以设计成内部节点执行左端的程序,fork的子节点执行右端的程序,但据说这样会使实现变复杂
原文:but doing so
correctly would complicate the implementation.
- 值得注意的是,pipeline两端的内容可以是并行的
File system
xv6的文件系统由data files(不加以解释的字节序列)和directories(顾名思义,目录,对data files和子directories的命名引用)。
其中目录形成树状结构,从root开始。/a/b/c为一个路径path,非常类似于常见操作系统的目录路径。chdir call可以改变进程的当前目录。
这两种方式实际上会打开相同的文件。
file name和file并不构成一对一关系,一个inode(底层文件)必须由inode number来唯一标识,但可以有多个file name,每个file name都是一个link to file。
例如
在上述代码段中,open创建了一个名为a的文件(O_CREATE参数表示如果该文件不存在就创建一个),link(a,b)使得这个文件既可以称作'a'也可以称作'b'。
当一个文件的link数为零(通过unlink call来移除link)并且没有fd指示它,那么我们将再也没有机会找到这个文件,那么它的inode number和保存该文件的磁盘空间将被释放。
Real world
本节不做讨论
Lab0: Xv6 and Unix utilities
这一节为lab:utilities
sleep(easy)
- 在user目录下创建sleep.c
点击查看代码
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int
main(int argc, char *argv[])
{
if(argc != 2){
fprintf(2, "Usage: sleep integer...\n");
exit(1);
}
else sleep(atoi(argv[1]));
exit(0);
}
- 打开Makefile修改UPROGS的内容(加入$U/-sleep\)
- 之后make qemu重新编译系统 测试加入的函数 使用 ./grade-lab-util sleep 指令来打分。如果成功可以使用 git commit -am 'my solution for util sleep' 指令来备份这一更改。
pingpong
- 在user路径下编写源文件
点击查看代码
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int main(int argc, char *argv[]){
int p1[2];
int p2[2];
pipe(p1);//创建管道
pipe(p2);
int pid = fork();//创建子进程,子进程会复制父进程
if(pid == 0){
close(p1[1]);
close(p2[0]);
char son[2];
read(p1[0],son,1); //读取管道内容
close(p1[0]);
printf("%d: received ping\n",getpid());//getpid获取进程ID
write(p2[1],"1",2);//向管道写入内容
close(p2[1]);
}else if(pid > 0){
close(p1[0]);
close(p2[1]);
write(p1[1],"1",2);
close(p1[1]);
char father[2];
read(p2[0],father,1);
printf("%d: received pong\n",getpid());
close(p2[0]);
}
exit(0);
}
- 修改Makefile中,再编译系统测试程序结果
primes(moderate/hard)
- 实验过程与上面类似,应注意该实验的并发控制,同时关注程序是否正常结束。
点击查看代码
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
void prime(int* p);
void main(int argc, char *argv[])
{
if(argc != 1){
fprintf(2, "Usage: primes \n");
exit(1);
}
else{
int p1[2];
int pid;
pipe(p1);
char buf[102]="2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35";
write(p1[1],buf,102);
pid=fork();
if(pid>0)
{
close(p1[0]);
close(p1[1]);
wait(0);
exit(0);
}
else
{
close(p1[1]);
prime(p1);
}
}
}
void prime(int* p1)
{
//printf("run prime\n");
int p2[2];
pipe(p2);
int pid ;
char buf[3];
if(read(p1[0],buf,3)) pid=fork();
else
{
close(p1[0]);
close(p2[0]);
close(p2[1]);
exit(0);
}
if(pid==0)
{
close(p2[1]);
prime(p2);
}
else{
int prim;
prim=atoi(buf);
printf("prime %d\n",prim);
int n;
close(p2[0]);
while(read(p1[0],buf,3))
{
n=atoi(buf);
//printf("n=%d\n",n);
if(n%prim!=0)
{
write(p2[1],buf,3);
//printf("send to next\n");
}
}
close(p2[1]);
close(p1[0]);
wait(0);
exit(0);
}
}
find(moderate)
- 只���参照ls的代码理解查找文件的操作,并注意 . 和 .. 这两个目录即可。
点击查看代码
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fs.h"
char* fmtname(char *path)//从path中读出文件名
{
static char buf[DIRSIZ+1];
char *p;
// Find first character after last slash. 找到在最后一个斜杠后面的字符
for(p=path+strlen(path); p >= path && *p != '/'; p--)
;
p++;
// Return blank-padded name. 返回空白填充名称
if(strlen(p) >= DIRSIZ) //如果字符比buf的size还长 直接返回p
return p;
memmove(buf, p, strlen(p)); //复制字符的内容到buf
memset(buf+strlen(p), ' ', DIRSIZ-strlen(p)); //buf的剩余部分填充空白
return buf;
}
void find(char* path,char* filename)
{
char buf[512], *p;
int fd;
struct stat st;
struct dirent de;
if((fd = open(path, 0)) < 0){
fprintf(2, "find: cannot open %s\n", path);
return;
}//打开路径
if(fstat(fd, &st) < 0){
fprintf(2, "find: cannot stat %s\n", path);
close(fd);
return;
}//打开的文件的信息保存到了st数组中。
//printf("%s %d %d %l\n", fmtname(path), st.type, st.ino, st.size);
switch(st.type){
case T_DEVICE:
case T_FILE:
if(strcmp(fmtname(path),filename)==0) printf("%s\n",path);
break;
//如果是同名文件,直接打印路径。
case T_DIR:
if(strlen(path) + 1 + DIRSIZ + 1 > sizeof buf){
printf("find: path too long\n");
break;
}
strcpy(buf, path);
p = buf+strlen(buf);
*p++ = '/'; //p指向buf的末尾
while(read(fd, &de, sizeof(de)) == sizeof(de))//将目录文件的内容读到de中
{
if(de.inum == 0 )
continue;
//printf("%s\n",de.name);
if(strcmp(de.name,".")==0||strcmp(de.name,"..")==0) {
//printf("next\n");
continue;
}
else memmove(p, de.name, DIRSIZ); //在buf的末尾加上de.name;
p[DIRSIZ] = 0;
/*if(stat(buf, &st) < 0){
printf("find: cannot stat %s\n", buf);
continue;
}
printf("%s %d %d %d\n", buf, st.type, st.ino, st.size);*/
find(buf,filename);
}
break;
}
close(fd);
}
int
main(int argc, char *argv[])
{
if(argc != 3){
fprintf(2, "Usage: find path filename\n");
exit(1);
}
int len=strlen(argv[2]);
char *p=argv[2]+len;
memset(p,' ', DIRSIZ-len);
argv[2][DIRSIZ]=0;
find(argv[1],argv[2]);//在某一path下找文件
//printf("%s",argv[2]);
exit(0);
}
// Directory is a file containing a sequence of dirent structures.
// #define DIRSIZ 14
// struct dirent {
// ushort inum;
// char name[DIRSIZ];
// };
// 定义在kernel中的stat.h
// struct stat {
// int dev; // File system's disk device
// uint ino; // Inode number
// short type; // Type of file
// short nlink; // Number of links to file
// uint64 size; // Size of file in bytes
// };
xargs(moderate)
- 注意在创建字符串数组后,不要忘记使用malloc为其分配空间,且user.h中不含realloc函数。
点击查看代码
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
void xx(char** buf,int i)
{
char c[100];
char* p=c;
while(read(0,p,1)&&*(p++)!='\n');
if(*(p-1)=='\n')
{
*(p-1)=0;
if(buf[i]!=0) {
free(buf[i]);
}
buf[i]=(char*)malloc(strlen(c)+1);
memmove(buf[i],c,strlen(c)+1);
int pid=fork();
if(pid==0){
xx(buf,i);
}
else{
exec(buf[0],buf);
wait(0);
exit(0);
}
}
else
{
exit(0);
}
}
void xargs(int argc, char* argv[])
{
char* buf[32];
int i;
for(i=0;i<argc-1;i++)
{
buf[i]=(char*)malloc(strlen(argv[i+1])+1);
}
for(i=0;i<argc-1;i++)
{
memmove(buf[i],argv[i+1],strlen(argv[i+1])+1);
}
int pid=fork();
if(pid==0)
{
xx(buf,i);
}
else
{
wait(0);
exit(0);
}
}
int main(int argc, char *argv[])//把前面命令的输出的每一行(从标准输入中读)附加到xargs的参数中
{
xargs(argc,argv);
return 0;
}
Chapter 1 Operating system organization
本章介绍了操作系统的结构,如何组织各部分来实现多线程(同时完成多种工作)、隔离(一个进程崩溃不会导致整个系统崩溃)和交互(不同进程之间需要进行交互)。主流的一种实现方法是monolithic kernel(单内核),Unix正是采用了这种结构。
+ 注意,xv6利用了intel x86的一些特性,同时这一章需要一些机器层面编程的前置知识。
Abstracting physical resources
一个首当其冲的问题在于,我们为什么需要操作系统,如果每个应用都有一个小本本记下它需要的system call(就像引入了一个库一样),需要使用的时候直接调用,那么应用可以直接与硬件交互,无疑是高效的。但这种结构对于每次运行单个应用非常可行,当需要多个应用同时进行时,将会面临如何安����件的问题,而这个问题由于没有操作系统只能交给应用们自己解决,就好像没有一个组织者一样,团队协作会变得很困难。同时如果某个应用出现bug,会直接影响硬件的功能,导致其他应用跟着出现问题。
而操作系统将硬件透明地分给各个进程(意味着在进程看起来,所有硬件都是可用的)。此时,应用不需要自己考虑共享硬件的问题,在应用的视角是看不到其他的应用的,同时它也不会和硬件直接接触。
这一点非常重要,因为这种透明性允许即使某些应用程序处于无限循环中,操作系统也能保证应用共享处理器。
同时,Unix进程使用exec来建立它们的内存映像,而不是直接与物理内存交互。这允许操作系统决定在内存中放置进程的位置;如果内存紧张,操作系统甚至可能将进程的一些数据存储在磁盘上。Exec还使得可执行文件可以便利地存储在文件系统映像中来使用。
- 总结一下,操作系统可以将pytsical resources抽象,它通过内核来实现进程,进而实现multiplex isolation,通过exec call和fd确保应用不直接操作物理内存,进而实现memory isolation。
User mode, kernel mode, and system calls
要想实现较强的隔离(isolation),就需要应用不能接触到其他进程的内存,同时应用不能更改操作系统的数据结构和指令。
上图展示了操作系统的这种结构,可以看出首先shell和cat是分开的,其次它们运行在user space和kernel space是分开的,中间的红线代表user kernel boundary。shell和cat没办法执行特权指令(privileged instructions),而kernel可以执行特权指令(比如读写磁盘就包含用到了特权指令)。当应用在user space尝试使用特权指令时,操作系统会先切换到kernel mode,之后会把尝试使用特权指令的应用清除。所以当应用想要(例如)读写磁盘时,必须交给kernel来完成。
上述切换,在xv6里由一条特殊指令int来实现,它将processor(处理器,hardware)由user mode切换到kernel mode,当切换到kernel mode,kernel会让程序由一个指定的点(entry point)进入,之后kernel会验证system call的参数,并决定是否执行。
- 注意,entry point的设置由kernel来实现,因为它需要保证程序(可能是恶意程序)进入kernel不会跳过验证参数的环节。
- 实际上user space的应用(例如shell)使用fork call时,并不是直接调用kernel中的fork,而是给出一个ecall指令,ecall的参数是一个数字,对应着system call,之后会跳转到kernel,kernel执行一个名为syscall的函数(定义在syscall.c),由syscall来调用对应的system call。
Kernel organization
当规定操作系统的所有部分都运行在kernel mode中时,我们称之为monolithic kernel。(xv6正采用了这种结构)
在这种结构中,操作系统的所有指令都有操作硬件的特权。但如果内核中出现错误,往往会导致操作系统崩溃。
为了降低内核出错的风险,操作系统设计者最大限度地减少在内核模式下运行的操作系统代码量,并在用户模式下执行大部分操作系统。这种内核被称为微内核(microkernel)。
上图展示了操作系统的这种结构,可以看出file server运行在user space(OS services 也作为进程运行时称作 servers),microkernel此时提供一种进程间的交互机制来与OS运行在user mode的部分沟通。
Code: the first address space
一开始,机器开机,它初始化自己并从磁盘中加载一个boot loader(见xv6讲义附录B)并执行,boot loader会从磁盘中加载kernel,并从entry处开始执行它,entry设置页表,将物理地址中0 ~ 4Mb也就是到0x000400000分别映射到0~4Mb和KERNBASE到KERNBASE+4Mb。
前者虚拟地址就是物理地址,只要kernel中的entry部分还在运行就需要这个映射(最后会被删除)。后者当entry结束时kernel会用到。
物理地址中0~4Mb的内容包含了I/O设备和kernel的text & data。
entry还将 entrypgdir 的物理地址载入到控制寄存器 %cr3 中。分页硬件必须知道 entrypgdir 的物理地址才能找到页表。(因为此时它还不知道如何翻译虚拟地址;它也还没有页表。)
entrypgdir 这个符号指向内存的高地址处,但只要用宏V2P_WO[1] 减去 KERNBASE 便可以找到其物理地址。为了让分页硬件运行起来, xv6 会设置控制寄存器 %cr0 中的标志位 CR0_PG。
之后entry将%esp指向栈,然后跳转到main开始执行,我们必须使用间接跳转,否则汇编器会生成 PC 相关的直接跳转,而该跳转会运行在内存低地址处的 main。 main 不会返回,因为栈上并没有返回 PC 值。好了,现在内核已经运行在高地址处的函数 main中了。
Code: creating the first process
main先执行一些初始化,之后会调用userinit( ),userinit( )又先调用allocproc( ),allocproc( )做的工作是在进程表(process table)中分配一个槽(struct proc),并初始化process state,为其运行做准备。
注意一点:userinit 会被第一个新建的进程调用,而 allocproc 是通用的,即不仅被第一个进程使用,也要被每个新建的进程使用。
allocproc 会在 proc 的表中找到一个标记为 UNUSED的槽位。当它找到这样一个未被使用的槽位后,allocproc 将其状态设置为 EMBRYO,使其被标记为被使用的并给这个进程一个独有的 pid。接下来,它尝试为进程的内核线程分配内核栈。如果分配失败了,allocproc 会把这个槽位的状态恢复为 UNUSED 并返回0以标记失败。
allocproc 为新进程设置好一个特定的栈和一系列内核寄存器,使得进程第一次运行时会返回到用户空间。其中一个做法是,allocproc 通过设置返回程序计数器的值,使得新进程的内核线程首先运行在 forkret 的代码中,然后返回到 trapret中运行。(内核线程会从p->context->eip处开始执行,p为proc类型的变量)
forkret会判断是否是第一个进程,如果是就做一些初始化。forkret除此之外不做其他事情,只是根据栈中的返回地址返回(allocproc会将trapret的地址放到栈中)。
- 注意,我们不能在 main 中进行初始化,因为它们必须在一个拥有自己的内核栈的普通进程中运行。
trapret做的事是从栈中修复trapframe中的user regisiter的值,并跳转到process中去。(无论是第一个进程还是之后fork出来的进程都会有这一步)
从kernel mode到user mode 的转换用到了中断机制(chapter 3),它由system call int实现,int在进入kernel mode 时会将user register保存在kernel的栈中,userinit在栈上写值,让它和一个进程通过int call进入kernel mode后栈的值一样,这样系统中 使进程从kernel mode 返回user mode 的那部分代码也就同样适用于产生第一个user process的过程了。
这样我们的第一个user mode下的进程就已经准备好了。
这个进程将要执行initcode.S这个程序,我们首先需要为进程分配物理空间来储存initcode.S。userinit会接着调用 setupkvm(),这个函数顾名思义set up kernel virtual memory,它会把虚拟地址映射到相应的物理地址中去,也就分配好了空闲的物理空间 ,最终的结果就是得到了一个虚拟的地址空间来给内核使用。
userinit同理还调用inituvm(),来得到虚拟的地址空间给user 程序使用,同时把程序文件的内容copy到地址空间的相应位置。
userinit会将trap frame设置成user mode的初始状态:
%cs 寄存器保存着一个段选择器, 指向段 SEG_UCODE 并处于特权级 DPL_USER(即在用户模式而非内核模式)。类似的,%ds, %es, %ss 的段选择器指向段 SEG_UDATA 并处于特权级 DPL_USER。%eflags 的 FL_IF 位被设置为允许硬件中断;我们将在第3章回头看这段代码。
栈指针 %esp 被设为了进程的最大有效虚拟内存,即 p->sz。指令指针则指向初始化代码的入口点,即地址0。
函数 userinit 把 p->name 设置为 initcode,这主要是为了方便调试。还要将 p->cwd 设置为进程当前的工作目录;我们将在第6章回过头来查看 namei 的细节。
一旦进程初始化完毕,userinit 将 p->state 设置为 RUNNABLE,使进程能够被调度。
Code: Running the first process
当userinit结束后,第一个user process的状态就设置好了,mpmain( )开始执行,它调用scheduler( )来运行这个进程。
scheduler( )做的工作是:找到为一个 p->state 为 RUNNABLE 的进程(正如我们前面设置的那样,这样的进程是准备好了的,当运行第一个进程时,显然只有initproc是我们在上一节设置好了的)
然后将cpu->proc指向process table中对应找到的那个进程的 proc变量 ,接着调用 switchuvm(p) 通知硬件开始使用目标进程的页表。
- 注意,由于 setupkvm 使得所有进程的页表对内核代码和数据具有相同的映射,也就是说对于所有的进程,内核的代码和数据都映射到地址空间的同一位置。所以当内核运行时我们改变页表是没有问题的。
switchuvm 同时还建立了任务状态段 SEG_TSS,让硬件在进程的内核栈中执行系统调用与中断。我们将在第3章研究任务状态段。
scheduler 接着把进程的 p->state 设置为 RUNNING,调用 swtch(&(c−>scheduler), p−>context);,来将context切换为目标进程的内核线程中。
swtch( )的做法为:swtch 会保存当前的寄存器。当前的context并不是进程的context,而是一个特殊的每个进程的scheduler的context。因此scheduler( )告诉swtch将当前硬件的寄存器的内容保存在per-cpu的存储(cpu->scheduler)中,而不是保存在任何进程的内核线程的context中。然后将保存的目标内核线程的寄存器(p->context)加载到x86硬件寄存器中,包括堆栈指针和指令指针。我们将在第5章讨论 swtch 的细节。最后的 ret指令从栈中弹出目标进程的 %eip,从而结束context切换工作。现在处理器就运行在进程 p 的内核栈上了。
由于上一节allocproc()的设计,ret开始执行forkret,之后执行trapret。他们做的工作和上一节说的一样,结果就是:trap frame 的内容转移到了 CPU 状态中,处理器会从 trap frame 中 %eip 的值继续执行。对于 initproc 来说,这个值就是虚拟地址0,即 initcode.S 的第一个指令。
这时 %eip 和 %esp 的值为0和4096,这是进程地址空间中的虚拟地址。处理器的分页硬件会把它们翻译为物理地址。allocuvm 为进程建立了页表,所以现在虚拟地址0会指向为该进程分配的物理地址处。allocuvm 还会设置标志位 PTE_U 来让分页硬件允许用户代码访问内存。userinit 设置了 %cs 的低位,使得进程的用户代码运行在 CPL = 3 的情况下,这意味着用户代码只能使用带有 PTE_U 设置的页,而且无法修改像 %cr3 这样的敏感硬件寄存器。这样,处理器就受限只能使用自己的内存了。
The first system call: exec
initcode.S 干的第一件事是触发 exec 系统调用。initcode.S刚开始会将 \(argv,\)init,$0 三个值推入栈中,接下来把 %eax 设置为 SYS_exec 然后执行 int T_SYSCALL:这样做是告诉内核运行 exec 这个系统调用。如果运行正常的话,exec 不会返回:它会运行名为 \(init 的程序,\)init 是一个以空字符结尾的字符串,即 /init(7721-7723)。如果exec失败并返回,initcode会循环调用exit系统调用,该调用肯定不会返回。
系统调用 exec 的参数是 \(init、\)argv。最后的0让这个手动构建的系统调用看起来就像普通的系统调用一样,我们会在第3章详细讨论这个问题。和之前的代码一样,xv6 努力避免为第一个进程的运行单独写一段代码,而是尽量使用通用于普通操作的代码。
第2章讲了 exec 的具体实现。这里概括地讲,exec会用从文件系统中获取的 /init 的二进制代码代替 initcode 的代码。现在 initcode 已经执行完了,进程将要运行 /init。 init会在需要的情况下创建一个新的控制台设备文件,然后把它作为描述符0,1,2打开。接下来它将不断循环,开启控制台 shell,处理没有父进程的僵尸进程,直到 shell 退出,然后再反复。系统就这样建立起来了。
Process overview
内核通过user/kernel mode flag、地址空间和线程的时间切片的方式实现进程之间的隔离。进程是一个抽象概念,它让一个程序可以假设它独占一台机器。进程向程序提供“看上去”私有的,其他进程无法读写的内存系统(或地址空间),以及一颗“看上去”仅执行该程序的CPU。
Xv6使用页表为每个进程提供自己的地址空间。
- 注意这一机制是由x86 cpu也就是硬件来直接实现的,x86的分页部件完成了这一点。
页表将虚拟地址(x86指令操纵的地址)转换(或“映射”)为物理地址(处理器芯片发送到主存储器的地址)。
地址空间的结构如图所示,由于是分页后的虚拟地址,故地址从0开始,一般从低向高依次存放进程的指令,全局变量,堆栈区和用户按需扩展的堆区(heap)(malloc用)。
- 注意每个进程都将kernel映射到地址空间的一块(为了给用户留下足够的内存空间,xv6 将内核映射到了地址空间的高地址处,即从 0x80100000 开始)。当进程使用系统调用时,系统调用实际上会在进程地址空间中的内核区域执行。这种设计使得内核的系统调用代码可以直接指向用户内存。
xv6 使用结构体 struct proc 来维护一个进程的状态,其中最为重要的状态是进程的页表,内核栈,当前运行状态。
每个进程都有一个运行线程(或简称为线程)来执行进程的指令。线程可以被暂时挂起,稍后再恢复运行。系统在进程之间切换实际上就是挂起当前运行的线程,恢复另一个进程的线程。线程的大多数状态(局部变量和函数调用的返回地址)都保存在线程的栈上。
每个进程都有用户栈和内核栈(p->kstack)。当进程运行用户指令时,只有其用户栈被使用,其内核栈则是空的。然而当进程(通过系统调用或中断)进入内核时,内核代码就在进程的内核栈中执行;进程处于内核中时,其用户栈仍然保存着数据,只是暂时处于不活跃状态。进程的线程交替地使用着用户栈和内核栈。要注意内核栈是用户代码无法使用的,这样即使一个进程破坏了自己的用户栈,内核也能保持运行。
p->state 指示了进程的状态:新建、准备运行、运行、等待 I/O 或退出状态中。
p->pgdir 以 x86 硬件要求的格式保存了进程的页表。
Code中的一些知识
我们需要知道操作系统是如何启动的,也就是内核如何为自己创建第一个地址空间,如何创建和启动第一个进程,以及该进程如何执行第一个系统调用。
参考xv6中文文档。
注意文中出现的数字为xv6系统的源码的行号。(注意中文文档中对应的xv6源码是老旧版本的,新版本请看我在文章开头给的下载链接)
注意 kernel刚启动时还没有设置有关页表的信息,entry的代码设置了页表。使得kernel开始得以在地址空间(虚拟地址)上运行。
把 xv6 内核装载到物理地址 0x100000 处,entry在实际的物理地址上执行页表的设置操作
也就是说entry一开始运行时是处在低地址中的,所以设置的页表项0即将虚拟地址 0:0x400000 映射到物理地址 0:0x400000。换句话说,只要entry还运行在内存的低地址处,那么我们就不能只将kernel映射到虚拟的高地址,否则会导致当前运行的entry找不到对应的物理地址。
当硬件启动后,boot loader将xv6装载到硬盘上,kernel.ld规定了xv6将由entry处开始运行,entry很快就会跳转到高地址的main,从而进入kernel mode,main会执行一系列的setup,然后开始执行userinit()(定义在kernel里proc.c中,设置第一个user process,实际上是一个胶水程序),userinit会先执行一小段称作initcode的代码(定义在user中),initcode会exec init,init会fork一个子进程来exec sh,此时shell启动。init是user的第一个进程,shell是第二个,其他的所有进程都由init fork而来(init会无限循环地fork子进程,除非出现错误)。
kernel 执行内存地址 = 指令虚拟地址 - 0x80000000
entry简而言之做了五件事
1、开启 4MB 内存页支持
2、建立内存页表
3、开启内存分页机制
4、设置内核栈顶位置
5、跳转到 main 继续执行
Lab1: system calls
Using gdb (easy)
- 如果第一次用gdb执行 echo "add-auto-load-safe-path $(pwd)/.gdbinit " >> ~/.gdbinit
- 在第一个窗口执行make CPUS=1 qemu-gdb 拆分终端在第二个窗口执行gdb-multiarch。
- 使用gdb的layout asm 或者layout src功能同时显示gdb命令行和汇编代码/源代码。
根据backtrace的结果,当系统启动时usertrap调用了syscall。
程序的调用堆栈中排在前面的是最近被调用的函数,也就是新调用的函数。例如,在backtrace命令的输出中,最上面的一行是当前正在执行的函数,下一行是调用它的函数,以此类推。
由p /x *p 打印出16进制形式的当前p的值,其中trapframe的地址为0x87f76000。
由于系统刚刚启动,此时p->trapframe->a7应该是由initcode那段代码初始化给出的。
查看user/initcode.S,发现在start标签里出现li a7, SYS_exec,查看kernel/syscall.h该值为7。
gdb查看num的值验证猜想。
ecall指令是MIPS指令集中的一条特殊指令,用于触发系统调用。它没有参数,但可以通过寄存器来传递系统调用的参数。在MIPS64架构中,系统调用的参数通常存储在a0到a7这8个寄存器中,具体哪些寄存器用于传递哪些参数取决于系统调用的类型和实现。
用p /x $sstatus指令查看sstatus的值,得到的结果为0x22,也就是0b00100010。说明之前的处在user模式下。
- 在RISC-V处理器中,$sstatus寄存器的各个位依次表示以下状态:
第0位(UIE,User Interrupt Enable):用户态中断使能位,用于控制用户态下的中断是否被允许。
第1位(SIE,Supervisor Interrupt Enable):超级用户态中断使能位,用于控制超级用户态下的中断是否被允许。
第2位(UPIE,User Previous Interrupt Enable):上一个特权级(即用户态)中断使能位,用于保存上一个特权级下的中断使能状态。
第3位(SPIE,Supervisor Previous Interrupt Enable):上一个特权级(即超级用户态)中断使能位,用于保存上一个特权级下的中断使能状态。
第4位(SPP,Supervisor Previous Privilege):上一个特权级位,用于保存上一个特权级的值。
第5至7位:保留位,暂未定义。
在RISC-V处理器中,SPP(Supervisor Previous Privilege)位是$sstatus寄存器���一部分,用于保存上一个特权级的值。当处理器从高特权级(如超级用户态)切换到低特权级(如用户态)时,SPP位会被设置为高特权级的值,以便在必要时可以恢复到高特权级。
具体来说,SPP位的值为0表示上一个特权级为用户态,值为1表示上一个特权级为超级用户态。当处理器从高特权级切换到低特权级时,SPP位会被设置为1,以便在必要时可以恢复到高特权级。当处理器从低特权级切换到高特权级时,SPP位会被设置为0,以便在必要时可以恢复到低特权级。
将kernel/syscall.c中将num = p->trapframe->a7;改为num = * (int *) 0;运行qemu
出现panic,在kernel/kernel.asm中查找
成功定位到报错的汇编代码。可以发现更改后a3对应num的值。
如果定位到报错的汇编代码仍不知道如何修改可以使用gdb在汇编代码的地址处设置断点进行调试。
不懂。
在gdb下调试,使用info proc得到pid为1,使用p p->name得到当前运行的进程是initcode。
System call tracing (moderate)
根据课程给的提示修改kernel中的各个文件。
在kernel/proc.c中给出sys_trace的函数定义,参照其他函数的定义方式。
- user/trace.c已经给出,该函数会传递参数给kernel/sysproc.c 中定义的sys_trace函数,由它来决定执行什么操作。
- 注意此syscall作用是将用户空间给的参数(user/trace.c已经存在,给出了user mode下的定义)存入myproc的某一部分。trace的输出在每次运行syscall的时候产生。
- 具体过程参照大佬给的流程图
在kernel/proc.h中添加一个mask值
在kernel/sysproc.c中给出sys_trace的定义
修改syscall,来实现trace的功能。
点击查看代码
extern uint64 sys_chdir(void);
extern uint64 sys_close(void);
extern uint64 sys_dup(void);
extern uint64 sys_exec(void);
extern uint64 sys_exit(void);
extern uint64 sys_fork(void);
extern uint64 sys_fstat(void);
extern uint64 sys_getpid(void);
extern uint64 sys_kill(void);
extern uint64 sys_link(void);
extern uint64 sys_mkdir(void);
extern uint64 sys_mknod(void);
extern uint64 sys_open(void);
extern uint64 sys_pipe(void);
extern uint64 sys_read(void);
extern uint64 sys_sbrk(void);
extern uint64 sys_sleep(void);
extern uint64 sys_unlink(void);
extern uint64 sys_wait(void);
extern uint64 sys_write(void);
extern uint64 sys_uptime(void);
extern uint64 sys_trace(void);
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
[SYS_trace] sys_trace,
};
static char *syscall_name[] = {
[SYS_fork] "fork",
[SYS_exit] "exit",
[SYS_wait] "wait",
[SYS_pipe] "pipe",
[SYS_read] "read",
[SYS_kill] "kill",
[SYS_exec] "exec",
[SYS_fstat] "fstat",
[SYS_chdir] "chdir",
[SYS_dup] "dup",
[SYS_getpid] "getpid",
[SYS_sbrk] "sbrk",
[SYS_sleep] "sleep",
[SYS_uptime] "uptime",
[SYS_open] "open",
[SYS_write] "write",
[SYS_mknod] "mknod",
[SYS_unlink] "unlink",
[SYS_link] "link",
[SYS_mkdir] "mkdir",
[SYS_close] "close",
[SYS_trace] "trace",
};
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;//*(int*)0;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num]();
if(p->mask >> num) printf("%d:syscall %s -> %d\n",p->pid,syscall_name[num],p->trapframe->a0);
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
> argint()函数可以从用户空间读取一个整数参数存入指定的地址中去,例如argint(0,&n)从用户空间偏移量为0的位置读一个参数存入&n处。
使用trace 2 usertests forkforkfork命令进行测试来验证实验结果。
Sysinfo (moderate)
采用同样的步骤添加system call。
- 分析sysinfotest函数的作用:不接受参数,返回一个类型为sysinfo的表。
- sysinfotest函数想要调用定义在kernel/proc.c中的sysinfo(system call)其定义为int sysinfo(struct sysinfo*); ,它会将参数传递给kernel中的sys_info函数,由该函数执行相应的操作。
- 需要设计的sys_info应该不接受任何参数,将一个sysinfo类型的数据copyout。具体需要得到剩余内存的大小,进程数。
查看kernel/sysinfo.h
查看kernel/sysfile.c中的sys_fstat函数和kernel/file.c中的filestat函数,容易知道addr为user pointer to struct stat,copyout函数将内核空间地址的(char*)&st处的数据复制到用户空间地址addr处,复制sizeof(st)个字节。
据此设计sys_sysinfo
接下来实现获取剩余内存和进程数即可。
编译后输入sysinfotest测试结果。
make grade 发现出错
发现评测系统严格要求了输出格式,将trace关卡的输出多加一个空格就可以了。
- time.txt应当给出自己完成实验的时间,这里没有加上。
Chapter 2 Page tables
操作系统通过页表机制完成虚拟地址和物理地址映射,某种意义上页表就像一个编码器和译码器。
xv6 主要利用页表来区分多个地址空间,保护内存。另外,它也使用了一些简单的技巧,即把不同地址空间的多段内存映射到同一段物理内存(内核部分),在同一地址空间中多次映射同一段物理内存(用户部分的每一页都会映射到内核部分),以及通过一个没有映射的页保护用户栈。
Paging hardware
x86 的指令(用户和内核均是如此)计算的都是一个32位虚拟地址。如何将虚拟地址映射为物理地址呢。
x86 页表是一个包含 2^20(1,048,576)条页表条目(PTE)的数组。我们取虚拟地址的前20位作为该数组的下标(具体是高10位确定一个page directory,低10位确定一个page table),就能确定虚拟地址对应的PTE。
每条 PTE 包含了一个 20 位的物理页号(PPN)及一些标志位。而虚拟地址的低12位是offset(偏移量)。
物理地址的计算方法就是PPN+offset。
- 我们分析一下分页机制,简化地,如果设计一级页表,那么2页表的大小为2^20 个32位数据,也就是32Mb。其中前20位能够映射2^20 个页,由于偏移量的取值范围位0~sizeof(页),可知页的大小位2^12(4 kb),所以页表能够映射的物理地址的大小为4GB。
- 如果设计二级页表,即高10位为page directory指向page table的地址,那么页表所占的大小会进一步缩小位4Mb+4Kb
此处附上大佬博客
每个 PTE 都包含一些标志位,说明分页硬件对应的虚拟地址的使用权限。PTE_P 表示 PTE 是否陈列在页表中:如果不是,那么一个对该页的引用会引发错误(也就是:不允许被使用)。PTE_W 控制着能否对页执行写操作;如果不能,则只允许对其进行读操作和取指令。PTE_U 控制着用户程序能否使用该页;如果不能,则只有内核能够使用该页。图 2-1 对此进行了说明。这些的标志位和页表硬件相关的结构体都在 mmu.h(0200)定义。
Process address space
内核创建的映射如图所示,且对于每个进程都有一个这样的映射。
每个进程都有自己的页表,xv6 会在进程切换时通知分页硬件切换页表。每个进程的页表同时包括���户内存(不同进程的页表将其用户内存映射到不同的物理内存中,因此每个进程就拥有了私有的用户内存)和内核内存(同一块物理内存)的映射,这样当用户通过中断或者系统调用转入内核时就不需要进行页表的转换了。
总结一下,xv6 保证了每个进程只能使用其自己的内存,并且每个进程所看到的内存都是从虚拟地址 0 开始的一段连续内存。对于一个进程,xv6 只把该进程所使用的内存对应的 PTE 的 PTE_U 设为 1,其他 PTE 则不然,这样就可以实现前者。对于后者,则是让页表把连续的虚拟页映射到实际分配的物理页。
Code: creating an address space
main调用kvmalloc(1840)创建并切换到一个页面表,该表具有内核运行所需的KERNBASE以上的映射。
这里我们详细讲讲setupkvm():它首先分配一页内存来保存page directory。然后,它调用mappages来配置内核所需的映射,这些映射在kmap(1809)数组可以看到。
映射包括内核的指令和数据、高达PHYSTOP的物理内存以及实际上是I/O设备的内存范围。
mappages将一块虚拟地址到物理地址的对应范围的映射加入到页表中。
mappages()的做法是:它以一页为单位对每块虚拟地址进行映射。对于要映射的每个虚拟地址,mappages调用walkpgdir来查找该地址的PTE地址。然后,它初始化PTE以保存相关的物理页码、所需的权限(PTE_W和/或PTE_U)和PTE_P来将此PTE标记为有效。walkpgdir在查找虚拟地址的PTE时模仿x86分页硬件的操作(见图2-1)。walkpgdir使用虚拟地址的高10位来查找页表目录。如果页面目录不存在,则所需的页表尚未分配;如果设置了alloc参数,walkpgdir会对其进行分配,并将其物理地址放在页表目录中。最后,它使用虚拟地址的下10位来在页表中找到PTE的地址。
Physical memory allocation
xv6维护一个空闲物理页组成的链表struct run,分配内存将页移出该链表,而释放内存将页加入该链表。
然而,这个链表需要初始化,也就是要确定初始状态哪些页是空闲的,这实际上也是一个分配问题。
为了解决它,xv6 通过在 entry 中使用一个特别的页分配器来解决这个问题。该分配器会在内核数据部分的后面分配内存。该分配器不支持释放内存,并受限于 entrypgdir 中规定的 4MB 分配大小。即便如此,该分配器还是足够为内核的第一个页表分配出内存。
User part of an address space
无非是将进程的虚拟空间中的user部分详细讲了讲。
Real world
实际的操作系统有很多更加精巧的机制,可以自行了解。
这里注意entry初始化的页表,采用4Mb的分页,也就是“超级表”,与内核创建的页表有很打不同。
具体来说,xv6 只在初始页表(1311)中使用了“超级页”。数组的初始化设置了 1024 条 PDE 中的 2 条,即 0 号和 512 号(KERBASE >> PDXSHIFT),而其他的 PDE 均为 0。xv6 设置了这两条 PDE 中的 PTE_PS 位,标记它们为“超级页”。内核还通过设置 %cr4 中的 CP_PSE(Page Size Extension) 位来通知分页硬件允许使用超级页。
Code: Physical memory allocator
正如上一节所说的,我们通过维护一个空闲物理页的链表来实现分配器。
分配器是这样初始化的:函数main调用kinit1和kinit2来初始化分配器。之所以有两个调用,。这样做是由于 main 中的大部分代码都不能使用锁以及 4mb 以上的内存。kinit1 在前 4mb 进行了不需要锁的内存分配。而 kinit2 允许了锁的使用并分配了更多内存。原本应该由 main 决定有多少物理内存可用于分配,但在 x86 上很难实现。相反,它假设机器有224兆字节(PHYSTOP)的物理内存,并使用内核末端和PHYSTOP之间的所有内存作为空闲内存的初始池。kinit1 和 kinit2 调用 freerange 将内存加入空闲链表中(分配器原本一开始没有内存可用,这里对 kfree 的调用给了分配器可管理的内存),freerange 则是通过对每一页调用 kfree 实现该功能。(为了确保对每一页(大小为4096b)调用 kfree, freerange 用 PGROUNDUP 来保证分配器只会释放4096倍数开始的物理地址。
这里详细了解一下kfree():它首先将被释放内存的每一位设为1。这使得访问已被释放内存的代码所读到的不是原有数据,而是垃圾数据;这样做使得我们能在运行时更早地让这种错误代码发生崩溃。接下来 kfree 把 v 转换为一个指向结构体 struct run 的指针,在 r->next 中保存原有空闲链表的表头,然后将当前的空闲链表设置为 r(头插法)。kalloc 移除并返回空闲链表的表头(去除表头所指的空闲页,即分配内存)。
分配器会通过高地址的虚拟地址找到它们映射的物理页,而非通过其物理地址。所以 kinit 会使用 p2v(PHYSTOP)来将 PHYSTOP(一个物理地址)翻译为虚拟地址。分配器有时将地址看作是整型,这是为了对其进行运算(譬如在 kinit 中遍历所有页);而有时将地址看作读写内存用的指针(譬如操作每个页中的 run 结构体);对地址的双重使用导致分配器代码中充满了类型转换。另外一个原因是,释放和分配内存隐性地改变了内存的类型。
Code: sbrk
Sbrk是对进程的内存进行收缩或增长的系统调用。它是由函数growtproc实现的。如果n为正,growtproc会分配一个或多个物理页面,并将它们映射到进程地址空间的顶部。如果n为负数,growtproc将从进程的地址空间中取消映射一个或多个页面,并释放相应的物理页面。要进行这些更改,xv6会修改进程的页面表。进程的页表存储在内存中,因此内核可以使用普通的赋值语句来更新表,这就是allocuvm和dealocovm所做的。x86硬件将页表项入口缓存在Translation Look-aside Buffer(TLB)中,当xv6更改页表时,它必须使已缓存的条目无效。因为如果它没有使缓存的条目无效,那么在以后的某个时候,TLB可能会使用一个旧的映射,指向一个同时被分配给另一个进程的物理页,因此,一个进程可能能够在其他进程的内存上乱写乱画。Xv6通过重新加载cr3(保存当前页表地址的寄存器),使过时的缓存条目无效。
Code: exec
exec 是创建地址空间中用户部分的系统调用。它根据文件系统中保存的某个文件来初始化用户部分。exec通过 namei()打开二进制文件,这一点将在第6章进行解释。然后,它读取 ELF 头。
xv6 的应用程序用广泛使用的 ELF 格式来描述,该格式在 elf.h 中定义。一个 ELF 二进制文件包括了一个 ELF 头,即结构体 struct elfhdr(0905),然后是连续几个程序段的头,即结构体 struct proghdr(0924)。每个 proghdr 都描述了需要载入到内存中的程序段。xv6 中的程序只有一个程序段的头,但其他操作系统中可能有多个。
exec 第一步是检查文件是否包含 ELF 二进制代码。一个 ELF 二进制文件是以4字节的“魔法数字”0x7F,“E”,“L”,“F”开头的,或者写为宏 ELF_MAGIC(0952)。如果 ELF 头中包含正确的魔法数字,exec 就会认为该二进制文件的结构是正确的。
exec 通过 setupkvm( )分配了一个没有用户部分映射的页表,再通过 allocuvm( )为每个 ELF 段分配内存,然后通过 loaduvm( )把段的内容载入内存中。allocuvm 会检查请求分配的虚拟地址是否是在 KERNBASE 之下。 loaduvm( ) 通过 walkpgdir 来找到写入 ELF 段的内存的物理地址;通过 readi 来将段的内容从文件中读出。
exec 创建的第一个用户程序 /init 程序段的头是这样的:
程序段头中的 filesz 可能比 memsz 小,中间相差的地方应该用0填充(用于 C 的全局变量)而不是继续从文件中读数据。对于 /init,filesz 是2240字节而 memsz 是2252字节。所以 allocuvm 会分配足够的内存来装2252字节的内容,但只从文件 /init 中读取2240字节的内容。
现在 exec 要分配以及初始化用户栈了。它只为栈分配一页内存。exec 一次性把参数字符串拷贝到栈顶,然后把指向它们的指针保存在 ustack 中。它还会在参数列表的最后放一个空指针。这样,ustack 中的前三条条目就是伪造的返回 PC,argc 和 argv 指针了。
exec 会在栈的页下方放一个无法进入的页,这样当程序尝试使用超过一个页的栈时就会报错。另外,这个无法进入的页也让 exec 能够处理那些过于庞大的参数;当参数过于庞大时,exec 用于将参数拷贝到栈上的函数 copyout 会发现目标页无法进入,并且返回-1。
在建立新的用户内存时,如果 exec 发现了错误,比如一个无效的程序段,它就会跳转到标记 bad 处,释放这段内存映像,然后返回-1。exec 必须在确认系统调用能够成功后才能释放原来的内存映像,否则若原来的内存映像已经被释放了,exec 甚至都无法向它返回-1了。exec 只可能发生在建立新的内存映像时报错。一旦新的内存映像建立完成,exec 就能装载新映像而把旧映像释放。最后,exec 成功地返回0。
Exec将字节从ELF文件加载到ELF文件指定的地址处的内存中。用户或进程可以将他们想要的任何地址放入ELF文件中。因此,exec是有风险的,因为ELF文件中的地址可能会意外或故意包括了内核。如果内核对这种状况不够警觉,轻则导致安全漏洞,重则导致系统崩溃。
xv6执行许多检查以避免这些风险。为了理解这些检查的重要性,请考虑如果xv6不检查if(ph.vaddr+ph.memsz<ph.vaddr)会发生什么。它检查和是否溢出32位整数。
一种危险的情况是:用户可能会构造一个ELF二进制文件,其ph.vaddr指向内核,ph.memsz足够大,以至于总和溢出到0x1000。由于总和很小,它将通过allocuvm中的if(newsz>=KERNBASE)检查。随后对loaduvm的调用只传递了ph.vaddr自己,而不包括ph.memsz,也不根据KERNBASE检查ph.vaddr,从而将数据从ELF二进制文件复制到内核中。用户程序可以利用这一点以内核权限运行任意的用户代码。
如本例所示,必须非常小心地进行参数检查。内核开发人员很容易忽略一个关键的检查,而现实世界中的内核有很长一段检查不足的历史,用户程序可以利用这些检查来获得内核权限。xv6很可能无法完全校验提供给内核的用户级数据,恶意用户程序可能会利用这些数据来规避xv6的隔离。
Lab2: page tables
在实验开始前先阅读kernel/memlayout.h的代码,它展示了内存的布局。主要参照一下注释的内容。
注意以下地址中的虚拟地址,可能使用不同的页表。
可以发现KERNBASE为0x80000000L。(kernbase的注释里说是物理地址,但可以参照之前贴的figure1.2理解,应该指的是虚拟地址)
PHYSTOP为(KERNBASE + 12810241024)。(此处的PHYSTIOP为物理地址,0x80000000~PHYSTOP总计128Mb的空间是供内核和用户使用的RAM)。
// the kernel expects there to be RAM
// for use by the kernel and user pages
// from physical address 0x80000000 to PHYSTOP.
- define TRAMPOLINE (MAXVA - PGSIZE)*。无论物理还是虚拟地址中,在最高的地址有一个tramppoline,占了一页。
- define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE) 在trampoline下面为kernel的栈,p为参数
- define TRAPFRAME (TRAMPOLINE - PGSIZE) 在user memory中,trampoline下面是trampframe。(和上面的使用不同的页表)
阅读vm.c,介绍了虚拟地址。
- mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)函数,将虚拟地址va映射到物理地址pa,并设置权限perm,size表示虚拟地址的范围。其中,pte表示页表项,PTE_V为1表示该页表项已经存在,也就是此虚拟地址已经有了映射。walk函数遍历页表,第三个参数如果为1则会自动创建确实的页表项,否则会返回NULL。
阅读kernel/kalloc.c,它主要负责分配和释放物理内存。
如图所示,kmem为一个带锁的链表。
和前面讲的一样,物理页的分配主要是维护这个链表,一开始kinit函数会初始化链表,它首先initlock(),之后freerange(end, (void*)PHYSTOP);,也就是将end到PHYSTOP处的空间全部释放。freerange由kfree来实现,做法是将kfree接受一个页的开始地址,将其用头插法插到kmem.freelist中去,期间会获得一个锁,插入完成后释放。
extern char end[]; // first address after kernel defined by kernel.ld.
- 注意这里kfree接受的应该是物理页的地址。所以end应该对应着物理地址。
之后就是分配时从链表头部取出一个可用的页的地址,释放时就用上面提到的kfree。
Speed up system calls (easy)
在这关中ugetpid()在用户空间定义,且自动使用虚拟地址,我们需要做的只是建立映射。
根据题目的要求以及提示,我们在进程的虚拟地址中增加一个到指定页(该页的开头保存pid,并且该页只可读)的映射。
具体来说,我们需要在kernel/proc.c中的proc_pagetable()中添加这一映射()且设置标志位让页的数据只读,并且在 allocproc()给页赋初值pid,当进程结束时freeproc()要free这一页。
由于proc_pagetable()函数接受的参数是一个proc类型的变量p,所以我们要修改kernel/proc.h在此结构体中加上upid。
具体的做法是仿照proc中的结构体trapframe的定义,注意这里的upid是一个结构体指针,我们的要在kernel/proc.c中的allocproc()函数中给它赋初值。
这里的报错信息是因为采用了条件编译。这里我们用kalloc()函数赋给upid一个空闲物理页的地址,接下来要将这个地址映射到USYSCALL这一虚拟地址。
在proc_pagetable中完成这一映射,同样是仿照trapframe的做法。
最后在freeproc()中释放物理页。
运行后遇到了一个panic
根据panic的提示信息找到freewalk()函数,它在kernel/vm.c,这个函数被用来释放页表所使用的物理页以及页表本身的内存。
出现panic信息的条件是,512个页表项中的某一个是valid但是出现了read/write/execute标志位,也就是这个页表项没有指向更低级的页表(递归到了leaf)。
我们在proc.c中修改proc_freepagetable中取消USYSCALL的映射,否则由于该页可读,freewalk时就会panic。
之后运行出现
使用gdb进行调试,单步进行后发现到图示函数时出现错误信息,根据gdb中的PC的值定位到kernel.asm中相应的位置(kernel/riscv.h)。看不懂,推测是因为我用的实验环境是2020年版的,但源码是2022年版的,这个实验暂时告一段落。
Print a page table (easy)
根据实验描述,我们要在vm.c中定义一个vmprint()函数并把它的原型加入到defs.h中,并在exec中使用它。
参照上文有提到的freewalk()很容易就能给出vmprint()的代码。
更改如下
运行结果为
对于问题
可以参照defs.h中的宏定义和一张图来理解
可以发现从PTE到物理地址的转化由#define PTE2PA(pte) (((pte) >> 10) << 12)实现,物理地址到PTE的转化由#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)实现,一些标志位的设置参照上表。且规定当PTE中不包含read/write/excuate标志位时,此PTE映射到下一级页表的地址,否则直接映射到物理页的地址(该物理页不存页表)
Detect which pages have been accessed (hard)
根据描述,我们要利用PTE_A来判断某些页是否曾被访问过,具体来说是实现paaccess(va,size,*buf),这是一个system call 用来检测user space中的页。
- 阅读user/pgtbltest.c,它简单使用了一下pageaccess()
- 阅读walk()函数,它根据虚拟地址(va)在页表(pagetable)中查找并返回对应的页表项(pte_t)。如果对应的页表项不存在,根据参数alloc的值决定是否分配新的页表并返回对应的页表项。(如果alloc为0找不到就返回0,如果alloc为1找不到就创建对应va的页表项并返回)
- 根据提示修改kernel中的riscv.h 和sysproc.c文件
- 这里我注释掉了ugetpid的测试,只测试pageaccess,测试结果如下
- make grade 可以看到第二第三个实验已经通过了。
Chapter 3 Traps, interrupts, and drivers
本章介绍三个问题。
- 内核如何使处理器在用户态和内核态切换?
- 内核和设备如何并行?
- 内核如何知道设备的接口是什么样的?
Systems calls, exceptions, and interrupts
当出现system call,expection(产生中断的非法程序操作),interrupt(硬件产生的希望引起操作系统注意的信号。例如,当硬盘读完一个数据块时,它会产生一个中断来提醒操作系统这个块已经准备好被获取了)这时我们要让进程从用户态进入内核态,内核负责处理这三种情况,而不是进程,因为前者具有处理中断所需的特权和状态。
在处理前
- 系统必须保存寄存器以备将来的状态恢复。 (建立中断帧)
- 系统必须准备好在内核中执行,必须选择一个内核开始执行的地方。 (根据中断号,去IDT表中找相应的描述符)
- 内核必须能够获得关于这个事件的信息,例如系统调用的参数。(通过函数argint argptr argstr实现获取参数)
- 同时还必须保证安全性;系统必须保持用户进程和系统进程的隔离。
我们的方案时这样的:中断终止正常的处理器循环然后开始执行中断处理程序中的代码。在开始中断处理程序之前,处理器保存寄存器,这样在操作系统从中断中返回时就可以恢复他们。关键是处理器需要在用户模式和内核模式之间切换。
此外本文常用trap来代替expection,同时注意trap是当前进程产生的中断,interrupt是硬件产生的中断。
X86 protection
x86 有四个特权级,从 0(特权最高)编号到 3(特权最低)。在实际使用中,大多数的操作系统都使用两个特权级,0 和 3,他们被称为内核模式和用户模式。当前执行指令的特权级存在于 %cs 寄存器中的 CPL 域中。
简单的复习一些寄存器:%ss 保存的是当前栈的段选择符,即栈段寄存器;%esp 保存的是当前栈顶的地址,即栈指针;%eip寄存器是指令指针寄存器,用于存储下一条要执行的指令的地址;%cs寄存器是代码段寄存器,用于存储当前代码段的段选择符,代码段是指存储程序指令的内存段,%cs寄存器存储了当前使用的代码段的段选择符。%ds和%es寄存器分别是数据段寄存器和附加数据段寄存器,用于存储当前数据段的段选择符。数据段是指存储程序数据的内存段,%ds和%es寄存器存储了当前使用的数据段的段选择符。
在 x86 中,中断处理程序的入口在中断描述符表(IDT)中被定义。这个表有256个表项,每一个都提供了相应的 %cs 和 %eip。
一个程序要在 x86 上进行一个系统调用,它需要调用 int n 指令,这里 n 就是 IDT 的索引。int 指令进行下面一些步骤:
- 从 IDT 中获得第 n 个描述符,n 就是 int 的参数。
- 检查 %cs 的域 CPL <= DPL,DPL 是描述符中记录的特权级。(用户程序的指令特权级足够高才能产生相应的中断,并进入中断处理程序)
- 如果目标段选择符的 PL < CPL,就在 CPU 内部的寄存器中保存 %esp 和 %ss 的值。(否则就相当于是执行普通的程序,不需要特权级的切换。)
- 从一个任务段描述符中加载 %ss 和 %esp。
- 将 %ss 压栈。
- 将 %esp 压栈。
- 将 %eflags 压栈。
- 将 %cs 压栈。
- 将 %eip 压栈。(操作系统可以使用 iret 指令来从一个 int 指令中返回。它从栈中弹出 int 指令保存的值,然后通过恢复保存的 %eip 的值来继续用户程序的执行。)
- 当发生interrupt时清除%eflags中的IF位。
- 设置 %cs 和 %eip 为描述符中的值。(这样接下来会从%eip中读出interrupt handler的指令地址,进入该处理程序。)
得到的结果如下图
总结一下,产生中断时,如果发生了特权级转换,那么用户态下的栈顶指针和栈段寄存器会被cpu用其他寄存器保存,然后将其替代为中断处理程序的esp和ss,之后将 用户态的ss,esp,eflags,cs,eip 压入中断处理程序的栈中(ss和esp已经改变)。之后视情况清除IF位,然后将cs和eip设置为中断处理程序的,eip改变导致接下来开始执行中断处理程序。
Code: The first system call
第1章以initcode.S调用exec结束,让我们再仔细研究一下。
这个进程将 exec 所需的参数压栈,然后把系统调用号存在 %eax 中。这个系统调用号和 syscalls 数组中的条目匹配,(syscall 是一个函数指针的数组)。我们需要设法使得 int 指令将处理器的状态从用户模式切换到内核模式,从而能调用的内核函数(例如 sys_exec),并且可以取出 sys_exec 的参数。接下来的几个小节将描述 xv6 是如何做到这一点的,你会发现我们可以用同样的代码来实现中断和异常。
Code: Assembly trap handlers
xv6 必须设置硬件在遇到 int 指令时进行一些特殊的操作,这些操作会使处理器产生一个中断。x86 允许 256 个不同的中断。中断 0-31 被定义为软件中断,比如除 0 错误和访问非法的内存页。xv6 将中断号 32-63 映射给硬件中断,并且用 64 作为系统调用的中断号。
Tvinit ( ) 在 main 中被调用,它设置了 idt 表中的 256 个表项。中断 i 被地址为 vectors[i] 的代码处理。每一个中断处理程序的入口点都是不同的。
Tvinit 处理 T_SYSCALL(即用户系统调用 trap),特别地:它通过传递第二个参数值为 1 来指定这是一个陷阱门。陷阱门不会清除 FL 位,这使得在处理系统调用的时候也接受其他中断。
内核还设置系统调用门的权限为 DPL_USER,这使得用户程序可以通过显式地使用 int 指令产生一个trap。(因为指令的特权级高于中断程序的特权级)
xv6 不允许进程用 int 来产生其他中断(比如设备中断);如果它们这么做了,就会抛出通用保护异常,也就是发出 13 号中断。(因为指令的特权级低于中断程序的特权级)
当特权级从用户模式向内核模式转换时,内核不能使用用户的栈,因为它可能不是有效的。用户进程可能是恶意的或者包含了一些错误,使得用户的 %esp 指向一个不是用户内存的地方。xv6 会在trap发生的时候进行一个栈切换,栈切换的方法是让硬件从一个任务段描述符中读出新的栈选择符和一个新的 %esp 的值。函数 switchuvm()把用户进程的内核栈顶地址存入任务段描述符中。
当trap发生时,处理器会做下面一些事。如果处理器在用户模式下运行,它会从任务段描述符中加载 %esp 和 %ss,把旧的 %ss 和 %esp 压入新的栈中。如果处理器在内核模式下运行,上面的事件就不会发生。处理器接下来会把 %eflags,%cs,%eip 压栈。对于某些trap来说(例如一个page fault),处理器会压入一个错误字。而后,处理器从相应 IDT 表项中加载新的 %eip 和 %cs。
xv6 使用一个 perl 脚本来产生 IDT 表项指向的中断处理函数入口点。每一个入口都会压入一个错误码(如果进程没有压入的话),压入中断号,然后跳转到 alltraps。
Alltraps继续保存处理器的寄存器:它压入 %ds, %es, %fs, %gs, 以及通用寄存器。这么做使得内核栈上压入一个 trapframe(中断帧) 结构体,这个结构体包含了中断发生时处理器的寄存器状态(参见图3-2)。
总的来说,处理器压入 %ss,%esp,%eflags,%cs 和 %eip。处理器或者中断入口会压入一个错误码,而alltraps负责压入剩余的。中断帧包含了所有处理器从当前进程的内核态恢复到用户态需要的信息,所以处理器可以恰如中断开始时那样继续执行。回顾一下第二章,userinit通过手动建立中断帧来达到这个目标(参见图1-4)。
考虑第一个系统调用,被保存的 %eip 是 int 指令下一条指令的地址。%cs 是用户代码段选择符。%eflags 是执行 int 指令时的 eflags 寄存器,alltraps 同时也保存 %eax,它存有系统调用号,内核在之后会使用到它。
现在用户态的寄存器都保存了,alltraps 可以完成对处理器的设置并开始执行内核的 C 代码。处理器在进入中断处理程序之前设置选择符 %cs 和 %ss;alltraps 设置 %ds 和 %es。
一旦段设置好了,alltraps 就可以调用 C 中断处理程序 trap 了。它压入 %esp 作为 trap 的参数,%esp 指向刚在栈上建立好的中断帧(3021)。然后它调用 trap。trap 返回后,alltraps 通过加esp来弹出栈上的参数。然后执行标号为 trapret 处的代码。我们在第二章阐述第一个用户进程的时候跟踪分析了这段代码,在那里第一个用户进程通过执行 trapret 处的代码来退出到用户空间。同样地事情在这里也发生:弹出中断帧会恢复用户模式下的寄存器,然后执行 iret 会跳回到用户空间。
刚刚我们讨论的是发生在用户模式下的中断,但是中断也可能发生在内核模式下。在那种情况下硬件不需要进行栈转换,也不需要保存栈指针或栈的段选择符;除此之外的别的步骤都和发生在用户模式下的中断一样,执行的 xv6 中断处理程序的代码也是一样的。而 iret 会恢复了一个内核模式下的 %cs,处理器也会继续在内核模式下执行。
Code: C trap handler
我们在上一节中看到每一个处理程序会建立一个中断帧然后调用 C 函数 trap。trap查看硬件中断号 tf->trapno 来判断自己为什么被调用以及应该做些什么。如果中断是 T_SYSCALL,trap 调用系统调用处理程序 syscall。我们会在第五章再来讨论这里的两个 proc->killed 检查。
当检查完是否是系统调用,trap 会继续检查是否是硬件中断(我们会在下面讨论)。中断可能来自硬件设备的正常中断,也可能来自异常的、未预料到的硬件中断。
如果中断不是一个系统调用也不是一个硬件主动引发的中断,trap 就认为它是一个发生中断前的一段代码中的错误行为导致的中断(如除零错误)。如果产生中断的代码来自用户程序,xv6 就打印错误细节并且设置 proc ->killed 使之待会被清除掉。我们会在第五章看看 xv6 是进行清除的。
如果是内核程序正在执行,那就出现了一个内核错误:trap 打印错误细节并且调用 panic。
Code: System calls
对于系统调用,trap 调用 syscall。syscall 从中断帧中读出系统调用号,中断帧也保存着被保存的 %eax(该寄存器此时会保存系统调用号),以及到系统调用函数表的索引。对第一个系统调用而言,%eax 保存的是 SYS_exec(一个宏定义),并且 syscall 会调用第 SYS_exec 个系统调用函数表的表项,相应地也就调用了 sys_exec。
syscall 在 %eax 保存系统调用函数的返回值。当 trap 返回用户空间时,它会从 curproc->tf 中加载其值到寄存器中。因此,当 exec 返回时,它会返回系统调用处理函数返回的返回值。系统调用按照惯例会在发生错误的时候返回一个小于 0 的数,成功执行时返回正数。如果系统调用号是非法的,syscall 会打印错误并且返回 -1。
之后的章节会讲解系统调用的实现。这一章关心的是系统调用的机制。还有一点点的机制没有说到:如何获得系统调用的参数。工具函数 argint、argptr 和 argstr 获得系统调用的第 n 个参数,他们分别获取一个整数,或者指针,或者字符串始址。argint 利用用户空间的 %esp 寄存器定位第 n 个参数:%esp 指向系统调用结束后的返回地址。参数就恰好在 %esp 之上(%esp+4)。因此第 n 个参数就在 %esp+4+4*n。
简单回忆一下函数调用的现场保护,当我们遇到一个函数调用时首先将第1,2,3——n个参数压栈,之后执行call指令,它会将断点地址压栈,并修改eip为子函数的入口地址。所以我们得到一个这样的堆栈 。
进入子程序后,esp指向栈顶,也就是断点地址,我们就可以通过[esp+4]来访问参数了,看起来xv6采用了这种方法。
但实际上此时esp需要保持不变,但栈顶指针在一些情况下是会出现改变的,我们这里简单扩展一个更好的方式
。
argint 调用 fetchint 从保存的用户内存地址读取值到 *ip。fetchint 可以简单地将这个地址直接转换成一个指针,因为用户和内核共享同一个页表,但是内核必须检验这个指针的确指向的是用户内存空间的一部分。内核已经设置好了页表来保证本进程无法访问它的私有地址以外的内存:如果一个用户尝试读或者写高于(包含)p->sz的地址,处理器会产生一个段中断,这个中断会杀死此进程,正如我们之前所见。但是现在,我们在内核态中执行,用户提供的任何地址都是有权访问的,因此必须要检查这个地址是在 p->sz 之下的。
argptr 和 argint 的目标是相似的:它解析第 n 个系统调用参数。argptr 调用 argint 来把第 n 个参数当做是整数来获取,然后把这个整数看做指针,检查它的确指向的是用户地址空间。注意 argptr 的源码中有两次检查。首先,用户的栈指针在获取参数的时候被检查。然后这个获取到得参数作为用户指针又经过了一次检查。
argstr 是最后一个用于获取系统调用参数的函数。它将第 n 个系统调用参数解析为指针。它确保这个指针是一个 NUL 结尾的字符串并且整个完整的字符串都在用户地址空间中。
系统调用的实现(例如,sysproc.c 和 sysfile.c)仅仅是封装而已:他们用 argint,argptr 和 argstr 来解析参数,然后调用真正的实现。在第二章,sys_exec 利用这些函数来获取参数。
例如
Code: Interrupts
主板上的设备可以产生interrupt(中断),xv6 必须配置硬件来处理这些中断。没有硬件的支持 xv6 不可能正常使用起来:用户不能够用键盘输入,没有一个能够存储数据的文件系统等等。幸运的是,添加一些简单设备的中断并不会增加太多额外的复杂性。正如我们将会见到的,中断可以使用与system call系统调用和 exception 异常处理相同的代码。
中断除了可以在任何时候产生这一特质外和系统调用相似。主板上的硬件能够在需要的时候向 CPU 发出信号(例如用户在键盘上输入了一个字符)。我们得对设备编程来产生一个中断,然后令 CPU 接受它们的中断。
我们来看一看分时硬件和时钟中断。我们希望分时硬件大约以每秒 100 次的速度产生一个中断,这样内核就可以在多个进程之间进行时钟分片。100 次每秒的速度足以提供良好的交互性能并且同时不会使处理器进入不断的中断处理中。
像 x86 处理器一样,PC 主板也在进步,并且提供中断的方式也在进步。早期的主板有一个简单的可编程中断控制器(被称作 PIC)。随着多处理器PC板的出现,需要一种处理中断的新方法,因为每个CPU都需要一个中断控制器来处理发送给它的中断,并且必须有一种将中断路由到处理器的方法。这种方式由两部分组成:一部分位于I/O系统中(IO APIC,ioapic.c),另一部分连接到每个处理器上(local APIC,lapic.c)。Xv6是为具有多个处理器的板设计的:它忽略来自PIC的中断,并配置ioapic和local APIC。。
IO APIC有一个表,处理器可以通过内存映射I/O对表中的条目进行编程。在初始化期间,xv6程序将中断0映射到IRQ 0,依此类推,但会全部禁用它们。特定的设备启用特定的中断,并指定哪个处理器能接受该中断。举例来说,xv6 将键盘中断分发到处理器 0。将磁盘中断分发到编号最大的处理器,你们将在下面看到。
时钟芯片是在 LAPIC 中的,所以每一个处理器可以独立地接收时钟中断。xv6 在 lapicinit中设置它。关键的一行代码是 timer中的代码,这行代码告诉 LAPIC 周期性地在 IRQ_TIMER(也就�� IRQ 0)产生中断。第 7451 行打开 CPU 的 LAPIC 的中断,这使得 LAPIC 能够将中断传递给本地处理器。
处理器可以通过设置 eflags 寄存器中的 IF 位来控制自己是否想要收到中断。指令 cli 通过清除 IF 位来屏蔽中断,而 sti 又打开一个中断。xv6 在启动主 cpu和其他 cpu时屏蔽中断。每个处理的调度器打开中断。为了控制一些特殊的代码片段不被中断,xv6 在进入这些代码片段之前关中断(例如 switchuvm())。
xv6 在 idtinit()中设置时钟中断触发中断向量 32(xv6 使用它来处理 IRQ 0)。中断向量 32 和中断向量 64(用于实现系统调用)的唯一区别就是 32 是一个中断门,而 64 是一个陷阱门。中断门会清除 IF,所以被中断的处理器在处理当前中断的时候不会接受其他中断。从这儿开始直到 trap 为止,硬件中断执行和系统调用或异常处理相同的代码——建立中断帧。
这里有个疑问,0号为时钟中断,32号为键盘中断,为什么vector[32]会处理IRQ 0呢?没搞懂,看看远处的gpt更搞不懂了。。。
当因时钟中断而调用 trap 时,trap 只完成两个任务:递增时钟变量的值,并且调用 wakeup。我们将在第 5 章看到后者可能会使得中断返回到一个不同的进程。
Drivers
驱动程序是操作系统中用于管理某个设备的代码:它提供设备相关的中断处理程序,操纵设备完成操作,操纵设备产生中断,等等。驱动程序可能会非常难写,因为它和它管理的设备同时在并发地运行着。另外,驱动程序必须要理解设备的接口(例如,哪一个 I/O 端口是做什么的),而设备的接口又有可能非常复杂并且文档稀缺。
xv6 的硬盘驱动程序给我们提供了一个良好的例子。磁盘驱动程序从磁盘上拷出和拷入数据。磁盘硬件一般将磁盘上的数据表示为一系列的 512 字节的块(亦称扇区):扇区 0 是最初的 512 字节,扇区 1 是下一个,以此类推。操作系统用于文件系统的块大小可能与磁盘使用的扇区大小不同,但通常块大小是扇区大小的倍数。Xv6的块大小与磁盘的扇区大小相同。为了表示磁盘扇区,操作系统也有一个数据结构与之对应。这个结构中存储的数据往往和磁盘上的不同步:可能还没有从磁盘中读出(磁盘正在读数据但是还没有完全读出),或者它可能已经被更新但还没有写出到磁盘。磁盘驱动程序必须保证 xv6 的其他部分不会因为不同步的问题而产生错误。
Code: Disk driver
通过 IDE 设备可以访问连接到 PC 标准 IDE 控制器上的磁盘。IDE 现在不如 SCSI 和 SATA 流行,但是它的接口比较简单使得我们可以专注于驱动程序的整体结构而不是硬件的某个特别部分的细节。
磁盘驱动程序用结构体 buf(称为缓冲区)来表示一个文件系统块。BSIZE[2] 与IDE的扇区大小相同,因此每个缓冲区表示特定磁盘设备上一个扇区的内容。域 dev 和 sector 给出了设备号和扇区号,域 data 是该磁盘扇区数据的在内存的拷贝。(尽管xv6文件系统选择BSIZE与IDE的扇区大小相同,但驱动程序可以处理扇区大小的倍数的BSIZE。操作系统通常使用大于512字节的块来获得更高的磁盘吞吐量。)
域 flags 记录了内存和磁盘的联系:B_VALID 位代表数据已经被读入,B_DIRTY 位代表数据需要被写出(数据已经被CPU更新,dirty表示此时内存中的该数据和磁盘中的数据不一致,需要将数据/某种结果写出到磁盘中)。
B_BUSY 位是一个锁;它代表某个进程正在使用这个缓冲区而其他进程必不能使用它。当一个缓冲区的 B_BUSY 位被设置,我们称这个缓冲区被锁住。
内核在启动时通过调用 main中的 ideinit( )初始化磁盘驱动程序。ideinit 调用 ioapicenable 来打开 IDE_IRQ 中断。调用 ioapicenable 会打开最后一个处理器的该中断(ncpu-1),例如在一个双处理器系统上,CPU 1 会专门处理磁盘中断。
接下来,ideinit 检查磁盘硬件。它最初调用 idewait来等待磁盘可以接受命令。PC 主板通过 I/O 端口 0x1f7 来表示磁盘硬件的状态位。idewait获取状态位,直到 busy 位(IDE_BUSY)被清除,以及 ready 位(IDE_DRDY)被设置。
现在磁盘控制器已经就绪,ideinit 可以检查有多少磁盘。它假设磁盘 0 是存在的,因为启动加载器和内核都是从磁盘 0 加载的,但它必须检查磁盘 1。它通过写 I/O 端口 0x1f6 来选择磁盘 1 然后等待一段时间,获取状态位来查看磁盘是否就绪。如果不就绪,ideinit 认为磁盘不存在。
ideinit 之后,就只有在块高速缓冲(buffer cache)调用 iderw时才会再次用到磁盘,iderw 根据标志位更新一个锁住的缓冲区。如果 B_DIRTY 被设置,iderw 将缓冲区的内容写到磁盘;如果 B_VALID 没有被设置,iderw 从磁盘中读出数据到缓冲区。
磁盘访问耗时在毫秒级,对于处理器来说是很漫长的。boot loader发出磁盘读命令并反复读磁盘状态位直到数据就绪。这种轮询或者忙等待的方法对于boot loader来说是可以接受的,因为没有别的更好的办法了。但是在操作系统中,更有效的方法是让其他进程占有 CPU 并且在磁盘操作完成时接受一个中断。iderw 采用的就是后一种方法,维护一个等待中的磁盘请求队列,然后用中断来指明哪一个请求已经完成。虽然 iderw 维护了一个请求的队列,简单的 IDE 磁盘控制器每次只能处理一个操作。磁盘驱动程序的原则是:它已将队首的缓冲区送至磁盘硬件;其他的只是在等待他们被处理。
iderw(b)将buf b 送到队列的末尾。如果这个缓冲区在队首,iderw 通过 idestart 将它送到磁盘中;在其他情况下,当且仅当它前面的缓冲区被处理完毕,一个缓冲区被开始处理。
idestart 根据标志位处理缓冲区所在设备和扇区的读或者写操作。如果操作是一个写操作,idestart 必须提供数据而在写入磁盘的操作完成后会发出一个中断。如果操作是一个读操作,则发出一个代表数据就绪的中断,然后中断处理程序会读出数据。注意 iderw 有一些关于 IDE 设备的细节,并且在几个特殊的端口进行读写。如果任何一个 outb 语句错误了,IDE 就会做一些我们意料之外的事。保证这些细节正确也是写设备驱动程序的一大挑战。
当iderw 已经将请求添加到了队列中(并在必要的时候开始处理)之后,iderw 还必须等待结果。就像我们之前讨论的,轮询并不是有效的利用 CPU 的办法。相反,iderw 睡眠,等待中断处理程序在操作完成时更新缓冲区的标志位。当这个进程睡眠时,xv6 会调度其他进程来保持 CPU 处于工作状态。
最终,磁盘会完成自己的操作并且触发一个中断。trap 会调用 ideintr 来处理它。ideintr查询队列中的第一个缓冲区,看刚刚发生了什么操作。如果该缓冲区刚刚正在被读入并且disk controller有在等待的数据,ideintr 就会调用 insl 将数据从disk controller的缓冲区读到内存中。现在那个缓冲区已经就绪(读完了):ideintr 设置 B_VALID,清除 B_DIRTY,唤醒任何一个睡眠在这个缓冲区上的进程。最终,ideintr 将下一个等待中的缓冲区传递给磁盘。
Real world
想要完美的支持所有的设备需要投入大量的工作,这是因为各种各样的设备有各种各样的特性,设备和驱动之间的协议有时会很复杂。在很多操作系统当中,各种驱动合起来的代码数量要比系统内核的数量更多。
实际的设备驱动远比这一章的磁盘驱动要复杂的多,但是他们的基本思想是一样的:设备通常比 CPU 慢,所以硬件必须使用中断来提醒系统它的状态发生了改变。现代磁盘控制器一般在同一时间接受多个未完成的磁盘请求,甚至重排这些请求使的磁盘使用可以得到更高的效率。相比以前的简单磁盘,操作系统经常负责重排请求队列。
很多操作系统可以驱动固态硬盘,因为固态硬盘提供了更快的数据访问速度。虽然固态硬盘和传统的机械硬盘的工作机制很不一样,但是这两种设备都使用了基于块的接口,在固态硬盘上读写块仍然要比在内存在读写成本高的多。
其他硬件和磁盘非常的相似:网络设备缓冲区保存包,音频设备缓冲区保存音频采样,显存保存图像数据和指令序列。高带宽的设备,如硬盘,显卡和网卡在驱动中同样都使用直接内存访问(Direct memory access, DMA)而不是直接用 I/O(insl, outsl)。DMA 允许磁盘控制器或者其他控制器直接访问物理内存。驱动给予设备缓存数据区域的物理地址,可以让设备直接地从主存中读取或者写入,一旦复制完成就发出中断。使用 DMA 意味着 CPU 不直接参与传输,这样做的效率更高并且使得 CPU Cache 开销更小。
在这一章中的绝大多数设备使用了 I/O 指令来进行编程,但这都是针对老设备的了。而所有现代设备都使用内存映射 I/O (Memory-mapped I/O)来进行编程。
有些设备动态地在轮询模式和中断模式之间切换,因为使用中断的成本很高,但是在驱动去处理一个事件之前,使用轮询会导致延迟。举个例子,对于一个收到大量包的网络设备来说,可能会从中断模式到轮询模式之间切换,因为它知道会到来更多的包被处理,使用轮询会降低处理它们的成本。一旦没有更多的包需要处理了,驱动可能就会切换回中断模式,使得当有新的包到来的时候能够被立刻通知。
IDE 硬盘的驱动静态的发送中断到一个特定的处理器上。有些驱动使用了复杂的算法来发送中断,使得处理负载均衡,并且达到良好的局部性。例如,一个网络驱动程序可能为一个网络连接的包向处理这个个连接的处理器发送一个中断,而来自其他连接的包的中断发送给另外的处理器。这种分配方式很复杂;例如,如果有某些网络连接的活动时间很短,但是其他的网络连接却很长,这时候操作系统就要保持所有的处理器都工作来获得一个高的吞吐量。
用户在读一个文件的时候,这个文件的数据将会被拷贝两次。第一次是由驱动从硬盘拷贝到内核内存,之后通过 read 系统调用,从内核内存拷贝到用户内存。同理当在网络上发送数据的时候,数据也是被拷贝了两次:先是从用户内存到内核空间,然后是从内核空间拷贝到网络设备。对于很多程序来说低延迟是相当重要的(比如说 Web 服务器服务一个静态页面),操作系统使用了一些特别的代码来避免这种多次拷贝。一个在真实世界中的例子就是缓冲区大小通常是符合内存页大小的,这使得只读的数据拷贝可以直接通过分页映射到进程的地址空间,而不用任何的复制。
Lab3: Traps
阅读xv6chapter4和kernel/trampoline.S以及kernel/trap.c
可以发现,trampoline在kernel space和user space中具有相同的虚拟地址,这样当切换页表时不会影响它。
这段汇编代码做的工作就是当进入kernel mode时将用户寄存器的值保存到TRAPFRAME中,当切换回user mode时将TRAPFRAME中的值读到用户寄存器中去(分别为uservec和userret)。
而trap.c主要包括用户产生的中断处理函数usertrap,返回函数usertrapret以及处理内核态下中断的kerneltrap。
RISC-V assembly (easy)
执行make fs.img并阅读user/call.asm
addi 加立即数
li load立即数
ld 从地址中加载值
addiw 将16位立即数扩展为32再加
- main中a2和a1保存printf的参数,g( ),f( )中a0保存参数和返回值。
- li a1,12直接给出了f(8)+ 1 的结果,所以main在这条语句调用了f,f的实现用到了g,所以f也在这条语句调用了g。编译器通过内联函数实现这一点。
- printf位于0x64a 由 30: auipc ra,0x0
34: jalr 1562(ra) # 64a 这两局容易计算出0x30+1562=0x64a 也就是跳转到printf函数中去了。 - jalr 1562(ra)会跳转,并将跳转前的下一条指令的地址(返回地址)存入ra中,所以ra的值为0x38
- 将代码添加到main中(同时添加一段演示代码),根据执行结果,0xE110为57616的16进制形式,由于是小段存放,起始地址存放数据的最低字节,所以实际上的输出顺序为72 6c 64 00,参照ASCII表可以知道对应的字符串为r l d NULL。如果为大段存储,那么起始地址存放最高字节的内容我们要用0x726c6400来替换i才能获得相同的输出,但应该不需要更改57616。
6. 输出和新的汇编代码如下图所示,可以发现没有li a2的语句,所以printf只是打印保存在寄存器a2中的值,但其值并不确定。
Backtrace (moderate)
编译器为了能够追踪错误,在栈中维护了一个stack frame,他由两部分组成,一个是返回地址,一个是指向调用方函数的stack frame的frame pointer。有点类似与一个链表的结构。
这张图显式地给出了栈的结构,也就是说我们的当前stack frame pointer指向返回地址加8的位置,同时也是另一个frame pointer加16的位置。
当我们找找找,到了stack所在页的边界时就意味着该停止了(一个内核进程的栈只被分配一个物理页),由于后入栈(后别调用的函数)的元素在栈顶,当我们按照frame pointer找前一个函数的地址时,fp的值会逐渐变小,可以用while(uint64)fp>PGROUNDDOWN((uint64)fp)的条件来判断fp是否超出一页的范围。
PGROUNDDOWN取只取地址中以4096为单位的部分。如果这部分的值改变说明到了另外一个页。
运行后得到如下结果,之后用指令 addr2line -e kernel/kernel验证,得到调用函数的序列。
最后不要忘了再panic()中调用backtrace()来方便后面的debug。
点击查看代码
void
backtrace()
{
printf("backtrace:\n");
uint64* fp=(uint64*)(r_fp());
//printf("%d\n",sizeof(uint64));
while((uint64)fp>PGROUNDDOWN((uint64)fp))
{
printf("%p\n",(*((fp-1))));
fp=(uint64 *)(*(fp-2));
}
}
Alarm (hard)
test0:hint给出了user下的sigreturn()和sigalarm()的定义,这两个函数显然要调用相应的system call,我们用sys_sigreturn和sys_sigalarm实现从用户态向内核传参,将其保存在proc结构体的新定义的变量中。那么我们接下来就需要修改kernel/trap.c中的usertrap()函数使得当curtick为ticks的值时,调用proc->handler指定地址的函数。(注意到whick_dev表示中断来自什么设备)
根据hint简单修改一下
之后在sysproc.c中完成hint所说的内容
点击查看代码
uint64
sys_sigalarm(void)
{
int ticks;
uint64 handler;
argint(0,&ticks);
argaddr(1,&handler);
myproc()->ticks=ticks;
myproc()->handler=handler;
return 1;
}
uint64
sys_sigreturn(void)
{
return 0;
}
在usertrap()中加入根据时钟计数跳转到handler的中断处理
点击查看代码
if(which_dev == 2){
p->curtick++;
if(p->curtick>=p->ticks && p->ticks)
{
p->curtick=0;
p->trapframe->epc = p->handler;
}
}
这样test0就做完了。
test1\test2\test3
为了在alarm后不干扰user进程的正常运行我们还需要保存用户寄存器,返回地址,同时handler不能同时多次被启用,sigreturn的结果要保存在寄存器a0中。
总结一下中断处理的过程,由assembly trap handlers alltraps来保存返回地址,中断帧,中断号等内容到内核栈中,之后设置好CPU执行kernel/trap.c中的函数(usertrap,usertrapret,kerneltrap等),这些函数根据中断的类型该干嘛干嘛(exception就kill 硬件就wait syscall就调用syscall之类的)。
所以这一关我们参照一下普通的时钟中断是怎么做的,它在执行alltrap时将用户寄存器保存在p->procframe中,返回地址保存在p->frame->epc中。而我们修改trap.c中的usertrap想要让它先调用handler之后正常继续原来的user程序。但这里的halder和syscall不同,它并不知道保存在内核栈中的返回地址和用户寄存器也不会恢复它们,handler一般只产生一个返回值。
这里实验规定了handler在要结束时需要调用sys_sigreturn。
所以我们需要将利用用户栈中已经保存了的内容,将其拷贝出来供sys_sigreturn使用,使得handler在返回时就像从usertrap()中返回了一样。
点击查看代码
if(which_dev == 2)
{
if(p->ticks == 0)
yield();
else
{
p->curtick++;
if(p->curtick == p->ticks){
p->curtick=0;
p->ra=p->trapframe->epc;
memmove(p->origin_trapframe,p->trapframe,PGSIZE);
p->trapframe->epc=(uint64)p->handler;
}
else
yield();
}
}
usertrapret();
点击查看代码
uint64
sys_sigalarm(void)
{
int ticks;
uint64 handler;
argint(0,&ticks);
argaddr(1,&handler);
myproc()->ticks=ticks;
myproc()->handler=(void *)handler;
return 0;
}
uint64
sys_sigreturn(void)
{
struct proc *p = myproc();
p->handlerflag=0;
p->trapframe->epc = p->ra;
memmove(p->trapframe,p->origin_trapframe,PGSIZE);
return p->trapframe->a0;
}
点击查看代码
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
{
if(p->ticks == 0)
yield();
else
{
p->curtick++;
if(p->curtick >= p->ticks && (p->handlerflag == 0) ){
p->handlerflag=1;
p->curtick=0;
p->ra=p->trapframe->epc;
memmove(p->origin_trapframe,p->trapframe,PGSIZE);
p->trapframe->epc=(uint64)p->handler;
}
else
yield();
}
}
usertrapret();
make clean之后想给实验打个分,执行make grade发现我加上的r_fp( )函数报错了没法编译,蚌埠住了。(可以尝试删掉r_fp函数,编译一次,会报错,之后ctrl-z再编译一次有一定几率就行了)
Lab4: Copy-on-Write Fork for xv6
根据实验的介绍,我们在fork时不要新分配一个物理页,而是让子进程PTE指向父进程的物理页,但此时需要清除PTE_W,让两个进程都不能更改这个页。
当进程尝试write这个页时,显然会出现page fault,此时usertrap()要能识别这个page fault是由COW引起的,并在此时分配一个新的物理页,把子进程的PTE指向新页,记得将原本被修改的PTE_W修复。
当指向某一物理页的所有进程都结束时,该物理页才会被free,所以我们需要记录指向某一物理页的进程的个数,当它为0时,kfree()才能生效。
修改copyout(),当它遇到一个COW page,需要它能正确的确定读写权限。
Chapter 4 Locking
xv6的程序在两种情况下会相互之间产生干扰。
- 多处理器执行的代码操作同一块内存空间
- 单处理器中中断程序和非中断程序用到了相同的数据(当可中断程序执行时,发生了一个中断,这个中断修改了可中断程序使用的数据,之后返回了可中断程序中继续执行,那么这个程序显然有出错的可能)
我们使用锁来提供互斥,在同一时间只有持有锁的CPU才能使用数据结构。
Race conditions
The sequence of instructions between acquire and release is often called a critical section
关键段中的代码是我们定义的一个原子操作,它不可再分,不同的进程之间只能看到操作前和操作后的状态,而不能在操作中间横插一脚。
下面的“Using locks”一节会介绍如何选择关键段来在保证正确的同时获得更高的并行性。
Code: Locks
首先介绍spin lock。
xv6 用结构体 struct spinlock来表示它。spinlock结构体中关键的域是 locked 。它是一个字,在锁可以被获得时值为0,而当锁已经被获得时值为非零。
逻辑上讲,xv6 应该用下面的代码来获得锁
void
acquire(struct spinlock *lk)
{
for(;;) {
if(!lk->locked) {
lk->locked = 1;
break;
}
}
}
然而这段代码在多处理器上并不能保证互斥。有可能两个(或多个)CPU 接连执行到第25行,发现 lk->locked 为0,然后都执行第26、27行拿到了锁。这时,两个不同的 CPU 持有锁,违反了互斥。这段代码不仅不能帮我们避免竞争条件,它本身就存在竞争。这里的问题主要出在第25、26行是分开执行的。若要保证代码的正确,就必须让第25、26行是原子操作的。
为了让这两行变为原子操作, xv6 采用了x86硬件上的一条特殊指令 xchg(0569)。在这个原子操作中,xchg 交换了内存中的一个字和一个寄存器的值。函数 acquire(1574)在循环中反复使用 xchg;每一次都读取 lk->locked 然后设置为1(1581)。如果锁已经被持有了,lk->locked 就已经为1了,故 xchg 会返回1然后继续循环。如果 xchg 返回0,但是 acquire 已经成功获得了锁,即 locked 已经从0变为了1,这时循环可以停止了。一旦锁被获得了,acquire 会记录获得锁的 CPU 和栈信息,以便调试。当某个进程获得了锁却没有释放时,这些信息可以帮我们找到问题所在。当然这些信息也被锁保护着,只有在持有锁时才能修改。
循环执行的目的是等待这个锁被之前的所有者释放。
Code: Using locks
使用锁的一个难点在于要决定使用多少个锁,以及每个锁保护哪些数据、不变量。不过有几个基本原则。首先,当一个 CPU 正在写一个变量,而同时另一个 CPU 可能读/写该变量时,需要用锁防止两个操作重叠。第二,当用锁保护不变量时,如果不变量涉及到多个数据结构,通常每个数据结构都需要用一个单独的锁保护起来,这样才能维持不变量。
注意我们在考虑问题时,需要注意到即使是一个简单的操作,在翻译成汇编指令后也可能不是原子操作,意味着存在中间状态。同时还需要注意到操作指令的顺序可能和我们给出的并不一致。了解这些之后再考虑如果这时其他处理器或者中断程序介入会出现什么后果。
上面只说了需要锁的原则,那么什么时候不需要锁呢?由于锁会降低并发度,所以我们一定要避免过度使用锁。当效率不是很重要的时候,完全可以使用单处理器计算机,这样就完全不用考虑锁了。一个简单的内核可以通过在进入内核时获取一个锁,并在退出内核时释放这个锁来在多处理器上实现这一点(尽管诸如pipe或wait之类的系统调用会带来问题)。许多单处理器操作系统就用这种方法运行在了多处理器上,有时这种方法被称为“内核巨锁(giant kernel lock)”,但使用这种方法就牺牲了并发性:即一时间只有一个 CPU 可以运行在内核上。如果我们想要依靠内核做大量的计算,那么使用一组更为精细的锁来让内核可以在多个 CPU 上轮流运行会更有效率。
最后,对于锁的粒度选择是并行编程中的一个重要问题。xv6 只使用了几个简单的锁(见下图);例如,xv6 中使用了一个单独的锁来保护进程表及其不变量,我们将在第5章讨论这个问题。更精细的做法是给进程表中的每一个条目都上一个锁,这样在不同条目上运行的线程也能并行了。但是在进程表中维护那么多个不变量就必须使用多个锁,这就让情况变得很复杂了。不过 xv6 中的例子已经足够让我们了解如何使用锁了。
Deadlock and lock ordering
如果一段代码要使用多个锁,那么必须要注意代码每次运行都要以相同的顺序获得锁,否则就有死锁的危险。假设某段代码的两条执行路径都需要锁 A 和 B,但路径1获得锁的顺序是 A、B,而路径2获得锁的顺序是 B、A。这样就可能有下面的情况:路径1获得了锁 A,而在它继续获得锁 B 之前,路径2获得了锁 B,此时就死锁了。这时两个路径都无法继续执行下去了,因为这时路径1需要锁 B,但锁 B已经在路径2手中了,反之路径2也得不到锁 A。为了避免这种死锁,所有的代码路径获得锁的顺序必须相同。避免死锁也是我们把锁作为函数使用规范的一部分的原因:调用者必须以固定顺序调用函数,这样函数才能以相同顺序获得锁。
Xv6有许多长度为2的锁序链,涉及ptable.lock,这是由于睡眠的工作方式。
例如,ideintr在调用wakeup时持有ide锁,而wakeup会获取ptble lock。
文件系统中有很多两个锁的例子,例如文件系统在删除一个文件时必须持有该文件及其所在文件夹的锁。xv6 总是首先获得文件夹的锁,然后再获得文件的锁。
再例如,创建一个文件需要同时持有目录上的锁、新文件的inode上的锁,磁盘块缓冲区上的锁,idelock和ptable.lock。为了避免死锁,文件系统代码总是按照刚刚说的顺序获取锁。
Interrupt handlers
Xv6在许多情况下使用spin lock来保护中断处理程序和线程都使用的数据。例如,时钟中断可能(3414)在内核线程读取sys_sleep中的tick(3823)的同时增加tick。tickslock锁的使用能够使这两个进程顺序化操作tick数据。
即使在单个处理器上,中断也可能导致并发:在允许中断时,内核代码可能在任何时候停下来,然后执行中断处理程序。假设 iderw 持有 idelock,然后中断发生,开始运行 ideintr。ideintr 会试图获得 idelock,但却发现 idelock 已经被获得了,于是就等着它被释放。这样,idelock 就永远不会被释放了,只有 iderw 能释放它,但又只有让 ideintr 返回 iderw 才能继续运行,这样处理器、整个系统都会死锁。
为了避免这种情况,当中断处理程序会使用某个锁时,处理器就不能在允许中断发生时持有那个锁。xv6 做得更加保守:当处理器进入spin lock的关键段时,xv6始终确保在该处理器上禁用中断。但中断仍然可以发生在其他处理器上,因此其他处理器中断的可以等待那个禁用中断的处理器中的线程释放锁。
当处理器不再有spin lock时,xv6 重新启用中断。
我们必须做一点计数来处理嵌套的关键段。 acquire 调用 pushcli (1667) , release 调用 popcli (1679) 来跟踪当前处理器上锁的嵌套级别。当该计数达到零时,popcli 恢复存在于最外层关键段开始处的中断启用状态。 cli和sti函数分别执行x86中断禁用和启用指令。
重点是acquire要在可能获取锁的 xchg (1581) 之前,调用 pushcli。如果两者颠倒,就可能在几个时钟周期里,中断仍被允许,而锁也被获得了,如果此时不幸地发生了中断,系统就会死锁。类似的,release 也一定要在释放锁的 xchg 之后调用 popcli。
Instruction and memory ordering
在本章中,我们都假设了处理器会按照代码中的顺序执行指令。但是许多处理器会通过指令乱序来提高性能。如果一个指令需要多个周期完成,处理器会希望这条指令尽早开始执行,这样就能与其他指令交叠,避免延误太久。例如,处理器可能会发现一系列 A、B 指令序列彼此并没有关系,在 A 之前执行 B 可以让处理器执行完 A 时也差不多执行完 B。编译器在编译时可能也会通过指令乱序来优化程序。但是并发可能会让这种乱序行为暴露到软件中,导致不正确的结果。
这里介绍一下乱序执行,它和CPU流水线有关,当CPU发现指令之间没有依赖关系时,它会让周期长的指令先开始运行。 如图 否则交叠的部分就会变少,出现延误(可以自己动手画一画将原本的指令一换到指令三的位置执行的图)
例如下面的代码insert,即使我们使用了锁,如果指令乱序使得l->next = list ; list = l ;在release(&listlock)之后执行(因为在不知情的情况下release开起来和对list的操作没有依赖关系),那么另外的处理器可以获取锁,然后它会在list的头部插入一个l,那么就相当于覆盖了之前要进行的那次操作。
1 l = malloc(sizeof *l);
2 l->data = data;
3 acquire(&listlock);
4 l->next = list;
5 list = l;
6 release(&listlock);
为了告诉硬件和编译器不要执行此类重新排序,xv6 在acquire和release中都使用了 __sync_synchronize()。 _sync_synchronize() 是一个内存屏障:它告诉编译器和 CPU 不要重排穿过内存屏障的load和store操作。
xv6 只关心acquire和release的顺序,中间那部分对数据的操作如果没有依赖关系依旧是可以乱序的。
Sleep locks
有时 xv6 代码需要长时间持有锁。例如,文件系统(第 6 章)在磁盘上读取和写入文件内容时需要保持文件锁定,这些磁盘操作可能需要数十毫秒。效率要求在等待时程序要让出处理器,以便其他线程可以取得进展,这反过来意味着 xv6 需要在context切换时保持良好工作的锁。 xv6 以sleep lock的形式提供了这样的锁。
sleep lock支持在关键段里让出处理器。这种特性提出了一个设计挑战:如果线程 T1 持有锁 L1 并已让出处理器,而线程 T2 希望获取 L1,我们必须确保 T1 可以在 T2 等待时执行,以便 T1 可以释放 L1。 T2 不能在这里使用spin lock的acquire函数:它在关闭中断的情况下spin,这会阻止 T1 运行。为避免这种死锁,sleep lock的获取函数(称为 acquiresleep)在等待时让出处理器,并且不禁用中断。
acquiresleep (4622) 使用将在第 5 章中解释的技术。总的来说,sleep lock有一个受spin lock保护的locked字段,并且 acquiresleep 对sleep的调用以原子方式让出 CPU 并释放spin lock。这样做的结果会是其他线程可以执行,而 acquiressleep 等待。
因为sleep lock使中断处于启用状态,所以它们不能用于中断处理程序。因为 acquiresleep 可能会放弃处理器,所以不能在spin lock的关键段内使用sleep lock(尽管可以在sleep lock的关键段内使用spin lock)。
Xv6 在大多数情况下使用spin lock,因为它们的开销很低。它只在文件系统中使用sleep lock,这样可以方便地在冗长的磁盘操作中持有锁。
sleep lock 与spin lock的关键区别有两点:
- spin lock 用一个while循环等待之前的拥有者释放锁,之后就获得锁开始执行接下来的操作,也就是说用到自旋锁的线程有一个等待释放锁的过程。而sleep lock实际上在spin lock保护的关键段内检查locked段,如果locked = 1说明该锁已经被占用了,那么直接开摆,让出cpu并释放spin lock(释放spin lock是因为关键段相当于已经结束了),也就是说用到睡眠锁的线程并不会占用CPU等待持有者释放锁。
- spin lock 在获取前要禁用CPU的中断,但sleep lock允许,因为sleep lock主要用于磁盘操作,本身就需要硬件中断,当然还有涉及到让出处理器的其他原因。
Limitations of locks
锁往往能干净利落地解决并发问题,但也有笨拙的时候。后续章节会指出xv6中的此类情况;本节概述了出现的一些问题。
有时,一个函数使用必须由锁保护的数据,但已经持有锁的代码和并不试图持有锁的代码可能都要调用这个函数。处理这个问题的一种方法是让函数有两个变体,一个获取锁,另一个期望调用者已经持有锁;参见 wakeup1 (2953)。另一种方法是让函数要求调用者持有锁,无论调用者是否需要它,如 sched (2758)。内核开发人员需要了解此类要求。
似乎可以通过允许递归锁来简化调用者和被调用者都需要锁的情况,这样如果一个函数持有锁,它调用的任何函数都被允许重新获取锁。然而,程序员随后需要推理调用者和被调用者的所有组合,因为数据结构的不变量将不再总是在获取之后保持不变。递归锁是否比 xv6 使用关于需要持有锁的函数的约定更好尚不清楚。更糟糕的是(与避免死锁的全局锁排序一样)锁需求有时不是私有的,而是嵌入函数和模块的接口。
锁不能解决的情况是当一个线程需要等待另一个线程对数据结构的更新时,例如当pipe的reader等待其他线程写入pipe时。等待线程不能持有那个数据结构的锁,因为那样会阻止它正在等待的更新。相反,xv6 提供了一种单独的机制来共同管理锁和事件等待;见第五章wakeup和sleep的描述。
Real world
然而,锁无关编程比锁编程更复杂;例如,我们必须担心内存和指令乱序。xv6不涉及锁无关数据结构和算法,而只是用锁来保护需要保护的数据。
Chapter 5 Scheduling
任何操作系统都可能碰到进程数多于处理器数的情况,这样就需要考虑如何分享处理器资源。理想的做法是让分享机制对进程透明。通常我们对进程造成一个自己独占处理器的假象,然后让操作系统的多路复用机制(multiplex)将单独的一个物理处理器模拟为多个虚拟处理器。本章将讲述 xv6 是如何为多个进程模拟出多处理器的。
Multiplexing
Xv6 的multiplex在两种情况下将处理器从一个进程切换到另一个进程。
- Xv6 的sleep和wakeup机制在进程等待device或pipe I/O 完成,等待子进程exit,或者等待sleep system call时切换进程。
- Xv6 在执行用户指令时周期性地切换进程。
这样的多路复用机制为进程提供了独占处理器的假象,类似于 xv6 使用内存分配器和页表硬件为进程提供了独占内存的假象。
实施multiplex会带来一些挑战。一、如何从一个进程切换到另一个进程?xv6 采用了普通的context切换机制;虽然这里的思想是非常简洁明了的,但是其代码实现是操作系统中最晦涩难懂的一部分。二、如何透明地切换到用户进程? Xv6 使用标准技术通过时钟中断驱动上下文切换。第三,许多 CPU 可能同时在进程之间切换,必须设计一个锁来避免race contition。第四,进程退出时必须释放进程的内存和其他资源,但它不能自己完成所有这些,因为(例如)它无法在仍使用内核堆栈的同时释放自己的内核堆栈。最后,多核机器的每个内核都必须记住它正在执行哪个进程,以便系统调用影响对应进程的内核状态。 Xv6 试图尽可能简单地解决这些问题,但最后其代码实现还是比较“巧妙”。
xv6 必须为进程提供互相协作的方法。譬如,父进程需要等待子进程结束,以及读取管道数据的进程需要等待其他进程向管道中写入数据。与其让这些等待中的进程消耗 CPU 资源,不如让它们暂时放弃 CPU,进入睡眠状态来等待其他进程发出事件来唤醒它们。但我们必须要小心设计以防睡眠进程遗漏事件通知。本章我们将用管道机制的具体实现来解释上述问题及其解决方法。
Code: Context switching
上图展示了从一个用户进程切换到另一个用户进程所涉及的步骤:用户内核转换(系统调用或中断)到旧进程的内核线程,context切换到当前 CPU 的调度程序线程,context切换到新进程的内核线程,并trap return到用户级进程。 xv6 调度程序有自己的线程(保存的寄存器和堆栈),因为让它在任意进程的内核堆栈上执行并不安全;我们将在 exit 中看到一个示例。在本节中,我们将研究在内核线程和调度程序线程之间切换的机制。
线程的切换涉及到了保存旧线程的 CPU 寄存器,恢复新线程之前保存的寄存器;其中 %esp 和 %eip 的变换意味着 CPU 会切换运行栈与运行代码。
swtch 并不了解线程,它只是简单地保存和恢复寄存器集合,即上下文。当进程让出 CPU 时,进程的内核线程调用 swtch 来保存自己的上下文然后返回到调度器的上下文中。每个上下文都是以结构体 struct context* 表示的,这实际上是一个保存在内核栈中的指针。swtch 有两个参数:struct context **old、struct context *new。它将当前 CPU 的寄存器压入栈中并将栈指针保存在 *old 中。然后 swtch 将 new 拷贝到 %esp 中,弹出之前保存的寄存器,然后返回。
接下来我们先不考察调度器调用 swtch 的过程,我们先回到用户进程中看看。在第3章中我们知道,有可能在中断的最后,trap 会调用 yield。yield 又调用 sched,其中 sched 会调用 swtch 来保存当前上下文到 proc->context 中然后切换到之前保存的调度器上下文 cpu->scheduler(2822)。
Swtch (3052) 首先将其从堆栈中弹出参数复制到调用者保存的寄存器 %eax 和 %edx (3060-3061)中; swtch 必须在更改堆栈指针并不能再通过 %esp 访问参数之前执行此操作。然后 swtch 压入寄存器状态,在当前堆栈上创建context结构。只需要保存被调用者保存的寄存器; x86 上的约定是这些是 %ebp、%ebx、%esi、%edi, 和 %esp。swtch 显式地压入前四个寄存器(3064-3067);最后一个则是在 struct context* 被写入 old(3070)时隐式地保存的。要注意,还有一个重要的寄存器,即程序计数器 %eip,该寄存器在使用 call 调用 swtch 时就保存在栈中 %ebp 之上的位置上了。保存了旧寄存器后,swtch 就准备要恢复新的寄存器了。它将指向新上下文的指针放入栈指针中(3071)。新的栈结构和旧的栈相同,因为新的上下文其实是之前某次的切换中的旧上下文。所以 swtch 就能颠倒一下保存旧上下文的顺序来恢复新上下文。它弹出 %edi %esi %ebx %ebp 然后返回(3074-3078)。由于 swtch 改变了栈指针,所以这时恢复的寄存器就是新上下文中的寄存器值。
在我们的例子中,sched 调用 swtch 切换到 cpu->scheduler,即 per-Cpu 的scheduler的context。这个上下文是在之前 scheduler 调用 swtch(2781)时保存的。当 swtch 返回时,它不会返回到 scheduler 中,而是返回到 scheduler,其栈指针指向了当前 CPU 的scheduler的栈,而非 initproc 的内核栈。
Code: Scheduling
Code: mycpu and myproc
Sleep and wakeup
Code: Sleep and wakeup
Code: Pipes
Code: Wait, exit, and kill
Real world
Chapter 6 File system
Overview
Buffer cache layer
Code: Buffer cache
Logging layer
Log design
Code: logging
Code: Block allocator
Inode layer
Code: Inodes
Code: Inode content
Code: directory layer
Code: Path names
File descriptor layer
Code: System calls
Real world
Chapter 7 Summary
Appendix A PC hardware
Processor and memory
I/O
Appendix B The boot loader
Code: Assembly bootstrap
Code: C bootstrap
Real world
MIT6.1810の学习笔记的更多相关文章
- MIT6.828学习笔记1
Lab 1: Booting a PC Part 1: PC Bootstrap The PC's Physical Address Space 早期的PC机基于Intel的8088处理器,能够寻址1 ...
- MIT6.828学习笔记3(Lab3)
Lab 3: User Environments 在这个lab中我们需要创建一个用户环境(UNIX中的进程,它们的接口和实现不同),加载一个程序并运行,并使内核能够处理一些常用的中断请求. Part ...
- shell学习笔记
shell学习笔记 .查看/etc/shells,看看有几个可用的Shell . 曾经用过的命令存在.bash_history中,但是~/.bash_history记录的是前一次登录前记录的所有指令, ...
- Zabbix学习笔记(yum源安装)
Zabbix学习笔记(yum源安装) 链接:https://pan.baidu.com/s/19RXhumkB-ulpI4BGOa5b_A 提取码:115h 复制这段内容后打开百度网盘手机App,操作 ...
- XV6学习笔记(1) : 启动与加载
XV6学习笔记(1) 1. 启动与加载 首先我们先来分析pc的启动.其实这个都是老生常谈了,但是还是很重要的(也不知道面试官考不考这玩意), 1. 启动的第一件事-bios 首先启动的第一件事就是运行 ...
- js学习笔记:webpack基础入门(一)
之前听说过webpack,今天想正式的接触一下,先跟着webpack的官方用户指南走: 在这里有: 如何安装webpack 如何使用webpack 如何使用loader 如何使用webpack的开发者 ...
- PHP-自定义模板-学习笔记
1. 开始 这几天,看了李炎恢老师的<PHP第二季度视频>中的“章节7:创建TPL自定义模板”,做一个学习笔记,通过绘制架构图.UML类图和思维导图,来对加深理解. 2. 整体架构图 ...
- PHP-会员登录与注册例子解析-学习笔记
1.开始 最近开始学习李炎恢老师的<PHP第二季度视频>中的“章节5:使用OOP注册会员”,做一个学习笔记,通过绘制基本页面流程和UML类图,来对加深理解. 2.基本页面流程 3.通过UM ...
- 2014年暑假c#学习笔记目录
2014年暑假c#学习笔记 一.C#编程基础 1. c#编程基础之枚举 2. c#编程基础之函数可变参数 3. c#编程基础之字符串基础 4. c#编程基础之字符串函数 5.c#编程基础之ref.ou ...
- JAVA GUI编程学习笔记目录
2014年暑假JAVA GUI编程学习笔记目录 1.JAVA之GUI编程概述 2.JAVA之GUI编程布局 3.JAVA之GUI编程Frame窗口 4.JAVA之GUI编程事件监听机制 5.JAVA之 ...
随机推荐
- kettle从入门到精通 第五十六课 ETL之kettle Microsoft Excel Output
1.9.4 版本的kettle中有两个Excel输出,Excel输出和Microsoft Excel输出.前者只支持xls格式,后者支持xls和xlsx两种格式,本节课主要讲解步骤Microsoft ...
- 苹果手机 ios 系统如何升级为鸿蒙HarmonyOS
用苹果手机的朋友们注意了 根据最新的可靠消息,苹果手机升级为HarmonyOS,教程如下: 第一步 手机电量充足的情况下,将苹果手机连接至WIFI无线网络. 第二步 ...... [下一页]
- Spring源码——AOP实现原理
引言 Spring AOP(Aspect Orient Programming),AOP翻译过来就是面向切面编程,它体现的是一种编程思想,是对面向对象编程(OOP)的一种补充. 在实际业务开发过程中, ...
- flutter 尝试创建第一个页面(三)
新建目录 assets 存放图片 在pubspec..yaml 中添加 flutter: # The following line ensures that the Material Icons f ...
- AI赋能ITSM:企业运维跃迁之路
随着企业信息化建设的深入,IT运维管理作为保证企业信息系统稳定运行的重要工作,越来越受到重视. 那么,什么是IT运维呢? 简单地说,IT运维是一系列维护.管理和优化企业IT基础设施.系统和应用程序的活 ...
- 数据标注工具 doccano
目录 安装 运行 doccano 使用 doccanno 上传数据 定义标签 添加成员 开始标注 导出数据 查看数据 统计 数据标注工具 Label-Studio 安装 打开命令行(cmd.termi ...
- 日志之log4j2和springboot
log4j2比logback好用. 现在之所有以spring采用logback,根据我个人的理解应该是某种非常特殊的理由.否则log4j2的性能比logback更好,且异步性能极好! 异步日志是log ...
- mac brew install Error: No available formula with the name “*“的解决办法
背景 在mac上使用brew安装软件发生错误 解决办法 执行以下命令即可 rm -rf /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core ...
- 3D捕鱼大富翁源码分析
今天接受了一个捕鱼的源码,技术栈采用: 客户端:Unity 服务端:Java 数据库:mysql 缓存:redis 先来几张成品图 编辑编辑 编辑编辑 编辑 在代码中看到有腾讯推广渠道, ...
- 小米节假日API, 查询调休
小米的节假日API, 用于查询一年中的第X天是否正在放假或是在调休. 在浏览器中打开保存下来, 一年只需要调用一次即可. https://api.comm.miui.com/holiday/holid ...