八、用户进程:TSS、用户进程的创建、用户进程的执行(通过调度函数)

来源:互联网 发布:python 安卓 编辑:程序博客网 时间:2024/05/16 19:51

TSS

起初Intel的建议是给每个任务都关联一个任务状态段(TSS)。在CPU中有一个TR寄存器,始终指向当前正在运行的任务,因此在CPU看来,任务切换的实质就是TR寄存器指向不同的TSS。但是Linux中,所有的任务共享一个TSS。TSS和其他段一样,本质上也是一片储存数据的区域,CPU用这片区域保持任务的最新状态。因此,TSS段就要像其他段一样,需要用个描述符来描述TSS段。因此也要在GDT中注册好。注意的是,TSS是属于系统段描述符,因此 S 为0。TSS描述符用来描述TSS段,TSS段在内存中,它的元素按照固定格式来排列。
TSS段里面基本上全部都是寄存器的名字,这些寄存器就是任务运行中的最新状态。TSS中有三组栈,Linux只使用了SS0和esp0。当任务被换下CPU时候,CPU自动的将当前任务的资源状态保存到该任务对应的TSS中,CPU通过新任务的TSS选择子加载新任务时候,会将新任务的TSS中的数据加到寄存器中,同时更新TR,使其指向新任务的TSS,当然我们的Linux不需要这样,因为只使用一个TSS。TSS段也需要选择子来访问的。TR寄存器保存的是TSS的选择子,任务改变就更新TR寄存器中的选择子可以指向另一个TSS了。
注意分清:TSS只是一个内存段的名字而已,它和数据段,代码段一样的,都需要一个描述符来描述这个段的属性和位置。GDT中储存着这些描述符,GDTR储存着段描述符表的位置,TSS的描述符只是GDT中的一个描述符而已。TR中储存的是TSS的选择子。任务切换就是改变TR的指向。

现代操作系统采用的任务切换

CPU由低特权级向更高特权级转移的一种情况是用户模式下面发生中断,这会发生堆栈的切换。Linux中只用到一个TSS,所以我们必须提前创建一个TSS,并且至少初始化TSS中的 ss0 和 esp0。因此我们使用TSS的唯一理由就是为0特权级的任务提供栈。当用户模式下发生中断时候,CPU会自动的从TSS 中取出 0 级栈,然后一系列 push 指令。

实现用户进程

进程与内核线程最大的区别就是进程有单独的4GB空间。每一个进程都有4GB的空间,也就是说每个进程都有一个页表。进程是基于线程实现的,它与线程一样使用相同的pcb结构,即 struct task_struct。我们在进程的PCB结构中加入两个成员:
uint32_t* pgdir;//进程自己页表的虚拟地址struct virtual_addr userprog_vaddr;//用户进程的虚拟地址池
页表使用虚拟地址是因为页目录表本身也要占用内存来存储,我们在为进程创建页目录表,肯定要为页目录表申请内存,内存管理系统返回的地址肯定都是虚拟地址,不可能返回物理地址。

为进程创建页表和3特权级栈

不同的进程有各自不同的页表,因为我们创建一个进程就要为进程单独创建一个属于它的页目录表+页表。另外还要为用户进程创建在3特权级下面的栈。
//功能:在虚拟地址池中申请 n 页的页表,并返回起始地址
//需要注意到是:内核的虚拟地址池是全局变量,但是用户进程的虚拟地址池是在用户进程PCB中的,即一个用户进程有个属于它自己的虚拟地址池。
static void* vaddr_get(enum pool_flags pf, uint32_t pg_cnt) {   int vaddr_start = 0, bit_idx_start = -1;   uint32_t cnt = 0;   if (pf == PF_KERNEL) {     // 内核内存池      bit_idx_start  = bitmap_scan(&kernel_vaddr.vaddr_bitmap, pg_cnt);      if (bit_idx_start == -1) { return NULL;      }      while(cnt < pg_cnt) { bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 1);      }      vaddr_start = kernel_vaddr.vaddr_start + bit_idx_start * PG_SIZE;   } else {     // 用户内存池      struct 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;   /* (0xc0000000 - PG_SIZE)做为用户3级栈已经在start_process被分配 */      ASSERT((uint32_t)vaddr_start < (0xc0000000 - PG_SIZE));   }   return (void*)vaddr_start;}

//功能:将地址 vaddr 与 pf 池中的物理地址管理
//注意:以往我们申请在一页内存返回虚拟地址时候,都是系统自己帮我们申请的,这个我们是自己定义一个虚拟地址,然后完成映射。
void* get_a_page(enum pool_flags pf, uint32_t vaddr) {   struct pool* mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool;   lock_acquire(&mem_pool->lock);   /* 先将虚拟地址对应的位图置1 */   struct task_struct* cur = running_thread();   int32_t bit_idx = -1;/* 若当前是用户进程申请用户内存,就修改用户进程自己的虚拟地址位图 */   if (cur->pgdir != NULL && pf == PF_USER) {      bit_idx = (vaddr - cur->userprog_vaddr.vaddr_start) / PG_SIZE;      ASSERT(bit_idx > 0);      bitmap_set(&cur->userprog_vaddr.vaddr_bitmap, bit_idx, 1);   } else if (cur->pgdir == NULL && pf == PF_KERNEL){/* 如果是内核线程申请内核内存,就修改kernel_vaddr. */      bit_idx = (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;      ASSERT(bit_idx > 0);      bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx, 1);   } else {      PANIC("get_a_page:not allow kernel alloc userspace or user alloc kernelspace by get_a_page");   }   void* page_phyaddr = palloc(mem_pool);   if (page_phyaddr == NULL) {      return NULL;   }   page_table_add((void*)vaddr, page_phyaddr);    lock_release(&mem_pool->lock);   return (void*)vaddr;}

如何从特权级0进入特权级3

刚开始我们的系统一直运行在kernel中,特权级一直是0,所以我们要从0特权级进入3特权级。我们的中断从3特权级进入0特权级,然后是通过 iret 返回,所以我们这里也假装通过 iret 返回,所以要经过 intr_exit,即一大堆的 pop 指令,将3特权级的寄存器资源弹出去。从中断返回时候,要弹出之前的中断前的寄存器资源,所以我们要进入3特权级,要提前在 intr_exit中设置好3特权级的资源。CPU通过中断栈中CS的RPL知道了将会返回哪个特权级,所以RPL必须为3。

1,假装从中断返回,用过 iret 指令,就要经过 intr_exit 的一系列 pop

2,要提前在中断栈中设置好3特权级的环境:cs和eip、ss 和 esp 等,以便借着pop可以弹出 

3,中断栈中CS中的RPL 必须为3

4,相应的在用户模式下,只能访问特权级为3的代码段和数据段,因此中断栈中的相应的段寄存器中的选择子指向的段描述符的DPL也要为3

5,在进入中断时候,eflags 的 IF 位 为0,不能响应中断,在退出中断后要置1,因此中断栈中的IF要置1

6,中断栈中的 eflags 的IOPL 位为1 ,不允许用户进程直接访问硬件。

用户进程创建流程

进程首先要先创建好,然后才能运行。进程创建由函数 process_execute()来完成:包括创建进程使用的PCB(一页),然后完善这个PCB:初始化好struck task_stack:虚拟地址池和页表、创建好 thread_stack:注意是进程的函数了、创建好 intr_stack:进程的上下文环境、然后将进程加入全部队列和就绪队列

图中的 init_thread() 是创建 task_stack 的,thread_create() 是创建 thread_stack的:首次运行是调用 kernek_thread(start_process,user_prog),然后调用 start_process(user_prog)的。

创建好后开始执行,通过 时钟中断后时间片到了然后调用 schedule()来进行调度就行进程队列,pop 出用户进程开始执行的。首先激活页表,创建时候将页表的内容都已经写好, 但是并没有给CR3赋值,所以。在shedule()中要将页表激活,就是给CR3赋值。然后通过 ret 弹到 kernel_thread(start_process,user_porg)-->start_process(user_prog)。start_process(user_prog)函数功能:用来构建用户进程的上下文,也就是在 intr_stack 附上合适的值,然后调用 intr_eixt,pop 出一系列值,进入到用户进程中CPU开始执行。

注意:可以看出1,进程是在线程的基础上执行的,很多函数都是通用的。2,线程在执行到 kernel_thread( function,arg)--》function(arg),这个函数功能就是直接执行进程代码了。但是进程到这一步后的函数功能是:先初始化 intr_stack,然后调用 intr_exit,pop 出一系列的值,然后执行进程的代码。


c程序的内存分布

用户程序内存空间的最顶端是用来储存命令行参数及环境变量,这些内容是用C运行库写进去的。紧接着是栈空间和堆空间,栈与堆在空间上是相接的。然后下面是未初始化数据段bss, 初始化数据段 data 及代码段 text,这些段由链接器和编译器负责。
4GB的虚拟地址空间中,(0xc000_0000-1)是用户空间的最高地址,0xc000_0000-0xffff_ffff是内核空间。我们也要效仿这种结构,把用户空间的最高处即 0xc000_0000作为栈底地址,(其实命令行参数也是被压入栈中的)。因此我们在物理池中为栈申请的地址应该是 0xc000_0000-0x1000,此地址是用户栈空间栈顶的下边界,然后加上 4K,得到栈底地址,赋值给 esp。这里都是在用户页表中进行的地址申请的。
每个进程都拥有独立的虚拟地址空间,本质上就是每个进程都有单独的页表,页表地址是储存在页表寄存器 CR3 中的,CR3寄存器只有1个,因此不同的进程在执行前,都要在CR3寄存器中为其换上与之配套的页表。从而实现了虚拟地址空间的隔离。内核线程都是共同使用的一套页表,即内核页表 0x10_0000。
当用户进程的内存空间布局是参照着Linux下C程序的布局方案来做,堆的起始地址是固定的。C程序分为预处理、编译、汇编和链接四个阶段,预处理器将代码中的预处理命令(宏定义等)展开。编译器将代码编译成中间语言,即汇编语言,汇编语言中生成关键字section,如 section .text vstart=0,将代码划分为了不同的节,但是这里的关键字的作用只是将程序划分开而已,起什么名字无所谓,也可以说section my_code vstart=0 ,这些都是伪代码,是给汇编器看的。汇编器翻译时会根据下面指令代码:如 start:、jmp 来进行翻译,翻译成不同属性的节。注意汇编语言中的关键字section和链接时的section是不一样的,汇编语言中只是关键字而已,用来划分程序区域,这些名字也是随意起的,但是链接时的section就具有了属性了,比如汇编语言中的 .data 定义的是字符串,会被翻译成 .radata节。汇编器根据每个汇编文件中的关键字section翻译成机器代码,将关键字section翻译成链接时候的secti,它们此时都具有了各自的属性,但是并不被汇编器聚合在一起,这时生成的是可重定位的目标程序。链接器将几个可重定位的目标代码中的相同属性的section放在一起组成segment,生成可执行的目标程序。我们常说的代码段和数据段就是合并后的segment。
在操作系统的角度来看,它不关心节的多少和名称、只关心程序中的节或者段的属性是什么,以便在加载程序时候为其分配不同的段选择子,从而使程序中的内存指向不同的段描述符,起到保护内存的作用。按照属性来划分节,大致上分三种:
1,可读写的数据:如数据节 .data 和未初始化节.bss
2,只读可执行的代码:如代码节 .text 和初始化代码节 .init
3,只读数据:如只读数据节 .rodata,一般情况下字符串就是储存在此节中。
所有的节经过以上的划分都会分为三种之一,这样方便了操作系统加载程序时的内存分配。链接器会把相同属性的节归并形成segment。所以 .data和.bss 在一个segment中, .text 和 .init 在一个segment中,.rodata在一个segment中。
.text 和 .data 段是程序运行必须的,它们都存在与程序文件中,但是 .bss 并不存在于程序文件中,它仅存在于内存中,其实际内容也是在程序运行过程中才产生的。所以我们确定堆地址时候需要指定 .bss 的结束地址,还好的是编译器将.bss 翻译为了数据段,和 .data 在一起,所以我们只需要指定数据段的起始地址和大小就可以推算出来堆地址,即堆的起始地址在用户进程地址最高的段之上就可以了,因为用户进程是被 gcc 翻译的。

实现用户进程

操作系统是为用户进程服务的,它提供了各种系统功能供用户进程调用。为了用户进程可以访问到内核服务,必须确保用户进程在自己的地址空间中能够访问到内核才行。虚拟地址空间由页表来控制,页表由操作系统来管理,所以用户空间的虚拟空间是由操作系统分配的。每个用户都有4GB的虚拟空间,操作系统把4GB的用户空间和内核空间,操作系统占了3G-4G,用户空间是0G-3G。用户进程占据了页目录表的 0-767 个页目录项,内核占据页目录表的 768-1023个目录项。

操作系统在物理内存中的位置是固定的,内核进程也只有一套页表,也是固定的。我们要想内核被所有的用户进程都可以访问到,我们只需要把每个用户进程页目录项用内核页目录表的第768-1023个页目录项代替即可。因为我们的内核页表也是3GB-4GB的,这样当我们的用户进程的3GB-4GB的一个地址来访问时候,就会用虚拟地址的10+10+12来访问物理地址。我们只复制页目录项即可,页表项不需要复制,因为1024个页表在内核的页表地址中已经存在了,用户进程中直接转到内核页表项中去找了。

//功能:创建页表,注意页表项都应该填入物理地址,CR3中应该填入的也是物理地址

//实现:在内核物理池中申请一页,4k/4=1024项,我们将后四分之一复制成内核的。

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  先复制页表 memcpy((uint32_t*)((uint32_t)page_dir_vaddr + 0x300 * 4), (uint32_t*)(0xfffff000 + 0x300 * 4), 1024);// page_dir_vaddr + 0x300*4 是内核页目录的第768项 //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;}

用户进程有自己的堆和栈,因此必须要有方法跟踪内存的分配情况。和内核一样,用户进程也是用位图来管理地址分配的,每个进程有自己单独的位图。用户进程被加载到内存后,剩余未用的高地址都被作为堆和栈的共享空间。虚拟地址池结构包括虚拟起始地址和虚拟地址的位图。我们选择 0xc0804_8000 为用户程序的起始虚拟地址,即0x0804_8000 --0xc000_0000为用户进程的虚拟地址。我们还是用位图的一位表示4K,那么我们的虚拟地址需要的位图页数就要从内核物理池中申请。

//功能:创建用户进程虚拟地址位图

//实现:赋值用户进程虚拟地址的起始地址、赋值用户进程虚拟地址位图的起始地址和位图字节长度。

void create_user_vaddr_bitmap(struct 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);}
//功能:创建用户进程:在内核中申请PCB,然后初始化各个栈,主要是为 task_stack 中的页表和虚拟地址池赋值。

void process_execute(void* filename, char* name) {    /* pcb内核的数据结构,由内核来维护进程信息,因此要在内核内存池中申请 */   struct task_struct* thread = get_kernel_pages(1); //为进程申请一个pcb页。   init_thread(thread, name, default_prio);  //初始化task_struck   create_user_vaddr_bitmap(thread);//为task_struck中的虚拟地址赋值   thread_create(thread, start_process, filename);//初始化thread_stack,即第一次运行时候的函数   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);}

//功能:激活进程:包括激活页表和更新tss中的0特权级栈。激活页表是将页表物理地址赋予CR3。更新tss是将

void process_activate(struct 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);   }}

//功能:创建用户进程上下文。当通过switch()函数开始执行用户进程时候首先要先设置用户上下文环境,然后 iret 出去。

void start_process(void* filename_) {   void* function = filename_;   struct task_struct* cur = running_thread();   cur->self_kstack += sizeof(struct thread_stack);   struct 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");}

用户进程的特征

用户进程的特征为:3特权级、单独的页表和3特权级的栈。无论代码在内核空间还是在用户空间,代码都是一段指令而已,CPU并不能分清这段代码是用户代码还是内核代码。处理器关注的是CPL:当前特权级,即cs中的RPL。