zl程序教程

您现在的位置是:首页 >  其它

当前栏目

MIT_6.s081_Information1:Operating System Overview

system MIT Operating S081 Overview
2023-06-13 09:15:43 时间

MIT_6.s081_Information1:Operating System Overview

于2022年3月2日2022年3月2日由Sukuna发布

由于现存的xv6文档中文翻译基于x86架构,我只能重新啃英文原版了.

1 xv6系统的启动过程:

1.1xv6引导器

当x86系列的PC机启动的时候,首先会执行BIOS程序,BIOS程序一般会存放在固定的ROM中,一般在磁盘固定扇区中.BIOS 的作用是在启动时进行硬件的准备工作,接着BIOS程序会把控制权递交给操作系统.具体来说,BIOS会把控制权递交给从引导扇区中的固定的代码中(BIOS会把引导扇区存储的代码加载到内存0x7c00处),接着引导程序会把操作系统内核载入到内存中,控制权递交给内核,程序是M态的.

在xv6系统,引导程序由汇编引导程序和代码引导程序.

1.2 内核态进入用户态

阅读kernel.asm(内核整体的代码)

Disassembly of section .text:

0000000080000000 <_entry>:
    80000000:	00009117          	auipc	sp,0x9
    80000004:	86013103          	ld	sp,-1952(sp) # 80008860 <_GLOBAL_OFFSET_TABLE_+0x8>
    80000008:	6505                	lui	a0,0x1
    8000000a:	f14025f3          	csrr	a1,mhartid
    8000000e:	0585                	addi	a1,a1,1
    80000010:	02b50533          	mul	a0,a0,a1
    80000014:	912a                	add	sp,sp,a0
    80000016:	652050ef          	jal	ra,80005668 <start>

我们看到了_entry这个标签,也就是说内核是从_entry开始运行的,那我们首先查看一下entry.S的代码:

	# qemu -kernel loads the kernel at 0x80000000
        # and causes each CPU to jump there.
        # kernel.ld causes the following code to
        # be placed at 0x80000000.
.section .text
.global _entry
_entry:
	# set up a stack for C.
        # stack0 is declared in start.c,
        # with a 4096-byte stack per CPU.
        # sp = stack0 + (hartid * 4096)
        la sp, stack0
        li a0, 1024*4
	csrr a1, mhartid
        addi a1, a1, 1
        mul a0, a0, a1
        add sp, sp, a0
	# jump to start() in start.c
        call start

引导程序会把内存载入到0x80000000这个地址是因为在0~0x80000000这个地址范围内还有I/O设备等(还有程序的逻辑地址)

entry.S开始设置了一个栈,栈的带下是1024*4=4KB,其中mhartid是运行当前程序的CPU核的ID,那么第i个核的栈地址空间就分配到stack+(hartid)*4096~stack+(hartid+1)*4096这个范围内.

因为这个操作系统是运行在多核的RISC-V操作系统上,由多个核同时访问一个内存空间,所以说每个核的CPU只在允许的内存空间中执行代码.其中每个核的寄存器又是不一样的,所以说可以修改每个核的sp寄存器来区分不同的核的代码运行空间.

在entry.S执行完操作之后,根据汇编代码,程序会跳转到start的这个函数中

// entry.S jumps here in machine mode on stack0.
void start()
{
  // set M Previous Privilege mode to Supervisor, for mret.
  unsigned long x = r_mstatus();
  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;
  w_mstatus(x);

  // set M Exception Program Counter to main, for mret.
  // requires gcc -mcmodel=medany
  w_mepc((uint64)main);

  // disable paging for now.
  w_satp(0);

  // delegate all interrupts and exceptions to supervisor mode.
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  // configure Physical Memory Protection to give supervisor mode
  // access to all of physical memory.
  w_pmpaddr0(0x3fffffffffffffull);
  w_pmpcfg0(0xf);

  // ask for clock interrupts.
  timerinit();

  // keep each CPU's hartid in its tp register, for cpuid().
  int id = r_mhartid();
  w_tp(id);

  // switch to supervisor mode and jump to main().
  asm volatile("mret");
}

在start函数中我们还是会执行在M态执行的操作:

首先会执行mepc寄存器的操作,改变了mepc寄存器就相当于改变了断点值.这个寄存器相当于S态中断进入到M态的时候,触发中断的PC,当M态回到S态的时候,就继续从断点处执行.当然这个操作还制定了M态比S态更加接近于内核.

接着下一步就是取消分页,在这一部分,虚拟地址是和实际的物理地址是一一对应的.

再下一步,将所有的中断委托给S态进行处理.

再下一步,指定程序允许的物理地址,在S态我们允许访问所有的物理地址

在下一步,对时钟芯片编程以产生计时器中断.

再下一步,取CPU的核id

最后一步,返回到main()函数,执行mret指令.

操作系统接着就会进入main函数,main函数主要初始化设备和一些子系统,然后调用userinit()函数来生成第一个进程,第一个进程只会运行很基础的程序,这个程序再initcode.S中已经声明.(这个时候已经进入U态了)

# Initial process that execs /init.
# This code runs in user space.

#include "syscall.h"

# exec(init, argv)
.globl start
start:
        la a0, init
        la a1, argv
        li a7, SYS_exec
        ecall

系统调用的参数是a0~a7,其中a0~a6代表argv等,a7代表具体执行什么系统调用.

这个时候系统会执行init程序,init程序这个时候就会加载sh程序,并且会初始化一个新的文件描述器(你可以认为是标准的I/O输入输出,控制台输出,因为Unix类型的系统会把设备当成文件来看),然后执行Shell程序,系统开始执行

2 操作系统接口与系统调用

这个操作系统没有图形化界面,目前只能执行基本的键盘命令.

操作系统通过接口向用户程序提供服务。设计一个好的接口实际上是很难的。一方面我们希望接口设计得简单和精准,使其易于正确地实现;另一方面,我们可能忍不住想为应用提供一些更加复杂的功能。解决这种矛盾的办法是让接口的设计依赖于少量的机制 (mechanism),而通过这些机制的组合提供强大、通用的功能。

xv6提供 Unix 操作系统中的基本接口(由 Ken Thompson 和 Dennis Ritchie 引入),同时模仿 Unix 的内部设计。Unix 里机制结合良好的窄接口提供了令人吃惊的通用性。这样的接口设计非常成功,使得包括 BSD,Linux,Mac OS X,Solaris (甚至 Microsoft Windows 在某种程度上)都有类似 Unix 的接口。理解 xv6 是理解这些操作系统的一个良好起点。

xv6 使用了传统的内核概念 – 一个向其他运行中程序提供服务的特殊程序。每一个运行中程序(称之为进程)都拥有包含指令、数据、栈的内存空间。指令实现了程序的运算,数据是用于运算过程的变量,栈管理了程序的过程调用。

听上去很绕是吧,我们可以简单地理解,就是操作系统类似于高速公路的服务区,程序就是司机,司机在高速公路上开车相当于程序的正常执行,进入高速公路服务区司机就不会继续开车了,程序暂停执行.所以说程序获得操作系统提供的服务是通过中断的方式获得的.这个中断可以简称访管中断.

程序想获得操作系统的服务,先通过访管中断进入中断处理程序,这个时候就进入到了S态,在中断处理程序中根据某种特殊寄存器的值跳转到特殊的地址执行特殊的程序,这种特殊的程序叫做系统调用.因为用户态的程序权限有限,所以说要向操作系统提获取给更高的权限.只能通过中断的方式获取.

总得来说,进程通过系统调用使用内核服务。系统调用会进入内核,让内核执行服务然后返回。所以进程总是在用户空间和内核空间之间交替运行。

3 更多的基础知识

3.1 内核的组成

RISC-V的CPU主要分成三个态,操作系统会在三个态中穿插进行.

其中M态是机器态,在M态的操作系统有最高的权限,最高的优先级,可以执行所有指令,但是操作系统一般只在刚开始启动的时候是M态,在执行了一段初始化代码后就会降低到S态.

操作系统的内核一般是在S态进行运行,在S态,我们可以执行所有的指令,包括一部分特权指令,特权指令不知道的回去翻一下操作系统书.

在操作系统的空间划分中,我们一般划分内核态和用户态空间,在S态的时候,所有的程序和堆栈都是在内核态空间的,在U态的时候,所有的程序和堆栈都是在用户态空间的.

综上所述,内核主要由内核态空间和一些系统调用组成.这种内核一般称为monolithic kernel.

下面给出了基本的内核态运行的代码,运行这些代码就需要通过系统调用进入内核态,执行syscall.c之后然后再跳转到具体的代码.

对于另外一种microkernel的操作系统,它们会把一部分应该在S态运行的代码下放到U态防止出现问题,这个叫做微内核.

3.2 程序的逻辑地址

程序空间主要由基本的代码和数据,栈,堆,栈帧组成.其中栈帧保存了当前程序执行的时候一些基本的寄存器、断点信息、页表信息和CPU信息.

struct trapframe {
  /*   0 */ uint64 kernel_satp;   // kernel page table
  /*   8 */ uint64 kernel_sp;     // top of process's kernel stack
  /*  16 */ uint64 kernel_trap;   // usertrap()
  /*  24 */ uint64 epc;           // saved user program counter
  /*  32 */ uint64 kernel_hartid; // saved kernel tp
  /*  40 */ uint64 ra;
  /*  48 */ uint64 sp;
  /*  56 */ uint64 gp;
  /*  64 */ uint64 tp;
  /*  72 */ uint64 t0;
  /*  80 */ uint64 t1;
  /*  88 */ uint64 t2;
  /*  96 */ uint64 s0;
  /* 104 */ uint64 s1;
  /* 112 */ uint64 a0;
  /* 120 */ uint64 a1;
  /* 128 */ uint64 a2;
  /* 136 */ uint64 a3;
  /* 144 */ uint64 a4;
  /* 152 */ uint64 a5;
  /* 160 */ uint64 a6;
  /* 168 */ uint64 a7;
  /* 176 */ uint64 s2;
  /* 184 */ uint64 s3;
  /* 192 */ uint64 s4;
  /* 200 */ uint64 s5;
  /* 208 */ uint64 s6;
  /* 216 */ uint64 s7;
  /* 224 */ uint64 s8;
  /* 232 */ uint64 s9;
  /* 240 */ uint64 s10;
  /* 248 */ uint64 s11;
  /* 256 */ uint64 t3;
  /* 264 */ uint64 t4;
  /* 272 */ uint64 t5;
  /* 280 */ uint64 t6;
};

3.3 进程

进程就是运行的代码,进程可以通过调用ecall指令来进入到S态.其中进入到S态的何处就是有S态进行定义的.S态也可以调用sret指令回到断点处继续执行指令.

下面给顶了进程的代码

struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int pid;                     // Process ID

  // wait_lock must be held when using this:
  struct proc *parent;         // Parent process

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;               // Virtual address of kernel stack
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // User page table
  struct trapframe *trapframe; // data page for trampoline.S
  struct context context;      // swtch() here to run process
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

其中trapframe就是栈帧,pagetable就是页表.(可以复习一下OS关于内存的论述),state用于描述进程的状态.然后chan我猜测是进程等待队列的下一个元素.file就是打开的文件,cwd就是当前的目录,kstack是内核栈的位置,parent就是父进程.

进程之间是用数组来进行组合的.

在这一节你需要知道的是:

每一个进程都有一个页表来标记va和pa,不同的进程靠不同的页表来物理地相互阻隔,由于页表不同,不同的进程访问相同的虚拟地址,却是访问不同的物理地址,达到了物理的阻塞.

每一个进程再一定的时间会被其他的进程打断,这个时候CPU停止对于这个进程的执行,转而执行其他的进程.

每个进程都有两个栈,在U态处理的时候访问用户栈,在S态处理的时候访问内核栈.当进程被打断的时候信息会存储在用户态栈中.

总之,一个进程有控制流,什么时候控制CPU,还有数据流,对于内存和栈的访问.