Talk is c|

宇星海

园龄:2年4个月粉丝:0关注:6

2025-02-10 21:14阅读: 9评论: 0推荐: 0

《操作系统真相还原》实验记录2.8——用户进程的创建及切换运行实现

零、文章说明

  1. 本系列文章为阅读《操作系统真相还原》书籍笔记,因此文章内容绝大多数均为摘抄,会存在上下文不连通及内容跳跃的情况。
  2. 如想一同制作操作系统,请跟随原书籍或【本人项目笔记+GitHub:-HC-OS-操作系统设计项目】,方才能保证制作及【理论+代码】的连续性。
  3. Github项目中包含【代码 + 各部分运作细致流程图】,感兴趣读者请移步:GitHub:-HC-OS-操作系统设计项目

一、为什么要有任务状态段TSS【Task State Segment】

1.1 多任务的起源

  1. 操作系统毕竟只是软件,能做的事实在有限,在软件层面实现的多任务调度有点类似今天的用户态多线程,效率不高且安全性上有诸多问题,于是向 CPU 厂商提了这个需求,希望硬件给予多任务的原生支持。 硬件厂商为此提供了硬件解决方案,其中最主要的就是 LDT 和 TSS。

1.2 TSS 的作用

  1. CPU 执行任务时,需要把任务运行所需要的数据加载到寄存器、栈和内存中,因为 CPU 只能直接处理这些资源中的数据。内存中的数据往往被加载到高速的寄存器后再被CPU处理,处理完成后,再将结果回写到低速的内存中,所以,任何时候,寄存器中的内容才是任务的最新状态。采取轮流使用 CPU 的方式运行多任务,当前任务在被换下 CPU 时,任务的最新状态,也就是寄存器中的内容应该找个地方保存起来,以便下次重新将此任务调度到 CPU 上时可以恢复此任务的最新状态,这样任务才能继续执行,否则就出错了
  2. 因此,Intel 的建议是给每个任务“关联”一个任务状态段,这就是 TSS(Task State Segment),用它来表示任务
    1. 之所以称为“关联”,是因为 TSS 是由程序员“提供”的,由 CPU 来“维护”。
    2. “提供”就是指 TSS 是程序员为任务单独定义的一个结构体变量
    3. “维护”是指 CPU 自动用此结构体变量保存任务的状态(任务的上下文环境,寄存器组的值)和自动从此结构体变量中载入任务的状态。
  3. 当加载新任务时,CPU 自动把当前任务(旧任务)的状态存入当前任务的TSS,然后将新任务 TSS 中的数据载入到对应的寄存器中,这就实现了任务切换。TSS 就是任务的代表,CPU 用不同的 TSS 区分不同的任务,因此任务切换的本质就是 TSS 的换来换去
    1. 在 CPU 中有一个专门存储 TSS 信息的寄存器,这就是 TR 寄存器,它始终指向当前正在运行的任务,因此,“在 CPU 眼里”,任务切换的实质就是 TR 寄存器指向不同的TSS。
  4. TSS 和其他段一样,本质上是一片存储数据的内存区域,Intel 打算用这片内存区域保存任务的最新状态(也就是任务运行时占用的寄存器组等),因此它也像其他段那样,需要用某个描述符结构来“描述”它,这就是 TSS 描述符,TSS 描述符也要在 GDT 中注册,这样才能“找到它”。
    TSS描述符格式
  5. TSS描述符格式介绍
    1. TSS 描述符属于系统段描述符,因此 S 为 0,在 S 为 0 的情况下,TYPE 的值为10B1。我们这里关注一下 B 位,B 表示busy 位,B 位为 0 时,表示任务不繁忙,B 位为 1 时,表示任务繁忙
    2. 什么是任务繁忙?
      1. 任务繁忙有两方面的含义,一方面就是指此任务是否为当前正在 CPU 上运行的任务。
      2. 另一方面是指此任务嵌套调用了新的任务,CPU 正在执行新任务,此任务暂时挂起,等新任务执行完成后 CPU 会回到此任务继续执行,所以此任务马上就会被调度执行了。这种有嵌套调用关系的任务数不只两个,可以很多,比如任务 A 调用了任务 A.1,任务 A.1 又调用了任务 A.1.1 等,为维护这种嵌套调用的关联,CPU 把新任务 TSS 中的 B 位置为 1,并且在新任务的 TSS 中保存了上一级旧任务的 TSS 指针(还要把新任务标志寄存器 eflags 中 NT(任务嵌套Nest Tast) 位的值置为1),新老任务的调用关系形成了调用关系链。
    3. 当任务刚被创建时,此时尚未上 CPU 执行,因此,此时的 B 位为0,TYPE 的值为1001。当任务开始上 CPU 执行时,处理器自动地把 B 位置为1,此时 TYPE 的值为1011。当任务被换下 CPU 时,处理器把 B 位置 0。注意,B 位是由 CPU 来维护的,不需要咱们人工干预。
    4. B 位存在的意义可不是单纯为了表示任务忙不忙,而是为了给当前任务打个标记,目的是避免当前任务调用自己,也就是说任务是不可重入的。不可重入的意思是当前任务只能调用其他任务,不能自己调用自己。
      1. 原因是如果任务可以自我调用的话就混乱了,由于旧任务和新任务是同一个,首先 CPU 进行任务状态保护时,在同一个 TSS 中保存后再载入,这将导致严重错误
      2. 其次,旧任务在调用新任务时,新任务执行完成后,为了能够回到旧任务,在调用新任务之初,CPU 会自动把老任务的 TSS 选择子写入到新任务 TSS 中的 “上一个任务的 TSS 指针” 字段中(后面在任务切换时会讨论),此指针形成了一个任务嵌套调用链,CPU 是靠此指针形成的链表来维护任务调用链的。如果任务重入的话,此链则被破坏(形成了一个闭环)。
      3. 为避免这种情况的发生,CPU 利用 B 位来判断被调用的任务是否是当前任务,若被调用任务的 B 位为 1,这就表示当前任务自己在调用自己。因此,B 位主要是用来给 CPU 做重入判断用的。
      4. 注意,并不是只有当前任务的 B 位才为1,那些被当前任务通过 call 指令嵌套调用的新任务,除了其 TSS 的 B 位会被置为1 以外,老任务 TSS 的 B 位不会被清 0,而是继续保持为 1。因为 call 指令是“有去有回”的指令,它执行新任务后还需要再回来,新任务属于当前任务(老任务)的分支。老任务由于未执行完,相当于被自己调用的新任务中断了,因此原任务 TSS 中的 B 位依然保持为 1,并不会被置为 0。
  6. 任务是单独的个体,因此每个任务都拥有自己的 TSS。当然,这只是 Intel 这么设想的,现代操作系统为了效率问题,一般并不这么做
  7. TSS 同其他普通段一样,是位于内存中的区域,因此可以把 TSS 理解为 TSS 段,只不过 TSS 中的数据并不像其他普通段那样散乱,TSS 中的数据是按照固定格式来存储的,所以 TSS 是个数据结构。
    TSS结构
  8. TSS中的字段基本上全是寄存器名称,这些寄存器就是任务运行中的最新状态。这就像拍照片一样,按下快门的一瞬间,胶片上记录的是事物当时的最新状态,因此也称为快照。可见 TSS 的主要作用就是保存任务的快照,也就是 CPU 执行该任务时,寄存器当时的瞬时值
  9. Linux 只用到了 0 特权级和 3 特权级,用户进程处于 3 特权级,内核位于 0 特权级,因此对于Linux来说只需要在 TSS 中设置SS0 和 esp0,咱们也效仿它,只设置 SS0 和 esp0 的值就够了
  10. TSS 是 CPU 原生支持的数据结构,因此 CPU 能够直接、正确识别其中的所有字段。当任务被换下 CPU 时,CPU 会自动将当前寄存器中的值存储到 TSS 中的对应位置,当有新任务上 CPU 运行时,CPU 会自动从新任务的 TSS 中找到相应的寄存器值加载到对应的寄存器中。
  11. 和 LDT 一样,CPU 对 TSS 的处理也采取了类似的方式,它提供了一个寄存器来存储 TSS 的起始地址及偏移大小。但也许让人有点意外,这个寄存器不叫TSSR,而是称为 TR(Task Register)。
    TR寄存器的结构
  12. TSS 和 LDT 一样,必须要在 GDT 中注册才行,这也是为了在引用描述符的阶段做安全检查。因此 TSS 是通过选择子来访问的,将tss 加载到寄存器 TR 的指令是ltr,其指令格式为:ltr “16 位通用寄存器”或“16 位内存单元
    TSS和GDT、LDT的全景图

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

  1. 上节中,我们介绍了 CPU 提供的多任务支持,每个任务拥有自己的 TSS,每个任务也可以有自己的 LDT,看样子还是很简洁的,但为什么 Linux 没有采用此方式呢?
    1. 首先,在上一节中,我们用“call 0x0018:0x1234”举例说明了通过 call + TSS选择子的形式进行任务切换的过程。您看,此过程大概分成 10 步,这还是直接用 TSS 选择子进行任务切换的步骤,这已经非常繁琐了,在每一次任务切换过程中,CPU 除了做特权级检查外,还要在 TSS 的加载、保存、设置 B 位,以及设置标志寄存器 eflags 的 NT 位诸多方面消耗很多精力,这导致此种切换方式效能很低
      1. 虽然 Intel 提供了 call 和 jmp 指令实现任务切换,但这两个复杂指令集中的指令所消耗的时钟周期也是可观的。
      2. 一个任务需要单独关联一个TSS,TSS 需要在 GDT 中注册,GDT 中最多支持 8192 个描述符,为了支持更多的任务,随着任务的增减,要及时修改GDT,在其中增减 TSS 描述符,修改过后还要重新加载GDT。这种频繁修改描述符表的操作还是很消耗 CPU 资源的。
    2. 以上是效率方面的原因,除了效率以外,还有便携性和灵活性等原因,不仅 Linux 未采用这种原生的任务切换方法,而且几乎所有 x86 操作系统都未采用。
    3. 导致转移到更高特权级的一种情况是在用户模式下发生中断,CPU 会由低特权级进入高特权级,这会发生堆栈的切换。当一个中断发生在用户模式(特权级3),处理器从当前 TSS 的 SS0 和 esp0 成员中获取用于处理中断的堆栈。因此,我们必须创建一个TSS,并且至少初始化 TSS 中的这些字段。
    4. 【重点结论】:我们使用 TSS 唯一的理由是为 0 特权级的任务提供栈。
  2. 咱们是效仿 Linux 的任务切换方法的,那有必要看看 Linux 是怎样做的。
    1. Linux 为每个 CPU 创建一个TSS,在各个 CPU 上的所有任务共享同一个TSS,各 CPU 的 TR 寄存器保存各 CPU 上的TSS,在用 ltr 指令加载 TSS 后,该 TR 寄存器永远指向同一个TSS,之后再也不会重新加载TSS。在进程切换时,只需要把 TSS 中的 SS0 及 esp0 更新为新任务的内核栈的段地址及栈指针
    2. Linux 在 TSS 中只初始化了SS0、esp0 和 I/O 位图字段,除此之外 TSS 便没用了,就是个空架子,不再做保存任务状态之用。那任务的状态信息保存在哪里呢?
    3. 当 CPU 由低特权级进入高特权级时,CPU 会“自动”从 TSS 中获取对应高特权级的栈指针(TSS 是 CPU 内部框架原生支持的嘛,当然是自动从中获取新的栈指针)。我们具体说一下,Linux 只用到了特权 3 级和特权 0 级,因此 CPU 从 3 特权级的用户态进入 0 特权级的内核态时(比如从用户进程进入中断),CPU 自动从当前任务的 TSS 中获取 SS0 和 esp0 字段的值作为 0 特权级的栈,然后Linux“手动”执行一系列的 push 指令将任务的状态保存在 0 特权级栈中,也就是 TSS 中 SS0 和 esp0 所指向的栈

二、定义并初始化TSS【已完成,见项目代码】

  1. 需要注意的一点是:线程的实现机制位于内核中,即进程的身份证——PCB位于内核空间。
    tss.c
#include "tss.h"
#include "stdint.h"
#include "print.h"
#include "global.h"
#include "thread.h"
#include "string.h"
#include "memory.h"

/*the struct of 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 idt;
    uint32_t trace;
    uint32_t io_base;
};
static struct tss tss;

void update_tss_esp0(struct task_struct* pthread) {
    tss.esp0 = (uint32_t*)((uint32_t)pthread + PG_SIZE);
}

/*create GDT's descriptor*/
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;
}

/*create TSS descriptor in GDT and reload GDT use lgdt*/
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; //it indicate don't have I/O bitmap.

    /*GDT's segement base adress is 0x900, we set TSS descriptor to the forth position in GDT.*/
    *((struct gdt_desc*)0xc0000920) = make_gdt_desc((uint32_t*)&tss, tss_size - 1, TSS_ATTR_LOW, TSS_ATTR_HIGH);

    /*create user's descriptors about data segement and code segement.*/
    *((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);

    /*lgdt's structure:  `lgdt (16bit limit) (32bit GDT's segement base address)`*/
    uint64_t lgdt_operand = ((8 * 7 - 1) | ((uint64_t)(uint32_t)0xc0000900 << 16));  // " | " is the operator of "or".

    asm volatile ("lgdt %0" : : "m"(lgdt_operand));
    asm volatile ("ltr %w0" : : "r"(SELECTOR_TSS));
    put_str("tss_init and ltr done\n");
}

三、实现用户进程

3.1 实现用户进程的原理

  1. 如果要基于线程实现进程,我们把 function 替换为创建进程的新函数就可以,先把控制权拿到手再说,进程相关的具体工作再由新函数完成。
    线程的创建流程

3.2 用户进程的虚拟地址空间

  1. 进程与内核线程最大的区别是进程有单独的 4GB 空间,这指的是虚拟地址。
  2. 【重点注意】与各个进程相关的数据,如果数据量不大的话,最好是存储在该进程的 PCB 中,这样便于管理。在上一节中提到,进程是基于线程实现的,因此它和线程一样使用相同的 PCB 结构,即 struct task_struct,我们要做的就是在此结构中增加一个成员——虚拟内存池manager,用它来跟踪用户虚拟地址空间的分配情况

3.3 【重点创建工作】为进程创建页表和 3 特权级栈

  1. 进程与线程的区别是进程拥有独立的地址空间,不同的地址空间就是不同的页表,因此我们在创建进程的过程中需要为每个进程单独创建一个页表我们这里所说的页表包含“页目录表+页表”,页目录表用来存放页目录项PDE,每个 PDE 又指向不同的页表。页表虽然用于管理内存,但它本身也要用内存来存储,所以要为每个进程单独申请存储页目录项及页表项的虚拟内存页。
  2. 大多数情况下,用户进程在特权级 3 下工作,因此,我们还要为用户进程创建在 3 特权级的栈。栈也是内存区域,所以,创建进程过程中,咱们还得为进程分配内存(虚拟内存)作为 3 级栈空间注意:0级栈空间即为该进程PCB中的栈空间】。

3.4 进入特权级 3

  1. 到目前为止我们都工作在 0 特权级下,如何从特权级 0 迈向特权级 3 呢?
    1. 一般情况下,CPU 不允许从高特权级转向低特权级,除非是从中断和调用门返回的情况。
    2. 咱们进入特权级 3 只能借助从中断返回的方式,我们可以骗过 CPU,在用户进程运行之前,使其以为我们在中断处理环境中,这样便“假装”从中断返回。
    3. 从大体上来看,首先得在特权级 0 的环境中,其次是执行 iretd 指令。
    4. 关键点1:从中断返回,必须要经过kernel.S文件中的 intr_exit() 函数,即使是“假装”。
    5. 关键点2:必须提前准备好用户进程所用的栈结构,在里面填装好用户进程的上下文信息,借一系列 pop 出栈的机会,将用户进程的上下文信息载入 CPU 的寄存器,为用户进程的运行准备好环境
    6. 关键点3:我们要在栈中存储的 CS 选择子,其 RPL 必须为 3 。
    7. 关键点4:栈中段寄存器的选择子必须指向 DPL 为 3 的内存段。
    8. 关键点5:必须使栈中 eflags 的 IF 位为 1 ,这样才可进入中断。
    9. 关键点6:必须使栈中 eflags 的 IOPL 位为 0 ,对于 IO 操作,不允许用户进程直接访问硬件,只允许操作系统有直接的硬件控制。
  2. CPU 是如何知道从中断退出后要进入哪个特权级呢?
    1. 这是由栈中保存的 CS 选择子中的 RPL 决定的,我们知道,CS.RPL 就是 CPU 的 CPL,当执行 iretd 时,在栈中保存的 CS 选择子要被加载到代码段寄存器 CS 中,因此栈中 CS 选择子中的 RPL 便是从中断返回后 CPU 的新的CPL。

3.5 用户进程创建的流程

  1. 本节先从全局做进程创建的流程介绍。
    进程的创建流程

3.6 实现用户进程—上

  1. 用户进程在执行前,是由操作系统的程序加载器将用户程序从文件系统读到内存,再根据程序文件的格式解析其内容,将程序中的段展开到相应的内存地址。程序格式中会记录程序的入口地址,CPU 把 CS:[ E ]IP 指向它,该程序就被执行了。
  2. struct intr_stack 栈用来存储进入中断时任务的上下文,struct thread_stack 用来存储在中断处理程序中、任务切换(switch_to)前后的上下文。
  3. 但在用户进程实现中,struct intr_stack 栈有两个作用,一方面是任务被中断时,用来保存任务的上下文,另一方面这是为了给进程预留的,用来填充用户进程的上下文,也就是寄存器环境
    PCB中的栈布局——a
  4. EFLAGS中的属性位示意图
    EFLAGS中的属性位示意图
  5. C程序的内存布局
    C程序的内存布局
  6. 内存管理模块返回的地址是内存空间的下边界,所以我们为用户 3 级栈申请的地址应该是(0xc0000000-0x1000),此地址是用户栈空间的下边界。这里我们用宏来定义此地址,即USER_STACK3_VADDR。
  7. proc_stack 的数据类型是 struct intr_stack,虽然咱们把它定义在 PCB 中,位于 PCB 中最顶端,但它完全可以用局部变量代替因为它只用这一次,之后不需要再次访问。故可以在函数 start_process 中这样声明 proc_stack:struct intr_stackproc_stack;,后面的填充操作完全一样,而且也用不着执行第 17~18 行的代码了。不过,既然此时 PCB 顶端的 struct intr_stack 空间还空着,不用就浪费了,因此我们使用上述空余的空间。
  8. 【重点注意】
    1. 每个进程都拥有独立的虚拟地址空间,本质上就是各个进程都有单独的页表,页表是存储在页表寄存器 CR3 中的,CR3 寄存器只有 1 个,因此,不同的进程在执行前,我们要在 CR3 寄存器中为其换上与之配套的页表,从而实现了虚拟地址空间的隔离
    2. 控制寄存器CR3用于存储页目录表物理地址,所以CR3寄存器又称为页目录基址寄存器(Page Directory Base Register,PDBR)
      CR3寄存器结构

3.7 bss 简介

  1. 我们要实现用户进程的堆内存管理,现在就要着手规划用户进程内存空间布局的基础工作,而 bss 的内容,涉及到堆内存管理的实现。
  2. 用户进程的内存空间布局我们也参照着 Linux 下 C 程序的布局方案来做。
    C程序的内存布局
  3. 在 C 程序的内存空间中,位于低处的三个段是代码段、数据段和 bss 段,它们由编译器和链接器规划地址空间,在程序被操作系统加载之前它们地址就固定了
  4. 堆位于 bss 段的上面,栈位于堆的上面,它们共享 3GB 空间中除了代码段、数据段及顶端命令行参数和环境变量等以外的其余可用空间,它们的地址由操作系统来管理,在程序加载时为用户进程分配栈空间,运行过程中为进程从堆中分配内存。堆向上扩展,栈向下扩展,因此在程序的加载之初,操作系统必须为堆和栈分别指定起始地址
  5. C 程序大体上分为预处理、编译、汇编和链接四个阶段。根据语法规则,编译器会在汇编阶段将汇编程序源码中的关键字 section 或 segment编译成节,也就是之前介绍的 section,此时只是生成了目标文件,目标文件中的这些节还不是程序空间中的独立的代码段或数据段,或者说仅仅是代码段或数据段的一部分。链接器将这些目标文件中属性相同的节(section)合并成段(segment),因此一个段是由多个节组成的,我们平时所说的 C 程序内存空间中的数据段、代码段就是指合并后的 segment。
    1. 汇编源码中,通常用语法关键字 section 或 segment 来逻辑地划分程序区域,虽然很多书中都称此程序区域为段,但实际上它们就是节section。注意,这里所说的 segment 和 section 是指汇编语法中的关键字,仅指它们在汇编代码中的语法意义相同,并不是指编译、链接中的 section 和 segment。
    2. 为什么要将 section 合并成 segment ?这么做的原因也很简单,一是为了保护模式下的安全检查,二是为了操作系统在加载程序时省事。
  6. 在操作系统的视角中,它只关心程序中这些节的属性是什么,以便加载程序时为其分配不同的段选择子,从而使程序内存指向不同的段描述符,起到保护内存的作用。因此最好是链接器把目标文件中属性相同的节合并到一起,这样操作系统便可统一为其分配内存了。按照属性来划分节,大致上有三种类型:
    1. 可读写的数据,如数据节 .data 和未初始化节 .bss
    2. 只读可执行的代码,如代码节 .text 和初始化代码节 .init
    3. 只读数据,如只读数据节.rodata,一般情况下字符串就存储在此节
    4. 经过这样的划分,所有节都可归并到以上三种之一,这样方便了操作系统加载程序时的内存分配。由链接器把目标文件中相同属性的节归并之后的节的集合,便称为segment,它存在于二进制可执行文件中,也就是 C 程序运行时内存空间中分布的代码段、数据段等段
    5. text 段是代码段,里面存放的是程序的指令,data 段是数据段,里面存放的是程序运行时的数据,它们的共同点是都存在于程序文件中,也就是在文件系统中存在,原因很简单,它们是程序运行必备的“材料”,必须提前准备好,基本上是固定好的内容。
  7. bss 并不存在于程序文件中,它仅存在于内存中,其实际内容是在程序运行过程中才产生的,起初并无意义,换句话说,程序在运行时的那一瞬间并不需要 bss,因此完全不需要事先在程序文件中存在,程序文件中仅在 elf 头中有 bss 节的虚拟地址、大小等相关记录,这通常是由链接器来处理的,对程序运行并不重要,因此程序文件中并不存在 bss 实体。
  8. bss 中的数据是未初始化的全局变量和局部静态变量,程序运行后才会为它们赋值,因此在程序运行之初,里面的数据没意义,由操作系统的程序加载器将其置为 0 就可以了,虽然这些未初始化的全局变量及局部静态变量起初是用不上的,但它们毕竟也是变量,即使是短暂的生存周期也要占用内存,必须提前为它们在内存中“占好座”,bss 区域的目的也正在于此,就是提前为这些未初始化数据预留内存空间
  9. 总结一下:未运行之前或运行之初,程序中 bss 中的内容都是未初始化的数据,它们也是变量,只不过这些变量的值在最初时是多少都无所谓,它们的意义是在运行过程中才产生的,故程序文件中无需存在 bss 实体,因此不占用文件大小。在程序运行后那些位于 bss 中的未初始化数据便被赋予了有意义的值,那时 bss 开始变得有意义,故 bss 仅存在于内存中。既然 bss 中的数据也是变量,就肯定要占用内存空间,需要把空间预留出来,但它们并不在文件中存在,对于这种只占内存又不占文件系统空间的数据,链接器采取了合理的做法:由于 bss 中的内容是变量,其属性为可读写,这和数据段属性一致,故链接器将 bss 占用的内存空间大小合并到数据段占用的内存中,这样便在数据段中预留出 bss 的空间以供程序在将来运行时使用注意,这里所说的是 bss 的尺寸会被合并到数据段,并不是 bss 中的实际内容也会被合并到数据段中,毕竟起初 bss 中的内容无意义,将它的内容合并到其他段中“毫无意义”。当程序文件被操作系统加载器加载时,加载器会为程序的各个段分配内存,由于 bss 已被归并到数据段中,故bss仅存在于数据段所在的内存中。因此, bss 的作用就是为程序运行过程中使用的未初始化数据变量提前预留了内存空间。程序的 bss 段(数据段的一部分)会由该加载器填充为0。由此可见,为生成在某操作系统下运行的用户程序,编译器和操作系统需要相互配合。
  10. 用户程序的装载本质上就是将用户程序中类型为LOAD 的段拷贝到指定的内存地址
  11. 介绍bss,是为了要介绍堆的实现。在C语言中,函数 malloc 用来动态申请内存,所谓的动态内存申请,是指程序在运行中申请的内存,并不是在程序加载时由操作系统加载器为程序段分配的“固定、静态”内存,这种动态申请的内存就是操作系统从申请者自己的堆中分配的。
  12. 综上所述,我们不需要单独知道 bss 的结束地址,只要知道数据段的起始地址及大小,便可以确定堆的起始地址。
  13. 有关程序加载的内容,将来我们实现了从文件系统中加载运行用户程序时就更清楚了。
  14. 目前我们只要了解虽然堆的起始地址应该在 bss 之上,但由于 bss 已融合到数据段中,要实现用户进程的堆,已不需要知道 bss 的结束地址,将来咱们加载程序时会获取程序段的起始地址及大小,因此只要确保堆的起始地址在用户进程地址最高的段之上就可以了。

3.8 实现用户进程—下

操作系统被所有用户进程共享
任意进程的页表及物理页之间的映射关系

  1. 目的:在用户进程中能够访问到内核
  2. 方法
    1. 为每一个用户进程准备一份内核的符号链接(软链接)
    2. 符号链接
      1. 对于用户来说,文件是通过文件名来访问的,可以把文件名理解为存储在磁盘上的文件实体的访问入口
      2. 符号链接是为同一个文件实体多创建了一个访问入口,相当于为原文件起个别名,就像人有大名和小名,都是指同一个人。
  3. 页表是记录在页目录项中,此处页目录项对于内核物理内存的作用,相当于 Linux 中文件的符号链接,页目录项是访问内核所在物理内存的入口。因此,为了访问到内核,我们只要给每个用户进程创建访问内核的入口即可,即各进程共享第768个及以上的页目录项。

3.9 让进程跑起来—用户进程的调度

  1. 在这之前,我们已经将进程创建好,并且添加到就绪队列中了,不管任务是线程,还是进程,目前的任务调度器 schedule 一律按内核线程来处理。内核线程是 0 特权级,并且它使用内核的页表,这与进程的区别很大,进程的特权级是 3,并且有自己单独的页表,因此我们需要改进调度器,增加对进程的处理。

3.10 测试用户进程

  1. 用户进程的创建是由函数 process_execute() 完成的,本节咱们将在 main.c 中调用它,创建两个用户进程。
  2. 若把咱们所说的用户态和内核态的概念转换为处理器能够识别的东西,那就是特权级变化和特权级检查。处理器关注的是只要处理器的当前特权级 CPL 为3,就不能访问比它特权级更高(数值上DPL < CPL)的内存。尽管u_prog_a本身是在内核地址空间,处理器用的是进程页目录表中指向内核空间的第 768 个页目录项对应的页表来访问的 u_prog_a 及变量 test_var_a,而且页表中低 3GB 的空间没用上,但这和用户态、内核态无关。当名为“u_prog_a”的用户进程运行后,它的特权级为3,它拥有自己独立的页表,并且通过准备好的 DPL 为3的段描述符访问内存,这完全符合用户进程的特征和行为,而且最最重要的是我们早已把所有页目录项和页表项的 US 位都置为1(loader.S 和memory.c 中所有涉及到 PDE 和 PTE 的地方都用的是PG_US_U),这表示处理器允许所有特权级的任务访问目录项或页表项指向的内存,所以用内核空间中的函数来模拟用户进程是没有问题的。
    1. 提醒一下,如果此时将 US 置为 0 的话咱们就不能用内核函数来模拟用户进程了,处理器会抛出 page_fault 异常,就是缺页异常,并不是一般保护性异常(GP 异常)。

本文作者:宇星海

本文链接:https://www.cnblogs.com/Yu-Xing-Hai/p/18708801/User-process

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   宇星海  阅读(9)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起