Lec 2,3 - Lab 2 system calls
操作系统的隔离性
如果没有操作系统,应用程序会直接与硬件交互。比如,应用程序可以直接看到CPU的多个核,看到磁盘,内存。这种设计有2个问题,第一个是恶意程序可以直接强行霸占CPU,而不把CPU执行权转让给其他程序。那么别的进程将无法获得运行。
另外一个危险是因为进程直接操作物理内存,所以很有可能进程之前相互覆盖掉了对方存在内存里必要的数据。
使用操作系统的一个原因,甚至可以说是主要原因就是为了实现multiplexing和内存隔离。
操作系统通过进程的概念抽象出给每个应用不同的内存空间和CPU的使用。比如, 应用程序不能直接与CPU交互,只能与进程交互。操作系统内核会完成不同进程在CPU上的切换。
应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限。
操作系统的防御性
操作系统需要确保所有的组件都能工作,所以它需要做好准备抵御来自应用程序的攻击。如果说应用程序无意或者恶意的向系统调用传入一些错误的参数就会导致操作系统崩溃,那就太糟糕了。
这种防御主要是靠应用程序不能够打破对它的隔离。应用程序非常有可能是恶意的,它或许是由攻击者写出来的,攻击者或许想要打破对应用程序的隔离,进而控制内核。
通常来说,需要通过硬件来实现这的强隔离性。这里的硬件支持包括了两部分,第一部分是user/kernel mode,kernel mode在RISC-V中被称为Supervisor mode;第二部分是page table或者虚拟内存(Virtual Memory)
user/kernel mode
当运行在kernel mode时,CPU可以运行特定权限的指令(privileged instructions)(比如设置page table寄存器、关闭时钟中断)
当运行在user mode时,CPU只能运行普通权限的指令(unprivileged instructions)(比如两个寄存器相加的指令ADD、将两个寄存器相减的指令SUB、跳转指令JRC。
当应用程序想执行特定权限指令时,必须要通过系统调用转换到KERNEL MODE中,然后Kernel 会对这个申请进行判断并阻止不合法的请求。
虚拟内存
每一个进程都会有自己独立的page table和虚拟内存,这样的话,每一个进程只能访问出现在自己page table中的物理内存。
操作系统会设置page table,使得每一个进程都有不重合的物理内存,这样一个进程就不能访问其他进程的物理内存,因为其他进程的物理内存都不在它的page table中。
一个进程甚至都不能随意编造一个内存地址,然后通过这个内存地址来访问其他进程的物理内存。这样就给了我们内存的强隔离性。
如何做user mode 到 kernel mode的切换
在RISC-V中,有一个专门的指令用来实现这个功能,叫做ECALL。 ECALL接受一个数字参数,代表具体的系统调用方法。
举个例子, 假设我现在要执行另一个系统调用write,write系统调用不能直接调用内核中的write代码,而是由封装好的系统调用函数执行ECALL指令。指令的参数是代表了write系统调用的数字。之后控制权到了syscall函数,syscall会实际调用write系统调用。
XV6开机过程
kernel/entry.S
当 xv6 的系统启动的时候,首先会启动一个引导加载程序(存在 ROM 里面),之后装载内核程序进内存
注意由于只有一个内核栈,内核栈部分的地址空间可以是固定,因此 xv6 启动的时候并没有开启硬件支持的 paging 策略,也就是说,对于内核栈而言,它的物理地址和虚拟地址是一样的
引导加载程序把内核代码加载到物理地址为 0x8000000 的地方(0x0 - 0x80000000 之间有 I/O 设备)
在机器模式下,CPU 从 _entry 处开始执行操作系统的代码
首先需要给内核开辟一个内核栈,从而可以执行 C 代码
每一个 CPU 都应该有自己的内核栈(xv6 最多支持 8 个 CPU),开始每个内核栈的大小为 4096 byte,地址空间向下增长
kernel/entry.S
_entry:
# 设置一个内核栈
# stack0 在 start.c 中声明, 每个内核栈的大小为 4096 byte
# 以下的代码表示将 sp 指向某个 CPU 对应的内核栈的起始地址
# 也就是说, 进行如下设置: sp = stack0 + (hartid + 1) * 4096
la sp, stack0 # sp = stack0
li a0, 1024*4 # a0 = 4096
csrr a1, mhartid # 从寄存器 mhartid 中读取出当前对应的 CPU 号
# a1 = hartid
addi a1, a1, 1 # 地址空间向下增长, 因此将起始地址设置为最大
mul a0, a0, a1 # a0 = 4096 * (hartid + 1)
add sp, sp, a0 # sp = stack0 + (hartid + 1) * 4096
# 跳转到 kernel/start.c 执行内核代码
call start
kernel/start.c
// 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);
unsigned long x = r_mstatus();
调用一个函数 r_mstatus(),这个函数可能是用于读取 mstatus 寄存器的当前值,并将其存放到变量 x。
x &= ~MSTATUS_MPP_MASK;
这里使用了一个位掩码 MSTATUS_MPP_MASK
,将 x 中与 MSTATUS_MPP_MASK
相关的位清零。这一步是为了确保能够设置一个新的机器模式权限而不会影响到其他的位。
x |= MSTATUS_MPP_S;
然后,将 x 的特定位设置为表示 Supervisor 模式的值。MSTATUS_MPP_S
是表示 Supervisor 模式的常数/掩码。这一步的目的是将机器的权限级别设置为 Supervisor 模式。
w_mstatus(x);
调用函数 w_mstatus(x)
以将修改后的 x 值写回到 mstatus 寄存器。
// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);
// disable paging for now.
w_satp(0);
两条指令是为了确保 xv6 在启动过程中能够在正确的执行环境下运行。通过设置 mepc,确保操作系统在机器模式的异常返回时跳转到正确的位置;通过设置 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);
让我们逐行解析这段代码:
-
w_medeleg(0xffff);
这行代码将机器异常委派寄存器 (medeleg
) 的所有位设置为 1。在 RISC-V 中,medeleg
寄存器用于指定哪些异常应当被委派到 supervisor 模式。将其所有位都设置为 1 通常意味着将所有可以被委派的异常从机器模式委派到 supervisor 模式。 -
w_mideleg(0xffff);
这行代码将机器中断委派寄存器 (mideleg
) 的所有位设置为 1。这与medeleg
类似,但是针对的是中断而不是异常。这意味着将所有可以被委派的中断从机器模式委派到 supervisor 模式。 -
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
这行代码修改了 supervisor 中断使能寄存器 (sie
)。-
r_sie()
读取当前的sie
寄存器值。 -
SIE_SEIE
,SIE_STIE
, 和SIE_SSIE
是特定的中断使能位掩码,它们分别代表 supervisor 外部中断、定时器中断和软件中断。 - 使用位或操作来启用这些中断。
-
w_sie(...)
将修改后的值写回到sie
寄存器。
-
总结:这段代码的目的是配置 RISC-V CPU,使得大部分异常和中断不是在机器模式中处理,而是在 supervisor 模式中处理,并确保在 supervisor 模式下特定的中断是启用的。这样的配置在操作系统中很常见,尤其是那些希望将大部分的异常和中断处理逻辑放在一个更高层次(但非最高特权)的模式中的系统。
// 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");
这段代码是xv6在RISC-V平台上的初始化过程的一部分,涉及到时钟中断的初始化和每个CPU的hartid
(硬件线程ID)的设置。让我们逐行解析:
-
timerinit();
这行代码调用了timerinit
函数。- 作用: 这个函数的主要目的是初始化时钟以产生定期的时钟中断。在许多操作系统中,时钟中断是非常重要的,因为它用于进行任务调度、更新时间、预算任务的CPU时间等。
-
int id = r_mhartid();
这行代码调用了一个函数r_mhartid
,该函数读取RISC-V的mhartid
寄存器。-
mhartid
寄存器: 在RISC-V架构中,mhartid
寄存器包含当前硬件线程的ID。在多核心处理器中,每个核心都有其独特的hartid
。 - 作用: 通过读取这个寄存器,你可以知道当前代码在哪个CPU核心上运行。
-
-
w_tp(id);
这行代码调用了一个函数w_tp
,该函数写入RISC-V的tp
(线程指针)寄存器。-
tp
寄存器: 在RISC-V架构中,tp
寄存器是一个可以自由使用的寄存器,通常用作线程局部存储的指针。它不是由硬件定义的功能,而是由软件(如操作系统)定义。 -
作用: 这里,xv6使用
tp
寄存器来存储每个CPU的hartid
。这为操作系统提供了一个快速访问当前CPU ID的方法,不需要每次都去读mhartid
寄存器。
-
总结:这段代码的主要作用是初始化时钟中断并为每个CPU核心设置一个快速访问其硬件线程ID的方法。
-
asm volatile("mret");
这行代码的目的是执行 mret 指令,使 CPU 从异常处理程序返回到中断或异常发生前的位置。也就是main
kernel/main.c
main() 函数中首先进行很多属性的配置,然后通过 usetinit() 创建第一个进程
对任意一个 CPU,我们需要配置它一些系统属性
// kernel/main.c
// 1. 打开硬件支持的 paging 策略
// 但是对于内核而言, 使用的策略是虚拟地址直接映射到相同的物理地址
// 通过 w_satp(MAKE_SATP(kernel_pagetable))
kvminithart();
// 2. 装载中断处理函数指针, 内核态的中断处理程序设置为 kernelvec
trapinithart();
// 3. 打开对外部中断的响应
plicinithart();
对于 0 号 CPU,因为这是第一个启动的 CPU,我们需要进行一些特殊的初始化配置
当然也包括上面的配置
consoleinit(); // 配置控制台属性(锁, uart寄存器配置)
printfinit(); // 配置 printf 属性(锁)
kinit(); // 物理页分配策略的初始化(锁, 开辟空间)
kvminit(); // 新建一个内核的物理页表, 调用 kalloc() 生成一个页表(4096 byte)
// 通过调用 kvmmake() 设置一些固定的函数入口
// 对内核而言, 使用虚拟地址直接映射到物理地址的内核页表映射策略
kvminithart(); // 1
procinit(); // 初始化进程表(最多支持 64 个进程)
trapinit(); // 初始化中断异常处理程序的一些配置(锁)
trapinithart(); // 2
plicinit(); // 设置响应外部中断的处理程序
plicinithart(); // 3
binit(); // buffer cache, 新建 cache, 双向链表形式组织, 锁
iinit(); // inode cache, 文件列表 cache, 锁
fileinit(); // file table, 文件表(锁)
virtio_disk_init(); // emulated hard disk
userinit(); // 新建第一个用户进程
__sync_synchronize();
started = 1;
kernel/proc.c
// Set up first user process.
void
userinit(void)
{
struct proc *p;
p = allocproc();
initproc = p;
// allocate one user page and copy initcode's instructions
// and data into it.
uvmfirst(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
// prepare for the very first "return" from kernel to user.
p->trapframe->epc = 0; // user program counter
p->trapframe->sp = PGSIZE; // user stack pointer
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
p->state = RUNNABLE;
release(&p->lock);
}
userinit() 函数执行的逻辑如下
- 调用 allocproc() 从进程表中找到一个状态为 UNUSED 的进程
- 找到之后,进行一些初始化配置
- 找不到返回 0,说明已经达到系统内置的最大进程数量
- 计算 pid
- state 设置为 USED
- 调用 kalloc() 分配一个 trapframe
- trapframe 的作用是在用户态进入内核态的时候保存其所有的寄存器
- 如果没有分配到 trapframe,那么调用 freeproc() 退出
- freeproc():属性值修改为空闲、清空内存区域
- 调用 proc_pagetable() 分配一个用户态的页表
- 同时进行页表项的配置等
- 如果没有分配到页表,那么调用 freeproc() 退出
- 设置 context 寄存器 ra、sp(进程切换)
- ra:用户态应该执行的代码地址
- sp:栈指针
- 找到之后,进行一些初始化配置
- 把初始化代码放入进程的页表中
- 一段机器代码,是一个系统调用 exec("/init"),
- 会执行代码 user/initcode.S,开始运行 shell
只是加载,没有运行
- 设置 trapframe 中的寄存器 epc、sp(异常中断返回用户态)
- epc:用户态的 PC
- sp:用户态得到栈指针
- 设置进程名称为 initcode,进程工作目录为 /
- 设置进程状态为 RUNNABLE
- 最后返回 kernel/main.c 中执行进程调度程序 scheduler()
调度之后才开始运行上面加载的机器代码
上述代码就是执行exec ,然后去调用
init
然后定位到user/init.c
看它做的事,就很直观了。
int
main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
for(;;){
// this call to wait() returns if the shell exits,
// or if a parentless process exits.
wpid = wait((int *) 0);
if(wpid == pid){
// the shell exited; restart it.
break;
} else if(wpid < 0){
printf("init: wait returned an error\n");
exit(1);
} else {
// it was a parentless process; do nothing.
}
}
}
}
综上,XV6的启动过程,我们可以总结为:
- entry.S 给内核开辟一个内核栈,从而可以执行 C 代码
-
start.c
配置 RISC-V CPU,使得大部分异常和中断不是在机器模式中处理,而是在 supervisor 模式中处理,并确保在 supervisor 模式下特定的中断是启用的。并通过mret
去到之前配置好的异常捕获起始点w_mepc((uint64)main);
-
main.c
首先进行很多属性的配置,然后通过userinit()
创建第一个进程 -
proc.c
把初始化代码通过uvmfirst
把user/initcode.S
放入进程的页表中并且是地址为0处。 通过allocproc
获取第一个用户进程,并用p->context.ra = (uint64)forkret;
指定返回时,应该去forkret
,forkret
时进入用户态, 并在最后则调用mret
回到之前设置的异常捕获点,地址为0.p->trapframe->epc = 0;
; 执行initcode.S
的代码 - initcode.S 主要就是执行用户态的
init
指令。代码在user/init.c
-
init.c
中,通过fork和exec, 执行sh
命令,启动shell完成。
Lab 2 Optional Challenge
1. Print the system call arguments for traced system calls
额外维护一下每个syscall 需要的参数个数,然后进行打印
int syscall_arg_counts[] = {
0, // placeholder for syscall 0
0, // fork
1, // exit
1, // wait
1, // pipe
3, // read
1, // kill
2, // exec
2, // fstat
1, // chdir
1, // dup
0, // getpid
1, // sbrk
1, // sleep
0, // uptime
2, // open
3, // write
3, // mknod
1, // unlink
2, // link
1, // mkdir
1, // close
1, // trace
0 // sysinfo
};
void syscall(void)
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
uint64 tmp = p->trapframe->a0;
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
if (p->trace_arg & (1 << num)) {
printf("%d: syscall %s -> %d ", p->pid, syscall_names[num], p->trapframe->a0);
if (syscall_arg_counts[num] > 0) printf(",params: %d ", tmp);
if (syscall_arg_counts[num] > 1) printf("%d ", p->trapframe->a1);
if (syscall_arg_counts[num] > 2) printf("%d ", p->trapframe->a2);
printf("\n");
}
}
测试结果:
1698542110075.png2. Compute the load average and export it through sysinfo
先在proc.c
里加一个函数, 去计算过去1分钟的平均load情况。
这里使用的是指数移动平均去计算过去1分钟的情况,
calculate_load_1m
采用每秒钟调用1次的策略。那么相关公式计算根据如下:
1698542437458.png
下面就是要设置,怎么去每秒调用1下这个calculate_load_1m
的函数
因为xv6在启动的时候设置了时钟中断为0.1s; 所以我们可以在trap.c
中的时钟中断函数里,每10次去triger上述方法,来达到每秒计算1次load 的效果。这样可以去统计过去1分钟的指数移动平均load.
void
clockintr()
{
acquire(&tickslock);
ticks++;
if (ticks % 10 == 0)
calculate_load_1m();
wakeup(&ticks);
release(&tickslock);
}
余下的就是补充下数据结构,并且在调用sysinfo
时,调用get_avgload_1m
去设置当前的load,并打印。
struct sysinfo {
uint64 freemem; // amount of free memory (bytes)
uint64 nproc; // number of process
float loadavg1m; // load average in past 1 minute
};
uint64
sys_sysinfo(void)
{
struct proc *p = myproc();
struct sysinfo info;
uint64 addr; // user pointer to struct sysinfo
argaddr(0, &addr);
info.freemem = kcollect();
info.nproc = proc_number();
info.loadavg1m = get_avgload_1m();
if(copyout(p->pagetable, addr, (char *)&info, sizeof(info)) < 0)
return -1;
return 0;
}
测试结果
测试方法,需要我们自己实现一下用户shell测的sysinfo命令,然后通过不断开一些sleep的进程,并在后台运行去检测load的变化情况
user/sysinfo.c
1698542801833.png下图最后一个数字为load:
1698542967469.png