Interrupts


真实操作系统内存使用情况

运行top指令,在Mem这一行,大部分被用掉的内存,都是被buff/cache用了。

大部分操作系统运行时几乎没有任何空闲的内存。因此很多时候要分配内存都要先撤回一些内存。

VIRT表示的是虚拟内存地址空间的大小,RES是实际使用的内存数量。实际使用的内存数量远小于地址空间的大小。之前介绍的基于虚拟内存和page fault提供的功能在这都有所使用。


Interrupt硬件部分

中断对应的场景是:硬件需要得到操作系统的关注。操作系统会保存当前的工作,处理中断,再恢复先前的工作。与系统调用、page fault的过程非常相似,因此使用相同的机制。

中断和系统调用的差别主要有三点:

  • asynchronous:当硬件生成中断时,Interrupt handler 与当前运行的进程在 CPU 上没有任何关联;但系统调用发生在运行进程的 context 下。

  • concurrency:对中断来说,CPU 和生成中断的设备是并行运行的。

  • program device:每个设备都需要被编程,如网卡、UART。

我们在这里主要关注外部设备的中断,而非定时器中断或软件中断。主板上,各种线路将外设与 CPU 连接在一起,处理器通过PLIC(Platform Level Interrupt Control)来处理设备中断。

共有53个不同的来自设备的中断。中断到达 PLIC 后,PLIC 会路由这些中断,图的右下角是 CPU 的核,PLIC 会将中断路由到某一个 CPU 的核。如果所有 CPU 核都在处理中断,PLIC会保留中断直到有一个 CPU 核可以用来处理中断。因此 PLIC 需要保存一些数据来跟踪中断状态。


设备驱动概述

管理设备的代码称为驱动,所有驱动都在内核中。大部分驱动的代码都分为两个部分,bottom/top。这里以 UART 设备的驱动为例。

bottom部分通常是Interrupt handler,当中断送到 CPU 并被接收,CPU 会调用相应的Interrupt handler,它并不运行在任何特定进程的context中,它只处理中断。但也因此它存在一些限制,因为进程的page table并不知道应该从哪个地址读写数据,也就无法直接从Interrtupt handler读写数据。

top部分是用户进程或内核其他部分调用的接口。对 UART 是read/write接口。这部分通常与用户的进程交互并进行数据读写。

通常,驱动中会有一些队列,或者说 buffer。topbottom部分的代码都会从队列中读写数据。这里的队列可以将并行运行的设备和 CPU 解耦开。

对设备的编程通常是通过memory mapped I/O完成的。设备地址出现在物理地址的特定区间,这由主板制造商决定。操作系统知道这些设备位于物理地址空间的具体位置,然后通过load/store指令对这些地址进行编程,实际就是读写设备的控制寄存器来操作设备实现相应的行为。

16550 是 QEMU 模拟的 UART 设备,QEMU 用这个模拟的设备与键盘和 Console 进行交互。图中表明了芯片拥有的寄存器。例如控制寄存器000,写它可以将数据写入寄存器中,读它可以读出寄存器中的内容。UART 可以通过串口发送数据 bit,线路另一侧的 UART 芯片可以将数据 bit 重新组合成字节。以及控制寄存器001,可以通过它控制 UART是否产生中断。

当通过load将数据写入Transmit Holding Register,UART 芯片会通过串口线将这个 Byte 送出,完成发送后 UART 会生成一个中断给内核,这时才能写入下一个数据。上图的 UART 芯片有一个容量16的 FIFO。


xv6中设置中断

我们讨论$ls

xv6中,Shell会输出提示符$。实际过程是:设备会将字符传输给 UART 的寄存器,UART 在发送完字符后产生一个中断,在 QEMU 中,模拟的线路的另一端会有另一个模拟的 UART 芯片,这个芯片连接到虚拟的 Console,它会将$显示在 console 上。

ls是用户输入的字符。键盘连接到 UART 的输入线路,当键盘上一个按键被按下,UART 芯片会将按键字符通过串口线发送到另一端的 UART 芯片。它会先将数据 bit 合并成一个 Byte,然后再产生一个中断,并告诉处理器有一个来自键盘的字符,之后Interrupt handler会处理来自 UART 的字符。

RISC-V 有许多与中断有关的寄存器:

  • SIE寄存器:这个寄存器中有一个 bit (E) 专门针对外部设备的中断;另有一个 bit (S) 专门针对软件中断和一个 bit (T) 针对定时器中断。软件中断可能由一个 CPU核触发给另一个 CPU 核。

  • SSTATUS寄存器:这个寄存器中有一个 bit 来打开或关闭中断。

每个CPU核都有独立的 SIE 和 SSTATUS 寄存器。SIE 单独控制特定的中断,SSTATUS 控制所有的中断。

  • SIP寄存器:处理器通过这个寄存器查看是什么类型的中断。

  • SCAUSE寄存器:表明当前状态的原因是中断。

  • STVEC寄存器:保存当trappage fault或中断发生时,CPU 运行的用户程序的程序计数器。

start函数先将所有的中断都设置在Supervisor mode,然后设置 SIE 寄存器来接收外部设备、软件和定时器中断,之后初始化定时器。

void
start()
{
  // set M Previous Privilege mode to Supervisor, for mret.
  unsigned long x = r_mstatus();
  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;
  w_mstatus(x);

  // set M Exception Program Counter to main, for mret.
  // requires gcc -mcmodel=medany
  w_mepc((uint64)main);

  // disable paging for now.
  w_satp(0);

  // delegate all interrupts and exceptions to supervisor mode.
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  // configure Physical Memory Protection to give supervisor mode
  // access to all of physical memory.
  w_pmpaddr0(0x3fffffffffffffull);
  w_pmpcfg0(0xf);

  // ask for clock interrupts.
  timerinit();

  // keep each CPU's hartid in its tp register, for cpuid().
  int id = r_mhartid();
  w_tp(id);

  // switch to supervisor mode and jump to main().
  asm volatile("mret");
}

main函数中,console是第一个外设。

void
main()
{
  if(cpuid() == 0){
    consoleinit();
    ...
    plicinit();      // set up interrupt controller
    plicinithart();  // ask PLIC for device interrupts
    ...
  }
  scheduler();
}

consoleinit中,先初始化锁,然后调用uartinit,这个函数会配置好 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;
}

uartinit的流程是先关闭中断,然后设置波特率(串口线的传输速率),设置字符长度为8 bit,并重置 FIFO,最后打开中断。

void
uartinit(void)
{
  // disable interrupts.
  WriteReg(IER, 0x00);

  // special mode to set baud rate.
  WriteReg(LCR, LCR_BAUD_LATCH);

  // LSB for baud rate of 38.4K.
  WriteReg(0, 0x03);

  // MSB for baud rate of 38.4K.
  WriteReg(1, 0x00);

  // leave set-baud mode,
  // and set word length to 8 bits, no parity.
  WriteReg(LCR, LCR_EIGHT_BITS);

  // reset and enable FIFOs.
  WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);

  // enable transmit and receive interrupts.
  WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);

  initlock(&uart_tx_lock, "uart");
}

运行完uartinit后,UART 就可以生成中断了。但还没有对 PLIC 编程,因此中断不能被 CPU 感知。在main函数中还要调用plicinit函数。

void
plicinit(void)
{
  // set desired IRQ priorities non-zero (otherwise disabled).
  *(uint32*)(PLIC + UART0_IRQ*4) = 1;
  *(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}

PLIC 与外设一样,也占用了一个 I/O 地址。这里设置了 PLIC 会接收哪些中断,进而将中断路由到 CPU。在上面设置了 UART 和 IO磁盘的中断。

void
plicinithart(void)
{
  int hart = cpuid();

  // set enable bits for this hart's S-mode
  // for the uart and virtio disk.
  *(uint32*)PLIC_SENABLE(hart) = (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);

  // set this hart's S-mode priority threshold to 0.
  *(uint32*)PLIC_SPRIORITY(hart) = 0;
}

plicinit由0号 CPU 运行。之后,每个 CPU 的核都需要调用plicinithart函数表明可以处理哪些外设中断。上面每个 CPU 核都可以处理来自 UART 和 VIRTIO 的中断,中断的优先级被设置为0。

目前,生成中断的外部设备和传递中断给 CPU 的 PLIC 都设置好了,但 CPU 还没有设置好接收中断,需要设置 SSTATUS 寄存器。在main的最后调用了scheduler函数。

void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();

  c->proc = 0;
  for(;;){
    // The most recent process to run may have had interrupts
    // turned off; enable them to avoid a deadlock if all
    // processes are waiting.
    intr_on();

    int found = 0;
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if(p->state == RUNNABLE) {
        // Switch to chosen process.  It is the process's job
        // to release its lock and then reacquire it
        // before jumping back to us.
        p->state = RUNNING;
        c->proc = p;
        swtch(&c->context, &p->context);

        // Process is done running for now.
        // It should have changed its p->state before coming back.
        c->proc = 0;
        found = 1;
      }
      release(&p->lock);
    }
    if(found == 0) {
      // nothing to run; stop running on this core until an interrupt.
      intr_on();
      asm volatile("wfi");
    }
  }
}

scheduler函数主要是运行进程。但在实际运行进程前会执行intr_on函数设置 SSTATUS 寄存器来使 CPU 能接收中断。

static inline void
intr_on()
{
  w_sstatus(r_sstatus() | SSTATUS_SIE);
}

以上就是中断的基本配置。


UART驱动的top部分

接下来介绍如何从 Shell 程序输出提示符$到 Console。首先是init.c中的main函数,这是系统启动后运行的第一个进程。

int
main(void)
{
  int pid, wpid;

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

  for(;;){
    printf("init: starting sh\n");
    pid = fork();
    if(pid < 0){
      printf("init: fork failed\n");
      exit(1);
    }
    if(pid == 0){
      exec("sh", argv);
      printf("init: exec sh failed\n");
      exit(1);
    }

    for(;;){
      // this call to wait() returns if the shell exits,
      // or if a parentless process exits.
      wpid = wait((int *) 0);
      if(wpid == pid){
        // the shell exited; restart it.
        break;
      } else if(wpid < 0){
        printf("init: wait returned an error\n");
        exit(1);
      } else {
        // it was a parentless process; do nothing.
      }
    }
  }
}

main首先尝试以读写模式打开console设备,如果失败,调用mknod来创建设备节点;然后用open打开确保获取到文件描述符,这里是第一个打开的文件,所以是文件描述符0;再调用dup创建 stdout 和 stderr,这里实际上通过复制文件描述符0,分别得到1、2,这样就绑定了标准输入、输出和错误。

dup复制给定的文件描述符并返回新的文件描述符,这两个描述符指向同一个文件表项。

Shell 首先打开文件描述符0、1、2,然后向文件描述符2打印提示符$

int
getcmd(char *buf, int nbuf)
{
  write(2, "$ ", 2);
  memset(buf, 0, nbuf);
  gets(buf, nbuf);
  if(buf[0] == 0) // EOF
    return -1;
  return 0;
}

这里代码是直接调用了write系统调用。也可通过fprintf(2, "$ "),由 Shell 输出的每个字符都会触发一个write系统调用。

static void
putc(int fd, char c)
{
  write(fd, &c, 1);
}

write系统调用最终会走到sys_write

uint64
sys_write(void)
{
  struct file *f;
  int n;
  uint64 p;

  argaddr(1, &p);
  argint(2, &n);
  if(argfd(0, 0, &f) < 0)
    return -1;

  return filewrite(f, p, n);
}

函数先检查参数,然后调用filewrite

int
filewrite(struct file *f, uint64 addr, int n)
{
  int r, ret = 0;

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

  if(f->type == FD_PIPE){
    ret = pipewrite(f->pipe, addr, n);
  } else if(f->type == FD_DEVICE){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
      return -1;
    ret = devsw[f->major].write(1, addr, n);
  } else if(f->type == FD_INODE){
    // write a few blocks at a time to avoid exceeding
    // the maximum log transaction size, including
    // i-node, indirect block, allocation blocks,
    // and 2 blocks of slop for non-aligned writes.
    // this really belongs lower down, since writei()
    // might be writing a device like the console.
    int max = ((MAXOPBLOCKS-1-1-2) / 2) * BSIZE;
    int i = 0;
    while(i < n){
      int n1 = n - i;
      if(n1 > max)
        n1 = max;

      begin_op();
      ilock(f->ip);
      if ((r = writei(f->ip, 1, addr + i, f->off, n1)) > 0)
        f->off += r;
      iunlock(f->ip);
      end_op();

      if(r != n1){
        // error from writei
        break;
      }
      i += r;
    }
    ret = (i == n ? n : -1);
  } else {
    panic("filewrite");
  }

  return ret;
}

filewrite中首先会判断文件描述符的类型。mknod生成的文件描述符属于设备FD_DEVICE,对这种类型,会为特定设备执行设备相应的write函数。这里会调用console.c中的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;
}

先通过either_copyin将字符拷入,然后调用uartputc函数将字符写入给 UART 设备。可以认为这个函数是一个 UART 驱动的top部分。

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

uartputc会实际打印字符。UART 内部有一个32字符大小的 buffer 用来发送数据,并有一个为 consumer 提供的读指针和为 producer 提供的写指针,构建出一个环形的 buffer。

#define UART_TX_BUF_SIZE 32
char uart_tx_buf[UART_TX_BUF_SIZE];
uint64 uart_tx_w; // write next to uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE]
uint64 uart_tx_r; // read next from uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE]

在例子里,Shell 是 producer,uartputc首先判断环形 buffer 是否已满,如果已满,则会sleep一段时间,把 CPU 出让给其他进程。这里提示符$是我们送出的第一个字符,因此代码会往下走,把字符送到 buffer 中,更新写指针,然后调用uartstart函数。

void
uartstart()
{
  while(1){
    if(uart_tx_w == uart_tx_r){
      // transmit buffer is empty.
      ReadReg(ISR);
      return;
    }

    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // the UART transmit holding register is full,
      // so we cannot give it another byte.
      // it will interrupt when it's ready for a new byte.
      return;
    }

    int c = uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE];
    uart_tx_r += 1;

    // maybe uartputc() is waiting for space in the buffer.
    wakeup(&uart_tx_r);

    WriteReg(THR, c);
  }
}

uartstart通知设备执行操作。首先检查当前设备是否空闲,如果空闲,就从 buffer 中读出数据,将数据写入到 THR 发送寄存器。这相当于告诉设备,有一个字节需要发送。一旦数据送到设备,系统调用就会返回,用户应用程序 Shell 就可以继续执行。这里由内核返回用户空间的机制与trap相同。


UART驱动的bottom部分

向 Console 输出字符过程中,如果发生了中断,由于之前已经在 SSTATUS 寄存器中打开了中断,这里会触发中断。假设键盘生成了一个中断并发向 PLIC,PLIC 会将中断路由给一个特定的 CPU 核,如果该核设置了 SIE寄存器的E bit(针对外部中断),那么处理过程如下:

  • 首先清除 SIE 寄存器的相应 bit 位。这样可以组织 CPU 核被其他中断打扰。

  • 然后设备 SEPC 寄存器为当前的程序计数器。

  • 保存当前的 mode。

  • 将 mode 设置为 Supervisor mode。

  • 将程序计数器的值设置为 STVEC 的值。

对于 Shell 的例子,STVEC的值为uservec的地址,uservec又会调用usertrap处理中断。

  else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
    printf("            sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());
    setkilled(p);
  }

devintr中,首先通过 SCAUSE 寄存器判断中断是否来自外设,若是,则调用plic_claim获取中断。

int
devintr()
{
  uint64 scause = r_scause();

  if(scause == 0x8000000000000009L){
    // 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){
      virtio_disk_intr();
    } else if(irq){
      printf("unexpected interrupt irq=%d\n", irq);
    }

    // the PLIC allows each device to raise at most one
    // interrupt at a time; tell the PLIC the device is
    // now allowed to interrupt again.
    if(irq)
      plic_complete(irq);

    return 1;
  } else if(scause == 0x8000000000000005L){
    // timer interrupt.
    clockintr();
    return 2;
  } else {
    return 0;
  }
}

plic_claim中,当前 CPU 核会告诉 PLIC 自己要处理中断,PLIC_SCLAIM会将中断号返回。对于 UART来说,返回的中断号为10。

int
plic_claim(void)
{
  int hart = cpuid();
  int irq = *(uint32*)PLIC_SCLAIM(hart);
  return irq;
}

devintr函数中,如果是 UART中断,会调用uartintr函数。它会从 UART 的接收寄存器中读取数据,然后将获取的数据传递给consoleintr函数。不过这里目前还没有通过键盘输入任何数据,所以 UART 的接收寄存器为空。

// read one input character from the UART.
// return -1 if none is waiting.
int
uartgetc(void)
{
  if(ReadReg(LSR) & 0x01){
    // input data is ready.
    return ReadReg(RHR);
  } else {
    return -1;
  }
}

// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from devintr().
void
uartintr(void)
{
  // read and process incoming characters.
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }

  // send buffered characters.
  acquire(&uart_tx_lock);
  uartstart();
  release(&uart_tx_lock);
}

所以代码会直接运行到uartstart函数,这个函数会将 Shell 存储在 buffer 中的任意字符送出。这样驱动的top部分就和bottom部分就解耦开了。

只有一个 UART 设备,一个 buffer 只针对一个 UART 设备,而这个 buffer 会被所有 的 CPU 核共享。因此需要锁来确保多个 CPU 核上的程序串行的向 Console 打印输出。


Interrupt相关的并发

与中断相关的并发包括:

  • 设备与 CPU 是并行运行的。例如,当 UART 向 Console 发送字符的时候,CPU 会返回执行 Shell,而 Shell 可能会再执行一次系统调用,向 buffer 中写入另一个字符。这里的并行称为producer-consumer并行。

  • 中断会停止当前运行的程序。对于一些内核代码来说,如果不能在执行期间被中断,则需要内核临时关闭中断,来确保这段代码的原子性。

  • 驱动的 top 和 bottom 部分是并行运行的。例如,Shell 会在传输完提示符$之后会再调用write系统调用传输空格字符,代码会走到 UART 驱动的 top 部分,将空格写入到 buffer 中。但同时在另一个 CPU 核,可能会收到来自 UART 的中断,进而执行驱动的 bottom 部分,查看相同的 buffer。所以一个驱动的这两部分可以并行的在不同 CPU 上运行,通过lock来管理并行。

这里主要关注第一点。驱动中会有 buffer,在前面 UART的例子中,buffer 是32自己大小,且有两个指针,分别是读指针和写指针。如果两个指针相等,buffer 是空的。当 Shell 调用uatrputc函数时,会将字符写入写指针位置并将写指针加1。

producer可以一直写入数据,直到写指针+1等于读指针。这时 buffer 满了,代码会 sleep,暂时搁置 Shell 并运行其他进程。

Interrupt handleruartintr函数在这个场景下是consumer。每当

有一个中断,且读指针落后于写指针,uartintr就会从读指针读取一个字符再通过 UART 设备发送,并将读指针加1,直到二者相等。


UART读取键盘输入

Shell 会调用read从键盘中读取字符,在read系统调用的最底层,会调用fileread函数,这个函数中,如果读取的文件类型是设备,会调用相应设备的read函数。

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;
    r = devsw[f->major].read(1, addr, n);
  } else if(f->type == FD_INODE){
    ilock(f->ip);
    if((r = readi(f->ip, 1, addr, f->off, n)) > 0)
      f->off += r;
    iunlock(f->ip);
  } else {
    panic("fileread");
  }

  return r;
}

在这个例子中的就是consoleread函数。

int
consoleread(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){
      if(killed(myproc())){
        release(&cons.lock);
        return -1;
      }
      sleep(&cons.r, &cons.lock);
    }

    c = cons.buf[cons.r++ % INPUT_BUF_SIZE];

    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 == '\n'){
      // a whole line has arrived, return to
      // the user-level read().
      break;
    }
  }
  release(&cons.lock);

  return target - n;
}

这里也有一个 buffer,包含128个字符。

#define INPUT_BUF_SIZE 128
  char buf[INPUT_BUF_SIZE];
  uint r;  // Read index
  uint w;  // Write index
  uint e;  // Edit index
} cons;

在这个场景下,Shell是consumer,从 buffer 中读取数据;而键盘是producer,将数据写入到 buffer 中。

consoleread中可以看到,当读写指针一样时,进程会sleep。因此,当 Shell 打印 $后,如果键盘没有输入,Shell 进程会sleep,直到键盘有字符输入,这会被发送到主板上的 UART 芯片,产生中断后再被 PLIC 路由到某个 CPU 核,然后触发devintr函数,通过uartgetc获取相应字符,再传递给consoleintr函数。

默认情况下,字符会通过consputc输出到 console 上给用户看,然后被存放在 buffer 中。遇到换行符时,唤醒之前sleep的进程,再从 buffer 中将数据读出。

这样可以通过 buffer 将consumerproducer解耦,使它们可以按照自己的速度独立并行运行。


Interrupt的演进

polling和Interrupt

Last updated