xv6 chapter5: Interrupts and device drivers
这一章主要讲操作系统如何控制和组织硬件,之前的操作多集中在CPU这一硬件的操作,但还有很多其它硬件需要操作系统管理,这也是操作系统的基本任务之一。
在操作系统中每个硬件有其专门为其编写的控制代码,这部分代码叫作该硬件的驱动(driver),它的工作包括配置硬件,处理中断,与进程交互等,总的来说驱动代码需要非常清楚硬件的各种接口才能编写。当硬件需要内核协作时就会发出interrupt,内核解析出该trap类型并调用相应的interrupt handler。这一过程在xv6中具体体现为trap解析出该trap来自外部设备时调用devintr处理,在devintr中进一步解析哪个设备发出中断并调用相应的handler。
一般驱动代码分为两部分,一部分是提供给进程或者内核使用的高级接口,比如通过read想要从磁盘读取数据,这部分代码只需要设置一些参数后就等待下面的操作完成,另一部分就是驱动的低级处理代码,这部分代码通常与硬件相关,以interrupt handler的形式出现,当底部操作完成时即可通知或唤醒上层正在等待的代码。
xv6的控制台输入
在kernel/console.c中就是控制台的驱动代码,它主要负责处理键入的字符(通过另一个硬件UART传递),比如Ctrl+u等特殊字符,以及利用一个buffer将其累积为一行行的数据,供相应的read系统调用使用。整个console.c里面主要由consputc,consolewrite,consoleread,consoleintr几个函数组成,第一函数由内核使用,用于内核将一个字符传入到console,可以看到它只是简单的发送功能,除了删除字符特殊处理了一下,该函数不在consolewrite中使用,一方面区分了内核与用户对console的一些控制逻辑,另一方面是为了防止死锁(原理下章提到,由于这章锁使用较多建议先阅读下章再回来看这章),因为uartputc会有一个spinlock来控制并发,如果中断和用户都用这个lock会导致死锁,内核使用的consputc会调用uart中不使用锁的函数。后面两个函数则用于用户的write与read调用,可以在consoleinit中看到两个函数被绑定为CONSOLE相应的系统调用,最后一个consoleintr则是在系统接受到字符产生中断时会被调用,用户按下键盘之后UART首先接收到这个字符然后产生中断,该中断会调用uartintr,而uartintr会调用consoleintr来处理一些特殊字符比如Ctrl+p等,如果只是普通字符consoleintr则会将其存储到cons.buf当中供以后的read使用,可以看到当遇到’\n’与’\r’时会调用wake来唤醒一个进程。
在kernel/uart.c中的则是关于UART(Universal Asynchronous/Synchronous Transmitter)的驱动,也可以看作console驱动管理的硬件,操作系统需要通过一组寄存器来控制它,且该部件使用了mmio方式控制,从UART0(0x10000000)出开始就是该硬件的寄存器,每个寄存器大小为8bit,具体功能详见其手册。比较重要的几个寄存器如下:
0: RHR / THR – receive/transmit holding register
1: IER – interrupt enable register, RX_ENABLE=0x1 and TX_ENABLE=0x2
…
5: LSR – line status register, RX_READY=0x1 and TX_IDLE=0x20
可以将UART看作三个部分,receive hardware,transmit hardware,以及内部维护一个FIFO,当按下键盘时uart产生interrupt,经devintr解析后会调用uartintr,先从寄存器读出该字符然后传递给consoleintr处理该字符。
xv6的控制台输出
前面提到的consoleintr如果要在屏幕上显示一个字符会调用uartputc完成,前面说过uart有三部分,要在屏幕上显示的字符是由transmit hardware传输到屏幕端的,在uart.c里维护了一个buffer,存储需要传输到屏幕的字符,uartputc本质上就是将字符放到这个缓冲区里面,没有真正去发送这个字符,真正完成发送这个动作的是uartstart,它会检查当前的传送器是否可用,然后进行一些其它的逻辑检查,如果一切准备就绪那就将字符写到对应寄存器发送,当发送完成时会产生新的中断,在uartintr中又调用了uartstart,所以当需要发送多个字符时只有第一次发送会主动调用uartstart,后面都是由中断自动完成。
xv6的时钟中断
RISC-V有硬件产生恒定频率的信号,且需要时钟中断工作在机器模式下,这也就意味着时钟中断的处理会有独立的一套寄存器,且与普通的内核代码有区别,具体的代码放在start.c里,这段代码会在main之前被执行,timerinit就在此处被调用,其主要工作就是配置CLINT,使其按照一定的delay触发一个中断,并设置好对应的中断处理入口。xv6允许中断在任何时候发生,即使是在执行内核代码,发生时钟中断时也可能切换线程,这也就导致内核线程可能被挂起然后被另外的CPU所执行。
Lab: Copy-on-Write Fork for xv6
在traps那章的结束介绍了一些关于Page Fault的重要应用,这个实验就是要实现其中的cow
这里再说一下cow的原理,由于大部分fork调用之后都会紧跟一个exec调用,exec调用会构造一个新的页表并释放当前页表,可在原始的xv6中,fork会将父进程地址空间的所有内容全部拷贝一份,而这份页表几乎没怎么使用就会被后面的exec释放掉,中间这份深拷贝几乎是多余的。如果只拷贝父进程的页表项目,并且将所有页表项都设置为只读,子进程就能通过构造的页表访问父进程的所有内容,当需要向某个地址写入内容时则会触发中断,内核此时为其分配一个新的页并将内容拷贝至该页然后修改pte使其指向该页,并将其PTE_W置1,这样中断返回时用户再次访问该地址即可写入。
其中几个需要解决的主要问题如下:
- 怎么区分一个页是否是cow页,因为如果该页原先就是只读页而子进程如果向该页写入就应该触发错误,只有该页为cow页才会为其申请新的页
- 当某个只读页被复制到多个进程,其中的一个进程如果释放地址空间则会将该页释放,导致其它进程无法读取或者读取到错误信息,需要记录一个页的引用次数
- 让中断处理程序能识别出该种类型的中断
第一个问题可以通过利用PTE的reserved位来标记该页是否为cow页,在riscv.h里添加一个宏即可:#define PTE_RSW (1L << 8) // cow page mark
第二个问题我使用了一个固定大小的数组来记录每个物理页被引用的次数,定义在kernel/kalloc.c中:
#define PA2HASH(pa) (((uint64)pa-KERNBASE)>>PGSHIFT)
void freerange(void *pa_start, void *pa_end);
extern char end[]; // first address after kernel.
// defined by kernel.ld.
struct {
struct spinlock lock;
uint8 pagecnt[((PHYSTOP-KERNBASE)>>PGSHIFT)+100];
} pageref;
由于内核与用户的代码在KERNBASE以后,ens是一个变量无法在定义时确定,预估内核代码100个页以下,
由于此结构可能被多个线程同时访问所以需要设定一个锁来保证正确性。
对于第三个问题,可以查阅RISC-V的手册,在kernel/trap.c里的devintr
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 2128099421@qq.com