全局描述符表GDT(Global Descriptor Table)
在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。
GDTR中存放的是GDT在内存中的基地址和其表长界限。而GDT存放的是段描述符。一般8个字节。在Linux中,【Base Address,Limit,Access】,他们加在一起被放在一个64bit长的数据结构中,被成为段描述符,但是intel为了向后兼容,将段基址寄存器仍然规定为16bit,很明显,我们无法使用16bit长度的段寄存器来引用64bit的段描述符。怎么办呢?解决的办法是把这些长度为64bit的段描述符放入一个数组中,而将段寄存器中的值作为下标索引来间接引用(事实上就是将段寄存器中的高13bit的内容作为索引)。这个全局数据就是GDT。事实上GDT中存放的不仅仅是段描述符,还有其他描述符,他们都是64bit长。而逻辑地址的基地址就是16bit就可以了,不必64bit。
段选择子:
是处理器将索引值乘以8的,不是自己,另外还要右移三位,例如是第二段吧,首先是0000 0000 0000 0000,只用index的话,就是0000 0000 0001 0000最后是0x0010,实际上也是乘以8啊
这是两个4字节,
当我们要访问某个段中的一个地址时候:
1.由GDTR得到段描述符表GDT
2.从段选择子中的前13位得到我们要访问的段的描述符在段描述符表中的索引(Index)(需要考虑TI和RPL)
3.从段描述符表中得到要访问的段的描述符,得到其基地址
4.基地址加上偏移地址就是我们要访问的内存地址(当然这里是虚拟地址,接下来是分页机制的功能将虚地址转换为物理地址,不做讨论。)
我们来看一个例子吧:
给出逻辑地址:33h:12345678h转换为线性地址
1.段选择子SEL=33h=0000000000100 0 01b
前13bit是4,则在GDT中选择第四个描述符;TI=0代表是在GDT中选择,
后边的特权级RPL=1,假设第四个段描述符的基地址为11111111h
2.OFFSET=12345678h,则线性地址为:base+offset=11111111h+12345678h=123456789h
可以看出,逻辑地址的基地址并不是真正的基地址了,而是索引+权限的集合,一共16位。每个逻辑地址由16位的段选择符+32位的偏移量组成。
LDT相当于二级描述符表,存在GDT中,我们还是来看一个例子吧:
给出逻辑地址:37h:12345678h转换为线性地址
1.段选择子:37h=0000000000100 1 01b,最低第三bit是1,在LDT中选择
2.首先,从GDTR中得到GDT表
3.从以LDTR寄存器为GDT的段选择子,用LDTR的前13bit得到LDT在GDT中的索引,得到LDT的基址(假设为4)
4.使用段选择子37h的前13bit在LDT表中得到LDT描述符(假设是3),从而得到LDT段描述符的基址(假设为11111111h)
5:基地址加上偏移地址12345678得到线性地址:23456789h
这里每个LDT是不同的,因此每一个程序都会不同吧,主要是,LDTR肯定不知道的。可能每一个程序的LDTR自动修改。在Linux中,逻辑地址等同于线性地址,在不分页的情况下,线性地址就是物理地址。而且也没有LDT表。这个下面讲:
在保护模式下,80386虚地址空间可达16K个段,每段大小可变,最大达4GB。逻辑地址到线性地址的转换由80386分段机制管理。段寄存器CS、DS、ES、SS、FS或GS各标识一个段。这些段寄存器作为段选择器,用来选择该段的描述符。段选择子,就是这么回事。
前面说了那么多关于分段机制的实现,其实,对于Linux来说,并没有什么卵用。Linux对80386的分段机制使用得很有限,因为Linux的设计目标是支持绝大多数主流的CPU,而很多CPU使用的是RISC体系结构,并没有分段机制,所以为了让 Linux 具有更好的可移植性,最好是去掉段机制而只使用分页机制。但不幸的是,IA32规定段机制是不可禁止的,首先,我们要明确,分段机制是IA32提供的寻址方式,这是硬件层面的。就是说,不管你是windows还是linux,只要使用IA32的CPU访问内存,都要经过MMU的转换流程才能得到物理地址,也就是说必须经过逻辑地址–线性地址–物理地址的转换。因此不可能绕过它直接给出线性地址空间的地址。万般无奈之下,Linux的设计人员干脆让段的基地址为0,而段的界限为4GB,这时任意给出一个偏移量,则等式为“0+偏移量=线性地址”,也就是说“逻辑地址中的偏移量=线性地址”。也就是说,给一个进程4G的内存空间也好,还是给一个段4G的内存空间也好,一般是给一个进程4G空间,说给一个段4G空间也不差,这只是对分段的妥协。所以2.6版内核只有在80x86结构下才使用分段,而且只是象征性地使用了一下,因为Linux基本不使用分段的机制,或者说,Linux中的分段机制只是为了兼容IA32的硬件而设计的。Linux内核的设计并没有全部采用Intel所提供的段方案,仅仅有限度地使用了一下分段机制。这不仅简化了Linux内核的设计,而且为把Linux移植到其他平台创造了条件。由于IA32段机制还规定,必须为代码段和数据段创建不同的段,所以Linux必须为代码段和数据段分别创建一个基地址为0,段界限为4GB的段描述符。不仅如此,由于Linux内核运行在特权级0,而用户程序运行在特权级别3,根据IA32段保护机制规定,特权级3的程序是无法访问特权级为0的段的,所以Linux必须为内核用户程序分别创建其代码段和数据段。这就意味着Linux必须创建4个段描述符——特权级0的代码段和数据段,特权级3的代码段和数据段,下表显示了这四个重要段的段描述符字段的值:
看见没,都是0,相应的段描述符由宏__USER_CS,__USER_DS,__KERNEL_CS,和__KERNEL_DS分别定义。例如,为了对内核代码段寻址,内核只需要把这个宏产生的值装进cs段寄存器即可。 注意,与段相关的线性地址从0开始,达到232 -1的寻址限长。这就意味着在用户态或内核态下的所有进程可以使用相同的逻辑地址。所有段都从0x00000000开始,这可以得出另一个重要结论,那就是在Linux下逻辑地址与线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。Linux的进程中数据段和代码段都是0开始的,都能扩展到4GB,根据其他的不同在页表中区分开。每个段的页表都不同,在线性转物理地址的时候,它能知道这是哪一个段的页表,从而进行转换,页表是实现虚拟内存映射的重要机制,就算不分页,它也是需要进行映射处理的。在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果PG=0,则分页机制无效,线性地址就直接做为物理地址。逻辑地址直接变成物理地址。基本上就是一页4K。
内核态地址对应的相关页表项,对于所有进程来说都是相同的(因为内核空间对所有进程来说都是共享的),而这部分页表内容其实就来源于“内核页表”,即每个进程的“进程页表”中内核态地址相关的页表项都是“内核页表”的一个拷贝。用户进程会进入内核从而访问内核。这就是系统调用。
页目录表的大小为4k(刚好是一个页的大小),包含1024项,每个项4字节(32位),项目里存储的内容就是页表的物理地址。如果页目录表中的页表尚未分配,则物理地址填0。
页表的大小也是4k,同样包含1024项,每个项4字节,内容为最终物理页的物理内存起始地址。可以说除了低一级,就没什么区别。页表内容是不连续的,因为页肯定是不连续的,这是它的意义,而页目录表连续不连续,就要看页表连续不连续,一般是不连续的,那么页目录表就是不连续的,一般一个进程一个页目录表,因为页表肯定是依附进程的。每个活动的任务,必须要先分配给它一个页目录表,并把页目录表的物理地址存入cr3寄存器。页表可以提前分配好,也可以在用到的时候再分配。Linux
采用了四级页表来管理内存页,多级页表就是把内存分成区块来管理,将原来的映射关系改成区块索引和区块内的偏移。由于虚拟内存空间通常只用了很少一部分,那么,多级页表就只保存这些使用中的区块,这样就可以大大地减少页表的项数。
举个例子:
线性地址 0x80495b0 转换成二进制后是 0000 1000 0000 0100 1001 0101 1011 0000,
页目录表索引 页表索引 偏移量
最高10位0000 1000 00的十进制是32,CPU查看页目录表第32项,里面存放的是页表的物理地址。线性地址中间10位00 0100 1001 的十进制是73,页表的第73项存储的是最终物理页的物理起始地址。物理页基地址加上线性地址中最低12位的偏移量,CPU就找到了线性地址最终对应的物理内存单元。
虚拟地址转换是用页机制完成的,虚拟地址分布如下:
这解释了为什么程序代码会从0开始。