前言
本人准研三,面临秋招压力,遂总结部分计算基础知识,以备不时之需,有些是从大佬博文摘抄的,都附有相应博文链接,如有遗漏,烦请联系本人更改,如有侵权,我会修改矫正。最后祝各位OFFER多多,进大厂!
以下是操作系统常见面试题
**一、操作系统的线程和进程的区别,线程的几种状态
1.进程的概述
① 进程和线程
进程(Process)是资源分配的基本单位,线程(Thread)是CPU调度的基本单位。
- 线程将进程的资源分和CPU调度分离开来。 以前进程既是资源分配又是CPU调度的基本单位,后来为了更好的利用高性能的CPU,将资源分配和CPU调度分开。因此,出现了线程。
- 进程和线程的联系: 一个线程只能属于一个进程,一个进程可以拥有多个线程。线程之间共享进程资源。
- 进程和线程的实例: 打开一个QQ,向朋友A发文字消息是一个线程,向朋友B发语音是一个线程,查看QQ空间是一个线程。QQ软件的运行是一个进程,软件中支持不同的操作,需要由线程去完成这些不同的任务。
② 进程和线程的区别
广义上的区别:
- 资源: 进程是资源分配的基本单位,线程不拥有资源,但可以共享进程资源。
- 调度: 线程是CPU调度的基本单位,同一进程中的线程切换,不会引起进 程切换;不同进程中的线程切换,会引起进程切换。
- 系统开销: 进程的创建和销毁时,系统都要单独为它分配和回收资源,开销远大于线程的创建和销毁;进程的上下文切换需要保存更多的信息,线程(同一进程中)的上下文切换系统开销更小。
- 通信方式: 进程拥有各自独立的地址空间,进程间的通信需要依靠IPC;线程共享进程资源,线程间可以通过访问共享数据进行通信。
进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。
IPC的方式通常有管道(包括无名管道和命名管道)、消息队列、信号量、共享存储、Socket、Streams等。其中 Socket和Streams支持不同主机上的两个进程IPC。
Linux系统中进程和线程的区别:
- 在Linux系统中,内核调度的单元是struct task_struct,每个进程对应一个task_struct。
- 2.6以前的内核中没有线程的概念,内核将线程视为轻量级进程(LWP),并为每一个线程分配一个task_struct。
- 2.6以后的内核中出现了线程组的概念,同一个进程中的线程放入一个线程组中;内核仍然视线程为轻量级进程,每个task_struct对应一个进程或者线程组中的一个线程。
- 如果线程完全在内核态中实现(内核线程,KLT),内核调度的单元是线程。此时,进程与线程的区别非常微妙。
- 如果线程完全在用户态实现(用户线程,ULT),内核调度的单元是进程,内核对用户线程一无所知。内核只负责分配CPU给进程,进程得到CPU会后再分配给内部的线程
③ 进程的组成
进程由程序代码、数据、进程控制块(Process Control Block, PCB)三个部分组成,即进程映像(Process Image)。
关于PCB :
- PCB描述进程的基本信息和运行状态,所谓的创建撤销进程,都是指对 PCB 的操作。
- PCB是一个数据结构,它常驻内存,其中的进程ID(PID)唯一标识一个进程。
- 标识符:自身ID(PID)、父进程ID(PPID)、用户ID(UID)
- 处理机状态:主要由处理机的各种寄存器中的内容组成,包括通用寄存器,程序计数器(PC),存放下一条要访问的指令地址;程序状态字(PSW),包含条件码、执行方式、中断屏蔽标志等状态信息;用户栈指针,存放过程和系统调用的参数及调用地址。
- 进程调度信息 :包括进程状态,指明进程的当前状态;进程优先级;进程调度所需的其它信息,如进程已等待CPU的时间总和、进程已执行的时间总和等;事件,由执行状态转变为阻塞状态所等待发生的事件,即阻塞原因。
- 进程控制信息:包括程序和数据的地址,是指进程的程序和数据所在的内存或外存地址;进程同步和通信机制,指实现进程同步和进程通信时必需的机制,如消息队列指针、信号量等;资源清单,进程所需的全部资源及已经分配到该进程的资源的清单;链接指针。
PCB的组织方式: 链接和索引
- 链接:运行态、就绪态、阻塞态分别维护一个链表,每种状态的PCB通过链表连接。其中就绪态的链表只有一个PCB,因为同一时刻只有一个进程处于就绪态。
- 索引: 运行态、就绪态、阻塞分别维护一个PCB表,该表中的每个entry指向一个PCB。
每个进程都有自己的PID,进程依靠进程树进行组织。其中根进程的PID = 1,父进程的撤销会撤销全部的子进程。
2. 进程的生命周期
① 进程的状态切换
关于几种状态:
- 就绪态: 刚创建的进程完成PCB的初始化后,被加到就绪队列中,等待CPU的调度。
- 运行态: 处于就绪态的进程获得CPU时间后,变为运行态。运行态的进程使用完CPU时间后,变为就绪带态。只有就绪态和运行态可以相互转换。
- 阻塞态: 进程在运行的过程中由于缺少必须的资源,变为阻塞态。注意: 资源不包括CPU时间,一般指I/O操作等。
- 挂起态: 五状态模型中,缺少对I/O操作的描述,于是增加交换(Swapping) 这一概念。处于阻塞态的进程由于长时间未等待到资源,通过换出将其从内存转到外存变为挂起态,这样可以减少内存占用;当资源可用时,处于挂起态的进程通过换入从外存转到内存变为就绪态。
②进程的创建
Linux系统中的进程创建:
- 父进程调用fork()、vfork()、clone()中的任一方法创建新进程,这些方法对应的系统调用入口分别为sys_fork()、sys_vfork()、sys_clone()。这些系统调用内部都会调用do_fork()函数完成进程创建,只是携带的参数不同。
- do_fork()函数会调用copy_process()函数,执行进程创建的实际工作。
- copy_process()函数调用dup_task_struct()函数复制当前的task_struct。
- copy_process()函数调用shed_fork()函数初始化进程数据结构,将进程状态设位置为 TASK_RUNNING(统计结果是: 1,只有该状态的进程才可能在CPU上运行)。
- copy_process()函数复制父进程的所有信息。
- copy_process()函数调用copy_thread()函数初始化子进程的内核栈,copy_thread()函数会将子进程的返回值设为0:childregs->ax = 0,返回地址设为ret_from_fork: p->thread.ip = (unsigned long) ret_from_fork;,因此子进程是从ret_from_fork开始执行的。
- copy_process()函数为子进程分配并设置PID。至此,子进程准备就绪,等待被调度。
关于进程创建的一些说明:
- 新创建的子进程和父进程拥有相同的代码段,代码段的内容是从fork()函数之后开始的。即从ret_from_fork开始的。
- 若创建后的子进程想要执行不同的程序,可以调用exec()函数族覆盖进程映像,但是子进程的PID不会发生改变。
- fork()函数调用失败返回-1,调用成功在父进程中返回子进程的PID,在子进程中返回0。
- 子进程和父进程拥有相同的代码段,但是由于fork()函数的返回值不同,导致子进程和父进程会打印不同的信息。
- 子进程可以通过getpid()获取自己的PID,通过getppid()获取父进程的PID;父进程只能通过getpid()获取自身的PID,要想获取子进程的PID,必须使用变量保存fork()函数的返回值。
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
pid_t pid;
int count=0;
pid = fork();
if(pid < 0)
{
perror("fork failed");
exit(1);
}
if(pid == 0)
{
printf("This is the child process. My PID is: %d. My PPID is: %d.\n", getpid(), getppid());
count++;
}
else
{
printf("This is the parent process. My PID is %d, child process PID is %d.\n", getpid(), pid);
count++;
}
printf("统计结果是: %d\n", count);
return 0;
}
This is the child process. My PID is: 12778. My PPID is: 12777
统计结果是: 1
This is the parent process. My PID is 12777, child process PID is 12778.
统计结果是: 1
fork()函数:
- 传统的fork()函数,子进程复制父进程的虚拟空间,共享父进程的代码段,数据段、栈、堆都会分配新的物理内存。除了PID和PCB中的某些参数不同之外,子进程是父进程的精确复制。
- Linux下的fork()函数,采用写时复制(Copy on Write,CoW):内核一开始只为子进程复制父进程的虚拟空间,并不分配物理内存,而是共享父进程的物理内存。当子进程或者父进程想要修改物理内存时,再为子进程分配单独的物理内存。
- fork() 函数执行后,子进程和父进程的运行是无关的,二者执行的先后顺序不固定。
vfork()函数:
- 比fork()函数更加激进,子进程直接共享父进程的虚拟空间。
- 如果子进程修改了父进程地址空间,父进程被唤醒时会发现发现自己的数据被改了,完整性丢失,所以这是不安全的。
- vfork()函数保证子进程先运行,只有当子进程退出(调用_exit()函数)或者执行其他程序(调用exec()函数),被阻塞的父进程才可能被调度运行。
如果在调用_exit()函数和exec()函数之前,子进程依赖父进程的进一步动作,则会导致死锁。 - vfork()函数一般紧接着调用exec()函数,这时会为子进程分配新的物理内存,省掉了fork()函数中笨重的数据复制过程。 可以说,vfork()函数就是为了exec()函数而生。
- clone()函数: 留给轻量级进程,提供选项,自己选择复制哪些信息。
③ 进程的终止
进程终止的原因:
- 正常结束: exit、halt、logoff。
- 异常结束: 无可用内存、越界、保护错误、算术错误、I/O失败、无效指令、特权指令等
- 外界干预: kill进程、父进程终止(父进程终止,其子进程可能被系统终止)、父进程请求(父进程可以请求终止子进程)。
进程终止的过程:
- 检索PCB,检查进程状态
- 将进程从运行态转换成终止态
- 检查是否有子进程需要终止
- 将获取到的资源归还给父进程或系统
- 将该进程的PCB从PCB队列中移出
④ 进程的上下文切换
上下文切换: 是指CPU从一个进程(或线程)切换到另一个进程(或线程),涉及到控制权的转移。
- 保存当前进程的上下文
- 选择某个进程,恢复其上一次换出时保存的上下文
- 当前进程将控制权转移给新恢复的进程
task_struct是Linux中的PCB,进程上下切换时需要保存task_struct中的内容。包含以下内容:
- 标识符: 唯一标识一个进程
- 状态: 记录进程状态,如阻塞、就绪、运行等状态
- 优先级: 记录进程的优先级,可以根据优先级对进程执行调度
- 程序计数器PC: 指向进程中下一条将要执行的指令
- 内存指针: 程序代码和进程相关诗句的指针
- 上下文数据: 进程运行时,CPU中寄存器的内容
- I/O状态信息: 显示的I/O请求,分配给进程的I/O设备、被进程使用的文件列表等
- 记账信息: 处理器的时间总和、记账号等
线程的切换: 线程共享进程的资源,进行线程切换时,只需要保存线程的私有数据:栈、程序计数器、寄存器
进程切换的开销比线程切换的开销大:
- 显式原因: 进程的上下文切换需要保存更多的信息,比线程的上下文切换开销更大。
- 隐式原因: 进程切换时使用不同资源的task_struct之间的调度,而线程切换是使用相同资源的task_struct之间的调度。进程切换使得原有的缓存不适用,会触发缺页中断;而线程切换时,由于使用相同资源,缓存的命中率更高很多。
- Linux中使用TLB(Translation Lookaside Buffer,转换检测缓冲)来管理虚拟内存到物理内存的映射关系。虚拟内存更新后,TLB也需要刷新,内存的访问会随之变慢。
该部分节选自CSDN大佬文章
**二、进程和线程的通信方式,实际案例
进程和线程的区别:
对于进程来说,子进程是父进程的复制品,从父进程那里获得父进程的数据空间,堆和栈的复制品。
而线程,相对于进程而言,是一个更加接近于执行体的概念,可以和同进程的其他线程之间直接共享数据,而且拥有自己的栈空间,拥有独立序列。
共同点: 它们都能提高程序的并发度,提高程序运行效率和响应时间。线程和进程在使用上各有优缺点。 线程执行开销比较小,但不利于资源的管理和保护,而进程相反。同时,线程适合在SMP机器上运行,而进程可以跨机器迁移。
他们之间根本区别在于 多进程中每个进程有自己的地址空间,线程则共享地址空间。所有其他区别都是因为这个区别产生的。比如说:
- 速度。线程产生的速度快,通讯快,切换快,因为他们处于同一地址空间。
- 线程的资源利用率好。
- 线程使用公共变量或者内存的时候需要同步机制,但进程不用。
而他们通信方式的差异也仍然是由于这个根本原因造成的。
通信方式之间的差异
因为那个根本原因,实际上只有进程间需要通信,同一进程的线程共享地址空间,没有通信的必要,但要做好同步/互斥,保护共享的全局变量。
而进程间通信无论是信号,管道pipe还是共享内存都是由操作系统保证的,是系统调用.
一、进程间的通信方式
- 管道( pipe ):
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。 - 有名管道 (namedpipe) :
有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。 - 信号量(semophore ) :
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。 - 消息队列( messagequeue ) :
消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。 - 信号 (sinal ) :
信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。 - 共享内存(shared memory ) :
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。 - 套接字(socket ) :
套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。
二、线程间的通信方式 - 锁机制:包括互斥锁、条件变量、读写锁
互斥锁提供了以排他方式防止数据结构被并发修改的方法。读写锁允许多个线程同时读共享数据,而对写操作是互斥的。条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。 - 信号量机制(Semaphore):
包括无名线程信号量和命名线程信号量 - 信号机制(Signal):类似进程间的信号处理
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
该部分选自CSDN博客
实际应用场景
1. 多进程应用场景
- nginx主流的工作模式是多进程模式(也支持多线程模型)
- 几乎所有的web server服务器服务都有多进程的,至少有一个守护进程配合一个worker进程,例如apached,httpd等等以d结尾的进程包括init.d本身就是0级总进程,所有你认知的进程都是它的子进程;
- chrome浏览器也是多进程方式。 (原因:①可能存在一些网页不符合编程规范,容易崩溃,采用多进程一个网页崩溃不会影响其他网页;而采用多线程会。②网页之间互相隔离,保证安全,不必担心某个网页中的恶意代码会取得存放在其他网页中的敏感信息。)
- redis也可以归类到“多进程单线程”模型(平时工作是单个进程,涉及到耗时操作如持久化或aof重写时会用到多个进程)
2. 多线程应用场景 - 线程间有数据共享,并且数据是需要修改的(不同任务间需要大量共享数据或频繁通信时)。
- 提供非均质的服务(有优先级任务处理)事件响应有优先级。
- 单任务并行计算,在非CPU Bound的场景下提高响应速度,降低时延。
- 与人有IO交互的应用,良好的用户体验(键盘鼠标的输入,立刻响应)
- 案例:
桌面软件,响应用户输入的是一个线程,后台程序处理是另外的线程;
memcached
该部分选自CSDN博客
**三、操作系统的孤儿进程和僵尸进程的区别
1.产生的原因
1)一般进程
正常情况下:子进程由父进程创建,子进程再创建新的进程。父子进程是一个异步过程,父进程永远无法预测子进程的结束,所以,当子进程结束后,它的父进程会调用wait()或waitpid()取得子进程的终止状态,回收掉子进程的资源。
2)孤儿进程
孤儿进程:父进程结束了,而它的一个或多个子进程还在运行,那么这些子进程就成为孤儿进程(father died)。子进程的资源由init进程(进程号PID = 1)回收。
3)僵尸进程
僵尸进程:子进程退出了,但是父进程没有用wait或waitpid去获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称为僵死进程。
2.问题危害
注意:unix提供了一种机制保证父进程知道子进程结束时的状态信息。
这种机制是:在每个进程退出的时候,内核会释放所有的资源,包括打开的文件,占用的内存等。但是仍保留一部分信息(进程号PID,退出状态,运行时间等)。直到父进程通过wait或waitpid来取时才释放。
但是这样就会产生问题:如果父进程不调用wait或waitpid的话,那么保留的信息就不会被释放,其进程号就会被一直占用,但是系统所能使用的进程号是有限的,如果大量产生僵死进程,将因没有可用的进程号而导致系统无法产生新的进程,这就是僵尸进程的危害
孤儿进程是没有父进程的进程,它由init进程循环的wait()回收资源,init进程充当父进程。因此孤儿进程并没有什么危害。
补充:任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程的数据结构,等待父进程去处理。如果父进程在子进程exit()之后,没有及时处理,出现僵尸进程,并可以用ps命令去查看,它的状态是“Z”。
3.解决方案
1)kill杀死元凶父进程(一般不用)
严格的说,僵尸进程并不是问题的根源,罪魁祸首是产生大量僵死进程的父进程。因此,我们可以直接除掉元凶,通过kill发送SIGTERM或者SIGKILL信号。元凶死后,僵尸进程进程变成孤儿进程,由init充当父进程,并回收资源。
或者运行:kill -9 父进程的pid值、
2)父进程用wait或waitpid去回收资源(方案不好)
父进程通过wait或waitpid等函数去等待子进程结束,但是不好,会导致父进程一直等待被挂起,相当于一个进程在干活,没有起到多进程的作用。
3)通过信号机制,在处理函数中调用wait,回收资源
通过信号机制,子进程退出时向父进程发送SIGCHLD信号,父进程调用signal(SIGCHLD,sig_child)去处理SIGCHLD信号,在信号处理函数sig_child()中调用wait进行处理僵尸进程。什么时候得到子进程信号,什么时候进行信号处理,父进程可以继续干其他活,不用去阻塞等待。
ps -a -o pid,ppid,state,cmd
显示:
显示:(状态Z代表僵尸进程)
S PID PPID CMD
S 3213 2529 ./pid1
Z 3214 3213 [pid1]
Z 3215 3213 [pid1]
Z 3219 3213 [pid1]
Z 3220 3213 [pid1]
Z 3221 3213 [pid1]
R 3223 3104 ps -a -o state,pid,ppid,cmd
用第一种方法,解决僵尸进程,杀死其父进程
运行:kill -9 3213
注意:僵尸进程无法用kill直接杀死,如kill -9 3214,再用上面命令去查看进程状态,发现3214进程还在。
该部分选自CSDN博客
**四、操作系统的虚拟内存,分段分页,缺页调度的流程
1. 早期的内存分配机制
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?下面通过实例来说明当时的内存分配方法:
某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。
图一 早期的内存分配方法
问题1:进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有bug的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。
问题2:内存使用效率低。在A和B都运行的情况下,如果用户又运行了程序C,而程序C需要20M大小的内存才能运行,而此时系统只剩下8M的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序C使用,然后再将程序C的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。
问题3:程序运行的地址不确定。当内存中的剩余空间可以满足程序C的要求后,操作系统会在剩余空间中随机分配一段连续的20M大小的空间给程序C使用,因为是随机分配的,所以程序运行的地址是不确定的。
2. 分段
为了解决上述问题,人们想到了一种变通的方法,就是增加一个中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。当创建一个进程时,操作系统会为该进程分配一个4GB大小的虚拟进程地址空间。之所以是4GB,是因为在32位的操作系统中,一个指针长度是4字节,而4字节指针的寻址能力是从0x00000000~ 0xFFFFFFFF,最大值0xFFFFFFFF表示的即为4GB大小的容量。与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。如果你的计算机上安装了512M大小的内存,那么这个物理地址空间表示的范围是0x00000000~0x1FFFFFFF。当操作系统做虚拟地址到物理地址映射时,只能映射到这一范围,操作系统也只会映射到这一范围。当进程创建时,每个进程都会有一个自己的4GB虚拟地址空间。要注意的是这个4GB的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。那是不是这4GB的虚拟地址空间应用程序可以随意使用呢?很遗憾,在Windows系统下,这个虚拟地址空间被分成了4部分:NULL指针区、用户区、64KB禁入区、内核区。应用程序能使用的只是用户区而已,大约2GB左右(最大可以调整到3GB)。内核区为2GB,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。
人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段(Sagmentation)的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个10M大小的空间映射到物理地址空间中某个10M大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的
物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。还是以实例说明,假设有两个进程A和B,进程A所需内存大小为10M,其虚拟地址空间分布在0x00000000到0x00A00000,进程B所需内存为100M,其虚拟地址空间分布为0x00000000到0x06400000。那么按照分段的映射方法,进程A在物理内存上映射区域为0x00100000到0x00B00000,,进程B在物理内存上映射区域为0x00C00000到0x07000000。于是进程A和进程B分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程A的地址空间就是分布在0x00000000到0x00A00000,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程A究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了。 图二显示的是分段方式的内存映射方法。
图二 分段方式的内存映射方法
这种分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序,这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页(Paging)。
3. 分页
分页的基本方法是,将地址空间分成许多的页。每页的大小由CPU决定,然后由操作系统选择页的大小。目前Inter系列的CPU支持4KB或4MB的页大小,而PC上目前都选择使用4KB。按这种选择,4GB虚拟地址空间共可以分成1048576个页,512M的物理内存可以分为131072个页。显然虚拟空间的页数要比物理空间的页数多得多。
在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。
下面通过介绍一个可执行文件的装载过程来说明分页机制的实现方法。一个可执行文件(PE文件)其实就是一些编译链接好的数据和指令的集合,它也会被分成很多页,在PE文件执行的过程中,它往内存中装载的单位就是页。当一个PE文件被执行时,操作系统会先为该程序创建一个4GB的进程虚拟地址空间。前面介绍过,虚拟地址空间只是一个中间层而已,它的功能是利用一种映射机制将虚拟地址空间映射到物理地址空间,所以,创建4GB虚拟地址空间其实并不是要真的创建空间,只是要创建那种映射机制所需要的数据结构而已,这种数据结构就是页目和页表。
当创建完虚拟地址空间所需要的数据结构后,进程开始读取PE文件的第一页。在PE文件的第一页包含了PE文件头和段表等信息,进程根据文件头和段表等信息,将PE文件中所有的段一一映射到虚拟地址空间中相应的页(PE文件中的段的长度都是页长的整数倍)。这时PE文件的真正指令和数据还没有被装入内存中,操作系统只是根据PE文件的头部等信息建立了PE文件和