Linux内核(一)内存管理

Linux内存管理

1. 进程被分配的内存都是虚拟内存,看起来好像是由1GB内核使用的内存+3GB自己可以使用的内存构成。

2. 全局描述符表GDT(Global Descriptor Table)在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。GDTR中存放的是GDT在内存中的基地址和其表长界限。而GDT存放的是段描述符。一般8个字节。

3. 在Linux中,逻辑地址等同于线性地址,在不分页的情况下,线性地址就是物理地址。

4. 在保护方式下,段的特征有以下三个:段基址,段限长,段属性。这三个特征存储在段描述符(segmentdescriptor)之中(8字节),用以实现从逻辑地址到线性地址的转换。段描述符存储在段描述符表之中,通常,我们使用 段选择符(序号索引) 定位 段描述符 在这个表中的位置。每个逻辑地址由16位的段选择符+32位的偏移量组成。

  段基址规定线性地址空间中段的开始地址。在保护模式下,段基地址长32位。因为基地址长度与寻址地址的长度相同(都是32位),所以段基地址可以是 0~4GB 范围内的任意地址,而不像实方式下规定的边界必须被16整除(因为在组合时乘了一个F)。不过,还是建议应当选取那些 16 字节对齐的地址。尽管对于 Intel 处理器来说,允许不对齐的地址,但是,对齐能够使程序在访问代码和数据时的性能最大化。最多可以包含8K个这样的描述符(因为段选择子(索引寄存器)是16位的,其中的13bit用来作index,就是这么规定的)。

  段界限规定段的大小。在保护模式下,段界限用20位表示(4G),而且段界限可以是以字节为单位或以4K字节为单位。偏移量是从 0 开始递增,段界限决定了偏移量的最大值。对于向下扩展的段,如堆栈段来说, 段界限决定了偏移量的最小值。

5. 每个段都需要一个描述符。为了存放这些描述符,需要在内存中开辟出一段空间。在这段空间里,所有的描述符都是挨在一起,集中存放的,这就构成一个描述符表。GDT是全局的,LDT是局部的。GDT存哪里都行,它不是一个段,但LDT是一个LDT类型的段,被GDT收录,因此GDT里存的是LDT。那么GDTR与LDTR功能就不尽相同,GDTR是一个常量,起到存储作用,LDTR是一个变量,起到缓存作用。你可以直接使用GDT,也可以先构造一个LDT,使用LDT。GDT本身不是一个段,而是线性地址空间的一个数据结构;因为每个描述符长度是8,所以GDT的基地址最好进行8字节对齐。

6. 在实模式下叫段寄存器,在保护模式下就叫段选择器了,也叫段选择器,因为存的只是GDT表的序号。段选择符也叫段选择子,16位,就是一个寄存器,它只有13bit可用,剩下的是判断是否在LDT中(TI=1)和请求特权级RPL,我们把这个寄存器送入就会找到那8字节地址,这个地址直接索引号*8就是偏移地址了,从而直接使用段也好,还是使用LDT也好。

7. LDT的段描述符与普通段描述符没有什么两样,都是8字节的。一个段基地址打头,剩下的就是段界限,控制命令什么的。

8. 因为Linux的逻辑地址等于线性地址,什么意思呢,就是段不起作用,实际上,任何段都是从0开始的,这叫虚拟内存,既然段基地址是0,那么线性地址与逻辑地址就没有什么区别,逻辑地址是线性地址的偏移地址部分。更多的,Linux就不会使用LDT,因为基址都是0没什么好存的。

9. GDT的第12和13项段描述符是 __KERNEL_CS 和__KERNEL_DS,第14和15项段描述符是 __USER_CS 和__USER_DS。内核任务使用__KERNEL_CS 和__KERNEL_DS,所有的用户任务共用__USER_CS 和__USER_DS,也就是说不需要给每个任务再单独分配段描述符。内核段描述符和用户段描述符虽然起始线性地址和长度都一样,但DPL(描述符特权级)是不一样的。__KERNEL_CS 和__KERNEL_DS 的DPL值为0(最高特权),__USER_CS 和__USER_DS的DPL值为3。共用也代表基址为0.GDT几乎是固定的。

10. 在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果PG=0,则分页机制无效,线性地址就直接做为物理地址。逻辑地址直接变成物理地址。基本上就是一页4K。

11. 当前任务的线性地址转物理地址的查找表,即页表(page table)。它自然是一一对应的,有那么多页,它们是不连续的,但是一一对应的,这些页的地址存在页表中,线性地址操作一番后,就可以找到物理地址了。那页目录表是可能页表也会超过4k所作。

12. 

32位的线性地址被分成3个部分:

最高10位 Directory 页目录表偏移量(index),中间10位 Table是页表偏移量(index),最低12位Offset是物理页内的字节偏移量(index)。因为2的12次方就是4K,低12位能分割出来实属正常。这样分割是没错的。因为如果页表超过4k怎么办呢,是吧。

13. 页目录表的大小为4k(刚好是一个页的大小),包含1024项,每个项4字节(32位),项目里存储的内容就是页表的物理地址。如果页目录表中的页表尚未分配,则物理地址填0。

14. 页表的大小也是4k,同样包含1024项,每个项4字节,内容为最终物理页的物理内存起始地址。可以说除了低一级,就没什么区别。页表内容是不连续的,因为页肯定是不连续的,这是它的意义,而页目录表连续不连续,就要看页表连续不连续,一般是不连续的,那么页目录表就是不连续的,一般一个进程一个页目录表,因为页表肯定是依附进程的。每个活动的任务,必须要先分配给它一个页目录表,并把页目录表的物理地址存入cr3寄存器。页表可以提前分配好,也可以在用到的时候再分配

15. 线性地址 0x80495b0 转换成二进制后是 0000 1000 0000 0100 1001 0101 1011 0000,最高10位0000 1000 00的十进制是32,CPU查看页目录表第32项,里面存放的是页表的物理地址。线性地址中间10位00 0100 1001 的十进制是73,页表的第73项存储的是最终物理页的物理起始地址。物理页基地址加上线性地址中最低12位的偏移量,CPU就找到了线性地址最终对应的理内存单元

16. Linux自然不会给每个进程都真的分配4G的内存,也没有那么多的内存,Linux利用CPU的一个机制解决了这个问题。进程创建后我们可以给页目录表的表项值都填0,CPU在查找页表时,如果表项的内容为0,则会引发一个缺页异常,进程暂停执行,Linux内核这时候可以通过一系列复杂的算法给分配一个物理页,并把物理页的地址填入表项中,进程再恢复执行。当然进程在这个过程中是被蒙蔽的,它自己的感觉还是正常访问到了物理内存。完全黑箱操作,临时分配。

17. 之所以说每个段的段基址都是0,那是因为每个段,都分配了4G的内存。Linux没有LDT,GDT里也都是0,逻辑地址完全就是线性地址,完全是通过页目录表和页表的方式,从以0开头的方便逻辑地址转换为实际开辟的物理地址。GDT存在的意义仅仅是为了标注控制符号。如果说有LDT的系统,它的GDT可以存段符也可以存LDT,仅凭段符的转换就可以直接作用与实际内存。而Linux不行,甭管分页不分页,它的页目录表也是必须存在的。用来指示起始地址不分页的话页表就没了,就一个页目录表,有点名不副实呢。一个段4个G,一个段一个页表,页表刚好4K,它能存1024*4K=4096K,刚好4G啊。一个页表就能表示一个段。一个进程一个页目录表。。。还是说一个进程分配4G。是一个进程4G,每一个段在其中:

那么段基址为0是怎么解释的呢?也就是说,对于一个进程的段基址,必然不是0.

但是基址就是0.。。。仅仅特权级和种类不同。。。因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 开始,长度4G,那么。。。有矛盾了。先看下面的吧。

18. 伙伴系统每次分配内存都是以页(4KB)为单位的,但系统运行的时候使用的绝大部分的数据结构都是很小的,为一个小对象分配4KB显然是不划算了。Linux中使用slab来解决小对象的分配。在linux内核中伙伴系统用来管理物理内存,其分配的单位是页,但是向用户程序一样,内核也需要动态分配内存,而伙伴系统分配的粒度又太大。由于内核无法借助标准的C库,因而需要别的手段来实现内核中动态内存的分配管理,linux采用的是slab分配器。slab分配器不仅可以提供动态内存的管理功能,而且可以作为经常分配并释放的内存的缓存。通过slab缓存,内核能够储备一些对象,供后续使用。需要注意的是slab分配器只管理内核的常规地址空间(准确的说是直接被映射到内核地址空间的那部分内存包括ZONE_NORMAL和ZONE_DMA)。

19. 采用了slab分配器后,在释放内存时,slab分配器将释放的内存块保存在一个列表中,而不是返回给伙伴系统。在下一次内核申请同样类型的对象时,会使用该列表中的内存开。提供小块内存的分配支持,不必每次申请释放都和伙伴系统打交道,提供了分配释放效率,如果在slab缓存的话,其在CPU高速缓存的概率也会较高。伙伴系统的操作队系统的数据和指令高速缓存有影响,slab分配器降低了这种副作用。伙伴系统分配的页地址都页的倍数,这对CPU的高速缓存的利用有负面影响,slab分配器通过着色使得slab对象能够均匀的使用高速缓存,提高高速缓存的利用率,slab分配器并没有脱离伙伴系统,而是基于伙伴系统分配的大内存进一步细分成小内存分配。伙伴系统分配出一块区域(通常有几个页的大小)给slab。slab在将其分出若干个object,而管理这些的数据结构是struct kmem_cache。

20. 使用struct kmem_cache结构描述的一段内存就称作一个slab缓存池。一个slab缓存池就像是一箱牛奶,一箱牛奶中有很多瓶牛奶,每瓶牛奶就是一个object。分配内存的时候,就相当于从牛奶箱中拿一瓶。总有拿完的一天。当箱子空的时候,你就需要去超市再买一箱回来。超市就相当于partial链表,超市存储着很多箱牛奶。如果超市也卖完了,自然就要从厂家进货,然后出售给你。厂家就相当于伙伴系统。

21. slub把内存分组管理,每个组分别包含2^3、2^4、...2^11个字节,在4K页大小的默认情况下,另外还有两个特殊的组,分别是96B和192B,共11组。之所以这样分配是因为如果申请2^12B大小的内存,就可以使用伙伴系统提供的接口直接申请一个完整的页面即可。

kmalloc_caches代表的是分类,并不是仅仅一个页大小,不仍然8个字节连自己都存不了,实际上应该是管了一连串的页,它们只分配出8字节的object才对。

其中的每一份都是一个kmem_cache,   一切的一切源于kmalloc_caches[12]这个数组,该数组的定义如下:

struct kmem_cache kmalloc_caches[PAGE_SHIFT] __cacheline_aligned;

可以把一个kmem_cache结构体看做是一个特定大小内存的零售商,整个slub系统中共有12个这样的零售商,每个“零售商”只“零售”特定大小的内存,例如:有的“零售商”只"零售"8Byte大小的内存,有的只”零售“16Byte大小的内存。

每个零售商(kmem_cache)有两个“部门”,一个是“仓库”:kmem_cache_node,一个“营业厅”:kmem_cache_cpu。“营业厅”里只保留一个slab,只有在营业厅(kmem_cache_cpu)中没有空闲内存的情况下才会从仓库中换出其他的slab。

  所谓slab就是零售商(kmem_cache)批发的连续的整页内存,零售商把这些整页的内存分成许多小内存,然后分别“零售”出去,一个slab可能包含多个连续的内存页。slab的大小和零售商有关。它被kmem_cache_node和kmem_cache_cpu控制着。

22. 在实际需要某个虚拟内存区域的数据之前,和物理内存之间的映射关系不会建立。如果进程访问的虚拟地址空间部分尚未与页帧关联,处理器自动引发一个缺页异常。

23. 在8086处理器诞生之前,内存寻址方式就是直接访问物理地址。8086处理器为了寻址1M的内存空间,把地址总线扩展到了20位。但是,一个尴尬的问题出现了,ALU的宽度只有16位,也就是说,ALU不能计算20位的地址。为了解决这个问题,分段机制被引入,登上了历史舞台。也就是说,到了32位时代,由于寄存器能直接访问内存任何一个地方,实际上就不用分段了。但是分段有另外一个好处,那就是所有偏移地址都从0开始,简化编程,如果这样的话,等到了32位,偏移地址就必须指出其实际物理地址,也就是不再是从零开始(实际上也不叫偏移地址了),因此这就是虚拟地址转换的重要原因之一,虚拟地址转换不仅可以让应用程序感觉到它在独占整个4G段,而且还从零开始,摆脱了写死物理地址的问题。

24. 在IA32的保护模式下,这个逻辑地址不是被直接送到内存总线而是被送到内存管理单元(MMU)。MMU由一个或一组芯片组成,其功能是把逻辑地址映射为物理地址,即进行地址转换。在Linux上的分段,仅仅是为了清晰程序结构的作用。IA32中有六个16位段寄存器:CS, DS, SS, ES,FS, GS.跟8086的段寄存器不同的是,这些寄存器存放的不再是某个段的基地址,而是某个段的选择符(Selector)。

25. 全局描述符表GDT,包含着系统中所有任务都共用的那些段的描述符。 它的第一个8字节位置没有使用。

局部描述符表LDT,包含了与一个给定任务有关的描述符,每个任务各自有一个的LDT。 有了LDT,就可以使给定任务的代码、 数据与别的任务相隔离。每一个任务的局部描述符表LDT本身也用一个描述符来表示,称为LDT描述符,它包含了有关局部描述符表的信息,被放在全局描述符表GDT中。

中断描述符表IDT,包含256个门描述符。IDT中只能包含任务门、中断门和陷阱门描述符,虽然IDT表最长也可以为64K字节,但只能存取2K字节以内的描述符,即256个描述符,这个数字是为了和8086保持兼容。

首先,我们要明确,分段机制是IA32提供的寻址方式,这是硬件层面的。就是说,不管你是windows还是linux,只要使用IA32的CPU访问内存,都要经过MMU的转换流程才能得到物理地址,也就是说必须经过逻辑地址–线性地址–物理地址的转换。前面说了那么多关于分段机制的实现,其实,对于Linux来说,并没有什么卵用。因为,Linux基本不使用分段的机制,或者说,Linux中的分段机制只是为了兼容IA32的硬件而设计的。Linux内核的设计并没有全部采用Intel所提供的段方案,仅仅有限度地使用了一下分段机制。这不仅简化了Linux内核的设计,而且为把Linux移植到其他平台创造了条件,因为很多RISC处理器并不支持段机制。

26. 在 IA32 上任意给出的地址都是一个虚拟地址,即任意一个地址都是通过“选择符:偏移量”的方式给出的,这是段机制存访问模式的基本特点。所以在IA32上设计操作系统时无法回避使用段机制。一个虚拟地址最终会通过“段基地址+偏移量”的方式转化为一个线性地址。 但是,由于绝大多数硬件平台都不支持段机制,只支持分页机制,所以为了让 Linux 具有更好的可移植性,我们需要去掉段机制而只使用分页机制。但不幸的是,IA32规定段机制是不可禁止的,因此不可能绕过它直接给出线性地址空间的地址。万般无奈之下,Linux的设计人员干脆让段的基地址为0,而段的界限为4GB,这时任意给出一个偏移量,则等式为“0+偏移量=线性地址”,也就是说“偏移量=线性地址”。也就是说,给一个进程4G的内存空间也好,还是给一个段4G的内存空间也好,一般是给一个进程4G空间,说给一个段4G空间也不差,这只是对分段的妥协。

27. 另外,由于IA32段机制还规定,必须为代码段和数据段创建不同的段,所以Linux必须为代码段和数据段分别创建一个基地址为0,段界限为4GB的段描述符。不仅如此,由于Linux内核运行在特权级0,而用户程序运行在特权级别3,根据IA32段保护机制规定,特权级3的程序是无法访问特权级为0的段的,所以Linux必须为内核用户程序分别创建其代码段和数据段。这就意味着Linux必须创建4个段描述符——特权级0的代码段和数据段,特权级3的代码段和数据段。

28. 关于逻辑地址从0开始的问题,是因为每个段的页表都不同,在线性转物理地址的时候,它能知道这是哪一个段的页表,从而进行转换,页表是实现虚拟内存映射的重要机制,就算2不分页,它也是需要进行映射处理的。

29. 

内核页表中的内容为所有进程共享,每个进程都有自己的“进程页表”,“进程页表”中映射的线性地址包括两部分:

用户态

内核态

其中,内核态地址对应的相关页表项,对于所有进程来说都是相同的(因为内核空间对所有进程来说都是共享的),而这部分页表内容其实就来源于“内核页表”,即每个进程的“进程页表”中内核态地址相关的页表项都是“内核页表”的一个拷贝。用户进程会进入内核从而访问内核。这就是系统调用。

妙啊。。。

发布了163 篇原创文章 · 获赞 20 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/HeroIsUseless/article/details/105714200