分类: LINUX
2006-06-07 19:26:35
简单的进程调度
Linux0.11的进程调度代码在kernel/sched.c中,主要由四个函数实现,分别是schedule(),sleep_on(),wake_up(),switch_to()。
下面看看进程的数据结构:
struct task_struct
{
/* these are
hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields
*/
int exit_code;
unsigned long
start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long
utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system
info */
int tty; /*
-1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this
task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this
task */
struct tss_struct tss;
};
进程创建时,优先级priority被赋一个初值,一般为
0~70之间的数字,这个数字同时也是计数器counter的初值,就是说进程创建时两者是相等的。字面上看,priority是"优先级"、 counter是"计数器"的意思,然而实际上,它们表达的是同一个意思-进程的"时间片"。Priority代表分配给该进程的时间片,counter
表示该进程剩余的时间片。在进程运行过程中,counter不断减少,而priority保持不变,以便在counter变为0的时候(该进程用完了所分配的时间片)对counter重新赋值。当一个普通进程的时间片用完以后,并不马上用priority对counter进行赋值,只有所有处于可运行状态的普通进程的时间片(p->counter==0)都用完了以后,才用priority对counter重新赋值,这个普通进程才有了再次被调度的机会。这说明,普通进程运行过程中,counter的减小给了其它进程得以运行的机会,直至counter减为0时才完全放弃对CPU的使用,这就相对于优先级在动态变化,所以称之为动态优先调度。至于时间片这个概念,和其他不同一样的,Linux的时间单位也是"时钟滴答",只是不同对
一个时钟滴答的定义不同而已(Linux为10ms)。进程的时间片就是指多少个时钟滴答,比如,若priority为20,则分配给该进程的时间片就为 20个时钟滴答,也就是20*10ms=200ms。Linux中某个进程的调度策略(policy)、优先级(priority)等可以作为参数由用户自己决定,具有相当的灵活性。内核创建新进程时分配给进程的时间片缺省为200ms(更准确的应为210ms),用户可以通过系统调用改变它。
下面我们首先来看schedule()函数,该函数首先检查任务数组中的所有任务,对每一个任务先检查它的报警定时器alarm,如果时间已经过期,则设置信号位图位SIGALRM。
接着检查信号位图,如果出去被堵塞的信号外还有其他信号,并且任务是可中断的,则把任务状态设置为可运行。
接着检查每一个任务的时间值counter,选择值最大的一个。
如果所有任务的时间值都为0,则要根据优先级重新计算时间片,然后继续循环。
最后通过函数switch_to()切换到所选择任务号的进程执行。
void
schedule(void)
{
int i,next,c;
struct task_struct ** p;
/* check alarm,
wake up any interruptible tasks that have got a signal */
//检查任务的状态
for(p = &LAST_TASK ; p >
&FIRST_TASK ; --p)
if (*p) {
if ((*p)->alarm
&& (*p)->alarm < jiffies) {
(*p)->signal
|= (1<<(SIGALRM-1));
(*p)->alarm
= 0;
}
if (((*p)->signal &
~(_BLOCKABLE & (*p)->blocked)) &&
(*p)->state==TASK_INTERRUPTIBLE)
(*p)->state=TASK_RUNNING;
}
/* this is the
scheduler proper: */
//找到需要切换执行的任务的任务号
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state ==
TASK_RUNNING && (*p)->counter > c)
c =
(*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p >
&FIRST_TASK ; --p)
if (*p)
(*p)->counter =
((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);//切换到该任务执行
}
如果要让某个等待的队列睡眠,则调用sleep_on()函数,比如在
tatic inline void
wait_on_buffer(struct buffer_head * bh)
{
cli();
while (bh->b_lock)
sleep_on(&bh->b_wait);
sti();
}
需要当前进程睡眠,且调用sleep_on()函数即可。
参数 p是需要睡眠队列的头指针,要使当前进程睡眠,把他的状态设置为TASK_UNINTERRUPTIBLE,然后调用调度函数继续执行,直到该队列被唤醒,则会恢复到调度点继续执行。
void
sleep_on(struct task_struct **p)
{
struct task_struct *tmp;
if (!p)
return;
if (current == &(init_task.task))
panic("task[0] trying to
sleep");
tmp = *p;
*p = current;
current->state = TASK_UNINTERRUPTIBLE;
schedule();
if (tmp)//被唤醒的返回点,继续执行
tmp->state=0;
}
在这里构筑了一个隐士的等待队列:因为tmp为一个局部的变量,所以在当前进程空间堆栈上。
current Task C Task
B Task A
|
tmp |
Task_struct |
|
tmp |
Task_struct |
|
tmp |
Task_struct |
|
tmp |
|
我们来看当前进程调用了sleep_on(&bh->b_wait)函数,并且bh->b_wait为task C,则在缓冲区的等待进程的队列上有task A,task B,task C;所以进入sleep_on()函数后,通过局部变量tmp指向了task C,则形成了隐士的队列。然后把当前进程状态设置成不可中断的,通过*p = current;使bh->b_wait指向当前的进程。
调用了wake_up()函数后,则当前进程被设置成就绪态,等待调度,然后把等待队列的头指针设置为NULL。
如果当前进程被调度到了, cpu会跳到switch_to中ljmp后面的那条指令,然后从schedule返回。执行if (tmp) tmp->state=0; ,
则当前进程把下一个进程task
C 设置成可运行态,参与系统的调度。而当前进程继续执行。
唤醒函数就是把睡眠的任务的状态设置为0(运行态)。
void
wake_up(struct task_struct **p)
{
if (p && *p) {
(**p).state=0;
*p=NULL;
}
}
下面在讲述任务切换之前,我们要补充其它的知识。
一、i386体系结构与kernel
存储管理
提到进程管理就必须首先了解基本的i386体系结构和存储管理。这是因为体系结构决定了存储管理的实现,而进程管理又与存储管理密不可分。在现代操作系统中,存储管理一般都采用虚拟存储的方式,也就是系统使用的地址空间与实际物理地址空间不同,是“虚拟”的地址。处理器提供一定的机制将“虚拟”的地址转换为实际的地址。操作系统的存储管理就是基于处理器提供的地址转换机制来实现的。
基本的存储管理有两种方式,即段式和页式。段式管理就是将内存划分为不同的段(segment),通过段指针来访问各个段的方法。在比较早的系统中,如pdp-11等就是采用这种方法。这种方法的缺点是在设计程序时必须考虑段的划分,这是很不方便的。页式管理就是将内存划分为固定大小的若干个页面(page),以页面为单位分配使用。通过页映射表完成地址转换。
i386 的存储管理采用两级虚拟的段页式管理,就是说它先分段,再分页。具体地说,它通过gdt(Global Descriptor Table)和ldt(Local Descriptor Table)进行分段,把虚拟地址转换为线性地址。然后采用两级页表结构进行分页,把线性地址转换为物理地址。
在Linux中,操作系统处于处理器的0特权级,通过设置gdt,将它的代码和数据放在独立的段中,以区别于供用户使用的用户数据段和代码段。所有的用户程序都使用相同的数据段和代码段,也就是说所有的用户程序都处于同一个地址空间中。程序之间的保护是通过建立不同的页表映射来完成的。
段变换:
首先段寄存器中放的是选择符,指令所在的位置叫做偏移值。
通过选择符,在GDT表中找到相应的描述符项,把描述符项中的基地址加上偏移值就得到线形地址,
页变换:
然后把线形地址通过页目录和页表找到相应的物理地址。
描述符为:struct desc_struct {long a,b;};下面为代码段和数据段的描述符:
31 23 15 7 0
(BASE)位31..24 |
g |
x |
o |
Avl |
Limit 位19..16 |
p |
dpl |
1 |
type |
(BASE)位23..16 |
段基地址(BASE)位15..0 |
段限长(LIMIT)位15..0 |
BASE :32位的基地址;
LIMIT:20位长度;
如果g为0,则以1字节为单元;
如果g为1,则以4K字节为单元,段限长左移12位;
g(granularity)粒度位,指示LIMIT的单元类型;
type:区分不同类型的描述符;
dpl:descriptor
privilege level描述符特权级0和3级;
p:present bit段存在位,该位为1指示描述符存在;
一个描述符项共8字节。
线形地址的具体格式为:
31 22 21 12 11 0
页目录(DIR) |
页(PAGE) |
偏移值(OFFSET) |
高10位为页目录的偏移值;
中间10位为页表项的偏移值;
低12位为偏移值,该偏移为在4K的物理页面的偏移
二、
进程管理
进程是一个程序的一次执行的过程,是一个动态的概念。在i386体系结构中,任务和进程是等价的概念。进程管理涉及了系统初始化、进程创建/消亡、进程调度 以及进程间通讯等等问题。在Linux的内核中,进程实际上是一组数据结构,包括进程的上下文、调度数据、信号处理、进程队列指针、进程标识、时间数据、 信号量数据等。这组数据都包括在进程控制块PCB(Process
Control Block)中。
Linux进程管理与前面的 i386体系结构关系十分密切。前面已经讨论了i386所采用的段页式内存管理的基本内容,实际上,i386中的段还有很多用处。例如,在进程管理中要用到一种特殊的段,就是任务状态段tss(Task Status Segment)。每一个进程都必须拥有自己的tss,这是通过设置正确的tr寄存器来完成的。根据i386体系结构的定义,tr寄存器中存放的是tss 的选择符(selector),该选择符必须由gdt中的项(即描述符descriptor)来映射。同样的,进程的ldt也有这种限制,即ldtr对应的选择符也必须由gdt中的项来映射。
在kernel中,为了满足i386体系结构的这种要求,采用了预先分配进程所需要的gdt项目的方法。也就是为每一个进程保留2项gdt条目。进程PCB中 的tr和ldtr值就是它在gdt中的选择符。进程与它的gdt条目的对应关系可以由task数组来表示。
下面是详细的分析。
1.系统初始化
在Linux中,一些与进程管理相关的数据结构是在系统初始化的时候被初始化的。其中最重要的是gdt和进程表task。
Gdt的初始化主要是确定需要为多少个进程保留空间,也就是需要多大的gdt。
在boot/head.s文件中完成GDT表,IDT表和一个页目录和4个页表的安装;
部分程序代码分析:
_pg_dir: /*页目录存放的位置,为物理内存的绝对地址0x0000处*/
call setup_idt /*设置中断描述符表*/
call setup_gdt /*设置gdt描述符表*/
…………………………..
jmp after_page_tables /*安装页目录和页表*/
setup_gdt: /* gdt描述符表开始处*/
lgdt gdt_descr /*load 全局描述符表寄存器*/
ret
gdt_descr: /*下面两行是lgdt的6字节操作数:长度,基址。*/
.word 256*8-1 /*占用2个字节,表示长度为 2143=256*8-1个字节*/
.long _gdt /* 占用4个字节,表示gdt的基地址*/
/*linux 0.11的全局表,前4项分别是:空项(不用)、内核代码段描述符、内核数据段描述符、系统段描述符,其中系统段描述符没有使用。后面还留有252项的空间,用于存放任务的局部描述符(LDT)和对应的任务状态段(TSS)描述符*/
/*(0-null, 1-cs, 2-ds, 3-sys, 4-TSS0, 5-LDT0, 6-TSS1, 7-LDT1….)*/
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x
.quad 0x
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
/*252项,每项8字节,填0*/
我们算一下内核代码段的长度最大长度是不是
而0x
而页目录和页表的具体安装代码为:
etup_paging: /*首先对5页内存(1页目录和4页页表)清0。*/
movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables */
xorl %eax,%eax
xorl %edi,%edi /* pg_dir is at 0x000 */
cld;rep;stosl /*页目录在地址0x000,本程序的开始的标号既是*/
/*下面代码在页目录项中设置4个页表的属性和地址*/
movl $pg0+7,_pg_dir /* set present bit/user r/w */
movl $pg1+7,_pg_dir+4 /* --------- " " --------- */
movl $pg2+7,_pg_dir+8 /* --------- " " --------- */
movl $pg3+7,_pg_dir+12 /* --------- " " --------- */
/*下面代码填写4个页表的页表项的内容,共有:4(页表)*1024(项)=4096项, 每4K一个页面,则能映射物理内存4096*4K=
每项的内容为:当前映射的物理内存地址+该页的标志(7)。
使用方法是从最后一个页表的最后一项开始按倒退顺序填写。一个页表的最后一项在页表中的位置是1023*4=4092。因此最后一页的最后一项的位置就是$pg3+4092.
*/
/*物理内存也是从最后一个页面的开始地址(
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax
jge 1b
/*设置页目录基地址寄存器cr3的值,指向页目录表*/
xorl %eax,%eax /* pg_dir is at 0x0000 */
movl %eax,%cr3 /* cr3 - page directory start */
/*启动分页机制,cr0的PG位31为1*/
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* set paging (PG) bit */
ret /* this also flushes prefetch-queue */
从这里ret过后,先前被push进堆栈的main函数弹出运行。伟大的征程开始了!!!
初始化完成后,内核模块在内存中的位置为:
. |
||
Lib 模块代码 |
||
fs 模块代码 |
||
mm模块代码 |
||
Kernel 模块代码 |
||
mian.c程序代码 |
||
全局描述符表(2k) |
||
中断描述符表(2k) |
||
Head.s程序中部分代码 |
||
软盘缓冲区(1K) |
||
0x4000 |
||
0x3000 |
||
0x2000 |
||
0x1000 |
||
0x0000 |
其中绿色部分为head.s程序的代码,也就是说内存中的最低部分的代码为head的原因了。
进程表task实际上是一个PCB指针数组,其定义如下: Struct task_struct *task[NR_TASKS] =
{&init_task,};
其中,init_task是系统的第0号进程,也是所有其它进程的父进程。系统在初始化的时候,必须手工设置这个进程,把它加到进程指针表中去,才能启动进程管理机制。可以看出,这里task的大小同样依赖于NR_TASKS的定义。
2.进程创建
在Linux 中,进程是通过系统调用fork创建的,新的进程是原来进程的子进程。需要说明的是,不存在真正意义上的线程 (Thread)。Linux中常用的线程pthread实际上是通过进程来模拟的。也就是说linux中的线程也是通过fork创建的,是“轻”进程。 fork系统调用的流程如下:
fork()->system_call(kernel/system_call.s)->sys_fork(kernel/system_call.s)->
find_empty_process()->copy_process()
看看sys_fork的代码:
_sys_fork:
call _find_empty_process
testl %eax,%eax
js
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
1: ret
所以在sys_fork中是调用两个C函数来完成进程的创建的,find_empty_process()(kernel/fork.c)主要是用来在全局task表中找到一个空闲项,并返回该task数组中的标号,同时增长进程的last_pid(最新进程号)。
拷贝进程用于创建并复制进程的代码段和数据段和环境。在进程复制过程中,主要牵涉进程数据结构中信息的设置。系统首先为新创建的进程在主内存区中申请一页内存来存放其任务数据结构信息并复制当前进程任务数据结构中的所有内容作为新进程任务数据结构的模板。
然后对复制的任务数据结构进行修改。把当前进程设置为新进程的父进程,清除信号位图并复位新进程各统计值。接着根据当前任务进程设置任务状态段(TSS)中各寄存器的值。由于创建时新进程返回值应该为0,所以要设置tss.eax=0。新进程内核态堆栈指针tss.esp0被设置成新任务数据结构所在内存页面的顶端,而堆栈段tss.ss0被设置成内核数据段选择符。Tss.ldt设置为局部描述符在GDT中的索引。
此后系统设置新任务的代码和数据段基址、限长并复制当前进程内存分页管理的页表。如果父进程中有文件是打开的,则将对应文件的打开次数增1。接着在GDT中设置新任务的TSS和LDT描述符项,其中基地址信息指向新进程任务结构中的tss和ldt。最后再将新任务设置成可运行状态并返回新进程号。
其中参数nr是调用find_empty_process()分配的任务数组项号。none是system_call.s中调用sys_call_table时压入堆栈的返回地址。
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,`
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
/*复制当前进程的内容,不会复制堆栈*/
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->father = current->pid;
p->counter = p->priority;
p->signal = 0;
p->alarm = 0;
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0;
p->cutime = p->cstime = 0;
p->start_time = jiffies;
//以下设置任务状态段TSS所需要的数据
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;//内核态堆栈指针,指向任务所在页的顶端.
//ss0:esp0用作进程在内核态工作时的堆栈。
p->tss.ss0 = 0x10; //堆栈段选择符,与内核数据段相同
p->tss.eip = eip;
p->tss.eflags = eflags;
p->tss.eax = 0;
p->tss.ecx = ecx;
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
p->tss.ebp = ebp;
p->tss.esi = esi;
p->tss.edi = edi;
p->tss.es = es & 0xffff;
p->tss.cs = cs & 0xffff;
p->tss.ss = ss & 0xffff;
p->tss.ds = ds & 0xffff;
p->tss.fs = fs & 0xffff;
p->tss.gs = gs & 0xffff;
p->tss.ldt = _LDT(nr);//设置新任务的局部描述符表的选择符(LDT描述符在GDT中)
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
//设置新任务的局部描述符ldt的代码和数据段基址、限长并复制页表。
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
//父进程中有文件是打开的,则将文件的计数增1
for
(i=0; i
if (f=p->filp[i])
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
//在GDT中设置新任务的TSS和LDT描述符项,数据从task数组中取。
//当任务切换时,任务寄存器tr由CPU自动加载(tr中存放的是TSS在GDT中的选择符)。
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case */
return last_pid;
}
比较关键的步骤是:
a)将新进程的PCB加入到进程表中。从前面的讨论可以知道,进程表 task的大小是预先定义的,所以首先要从task中找到一个空的表项:nr
= find_empty_process()。如果不能找到空的表项,说明系统已经达到了最大进程数的上限,不能创建新进程。返回的nr是空闲表项的下标。
b)将父进程的地址空间复制到子进程中。在这一步中,还要复制父进程的ldt到子进程,然后在gdt中建立子进程的ldt描述符(descriptor),并将这个选择符保存在PCB中。
为子进程设置tss。同时在gdt中建立子进程的tss描述符,并将这个选择符保存在PCB中。
拷贝内存函数首先取得当前进程的代码段限长和数据段限长,从ldt中取得代码段和数据段的基地址。然后计算新进程的基址,每一个进程的线形空间大小是
然后设置新进程的代码段和数据段的基地址,并复制代码和数据段。
int copy_mem(int nr,struct task_struct * p)
{
unsigned long old_data_base,new_data_base,data_limit;
unsigned long old_code_base,new_code_base,code_limit;
code_limit=get_limit(0x
data_limit=get_limit(0x17);//取局部描述符表中数据段描述符项ldt[2]的限长;
//从ldt[1]这个描述符项中取代码段基址。
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
if (old_data_base != old_code_base)
panic("We don't support separate I&D");
if (data_limit < code_limit)
panic("Bad data_limit");
new_data_base
= new_code_base = nr * 0x4000000;// 基址=进程号*
p->start_code = new_code_base;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}
return 0;
}
再来看看怎么取得局部描述符表中代码段或者数据段的基址,get_base()函数是一个宏,把ldt转换成所在内存中的地址,结合描述符结构各字节的含义直接操作内存地址。
#define get_base(ldt)
_get_base( ((char *)&(ldt)) )
/*从地址addr处描述符中取段基地址。.
edx—存放基地址base;%1—地址addr偏移2;%2—地址addr偏移4;%3—地址addr偏移7;
*/
#define _get_base(addr) ({\
unsigned long __base; \
__asm__("movb %3,%%dh\n\t" \ //取[addr+7]处基地址高16位中的高8位 (位31-24)àdh
"movb %2,%%dl\n\t" \ //取[addr+4]处基地址高16位中的低8位 (位23-16)àdl
"shll $16,%%edx\n\t" \ //基地址高16位移到edx中高16位处。
"movw %1,%%dx" \ //取[addr+2]基址base低16位 (位15-0)àdx
:"=d" (__base) \ //从而edx中含有32位的段基地址。
:"m" (*((addr)+2)), \
"m" (*((addr)+4)), \
"m" (*((addr)+7))); \
__base;})
下面我们看看在GDT中怎么设置新进程的TSS描述符项。
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
为什么用gdt加上一个数字了,因为gdt的描述符项是8个字节为一项的,所以gdt+4就直接跳到了任务0的TSS的描述符的地址处,nr<<1=nr*2表示一个任务要两个描述符项(TSS和LDT)。而&(p->tss)表示新进程的TSS的地址。所以我们可以猜测在全局描述符表(GDT)中,新任务的TSS描述符项的基地址部分就是进程的tss所在的地址。
在全局描述符表中设置任务状态段描述符。
//n—是该描述符的指针;addr—是描述符中的基地址值;任务状态段描述符的类型是0x89。
#define
set_tss_desc(n,addr) _set_tssldt_desc(((char *) (n)),addr,"0x89")
//参数: n –在全局表中描述符项n所对应的地址;addr—状态段/局部表所在内存的基地址;type--描述符中的标志类型字节。
%0-eax(地址addr);%1--(描述符项n的地址);%2--(描述符项n的地址偏移2处);
%3-(描述符项n的地址偏移4处);%4-(描述符项n的地址偏移5处);
%5-(描述符项n的地址偏移6处);%6-(描述符项n的地址偏移7处);
#define _set_tssldt_desc(n,addr,type) \
__asm__ ("movw $104,%1\n\t" \ //将TSS长度放入描述符长度域(第0-1字节)
"movw %%ax,%2\n\t" \ //将基地址的低字放入描述符第2-3字节。
"rorl $16,%%eax\n\t" \ //将基地址高字移入ax中。
"movb %%al,%3\n\t" \ //将基地址高字中低字节移入描述符第4字节。
"movb $" type ",%4\n\t" \ //将标志类型字节移入描述符第5字节。
"movb $0x00,%5\n\t" \ //描述符第6字节置0。
"movb %%ah,%6\n\t" \ //将基地址高字中高字节移入描述符第7字节。
"rorl $16,%%eax" \ //eax清0。
::"a" (addr), "m" (*(n)), "m" (*(n+2)), "m" (*(n+4)), \
"m" (*(n+5)), "m" (*(n+6)), "m" (*(n+7)) \
)
下面是描述符各字节所代表的意思:
31 23 15 7 0
(BASE)位31..24 |
g |
x |
o |
Avl |
Limit 位19..16 |
p |
dpl |
1 |
type |
(BASE)位23..16 |
段基地址(BASE)位15..0 |
段限长(LIMIT)位15..0 |
基地址由下面偏移组成:描述符项n的地址偏移2处,描述符项n的地址偏移4处,描述符项n的地址偏移7处。
类型:描述符项n的地址偏移5处。
段限长:描述符项n的地址,描述符项n的地址偏移6处。
3. 进程调度 这里我们暂不讨论进程调度的算法,只是看一看进程切换时应该做的工作。
进程切换的思想:跳转到一个任务的TSS段选择符组成的地址处会造成CPU进行任务切换操作。
//输入:%0—偏移地址(*&__tmp.a); %1—存放新TSS的选择符;
dx—新任务n的TSS段选择符; ecx—新任务指针task[n].
//其中临时数据结构__tmp用于组建远跳转(far jump)指令的操作数。该操作数由4字节偏移地址和2字节段选择符组成。因此__tmp中a的值是32位偏移值,而b的低2字节是新tss段的选择符(高2字节不用),跳转到TSS段选择符会造成任务切换到该TSS对应的进程。对于造成任务的长跳转,a值无用。内存间接跳转指令使用6字节操作数作为跳转目的地的长指针,
其格式为:jmp 16位段选择符:32位偏移值。但在内存操作中操作数的表示顺序与这里正好相反。
#define
switch_to(n) {\
struct {long a,b;}
__tmp; \
__asm__("cmpl
%%ecx,_current\n\t"\ //新任务n是当前任务吗?(current==task[n])
"je
"movw %%dx,%1\n\t" \ //将新任务16位选择符存入__tmp.b中。
"xchgl %%ecx,_current\n\t" \ //current=task[n];ecx=被切换出的任务。
"ljmp %0\n\t" \ //执行长跳转到*&__tmp,造成任务切换。
"cmpl
%%ecx,_last_task_used_math\n\t" \ //在任务切换回来后执行。
"jne
"clts\n" \
"1:" \
::"m"
(*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c"
((long) task[n])); \
}
ljmp %0可以解释为:ljmp *&__tmp.a
因为__tmp是一个结构struct
{long a,b;} __tmp; 所以在内存中是相临的,则跳转为:ljmp
*&__tmp.a,*&__tmp.b因为组成一个地址是这样的
ljmp selector:offset ;所以选择符是16位的,offset是32位。
这是就是逻辑地址到线形地址的寻址。
那么这个时候selector就是在全局描述符的偏移,也就是该TSS描述符项相对GDT开始的偏移字节。
INTEL CPU判断这是一个TSS的描述符项的时候,会造成任务切换到该TSS对应的进程执行,就不需要偏移地址了。
LINUX任务的的两个堆栈:
任务的内核态堆栈:和task_struct在同一个物理页面,且在copy_process时,p->tss.ss0=0x10;为内核态堆栈的堆栈段的选择符和内核数据段相同, p->tss.esp0= PAGE_SIZE + (long) p; 为内核态堆栈的堆栈指针指向PCB所在页面的顶部;
使用:在任务通过系统调用进入内核态时,用到内核态堆栈。
任务的用户态堆栈: 位于任务进程空间的末端,图示:
代码 |
数据 |
Bss |
堆栈 |
参数入栈部分 |
参数和环境变量 |
nr*
堆栈指针
此图显示为fork()产生的进程空间的示意图,黄色部分表示堆栈中已经有了内容,该逻辑地址已经和实际的物理地址对应上了,前面灰色的部分表示逻辑地址还没有对应上物理地址。当程序执行时发生页错误,产生中断,则调用函数page_fault()(mm/page.s),该函数区分是缺少页面或者是共享页面引发的错误,如果是缺页,则调用do_no_page()申请页面,否则调用write
protect (写时保护)函数do_wp_page()分配页面。
堆栈指针p->tss.esp,堆栈段p->tss.ss.调用fork()后和父进程的堆栈空间共享。如果在子进程中通过execve执行了一个程序,则重新计算进程的用户态堆栈指针。
使用:用户态运行时使用的堆栈。
用fork创建进程
除了进程0,其它所有的进程都是fork产生的。子进程是通过复制父进程的数据和代码产生的。创建结束后,子进程和父进程的代码段、数据段共享。但是子进程有自己的进程控制块、内核堆栈和页表。
我们知道一个进程需要有如下3个结构
1. task[]数组中的一项,即进程控制块(task_struct)
2. GDT中的两项,即TSS段和LDT段描述符
3. 页目录和页表
所以fork()的任务就是为一个新进程构造这3个结构。
sys_fork() 系统调用的实现在2个文件中。fork.c中的全部和system_call.s中208-291行。sys_fork()系统调用分成2步完成,第一步调用函数find_empty_process(),在task[]数组中找一项空闲项,第二步调用copy_process() 函数,复制进程。
sys_fork() 入口
_sys_fork:
// 第一步,调用find_empty_process()函数,找task[]中的空闲项。
// 找到后数组下标放在eax中。如果没找到直接跳转到ret指令。
call
_find_empty_process
testl %eax,%eax
js
push
%gs //
中断时没有入栈的寄存器入栈,
// 作为copy_process() 函数的参数
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
// 第二步,调用copy_process() 函数复制进程。
call _copy_process
addl $20,%esp
1: ret
程序调用copy_process() 函数时,
当前进程内核堆栈的情况如下:
有了这个图就可以很好的理解进程从用户态进入内核态时,进程内核堆栈的变化了。这个图是一位oldlinux论坛上的网友画的。
我们来看一下move_to_user_mode()这个宏是怎样实现从内核态到用户态的切换的。
#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \
"pushl
$0x17\n\t" \
"pushl
%%eax\n\t" \
"pushfl\n\t"
\
"pushl $0x
"pushl $
"iret\n"
\
"1:\tmovl
$0x17,%%eax\n\t" \
"movw
%%ax,%%ds\n\t" \
"movw
%%ax,%%es\n\t" \
"movw
%%ax,%%fs\n\t" \
"movw
%%ax,%%gs" \
:::"ax")
5个push对应ss
esp eflags cs eip
(1)任务0的用户态堆栈段选择符:SS=0X17。
(2)任务0的用户堆栈栈顶指针:ESP=当前内核程序堆栈栈顶指针。
(3)CPU状态=当前内核程序运行时CPU状态
(4)任务0的代码段选择符CS=0x
(5)任务0的指令指针EIP指向任务0的初始化指令处(即move_to_user_mode()的第9行,为”1 :”标号处)
Iret 返回后,ds,es,fs,gs的选择符都为0x17=0001 0111b,根据下面的理论:
每个任务都有自己的局部描述符表LDT。其中保存着该任务的代码段和数据段描述符。而数据段描述符的选择符是0x17,即
0b0001,0111
比特0,1表示该选择符的请求者特权级RPL;
比特2指明示是当前局部表;
比特3--15是局部表中描述符的索引值。索引值2指向数据段(ldt[2])。
所以任务0转到用户态运行后就设置了自己的各个寄存器的值了!
参考参考文献:
1、 <
2、
<
|