目录
冯诺依曼
输入设备有:键盘,鼠标,摄像头,磁盘,网卡... 输出设备:显示器,声卡,磁盘,网卡... 中央处理器:CPU 存储器:内存;设备之间通过总线连接起来进行数据交互,数据从一个设备到另一个设备的过程,本质上是一种拷贝;
其中设备之间最慢的拷贝决定了计算机整体的效率(木桶效应);没有存储器,输入设备的数据直接拷贝到CPU中,外设拷贝数据很慢而CPU处理数据很快:CPU大多数时间都在等待输入设备的数据拷贝,而输出设备也在等待CPU拷贝数据过来,这就导致计算机整体效率低下;有了存储器(内存)的出现:输入设备先预加载一大批数据在内存中,CPU通过内存快速进行处理数据,通过内存将结果拷贝到输出设备中,提高了整机的效率;内存扮演的角色本质上是巨大的缓存区,这也是冯诺依曼结构设计的精妙之处!
储存金字塔
除了内存储存数据的功能外,在CPU内部也有很多寄存器用来储存数据,由于功能与CPU远近的原因,有了以下的储存金字塔
距离CPU越近,效率越高,成本越高;如果自身是一个土豪,想造一台非常快的计算机这时有可能的,把各种寄存器都给CPU加上,但成本也随之上升,如果计算机发展按照这种方式来的话计算机就成了奢侈品了,只有少数人能够用得起;有了冯诺依曼提出的存储器的设计,把数据先预加载到内存中,CPU进行处理后有内存刷新到输出设备中,虽然速度上不及寄存器,但相对来说也能让计算机得到不错的效率,关键是价格还便宜,这样普通人也能够用得起计算机了,人多了也就发展成如今的互联网,所以我们现在能够人手一个笔记本电脑是冯诺依曼所带来的,伟大无需多言
场景
学习了冯诺依曼体系结构,试着解释以下数据流动过程:
你在广东,你的朋友在北京上大学,你们现在都在网上通过QQ进行聊天,当你在QQ聊天框中发送一句:你好;你的朋友就从QQ对话框中收到了你好的信息(不考虑网络)
从键盘(输入设备)获取数据后,加载到内存中,通过QQ(QQ当前正在使用已经加载到内存中),CPU对数据的处理加密,接着把数据输出交给网卡(输出设备)进行(网络)传输;你朋友的电脑网卡(输入设备)接收后把数据加载到内存,通过QQ,CPU的解密完成对数据的处理,将数据显示到显示屏(输出设备),最终看到了你好的消息
操作系统
操作系统,简称OS:它是用来进行软硬件资源管理的软件,广义上来说OS等于OS内核加上OS周边程序(比如Windows自带的办公三件套),狭义上来说只有OS内核;当我们打开电脑正在加载就是OS加载到内存的过程;操作系统很复杂,我们先来看看OS的一些层状结构
其中内存管理,进程管理,文件管理...这层就是上面提到的OS内核,而OS管理各种硬件的方式是在中间加上驱动层,不直接管理硬件通过驱动程序间接来管理;为什么不直接进行管理? 因为提供硬件的厂商有很多,OS直接管理就只认当前硬件的厂商,比如磁盘出现问题要进行更换就只能去找购买对应生产磁盘的厂商,其它OS不认,这就会导致硬件方面看看哪个厂商拳头大的问题;为了解决问题,OS说了:厂商你生产的硬件如果要让我认识,你还要提供对应驱动程序:也就是去写OS所能认识的通信协议给OS,OS通过协议能够识别到硬件从而能对硬件进行管理;当然这只是其中的原因之一,硬件安全性问题出现后间接管理能够保证OS本身不受影响
OS对软硬件资源管理,这是OS存在的目的吗? 不是,OS对软硬件进行管理只是一种手段,其目的是为了提供一个安全,稳定,高效的环境给人们进行使用;因为OS本质上是工具,发明出来是为了给人去方便使用,也就是以人为本
理解操作系统
OS对软硬件资源进行各种管理,那什么是管理呢?管理的目的是什么呢?
在大学里,你的身份是一名学生同时也是属于被管理的对象,而管理你的人是你的辅导员,还有学校的校长。这可能要说了,学校的校长在整个大学期间基本上也就开学典礼见过面其它时候就没见过面了怎么可能管理着我?但你想想看,管理真的一定要管理者与被管理者直接接触吗?当然不是,校长管理着你们,实际上是管理着数据——教务系统的学生信息,也就是说,管理本质上是对数据进行管理;当校长想找到某届绩点最高的一名学生名字,他可以在教务系统里去找绩点最高对应的学生名字来,如果是十几名学生的话管理起来也很容易,学生毕业了删除对应的信息很容易;但随着被管理学生越来越多,人的信息也变得越来越多,校长很难管理。在这时校长想起自己有学过一点计算机,会写代码。他就用自己的知识以及想法将每一名学生的数据用一个结构体类型存储起来:struct stu{ char name[16] , char sex , char add[123]... struct stu* next},在结尾处存储一个struct str* next,将每一名学生的数据用链表的形式连接;从此:校长对学生的管理工作,变成了对链表的增删查改;校长是操作系统:操作系统管理软硬件采用的方法:先描述,在组织!
在C++或者其它面向对象的语言中也是采用了先描述后组织的方式去设计语言的,比如:C++不是有封装吗?把所有数据封装成一个一个的对象,这就是描述;C++不是有STL容器吗?使用STL创建对象,对象此时不是被STL所管理,也就是组织吗! 而学习STL容器之前是不是要学习数据结构作铺垫才能更好使用STL容器,容器(底层)本质上是数据结构;所以说:只有前面学习好了数据结构打基础,后面学习面向对象语言与理解OS才不是问题!
结构图
理解到管理本身不是对人的管理,而是对数据的管理,在OS中也是一样:OS对软硬件进行管理不是直接管理,而是通过对软硬件描述成一个一个对象,再以某种特定方式组织起来统一管理,那在OS中一定存在着很多数据结构:链表,栈,队列...由于OS要兼容各种硬件,同时也要保证硬件出现错误不会影响OS本身,所有提供了驱动程序(通过硬件的厂商编写的程序,一定能被OS识别);在上层我们作为用户是直接使用OS进行我们要想要的结果吗?
与OS管理硬件类似:如果让用户直接操作,用户把OS其中一个字段改了,之前启动的程序都受影响,导致OS全盘崩溃:考虑到用户自己操作对OS影响很大,所以OS提供了一层系统调用来让用户间接操作OS;用户使用scanf 读取键盘数据,本质就是调用对应的系统调用,向OS下达指令,OS再在管理的数据中找到键盘的数据,看看键盘此时的状态是否能让用户读取,可以的话再通过驱动程序下达用户要读取键盘的指令,此时键盘的数据就被用户通过scanf读取到了!
但是间接使用OS对于用户来说你要了解OS的内存管理,进程管理...以及对应的C/C++函数调用或者系统调用,这对程序猿来说不是问题,但是我作为一个小白,什么都不懂,怎么来使用OS呢? 为此在用户(程序猿)上面还要一层图形化界面(本质上是程序猿为了方便用户使用而开发出来的),把一个一个指令或者函数给我们以简单的形式展现出现,进行简单的了解后就能迅速地使用了
总结:为什么要有操作系统? 操作系统对下(手段)进行软硬件资源管理的工作,对上(目的)为用户提供一个安全的,稳定的,高效的运行环境;操作系统本质上是一个用户使用起来方便的工具,可不可靠决定了它的存在意义!
库与系统调用
如果你在Linux环境下使用系统调用进行操作,换了平台如Windows环境后代码就跑不了,因为不同平台下系统调用会有所差异;但你使用语言如C语言就不同了,在Linux环境下能跑,在Winodws环境下也能跑,这就是我们所说的语言具有跨平台性,那这时这么做到的呢? 在不同环境下你在安装编译器时,除了安装编译器,另外还要你选择语言标准库进行安装,在不同环境下安装的标准库都不同,但它所提供的函数接口都是相同的;你在Linux下能跑是因为安装了Linux环境下的标准库;在Winodws能跑是因为安装了Winodws环境下的标准库,能够跨平台性是因为库早就给我们写好了在当前平台下的实现,只是我们站在语言层上没感觉而已(关于库的理解在基础IO部分重点介绍)
进程(Linux)
进程在系统课本书上的定义:程序的一个执行实例,正在执行的程序;在内核观点看来是:担当分配系统资源(CPU时间,内存)的实体;看着很抽象不好理解;我们先来下个简单的定义:进程 = PCB结构体 + 程序运行的代码和数据;可执行程序在没有执行时一般在磁盘中保存,执行时把代码和数据加载到内存,OS同时创建PCB结构体(在Linux中我们把它就在task结构体)管理这个程序的代码和数据,以队列的方式管理起来:哪个进程可以被CPU调度了,就从队列中pop,在CPU内部执行;这个过程只是简化版的过程,实际上程序运行时的过程很复杂,这个先有个理解(后面讲以task结构体作为讲解)
如何理解进程的动态运行?
程序运行时被CPU调度过程一定会经历不同的队列:只要将来进程在不同的队列中,就能够访问到不同的资源;这就好比在面试时有三面,每次面试时不同的面试官都要拿到你的简历对你进行面试,你也可以与不同的面试官进行交流,总结出不同的面试经验(资源)
task结构体
首先自己来写一段简单的代码
#include<stdio.h>
#include<unistd.h>
int main()
{
while(true)
{
printf("I am a process\n");
sleep(1);
}
return 0;
}
进行./程序 运行程序其实与指令运行是一样的
程序运行时使用指令ps 来查看到进程正在执行
把head带上
每一个进程都要有自己唯一标识符,叫做进程pid;如果不用指令ps 查进程的pid,要怎么做才能知道呢? 由于进程统一被OS管理,用户要想直接从task结构体里面拿是不可能的,OS也不让你这么干,所以OS要为我们提供系统调用:getpid() 来让我们获取到当前进程的pid,这也是我们学习到的第一个系统调用
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
while(1)
{
printf("I am a process %d\n",getpid());
sleep(1);
}
return 0;
}
指令ps 进行验证,如果要让当前死循环的程序停下来,除了 ctrl + c 终止进程,也可以使用指令 kill =9 pid 杀掉进程
ppid则是进程的父进程,我们看到上面进程的ppid是2944,它是谁呢?
bash是谁? 我们在前面的权限中用一个故事来理解:shell是媒婆,是命令行解释权的总称,而bash是王婆,是命令行解释器重具体的一款软件
创建进程的方式
启动进程就相当于是bash创建子进程的动作,其目的是为了保证自己不受影响;对应的,我们自己启动的进程也可以创建出子进程来,也是需要系统调用 fork() 来完成子进程的创建
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("only a process:%d\n",getpid());
sleep(3);
fork();
printf("I am a process:%d\n",getpid());
sleep(3);
return 0;
}
现象是过了3秒后看到了两个进程打印了
如果我们创建出子进程只是与父进程一样执行重复的代码,那创建子进程有什么意义呢?
实际上创建出子进程后更多的是让子进程来执行与父进程不同的代码,并发起来效率提高才是目的;那这要怎么实现呢?我们来来看看 fork() 函数返回值
fork()错误返回-1,返回0表示当前进程是子进程,其它返回值表示当前进程是父进程,修改上面的代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("only a process:%d\n",getpid());
sleep(3);
pid_t id=fork();
if(id==-1) return -1;
else if(id==0)
{
while(1)
{
printf("I am a child process:%d\n",getpid());
sleep(1);
}
}
else
{
while(1)
{
printf("I am a father process:%d\n",getpid());
sleep(1);
}
}
return 0;
}
此时父子进程就能执行对应不同的代码
为什么fork()返回值可以返回两次?为什么函数fork() 返回值既可以是0,也可以是其它值?
先来回答第一个问题:在fork()函数执行后return id时的这个代码是被执行了几次呢?如果是场景情况下函数return 只有执行一次,之后就进行函数栈帧的销毁;而fork()里面创建了子进程,无论子进程还是父进程先执行到return id这行代码后,由于函数中还存在着一个进程不会进行函数栈帧的销毁,要等到所有进程全部都return id后退出了才销毁,所有说return 执行了两次,也就返回了两次;
回答第二个问题:上面讲了进程 = task结构体 + 代码和数据,而进程之间一定要具有独立性(保证一个进程出现异常不影响其它进程),OS也要为子进程提供一个task结构体,而代码本身具有只读属性,父进程执行的代码可以被子进程所共享,子进程是可以看到父进程全部的代码的,只是它fork()创建后要往下走;而数据就不同了:数据要被父子进程所私有化,因为数据的不同会影响进程执行过程出现不同的结果,OS为了保证数据私有化就要进行写实拷贝操作实现,至于怎么做到的这里不展开讲,记住有这回事就行
除了上面创建的一个子进程外,我们也可以一次创建出多个子进程
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
void RunChild()
{
while(1)
{
printf("I am a child process:%d\n",getpid());
sleep(1);
}
}
int main()
{
const int num=5;
for(int i=1;i<=num;i++)
{
if(fork()==0)
{
RunChild();
}
}
while(1)
{
printf("I am a father process:%d\n",getpid());
sleep(1);
}
return 0;
}
除了ps查看进程外,还可以通过命令 /proc 的方式查看进程,进程pid对应一个目录
在pid目录中还有一个cwd(current working directory)记录着进程当前的工作路径,默认是可执行程序所在的路径,而fopen创建一个文件时进程会为我们拼接上当前进程的工作路径,从而让创建文件与进程在同一目录下,cwd 目录也可以使用 chdir(新的路径) 函数改变cwd,让fopen创建的文件指定放在指定的目录中,达到我们想要的效果
进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程叫做任务),下面的状态在kernel源代码里定义
通过代码以及现象来认识各种状态
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
while(1)
{
printf("I am a process:%d\n",getpid());
}
return 0;
}
结果发现:明明进程正在打印数据,但查进程时显示出于S状态(+说明进程是在前台运行,没有+说明进程是在后台运行,后台运行指令后面加 &,这里S+默认看成是S)
这时因为CPU执行速度很快,而OS刷新到显示器上速度很慢(与硬件进行IO非常慢),CPU很多时间不是在执行而是在等待OS刷新数据,如果把打印数据代码去掉后,此时进程状态就是预想的R状态
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
while(1)
{
}
return 0;
}
总结:R:进程运行的状态;S:进程处于休眠的状态,很多时候是进程等待“资源”(显示器)就绪,进程处于休眠状态可以 ctrl + c 或者 kill -9 pid 进行中断,休眠很多时候也是可中断休眠
认识信号SIGSTOP(19):正在运行的进程处于暂停状态;SIGCONT(18):处于暂停状态的进程被唤醒
除了通过指令(信号)让进程处于停止状态,还有别的场景吗?
使用gdb调试时,b 行号 打断点后让让gdb r 跑起来,执行到当前断点处,此时进程也是出于暂停状态
总结:T/t:让进程暂停,等待进一步被唤醒
有了S(睡眠)状态,为什么还要有D(不可中断睡眠)状态?
有这么一个场景:进程的任务是:通过与磁盘IO交互,把1GB的数据写到某个特定的文中;因为进程要等待数据拷贝到磁盘中,所以此时处于S状态(如果没有D状态的话),等待IO交互完成;这时OS因为太多进程占用资源导致压力过大,要想办法杀到一些进程来释放空间(OS有权利对进程进行管理),看到这个进程处于S状态因为它什么也不干就把它杀掉了,此时磁盘写入数据失败要把结果返回给进程时发现进程不见了,此时还有别的进程要往磁盘进行读取写入,磁盘也就不管失败的结果就转头执行别的进程的任务了;在该场景中1GB数据的写入失败问题是谁的错误呢?进程说是OS的锅,它还没等我解释就把我杀掉了;OS说如果我压力过大宕机了所有的进程全都完蛋,到时就不止1GB数据写入失败的问题了;这时它们两个转头看向了磁盘,磁盘说不管我的事,我都把结果返回了是进程被杀掉后导致我无法告诉用户读取数据失败的!
所以为了导致这个现象发生,Linux给了一种全新的状态:D:不可被杀,深度睡眠,不可中断睡眠状态;kill命令杀不了,OS也杀不了;它只有两种退出:要么IO工作完成,自己醒来;要么重启或者断点的物理操作,中断所有进程运行;长时间进程处于D状态通常表明底层硬件或驱动程序存在问题,这时就要排查问题所在了
僵尸进程
子进程已经运行完毕要进行退出时,需要有父进程进行回收;如果子进程一意孤行没有将退出信息交给父进程,就会产生僵尸进程
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
if(fork() == 0)
{
int cnt = 5;
while(cnt)
{
printf("I am child, cnt: %d, pid: %d\n", cnt, getpid());
sleep(1);
cnt--;
}
}
else
{
while(1)
{
printf("I am parent, running always!, pid: %d\n", getpid());
sleep(1);
}
}
return 0;
}
由于子进程没有被父进程回收后就退出了,此时它的代码和数据虽然被OS释放了,但task_struct结构体OS还一直维持;灵魂没有了只剩下皮囊,这在影视剧里面我们叫做僵尸;
父进程回收子进程,之前不是说进程之间具有独立性吗?
父进程回收的是子进程的退出信息,不会对task_struct结构体造成影响,能保证进程之间的独立性;没被父进程回收的僵尸进程会一直存在,造成内存泄漏,OS资源被占满后会非常卡顿甚至被重启;
为什么我们从来没有关心过僵尸进程(内存泄漏)?(命令行启动进程是OS创建子进程执行,先退出也是子进程)
父进程Bash就自动回收子进程的Z(默认处理了);总结:Z:进程处于僵尸状态,kill命令无法杀死(本来就是死了);X:进程死了的状态(被OS回收了),这个过程很快,演示不了
孤儿进程
父进程如果先退出,子进程会变成孤儿进程;孤儿进程一般都是要被1号进程(OS)所领养
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
if(fork() == 0)
{
while(1)
{
printf("I am child, pid: %d\n", getpid());
sleep(1);
}
}
else
{
int cnt=5;
while(cnt)
{
printf("I am parent, cnt:%d, pid: %d\n",cnt, getpid());
sleep(1);
cnt--;
}
}
return 0;
}
父进程先退出,子进程被1号进程(OS)领养了,OS为什么要领养它? 子进程退出要进行回收,OS承担了父进程回收子进程的任务
进程(OS)
在操作系统中,进程有很多状态:创建,就绪,执行,阻塞,终止状态
终止状态好理解,不就是Linux中的僵尸Z和死亡X;执行状态对应的是R状态;创建和就绪状态Linux没有,但你也可以理解成就绪与执行状态两个都看成是Linux中R状态:OS(CPU只有一个)会为我们维护出一个运行队列struct runqueue:里面保存着执行队列的头部与尾部,将R状态下的进程全都入队,进程入队后就表明:当前进程已经准备就绪,可以随时被调度了;CPU就会依次调度进程执行;如果先调度的进程是一个死循环,CPU调度不退出导致后面的进程没有被执行到,那怎么办? CPU不是简单地调用进程,其中还有OS还维护了时间片(sched_entity结构体),时间到了就会对进程进行切换,把当前被调度的进程移动队列的尾部,让下一个进程调度...让多个进程以切换的方式,在同一时间段内得以推进代码,我们称为并发
阻塞
C语言中scanf()函数执行时如果我们不在键盘上输入数据,当前进程处于什么状态?此时是在等待什么资源?
#include <stdio.h>
int main()
{
int a=10;
scanf("%d",&a);
return 0;
}
通过ps看到当前此时处于S状态,处于休眠状态,此时它正在等待键盘(输入)资源就绪,也就是说:休眠:S/T状态在OS中称为阻塞状态
OS要对硬件作管理,它是怎么管理的?
我们说OS是软硬件资源管理者,也说了管理的本质不是对对象作管理,而是对数据作管理;每个硬件要被OS所管理,OS会为它们各自创建一个device结构体,结构体中有对应设备的类型type(宏定义),当前状态status,指向下一个设备结构体struct device *next...最重要的是结构体内部维护了当前进程等待设备资源就绪的等待队列task_struct* wait_queue,比如上面进程被CPU调度时执行scanf()函数需要获取键盘资源,此时OS就要把它从运行队列run_queue中剥离出来连入到wait_queue,其它进程也要获取键盘资源就依次连入...当键盘的状态status准备就绪时进程也拿到了键盘数据后就把它再次连入到run_queue中(唤醒进程)等待再次被CPU调度...请注意:进程连入不同的队列中只有task_struct发生改变,代码和数据原先在哪里就是在哪里
挂起
在磁盘中有一个swap分区,当内存空间吃紧时,OS会将一些进程唤出到swap分区中来缓解压力,这叫做挂起;当压力解除时OS就重新将swap分区中的进程唤入到内存中等待被调度;挂起虽然能够化解内存压力,但是频繁挂起会导致效率问题(本质上是一种效率换空间的做法)所以为了避免OS频繁过去,swap分区大小在设计时不能过大,倒逼OS在不得已的情况下不要挂起
进程切换
进程被CPU调度时如果时间片到了,它会进行进程切换为下一个进程进行调度;未完成调度的进程势必是要保存当前执行的数据和未执行的数据,方便下一次CPU调度时知道从哪个地方开始执行;这种现象就好比你在上大学时去当兵了,为了不影响退伍时你继续读书,学校要为你保存当前你在学校的数据,退伍后回到学校要为你恢复之前的数据,让你继续完成学业;进程当前的数据称为进程的上下文,保存当前数据称为保存上下文,还原之前数据称为恢复上下文
进程的上下文具体指的是什么?
你要知道:CPU中有很多的寄存器:eax,eip,CR0~CR8...C语言中函数结果为什么能够成功返回,就是把数据保存在寄存器eax中让它进行返回而不是将变量返回;进程被CPU调度时各种寄存器一定有调度进程时所产生的各种数据,当时间片到了,CPU要将当前寄存器的所有数据保存在进程task_struct 中的 tss_struct结构体 保存上下文,当下次进程被调度时CPU就从看看结构体tss_struct是否被调度过,是的话就从里面把数据拿出来给到对应的寄存器中,继续从之前切换的位置继续执行,要注意:CPU内只有一套寄存器(硬件),而寄存器数据可以有多套取决于有多少进程接下来进程切换,所以说:寄存器 != 寄存器的内容
优先级
在进程task_struct结构体中有这样一个属性:prio表示当前进程的优先级,代表当前进程在运行队列的顺序,值越小优先级越高;与权限区别开:权限是能不能访问资源的问题,而优先级是具备能的情况下访问资源的先后顺序
为什么要有优先级?
进程访问的资源(CPU)始终是有限的:即大部分情况下进程是比较多的,为了让进程合理访问资源所设计出来优先级:这就好像中午了去食堂吃饭时需要排队一样,因为学校窗口不够应对全部学生而规定打饭必须排队,那学校就不能开窗口来满足全部学生吗?多开窗口后本来在计划宿舍吃泡面的人看到后也一窝蜂涌去食堂,还是要排队且浪费钱的事学校可能去做吗
大部分操作系统如Linux,Winodws关于调度与优先级原则:分时操作系统(基于时间片调度轮转的)来强调基本的公平,如果一个进程排队过程比较紧急需要立刻被CPU调度的话,就可以设置优先级让它排在第一位,但这个过程并不能频繁进行,因为如果一个进程长时间得不到调度会产生饥饿问题
查看方式
在Linux中,优先级 = 默认优先级(80) + nice值,nice值范围【-20,19】;
- UID : 代表执行者的身份;
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行;
- NI :代表这个进程的nice值
修改优先级:指令top打开资源管理器 -> r -> 输入进程pid -> 输入要修改的nice值
再次查看优先级
命令行参数
平时写代码main是不用传参的,但它也可以传递参数来写,具体参数是什么呢用代码要演示
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
for(int i = 0; i<=argc; i++)
{
printf("ergv[%d]->%s\n", i, ergv[i]);
}
return 0;
}
argv是一个指针数组,存的是字符或者字符串的起始地址,arg是argv中的个数;argv相当于为我们维护了一张表,把命令行参数的字符串以空格为分界线分割保存在对应位置中
argv保存这些字符串有什么用?
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
if(argc!=2)
{
printf("select function:[-a b c] no:%s\n",argv[0]);
return -1;
}
if(strcmp(argv[1],"-a")==0)
{
printf("This is Function1\n");
}
else if(strcmp(argv[1],"-b")==0)
{
printf("This is Function2\n");
}
else if(strcmp(argv[1],"-c")==0)
{
printf("This is Function3\n");
}
else
{
printf("No Function\n");
}
return 0;
}
通过对命令行参数的设置来实现同一个程序能定制化不同的功能,也就是一个指令通过不同的参数执行不同的功能一样
把命令行参数填充到argv中,让写代码的人能够通过它来获取用户输入的参数,谁干的?
一个程序被执行本质上就是启动了一个进程,那么这个进程的父进程是谁呀? 不就是bash吗
也就是启动一个程序bash会为我们创建一个出子进程来执行;bash也称为命令行解释器:把用户输入的参数给我们维护起来放到 argv 中;而前面说了:子进程是能够看到父进程数据并访问(没有说能修改):所以子进程就能看到bash维护好的argv并使用它
环境变量
在Linux我们可以设置全局变量,来告诉bash指令执行时应该去哪里寻找可执行程序,这种全局变量称为环境变量。PATH就是其中的一种
现在有一个需求:把自己写的可执行程序的路径添加到PATH中,后面执行我写的可执行程序就不用带./来执行了
通过 PATH = 自己写的可执行程序路径 虽然不用./能够让我的程序跑起来了但是系统的指令跑不了:原因是我把其它环境变量的路径给覆盖了
这时你也不用慌着去重装系统:重启xshell后原先的PATH路径又回来了:因为PATH在每次登录LInux时需要先被bash加载到内存,重启前PATH变量是什么与下一次的登录无关
如果不要把PATH原先的路径被覆盖还要让我的可执行程序不用./执行,有两种方法
第一:把它添加到 /usr/bin 目录中
但这样会污染系统指令集不太好;另一种方法:把我的可执行程序的路径添加到PATH中
但是这么做下次登录时还是要重新设置才能生效,先要一劳永逸就需要修改配置文件 .bash_profile 文件(在每个用户的家目录中)的PATH变量(每次登录bash进程加载到内存时都是在该文件下进行读取PATH变量的数据后生效)
更多环境变量
bash启动时加载进来的可不仅仅只有PATH这一个环境变量,使用指令env 进行查看
举例其中的一些环境变量:PWD 变量维护当前所在绝对路径,pwd指令本质上是打印它的内容;HISTSIZE 变量维护历史指令,让我们能够通过方向键进行历史指令查找...
也可以自己导入环境变量,但因为是内存级的下次登录就消失了
除了环境变量,还有本地变量:不存在env中但可以通过echo $name 找到
理解环境变量
使用 environ 全局变量来获取当前进程环境变量
#include <stdio.h>
#include <unistd.h>
int main()
{
extern char** environ;
for(int i=0;environ[i];i++)
{
printf("env[%d]:%s\n",i,environ[i]);
}
return 0;
}
打印结果
打印出来的环境变量与指令env所查到的环境变量是一样的,因为指令执行也是bash创建子进程获取环境变量,原理与上面的代码是差不多的
进程是怎么看到环境变量的呢?
与命令行参数一样:启动可执行程序时创建的进程,它的父进程是bash,bash在登录时就把环境变量导入到内部(通过读取OS配置文件),子进程默认是能看到父进程的数据并访问的(不信可以自己创建全局变量,创建子进程看看子进程能否打印全局变量)子进程能够打印出环境变量就不奇怪
环境变量有很多,bash内部要如何组织?
与命令行参数一样:bash除了维护命令行参数表,还维护了环境变量表char* env[],保存着一个一个的环境变量字符串;使用char** envrion 打印环境变量是因为 environ 指向这张表的地址
既然bash维护了这张环境变量表,那我们也可以通过mian()通过参数把表给带进来
#include <stdio.h>
#include <unistd.h>
int main(int argc,char* argv[],char* env[]) {
//extern char** environ;
for(int i=0;env[i];i++)
{
printf("env[%d]:%s\n",i,env[i]);
}
return 0; }
总结:环境变量具有系统性的全局属性,本身能够被子进程继承并访问
内建指令
指令本质是bash创建子进程通过环境变量找到可执行程序的路径后执行,那我通过 export 导入环境变量的过程,不也是bash创建子进程通过export的可执行程序执行的过程,子进程导入环境变量我bash作为父进程,应该是不知道的(当成结论先记)但是事实是指令执行完后bash存在了导入的环境变量
这时怎么回事?
其实echo不是bash创建子进程通过可执行程序执行的,而是内置在bash中,由bash亲自执行,这种我们称为内建指令,使用which是查不到它所在的可执行路径的
解决遗留问题:本地变量
除了环境变量还有上面提到的本地变量,通过echo $name 可以查到,但在环境变量表env查不到,因为本地变量只在当前bash有效且不能被子进程所继承(通过代码验证);这也侧面反映了echo也是由bash自己执行的指令,也称为内建命令
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("%s\n",getenv("PATH"));
printf("%s\n",getenv("MYEXPORT"));
return 0;
}
地址空间
代码看现象
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 3;
while (cnt--)
{
printf("I am child process pid:%d ppid:%d g_val:%d &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
g_val = 200;
printf("child process modify g_val:100->%d\n", g_val);
while (1)
{
printf("I am child process pid:%d ppid:%d g_val:%d &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
else
{
while (1)
{
printf("I am father process pid:%d ppid:%d g_val:%d &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
现象:父子进程值不同,但地址却相同?
子进程修改了变量后打印出来怎么还是同一个地址?
首先我们要明白:父子进程是具有独立性的,互不影响各自进程的执行,但也不是老死不相往来:进程=内核数据结构(task_struct) + 代码和数据;保证独立性要创建各自的task_struct,因为代码具有自读属性,父进程与子进程共享这也没毛病,并没有影响两者之间的独立性;但数据从头到尾都没有说过子进程能够修改,这时修改了全局变量g_val后出现与原来变量地址是相同的,可以排查肯定不是我们所理解的物理地址,这里的地址其实是虚拟地址!
基本的理解
程序执行时,我们说过:程序代码和数据要从磁盘加载到内存,同时OS要为我们创建task_struct结构体保存进程执行的相关属性;但OS内部还要给进程创建地址空间保存程序执行过程的数据,有:栈区,堆区,未初始化数据,初始化数据,正文代码...同时也要给数据地址,我们叫做虚拟地址;但实际上数据是储存在(物理)内存中,才能被CPU执行,所以需要页表(也是在OS内部)来帮助我们进行虚拟地址与物理地址的转化,当进程修改数据时就能通过页表找到数据在内存的位置并更新(注意这句话);当进程创建出子进程时,进程之间是具有独立性,OS除了为子进程创建task_struct,也要在OS内部维护子进程的地址空间与页表,由于代码具有可读属性,可以为两者所共享就不用再创建,但数据就要各自私有化一份:由于g_val在子进程创建之前就存在父进程的地址空间且与内存形成映射了,OS为子进程创建的地址空间与页表就只是把父进程的那部分拷贝给子进程而已,并没有什么改变;但OS检测到子进程要修改g_val的数据时,OS先在内存中创建新空间用来拷贝g_val,把子进程页表映射的物理地址该为新拷贝的物理地址,虚拟地址不改,修改g_val为200就只是在这个新创建的g_val下修改,与原来指向的g_val(父进程的数据)没关系,这样当访问数据时父进程看到的是g_val = 100,而子进程看到的 g_val = 200;用户在上层以为修改后父子进程的g_val是一样的:其实父子进程的g_val早就不一样了,只是因为子进程拷贝过来是g_val在地址空间的虚拟地址与父进程的一样,而打印地址又刚好是虚拟地址罢了
为了保证独立性,为什么OS在创建子进程时不直接把数据拷贝一份呢?
父进程中很多数据:命令行参数,地址空间,栈区...很多数据子进程大概率不会修改,如果子进程一直不修改,拷贝的数据不就浪费空间了吗?因为不确定子进程是否修改,先让你与父进程共享数据,等到子进程修改数据时按照修改的数据进行写时拷贝进行按需拷贝,来达到节省空间目的
如何理解地址空间
什么是划分区域
在读书时,如果与同桌的关系特别的不好,在课桌上画上一条38线进行划分区域,课桌的长度是100cm,这条线就在50cm处;如果他一直越界这条线就调整为70,80...;如果用计算机语言来描述的话:struct area { int l , int r }; struct desktop { struct area left , struct area right};定义 struct desktop d1; d1.left.l = 1 , d1.left.r = 50; d1.right.l = 51 , d1.right.r = 100,线进行调整就只是把这些成员变量给修改即可;在OS内核中地址空间可以理解每个区域划分就是这样的
理解地址空间
在遥远的漂亮国有一个大富翁,他有两个私生子,彼此是不知道对方的存在的。大富翁在不同的时间段分别对这两个孩子说:要好好生活,等我以后驾鹤西去了,我的总资产10个亿都是你的。这两个孩子听完之后都很高兴。有一天,他的大儿子跑过来对他说:老爸,要买书本费用还差300 能不能发给我一下。大富翁爽快地答应了并且马上给了他。过了几天,他的二儿子对他说:能不能现在就把10个亿给我,好让我去外面做生意。大富翁听完很生气没回复他,他的儿子看到他父亲脸色一下子就黑了连忙说:我不要10亿了,我只用1万就足够了。大富翁听完之后也拿钱给了他。大富翁对每个儿子都说自己有10个亿,但是当其中一人需要这10个亿时大富翁却没有给,可以理解大富翁其实是在给孩子们“画大饼”
而大富翁画饼的行为就相当OS在给各个进程分配了10个亿的地址空间:我这个有4个G(32位下)让进程尽管去申请空间;进程多了起来地址空间也要相应变多,OS也要对这些地址空间也就是struct mm_struct 结构体进行管理,怎么管理?先描述,再组织:在结构体内部组织好各个区域划分的成员变量,使用双链表管理起来,对地址空间的管理转化为对链表的管理;
为什么要有地址空间
首先,如果没有地址空间,进程就直接指向(物理)内存,数据再内存的哪个位置进程就要记住,这对进程来说负担有亿点大,所以OS说:进程你不要直接找内存,我给你申请一个地址空间,你的代码和数据就在地址空间上存着就行了,剩下的工作如页表映射等工作我来帮你做了;每个地址空间中对区域进行划分,这块区域是栈区,堆区,共享区...都是一样的;再也不用担心这个进程要在这块地址中找代码,那个进程要在那个地址找代码,也就是说:地址空间将代码数据从无序变有序,让进程以统一的视角来看待物理内存,以及自己运行的各个区域;
其次,如果内存满了的情况下,OS看到内存的某一块空间的代码已经被执行完了,可以先将进程加载到内存的未执行代码给唤出到外设中,在进程看来它是不知道的,因为它的代码保存在地址空间中以为代码还在内存中,等到真的要执行了就重新将代码加载进磁盘中:这样就很高效利用内存空间给了OS有很大的操作空间,这也就是:地址空间将进程管理模块和内存管理模块进行解耦,充分利用空间
最后,进程要访问的数据越界了,这在地址空间上表现为拿着虚拟地址在页表中找不到映射到物理地址,OS知道你是越界了就把这个过程给拦截了,不让你进行如何操作;这个过程就好比:过年你的压岁钱拿去乱花,你父母看到你这样糟蹋钱就收了你的钱,让你有需要再来找他们拿;当你要拿钱买学习资料时你的父母允许了,你也就拿到了钱;当你想要拿钱买玩具时,你父母就不允许了,把你的请求给拦截了,你的钱也就被保护起来;这也就是说:地址空间可以有效拦截非法请求,从而间接实现对物理内存的保护
理解写时拷贝
在C语言中:char* s = “hello world” *s = ‘H’,编译后发现代码可以通过,但运行时发生崩溃:在C语言学习中我们说是因为字符串在字符常量区,不能别修改,但为什么字符常量区的内容不能别修改呢?
页表将虚拟地址转化成物理地址这个过程还要CPU中的寄存器CP3与MMU共同来参与,且页表这个可不单单是进行转化怎么简单,内部实际上很复杂,其中有很多的权限标记位:读权限r 写权限w 可执行权限x (与前面的Linu权限类似),s数据建立映射关系后页表对应的权限是只读权限r,当执行 *s = 'H'时在OS发现页表中权限r,该操作不被允许,所以立刻就终止了你的执行操作:给你的进程发信号(出错原因)后终止了你的进程
以最开始的问题代码为例:父进程建立好映射后g_val在页表中本来的权限是r,w;因为创建了子进程,拷贝父进程的地址空间与页表之前要先把g_val的读权限w去掉,也就是置0表示没这个权限,所以两者对g_val就只有读权限r;当其中一方修改g_val时,OS发现g_val在页表中虽然没有写权限w,但是两个进程在共享着g_val,OS就明白了原来是要进行写时拷贝,所有它才进行开辟新空间,拷贝数据,重新建立映射...
理解虚拟地址
有了地址空间,页表的概念,CPU就能从进程的虚拟地址转为物理地址找到数据并执行,但你有没有好奇过虚拟地址是怎么来的呢? 其实在编译器将我们的代码编译形成可执行程序时虚拟地址就早已经存在了,可以试着将可执行程序反汇编看看
所以程序在加载到内存,call 0x112233 时CPU会拿着这个虚拟地址结果寄存器与页表转化成物理地址从而找到数据执行,找数据执行的过程就是通过虚拟地址转化的过程;当然其中还要虚拟地址还要涉及到编译器进行编址比如平坦模式的相关知识,这要等学习到动静态库时才能体会
解决遗留问题:fork()创建子进程时为什么id即可以是0(子进程)有可以是非0(父进程)?学习了上面的知识大概你就能明白了:两者其中一方将id的改变就是因为发生了写时拷贝导致的
Linux调度运行队列
我们说:OS为了管理进程,会创建一个队列来管理,将进程一个一个链入到队列中,但这只是广泛的理解,实际上OS内部是用一个数组queue[140]来管理(包含在rt_rq结构体中),其中管理的进程只会用到99~139的位置,刚好是40个位置,优先级的nice值【-20,19】也是40个值,这两者难道有联系吗? 对的,nice值的个数就是通过它来规定的:-20对应99,19对应139...进程就可以通过nice值找到在数组中的位置,如果该位置有进程了就连接到它的后面...OS管理的是这40个队列;这样OS想找指定的进程就通过优先级能快速找到在哪个位置,遍历这个位置即可找到
但如果想找前面的进程来让CPU调度呢?刚好99~138位置都没有只有在139有,从头到尾一个一个遍历也太慢了吧!
这时就需要用到rt_rq结构体中的另一个成员bitmap[5],它是long 类型,共有:32 * 5 = 160个bit位,使用99~139位置的bit:0表示当前位置无进程,1表示当前有进程;OS找进程调度时就通过它来快速查找:如果bitmap[i]为0就一下子排查了32个位置没有进程的情况,不为0就从这32个位置依次查找,找到就把当前位置的所有进程整个给拿出来让CPU调度:这种我们称为O(1)调度算法,是有谷歌的一名工程师设计出来的,想要寻找更多细节可以去网上查具体源码的实现
进程被CPU调度时时间片到了或者是新到来的进程,也是放在这个数组中吗?
不是的,新到来的进程或者时间片到了的进程我们是把它们放在另一个相同的数组中;前面数组保存的进程称为活跃进程:进程只出不进;这个数组保存的进程称为过期进程:进程只进不出;当CPU把所有活跃进程都处理完后才去处理所有过期进程,这个过程是循环的...OS管理这两个rt_rq结果体通过数组来管理:rt_rq array[2],通过指针(下标)active,expired来找待:比如OS先用active找活跃总进程让CPU处理,CPU处理完后,OS找过期进程只需swap(active,expired),还是使用active找过期进程:此时在OS看来使用active找进程没有活跃与过期进程的说法,而是待处理的进程!
nice值的好处
如果没有nice值,修改优先级时就需要把进程从指定的位置将进程从链表(相同的位置的进程依次连接)中剥离出来,修改值后还要把进程放在不同的位置中,这就OS来说很复杂进程来说很耽误时间;要修改进程的优先级时先用nice修改后先记住,但进程时间片到了后通过原优先级与nice值重新确立新优先级后,修改进程优先级属性,之后把进程放到指定位置中等待下次的调度,非常节省OS管理进程的成本