5 Interrupts and device drivers
我们以串口举例,从控制台初始化开始,以命令行显示"$ "和命令输入为例,分析控制台的整个输入输出过程以及中断在其中起的作用
控制台的硬件连接
qemu中模拟的硬件连接
驱动程序框架
书里面的原话:
Many device drivers execute code in two contexts: a top half that runs in a process’s kernel thread, and a bottom half that executes at interrupt time. The top half is called via system calls such as read and write that want the device to perform I/O. This code may ask the hardware to start an operation (e.g., ask the disk to read a block); then the code waits for the operation to complete. Eventually the device completes the operation and raises an interrupt. The driver’s interrupt handler, acting as the bottom half, figures out what operation has completed, wakes up a waiting process if appropriate, and tells the hardware to start work on any waiting next operation.
译文:
许多设备驱动程序在两种上下文中执行代码:上半部分在进程的内核线程中运行,下半部分在中断时执行。上半部分是通过系统调用来调用的,比如读和写,它们需要设备执行I/O。这段代码可以要求硬件启动一个操作(例如,要求磁盘读取一个块);然后,代码等待操作完成。最终,设备完成操作并引发中断。驱动程序的中断处理程序,作为底部的一半,计算出哪个操作已经完成,如果合适的话,唤醒一个等待的进程,并告诉硬件开始任何等待的下一个操作。
前期初始化
在mian.c里面进行了控制台驱动的初始化consoleinit,可以看到他就是进行了串口驱动的初始化,同时对控制台设备的read和write函数重定义。(其实控制台驱动就是对串口驱动进行了一层封装)
void consoleinit(void) { initlock(&cons.lock, "cons"); uartinit(); // connect read and write system calls // to consoleread and consolewrite. devsw[CONSOLE].read = consoleread; devsw[CONSOLE].write = consolewrite; }
之后来到第一个进程init.c
int main(void) { int pid, wpid; if(open("console", O_RDWR) < 0){ mknod("console", 1, 1); open("console", O_RDWR); } dup(0); // stdout dup(0); // stderr for(;;){ printf("init: starting sh "); pid = fork(); if(pid < 0){ printf("init: fork failed "); exit(1); } if(pid == 0){ exec("sh", argv); printf("init: exec sh failed "); exit(1); } while((wpid=wait(0)) >= 0 && wpid != pid){ //printf("zombie! "); } } }
他为我们的控制台设备创建了设备节点,使它能够像文件一样被访问。并且创建了文件描述符0,1,2,实际上它们都指向同一设备文件。最后创建shell进程。
Code:Console output
sh.c
int getcmd(char *buf, int nbuf) { fprintf(2, "$ "); memset(buf, 0, nbuf); gets(buf, nbuf); if(buf[0] == 0) // EOF return -1; return 0; } int main(void) { static char buf[100]; int fd; // Ensure that three file descriptors are open. while((fd = open("console", O_RDWR)) >= 0){ if(fd >= 3){ close(fd); break; } } // Read and run input commands. while(getcmd(buf, sizeof(buf)) >= 0){ if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){ // Chdir must be called by the parent, not the child. buf[strlen(buf)-1] = 0; // chop if(chdir(buf+3) < 0) fprintf(2, "cannot cd %s ", buf+3); continue; } if(fork1() == 0) runcmd(parsecmd(buf)); wait(0); } exit(0); }
shell首先打开控制台设备,随后进入getcmd函数,在这里我们就可以看到命令行的"$ "是怎么得到的。
fprintf(2, "$ ");
fprintf函数负责将字符打印到控制台。再深入一点,fprintf函数的调用链:
printf->writ->filewrite->devsw[xxx].write = consolewrite
consolewrite:
int consolewrite(struct file *f, int user_src, uint64 src, int n) { int i; acquire(&cons.lock); for(i = 0; i < n; i++){ char c; if(either_copyin(&c, user_src, src+i, 1) == -1) break; consputc(c); // 串口输出字符 } release(&cons.lock); return n; }
consputc()实现了字符的串口输出,输出的字符将通过屏幕显示出来。至此,命令行的"$ "成功打印。
Code:Console input
Top half
在"$ "打印完之后,就是控制台的输入部分了。
gets(buf, nbuf);
gets函数负责从标准输入获得字符串,遇到回车或换行结束
char* gets(char *buf, int max) { int i, cc; char c; for(i=0; i+1 < max; ){ cc = read(0, &c, 1); if(cc < 1) break; buf[i++] = c; if(c == ' ' || c == ' ') // 回车或换行结束 break; } buf[i] = ' '; return buf; }
这里的read函数调用链:
read->fileread->devsw[xxx].read = consoleread
consoleread:
int consoleread(struct file *f, int user_dst, uint64 dst, int n) { uint target; int c; char cbuf; target = n; acquire(&cons.lock); while(n > 0){ // wait until interrupt handler has put some // input into cons.buffer. while(cons.r == cons.w){ // r==w,buffer内还没有数据 if(myproc()->killed){ release(&cons.lock); return -1; } sleep(&cons.r, &cons.lock); // 进程阻塞 } c = cons.buf[cons.r++ % INPUT_BUF]; // 获得buffer的字符 if(c == C('D')){ // end-of-file if(n < target){ // Save ^D for next time, to make sure // caller gets a 0-byte result. cons.r--; } break; } // copy the input byte to the user-space buffer. cbuf = c; if(either_copyout(user_dst, dst, &cbuf, 1) == -1) // 将字符从内核传给用户 break; dst++; --n; if(c == ' '){ // a whole line has arrived, return to // the user-level read(). break; } } release(&cons.lock); return target - n; }
从consoleread可以看出,在还没有按键输入命令时,进程会进入阻塞;而有按键按下时,它会将我们输入的字符传递给用户程序去处理
以上就是控制台驱动的上半部分,没有数据就阻塞等待,一旦有数据就进行处理
Bottom half
在我们向Console输入字符时,发生了中断,RISC-V会做以下操作:
- 清除SIE寄存器相应的bit,这样可以阻止CPU核被其他中断打扰,该CPU核可以专心处理当前中断。处理完成之后,可以再次恢复SIE寄存器相应的bit。
- 会设置SEPC寄存器为当前的程序计数器。我们假设Shell正在用户空间运行,突然来了一个中断,那么当前Shell的程序计数器会被保存。
- 要保存当前的mode。在我们的例子里面,因为当前运行的是Shell程序,所以会记录user mode。
- 再将mode设置为Supervisor mode。
- 将程序计数器的值设置成STVEC的值。(注,STVEC用来保存trap处理程序的地址,详见lec06)
在XV6中,STVEC保存的要么是uservec或者kernelvec函数的地址,具体取决于发生中断时程序运行是在用户空间还是内核空间。在我们的例子中,Shell运行在用户空间,所以STVEC保存的是uservec函数的地址。而从之前的课程我们可以知道uservec函数会调用usertrap函数。所以最终,我们在usertrap函数中。
void usertrap(void) { ... else if ((which_dev = devintr()) != 0) { // ok } ... }
usertrap会调用devintr函数,这是中断的处理函数
// check if it's an external interrupt or software interrupt, // and handle it. // returns 2 if timer interrupt, // 1 if other device, // 0 if not recognized. int devintr() { uint64 scause = r_scause(); if ((scause & 0x8000000000000000L) && (scause & 0xff) == 9) { // this is a supervisor external interrupt, via PLIC. // irq indicates which device interrupted. int irq = plic_claim(); // 获取中断源 if (irq == UART0_IRQ) { uartintr(); } else if (irq == VIRTIO0_IRQ || irq == VIRTIO1_IRQ) { virtio_disk_intr(irq - VIRTIO0_IRQ); } else { // the PLIC sends each device interrupt to every core, // which generates a lot of interrupts with irq==0. } if (irq) plic_complete(irq); return 1; } else if (scause == 0x8000000000000001L) { // software interrupt from a machine-mode timer interrupt, // forwarded by timervec in kernelvec.S. if (cpuid() == 0) { clockintr(); } // acknowledge the software interrupt by clearing // the SSIP bit in sip. w_sip(r_sip() & ~2); return 2; } else { return 0; } }
键盘输入属于外部中断里的串口中断,所以会进入uartintr()处理函数
// trap.c calls here when the uart interrupts. void uartintr(void) { while(1){ int c = uartgetc(); // 从串口中读字符 if(c == -1) break; consoleintr(c); // 将字符交由控制台中断处理 } }
控制台中断处理函数consoleintr()将处理串口中得到的字符
// // the console input interrupt handler. // uartintr() calls this for input character. // do erase/kill processing, append to cons.buf, // wake up consoleread() if a whole line has arrived. // void consoleintr(int c) { acquire(&cons.lock); switch(c){ // ctrl+P/U/H case C('P'): // Print process list. procdump(); break; case C('U'): // Kill line. while(cons.e != cons.w && cons.buf[(cons.e-1) % INPUT_BUF] != ' '){ cons.e--; consputc(BACKSPACE); } break; case C('H'): // Backspace case 'x7f': if(cons.e != cons.w){ cons.e--; consputc(BACKSPACE); } break; // 普通命令 default: if(c != 0 && cons.e-cons.r < INPUT_BUF){ c = (c == ' ') ? ' ' : c; // echo back to the user. consputc(c); // 字符回显 // store for consumption by consoleread(). cons.buf[cons.e++ % INPUT_BUF] = c; // 往buffer存字符 if(c == ' ' || c == C('D') || cons.e == cons.r+INPUT_BUF){ // wake up consoleread() if a whole line (or end-of-file) // has arrived. cons.w = cons.e; // 修改写指针位置 wakeup(&cons.r); // 解除阻塞 } } break; } release(&cons.lock); }
这就是控制台获得键盘输入的原理。在有键盘字符输入时,它会触发中断,将我们输入的字符在控制台里回显,同时把字符存在buffer缓冲区里,直到回车才会解除shell进程的阻塞,进而处理输入的指令。
至此,控制台的输入输出原理分析结束。从中我们可以得到一个更重要的知识点:生产者-消费者设计模式。在Console input中体现最为明显:top half是控制台驱动的上半部,由内核线程执行,调用了read/write后等待数据,作为消费者;bottom half是控制台驱动的下半部,由中断执行,一有数据接收到就把数据存进buffer,解除top half阻塞,为top half提供数据,作为生产者。
这样的设计模式让cpu和设备能够有效地并行运行,不会因为两方的速度问题出问题,但并行也会出现一些其他问题。例如,Shell会在传输完提示符“$”之后再调用write系统调用传输空格字符,代码会走到UART驱动的top部分(注,uartputc函数),将空格写入到buffer中。但是同时在另一个CPU核,可能会收到来自于UART的中断,进而执行UART驱动的bottom部分,查看相同的buffer。所以一个驱动的top和bottom部分可以并行的在不同的CPU上运行。这里我们通过lock来管理并行。因为这里有共享的数据,我们想要buffer在一个时间只被一个CPU核所操作。