interrupt和trap在xv6上的实现,涉及到TSS,IDT等。
interrupt, trap, exception是什么
在x86系统中,中断(interrupt)有如下分类,
- CPU外部中断 - 外部设备产生的中断,称为interrupt
- 可屏蔽中断(interrupt,如IO设备)
- 不可屏蔽中断(NMI),如磁盘错误,极少发生,一旦发生别无他法
- CPU内部中断 - 由CPU本身产生的中断
- 软件主动产生的中断,称为trap
- CPU检测到的异常(如除数为0),称为exception
- 潜在可恢复的错误,称为fault,如缺页故障
- 不可恢复的错误,称为abort
所有这些,interrupt, trap, exception,在CPU看来都是一样的,CPU称它们为中断,
所以interrupt有两个意思:泛指所有中断和特指外部中断,这需要根据上下文来区分。
task state segment(TSS)是什么
TSS,即任务状态段,它是GDT/LDT中的一个段描述符,用来提升CPU的运行级别,要使用
这个段,CPU当前运行级别(CPL)必须小于等于该段的DPL,TSS段中保存的是任务状态,即一堆
的寄存器值,值得注意的是三组寄存器值:
- ss0、esp0
- ss1, esp1
- ss2, esp2
这三组寄存器是用来寻找栈的,x86提供0~3共四个级别,而TSS用来提升级别,所以低级别
可以提升到高级别,提升之后需要把当前的任务状态保存起来,其实就是保存到ssX, espX指定
的栈中,比如从3提升到0,则CPU自动将任务状态保存到ss0:esp0指定的栈中,级别3这一组就
不需要了,因为它是最低的级别了。
xv6是如何做的
每个进程,包括init进程,在创建之初就已经把该进程的kernel stack布局好了,
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
| 进程创建之初,其内核栈的样子:
/ +---------------+ <-- stack base(= p->kstack + KSTACKSIZE) | | ss | | +---------------+ | | esp | | +---------------+ | | eflags | | +---------------+ | | cs | | +---------------+ | | eip | <-- 从此往上部分,在iret时自动弹出到相关寄存器中,只需把%esp指到这里即可 | +---------------+ | | err | | +---------------+ | | trapno | | +---------------+ | | ds | | +---------------+ | | es | | +---------------+ | | fs | struct trapframe | +---------------+ | | gs | | +---------------+ | | eax | | +---------------+ | | ecx | | +---------------+ | | edx | | +---------------+ | | ebx | | +---------------+ | | oesp | | +---------------+ | | ebp | | +---------------+ | | esi | | +---------------+ | | edi | \ +---------------+ <-- p->tf | trapret | / +---------------+ <-- forkret will return to | | eip(=forkret) | <-- return addr | +---------------+ | | ebp | | +---------------+ struct context | | ebx | | +---------------+ | | esi | | +---------------+ | | edi | \ +-------+-------+ <-- p->context | | | | v | | empty | +---------------+ <-- p->kstack */
|
这些在init process的分析中都已经说过了,因为CPU执行iret
时会把trapframe的eip
弹出到%eip寄存器中进而转到这里执行,所以我们只需设置trapframe的eip变量就可以
控制CPU的下一个执行指令地址,转到用户态之后进程的内核栈就是空的了。
接下来是trap的整个过程。我们以系统调用为例。
从int $T_SYSCALL开始
进行系统调用的方式是这样的:
exec system call1 2
| movl $SYS_exec, %eax int $T_SYSCALL
|
首先把trapno放到%eax里面,这个后续会用到。CPU在执行int
指令时会自动跳转到相关的
中断处理程序里,也就是vectorXXX
处,这里为每个中断设置了一个中断处理程序,xv6的
中断处理表是用vector.pl生成的,基本每个表项都是一样的,类似于这样,
1 2 3 4
| vector0: pushl $0 # errcode pushl $0 # T_SYSCALL jmp alltraps
|
int
指令包含的内容是很多的,
- 栈转换,stack转换到tr寄存器指定的tss里面的ss0:esp0,这是该进程对应的内核栈
- 自动将相关寄存器的内容进栈:ss, esp(如果涉及到ring变化的话), eflag, cs, eip。
这样返回用户态之后才能恢复执行
- 跳转到中断处理程序处
跳转完成之后,内核栈是这个样子的,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 这部分是CPU执行int指令时自动压栈的,
+---------------+ <-- stack base(= p->kstack + KSTACKSIZE) | ss | +---------------+ | esp | +---------------+ | eflags | +---------------+ | cs | +---------------+ | eip | +---------------+ <-- %esp */
|
中断处理程序
接着开始执行中断处理程序,在vectorXXX里面只是将errcode和trapno压栈,
errcode如果有的话都是0,trapno即系统调用的中断号T_SYSCALL,内核栈
变成这样了,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| +---------------+ <-- stack base(= p->kstack + KSTACKSIZE) | ss | +---------------+ | esp | +---------------+ | eflags | +---------------+ | cs | +---------------+ | eip | +---------------+ | err | +---------------+ | trapno | +---------------+ <-- %esp */
|
然后jmp到alltraps继续执行,
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
| # vectors.S sends all traps here. .globl alltraps alltraps: # Build trap frame. pushl %ds pushl %es pushl %fs pushl %gs pushal # Set up data and per-cpu segments. movw $(SEG_KDATA<<3), %ax movw %ax, %ds movw %ax, %es movw $(SEG_KCPU<<3), %ax movw %ax, %fs movw %ax, %gs
# Call trap(tf), where tf=%esp pushl %esp call trap addl $4, %esp
# Return falls through to trapret... # 这句话的意思是代码继续往下执行完trapret。 # 'return',名词;fall through to,动词,继续直到... .globl trapret trapret: popal popl %gs popl %fs popl %es popl %ds addl $0x8, %esp # trapno and errcode iret
|
一进来就是一通压栈,压入段寄存器,压入通用寄存器pushal
,到此为止,内核栈又
变样子了,
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
| / +---------------+ <-- stack base(= p->kstack + KSTACKSIZE) | | ss | | +---------------+ | | esp | | +---------------+ | | eflags | | +---------------+ | | cs | | +---------------+ | | eip | <-- 从此往上部分,在iret时自动弹出到相关寄存器中,只需把%esp指到这里即可 | +---------------+ | | err | | +---------------+ | | trapno | | +---------------+ | | ds | | +---------------+ | | es | | +---------------+ | | fs | struct trapframe | +---------------+ | | gs | | +---------------+ | | eax | | +---------------+ | | ecx | | +---------------+ | | edx | | +---------------+ | | ebx | | +---------------+ | | oesp | | +---------------+ | | ebp | | +---------------+ | | esi | | +---------------+ | | edi | \ +---------------+ <-- %esp */
|
OMG,整个栈,此时不就是一个trapframe结构吗?是的!修改一下段寄存器之后开始调用
trap()
函数,这是一个C函数,里面会处理所有的中断、trap,它的参数就是一个trapframe,
1 2 3 4 5
| # 为trap()函数准备参数,struct trapframe* pushl %esp call trap # 干掉之前压栈的参数,完成之后%esp又指向trapframe结构了 addl $4, %esp
|
到此为止,系统调用的实质工作已经完成了,接下来就要返回用户态了,也就是trapret
,
alltraps
会自动往下执行到trapret
。很显然,它要把寄存器恢复回去,
1 2 3 4 5 6 7 8
| .globl trapret trapret: popal # 恢复通用寄存器 popl %gs # 恢复各种段寄存器 popl %fs popl %es popl %ds addl $0x8, %esp # trapno and errcode,干掉这两个
|
我们故意把iret
分开来,在iret
执行前,内核栈样子是这样的,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| +---------------+ <-- stack base(= p->kstack + KSTACKSIZE) | ss | +---------------+ | esp | +---------------+ | eflags | +---------------+ | cs | +---------------+ | eip | +---------------+ | err | +---------------+ | trapno | +---------------+ <-- %esp */
|
咦,好眼熟啊,对这正是CPU执行int
之后内核栈的样子,这部分是被CPU
自动压栈的,接下来是iret
指令,又是一个很复杂的指令,它会把int
自动压栈的内容再自动恢复回去,这样程序就回到eip处了,即中断发生的
指令处,一切就跟没有发生一样。
可是系统调用,比如说read(),它的执行结果呢?
syscall.c1 2 3 4 5 6 7 8 9 10 11 12 13
| void syscall(void) { int num;
num = proc->tf->eax; if(num > 0 && num < NELEM(syscalls) && syscalls[num]) { proc->tf->eax = syscalls[num](); } else { cprintf("%d %s: unknown sys call %d\n", proc->pid, proc->name, num); proc->tf->eax = -1; } }
|
原来如此,系统调用的执行结果保存在通用寄存器里了。那系统调用的参数呢?比如说打开文件时
的“文件路径”参数,这是怎么传递的呢?答案在syscall.c里面,
1 2 3 4 5
| int argint(int n, int *ip) { return fetchint(proc->tf->esp + 4 + 4*n, ip); }
|
process在创建之初就设置p->tf属性了,这个指针指向内核栈的某个位置,见allocproc()
函数,
系统调用的参数是通过读取用户栈传递的。
整个系统调用至此完结。
疑问及解答
从scheduler()看来,每个CPU都会找RUNNABLE的进程执行,而一旦开始执行就会将进程状态设置为
RUNNING,于是其他CPU不可能再执行这个进程了,这样看来,一个进程同时只能被一个CPU执行!
当然,也有可能进程A被CPU0执行一段时间,然后不管是被中断还是时间片用完,还是自己主动yield,
它都会再次进入RUNNABLE状态,然后其他CPU可能发现并执行它,但是同一时刻,它只能在一个
CPU上执行。但是问题来了,一个进程在CPU0上被中断,然后还能在CPU1上被恢复吗?
答:
这涉及到抢占式内核和非抢占式内核之分,如果进程在内核态执行时运行进程被更高优先级进程挂起,
那么kernel就是抢占式的,否则一个进程在进入kernel执行时不允许被中断,那么kernel就是非抢占式的。
xv6是抢占式的内核,也就是说,当进程通过系统调用进入内核之后允许中断,当进程通过系统调用进入内核
正在运行,此时中断再次到达后,比如时钟中断,它会再次进入到alltraps,在这里push各种寄存器,
然后再次执行到trap,并主动yield(),这个过程其实类似于调用int
指令,只是这是发生在内核中的
中断,CPU自动保存寄存器时不会保存ss, esp(trapframe最后两个变量),这之后当前正在运行的进程
内核栈中就保存了一个trapframe,一个context,而trapret会return到新的进程继续执行,因为这期间
栈被切换了。等待这个进程被再次调度后会从它的context继续执行,而此次执行就不一定是以前的那个
CPU了。
(over)