cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先权高的进程有优先执行权利。
在linux系统中运行一个进程后输入ps -l命令可查看具体情况:
PRI即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。
NI就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。这里其实可以理解为优先级调整的幅度大小,有了nice值可以直观地看出来。
PRI值越小越早被执行,那么在此基础上加入nice值后,将会使得进程优先级PRI变为:PRI(new)=PRI(old)+nice。这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。所以,调整进程优先级,在Linux下,就是调整进程nice值;nice其取值范围是-20至19,一共40个级别。
这样设置的原因是:优先级是一种相对的,而不是绝对的。意思就是不能让某个进程享有绝对的优先,这种情况会导致严重的进程“饥饿问题”,而OS中的调度器就是能让每个进程都享受到CPU资源。
系统内部命令为什么不需要使用路径?因为有环境变量的存在。
我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但
是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
环境变量的本质是OS在内存/磁盘文件中开辟的空间,用来保存系统相关的数据
可以这么理解:int a = 10;环境变量就相当于此时的a,数字10就相当于对应指令的路径。那么找到a的地址然后取里面的值就可以拿到对应的指令结果。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
常见的环境变量:
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
运行echo $PATH可以看到下列结果:
这个结果的意思是:我们输入的系统指令,系统会在这些以冒号分隔的路径找;原则是从第一个冒号分隔的路径开始,找不到就到下一个冒号分隔的路径,以此类推。
对于HOME变量,其作用是指定不同用户的初始家目录:用root用户和自定义用户进入系统时的初始目录不同,分别是/root和/home/user;其原因就是对于不同用户,HOME变量不同。
举个例子:
myval=100;//设置一个本地变量,注意不要有空格
此时的myval是本地变量,只在本地显示:
set | grep myval;
//输出结果myval = 100;
在全局情况下不显示:
env | grep myval;
//无显示内容
把本地变量转为环境变量:
export myval;
env | grep myval;
//输出myval = 100;
系统是会去找指令,但是这个过程是怎么做到的?
其实main函数也是有参数列表的:
argv是一个指针数组,而argc表示的是该数组的元素个数,第三个参数暂不讨论
那么创建一个test.c程序对该指针数组进行遍历如下:
int main(int argc,char* argv[],char* env[])
{
for(int i=0;i<argc;++i)
{
printf("argv[%d]:%s\n",i,argv[i]);
}
return 0;
}
可以发现输出结果只有一个可执行程序的执行命令./test
argv[0]:./test
此时再输入几条指令-a -b -c
[zcb@VM-8-7-centos lesson2]$ ./test -a -b -c
会发现它们作为argv数组的成员被打印了出来
argv[0]:./test
argv[1]:-a
argv[2]:-b
argv[3]:-c
如何理解?类比ls指令(列出指定路径下所有文件),ls可以带有指令-a -l -i,把ls看做一个可执行程序,而后面接着的指令被作为命令行参数导入进该指针数组了,那么不同的参数列表传递给main函数,就可以让该程序呈现出不同的结果。
./test是这样的原理,类比ls等系统的指令也是这样的原理,这就很好的解释了为什么ls 后带的命令行参数等可以呈现出不同的结果,这本质上就是通过给main函数传递不同的参数,以实现不同的功能。
接下来讨论第三个参数
第三个参数实际上就是OS自己实现的系统默认的指令字符指针数组,和上面的例子一样。只不过前面的是指向变量的字符指针数组,而第三个参数是指向环境变量的字符指针数组。
使用while循环对该指针数组进行遍历,得到的是一堆指令,这便是系统默认的指令,而给main函数传第三个参数的这个行为是OS做的。
一个进程运行起来后,如果你自定义了并export了一个环境变量,此时搜索,是可以搜到的;因为环境变量是一个系统级别的全局变量,bash之下所有进程都可以获取,本质就是调用方会给main函数传env,让所有进程都能找的到该变量。
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for(; env[i]; i++){
printf("%s\n", env[i]);
}
return 0;
}
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。
int main()
{
extern char **environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
注意到这个方法没有在main函数实现参数列表也是可以得到环境变量的,这是因为environ指向的就是上述存储环境变量地址,所以environ其实就是一个二级指针:
图中是获取环境变量的方法,系统会生成一个char**的指针,指向main函数接收到的指针数组,进而访问其成员。
其实上述的方法大多数是不适用的,因为太过麻烦,常用的是下列方法:
printf("%s\n", getenv("PATH"));
//例如
//mystr=“hello”;//注意不要带空格
//export mystr;
//printf("%s\n",getenv("mystr"));
getenv是可以直观地把环境变量的内容打印出来,这个函数的本质其实是把上面的遍历方式封装起来了,其函数实现就是上面写的遍历数组方式。
环境变量的全局性本质是环境变量可以被子进程继承下去。
但是本地变量是不可以的,因为它的生命周期不具有全局性。
如果这时候把该本地变量导出为环境变量(按照上面提到的导出方法),就可以被子进程所继承。(如果未创建其他进程,那么上述操作其实就是把本地变量的地址写入到了bash进程的环境变量路径内,此时env查找指令是可以看到该环境变量的)
此时的my_env还是本地变量,下列程序运行为null
int main(){
printf("%s\n",getenv("my_env"));
return 0;
}
执行下列语句后变成环境变量:
export my_env="hello"
再次运行程序结果为hello
原本子进程中打印出来的是null,将my_env本地变量导出为环境变量以后子进程打印的也就变成了hello,这说明了什么?
这说明了环境变量是可以被子进程继承下去的,也就是说只要父进程设置了环境变量,所有的子进程都能找到该环境变量并使用。
例如bash进程设置了环境变量以后,以后创建的所有子进程都可以找到它并使用。
在linux系统中进行实验:
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
这很好理解:因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。但是如果做出如下修改:
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必须负责将 虚拟地址 转化成 物理地址 。
所以准确地来说,应该叫做虚拟地址空间,而不是程序地址空间,并且这个地址空间是虚拟的。
每个进程都有一个地址空间,都认为自己独占了自己物理内存。 就比如自己申请了1000个字节的地址,不一定就能马上使用1000个字节的地址;因为我们有不完全使用该地址的可能,所以为了充分安排进程地址空间,不会立即把这1000个字节的地址空间给到你。
那OS如何知道我使用不使用?
OS会辨别你从来没有对该地址空间读写过,判定你不使用该地址空间。
所以,进程的地址空间实际上是以区域划分来实现的,平时我们听到的代码区、数据区、未初始化数据区,其实就是一个个划分好的虚拟地址空间。
并且,系统是通过结构体来管理划分的虚拟地址空间,结构体里存放有关每个区域的起始和结束的信息。
可以这样理解:32位系统中,能访问的最大地址空间为232字节,那么如果把0~232 字节的地址比做成一把尺子,那么mm_struct其实就相当于尺子上的刻度;那么虚拟地址空间其实就是这些刻度里的某一个范围,而尺子的刻度又是线性增长的,那么虚拟地址空间也是线性增长的。
所以:进程地址空间本质是内存中的一种内核数据结构----mm_struct。
对于每个进程来说,它们都认为自己占有了整个地址空间(4GB)。 所以其实虚拟地址不过是系统为进程画的一张大饼,但是要真正实现这个大饼的功能,要通过页表。
图意为:当用户进行内存申请操作时,OS会在虚拟地址空间划分区域,并将对应的地址给到页表,页表再根据拿到的地址在物理地址里开辟对应空间,然后在页表右侧填入对应地址,让两个地址形成映射,然后某一进程需要访问时,就有了地址可以间接找到用户所申请的空间。在这个过程中,物理地址对于用户与进程来说就是无法直接访问到的。
这里就可以解释:所谓栈向下增长、堆向下增长这个过程其实就是通过改变mm_struct里不同区域对应的刻度(地址范围),然后通过页表建立新的地址映射,来达到增长、减少的操作。
平时我们生成的可执行文件.exe,其实也在虚拟地址里划分好了,这里用划分这个词是因为,一个程序里会有不同的代码类型,.code、randomly、init(已初始化)、uninit(未初始化),所以其实可执行程序在虚拟地址空间里是分段的。
这些步骤其实是编译器做的,大大减轻了OS的负担,而在这个过程中编译器可能会对代码进行各种各样的优化;所以说不同的程序在不同的编译器可能会出现不同的结果。
32位系统中,虚拟地址空间为 0 ~ 4G,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间,虚拟地址空间分布如下图:
存放内核的代码和数据,所有进程的内核代码段都映射到同样的物理内存,并在内存中持续存在,是操作系统的一部分。内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。
用户空间给各个进程使用,也称为使用者空间。用户空间中的代码运行在较低的特权级别上,只能看到允许它们使用的部分系统资源,并且不能使用某些特定的系统功能,也不能直接访问内核空间和硬件设备,以及其他一些具体的使用限制。用户空间又大致细分为下列一些空间:
栈又称堆栈,由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。存储局部变量、函数参数值。栈从高地址向低地址增长,是一块连续的空间。
内存映射以及共享库(动态库)所在的内存。
堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆从低地址向高地址增长。通过 new() 或者 malloc() 函数可以在堆区开辟空间。
BSS(Block Started by Symbol)段中通常存放程序中以下符号:
未初始化的全局变量和静态局部变量;
初始值为 0 的全局变量和静态局部变量(依赖于编译器实现)
数据段通常用于存放程序中已初始化且初值不为 0 的全局变量和静态局部变量。
代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。
位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。
如果我们允许一个进程直接访问到物理地址空间,如何保证这个进程不会越界访问?如何保证它会规规矩矩精确定位到自己应该去的地方?很明显要借助一种标准,用来约束每个进程,防止它们越界。
例如,某一进程要访问物理地址的代码区,经页表查询后,OS发现对应物理地址压根就不存在该进程所访问的地址,就会直接拒绝访问,保证了物理地址和各个进程的代码数据的安全性。 就像下列代码:
const char str[] = "hello world!";
*str[0] = 'a';//error!!
我们都知道无法修改字符串的内容,因为它存在于常量区,那么为什么它存在与常量区就无法修改?
这是因为OS对于该区域,只给了你只读的权限。 这也是页表的功能之一,记录了你对于该区域的权限。所以平时我们写的程序产生野指针会崩溃其实就是该进程被OS杀死。
这时候回到刚才说的申请1000字节的例子,其实就是OS暂时扩大了该进程的虚拟地址空间,让你以为确实多了1000字节的空间,而当你真正需要的时候,OS会通过页表的方式建立好映射,给你到物理内存中分配。这叫做基于缺页中断进行内存申请。 俗称:画大饼。
说到进程申请空间的过程其实是OS给进程“画大饼”,那么这张大饼就是通过虚拟地址空间来完成的,通过它,可以将页表映射的这个过程透明化,让进程认为自己真的访问到了这么大的物理内存。但就算最后进程访问到了,它也仅仅只是知道自己访问到了,而不知道OS一开始到底有没有分配给它。
上文说的,通过添加软件层,完成有效地对内存空间进行风险管理;保证了物理地址和各个进程的代码数据的安全性;通俗点说,就是每个进程之间不会错误地去访问不属于自己的地址空间。
将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写和OS进行内存管理操作,进行软件上的分离(将应用和内存管理进行解耦)。
站在CPU和应用层的角度,如果每次运行,CPU都要找到该进程的运行入口,那么每个进程都不一样,这是非常麻烦的;所以前面说到的通过用地址空间对进程“画大饼”的方式,所有进程都会认为自己使用了整个4GB的内存空间,就可以实现进程统一,使用统一的4GB空间,而且每个进程的相对位置都会比较确定。而不是每个进程都分散在不确定的地址空间,需要CPU逐个处理。
物理内存地址的空间得到充分利用,进程可以使用物理内存的任意内存空间,大大减少内存管理的负担。
最后达到目标:每个进程都认为自己有着4GB的内存空间。
这时候页表+地址空间的作用就一目了然:
在这里就可以很好地理解第3节举的例子,为什么改变了数据后,相同的地址会打印出不同的内容:打印出来的是虚拟地址,会一样是因为子进程是由父进程继承而来,自然也就一样;而子进程对该值进行写入时,发生了写时拷贝,把另一份物理地址空间给到了子进程,重新建立了映射关系,所以才会出现有不同的值;因为这时候对于父子进程来说,对应的物理内存地址本就不同。
创建进程并不是简单的将代码数据加载到内存中,而是要先描述再组织:通过task_struct和mm_struct还有页表,将物理内存和虚拟地址空间还有进程联系起来。