type
status
date
slug
summary
tags
category
icon
password
我们将开发一个用户 终端 (Terminal) 或 命令行 (Command Line Application, 俗称 Shell ) , 形成用户与操作系统进行交互的命令行界面 (Command Line Interface)

进程概念及重要系统调用

进程概念

相比于任务 , 进程 (Process) 的含义是在操作系统管理下的程序的一次执行过程。这里的“程序的”成了形容词,而执行过程成为了主角,这充分体现了动态变化的执行特点
打一个比方,可执行文件本身可以看成一张编译器解析源代码之后总结出的一张记载如何利用各种硬件资源进行一轮生产流程的 蓝图 。而内核的一大功能便是作为一个硬件资源管理器,它可以随时启动一轮生产流程(即执行任意一个应用),这需要选中一张蓝图(此时确定执行哪个可执行文件),接下来就需要内核按照蓝图上所记载的对资源的需求来对应的将各类资源分配给它,让这轮生产流程得以顺利进行。当按照蓝图上的记载生产流程完成(应用退出)之后,内核还需要将对应的硬件资源回收以便后续的重复利用。
因此,进程就是操作系统选取某个可执行文件并对其进行一次动态执行的过程。相比可执行文件,它的动态性主要体现在:
  1. 它是一个过程,从时间上来看有开始也有结束;
  1. 在该过程中对于可执行文件中给出的需求要相应对 硬件/虚拟资源 进行 动态绑定和解绑 。
两个进程执行同一个可执行文件:
启动时间、占据的硬件资源、输入数据均有可能是不同的,这些条件均会导致它们是不一样的执行过程
线程:
线程是进程的一部分,一个进程可以包含一个或多个线程。各个线程之间共享进程的地址空间,但线程要有自己独立的栈(用于函数访问,局部变量等)和独立的控制流。且线程是处理器调度和分派的基本单位
协程:
也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又是比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善

进程模型与重要系统调用

我们只介绍本章实现的内核中采用的一种非常简单的进程模型。这个进程模型有三个运行状态:
  • 就绪态、运行态和等待态;
  • 有基于独立页表的地址空间;
  • 可被操作系统调度来分时占用 CPU 执行;
  • 可以动态创建和退出;
  • 可通过系统调用获得操作系统的服务。

fork 系统调用

PID:process identifier
  • 系统中同一时间存在的每个进程都被一个不同的 进程标识符 (PID, Process Identifier) 所标识
用户初始进程:
  • 内核初始化完毕后创建的进程
  • 由硬编码完成
  • 其他进程都是由fork系统调用创建的
进程A调用fork创建进程B:
  • 首先要进行系统调用,进入内核态
  • 这个进程 B 和调用 fork 的进程A在它们分别返回用户态那一瞬间几乎处于相同的状态:
    • 这意味着它们包含的用户态的代码段、堆栈段及其他数据段的内容完全相同
    • 但是它们是被放在两个独立的地址空间中的
  • 进程B的地址空间是从进程A拷贝的
  • 寄存器也完全相同
    • PC相同,两个进程返回用户态后,下一条指令是一样的
    • sp相同,两个进程的用户栈在各自地址空间的同一位置
    • 其他通用寄存器相同,Trap上下文和任务上下文相同
  • 一个父进程下可以有多个子进程——树
相比创建一个进程, fork 的另一个重要功能是建立一对新的父子关系

waitpid 系统调用

子进程通过系统调用exit退出之后,不能立即回收它所占用的全部系统资源,因为它的内核栈就还正在处理系统调用
处理方法:
当子进程退出之后,只回收一部分资源,然后父进程将其标记为僵尸进程(Zombie Process)
然后调用waitpid系统调用,用来收集该子进程的返回状态并回收掉它所占据的全部资源,这样这个进程才被彻底销毁
父进程先于子进程退出怎么办?
如果一个进程先于它的子进程结束,在它退出的时候,它的所有子进程将成为进程树的根节点——用户初始进程的子进程,同时这些子进程的父进程也会转成用户初始进程。这之后,这些子进程的资源就由用户初始进程负责回收了,这也是用户初始进程很重要的一个用途
sys_waitpid 在用户库中被封装成两个不同的 API,  wait(exit_code: &mut i32)  和  waitpid(pid: usize, exit_code: &mut i32), 前者用于等待任意一个子进程,后者用于等待特定子进程。它们实现的策略是如果子进程还未结束,就以 yield 让出时间片

exec 系统调用

fork 创建的子进程跟父进程几乎一模一样,只有地址空间被分开了,但是这显然是不行的
exec 系统调用就用于让子进程执行跟父进程不一样的可执行文件
  • 清空当前进程的地址空间
  • 加载一个可执行文件
  • 返回用户态开始执行
胖指针:
在 Rust 中,“胖指针”通常是指对指向的数据类型进行了动态调整,因此它不仅包含了指向内存中某个位置的地址,还包含了额外的元数据信息。
最常见的例子是 str 类型。在 Rust 中,str 类型是一个固定长度的指针加一个长度,即 &str 类型。这个长度信息指示了字符串的实际长度,因此被称为“胖指针”。
在这个例子中,&str 实际上是一个包含指向字符串数据的地址以及字符串的长度的结构。
而path也是一个胖指针,在实际进行系统调用的时候,我们只会将起始地址传给内核(对标 C 语言仅会传入一个 char* )
这就需要应用负责在传入的字符串的末尾加上一个 \0 ,这样内核才能知道字符串的长度。下面给出了用户库 user_lib 中的调用方式:
通过将 path 转换为 usize 类型,将字符串的起始地址传递给了内核,但是没有传递字符串的长度。而后面的 0, 0 则可能是作为占位符使用,表示并未传递额外的长度信息给内核。这意味着内核需要在传入的字符串末尾通过遍历来找到空字符,以确定字符串的长度。

应用程序示例

借助这三个重要系统调用,我们可以开发功能更强大的应用。下面是两个案例: 用户初始程序-init 和 shell程序-user_shell 。

用户初始程序-initproc

创建用户初始进程,它将通过 fork+exec 创建 user_shell 子进程,并将被用于回收僵尸进程

shell程序-user_shell

user_shell 需要捕获用户输入并进行解析处理,为此添加一个能获取用户输入的系统调用:
我们在用户库中将其进一步封装成每次能够从 标准输入 中获取一个字符的 getchar 函数。
  • 需要向read函数提供一个缓冲区
  • getchar()将每次临时声明一个长度为 1 的缓冲区提供给read函数
    • 每次获取一个用户输入的字符,然后进行相应动作
    • 声明的字符串 line 则维护着用户当前输入的命令内容,它也在不断发生变化
  • 如果用户输入回车键(第 28 行),那么user_shell 会 fork 出一个子进程(第 34 行开始)并试图通过 exec 系统调用执行一个应用,应用的名字在字符串 line 中给出。如果 exec 的返回值为 -1 , 说明在应用管理器中找不到对应名字的应用,此时子进程就直接打印错误信息并退出;否则子进程将开始执行目标应用。
  • 如果用户输入退格键(第 53 行),首先我们需要将屏幕上当前行的最后一个字符用空格替换掉, 这可以通过输入一个特殊的退格字节 BS 来实现。其次,user_shell 进程内维护的 line 也需要弹出最后一个字符。
  • 如果用户输入了一个其他字符(第 61 行),就接将它打印在屏幕上,并加入到 line 中。
  • 按键 Ctrl+A 再输入 X 来退出qemu模拟器。

进程管理的核心数据结构

为了更好实现进程管理,我们需要设计和调整内核中的一些数据结构,包括:
  • 基于应用名的应用链接/加载器
  • 进程标识符 PidHandle 以及内核栈 KernelStack
  • 任务控制块 TaskControlBlock
  • 任务管理器 TaskManager
  • 处理器管理结构 Processor

基于应用名的应用链接/加载器

  • 在链接器 os/build.rs 中,我们按顺序保存链接进来的每个应用的名字
  • 在加载器 loader.rs 中,我们用一个全局可见的 只读 向量 APP_NAMES 来按照顺序将所有应用的名字保存在内存中:
    • 使用 get_app_data_by_name 可以按照应用的名字来查找获得应用的 ELF 数据,而 list_apps 在内核初始化时被调用,它可以打印出所有可用应用的名字。

      进程标识符和内核栈

      进程标识符

      同一时间存在的所有进程都有一个自己的进程标识符,它们是互不相同的整数。这里将其抽象为一个 PidHandle 类型,当它的生命周期结束后,对应的整数会被编译器自动回收:
      进程标识符分配器 PidAllocator ,并将其全局实例化为 PID_ALLOCATOR :
      将alloc包装为一个全局分配进程标识符的接口 pid_alloc 提供给内核的其他子模块,就不用直接访问PID_ALLOCATOR了:

      内核栈

      从本章开始,我们将应用编号替换为进程标识符来决定每个进程内核栈在地址空间中的位置
      在内核栈 KernelStack 中保存着它所属进程的 PID:
      • Return (bottom, top) of a kernel stack in kernel space.
      impl KernelStack
      • new 方法可以从一个 PidHandle ,也就是一个已分配的进程标识符中对应生成一个内核栈 KernelStack 
      • 它调用kernel_stack_position 来计算出这个进程在内核地址空间的位置,然后将这个进程对应的逻辑段插入内核地址空间
        • 内核地址空间包含了整个操作系统内核的代码和数据结构,是操作系统的核心部分,而一个进程的内核栈只是该进程在内核态运行时使用的栈空间。
      • 将一个类型为 T 的变量压入内核栈顶并返回其裸指针
      • 获取内核栈顶在内核地址空间里面的地址
      • 当 KernelStack 生命周期结束后,这些物理页帧也将会被编译器自动回收

      进程控制块

      • 在内核中,每个进程的执行状态、资源控制等元数据均保存在一个被称为 进程控制块 (PCB, Process Control Block) 的结构中,它是内核对进程进行管理的单位。在内核看来,它就等价于一个进程,修改之前的任务控制块就可以实现内核控制块的结构
      • 任务控制块不可变,在初始化之后就不再变化的作为一个字段直接放在任务控制块中。这里将进程标识符 PidHandle 和内核栈 KernelStack 放在其中
      • 在运行过程中可能发生变化的则放在 TaskControlBlockInner 中,将它再包裹上一层 UPSafeCell<T> 放在任务控制块中。 在此使用 UPSafeCell<T> 可以提供互斥从而避免数据竞争
      • 注意我们在维护父子进程关系的时候大量用到了智能指针 Arc/Weak ,当且仅当它的引用计数变为 0 的时候,进程控制块以及被绑定到它上面的各类资源才会被回收
      • 当进程调用 exit 系统调用主动退出或者执行出错由内核终止的时候,它的退出码 exit_code 会被内核保存在它的任务控制块中,并等待它的父进程通过 waitpid 回收它的资源的同时也收集它的 PID 以及退出码
      新增状态Zombie
      • inner_exclusive_access:获取当前进程控制块inner的可变引用
      • getpid:获取当前进程的pid
      • new:用来创建一个新的进程,目前仅用于内核中手动创建唯一一个初始进程 initproc
      • exec:用来实现 exec 系统调用,即当前进程加载并执行另一个 ELF 格式可执行文件
      • fork:用来实现 fork 系统调用,即当前进程 fork 出来一个与之几乎相同的子进程

      任务管理器

      我们需要将任务管理器对于 CPU 的监控职能拆分到处理器管理结构 Processor 中去, 任务管理器自身仅负责管理所有任务。在这里,任务指的就是进程
      TaskManager 将所有的任务控制块用引用计数 Arc 智能指针包裹后放在一个双端队列  VecDeque  中。 使用智能指针的原因在于,任务控制块经常需要被放入/取出,如果直接移动任务控制块自身将会带来大量的数据拷贝开销, 而对于智能指针进行移动则没有多少开销。其次,允许任务控制块的共享引用在某些情况下能够让我们的实现更加方便。
      TaskManager 提供 add/fetch 两个操作,前者表示将一个任务加入队尾(ready_queue里面),后者则表示从队头中取出一个任务来执行。 从调度算法来看,这里用到的就是最简单的 RR 算法。全局实例 TASK_MANAGER 则提供给内核的其他子模块 add_task/fetch_task 两个函数。

      处理器管理结构

      正在执行的任务

      • take_current:take()方法用于获取Option类型中的值,并将Option设置为空。
      • current:返回当前执行的任务的一份拷贝

      任务调度的 idle 控制流

      每个 Processor 都有一个 idle 控制流,它们运行在每个核各自的启动栈上,功能是尝试从任务管理器中选出一个任务来在当前核上执行。 在内核初始化完毕之后,核通过调用 run_tasks 函数来进入 idle 控制流
      当一个应用交出 CPU 使用权时,进入内核后它会调用 schedule 函数来切换到 idle 控制流并开启新一轮的任务调度
      可以看到一个任务交出CPU使用权后,该核的任务会被切换回idle_task,然后会进行循环,直到找到下一个准备执行的任务,然后把任务切换到那个任务去

      进程管理机制的设计实现

      初始进程的创建

      • 新建初始进程的任务管理块INITPROC,初始任务名字叫initproc
      • 在初始化 INITPROC 之后,则在 add_initproc 中可以调用 task 的任务管理器 manager 子模块提供的 add_task 接口将其加入到任务管理器

      进程调度机制

      调用 task 子模块提供的 suspend_current_and_run_next 函数可以暂停当前任务,并切换到下一个任务,下面给出了两种典型的使用场景

      Lab3

      pub fn sys_spawn(_path: *const u8)
      • 模仿TaskControlBlock的fork函数创建一个新的任务控制块
      • 其中,不需要复制父进程的地址空间,而是像exec一样直接将地址空间设置为该任务本来的地址空间
      • 手动添加该新建的子进程到父进程的children数组(本来是由fork完成)
      • 添加任务至任务管理器
      pub fn sys_set_priority(_prio: isize)
      • 没太看懂这个章节需要干嘛,不用实现stride算法吗?
      • 直接在TCB里面添加可变的两个属性,priority和stride即可;以及可以访问并修改这两个值的函数(仿照inner_exclusive_access)
      • priority初始值为16,stride初始值为0
      stride算法的实现:
      • 以前的算法是先进先出,直接fetch ready_queue中的第一个,现在从当前 runnable 态的进程中选择 stride 最小的进程调度
      • pass = BigStride / priority,每次取出一个任务,应该更新其步长,让stride+=pass
      • 注意,要判断stride是否溢出,溢出才+=pass
      • 而且找最小stride的时候,使用暴力枚举,这样就允许stride重复,因为始终能找出一个“最小”的stride,让任务可以执行完
      数组Computer Networking Notes
      • Giscus