0%

HIT-OSLab4

HIT-OSLab4

实验目的:

  • 深入理解进程和进程切换的概念
  • 综合应用进程、CPU 管理、PCBLDT、内核栈、内核态等知识解决实际问题
  • 开始建立系统认识

实验内容:

  • 将 Linux-0.11 中采用的基于 TSS 进程切换去掉,取而代之的是基于堆栈的切换程序,具体地说,也就是将进程切换函数 schedule() 函数中的 switch_to() 函数从原本的基于 TSS 切换改写成基于堆栈的切换
  • 编写汇编程序 switch_to(),完成主体框架,在主体框架下依次完成 PCB 切换、内核栈切换、LDT 切换等
  • 修改 fork(),由于是基于内核栈的切换,所以进程需要创建出能完成切换的内核栈
  • 修改 PCB,即 task_struct 结构,增加相应的内容域,同时处理由于修改了 task_struct 所造成的影响

实验过程

TSS(Task State Segment)即任务状态段:

  • 在 x86 架构下,每个进程/线程都有自己的 TSS,用于存储进程的状态信息
  • TSS 中存储了进程切换所需的寄存器和栈信息,使得进程在切换时能够快速地恢复现场并继续执行

TSS0 是 Linux-0.11 中的一个特殊段,用于保存内核进程的状态,在保护模式下,操作系统可以使用 TSS0 来保存当前内核进程的栈地址等信息,以便在需要时切换到其他内核进程

在 linux-0.11 中,表示 TSS 的结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};
  • tss_struct 也会被记录在 task_struct 中:
1
2
3
4
5
struct task_struct {
......
/* tss for this task */
struct tss_struct tss;
};

GDT(Global Descriptor Table)是 x86 处理器中的一个全局数据表,用于描述进程的内存空间:

  • 在 linux-0.11 中的 GDT 条目只有两个成员:
    • TSS:任务状态段
    • LDT:局部描述符表,用于描述一个进程的内存布局(以权限划分的各个段)

接下来我们可以分析一下 GDT 的生成过程:

  • 系统加电时,最先启动的程序就是 BIOS,负责将操作系统从硬盘加载到内存中
  • 之后系统执行 bootloader,其源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
entry start
start:

! ok, the read went well so we get current cursor position and save it for
! posterity.

mov ax,#INITSEG ! this is done in bootsect already, but...
mov ds,ax
mov ah,#0x03 ! read cursor pos
xor bh,bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.

......

end_move:
mov ax,#SETUPSEG ! right, forgot this at first. didn't work :-)
mov ds,ax
lidt idt_48 ! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate
  • 这段代码的前半部分在 Lab1 中已经分析过了,我们只需要看最后两段代码:
1
2
lidt	idt_48		! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate
  • lidt 用于加载中断描述符段表 IDT
  • lgdt 用于加载全局描述符段表 GDT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
gdt:
.word 0,0,0,0 ! dummy

.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386

.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386

idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L

gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx
  • 这里 bootloader 只做了最低程度的 GDT/IDT 初始化(为了 bootloader 的其他模块可以正常运行),后续的核心操作在 head.s 中
  • 而 head.s 文件通常会在引导程序执行的最后一个阶段被调用,其源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
.text
.globl idt,gdt,pg_dir,tmp_floppy_area
pg_dir:
.globl startup_32
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss stack_start,%esp /* 将栈指针%esp的值设置为stack_start */
call setup_idt /* 设置中断描述符段表 */
call setup_gdt /* 设置全局描述符段表 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setup_gdt:
lgdt gdt_descr
ret

setup_idt:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */

lea idt,%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
lidt idt_descr
ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.align 2
.word 0
idt_descr:/* 前(低)16位是IDT的界限值,后(高)32位是IDT的基地址 */
.word 256*8-1 # idt contains 256 entries
.long idt
.align 2
.word 0
gdt_descr: /* 前(低)16位是GDT的界限值,后(高)32位是GDT的基地址 */
.word 256*8-1 # so does gdt (not that that's any
.long gdt # magic number, but it works for me :^)

.align 8
idt: .fill 256,8,0 # idt is uninitialized

gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
  • 函数 setup_idt/setup_gdt 会分别创建 IDT/GDT 的内存结构,在后续的初始化中会往其中添加条目
  • 源码中没有设置具体的 GDT/IDT 基地址,而是在编译时由编译器计算获取

下面先分析 TSS 进程切换的过程,在 linux-0.11 中 switch_to 的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define switch_to(n) {\ 
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \ /* 比较task[n]和current */
"je 1f\n\t" \
"movw %%dx,%1\n\t" \ /* 将TSS选择符存放入tmp.b(tmp.a中的偏移为默认值-"0") */
"xchgl %%ecx,current\n\t" \ /* 交换current和task[n]的指针 */
"ljmp *%0\n\t" \ /* 跳转到目标进程 */
"cmpl %%ecx,last_task_used_math\n\t" \ /* 比较task[n]和last_task_used_math */
"jne 1f\n\t" \
"clts\n" \ /* 清除时间戳计数器(TSC)寄存器中的计数值 */
"1:" \
::"m" (*&__tmp.a),"m" (*&__tmp.b), \
"d" (_TSS(n)),"c" ((long) task[n])); \ /* edx存放任务号为n的TSS选择符,ecx存放task[n] */
}
  • __asm__ 语法中,:: 后面跟着的字母序列表示汇编指令的类别:
    • m:类型为内存(不需要借助寄存器)
    • r:类型为寄存器
    • d:类型为 edx 寄存器
    • c:类型为 ecx 寄存器
  • current 指向当前进程的 task_struct 结构体
  • last_task_used_math 存储上一次使用 math 模式的任务的 task_struct 结构体
1
2
3
4
#define FIRST_TSS_ENTRY 4
#define FIRST_LDT_ENTRY (FIRST_TSS_ENTRY+1)
#define _TSS(n) ((((unsigned long) n)<<4)+(FIRST_TSS_ENTRY<<3))
#define _LDT(n) ((((unsigned long) n)<<4)+(FIRST_LDT_ENTRY<<3))
  • FIRST_TSS_ENTRY<<3(32) 为 TSS 的基地址
  • FIRST_LDT_ENTRY<<3(40) 为 LDT 的基地址
  • 通过宏函数 _TSS(n)_LDT(n) 就可以计算出目标 TSS / LDT 在 GDT 中的地址

函数 switch_to 除了完成 current 的相关操作以外,最核心的指令就是 ljmp *%0

  • 指令 ljmp 有两个参数:TSS 段选择符,偏移地址
    • 结构体 __tmp 就是为了保存这两个参数
    • tmp.a 中保存偏移(默认值为 “0”),在 tmp.b 中保存目标的 TSS 选择符
  • 指令 ljmp 执行后,系统就会读取目标 tss_struct 结构体中的进程信息,然后实现进程跳转
    • 详细的操作步骤由 x86 架构的硬件负责

接下来阐述一下基于堆栈的进程切换过程:

  • 当系统发生中断从用户态进入内核态时,CPU 通过 _TSS(n) 寄存器找到 TSS 的位置,根据 TSS 中保存的 ss0:esp0 的值切换到内核栈,然后将用户栈的 ss,esp,eflags,cs,eip 的值保存在内核栈中
  • 中断处理完成后,此时若调度函数找到了需要切换的进程,此时就该将进程的其他寄存器信息也保存至内核栈中,然后切换到目的进程的 PCB、内核栈、LDT
  • 最后从目的进程的内核栈中恢复寄存器的值,并从中断返回,此时 iret 将弹出目的进程的 cs:eip,从而能跳转到目的进程中继续执行,这样就完成了进程的切换

下面是 linux-0.11 管理内核栈的结构体:

1
2
3
4
union task_union {
struct task_struct task;
char stack[PAGE_SIZE];
};
  • Linux-0.11 将进程的 task_struct(PCB)和内核栈定义在了一起(联合体)
  • PS:其实这里我有一些疑问,将两者放在一起会不会产生安全问题

copy_process 中,内核为 task_struct 分配了 4KB 的空间:

1
p = (struct task_struct *) get_free_page();
  • task_struct 的大小没有 4KB,因此剩下的空间就属于内核栈
  • PS:由于栈是向上增长的,而 task_struct 在页内存的低地址,一般不会发生冲突(如何内核栈使用过大,也可能会覆盖 task_struct)

内核设置 tss_struct->esp0(内核栈地址)的代码:

1
p->tss.esp0 = PAGE_SIZE + (long) p;
  • PCB 位于这页内存的低地址,内核栈位于这页内存的高地址

由于当前内核版本的内核栈顶地址记录在 tss_struct 中,如果不使用 TSS 机制则需要在 task_struct 中添加新的条目以记录内核栈顶地址:

1
2
3
4
5
6
7
8
9
10
11
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long kernelstack; /* 用于记录内核栈顶地址(相当于task_struct->tss.esp0) */
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
......
};

接下来有一处关于 task_struct 的硬编码和多次偏移地址需要修改:

1
2
3
4
5
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task, \
/* signals */ 0,{{},},0, \
......
}
  • INIT_TASK 用于生成 swapper 进程(pid = 0)
1
2
3
4
5
6
7
state	= 0		# these are offsets into the task-struct.
counter = 4
priority = 8
KERNEL_STACK = 12
signal = 12+4
sigaction = 16+4 # MUST be 16 (=len of sigaction)
blocked = (32*16+4)
  • 修改 system_call.s 中定义的偏移量(添加 KERNEL_STACK,后续的条目向后移4字节)

当新进程被创建时,我们需要为其设置内核栈,具体操作可以参考如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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();
......

// 将新进程的TSS初始化去掉(我们只需要使用TSS0)
/*
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;
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);
p->tss.trace_bitmap = 0x80000000;
*/
long * krnstack = (long *)(PAGE_SIZE+(long)p); /* 设置内核栈顶 */
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;
*(--krnstack) = ds & 0xffff;
*(--krnstack) = es & 0xffff;
*(--krnstack) = fs & 0xffff;
*(--krnstack) = gs & 0xffff;
*(--krnstack) = esi;
*(--krnstack) = edi;
*(--krnstack) = edx;
*(--krnstack) = (long) first_return_kernel;
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0; /* eax(默认为"0") */
p->kernelstack = krnstack;
......
return last_pid;
}

为了理解内核栈的排列,我们可以先看基于内核栈的 switch_to 函数:

1
extern void switch_to(struct task_struct *pnext, unsigned long ldt);
  • 该函数需要两个参数:切换进程的 task_struct 结构体和 ldt 编号

具体的实现如下:

1
struct tss_struct *tss = &(init_task.task.tss);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
.align 2
switch_to:
pushl %ebp /* 构建栈帧,保存eax,ebx,ecx寄存器(存入当前进程的内核栈) */
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax

movl 8(%ebp),%ebx
cmpl %ebx,current /* 对比task[n](切换进程)是否为当前进程 */
je 1f

movl %ebx,%eax
xchgl %eax,current /* 交换current和task[n]的指针 */

movl tss,%ecx /* tss永远指向内核进程的tss_struct(即tss0) */
addl $4096,%ebx /* 此时ebx指向切换进程的栈顶 */
movl %ebx,4(%ecx) /* 将task[n]的栈顶存入tss0->esp0(tss0+4) */

movl %esp,KERNEL_STACK(%eax) /* 将esp存入当前进程的task_struct->kernelstack */
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp /* 设置esp为task[n]的task_struct->kernelstack */

/* 从此刻开始已经进入task[n]的内核栈 */
movl 12(%ebp),%ecx
lldt %cx /* 修改LDTR寄存器 */

movl $0x17,%ecx /* 通过fs寄存器,操作系统才能访问进程的用户态内存 */
mov %cx,%fs /* 这里LDT切换完成意味着切换到了新的用户态地址空间,所以需要重置fs */
cmpl %eax,last_task_used_math
jne 1f
clts

1: popl %eax /* 弹出切换进程的内核栈数据 */
popl %ebx
popl %ecx
popl %ebp
ret /* 执行first_return_kernel函数 */
  • switch_to 编写完成以后,需要将原来的 switch_to 注释掉

这里有一点需要注意:

  • 即使我们不使用 TSS 机制切换进程但也要设置 TSS0
  • 中断处理时需要寻找当前进程的内核栈,否则就不能从用户栈切到内核栈,内核栈的寻找是借助当前进程 TSS 中存放的信息来完成的(当前进程的 TSS 还是通过 TR 寄存器在 GDT 全局描述符表中找到的)
  • 虽然此时不使用 TSS 进行进程切换,但是 Intel 的中断处理机制还是要保持,所以每个进程仍然需要一个 TSS,操作系统需要有一个当前 TSS,这里采用的方案是让所有进程共用一个 TSS0

函数 first_return_kernel 负责完成剩下寄存器的切换,以及程序的跳转:

1
2
3
4
5
6
7
8
9
10
.align 2
first_return_kernel:
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret /* 从中断中返回,并设置cs:eip,eflags,ss:esp */
  • 指令 iret 执行以后,进程将恢复到用户态进程

最后修改一下 schedule 中关于 switch_to 的调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
while (1) {
......
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i ,pnext = *p;
}
......
}
// switch_to(next);
switch_to(pnext,_LDT(next));