现代操作系统的实现是一个高度抽象的、复杂的、伟大的工程。本文主要是简单介绍操作系统里面的最重要的三个抽象概念:进程,虚拟地址空间,文件,以及他们各自涉及到的相关要点。
进程
进程是对cpu的抽象,cpu是执行一条条的指令,进程被抽象为程序的执行流。
跟进程相似的一个概念是线程,其中线程可以再分为2类,一类是用户级线程,也就是posix线程(c语言里面的pthread类函数);还有一类是内核级线程(轻量级进程)。前者只是在用户空间运行,不会被进程调度器所管理,所以在多SMP的环境下,它体现不出良好的性能。后者则能够被内核中的进程调度器所管理,但是由于需要上下文的切换,即从用户空间和内核之间切换,这需要一定的开销,但是比纯进程的上下文切换开销要小。
所以可以这么总结,上下文切换开销:进程>轻量级进程>线程
SMP环境下的优势:进程>轻量级进程>线程
再谈谈进程和线程(posix线程,轻量级进程介于2者之间,不多说了)的联系和区别。
联系:
首先,不管是进程还是线程,它们都是对cpu的抽象,都是程序执行流。其次,线程必须依赖于其所在的进程才能存在,没有进程,何谈线程。默认情况下,可以认为一个进程里面只有一个线程(主线程),可以通过调用c语言函数pthread_create()产生一个线程,相对于初始的主线程,这个线程叫做对等线程。最后2者都有三种状态,运行、就绪、等待。就绪状态是等待cpu到达,等待状态是进程阻塞在某种状态,比如IO操作的完成
区别:
每个进程有自己的地址空间(即下文将会谈到的虚拟地址空间),一般情况,虚拟地址空间的范围(32位机器)是0到4G,记住,这是虚拟的空间,是操作系统对内存的抽象,实际上它是通过页表映射到物理内存的,比较一般的进程占用的物理内存大约就是20M左右。线程是依赖于其所在进程的,也就是各个线程共享进程的地址空间,能够看到进程当中的各个变量,文件描述符等。当然线程也有自己独有的地址空间,即线程栈。
由于进程拥有独立的地址空间,所以进程之间通信变得相对麻烦,一般可以通过信号,队列,共享内存,socket进行通信。线程之间也可以通过这些机制进行通信,但是由于各个线程共享所在进程的地址空间,所以通信变得比较容易。
最后简单说说进程之间的关系。系统启动的时候有个进程id为1的init进程,这个进程是整个系统中最主要的,很多进程都是由它fork出来的。当父进程死掉时,一般是由init进程对其子进程进行回收的,避免子进程变为僵尸进程(无用进程,但是占用资源)。很多时候父进程需要显式地等待子进程死亡,然后为他收尸。另外,一类的进程形成进程组,进程组有个进程组id。
虚拟地址空间
虚拟地址空间是对存储器的抽象,对于程序员或者进程来说,进程的寻址范围(32位机器)是0到4G。这4G的空间被分为栈、堆、BSS(未初始化的数据段)、已初始化的数据段、程序正文段。当程序从磁盘载入时,程序正文段、已初始化的数据段被写入相应的数据,BSS中的变量被初始化为0。堆、栈在最初的时候是空的。
虚拟地址空间只是操作系统提供给进程的一个友好的存储器模型,实际上,进程的一切行为都是发生在物理存储器上的。那么就需要一种机制将虚拟地址空间映射到物理内存,这就是页表。将虚拟地址空间和物理存储器划分为多个页面(二者之间的页面大小一般是相同的,典型地为4k),这样每个虚拟页就可以根据页表表项映射到对应的物理页。
程序被执行时,cpu会将pc寄存器中的地址送到内存总线,比如:mov ax 0x12345678,0x12345678这个地址是虚拟地址。很显然,只靠页表是不能够找到对应的物理地址的,因为页表粒度太粗了,所以引入了一个MMU(内存管理单元),它根据页号和页面偏移将物理地址计算出来。
页表是存放在内核中,而且是每个进程都有对应的页表。这样的话,一方面是每次物理地址计算,都多了一次内存访问,内存访问速度相对于cpu来说是小巫见大巫。又根据程序一般具有局部性,就是说每个程序反复用到的页面只有那么几个。这种情况在计算机领域是很常见的,没错,就是使用更nb的设备把它缓存起来,这就是TLB,它的作用是根据虚拟页号快速地找到相应的物理页号。然后再使用MMU计算出物理地址。另一方面的问题是,4G的虚拟地址空间,4k的页面大小需要100万个页表表项。即使每个表项1B(其实肯定不止啦),都要1M的空间。这个问题可以通过多级页表来解决。
另外,如果某个虚拟地址不能在当前的页表中找到相应的物理页号,这种情况叫做缺页中断。这会cpu陷入内核,使用某种算法将某个物理页面淘汰出去。淘汰是这样进行的:如果这个页面被进程修改过了,那么将其写入磁盘,否则只是简单地丢弃。然后将相应的页面从磁盘中载入到物理页面中,接着修改页表表项
虚拟内存的概念是经过高度抽象化的,早期的操作系统是没有虚拟内存的概念,所以将cpu送出的地址(物理地址)直接送往存储器总线,这导致多道程序设计相当麻烦,历史上曾经使用基址寄存器和界限寄存器进行解决,但效果不好。另外针对内存超载,曾经使用过swapping技术进行解决,即将进程换出到磁盘。也使用过覆盖的方式,将程序划分为一个个段,相当之麻烦。自从有了虚拟内存,我们生活在幸福的时代。但是不应该忘记历史,一是不重复历史,二是某些技术可能在嵌入式等低端设备上变得相当可行。
文件
文件是对磁盘的抽象。磁盘(专业一点叫磁盘驱动器)是由一个个盘片组成的,柱面,磁道,扇区的概念不说了,现代磁盘的构造相对比较复杂。但是基本上还是由盘片,柱面,扇区这3个元素来决定的寻址的。盘面的上方有磁盘臂在移动,用于磁盘寻道。SATA接口的磁盘读取数据的速度大约100MB/s,但这仅仅是数据传输速度,还要考虑寻道和等待时间,这2个是耗时的操作。
磁盘中有个叫MBR的很小的区域,它是用来存放分区信息和引导程序的,因为MBR太小了,所以分区的数量是有限制的,linux系统中最多4个主分区。MBR会确定活动分区,读入分区的第一个块——引导块,引导块中的程序将会装入该分区中的操作系统。引导块之后是超级块,空闲空间,i节点,根目录,文件和目录。每一分区对应一个文件系统,所以i节点的概念是对于一个分区来说的,也就是说一个系统中可以由重复的i节点。所有的文件系统之上又有一个虚拟文件系统,对上层提高统一的接口。
先解释2个重要概念:
i节点是一个数据结构,一般情况下,一个i节点对应一个文件,它记录了文件的信息和文件的磁盘地址,不包括文件名。
目录项,其实linux中,目录也是被当做文件的,那么目录中的内容是什么呢?就是目录项,目录项是由文件名和i节点组成的。硬连结是通过增加目录项来实现的,删除一个连结文件的时候,就把对应的目录项给删了,但不会删除i节点,直到没有目录项指向i节点时,才会删除i节点。
刚刚说过,文件是对磁盘的抽象,所以当我们创建一个文件时,然后往其中写入一些信息,然后保存,映射到磁盘上就是一些磁盘扇区被写入相应的信息(包括i节点信息,目录项信息,数据信息),这中间涉及到比较多的细节。接下来根据本人的理解,简单介绍。
当我们在进程中打开一个文件的时候,是怎样定位到相应的磁盘的磁道上?一般情况下,文件系统的根目录i节点都是随系统启动而保存在内存中的。当要查找/usr/jonda/my.txt时,系统可以容易地知道根目录/的i节点找到对应的磁盘地址,然后找到目录项usr,然后找到usr的i节点,然后找到usr的磁盘块,然后找到usr的目录项jonda,然后找到jonda的i节点,然后找到jonda的磁盘块,然后找到jonda的目录项my.txt,然后找到my.txt的i节点,然后找到my.txt的数据。
文件的内核数据结构保存在进程表项中,其中包括fd,文件表,i节点表,fd指向文件表表项,文件表表项有一个指针指向i节点表表项。文件表中最主要的是文件的偏移量(fork进程的时候,父子进程共享文件表)。i节点里的信息上面已经说过(磁盘里的i节点只是记录信息,只有放到内存中,才有数据结构可言,才能被进程直接访问)。
打开之后便是读写文件,现在的计算机都是使用DMA技术进行文件读写的。DMA装置是一个芯片,它代替了以前使用cpu直接进行磁盘的读写文件。下面讲述一下文件的读取操作,它是cpu,DMA控制器(DMA控制器有字节计数寄存器,内存地址寄存器,控制寄存器),磁盘控制器,内存,总线,磁盘之间相互协作进行的。首先cpu根据内核数据结构对DMA控制器发送指令,让它读取某文件。指令信息包括端口信息,磁盘块信息,文件偏移量,存储器地址。然后将这些信息传给磁盘控制器,磁盘控制器将信息整合后指向到某个扇区,然后将数据读取到磁盘控制器缓冲区,然后再根据总线上(由DMA控制器写的)的地址信息,将缓冲区中的数据拷贝到内存当中,当DMA控制器的字节计数器为0时,向cpu发出中断操作。然后cpu继续执行程序中接下来的指令。
文中很多东西可能没有说清楚,本来应该用图来说明会很清晰的,但是现在不想弄了,先放着,以后有心情的时候来改。写到这里我才想起来,今天是约了人去爬山的。。。悲剧了。
本来每个学期我都会将学习和生活记录下来的,但是这学期实在不想了,平淡的人生无需太多的解释说明,另外那qq空间早就应该废了。昨晚我转辗反侧,不写生活的事情了,怎么也得写一篇技术相关的文章来纪念一下这4年的大学生活,所以写了此文,其实只是总结一些东西而已,可能还总结得不好。
还有5天,小弟我将走出华南某理工学校,如果某大神不幸看到此文,请嘴下留情,不吝赐教,我将不胜感激。