操作系统真象还原 学习笔记12--用户进程

selph
selph
发布于 2021-02-11 / 1445 阅读
0
1

操作系统真象还原 学习笔记12--用户进程

本篇对应书籍第十一章的内容

本篇内容介绍了用户进程的实现和切换,这里的用户进程就像是高级版的线程,用户进程拥有自己的空间,执行特权级为3。

第一次调试问题花了超过10个小时

本篇难点:

  • 理解用户进程进入3特权级的过程

为什么要有任务状态段 TSS

硬件厂商提供的多任务硬件解决方案主要是 LDT 和 TSS。

TSS 的作用

Intel 的建议是给每个任务关联一个任务状态段,这就是 TSS,用它来表示任务,任务的切换通过TSS的切换实现

TSS 是由程序员“提供”的,由 CPU 来维护。

CPU 中有一个专门存储 TSS 信息的寄存器,这就是 TR 寄存器,它始终指向当前正在运行的任务。

TSS 和其它段一样,本质上是一片存储数据的内存区域,需要注册到 GDT,就像是 TSS 段

Intel 打算用这片内存区域保存任务的最新状态。使用 TSS 描述符来“描述” TSS。

TSS 描述符格式:

image-20210209192406202

TSS 结构:

image-20210209192436240

TSS 的主要作用就是保存任务的快照,也就是 CPU 执行该任务时,寄存器当时的瞬时值。

除了从中断和调用门返回外,CPU 不允许从高特权级转向低特权级。另外,CPU 在不同特权级下用不同的栈。

Linux 只用到了 0 特权级和 3 特权级,因此只设置 SS0 和 esp0 的值就够了。

TR 寄存器:

image-20210209192451300

TSS通过选择子来访问:

ltr <16位通用寄存器/16位内存单元>

CPU 原生支持的任务切换方式

原生支持的任务切换方式效率不高,所以现在操作系统都不使用。

CPU 厂商提供了 LDT 和 TSS两种原生支持,他们要求每个任务都要分配一个LDT和TSS,通过切换这两个结构来进行切换任务,TSS用于保存和恢复任务的状态,LDT则是任务的实体资源(可以直接采用平坦模型,使用那个4GB大小的GDT),实际上LDT可有可无,实际起作用的是TSS

进行任务切换的方法有:

  1. 通过中断+任务门进行切换(步骤贼多)
  2. call或jmp+任务门(call和jmp消耗的时钟周期很长)
  3. iretd

这几种方法怪复杂的,也用不上,就不记录了,以后有闲心了再来了解吧

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

TSS 是 x86 结构 CPU 的特定结构,被用来定义任务

使用 TSS 的唯一理由是为 0 级的任务提供栈,CPU 向更高特权级转移时所使用的栈地址,需要提前在 TSS 中写入。

CPU 要求用 TSS 硬指标,所以需要应付这个指标

Linux 为每一个 CPU 都创建 1 个 TSS,每个 CPU 的 ltr 也指向这个 TSS,不再发生改变,任务切换时,只需要将 ss0 和 esp0 更新为新任务的内核栈和栈指针即可

Linux 在 TSS 中只初始化了 SS0、esp0、I/O位图字段,不用于保存任务状态

任务状态会在CPU从低级进入高级时(CPU自动获得新的栈指针),经过一系列“手动”push,存到0级栈中。

定义并初始化 TSS

这里的工作是初始化TSS结构,创建 GDT 描述符,并注册到 GDT 中去

kernel/global.h

增改如下内容:

/*-------------- GDT描述符属性 ------------*/
#define	DESC_G_4K    1
#define	DESC_D_32    1
#define DESC_L	     0	// 64位代码标记,此处标记为0便可。
#define DESC_AVL     0	// cpu不用此位,暂置为0  
#define DESC_P	     1
#define DESC_DPL_0   0
#define DESC_DPL_1   1
#define DESC_DPL_2   2
#define DESC_DPL_3   3
/* 
   代码段和数据段属于存储段,tss和各种门描述符属于系统段
   s为1时表示存储段,为0时表示系统段.
*/
#define DESC_S_CODE	1
#define DESC_S_DATA	DESC_S_CODE
#define DESC_S_SYS	0
#define DESC_TYPE_CODE	8	// x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.  
#define DESC_TYPE_DATA  2	// x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.
#define DESC_TYPE_TSS   9	// B位为0,不忙

#define RPL0 0  
#define RPLl 1  
#define RPL2 2
#define RPL3 3

#define TI_GDT 0
#define TI_LDT 1

#define SELECTOR_K_CODE 	((1 << 3) + (TI_GDT << 2) + RPL0) 
#define SELECTOR_K_DATA 	((2 << 3) + (TI_GDT << 2) + RPL0) 
#define SELECTOR_K_STACK 	SELECTOR_K_DATA
#define SELECTOR_K_GS 		((3 << 3) + (TI_GDT << 2) + RPL0)
/* 第3个段描述符是显存,第4个是tss */
#define SELECTOR_U_CODE	   ((5 << 3) + (TI_GDT << 2) + RPL3)
#define SELECTOR_U_DATA	   ((6 << 3) + (TI_GDT << 2) + RPL3)
#define SELECTOR_U_STACK   SELECTOR_U_DATA

#define GDT_ATTR_HIGH		     ((DESC_G_4K << 7) + (DESC_D_32  << 6) + (DESC_L << 5)      +(DESC_AVL << 4))
#define GDT_CODE_ATTR_LOW_DPL3	 ((DESC_P << 7)    + (DESC_DPL_3 << 5) + (DESC_S_CODE << 4) + DESC_TYPE_CODE)
#define GDT_DATA_ATTR_LOW_DPL3	 ((DESC_P << 7)    + (DESC_DPL_3 << 5) + (DESC_S_DATA << 4) + DESC_TYPE_DATA)

/*-------------- TSS描述符属性 ------------*/
#define TSS_DESC_D  0 

#define TSS_ATTR_HIGH ((DESC_G_4K << 7) + (TSS_DESC_D << 6) + (DESC_L << 5) + (DESC_AVL << 4) + 0x0)
#define TSS_ATTR_LOW ((DESC_P << 7) + (DESC_DPL_0 << 5) + (DESC_S_SYS << 4) + DESC_TYPE_TSS)
#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;
}; 

这里新增了 GDT 的属性和新的描述符(用户代码/数据/栈段描述符)

userprog/tss.c

#include "tss.h"
#include "stdint.h"
#include "global.h"
#include "string.h"
#include "print.h"

// 任务状态段 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;
}; 
static struct tss tss;

// 更新 tss 中 esp0 字段的值为 pthread 的 0 级栈
void update_tss_esp(struct task_struct* pthread) {
    tss.esp0 = (uint32_t*)((uint32_t)pthread + PG_SIZE);
}

// 创建 gdt 描述符
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");
    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));
    asm volatile ("lgdt %0" : : "m" (gdt_operand));
    asm volatile ("ltr %w0" : : "r" (SELECTOR_TSS));
    put_str("tss_init and ltr done\n");
}

前面先定义了TSS的结构

  • update_tss_esp函数把tss中的esp0字段更新位当前线程的0级栈
  • make_gdt_desc函数用来拼接创建GDT描述符
  • tss_init函数用来初始化TSS,同时也创建TSS、3级代码段和3级数据段

这两段代码还算比较好懂,初始化TSS的操作也就是给TSS结构的SS0和IO位图赋值,至于esp0,则任务切换的时候会通过update_tss_esp函数进行赋值

运行 Bochs

编译,运行:

image-20210209214247792

TSS段成功注册到 GDT 中

实现用户进程

实现用户进程的原理

这里基于线程来实现进程,先来回顾一下线程创建的流程:

image-20210209215440931

线程通过thread_start函数进行创建,通过init_thread函数初始化线程PCB信息,通过thread_create函数设置线程栈内容,这个线程栈就是线程运行的时候恢复到线程上的栈,这里的eip指向线程函数,由kernel_thread函数执行我们的线程函数

初始化PCB信息和线程栈后,将该PCB加入线程就绪队列,等时间片来了就可以执行了

要基于线程实现进程,只要把function替换成创建进程的函数就行了。

至于为啥,先往后看

用户进程的虚拟空间

进程与内核线程最大的区别就是进程有单独4GB的空间

为了演示需要(实际上不需要吗?),我们单独为每个进程维护一个虚拟地址池,用来记录该进程的虚拟地址中的分配情况

因为是基于线程的进程,所以它和线程一样用PCB来存储信息,这里我们要为PCB添加一个新的成员来跟踪用户空间虚拟地址分配情况

thread/thread.h

// 进程或线程的 PCB
struct task_struct {
    uint32_t* self_kstack;         // 各内核线程都用自己的内核栈
    enum task_status status;
    char name[16];
    uint8_t priority;              // 线程优先级
    uint8_t ticks;                 // 每次在处理器上执行的时间嘀嗒数
    uint32_t elapsed_ticks;        // 此任务上 cpu 运行后至今占用了多少嘀嗒数

    struct list_elem general_tag;   // 用于线程在一般队列中的结点
    struct list_elem all_list_tag;  // 用于线程在 thread_all_list 中的结点

    uint32_t* pgdir;                // 进程自己页表的虚拟地址
    struct virtual_addr userprog_vaddr; // 用户进程的虚拟地址
    uint32_t stack_magic;           // 栈的边界标记, 用于检测栈的溢出
};

这里的struct virtual_addr userprog_vaddr;就是我们新增的成员

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

进程和线程的区别就是不同的空间,不同的空间意味着不同的页表,所以要为每个进程单独申请存储页目录项和页表项的虚拟内存页

用户进程工作在3特权级,所以需要创建个3特权级的栈

这里涉及的是内存管理相关的操作,本节添加内存功能:申请用户内存地址、互斥申请操作

kernel/memory.c

增改如下内容:

//内存池结构,生成两个实例用于管理内核内存池和用户内存池
struct pool{
	struct bitmap pool_bitmap;	//本内存池用到的位图结构, 用于管理物理内存
	uint32_t phy_addr_start;	//本内存池所管理物理内存的起始地址
	uint32_t pool_size;			//本内存池字节容量
	struct lock lock; 			//申请内存时互斥
};

增加成员lock,用于申请内存时互斥,避免公共资源的竞争

//在 pf 表示的虚拟内存池中申请 pg_cnt 个虚拟页,成功则返回虚拟页的起始地址,失败则返回 NULL
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);	//将位起始值开始连续置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;
        ASSERT((uint32_t)vaddr_start < (0xc0000000 - PG_SIZE));
	}
	return (void*)vaddr_start;
}

vaddr_get函数中新增else条件中的内容:用户内存池,处理逻辑与内核内存池相同,只是要先获取一下当前线程的PCB(内核线程则不用)

// 在用户空间中申请 4k 内存, 并返回其虚拟地址
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;
}

// 将地址 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;
}

// 得到虚拟地址映射到的物理地址
uint32_t addr_v2p(uint32_t vaddr) {
    uint32_t* pte = pte_ptr(vaddr);
    return ((*pte & 0xfffff000) + (vaddr & 0x00000fff));
}

新增3个函数:3个函数都比较容易看懂

  • get_user_pages:在用户内存中以整页为单位分配内存
  • get_a_page:在某个内存池中获取一个页,与get_user_pages和get_kernel_pages不同的是,可以指定虚拟内存的地址
  • addr_v2p:返回虚拟地址所映射的物理地址
    lock_init(&kernel_pool.lock);
    lock_init(&user_pool.lock);

以上两行添加到mem_init函数中去

进入特权级 3

当前我们在CPU特权级0下,要进入用户的特权级3只有两种方法,中断门和调用门、或者iretd,这里使用iretd指令来假装从中断返回从而进入特权级3,需要具备以下条件:

  1. 从中断返回,必须要经过intr_exit
  2. 必须提前准备好用户进程所需要的栈结构,填好用户进程的上下文
  3. 在栈中存储的CS选择子的RPL必须为3
  4. 栈中段寄存器的选择子必须指向DPL为3的内存段
  5. 栈中eflags的IF位为1
  6. 栈中eflags的IOPL位为0

用户进程创建的流程

进程从创建到运行总体上分为两步,进程的创建是通过process_execute函数完成的,进程的执行由时钟中断调用函数schedule完成,步骤大致如图所示:

看不明白就先往后看

image-20210210081637605


image-20210210081650395

实现用户进程(上)

构造进程的上下文环境免不了要设置eflags

kernel/global.h

添加如下内容:

#define EFLAGS_MBS (1 << 1)
#define EFLAGS_IF_1 (1 << 9) // IF 为 1, 开中断
#define EFLAGS_IF_0 0        // IF 为 0, 关中断
#define EFLAGS_IOPL_3 (3 << 12) // IOPL3, 用于测试用户程序在非系统调用下进行 IO
#define EFLAGS_IOPL_0 (0 << 12) // IOPL0

#define NULL ((void*)0)
#define DIV_ROUND_UP(X, STEP) ((X + STEP - 1) / (STEP))
#define bool int
#define true 1
#define false 0

#define PG_SIZE 4096

userprog/process.c

这里就把函数单独都拿出来介绍了:

extern void intr_exit(void);    // 通过中断返回指令进入3特权级

// 构建用户进程初始上下文信息
void start_process(void* filename_) {
    void* function = filename_;
    struct task_struct* cur = running_thread();
    cur->self_kstack += sizeof(struct thread_stack);                                    // 指向 intr_stack
    struct intr_stack* proc_stack = (struct intr_stack*)cur->self_kstack;               // 用新变量保存 intr_stack 地址
    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;                 // 选择子设置为3特权的区域
    proc_stack->eip = function;                                                         // 待执行的用户程序地址
    proc_stack->cs = SELECTOR_U_CODE;                                                   // 修改 CS 段特权级
    proc_stack->eflags = (EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1);                    // 修改 eflags 的 IF/IOPL/MBS
    proc_stack->esp = (void*)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) + PG_SIZE);  // 申请用户栈空间,申请函数返回的是申请内存的下边界,所以这里的地址应该是用户栈的下边界
    proc_stack->ss = SELECTOR_U_DATA;                                                   // 修改 SS 段特权级
    asm volatile("movl %0, %%esp; jmp intr_exit" : : "g" (proc_stack) : "memory");      // 修改 esp 指针,执行 intr_exit 跳转到 3 环
}

start_process函数,用来构建用户进程初始化上下文信息,修改intr_stack栈的信息如段选择子,cs段,ss段,esp位置,eflags,初始化通用寄存器等,这个函数在创建完成用户页表后执行,所以会在自己的用户空间内进行初始化

intr_stack栈的作用有两个:

  1. 任务被中断用来保存上下文
  2. 给进程预留,用来填充进程的上下文

C 程序在内存空间的分布:

image-20210210085352253

最高地址存放环境变量和命令行参数,然后是栈和堆,栈和堆是相向扩展的,所以操作系统需要检测是否冲突,再之下是bss,data,text这些东西由编译器和链接器负责

这里我们也进行效仿,用户空间最高位用来存命令行参数,也就是 0xc0000000-1,然后栈就申请在 0xc0000000-0x1000的位置(申请时候用的是低地址)然后再加上0x1000赋值给esp作为栈底

下一个函数:

// 激活页表
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");
}

// 激活线程或进程的页表, 更新 tss 中的 esp0 为进程的特权级 0 的栈
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);
    }
}

判断当前任务是进程还是线程,是根据PCB中的pgdir进行的,如果是NULL,则表示是线程,反之是进程

只有在任务调度时才会切换页表以及更新0级栈

process_activate函数是被schedule调用的

bss 简介

在程序加载之初,系统需要为堆和栈指定起始地址,C语言程序上大体上分为预处理、编译、汇编、链接四个阶段,在链接阶段将目标文件内属性相同的节合并成一个段(一方面为了安全检查、一方面为了方便操作系统加载)

从C程序内存布局中可以看到,堆的起始位置应该在bss段之上

bss段是用来存储运行过程中的未初始化的全局变量和静态局部变量的,bss仅存在于内存中,不存在于文件中,bss段存在的目的就是为了这些未初始化的数据预留空间

但是bss段和数据段的属性是一样的,会被自动归结到数据段当中,所以只要知道数据段的起始地址和大小,就可以确定堆的起始地址了

将来实现用户进程的堆,只要堆在用户进程地址上最高的段之上就可以了

实现用户进程(下)

userprog/process.c

// 创建页目录表, 将当前页表的表示内核空间的 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_pages failed!");
        return NULL;
    }
    // 1 先复制页表
    // page_dir_vaddr + 0x300 * 4 是内核页目录的第 768 项
    // 0xfffff000 是内核页目录表的基地址,会访问到当前页目录表的最后一个目录项,也就是当前页目录表本身
    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(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;  //计算位图长度,内存大小/页大小(1位=1页)/8位(1字节=8位)
    bitmap_init(&user_prog->userprog_vaddr.vaddr_bitmap);
}

// 创建用户进程
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);
}

create_page_dir 函数申请了一页新的内核内存空间,用来存放用户进程的页目录表,为了让共享内核,所以将内核所占有的1GB虚拟空间的页目录表的映射,复制到用户页目录表中,也就是768~1023个页目录项,然后把第1023个页目录项改成自己的页目录表的地址,返回页目录表虚拟地址

create_user_vaddr_bitmap 函数为用户进程创建一个虚拟地址位图并清零初始化,位图信息在用户进程PCB的userprog_vaddr.vaddr_bitap中

process_execute 函数的目的是创建用户进程并加入就绪队列,申请1页空间存储PCB,初始化PCB,向PCB中添加位图信息,向PCB中添加栈上下文信息,给PCB中的pgdir赋值(通过调用create_page_dir 函数),到这里PCB的内容已经填充完毕了,接下来关中断,把PCB加入就绪队列和全部队列中,开中断,实现原子操作

进程的创建和线程的创建基本上是一样的流程,不同点在于进程要填充的PCB信息比线程多一点,多了一个虚拟位图信息和pgdir页目录信息,其他流程是一样的

PCB不仅能表示线程,还能表示一个进程,通过pgdir成员来区分

用户进程调度

当前的调度器调度线程一律按内核线程处理,0级特权,内核页表,而进程是3级特权,用户页表,所以需要改进一下:

thread/thread.c

// 实现线程调度
void schedule(void) {
    ASSERT(intr_get_status() == INTR_OFF);

    struct task_struct* cur = running_thread();
    if(cur->status == TASK_RUNNING) {
        // 若此线程只是 CPU 时间片到了, 将其加入到就绪队尾
        ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
        list_append(&thread_ready_list, &cur->general_tag);
        cur->ticks = cur->priority;
        cur->status = TASK_READY;
    } else {
        // 若此线程阻塞, 不需要将其加入队列
    }

    ASSERT(!list_empty(&thread_ready_list));
    thread_tag = NULL;
    thread_tag = list_pop(&thread_ready_list);
    struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
    next->status = TASK_RUNNING;

    process_activate(next);	//新增内容

    switch_to(cur, next);
}

这里的新增仅1行,就是在切换前将页表进行切换,根据任务是否是进程,修改tss中的esp0

测试用户进程

kernel/main.c

#include "print.h"
#include "init.h"
#include "debug.h"
#include "memory.h"
#include "thread.h"
#include "console.h"
#include "process.h"

void k_thread_a(void*);
void u_prog_a(void);
int test_var_a = 0;


int main(){
	put_str("\nI am kernel\n");
	init_all();

	//thread_start("k_thread_a",31,k_thread_a,"A_ ");
    process_execute(u_prog_a, "u_prog_a");
    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 u_prog_a(void) {
    while(1) {
        test_var_a++;
        put_str("yes\n");
    }
}

坑点

之前为了偷懒省事,直接cv别人敲好的书上的代码,这次出问题了。。。

在interrupt.c文件中,idt_init()函数中如下内容:

	uint64_t idt_operand = ((sizeof(idt) - 1) | ((uint64_t)(uint32_t)idt << 16));
	//uint64_t idt_operand = ((sizeof(idt) - 1) | ((uint64_t)((uint32_t)idt << 16)));

注释掉的那行是我复制的,正确的是上面那一行,多了一个括号!!出现这种问题真的,真的排查到恶心好不

然后在thread.c文件中,init_thread函数中:

    //pthread->priority = prio;
    pthread->ticks = prio;

应该给ticks赋值的,结果给priority赋值了,这种细节问题真的,太折腾人了

经过10个小时的调试排错,终于解决了,不过虽然很折腾人,但是也是有点收获的,经过这次调试,直接强化了本书中对进程切换和线程切换的原理的认识。。

一个教训:下次懒得手抄代码想复制现成的代码的时候,一定要把别人的代码先跑起来,确保无误了再copy!!!

运行 Bochs

编译,运行:

image-20210211170856078

异常分析

嗯,虽然还是报错了,但是这个错是正常的,为什么这么说呢,因为在3特权级的用户进程中,访问内核函数会直接报错,报的就是这个Page Fault,缺页异常,为什么说一定是这个问题呢?咱们再给process.c文件中的start_process函数的给eip赋值那一行之后加个while(1);让程序断在那里(这个eip就是第一次进程切换的时候进程的执行流会跳转到的地方),咱们来查看一下给eip赋的值是多少:

image-20210211171326049

这里给参数赋值用的是edx装变量,然后赋值到地址上去,所以edx里装的就是给构造的栈中的eip赋值的值:0x15b3,跟上面缺页异常报的地址一致,所以可以认为这里缺页异常的原因就是:

3特权级的进程跳转到内核函数执行时因为权限检查失败而导致异常

当前咱们在进程中,所以可以顺便来查看一下进程的信息:

image-20210211171551425

当前页表地址是0x218000,与之前内核线程的页目录表不一样,说明页表进行了切换

当前还没进行权限切换,所以还看不到特权级的变化,将循环删掉记住刚才的位置重启下断点继续执行,执行到iret就是权限级切换完毕的时候,执行到这个位置,来查看一下当前段寄存器:

image-20210211172654975

段寄存器的后2位表示rpl,这里不是3(11b)就是b(1011b)后两位都是3,也就是3特权级,当前进程的特权级是3

从iret返回的时候,下一条地址就是0x15b3:

image-20210211172935913

一执行就报错,看来我们的结论是正确的,任务切换的时候,3特权级切换到0特权级会报错缺页异常


一天后

我发现啊,别人的代码可以跑起来这个用户进程!

我觉得我的应该也行,但是为啥呢,在调试阶段,我对比了别人正常的代码和我有问题的代码,没有发现任何区别

emmm

遇到困难的时候要回归基础,重新来回顾一下特权级相关的知识

代码执行的时候只有 GDT 中代码段描述符的 DPL,是这个段的特权级,而段选择子中的 RPL是我们当前的执行特权级,也就是 CPL,除了段和段选择子以外,还有一个地方需要设置内存的访问权限--那就是页,在创建页表的时候也要指定页的特权级

当前核实过了 DPL 和 RPL,都是3,但是就是执行不了,但是却忽视了页的属性,经过排查发现了又一个让人哭笑不得的问题:

PG_US_U	equ	000b
PG_US_S	equ	100b

以上代码是我在创建页表时候定义的宏,1是用户级,0是内核级,而这里我给写反了。。修改过来后,进行运行:

image-20210212153130678

还错....

不过这里已经执行到0x15b3里面了,这里报错的缺页异常是0xbffffffc,也就是0xc0000000-4,也就是入栈的时候,栈的特权级还是0不是3所导致的,来追溯一下3级栈是怎么来的:

   proc_stack->esp = (void*)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) + PG_SIZE ) ;

栈通过get_a_page申请来的,这里去memory.c去查看一下发现是通过page_table_add创建的页,而page_table_add函数里使用了PG_US_U宏:

			*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1); //创建pte

经验告诉我,同一个人在同一个问题被解决之前,会不断的犯相同的错误,这里去看一下memory.h的宏定义:

#define PG_US_U	0	//U/S 属性位值,系统级
#define PG_US_S 4	//U/S 属性位值,用户级

果然,还是写反了。。改过来之后,再进行修改一下主函数main,让内核线程来打印输出用户进程不断++的变量,编译运行:

image-20210212154322639

终于,成功啦!!

本篇总结

学习花了4个小时,调试花了10个小时,也是绝了

本章节的内容是实现用户进程,为了让用户程序和系统程序有所区分,就有了用户进程

进程和线程的区别是,进程有独立的内存空间,进程之间的空间是隔离开的

但本书是基于线程来实现进程的,也就是说,进程也会有一个自己的PCB,进程的PCB会比线程的PCB多一些东西:页表地址(虚拟地址)、虚拟空间位图

进程也是由PCB来标识的,在这里的进程和线程一样,是一个执行流,可以理解成,单线程的进程就是一个线程,进程的作用得等到到时候做进程内多线程才能知道

或者可以认为,进程就是一个主线程运行在了一个独立的空间

因为 CPU 硬件级要求使用 TSS 来进行任务切换,但是 TSS 并不好用,但是又不能不用,所以就用其中一个tss.esp0来进行0级栈的切换了

实现用户进程,要考虑解决的问题如下:

  • 用户进程要有自己的内存空间:给进程创建一个页表,将页表地址(vaddr)写到PCB中,切换进程的时候,判断是不是进程,是进程就切换cr3的值
  • 用户进程要运行在3特权级下:在进程第一次执行的时候通过伪造中断现场,使用iretd指令从0级返回3级段空间,执行3级指令

经过类似线程的PCB构建流程之后,将进程PCB添加到PCB队列和PCB就绪队列中,等待时间片就可以开始执行了

参考资料


评论