1. 前言,冯诺依曼体系结构
冯·诺依曼体系结构是计算机领域的一种设计理念,由美籍匈牙利科学家约翰·冯·诺依曼提出。
该体系结构的主要特点包括:
- 存储程序:程序和数据以二进制形式存储在计算机的存储器中。
- 程序控制:计算机按照程序中指令的顺序依次执行操作。
- 五大组件:
- 运算器:负责执行算术和逻辑运算。
- 控制器:控制计算机的操作,协调各组件的工作。
- 存储器:存储数据和程序。
- 输入设备:用于将数据输入到计算机中。
- 输出设备:将处理结果输出。
关于冯诺依曼体系结构,必须注意以下几点:
- 这里的存储器指的是内存。
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备),因为外设的速度太慢,根据短板效应,会导致整机的效率太低!
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
- 一句话,所有设备都只能直接和内存打交道。
计算机里几乎所有的设备都有数据存储的能力!
对于CPU这个设备,它的处理速度是最快的,紧接着是内存,然后是各种外设(磁盘)。
由此就能得到一个存储分级图:
以CPU为中心,距离CPU越近,存储效率越高,但是相应的造价也就越贵!
- 如果我们一味的只追求速度,不断地扩大寄存器和Cache级别存储的容量是可以做到的吗?答案是肯定的,但是这样所带来的结果一定是这台计算机将会非常的昂贵,哪怕是我们的超级计算机也没有采用这种方案。
- 如果我们一味的只追求价格,全部都用便宜的存储介质,虽然价格很便宜,但是这样带来后果就是计算基本用不了。
当代的普通计算机,兼顾了价格和性能,基本做到了家家都能用的起计算机。
冯·诺依曼体系结构的优点包括:
- 通用性强:适用于多种不同的应用和任务。
- 结构简单:易于理解和实现。
- 可扩展性好:能够通过增加硬件和软件来扩展计算机的功能。
冯·诺依曼体系结构对现代计算机的发展产生了深远影响,大多数计算机都采用了这种体系结构。然而,随着技术的不断进步,也出现了一些对冯·诺依曼体系结构的改进和扩展。
2. 操作系统
2.1 概念
操作系统是一款进行软硬件资源管理的软件!同时也是计算机第一个加载的软件。
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
那么为什么要有操作系统呢?
操作系统是通过将软硬件资源管理好(手段),给用户提供良好(易用、稳定、高效、安全)的使用环境的(目的)。
2.2 如何理解“管理”
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的搞“管理”的软件。
操作系统管理的核心是:
- 进程管理;
- 内存管理;
- 文件/IO管理;
- 驱动管理。
既然操作系统是计算机系统的管理者,那么操作系统是如何做到管理这些软硬件的呢?
首先,答案是这六个字:
==先描述,再组织==。
任何管理工作都可以用这六个字进行计算机建模!就拿我们在C/C++的小项目来说,在写项目之初,我们总是先写struct/class。好比我们写学生信息管理项目,我们一定会先创建一个student类或者student结构体,来存储学生的各类信息,这就是先描述。随后根据这些类和结构体进行详细的方法实现,这就是再组织。
2.3 系统调用和库函数概念
由这个操作系统层状图可以看出,用户在最上层,一般一个用户想要访问最底层的OS数据或者访问硬件,那么就必须要贯穿整个层状结构!但是操作系统对我们是“不信任”状态,怕群众中有坏人,直接破坏了一些操作系统的底层,所以用户必定会调用系统调用!
但是,系统调用使用起来会比较麻烦
- 站在使用系统的人,使用诸如shell、图形化界面这一类的外壳程序,通过这些外壳程序进行系统调用,就会方便很多。
- 站在系统开发人员的角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而打包形成库,专业人士做专业的事,这些库交给他们去维护,有了库,对于开发者就很有利于更上层用户或者开发者进行二次开发,从而提高了开发效率并且降低了开发了成本。
3. 进程
3.1 进程的基本概念
课本概念:
程序的一个执行实例,正在执行的程序等
内核观点:
担当分配系统资源(CPU时间,内存)的实体。
操作系统中可能会同时存在非常多的“进程”,那么操作系统是如何管理如此之多的进程呢?答案依旧和我们上面所提到的方式一样:先描述,再组织!
3.2 描述进程——PCB
Linux是用C语言写的,所以描述进程就需要用到struct
结构体,结构体里包含了进程的各种属性,这个结构体也就叫做进程控制块。
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),而Linux操作系统下的PCB具体是是: task_struct。
把PCB抽象成struct
结构体代码:
struct PCB
{
// id
// 代码地址&&数据地址
// 状态
// 优先级
// 链接字段
struct PCB *next; // 下一个PCB的地址
};
此时,回答一个问题,什么是进程?
- 进程就是把磁盘中的程序加载拷贝到内存中去。
- 进程 = 可执行程序 + 内核数据结构(PCB)
PCB就是为了方便OS对进程进行管理。
3.2.1 task_struct
什么是task_struct?
- task_struct是PCB的一种。
- 在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
3.2.2 task_ struct内容分类
1. 标识符(pid or process id):描述本进程的唯一标识符,用来区别其他进程。
查看pid的命令:
ps options
进程状态查看:
ps aux / ps ajx command
(这三个字母的顺序可以任意颠倒)
例如,使用命令查看可执行程序的进程状态:
ps ajx | grep test
这样看起来好干,不知道每一列的数据对应的都是什么意思,于是我们想查看列属性:
ps ajx | head -1
进程的第一行信息就显示出来了。
拼接一下命令:ps ajx | head -1 && ps ajx | grep test
于是就可以得到这样的结果
此时,终止右边的进程,再次查询时会发现:./test
不见了,也就是我们的可执行程序执行结束了,也就不再属于进程了。
由此可以得出一个结论,我们所有的指令、软件包括自己写的程序等,只要运行起来了,最终都是进程!
那么如何使用代码获取自己的pid呢?来看一下man手册里的部分描述:
用一段代码举个栗子:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
int count = 100;
while (count--)
{
pid_t id = getpid();
printf("%d, 我的pid:%d\n", count, id);
fflush(stdout);
sleep(1);
}
printf("\n");
return 0;
}
运行一下:
这时就能看到该可执行程序的pid了,同时我们也证明了这个pid准确无误。
如果想要结束一个进程呢?命令如下
kill -9 pid
(杀死pid为xxx的进程)
通过上面举的一系列的栗子,不难发现进程每次运行所分配的pid都不一样,但是又能发现PPID都是,这是为什么呢,这个PPID又是什么呢?
进程还有自己的父进程id,这个PPID就是父进程ID,在命令行中,父进程一般就是命令行解释器bash!
引申一下,在Linux中创建进程的方式有:
- 命令行中直接启动进程,也就是手动启动。
- 通过代码来进行进程创建。
启动进程的本质就是创建进程,一般都是通过父进程创建的!以父进程为模板,就父进程中大部分的属性和内容赋给了子进程,所以Linux中的进程关系就注定会有一种关系,叫作父子关系。Linux中的进程派生创建是单血缘关系。
我们在命令行启动的进程,都是bash的子进程!
那么如何使用代码获取父进程的pid呢?其实和获取pid的方式基本相似,来看一下man手册里的部分描述:
同样用一段代码举个栗子:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("my pid is <%d>, my parent pid is <%d>", getpid(), getppid());
sleep(1);
}
return 0;
}
除了ps
外,Linux中查看进程还有第二种方式:
在Linux下存在一个
proc
目录,它是Linux为我们维护的动态目录结构。使用命令ls /proc/
进入该目录:
验证一下:
以上是分别在进程运行时和把进程结束的情况所查看的目录,不难发现当进程运行时,我们能找到对应的进程目录,结束时目录也就被杀死结束了。
得到结论:
proc
是一个动态的目录结构,它存放了所有存在进程的目录名称,这个目录名称就是以pid命名的。
我们来看一下这个进程目录里有什么东东:
(这里我截取了一部分)exe
:这个进程依旧能找到我们的可执行程序具体在哪个目录下(看来没有忘本o(╥﹏╥)o)。
cwd
:全称current work directory也叫当前工作目录,当前进程记录了该进程的当前工作目录。默认情况下,进程启动所处的路径就是当前路径!
如何更改当前工作目录呢?使用函数chdir()
,如下chdir的用法:
2. 状态: 任务状态,退出代码,退出信号等。
3. 优先级: 相对于其他进程的优先级。
4. 程序计数器: 程序中即将被执行的下一条指令的地址。
5. 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
6. 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
7. I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
8. 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
9. 其他信息
3.3 通过系统调用创建进程——fork初识
如何理解启动进程这种行为呢?
本质其实就是系统多了一个进程,OS要管理的进程也就多了一个,进程 = 可执行程序 + task_struct对象(内核对象),创建一个进程就是要申请内存,保存当前进程的可执行程序 + task_struct对象,并将task_struct对象添加到进程列表中!
3.3.1 fork使用
首先运行一下 man fork
来认识fork
(好长好长,截取一部分看看吧~)
调用一下fork()
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("I am a parent process,my pid is <%d>\n", getpid());
int ret = fork();
while (1)
{
printf("I am a process,my pid is <%d>,ppid is <%d>,ret=%d\n", getpid(), getppid(), ret);
sleep(1);
}
return 0;
}
观察现象:
我们发现,
fork()
函数有两个返回值,其中一个返回值返回的是子进程的pid,另一个返回值是0。结合目前能得出结论,只有父进程执行fork()
之前的代码(此时只有父进程没有子进程),fork()
之后,父子进程都要执行后续的代码。
震惊!!一个函数居然会有两个返回值?!
fork代码的一般写法:
- 我们为什么要创建子进程?
因为我们想要子进程协助父进程完成一些工作,有些工作是单进程解决不了的!- 我们创建子进程就是为了让子进程和父进程做不一样的事情的,那么如何保证他们是做不一样的事的呢?
可以通过判断fork的返回值,判断是谁父进程谁是子进程,然后让他们执行不同的代码片段!
那么fork在现实中到底应该怎么使用呢?这里使用代码模拟一下类似于迅雷的边下边播功能:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("I am a parent process,my pid is <%d>\n", getpid());
pid_t ret = fork();
// 进程创建失败
if (ret < 0)
{
perror("fork");
return 1;
}
else if (ret == 0)
{
// child
while (1)
{
printf("I am a child process, my pid is <%d>, ppid is <%d>, ret=%d, loading...\n", getpid(), getppid(), ret);
sleep(1);
}
}
else
{
// Parent
while (1)
{
printf("I am a parent process, my pid is <%d>, ppid is <%d>, ret=%d, playing...\n", getpid(), getppid(), ret);
sleep(1);
}
}
return 0;
}
可以说震碎三观的又来了,两个条件判断同时进行,两个while死循环同时进行,这在我们日常书写代码是根本不可能发生的事儿~
3.3.2 fork原理
1.fork干了什么事情
fork创建子进程,系统中会多一个子进程
- 以父进程为模板,为子进程创建PCB(模板并不是严格的拷贝,只是大部分属性相同,少部分属性不同就比如子进程的pid和ppid和父进程就不同)
- 我们所创建的子进程,是没有代码和数据的!!目前是和父进程共享代码和数据!所以,fork之后,父子进程会执行一样的代码。
fork之前父子进程也是都能看到所有代码的。
但是为什么子进程不从头开始执行呢?
我们日常的程序也要从上到下按顺序执行,这是因为在计算机中存在pc程序计数器/eip指针(保存当前正在执行的指令的下一个,当执行完当前指令时,pc/eip会自动往后更新),当父进程执行完fork,pc/eip指向fork后续的代码,如果从头开始执行,pc/eip也会被子进程继承,从而会导致和父进程一样再次创建子进程从而造成死循环~
2.为什么fork会有两个返回值
抽象一下fork函数
我们知道,fork之后代码会共享,这里可以理解一下,就是fork做完核心工作的代码会共享,从上图可以看到,return也是代码也会被共享,因此父进程被调度要执行return,子进程被调度也要执行return,所以说fork就会有两个返回值。(但真是情况是,操作系统是通过一些寄存器来做到返回值返回两次的)
3.为什么fork的两个返回值,会给父进程返回子进程的pid,给子进程返回0
4.fork之后,父子进程谁先运行
fork之后创建完子进程只是一个开始,创建完子进程之后,系统的其他进程,父进程和子进程接下来要被调度执行。当父进程的PCB都被创建并在运行队列中排队的时候,哪一个进程的PCB先被选择调度,哪个进程就先运行!所以说,谁先运行是不确定的,由各自PCB中的调度信息(如时间片、优先级等)和调度算法共同决定,也就是由操作系统自主决定!
结束父进程不会影响子进程,结束子进程也不会影响父进程。进程之间运行的时候,无论进程之间是什么关系,是具有独立性的!那么系统是如何做到这种独立性的呢?
进程间的独立性,首先是表现在有各自的PCB,代码本身是只读的,所以进程之间不会相互影响。
虽然代码是共享的,但是数据父子进程是会修改的,它们会想办法将数据各自私有一份!那么又是怎么做到的呢?
写时拷贝,在最开始的创建子进程的时候,我们是以浅拷贝的方式让子进程共享代码的,一旦父子进程开始尝试进行写入时,系统会把对应的变量进行深拷贝。
5.如何理解同一个变量会有不同的值
返回的时候发生了写时拷贝,所以同一个变量会有不同的值。