操作系统段页结合的实际内存管理--13

x33g5p2x  于2022-07-26 转载在 其他  
字(13.7k)|赞(0)|评价(0)|浏览(801)

段、页结合: 程序员希望用段, 物理内存希望用页,所以…

  • 如果按段来操作内存,需要在内存中直接划分出一整块内存来存放当前段

这种方式对物理内存的使用而言很不友好,因为会造成大量的内存碎片,但是对程序员而言却很友好,因为访问某个段的内存是连续,而不是离散的

  • 如果按页来操作内存,就需要将当前段打散成很多页后,存放在内存中

这种方式对于内存的管理而言非常的友好,不会造成内存碎片问题,但是站在程序员的角度来看,段内的内存是不连续的。

能否站在程序员的视角看来,程序分段存放在内存上的模样是连续的,但是站在物理内存视角看来,却是分页管理的呢?

我们期望的模样就是上面这个样子,在程序员看来段内内存是连续的,但是实际物理内存是分页管理的,那么为了实现这个效果,势必就需要额外增加一层映射。

那么处于中间状态,看上去像物理内存的内存应该被叫做什么呢?

  • 对于用户来说,可以在该内存上划分地址空间来存放对应的段,但是这样划分得到的地址并不是真实的物理地址,还需要再次经过一层映射,映射到物理页上才行。
  • 在该内存上分配的地址并不是真实地址,而是虚拟地址,只有经过一层映射后,才能映射到真实的物理页上,所以该内存也被称为虚拟内存。

对于应用程序而言,只需要在虚拟内存中划分出一整块空间来存放当前段即可,然后会由操作系统将这块虚拟内存空间映射到对应的多个物理页上。

当程序需要访问段中某个数据时,也只需要访问对应虚拟内存中的地址,然后由操作系统将该虚拟地址映射到真实的物理地址上,完成访问。

段、页同时存在:段面向用户/页面向硬件

有了虚拟内存的之后,用户写的程序首先在虚拟内存中划分出对应的空间来存放,但是实际程序载入内存时,却会根据先前划分的虚拟地址空间,分别打散存储到对应多个物理页上。

当用户想要访问内存时,也只需要面向虚拟内存操作即可,用户发出的地址都是虚拟地址,但是操作系统通过将虚拟地址映射到物理地址后,用户就可以正常读取和设置物理内存中的数据了,对于用户而言操作虚拟内存和物理内存无区别。

段、页同时存在是的重定位(地址翻译)

当段页结合,引入虚拟内存之后,关键点就在于如何将虚拟地址翻译为真实的物理地址,这个过程如上兔所示:

  • 首先,用户按照段的方式进行内存访问,利用: 段号+段内偏移,然后通过查询段表(LDT),得到段基址,然后加上段内偏移,得到虚拟地址。
  • 虚拟地址,经过MMU计算,得到虚拟页号,然后去查询对应的页表,得到对应的真实物理页号
  • 通过真实的物理页号,和对应的页内偏移地址,就可以计算出真实的物理地址了

一个实际的段、页式内存管理

这个故事从哪里开始?

  • 内存管理核心就是内存分配,所以从程序放入内存、使用内存开始…

程序是一段代码,存放在磁盘上,想要让程序运行起来,就必须将程序从磁盘中读取到内存中来,那么这个过程就涉及到内存的分配: 在虚拟内存中为当前程序分配对应的段,建立对应的段表。并且还需要为每个虚拟内存中的段,打散后映射到多个物理页上,然后建立对应的页表,这样才能把程序顺序读入到内存中来。

运行起来的程序就是进程,因此上面讲述的过程其实是包含在一个新进程被创建出来的过程中的,因此是进程带动了内存的使用。

所以,下面将目光放到fork创建进程的地方,从这里讲起:

段、页式内存下程序如何载入内存?

  • 首先需要在虚拟内存中通过分区适配算法,找到一块空闲分区,来存放程序中的段,这里的存放实际上指的是在段表中新增一条表项,记录当前分配在虚拟内存中的段基址和占据的大小
  • 将虚拟内存中分配的段空间打散,按照对应的分页机制,映射到若干物理页上去,这里需要完成的是建立对应的页表,这样才能通过虚拟地址翻译,然后查询页表得到真实的物理地址
  • 然后配合磁盘读写,就可以将程序分段读入到对应的物理页上

当用户需要访存时,首先通过段号,查询段表,得到段基址; 然后拼接段内偏移地址得到虚拟地址,再通过对应的分页机制,解析出虚拟地址对应到页表中哪一个表项,然后得到对应的物理页号,通过物理页号就可以轻松计算出物理页号的基址,加上页内偏移地址,最终计算出实际的物理地址。

故事从fork()开始 --> 分配虚存,建段表

一个程序要运行,那肯定对应要有一个进程,因此首先是创建进程,分配完各种资源后,才能运行,而创建进程的流程之前已经分析过了,简化下就是这几步: fork()—>sys_fork—>copy_process

在linux/kernel/fork.c中
int copy_process(int nr, long ebp,...)
{ ...
copy_mem(nr, p); ...

copy_process函数负责完成进程各种资源的初始化工作,当然也包括对于程序需要内存的创建了,而该职责由copy_mem函数完成。

copy_mem函数就主要完成在虚拟内存中对段空间的申请,对应页表的建立和程序从磁盘读入到物理页的过程。

// 设置新任务的代码和数据段基址、限长并复制页表。   
// nr 为新任务号;p 是新任务数据结构的指针。   
int copy_mem (int nr, struct task_struct *p)  
{  
  unsigned long old_data_base, new_data_base, data_limit;  
  unsigned long old_code_base, new_code_base, code_limit;  
  code_limit = get_limit (0x0f);    // 取局部描述符表中代码段描述符项中段限长。   
  data_limit = get_limit (0x17);    // 取局部描述符表中数据段描述符项中段限长。   
  old_code_base = get_base (current->ldt[1]);    // 取原代码段基址。   
  old_data_base = get_base (current->ldt[2]);    // 取原数据段基址。   
  if (old_data_base != old_code_base)   // 0.11 版不支持代码和数据段分立的情况。   
    panic ("We don't support separate I&D");  
  if (data_limit < code_limit)   // 如果数据段长度 < 代码段长度也不对。   
    panic ("Bad data_limit");  
  new_data_base = new_code_base = nr * 0x4000000;   // 新基址=任务号*64Mb(任务大小)。   
  p->start_code = new_code_base;  
  set_base (p->ldt[1], new_code_base);   // 设置代码段描述符中基址域。   
  set_base (p->ldt[2], new_data_base);   // 设置数据段描述符中基址域。   

  //------------------------------下面可以暂时不看----------------------------------------------
  
   if (copy_page_tables (old_data_base, new_data_base, data_limit))  
    {               // 复制代码和数据段。   
      free_page_tables (new_data_base, data_limit); // 如果出错则释放申请的内存。   
      return -ENOMEM;  
    }  
  return 0;  
}

下面这三行代码就完成了在虚拟空间中对段空间的申请:

new_data_base=nr*0x4000000; //64M*nr
set_base (p->ldt[1], new_code_base);   // 设置代码段描述符中基址域。   
set_base (p->ldt[2], new_data_base);   // 设置数据段描述符中基址域。

可以看出linux 0.11中代码段和数据段是不进行区分的,因此这两者是共享一块虚拟内存的

进程0、进程1、进程2的虚拟地址

  • 每个进程的代码段、数据段都是一个段
  • 每个进程占64M虚拟地址空间,互不重叠

因为每个进程的段空间不重叠,意味着各个进程的虚拟空间中的虚拟地址不会重叠,那么对应各个进程的虚拟地址解析得到的虚拟页号不会重叠,因此在linux 0.11中多个进程可以共享一套页表。
对于现代操作系统而言,各个进程对应的页表是会产生重叠的,因此每个进程需要有自己的段表和页表

接下来应该是什么了? —> 分配内存、建页表

在分配完段表后,下面就是对页表进行创建和处理了。

上面cpoy_process函数中还有最后一段没有看,下在时机成熟了,可以来看看:

// 设置新任务的代码和数据段基址、限长并复制页表。   
// nr 为新任务号;p 是新任务数据结构的指针。   
int copy_mem (int nr, struct task_struct *p)  
{  
  unsigned long old_data_base, new_data_base, data_limit;  
  unsigned long old_code_base, new_code_base, code_limit;  
  code_limit = get_limit (0x0f);    // 取局部描述符表中代码段描述符项中段限长。   
  data_limit = get_limit (0x17);    // 取局部描述符表中数据段描述符项中段限长。   
  old_code_base = get_base (current->ldt[1]);    // 取原代码段基址。   
  old_data_base = get_base (current->ldt[2]);    // 取原数据段基址。   
  if (old_data_base != old_code_base)   // 0.11 版不支持代码和数据段分立的情况。   
    panic ("We don't support separate I&D");  
  if (data_limit < code_limit)   // 如果数据段长度 < 代码段长度也不对。   
    panic ("Bad data_limit");  
  new_data_base = new_code_base = nr * 0x4000000;   // 新基址=任务号*64Mb(任务大小)。   
  p->start_code = new_code_base;  
  set_base (p->ldt[1], new_code_base);   // 设置代码段描述符中基址域。   
  set_base (p->ldt[2], new_data_base);   // 设置数据段描述符中基址域。   

  //------------------------------重点看下面这段代码----------------------------------------------
  
  //old_data_base指向的是父进程在虚拟内存中数据段和代码段的地址,作为拷贝的源地址
  //而new_data_base指向的是上面在虚拟内存中分配的子进程数据段和代码段的地址,作为拷贝的目的地址
  //data_limit是段限长
   if (copy_page_tables (old_data_base, new_data_base, data_limit))  
    {               // 复制代码和数据段。   
      free_page_tables (new_data_base, data_limit); // 如果出错则释放申请的内存。   
      return -ENOMEM;  
    }  
  return 0;  
}

copy_page_tables是用来对子进程页表进行处理的,那么具体是怎么处理的呢?

下面就一点点来读一下它的源码吧:

copy_page_tables函数源码解析

copy_page_tables这个函数在父进程创建子进程的过程中使用,父进程要负责设置子进程的代码段、数据段(线性空间),然后为子进程拥有的线性地址空间创建对应的页目录项和页表,使得子进程能够进行内存寻址。copy_page_tables的工作就是通过复制父进程的页表来创建子进程的页表,并设置相应的页目录项。
这里线性地址空间就是虚拟内存

注意页目录项和页表的区别,并且要搞清楚他们分别的作用是什么:

一个页目录表管理1024个页目录项
一个页表管理1024个页表项
一个页表项管理一个物理页面,一个物理页面有4K物理地址

copy_page_tables函数的源码我们先一点点来读一下:

  • 先来看看copy_page_tables函数的参数
int copy_page_tables(unsigned long from,unsigned long to,long size)

函数的参数是从fork->copy_process->copy_mem中传递过来的old_data_base,new_data_base,data_limit。

  • 其中old_data_base是父进程局部描述符表LDT中数据段的基地址(虚拟地址空间)

  • new_data_base为即将创建的子进程在虚拟地址空间中的基地址
    linux 0.11中整个4G大小的虚拟内存是等分的,目前支持的是64个进程,则4G/64=64MB,即每个进程分配64MB的虚拟地址空间,因此对于一个任务号为nr的进程,即对应的虚拟地址空间起始地址为nr*64MB,

  • data_limit为父进程的局部描述符表LDT中数据段描述符中的段限长。

  • copy_page_tables首先会去检查虚拟地址对齐

if ((from&0x3fffff) || (to&0x3fffff))    //4M对齐检测,否则die  
        panic("copy_page_tables called with wrong alignment");

32位虚拟地址拆分成3部分来看,10 | 10 | 12, 前十位对应页目录项,中间10位对应页表项,最后12位对应页内偏移。

0x3fffff:最后12位全为1,相当于4MB,也就是一个页表管辖4MB的地址空间(4MB线性地址空间<->4MB物理地址空间)。
一个页表可以管理多大的物理内存空间,取决于该页表内部存放了多少条页表项,每个页表项会记录一个虚拟页号到物理页号的映射关系,并且一个物理页大小为4K

上述代码也就是判断末尾的22位是否全为0,也就是说任意进程的虚拟地址空间必须是从0x000000开始的4MB的整数倍虚拟地址。

  • 然后再通过父进程数据段基地址对应的虚拟地址得到页目录项的物理地址
from_dir = (unsigned long *) ((from>>20) & 0xffc); // _pg_dir = 0,通过线性地址得到页目录项物理地址

存放页目录项由内存中一个空闲页面完成的,一页大小为4K,每个表项大小为4B,因此页目录表共1K项。

从上图可以看出,想要得到某个虚拟地址对应的页目录号,只需要将虚拟地址左移22位即可,但是为什么上面只是左移了20位呢? 还有& 0xffc是什么意思呢?

  • 页目录号索引=from>>22,又因为1个页目录项占4B,则页目录项相对物理地址=页目录号<<2,例如页目录号为1,则页目录项相对物理地址为4B

又因为页目录的基址为0x000000,因此页目录项相对物理地址=页目录项绝对物理地址。

故【(线性地址>>22)<<2】得到的就是页目录项的物理地址。【(线性地址>>22)<<2】这句话实际上就等价于【(线性地址>>20) & 0xffc】, 也就是把最后2位清空。
拿到父进程起始页目录项的地址

  • 计算要复制的页表数
size = ((unsigned) (size+0x3fffff)) >> 22;     //计算页表数

size的单位是B,是父进程数据段对应的段限长,这个段限长首先限制的是虚拟地址空间寻址大小,例如进程0的数据段限长为640KB,因此其虚拟地址空间为0~640KB,同时也间接得限制了物理地址空间的寻址大小,因为任何物理地址都需要有与之相映射的虚拟地址,对于0进程而言,映射的物理内存是物理内存起始开始的640KB的空间。

而对于一般的进程,虚拟地址空间为64M,对应的最大物理内存空间也是64M,一个页目录项或者说一个页表管辖4MB的物理内存空间,因此一个进程线性地址空间最多可以拥有16个页目录项,也即16个页表。因此,页表数=(size+4M/4M),相当于求需要多少个页表来管辖,也就是copy_page_tables要复制多少个页表。有多少个页表,则就有多少个对应的页表项。

计算子进程要复制多少个父进程的页表

  • 根据页目录项物理地址得到页表物理地址
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);//根据页目录项地址得到页表物理地址

from_dir是页目录项的地址,取*号,相当于取页目录项的内容,因为页目录项指向页表,也即页目录项的内容就是页表的物理地址。

因为get_free_page返回的物理地址=相对物理地址<<12+LOW_MEM(每页大小为2^12 =4KB),因此将低12位清零&fffff000得到的就是页面的物理地址,页表也是占用1页面大小,因此这里得到的就是页表的物理地址,同时也是第一项页表项的物理地址。

最后12位实际上用于存储页表的属性。至于为什么只用20位存储,因为目前使用的是16M物理内存,16MB/4KB=224/212=2^12页数,20位足够用了,实际上20位可以索引最多4G的物理空间。
拿到父进程起始页目录项指向的页表地址

  • 拷贝页表项
if (!(to_page_table = (unsigned long *) get_free_page()))      
            return -1;  /* Out of memory, see freeing */  //取空闲物理页为to_page_table赋值       
*to_dir = ((unsigned long) to_page_table) | 7;    //将页表存进相应页目录项,//7表示可读写  
nr = (from==0)?0xA0:1024;    //如果是0地址,只拷贝160页,否则拷贝1024页  
							//一个页目录表管理1024个页目录项  
							//一个页表管理1024个页表项  
							//一个页表项管理有4K物理地址  
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {  
  this_page = *from_page_table;    //从源页表中取源页表项  
  if (!(1 & this_page))            //如果源页表项未被使用,跳过  
    continue;  
  this_page &= ~2;                 //目的页表项读写位, 设置为只读                                 
  *to_page_table = this_page;      //将源页表项存进目的页表项  
  if (this_page > LOW_MEM) {       //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管  
    *from_page_table = this_page;//源页表项也要设置为只读  
    this_page -= LOW_MEM;        //取相对主内存的偏移地址  
    this_page >>= 12;            //取主内存管理数组索引  
    mem_map[this_page]++;        //物理页引用次数加1  
  }  
}

首先为子进程页表(1个页表占4K)存储申请一个页面的物理内存,get_free_page()。

这里是引用

然后执行*to_dir = ((unsigned long) to_page_table) | 7,将子进程页表存到相应的页目录项当中,页目录项也是前20位代表页表的地址,后12位代表页表对应的页面的属性,因此把最后3位设置成用户权、可读写、存在,相当于设置了页表对应页面的属性

如果是0进程则只拷贝160个页表项,否则全部拷贝1K个页表项,接着for循环拷贝每一个页表项,对于未使用的页表项不进行拷贝。

this_page &= ~ 2其中2=010,~2=101,代表用户,只读,存在。因为此时父子进程共享了物理页,因此需要对物理页的引用次数+1。可以看到这里面索引的计算,索引=(物理地址- LOW_MEM)/4KB。

页表项是前20位代表页表的地址,后12位代表页表对应的页面的属性,因此把最后3位设置成用户权、可读写、存在,相当于设置了页表项对应页面的属性

写时复制

最后还要强调一下如下代码:

if (this_page > LOW_MEM) {       //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管  
    *from_page_table = this_page;//源页表项也要设置为只读  
    this_page -= LOW_MEM;        //取相对主内存的偏移地址  
    this_page >>= 12;            //取主内存管理数组索引  
    mem_map[this_page]++;        //物理页引用次数加1  
  }

对于这段代码的理解:

对于内核空间,不使用写时复制。一个页面被多个进程共享,每当一个进程产生一次写保护错误,内核将给进程分配一个新的物理页面,将共享页面的内容复制过来,新的页面将设置为可读写,而共享页面仍然是只读的,只是共享计数减小了。当其他共享进程都产生了一次写保护错误后,共享页面的共享计数减成了1,其实就是被一个进程独占了,但此时该共享页面仍然是只读的,如果独占它的进程对它进行写操作仍然会产生写保护出错。为什么不在共享计数减成了1之后就将共享页面置为可写呢?

原因很简单,因为系统并不知道最后是哪个页表项指向这个共享页,如果要把它查找出来会有很大的系统开销,这是中断处理程序应当尽量避免的,所以采用了以逸待劳的办法。如果当初共享的页面不属于主内存块,在共享时就没有作共享计数的处理,就不存在共享计数的问题,直接复制就可以了。

但是,对于内核空间来说,却是不同的。内核空间不使用写时复制机制!这也是Linus在其内核代码注释中提到的,对于内核空间来说,还是以特殊的进程0和进程1来说。进程0是系统中第一个手工创建的程序,其特殊在其地址空间属于内核空间,也就是说,进程0是内核空间的物理页面。系统通过fork函数产生了进程1,此时进程0和1共享内核物理页面(假设为KM)。但是其特殊就特殊在在fork时,内核针对内核空间是特殊对待的。也就是说,只有对于非内核地址空间的页面,才会将被fork共享的页面所对应的页表项设为只读,从而在最后一次写操作的时候,将源页面释放。而对于内核地址空间的地址共享,只将进程1的页目录的属性设置为只读,而源目录表项(进程0)依然是可读写的。这就导致进程1fork进程0而产生之后,只有进程1对共享的物理页面(内核地址空间)进行写操作的时候才会产生写时复制,为进程1在主内存中申请一页新的物理页面作为独属于进程1的物理页面。而进程0对其共享内存的写操作不会引起写时复制,即KM就好像独属于进程0一样。

copy_page_tables完整源码如下:

/* 
 * copy_page_tables()函数只被fork函数调用  
 * 拷贝只是拷贝页表,页表是管理4M地址的,所以按照4M对齐  
 * 不拷贝物理页内容,当发生写时拷贝才会拷贝页表所管理的物理页内容  
 * 对于进程0和1,只拷贝前160页共640Kb,出于效率考虑  
 * 0-1M作为内核驻留地址区域,禁止写覆盖  
 * 参数from,to是0-4G线性地址,size是字节为单位  
 */  
int copy_page_tables(unsigned long from,unsigned long to,long size)  
{  
    unsigned long * from_page_table;        //用于管理源页表      
    unsigned long * to_page_table;          //用于管理目的页表  
    unsigned long this_page;                //用于保存页表  
    unsigned long * from_dir, * to_dir;     //用于管理源页目录项,目的页目录项  
    unsigned long nr;                       //用于保存页表项个数  
  
    if ((from&0x3fffff) || (to&0x3fffff))    //4M对齐检测,否则die  
        panic("copy_page_tables called with wrong alignment");  
    from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */  
                                                          //源页目录项  
    to_dir = (unsigned long *) ((to>>20) & 0xffc);    //目的页目录项  
    size = ((unsigned) (size+0x3fffff)) >> 22;        //页表项个数是字节数除以4M  
    for( ; size-->0 ; from_dir++,to_dir++) {                                  
        if (1 & *to_dir)    //最后一位P位,如果目的页目录项已经被使用,die  
            panic("copy_page_tables: already exist");  
        if (!(1 & *from_dir)) //最后一位代表P位,如果源页目录项未使用,跳过,不拷贝       
            continue;    
        from_page_table = (unsigned long *) (0xfffff000 & *from_dir);//取源页表地址  
        if (!(to_page_table = (unsigned long *) get_free_page()))      
            return -1;  /* Out of memory, see freeing */  //取空闲物理页为to_page_table赋值                                      
                                                          //如果没有空闲物理页,die  
        *to_dir = ((unsigned long) to_page_table) | 7;    //将页表存进相应页目录项,  
                                                          //7表示可读写  
                                                          //想一下常用的chmod 777 anyfile  
        nr = (from==0)?0xA0:1024;    //如果是0地址,只拷贝160页,否则拷贝1024页  
                                     //一个页目录表管理1024个页目录项  
                                     //一个页表管理1024个页表项  
                                     //一个页表项管理有4K物理地址  
                                                                                 
        for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {  
            this_page = *from_page_table;    //从源页表中取源页表项  
            if (!(1 & this_page))            //如果源页表项未被使用,跳过  
                continue;  
            this_page &= ~2;                 //目的页表项读写位,  
                                             //设置为只读                                 
            *to_page_table = this_page;      //将源页表项存进目的页表项  
            if (this_page > LOW_MEM) {       //如果是主内存区  
                *from_page_table = this_page;//源页表项也要设置为只读  
                this_page -= LOW_MEM;        //取相对主内存的偏移地址  
                this_page >>= 12;            //取主内存管理数组索引  
                mem_map[this_page]++;        //物理页引用次数加1  
            }  
        }  
    }  
    invalidate();    //刷新高速缓存  
    return 0;        //返回0表示成功  
}

程序、虚拟内存+物理内存的样子

到此为止,子进程相关内存资源就申请完了,目前整体模样如下:

注意,页表中的页表项的虚页号一定是连续的,但是并不一定每个虚页号都对应一个实页号,会存在一些虚页号没有和对应的实页号建立映射关系,而此时如果程序需要访问该虚页号,则会抛出缺页异常

p=7? 父进程p=7、子进程*p=8? 读写内存 *p=7

子进程此时已经被加载进内存了,下一步就是如何读写程序了。

假设父进程需要向内存中写入一个7,然后给出一个偏移地址,MMU通过段基址寄存器中保存的段号,和对应的偏移地址自动去查询段表,得到对应的虚拟地址,然后通过虚拟地址就知道了页目录号,页号,和页内偏移地址,然后马上又会去查询页表,得到对应的真实物理地址,然后进行内存的写入。
这里假设要写入的物理内存位于内核空间,因此父进程对于共享的物理内存是可写的,而子进程是只读的

此时子进程需要向同样的偏移地址除写入数据,因为子进程复制了父进程的页表,因此计算得出的物理内存是一致的,但是对应的页表项是只读的,因此进程产生一次写保护错误,内核将给进程分配一个新的物理页面,将共享页面的内容复制过来,新的页面将设置为可读写,而共享页面仍然是只读的。

写时复制相当于一种懒加载思想,对于节约内存使用而言,有很大帮助,毕竟大部分情况下读多写少

MMU简单科普

虚拟地址/物理地址:

如果处理器没有MMU,CPU内部执行单元产生的内存地址信号将直接通过地址总线发送到芯片引脚,被内存芯片接收,这就是物理地址(physical address),简称PA。英文physical代表物理的接触,所以PA就是与内存芯片physically connected的总线上的信号。

如果MMU存在且启用,CPU执行单元产生的地址信号在发送到内存芯片之前将被MMU截获,这个地址信号称为虚拟地址(virtual address),简称VA,MMU会负责把VA翻译成另一个地址,然后发到内存芯片地址引脚上,即VA映射成PA,如下图:

所以物理地址①是通过CPU对外地址总线②传给Memory Chip③使用的地址;而虚拟地址④是CPU内部执行单元⑤产生的,发送给MMU⑥的地址。硬件上MMU⑥一般封装于CPU芯片⑦内部,所以虚拟地址④一般只存在于CPU⑦内部,到了CPU外部地址总线引脚上②的信号就是MMU转换过的物理地址①。

软件上MMU对用户程序不可见,在启用MMU的平台上(没有MMU不必说,只有物理地址,不存在虚拟地址),用户C程序中变量和函数背后的数据/指令地址等都是虚拟地址,这些虚拟内存地址从CPU执行单元⑤发出后,都会首先被MMU拦截并转换成物理地址,然后再发送给内存。也就是说用户程序运行*pA =100;"这条赋值语句时,假设debugger显示指针pA的值为0x30004000(虚拟地址),但此时通过硬件工具(如逻辑分析仪)侦测到的CPU与外存芯片间总线信号很可能是另外一个值,如0x8000(物理地址)。当然对一般程序员来说,只要上述语句运行后debugger显示0x30004000位置处的内存值为100就行了,根本无需关心pA的物理地址是多少。但进行OS移植或驱动开发的系统程序员不同,他们必须清楚软件如何在幕后辅助硬件MMU完成地址转换。

MMU的内存保护功能:

既然所有发往内存的地址信号都要经过MMU处理,那让它只单单做地址转换,岂不是浪费了这个特意安插的转换层?显然它有能力对虚地址访问做更多的限定(就像路由器转发网络包的同时还能过滤各种非法访问),比如内存保护。可以在PTE条目中预留出几个比特,用于设置访问权限的属性,如禁止访问、可读、可写和可执行等。设好后,CPU访问一个VA时,MMU找到页表中对应PTE,把指令的权限需求与该PTE中的限定条件做比对,若符合要求就把VA转换成PA,否则不允许访问,并产生异常。

虚拟地址转换物理地址的过程:

打开mmu后,cpu访问的都是虚拟地址,当cpu访问一个虚拟地址的时候,会通过cpu内部的mmu来查询物理地址,mmu首先通过虚拟地址在tlb中查找,如果找到相应表项,直接获得物理地址;如果tlb没有找到,就会通过虚拟地址从页表基地址寄存器(cr3)保存的页表基地址开始查询多级页表,最终查询到找到相应表项,会将表项缓存到tlb中,然后从表项中获得物理地址。

操作系统和MMU:

实际上MMU是为满足操作系统越来越复杂的内存管理而产生的。OS和MMU的关系简单说:

  • 系统初始化代码会在内存中生成页表,然后把页表地址设置给MMU对应寄存器,使MMU知道页表在物理内存中的什么位置,以便在需要时进行查找。之后通过专用指令启动MMU,以此为分界,之后程序中所有内存地址都变成虚地址,MMU硬件开始自动完成查表和虚实地址转换。
  • bos初始化后期,创建第一个用户进程,这个过程中也需要创建页表,把其地址赋给进程结构体中某指针成员变量。即每个进程都要有独立的页表。

MMU映射失败的几种情况:

1.访问了受内核保护的页面,或者访问了只读的页面(比如c语言中存储字符串字面量和const变量的段),此时内核会抛出段错误

2.页面和页框没有产生映射关系,但是数据页已经被其他进程加载到内存中了,此时只需要建立页面和页框的映射关系,称为次级缺页中断
这里的页面就是虚页号,而页框就是实页号

3.页面和页框没有产生映射关系,数据页也没有被加载到内存中(在磁盘上),此时需要发生磁盘io从磁盘中加载页到内存中,还需要建立页面和页框的映射关系,称为严重缺页中断。

除了第一点,第二第三都会以内核降低自身运行速度来修复,也就通过中断形成页表映射,然后再重新执行引起中断的命令(此时数据页已经在内存中并且建立映射关系了)。

页表作用小结

打开mmu后,对没有页表映射的虚拟内存访问或者有页表映射但是没有访问权限都会发生处理器异常,内核选择杀死进程或者panic;

通过页表给一段内存设置用户态不可访问, 这样可以做到用户态的用户进程不能访问内核地址空间的内容;

而由于用户进程各有一套自己的页表,所以彼此看不到对方的地址空间,更别提访问,造成每个进程都认为自己拥有所有虚拟内存的错觉;

通过页表给一段内存设置只读属性,那么就不容许修改这段内存内容,从而保护了这段内存不被改写;

对应用户进程地址空间映射的物理内存,内核可以很方便的进行页面迁移和页面交换,而对使用虚拟地址的用户进程来说是透明的;

通过页表,很容易实现内存共享,使得一份共享库很多进程都可以映射到自己地址空间使用;

通过页表,可以小内存加载大应用程序运行,在运行时按需加载和映射…

参考

Linux内存管理之copy_page_tables源码理解

相关文章