【Linux】冯诺依曼体系结构、操作系统、进程概念、进程状态、环境变量、进程地址空间
目录
一、冯诺依曼体系结构
冯诺依曼思想包括:存储程序、程序控制和计算机的五大功能部件。
存储程序:将程序存放在计算机的存储器中。
程序控制: 按指令地址访问存储器并取出指令,经译码依次产生指令执行所需的控制信号,实现对计算的控制,完成指令的功能。
五大功能部件:控制器、运算器、存储器、输入设备、输出设备。
其中,运算器完成算术运算、逻辑运算,
控制器控制指令的执行, 根据指令功能给出实现指令功能所需的控制信号;
控制器和运算器构成了中央处理器CPU。
存储器就是内存,存放程序和数据,带电存储,具有掉电易失的特性。
输入设备能够输入操作者提供的原始信息,并将其转化为机器能识别的,如键盘、鼠标等。
输出设备将计算机处理的结果用人们或其他机器能够接受的方式输出,如显示屏。
- 在不考虑缓存的情况下,CPU只能对内存进行读写,不能直接访问其他设备。
- 外设(输入设备或输出设备)想要输入或输出数据,也只能写入内存或从内存中读取。
二、操作系统(OS)
1. 操作系统是什么
操作系统是一个进行软硬件资源管理的软件。
操作系统包括:内核(进程管理、内存管理、文件管理、驱动管理)、其他程序(如函数库,shell程序等等)
操作系统为什么对软硬件资源进行管理呢? 操作系统通过管理好软硬件资源(手段),给用户提供良好(安全、稳定、高效、功能丰富)的执行环境(目的)。
2. 操作系统如何做管理
先描述,在组织。将需要管理的对象用结构体描述,再将每个结构体进行连接,形成链表一样的数据结构。
当操作系统下达命令后,驱动程序就会对这些结构进行增删查改等操作。
计算机的层状结构:
3. 系统调用和库函数概念
- 系统调用:在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。在执行一段程序时,比如printf(“hello world”)时,实际上进行了系统调用,但我们并不知道,因为编译器帮你做了。
- 库函数:系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
三、进程
1. 进程是什么?
进程是程序的一个执行实例,是正在执行的程序。
以上是书本中的概念,一句话来概括:进程=内核描述进程的数据结构+当前进程的代码和数据。
当我们写好一段代码经过编译、链接等过程后生成了可执行程序,此时的可执行程序是一个文件,存储在磁盘中。当我们运行该程序时,该程序的代码和数据就会被加载到内存中。此时操作系统会将进程的各种属性放在一个叫做PCB的结构体中,并将PCB用链表等数据结构管理起来,方便进行增删查改。
2. 描述进程-PCB
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是:task_struct。
Linux中描述进程的结构体叫做task_struct,它会被装载到RAM(内存)里并且包含着进程的信息。
task_struct内容分类
- 标示符:描述本进程的唯一标示符,用来区别其他进程。
- 状态:任务状态,退出代码,退出信号等。
- 优先级:相对于其他进程的优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息。
3. 查看进程的方法
进程的信息可以通过/proc系统文件夹查看
- 要获取PID为1的进程信息,需要查看/proc/1这个文件夹。
- 大多数进程信息同样可以使用top和ps这些用户级工具来获取。
当我们执行上述程序,便可以通过以下指令查询到该进程的信息。
通过系统调用查看进程PID - 进程id:PID,通过getpid()获取
- 父进程id:PPID,通过getppid()获取
当我们多次执行程序,能够发现每次启动时进程id都不同,但父进程id都相同
这是因为这些进程有共同的父进程bash——命令行解释器。
通过系统调用创建进程
创建进程需要用到fork,先通过man手册来了解一下fork。
fork能够创建子进程,在父进程中,返回值为子进程id,在子进程中返回值为0。
显然,fork调用后的内容打印了两边,原因是fork()创建了子进程,父子进程都打印了fork后。
我们还可以通过搭配if-else来进行分流,利用fork的返回值进行区分
fork之后执行流会变成两个执行流,当有一个执行流尝试修改数据时,操作系统会为该进程将代码和数据拷贝一份,再进行修改,我们称之为写时拷贝。
四、进程状态
1 运行、阻塞和挂起状态
程序在运行时,需要CPU读取程序的数据并进行计算,但进程的数量一般会多于CPU,所以操作系统采用了运行队列来对进程进行管理。进程入队列,等待CPU资源。CPU调度进程就是从运行队列中,找到进程PCB,并执行进程对应的代码和数据。
进程的运行状态并不是指进程正在运行,而是指这个进程的PCB在CPU的运行队列中。
进程还会占用外设资源,但外设的运行速度很慢,也会出现多个进程访问一个硬件的情况,所以这里进程也需要排队。当CPU调度的某个进程需要访问外设时,操作系统就会把这个进程放到硬件的等待队列中,直到硬件准备就绪,此时这个进程的状态就是阻塞状态。
当多个进程状态都是阻塞时,这些进程无法被CPU立即在执行,需要排队很长时间,进程的代码和数据始终占用着内存,操作系统为了避免这样的浪费,会将这些进程的代码和数据暂时保存在磁盘上,但保留进程的PCB在内存里,这样的进程,称之为挂起进程。等到进程需要的硬件资源准备就绪后,操作系统会将进程的代码和数据唤回内存。
2 Linux中的进程状态
运行状态:并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列
里。
只要执行一个死循环程序,即可观察到运行状态。
休眠状态:当我们像上述代码中添加一个printf语句,观察现象。
观察到此时状态变成了S,这就是休眠状态。
因为我们的代码访问了显示器,显示器是外设,速度很慢,99%的时间都是进程等待显示器就绪,1%的时间是CPU在运行,所以查看到进程是休眠状态,是阻塞状态的一种。
停止状态:当进程被停止时,也是阻塞状态的一种。
kill -19 + 进程id --- 停止运行进程
kill -18 + 进程id --- 继续运行进程
可以看见当我们输入kill -19后,进程的状态从R+变成了T,也就是停止了。
这里的+是用来标志这个进程是前台还是后台进程,状态后面带+,表示该进程在前台,不带+则是后台进程。
后台进程在运行时,shell命令行可以使用,但无法使用ctrl+C将后台进程终止,需要使用kill -9来杀掉进程。
磁盘休眠状态:有时候也叫不可中断睡眠状态,在这个状态的进程通常会等待IO的结束。
当阻塞进程太多,内存空间不足,操作系统会将一些进程挂起,但如果依旧无法解决问题,OS就会将进程杀死,但杀死进程可能会导致数据的丢失,造成巨大损失,所以就提供了磁盘休眠状态,用D来表示,这样的进程无法被OS杀掉,只能等IO结束进程自己醒来。但这种状态一般情况下不会出现,只有在高IO的情况下才可能会出现。
跟踪状态:当我们调试某个可执行程序时,如果打了断点,当进程运行到断点就会停下来,等待我们的下一步操作,此时查看进程的状态就是跟踪状态,用小写字母 t 表示。
僵死状态:
一个进程在完成任务之后,其父进程或操作系统需要知道该任务完成的结果,所以当进程终止时,OS的机制是不立即释放该进程的内存资源的,要保存到其父进程来读取进程的结果。
但如果某个进程退出后,没有被父进程或OS回收,这样的进程就是僵尸进程。
下面用一段代码演示僵尸进程的产生,首先创建一个子进程,让子进程在父进程之前退出,此时子进程就会处于僵死状态。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
printf("我是子进程,pid:%d, ppid:%d\n", getpid(), getppid());
sleep(5);
exit(0);
}
else if (id > 0)
{
int count = 10;
while (count--)
{
printf("我是父进程,pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
可见当程序执行五秒之后子进程退出但父进程不回收,子进程的状态变为Z+僵死状态。
当父进程运行结束,操作系统会将父进程和子进程一起回收,避免内存泄漏。
进程的退出状态也属于进程的基本信息,会被保存在进程PCB中,也就是说如果父进程一直不读取子进程的退出状态,那么PCB就要一直维护这种状态,这是就会产生内存泄露的问题。
僵尸进程无法被杀死,因为它已经死了。
死亡状态:当进程死亡后,操作系统会很快的回收进程的所有的资源和数据,以至于我们无法观察到X状态。它的PCB和对应的代码和数据都被释放,不再占用内存资源。
孤儿进程:如果一个程序运行时,父进程先退出,那么子进程就变成了孤儿进程,此时1号init进程就会回收它。
当我们用刚才的程序,提前杀死父进程,可以看到子进程的PPID变成了1,并且该进程变成了后台进程。
作为操作系统是必须要领养这个孤儿进程的,为了防止内存泄漏!
五、进程优先级
1. 什么是优先级
cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
2.查看优先级
通过ps -l指令可以查看进程的信息,其中PRI和NI组合起来用来表示进程的优先级。
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。
那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。
PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为: PRI(new)=PRI(old)+nice。
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。
所以,调整进程优先级,在Linux下,就是调整进程nice值。
nice其取值范围是-20至19,一共40个级别。
3. 修改优先级
想要修改优先级,需要使用top指令,并且需要管理员身份,这个指令用来修改nice值,范围是-20到19,如果输入范围以外的数,也不会获得更大或者更小的数值,因为OS不会让我们过度修改nice值。
方法:进入top后按“r”–>输入进程PID–>输入nice值
六、其他概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行。
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
七、环境变量
1.引入
我们平时使用的Linux指令其实是可执行程序,但我们自己创建的可执行程序在运行时需要加上./,而系统提供的指令不需要,这是为什么呢?
要执行一个程序需要先找到它,这也是为什么我们要在前面加上./,指的是当前目录,而系统指令的位置在/usr/bin目录下,想要让我们创建的可执行程序不用加上./可以将其拷贝到/usr/bin目录下,但不建议这样做,因为自己创建的程序未经过严格的测试,会污染系统的指令池。
那为什么在/usr/bin目录下系统就能够找到呢?
因为系统中存在环境变量PATH,操作系统在启动时会定义一个PATH变量,可以利用echo $PATH
指令进行查看。
在执行系统指令时,操作系统会默认在PATH环境变量里面的路径查找我们输入的指令,所以想要不带./运行自己的程序可以将程序所在路径添加进环境变量PATH中。
我们可以随意的修改PATH,因为只要重新登陆,PATH就会恢复。
使用export指令来修改环境变量。
export PATH=路径 这种操作会覆盖原有的内容不建议使用
export PATH=$PATH:路径 建议使用
当修改成功之后,就可以不加./执行自己的程序了。
不过重新登陆后环境变量就会恢复到默认,所以不加./执行自己的程序也只限定在本次登录。
2.环境变量和本地变量的关系
父进程的本地变量不会被子进程继承,但环境变量会被继承下去。
#include <stdio.h>
#include <stdlib.h>
#define MY_ENV "myval"
int main()
{
char* myenv = getenv(MY_ENV);
if (myenv == NULL)
{
printf("%s:not found\n", MY_ENV);
return 1;
}
printf("%s=%s\n", MY_ENV, myenv);
return 0;
}
使用set指令可以显示所有本地变量,使用env指令可以显示所有环境变量。
取消本地变量:unset+变量名
在定义环境变量时可以带双引号也可以不带,但如果环境变量带有空格就要加上双引号了。
3.命令行参数表和环境变量表
在main函数中有几个隐藏的参数,平时在做OJ题的时候有可能会注意到。
int main(int argc, char* argv[])
不过我们平时并不使用这些参数,但在系统编程中这些参数比较常用。
int main(int argc, char* argv[])
{
int i = 0;
for (; i < argc; ++i)
{
printf("argv[%d]->%s\n", i, argv[i]);
}
return 0;
}
通过上面的例子可以看出,main函数中的第一个参数argc是命令行中运行程序的时候以空格为分隔符字符串的个数,而argv指针数组中的指针,指向的就是这些字符串。
系统提供的各种指令能够通过带-a、-l等实现不同的功能就是这样实现的
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
void Usage(const char* name)
{
printf("\nUsage: %s -[a|b|c]\n\n", name);
exit(0);
}
int main(int argc, char* argv[])
{
if (argc != 2) Usage(argv[0]);
if (strcmp(argv[1], "-a")) printf("打印当前目录下的文件名\n");
else if (strcmp(argv[1], "-b")) printf("打印当前目录下文件的详细信息\n");
else if (strcmp(argv[1], "-c")) printf("打印当前目录下的文件名(包含隐藏文件)\n");
else printf("更多功能待开发\n");
return 0;
}
利用这两个参数可以实现一个如上所示的进程。
4.子进程获取环境变量的方式
在前面我们知道了可以通过getenv()系统调用可获得环境变量的值。
下面介绍的一种方式,也是通过main函数的参数:char* env[]
int main(int argc, char* argv[], char* env[])
{
int i = 0;
for (; env[i]; ++i)
{
printf("env[%d]->%s\n", i, env[i]);
}
return 0;
}
通过上面这段代码我们打印出了所有的环境变量。
C语言提供了一个第三方的指针变量environ,在调用main函数时,系统就把这个变量传给了main,environ就相当于指针数组env[]的另一个数组名,environ指向的就是env的第一个元素。environ是一个二级指针,下面用environ打印环境变量
int main()
{
extern char** environ;
int i = 0;
for (; environ[i]; ++i)
{
printf("environ[%d]->%s\n", i, environ[i]);
}
return 0;
}
八、进程地址空间
1.虚拟地址空间的引入
#include <stdio.h>
#include <unistd.h>
int global_val = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 0;
while (1)
{
printf("我是子进程, pid:%d, ppid:%d, global_val:%d, &global_val:%p\n", getpid(), getppid(), global_val, &global_val);
sleep(1);
++cnt;
if (cnt == 3)
{
global_val = 300;
printf("子进程已经更改了global_val的值了!\n");
}
}
}
while (1)
{
printf("我是父进程, pid:%d, ppid:%d, global_val:%d, &global_val:%p\n", getpid(), getppid(), global_val, &global_val);
sleep(1);
}
return 0;
}
从上面程序的结果来看,一个全局变量在地址并未改变的情况下,竟然出现了不同的值,这是为什么呢?首先一个变量肯定是只能有一个值的,但地址只有一个,这就说明现在内存中应该出现了两个变量。而一个变量在内存中只能有一个地址,那么我们打印出来的一定不是真实的地址,,也就是说现在的内存中有两个全局变量,分别属于父子进程,并且他们都拥有自己的物理内存地址,只是他们共用了一个虚拟地址。之前在学习C和C++时所看到的其实都是虚拟地址,真正的物理地址,用户看不到,统一由操作系统保管。
2.虚拟地址空间布局
代码段:存放函数体的二进制代码,代码段是只读的,可以防止其他进程恶意修改正在运行的进程的二进制指令,程序的执行就是从代码段中的main函数开始执行,程序运行结束后由操作系统回收。
初始化数据段:用于存储初始化的全局变量、static变量、对象、字符串、数组等常量。但基本类型常量不在这里,它们在代码段。
未初始化数据段:包含所有未初始化的全局变量和static变量,此段中所有变量由0或NULL初始化。
堆区:堆区时向上增长的,程序运行时动态申请的内存空间就是在堆上开辟的,由开发人员手动申请释放,。
映射段:也称共享区,存储动态链接库、以及共享文件等。
栈区:栈时向下增长的,函数的局部变量、返回值、形参等都在栈区上,函数调用时的栈帧就在栈上。
mm_struct就是操作系统描述虚拟空间所构造的结构体。
3.为什么要存在虚拟地址空间?
虚拟内存是计算机系统内存管理的一种技术,它让进程认为它拥有连续可用的内存,实际上它通常被分割成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,需要时才进行数据交换。
进程在和外设IO的过程,所占内存大小为4KB,即4096个连续的物理地址,这部分区域被称为页。
进程无法直接接触物理内存,只能通过虚拟地址依靠页表映射物理地址的方式,来间接访问物理内存。因为页表的存在,进程在访问不属于当前进程的地址时,页表就会拦截进程非法访问地址的请求。
如上面的例子,当子进程修改global_val时,操作系统会先拷贝这个变量,更改页表映射,最后让子进程对数据进行修改,这样的技术被称为写时拷贝。这样保证了进程的独立性,通过虚拟地址空间和页表,让不同的进程使用同一个虚拟地址,能够映射到不同的物理内存。