目录
1.冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
输入设备\单元:键盘、话筒、摄像头、磁盘、网卡等
输出设备\单元:显示器、音响、磁盘、网卡、显卡等
存储器:存储器就是内存
中央处理器(cpu)(运算器+控制器):运算器主要承担的是计算的功能,计算可以分为算数计算和逻辑计算。控制器主要承担的是cpu与其他设备交互协调的功能,例如输入设备将数据输入存储在内存中,cpu就可以读取数据了,cpu如何知道何时可以开始读取数据呢,虽然cpu不和外设(输入输出设备等)在数据上有交互(后面详细介绍),但并不代表二者之间不交互,输入设备将数据输入完后和控制器进行交互,cpu就可以开始从内存中读取数据了。
注:中央处理器cpu只是一个具有运算能力和控制能力的提线木偶,真正让中央处理器去完成某些计算和控制的是整个计算机的大脑——软件,其中最具有代表性的就是操作系统。
几乎所有的硬件只能被动的完成某种功能,而不能主动的完成某种功能,一般都是要配合软件完成的,最经典的就是操作系统和cpu的配合。
问题:为什么不能输入设备传输数据给cpu,cpu计算完传给输出设备?为什么要有内存?
从技术角度:
cpu的运算速度>寄存器的存取速度>高速缓存L1-L3Cache>内存>外设(磁盘)>光盘、磁带。输入设备和输出设备速度很慢而cpu速度非常快,根据木桶原理,整个系统的速度由速度最慢的设备决定,即由输入设备和输出设备速度决定,那么系统的运算速度将会非常慢。存储器的价值在于输入设备在输入数据时先将数据存储在存储器中,存储器中的数据再被cpu读取,cpu得到运算结果后将结果写回存储器中,存储器即内存将运算结果定期刷新到输出设备中。从数据角度外设(输入输出设备等)几乎不和cpu打交道,直接和内存打交道,cpu也同样仅和内存打交道。
内存在我们看来就是体系结构的一个大的缓存,因为输入输出设备和cpu都只和内存之间进行交互,因此系统的性能就以内存的速度为衡量标准而不再以输入输出设备的速度为衡量标准,这样就适配了外设和cpu速度不均衡的特点。因为有存储器的存在,存储器具有一定数据暂存的能力,这就让软件有了更大的生存空间和价值。
从成本角度:
寄存器的成本>内存成本>磁盘(外设)成本。内存的意义在于使用较低的成本,能够获得较高的性能。
问题:为什么我们自己写的软件,编译好之后要运行,必须先加载到内存中?
答:cpu只和内存打交道,而我们的代码软件进行编译,编译后的.exe等文件是在磁盘上的,因为cpu只在内存中读取指令或数据,这就要求如果要运行程序,就必须将程序先加载到内存中,换句话说这是体系结构规定的。
补充:事实上在启动程序还没有运行程序的时候,所用数据就已经被预加载到了内存中,这个过程就是外设数据导入内存的过程,在该过程中cpu可能在进行其他计算,这样就可以把数据加载的过程和cpu计算过程并行起来。很多数据是由操作系统自动提前加载到内存中的,例如开机的过程要等一段时间,其本质上就是将操作系统预加载到内存中,当你刚刚open打开某个文件时,文件中的数据可能早已经被操作系统预加载到内存中了,这就是操作系统预加载的特点。什么数据要被操作系统预加载呢?操作系统预加载的理论依据是什么?操作系统预加载的理论依据是局部性原理,局部性原理就是将要加载使用数据的周边数据提前加载到内存中。
问题:如下图所示,两个不同的地方有两个人使用电脑qq进行交流,请描述数据流的流向(忽略网络部分)。
答:消息发送模块的输入设备是键盘,发送的消息数据首先到了存储器(内存)中,cpu读取发送的消息数据经过计算与qq头像昵称等其他数据进行打包,将打包后的数据重新写入存储器中,打包后的数据分为两条路,一条路输出设备是屏幕,即刷新打印到屏幕上,在qq对话框中显示你发送的消息,另一条路输出设备是网卡,即刷新传输到网卡中,然后通过网络传输到接收模块的输入设备网卡中,网卡将打包后的数据写入存储器中,cpu读取打包后的数据进行解包,将解包得到的消息内容和qq头像昵称等其他数据写入存储器中,存储器刷新将解包后得到的这些消息打印到屏幕中。
2.操作系统(Operator System)
操作系统是一款软件,一款做软硬件管理的软件,对下要管理好软硬件资源,对上给用户提供一个良好稳定的运行环境。
2.1.如何理解管理
问题:什么叫做管理?如何理解管理?
答:举一个例子,在学校中有校长、辅导员、学生三种角色,其中校长是管理者学生是被管理者。校长与学生不见面是如何管理学生的呢?管理的本质不是对被管理对象进行直接管理,而是只要拿到被管理对象的所有相关数据,我们对数据管理,就可以体现对人的管理,也就是说管理的本质是对数据做管理。校长如何拿到学生数据的呢?答案是辅导员,如果说校长是管理者学生是被管理者那么辅导员就是执行者,管理者校长做决策执行者辅导员执行。执行者辅导员不仅可以获得学生数据还可以对学生进行管理。类比校长、辅导员、学生的例子,一个基本的管理流程就是执行者获取被管理者的数据并交给管理者,管理者根据数据做出决策,执行者根据决策控制被管理者。将校长、辅导员、学生的例子与计算机系统进行类比,管理者校长就是操作系统做决策,被管理者学生就是硬件,执行者辅导员就是驱动执行决策,操作系统通过驱动拿到硬件相关的数据,将这些数据保存下来,当有某些需求的时候,操作系统进行决策,将决策交给驱动程序执行,驱动程序通过控制硬件来满足需求。
问题:数据是有“多少”的分别的,管理者如何管好大量的数据?
答:人认识世界的方式是通过属性认识世界,例如在c++中一切皆对象,描述对象时可以通过抽取对象的属性(成员属性等)达到描述对象的目的。类比校长、辅导员、学生的例子,校长想要管理好大量的学生数据,可以抽取所有学生的属性,描述对应的同学。如果校长同时也是一个程序员,那么校长就可以使用c语言中的结构体struct来描述一个学生的所有属 性,如下图一所示,此时就可以用该student结构体定义一个个对象与一个个学生对应,如果结构体中增加两个指针变量那么就可以用链表的方式将一个个学生对象管理起来。总的来说就是,管理的本质是对数据做管理,如果数据量过大,可以对数据的属性进行描述,并转换成某种结构(struct、class),那么此时对学生的管理就变成了对链表的增删改查。
总结:管理的本质是对数据做管理,对数据的管理可以转换成对某种数据结构的管理。
管理的核心理念:先描述(利用struct、class描述对象属性),再组织(利用数据结构将描述后的struct、class对象组织管理起来)。
2.2.操作系统
操作系统概念:任何计算机系统都包含一个基本的程序集合,称为操作系统 (OS) 。笼统的理解,操作系统包括:内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(例如函数库,shell程序等等)
设计 OS 的目的:与硬件交互,管理所有的软硬件资源
为用户程序(应用程序)提供一个良好的执行环境
定位:在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件
举个例子,在银行中有电脑、桌椅、仓库、宿舍等;同时也有负责电脑的IT部门、负责桌椅的后勤部门、负责仓库的仓库管理员、负责宿舍的楼管阿姨;银行中有银行的正式员工,这些员工可以通过使用银行中电脑、桌椅、仓库、宿舍等进而产生银行的服务;银行中有行长负责统筹管理,管理银行的正式员工,负责管理电脑的IT部门、负责桌椅的后勤部门、负责仓库的仓库管理员、负责宿舍的楼管阿姨,管理电脑、桌椅、仓库、宿舍等。银行的这些组成部分合起来可以看作银行体系结构,有了银行体系结构中的各部分,就可以对上提供服务。
银行中的电脑、桌椅、仓库、宿舍就类似于硬件;负责电脑的IT部门、负责桌椅的后勤部门、负责仓库的仓库管理员、负责宿舍的楼管阿姨类似于和硬件打交道的驱动软件;银行的正式员工类似内存管理、进程管理、文件管理、驱动管理这四大操作系统管理软件;银行行长类似操作系统统筹管理软硬件。由硬件、驱动软件、操作系统四大管理软件、操作系统组成的结构可以看作操作系统管理结构,有了操作系统管理结构中的各部分,就可以对外提供服务。
注:操作系统的管理分为四大类:内存管理、进程管理、文件管理、驱动管理。
银行的服务对象是人,但是银行要保护自己所以不相信任何人,我们见到的所有的银行,都是一个封闭体,暴露出来一些窗口,银行给所有人提供服务的方式是通过窗口提供的。操作系统的服务对象是人,但是操作系统要保护自己所以不相信任何人,操作系统给用户提供服务的方式是通过接口提供的。
Linux内核是使用C语言写的,操作系统提供的接口也就是C语言所提供的函数调用,我们将此类函数调用叫做系统调用。
如下图所示是计算机的体系结构,计算机体系最底层是硬件,这些硬件以冯诺依曼结构组织好的,前面的冯诺依曼图是一个逻辑图,真实的硬件是嵌入在主板中的。硬件的上一层是驱动程序,每一种硬件都会有自己的驱动程序来对硬件进行操作。驱动程序上一层是操作系统,操作系统对各种资源进行管理,细分可划分为内存管理、进程管理、文件管理、驱动管理。
有了底层硬件、驱动程序、操作系统三层结构,那么操作系统通过管理就可以对外提供服务了。
问题:操作系统为什么要提供服务呢?
答:计算机和操作系统设计出来就是为了给人提供服务的。上层的应用当中一定要直接或间接访问硬件来完成某种功能,我们之前使用printf或cout向显示器打印,所谓打印本质就是将数据写到硬件上,c程序本身没有资格向硬件写入,需要贯穿计算机的体系结构从上往下依次配合。
问题:操作系统如何提供服务?
答:操作系统不相信任何人,不会直接暴露自己的任何数据结构、代码逻辑和其他数据相关的细节。操作系统是通过系统调用的方式对外提供接口服务的,Linux操作系统是用c语言写的,这里所谓的"接口"本质就是c函数,我们学习系统编程本质就是在学习这里的系统接口。
继续前面银行的例子,银行中开放窗口对用户提供服务,但是很多老年用户不会通过窗口办理业务,在银行中会有接待员,帮助老年用户办理业务。计算机系统也类似,操作系统通过系统调用的方式对外提供接口服务,直接使用这些系统调用成本过高,图形化界面、命令行解释器和各种库等类似银行中的接待员,可以帮助降低使用成本。在计算机体系中图形化界面、命令行解释器和各种库等这些可以帮助我们降低操作系统系统调用使用成本,因此在操作系统之上还有一层叫做用户操作接口层。有了用户操作接口,那么上层的用户层就可以进行指令操作、开发操作、管理操作等。
注:
1.我们通过各种编程语言进行开发其实就是在用户层,而这些语言的各种标准库在用户操作接口层,写完代码进行编译时,链接阶段会将我们的代码和用户操作接口层的标准库代码进行链接,而链接的一些标准库代码就会调用系统调用接口(通常标准库接口如果要访问硬件,那么其底层一定有系统调用)。
2.windows的系统接口和Linux的系统接口是不一样的。但是windows下c语言打印是使用printf,Linux下c语言打印也是使用printf,printf底层一定会调用系统接口,windows的系统接口和Linux的系统接口又不一样,是如何调用的呢?我们通常说一门语言跨平台可移植其实就是该语言的库在windows下可以选择windows的接口在Linux下可以选择Linux的接口,这样我们使用相同的接口在不同的平台下可以表现出相同的功能(类似多态)。
3.进程
3.1.基本概念
进程是一个运行起来的程序。
如下图所示,对磁盘中的test.c文件进行编译得到了test.exe程序,程序也是文件,文件在磁盘中。程序要执行需要先从磁盘加载到内存中,加载到内存中的程序可以理解为进程,也就是说进程是程序的一个执行实例,是正在执行的程序。这里的进程解释是书本上的对进程的描述,后面说的进程都是该理解下的进程,但是该进程的理解是不太全面的。
操作系统里面可能同时存在大量的进程,因此操作系统需要将所有的进程管理起来,而对进程的管理本质就是对进程数据的管理,而管理的核心理念是先描述再组织,也就是对所有进程先进行描述,再组织起来。
因此这里操作系统将程序从磁盘加载到内存中并不是仅仅将程序的代码和数据加载到内存中,操作系统为了管理该进程还需要进行描述和组织,也就是创建struct结构体对进程描述,并利用数据结构进行组织。
在Linux中创建的结构体名称为task_struct,利用task_struct定义进程对象来描述对应进程的所有属性,每有一个进程就会有一个对应的task_struct结构体对象,然后将这些task_struct结构体对象使用数据结构组织起来,那么操作系统对进程的管理最终变成了对内核中的数据结构的管理。
全面的进程理解:将描述某进程的结构体task_struct和该进程(即前面提到的不全面的进程理解)合并起来才是一个全面理解下的进程。
一般将描述进程的结构体叫做PCB(process ctrl block),翻译为进程控制块,Linux下具体的进程控制块叫做task_struct,用task_struct结构体来描述进程。
观察task_struct的源代码:
如果要看Linux内核的源代码,可以去网站The Linux Kernel Archives中,如下图所示,点击HTTP选择Linux选择kernel/,这里有各种版本的Linux内核源代码,选择v2.6/(2.6是非常经典的一个版本),推荐下载2.6.32系列。
如下图所示就是Linux内核中的进程控制块,
3.2.进程相关操作
查看进程:
创建一个mytest.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./mytest命令将程序运行起来,运行起来之后该程序就成为了进程,进程名为mytest,如下图三所示。
查看进程的第一种方式:ps axj/ajx(最常用)
右击选项卡选择复制SSH渠道,复制一个该账户新的选项卡以便于观察。使用ps ajx命令可以显示系统中所有的进程,如下图一所示,想直接找到我们刚刚启动的mytest进程,使用命令ps axj | grep 'mytest'即可,如下图二所示,命令ps axj | grep 'mytest'中,ps axj先执行,该进程结束将结果通过管道传给grep 'mytest',grep 'mytest'再执行,此时grep指令也是一个进程,所以下图二中将grep指令进程也打印了出来,如果不想看到grep指令进程使用命令ps axj | grep 'mytest' | grep -v grep,-v选项就是选项后的关键字一律不显示,如下图三所示,这里只打印了mytest进程。
我们自己写的代码,编译成为可执行程序,启动之后就是一个进程,别人写的程序启动之后也是进程,例如Linux中的ls、pwd、touch、chgrp等等命令运行时都是一个进程(只不过这些命令运行的很快,对应进程很快结束),在/usr/bin/路径下有系统中大部分命令,如下图所示,这些命令其实就是可执行程序,等价于windows下的.exe文件。
windows下双击桌面快捷方式或直接双击.exe文件就启动了该进程,这与Linux中使用指令或类似./mytest启动进程的本质是一样的,只不过表现形式不同。
查看进程的第二种方式:(查看进程更详细的信息)
在根目录下有很多路径,如下图一所示,其中root是root用户的家目录,home是其他用户的家目录,tmp是一个共享文件,这里要介绍的是proc目录,proc是一个内存文件系统,proc里面存的是当前系统实时的进程信息。使用ls /proc命令查看该目录,如下图二所示。每一个进程在系统中都会存在一个唯一的标识符PID(全称为process id),就如同学校每个学生都有一个学号。使用ps ajx | head -1命令可以显示出title列名称,如下图三所示,符号&&称为逻辑与,当&&前面的指令执行成功了再执行&&后面的指令,那么我们就可以使用命令ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep同时显示列名称和对应的进程信息,如下图四所示,可以看到进程名为mytest,进程的PID为7019。
知道了mytest进程的PID,在/proc路径下就可以通过进程PID查找该进程的目录,如下图五所示,使用命令ls /proc/7019查找PID为7019的进程目录,当把mytest进程关闭,那么该进程的目录就不存在了,如下图六所示。
可执行程序mytest运行得到进程mytest,将该进程关闭后再次运行可执行程序mytest得到相同名称的mytest进程,但是该进程已经不是原本的mytest进程了,应该看作是一个新的进程,操作系统会重新给新的mytest进程分配PID,如下图所示。
通过进程的PID,查看/proc路径下对应进程的目录,目录中显示的就是该进程的各种属性,这些属性以文件的形式呈现出来,如下图一所示。使用命令ls /proc/7190 -al可以查看对应进程的目录详细信息,如下图二所示,其中exe -> /home/dxf/test2023_2_7/mytest显示了该进程在磁盘中可执行程序所在的路径,其中cwd -> /home/dxf/test2023_2_7显示了进程当前的工作路径,这个路径就是我们之前经常提的当前路径,所以当前路径准确的讲应该是当前进程所在路径,进程自己会维护。
进程当前的工作路径即当前路径取决于可执行程序的位置,与可执行程序所在路径相同。如下图三所示,我们将mytest.c代码进行修改,使其在当前路径下生成log.txt文件(fopen函数打开文件,文件名中如果不带路径则默认是在当前路径),使用make命令并./mytest运行,可以看到当前路径下多了一个log.txt文件,如下图四所示。如果新建一个test目录,将可执行程序mytest移动到test目录中,此时使用./mytest命令执行,如下图五所示,如下图六所示,可以看到此时mytest进程的当前路径为/home/dxf/test2023_2_7/test,那么此时新创建的log.txt文件就应该在新创建的test目录中,如下图七所示。
注:PID、当前路径等都是进程的内部属性,这些属性数据存储在哪里呢?答案是进程控制块PCB,在Linux下是task_struct结构体中。
获取进程的PID和结束进程:
创建一个mytest.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./mytest命令将程序运行起来,运行起来之后该程序就成为了进程,进程名为mytest,如下图三所示。
使用man 2 getpid命令,如下图所示,可以看到使用getpid函数可以获得启动后本进程的PID,getpid是一个系统接口,使用gepid函数需要包含<sys/types.h>和<unistd.h>头文件,这里的pid_t其实就是无符号整数。
如下图一所示修改mytest.c代码,将getpid函数使用起来,然后使用make命令和./mytest命令运行代码,如下图二所示。使用命令ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep查看mytest进程的PID,如下图三所示,和getpid函数返回的相同。
要退出某进程,可以在进程输出栏中使用ctrl c退出进程,也可以在命令行中使用kill -9 14203命令结束PID为14203的进程,如下图所示,选项-9的意思是给对应PID的进程发送9号信号。
获取进程的父进程的PID:
创建一个mytest.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./mytest命令将程序运行起来,运行起来之后该程序就成为了进程,进程名为mytest,如下图三所示。
使用man 2 getppid命令,如下图所示,可以看到使用getppid函数可以获得启动后本进程的父进程的PID,getppid是一个系统接口,使用geppid函数需要包含<sys/types.h>和<unistd.h>头文件,这里的pid_t其实就是无符号整数。
如下图一所示修改mytest.c代码,将getpid函数使用起来,然后使用make命令和./mytest命令运行代码,如下图二所示。使用命令ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep查看mytest进程的父进程的PID,如下图三所示,和getppid函数返回的相同。
如下图一所示,多次结束并重新运行mytest可执行程序,可以看到每一次mytest进程的PID都不同但是每一次mytest进程的父进程的PID相同。mytest进程每次PID不同是正常的,mytest进程的父进程每次的PID相同是为什么呢?mytest进程的父进程是谁呢?
我们使用命令查看一下PID为13596的mytest进程的父进程目录,如下图二所示,我们发现mytest进程的父进程是一个叫做bash的东西。
其实几乎我们在命令行上所执行的所有的指令(包括执行自己的程序),都是bash进程的子进程。
创建子进程:
使用man 2 fork命令,如下图一二所示,可以看到使用fork函数可以创建一个子进程,如果创建成功把子进程的PID返回给父进程并且返回0给子进程,如果创建失败将-1返回给父进程。
如下图一所示的代码和运行结果,hello word被打印了两次,如下图三所示的代码和运行结果,同一个id两次打印的结果不同。
如下图一所示的代码和运行结果,如果id为0则代表其为子进程,如果id大于0则其为父进程并且id值为子进程的PID,根据运行结果来看,子进程打印出来的自己的PID为19191其父进程的PID为19190,父进程打印出来的自己的PID为19190,这样就证明了fork成功创建了一个子进程。使用命令ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep可以看到有两个mytest进程,这两个进程是父子进程关系,且两个PID和图一打印的结果相同。
结论:
1.fork之后,父进程和子进程会共享代码,一般都会执行后续的代码。(这就是前面printf为什么会打印两次的问题)
2.fork之后,父进程和子进程返回值不同,可以通过不同的返回值进行判断,让父子进程执行不同的代码块。
问题:fork()为什么给父进程返回子进程的PID,给子进程返回0?
答:父进程和子进程的比例是1:n的,n是大于等于1的,所以父进程必须得有标识子进程的方案,该方案就是fork之后给父进程返回子进程的PID,子进程最重要的是要知道自己被创建成功了,因为子进程找父进程使用getppid即可成本非常低。
问题:为什么fork函数会返回两次?
补充1:fork函数是由操作系统提供的系统接口,fork之后操作系统多了一个进程,如果操作系统本身有一个进程,那么操作系统中有task_struct+该进程代码和数据,如果该进程有fork函数,那么操作系统中除了task_struct+该进程代码和数据还会有task_struct+子进程代码和数据。子进程的task_struct对象内部的数据即进程属性从哪里来呢?基本上是从父进程拷贝下来的(PID等属性除外)。子进程被创建出来就是为了执行代码和计算数据的,子进程的代码从哪里来呢?子进程和父进程执行同样的代码,fork之后父子进程代码共享(父子进程代码共享数据各自私有,数据私有后面会讲)。虽然父子之间代码是共享的,但是我们可以通过不同的fork返回值让不同的进程执行不同的代码。
补充2:如何理解进程被运行?前面提到所有进程的PCB都会使用数据结构(例如双链表)进行管理,但这并不是说一个进程的PCB只能在一个数据结构中进行管理,可以将一个进程的PCB同时放在多个数据结构中进行管理。
操作系统中有一个代码模块叫做调度器,在Linux内核中,每一个CPU都会存在一个运行队列runqueue,运行队列中管理进程的PCB(Linux中PCB是具体的task_struct结构体),每一个task_struct都有对应进程的各种属性并且指向对应进程的代码和数据。调度器根据优先级在运行队列中选择合适的PCB,让CPU执行PCB对应的进程代码和数据。创建子进程,生成子进程对应的task_struct,其中子进程的task_struct中属性基本拷贝父进程并且同时指向父进程指向的代码(父子进程代码共享数据各自私有),然后子进程加入到运行队列runqueue中供调度器调度,如下图所示。
答:调用一个函数,当这个函数准备return的时候,这个函数的核心功能已经完成了,而如果函数中调用了fork函数,那么首先子进程已经被创建了,其次子进程PCB已经被放入运行队列。CPU执行父进程进行了一次return,子进程指向代码和数据与父进程相同,执行子进程还要进行一次return,因此总共会return两次也就是会返回两次。
4.进程状态
4.1.进程状态概述
4.1.1.操作系统层面的进程状态
进程的状态是用一个整数来表示,因此进程的状态本质上就是一个整数,这个整数在进程的task_struct中,如下图所示是Linux内核源代码,其中变量state用来存取这个整数。
操作系统中进程的状态可以分为四类:运行态、终止态、阻塞态、挂起态。
运行态:
在Linux内核中,每一个CPU都会存在一个运行队列runqueue,运行队列中管理进程的PCB(Linux中PCB是具体的task_struct结构体),进程对应的task_struct只要在运行队列runqueue中。那么该进程的状态就是运行态。
注:进程是运行态不代表进程正在被CPU运行,而是该进程在运行队列runqueue中,即已经准备好了随时可以调度。
终止态:
进程是终止态的表示该进程还在,只不过永远不运行了,随时等待被释放。
问题:进程都终止了,为什么不立刻释放对应的资源,而是要维护一个终止态?
答:当进程终止了,不能保证操作系统立刻就能释放进程对应的资源,有可能操作系统此时很忙暂时无法去释放。因此终止的进程需要维持终止状态来告诉操作系统自己已经终止,不能再被调度,等操作系统不忙了要将自己释放掉。
阻塞态:
一个进程申请使用资源的时候,不仅仅是在申请CPU资源,进程可能申请其他更多的资源,例如:磁盘、网卡、显卡、显示器、声卡、音响等。如果我们申请CPU资源暂时无法得到满足,需要排队(运行队列),如果我们申请其他慢设备的资源也是需要排队的,这里排队的本质其实是进程的task_struct在排队,因此前面我们说过进程的task_struct会用数据结构(例如双链表)维护起来,但并不是说进程的task_struct只会维护在一个数据结构中,如果存在大量的设备资源需要被进程所申请,那么就需要对应进程的task_struct去对应的数据结构中排队。
操作系统对各种软硬件管理必须先描述再组织,如果是c语言,描述就是对各种软硬件使用结构体定义,如下图所示,每一个软硬件结构体中都会有一个运行队列数据结构(图中的queue或wait_queue)来维护进程。如果此时一个进程正在被CPU执行,CPU执行对应程序,程序中要通过磁盘读取数据,但是磁盘此时正在忙暂时无法读取,那么此时进程不可能再占用着CPU在CPU上等待磁盘,操作系统会将该进程的task_struct放到磁盘的运行队列数据结构中,CPU继续运行其运行队列中其他的进程。
当进程访问某些资源(磁盘网卡等),该资源如果暂时没有准备好,或者正在给其他进程提供服务,此时操作系统会将当前进程从CPU的运行队列runqueue中移除,并且将当前进程放 入对应设备的运行队列中等待,这就是操作系统对进程的管理任务。
如果磁盘设备就绪可以读取数据了,操作系统会将该进程的task_struct重新放入CPU的运行队列runqueue中。
当我们的进程此时在等待外部资源的时候,操作系统将该进程的task_struct移动到对应设备资源的运行队列中,该进程代码不会再被CPU执行,从用户角度来看该进程卡住了,这就是进程阻塞。
进程等待某种资源(非CPU),资源没有就绪的时候,进程需要在该资源的等待队列中进行排队,此时进程的代码并没有运行,此时进程所处的状态就叫做阻塞。
挂起态:
如下图所示,磁盘中的可执行程序加载到内存中形成进程,如果内存中进程过多导致内存不足了怎么办?那么操作系统就要帮我们进行辗转腾挪。短期内不会被调度的进程(进程的PCB等的资源短期内不会就绪),它的代码和数据依旧在内存中,就是白白的浪费空间,操作系统就会把该进程的代码和数据置换到磁盘上,进程的PCB依旧保留在内存中排队等待资源,这样的进程状态就是挂起状态。
磁盘中会有一个与内存相匹配的swap分区,一般swap分区的大小和内存大小保持一致,操作系统如果要将进程的代码和数据置换到磁盘上,是将这些代码和数据置换到磁盘的swap分区。
注:这里就可以看出,往往内存不足的时候,伴随着磁盘被高频率访问。
4.1.2.Linux进程状态
如下图所示是Linux内核中的源代码,展示了Linux下进程的所有状态。
R(running):运行状态,进程是R状态并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里,对应操作系统层面的运行态。
S(sleeping):浅度睡眠/可中断睡眠状态,意味着进程在等待资源就绪(除磁盘资源外),对应操作系统层面的阻塞态的一种。
D(disk sleep):深度睡眠/不可中断睡眠状态,一般而言在Linux中,如果进程等待的是磁盘资源,那就是深度睡眠/不可中断睡眠,对应操作系统层面的阻塞态的一种。
Z(zombie):僵尸状态,意味着进程退出了其不会立即释放,至少其PCB不会立即释放,只有父进程读取它或者操作系统回收它时,该进程才会由Z变为X进而被释放。
X(dead):死亡状态,死亡状态的进程还存在,只不过永远不运行了,随时等待被释放,对应操作系统层面的终止态。
T(stopped):暂停状态,常规的功能性暂停。
t(tracing stop):追踪暂停状态,进程被调试的时候,遇到断点所处的状态。
注:
1.S状态的进程操作系统和用户都可以唤醒该进程,以转为R状态运行或直接杀掉该进程。D状态的进程操作系统和用户都无权唤醒,只能等D状态的进程自己醒来,即等磁盘读写完毕给磁盘反馈后进程自己醒来。
2.一般进程的X状态只在一瞬间,难以用代码进行演示。
3.操作系统层面的运行状态对应Linux的R状态,操作系统层面的终止状态对应Linux的Z和X状态,操作系统层面的阻塞状态对应Linux的S或D状态二者都有可能,操作系统层面的阻塞状态对应Linux的S或D状态,操作系统层面的挂起状态对应Linux的S、D或T、t状态四者都有可能。
S状态:
创建一个process.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./process命令将程序运行起来,运行起来之后该程序就成为了进程,进程名为process,如下图三所示。
使用命令ps ajx | head -1 && ps ajx | grep process | grep -v grep观察进程的状态,如下图所示,我们发现此时进程的状态是S状态,CPU正在执行程序,为什么程序的状态是浅度睡眠/可中断睡眠?这是因为CPU速度很快显示器速度很慢,而代码不停的printf,进程要不停的申请显示器资源,不停的等待显示器资源就绪,因此绝大部分时间该进程都是等待的,因此进程绝大部分时间都为S浅度睡眠/可中断睡眠。
R状态:
我们将process.c文件的代码进行修改,如下图一所示,make生成process可执行程序然后./process运行,此时使用命令ps ajx | head -1 && ps ajx | grep process | grep -v grep观察进程的状态,如下图所示,我们发现此时进程的状态是R状态,
D状态:
进程深度睡眠/不可中断睡眠即进程的D状态较难使用代码来模拟演示(如果想模拟演示可以借助dd指令,这里不介绍),我们举一个形象的例子来理解。
内存中的进程有500M数据要写到磁盘中,磁盘拿到数据进行写入,写入的过程中进程不能走,因为磁盘写入后是否写入成功还要告诉进程,进程要将结果返回给用户(对应c语言调用fwrite、fread的返回值),因此磁盘写入的时候进程在内存中设置为S状态等待磁盘的回复。一般计算机或服务器压力过大,操作系统是会终止用户进程的(程序闪退的原因),如果在磁盘写入、进程等待的时候,内存中其他进程过多导致内存压力过大,操作系统杀死了该正在等待的进程,过了一会磁盘写入成功或失败,欲将写入的结果返回给进程,然而进程已被杀死不见了,这样就无法将磁盘写入的结果返回给用户,如果这500M数据很重要而磁盘写入失败了,那么这500M数据就丢失了,这就可能会造成很多严重的后果。
上面问题的根本原因是操作系统可以杀死磁盘写入时正在内存中等待结果的进程,为解决这种问题,操作系统引入了一种状态叫做D状态,D状态是深度睡眠/不可中断睡眠,处于D状态的进程操作系统和用户都无法杀死,这样将磁盘写入时正在内存中等待结果的进程设置成D状态,该进程无法被杀死,只有磁盘读写完成给进程反馈之后该进程才会解除深度睡眠状态,将D状态转为非D状态,这样上面的问题得到解决。
注:只有关机重启和拔电源可以杀死D状态的进程,如果内存中存在大量的D状态进程那么关机甚至可能会失败。
Z状态:
当一个Linux中的进程退出的时候,一般该进程不会直接进入X状态(进程死亡,其资源可以立马回收),而是进入Z状态。
进程被创建出来,一定是因为要有任务让这个进程执行,当该进程退出的时候,要知道这个进程把任务完成的如何了,一般需要将进程的执行结果告知父进程或操作系统。因此进程退出,要维护一个Z状态,就是为了让父进程或操作系统读取它的执行结果。
具体地说,Z状态是为了维护退出信息,以供父进程或操作系统读取,而进程要退出时退出信息会写入PCB中,因此Z状态进程就是进程本身(该进程的代码和数据)可以释放,但进程的PCB不能释放。
模拟僵尸进程:如果创建子进程,子进程退出了,父进程不退出也不等待子进程(不管子进程),子进程退出之后所处的状态就是Z状态。我们将process.c文件的代码进行修改,如下图一所示,make生成process可执行程序,如下图二所示,使用命令while :; do ps ajx | head -1 && ps ajx | grep process | grep -v grep; sleep 1;echo "#################################################################"; done,该命令是一个监控脚本可以在命令行上连续显示父进程和子进程的状态,此时使用./process命令将程序运行起来,当子进程打印“我已经僵尸了,等待被检测”后,从监控脚本中可以看到子进程的状态由S状态转为Z状态。
注:exit函数可以终止进程,使用exit函数需要包含<stdlib.h>头文件。
问题:长时间僵尸状态有什么问题呢?
答:如果没有人回收僵尸状态的进程,该进程的僵尸状态会一直维护,该进程的相关资源(task_struct)不会被释放,造成内存泄漏,因此一般而言必须要求父进程进行回收。这里具体如何解决僵尸进程造成内存泄漏的问题我们后面再讲解。
补充:孤儿进程
子进程退出,父进程不管,那么子进程就要一直维持僵尸状态,那如果父进程先退出呢?
我们将process.c文件的代码进行修改,如下图一所示,make生成process可执行程序,如下图二所示,使用命令while :; do ps ajx | head -1 && ps ajx | grep process | grep -v grep; sleep 1;echo "#################################################################"; done,该命令是一个监控脚本可以在命令行上连续显示父进程和子进程的状态,此时使用./process命令将程序运行起来,当父进程三秒后退出了,从监控脚本中可以看到父进程被杀死了只显示了子进程的信息,此时子进程的父进程为1号进程。
父进程也有自己的父进程,为什么父进程没有先变为僵尸进程呢?这是因为父进程的父进程是bash,bash会自动回收其子进程。
如果父进程提前退出,子进程还在运行,那么子进程会被1号进程领养(这里的1号进程就是操作系统,如下图三所示),我们将这种被领养的进程称为孤儿进程。
注:孤儿进程无法使用ctrl c终止掉,并且孤儿进程的状态没有后面的符号+,如下图二所示,状态后面跟+代表这个进程是一个前台进程(能ctrl c终止的都是前台进程),状态后面没有+代表这个进程是一个后台进程(不能ctrl c终止的都是后台进程),后台进程无法使用ctrl c终止,只能使用kill命令终止,kill命令杀死孤儿进程后操作系统就会立刻回收该进程。
T状态:
我们将process.c文件的代码进行修改,如下图一所示,使用make命令生成可执行程序,使用./process命令将程序运行起来,使用命令while :; do ps ajx | head -1 && ps ajx | grep process | grep -v grep; sleep 1;echo "#################################################################"; done查看进程状态,使用kill -19 15099命令暂停对应进程,此时process程序的运行就会暂停,查看进程状态其为T状态,如下图二所示。
如果想让暂停的进程继续运行,使用kill -18 15099命令即可,如下图三所示。
4.2.进程状态之间的切换
如下图所示是Linux下进程状态之间的切换图:
注:图中TASK_RUNNING就绪和占用CPU执行其实都是运行状态R。
5.进程优先级
5.1.进程优先级
问题:优先级是什么?
答:进程优先级是进程获取资源的先后顺序。
注:优先级和权限的区别:权限讨论的是能和不能获取操作某资源的问题,而优先级的前提条件是能获取操作某资源,讨论的是先还是后的问题。
问题:为什么会存在优先级?
答:系统里面永远都是进程占大多数,而资源是少数,因此进程竞争资源是常态,那么这些进程需要确认使用资源的先后,也就是确认进程的优先级。
查看进程优先级:
创建一个process.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./process命令将程序运行起来,使用命令ps -la(如果只使用ps -l只能看到本选项卡的进程,加上a选项可以看到全部的进程),如下图三所示。Linux下进程的优先级由priority和nice决定,priority简称PRI,nice简称NI,对应下图三所示的两列。
Linux下PRI的数值越小代表其优先级越高,PRI的数值越大代表其优先级越低。
修改进程优先级:
如果要更改进程的优先级,需要更改的不是PRI而是NI,nice即NI值是进程优先级的修正数据。PRI和NI之间的换算公式为PRI=prio_old+NI,因此修改NI值也就修改了PRI值,其中prio_old是修改之前老的PRI值,每次重新设置修改优先级,这个prio_old都会被恢复成80。
首先可以使用ps -la命令查看要修改进程的PID,使用top命令修改进程的优先级,然后输入r,此时系统提示我们PID to renice要对优先级进行修改了,输入要修改优先级的进程的PID,此时系统提示我们Renice PID 16568 to value要将PID为16568的进程优先级改成多少,输入要修改的优先级值然后回车即可(这里我们设置NI为-100,普通进程NI范围为-20至19,因此即使我们设置-100,最终也只能为-20),如下图一所示。
进程优先级修改之后,再次使用ps -la命令,可以看到进程的NI值变成了-20,PRI变成了60,根据公式PRI=prio_old+NI,PRI=80+(-20)=60计算与实际吻合。这样PRI数值减小了优先级增加了。
注:
1.一个进程的优先级不能被轻易修改,因为修改会打破调度器平衡,如果普通用户修改,那么会如下图所示提示修改失败,如果非要修改,那么得具备超级用户root的权限
2.Linux不允许进程无节制的设置优先级,Linux下普通进程的NI取值范围是-20至19,一共40个级别。实际上Linux系统一共有140个优先级,而只有40个优先级是给普通进程使用的,其他很多优先级并不是给我们普通进程使用的,对应PRI,PRI的60到99区间值是给普通进程使用的。
5.2.其他概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
注:进程运行具有独立性,不会因为一个进程挂掉或异常,而导致其他进程出现问题。
并行: 多个进程在多个CPU下分别同时进行运行,这称之为并行。
注:如果存在多个CPU的情况,在任何一个时刻,都有可能有多个进程在同时被运行(在CPU上运行)。
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
问题:我们的电脑一般都是单CPU的,但是我们的电脑中有各种进程可以同时在运行,这是为什么?
答:表面上看电脑中各种进程同时在运行,其实它们并不是同时在运行,而是在极短时间内依次运行的,只不过这样看起来像是同时在运行。不要以为进程一旦占有CPU,就会一直执行到结束才会释放CPU资源,我们遇到的大部分操作系统都是分时的,操作系统会给每一个进程在一次调度周期中赋予一个时间片,如下图所示,假如给每一个进程赋予一个10ms的时间片,每一个进程执行10ms就被操作系统的调度器模块从CPU剥离,按照进程时间片的时间CPU继续执行后面的进程。在一段时间内,多个进程都会通过切换交叉的方式让多个进程代码在一段时间内得到推进,这种现象叫做并发。
问题:操作系统就是简单的根据队列来进行先后调度的吗?如果来了一个优先级更高的进程如何处理呢?
答:目前的计算机都支持抢占式内核,正在运行的低优先级进程(正在其时间片的运行时间内),如果来了个优先级更高的进程,调度器会直接把低优先级进程从CPU上剥离,放上优先级更高的进程,这就叫作进程抢占。
问题:CPU上执行进程的时间片结束要执行后面的进程,或者该进程时间片没有结束但有其他优先级更高的进程需要运行,该进程被操作系统从CPU剥离,这些都涉及到进程间切换,那么操作系统是如何进行进程间切换的?
补充:CPU内的寄存器可以临时的存储数据,临时存储的这些数据非常少但非常重要。寄存器可以分为可见寄存器和不可见寄存器。函数有整数返回值时,返回的整数值就临时存储在了寄存器中,像这种可以被我们使用的寄存器就是可见寄存器,典型的有eax、ebx、ecx、edx等,还有很多寄存器是给操作系统使用的,这些寄存器就是不可见寄存器。
答:当进程在被执行的过程中,一定会存在大量的临时数据,会暂存在CPU的寄存器中,我们把进程在运行中产生的各种寄存器数据叫做进程的硬件上下文数据。当进程被剥离或时间片结束正常退出时,需要保存上下文数据,当进程恢复或再次运行的时候需要将曾经保存的上下文数据恢复到寄存器中。
当进程被剥离或时间片结束正常退出时,进程的硬件上下文数据会保存在进程的task_struct中,当进程恢复或再次运行的时候会从进程的task_struct中将上下文数据加载到寄存器中。
6.环境变量
6.1.环境变量引入
环境变量的概念我们通过 一个问题引出:
问题:为什么我们自己的代码运行要带路径(类似./mytest),而系统的指令不用带路径?
答:不带路径直接使用我们自己的代码程序,通过报错就可以看出问题所在,如下图所示,执行一个可执行程序,前提是要先找到它。那么为什么系统的命令能找到而我们自己的程序找不到呢?系统中是存在相关的环境变量,保存了程序的搜索路径的。
环境变量是电脑开机或用户登录时在系统中自动生成的一组变量,只要是变量,那么就一定有变量名和内容,这些变量都有不同的应用场景。Linux系统查看环境变量的指令是env,如下图一所示。windows系统查看环境变量的方式是右击此电脑选择属性,选择高级系统设置,点击环境变量,上面显示的是用户的环境变量,下面显示的是系统的环境变量,如下图二所示。
系统中搜索可执行程序的环境变量叫做PATH,我们直接使用env | grep PATH命令,如下图一所示,或者使用echo $PATH命令,显示PATH环境变量的内容,如下图二所示,其中echo是进行输出,符号$是获取后面变量的内容。从下图一二都可以看出环境变量PATH里面有多个路径,多个路径之间使用冒号作为分隔符。
当我们使用系统指令或不带路径运行自己的程序时,系统会在PATH环境变量中的多个路径下依次进行查找,如果在某个路径下找到了对应的系统指令或自己的程序就会执行该路径下对应指令或程序,并且停止从后面的路径下查找。
我们的常用系统指令(例如ls等)在/usr/bin路径下,因此这些命令在环境变量PATH的路径中能被找到,所以执行系统指令时可以不带路径。我们自己的可执行程序所在路径不在环境变量PATH中,系统依次搜索PATH中路径都没有找到该可执行程序,因此返回command not found,所以执行我们自己的可执行程序时需要带路径。
如果想不带路径还能执行我们自己的可执行程序,那么有两种思路:
思路一:将我们自己的可执行程序拷贝到环境变量PATH的任意路径中,如下图所示。将我们自己的可执行程序拷贝到环境变量PATH的任意路径中,我们通常叫做将软件安装到了系统上。
注意:
1.普通用户这里要拷贝到目标地点并不是在自己的工作目录下,没有权限因此无法拷贝成功,需要使用root用户进行拷贝。
2.不建议将自己的程序拷贝到环境变量PATH的任意路径中,因为这样会污染Linux下的命令池,我们将上面拷贝的程序删除,如下图所示,此时不带路径运行自己的程序将无法运行。
思路二:将我们自己的程序路径添加到环境变量PATH中,如下图所示,使用命令export PATH=$PATH:/home/dxf/test2023_2_13将自己程序的路径追加到环境变量PATH中。
注意:
1.可以直接在Linux的命令行中直接定义变量,在Linux命令行定义的变量分为两种:
第一种是定义本地变量,定义方式类似aaa=100,如下图一所示,使用echo $aaa可以打印变量的内容,这样定义出的变量并不是环境变量,因此使用env | grep aaa命令得到的结果没有找到对应变量。
第二种是定义环境变量,定义方式类似export bbb=123,如下图二所示,export的功能是导出环境变量,使用env | grep bbb命令得到的结果找到了对应变量。
2.如果直接使用命令export PATH=/home/dxf/test2023_2_13,如下图一所示,这样环境变量PATH中就只有/home/dxf/test2023_2_13这一个路径了,原本环境变量PATH中的路径都被覆盖掉了,那么此时很多系统命令都无法再使用。
遇到这种情况其实问题不大,我们自己在命令行上设置的环境变量具有临时性,只有在此次登录有效,这种情况我们重新登录一下账号即可,如下图二所示,重新进行用户登录使用echo $PATH命令可以看到环境变量PATH中的路径恢复了。
用户登录后,这些环境变量其实是在内存中存储的,我们修改修改的也只是内存中的环境变量,下次在登录的时候会重新读取系统配置文件,重新生成环境变量。如果想让环境变量的修改永久有效那么需要去更改配置文件。
3.环境变量不建议修改,如果一定要改环境变量那么最好是进行新增内容而不要进行覆盖式的写。环境变量的配置文件更不推荐去修改。
补充:使用 which+指令/可执行程序(指令和可执行程序必须在环境变量PATH中的某个路径中) 可以显示对应指令/可执行程序所在的路径,如下图所示。
注:如果which的指令和可执行程序不在环境变量PATH中的某个路径中,那么是无法返回对应路径的。
6.2.环境变量介绍
环境变量基本概念:
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
常见的环境变量:
MANPATH:记录man手册的搜索路径。
HOSTNAME:对应主机的机器名,可以使用命令echo $HOSTNAME显式的查看,如下图一所示。
SHELL:当前shell命令行的路径,可以使用命令echo $SHELL显式的查看,如下图二所示。
HISTSIZE:查找历史输入命令的命令数,可以使用命令echo $HISTSIZE显式的查看,如下图三所示,使用history可以显示全部的历史命令,如下图四所示。
SSH_CLIENT:登陆主机的IP地址。
USER:当前使用系统的用户,可以使用命令whoami或echo $USER显式的查看,如下图五所示。
LS_COLORS:ls相关命令的配色方案,使用ls等命令经常会根据文件类型显示不同的颜色,这就是由LS_COLORS决定的。
PATH:各种指令的搜索路径。
MAIL:Linux系统中的邮箱路径。
PWD:当前用户所处的路径,可以使用命令pwd或echo $PWD显式的查看,如下图六所示。
LANG:支持的编码格式。
HOME:用户所对应的家目录,可以使用命令echo $HOME显式的查看,不同用户对应的家目录不同,如下图七所示。
LOGNAME:登录系统的用户。
注:系统启动的时候会自动执行相关的配置文件将数据载入到内存的环境变量中,Linux系统中的配置文件有.bashrc等。
查看环境变量的方法:
使用 echo $NAME 命令进行查看,其中NAME是要查看的环境变量名称。和环境变量相关的命令:1. echo: 显示某个环境变量值2. export: 设置一个新的环境变量3. env: 显示所有环境变量4. unset: 清除环境变量5. set: 显示本地定义的shell变量和环境变量注:
1.unset的功能是清除环境变量,使用方式如下图所示。
2.如下图所示,使用命令aaa=123创建出来的变量是普通本地变量,使用env | grep aaa命令是找不到aaa变量的,我们可以使用export aaa命令将aaa变量设置成环境变量,再使用env | grep aaa命令就可以找到了。我们再使用bbb=111命令创建一个普通本地变量,如果想要查看本地变量,可以使用set命令,set命令既可以查看本地变量也可以查看环境变量。
补充知识1:main函数的参数
main函数参数:
main函数可以带两个参数,两个参数分别是int argc和char *argv[],argv是一个指针数组,argc是指针数组中元素的个数。
创建一个myproc.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序。分别使用./myproc、./myproc -a、./myproc -a -b、./myproc -a -b -c命令运行程序,如下图三所示,其中myproc是可执行程序,后面跟的-a -b -c都是选项。
无论是程序名还是选项最终都是以字符串的形式存储在一个指针数组中,指针数组的0号下标位置存储的是命令行中输入的第一块区域./myproc字符串的起始地址,指针数组的1号下标位置存储的是命令行中输入的第二块区域-a字符串的起始地址,依次类推,指针数组有效位后以NULL结尾。
因此,我们给main函数传递的int argc和char *argv[],命令行参数传递的是命令行中输入的程序名和选项。
main函数参数的意义:同一个程序通过传递不同的参数,让同一个程序有不同的执行逻辑和执行结果,也就是同一个程序在Linux系统中会根据不同的选项让不同的命令可以有不同的表现,这就是指令中那么多选项的由来和起作用的方式。
main函数参数的应用:
设计一个myproc计算器程序,使得命令myproc -a 10 20做10和20的加法,命令myproc -s 10 20做10和20的减法,命令myproc -m 10 20做10和20的乘法,命令myproc -d 10 20做10和20的除法。
环境变量的获取:
方法一:命令行第三个参数
前面讲到main函数可以带两个参数,两个参数分别是int argc和char *argv[],其实main函数还可以带第三个参数,第三个参数是char *env[],该参数是一个指针数组,指针数组中的内容是环境变量的字符串地址,指针数组有效位后以NULL结尾,形象图如下图所示。
创建一个myproc.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./myproc运行程序,如下图三所示,可以看到将环境变量打印了出来。
注:一个进程是会被传入环境变量参数的。如果一个c语言函数没有参数的而调用该函数时进行传参,这样是不会报错的,并且函数中还可以通过一些方式读取到传参的值,因此之前我们即使没有写过main函数的char *env[]参数,系统照样可以将环境变量传进来。
方法二:通过第三方变量environ获取
c语言提供了一个第三方全局变量char **environ,extern char **environ声明该变量之后,就可以使用了。
创建一个myproc.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./myproc运行程序,如下图三所示,可以看到将环境变量打印了出来。
方法三:通过getenv接口获取
c语言提供了一个getenv函数,通过getenv函数可以得到某个环境变量的内容。
创建一个myproc.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,使用./myproc运行程序,如下图三所示,可以看到将某一个具体的环境变量打印了出来。
注:使用getenv函数需要包含<stdlib>头文件。
问题:为什么要获取环境变量?
答:通过获取的环境变量可以实现某些特殊功能,比如写一个只有本用户才能运行的程序,如下图一所示,那么dxf用户执行该程序就会输出成功执行,其他用户执行该程序就会输出权限拒绝,如下图二所示。
补充知识2:命令行中启动的进程,父进程全部都是BASH
创建一个myproc.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,然后连续使用./myproc和ctrl c运行终止程序,如下图三所示,我们发现当前进程的pid在变化但是父进程的pid不变,前面讲过父进程是bash进程,如下图四所示。
bash进程是系统启动之后创建的一个命令行解释器,如果我们使用命令kill -9 4447杀掉bash进程,那么命令行就会挂掉输入任何命令都没有反应,如下图五所示。
因此我们之所以可以正常的使用命令行,是因为这些命令本质上是先被bash进程获得。可执行程序bash在/usr/bin/路径下,当用户登录的时候,系统就会给用户创建一个bash进程,在bash进程中通过scanf、cin获得用户的命令,在命令行中每使用一个命令或运行一个自己的程序,bash中都会使用fork启动一个子进程来执行,至于bash进程fork之后是如何将命令程序或自己的程序加载进来执行的,我们下一个博客进程控制再进行讲解。
环境变量具有全局属性:
创建一个myproc.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令生成可执行程序,然后使用./myproc执行该程序,如下图三所示,我们发现getenv函数返回的是null,即没有该环境变量。
我们使用命令dxf_qyn=123456789创建一个本地变量,那么使用set | grep dxf_qyn可以找到,使用env | grep dxf_qyn无法找到,/myproc程序运行getenv函数返回结果仍然是null,使用export dxf_qyn将本地变量dxf_qyn设置成环境变量,此时/myproc程序运行getenv函数返回结果为123456789。
环境变量是会被子进程继承下去的,如果我们定义了一个环境变量,其实相当于是在bash进程处定义了一个环境变量,那么该环境变量就会以bash作为起点,进而被bash往后所有的子进程继承,因此我们称环境变量具有全局属性。
所谓的本地变量,本质就是在bash内部定义的变量,不会被子进程继承下去。
问题1:如下图所示,创建了一个本地变量local_val,前面说过在命令行中启动的程序基本都要启动子进程来执行,echo命令也启动了子进程,而bash的本地变量local_val不会被子进程继承,那么为什么bash进程的本地变量local_val会被echo命令子进程读取到呢?
问题2:如下图所示,同样的本地变量local_val,使用export命令导出,export命令也启动了子进程,那么导出的local_val应该是在export子进程中,为什么会在父进程bash中呢?
答:Linux下大部分命令都是通过子进程的方式执行的,但是还有一部分命令不通过子进程的方式执行,而是由bash自己执行,即调用自己的对应函数来完成特定的功能,我们把这种命令叫做内建命令。内建命令与系统中存在的命令不同,内建命令是bash在自己代码中强行编码进去的命令。
7.程序地址空间
7.1.地址空间介绍
我们曾在c语言c++中学的程序地址空间,如下图所示,其实这里的程序地址空间准确的来说应该叫做进程地址空间,因此我们后面统一称为进程地址空间。
如下图一所示,这里打印main函数地址,main函数相当于一个函数的代表,对应代码区的地址;打印g_val和un_g_val地址,g_val和un_g_val相当于已初始化全局变量和未初始化全局变量的代表,对应初始化数据区和未初始化数据区的地址(前面学的静态区);打印ml内容,ml是一个指针存储着malloc出来空间的地址,对应堆区的地址;打印ml地址,ml是一个指针,对应栈区的地址;使用for循环打印argv[i]和env[i],argv[i]里面存储着命令行字符串的地址,env[i]里面存储着环境变量的地址,二者对应命令行参数环境变量地址。
运行结果如下图二所示,正文代码区的main函数地址为0x40057d,静态区的已初始化变量和未初始化变量的地址分别为0x60103c、0x601044,堆区的ml内容为0x1bcb010,栈区的ml地址为0x7fff31e084e0,命令行参数环境变量区的argv[i]和env[i]内容为0x7fff31e0a7cf、0x7fff31e0a7d8等。根据图二运行结果,我们发现地址从上往下依次增大,也就是按照正文代码区、初始化数据区、未初始化数据区、堆区、栈区、命令行参数环境变量区的顺序地址依次增大,且堆和栈之间有一块非常大的地址镂空,这部分镂空区域叫做共享区,共享区我们后面会讲到。
如下图一所示,连续在堆区申请了四个空间,空间地址分别赋值给m1、m2、m3、m4,如下图二所示,申请的空间地址逐渐增大,因此堆区空间是从低地址空间向高地址空间依次使用的。
如下图一所示,连续在栈区创建了四个变量m1、m2、m3、m4,如下图二所示,申请的空间地址逐渐减小,因此栈区空间是从高地址空间向低地址空间依次使用的。
总结:堆区向地址增大方向增长,栈区向地址减少方向增长,堆栈相对而生,这与上面的进程地址空间图相吻合。
问题:如何理解static变量?
答:函数内定义的变量用static修饰,本质是编译器将该变量编译进全局数据区(即静态区或初始化/未初始化数据区)。
7.2.感知地址空间存在
根据下图一所示的代码和下图二所示的运行结果,可以看到父进程和子进程打印的全局变量g_val值相同地址也相同,也就是说当父进程或子进程没有修改全局数据的时候,父子进程是共享该数据的。
注:程序中有fork函数,那么父子进程谁先运行我们说了不算,其是由CPU先调度哪个进程来决定的,我们经常发现打印出来的结果是父进程先跑,这是因为此时父进程正在其时间片范围内执行,将子进程创建出来之后还可以进行打印,但这并不代表父进程一定先运行。
根据下图一所示的代码和下图二所示的运行结果,可以看到父进程和子进程打印的全局变量g_val值不相同地址却相同,也就是说这里父子进程读取同一个变量(因为地址一样),但父子进程读取到的内容却不一样,这是为什么呢?原因我们在下面7.3小节中详细介绍。
从这里首先说明了我们在c/c++中使用的地址,绝对不是物理地址,如果是物理地址这种现象不可能产生。c/c++中使用的地址叫做虚拟地址、线性地址或逻辑地址。
问题:为什么操作系统不让用户直接看到物理内存呢?
答:内存是一个硬件,不能阻拦用户访问,内存只能被动的进行读取和写入,操作系统不允许用户直接看到物理内存,因为如果让用户直接对物理内存中的数据进行修改是不安全的。
7.3.深度理解地址空间
每一个进程在启动的时候,都会让操作系统给他创建一个地址空间,该地址空间就是进程地址空间,对应前面所说的虚拟地址,如下图所示,每一个进程都会有一个自己的进程地址空间(这就是上面父子进程打印全局变量g_val值不相同地址却相同的原因)。
前面我们讲过进程具有独立性(多进程运行,需要独享各种资源,多进程运行期间互不干扰),进程相关的数据结构是独立的,进程的代码和数据是独立的。
讲一个故事:一个富翁有十亿美金资产,他有三个私生子,为了让每个私生子都不知道另外两个私生子的存在,富翁给每个私生子都画了个饼“等我去世后,你就可以继承我的全部十亿美金资产”。
上面这个故事中富翁对应操作系统,三个私生子就是三个进程 ,而富翁给三个私生子画的饼就是进程地址空间。
因此进程地址空间其实是逻辑上抽象的概念,为了让每一个进程都认为自己是独占系统中的所有资源的。
进程地址空间:进程地址空间其实就是操作系统通过软件的方式,给进程提供一个软件视角,让进程认为自己会独占系统的所有资源(内存)。
操作系统要管理每个进程的地址空间,进程地址空间其实是内核的一个结构体struct mm_struct,mm_struct模拟下图一所示的各种区,mm_struct结构体里面的内容与下图二类似,这样就可以将地址空间划分成不同的区域。
![]()
如下图所示,进程的task_struct中有一个指针指向进程地址空间mm_struct,mm_struct通过页表与物理内存之间建立映射关系。页表本质是一张映射表,页表左边是虚拟地址右边是物理地址,在虚拟地址与实际物理地址之间建立映射关系。将程序加载到内存由程序变成进程之后,操作系统会给每一个进程构建一个页表结构。
我们看到的各种地址其实是在虚拟的地址空间中分配好的地址,该地址通过页表转换成对应的物理地址。
程序变成进程的过程:
程序被编译出来,没有被加载到内存的时候,程序内部是有地址的(源代码在链接的时候就用到了地址)。程序被编译出来,没有被加载的时候,程序内部有区域的划分。
使用命令readelf -S myproc就可以看到myproc可执行程序内各种区域的划分,如下图所示,Address栏就是对应区域的地址,Offset就是对应区域的偏移量,.data是数据区,.bss是未初始化全局变量区,.rodata是只读数据区,.text是代码区等等。
如下图所示,可执行程序经过编译后已经分成了不同的区域,并且编译程序的时候就是按照0x00000000到0xFFFFFFFF进行编址的,可执行程序中地址划分与内存中的绝对地址不同,其采用相对地址的方式(相对于整个可执行程序最开始,类似偏移量)。当程序加载到内存变为进程时,通过页表将可执行程序中数据的相对地址转换为内存中的绝对地址存储到内存对应位置中,相对地址的范围是0x00000000到0xFFFFFFFF的转换为绝对地址只有一段地址区域,因此在进程看来自己占有整个内存空间而实际中仅占用内存空间的一小部分。
可执行程序中的地址是相对地址,我们一般称这种相对地址为逻辑地址或虚拟地址,当可执行程序加载到内存中相对地址变为绝对地址,整个内存的地址是线性增加的,因此又称内存中的地址为线性地址。
进程的进程地址空间中地址与可执行程序编译后的地址完全相同(进程地址空间使得进程认为整个内存都是自己独享的),程序中各数据在进程地址空间中地址和可执行程序编译后地址也相同。CPU运行程序时访问数据要通过地址,首先通过进程PCB找到进程的进程地址空间,进程地址空间中的地址通过页表与内存中绝对地址建立映射关系,那么CPU就可以读取数据了。
总结:
1.可执行程序加载转换为进程,各数据的相对地址通过页表转换成绝对地址存储在内存对应位置,进程运行CPU读取数据根据其相对地址通过页表转换为绝对地址在内存中读取。
2.虚拟地址空间不仅操作系统有涉及,编译器也会涉及到。
问题(7.2节留下的问题):当父进程或子进程没有修改数据的时候,父进程和子进程打印的全局变量g_val值相同地址也相同,父子进程是共享该数据的。当父进程或子进程修改了数据,父进程和子进程打印的全局变量g_val值不相同地址却相同,也就是说父子进程读取同一个变量(因为地址一样),但父子进程读取到的内容却不一样,这是为什么呢?
答:父进程和子进程分别各自有mm_struct和页表,子进程的mm_struct和页表是以父进程的mm_struct和页表为模板的。当父进程或子进程没有修改全局数据的时候,父进程和子进程各自的mm_struct中都有g_val变量的虚拟地址且都为0x601054,变量虚拟地址相同页表相同映射到内存中的绝对地址也相同,因此读取到的g_val变量值相同;当父进程或子进程修改了全局数据,因为进程具有独立性(进程间互不影响),如果子进程将变量改了,父进程对该变量识别就会有问题,因此当操作系统识别到子进程(父进程)对数据进行了修改,会重新给子进程(父进程)开辟一段空间并且将原本空间的值拷贝过来,子进程(父进程)页表的右侧发生改变使得子进程mm_struct中0x601054地址经过页表映射指向新开辟的空间,子进程(父进程)对数据修改修改的是新开辟空间的数值,如下图所示,因此父进程和子进程打印的全局变量g_val值不相同地址却相同。
当父子有一个进程尝试去修改某个变量时,操作系统会给修改的一方重新开辟空间并且将原始数据拷贝到新开空间中,这种拷贝行为我们叫做写时拷贝。
总结:当父子进程都没有修改数据,那么父子进程用的都是同一份数据,即父子进程是共享数据的;当父进程或子进程修改了某数据,那么操作系统会通过页表将父子进程的该数据通过写时拷贝的方式进行分离。
注:
1.因为父子进程的代码部分一般是无法被修改的,因此父子进程是共享代码的,而父子进程的数据部分有可能被修改,因此父子进程的某些数据是不共享即分离的。
2.如果父进程或子进程的代码部分发生了变化,那么代码部分也需要写时拷贝进行分离。
3.前面讲过fork函数有两个返回值,接收返回值的同一个变量pid_t id为什么会有两个返回值?pid_t id是属于父进程栈空间中定义的变量,fork内部return会被执行两次,return的本质就是通过寄存器将返回值写入到接收返回值的变量中,当id=fork()的时候谁先返回,谁就要发生写时拷贝,所以同一个变量会有不同的内容值,从本质来说是因为父进程和子进程id变量的虚拟地址是一样的,但是其对应的物理地址不一样。
7.4.地址空间存在的原因
问题:为什么要有虚拟地址空间?
原因一:保护内存
如下图所示,如果直接将程序1和程序2加载到内存中,两个进程的PCB直接指向内存,那么如果进程1代码有bug,越界进行了修改,有可能修改了进程2部分的数据,甚至有可能修改了内存中操作系统的数据,因此直接让进程访问物理内存是不安全的。有了地址空间,在访问的时候就需要页表的映射关系,如果页表中有映射关系那么就说明对应内存的这部分空间是该进程的可以访问,如果有bug要越界进行修改或访问,那么该进程的页表中没有对应的映射关系,因此转化失败,将越界在页表中阻拦了。
有了进程地址空间,访问内存添加了一层软硬件层,可以对转化过程进行审核,非法的访问就可以直接拦截了。
原因二:通过地址空间对进程管理和Linux内存管理进行功能模块的解耦
如果进程要malloc申请空间,那么在地址空间中将堆区向上移动虚拟的扩大堆区,此时并不真的向内存申请空间,当需要申请堆区空间,操作系统不会立即申请,因为操作系统认为提前将空间申请好用户不一定会立即使用,那么该内存空间在这一段时间被占着不被使用相当于是浪费了。当用户访问申请的堆区空间时,操作系统再向内存申请空间,然后在该进程的页表中建立好映射关系。
原因三:让进程或程序可以以一种统一的视角看待内存
也就是说每个进程都可以认为自己有代码区、已初始化数据区、未初始化数据区、堆区、栈区等,并且程序中变量在地址空间中的地址可以不用改变,每次加载程序时只需要改变对应进程的页表右半部分即可,这样就可以通过改变页表将程序每次运行加载到内存不同的地方,这样方便以统一的方式来编译和加载所有的可执行程序,简化进程本身的设计与实现。
8.Linux内核进程调度队列
操作系统允许不同优先级进程的存在,且相同优先级的进程是可能存在多个的。
一个CPU拥有一个runqueue如果有多个CPU就要考虑进程个数的负载均衡问题。优先级普通优先级:100~139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
实时优先级:0~99(不关心)
活动队列根据不同的优先级,将特定的进程放入不同的进程队列中。
nr_active: 总共有多少个运行状态的进程。
queue[140]: 类似哈希数据结构,其中每个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级。
从该结构中,选择一个最合适的进程,过程是怎么的呢?
1. 从0下表开始遍历queue[140]。2. 找到第一个非空队列,该队列必定为优先级最高的队列。3. 拿到选中队列的第一个进程开始运行,然后依次往后运行其他进程。4. 遍历queue[140]时间复杂度是常数,但还是太低效了,使用位图来改进。bitmap[5]:位图,一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示140个队列是否为空,这样可以直接找到下一个非空队列,便可以大大提高查找效率。
过期队列过期队列和活动队列结构一模一样,当有新进程要入runqueue时,根据其优先级插入到对应的过期队列中。
CPU永远执行active指针指向的队列,active指针指向的就是此时的活动队列,expired指针指向的就是此时的过期队列,当此时活动队列上的进程都依次被处理完毕之后,将active指针和expired指针指向的内容进行交换,那么原本的过期队列就变为活动队列,CPU开始执行新的活动队列。
active指针和expired指针active指针永远指向活动队列。
expired指针永远指向过期队列。
总结在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法。