第十一章 用户进程
本文是对《操作系统真象还原》第十一章学习的笔记,欢迎大家一起交流,目前所有代码已托管至 fdx-xdf/MyTinyOS 。
TSS
TSS 介绍
TSS:是用于存储任务状态的一个数据结构,每个任务都有自己的 TSS。这个数据结构包含了在任务切换时需要保存和恢复的信息,例如处理器寄存器的值、堆栈指针、页目录基地址寄存器的值等,它放在内存的一块连续区域中。TSS 是硬件级别任务切换机制的一部分。
Intel 的建议是给每个任务“关联”一个任务状态段,这就是 TSS (Task State Segment),用它来表示任务。之所以称为“关联”,是因为 TSS 是由程序员“提供”的,由 CPU 来“维护”。"提供”就是指 TSS 是程序员为任务单独定义的一个结构体变量,“维护”是指 CPU 自动用此结构体变量保存任务的状态(任务的上下文环境,寄存器组的值)和自动从此结构体变量中载入任务的状态。当加载新任务时, CPU 自动把当前任务( 旧任务)的状态存入当前任务的 TSS ,然后将新任务 TSS 中的数据载入到对应的寄存器中,这就实现了任务切换。 TSS 就是任务的代表, CPU 用不同的 TSS 区分不同的任务,因此任务切换的本质就是 TSS 的换来换去。
在 CPU 中有一个专门存储 TSS 信息的寄存器,这就是 TR 寄存器,它始终指向当前正在运行的任务,因此,“在 CPU 眼里”,任务切换的实质就是 TR 寄存器指向不同的 TSS。
TSS 和其他段一样,本质上是一片存储数据的内存区域, Intel 打算用这片内存区域保存任务的最新状态(也就是任务运行时占用的寄存器组等),因此它也像其他段那样,需要用某个描述符结构来“描述”它,这就是 TSS 描述符, TSS 描述符也要在 GDT 中注册,这样才能“找到它“。TSS 描述符格式如下:
TSS 同其他普通段一样,是位于内存中的区域,因此可以把 TSS 理解为 TSS 段,只不过 TSS 中的数据井不像其他普通段那样散乱, TSS 中的数据是按照固定格式来存储的,所以 TSS 是个数据结构。
可以看到 TSS 里面一共有三个栈,但是我们只会用的 0 级栈,当我们的用户进程进行中断处理时 CPU 就会自动取出来 SS0 和 ESP0 字段的值,作为特权级 0 的栈,然后将中断处理中保存的信息放到 0 级栈中。
一个总结图:
TSS 用法
TSS 是 CPU 原生支持的数据结构,因此 CPU 能够直接、正确识别其中的所有宇段。当任务被换下 CPU 时, CPU 会自动将当前寄存器中的值存储到 TSS 中的对应位置,当有新任务上 CPU 运行时, CPU 会自动从新任务的 TSS 中找到相应的寄存器值加载到对应的寄存器中。
现代 x86 体系上的操作系统并没有采用 intel 设计 CPU 时想的那种任务切换方式(见书 P494、P495),因为其开销过大而导致效率过低,而是采用的一种基于 TSS 机制(因为这是硬件提供的,绕不开)的缩减版任务切换方式,在这种情况下,TSS 主要被用于存储每个处理器的内核栈地址,以支持从用户模式到内核模式的切换,以下是关于利用 TSS 实现任务切换的一些要点。
1、当一个中断发生在用户态(特权级 3),处理器将从当前任务的 TSS 中获取 SS0 和 ESP0 字段的值。
2、每个 CPU 中只创建一个 TSS,在各个 CPU 上执行的所有任务都共享一个 TSS。
3、在 TR 加载 TSS 后,该 TR 寄存器将永远指向那一个 TSS,之后再也不会重新加载 TSS。
4、在进程切换时,只需要把 TSS 中的 SS0 和 ESP0 更新为新任务的内核栈的段地址以及栈指针。
5、Linux 对 TSS 的操作是一次性加载 TSS 到 TR,之后不断修改同一个 TSS 的内容,不再重复加载。
6、Linux 中任务切换不使用 call 和 jmp 指令,避免了任务切换的低效。
任务的状态信息存储位置: 当用户态触发中断后,由特权级 3 陷入特权级 0 后,CPU 自动从当前任务的 TSS 中获取 SS0 和 ESP0 字段的值,作为特权级 0 的栈,然后手动执行一系列 push 指令将任务的状态保存在特权级 0 的栈中
TSS 作为绕不开的硬件机制,所以我们必须要先进入这种机制。也就是必须要 GDT 表中为其创建一个 TSS 段描述符,然后用加载选择子进入 TR 寄存器。当我们进行进程切换的时候不再是加载不同的 TSS,而是一直加载同一个 TSS,只是对 TSS 的 esp0 和 ssp0 进行更改。
TSS 代码部分
首先先在 global.h 中定义一些用到的属性和结构
//--------------- TSS描述符属性 ------------
#define TSS_DESC_D 0 //这个D/B位在其他段描述中用于表示操作数的大小,但这里不是,实际上它根本就没有被使用(总是设置为0)。//这是因为TSS的大小和结构并不依赖于处理器运行在16位模式还是32位模式。//无论何时,TSS都包含了32位的寄存器值、32位的线性地址等等,因此没有必要用D/B位来表示操作的大小#define TSS_ATTR_HIGH ((DESC_G_4K << 7) + (TSS_DESC_D << 6) + (DESC_L << 5) + (DESC_AVL << 4) + 0x0) //TSS段描述符高32位高字
#define TSS_ATTR_LOW ((DESC_P << 7) + (DESC_DPL_0 << 5) + (DESC_S_SYS << 4) + DESC_TYPE_TSS) //TSS段描述符高32位低字
#define SELECTOR_TSS ((4 << 3) + (TI_GDT << 2 ) + RPL0)struct gdt_desc {uint16_t limit_low_word;uint16_t base_low_word;uint8_t base_mid_byte;uint8_t attr_low_byte;uint8_t limit_high_attr_high;uint8_t base_high_byte;
}; #define PG_SIZE 4096////定义模块化的中断门描述符attr字段,attr字段指的是中断门描述符高字第8到16bit
#define IDT_DESC_P 1
#define IDT_DESC_DPL0 0
#define IDT_DESC_DPL3 3
#define IDT_DESC_32_TYPE 0xE // 32位的门
#define IDT_DESC_16_TYPE 0x6 // 16位的门,不用,定义它只为和32位门区分#define IDT_DESC_ATTR_DPL0 ((IDT_DESC_P << 7) + (IDT_DESC_DPL0 << 5) + IDT_DESC_32_TYPE) //DPL为0的中断门描述符attr字段
#define IDT_DESC_ATTR_DPL3 ((IDT_DESC_P << 7) + (IDT_DESC_DPL3 << 5) + IDT_DESC_32_TYPE) //DPL为3的中断门描述符attr字段#define NULL ((void*)0)
#define bool int
#define true 1
#define false 0
然后在 userprog/tss.h
中声明导出函数
#ifndef __USERPROG_TSS_H
#define __USERPROG_TSS_H
#include "thread.h"
void update_tss_esp(struct task_struct* pthread);
void tss_init(void);
#endif
然后就到了真正初始化 TSS 的部分 userprog/tss.c
首先定义 TSS 的结构体,可以和上面的图对比一下,是一一对应的,然后声明一个全局的 tss 变量,每次进程切换的时候就是对该变量进行操作
然后 update_tss_esp 函数,就是每次进程切换的时候要切换 0 级栈,用户进程 pcb 所在物理页的最后就是中断栈,所以 tss.esp0 = (uint32_t *)((uint32_t)pthread + PG_SIZE)
,后面会细说。
然后就是 make_gdt_desc 函数,用于创建 gdt 描述符,按照上面所说的格式进行拼凑
然后就是 tss_init,除了初始化 tss 还初始化了供用户进程使用的数据段和代码段,我们先将 tss 的 tss 清 0,然后对其赋值
- 65 行对 ss0 进行赋值,因为内核栈段之前就定义过了,所以直接拿过来就行,当然这里填的是选择子。
- 66 行设置 io 位图,看注释即可
- 72-76 行定义三个段描述,由于第 0 个描述符无法用,内核用了内核代码段、内核数据和栈段、显存段,所以 tss 要放到第四个位置,一个描述符 8 字节,所以 tss 的位置就是
0xc0000900+8*4=0xc0000920
,后面两个段直接顺序展开,其中 tss 的 0 级的,后面两个是 3 级的 - 然后重新计算 gdt 界限,然后重新加载 gdt 和 tss
#include "tss.h"
#include "stdint.h"
#include "global.h"
#include "string.h"
#include "print.h"// 定义tss的数据结构,在内存中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;uint16_t trace;uint16_t io_base;
};static struct tss tss;// 用于更新TSS中的esp0的值,让它指向线程/进程的0级栈
void update_tss_esp(struct task_struct *pthread)
{tss.esp0 = (uint32_t *)((uint32_t)pthread + PG_SIZE);
}//用于创建gdt描述符,传入参数1,段基址,传入参数2,段界限;参数3,属性低字节,参数4,属性高字节(要把低四位置0,高4位才是属性)
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;
}/* 在gdt中创建tss并重新加载gdt */
void tss_init() {put_str("tss_init start\n");uint16_t tss_size = (uint16_t)sizeof(tss);memset(&tss, 0, tss_size);tss.ss0 = SELECTOR_K_STACK;tss.io_base = tss_size; //io_base 字段的值大于或等于 TSS 的大小,那么这意味着 用于表示I/O 位图的数组超出了 TSS 的界限,//或者说,TSS 结构实际上并没有包含 I/O 位图。在这种情况下,处理器就会假定该任务可以访问所有 I/O 端口/* gdt段基址为0x900,把tss放到第4个位置,也就是0x900+0x20的位置 *///在gdt表中添加tss段描述符,在本系统的,GDT表的起始位置为0x00000900,那么tss的段描述就应该在0x920(0x900+十进制4*8)*((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");
}
将初始化函数加入到 init_all
中,编译看效果,可以看到新加入的描述符已经显示
实现用户进程
用户进程创建
进程与内核线程最大的区别是进程有单独的 4GB 空间,当然这指的是虚拟地址。
- 因此,我们需要单独为每个进程维护一个虚拟地址池
- 此外,为了维护每个进程的虚拟内存池与用户物理内存池的映射关系,我们还需要为每个进程创建页目录表
- 最后,每个进程的特权级是 3,而此前我们一直在 0 特权级下工作,因此我们还需要完成从特权级 0 到特权级 3 的转换
我们在实现线程的时候用了线程启动器,这里我们可以在线程启动器里面去嵌套执行进程启动器,去完成进程的初始化工作,后面有流程图
管理自己虚拟地址空间的地址池
因为每个进程都有自己的 4GB,每个进程肯定要有个内存池结构体来管理这个虚拟地址空间,所以修改 thread/thread.h 中的 task_struct 结构体,增加虚拟内存池结构体,来管理自己的虚拟地址空间
struct task_struct {uint32_t* self_kstack; // 用于存储线程的栈顶位置,栈顶放着线程要用到的运行信息enum task_status status;uint8_t priority; // 线程优先级char name[16]; //用于存储自己的线程的名字uint8_t ticks; //线程允许上处理器运行还剩下的滴答值,因为priority不能改变,所以要在其之外另行定义一个值来倒计时uint32_t elapsed_ticks; //此任务自上cpu运行后至今占用了多少cpu嘀嗒数, 也就是此任务执行了多久*/struct list_elem general_tag; //general_tag的作用是用于线程在一般的队列(如就绪队列或者等待队列)中的结点struct list_elem all_list_tag; //all_list_tag的作用是用于线程队列thread_all_list(这个队列用于管理所有线程)中的结点uint32_t* pgdir; // 进程自己页表的虚拟地址struct virtual_addr userprog_vaddr; // 用户进程的虚拟内存池uint32_t stack_magic; //如果线程的栈无限生长,总会覆盖地pcb的信息,那么需要定义个边界数来检测是否栈已经到了PCB的边界
};
上述结构体补充的 userprog_vaddr
即用户进程自己的虚拟内存池。所以我们在初始化的时候也要对其进行初始化,如下:
/* 创建用户进程虚拟地址位图 */
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);
}
其中 #define USER_VADDR_START 0x8048000
,linux 下大部分可执行程序的入口地址(虚拟)都是这个附近,我们也仿照这个设定,然后用户进程最高可用地址 0xc0000000
,然后再计算占多少物理页,然后去申请,对用户进程虚拟地址位图进行初始化。
计算物理页需要向上取整,其中对应的宏定义为 #define DIV_ROUND_UP(X, STEP) ((X + STEP - 1) / (STEP)) //用于向上取整的宏,如9/10=1
独立的页表
有独立的 4GB 所以肯定要有独立的页表进行管理,所以 create_page_dir
函数就是用来创建页目录表的
- 首先 6-10 行:页表不能被用户直接访问,所以要在内核中申请
- 14 行:由于高 1GB 是内核空间,是共享的,所以高 1GB 的页目录表项直接从内核页目录表复制过来
- 18-20 行则是页目录的最后一项存放页目录表的起始物理地址
/* 创建页目录表,将当前页表的表示内核空间的pde复制,* 成功则返回页目录的虚拟地址,否则返回-1 */
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;
}
其中有个辅助函数 addr_v2p 定义在 memory.c 中,由于从虚拟地址得到物理地址。
/* 得到虚拟地址映射到的物理地址 */
uint32_t addr_v2p(uint32_t vaddr)
{uint32_t *pte = pte_ptr(vaddr);/* (*pte)的值是页表所在的物理页框地址,* 去掉其低12位的页表项属性+虚拟地址vaddr的低12位 */return ((*pte & 0xfffff000) + (vaddr & 0x00000fff));
}
从特权级 0 进入特权级 3
特权级从 0 到 3 的途径之一是中断返回,从中断返回要用到 iretd 指令,iretd 指令的主要工作流程为
- 用栈中的数据作为返回地址
- 加载栈中 eflags 的值到 efags 寄存器
- 如果栈中 cs.rpl 若为更低的特权级,处理器的特权级检查通过后,会将栈中 cs 载入到 CS 寄存器,栈中 ss 载入 SS 寄存器,随后处理器进入低特权级。
其中退出中断的出口是汇编语言函数 intr_exit
,这是我们定义在 kernel.s
中的,此函数用来恢复中断发生时、被中断的任务的上下文状态,并且退出中断。
布局中断栈的函数如下:
// 用于初始化进入进程所需要的中断栈中的信息,传入参数是实际要运行的函数地址(进程),这个函数是用线程启动器进入的(kernel_thread)
// 即构建用户进程初始上下文信息
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(gs我们一般用于访问显存段,这个让内核态来访问)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); //设置用户态下的eflages的相关字段//下面这一句是在初始化中断栈中的栈顶位置,我们先为虚拟地址0xc0000000 - 0x1000申请了个物理页,然后将虚拟地址+4096置为栈顶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");
}
最后跳转的 intr_exit
如下:
intr_exit: ; 以下是恢复上下文环境add esp, 4 ; 跳过中断号popadpop gspop fspop espop dsadd esp, 4 ;对于会压入错误码的中断会抛弃错误码(这个错误码是执行中断处理函数之前CPU自动压入的),对于不会压入错误码的中断,就会抛弃上面push的0 iretd ; 从中断返回,32位下iret等同指令iretd
intr_stack 的结构如下:
/*********** 中断栈intr_stack ************ 此结构用于中断发生时保护程序(线程或进程)的上下文环境:* 进程或线程被外部中断或软中断打断时,会按照此结构压入上下文* 寄存器, intr_exit中的出栈操作是此结构的逆操作* 此栈在线程自己的内核栈中位置固定,所在页的最顶端********************************************/
struct intr_stack {uint32_t vec_no; // kernel.S 宏VECTOR中push %1压入的中断号uint32_t edi;uint32_t esi;uint32_t ebp;uint32_t esp_dummy; // 虽然pushad把esp也压入,但esp是不断变化的,所以会被popad忽略uint32_t ebx;uint32_t edx;uint32_t ecx;uint32_t eax;uint32_t gs;uint32_t fs;uint32_t es;uint32_t ds;/* 以下由cpu从低特权级进入高特权级时压入 */uint32_t err_code; // err_code会被压入在eip之后void (*eip) (void);uint32_t cs;uint32_t eflags;void* esp;uint32_t ss;
};
所以当我们的函数跳转之后,此时 esp 执行中断栈,然后就会不断 pop,edi 到 ds 都被赋成我们初始化值,然后执行 iretd,cpu 执行返回至 eip 所执行的地方执行函数,然后加载 eflags,esp,ss,至此,完成 r0 到 r3 的转变
还有一个辅助函数 get_a_page,由于给指定的虚拟内存申请物理内存
/* 将地址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;
}
总结
创建用户进程的总函数如下:
/* 创建用户进程 */
void process_execute(void* filename, char* name) { /* pcb内核的数据结构,由内核来维护进程信息,因此要在内核内存池中申请 */struct 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);
}
注意,我们在 thread_create
中传递的执行函数是 start_process
,这个函数会帮助我们继续完成进程初始化的一些任务,然后再去执行进程对应的函数,总的流程图如下:
用户进程执行
现在还没有完成所有的工作,上述工作仅仅是初始化或者叫创建一个进程。当进程由由于主进程的时间片到期而调度上机时。A、需要切换到进程自己的页表,这个还没有做到。B、中断退出进入进程执行,但是当进程执行过程中,由于时钟中断发生,需要从 TSS 中取出进程 0 级的 ss 与 esp,才能顺利切换到内核栈中,所以,我们需要修改 schedule 函数,将进程的内核栈的 esp0 保存到 TSS 中。
对应的函数如下:
/* 激活页表 */
void page_dir_activate(struct 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);}asm volatile ("movl %0, %%cr3" : : "r" (pagedir_phy_addr) : "memory"); //更新页目录寄存器cr3,使新页表生效
}//用于加载进程自己的页目录表,同时更新进程自己的0特权级esp0到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)update_tss_esp(p_thread); /* 更新该进程的esp0,用于此进程被中断时保留上下文 */
}
我们在切换页表的时候需要先判断是进程还是内核线程,内核线程的页目录物理地址是 0x100000,用户进程的则是 pcb 里面对应的,然后将其装载到 cr3 即可
然后还有更新 esp0,这个函数我们在 tss.c 实现过了,最后在总结部分再详细说一下
最后将 process_activate 加入到 schedule
函数中即可。
执行图如下:
结果
main.c 修改如下:
#include "print.h"
#include "init.h"
#include "thread.h"
#include "interrupt.h"
#include "console.h"
#include "process.h"void k_thread_a(void*);
void k_thread_b(void*);
void u_prog_a(void);
void u_prog_b(void);
int test_var_a = 0, test_var_b = 0;int main(void) {put_str("I am kernel\n");init_all();thread_start("k_thread_a", 31, k_thread_a, "argA ");thread_start("k_thread_b", 31, k_thread_b, "argB ");process_execute(u_prog_a, "user_prog_a");process_execute(u_prog_b, "user_prog_b");intr_enable();while(1);return 0;
}/* 在线程中运行的函数 */
void k_thread_a(void* arg) { char* para = arg;while(1) {console_put_str(" v_a:0x");console_put_int(test_var_a);}
}/* 在线程中运行的函数 */
void k_thread_b(void* arg) { char* para = arg;while(1) {console_put_str(" v_b:0x");console_put_int(test_var_b);}
}/* 测试用户进程 */
void u_prog_a(void) {while(1) {test_var_a++;}
}/* 测试用户进程 */
void u_prog_b(void) {while(1) {test_var_b++;}
}
编译运行,不断打印:
总结
这节中我们实现了用户进程,然后我们来整体把握一下用户进程创建、执行、调度的过程。先献上流程图:
如同内核线程一样,进程需要有一个自己的 task_struct 结构体(内核空间中),这个结构体中存着进程自己的管理信息,相比于内核线程,进程的 task_struct 中多出了至关重要的虚拟内存池结构体用于管理进程自己的虚拟地址空间(这个结构体与它的位图都在内核空间中),以及记录自己的页目录表位置的变量(创建的页目录表与页表均放在内核空间中),这个就体现了为什么进程有自己独立的虚拟地址空间。
第一次执行的过程中,switch_to 的 ret 进入到 kernel,然后 kernel 中执行 start_process 函数准备进程的中断栈中的内容,通过 iret 去真正进入进程要执行的函数(作为对比,内核线程是 switch_to 中的 ret 进入线程启动器直接执行函数,相当于进程在线程的基础上多了 iret),所以我们只要在中断栈中准备好 iret 返回的信息就行了,中断栈里面的段寄存器选择子字段全是 DPL = 3,所以 iret 之后,就进入了用户态。而且中断栈中要设定好用户栈的栈顶位置(这个栈空间就要在用户空间中)。
进程调度的过程:当切换到一个用户进程时,会先在 schedule
中在执行 process_activate
函数,该函数会更新页表和 tss.esp0,然后走上面说过的 ret 和 iret 的流程,执行用户程序,当该用户程序遇到时钟中断的时候,CPU 会自动从 TSS 中取出 ss0 与 esp0,然后将进程在用户态运行的信息保存在取出的 ss0:esp0 指向的内核栈中(在中断处理函数的汇编入口处完成)。保存完之后,内核栈的 esp 指向 pthread + PG_SIZE
,然后继续指向 switch_to 函数,继续保存一些东西,这些东西会保存到 thread_stack
中。
返回的时候先回将 thread_stack
里面的东西 pop 出来,然后不断 leave,ret,直到返回至 intr_exit
,然后中断返回,此时 esp 也刚好指向中断栈,然后恢复继续执行。
这里截取了返回到intr_exit
的栈,可以看到第一个变量就是时钟中断中断号,后面就是中断栈
然后下面就是iret返回指向的样子,可以自己对应一下