根据赵博的完全注释写的。
个人认为看进程调度,必须知道一点x86的知识。希望下面的东西有用。文笔不好乱写的。
有空一起钻研。
基础知识
系统地址寄存器:
系统寄存器时用来管理在保护模式下使用系统表。用于内存的系统表有全局描述符(GDT),局部表述表(LDT),中断描述表(IDT)。系统寄存器(在本章中如无特殊说明系统寄存器只包含用于内存管理的系统寄存器)有全局描述符表寄存器(GDTR),局部表述符表寄存器(LDTR),中断表述表寄存器(IDTR)。
GDTR: 是48位寄存器,存放全局表述符表GDT的32位线性地址,16位GDT表的界限值。由ldt装载48位寄存器。
IDTR: 也是48位寄存器,存放中断描述符表LDT的 32 位线性地址,16位IDT表的界限值。lidt装载48位寄存器。
LDTR:这个寄存器比较特殊,分为用户可见部分(16位)和不可见部分(48位 ,用来存放LDT的基址和界限), 有特权指令 lldt 装载。
IDT的设置方式和格式将在进程管理中介绍。
X86特有的段式管理:
经过段变换把程序的虚拟地址转换成线性地址,线性地址再经过页式变换后变成物理地址,输出到地址线,即可访问到正确的数据或者指令。
在386中有6个段寄存器 CS, DS, ES,SS, FS,GS,在实地址模式下这些寄存器是用来存放16段地址的, 我们都知道通过 CS:IP的形式访问 20 位的物理地址,也即CS左移4位然后加上 IP正好是20位地址总线。在保护模式下段寄存器的作用已经改变。在保护模式的段式管理中,每个程序都有一个段表述符(指GDT和LDT的一个项,具体格式将在后面介绍),至于选择GDT还是LDT中的项,以及具体到底是哪一项,就有段寄存器(CS,DS)决定了。此时CS:EIP的意义变为,CS的BIT[2]指出是用LDT还是GDT,BIT[3-15]指出了在描述符表中的索引值。然后从描述符表中的描述符中取出基址加上EIP构成32线性地址,经过页表转换后即成为32物理地址。由于LDT和GDT是在内存中的,如果每次访问内存都要额外多一次访存操作,这样的开销是无法接受的。所以当一个进程开始运行前,操作系统会把此进程的LDT和GDT的描述符分别装入高速缓冲中,以便快速访问。此时根据段选择符的BIT[2]选择保存在LDT 或者GDT中的基址(在高速缓冲中),这样就不用每次都访问内存了。具体段变化见下图:
LDT和GDT中描述符的格式(共8字节):
高位
段界限 7-0
段界限 15-8
基址 7-0
基址 15-8
基址 23-16
P DPL S TYPE
G D/B 0 AVL 段界限 19-16
基址 31-24
0
1
2
3
4
5
6
7
S = 1 是非系统段 ,即如果此描述符被选中,描述符中基址和段界限可以直接使用。分为数据段描述符和代码段描述符。
S =1 是系统段, 有LDT描述符和 TSS描述符,在任务切换时使用。具体在进程管理章节讲述。
用特权指令lldt %dx 装载LDTR时,dx中的选择符不是指向LDT的,而是指向GDT的,当执行这条指令时处理器把dx 装载到LDRT的可见部分,然后按照dx的内容从GDT中取出LDT描述符中的基址和界限值装入LDTR的不可见部分。以后任务进行段转换访问LDT中的描述符时就以LDTR中的基址为基址。
X86分页机制:
分页机制是把线性地址转换成物理地址,以进行数据的存储。在分析linux 源代码的时候,一定要分清楚是什么类型的地址,虚拟地址(未经段变换),线性地址(未经页变换),物理地址(输出到地址总想上的真实地址)。在进入保护模式前的段变换是很简单的,以CS:IP为例,段变换后的地址为 CS<<4 + IP。进入保护模式后也以CS:EIP为了例,此时CS已经为段选择符,根据CS的内容从GDT 或者LDT中取出基地址,然后加上EIP即为线性地址。由此我们知道在进入保护模式前至少要设置好GDT,不然段变换一定出错。分页机制是通过CR0.PG为来标志是否分页。下面来讲述一下页式变换过程。
在Intel x86处理器中有几种分页方式,并且由CR4[PSE] 和CR4[PAE]决定。
PAE = 0 PSE=0 32位物理地址总线下的4KB分页方式
PAE = 0 PSE=1 32位物理地址总线下的4M 和 4MB分页方式
PAE = 1 PSE=0 36位物理地址总线下的4KB 分页方式
PAE = 1 PSE=1 36位物理地址总线下的4KB 和 2MB分页方式
我主要介绍在32位物理地址总线下的4M 和 4MB分页方式
(1) 4KB 分页方式
32位线性地址的空间大小为 4G,Intel采用二级页表的形式来映射整个线性空间。如下图所示:
1024个页表项
1024个页表项
1024个页表项
物理页面 …… 物理页面
物理内存
4KB 分页方式下页目录项的格式为:
页目录号 页面号 偏移地址
CR3
+
(2) 4MB分页方式
当CR4[PSE] = 1 时,表明此时为 4KB或者 4MB分页,具体有页目录项中的 BIT[7]指出,如果BIT[7]=1则为 4MB 否则为 4KB.由于一个页目录有1024项,每项指向一个4MB的地址空间,对于32线性地址来说正好一个页目录。所以只有一级页表,这样就少访问存储器一次。提高了效率。自然页目录项中的页面基地址变为 10位,而不是 4KB中的20位。地址转换图如下:
31 22 21 0
页目录号 偏移地址
CR3
+
线性地址
低22位
高 10位 32位物理地址
页目录
linux 0.11 最多只支持64个进程,为什么是64个而不是其他的个数这里是有原因的。当然这个原因是我自己猜的,也没有向linus考证过。一个是因为 linux 0.11只使用了x86芯片的32位物理地址,因此它的线性地址也是32位的。这样整个寻址空间为 4 GB。考虑到linux0.11采用的是minix文件系统,它文件的大小最大不能超过64MB,所以每个进程所占的最大虚拟空间不超过 64MB,64个进程也就不可能超过4GB。这点很重要,系统中的所有进程的虚拟地址空间不超过4GB,这样整个系统只要一个页目录就为所有进程提供页变换。
Linux 0.11管理内存主要有两个文件,一个时memory.c和 page.s,memory.c将在下面详细介绍。Linux 0.11为了节省物理内存,采用写时复制技术。也即当使用 fork 创建一个子进程时。子进程并不复制父进程的物理页面,而是只复制父进程的页目录和页面。并把页目录项和页面项修改为只读属性。当子进程或者父进程要修改页面时,会引发 int 14 中断,此时才真正分配一页物理页面。invalidate
Page.s是用来处理 int 14 缺页中断。缺页中断包括两种:一种是访问的线性地址所对应的物理页面不在内存中,还有就是由写时复制技术引起的,当进程第一次去写一个共享页面时会引起这个中断。
基础知识
中断描述符表IDT 和 中断寄存器IDTR:
中断描述符表包含3种描述符:任务门,中断门,陷阱门。由于在linux中只使用了中断门,这里就只介绍中断门。
当执行 int n 指令时 ,cpu把idtr作为中断描述符表的基址,以 n位索引。从idt中取出段选择符(16位)到cs , 取出32位偏移量到 eip。
X86任务切换全面介绍
任务切换一共由四种形式:
⑴ 在当前程序、任务或过程中执行一条LJMP或CALL指令转到GDT中TSS描述符。(直接任务转换) (在linux0.11中使用ljmp)
⑵ 在当前程序、任务或过程中执行一条JMP或CALL指令转到GDT或当前LDT中一个任务门描述符(在linux0.11中没有使用)。(间接任务转换)
⑶通过一个中断或异常矢量指向IDT中的一个任务门描述符(在linux0.11中没有使用)。(间接任务转换)
⑷当标志位EFLAGS·NT设置时,当前任务执行指令IRET(或IRETD,用于32位程序转换。(直接任务转换)对于指令JMP、CALL、IRET、中断和异常,它们都是程序执行时的一种重定向机制。 一个TSS描述符、一个任务门(调用或跳转到一个任务)或标志位NT(执行指令IRET时) 的状态共同决定了是否发生任务切换。
以上第一和第四种形式可以用下图来表达:
当jmp或者call时,如果cpu发现目标选择符在GDT种对应的是TSS描述符时,这是cpu并不是真正的跳到 jmp或者call所给出的地址。而是进行任务切换。具体任务切换时会降到。
第二和第三种形式用下图来表示:
这种形式在linux0.11中并不存在 ,在以后的版本中也没有使用。
任务寄存器 TR: 是16寄存器,用来存放TSS描述符的选择字。当进行任务切换时,根据TR给出的选择字进行旧任务的上下文保存和新任务的上下文恢复。在linux0.11中,TSS只放在GDT中。
系统调用(模式切换)
系统调用是用户程序访问内核的唯一途径,如下图所示:
用户空间和内核空间
用户空间和内核空间的区别时cpu的运行级别,当cpu的cpl为0时为进程的内核空间,当cpu的cpl为 3时为进程的用户空间。在linux0.11中通过下面的一个宏定义把进程0从内核空间切换到用户空间。下面我们来具体分析一下着段简短的代码:
我们可以清楚的看到当执行 iret指令时,当前cpu的cpl为0。而 在堆栈中的
cs = 00001 1 11 ,即它的dpl为3。则除了从堆栈中弹出 eip,cs,eflags外还有esp和ss。再接下来的四行设置 ds等段寄存器。这样就完成了从内核空间到用户空间的切换。我们从这里也可以看到,进程0在内核空间执行时的堆栈跟用户空间的堆栈时同一个。这样做的原因时,进程一的内核堆栈内容要拷贝进程0的,所以进程0的内核堆栈必须是“干净”的。所以进程0在内核空间时使用的并不是真正意义上的内核堆栈,而是用户空间的,在这里巧妙的“偷梁换柱”了一下。
系统调用过程:
1. 当进程执行 int 0x80指令时, cpu访问中断描述符表,发现当前cs中的 cpl 与中断描述符的dpl不同。则cpu 会进行堆栈切换(内核堆栈的值从tss中获取),即把ss,esp也压入进程的内核堆栈,然后是 eflags,cs,eip。cs和eip的值来自中断描述符。具体可见本章的基础知识部分。此时内核堆栈的内容为红色内容:
用户ss
用户 esp
eflags
cs
eip
ds
es
fs
edx
ecx
ebx
task_struct
2.进入内核空间 ,也即系统调用的处理程序:
此时系统调用程序把 ds,es,fs,edx,ecx,ebx压入
内核堆栈。此时堆栈内容位红色加蓝色:
movl $0x10,%edx # 因为此时已经时内核空间,所以
mov %dx,%ds #把数据段选择符设置为指向GDT[2]
mov %dx,%es # 因为cs在 执行int的时候已经设置好,
所以在这里就不必在设置了,有了代码和数据段,程序
自然可以运行。
movl $0x17,%edx # fs 指向ldt中的数据段描述符
mov %dx,%fs #之所以这样是为了内核空间向
#用户空间写数据。以后凡是向用户空间写数据,数据段
选择符号一定为 fs寄存器
3.做完上述工作后变执行一条
call _sys_call_table(,%eax,4)指令跳到相应的
系统调用函数处执行。
4.系统调用返回
系统调用返回时执行 iret指令。当返回目标的特权级和当前特权级的相同的话,则cpu只从堆栈中弹出eip,cs,eflags。如果返回目标的特权级和当前特权级不同,则还要弹出esp,ss,把进程的堆栈从内核堆栈切换到用户堆栈。
进程调度
进程调度是操作系统最复杂的代码,一下子往往很难看懂。我也不知道从哪里讲起好了。希望通过前面的讲述,你已经有了一定的基础。下面我从main函数讲起,来系统的描述一下进程调度是怎么回事情。
void main(void)
{
…… ……
mem_init(main_memory_start,memory_end);/*初始化内存*/
trap_init(); /*初始化各种中断门,在*/
blk_dev_init();
chr_dev_init();
tty_init();
time_init();
sched_init(); /*初始化进程 0*/
buffer_init(buffer_memory_end);
hd_init();
floppy_init();
sti();/*开中断*/
move_to_user_mode(); /*把进程0从内核空间移到用户空间*/
if (!fork()) { /* we count on this going ok,此处的fork为内联函数 */
init();
}
for(;;) pause();
}
既然要了解进程调度和管理,那就从进程0开始,直到它派生出进程1。然后我将分情况讨论一下进程的调度。到时候你就会清楚的知道进程是如何fork,如何调度,以及当某个进程fork 一个子进程时,为什么在父进程中fork返回的是子进程号,而在子进程是0。希望我的愿望能成为现实。介绍完这些之后,还将介绍一下interruptible_sleep_on(),sleep_on() 和wake_up()这一对函数。这里牵涉到一个隐式链表。
Sched_init():
要说进程0到底是什么时候创建的真的很难表述清楚。因为进程0并不是像其他进程那样,调用一个fork创建出来的。只要fork一执行完就代表子进程以及创建完成。进程0 的部分task_struct的字段是通过手工制造的,在编写程序的时后已经设置好了。具体见sched.c中的INIT_TASK宏定义。它就是最初进程0 task struct的内容。
在执行到sched_init()之前其实还不能真正称它为一个完整的进程。因为进程0在gdt中没有TSS描述符,任务寄存器TR也就不可能有合法值。没有TR,TSS描述符是不可能进行进程切换的。
void sched_init(void)
{
int i;
struct desc_struct * p;
…………
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
/*设置 tss 描述符,用于进程切换*/
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
/*设置ldt描述符,用于进程0从内核空间到用户空间后 的段变换*/
p = gdt+2+FIRST_TSS_ENTRY;
for(i=1;i<NR_TASKS;i++) {
/*把gdt的其他描述符初始化为 0*/
task[i] = NULL;
p->a=p->b=0;
p++;
p->a=p->b=0;
p++;
}
__asm__("pushfl ; andl $0xffffbfff,(%esp) ; popfl");
ltr(0);/*装载 tr寄存器,用于进程切换*/
lldt(0);
……
}
进程0 调用fork 产生进程1
当进程0运行到fork 时将会产生一个子进程,fork是一个系统调用。上面已经描述过系统调用,这里将给出整个系统调用的流程。在这流程还将假设有进程切换,用来说明进程是如何切换的 。
进程0执行 fork时,会把fork返回后的下一条指令压入内核堆栈。内核堆栈的状态图如下,
if (!fork()) { /* we count on this going ok */
init();
}
进程0的内核堆栈
此时进程0已经在内核空间,开始执行下面这条指令
call _sys_call_table(,%eax,4)/*跳转到真正的系统调用函数处执行,eax中时系统调用号,由进程0在执行int指令前压入。(,%eax,4)表示 eax×4,因为函数地址为32为地址。由于是近调用所以只压入eip,跳转到sys_fork处运行*/
pushl %eax /*在看这条指令前,先请细看sys_fork。如果调用的是fork,则eax中是子进程号*/
movl _current,%eax
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule /*在这里我们假设父进程已经把时间片用完,然后调度,并且调度给子进程,看schedule*/
/*下面是父进程继续运行*/
……
3: popl %eax /*弹出eax即子进程号,当执行iret后,eax为默认的返回值,然后fork的返回值为子进程号*/
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret
_sys_fork:
call _find_empty_process /*返回一个空闲的进程号,如果没有则返回一个负数*/
testl %eax,%eax
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process /*复制和设置新进程的task_truct*/
/*copy_process 返回的子进程号,根据规定返回值是在eax中的*/
addl $20,%esp /*弹出上面压入的5个寄存器*/
1: ret/*返回到函数调用处*/
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)
{
p = (struct task_struct *) get_free_page();
分配一页物理页面,物理页面的最开始端是task_struct,后半部分作为
内核堆栈。
*p = *current; 复制父进程的task_struct
修改部分task_struct的字段
p->state = TASK_UNINTERRUPTIBLE;/*设置进程为不可中断状态*/
p->pid = last_pid;/*设置进程号*/
p->father = current->pid;/*设置父进程号*/
p->tss.esp = esp;
p->tss.ss = ss & 0xffff; 子进程的用户堆栈暂时与父进程共享,当写堆栈时,发生缺页中断,从此“分道扬镳 ”
p->tss.eip = eip;/*这个是父进程调用 int 0x80压入的返回值,也就说父子进程都返回到同一个虚拟地址*/
p->tss.eax = 0;/*这个是在子进程中的返回值,根据上面的eip可以知道为什么fork之后子进程返回的是0了*/
p->tss.es = es & 0xffff; 所有段寄存器的值都是父进程先前压入的
return last_pid; /*返回子进程号,默认返回值在eax中*/
}
void schedule(void) 和switch_to
{
……这里检测信号量,改变有信号进程的状态
然后根据优先级条选出一个进程来运行。我们假设选中的是刚刚fork的子进程
next即为下一个要运行的进程号
switch_to(next);/*这个是宏定义,无参数入栈*/
}
tem.a eip
tem.b cs
eax子进程号
ebx
ecx
edx
fs
es
ds
eip
cs
eflags
user esp
user ss
任务切换的工作:
当cpu发现段选择子是指向TSS描述符的,就忽略eip,不进行跳转,而是进行任务切换。具体步骤如下:
1. 首先要保存当前任务的状态。处理器根据TR的内容寻址当前TSS的基址,把所有通用寄存器,段寄存器,EFLAGS,EIP都保存到当前任务的 TSS描述符中。
2. 把当前选择符的值复制到TR寄存器,用来下次任务切换是保存上下文。从新任务的TSS数据结构中装载处理器的个寄存器:通用寄存器,EFLAGS,EIP,段寄存器。
根据copy_process函数的说明,我们立即知道EIP的值是fork返回后的的一条指令地址。返回值为子进程中的eax,在copy_process中设置为0,终于知道为什么在子进程中fork为什么返回的是0 了。
sleep_on 和wake_up
当进程反问buffer或者磁盘时,很可能数据不能立即得到。此时进程通过调用sleep_on函数主动刮起直接,并调度其他进程。我们假设有一个buffer,有3个进程P1,P2,P3去访问它,结果buffer不可用。这三个进程分别调用了sleep_on挂起在这个buffer的等待队列上,这其实是一个隐式等待队列。结果如下图所示:
(p1 p2 p3的状态都为 TASK_UNINTERRUPTIBLE)
通过wake_up也可以唤醒*p所指向的进程。
void sleep_on(struct task_struct **p)
{
struct task_struct *tmp;
if (!p)
return;
if (current == &(init_task.task)) /* 进程0不能挂起*/
panic("task[0] trying to sleep");
tmp = *p;
*p = current;
current->state = TASK_UNINTERRUPTIBLE;/*不可中断*/
schedule();/*调度其他程序*/
if (tmp) /*唤醒它前面的等待进程*/
tmp->state=0;
}
void wake_up(struct task_struct **p)
{
if (p && *p) {
(**p).state=0;/*把进程的状态设为 可运行态*/
p=NULL;/*必须设置为null,不然后果不堪设想*/
}
}