本篇对应书籍第十一章的内容
本篇内容介绍了用户进程的实现和切换,这里的用户进程就像是高级版的线程,用户进程拥有自己的空间,执行特权级为3。
第一次调试问题花了超过10个小时
本篇难点:
- 理解用户进程进入3特权级的过程
为什么要有任务状态段 TSS
硬件厂商提供的多任务硬件解决方案主要是 LDT 和 TSS。
TSS 的作用
Intel 的建议是给每个任务关联一个任务状态段,这就是 TSS,用它来表示任务,任务的切换通过TSS的切换实现
TSS 是由程序员“提供”的,由 CPU 来维护。
CPU 中有一个专门存储 TSS 信息的寄存器,这就是 TR 寄存器,它始终指向当前正在运行的任务。
TSS 和其它段一样,本质上是一片存储数据的内存区域,需要注册到 GDT,就像是 TSS 段
Intel 打算用这片内存区域保存任务的最新状态。使用 TSS 描述符来“描述” TSS。
TSS 描述符格式:
TSS 结构:
TSS 的主要作用就是保存任务的快照,也就是 CPU 执行该任务时,寄存器当时的瞬时值。
除了从中断和调用门返回外,CPU 不允许从高特权级转向低特权级。另外,CPU 在不同特权级下用不同的栈。
Linux 只用到了 0 特权级和 3 特权级,因此只设置 SS0 和 esp0 的值就够了。
TR 寄存器:
TSS通过选择子来访问:
ltr <16位通用寄存器/16位内存单元>
CPU 原生支持的任务切换方式
原生支持的任务切换方式效率不高,所以现在操作系统都不使用。
CPU 厂商提供了 LDT 和 TSS两种原生支持,他们要求每个任务都要分配一个LDT和TSS,通过切换这两个结构来进行切换任务,TSS用于保存和恢复任务的状态,LDT则是任务的实体资源(可以直接采用平坦模型,使用那个4GB大小的GDT),实际上LDT可有可无,实际起作用的是TSS
进行任务切换的方法有:
- 通过中断+任务门进行切换(步骤贼多)
- call或jmp+任务门(call和jmp消耗的时钟周期很长)
- 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
编译,运行:
TSS段成功注册到 GDT 中
实现用户进程
实现用户进程的原理
这里基于线程来实现进程,先来回顾一下线程创建的流程:
线程通过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,需要具备以下条件:
- 从中断返回,必须要经过intr_exit
- 必须提前准备好用户进程所需要的栈结构,填好用户进程的上下文
- 在栈中存储的CS选择子的RPL必须为3
- 栈中段寄存器的选择子必须指向DPL为3的内存段
- 栈中eflags的IF位为1
- 栈中eflags的IOPL位为0
用户进程创建的流程
进程从创建到运行总体上分为两步,进程的创建是通过process_execute函数完成的,进程的执行由时钟中断调用函数schedule完成,步骤大致如图所示:
看不明白就先往后看
实现用户进程(上)
构造进程的上下文环境免不了要设置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栈的作用有两个:
- 任务被中断用来保存上下文
- 给进程预留,用来填充进程的上下文
C 程序在内存空间的分布:
最高地址存放环境变量和命令行参数,然后是栈和堆,栈和堆是相向扩展的,所以操作系统需要检测是否冲突,再之下是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
编译,运行:
异常分析
嗯,虽然还是报错了,但是这个错是正常的,为什么这么说呢,因为在3特权级的用户进程中,访问内核函数会直接报错,报的就是这个Page Fault,缺页异常,为什么说一定是这个问题呢?咱们再给process.c文件中的start_process函数的给eip赋值那一行之后加个while(1);
让程序断在那里(这个eip就是第一次进程切换的时候进程的执行流会跳转到的地方),咱们来查看一下给eip赋的值是多少:
这里给参数赋值用的是edx装变量,然后赋值到地址上去,所以edx里装的就是给构造的栈中的eip赋值的值:0x15b3,跟上面缺页异常报的地址一致,所以可以认为这里缺页异常的原因就是:
3特权级的进程跳转到内核函数执行时因为权限检查失败而导致异常
当前咱们在进程中,所以可以顺便来查看一下进程的信息:
当前页表地址是0x218000,与之前内核线程的页目录表不一样,说明页表进行了切换
当前还没进行权限切换,所以还看不到特权级的变化,将循环删掉记住刚才的位置重启下断点继续执行,执行到iret
就是权限级切换完毕的时候,执行到这个位置,来查看一下当前段寄存器:
段寄存器的后2位表示rpl,这里不是3(11b)就是b(1011b)后两位都是3,也就是3特权级,当前进程的特权级是3
从iret返回的时候,下一条地址就是0x15b3:
一执行就报错,看来我们的结论是正确的,任务切换的时候,3特权级切换到0特权级会报错缺页异常
一天后
我发现啊,别人的代码可以跑起来这个用户进程!
我觉得我的应该也行,但是为啥呢,在调试阶段,我对比了别人正常的代码和我有问题的代码,没有发现任何区别
emmm
遇到困难的时候要回归基础,重新来回顾一下特权级相关的知识
代码执行的时候只有 GDT 中代码段描述符的 DPL,是这个段的特权级,而段选择子中的 RPL是我们当前的执行特权级,也就是 CPL,除了段和段选择子以外,还有一个地方需要设置内存的访问权限--那就是页,在创建页表的时候也要指定页的特权级
当前核实过了 DPL 和 RPL,都是3,但是就是执行不了,但是却忽视了页的属性,经过排查发现了又一个让人哭笑不得的问题:
PG_US_U equ 000b
PG_US_S equ 100b
以上代码是我在创建页表时候定义的宏,1是用户级,0是内核级,而这里我给写反了。。修改过来后,进行运行:
还错....
不过这里已经执行到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,让内核线程来打印输出用户进程不断++的变量,编译运行:
终于,成功啦!!
本篇总结
学习花了4个小时,调试花了10个小时,也是绝了
本章节的内容是实现用户进程,为了让用户程序和系统程序有所区分,就有了用户进程
进程和线程的区别是,进程有独立的内存空间,进程之间的空间是隔离开的
但本书是基于线程来实现进程的,也就是说,进程也会有一个自己的PCB,进程的PCB会比线程的PCB多一些东西:页表地址(虚拟地址)、虚拟空间位图
进程也是由PCB来标识的,在这里的进程和线程一样,是一个执行流,可以理解成,单线程的进程就是一个线程,进程的作用得等到到时候做进程内多线程才能知道
或者可以认为,进程就是一个主线程运行在了一个独立的空间
因为 CPU 硬件级要求使用 TSS 来进行任务切换,但是 TSS 并不好用,但是又不能不用,所以就用其中一个tss.esp0来进行0级栈的切换了
实现用户进程,要考虑解决的问题如下:
- 用户进程要有自己的内存空间:给进程创建一个页表,将页表地址(vaddr)写到PCB中,切换进程的时候,判断是不是进程,是进程就切换cr3的值
- 用户进程要运行在3特权级下:在进程第一次执行的时候通过伪造中断现场,使用
iretd
指令从0级返回3级段空间,执行3级指令
经过类似线程的PCB构建流程之后,将进程PCB添加到PCB队列和PCB就绪队列中,等待时间片就可以开始执行了
参考资料
-
《操作系统真象还原》Chapter 11
-
操作系统真象还原第十一章:https://blog.csdn.net/zhwenx3/article/details/111658069