目录
前言:这一篇主要是讲述Linux的进程相关概念,包括PCB(take_struct),fork,僵尸进程,孤儿进程,进程地址空间等,很重要。
一.冯诺依曼体系结构
计算机是由一个个的硬件组件组成的
输入:键盘、磁盘、网卡、话筒、摄像头等
输出:显示器、磁盘、网卡、音响、显卡等
中央处理器(运算器+控制器)【CPU】:含有运算器和控制器,进行算数计算和逻辑计算
存储器:就代表内存
为什么要有内存呢?
① 技术角度:CPU的运算速度 > 寄存器的速度 > L1~L3Cache > 内存 >> 外设(磁盘) >> 光盘、磁带。
因为外设的速度相较于cpu非常慢,因此我们让内存在CPU和外设之间进行交互,而不是让CPU和外设之间访问。(在输入数据之前,会提前写入内存中,等真正要输入数据时,就直接让内存和CPU进行访问,输出数据同理)
内存在我们看来,就是体系结构的一个大的缓存,适配外设和CPU速度不均的问题
② 成本角度
寄存器 >> 内存 >> 磁盘(外设)
因为寄存器的成本过高,并且为了普及计算机,所以退而求其次,选择内存作为存储器。
几乎所有的硬件,只能被动的完成某种功能,不能主动的完成某种功能,一般都是要配合软件完成的(OS+CPU)。
数据加载到内存时,要遵循局部性原理(该数据与内存交互时,其数据附近的数据也输入到内存中,以提高交互速度)
举个例子:我们通过聊天软件发消息和传输文件是如何进行的呢?
发消息:通过键盘(输入设备)输入数据,然后输入到内存中,与CPU进行交互后,输出到网卡(输出设备)中,再通过网络传输到对方的网卡(输入设备)中,然后输入到内存中,与CPU交互后,输出到显示器中。
传输文件:通过磁盘(输入设备)把文件输入到内存中,与CPU交互后,输出到网卡(输出设备)中,再通过网络传输到对方的网卡(输入设备)中,然后输入到内存中,与CPU交互后,输出到对方的磁盘(输出设备)中。
二.OS (操作系统)
1.OS概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。
操作系统包括:
① 内核(进程管理、内存管理、文件管理、驱动管理)
② 其它程序(库函数,shell等)
2.设计OS的目的
(1)与硬件交互,管理 所有的软硬件资源。
(2) 为用户程序(应用程序)提供一个良好的执行环境。
3.定位:
操作系统是一款软件,进行"管理"的软件。
4.理解"管理"
管理的本质:不是对被管理对象进行管理,而是只要拿到被管理对象的所有的相关数据,我们对数据的管理,就可以体现对人的管理。
管理的本质->对数据做管理->对某种数据结构的管理
管理的核心理念:先描述,再组织
描述用struct结构体(take_struct),组织用链表或其它高效的数据结构。
5.系统调用概念
Linux内核是用C语言写的,接口是用C语言来提供的函数调用。
为了防止用户的操作对OS造成影响,但是要给用户提供服务(OS设计出来就是为了给人提供服务的),因此OS是通过给用户提供接口的方式来解决。
在开发角度,OS对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由OS提供的接口,叫做系统调用。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就有利于更上层用户或者开发者进行二次开发。
三.进程
1.基本概念
进程 = 可执行程序 + 该进程对应的内核数据结构
进程是程序的一个执行实例,正在执行的程序等
担当分配系统资源(CPU时间、内存)的实体
2.PCB
(1)take_struct
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。这个进程控制块就是PCB,而Linux的PCB就是take_struct。
为什么管理进程要用PCB(process ctrl block)呢?【LInux采用take_struct】?
因为管理的核心理念是先描述,再组织。为了描述进程的所有属性数据,因此采用了PCB。而Linux是用C语言写的,而C语言中能采用的一定是struct,所以Linux中的PCB叫做take_struct。
(2)take_struct内容分类
① 标识符:描述本进程的唯一标识符,用来区别其它进程
② 状态:任务状态,退出代码,退出信号等。
③ 优先级:相对于其它进程的优先级。
④ 程序计数器:程序中即将被执行的下一条指令的地址。
⑤ 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
⑥ 上下文数据:进程执行时处理器的寄存器中的数据
⑦ I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
⑧ 记账信息:可能包括处理器时间总和,使用的时间总和,时间限制,记账号等。
⑨ 其它信息
3.查看进程
(1)/proc
进程的信息可以通过/proc系统文件夹查看。(无论是自己写的代码,编译称为可执行程序,启动后是一个进程,还是别人写的代码,例如ls、pwd、touch等,启动之后也是一个进程)
(2)当前路径
这里我们来理解一个概念:当前路径。
当前路径是进程当前的工作路径。
当前进程所在地路径,进程自己会维护。
在进程的cwd中会保存进程当前的工作路径,而exe就是该进程对应的执行程序的磁盘文件。
(3)ps axj
查看进程的第一种方式。
这里我们先写一个mytest程序,用来当作进程。
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process!\n");
sleep(1);
}
return 0;
}
运行该程序:
ps axj可以用来查看所有的进程,如果想要查看特定的进程就采用管道的方式:
ps axj | grep 'mytest'
这里我们看到由两个进程,一个是mytest,一个是grep,因此如果我们不想看到grep就:
ps axj | grep 'mytest' | grep -v grep
我们想要得到该进程的具体信息,可以使用head -1 ,而想要与此同时显示该进程就应该:
ps axj | head -1 && ps axj | grep 'mytest' | grep -v grep
这里不但显示了进程的信息,在上面又显示了每个信息的意思,PPID就是父进程的id,PID就是子进程的id。
(4)通过系统调用获取进程标识符
PID:进程id
PPID:父进程id
得到进程id的系统调用函数,getpid()
得到父进程id的系统调用函数,getppid()
改进我们的代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process! pid: %d, ppid : %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
运行后,得到:
这里我们就可以看到对应的进程id和父进程id,我们再通过ps axj函数来验证一下:
这里可以看到,进程id和父进程id与上面的完全相同。
我们重新运行一下:
这里会发现进程id变了,而父进程id却没有变化,进程id每次运行都会变化,很正常,那父进程id为什么不变呢?
父进程其实就是shell中的bash,几乎我们再命令行上所执行的所有的指令(cmd),都是bash进程的子进程。
(5)fork创建进程(系统调用)
fork()函数是用来创建子进程的,它有两个返回值。
父进程返回子进程的pid,给子进程返回0。
先创建一个mytest.c:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
printf("hello process! id: %d\n", id);
sleep(1);
return 0;
}
这里可以发现,我们运行了一次程序,但是运行了两次,并且两次的结果不同。
再修改一下代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
// id:0 子进程, >0: 父进程
if(id == 0)
{
// child
while(1)
{
printf("我是子进程,我的pid:%d,我的父进程是:%d\n", getpid(), getppid());
sleep(1);
}
}
else
{
// parent
while(1)
{
printf("我是父进程,我的pid:%d,我的父进程是:%d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
fork()之后,父进程和子进程的返回值不同,可以根据不同的返回值进行判断,让父子执行不同的代码块。
到这为止,我们应该会有很多的问题。
① printf为什么会打印两次呢?
fork()之后,父进程和子进程会共享代码,一般都会执行后续的代码
② fork()为什么给父进程返回子进程的pid,给子进程返回0呢?
在现实生活中,作为孩子一定只有父亲,而一个父亲可以有多个孩子,为了分清孩子,就给孩子起了不同的名字。
fork()也是如此,父进程必须有标识子进程的方案,fork之后,给父进程返回子进程的pid。
子进程最重要的是要知道自己被创建成功了,因为子进程找父进程的成本非常低getppid()。
③ 为什么fork会返回两次呢?
fork之后,系统多了个进程:
子进程:take_struct + 进程代码和数据
父进程:take_struct + 子进程的代码和数据
子进程的take_struct对象,内部的数据是从哪来的呢? 内部的数据基本是从父进程继承下来的。
子进程计算数据,执行的代码是从哪来的呢? 子进程是和父进程执行同样的代码,fork之后,父子进程代码共享。而不同的返回值,可以让不同的进程执行不同的代码。
调用一个函数,当这个函数准备return的时候,这个函数的核心功能就已经完成了。
1.子进程已经被创建了
2.将子进程放入了运行队列
这里我们就要来了解一下什么是运行队列:
运行队列是CPU,调度器(runqueue)和进程组成的,被放入运行队列的进程,都是可以随时被调用的进程。每一个进程中都含有代码和数据。
四.进程状态
1.OS的进程状态
① 运行态:进程只要在运行队列中就叫做运行态,代表该进程已经准备好了,随时可以被调度。
② 终止态:该进程还在,只不过永远不运行了,随时等待被释放。
那么既然进程都终止了,为什么不立马释放对应的资源,而要维护一个终止态呢?
原因:释放是要花时间的,而OS可能当时很忙,并没有时间去释放,因此维护一个终止态,等OS有时间了,再对不需要运行的程序进行终止。
③ 阻塞态:进程等待某种资源(非CPU),资源没有就绪的时候,进程需要在该资源的等待队列中进行排队,此时进程的代码并没有运行,进程所处的状态就叫做阻塞。
一个进程,使用资源的时候,可不仅仅是在申请CPU资源。
进程可能申请更多的其它资源:磁盘,网卡,显卡,显示器资源,声卡/音响。
如果我们申请CPU资源,暂时无法得到满足,就需要排队;而我们申请其它慢设备(输入输出设备)的资源时,也是需要排队的。
当进程访问某些资源(磁盘网卡),该资源如果暂时没有准备好,或者正在给其它进程提供服务时:1.当前进程要从runqueue中移除 2.将当前进程放入对应设备的描述结构体中的等待队列。
进程在等待外部资源的时候,该进程的代码不会被执行,就会造成进程阻塞。
④ 挂起态:内存不足时,OS会帮我们对短期内不会被调度的进程的代码和数据置换到磁盘上。
因为这些短期内不会被调度的进程,它的代码和数据如果依旧在内存中就会白白的浪费空间,所以OS通过把这些进程的代码和数据置换到磁盘(swap分区)上来减少空间的占用。
往往内存不足的时候,伴随着磁盘被高频率访问。
2.Linux的进程状态
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 */
};
① R是运行态
② S和D可能是阻塞态,也可能是挂起态。
S:浅度睡眠状态,可中断。正常情况下,大部分都是S状态。
D:深度睡眠状态,不可中断。如果等待的是磁盘资源,一般就是D状态。
为了防止等待写入磁盘的进程被OS所中断,导致磁盘资源丢失。
这里就显示着进程的状态。
③ T和t可能是挂起态。意思是暂停。
T:就代表暂停。
t:就是T的一种特殊情况,当我们对进程调试时,会出现t。
④ X:死亡状态
死亡状态,资源可以立马回收。
⑤ Z:僵尸状态
当一个Linux中的进程退出的时候,一般不会直接进入X状态,而是进入Z状态。
一个进程之所以被创建出来,一定是有任务让这个进程执行的,当该进程退出时,我们不会知道该任务完成如何。因此,为了知道这个结果,就有了Z状态,在Z状态的进程,要将执行结果告知给父进程或者OS。
Z状态,就是为了维护退出信息,让父进程或者OS读取的。
长时间在Z僵尸状态,会有一些问题:如果没有人回收子进程,该状态就会一直维护。该进程的相关资源(take_struct)不会被释放,就会导致内存泄漏。
模拟一个僵尸进程:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
int cnt = 5;
while(cnt > 0)
{
printf("我是子进程,我还剩下 %d s\n", cnt--);
sleep(1);
}
printf("我是子进程,我已经变成僵尸进程了,等待被检测\n");
exit(0);
}
else
{
// father
while(1)
{
sleep(1);
}
}
return 0;
}
说到僵尸进程,我们再来了解一个孤儿进程:
孤儿进程是父进程提前退出,子进程还在运行,这时子进程会被1号进程领养(即子进程的父进程变成1号进程)
再来模拟一下孤儿进程:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
while(1)
{
sleep(1);
}
}
else
{
// father
int cnt = 3;
while(cnt)
{
printf("我是父进程,我:%d\n", cnt--);
sleep(1);
}
exit(0);
}
return 0;
}
僵尸进程和孤儿进程的差异:
僵尸进程:子进程先于父进程退出,父进程没有对子进程的退出进行处理,因此子进程会保存自己的退出信息而无法释放所有资源成为僵尸进程导致资源泄露。
孤儿进程:父进程先于子进程退出,子进程成为孤儿进程,运行在后台,父进程成为1号进程(而孤儿进程的退出,会被1号进程负责任的进行处理,因此不会成为僵尸进程)。
五.进程优先级
1.基本概念
① CPU资源分配的先后顺序,就是指进程的优先权。
② 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
③ 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
2.什么是优先级
优先级就是进程获取资源的先后顺序。
3.为什么会存在优先级
排队的本质叫做优先级。而排队的原因就是资源不够。
系统里面永远都是:进程占大多数,而资源是少数。因此要确认先后。
4.优先级的操作
优先级就是由PRI(priority)和NI(nice)两个来决定的。
PRI:代表优先级
NI:代表进程优先级的修正数据
要更改进程优先级,需要更改的不少PRI,而是NI。
要想修改进程优先级可以通过top命令来修改,但是要用root权限,因此操作如下:
sudo top
进入top,输入r,然后再输入想要修改优先级的进程id,再输入想要修改的大小即可。
进程优先级初始值为80,Linux不允许进程无节制的设置优先级。
prio = prio_old + nice 每次设置优先级,这个prio_old优先级都会恢复成为80
nice区间[-20, 19]
priority区间[60, 99]
这里我们输入-100,会发现PRI变为60,NI变为-20。
原因就是PRI和NI是有区间范围的。
而我们再输入10,会发现PRI变为90,NI变为10。
这是因为每次设置优先级时,prio_old的优先级都会恢复为80,重新开始计算。
六.进程的其它概念
1.几个概念
(1)竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效的完成任务,更合理的竞争相关资源,便有了优先级。
(2)独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。
进程运行具有独立性,不会因为一个进程挂掉或者异常,而导致其它进程出现问题。
进程如何做到具有独立性的呢:进程地址空间。
(3)并行:多个进程在多个CPU下,同时进行运行,就称之为并行。
并行中的每个CPU都是并发的。
(4)并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,就称之为并发。
OS会给每一个进程,在一次调度周期中,赋予一个时间片的概念。在一个时间段内,多个进程都会通过切换交叉的方式,让多个进程代码,在一段时间内都得到推进,这种现象,就叫做并发。
2.进程抢占
抢占式内核。
OS并不是简单的根据队列来进行先后调度的。
一个正在运行的低优先级进程,如果来个优先级更高的进程,调度器会直接把这个低优先级的进程从CPU上剥离,放上优先级更高的进程,就是进程抢占。
3.调度队列
在队列中是允许不同优先级的进程存在的,而相同优先级的进程,也是可能存在多个的。
这个队列有两个take_struct* queue[]的指针数组,一个存放活跃的进程,一个存放过期的进程。
这个类似于哈希表的结构,根据不同的优先级,会将特定的进程放入不同队列不同数组下标中。如果有相同优先级的,会把这些相同优先级的放在一起。
过期了的进程会放在那个存放过期进程的数组中,如果活跃的进程变为过期进程,就会把这两个数组队列进行交换。(这个交换会有很多次,因为CPU是并发的,多个进程之间交叉运行,这个时间极短)。
4.进程间切换
CPU内的寄存器:可以临时的存储数据,存储的很少,但是非常重要。
寄存器分为:可见寄存器和不可见寄存器。
当进程在被执行的过程中,一定会存在大量的临时数据,会暂存在CPU内的寄存器中。
保存的目的是为了恢复。
进程在运行中产生的各种寄存器数据,叫做进程的硬件上下文数据。
当进程被剥离,需要保存上下文数据。
当进程恢复的时候,需要将曾经保存的上下文数据恢复到寄存器中。
上下文保存的位置:take_struct
一个寄存器中会保存多份数据。
七.环境变量
1.概念
① 环境变量一般是指在OS中用来指定系统运行环境的一些参数。
② 编写C/C++代码,在链接的时候,我们从来都不知道我们所链接的动静态库在哪,但是一样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
③ 环境变更了通常具有某些特殊用途,还有在系统当中通常具有全局特性。
为什么我们的代码运行要带路径呢,而系统的指令不用带路径呢?
因为系统中时存在相关的环境变量,保存了程序的搜索路径。
系统中搜索可执行程序的环境变量叫做PATH。
正常情况下,我们不能直接调用我们的程序,如果调用就会如图所示。
而带路径时,就可以正常运行:
当我们把这个进程放入/usr/bin中,就可以直接调用了:
当我们再删掉/usr/bin中的进程后,直接调用又会出错了:
2.查看环境变量
(1)env
env可以查看环境变量。
因此可以通过env | grep PATH,来查看对应的环境变量和位置。
(2)echo
除了env,通过echo $的方式也可以查看该环境变量的位置。
3.创建环境变量
(1)export
我们也可以直接定义变量,定义aaaa=123就只是一个普通变量,这时echo $就只会显示该变量,而不是位置, 并且通过env也不会显示任何值(env只能看环境变量)。
而通过export的普通变量就会变成环境变量,可以通过env后显示出来。
这里我们将PATH的位置变为我们当前的pwd,就会发现ls、pwd、mkdir等这些命令都无法使用了,这就是因为,OS在查看时会去那几个特定的位置查找,而不会去其它位置查找。
当我们修改后,只需要退出后重进,环境变量就会刷新到原来那样。
(2)set
set可以显示本地定义的shell变量和环境变量
(3)unset
unset可以清除环境变量。
通过export设置的环境变量aaaa,在经过unset后就会变成普通变量。
4.常见环境变量
(1)HOSTNAME:查看主机名
(2)SHELL:查看shell位置
(3) HISTSIZE:查看所能存储的最大指令个数
(4)USER:查看当前用户名
(5)PWD:查看当前路径
(6)PATH:查看path的路径
(7)HOME:查看用户的主工作目录
5.总结一下和环境变量相关的命令
① echo:显示某个环境变量值
② export:设置一个新的环境变量
③ env:显示所有环境变量
④ unset:清除环境变量
⑤ set:显示本地定义的shell变量和环境变量
6.通过代码获取环境变量
为什么在Linux系统中,会根据不同的选项,不同的命令参数,让相同的指令有不同的表现呢?
这就与main中的参数有关了。
main函数中是可以有三个参数的:
① int agrc:agrc是传递的参数的数量
② char* argv[]:这个指针数组存放传递的不同参数
③ char* env[]:存放环境变量
我们平常如果括号内什么都不写当然是可以的,传参就接收,而如果写入void,那么就一定无法接受传参。如果我们确定不传参时,可以写成int main(void)。
首先看前两个参数:
#include <stdio.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
for(int i = 0; i < argc; ++i)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
如上图方式进行调用,该进程就会按照传递过来的参数进行打印,可以让我们清楚的看到。
main允许传递参数就可以让同一个程序,通过传递不同的参数,让同一个程序有不同的执行逻辑,因此就有了不同的执行结果。
我们给main函数传递的是argc,char* argv[],命令行参数传递的是 命令行中输入的程序名和选项。
我们来模拟实现一个计算器:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main(int argc, char* argv[], char* env[])
{
if(argc != 4)
{
printf("Usage: %s [-a|-s|-m|-d] one_data two_data\n", argv[0]);
return 0;
}
int x = atoi(argv[2]);
int y = atoi(argv[3]);
// add: 加法
if(strcmp("-a", argv[1]) == 0)
{
printf("%d+%d=%d\n", x, y, x + y);
}
// 减法
else if(strcmp("-s", argv[1]) == 0)
{
printf("%d-%d=%d\n", x, y, x - y);
}
// 乘法
else if(strcmp("-m", argv[1]) == 0)
{
printf("%d*%d=%d\n", x, y, x * y);
}
// 除法
else if(strcmp("-d", argv[1]) == 0 && y != 0)
{
printf("%d/%d=%d\n", x, y, x / y);
}
else
{
printf("Usage: %s [-a|-s|-m|-d] one_data two_data\n", argv[0]);
}
return 0;
}
这就通过前两个参数,实现了一个简单的计算器。
再来看第3个参数:
获取环境变量的第一种方法:
char* env[]:
environ也是这样的:
第三个参数就是用来接收环境变量的,每个程序都会收到一张环境表,环境表就是一个字符指针数组,每个指针指向一个以'\0'结尾的环境字符串。
一个进程是会被传入环境变量参数的。
#include <stdio.h>
#include <unistd.h>
int main(int argc, char* argv[], char* env[])
{
for(i = 0; env[i]; i++)
{
printf("env[%d]: %s\n", i, env[i]);
}
return 0;
}
运行后,打印出来的都是环境变量。
获取环境变量的第二种方法:
environ:environ是C语言提供的全局变量
与上面的char* env()几乎是一样的。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
extern char **environ;
for(int i = 0; environ[i]; i++)
{
printf("%d: %s\n", i, environ[i]);
}
return 0;
}
我们还可以利用第三个参数,实现很多分类的操作:
获取环境变量的第三种方法:
getenv:是用来获取环境变量的(通过系统调用获取环境变量)
#include <stdio.h>
#include <unistd.h>
#include <string>
int main(int argc, char* argv[], char* env[])
{
char* id = getenv("USER");
if(strcmp(id, "hb") != 0)
{
printf("权限拒绝!\n");
return 0;
}
printf("成功执行...\n");
return 0;
}
当我用这个用户运行时:
而当我切换到root用户:
这就实现同一个代码,对不同的用户的给予的权限不同。
include <stdio.h>
#include <unistd.h>
#include <string>
int main(int argc, char* argv[], char* env[])
{
char* val = getenv("PATH");
printf("%s\n", val);
return 0;
}
7.环境变量具有全局属性
(1)bash
在命令行中启动的进程,父进程全都是bash
这里,通过getppid,得到父进程,然后kill -9杀掉这个父进程(bash)
这里就发现我的云服务器直接close了 。bash进程一旦被kill,就无法继续进行了。
(2)全局属性
环境变量是具有全局属性的。
所谓的本地变量,本质就是在bash内部定义的变量,不会被子进程继承下去。
而环境变量是会被子进程继承下去的。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
while(1)
{
printf("hello myproc!, pid: %d, ppid: %d,myenv=%s\n", getpid(), getppid(),getenv("qx));
sleep(1);
}
return 0;
}
先设置qx为普通变量:
然后运行发现:
myenv是null
再设置为环境变量:
再次运行:
这时就可以看到环境变量的值了。
(3)内建命令
Linux下大部分命令都是通过子进程的方式执行的,但是,还有一部分命令吗,不通过子进程的方式执行,而是由bash自己执行(调用自己对应的函数来完成特定的功能),把这种命令叫做内建命令。例如export,cd。
八.进程(程序)地址空间
1.空间布局:
接下来我们来验证一下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int un_g_val;
int g_val=100;
int main(int argc, char* argv[], char* env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr: %p\n", &un_g_val);
char* m1 = (char*)malloc(100);
char* m2 = (char*)malloc(100);
char* m3 = (char*)malloc(100);
char* m4 = (char*)malloc(100);
printf("heap addr : %p\n", m1);
printf("stack addr : %p\n", &m1);
int i = 0;
for(i = 0; i < argc; ++i)
{
printf("argv addr : %p\n", argv[i]);
}
for (i = 0; env[i]; ++i)
{
printf("env addr : %p\n", env[i]);
}
while(1)
{
printf("hello myproc!, pid: %d, ppid: %d, myenv=%s\n", getpid(), getppid (), getenv("qx"));
sleep(1);
}
return 0;
}
根据结果可以发现,与上面的图的结构是相同的,并且栈和堆之间的空间非常大。
再来验证一下堆和栈的增长方向:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int un_g_val;
int g_val=100;
int main(int argc, char* argv[], char* env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr: %p\n", &un_g_val);
char* m1 = (char*)malloc(100);
char* m2 = (char*)malloc(100);
char* m3 = (char*)malloc(100);
char* m4 = (char*)malloc(100);
printf("heap addr : %p\n", m1);
printf("heap addr : %p\n", m2);
printf("heap addr : %p\n", m3);
printf("heap addr : %p\n", m4);
printf("stack addr : %p\n", &m1);
printf("stack addr : %p\n", &m2);
printf("stack addr : %p\n", &m3);
printf("stack addr : %p\n", &m4);
int i = 0;
for(i = 0; i < argc; ++i)
{
printf("argv addr : %p\n", argv[i]);
}
for (i = 0; env[i]; ++i)
{
printf("env addr : %p\n", env[i]);
}
while(1)
{
printf("hello myproc!, pid: %d, ppid: %d, myenv=%s\n", getpid(), getppid (), getenv("qx"));
sleep(1);
}
return 0;
}
根据这两个的结果图,也可以很明显的看出堆是向上增长的,而栈是向下增长的。
然后我们再来看看static成员在哪:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int un_g_val;
int g_val=100;
int main(int argc, char* argv[], char* env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr: %p\n", &un_g_val);
char* m1 = (char*)malloc(100);
char* m2 = (char*)malloc(100);
char* m3 = (char*)malloc(100);
char* m4 = (char*)malloc(100);
static int s = 100;
printf("heap addr : %p\n", m1);
printf("heap addr : %p\n", m2);
printf("heap addr : %p\n", m3);
printf("heap addr : %p\n", m4);
printf("stack addr : %p\n", &m1);
printf("stack addr : %p\n", &m2);
printf("stack addr : %p\n", &m3);
printf("stack addr : %p\n", &m4);
printf("s stack addr : %p\n", &s);
int i = 0;
for(i = 0; i < argc; ++i)
{
printf("argv addr : %p\n", argv[i]);
}
for (i = 0; env[i]; ++i)
{
printf("env addr : %p\n", env[i]);
}
while(1)
{
printf("hello myproc!, pid: %d, ppid: %d, myenv=%s\n", getpid(), getppid (), getenv("qx"));
sleep(1);
}
return 0;
}
这里我们可以看到,加了static的c变量并不在所应该在的栈区,而是在全局数据区,这更加说明了static修饰的成员是全局变量。
2.进程地址空间的存在
我们看这样的两个代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val=100;
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
while(1)
{
printf("我是子进程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
else
{
// parent
while(1)
{
printf("我是父进程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(2);
}
}
return 0;
}
这个进程的运行结果中,子进程和父进程的全局变量是相同的,并且地址也相同。
即当父子进程没有人修改全局数据的时候,父子是共享该数据的。
下面我们让子进程对全局变量进行修改:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val=100;
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
int flag = 0;
while(1)
{
printf("我是子进程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
++flag;
if(flag == 3)
{
g_val = 200;
printf("子进程已修改全局数据!\n");
}
}
}
else
{
// parent
while(1)
{
printf("我是父进程: %d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val);
sleep(2);
}
}
return 0;
}
这时我们在看结果会发现,在子进程改完后,子进程和父进程的全局变量不同了,但是我们还会发现子进程和父进程的全局变量的地址相同。
这是因为我们所看到的地址都是虚拟地址(也叫线性地址或逻辑地址),并不是真正的物理地址。
那么为什么OS不让我们直接看到物理内存呢?
因为内存就是一个硬件,不能阻拦人的访问,只能被动的进行读取和写入。为了防止内存被破坏,就有了虚拟地址的出现。
而虚拟地址就是根据进程地址空间而出现的。
3.什么是进程地址空间
(1)介绍进程地址空间
每一个进程在启动的时候,都会让OS给它创建一个地址空间,该地址空间就是进程地址空间。
每一个进程都会有一个自己的进程地址空间。
OS管理这些进程地址空间的方法:先描述,再组织。
进程地址空间,其实是内核的一个数据结构:mm_struct
(2)概念
进程地址空间是OS通过软件的方式,给进程提供一个软件视角,认为自己会独占系统的所有资源(内存)。
就是因为有了进程地址空间,才有了独立性的概念。
独立性:多进程运行期间互不干扰。
因为每一个进程都认为自己是独占系统中的所有资源的。
(3)模型
一个进程take_struct要先经过进程地址空间mm_struct,然后经过页表,页表再根据虚拟地址映射出物理地址,然后根据物理地址存入到对应的物理内存中。
(4)区域
mm_struct是根据区域划分的。每个区域范围都是有对应的编号的。
struct mm_struct
{
long code _start;
long code _end;
long init _start;
long init _end;
long uninit _start;
long uninit _end;
long heap _start;
long heap _end;
long stack _start;
long stack _end;
}
每个start和end之间就是一个区域,每个区域之间存放不同的进程。
编译程序的时候,就认为程序是按照0000~FFFF进行编址的。
虚拟地址空间,不仅仅是OS会考虑,编译器也是会考虑的。
4.程序如何变成进程
① 程序被编译出来,没有被加载的时候,程序内部,有地址吗? 有!
② 程序被编译出来,没有被加载的时候,程序内部,有区域吗? 有!
可执行程序在编译好之前就有自己的地址(虚拟地址),可执行程序在内存中是有区域的,所有的区域都在磁盘中已经划分好了,所谓的加载到内存就是根据区域把它加载到内存。
加载完之后,程序到内存里了,OS会给每一个进程创建一个PCB(take_struct),然后take_struct指向地址空间(mm_struct),它再经过页表映射到物理内存(因为程序本身有自己的虚拟地址,直接就可以映射出对应的物理地址)【当它读取代码中的数据时,因为这个代码中的地址已经被转换成虚拟地址,所以CPU得到的全部都是虚拟地址,然后在进行选址的时候,自动的会做页表转换,找到对应的物理地址】,然后程序开始读取代码中的数据。
程序内部的地址和内存的地址是没有关系的。
我们再来理解一个生活中的例子:
上学的时候,我们每个人都有学号(原来就有虚拟地址),假设我们班级的桌子也有序号(物理地址),那么当老师提问的时候就会根据学号,而不是桌子的序号,但是这个桌子的序号也相当于我们的一个标识。
这里的学号就是我们的虚拟地址,而桌子的序号就是物理地址。老师不会管我们的桌子的序号是什么(就相当于OS不会管物理地址),而知道了学号,就知道了桌子的序号(相当于知道了虚拟地址,就可以映射出物理地址)。
5. 解答疑问
学到这,我们再来思考上面子进程和父进程全局变量地址相同,但是值却不同的问题。
子进程通过页表找到物理内存,要进行修改时,会发生写时拷贝(因为进程具有独立性),在修改子进程之前,会在物理内存上再开一个空间,然后修改该地址上的值。
这里子进程时新开了一个物理地址,但是我们看不见物理地址,而虚拟地址并没有改变,因此表面上看子进程和父进程的地址是相同的(虚拟地址相同),但是实际上子进程和父进程的物理地址已经不同了( 在最开始子进程和父进程的虚拟地址和物理地址都是相同的,但是修改后,虽然虚拟地址相同,但是子进程和父进程经过映射后的物理地址就不同了)。所以我们会看到地址相同,值却不同,
这里还有一个问题: fork有两个返回值,返回的值给pid_t id,而这同一个变量,却会接收到两个值(子进程为0,父进程为子进程的id),这是为什么呢?
pid_t id是属于父进程栈空间中定义的变量,在fork内部,return会被执行两次,return的本质就是通过寄存器将返回的值写入到接受返回值的变量中。
当id = fork()的时候,谁先返回,谁就要发生写时拷贝,所以同一个变量会有不同的值。本质就是因为子进程和父进程的虚拟地址相同,但是子进程和父进程对应的物理地址是不一样的。
6.进程地址空间的作用
为什么要有进程地址空间呢?
访问内存添加了一层软硬件层,可以对转化过程进行审核,对非法的访问可以直接拦截。
进程地址空间作用:
① 保护内存
② 进行功能模块的解耦
我们先了解一下Linux的内存管理:
我们想malloc个空间,但是并不知道什么时候用,于是地址空间就会把空间申请上,但是并不会在内存上申请空间(不通过页表,在页表之前),因为OS认为如果提前把物理空间开辟好了,现在却不用,就会浪费内存。等到需要用了,再申请内存,建立好映射关系。
就可以提高OS的效率 。
这样,Linux只要有进程地址空间的存在,它就可以把对进程的调度执行代码(Linux的进程管理)和LInux的内存管理通过页表就彻底进行了解耦
③ 让进程或程序可以以一种统一的视角看待内存。
每个代码都可以认为自己拥有所有的地址,都可以从同一个位置开始。这样就方便以统一的方式来编译和加载所有的可执行程序。
可以简化进程本身的设计与实现。
如果没有进程地址空间:
① 用户可能因为一个野指针就导致内存被修改,没有任何的保护。
② 想malloc一个空间,那么必须立刻调度malloc的底层代码,在物理内存上真正给malloc空间开辟出来(这时正在调度进程,却要过去执行内存管理的代码),这就没有解耦的功能。
③ 每次加载的物理地址都是不同的,每次看到的地址都会发生变化,这样无论是CPU调度还是OS管理都会增加成本。