冯诺依曼体系结构
操作系统(Operator System)
在这里
设计OS目的:
对下:1、与硬件交互,管理好软硬件资源。
校长:操作系统
辅导员:驱动程序
硬件:学生
校长提出决策,辅导员执行决策。
对上:2、为用户程序(应用程序)提供一个良好的执行环境。
操作系统管理好软硬资源后,操作系统就能为用户提供良好的运行环境,例如提供了系统接口。
例如:用户需要执行printf(“hello”);语句,向显示器写入hello字符串,那么printf()函数的操作必须贯穿操作系统,通过调用系统接口,并且得到操作系统的允许,才能访问软硬件资源。
系统接口与用户层接口
系统接口:Linux操作系统由c语言写,所以叫c函数;
用户层操作接口:在系统接口之上,对系统接口封装的函数库(c库,java库),shell的外壳,部分指令。
注意:用户能通过用户操作接口进行指令操作,开发操作,管理操作。库函数里如果软件需要对硬件方面的操作,那么在语言层面上会调用系统接口。
进程
基本概念
课本概念:程序的一个执行实例,正在执行的程序等
内核观点:担当分配系统资源(CPU时间,内存)的实体。
什么是进程?
结论:进程= 程序 + 操作系统维护进程的相关的数据结构。
曾经我们启动的程序,先从硬盘上把程序的代码+数据加载到内存里,被刚刚加载到内存的叫程序文件内容,这个程序文件内容就好比学生,校长要管理好学生,先对学生描述,然后通过数据结构把每一位学生组织起来。所以操作系统给每个加载到内存的程序都创建了进程控制块(PCB),PCB是操作系统里的统称名词,在Linux操作系统下叫task_struct的结构体名。PCB对程序进行描述,还有相关的数据结构,操作系统只需要对task_struct操作就能达到对程序的管理。
tast_struct包含了进程内部的所有属性信息!进程就好比链表节点,为了管理好,就要有链表的数据结构。
占在os的角度, 由双链表数据结构组织进程,CPU有一个运行队列,CPU要处理程序时,只需要让进程的链表头链接到 运行队列即可。
结论:有了进程控制块,所有的进程管理任务与进程对应的程序毫无关系!!与进程对应的内核创建的该进程的PCB强相关。
描述进程-PCB
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
task_struct-PCB的一种
- 在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_ struct内容分类
task_struct数据结构庞大而复杂,但它可以分成一些功能组成部分:
1.标示符: 描述本进程的唯一标示符,用来区别其他进程。
例如进程pid,获取进程pid,我们可以调用getpid(),该函数包含在unistd.h文件里,该文件不属于c标准库。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
printf("%d\n",getpid());//获取当前进程pid
printf("%d\n",getppid());//获取父进程pid
return 0;
}
注意:我们执行的程序都是bash的子进程。
2. 状态: 任务状态,退出代码,退出信号等。
ps:进程状态在下面会将。
当程序执行最后一条代码时,return n;n为退出码,该退出码会写到进程信息里。echo $?
为获取环境变量,该变量保存输出最近执行的程序的退出码。
也就task_struct 当前具有:
task_struct{
pid_t pid;// 进程pid
pid_t ppid;// 父进程pid
int code,exit_code; 退出码
int status;// 进程转态
}
3. 优先级: 相对于其他进程的优先级。
进程都能被CPU调用,但是优先级能决定进程谁先被执行。
4. 程序计数器: 程序中即将被执行的下一条指令的地址。
有一个pc指针,指向下一条指令,程序在运行的时候会不断的修改pc指针。
5.内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
可通过该指针找到对应实体;
6. I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。
文件描述符。关于i/o
7.记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
记账信息给操作系统的调度模块(算法)使用,能较为均衡的调度每个进程,获取CPU资源。
8.上下文数据 :进程执行时处理器的寄存器中的数据。
CPU里有一套寄存器,用来保存每个进程的临时数据,CPU规定在一个时间片后会切换下一个进程,为了保护上下文数据,CPU的寄存器的数据会保存到PCB里,当切换回来时,再把上下文数据恢复。通过CPU的来回切换,用户能感受到多个进程同时在运行。
查看进程
进程的信息可以通过 /proc 系统文件夹查看 ,进程创建时,操作系统会以当前pid为目录名,把进程的相关信息保存到该目录下,进程销毁该目录也销毁。
cwd:当前进程工作目录路径。
exe:启动执行程序的文件路径。
注意:这些文件不属于磁盘文件,而是内存文件。
大多数进程信息同样可以使用top和ps这些用户级工具来获取。
fork初识
fork是用来创建子进程。创建子进程那么系统上就多了一个进程,
1、默认情况下,子进程会“继承”父进程代码和数据,原因很简单,父进程的代码和数据是向磁盘上获取的,而子进程没有。
2、子进程内核数据结构task_struct也会以父进程为模版,初始化子进程task_struct.
如何理解父子继承?
继承代码:代码只有一份,并且代码是不可修改的。
继承数据:进程之间是具有独立性的,默认情况下数据是共享的,但是如果要进程对数据的修改,那么需要通过“写时拷贝”来完成进程数据的独立性。
通过fork()创建进程
头文件: <unistd.h>
参数:无参
返回值:失败返回<0;成功时,给子进程返回0,给父进程返回子进程pid
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
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;
}
如何理解fork()函数有两个返回值?
pid_t fork()
{
// 创建进程代码
// ……
// ……
// ……
return xxx;
父进程创建了新进程,新进程继承了父进程代码,那么父子进程都会往下执行,自然就有了两个返回值,至于为什么给子进程返回0,给父进程返回子进程pid,一开始我还以为是return getpid(),结果不是我想的那样;父进程创建子进程目的就是为了让子进程干活,父进程与子进程是1:n的关系,父进程要获取子进程pid达到能控制子进程,而子进程只需要通过getppid即可获得。
如何理解两个返回值的设置?
返回值由同一块空间接收,数据是共享的,但是进程具有独立性,那么谁先写入,那么谁就要“写时拷贝”。
进程状态
进程状态的意义:方便操作系统快速判断进程,完成特定的功能,比如调度,本质是一种分类。
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。下面的状态在kernel源代码里定义:
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
一个进程处于运行状态时不一定正在占用CPU资源,一个进程在满足被CPU调度的条件下会被放在run_queue里,在队列里准备给CPU调度,这就叫进程处于运行状态。
状态演示:
int main()
{
while(true)
{};//死循环
return 0;
}
浅度睡眠状态-S(可中断睡眠)
当我们完成某种任务时,任务条件不具备,需要进程进行某种等待,可能等待网卡,磁盘,显示器,等外设资源时还有sleep命令,这时候进程处于S状态。
ps:千万不要以为进程只会等待CPU资源。
我们把运行状态的进程从run_queue放到等待队列中,就叫做挂起(阻塞)。
从等待队列,放到运行队列,然后被CPU调度就叫做唤醒进程。
等待队列与运行队列有什么区别?
运行队列:等待着CPU的资源。
等待队列:等待着外设资源,或者是被某种方式被限制到等待队列里(例如:sleep() )。当进程等到某种资源时,就会被放到运行队列里,然后等待CPU调度,访问某种资源。
所以:进程在某种队列里就处于某种状态。
状态演示:
int main()
{
while(true)
{
printf("hello world\n");
};//死循环
return 0;
}
大部分情况处于S状态,是因为大部分时间在等待IO设备准备就绪,当IO准备就绪后,进程放到运行队列被CPU调度然后往外设写入。
深度睡眠状态-D(不可中断睡眠)
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
有一种场景: 进程要求从内存中写入数据到磁盘,此时进程处于休眠状态,操作系统发现进程什么事都不干浪费资源,操作把进程杀掉,那么磁盘完成工作以后会向进程汇报,但是进程已死,万一磁盘写入失败,那么失败的结果没人知道及处理,所以就有了D状态,一个D状态一个爷,如果系统中存在大量的爷进程那么系统会宕机。
暂停状态-T
在Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
暂停状态与等待状态的区别?
等待状态期间还会有部分数据可以被修改。
暂停状态不会有数据被修改。
状态演示:
一个程序正在运行, 我们给该进程发送 19信号。
kill -19 进程pid
程序继续运行
kill -18 进程pid
注意:完成暂停及继续操作后进程会被切换到后台,我们需要kill -9 pid 才能杀死进程。
关于信号部分,进程信号章节会讲解。
僵尸状态&死亡状态
死亡状态:只是一个回收状态,当一个进程的退出信息被读取后,该进程所申请的资源进程相关的内核数据结构+你的代码和数据就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。
僵尸状态:
当进程被某种因素终止后,会先进入僵尸状态,该进程不会立马被释放,而是供操作系统或是其父进程进行辨别退出原因。
所以说进程的死亡顺序是先进入僵尸状态然后死亡状态。
状态演示:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
if(ret < 0)
{
perror("fork");
return 1;
}
else if(ret == 0)
{ //child
while(true)
{
printf("I am child : %d!, ret: %d\n", getpid(), ret);
sleep(1);
}
}
else
{ //father
while(true)
{
printf("I am father : %d!, ret: %d\n", getpid(), ret);
sleep(1);
}
}
return 0;
}
父子进程不断的打印,父子当前处于S或者R状态,我们给子进程发送9号信号杀死子进程,
子进程进入僵尸状态,父进程没有对子进程由任何的处理操作例如等待操作,这个后面会学,所以子进程一直处于僵尸状态。
僵尸进程的危害:
- 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态?是的!
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说, Z状态一直不退出, PCB一直都要维护?是的!
- 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构
- 对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
- 内存泄漏?是的!
孤儿进程
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
父进程先退出,子进程就称之为“孤儿进程”,孤儿进程被1号init进程领养,当然要有init进程回收喽。
进程优先级
基本概念
cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
为什么要有优先级?
资源少,让优先级高的享受更多的资源。
查看系统进程
我们很容易注意到其中的几个重要信息,有下:
UID : 代表执行者的身份
PID : 代表这个进程的代号
PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI :代表这个进程可被执行的优先级,其值越小越早被执行
NI :代表这个进程的nice值
PRI & NI
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。
那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=80+nice。
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
所以,调整进程优先级,在Linux下,就是调整进程nice值,nice其取值范围是-20至19,一共40个级别。
nice值为什么是一个相对较小的一个范围?
优先级再怎么设置,也只能是一种相对的优先级,不能出现绝对的优先级,否则会出现很严重的进程“饥饿问题”。也就是部分进程优先级低,没有或者极少数能获取CPU资源。
调度器:较为均衡的让每个进程享受到CPU资源。例如:A、B、C进程,在一个时间内,分别获取CPU资源的次数,50,40,30。
用top命令更改已存在进程的nice:
1、top
2、进入top后按“r”–>输入进程PID–>输入nice值
其他概念
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
环境变量
基本概念
环境变量本质就是 操作系统在内存/磁盘文件中开辟的空间,用来保存系统相关的数据。
环境变量的用途
为什么 ls 命令不用带路径?
因为有环境变量PATH,系统会通过该变量去查找 ls指令程序。
打印PATH环境变量:
echo $PATH
打印结果:
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/BBQ
用冒号隔开的一条一条搜索路径。
常见环境变量
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
和环境变量相关的命令
1.echo: 显示某个环境变量值
2. export: 设置一个新的环境变量
3. env: 显示所有环境变量
4. unset: 清除环境变量
5. set: 显示本地定义的shell变量和环境变量
本地变量 & 环境变量
系统上还存在一种变量,是与本次登录(session)有关的变量,只在本次登录有效。
本地变量的定义:
myval = 12345
set | grep myval
env | grep myval
export myval
env | grep myval
export myval :导入变量myval到环境变量列表里。
环境变量的组织方式
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。每个进程都有它所运行的的一个环境变量,环境变量一般是存放在内存的用户空间的一个环境变量表中,这个环境变量表是在进程生成时,从父进程的环境变量表中拷贝一份。
环境变量的获取方式
方式一、通过系统调用获取或设置环境变量(推荐写法)
char *getenv(const char *name)
// name:环境名
// 返回值:环境名对应值
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
相当于:echo $PATH
方式二、main函数的第三个参数
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。
int main(int argc,char* argv[],char * envp[])
argc: 传入的选项个数
argv:传入的选项列表,末尾null
envp:传入环境变量列表,末尾null
例如:ls -a -l
argc=2,argv[]= “-a”, “-l”
#include<stdio.h>
int main(int argc,char* argv[],char * envp[])
{
int i=0;
while(envp[i])
{
printf("envp[%d]:%s\n",i,envp[i]);
i++;
return 0;
}
方式三、通过第三方变量environ获取
#include <stdio.h>
int main(int argc, char *argv[])
{
extern char **environ;
int i = 0;
for(; environ[i]; i++)
{
printf("%s\n", environ[i]);
}
return 0;
}
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在 使用时 要用extern声明。
环境变量通常是具有全局属性的
环境变量通常是具有全局属性,是因为环境变量可以被子进程继承下去。
我们在命令行上启动的进程,父进程都是bash,bash的父进程是操作系统。在bash被启动时,就把环境配置好,包含环境变量等等,例如通过配置文件/etc/bashrc。
验证父子进程会继承环境变量:
// code.c
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("%s\n",getenv("my_val")) ;
return 0;
}
进程地址空间
看如下代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
//数据是各自私有一份(写时拷贝)
if(fork() == 0){
//child
int cnt = 5;
while(cnt){
printf("I am child, times: %d, g_val = %d, &g_val = %p\n", cnt, g_val, &g_val);
cnt--;
sleep(1);
if(cnt == 3){
printf("##################child更改数据#########################\n");
g_val = 200;
printf("##################child更改数据done#########################\n");
}
}
}
else{
//parent
while(1){
printf("I am father, g_val = %d, &g_val = %p\n", g_val, &g_val);
sleep(1);
}
}
return 0;
}
运行结果:
代码分析:全局变量g_val被写时拷贝,在物理内存中应该有两份g_val,可是child更改数据后,父子进程的g_val的值不一样,但地址却是一样的。
说明我们获取的地址并不是物理内存上的地址。
实际上,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址。物理地址用户一概是看不到的,是由操作系统统一进行管理的。
进程虚拟地址空间初识
进程地址空间分布图:
每个进程都有一张进程地址空间,它是操作系统给进程画的一张大饼,让进程以为自己独占物理内存。
进程地址空间在内核中是一个数据结构类型 ,具体进程的地址空间变量 struct mm_struct{}。
mm_struct结构体区域的划分
大饼是可以通过数据的方式进行画大饼!生活中处处是大饼,银行,亿万富翁私生子,父母的代保管的压岁钱等等。
划分区域?
划分区域就是区域的开始到区域的结束。从高地址到低地址就像一把尺子,我们可以通过尺子的刻度来划分区域。
虽然这里只有start 和 end 但是每个进程都认为 mm_struct 代表了整个内存 且所有的地址为0x0000……00 ~ 0xFFF……FF,且每个进程地址空间的划分是按照4GB空间划分的,也就是每个进程都认为自己拥有4GB。
什么是虚拟地址?
地址空间上进行区域划分时,对应的线性位置虚拟地址。
mm_struct结构体与物理内存进行关联
页表和MMU硬件的作用是将虚拟地址转换为物理地址。
页表详细讲解
进程虚拟地址空间与页表的作用
注意:对软硬件的访问都必须贯穿操作系统。
1、通过添加一层软件层,完成有效的对进程操作内存进行风险管理,本质目的是为了,保护物理内存以及各个进程的数据安全。
添加软件层就是os的助理,帮助OS。
例如:如果没有进程虚拟空间与页表,那么程序之间就会对内存不发觉的乱用,还有就是页表其实是一种权限,例如我们的语言层定义const变量,该变量只能读,一但我们修改,就会被页表检测出来。
2、将内存申请和内存使用的概念,在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和os进行内存管理操作,进行软件上面的分离。
例如:我们申请的1000字节有可能不会立马在物理内存中申请,而是在读写的时候才申请。在os的角度,如果空间立马给你,就意味着,整个系统会有一部分空间,本来可以被别使用的,现在却被闲置这。
注意:这种操作是基于缺页中断进行物理内存申请。
我们也不用担心os系统申请不到空间,os会通过内存管理算法来给进程想办法开辟空间。例如:把一些进程闲置的内存数据放到磁盘上,然后把空间让出来。
3、站在CPU和应用层的角度,进程统一可以看做统一使用4GB空间,而且每个空间区域的相对位置,是比较确定的。(统一的视角看待进程)
例如:CPU是如何知道我们进程的起始代码在哪里?只要找到入口就可以按顺序执行了。每个进程的代码都在内存里,CPU每次都要维护这起始入口去找,效率低。
那么有了进程地址空间后,CPU只需要有一个特定的地址,每次的都使用该地址去查找,从磁盘加载到内存,页表只需要获取到加载到内存的物理地址,那么CPU就能轻松找到。其他区域也类似。
总结:OS最终这样设计的目的,达到一个目标:每个进程都认为自己是独占系统资源的!
程序地址空间补充
最后解析:
为什么父子g_val是两个相同的地址,原因就是进程虚拟地址空间,在语言上我们用的都是虚拟地址,当创建子进程时,子进程以父进程为模版创建,所以此时,子进程的页表地址空间与父进程基本一样,当子进程对g_val 进行修改时,os检测到要进行写时拷贝,开辟新空间复制g_val值,改变子进程页表对g_val的物理映射。父子进程对g_val的虚拟地址从未改动过,所以打印的g_val的地址是一样的。
补充:
所有的只读数据,一般在物理内存只保留一份。原因:操作系统维护一份是成本最低的。
int main()
{
const char* p="hello";
const char* str="hello";
printf("%p\n",p);
printf("%p\n",str);
return 0;
}
运行结果:打印同样的地址。