xv6(RISC-V)操作系统源码分析第五节——中断与设备驱动

一、驱动程序

驱动程序是操作系统中管理特定设备的代码,它的功能包括:

  1. 配置设备相关的硬件
  2. 控制设备的执行
  3. 处理设备产生的中断
  4. 与等待设备I/O的进程进行交互

驱动程序会与它所管理的设备并发执行。

驱动程序必须了解设备的硬件接口。

设备可以产生设备中断,在xv6中,内核中的devintr程序会处理这个中断。

许多设备驱动程序会在两个上下文(context)中执行代码。这两个上下文分别是:

  • 上半部分(top half):在进程的内核线程中运行。通过系统调用(如希望执行系统调用read和write)。这段代码可能会要求硬件开始一个操作,然后等待操作完成,而其中的这个等待过程过程,CPU就执行驱动程序的中断处理程序。
  • 下半部分(bottom half):在中断时执行,自然也在内核空间中执行。它会处理这个设备中断,在合适的时候,唤醒一个阻塞的进程,并告诉硬件执行下一个操作。

二、控制台的输入

(一)在真实物理硬件上的处理

控制台驱动程序是驱动结构的一个简单说明。控制台驱动程序通过连接到RISC-V上的UART串行端口硬件,接受输入的字符。控制台驱动程序每次累计一行输入,处理特殊的输入字符,如退格键和control-u。

而与驱动程序交互的UART硬件是16550芯片。16550将管理一个连接到终端或其他计算机的RS232串行链接。

(二)在QEMU模拟器上的处理

当用户在QEMU中向xv6输入时,用户的按键会通过QEMU的模拟UART硬件传递给xv6,具体来讲,就是QEMU有一个仿真16550芯片(该仿真芯片实现了模拟UART硬件),这个仿真芯片将连接到用户的键盘和显示器上。控制台驱动程序会获得这些输入,然后像shell这样的用户进程会使用read系统调用从控制台驱动程序中获得输入行。

(三)控制台输入的实现

1.UART控制寄存器

UART硬件在软件看来是一组内存映射的控制寄存器(即,部分RISC-V硬件的物理内存地址会关联到UART设备,所以加载与存储前面的物理内存地址交互的不是DRAM,而是硬件设备)

UART的物理内存映射地址从0x10000000(UART0)开始。

// qemu puts UART registers here in physical memory.
#define UART0 0x10000000L

每个UART控制寄存器的宽度是一个字节,它们与UART0的偏移量定义在下面:

// the UART control registers.
// some have different meanings for
// read vs write.
// see http://byterunner.com/16550.html
#define RHR 0                 // receive holding register (for input bytes)
#define THR 0                 // transmit holding register (for output bytes)
#define IER 1                 // interrupt enable register
#define IER_RX_ENABLE (1<<0)
#define IER_TX_ENABLE (1<<1)
#define FCR 2                 // FIFO control register
#define FCR_FIFO_ENABLE (1<<0)
#define FCR_FIFO_CLEAR (3<<1) // clear the content of the two FIFOs
#define ISR 2                 // interrupt status register
#define LCR 3                 // line control register
#define LCR_EIGHT_BITS (3<<0)
#define LCR_BAUD_LATCH (1<<7) // special mode to set baud rate
#define LSR 5                 // line status register
#define LSR_RX_READY (1<<0)   // input is waiting to be read from RHR
#define LSR_TX_IDLE (1<<5)    // THR can accept another character to send

举个例子,LSR寄存器中的某些位表示是否有输入字符在等待软件读取。若有,则这些字符从RHR寄存器中读取,每读取一个字符,UART硬件就会将其从內部等待字符队列中删除。当内部等待字符队列为空时,清除LSR中的就绪位。

UART的传输硬件独立于接收硬件,从上面的例子也可以看见这一点。

若软件向THR写入一个字节,UART就会发送该字节。

2.具体实现

main调用consoleinit来初始化UART控制寄存器。

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;
}

该函数首先初始化UART,当UART接收到一个字节的输入时,产生一个接收中断,当UART每完成发送一个字节的输出时,就产生一个传输完成中断。当用户键入一个字符时,UART硬件向RISC-V抛出一个中断,从而激活xv6的trap处理程序。trap处理程序调用devintr,查看scause寄存器,确定中断来自一个外部设备,然后它向PLIC硬件单元询问哪个设备发生中断,若是UART,devintr调用uartintr。

uartintr从UART硬件中读取在等待的输入字符(UART硬件中有一个內部等待字符队列),并将它们交给consoleintr。而consoleintr不会等待输入字符,它将输入字符缓存至cons.buf中,直到cons.buf满一行字符,当然consoleintr会特别处理退格键和其他字符。当一个新行到达时,consoleintr会唤醒一个等待的consoleread(若consoleread还有空闲的话)。

被唤醒的consoleread会注意到cons.buf中的完整的一行,并将其复制到用户空间,最后通过系统调用机制(read的系统调用)返回到用户空间。

 xv6的shell通过init程序打开的文件描述符从控制台读取。

  mknod("console", CONSOLE, 0);

shell会调用read系统调用去读取输入行。上面所说的read系统调用即指这个。如果用户没有输入完整的一行,然后调用了read的进程将在sleep中等待。对于shell表现为:光标闪烁,等待输入。

三、控制台的输出

shell向控制台写数据使用write系统调用。而write系统调用会到达uartputc,即uartputc会将每个字符追加到缓冲区,当缓冲区满时,uartputc会被阻塞等待。uartputc会调用uartstart来启动(马上就会介绍,uartputc只会调用uartstart发送缓冲区中的第一个字节)设备发送,启动成功后就会返回。所以,上面缓冲区这一机制的实现,使得写进程不需要等待UART硬件发送完成。

// add a character to the output buffer and tell the
// UART to start sending if it isn't already.
// blocks if the output buffer is full.
// because it may block, it can't be called
// from interrupts; it's only suitable for use
// by write().
void uartputc(int c)
{
  acquire(&uart_tx_lock);

  if(panicked){
    for(;;)
      ;
  }
  while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
    // buffer is full.
    // wait for uartstart() to open up space in the buffer.
    sleep(&uart_tx_r, &uart_tx_lock);
  }
  uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
  uart_tx_w += 1;
  uartstart();
  release(&uart_tx_lock);
}

每次UART发送完一个字节,它都会产生一个中断。而中断的到来,会使uartintr调用uartstart来发送一个字节。

总得来说,usrtputc会调用usrtstart发送缓冲区中的第一个字节,当UART发送完成后会产生一个中断,这个中断又使得uartintr调用uartstart来发送此时缓冲区的第一个字节。

I/O并发:

I/O并发使进程活动与设备活动解耦。因为只有解耦,才会使得进程的输出不被设备拖累,控制台的输入不会必须依赖于进程。该解耦提高了进程与I/O设备的并发程度,从而提高了性能。

在xv6中,I/O并发依靠中断与缓冲区来实现,就如上文所介绍的那样。

四、并发的驱动程序的安全性

并发的驱动程序存在安全问题,主要体现在下面两个方面:

  • 不同CPU上的两个进程可能会同时调用consoleread;硬件可能会在一个CPU正在执行consoleread时,向该CPU抛出一个控制台中断(UART的中断);硬件可能会在consoleread执行时向另一个CPU抛出一个控制台中断。这部分问题解决将在后面内容中讲到。
  • 一个进程正在等待来自设备的输入,但当输入到来的中断发生时,该进程被切换了。因此,中断处理程序不允许知道被中断的进程或代码。例如,一个中断处理程序不能安全地用当前进程的页表调用copyout。中断处理程序通常只做相对较少的工作。例如,中断处理程序只是将输入数据复制到缓冲区,并唤醒上半部分代码来做剩下的工作。

五、定时器中断

到目前为止,已经介绍了异常、系统调用与设备中断。接下来将介绍最特别的中断——定时器中断。特别体现在两个地方:

  • 定时器中断的概念与严格意义上的中断不同
  • 定时器中断的处理机制与陷阱的处理机制完全分离

定时器中断:计算机的定时器溢出而执行的中断,用于计时从而确定进程的执行时间,时间一到就进行进程调度。

严格意义上讲,定时器中断是一个伪中断,算不上真正的中断。因为真正的中断指意料之外的事,而定时器中断是事先规划好的。

xv6使用定时器中断来维护它的时钟,并使它能够切换计算密集型进程。usertrap和kerneltrap中的yield调用会导致这种切换。每个CPU都会抛出定时器中断,xv6会对时钟硬件进行编程,使其周期性地中断相应的CPU。

RISC-V要求只能在机器模式下处理定时器中断,而RISC-V机器在机器模式下运行时没有分页,并且有一套单独的控制寄存器,所以,在机器模式下运行普通的xv6内核代码不现实。这导致了定时器中断的处理机制与陷阱的处理机制完全分离。

在main执行之前的start程序是在机器模式下执行的,它设置了接收定时器中断。

// arrange to receive timer interrupts.
// they will arrive in machine mode at
// at timervec in kernelvec.S,
// which turns them into software interrupts for
// devintr() in trap.c.
void timerinit()
{
  // each CPU has a separate source of timer interrupts.
  int id = r_mhartid();

  // ask the CLINT for a timer interrupt.
  int interval = 1000000; // cycles; about 1/10th second in qemu.
  *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval;

  // prepare information in scratch[] for timervec.
  // scratch[0..2] : space for timervec to save registers.
  // scratch[3] : address of CLINT MTIMECMP register.
  // scratch[4] : desired interval (in cycles) between timer interrupts.
  uint64 *scratch = &timer_scratch[id][0];
  scratch[3] = CLINT_MTIMECMP(id);
  scratch[4] = interval;
  w_mscratch((uint64)scratch);

  // set the machine-mode trap handler.
  w_mtvec((uint64)timervec);

  // enable machine-mode interrupts.
  w_mstatus(r_mstatus() | MSTATUS_MIE);

  // enable machine-mode timer interrupts.
  w_mie(r_mie() | MIE_MTIE);
}

上面程序的主要作用如下:

  1. 对CLINT(core-local interruptor)进行编程,使其周期性产生一次中断
  2. 设置一个类似trapframe的暂存区,帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址
  3. 将mtvec(Machine Trap-Vector Base-Address Register,用于设置中断入口地址)设置为timervec(定时器中断向量),启动定时器中断

定时器中断可能发生在用户代码或内核代码执行的任何时候。这就引出了新的问题,如何确保在关键的内核代码执行时不被定时器中断打断。xv6的解决方案是定时器中断处理程序要求RISC-V硬件引发一个软件中断并立即返回。而RISC-V用普通的trap机制将软件中断传递给内核,并允许内核禁用它们。这部分代码在devintr中,如下:

 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;
  }
}

定时器中断处理程序没有C代码,完全由RISC-V汇编编写而成。其功能如下:

  • 在start准备的暂存区保存部分寄存器
  • 告诉CLINT何时产生下一个定时器中断
  • 使RISC-V产生一个软件中断
  • 恢复寄存器
  • 返回

总结:

本节重点讲述设备中断、设备驱动程序与定时器中断,至此,xv6的所有陷阱全部讲解完毕。

参考资料:

[1] FrankZn/xv6-riscv-book-Chinese (github.com)

[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com) 

  • 20
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值