6.S081 学习笔记 - Lab-System-calls
操作系统架构
操作系统需要实现三个基本功能:多路复用、隔离和交互。
操作系统将硬件资源抽象为服务实现这三个功能。
操作系统在进程间透明地切换资源,程序不必意识到自己在和其他程序分时共享资源。即使某个程序处于无限循环,也不会导致其他程序获取不到资源。
程序通过系统调用访问敏感资源,操作系统就能对访问进行合理的控制,避免程序之间的干扰。
系统资源的抽象使得进程间的交互变得简单,例如文件描述符抽象了许多细节,进程不用关心它到底是从文件还是管道读取数据。
操作系统必须保证普通程序不能修改甚至访问属于操作系统的数据结构和指令,也不能访问其他程序的内存。这样一个程序的错误就不会影响到操作系统或其他程序。
CPU 为这种强隔离提供了硬件支持。如 RISC-V 有机器模式 (Machine Mode)、特权模式 (Supervisor Mode) 和用户模式 (User Mode)。机器模式拥有最高权限,CPU 启动时运行在机器模式,对计算机进行配置,然后切换到特权模式。在特权模式下,允许执行特权指令。以特权模式运行的程序称为内核 (kernal),内核运行在内核空间。应用程序运行在用户空间中,只能执行用户模式的指令。
普通程序调用内核函数时必须转交给内核,CPU 会转换到特权模式在内核指定的入口点处执行代码,对系统调用的参数进行校验,判断程序能否执行这个系统调用。如果入口点可以由程序控制,那么恶意程序就可以跳过校验。
宏内核 (Monolithic Kernel):整个操作系统在内核中,所有的系统调用都以特权模式运行。优点是设计简单,便于系统不同部分配合,缺点是接口复杂,开发时容易出错。一旦内核出现问题,整个系统就会崩溃,需要重启。
微内核 (Microkernel):最小化特权模式下运行的代码,将大部分操作系统功能放在用户空间。优点是内核相对简单,稳定性高,缺点是性能较差。
内核实现进程的机制包括用户 / 特权模式标志,地址空间和线程时间片。进程的抽象会给程序一种它独占了整个计算机的错觉。
Xv6 使用页表给每个进程分配独立的地址空间,页表将虚拟地址映射到物理地址。虚拟地址从 0 开始依次是用户空间指令、全局变量、栈和堆。
每个进程有一个执行线程,线程可以挂起并在稍后恢复执行。操作系统就是通过挂起一个线程,再恢复另一个线程来实现进程间的透明切换的。大部分线程的状态存储在线程的栈中。每个进程有两个栈,一个用户栈,一个内核栈。
进程执行用户指令时,只会使用用户栈,内核栈是空的。当进程进入内核,内核代码会在内核栈上执行,用户栈保持不变。独立内核栈使得用户栈坏掉了内核也能正常运行。
进程执行 RISC-V 的 ecall
进行系统调用,ecall
指令会将程序从用户模式切换到特权模式,然后跳转到内核的入口点。代码在入口点处切换到内核栈,然后执行系统调用的内核指令。系统调用完成后,内核会执行 sret
指令降低权限回到用户空间,继续执行系统调用之后的指令。
源码阅读
user/user.h
中声明了供用户程序调用的系统调用函数和 C 库函数。user/usys.pl
是一个 Perl 脚本,程序化生成系统调用汇编的存根,存入usys.S
文件。存根是一个简短的代码段,将系统调用号和参数传递给内核,并发起系统调用。kernel/syscall.h
中定义了系统调用的编号。kernel/syscall.c
对系统调用的编号进行校验,并实现了一些获取系统调用参数的函数。在syscall
函数中调用了对应系统调用的包装函数。kernel/proc.h
定义了进程相关的数据结构,包括进程的寄存器、状态、内核栈、页表等。kernel/proc.c
中实现了进程相关的系统调用,如fork
、wait
等。kernel/sysfile.c
中是文件系统相关的系统调用的包装函数。kernel/sysproc.c
中是进程相关的系统调用的包装函数。kernel/defs.h
中包含多个文件的函数声明。
第一个系统调用
Xv6 启动后的第一个进程执行的是 initcode.S
,在此程序中调用了 exec
系统调用重新进入内核。
.S
文件和.asm
文件都是汇编代码文件,区别是.S
文件会经过预处理,可以包含 C 的预处理器指令。比如 initcode.S
中的#include "syscall.h"
。而.asm
文件不会经过预处理。
1 |
|
initcode.S
将 exec
系统调用所需的参数存入 a0
、a1
,将 exec
的编号 SYS_exec
存入 a7
。所有系统调用的编号都定义在 syscall.h
中。
在 syscall.c
中定义了一个函数指针数组 syscalls
,索引是系统调用的编号,值是对应函数的指针 (这里其实是系统调用的包装函数)。syscall
函数中取出 a7
寄存器中的系统调用编号,并检验是否合法,然后根据编号在 syscalls
数组中找到对应的函数指针,调用这个函数,将返回值存入 a0
寄存器。一般返回值为 0
表示成功,-1
表示失败。
1 |
|
题解
本次实验要求实现一些新的系统调用,涉及到的代码文件较多,关键是通过阅读源码理解各个文件的作用和调用的流程。
System call tracing (moderate)
本题要求实现一个系统调用 trace
。trace
接受一个 int
参数 mask
,表示要追踪的系统调用的掩码。如 trace(1 << SYS_fork)
会追踪 fork
系统调用,而 trace(1 << SYS_fork | 1 << SYS_exit)
或 trace(6)
会同时追踪 fork
和 exit
系统调用。系统调用编号定义在 kernel/syscall.h
中。
trace
应在参数中指定类型的系统调用返回前打印进程 PID、系统调用名称和返回值。
trace
应能追踪调用它的进程和其子进程中的系统调用,但不会影响其他进程。
思路
- 在
kernel/syscall.c
中的syscall
函数中进程进行了系统调用,这里可以获取到进程 PID、系统调用的编号和返回值,因此在此处打印追踪信息。 - 在
proc
结构体中新增一个成员变量trace_mask
用于判断是否追踪,追踪哪些系统调用 - 在
sys_trace
函数中将trace
系统调用的参数存入proc->trace_mask
- 在
fork
时复制父进程的trace_mask
到子进程 - 系统调用的名称可通过创建一个数组来存储,索引是系统调用的编号,值是系统调用的名称
实现代码
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
Sysinfo (moderate)
本题要求实现一个系统调用 sysinfo
。sysinfo
系统调用接受一个指向 sysinfo
结构体的指针作为参数,并将系统信息填入这个结构体中。sysinfo
结构体包含两个字段:freemem
表示空闲内存的字节数,nproc
表示状态不是 UNUSED
的进程数量。
思路
获取内存信息涉及到了 kalloc.c
文件,先对照参考手册第 3.5 节阅读源码大致弄明白内存是怎么组织的。主要涉及到两个结构体 run
和 kmem
。run
结构体是一个链表节点,用于表示内存块,每块 4096 字节。kmem
结构体包含内存的空闲列表 freelist
和一个自旋锁 lock
。
1 |
|
freelist
一开始没有初始化,所以为 0
。阅读 kint
、kfree
函数可以知道 freelist
的作用。
在初始化时,kinit
对所有的内存块使用 kfree
进行初始化。kfree
函数会向指定内存块 r
填入垃圾数据,然后进行下面的操作。
1 |
|
初始化过程如下,可以看出 freelist
是空闲内存块的头指针。因此我们只需要遍历空闲列表就能知道有多少空闲的内存。
1 |
|
第二个功能是获取进程数量,在 proc.c
中有一个进程数组 proc
,而根据 proc.h
中 proc
结构体的定义有 state
字段表示进程的状态。所以遍历 proc
数组,统计状态不是 UNUSED
的进程数量即可。
关于如何获取指针参数可以参考 kernel/sysproc.c
中的 sys_wait
函数,将获取到的数据传回用户空间参考 kernel/sysfile.c
中的 sys_fstat
函数和 kernel/file.c
" 中的 filestat
函数。
实现代码
首先和 trace
一样,先在用户空间声明 sysinfo
系统调用函数和存根,然后在内核空间定义系统调用编号和包装函数。
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
总结
实现新系统调用的一般步骤
- 在
user/user.h
中声明系统调用的函数原型 - 在
user/usys.pl
中添加系统调用的存根 - 在
kernel/syscall.h
中定义系统调用的编号 - 在
kernel/syscall.c
中声明系统调用的包装函数并将其存入syscalls
数组,添加系统调用名称到syscall_names
数组 - 实现系统调用函数
在系统调用中和用户空间进行数据交互
argint(int n, int *ip)
:获取一个int
参数,n
是参数的编号,ip
是指向存储参数的变量的指针argaddr(int n, uint64 *ip)
:获取一个指针参数,n
是参数的编号,将指针的地址存入ip
指向的变量argstr(int n, char* buf, int max)
:获取一个字符串参数,n
是参数的编号,将字符串拷贝到buf
中,最多拷贝max
个字符上面的都是通过
argraw
函数实现的,argraw
函数会从 CPU 寄存器中获取参数1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21static uint64
argraw(int n)
{
struct proc *p = myproc();
switch (n) {
case 0:
return p->trapframe->a0;
case 1:
return p->trapframe->a1;
case 2:
return p->trapframe->a2;
case 3:
return p->trapframe->a3;
case 4:
return p->trapframe->a4;
case 5:
return p->trapframe->a5;
}
panic("argraw");
return -1;
}copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
:将内核空间的数据拷贝到用户空间,pagetable
是进程的页表,dstva
是虚拟地址,src
是要数据的指针,len
是要拷贝的字节数