问题引入
在计算机里,内存分为虚拟内存和物理内存。 数据是存放在物理内存中的,而程序中使用的是虚拟内存并通过虚拟内存地址来访问数据和代码的,那么操作系统是如何将虚拟内存地址映射成为实际的物理内存的呢?
基础定义
- 虚拟地址(Virtual Address)是指由程序产生的由段选择符和段内偏移地址两个部分组成的地址。因为这两部分组成的地址并没有直接用来访问物理内存,而是需要通过分段地址变换机制处理或映射后才 对应到物理内存地址上,因此这种地址被称为虚拟地址。虚拟地址空间由 GDT 映射的全局地址空间和 由 LDT 映射的局部地址空间组成。
- 逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分。应用程序员仅需与逻辑地址打交道,而分段和分页机制对他来说是完全透明的,仅由系统编程人员涉及。不过有些资料并不区分逻辑地址和虚拟地址的概念,而是将它们统称为逻辑地址。
- 线性地址(Linear Address)是虚拟地址到物理地址变换之间的中间层,是处理器可寻址的内存空间 (称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386 的线性地址空间容量为 4G。
- 物理地址(Physical Address)是指出现在 CPU 外部地址总线上的寻址物理内存的地址信号,是地址 变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。 如果没有启用分页机制,那么线性地址就直接成为物理地址了。
地址转换分析
程序在执行时,传递给CPU的地址是逻辑地址,它由两部分组成,一部分是段选择符(比如cs和ds等段寄存器的值), 另一部分为有效地址(即偏移量,比如eip寄存器的值)。逻辑地址必须经过映射转换变为线性地址, 线性地址再经过一次映射转为物理地址,才能访问真正的物理内存。转化过程如下:
逻辑地址转换成线性地址
- 逻辑地址是以
"段寄存器:偏移地址"
形式存在的。段寄存器是一个16位的寄存器, 其中第0和1位控制着将要访问段的特权级,第2位说明是在GDT还是LDT寻找地址。 高13位作为一个索引值,总共8192个索引。段的基地址、段限长以及段的保护属性存储在一个称为段描述符(Segment Descriptor)的结构项中。 在逻辑地址到线性地址的转换映射过程中会使用这个段描述符。如下图所示,通过段寄存器里的索引,可以从段描述符表里找到段的基址。 然后用基址加上段内的偏移量,就得到了对应的线性地址。
- 对于 a.out 格式的模块文件来说,由于段类型是预先知道的,因此链接程序对 a.out 格式的模块文件进行存储分配比较容易。例如,对于具有两个输入模块文件和需要连接一个库函数模块的情况,其存储分配情况见图 3-9 所示。每个模块文件都有一个代码段(text)、数据段(data)和一个 bss 段,也许还会有一些 看似外部(全局)符号的公共块。链接程序会收集每个模块文件包括任何库函数模块中的代码段、数据段 和 bss 段的大小。在读入并处理了所有模块之后,任何具有非零值的未解析的外部符号都将作为公共块来 看待,并且把它们分配存储在 bss 段的末尾处。
线性地址转换成物理地址
-
分页机制
内存分页管理机制的基本原理是将 CPU 整个线性内存区域划分成 4096 字节为 1 页的内存页面。程序申请使用内存时,系统就以内存页为单位进行分配。在使用这种内存分页管理方法时,每个执行中的进程(任务)可以使用比实际内存容量大得多的连 续地址空间。为了在使用分页机制的条件下把线性地址映射到容量相对很小的物理内存空间上,80386 使用了页目录表和页表,因为 1 个页表可有 1024 项,因此一个页表最多可以映射 1024 * 4KB = 4MB 内存;又因为一 个页目录表最多有 1024 项,对应 1024 个二级页表,因此一个页目录表最多可以映射 1024 * 4MB = 4GB 容量的内存。即一个页目录表就可以映射整个线性地址空间范围。
如果没有开启分页,那么处理器直接把线性地址映射到物理地址(即线性地址被送到处理器地址总线 上)。如果对线性地址空间进行了分页处理,那么就会使用二级地址转换把线性地址转换成物理地址。
-
转换机制
线性地址可以分为三部分:页目录索引,页表索引,和字节偏移索引。线性地址的位 31-22 共 10 个比特用来确定页目录中的目录项,位 21-12 用来寻址页目录项指定的页 表中的页表项,最后的 12 个比特正好用作页表项指定的一页物理内存中的偏移地址。 如下图所示,通过页目录索引和CR3寄存器指定的页目录基址之和,可以查询到对应的页表基址。再通过页表索引和页表基址之和, 可以得到对应的页框地址,页框地址再加上页内字节偏移,就最终获得了对应的物理地址。
-
分页机制是把线性和物理地址空间都划分成 各个页面,每个页面都是4K,并在这两个空间之间提供了任意映射。
CPU执行指令过程
- 从C源代码经过编译器编译,链接器链接生成可执行文件之后,操作系统会将可执行文件加载入内存,CPU将从程序的第一个指令开始执行。 首先,CPU中的CS寄存器指向了程序被加载内存之后所在代码段的基址,而IP寄存器指向了下一条程序要执行的指令。 CS中的段基址加上IP寄存器中的值,形成一个线性地址,这个线性地址经过转换,形成物理地址,然后通过地址总线, 在对应的内存地址获得对应的一条指令,再把对应的指令通过数据总线传输到CPU的指令缓冲器中, 然后由指令缓冲器传给指令执行控制器,执行对应的指令。
- CPU构造:CPU主要由运算器、控制器、寄存器组和内部总线等构成。
- ALU:运算器是计算机中执行各种算术和逻辑运算操作的部件。运算器由算术逻辑单元(ALU,Arithmetic Logical Unit)、累加器、状态寄存器、通用寄存器组等组成。算术逻辑运算单元(ALU)的基本功能为加、减、乘、除四则运算,与、或、非、异或等逻辑操作,以及移位、求补等操作。计算机运行时,运算器的操作和操作种类由控制器决定。运算器处理的数据来自存储器;处理后的结果数据通常送回存储器,或暂时寄存在运算器中。
- CU:控制器是计算机的指挥中心,负责决定执行程序的顺序,给出执行指令时机器各部件需要的操作控制命令。由程序计数器、指令寄存器、指令译码器、时序产生器和操作控制器组成,它是发布命令的“决策机构”,即完成协调和指挥整个计算机系统的操作。控制器从内存中取出一条指令,并指出下一条指令在内存中位置,对指令进行译码或测试,并产生相应的操作控制信号,以便启动规定的动作,指挥并控制CPU、内存和输入/输出设备之间数据流动的方向。 寄存器组用于在指令执行过后存放操作数和中间数据,由运算器完成指令所规定的运算及操作。
- 寄存器组:计算机体系结构中常用到的寄存器包括以下几类寄存器(以32位X86系统为例):
a) 通用寄存器:EAX,EBX,ECX,EDX
b) 源变址目标变址寄存器:ESI,EDI
c) 栈相关寄存器:SS,ESP,EBP
d) 代码段寄存器,程序指令寄存器:CS,IP
e) 数据段寄存器:DS(常与ESI寄存器结合使用)
f) 附加段寄存器:ES(常与EDI寄存器集合使用)
g) Flag标志寄存器:
ZF 零标志,零标志ZF用来反映运算结果是否为0。如果运算结果为0,则其值为1,否则其值为0;
AF 辅助进位标志,运算过程中第三位有进位值,置AF=1,否则,AF=0;
PF 奇偶标志,当结果操作数中偶数个"1",置PF=1,否则,PF=0;
SF 符号标志,当结果为负时,SF=1;否则,SF=0。溢出时情形例外;
CF 进位标志,最高有效位产生进位值,例如,执行加法指令时,MSB(最高位)有进位,置CF=1;否则,CF=0;
OF 溢出标志,若操作数结果超出了机器能表示的范围,则产生溢出,置OF=1,否则,OF=0。 - CPU的系统总线包括数据总线,地址总线,控制总线。
- 数据总线用于传送数据信息。数据总线是双向总线,即它既可以把CPU的数据传送到存储器或I/O接口等其他部件,也可以将其他部件的数据传送到CPU。
- 地址总线是专门用来传送地址的,由于地址只能从CPU传向外部存储器或I/O端口,所以地址总线总是单向的,这与数据总线不同。地址总线的位数决定了CPU可直接寻址的内存空间大小,比如8位微机的地址总线为16位,则其最大可寻址空间为2^16=64KB,16位微型机的地址总线为20位,其可寻址空间为2^20=1MB。一般来说,若地址总线为n位,则可寻址空间为2^n字节。有的系统中,数据总线和地址总线是复用的,即总线在某些时刻出现的信号表示数据而另一些时刻表示地址,而有的系统则是分开的。
- 控制总线用来传送控制信号。控制信号中,有的是微处理器送往存储器和I/O接口电路的,如读/写,中断响应信号等; 也有是其他部件反馈给CPU的,比如:中断申请、复位、总线请求、设备就绪等。因此,控制总线的传送方向由具体控制信号而定, 一般是双向的,控制总线的位数要根据系统的实际控制需要而定。实际上控制总线的具体情况主要取决于CPU。
内存寻址模型
- 逻辑地址,线性地址,物理地址
- 逻辑地址是编译器生成的,在linux环境下,使用C语言指针时,指针的值就是逻辑地址。对于每个进程而言,他们都有一样的进程地址空间,类似的逻辑地址,甚至很可能相同。逻辑地址由段地址+段内偏移组成。
- 线性地址是由分段机制将逻辑地址转化而来的,如果没有分段机制作用,那么程序的逻辑地址就是线性地址了。
- 物理地址是CPU在地址总线上发出的电平信号,要得到物理地址,必须要将逻辑地址经过分段,分页等机制转化而来。
- x86体系结构下,使用的较多的内存寻址模型主要有三种:
- 实模式扁平模型 real mode flat model
- 实模式分段模型 real mode segment model
- 保护模式扁平模型 protected mode flat model
-
实模式和保护模式相对,实模式运行于20位地址总线,保护模式则启用了32位地址总线,地址使用的是虚拟地址,引入了描述符表;虽然二者都引入了段这样一个概念,但是实模式的段是64KB固定大小,只有16个不同的段,CS,DS等存储的是段的序号。保护模式则引入了GDT和LDT段描述符表的数据结构来定义每个段。
-
扁平模型和分段模型相对,区别在于程序的线性地址是共享一个地址空间还是需要分成多个段,即为多个程序是同时运行在同一个CS,DS的范围内还是每个程序都拥有自己的CS,DS:也就是说前者(flat)指令的逻辑地址要形成线性地址,不需要切换CS,DS;后者的逻辑地址,必须要经过段选择子去查找段描述符,切换CS,DS,才能形成线性地址。
-
实模式分段模型 real mode segment model
在实模式里,20位地址总线,16位的寄存器无法表示,一个基址寄存器+一个段寄存器联合起来则可以表示更大的一个地址空间。于是发明了这种段寄存器左移4位+基址寄存器用以间接寻址。 20根地址线,表示 0x00000 - 0xfffff这个范围的地址(即1M) 而寄存器16位,还有4位怎么办?于是8086CPU将1MB的存储器空间分成许多逻辑段,每个段最大限制为64KB(为了能让16位寄存器寻址,2^20=2^10*2^10=2^10*2^6*2^4==16*64K), 段地址就是逻辑段在主存中的起始位置。为了能用16位寄存器表示段地址,8086规定段地址必须是模16地址,即为xxxx0H形式,省略低4位0,段地址就可以用16位数据表示,它通常被保存在16位的段寄存器中。存单元距离段起始位置的偏移量简称偏移地址,由于限定每段不超过64KB,所以偏移地址也可以用16位数据表示。物理地址:在1M字节的存储器里,每一个存储单元都有一个唯一的20位地址,称为该存储单元的物理地址,把段地址左移4位(因为段地址低4位都是零)再加上偏移地址就形成物理地址。Seg<<4+Offset 对于 8086/8088 运行在实模式的程序,其实就是运行在实模式分段模型中。对于不同的程序,有不同的CS,DS值,每个程序的段起始地址都不同。对于这样的程序而言,偏移地址16位的特性决定了每个段只有64KB大小。 -
实模式扁平模型 real mode flat model
该模式只有在386及更高的处理器中才能出现。80386的实模式,就是指CPU可用的地址线只有20位,能寻址0~1MB的地址空间。注意:80386的实模式并不等同于8086/8088的实模式,后者的实模式其实就是实模式分段模型。扁平模型,意味着我们这里不使用任何的分段寄存器。(尽管也使用了CS,DS,只是不用程序员去显示地为该寄存器赋值,jmp指令时就已经将CS, DS设置好了) -
保护模式扁平模型 protected mode flat model
Linux, Window XP/7采用的内存寻址模型,Linux中,段主要分为4种,即为内核代码段,内核数据段,用户代码段,用户数据段。 对于内核代码段和数据段而言,CS,DS的值是0xC00000000,而用户代码和数据段的CS,DS的值是0x00000000 当CPU运行于32位模式时,不管怎样,寄存器和指令都可以寻址整个线性地址空间,所以根本就不需要再去使用基地址。基址可以设为一个统一的值。
Linux内存的管理和分配
- 对于 Linux 0.11 内核,它默认最多支持 16M 物理内存。 end 标示出内核模块结束的位置。随后是高速缓冲区,它的最高内存地址为 4M 。高速缓冲区被显示内存和 ROM BIOS 分成两段。剩余的内存部分称为主内存区。主内存区就是由
mm/memory.c和page.s
进行分配和管理。若系统中还存在 RAM 虚拟盘时,则主内存区前段还要扣除虚拟盘所占的内存空间。当需要使用主内存区时就需要向memory.c
的内存管理程序申请,所申请的基本单位是内存页。 - Linux 的页目录和页表是在程序
head.s
中设置的。head.s
程序在物理地址 0 处存放了一个页目录表,紧随其后是 4 个页表。这 4 个页表将被用于内核所占内存区域的映射操作。page.s
程序用于实现页异常中断处理过程(INT 14)。对由于缺页和页写保护引起的中断,该中断处理过程会分别调用memory.c
中的do_no_page()
和do_wp_page()
函数进行处理。do_no_page()
会把需要的页面从块设备中取到内存指定位置处。在共享内存页面情况下,do_wp_page()
会复制被写的页面(copy on write,写时复制),从而也取消了对页面的共享。 - 在开启了分页机制(PG=1)状态下,若 CPU 在转换线性地址到物理地址的过程中检测到以下条件, 就会引起页出错异常中断 INT 14:
- 地址变换过程中用到的页目录项或页表项中的存在位(P)标志等于 0,表示页表或包含操作数的页面不在物理内存中;
- 当前执行程序没有足够的特权访问指定的页面,或用户模式代码对只读页面进行写操作等。
下图就是内存物理地址空间的分配示意图:
其中,Linux 内核程序占据在物理内存的开始部分,接下来是供硬盘或软盘等块设备使用的高速缓冲区部分(其中要扣除显示卡内存和 ROM BIOS 所占用的内存地址范围 640K–1MB)。当一个进程需要 读取块设备中的数据时,系统会首先把数据读到高速缓冲区中;当有数据需要写到块设备上去时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到相应的设备上。内存的最后部分是供所有程序可以随时申请和使用的主内存区。内核程序在使用主内存区时,也同样首先要向内核内存管理模块提出申请,并在申请成功后方能使用。对于含有 RAM 虚拟盘的系统,主内存区头部还要划去一部分,供虚拟盘存放数据。
-
一个执行程序进程的代码和数据在其逻辑地址空间中的分布情况
由 GDT 映射的地址空间称为全局地址空间,由 LDT 映射的地址空间则称为局部地址空间,而这两者构成了虚拟地址的空间。
每个进程所占据的逻辑地址空间在线性地址空间中都是从 nr*64MB 的地址位置开始(nr 是任务号), 占用逻辑地址空间的范围是 64MB。其中最后部的环境参数数据块最长为 128K,其左面是起始堆栈指针。 另外,图中 bss 是进程未初始化的数据段,在进程创建时 bss 段的第一页会被初始化为全零。
写时复制(copy on write)机制
-
fork创建新进程后,此时系统并不为新的进程分配实际的物理内存页面,而是让它共享其父进程的内存页面。只有当父进程或新进程中任意一个有写内存操作时,系统才会为执行写操作的进程分配相关的独自使用的内存页面。这 种处理方式称为写时复制(Copy On Write)技术。因此在进程被创建后,若该进程及其父进程都没有使用堆栈,则两者共享同一堆栈对应的物理内存页面。只有当其中一个进程执行堆栈写操作(例如 push 操作)时内核内存管理程序才会为写操作进程分配新的内存页面。
-
写时复制是一种推迟或免除复制数据的一种方法。此时内核并不去复制进程整个地址空间中的数据, 而是让父进程和子进程共享同一个拷贝。当进程 A 使用系统调用 fock 创建出一个子进程 B 时,由于子 进程 B 实际上是父进程 A 的一个拷贝,因此会拥有与父进程相同的物理页面。也即为了达到节约内存和 加快创建进程速度的目标,fork()函数会让子进程 B 以只读方式共享父进程 A 的物理页面,同时也将父进程 A 对这些物理页面的访问权限也设成只读(详见
memory.c
程序中的copy_page_tables()
函数)。这样 一来,当父进程 A 或子进程 B 任何一方对这些共享物理页面执行写操作时,都会产生页面出错异常 (page-fault INT 14
)中断。此时CPU就会执行系统提供的异常处理函数do_wp_page()
来试图解决这个异常。 这就是写时复制机制。 -
do_wp_page()
函数会对这块导致写入异常中断的物理页面进行取消共享操作(通过调用un_wp_page()
函数) ,并为写进程复制一新的物理页面,从而使得父进程 A 和子进程 B 各自拥有一块内容相同的物理页面,并且把将要执行写入操作的这块物理页面标记成可以写访问的,这时才真正地进行了复制操作(只复制这一块物理页面)。最后,从异常处理函数中返回时 CPU 就会重新执行刚才导致异常的写入操作指 令,使进程能够继续执行下去。 -
因此,对于进程在自己的虚拟地址范围内进行写操作时,就会使用上面这种被动的写时复制操作方 式,也即:写操作 -> 页面异常中断 -> 处理写保护异常 -> 重新执行写操作指令。而对于系统内核代 码,当在某个进程的虚拟地址范围内执行写操作时,例如进程调用某个系统调用,若该系统调用会将数 据复制到进程的缓冲区域中,则内核会通过
verify_area()
函数首先主动地调用内存页面验证函数write_verify()
,来判断是否有页面共享的情况存在,如果有,就进行页面的写时复制操作。 -
写时复制会把对内存页面的复制操作推迟到实际要进行写操作的时刻,在页面不会被写的情况下就可以根本不用进行页面复制操作。例如,当 fork()创建了一个进程后立即调用 execve()去执行 一个新程序的时候。因此这种技术可以避免不必要的内存页面复制的开销。
Linux任务0和任务1对三个内存地址的使用
-
在内核代码地址空间(线性地址<1MB)执行 fork() 来创建进程时并没有采用写时复制技术。因此当进程 0(idle 进程)在内核空间创建进程 1(init 进程) 时将使用同一段代码和数据段。但是由于进程 1 复制的页表项也是只读的,因此当进程 1 需要执行堆栈 (写)操作时也会引起页面异常,从而在这种情况下内存管理程序也会在主内存区中为该进程分配内存。
-
任务切换操作示意图:
-
任务0
任务 0 是系统中一个人工启动的第一个任务。它的代码段和数据段长度被设置为 640KB。该任务的代码和数据直接包含在内核代码和数据中,是从线性地址 0 开始的 640KB 内容,因此可以它直接使用内 核代码已经设置好的页目录和页表进行分页地址变换。由于任务 0 直接被包含在内核代码中,因此不需要为其再另外分配内存页。
-
任务1
系统在使用
fork()
创建任务 1(init 进程)时为存放任务 1 的二级页表而在主内存区申 请了一页内存来存放,并复制了父进程(任务 0)的页目录和二级页表项。因此任务 1 有自己的页目录和页表表项,它把任务 1 占用的线性空间范围 64MB–128MB(实际上是 64MB–64MB+640KB)也同样映射到了物理地址 0–640KB 处。此时任务 1 的长度也是 640KB,并且其代码段和数据段相重叠,只占用一个页目录项和一个二级页表。另外,系统还会为任务 1 在主内存区域中申请一页内存用来存放它的 任务数据结构和用作任务 1 的内核堆栈空间。任务数据结构(也称进程控制块 PCB)信息中包括任务 1 的 TSS 段结构信息。任务 1 的用户态堆栈空间将直接共享使用处于内核代码和数据区域(线性地址 0–640KB)中任务 0 的用户态堆栈空间
user_stack[]
(参见 kernel/sched.c,第 82–87 行),因此这个堆栈需要在任务 1 实际使 用之前保持“干净”,以确保被复制用于任务 1 的堆栈不含有无用数据。
需求加载(Load on demand)机制
- 在使用 execve()系统调用加载运行文件系统上的一个执行映像文件时,内核除了在 CPU 的 4G 线性地址空间中为对应进程分配了 64MB 的连续空间,以及为其环境参数和命令行参数分配和映射了一定数量的物理内存页面以外,实际上并没有给执行程序分配其它任何物理内存页面。当然也谈不上从文件系 统上加载执行映像文件中的代码和数据。
- 因此一旦该程序从设定的入口执行点开始运行就会立刻引起 CPU 产生一个缺页异常(执行指针所在的内存页面不存在)。此时内核的缺页异常处理程序才会根据引 起缺页异常的具体线性地址把执行文件中相关的代码页从文件系统中加载到物理内存页面中,并映射到进程逻辑地址中指定的页面位置处。当异常处理程序返回后 CPU 就会重新执行引起异常的指令,使得执行程序能够得以继续执行。
- 若在执行过程中又要运行到另一页中还未加载的代码,或者代码指令需要访问还未加载的数据,那 么 CPU 同样会产生一个缺页异常中断,此时内核就又会把执行程序中的其他对应页面内容加载到内存中。 就这样,执行文件中只有运行到(用到)的代码或数据页面才会被内核加载到物理内存中。这种仅在实际需要时才加载执行文件中页面的方法被称为需求加载(Load on demand)技术或需求分页 (demand-paging)技术。
- 采用需求加载技术的一个明显优点是在调用 execve()系统调用后能够让执行程序立刻开始运行,而无需等待多次的块设备 I/O 操作把整个执行文件映像加载到内存中后才开始运行。因此系统对执行程序的加载执行速度将大大地提高。但这种技术对被加载执行目标文件的格式有一定要求。它要求被执行的文件目标格式是 ZMAGIC 类型的,即需求分页格式的目标文件格式。在这种目标文件格式中,程序的代 码段和数据段都从页面边界开始存放,以适应内核以一个页面为单位读取代码或数据内容。
进程状态
-
当一个进程的运行时间片用完,系统就会使用调度程序强制切换到其他的进程去执行。另外,如果进程在内核态执行时需要等待系统的某个资源,此时该进程就会调用
sleep_on()
或interruptible_sleep_on()
自愿地放弃CPU的使用权,而让调度程序去执行其他 程 。进程则进入睡眠状态(TASK_UNINTERRUPTIBLE 或 TASK_INTERRUPTIBLE
)。 只有当进程从“内核运行态”转移到“睡眠状态”时,内核才会进行进程切换操作。在内核态下运行的进程不能被其他进程抢占,而且一个进程不能改变另一个进程的状态。为了避免进程切换时造成内核数据错误,内核在执行临界区代码时会禁止一切中断。 -
进程状态宏定义
46 #define TASK_RUNNING 0 // 进程正在运行或已准备就绪。 47 #define TASK_INTERRUPTIBLE 1 // 进程处于可中断等待状态。 48 #define TASK_UNINTERRUPTIBLE 2 // 进程处于不可中断等待状态,主要用于 I/O 操作等待。 49 #define TASK_ZOMBIE 3 // 进程处于僵死状态,已经停止运行,但父进程还没发信号。 50 #define TASK_STOPPED 4 // 进程已停止。
-
进程状态转化示意图
-
参考链接
https://blog.csdn.net/chungle2011/article/details/80077716