linux(进程)

冯诺依曼体系

此处的存储器指的是内存,不是磁盘。输入设备时键盘、话筒、摄像头、网卡、磁盘,理解为采集数据的设备。数据计算完毕之后要想办法将结果呈现给用户,这就需要输出设备,输出设备就是显示器、磁盘、网卡、声卡、音响。  输入和输出设别陈伟外围设备,外围设备一般都会比较慢一些。

为什么要有内存呢?于木桶效应,没有内存计算机也可以运行,但是效率会大大的减慢。 内存和磁盘都可以存储数据吗,但是内存相对于磁盘速度时最快的。 由在cpu计算a事件的同时预先将外设的数据帮到内存中,我们因为有了内存的存在,我们可以对数据进行预加载,cpu以后再进行数据计算的时候,根本就不需要访问外设,而只要直接伸手向内存要就可以了。整机的效率就不以外设为主而以内存为主,解决外设与cpu速度不匹配的问题。结论:再数据层面上,一般cpu不和外设直接沟通,而是直接和内存打交道。为什么我们的程序必须先被加载到内存中:体系结构决定的。数据在流动的过程当中一定是先从外设到达内存,并不能到达其它地方。外设在数据层面上也只会和内存打交道。 外设与内存之间的数据沟通是不需要cpu参与进来的。

操作系统

开机的过程就是将操作系统加载到内存当中,而操作系统是一款进行软硬件资源管理的软件。 那操作系统是如何对硬件进行管理的呢?  先描述再组织,你是什么硬件,硬件都有一些公共的属性,先将硬件以结构体的形式描述起来,然后将所有的硬件用链表联系起来。对硬件的管理就转换成对链表的增删查改。操作系统通过各种驱动程序对各种硬件的属性信息做提取,操作系统对所有的硬件进行抽象面向对象似的先描述成设备结构体,填充设别结构体构建设备结点,让后将所有底层所管理的设备全部以某种数据结构管理起来,最后对设别的管理就变成了对链表的管理。

所以我们也可以知道管理者和被管理者其实是不需要直接沟通的,既然没有直接的沟通,那管理者如何进行管理呢?管理的本质是对被管理对象的数据做管理,管理者是通过驱动拿到硬件的数据的。管理者是通过数据来做决策的。拿完之后,由于数据量比较大,那操作系统总不能拿着数据一个一个的去进行遍历,首先被管理的角色是有共性的,所以把所有被管理的对象通过面向对象的方式描述起来,构建成为对象之后第二步就是将对象信息经过信息采集填充好每一个对象,最后将被管理的对象通过某种数据结构管理起来,管理的建模就完成了。对设备软硬件的管理就变成了对链表的增删查改

操作系统为什么对软硬件资源进行管理?操作系统对下通过管理好硬件资源(手段),对上给用户提供良好(安全、稳定、高效、功能丰富等)的执行环境(目的)。

操作系统的目的是为了给用户提供非常良好的服务,但是操作系统又不会信任我们,他不会将自己的内部信息暴漏给我们,而是将自己的特定功能以接口的方式供我们使用的,这一批接口我们称之为系统调用,换而言之,系统对上提供服务的方式是通过各种各样的系统调用来提供的。系统调用其本质就是系统设计的c函数,通过调用函数来完成使用操作系统的功能,这样服务也提供了,但是系统的封装性也保证了。但是我们写的c语言这么都没用过系统调用的接口?因为系统调用接口使用的成本会比较高,于是就会有人基于系统调用接口做二次开发,这就形成了图形化界面、shell即工具集、c语言。而在这二次开发的上面还有一层就是我们的各种应用软件,这也是我们现在所在做的工作,应用层代码,距离底层硬件还有很远的距离。设计好的库函数可以可以通过调用系统调用的组合提供功能更加丰富的库函数,而通过库函数可以大大提高我们的工作效率。 应用软件->系统调用的二次开发->系统调用->操作系统->硬件驱动->硬件。

 操作系统在执行cpu代码的时候,操作系统会帮我通过一定的算法去预测未来哪些东西是可能即将被访问的,他会把数据预先加载到内存,这里加载之后cpu可以进行正常的存取,所以整机的效率就不是由cpu和外设的下限决定的,而是由cpu和内存的下限决定的。当我们正在执行第100行代码,那周边的代码或者数据有较大的概率被访问到的,提高内存的命中的效率。总之我们不要把信息流在体系结构中流动的过程想象成串行化的,它可以在实践层面上进行事件重叠,从而替身效率。

有时候我么写的软件只能在固定的平台上跑,比如安卓上编写的程序只能在安卓平台上跑,除了本省二进制编译器编译的二进制程序不一样,还有一个重要的原因是底层调用的接口不一样。如果一个程序采用了系统的调用接口,此时这个软件就不具备跨平台的能力。

进程

我们任何启动并运行程序的行为都是由操作系统帮助我们将程序转换成为进程完成特定的任务。 可执行程序进入到内存并不意味着程序就变成了进程。为了更好的管理载入到内存的程序,操作系统必须进行先描述再组织,操作系统会帮我们再内核当中创建一个数据结构对象 ,在linux中叫做task_struct,也叫pcb,提取了所有进程的属性,所以当磁盘的程序加载到内存当中的时候,每加载一个程序都在内核当中创建一个pcb。通过对应程序的pcb的指针成员可以找到对应的代码,而且pcb之间是按照链表链接在一块的。进程的管理并不是对程序的代码和数据做管理,而是对建在到内存的程序当中的代码和数据提取出进程相关的属性并生成结构体pcb,先描述起来,然后对他做管理。总结:进程就是内核关于进程的相关数据结构加上当前进程的代码和数据这两个部分。 

当一个可执行程序多次加载到内存,那么就会查看到多个进程。

我们可以通过ps axj查看进程,还可以通过ls /proc查看进程,proc目录是内存级别的文件系统,只有当操作系统启动的时候才会存在,磁盘上并不会存在对应的proc目录,进程目录里面可以查看你所指定 进程的进程相关属性。

getpid:获取当前进程的pid;getppid:获取父进程的pid

我们发现每一次执行改代码,进程的pid都会发生变化,但是父进程的pid都不会变化,于是我们查一下这个父进程。

父进程就是bash,说明bash命令行解释器本质上也是一个进程,而且命令行启动的所有程序都会变成进程,而该进程对应的父进程都是bash。我们在命令行上启动的程序最终他都变成了进程,相当于操作系统的层面上当我们./执行程序时,实际上时操作系统找到了我们的程序并把程序加载到内存当中,而且在内核当中创建了对应的pcb,并且将pcb插入到进程的管理链表当中,所以对进程的管理就变成了对链表的增删查改。所以这边也可以理解了,bash怕我们写的代码有问题,如果bash跑有bug的代码的化,bash挂了就全都挂了,那怎么样既能够让我的shell向后解释又可以软件出现bug的时候还能不受到影响呢?shell在执行命令的时候通过创建子进程的方式来执行我们对应的代码的,所以我们看到上面的子进程对应的父进程都是17450。

kill -9 19210:给pid为19210的进程发送终止信号,也就是杀死该子进程。如果我们通过该方法杀掉bash,会导致bash崩溃,如果bash不创建子进程而是自己去执行有问题的代码,bash也会出现这样的情况崩溃,无法去进行命令行解释了。我们一般启动的进程,都是bash的子进程,当然也有特殊情况。那么我们平常的ls、touch这些指令也是一样的,欺负进程也是pid为17450的bash的进程。

bash是如何做到创建子进程的呢?

./可执行程序就是将可执行程序变成进程跑起来,而fork()是一个在代码成面上创建进程的函数。

17450是bash的pid,当你运行你的程序时,你就成了bash的子进程,当你在你的程序内部又创建了子进程,该子进程和你的程序时父子关系,和bash是爷孙关系。我们也可以通过上面知道fork命之后会有两个执行流。

fork的返回值:cd 1

fork之后执行流会变成两个执行流,父进程和子进程谁先运行这是完全不清楚的,这取决于操作系统先调度哪个进程。fork之后的代码共享,通常我们通过if和elseif来进行执行流分流。fork如果进程创建成功,给父进程返回子进程的pid,给子进程返回0。

问题:这么一个函数会有两个返回值?变量ret的地址相同但是读取的内容确实不相同的?

当我想让父子进程各自执行不同的任务去进行运行?一般如下操作

在多执行流的情况下if和elseif可以同时成立,并且两个死循环也可以同时执行。

进程就是内核数据结构加上进程的代码和数据,一旦生成一个子进程,其实并不需要再复制一份代码和数据,而是在内核中再生成一份PCB,这跟PCB继承了大部分父进程PCB的信息,并且和父进程一样指向同一块代码和数据。进程在运行的时候是具有独立性的,任何一个进程出现故障或者异常,不会影响其它进程。父子进程运行的时候也是一样的具有独立性。 

如果有一个进程将数据修改了,他并不会影响另外一个进程,而且数据打印出来的地址是一样的。这个跟我们上面fork的返回值是一样的现象。这是因为当有一个执行流尝试修改数据的时候,OS会自动给我们当前进程触发:写实拷贝,就是当我们想写的时候,先将该数据拷贝一份,你想改去另一个地方改,别改原始数据,所以对我们来讲,写的位置发生变化,但并不影响读的操作。

fork的时候,操作系统内部为了维护进程需要给子进程创立PCB,父子进程代码和数据贡共享,由于代码编译之后就是只读的,所以父子进程不存在影响,而对于数据你改的话操作系统会让你在别的地方进行修改,不会影响原始数据的。 

fork为什么return两个值?当我们函数内部准备执行return的时候,我们的主体功能已经完成,return只是为了给调用该函数的主体一个结果。说明fork准备return的时候其子进程已经创建出来了,然后执行return语句的就不仅只有父进程了还有子进程都可以调度该语句,也就是fork之后代码共享,包括了这个ruturn的代码。所以return语句就执行了两次,有两个不同的返回值。虽然父子进程return了各自的值,但是我们只用一个变量来进行接收,这就是写实拷贝,ret出现了地址一样值不一样的现象,地址只是看起来一样,但是是被存入到不同的空间,这样证明了这里的地址一定不是物理地址,某则相同的物理地址不可能出现值不同的情况。

小小总结一下:fork之后首先要创建子进程pcb,子进程没有独立的代码和数据,默认创建子进程会共享父进程的代码和数据,代码只读所以共享对于父子进程都无影响,数据是以写实拷贝的方式各自私有一份。fork对应的返回值会有两个,应为当他执行return的时候其中一定是父子进程都执行了return,所以才会有两个返回值。

fork创建子进程,操作系统要为子进程创建地址空间,虚拟地址经过页表映射到物理内存当中。当你返回。返回的本质就是写入,谁先返回谁先发生写实拷贝,所以个同一个ret会有两个不同的返回值。进程调用fork之后,内核将为子进程分配新的内存块和内存数据结构,将父进程的内核数据结构内容拷贝至子进程(主要是pcb和地址空间),添加子进程到系统进程队列当中,fork返回开始调度器调度。

 进程的状态

阻塞:进程因为等待某种条件就绪,而导致的一种不推进的状态。进程没有被cpu调度,进程卡住了。阻塞一定是在等待某种资源。为什么阻塞?进程要通过等待的方式,等具体的资源被别人用完之后,再被自己使用。阻塞:进程等待某种资源就绪的过程。进程等待的资源,就是各种软硬件资源,比如网卡、显卡、磁盘、话筒、操作系统调用、对方发送数据。比如下载软件的时候,cpu调度该进程进行下载任务,这时网络断了 ,cpu就将该进程设置为阻塞状态,等待网络恢复再进行调度。总结:进程等待某种资源,因为对应的资源也是要被操作系统管理的。所有的外设都要被操作系统管理,所以外设都被操作系统进行先描述再组织,抽象成了结构体对象并组织成了数据结构,我们只需要再外设的结构体里面添加进程的队列指针,或者维护一个进程对列。但我们一个进程被调度的时候,就是拿着它的pcb通过pcb找到它对应的代码和数据去运行,如果发现它的代码当中有些资源没有就绪,我们只需要将进程控制块从cpu的特定队列中拿下来放到你所等待的某种资源所维护的排队中,这就叫做等待某种资源,此时该进程不会被cpu调度,处于阻塞状态。比如一个代码调用了cin,但是用户一种没有进行输入,该进程就处于阻塞状态。阻塞就是因为当前进程需要等待某种资源就绪不被调度,一定是进程task_struct结构体需要被OS管理的资源下排队,不要认为pcb只能在cpu上排队。 

挂起:系统的内存资源特别紧张的时候,操作系统会做一件事情,需要通过自己的一套算法,把占有内存的闲置的不被调度的进程对应的数据和代码从内存交换到磁盘当中,此时这部分的内存资源就被释放了。当该进程需要再被调度的时候,再将该进程对应的代码和数据放入内存当中。我们把将进程的代码和数据暂时性的由操作系统将其交换到磁盘,此时该进程就叫做挂起。

进程是R状态但这并不意味着该进程一定再cpu中运行,所以进程还要维护一个run队列。

进程是什么状态,一般也看这个进程在哪里排队。一个进程在cpu的run对列排队,他就是r状态。剩下的大部分其它队列都可以理解成阻塞状态。

此时我们查到的s状态时阻塞状态的一种,以休眠状态进行阻塞的。当前进程并没有一直在cpu的运行队列当中,是因为printf导致等待某种资源,查到的是s。cpu的运行速度非常快,不允许你再持有cpu的同时还在等外设,所以在切换的过程中,就将该进程放在了外设的等待队列当中。但是为什么我们查询了进程状态多次都是s状态,因为cpu执行该行代码的时间是一瞬间的事情,大多数时间都是s状态。但是当我们注释掉printf("hello wjj")代码的时候,为什么查询的状态一直是r,因为当前的代码当中没有任何需要访问资源的代码,只有while循环判断,而while循环判断就是一种计算,所以当前代码就是一个纯计算的代码,所以他在调度的整个进程声明周期里面只会用cpu资源,只要被调度就是r状态。

进程在r状态并不直接代表进程在运行,而代表该进程在运行队列中排队,这个队列并不是由cpu进行维护的,而是由操作系统进行维护的。操作系统位于内存当中。

s状态属于休眠状态,等待所等待的资源就绪。休眠本质是一种阻塞状态。是可中断休眠,也就是可以终止,比如说按ctrl+c。

d状态属于休眠状态,但是是不可中断休眠,也是一种阻塞状态。如果进程处于d状态,那就无法被杀死,即使是操作系统也无法杀死进程。比如有一个进程是需要往磁盘写入数据,此时进程处于磁盘外设的等待队列当中,如果处于s状态,当内存资源紧张的时候操作系统是有权力将该进程杀死的,杀死了的话,当磁盘写入数据失败需要跟进程进行反馈的时候,发现进程已经不见了,此时不知道该如何处理,所以就产生了d状态,只有等d状态自己苏醒过来才可以进行杀死。一般出现一个d状态就证明系统快要不行了,因为d状态一般一瞬间就完成了,如果当前可以观察到d状态,就证明了外设资源已经卡到不行了,压力很大,宕机了,d状态有可能内存资源是够的,但是外设资源比较紧张。

T状态时暂停状态,也是一种阻塞状态,并不是进程而是暂停进程。

进程这边的暂停可能不是因为等待某种资源,而是用户让它主动暂停的。

我们的进程查看状态的时候如果带了加号,证明它是在前台运行的,前台运行的可以ctrl+c终止,不带加号证明是在后台运行的,这时可以正常执行shell指令,但是进程在后台继续执行自己的代码。前台进程可以采用ctrl+c,但是后台得采用kill -9 。

t状态也是一种暂停状态,休眠时等待某种资源,是真正的阻塞状态,而t通常是操作系统在不杀掉进程的情况下停止某种行为。调试过程中运行代码并且在断点处停下来的本质就是让进程暂停,此时进程处于t状态。

X状态就是死亡状态,是存在的,但是一般查不到,因为X是一个瞬时状态。

Z状态是僵尸状态,

我们为什么创建进程?因为我么要让进程帮我们办事,分为两种心理状态。一种是关心结果,二是不关心结果。main函数的返回值叫做进程退出码,任何在命令行启动的子进程都是bash的子进程,父进程bash创建子进程让子进程帮我去办事,事情办得怎么样是通过子进程的退出码查看的。

如果一个进程退出了,立马X状态,立马退出,你作为父进程,有没有机会拿到退出结果呢?Linux当进程退出的时候,一般不会立马彻底退出,而是维持一个状态叫做Z,也做僵尸状态——方便后续父进程(OS)读取该子进程退出的退出结果。

如何让我们看到僵尸状态呢?子进程退出,并且不对子进程进行回收 。加入创建了100个子进程,现在都退出了但是不回收,这100个进程都要暂用内存资源。因为如果我不释放,操作系统为了维持对应的状态就要维护相关的数据结构,说白了就是pcb,时间长了就是内存泄漏问题,所以我们必须得回收。维护僵尸进程的意义就是为了让我们对应的父进程读取它的退出码知道它的退出状态。一旦回收了进程,进程就会瞬间从Z状态回到X状态,进而操作系统才能真正的释放系统的内存资源。

总结一下:不同的进程在不同的执行生命过程之中是要处于不同的状态的。进程可能在疯狂占用cpu的资源,也有可能当前在某些资源当中进行等待,所以进程是一个运行当中的事情,但这并不意味着进程一定在cpu上,该运行是和在磁盘当中的程序相比较的,被操作系统管理、调度或者挂在某个阻塞队列当中等待条件就绪的,这就可以体现出进程的动态运行特征。阻塞就是进程等待某种条件就绪而导致代码不推进,就像某个程序卡住了,卡住了就叫做阻塞,阻塞一定是在等待某种资源,进程要通过等待的方式等具体的资源或是被其它进程用完之后再被自己利用,要么就是等待这些资源自己就绪好从而可以被我使用,所以说阻塞时进程等待某个资源就绪的过程。我们知道系统中存在大量进程,操作系统要管理就得先描述再组织,同样的不考虑软件,在硬件的角度,计算机有磁盘、网卡等各种外设硬件(除了cpu和内存),外设也要被操作系统管理,所以再系统当中一定存在为了管理这些硬件所对应的内核数据结构,因为先描述再组织,而这些内核数据结构当中每一种结构都可以维护一个进程的等待队列,不仅仅只有cpu会维护,所以当我们进行操作系统调度的时候,等待cpu的时候就把进程的pcb结点挂到cpu的等待队列里面,诸如此类的还有键盘、网卡对应的进程等待队列,所以操作系统调度的本质就是将进程投递到不同的队列当中,同时cpu也可以快速选择系统当中最合适的进程来被调度。所以在不同的队列本质就是针对进程的不同状态对进程进行划分,只有值得运行的、需要在运行队列当中的进程才是值得被调度的,如果进程当前在等待某种外设资源基本都要处于阻塞状态,除此之外就一定会在运行对列当中的等待。进程阻塞就是因为进程的资源需要让操纵系统将自己的状态改成了非运行状态,等资源就绪的时候我们再由操作系统把我们放到运行队列中再运行。 

当进程等待某种资源就绪处于阻塞状态并不能被立即调度的时候,换句话就是进程对应的代码和数据不会被执行,与其说将代码和数据放在内存里面暂用资源,倒不如将代码和数据切换到磁盘,当资源就绪之后再将代码和数据换入进来继续执行,将进程的代码和数据展示交换到磁盘的过程我们称之为挂起。挂起和阻塞并不冲突。凡是处于r状态的进程并不一定在cpu上持有cpu的资源,r状态更确切地说就是我已经准备好了,可以随时被调度。S是可中断休眠状态,其实也属于阻塞状态的一种,可能因为等待某种资源比如说向显示器打印,操作系统必须在显示器打印完成之后才可以继续向后面运行,所以将进程数据放到外设对应的等待队列当中展示不调度,等向显示器打印完成之后再调度。D是不可中断休眠,不可中断 休眠,通常在等待高IO的时候进程要将自己设置为D状态,为什么我们进不到,因为我们IO的场景以及IO的数据量都太小了,如果IO的数据量很大的话,就有可能可以看到D状态的,但是一般在系统中看到D状态并不是什么好事情。T暂停状态通常是为了满足某些特定的场景让我们进程处于停止运行的状态,诸如像一些没有权利向显示器打印数据的进程如果强制向显示器打印,操作系统就很有可能禁止进程打印,就将进程暂停了。X 状态时死亡状态,进程等于内核数据结构加上该进程对应的数据和代码,所以真正当进程死亡的时候曾经的内核数据结构加上代码和数据就应该被释放掉。对于进程的运行,如果我们关心进程的运行结果的话,就必须甄别出来进程运行完的结果到底怎么样?我们写的函数的return值做为退出码通常可以做为进程运行结果的一个评判。当我们的进程运行完了之后,此时进程无法立即退出,而是处于Z状态的僵尸状态,这个僵尸状态主要是为了方便我们操作系统后续读取该进程的退出结果,需要有人进行回收该僵尸状态该进程才能够彻底退出,坏处就是如果僵尸状态一直不被回收可能会带来内存泄漏,所以读取完进程的退出结果之后,还要想办法回收进程以释放内存。

孤儿进程

现在我们通过fork写一个父子进程,父进程运行一段事件之后直接return退出,我们会看到此时子进程还是s状态(这里可以理解成r状态,但是子进程需要往显示器上打印,所以大多时候在显示器外设的进程等待队列当中),而父进程却已经查不到了,为什么没有看到父进程处于僵尸状态呢?上面我们看到了如果是子进程退出父进程还在的话,子进程是处于僵尸状态的。但在这里却见不到父进程的僵尸状态。 因为bash爷爷进程会回收该父进程的僵尸状态。其二还有一个要注意的地方是, 如果父子进程当中,父进程先退出,而子进程暂时并没有退出,那么子进程在父进程退出之后要被1号进程领养(让1号进程成为父进程),而1号进程我们可以称之为操作系统,被领养的进程我们称之为孤儿进程。并且此时子进程被OS领养之后,它将会有前台进程变成后台进程,虽然不影响我们在命令行执行指令操作,但是无法通过ctrl+c来结束进程。可以kill -9 pid也可以通过killall+运行进程的名称来将其杀掉。

为什么父进程退出时,子进程如果还在运行会被OS领养?因为不被领养的话,这个孤儿没有父进程,如果这个孤儿进程推出了,它由谁来进行回收其僵尸状态呢?没有人回收它的话,一定会造成内存泄露的。

环境变量

我么编译的代码和系统上的指令本质上都是可执行程序,为什么我们写的代码编译之后要带./才可以运行?./是通过相对路径来执行我们的可执行程序,我们可以通过相对路径./mytest同时也可以通过绝对路径/root/mydir/mytest来运行我们写的编译好的可执行程序。系统指令不用带这种路径是因为我们系统种存在环境变量,这个环境变量能够帮助我们通过该变量在系统的特定路径下搜索到这些系统指令的,执行一条命令的前提一定是先找到它。

echo时打印字符串,$符号时读取该变量的内容。系统指令会在该环境变量指定的路径种寻找,找到了就不用带路径,直接自动执行了。

还有一个方法就是将可执行程序拷贝到当前PATH环境变量的系统默认路径下,也是可以的,在Linux当中,把可执行程序拷贝到系统默认路径下,让我们可以直接访问的方式相当于Linux下软件的安装。rm该可执行程序就相当于卸载。

env可以查看为当前用户所配置的环境变量有哪些,对于不同的用户,环境变量的个数可能有所不同,并且不同用户对于相同的环境变量内容也可能会有不同。

指针就是一个地址,凡是具有指向能力的数据都可以叫做指针,标定内存中的一处位置。指针变量是一个变量,需要在内存当中开辟4/8个字节的空间,具有数据存储和被修改的能力。

int main(int argc, char* argv[], char* envp[])//main函数其实最多可以传递三个参数。char* envp[]其本质是char的二级指针,所以我们可以通过指针数组char* envp[]的方式来获取,同样也可以通过二级指针environ来获取环境变量。                                          
{
    for (int i = 0; envp[i]; i++)//envp是一个表结构,是一个传递给当前进程的换进变量表。
    //该表的最后一个有效指针的下一位是NULL,所以for循环执行到最后一个有效数据的下一个时候,envp[i]就是NULL为假跳出循环。                             
    {
        printf("envp[%d]->%s\n", i, envp[i]);
    }
    return 0;
}

由上可知:我们的进程内部本身就有环境变量。但是以上两种获得环境变量并不是主流,主流的是通过getenv函数获取环境变量,本质还是还是在传给我的环境变量表中做字符串匹配的工作,把要到的字符串拿到,仅此而已。

所以我们的程序当中是可以运用系统给我们环境变量,从而写出与系统强相关的代码。

比如上面实现了根据当前的用户来决定是否执行程序。

环境变量本质就是一个内存级的一张表,这张表由用户在登录系统的时候,给特定用户形成属于自己的环境变量表。环境变量中的每一个都有自己的用途,有的时进行路径查找的,有的是进行身份认证的,有的是进行动态库查找的,有的是用来进行确认当前路径等等,每一个环境变量都有自己的特定应用场景。环境变量每一个元素都是kv的。那么环境变量的数据,是从哪里来的呢?从系统的相关配置文件中读取,当我们用户登录成功之后,我们的系统会重新读取配置文件,把配置问件当中的脚本自动一执行,把我们自动形成当前的环境便变量。环境变量这张表由shell内部来维护,shell此是一定是已经加载到内存当中的进程,bash创建子进程执行命令的时候还可让父进程给子进程传参,可以将它维护的环境变量表传给子进程。

上述我导入环境变量其实是我把环境变量添加到了shell维护的环境变量表char* envp[],也就是在该指针数组当中再次添加一个元素。这张表是在用户登录的时候,由已经加载到内存的shell进程从系统的配置文件当中读取的环境变量表并进行了初始化。环境变量是可以被相关的子进程继承下去的——环境变量具有全局属性。

不带export的变量导入方式属于本地变量,只在shell内部有效,无法被shell创建的子进程通过getenv获取,解决方式就是export 变量名,这一步就是将本地变量添加到环境变量表里面。本地变量和环境标量都是会被shell记录的。

int main(int argc, char *argv[])//main函数的数组的这两个参数。argv是一个指针数组,最后一个有效数据的下一个是NULL。argc是argv数组的元素个数。
{
    for(int i=0;i<argc;i++)
    {
        printf("argv[%d]->%s\n",i;argv[i]);
    }
}

我们在命令行当中输入的字符串,以空格做为分隔符就可以形成一个一个的字串,第一个字串是可执行程序,后面的字串统称为参数选项。我们可以将这个看成一张表,注意和环境变量表区分,这张表是由bash制作的,但是是子进程拿过去用的。
命令行选项本质就是以字符串的形式通过命令行传递给了我的程序,对应程序的内部对选项做判断,然后可以让同样的一个软件携带不同的选项就可以表象不同的现象或者执行不同的结果。命令行参数何以让我们编写出多选项的程序。

进程优先级

权限是能或者不能的问题,而优先级是谁先谁后的问题。

为什么会有优先级?因为cpu资源有限了,大量的进程竞争有限的cpu资源。PRI代表当前进程的优先级,此值越小优先级越高。NI代表优先级的修正数字。UID是用户的id,是操作系统用来表示用户的,而用户使用用户的名字来表示彼此的。调度器是需要以较为平均让我们的每一个被调度的进程都能在特定的时间段内享有cpu资源,不能对个别的进程有偏好,所以这也注定了我们调整进度的优先级的尺度不会特别大。一般调整优先级是采用调整NI值。

"top"+"r"+"14571(想要调整的进程的pid)"+"-20"

我通过同样的方法调整进程的优先级到90,却看到的是下面的现象,不是60+19=79而是80+19=99。以为newpri=oldpri(80)+NI(老的优先级都是从80开始的)。

进程之间具有独立性。如果计算机有多个cpu,就可以让多个进程在多个cpu下分别同时运行,这称之为并行。如果只有一个cpu,那么在任何一个时刻只有一个进程在运行。多个进程在一个cpu下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。

set:打印本地和环境变量,比env打印的明显要多一些。unset:取消某一个本地变量

进程地址空间

子进程对全局数据进行了修改,并不影响父进程。这里发生了写实拷贝,也可以从进程具有独立性这个方向来进行解释。进程是内核数据结构+对应的代码和数据 ,进程的独立性就要求内核数据结构、代码数据保证独立性,采用写实拷贝实现对数据最修改不影响其它进程。写实拷贝将一个变量变成两个变量,为什么地址确实相同的呢?所以这个地址不可能是物理地址,否则读到同一个地址的变量不可能是不同的数值。我们在语言层面用的地址并不是物理地址,该地址叫做虚拟地址or线性地址。 
数据最终存放的位置都是物理内存当中


当子进程要修改g_value的时候,操作系统会在内存当中重新申请一块空间,然后把g_value的值赋值过来,操作系统再重新构建一下页表的映射关系,不要让子进程再指向父进程曾经的数据了,指向新的物理内存。由于修改的是页表的value(物理内存地址),也就是页表的key值(虚拟地址)不会发生改变,而子进程的虚拟地址是继承自父进程的,这就实现了相同的虚拟地址访问不同的物理内存空间,实现了进程数据的独立性。
对于代码pid_t id = fork():fork在返回的时候,父子都有了,return了两次,id是定义的局部变量,返回的本质就是写入,所以谁先返回,谁就让os发生写实拷贝,所以同样的一个id值会有两个不同的数据。

为什么要有进程地址空间?进程地址空间是发展的产物,最开始是没有的,但是会存在野指针问题导致访问到其它进程进而导致另一个进程出现故障,无法保证进程之间的独立性。为了解决这个问题,就有了页表和地址空间的概念,任何一个被cpu读进来的数据在进行访问的时候,不是直接访问物理内存,而是先通过地址空间结合页表的映射去访问物理内存。加了映射,就存在是否可以成功映射的问题,可以将不合理的访问进行拦截,这个方式就是通过添加一层软件层的方式让我们在访问之前物理空间进行保护。

首先虚拟地址在过来的时候,还会伴随着操作的指令,告诉os这次操作是读还是写,所以当我们出来一个地址并且携带其操作的时候,可以现在mm_struct中先验证,比如你问的是代码去的地址,可是配套的地址是栈区,操作系统就会直接进行拦截,因为我们有区域的划分,直接从区域上判定你当前是否越界。如果访问的确实是自己虚拟地址空间的某些代码和地址是,页表不仅是映射还有权限,表示对应空间是可读的还是可写的,所以可以根据你的操作再去限定你。

代码是只读的,凭借的就是代码区域再页表映射的过程当中权限写的都是r,所以映射到物理内存的时候我们只能进行读取。

向操作系统申请内存,是在需要的时候给我,而不是立马给我。因为操作系统一般不允许浪费或者不高效,而我们申请内存并非就是立马使用,在我申请成功之后和使用之前就会有一段时间窗口(这个空间没有被正常使用,但是别人用不了,也就是闲置状态),导致内存的使用率存在浪费,所以操作系统在进行malloc申请的时候,只在虚拟地址空间进行申请,然后在页表上填上虚拟地址,但是物理地址什么都不写,物理内存暂时也不去申请,这段映射关系也不需要去维护,因为映射关系也只完成了一办,然后malloc可以返回了,过了一段时间,当进程开始尝试对该空间进行写入的时候,才会开始去申请物理空间,并且把页表的映射关系完全构建出来,然后在去进行后续的写入。我们把操作系统当我需要内存才给我进行申请或者检测对应的代码和数据不在内存当我需要访问的时候再跟我换入这样的操作系统机制叫做缺页中断。因为有了页表的存在,我现在并不关心我们的数据在物理内存的具体哪个位置,只要我们可以通过页表的映射访问到就可以了。进程再怎么去申请空间按调整空间,只需要调整一下页表,物理内存只需要关心哪些空间被占有哪些空间闲置,然后哪个进程要空间了要多少。页表的存在也实现进程管理和内存管理的解耦合。

我们代码编译成为可执行程序,没有被加载到内存,请问我们的程序内部有没有地址呢? 

是由地址的,可执行程序有自己对应的格式,在磁盘上形成可执行程序的时候,就有了代码段、全局段等等。当加载到内存的时候是可以分批式的将自己的各个数据段分别加载到自己的内存空间当中。源代码被编译的时候,就是按照虚拟地址空间的方式对代码和数据早就已经编好了对应的编制。所以虚拟地址这样的策略不知会影响os,还要让我们的编译器遵守这样的规则。换而言之,我们的可执行程序已经按照虚拟地址空间的方式把代码变好了,它的函数、代码、变量都有它的地址,并且是按照虚拟地址空间的方式给我们已经变好了。cpu执行代码的时候,读到了数据中涵盖了某个地址(比如第三行代码要调用一个自己实现的函数),这个地址也是虚拟地址,让后cpu拿到这个虚拟地址再通过页表映射拿到真正存在于物理内存的函数。有了进程地址空间,不管其它物理内存的哪一个具体的位置,都可以让进程以统一个视角看待自己的代码和数据

进程的代码和数据必须一直在内存当中吗?不一定,当我需要那一部分的代码数据再加载到内存,不需要的代码和数据就会释放掉,然后通过页表重新建立映射关系。打游戏手机发热就是大量的数据再内存与磁盘之间发生交换已经网络的io造成的。

总结一下:虚拟地址空间就是操作系统内部为进程创建出来的一种具体的数据结构对象,用来让进程以统一的视角来看待对应的物理内存。因为有了虚拟地址空间的存在,它可以让我们的内存管理和进程管理独立开来,我们还可以在磁盘当中编译程序的时候就把我们编译好的程序以地址空间的方式也排布好,这样加载到内存时,当我们cpu进行读取识别时,cpu内部的地址全都是虚拟地址,让后经过页表映射找到对应的指令之后在进行读取,它内部滚动转换的过程就转起来了。地址空间可以很好的保护物理内存,实现进程管理内存管理解耦合,让进程已统一的视角看待内存。通过在内存当中定义mm_struct数据结构,这个数据结构里面充满了大量的区域,所谓区域就是start和end的指针,用来限定代码区数据区等各种区域的起始和结束,让后通过也表映射到物理内存。

进程控制

为什么不能创建子进程之后直接拷贝一份父进程的代码和数据呢?还要采用写实拷贝这种方法多此一举?因为os是不允许任何内存资源的浪费的。写实拷贝在这里时一种资源筛选,当子进程尝试做修改,即子进程要用的空间才进行分配,所以写实拷贝是一种按需申请资源的策略。写实拷贝的时候数据层面才能发生写实拷贝,对于代码虽然不能被直接修改,但是可以被整体替换。系统中有太多的进程或者实际用户的进程数超过了限制会导致fork失败。

$?只会保留最近一次执行的进程的退出码。所以连续执行两次$?指令的时候,第二次$?保留的退出码所代表的进程是第一次执行$?指令这个进程。系统当中有一批默认的退出码及其相应的字符描述。

上面展示的是c语言对应的退出码的字符解释

但不是所有的指令或者可执行程序都会去遵照c语言的退出码

退出码情况分类:

a正常执行结束(1、结果正确 2、结果错误)

b奔溃了(进程异常)奔溃的本质:进程因为某些原因,导致进程收到了来自操作系统的信号。

如何理解进程退出呢?

 进程退出就说明os内部少了一个进程,os就要释放进程对应的内核数据结构+代码数据(如果独立的化)。

进程退出有哪些方式呢?

main函数return,其它函数return仅仅代表该函数返回,并不能代表进程退出。而进程退出本质是main执行流执行退出

exit函数退出,exit(int code)代表的就是进程的退出码,等价于main函数的return退出码。在代码的任意地方调用该函数都表示进程退出。exit是c语言的库函数。还有一个类似的系统调用接口就是_exit,使用的时候可以等价于exit。

exit和_exit的区别:

exit函数会执行关闭文件冲刷缓冲区的动作,但是_exit函数直接让操作系统干掉进程,不会对缓冲区进行任何的刷新。可以理解成为exit封装了_exit。要杀死一个进程只可能是os而不是用户,所以用户想要杀死进程最终还是要采用系统调用的接口来让系统杀死该进程。

由此可知缓冲区一定不在操作系统内部,否则的话exit和_exit都应该可以刷新出来“hello wjj"。

进程等待

为什么进行进程等待?一是避免内存泄漏(目前一定要做的),二是获取子进程执行的结果(如果必要),起始获取子进程的结果可以转换成为获取信号+退出码的方案

什么是进程等待?就是通过系统调用,获取子进程退出码或者退出信号的方式,顺便释放内存问题。

  #include<stdio.h>    
  #include<unistd.h>    
  #include <stdlib.h>    
  #include <string.h>    
  #include <sys/types.h>    
  #include <sys/wait.h>    
  int main()    
  {    
      pid_t id = fork();    
      if(id == 0)    
      {    
          //子进程    
          int cnt = 5;    
          while(cnt)    
          {    
              printf("我是子进程,我还活着呢,我还有%dS, pid: %d, ppid%d\n", cnt--, getpid(), getppid());    
              sleep(1);    
          }    
          exit(0);    
      }    
      //父进程    
      sleep(10);    
      pid_t ret_id=wait(NULL);    
      printf("我是父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d",getpid(),getppid(),ret_id);
      sleep(5);
  } 

父进程在wait的时候,子进程退出了父进程就通过wait把子进程的z状态回收了。但是请问子进程没有退出的时候,父进程在干什么?父进程一定不是运行状态,否则会立马执行printf命令,所以父进程也一定不在运行队列当中,只能在阻塞队列当中,父进程只能一直在调用wait或者waitpid进行等待——阻塞等待,此时父进程pcb会链接到子进程的某个类似于task_struct* parent的成员变量当中,可以理解成父进程在子进程的等待队列当中,当子进程一旦推出了,os就拿着子进程pcb当中的panrent指针找到父进程,将父进程的状态从s变成r并且放到了运行队列当中,父进程开始继续执行wait或者waitpid,就将子进程回收了。阻塞等待有三种结果:1、好了;2、出错

//父进程
while(1)
{
    int status = 0;
    pid_t ret_id = waitpid(id, &status, WNOHANG); //wait no夯住了
    if(ret_id < 0)//出错了
    {
        printf("waitpid error!\n");
        exit(1);
    }
    else if(ret_id == 0)//子进程还没有好,你再等等
    {
        RunTask();
        sleep(1);
        continue;
    }
    else//子进程退出了
    {
        printf("我是父进程,等待子进程成功, pid: %d, ppid: %d, ret_id: %d, child exit code: %d, child exit signal: %d\n",getpid(), getppid(), ret_id, (status>>8)&0xFF, status & 0x7F);
        break;
    }
}
//优化
while(1)
{
    int status = 0;
    pid_t ret_id = waitpid(id, &status, WNOHANG); // 夯住了
    if(ret_id < 0)
    {
        printf("waitpid error!\n");
        exit(1);
    }
    else if(ret_id == 0)
    {
        RunTask();
        sleep(1);
        continue;
    }
    else{
        if(WIFEXITED(status)) //若该宏为真,表明没有收到信号,正常结束,通过WEXITSTATUS(status)查看退出码
        {
            printf("wait success, child exit code: %d\n", WEXITSTATUS(status));
        }
        else//收到了信号,异常终止,现在查看退出码已经没有意义了,选择查看退出信号即可。
        {
            printf("wait success, child exit signal: %d\n", status & 0x7F);
        }
        break;
    }
}

如果我们不想再watipid处卡住呢?采用非阻塞轮询, 隔一段时间就做一次子进程的状态检测。非阻塞轮询的结果有三种:1、好了;2、我还没有好,你再等等;3、出错了。在进程等待的时候我们可以选择采用非阻塞的等待方式,waitpid的第三个参数设置为WNOHANG。

终止信号如果是0:没有收到信号,正常退出。正常退出才有必要看退出码,若退出码是0,就是代码正常跑完结果正确。退出码非0就表示代码正常跑完但是结果不正确。

终止信号如果是非0:收到了异常的信号,说明是非正常退出,此时退出码已经没有意义了。

两个:宏定义退出码,完善了一下代码,其中有一个函数指针的问题

进程的程序替换

创建子进程的目的是什么?目的就是为了让子进程帮我执行特定的任务1、子进程执行父进程一部分代码 ,fork之前这部分代码已经存在了,只不过是通过if来进行分流让子进程单独去执行这一部分的代码,但这部风的代码还是归属于父进程的;2、如果子进车给想要执行一个全新的程序代码呢?可以进行程序的替换。进程的程序替换并没有创建新的进程,只是将新的程序加载到当前进程的代码和数据段。 当创建子进程的时候,我们是先把内核的数据结构创建出来,然后再在需要的时候把代码和数据load到内存里面,我们把这种行为称之为加载。加载就是现在内存里面帮我们创建数据结构之类的额,然后cpu调度进程的时候最开始喂给进程的根本就不是main函数,而是excel,把用户想运行的命令传递给execl这样的 接口,然后换入到内存里,最后这个进程就变成新的程序了,也就是用户想要的进程了。但是后续代码为什么看不到printf end的内容了呢?因为当我们执行完execl程序替换之后,新的代码和数据就被加载了,后续的代码属于老代码,直接被替换了,没机会执行了。所以程序替换是整体替换,不能局部替换。程序替换只会影响调用的进程,即便是父子进程,如果是子进程发生了替换,其实并不会影响父进程。父子进程各自拥有独立的pcb、虚拟地址空间和页表,虽然代码和数据父子进程可能是共享的,主要是页表指向的位置是一样的,当子进程想进行程序替换的时候,子进程会加载新的代码和数据,虽然再替换之前子进程的代码和数据是指向父进程的,操作系统会为我们自动发生写实拷贝,将我们的代码数据进行区分,所以子进程加载新的程序的时候,子进程先进性写实拷贝,然后将子进程新的代码和数据重新建立映射,最后子进程开始执行新的程序了。子进程执行的是全新的程序,新的代码,说明写实拷贝在代码区也可以发生。excel是函数,函数就有可能会执行失败,失败了就会返回-1替换没有成功,就只能执行原先代码的后续代码。如果excel这样的函数如果替换成功,不会有返回值,如果替换失败了,一定有返回值=》如果失败了,必定返回=》只要有返回值就失败了=》不用对该函数进行返回值判断,只要继续向后运行一定是失败的。 

int execl(const char *path, const char *arg, ...):l是list的意思,path代表的是你想执行谁,因为是path所以要带路径,这一步是为了找到可执行程序;后面的一系列参数都是加载执行它。

int execv(const char *path, char *const argv[]):v是vector的意思,和execl差不多用法。 

int execlp(const char *file, const char *arg, ...):p代表我们有自己指定的path,当我们执行指定程序的时候,只需要指定程序名即可,系统会自动在环境变量path中进行查找。execlp("ls","ls","-l","-a","-n",NULL);第一个ls是我要执行谁,第二个ls是我想怎么执行它。

int execvp(const char *file, char *const argv[]):带p就说明会自动去系统路径下寻找,带v是说明不以列表的方式传递参数,以数组的方式统一传递。

int execle(const char *path, const char *arg,..., char * const envp[]):第三个参数是自定义环境变量,当前我们调用环境变量的时候,想让我们使用默认环境变量就不用管这个参数,如果不想使用父进程传递的环境变量,想使用自定义的环境变量,就需要传递该参数来实现。


环境变量具有全局属性,可以被子进程继承下去。怎么办到的?因为所有的指令都是bash的子进程,而bash执行所有的指令都可以通过execl去执行,我们要把bash的环境变量交给子进程 只需要调用execle,将环境变量做为最后一个参数交给子进程不就可以让子进程拿到了吗。main函数也有环境变量的,所以main函数最终是由execle来调用的,execle将环境变量也给main函数传递了一份。

进程替换不一定只有c语言实现的可执行程序可以执行,只有是可以被操作系统调用成为进程的可执行程序,不管是什么语言实现的,都可以实现替换。

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define MAX 1024
#define ARGC 64//命令最多拆分成64个字串
#define SEP " "

int split(char *commandstr, char *argv[])
{
    assert(commandstr);
    assert(argv);

    argv[0] = strtok(commandstr, SEP);
    if(argv[0] == NULL) return -1;
    int i = 1;
    while((argv[i++] = strtok(NULL, SEP)));
    //int i = 1;
    //while(1)
    //{
    //    argv[i] = strtok(NULL, SEP);
    //    if(argv[i] == NULL) break;
    //    i++;
    //}
    return 0;
}

void debugPrint(char *argv[])
{
    for(int i = 0; argv[i]; i++)
    {
        printf("%d: %s\n", i, argv[i]);
    }
}

int main()
{
    while(1)
    {
        char commandstr[MAX] = {0};
        char *argv[ARGC] = {NULL};
        printf("[wjj@mymachine cur_path]# ");//打印命令行提示符
        fflush(stdout);//由于上面没有加\n,所以导致并不能刷新缓冲区。
        char *s = fgets(commandstr, sizeof(commandstr), stdin);//从stdin从拿去字符串存在commandstr字符数组当中,fgets函数会也会读取到\n。
        assert(s);//debug的时候存在,但是release会将assert裁剪掉,在有些编译器下如果一个变量没有使用过,时会有报警的,所以有了虾米那一行代码。
        (void)s; // 保证在release方式发布的时候,因为去掉assert了,所以s就没有被使用,而带来的编译告警, 所以这里虽然什么都没做,但是可以让该行代码充当一次使用。这是一个变成技巧。
        
        commandstr[strlen(commandstr)-1] = '\0';//假设字符串是abcd\n\0,strlen的长度是5,我们要的下标是四
        int n = split(commandstr, argv);//将字符串进行切割"ls -a -l" -> "ls" "-a" "-l"
        if(n != 0) continue;
        //debugPrint(argv);
        // version 1
        pid_t id = fork();
        assert(id >= 0);
        (void)id;

        if(id == 0)
        {
            //child
            execvp(argv[0], argv);
            exit(1);
        }
        int status = 0;
        waitpid(id, &status, 0);
        //printf("%s\n", commandstr);
    }
}

Linux的'.'和'source'这两条命令是让后面的配置文件立马生效,比如. .bash_profie或者source .bash_profile。

批量化注释:命令模式下ctrl+v进入到visual block模式,然后通过不断的按j来选择要注释的区域,然后输入I(大写的i)+'//',最后按esc就可以了。

批量化取消注释:命令模式下ctrl+v进入到visual block模式,然后通过按l外加方向键(方向键是hjkl)选中要取消注释的区域,最后按d就可以了。

cpp的后缀和可以是cc或者cxx 

typedef void (*func_t)(); //将函数指针重定义为func_t
func_t other_task[TASK_NUM] = {NULL}; //函数指针数组,数组的元素类型是void func()这种函数类型的指针

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值