一个操作系统的实现——进程

本文详细阐述了在Orange操作系统中实现进程和进程切换的关键步骤,包括任务定义、初始化进程结构体、任务执行以及中断处理。特别强调了如何在不同特权级别之间切换,以及中断处理过程中对寄存器的保存与恢复。此外,文章还讨论了系统调用的封装以及如何通过系统调用实现进程间的高效切换。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在Orange中,对于进程及进程切换的实现包含以下步骤:

Step 1: 首先定义一个任务,如下类似的函数,函数名就是任务的入口地址。

void TestA()
{
	while(1){}
}

Step 2: 在kernel_main函数中初始化进程结构体,除了初始化进程的段寄存器(注意TI位为1,表示LDT选择子),eip,esp,eflags之外,关键需要初始化进程的局部描述符LDT在GDT中的选择子,以便从内核跳入进程时,能够在GDT中找到进程到LDT。

PUBLIC int kernel_main()
{
	disp_str("-----\"kernel_main\" begins-----\n");

	PROCESS* p_proc	= proc_table;
	p_proc->ldt_sel	= SELECTOR_LDT_FIRST;
	memcpy(&p_proc->ldts[0], &gdt[SELECTOR_KERNEL_CS >> 3], sizeof(DESCRIPTOR));
	p_proc->ldts[0].attr1 = DA_C | PRIVILEGE_TASK << 5;	// change the DPL
	memcpy(&p_proc->ldts[1], &gdt[SELECTOR_KERNEL_DS >> 3], sizeof(DESCRIPTOR));
	p_proc->ldts[1].attr1 = DA_DRW | PRIVILEGE_TASK << 5;	// change the DPL
	p_proc->regs.cs		= ((8 * 0) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
	p_proc->regs.ds		= ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
	p_proc->regs.es		= ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
	p_proc->regs.fs		= ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
	p_proc->regs.ss		= ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
	p_proc->regs.gs		= (SELECTOR_KERNEL_GS & SA_RPL_MASK) | RPL_TASK;
	p_proc->regs.eip	= (u32)TestA;
	p_proc->regs.esp	= (u32) task_stack + STACK_SIZE_TOTAL;
	p_proc->regs.eflags	= 0x1202;	// IF=1, IOPL=1, bit 2 is always 1.

	p_proc_ready	= proc_table; 
	restart();

	while(1){}
}

这里需要注意的是,对于一个任务进程采用局部描述符去访问,每新建一个任务需在GDT中对应插入一个描述符,而这个描述符指向该任务的LDT,这个LDT又包含两个描述符,一个为代码段描述符,另一个为数据段描述符,当然你也可再定义一个堆栈段描述符。因此可以计算,对于一个16位的段选择子,除去低3位,还剩13位用于描述符寻址,2^13=8192个,再除去第一个空描述符,两个内核描述符,一个显存描述符,一个TSS描述符,就剩8192-5=8187个给任务进程了。但是如果还想定义更多,如何操作呢?

此外,对于其他通用寄存器默认初始化为0.

Step 3: 将p_proc_ready指向该任务结构,然后执行restart开始进入TestA执行。

restart:
	mov	esp, [p_proc_ready]
	lldt	[esp + P_LDT_SEL] 
	lea	eax, [esp + P_STACKTOP]
	mov	dword [tss + TSS3_S_SP0], eax
	pop	gs
	pop	fs
	pop	es
	pop	ds
	popad
	add	esp, 4
	iretd

首先,程序第二行将esp指向进程结构体,也即指向进程的堆栈结构STACK_FRAME;第三行加载进程到ldt描述符地址,[esp + P_LDT_SEL]其实就是进程结构体中的成员ldt_sel的值,当进程执行时,段寄存器将指向LDT(段寄存器在初始化的时候,TI位为1);

第四行和第五行,将进程堆栈结构的栈顶地址(STACK_FRAME成员ss的地址)赋给TSS中ring0堆栈指针esp0,为什么要这样操作,开始想了很久不得其解。在反复想了很久之后,慢慢有所理解。总的来说要把握好以下两点:

1. 处理器从外层(低特权级,任务进程)向内层(高特权级,内核ring0)转移的时候,内层堆栈指针将自动从TSS中的esp0获取,也即内核程序运行期间其堆栈指针将指向ss0:esp0。

2. 在发生中断事件的时候CPU会自动将EFLAGS,CS,EIP压入栈中,如果处理器检测到有特权级转移,那么首先要将ss和esp入栈。

因此在内核运行期间,如果下一次中断发生时(内核->任务进程),进程的ss,esp,eflags,cs和eip寄存器的值将依次被压入进程的堆栈结构(因为此时内核的esp指向进程的栈顶)。

最后就是依次将进程堆栈结构所保存的寄存器内容弹出,注意因为涉及到特权级转移,iretd要额外弹出ss和esp。此时,处理开始运行任务进程了!

Step 4: 上面3步完成了ring0到ring1的切换,使得处理器从内核态跳转到了任务进程,接下来我们要打开时钟中断(在init_8259A()中打开),在时钟中断程序中搞点事情,注意此时并未对时钟芯片进行配置,启动之后将以一个默认中断频率执行,大概是18.2HZ,详细设置参见6.2.5。

hwint00:		; Interrupt routine for irq 0 (the clock).
	sub	esp, 4
	pushad		; `.
	push	ds	;  |
	push	es	;  | 保存原寄存器值
	push	fs	;  |
	push	gs	; /
	mov	dx, ss
	mov	ds, dx
	mov	es, dx
	
	mov	esp, StackTop		; 切到内核栈

	inc	byte [gs:0]		; 改变屏幕第 0 行, 第 0 列的字符

	mov	al, EOI			; `. reenable
	out	INT_M_CTL, al		; /  master 8259
	
	push	clock_int_msg
	call	disp_str
	add	esp, 4
	
	mov	esp, [p_proc_ready]	; 离开内核栈

	lea	eax, [esp + P_STACKTOP]
	lldt	[esp + P_LDT_SEL]
	mov	dword [tss + TSS3_S_SP0], eax

	pop	gs	; `.
	pop	fs	;  |
	pop	es	;  | 恢复原寄存器值
	pop	ds	;  |
	popad		; /
	add	esp, 4

	iretd

当时钟中断到来时,处理器进入内核态(ring1->ring0),首先是处理器将当前任务进程的CPU寄存器压栈,注意在restart函数中已将TSS的esp0指向任务进程堆栈结构的栈顶,并且当前esp将自动切换为esp0,压栈操作将CPU当前状态保存在任务进程的堆栈结构中,另外中断发生时ss,esp,eflags,cs,eip将自动入栈,因此对照STACK_FRAME结构,一系列的push正好填满这个结构。程序开头的sub esp,4是相当于一个压栈操作,将retaddr“入栈”。

入栈操作完成后,将堆栈切换到内核栈(在kernel.asm中定义了2k的内核栈),因为接下来会调用disp_str函数,会涉及到堆栈操作,由于切换前一系列的push使得esp现在指向进程堆栈结构栈底,为了不破坏进程表,所以我们需要切换堆栈,将esp指向另外的位置。函数调用完成后,调用mov  esp, [p_proc_ready]离开内核栈。接下来为什么又要设置TSS呢?因为我们现在只有一个进程,以后有多个进程时,在进程切换的时候就要将相应进程的栈顶保存到esp0,以保证再次中断时esp指向相应任务进程堆栈结构。

最后一系列的pop指令恢复原进程寄存器,再次执行任务进程,注意iretd依次弹出eip,cs,eflags,esp,ss。另外程序中间的out指令为了让时钟中断不停地发生。

此外,对于中断重入问题,Orange采用一个全局变量标志来解决,另外CPU在响应中断的过程中会自动关闭中断,所以为了在中断程序中还可响应其它中断,需要人为地调用sti打开中断。

至此,关于进程以及进程切换设计的基本原理介绍完毕,虽然上述只有一个进程体,但是对于添加更多的进程就是依葫芦画瓢的过程。关键在于修改中断处理程序,在中断处理程序中去调用clock_handler函数,在clock_handler函数中完成进程切换。

PUBLIC void clock_handler(int irq)
{
	disp_str("#");
	p_proc_ready++;
	if (p_proc_ready >= proc_table + NR_TASKS)
		p_proc_ready = proc_table;
}

当然上面的时钟中断处理函数中只是简单的对进程进行顺序的处理(时间片轮转),如果要实现一些复杂的调度处理的话,一方面要针对你的调度算法修改上面函数内容,另一方面可能需要对进程结构体增加相应成员变量。Orange中实现了一个简单的基于优先级的进程调度算法,如下代码所示(为什么要在外层加上一个while?)。

PUBLIC void schedule()
{
	PROCESS* p;
	int	 greatest_ticks = 0;

	while (!greatest_ticks) {
		for (p = proc_table; p < proc_table+NR_TASKS; p++) {   //扫描每一个进程的ticks,最大进程的ticks保存到greatest_ticks变量中,并且把该进程指针赋值给p_proc_ready,也就是下一个将要运行的进程就是进程数组里面当前ticks最大的那个。
			if (p->ticks > greatest_ticks) {
				greatest_ticks = p->ticks;
				p_proc_ready = p;
			}
		}

		if (!greatest_ticks) {           //如果所有进程的ticks都已0,就重新赋值。
			for (p = proc_table; p < proc_table+NR_TASKS; p++) {
				p->ticks = p->priority;
			}
		}
	}
}

除了上面内容之外还有一个系统调用的知识点没有提及。相比进程来说这个就比较简单了,无非就是人为产生一个中断,通过这个中断去访问内核中的一些变量或者实现一些特定的功能。接下来再通过对一个系统调用的分析来具体说明一下进程的切换过程。

我们知道处理器在时钟中断函数中实现任务进程的切换,在时钟中断中,首先通过采用一系列的push指令来保存当前CPU的寄存器到当前进程堆栈结构中,然后又利用pop指令将下一个“待命”进程的上下文调入处理器。可以看到进程切换时的push和pop操作非常直观并且简单,但是如果系统要实现除了时钟中断之外的其它所有中断包括所有系统调用呢?可以想象所有的中断中如果我们都进行一系列的必不可少的push和pop操作,将显得非常繁琐,那么何不对此写一个函数来此进行封装,这样不仅减少代码量并且也增加可阅读度。因此Orange就对push和pop进行了一个封装,也就是kernel.asm中的save函数:

save:
        pushad          ; `.
        push    ds      ;  |
        push    es      ;  | 保存原寄存器值
        push    fs      ;  |
        push    gs      ; /
        mov     dx, ss
        mov     ds, dx
        mov     es, dx

        mov     esi, esp                    ;esi = 进程表起始地址

        inc     dword [k_reenter]           ;k_reenter++;
        cmp     dword [k_reenter], 0        ;if(k_reenter ==0)
        jne     .1                          ;{
        mov     esp, StackTop               ;  mov esp, StackTop <--切换到内核栈
        push    restart                     ;  push restart
        jmp     [esi + RETADR - P_STACKBASE];  return;
.1:                                         ;} else { 已经在内核栈,不需要再切换
        push    restart_reenter             ;  push restart_reenter
        jmp     [esi + RETADR - P_STACKBASE];  return;
                                            ;}
当进入中断之后直接call save就可以完成一系列的push操作,并切换到内核栈。这里又有疑问了,在hwint00时钟中断程序中,第一条指令是sub esp 4,但是在save中并没有这样的指令?这里首先就要谈下call指令的一个重要知识:在调用call指令的时候,CPU自动将下一条指令的地址压入到栈中。上面我们提到过sub esp 4指令相当于执行了一次压栈操作,由于call的这种特性,压栈操作被自动执行了。在进程堆栈结构中所定义的retaddr变量,其用武之地就在这里,我们先不管它。另外可以看到save函数封装了一系列的push操作,但是pop又是谁来操作?先看下面一个系统调用中断的例子(等同于时钟中断),在Orange中当调用int 0x90时就会执行下面程序:

sys_call:
        call    save

        sti

        call    [sys_call_table + eax * 4]
        mov     [esi + EAXREG - P_STACKBASE], eax

        cli

        ret
进入系统调用中断后,首先调用save执行一系列push操作,并切换到内核栈,这时我们再回到save函数中再仔细研究下这个函数,我们已经知道call调用的时候,下一条指令的地址自动被压入栈中(注意这里进程的esp指向堆栈结构的retadr成员,所以retadr的值应为下一条指令(sti)的地址),因此在函数结束后我们要调用ret指令将入栈的返回地址弹出,但是在save函数中我们并没有见到ret指令。因为涉及到堆栈切换,在这里save并不能单纯的被看成是函数来使用了,在save函数中,当push完所有寄存器后,接着执行了一条mov  esi, esp指令,首先我们要清楚当前esp指向当前进程表的起始地址处(一系列push导致),为什么要执行这样一条指令呢?我们看到接下来有一个jmp跳转使用到了esi,很容易分析jmp的操作数[esi + RETADR - P_STACKBASE]正好指向当前进程表堆栈结构的retadr成员,因此执行该jmp将跳到sys_call函数中call save的下一条指令执行。最后,save函数中就剩下一条push restart(restart_reenter)指令还没分析了,其实该指令是为sys_call的最后一条指令ret做准备,需要注意的是此时已切换到内核栈,因此可以将sys_call中的ret指令理解为save函数中未调用的ret指令,ret将弹出restart(或restart_reenter)的地址到eip,然后执行restart(或restart_reenter)函数。





评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值