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

发布时间 2023-03-22 21:16:39作者: yudoge

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中的整行,将它拷贝到用户空间,然后(通过系统调用机制)返回到用户空间。