6.S081-7中断(输入输出) - Interrupts
文章目录
本节课的主要内容:
-
console中的提示符“$ ”是如何显示出来的;
-
如果你在键盘输入“ls”,这些字符是怎么最终在console中显示出来的。
仔细越读以下内容,将会对理解接下来的课程有帮助:
-
缩写:本节中IRQ = Interupt ReQuest(中断)。
-
UART0:是一个硬件设备,连接了console和keyboard,操作系统通过UART和这两个设备进行交互。console就是接收一个字符,然后够输出到屏幕上的设备。
-
因为只有一个UART设备,一个buffer只针对一个UART设备,而这个buffer会被所有的CPU核共享,这样运行在多个CPU核上的多个程序可以同时向Console打印输出,而驱动中是通过锁来确保多个CPU核上的程序串行的向Console打印输出。
1. 复习虚拟内存(上节课知识点)
Linux环境下使用top
命令查看,可以得到如下信息👇(图为课题组服务器)
从图中的这一行👇可以看出,总内存占有量:11512011, 空闲的内存只有571384(大部分都被占用了used + buff/cache)。
KiB Mem : 11512011+total, 571384 free, 10159904+used, 12949688 buff/cache
解释一下buff/cache: 因为我们想要操作系统的物理内存尽可能高的利用率,因此我们将闲置的内存当作buff / cache。
以上是一个非常常见的场景,大部分操作系统运行时几乎没有任何空闲的内存。这意味着,如果应用程序或者内核需要使用新的内存,那么我们需要丢弃一些已有的内容。 —— 当当前进程需要比目前系统中free的内容还要多的时候:需要首先从used 或者 buff/ cache中撤回一部分已经被占用的内存。
VIRT是虚拟内存大小(理论上已经分配的页),RES是分配的真实值(实际上物理页大小)——可以看出RES远小于VIRT。所以,我们上节课讨论的基于虚拟内存和page fault提供的非常酷的功能在这都有使用,比如说demand paging。
2. 硬件触发中断 + 驱动管理(接口)
中断的场景:硬件想要得到OS的关注。—— 形成中断后,OS需要保护现场,处理中断,恢复现场。
比如网卡收到package,键盘被按下…
中断和系统调用的区别?
- asynchronous —— 中断并不是由当前运行的任何一个进程触发的,而是硬件触发的。
- concurrency—— CPU和引发中断的设备是真正的并行(各自在运行)
- program device——需要被编程——比如网卡,UART等——需要处理专门的寄存器,信号转换,缓存等 —— 这个其实就是本科的《汇编与微机原理》的驱动程序(管理设备的代码)
简单描述一下驱动程序:分为top和bottom两个部分。
- top是用户进程,或者内核其他部分调用的接口,有read/write接口。
- bottom 是Interrupt handler (CPU调用Interrupt handler来处理中断)。
这里关于具体的实现,本科的微机原理十分详细,不过我已经忘了大半。。。。需要注意的是,这里的设备使用的是独立的地址空间,因此不能直接让进程操作虚拟内存页进行数据操作,需要通过Memory-mapped IO,来进行。
3. 设置中断(通过设置寄存器,让CPU为可以接受中断的状态)
RISC-V有许多与中断相关的寄存器:
- SIE(Supervisor Interrupt Enable)寄存器。这个寄存器中有一个bit(E)专门针对例如UART的外部设备的中断;有一个bit(S)专门针对软件中断,软件中断可能由一个CPU核触发给另一个CPU核;还有一个bit(T)专门针对定时器中断。我们这节课只关注外部设备的中断。
- SSTATUS(Supervisor Status)寄存器。这个寄存器中有一个bit来打开或者关闭中断。每一个CPU核都有独立的SIE和SSTATUS寄存器,除了通过SIE寄存器来单独控制特定的中断,还可以通过SSTATUS寄存器中的一个bit来控制所有的中断。
- SIP(Supervisor Interrupt Pending)寄存器。当发生中断时,处理器可以通过查看这个寄存器知道当前是什么类型的中断。
- SCAUSE寄存器,这个寄存器我们之前看过很多次。它会表明当前状态的原因是中断。
- STVEC寄存器,它会保存当trap,page fault或者中断发生时,CPU运行的用户程序的程序计数器,这样才能在稍后恢复程序的运行。
接下来我们看看XV6是如何对其他寄存器进行编程,使得CPU处于一个能接受中断的状态。
看一下console的$是如何输出的👇
start.c中的start函数(entry.S jumps here in machine mode on stack0.),其中的w_XXX
代表的是写XXX寄存器命令。
// 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);
// 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中断👇(很明显,第一行就是consoleinit();
,紧接着就是printfinit();
)
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
binit(); // buffer cache
iinit(); // inode cache
fileinit(); // file table
virtio_disk_init(minor(ROOTDEV)); // emulated hard disk
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}
scheduler();
}
看一下consoleinit(void);
—— 这里我们先不管锁🔒的内容。所以第一条函数是uartinit();
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();
👇,它首先关闭中断,设置波特率,设置字符长度为8bit,重置FIFO,最后再重新打开中断。
void
uartinit(void)
{
// disable interrupts.
WriteReg(IER, 0x00);
// special mode to set baud rate.
WriteReg(LCR, 0x80);
// 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, 0x03);
// reset and enable FIFOs.
WriteReg(FCR, 0x07);
// enable receive interrupts.
WriteReg(IER, 0x01);
}
以上就是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可以接收UART的中断,第二行是可以接受磁盘IO的中断。
**解释PLIC:**所有的设备都连接到处理器上,处理器上是通过Platform Level Interrupt Control,简称PLIC来处理设备中断。**PLIC会管理来自于外设的中断。**PLIC与外设一样,也占用了一个I/O地址(0xC000_0000)。
具体流程如下👇
-
PLIC会通知当前有一个待处理的中断;
-
其中一个CPU核会Claim接收中断,这样PLIC就不会把中断发给其他的CPU处理;
-
CPU核处理完中断之后,CPU会通知PLIC;
-
PLIC将不再保存中断的信息。
注意在main中👇——plicinit();
之后就是plicinithart();
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
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函数👇可以看到,该函数主要是运行进程,但是进程运行前,会先执行iter_on()
;
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns. It loops, doing:
// - choose a process to run.
// - swtch to start running that process.
// - eventually that process transfers control
// via swtch back to the scheduler.
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
// Avoid deadlock by ensuring that devices can interrupt.
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->scheduler, &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){
intr_on();
asm volatile("wfi");
}
}
}
其中iter_on()
定义如下👇,它只做一件事:设置SSTATUS寄存器,打开中断标志位。
// enable device interrupts
static inline void
intr_on()
{
w_sstatus(r_sstatus() | SSTATUS_SIE);
}
4. $
的输出——UART驱动的top部分
接下来我想看一下如何从Shell程序输出提示符“$ ”到Console。首先我们看init.c中的main函数,这是系统启动后运行的第一个进程。
- 运行在用户空间 —— 启动的第一个进程,进程号为1,init进程,然后它会fork出来进程号为2的shell进程 —— 这些都是CSAPP + 本课程前面的内容了,不再详细描述。
这里真正关注的是,首先打开了一个叫做console的设备+通过mknod创建了console设备(其实就是把驱动挂载到内核中——本科嵌入式的内容。)
因为这里的open是第一次打开的文件,因此文件描述符fd = 0,然后通过dup创建了fd = 1, fd = 2 (也就是说,其实0,1,2都是代表console文件(其实是console设备,但是通过挂载技术,OS让设备可以将设备虚拟成文件来操作))。
char *argv[] = { "sh", 0 };
int
main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", 1, 1);
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);
}
while((wpid=wait(0)) >= 0 && wpid != pid){
//printf("zombie!\n");
}
}
}
再来看shell程序是做了什么👇
- 首先确认0,1,2是否都打开了
- 运行
gtcmd
—— 可以看到这里输出了$
尽管Console背后是UART设备,但是从应用程序来看,它就像是一个普通的文件。Shell程序只是向文件描述符2写了数据,它并不知道文件描述符2对应的是什么。在Unix系统中,设备是由文件表示。
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;
}
int
main(void)
{
static char buf[100];
int fd;
// Ensure that three file descriptors are open.
while((fd = open("console", O_RDWR)) >= 0){
if(fd >= 3){
close(fd);
break;
}
}
// Read and run input commands.
while(getcmd(buf, sizeof(buf)) >= 0){
if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
// Chdir must be called by the parent, not the child.
buf[strlen(buf)-1] = 0; // chop \n
if(chdir(buf+3) < 0)
fprintf(2, "cannot cd %s\n", buf+3);
continue;
}
if(fork1() == 0)
runcmd(parsecmd(buf));
wait(0);
}
exit(0);
}
继续来看write(2, "$ ", 2);
前面的实验(6.S081 附加Lab1 用户执行系统调用的过程(Trap))中,已经看过系统调用的过程,它是调用了sys_write
实现的👇
uint64
sys_write(void)
{
struct file *f;
int n;
uint64 p;
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
return -1;
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){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
ret = devsw[f->major].write(f, 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(f->ip->dev);
ilock(f->ip);
if ((r = writei(f->ip, 1, addr + i, f->off, n1)) > 0)
f->off += r;
iunlock(f->ip);
end_op(f->ip->dev);
if(r < 0)
break;
if(r != n1)
panic("short filewrite");
i += r;
}
ret = (i == n ? n : -1);
} else {
panic("filewrite");
}
return ret;
}
显然是执行了这一部分👇——针对不同的devs,我们有不同的write函数,这里是console,因此对应的是consolewrite
函数。
else if(f->type == FD_DEVICE){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
ret = devsw[f->major].write(f, 1, addr, n);
}
// map major device number to device functions.
struct devsw {
int (*read)(struct file *, int, uint64, int);
int (*write)(struct file *, int, uint64, int);
};
consolewrite
对应的是UART驱动的top部分,这里的either_copyin
的意思是copy (either from kernel or user——depending on src),也就是从某个空间取得一个字符,赋值到c。——然后运行consputs(c);
//
// user write()s to the console go here.
//
int
consolewrite(struct file *f, int user_src, uint64 src, int n)
{
int i;
acquire(&cons.lock);
for(i = 0; i < n; i++){
char c;
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
consputc(c);
}
release(&cons.lock);
return n;
}
consputs(c);
如下👇,它首先判断是否panicked然后处理特出字符(backspace等),然后调用了uartput(c)
来实现普通字符的输出。
//
// send one character to the uart.
//
void
consputc(int c)
{
extern volatile int panicked; // from printf.c
if(panicked){
for(;;)
;
}
if(c == BACKSPACE){
// if the user typed backspace, overwrite with a space.
uartputc('\b'); uartputc(' '); uartputc('\b');
} else {
uartputc(c);
}
}
uartputc
如下👇(这里已经是驱动的top部分了),这里很像一个生产者消费者模型,即producer生产内容,放到buffer里面(buffer是一个循环的buffer),首先判断队列是否满了,如果是的话,需要等待(生产者消费者的多线程,阻塞),如果buffer不是满的,就将c写入队列,并更新写指针,之后调用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().
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.
sleep(&uart_tx_r, &uart_tx_lock);
} else {
uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
uart_tx_w += 1;
uartstart();
release(&uart_tx_lock);
return;
}
}
}
uartstart();
如下👇uartstart就是通知设备执行操作。首先是检查当前设备是否空闲,如果空闲的话,我们会从buffer中读出数据,然后将数据写入到THR(Transmission Holding Register)发送寄存器。这里相当于告诉设备,我这里有一个字节需要你来发送。一旦数据送到了设备,系统调用会返回,用户应用程序Shell就可以继续执行。这里从内核返回到用户空间的机制与之前课程中的trap机制是一样的。
// 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;
}
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);
}
}
与此同时,UART设备会将数据送出。在某个时间点,我们会收到中断,因为我们之前设置了要处理UART设备中断。接下来我们看一下,当发生中断时,实际会发生什么。
其实,我看了一下19年的OS源码,内容是这样的👇(没有uartstart,也没有锁,直接进行写入操作(不过只能写入单字符))——上面我们讲的内容是先放到缓存里,再去操作,因此会有可能需要生产者消费者模型,而这里要更加简单,因为是单字符操作。
// write one output character to the UART.
void
uartputc(int c)
{
// wait for Transmit Holding Empty to be set in LSR.
while((ReadReg(LSR) & (1 << 5)) == 0)
;
WriteReg(THR, c);
}
5. $
的输出——UART驱动的bottom部分
如果发生了中断,RISC-V会如何操作?
-
首先,会清除SIE寄存器相应的bit,这样可以阻止CPU核被其他中断打扰,该CPU核可以专心处理当前中断。处理完成之后,可以再次恢复SIE寄存器相应的bit。
-
之后,会设置SEPC寄存器为当前的程序计数器。我们假设Shell正在用户空间运行,突然来了一个中断,那么当前Shell的程序计数器会被保存。
-
之后,要保存当前的mode。在我们的例子里面,因为当前运行的是Shell程序,所以会记录user mode。
-
再将mode设置为Supervisor mode。
-
最后将程序计数器的值设置成STVEC的值。(注,STVEC用来保存trap处理程序的地址,详见lec06)在XV6中,STVEC保存的要么是uservec或者kernelvec函数的地址,具体取决于发生中断时程序运行是在用户空间还是内核空间。在我们的例子中,Shell运行在用户空间,所以STVEC保存的是uservec函数的地址。而从之前的课程我们可以知道uservec函数会调用usertrap函数。所以最终,我们在usertrap函数中。我们这节课不会介绍trap过程中的拷贝,恢复过程,因为在之前的课程中已经详细的介绍过了。
看一下trap.c中的usertrap如何处理中断👇——通过调用devintr()函数判断是否是外部中断(或者是软件中断)
} else if((which_dev = devintr()) != 0) {
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
printf("page down:%d\n",PGROUNDDOWN(r_stval()));
p->killed = 1;
}
devintr()
如下👇,首先调用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.
int irq = plic_claim();
if(irq == UART0_IRQ){
uartintr();
} else if(irq == VIRTIO0_IRQ || irq == VIRTIO1_IRQ ){
virtio_disk_intr(irq - VIRTIO0_IRQ);
}
plic_complete(irq);
return 1;
} else if(scause == 0x8000000000000001L){
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
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 + 0x201004);
int irq = *(uint32*)PLIC_SCLAIM(hart);
return irq;
}
此外,从devintr()
可以看到👇,即,如果是UART0触发的中断,那么会调用uartintr();
(应该是中断处理程序)
if(irq == UART0_IRQ) {
uartintr();
}
uartintr();
程序如下👇,因为这里我们还没有输入内容,因此没有所以代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“
”之后,
S
h
e
l
l
还会输出一个空格字符,
w
r
i
t
e
系统调用可以在
U
A
R
T
发送提示符“
”之后,Shell还会输出一个空格字符,write系统调用可以在UART发送提示符“
”之后,Shell还会输出一个空格字符,write系统调用可以在UART发送提示符“”的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个空格字符送出。
这样,驱动的top部分和bottom部分就解耦开了。
// 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 trap.c.
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);
}
6. 中断的并发(生产者-消费者模型)
我们之所以需要锁是因为有多个CPU核,但是却只有一个Console。
驱动的top和bottom部分可以并行的运行。所以一个CPU核可以执行uartputc函数,而另个一CPU核可以执行uartintr函数,我们需要确保它们是串行执行的,而锁确保了这一点。
学生提问:那是不是意味着,某个时间,其他所有的CPU核都需要等待某一个CPU核的处理?
Frans教授:这里并不是死锁。其他的CPU核还是可以在等待的时候运行别的进程。
这里主要讲的是生产者 - 消费者模型(上面的uart的buffer是一个环形队列),关于这部分,可以看前面OS的代码(uartstart
和uartputsc
),但是这里会可能会更加详细,并且易于理解:我的博客 C++ 实现生产者和消费者(并发)。
这里注意:
学生提问:对于uartputc中的sleep,它怎么知道应该让Shell去sleep?
Frans教授: sleep会将当前在运行的进程存放于sleep数据中 (我猜应该指的就是阻塞队列中)。它传入的参数是需要等待的信号,在这个例子中传入的是uart_tx_r的地址。在uartstart函数中**,一旦buffer中有了空间,会调用与sleep对应的函数wakeup,传入的也是uart_tx_r的地址。任何等待在这个地址的进程都会被唤醒。**有时候这种机制被称为conditional synchronization。
7. ls
—— UART读取键盘输入
在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){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
return -1;
r = devsw[f->major].read(f, 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函数,该函数源码👇
//
// 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(struct file *f, 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(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
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;
}
这里与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函数。
默认情况下,字符会通过consputc,输出到console上给用户查看。之后,字符被存放在buffer中。在遇到换行符的时候,唤醒之前sleep的进程,也就是Shell,再从buffer中将数据读出。
所以这里也是通过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度,独立的并行运行。如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来。
8. 中断的演化(略)
对于现在的CPU来说,中断速度太慢了 —— 如果一个高速产生中断那么中断处理速度将会成为一个瓶颈,比如千兆网卡。
这个网卡收到了大量的小包,网卡每秒可以生成1.5Mpps,这意味着每一个微秒,CPU都需要处理一个中断,这就超过了CPU的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
据。而键盘是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函数。
默认情况下,字符会通过consputc,输出到console上给用户查看。之后,字符被存放在buffer中。在遇到换行符的时候,唤醒之前sleep的进程,也就是Shell,再从buffer中将数据读出。
所以这里也是通过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度,独立的并行运行。如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来。
8. 中断的演化(略)
对于现在的CPU来说,中断速度太慢了 —— 如果一个高速产生中断那么中断处理速度将会成为一个瓶颈,比如千兆网卡。
这个网卡收到了大量的小包,网卡每秒可以生成1.5Mpps,这意味着每一个微秒,CPU都需要处理一个中断,这就超过了CPU的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
这里的解决方法就是使用polling。除了依赖Interrupt,CPU可以一直读取外设的控制寄存器,来检查是否有数据。对于UART来说,我们可以一直读取RHR寄存器,来检查是否有数据。现在,CPU不停的在轮询设备,直到设备有了数据。