目录
冯·诺依曼体系结构
操作系统
管理
系统调用和库函数
进程的概念
进程控制块——PCB
查看进程
通过系统调用获取进程标示符
通过系统调用创建进程
进程状态
运行状态-R
编辑
浅度睡眠状态-S
深度睡眠状态-D
暂停状态-T
死亡状态-X
僵尸状态-Z
僵尸进程的危害
孤儿进程
进程优先级
PRI和NI
其他
环境变量
常见的环境变量
环境变量的组织方式
进程地址空间
细谈虚拟地址
换入换出
冯·诺依曼体系结构
在讲解进程之前,我们先来讲解一下计算机中经常会提到冯·诺依曼体系结构,我们生活中常见的计算机或者是不常见的大型服务器,这些都是遵守冯·诺依曼体系的。
为什么是这样的结构那就需要谈一谈了。
计算机的出现是为了解决问题的,首先需要将问题(数据)输入到计算机当中,所以必须要有输入设备。计算机解决完问题后还需要显示出来,所以必须要有输出设备。
计算机通过输入设备拿到数据,数据在计算机中会进行一系列的算术运算和逻辑运算(算逻运算),通过输出设备进行输出,这就是简单的一个流程。
但是计算机不仅要有算逻运算功能,还要有控制功能,控制输入和输出设备。所以具有算逻运算功能和控制功能的模块称为中央处理器,也就是CPU。
但是相对于CPU,输入输出设备是很慢的,会拖慢整个体系的速度,为了不让CPU和输入输出设备直接进行交互,添加了存储器,也就是内存。
内存要比输入输出设备快很多,但是又比CPU慢,它在中间起到一个缓冲作用。
像C语言中写的scanf函数,运行的时候被加载到内存中,等待输入设备的输入;printf也是,有的时候并不是直接输出的输出设备,而是先加载到内存中。这就是一个IO的过程。
所以程序的运行也必须先加载到内存中,CPU只能和内存交互,而程序需要使用CPU。
操作系统
简单来说,操作系统就是进行软硬件资源管理的软件。他就是一个软件,买回来的电脑也是帮我们装好了操作系统,我们才能方便使用。
设计操作系统的目的一是为了更好的与硬件交互。管理所有的软硬件资源;二是为用户提供一个良好的执行环境,总而言之,操作系统最重要的就是管理。
当我们把计算机拿到手的时候,肉眼可见的就是一个“盒子”,里面有很多的硬件,比如键盘显示器啥的。他们在底层也是遵循冯·诺依曼体系结构排列的。
我虽然知道他是一堆硬件,但我不知道怎么用啊,这就使用操作系统来帮我们管理,方便我们使用。但是操作系统也不是直接和底层硬件交互的,在操作系统和底层硬件中间添加了一层驱动层,它来帮我们控制底层硬件。
操作系统主要帮我们进行内存管理、进程管理、文件管理、驱动管理。它再往上就是用户层,也就是我们使用的这一层。
操作系统也不能让用户直接访问它,它为用户提供了一些系统调用接口,但是普通用户的使用成本太高,人们又在系统调用接口之上有添加了一层用户操作接口,这些接口就是对系统调用接口进行封装,也就是我们平常使用的函数。
管理
上面说了这么多,最重要的还是管理,操作系统是帮我们管理计算机的。
举个例子,平常生活常见的还是老师对学生进行管理,我们肯定写过学生管理系统这个小项目,有一个struct结构体,里面存放学生的信息,把所有学生的信息都管理起来就是学生管理系统。
现在老师想要挑几个学生去做什么,要成绩好的,这时候就可以通过这个管理系统根据学习成绩找到。假如有一天,有一个领导来选几个学习好的学生,但是领导是不认识这些学生的啊,他依旧可以通过这个管理系统找到对应的学生。
所以管理的并不是这些学生,管理的是这些数据,通过数据结构把学生的信息都组织起来,对这个数据结构进行增删改查。
总结:管理就是,先将被管理者的各种信息进行描述,然后再将多个被管理者的描述信息根据某种数据结构组织起来,最后管理者管理被管理者实际上就是对数据结构的管理。操作系统中也存在着大量的数据结构和算法。 最重要的就是这六个字:先描述,再组织。
系统调用和库函数
操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用接口。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以有的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
进程的概念
进程就是一个正在执行的实例,一个程序运行起来,他就变成了一个进程。从内核的观点来看,进程是分配系统资源(内存、CPU)的实体。不知道没关系,继续往后看。
我们写完代码经过编译链接后形成的可执行文件是放在硬盘上的。当我们点击它让它运行起来就是把它加载到内存中,只有加载到内存中,CPU才可以执行它,这个时候他就不再是一个程序,而是一个进程。
进程控制块——PCB
操作系统是帮助我们管理的,既然有进程,操作系统要不要帮我们管理进程呢,答案是要的,如何管理进程呢,就是先描述,再组织。
电脑运行起来就会有很多进程,这些都是需要管理的,操作系统整理这些进程的所有属性,整理完后写入struct结构体中,这样每一个结构体就对应一个进程,要管理这些进程就是对结构体中数据的管理,这个结构体就叫做进程控制块,也叫做PCB(process control block)。
操作系统把这些PCB用双链表的形式组织起来,这样对进程的管理就变成了对PCB链表的增删改查。
所以什么是进程,进程就是:对应的代码和数据+进程PCB数据结构(内核数据结构)。
task_struct-PCB的一种
- 在Linux中描述进程的结构体叫做task_struct
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息
task_ struct内容分类(成员变量)
- 标示符: 描述本进程的唯一标示符,用来区别其他进程
- 状态: 任务状态,退出代码,退出信号等
- 优先级: 相对于其他进程的优先级
- 程序计数器: 程序中即将被执行的下一条指令的地址
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等
查看进程
说了这么多,说了进程是什么,我还没有见过呢,接下来就来看一看它是什么。使用ps命令就可以查看进程信息。
ps axj就可以查看,在这之前先写一个循环代码,保证让这个进程一直在执行,如果不写循环,进程一下子就结束了,也就看不到了。
ps axj | head -1 && ps axj | grep myproc
两个窗口,但是同一个机器,左边保证进程一直在运行,右边输入命令,把进程信息第一行和正在运行的进程找出来。
也可以使用top,运行就类似于任务管理器一样的窗口。
也可以通过根目录下的一个文件查看,使用ls /proc就可以查看
在这之中有很多数字,这些就对应进程的pid,通过ps命令,找到了刚才进程的pid。
在这个文件下果然有一个和pid相同的文件,那我们就看看里面有什么。
其中有一行是cwd ->...,cwd(current working directory)代表当前工作目录,当进程运行时,就会有一个属性来保存自己所在的路径,所以有需要打开当前目录的操作就可以通过它找到,以上都是简单介绍,通常用还是使用ps命令。
通过系统调用获取进程标示符
这就要说一下目前为止使用的第一个系统调用getpid。使用man getpid可以查看这个调用信息。
pid_t是系统提供的类型,是一个无符号整数,使用的时候写上头文件,不需要传入参数就可以查看pid。
pid是什么,pid是子进程的id,进程也是有父子关系的,想要获得父进程的pid就需要使用getppid了。
这里的pid不一样是因为重新编写之后,再启动时用的进程是不一样的,pid就会变成其他的。下面的就是代码。
下面我们再说一下,pid是这个进程,但是ppid是什么的,这个进程是我启动的啊,不急,我们再查看一下ppid是什么。
可以看到,ppid就是bash,bash就是这个进程的父进程。bash是什么呢?
我们重新启动这么多遍,pid一直在变,但是父进程一直不变,bash也就是我们用的shell,它通过创建子进程来执行的,子进程销毁了并不影响bash。
如何创建子进程,下面我们就来说一下。
通过系统调用创建进程
这里我们先来认识一下fork,使用man fork看一下,他的作用就是创建一个子进程。
这个fork很奇怪,他有两个返回值,但是一个函数怎么可以有两个返回值呢?
pid_t fork(); // 返回值: // 失败就返回-1 // 成功时,给父进程返回子进程的pid,给子进程返回0
下面来实验一下。
图片可能不够直观,在打印的时候是两行一起出来的,而且,有一个进程的ppid是另一个进程的pid,所以这里有两个进程,一个父一个子,在fork之后,代码是父子共享的。
我们在把返回值加上pid_t ret = fork();,上面说到fork创建子进程成功,给父进程返回子进程的pid,给子进程返回0。
但是我们知道了又能怎么样的,我把你创建出来,结果你干着和我一样的事,这有什么用呢?接下来就是如何让他们干不同的事。
#include <stdio.h> #include <unistd.h> int main() { pid_t id = fork(); // 创建子进程 if (id < 0) // 创建子进程失败 { // 失败 perror("fork"); return 1; } else if (id == 0) { // 值是0,代表子进程 while (1) { printf("I am child, pid:%d, ppid:%d\n", getpid(), getppid()); sleep(1); } } else { // 值大于0,返回了子进程的pid,所以是父进程 while (1) { printf("I am parent, pid:%d, ppid:%d\n", getpid(), getppid()); sleep(1); } } return 0; }
现在就可以看到两个现象:if else语句两个在同时执行,两个while循环在同时执行。
再使用ps命令就可以看到两个进程。
看到这里我们在回过头来看看,fork为什么会有两个返回值,创建子进程的时候,操作系统会帮我们新建一个task_struct结构体,这就是子进程的PCD,它的内部属性要以父进程为模板来构建。在CPU和操作系统运行某一个程序的时候会在运行队列中选一个task_struct。当我们的fork函数运行到return语句的时候,这时子进程已经被创建出来了,返回也是只是返回一个值,等到操作系统调度到这两个进程的时候,父进程和子进程返回对应的值,所以会返回两个值,但是是不同进程各自返回的。
还有一个问题,fork之后,父子进程调度的顺序是不一定的,父进程运行一会儿就被放到了运行队列的后面,子进程运行一会儿也会被放到队列后面,所以顺序是不一定的,由操作系统调度算法决定。
进程状态
如果各位学过学校的操作系统课程一定会见过类似于这样的图。
那我们就来介绍一下各个状态:
创建:创建一个进程。
运行:task_struct 结构体在运行队列中,就叫做运行态。
阻塞:等待非CPU资源就绪, scanf和cin函数执行的时候就是阻塞,他们在等待键盘等资源的输入。
挂起:当内存不足的时候,操作系统通过适当的位置置换进程的代码和数据到磁盘
我们看一下Linux下的进程状态。
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 */ };
接下来就一个一个介绍他们。如何查看每个进程的状态,还是那个命令ps axj;。
运行状态-R
一个进程处于R-运行状态(running)对应上面的运行态,并不是说进程一定处于运行当中,运行状态表明一个进程要么在运行中,要么在运行队列里。也就是说,可以同时存在多个R状态的进程。
写一个循环就是运行状态。
int main() { while (1) {} return 0; }
也可以看到R后面有一个“+”,这代表这个进程是一个前台进程,他的意思就是只要一启动,那么各种ls等命令就没有用了,这时候可以用Ctrl + c来终止它。
在输入“./myproc &”的时候就代表他是一个后台进程并显示该进程的pid,此时也就没有那个“+”了,
浅度睡眠状态-S
一个进程处于S-浅度睡眠状态(sleeping)对应上面的阻塞态,代表该进程正在等待某件事情的完成,处于浅度睡眠状态的进程随时可以被唤醒,也可以被杀掉(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))。就像一开始的打印进程pid用的printf函数和scanf函数,他们都是在等待某一资源。
那什么是可中断睡眠呢,一个进程是睡眠状态,我可以发一个信号让他变成其他状态,或者操作系统因为某些原因直接“杀掉”了这个进程,这就是可中断状态,什么是信号后面也会说。
深度睡眠状态-D
一个进程处于D-深度睡眠状态(disk sleep)也叫磁盘睡眠状态、深度睡眠状态,表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
有些进程需要通过磁盘来写入一些数据,在写入的时候,因为系统的压力过大,操作系统会“杀掉”一些进程来节省空间,如果这个进程被“杀掉”了,此时数据就会丢失,为了不让这样的事情发生就有了D-深度睡眠状态,也就是不可中断状态。
暂停状态-T
我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
暂停状态不同于睡眠状态的是睡眠状态也就是阻塞态,它是在等待某种资源,而暂停状态只是把它停住了。
发送信号的命令是:
kill -SIGSTOP pid
上面的代码中还有一个 t 状态,这也是暂停的一种,在gdb调试的时候,打上断点再运行,这时候就可以看到 t 状态。打上断点也就是让进程停下来。
死亡状态-X
这个状态只是一个返回状态,你不会在任务列表里看到这个状态。他也代表进程终止,操作系统可以立即回收我,它也是瞬时性的,所以基本很难看到。
僵尸状态-Z
僵尸状态是一个比较特殊的状态,当一个进程退出的时候,它曾经申请的资源并不会立即被释放,而是要暂时存储一段时间,这时候就处于一个被检测的状态以供操作系统或是其父进程进行读取,如果信息一直未被读取就会产生僵尸状态。
#include <stdio.h> #include <stdlib.h> // 添加这个头文件才可以使用exit #include <unistd.h> int main() { pid_t id = fork(); // 创建子进程 if (id < 0) // 创建子进程失败 { // 失败 perror("fork"); return 1; } else if (id == 0) { // 值是0,代表子进程 while (1) { printf("I am child, pid:%d, ppid:%d\n", getpid(), getppid()); sleep(3); exit(0); // 让子进程退出,退出码设为0 } } else { // 值大于0,返回了子进程的pid,所以是父进程 while (1) { printf("I am parent, pid:%d, ppid:%d\n", getpid(), getppid()); sleep(1); } } return 0; }
这样就可以看到僵尸状态了,在后面还有一个单词<defunct>,这就代表失效或死亡的。
僵尸进程的危害
- 僵尸进程这个状态必须一直维持着,因为他要告诉他的父进程它的退出信息;父进程不读取就会变成僵尸进程。
- 僵尸进程的退出信息保存在PCB中,进程不退出,PCB就要一直维护。
- 如果父进程创建了很多子进程,但是都不回收,数据结构就会占用内存,这会造成资源浪费。
- 资源无法回收,僵尸进程还会导致内存泄漏。
孤儿进程
前面也说到过,Linux中的进程大多都是父子关系的,如果父进程提前退出,无法读取子进程的退出信息,进入僵尸状态的进程就无法处理,这时的进程就叫做孤儿进程。
如果一直不处理,孤儿进程就会一直占用资源,这样就会造成内存泄漏,所以孤儿进程会被1号init进程(系统本身)“领养”,此时他就是孤儿的父进程,退出信息就由它来处理。
接下来就来演示一下孤儿进程。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { pid_t id = fork(); // 创建子进程 if (id < 0) // 创建子进程失败 { // 失败 perror("fork"); return 1; } else if (id == 0) { // 值是0,代表子进程 while (1) { printf("I am child, pid:%d, ppid:%d\n", getpid(), getppid()); sleep(1); } } else { // 值大于0,返回了子进程的pid,所以是父进程 int cnt = 5; while (cnt--) { printf("I am parent, pid:%d, ppid:%d\n", getpid(), getppid()); sleep(1); } } }
进程优先级
优先级是获取某种资源的先后顺序,而进程优先级就是进程获取CPU资源的先后顺序,因为CPU是有限的,进程又有很多,优先级高的进程就优先执行。
怎么来确定优先级呢,它实际上就是用一些数来表明,Linux优先级的做法:
- 优先级 = 老的优先级 + nice值。
他们是什么呢,接下来我们就来看一下,还是使用ps命令,只是这次的选项是-la。
pid和ppid已经知道是什么意思了,UID就表示执行者的身份。
PRI和NI
- PRI代表进程的优先级(priority),就是进程被CPU执行的先后顺序,值越小进程的优先级别越高。
- NI代表的是nice值,其表示进程可被执行的优先级的修正数值。
- PRI = PRI(old) + nice
- nice的值为负值,那么该进程的PRI将变小,即其优先级会变高。
- 调整进程优先级,在Linux下,就是调整进程的nice值。
- nice的取值范围是-20至19,一共40个级别
- 在Linux中PID(old)默认为80,nice值加减也是对80加减。
使用top命令也可以更改nice值,输入top命令后进入了类似任务管理器的界面,再按r,输入想要修改nice的进程的pid和nice的取值就可以了。
其他
在学校中学习操作系统部的时候一定会说到几个词:
- 竞争性:系统中进程很多,而CPU只有几个甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,所以有了优先级。
- 独立性:多进程运行时,每个进程独享一份资源,一个进程退出了不影响其他进程。
- 并行:多个进程在多个CPU下分别同时进行运行,这叫做并行。
- 并发:多个进程在一个CPU下采用进程切换的方式(有一种是时间片轮转),在一段时间之内,让多个进程都得以推进,这叫做并发。
下面我们来谈一谈是如何切换的。
每一个进程在都是在CPU中执行的,CPU中有很多寄存器,在寄存器会保存当前进程的临时数据也就是上下文数据,当你的进程时间片用完的时候是需要把你切出去的,此时需要把这些上下文数据保存在PCB中,等到我下一次被调度的时候,还可以按照上一次执行的位置继续向后执行。
环境变量
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。环境变量通常具有某些特殊用途,并且在系统当中通常具有全局特性。
说了这么多,可我还是不知道他是什么啊,我们就来看一下,使用echo $PATH查看环境变量。
平常我们使用ls、clear等命令并不需要加上路径,但是运行我们自己写的代码的时候就要加上 ./,使用/usr/bin/ls也是可以的,但是并不需要,这就是因为环境变量,在使用ls时,会自动去PATH中去找路径(上图的红框),/usr/bin 路径找到了就可以调用ls,我的程序找不到就必须加上 ./,那是不是只要把我写的程序添加到/usr/bin中就可以了呢,是的,但是不建议这样干;还有一种方式是把这个程序的路径添加到环境变量中。
export PATH=$PATH:想要添加的路径
以 : 作为分隔符
这样就可以了,不用写路径就可以运行程序。这个添加进去的环境变量的持续时间是直到关掉本次对话,再打开一个新对话这个环境变量就没有了,真的想要添加就需要改配置文件了。
上面也说了,环境变量具有全局属性,子进程的环境变量是来自于父进程,那么父进程的环境变量来着哪呢?父进程也就是shell,环境变量写在配置文件中,也可以说是shell脚本,shell启动的时候读取它来获得环境变量。
常见的环境变量
- PATH:刚才说过了,他就是指定命令的搜索路径。
- HOME:指定用户的工作目录,echo $HOME就是当前用户的目录,cd ~也是靠它。
- SHELL:当前shell,通常是bash
和环境变量相关的命令:
- echo:显示某个环境变量的值
- export:设置一个环境变量。
- env:显示所有的环境变量
- set:显示本地定义的shell变量和环境变量
- unset:清除环境变量
环境变量的组织方式
在系统中,环境变量其实就是一个字符指针数组,先来提一个问题,main函数有几个参数呢,平常也用还不到参数,其实main函数有3个参数。
main(int argv, char* argc, char* env);
先来看看前两个参数,他们两个是一起看的。
int main(int argc, char* argv[]) { for (int i = 0; i < argc; i++) { printf("argv[%d]:%s\n", i, argv[i]); } return 0; }
此时命令行上一共有6个参数对应数组中的位置,我们平常使用的ls -l -la,也是类似这样的。命令行参数可以让同一个程序通过选项执行不同的子功能,这些参数也是父进程bash先拿到再继承给子进程的。
再来看最后一个,最后一个其实是一张环境变量表。
int main(int argc, char* argv[], char* env[]) { for (int i = 0; env[i]; i++) // 最后是以NULL结尾的,所以可以这么写 { printf("env[%d]:%s\n", i, env[i]); } return 0; }
C/C++就可以通过main的第三个参数拿到环境变量,也可以通过声明environ来拿到环境变量,其实和env是差不多的。
int main(int argc, char* argv[], char* env[]) { extern char** environ; for (int i = 0; environ[i]; i++) { printf("env[%d]:%s\n", i, environ[i]); } return 0; }
这两种拿到环境变量的方法看一下也就完了,要是真的要拿到环境变量还是使用getenv接口。
#include <stdio.h> #include <stdlib.h> int main(int argc, char* argv[], char* env[]) { printf("%s\n", getenv("PATH")); return 0; }
如何拿到环境变量也就说到这里,但是main作为一个函数,怎么可能凭空出现一个env参数,是谁传给他的呢?其实是当前进程的父进程那里拿来的,父进程就是bash,怎么证明呢?
int main(int argc, char* argv[], char* env[]) { printf("%s\n", getenv("test")); return 0; }
假如想要导入一个test环境变量,但是是不可能找到的,因为当前的bash也就是shell中没有这个环境变量。
没有添加一个就好了。
添加后就有了,也就能拿到了,也不需要重新编译。
所以所有的环境变量都会被子进程继承,因为环境变量具有全局性,所以才可以被子进程继承。
在命令行上随便写一个赋值,这时候他就是一个局部变量,在环境变量中无法找到他,但是set查找所有的变量可以找到他。还有就是getenv也不可以获取他,想要获取他就export把它添加到环境变量中。
进程地址空间
首先我们先来看一下空间分布图:
通常写代码的时候,定义的局部变量就在栈区,栈区是向下开辟的,申请空间就在堆区,堆区是向上开辟的,光说不管用,我们就来看看是不是这样的。
#include <stdio.h> #include <stdlib.h> int un_g_val; // 未初始化数据 int g_val = 10; // 已初始化数据 int main(int argc, char* argv[], char* env[]) { printf("test: %p\n", main); // 正文代码 printf("init: %p\n", &g_val); // 已初始化 printf("uninit: %p\n", &un_g_val); // 未初始化 char* p1 = (char*)malloc(16); char* p2 = (char*)malloc(16); printf("heap: %p\n", p1); // 堆地址 printf("heap: %p\n", p2); // 堆地址 printf("stack: %p\n", &p1); // 栈地址 printf("stack: %p\n", &p2); // 栈地址 printf("argv: %p\n", argv[0]); // 命令行参数 return 0; }
打印出的地址和我们上面所说的规律是一样的。但是,就上面的图片中一块一块的可不是内存!!!
再来继续看下面的代码。
#include <stdio.h> #include <unistd.h> int g_val = 0; int main() { pid_t id = fork(); // 创建子进程 if (id == 0) { // 子进程 int cnt = 0; while (1) { printf("I am child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val); sleep(1); cnt++; if (cnt == 3) // 3秒后改变g_val的值 { g_val = 100; printf("change success, g_val: 0 -> 100\n"); } } } else { // 父进程 while (1) { printf("I am parent, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val); sleep(1); } } return 0; }
在前三秒还是正常的,父子进程共享全局变量,值是一样的,地址也是一样的;后三秒中就有问题了,g_val的值只在子进程中改了,因为进程具有独立性,你改了不影响我,父进程还是0,所以父子看到的也不是同一个变量,往后看,他们两个的地址居然还是一样的,地址是一样的,读取的值居然是不一样的。
所以上述的地址并不是真正的“物理地址”,而是虚拟地址。所以原来我们写过的关于地址的代码都不是物理地址,都是虚拟的,也叫做线性地址。
为什么不能直接使用物理地址,因为在以前就是直接访问物理地址,假如物理地址中有多个进程,每个进程有一块自己的空间,假如有一个进程出现了野指针,它不小心访问了其他进程,但是这块内存是可以随便被读写的,它不像有些地址是只读的,所以这种做法是不安全的。
直接使用物理内存不安全,那就不用,现代的计算机使用的都是虚拟地址,这种地址空间本质上也是一种数据结构,在Linux中这块虚拟地址空间由mm_struct实现,它的编址是从0x0000 0000到0xFFFF FFFF的,在这块空间中也要有各个区域的划分的。这种数据结构就类似于下面这样,规定每个区的起始和终点。
struct mm_struct { int code_start; int code_end; // ... int heap_start; int heap_end; int stack_start; int stack_end; // ... };
在进程被创建时,它的PCB也就是task_struct和虚拟地址空间mm_struct也会被创建,操作系统通过task_struct中存储的结构体指针找到mm_struct的地址。进程地址空间当中的各个虚拟地址通过页表一一映射到物理内存中(地址空间和页表是每一个进程私有一份的),只要映射的是物理内存中不同的区域,就能让进程之间不会相互干扰,从而保证了进程的独立性。
现在就可以解决刚才的问题,父进程创建出子进程,子进程会继承父进程大部分的数据和代码。所以一开始不管是父进程还是子进程他们两个各自的虚拟地址都是一样的,所以变量的地址也是一样的,他们都指向同一块物理地址,只有当父进程或者子进程需要修改数据的时候,才将数据在内存中拷贝一份再修改。这种需要数据修改时再拷贝的方式叫做写时拷贝。
现在也就能很好地说明了fork函数返回的时候为什么返回了两个值,在return的时候相当于对id进行写入,这时候就发生了写时拷贝。
细谈虚拟地址
上面的问题已经说完了,接下来还要再说一下虚拟内存。
虚拟地址空间本质上是一种数据结构。当我们的程序在编译完形成可执行程序的时候,在那个文件当中已经有了地址,上面讲到的地址空间不止操作系统要遵守这样的规则,我们使用的编译器也要遵守,在编译代码的时候就已经分成了各个区,使用同样的编址方式给每一行代码都进行了编址,把每一行代码放到了对应的区,比如int a = 0;这句代码也有了虚拟地址,放在了栈区。
当可执行程序被加载到内存中,他就变成了一个进程,每一行代码有了自己的物理地址。当这个可执行程序变成进程的时候,创建自己的task_struct和虚拟地址mm_struct,并且分区采用的就是当时编译时的分区,再把虚拟地址和物理地址进行映射。
当CPU执行代码的时候,找到程序的入口,这也是虚拟地址,通过映射找到物理内存中第一行代码的虚拟地址,拿到了这条指令后,这条指令里面也存放着下一条指令的虚拟地址,再次通过页表映射找到下一条指令,就这样运行下去。
为什么要有地址空间呢?
从另一个角度来说这也是一种保护机制,上面说到物理地址可以随便被读写,要是通过虚拟地址也可以随便读写啊,只要我弄好了映射关系。
要解决这种问题也要通过页表,其实页表也是一种数据结构,在这之中也存在着地址的权限,就像const char* str = "hello world";,他就存放在字符常量区,所以虚拟地址规定这一部分代码权限就是只读。当我们进行非法操作,操作系统就会识别到,并让你这个进程崩溃,也就是进程退出。
这样就有效地保护了物理内存。所以说了这么多,这些操作都是在操作系统的监管之下完成的,并且也保护了所有的合法数据。
所以从磁盘上的数据可以随便加载到内存中的任何位置,只要建立好映射关系,就是让物理内存的分配和进程管理没关系了,说白了就是内存管理和进程管理就完成了解耦合。
平常我们使用new和malloc也是在虚拟地址上申请的空间,但是如果我在物理地址上申请了空间,但是我暂时不用,这不就是浪费了空间吗,所以在你申请空间后,物理内存甚至一个字节也没有给你,只有当你访问这块申请的空间时,操作系统才会执行相关操作,申请空间和页表映射,但我们使用的时候是感觉不到的,这就使用了延迟分配的策略,提高了内存的利用率。
在程序写入到物理内存的时候,他可以加载到任何位置,所以在物理内存中的所有代码和数据都是乱序的,因为有了页表,他可以将虚拟地址和物理地址进行映射,那么在进程看来,他的内存分布就是有序的,所以地址空间和页表可以将内存分布有序化。
这样每个进程可以通过页表映射到自己的物理内存,他们也认为自己拥有4GB(32位下)空间,并且这些空间还是有序的,这样就实现了进程的独立性。
换入换出
如果在创建进程的时候,只有内核数据结构被创建出来,这就是新建状态,假如这时候操作系统中的进程很多,你的代码和数据都不会加载到物理内存中,当你真正用的时候才给你加载到内存并建立好映射关系。
所以程序是可以分批加载的,加载也叫做从磁盘中换入到内存,如果这个进程短时间内不会被执行,他就会被从内存中换出到磁盘,这就叫做挂起。
页表映射不仅仅可以映射到内存,它也可以映射到磁盘,进程要执行的代码通过映射发现他不在内存中而在硬盘中,把这块代码直接换入到内存中就可以了,当我不用这块代码时也不需要将内存中的数据交换到硬盘,直接释放就可以了,要用的时候换入就好了。