我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系结构。
截至目前,我们所认识的计算机,都是有一个个的硬件组件组成 :
- 输入单元:包括键盘, 鼠标,扫描仪, 写板等
- 中央处理器(CPU):含有运算器和控制器等
- 输出单元:显示器,打印机等
输入设备、输出设备统称为外设。
对于计算机而言,冯诺依曼体系结构决定了物理硬件层面的数据流向。
即数据经输入设备进入存储器,由存储器写入到CPU,在由CPU处理完成后再流向存储器,后经输出设备显示。
那为什么数据不由外设直接流向CPU
,而是需要流经内存,通过内存进入CPU
?换句话来说,我们写的可执行程序为什么必须先加载到内存中呢?
这个是由于CPU
和内存访问性能的差距非常大。
- CPU:按照摩尔定律,CPU 的访问速度每 18 个月便会翻一番,相当于每年增长 60%。
- 内存:每年只增长 7% 左右。内存响应时间大概是 100us,也就是极限情况下,大概每秒可以访问 1000 万(= 1s / 100ns)次。
- HDD:磁盘寻道时间约 10ms,大概每秒可以访问 100 次。
为了弥补两者之间的性能差异,充分利用 CPU,现代 CPU 中引入了高速缓存(CPU Cache)。高速缓存分为 L1/L2/L3 Cache,不是一个单纯的、概念上的缓存(比如使用内存作为硬盘的缓存),而是指特定的由 SRAM 组成的物理芯片。
程序运行的时间主要花在将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作缓存行(Cache Line)。在我们日常使用的 Intel 服务器或者 PC 里,Cache Line 的大小通常是 64 字节。
现在总结一下,为了平衡 CPU 和内存的性能差异,现在 CPU 引入高速缓存。
OS是进行软硬件管理的一款软件。
- 减少用户使用计算机的成本
- 对下管理好所有硬件,对上给用户提供一个稳定高效的运行环境
OS是硬件与用户之间交互的桥梁。
- 进程管理
- 内存管理
- 文件管理
- 驱动管理
一个真正“搞管理”的软件。
什么是管理?
举个例子,假设在学校里面只有校长、辅导员和学生。
学校的真正的管理者是校长,而学生是被管理者。校长只有一个,而学生却有很多,校长是如何管理的呢?我们在入学前,我们的学籍信息就会转到我们所要就读的学校,校长并不会直接管理我们,他通过辅导员(执行者)对我们的学籍信息进行管理。
除此之外,校长还对学校里面的环境进行管理,校长通过对这些学校里面的环境进行登记造册,从而进行管理,例如,校长准备拆掉一栋老旧的教学楼,他直接让施工队拆掉XX楼,校长并不需要自己去拆掉那栋教学楼。校长不需要直接管理我们,他通过各种方式对我们的信息进行管理,我们的信息就是数据,所以管理是直接对数据操作。总结一下就是,管理是先用数据描述被管理的对象,再进行组织。
总结一下:对应起来,校长就是OS,辅导员、施工队就是驱动,学生就是软件,学校环境是硬件。
在Linux操作系统中进行资源管理也是一样,先描述 再管理
,描述用struct结构体(Linux底层是用C语言写的),而由于管理的资源是很多的,需要某种数据结构组织起来,所以可以看成对双链表的增删查改
- 用户部分:
计算机体系是一个层状结构,任何访问硬件或者系统软件的行为,都必须通OS接口,贯穿OS进行访问操作
OS不信任任何用户,任何对系统硬件或软件的访问都必须经过操作系统提供的接口(system call
)- 系统调用和库函数:
- 系统调用:在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用
- 库函数:系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发
(分为不使用system call
和 使用system call
的比如printf
)- 硬件部分:遵守冯诺伊曼结构
程序的一个执行实例,正在执行的程序等
也可以说是担当分配系统资源(CPU时间,内存)的实体
可以理解为进程属性的集合 ,进程信息被放在这个叫做进程控制块的数据结构中,称之为PCB(process control block)
在Linux中描述进程的结构体叫做task_struct
,它是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息
当我们运行起来多个个程序时:
OS对进程的管理转化为了对进程信息的管理,先描述再组织。管理进程成为了对双链表的增删查改!
我们可以说:
进程 = 程序+内核申请的数据结构(PCB)
task_struct
的内容
task_struct
内容分类:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程
- 状态: 任务状态,退出代码,退出信号等
- 优先级: 相对于其他进程的优先级(由于计算机的资源有限,即“狼多肉少”)
- 程序计数器(PC): 程序中即将被执行的下一条指令的地址
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等
- 其他信息
top
命令相当于Windows
系统下的任务管理器
每一个进程都有唯一的标识符
pid
,和父进程ppid
相当于现实生活中的我们中国人的身份证号,这是唯一的。
下面运行这段代码:
#include
#include int main() { while(1){ printf("i am a process, my id is %d\n", getpid()); sleep(1); } return 0; } 用命令
ps ajx
可查看,同时配合管道ps ajx | head -1 && ps ajx | grep test
一般情况下,每个就能成的父进程都是命令行解释器
bash
同时,我们也可以在目录下查看进程
如:要获取
PID
为33517的进程信息,你需要查看/proc/33517
这个文件夹
进程在CPU上运行,会有很多寄存器上的临时数据(上下文数据)
进程放在CPU上之后,不是一直在运行直到进程运行结束。每个进程都有一个运行时间单位:时间片
一般进程让出CPU:
a. 来个一个优先级更高的进程(OS必须支持抢占)
b. 时间片到了
单CPU/单核:跑起来多个进程,通过进程快速切换的方式,在一段时间内,让所有的进程代码都得到推进,并发!
多CPU/多核:任何时刻,允许多个进程同时执行,并行!
理解进程间切换(进程间的上下文切换):
- CPU是在周而复始的取指令,分析指令,执行指令,CPU里的寄存器的内容也在不断变化(比如
EIP
是下一条指令的地址)而CPU的寄存器数据,称为进程的硬件上下文- 当一个进程在运行中,由于某些原因(比如时间片到了),需要被暂时停止执行,让出CPU,需要进程保存(以便恢复)自己的所有临时数据(当前进程的上下文数据)
- 对于可能运行的进程的PCB会被一个运行队列(
runqueue
)链接起来,以便CPU查找或恢复
什么是上下文切换?
- 操作系统内核使用一种称为上下文切换的异常控制流(突变的控制流)来实现多任务。
- 内核调度器调度一个新的进程运行后,它就抢占当前进程,并使用上下文切换的机制将控制转移到新的进程。
上下文如何切换?
- 保存当前进程的上下文;
- 回复某个先前被抢占的进程被保存的上下文;
- 将控制传递给这个新恢复的进程。
通过
man
手册查看fork
注意:
pid_t
一般是size_t
,这里是int
。我们来演示一下基础用法:
#include
#include int main() { int ret = fork(); while(1) { printf("hello proc : %d!, ret: %d\n", getpid(), ret); sleep(1); } return 0; } 结果:
我们可以看到第一个
test
的pid
是26110,而第二个pid
是26111,而他的ppid
是26110,所以第一个进程是第二个进程的父进程。而第一个进程的父进程是
-bash
.
那么对于
fork()
该如何去理解呢?
程序员角度:
操作系统中所有的进程具有独立性,为了不让进程间互相干扰,所以:
父子共享用户代码(只读 不可修改),而用户数据各自私有一份这里的用户代码指的是整个程序的代码,但是子进程会执行他自己相应的代码,而父进程不会去执行自己想过硬的代码。
内核角度:
进程=
程序代码+
内核数据结构(task_struct)
创建子进程,通常以父进程为模板,其中子进程默认使用的是父进程的代码和数据(写时拷贝)
fork()
函数的返回值在
man
手册中对于fork()
函数返回值的描述是这样的:即子进程创建成功后,将子进程的
pid
返回给父进程,将0返回给子进程,若创建失败则返回-1.下面我们来进一步认识
fork()
函数返回值。#include
#include #include int main() { int ret = fork(); if(ret < 0){ perror("fork"); return 1; } else if(ret == 0){ //child while(1) { printf("I am child : %d!, ret: %d\n", getpid(), ret); sleep(1); } } else{ //father while(1) { printf("I am father : %d!, ret: %d\n", getpid(), ret); sleep(1); } } sleep(1); return 0; } 能同时进行两个死循环的根本原因不是if,else同时进入,也不是一个代码内可以同时进行多个循环,而是多执行流
return也是代码,是创建子进程成功后和父进程共享的代码
父进程子进程代码共享都要执行,所以肯定有两个返回值
下面我们来说一个有趣的问题,为什么要把子进程的pid返回给父进程?
原因很简单:我们举个例子,一个父亲可以有多个子女,但是子女们只有一个父亲。那么父亲如何分别他的子女呢?当然是给他们每一个人建立一个唯一的标识。对应进程,父进程得到子进程的
pid
是为了更好的管理子进程。
数组存储,while创建
一个进程可以有多个状态(在Linux内核里,进程有时候也被叫做任务)
在Linux源码中有这么一个数组(
task_state_array
):/* * The task state array is a strange "bitmap" of * reasons to sleep. Thus "running" is zero, and * you can test for combinations of others with * simple bit tests. */ 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 运行状态 (running):不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里,可以被调度
S 睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep
))
D 磁盘休眠状态 (disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep
),在这个状态的进程通常会等待IO的结束
T 停止状态 (stopped): 可以通过发送 SIGSTOP
信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT
信号让进程继续运行
X 死亡状态 (dead) :这个状态只是一个返回状态,你不会在任务列表里看到这个状态
僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程
没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
#include
#include #include #include void DoThing() { int count = 0; while(count < 5) { printf("pid: %d, ppid: %d, count:%d\n", getpid(), getppid(), count); count++; sleep(1); } } int main() { pid_t ids[5]; printf("i am father, my pid is %d\n", getpid()); for(int i = 0; i < 5; ++i) { ids[i] = fork(); if(ids[i] == 0) { //child DoThing(); exit(1); } } printf("%d, %d, %d, %d, %d\n", ids[0], ids[1], ids[2], ids[3], ids[4]); getchar(); return 0; } 理解:
- 僵尸状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程 没有读取到子进程退出的返回代码时就会产生僵尸进程
- 僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码,所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
它的危害:
- 进程的退出状态必须被维持下去,因为他要告诉父进程完成的任务情况,但父进程如果一直不读取,那子进程就一直处于Z状态
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在
task_struct(PCB)
中(Z状态一直不退出, PCB一直都要维护)- 所以一个父进程创建了很多子进程,但是不回收,就会造成内存资源的浪费吗,造成内存泄漏
僵尸状态不能被kill
父进程先退出,子进程就称之为“孤儿进程”
此时孤儿进程的父进程是pid
为1的进程system/d
也就是OS
#include
#include #include #include int main() { int ret=fork(); if(ret>0) { //Father sleep(5); printf("I am Father,now I quit\n"); exit(0); } while(1) { printf("I am son,my pid is:%d,my ppid id:%d\n",getpid(),getppid()); =-sleep(1); } } 一旦进程变为孤儿进程,就会由前台变为后台状态为
S+
他们两个的区别:
- 优先级:一定能得到某种资源
- 权限:决定能否得到某种资源
使用命令
ps -al
查看优先级及各种信息Linux的优先级由
PRI
和NI
共同决定:(优先级的数值越小,优先级越高,不能一味的低,OS调度器要适度考虑公平问题,避免饥饿问题)
- PRI(PRIORITY): 即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
- NI(NICE): 表示进程可被执行的优先级的修正数值(NICE其取值范围是[-20,19),一共40个级别)
PRI值越小越快被执行,那么加入NICE值后,将会使得PRI变为: PRI(new)=PRI(old)+nice
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
所以,调整进程优先级,在Linux下,就是调整进程nice值
先使用命令ps -al查看当前所有进程 记录下想要修改NICE的进程的pid
top命令进入资源管理器 按r输入进程pid再输入新的nice值,回车,q退出
可以看到PRI值已变为99(80+19)
可以看到PRI值已变为60(80-20)
注意:
- PRI(new)=PRI(old)+nice中的PRI(old)一直都是80保持不变,比如我们在上面的基础上再次修改nice值新的PRI值也是在80的基础上加上新的nice值
- 如果我们设置的nice值超过的集合[-20,20)的自然数,会自动将nice设为最大值和最小值,比如-100实际上设为的是-20,100实际设为的是19,也就是进程PRI值最高为99,最低为60。
其他概念:
- 竞争性:系统的进程数目众多,而计算机资源少量,所以进程之间具有竞争属性。
- 独立性:多进程运行时,需要独享各种资源,多进程运行期间互不干扰。
- 环境变量:一般是指在操作系统中用来指定操作系统运行环境的一些参数
- 我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
- PATH: 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
- SHELL : 当前Shell,它的值通常是/bin/bash。
echo $PATH // NAME
你的环境变量名
指定目录的搜索路径
当我们使用我们自己的程序的时候需要
./
表示当前路径如果没有使用则会报错
command not foung
那么为什么我们使用ls、cd等就可以,这是因为系统在PATH(辅助系统进行指令查找)的帮助下进行查找
如何使我们的程序和这些指令一样呢?
将自己的可执行程序添加到系统路径下
cp .o /usr/bin/
将当前路径添加到PATH
PATH = $PATH:/xxx/xxx/
应注意的是方法二,环境变量是用户登录时从配置文件加载的,所以重新登陆会清空
指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
当前Shell,它的值通常是/bin/bash
头文件
函数 | 功能 |
---|---|
char \*getenv(const char \*name) |
获取环境变量 |
int setenv(const char \*name, const char \*value, int overwrite) |
设置环境变量 |
只能在当前shell命令行解释器内被访问,不能被子进程继承
只在本进程bash内有效
环境变量具有全局属性,即可以被子进程继承
将本地变量变为环境变量
set:只能显示环境变量
env:可以显示本地变量和环境变量
shell程序内部的一个函数,如echo、export等
当shell执行的命令时内建命令时直接调用内建命令,他执行的更快,使用type命令可以查看该命令是否为内置命令
每个程序都会收到一张环境表,环境表environ是一个字符指针数组,每个指针指向一个以\0结尾的环境字符串
main函数是一个函数,其实也可以带参数
int main(int argc, char* arrgv[], char* envp[]) { program-satements }
main函数有三个参数:
- 第一个参数:argc是个整型变量,表示命令行参数的个数(含第一个参数)
- 第二个参数:argv是一个字符指针的数组,每一个元素都是一个字符指针,指向一个字符串。这些字符串就是命令行中的每一个参数(字符串)
- 第三个参数:envp是字符指针的数组,数组的每一个元素是指向一个环境变量(字符串)的字符指针
#include
int main(int argc, char* argv[], char* envp[]) { int i = 0; for(i = 0; i < argc; i++) { printf("argv[%d]: %s\n", i, argv[i]); } return 0; } 命令行参数是为了利用同一份代码通过不同的参数去实现不同的功能
在学习语言的时候,我们在学习内存的时候会想到下面这一张内存空间的图
但是在系统层面上说,这个图不准确,这只是当时学习语言的权宜之计。他真正的名称是进程地址空间,也就是下面这一张图。
下面我们来验证一下:
#include
#include int g_uninit_val; int g_init_val = 100; int main() { printf("code addr : %p\n", main); const char* p = "hello Linux!"; printf("read only : %p\n", p); printf("global init value : %p\n", &g_init_val); printf("global uninit value : %p\n", &g_uninit_val); char* q1 = (char*)malloc(10); char* q2 = (char*)malloc(10); char* q3 = (char*)malloc(10); char* q4 = (char*)malloc(10); printf("heap addr : %p\n", q1); printf("heap addr : %p\n", q2); printf("heap addr : %p\n", q3); printf("heap addr : %p\n", q4); int val = 0; static int s_val = 0; printf("stack addr : %p\n", &val); printf("s_val addr : %p\n", &s_val); printf("stack addr : %p\n", &p); printf("stack addr : %p\n", &q1); printf("stack addr : %p\n", &q2); printf("stack addr : %p\n", &q3); printf("stack addr : %p\n", &q4); return 0; }
下面执行这一段代码,会发生一个有趣的现象。
#include
#include #include int g_val = 200; int main() { printf("At begin g_val is: %d\n",g_val); pid_t id=fork(); if(id==0) {//child int count =0; while(1) { printf("child pid: %d, ppid: %d, g_val=%d, [&g_val=%p]\n", getpid(),getppid(),g_val,&g_val); sleep(1); count++; if(count==5) g_val=100; } } else if(id>0) {//parent while(1) { printf("parent pid: %d, ppid: %d, g_val=%d, [&g_val=%p]\n", getpid(),getppid(),g_val,&g_val); sleep(1); } } else; } 执行后的我们可以观察到子进程会改变全局变量g_val的大小,但是他的地址和父进程的g_val的地址一样。这是为什么呢?
在前面我们知道父子进程共享代码,但是各自的数据私有。
- 变量内容不一样,父子进程输出的变量绝不是同一个变量;
- 观察到地址相同,说明这个地址绝不是物理地址;
- 在Linux中,这种地址我们称为 虚拟地址;
- 我们在C/C++语言中所看到的地址,全部都是虚拟地址!用户一般看不到物理地址。
冯诺依曼规定:程序运行的时候,代码和数据一定在物理内存上。
将虚拟地址和物理地址相互转化的工作由OS完成。
在计算机中,OS会给进程一种假象,进程可以独占整个内存(物理空间),实际是不可能的。
在Linux中,描述进程空间的结构体是mm_struct。
struct mm_struct { /*...*/ struct vm_area_struct *mmap; /* list of VMAs */ struct rb_root mm_rb; u64 vmacache_seqnum; /* per-thread vmacache */ unsigned long mmap_base; /*映射基地址*/ unsigned long mmap_legacy_base; /*不是很明白这里*/ unsigned long task_size; /*该进程能够vma使用空间大小*/ unsigned long highest_vm_end; /*该进程能够使用的vma结束地址*/ pgd_t * pgd; atomic_t mm_users; atomic_t mm_count; int map_count; /* vma的总个数 */ unsigned long total_vm; /* 映射的总页面数*/ /*...*/ unsigned long start_code, end_code, start_data, end_data; unsigned long start_brk, brk, start_stack; unsigned long arg_start, arg_end, env_start, env_end; /*...*/ }
虚拟内存到物理空间的映射由页表完成。
页表:是一种特殊的数据结构,放在系统空间的页表区,存放逻辑页与物理页帧的对应关系。 每一个进程都拥有一个自己的页表,PCB表中有指针指向页表
正是这种映射关系,每一个进程都有一个虚拟内存。
那为什么不直接访问内存?
如果直接进程访问物理内存,那么看到的地址就是物理地址,使用指针造成越界
无法保证进程的独立性
因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改,也可以读取
虚拟地址到物理地址的转化,由OS来完成的,同时也可以帮系统进行合法性检测
每个页表项会有许可位来控制对一个虚拟页面内容的访问,如果一条指令违反了许可条件,CPU就会触发保护机制,Linux Shell一般将这种异常报告为段错误(segmentation fault)
为了会进行回收等操作,内存需要知道某个进程的退出内存,所以内存管理模块和进程管理模块是强耦合的,
管理只需要知道那些内存区域(page)是无效的,哪些是有效的将内管管理 所以和进程管理进行解耦,
底层类似智能指针,每次申请空间就会给一个count变量++,回收一次count就–,当count为0就进行解耦
为什么进程地址空间是按照区域划分的?
在磁盘中的可执行程序,本身是按文件的方式组织成一个个的区域
由于程序在物理空间内的存放位置完全是根据当前内存状态存放的,为了能让进程(PCB)找到 ,进程地址空间也进行了区域划分,通过页表将所有的数据整合起来,使在地址空间看到的和在磁盘看到的是同一种物理排序
有了地址空间,我们就可以在确定的位置,执行代码的入口,完成运行
物理内存是以页为单位的,一页为4kb,而程序中的每一个块被分为若干个4kb,一个4kb叫做页帧
现在回到最初的问题,为什么父子进程的g_val的地址一样?
- 当创建子进程的时候,子进程的task_struct,mm_struct ,页表等都会以父进程为模板创建,(默认继承父进程的大部分属性)
- 父子进程共享一份代码,控制语句if else 控制父子进程执行的代码块
- 父子进程的进程地址空间里的虚拟地址&g_val一定一样,当子进程想对g_val进行写的时候,操作系统发现进程只有读权限,于是操作系统在物理内存开辟一个新的和g_val一样的空间,将g_val拷贝进去,同时让子进程的页表映射到这块空间,同时具有写权限
- 在上层看来虚拟地址是一样的,只是映射到的物理地址变化了,本质上进行了写时拷贝
进程和程序的区别: