【Linux操作系统】进程概念
目录
1. 冯诺依曼体系结构
2. 操作系统(Operator System)
2-1 概念
2-2 设计OS的目的
2-3 核心功能
如何理解 "管理"呢
2-4 系统调用和库函数概念
3. 进程
3-1 基本概念与基本操作
3-1-1 描述进程-PCB
基本概念
task_struct-PCB的一种
3-1-2 task_ struct
内容分类
组织进程
3-1-4 查看进程
1.通过系统调用获取进程标示符
2. 大多数进程信息同样可以使用top和ps这些用户级工具来获取
3. 进程的信息可以通过/proc 系统文件夹查看
补充1:
补充2:进程父子关系问题
3-1-5 通过系统调用创建进程-fork初识
• 为什么fork给父子返回各自的不同返回值?
• 为什么一个函数会返回两次?
• 为什么一个变量,既==0,又大于0?导致if else 同时成立???
3-2 进程状态
3-2-1课本概念
3-2-1运行&&阻塞&&挂起状态
1.理解内核链表
3-2-3 Linux内核源代码怎么说
3-2-2 进程状态查看
3-2-3 Z(zombie)-僵尸进程
3-2-4 僵尸进程危害
3-2-5 孤儿进程
3-3 进程优先级
3-3-1 基本概念
3-3-2 查看系统进程
3-3-3 PRI and NI
3-3-4 查看和修改进程优先级的命令
1.优先级的极值问题
3-3-5 补充概念-竞争、独立、并行、并发
3.4 进程切换
3-4-1.死循环进程如何运行
3-4-2.cpu和寄存器
3-4-3.具体如何切换
参考一下Linux内核0.11代码
3-4-4 Linux真实调度算法
Linux2.6内核进程O(1)调度队列
4. 环境变量
4-1 基本概念
4-1-1补充:命令行参数
4-2 常见环境变量
4-4 和环境变量相关的命令
4-5 通过代码如何获取环境变量
•命令行参数
• 系统调用获取,getenv
• 通过第三方变量environ获取
4-8 环境变量通常是具有全局属性的
4-8-1补充:本地变量
5. 程序地址空间-第一讲
5-1 研究平台
5-2 程序地址空间回顾
5-3 虚拟地址
5-4 进程地址空间
5-4-1.如何理解虚拟地址空间
5-4-2理解区域划分
5-5 为什么要有虚拟地址空间
5-5-1如果程序直接可以操作物理内存会造成什么问题?
• 安全风险
• 地址不确定
• 效率低下
5-6拓展(其他的详细内容后面再展开)
1. 冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器等。内外有大量的硬件,分为两类。
输入设备:键盘、鼠标、话筒,摄像头、网卡、磁盘等。输出设备包括显示器、磁盘网卡,打印机等,我们把这些也叫作外设,外设的排布并不是杂乱无序的,而是遵守遵守冯诺依曼体系。
关于冯诺依曼,必须强调几点:
这里的存储器指的是内存,中央处理器就是我们常说的CPU,即CPU=运算器+控制器,而像磁盘这种设备我们也叫作外存。
在上面这个体系中,我们从输出设备获取数据,加载(拷贝)到内存中,我们把这个过程叫做输入,即Input,I,再把经过CPU处理的数据写入(拷贝)到输出设备中,我们把这个过程叫做输出,即Output,O,这就是我们站在内存角度理解的IO。
在以前的学习中,我们了解到运行一个程序往往要先加载到内存,才可以运行,而程序运行之前,往往又是作为文件,存放在磁盘中的。之前我们并不清楚为什么会这样,但是现在我们清楚这是由体系结构规定的,CPU获取、写入等只能从内存中来进行,我们必须先将数据拷贝到内存,CPU才可以执行我们的程序,访问我们的数据(这里代码作为文件是存放在磁盘的,所以这里磁盘既是输出,也是输入设备,输入输出设备概念并不绝对。)CPU执行完再将数据拷贝到内存,内存再将数据拷贝到外存。
不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
一句话,所有设备都只能直接和内存打交道。
在整个体系中,我们发现数据是从一个设备拷贝到另一个设备的,所以我们可以得出一个结论:体系结构的效率是由设备的拷贝效率决定的。
那么问题来了,为什么上面的体系中CPU只能和内存打交道呢?
根据上面的图,我们发现速度越快、越贵、存储空间越少,其中CPU的速度是最快的,而往下的存储跟CPU的速度最少相差好几个数量级以上,所以如果没有内存,那么CPU直接跟外设打交道,那么根据木桶效应,最终组装出来的系统的速度就会直接由外设决定(每次CPU都需要等外设拷贝数据,外设相比CPU又太慢了),效率非常低效。而使用内存,我们就可以设计上图中的三级缓存机制,以及其他算法,将整体系统效率提升到相对客观的程度(内存跟CPU之间相差只有几个数量级,并且CPU从内存拷贝数据时,我们可以通过提前将下一次可能需要的数据准备好这样的设计)。当然观察上面的图,我们发现其实我们也可以将所有的设备都是用寄存器制作,速度也非常快,但是造价非常高昂。
所以总的来说冯诺依曼体系设计的意义就在于普通人可以通过相对低廉的价格获取一台性能不错的计算机。我们可以说现代计算机是一种性价比的产物。而正是这种设计,越来越多的普通人可以获得电脑,最终诞生了互联网以及相关一系列事物。
注意:
对冯诺依曼的理解,不能停留在概念上,要深入到对软件数据流理解上。
以QQ聊天为例,我们来分析根据冯诺依曼体系来分析一下数据的流动过程。
QQ的程序文件被加载到内存中,我们从键盘输入数据,数据被拷贝到内存中,程序和数据被拷贝给CPU,处理的结果拷贝给输出设备,这里是网卡,网卡再拷贝给网络,网络再拷贝给远端网卡,网卡将数据拷贝到内存中,经过CPU中QQ程序处理,数据再拷贝到显示器这个输出设备上。我们发现即使目前我们并不了解系统以及网络更加深入的知识,仅靠对体系的理解,我们也可以理解数据的流动了。
2. 操作系统(Operator System)
2-1 概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。我们也可以成操作系统是一款进行软硬件管理器的软件。
笼统的理解,操作系统包括:
• 内核(内核主要负责进程管理,内存管理,文件管理,驱动管理等)
• 其他程序(操作系统为了我们方便开发,在内核基础上提供了其他程序,例如函数库,shell程序等等)
2-2 设计OS的目的
• 对下,与硬件交互,管理所有的软硬件资源(手段)。在计算机中,并不是人直接的访问管理对应的硬件资源,而是由OS进行统一管理,我们需要访问对应的硬件资源,需要先访问OS,再由OS访问对应硬件资源
• 对上,为用户程序(应用程序)提供一个良好的执行环境(目的)。OS将所有硬件资源都管理好,就不需要我们人去担心了,我们可以基于OS,执行各种操作,需要访问硬件资源的,会由OS帮助我们进行处理。
上图描述的是软硬件体系层状结构,体系结构设计成层状满足低内聚、高耦合特点,这样当我们修改某一层的具体实现,其他层并不需要发生改动,大大降低我们的维护成本。
在这个体系结构中,我们用户处于最上层,我们用户想要访问操作系统,就必须使用系统调用--其实就是函数,只不过是系统提供的。
在日常的使用中,我们可能觉得自己并没有使用过是系统调用,其实我们日常使用的库函数中就可能封装了对应的系统调用(只要需要访问对应的硬件资源,就需要封装对应顶点系统调用,通过系统去访问对应的硬件资源),我们通过库函数调用系统调用,访问系统。
只要我们的程序可以判断访问了硬件,那么这个程序就必须贯穿了上图软硬件体系结构
2-3 核心功能
• 在整个计算机软硬件架构中,操作系统处在一个承上启下的位置,操作系统的定位是:一款纯正的“搞管理”的软件。
如何理解 "管理"呢
在日常生活中,每个人都有决策、执行两种动作,我们做了某一种决策,我们就执行这个决策去完成对应的事情。
以学校的管理系统为例,在这个管理体系中,校长是管理者,有决策权,学生是被管理者,辅导员是中间层,只有执行权。对于校长来说,决定是否给一名学生颁发奖学金,不需要校长本人跟学生接触,校长只需要知道谁谁的综测等数据,就可以根据数据进行决策,而这些数据同样不需要见面,只需要由辅导员这个中间层去获取就可以了,但是数据也不是乱获取,校长还是需要告诉辅导员需要哪些数据。
所以根据上面的例子我们总结,管理者管理被管理者,不需要见面,可以通过数据直接管理,而这些数据则是由中间层去与被管理对象接触获取的,管理者只需要告诉中间层获取哪些数据就可以了。
有了对应的数据,校长就可以建表来管理这些数据了,那么校长管理学生就直接转化成对表格的增删查改。
而对应到计算机的世界中,这一个一个学生的数据就可以通过结构体描述建立模型,然后将这一个一个结构体通过链表这个数据结构串联,那么校长对学生的管理就转换成了对于链表节点的增删查改了。
所以现在我们再回头理解操作系统是如何搞管理的呢?
我们以对下为例,操作系统对硬件资源的管理,不需要系统直接管理硬件,体系架构设计时,抽象了一层软件层--驱动程序,操作系统告诉驱动程序管理一个硬件需要什么数据,由驱动程序来从底层硬件获取对应的数据,用来初始化描述对应硬件的结构体,那么对于操作系统来说,这一个个硬件就变成了结构体,操作系统就可以通过链表这种数据结构把它们串联起来了,那么操作系统对于硬件资源的管理,就变成了对于链表这样数据结构的增删查改了,维护成本大大降低。
对于上述过程,我们可以抽取出一句话即先描述,再组织,通过这句话,我们对任何"管理"场景进行建模。(后面的学习中会经常遇到,并不断深化理解)
因此即使目前我们并没有学习进程、文件一系列概念,我们现在也可以大胆假设OS想要对其进行管理,也必然是先将对应数据描述,再通过数据结构组织起来。
同时我们也可以发现一个惊人的事实即:我们所学的所有现代语言C++、java、python、go中的类这个概念对应先描述,跟数据结构有关的库如STL对应再组织。所有现代语言这样的设计本质上就是更加贴合了我们现实世界的管理逻辑。同样现在我们也可以明白,现代计算机学科中为什么会有数据结构以及算法相关的课程,本质上就是为了再组织这一过程,方便我们对数据进行组织。
总结
计算机管理硬件
1. 描述起来,用struct结构体
2. 组织起来,用链表或其他高效的数据结构
2-4 系统调用和库函数概念
操作系统我们上文说是需要向上为用户提供服务的,但是操作系统本质上是不详细任何用户的,用户想要访问操作系统,必须通过系统调用。
以银行为例,虽然大部分人是好人,但是也不乏坏人,所以为了防止这部分坏人,因此大部分业务,银行并不允许我们自主办理,所以提供了业务办理窗口。OS类似,也是为了防止部分危险的行为,不允许我们直接访问操作系统,我们只能通过OS提供的系统调用去访问操作系统。同样的,类似于老大爷去银行,会有人帮助引导一样,其实对于很多用户来说,可能并不熟悉底层相关的技术细节,而我们想要使用系统调用又必须了解底层的细节,因此工程师们又在系统调用的基础上再封装了一层,形成了上层开发者常常会用到的库函数,这样我们上层开发直接调用对应的库函数,由库函数间接去调用系统调用,这样就屏蔽了底层的细节,不需要去了解底层了。
因此我们可以说系统调用和库函数之间是上下层的关系。
Linux/Windows/Macos之类的系统底层大部分都是使用C语言写的,因此探究系统调用的本质其实就是C风格的函数,那么就会有对应的输出参数和返回值。输出参数是用户输给系统,返回值是系统返回给用户,因此通过这些参数用户和操作系统之间就形成了某种数据交互。
总结
• 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
• 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
3. 进程
3-1 基本概念与基本操作
• 课本概念:程序的一个执行实例,正在执行的程序等
• 内核观点:担当分配系统资源(CPU时间,内存)的实体。
注:因为操作系统是一款进行软硬件管理的软件,所以操作系统要执行,那么必然是在系统开机时先加载到内存中的。
我们执行一个程序必先将程序从磁盘加载到内存即代码和数据,这是由冯诺依曼体系决定的,那对于这些加载的代码和数据我们能叫做进程吗?其实这些并不是进程
实际使用的时候我们往往是要同时执行多个进程的,即内存中是同时存在多份代码和数据,而由于执行先后顺序以及代码本身大小,所以同一时刻可能存在多个不同状态的代码和数据,有的刚加载进来,有的正在被CPU执行,有的已经执行完了,那么对于这么多状态的代码和数据,CPU应该如何管理呢?先描述再组织
因此就OS内就有一种结构体,内部有代码地址、数据地址、id、优先级等属性,包含了一份代码和数据管理所需的所有属性,对于每一份代码和数据就可以用这个结构体来描述。当每一份代码和数据加载到内存中,系统就会为其创建对应的结构体对象,这样对于代码和数据的管理,就转换成对结构体对象的管理。所以现在我们可以说进程=内核数据结构对象+自己的代码和数据
我们把这种结构体就叫做PCB
3-1-1 描述进程-PCB
基本概念
• 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,本质上就是一个结构体。
• 课本上称之为PCB(process control block),这是对于不同操作系统下进程控制块的抽象统称,具体实现有不同的名称,Linux操作系统下的PCB是: task_struct
task_struct-PCB的一种
• 在Linux中描述进程的结构体叫做task_struct。
• task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
所以现在我们可以说进程=PCB(task_struct)+自己的代码和数据。
然后OS通过链表这样的数据结构将PCB连接起来,这样对于代码和数据的管理就转换为对于链表的增删查改,这样当加载代码和数据到内存就创建对应的PCB并链入链表,当CPU要调度,也是先在链表中找到对应PCB,再根据对应的PCB找到对应的代码和数据;当代码和数据执行完,不管代码和数据要释放,再将对应的链表中的PCB也释放掉。
3-1-2 task_ struct
内容分类
• 标示符: 描述本进程的唯一标示符,用来区别其他进程。
• 状态: 任务状态,退出代码,退出信号等。
• 优先级: 相对于其他进程的优先级。
• 程序计数器: 程序中即将被执行的下一条指令的地址。
• 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
• 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
• I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
• 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
• 其他信息
• 具体详细信息后续会介绍
组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。
3-1-4 查看进程
我们历史上执行的所有指令、工具、自己的程序运行起来全都是进程,既然是进程,那么就必然自己对应的标识符即进程ID--pid,所以我们可以通过查看进程pid来证明这一点
1.通过系统调用获取进程标示符
我们可以使用getpid,getppid系统调用来完成,可以使用man 来查询,因为是系统调用,所以需要查询2号手册
• 进程id(PID)
• 父进程id(PPID)
pid_t是由系统提供的,本质上是int类型,函数调用成功返回特定大于0的数
2. 大多数进程信息同样可以使用top和ps这些用户级工具来获取
ps axj 查看所有进程信息
也可以使用top命令,top启动不退出,需要使用q,手动退出
如果我们想要查看我们自己的进程,我们可以通过| 和grep过滤出我们进程相关信息
要查看更多的信息,这里我们head -1把属性列显示出来,这里为了能够在属性列下查看我们的进程信息,我们需要使用组合命令,我们可以在两条命令间用;和&&
需要解释的是这里grep也是个进程,本身也包含我们查的进程的程序名这个信息,所以上图中下面这个进程就是对应的grep这个进程,如果我们不想看到这个grep进程,可以使用grep -v反向过滤信息
3. 进程的信息可以通过/proc 系统文件夹查看
proc这个文件夹是内存级的,跟磁盘并没有关系
如:要获取PID为1的进程信息,你需要查看/proc/1 这个文件夹。
查看我们自己正在运行的程序,我们发现proc文件夹内也存在一个名为21150的文件夹,里面就存在着这个进程相关信息的文件
当我们把进程杀掉,我们发现这个PID对应的proc中的文件夹也消失了。
一但我们新启动,proc里面也会多出一个新的对应PID的文件夹了。
所以总结proc内存储的文件夹名是一个进程的PID,这个文件夹存储的是进程对应的动态信息,当进程退出,文件夹会被自动移除。
补充1:
我们查看对应的proc/下进程对应的文件,这里需要额外介绍的是这里的exe记录的是进程在磁盘中可执行文件的存放路径。
当我们将对应文件删除,exe就会飘红,说明可执行文件不存在了(进程本身加载到内存中,所以依然会运行)
cwd会记录进程的工作路径,这也就是为什么我们通过程序fopen创建对应的文件时不加路径,程序依然可以在正确位置创建文件的原因,因为OS会将fopen的文件名与进程cwd记录的当前工作路径做拼接得到完整的路径
我们可以使用chdir修改当前进程记录的工作路径
修改完成,程序就在指定路径下创建文件了。
所以现在我们也可以理解了,bash作为一个进程,cd之类的命令底层又是如何通过绝对/相对命令切换路径的,本质上就是靠chdir。
补充2:进程父子关系问题
getppid是获取操作系统父进程的id
在Linux操作系统中,一个进程是由它的父进程创建的,它的父进程也是由父进程的父进程创建的,所有的进程都是由它的父进程创建的,所以在Linux操作系统是一个单亲繁殖的系统,每一个父进程可以创建多个子进程,所以对于每一个进程来说也都是一颗颗进程树。
我们运行我们的程序,我们发现我的的程序每次分配的pid都不一样,但是父进程ppid每次都是一样的,说明这个父进程每次都是一样的,我们的程序进程是由同一个父进程创建的。
我们根据ppid查看,发现父进程就是bash进程,这也就是我们之前所说的命令行解释器,即命令行解释器也是一个进程。
其实在OS中,每当我们登录一个用户,系统就会为我们分配一个bash(这样做的好处具体参考之前shell的作用)
所以现在我们可以理解为什么我们不输入命令,命令行就一直卡着,本质上就类似于scanf,bash进程在等我们输入参数,然后做各种分析,然后执行对应命令。
3-1-5 通过系统调用创建进程-fork初识
• 运行 man fork 认识fork
我们根据上面的代码进行执行,那么fork之后应该有两个执行流去执行之后的代码,我们会看到两个不同的pid(fork之前的代码不会再执行了)
进程本质上PCB+自己的代码和数据,所以fork创建子进程一定会创建对应子进程的PCB,但是此时系统没有加载新的代码和数据,所以子进程并没有自己的代码和数据,所以这里创建的子进程PCB其实是拷贝的父进程的PCB(如pid之类的独有的属性还是不一样的),因此子进程根据PCB指向的代码和数据就是父进程的代码和数据,因为fork之前的代码已经执行过了,所以fork之后的子进程也只是从fork之后开始执行。
• 但是通过man手册,我们又发现fork有两个返回值,对父子进程的返回值不一样。
我们根据上面的代码发现我们果然可以根据fork的返回值,通过if分流,让父子进程去执行不同的代码块。
这似乎与我们过去语言的学习相冲突。因此这里问题就产生了。
• 为什么fork给父子返回各自的不同返回值?
因为在系统中,父进程与子进程之比是1:n的,也就是说一个父亲有多个孩子,一个孩子只有一个父亲,所以父进程具有唯一性,我们只需要标识是否创建成功就可以了,而子进程我们就需要返回每一个子进程的pid给父进程方便进行区分。
• 为什么一个函数会返回两次?
要想我们回答一个问题,我们需要先想清楚一个函数已经到return了,那这个函数的核心功能是否做完了?其实核心功能做完了,也就是说fork在return之前,已经创建出子进程了,那么此时就有两个执行流,所以两个执行流会同时执行return语句,所以fork函数就有两个返回值了。
• 为什么一个变量,既==0,又大于0?导致if else 同时成立???
这里因为需要其他的知识,所以目前只能先回答一般,具体到下面的程序地址空间回答。
在日常的使用中,我们同时运行多个程序,我们发现即使有一个程序因为各种原因挂了,也并不影响我们其他程序的使用,所以我们发现进程具有独立性。
对于父子进程来说也同样如此。父子进程共享同一份代码和数据,对于代码来书,代码是只读的,无法修改,因此子进程即使挂了,也不会影响父进程。
但是数据是可以修改,因此子进程如果可以修改数据,那么对于父进程来说,就无法保证独立性,所以当fork核心功能执行完,此时就有两个执行流,return返回,本质上就是对外部的id进行修改,所以在父子任何一方修改之前,OS就会将要修改的数据在底层拷贝一份,让目标进程去修改这个拷贝数据,这样其他进程还共享原本的数据,保持了独立性,我们把这种技术就叫做写时拷贝。
注:写时拷贝之后,父子进程PCB关的数据不是同一份了。
3-2 进程状态
3-2-1课本概念
一个进程可以有多个状态,而这个状态我们这里需要明确的是本质上就是task_struct中的一个整形值。
主要可以分为以下的状态,其中只有运行状态是真正持有CPU的,不同的状态之间可以相互转化。
3-2-1运行&&阻塞&&挂起状态
在Linxu OS中通过task_struct可以找到对应的代码和数据,不同的进程是通过双向链表维护起来的。
而对于CPU来说,一个CPU就有一个对应的调度队列,CPU执行对应的程序就是从调度队列中去下一个task_struct找到对应的代码和数据进行执行。
与语言层不同,OS中的task_struct既是在双向链表中的,也是可以在CPU的调度队列中的,即同时存在语多个数据结构之中。一个task_stuct如果要被执行,就会被放入到对应的调度队列中,根据调度算法之一的FIFO,队列前面的程序就可以视为优先级高,那么就会先被执行,排在后面的后执行。在LInux中这个调度队列叫做runqueue,类型可以理解就是struct task_struct*。
运行状态:进程在调度队列中,进程的状态就是running。
在日常中,我们使用scanf,程序运行到这个就会停下等待用户输入,这个现象就是阻塞的现象,但是这里我们需要更正的是,这里程序不是在等待用户输入,而是在等待用户键盘设备就绪,设备不就绪就无法读取数据,则该程序也不会被调度,直到设备就绪,才会被重新调度。
阻塞状态:等待某种资源或者设备就绪。
常见的设备资源有键盘、显示器、网卡、磁盘、摄像头、话筒等,OS管理这些资源同样是先描述在组织,通过结构体描述对应硬件资源,然后OS通过管理这些结构体就可以管理硬件资源了。
同样的对应每一个硬件PCB内都一个对应的wait_queue等待队列,当我们的CPU调度到每一个程序发现对应的硬件资源不就绪,那么OS就会把对应的进程从CPU上剥离下来并且不再放入到调度队列中,而是放入对应不就绪的硬件资源PCB的等待队列中,因此这个进程不在调度队列中,那么这个进程就不会再被执行,所以程序就看着就卡住了,我们把这种状态叫做阻塞。直到硬件资源就绪,OS才会把对应进程从等待队列拿下来,重新放入到调度队列中
所以从运行到阻塞,本质上就是将对应的PCB链入到不同的队列中。进程状态的变化表现之一就是要在不同的队列中进行流动,本质上就是对数据结构的增删查改。
而当我们的系统非常忙碌,资源严重不足时,OS就会从设备的等待队列中,根据对应的算法将暂时不会使用的进程的代码和数据唤出到磁盘swap交换分区中,等待队列只留下对应的PCB,我们把这种唤出操作就叫做挂起,而从阻塞队列中唤出,组合其来就是阻塞挂起(OS将调度队列中的代码和数据唤出就是就绪挂起了。),当系统资源充足是,OS又会重新将swap中的资源唤入到系统中,将代码数据和PCB重新连接。
1.理解内核链表
之前语言的学习中,我们定义一个链表节点,往往是链接诶对指针和数据封装在一起。而OS PCB中封装的结构体存储的只有指向前后节点的指针,如下图。这样我们也可以通过结构体内的指针遍历整个链表
而在C语言阶段我们又了解到结构体名的地址跟结构体内首元素的地址是一样的,结构体内成员的地址是由低到高顺序增长的。那么我们通过给0号地址强转结构体类型(类型只是编译器理解对应变量存储的一种方式)再去取出对应成员地址,这时候我们就可以得到该成员相对于结构体首元素的偏移量,那么对于进程PCB来说,利用这种方法,我们知道PCB根据链接的指针加上这个计算偏移量的方式,那么我们就可以访问PCB链表中的任何一个PCB的任何一个成员。
因此现在一个PCB中存储任何一个list_head或者其他的结构体都可以访问整个数据结构中的每一个PCB的具体数据。即PCB现在可以以任何数据结构管理数据,一个PCB可以同时以多个数据结构的形式(链表、二叉树、哈希表等)进行管理。
3-2-3 Linux内核源代码怎么说
• 为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状
态(在Linux内核里,进程有时候也叫做任务)。
注:具体系统中的状态定义是基于课本即理论指导的,但是根据具体的实际情况,会有自身特殊的定义。
进程状态在kernel源代码里定义:会有对应的符号来表示(实际上不同的状态就是一些数值)
/*
*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):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
3-2-2 进程状态查看
ps aux / ps axj 命令
• a:显示一个终端所有的进程,包括其他用户的进程。
• x:显示没有控制终端的进程,例如后台运行的守护进程。
• j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息
• u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等
可以看到程序运行起来,进程状态显示的就是R了。
需要注意的是如下图,因为printf是打印到显示器上的,程序需要与硬件交互,即IO,需要等待硬件就绪,相对来说耗时,进程大部分时间都在等待,所以我们显示的进程状态显示的S,即S状态,
所以这里的S状态就是上文我们提到的阻塞状态
注:这里的+表示的是程序在前台运行,我们在运行程序是后面加上& 表示让程序在后台运行,就看不到+号了,至于前后台具体介绍后文会介绍。
t和T都是暂定状态,而t也叫追踪状态,当我们debug打断点时候,进程会被暂停,此时就是t状态而T状态当我们运行程序可以Ctrl + z ,主动让程序暂停,此时就是T状态
t和T与S状态不同,S即阻塞往往是等待某种资源,而t和T往往是某种条件不具备或者触发某种错误了,这时候OS怀疑进程发生错误,但是错误又不是那么严重到让OS直接杀掉进程,所以OS暂停程序,让用户判断是否继续运行,本质上就是一种止损,比如t就是调试中,暂停让用户判断是否继续进行。
比如此时我们某一个进程正在对磁盘写入数据,需要等待磁盘返回信息,进行处理(比如写入失败重新写入等),需要较长时间。但是此时系统资源严重不足,上文各种唤出操作都做了,依然不足,那么此时OS就会采取杀进程的方式来节省资源。那么此时进程被杀掉了,但是磁盘写入状态不确定,假如此时磁盘写入失败,需要重新写入,但是进程已经被杀掉了,那么我们此时事实上就丢失了这100MB的数据,假如说这个100MB的数据是什么重要数据,那么就会造成非常严重的后果。
所以所谓的D状态就是针对这个情况的,凡是涉及对精密数据的高IO操作的进程都设为D状态,这样OS就不会出现杀掉它的情况了。所以D状态也是一种阻塞状态。
X对应的就是系统的结束状态
3-2-3 Z(zombie)-僵尸进程
• 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
• 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
往往我们创建一个子进程,是希望子进程可以帮助我们完成某个任务的,子进程会返回相关的信息给父进程(信息就存储在task_struct中),父进程也需要读取相关子进程的信息,而子进程为了父进程能够获取对应的信息就会维持僵尸状态,知道父进程读取相关信息(使用wait()系统调用,后
面讲),子进程才会死亡。
• 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
3-2-4 僵尸进程危害
所以我们可以看到如果父进程一致不管,不回收,不获取子进程的退出信息,那么z会一直存在,维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,
换句话说,Z状态一直不退出,PCB一直都要维护。那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费,这就是了我们常说的内存泄漏问题。
对于一般的进程来说,其实当进程退出,所申请的内存会被系统自动释放,所以对于一般的进程来说内存泄漏的问题并不算是问题,但是我们的比如操作系统通常来讲都是长时间运行的,即常驻进程,像公司的服务器之类也是常驻的,所以对于常驻进程来说一旦出现内存泄漏,就非常致命。
ptrace系统调用追踪进程运行,有兴趣研究一下
挂起因为涉及非常低层,属于OS应该关心的事情,所以在操作系统的具体设计上,对于用户来说是没有对应的状态表示的。
3-2-5 孤儿进程
• 父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
• 父进程先退出,子进程就称之为“孤儿进程”
我们发现当父进程先退出,孤儿进程会被1号init/systemd进程领养,先关返回信息要有init/systemd进程回收。
每当我们登录,就是这个1号进程为我们创建的bash进程,这里我们可以简单理解成OS。
注:被领养后的孤儿进程自动变成后台程序,我们只能通过信号kill - 9 pid杀掉它。
3-3 进程优先级
3-3-1 基本概念
• cpu资源分配的先后顺序,就是指进程的优先级(priority)。
• 优先级高的进程有优先执行权利。配置进程优先级对多任务环境的linux很有用,可以改善系统性能。
• 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大 改善系统整体性能。
权限解决的是能否得到某种资源,而优先级是能得到资源情况下,谁先谁后的问题。
之所以需要优先级这个概念,是因为相比我们的进程CPU资源更加稀缺,所以需要通过优先级确定谁先谁后的问题。
而实际上所谓优先级就是task_struct中int类型的数据,值越低,优先级越高,反之优先级越低。
但是Linux上我们所使用的主要是一种基于时间片的分时操作系统,每个进程在CPU上运行
固定时间,运行完时间就只能等下一轮,这种系统的特点就是需要考虑一定的公平性,优先级可能变化,但是变化的幅度不能太大
3-3-2 查看系统进程
在linux或者unix系统中,用ps –l命令则会类似输出以下几个内容:
我们很容易注意到其中的几个重要信息,有下:
• UID : 代表执行者的身份,ls -ln 可以查阅
其实我们Linux系统中并不是通过用户名来区分用户的,而是通过UID,每创建一个进程,系统就会分配对应的UID,如果创建文件,就会将进程对应的UID写入到文件中。
所以现在回头来看系统是如何区分用户对于文件是拥有者、所属组还是other的?
Linux系统中,访问任何资源都是,都是进程访问,进程就是用户,所以本质上就是通过比较进程的UID和文件记录的UID,来确认进程和文件之间关系的。
• PID : 代表这个进程的代号
• PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
• PRI :代表这个进程可被执行的优先级,其值越小越早被执行,默认:80(注一般不建议随便改进程优先级)
• NI :进程优先级的修正数据,代表这个进程的nice值
3-3-3 PRI and NI
• PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
• PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:进程真实优先级PRI(new)=PRI(old)+nice
• 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
• 调整进程优先级,在Linux下,就是调整进程nice值,我们其实也只能通过调整修正值来修改优先级
• nice其取值范围是-20至19,一共40个级别。
3-3-4 查看和修改进程优先级的命令
用top命令更改已存在进程的nice:
• top
• 进入top后按“r”–>输入进程PID–>输入nice值
我们发现进程优先级的修改始终保持进程真实优先级=PRI(默认80) + NI,这样设计的好处就是每次修改我们都不在需要关心上次的优先级是多少了。
注意:
• 其他调整优先级的命令:nice,renice或者系统函数
1.优先级的极值问题
我们发现nice值的范围默认是[-20,19],而PRI默认是80,所以Linux进程的优先级范围就是[60,99]一共40个变化范围。
之所以跟优先级的变化幅度不能太大,跟上文介绍的时间片有关,在Linux中可能会存在进程恶意修改进程优先级或者用户优先级设立不合理,进而导致优先级高的进程长时间占据CPU资源,优先级低的进程,长时间就得不到CPU资源,进而导致了进程饥饿问题。
3-3-5 补充概念-竞争、独立、并行、并发
• 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
• 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰(每个进程都有自己独立的PCB和代码以及数据,所以一个进程挂掉,不会影响其他进程)
• 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
• 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发(每个进程的代码都执行一段时间,执行完一段时间切换下一个,多个进程循环执行,因为CPU很快,即使非常短的时间也能执行很多,所以对于人来说,看到的就是多个进程共同运行的现象了。具体原因下文进程切换解释)
3.4 进程切换
3-4-1.死循环进程如何运行
一但一个进程占有CPU实际并不会直接将自己的代码直接跑完,因为存在时间片,当跑完一个时间片,进程就会被切下换上下一个进程继续执行,而上一个进程只能等待下一轮继续执行之前剩下的代码。
所以对于死循环来说,不会一直占有CPU,也是执行一段时间后切换下来。
3-4-2.cpu和寄存器
CPU内存在很多的寄存器,这些寄存器就是CPU内部的临时空间,寄存器是存储数据的空间,不等于寄存器里面的数据。当一个进程被执行时,这些寄存器会记录执行产生的临时数据,比如代码执行到第几行了,临时变量的值,此时执行的运算操作等等数据,我们把这些叫做进程的上下文数据。当一个进程运行时间片到了,被剥离下来,那么该进程的上下文数据就会被保存,并被一同拿下来保存,当该进程重新占有CPu,那么该上下文数也会被一同加载,恢复进程历史上下文数,那么进程就可以从刚刚停下的地方重新运行了。
3-4-3.具体如何切换
CPU上下文切换:其实际含义是任务切换, 或者CPU寄存器切换。当多任务内核决定运行另外的任务时, 它保存正在运行任务的当前状态, 也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中, 入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的运行,当上一个进程重新占据CPU,保存的上下文数据会重新存入到对应的寄存器中,那么进程就回到之前的执行状态,这一过程就是context switch。
进程切换最核心的就是保存和恢复当前进程的硬件上下文的数据,即CPU内寄存器的内容
那么上下文数据又保存到哪里呢?当代上下文数据是保存到tss结构体中了,并且通过task_struct结构体可以找到对应上下文数据。
参考一下Linux内核0.11代码
注意:
时间片:当代计算机都是分时操作系统,没有进程都有它合适的时间片(其实就是一个计数
器)。时间片到达,进程就被操作系统从CPU中剥离下来。
3-4-4 Linux真实调度算法
Linux2.6内核进程O(1)调度队列
上图是Linux2.6内核中进程队列的数据结构,之间关系也已经给大家画出来,方便大家理解
一个CPU拥有一个runqueue,多个CPUj就具有多个runqueue,我们首先看上图中的活跃进程中的queue,这个queue数组内部就存储的task_struct指针,queue0~99的下标我们称之为实时优先级,不过这个我们一般不关心(实时优先级是实时操作系统的概念,与基于时间片的分时操作系统不同,实时操作系统是有一个重要的进程来,就会立刻执行该进程,常用与工业比如车载系统中,这里是Linux为了拓宽系统的市场,所以设计的,一般我们不需要考虑);
100~139称为普通优先级,一共40个下标正好对应我们上文讲的PRI优先级概念,所以我们正常的优先级是通过计算映射到对应的下标出的,对于一个task_struct来说会根据的对应的优先级链入到对应的下标处,相同优先级的task_struct就构成了一个链表/队列,所以我们可以看到queue就是一个哈希桶。所以未来我们按照从上往下遍历就可以实现按照优先级进行调度,在局部上,对于每一个队列,我们按照先进先出的原则(FIFO)。
但是问题又来了,根据这种结构调度,如果我们的进程PCB在最末尾呢?我们不还是需要遍历这个数组吗?所以这种设计还是不够优雅。所以又设计了unsigned int bitmap[5],这是一个位图,32X5=160,160个位我们只使用前140个对应queue140个下标,我们通过位图每一个比特上0/1判断对应queue下标处是否存在队列。
最终我们调度一个进程就不再需要遍历数组了,我们根据位图+哈希可以快速找到一个进程并调用了,以近大O(1)的时间复杂度。这就是Linux的大O(1)调度算法。
但是现在问题又来了,如果我们是调用一个死循环呢?我们调用完一个时间片后还是把它放到原来的queue里面吗?那如果一直这样,不就相当于死循环进程一直占据高优先级位置,一直在调用吗,其它进程不就调用不了吗?
所以实际上runqueue中定义了 struct rqueue_elem prio_arry[2];prio_arry[0]指向的是我们上面将的活跃队列,runqueue中有active指针指向它;prio_arry[1]指向我们上图中的过期队列,有expired指向它。过期队列的结构和活跃队列一模一样。
当我们的进程开始调度,会根据active找到活动队列,因为active指针永远指向活动队列,当我们的进程在活跃队列调用完一个时间片之后,会放到对应过期队列的对应优先级下标处,这样随着进程的调度,活动队列上的进程会越来越少,过期队列上的进程会越来越多。
nr_active表示的是总共有多少个运行状态的进程,当nr_active现实活动队列上的队列都调用完成,那么我们swap交换active和expired指向,这样原来的过期队列变成新的活动队列,这样调度就轮转起来了,直到所有进程全部执行完。
注:我们把处于过期队列中的进程就可以叫做处于就绪状态了。像一些系统提供的抢占之类的功能就是将进程直接链入到对应活动进程中
注:如果有多个CPU就要考虑requeue的负载均衡问题,新的进程会被分配给目前任务量较轻的runqueue中。
• 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法!
4. 环境变量
4-1 基本概念
• 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
• 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
• 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
4-1-1补充:命令行参数
之前C语言的学习中,我们不了解main是否有参数,以及如果有,这些参数是什么?
先介绍main的两个参数,一个是int argc,一个是char* argv[]
所以我们发现argv本质上是一个变长数组,我们命令行输入的就被视作一个大字符串,再被以空格为间隔切成了一个个子串,放入到argv对应下标处,我们把这些子串就叫做命令行参数。
现在我们发现我们在命令行传入对应的选项,我们的程序就能根据我们输入的选项执行对应的代码了
所以main的命令行参数是实现程序子功能的方法,就是我们指令的实现原理。
所以我们现在就可以知道就是bash完成的对命令行的子串切分工作,同时我们的进程拥有一张argv表,用来支持实现选项功能。
4-2 常见环境变量
• PATH : 指定命令的二进制文件默认搜索路径
执行一个进程就需要找到对应的进程,之所以我们的代码需要指明路径,系统指令不需要,是因为系统中存在环境变量PATH来指明指令二进制文件默认搜索路径。
我们env查看环境变量表,环境变量以名字=内容形式组织
可以看到PATH指定查找对应的二进制文件会到bin目录下查找,PATH中:分割的就是一个一个路径
echo $NAME //NAME:你的环境变量名称,这个指令可以显示对应环境变量的内容
我们通过环境变量名=内容形式可以修改环境变量内容,我们发现自己的命令可以直接使用,但是这是覆盖式修改,覆盖原有路径,所以系统指令无法使用(这种修改只是内存级的,退出重新登录即可恢复)
所以我们应该使用name=$name:内容的形式,这样修改会将新增内容拼接到原内容
那么首先第一个问题环境变量是存储在哪里的呢?
环境变量是存储在系统的配置文件中的,当我们登录,系统创建bash进程,bash进程从系统中读取形成一张环境变量表,内部存储的是指针,指向对应环境变量字符串(每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串)
当我们在命令行数输入参数,bash会形成命令行参数表,根据参数和环境变量表找是否存在对应指令,如果存在则创建子进程执行对应的指令。
所以bash中有两张表一张环境变量表、一张命令行参数表。
所以如果我们有多个用户登录,就有多个bash,每个bash都有这两张表
• HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
cd ~可以切换到家目录,我们的家目录是谁,也是通过我们的环境变量HOME来确定的
• SHELL : 当前Shell,它的值通常是/bin/bash。
• HISTSIZE: 记录历史命令数量的上限。
• PWD:当前的工作路径
4-4 和环境变量相关的命令
1. echo: 显示某个环境变量值
2. export: 设置一个新的环境变量
3. env: 显示所有环境变量
4. unset: 清除环境变量、本地变量
5. set: 显示本地定义的shell变量和环境变量
4-5 通过代码如何获取环境变量
•命令行参数
承接上文的内容,我们介绍命令行第三个参数就是我们的环境变量表
通过第三参数,我们就可以打印出环境变量表了。
注:实际上main只是我们程序的入口函数,在main之前系统会调用start来根据main的参数个数来说进行不同的代码,这也就是为什么main带的参数个数不固定的原因。
子进程中的环境变量表是继承自父进程的,我们发现父进程中导入的环境变量可以被子进程继承。
所以一开始对于我们来说只有bash这一个进程,那么对之后所有的进程来说,都可以继承父进程的环境变量表,这也就是为什么说环境变量具有全局性了,因为继承的是同一张表。
• 系统调用获取,getenv
那么基于这个获取环境变量的操作,我们就可以根据用户的身份,控制一个程序谁可以执行什么样的功能
• 通过第三方变量environ获取
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用
extern声明。
4-8 环境变量通常是具有全局属性的
4-8-1补充:本地变量
实际上发现bash中也可以定义变量,这种变量我们叫做本地变量,所以实际上bash会记录本地变量和环境变量两套变量,本地变量不会被子进程继承,只在bash内部有效。这种变量主要用于支持shell脚本、其他解释性语言脚本、命令行中一些特殊标识、换行等功能。
注:unset可以清除本地变量、echo $ 可以显示本地变量的值,set可以同时显示环境变量和本地变量。export可以将本地变量导成环境变量。
但是这个有个奇怪的地方,export 不是子进程执行吗?导入bash的环境变量,不就是修改了父进程的数据吗?这不就影响了进程的独立性了吗?
所以这里export是内建命令,像pwd也是,内建命令不需要创建子进程,是有bash亲自执行的,通过调用函数或者系统调用完成的,不需要环境变量也可以运行。
5. 程序地址空间-第一讲
5-1 研究平台
• kernel 2.6.32
• 32位平台
5-2 程序地址空间回顾
我们在学习C语言的时候,遇见过这样的空间布局图
在学习之前,我们可以先对其进行各区域分布验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char* argv[], char* env[])
{const char* str = "helloworld";printf("code addr: %p\n", main);printf("init global addr: %p\n", &g_val);printf("uninit global addr: %p\n", &g_unval);static int test = 10;char* heap_mem = (char*)malloc(10);char* heap_mem1 = (char*)malloc(10);char* heap_mem2 = (char*)malloc(10);char* heap_mem3 = (char*)malloc(10);printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)printf("read only string addr: %p\n", str);for (int i = 0; i < argc; i++){printf("argv[%d]: %p\n", i, argv[i]);}for (int i = 0; env[i]; i++){printf("env[%d]: %p\n", i, env[i]);}return 0;
}
$ ./a.out
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482e
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[5]: 0x7ffd0f9a486e
env[6]: 0x7ffd0f9a4892
env[7]: 0x7ffd0f9a48a5
env[8]: 0x7ffd0f9a48ae
env[9]: 0x7ffd0f9a48f1
env[10]: 0x7ffd0f9a4e8d
env[11]: 0x7ffd0f9a4ea6
env[12]: 0x7ffd0f9a4f00
env[13]: 0x7ffd0f9a4f13
env[14]: 0x7ffd0f9a4f24
env[15]: 0x7ffd0f9a4f3b
env[16]: 0x7ffd0f9a4f43
env[17]: 0x7ffd0f9a4f52
env[18]: 0x7ffd0f9a4f5e
env[19]: 0x7ffd0f9a4f93
env[20]: 0x7ffd0f9a4fb6
env[21]: 0x7ffd0f9a4fd5
env[22]: 0x7ffd0f9a4fdf
现在根据地址我们可以解释了,为什么常量字符串不可以修改,应该它是编码到代码区的,而代码区是只读的,所以字符串就是只读的;static修饰的栈上变量之所以具有全局属性就是因为它被编码到初始化数据区,所以它具有了全局属性了。我们现在也可以看到堆区是向上增长的了,栈区是向下增长的了。这是我们之前学习语言所了解的概念。
现在我们需要纠正一下,我们上面的程序地址空间并不是实际的物理内存空间排布,程序地址空间实际上应该叫做进程地址空间(虚拟地址空间)这才是系统上的概念。
5-3 虚拟地址
来段代码感受一下
输出
我们发现,父子进程,输出地址是一致的,但是变量内容不一样。所以父子进程输出的变量绝对不是同一个变量,变量内容不一样, 但地址值是一样的,说明,该地址绝对不是物理地址
在Linux地址下,这种地址叫做虚拟地址,我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理,OS负责将虚拟地址转化成物理地址。
5-4 进程地址空间
系统中一个进程对应一个虚拟地址空间,虚拟地址空间宽度是1字节,所以32位下总共就是4G,0~3G是用户空间,用户拿着地址就可以直接访问,3~4G是内核空间,虚拟地址空间不是物理内存,我们的task_struct执行代码就会在虚拟地址空间上找到对应的扁变量的虚拟地址,有了虚拟地址就可以经过页表(一个进程就对应一个页表,页表是用来做虚拟地址到物理地址映射的),找到变量实际存储的物理地址,所有对象都有虚拟地址,我们都可以通过这个方法找到对应物理地址
(注虚拟地址空间的宽度是1字节,我们取到的只是1字节的地址,但是根据类型,我们就可以知道对应的偏移量--因为一个变量的数据是连续地址存放的,我们就可以取出一个变量的完整数据)
所以当fork出子进程,子进程也要有自己的虚拟地址空间和页表,这里就发生了浅拷贝,虚拟与物理地址与父进程完全一样。
而当我们的子进程要修改数据,那么就会发生写时拷贝,物理内存开辟一块空间存放要修改的数据,页表的物理地址修改,虚拟地址不变,这也就意味着相同的虚拟地址指向了不同的物理地址。
所以现在我们可以回答上面的遗留问题了,为什么一个变量既==0,又大于0,因为这里fork内部return发生写时拷贝,这个变量是虚拟地址,实际指向不同的物理地理地址,我们发现我们的代码地址跟实际物理地址因为这一层虚拟层就出现了解耦。
5-4-1.如何理解虚拟地址空间
可以说,mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:
虚拟地址空间的出现实现了一个进程对应一个虚拟地址空间,对于每个进程来说,看不到实际的物理内存的其他进程使用情况,那么就让每一个进程认为自己有4GB的物理内存,即独占整个物理内存。
既然一个进程使用一个完整的虚拟地址空间,那么每个区域的划分等属性就需要管理,一个进程对应一个虚拟地址空间,那就有多个虚拟地址空间需要管理,所以先描述,再组织,虚拟地址空间本质就是一个数据结构,在Linux中就是一个叫mm_struct的结构体。
描述linux下进程的地址空间的所有的信息的结构体是mm_struct (内存描述符)。每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程的结构。
struct task_struct
{/*...*/struct mm_struct* mm; //对于普通的用户进程来说该字段指向他的虚拟地址空间的用户空间部分,对于内核线程来说这部分为NULL。struct mm_struct* active_mm; // 该字段是内核线程使用的。当该进程是内核线程时,它的mm字段为NULL,表示没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是一样的,内核线程可以使用任意进程的地址空间。/*...*/
}
定位mm_struct文件所在位置和task_struct所在路径是一样的,不过他们所在文件是不一样的,
mm_struct所在的文件是mm_types.h。
struct mm_struct
{/*...*/struct vm_area_struct* mmap; /* 指向虚拟区间(VMA)链表 */struct rb_root mm_rb; /* red_black树 */unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的大小*//*...*/// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。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;/*...*/
}
而这样的结构体又是怎么样的呢?
5-4-2理解区域划分
我们以一张桌子为例,我们划分所谓的区域其实只要在桌子上一条线,那么我们就可以说从哪到哪是什么什么区域,我们发现通过描述从什么到什么就可以划分一个区域,对应到计算机上,一块连续的区域,我们只需要记录开始和结束地方的地址我们就可以划分一块区域出来,我们也就可以通过int类型来表示这些地址了。4GB空间,我们对其进行编址,0、1、2、……等这一个一个刻度就是一个一个地址,即虚拟地址空间上的地址。同样的我们调整某一块区域大小也只需要调整对应的开始和结束的地方地址,比如上移或者下移。
源码如下
所以现在再次理解,当我们加载代码和数据时候,现在物理内存申请对应的空间,然后再在虚拟地址空间申请相同地址的空间,再建立虚拟到物理的转化,然后虚拟地址就可以提供给上层使用了。
既然虚拟内存空间是结构体,那么就需要初始化,而初始化的值就是在上述加载的过程中获取的,虚拟地址空间申请需要调整对应的空间大小,本质上就是根据值,调整结构体中对应开始和结束变量的值。
5-5 为什么要有虚拟地址空间
从此往后,程序加载到物理内存可以加载到物理内存的任意位置,甚至一段程序分多段加载到物理内存的不同地址处也没事,对于上层用户,只会看到虚拟地址空间中程序是完整的,地址是有序的,所以虚拟地址空间第一个意义就是让无序变有序。
从今往后,OS根据虚拟地址查物理地址,一定是要经过页表的,那么如果是查物理内存中不存在的地址即野指针,页表发现该地址非法,程序就会报错,并且页表中还存在一个权限位,表示根据对这个地址的数据可以进行的操作,而这也就是不同区域特性的本质,比如对于正文代码只有只读权限,那么想要修改,OS查看对应的权限,发现不符,进行权限拦截,就会报错。所以虚拟地址空间的第二个作用就是在物理转换的过程中,对我们的地址和操作进行合法性判定,进而保护物理内存。
所以现在如果我们的程序会先生成对应的task_struct和页表,暂时没有代码和数据,如果这个程序非常庞大,超过内存的大小,我们就先不将程序全部加载进来,而是只加载一部分,那么当程序执行到未加载部分(已执行完的部分可以释放了)发现虚拟地址经过页表映射找不到物理地址,就会触发缺页中断,此时再将程序部分代码加载进来,此时跟物理有关的内存IO部分跟进程管理部分就完成了解耦,进程管理部分不需要去关心内存IO部分做了什么。(因为有地址空间的存在,所以我们在C、C++语言上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知!!)
所以我们现在再次理解挂起,实际上就是将我们对应的物理内存上加载的数据唤出到对应分区,页表清空,而进程管理部分不需要释放,重新唤入只需要物理内存重新加载数据,并重新建立页表映射
5-5-1如果程序直接可以操作物理内存会造成什么问题?
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。
这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。
• 安全风险
每个进程都可以访问任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内
存区域,如果是一个木马病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
• 地址不确定
众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中
去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝
的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程
都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程
在运行了,那执行a.out的时候,内存地址就不一定了
• 效率低下
如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理
内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内
存,但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝
时间太长,效率较低。
5-6拓展(其他的详细内容后面再展开)
对于堆区来说是一块一块申请的,那么就会有多个起始地址,那么又是如何管理的呢?
其实对于对于来说每一块都有对应的vm_area_struct节点进行管理,多个vm_area_struct节点以链表形式被mm_struct统一管理。
更深入一点,其实对于每一块区域来说都是这样,实际每一个区域都是通过vm_mm链表进行管理,最后同一通过mm_struct进行管理,只需要记录每一个vm_mm对应的地址就行。
那既然每一个进程都会有自己独立的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织方式有两种:
1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;
2. 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树。
linux内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA),由于每个不同质的虚
拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各个VMA,方便进程快速访问。
struct vm_area_struct {unsigned long vm_start; //虚存区起始unsigned long vm_end; //虚存区结束struct vm_area_struct* vm_next, * vm_prev; //前后指针struct rb_node vm_rb; //红黑树中的位置unsigned long rb_subtree_gap;struct mm_struct* vm_mm; //所属的 mm_structpgprot_t vm_page_prot;unsigned long vm_flags; //标志位struct {struct rb_node rb;unsigned long rb_subtree_last;} shared;struct list_head anon_vma_chain;struct anon_vma* anon_vma;const struct vm_operations_struct* vm_ops; //vma对应的实际操作unsigned long vm_pgoff; //文件映射偏移量struct file* vm_file; //映射的文件void* vm_private_data; //私有数据atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMUstruct vm_region* vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMAstruct mempolicy* vm_policy; /* NUMA policy for the VMA */
#endifstruct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;