1.CH-3文档学习笔记

第五章 中断和设备驱动

驱动程序:

  • 作用:配置硬件设备,告诉设备要执行的操作,处理设备产生的中断,与等待设备I/O的进程进行交互。
  • 难点:驱动程序需与设备并行运行,驱动程序必须理解设备的硬件接口,可能没有文档。

设备中断是trap的一种,内核通过处理代码识别设备,然后调用相应的驱动程序进行处理,这种调度发生在devintr(kernel/trap.c:177)

设备驱动程序分为两部分:

  • top运行在内核,bottom在中断时执行。

  • top通过系统调用(如read和write)执行,具体操作有:

    1. 请求设备执行I/O。

    2. 请求硬件执行一些操作(例如,读取一个磁盘数据块)。

  • 设备完成操作后发出中断信号。驱动程序的中断处理程序作为下半部分(bottom)继续执行:确定哪些操作已完成,唤醒等待中的进程。

5.1 代码:控制台输入

控制台驱动程序通过RISC-V的UART串口硬件UART(Universal Asynchronous Receiver/Transmitter):通用异步收/发器接收字符。

控制台驱动程序一次累积一行输入、处理特殊字符,如backspaceCtrl-u。用户进程(如Shell)使用read系统调用获取输入的行。

当通过键盘输入时,即通过QEMU模拟的UART硬件传递字符到xv6。

与驱动程序通信的UART硬件是QEMU模拟的16550芯片。在真正的计算机上,16550管理连接到终端的RS232串行链路。当运行QEMU时,它连接到键盘和显示器。

[!NOTE]

16550:是一款为实现串行通信接口而设计的芯片,管理连接到PC的设备

img

RS232:是一种串口接口标准

img

image-20220921022817320

在软件看来,UART硬件是一组内存映射的控制寄存器。

  • UART被映射到一些地址上,对这些地址进行读取就是与设备进行交互。

  • UART的内存映射起始地址为0x10000000(UART0(kernel/memlayout.h:21)),有一组UART控制寄存器,大小都为1字节,它们与UART0之间的偏移量定义在(kernel/uart.c:22)中。

    例如,LSR寄存器表示输入的字符是否正在等待软件读取。这些字符可从RHR寄存器读出。每次读取一个字符,UART硬件都会按照FIFO算法将其从等待字符寄存器中删除,并在FIFO为空时清除LSR中的"ready"位。

  • UART发送硬件在很大程度上独立于接收硬件;软件向THR写入一个字节,UART就会将该字节发送出去。

初始化UART

xv6在main.c\main()中调用consoleinit()-->uartinit()初始化UART硬件。使其每接收到一个字节生成一个接收中断、每发送完一个字节生成一个发送完成中断。

shell读取用户输入

当有数据到达时,会发生中断,函数走向usertrap()-->devintr()-->uartintr()

void uartintr(void){
  // read and process incoming characters.
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }
  
  。。。
}

通过uartgetc读取RHR寄存器,获取输入的字符,将字符通过consoleintr写入缓冲区

用户程序调用read时,会依次到达sys_read()->fileread()->consoleread()

consoleread每次处理一行,然后返回

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

  target = n;
  acquire(&cons.lock);
  while(n > 0){
    // 若uart缓冲区为空,就sleep在cons.r上
    while(cons.r == cons.w){
      if(killed(myproc())){
        release(&cons.lock);
        return -1;
      }
      sleep(&cons.r, &cons.lock);
    }

    // 否则读出新来的字符
    c = cons.buf[cons.r++ % INPUT_BUF_SIZE];

    // 如果c为EOF字符
    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;
    }

    // 复制字符到用户空间
    cbuf = c;
    if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
      break;

    dst++;
    --n;

    // 如果c是换行符,说明一行输入完成,停止读出
    if(c == '\n'){
      // a whole line has arrived, return to
      // the user-level read().
      break;
    }
  }
  release(&cons.lock);

  return target - n;
}

接收总结

  1. 当有数据来时,会发生中断,xv6会将输入字符读出,写入缓冲区
  2. 程序调用read时,会将字符从缓冲区读出,每次读出一行

5.2 代码:控制台输出

xv6初始化时,consoleinit()consolereadconsolewrite写入devsw数组中,

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

当调用write时,会依次走向sys_write--> filewrite --> ret = devsw[f->major].write(1, addr, n); -->consolewrite

int consolewrite(int user_src, uint64 src, int n){
  int i;

  for(i = 0; i < n; i++){
    char c;
    if(either_copyin(&c, user_src, src+i, 1) == -1)
      break;
    uartputc(c);
  }

  return i;
}

consolewrite每次复制一个字符到内核,然后调用uartputc

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_tx_buf,是环结构

如果缓冲区已满,则等待

若未满,将字符写入到缓冲区中,调用uartstart启动设备传输(如果还未启动),然后返回。

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_BUF_SIZE];
    uart_tx_r += 1;
    
    wakeup(&uart_tx_r);
    
    WriteReg(THR, c);
  }
}
  1. LSR: LINE STATUS REGISTER

    LSR BIT 5:
    0 = transmit holding register is full. 16550 will not accept any data for transmission.
    1 = transmitter hold register (or FIFO) is empty. CPU can load the next character.

  2. THR:TRANSMIT HOLDING REGISTER

    需将待发送字符写入此寄存器

每当UART发送完一个字节,就会产生一个中断。依次到达usertrap()-->devintr()-->uartintr()

void uartintr(void){
  。。。
  
  // send buffered characters.
  acquire(&uart_tx_lock);
  uartstart();
  release(&uart_tx_lock);
}

uartintr会调用uartstartuartstart会检查是否有待发送数据,若有就发送。

发送总结

调用write时,会将字符写入缓冲区,第一个字节将由uartputc调用uartstart发送,剩余字节将通过中断调用uartstart发送,直到传输完成。

5.3 驱动中的并发

您可能已经注意到 consolereadconsoleintr 中存在 acquire调用。这些调用获取一个锁,该锁保护console驱动程序的数据结构免受并发访问的影响。这里存在三个并发危险,这些危险可能导致竞争或死锁。

  • 不同 CPU 上的两个进程可能同时调用 consoleread;
  • 硬件可能在某个CPU正在执行 consoleread 时要求该 CPU 发送控制台(实际上是 UART)中断;
  • 硬件可能在 consoleread 执行时在不同的 CPU 上交付控制台中断。

在驱动程序中需要注意并发的另一种场景是,一个进程可能正在等待来自设备的输入,但是输入的中断信号在另一个进程(或者根本没有进程)正在运行时到达。因此中断处理程序不允许考虑已经中断的进程或代码。例如,中断处理程序不能安全地使用当前进程的页表调用copyout(注:因为你不知道是否发生了进程切换,当前进程可能并不是原先的进程)。中断处理程序通常做的工作相对较少(例如,只将输入数据复制到缓冲区),其余的工作需要唤醒上半部分代码完成。

5.4 定时器中断

Xv6使用定时器中断来维持其时钟,以使其能够在受计算量限制的进程(compute-bound processes)之间切换;usertrapkerneltrap中的yield调用会产生这种切换。定时器中断来自于连接到每个RISC-V CPU的时钟硬件。Xv6对该时钟硬件进行编程,周期性地中断每个CPU。

RISC-V要求定时器中断在机器模式而不是管理模式下进行。RISC-V在机器模式下运行时不分页,并且有一组单独的控制寄存器,因此在机器模式下运行普通的xv6内核代码是不切实际的。因此,xv6处理定时器中断与上文介绍的trap机制是完全分开的。

在机器模式下执行的代码位于main之前的start.c中,它设置了接收定时器中断(kernel/start.c:57)。工作的一部分是对CLINT硬件(core-local interruptor)进行编程,使其在一定的延迟后产生中断。另一部分是设置一个scratch区域,类似于trapframe,以帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址。最后,startmtvec设置为timervec,并启用定时器中断。

计时器中断可能发生在用户或内核代码执行的任何时候;内核无法在临界区操作期间禁用计时器中断。因此,计时器中断处理程序必须保证不干扰被中断的内核代码。基本策略是处理程序要求RISC-V发出一个“软件中断”就立即返回。RISC-V用普通trap机制将软件中断传递给内核,并允许内核禁用这些中断。处理由定时器中断产生的软件中断的代码可以在devintr(kernel/trap.c:204)中看到。

机器模式定时器中断向量(中断处理程序)是timervec(kernel/kernelvec.S:93)。它在start准备的scratch区域中保存一些寄存器,以告诉CLINT何时生成下一个定时器中断,要求RISC-V引发软件中断,恢复寄存器并返回。定时器中断处理程序中没有C代码。

5.5 真实世界

Xv6允许在内核中执行时以及在执行用户程序时触发设备和定时器中断。定时器中断迫使定时器中断处理程序进行线程切换(调用yield),即使在内核中执行时也是如此。如果内核线程有时花费大量时间计算而不返回用户空间,那么在内核线程之间公平地对CPU进行时间分割的能力非常有用。但在xv6中,内核代码需要注意内核可能会挂起(由于定时器中断),然后在另一个CPU上恢复,这是xv6中一些复杂性的来源。如果设备和计时器中断只在执行用户代码时发生,内核可以变得简单一些。

在一台典型的计算机上支持所有设备是一项艰巨的工作,因为设备太多,这些设备有许多特性,设备和驱动程序之间的协议可能很复杂,而且缺乏文档。在许多操作系统中,驱动程序比核心内核占用更多的代码。

UART驱动程序读取UART控制寄存器,一次一字节地检索数据;因为是软件驱动数据移动,因此这种模式被称为编程I/O(Programmed I/O)。编程I/O很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将输入数据写入内存,直接从内存读取输出数据。现代磁盘和网络设备都使用DMA。DMA设备的驱动程序在RAM中准备数据,然后使用控制寄存器告诉设备去处理准备好的数据。

当一个设备在不可预知的时间需要注意时,中断是有意义的。但是中断有很高的CPU开销。因此,一些高速设备,如网络和磁盘控制器,使用一些技巧来减少对中断的需求。一个技巧是对整批传入或传出请求使用单个中断。另一个技巧是驱动程序完全禁用中断,定期检查设备是否需要处理。这种技术被称为轮询(polling)。如果设备执行的非常快,轮询是有意义的,但如果设备大部分都处于空闲状态,轮询就会浪费CPU时间。一些驱动程序会根据当前设备负载在轮询和中断之间动态切换。

UART驱动程序首先将传入的数据复制到内核中的缓冲区,然后复制到用户空间。这在低数据速率下是可行的,但对于生成或消费数据非常快的设备来说,这种双重拷贝会显著降低性能。一些操作系统会使用DMA直接在用户空间缓冲区和设备硬件之间移动数据。

如第1章所述,控制台对应用程序来说就像一个普通文件,应用程序使用read和write系统调用处理输入输出。应用程序可能希望控制无法通过标准文件系统调用表示的设备(例如,在控制台驱动程序中启用/禁用行缓冲)。Unix通过ioctl系统调用来处理这种情况。

计算机的某些使用要求系统必须在有限的时间内作出响应。例如,在安全关键系统中,错过最后期限可能会导致灾难。Xv6不适合硬实时设置。支持硬实时的操作系统往往是与应用程序链接的库,通过这种方式可以分析确定最坏情况下的响应时间。Xv6也不适用于软实时应用程序,因为偶尔错过截止日期是可以接受的,因为Xv6的调度器过于简单,而且它的内核代码会停用很长一段时间的中断。

posted @ 2024-04-21 00:11  INnoVation-V2  阅读(3)  评论(0编辑  收藏  举报