《操作系统真象还原》 第十一章 用户进程

概述

Linux用户进程是运行在CPU用户态(Ring 3)的程序执行实例,每个进程拥有彼此隔离的独立虚拟地址空间,无法直接访问内核或硬件资源;当需要执行特权操作(如读写文件、申请内存)时,必须通过系统调用陷入内核,由内核代表其完成工作。内核通过进程控制块(task_struct)管理进程的状态、资源、调度等信息,并在多个进程之间通过软件上下文切换实现并发执行,从而在保证安全性与稳定性的前提下,为用户提供多任务处理能力

Linux创建用户进程机制主要是为了安全和稳定

隔离保护 :让用户程序运行在受限的用户态,无法直接访问内核或其它进程的内存。一个进程崩溃不会拖垮整个系统。

抽象简化:每个进程都觉得自己独占了CPU和内存,程序员无需操心硬件资源如何分配。

多任务支持:内核能公平地调度多个进程,让它们并发执行。

TSS

TSS(Task State Segment,任务状态段)是 x86 架构 CPU 提供的一个硬件数据结构,用于保存任务(进程/线程)的状态

核心作用

  1. 保存硬件上下文

    当发生任务切换时,CPU 可将当前任务的寄存器(EAX、EIP、CS、EFLAGS 等)自动保存到 TSS 中,并加载新任务的 TSS 内容

  2. 特权级栈切换

    最重要的用途:当程序从用户态(Ring 3) 陷入内核态(Ring 0) (如系统调用、中断、异常)时,CPU 自动从 TSS 中读取 SS0 / ESP0 字段,将其加载为内核栈,从而完成安全的内核栈切换

  3. I/O 端口权限控制

    TSS 可以包含一个 I/O 许可位图,用于精细控制当前任务能否直接访问特定 I/O 端口

TSS描述符

TSS 的描述符是存放在 全局描述符表(GDT) 中的一种系统段描述符。GDT 里可以同时包含代码段、数据段、LDT 描述符以及多个 TSS 描述符

位范围 内容
0-15 段界限低 16 位(Limit[15:0])
16-39 基地址低 24 位(Base[23:0])
40-43 类型(Type):0x9 = 可用 TSS,0xB = 忙 TSS
44 描述符类型(S)= 0(系统段)
45-46 DPL(描述符特权级,通常为 0)
47 存在位(P)
48-51 段界限高 4 位(Limit[19:16])
52-55 保留(通常为 0)
56-63 基地址高 8 位(Base[31:24])

TSS结构

TSS(Task State Segment,任务状态段)是 x86 架构中用于保存任务(进程/线程)硬件上下文的数据结构

偏移 (Byte) 字段名 含义
0 prev_tss 前一任务的 TSS 选择子(硬件任务切换时自动填写,用于嵌套返回)
4 esp0 Ring 0 栈指针(当从 Ring 3 陷入 Ring 0 时自动加载)
8 ss0 Ring 0 栈段选择子
12 esp1 Ring 1 栈指针(通常不用)
16 ss1 Ring 1 栈段选择子
20 esp2 Ring 2 栈指针
24 ss2 Ring 2 栈段选择子
28 cr3 页目录物理基址(PDBR),用于硬件任务切换时切换地址空间
32 eip 指令指针(待恢复的任务的 CS:EIP)
36 eflags 标志寄存器
40 eax 通用寄存器 EAX
44 ecx 通用寄存器 ECX
48 edx 通用寄存器 EDX
52 ebx 通用寄存器 EBX
56 esp 栈指针(当前任务的用户/内核栈,取决于切换前的 CPL)
60 ebp 基址指针
64 esi 源索引寄存器
68 edi 目标索引寄存器
72 es ES 段寄存器
76 cs CS 段寄存器
80 ss SS 段寄存器
84 ds DS 段寄存器
88 fs FS 段寄存器
92 gs GS 段寄存器
96 ldt_sel LDT 段选择子(任务私有的局部描述符表)
100 trap 调试陷阱标志(低 16 位,第 0 位为 T 标志,其他保留)
102 iomap_base I/O 权限位图基址(相对于 TSS 起始的偏移,通常设为 sizeof(TSS) 即 104,表示位图紧接其后。若等于界限值则无位图)

现代操作系统并没有intel设计时想的那种用硬件来切换的任务切换方式**,** 现代操作系统(如 Linux、Windows、macOS)依然需要使用 TSS因为 CPU 在硬件层面强制要求:当从用户态陷入内核态(系统调用、中断、异常)时,必须从 TSS 中读取内核栈指针(SS0/ESP0 或 RSP0)。这是 x86 架构无法绕开的底层机制

现代操作系统如何使用 TSS

每个 CPU 核心只创建一个 TSS,而不是每个任务一个

初始化时:在 GDT 中设置 TSS 描述符,执行LTR将其加载到 TR 寄存器

每次进程切换时:仅修改 TSS 中的ESP0/RSP0字段,指向新进程的内核栈顶

不用JMP/CALL指令触发硬件任务切换(完全由软件保存/恢复上下文)

使用方式

初始化TSS

在操作系统启动或创建新任务时,内核在内存中分配一个TSS结构,并填充其初始状态。例如:设置内核栈指针(SS0/ESP0)指向任务的内核栈顶;如果需要硬件任务切换,还需初始化CR3(页目录基址)、EIP(代码入口)、ESP(用户栈指针)等字段。此外,可配置I/O权限位图以控制端口访问

将TSS的描述符添加到GDT中

操作系统在全局描述符表(GDT)中为每个TSS创建一个系统段描述符。描述符包含TSS的基地址、界限(大小)、类型(如0x9表示"可用32位TSS")、DPL(通常为0)等标志。添加后,CPU可以通过该描述符访问对应的TSS

使用LTR指令加载TSS

当操作系统想要切换到一个新任务时,操作系统执行LTR指令,将GDT中TSS描述符的选择子加载到任务寄存器(TR)中。此后,TR始终指向当前活动的TSS。该指令只能在特权级0执行,且加载后不会发生任务切换,且只告诉CPU当前任务的TSS位于何处

任务切换

当处理器执行任务切换时,它会自动保存当前任务的状态到当前任务的TSS中,并从新任务的TSS中恢复新任务的状态。这包括保存和恢复处理器寄存器的值、堆栈指针的值等

由调度器选择下一个任务

内核的调度器(如 Linux 的 CFS)根据优先级、时间片等策略,决定下一个要运行的进程/线程。

软件保存和恢复上下文

在切换时,内核通过一系列 mov、push、pop 指令,将当前任务的通用寄存器、段寄存器、栈指针、指令指针等状态保存到其 task_struct/thread_struct 中,然后加载下一个任务的类似状态。

切换地址空间

修改 CPU 的 CR3 寄存器(页表基址),切换到新任务的虚拟地址空间。这一步会刷新 TLB(或使用 PCID 技术避免完全刷新)。

仅借用 TSS 的内核栈指针

每个 CPU 核心只有一个 TSS,在初始化时通过 LTR 加载。

在每次任务切换时,内核仅更新该 TSS 中的 ESP0(32 位)或 RSP0(64 位)字段,使其指向新任务的内核栈顶。

完全不使用硬件任务链

不依赖 Previous Task Link、NT 标志或 IRET 返回。

任务切换完全由调度器的 switch_to 宏或类似代码完成,返回用户态时统一使用 iret 或 sysret

代码

global.h

首先修改/kernel/global.h

添加模块化的段描述符字段,定义TSS的选择子、定义用户程序用的代码段、数据段、栈段选择子

复制代码
// ----------------  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
#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 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))    //定义段描述符的高32位的高字
#define GDT_CODE_ATTR_LOW_DPL3	 ((DESC_P << 7) + (DESC_DPL_3 << 5) + (DESC_S_CODE << 4) + DESC_TYPE_CODE)  //定义用户程序用的代码段描述符高32位的低字
#define GDT_DATA_ATTR_LOW_DPL3	 ((DESC_P << 7) + (DESC_DPL_3 << 5) + (DESC_S_DATA << 4) + DESC_TYPE_DATA)  //定义用户程序用的数据段描述符高32位的低字

//---------------  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

这些宏和结构体是内核初始化 GDT 和 TSS 的辅助定义,目的是:

建立内核代码段、数据段、用户代码段、用户数据段以及 TSS 段的描述符

为用户进程提供特权级 3 的代码和数据段,并确保从用户态到内核态的中断能正确切换栈(通过 TSS)

简化描述符的构造过程,避免手写二进制位

tss.h

/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.c

/userprog/tss.c

复制代码
#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");
   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表中添加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");
}

定义 TSS 结构体,与 x86 硬件 TSS 格式完全一致,用于保存任务状态(特别是特权级 0 的栈指针 esp0 和 ss0)

update_tss_esp:更新 TSS 中的 esp0 字段,指向指定进程/线程的内核栈顶(PCB 页的顶部)。这样当用户进程发生中断时,CPU 会自动切换到该内核栈

make_gdt_desc:辅助函数,根据基址、界限、属性低字节和高字节构造一个 8 字节的 GDT 描述符(返回 struct gdt_desc)

tss_init:

清零 TSS 并设置 ss0 为内核栈段选择子 (SELECTOR_K_STACK),io_base 为 TSS 大小(表示无 I/O 位图)

在 GDT 的固定虚拟地址 0xc0000920 处写入 TSS 描述符(索引 4)

在 0xc0000928 和 0xc0000930 分别写入用户代码段和用户数据段描述符(DPL=3,基址 0,界限 4GB)

构造 GDT 操作数(16 位界限 + 32 位基址),执行 lgdt 加载 GDT

执行 ltr 加载任务寄存器,使 TSS 生效

init.c

修改/kernel/init.c

复制代码
#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "timer.h"
#include "memory.h"
#include "thread.h"
#include "console.h"
#include "keyboard.h"
#include "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初始化
}

用户进程

实现用户进程的核心原理,可以概括为:操作系统通过硬件提供的特权级隔离机制,为每个程序创建独立的虚拟地址空间和内核栈,并以时间片轮转的方式进行调度,让程序运行在受限的用户态,同时通过中断/系统调用陷入内核获得服务

进程与内核线程的核心区别如下:

1、进程有单独的4GB虚拟地址空间

2、进程运行在特权级3,内核线程运行在特权级0

用户进程的虚拟地址空间

我们需要做到以下两点

A 一个管理自己虚拟地址空间的地址池;B 一个自己的独立的页表

下面我们来实现让用户进程拥有一个管理自己虚拟地址空间的地址池,也就是A

虚拟地址空间的地址池

thread.h

修改 **/thread/thread.h,**修改task_struct结构体,增加虚拟内存池结构体

复制代码
#include "memory.h"

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的边界
};
process.h

/userprog/process.h

复制代码
#ifndef __USERPROG_PROCESS_H 
#define __USERPROG_PROCESS_H 
#include "thread.h"
void create_user_vaddr_bitmap(struct task_struct* user_prog);
#define USER_VADDR_START 0x8048000	 //linux下大部分可执行程序的入口地址(虚拟)都是这个附近,我们也仿照这个设定

#endif
process.c

/userprog/process.c

我们创建进程时需要初始化这个结构体,所以我们写个初始化虚拟内存池结构体的函数

为一个用户进程创建虚拟地址空间的位图。设置用户虚拟地址的起始位置,计算管理整个用户空间(3GB)所需的位图大小(以页为单位),从内核内存池分配足够的物理页来存储位图,设置位图的字节长度,并初始化位图(所有位清零)

复制代码
#include "process.h"
#include "thread.h"
#include "global.h"   //定义了PG_SIZE
#include "memory.h"
#include "bitmap.h"

//用于初始化进程pcb中的用于管理自己虚拟地址空间的虚拟内存池结构体
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);        //初始化位图
}
global.h

/kernel/global.h

global.h中新增下列代码

复制代码
#define DIV_ROUND_UP(X, STEP) ((X + STEP - 1) / (STEP))  //用于向上取整的宏,如9/10=1

独立的页表

process.h

/userprog/process.h新增下列代码

复制代码
uint32_t* create_page_dir(void);
process.c

/userprog/process.c新增下列代码

复制代码
#include "string.h"
#include "console.h"
//用于为进程创建页目录表,并初始化(系统映射+页目录表最后一项是自己的物理地址,以此来动态操作页目录表),成功后,返回页目录表虚拟地址,失败返回空地址
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;
   }
   //将内核页目录表的768号项到1022号项复制过来
   memcpy((uint32_t*)((uint32_t)page_dir_vaddr + 768*4), (uint32_t*)(0xfffff000 + 768 * 4), 255 * 4);
   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;
}

create_page_dir 函数为进程创建独立的页目录表。它首先从内核内存池分配一页作为页目录的存储空间(虚拟地址),将内核页目录中第 768 到 1022 项(即内核空间部分)复制到新页目录中,使进程共享内核地址空间。然后获取该页目录的物理地址,并将页目录的最后一项(第 1023 项)设为该物理地址加上用户级可读写属性,实现页目录的自引用,便于通过线性地址动态访问页表。最后返回页目录的虚拟地址,供进程切换时加载到 CR3 寄存器

memory.h

/kernel/memory.h

复制代码
uint32_t addr_v2p(uint32_t vaddr);
void* get_user_pages(uint32_t pg_cnt);
memory.c

/kernel/memory.c 增加函数函数addr_v_to_p与get_user_pages,物理内存池结构体增加锁

复制代码
#include "sync.h"

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

//初始化内核物理内存池与用户物理内存池
static void mem_pool_init(uint32_t all_mem) {
   	put_str("   mem_pool_init start\n");
   	uint32_t page_table_size = PG_SIZE * 256;	  // 页表大小= 1页的页目录表+第0和第768个页目录项指向同一个页表+
                                                  // 第769~1022个页目录项共指向254个页表,共256个页表
   	uint32_t used_mem = page_table_size + 0x100000;	  // 已使用内存 = 1MB + 256个页表
   	uint32_t free_mem = all_mem - used_mem;
   	uint16_t all_free_pages = free_mem / PG_SIZE;        //将所有可用内存转换为页的数量,内存分配以页为单位,丢掉的内存不考虑
   	uint16_t kernel_free_pages = all_free_pages / 2;     //可用内存是用户与内核各一半,所以分到的页自然也是一半
   	uint16_t user_free_pages = all_free_pages - kernel_free_pages;   //用于存储用户空间分到的页

/* 为简化位图操作,余数不处理,坏处是这样做会丢内存。
好处是不用做内存的越界检查,因为位图表示的内存少于实际物理内存*/
   	uint32_t kbm_length = kernel_free_pages / 8;			  // 内核物理内存池的位图长度,位图中的一位表示一页,以字节为单位
   	uint32_t ubm_length = user_free_pages / 8;			  // 用户物理内存池的位图长度.

   	uint32_t kp_start = used_mem;				  // Kernel Pool start,内核使用的物理内存池的起始地址
   	uint32_t up_start = kp_start + kernel_free_pages * PG_SIZE;	  // User Pool start,用户使用的物理内存池的起始地址

   	kernel_pool.phy_addr_start = kp_start;       //赋值给内核使用的物理内存池的起始地址
   	user_pool.phy_addr_start   = up_start;       //赋值给用户使用的物理内存池的起始地址

   	kernel_pool.pool_size = kernel_free_pages * PG_SIZE;     //赋值给内核使用的物理内存池的总大小
   	user_pool.pool_size	 = user_free_pages * PG_SIZE;       //赋值给用户使用的物理内存池的总大小

   	kernel_pool.pool_bitmap.btmp_bytes_len = kbm_length;     //赋值给管理内核使用的物理内存池的位图长度
   	user_pool.pool_bitmap.btmp_bytes_len	  = ubm_length;   //赋值给管理用户使用的物理内存池的位图长度

/*********    内核内存池和用户内存池位图   ***********
 *   位图是全局的数据,长度不固定。
 *   全局或静态的数组需要在编译时知道其长度,
 *   而我们需要根据总内存大小算出需要多少字节。
 *   所以改为指定一块内存来生成位图.
 *   ************************************************/
// 内核使用的最高地址是0xc009f000,这是主线程的栈地址.(内核的大小预计为70K左右)
// 32M内存占用的位图是2k.内核内存池的位图先定在MEM_BITMAP_BASE(0xc009a000)处.
   	kernel_pool.pool_bitmap.bits = (void*)MEM_BITMAP_BASE;      //管理内核使用的物理内存池的位图起始地址
							       
/* 用户内存池的位图紧跟在内核内存池位图之后 */
   	user_pool.pool_bitmap.bits = (void*)(MEM_BITMAP_BASE + kbm_length);     //管理用户使用的物理内存池的位图起始地址
   /******************** 输出内存池信息 **********************/
   	put_str("      kernel_pool_bitmap_start:");put_int((int)kernel_pool.pool_bitmap.bits);
   	put_str(" kernel_pool_phy_addr_start:");put_int(kernel_pool.phy_addr_start);
   	put_str("\n");
   	put_str("      user_pool_bitmap_start:");put_int((int)user_pool.pool_bitmap.bits);
   	put_str(" user_pool_phy_addr_start:");put_int(user_pool.phy_addr_start);
   	put_str("\n");

   /* 将位图置0*/
   	bitmap_init(&kernel_pool.pool_bitmap);
   	bitmap_init(&user_pool.pool_bitmap);

	lock_init(&kernel_pool.lock);
   	lock_init(&user_pool.lock);

   /* 下面初始化内核虚拟地址的位图,按实际物理内存大小生成数组。*/
   	kernel_vaddr.vaddr_bitmap.btmp_bytes_len = kbm_length;      // 赋值给管理内核可以动态使用的虚拟地址池(堆区)的位图长度,
         //其大小与管理内核可使用的物理内存池位图长度相同,因为虚拟内存最终都要转换为真实的物理内存,可用虚拟内存大小超过可用物理内存大小在
         //我们这个简单操作系统无意义(现代操作系统中有意义,因为我们可以把真实物理内存不断换出,回收,来让可用物理内存变相变大)

  /* 位图的数组指向一块未使用的内存,目前定位在内核内存池和用户内存池之外*/
   	kernel_vaddr.vaddr_bitmap.bits = (void*)(MEM_BITMAP_BASE + kbm_length + ubm_length);   //赋值给管理内核可以动态使用的虚拟内存池(堆区)的位图起始地址

   	kernel_vaddr.vaddr_start = K_HEAP_START;     //赋值给内核可以动态使用的虚拟地址空间的起始地址
   	bitmap_init(&kernel_vaddr.vaddr_bitmap);     //初始化管理内核可以动态使用的虚拟地址池的位图
   	put_str("   mem_pool_init done\n");
}

/* 从内核物理内存池中申请pg_cnt页内存,成功则返回其虚拟地址,失败则返回NULL */
void* get_kernel_pages(uint32_t pg_cnt) {
	lock_acquire(&kernel_pool.lock);
   	void* vaddr =  malloc_page(PF_KERNEL, pg_cnt);
   	if (vaddr != NULL) {	   // 若分配的地址不为空,将页框清0后返回
      	memset(vaddr, 0, pg_cnt * PG_SIZE);
   	}
	lock_release(&kernel_pool.lock);
   	return vaddr;
}

/* 在用户空间中申请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;
}

//将虚拟地址转换成真实的物理地址
uint32_t addr_v2p(uint32_t vaddr) {
   uint32_t* pte = pte_ptr(vaddr);	//将虚拟地址转换成页表对应的页表项的地址
   return ((*pte & 0xfffff000) + (vaddr & 0x00000fff));		//(*pte)的值是页表所在的物理页框地址,去掉其低12位的页表项属性+虚拟地址vaddr的低12位
}

mem_pool_init:根据总内存大小,计算内核和用户物理内存池的起始地址、大小以及位图存储位置,初始化两个内存池的位图和锁,并初始化内核虚拟地址池(堆区)的位图

get_kernel_pages:从内核物理内存池分配连续 pg_cnt 个物理页,返回对应的内核虚拟地址(已清零),操作加锁

get_user_pages:从用户物理内存池分配连续物理页,返回用户虚拟地址(已清零),操作加锁

addr_v2p:通过页表自映射机制,将给定的虚拟地址转换为物理地址(前提是虚拟地址已映射)

从特权级0进入特权级3

从特权级 0 切换到特权级 3 的一种方法是利用中断返回指令 iret。当 CPU 执行 iret 时,会从栈中弹出 cs 选择子,若该选择子的 RPL 为 3,则 CPU 自动降级到用户态。因此,切换特权级的关键在于让 iret 加载用户程序的代码段选择子。

由于我们的进程是基于线程模型构建的,为了不破坏现有线程执行逻辑,需要利用已有的线程启动机制。原本在内核线程中,switch_to 通过 ret 指令跳转到 kernel_thread(线程启动器),再由它执行真正的内核线程函数。

在此基础之上,我们做如下修改:将 kernel_thread 要调用的参数从原来的用户函数地址改为中断栈初始化函数 start_process。这样,线程启动器在 ret 之后会进入 start_process,该函数负责构造中断栈并最终通过 iret 完成特权级切换,从而将执行流转向用户进程。即,在原有的 ret 机制之后,增加了 iret 的步骤。

初始化进程

process.h

/userprog/process.h

复制代码
#define USER_STACK3_VADDR  (0xc0000000 - 0x1000)    //定义了一页C语言程序的栈顶起始地址(虚拟),书p511
void start_process(void* filename_);
void intr_init(void* func);
process.c

/userprog/process.c

复制代码
extern void intr_exit(void);

//用于初始化进入进程所需要的中断栈中的信息,传入参数是实际要运行的函数地址(进程),这个函数是用线程启动器进入的(kernel_thread)
void start_process(void* filename_) {
   void* function = filename_;
   struct task_struct* cur = running_thread();
   cur->self_kstack += sizeof(struct thread_stack); //当我们进入到这里的时候,cur->self_kstack指向thread_stack的起始地址,跳过这里,才能设置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(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");
}
global.h

/kernel/global.h

复制代码
//定义eflages寄存器用的一些字段,含义见书p511
#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
memory.h

/kernel/memory.h

复制代码
void* get_a_page(enum pool_flags pf, uint32_t vaddr);
memory.c

/kernel/memory.c

复制代码
#include "thread.h"

//用于为指定的虚拟地址申请一个物理页,传入参数是这个虚拟地址,要申请的物理页所在的地址池的标志。申请失败,返回null
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);
	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;
}

1.get_a_page(enum pool_flags pf, uint32_t vaddr)

功能:为虚拟地址 vaddr 分配一页物理内存,建立页表映射,使该虚拟地址可用。

参数:

pf:内存池标志(PF_KERNEL 或 PF_USER),指明从内核物理内存池还是用户物理内存池分配。

vaddr:需要映射的虚拟地址。

返回值:成功返回虚拟地址 vaddr;失败返回 NULL。

  1. struct pool 与 kernel_pool / user_pool

代表物理内存池,用于管理可分配的物理页。系统通常分为内核专用物理内存池和用户专用物理内存池。

根据 pf 选择对应的池,并对其加锁(互斥),保证多线程下分配物理页的原子性。

  1. lock_acquire(&mem_pool->lock) / lock_release(&mem_pool->lock)

获取/释放内存池的自旋锁或互斥锁,防止并发分配导致位图或链表数据不一致。

  1. running_thread()

返回当前正在执行的线程/进程的 task_struct 指针(即进程控制块 PCB)。

5.虚拟地址位图相关操作

cur->pgdir:当前线程的页目录表指针。内核线程没有独立的用户地址空间,因此 pgdir == NULL 代表这是内核线程;用户进程有独立页表,pgdir != NULL。

userprog_vaddr / kernel_vaddr:结构体,描述用户/内核虚拟地址池的范围和位图。

vaddr_start:虚拟地址池起始地址。

vaddr_bitmap:位图,每一位代表一页的占用状态(0 空闲,1 已占用)。

bit_idx = (vaddr - vaddr_start) / PG_SIZE:计算 vaddr 对应位图中的索引。

bitmap_set(..., bit_idx, 1):将位图对应位置 1,标记该虚拟页已分配。

逻辑分支:

用户进程申请用户内存(pf == PF_USER 且 pgdir != NULL):修改该进程自己的虚拟地址位图。

内核线程申请内核内存(pf == PF_KERNEL 且 pgdir == NULL):修改全局内核虚拟地址位图。

其他组合(如内核线程申请用户内存)视为非法,触发 PANIC。

  1. palloc(mem_pool)

从选定的物理内存池 mem_pool 中分配一个空闲物理页。

返回物理页的起始物理地址;若池已满则返回 NULL。

  1. page_table_add((void*)vaddr, page_phyaddr)

建立页表映射:将虚拟地址 vaddr 映射到物理地址 page_phyaddr。

通常会修改当前进程的页目录表/页表项,使得 CPU 能通过 vaddr 访问到刚分配的物理页。

保存进程的内核栈的esp0到TSS

当进程由由于主进程的时间片到期而调度时,需要切换到进程自己的页表,中断退出进入进程执行时,但由于时钟中断发生,需要从TSS中取出进程0级的ss与esp,才能顺利切换到内核栈中,于是修改schedule函数,将进程的内核栈的esp0保存到TSS中

process.h

/userprog/process.h

复制代码
void page_dir_activate(struct task_struct* p_thread);
void process_activate(struct task_struct* p_thread);
process.c

/userprog/process.c

复制代码
#include "tss.h"
#include "debug.h"

/* 激活页表 */
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,用于此进程被中断时保留上下文 */
}
  1. page_dir_activate(struct task_struct* p_thread)

功能:激活目标线程/进程的页表,即让CPU使用该任务的地址空间。

关键步骤:

确定页目录表的物理地址:

如果 p_thread->pgdir == NULL,说明目标是内核线程,页目录表使用固定的内核页目录(物理地址 0x100000)。

如果 p_thread->pgdir != NULL,说明目标是用户进程,需用 addr_v2p 将其页目录表的虚拟地址转换为物理地址。

将最终得到的物理地址写入 CR3 寄存器(movl %0, %%cr3),使新页表立即生效。

注意:即使切换的目标是内核线程,也重新加载CR3,防止之前运行的进程页表残留,导致内核线程错误使用用户页表。

  1. process_activate(struct task_struct* p_thread)

功能:在任务切换时,完整地激活一个线程/进程的执行环境,包括加载页表和更新TSS。

关键步骤:

调用 page_dir_activate(p_thread),激活目标任务的页表。

判断目标任务是否为用户进程(p_thread->pgdir != NULL):

如果是用户进程,调用 update_tss_esp(p_thread) 将进程的 0特权级栈指针(ESP0) 写入TSS。当该进程在用户态被中断时,CPU会从TSS中取出ESP0作为内核栈指针,保证中断处理能正确切换到内核栈。

如果是内核线程,其特权级本身就是0,中断时不会从TSS取栈,因此无需更新ESP0。

thread.c

/thread/thread.c修改schedule

复制代码
#include "process.h"

/* 实现任务调度 */
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;     // 重新将当前线程的ticks再重置为其priority;
      cur->status = TASK_READY;
   } 
   else { 
      /* 若此线程需要某事件发生后才能继续上cpu运行,
      不需要将其加入队列,因为当前线程不在就绪队列中。*/
   }

   ASSERT(!list_empty(&thread_ready_list));
   thread_tag = NULL;	  // thread_tag清空
/* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */
   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);   
}

封装函数

封装成用于创建进程的函数

process.h

/userprog/process.h

复制代码
#define default_prio 31 //定义默认的优先级

struct list thread_ready_list; //线程就绪队列
struct list thread_all_list;   //线程全部队列

void process_execute(void* func, char* name);
process.c

/userprog/process.c

复制代码
#include "interrupt.h"
	
//用于创建进程,参数是进程要执行的函数与他的名字
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.h

/thread/thread.h

复制代码
extern struct list thread_ready_list;
extern struct list thread_all_list;
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 位于内核空间,相比内核线程多出了:

虚拟内存池结构体(含位图)------ 用于管理进程独立的用户虚拟地址空间。

页目录表指针 pgdir ------ 指向进程自己的页目录表(也放在内核空间)。

这些额外字段体现了进程具有独立的虚拟地址空间,而内核线程共享内核地址空间。

进程首次启动流程(对比内核线程)

内核线程:switch_to 通过 ret 直接进入 kernel_thread 启动器,再执行目标函数。

进程:switch_to 的 ret 进入 start_process 函数,该函数在栈上构造 intr_stack(填好用户态寄存器、段寄存器、用户栈等),然后通过 iret 进入用户态执行目标函数。

关键区别:进程在 ret 基础上增加了 iret 步骤,利用 iret 切换特权级(因为 cs 等段选择子 DPL=3)。

进程切换前的 TSS 设置

在切换到进程执行之前,必须更新 TSS 中的 esp0(和 ss0),使其指向该进程的内核栈顶。这样当进程运行中发生中断时,CPU 才能自动从 TSS 加载正确的内核栈。

运行中的进程发生中断

CPU 自动从 TSS 中读取 ss0 和 esp0,切换到该进程的内核栈。

将用户态的上下文(包括 ss、esp、eflags、cs、eip 等)压入这个内核栈。

如果此时发生调度切换,当前进程的内核栈顶会被保存在其 task_struct 中,并更新 TSS 中的 esp0 为下一个进程的内核栈顶。

被切回时恢复执行的流程

调度器选中进程后,从它的 task_struct 中取出之前保存的内核栈顶,恢复 esp。

从中断栈中弹出用户态的执行环境(包括 eip、cs、eflags、esp、ss)。

执行 iret 指令,返回到用户态继续执行该进程被中断前的指令。

本文代码参考自https://github.com/xukanshan/the_truth_of_operationg_system

相关推荐
Qt程序员2 小时前
网络 I/O 面试必考点:从多进程多线程到异步 I/O 与多路复用
linux·网络编程·多线程·epoll·网络io·阻塞io·io_uring
model20052 小时前
虚拟环境安装yolo26
linux·运维·服务器
Cat_Rocky3 小时前
keepalived简单配置
linux·运维·服务器
开开心心就好3 小时前
一键扫描电脑重复文件的实用工具
linux·运维·服务器·随机森林·智能手机·excel·启发式算法
charlie1145141913 小时前
AwesomeQt:最小的Qt6系列迷你版本教程发布!
linux·c++·qt·c
嵌入式×边缘AI:打怪升级日志4 小时前
Tina SDK Linux Kernel 基本使用(实战篇:为开发板添加用户按键驱动支持)
linux·运维·服务器
瞎折腾啥啊4 小时前
VCPKG详细使用教程
linux·c++·cmake·cmakelists
爱莉希雅&&&4 小时前
MySQL MGR + MySQL Router 高可用集群完整笔记(含手动配置 + Shell 接管双路线)
linux·数据库·笔记·mysql·mysqlrouter·mysqlshell
楼田莉子5 小时前
仿Muduo的高并发服务器:LoopThread模块及其ThreadPool模块
linux·服务器·c++·后端·学习