1. 冯诺依曼体系结构

我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。

截至目前,我们所认识的计算机都是由一个个的硬件组件组成

  • 输入单元:包括键盘,鼠标,扫描仪,手写板等
  • 中央处理器(CPU):含有运算器和控制器等
  • 输出单元:显示器,打印机等

关于冯诺依曼,需要强调几点

  • 这里的存储器指的是内存
  • 不考虑缓存的情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)(数据层面)
  • 外设(输入或输出设备)要输入或输出数据,也只能写入内存或者从内存中读取
  • 一句话,所有设备都只能直接和内存打交道

        

2. 操作系统(Operator System)

任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)

笼统的理解,OS包括:

  • 内核(进程管理,内存管理,文件管理,驱动管理)
  • 其他程序(例如函数库,shell程序等)

系统调用和库函数概念

  • 在开发角度上,OS对外表现为一个整体,但是会暴露自己的部分接口,供上层开发使用。这部分由OS提供的接口叫做系统调用。
  • 系统调用在使用上,功能比较基础,对用户的要求相对也比较高。所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。

3. 进程

3.1 基本概念与基本操作

  • 课表概念:程序的一个执行程序,正在执行的程序等
  • 内核观点:担当分配系统资源(CPU时间,内存)的实体。
  • 当前:进程 = 内核数据结构(task_struct)+ 自己的程序代码和数据

3.2 task_struct

内容分类

  • 标识符:描述本进程的唯一标识符,用来区分其他进程。
  • 状态:任务状态,退出码,退出信号等。
  • 优先级:相对于其他进程的优先级。
  • 程序计数器:程序中即将被执行的下一条指令的地址。
  • 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享内存块的指针。
  • 上下文数据:进程执行时处理器的寄存器中的数据。
  • I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
  • 记账信息:可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
  • 其他信息

组织进程

可以在内核源码中找到它。所有运行在系统里的进程都以task_struct双链表的形式存在内核里。

通过系统调用创建进程-fork初识

  • 运行 man fork 认识 fork

  • fork 有两个返回值

  • 父子进程代码共享,数据各自开辟空间,私有一份(采用写实拷贝)
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
    int ret = fork();
    if (ret < 0)
    {
        perror("fork");
        return 1;
    }

    else if (ret == 0)
    { // child
        printf("I am child : %d!, ret: %d\n", getpid(), ret);
    }

    else
    { // father
        printf("I am father : %d!, ret: %d\n", getpid(), ret);
    }
    
    sleep(1);
    return 0;
}
  • fork为什么会有两个返回值?

  • 两个返回值各种给父子如何返回?

  • 至于:一个变量怎么能让 if else if 同时成立这个问题,需要在后面才能解释清楚。

  1. 两个返回值的原因:
  •  fork() 创建子进程后,子进程会复制父进程的所有内容,包括执行到的代码位置
  • 所以父子进程会从 fork() 调用之后继续执行,各自获得一个返回值

     2. 返回值分配:

  • 父子进程得到子进程的PID(大于0)
  • 子进程得到0
  • 两个进程都有自己独立的 getpid() 的值

     3. if-else if “同时成立”的真相:

  • 实际上不是在一个进程中同时成立
  • 而是两个不同的进程分别进入了不同的分支:
    • 父进程进入 else 分支
    • 子进程进入 else if (ret == 0) 分支
  • 由于输出几乎同时出现,给用户造成了“同时成立”的错觉

3.3 进程状态

Linux内核源代码怎么说?

为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。

下面的状态在Kernel源代码里定义:

/*
 *The task state array is a strange "bitmap" of
 *reasons to sleep. Thus "running" is zero, and
 *you can test for combinations of others with
 *simple bit tests.
 */
static const char *const task_state_array[] = {
    "R (running)",    /*0 */
    "S (sleeping)",   /*1 */
    "D (disk sleep)", /*2 */
    "T (stopped)",    /*4 */
    "t (tracing stop)", /*8 */
    "X (dead)",         /*16 */
    "Z (zombie)",       /*32 */
};
  • R运行状态(running):并不意味着进程一定在运行中,它表明进程要么是在运行中,要么在运行队列里。
  • S睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))
  • D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
  • T停止状态(stopped):可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
  • X死亡状态(dead):这个状态只有一个返回值,你不会在任务列表里看到这个状态。

进程状态查看

ps aux / ps axj 命令 
  • a:显示一个终端所有的进程,包括其他用户的进程。
  • x:显示没有控制终端的进程,例如后台运行的守护进程。
  • j:显示进程归属的进程组ID、回话ID、父进程ID,以及与作业控制相关的信息。
  • u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存的使用情况等。

        

Z(zombie)僵尸进程

  • 僵死状态 (Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用)没有读取到子进程推出的返代码时就会产生僵死(尸)进程。
  • 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态码。
  • 所以,只要子进程退出,父进程还在运行,但是父进程没有读取子进程状态,子进程进入Z状态。

僵尸进程危害

  • 进程的退出状态必须被维持下去,因为子进程要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可是父进程如果一直不读取,那么子进程就要一直处于Z状态?是的!
  • 维护退出状态本身就是要用数据维护,也属于进程的基本信息,所以保存在 task_struct(PCB)中。换句话说,Z状态一直不退出,PCB一直都要维护?是的。
  • 那一个父进程创建了很多子进程,但是不回收,是不是就会造成内存资源的浪费?是的,因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
  • 会导致内存泄漏?是的!

孤儿进程

  • 父进程退出,子进程就称之为“孤儿进程”。
  • 孤儿进程被1号 init/systemd 进程领养,当然要由 init/systemd 回收。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return 1;
    }
    
    else if (id == 0)
    { // child
        printf("I am child, pid : %d\n", getpid());
        sleep(10);
    }

    else
    { // parent
        printf("I am parent, pid: %d\n", getpid());
        sleep(3);
        exit(0);
    }

    return 0;
}

3.4 进程优先级

基本概念

  • CPU资源分配的的先后顺序,就是指进程的优先权(priority)。
  • 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的Linux很有用,可以改善系统性能。
  • 还可以吧进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。

查看系统进程

在Linux或者Unix系统中,用ps -l 命令则会输出一下内容:

我们很容易注意到其中几个重要信息,如下:

  • UID:代表执行者的身份
  • PID:代表这个进程的代号
  • PPID:代表这个进程是由哪个进程发展衍生而来的,就是父进程的代号
  • PRI:代表这个进程可执行的优先级,其值越小越早被执行
  • NI:代表这个进程的nice值

PRI && NI

  • PRI即进程的优先级,或者通俗来讲就是CPU执行进程的先后顺序,此值越小进程的优先级别越高。
  • NI就是nice值,其表示进程可执行的优先级的修正数值。
  • PRI值越小越早被执行,那么加入NI后,会使PRI变为 PRI(new) = PRI(old) + nice
  • 这样,当nice值为负的时候,那么程序将会优先级的值变小,优先级就会越高,那么就会越快被执行。
  • 所以调整优先级在Linux下就是调整nice值。
  • nice值的取值范围是 【-20,19】,一共40个级别。

PRI vs NI

  • 需要强调的是,进程的nice值不是进程的优先级,他们不是一个概念,但是nice值会影响到优先级变化。
  • 可以理解nice值是进程优先级的修正数据。

竞争、独立、并行、并发

  • 竞争性:系统进程数目众多,但是CPU资源只有少量甚至1个,所以进程之间是具有竞争属性的。为了高效的完成任务,更合理的竞争相关资源,便有了优先级顺序。
  • 独立性:多进程运行,需要独享各种资源,多进程运行期间相互不干扰。
  • 并行:多个进程在多个CPU下分别、同时进行运行,这称之为并行。
  • 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。

3.5 进程切换

CPU上下文切换:其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行的当前状态,也就是CPU寄存器中的所有内容。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的执行,这一过程就是 contex switch。

时间片:当代计算机都是分时操作系统,每个进程都有适合它的时间片(就是一个计数器)。时间片到达,进程就被OS从CPU中剥离下来。

Linux2.6内核进程 O(1)调度队列

一个CPU拥有一个runqueue

  • 如果有多个CPU就要考虑进程个数均衡负载问题。

优先级

  • 普通优先级:100 ~ 139 (都是普通的优先级,nice是 【-20,19】,可与之对应)
  • 实时优先级:0 ~ 99 (不关心)

活动队列

  • 时间片还没有结束的所有进程都按照优先级放在该队列。
  • nr_active:总共有多少个运行状态的进程。

  • queue[140]:一个元素就是一个进程队列,相同优先级的暗中FIFO规则进行排队调度,所以数组下标就是优先级。

  • 从该结构中,如何选择一个最合适的进程?

    • 从0下标开始遍历 queue[140] 。
    • 找到第一个非空队列,该队列必定为优先级最高的队列。
    • 拿到所选队列的第一个进程,开始运行,调度完成。
    • 遍历 queue[140] 时间复杂度是常数,但是效率还是太低了。      
  • bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用 5 * 32 个比特位表示队列是否为空,这样便可以大幅度提高查找效率。

过期队列

  • 过期队列和活动队列的结构一模一样。
  • 过期队列上放置的进程,都是时间片耗尽的进程。
  • 当活动队列上的进程都被处理完成之后,对过期队列的进程进行时间片重新计算。

active指针和expired指针

  • active指针永远指向活动队列
  • expired指针永远指向过期队列
  • 活动队列上的进程会随着CPU不断运行逐渐减少,过期队列会因为时间片耗尽导致过期进程越来越多。
  • 在合适的时候,OS会交换active指针和expired指针的内容,就相当于具有了一批新的活动进程。

4. 命令行参数和环境变量

4.1 基本概念

  • 环境变量(environment variables) 一般是指在OS1中用来指定OS运行环境中的一些参数。
  • 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们所链接的动静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
  • 环境变量通常具有某些特殊用途,还有在系统当中通常具有特性。

4.2 常见的环境变量

  • PATH:指定命令的搜索路径
  • HOME:指定用户的主工作目录(即用户登录到Linux系统中时,默认的目录)
  • SHELL:当前Shell,它的值通常是/bin/bash。

4.3 环境变量的组织方式

5. 程序地址空间

5.1 研究平台

  • kernel 2.6.32
  • 32位平台

5.2 程序地址空间

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;

int main(int argc, char *argv[], char *env[])
{
    const char *str = "helloworld";
    printf("code addr: %p\n", main);
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);
    static int test = 10;
    char *heap_mem = (char *)malloc(10);
    char *heap_mem1 = (char *)malloc(10);
    char *heap_mem2 = (char *)malloc(10);
    char *heap_mem3 = (char *)malloc(10);

    printf("heap addr: %p\n", heap_mem);     // heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem1);    // heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem2);    // heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem3);    // heap_mem(0), &heap_mem(1)
    printf("test static addr: %p\n", &test); // heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem);   // heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem1);  // heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem2);  // heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem3);  // heap_mem(0), &heap_mem(1)
    printf("read only string addr: %p\n", str);
    
    for (int i = 0; i < argc; i++)
    {
        printf("argv[%d]: %p\n", i, argv[i]);
    }
    
    for (int i = 0; env[i]; i++)
    {
        printf("env[%d]: %p\n", i, env[i]);
    }

    return 0;
}
$ ./a.out
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482e
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[5]: 0x7ffd0f9a486e
env[6]: 0x7ffd0f9a4892
env[7]: 0x7ffd0f9a48a5
env[8]: 0x7ffd0f9a48ae
env[9]: 0x7ffd0f9a48f1
env[10]: 0x7ffd0f9a4e8d
env[11]: 0x7ffd0f9a4ea6
env[12]: 0x7ffd0f9a4f00
env[13]: 0x7ffd0f9a4f13
env[14]: 0x7ffd0f9a4f24
env[15]: 0x7ffd0f9a4f3b
env[16]: 0x7ffd0f9a4f43
env[17]: 0x7ffd0f9a4f52
env[18]: 0x7ffd0f9a4f5e
env[19]: 0x7ffd0f9a4f93
env[20]: 0x7ffd0f9a4fb6
env[21]: 0x7ffd0f9a4fd5
env[22]: 0x7ffd0f9a4fdf

5.3 虚拟地址

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;

int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return 0;
    }

    else if (id == 0)
    { // child
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }

    else
    { // parent
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }

    sleep(1);

    return 0;
}

输出:

//与环境相关,观察现象即可 
parent[2995]: 0 : 0x80497d8 
child[2996]: 0 : 0x80497d8 

我们发现输出出来的变量值和地址,在父子进程是一样的,因为子进程继承父进程,父子没有对变量进行任何修改。但是将代码稍加改动:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;

int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return 0;
    }

    else if (id == 0)
    { // child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程再读取
        g_val = 100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }

    else
    { // parent
        sleep(3);
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    
    sleep(1);

    return 0;
}

输出:

//与环境相关,观察现象即可 
child[3046]: 100 : 0x80497e8 
parent[3045]: 0 : 0x80497e8 

这时我们发现,父子进程对于同一全局变量,输出地址是一致的,但是变量内容却不一样,能得到以下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
  • 但是地址是一样的,说明这个地址绝非我们所认知的物理地址!

在Linux地址下,这种地址叫做虚拟地址

我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址用户一概看不到,由OS统一管理。

OS负责把虚拟地址转化成物理地址。

5.4 进程地址空间

所以之前说“程序地址空间”是不准确的,准确来说应该是进程地址空间,那么该如何理解?

分页&&虚拟地址空间

理解虚拟地址空间

核心比喻:每个进程都有自己的 “私人空间”

想象一下,每个进程一出生,OS就为它们分配了一个 完全独立的、完整的、从零开始寻址的线性内存空间。

这个空间非常庞大 (在32位系统上是 4GB,64位系统上更是天文数字),并且 与所有其他进程的空间平行存在。

进程 A 的宇宙 (虚拟地址空间)   进程 B 的宇宙 (虚拟地址空间)
  0x00000000                    0x00000000
      ↓                              ↓
  ... [A的代码]                   ... [B的代码]
  ... [A的数据]                   ... [B的数据]
  ... [A的堆]                     ... [B的堆]
      ↓                              ↓
  0xFFFFFFFF                    0xFFFFFFFF

关键点

  1. 独立性/安全性:进程A无法直接感知或访问进程B的空间。一个进程的崩溃不会影响其他空间。这提供了根本的内存安全和稳定性。
  2. 一致性/简化:对于进程A而言,它“认为”自己独占了整个内存空间。它的代码、数据和堆栈总是出现在 固定的、预期的地址 上,这及大地简化了程序的编写和编译。
  3. 欺骗性:这个空间是“虚拟”的,它并不完全对应真实的物理内存(RAM)。OS和CPU内存管理单元(MMU)就像一个 翻译官 ,负责将虚拟空间的地址翻译成真实的物理地址。这个翻译过程对进程是透明的。

理解区域划分

现在,我们来看看这个“私人空间”内部是如何规划和管理的。这就是 区域划分。

核心比喻:城市规划图

把虚拟地址空间想象成一块要开发的土地,OS是总规划师,它制定了严格的  zoning (功能分区)法律。

32位 Linux 进程的经典城市规划

为什么要这样划分?

  • 安全性 & 稳定性                
    • 代码区只读:防止程序指令被意外或者恶意修改。
    • 内核空间受保护:用户程序崩溃不会波及操作系统。
  • 功能隔离 & 效率
    • 栈:为函数调用设计,后进后出(LIFO),分配/释放及其高效(只是移动栈指针)。
    • 堆:为生命周期灵活的内存设计,可以按需分配和释放,单管理更复杂。
    • 数据区:让全局变量有固定的 “家”,便于在程序启动时集中初始化。
  • 硬件协作
    • CPU 的 MMU 不仅做虚拟到物理的地址翻译,还负责强制执行这些区域的 权限 (读、写、执行)。一旦程序越界访问(例如向代码区写入),MMU会立即触发一个异常(段错误),由OS终止该进程。

5.5 浅谈虚拟内存管理

描述Linux下进程的地址空间的所有的信息结构体是 mm_struct (内存描述符)。每个进程只有一个 mm_struct 结构,在每个进程的 task_struct 结构中,有一个指向该进程的 mm_struct 结构体指针。

struct task_struct
{
    /*
       对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,
       对于内核线程来说这部分为NULL。
    */
    
    struct mm_struct *mm; 
    
    /* 
        该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,
        表⽰没有内存地址空间,可也并不是真正的没有,
        这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
    */
    
    struct mm_struct *active_mm; 
}

可以说,mm_struct 结构是对整个用户空间的描述。每一个进程都会有自己独立的 mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由 task_struct 到 mm_struct ,进程的地址空间的分布情况:

定位 mm_struct 文件所在位置和 task_struct 所在路径是一样的,不过他们所在文件是不一样的, mm_struct 所在的文件是 mmtypes.h。

struct mm_struct
{
    /*...*/
    struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */
    struct rb_root mm_rb;        /* red_black树 */
    unsigned long task_size;     /*具有该结构体的进程的虚拟地址空间的⼤⼩*/
    /*...*/
    // 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;
    unsigned long arg_start, arg_end, env_start, env_end;
    /*...*/
}

既然每一个进程都会有自己独立的 mm_struct,OS肯定要对这么多进程的 mm_struct 组织起来。

虚拟空间的组织方式有两种:

  1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;
  2. 当虚拟区多时采用红黑树进行管理,由 mm_rb 指向这棵树。

Linux内核使用 vm_area_struct 结构表示一个独立的虚拟内存区域(VMA),由于每个不同的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个 vm_area_struct 结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是 vm_area_struct 结构来连接各个 VMA,方便进程快速访问。

struct vm_area_struct
{
    unsigned long vm_start;                   // 虚存区起始
    unsigned long vm_end;                     // 虚存区结束
    struct vm_area_struct *vm_next, *vm_prev; // 前后指针
    struct rb_node vm_rb;                     // 红⿊树中的位置
    unsigned long rb_subtree_gap;
    struct mm_struct *vm_mm; // 所属的 mm_struct
    pgprot_t vm_page_prot;
    unsigned long vm_flags; // 标志位
    struct
    {
        struct rb_node rb;
        unsigned long rb_subtree_last;
    } shared;
    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;
    const struct vm_operations_struct *vm_ops; // vma对应的实际操作
    unsigned long vm_pgoff;                    // ⽂件映射偏移量
    struct file *vm_file;                      // 映射的⽂件
    void *vm_private_data;                     // 私有数据
    atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
    struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
    struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
    struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

所以我们可以对上图在进行更细致的描述,如下图所示:

5.6 为什么要有虚拟内存空间

这个问题其实可以转化为:如果程序直接操作物理内存会造成什么问题?

在早期的计算机中,要运行一个程序,会把这些程序全部装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址实际上都是实际的物理内存地址。当计算机同时运行多个程序时,OS是如何为这些程序分配内存的呢?

例如某台计算机总的内存大小是128MB,现在同时运行程序A和B,A需要占用内存10MB,B需要占用内存110MB。计算机在给程序分配内存时会采用这样的方法:先将内存中的前10MB分配给程序A,接着再从内存中剩余的118MB划分出110MB分配给程序B。

这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

  • 安全风险
    • 每个进程都可以访问任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内存区域,如果是一个木马病毒,那么它就能随意的修改内存空间,让设备直接瘫痪。
  • 地址不确定
    • 众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行 a.out 时候,内存当中一个进程都没有运行,所以搬移到内存地址是 0x00000000,但是第二次的时候,内存已经有10个进程在运行了,那么执行 a.out 的时候,内存地址就不一定了。
  • 效率低下
    • 如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法就是降不常用的进程拷贝到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程一起拷走(因为物理内存时连续的),这样在内存和磁盘之间拷贝时间太长,效率较低。

                物理内存是连续的,所以拷贝时需要一整块符合要求内存大小的空间,虚拟内存只用交换页就好了。

存在这么多问题,有了虚拟地址空间和分页机制就都能解决了吗?当然!

  • 地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,也一定要在OS的监管之下来进行访问!也顺便 保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!
  • 因为有地址空间和页表映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载。物理内存的分配 和 进程的管理就可以做到没有关系,进程管理和内存管理模块就完成了解耦合
    • 因为有地址空间的存在,所以我们在C、C++语言上 new、malloc空间的时候,其实就是在地址空间上申请的,物理内存可以甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户完全0感知!!
  • 因为页表映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在 进程视角所有的内存分布都可以是有序的

                                                虚拟空间连续,物理空间不连续。

Logo

昇腾计算产业是基于昇腾系列(HUAWEI Ascend)处理器和基础软件构建的全栈 AI计算基础设施、行业应用及服务,https://devpress.csdn.net/organization/setting/general/146749包括昇腾系列处理器、系列硬件、CANN、AI计算框架、应用使能、开发工具链、管理运维工具、行业应用及服务等全产业链

更多推荐