[面试] 操作系统

191 阅读23分钟

02 操作系统

进程模型

1. 进程与线程

# 基本概念
  - 进程是程序中所有线程的封装, 是系统资源分配和调度的基本单位, 实现操作系统的并发;
  - 线程是进程的的组成部分, 是 CPU 资源调度和分配的基本单位, 实现了进程内部的并发;
# 区别
  [资源占用]
  - 进程在运行过程中拥有独立的内存单元, 但是网络/文件的句柄和其它进程共享;
  - 线程在运行过程中拥有独立的虚拟 CPU 资源(寄存器, 指令计数器等)和栈区内存单元, 但代码区, 全局区, 堆区内存和同进程中的其他线程共享;
  [系统开销]
  - 进程创建和销毁的开销较大, 它包括大量内存, I/O方面的切换;
  - 线程的创建和销毁开销较小, 它仅涉及少量 CPU 资源和栈区内存资源;
    (CR3 寄存器保存着当前进程页目录的物理地址,切换进程就会改变 CR3 的值)
  [通信方式]
  - 所有程序都是由线程组成的, 所以计算机上的通信本质上都是线程间的通信;
  - 同一进程内的线程之间, 通信比较简单, 它们共享进程的内存空间, 可以通过直接读取共享内存数据来实现通信(如全局变量);
  - 不同进程间的线程之间, 通信比较复杂, 它们在内存上相互隔离, 可以通过管道, 加锁机制, 套接字配合共享内存等方式实现; 

2. 进程通信(管道)

# 普通管道
  - 指的是由内核管理的缓冲区, 一个进程可以往管道中写数据, 另一个进程可以往管道中读数据, 从而完成进程间的通信;
  - 管道只能完成父子进程间的通信, 它是父进程调用 fork 函数时创建出来的;
  - 父子进程都可以对管道内容进行读和写, 但是同一时间只能一个读一个写;
# FIFO管道
  - 又称为命名管道, 它利用了文件系统的特殊机制, 用一个文件的路径来表示管道;
  - FIFO管道弥补了普通管道只能在父子进程间传递信息的不足, 不相关的两个进程可以通过同一文件路径实现进程通信;
  - 但是, 它只能以先进先出的顺序传输信息, 并且随着进程的消失, 管道也随之消失;

3. 进程通信(互斥量)

# 锁机制
  - 对于多线程同时访问的共享资源, 
    * 如果是以读的方式访问, 那么多线程并发访问它不会产生错误;
    * 如果是以写的方式对其进行修改, 那么多线程并发访问会产生逻辑错误;
    * 在这种情况下, 就要加锁访问, 保证每一时刻只有一个线程可以获取并修改共享资源;
# 原理
  - 其本质是设置一个 int 类型的全局变量, (C++中封装为mutex类), 它只有 1 和 0 两种取值, 分别表示资源占用和未占用;
  - 线程获取共享资源前, 调用 lock 函数, 检测该变量的状态
    * 诺为 1, 则资源未占用, 将其置为 0 然后访问共享资源; 
    * 诺为 0, 则资源被占用, 线程进入睡眠(waiting)状态, CPU 交给其他线程;
    * 等待其他线程访问完毕后, 会调用 unlock 函数, 将该变量重新恢复为 1, 然后由内核通知睡眠了的线程, 转换到就绪(readying)状态, 接着访问资源;

4. 进程通信(套接字)

# 套接字
  - 套接字分为
    * 本地(UNIX域)套接字: 实现本地进程间的通信;
    * 网络(TCP)套接字: 实现网络中不同主机进程间的通信;
  - 套接字通信的过程分为:
    - 创建套接字: 调用 socket 函数, 指定网络类型, 套接字类型, 网络协议; 返回套接字描述符, 其本质是一个 int 类型的变量;
    - 绑定IP和端口: 调用 bind 函数, 将套接字绑定到网络IP和端口;
    - 监听套接字: 调用 listen 函数监听套接字, 当其他进程调用 connect 函数并传入对应的IP和端口时, 就监听成功;
    - 接收对方进程套接字: 调用 accept 函数, 接收对方进程的套接字;
    - 发送和接收:调用 recv 和 send 函数, 进行数据的发送和接受;

5. 其他进程通信

# 消息队列
  - 以链表的形式存储信息, 所有进程可以对其进行写入和读取;
  - 独立于进程存在, 进程消失后依然存在;
  - 消息队列具有数据报格式, 不像管道一样是无格式的字节流;
  - 消息队列可以随机读取, 不必照先进先出的规则进行;
# 信号
  - 信号比较复杂, 可以理解为是在软件层面对中断的一种模拟, 是进程通信中唯一的异步通信机制;
  - 类似于中断服务函数, 信号也可以预先定义一个信号处理函数;
  - 信号的触发是在用户空间, 所以只有当进程运行在用户态, 或者返回用户态时, 才可以触发;
  - 信号的来源可以是硬件, 如按下键盘; 也可以是软件, 如调用 kill 系统函数;
# 信号量
  - 类似于互斥锁, 只不过将原来锁 0 和 1 的两种状态扩展到 0 至 n 的 n+1 种状态;
  - 它可以根据共享资源的数量, 控制并发访问线程的数量;
  - 当共享资源被占用一份, n 就减 1, 诺 n 为 0, 则线程陷入阻塞;
  ------------------------------------------------------------------------------------
  n = create(5);       # 允许 5 个线程并发
  fun(){               # 线程函数
      wait(n);         # n > 0; n--
      临界区
      signal(n);       # n++
  }
  ------------------------------------------------------------------------------------
# 共享内存
  - 共享内存允许不同的进程访问同一片内存空间, 是最快的一种进程间通信的方式;
  - 其原理是修改进程虚拟空间的映射, 让两个进程共享同一块物理内存;
  - 涉及到共享数据的安全和同步, 需要依靠互斥锁和信号量来进行访问控制;

锁机制

1. 临界区

# 概念
  - 临界区指一段代码包含了对共享数据的读和写;
  - 为了保证程序的安全和可靠, 临界区资源的访问需要保证原子性和互斥性;
# 原子性
  - 原子性指的是一段指令必须连续执行完成, 中间不可被打断; 否则, 即为失败, 需要退回到初始状态;
# 互斥性
  - 互斥性指的是对临界区资源执行原子性操作时, 只能允许一个操作对象;
  - 当有其他对象也想操作临界区资源时, 必须等待前者操作完成;

2. 互斥锁

# 代码
  ----------------------------------------------------------------------------------
  fun(){
      m.lock();
       临界代码;
      m.unlock();
  }
  ----------------------------------------------------------------------------------
# 原理
  - 其本质是设置一个 int 类型的全局变量, (C++中封装为mutex类), 它只有 1 和 0 两种取值, 分别表示资源占用和未占用;
  - 线程获取共享资源前, 调用 lock 函数, 检测该变量的状态
    * 诺为 1, 则资源未占用, 将其置为 0 然后访问共享资源; 
    * 诺为 0, 则资源被占用, 线程进入睡眠(waiting)状态, CPU 交给其他线程;
    * 等待其他线程访问完毕后, 会调用 unlock 函数, 将该变量重新恢复为 1, 然后由内核通知睡眠了的线程, 转换到就绪(readying)状态, 接着访问资源;

3. 自旋锁

# 代码 - 使用 std::atomic_flag 实现
----------------------------------------------------------------------------------------
  # 1. 声明 flag
  std::atomic<int> Tag = 1;
  
  # 2. 加锁 - while 循环, 判断 Tag
  void lock()
  {
      while(Tag == 0);
      Tag = 0;
  }
  
  # 3. 解锁 - 清除 Tag
  void unlock()
  {
    Tag = 1;
  }
----------------------------------------------------------------------------------------
# 自旋锁(忙等待锁)
  - 自旋锁在检测到资源被占用时, 线程依旧处于运行状态, 其通过不断地循环检测, 判断资源是否释放, 直到资源释放然后立即获得资源;
  - 自旋锁会一直占用 CPU 计算周期, 浪费 CPU 计算资源;
# 非自旋锁(无等待锁)
  - 非自旋锁在检测到资源被占用时, 线程立即进入睡眠(waiting)状态, CPU 资源交给其他线程使用;
  - 在系统内核中, 是通过一个等待队列来保存这些睡眠线程的, 新的睡眠线程会添加到队尾, 当检测到资源释放时, 内核通知队首线程, 让其进入就绪状态, 尝试获取资源;

4. 条件变量

# 代码
---------------------------------------------------------------------------------------------
  # 1.创建锁对象和条件变量对象
    std::lock_guard<std::mutex> my_guard(my_mutex);
    std::condition_variable my_cind;
  # 2.调用 wait 等待条件变量成立
    my_cond.wait(my_guard,[this]{...});              // 自定义条件
  # 3.其他线程调用 notify 尝试唤醒 wait 的线程
    my_cond.notify_one(); 
---------------------------------------------------------------------------------------------
# 原理
  - 条件变量需要和互斥锁配合使用, 它可以自定义一个条件, 只有当该条件满足时, 线程才能够获取锁, 并访问资源;
  - 当不满足条件时, 线程会一直处于睡眠(waiting)状态, 但唤醒它的操作是由其它线程进行的, 不是内核;
  - 具体过程
    * 线程调用 wait 函数, 先尝试获得锁, 再检查自定义条件是否满足, 共有三种情况;
      1) 一开始就无法获得锁, 线程直接进入睡眠状态; 
      2) 成功获取锁, 检查自定义条件不满足, 线程也直接进入睡眠状态;
      3) 成功获取锁, 且检查自定义条件满足, 线程获取资源, 并执行接下来的逻辑;
    * 线程进入睡眠状态, 则需要等待其他线程调用 notify 函数将其唤醒, 这里的唤醒只是尝试唤醒, 不一定成功执行;
    * 被唤醒的进程依然要调用 wait 函数依次先尝试获得锁, 再检查自定义条件是否满足, 只有都满足, 才会成功执行接下来的逻辑;

5. 共享锁/读写锁

# 代码
---------------------------------------------------------------------------------------------
  # 1. 声明共享锁
  std::shared_mutex test_lock;
  # 2. 在 write_lock 中, 用 unique_lock 类模板
  std::unique_lock<std::shared_mutex> lock(test_lock);
  # 3. 在 read_lock 中, 用 shared_lock 类模板
  std::shared_lock<std::shared_mutex> lock(test_lock);
---------------------------------------------------------------------------------------------
# 原理
  - 共享锁,可用于实现多线程的读和写
  - 共享锁有两种访问级别:
    * 共享:多个线程可以共享这个锁的拥有权, 用于读操作; 
           如果一个线程以共享的方式获取了锁,则其他任何线程都无法以互斥的方式获取锁,但是可以以共享的方式获取锁;
    * 互斥:仅一个线程可以拥有这个锁, 用于写操作;
           如果一个线程以互斥的方式获取了锁,则其他线程都无法获取该锁;

6. 死锁

# 死锁的四个条件
  - 互斥: 资源只能被一个进程占有;
  - 再次占有: 已经获得一份互斥资源的进程可以在不释放原来资源的情况下, 再次占有另一份互斥资源;
  - 不可抢占: 进程不能强制另一个资源释放其已经占有的资源, 只能等待它主动释放;
  - 环路等待: 两个或两个以上的进程在已经占有资源的情况下, 都在等待对方资源的释放;
# 解决
  - 诺进程要连续占用两个资源, 则在资源占用时应该一次性将两个资源都加锁, 用完后再一起释放; 当只有一个资源可用时, 加锁失败继续等待;
  - 将进程连续占用资源的顺序保持一致, 如 A 进程先占用资源 1, 再占用资源 2; 那么 B 进程也要先占用资源 1, 再占用资源 2;

7. 单例模式 (双重检查)

// 单例模式
   - 整个项目中, 某个类的对象只能创建一个;

// I. 单例类的定义
class MyCAS{
private:
    MyCAS(){}                           // 1. 私有化构造函数
private:
    static MyCAS* m_instance;           // 2. 私有化类对象指针
public:
    static MyCAS* GetInstance(){        // 3. 公开创建类的静态方法
        if(m_instance == NULL){
            // 创建类实例 (new 方法在堆中创建, 程序退出不会自动释放内存)
            m_instance = new MyCAS();
            // 创建一个静态 CG 对象 (静态对象在程序结束时会自动释放内存, 其析构函数执行)
            static CG c;
        }
        return m_instance;
    }
    
    class CG{                           // 4. 类中创建类, 用来释放内存
    public:
        ~CG(){
            if( MyCAS::m_instance != NULL ){
                delete MyCAS::m_instance;
                MyCAS::m_instance = NULL;
            }
        }
    }
}

// II. 单例类的初始化
MyCAS* MyCAS::m_instance = NULL;                 // 懒汉模式(初始化时, 没有创建实例)
MyCAS* MyCAS::m_instance = MyCAS::GetInstance(); // 饿汉模式(初始化时, 就创建好了实例)

// III. 主线程中单例类的创建和使用
int main(){
    MyCAS* instance = MyCAS::GetInstance();      // 获得类的实例
}

// 创建两个对象问题
   - 避免多线程同时创建类的实例;
   - 当使用懒汉模式时, 单例类的实例是在线程获取类对象时创建的;
   - 多线程同时都是第一次创建类对象时, 可能创建两个对象;

// 解决方法 (双重检查) - 修改创建类的静态方法
static MyCAS* GetInstance(){ 
    if(m_instance == NULL){
        my_mutex.lock();
        if(m_instance == NULL){
            // 创建类实例 (new 方法在堆中创建, 程序退出不会自动释放内存)
            m_instance = new MyCAS();
            // 创建一个静态 CG 对象 (静态对象在程序结束时会自动释放内存, 其析构函数执行)
            static CG c;
        }
        my_mutex.unlock();
    }    
    return m_instance;
}
   - 单层锁定的问题
     - 第一次创建类对象时, 多个线程可以同时满足 m_instance == NULL 的条件;
     - 故锁必须放在 m_instance == NULL 判断之前;
     - 但这又造成线程每一次获取对象都要进行锁定, 但其实只有第一次创建对象时才需要锁;
     - 所以在外层再加一次判断;

双重锁定 (避免多线程同时创建类的实例)

  • 当使用懒汉模式时, 单例类的实例是在获取类对象时创建的;
  • 多线程同时都是第一次创建类对象时, 可能同时访问文件
// 解决方法 (双重锁定) - 修改创建类的静态方法
static MyCAS* GetInstance(){ 
    if(m_instance == NULL){
        std::lock_guard<std::mutex> myguard(my_mutex);
        if(m_instance == NULL){
            // 创建类实例 (new 方法在堆中创建, 程序退出不会自动释放内存)
            m_instance = new MyCAS();
            // 创建一个静态 CG 对象 (静态对象在程序结束时会自动释放内存, 其析构函数执行)
            static CG c;
        }
    }    
    return m_instance;
}

单层锁定的问题

  • 第一次创建类对象时, 多个线程可以同时满足 m_instance == NULL 的条件;
  • 故锁必须放在 m_instance == NULL 判断之前;
  • 但这又造成线程每一次获取对象都要进行锁定, 但其实只有第一次创建对象时才需要锁;

双重锁定极端情况分析

  • 第一次创建类对象时, 多个线程可以同时满足 m_instance == NULL 的条件;
  • 只有一个线程抢到锁, 满足第二个m_instance == NULL 条件进行实例创建, 其余线程等待;
  • 锁释放后, 其余线程抢到锁, 但不满足第二个m_instance == NULL 条件, 所以跳出, 获得返回的类对象;

内存模型

1. 虚拟内存

# 是什么?
  - 虚拟内存是进程运行过程中, 它所看到的内存空间;
  - 对于 32 位系统, 每个进程都有独有的 4G 虚拟内存空间;
  - 但是所有进程的底层实际上共享着同一个物理内存空间, 物理内存可以远小于虚拟内存;
# 原理
  - 计算机在运行进程时, 并不是直接将进程的数据全部从磁盘中拷贝到物理内存中;
  - 在进程创建时, 仅仅创建了虚拟内存, 然后创建页表, 将虚拟内存地址和磁盘数据地址一一映射;
  - 在进程运行时, 进程查询虚拟内存映射表, 诺查询结果是数据不在内存中(即缺页), 那么通过 CPU 中断, 将磁盘数据拷贝到内存中, 然后再从内存中获得数据;
  - 诺查询结果是数据在内存中, 那么获得对应的物理内存地址, 取得数据;
# 为什么?
  - 扩大地址空间, 计算机上运行的进程很多, 每个进程理论上都需要 4G 大小的内存空间, 真实的物理内存无法满足如此多的进程共同运行在同一台计算机上;
  - 内存保护, 每一个进程都有独有的虚拟内存, 它们互相隔离, 保证了进程之间不相互干扰;
  - 内存共享, 通过修改虚拟内存的映射, 可以让多个进程中相同的数据共享同一物理内存, 减少了内存数据的冗余和浪费;
  - 进程通信, 通过共享内存也可以实现进程间的通信;
  - 切换开销减少, 由于多个进程共享同一物理内存, 所以在进程切换时, 不同进程的大部分数据都可以在内存中找到, 不必从磁盘中重新替换;
  - 虚拟内存从程序的角度看是连续的, 但是从物理内存看是碎片化的, 这种方式让物理内存的使用更有效率;

2. 分页

# 虚拟内存的问题
  - 虚拟内存需要维护虚拟内存地址和真实物理内存地址间的映射关系, 如果每个数据的地址都一一对应, 那么需要创建非常大的映射表, 占用大量内存资源;
# 解决
  - 为了减少虚拟内存映射产生的内存消耗, 采用分页的机制对内存进行划分;
  - 在 32 位系统中, 一页的大小为 4k, 4G 内存可以分为 2^20 页;
  - 虚拟内存和物理内存都被分页, 在内存映射表中存储相应的页码映射关系, 从而缩小了页表的大小;
  - 在查询到缺页时, 磁盘与内存之间的数据拷贝将以页为单位进行, 即一次拷贝 4k 数据;

3. 缺页中断

# 概述
  - 当进程查询页时, 发现所需数据不在内存中, 将会触发缺页中断, 将磁盘中所需的数据拷贝到内存中;
# 过程
  - 保护现场, 将当前 CPU 资源(寄存器数据)保存到临时内存空间;
  - 转入缺页中断处理程序, 将磁盘中对应数据所在页的内容拷贝到内存页中;
  - 将该内存页的地址赋值给页表;
  - 恢复现场, CPU 再次执行内存数据查询;

4. 分段

# 原因
  - 进程中的数据是分段的, 包括代码区, 全局区, 堆区和栈区;
  - 在进程创建和运行过程中, 各段数据都会动态地增加, 所以在虚拟内存中需要区分出独立的段空间;

5. 段页式内存管理

# 概念
  - 逻辑地址: 进程中各段数据的相对地址;
  - 线性地址: 进程在虚拟内存中的绝对地址;
  - 物理地址: 物理内存中的地址;
  - 段寄存器: 每个进程都有一组段寄存器记录当前进程各段基址在段描述符表中的索引号;
  - 段描述符表: 一组数组, 记录了各进程各段数据的基地址和状态, 按全局和局部(进程)分, 可分为 GDT 和 LDT 两部分;
  - 页目录表: 每个进程维护一个页目录表, 占 4k 大小, 保存 1024 个 32 位表项, 每个表项保存一个页表的起始地址, 由线性地址前 10 位定位;
  - 页表: 保存内存页的起始地址, 以及状态(数据是否在内存中, 是否最近读取, 是否修改), 也占 4k 大小, 保存 1024 个 32 位表项, 由线性地址中间 10 位定位;
  - 内存页: 保存进程需要读取的真正数据, 由线性地址的后 12 位作为偏移量来定位;
  
# 地址转换过程
  - 根据进程段寄存器中记录的索引号, 在对应的段描述符表中查找到段基址, (段描述符表分为 GDT 和 LDT 两种, 对应全局和进程, 它在段寄存器中有标志位控制);
  - 将段基址 + 逻辑地址 = 线性地址;
  - (部分 Linux 系统的段基址都是从 0 开始的, 所以它们的线性地址就等于逻辑地址, 它们的线性地址转换被称为'假转换');
  - 32 位系统中, 线性地址有 32 位, 可以分为 3 部分, 前 10 位是进程页目录表的偏移量, 中间 10 位是页表的偏移量, 最后 12 位是内存页的偏移量;
  - 具体步骤是: 通过寄存器找到进程对应的页目录表, 然后用线性地址前10位定位, 获得对应页表起始地址; 找到页表, 用线性地址中间 10 位定位获取内存页起始地址; 用线性地址后 12 位, 最终获取进程所要的数据;

# 其他
  - 对于大内存, 线性地址到物理地址的转换还可以拆分出更多级的中间页表来实现;
  - 快表: 通过硬件实现虚拟地址到物理地址的直接转换, 适合将一小部分命中率特别高的数据加入快表, 从而加快整体效率;

I/O 模型

1. 五大 I/O 模型

# I/O
  - 进程需要访问非内存空间的数据;
  - 它们一般来源于磁盘或网络传输, 访问速度较慢;
  - 因此它们需要一个缓冲区(网络缓冲区,内核缓冲区)作缓存, 当数据完全准备好后, 再一次性拷贝进程序的内存中; 
# I/O 阶段
  - 等待数据准备阶段
  - 将数据从内核缓冲区拷贝进进程: 
# 五大模型
  - 阻塞
    * 进程需要访问 I/O 数据时, 触发 I/O 系统调用, 然后一直等待数据准备, 当准备好后立即将数据拷贝至进程内存, 再继续执行进程逻辑;
  - 非阻塞
    * 进程需要访问 I/O 数据时, 触发 I/O 系统调用, 然后去执行其他的进程逻辑, 每隔一段时间查询数据是否准备完成, 当发现准备好就将数据拷贝至进程内存;
  - I/O 复用
    * 进程需要同时访问多个 I/O 数据, 这时用一个函数同时监控这些数据的状态, 然后去执行其他的进程逻辑;
    * 每隔一段时间, 进程只需查询这个函数, 就可获取这些数据中哪些已经准备完毕, 然后将准备完毕的数据依次拷贝至进程内存;
    * 整体逻辑与非阻塞一致, 只是非阻塞只监控一个, I/O 复用监控多个;
  - 信号驱动
    * 进程需要访问 I/O 数据时触发系统调用, 然后立即返回, 之后不再关心数据准备情况;
    * 当数据准备完成后, 系统会触发信号通知进程, 从而触发信号处理程序完成数据拷贝到进程内存;
  - 异步 I/O
    * 进程需要访问 I/O 数据时触发系统调用, 然后立即返回, 之后不再关心数据准备和拷贝的情况;
    * 当数据准备完成后, 系统自动将数据拷贝到进程内存, 然后通知进程已完成;
# 同步与异步
  _ 同步: 将数据从内核缓冲区拷贝进进程的阶段, 由进程自己完成, 该阶段进程会发生阻塞;
  - 异步: 数据准备和数据拷贝都由系统完成, 进程完全不需要关心;

2. I/O 复用模型

# 分类
  - select, poll, epoll;
# select 
  [语法]
  - 初始化集合:    FD_ZERO(&fd);
  - 描述符加入集合: FD_SET(sock, &fd);
  - select 调用 : ret = select(int 文件描述符数量+1, fd_set *读集合, fd_set *写集合, fd_set *异常集合, struct timeval *阻塞时间);
  - 返回值判断: 
                 * < 0: 出错;
                 * = 0: 没有数据准备好;
                 * > 0; 有文件描述符准备好;
  - 判断文件描述符是否准备好: FD_ISSET(sock. &fd);
  - 清除文件描述符:  FD_CLR(sock, &fd);
  [特点]
  - 利用字符位来表示文件描述符的就绪状态, 可监视的文件描述符有限, 默认32个(windows)或1024个(Linux), 可以修改 FD_SETSIZE 来改变;
  - fd 列表维持在进程中, 调用函数时需要将其拷贝到内核缓冲区;
  - 对 fd 进行扫描时是线性扫描,即采用轮询的方法,效率较低;
  - 水平触发: 已经准好的文件描述符, 向进程汇报后没被处理, 下一次依然会提醒;
# poll
  [特点]
  - 类似于 select, 但采用数组来保存描述符, 没有描述符数量限制;
  - 某些系统不支持 poll;
# epoll
  [特点]
  - fd 列表维持在内核中, 不需要在每次函数调用时将 fd 列表拷贝至内核, 文件描述符只需在注册时向内核拷贝一次即可, 由红黑树维护;
  - 当某个文件描述符准备好后, 内核会自动调用回调函数, 将准备好的文件描述符添加到一个链表;
  - 当调用函数时, 进程直接从内核中获得链表, 即可直接获取准备完毕的文件描述符, 不必像 select 和 poll 一样线性轮询所有文件描述符;
  - 支持边缘触发, 即每个准备好的文件描述符只提醒一遍;

用户态和内核态

# 运行内存地址
  - 用户态: 0 ~ 3G (进程独占)
  - 内核态: 3 ~ 4G (进程共享)
# 必要性
  - 操作系统具有一些基础且关键的功能, 比如创建新进程, 访问磁盘或网络数据等;
  - 一方面, 这些功能大部分进程都需要
    * 每个用户进程不需要单独设计这些相同的计算逻辑
    * 可以把它们都提出来, 封装为内核进程, 在需要时直接调用 被所有进程共享
  - 另一方面, 这些功能涉及系统底层安全, 随意的破环或修改会造成系统崩溃
    * 将这些内核的计算逻辑和数据与用户进程分开存放, 防止用户进程在平时运行中对内核数据进行修改
    * 用户进程只能在特殊情况下, 则可以安照规定的流程去调用这些内核函数, 安全地实现关键功能
# 用户态和内核态的切换方式
  用户态和内核态的切换本质上是通过中断来实现的, 具体分以下三种:
  - 系统调用
    * 用户进程主动触发中断, 要求内核提供相应服务
    * 如, 进程调用 fork() 函数, 要求内核创建一个子进程
  - 异常
    * 用户进程执行过程中发生错误, 需要内核提供解决方法
    * 如, 进程发生缺页异常, 触发缺页中断, 需要内核将所需要的数据从磁盘拷贝进内存
  - 外围设备产生的中断
    * 外围设备完成任务后, 会触发硬件中断来通知用户进程, 这时也需要内核去进行处理
    * 如, 网络数据准备完毕, 网卡会通知进程进行读取