MIT 6.828 操作系统课程系列2 System calls#

https://pdos.csail.mit.edu/6.828/2022/labs/syscall.html
本次实验需要读xv6手册第2章、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会提供指令(ecall)从user模式转到supervisor模式。
    收到system call后,kernel会检查其参数。没有问题才运行。
    kernel必须控制转入supervisor模式的入口,如果一般程序能控制,有安全问题。


kernel的规划#

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

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

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

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

  • 现实中两种kernel都很多。linux是monolithic。哪种更好没有定论。

  • 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里有定义)。

  • trampoline包含转入转出kernel的代码。trapframe用来记录user进程的状态。

  • 每个进程有一个执行线程,现在只考虑一个线程。后续lab会写多线程。

  • 每个进程有user和kernel两个stack。程序在user空间时就用user栈,进入kernel是就用kernel栈。
    kernel栈隔离且受保护,即使user栈坏了也不会被影响。

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

  • 在user模式下,xv6会让paging硬件用p->pagetable。

xv6的启动流程#

MIT 6.828 操作系统课程系列0

安全模型#

  • 处理恶意攻击比处理bug要难。

  • os必须把user代码想象成坏到极点,会不择手段进行破坏。比如访问它不该访问的地址,执行不该执行的riscv指令等。

  • 它可能读写riscv的控制寄存器,直接访问硬件,给kernel传恶意的参数等等。

  • kernel必须把user代码限制在自己的区域。

  • kernel代码必须小心写,进行论证。尽量无bug无恶意代码。

  • 现实中很难做到完美。是永恒的问题。

Traps from user space#

4.1章在MIT 6.828 操作系统课程系列0里有学习,继续看4.2。

一个trap发生在user空间的原因

  1. user程序调system call(ecall指令)

  2. exception

  3. 硬件中断

initcode.S。代码会把参数放进a0和a1寄存器,exec的代码7(syscall.h)放进a7。

system call总体流程#

  1. 首先会在某个节点配置stvec为uservec。那么后续user的ecall会走uservec代码。

  2. 供用户程序使用的所有syscall声明都在user.h,实现是没有c代码的。它是在makefile里用perl $U/usys.pl > $U/usys.S直接生成汇编,填syscall代码到a7走ecall。

  3. 用户程序一调这种函数,直接走ecall。

  4. ecall指令触发trap。走uservec,usertrap()。检查scause,8为syscall。

  5. syscall(),从a7取出代号调用相应的kernel实现的system call函数。

  6. system call函数里需要从寄存器取参数(使用argaddr/argint/argfd等等)。返回值存入a0。 //6. 结束后exec就会返回a0里的值。

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

system call详细流程#

配置stvec的时间点。最开始的一次是在forkret()。
forkret在allocproc中配置,每当回到user空间时会走。


// main()中进行各种初始化
// 此时cpu为S模式

userinit();      // first user process
    struct proc *p;

    p = allocproc();
        // 找一个可用的进程资源。状态置为USED。
        // 给p->trapframe分配一个页
        // proc_pagetable创建一个user页表

        // 设置context信息。后续运行进程时用到
        p->context.ra = (uint64)forkret;
        p->context.sp = p->kstack + PGSIZE;

    initproc = p;

    // initcode是写死的initcode.S编译生成的数据
    // 内容是发起syscall。exec调用init。
    // uvmfirst从user页表分配一页,从虚拟地址0开始。把initcode数据复制进去,后续可直接运行。  
    uvmfirst(p->pagetable, initcode, sizeof(initcode));
    p->sz = PGSIZE;  // 进程大小为一页

    p->trapframe->epc = 0;      // user program counter
    // user pc寄存器设为0。后续会根据这个值,运行上面0地址的指令。即运行initcode.S。

    p->trapframe->sp = PGSIZE;  // user stack pointer
    // sp从第2页开始

    safestrcpy(p->name, "initcode", sizeof(p->name));
    p->cwd = namei("/");

    p->state = RUNNABLE;
    // 状态设为RUNNABLE。后续进程调度时可开始执行。

    release(&p->lock);


// 进入进程调度

scheduler();
    struct proc *p;
    struct cpu *c = mycpu();

    c->proc = 0;
    for(;;)
        for(p = proc; p < &proc[NPROC]; p++)
            acquire(&p->lock);

            if(p->state == RUNNABLE) {
                p->state = RUNNING;
                c->proc = p;
                swtch(&c->context, &p->context);
                    // 切换到userinit创建的进程

                    // swtch.S

                    // context包含ra/sp/s0-s11一共14个寄存器

                    // sd ra, 0(a0)
                    // sd sp, 8(a0)
                    // ...

                    // ld ra, 0(a1)
                    // ld sp, 8(a1)
                    // ...

                    // ret

                    // 根据riscv的calling convention。函数调用的前两个参数会存在a0和a1。
                    // sd把寄存器值存到内存地址
                    // ld把内存地址上的值存到寄存器
                    // 最后的效果就是把当前的context寄存器保存到c->context,
                    // 把p->context的寄存器值加载到cpu当前寄存器。

                    // 在swtch运行过程中把ra写为p->context中的ra。
                    // 那么swtch结束时会返回到之前设置的forkret。

                forkret
                    usertrapret
                        // 此时cpu仍在S模式
                        // 开始为回到user模式做准备

                        struct proc *p = myproc();

                        intr_off(); // 关中断

                        uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
                        w_stvec(trampoline_uservec);
                        // TRAMPOLINE = MAXVA - PGSIZE指向虚拟地址最后一页。已经在kvminit中映射到了trampoline.S中的trampoline。
                        // TRAMPOLINE是虚拟地址。uservec - trampoline是一个offset。
                        // trampoline_uservec得到uservec函数的地址
                        // 设置为stvec。后续user的trap会走uservec。
                        // 之前已经给cpu设置了kernel页表,所以这里都是传入虚拟地址。

                        // 也就是返回user空间的途中设置了stvec。
                        // 之后一旦user空间的system call走ecall触发trap后就走uservec
                        // 这里可以算作system call的起点
                        // 第一次触发是从userinit。以后每个进程都可触发。

                            /*
                            uservec:    
                                #
                                # trap.c sets stvec to point here, so
                                # traps from user space start here,
                                # in supervisor mode, but with a
                                # user page table.
                                #

                                # save user a0 in sscratch so
                                # a0 can be used to get at TRAPFRAME.
                                csrw sscratch, a0 # 暂时没懂

                                # each process has a separate p->trapframe memory area,
                                # but it's mapped to the same virtual address
                                # (TRAPFRAME) in every process's user page table.
                                li a0, TRAPFRAME // TRAPFRAME是虚拟地址最后一页
                                
                                # 保存当前寄存器状态
                                # 把整套寄存器存到本进程的TRAPFRAME位置。也就是填充了struct trapframe定义的结构。
                                # 从ra开始
                                # save the user registers in TRAPFRAME
                                sd ra, 40(a0)
                                sd sp, 48(a0)
                                sd gp, 56(a0)
                                sd tp, 64(a0)
                                sd t0, 72(a0)
                                sd t1, 80(a0)
                                sd t2, 88(a0)
                                sd s0, 96(a0)
                                sd s1, 104(a0)
                                sd a1, 120(a0)
                                sd a2, 128(a0)
                                sd a3, 136(a0)
                                sd a4, 144(a0)
                                sd a5, 152(a0)
                                sd a6, 160(a0)
                                sd a7, 168(a0)
                                sd s2, 176(a0)
                                sd s3, 184(a0)
                                sd s4, 192(a0)
                                sd s5, 200(a0)
                                sd s6, 208(a0)
                                sd s7, 216(a0)
                                sd s8, 224(a0)
                                sd s9, 232(a0)
                                sd s10, 240(a0)
                                sd s11, 248(a0)
                                sd t3, 256(a0)
                                sd t4, 264(a0)
                                sd t5, 272(a0)
                                sd t6, 280(a0)

                                # save the user a0 in p->trapframe->a0
                                csrr t0, sscratch
                                sd t0, 112(a0)

                                # 拿出返回user空间时保存的p->trapframe值
                                # initialize kernel stack pointer, from p->trapframe->kernel_sp
                                ld sp, 8(a0)

                                # make tp hold the current hartid, from p->trapframe->kernel_hartid
                                ld tp, 32(a0)

                                # t0为返回user空间时设置的usertrap
                                # load the address of usertrap(), from p->trapframe->kernel_trap
                                ld t0, 16(a0)

                                # fetch the kernel page table address, from p->trapframe->kernel_satp.
                                ld t1, 0(a0)

                                # wait for any previous memory operations to complete, so that
                                # they use the user page table.
                                sfence.vma zero, zero

                                # t1是kernel页表。喂给cpu
                                # install the kernel page table.
                                csrw satp, t1

                                # flush now-stale user entries from the TLB.
                                sfence.vma zero, zero

                                # jump to usertrap(), which does not return
                                jr t0 # 跳转到usertrap()

                                */
                                    usertrap(void) {
                                        // 此时cpu是S模式

                                        int which_dev = 0;

                                        if((r_sstatus() & SSTATUS_SPP) != 0)
                                            panic("usertrap: not from user mode");

                                        // send interrupts and exceptions to kerneltrap(),
                                        // since we're now in the kernel.
                                        w_stvec((uint64)kernelvec);
                                        // 来自kernel的trap处理。暂时忽略。

                                        struct proc *p = myproc();

                                        // save user program counter.
                                        p->trapframe->epc = r_sepc();
                                        // 根据riscv-privileged文档4.1.7。
                                        // 当发生trap,进入S模式时,sepc被存入trap发生时的指令位置。那么可用这个值返回现场。
                                        // 保存在p->trapframe->epc

                                        if(r_scause() == 8){
                                            // scause8为user的system call

                                            if(killed(p))
                                              exit(-1);

                                            // sepc points to the ecall instruction,
                                            // but we want to return to the next instruction.
                                            p->trapframe->epc += 4;
                                            // 上面提到sepc会存为发生trap时的指令,它是ecall。我们要跳过ecall,就+=4。

                                            // an interrupt will change sepc, scause, and sstatus,
                                            // so enable only now that we're done with those registers.
                                            intr_on();

                                            syscall();
                                                int num;
                                                struct proc *p = myproc();

                                                // 约定syscall的代码存在a7
                                                num = p->trapframe->a7;
                                                if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
                                                    // 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]();

                                                    // 调用具体的syscall函数。返回值放a0。
                                                } else {
                                                    printf("%d %s: unknown sys call %d\n",
                                                            p->pid, p->name, num);
                                                    p->trapframe->a0 = -1;
                                                }

                                        } else if((which_dev = devintr()) != 0){
                                            // ok
                                        } else {
                                            printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
                                            printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
                                            setkilled(p);
                                        }

                                        if(killed(p))
                                            exit(-1);

                                        // give up the CPU if this is a timer interrupt.
                                        if(which_dev == 2)
                                            yield();

                                        usertrapret(); // 结束具体的syscall。又走usertrapret,准备回到user空间。
                                    }

                        // set up trapframe values that uservec will need when
                        // the process next traps into the kernel.
                        p->trapframe->kernel_satp = r_satp();         // kernel page table
                        p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
                        p->trapframe->kernel_trap = (uint64)usertrap;
                        p->trapframe->kernel_hartid = r_tp();         // hartid for cpuid()
                        // 保存kernel页表。atp
                        // 保存kernel的sp
                        // 设置处理user trap的函数
                        // 保存hartid

                        // set up the registers that trampoline.S's sret will use
                        // to get to user space.

                        // set S Previous Privilege mode to User.
                        unsigned long x = r_sstatus();
                        x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode     // 配置为user模式
                        x |= SSTATUS_SPIE; // enable interrupts in user mode
                        w_sstatus(x);

                        // set S Exception Program Counter to the saved user pc.
                        w_sepc(p->trapframe->epc);
                        // 设置pc为ecall之后。即ecall完成后继续往下走。

                        // tell trampoline.S the user page table to switch to.
                        uint64 satp = MAKE_SATP(p->pagetable);
                        // 页表配置为进程的user页表

                        // jump to userret in trampoline.S at the top of memory, which 
                        // switches to the user page table, restores user registers,
                        // and switches to user mode with sret.
                        uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline);
                        ((void (*)(uint64))trampoline_userret)(satp);
                            // 走userret函数。sret后进user模式。

                            /*
                            userret:
                                # userret(pagetable)
                                # called by usertrapret() in trap.c to
                                # switch from kernel to user.
                                # a0: user page table, for satp.

                                # switch to the user page table.
                                sfence.vma zero, zero
                                csrw satp, a0
                                sfence.vma zero, zero

                                li a0, TRAPFRAME

                                # uservec里保存的寄存器。现在都拿回来。

                                # restore all but a0 from TRAPFRAME
                                ld ra, 40(a0)
                                ld sp, 48(a0)
                                ld gp, 56(a0)
                                ld tp, 64(a0)
                                ld t0, 72(a0)
                                ld t1, 80(a0)
                                ld t2, 88(a0)
                                ld s0, 96(a0)
                                ld s1, 104(a0)
                                ld a1, 120(a0)
                                ld a2, 128(a0)
                                ld a3, 136(a0)
                                ld a4, 144(a0)
                                ld a5, 152(a0)
                                ld a6, 160(a0)
                                ld a7, 168(a0)
                                ld s2, 176(a0)
                                ld s3, 184(a0)
                                ld s4, 192(a0)
                                ld s5, 200(a0)
                                ld s6, 208(a0)
                                ld s7, 216(a0)
                                ld s8, 224(a0)
                                ld s9, 232(a0)
                                ld s10, 240(a0)
                                ld s11, 248(a0)
                                ld t3, 256(a0)
                                ld t4, 264(a0)
                                ld t5, 272(a0)
                                ld t6, 280(a0)

                                # restore user a0
                                ld a0, 112(a0)
                                
                                # return to user mode and user pc.
                                # usertrapret() set up sstatus and sepc.
                                sret

                                // pc已经设置到ecall之后,继续执行后续指令。一个system call完全结束。

                            */

                c->proc = 0;
            }

            release(&p->lock);



实战#

gdb#

一个窗口make qemu-gdb
目录下会生成.gdbinit。里边包含了gdb调试这个qemu所需的准备命令。
另一个窗口gdb-multiarch --command=.gdbinit。这样gdb启动时会加载这个.gdbinit

trace#

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

  • 第一个参数是一个mask设置,对应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_mask

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

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

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

  9. 修改kernel/syscall.c。调用syscall后判断num和trace_mask,如果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为4k字节。

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

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

  • freerange
    对地址范围进行kfree。初始化时会对物理内存整个范围进行kfree,形成一个全新的可用页的list。

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

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

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