前言
在上一讲 认识冯诺依曼体系&&初识LinuxOS 中已经学到了计算机的整体的体系结构和操作系统在计算机中起到的重要作用,今天我们就来学习一下,在如此复杂的计算机体系中,操作系统是如何有效的组织和管理计算机的?
在讲操作系统如何是管理的之前,可以先说一说什么叫做管理?
我们在学校中常说辅导员管理整个年级,那么辅导员管理的整个年级到底指的是啥?**管理其实本质上是在管理一些有效的数据。**辅导员是在管理整个年级中反馈给辅导员的数据信息,而每一个班级反馈的数据信息形成了整个年级的数据信息,每一个班级中的每一个学生反馈的数据信息形成的数据信息组成了整个班级的信息。在辅导员收到这些数据信息的时候,就会进行一些措施去执行。这样就形成了整个管理的过程。
那说了这么多,我们再回来说说操作系统是如何进行管理的。同样操作系统也是通过收到来自计算机中各种数据来进行管理的。那么这些数据从哪里来的呢?这就是操作系统中最重要的概念了,即抽象出各种概念,通过结构体的形式描述出来,最后通过数据结构的形式组织起来。
数据的来源就是操作系统中的众多结构体,为了描述出结构体,操作系统给结构体定义了很多的属性,这样结构体就可以返回很多操作系统所需要的有效信息了。如此多的结构体返回有效的数据信息的时候操作系统还是很头疼,所以此时数据结构就起到了至关重要的作用,即将这些结构体通过不同的数据结构组织起来,通过使用不同的数据结构组织起来的数据所实现的效果是不一样的,可以通过具体的要求来进行选择,如如果想要快速找到元素,可以使用树这种结构。如果想要进行快速地插入元素可以使用链表的形式…
有六个字总结操作系统是如何进行管理的:先描述,再组织。
操作系统中有很多抽象出的概念,而进程就是其中最深刻的概念之一。
进程的定义:一个执行中程序的实例,即一个正在执行的程序。
如果站在内核的角度来看:进程是分配系统资源的单位。
前面说了一个抽象的概念需要一个具体的结构体来进行描述的。进程中的信息就被放在了一个叫做进程控制块(PCB)的结构体中。
在不同的操作系统下进程控制块的名称不同(就好像不同地方的人称呼某一个东西会有不同的叫法一样),在Linux操作系统中PCB的具体名称是:task_struct
。
当一个程序被加载到内存中要开始执行的时候,操作系统同时会给该进程分配一个PCB,在Linux中就是task_struct
这里面包含了所有关于进程的数据信息。所以CPU对task_struct
进行管理就相当于对进程进行管理。
task_struct是Linux内核的一种数据结构,它会被装载到RAM里并包含进程的信息。每个进程都把它的信息放在task_struct这个数据结构里面,而task_struct包含以下内容:
标识符:与进程相关的唯一标识符,用来区别其他进程
状态:进程会有不同的状态,如运行,停止等等
优先级:相对于其他进程的优先顺序
程序计数器:程序中即将执行的下一条指令的地址
内存指针:包括程序代码和进程相关数据的是很
上下文信息:进程执行时CPU的寄存器中的数据
IO状态信息: 包括显示的I/O请求,分配给进程的I/O设备和正在被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟总数,时间限制,记账号等
1.进程标示符: 描述本进程的唯一标示符,用来区别其他进程。
也就是进程的PID,PID是操作系统中唯一标识的进程号。
有两个获得进程PID的方式:
1.1.可以使用ps aux
查看进程的信息。
1.2.可以使用系统接口得到进程PID和父进程的PPID
#include
#include
int main() {
printf("pid=%d, ppid=%d\n", getpid(), getppid()); // 进程号和父进程号
return 0;
}
2.进程状态
后面会详细讲解每一个状态,这里先大概地介绍一下。
3.优先级
因为CPU资源有限,而进程却有很多个,所以需要优先级这个属性去决定了进程拿到资源的顺序。****
4. 程序计数器: 程序中即将被执行的下一条指令的地址。
CPU有三个工作:取指令,分析指令和执行指令。CPU中的指令寄存器每一次都会保存下一条指令的地址,以此来进行指令判断。
5. 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
6.上下文数据
通常操作系统内核使用一种叫做**「上下文切换」**的方式来实现控制流。
实行这种机制是因为CPU只有一套寄存器,所有只能有将一个进程的存储数据放入寄存器中计算,从而形成了上下文数据。但是同时有多个进程的时候,操作系统为了使得CPU的利用率最高,所以会让进程之间来回的切换,一般进程切换有两种情况。
6.1 第一种情况:我们称两个执行流在执行的时间上与另一个执行流有重叠的部分,就称这个两个执行流在 「并发的运行」。一个进程在和其他的进程轮流轮流运行成为 「多任务」。一个进程执行它的控制流的那一段时间叫做 「时间片」。简单来说,每一个进程都会有最多执行的时间限制,如果执行时间超过了时间片,就会自动的退出执行。
6.2 第二种情况:当操作系统内核,发现一个优先级更高的进程的时候,该优先级更高的进程就会「抢占」当前进程的位置,然后执行优先级更高的进程。等到该进程执行完后,在执行被 「抢占」 的进程。这种决策方式叫做 「调度」。
以上两种情况,都会使得进程莫名其妙的退出CPU的执行,但是下次CPU还想接着上一次执行的地方继续执行那个莫名其妙退出的进程,所以就需要在进程退出之前,在task_struct
中保留下上一次执行的数据,方便下一次再被执行。
7. I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
可以在内核源代码里找到task_struct
。所有运行在系统里的进程都以task_struct
双链表链表的形式存在内核里。如果在情况复杂的情况下,双链表中的节点也有可能存在在其他的数据结构中,例如队列等。
查看进程有三种方式:
第一种方式:在/proc
这个目录下保存着所有进程的信息
第二种方式:可以使用命令
ps aux # 查看系统中所有的进程信息
ps axj # 可以查看进程的父进程号
第三种方式:使用top
命令
top # 动态的查看进程的信息,其中的信息默认3秒回更新
创建进程有两种创建方式:
1.使用./
运行某一个可执行程序,这种是最常见的方式
2.使用系统调用接口创建进程,即使用fork()
当时用fork()
函数之后,就在原来的进程中创建了一个子进程,在fork()
之前的代码只被父进程执行,在fork()
之后的代码有父子进程一起执行。
创建的子进程和父进程几乎一模一样,子进程和父进程的共享地址空间,子进程可以或者父进程中所有的文件,只有PID是父子进程最大的不同。
利用fork创建一个进程来举个例子:
#include
#include
int main()
{
pid_t pid = fork();
if (pid < 0) {
printf("error");
}
if (pid == 0) {
printf("i am a child process\n"); // 输出
} else {
printf("i am a father process\n"); // 输出
}
return 0;
}
如果fork成功创建了一个进程,那么上面的代码就会输出i am a child process i am a father process
这两句话。
这里面有很多有意思的点:
fork函数调用一次,返回两次。
上面的代码是如何实现执行两个不同的分支语句的呢?其实是因为fork函数会返回两个返回值,一个是子进程会返回0,一个是父进程会返回子进程的PID。所以会同时进程两个分支语句中。
并发执行
父子进程是两个并发运行的独立程序。前面说过并发,就是两个执行流在执行的时间上有重叠的部分。也就是说父子进程谁先被调度是不能确定的。
相同但是独立的地址空间
两个进程其实地址空间是一样的,但是它们都有自己私有的地址空间,所以父子进程的运行都是独立的,一个进程中的内存不会影响另一个进程中的内存。
共享文件
子进程继承了父进程所有打开的文件,所以父进程调用fork的时候,stdout
文件呢是打开的,所以子进程中执行的内容也可以输出到屏幕上
下面这个例子有一些的复杂,你可以检验一下自己有没有真正理解fork()
#include
#include
#include
int main()
{
fork();
fork();
printf("hello\n");
exit(0);
return 0;
}
上面应该是输出了4个hello
,下面这张图就可以解释
可以使用ps aux
或者ps axj
命令查看进程的状态。
进程有很多的不同的状态,在kernel源代码中是这样定义的
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状态的进程有的在cpu中执行,但是很多都是在运行队列中等待运行,也就是该进程允许被调度。
模拟实现:
可以运行任意一个可运行的程序,即可出现R状态。
这种状态是一种浅度睡眠,此时的进程是在被阻塞的状态中,等待着条件的满足过后进程才可以运行。在这种状态下可以被信号激活,也可以被信号杀死。
模拟实现:
可以使用sleep()
系统调用接口使得一个进程睡眠
#include
int main()
{
while (1)
{
printf("hello world\n");
sleep(100); // 睡眠100秒
}
return 0;
}
这种状态是一种深度休眠的状态,在这种状态下即使是操作系统发送信号也不可以杀死进程,只能等待进程自动唤醒才可以。
模拟实现:
这种情况没法模拟,一般都是一个进程正在对IO这样的外设写入或者读取的时候,为了防止操作系统不小心杀掉这个进程,所以特地创建出一个状态保护这种进程。
可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
模拟实现:
可以使用信号
kill -SIGSTOP PID // 停止进程
kill -SIGSONT PID // 继续进程
进程停止执行,进程不能投入运行。通常这种状态发生在接受到SIGSTOP、SIGTSTP、SIGTTIN、SIGOUT等信号的时候。
模拟实现:
可以使用kill -9 PID
即可杀死一个进程
后面会详细讲解
如果父进程比子进程先退出,那么此时子进程就叫做孤儿进程。而操作系统不会让这个子进程孤苦伶仃的运行在操作系统中,所以此时孤儿进程会被init
进程(也就是1号进程,即所有进程的祖先)领养,从此以后孤儿进程的状态和最后的PCB空间释放都是由init
进程负责了。
模拟实现:
模拟实现让父进程比子进程提前退出。
#include
#include
#include
int main()
{
pid_t pid = fork();
if (pid == 0) { // 子进程一直执行
while (1) {
printf("I am a child, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(1);
}
} else {
int count = 5; // 父进程执行5次
while (count --) {
printf("I am a father, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(1);
}
exit(1);
}
return 0;
}
使用shell脚本监控进程信息
# 每隔一秒显示进程的信息
while :; do ps axj | head -1 && ps axj | grep a.out | grep -v grep; sleep 1; done
父进程退出后,自己子进程被1号init
进程收养。
前面说过进程的作用是为了给操作系统提供信息的,所以在进程调用结束之后,应该将该进程完成的任务情况汇报(eixt code
)给操作系统,但是进程在执行完之后已经结束了,所以此时进程的状态就是僵尸状态。
僵尸进程:即进程已经结束了,但是父进程没有使用wait()
系统调用,此时父进程不能读取到子进程退出返回的信息,此时就该进程就进入僵死状态。
进程已经结束了,但是进程控制块PCB却还是没有被释放,这时就会浪费这一块资源空间。所以会导致操作系统的内存泄漏。
僵死状态需要父进程发出wait()
系统调用终止进程,如果父进程不终止进程,那么此时要消灭僵尸进程只能通过找到僵尸进程的父进程,然后kill
掉这个父进程,然后僵尸进程就会成为孤儿进程,此时由init
进程领养这个进程然后杀死这个僵尸进程。
模拟实现:
模拟实现让子进程比父进程提前退出。
#include
#include
#include
int main()
{
pid_t pid = fork();
if (pid == 0) {
int count = 5; // 子进程运行5次
while (count --) {
printf("I am a child, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
} else { // 父进程一直运行
while (1) {
printf("I am a father, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
可以使用shell脚本监控
# 每隔一秒就查看进程的信息
while :; do ps aux | head -1 && ps aux | grep a.out | grep -v grep; sleep 1; done
子进程执行了5次之后,编程僵尸状态
粗糙版本:
详细版本:
进程优先级为进程获取cpu
资源分配的先后顺序,即进程的优先权,优先级高的进程可以有优先执行的权力。
之所以会存在进程优先级,是因为cpu
本身的资源分配是有限的,一个cpu
一次只能run
一个进程,但是一个操作系统中可能会有成千上百的进程,所以需要存在进程优先级来确定每一个进程获得cpu
资源分配的顺序。
在linux或者unix系统中,用ps –al
或者ps -l
命令则会类似输出以下几个内容:
其中我们来了解几组关于进程优先级的相关信息:
- UID:执行者的身份,用户标识符
- PID:进程的编号
- PPID:进程的父进程的编号
- PRI:进程可被执行的优先级,PRI越小代表优先级越高
- NI:进程的nice值,代表进程优先级的修改数值
PRI
和NI
是一组对应的概念。NI
的取值会影响到PRI
的最终值。
PRI
代表进程被CPU
执行的先后顺序,并且 PRI
越小进程的优先级越高。NI
代表nice
值,表示进程的优先级的修改数值。所以两者之间有一个计算的公式:(new)PRI = (old)PRI + NI
。
注意:
1.PRI
在系统中默认初始化为80
。
2.NI
的取值范围为-20 ~ 19
,一共40个级别。
3.当NI
为正值,PRI
增大,进程优先级变高。当NI
为负值,PRI
变小,进程优先级变小。
例如:
例1:
默认进程的PRI
为80,当前的nice
值为0
,所以最终的PRI
为80
。
例2:
默认进程的PRI
为80,当前的nice
值为10
,所以最终的PRI
为90
。
例3:
默认进程的PRI
为80,当前的nice
值为-10
,所以最终的PRI
为70
。注意当要调整进程的nice
值为负值的时候,由于要提升进程的优先级,所以需要超级用户root
的权限才可以。
总结:在Linux
环境下,我们一般说调整进程的优先级,就是在调整nice
值。nice
值决定性的影响到进程优先级。
top
命令更改nice
值top
命令是一个可以动态查看进程信息的命令(很像windows
中的任务管理器)。
使用top
命令的更改命令三部曲:
1.使用
top
命令后,按r
键,要求你输入需要更改进程优先级的进程PID2.输入需要更改进程优先级的进程PID
3.输入你想要更改后的
nice
值
renice
命令更改nice
值修改进程的优先级也可以直接使用renice
命令。
语法格式:renice nice值 进程PID
前面一直在介绍单个进程的概念,下面我们稍微了解一下多个进程之间的关系概念。
1.竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
2.独立性:多个进程运行,需要独享各种资源,多个进程运行期间互不干扰。
3.并发:多个进程在一个CPU
下采用进程切换的方式,在一段时间内,让多个进程都得以推进,称之为并发,所以两个并发的进程之间在执行之间上有重叠的部分。
4.并行:多个进程在多个CPU
下同时运行,称之为并行。
环境变量有很多的应用场景,这里举一个例子:
我们通常编写的
C/C++
代码,在编译之后将每一个目标文件链接起来的时候,我们并不知道动静态库的位置,但是将所有的目标文件链接起来,生成可执行文件,这就时依靠这全局的环境变量在查找。
可以使用echo
命令来查看环境变量,语法格式为:
echo $NAME # NAME为环境变量名
例如:查看PATH
环境变量:
echo $PATH
常见的环境变量有很多,这里我们就来分析一下其中的三个:
PATH
:中存放的环境变量是为了在执行命令的时候,可以在PATH
中找到对应的路径,这样就可以不用写出命令绝对路径了。
举一个例子:ls
的执行过程
ls
命令的最常用的一个命令,但是其实ls
也只不过是在系统中的一个封装的可执行程序而已,可以使用which
命令查看ls
的路径,可以看到ls
的路径为/usr/bin/ls
。
所以我们可以这样使用ls
命令:
/usr/bin/ls # 查看当前目录下的文件
但是平时我们却通常是直接使用ls
命令的,这是为什么呢?这就是因为在PATH
路径下有/usr/bin
。
因为在使用ls
命令的时候,系统就会首先去PATH
中的环境变量从左向右地寻找ls
的工作路径。如果发现了ls
的工作路径,这时直接使用ls
命令就不会报错了。
通常我们自己在写完一个代码后,形成了一个可执行文件,需要通过./
这样的方式才可以运行,这是因为环境变量PATH
中没有当前可执行程序的工作目录,所以我们只能通过./
这样的方式,自己手动的通过相对路径的方式运行可执行程序。
假设我们在/home/zhy/test
目录下,有一个hello
的可执行程序。运行之后可以打印出hello world
如果想要让我们的可执行程序可以直接像ls
命令那样直接运行,我们可以用两种方法:
1.在PATH
中用别人的工作目录。
可以将hello
这个可执行程序,拷贝一份放入PATH
中已经存在的工作目录下(比如说是/usr/bin/
,这样在运行可执行程序的时候,其实运行的是在/usr/bin/
下的hello
。
sudo cp hello /usr/bin/
但是强烈地不推荐使用这种方法,因为这样就会污染了其他工作目录。不利于整个系统的发展
2.自己在PATH
创建一个新目录。
第二种方法就是将当前的工作目录添加到PATH
中即可。
需要使用export
命令,在PATH
中添加新的工作目录。
export PATH=$PATH:/home/zhy/test/
在执行完上述的命令之后,就可以直接使用hello
指令了。
注意:这样操作的话,其实在下一次重新开使用Linux的时候,原来的环境变量就会被重新的覆盖,导致hello
又不可以直接被使用了。如果想要永久的使得命令生效,就必须要修改~/.bash_profile
文件才可以。
vim ~/.bash_profile # 将创建工作目录的指令写在.bash_profile中
source .bash_profile # 使得.bash_profile中的内容生效
任何一个用户都有自己的主工作目录,HOME
保存的就是当前用户的主工作目录。
Linux
中的shell
有很多不同的版本,也就是有很多不同的命令行解释器。常见的有bash
,sh
,tsh
等等。SHELL
中保存的就是当前的命令行解释器的版本。
1.export
:
设置新的环境变量
可以将local
本地变量变成全局变量。
举例:
[zhy@zhyALG 21-10-3]$ MYVAL="hello zhy" # 在本地中设置一个局部变量MYVAL
[zhy@zhyALG 21-10-3]$ set | grep MYVAL # 在本地环境变量(set)可以找到MYVAL
MYVAL='hello zhy'
[zhy@zhyALG 21-10-3]$ env | grep MYVAL # 在全局环境变量(env)找不到MYVAL
[zhy@zhyALG 21-10-3]$ export MYVAL # 使用export使得本地变量成为全局变量
[zhy@zhyALG 21-10-3]$ env | grep MYVAL # 在全局环境变量(env)可以找到MYVAL了
MYVAL=hello zhy
2.env
: 显示所有的环境变量
3**.set
:** 显示本地定义的shell
变量和环境变量,和env
中的环境变量相比,set
中的环境变量只在本进程中有效。
4.unset
: 清除环境变量
其实环境变量的存储方式就是利用的指针数组,一个数组中存放了很多的指针,每一个指针都指向一个环境变量的首地址,因此我们可以找到对应的环境变量,而且指针数组的最后一定存放了NULL
作为结尾。
总结:每一个进程都会收到一个环境表,环境表本质上就是一个以NULL
为结尾的指针数组。
如果想要通过代码实现获取环境变量,可以有三种不同的方式。
main()
函数的第三个参数你知道我们平时在C/C++
中写的main()
函数其实是有参数的吗?不仅是有参数并且还不止一个参数。
int main(int argc, char* argv[], char* envp[]);
argc
:代表调用main()
函数进程的时候,传入的参数个数。argv
:代表调用main()
函数进程的时候,传入的参数。envp
:代表环境变量表,由此获取系统的环境变量。下面两个例子可以帮助你的理解:
案例一:
#include
using namespace std;
int main(int argc, char* argv[])
{
for (int i = 0; i < argc; i ++) {
cout << argv[i] << endl;
}
return 0;
}
运行结果:
分析图:
默认情况下:argv[0]
存放的可执行文件的文件名。后面就是插入main()
函数的参数。
案例二:
#include
#include
using namespace std;
int main(int argc, char* argv[], char* envp[])
{
for (int i = 0; envp[i]; i ++) {
cout << envp[i] << endl;
}
return 0;
}
运行结果:
第二种方法:可以使用第三方变量environ
获取。
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。
#include
using namespace std;
int main()
{
extern char** environ;
for (int i = 0; environ[i]; i ++) {
cout << environ[i] << endl;
}
return 0;
}
运行结果:和main()
函数的第三个参数显示的结果相同
getenv()
第三种方法:直接使用系统调用接口getenv()
。getenv
函数可以根据所给的环境变量名,在环境变量表中进行搜索,并返回一个找到搜索的环境变量的指针。
例如:
#include
#include // getenv()函数在的头文件中
using namespace std;
int main()
{
cout << getenv("HOME") << endl;
return 0;
}
运行结果:
下面这张图是我们通常意义认为的C/C++
内控空间分布图:
在Linux
系统中,我们可以可以通过代码来验证这张图的正确性:
#include
#include
#include
using namespace std;
int main(int argc, char* argv[], char* envp[])
{
int val;
int unval = 10;
cout << "code address: " << main << endl; // 正文代码区
char* str = "hello world";
printf("read only address: %p\n", str); // 只读常量区
cout << "init address: " << &val << endl; // 初始化数据区
cout << "uninit address: " << &unval << endl; // 未初始化数据区
int* p = (int*)malloc(4);
cout << "heap address: " << p << endl; // 堆区
cout << "stack address: " << &p << endl; // 栈区
for (int i = 0; i < argc; i ++) {
printf("args address: %p\n", argv[i]); // 命令行参数区
}
for (int i = 0; envp[i]; i ++) {
printf("env address: %p\n", envp[i]);
}
return 0;
}
运行结果:
下面拿一段比较奇怪的代码作为一个引例:
#include
#include
#include
int main()
{
int x = 1;
pid_t pid = fork();
if (pid == 0) {
x ++;
printf("child: PID:%d, PPID:%d, x:%d, &x:%p\n", getpid(), getppid(), x, &x);
exit(0);
}
sleep(1);
printf("father: PID:%d, PPID:%d, x:%d, &x:%p\n", getpid(), getppid(), x, &x);
return 0;
}
代码的含义如下: 首先定义一个变量x
,然后fork()
出一个子进程。在子进程中使x++
,并打印出子进程的PID
,PPID
,x
和&x
。而为了使子进程在父进程之前运行完,所以在父进程中先休眠1秒,接着再打印出父进程的PID
,PPID
,x
和&x
。
预期结果: 子进程中的x
等于2
,父进程中的x
也等于2
,并且两个x
的地址应该是相同的。
实际运行结果:
这个结果应该是让不少同学大吃一惊的,因为拥有相同地址的变量,应该是同一个变量,但是一个变量竟让会有两个不同的数值。
不管中间发生了什么最终会出现两个不同的数值,说明在实际存储的空间中x
一定是被存放在了不同的区域当中了,这就是下面要介绍的一个新的概念:「虚拟地址空间」。
实际上,平时我们站在学习语言的层面上看的地址空间都是虚拟的地址空间,也就是说这个空间并不是实际存在的,并不是实际的物理上的内存地址空间。为了防止用户破坏系统中的空间,真正存储变量的空间由操作系统统一管理。
所以上面&x
其实打印出来的是虚拟的地址空间,虚拟地址空间通过一系列的翻译转化可以通过「页表」映射到真正的物理地址空间,而子进程中的x=2
和父进程中的x=1
就存放在这两个真正的物理空间中。这就解释了为什么相同的虚拟地址空间可以有两个不同的数值
mm_struct
其实在学习完上面进程的概念之后,我们应该把「程序地址空间」称之为「进程地址空间」。而进程地址空间本质上也是一种在操作系统的一个内核数据结构,在Linux
中进程地址空间称之为struct mm_struct(内存描述符)
的结构体。Linux
就是通过这个结构体来实现「内存管理」的。
每个进程只有一个mm_struct结构,在每个进程的task_struct结构体中,有一个指向该进程的结构。可以说,mm_struct结构是对整个用户空间的描述。
struct mm_struct {
//...
unsingned long start_code,end_code,start_data,end_data; //代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_data
unsigned long start_brk,brk,start_stack; //start_brk和brk记录有关堆的信息,start_brk是用户虚拟地址空间初始化,brk是当前堆的结束地址,start_stack是栈的起始地址
unsigned long arg_start,arg_end,env_start,env_end; //参数段的开始arg_start,结束arg_end,环境段的开始env_start,结束env_end
// ...
};
上面这个Linux
内核的源代码,可以看到struct mm_struct
中也是被划分成为了多个不同的区域的。这些虚拟地址通过页表和物理内存建立映射的联系。由于虚拟地址也是有0x00000000
到0xffffffff
线性增长的,所以虚拟地址也叫作「线性地址」。
补充:
1.堆的向上增长和栈的向下增长是上都是在改变struct mm_struct
中end_brk
和end_stack
的位置而已。
2. 我们生成的可执行程序实际上也被分为了各个区域,例如初始化区、未初始化区等。当该可执行程序运行起来时,操作系统则将对应的数据加载到对应内存当中即可,大大提高了操作系统的工作效率。
struct_task_struct
,struct_mm_struct
和页表的关系在最开始介绍进程在创建的时候,我们了解到每当起一个进程的时候都实际上是在内核中创建了一个struct task_struct
。
学到这里,我们又可以重新的认知进程的创建过程:闯将与进程对应的进程控制块struct task_struct
,进程描述符struct mm_struct
和对应的页表。而struct task_struct
中有指向struct mm_struct
的指针,所以可以找到struct mm_struct
。然后struct mm_struct
中的内容通过页表映射到物理内存中。
回到最开始那一段奇怪的代码中:
#include
#include
#include
int main()
{
int x = 1;
pid_t pid = fork();
if (pid == 0) {
x ++;
printf("child: PID:%d, PPID:%d, x:%d, &x:%p\n", getpid(), getppid(), x, &x);
exit(0);
}
sleep(1);
printf("father: PID:%d, PPID:%d, x:%d, &x:%p\n", getpid(), getppid(), x, &x);
return 0;
}
父子进程都有自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置。
1.一开始创建子进程的时候,子进程和父进程的代码和数据共享,即相同的虚拟地址会映射到相同的物理地址空间。
2.当在子进程要修改父进程中的数据的时候,父进程中的数据会重新的拷贝一份,然后子进程再对数据进行修改。这样父子进程中的数据就独立了。
这种只有在多个进程中其中一个进程对数据进行修改的时候再进行拷贝的行为称之为「写时拷贝」。
对于写时拷贝,有两个问题:
1.为什么要进行写时拷贝?
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
2.为什么不在创建子进程的时候就直接在子进程中拷贝一份父进程中的data和code?
子进程不一定会修改父进程中的code或者data,只有当需要修改的时候,拷贝父进程中的数据才会有意义,这种按需分配的方式,也是一种延时分配,可以高效的时候使用内存空间和运行的效率。
1.通过虚拟地址+页表的这种方式,可以使得用户不能接触到物理内存,这样就不会出现系统级别(访问物理内存)的越界问题了,因为虚拟内存的越界问题并不会影响到实际的物理内存。本质上说就是保护了内存。
2.为每一个进程提供了一致的地址空间,从而简化了内存管理。
3.更好的完成了进程的独立性以及合理使用内存空间,并将进程调度(task_struct
管理)和内存管理(mm_struct
管理)进行了解耦。