《操作系统真象还原》第十一章
本篇对应书籍第十一章的内容
本篇内容介绍了用户进程的实现和切换,这里的用户进程就像是高级版的线程,用户进程拥有自己的空间,执行特权级为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.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
userprog/tss.c
#include "tss.h"
#include "stdint.h"
#include "global.h"
#include "string.h"
#include "print.h"
#include "../thread/thread.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;
// 当 io位图偏移地址 大于等于 tss大小 - 1 时表示没有 io位图
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 位的段基址 = 48 位(0~15 = limit, 16~47 = GDT起始地址)
// limit: 7个描述符(包括第0个描述符), 所以 limit = 8 * 7 - 1
// 将 0xc0000900 先转位32位再转为64位(不可一步转为64位)
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函数进行赋值
init.c
#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "../device/timer.h"
#include "memory.h"
#include "../thread/thread.h"
#include "../device/console.h"
#include "../device/keyboard.h"
#include "../userprog/tss.h"
/* 负责初始化所有模块 */
void init_all() {
put_str("init_all\n");
idt_init(); // 初始化中断
mem_init(); // 初始化内存管理系统
thread_init(); // 初始化线程相关结构
timer_init(); // 初始化 PIT
console_init(); // 初始化终端
keyboard_init(); // 键盘初始化
tss_init(); // tss 初始化
}
运行 Bochs
编译,运行:
TSS段成功注册到 GDT 中
实现用户进程
实现用户进程的原理
这里基于线程来实现进程,先来回顾一下线程创建的流程:
线程通过thread_start函数进行创建,通过init_thread
函数初始化线程PCB信息,通过thread_create
函数设置线程栈内容,这个线程栈就是线程运行的时候恢复到线程上的栈,这里的eip指向线程函数,由kernel_thread函数执行我们的线程函数
初始化PCB信息和线程栈后,将该PCB加入线程就绪队列,等时间片来了就可以执行了
要基于线程实现进程,只要把function替换成创建进程的函数就行了。
至于为啥,先往后看
用户进程的虚拟空间
进程与内核线程最大的区别就是进程有单独4GB的空间
为了演示需要,我们单独为每个进程维护一个虚拟地址池,用来记录该进程的虚拟地址中的分配情况
因为是基于线程的进程,所以它和线程一样用PCB来存储信息,这里我们要为PCB添加一个新的成员来跟踪用户空间虚拟地址分配情况
thread/thread.h
#include "bitmap.h"
#include "../kernel/memory.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.h
/* 得到虚拟地址映射到的物理地址 */
uint32_t addr_v2p(uint32_t vaddr);
/* 将地址 vaddr 与 pf 池中的物理地址关联, 仅支持一页空间分配 */
void* get_a_page(enum pool_flags pf, uint32_t vaddr);
/* 在用户空间中申请 4k 内存, 并返回其虚拟地址 */
void* get_user_pages(uint32_t pg_cnt);
增改如下内容:
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;
}
// 将位起始值开始连续置1, 直到设置完需要的页位置
while (cnt < pg_cnt) {
bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt, 1);
cnt++;
}
// 获取起始页的虚拟地址
vaddr_start = kernel_vaddr.vaddr_start + bit_idx_start * PG_SIZE;
} else {
// 用户内存池
struct task_struct* cur = running_thread();
// 在用户进程的虚拟地址位图申请 pg_cnt 个页
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_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 = (虚拟地址 - 虚拟地址起始地址) / 每位大小
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) {
// 得到虚拟地址对应的 pte, pte中记录了物理页框地址
uint32_t* pte = pte_ptr(vaddr);
// (*pte)的值是页表所在的物理页框地址,
// 去掉其低12位的页表项属性 + 虚拟地址 vaddr 的低12位(偏移地址)
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_pool_init
函数中去
我们的内存分配是 内核有一个专门的虚拟内存管理池 而进程是每个进程都有一个专门的虚拟内存管理池
物理内存的话 还是只有两个 内核物理内存池 和 用户物理内存池怎么保证每个进程中操作系统都是可见的 因为每个进程都有一个单独的页表
单独的页表里面共有的部分是内核部分的页目录项 操作系统创建好后的内存地址就是固定的 这样的话 操作系统对于每个进程都是可见的怎么保证进程的独立空间呢 大家都用相同的虚拟地址怎么才能不冲突呢 之前说过的的每个进程独享一个页表 那么相同的虚拟页目录项相对应的物理地址是可以不同的 分配是由操作系统来做的
但是为什么进程每个都还要一个独用的虚拟内存管理池呢 因为对于一个进程里面的资源 是不可以用相同的虚拟地址 里面的地址都是唯一的
进入特权级 3
当前我们在CPU特权级0下,要进入用户的特权级3只有两种方法,中断门和调用门、或者iretd,这里使用iretd指令来假装从中断返回从而进入特权级3,需要具备以下条件:
- 从中断返回,必须要经过intr_exit
- 必须提前准备好用户进程所需要的栈结构,填好用户进程的上下文
- 在栈中存储的CS选择子的RPL必须为3
- 栈中段寄存器的选择子必须指向DPL为3的内存段
- 栈中eflags的IF位为1(继续响应新的中断)
- 栈中eflags的IOPL位为0(不允许用户进程直接访问硬件)
用户进程创建的流程
进程从创建到运行总体上分为两步,进程的创建是通过process_execute函数完成的,进程的执行由时钟中断调用函数schedule完成,步骤大致如图所示:
创建进程大概的流程是:
- 先在内核中分配一页内存 为线程thread分配一页pcb
- 初始化线程后 为我们的进程的虚拟内存位图分配内存
- 创造线程 此线程为了调用
start_process
初始化函数(备注start_process
中把进程的环境给初始化好 即可调用intr_exit iretd
跑路 当然这是后话) - 紧接着把进程的页表空间分配好 并且初始化好
- 把含有
start_process
的线程放到就绪列表中 就等着被调用了 - 线程
schedule
调用后 发现pgdi
r非空 换页表重新装载 并调用start_process
start_process
做了一大堆事情 并把分配了一页内存给栈空间 之后就jmp intr_exit
切换去了- 各种pop之后 cs ip ds各种寄存器已经被切换了 进入用户进程
- 切换进程时 只需要把TSS中的esp0切换即可 即内核线程栈
看不明白就先往后看
实现用户进程(上)
构造进程的上下文环境免不了要设置eflags
kernel/global.h
添加如下内容:
//--------------- eflags属性 ----------------
/********************************************************
--------------------------------------------------------------
Intel 8086 Eflags Register
--------------------------------------------------------------
*
* 15|14|13|12|11|10|F|E|D C|B|A|9|8|7|6|5|4|3|2|1|0|
* | | | | | | | | | | | | | | | | | | | | '--- CF……Carry Flag
* | | | | | | | | | | | | | | | | | | | '--- 1 MBS
* | | | | | | | | | | | | | | | | | | '--- PF……Parity Flag
* | | | | | | | | | | | | | | | | | '--- 0
* | | | | | | | | | | | | | | | | '--- AF……Auxiliary Flag
* | | | | | | | | | | | | | | | '--- 0
* | | | | | | | | | | | | | | '--- ZF……Zero Flag
* | | | | | | | | | | | | | '--- SF……Sign Flag
* | | | | | | | | | | | | '--- TF……Trap Flag
* | | | | | | | | | | | '--- IF……Interrupt Flag
* | | | | | | | | | | '--- DF……Direction Flag
* | | | | | | | | | '--- OF……Overflow flag
* | | | | | | | | '---- IOPL……I/O Privilege Level
* | | | | | | | '----- NT……Nested Task Flag
* | | | | | | '----- 0
* | | | | | '----- RF……Resume Flag
* | | | | '------ VM……Virtual Mode Flag
* | | | '----- AC……Alignment Check
* | | '----- VIF……Virtual Interrupt Flag
* | '----- VIP……Virtual Interrupt Pending
* '----- ID……ID Flag
*
*
**********************************************************/
#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
userprog/process.h
#ifndef __USERPROG_PROCESS_H
#define __USERPROG_PROCESS_H
#include "../thread/thread.h"
#include "stdint.h"
// 默认优先级
#define default_prio 31
// (0xc0000000 - 1)为用户空间最高地址, 往上为内核地址
// (0xc0000000 - 0x1000) 为栈顶的下边界, 0x1000 = 4096 = 4k
#define USER_STACK3_VADDR (0xc0000000 - 0x1000)
// 用户进程起始地址, 这里采用 Linux 的入口地址
#define USER_VADDR_START 0x8048000
/* 创建用户进程 */
void process_execute(void* filename, char* name);
/* 构建用户进程初始上下文信息(填充用户进程的 intr_stack) */
void start_process(void* filename_);
/* 激活线程或进程的页表, 更新 tss 中的 esp0 为进程的特权级0的栈 */
void process_activate(struct task_struct* p_thread);
/* 激活页表(更新 cr3 指向的页目录表, 每个进程有自己的页目录表) */
void page_dir_activate(struct task_struct* p_thread);
/* 创建页目录表, 将当前页表的表示内核空间的 pde 复制,
* 成功则返回页目录的虚拟地址, 否则返回 -1 */
uint32_t* create_page_dir(void);
/* 创建用户进程虚拟地址位图 */
void create_user_vaddr_bitmap(struct task_struct* user_prog);
#endif
userprog/process.c
这里就把函数单独都拿出来介绍了:
extern void intr_exit(void); // 通过中断返回指令进入3特权级
/* 构建用户进程初始上下文信息(填充用户进程的 intr_stack) */
void start_process(void* filename_) {
void* function = filename_;
struct task_struct* cur = running_thread(); // 获取当前线程
cur->self_kstack += sizeof(struct thread_stack); // 指向 intr_stack
// 用新变量保存 intr_stack 地址
struct intr_stack* proc_stack = (struct intr_stack*) cur->self_kstack;
proc_stack->edi = 0; // 初始化通用寄存器
proc_stack->esi = 0;
proc_stack->ebp = 0;
proc_stack->esp_dummy = 0;
proc_stack->ebx = 0;
proc_stack->edx = 0;
proc_stack->ecx = 0;
proc_stack->eax = 0;
proc_stack->gs = 0; // 用户态用不上, 直接初始为 0
proc_stack->ds = SELECTOR_U_DATA; // 选择子设置为3特权的区域
proc_stack->es = SELECTOR_U_DATA;
proc_stack->fs = SELECTOR_U_DATA;
proc_stack->eip = function; // 待执行的用户程序地址
proc_stack->cs = SELECTOR_U_CODE; // 修改 CS 段特权级
// 修改 eflags 的 IF/IOPL/MBS
proc_stack->eflags = (EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1);
// 申请用户栈空间, 申请函数返回的是申请内存的下边界,
// 所以这里的地址(USER_STACK3_VADDR)应该是用户栈的下边界, 所以 + PG_SIZE得到栈底地址
proc_stack->esp = (void*) ((uint32_t) get_a_page(PF_USER, USER_STACK3_VADDR) + PG_SIZE);
proc_stack->ss = SELECTOR_U_DATA; // 修改 SS 段特权级
// 修改 esp 指针, 执行 intr_exit, 将 proc_stack 中的数据载入CPU寄存器, 进入特权级3
asm volatile("movl %0, %%esp; jmp intr_exit" : : "g"(proc_stack) : "memory");
}
start_process
函数,用来构建用户进程初始化上下文信息,修改intr_stack栈
的信息如段选择子,cs段,ss段,esp位置,eflags,初始化通用寄存器等,这个函数在创建完成用户页表后执行,所以会在自己的用户空间内进行初始化
intr_stack栈的作用有两个:
- 任务被中断用来保存上下文
- 给进程预留,用来填充进程的上下文
C 程序在内存空间的分布:
最高地址存放环境变量和命令行参数,然后是栈和堆,栈和堆是相向扩展的,所以操作系统需要检测是否冲突,再之下是bss,data,text这些东西由编译器和链接器负责
这里我们也进行效仿,用户空间最高位用来存命令行参数,也就是 0xc0000000 - 1
,然后栈就申请在 0xc0000000 - 0x1000
的位置(申请时候用的是低地址)然后再加上0x1000
赋值给esp作为栈底
下一个函数:
/* 激活页表(更新 cr3 指向的页目录表, 每个进程有自己的页目录表) */
void page_dir_activate(struct task_struct* p_thread) {
/********************************************************
* 执行此函数时, 当前任务可能是线程。
* 之所以对线程也要重新安装页表, 原因是上一次被调度的可能是进程,
* 否则不恢复页表的话, 线程就会使用进程的页表了。
********************************************************/
// 若为内核线程, 需要重新填充页表为 0x100000
// 默认为内核的页目录物理地址, 也就是内核线程所用的页目录表
uint32_t pagedir_phy_addr = 0x100000;
if(p_thread->pgdir != NULL) {
// 用户态进程有自己的页目录表(每个页目录表代表4GB = 1024 * 4MB), 根据页表虚拟地址获得物理地址
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);
// 激活该进程或线程的页表(更新 cr3 指向的页目录表)
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仅存在于内存中,不存在于文件中(仅在elf文件头中记录了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. 先复制页表(将当前页表的表示内核空间的 pde 复制到新页表对应映射内核部分)
// 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; // 提前定好的起始位置
// 除法向上取整, 计算位图需要的内存页数
// (内核起始地址 - 用户进程起始地址) / PG_SIZE / 8 = 位图需要的 byte
// 再 / PG_SIZE 向上取整得到位图需要的内存页数
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);
//计算位图长度, 内存大小 / 页大小(1位=1页)/ 8位(1字节=8位)
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) {
// pcb 内核的数据结构, 由内核来维护进程信息, 因此要在内核内存池中申请
struct task_struct* thread = get_kernel_pages(1);
init_thread(thread, name, default_prio); // 初始化进程基本信息
create_user_vaddr_bitmap(thread); // 创建用户进程虚拟地址位图
// 初始化线程栈 thread_stack, start_process为进程待执行函数, filename为其参数
// start_process: 填充用户进程的 intr_stack
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() {
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清空
// 将 thread_ready_list 队列中的第一个就绪线程弹出, 准备将其调度上cpu
thread_tag = list_pop(&thread_ready_list);
// 获取 thread_tag 对应的 PCB
struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
next->status = TASK_RUNNING;
// 切换前将页表进行切换,根据任务是否是进程,修改 tss 中的 esp0
process_activate(next); //新增内容
switch_to(cur, next);
}
这里的新增仅1行,就是在切换前将页表进行切换,根据任务是否是进程,修改tss中的esp0
process.h:
process.c:
测试用户进程
kernel/main.c
#include "print.h"
#include "init.h"
#include "debug.h"
#include "memory.h"
#include "../thread/thread.h"
#include "interrupt.h"
#include "../device/console.h"
#include "../userprog/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() {
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_a, "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++;
}
}
// 只打开时钟中断, 其它全部关闭
outb(PIC_M_DATA, 0xfe);
outb(PIC_S_DATA, 0xff);
运行 Bochs
BUILD_DIR = ./build
ENTRY_POINT = 0xc0001500
AS = nasm
CC = gcc
LD = ld
LIB = -I lib/ -I lib/kernel/ -I lib/user/ -I kernel/ -I device/
ASFLAGS = -f elf
CFLAGS = -Wall -m32 -fno-stack-protector $(LIB) -c -fno-builtin -W -Wstrict-prototypes -Wmissing-prototypes
LDFLAGS = -m elf_i386 -Ttext $(ENTRY_POINT) -e main -Map $(BUILD_DIR)/kernel.map
OBJS = $(BUILD_DIR)/main.o $(BUILD_DIR)/init.o $(BUILD_DIR)/interrupt.o \
$(BUILD_DIR)/timer.o $(BUILD_DIR)/kernel.o $(BUILD_DIR)/print.o \
$(BUILD_DIR)/debug.o $(BUILD_DIR)/memory.o $(BUILD_DIR)/string.o \
$(BUILD_DIR)/bitmap.o $(BUILD_DIR)/thread.o $(BUILD_DIR)/list.o \
$(BUILD_DIR)/switch.o $(BUILD_DIR)/sync.o $(BUILD_DIR)/console.o \
$(BUILD_DIR)/keyboard.o $(BUILD_DIR)/ioqueue.o $(BUILD_DIR)/tss.o \
$(BUILD_DIR)/process.o
############ C 代码编译 ##############
$(BUILD_DIR)/main.o: kernel/main.c lib/kernel/print.h \
lib/stdint.h kernel/init.h lib/string.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/init.o: kernel/init.c kernel/init.h lib/kernel/print.h \
lib/stdint.h kernel/interrupt.h device/timer.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/interrupt.o: kernel/interrupt.c kernel/interrupt.h \
lib/stdint.h kernel/global.h lib/kernel/io.h lib/kernel/print.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/timer.o: device/timer.c device/timer.h lib/stdint.h \
lib/kernel/io.h lib/kernel/print.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/debug.o: kernel/debug.c kernel/debug.h \
lib/kernel/print.h lib/stdint.h kernel/interrupt.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/string.o: lib/string.c lib/string.h \
kernel/debug.h kernel/global.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/memory.o: kernel/memory.c kernel/memory.h \
lib/stdint.h lib/kernel/bitmap.h kernel/debug.h lib/string.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/bitmap.o: lib/kernel/bitmap.c lib/kernel/bitmap.h \
lib/string.h kernel/interrupt.h lib/kernel/print.h kernel/debug.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/thread.o: thread/thread.c thread/thread.h \
lib/stdint.h lib/string.h kernel/global.h kernel/memory.h \
kernel/debug.h kernel/interrupt.h lib/kernel/print.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/list.o: lib/kernel/list.c lib/kernel/list.h \
kernel/interrupt.h lib/stdint.h kernel/debug.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/sync.o: thread/sync.c thread/sync.h \
lib/stdint.h thread/thread.h kernel/debug.h kernel/interrupt.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/console.o: device/console.c device/console.h \
lib/kernel/print.h thread/sync.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/keyboard.o: device/keyboard.c device/keyboard.h \
lib/kernel/print.h lib/kernel/io.h kernel/interrupt.h \
kernel/global.h lib/stdint.h device/ioqueue.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/ioqueue.o: device/ioqueue.c device/ioqueue.h \
kernel/interrupt.h kernel/global.h kernel/debug.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/tss.o: userprog/tss.c userprog/tss.h \
kernel/global.h thread/thread.h lib/kernel/print.h
$(CC) $(CFLAGS) $< -o $@
$(BUILD_DIR)/process.o: userprog/process.c userprog/process.h \
lib/string.h kernel/global.h kernel/memory.h lib/kernel/print.h \
thread/thread.h kernel/interrupt.h kernel/debug.h device/console.h
$(CC) $(CFLAGS) $< -o $@
############## 汇编代码编译 ###############
$(BUILD_DIR)/kernel.o: kernel/kernel.asm
$(AS) $(ASFLAGS) $< -o $@
$(BUILD_DIR)/print.o: lib/kernel/print.asm
$(AS) $(ASFLAGS) $< -o $@
$(BUILD_DIR)/switch.o: thread/switch.asm
$(AS) $(ASFLAGS) $< -o $@
############## 链接所有目标文件 #############
$(BUILD_DIR)/kernel.bin: $(OBJS)
$(LD) $(LDFLAGS) $^ -o $@
.PHONY: mk_dir hd clean all
mk_dir:
if [ ! -d $(BUILD_DIR) ]; then mkdir $(BUILD_DIR); fi
hd:
sudo dd if=$(BUILD_DIR)/kernel.bin \
of=/home/steven/source/os/bochs/hd60M.img \
bs=512 count=200 seek=9 conv=notrunc
clean:
cd $(BUILD_DIR) && rm -f ./*
build: $(BUILD_DIR)/kernel.bin
all: mk_dir build hd
编译,运行:
流程
创建线程
创建进程
本篇总结
本章节的内容是实现用户进程,为了让用户程序和系统程序有所区分,就有了用户进程
进程和线程的区别是,进程有独立的内存空间,进程之间的空间是隔离开的
但本书是基于线程来实现进程的,也就是说,进程也会有一个自己的PCB,进程的PCB会比线程的PCB多一些东西:页表地址(虚拟地址)、虚拟空间位图
进程也是由PCB来标识的,在这里的进程和线程一样,是一个执行流,可以理解成,单线程的进程就是一个线程,进程的作用得等到到时候做进程内多线程才能知道
或者可以认为,进程就是一个主线程运行在了一个独立的空间
因为 CPU 硬件级要求使用 TSS 来进行任务切换,但是 TSS 并不好用,但是又不能不用,所以就用其中一个tss.esp0来进行0级栈的切换了
实现用户进程,要考虑解决的问题如下:
- 用户进程要有自己的内存空间:给进程创建一个页表,将页表地址(vaddr)写到PCB中,切换进程的时候,判断是不是进程,是进程就切换cr3的值
- 用户进程要运行在3特权级下:在进程第一次执行的时候通过伪造中断现场,使用
iretd
指令从0级返回3级段空间,执行3级指令
经过类似线程的PCB构建流程之后,将进程PCB添加到PCB队列和PCB就绪队列中,等待时间片就可以开始执行了