Traps in XV6
在riscv中,在以下3种情况下会进入traps:
- 系统调用,当用户程序执行
ecall
指令要求进入内核态的时候。 - 异常:(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址。
- 设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注。
和traps有关的寄存器有以下几种,在机器模式下处理traps有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。
stvec
:内核在这里写入其陷阱处理程序的地址;发生trps的时候就会跳转到这个函数入口。sepc
:当发生陷阱时,RISC-V会在这里保存程序计数器pc
(因为pc
会被stvec
覆盖)。sret
(从陷阱返回)指令会将sepc
复制到pc
。内核可以写入sepc
来控制sret
的去向。这个值往往需要单独保存,因为可能会发生中断嵌入。scause
:产生taps原因的数字。sscratch
:内核在这里放置了trampframe的地址,用于在进入内核态的时候保存用户态的上下文。sstatus
:其中的 SIE 位控制设备中断是否启用。如果内核清空SIE ,RISC-V将推迟设备中断,直到内核重新设置SIE 。SPP 位指示陷阱是来自用户模式还是管理模式,并控制sret
返回的模式。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
- 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
- 清除SIE以禁用中断。
- 将
pc
复制到sepc
。 - 将当前模式(用户或管理)保存在状态的SPP位中。
- 设置
scause
以反映产生陷阱的原因。 - 将模式设置为管理模式。
- 将
stvec
复制到pc
。 - 在新的
pc
上开始执行。
CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc
之外的任何寄存器。内核软件必须执行这些任务。
下面从代码分析一下过程,一般进入traps分为从用户空间进入和从内核空间进入。
从用户态陷入
从用户态陷入的过程大致如同所示,其中绿色的部分的代码为汇编,在trampoline页中。相同的映射到了用户页表和内核页表,因此可以在此切换页表而不会出现问题。
从用户态陷入内核态主要依靠2个函数 uservec、usertrap,从内核态返回通过函数usertrapret、userret。
uservec
:把用户空间的上下文加载到trampframe中,这其中包括内核态传递的参数,a0-a7,可以从对应进程的 p->trampframe
中获取用户态传递到内核的参数。之后uservec
把内核页表,usertrap的地址加载到对应寄存器,最后 jr t0
,跳转到 usertrap
。
uservec:
# 交换a0和sscratch,sscratch一开始存储的TRAPFRAME
csrrw a0, sscratch, a0
# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
...
# 超级多的寄存器
# 保存a0
csrr t0, sscratch
sd t0, 112(a0)
# restore kernel stack pointer from p->trapframe->kernel_sp
ld sp, 8(a0)
# make tp hold the current hartid, from p->trapframe->kernel_hartid
ld tp, 32(a0)
# load the address of usertrap(), p->trapframe->kernel_trap
ld t0, 16(a0)
# restore kernel page table from p->trapframe->kernel_satp
ld t1, 0(a0)
csrw satp, t1
sfence.vma zero, zero
# a0 is no longer valid, since the kernel page
# table does not specially map p->tf.
# jump to usertrap(), which does not return
jr t0
usertrap
的作用是:
切换到内核空间的中断,因为已经进入了内核,把中断向量指向内核的中断函数
通过
scause
判断产生中断的原因,然后调用对应的处理函数
usertrapret的作用是为返回用户空间,需要关闭中断,把中断又改回用户空间的中断,配置寄存器,然后跳转到userret
。
userret
:切换到传入的参数的页表,载入存入trampframe的寄存器。
userret:
# userret(TRAPFRAME, pagetable)
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# switch to the user page table.
csrw satp, a1
sfence.vma zero, zero
# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
ld t0, 112(a0)
csrw sscratch, t0
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
...
# 超级多的寄存器
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
从内核空间陷入
当xv6进入内核的时候,内核将 stvec
指向 kernelvec
,kernelvec
将寄存器保存在被中断的内核线程的栈上,调用 kerneltrap
,然后在返回的时候重新载入寄存器。
kernelvec:
# make room to save registers.
addi sp, sp, -256
# save the registers.
sd ra, 0(sp)
sd sp, 8(sp)
sd gp, 16(sp)
# 存储寄存器
# call the C trap handler in trap.c
call kerneltrap
# restore registers.
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
# 载入寄存器
addi sp, sp, 256
# return to whatever we were doing in the kernel.
sret
kerneltrap
为两种类型的陷阱做好了准备:设备中断和异常。如果陷阱不是设备中断,则必定是一个异常,内核中的异常将是一个致命的错误;内核调用panic
并停止执行。
当kerneltrap
的工作完成后,他返回被中断的线程。由于在中断中可能调用了yield,破坏了保存的sepc
和在sstatus
中保存的前一个状态模式,因此kerneltrap
在启动时保存它们。在结束的时候重新加载一次。
中断
许多设备驱动程序在两种环境中执行代码:上半部分在进程的内核线程中运行,下半部分在中断时执行。上半部分通过系统调用进行调用,如希望设备执行I/O操作的read
和write
。这段代码可能会要求硬件执行操作(例如,要求磁盘读取块);然后代码等待操作完成。最终在设备完成操作的时候引发中断。驱动程序的中断处理程序充当下半部分,计算出已经完成的操作,如果合适的话唤醒等待中的进程,并告诉硬件开始执行下一个正在等待的操作。
UART
UART硬件在软件中看起来是一组内存映射的控制寄存器。也就是说,存在一些RISC-V硬件连接到UART的物理地址,以便载入(load)和存储(store)操作与设备硬件而不是内存交互。UART的内存映射地址起始于0x10000000
或UART0
。
main
函数调用consoleinit
来初始化UART硬件。UART对接收到的每个字节的输入生成一个接收中断,对发送完的每个字节的输出生成一个发送完成中断。
在read
系统调用中,当参数fd为1的时候,调用argfd获取该进程中fd对应的文件资源,文件资源即 struct file *ofile[NOFILE];
在第一个进程被创建的时候就调用了open
函数打开 console 就占用了fd为1的文件描述符,之后创建子程序的时候进行了复制。
因此,read读取fd为1的文件为读取终端,最终会调用在 consoleinit
初始化的时候设置的函数 consoleread
,写函数也同理。
简单描述一下终端输入输出字符的过程:
当用户输入一个字符时,UART硬件要求RISC-V发出一个中断,从而激活xv6的陷阱处理程序。陷阱处理程序调用devintr
,它查看RISC-V的scause
寄存器,发现中断来自外部设备。然后它要求一个称为PLIC的硬件单元告诉它哪个设备中断了。如果是UART,devintr
调用uartintr
。按字节读取字符串,然后放入缓冲区中,当写入输入缓冲区一行的时候,唤醒 consoleread
。
consoleread由read syscall 调用,负责把输入缓冲区的字符串拷贝到目的地,在输入缓冲区为空的时候 sleep
。
在连接到控制台的文件描述符上执行write
系统调用,最终将到达uartputc
。设备驱动程序维护一个输出缓冲区(uart_tx_buf
),这样写进程就不必等待UART完成发送;相反,uartputc
将每个字符附加到缓冲区,调用uartstart
来启动设备传输(如果还未启动),然后返回。导致uartputc
等待的唯一情况是缓冲区已满。
每当UART发送完一个字节,它就会产生一个中断。uartintr
调用uartstart
,检查设备是否真的完成了发送,并将下一个缓冲的输出字符交给设备。因此,如果一个进程向控制台写入多个字节,通常第一个字节将由uartputc
调用uartstart
发送,而剩余的缓冲字节将由uartintr
调用uartstart
发送,直到传输完成中断到来。
需要注意,这里的一般模式是通过缓冲区和中断机制将设备活动与进程活动解耦。即使没有进程等待读取输入,控制台驱动程序仍然可以处理输入,而后续的读取将看到这些输入。