内存管理和地址翻译概述

来源:互联网 发布:网易广州 知乎 编辑:程序博客网 时间:2024/06/05 17:23

ps:个人总结与理解,不保证正确性。。。

[保护模式中指令地址的理解]
     在传统的无保护模式下的地址空间中,在指令中的地址是16位的,段寄存器也是16位的。但当时Intel8086支持的是20位的地址,因此会将指令中的地址与相应的段寄存器中的内容进行操作得到20位的地址。具体的操作是应该是段寄存器中的内容左移4位,然后与指令中的地址求和,这样就得到了新的20位的地址。这样的缺陷是当时没有地址空间保护机制,并且改变段寄存器中的内容也不是什么特权指令,因此进程可以通过自己改变段寄存器中的值来访问全部的内存空间。当时使用的段寄存器有CS、DS、SS和ES
     针对这个缺陷,Intel使用了保护模式,增加了对内存空间的保护。在保护模式中,对段设置了读写需要的特权级别,并且还要保存相应的段的基地址,这就需要使用另外的数据结构来存储。所以在保护模式下,段寄存器中的内容的理解发生了变化。段寄存器不再是作为某一个具体的段的基地址来使用,而是让其中的内容变成了指向用于内存中某个数据结构的指针,这个数据结构中存储了我们使用段说需要 的信息。这样,对指令中内存地址的使用,便按照下面的步骤来进行。
     (1)、根据指令的性质来确定应该使用哪一个段寄存器
     (2)、根据段寄存器中的内容,找到相应的数据结构(暂时叫做地址段描述结构)
     (3)、从地址段描述结构中找到段的基地址
     (4)、将指令中发出的地址作为偏移值,与段描述结构中规定的段长相比,看看是否存在越界情况
     (5)、根据指令的性质和段描述符中的访问权限来确定是否越权
     (6)、将指令中发出的地址作为偏移值,与基地址相加得到实际的虚拟地址
在这个过程中,使用到的用来描述段的数据结构,成为段描述符。由于程序中不同内容会组成不同的段,因此这些段描述符组成一个数组,叫做段描述符表。在Linux中,段描述符表分为全局的和局部的,分别叫做GDT和LDT。GDT和LDT存在与内存中,它们的地址保存在相应的寄存器中,分别是GDTR和LDTR。
     那么,从段寄存器中如何确定是要使用哪个段描述符表呢?这就涉及到对段寄存器的理解。段寄存器一般是16位的,和之前无保护模式的情形一致。从低到高,它的第0位和第1位确定了访问的特权级别(二进制00到11,00最高,11最低)。第2位则指明了使用的描述符表的类型,0表示使用GDTR,1表示使用LDTR。剩下的13位,这是在LDTR或者GDTR中数组的索引,用于找到具体的描述项。如下图所示


这样,就可以通过段寄存器和GDTR、LDTR来找到相应的段的描述符了。在段的描述符中,有了解段所需的一切信息,其中就包括访问段所需的特权级别,这个段的大小、段的基地址。每个段描述符的大小是8字节,即64位。在64位中的各个位都对段的某一方面信息做了说明,64位的段描述符结构如下(图示来自于Intel80386的参考手册,这里给出的是通用的格式,一些特殊系统的格式与此类似)


段的基地址共32位,能够定位一个4G的线性地址空间。它们组合起来构成一个32位的段基地址。段的限长(limit)共有20位,段的限长的有两种理解方式,在以1直接位单位的理解中,段限长最大值是1M,在以4K直接为单位的理解中,段限长的最大值是4G(1M*4K)。
DPL用2位来表示,表示查看段的内容所需的特权级别。A标志表示段是否被访问了(0表示未被访问,1表示已被访问)。 P表示段现在是否在内存中(0表示不在内存中,1表示在内存中)。G表示段限长大小的粒度,1表示粒度位4K,0表示粒度位1字节,G标志位会影响段限长大小的计算结果。
关于段描述符中的DPL字段,在LDT和GDT中的意义是不一样的,在LDT中时,表示当前进程的运行级别,在GDT中表示访问此部分数据所需要的级别,在Linux系统中只有0和3两个级别。
TYPE用3位来表示,如下图所示
E=0时表示为数据段,此时如果ED=0表示向上伸展(数据段),ED=1表示向下伸展(堆栈段)。W=0不能写,W=1则可写
E=1时表示代码段,此时C=0表示忽视特权级,C=1则遵循特权级。R=0不可读,R=1表示可读

在Linux Kernel 0.11版本中,对描述符表的定义分别如下
extern desc_table idt,gdt;
desc_table的定义如下
typedef struct desc_struct {
                 unsigned long a,b;
} desc_table[256];
  而LDT的定义是在每个进程的数据结构中的struct desc_struct ldt[3];

[段式内存管理和分页式内存管理]
     在说明段式内存管理和分页式内存管理之前,先要明确几个概念:逻辑地址、线性地址(虚拟地址)、和物理地址。逻辑地址到线性地址的转换需要使用段式内存管理、线性地址到物理地址的转换需要使用分页式内存管理。或者应该这样说,如果使用了分段式内存管理,那么就需要使用段寄存器、LDTR、GDTR来将逻辑地址转换成线性地址;如果没有使用分段式内存管理,那么线性地址和逻辑地址就是一样的。如果使用了分页式内存管理,那么线性地址到物理地址的转换就要使用到的分页式内存管理的一套东西;如果没有使用分页式内存管理,那么线性地址和物理地址就是一致的。
     逻辑地址可以理解成操作指令中的地址,在分段式内存管理中,逻辑地址代表的是相应的段中的偏移值。
     线性地址也就是虚拟地址,它的大小与系统的位数有关,在32位操作系统中,便是4G的地址空间。
     物理地址代表机器的硬件能使用的地址大小。
     在Intel 80386的说明手册中,给出了这几个地址的转换示意图


这里关于线性地址到物理地址的标记有误。应该是支持分页的时候,会加上PAGE TRANSLATION,不支持分页的时候就直接到物理地址了

在Linux内核0.11中,使用的是一个由页目录表和页表构成的二级方式来进行线性地址到物理地址的转换。页目录表中的每一项都指向一个页表所在的页。页表中的每一项都指向一个内存页。Linux 0.11中对线性地址的理解如下


一个页目录表共有1024项,每个页表中也有1024项。它们中的每一项都是一个32位的地址,也就是4B大小。因此,一个完整的页目录表所占的空间是4B*1024=4K,一个完整的页表所占的内存空间的大小是4B*1024=4K。刚好是Linux中每个内存页的基本大小。这样也就保证了一个完整的页目录表或者页表刚好能够装入一页内存中。并且,要注意,在页目录表和页表中各项保存的地址是一个物理地址。

在《Linux内核完全注释》中指出,在Linux中所有的进程共用一个页目录表,每个进程各自有一个页表。既然所有进程共用一个页目录表,那么在不同进程中,同一个线性地址,如何保证使用的是不同的物理内存呢?既然是同一个页目录表,那么同一个虚拟地址得到的页表的地址是相同的,得到的页表项也是相同的,这样得到的物理地址也应该是相同的,对此应该怎样理解呢?

对此的理解要结合Linux 内核0.11的进程在逻辑地址空间中的分布来看。在Linux内核的0.11版本中,每一个进程的线性地址都是从nr*64MB的地方开始的(nr)代表的是这个进程的进程号,并且每个进程所占用的线性地址空间是64M。也就是说,对于一个进程编号位nr的进程来说,它的逻辑地址转换成线性地址后,线性地址空间是nr*64M到(nr+1)*64M。并且很容易计算,在页目录表中每相邻的两个页目录之间的物理地址差别是4M(1024*4K)。而每两个进程的线性地址之间的差距至少是64M,因此每个进程的最多可能占据页目录表中的16项,不同的进程的线性地址完全不会重合,不同进程的线性地址的高10位肯定是不相同的。这也就解释了为什么在Linux内核的0.11版本中,可以只需要使用一个页目录表即可。

[页目录表和页表分析]
由于页表和内存页都是4KB对齐的(因为每一个页的大小是4KB)。所以,在页目录表和页表中的每一项的地址的低12位其实对于定位页表和物理页没有帮助的。因此,在页目录表和页表中每一项的低12位用于表示其它作用。INTEL 80386的帮助手册上这样规定在页目录表和页表中每一项数据的格式如下

R/W位为1表示页面可被读、写或执行。如果为0,表示页面只读或可执行。处理器处于特权级别时,R/W位不起作用。U/S位为1表示用户进程使用,为0时表示内核使用。
对此,我们以memory.c中的一个内存页复制函数copy_page_tables来作为实例进行说明
/*
拷贝一个范围内的内存,以内存页为粒度进行拷贝
这里的from和to都是32位的线性地址,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;
                 /*
要求地址的低22位全部是0,即地址要是4M对齐的。因为这里只是拷贝对应的页目录表中的内容,而页目录中每一项对应的地址是以4M对齐的
                */
                 if ((from&0x3fffff) || (to&0x3fffff))
                                panic( "copy_page_tables called with wrong alignment" );
                 /*
               0xffc=1111 1111 1100
                这里是将线性地址转换成对应的在页目录表中的索引。又因为页目录表所在的地址为物理地址0处。
               所以这里的索引就刚好是对应的页目录表项的物理地址
                */
                from_dir = ( unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
                to_dir = ( unsigned long *) ((to>>20) & 0xffc);
                 /*
               参数中所给的size代表的是拷贝的线性地址范围的大小,这里将其转换成对应的在页目录表中项的个数。不足一项的按一项来处理
                */
                size = (( unsigned) (size+0x3fffff)) >> 22;
                 for( ; size-->0 ; from_dir++,to_dir++) {
                                 /*
                             地址的第0 bit位表示的是相应的页是否已在内存中,即是否已被使用
                                */
                                 if (1 & *to_dir)
                                                panic( "copy_page_tables: already exist" );
                         /*表示来源地址中此项没有被使用到,即此部分地址空间没有用到,因此不用拷贝*/
                                 if (!(1 & *from_dir))
                                                 continue;
                         /*
                         获取页目录表项数据
                          页目录项和页表项数据的低12位是有特殊含义的,对于不同的进程来说,意义不一样,不用拷贝
                         只需要高20位的地址即可*/
                                from_page_table = ( unsigned long *) (0xfffff000 & *from_dir);
                    /*get_free_page函数的作用是获取一页可用的物理内存页,并标记为已用
                       如果获取成功就返回对应的内存页的物理地址
                      如果获取失败,则返回0,表示内存不足
                      */
                                 if (!(to_page_table = (unsigned long *) get_free_page()))
                                                 return -1;                 /* Out of memory, see freeing */
                                *to_dir = (( unsigned long ) to_page_table) | 7;  //设置地址项的标记位
                              /*设置要复制的页表项的个数。如果是从内核处进行复制,那么只需要复制160项。
                                如果是从一般的用户进程处进行复制,那么需要复制全部的1024项。
                         至于为什么对内核的时候只需要复制160项,可能是与内核本身所占的空间的大小有关 */
                          nr = (from==0)?0xA0:1024;
                                 for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
                                                this_page = *from_page_table; //对应的物理页在来源的地址中没有被用到
                                                 if (!(1 & this_page))
                                                                 continue;
                                                this_page &= ~2; //将R/W位设成0。设置成只读,是与Linux的写时复制有关
                                                *to_page_table = this_page;
                                   /*LOW_MEM=0x100000=2^20=1M。1M以下是内核代码页面。
                                   如果此地址在1M以上,就需要设置内存页面映射数组*/
                                                 if (this_page > LOW_MEM) {
                                                                *from_page_table = this_page; //将来源的项的R/W位设成0
                                                                this_page -= LOW_MEM;
                                                                this_page >>= 12;  //定位页的时候,低12可以不需要
                                                                mem_map[this_page]++; //引用次数加1
                                                }
                                }
                }
                invalidate();
                 return 0;
}
[实例分析]

这里的实例代码来自于linux kernel v0.11

在fork.c中,有copy_mem函数,这个函数是设置新任务代码的数据段基地址、段限长、并复制页表。在copy_mem函数中,有对LDT和GDT以及段描述符的使用,函数的定义如下
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)
                                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; //0x4000000=2^26
                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;
}
   函数的参数中,nr位新任务号,p是指向新任务数据结构的指针。
首先看get_limit,这是一个宏,定义在sched.h中,定义如下
#define get_limit(segment) ({ \
unsigned long __limit; \
__asm__( "lsll %1,%0\n\tincl %0": "=r" (__limit):"r" (segment)); \
__limit;})
#endif
汇编指令lsll的作用是加载段限长的指令,也就是说这个宏的作用就是根据传递进来的段描述符,获取段的限长,由于获取的值是从0开始计时的,所以还要使用incl指令将结果加1。
首先看code_limit=get_limit(0x0f);段描述符是0x0f,也就是0000 0000 0000 1111。从这里可以看出,使用的是LDT表,并且特权级别是3,在LDT中的索引是1,也就是对应进程的代码段
data_limit=get_limit(0x17)。段描述符是0x17,也就是0000 0000 0001 0111。使用的是LDT表,特权级是3,在LDT表中的索引值是2,也就是进程的数据段。
然后再看get_base的定义。这也是一个宏,定义在sched.h中,定义为
#define get_base(ldt) _get_base( ((char *)&(ldt)) )
它调用另外一个宏,_get_base,对_get_base的定义在sched.h中,定义
#define _get_base(addr) ({\
unsigned long __base; \
__asm__( "movb %3,%%dh\n\t" \
                 "movb %2,%%dl\n\t" \
                 "shll $16,%%edx\n\t" \
                 "movw %1,%%dx" \
                : "=d" (__base) \
                : "m" (*((addr)+2)), \
                 "m" (*((addr)+4)), \
                 "m" (*((addr)+7))); \
__base;})
  对照着翻译一下,汇编代码大致是这个样子(不一定符合AT&A的汇编格式)
movb [addr+7] %dh  
movb [addr+4] %dl
shll $16 %edx
movw [addr+2] %dx
注意在调用_get_base宏的时候,参数是作为一个char*传递进来的,因此addr+7,是将指针位置往后移动了56个bit位。
movb [addr+7] %dh 是定位到64bit位中的第56位,然后取8bit的数,也就是base的24至31位,放在dh寄存器中
同理,movb [addr+4] %dl,是定位到64bit位的第32位,取8bit的数,也就是base的16至32位。执行到这里的时候,dx寄存器中就存放着base的高16位数。然后shll $16,edx,将这高16位数放在edx寄存器的高16位。 movw [addr+2] %dx 。定位到64位中的第16位,并且取16位的数据,也就是获取了base的0到15位,放在dx寄存器中。到这里,就使得在edx寄存器中存放了整个段的基地址

在进程的ldt表中,ldt[1]对应的是进程的代码段,所以old_code_base=get_base(current->ldt[1]);就是获取的当前进程的代码段的基地址

在Linux 内核0.11版本中,代码段和数据段使用的是同一个段的基地址,即对这两个段并没有做严格的区分,而是使用同一个段。这个段的低部分用与代码,这个段的高部分用与数据,在copy_mem函数中从下面几条语句中可以看出,
首先说明进程的代码段和数据段使用的是同一个基地址
new_data_base=new_code_base=nr*0x4000000;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base)
set_base是一个宏,它的作用是将由第二个参数指定的段的基地址,写入到第一个参数指定的段描述符中。从这里的代码可以很容易看出,对于一个进程来说,它使用的代码段和数据段其实是同一个段。而在前面在对当前进程检查时,有这条语句
if(data_limit<code_limit)
     panic("Bad data_limit");
既然是使用同一个段,那么必定有一个在上一个在下。既然要保证data_limit>=code_limit,那么肯定是要代码部分在下,数据部分在上了