冯诺依曼体系结构

1-1 理解冯诺依曼体系

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

截至目前,我们所认识的计算机,都是由一个个的硬件组件组成
输入单元:包括键盘, 鼠标,扫描仪, 写板,磁盘等
中央处理器(CPU):含有运算器控制器
输出单元:显示器,打印机等
存储器:内存
关于冯诺依曼,必须强调几点:
这里的存储器指的是内存
不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)(数据层面)
外设(输⼊或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取
⼀句话,所有设备都只能直接和内存打交道
体系结构规定软件运行,必须加载到内存
数据是从一个设备"拷贝"到另一个设备

当代计算机是性价比产物,芯片技术 + 摩尔定律

1-2 理解数据流动

        我们直接用qq聊天来举例

数据流动本质:拷贝

2 操作系统(operator system)

2-1 概念

任何计算机系统都包含一个基本的程序集合,称为操作系统(OS),操作系统是一个进行软硬件管理的软件。笼统的理解,操作系统包括:
• 内核(进程管理,内存管理,文件管理,驱动管理)
• 其他程序(例如函数库,shell程序等等)

2-2 设计OS的目的

对上,为用户程序(应用程序)提供一个良好的执行环境
• 对下,与硬件资源,管理所有的软硬件资源

注意:

1 软硬件体系结构层状结构

2 访问操作系统,必须使用系统调用 -- 其实就是函数只不过是系统提供的

3 我们的程序,只要你判断出它访问了硬件,那么它必贯穿整个体系结构!

4 库可能在底层做了封装

2-3 核心功能

在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件

2-4 如何理解"管理"

我们通过学生,辅导员,校长之间的关系来理解一下管理

        我们思考一个问题? 校长管理学生要自己一个一个去管理吗?实际上是不用的,校长想要知道学生的出勤情况,以及了解这个学生的基本信息做出安排,只需要看辅导员专门收纳学生信息的数据表就行了,然后根据数据表做出安排,然后让辅导员去执行落实,校长和学生实际上从来没有见过面。那么在日常的校长在管理学生的工作就变成了对于中间层数据传上来形成的数据表的增删查改

结论:

1 要管理者,管理者和被管理者,可以不需要见面

2 管理者和被管理者,根据"数据"进行管理

3 不需要见面,如何得到数据? 由中间层得到  

总结:

管理 就是先描述,再组织

描述起来,用struct结构体。组织起来,用链表或其他高效的数据结构

2-5 系统调用和库函数概念

理解系统调用和库:

        操作系统要向上提供对应的服务,但是操作系统不信任任何用户或者人,所以系统提供一套函数即为系统调用专门来进行系统和用户之间,进行某种数据交互。

        就像银行不信任顾客取钱的时候不可能让顾客直接进金库里面拿,而是开启一个一个窗口让用户来窗口处取钱而这些窗口就是系统调用的接口函数

        库就是一些开发者发现系统调用对用户要求比较高或者为了提高代码的可移植性所以对系统调用函数做了适度的封住,例如C语言库

系统调用和库的关系:

        系统调用和库其实就是上下层的关系,就像银行系统和服务窗口一样,只是银行给用户提供服务的地方

3 进程

3-1 基本概念和基本操作

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

3-1-1 描述进程PCB

基本概念

• 进程信息被放在⼀个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
• 课本上称之为PCB(process control block), Linux 操作系统下的 PCB 是: task_struct

task_struct-PCB 的一种

• 在 Linux 中描述进程的结构体叫做 task_struct 。
• task_struct 是 Linux 内核的⼀种数据结构类型,它会被装载到 RAM(内存) 里并且包含着进程的信息。

3-1-2 task_ struct

内容分类
标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
• 优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据: 进程执行时处理器的寄存器中的数据。
• I∕O状态信息: 包括显示的I/O请求,分配给进程的I∕O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
• 其他信息
• 具体详细信息后续会介绍
组织进程
        可以在内核源代码里找到它。所有运行在系统里的进程都以 task_struct 双链表的形式存在内核里。

3-1-3 初步理解以及查看进程

1. 进程的信息可以通过 /proc 系统文件夹查看

2. 多数进程信息同样可以使用 top ps 这些用户级工具来获取

ps ajx 查看所用进程,用 && 可以同时实行两份指令

注意:

        从这里看,bash也是个进程,所以我们可以得到一个结论就是命令行解释器本质上就是一个进程,我们历史上执行所有指令,工具,自己程序,运行起来,全部都是进程

3.  进程中自带了 cwd 会记录下自己当前的工作路径 还有 exe 会记录自己进程所对应可执行程序文件 

3-1-5 通过系统调用获取进程标示符

进程id(PID)

父进程 id(PPID)
  1 #include <stdio.h>
  2 #include <unistd.h>
  3 
  4 int main()
  5 {
  6     printf("hello world\n");
  7     printf("pid:%d,ppid:%d\n",getpid(),getppid());                                                                                              
  8     return 0;
  9 }

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

• 运行 man fork 认识fork

• fork 有两个返回值
• 父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)
  1 #include <stdio.h>
  2 #include <unistd.h>
  3 
  4 int main()
  5 {
  6     int ret =fork();                                                                                                                            
  7     printf("hello process:%d,ret:%d\n",getpid(),ret);
  8     return 0;
  9 }

 fork 后通常用 if 进行分流,子进程的返回值是0,父进程的返回值是那个子进程的pid

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 
  5 int main()
  6 {
  7     int ret =fork();
  8     if(ret==0)
  9     {
 10         printf("我是子进程,我的pid是:%d\n",getpid());
 11         exit(1);
 12     }
 13 
 14     printf("我是父进程,我的pid是:%d\n",getpid());                                                                                               
 15 
 16     return 0;
 17 }

3-1-7 理解子进程--fork

1 fork会有两个不同的返回值而且fork函数会返回两次,并且父进程和子进程的数量比为 1 : n 。因为父子进程在fork函数内部就已经完成了子进程的创建和调用所以子进程也会执行fork的return语句

2 为什么在上述中一个变量,即 ==0 ,又大于0?导致 if else 同时成立?是因为子进程会对哪一个变量进行写时拷贝所以虽然是同一个变量名但是实际上在底层物理空间中区分开了 

结论: 进程具有独立性

3-2 进程状态

3-2-1 初步理解进程状态

1 课本上的说法 -- 名词提炼

2 运行 && 阻塞 && 挂起

运行:进程在调度队列,进程的状态都是running

阻塞:等待某种设备或者资源就绪 (键盘,显示器,网卡,磁盘,摄像头,话筒 ....)

挂起:当内存不足时,在磁盘上有一个swap交换区就会将一些数据代码拷贝到swap上当这个过程就是挂起,就是为了缓解内存压力

3-2-2 理解内核链表的话题

        我们从上面可以看到在内核中可以看到,struct device 为什么可以挂在在task_struct 上,因为他们不是像链表一样直接存放的是节点,而是在每一个结构体中存放指针,然后通过这个指针在整个结构体的偏移量就可以找到对应的指针进行链接

3-2-3 Linux内核源代码怎么说

/*
*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)":可中断休眠,浅睡眠,例如读取外设输入就是 S 状态(阻塞状态),信号可以唤醒

"D (disk sleep)":不可中断休眠,深度休眠,常用于磁盘文件读取时候的 D 状态(阻塞状态),信号不可以杀死

"T (stopped)":进程完全暂停执行,但是进程可以被再次执行

"t (tracing stop)":调试断点暂停,gdb 使用

"X (dead)":进程退出

"Z (zombie)":僵尸状态,为了让父进程可以获得推出信息

3-2-4 进程状态查看
 

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

3-2-5 Z(zombie)-僵尸进程

• 我们创建子进程的目的就是为了让子进程完成某种事情,所以为了知道事情完成的程度和情况我们父进程就必须从子进程中得到放在task_struct的信息

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

3-2-6 僵尸进程危害

• 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我
办的怎么样了。可父进程如果⼀直不读取,那子进程就⼀直处于Z状态?是的!

• 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,
换句话说,Z状态一直不退出,PCB⼀直都要维护?是的!
• 那⼀个父进程创建了很多⼦进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数
据结构对象本身就要占用内存,想想C中定义⼀个结构体变量(对象),是要在内存的某个位置
进行开辟空间!
• 是否存在内存泄漏?是的!
• 什么样的进程内存泄漏问题,比较麻烦?常驻内存比如内核结构申请的task_struct。因为在内核内部会维护一个关于废弃的task_struct的链表所以申请task_struct有时候不需要而外开辟空间而是直接在链表上摘取一个结构体这个结构体内存就是常驻内存

3-2-7 孤儿进程

• 父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
• 父进程先退出,子进程就称之为“孤儿进程”
孤儿进程被1号init/systemd进程领养,当然要有init/systemd进程回收喽。因为不回收让进程一直保持在僵尸状态会导致内存泄漏

扩展:

可以命令用这个直接杀死进程
kill -9 进程pid

3-3 进程优先级

3-3-1 基本概念

优先级是什么?

        是进程得到CPU资源的先后顺序,注意和权限做区分,权限是决定能否得到某种资源,而优先级是决定得到资源的先后顺序
 

为什么要有优先级?

        目标资源短缺,导致要通过优先级决定谁先谁后
 

优先级是怎么实现的?

        通过PRI:决定进程的优先级,NI:进程优先级的修改数据,这俩个数据来决定优先级,PRI+NI的值越低,优先级越高,反之越低,是基于时间片的分时操作系统,考虑公平性,优先级可能发生变化,但是变化幅度不大
 

优先级的意义

• 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
• 还可以把进程运行到指定的CPU上,这样⼀来,把不重要的进程安排到某个CPU,可以改善
系统整体性能。

3-3-2 查看系统进程

我们很容易注意到其中的几个重要信息,有下:
• UID : 代表执行者的身份(系统通过进程访问来确定访问文件的人的身份)
• PID : 代表这个进程的代号
• PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
• PRI :代表这个进程可被执行的优先级,其值越小越早被执行
• NI :代表这个进程的nice值
进程真实优先级=PRI(默认)+NI

3-3-3 PRI and NI

• PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高

• 那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
• PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
• 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快
被执行
• 所以,调整进程优先级,在Linux下,就是调整进程nice值
nice其取值范围是-20至19,⼀共40个级别
调整优先级的方法:
1 命令:nice命令对于新建进程,启动时设置nice,renice命令对于已有进程,动态修改nice值
2 系统调用:
        getpriority可以得到优先级数据,setpriority可以设置优先级数据

3-3-4 PRI vs NI

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

3-3-5 补充概念-竞争、独立、并行、并发

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


3-4 进程切换

3-4-1 理解死循环

死循环的运行:

        a. 一旦一个进程占据CPU,会把自己的代码跑完吗? 不会!! 时间片的东西
        b. 死循环进程,不会打死系统,不会一直占据CPU而是进行进程切换

3-4-2 理解CPU和寄存器

寄存器就是CPU内部的临时空间

寄存器 != 寄存器内部的数据,寄存器是空间内存,寄存器内部的数据是内容是多变的

3-4-3 理解进程切换

        CPU上下文切换:其实际含义是任务切换, 或者CPU寄存器切换。当多任务内核决定运行另外的任务时, 它保存正在运行任务的当前状态, 也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中, 入栈工作完成后就把下⼀个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器, 并开始下⼀个任务的运行, 这一过程就是context switch。
举例理解如何切换:
        如果我们现在选择去当兵,那我们要先向辅导员申请,然后申请通过学校为我们保留学籍这这个过程,就是进程开始从CPU中剥离下来并且保存数据的过程。当兵的过程,就是我们被CPU切换的过程,当我们当兵回来就要恢复学籍这个过程,就是CPU恢复我们这个进程的上下文数据,因为我们恢复完我们的学籍我们原来是那个年级的现在还是从那个年级开始读,就像我们进程恢复后也还是从原本结束的地方开始进行,我们不需要从头开始读,进程也不需要从头开始再执行
具体:
 

进程切换,最核心的,就是保存和恢复当前进程的硬件上下文数据,既CPU内寄存器的内容!!

参考一下Linux内核0.11代码:

结论:

        所以当前进程要把自己的硬件的上下文数据,保存起来,保存在哪里?显而易见,保存在了task_struct的TSS(任务状态段)内部

3-5 Linux内核调度算法

3-5-1 认识调度队列

上图是Linux2.6内核中进程队列的数据结构

注意:⼀个CPU拥有⼀个runqueue,如果有多个CPU就要考虑进程个数的负载均衡问题

3-5-2 从运行队列理解优先级

• 普通优先级:100〜139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)

• 实时优先级:0〜99(不关心)

3-5-3 理解 active 指针和 expired 指针

• active 指针永远指向活动队列
• expired 指针永远指向过期队列
• 可是活动队列上的进程会越来越少过期队列上的进程会越来越多,因为进程时间片到期是⼀直
都存在的。
• 没关系,在合适的时候,只要能够交换 active 指针和 expired 指针的内容,就相当于有具有了⼀批新的活动进程!

3-5-4 理解O(1)调度算法

1 挑队列

队列分类

队列分为active 活动队列expired过期队列

队列作用

active 活动队列:存放时间片(一个进程单次占用CPU的时间)还没有耗尽的就绪进程,调度器优先从这里选任务运行。

expired 过期队列:用来存放时间片已经用完的就绪进程。 进程刚挪进过期队列的时候,内核就已经给它分配好了新的时间片,只是这一轮调度周期(epoch)不会运行它。 等到活动队列里所有进程时间片全部耗光、活动队列为空时,直接交换两个队列的指针。过期队列就变成新的活动队列,里面的进程带着之前已经分配好的时间片,进入下一轮调度;交换队列这个动作本身,不会再重新计算时间片

队列的内容

nr_active: 总共有多少个运行状态的进程
queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,
数组下标就是优先级
bitmap[5]:⼀共140个优先级,⼀共140个进程队列,为了提高查找非空队列的效率,就可以用
5*32个比特位表示队列是否为空,这样,便可以大提高查找效率!

队列挑选

        调度器优先使用活动队列,通过判断nr_active活动队列里还有没有就绪进程。进程在活动队列上运行,单个进程时间片用完,就把这个进程挪到过期队列,并分配新的时间片

        随着一个个进程耗尽时间片,活动队列里的进程不断变少。当活动队列的 nr_active 变为 0,也就是活动队列里没有可运行的普通进程时,交换两个队列的指针:原来的过期队列变成新的活动队列,原来已经空掉的活动队列变成新的过期队列,继续下一轮调度。

2 挑进程
从该结构中,选择一个最合适的进程,过程是怎么的呢?
1.先检查active->nr_active,判断是否需要互换 active 和 expired 队列。如果活动队列为空,交换两者指针
2.依靠bitmap位图标记清单,调用找位函数直接定位位图中第一个置 1 的位,对应queue[140]的链表
3. 找到第一个非空队列,该队列必定为优先级最高的队列
4. 取出选中队列的第一个进程既形成队列的链表的头节点(FIFO规则),投入 CPU 运行,开始消耗时间片,本次调度挑选进程完成!

4 命令行参数和环境变量

4-1 命令行参数

4-1-1 基本概念

执行程序的时候,在命令后面跟的参数,就是命令行参数。C 语言程序通过 main 函数两个参数接收

int main(int argc,char* argv[])
{
    return 0;
}

4-1-2 理解命令行参数表

        bash 作为命令行解释器,读取用户输入,解析并构造命令行参数表(字符串指针数组),数组末尾以NULL作为结束标记无引号的空格会被 bash 当作分隔符切分成多个参数被引号包裹的含空格内容整体作为一个参数,不会被切分

./prog hello world     #空格作为分割符

./prog "hello world"   #空格被引号包裹,作为参数选项放入命令行参数表

        bash 构造完参数表后,将它传递给新创建的应用程序,供程序 main 函数的 argv 使用,参数表归属于新程序,bash 不会长期维护该表,参数表中argv[0] 存放程序自身的名称,后面依次为各个命令行选项与参数。

例:

  1 #include <stdio.h>
  2 
  3 int main(int argc,char*argv[])
  4 {
  5     int i=0;
  6     for(i=0;i<=argc;i++)                                                                                                                        
  7     {
  8         printf("argc[%d]:%s\n",i,argv[i]);
  9     }
 10     return 0;
 11 }

4-2 环境变量

4-2-1 基本概念

• 环境变量(environment variables)⼀般是指在操作系统中用来指定操作系统运行环境的一些参数
• 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪
里,但是照样可以链接成功,生成可执行程序,原因就是相关环境变量帮助编译器进行查找。
• 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
 环境变量就是shell / 进程的全局字符串键值对,格式:名称 = 内容,例如 PATH=/user/bin:/bin
( : 是Linux下的路径分隔符)

4-2-2 理解环境变量

• 从存储角度理解,环境变量会在 bash 形成一张表 --- 环境变量表

• 系统中存在环境变量,来帮助系统找到目标二进制文件

 环境变量最开始从哪里来?从系统的相关配置文件上来

环境变量组织方式环境变量表

每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以 ’ \0 ’ 结尾的环境字符串

4-2-3 常见环境变量

• PATH : 指定命令的搜索路径

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

4-2-4 查看环境变量的方法

echo $NAME //NAME:你的环境变量名称

4-2-5 结合环境变量理解命令

        从我们自己的可执行程序和系统的命令来看,为什么我们的可执行程序在运行的时候,都要加上 ./ 但是系统的命令例如 ls 就不需要加 ./

        这是因为在环境变量中有一个PATH的环境变量,当我们在执行命令的时候,系统就会通过环境变量在系统中找到对应的可执行程序,但是我们自己的代码就需要点明路径因为没有相对应的环境变量

4-2-6 获取环境变量的方法

1 操作

1. echo: 显示某个环境变量值

2. export: 设置一个新的环境变量

export 键 = 值

3. env: 显示所有环境变量

4. unset: 清除环境变量

5. set: 显示本地定义的shell变量和环境变量

        注意:export创建的环境变量会在服务器断链即bash进程结束的时候清除,如果想要永久保存那要在bash的环境变量配置表里新增

2 代码

• 命令行的第三个参数

  1 #include <stdio.h>
  2 
  3 int main(int argc,char*argv[],char* env[])                                                                                                      
  4 {                                        
  5      int i = 0;
  6      for(; env[i]; i++){
  7          printf("%s\n", env[i]);
  8      }                         
  9      return 0;                            
 10 }       

• 第三方全局变量environ

1 #include <stdio.h>
2 int main(int argc, char *argv[])
3 {
4     extern char **environ;
5     int i = 0;
6     for(; environ[i]; i++){
7         printf("%s\n", environ[i]);
8     }
9     return 0;
10 }

• 系统调用,getenv(),putenv()

4-2-7 理解环境变量的特性        

• 环境变量具有全局属性,可以被子进程继承下去

bash 会记录两套变量
        环境变量: 通过 export这个内建命令(不需要创建子进程,而是让bash自己亲自执行,bash自己调用函数,或者系统调用完成),来创建的变量

export i 

        本地变量:直接设置得到,不会被子进程继承,只在bash内部使用

i=10

5 程序地址空间

5-1 程序地址空间回顾

我们回顾我们之前学习C/C++语言的时候,我们进程讲的内存空间结构图

5-2 引入虚拟地址

我们先来看一个现象

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <sys/wait.h>
  4 #include <stdlib.h>
  5                 
  6 int main(int argc,char*argv[],char* env[])
  7 {               
  8     int a=10;   
  9     int ret =fork();
 10     if(ret == 0)
 11     {           
 12          a++;   
 13          printf("变量a的值为:%d,它的地址是%p\n",a,&a);
 14          exit(1);                                                                                                                               
 15     }                                                                                                                                           
 16     int status=0;
 17     waitpid(ret,&status,0);
 18     printf("变量a的值为:%d,它的地址是%p\n",a,&a);
 19     return 0;                                                                                                                             
 20  } 

        明明子进程已经对变量a进行修改了,发生了写时拷贝,确实打印输出的变量a的值也确实不同,但是为什么我们取地址却对应的时一样的地址值呢?

        这是因为这个地址不是实际我物理地址,在Linux下这个地址叫做虚拟地址我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理OS必须负责将 虚拟地址 转化成 物理地址 。

5-3 进程地址空间

所以之前说 ‘ 程序的地址空间 ’ 是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?

分页&虚拟地址空间


        这个就可以解释之前看到的现象,同一个变量,地址相同,其实是虚拟地址相同,但是内容可能被映射到不同的物理内存上。

举例理解

        一个大富翁分家产,为了不寒几个孩子的心,都说会将所有家产都给他们,这就是我们常见的画饼,其实大富翁就是操作系统,而孩子就是经常,这个所谓的饼就是虚拟地址空间,虽然都说将所有的家产给你即让每个进程认为它一个人独占4GB的物理空间,实际上没有独占而是画的饼

        问题来了,饼要不要管理? 当然是要的,也是先描述,再组织的管理方法,所以虚拟地址空间本质也是一个数据结构。

        那是用什么方式记录呢?记录在一个结构体内部,而且它只记录每一个段的开始和结束的地址,对整个虚拟地址空间进行区域划分,来管理整个虚拟地址空间

总结进程创建的流程

        1.在虚拟地址空间中申请指定大小的空间,调整区域划分
        2.加载程序,申请物理空间
        3. 进行页表映射,将物理地址转化为虚拟地址,提供给上层用户使用

5-4 虚拟内存管理

        描述Linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有一个mm_struct结构,在每个进程的 task_struct 结构中,有一个指向该进程的mm_struct结构体指针。
struct task_struct
{
    /*...*/
    struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他
//的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。

    struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当
//该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因
//为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。

    /*...*/

}
        可以说, mm_struct 结构是对整个用户空间的描述。每⼀个进程都会有自己独立的 mm_struct , 这样每一个进程都会有自己独立的地址空间才能互不干扰,那么 mm_struct 初始化值从哪来? 加载的时候,进行初始化。先来看看由 task_struct 到 mm_struct ,进程的地址空间的分布情况:
定位 mm_struct 文件所在位置和 task_struct 所在路径是⼀样的,不过他们所在文件是不⼀样
的, mm_struct 所在的文件是 mm_types.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 ,操作系统肯定是要将这么多进程的 mm_struct 组织起来的!虚拟空间的组织方式有两种:
1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;
2. 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树。
        linux内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个 vm_area_struc t结构来分别表示不同类型 的虚拟内存区域。上面提到的两种组织方式使用的就是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-5 为什么要有虚拟地址空间

安全风险
每个进程都可以访问任意的内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内
存区域,如果是⼀个木马病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
地址不确定
众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中
去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝
的实际内存地址每⼀次运运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程
都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程
在运行了,那执行a.out的时候,内存地址就不⼀定了
效率低下
如果直接使用物理内存的话,一个进程就是作为⼀个整体(内存块)操作的,如果出现物理
内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内
存,但是如果是物理地址的话,就需要将整个进程⼀起拷走,这样,在内存和磁盘之间拷贝
时间太长,效率较低。
存在这么多问题,有了虚拟地址空间和分页机制就能解决了吗?当然!
地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,
也⼀定要在OS的监管之下来进行访问!!也顺便保护了物理内存中的所有的合法数据 ,包括各个进程以及内核的相关有效数据!
因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置
的加载!物理内存的分配 和 进程的管理就可以做到没有关系, 进程管理模块和内存管理模块就完
成了解耦合
因为有地址空间的存在,所以我们在C、C++语言上new, malloc空间的时候,其实是在地址
空间上申请的,物理内存可以甚至⼀个字节都不给你。而当你真正进行对物理地址空间访问
的时候,才执行内存的相关管理算法,比如缺页中断,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统⾃动完成,用户包括进程完全0感知!!
因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的
虚拟地址和物理地址进行映射,在 进程视角所有的内存分布都可以是有序 的。
澄清一些问题:
        1. 我们可以不加载代码和数据,只有task_struct,mm_struct,页表,然后之后要加载代码数据和代码的时候,就发生缺页中断再进行加载
 
        2. 进程挂起就是将进程的代码数据拷贝到磁盘的swap分区
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐