前言
计算机体系结构——冯诺依曼体系结构
计算机,都是有一个个的硬件组件组成
计算机的硬件有:
- 输入设备:键盘, 鼠标,磁盘,网卡等
- 中央处理器(CPU):含有运算器和控制器等
- 输出设备:显示器,音响,磁盘,网卡等
- 存储器:内存
💡注意事项:
- ⭐这里的存储器指的是内存,不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备),外设要输入或者输出数据,也只能写入内存或者从内存中读取。
- 因为外设和cpu交互,速度是有很大级别的差距。cpu和内存交互是因为内存可以由软件优化,比如操作系统预先把外设的数据加载到内存中,由cpu和内存交互从而优化大大提高计算机的运行效率。
- ⭐cpu读取数据(数据+代码),都是要从内存中读取。站在数据的角度,可以认为cpu不和外设直接交互。
- cpu要处理数据,需要将外设中的数据加载到内存。站在数据的角度,外设只和内存打交道。
一、操作系统
1. 操作系统概念
任何计算机系统都包含一个基本的程序集合/软件,称为操作系统(OS)
笼统的理解,操作系统包括:
-
内核:包含进程管理,内存管理,文件管理,驱动管理
-
其他程序,例如函数库, shell程序等
2. 为什么有操作系统?
对上:提供一个良好的使用环境
对下:通过管理好软硬件资源的方式保证系统的稳定性。
3. 操作系统的定位
在整个计算机软硬件架构中,操作系统的定位是: 一款纯正的⭐搞管理的软件
4. 如何理解管理
- 在计算机学科中管理的理解就是:⭐先描述,再组织
- ⭐Linux内核是用c语言写的;Linux中计算机管理硬件或进程都是先用struct结构体根据描述类型定义对象,再用链表或其他高效的数据结构组织起来
- 操作系统内部有大量的数据结构和算法,数据结构存储被管理对象,算法实现对被管理对象的增删查改
类比在我们的学校,校长像操作系统一样是管理者,辅导员像驱动程序一样是执行者(驱动程序也是软件),学生像硬件一样是被管理者。
管理者和被管理者可以不直接沟通,正如校长和我们没有直接接触却可以管理我们。
管理者拿到被管理者的核心数据,来支持管理决策,正如校长拿到学生绩点的数据,所以说管理是对被管理对象的数据进行管理。
5. 系统调用和库函数概念
- 操作系统对外会表现为一个整体且对所有人都不信任,只是会暴露自己的部分接口供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
- Linux是用C语言写的,系统调用接口(system call)的本质就是用C语言提供的函数。
- 系统调用在使用上,功能比较基础并且对用户的要求相对也比较高。所以,有些开发者可以对部分系统调用进行适度封装,从而形成库。有了库,就很有利于更上层用户或者开发者进行二次开发。
二、进程
1. 基本概念
- 其实我们启动了一个软件,本质上就是启动了一个进程!
- 在Linux下运行一条命令
./xxx
,其实就是在系统层面上创建了一个进程。 - Linux系统是可以同时加载多个程序的,也就是说Linux系统是可能同时存在大量的进程的。
- 在Linux中管理进程的方式也是先描述(👇PCB),再组织。
- 进程 = 对应的代码和数据(可执行程序)+该进程对应的PCB结构体! ⭐⭐⭐
2. 描述进程-PCB
- 程序/文件=内容+属性,当我们运行一个程序,它加载到内存中,不仅仅把的代码和数据加载进来,还要把它对应的属性加载进来。
- 操作系统管理一个进程,需要一个数据结构来描述这个进程。进程信息被放在一个叫PCB进程控制块(struct)的数据结构中,可以理解为进程所有属性的集合。
- 对进程的管理就变成了对进程PCB结构体链表的增删改查。
- PCB也就是进程控制块(process control block),在Linux中描述进程的结构体叫做task_struct。task_struct是PCB的一种。
3. PCB简介
- PCB全称伪process control block,意为进程控制块,是一个结构体。
- 不同的操作系统中PCB名字是不一样的,在Linux中描述进程的结构体叫做 task_struct。
- task_struct 是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的属性信息。
task_ struct 属性分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程(身份证号)
- 状态: 任务状态,退出代码,退出信号等
- 优先级: 相对于其他进程的优先级,确定先后
- 程序计数器: 程序中即将被执行的下一条指令的地址
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等
- 其他信息
4. 组织进程
所有运行在系统里的进程都以task_struct描述起来,并且以双链表 的形式存储在内核里
5. 查看进程
ps
该命令默认只能查看这个终端下对应的进程
ps axj
查看系统中所有的进程
ps axj | grep ‘进程名’
查看这个进程的属性信息
ps axj | head -1 && ps axj | grep '进程名'
查看这个进程的属性信息并且列出对应的属性名。
top
该命令也可以显示系统中的进程(相当于window下的任务管理器,但是不方便,q退出)
进程的信息也可以通过 /proc 系统文件夹查看,要获取PID为1的进程信息,你需要查看 /proc/1 这个文件夹。
举例:我的可执行程序为 myproc,运行期间的进程信息👇
- 进程id(PID,process id)
- 父进程id(PPID,parent process id)
6. 通过系统调用获取进程标示符
//两个头文件 #include <sys/types.h>, #include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t pid=getpid(); //获取自己的进程PID⭐ ,返回值是pid_t
pid_t ppid=getppid();//获取自己的父进程PPID,没有其他指令默认是bash进程
printf("pid: %d\n", pid);
printf("ppid: %d\n", ppid);
return 0;
}
⭐ 可以在Linux终端输入命令
kill -9 进程的PID
中止进程
7. 通过系统调用fork( )创建子进程
格式:
#include<unistd.h>
pid_t fork(void);⭐
返回值:
失败时:返回-1;
成功时:给父进程返回子进程的pid,给子进程返回0
//fork()有两个返回值⭐
举例:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
printf("i am parent process :pid: %d\n",getpid());
pid_t ret =fork();
//变成两个进程,一个父进程,一个子进程
printf("ret:%d pid:%d ppid:%d\n ",ret,getpid(),getppid());
sleep(1);
return 0;
}
运行结果👇
8. fork( )基本用法
⭐ fork()是个函数,是操作系统调用接口
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
pid_t id=fork();
//变成两个进程,一个父进程,一个子进程
if(id<0)
{ //创建失败
perror("fork");//打印出fork失败的原因,是C语言提供的出错接口
return 1;
}
else if(id==0)//id在子进程里面是0
{
//child process(task)
while(1)
{
printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
//parent process
while(1)//id在父进程里面是子进程的pid
{
printf("I am father,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
sleep(1);
return 0;
}
🚨结论:
-
我们可以通过fork()创建父子进程,根据返回值的不同,让父子进程执行不同的代码。
-
父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)。
-
fork之后,代码是父子进程共享的,但是因为id值的不同,所以父子进程执行不同的代码 。
-
可以惊奇的发现,else if 和 else可以同时执行,并且两个死循环也同时执行❗ 主要是因为fork之后有两个不同的执行流。
-
fork之后,给父进程返回子进程的pid,给子进程返回0。因为父进程:子进程 = 1 : n ,父进程可以有很多子进程,子进程只能有一个父进程,这么设计是为了让父子进程都可以互相标识。
-
同一个变量id,会有不同的值❓ 这个问题会在进程地址空间详解
fork为什么会有两个返回值❓
- fork()是操作系统的接口,代码的实现是在操作系统里的。
- fork有两个返回值是因为fork内部,父子各自会执行自己的return语句。
- 但是返回两次并不代表一个变量会保存两次。
那么创建子进程时候,操作系统要做什么呢❓
本质就是系统多了一个进程,要新建一个 task_struct结构体,其内部属性要以父进程为模板创建。
操作系统和cpu运行某一个进程,本质是从task_struct形成的队列中挑选一个task_struct,来执行它的代码❗进程调度也就是在task_struct形成的队列中选择一个进程的过程❗ (只要想到进程,优先想到进程对应的task_struct)
父子进程哪一个被先运行❓
谁先运行不一定,这个是由操作系统的调度器决定的。
三、进程状态
1. 进程状态分类
- 新建:字面意思
- 运行:task_struct 结构体在运行队列中排队,就叫运行态
- 阻塞:等待非CPU资源就绪就是阻塞状态。系统中一定是存在各种资源的(磁盘,显卡等),同时系统中也不只是存在一种队列。例如scanf的时候不输入一直卡在那里,就是等待键盘数据资源就绪,此时这个状态就是阻塞状态
- 挂起:当内存不足的时候,OS通过适当的置换进程的代码和数据到磁盘,此时状态为挂起,但是PCB还在系统中。
- 退出:字面意思。
2. Linux进程状态分类
在Linux中进程可以分为:
- 前台进程(前台进程会占用bash对话框,它在运行的时候命令行解释器是没办法解释命令的。状态后面有+)
- 后台进程(运行的时候加上&符号,状态后面没有+,杀掉后台进程可以
kill -9 此进程的pid
)
⭐Linux进程状态可以分为:
- R运行状态(running) : 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里,对应上面的运行态。
- S睡眠状态(sleeping): 意味着进程在等待事件完成,这里的睡眠也叫做可中断睡眠,对应的就是上面的阻塞状态。
- D磁盘休眠状态(Disk sleep)也叫不可中断睡眠状态(uninterruptible sleep)可以理解为深度睡眠,不可以被中断,也就是不可以被被动唤醒,在这个状态的进程会等待IO的结束。
- T停止状态(stopped): 可以通过发送 19(SIGSTOP) 信号给进程来停止(T)进程
kill -19 此进程的pid
。这个被暂停的进程可以通过发送18(SIGCONT)信号让进程继续运行kill -18 此进程的pid
。(应用最多就是在调试的场景)- X死亡状态(dead):这个状态只是一个返回状态,瞬时性非常强,你不会在任务列表里看到这个状态。
- Z僵尸状态(zombie):是什么? 为什么?怎么办?👇
👀当服务器压力过大的时候,os会通过一定的手段,杀掉一些处于睡眠进程来起到节省空间的作用。但是如果磁盘在读写数据的时候位于睡眠状态,杀掉此进程会导致数据的丢失,所以操作系统的设计者设计了D状态,防止该事件的发生。
3. 查看进程状态
ps aux / ps axj 命令
ps axj | head -1 && ps axj | grep 进程名 | grep -v grep
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- UID : 代表执行者的身份
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
4. 僵尸进程
是什么?
僵死状态(Zombies)是一个比较特殊的状态:当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵尸进程,这个时候不允许被OS释放,处于一个被检测的状态就叫做僵尸状态。(代码和数据是可以释放的,但是描述进程的PCB还在)
为什么?
是为了让父进程和操作系统来进行回收。
僵尸进程危害
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说, Z状态一直不退出,PCB一直都要维护。
- 那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费。因为数据结构对象本身就要占用内存,是要在内存的某个位置进行开辟空间。
- 内存泄漏
5. 孤儿进程
- 父进程先退出,子进程还在,此时子进程就称之为“孤儿进程” ⭐
- 孤儿进程会被1号init进程(就是系统本身)领养,由init进程回收⭐
为什么要被领养?
因为:当子进程退出的时候,父进程早已不在,需要领养进程来进行回收
四、进程优先级
1. 为什么要有优先级?
- 因为CPU是有限的,进程太多,需要通过某种方式竞争资源!
- 优先权高的进程有优先执行权利,配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
- 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
2. 什么是优先级?
确认是谁应该先获得某种资源,谁后获得该资源。我们可以用一些数据来表明优先级,该数字在PCB结构体内
3. Linux下具体的优先级做法
优先级 = 老的优先级(PRI) + nice值 (NI)
- PRI即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小 进程的优先级别越高。
- NI就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new) = PRI(old) + nice 。当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。
- 可以理解nice值是进程优先级的修正修正数据。所以,调整进程优先级,在Linux下,就是调整进程nice值。nice其取值范围是[-20 - 19],一共40个级别。
- 修改nice值,
输入 top打开Linux下的任务管理器,再输入 r ,再输入进程的pid , 再输入想要修改的nice值
,每一次设置优先级,老的优先级都是80,不会记录上次的PRI值。
五、 进程之间的特性
- 竞争性: 系统进程数目众多,而CPU资源只有少量,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
- 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行。
- 并发: 多个进程在一个CPU下采用⭐进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
- cpu在执行的时候,并不是把一个进程的代码拿到后就不拿其他的代码了,而是基于时间片(时间片即CPU分配给各个程序的时间),抢占与出让(高优先级插队)使每一个进程在对应的时间段内较为均衡的占有CPU的资源。
进程切换:
六、环境变量
1. 基本概念
⭐环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
当用户自己写了一个程序的时候,是需要加 ./ 来指定这个程序的路径才可以运行的,但是为什么而执行系统程序/命令 ls 可以不带路径呢❓
答:因为这与环境变量有关,这些系统命令在环境变量所指向的路径中,而用户自己写的可执行程序不在环境变量所指向的众多路径之中。
用户可以把自己写的可执行程序拷贝到环境变量指向的路径中,但是这样会污染系统的命令池,所以不建议这么做。可以通过输入命令export PATH=$PATH:可执行程序的路径
(只在本次登录中被修改,永久生效需要修改配置文件),将用户写的可执行程序的路径放到系统的环境变量中,这时执行用户写的可执行程序的时候就不需要再前面加它的路径了。
2. 常见环境变量
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录
- SHELL : 当前Shell,它的值通常是/bin/bash
- 等等
3. 查看环境变量方法
echo $NAME
//NAME:你的环境变量名称
例如:echo $PATH
4. 环境变量相关的命令
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量
5. 环境变量的组织方式
int main(int argc, char* argv[], char* env[])
main函数可以带三个参数👆,前两个是命令行参数,第三个参数 char* env[ ]
是每一个进程在启动的时候,启动该进程的进程(父进程)传递给它的环境变量信息,都可以通过该参数传导过来。
char* env[]
是指针数组类型,数组里面存放的是一个个char*的指针,这些指针保存的是环境变量字符串。这些环境变量以指针数组的形式被一个个维护起来,整个数组作为参数传递给main函数。
每个程序都有一个环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
argc标识命令行参数中传了几个参数,argv [ ]是命令行参数的指针数组
命令行参数就是用户在启动这个可执行程序的时候,给这个程序传入的选项
. / myprc -a -b
,ls -a -l
ls会被当作argv [0],依次递推
命令行参数可以让我们同样的一个程序通过选项的方式选择使用同一个程序的不同子功能,这个参数也是被父进程bash在命令行先拿到再传递给子进程的。
6. 获取环境变量
- 通过代码如何获取环境变量
- 通过命令行第三个参数
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
for(int i = 0;; env[i]; i++)
{
printf("%s\n", env[i]);
}
return 0;
}
- 通过第三方变量environ环境表获取
#include <stdio.h>
int main(int argc, char *argv[])
{
//全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明
extern char **environ;
for(int i = 0;; environ[i]; i++)
{
printf("%s\n", environ[i]);
}
return 0;
}
2.通过系统调用获取或设置环境变量
getenv
⭐
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
printf("%s\n", getenv("PATH"));⭐
return 0;
}
7. 环境变量通常是具有全局属性的
- 环境变量具有全局属性,可以被子进程继承下去
七、进程地址空间
1. 补充知识
之前博客中提到过进程的空间布局图:
那么实际上的进程空间真的如此吗,每个区域都符合上述的布局吗❓
这里可以通过代码验证一下👇
1 #include<stdio.h>
2 #include<stdlib.h>
3 int unval;
4 int val=100;
5
6 int main(int argc,char* argv[],char* env[])
7 {
8 int i=0;
9 for(i=0;i<argc;i++)
10 {
11 printf("argv[%d]:%p\n",i,argv[i]);
12 }
13 int j=0;
14 for(j=0;env[j];j++)
15 {
16 printf("env[%d]:%p\n",j,env[j]);
17 }
18 printf("代码区:%p\n",main);
19 printf("init:%p\n",&val);
20 printf("uninit:%p\n",&unval);
21 char*p1=(char*)malloc(16);
22 char*p2=(char*)malloc(16);
23 char*p3=(char*)malloc(16);
24 char*p4=(char*)malloc(16);
25 printf("heap:%p\n",p1);
26 printf("heap:%p\n",p2);
27 printf("heap:%p\n",p3);
28 printf("heap:%p\n",p4);
29
30 printf("stack:%p\n",&p1);
31 printf("stack:%p\n",&p2);
32 printf("stack:%p\n",&p3);
33 printf("stack:%p\n",&p4);
34
35 return 0;
36 }
//malloc了16个字节,但是给了20个字节。
//多出来的字节用来记录这次申请的属性(cookie信息)信息,free的时候也是根据这个来释放空间的。
在Linux下的运行结果如下👇(window下可能会有偏差):
通过一一对比地址信息,我们可以发现,进程地址空间的布局果真如此❗
2. 什么是地址空间?
当使用fork()
创建一个子进程并且让该子进程修改一个变量的值,再循环打印变量值和地址可以惊奇的发现同一个地址,同时读取的时候,出现了不同的值❗❗❗
💡结论:
- ⭐⭐⭐上图的地址空间绝对不是内存⭐⭐⭐
- ⭐这里的地址,绝对不是物理内存的地址!!!
- ⭐这里的地址是虚拟地址(在Linux中也可以叫线性地址)
- 所有的语言,如果它有地址的概念,这个地址一定不是物理地址而是虚拟地址。
- 硬盘,网卡等外设其实也有寄存器,使用虚拟地址空间统一将不同的硬件对应的设备进行编址,就可以以统一的视角来看待不同的设备。
老式的计算机没有虚拟地址的概念,进程可以直接访问物理内存,而内存是随时都可以被读写的,所以进程之间可能互相干扰造成安全问题。根本原因就是由于直接使用物理地址导致的。
所以现代计算机提出了下面的方式:
要访问物理地址,需要先进行映射(虚拟地址->页表->物理地址)。
- 如果虚拟地址是一个非法地址,映射的机制可以启到禁止映射作用,就无法访问物理空间,就是变相保存了物理内存。
💡综上所述:
- 地址空间的本质是操作系统内的一种数据结构
mm_struct
,用来描述一个进程所能看到的各个区域,地址空间和页表每个进程都私有一份。 - 只要保证,每个进程的页表映射的物理内存是不同的地址,就能做到,进程之间不会互相干扰,从而保证进程的独立性!!!
3. 如何理解区域划分?
地址空间的本质在内核中的区域划分就是在地址空间是一种数据结构,里面至少要有各个区域的划分(区域的划分本质是在一定的范围里定义出start
和end
):
struct mm_struct
{
int code_start;
int code_end;
int init_start;
int init_end;
int uninit_start;
int uninit_end;
int heap_start;
int heap_end;
int stack_start;
int stack_end;
//....其他的属性
}
- 在Linux内核中,struct mm_struct 叫做进程虚拟地址空间
范围变化,本质就是对
start
或者end
标记值+ / -
特定的范围
4. 一个变量怎么保存不同的值
fork函数内部执行完毕后执行return的时候发生了写时拷贝,所以内存中父子进程有属于自己的变量空间,只不过在用户层用同一个虚拟地址来标识,通过不同的页表映射,在物理内存中的不同位置存放。
5. 地址空间是如何设计的?
地址空间并不是内存,而是操作系统内为进程专门设计的一种内核数据结构,里面包含的重点区域是关于各个区域的划分,所以地址空间中会存在大量的start和end还有其他的更多属性。
所谓的地址空间本质上是操作系统看待内存的一种方案
6. 为什么有地址空间?
- 保护了物理内存中的合法数据,包括各个进程以及内核的相关数据。
- 凡是非法的访问或者映射,操作系统都会识别到并终止此进程。
-地址空间和页表是操作系统来维护的,也就意味凡是使用地址空间 +页表进行映射,也一定要在操作系统的监管下来进行访问。
- 内存管理模块和进程管理模块就完成了解耦合。
因为有地址空间和页表的存在,我们的物理内存中,可以对的数据进行任意位置的加载,物理内存的分配就可以做到进程的管理做到没有关系。
- 延迟分配提高整机的效率,使内存的有效使用是几乎是百分之百。
本质上,因为有地址空间的存在,所以上层申请空间,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你,而当真正进行对物理地址内存空间访问的时候,才执行内存相关的管理算法, 帮你申请内存,构建页表映射关系,然后让你进行内存的访问(这一切是由操作系统自动完成,用户零感知。)。
- 地址空间+页表的存在可以将内存分布有序化。
因为在物理内存中理论上可以任意位置加载,物理内存中的几乎所有的数据和代码在内存中是乱序的。
但是因为页表的存在,可以将地址空间上的虚拟地址和物理地址进行映射,是在进程的视角,所有的内存分布都是有序的。
- 不同的进程映射到不同的物理内存在,很容易做到进程独立性的实现。
因为由地址空间的存在,每个进程都认为字节拥有4GB空间(32位),并且每个区域是有序的,进而可以通过页表映射到不同的区域,来实现进程的独立性,每个进程不知道其他进程的存在。
💡补充知识:
- 可执行程序编译的时候,内部已经有地址了,是由编译器完成的
- 页表也是一种数据结构,不仅维护映射关系,还能维护权限
- CPU内部拿到的都是虚拟地址
- 进程要访问的内存的数据和代码,可能并没有在物理内存中(跑完了,操作系统给你释放了)