MIT 6.S081 操作系统课程系列2 System calls

https://pdos.csail.mit.edu/6.828/2020/labs/syscall.html 本次实验需要读xv6手册第二章、4.3、4.4。看相关kernel代码。 理解system call的流程并实现一些system call。


物理资源的抽象

  • 为什么要有system call。因为为了安全等因素,需要有一层隔离,不能让应用程序直接访问敏感资源。

  • 为一些关键资源做一系列system call,供应用程序使用,是比较好的办法,既安全又分层化方便开发。比如文件的open/read/write等等。 比如exec,会做一系列内存操作,不需要应用程序自己处理。


user模式和supervisor模式

  • 应用程序与内核是“隔离”的,应用程序的崩溃绝不能影响内核的正常运行。 所以不允许一个应用程序访问内核本身的数据和内存等。 而且应用程序之间也不允许相互访问内存。

  • cpu对这种隔离有硬件上的支持。比如risc-v有三种模式:machine mode/supervisor mode/user mode。 machine模式下执行的指令有所有权限,通常是机器启动时配置各种资源时使用。 xv6在machine模式下执行少数代码后会切换到supervisor模式。

  • supervisor模式下cpu可以执行一些需要权限的指令。比如中断的开关,读写寄存器等等。 如果在user模式下执行这些指令,cpu会忽略。 普通应用程序只能运行在user模式下,所谓的user space。 supervisor模式下运行的程序在所谓的kernel space里。

  • 应用程序想调用system call时必须转到kernel模式。cpu会提供指令从user模式转到supervisor模式。 收到system call后,kernel会检查其参数。没有问题才运行。


kernel的规划

  • 一个大问题是操作系统的哪些部分要运行在supervisor模式。

  • 一种方法是整个操作系统都放进去,即monolithic kernel。 这样的好处是好实现,少费脑筋,可共用资源比如各种buff。 一个坏处是各个模块之间的接口会过于复杂,开发人员容易犯错。 monolithic kernel里的错误是致命的,因为supervisor模式下的错误通常会造成整个kernel崩溃。

  • 为了缓解这个问题,可尽量减少运行在supervisor模式下的代码,而转到user模式下执行。成为micro kernel微内核。

  • 例如用户程序想访问文件,操作系统会在user空间跑一个文件服务器,用户程序还是向kernel请求system call,kernel把请求再发给user空间的文件服务器。这样内核实际跑的代码就很少,只是转发了一下命令。

  • xv6是monolithic kernel,体积小,功能少。


进程

  • xv6中隔离的粒度是进程。进程间无法相互访问各自内存、cpu信息、文件等资源。 进程也不能访问kernel的资源。

  • kernel代码必须很小心地实现,防止用户程序的各种恶意操作。 进程有自己的地址空间,仿佛自己占用是一套独立完整的机器资源。

  • xv6用分页表(硬件实现)实现每个进程的地址空间。 risc-v分页表会把虚拟地址转换成实际的物理内存地址。

进程的虚拟地址空间示意:

    _____________   
    | trampoline |  <- maxva
    |____________|
    | trapframe  |
    |____________|
    |            |
    |            |
    |            |
    |            |
    |   heap     |
    |            |
    |            |
    |            |
    |            |
    |____________|
    | user stack |
    |____________|
    |            |
    | user text  |
    | user data  |
    |____________|  <- 0

  • riscv的指针为64bit。会用低39位来查分页表中的地址。xv6又会用其中的38位,所以寻址空间就是pow(2, 38)-1=0x3fffffffff(kernel/riscv.h里又定义)。

  • 每个进程有user和kernel两个stack。程序在user空间时就用user栈,进入kernel是就用kernel栈。

  • 进程调用system call时先发送rsicv的ecall指令,获取硬件权限然后把程序接到kernel指定的入口,在入口处会转换到kernel栈然后执行system call的代码。 当system call执行完,发送sret指令取消硬件权限,切换回user栈和user空间。


xv6的启动流程

  1. 上电。bootloader加载kernel进内存。地址为0x80000000。因为0x0到0x80000000包含io设备,所以放到0x80000000。

  2. machine模式。cpu执行entry.S里的_entry。此时硬件分页关闭,虚拟地址直接映射到物理地址。

  3. _entry起一个stack0让xv6能够跑c代码。sp指针指向stack0+4096。开始跑start.c里的start()函数。

  4. start()做一些machine模式特许的操作。

  5. 切换到supervisor模式。写main函数(kernel/main.c)的地址到mepc,直接返回到main函数。

  6. main()初始化各种设备和子系统。调用userinit()创建第一个进程。执行user/initcode.S。

  7. initcode.S执行exec(system call),启动init程序(user/init.c)。进入shell等待用户输入。


system call的c代码解析

  • exec为例,用户程序代码会把参数放进a0和a1寄存器,exec的代码7(syscall.h)放进a7。

  • 见syscall()函数,从a7取出代号调用相应的system call函数。

  • system call函数里需要从寄存器取参数(使用argaddr/argint/argfd等等)。 返回值存入a0。

  • 结束后exec就会返回a0里的值。

  • 有时需要返回较多的数据,需要用copyout从kernel复制数据到用户地址。


实战

trace

  • 做一个trace程序(system call),供user层的trace调用。

  • 第一个参数是一个bit设置,对应syscall.h里的定义。比如传32,就是1<<5,5对应的就是SYS_read。可以同时打开多项。

  • 调用system call后,检查对应的bit是否打开,打开的话就打印信息。

  1. makefile里UPROGS加入trace

  2. user/user.h里加入trace的定义

  3. user/usys.pl里加入trace的定义

  4. kernel/syscall.h里加入trace新号

  5. kernel/proc.h的进程结构体里添加int类型trace_flag

  6. kernel/sysproc.c里加入sys_trace() sys_trace这个system call。获取参数用argint。给myproc()的trace_flag赋值即可。

  7. 修改fork函数。把parent的trace_flag传给child。

  8. 初始化进程的地方把trace_flag置0。

  9. 修改kernel/syscall.c。调用syscall后判断num和trace_flag,如果bit被打开,打印相关信息。


sysinfo

  • 做一个sysinfo程序(system call)。打印系统信息。 测试程序为user/sysinfotest.c

  1. 修改makefile

  2. user/user.h里加入sysinfo的定义(参照sysinfotest.c的使用方法)

  3. 参照trace的实现,添加其他定义。

  4. 实现sysinfo函数。

  • 参考sysfile.c里的sys_fstat。会接收一个指针,要用copyout把kernel模式下的内存数据copy到这个指针的地址。 要获取系统可用内存和当前进程总数这两个数据。

  • 如何获取系统的空闲内存?需要看一下内存操作的实现。 memlayout.h里定义了KERNBASE和PHYSTOP,从0x80000000L开始的128M字节为可用内存。 riscv.h里定义了页的大小PGSIZE为4字节。

  • 看一下kalloc.c里对内存的操作 有一个run结构类型包含一个指向同类型结构的指针,就是一个简单的list。 有一个kmem结构实体包含一个run的指针freelist,和一个spinlock。对kmem操作时都要锁这个spinlock。

  • 先看kfree如何释放或初始化内存。 kfree接收一个指针pa,pa的值是要释放的内存的地址比如0x80000008L,往0x80000008L开始填4字节垃圾(也可以不管),然后把pa插到freelist头部。 kalloc先检查freelist头部,如果不是0就往头部填充一些垃圾返回(这里没有硬性规定,所以分配内存后一般需要memset置零。),把freelist往后挪。

  • 这样就很清晰了,freelist实际就是个可用内存的链表。每次free一个页就把这个地址做成指针插到头部,每次alloc一个页就拿出头部,之前的第二项变成新的新的头部。

  • 懂了内存分配的原理,获取可用内存就非常简单,遍历一下freelist并计数即可。

  • 统计当前进程总数,遍历proc数组,统计不为UNUSED的数量即可。