写操作系统之实现进程

C语言和汇编语言混合编程

方法

本节的“混合编程”不是指在C语言中使用汇编语言,或在汇编语言中使用C语言。它是指在C程序中使用汇编语言编写的函数、变量等,或者反过来。

混合编程的核心技巧是两个关键字:externglobal

有A、B两个源码文件,A是C语言源码文件,B是汇编语言源码文件。在B中定义了变量、函数,要用global导出才能在A中使用;在B中要使用A中的变量、函数,要用extern导入。

用最简单的话说:使用extern导入,使用global导出。

在A中使用B中的函数或变量,只需在B中导出。

在B中使用A中的函数或变量,只需在B中导入。

是这样吗?

请看例程。

例程

代码

bar.c是C语言写的源码文件,foo.asm是汇编写的源码文件。在foo.asm中使用bar.c中创建的函数,在bar.c中使用foo.asm提供的函数。foo.asm创建的函数用global导出,使用bar.c中创建的函数前使用extern导入。

foo.asm。

extern choose

[section .data]

GreaterNumber	equ	51
SmallerNumber equ	23

[section .text]

global _start
global _displayStr

_start:
	push	GreaterNumber
	push	SmallerNumber
	call choose
	add [esp+8]	; 人工清除参数占用的栈空间
	
	; 必须调用 exit,否则会出现错提示,程序能运行。
	mov eax, 1	; exit系统调用号为1
	mov ebx, 0	; 状态码0:正常退出
	int 0x80
	
	ret
	
; _displayStr(char *str, int len)	
_displayStr:
	mov eax, 4	; write系统调用号为4
	mov ebx, 1	; 文件描述符1:标准输出stdout
	; 按照C函数调用规则,最后一个参数最先入栈,它在栈中的地址最大。
	mov ecx, [ebp + 4]		; str
	mov edx, [ebp + 8]		; len。ebp + 0 是 cs:ip中的ip
	int 0x80
	
	ret	; 一定不能少

bar.c。

void choose(int a, int b)
{
  if(a > b){
    _displayStr("first", 5);
  }else{
    _displayStr("second", 6);
  }
  
  return;
}

代码讲解

导入导出

extern choose,把choose函数导入到foo.asm中。

global _displayStr,导出foo.asm中的函数,提供给其他文件例如bar.c使用。

系统调用

系统调用模板。

; code-A
mov eax, 4	; write系统调用号为4
mov ebx, 1	; 文件描述符1:标准输出stdout
; 按照C函数调用规则,最后一个参数最先入栈,它在栈中的地址最大。
mov ecx, [ebp + 4]		; str
mov edx, [ebp + 8]		; len。ebp + 0 是 cs:ip中的ip
int 0x80

; code-B
; 必须调用 exit,否则会出现错提示,程序能运行。
mov eax, 1	; exit系统调用号为1
mov ebx, 0	; 状态码0:正常退出
int 0x80

两段代码展示了系统调用int 0x80的用法。暂时不必理会“系统调用”这个概念。

使用int 0x80时,eax的值是希望执行的系统函数的编号。例如,exit的编号是1,当eax中的值是1时,int 0x80会调用exitwrite的编号是4。

exit和write的函数原型如下。

void exit(int status);
int write(int handle, void *buf, int nbyte);

ebxecxedx中的值分别是系统调用函数的第一个参数、第二个参数、第三个参数。

编译运行

用下面的命令编译然后运行。

nasm -f elf foo.o foo.asm
gcc -o bar.o bar.c -m32
ld -s -o kernel.bin foo.o bar.o -m elf_i386
# 运行
./kernel.bin

切换堆栈和GDT

是什么

在《开发加载器》中,我们已经完成了一个简单的内核。那个内核是用汇编语言写的,使用的GDT在实模式下创建。在以后的开发中,我们讲主要使用C语言开发。能在C语言中使用汇编语言中的变量,例如GDT,可是,要往GDT中增加一个全局描述符或修改全局描述符的属性,在C语言中,就非常不方便了。到了用C语言编写的内核源代码中,要想方便地修改GDT或其他在汇编中创建的变量,需要把用汇编源码创建的GDT中的数据复制到用C语言创建的变量中来。

切换GDT,简单地说,就是,把汇编源代码中的变量的值复制到C语言源代码中的变量中来。目的是,在C语言源代码中更方便地使用这些数据。

切换堆栈,除了使用更方便,还为了修改堆栈的地址。

切换堆栈的理由,我也不是特别明白这样做的重要性。

怎么做

  1. 汇编代码文件kernel.asm,C语言代码文件main.c。
  2. 在C语言代码中定义变量gdt_ptr
  3. 在kernel.asm中导入gdt_ptr
  4. 在kernel.asm中使用sgdt [gdt_ptr]把寄存器gdtptr中的数据保存到变量gdt_ptr中。
  5. 在main.c中把GDT复制到main.c中的新变量中,并且修改GDT的界限。
  6. 在kernel.asm中重新加载gdt_ptr中的GDT信息到寄存器gdtptr中。

请在下面的代码中体会上面所写的流程。

image-20211014140616238

代码讲解

sgdt [gdt_ptr],把寄存器gdtptr中的数据保存到外部变量gdt_ptr中。

寄存器gdtptr中存储的数据的长度是6个字节,前2个字节存储GDT的界限,后4个字节存储GDT的基地址。在《开发引导器》中详细讲解过这个寄存器的结构,不清楚的读者可以翻翻那篇文章。

切换GDT

void Memcpy(void *dst, void *src, int size);
typedef struct{
        unsigned short seg_limit_below;
        unsigned short seg_base_below;
        unsigned char  seg_base_middle;
        unsigned char seg_attr1;
        unsigned char seg_limit_high_and_attr2;
        unsigned char seg_base_high;
}Descriptor;

Descriptor gdt[128];

Memcpy(&gdt,
                (void *)(*((int *)(&gdt_ptr[2]))),
                *((short *)(&gdt_ptr[0])) + 1
        );

把在实模式下建立的GDT复制到新变量gdt中。

这段代码非常考验对指针的掌握程度。我们一起来看看。

  1. gdt_ptr[2]是gdt_ptr的第3个字节的数据。
  2. &gdt_ptr[2]是gdt_ptr的存储第3个字节的数据的内存空间的内存地址。
  3. (int *)(&gdt_ptr[2])),把内存地址的数据类型强制转换成int *。一个内存地址,只能确定是一个指针类型,但不能确定是指向哪种数据的指针。强制转换内存地址的数据类型为int *后,就明确告知编译器这是一个指向int数据的指针。
  4. 指向int数据的指针意味着什么?指针指向的数据占用4个字节。
  5. (*((int *)(&gdt_ptr[2])))是指针(int *)(&gdt_ptr[2]))指向的内存空间(4个字节的内存空间)中的值。从寄存器gdtptr的数据结构看,这个值是GDT的基地址,也是一个内存地址。
  6. Memcpy的函数原型是void Memcpy(void *dst, void *src, int size);,第一个参数dst的类型是void *,是一个内存地址。(void *)(*((int *)(&gdt_ptr[2])))中最外层的(void *)把内存地址强制转换成了void *类型。
  7. gdt_ptr[0])是gdt_ptr的第1个字节的数据,&gdt_ptr[0])是存储gdt_ptr的第1个字节的数据的内存空间的内存地址。
  8. (short *)(&gdt_ptr[0]),把内存地址的数据类型强制转换成short *short *ptr这种类型的指针指向两个字节的内存空间。假如,ptr的值是0x01,那么,short *ptr指向的内存空间是内存地址为0x010x02的两个字节。
  9. 说得再透彻一些。short *ptr,指向一片内存空间,ptr的值是这片内存空间的初始地址,而short *告知这片内存空间的长度。short *表示这片内存空间有2个字节,int *表示这片内存空间有4个字节,char *表示这片内存空间有1个字节。
  10. *((short *)(&gdt_ptr[0]))是内存空间中的值,是gdt_ptr[0]、gdt_ptr[1]两个字节中存储的数据。从gdtptr的数据结构来看,这两个字节中存储的是GDT的界限。
  11. GDT的长度 = GDT的界限 + 1。
  12. 现在应该能理解Memcpy这条语句了。从GDT的基地址开始,复制GDT长度那么长的数据到变量gdt中。
  13. gdt是C代码中存储GDT的变量,它的数据类型是Descriptor [128]
  14. Descriptor是表示描述符的结构体。gdt是包含128个描述符的数组。这符合GDT的定义。
  15. 把GDT从汇编代码中的变量或者说某个内存空间复制到gdt所表示的内存空间后,就完成了GDT的切换。

修改gdtptr

先看代码,然后讲解代码。下面的代码紧跟上面的代码。可在本节的开头看全部代码。

short *pm_gdt_limit = (short *)(&gdt_ptr[0]);
int *pm_gdt_base = (int *)(&gdt_ptr[2]);

//*pm_gdt_limit = 128 * sizeof(Descriptor) * 64 - 1;
*pm_gdt_limit = 128 * sizeof(Descriptor) - 1;
*pm_gdt_base = (int)&gdt;
  1. 由于GDT已经被保存到了新的内存空间中,以后将使用这片内存中的GDT,所以,需要更新寄存器gdtptr中存储的GDT的基地址和GDT界限。
  2. 使用lgdt [gdt_ptr]更新gdtptr中的值。要更新gdtptr中的值,需要先更新gdt_ptr中的值。
  3. 更新gdt_ptr的过程,又是玩耍指针的过程。熟悉指针的读者,能轻松看懂这几条语句,不需要我啰里啰嗦的讲解。
  4. 在前面,我说过,指针,表示这个变量的值是一个内存地址;而指针的类型(或者说指针指向的数据的数据类型)告知从这个内存地址开始有多少个字节的数据。再说得简单一些:指针,告知内存的初始地址;指针的类型,告知内存的长度。
  5. pm_gdt_limit是一个short *指针。它的含义是是一段初始地址是&gdt_ptr[0]、长度是2个字节的内存空间。这是什么?联想到寄存器gdtptr的数据结构,它是GDT的界限。
  6. 用通用的方法理解pm_gdt_base。它是GDT的基地址。
  7. 新的GDT表中有128个描述符,相应地,新GDT的界限是128个描述符*一个描述符的长度-1
  8. 更新gdt_ptr的前2个字节的数据的语句是*pm_gdt_limit = 128 * sizeof(Descriptor) - 1
  9. 如果理解这种语句有困难,就理解它的等价语句:mov [eax], 54
  10. 新GDT的基址是多少?就是变量gdt的内存地址。

更新gdt_ptr的值后,在汇编代码中用lgdt [gdt_ptr]重新加载新GDT到寄存器gdtptr中。就这样,完成了切换GDT。

切换堆栈

代码讲解

esp的中的数据修改为进入内核后的一段内存空间的初始地址,这就是切换堆栈。

切换堆栈的相关代码如下。我们先看代码,然后理解关键语句。

[section .bss]
Stack   resb    1024*2
StackTop:


[section .text]
mov esp, StackTop

Stack resb 1024*2,从Stack所表示的内存地址开始,把2048个byte设置成0。

StackTop:是一个标号,表示一段2kb的内存空间的堆栈的栈顶。

mov esp, StackTop,把堆栈的栈顶设置成StackTop

[section .bss],表示后面的代码到下一个节标识前都是.bss节,一般放置未初始化或初始化为0的数据。

[section .text],表示后面的代码到下一个标识前都是.text节,一般放置指令。

在汇编语言写的源文件中,这些节标识是给程序员看的,非要把数据初始化写到.text节,编译器也能给你正常编译。

push 0
;push 0xFFFFFFFF
popfd

popfd把当前堆栈栈顶中的值更新到eflags中。

eflags

eflags是一个寄存器。先看看它的结构图。

eflags

不用我多说,看一眼这张图,就知道它有多复杂。

eflags有32个字节,几乎每个字节都有不同的含义。要完全弄明白它很费时间。我只掌握下面这些。

  1. 在bochs中查看eflags的值,使用info eflags。对查看到的结果,大写的位表示为1,小写的表示为0;如:SF 表示1,zf 表示0。
  2. 使用popfdpopf能更新eflags的值。
    1. pof,将栈顶弹入 EFLAGS 的低 16 位。
    2. popfd,将栈顶弹入 EFLAGS的全部空间。
  3. 算术运算和eflags的状态标志(即CF、PF、AF、ZF、SF)这些有关系。例如,算术运算的结果的最高位进位或错位,CF将被设置成1,反之被设置成0。

下面是在bochs中查看到的eflags中的值。

# code-A
eflags 0x00000002: id vip vif ac vm rf nt IOPL=0 of df if tf sf zf af pf cf
<bochs:4> info eflags
id vip vif ac vm rf nt IOPL=0 of df if tf sf zf af pf cf
# code-B
eflags 0x00247fd7: ID vip vif AC vm rf NT IOPL=3 OF DF IF TF SF ZF AF PF CF
<bochs:9> info eflags
ID vip vif AC vm rf NT IOPL=3 OF DF IF TF SF ZF AF PF CF

错位是什么?

code-A。eflags 0x00000002的意思是,eflags的初始值是0x00000002。对照eflags的结构图,第1位是保留位,值是1,其他位全是0,最终结果就是0x2。

code-B。eflags 0x00247fd7,eflags的初始值是0x00247fd7。为什么会是这个结果?因为我入栈数据0xFFFFFFFF,然后执行了popfd。换言之,我把eflags的值设置成了0xFFFFFFFF。然而eflags中有些保留位的值总是0,有些非保留位又必须是某种值,0xFFFFFFFF和这些默认值(或保留值)综合之后变成了0x00247fd7。

说了这么多,最重要的一点是:解读info eflags的结果。eflags 0x00000002中的0x00000002是eflags中的数据。

中断

本篇的中心是实现进程,我想从本文第一个字开始就写怎么实现进程。可是实现进程需要用到GDT、堆栈,还要用到中断。不先写这些,先写进程,写进程时要用到这些再写这些内容,那成了倒叙了。到时候,写进程的实现不能连贯又要插入中断这些内容。

是什么

现代操作系统都支持多进程。我们的电脑上同时运行浏览器、听歌软件和编辑器,就是证明。同一时刻、同一个CPU只运行一个进程。CPU怎么从正在运行的进程转向执行另外一个进程呢?为进程切换提供契机的就是中断。

CPU正在运行浏览器,我们敲击键盘,会产生键盘中断,CPU会执行键盘中断例程。

CPU正在运行浏览器,即使我们没有敲击键盘,可是CPU已经被浏览器使用了很长时间,按一定频率产生的时钟中断发生,CPU会在执行时钟中断例程时切换到其他进程。

上面是几个中断的例子。看了这些例子,回答一下中断是什么?中断,就是停止正在执行的指令,陷入操作系统指令,目的往往是切换到其他指令。

实现机制--通俗版

再用通俗的例子,从中断的实现机制,说明中断是什么。用数组对比理解中断的实现机制。

  1. 中断发生,产生一个中断向量号(数组索引)。
  2. 在IDT(数组)中,根据向量号(数组索引),找到中断例程(数组元素)。
  3. 执行中断例程。

简单说,发生了一件事,CPU中止手头的工作,去执行另一种工作。每件事都有对应的工作。

中断分为内部中断和外部中断。

读取软盘使用的int 13是内部中断,敲击键盘产生的中断是外部中断,时钟中断也是外部中断。

实现机制--严谨版

实现流程

  1. 建立中断向量表。
  2. 创建变量IdtPtr,用GdtPtr类比理解。必须紧邻中断向量表。
  3. 创建中断向量表中的向量对应的中断例程。不确定是否需要紧邻中断向量表,我目前是这么做的。
  4. lidt [IdtPtr]把中断向量表的内存地址等信息加载到寄存器IDTPtr。用GdtPtr寄存器类比理解这个寄存器。

工作流程

  1. 根据中断向量号在IDT中找到中断描述符。
  2. 中断描述符中包含中断例程所在代码段的选择子和中断例程在代码段中的偏移量。
  3. 根据选择子和偏移量找到中断例程。
  4. 运行中断例程。

再看看下面这张图,想必能很好理解上面的文字了。

image-20211015174541486

代码

前面讲过的工作流程,会在下面的代码中一一体现。一起来看看。

建立IDT

IDT是中断向量表。类似GDT,也是内存中的一块区域。但它内部包含的全是"门描述符"。

IDT中的每个门描述符的名称是中断向量号,选择子是中断向量号对应的处理中断的代码。

; 门
; 门描述符,四个参数,分别是:目标代码段的偏移量、目标代码段选择子、门描述符的属性、ParamCount
%macro Gate 4
        dw      (%1 & 0FFFFh)                           ; 偏移1
        dw      %2                                      ; 选择子
        dw      (%4 & 1Fh) | ((%3 << 8) & 0FF00h)       ; 属性
        dw      ((%1 >> 16) & 0FFFFh)                   ; 偏移2
%endmacro ; 共 8 字节

; 门描述符,四个参数,分别是:目标代码段的偏移量、目标代码段选择子、门描述符的属性、ParamCount
; 属性是 1110,或者是0111。不确定是小端法或大端法,所以有两种顺序的属性。
; 属性是 0111 0001。错误.
; 属性应该是 1000 1110
[SECTION .idt]
ALIGN 32
[BITS 32]
LABEL_IDT:
%rep    32
        Gate    Superious_handle, SelectFlatX, 08Eh, 0 ; 属性是 1110,或者是0111。不确定是小端法或大端法,所以有两种顺序的属性。
%endrep
.20h:   Gate    ClockHandler,   SelectFlatX, 08Eh, 0
;.80h:  Gate    ClockHandler,   SelectFlatX, 08Eh, 0
%rep 222
        Gate    Superious_handle, SelectFlatX, 08Eh, 0
%endrep
IDT_LEN equ     $ - LABEL_IDT
IdtPtr  dw      IDT_LEN - 1
        dd      0
;END OF [SECTION .idt]

SelectFlatX是一个GDT选择子,ClockHandler是中断例程在这个选择子指向的代码段中的偏移量。

IDT是门描述符(一种数据结构,类似GDT中的全局描述符)组成的表,占据一段内存空间。确定一段内存空间只需两个要素:初始地址和界限大小。IdtPtr就提供了这两个要素。

使用下面的语句把IDT的初始地址和界限大小加载到IDTPtr寄存器。

lidt [IdtPtr]

建立中断例程

_SpuriousHandler:
SpuriousHandler	equ	_SpuriousHandler - $$
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd
	
_UserIntHandler:
UserIntHandler	equ	_UserIntHandler - $$
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd
	
_ClockHandler:
ClockHandler    equ     _ClockHandler - $$
        ; error: operation size not specified
        ;inc [gs:(80*20 + 21)*2]
        xchg bx, bx
        inc byte [gs:(80*20 + 21)*2]
        ; 发送EOF
        mov al, 20h
        out 20h, al
        ; 不明白iretd和ret的区别
        iretd	

上面的代码和IDT在同一代码段。SpuriousHandlerUserIntHandler是段内的两个偏移量。

_SpuriousHandler:
SpuriousHandler	equ	_SpuriousHandler - $$
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd

不能完全理解这种写法。不过,可暂且当作一种语法规则去记住就行了。以后有类似功能需要实现,就用这种写法。不过,我也试着分享一下自己的看法。

上面的代码其实等价于下面的代码。

SpuriousHandler	equ	_SpuriousHandler - $$

_SpuriousHandler:
	mov al, 'A'
	mov ah, 0Fh
	mov [gs:(80*20+20)*2], ax
	iretd

要了解这段代码,需了解一点汇编知识。

汇编语言中有个概念“标号''。上面的SpuriousHandler_SpuriousHandler就是标号,前者是非指令的标号,后者是指令的标号。指令的标号必须在标识符后面加上冒号。上面的中断例程中的_SpuriousHandler后面确实有冒号。指令前的标号表示这块指令中的第一条指令的内存地址。

使用宏Gate创建中断描述符,需要用到中断例程在段内的偏移量。_SpuriousHandler是在内存中的内存地址,这个内存地址是相对于本段段首的内存地址还是相对于其他某个参照量呢?我也不知道。假如,这个内存地址是相对于其他某个参照量,它就不是在本段内的偏移量。为了确保在宏中使用的是偏移量,因此我们使用_SpuriousHandler - $$也就是SpuriousHandler而不是_SpuriousHandler

当中断向量号是在0~127(包括0和127)时,会执行中断处理代码SpuriousHandler

当中断向量号是080h时,会执行中断处理代码UserIntHandler

调用中断

调用中断的语句很简单,如下:

int 00h
int 01h
int 10h
int 20h

小结

  1. 中断,由硬件或软件触发,迫使CPU停止执行当前指令,切换到其他指令,目的是让计算机响应新工作。
  2. 提供中断向量,在IDT中寻找中断描述符,根据在中断描述符中找到的段选择子和偏移量,找到中断例程并执行。
  3. 一句话,根据中断向量找到中断例程并执行。
  4. 为什么能根据中断向量找到中断例程?这是更底层的硬件工作机制。我们只需为硬件提供IDT的初始地址和界限就行。
  5. 本小节介绍中断省略了中断发生时保存CPU的快照、堆栈和特权级的转移。

外部中断

时钟中断、键盘中断、鼠标中断,还有其他中断在同一时刻产生,CPU应该处理哪个中断?事实上,CPU并不直接和这些产生中断的硬件打交道,而是通过一个”代理''。代理会按照优先级规则从众多中断中挑选一个再交给CPU处理。

处理中断的这个代理就是8259A。它是一个硬件。看看8259A的示意图。

image-20211016081331282

8259A只是处理可屏蔽中断的代理。

8259A

我们使用两个8259A,一个是主片,一个是从片,从片挂载在主片上。

8259A有8个引脚,每个引脚能挂载一个会产生中断的硬件,例如硬盘;当然,每个引脚也能挂载另外一个8259A。主从两个8259A能挂载15个硬件。

8259A是可编程硬件,通过向相应的端口写入ICW和OCW数据实现编程。下面的代码展示了如何对它编程。

初始化8259A

; /home/cg/os/pegasus-os/v25/kernel.asm
Init_8259A:
        ; ICW1
        mov al, 011h
        out 0x20, al
        call io_delay

        out 0xA0, al
        call io_delay

        ; ICW2
        mov al, 020h
        out 0x21, al
        call io_delay

        mov al, 028h
        out 0xA1, al
        call io_delay

        ; ICW3
        mov al, 004h
        out 0x21, al
        call io_delay

        mov al, 002h
        out 0xA1, al
        call io_delay

        ; ICW4
        mov al, 001h
        out 0x21, al
        call io_delay

        out 0xA1, al
        call io_delay
        
        ; OCW1
        ;mov al, 11111110b
        mov al, 11111101b
        out 0x21, al
        call io_delay

        mov al, 11111111b
        out 0xA1, al
        call io_delay
        
        ret

; 让CPU空转四次
io_delay:
				; 让CPU空转一次
        nop
        nop
        nop
        nop
        ret        

ICW和OCW的数据结构

ICW的全称是Initialization Command Word,OCW的全称是Operation Commnd Word。

OCW简单一些,只需写入ICW1就能满足我们的需求。向主8259A的0x21写入OCW1,向从8259A的0xA1写入OCW1,作用是屏蔽或放行某个中断,1表示屏蔽,0表示放行。

四个ICW都被用到了。必须按照下面的顺序写入ICW和OCW。

  1. 通过0x20端口向主8259A写入ICW1,通过0xA0端口向从8259A写入ICW1。
  2. 通过0x21端口向主8259A写入ICW2,通过0xA1端口向从8259A写入ICW2。
  3. 通过0x21端口向主8259A写入ICW3,通过0xA1端口向从8259A写入ICW3。
  4. 通过0x21端口向主8259A写入ICW4,通过0xA1端口向从8259A写入ICW4。
  5. 向主8259A的0x21写入OCW1,向从8259A的0xA1写入OCW1。

像这样写入数据,有点怪异。

写入ICW2、ICW3、ICW4的端口相同,怎么区分写入的数据呢?我是这样理解的。由8259A自己根据写入的端口和顺序来识别。对于主片,第一次向0x20端口写入的是ICW1,第二次向0x21端口写入的是ICW2,第三次向0X21端口写入的是ICW3,第五次向0x21端口写入的是OCW1。从片也像这样根据端口和顺序来识别接收到的数据。

为什么要这么写代码?我猜测,是8259A提供的使用方法要求这么做。不必纠结这个。就好比使用微信支付的SDK,不需要纠结为啥要求按某种方式传参一样。

需要我们仔细琢磨的是ICW和OCW的数据结构。先看它们的数据结构图,再逐个分析它们的值。

image-20211016074508539

ICW1的值是011h,把011h转换成二进制数据是00010001b。对照ICW1的数据结构,第0位是1,表示需要写入ICW4。第1位是0,表示主8259A挂载了从8259A。第4位是1,因为8259A规定ICW1的第4位必须是1。其他位都是0,对照上图中的ICW1能很容易看明白。

ICW2表示8259A挂载的硬件对应的中断向量号的初始值。主8259A的ICW2的值是020h,表示挂载在它上面的硬件对应的中断向量号是020h到027h。主8259A的ICW2的值是028h,表示挂载在它上面的硬件对应的中断向量号是028h到02Fh。

ICW3的数据结构比较特别,主片的ICW3的数据结构和从片的ICW3的格式不同。主片的ICW3用位图表示序号,从片的ICW3用数值表示序号。主片的ICW3是004h,把004h转化成二进制数00000100b,第2个bit是1,表示主片的第2个引脚(引脚编号的初始值是0)挂载从片。从片的ICW3是002h,数值是2。

ICW3的工作机制是这样的。从片认为自己挂载到了主片的第2个引脚上。主片向所有挂载在它上面的硬件发送数据时,数据中会包含这些数据的接收方是挂载在第2个引脚上的硬件。每个硬件接收到数据后,检查一下自己的ICW3中的值是不是2,如果是2,就认为这些数据是发给自己的;如果不是2,就认为这些数据不是发给自己的然后丢弃这些数据。

OCW1的结构很简单。下图右侧的注释是主片的,如果是从片,只需把IRQ0到IRQ7换成IRQ0到IRQ15。

image-20211016081257951

主片的OCW1是11111101b,表示打开放行键盘中断;如果是11111110b,表示放行时钟中断。

从片的OCW1是11111111b,表示屏蔽IRQ8到IRQ15这些中断。

实现单进程

进程是一个运行中的程序实体,拥有独立的逻辑控制流和地址空间。这是进程的标准定义。

我们实现进程,关注进程的三要素:进程体、进程表和堆栈。进程体所在的代码段和堆栈所在的堆栈段共同构成进程的地址空间。独立的逻辑控制流是指每个进程好像拥有一个独立的CPU。

什么叫拥有独立的CPU?我的理解是,进程在运行过程中,数据不被非自身修改,核心是:不受干扰,就像CPU只运行一个进程一样不受非法修改数据、代码等。

下面详细介绍进程的三要素。

进程三要素

进程体

用C语言编写进程体。外观上,进程体是一个C语言编写的函数。例如下面的函数TestA

image-20211016100421769

进程表

CPU的快照

CPU并不是一直运行一个进程,而是总是运行多个进程。怎么实现让进程独享CPU的效果呢?使用进程表实现。

CPU运行A、B进程。A进程运行1分钟后,CPU选择了B进程运行,B进程运行1分钟后,CPU又开始运行A进程。假如A进程中有一个局部变量num,num的初始值是0;第一次运行结束时,num的值变成了5;当A进程重新开始运行时,必须保证局部变量的值是5,而不是0。不仅是num的值需要是第一次运行结束时的值,A的所有值都必须是第一次运行结束时的值。

说了这么多,只为了引申出进程表的作用。进程表在进程初始化时提供进程的初始信息,在进程被休眠时保存当前CPU的值,换句话说,为CPU建立一张快照。

为CPU建立快照,就是把寄存器中的值保存起来。保存到哪里?保存到进程表。

进程表的主要元素是一系列寄存器的值,然后还有一些进程信息,例如进程的名称、优先级等。

// 进程表 start
typedef struct{
        // 中断处理程序压栈,手工压栈
        unsigned int gs;
        unsigned int fs;
        unsigned int es;
        unsigned int ds;
        // pushad压栈,顺序固定
        unsigned int edi;
        unsigned int esi;
        unsigned int ebp;
        // 在这里消耗了很多时间。为啥需要在这里补上一个值?这是因为popad依次出栈的数据中有这么个值,
        // 如果不补上这一位,出栈时数据不能依次正确更新到寄存器中。
        unsigned int kernel_esp;
        unsigned int ebx;
        unsigned int edx;
        unsigned int ecx;
        unsigned int eax;
        // 中断发生时压栈
        unsigned int eip;
        unsigned int cs;
        unsigned int eflags;
        unsigned int esp;       // 漏掉了这个。iretd会出栈更新esp。
        unsigned int ss;
}Regs;

typedef struct{
        Regs s_reg;
        // ldt选择子
        unsigned short ldt_selector;
        // ldt
        Descriptor ldts[2];
        unsigned int pid;
}Proc;

Proc是进程表,有四部分组成:寄存器组、GDT选择子ldt_selector、LDT、进程ID--pid。

寄存器组保存CPU的快照数据,存储在栈s_reg中。根据入栈操作的执行方式不同,分为三部分。

手工压栈入栈的是和下列变量同名的寄存器的值。

// 中断处理程序压栈,手工压栈
unsigned int gs;
unsigned int fs;
unsigned int es;
unsigned int ds;

用pushad入栈的是和下列变量同名的寄存器的值。

// pushad压栈,顺序固定
unsigned int edi;
unsigned int esi;
unsigned int ebp;
// 在这里消耗了很多时间。为啥需要在这里补上一个值?这是因为popad依次出栈的数据中有这么个值,
// 如果不补上这一位,出栈时数据不能依次正确更新到寄存器中。
unsigned int kernel_esp;
unsigned int ebx;
unsigned int edx;
unsigned int ecx;
unsigned int eax;

中断发生时自动入栈的是和下列变量同名的寄存器的值。

 // 中断发生时压栈
 unsigned int eip;
 unsigned int cs;
 unsigned int eflags;
 unsigned int esp;       // 漏掉了这个。iretd会出栈更新esp。
 unsigned int ss;

入栈的顺序是这样的:中断发生时自动入栈------>pushad入栈------>手工入栈。

出栈的顺序是这样的:手工出栈------>popad出栈------>iretd出栈。

寄存器中的数据的入栈顺序和s_reg的成员的上下顺序相反。

s_reg的成员的上下顺序和寄存器中的数据出栈的顺序一致。

IA-32的pushad指令在堆栈中按顺序压入这些寄存器的值:EAX,ECX,EDX,EBX,ESP,EBP,ESI和EDI。

IA-32的popad指令从堆栈中按顺序弹出栈元素到这些寄存器:EDI,ESI,EBP,ESP,EBX,EDX,ECX,EAX。

可以看出,pushad和popad正好是相反的运算。

需要注意,CPU的esp中的值并不是由pushad压入堆栈,而是在中断发生时自动压入堆栈的。这么说还不准确。pushad入栈的数据虽然也是esp中的值,却不是为进程建立快照时需要保存的值。发生中断前,堆栈是进程的堆栈;发生中断进入中断例程后,堆栈已经变成了内核栈。为进程建立快照需要保存的是进程的堆栈栈顶而不是内核堆栈的栈顶。esp的值有两次被压入s_reg,但是,CPU恢复之前的进程时使用的是中断发生时自动压入堆栈的esp中的值。

恢复之前的进程时,先把之前由pushad入栈的esp的值更新到esp中,最后被iretd把中断发生时自动压入堆栈的esp中的值更新到esp中。这正是我们期望的结果。

能不能删除s_reg的kernel_esp成员?不能。pushad、popad总是操作8个数据和对应的寄存器,如果缺少一个数据,建立快照或从快照中恢复时会出现数据错误。

有一个疑问需要解释:s_reg明明是一个结构体,怎么说它是栈,还对它进行压栈出栈操作呢?

我认为这是一个疑问,是受高级编程语言中的堆栈影响。在潜意识中,我认为,先进后出的结构才是堆栈;具备这种特性还不够,似乎还必须是一个结构体。完全不是这样的。只要按照“先进后出”的原则对一段存储空间的数据进行操作,这段存储空间就能被称之为堆栈。

其他

ldt_selector这个名字不是很好,因为,它是GDT的选择子,却容易让人误以为是LDT的选择子。

LDT是一段内存空间,和局部描述符的集合。GDT是全局描述符的集合。局部描述符和全局描述符的数据结构一样。通过ldt_selector在GDT中找到对应的全局描述符。这个全局描述符记录LDT的信息:初始地址和界限大小。

ldts就是LDT,只包含两个描述符,一个是代码段描述符,一个是数据段、堆栈段等描述符。它被存储在进程表中。

pid是进程ID。

堆栈

int proc_stack[128];
// proc 是进程表
proc->s_reg.esp = (int)(proc_stack + 128);

在前面已经说过,堆栈也好,队列也好,都是一段存储空间,只需按照特定的原则读写这段存储空间,例如“先进后出”,就是堆栈或队列。不要以为只有像那些数据结构书中那样定义的数据结构才是堆栈或队列。

把esp设置成proc_stack + 128,体现了堆栈从高地址向低地址向下生长。堆栈的数据类型是int [128],对应esp是一个32位寄存器。

proc_stck是堆栈的初始地址,加128后,已经到了堆栈之外。把初始栈顶的值设置成堆栈之外,有没有问题?

没有问题。push指令的执行过程是:先移动栈顶指针,再存储数据。初始状态,往堆栈中入栈4,过程是这样的:

  1. 堆栈指针减去1,esp的值是 proc_stack + 128 - 1proc_stack + 127在堆栈proc_stack内部,从数组角度看,是最后一个元素。
  2. 再存储数据,proc_stack[127] = 4

启动进程

准备好进程三要素后,怎么启动进程?启动进程包括进程“第一次”启动和进程“恢复”启动。先探讨进程“第一次启动”。

进程“第一次启动”,是特殊的“恢复”启动。是不是这样呢?是可以的。在一个进程死亡前,将会经历很多次“恢复”启动,第一次启动只是从初始状态“恢复”启动而已。CPU不关注进程是第一次启动还是“恢复”启动。程序计数器指向什么指令、寄存器中有什么数据,它就执行什么指令。我们只需把进程的初始快照放入CPU就能启动进程。快照在进程表中,要把进程表中的值放入CPU。下面的restart把进程快照放入CPU并启动进程。

; 启动进程
restart:
        mov eax, proc_table
        mov esp, eax
        ; 加载ldt
        lldt [proc_table + 68]
        ; 设置tss.esp0
        lea eax, [proc_table + 68]
        mov [tss + 4], eax
        ; 出栈
        pop gs
        pop fs
        pop es
        pop ds
        
        popad
        iretd

这个函数的每行代码都大有玄机,需要琢磨一番。

restart做了两件事:恢复快照,把程序计数器指向进程体。

恢复快照分为设置LDTPtr寄存器、设置tss.esp0、设置其他寄存器的值。

lldt [proc_table + 68],设置LDTR寄存器的值。LDTR的数据结构和选择子一样。sizeof(Regs)的值是68,所以proc_table + 68是进程表的成员ldt_selector的内存地址。

在这里耗费了不少时间,原因是我误以为LDTR的结构和GDTR的结构相同。二者的结构并不相同。

mov [tss + 4], eax,设置tss.esp0。tss是个比较麻烦的东西,在后面专门讲解。

pop gsiretd,设置其他寄存器的值。

iretd,依次出栈数据并更新ss、esp、eflags、cs、eip`这些寄存器的值。

cs、eip被更新为进程的代码段和进程体的入口地址后,进程就启动了。

代码

void kernel_main()
{
	Proc *proc = proc_table;
	
	proc->ldt_selector = LDT_FIRST_SELECTOR;
	
	Memcpy(&proc->ldts[0], &gdt[CS_SELECTOR_INDEX], sizeof(Descriptor));
	// 修改ldt描述符的属性。全局cs的属性是 0c9ah。
	proc->ldts[0].seg_attr1 = 0xba;
	Memcpy(&proc->ldts[1], &gdt[DS_SELECTOR_INDEX], sizeof(Descriptor));
	// 修改ldt描述符的属性。全局ds的属性是 0c92h
	proc->ldts[1].seg_attr1 = 0xb2;

	// 初始化进程表的段寄存器
	proc->s_reg.cs = 0x05;	// 000 0101		
	proc->s_reg.ds = 0x0D;	// 000 1101		
	proc->s_reg.fs = 0x0D;	// 000 1101		
	proc->s_reg.es = 0x0D;	// 000 1101		
	//proc->s_reg.ss = 0x0D;	// 000 1101	
	proc->s_reg.ss = 0x0D;	// 000 1100	
	// 0x3b--> 0011 1011 --> 0011 1 001
	// 1001
	proc->s_reg.gs = GS_SELECTOR & (0xFFF9);
	// proc->s_reg.gs = 0x0D;
	// 初始化进程表的通用寄存器
	proc->s_reg.eip = (int)TestA;
	proc->s_reg.esp = (int)(proc_stack + 128);
	// 抄的于上神的。需要自己弄清楚。我已经理解了。
	// IOPL = 1, IF = 1
	// IOPL 控制I/O权限的特权级,IF控制中断的打开和关闭
	proc->s_reg.eflags = 0x1202;	
	// 启动进程,扣动扳机,砰!		
	
	dis_pos = 0;
	// 清屏
	for(int i = 0; i < 80 * 25 * 2; i++){
		disp_str(" ");
	}	

	restart();

	while(1){}
}

代码的条理很清晰,做了下面几件事:

  1. 选择进程表。
  2. 填充进程表。在restart中,填充的是CPU。
  3. 清除屏幕上的字符串。
  4. 启动进程。
  5. 让CPU永远执行指令。

代码详解

填充进程表是主要部分,详细讲解一下。

ldt_selector

proc->ldt_selector = LDT_FIRST_SELECTOR;,设置指向进程的LDT的选择子。这是GDT选择子,通过这个选择子在GDT中找到对应的全局描述符,然后通过这个全局描述符找到LDT。

LDT_FIRST_SELECTOR的值是0x48。为什么是0x48

因为,我把GDT设计成了下列表格这个样子。每个选择子的值是由它在GDT中的位置也就是索引决定的。为什么这些描述符在GDT中的索引(位置)不连贯呢?这是因为我设计的GDT中还有许多为了进行其他测试而填充的描述符。

选择子的高13位是描述符在描述符表中的偏移量,当然,偏移量的单位是一个描述符的长度;低13位存储TI和CPL。

全局描述符 索引 选择子
空描述符 0 高13位是0,第3位是0,最终结果是0b,0x0
代码段描述符 1 高13位是1,第3位是0,最终结果是1000b,0x8
数据段描述符 6 高13位是6,第3位是0,最终结果是110 000b,0x30
视频段描述符 7 高13位是7,第3位是0,最终结果是111 000b,0x38
TSS段描述符 8 高13位是8,第3位是0,最终结果是1000 000b,0x40
LDT段描述符 9 高13位是9,第3位是0,最终结果是1001 000b,0x48

LDT

根据ldt_selector找到LDT,LDT的内存地址已经确定了,就在进程表中,不能更改。要让ldt_sector间接指向进程表中的LDT,只能在指向LDT的全局描述符下功夫。

// 初始化LDT
// 对应ldts只有两个元素。
int ldt_size = 2 * sizeof(Descriptor);
// int ldt_attribute = 0x0c92;          // todo ldt的属性怎么确定?
int ldt_attribute = 0x82;          // todo ldt的属性怎么确定?
// proc_table[0].ldts 只是在代码段中的偏移量,需要加上所在代码段的基地址。
int ldt_base = VirAddr2PhyAddr(ds_phy_addr, proc_table[0].ldts);
// 全局描述符的基地址、界限、属性都已经知道,足以创建一个全局描述符。
// ldt_base 是段基值,不是偏移量。
InitDescriptor(&gdt[LDT_FIRST_SELECTOR_INDEX], ldt_base, ldt_size - 1, ldt_attribute);

唯一要特别着墨的是ldt_attribute为什么是0x82

指向LDT的全局描述符的特征有:可读、代码段、粒度是1bit、32位。翻翻《写操作系统之开发加载器》,搬过来下面的表格。

A W E X S DPL DPL P AVL L D/B G
0 1 0 0 0 0 0 1 0 0 0 0

在上面的表格中填入LDT的全局描述符的属性。G位是0而不是1。LDT只占用16个bit,不需要使用4KB来统计长度。W位是1,表示LDT是可读可写的。我们会看到,确实需要对LDT进行写操作。

要特别注意,S位是0,因为,LDT是一个系统段,不是代码段也不是数据段。TSS也是系统段。

从右往左排列表格的第二行的每个单元格,得到属性值。它是0000 1000 0010b,转换成十六进制数0x82

我以前提出的问题在今天被我自己解答了,这就叫“温故而知新”。就是不知道做这些事有没有用。

填充进程体的LDT。

Memcpy(&proc->ldts[0], &gdt[CS_SELECTOR_INDEX], sizeof(Descriptor));
// 修改ldt描述符的属性。全局cs的属性是 0c9ah。
proc->ldts[0].seg_attr1 = 0xba;
Memcpy(&proc->ldts[1], &gdt[DS_SELECTOR_INDEX], sizeof(Descriptor));
// 修改ldt描述符的属性。全局ds的属性是 0c92h
proc->ldts[1].seg_attr1 = 0xb2;

上面的代码的思路是:复制GDT中的代码段描述符、数据段描述符到LDT中,然后修改LDT中的描述符的属性。

段寄存器的值

s_reg.cs是LDT中第一个描述符的选择子。这个选择子高13位是索引0,低3位是TI和RPL,TI是1,RPL是1,综合得到选择子的值是0x5。按同样的方法计算出其他段寄存器的值是0xD。

gs的值比较特别。在上面的代码中,我为了把gs的RPL设置得和其他段选择子的RPL相同,把视频段选择子的RPL也设置成了1,这不是必要的。视频段描述符依然是GDT中的描述符。proc->s_reg.gs = GS_SELECTOR & (0xFFF9);,体现了前面所说的一切。

由于代码中错误的注释,再加上我在一瞬间弄错了C语言运算符&的运算规则,在此浪费了不少时间。我误把&当成汇编语言中的运算符。

eip的值

eip存储程序计数器。程序计数器是下一条要执行的指令的地址,在操作系统中,是指令在代码段中的偏移量。

要在启动进程前把eip的值设置成进程体的入口地址。函数名就是进程体的入口地址,C语言中的函数和汇编语言中的函数都是如此。入口地址是函数的第一条指令的地址。

proc->s_reg.eip = (int)TestA;,正如我们看到的,直接使用TestA就能把函数的入口地址赋值给s_reg.eip。前面的(int)为了消除编译器的警告信息。

如果TestA是一个变量,使用变量名得到的是变量中存储的值而不是变量的存储地址。为什么?编译器就是这么做的。我的理解,函数名和数组名类似。

eflags的值

eflags

image-20211017144413829

又见到eflags了。上次看过eflags后,我到现在还心有余悸。这一次,我们关注eflags中的IOPL。

IOPL控制用户程序对敏感指令的使用权限。敏感指令是in、ins、out、outs、cli、sti这些指令。

IOPL占用2个bit。它存储在eflags中,只能在0特权级下通过popfiretd修改。

使用IOPL设置一个特权级的用户程序对所有端口的访问权限,使用I/O位图对一个特权级的用户程序设置个性化的端口访问权限(能访问部分端口、不能访问另外的端口)。

用户程序的CPL<IOPL,用户程序能访问所有端口。否则,从I/O位图中查找用户程序对端口的访问权限。

为什么把eflags的值设置成0x1202?我们把IOPL设置成1,把IF设置成1,综合而成,最终结果是0x1202。

IF是1,在restart中执行iretd后,打开了中断。恢复进程后一定要打开中断,才能在进程运行过程中处理其他中断。

IOPL的值如果设置为0,CPL是1的进程就不能执行敏感指令。

IF和IOPL的值设置成当前数据,还比较好理解。让我感觉困难的是eflags冗长的结构和计算最终数值的繁琐方法。不过,我找到一个表格(见下面的小节“eflags"结构表格),大大减少了麻烦。

我怎么使用这个表格?IOPL是1,那么,eflags的值是0x1000;IF的值是1,那么,eflags的值是0x0200;第1个bit是保留位,值是1,那么,eflags的值是0x0002。把这些eflags进行|运算,最终结果是0x1202。正好和代码中的值相同,这是一个巧合。但表格提供了一种方法,就是只计算相应元素时eflags的值是多少。用这种方法,能避免每次都需要画一个32位的表格然后逐个单元格填充数据。计算段属性也可以用这种方式简化计算方法。

计算eflags的唯一难点是弄清楚每个位的含义。

有空再看这个eflags,此刻只想尽快离开这个知识点。

eflags结构表格

image-20211017150338862

剩余

剩下的代码的功能是清屏、启动进程、让CPU永远执行。在前面已经讲过,不再赘述。

实现多进程

简单改造上面的单进程代码,就能启动多个进程。真的是这样吗?很遗憾,真的不是这样。

准备好多个进程三要素很容易,可是,单进程代码缺少切换到其他进程的机制。一旦执行准备好的第一个进程,就会一直执行这个进程,没有契机执行其他进程。需加入中断例程、建立快照的代码,再加上多个进程需要的材料,就能实现多进程。现在,我们开始把单进程代码改造为多进程代码。

多个进程要素

进程体

void TestA()
{
        while(1){
                disp_str("A");
                disp_int(2);
                disp_str(".");
                delay(1);
        }
}

void TestB()
{
        while(1){
                disp_str("B");
                disp_int(2);
                disp_str(".");
                delay(1);
        }
}

void TestC()
{
        while(1){
                disp_str("C");
                disp_int(3);
                disp_str(".");
                delay(1);
        }
}

三个函数分别是进程A、B、C的进程体。不能再像前面那样直接把函数名赋值给s_reg.eip了,因为在循环中不能使用固定值。新方法是建立一个进程体入口地址的数组。这个数组中的元素的数据类型是函数指针。

先了解一下函数指针。

#include <stdio.h>
 
int max(int x, int y)
{
    return x > y ? x : y;
}
 
int main(void)
{
    /* p 是函数指针 */
    int (* p)(int, int) = & max; // &可以省略
    int a, b, c, d;
 
    printf("请输入三个数字:");
    scanf("%d %d %d", & a, & b, & c);
 
    /* 与直接调用函数等价,d = max(max(a, b), c) */
    d = p(p(a, b), c); 
 
    printf("最大的数字是: %d\n", d);
 
    return 0;
}

使用函数指针的代码是:

// code-A
/* p 是函数指针 */
int (* p)(int, int) = & max; // &可以省略
d = p(p(a, b), c); 

上面的代码等价于下面的代码。

// code-B
typedef int (* Func)(int, int);
Func p = & max; // &可以省略
d = p(p(a, b), c); 

code-A在声明变量的同时初始化变量。code-B先创建了数据类型,然后初始化一个这种类型的变量并初始化。我们等会使用code-B风格的方式创建函数指针。

函数指针语法似乎有点怪异,和普通指针语法差别很大。其实它们的差别不大。

int *p,声明一个指针,指针名称是p,数据类型是int *

int (*p)(int, int),声明一个函数指针,指针名称是p,数据类型是int *(int, int)

把这种表面风格不一样的指针声明语句看成”数据类型+变量名“,就会觉得它们的风格其实是一样的。

typedef int (* Func)(int, int);,实质是typedef int *(int, int) Func;

好了,函数指针语法这个障碍已经被消除了,我们继续实现多进程。

前面,我说到,把进程体的入口放到数组中,存储进程体的入口的数据类型需要是函数指针。代码如下。

typedef void (*Func)();

typedef struct{
        Func func_name;
        unsigned short stack_size;
}Task;

Task task_table[3] = {
        {TestA, A_STACK_SIZE},
        {TestB, B_STACK_SIZE},
        {TestC, C_STACK_SIZE},
};

设置eip的值的代码是proc->s_reg.eip = (int)task_table[i].func_name;

eip的值是进程体的入口,是一个内存地址;而函数名例如TestA就是内存地址,Task的成员func_name是一个指针,能够把TestA等函数名赋值给它。把内存地址赋值给指针,这符合语法。

堆栈

// i是进程体在进程表数组中的索引;堆栈地址从高地址往低地址生长。
proc->s_reg.esp = (int)(proc_stack + 128 * (i+1));

进程表

#define PROC_NUM 3
Proc proc_table[PROC_NUM];

单进程代码中,只有一个进程,因此只需要一个进程表。现在,有多个进程,要用一个进程表数组存储进程表。填充进程表的大概思路是遍历进程表数组,填充每个进程表。全部代码如下。

void kernel_main()
{
	counter = 0;
	Proc *proc = proc_table;
	for(int i = 0; i < PROC_NUM; i++){	
		proc->ldt_selector = LDT_FIRST_SELECTOR + 8 * i;
		proc->pid = i;	
		Memcpy(&proc->ldts[0], &gdt[CS_SELECTOR_INDEX], sizeof(Descriptor));
		// 修改ldt描述符的属性。全局cs的属性是 0c9ah。
		proc->ldts[0].seg_attr1 = 0xba;
		Memcpy(&proc->ldts[1], &gdt[DS_SELECTOR_INDEX], sizeof(Descriptor));
		// 修改ldt描述符的属性。全局ds的属性是 0c92h
		proc->ldts[1].seg_attr1 = 0xb2;

		// 初始化进程表的段寄存器
		proc->s_reg.cs = 0x05;	// 000 0101		
		proc->s_reg.ds = 0x0D;	// 000 1101		
		proc->s_reg.fs = 0x0D;	// 000 1101		
		proc->s_reg.es = 0x0D;	// 000 1101		
		proc->s_reg.ss = 0x0D;	// 000 1100	
		// 需要修改gs的TI和RPL	
		// proc->s_reg.gs = GS_SELECTOR;	
		// proc->s_reg.gs = GS_SELECTOR | (0x101);
		//proc->s_reg.gs = GS_SELECTOR;
		//proc->s_reg.gs = GS_SELECTOR & (0x001);
		// 0x3b--> 0011 1011 --> 0011 1 011
		// 1001
		proc->s_reg.gs = GS_SELECTOR & (0xFFF9);
		proc->s_reg.eip = (int)task_table[i].func_name;
		proc->s_reg.esp = (int)(proc_stack + 128 * (i+1));
		// IOPL = 1, IF = 1
		// IOPL 控制I/O权限的特权级,IF控制中断的打开和关闭
		proc->s_reg.eflags = 0x1202;	
		
		proc++;
	}
	
	proc_ready_table = proc_table;	
	dis_pos = 0;
	// 清屏
	for(int i = 0; i < 80 * 25 * 2; i++){
		disp_str(" ");
	}	
	dis_pos = 2;
	restart();

	while(1){}
}

大部分代码和单进程代码相同,只是在后者的基础上增加了一个循环。此外,还有下面这些细微差别。

proc->ldt_selector = LDT_FIRST_SELECTOR + 8 * i;。指向进程的LDT的全局描述符在GDT中依次相邻,相邻描述符之间的差值是1,反映到指向LDT的选择子的差别就是8。

proc->s_reg.eip = (int)task_table[i].func_name;,在前面专门解释过。

proc->s_reg.esp = (int)(proc_stack + 128 * (i+1));

proc_stack是堆栈空间,每个进程的堆栈是128个字节。

第一个进程的初始堆栈栈顶是proc_stack + 128,第一个进程的初始堆栈栈顶是proc_stack + 128 + 128,第一个进程的初始堆栈栈顶是proc_stack + 128 + 128 + 128

进程快照

多进程模型中,进程切换的契机是时钟中断。时钟中断每隔一段时间发生一次。在时钟中断例程中,根据某种策略,选择时钟中断结束后要执行的进程是时钟中断发生前的进程还是另外一个进程。选择执行哪个进程,这就是进程调度。

进程在时钟中断前的数据、下一条要执行的指令等都需要保存起来,以便恢复进程时在前面的基础上继续执行,而不是另起炉灶。

进程快照是CPU在某个时刻的状态,通过把CPU的状态保存到进程表。在前面的小节“CPU快照”已经详细介绍了方法。在此基础上,一起来看看建立快照的代码。

;中断发生时会依次入栈ss、esp、eflags、cs、eip
; 建立快照
pushad
push ds
push es
push fs
push gs

时钟中断例程

代码

hwint0:
        ; 建立快照
        pushad
        push ds
        push es
        push fs
        push gs

        mov dx, ss
        mov ds, dx
        mov es, dx
        
        mov esp, StackTop
        sti
        push ax
        ; 进程调度
        call schedule_process
        ; 置EOI位
        mov al, 20h
        out 20h, al
        pop ax
        cli
        ; 启动进程
        jmp restart

堆栈变化

中断发生时,esp的值是TSS中的esp0,ss是TSS中的ss0。

在hwint0中,mov esp, StackTop把esp设置成内核栈StackTop,ss依然是TSS中的ss0。

在restart中,把esp设置成从中断中要恢复执行的进程的进程表proc_table_ready,ss还是TSS中的ss0。

进程恢复后,esp是进程表中的esp,ss是进程表中的ss。

在这些堆栈变化中,只有第一次变化,需要解释。其他的变化,都很好懂。

中断发生时,CPU会自动从TSS中获取ss和esp的值。欲知详情,请看下一个小节。

TSS

TSS的全称是task state segment,中文全称是”任务状态段“。它是硬件厂商提供给操作系统的一个硬件,功能是保存CPU的快照。但主流操作系统都没有使用TSS保存CPU的快照,而是使用我们在上面见过的那种方式保存CPU的快照。

结构图

image-20211017191504714

结构代码
typedef struct{
        // 上一个任务的TSS指针
        unsigned int last_tss_ptr;
        unsigned int esp0;
        unsigned int ss0;
        unsigned int esp1;
        unsigned int ss1;
        unsigned int esp2;
        unsigned int ss2;
        unsigned int cr3;
        unsigned int eip;
        unsigned int eflags;
        unsigned int eax;
        unsigned int ecx;
        unsigned int edx;
        unsigned int ebx;
        unsigned int esp;
        unsigned int ebp;
        unsigned int esi;
        unsigned int edi;
        unsigned int es;
        unsigned int cs;
        unsigned int ss;
        unsigned int ds;
        unsigned int fs;
        unsigned int gs;
        unsigned int ldt;
        unsigned int trace;
        unsigned int iobase;
}TSS;

结构图中的ss0、ss1这类高16位是保留位的成员也用32位来存储。iobase也是。这么多成员,我们只使用了ss0、esp0、iobase。

填充

进程初次启动前,往进程表中填入了要更新到TSS中的数据。看代码。

tss.ss0 = DS_SELECTOR;

为什么没有填充esp0?其实有填充,在restart中。

; 设置tss.esp0
lea eax, [proc_table + 68]
mov [tss + 4], eax

从TSS的结构图,能计算出tss + 4是esp0的地址,所以上面的代码是把进程表中的s_reg的栈顶(最高地址)存到了TSS的esp0中。

中断发生时,CPL是0,CPU会自动从TSS中选择对应的ss0和esp0;如果CPL是1,CPU会选择ss1和esp1。

进程初次启动前,TSS中的ss0和esp0分别被设置成进程的ss和进程表的s_reg的最高地址。中断发生时,CPU从TSS中取出ss0和esp0,然后压栈。注意,此时的ss0和esp0分别是我们在前面设置的被中断的进程的ss和进程表的s_reg的最高地址。就这样,CPU发生中断时的状态被保存到了该进程的进程表中。

在中断例程的末尾,TSS中的ss和esp0被设置成即将从中断中恢复的进程的ss和s_reg的最高地址。当中断再次发生时,CPU状态会被保存到这个进程的进程表中。

中断重入

时钟中断发生后,执行时钟中断例程,未执行完时钟中断例程前又发生了键盘中断,这种现象就叫中断重入。

处理思路

发生中断重入时,怎么处理?迅速结束重入的中断例程,不完成正常情况下应该完成的功能,然后接着执行被重入的中断例程。具体思路是:

  1. 设置一个变量k_reenter,初始值是2。当然也能把初始值设置成3、4或其他值。
  2. 当中断发生时,k_reenter减去1。
  3. 在中断例程中,执行完成特定功能例如进程调度代码前,检查k_reenter的值是不是1。
    1. 是1,执行进程调度代码,更换要被恢复执行的进程。
    2. 不是1,跳过进程调度代码,不更换要被恢复执行的进程,也就是说,继续执行被中断的进程。
  4. 在中断例程的末尾,k_reenter加1。

代码

; k_reenter 的初始值是0。
hwint0:
        ; 建立快照
        pushad
        push ds
        push es
        push fs
        push gs
        
				mov dx, ss
        mov ds, dx
        mov es, dx
        mov fs, dx

        mov al, 11111001b
        out 21h, al
        ; 置EOI位 start
        mov al, 20h
        out 20h, al
        ; 置EOI位 end
        inc dword [k_reenter]
        cmp dword [k_reenter], 0
        jne .2
.1:
        mov esp, StackTop
.2:
        sti
        call clock_handler
        mov al, 11111000b
        out 21h, al
        cli
        cmp dword [k_reenter], 0
        jne reenter_restore
        jmp restore
        
; 恢复进程
restore:
        ; 能放到前dword 面,和其他函数在形式上比较相似
        mov esp, [proc_ready_table]
        lldt [esp + 68]
        ; 设置tss.esp0
        lea eax, [esp + 68]
        mov dword [tss + 4], eax
reenter_restore:
        dec dword [k_reenter]
        ; 出栈
        pop gs
        pop fs
        pop es
        pop ds
        
        popad
        iretd

k_reenter的初始值是0,中断发生时,执行到第一条cmp dword [k_reenter], 0语句,当这个中断是非重入中断时,k_reenter的值是0;当这个中断是重入中断时,k_reenter的值是2,总之k_reenter的值大于0。

上面的判断需要琢磨一番。

代码中,对重入中断的处理是:

  1. 跳过mov esp, StackTop。没有更改esp的值。
  2. 执行reenter_restore。没有更换要被恢复的进程。也就是说,恢复被重入中断打断的进程。

在上面的代码中,无论是重入中断还是非重入中断,都执行了完成特定功能的代码。不应该如此。以后找机会优化。

原子操作

在中断例程中,建立快照结束后,执行了sti打开中断。为什么要打开中断?因为发生中断时,(什么?CPU吗?)会自动关闭中断。在完成特定功能的代码执行完后、恢复快照到CPU前,又会关闭中断。执行iretd后,中断又被打开。

建立快照前、恢复快照前,都关闭了中断。为什么?为了确保CPU能一气呵成、不受其他与建立或恢复快照无关的指令修改快照或CPU的状态。想象一下,在建立快照的过程中,正要执行push eax时,又发生了其他中断,在新中断的中断例程中无法根据k_reenter识别新中断是不是重入中断。为了避免这种混乱的状况,必须保证建立CPU快照的过程不被打断。恢复进程的过程也是一样。

我无法模拟这种混乱的情况。这个解释似乎有点牵强,搁置,看我以后能不能想出更好的例子。

特权级

CPU有四个特权级,分别是0特权级、1特权级、2特权级、3特权级。特权级从0特权级到3特权级的权限依次递减。操作系统运行在0特权级,用户进程运行在3特权级,系统任务运行在1特权级。

参考资料

《一个操作系统的实现》

《操作系统真相还原》

x86 and amd64 instruction reference

FLAGS register

posted @ 2021-10-17 22:37  东小夫  阅读(530)  评论(0编辑  收藏  举报