首先让我们澄清一件事,在单处理器(单核)上同时运行多任务是不可能的,实际中,处理器只是在任务间切换非常快,像每秒100次,所以看起来像同时有多个进程在运行。
386处理器对多任务提供硬件支持,但是也可以通过软件方式实现任务切换,这是我以前写的一个例子,它在多个代码段中切换执行。我们都知道每个处理器中都只有一套寄存器,并且它们被所有的处理器共享,所以当一个执行中的进程停止并在它切换到其它进程前,有关于当前进程的信息,即寄存器和其它的一些信息都会被储存,因为其它进程会使用它们。这些信息被称为“上下文”,386处理器使用任务状态段来储存这些信息,它至少有104字节长,并有一个TSS描述符指向它。TSS描述符只能在GDT中出现,也就意味着用户任务不能通过它们自己的LDT来进行任务切换。当我们用硬件方式进行任务切换时,处理器把应储存的信息自动存储在TSS中。
TSS定义了任务的执行环境状态。看一下TSS结构,
表很长,但实际上没有看起来的那么吓人。那些标明“not used”的域被Intel保留。386处理器支持任务嵌套,也就是说当任务1切换到任务2后,任务2的“Back link”域被设置成任务1的选择子并且EFLAGS
寄存器的NT(嵌套任务)被设置,因此当任务2返回时,处理器知道哪个任务应该获得控制权。我们知道386处理器支持4个不同的权限级,并且TSS允许在任务切换时每个权限级使用不同的堆栈,所以一个进程可以有4个不同的堆栈。在后面的教程中我们准备讨论一下寄存器CR3
。以后我们会讨论LDT。在此时,不用关心I/O位映射和T位是什么东西。
像GDT中的其它描述符那样,TSS也需要GDT中有一个描述符指向它,这是格式,
一般的GDT描述符,
我们可以把它和一般的GDT描述符比较一下,我么可以发现D(数据或代码段)和X(未用)位被设为0,AVL用户可用。类型是010B,B(位41)是TSS描述符中的忙状态位,因为每个进程只能有一个TSS,一旦在执行状态TSS的B位被设置,表示这个TSS不能被使用了以防止再入。A(存取)被设置,但是即使A被设置的情况下,进程既不能读也不能写TSS。描述符中的其它域和普通的数据和代码段的意义相同,只需要记住一件事,TSS的描述符长度必须大于104字节,这是TSS的最小长度。另一件事,实际上大多数操作系统使用软件方式进行上下文切换,因为硬件方式只保存上面我们所提到的寄存器,不包含FPU
,MMX
,SSE
之类。但是我们在Skelix中用不到它们所以我们可以安全的用TSS进行环境保存。
在本节中,TSS的DPL域被设为0,因此只有内核有权限进行任务切换。就像GDTR
和IDTR
,TSS描述符也有它自己用于任务切换的寄存器TR
,可以用指令LTR
载入。指向GDT中TSS描述符的选择子不能被装入其它任何段寄存器,否则异常发生。
有好几种方法可以进行任务切换,我们准备使用一个长转跳到GDT表中TSS描述符的方法进行。所有上面提到的东西都是为了这个目的。如果你想用其它的办法,那么你最好自己到处翻代码吧,我相信关于这个话题的书是很少……很少……少……的。
任务切换时,处理器首先保存当前任务的上下文在当前TSS中,然后载入新任务的任务寄存器,通过新任务在GDT中描述符中的TSS内容载入寄存器,最后执行新任务。
看一下代码,这是我们要用到的TSS结构,
06/include/task.h
struct
TSS_STRUCT {
unsigned
int
back_link;
unsigned
int
esp0, ss0;
unsigned
int
esp1, ss1;
unsigned
int
esp2, ss2;
unsigned
int
cr3;
unsigned
int
eip;
unsigned
int
eflags;
unsigned
int
eax,ecx,edx,ebx;
unsigned
int
esp, ebp;
unsigned
int
esi, edi;
unsigned
int
es, cs, ss, ds, fs, gs;
unsigned
int
ldt;
unsigned
int
trace_bitmap;
};
它和我提到的TSS结构完全一样,但是既然处理器要用这个结构,那么它必须是104字节长并且没有填充,如果你使用IA-64结构或者其它架构或其它编译器,请自己查看文档。
对一个操作系统而言,这些信息是不够的,所以另一个结构TASK_STRUCT
被用来包裹这个TSS结构。
#define TS_RUNNING 0
#define TS_RUNABLE 1
#define TS_STOPPED 2
struct
TASK_STRUCT {
struct
TSS_STRUCT tss;
unsigned
long
tss_entry;long
unsigned
long
ldt[2];long
unsigned
long
ldt_entry;long
int
state;
int
priority;
struct
TASK_STRUCT *next;
};
#define DEFAULT_LDT_CODE 0x00cffa000000ffffULL
#define DEFAULT_LDT_DATA 0x00cff2000000ffffULL
#define INITIAL_PRIO 200
这是我们所需的一切,这些TS_*
定义了所有有效的进程状态。
TASK_STRUCT
中的tss_entry
定义了TSS描述符,一会儿我会解释它。两个*ldt*
域也会一会儿解释。state
存储了当前进程状态,即几个TS_*
之一。priority
定义了进程被执行的优先级,新任务会被给予一个初始优先级INITIAL_PRIO
。所有Skelix管理的任务都被连接成链表,next
指向链表中的下一个任务。
让我们看看例子,这个TASK_STRUCT
为任务0准备,这是系统中的第一个任务,当内核完成初始化后,就成为了任务0。
static
unsigned
long
TASK0_STACK[256] = {0xf};
这是这个任务用的CPL为0时的栈。没有那个0xF,我遇到了编译器的一个问题,如果仅仅像static unsigned long TASK0_STACK[256];
这样定义,那么这个内存区域就消失了,必须得把它的第一个值设为非零。(沈峰解释说,如果没有初始值的话,TASK0_STACK[256]
就是一个初始为零的静态类型,这种类型会被放置到.bss
中,目标文件只会记录它的长度和地址之类的东西,而不保存它占用的空间。所以我们必须把它的一个值设为非零,来把它放到.data
中去。)
struct
TASK_STRUCT TASK0 = {
/* tss */
{ /* back_link */
0,
/* esp0 ss0 */
(unsigned
)&TASK0_STACK+sizeof
TASK0_STACK, DATA_SEL,
让esp0
指向“栈底”。下面出现的DATA_SEL
和CODE_SEL
定义在06/include/kernel.h
中,它们是GDT中的数据段和代码段的选择子。
/* esp1 ss1 esp2 ss2 */
0, 0, 0, 0,
/* cr3 */
0,
/* eip eflags */
0, 0,
/* eax ecx edx ebx */
0, 0, 0, 0,
/* esp ebp */
0, 0,
/* esi edi */
0, 0,
/* es cs ds */
USER_DATA_SEL, USER_CODE_SEL, USER_DATA_SEL,
/* ss fs gs */
USER_DATA_SEL, USER_DATA_SEL, USER_DATA_SEL,
/* ldt */
0x20,
/* trace_bitmap */
0x00000000},
/* tss_entry */
0,
/* idt[2] */
{DEFAULT_LDT_CODE, DEFAULT_LDT_DATA},
/* idt_entry */
0,
/* state */
TS_RUNNING,
/* priority */
INITIAL_PRIO,
/* next */
0,
};
我们有了TSS,我们准备做一个TSS描述符,记住GDT中有两个保留的位置。
06/bootsect.s
gdt:
.quad
0x0000000000000000 # null descriptor
.quad
0x00cf9a000000ffff # cs
.quad
0x00cf92000000ffff # ds
.quad
0x0000000000000000 # reserved for tss
.quad
0x0000000000000000 # reserved for ldt
第四个项(0x3)为当前任务的TSS保留,所以定义了CURR_TASK_TSS = 3
作为GDT中这个位置的索引。我们准备让当前任务使用这个位置,一旦它放弃控制,它将这个描述符存在它TASK_STRUCT
中的tss_entry
里。当新任务接过控制权后,它把它自己的在TASK_STRUCT
中的TSS描述符存入这个位置,通过这种方式我们可以允许无限多的任务运行在系统中。因为GDT有长度限制,它总共可以存储8096个描述符,实际上Linux在很长时间里都限制任务数。我不明白为什么有这个限制,因为看起来取消掉这个限制不是很困难的事情。
unsigned
long
long
set_tss(unsigned
long
tss) {long
unsigned
long
tss_entry = 0x0080890000000067ULL;long
tss_entry |= ((tss)<<16) & 0xffffff0000ULL;
tss_entry |= ((tss)<<32) & 0xff00000000000000ULL;
return
gdt[CURR_TASK_TSS] = tss_entry;
}
set_tss
生成TSS描述符,并把它放入GDT中,我们可以看到这个描述符的DPL是0,所以只有内核可以使用这个描述符。
unsigned
long
long
get_tss(void
) {
return
gdt[CURR_TASK_TSS];
}
我们用GDT有一段时间了,LDT就应该很简单了。LDT很像GDT,只是是局部的,每个任务都可以有它自己的LDT,我们要让Skelix中的每一个任务都在LDT中有两个描述符,第一个是代码段描述符,第二个是数据和堆栈段描述符。现在让我们复习一下选择子的格式,
我们要让所有的任务运行在权限等级3,也就是说RPL=11b,并且TI=1指出这个选择子指向LDT。与GDT不同,LDT中的第一个描述符可以被用户使用,因此指向代码段的选择子是0x7,指向数据和堆栈段的选择子是0xF。
TSS中的LDT域是一个指向GDT中描述符的选择子,这个描述符描述了这个LDT。我知道它听起来很令人困惑,所以让我把它说明白点,首先我们让每一个任务有一个LDT,并且这个LDT存在内存中的某个位置(现在我们不关心虚拟内存的问题),并且我们需要一个描述符去描述这个包含着LDT的内存区域,这个描述符会被放到GDT中,指向它的选择子会被放到这个任务TSS的LDT域中。
GDT中的第三个描述符是给当前任务的TSS,第四个非常清楚,是当前任务的LDT。因此它们的选择子分别是0x18和0x20。像处理TSS的函数那样,我们有,
06/task.c
unsigned
long
long
set_ldt(unsigned
long
ldt) {long
unsigned
long
ldt_entry = 0x008082000000000fULL;long
ldt_entry |= ((ldt)<<16) & 0xffffff0000ULL;
ldt_entry |= ((ldt)<<32) & 0xff00000000000000ULL;
return
gdt[CURR_TASK_LDT] = ldt_entry;
}
很像GDT项,除了DPL不是0而是3。
unsigned
long
long
get_tss(void
) {
return
gdt[CURR_TASK_TSS];
}
在此刻我们让所有的任务有同样的LDT内容,意味着它们共享内存空间,这不是个好主意,但我会通过虚拟内存给它们不同的内存空间,会在后面教程里介绍。
06/include/task.h
#define DEFAULT_LDT_CODE 0x00cffa000000ffffULL
#define DEFAULT_LDT_DATA 0x00cff2000000ffffULL
这些是任务LDT中的LDT描述符,它们几乎和GDT一样,除了DPL是3。
我上面提到过,所有的任务都通过单链表连到一起,这里有两个重要的指针,一个是TASK0
中的next
,它是链表头,另一个current
指针指向当前运行的任务。
在开始任务前,我们定义,
06/task.c
struct
TASK_STRUCT *current = &TASK0;
接下来我们看一下任务是怎么生成的,
06/init.c
static
void
new_task(struct
TASK_STRUCT *task, unsigned
int
eip
,
unsigned
stack0, int
unsigned
stack3) {int
new_task
接受四个参数,第一个是新任务的TASK_STRUCT
结构,第二个是程序的入口点,TSS中的eip
将会被赋予此值,所以处理器才知道哪里去执行程序代码,esp0
和esp3
参数是为了设定权限级别0和3时的ESP
,因为它们的选择子有固定值0x10和0xf,ss0和ss就没有被使用。
memcpy(task, &TASK0, sizeof
(struct
TASK_STRUCT));
task->tss.esp0 = stack0;
task->tss.
= eip
;eip
task->tss.eflags = 0x3202;
task->tss.esp
= stack3;
task->priority = INITIAL_PRIO;
task->state = TS_STOPPED;
TASK0
被用来当做模板,我们仅需改变几个域就可以。在任务链接正确修改前,我们改变新任务状态到TS_STOPPED
。否则,另一个new_task
调用时会破坏链表。
task->next = current->next;
current->next = task;
task->state = TS_RUNABLE;
}
将新任务加入链表中。
extern
task1_run(void
);void
extern
task2_run(void
);void
它们是新任务,一会儿介绍。
static
long
task1_stack0[1024] = {0xf, };
static
long
task1_stack3[1024] = {0xf, };
static
long
task2_stack0[1024] = {0xf, };
static
long
task2_stack3[1024] = {0xf, };
因为本节中还没有内存管理,所以我们仅仅建立几个数组作为任务堆栈。
void
init(void
) {
char
wheel[] = {'\\', '|', '/', '-'};
int
i = 0;
struct
TASK_STRUCT task1;
struct
TASK_STRUCT task2;
idt_install();
pic_install();
kb_install();
timer_install(1000);
set_tss((unsigned
long
)&TASK0.tss);long
set_ldt((unsigned
long
)&TASK0.ldt);long
载入TASK0
的LDT和TSS描述符到GDT中。
__asm__
("ltrw
%%ax
\n\t"::"a"(TSS_SEL));
__asm__
("lldt
%%ax
\n\t"::"a"(LDT_SEL));
像GDT和LDT那样,也有两个为TSS准备的寄存器,TR
和LDTR
,它们分别用ltr
和lldt
指令装入。在操作任务之前,我们必须在TR
和LDTR
中装入有效的值,否则你会得到一般保护错。
sti();
new_task(&task1,
(unsigned
int
)task1_run,
(unsigned
int
)task1_stack0+sizeof
task1_stack0,
(unsigned
int
)task1_stack3+sizeof
task1_stack3);
new_task(&task2,
(unsigned
int
)task2_run,
(unsigned
int
)task2_stack0+sizeof
task2_stack0,
(unsigned
int
)task2_stack3+sizeof
task2_stack3);
建立两个新任务,注意,建立之前,中断已经被打开了。
__asm__
("movl
%%esp
,%%eax
\n\t" \
"pushl
%%ecx
\n\t" \
"pushl
%%eax
\n\t" \
"pushfl
\n\t" \
"pushl
%%ebx
\n\t" \
"pushl
$1f\n\t" \
"iret
\n" \
"1:\tmovw %%cx
,%%ds
\n\t" \
"movw
%%cx
,%%es
\n\t" \
"movw
%%cx
,%%fs
\n\t" \
"movw
%%cx
,%%gs
" \
::"b"(USER_CODE_SEL),"c"(USER_DATA_SEL));
现在内核成了任务TASK0
,使用一个长返回,它从TASK0
中载入了正确的EIP
、CS
、SS
和ESP
的值,栈看起来像这样,
+--------------------+
| LDT stack selector |
+--------------------+
| ESP |
+--------------------+
| EFLAGS |
+--------------------+
| LDT code selector |
+--------------------+
| EIP |
+--------------------+
接着从LDT中载入数据段选择子。
for
(;;) {
__asm__
("movb
%%al
, 0xb8000+160*24"::"a"(wheel[i]));
if
(i == sizeof
wheel)
i = 0;
else
++i;
}
}
现在所有的任务都在权限级3运行,所以其它有更高权限级的部分已经无法访问了,也就是保护的概念。但是在Skelix中,我不准备把内存空间完全分离,内存保护会通过虚拟内存的内存映射完成。在下面的教程中,用户任务可以通过系统调用访问系统函数。
现在我们有了三个任务,那么哪一部分会进行任务切换?像你想的那样,我们已经让计时器定期中断,所以这是当然的选择。我们必须像这样改变计时器中断的代码,
06/timer.c
do_timer(void
) {void
struct
TASK_STRUCT *v = &TASK0;
int
x, y;
++timer_ticks;
get_cursor(&x, &y);
set_cursor(71, 24);
kprintf(KPL_DUMP, "%x", timer_ticks);
set_cursor(x, y);
outb(0x20, 0x20);
cli();
for
(; v; v=v->next) {
遍历任务列表,改变所有任务的优先级。
if
(v->state == TS_RUNNING) {
if
((v->priority+=30) <= 0)
v->priority = 0xffffffff;
} else
v->priority -= 10;
}
所有任务的优先级都会在时钟中断期间被改变,当前运行的任务有更高数值的优先级,也就是更低的优先级。等待的任务获得更高的优先级。
if
(! (timer_ticks%1))
scheduler();
在两个时钟滴答时重新调度所有任务。
sti();
}
看一下scheduler
,
scheduler(void
) {void
struct
TASK_STRUCT *v = &TASK0, *tmp = 0;
int
cp = current->priority;
for
(; v; v = v->next) {
if
((v->state==TS_RUNABLE) && (cp>v->priority)) {
tmp = v;
cp = v->priority;
}
}
遍历任务链表,执行拥有最高优先级的任务,也就是优先级数值最低的一个。
if
(tmp && (tmp!=current)) {
current->tss_entry = get_tss();
current->ldt_entry = get_ldt();
我们把GDT中TSS和LDT描述符的值存入TASK_STRUCT
,因为描述符中的一些状态字可能被处理器改变了。
tmp->tss_entry = set_tss((unsigned
long
)((long
unsigned
int
)&tmp->tss));
tmp->ldt_entry = set_ldt((unsigned
long
)((long
unsigned
int
)&tmp->ldt));
current->state = TS_RUNABLE;
tmp->state = TS_RUNNING;
current = tmp;
将要运行任务的TSS和LDT存入GDT,并改变它们的state
。
__asm__
__volatile__
("ljmp
$" TSS_SEL_STR ", $0\n\t");
}
}
Skelix使用长转跳指令来执行任务切换,段部分是TSS选择子,偏移部分被处理器忽略。TSS_SEL_STR
被定义为"$0x18",必须包含引号,C才能把字符串连在一起。
最后,我们看一下task1_run
和task2_run
的代码,它们的入口由汇编写成而不是C,因为我现在不想处理C函数的栈变化。这两个任务其实调用了另外的C函数去做实际工作。
task1_run:
call
do_task1
jmp
task1_run
task2_run:
call
do_task2
jmp
task2_run
首先,让我们检查一下是否任务在权限3工作正常,我们调用kprintf
来打印任务当前使用的CS
,然后让它们原地跳,否则它们的输出不会停止,屏幕上翻太快什么也看不到。
06/init.c
void
do_task1(void
) {
unsigned
int
cs;
__asm__
("movl
%%cs
, %%eax
":"=a"(cs));
kprintf(KPL_DUMP, "%x", cs);
for
(;;)
;
}
void
do_task2(void
) {
unsigned
int
cs;
__asm__
("movl
%%cs
, %%eax
":"=a"(cs));
kprintf(KPL_PANIC, "%x", cs);
for
(;;)
;
}
不要忘记在Makefile中的KERNEL_OBJS
加入新的模块,
06/Makefile
KERNEL_OBJS= load.o init.o isr.o timer.o libcc.o scr.o kb.o task.o kprintf.o exceptions.o
我们可以看到,首先我们使用LDT,并且CS
是0x7,另一件事是kprintf
使用全局缓冲,所以实际上所有的任务使用同一个缓冲区,因此当一个任务开始输出另一个任务尚未结束时,两个任务的输出可能会混在一起。
让我们看点好玩的,我们让两个任务输出不同的字符。
06/init.c
void
do_task1(void
) {
print_c('A', BLUE, WHITE);
}
void
do_task2(void
) {
print_c('B', GRAY, BROWN);
}
你可以自由使用我的代码,如有疑问请联系我。