O/S提供CPU等硬件资源分配以及安全的功能

  • 时分(time-share)的机制:让CPU资源能够被合理的调度。进程调度
  • 隔离性(isolation):进程的资源(如内存)与故障都不会相互干扰
  • 复用(multiplexing):c语言的库文件
  • 交互(interaction):进程之间的通讯,如管道pipe

xv6运行在多核RISC-V微处理器上,RISC-V是一个64位CPU.

xv6是一个用”LP64“C编写的内核(L-long,P-pointer),在c语言中long类型与pointer类型都是64位,int类型位32位。

qemu是一个c语言编写的模拟硬件的程序,但是我们在编写xv6时,应该qemu看作一块RISC-V主板,内含CPU、ROM、RAM、磁盘等等硬件,xv6就是运行在该主板上的操作系统。

抽象物理资源

抽象

为什么要使用操作系统?

应用通过图1.2的系统调用接口(库)可以直接与硬件资源交互,方便程序设计者去编写程序

库方法的缺陷

有多个应用同时运行,应用必须保持良好的特性(不能有bug)。例如应用需要周期性的放弃CPU,让其他应用使用。这便是时分的调度资源的方法。

协助时分方案(cooperative time share scheme):提供了共享CPU的方法,但是如果有bug的软件运行,那么会导致整个系统奔溃。因此我们需要系统提供隔离性。

将物理资源抽象为 服务(文件服务)或是系统调用 提供那些便利?

  1. 强隔离性:禁止应用直接访问硬件资源,而是提供便捷的方式去访问物理资源。
  • 提供文件服务(文件名)方便对硬盘资源进行交互操作,使用read、write、open接口使用硬盘资源

  • 通过进程访问cpu的资源,同时应用也不需要考虑时间共享问题,系统中有调度程序去控制进程的切换。

  1. 编程便捷性:对于程序员来说,我们不需要记住这些硬件的(二进制)指令来进行编程,而是通过简单易懂的接口直接使用物理资源进行程序编写。

模式与系统调用

强隔离性:

  • 应用与硬件隔离:禁止应用直接访问敏感的硬件资源,将资源抽象为服务。例如使用文件系统调用接口去访问磁盘。
  • 进程隔离:一个进程不能够访问(操作)另一个进程的资源,例如:不能篡改另一个进程的内存资源
  • 硬边界(安全性):用户空间与内核空间的实现。当应用出错时,O/S进程不会被这一个应用影响,相反O/S会将这个错误应用清除并运行另一个应用

O/S实现强隔离性

  1. 应用禁止修改O/S的数据结构和指令
  2. 应用不能访问其他进程资源

CPU提供隔离性

在RISC-V这样中三种特权模式:

  1. 机器模式(machine mode):CPU初始时以该模式启动,并且指令最高特权级运行,该模式主要用于配置计算机。
  2. 管理者模式(supervisor mode):允许CPU执行特权指令,如禁用中断,读写页表地址寄存器等。用户执行这些指令会导致程序终止。运行在内核空间的软件称为内核,其可以执行特权指令
  3. 用户模式(user mode):应用只能执行用户指令,运行在用户空间。

特权模式转换

应用想要调用内核函数(系统调用)必须要转到内核,但是应用不能直接调用内核函数,

CPU提供了特别指令:从用户模式到管理者模式然后进入内核指定的入口点(entry point)。在RISC-V中使用ecall可以达到目的

当CPU转换到管理者模式,内核将验证系统调用的参数,决定是否要执行请求操作。

特权级修改指令

  • ecall:当进程使用系统调用将会执行ecall指令(RISC-V),该指令将会提升硬件的特权级,改变程序计数器到内核定义的入口点(entry point)。这样就可以转到内核栈并执行内核指令。
  • sret:当内核执行完系统调用返回用户空间时,使用sret指令将会降低硬件的特权级,并重新执行用户指令

内核组织

关键:O/S运行在管理者模式

单内核(monolithic kernel)

定义:整个操作系统都在内核空间中,所有系统调用都运行在管理者模式

这种内核组织中整个O/S以完全的(full)硬件特权级运行,这样的方便之处在于

  • OS的设计者不需要决定O/S的那一个部分需要完全硬件特权级
  • 使O/S不同部分之间的协作更加简单,如文件系统与虚拟内存系统可以共享缓冲区缓存

缺点

  • O/S的不同部分的接口会更加复杂,容易让系统开发者犯错。同时在单内核中犯错是致命的
  • 内核故障会导致整个计算机停止工作,同时应用也会故障,计算机必须重启才行。

微内核(microkernel)

定义:减少大量运行在管理者模式的OS代码,并将大部分的OS在用户模式运行

图2.1

图中阐明了,文件系统像一个用户程序一样运行,作为进程运行的OS服务称为服务器,为了允许应用与文件服务器交互,内核提供了了进程间的交流机制从一个用户进程发送消息(message)到另一个进程。

总结

实际上,单内核与微内核都非常普遍。许多Unix的内核实现如单内核,而向Minix、L4、QNX这样的OS就是微内核,并且微内核经常适用于嵌入式设置开发。

xv6是一个单内核实现的(类unix系统),因此xv6内核接口相对于OS接口,内核实现完全像是一个OS。

代码:xv6组织

xv6的内核资源圈在kernel/的子文件中,资源也是被分为文件,像是一个个模块一样。图2.2也列出了这些文件,模块间的接口定义在(kernel/defs.h)中。

进程概述

进程抽象

xv6中的隔离单元(unit of isolation)就是一个进程,进程抽象是为了预防进程破坏或是监听另一进程的资源(CPU、内存、文件描述符等),同时也保证了进程不会破坏内核程序,更不可能破坏内核的隔离机制。

进程抽象对隔离性的实施提供了帮助

  • 程序像是运行在一个私有的机器上。这机器中有私有的内存系统和地址空间,其他进程禁止读写。同时进程运行在这个机器的CPU上,执行程序指令。

但实际上这个机器上可以运行许多个程序,而进程都会共享机器的CPU、内存(虚拟内存)等资源。进程的抽象给进程造成了一种假象,那就是这个机器只有我这一个程序在运行。

页表提供了内存的隔离性

xv6中我们使用了页表(用硬件实现的)给每个进程一个私有空间,将内存的物理空间(物理地址,physical address,PA)划分一个固定大小的地址空间(图2.3)给进程作为私有的虚拟空间(虚拟地址,virtual address,VA)。

那么通过这样就可以实现内存上的隔离,简单来说就是将一块大蛋糕(物理内存)划分给每一个进程,给它们一种自己得到所有蛋糕的错觉,那么它们就不会打扰其他进程的运行了

RISC-V页表的作用,主要是将虚拟地址通过映射转换为一个物理地址,也就是map(VA->PA)

xv6中维持了给每一个页表维持了一个独立的页表,定义了进程的地址空间。进程的虚拟地址空间是从0开始的,如图2.3中的布局

  1. user text and data:保存了指令与全局变量
  2. user stack:用户栈,保存了局部变量与函数调用地址
  3. heap:堆,用户可以根据需求进行扩展(malloc)
  4. trapframe:一个页(page),映射保存/恢复用户进程的状态
  5. trampoline:一个页(page),包含进出内核的代码

进程地址空间有所限制,RISC-V是一个64位宽的指针,在页表硬件只用低39位查找虚拟地址空间,xv6只使用39位中的38位。最大的地址空间为$MAXVA=2^{38}-1$ = 0x3fffffffff。

Figure2.3.png

进程的虚拟内存分配(扩展)

《CSAPP》进程地址空间分配::进程创建时会分配这些固定的内存,从上到下依次为

  1. 用户栈(Stack)(运行期创建):函数调用与局部数据变量
  2. 内存映射区域:链接共享库
  3. 运行期堆(heap):C语言以malloc分配,C++以new分配
  4. 读写段(R/W Segment):
    • .data:已经初始的全局变量和静态变量(C语言的Static变量)。
    • .bss:未初始化(或是初始化为0)的全局变量和静态变量。
      说明全局变量和静态变量(全局静态变量也是一样)的生命周期是编译期到程序结束,注意局部的变量是保存在用户栈中。
  5. 只读段(R/O Segment):
    • .init:定义了一个小函数,叫做_init,被程序初始化代码调用
    • .text:编译期生成的机器码
    • .rodata:只读数据,如printf语句中的格式化字符串,或是const声明的变量

Process space

程序计数器: program counter 简称为PC

PC是CPU中的寄存器,保存了当前正在执行的指令的地址(位置)。当每个指令被获取,PC的存储地址加一。在每个指令被获取之后,程序计数器指向顺序中的下一个指令。当计算机重启或复位时,程序计数器通常恢复到零。

进程组成

  • 线程(thread):进程最开始只有一个线程,运行进程的指令
  • 内核栈:p->kstack,内核栈是一个独立存在,因此,进程的用户栈出现问题时,内核也可以运行。
  • 用户栈:储存局部变量等数据
  • 进程状态:p->state,xv6中进程状态:分配(allocated)、就绪(ready)、运行(running)、阻塞(blocked)
  • 页表:p->pagetable,页表作为物理地址的记录分配后存储在内存中,在用户空间执行进程时,xv6使分页硬件使用p->pagetable。

栈的切换

  1. 进程运行用户指令只使用用户栈,而用户栈为空
  2. 进程进入内核,用户栈会保存数据,但不会使用这个栈
  3. 进程的线程将会选择用户栈与内核栈

进程状态(扩展):OSTEP

  • 运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。
  • 就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。
  • 阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。一个常见的例子是,当进程向磁盘发起 I/O 请求时,它会被阻塞,因此其他进程可以使用处理器。

Process: State Transitions

进程:两个主要设计思想

  1. 地址空间(内存虚拟化):给进程以为自己拥有整个内存的错觉
  2. 线程(CPU虚拟化):给进程以为自己独占CPU的错觉

代码分析

进程结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Per-process state
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)
};

在xv6的内核维持了进程的许多状态,全都汇集到了struct proc数据结构中。我们通常使用p->xxx指向一个proc结构体中的元素。

进程重要的状态

  • 页表
  • 内核栈(p->kstack)
  • 运行状态(proc.h中procstate枚举类型)
1
extern struct cpu cpus[NCPU];

CPU状态

1
2
3
4
5
6
7
// Per-CPU state.
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};

XV6启动

内核启动

加载内核

RISC-V主板电源启动->ROM中的主引导程序(Boot loader)将内核程序载入内存->进入机器模式

加载机器模式(machine mode)

CPU执行_entry程序,将分页(paging)硬件禁用(start.c:34):虚拟内存直接映射到物理内存

为什么XV6内核在内存中的初始化物理地址为0x80000000?

0x00000000:0x80000000这一段地址保存的是I/O的设备

生成内核栈

_entry中加载了栈指针寄存器sp其地址为stack0+4096,处于栈顶(栈在RISC-V中是向下增长的),生成内核栈。

设置寄存器

entry.S在之后调用了start.c文件,开始配置特权模式

mret这个指令,可以让CPU进入管理者(surpervisor)模式,主要是用于让上一个调用中返回,从管理者模式返回到机器模式。

  1. mstatus:保存了上一个特权模式,调用mret指令就进入该特权模式
  2. mepc:设置mret的返回地址
  3. satp:页表寄存器,写入0就禁用了页表硬件

初始化时钟,设置时间片

start函数最后调用mret指令,将程序计数器转到main,进入管理者模式转到main.c程序中

内核模式如何配置查看后续的代码分析中的main函数

代码分析

1.entry.S:生成内核栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.section .text
.global _entry
_entry:
# 设置C语言的栈
# stack0在start.c中被声明
# with a 4096-byte stack per CPU. 每个CPU的栈只有4096字节
# 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
spin:
j spin

2.start.c

  • 在11行处声明了,stack0
  • 在21行处_entry被调用入了C代码

start不会像一个调用一样返回(returning),而是将这些事情设置的像有过调用一样,并且在机器模式下执行

  1. 通过在寄存器mstatus中,设置之前的特权模式为管理者模式(24-27行)
  2. 通过写入main地址到寄存器mepc,设置返回地址为main的地址(31行)
  3. 通过写入0到页表寄存器satp,在管理者模式中将虚拟地址禁用(34行)
  4. 将所有的异常与中断托付给管理者模式(37-39行)
  5. 管理者模式能够接触到所有物理内存(43-44行)

简单来说通过设置mstatus的模式为管理者模式,通过调用mret就能进入管理者模式了。而我们将mret的返回地址设置为main,这样xv6就在管理者模式中进入内核程序的主函数中了

时钟生成

在进入管理者模式之前,start还要执行一个任务(47行):对时间切片进行编程初始化时钟中断

进入管理者模式,转到内核主函数

在54行处通过mret汇编代码转换为管理者模式(内核模式),进入main函数,mret指令最多被用来从先前的管理者模式转到机器模式。之后程序的计数器转到main函数(main.c:11)

1
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

void main();
void timerinit();

// entry.S needs one stack per CPU.内核栈
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

// a scratch area per CPU for machine-mode timer interrupts.
uint64 timer_scratch[NCPU][5];

// assembly code in kernelvec.S for machine-mode timer interrupt.
extern void timervec();

// 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");
}

user/main.c

main程序初始化设备与子系统(文件系统),它通过userinit(proc.c:233行)去创建第一个进程。第一个进程将会执行汇编写的程序user/initcode.S,进行第一个系统调用。

在initcode.S(3行)载入了exec系统调用的编号,SYS_EXEC(syscall.h:8行)到寄存器a7然后调用ecall返回到内核

1
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
# 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

# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit

# char init[] = "/init\0";
init:
.string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0

内核在syscall(132行)中使用寄存器a7的编号,去调用想要的系统调用。系统调用表(syscall.c)持有SYS_EXEC到sys_exec的映射

当内核完成exec,将会/init进程返回到用户空间。init(user/init.c:15行)创建一个控制台的设备文件,然后在控制台中启动console,xv6启动了

1
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
36
37
38
39
40
41
42
43
44
45
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

volatile static int started = 0;

// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
binit(); // buffer cache
iinit(); // inode table
fileinit(); // file table
virtio_disk_init(); // emulated hard disk
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}

scheduler();
}

GDB

参考链接

我的实验环境为ubuntu20.04,只有gdb-multiarch这个gdb调试工具

连接GDB Server

  1. 打开一个terminal,在xv6的文件夹中键入make qemu-gdb打开gdbserver。在课程演示中我们使用了CPUs=1这个选项以一个cpu运行xv6
    image.png

  2. 再打开一terminal,同样在xv6文件夹中,键入gdb-multiarch这个命令就可以链接到gdb-server了,链接成功后如图所示
    image.png