中断函数中调用延时会影响其他中断?_MIT 6.828:实现操作系统 | Lab 3A:中断初始化

bdb7486fb01818a21d7dcc5a09e30971.png

本文使用 Zhihu On VSCode 创作并发布

本文为本人实现6.828 Lab的笔记,接续上一篇:

MIT 6.828:实现操作系统 | Lab 3A:为进程加载ELF文件

Lab其他部分在专栏不定期更新,环境搭建请看第一篇:

MIT 6.828:实现操作系统 | Lab1:快来引导一个内核吧

本文思维导图:

464ca020f436c49cd659df612841d6f4.png
思维导图

本文md文档源码链接:AnBlogs

x86中断行为

如果你之前没有底层开发经验,务必仔细了解处理器的中断机制,务必认真阅读x86手册第九章和x86手册第七章。如果说前面的Lab还可以依靠从前系统编程的经验支撑,到这里就不得不更深入理解原理了。这个标题下的内容为我对x86手册第九章的理解,只写了对后面写代码有帮助的部分,其它部分只作简单理解,不作深入掌握了。

中断是控制转移

中断是一种控制转移control transfer机制,可以联系call,同样也是控制转移机制。我们可以看到两者包含的平行概念。

call指令需要指定一个函数,也就是一个地址。中断需要指定中断类型,也就是中断发生的原因,以及对应的中断序号,也称为中断向量。它们都需要知道向哪里跳转。

call指令将执行前的运行状态保存,中断也要进行类似的压栈。它们都需要知道向哪里返回,如何恢复之前的执行。

call指令调用后,处理器继续使用原来的栈,通常使用ebp标记新的开端。中断发生后,处理器使用事先指定好的另一个栈。它们都需要一个新的栈环境,即便相比之下不是一样的

中断的意义

我们需要中断,因为我们要让CPU从用户态进入内核态

用户态是各个进程所在的状态,不能直接操作硬件,只能进行非常有限的操作,从而使得一个进程出现错误时,不会影响其他进程。进程通常只能访问指定范围内的地址,不能直接操作各种外设,像在屏幕上显示字符这样的操作,必须通过系统调用System Call,通过内核完成。

进程必须能够进行系统调用,否则整个计算机的功能就因为操作系统受到了严重限制,丧失了设计的意义。要进行系统调用,就必须获得比用户态更多的权限,就必须从用户态进入内核态

然而,中断必须在被保护的状态下进行,中断处理函数不能由用户进程指定,否则也就没有保护的意义了。接下来介绍保护机制。

保护中断

中断保护由两个机制组成。

中断入口

一个保护机制是中断处理函数,或者叫做入口。这个函数在中断描述符表中指定,中断发生时,处理器跳转到这个函数开始执行。中断只能令处理器跳转到指定好的函数,设置中断处理函数的操作只能在内核态下进行,这样也就实现了保护。

中断描述符中断描述符表的成员,包含了和中断处理函数入口有关的信息。有三种结构,对应三种中断类型,如下:

4e4bc3c072d80d1ee92bc9af2400c5e8.png
中断描述符

在文件inc/mmu.h中,定义了一些结构体和宏,帮助我们操作给定的中断描述符表。主要初始化在函数trap_init中完成。

Task State Segment

另一个保护机制是任务状态Task State Segment。由它的名字,它存储在一个Segment中,这个SegmentGlobal Segment Table,有一个对应的selector。我们没有使用Segment有关的内存映射机制,但是我们使用了它的权限保护机制。这个Segment放置了中断发生后,处理器应使用哪个栈,也就是寄存器ss, esp的值。同时也通过Selector指明了这个Segment要求的权限,限制一些中断入口的进入方式。

中断发生后,处理器将之前的执行状态保存在Task State指定好的中,以便之后能够返回到指定状态继续执行。中断触发后,处理器把当前栈顶切换到这个栈,并压入相应信息。这些信息不能在用户态下改变,否则程序可以跳转到任意想要跳转的地方,并且可能获得内核态权限。幸运的是,只要我们将Task State Segment的权限设置为内核态,就可以保护它。

压栈的信息如下,发生权限变化和不发生的情况有所不同。

cab201fb40659fb94a9cb7a13f0510e9.gif
中断栈

可以看到,权限变化时,往栈上压入了更多信息。压栈顺序非常重要,这也就是结构体struct Trapframe的声明顺序。注意到,结构体开头声明的几个属性不在图中,故我们在trapentry.S中自己把那些信息压栈。将此时的栈顶设置为struct Trapframe指针,也就可以方便地访问一个现成的结构体,包含关于中断前处理器状态的所有信息。

具体的初始化和使用在后文实现。

JOS不重度使用中断处理函数Task State,没有完全使用这些硬件机制。

我们可以在中断描述符表中指定好每个中断的入口,从而方便地使用这一硬件功能,但是我们没有这么做。我们让所有的中断最终跳转到一个相同的处理函数,在这个函数中通过switch手动分发这些中断。

我们可以使用Task State Segment指定别的地址作为系统调用的栈,但是我们没有这么做。我们使用了内核的栈KSTACKTOP当做中断栈。

区分一些概念

和中断有关的概念经常容易搞混,不同作者有不同的预设。这里整理一下有关概念。

从代码层面来看,中断异常没有本质上的区别。我们不是硬件工程师,也就不去深究它们的区别,用中断同时指代两者。可以对中断稍微分类,加深理解。

中断通常有3种来源,硬件触发、指令触发和软件错误。硬件触发如外设电路将CPU引脚电平拉高,如DMA完成内存操作,常用于IO设备控制。指令触发常用在实现系统调用中,通过指令int在代码中触发处理器中断,从而进入内核态。其余的错误可统称为软件错误,可能是遇到了无法执行的指令,如Divide By 0,也可能是触发了保护机制,如Page Fault

也可以采用另一种分类加深理解,可以分为同步异步。和其他语境类似,异步的意思是,中断是对之前一种信号回应response。如CPU要向硬盘写入数据,于是给总线上其它设备分发了这个任务。相应设备完成之后,告诉CPU完成这个信息,也就是触发中断。CPU相应执行的中断处理,可以看做回调函数。在发出信号和收到回应之间,CPU的执行不阻塞。

相对地,同步代表中断发生后,CPU原先的执行被阻塞,切换到了处理函数。处理函数完成之后,再返回到原先被阻塞的procedure继续执行。

写代码时,我们不需要关心中断的具体分类,只需要根据中断携带的信息,进行妥善处理就可以。

Task State初始化

在函数trap_init_percpu中,初始化了处理器的Task State Segment中断描述符表。这个函数在trap_init函数中调用,在写好一系列中断描述符表初始化操作之后,调用这个函数,令中断描述符表生效,并初始化Task State

Task State中指定了要使用的栈,在trap_init_percpu中这样初始化:

ts.ts_esp0 = KSTACKTOP;
ts.ts_ss0 = GD_KD;

接着设置了IO操作的权限,我们不关心这里的细节。

ts.ts_iomb = sizeof(struct Taskstate);

然后把这个Task State Segment装载进Global Segment Table。回想Segment Table Selector的格式如下:

ac8aef983f1f508abb23e9c1700cf6ac.gif
Segment Selector

GD_TSS0定义了Task State Segment对应的选择器,要获得对gdt的索引,需要右移3位,如下:

gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t) (&ts), sizeof(struct Taskstate) - 1, 0);
gdt[GD_TSS0 >> 3].sd_s = 0;

最后设置寄存器,令以上配置生效。

目前为止,这个函数是为处理一个CPU初始化而设计的。下一个Lab要操作多个CPU,也就需要修改这个函数了,不过道理都是类似的。

中断描述符表初始化

我们想要的效果是,一个中断产生之后,根据这个中断的类型和携带的参数,执行相应的处理函数。大概对应Lab 3 Exercise4实现。初始化操作集中在函数trap_init和文件trapentry.S中。

中断统一入口

每个中断都对应了一个入口,写在中断描述符表中。在trap_init函数中,借助宏SETGATE配置中断描述符表。务必看清楚文件mmu.h中关于这个宏的说明,每个参数的含义。

在使用SETGATE配置中断描述符表之前,先要有中断入口函数。这些函数写在trapentry.S中。每个中断都有一个入口函数,我们可以在这里就给每个中断写入口函数。将函数的符号声明为全局global,也就可以在文件trap.c中的C代码获得函数指针。通过这些函数指针就可以初始化中断描述符表

Lab在文件trapentry.S中提供了两个宏TRAPHANDLER*,对所有中断进行了统一处理。让所有中断经过一个统一的流程后,再通过C代码分发处理函数,而不是直接在trapentry.S中单独写每个中断的处理函数。

#define TRAPHANDLER(name, num)						
	.globl name;		/* define global symbol for 'name' */	
	.type name, @function;	/* symbol type is function */		
	.align 2;		/* align function definition */		
	name:			/* function starts here */		
	pushl $(num);							
	jmp _alltraps

这个宏做了这些事情:

  1. 创建一个函数symbol,名字由宏参数得到。
  2. 将这个symbol设置为全局。
  3. 这个函数将中断序号压栈,并跳转到_alltraps

有的中断会将一个额外的error code压栈,在进入中断描述符表指定的入口函数之前,就和其他数据一起压栈了。有的中断不会进行这个操作。为了统一处理这两种中断,使得它们的栈具有相同形式,对于不进行压error code操作的中断,我们在原本error code的位置压一个0填充,正如宏TRAPHANDLER_NOEC中的那样。哪些中断会自动压error code,请看x86所有异常。

TRAPHANDLER*接受两个宏参数,一个是要生成的中断入口名字,一个是中断序号。中断序号在文件inc/trap.h中有一系列宏声明,对应了一系列我们感兴趣的中断。查manual之后,就可以在文件trapentry.S中创建一系列函数。

TRAPHANDLER_NOEC(t_divide, T_DIVIDE)
TRAPHANDLER_NOEC(t_debug, T_DEBUG)
TRAPHANDLER_NOEC(t_nmi, T_NMI)
TRAPHANDLER_NOEC(t_brkpt, T_BRKPT)
TRAPHANDLER_NOEC(t_oflow, T_OFLOW)
TRAPHANDLER_NOEC(t_bound, T_BOUND)
TRAPHANDLER_NOEC(t_illop, T_ILLOP)
TRAPHANDLER_NOEC(t_device, T_DEVICE)
TRAPHANDLER(t_dblflt, T_DBLFLT)
TRAPHANDLER(t_tss, T_TSS)
TRAPHANDLER(t_segnp, T_SEGNP)
TRAPHANDLER(t_stack, T_STACK)
TRAPHANDLER(t_gpflt, T_GPFLT)
TRAPHANDLER(t_pgflt, T_PGFLT)
TRAPHANDLER(t_fperr, T_FPERR)
TRAPHANDLER(t_align, T_ALIGN)
TRAPHANDLER(t_mchk, T_MCHK)
TRAPHANDLER(t_simderr, T_SIMDERR)

TRAPHANDLER_NOEC(t_syscall, T_SYSCALL)

我们使用这两个宏创建所有中断的入口,故所有中断都要跳转到_alltraps,同时每个中断入口的创建形式都相同,也就具有了实际上的中断统一入口

初始化中断描述符表

在函数trap_init中,将刚刚写好的一系列入口,以函数指针的形式,写进中断描述符表

给宏SETGATE传函数名和对应的中断序号即可,在使用函数名之前,必须先声明函数,告诉连接器要使用来自另一个文件的symbol。如要初始化Divide By 0

void t_divide();
SETGATE(idt[T_DIVIDE], 0, GD_KT, t_divide, 0)

为了方便,再写一个宏,再封装一层:

#define DECLARE_INTENTRY(funcName, intNumber, privLevel) 
    void funcName();                
    SETGATE(idt[intNumber], 0, GD_KT, funcName, privLevel)

根据SETGATE第二个参数的不同,还要另外写一个宏:

#define DECLARE_TRAPENTRY(func_name, entry_num, privLevel) 
    void func_name();                
    SETGATE(idt[entry_num], 1, GD_KT, func_name, privLevel)

Interrupt EntryTrap Entry的区别在于,是否在中断返回之前阻止新的中断产生。目前为止,这个区别造成的后果还不显著。

各个中断初始化应如下:

DECLARE_INTENTRY(t_divide, T_DIVIDE, 0)
DECLARE_INTENTRY(t_debug, T_DEBUG, 3)
DECLARE_INTENTRY(t_nmi, T_NMI, 0)
DECLARE_TRAPENTRY(t_brkpt, T_BRKPT, 3)
DECLARE_INTENTRY(t_oflow, T_OFLOW, 0)
DECLARE_INTENTRY(t_bound, T_BOUND, 0)
DECLARE_INTENTRY(t_illop, T_ILLOP, 0)
DECLARE_INTENTRY(t_device, T_DEVICE, 0)
DECLARE_INTENTRY(t_dblflt, T_DBLFLT, 0)
DECLARE_INTENTRY(t_tss, T_TSS, 0)
DECLARE_INTENTRY(t_segnp, T_SEGNP, 0)
DECLARE_INTENTRY(t_stack, T_STACK, 0)
DECLARE_INTENTRY(t_gpflt, T_GPFLT, 0)
DECLARE_INTENTRY(t_pgflt, T_PGFLT, 3)
DECLARE_INTENTRY(t_fperr, T_FPERR, 0)
DECLARE_INTENTRY(t_align, T_ALIGN, 0)
DECLARE_INTENTRY(t_mchk, T_MCHK, 0)
DECLARE_INTENTRY(t_simderr, T_SIMDERR, 0)

DECLARE_TRAPENTRY(t_syscall, T_SYSCALL, 3)

还需要特别说明的是宏DECLARE_INTENTRY的第三个参数,指定了权限DPL。对于部分通过int指令触发的中断,只有在大于指定的权限状态下触发中断,才能进入中断入口函数。如在用户态下触发权限为0T_DEBUG中断,就不能进入入口,反而会触发GPFLT中断。更详细的解释,请看x86手册7.4。

我们将一些入口DPL设置为3,允许它们通过int触发。

GPFLT中断为所谓的兜底,要是产生的错误没有对应其它中断,就会触发这个中断。在这里,中断尝试从用户态进入一个只能由内核态进入的入口,就会触发这个中断,作为一种保护机制。

具体哪些情况会触发GPFLT,请看x86手册9.8.13。

到这里,中断还不能正常执行,我们还需要写写trapentry.S中的汇编代码。

_alltraps

_alltraps开始,每个中断都会走过相同的代码。Lab要求我们在_alltraps中进行如下操作:

  1. 让更多信息进栈,使得栈具有结构体struct TrapFrame的形式。
  2. 在寄存器%ds, %es的位置上放置宏GD_KD的值。
  3. 将当前栈指针压栈,给trap函数传参。
  4. 调用trap函数。

总的来说,这段代码的意义就是正确地向trap函数传参,重点在把栈制作得和一个struct TrapFrame一样。

其实我们离答案很近。翻到上文的一张图片,可以发现,中断产生时,处理器已经自动压了一部分信息到栈上。

cab201fb40659fb94a9cb7a13f0510e9.gif
中断栈

联系struct TrapFrame的声明,可以看到,从最后一个元素,寄存器ss的值,到结构体的第8个声明的属性uintptr_t tf_eip,处理器都已经压好了。在进入_alltraps之前,前面的代码还处理好了error codetrap number,现在仅剩tf_ds, tf_es, tf_regs需要处理。

Lab讲义中说得很明确,要给ds, es寄存器传宏GD_KD的值,这又解决了一个问题。以上一瞬间解决了几乎所有需要处理的struct TrapFrame成员,可以写出前半部分代码如下。

_alltraps:
    pushl %ds
    pushl %es

剩下的struct PushRegs结构体,可以直接通过popa指令构造。pusha指令意为push all registers,将所有寄存器的值压栈,顺序正好对应struct PushRegs的声明顺序。

再接着将GD_KD的值赋值给寄存器ds, es,就可以调用trap函数了。完整代码如下:

_alltraps:
    pushl %ds
    pushl %es
    pushal
    # load GD_KD into %ds, %es
    movw $(GD_KD), %ax
    movw %ax, %ds
    movw %ax, %es
    # pass a pointer to the trap frame for function trap
    pushl %esp
    call trap

call指令的前一个指令,就是将当前栈指针压栈了,就是在给trap函数传参。在文件kern/trap.c中的trap函数中,函数接受的参数tf,就是这样传进来的。

可以想想为什么这样传参是对的。栈的生长方向是虚拟地址变小的方向,访问结构体是在一个基地址的基础上加上一个偏置。我们按照结构体声明的相反顺序,将结构体成员的值压栈,而最终指向结构体的指针,比任意结构体成员的地址都要小。这样想一下,是一个不错的sanity check

仔细阅读trap函数的代码,可以看到,若中断是在用户态下触发,就要对tf指针指向的结构体,也就是刚刚压栈的那个结构体,进行拷贝,从而控制用户态下的不安全因素。

拷贝完结构体之后,调用函数trap_dispatch,将中断分发到指定的handler处理。

测试配置

kern/init.c中调用了宏函数ENV_CREATE,从而指定了在之后的env_run中要执行的进程。可以执行的进程在user目录下的一系列C文件中写好了,都非常简单。给ENV_CREATEuser_**处填写对应在user目录下的文件名,就可以了。一开始使用的是user_hello,就是对应user/hello.c

我们尝试改成user_divzero,试图从用户态触发Divide By 0异常。可以在文件trapentry.S或函数trap中设置断点,感受中断执行流程。

如果配置正确,在函数env_run调用env_pop_tf之后,处理器开始执行trapentry.S下的代码。应该首先跳转到TRAPENTRY_NOEC(t_divide, T_DIVIDE)处,再经过_alltraps,进入trap函数。

可以使用Lab提供的打分测试。在根目录下运行make grade,若之前操作正确,则Lab 3A下会有相应分数。这个打分测试是运行了一个Python脚本grade-lab3。如果你之前没有这么做的话,你可以回到之前的Lab跑一下之前相应的打分。看看自己能的几分。

接着看运行流程

再仔细看看trap函数及其调用的函数的行为。

进入trap函数后,先判断是否由用户态进入内核态,若是,则必须保存进程状态。也就是将刚刚得到的TrapFrame存到对应进程结构体的属性中,之后要恢复进程运行,就是从这个TrapFrame进行恢复。

若中断令处理器从内核态切换到内核态,则不做特殊处理,详情请看x86 Nested Interrupt。

接着调用分配函数trap_dispatch,这个函数根据中断序号,调用相应的处理函数,并返回。故函数trap_dispatch返回之后,对中断的处理应当是已经完成了,该切换回触发中断的进程。修改函数trap_dispatch的代码时应注意,函数后部分不应该执行,否则会销毁当前进程curenv。中断处理函数返回后,trap_dispatch应及时返回。

切换回到旧进程,调用的是env_run,根据当前进程结构体curenv中包含和运行有关的信息,恢复进程执行。如运行user_divzero之后,若对错误不作任何处理,重新恢复执行进程后,进程尝试重新执行之前的指令,进而重新触发Divide By 0中断,造成死循环。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值