一. 认识操作系统
1. 什么是操作系统
操作系统是做管理软硬件工作的软件。
2. 为什么要有操作系统
操作系统在计算机系统里处于承上启下的低位:向下通过驱动程序与硬件间接交互,充分发挥硬件的功能;向上为用户提供接口,给用户提供稳定、高效、简易的使用环境。
3. 计算机的体系结构
自下而上:硬件部分 -> 系统软件部分 -> 用户部分
驱动程序
什么是驱动程序?
驱动程序全称:设备驱动程序(Device driver),是一种特殊的程序,说特殊点就是一段代码,其中包含有关硬件设备的信息在。Linux内核中 '驱动程序的代码 ',会占到 ”OS操作系统的内核源码“的 70%。
并不是说所有硬件设备都需要驱动程序才能使用,像CPU、内存等就不需要,因为这些硬件对于一台电脑而言过于重要,他们都是BIOS所直接支持的硬件。
为什么要有驱动程序?
驱动程序完成硬件 ‘设备的电子信号’ 和 ‘计算机系统的代码指令’ 之间的翻译,是硬件和操作系统之间的"桥梁“。
我们的计算机使用的是代码指令,而外部硬件设备识别的是电子信号,这是两个完全不同的东西,所以我们的计算机与外设要通过’驱动程序’ 进行互动通信。
驱动程序如何工作?
比如我们要播放音乐:
- OS会发送指令给 ‘声卡–驱动程序’。
- '声卡–驱动程序’收到指令后,将其’翻译’成 声卡能听懂的 ‘电子信号’。
- 然后把这个’电子信号’,给到声卡,让声卡播放音乐。
系统调用
操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。系统调用是通向操作系统本身的接口,是面向底层硬件的。通过系统调用,可以使得用户态运行的进程与硬件设备(如CPU、磁盘、打印机等)进行交互。
例如 read: 从打开的文件或设备中读取数据 ,内核将调用内核相关函数sys_read()来实现,用户程序不能直接调用这些函数,这些函数运行在内核态,CPU 通过软中断切换到内核态开始执行内核系统调用函数。
库函数
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,方法是把一些常用到的函数编完放到一个文件里,供不同的人进行调用。一般放在.lib文件中。有了库,就很有利于上层用户或者开发者进行二次开发。
例如 read()函数根据传入参数,直接就能读文件,而背后隐藏的比如文件在硬盘的哪个磁道,哪个扇区,加载到内存的哪个位置等等这些操作,我们是不必关心的,这些操作里面自然也包含了系统调用。
系统调用和库函数的关系
系统调用和库函数是上下级的关系,一般涉及到硬件的库函数都要经过系统调用,再比如算法的库函数就不用经过系统调用。
二. 进程
1. 进程概念
进程是程序在一个数据集合上运行的过程,是加载到内存当中的程序,通俗理解就是正在进行中的程序。
进程 = 代码 + 相关的数据 + PCB (进程控制块Process Control Block)
比如我们运行了一个程序proc,利用ps指令查看他的进程信息
进程和程序的关系
进程是动态的,程序是静态的,进程有创建,执行,消亡,所以进程实体是有生命周期的,而程序只是一组有序指令的集合。
2. 描述进程的实体 — PCB
为了描述和控制进程的运行,系统为每个进程定义了一个结构体——进程控制块PCB(Process Control Block),他包含了很多与进程相关的信息,PCB是进程存在的唯一标志。 Linux 系统中的PCB叫做task_struct ,而在 Windows 操作系统中则使用一个执行体进程块EPROCESS(全称Execute Process)来表示进程对象的基本属性。
2.1 进程标示符
PID:全称Procss ID,描述本进程的唯一标示符,用来区别其他进程。
PPID:全称Parent Procss ID,父进程的标识符。
2.2 状态
标识进程当前所处的任务状态。
①:R运行状态(running)
该状态不意味着进程一定在运行中,它表明进程要么是正在CPU里运行,要么在运行队列里等待运行。
前台运行与后台运行
./ proc,运行可执行程序proc,我们看到它的运行状态是R+,说明该进程在前台运行,前台运行的进程只能有一个。
./proc &,在最后位置加上取地址符号来运行程序proc,可以看到它的状态是R,说明该进程在后台运行,后台运行的程序可以有无数个。
②:S睡眠状态(sleep)
也叫作可中断睡眠状态(interruptible sleep)。进程在等待事件发生而被放入对应事件的等待队列中,当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。
通过top命令我们会看到,大多数进程都处于这个状态,因为进程很多,而cpu一次只能执行一个(这里指单核的处理器),如果不是绝大多数进程都在睡眠,CPU又怎么响应得过来。
③:D磁盘休眠状态(Disk sleep)
也叫作不可中断睡眠状态(uninterruptible sleep)。进程不响应异步信号,即不受任何信号影响,通常会等待IO的结束才唤醒。看这个名字就知道与硬件有关,比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互,这个时候通常进入这个状态以对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。
④:T停止状态(stopped)
向进程发送一个SIGSTOP信号,它就会因响应该信号而进T状态(除非该进程本身处于D状态而不响应信号),此时向进程发送一个SIGCONT信号,可以让其从T状态恢复到R状态。
当进程正在被跟踪时,它处于T这个特殊的状态。“正在被跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行进一步的操作。比如我们在调试到断点处时进程就处于T状态。
⑤:X死亡状态(dead)
进程即将被销毁。这个状态只是一个返回状态,销毁过程是非常短暂的,几乎不可能通过ps命令捕捉到。
⑥:Z(zombie)-僵尸进程
概念
子进程退出,父进程运行,但父进程没有调用 wait 或者 waitpid 函数完成对子进程资源的最后清理,即退出信号的读取,这时子进程就处于僵尸状态(Z)。
成因
子进程在被销毁时操作系统会来清理该进程的资源,除了他的task_struct,因为他要告诉关心它的父进程,你交给我的任务,我办的结果如何?子进程在退出的过程中,内核会给其父进程发送一个信号,通知父进程来“收尸”。这个信号默认是SIGCHLD,父进程收到后可以通过wait系列的系统调用(如wait、waitid)来等待某个或某些子进程的退出并获取他的退出码和一些统计信息,顺带销毁子进程最后的task_struct结构。
结果
只要父进程不退出,这个僵尸状态的子进程就一直存在,导致内存泄漏。那么如果父进程退出了呢,谁又来给子进程“收尸”?这时它就更惨了,变成僵尸不说还没人认领,它就又属于孤儿进程(子进程没完全退出,父进程先退出)哪一类。
这些孤儿进程会被托管给退出进程所在进程组的下一个进程(如果存在的话),或者是1号进程。
1号进程,即pid为1的进程,又称init进程。linux系统启动后,第一个被创建的用户态进程就是init进程。它有三项使命:
- 执行系统初始化脚本,创建一系列的进程(其后创建的所有进程都是init进程的子孙);
- 在一个死循环中等待其子进程的退出事件,并调用waitid函数来完成子进程的“收尸”工作;
- 收养孤儿进程。如果是僵尸的孤儿进程,会通过wait系列的系统调用从而让子进程彻底退出;不是僵尸进程的话没什么危害,因为该进程只是父进程换成了 init ,依然可以正常运行。
2.3 优先级
优先级决定的是在已经可执行的前提下谁先执行的问题,权限决定能否被执行。PRI越小,优先级越高,优先执行,就好像考试排名的数字一样,越小越靠。
在linux或者unix系统中,用ps –l命令可以看到进程相关的优先级信息:
- PRI(全称priority)=PRI_old + NI(全称NICE)
- PRI_old是常量80,而NI的取值范围是-20至19,一共40个级别
- PRI值越小其优先级会变高,则其越快被执行
- 一般是通过修改NI来改变一个进程的优先级的
用top命令更改已存在进程的nice
首先输入top,进入到实时进程页面
输入r,提示你输入要修改进程的PID。
最后输入你要修改的NI的值。输入如果超出NI的范围,就按边界的值算。
2.4 程序计数器
程序中即将被执行的下一条指令的地址,当运行中的进程被其他优先级更高的进程占用或该进程执行的时间片结束时,task_struct里的程序计数器会保存下一条执行指令的地址,下一次运行时可以从中断读取。
2.5 内存指针
包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针,进程运行时可以读取代码和相关数据。
2.6 上下文数据
进程执行时放在处理器的寄存器中的还没来得及写入目标区域的数据。这样下次进程在CPU运行时,可以从寄存器中读取到这些数据。
进程退出有两种情况:
①时间片到了。进程的时间片到了就必须退出,等待下一次运行。
②抢占。当进程在运行时并且时间片还没到,来了个优先级更高的就会抢占这个运行中的进程。
进程的代码还没有全部跑完就退出,退出去它会把还没有来得及写入目标区域的数据就会放到处理器的寄存器中,下次运行时可以读取这些数据。
3. 进程的组织方式
每个task_struct中都有一个tasks的域来连接到进程链表上去。把一个个进程以双向链表的形参式组织起来。
三. 进程的虚拟地址空间
1. 什么是虚拟地址空间
在回答这个问题之前我们,我们先来感受一下
我们发现,输出出来的变量值和地址是一模一样的,很好理解呀,因为子进程复刻了父进程,父子并没有对全局变量进行任何修改。可是将代码稍加改动:在父进程的判断条件里修改全局变量g_val=200
我们发现,父子进程中输出全局变量地址还是一致的,但是变量值不一样!我们可以得到如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明该地址不是物理地址!
- 在Linux地址下,这种地址叫做虚拟地址,其实他也是一种结构体叫做mm_struct。其实我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。
在学习C语言时,老师给我们画过这样的内存空间布局图:
试想一个地址空间就占4G,如果每个进程都有一个他自己的地址空间的话那就太消耗内存了。其实我们看到的这个地址空间是虚拟的,目的是为了让进程以为自己拥有了一块连续的完整的空间,实际这些连续的虚拟的地址都是通过页表映射到真实物理空间的,也就是说每个进程都有属于自己的一套虚拟地址空间,但其实他们通过各自的页表共同映射到同一块真实的物理空间。
进程地址空间本质上是内存中的一种内核数据结构,叫做mm_struct,前面的mm是memory简写。源代码里可以看到,它有对栈、对、数据的、代码段等各个区域进行划分:通过记录它们的起始位置下标和终止位置下标。
前面说过,task_sturct是进程存在的唯一表示,作为一个管理进程的数据结构,task_struct里存有指向该进程虚拟地址空间的指针:
虚拟内存通过页表映射到真实的物理内存,当然页表也是一种数据结构:
2. 为什么要使用虚拟地址空间
①统一标准
每个进程都认为自己看到的是相同的空间范围,即统一了各个区段的名称、顺序。对于开发者:虚拟地址展示给我们连续的地址,有利于我们学习、开发和维护。
②保护内存
如果大家都可以直接在同一块内存上修改,这样操作不安全,缺乏对内存的访问控制。而通过虚拟地址空间和页表的映射,我们只能间接操作物理内存,一旦间接了,操作系统就可以插一把手(从虚拟到物理的转化由操作系统来完成),这样可以更好的保护内存。
- 页表中有标志物理内存上数据的读写权限。若发现你要修改只读数据,那么就报内存访问错误。
- 从此以后,不会有任何系统级别的越界问题存在了,系统级别的意思是不会再错误的访问物理内存了。比如你去访问野指针,这个时候操作系统会去检查页表的映射关系,发现野指针的地址对应到的物理内存并没有被申请,这时操作系统会终止你的进程。
- 当对空指针访问时,即访问0号虚拟地址,程序发生段错误。虚拟内存的0号位置实际上是有空间的,不过不能读写,这是虚拟地址空间规定。
③进程独立
页表可以映射物理内存,每一个进程都只能访问自己虚拟地址映射的物理内存。这样多进程运行,独享各种资源,在运行期间不会相互干扰,每个进程都认为自己在独占内存。
3. 虚拟地址空间的使用
子进程的创建
fork后子进程拥有了自己的pcb、虚拟地址空间和它自己的页表。但是起初父子进程共用代码(因为代码不会被修改)和相关物理内存数据,为了节省空间所以共用,不单独在为子进程另外开辟一样的物理内存数据和代码。对于物理空间,只有当子进程或父进程单独对代码里的对象进行写操作时,才会在物理内存上单独开辟另外一块空间,并更改子(或父)进程页表的对应关系。
接下来就可以解释前面的现象了:
子进程创建后,复刻和父进程一样的虚拟地址空间。因为父子都没有修改代码里对象的值,为了节省物理内存,各自的页表映射到同一块物理空间。
接下来我们在父进程里修改g_val = 200,这时操作系统要在物理内存上重新开辟一块空间存父进程修改后的g_val的值,并更改父进程页表的映射关系。但是其它没修改的数据依然共享。这样做可以极致地节省内存空间,即能一起用就一起用,其中一个要进行写操作时就重新给这个要写的变量另外开辟一块空间,这也叫作写时拷贝,顾名思义写的时候才拷贝。
子进程从fork()之后运行
我们是在父进程运行的过程当中创建的子进程,那么子进程从哪里开始运行呢?
要注意的是printf函数是行缓冲函数,满足下面条件之一才会将缓冲区内容刷到对应的文件(一般是stdout即显示器)中。
- 缓冲区被填满
- 写入的字符中有’\n’或’\r’
- 调用fflush手动刷新
- 程序结束
4. 从结构上理解进程
进程就是代码 + 真实物理空间上所存储的的数据 + 一堆数据结构的集合,这些数据结构包括:PCB(进程控制块)、mm_struct(进程的虚拟地址空间)、页表。
四. 环境变量
1. 什么是环境变量
环境变量一般是指操作系统中指定操作系统运行环境的一些参数。它相当于一个指针,想要查看变量的值,需要加上“$”,类似于访问指针对象所指向内容的解引用操作。
环境变量的查看方式有两种
1. env (查看所有的环境变量以及他们的值) 2. echo $环境变量名 (查看特定环境变量的值)
2. 环境变量的作用
用来存储一些信息,这些信息可以被系统访问,也可以被我们的应用程序访问。
3. 环境变量的组织方式
每个程序都有一张环境表,环境表是一个字符串指针数组,每个指针指向一个以‘\0’结尾的环境字符串。
main函数的第三个参数就是环境表。
运行上图的程序我们可以打印所有的环境变量和他们对应的值
补充:关于main函数的前两个参数
执行可执行程序后main函数命令行参数的第一个参数是我们执行该程序的命令,即argv[0] = 该程序的执行命令。
如果我们在输入执行程序的命令(./text)时加上选项,这些选项会按照先后顺序加入到命令行参数的指针数组里。
我们可以在代码里读取这些命令行参数,根据他们的值定义自己想要的操作。
4. 常见的环境变量介绍
4.1 PATH
指定命令的默认搜索路径。
我们平时用的命令如:ls、pwd等都是操作系统在PATH环境变量里的这些路径下寻找并执行的。
这也就解释了为什么我们自己生成的可执行程序(也算是命令)在执行时必须加上路径(比如是./text,即执行当前目录下的text),因为PATH下的路径里面没有存放我们自己写的可执行程序;而其他系统命令只要输入它们的名称就可以运行,是因为PATH下的路径里有存放着这些命令。
我们也可以把自己的程序放到PATH下的路径里,这样不论在哪我们直接输入自己的可执行程序名称就可以运行。
举个例子下面是我们自己写的可执行程序:
方法一:永久的
把我们的可执行程序拷贝到PATH路径里。比如我们把我们的可执行程序text拷贝到/use/bin里。
添加完成后可以直接运行text,不用在说吗路径。
不过不推荐这样使用,因为他会污染linux自带的命名池。删除的话就是用绝对路径把这个可执行程序删除就行。
方法二:临时的
这里的临时的意思是,退出登录后即失效,下一次登录后就不存在了。方法是把这个可执行程序所在路径的绝对路径加到PATH环境变量里,这样你只要输入程序的名称,系统就会到PATH里找到你添加的路径,并在这个路径下去找你的可执行程序。
//中间以冒号隔开,没有空格 PATH=$PATH:<PATH1>:<PATH2>:<PATH3>:------:<PATHN>
还是以我们的text程序为例
我们添加的路径在退出登录后会自动删除。其实每一次登陆操作系统都会重新帮用户配置环境变量,默认的都是操作系统给我们安排好的。所以自己的也需要重新配置。
4.2 HOME
指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
从结果上看HOME的值等价于执行指令执行 cd ~ + pwd的结果。
cd ~这里的波浪号对应HOME的值。
4.3 SHELL
该变量的值为用户当前使用的解析器 ,就是当前Shell,在linux中它的值通常是/bin/bash。