十二. 实现用户进程

TSS

单核CPU想要实现多任务,唯一的方案就是多个任务共享同一个CPU,也就是让CPU在多个任务间轮转。

CPU执行任务时,需要把任务运行所需要的数据加载到寄存器、栈和内存中,因为CPU只能直接处理这些数据,这是CPU在设计上就直接决定的。任务的数据和指令是CPU的处理对象,任务的执行会占用寄存器和内存。

内存相对于CPU来说是低速设备,里面的数据往往被加载到高速寄存器之后被CPU处理,再将结果写到内存中。所以寄存器中的数据就是当前任务的最新状态。采用轮流使用CPU的方式运行多任务,在当前任务被换下CPU的时候,任务的状态应该被保存一份,TSS就是用来关联任务的。

TSS(任务状态段)是由程序员来提供,CPU进行维护。程序员提供是指需要我们定义一个结构体,里面存放任务要用的寄存器数据。CPU维护是指切换任务时,CPU会自动把旧任务的数据存放的结构体变量中,然后将新任务的TSS数据加载到相应的寄存器中

TSS和之前所说的段一样,本质上也是一片存储数据的内存区域,CPU用这块内存区域保存任务的最新状态。所以也需要一个描述符结构来表示它,这个描述符就是TSS描述符,它的结构如下

TSS描述符

这个描述符主要关注于它type字段中的B位,B位为1时表示任务繁忙。

任务繁忙有两方面的意义,一是此任务正在CPU上运行,二是此任务嵌套调用了新的任务,CPU此时正在执行这个新的任务,此任务暂时挂起,等到新任务运行完了之后返回此任务继续执行

这个B位是由CPU自动维护的,任务被调到上CPU的时候,CPU自动将此B位置1,被换下CPU的时候,自动将其置0

前面说的TSS描述符是用来描述TSS的,TSS的结构如下图

TSS结构

TSS结构中的数据就是我们保存任务时需要存储的数据,我们提供的保存TSS的数据结构也要照着这个设计。

Linux中采用的任务切换方式

Linux为了提高任务切换的速度,通过如下方式来进行任务切换

一个CPU上的所有任务共享一个TSS,通过TR寄存器保存这个TSS,在使用ltr指令加载TSS之后,该TR寄存器永远指向同一个TSS,之后在进行任务切换的时候也不会重新加载TSS,只需要把TSS中的SS0和esp0更新为新任务的内核栈的段地址及栈指针。

在当初硬件厂商设计TSS的时候,本意是想让一个任务保存一份TSS,这样在切换任务的时候,重新从内存中加载TSS,让TR寄存器指向该TSS,从而实现任务切换。但是这种方式切换任务,每次都要从内存中加载数据,对于CPU来说,速度太慢了,而且切换的步骤也十分繁琐。

Linux的任务切换方式只需要修改TSS中的SS0和esp0,进行任务切换的速度当然是大幅度提升了。

初始化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
/* 任务状态段tss结构 */
struct tss
{
uint32_t backlink;
uint32_t* esp0;
uint32_t ss0;
uint32_t* esp1;
uint32_t ss1;
uint32_t* esp2;
uint32_t ss2;
uint32_t cr3;
uint32_t (*eip) (void);
uint32_t eflags;
uint32_t eax;
uint32_t ecx;
uint32_t edx;
uint32_t ebx;
uint32_t esp;
uint32_t ebp;
uint32_t esi;
uint32_t edi;
uint32_t es;
uint32_t cs;
uint32_t ss;
uint32_t ds;
uint32_t fs;
uint32_t gs;
uint32_t ldt;
uint32_t trace;
uint32_t io_base;
};

该TSS的结构完全是根据上面图中所需而定义的。

TSS的初始化工作主要是初始化TSS结构的ss0和esp0,然后将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
39
40
41
void update_tss_esp(task_struct* pthread) 
{
tss.esp0 = (uint32_t*)((uint32_t)pthread + PG_SIZE);
}

static struct gdt_desc make_gdt_desc(uint32_t* desc_addr, uint32_t limit, uint8_t attr_low, uint8_t attr_high)
{
uint32_t desc_base = (uint32_t)desc_addr;
struct gdt_desc desc;
desc.limit_low_word = limit & 0x0000ffff;
desc.base_low_word = desc_base & 0x0000ffff;
desc.base_mid_byte = ((desc_base & 0x00ff0000) >> 16);
desc.attr_low_byte = (uint8_t)(attr_low);
desc.limit_high_attr_high = (((limit & 0x000f0000) >> 16) + (uint8_t)(attr_high));
desc.base_high_byte = desc_base >> 24;
return desc;
}

void tss_init()
{
put_str("tss_init start\n");
uint32_t tss_size = sizeof(tss);
memset(&tss, 0, tss_size);
tss.ss0 = SELECTOR_K_STACK;
tss.io_base = tss_size;

/* gdt段基址为0x900,把tss放到第4个位置,也就是0x900+0x20的位置 */

/* 在gdt中添加dpl为0的TSS描述符 */
*((struct gdt_desc*)0xc0000920) = make_gdt_desc((uint32_t*)&tss, tss_size - 1, TSS_ATTR_LOW, TSS_ATTR_HIGH);

/* 在gdt中添加dpl为3的数据段和代码段描述符 */
*((struct gdt_desc*)0xc0000928) = make_gdt_desc((uint32_t*)0, 0xfffff, GDT_CODE_ATTR_LOW_DPL3, GDT_ATTR_HIGH);
*((struct gdt_desc*)0xc0000930) = make_gdt_desc((uint32_t*)0, 0xfffff, GDT_DATA_ATTR_LOW_DPL3, GDT_ATTR_HIGH);

/* gdt 16位的limit 32位的段基址 */
uint64_t gdt_operand = ((8 * 7 - 1) | ((uint64_t)(uint32_t)0xc0000900 << 16)); // 7个描述符大小
asm volatile ("lgdt %0" : : "m" (gdt_operand));
asm volatile ("ltr %w0" : : "r" (SELECTOR_TSS));
put_str("tss_init and ltr done\n");
}

通过上面的步骤TSS描述符就被加载到gdt中了,可以直接在模拟器中观察当前gdt的数据

gdt数据

实现用户进程

实现原理

实现进程的过程是在之前的线程基础上进行的,在当初创建线程的时候是将栈的返回地址指向了kernel_thread函数,通过该函数调用线程函数实现的,其执行流程如下

线程函数执行流程

这里我们只需要把执行线程的函数换成创建进程的函数就可以啦。

进程与线程最大的区别就每个进程都拥有单独的4GB虚拟地址空间,所以,需要单独为每个进程维护一个虚拟地址池,用来标记该进程中哪些地址被分配了,哪些地址没有被分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct tag_task_struct
{
uint32_t *self_kstack; // 内核线程的栈顶地址
task_status status; // 当前线程的状态
char name[16];
uint8_t priority;
uint8_t ticks; // 线程执行的时间

uint32_t elapsed_ticks; // 线程已经执行的时间

struct list_elem general_tag;

struct list_elem all_list_tag;

uint32_t *pgdir; //进程页表的虚拟地址
struct virtual_addr userprog_vaddr; // 用户进程的虚拟地址池

uint32_t stack_magic; // 栈的边界标记,用来检测栈溢出
}task_struct;

该结构是进程线程通用的,是用来管理进程或线程数据的。有些数据是进程特用的,这样在线程使用该结构的时候只需要将这些数据置0即可。在这里将这个结构作为进程的pcb使用。

为用户进程创建页表

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
// 在虚拟内存池中申请pg_cnt个虚拟页
static void *vaddr_get(enum pool_flags pf, uint32_t pg_cnt)
{
int vaddr_start = 0;
int bit_idx_start = -1;
uint32_t cnt = 0;

if(pf == PF_KERNEL)
{
//...内核内存池
}
else
{
// 用户内存池
task_struct *cur = running_thread();
bit_idx_start = bitmap_scan(&cur->userprog_vaddr.vaddr_bitmap, pg_cnt);
if(bit_idx_start == -1)
return NULL;

while (cnt < pg_cnt)
{
bitmap_set(&cur->userprog_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 1);
}

vaddr_start = cur->userprog_vaddr.vaddr_start + bit_idx_start * PG_SIZE;

ASSERT((uint32_t)vaddr_start < (0xc0000000 - PG_SIZE));
}

return (void *)vaddr_start;
}

void *get_user_pages(uint32_t pg_cnt)
{
lock_acquire(&user_pool.lock);
void *vaddr = malloc_page(PF_USER, pg_cnt);
memset(vaddr, 0, pg_cnt * PG_SIZE);
lock_release(&user_pool.lock);
return vaddr;
}

进入3特权级

到目前为止我们一直工作在0级特权级下,这里既然是模仿操作系统的实现,用户进程肯定还是要工作在3特权级下的。这个kernel再简陋,基本的功能还是要有的。

一般情况下,CPU不允许从高特权级转向低特权级,只有从中断返回或者从调用门返回的情况下才可以。

这里采用从中断返回的方式进入3特权级。由于目前还没有用户进程,也就别谈从中断返回了,都没有进入中断何谈从中断返回呢,这里就只能再次欺骗一下CPU,就像之前创建线程一样,制造从中断返回的条件,执行iretd指令了。

iretd指令会用带栈中的数据作为返回地址,还会加载栈中的eflags的值到eflags寄存器,如果栈中的cs.rpl为更低的特权级,处理器的特权级检查通过之后会将栈中的cs载入到CS寄存器。从中断返回的过程就是进入中断的逆过程,所以我们只需要在栈中准备好数据,调用iretd指令即可。

构建用户进程初始上下文信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void start_process(void *filename_)
{
void *function = filename_;
task_struct *cur = running_thread();
cur->self_kstack += sizeof(thread_stack);
intr_stack *proc_stack = (struct intr_stack *)cur->self_kstack;
proc_stack->edi = proc_stack->esi = proc_stack->ebp = proc_stack->esp_dummy = 0;
proc_stack->ebx = proc_stack->edx = proc_stack->ecx = proc_stack->eax = 0;
proc_stack->gs = 0; // 用户态用不上,直接初始为0
proc_stack->ds = proc_stack->es = proc_stack->fs = SELECTOR_U_DATA;
proc_stack->eip = function; // 待执行的用户程序地址
proc_stack->cs = SELECTOR_U_CODE;
proc_stack->eflags = (EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1);
proc_stack->esp = (void *)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) + PG_SIZE);
proc_stack->ss = SELECTOR_U_DATA;
asm volatile("movl %0, %%esp; jmp intr_exit"
:
: "g"(proc_stack)
: "memory");
}

激活页表

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
/* 激活页表 */
void page_dir_activate(task_struct *p_thread)
{
/* 若为内核线程,需要重新填充页表为0x100000 */
uint32_t pagedir_phy_addr = 0x100000; // 默认为内核的页目录物理地址,也就是内核线程所用的页目录表
if (p_thread->pgdir != NULL)
{ // 用户态进程有自己的页目录表
pagedir_phy_addr = addr_v2p((uint32_t)p_thread->pgdir);
}

/* 更新页目录寄存器cr3,使新页表生效 */
asm volatile("movl %0, %%cr3"
:
: "r"(pagedir_phy_addr)
: "memory");
}

/* 激活线程或进程的页表,更新tss中的esp0为进程的特权级0的栈 */
void process_activate(task_struct *p_thread)
{
ASSERT(p_thread != NULL);
/* 击活该进程或线程的页表 */
page_dir_activate(p_thread);

/* 内核线程特权级本身就是0,处理器进入中断时并不会从tss中获取0特权级栈地址,故不需要更新esp0 */
if (p_thread->pgdir)
{
/* 更新该进程的esp0,用于此进程被中断时保留上下文 */
update_tss_esp(p_thread);
}
}

创建用户进程的页目录表

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
uint32_t *create_page_dir(void)
{
/* 用户进程的页表不能让用户直接访问到,所以在内核空间来申请 */
uint32_t *page_dir_vaddr = get_kernel_pages(1);
if (page_dir_vaddr == NULL)
{
console_put_str("create_page_dir: get_kernel_page failed!");
return NULL;
}

/************************** 1 先复制页表 *************************************/
/* page_dir_vaddr + 0x300*4 是内核页目录的第768项 */
memcpy((uint32_t *)((uint32_t)page_dir_vaddr + 0x300 * 4), (uint32_t *)(0xfffff000 + 0x300 * 4), 1024);
/*****************************************************************************/

/************************** 2 更新页目录地址 **********************************/
uint32_t new_page_dir_phy_addr = addr_v2p((uint32_t)page_dir_vaddr);
/* 页目录地址是存入在页目录的最后一项,更新页目录地址为新页目录的物理地址 */
page_dir_vaddr[1023] = new_page_dir_phy_addr | PG_US_U | PG_RW_W | PG_P_1;
/*****************************************************************************/
return page_dir_vaddr;
}

/* 创建用户进程虚拟地址位图 */
void create_user_vaddr_bitmap(task_struct *user_prog)
{
user_prog->userprog_vaddr.vaddr_start = USER_VADDR_START;
uint32_t bitmap_pg_cnt = DIV_ROUND_UP((0xc0000000 - USER_VADDR_START) / PG_SIZE / 8, PG_SIZE);
user_prog->userprog_vaddr.vaddr_bitmap.bits = get_kernel_pages(bitmap_pg_cnt);
user_prog->userprog_vaddr.vaddr_bitmap.btmp_bytes_len = (0xc0000000 - USER_VADDR_START) / PG_SIZE / 8;
bitmap_init(&user_prog->userprog_vaddr.vaddr_bitmap);
}

创建用户进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 创建用户进程 */
void process_execute(void *filename, char *name)
{
/* pcb内核的数据结构,由内核来维护进程信息,因此要在内核内存池中申请 */
task_struct *thread = get_kernel_pages(1);
init_thread(thread, name, default_prio);
create_user_vaddr_bitmap(thread);
thread_create(thread, start_process, filename);
thread->pgdir = create_page_dir();

enum intr_status old_status = intr_disable();
ASSERT(!elem_find(&thread_ready_list, &thread->general_tag));
list_append(&thread_ready_list, &thread->general_tag);

ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag));
list_append(&thread_all_list, &thread->all_list_tag);
intr_set_status(old_status);
}

用户进程一般是由加载器将用户程序加载到内存中,再根据其文件格式解析里面的内容,将程序的段加载到相应的内存地址,CS:EIP指向程序的入口地址,程序便执行起来了。由于目前还没有实现文件系统,只能通过函数来模拟进程的执行,但是产生的效果是一样的。

进程的创建便完成了。由于目前只能模拟一下进程的运行,所以模拟运行的线程函数任然运行在内核空间中,但是进程该有的属性都有了,比如3特权级,自己单独的页表,3特权级栈等。拥有了这些属性就可以称之为一个用户进程

mark

这是运行了进程函数之后cs的值,其低2位的值为11,也就是rpl=3,目前确实运行在3特权级下.