在这篇文章中,会详细介绍liunx系统下,计算机体系结构,操作系统,以及进程相关的知识。在学习进程前,有一些前导知识放在前面进行介绍。
冯诺依曼可谓是计算机之父,他提出了计算机制造的三个基本原则,即采用二进制逻辑
、程序存储执行以及计算机由五个部分
组成(运算器、控制器、存储器、输入设备、输出设备)。
输入设备: 键盘、鼠标、扫描仪等
输出设备: 显示器、打印机等
运算器、控制器:一般被称为中央处理器(CPU)
注意
- 体系结构中的存储器指的是内存
- 不考虑缓存的情况,CPU能且只能对内存进行读写,不能访问外部设备
- 外设输入或输出数据,只能向内存写入或读取
windows、macos、linux都成为操作系统。任何计算机系统都包括一个基本的程序集合,称为操作系统。其中包括内核(进程管理、内存管理、文件管理、驱动管理) 和 其他程序。
1.与硬件交互,管理所有软硬件资源
2.为应用程序提供运行环境
3.操作系统是管理计算机中软硬件资源的软件
要知道操作系统如何管理资源,需要了解三个东西:
- 管理的是什么
- 如何描述被管理的对象
- 如何组织被管理的对象
在计算机中所有的软件资源都是由代码构成,如何描述要管理的的对象,就用到了我们C语言中的
结构体
。如何组织这些被管理的对象就用到了数据结构中的链表
。
管理 = 描述 + 组织
描述使用结构体,组织使用链表
- 在开发的角度,操作系统会对外表现成一个整体,将自己的部分接口暴露给外界,供给上层开发使用,这部分由操作系统提供的接口,称为系统调用。
- 系统调用在使用上,功能比较基础,对用户的要求也比较高,所以,有些开发者对部分系统调用进行了重新封装,从而形成库,库函数有利于更上层开发者进行开发。
程序:本质上是一个文件,是静态的,存储在磁盘当中。
进程:程序运行起来之后就是进程,是动态的,由操作系统进行管理。
上面讲到了,操作系统管理是 描述+组织,描述使用结构体进行描述,组织使用链表进行组织。进程在运行结束后会自动销毁。
其中,描述的结构是这样的
- 描述结构体:
struct task_struct {...};
,每出现一个进程系统就会创建一个这样的结构体。该结构体也被称为进程控制块(PCB),该结构体中存储了关于进程的所有描述。- 组织:双向链表进行组织
进程PID也叫进程号,在当前操作系统中唯一标识一个进程。
通过命令
ps aux
对所有进程进行查看。
ps aux | grep [要查找的进程]
ps -ef | grep [要查找的进程]
可以查看父子进程号
系统中当前运行的所有进程
将自己写的程序转化为进程并查找
我们想看到自己写的程序变为进程,必须要将程序写为死循环,因为程序运行完后会自动销毁。
查看父子进程号
- 运行态:正在使用CPU资源进行运算的进程
- 就绪态:一切的准备资源都已经准备就绪,等待操作系统分配CPU资源
- 阻塞态:等待某种资源就绪后,才能进行运算。
1.运行状态 R
2.可中断睡眠状态S
3.不可中断睡眠状态D
4.暂停状态T
将正在运行的程序 ctrl+z
,使其暂停,变为暂停状态。在命令行中输入fg
,使其恢复运行。
ctrl+c
,直接结束进程。
5.跟踪状态t
当程序进入gdb调试,就变成了跟踪状态。
6.死亡状态X
当该PCB被标记为死亡状态,是无法通过查看到该进程的,因为该进程即将被释放。
7.僵尸状态Z
8.状态后的+
作用
保存了程序地址空间的首地址,指向程序地址空间的首地址
保存了进程即将要执行的下一条指令(汇编指令)
在调试模式下,输入ni
执行下一条汇编指令
保存了程序运行时的寄存器当前内容
在计算机系统中,进程会有很多条,但是CPU的精力有限,不可能一直只对一个进程进行服务。所以就出现了时间片轮转
。
时间片轮转:每个进程只有很短的CPU使用时间,当时间结束时,就轮到其他进程使用CPU了。但是当其他进程使用完CPU后,就要回到刚才的程序继续运行,如何回到刚才的程序,就用到了上面介绍的
程序计数器
和上下文信息
两个指针,才可以在CPU的时间片轮转中可以一直执行程序。
1. 概念
当程序运行起来了,就会对当前进程块创建一个文件夹,保存该进程的运行信息。
2. 存储位置
在根目录下的 /proc/[pid]
3. I/O信息
在根目录下的 /proc/[pid]/fg
下
CPU的使用率、内存的使用率、CPU的使用时长。在上文中已经做过了介绍。
在一个已经运行起来的程序中,再次创建一个进程。
注意:虽然是子进程拷贝父进程的PCB,但子进程不是从函数头开始运行,而是从fork()
函数调用的下一句开始执行,执行完后再回到父进程中去执行,不然就进入了无限递归。
代码示例
函数的执行过程
子进程拷贝父进程的PCB,但子进程不是从函数头开始运行,而是从
fork()
函数调用的下一句开始执行,执行完后再回到父进程中去执行
#include <stdio.h> #include <unistd.h> int main() { printf("begin fork...\n"); fork(); printf("end for...\n"); return 0; }
- 子进程创建成功:返回值 > 0
- 子进程创建失败:返回值 == 0,返回给子进程
返回值 > 0,返回给父进程,返回值就是子进程的PID。
不是一个函数返回了两个值,而是两个fork函数各返回了一个值
通过函数的返回值,进行判断,以此来实现不同代码的执行
getpid(): 谁调用返回谁的进程号
getppid(): 谁调用返回谁的父进程号
代码示例
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if(pid < 0) { printf("process filed create!"); return 0; } else if(pid == 0) { printf("this is child process, pid = %d, father process pid = %d\n", getpid(), getppid()); } else { printf("this is father process, pid = %d, father process pid = %d\n", getpid(), getppid()); } return 0; }
1.fork创建进程后,父进程先运行还是子进程先运行?
1.子进程在被创建出来之后,在内核当中是一个PCB,被挂载在双向链表中组织起来。父进程以同样的方式进行组织,两者处于平等关系。
2.所以父进程先运行还是子进程先运行并不确定,取决于操作系统如何进行进程间的调度。
3.父子进程之间,也是抢占式执行的。
2.子进程创建出来之后,子进程和父进程之间还有关系吗?
子进程创建出来之后复制父进程的PCB块,之后变成一个新的空间,所以与父进程没有任何关系了。
3.子进程创建出来之后,从哪里开始运行,为什么?
结论:子进程从
fork()
函数之后开始运行
原因:父进程调用
fork()
完毕之后,在父进程的PCB中,程序计数器存储的内容为:调用fork函数之后的下一条汇编指令。上下文信息存储的是fork调用后寄存器的内容。
子进程完全复制了父进程的PCB,所以将其内容也全部都复制了出来,所以子进程的程序计数器指向了fork函数的下一条语句,所以子进程从fork函数后开始执行。
4.在命令行解释器中启动一个进程,该进程的父进程是谁?
bash 命令行解释器
变成Z状态的进程,称之为僵尸进程,也可以说当前这个进程的状态为僵尸状态。
1.通过代码创建一个子进程
2.通过fork返回值,进行分支判断,使子进程先于父进程退出
3.此时子进程就变成了僵尸进程
代码实现
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if(pid < 0) { perror("fork()"); return 0; } else if(pid == 0) { printf("child process pid %d, ppid %d\n", getpid(), getppid()); } else { while(1) { printf("father process pid %d, ppid %d\n", getpid(), getppid()); sleep(1); } } return 0; }
在这一部分,我们先学习两个指令:
kill [pid]
终止当前进程
kill -9 [pid]
同样终止当前进程,威力比前头的大,如果这个命令都无法杀掉进程,则就真的无法杀死。
现在,我们试着通过上述两个指令杀死这个僵尸进程。
1.通过 kill [pid]杀进程
通过实验发现,该僵尸进程依然存在。再试试更厉害的指令。
2.通过 kill -9 [pid]杀进程
同样这个僵尸进程还是存在。
通过上述两个指令对僵尸进行进行清理,发现都不起作用。说明僵尸进程使刀枪不入的。
结论:
僵尸进程再内核中的task_struct结构体,不能够进行释放,占用的空间得不到释放,就会造成内存泄露。如果这种内存泄露积累到一定量,造成的影响将是不可估量的。
1.重启操作系统(这个方法代价太大,不推荐)
2.进程等待(此处不讲解)
3.杀死他的父进程(具体解析见下方孤儿进程)
父进程创建一个子进程,子进程先于父进程结束。子进程再退出时,会向父进程发送一个信号(SIGCHILD),而父进程对于该信号是忽略处理的,导致子进程再退出时,没有进程来回收子进程的资源(PCB),子进程就变成了僵尸进程。
先说现象,再看过程:
- 父进程变成了
1
号进程,当子进程的父进程先于子进程退出后,子进程会被1
号进程领养。1
号进程也称之为:init进程
。没有父进程,子进程就变成孤儿了。- 孤儿进程被
1
号进程领养了之后,子进程就变成了后台进程。bash命令依然可以正常运行。
1.通过代码创建一个子进程
2.通过fork返回值,进行分支判断,使父进程先于子进程退出,子进程一直运行
3.此时子进程就变成了孤儿进程。
从上图可以看出,父进程已经消失了,子进程变成了孤儿进程,但是它的父进程id
变成1
号进程。
在当前页面虽然子程序一直在运行,但是命令行参数依然可以使用,为什么呢?
解释以下为什么命令行参数仍然可以使用?
通过查看进程的指令,查看当前进程的状态,发现当前状态变成了可中断的睡眠状态,但是后头没有+
号,所以他不是前台进程。它是后台进程,所以可以执行命令行参数。
代码实现
#include <stdio.h> #include <unistd.h> int main() { pid_t pid = fork(); if(pid < 0) { perror("fork()"); return 0; } else if(pid == 0) { while(1) { printf("child process pid %d, ppid %d\n", getpid(), getppid()); sleep(1); } } else { printf("father process pid %d, ppid %d", getpid(), getppid()); } return 0; }
如果这个僵尸进程的父进程先没了,这个进程就变成了孤儿进程,变成孤儿进程后总要有人收养,所以
1
号进程收养了这个变成孤儿的僵尸。所以就有东西可以管理这个僵尸了,所以他就被释放了。
这也解释了我们上面的如何杀死僵尸进程的解决方法3。
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数。
例如:当我们代码编写完成后,对代码进行编译,在链接过程中,并没有对链接库所在的目录进行指定,但是可以成功链接到需要使用的链接库,这就是环境变量起到的作用。
根据上述例子,可以看出环境变量具有一些特殊的用途,并且在系统中还具有全局性
。
环境变量加载的顺序:系统环境变量 --> ~/.bashrc --> ~/.bash_profile
1.系统级环境变量 /etc/bashrc
。
所有用户都会加载该文件。所以不推荐修改此环境变量。如果修改环境变量,就去修改对应用户的环境变量。
2.用户级环境变量 ~/.bashrc
或 ~/.bash_profile
修改环境变量时,推荐修改这两个环境变量。
- PATH: 指定命令的搜索路径
- HOME: 指定用户的主工作目录
- SHELL: 当前的shell,它的值通常是
./bash/shell
1.使用echo
查看环境变量内容:echo $PATH
,其中显示出的结果以冒号作为间隔,每一个部分都是一个路径。
2.查看可使用的命令在哪个路径: which [命令]
每一个命令都是一个可执行的程序
3.查看所有的环境变量: env
环境变量以键值对的形式存储
命令范式: export [环境变量名称] = [$环境变量名称] : [新添加的环境变量名称]
注意:一定不要忘记添加 $
,目的是为了取出原来环境中的值在后方拼接,不然就会把原来的环境变量清空。
直接在命令行中执行命令,只针对当前终端有效,退出当前终端也就失效了。
比如说修改自己写的可执行程序作为命令
错误示范: 不加$
的后果。出现这种情况,关掉终端再开启一次就好了。
正确示范
将命令直接写到环境变量文件中,使用 source [环境变量文件]
,使其立即生效,并且关闭终端,命令依旧有效。
通过指针数组来进行存储,数组中的每一个元素都是一个指向某个字符串的指针,数组的最后一位元素一定指向NULL。
- argc: 命令行参数的个数,本质上就是argv数组的元素个数。
- argv: 具体的命令行参数
- env: 环境变量的值
代码实现
#include <stdio.h> int main(int argc, char* argv[], char* env[]) { int i; for(i = 0; env[i] != NULL; i++) { printf("%s\n", env[i]); } return 0; }
输出结果
通过第三方变量进行获取。
代码部分
#include <stdio.h> int main(int argc, char* argv[]) { extern char** environ; int i; for(i = 0; environ[i] != NULL; i++) { printf("%s\n", environ[i]); } return 0; }
结果
getenv()
函数获取函数原型:
char* getenv(const char* name)
参数列表:传递环境变量名称
返回值:返回环境变量的值
代码实现
#include <stdio.h> #include <stdlib.h> int main() { printf("%s\n", getenv("PATH")); return 0; }
结果
程序中所有变量都有属于自己的存储空间,并且物理空间是绝对独立的,一个物理空间存储一个数据。子进程复制父进程的PCB,应该说子进程中所有的变量也会有自己的物理空间,但是看程序说话!
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_val = 0; int main() { pid_t pid = fork(); if(pid < 0) { perror("fork"); return 0; } else if(pid == 0) { printf("child:%d, %d:%p\n", getpid(), g_val, &g_val); } else { printf("father:%d, %d:%p\n", getpid(), g_val, &g_val); } return 0; }
运行结果
我们发现父子进程打印出来的全局变量地址完全相同,父子进程中的每个变量应该都有自己的独立空间,为什么地址会相同?
再看一段代码:这次子进程中改变全局变量的值.
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_val = 0; int main() { pid_t pid = fork(); if(pid < 0) { perror("fork"); return 0; } else if(pid == 0) { g_val = 1; printf("child:%d, %d:%p\n", getpid(), g_val, &g_val); } else { sleep(3); printf("father:%d, %d:%p\n", getpid(), g_val, &g_val); } sleep(1); return 0; }
发现变量值不同了,但是地址还是相同
根据上述的代码,我们可以得出结论:
- 变量的内容不一样,所以父子进程输出的不是一个变量
- 两个地址值相同,所以他们一定不是显示的物理地址
- 在linux中,我们所能看到的地址被称为虚拟地址,而不是物理地址
- 虚拟地址的产生是为了操作系统能够统一管理物理内存而产生的。
- 虚拟地址中并不能够保存数据,数据在物理内存中保存的。所以当一个进程在使用虚拟地址访问数据时,操作系统需要通过虚拟地址查找对应的物理地址从而访问数据。
使用页表进行虚拟地址与物理地址的转换。
如何从虚拟地址得到物理地址?
虚拟地址=页号+页内偏移
- 页号 = 虚拟地址 / 页的大小
- 页内偏移 = 虚拟地址 % 页的大小
- 将虚拟地址空间分成大小相等的很多地址块,通常是4k,
- 通过页表将虚拟地址与物理地址进行关联
- 物理内存也划分成了和页大小相同的块,所以虚拟地址=物理地址
虚拟地址 = 段号 + 段内偏移
通过段表将虚拟地址与物理地址进行关联。
虚拟地址 = 段号 + 页号 + 页内偏移
通过段表 + 页表联系虚拟内存与物理内存
转换过程:
通过段号,找到页表起始位置,再通过页号找到块号,再通过块号找到物理内存对应的起始地址,再通过页内偏移,确定物理地址。
以上就是关于进程的基础概念了,如果掌握了进程的基础概念,那么学接下来进程的控制,将会简简单单,进程的控制且听下回分解。