OS_lab8
从内核态到用户态
实验概述
在本章中,我们首先会简单讨论保护模式下的特权级的相关内容。特权级保护是保护模式的特点之一,通过特权级保护,我们区分了内核态和用户态,从而限制用户态的代码对特权指令的使用或对资源的访问等。但是,用户态的代码有时不得不使用一些特权指令,如输入输出等。因此,我们介绍了系统调用的概念和如何通过中断来实现系统调用。通过系统调用,我们可以实现从用户态到内核态转移,然后在内核态下执行特权指令等,执行完成后返回到用户态。在实现了系统调用后,我们通过三步来创建了进程。这里,我们需要重点理解我们是如何通过分页机制来实现进程之间的虚拟地址空间的隔离。最后,我们介绍了fork/wait/exit的一种简洁的实现思路。
ddl:6月15日
实验任务
Assignment 1 系统调用
编写一个系统调用,然后在进程中调用之,根据结果回答以下问题。
- 展现系统调用执行结果的正确性,结果截图并说说你的实现思路。
- 分析执行系统调用后的栈的变化情况。
- 说明TSS在系统调用执行过程中的作用
实现getpid():
实现思路是通过调用getpid,然后去到PCB中获取当前正在运行的进程,获取对应的pid然后返回。
1 |
|
从运行结果中可以看到,我们成功地调用了getpid系统调用,然后成功进入到了内核态,并从PCB获取pid返回。
执行系统调用后的栈的变化情况:
设置断点:
看到在调用getpid之前,esp保存的是0x8048fd4,这是用户栈的位置;eax等都是0
ss保存的是0x3b,cs保存的是0x2b,eflags保存的是0x296
cs=0010 1011
特权级CPL刨保存在cs的低2位上,也就是二进制:(11)–十进制(3)
说明我们现在处在用户态中。
调用getpid之后,我们会进入到内核态
这里esp的位置就是内核栈的位置
这里的cs=0x20,CRL=0,说明我们确实在内核态中。
单步进入,我们可以看到我们的栈往上移动了0x10,仍然处于内核态中。
TSS的功能:
目前TSS的作用仅限于为CPU提供0特权级栈所在的地址和段选择子(只用到esp0和ss0)
CPU从TSS中取出高特权级的段选择子和栈指针,分别送入ss和esp。
CPU通过读取TR寄存器中TSS的地址,从而读取到TSS的内容
设置断点,然后查看tss的内容
变化后:发现esp0的值是内核栈的地址空间。
说明tss的作用就是将0特权级的栈取出来送到ss和esp。
Assignment 2 Fork的奥秘
实现fork函数,并回答以下问题。
- 请根据代码逻辑和执行结果来分析fork实现的基本思路。
- 从子进程第一次被调度执行时开始,逐步跟踪子进程的执行流程一直到子进程从
fork
返回,根据gdb来分析子进程的跳转地址、数据寄存器和段寄存器的变化。同时,比较上述过程和父进程执行完ProgramManager::fork
后的返回过程的异同。 - 请根据代码逻辑和gdb来解释fork是如何保证子进程的
fork
返回值是0,而父进程的fork
返回值是子进程的pid。
需要解决的四个问题:
如何实现父子进程的代码段共享?
进程又划分了3GB~4GB的空间来实现内核共享,进程的代码天然就是共享的如何使得父子进程从相同的返回点开始执行?
通过保存和恢复eip
以及复制用户态栈,实现父子进程从同一返回点继续执行。
实现机制:
- 父进程暂停与eip保存。
- 在
ProgramStartProcess
中保存了父进程的eip
,该eip
实际上是asm_system_call_handler
的返回地址。
- 在
- 子进程eip恢复与跳转
- 创建子进程后,通过
asm_start_process
启动子进程。 asm_start_process
的最后会执行iret
指令,将0特权级栈中保存的eip
(也就是父进程暂停时的返回地址)装载到子进程的eip
中。- 这样,子进程会从与父进程相同的返回点(即
asm_system_call_handler
的返回地址)开始执行。
- 创建子进程后,通过
- 逐步返回与栈复制
- 后续子进程会依次返回到
asm_start_process
、asm_system_call
,最终回到fork
的调用点。 - 因为会复制父进程的3特权级(用户态)栈到子进程,3特权级栈中保存了父进程在执行
int 0x80
软中断后的逐步返回地址。 - 因此,父子进程的逐步返回地址完全一致。
- 后续子进程会依次返回到
除代码段外,进程包含的资源有哪些?
进程包含的资源有0特权级栈,PCB、虚拟地址池、页目录表、页表及其指向的物理页。如何实现进程的资源在进程之间的复制?
借助于内核空间的中转页。
首先在父进程的虚拟地址空间下将数据复制到中转页中,再切换到子进程的虚拟地址空间中,然后将中转页复制到子进程对应的位置。
(1)fork实现的基本思路
见上文&后文
(2)使用gdb跟踪逻辑
![[file-20250531234900823.png]]
![[file-20250531235408725.png]]
![[file-20250531235424121.png]]
这里处于用户态CRL=3
![[file-20250531235504479.png]]
准备进入系统调用,这里将会调用进程管理器的fork函数
![[file-20250531235612819.png]]
![[file-20250531235624684.png]]
从地址空间和CRL=0,可以看出进入了内核态。
这里准备进入复制函数
![[file-20250531235745309.png]]
复制进程中会复制0级栈,同时手动将子进程中的寄存器eax设置为0,同时设置子进程的PCB(大部分复制父进程)
复制用户虚拟地址池(与父进程共用)
复制父进程页表
fork返回时,父进程的eax会被设置为子进程的pid
iret指令完成了:
- 从栈中弹出返回地址,将其放入程序计数器和代码段寄存器
- 如果CPU在保护模式下运行,会从栈中弹出EFLAGS的内容,将其恢复到标志寄存器中
- 处理优先级切换。
(3)怎么保证父进程获得子进程pid和子进程返回0
内核空间创建子进程结构:
- fork时,内核会为子进程分配一个新的PCB和栈,并把父进程的大部分上下文复制给子进程
专门设置fork返回值:
在内核空间,fork会有专门的代码逻辑,在父进程的返回路径上把eax设置为子进程的pid,在子进程的返回路径上把eax设置为0。让我们看看代码中如何实现这样子的设置:
我们在copyProcess的时候,有一行:
1 |
|
而我们知道,在调用完函数,我们要获取返回值的时候,是会去读取eax的值。也就是说,如果我们后续没有修改子进程的eax,最后子进程fork返回的也就会是0。(确实我们后面也没有再去修改了)
父进程fork返回的pid,就是executeProcess创建的新子进程的pid,在fork返回时直接返回给父进程。然后内核会把这个返回值放到父进程的eax里。
![[file-20250601202918743.png]]
进入fork前:eax为0
![[file-20250601203229851.png]]
进入了之后,eax被设置为2
![[file-20250601203501156.png]]
![[file-20250601203527506.png]]
这里返回了pid,会保存到父进程的eax中,也就是子进程的pid。
![[file-20250601203702692.png]]
![[file-20250601203817217.png]]
可以看到确实返回的是2
这里我们将会进入到复制进程函数中,这里会设置子进程的eax为0。
![[file-20250601204107707.png]]
设置eax前:可以看到,由于我们直接复制了父进程的内容,会出现eax也跟着复制的情况。
![[file-20250601204607789.png]]
执行之后:
![[file-20250601204443027.png]]
可以看到设置为0了。
如图,确实如此。
![[file-20250601204008354.png]]
Assignment 3 哼哈二将 wait & exit
实现wait函数和exit函数,并回答以下问题。
- 请结合代码逻辑和具体的实例来分析exit的执行过程。
- 请分析进程退出后能够隐式地调用exit和此时的exit返回值是0的原因。
- 请结合代码逻辑和具体的实例来分析wait的执行过程。
- 如果一个父进程先于子进程退出,那么子进程在退出之前会被称为孤儿进程。子进程在退出后,从状态被标记为
DEAD
开始到被回收,子进程会被称为僵尸进程。请分析src/6代码实例中,实现回收僵尸进程的有效方法。
exit的实现原理:
结合代码分析:
直接看教程
结束一个进程只需要释放掉其所占用的物理页内存和占用的虚拟地址池空间,只保留一个PCB并将进程状态设置为0.
exit的实现实际上是通过ProgramManager::exit
来完成的,总的来看,exit的实现主要分为三步。
- 标记PCB状态为
DEAD
并放入返回值。 - 如果PCB标识的是进程,则释放进程所占用的物理页、页表、页目录表和虚拟地址池bitmap的空间。否则不做处理。
- 立即执行线程/进程调度。
wait的实现原理:
分析进程退出后能隐式调用exit和此时的exit返回值是0的原因:
隐式调用exit:没有显示的调用exit()的时候,操作系统会自动的在返回时调用。
实现方法是:在进程的3特权级栈的顶部放入exit的地址和参数即可,当执行进程的函数退出后会主动跳转到exit。
1 |
|
这段代码中,我们将3特权级栈中的栈顶处userStack[0]放入exit的地址,然后CPU会认为userStack[1]是exit的返回地址,userStack[2]是exit的参数。
请结合代码逻辑和具体的实例来分析wait的执行过程:
看教程
如果一个父进程先于子进程退出,那么子进程在退出之前会被称为孤儿进程。子进程在退出后,从状态被标记为DEAD
开始到被回收,子进程会被称为僵尸进程。请分析src/6代码实例中,实现回收僵尸进程的有效方法。
生成僵尸进程:进程exit退出时,进程状态被标记为dead,退出返回值保存在PCB中,物理内存资源被释放,但是PCB本身并未被释放,需要等待父进程回收。
- 进程退出时变成僵尸进程,保留PCB和退出状态
- 父进程通过wait()系统调用主动回收僵尸子进程
- 循环等待机制确保所有子进程都被回收
- 资源管理策略分两阶段:先释放内存,后释放PCB
wait系统调用主动回收僵尸子进程:
1 |
|
实验知识
特权级
CPU的特权级分4个:0,1,2,3。(特权级依次降低)
- 本次实验中使用0和3;
- 把CPU处在特权级0的状态称为内核态,把CPU处在特权级3的称为用户态
内核态和用户态的划分是对程序访问资源的限制
一些概念:
- RPL,Request Privilege Level,段选择子的低2位所表示的值。
- CPL,Current Privilege Level,在CS寄存器中的段选择子的RPL,CPL标识了CPU当前的特权级。
- DPL,Descriptor Privilege Level,位于每一个段描述符中。
- 一致性代码段,简单理解,就是操作系统拿出来被共享的代码段,是可以被低特权级的用户直接调用访问的代码。在访问前后,特权级不会改变,用户态还是用户态,内核态还是内核态。具有这样的特点的代码段被称为一致性代码段。
- 非一致代码段,为了避免低特权级的访问而被操作系统保护起来的系统代码,只允许同级间访问,绝对禁止不同级访问,核心态不用用户态的资源,用户态也不使用核心态的资源。具有这样特点的代码段被称为非一致代码段。
在访问资源前,CPU会做特权级检查:
- 对于数据段和栈段,进行特权级检查,要求 $DPL \geq max{CPL,RPL}$
- 对于代码段:如果是一致性代码段,要求 $CPL \geq DPL$;对于非一致性代码段,要求$CPL \geq RPL$和$CPL = DPL$
从内核态进入用户态或者从用户态进入内核态,就要进行特权级转移:
- 从低特权到高特权转移:通过中断、调用等方式实现。
- 中断:程序通过使用
int
指令来调用特定中断,然后中断描述符中的代码段选择子被加载到CS寄存器,从而改变CPL
- 中断:程序通过使用
- 从高特权到低特权转移:通过中断返回和调用返回
CPU在不同特权级下会使用不同的栈。
- 当CPU使用中断从低特权级向高特权级转移的时候,CPU首先会从TSS(Task State Segment)中取出高特权级的段选择子和栈指针(即下图的esp0),然后将高特权级栈的段选择子和栈指针送入SS,ESP,最后将中断发生前的SS,ESP,EFLAGS、CS、EIP依次压入高特权级栈。
- TSS只会保存特权级0,1,2的段选择子和栈指针(3是最低的)
- 多任务切换机制下,TSS可以用于暂存任务的状态(但是目前不使用这个机制)
- 目前TSS的作用仅限于为CPU提供0特权级栈所在的地址和段选择子(只用到esp0和ss0)
CPU进制高特权级向低特权级转移,除了中断返回或调用返回。iret
和retf
。
低特权级栈的信息在进入中断前被保存在高特权级栈中,因此执行iret
后,低特权级栈的SS和ESP变可以被恢复。
系统调用的实现
用户态有时候需要使用到I/O指令等,所以这个时候我们需要跳转到内核态去。
通过中断实现系统调用:
- 系统调用时,系统调用的参数通过5个寄存器来传递。(因此参数不能超过5个)
asm_system_call通过汇编实现:
1 |
|
创建一个管理系统调用的类 SystemService
1 |
|
第0个系统调用:打印输入的五个参数,最后返回这五个参数的和。
中断处理函数:
1 |
|
运行结果:
进程的实现
用户进程和内核线程最大的区别在于用户进程有自己的虚拟地址空间,而内核线程使用的是内核虚拟地址空间
用户进程虚拟地址不会和内核冲突是由于映射保存在了每个进程自己的页目录/页表中,是独立的。
用户进程如果要访问内核资源,可以通过高地址映射的方法实现:
- 现有问题:
- 某些情况下,用户需要进入内核中执行,如系统调用等。
- 但是进程自己的虚拟地址并不包含内核内容
- 内核地址和数据存在物理地址的0-1MB处,进程看不到
- 解决办法:高地址映射
- 在每个页表高端3-4GB地方,保留一段虚拟地址,映射到内核实际的物理地址处(0-1MB)
- 这样子在用户空间的高端和内核空间的地址映射到的是同一块物理内存
- 这样,进程在进入内核态时(比如系统调用),可以直接用高地址访问到内核的数据结构(如
programManager
),不会和进程自己的0~1MB冲突。 - 实际上就将第0项和第768项指向同一个页表,就实现了。
同步地,需要修改makefile,保证我们的偏移不会出错
1 |
|
相对 0xc0000000
进行寻址,而不是从0开始
其中,-Ttext
参数本来是0x20000
,现在我们将其提升到3GB的空间。
虽然内核的变量被提升到了3GB以上的空间,但我们实际上加载内核还是加载到0x20000
处,只不过我们通过了分页机制将3GB以上的虚拟地址空间映射到0~1MB的空间。
同时,在跳转到内核之前,要保证正确开启了分页机制,也就是把这部分开启放置在bootloader中
初始化TSS和用户段描述符
在ProgramManager中加入存储3个代码段、数据段和栈段描述符的变量。
1 |
|
进程的运行环境需要用到TSS、特权级3下的平坦模式代码段和数据段描述符
这三个描述符的DPL为3
低特权级->高特权级:
- CPU会先在TSS中找到高特权级栈的段选择子和栈指针,送入SS、ESP
- 栈变成TSS保存的高特权级的栈,同时把低特权级的SS、ESP、EFLAGS、CS、EIP压入高特权级栈保存
TSS结构体:内容不可变更,是CPU规定的
- TSS也是存储数据的内存区域
- 段基址的意思是TSS的起始地址&tss
- 段界限是TSS的实际长度-1
- B为表示任务是否忙,0-不忙,1-忙
TR寄存器:保存TSS描述符的选择子,以便于CPU在发生特权级切换时能自动加载TSS中的内容
TSS的初始化
我们只需要得到高特权级(也就是0特权级)下的栈指针和栈段选择子:ss0和esp0
进程的创建
三步:
- 创建进程的PCB
- 初始化进程的页目录表
- 初始化进程的虚拟地址池
首先是中断保护机制:保存当前中断状态–关中断
1 |
|
基于线程机制创建进程PCB:创建线程
1 |
|
获取新建的PCB:
新建的PCB会放在 allPrograms
链表的末尾
tagInAllList
指定了PCB在全局程序列表中的链表节点字段
1 |
|
进程页目录表:调用函数创建,如果创建失败就将进程标记为dead
1 |
|
创建虚拟地址池:也是调用函数来创建,如果创建失败也将进程标记为dead
1 |
|
最后,恢复中断状态并返回
1 |
|
详细查看创建页目录表:
- 从内核地址池中分配一页存储页目录表
- 复制内核目录项到虚拟地址的高1GB
- 用户进程页目录表的最后一项指向用户进程页目录表本身
ProgramStartStack
来表示启动进程之前栈放入的内容
进程的调度
- src/3
进程的调度只需要在原先的进程的线程调度的基础上加入: - 切换页目录表
- 更新TSS中特权级0的栈
fork()
用于创建一个新进程,两个进程从fork的返回点开始执行。
- 只调用一次fork,但能够返回两次
- 父进程中,fork返回子进程的pid
- 子进程中,fork返回0
- 如果创建失败,fork返回一个负值
父子进程共享代码段,但不共享数据段、栈段等资源。
数据段和栈段等资源会被复制到子进程中。
运行:
得到结果:
可以看到父进程返回了子进程的pid=1343,子进程返回了0
实现fork()
需要解决4个问题:
- 父子进程代码段的共享
- 父子进程从相同的返回点开始执行
- 除代码段外,进程包含的资源有哪些?
- 如何实现进程资源在进程间的复制
PCB中需要添加父进程pid
fork()作为一个系统调用,需要在syscall.h中加入fork()系统调用和系统调用处理函数的定义
同时设置好系统调用
实现基本的函数:
fork这里就相当于调用2号系统调用,然后会进入到内核态,进入内核态之后,需要调用programManager.fork()
来完成后续工作
- 首先保留中断状态,然后关中断
- 禁止内核线程调用,设置父进程状态为运行之后,判断是否存在页目录地址(内核线程没有独立的页目录),通过这个来禁止。
- 创建子进程
- 初始化子进程(涉及到复制进程资源)
bool flag = copyProcess(parent, child);
- 复制进程启动栈
- 设置子进程栈指针
- 拷贝PCB属性
- 复制虚拟地址池
- 准备内存拷贝
- 页目录表复制
- 页表和物理页复制
- 清理和返回
1 |
|
进程包含的资源:
0特权级栈、PCB、虚拟地址池、页目录表、页表极其指向的物理页
exit()
用于进程和线程的主动结束运行
在进程或线程调用exit后,我们会释放其占用的所有资源,只保留PCB。
进程或线程状态标记为DEAD,PCB会由专门的进程or线程来回收
exit的实现实际上是通过ProgramManager::exit
来完成的,总的来看,exit的实现主要分为三步。
- 标记PCB状态为
DEAD
并放入返回值。 - 如果PCB标识的是进程,则释放进程所占用的物理页、页表、页目录表和虚拟地址池bitmap的空间。否则不做处理。
- 立即执行线程/进程调度。
wait()
wait的参数retval
用来存放子进程的返回值,如果retval==nullptr
,则说明父进程不关心子进程的返回值。wait的返回值是被回收的子进程的pid。如果没有子进程,则wait返回-1
。在父进程调用了wait后,如果存在子进程但子进程的状态不是DEAD
,则父进程会被阻塞,即wait不会返回直到子进程结束。