冯诺依曼体系结构
冯·诺依曼结构也称普林斯顿结构,是一种将程序指令存储器和数据存储器合并在一起的存储器结构。冯·诺依曼提出了计算机制造的三个基本原则,即采用二进制逻辑、程序存储执行以及计算机由五个部分组成(运算器、控制器、存储器、输入设备、输出设备),这套理论被称为冯·诺依曼体系结构。
我们常见的计算机,如笔记本。和我们不常见的计算机,如服务器等,大部分也都遵守冯诺依曼体系结构。
截至目前,我们所认识的计算机,都是有一个个的硬件组件组成:
- 输入设备:包括键盘, 鼠标,扫描仪, 写板等。
- 中央处理器(CPU):含有运算器和控制器等。
- 输出设备:显示器,打印机等。
Tips:
- 这里的存储器指的是内存。
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)。
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
一句话,所有设备都只能直接和内存打交道。
对冯诺依曼的理解,不能停留在概念上,要深入到对软件数据流理解上。
我们以QQ为例:
操作系统(Operator System)
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解。
操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)。
- 其他程序(例如函数库,shell程序等等)。
设计OS的目的:
- 与硬件交互,管理所有的软硬件资源。
- 为用户程序(应用程序)提供一个良好的执行环境。
对于我们使用者来说,我们不能直接对计算机的软硬件资源进行管理,但我们可以通过操作系统间接地对软硬资源进行管理。
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件。
那么,如何理解"管理"呢?
一句话:先描述,再组织。
- 描述起来,用struct结构体。
- 组织起来,用链表或其他高效的数据结构。
首先,进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合(描述)。再由OS来下达指令,驱动程序执行指令去统计各个硬件的基本信息、和工作情况等数据,然后上报给OS,OS把这些数据利用相关的数据结构保存起来,比如用链表地形式将描述出来地数据结构连接在一起即为组织。这样OS只需要在该数据结构中找到对应硬件的信息,并对这些信息进行处理,然后将处理过后的结果下达给驱动程序,驱动程序再下达给硬件,这样OS就不需要与硬件直接进行接触从而实现软硬件资源的管理了。
例如:
Linux操作系统下的PCB是task_struct,task_struct是linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息,这就是描述。
而组织就是把这一个一个task_struct组织起来,使所有运行在系统里面的进程都以task_struct链表的形式存在内核中。
统调用和库函数概念
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
系统调用接口是由一系列系统调用函数构成的特殊的接口。程序员或应用程序通过该特殊的接口取得操作系统内核所提供的服务,它是专为程序员编程时使用,是应用程序和系统内核通信的桥梁。也就是说,在应用程序中使用的系统调用是以函数的形式展现在用户面前,提供给用户使用。
设计系统调用接口既保证了OS自身的安全,又为我们提供了良好的服务。
因为OS会防止使用者因为错误操作而导致系统出错,所以OS会对用户进行一系列的限制,防止用户进行错误操作,但是用户同样也会有开发需求。这个时候,用户就可以通过系统调用接口来进行开发。
也就是说系统调用接口是直接与操作系统进行交互的。而系统调用封装形成的库等就形成了用户层面的操作接口,直接由用户使用。
进程
对于操作系统来说,它是怎么管理进行进程管理的呢?很简单,先把进程描述起来,再把进程组织起来。
基本概念:
- 课本概念:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(CPU时间,内存)的实体。
举个例子,如果我们要执行某个进程,首先需要把对应的数据从外设加载到内存,然后交给CPU执行。
但是我们不可能把进程的所以内容全部加载进内存,于是OS会把进程的基本信息如标识符,状态等先加载进入内存,这就是描述。
同样的对于加载进内存的每一个程序,OS都会对其进行统计和编号,并把这些信息统计起来存入对应的节点中。只有完成这一步才能将这个程序叫做进程,而这一步就是组织。
等到需要执行进程时,OS就会通过这些提前加载的数据,再去把进程所需要的其他文件从磁盘中加载到内存中进行执行。
对于进程来说,我们一定要把它与程序分开来:
- 程序是指令和数据的有序集合,是一个静态的概念。而进程是程序在处理机上的一次执行过程,它是一个 动态的概念。
- 程序可以作为一种软件资料长期存在,而进程是有一定生命期的。程序是永久的,进程是暂时的。
- 进程是由进程控制块、程序段、数据段三部分组成。
- 进程具有创建其他进程的功能,而程序没有。
- 同一程序同时运行于若干个数据集合上,它将属于若干个不同的进程,也就是说同一程序可以对应多个进程。
- 在传统的操作系统中,程序并不能独立运行,作为资源分配和独立运行的基本单元都是进程。
描述进程-PCB
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(process control block),Linux操作系统下的PCB是:task_struct。
task_struct是PCB的一种
在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。task_ struct内容:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息等。
组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。如果在情况复杂的情况下,双链表中的节点也有可能存在在其他的数据结构中,例如队列等。
查看进程
进程的信息可以通过 /proc 系统文件夹查看。
如:要获取PID为1的进程信息,你需要查看 /proc/1 这个文件夹。
如果要想查看一个进程的基本信息,我们也可以利用命令ps
列出当前系统所用进程信息。
ps -aux #
查看系统中所有的进程信息。
ps -axj #
可以查看进程的父进程号。
最后还可以使用top
命令来查看进程:
top指令就是我们熟知的任务管理器,其中的信息默认3秒更新一次。
这里以ps指令为例来对进程进行查看。
输入指令ps -axj | head -1 && ps -axj | grep "process"
来获取表头和带有process的关键字的进程。
先建立一个名为porcess的程序,代码为:
执行结果如下:
其中在进程处我们可以检测到一些参数:
其中PID相当于这个进程的编号,每个进程的PID都是唯一的,例如我们可以看到process进程的PID是12787。
PPID是父进程的PID,STAT则是该进程的状态,这些我们之后详细介绍。
既然一个进程有他的PID的,那么我们如何在代码中获取自己的PID呢?
这个时候就需要介绍两个系统调用接口了。
输入getpid()
和getppid()
可以分别查看当前进程和父进程的PID。
测试代码如下:
执行结果如下:
不难发现,通过ps指令得到的PID和PPID和通过系统调用接口得到的是一样的。
但是这次PID与之前的PID不同了。
这是因为每个进程的PID都是OS分配的,不可能每次进来都分配相同的PID。
但是PPID都是相同的,因为他们都有一个相同的父进程,也就是bash,也就是命令行解释器或者说是Shell。
最后,当我们想要结束掉某个进程时,我们可以利用快捷键Ctrl+c强行结束掉进程。
或者使用命令kill -9 pid
来结束进程。
通过系统调用创建进程(fork())
现在我们已经知道了对于进程来说,有父进程和子进程的说法。
也知道了我们的进程都有一个共同的父进程即Shell(bash),而Shell的一个作用就是执行命令的时候可以创建子进程来执行。
也就是说我们的进程是由Shell这个父进程创建的子进程。
Shell本身也是一个程序,只是在加载操作系统的时候一起被加载进内存了,然后成为一个进程。
当我们输入命令./process时,shell就能获取到该命令,然后向OS传递这个指令需要进行的工作,然后OS执行相应的操作。在这里就是Shell告诉OS在自己(bash)这个进程下面创建一个名为“process”的子进程。
因此Shell可以说是一切从命令行进来的程序的父进程。
这里我们需要注意Shell是没有能力创建进程的,创建进程的实际操作是又OS完成的!Shell只是向OS申请。
同理,由于Shell是一个进程,它也是可以被kill掉的,不过会导致Shell崩溃。
总结:
1、Shell本质上也是一个进程。
2、从Shell启动的程序都将变成进程,而该进程对应的父进程就是Shell。
3、Shell可以向OS申请创建子进程。
了解了父进程的概念之后,就不难理解fork()了。
fork接口就是使我们能在自己的程序下面创建子进程的接口。
对于fork来说,它的返回值的是:
1、如果进程创建成功,则给父进程返回其创建的子进程的pid,给其子进程创建的子进程返回0。2、进程创建失败返回-1。
先看一段代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("AAAAAAAA\n");
int ret = fork();
printf("BBBBBBBB\n");
if (ret < 0)
{
perror("fork");
return 1;
}
else if (ret == 0)
{ //child
printf("I am child : %d!, ret: %d\n", getpid(), ret);
}
else
{ //father
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}
执行结果如下:
不难发现AAAAAAAA输出了一次,BBBBBBBB输出了两次。
这是因为fork()会生成一个子进程,内容是父进程从fork()处开始的拷贝。
而ret的值的不同也说明了父子进程之间是相互独立,互不影响的。
fork的本质是在PCB链表后面在增加一个pcb节点,并以继承父进程的方式来填充该新pcb节点,当然也不是全部继承,子进程的pcb也有自己的隐私,比如pid。但是子进程在代码和数据方面刚开始时与父进程共享同一块。不是我们想象在内存空间中再拷贝一份一摸一样的数据,那么ret又为什么会有两个不同的值呢?这就要提到写时拷贝了,感兴趣的可以去了解一下。