目录
1.4.2. 如何更改进程的PRI,即如何更改已存在进程的NI?
1. 进程的优先级
1.1. 为什么要有优先级
其实在认识一个新事物时,有时候是什么和怎么办的问题并不是很难,而为什么这个话题有时候不好解决!
我们首先要知道,优先级谈论的是某个进程谁先享受某种资源,谁后享受某种资源,而在大多数情况下,这种资源指的就是CPU资源!那为什么进程要有优先级呢?
本质就是因为CPU资源是有限的,而进程太多!需要通过某种方式竞争CPU资源!间接的说明了进程间具有竞争性,因此在Linux中,需要有一个调度器根据优先级判断谁先被调度,谁后被调度。
1.2. 优先级是什么
PRI (priority) 优先级
cpu资源分配的先后顺序,就是指进程的优先级(priority)。
优先级高的进程有先被调度的权利。配置进程优先权对多任务环境的Linux很有用,可以改善系统性能。
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能
1.3. 如何表示优先级
我们可以用一些数据表示优先级!而Linux就是使用一些数据来表示优先级的,没有什么特殊的!而Linux中优先级的表现形式:PCB内的一个重要字段!
在现实生活中,你去找工作的时候,你说你高考的分数有多高,这是没有任何意义的,因为高考分数不被找工作这件事所认可!而对应到进程的优先级也是如此,如果这个优先级没有被谁利用,那么这个优先级也是没有任何意义的,因此优先级这个PCB中的字段必须被调度器所使用才有意义!也就是说,优先级就是调度器调度某个进程的主要参考!
1.4. Linux下具体的关于优先级的做法
1.4.1. 如何查看优先级
ps -al
ps -al是一个常用的 Linux/Unix 命令,用于列出当前系统中所有正在运行的进程的详细信息。
具体解释如下:
- ps 是 process status 的缩写,用于显示进程的状态。
- -al 是两个选项的组合。其中,"a" 选项表示显示当前终端会话中的所有进程,而不仅仅是属于当前用户的进程;"l" 选项表示使用列表形式展示进程详细信息。
UID : 进程所有者的用户IDPID : 代表这个进程的IDPPID :父进程的IDC: 该进程占用的CPU资源的百分比PRI :代表这个进程可被执行的优先级,其值越小越早被执行NI :代表这个进程的nice值TIME:进程使用的CPU时间。CMD:所执行的命令行命令
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是进程被CPU执行的先后顺序,此值越小,进程的优先级别越高,越优先被调度!那NI呢?就是我们所要说的nice值了,其表示进程优先级的修正数值PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice这样,当nice值为负值的时候,那么该进程的优先级值将变小,即其优先级会变高,则其越快被执行;所以,在Linux下,调整进程优先级,就是调整进程的nice值,nice其取值范围是[-20,19],一共40个级别;需要强调一点的是,进程的nice值不是进程的优先级,它们不是一个概念,但是进程nice值会影响到进程的优先级变化,可以理解nice值是进程优先级的修正数据!
1.4.2. 如何更改进程的PRI,即如何更改已存在进程的NI?
1. top,进入任务管理器;
2. 进入top后,输入 'r' ---> 输入进程PID ---> 输入NI值
注意:如果NI值为负值(即提高优先级),那么是需要root权限的!
3. 输入 'q',退出top
经过设置让NI = 10,可以看到此时优先级PRI = 90,NI = 10;
可当我再次设置 NI = -10,优先级会变成 PRI = 80, NI = -10吗?
我发现结果跟我的设想并不一样,此时的优先级PRI = 70,NI = -10,也就是说连续设置NI值并不会在以往设置的基础之上变更,而是在最初的基础上做出更改。也就是说,PRI(new) = PRI(old) + NI, 这个PRI(old) 一般都是80;
并且还有一个问题,我们知道NI的范围:[-20,19],这个NI值能在范围之外设置吗 ?
当我让NI = 20时,可以发现OS此时给我的NI值为19,也就是说,这个NI值只能在适当的范围内设置,即在这个范围内:NI = [-20,19];
那么新问题又来了,NI值为什么是要在这么一个比较小的范围呢?OS如何考虑的?
首先我们要明确一点,优先级如何设置,也只能是一种相对的优先级,不能出现绝对的优先级;
如果进程间的优先级跨度太大,那么OS就只会调度优先级高的进程,那么带来的问题就是一些进程不能及时获得CPU资源,也就是出现了较为严重的进程"饥饿问题";操作系统为了防止出现这种问题,就有了调度器,OS需要通过调度器让每个进程较为均衡地享受CPU资源,这也从侧面说明了进程是具有竞争性的。
1.4.3. top命令的扩展
当在Linux系统中运行top命令时,它会显示当前系统的实时性能统计信息和进程相关信息。这些信息包括系统的总体资源使用情况、CPU利用率、内存使用情况、交换区使用情况、进程列表以及每个进程的详细信息。
下面是top命令的一些常见显示项:
Load Average: 平均负载,表示系统在最近1分钟、5分钟和15分钟内的平均活跃进程数。它是判断系统负载的一个指标。
CPU Usage: 显示系统整体CPU利用率,以及每个CPU核心的利用率。
Memory Usage: 显示物理内存的使用情况,包括总内存、已用内存、空闲内存、缓存和缓冲区等。
Task Area: 显示所有活动进程的汇总信息,包括进程的PID(进程ID)、用户、优先级、占用CPU百分比、内存使用量等。
Command Area: 显示具体的进程列表,按照CPU使用率或内存使用率排序。可以实时监控和管理进程。
Top命令还提供了一些交互式操作,如按键盘上的数字键可以切换不同的排序方式,按下键盘上的"q"键可以退出top命令等。
通过top命令,系统管理员可以快速了解系统的整体负载情况、性能瓶颈所在,以及进程的资源消耗情况,是一个非常有用的性能监控工具。
2. 进程的其他概念
- 竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。
- 并行:多个进程在多个CPU下同时进行运行,在每一个时刻,多个进程同时在运行。
- 并发:多个进程通过CPU的快速切换,在一段时间内,多个进程都得以推进。
- 在多CPU的情况下,并行和并发可能是同时存在的;
有了对优先级的理解,我们应该可以确认进程是具有竞争性的。
但是这个独立性如何理解呢?
进程的独立性是指进程拥有自己的地址空间和资源,可以在不依赖其他进程或线程的情况下运行。进程的独立性可以通过操作系统提供的进程管理机制来实现,包括以下几种方式:
1. 进程的地址空间独立:每个进程都有自己独立的地址空间,它包含了进程所需的代码、数据和堆栈等信息。不同进程之间的地址空间是相互隔离的,这使得进程可以独立运行,彼此之间不会互相干扰。
2. 进程的资源独立:每个进程都有自己独立的资源,包括打开的文件、网络连接、进程内存等。不同进程之间的资源是相互独立的,这样进程可以在不互相干扰的情况下访问自己的资源。
3. 进程间通信机制:虽然进程是独立运行的,但是在某些情况下需要进程之间进行通信。操作系统提供了多种进程间通信机制,如管道、消息队列、共享内存等,使得不同进程之间可以进行数据共享或者传递控制信息等操作。
综上所述,进程的独立性通过操作系统提供的进程管理机制来实现,它使得每个进程独立运行,相互之间不会产生影响,从而保证了系统的稳定性和安全性。
3. 环境变量
3.1. 常见环境变量
PATH : 指定命令的搜索路径HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)SHELL : 当前Shell,它的值通常是/bin/bash
3.2. 环境变量有什么用
命令、可执行程序、工具等本质都是一个可以执行的二进制文件
上面的运行结果有一个问题:
为什么系统的可执行文件(例如这里的ll)为什么不带路径就可以执行呢?为什么用户写的可执行文件不带路径就执行不了呢?只有带上了路径才可以执行?
带上路径执行可以理解,因为OS要执行一个可执行文件,需要找到它的位置。
但为什么系统命令就不用带呢? ---- 答案就是因为环境变量
我们不要去质疑操作系统定义变量的能力,我们语言之所以可以定义变量,也是OS赋予的权利罢了。
Linux操作系统中存在一个环境变量PATH,环境变量 PATH 是一种用于指定操作系统在哪些目录中查找可执行文件的变量。
在Linux如何查看? echo $PATH就可以查看PATH里面的内容
在 Linux 或 Unix 系统中,当你在终端输入一个命令时(如 ls)且没有明确可执行程序的路径(相对/绝对路径) ,那么操作系统会在 PATH 变量指定的一系列目录中依次查找是否存在该命令的可执行文件,即可执行程序的搜索路径。如果找到了,则会执行该命令;如果没找到,那么就会报错;
PATH 变量的值是由一系列目录路径组成,这些目录的路径用冒号 ( : ) 分隔。路径一般情况下是绝对路径。
例如,PATH 变量的值如上图所示,那么系统会在 /usr/local/bin、/usr/bin 和 /usr/local/sbin、/usr/sbin、/home/Xq/.local/bin、/home/Xq/bin这些目录中依次查找是否存在当前命令的可执行文件,找到后执行该文件,找不到,就会报错;
bin其实就是binary的意思,及二进制文件所在的目录;
OK,经过你上面的分析,假如我执行自己的可执行程序不想明确路径的话,那么此时只需要将这个可执行程序所在的目录添加到环境变量PATH里面,是不是就可以了呢? 是的!
那么如何添加:
3.3. 环境变量PATH如何更改
方式一:(错误示范)
PATH=:cwd
cwd就是你这个可执行程序变成进程后的工作目录或者这个可执行程序所在的目录!
我将这个可执行程序文件所在的路径添加到了环境变量PATH里面,没问题,我的可执行程序跑起来了,但是,我的touch还能跑吗?cat还能跑吗?ll还能跑吗?man还能跑吗?
我查看PATH里面的内容,惊奇的发现,里面原来的内容咋不见了,咋被覆盖了呢?
但我不慌,因为这是内存级别的环境变量,随便搞,并没有更改配置文件,我们重新在打开一个终端就好了。
方式二:(正确示范)
那么如何正确的将可执行程序所在的目录添加环境变量PATH里面呢?
我们需要使用export PATH= $PATH:cwd(current work directory)
可以发现,通过export将可执行程序所在的目录添加到环境变量PATH里,此时我们自己的可执行程序可以不带路径并正常运行,并且此时这些系统命令也可以正常运行,原因就是因为没有覆盖之前环境变量PATH里面的内容,此时只是将现在这个进程的cwd添加到了环境变量PATH里;
其实我们以往安装软件的过程,本质就是把这个软件拷贝到系统环境变量的特定的命令目录下;如果我们想让自己添加的环境变量永久有效的时候,需要修改配置文件,~/.bash_profile(谨慎操作)、~/.bashrc(谨慎操作),环境变量,是写在配置文件里面的,shell启动的时候,通过读取配置文件获得起始的环境变量!
不建议修改配置文件,其一,我们自己的可执行程序可能会带命名污染问题;其二,不安全!
.bashrc配置文件
[Xq@VM-24-4-centos 11_18]$ ll ~/.bashrc
-rw-r--r-- 1 Xq Xq 350 May 12 2023 /home/Xq/.bashrc
[Xq@VM-24-4-centos 11_18]$ vim ~/.bashrc
[Xq@VM-24-4-centos 11_18]$
.bash_profile配置文件
[Xq@VM-24-4-centos 11_18]$
[Xq@VM-24-4-centos 11_18]$ ll ~/.bash_profile
-rw-r--r-- 1 Xq Xq 193 Apr 1 2020 /home/Xq/.bash_profile
[Xq@VM-24-4-centos 11_18]$ vim ~/.bash_profile
[Xq@VM-24-4-centos 11_18]$
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数,如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局属性;当然,Linux环境下的环境变量有很多,不仅有PATH,还有HOME,SHELL等等
3.4. 环境变量HOME
为什么不同用户登陆的时候,家目录不一样,根本原因就是不同用户各自的HOME环境变量不一样,也就是说环境变量HOME存储的是当前用户的家目录所在路径。
普通用户的环境变量HOME:
root用户的环境变量HOME:
3.5. 环境变量SHELL
SHELL: 当前Shell,它的值通常是/bin/bash
3.6. 和环境变量相关的命令
1. echo:显示某个环境变量值,例如,echo $PATH;2. export:设置一个新的环境变量;3. env:显示所有环境变量;4. unset:清除环境变量;5. set:显示本地定义的shell变量和环境变量;
3.7. 本地变量
操作系统还存在一种变量。是与本次登录(session)有关的变量,只在本次生效(本地变量)Xshell关闭就会消失!
这里面的myval就是本地变量,只在本次登陆有效
上面可以说明export 本地变量可以将本地变量导为环境变量,但是这个环境变量并没有写入配置文件里面里(内存级别的环境变量),此次关闭,这个环境变量也会消失;
unset:清除对应的环境变量或者本地变量!
3.8. 如何获取环境变量
方式一:env
env:显式系统中所有的环境变量
语言上面定义变量本质是在内存中开辟空间(有名字),不要去质疑OS开辟空间的能力
环境变量本质就是OS在内存/磁盘开辟的空间,用来保存系统相关的数据!
方式二:main的第三个参数
我们知道,main是最多携带三个参数的,如下:
// 前两个参数: 我们称之为命令行参数
// 第三个参数: 环境变量参数
int main(int argc char* argv[],char* env[])
每一个进程都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以'\0'为结尾的字符串,并且这个指针数组以NULL结尾;下面演示了这张表的一部分:
可以发现此时的环境变量 char* env[] 没带个数,那是因为char* env[]并不是用户填的,而是操作系统自动给我们填的,不会出现错误的选项,我们不用关心;
那么如何获取环境变量呢?
int main(int argc,char* argv[], char* env[])
{
for(int i = 0; env[i]; ++i)
{
printf("%s\n",env[i]);
}
return 0;
}
可以看到,此时打印的就是系统定义的环境变量。
命令行参数
// 前两个参数就是命令行参数
// argc: 命令行参数的个数
// argv: 是一个字符指针数组
int main(int argc,char* argv[],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;
}
argc代表着:命令行参数的个数!
argv本质是一个字符指针数组,该数组的每个元素都指向一个字符串,最后以NULL结尾,以 ./my_test -a -b -c举例,具体如下:
命令行参数有什么作用呢?我们可以在命令行上传递参数给main()中的argv这个指针数组,让同一个可执行程序根据不同的参数完成不同的子功能,也许现在我们很不能理解,但通过ls这个命令我们可以看出命令行参数的功能:
我可以看到,ls命令通过在命令行上传递不同的参数可以达到不同的表现形式(完成不同的子功能),也就是说可以得到不同的结果,那么,如果我们也想让同一个可执行程序经过传递不同的命令行参数,以达到不同的结果,该如何实现呢?
int main(int argc,char* argv[])
{
if(argc != 2)
{
printf("input error,eg: ./my_test -a\n");
exit(1);
}
else if(strcmp(argv[1],"-a") == 0)
{
printf("hehe\n");
}
else if(strcmp(argv[1],"-b") == 0)
{
printf("haha\n");
}
else if(strcmp(argv[1],"-c") == 0)
{
printf("heihei\n");
}
else
{
printf("input error, only -a | -b | -c\n");
exit(1);
}
return 0‘
}
命令有很多选项,用来完成同一个命令的不同子功能,这些选项底层使用的就是我们的命令行参数,即通过不同的命令行参数达到不同的表现形式,完成不同的子功能!
命令行参数说白了就是命令行上一个命令(可执行程序) + 各种选项,通过解析不同的选项,使得该命令有不同的表现形式,完成不同的子功能!
方式三:通过第三方变量environ获取环境变量
extern char **environ
Linux系统中提供了一个全局的第三方变量environ,这个第三方变量指向这个指针数组,即指向这张环境表!如下:
那么如何借助这个第三方变量获取环境变量呢?如下:
int main(int argc,char* argv[], char* env[])
{
extern char** environ; // 操作系统为我们提供的一个全局变量
for(int i = 0; environ[i]; ++i)
{
printf("%s\n",environ[i]);
}
return 0;
}
可以看到,此时打印的就是系统定义的环境变量。
方式四:通过getenv获取环境变量
#include <stdlib.h>
char* getenv(const char* name); //获得指定的环境变量
void Test3(void)
{
printf("PATH: %s\n",getenv("PATH"));
printf("HOME: %s\n",getenv("HOME"));
printf("SHELL: %s\n",getenv("SHELL"));
}
可以看到,此时打印的就是我们指定的环境变量。
3.9. 为什么说环境变量具有全局属性
OK,现在经过前面的分析已经确认了,一个进程跑起来的时候会有环境变量,但是我现在就非常好奇这个环境变量究竟是谁传给我的呢?
答案就是:父进程! 父进程将它的环境变量传递给了子进程!
而我们之前也说过,在命令行上跑起来的进程的父进程都是bash,例如:
可以发现,在命令行上启动的进程,其父进程都是一样的,并且这个进程就是bash!
好,你说了,子进程的环境变量是继承父进程的 ,那么如何证明?
void Test5(void)
{
printf("my_string: %s\n",getenv("my_string"));
}
那么如果我在bash这个父进程将my_string设置为本地变量,那么子进程会继承这个本地变量吗?
通过结果,可以发现,父进程的本地变量是无法被子进程继承下去的;
那么父进程的环境变量可以被子进程继承下去吗?如下:
可以发现,当export将本地变量导为环境变量后,子进程也就可以获得这个环境变量,也就证明了子进程的环境变量是继承父进程的环境变量,默认情况下所有的环境变量都会被子进程继承下去 ,而本地变量无法被子进程继承下去;
那么为什么说环境变量具有全局属性呢?就是因为父进程的环境变量可以被子进程继承下去,那么也就意味着环境变量将会影响整个用户系统,也就是说环境变量具有全局属性。
4. 进程地址空间
在学习C/C++的过程中,我们认为32位机器的程序的地址空间分布图如下:
4.1. 验证程序的地址空间分布
光有图可不行,我们需要用代码去验证一下它的正确性
int uninit_g_val;
int init_g_val = 10;
static int j = 10;
int main(int argc,char* argv[],char* env[])
{
printf("code address: %p\n",main);
const char* str = "cowsay hello";
printf("string address: %p\n",str);
printf("init global val address: %p\n",&init_g_val);
printf("global static address: %p\n",&j);
static int i = 10;
printf("local static address: %p\n",&i);
printf("uninit global val address: %p\n",&uninit_g_val);
int* ptr = (int*)malloc(sizeof(int));
printf("heap address: %p\n",ptr);
printf("stack address: %p\n",&ptr);
printf("argv address: %p\n",argv);
printf("env address: %p\n",env);
return 0;
}
测试结果如下:
code address: 0x4008a5
string address: 0x400baf
init global val address: 0x601064
global static address: 0x601068
local static address: 0x601070
uninit global val address: 0x601078
heap address: 0x64a010
stack address: 0x7fff1ea44ca0
argv address: 0x7fff1ea44d98
env address: 0x7fff1ea44da8
经过上图,我们可以确定这个程序地址空间的分布是正确的,其中static静态变量其实是在未初始化全局变量和初始化全局变量之间,即static修饰局部变量的本质是: 将局部变量开辟在全局区域; 其中堆栈之间的存在大量的空间(可以看到栈区的地址和堆区的地址差值非常大)。同时我们也可以看到,字符串(字符常量)是和代码位于同一块区域的(这块区域具有只读属性)!
注意:上面的结论默认只在Linux下有效!
4.2. 验证堆栈相对而生
什么叫堆栈相对而生呢?即堆的地址向高地址处增长,栈的地址向低地址处增长!验证如下:
void Test1(void)
{
int* ptr1 = (int*)malloc(4);
int* ptr2 = (int*)malloc(4);
int* ptr3 = (int*)malloc(4);
int* ptr4 = (int*)malloc(4);
printf("heap address1: %p\n",ptr1);
printf("heap address2: %p\n",ptr2);
printf("heap address3: %p\n",ptr3);
printf("heap address4: %p\n",ptr4);
printf("----------------------\n");
printf("stack address1: %p\n",&ptr1);
printf("stack address2: %p\n",&ptr2);
printf("stack address3: %p\n",&ptr3);
printf("stack address4: %p\n",&ptr4);
}
运行结果如下:
从结果我们也可以看到,堆的地址向高地址处增长,栈的地址向低地址处增长;称之为堆栈相对而生!
4.3. 上面的地址空间分布是内存吗?
这里有一个问题,上面的地址空间是物理内存吗?
答案是:根本就不是物理内存,甚至我们上面通过打印得到的地址也不是真正物理内存上的地址!
听到这个答案,相信很多人感觉三观都被震碎了,啊?这不是物理内存的地址,那我之前学习C/C++的那个地址是什么?
OK,这个地址究竟是什么,我们后面解释,但是你说我们之前学习的地址不是物理内存的地址,那么请证明:
int g_val = 10;
void Test2(void)
{
pid_t id = fork();
if(id == 0)
{
// child process
int cnt = 0;
while(1)
{
printf("i am a child process ,PID: %d,PPID: %d,g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
if(++cnt && cnt == 3)
{
g_val = 50;
printf("child process: g_val: 10 -> 50,success\n");
}
}
}
else
{
// parent process
while(1)
{
printf("i am a parent process,PID: %d,PPID: %d,g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
}
上面这段代码的逻辑:子进程和父进程都是死循环打印各种信息,最重要的信息就是,打印了一个全局变量的值和地址!并且子进程打印三次后,将这个全局变量的值由10更改为50;
运行结果如下:
现象如我们预期一样,前三秒子进程和父进程分别打印各自信息,第四次打印时:子进程将这个全局变量的值更改,继续和父进程重复打印!
可以看到,更改后的打印,子进程打印的全局变量的值为50,父进程打印的全局变量的值为10;这很好理解,因为进程具有独立性!当发生数据修改时,会发生写实拷贝!即这个g_val在父子进程看来是不同的值!
但是!最令人奇怪的就是,父子进程打印这个全局变量的地址却没有任何变化,还是同样的地址,这就非常奇怪了!如果我们假设打印的地址是物理内存地址,那么不就矛盾冲突了吗?要知道,物理地址具有唯一性,怎么可能同一个地址在同一个时刻中打印的值是不一样的呢?就好比说,在某一个时刻,李四的朋友A问李四正在干什么,李四说:我正在吃饭;也是在这同一个时刻,李四的朋友B问李四正在干什么,李四说:我正在跑步;同样在这一时刻,李四的朋友C问李四正在干什么,李四说:我在看电影;这很显然是不合理且矛盾的;因此,我们说,这里打印的地址绝对不可能是物理内存地址!
既然不是物理内存地址,那么请告诉我,这里打印的地址究竟是什么?
我们称之为:虚拟(内存)地址或者线性地址;
在这里有一个结论: 几乎所有的语言,如果它有 "地址"的概念,这个地址一定不是物理地址,而是虚拟地址!物理地址用户统一看不到,也访问不到,物理地址由OS统一管理,因此OS必须负责将虚拟地址转化为物理地址。
而我们刚刚看到的那个地址空间分布图:我们称之为进程的虚拟地址空间!
4.4. 进程的(虚拟)地址空间是什么
在32位下,一个进程的地址空间,其取值范围是0x00000000 ~ 0xFFFFFFFF,而我们将[0,3GB]称之为用户空间,将[3GB,4GB]称之为内核空间(属于OS的)。
我们验证地址空间的时候,是什么在打印呢?答案是:进程在打印!因此我们在4.1验证的地址空间分布图称之为进程的地址空间!
而我们在这里简化一下32位机器的进程地址空间,如图所示:
如何理解进程的地址空间呢?在这里用一个故事帮助我们感性的理解进程地址空间。
情景如下:
有一个土财主李四,他的资产有10个亿!但由于李四的私生活非常的混乱,他有若干个私生子(我们在这里简化一下,假设李四只有三个私生子),这三个私生子分别为小a,小b,小c;小a是一名大学生;小b是一名刚入职的程序员;小c是一个常年混迹于股市的股民;小a他们三个私生子互相不知道对方的存在;由于小a他们都是私生子,所以李四出于鼓励他们的角度,让他们做好自己本职的工作,于是,有一天,李四找到小a,对小a说:小a啊,你好好念书,等你把书念好了,老爹就退休,然后把这10亿的资产全部交给你去打理!小a一听,连声叫好,回答得非常迅速:老爹我一定把书念好(各种努力学习,自己脑补~~~)!!!然后李四又去找到小b,对小b说:小b啊,你好好工作,认真敲代码,等你变得非常有经验且对业务十分熟悉后,老爹就退休,让你管理咱家的所有资产!!!小b一听,浑身上下打满了鸡血,连声答应:老爹,你放心,我不会让你失望的(各种努力工作,自己脑补~~~)!!!同理,李四又去找到了小c,对小c也说出了类似于小a和小b的话,同样让小c也感到满满的动力!!
故事暂停到这里:
上面的情景:李四,这个土财主,作为上帝视角,是知道这些私生子是互相存在的!并且给他们每个人画了一张大饼(这张大饼就是李四的所有资产)!!!而在每个私生子自己的视角中认为:他自己独占李四的所有资产(因为私生子互相不认识);
故事继续:
有一天,小a找到李四:老爹,我想去买一本书,你给我500块钱吧,老爹一听,买书,好事啊,于是就同意了小a的请求,就给了小a500块钱!
同样,有一天,小b也找到李四:老爹,我想买一台最新款的电脑用来帮助我学习和处理业务,大概2W;老爹一听,是正事啊,然后就说没问题,就同意了小b的请求,就给了小b2W;
同样,有一天,小c找到李四:老爹,我想要投资一个项目,需要3个亿,老爹你能支持我吗?老爹一听,就问那这么多钱要去去干什么?(然后两人就说了很久,反正最后要钱失败了!);虽然小c要钱失败了!但是他还是认为自己独占老爹的所有资产,即他还是认为自己拥有这10个亿!!!
当李四还存在并且他画的饼还存在,只要李四满足私生子的需求,那么这些私生子就会认为他自己独占李四的资产 ;甚至如果索要太多,那么李四也可以拒绝这种请求,并且这个私生子还是会认为他自己独占李四的所有资产!
而我们把李四这个土财主--- 称之为OS!把这三个私生子 --- 称之为三个进程!把土财主李四给私生子画的大饼 --- 称之为地址空间!
如果此时私生子很多,那么老爹要给每一个私生子画一张大饼!!!大饼如此之多,那么老爹需不需要管理这些饼呢?
答案是:必须要管理;
类比到OS:
而我们知道,进程有很多!那么OS给每个进程都要画一张"大饼"(进程地址空间),那么进程地址空间如此之多,OS需不需要将这些管理起来呢?
答案是:必须要管理;
如何管理:先描述,在组织!
因此,内核中的地址空间,本质也一定是一种数据结构,将来也要和一个特定的进程关联起来!
站在每一个进程的角度:每一个进程都要有一个地址空间,都认为自己独占物理内存!
因为操作系统里会存在大量进程,那么就会有很多的地址空间,所以OS需要管理地址空间
如何管理: 先描述 在组织!
描述:如何描述?当然是用struct 结构体描述地址空间,具体为struct mm_struct,即地址空间本质在kernel中是一个数据结构(struct mm_struct)!
总结:虚拟地址空间不是物理内存,它本质上是OS为进程专门设计的一种内核数据结构 !!!其内部的主要内容就是关于各个区域的划分,即每个区域(例如栈区、堆区、代码段等等) 的起始和结束位置!!!当然也还包含其他属性!!!
4.5. 进程的(虚拟)地址空间是如何设计的
我们知道,OS为我们提供的地址空间本质是划分为不同区域的,例如:下图:
那么OS是如何设计这个地址空间的呢?
同样为了说清楚这个问题,我们同样举个例子:
在小时候,我们遇到过同桌划分三八线的场景,例如:
在现实生活中,如果这两个人要画三八线,很简单,只需要在课桌的中间画一条三八线即可!就可以将一段空间分为不同的区域!
那么如果用C语言,如何描述上面的过程呢?很简单,用一个struct结构体,用start和end两个成员标识不同区域的起始和结束不就好了吗?例如:
struct desk_struct
{
unsigned int start; // 区域的起始位置
unsigned int end; // 区域的结束位置
};
// 假设这个书桌的长度为100,三八线为50
// 如何表示李四和翠花的区域呢?
struct desk_struct lisi = {1,50};
struct desk_struct cuihua = {51,100};
而OS设计地址空间(struct mm_struct)也是同样如此,地址空间本质是一种内核数据结构!它里面至少有各个区域的划分!那么如何将将地址空间(32位机器下的4GB)划分为不同的区域吗?OK,那么我就用不同的start和end标识不同区域的起始位置和结束位置不就OK了吗?例如:
// 在kernel中会有类似这样的划分方式用来划分地址空间
struct mm_struct
{
unsigned long code_start; //代码段的起始地址
unsigned long code_end //代码段的结束地址
unsigned long init_gval_start; //已初始化的全局变量的起始地址
unsigned long init_gval_end; //已初始化的全局变量的结束地址
unsigned long uninit_gval_start; //...
unsigned long uninit_gval_end; //...
unsigned long heap_start; //...
unsigned long heap_end; //...
unsigned long stack_start; //...
unsigned long stack_end; //...
// 当然还会存在其他的字段
...
};
这就是进程虚拟地址空间!本质就是OS内核中的一种数据结构!
上面的地址空间的有些区域的范围不一定是死的,地址空间有些区域是会有变化的,例如栈向下增长,堆向上增长;所谓的增长本身就是一种范围变化,本质其实就是对某一个范围的start或者end标记值 +- 特定的值;
4.6. 为什么有进程的(虚拟)地址空间
第一个理由:
不知道各位考虑过没有,为什么OS要设计虚拟地址空间呢?为什么不让进程直接访问物理内存呢?
在历史上,进程是可以直接访问物理内存的!!!
而我们需要知道,物理内存本质就是一个硬件,随时可以被读写的(没有读写区域的限制),至于你这个位置能不能被访问,不是我物理内存考虑的问题!
那么也就是说,一个进程可以通过直接访问物理内存的方式访问其他进程的数据,甚至修改其他进程的数据!
那么就带来一个致命的问题:不安全,因为如果进程的独立性都无法保证,那么进程的数据就随时可能被其他进程访问(读写)!当然还会存在其他的问题,在这里就不详说了。
因此,这个问题的关键就在于:进程直接访问的是物理内存! 因此现代计算机没有提供进程直接访问物理内存的能力!因为我们要保证进程的独立性!
因此,现代计算机,通过下面的方式访问物理内存:
地址空间上的地址我们称之为虚拟地址;物理内存的地址我们称之为物理地址;
页表:本质是一个映射表,是由OS维护起来的,其功能:将虚拟地址映射为物理地址!
每个进程都有自己的PCB,并且OS为了保证进程的独立性,OS会为每一个进程创建地址空间(虚拟地址空间),当进程要访问物理内存的时候,需要先通过页表映射!将虚拟地址映射为物理地址!
有人看到这里,就非常奇怪,你最终还是在访问物理内存啊,这不是多此一举吗?即如果我的虚拟地址是一个非法地址呢,此时通过页表映射,不会出现问题吗?
如果虚拟地址是一个非法地址的话,那么此时页表就会禁止映射到物理内存,那么此时就变相地保护了物理内存!!!
每一个进程都会私有一份地址空间和页表(用户级)!!!
有了地址空间和页表,那么此时只要保证,每一个进程的页表,映射的是物理内存的不同区域,就可以做到,进程不会相互干扰,进而保证进程的独立性!
第二个理由:
首先,我们要知道页表是一张映射表,本质就是一种数据结构!!!
而我们之前说过,每个进程都会私有一份页表!因此OS必然会存在大量的页表,那么OS需不需要管理这些大量的页表呢?
答案是:必须要管理!!
这个页表不仅维护了每个进程的映射关系(将虚拟地址映射到物理地址),还有权限管理!!!页表会对每一个映射关系还维护了读写权限管理!!!例如:
void Test1(void)
{
const char* str = "cowsay hello";
*str = 'x';
}
上面的代码会编译报错(str位于只读位置,不可修改) ,在以前我们学习语言的时候,解释的措辞是:常量区的内容不可修改!!!我们以前只能解释到这一步,就无法再深入解释了,因为再进一步就是OS!
而今天,我们就要站在OS系统的角度理解这个问题!
现在我们就可以站在OS的角度解释这个问题了:之所以常量区的内容不可被修改,本质上就是因为OS对这块区域的权限设置为"r",即只读区域!!!
而我们也可以理解,之所以可以达到这个结果, 并不是硬件层面不支持你这个进程进行写入,而是在软件层面中禁止了你这个进程对其进行写入!!!本质就是页表对地址空间的每一块区域进行了读写权限设置!!!
不然,当可执行程序 ---> 进程的时候,不是要将代码和数据加载到内存吗?这不就是一个写入的过程吗?也就是说物理内存可以在任意时刻任意位置被进行读写!!!
总结:
因为地址空间和页表的存在,OS可以对进程的非法访问进行有效拦截(软件层面)!!!从而有效地保护了物理内存!!!也就是说,凡是非法的访问或者映射,OS都会识别到,并终止你这个进程!!而所有的进程崩溃,不就是进程退出吗吗?而我们知道OS是进程的管理者!!那么进程退出的本质就是OS杀掉了这个进程!!!
因为地址空间和页表是由OS创建并维护的!!!那么也就意味着凡是想使用地址空间和页表进行映射的进程,也一定要在OS的监管之下来进行访问!!!那么地址空间和页表也间接地保护了物理内存中的所有的合法数据,包括各个进程,以及内核的相关有效数据!!!
第三个理由:
因为有地址空间和页表映射的存在,我们的物理内存,是不是可以对未来的数据进行任意位置的加载?
答案:是的,当然可以!!!
那么物理内存的分配就可以和进程的管理,可以做到没有关系,也就是说内存管理模块 && 进程管理模块就完成了解耦合!!!
解耦合:即减少各个模块之间的关联性!!!而这个过程我们就称之为解耦合,耦合度越低,维护成本越低!!!
在这里有一个问题:我们在C、C++上malloc、new空间的时候,本质是在哪里申请的呢?是物理内存还是地址空间?
答案是:地址空间;
如果我申请了物理空间,但是如果我不立刻使用,是不是造成空间的浪费?
答案:是的!在OS角度,如果申请的物理空间立马给你,并且你不立刻使用,那么是不是意味着,整个OS会有一部分空间,本来可以给别人立即使用的,但是现在却被你闲置着!!!
这里有个故事帮助理解:
过年了,你收到了来自亲朋好友的压岁钱,我们在这里量化一下,假设你收了500的压岁钱!当天晚上,你对你妈妈说:我想用这500块钱去买一些玩具和一些零食吃,预算就是500,你妈妈同意了!但是由于天太晚了,那些门店都关门了,于是你妈妈说:我先帮你把钱收着,明天我带你一起去买吧!你一想,反正今天买不了了,于是同意了你妈妈的想法,然后你就去睡觉了!!!但与此同时,你老爹正在打麻将了,然后给你妈打了个电话,让她捎过来500块钱!说明天早上再还给她!!你妈妈就同意了,就将这500块钱给了你老爹!到了第二天,老爹打完麻将,就将这500块钱有还给了你妈妈,然后呢,你妈妈就带去买东西了!!!而这个过程就类似于我们申请空间的过程!!!
在这个过程中,当我准备用500去买东西的时候就类似于上层用户用malloc、new申请空间,而此时由于天色太晚,无法立刻去使用这500去购买物品,于是这个钱暂由你妈妈保管,这个过程就类似于,当你在上层申请了空间之后,如果你没有立即访问这段空间,那么OS就不会在物理内存上申请空间!!而会将这段物理空间分配给立刻需要访问的人,而在这里就是我们的老爹!!只有当你马上要访问这段空间时,OS才会在物理内存分配空间给你!!!
本质上,【因为有地址空间的存在,当上层用户申请空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你,而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请物理内存,构建页表映射关系】,然后,再让你进行物理内存的访问!!
这个过程(用【】包起来的这个过程)是由操作系统自动完成,用户包括进程完全0感知!!! 而这个技术我们称之为缺页中断(虚拟地址空间分配了,但物理内存没有分配)!!!
OS分配物理内存的时候,采用延迟分配的策略,来提高整机的效率,即当你准备使用的时候,才会给你分配物理内存,也就是说,几乎物理内存的有效使用率是100%的!!!
将物理内存申请和物理内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程(透明化)!!!
第四个理由:
数据理论上可以在物理内存的任意位置加载 ,那么是不是物理内存中的所有数据和代码在物理内存中是乱序的!!!
那么既然数据是乱序的,当CPU去访问内存时,那么访问成本必然是非常高的!!!
有序的数据访问成本低,乱序的数据访问成本高!!!
但是因为地址空间 + 页表的存在,可以将地址空间上的虚拟地址和物理地址进行映射,那么是不是在进程的视角中,所有的内存分布,都可以是有序的!!!
也就是说:地址空间 + 页表映射的存在可以将物理内存分有序化!!!
结合第三个理由:进程要访问的物理内存中的数据和代码,可能目前并没有在物理内存中!!!同样的,也可以让不同的进程映射到不同的物理内存!!是不是便很容易做到,进程独立性的实现!!!
进程的独立性,可以通过地址空间 + 页表的方式实现!!!
因为有地址空间的存在,每一个进程都认为自己独占4GB空间,并且各个区域是有序的,进而可以通过页表映射到物理内存不同的区域,来实现进程的独立性!!!
有了进程的地址空间,程序的代码和数据可以被加载到物理内存的任意位置,大大的减少内存管理的负担,经过页表映射,可以找到进程的代码和数据。例如,CPU要找一个进程的main(),你就去地址空间中的代码段去找。如果没有虚拟地址空间,就会导致不同进程产生差异化,也就意味着会更加复杂,有更多的特殊情况,因此需要虚地址空间的存在,做到统一化处理!!!
如何理解挂起:
将可执行程序加载到内存的本质就是创建进程,那么是不是必须非得立马把所有与进程相关的代码和数据Load到物理内存中,并创建内核数据结构,建立映射关系?
答案是: 不是!!!在最极端的情况下,只有内核数据结构被创建出来了,代码和数据根本就没有加载到物理内存中,映射关系也没有创建 !!!此时这样的进程,就可以赋予一个状态以作标识 ---> 新建状态!!!
理论上,可以实现对可执行程序的分批加载!!!
我们也可以从实例验证,相信我们都知道一些大型游戏的空间可能有100~200GB,甚至更多,而我们的内存才多大?一般都是8/16GB的内存,如果全把这些游戏内容加载到内存中,那怎么可能呢?因此这也证明了可执行程序是可以分批次被加载到物理内存中的!!!
加载的本质:唤入的过程!!!也就是说,将磁盘中的数据和代码唤入到物理内存中。
既然可以分批加载,可以分批换出吗?
当然可以!甚至,如果这个进程短时间不会再被执行了,比如阻塞了!!!
既然你短时间内不会再被执行了,并且你这个进程的代码和数据还在物理内存中,这不就是你占用着有限的空间却没有体现价值吗?因此OS完全可以把这个进程的代码和数据换出到磁盘上,而我们将这种进程(即进程的数据和代码被换出)的状态就称之为挂起!!!
因此,结论就是:进程的数据和代码被换出了,就叫做挂起!!!
实际上,页表映射的时候,可不仅仅映射的是内存!!!磁盘中的位置,也可以映射!!!即页表的右侧,既可以映射物理内存的地址,也可以映射磁盘的位置 !!!
4.6.1. 解释以前遗留的一个问题
有了上面的认识,我们就可以解释4.3.的现象了!
int g_val = 10;
void Test2(void)
{
pid_t id = fork();
if(id == 0)
{
// child process
int cnt = 0;
while(1)
{
printf("i am a child process ,PID: %d,PPID: %d,g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
if(++cnt && cnt == 3)
{
g_val = 50;
printf("child process: g_val: 10 -> 50,success\n");
}
}
}
else
{
// parent process
while(1)
{
printf("i am a parent process,PID: %d,PPID: %d,g_val: %d,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
}
4.6.1.1. fork之前:
4.6.1.2. fork之后,全局变量未修改之前:
fork之后,代码是共享的(代码不发生修改),数据在不发生修改的前提下也是共享的!因此他们的各自的进程地址空间这个g_val的虚拟地址是一样的,且经过页表映射,映射的是物理内存的同一段空间!如下:
4.6.1.3. 全局变量修改后:
当子进程发生数据修改时,由于进程要维持自己的独立性,因此此时会发生写实拷贝,子进程会重新在物理内存开辟空间,并且子进程会更改虚拟地址的映射关系,进而保证进程的独立性!如下:
当子进程修改这个全局变量之后,由于进程要维护自己的独立性,因此子进程会发生写时拷贝!即在物理内存中开辟新的空间!而我们打印的地址之所以一样,原因是因为我们打印的是虚拟地址!实际上,此时这两个进程通过页表将这个虚拟地址映射后的物理地址是不一样的!这也就是为什么我们能够看到,同一个地址,存储的值不一样!根本原因就是因为我们打印的是同一个虚拟地址罢了!!!
4.6.2. fork()为什么有两个返回值呢?
这个问题,我们在进程---上探讨过,当时我们的解释是:fork是一个以C语言实现的Linux系统调用接口 ,当执行return语句之前,其核心代码逻辑(创建子进程)已经执行完了,当走到return时,有两个进程,自然有两个执行流,那么执行两次return,自然有两个返回值!
而今天,我们要站在OS的角度看待这个问题:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
printf("ret: %d\n",ret);
return 0;
}
结果如下:
fork()内部的return会被执行两次,fork()内部的return的本质,本质就是对ret这个局部变量进行写入!此时就会发生写实拷贝,所以父子进程各自在物理内存中,有属于自己的变量空间 !只不过在用户层用同一个变量(虚拟地址)来进行标识罢了!!!如下图所示:
4.7. 扩展内容
当我们的源文件,在编译链接形成可执行程序之后,没有被加载到内存中的时候,请问:我们的可执行程序内部,有地址吗??
答案是:有的
objdump -h my_test // my_test是一个可执行程序
VMA ---> virtual memory address;其实,当源代码编译链接形成可执行程序的时候,可执行程序内部已经有地址了,这个地址就是虚拟地址!
地址空间我们不要仅仅理解成为OS内部要遵守的,其实编译器也要遵守!!! 即编译器编译代码的时候,就已经给我们形成了各个区域,例如代码区、数据区等等,并且采用和Linux内核中一样的编址方式,给每一个变量,每一行代码都进行了编址,故程序在编译的时候,每一个字段早已经具有了一个虚拟地址!
也就是说,将可执行程序加载到内存的同时,会将代码和数据 Load 到内存,同时编译形成的虚拟地址也要加载到内存中,因为虚拟地址也是数据!!!
可执行程序程序内部的地址,依旧用的是编译器编译好的虚拟地址,当程序加载到内存的时候,每行代码,每个变量具有了一个物理地址,外部的!
地址空间和页表最开始的时候数据从哪里来的呢?
我们知道,地址空间的地址称之为虚拟地址;物理内存的地址称之为物理地址!!!
将编译后形成的可执行程序中的虚拟地址 Load到 地址空间,相当于初始化了虚拟地址空间!!!
当CPU读到指令的时候,指令内部也有地址,那么这个地址是虚拟地址还是物理地址?
答案是:虚拟地址!!!
将虚拟地址 填到 页表的左侧!!将物理地址 填到 页表的右侧!!
如图所示:
总结:
我们要有一个认识: 虚拟地址空间不仅仅OS需要遵守,编译器也需要遵守!!!!
可执行程序内部是有地址的,这个地址就是虚拟地址;
地址空间和页表,在最开始的时候,数据从哪里来的呢?
将源代码编译链接形成可执行程序的时候, 可执行程序每一个变量和每一个函数都有地址,这个地址是编译器提供的虚拟地址;同样,每一个变量和每一个函数同样被加载到了物理内存,此时便有了物理地址!!!
当可执行程序变为进程的时候,OS会为进程创建PCB、地址空间、页表等等,此时会将可执行程序内部的虚拟地址初始化地址空间,同时,将虚拟地址填入页表的左侧,将物理内存中的代码和数据所在的物理地址填入页表的右侧!!!
当CPU去执行指令的时候,读取的是虚拟地址,通过页表映射,得到物理地址,进而访问物理内存!!!
而我们称之为这种机制就称之为虚拟内存!!!
在Linux下,逻辑地址、线性地址、虚拟地址,这三个是一回事!!!