Loading

xv6 book risc-v 第四章第五章 Trap相关

Trap和系统调用

中断和设备驱动

驱动是操作系统用于管理特定设备的代码:它配置设备硬件,通知设备执行操作,处理返回的中断,并且与可能在该设备上进行I/O等待的进程交互。编写驱动代码可能很棘手,因为驱动与它管理的设备是并行执行的,此外,驱动必须理解设备硬件接口,这可能是复杂的并且缺乏文档。

需要被系统注意的设备通常会被配置以生成中断(中断是一种trap),当一个设备发起了一个中断,内核的trap处理代码识别它并调用设备的中断处理器。在xv6中,这个调度发生在devintr(kernel/trap.c:177)

void 
kerneltrap()
{
  int which_dev = 0;
  uint64 sepc = r_sepc();
  uint64 sstatus = r_sstatus();
  uint64 scause = r_scause();
  

  // 注意这里!!!!!
  if((which_dev = devintr()) == 0){
    printf("scause %p\n", scause);
    printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
    panic("kerneltrap");
  }

  // ...
}

很多设备驱动都在两个上下文中执行代码:上半部分运行在进程的内核线程中,下半部分在中断时执行。上半部分通过如readwrite的系统调用被使用,表明我们想要设备执行I/O。这个代码可能请求硬件取执行一个操作(比如请求磁盘去读或写),然后,代码等待操作完成,最终,设备完成了操作,发起一个中断,此时,下半部分,也就是设备的中断处理器,认出是什么操作完成了,如果合适的话,唤醒一个等待的进程,并告诉硬件开始任何在等待中的后续操作。

5.1. 代码:Console输入

Console驱动(console.c)是一个很简单的驱动结构,console驱动通过RISC-V上的UART串口硬件接收人类输入的字符,驱动一次性积累一行输入,处理特殊输入字符(如backspace和control-u),像shell这样的用户进程,使用read系统调用去从console中获取输入行。当你向在QEMU中的xv6输入时,你的击键通过QEMU模拟的UART硬件传送到xv6中。

驱动对面的UART硬件是一个QEMU模拟的16550芯片,在一个真实的计算机上,一个16550可能管理连接到一个终端或其它计算机的RS232串行链路。当我们运行QEMU时,它连接到你的键盘和显示器。

在软件看来,UART硬件只是一组内存映射的控制寄存器,也就是说,有一些RISC-V硬件连接到UART设备上的物理地址,所以load和store操作实际上是在与设备交互,而不是与RAM交互。UART的内存映射地址起始于0x10000000,或者说UART0(kernel/memlayout.h:21)。有一些实用的UART控制寄存器,每一个都是一字节宽,它们与UART0的偏移量被定义在(kernel/uart.c:22)。举个例子,LSR寄存器包含了用于表示是否输入字符正在等待被软件读取的位。这些字符(如果有)可以通过读取RHR寄存器获得。每次一个字符被读取了,UART硬件将它从内部的等待字符FIFO队列中删除,当FIFO是空的时,清除LSR中的“ready”位。UART的传输硬件与接收硬件很大程度上是独立的,如果软件写入一个字节到THR中,UART传输这个字节。

xv6的main调用consoleinit(kernel/console.c:184)以初始化UART硬件,这个代码配置UART当它接收到每一个字节的输入时,生成一个接收中断,以及每次UART完成发送一个输出字节时,生成一个传输完成中断(kernel/uart.c:53)。

void
consoleinit(void)
{
  initlock(&cons.lock, "cons");

  uartinit(); // 设置uart芯片

  // 将console设备上的read和write调用连接到consoleread和consolewrite函数
  // 这就是上面所说的驱动程序的上半部分,包含了两个读写接口,当read和write系统调用
  // 发生时,如果fd是一个硬件设备,则会调用这两个读写接口,稍后我们会看到
  devsw[CONSOLE].read = consoleread;
  devsw[CONSOLE].write = consolewrite;
}
void
uartinit(void)
{
  // 先关闭中断
  WriteReg(IER, 0x00);

  // 打开设置波特率的特殊模式
  WriteReg(LCR, LCR_BAUD_LATCH);
  // 设置LSB波特率
  WriteReg(0, 0x03);
  // 设置MSB波特率
  WriteReg(1, 0x00);

  // 跳出设置波特率模式,设置字长为8比特
  WriteReg(LCR, LCR_EIGHT_BITS);

  // 重置、开启FIFO
  WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);

  // 开启传送(TX)和接收(RX)中断
  WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);

  initlock(&uart_tx_lock, "uart");
}

xv6的shell通过一个由init.c打开的文件描述符来读取console(user/init.c:19),read系统调用通过内核的consoleread(kernel/console.c:82)来完成工作,consoleread等待输入到达(通过中断)并被缓冲在cons.buf中,将输入复制到用户空间,并且(在一整行到达后)返回到用户进程。如果用户尚未输入整行,任何读取进程都将在sleep调用上等待(kernel/console.c:98)(第七章会解释sleep的细节)。

int
main(void)
{
  // 用户进程初始化时创建console设备
  // 现在有了三个文件描述符 stdin=>0 stdout=>1 stderr=>2
  int pid, wpid;

  if(open("console", O_RDWR) < 0){
    mknod("console", CONSOLE, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  // ...

}
// sys_read调用的函数,在kernel/file.c:106
int
fileread(struct file *f, uint64 addr, int n)
{
  int r = 0;

  if(f->readable == 0)
    return -1;

  // 如果文件类型是管道
  if(f->type == FD_PIPE){
    r = piperead(f->pipe, addr, n);
  // 如果文件类型是设备
  } else if(f->type == FD_DEVICE){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
      return -1;
    // 调用对应设备驱动程序的read函数,也就是我们在console.c中设置的consoleread
    r = devsw[f->major].read(1, addr, n);
  // 如果文件类型是inode
  } else if(f->type == FD_INODE){
  // ...

}
int
consoleread(int user_dst, uint64 dst, int n)
{
  uint target;
  int c;
  char cbuf;

  target = n;
  acquire(&cons.lock);
  
  // n代表要读的数量,如果还有要读的数据
  while(n > 0){
    // 等待中断处理器添加一些输入到cons.buffer
    // cons是一个环形队列,cons.r是读指针,cons.w是写指针,cons.r==cons.w代表队列中没数据
    while(cons.r == cons.w){
      if(myproc()->killed){
        release(&cons.lock);
        return -1;
      }
      // 没有数据就在cons.r上等待
      sleep(&cons.r, &cons.lock);
    }

    // 有数据了,读取字符c,并向前推进读指针
    c = cons.buf[cons.r++ % INPUT_BUF];

    // 如果读到的字符是eof (Control-D)
    if(c == C('D')){
      if(n < target){ // n < target代表已经读取了一些数据,但还没到用户要求的数量
        // 回退读指针,确保下次用户调用时能获得一个0字节的结果(我理解是确保用户下次调用时能看见已经eof了)
        cons.r--;
      }
      // 跳出循环
      break;
    }

    // 将输入字节拷贝到用户空间的缓冲区中
    cbuf = c;
    if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
      break;

    // 更新必要的变量
    dst++;
    --n;

    // 一行数据收集完了,返回到用户层面的read函数
    if(c == '\n'){
      break;
    }
  }
  release(&cons.lock);

  // 返回本次读取的字符数,target - n
  return target - n;
}

译者:嘶,对读取的理解有点加深了,之前在写rust网络编程的时候一直搞不懂为什么读取返回的长度是0时代表已经读到EOF,因为如果没到EOF并且当前数据没有到达时(比如网络连接没断开,只是包还没接收到)时,read的行为是阻塞直到数据到达,而只有在当前已经无法继续读取,上次读取返回时已经触碰文件尾部时才会立即返回一个长度0(没读取时target-n为0)。

当用户输入一个字符,UART通知RISC-V发起一个中断,激活xv6的陷阱处理程序。陷阱处理程序调用devintr(kernel/trap.c:177),它查看RISC-V的scause寄存器发现中断是来自外部设备的,然后它要求一个被称作PLIC的硬件单元来告诉它哪一个设备中断了(kernel/trap.c:186),如果是UART,devintr就会调用uartintr

void
usertrap(void)
{
  int which_dev = 0;

  // ...
  // 调用devintr,判断当前trap是否是设备中断,如果是的话,返回中断设备号
  } else if((which_dev = devintr()) != 0){
    // ok
  }
  // ...
int
devintr()
{
  // 读取scause确定trap是来自外部设备的中断
  uint64 scause = r_scause();

  if((scause & 0x8000000000000000L) &&
     (scause & 0xff) == 9){

    // irq代表哪一个设备的中断
    int irq = plic_claim();

    // 如果是UART0_IRQ,调用uartintr
    if(irq == UART0_IRQ){
      uartintr();
    // 如果是VIRTIO0_IRQ,调用virtio_disk_intr
    } else if(irq == VIRTIO0_IRQ){
      virtio_disk_intr();
    } else if(irq){
      printf("unexpected interrupt irq=%d\n", irq);
    }

  // ...
}
void
uartintr(void)
{
  // 不断读取并处理进来的字符
  while(1){
    // 从uart芯片中读取字符
    int c = uartgetc();
    if(c == -1)// 如果没有字符可读了
      break;
    // 将字符交由consoleintr处理
    consoleintr(c);
  }

  // send buffered characters.
  acquire(&uart_tx_lock);
  uartstart();
  release(&uart_tx_lock);
}
int
uartgetc(void)
{
  // 如果LSR寄存器的值表明有数据等待读取
  if(ReadReg(LSR) & 0x01){
    // 从RHR中读取字符
    return ReadReg(RHR);
  } else {
    // 当无字符可读取,返回-1
    return -1;
  }
}

uartintr(kernel/uart.c:180)从UART硬件中读取任何等待中的输入字符,并且将它们移交给consoleintr(kernel/console.c:138)。它不等待字符,因为未来的输入会发起一个新的中断,consoleintr的工作就是在cons.buf中积累字符直到完整的一行到达。consoleintr会特殊对待少量字符,比如backspace。当一个新行到达,consoleintr唤醒等待中的consoleread(如果有的话)。

void
consoleintr(int c)
{
  acquire(&cons.lock);

  switch(c){ // 处理特殊字符
  case C('P'):  // Ctrl-P,打印进程列表
    procdump();
    break;
  case C('U'):  // Ctrl-U,清除该行?
    while(cons.e != cons.w &&
          cons.buf[(cons.e-1) % INPUT_BUF] != '\n'){
      cons.e--;
      consputc(BACKSPACE);
    }
    break;
  case C('H'): // Backspace
  case '\x7f':
    if(cons.e != cons.w){
      cons.e--;
      consputc(BACKSPACE);
    }
    break;
  // 正常情况,处理该字符,回显并保存到cons.buf,检测是否已经累积了整行
  default:
    if(c != 0 && cons.e-cons.r < INPUT_BUF){
      c = (c == '\r') ? '\n' : c;

      // 通过consputc回显给用户
      consputc(c);

      // 保存到cons.buf
      cons.buf[cons.e++ % INPUT_BUF] = c;

      // 如果一行到达,唤醒在cons.r上等待的consoleread
      if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
        cons.w = cons.e;
        wakeup(&cons.r);
      }
    }
    break;
  }
  
  release(&cons.lock);
}

一旦被唤醒,consoleread将会观察cons.buf中的整行,将它拷贝到用户空间,然后(通过系统调用机制)返回到用户空间。

5.2. 代码:Console output

在一个连接到console的文件描述符上的write系统调用最终到达uartputc(kernel/uart.c:87)。设备驱动维护一个输出缓冲区(uart_tx_buf),这样的话执行写入的进程就不需要等待UART完成发送了,uartputc只需要将每一个字符追加到缓冲区中,然后调用uartstart以开启设备传送(如果尚未准备好的话),并直接返回。uartputc会阻塞的唯一情况就是缓冲区已经满了。

// kernel/console.c:58 console驱动的write函数 consolewrite
// 对于每一个用户输入字符,它都拷贝,并通过`uartputc`发给uart驱动
int
consolewrite(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;
    uartputc(c);
  }
  release(&cons.lock);

  return i;
}
// kernel/uart.c:86 uartputc
void
uartputc(int c)
{
  acquire(&uart_tx_lock);

  if(panicked){
    for(;;)
      ;
  }

  while(1){
    // 如果uart_tx_buf已满
    if(((uart_tx_w + 1) % UART_TX_BUF_SIZE) == uart_tx_r){
      // 等待uartstart()为我们清理出一些缓冲区空间
      sleep(&uart_tx_r, &uart_tx_lock);
    } else {
      // 追加到缓冲区
      uart_tx_buf[uart_tx_w] = c;
      // 修改写指针
      uart_tx_w = (uart_tx_w + 1) % UART_TX_BUF_SIZE;
      // 调用uartstart开始将缓冲区中的数据传送到UART设备
      uartstart();
      release(&uart_tx_lock);
      return;
    }
  }
}
// kernel/uart.c:137
void
uartstart()
{
  while(1){ // 如果缓冲区为空
    if(uart_tx_w == uart_tx_r){
      return;
    }
    
    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // UART传送寄存器已满,我们不能给它更多字节
      // 当它准备好接收字节时,它会给我们一个中断
      return;
    }
    
    // 获取缓冲区中的一个字节
    int c = uart_tx_buf[uart_tx_r];
    // 更新读指针
    uart_tx_r = (uart_tx_r + 1) % UART_TX_BUF_SIZE;
    
    // 唤醒在uartputc上等待缓冲区空间的进程
    wakeup(&uart_tx_r);
    
    // 写入UART
    WriteReg(THR, c);
  }
}

每次UART发完一个字节,它都会生成一个中断。uartintr调用uartstart来检查设备是否真的已经完成了发送,并且将下一个被缓冲的输出字符交给设备。因此,如果一个进程向console写入多个字节,通常第一个字节会被uartputcuartstart调用发送,其它缓冲的字节会在传送完成中断到达时被uartintr中的uartstart调用传送。

// 处理一个uart中断,中断产生的原因是输入已经到达或uart已经准备好更多的输出
// 或者两者都存在。由trap.c调用
void
uartintr(void)
{
  // 读取输入字符并移交给consoleintr
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }

  // 发送缓冲区中的字符
  acquire(&uart_tx_lock);
  uartstart();
  release(&uart_tx_lock);
}

译者:现在理解了为啥uartintr中实际上在处理两个事情了,因为中断的来源就是两个,一个是UART芯片接收到新的输入了,此时我们应该将输入发送给console设备,一个是UART芯片已经完成传送,此时我们应该继续发送缓冲区中的字符。

我们需要注意到这种通过缓冲和中断来将设备活动与进程活动解耦的通用模式。即使在没有进程在等待读取的情况下,console驱动仍然能够处理输入,后续的读取将会看到这个输入。类似的,进程可以发送输出并且无需等待设备。这种解耦可以提升性能,因为进程和设备I/O被允许并发执行,并且这在设备很缓慢(比如UART)时或者需要快速响应时(比如要echo输入的字符)。这个思想有时被称为I/O并发

该段原文:This decoupling can increase performance by allowing processes to execute concurrently with device I/O, and is particularly important when the device is slow (as with the UART) or needs immediate attention (as with echoing typed characters). This idea is sometimes called I/O concurrency.

驱动中的并发

你已经注意到了在consolereadconsoleintr中的acquire调用。这些调用获取一个锁以在并发的访问中保护console驱动的数据结构。这里有三种并发风险:在不同CPU上的两个进程可能在同一时间调用consoleread;在CPU正在consoleread中执行时,硬件可能向CPU传送一个console(实际上是UART)中断;在consoleread在执行时,硬件可能向其它CPU上传送一个中断。第六章中我们将探索lock是如何在这些场景中给我们帮忙的。

在驱动中另一个需要注意的并发问题是,一个进程可能在等待一个设备的输入,但是该输入的中断信号可能在另一个进程(或者没有任何进程)在运行时到达,因此中断处理程序不能想当然的对它们要中断的进程或代码做任何假设。举个例子,一个中断处理器通过当前进程的页表调用copyout是不安全的。中断处理器通常只做相对较少的工作(只是复制输入数据到buffer)并唤醒顶层代码去做更多。

时钟中断

xv6使用时钟中断来维护它的时钟,并使它能在计算绑定的进程间进行切换。在usertrapkerneltrap中的yield调用引发这种切换。定时器中断来自每一个RISC-V CPU上附带的时钟硬件,xv6编程这个时钟硬件以周期性的中断每个CPU。

RISC-V需要时钟中断在machine mode下被处理,而非supervisor mode。RISC-V的machine mode不再分页下执行,并且有一系列独立的控制寄存器,所以在机器模式下运行xv6内核代码是不可能的。所以,xv6处理时钟中断的方式完全不同于上面所说的trap机制。

start.c中的,执行在machine mode的代码,在main函数之前,设置接收时钟中断(kernel/start.c:57)。一部分工作是编程CLINT硬件(核心本地中断器)以在特定的延时后生成中断,另一部分工作就是建立一个scratch区域,模拟trapframe,以帮助定时器中断处理器保存寄存器以及CLINT寄存器中断地址。最终,start设置mtvectimervec并且启动时钟中断。

一个时钟中断可能在用户或内核代码执行的任意时间点发生,(即使)内核在执行关键操作时也没有任何办法关闭时钟中断。因此时钟中断处理器必须以一种能够保证不扰乱被中断的内核代码的方式下工作,处理程序的基本策略是请求RISC-V发起一个“软件中断”并立即返回。RISC-V通过常规的trap机制将软件中断传送到内核,并允许内核金庸它们。处理由定时器中断生成的软件中断的代码可以在devintr中看到(kernel/trap.c:204)。

machine-mode的定时器中断向量是timervec(kernel/kernelvec.S:93)。它保存了少量寄存器到由start准备的scratch区域,告诉CLINT何时生成下一个时钟中断,告诉RISC-V发起一个软件中断,回复寄存器并返回。在时钟中断处理器中没有C代码。

5.5. 真实世界

xv6允许在内核执行时的设备和时钟中断,在用户程序执行时也一样。时钟中断强制从时钟中断处理程序进行一次线程切换(一个yield调用),即使是在内核中执行。如果内核线程有时会花费大量时间进行运算,不返回到用户空间,那么在内核线程间进行公平的CPU时间分片的能力就十分有用。然而,这样的话内核代码就需要时刻注意它随时有可能被暂停(由于时钟中断)并且稍后可能会在不同的CPU上被恢复,这是xv6代码中一些复杂性的根源。如果时钟中断只会在执行用户代码时发生,那么内核就可能会更加简单。

在一个典型的电脑上支持所有的设备是很难的工作,因为设备太多了,并且都有很多特性,在设备和驱动程序之间的协议可能是很复杂的并且没有什么文档。在很多操作系统中,驱动占用了比内核更多的代码。

UART驱动通过读取UART控制寄存器一次获取一个字节,这个模式被称作可编程I/O,因为是软件在驱动数据的移动。可编程I/O是简单的,但是在高速率的数据传输下显得太慢了。需要快速移动大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将到达的数据写入到RAM中,从RAM中读取要送出的数据。现代的磁盘和网络设备都是用DMA。一个DMA设备驱动会在RAM中准备数据,然后对一个控制寄存器进行单次写入来告诉设备处理准备的数据。

在设备需要在不可预测的时间被关注,并且这种事情还不是常事时,中断很有意义,但是中断操作的CPU开销较高。因此告诉设备,比如网络和磁盘控制器,使用一些技巧来减少中断的需要。一个技巧是将一整批到达或送出请求封装到一个中断中,另一个技巧是驱动完全禁用中断,然后周期性的检查设备以查看是否需要被专注。这种技术被称作polling。polling在设备频繁的执行操作时是有意义的,但是当设备大部分时间都是空闲的时,它会浪费CPU时间。一些驱动会根据当前设备负载在polling和中断之间动态切换。

UART驱动首先复制到达数据到内核中的一个缓冲区里,然后再复制到用户空间。这在低数据速率时还可以接收,但是这种双重复制在频繁生成或消费数据的设备上会显著降低性能。一些操作系统可以直接在用户空间缓冲区和设备硬件之间移动数据,通常是通过DMA实现。

5.6. 练习

  1. 修改uart.c以完全不使用中断,你也需要修改console.c
  2. 添加一个以太网卡的驱动
posted @ 2023-03-22 16:17  yudoge  阅读(279)  评论(0编辑  收藏  举报