CPU通过任务管理的方式提供了基于硬件的多任务,为了实现多任务CPU提供了一下额外的数据结构和寄存器。这篇文章并不打算详细的介绍任务管理的结构和原理,因为Intel官方文档关于任务管理的章节描写的非常清楚,我只是想通过一个例子来理解整个任务管理的机制。
任务管理结构
多任务就是要实现多个任务之间的并行执行,就会涉及到任务之间的切换,既然要切换就要保证任务在切换回来之后还能够顺利的继续执行下去,那么在切换之前就要保存任务能够继续执行下去所必须的堆栈,寄存器等等上下文环境,这个上下文环境叫做任务状态。Intel官方手册中已经明确的描述了任务状态包含那些内容,这里就不赘述了。为了保存任务状态CPU提供了一种叫做TSS(任务状态段)的数据结构。TSS是与代码段,数据段等一样的一块内存区域,为了找到它还要在GDT中保存一个TSS的描述符,但是与其他的段描述符不同的是TSS描述符只能在GDT中,而不能够出现在LDT或者IDT中。与代码段或者数据段一样也需要将TSS的段选择子加载到一个段寄存器中来获得TSS,这个加载TSS段选择子的寄存器就叫做TR(任务寄存器),通过ltr指令来加载TSS段选择子,不过TR寄存器都是在任务切换的过程中由CPU自动的加载的,只有在初始化代码的时候需要程序员显式的调用ltr加载。
多任务环境中各个任务的任务状态是相对独立的,但是每个任务的状态都包很多段,如果把这些段描述符都放在GDT中,GDT会变得很长,很乱。CPU提供了LDT来保存一个任务自身相关的段,LDT是与GDT相似的结构,就是描述符的表,但是与GDT不同的是,LDT可能有很多,对于当前的任务,并不是所有的LDT都是可见的,只有LDTR寄存器中保存的选择子选定的LDT是可见的。此外LDT没有NULL descriptor,并且每一个LDT必须都在GDT中有相应的LDT描述符。
CPU还提供了一种任务门描述符的结构,它与TSS描述符不同,任务门描述符保存的是TSS描述符在GDT中的选择子,并且任务门描述符可以保存在GDT,LDT和IDT中。
任务切换
任务切换是一个很复杂的过程,因为切换过程中每一个步骤都要进行相应的检查,如果违规就会触发相应的异常。但是切换的流程是挺清晰的:
- 通过指令(ljmp/lcall/iret)获得新任务的选择子。
- 将当前的任务状态保存到当前的TSS中。
- 读取新任务的TSS,并从TSS中加载新任务的任务状态。
- 跳转到新任务开始之行。
其他步骤都是很简单的,第三步其实是挺复杂的过程,这里涉及到了多个数据结构交互,用图形表示一下最复杂的情况:
这里是最复杂的情况,一个任务涉及到的段都保存在自己的LDT中:
- 通过任务切换指令获得TSS selector,利用这个TSS selector获取GDT中的kernel TSS descriptor。
- 通过kernel TSS descriptor获得kernel TSS。
- kernel TSS中的LDT selector有效,通过LDT selector获得GDT中的kernel LDT descriptor。
- 通过kernel LDT descriptor获得kernel LDT。
- 通过kernel TSS中的CS selector获得kernel LDT中的CS descriptor。
- 通过CS descriptor获得kernel code segment。
示例
关于任务管理的例子代码比较多,托管在https://github.com/activesys/learning_cpu/tree/master/x86/task这里仅仅是对于各个文件的简单介绍:
- boot.s是启动扇区代码,它还负责将setup,中断处理代码和数据,kernel代码和数据以及user代码和数据从相应的扇区中读取出来放到指定的内存位置。
- common.inc定义了一下常量和宏,主要是对于整个代码的内存布局,以及GDT,LDT,IDT中的各个项的常量的定义。
- setup.s负责安装GDT,LDT,IDT以及TSS,还负责切换到保护模式并且初始化保护模式后的任务环境。
- kernel.s是ring0级别的代码和数据。
- user.s是ring3级别的代码和数据。
- build.sh是编译脚本,并且生成最终的镜像文件。
# switch to protected-mode
movl %cr0, %eax
orl $1, %eax
movl %eax, %cr0
# far jmp
ljmp $SETUP_SELECTOR, $_setup
保护模式代码初始化各个段,然后调用ltr来加载TR寄存器:
###############################################################
# Protected-mode code for setup
.code32
.type _setup, @function
_setup:
xorl %eax, %eax
movw $INT_DATA_SELECTOR, %ax
movw %ax, %ds
movw $INT_STACK_SELECTOR, %ax
movw %ax, %ss
movl $INT_STACK_INIT_ESP, %esp
movw $INT_VIDEO_SELECTOR, %ax
movw %ax, %es
movw $NULL_SELECTOR, %ax
movw %ax, %fs
movw %ax, %gs
movl $SETUP_TSS_SELECTOR, %eax
ltr %ax
ljmp $KERNEL_TSS_SELECTOR, $0x00
最后使用ljmp跳转到kernel任务,进行任务切换。ljmp后面跟着的选择是不再是段选择子而是tss的选择子。kernel任务的代码很简单只是向屏幕中打印了一些信息,然后使用lcall切换到了user任务,等user任务切换后来之后再次打印了一些信息并且进入无限循环:
###############################################################
# code for kernel
.code32
.globl _start
_start:
movl $KERNEL_STACK_INIT_ESP, %esp
movl $KERNEL_MSG_OFFSET, %edi
movl $KERNEL_MSG_LENGTH, %ecx
movl $KERNEL_FIRST_VIDEO_OFFSET, %edx
call _kernel_echo
# jmp to user code.
lcall $USER_TSS_SELECTOR, $0x00
movl $KERNEL_MSG2_OFFSET, %edi
movl $KERNEL_MSG2_LENGTH, %ecx
movl $KERNEL_SECOND_VIDEO_OFFSET, %edx
call _kernel_echo
jmp .
user任务的代码更是简单,只是向屏幕上打印了一些消息,展示了一下我们位于user任务代码段,然后使用iret指令切换回kernel任务:
###############################################################
# code for user
.code32
.globl _start
_start:
movl $USER_STACK_INIT_ESP, %esp
movl $USER_MSG_OFFSET, %edi
movl $USER_MSG_LENGTH, %ecx
movl $USER_FIRST_VIDEO_OFFSET, %edx
call _user_echo
iret
最终的运行结果:
从屏幕中输出的消息可以看出,代码首先进入了ring0级别的kernel任务,然后切换到了ring3级别的user任务,然后又切换回ring0级别的kernel任务。