本文为MIT 6.S081课程第五章教材内容翻译加整理。
本课程前置知识主要涉及:
下图是一台Athena计算机(注,MIT内部共享使用的计算机)的top指令输出。如果你查看Mem这一行:
首先是计算机中总共有多少内存(33048332),如果你再往后看的话,你会发现大部分内存都被使用了(4214604 + 26988148)。但是大部分内存并没有被应用程序所使用,而是被buff/cache用掉了。这在一个操作系统中还挺常见的,因为我们不想让物理内存就在那闲置着,我们想让物理内存被用起来,所以这里大块的内存被用作buff/cache。可以看到还有一小块内存是空闲的(1845580),但是并不多。
以上是一个非常常见的场景,大部分操作系统运行时几乎没有任何空闲的内存。这意味着,如果应用程序或者内核需要使用新的内存,那么我们需要丢弃一些已有的内容。现在的空闲内存(free)或许足够几个page用,但是在某个时间点如果需要大量内存的话,要么是从应用程序,要么是从buffer/cache中,需要撤回已经使用的一部分内存。所以,当内核在分配内存的时候,通常都不是一个低成本的操作,因为并不总是有足够的可用内存,为了分配内存需要先撤回一些内存。
另外,我这里将top的输出按照RES进行了排序。如果你查看输出的每一行,VIRT表示的是虚拟内存地址空间的大小,RES是实际使用的内存数量。从这里可以看出,实际使用的内存数量远小于地址空间的大小。所以,我们上节课讨论的基于虚拟内存和page fault提供的非常酷的功能在这都有使用,比如说demand paging。
有关这台机器的其它信息还有:
这里想传达的信息:大部分内存都被使用了,并且RES内存远小于VIRT内存。
驱动程序是操作系统中管理特定设备的代码:
需要操作系统关注的设备通常可以被配置为生成中断,这是陷阱的一种。内核陷阱处理代码识别设备何时引发中断,并调用驱动程序的中断处理程序;在xv6中,这种调度发生在devintr
中(kernel/trap.c:177)。
许多设备驱动程序在两种环境中执行代码:
read
和write
。户通过键盘按下了一个按键,键盘会产生一个中断。操作系统需要做的是,保存当前的工作,处理中断,处理完成之后再恢复之前的工作。这里的保存和恢复工作,与我们之前看到的系统调用过程非常相似。所以系统调用,page fault,中断,都使用相同的机制。
但是中断又有一些不一样的地方,中断与系统调用主要有3个小的差别:
我们首先要关心的是,中断是从哪里产生的?
主板可以连接以太网卡,MicroUSB,MicroSD等,主板上的各种线路将外设和CPU连接在一起。这节课的大部分内容都会介绍当设备产生中断时CPU会发生什么,以及如何从设备读写数据。
下图是来自于SiFive有关处理器的文档,图中的右侧是各种各样的设备,例如UART0。我们在之前的课程已经知道UART0会映射到内核内存地址的某处,而所有的物理内存都映射在地址空间的0x80000000之上。类似于读写内存,通过向相应的设备地址执行load/store指令,我们就可以对例如UART的设备进行编程。
所有的设备都连接到处理器上,处理器上是通过Platform Level Interrupt Control,简称PLIC来处理设备中断。PLIC会管理来自于外设的中断。如果我们再进一步深入的查看PLIC的结构图,
从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由这些中断。图的右下角是CPU的核,PLIC会将中断路由到某一个CPU的核。如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态。
如果你看过了文档,这里的具体流程是:
通常来说,管理设备的代码称为驱动,所有的驱动都在内核中。我们今天要看的是UART设备的驱动,代码在uart.c文件中。如果我们查看代码的结构,我们可以发现大部分驱动都分为两个部分: bottom/top。
通常情况下,驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而Interrupt handler(bottom部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和CPU解耦开来。
对应的就是应用层开发常说的生产者消费者模式,可以看做是一个消息队列
通常对于Interrupt handler来说存在一些限制,因为它并没有运行在任何进程的context中,所以进程的page table并不知道该从哪个地址读写数据,也就无法直接从Interrupt handler读写数据。驱动的top部分通常与用户的进程交互,并进行数据的读写。我们后面会看更多的细节,这里是一个驱动的典型架构。
在很多操作系统中,驱动代码加起来可能会比内核还要大,主要是因为,对于每个设备,你都需要一个驱动,而设备又很多。
接下来我们看一下如何对设备进行编程。通常来说,编程是通过memory mapped I/O完成的:
下图中是SiFive主板中的对应设备的物理地址:
例如: 0x200_0000对应CLINT,0xC000000对应的是PLIC。在这个图中UART0对应的是0x1001_0000,但是在QEMU中,我们的UART0的地址略有不同,因为在QEMU中我们并不是完全的模拟SiFive主板,而是模拟与SiFive主板非常类似的东西。
以上就是Memory-mapped IO。
下图是UART的文档 --> 16550是QEMU模拟的UART设备,QEMU用这个模拟的设备来与键盘和Console进行交互:
这是一个很简单的芯片,图中表明了芯片拥有的寄存器:
UART可以让你能够通过串口发送数据bit,在线路的另一侧会有另一个UART芯片,能够将数据bit组合成一个个Byte。
这里还有一些其他可以控制的地方:
实际上对于一个寄存器,其中的每个bit都有不同的作用。
如果你写入数据到Transmit Holding Register,然后再次写入,那么前一个数据不会被覆盖掉吗?
当XV6启动时,Shell会输出提示符“ ”,如果我们在键盘上输入ls,最终可以看到“ ls”。我们接下来通过研究Console是如何显示出“
实际上“ ”和“ls”还不太一样,“ ”是Shell程序的输出,而“ls”是用户通过键盘输入之后再显示出来的。
对于“ ”来说,实际上就是设备会将字符传输给UART的寄存器,UART之后会在发送完字符之后产生一个中断。在QEMU中,模拟的线路的另一端会有另一个UART芯片(模拟的),这个UART芯片连接到了虚拟的Console,它会进一步将“ ”显示在console上。
UART在点对点配置中运行,其中两个设备直接连接使用两条数据线:一条用于发送数据(TX),一条用于接收数据(RX)。一个设备的TX线连接到另一个设备的RX线,反之亦然。这允许设备之间的双向通信。
另一方面,对于“ls”,这是用户输入的字符。键盘连接到了UART的输入线路,当你在键盘上按下一个按键,UART芯片会将按键字符通过串口线发送到另一端的UART芯片。另一端的UART芯片先将数据bit合并成一个Byte,之后再产生一个中断,并告诉处理器说这里有一个来自于键盘的字符。之后Interrupt handler会处理来自于UART的字符。我们接下来会深入通过这两部分来弄清楚这里是如何工作的。
RISC-V有许多与中断相关的寄存器:
接下来看看代码,首先是位于start.c的start函数:
// entry.S jumps here in machine mode on stack0.
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);
// 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");
}
这里将所有的中断都设置在Supervisor mode,然后设置SIE寄存器来接收External,软件和定时器中断,之后初始化定时器。
接下来我们看一下main函数中是如何处理External中断:
我们第一个外设是console,这是我们print的输出位置。查看位于console.c的consoleinit函数:
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,uartinit函数位于uart.c文件。这个函数实际上就是配置好UART芯片使其可以被使用。
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");
}
这里的流程是先关闭中断,之后设置波特率,设置字符长度为8bit,重置FIFO,最后再重新打开中断。
以上就是uartinit函数,运行完这个函数之后,原则上UART就可以生成中断了。但是因为我们还没有对PLIC编程,所以中断不能被CPU感知。最终,在main函数中,需要调用plicinit函数。下图是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地址(0xC000_0000)。代码的第一行使能了UART的中断,这里实际上就是设置PLIC会接收哪些中断,进而将中断路由到CPU。类似的,代码的第二行设置PLIC接收来自IO磁盘的中断,我们这节课不会介绍这部分内容。
main函数中,plicinit之后就是plicinithart函数。plicinit是由0号CPU运行,之后,每个CPU的核都需要调用plicinithart函数表明对于哪些外设中断感兴趣。
void
plicinithart(void)
{
int hart = cpuid();
// set uart's enable bit for this hart's S-mode.
*(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;
}
所以在plicinithart函数中,每个CPU的核都表明自己对来自于UART和VIRTIO的中断感兴趣。因为我们忽略中断的优先级,所以我们将优先级设置为0。
到目前为止,我们有了生成中断的外部设备,我们有了PLIC可以传递中断到单个的CPU。但是CPU自己还没有设置好接收中断,因为我们还没有设置好SSTATUS寄存器。在main函数的最后,程序调用了scheduler函数,
scheduler函数主要是运行进程。但是在实际运行进程之前,会执行intr_on函数来使得CPU能接收中断。
// enable device interrupts
static inline void
intr_on()
{
w_sstatus(r_sstatus() | SSTATUS_SIE);
}
intr_on函数只完成一件事情,就是设置SSTATUS寄存器,打开中断标志位。
在这个时间点,中断被完全打开了。如果PLIC正好有pending的中断,那么这个CPU核会收到中断。
以上就是中断的基本设置。
接下来看一下如何从Shell程序输出提示符“$ ”到Console。首先我们看init.c中的main函数,这是系统启动后运行的第一个进程。
首先这个进程的main函数创建了一个代表Console的设备。这里通过mknod操作创建了console设备。因为这是第一个打开的文件,所以这里的文件描述符0。之后通过dup创建stdout和stderr。这里实际上通过复制文件描述符0,得到了另外两个文件描述符1,2。最终文件描述符0,1,2都用来代表Console。
Shell程序首先打开文件描述符0,1,2。之后Shell向文件描述符2打印提示符“$ ”。
//sh.c
int
getcmd(char *buf, int nbuf)
{
fprintf(2, "$ ");
memset(buf, 0, nbuf);
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}
尽管Console背后是UART设备,但是从应用程序来看,它就像是一个普通的文件。Shell程序只是向文件描述符2写了数据,它并不知道文件描述符2对应的是什么。在Unix系统中,设备是由文件表示。我们来看一下这里的fprintf是如何工作的。
在printf.c文件中,代码只是调用了write系统调用,在我们的例子中,fd对应的就是文件描述符2,c是字符“$” :
static void
putc(int fd, char c)
{
write(fd, &c, 1);
}
所以由Shell输出的每一个字符都会触发一个write系统调用。之前我们已经看过了write系统调用最终会走到sysfile.c文件的sys_write函数。
uint64
sys_write(void)
{
struct file *f;
int n;
uint64 p;
// argfd: 通过fd从当前进程打开的文件列表中获取对应的file实例
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
return -1;
// fd,字符地址,写入长度
return filewrite(f, p, n);
}
这个函数中首先对参数做了检查,然后又调用了filewrite函数。filewrite函数位于file.c文件中。
// Write to file f.
// addr is a user virtual address.
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){
//对设备写入的处理---major是主设备号
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
// 设备数组,每个设备将自己注册到该设备数组中---其实就是驱动的注册
// 这里可以参考上面的consoleinit
ret = devsw[f->major].write(1, addr, n);
} else if(f->type == FD_INODE){
//后面文件系统章节再看
...
} else {
panic("filewrite");
}
return ret;
}
在filewrite函数中首先会判断文件描述符的类型。mknod生成的文件描述符属于设备(FD_DEVICE),而对于设备类型的文件描述符,我们会为这个特定的设备执行设备相应的write函数。因为我们现在的设备是Console,所以我们知道这里会调用console.c中的consolewrite函数。
//
// user write()s to the console go here.
//
int
//参数: src地址是否来源于用户地址空间,数据源地址,数据长度
consolewrite(int user_src, uint64 src, int n)
{
int i;
acquire(&cons.lock);
// 依次拷贝每个字符
for(i = 0; i < n; i++){
char c;
// 依次将每个字符从src源地址处拷贝到c变量地址中
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
// 写入到uart进行字符输出
uartputc(c);
}
release(&cons.lock);
return i;
}
// Copy from either a user address, or kernel address,
// depending on usr_src.
// Returns 0 on success, -1 on error.
int
either_copyin(void *dst, int user_src, uint64 src, uint64 len)
{
struct proc *p = myproc();
// 如果源地址来源于用户态地址空间,那么使用当前进程的用户态页表进行地址翻译
if(user_src){
return copyin(p->pagetable, dst, src, len);
} else {
memmove(dst, (char*)src, len);
return 0;
}
}
这里先通过either_copyin将字符拷入,之后调用uartputc函数。uartputc函数将字符写入给UART设备,所以你可以认为consolewrite是一个UART驱动的top部分。uart.c文件中的uartputc函数会实际的打印字符。
// 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().
void
uartputc(int c)
{
acquire(&uart_tx_lock);
if(panicked){
for(;;)
;
}
while(1){
// uart_tx_w: 写指针 , uart_tx_r: 读指针
// 如果写指针+1等于读指针,说明缓冲区满了--此时不能进行写入,需要等待
if(((uart_tx_w + 1) % UART_TX_BUF_SIZE) == uart_tx_r){
// buffer is full.
// wait for uartstart() to open up space in the buffer.
// 当前进程阻塞等待在uart_tx_r条件变量上
sleep(&uart_tx_r, &uart_tx_lock);
} else {
//向环形缓冲区写入一个字符
uart_tx_buf[uart_tx_w] = c;
//写指针前推
uart_tx_w = (uart_tx_w + 1) % UART_TX_BUF_SIZE;
//让uart开始工作
uartstart();
release(&uart_tx_lock);
return;
}
}
}
uartputc函数会稍微有趣一些。在UART的内部会有一个buffer用来发送数据,buffer的大小是32个字符。同时还有一个为consumer提供的读指针和为producer提供的写指针,来构建一个环形的buffer(注,或者可以认为是环形队列)。
// 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++]
在我们的例子中,Shell是producer,所以需要调用uartputc函数。
// 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.
void
uartstart()
{
while(1){
//判断缓冲区是否为空
if(uart_tx_w == uart_tx_r){
// transmit buffer is empty.
return;
}
//读取LSR寄存器,获取其第五位,判断是否空闲
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_r = (uart_tx_r + 1) % UART_TX_BUF_SIZE;
// maybe uartputc() is waiting for space in the buffer.
// 唤醒阻塞在uart_tx_r条件变量上的进程
wakeup(&uart_tx_r);
// 向THR寄存器写入一个字符
WriteReg(THR, c);
}
}
uartstart就是通知设备执行操作:
接下来我们看一下,当发生中断时,实际会发生什么。
在我们向Console输出字符时,如果发生了中断,RISC-V会做什么操作?
假设键盘生成了一个中断并且发向了PLIC,PLIC会将中断路由给一个特定的CPU核,并且如果这个CPU核设置了SIE寄存器的E bit(注,针对外部中断的bit位),那么会发生以下事情:
注:
接下来看一下trap.c文件中的usertrap函数,我们在lec06和lec08分别在这个函数中处理了系统调用和page fault。今天我们将要看一下如何处理中断。
在trap.c的devintr函数中,首先会通过SCAUSE寄存器判断当前中断是否是来自于外设的中断。如果是的话,再调用plic_claim函数来获取中断。
// check if it's an external interrupt or software interrupt,
// and handle it.
// returns 2 if timer interrupt,
// 1 if other device,
// 0 if not recognized.
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.
// 读取claim寄存器,以获取待处理的中断源
int irq = plic_claim();
// 判断是否是uart外部中断源
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 == 0x8000000000000001L){
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
// m态下的时钟中断
if(cpuid() == 0){
clockintr();
}
// acknowledge the software interrupt by clearing
// the SSIP bit in sip.
w_sip(r_sip() & ~2);
return 2;
} else {
return 0;
}
}
plic_claim函数位于plic.c文件中。在这个函数中,当前CPU核会告知PLIC,自己要处理中断,PLIC_SCLAIM会将中断号返回,对于UART来说,返回的中断号是10。
// ask the PLIC what interrupt we should serve.
int
plic_claim(void)
{
int hart = cpuid();
int irq = *(uint32*)PLIC_SCLAIM(hart);
return irq;
}
从devintr函数可以看出,如果是UART中断,那么会调用uartintr函数。位于uart.c文件的uartintr函数,会从UART的接受寄存器中读取数据,之后将获取到的数据传递给consoleintr函数。哦,不好意思,我搞错了。我们现在讨论的是向UART发送数据。因为我们现在还没有通过键盘输入任何数据,所以UART的接受寄存器现在为空。
// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from trap.c.
void
// 当发生uart中断的时候,有两种可能: 键盘中断发生,或者数据传输完成,可以进行下一次传输了
uartintr(void)
{
// read and process incoming characters.
// 1. 检查键盘中断是否发生
while(1){
// uart的RHR寄存器中是否有可读数据,如果没有返回-1
int c = uartgetc();
if(c == -1)
break;
// 如果存在可读数据,传输给consoleintr进行输出
consoleintr(c);
}
// send buffered characters.
// 2. 检查是否是数据传输完成,可以进行下一次数据传输了
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}
所以代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“”之后,Shell还会输出一个空格字符,write系统调用可以在UART发送提示符“”的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个空格字符送出。
这样,驱动的top部分和bottom部分就解耦开了。
UART对于键盘来说很重要,来自于键盘的字符通过UART走到CPU再到我们写的代码。但是我不太理解UART对于Shell输出字符究竟有什么作用?因为在这个场景中,并没有键盘的参与。
uartinit只被调用了一次,所以才导致了所有的CPU核都共用一个buffer吗?
我们之所以需要锁是因为有多个CPU核,但是却只有一个Console,对吧?
那是不是意味着,某个时间,其他所有的CPU核都需要等待某一个CPU核的处理?
还要注意一点: 串口uart的写线是连接到屏幕,读线是连接到键盘,所以对RHR寄存器的读是读取键盘输入,对THR寄存器的写是向屏幕输出
接下来我们讨论一下与中断相关的并发,并发加大了中断编程的难度。这里的并发包括以下几个方面:
这里我将会关注在第一点,也就是producer/consumser并发。这是驱动中的非常常见的典型现象。如你们所见的,在驱动中会有一个buffer,在我们之前的例子中,buffer是32字节大小。并且有两个指针,分别是读指针和写指针。
如果两个指针相等,那么buffer是空的。当Shell调用uartputc函数时,会将字符,例如提示符“$”,写入到写指针的位置,并将写指针加1。这就是producer对于buffer的操作。
producer可以一直写入数据,直到写指针 + 1等于读指针,因为这时,buffer已经满了。当buffer满了的时候,producer必须停止运行。我们之前在uartputc函数中看过,如果buffer满了,代码会sleep,暂时搁置Shell并运行其他的进程。
Interrupt handler,也就是uartintr函数,在这个场景下是consumer,每当有一个中断,并且读指针落后于写指针,uartintr函数就会从读指针中读取一个字符再通过UART设备发送,并且将读指针加1。当读指针追上写指针,也就是两个指针相等的时候,buffer为空,这时就不用做任何操作。
这里的buffer对于所有的CPU核都是共享的吗?
对于uartputc中的sleep,它怎么知道应该让Shell去sleep?
以上就是Shell输出提示符“$ ”的全部内容。如你们所见,过程还挺复杂的,许多代码一起工作才将这两个字符传输到了Console。
在UART的另一侧,会有类似的事情发生,有时Shell会调用read从键盘中读取字符。 在read系统调用的底层,会调用fileread函数。在这个函数中,如果读取的文件类型是设备,会调用相应设备的read函数。
// Read from file f.
// addr is a user virtual address.
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){
//与file_write一样,如果当前文件类型是设备,那么通过主设备号,从设备数组中定位的设备驱动
//调用驱动的read方法,完成数据的读取
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
return -1;
// 第一个参数传入1表示addr是来自用户态的虚拟地址
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;
}
在我们的例子中,read函数就是console.c文件中的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.
//
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.
// 当缓存区为空时,就阻塞在cons.r条件变量上,直到被唤醒
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];
//这里的宏定义C是'D'-'@'字符=EOT传输结束字符
//键盘输入方式为Ctrl+D
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;
}
// Copy to either a user address, or kernel address,
// depending on usr_dst.
// Returns 0 on success, -1 on error.
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;
}
}
这里与UART类似,也有一个buffer,包含了128个字符。其他的基本一样,也有producer和consumser。但是在这个场景下Shell变成了consumser,因为Shell是从buffer中读取数据。而键盘是producer,它将数据写入到buffer中。
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;
从consoleread函数中可以看出,当读指针和写指针一样时,说明buffer为空,进程会sleep。所以Shell在打印完“$ ”之后,如果键盘没有输入,Shell进程会sleep,直到键盘有一个字符输入。所以在某个时间点,假设用户通过键盘输入了“l”,这会导致“l”被发送到主板上的UART芯片,产生中断之后再被PLIC路由到某个CPU核,之后会触发devintr函数,devintr可以发现这是一个UART中断,然后通过uartgetc函数获取到相应的字符,之后再将字符传递给consoleintr函数。
//
// the console input interrupt handler.
// uartintr() calls this for input character.
// do erase/kill processing, append to cons.buf,
// wake up consoleread() if a whole line has arrived.
//
void
consoleintr(int c)
{
acquire(&cons.lock);
switch(c){
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.
// 同步更新写指针
cons.w = cons.e;
// 唤醒阻塞在cons.r条件变量上的进程
wakeup(&cons.r);
}
}
break;
}
release(&cons.lock);
}
//
// send one character to the uart.
// called by printf, and to echo input characters,
// but not from write().
//
void
consputc(int c)
{
if(c == BACKSPACE){
// if the user typed backspace, overwrite with a space.
//这意味着用户按下了退格键。在这种情况下,函数会通过向 UART 发送三个字符来覆盖退格键字符:
//先发送一个退格字符 ('\b') 将光标移回,然后发送一个空格字符以擦除前一个字符,最后再发送一个退格字符将光标再次移回。
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
uartputc_sync(c);
}
}
// alternate version of uartputc() that doesn't
// use interrupts, for use by kernel printf() and
// to echo characters. it spins waiting for the uart's
// output register to be empty.
void
uartputc_sync(int c)
{
push_off();
if(panicked){
for(;;)
;
}
// wait for Transmit Holding Empty to be set in LSR.
// 不断轮询直到THR寄存器空闲,则进行字符写入操作
while((ReadReg(LSR) & LSR_TX_IDLE) == 0)
;
WriteReg(THR, c);
pop_off();
}
默认情况下,字符会通过consputc,输出到console上给用户查看。之后,字符被存放在buffer中。在遇到换行符的时候,唤醒之前sleep的进程,也就是Shell,再从buffer中将数据读出。
所以这里也是通过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度,独立的并行运行。如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来
最后我想介绍一下Interrupt在最近几十年的演进。当Unix刚被开发出来的时候,Interrupt处理还是很快的。这使得硬件可以很简单,当外设有数据需要处理时,硬件可以中断CPU的执行,并让CPU处理硬件的数据。
而现在,中断相对处理器来说变慢了。从前面的介绍可以看出来这一点,需要很多步骤才能真正的处理中断数据。如果一个设备在高速的产生中断,处理器将会很难跟上。所以如果查看现在的设备,可以发现,现在的设备相比之前做了更多的工作。所以在产生中断之前,设备上会执行大量的操作,这样可以减轻CPU的处理负担。所以现在硬件变得更加复杂。
如果你有一个高性能的设备,例如你有一个千兆网卡,这个网卡收到了大量的小包,网卡每秒可以生成1.5Mpps,这意味着每一个微秒,CPU都需要处理一个中断,这就超过了CPU的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
这里的解决方法就是使用polling。除了依赖Interrupt,CPU可以一直读取外设的控制寄存器,来检查是否有数据。对于UART来说,我们可以一直读取RHR寄存器,来检查是否有数据。现在,CPU不停的在轮询设备,直到设备有了数据。
这种方法浪费了CPU cycles,当我们在使用CPU不停的检查寄存器的内容时,我们并没有用CPU来运行任何程序。在我们之前的例子中,如果没有数据,内核会让Shell进程sleep,这样可以运行另一个进程。
所以,对于一个慢设备,你肯定不想一直轮询它来得到数据。我们想要在没有数据的时候切换出来运行一些其他程序。但是如果是一个快设备,那么Interrupt的overhead也会很高,那么我们在polling设备的时候,是经常能拿到数据的,这样可以节省进出中断的代价。
所以对于一个高性能的网卡,如果有大量的包要传入,那么应该用polling。对于一些精心设计的驱动,它们会在polling和Interrupt之间动态切换(注,也就是网卡的NAPI)。
下面是xv6对应教材的总结,大家阅读完上面的内容后,可以再通过书本内容进行回顾:
控制台驱动程序(console.c)是驱动程序结构的简单说明。控制台驱动程序通过连接到RISC-V的UART串口硬件接受人们键入的字符。控制台驱动程序一次累积一行输入,处理如backspace
和Ctrl-u
的特殊输入字符。用户进程,如Shell,使用read
系统调用从控制台获取输入行。当您在QEMU中通过键盘输入到xv6时,您的按键将通过QEMU模拟的UART硬件传递到xv6。
驱动程序管理的UART硬件是由QEMU仿真的16550芯片。在真正的计算机上,16550将管理连接到终端或其他计算机的RS232串行链路。运行QEMU时,它连接到键盘和显示器。
UART硬件在软件中看起来是一组内存映射的控制寄存器。也就是说,存在一些RISC-V硬件连接到UART的物理地址,以便载入(load)和存储(store)操作与设备硬件而不是内存交互:
0x10000000
或UART0
(kernel/memlayout.h:21)。Xv6的main
函数调用consoleinit
(kernel/console.c:184)来初始化UART硬件:
xv6的shell通过init.c (user/init.c:19)中打开的文件描述符从控制台读取输入:
read
的调用实现了从内核流向consoleread
(kernel/console.c:82)的数据通路。consoleread
等待输入到达(通过中断)并在cons.buf
中缓冲,将输入复制到用户空间,然后(在整行到达后)返回给用户进程。sleep
系统调用中等待(kernel/console.c:98)(第7章解释了sleep
的细节)。控制台输入的整个流程如下:
devintr
(kernel/trap.c:177),它查看RISC-V的scause
寄存器,发现中断来自外部设备。devintr
调用uartintr
。uartintr
(kernel/uart.c:180)从UART硬件读取所有等待输入的字符,并将它们交给consoleintr
(kernel/console.c:138);consoleintr
的工作是在cons.buf中积累输入字符,直到一整行到达。consoleintr
对backspace
和其他少量字符进行特殊处理。consoleintr
唤醒一个等待的consoleread
(如果有的话)。consoleread
将监视cons.buf中的一整行,将其复制到用户空间,并返回(通过系统调用机制)到用户空间。在连接到控制台的文件描述符上执行write
系统调用,最终将到达uartputc
(kernel/uart.c:87) 。设备驱动程序维护一个输出缓冲区(uart_tx_buf
),这样写进程就不必等待UART完成发送;相反,uartputc
将每个字符附加到缓冲区,调用uartstart
来启动设备传输(如果还未启动),然后返回。导致uartputc
等待的唯一情况是缓冲区已满。
每当UART发送完一个字节,它就会产生一个中断。uartintr
调用uartstart
,检查设备是否真的完成了发送,并将下一个缓冲的输出字符交给设备。因此,如果一个进程向控制台写入多个字节,通常第一个字节将由uartputc
调用uartstart
发送,而剩余的缓冲字节将由uartintr
调用uartstart
发送,直到传输完成中断到来。
需要注意,这里的一般模式是通过缓冲区和中断机制将设备活动与进程活动解耦:
这种解耦可以通过允许进程与设备I/O并发执行来提高性能,当设备很慢(如UART)或需要立即关注(如回声型字符(echoing typed characters))时,这种解耦尤为重要。这种想法有时被称为I/O并发。
你或许注意到了在consoleread
和consoleintr
中对acquire
的调用。这些调用获得了一个保护控制台驱动程序的数据结构不受并发访问的锁。这里有三种并发风险:
consoleread
;consoleread
正在执行时要求CPU传递控制台中断;consoleread
时向其他CPU传递控制台中断。第6章探讨了锁在这些场景中的作用。在驱动程序中需要注意并发的另一种场景是,一个进程可能正在等待来自设备的输入,但是输入的中断信号可能是在另一个进程(或者根本没有进程)正在运行时到达的。因此中断处理程序不允许考虑他们已经中断的进程或代码。
copyout
(注:因为你不知道是否发生了进程切换,当前进程可能并不是原先的进程)。中断处理程序通常做相对较少的工作(例如,只需将输入数据复制到缓冲区),并唤醒上半部分代码来完成其余工作。