xv6中trap(即让cpu暂停执行当前代码,去执行相应的处理代码的情况)有三种:
- 系统调用
- 异常,如除以零
- 设备中断,例如完成了一次读写事件,当然也包括定时中断
发生trap时,一般的处理是:
- 控制转移到kernel
- kernel保存当前进程的相关状态(具体就是trampoline.s中,将寄存器保存到进程的trapframe,将栈顶指针设为内核栈的,恢复tp寄存器,让其保存当前cpu的id,从trapframe中取出内核页表页的地址,设置为satp的值,并刷新tlb,从tf中取出usertrap的地址,并跳转到它)
- 执行trap handler代码(usetrap)
- (可选的)执行usertrapret(TODO),
- 执行trampoline.s中的userret
- 返回用户态,继续执行用户程序
上面usertrap和usertrapret有一些细节需要注意,TODO
riscv trap machinery
riscv有一些控制寄存器,xv6里用到的有这些:这些寄存器只能在supervisor 模式(即内核态)被读写
- stvec:保存trap handler的地址,当发生trap时,将他的值设为pc
- sepc:当trap发生时,将pc保存到sepc,trap返回用户态时(trampoline.s中的useret),执行sret将sepc设置为pc
- scause:发生trap时,这里保存的值指示trap的种类/原因
- sscratch:发生trap时,他的值是当前进程tf的地址
- sstatus:其中SPP bit标识之前的模式是supervisor(1)还是用户态(0),SIE bit标识当前中断是否开启,如果为0,那么中断会被推迟到SIE bit为1
当发生trap时,riscv的硬件会:
- 如果是设备中断,并且SIE为0,那么什么也不做
- 禁用中断(SIE置零
- 将pc复制到sepc
- 设置SPP bit,表明当前模式
- 设置scause,表明trap原因
- 将模式设为supervisor
- 设置pc为sepc
- 从新pc开始执行
cpu必须一次完成上述步骤,例如,如果没有更换pc,那么可能现在是内核态,但是仍然执行用户指令
注意,cpu并没有切换页表,没有切换栈,没有保存除了pc之外的寄存器,这些任务留给内核来执行,这样设计可以给软件提供最大的灵活性
trap from kernel space
内核态可能发生的trap有两种:异常和中断(内核态的系统调用应该不算trap
内核态中,stvec被设为kernelvec(内容为保存寄存器,并调用kerneltrap),因为已经是内核态了,所以此时的页表是内核页表,也是运行在内核栈上
上述的保存寄存器是保存在该进程的内核栈上(这一点很重要,因为如果在kerneltrap中切换到其他进程,如kerneltrap中调用yield,这样做就能保证重新切换回来时,能够从该进程的内核栈上恢复寄存器)
处理完trap后,恢复sepc和sstatus(因为如果yield了,他们的值可能被改动过了),然后继续运行kernelvec,从内核栈上恢复寄存器
如果kerneltrap中调用了yield,该怎么返回?其实没区别?yield切换到其他进程,然后重新调度回来后,依然kernelvec,返回到之前内核态发生trap的位置继续执行?
注意,之前提到过,stvec在用户态下指向的是trampoline.s中的uervec,但是发生trap之后,stvec并没有被马上设置为kernelvec的地址,但是这不会有问题,因为发生trap之后,cpu马上关闭了中断,所以即使内核中有一段时间stvec没有指向kernelvec,也没关系
trap from user space
用户空间的trap,比内核态要多一种:系统调用
处理user trap基本是这样一条路线:uservec,usertrap,usertrapret,userret(头尾两个是trampoline.s),中间是trap.c
在本os中,用户进程的页表里没有映射内核,因为riscv的硬件不会在发生trap时切换页表,所以我们需要将用户内核转换的代码映射在用户地址空间(页表)里,并且用户内核转换的代码trampoline.s必须切换到内核页表,并且为了避免崩溃,trampoline必须在内核页表和用户页表中的地址必须相同(WHY?TODO 当然确实是这么做的
(中间讲了一堆 乱七八糟的 uservec怎么把寄存器保存到trapframe里)
进入usertrap后,首先设置stvec为kernelvec(因为现在是内核态了),保存sepc,因为可能如果切换到其他进程,sepc可能会被覆盖。
如果是系统调用,那么运行syscall函数,注意此时会将sepc+4,因为之前sepc指向的是系统调用那一行汇编代码(ecall),但是我们返回时,要从系统调用之后继续运行
如果是异常,那么,kill该进程
返回地址空间,首先调用usertrapret,他首先修改stvec,重新指向uservec,并设置uservec所依赖的trapframe字段,设置sepc为之前保存的pc(之前trap时将pc保存到了sepc),最后,调用userret:切换到用户页表,恢复寄存器
MAKESATP 没看懂
#define MAKE_SATP(pagetable) (SATP_SV39 | (((uint64)pagetable) >> 12))
因为trampoline在内核和用户地址空间中的地址相同,所以上面即使切换了页表,也能正常运行
timer interrupt
定时中断是riscv时钟硬件定时发出的,它被用来维护xv6的时钟,以及用来驱动切换进程
riscv要求定时中断在machine mode中处理,而不是supervisor mode。而machine mode没有启用分页,并且有一套自己的控制寄存器,因此,一般不把内核代码运行在machine mode中。因此,xv6处理定时中断的方式和上面提到的trap是不同的。
在machine mode下执行的代码在start.c里,在main之前执行,设置接受定时中断:一方面是对CLINT(core-local interrupt)进行编程,使其在固定事件后发出中断信号,另一方面是设置scratch区,类似trapframe,以便timer interrupt handler能够保存寄存器,并且找到CLINT寄存器的地址。最后,start设置mtvec为timervec,启用定时中断
定时中断可能发生在用户态和内核态,内核不能再关键操作时禁用中断,因此定时中断处理程序在完成自己任务时必须保证不能打乱被中断的内核代码。基本策略是让定时中断请riscv发出一个software interrupt,然后马上返回。riscv使用普通的trap机制将software interrupt传给内核,允许内核禁用他们。处理定时中断产生的软中断的代码在devintr里
machine mode定时中断向量是timervec,保存一些寄存器到scratch里,告诉CLINT何时产生下一个中断,让riscv发出一个软中断,然后恢复寄存器,返回
code:调用系统调用
initcode.s调用了exec系统调用:首先设置参数,这里是设置a0(要执行的程序的名称,init),设置a1(argv:init,0),然后将系统调用号存到a7,再执行ecall:uservec->usertrap->syscall->userret(如之前所说)
syscall首先从a7(保存在tf里)里取出系统调用号,然后从函数指针的数组里取出对应系统调用的地址,并执行,将返回值保存在tf->a0,
返回用户态时,userret会将tf中寄存器的值恢复到cpu寄存器,然后继续执行用户代码
code:系统调用参数
riscv的c调用约定使用寄存器传递参数,在系统调用里(内核态),寄存器的值可以通过tf获取(argraw)
一些系统调用传递的是指针,这样,内核态就必须读取用户内存。例如,exec的argv就是一个指针数组。其中指针指向的就是用户内存,这带来了两个挑战:
- 用户程序可能是恶意的,从而可能传给内核一个非法的指针,或是让内核去读内核而不是用户内存
- 内核页表和用户页表的映射不同,所以内核不能直接读写用户提供的地址
这里的方式是,使用walkaddr,使用该用户页表,找到给出的虚拟地址对应的物理地址,然后从物理地址(为什么?因为在内核页表中,大部分的物理地址和虚拟地址都是值相同的)获取内容
walkaddr也检查了传入的用户虚拟地址,如果其超出了该用户的地址空间就报错,这样,就不会让内核读其他的内存
设备驱动
驱动是os中管理特定设备的代码,他告诉设备硬件:执行操作,配置设备,使其在完成操作后产生中断,处理得到的中断,以及和等待设备io的进程interact
驱动的代码可能很棘手,因为驱动和管理的设备并发运行。而且,驱动必须理解设备的硬件接口,这可能很复杂,文档很少
需要os操作的设备一般可以配置成产生中断(trap的一种)。发出中断时,内核trap处理程序必须能够辨认出来,并且调用设备的中断处理程序;在xv6中,这种分配发生在devintr中
很多设备驱动分为两部分:一部分是作为进程的一部分运行,另一部分是在中断时运行。前者被系统调用如read/write驱动,从而让设备执行一个操作,然后等待操作完成。完成后发出一个中断,驱动的中断处理程序知道什么操作完成了之后,唤醒等待的进程,(可能)告诉硬件继续执行其余在排队等待的操作
code:console driver
console driver是对驱动结构的一个简单阐述
console驱动通过UART串口硬件接受人们输入的字符。驱动每次积累一行的输入,处理诸如删除(backspace),control-u
用户进程,如shell,通过read系统调用来从console获取一行的输入,当你在qemu中输入到xv6,你按下的键通过qemu模拟的uart传递到xv6
驱动打交道的uart硬件是qemu模拟的16550芯片。在真是的计算机上,16550会管理连接到终端或者其他计算机的一个rs232串行链路
当运行qemu时,连接到键盘和显示器
uart硬件在软件看来就是一组内存映射的控制寄存器。即,有一些物理地址,riscv硬件将其连接到uart设备,这样加载保存是和设备而不是内存打交道。uart被映射到的内存地址是uart0.有一些uart控制寄存器,每个一个字节,他们到uart0的offset定义在uart.c。例如,lsr寄存器包含的bit指示输入的字符是否在等待软件读取,这些字符可以从rhr寄存器读取。每次读了一个字符后,uart硬件就将其从内部的保存等待字符的fifo中删除,如果fifo为空,那么lsr为0
main调用consoleinit来初始化uart硬件,并且配置使其产生输入中断
init.c中打开了console,shell从其对应的fd读取console收到的字符,调用read会最终调用consoleread,然后consoleread等待输入(通过中断),输入缓存在cons.buf中,将输入复制到用户空间,一行到达后,返回到用户进程。如果用户没有输入一整行,那么reading process会在sleep中等待。
当用户输入一个字符,uart让riscv发出一个中断,riscv和xv6以如上所述的方式处理中断,xv6的trap处理程序调用devintr,devintr通过scause发现中断来自外部设备,再从PLIC知道是哪个设备中断了,如果是uart,那么调用uartintr
uartintr从硬件寄存器rhr中读出字符,传递给consoleintr,他不等待字符,因为以后的输入会产生新的中断。consoleintr的工作是处理特殊字符(如control u,删除一行,control h 回退一格,control p打印当前运行的进程),在cons.buf中保存字符直到输入一行或者缓冲区满,那么此时唤醒consoleread
一旦consoleread被唤醒,就能观察到cons.buf中的一整行,复制到用户空间,并且返回到用户态
在多核机器上,中断可能传给任一个cpu。PLIC管理这个选择。中断可能传给在运行read的cpu,也可能是其他的cpu
因此中断处理程序不应该关心被中断的程序(?TODO