[xv6]:控制台中断

控制台Console是与用户进行交互的硬件设备,它接受用户的输入(如键盘输入),将其传递给内核和用户程序,进行相应的处理,然后再输出结果给用户(如输出到屏幕上)

首先,简单地看总体流程:

  • 用户将会通过键盘键入一连串字符,通过连接到RISC-V上的UART串行端口UART Serial-port)传输,控制台驱动程序将会顺利地接收这些输入

  • 接控制台驱动程序处理其中的一些特殊字符(如BackSpace和Ctrl等),并不断累积这些输入字符,直到达到完整的一行(一般用户键入Enter表示一行的结束)

  • 用户进程,例如shell,就会使用read从控制台中读取这些一行行的输入,然后由shell来具体处理它们

内核可以访问经内存映射UART控制寄存器。RISC-V硬件将UART设备连接到事先约定好的物理地址上,对这些固定物理地址的读或写指令,相当于直接于硬件设备进行交互,而不是与RAM交互

UART经内存映射到从物理地址0x10000000开始的部分上,它有一小部分控制寄存器,每个1B大小, 当处理设备中断时需要使用到这些寄存器

  • kernel/uart.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // the UART control registers are memory-mapped
    // at address UART0. this macro returns the
    // address of one of the registers.
    // 用于寻址, 采用的基址 + 偏移量的方式
    #define Reg(reg) ((volatile unsigned char *)(UART0 + reg))

    // 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)
    // 保持着UART接受的输入,等待着内核将其内容取走, 与read()相关
    #define THR 0 // transmit holding register (for output bytes)
    // 保持着内核的输入,等待着UART将其发送, 与write()相关
    #define IER 1 // interrupt enable register
    // 中断控制寄存器
    #define IER_TX_ENABLE (1<<0)
    #define IER_RX_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

1. 控制台读取用户输入流程

先给出整个流程,然后给出具体细节描述:

  • 用户使用read()系统调用从控制台读取输入,控制台等待用户输入, 此时会产生consoleread()进程

  • 用户键盘输入一个字符

  • xv6接收到中断,陷入trap

  • trap handler发现是外部设备中断,设备是UART,调用uartintr()

  • 发现RHR中有字符可读,调用consoleintr()

  • 将输入字符缓冲到cons.buf中,如果读到’\n’或’ctrl+D’,说明用户输入满足一行,就唤醒consoleread()

  • 读出一整行的用户输入,拷贝到用户空间中

键入字符后陷入trap

用户键入字符后,UART硬件向RISCV抛出一个终端, 激活usertrap(), usertrap()对中断类型进行检查,调用devintr()判断是否为硬件中断

  • kernel/trap.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    int
    devintr()
    {
    uint64 scause = r_scause();

    if((scause & 0x8000000000000000L) &&
    (scause & 0xff) == 9){
    // this is a supervisor external interrupt, via PLIC.

    // irq indicates which device interrupted.
    // 从PLC硬件单元中询问哪个硬件设备中断了
    int irq = plic_claim();

    if(irq == UART0_IRQ)
    // uart设备中断
    uartintr();
    // 后面的可以忽略...
    }

发现为uart中断,调用uartintr()

  • kernel/uart.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    void
    uartintr(void)
    {
    // read and process incoming characters.
    while(1){
    // 从RHR控制寄存器中读取一个字符
    int c = uartgetc();
    if(c == -1)
    break;
    consoleintr(c);
    }

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

    uartintr()内部会使用uartgetc()来从RHR控制寄存器中获取用户键入的字符

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int
    uartgetc(void)
    {
    if(ReadReg(LSR) & 0x01){
    // input data is ready.
    return ReadReg(RHR);
    } else {
    return -1;
    }
    }

    此时,已经确定了中断类型,会调用consoleintr()进行控制台相关处理

调用consoleintr()

关于console, xv6使用了一个cons对象来对其进行维护

  • kernel/console.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct {
    struct spinlock lock;

    // input
    #define INPUT_BUF 128
    char buf[INPUT_BUF]; // console缓冲区
    uint r; // Read index
    uint w; // Write index
    uint e; // Edit index // 这里的索引可能会超过缓冲区大小,后面使用了取余操作
    } cons;
  • kernel/console.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    //! @param c 用户键入的字符
    void
    consoleintr(int c)
    {
    acquire(&cons.lock);

    switch(c){
    // ctrl + P, ctrl + U, ctrl + H
    case C('P'): // Print process list.
    procdump();
    break;
    case C('U'): // Kill line.
    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;
    default:
    // 默认情况下会回显字符
    if(c != 0 && cons.e-cons.r < INPUT_BUF){

    c = (c == '\r') ? '\n' : c;

    // echo back to the user.
    consputc(c);

    // store for consumption by consoleread().
    cons.buf[cons.e++ % INPUT_BUF] = c;

    if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
    // wake up consoleread() if a whole line (or end-of-file)
    // has arrived.
    // 用户不断键入字符,直到用户输入换行符或者ctrl + d时
    // 才会唤醒之前睡在cons.r的consoleread()进程
    cons.w = cons.e;
    //
    wakeup(&cons.r);
    }
    }
    break;
    }

    release(&cons.lock);
    }

    在这里,默认情况下会对用户键入的字符进行回显,同时将其保存到cons对象的缓冲区中

    如果用户输入了一些特殊字符(如ctrl + p等),那么就做特殊处理

    当用户键入\n回车符或者ctrl + d文件结束符时,就立即唤醒之前处于sleep状态并且睡在cons.rconsoleread()进程

    consoleread()进程在用户发起read()操作时就会产生, 这些进程并没有阻塞,而是使用了schedule()让出了CPU

consoleread()进程读取控制台缓冲区内容到用户空间

  • kernel/console.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    /*! @brief 将console缓冲区的内容拷贝到sdt 
    *! @param user_dst 用于判断dst是用户地址还是内核地址
    */
    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.
    // 当console的read index与console 的write index相等
    // 有两种情况:
    // 1. 缓冲区没有字符可读
    // 2. 缓冲区已经读完,但是该行仍未结束
    while(cons.r == cons.w){
    if(myproc()->killed){
    release(&cons.lock);
    return -1;
    }
    // 睡眠
    // 这里并不会发生阻塞,而是进程的重新调度
    sleep(&cons.r, &cons.lock);
    }

    // 一次性读取一个字符
    c = cons.buf[cons.r++ % INPUT_BUF];

    if(c == C('D')){ // end-of-file(十进制为4), 当键入ctrl + D时就会触发
    if(n < target){
    // Save ^D for next time, to make sure
    // caller gets a 0-byte result.
    // 读取到了文件结束符, 但是没有达到target
    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().
    // 行尾符, 此时整个read()操作结束
    break;
    }
    }
    release(&cons.lock);

    return target - n;
    }

2. 将用户数据发送到控制台流程

在发送过程中需要使用到的两个比较重要的寄存器:

  • THR: 如果用户想要将字符发送到控制台,就必须将字符写入到该寄存器当中,随后,会由硬件自动读取该寄存器中的值

    ​ 如果THR寄存器中的值被硬件读出,就代表该字符真正的被控制台读取了,同时将会自动触发transmit complete 中断, 陷入

    ​ trap

  • LSR: 利用其中的第6个bit可以用来判断THR寄存器内部是否为空

xv6同样也为发送过程维护了一个缓冲区:

  • kernel/uart.c

    1
    2
    3
    4
    5
    6
    // the transmit output buffer.
    struct spinlock uart_tx_lock;
    #define UART_TX_BUF_SIZE 32
    char uart_tx_buf[UART_TX_BUF_SIZE];
    int uart_tx_w; // write next to uart_tx_buf[uart_tx_w++]
    int uart_tx_r; // read next from uart_tx_buf[uar_tx_r++]

简要流程:

  • 用户使用write()系统调用, 最终将导向到UART驱动程序的top half, 结果为调用uarputc()

  • uartputc()尝试将用户提供的字符写入到uart_tx_buf缓冲区

    如果缓冲区满的话,将会sleep

    否则,调用uartstart()

  • uartstart()会尝试将缓冲区的字符写入到THR寄存器中

    如果THR寄存器此时为空,则表明可以写入

    否则直接返回

  • 位于THR寄存器中的值被控制台读出,触发transmit complete中断,陷入trap

  • devintr()中发现中断为uart设备中断,调用uartintr()

  • uartintr()中经过判断发现是transmit complete中断,调用uartstart(), 陷入循环

可以看出uartputc()会尝试将用户提供的字符一次性全部写入缓冲区,然后再由uartstart()一次中断写入一个字符到控制台

在这里就不给出代码分析了,但重要的一点是, 上面的整个过程都是异步的, uartstart()在发现THR寄存器非空会直接返回,

uartputc()发现缓冲区满会进行sleep, 让出CPU

在xv6中还提供了一个uartputc_sync(), 是uartputc()的同步版本,采用了轮询方式(poiling), 用于用户希望快速发出内容的情况

  • kernel/uart.c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    void
    uartputc_sync(int c)
    {
    push_off();

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

    // wait for Transmit Holding Empty to be set in LSR.
    // 一直阻塞直到THR中的内容发出
    // 然后马上将待发送字符写入THR中

    while((ReadReg(LSR) & LSR_TX_IDLE) == 0)
    ;
    WriteReg(THR, c);

    pop_off();
    }

    这里没有使用到发送缓冲区,而是直接尝试将内容写入到THR中, 如果THR非空的话就会陷入阻塞

uart, 键盘, 显示器 ?

uart连接了两个键盘以及显示器(Console)两个设备,当通过键盘键入字符时,会将字符发送到uart的相关控制寄存器,然后寄存器的值会被显示器读取显示在屏幕上

3. uart相关并发

在xv6中,设备与CPU(进程)是并发运行的, 一个简单的理解就是UART在向Console发送字符时, Shell可能正同时向Console的缓冲区写入字符, 这被称为producer-consumer并行

通过代码可以看出,UART的输入以及输出缓冲区都是全局的,所有的CPU都会访问相同的输入输出缓冲区,因此在代码中使用了锁来保护共享的数据结构

**Top 与 Bottom **

通常情况下,大多数的设备驱动程序,都可以看成一个分上下部分的结构:顶部top half运行在内核空间中,通常由某一个进程的内核线程来运行,而底部bottom half则在中断产生时执行,大体上就是Interrupt handler

当内核希望与设备进行一些交互时,请求read、write等系统调用,驱动程序的top half就会被调用,top half会根据相应请求,让设备开始执行一些具体的操作(例如从磁盘上读一块);在相关操作完成后,设备就会产生中断,因此驱动程序的bottom half开始执行,它会查看设备完成的是什么工作,在适当的时候唤醒等待该工作的进程,同时让设备开始做新的工作


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!