一、冯诺依曼体系结构
输入单元:包括键盘、鼠标、扫描仪、数控板
中央处理器:含有运算器和控制器
输出单元:显示器、打印机
①上图所示的存储器指的是内存②不考虑缓存情况,cpu只能对内存进行读写,不能访问外设③外设要输入或者输出数据,只能写入内存或从内存中读取④所有设备都只能直接和内存交互,而不是cpu
二、操作系统
任何一个计算机系统都包括一个基本的程序集合,称为操作系统(OS)
操作系统包括:①内核(进程管理,内存管理,文件管理,驱动管理)②其他程序(函数库,shell程序)
系统调用与库函数:
·在开发角度,操作系统对外表现成一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口叫做系统调用
·系统调用在使用上功能比较基础,对用户的要求相对也比较高,所以部分开发者可以对部分系统调用进行适度封装,从而形成库,利于二次开发
三、进程
内核观点:担当分配系统资源(CPU时间,内存)的实体
什么是进程
1)进程是动态的,程序是静态的
当一个程序调用的时候,就创建了一个进程;进程在运行的时候是具有独立性的,不影响其他进程
2)进程=内核数据结构+进程的代码和数据=PCB+程序段+数据段
代码:只读
数据:当有一个执行流尝试修改数据的时候,OS会自动给我们当前进程触发写时拷贝
写时拷贝(copy-on-write, COW)就是等到修改数据时才真正分配内存空间,这是对程序性能的优化,可以延迟甚至是避免内存拷贝,当然目的就是避免不必要的内存拷贝。
所谓的创建进程, 实际上是创建进程实体中的PCB,而撤销进程,实际上是撤销进程实体的PCB。 (PCB是进程存在的唯一标志)
严格来说,进程实体和进程并不一样,进程实体是静态的,而进程是动态的
PCB:
进程信息被存放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合
进程控制块是系统为了管理进程设置的一个专门的数据结构,用它来记录进程的外部特征,描述进程的运动变化过程。
task struct:
是linux内核的一种数据结构,会被装载到RAM(内存)里并且包含着进程的信息
task struct内容分类:
标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据: 进程执行时处理器的寄存器中的数据
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息
四、fork入门知识
fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。可以简单地说fork()的作用就是创建一个子进程。
一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
子进程完全拷贝父进程的PCB,但并不是同一个;父子进程代码共享,数据独有;同一个变量在父子进程> 的地址完全一样,OS中虚拟内存机制保证父子进程运行独立互不干扰
创建一个子进程实际上是创建了一个PCB,父进程与子进程看到的是同一份代码和数据
创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。
fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1)在父进程中,fork返回新创建子进程的进程ID;
2)在子进程中,fork返回0;
3)如果出现错误,fork返回一个负值;
fork出错可能有两种原因:
1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。
2)系统内存不足,这时errno的值被设置为ENOMEM。
五、fork进阶知识
以下面的代码为例,使用<unistd.h>头文件,调用fork()函数。其中getpid()函数的作用是获取进程id,getppid()函数的作用是获取父进程id
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("AAAAA\n");
fork();
printf("BBBBBBBBB pid:%d ppid:%d\n",getpid(),getppid());
sleep(1);
return 0;
}
运行后的结果是
可以看出AAAAA只打印了一行,因为此时并没有执行fork()函数,只有初始执行的代码。之后通过fork()函数创建了一个子进程,因此BBBBBBBB打印了两次。
第一次打印B的时候,pid:25251 ppid:25728。而第二次打印B的时候,pid:28252 ppid:25251父进程的进程id与第一次打印的进程id相同。
因此可以说明fork()函数创建了一个id为28252的子进程,它的父进程id为28251
下面来考虑循环中使用fork()的效果
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int i = 0;
printf("i son/pa ppid pid fpid/n");
//ppid指当前进程的父进程pid
//pid指当前进程的pid,
//fpid指fork返回给当前进程的值
for(i = 0; i < 2; i++)
{
pid_t fpid = fork();
if(fpid == 0)
printf("%d child %4d %4d %4d/n", i, getppid(), getpid(), fpid);
else
printf("%d parent %4d %4d %4d/n", i, getppid(), getpid(), fpid);
}
return 0;
}
运行结果是:
i son/pa ppid pid fpid
0 parent 2043 3224 3225
0 child 3224 3225 0
1 parent 2043 3224 3226
1 parent 3224 3225 3227
1 child 1 3227 0
1 child 1 3226 0
第一步:在父进程中,指令执行到for循环中,i=0,接着执行fork,fork执行完后,系统中出现两个进程,分别是p3224和p3225(后面我都用pxxxx表示进程id为xxxx的进程)。可以看到父进程p3224的父进程是p2043,子进程p3225的父进程正好是p3224。我们用一个链表来表示这个关系:
p2043->p3224->p3225
第一次fork后,p3224(父进程)的变量为i=0,fpid=3225(fork函数在父进程中返向子进程id)
p3225(子进程)的变量为i=0,fpid=0(fork函数在子进程中返回0)
第二步:假设父进程p3224先执行,当进入下一个循环时,i=1,接着执行fork,系统中又新增一个进程p3226
对于此时的父进程p2043->p3224(当前进程)->p3226(被创建的子进程)
对于子进程p3225,执行完第一次循环后,i=1,接着执行fork,系统中新增一个进程p3227
p3224->p3225(当前进程)->p3227(被创建的子进程)
从输出可以看到p3225原来是p3224的子进程,现在变成p3227的父进程。父子是相对的
第三步:第二步创建了两个进程p3226,p3227,这两个进程执行完printf函数后就结束了,因为这两个进程无法进入第三次循环,无法fork,该执行return 0;了,其他进程也是如此。
细心的读者可能注意到p3226,p3227的父进程难道不该是p3224和p3225吗,怎么会是1呢?这里得讲到进程的创建和死亡的过程,在p3224和p3225执行完第二个循环后,main函数就该退出了,也即进程该死亡了,因为它已经做完所有事情了。p3224和p3225死亡后,p3226,p3227就没有父进程了,这在操作系统是不被允许的,所以p3226,p3227的父进程就被置为p1了,p1是永远不会死亡的
对于这种N次循环的情况,执行printf函数的次数为2*(1+2+4+……+2N-1)次,创建的子进程数为1+2+4+……+2N-1个。
printf的缓冲机制:printf某些内容时,操作系统仅仅是把该内容放到了stdout的缓冲队列里了,并没有实际的写到屏幕上。
但是,只要看到有/n 则会立即刷新stdout,因此就马上能够打印了
六、进程状态
阻塞:进程等待某种资源就绪的过程
task_struct是一个结构体,内部会包含各种属性,其中包括状态
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
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运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 (interruptible sleep)) ,本质上就是一种阻塞状态
D磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的 进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可 以通过发送 SIGCONT 信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
如果进程的状态带“+”则表明它是前台运行,可以使用ctrl+c终止运行。不论进程是否是前台运行都可以使用kill命令,直接终止进程。
七、Z僵尸进程
僵死状态(Zombies)是一个比较特殊的状态。
Linux中当进程退出的时候,一般进程不会立即彻底退出,而是要维持在僵死状态,方便后续父进程(OS)读取该子进程退出的退出结果
当进程退出并且父进程(使用wait()系统调用) 没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。可父进程如果一直不读取,那子进程就一直处于Z状态
进程进入僵尸进程也就意味着进程的PCB不会被释放,PCB会一直维护该僵尸进程,数据结构创建的对象一直占用内存空间,导致内存泄露
如果一个僵尸进程的父进程号为 1 ,即其父进程为 init 进程,那只有通过重启的方式来杀掉该进程。
八、孤儿进程
当其父进程死掉(终止)时,该进程被称为孤儿进程。父进程死掉的进程将会被初始进程(init process)接管,也就是让1号进程称为新的父进程
领养孤儿进程的原因:
如果不领养,该进程后续再退出,就没有进程回收,这个进程将会游离在系统中,始终占用系统资源
孤儿进程被1号进程时,这个进程就从前台进程变成了后台进程,无法使用CTRL+C退出
终止孤儿进程的方法:
孤儿进程使用大量资源,因此可以通过top或htop 轻松找到它们。 要
①杀死孤儿进程,可使用命令:kill -9 PID
②killall 进程名称
int main()
{
pid_t id=fork();
if(id==0)
{
while(1)
{
printf("我是子进程:pid=%d ppid=%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
int count=10;
while(1)
{
printf("我是父进程:pid=%d ppid=%d\n",getpid(),getppid());
sleep(1);
if(count--<=0)
{
break;
}
}
}
return 0;
}
九、进程优先级
基本概念
·cpu资源分配的先后顺序,就是指进程的优先权(priority)。
·优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
·还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整 体性能。
通过使用指令ps -l查看进程
头部标签为 F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
其中PID指的是该进程的id号,PPID指的是该进程的父进程id号,NI全拼为NICE指的是该进程的优先级的修正数据,PRI指的是当前进程的优先级,UID指的是当前用户的身份标识
PRI:指进程的优先级,也就是被CPU执行的先后顺序,值越小优先级越高
实际上PRI(new)=PRI(old)+NI
NI的取值范围为-19~20
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<time.h>
int main()
{
int num=5;
while(num-->0)
{
printf("我是父进程\n");
int i=0;
for(i=0;i<20;i++)
{
sleep(1);
}
}
pid_t a;
a=fork();
printf("我是子进程\n");
return 0;
}
执行如下代码后,登入root,先使用ps -al命令查看进程信息
用top命令更改已存在进程的nice:进入top后按“r”–>输入进程PID–>输入nice值
进程优先级特性:
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
十、环境变量
前提引入:
为什么系统的指令(诸如ls,pwd)不需要./来执行,而我们自己写的程序需要编译过后通过./执行,我们写的程序与系统程序有那些差异呢?
①首先我们写的程序与系统原有的可执行程序并没有差别,只是我们的程序没有被纳入linux库中
②其次,"./"中"."指的是当前路径,"/"是路径分隔符.在系统中存在一个环境变量,所以执行ls之类的语句时,会从环境变量中查找后执行,而我们需要执行的文件由于没有在环境变量中,因此需要指明当前位置的可执行程序
环境变量:
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
环境变量本质上就是一个内存级的一张表,这张表由用户在登陆系统的时候,给特定用户形成属于自己的环境变量表。每一个环境变量都有自己的特定的应用场景
环境变量记录了当前用户是谁,用于区分是否对文件有权限,是针对特定用户在特定的场景下使用的
环境变量通常具有全局属性,采用 export 变量名称 的方式可以将该变量加入环境变量中,从而被子进程继承。如果不是用export,那么这个变量仍然会被shell记录下来,但是只在shell内部有效,无法在子进程中调用
通过 echo +名称 的方式查看特定环境变量
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
如上图所示,在更改了环境变量之后,诸如ls之类的命令无法运行,但是执行自己写的mytest文件不需要./了
为了能同时运行自己写的程序和原有指令,采用export PATH=$PATH:路径
set:显示与设置Shell变量信息
set命令的功能是用于显示与设置Shell变量信息,管理员亦可以用该命令设置Shell终端特性,更好符合日常工作需要。
env:显示和定义环境变量
env命令来自于英文单词environment的缩写,其功能是用于显示和定义环境变量。为了能够让每个用户都拥有独立的工作环境,Linux系统使用了大量环境变量,平时要想查看和修改则可以用env命令进行管理。
通过代码获取环境变量
#include<stdio.h>
//这个程序的目的是展现环境变量,main函数中的char *envp[]是一个指针数组
//本质上是一个数组,内部存储的是指针
int main(int argc,char *argv[],char *envp[])
{
int i=0;
for(i=0;envp[i];i++)
{
printf("envp[%d]->%s\n",i,envp[i]);
}
return 0;
}
通过系统调用获取或设置环境变量
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
char* user=getenv("USER");
if(user==NULL) perror("getenv fail/n");
else printf("user:%s\n",user);
return 0;
}
十一、进程地址空间
![](https://i-blog.csdnimg.cn/blog_migrate/d16355132fae0743ee19e67b71708308.png)
首先来看一段代码
#include<stdio.h>
#include<unistd.h>
#include<assert.h>
int g_value=100;
int main()
{
pid_t id =fork();
assert(id>=0);
if(id==0)
{
while(1)
{
printf("我是子进程 我的id是:%d,我的父进程是:%d,g_value:%d,&g_value=%p\n",\
getpid(),getppid(),g_value,&g_value);
sleep(1);
g_value++;
}
}
else
{
while(1)
{
printf("我是父进程 我的id是:%d,我的父进程是:%d,g_value:%d,&g_value=%p\n",\
getpid(),getppid(),g_value,&g_value);
sleep(2);
}
}
}
这段代码的运行结果如下图所示,可以看出父进程的g_value的值并不会改变。也就是说子进程对全局数据的修改并不会影响父进程,由此反映出进程的独立性。
进程=内核数据结构+代码和数据。子进程是父进程的写诗拷贝。
由下图可以看出,对于同一个地址的读取会出现不同的数值。如果是物理地址不可能读取同一个变量的地址而出现不同的数值。因此可以得出结论该地址一定不是一个物理地址,也就是说在语言层面所使用的地址并不是物理地址,而是虚拟地址(线性地址)
操作系统负责将虚拟地址转化成物理地址,每个进程都向操作系统索求内存空间,操作系统并不会完全按照需求来分配空间,而是给每个进程分配一定的进程地址空间
操作系统通过页表将地址空间转化为物理内存
为什么会出现同一个地址出现不同的值?
当一个进程被创建,操作系统会自动生成一个页表,用于进行虚拟地址与物理地址的转换。如上图所示,当子进程被创建后,页表指向的物理地址与父进程相同。而在子进程中对g_value的值进行操作,由于进程的独立性,操作系统会自动在物理地址中找一块新的内存空间用于子进程的数值更改,实际上的物理地址改变了,但页表的索引并没有改变,因此显示的虚拟地址不变。从而产生了对于同一个地址却又多个不同数值的现象
如果没有进程地址空间会怎么样?
相当于对每个进程都直接在物理内存开辟一块空间,每个进程的空间都是紧挨着的。每一个进程的内存空间内依次存放着进程的相关信息与需要执行的代码块,cpu依据pcb找到进程所对应的代码执行。但是如果代码有问题,cpu访问的地址越界了,导致其他进程内存中的数据改变,无法保证进程的独立性。所以引入页表,通过虚拟地址在页表中的映射减少对其他进程的内存空间的访问,从而减少问题的出现。
进程地址空间拓展:
1.可以防止地址随意访问,保护物理内存与其他进程
2.在malloc一个空间的时候,操作系统只在程序需要的时候分配空间,而不是在申请的时候就分配空间。这样是为了避免申请空间与使用的时间差,如果有大量的进程进行这样的操作,会导致空间使用率过低。开辟的这块空间在未使用的时候别的进程仍然可以使用,但不会在同一个进程内重复malloc
3.将进程管理和内存管理进行解耦合
4.程序在被编译并且还没有被加载到内存里时,程序内部就已经有地址了。源代码在编译的时候,就是按照虚拟地址空间的方式对代码和数据进行编号