有三种情况会使CPU跳出当前指令流转而执行一些指定的代码。第一种情况是系统调用,使用ecall
指令来让内核做一些事情。第二种情况是异常,是由指令的非法执行引起的。第三种情况是设备中断,由设备发出中断信号引起设备中断。
上述三种情况在这里都被称为trap。当trap发生时,任意正在发生的指令都应当能恢复并继续执行。trap应当是透明的,它的一般流程是:先把控制强制转移到内核,然后内核保存寄存器和一些状态;接下来内核执行相应的处理流程;内核恢复先前保存的状态并从trap返回;原代码恢复继续执行。
xv6内核处理了所有的trap。由于内核本来就是系统调用的提供者,所以它显然处理了系统调用;用户进程需要借助内核来管理设备,所以它也处理了中断;内核会杀死在用户空间产生异常的进程,所以它也处理了异常。
xv6的trap处理例程有四个阶段:一,CPU执行硬件动作;二,一段汇编“向量”用于准备到内核C代码的路径;三,一段C处理代码以决定trap作什么;四,系统调用或设备驱动服务例程。虽然可以用统一的代码路径处理所有的三种trap,但把它分成三种处理路径是有益的:来自内核空间的trap,来自用户空间的trap,计时器中断。
由于内核与设备硬件的交互多是通过中断来进行的,所以设备驱动也和trap放在一起讨论。
RISC-V的trap机构
RISC-V支持许多关于中断的控制寄存器,可以在kernel/riscv.h里看到关于它们的定义。如下是一些重要的寄存器:
- stvec - 保存trap处理例程的地址
- sepc - 当trap发生的时候,保存程序计数器的值。
sret
指令把sepc
的值再保存到pc
。内核可以通过修改sepc
的值来控制sret
返回到哪里。 - scause - 用数字来描述trap的原因
- sscratch - 在trap handler最开始的时候有用
- sstatus - SIE位控制设备中断是否生效,SPP位记录trap来自于哪个模式(用户模式或管理员模式)
如上寄存器只能在管理员模式下操作,不能在用户模式下读写。机器模式下也有同样的一套寄存器,但xv6只把它们用于计时器中断。
除了计时器中断,RISC-V硬件处理所有trap流程都是一样的,如下所示:
- 如果是设备中断,且
sstatus
的SIE位为0,则不进行如下流程。 - SIE位置为0,禁止所有中断。
- 把
pc
复制到sepc
。 - 把当前的模式(用户或管理员)保存到
sstatus
寄存器的SPP位。 - 设置
scause
来记录中断的原因。 - 切换到管理员模式。
- 把
stvec
保存到pc
。 - 从新的
pc
开始执行。
CPU必须在一个单独的操作里执行完上述所有的步骤。
要注意的是,CPU不切换到内核的页表,不切换到内核的栈,不保存除pc
外的其它寄存器。那些事需要由内核来做。在trap的过程中CPU做尽量少的工作,这是为了给软件提供尽可能高的灵活性。
内核空间的trap
xv6内核运行的时候,可能发生两种trap:异常和设备中断。
当内核运行的时候,stvec
里保存的是标记kernelvec
的地址(详见kernel/kernelvec.S)。由于已经在内核里,所以不需要修改内核页表和内核栈。它只是保存了所有的寄存器,用以从中断代码中恢复。
kernelvec
保存了被中断内核线程的栈寄存器,因为这个值是属于那个线程的。如果trap引发了不同线程的切换,这就非常重要了。
然后kernelvec
就跳转到了kerneltrap
。kerneltrap
用于处理两种trap:设备中断和异常。kerneltrap
又通过调用devintr
来实现其功能。如果是设备中断,则在devinitr
里处理,如果是异常,则devinitr
返回0。因为内核里的异常一般都是严重的错误,所以在kerneltrap
里对异常的处理就是直接打印相关寄存器的信息然后panic。
如果kerneltrap
是因为计时器中断被调用的,并且运行的不是调度器线程而是进程的内核线程,kerneltrap
调用yield
来给其它线程一个运行的机会。本线程和它的kerneltrap
也会因为其它线程运行yield
得以恢复。
当kerneltrap
完成后它必须能正确的返回。由于yield
可能会改变sepc
和sstatus
的值,所以在刚开始的时候kerneltrap
要保存它们的值。在最后要恢复它们的值到相应的寄存器才可以从kerneltrap
返回。返回到kernelvec
后,从栈弹出值到相应的寄存器并执行sret
,这将会把sepc
保存到pc
从而恢复被中断的内核代码。
当从用户空间进到内核里,xv6会把kernelvec
保存到stvec
里(详见kernel/trap.c的usertrap
函数)。这就为内核执行的时候而stvec
的值是错的留下了窗口时间,在那个窗口关闭设备中断是非常重要的。幸运的是RISC-V开始执行一个trap的时候总是会关闭中断,而且xv6只有在stvec
寄存器被重新设置后才会使能设备中断。
用户空间的trap
用户空间的trap有三种情况:用户程序的系统调用,非法指令,设备中断。用户空间的trap从uservec
开始,然后跳转到usertrap
,然后调用usertrapret
,然后执行userret
。
用户代码的trap要比内核的困难多了,因为用户页表没有映射到内核,而且栈指针可能包含无效甚至是有害的内容。
RISC-V硬件并不会在trap发生的时候切换页表,那么就需要把stvec
指向的trap向量指令映射到用户页表里。此外,trap向量必须切换satp
以指向内核页表,而且为避免崩溃,向量指令必须在内核页表里映射同样的地址,就像在用户页表里那样。
xv6用一个trampoline页来满足上述约束,trampoline页包含了trap向量代码。xv6在所有的页表里都把trapoline页映射到了同样的虚拟地址。这个虚拟地址就是TRAMPOLINE
。trampoline的内容来自于trampoline.S
,而且当执行用户代码的时候会把uservec
的地址写入stvec
。
uservec
开始的时候,所有寄存器里的值都是属于中断代码的。但uservec
需要修改一些寄存器来设置satp
,那就需要先把这些寄存器的值保存在内存里。RISC-V提供sscratch
来帮助做这些事。在uservec
刚开始的csrrw
指令交换了a0
和sscratch
的值。现在a0
里保存的是之间内核里sscratch
的值,而sscratch
里保存的是用户代码里a0
的值。
uservec
接下来要保存用户寄存器。在进入用户空间之前,内核用sscratch
来指向每个进程的trapframe
,trapframe
用于保存所有的用户寄存器。由于satp
仍指向用户页表,uservec
需要把trapframe映射到用户地址空间。当创建每个进程的时候,xv6为进程的trapframe分配了一个页,并总是把它映射到虚拟地址TRAPFRAME
,TRAPFRAME
就在TRAMPOLINE
的下面。进程的p->tf
指向trapframe,它虽然是个物理地址,但可以通过内核页表来访问它。
所以,在切换a0
和sscratch
之后,a0
里保存的是当前进程trapframe的指针。现在uservec
保存了所有的用户寄存器,包括在sscratch
里保存的原a0
的值。
trapframe
包含了到当前进程的内核栈的指针,当前CPU的hartid,usertrap
的地址,内核页表的地址。uservec
把它们的值恢复到相应的寄存器里,然后就调用了usertrap
。
usertrap
和kerneltrap
的任务是一样的,判断trap的原因,执行然后返回。首先修改stvec
的值为kernelvec
以处理内核模式的trap。然后保存sepc
的值,因为sepc
的值有可能因为有进程切换进usertrap
而被覆盖。如果trap是系统调用,用syscall
来处理。如是设备中断,用devinitr
来处理。否则就是异常,直接杀死错误的进程。系统调用之所以要向保留的用户pc
加4,是为了返回的时候程序计数器指向ecall
的下一条指令。最后,usertrap
会检查进程是否已经被杀死,或者如果是计时器中断则应释放CPU。
要想返回用户空间,首先要调用usertrapret
。此函数通过设置控制寄存器来准备下一个用户空间的trap。首先,让stvec
指向uservec
,然后准备uservec
依赖的trapframe域,然后给sepc
恢复之前保存的用户程序计数器的值。最后,调用trampoline
页上的userret
,由userret
的汇编代码来切换页表。
usertrapret
在调用userret
的时候,把进程用户页表的指针传给了a0
,把TRAPFRAME
传给了a1
。userret
把satp
切换为进程的用户页表。由于trampoline页在所有页表中都映射在同样的虚拟地址,这就是在切换satp
后uservec
能继续执行的原因。接下来userret
为了以后和TRAPFRAME切换把a0
复制到了sscratch
。从此之后,userret
可使用的数据只有寄存器里的内容和trapframe里的内容。接下来userret
把trapframe里的内容恢复到用户的寄存器,切换a0
和sscratch
的值以恢复用户的a0
寄存器并并为下一次的trap保存TRAPFRAME,最后使用sret
返回用户空间。
计时器中断
xv6使用计时器中断管理时钟和切换进程。usertrap
和kerneltrap
调用yield
实现进程切换。时钟硬件的计时器中断附加到每个RISC-V CPU。xv6编程时钟硬件来周期性地中断每个CPU。
RISC-V要求计时器中断发生在机器模式,而不是管理员模式。RISC-V的机器模式没有分页,而且用不同的控制寄存器,所以没有办法在机器模式下运行普通的xv6代码。所以,xv6处理计时器中断和上述trap机制是完全不同的。
运行于机器模式的timerinit
(在kernel/start.c
),初始化了计时器中断。其中一部分工作是编程CLINT硬件来使某个间隔后生成中断。其它部分是设置一个空白区域,类似于trapframe,使得计时器中断的例程保存寄存器,和找到CLINT寄存器的地址。最后,设置mtvec
为timervec
并使能计时器中断。
不管执行的是用户代码还是内核代码,计时器中断可能发生在任何点上;内核也没有办法关闭计时器中断。所以计时器中断在执行的时候必须要保证不能影响到被中断的内核代码。基本的策略是计时器中断让RISC-V产生一个软件中断并立即返回。RISC-V使用通常的trap机制向内核分发软件中断,并允许内核关闭它们。计时器中断产生的软件中断的处理代码在devintr
。
机器模式的计时器中断向量是timervec
。首先,它向空白区域(scratch area)保存了一些寄存器的值,这个空白区域是在start
里被初始化的。然后,告诉CLINT何时生成下一次计时器中断。然后告诉RISC-V生成一个软件中断,恢复寄存器的值并返回。计时器中断里没有C代码。
调用系统中断
本节描述用户代码到exec
系统调用的路径。
首先,在user/initcode.S
,把传给init
的参数放在a0
和a1
这两个寄存器里,把exec
的系统调用号放在a7
里。系统调用号和系统调用的对应关系可在kernel/syscall.c
里看到。然后执行ecall
指令切换到内核并执行uservec
,uservec
调用usertrap
,usertrap
调用syscall
。
syscall
从trapframe里获得系统调用号,因为trapframe里包含了被保存的a7
。由于a7
的值是SYS_exec
,所以syscall
将调用sys_exec
。
syscall
把系统调用的函数的返回值记录在p->tf->a0
。当系统调用准备返回用户空间,userret
将把p->tf
的值载入寄存器并使用sret
返回用户空间。于是,当exec
返回用户空间,系统调用处理例程返回的值就通过a0
返回了。系统调用通过返回负数表示错误,0或正数表示成功。如果系统调用号无效,syscall
打印错误并返回-1。
系统调用的参数
关于系统调用的机制,还剩一点需要说明:找到系统调用参数。
RISC-V规定的C调用约定是,通过寄存器来传递参数。在系统调用期间,这些寄存器在trapframep->tf
里。函数arginit
,argaddr
和argfd
用于获取系统调用的参数,分别对应整数、指针和文件描述符。它们都调用argraw
来读取已保存的寄存器。
一些系统调用传递的参数是指针,内核必须用这些指针来读写用户内存。比如,exec
系统调用传递给内核的参数是字符串指针的数组。这些指针就面临两个挑战。一,用户程序可能是错误的或恶意的,传递给内核的可能是无效指针,或欺骗内核从而访问内核内存。二,内核页表的映射和用户页表的映射是不一样的,内核没法使用普通的指令访问用户空间。
许多内核函数都需要安全地读取用户空间,比如fetchstr
。系统调用exec
使用fetchstr
来获取用户空间的字符串参数。fetchstr
又调用了copyinstr
,copyinstr
查找用户页表里的虚拟地址,把它转化成内核可以使用的地址,再从这个地址里把字符串复制进内核。
copyinstr
把用户页表pagetable
的虚拟地址srcva
里的内容复制到dst
,最高复制max
字节。它使用walkaddr
遍历页表以找到虚拟地址对应的物理地址。由于内核映射了所有的物理地址且是等值映射,所以copyinstr
可以直接复制字符串。walkaddr
会检查所提供的虚拟地址是否属于进程的用户地址空间,所以程序无法欺骗内核来读取其它的内存。类似的函数copyout
,用于把内核的数据复制到用户提供的地址。
设备驱动
驱动是操作系统代码,用于管理特定的设备:它告诉设备硬件去执行操作,配置设备以生成中断,处理因此而产生的中断,与进程交互(进程可能在等待来自设备的I/O)。驱动代码可以很复杂,因为驱动设备和它管理的设备同时执行。另外,驱动程序必须理解设备的硬件接口,这个接口可能是复制的且缺少文档。
需要操作系统注意的设备通常可以配置为生成中断,中断是trap的一种。当设备产生中断的时候内核的trap处理代码必须能识别它,并调用该设备的中断处理例程。在xv6里,这个分发的过程是由devinitr
来控制的。
许多设备驱动由两部分组成:进程里的代码,中断时运行的代码。进程级的代码是由系统调用来驱动的(像write
和read
那样让设备执行I/O)。这部分代码可能是让设备执行一个操作(如读取磁盘里的一个块),然后等待操作的完成。设备完成操作后发出一个中断。驱动的中断处理例程推算出完成了什么操作(如果存在的话),在适当的情况下唤醒一个等待的进程,还可能会告诉硬件执行下一个等待的操作。
控制台驱动
以控制台驱动为例来说明驱动程序的结构。控制台驱动通过UART串口来接收人类输入的字符。驱动一次积累一行,处理特殊输入字符(如回退键和Ctrl-u)。用户进程(如shell),可以使用read
系统调用从控制台获取输入的行。
驱动管理的UART硬件是16550 chip。在真实的计算机上,16550通过RS232串口连接到终端或其它计算机。
UART通过内存映射寄存器的方法暴露给软件。这意味着,RISC-V通过一些物理地址连接到UART设备,对那些物理地址的读写是在与设备硬件交互,而不是与内存交互。UART的内存映射地址是UART0
(0x10000000)。那里有少量的UART控制寄存器,每个宽度都是1个字节。那些寄存器被定义在kernel/uart.c。例如,LSR
寄存器是行状态寄存器(line status register),它有一些位用来表示是否有输入字符需要被软件读取。这些用于读取的字符在RHR寄存器(receive holding register)。每读取一个,UART硬件就从内部的等待字符FIFO里删除一个,当FIFO为空时则清空LSR
的”就绪”位。
xv6的main
调用consoleinit
来初始化UART硬件,并配置UART硬件生成输入中断。
xv6的shell通过文件描述符来读取控制台。read
系统调用通过内核到达consoleread
。consoleread
等待输入的到达(通过中断),并缓存进cons.buf
,然后把输入复制到用户空间,在接收完整个行后返回用户进程。如果用户还没有输入完整的行,所有的读进程(reading processes)将在sleep
调用中等待。
当用户输入一个字符,UART硬件会向RISC-V发送一个中断。依如前所述的中断处理流程,调用到devinitr
。devintr
通过scause
寄存器查到中断来自于外设。然后,通过PLIC来获取是哪个设备中断。如果是UART,则调用uartintr
。
uartintr
从UART硬件里读取任何等待输入的字符,并把它们交给consoleintr
;它不等待字符,因为新的输入会产生新的中断。consoleintr
的工作是把输入字符积累到cons.buf
直到接收一个完整的行。consoleintr
会对回退字符或其它少量字符进行特殊处理。当接收到一个完整的行,consoleintr
会唤醒等待中的consoleread
(如果存在)。
一旦被唤醒,consoleread
将在cons.buf
里观察到一个完整的行,把它复制进用户空间,并返回到用户空间。
在多核机器上,中断有可能被分配到任意CPU上,这由PLIC管理。当CPU执行的进程在读取控制台的时候,中断也有可能发生。所以,中断处理例程需要设计为无需考虑进程或代码正在中断的情况。