0.briefly speaking
之前我们研究过Xv6中的陷阱机制,并搞懂了系统调用的全部流程,接下来我们以UART和console为研究对象,深入研读一下Xv6内核中有关设备中断驱动的代码,并对UART、shell、console、键盘、显示器等设备的协同运作过程做出解释。
本篇博客主要涉及以下代码的阅读:
1.kernel/uart.c
2.kernel/console.c
3.kernel/trap.c
4.16550 UART chip manual
我们首先从控制台(Console)的初始化和基本操作出发,然后通过两个相反的过程(UART从键盘读取数据、UART向显示器输出数据)弄懂整个串口驱动的全流程,最后给出这个过程的总览,以及各个部分之间的关系,以及对UART、Console、Shell在系统中的联系和关系做出总结。
由于篇幅所限,这篇博客只对初始化部分做一个大致的介绍,后续会将上述内容一一解释清楚。
1.初始化和基本操作
1.1 console的初始化
在启动过程中(kernel/main.c:14)执行的第一个任务consoleinit就是控制台console的初始化,这个函数的实现在(kernel/console.c:181)中,实现如下,我们将一点点的展开这个初始化过程,了解Xv6内部对于console设备的管理:
void
consoleinit(void)
{
// 初始化cons结构体中的锁
// 这里的cons就是控制台console的软件抽象
initlock(&cons.lock, "cons");
// 初始化串口
uartinit();
// connect read and write system calls
// to consoleread and consolewrite.
// 译:将读写系统调用连接到consoleread和consolewrite
devsw[CONSOLE].read = consoleread;
devsw[CONSOLE].write = consolewrite;
}
这个函数的作用非常清晰,首先它初始化了cons变量中的锁(关于锁的细节我们到下一部分源码才会深入研究),然后调用uartinit函数初始化了串口芯片16550,这部分需要参考大量16550芯片的寄存器细节,最后,consoleread和consolewrite函数被作为console结构的标准read和write操作,被注册在devsw中。
下面,我们首先对cons结构体和devsw进行简单的介绍,然后我们转入uartinit函数,仔细研究一下在Xv6中UART芯片是如何被初始化的,最后我们仔细阅读一下consoleread和consolewrite的函数实现
1.1.1 cons结构体
cons结构体定义在(kernel/console.c:44),这是console的软件抽象,它的定义和注释如下,我们可以看到整个console内部其实是有一个输入缓冲区的,并且有三枚指针来分别实时记录当前缓冲区的读、写、编辑位置。
struct {
// 一把自旋锁,用来保证互斥访问
struct spinlock lock;
// input
// 输入缓冲区和三个用来读写当前缓冲区的指针
#define INPUT_BUF 128
char buf[INPUT_BUF];
uint r; // Read index,读索引
uint w; // Write index, 写索引
uint e; // Edit index, 编辑索引
} cons;
1.1.2 UART的初始化
consoleinit中的下一个任务就是调用userinit函数(kernel/uart.c:52)对串口芯片16550进行初始化,这个函数中大量调用了宏WriteReg(kernel/uart.c:39),这个宏的定义如下:
// MMIO寄存器进行赋值,将v值写入寄存器
#define WriteReg(reg, v) (*(Reg(reg)) = (v))
而Reg宏的定义如下,它实质上是UART在MMIO中的基址加上对应寄存器的偏移量来实现的,在使用时我们只需要将对应的寄存器地址传进去即可,而这些寄存器的地址也被定义成了宏,分布在kernel/uart.c:22-36,如下所示:
// the UART control registers are memory-mapped
// at address UART0. this macro returns the
// address of one of the registers.
// 译:UART控制寄存器是地址UART0处开始内存映射的
// 这个宏返回其中一个寄存器的地址
#define Reg(reg) ((volatile unsigned char *)(UART0 + reg))
// 各个寄存器的定义和简单描述如下,在用到时我们会深入研读16550手册
#define RHR 0 // receive holding register (for input bytes)
#define THR 0 // transmit holding register (for output bytes)
#define IER 1 // interrupt enable register
#define IER_RX_ENABLE (1<<0)
#define IER_TX_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
接下来就可以研究一下uartinit函数了,在此之前首先贴出所有寄存器地址和功能的大致描述,以备在后续参考:
以及设置波特率的一张快速查找表,我们可以通过查表快速地确认向寄存器中写入的值:
void
uartinit(void)
{
// disable interrupts.
// 译:关闭中断
// IER寄存器控制着芯片上所有的中断的使能
// 这一步相当于关闭了所有UART可能发出的中断
// IER bit0: 管理receiver ready register中断
// IER bit1: 管理transmitter empty register中断
// IER bit2: 管理receiver line status register中断
// IER bit3: 管理modem status register中断
// IER bit4-7: 硬连线为0
WriteReg(IER, 0x00);
// special mode to set baud rate.
// 译:进入设置波特率的特殊模式
// 当向LCR(Line Control Register)最高位(bit7)写入1时
// 这将会改变地址000和001处两个寄存器的含义
// 000地址在普通模式下对应RHR和LHR两个寄存器,一个只读、一个只写,因此共用一个地址
// 001地址在普通模式下对应IER寄存器,就是上面管理中断的寄存器
// 在设置波特率的模式下,000和001分别对应DLL DLM两个寄存器,用来确定波特率
WriteReg(LCR, LCR_BAUD_LATCH);
// LSB for baud rate of 38.4K.
// 根据查表可知要将DLL、DLM两个寄存器分别设置为3
// 之所以设置为38.4K的波特率,可能与qemu的具体实现代码有关
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.
// 译:离开波特率设置模式
// 设置传输字长为8bit,不含奇偶校验位
// LCR的低两位设置为00、01、10、11时,分别对应5、6、7、8bit的字长
// 这里设置为8bit字长,即一个字节
WriteReg(LCR, LCR_EIGHT_BITS);
// reset and enable FIFOs.
// 译:重置并使能IO
// FCR_FIFO_ENABLE标志用于使能输入输出两个FIFO
// FCR_FIFO_CLEAR标志用于清空两个FIFO并将其计数逻辑设置为0
WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);
// enable transmit and receive interrupts.
// 译:使能输入输出中断
// 一旦同时使能了输入(RX)中断和FIFO,UART就会在到达trigger level时向CPU发起一个中断
// (这个trigger level默认值为1),同样,在输出THR为空时也会向CPU发起一个中断
WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);
// 初始化串口芯片输出缓冲区的锁
// Xv6内核里给输出又设置了一个缓冲区,默认大小为32
// 事实上在16550芯片内部TX和RX都有一个16字节的硬件FIFO作为缓冲
// 但是Xv6内核实际上还是一个个地发送和接收字节的,相当于将这层硬件缓冲透明化了
initlock(&uart_tx_lock, "uart");
}
上述就是UART芯片16550的全部初始化流程,需要注意的是除了16550芯片中已经拥有的硬件FIFO,Xv6内核中还设置了一个软件缓冲区uart_tx_buf用来暂存UART即将要发送的数据,与之一并定义的还有两枚读写指针和一个用于管理进程并发的自旋锁,代码如下所示。
// the transmit output buffer.
// 译:输出缓冲区
struct spinlock uart_tx_lock;
#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]
这个数据结构借助两枚指针和一个数组实现了一个环形队列,用来更好地管理这个输出缓冲区,这个循环队列的示意图和判空判满条件如下,注意整个循环队列的长度是32,以下直接写的明值:
至此,我们分析好了UART的初始化过程,现在可以回到consoleinit中,去分析一下它的读写函数了。
1.1.3 devsw数组和console的读写操作
在consoleinit函数中,在注册读写函数时涉及到了一个特殊的数组devsw,devsw的定义如下,可以看到它就是两个读写函数指针的封装,它封装了可以对一个设备施加的所有的操作,定位非常类似于Linux中的file_operations,但规模大大简化了,所以Xv6内核对驱动程序的支持还是相对简单的。
// map major device number to device functions.
// 译:将主设备号映射到对应的设备函数
struct devsw {
int (*read)(int, uint64, int);
int (*write)(int, uint64, int);
};
随后,使用devsw结构体在内核中声明了一个长度为NDEV的数组(kernel/file.c:16),而consoleinit函数中将console的读写函数注册在了这个数组中:
// 声明一个长度为NDEV的数组
// NDEV是Xv6内核中定义的最大主设备号,值为10
struct devsw devsw[NDEV];
这里值得补充的一点是,在UNIX系统中,有主设备号(major device number)和从设备号(minor device number)的区分,其中主设备号用来确定设备要使用的驱动程序大类。是的,在操作系统中一个驱动程序可以服务多个设备,这些设备往往拥有类似的特征,因此它们的驱动程序构成非常类似,不需要为每个设备都重写一遍非常相似的驱动程序。
而即便再类似的外部设备,它们的驱动程序也一定会有细微差别,这时候就需要借助从设备号(minor device number)在驱动程序中对特定的设备加以区分和细节处理了。所以所谓主从设备号就是操作系统内核中用于将特定驱动程序和设备关联起来的两个标识符。
在Xv6中,这个最大主设备号就是NDEV,它的值为10,这表明在Xv6内部最多只支持注册10种不同设备的驱动程序(事实上只定义了console一种),且每一种设备只支持读写两种操作,接下来我们就看看console的具体对读写函数定义。
首先是consolewrite函数的代码和注释如下,我首先将完整函数的意思解释出来,后面会有更深入的解释:
//
// user write()s to the console go here.
// 译:用户态调用的write函数将会进入这里
int
consolewrite(int user_src, uint64 src, int n)
{
int i;
// 开启一个循环,一个接一个地从地址src处复制到字符c中
// 并通过uartputc函数尝试将字符加入输出缓冲区并输出
for(i = 0; i < n; i++){
char c;
// 复制失败则跳出
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
// 成功,则将此字符放入UART输出缓冲区
// 并尝试驱动UART芯片向外发送
uartputc(c);
}
return i;
}
在这个函数中调用了either_copyin和uartputc两个函数,我们顺势了解一下它们的实现,首先是either_copyin函数,它定义在kernel:proc.c:620的位置,这个函数相当于将memmove和copyin函数合二为一了,根据传入参数usr_dst的不同来实现将数据复制到内核/用户地址中,有关于memmove函数和copyin函数细节的讲解,可以看之前关于虚拟内存的博客:6.S081——虚拟内存部分——xv6源码完全解析系列(2)和6.S081——虚拟内存部分——xv6源码完全解析系列(3)。
// Copy from either a user address, or kernel address,
// depending on usr_src.
// Returns 0 on success, -1 on error.
// 译:从用户地址空间或内核地址空间拷贝数据
// 这取决于usr_src,返回0表示成功,返回-1表示错误
int
either_copyin(void *dst, int user_src, uint64 src, uint64 len)
{
// 获取当前进程
struct proc *p = myproc();
// 如果user_src标志置位,则使用copyin函数从用户空间拷贝数据
if(user_src){
return copyin(p->pagetable, dst, src, len);
// 否则直接使用memmove函数从内核空间拷贝数据,这无需经由页表翻译
} else {
memmove(dst, (char*)src, len);
return 0;
}
}
接下来是uartputc函数(kernel/uart.c:80)的分析,它的源码和注解如下,我们可以看到在uartputc函数中做的主要就是尝试将一个字符放入上述的发送缓冲区(环形队列)中,如果缓冲区已满就让进程陷入睡眠状态,等到缓冲区有空位让出,而真正的发送动作是在uartstart函数中完成的。
// add a character to the output buffer and tell the
// UART to start sending if it isn't already.
// blocks if the output buffer is full.
// because it may block, it can't be called
// from interrupts; it's only suitable for use
// by write().
// 译:将一个字符放入输出缓冲区,如果UART还没有发送就告知它
// 如果输出缓冲区满了就阻塞
// 因为此函数可能被阻塞,所以它不能从中断中被调用,只适合被write使用
void
uartputc(int c)
{
// 获取输出缓冲区(环形队列)的锁
acquire(&uart_tx_lock);
// 如果内核发生故障,直接陷入死循环
// 程序失去响应
if(panicked){
for(;;)
;
}
// 否则尝试将字符放入发送缓冲区中并开始发送
while(1){
if(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
// buffer is full.
// wait for uartstart() to open up space in the buffer.
// 译:缓冲区已满,等候uartstart函数在buffer中开辟出新的空间
// 让当前线程休眠在uart_tx_r这个channel上,等待被唤醒
// 关于锁与并发机制的更多细节在后面的博客会一一分析
sleep(&uart_tx_r, &uart_tx_lock);
} else {
// 如果缓冲区未满,则将字符放入缓冲区,并调整指针
// 使用uartstart函数告知UART准备发送
// 最后释放锁
uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
uart_tx_w += 1;
uartstart();
release(&uart_tx_lock);
return;
}
}
}
在这个函数中进一步调用了uartstart函数(kernel/uart.c:133),我们再去研究一下这个函数,uartstart函数是直接驱动UART芯片发送数据的函数,它会首先检测一些条件,条件一旦满足就向UART的THR寄存器开始写入要发送的字符,驱动UART芯片向外发送数据,完整代码和注释如下:
// if the UART is idle, and a character is waiting
// in the transmit buffer, send it.
// caller must hold uart_tx_lock.
// called from both the top- and bottom-half.
// 译:如果UART在空闲状态,字符正在发送缓冲区中等待
// 那么直接发送之,调用者必须持有uart_tx_lock锁
// 在驱动的上半、下半部分均会调用
// 驱动的上半部分指用户或内核可以调用的函数接口
// 驱动的下半部分指的是中断处理程序本身
// 事实上,uartstart函数会被两个地方调用
// 一个是我们刚刚看到的uartputc函数,对应驱动的上半部分
// 还会被uartintr函数调用,这部分则是驱动的下半部分
void
uartstart()
{
while(1){
// 如果发送缓冲区为空,则直接返回
if(uart_tx_w == uart_tx_r){
// transmit buffer is empty.
return;
}
// 缓冲区中有字符等待发送,但是UART还没有完成上一次发送
// 这时也不可以发送成功,直接返回
// ReadReg和上面介绍的WriteReg宏类似,用来读取一个UART寄存器的值
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.
// 译:UART THR寄存器仍为满
// 此时不能给它另外一个字节,所以只能等它准备好时主动发起中断
return;
}
// 如果发送缓冲区中有字符并且UART正处于空闲状态
// 则可以准备发送,读取字符并调整读指针
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.
// 译:也许uartputc函数正等待缓冲区中有新的空间
// 这里直接唤醒之前在uart_tx_r地址上进行睡眠等待的锁
// 其实也就是将进程状态更改为RUNNING,从而进入调度队列
// 和uartputc中的sleep对应
wakeup(&uart_tx_r);
// 将数据写入UART的THR寄存器,这个值将会被UART自动移入
// TSR(transmit shift register)寄存器,一位位地串行发送出去
WriteReg(THR, c);
}
}
OK,到这里我们大致将consolewrite函数分析得差不多了,它做的事情很简单,首先将数据从源地址拷贝到一个本地临时变量c中,然后驱动UART芯片将其发送出去。
接下来看看consoleread函数的实现,之前我们提过在console的软件抽象cons结构体中也有一个软件缓冲区cons.buf,它其实和UART的输出缓冲区一样是一个环形队列,这里就不再反复给出它的示例了,我们直接看consoleread函数及其注释:
//
// user read()s from the console go here.
// copy (up to) a whole input line to dst.
// user_dist indicates whether dst is a user
// or kernel address.
// 译:用户的对console的read操作会进入到这里
// 将一整行输入读取到dst地址的地方
// user_dst标志表明地址是用户地址还是内核地址
int
consoleread(int user_dst, uint64 dst, int n)
{
// target表示的是期望复制的字符个数,也就是传入的n
// c、cbuf都是暂存字符用的
uint target;
int c;
char cbuf;
// 首先将复制目标确定为n,相当于对n的值做了保存
target = n;
acquire(&cons.lock);
// 在读入n个字符之前不退出循环
while(n > 0){
// wait until interrupt handler has put some
// input into cons.buffer.
// 译:在中断响应函数没有将字符放入cons.buffer之前
// 保持休眠等待
// 也就是当console缓冲区中无字符时,进程保持休眠
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];
// 如果按键是组合式的Ctrl+D,即End Of File组合键
if(c == C('D')){ // end-of-file
// 首先判断一下是否已经读出了一部分数据
// 如果是,则本次读取到此结束,但仍保留Ctrl+D在缓冲区中
// 如果Ctrl+D是本次读取的第一个字符,则直接结束,并丢弃缓冲区中的Ctrl+D
if(n < target){
// Save ^D for next time, to make sure
// caller gets a 0-byte result.
// 译:将Ctrl+D保存到下一次读取操作
// 并回退读指针,这样下次读取就会得到一个0字节的结果
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;
}
}
// 释放console缓冲区的锁
release(&cons.lock);
// 返回本次实际从缓冲区cons.buf读取的字符数量
return target - n;
}
我们可以看到,consoleread函数做的事情就是从console缓冲区中读取指定长度的输入(或者是一行输入),并拷贝到用户/内核空间的指定区域去。处理逻辑比较散,所以稍显零碎,尤其对于Ctrl+D组合键的处理稍显复杂,需要细细品位和阅读一下。拷贝数据时用的函数是either_copyout,这个函数和我们之前看到的either_copyin非常类似,只是方向相反,细节不再过多解释,函数代码实现如下:
// Copy to either a user address, or kernel address,
// depending on usr_dst.
// Returns 0 on success, -1 on error.
// 译:拷贝到用户空间或者是内核空间
// 取决于usr_dst标志
// 成功时返回0,失败时返回-1
int
either_copyout(int user_dst, uint64 dst, void *src, uint64 len)
{
struct proc *p = myproc();
if(user_dst){
return copyout(p->pagetable, dst, src, len);
} else {
memmove((char *)dst, src, len);
return 0;
}
}
至此,我们算是较为完整地认识了console设备的读写操作实现,以及UART的初始化过程。但是现在只是完成了驱动的注册和硬件的初始化,我们上面说UART芯片16550需要通过中断来通知CPU进而完成对设备的读写,因为是外部设备,所以中断信号一定会通过PLIC来路由到CPU核心。接下来梳理一下这部分工作。
1.2 PLIC的初始化
这部分工作我较为完整地总结在了之前的博客6.S081——补充材料——RISC-V架构中的异常与中断详解中,这篇博客非常完整地对RISC-V架构中的异常与中断做了梳理。
在Xv6内核中,与外部中断相关的初始化函数有plicinit(kernel/plic.c:11)与plicinithart(kernel/plic.c:19),我们结合SiFive开发板的手册,研究一下它们的行为。首先是plicinit函数,它的实现如下:
void
plicinit(void)
{
// set desired IRQ priorities non-zero (otherwise disabled).
// 译:设置希望的IRQ优先级为非零值(否则就会禁用此中断)
// 这里将虚拟磁盘中断和UART中断都设置为最低优先级1
*(uint32*)(PLIC + UART0_IRQ*4) = 1;
*(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}
这段定义代码和手册中是一一对应的:优先级写入PLIC基址+4
×
\times
×ID号这个地址:
PLIC对优先级的规定是这样的:外部中断设备有0-7一共8个优先级,数字越大优先级越高,其中0表示“无需响应的中断”,所以优先级设置为0表示屏蔽此中断。我们看到上述代码将虚拟磁盘中断和UART中断都设置为1,也就是最低优先级。那么,两个中断优先级一样,同时发生时应该优先响应谁呢,PLIC规定:优先响应中断ID号较小的那一个,而上述的两个中断ID定义分别如下:
// 虚拟磁盘中断ID更小,所以它享有更高优先级
#define UART0_IRQ 10
#define VIRTIO0_IRQ 1
第二个函数是plicinithart,这个函数对多个核心的中断使能和中断阈值做了初始化,代码如下:
void
plicinithart(void)
{
// 获取当前CPU的hartid,它是CPU的唯一标识符
int hart = cpuid();
// set uart's enable bit for this hart's S-mode.
// 根据标识符设置对应的S-Mode中断使能寄存器
// 中断使能寄存器中将中断ID对应的位设置为1,即可使能对应中断
*(uint32*)PLIC_SENABLE(hart)= (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);
// set this hart's S-mode priority threshold to 0.
// 译:设置S-Mode下的优先级阈值为0
*(uint32*)PLIC_SPRIORITY(hart) = 0;
}
对于PLIC中的中断使能寄存器,只需要将中断ID对应的位设置为1,即可使能中断:
而对于中断阈值interrupt_threshold,PLIC不会响应小于等于中断阈值的优先级的中断,为了让所有中断都被响应,这里将阈值设置为0,所以所有中断都会被响应,这就是初始化PLIC的全部流程。
亟待解决的问题
在memorylayout.h文件中定义的PLIC寄存器地址换算关系和手册中的寄存器地址是对应不上的:
// qemu puts platform-level interrupt controller (PLIC) here.
#define PLIC 0x0c000000L
#define PLIC_PRIORITY (PLIC + 0x0)
#define PLIC_PENDING (PLIC + 0x1000)
#define PLIC_MENABLE(hart) (PLIC + 0x2000 + (hart)*0x100)
#define PLIC_SENABLE(hart) (PLIC + 0x2080 + (hart)*0x100)
#define PLIC_MPRIORITY(hart) (PLIC + 0x200000 + (hart)*0x2000)
#define PLIC_SPRIORITY(hart) (PLIC + 0x201000 + (hart)*0x2000)
#define PLIC_MCLAIM(hart) (PLIC + 0x200004 + (hart)*0x2000)
#define PLIC_SCLAIM(hart) (PLIC + 0x201004 + (hart)*0x2000)
举个简单的例子,PLIC_SCLAIM宏如果代入hart = 1,那么计算出来的结果是0x0c203004,这个地址对应到的不是hart1的CLAIM寄存器,而是M态下hart2的CLAIM寄存器。这几个寄存器地址定义都存在类似的问题,我不知道问题出在哪里,这可能和qemu的源码有关,但是我目前还没有找到依据,望知情人告知一下,多谢!