《操作系统-真象还原》11. 用户进程

为什么要有任务状态段 TSS

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

为了支持多任务,CPU 厂商提供了 LDT 及 TSS 这两种原生支持,他们要求为每个任务分配一个 LDT 和 TSS(都有程序员自己构建),LDT 中保存的是任务的数据和代码,TSS 保存的是任务的上下文状态以及三种特权级指针、IO位图等信息。
那么任务切换就是切换这两个结构:将新任务对应的 LDT 加载到 LDTR,对应的 TSS 加载到 TR 寄存器。

任务切换的方式:三种。

还差两种没写上来,只写了一种,具体看书吧,这里我也是简单的了解了一下…

通过“中断门 + 任务门”进行任务切换
  • 与任务相关的选择子是 TSS。
  • 若想通过中断进行任务切换,则该中断对应的 IDT 描述符必须包含 TSS 选择子,而唯一包含 TSS 选择子的描述符便是任务门描述符。
    否则将会去执行对应的中断处理程序,而中断例程是当前任务的一部分,因此触发中断后调用中断处理程序的这个过程也不属于“新任务”。
  • 在 CPU 的眼里,一个 TSS 就代表一个任务,TSS 才是任务的标志,CPU 区分任务就是靠 TSS,因此,只要 TR 寄存器中的 TSS 信息不换,无论执行的是哪里的指令,也无论指令是否跨越特权级(从用户态到内核态),CPU 都认为还是在同一个任务中。

iretd 指令的作用:

  1. 从中断返回到当前任务的中断发生的位置。
  2. 当前任务是被嵌套调用时,它会调用自己 TSS 中的 “上一个任务的 TSS 指令” 的任务,也就是返回上一个任务。

NT 位是 elflags 中的第 14 位,1bit 宽度,它表示 Nest Task Flag(任务嵌套)。任务嵌套是指当前任务是被前一个任务所调用的,也就是当前任务嵌套在另一个任务中,当前任务执行完毕后,将会返回上一个任务,即返回当前任务的调用者的那个任务。

当处理器执行 iretd 指令时,会先判断 NT 的值:

  • NT = 0 表示返回到当前任务中断前的指令部分。
  • NT = 1 表示从新任务返回到旧任务,于是 CPU 从当前任务的 TSS 中取出 “上一个任务的 TSS 指针” 字段中的值,从而执行旧任务。

B 位:主要用来给 CPU 做重入判断。

  • B = 0 表示未被调用。
  • B = 1 表示已被调用。

并不是只有当前任务 B = 1,那些被 call 嵌套调用的新任务,除了新任务的 B 位置为 1 外,旧任务的 B 位依然还是 1。

TSS 字段中的 “上一个任务的 TSS 指针”,用于记录是哪个任务调用了当前任务,这是一个单链表的关系,如图:
image-20221107203941685

图意:A 调用任务 A.1,A.1 调用任务 A.1.1,…。

当调用一个新任务时,处理器做了两件事:

  • 自动将新任务 elfag 中的 NT 位置为 1。
  • 处理器将旧任务中的 TSS 选择子写入到新任务 TSS 中的 “上一个任务的 TSS 指针” 字段中。

中断发生时,处理器要把 NT 和 TF 位置为 0,若对应的描述符是中断描述符,还要再将标志寄存器中的 IF 位置为 0,这是为了避免中断嵌套,防止正在处理的中断尚未完成时,相同的中断源又发出中断信号,避免引发 GP 异常。

综上所述,中断发生时,通过任务门进行任务切换的过程如下:

  1. 从该任务门描述符中取出任务的 TSS 选择子。
  2. 用新任务的 TSS 选择子在 GDT 中索引 TSS 描述符。
  3. 判断该 TSS 描述符的 P 位是否为 1。
  4. 从寄存器 TR 中获取旧任务的 TSS 位置,保存旧任务的状态到旧任务的 TSS 中(状态指的是上下文等相关信息)。
  5. 把新任务 TSS 中的值加载到响应的寄存器中。
  6. 将 TR 寄存器指向新任务的 TSS 地址。
  7. 将新任务的 TSS 描述符中的 B 位置 1。
  8. 将新任务标志寄存器中的 NT 位置为 1。
  9. 将旧任务的 TSS 选择子写入到新任务 TSS 中的 “上一个任务的 TSS 指针” 字段中。
  10. 开始执行新任务。

为什么旧任务不修改 B 位?
答:因为旧任务并没有执行完,它现在执行的新任务也只是因为要完成某些必要的工作,才不得不调用新任务,新任务完成后就会立刻回去执行旧任务,因此旧任务的 B = 1,不需要修改。

当新任务完成后,调用 iretd 指令返回到旧任务,此时处理器会检查 NT 位,若为 1,则返回旧任务:

  1. 将当前任务(即为新任务)标志寄存器中的 NT 位置为 0。
  2. 将当前任务 TSS 描述符中的 B 位置为 0。
  3. 将当前任务的状态信息写入到 TR 所指向的 TSS 段中(指向是指向当前任务本身的)。
  4. 获取当前任务 TSS 中的 “上一个任务的 TSS 指针” 字段的值,将其加载到 TR 中,恢复上一个任务的状态。
  5. 从而恢复旧任务。

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

  • 当一个中断发生在用户态(特权级 3),处理器将从当前任务的 TSS 中获取 SS0 和 ESP0 字段的值。
  • 每个 CPU 中只创建一个 TSS,在各个 CPU 上执行的所有任务都共享一个 TSS。
  • 在 TR 加载 TSS 后,该 TR 寄存器将永远指向那一个 TSS,之后再也不会重新加载 TSS。
  • 在进程切换时,只需要把 TSS 中的 SS0 和 ESP0 更新为新任务的内核栈的段地址以及栈指针。
  • Linux 对 TSS 的操作是一次性加载 TSS 到 TR,之后不断修改同一个 TSS 的内容,不再重复加载。
  • Linux 中任务切换不使用 call 和 jmp 指令,避免了任务切换的低效。

任务的状态信息存储位置: 当用户态触发中断后,由特权级 3 陷入特权级 0 后…

  • CPU 自动从当前任务的 TSS 中获取 SS0 和 ESP0 字段的值,作为特权级 0 的栈,然后手动执行一系列 push 指令将任务的状态保存在特权级0的栈中。

code

userprog/tss.c:

/* 任务状态段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,需要把新的描述符放到第 4 个位置,即 0x900 + 0x20
    // 4 * 0x08 = 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)); // 重载 GDT
    asm volatile("ltr %w0" : : "r" (SELECTOR_TSS));// 加载 TSS

    put_str("tss_init and ltr done.\n");
}

实现用户进程

创建和执行进程的流程图

image-20221113201321566

创建与执行进程的代码实现

userprog/process.c:

extern void intr_exit(void);

/**
 * 构建用户进程、初始化其上下文
 * 该函数由 kernel_thread() 所调用
 * filename_ 是用户进程的名称
 */
void start_process(void* filename_) {
    void* function = filename_;
    struct task_struct* cur = running_thread(); // 进程是基于线程构建的
    cur -> self_kstack += sizeof(struct thread_stack); // 将 ESP 指向 intr_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");
}

// 激活页表
void page_dir_activate(struct task_struct* p_thread) {
    /**
     * 为什么要重载线程的 CR3?
     * 我们知道进程才有独立的地址空间,而线程用的是线程间共享的同一套,按理说只需要重载进程就行,为什么线程也要呢?
     * 说实话我感觉没啥好说的...
     * 就是你线程用的是自己的,而进程们用的是各自独立的,你看线程和进程用的必然都不是同一套,你若要执行对应想线程或进程,是不是必须要重载 CR3 到自己所对应的页目录物理地址去?
     */
    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(struct task_struct* p_thread) {
    ASSERT(p_thread != NULL);

    // 激活该进程或线程的页表
    page_dir_activate(p_thread); 

    // 若当前 p_thread 是内核线程,则不需要更新 ESP,因为其本身特权级就是0
    if(p_thread -> pgdir) 
        update_tss_esp(p_thread); // 更新用户进程的 ESP0,用于此进程被中断时恢复上下文
}

// 创建也目录表,将当前页表的 表示内核空间的 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;
    }

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

    /************* 更新页目录地址 *************/
    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;
    bitmap_init(&user_prog -> userprog_vaddr.vaddr_bitmap);
}

// 创建用户进程
void process_execute(void* filename, char* name) {
    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);
}

操作系统被所有用户进程共享

操作系统是为了服务用户进程的,它提供了各种各样的系统功能供用户进程调用。为了用户进程可以访问到内核服务,必须确保用户进程在自己的地址空间中能够访问到内核才行,也就是说内核空间必须是用户空间的一部分。

在用户进程 4GB 的虚拟地址空间的高 3GB 以上划分给操作系统,0~3GB 划分给用户进程自己。

为了实现共享操作系统,让所有用户进程的 3~4GB 的虚拟地址空间都指向同一个操作系统,也就是把所有用户进程的 3~4GB 的虚拟地址中的页表项所对应的物理页地址都指向同一片物理页地址,而这片物理页上是操作系统的实体代码。

总结:虚拟地址空间的 0~3GB 是用户进程,3~4GB 是操作系统。

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_page failed!");
        return NULL;
    }

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

    /************* 更新页目录地址 *************/
    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;
}

备注

(0xc0000000 - USER_VADDR_START) 的误解

image-20221114195050101

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值