摘自:http://xanpeng.github.com/linux/2012/05/31/linux-memory-management.html
Linux中memory management是一个很大的话题. 我几番尝试去更好地理解它:
[02-20] memory - 内容不足, 删除
[03-01] linux 虚拟内存, 地址空间布局, page cache, ...
[03-09] linux 物理内存管理
[03-10] 由malloc函数引发的内存"览胜"思考
[03-10] linux 内存管理的一些问题
[04-16] 内存屏障(memory barrier) - 内容不足, 删除
[05-04] Linux cached memory
这些post多是学习网络资料所得. 经历许久之后, 现在我仍然不能回答: 在Linux下面, malloc最多能分配多少内存, 为什么?
现在, 我打算来终结这个问题. 毕竟内存管理是已有的, 非未知的, 因此必然是可得到答案的.
我将阅读:
1. 深入Linux内核架构: Memory Management
2. Linux内核情景分析: 存储管理
3. Linux内核设计与实现: 内存管理
4. Linux系统编程: 内存管理
5. Understanding Linux Kernel: Memory Addressing, Memory Management
在这两天的空闲里, 我阅读了<Linux内核设计与实现>里的内存管理章节, 也看了 ULK 的内存章节, 还是不能建立全盘的理解. 经过反复阅读后者, 终能理解内存管理的概要!
<Linux内核情景分析>这本书讲解十分细致, 我觉得可能过于拘泥于细节, 而忽略了原理根本, 因此十分不适合初学者阅读. 而对于熟识者, 是否还需要阅读这样的书籍, 我也存有疑问, 毕竟熟识者完全可以自己阅读代码, 比观看他人的"代码解读"要来的容易.
文章写下来, 感觉有些罗嗦, 但实际上很能帮助我理解, 确切地把结论写出来意义不大, 把中间的思考过程写出来更加重要, 于是, 我坚持"罗嗦"吧.
前言
我们都知道, 个人电脑的硬件一般都是32位的, 对应地要安装32位的OS. 而服务器一般都是64位的, 对应地要安装64位的OS. 32位最为我们常见, 一般的书籍里面讲解地址空间, 内存布局等的时候, 也都是以32位作为例子的.
1. 比如在32位Linux下, 地址空间大小是4G, "内核地址空间:用户地址空间"一般是1:3.
2. 32位Linux下, 会有所谓的高端内存(highmem). 而64位下就不会有高端内存, 因为内核地址空间已经很大了, 现在绝不会有这么大的物理内存.
为了简单起见, 下面讨论的内容在非特指时均对应32位Linux的情况.
页框
内存制造商生产出来的内存是一个实际的物体.
Linux并不能"自动地"地去理解它, 并能够立即去访问它. 如果是其它的设备, 如即插即用设备, 我们不妨认为是设备驱动帮助内核去理解设备, 这个过程是需要有内存支持的, 内核必需位于内存中, 并已被执行. 但是, 如果要处理内存本身, 怎么办? 也就是说, 现在面对一个有CPU, 内存, 键鼠以及安装有Linux的硬盘的机器, 它是如何能够发现内存, 并将内存加入管理, 然后正常启动系统, 准备工作的?
这就是BIOS做的事情了, BIOS会在开机时发现内存并汇报. 这一部分了解到此即可, 不要过于细致. 我们需要关注的重点是: 内核是如何管理内存的.
要管理内存, 内核必然是要先去描述它. 物理内存的最小单位是 byte, 物理内存是每个 byte 都可以寻址的. 比如在 C 里面是有位段的语义的, 就是把多个 bit 大小的变量存放在一个 byte 里面, 以节省空间; 又比如定义一个字符数组, 对其中每个成员都是可以寻址的. 这些都说明按 byte 寻址的特性.
既然已经如此, 内核就没必要将物理内存抽象为很多个 byte 的集合, 这样过于细致. 内核是将物理内存抽象为多个页框(page frame)的集合, 每个页框大小一般为 4KB. 内核用 struct page
描述每一个页框, 这个结构里面包含多个字段, 比如 _count 表示该页框是否空闲, flags 表示该页框的属性, 可以通过设置 flags 设置对页框的访问权限, 执行权限等, 如存放有程序机器码的页不能被修改.
内核在运行过程之中, 必须要跟踪每一个页框的状态, 跟踪它们是否空闲, 是否包含内核代码, 还是包含内核数据等. 这是通过 mem_map 数据记录的, 这个数组里面包含物理内存所有页框的 page 结构(又称为页描述符), 每一个 page 结构大小是 32 字节, 整个 mem_map 数组占据的空间不足物理内存的 1%.
地址转换
内核并不直接操作物理内存, 而是操作虚拟内存. 虚拟内存是Linux内核至关重要的一个机制, 它最重要的作用是提供了抽象机制, 让用户态程序和内核程序都无需直接面对内存.
所以, Linux中的用到的地址都是"虚拟地址", 要通过转换之后, 才得到真正的内存的物理地址, 从而访问到真正的物理页框.
实际上, 不光Linux如此, Windows操作系统也是这样的. 甚至硬件层就提供了对"虚拟地址"这一抽象概念的支持, 如分段单元和分页单元, 段选择符和段寄存器等(参考ULK第二章), 这些硬件支持是为了地址转换设定的.
ULK第二章指出, 使用80x86 CPU时, 我们必需区分三种地址:
1. 逻辑地址(logical address): 由段(segment)和偏移量组成.
2. 线性地址(linear address): 也称虚拟地址(virtual address), 这就是Linux中经常接触的地址形式了.
3. 物理地址(physical address): 最终转化位硬件方式访问对应的物理内存了.
80x86 CPU在硬件上对内存就是按分段方式处理的(不知道是否可以这么理解), 其原因是为了将不同的数据放到不同的内存段中, 比如代码放入代码段, 数据放入数据段. 我不知道这么做到底有什么好处, 但 Linux 实际上是不鸟它的, Linux 内核通过设置一些寄存器, 将整个内存设置为仅有一个段, 直接绕过了分段机制.
Linux 这么做, 是因为它更看中分页机制. 硬件也有对分页的支持, 分段和分页在功能上有一些重复, 这不由让我轻浮地先说一句"真是蛋疼".
分页单元将线性地址转化为物理地址, 分页机制就是建立线性地址和物理地址的映射, 将每一个地址的32位拆分处理, 比如前10位用作一级索引, 接着的10位用作二级索引, 剩下的12位表示页框内的偏移. 这就意味着, 前面10位查出来的结果是判定用哪一个页表, 10-20位查出来的结果是判定用哪一个页框, 后面的12位用来判定具体操作哪一个位置.
这么分页的好处: 自然是为了加快查询/转换速度.
而在Linux内核中, 支持更多级别的地址处理, 比如五级: page global directory, page upper directory, page middle directory, page table, offset. 其处理方式是类似的.
我们就是根据页表找到线性地址对应的物理地址的, 页表是要存储在内存中的. 在Linux中, 每一个进程都有自己独立的地址空间, 也就都有自己独立的页表, context switch 的时候是要切换页表的. 页表是被建立的, 如果每一个进程都要在内存中建立整个地址空间的页表, 那么是耗时耗内存的, 所以一般是在内存中建立需要的页表 ---- 而这就是分级带来的好处. 比如三级结构中, 如只需建立 page global directory 中 #128 指向的页表.
内核地址空间
内核地址空间是虚拟地址的3G-4G部分, 内核代码产生并处理位于这个区间的地址, 假设物理内存有4G, 其中0-896MB可以直接映射到内核虚拟地址空间, 但物理内存>896MB部分怎么办呢? 这就是所谓的高端内存管理做的事情了, 这些高端内存并不映射到内核地址空间, 因此, 内核不能直接访问它们.
不妨先来看两张示意图, 第一个图片就是常见的虚拟内存布局, 或者说虚拟地址空间布局; 第二个图片是"少见的"物理内存布局. 从中可以看到, 物理内存是从低向高分配的, 物理内存的低位1GB都有特别的用途, 比如前面提到的 mem_map 就是放在这里. 具体细节后文物理内存管理部分会提及.
NUMA & Zone
Linux 2.6开始支持 Non-Uniform Memory Access(NUMA) 模型, CPU 访问内存的不同位置耗费的时间可以是不一样的.
内核还对内存做了分区, 分成三个区: ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM. ZONE_DMA 的内存范围是0~16MB, ZONE_NORMAL 的内存范围是16~896MB, ZONE_HIGHMEM 的内存范围是 >896MB.
顾名思义, ZONE_DMA 是为了 DMA 设定了, 一般供硬件设备使用, 因为某些硬件设备不使用内存映射机制, 而是需要直接访问内存.
ZONE_NORMAL,ZONE_DMA 包含的页框都是内核可以直接访问的, 当然是先经过简单的线性映射, 而 ZONE_HIGHMEM 包含的页框不能经由同样的线性映射被内核直接访问, 这就是所谓的高端内存了.
当然, ZONE_HIGHMEM 是可能不存在的, 比如前几年, 个人电脑的内存一般只有256M,512M这样, 根本就没有高端内存了. 另外, 前文提到, 在64位机器上也是没有高端内存的.
Linux 将内存分区的目的是直白的, 细分之后, 对于不同的内存请求, 可以在不同的分区里分配页框.
高端内存页框的内核映射
至此, 我们已经了解到, Linux 将物理内存划分为页框, 建立起 NUMA 的概念, 并将内存分区. 并且内核提供了虚拟内存这一抽象层, 内核地址空间只有1G大小, 只能映射1G大小的物理内存, >896M的内存属于高端内存, 需要特别的处理方法.
内核肯定要能访问所有的物理内存的, 因为物理内存分配这么重要的工作不是由用户程序完成的, 而是必须通过系统调用由内核代劳. 那么内核到底如何访问高端内存的呢?
低端内存的内核映射
在讲高端内存之前, 先来细致地看看"低端内存"是如何被内核访问的. 高端内存在32位机器下是指>896MB的物理内存, 低端内存指的就是<896MB的物理内存, 也就是 ZONE_DMA+ZONE_NORMAL.
在前文讲解分页的时候提到, 进程的线性地址(也就是虚拟地址)被分成三块, 第一块用来描述页全局目录, 包含10位, 能表示1024项. 页全局目录的前768项(能表示3G的虚拟地址空间, 用以表示虚拟地址空间中用户态的那一部分)的虚拟地址小于0xc0000000. 余下的表项对所有进程来说都是相同, 它们等于主内核页全局目录的相应表项.
主内核页全局目录(master kernel page global directory)是内核中的概念(不知道怎么去理解和解释它), 前面提到, 内核中使用的地址也是虚拟地址, 内核维护了一组自己使用的页表, 这些页表就驻留在主内核全局目录中.
由内核页表提供的最终映射必须能把从 0xc0000000 开始的虚拟地址转换为从 0 开始的物理地址. 因而可以认为, 0xc0000000开始的8996MB虚拟地址空间是线性映射到物理内存的前896MB的, 线性映射是指: 虚拟地址=0xc0000000+物理地址.
896M
你一定很好奇, 我们明明说到虚拟地址空间的最高1G是内核地址空间, 但是为什么高端内存是从896MB开始的呢? 通过简单的线性映射(加减偏移量), 1G虚拟地址是当然是可以映射1G物理地址的阿?
这是因为虚拟地址的最高 128MB 留给其他映射方式使用了(下文会提及, 可以猜想用途之一是高端内存管理), 1G-128M=896M. 这就是为什么.
三种映射机制
896MB 边界以上的页框并不映射在内核线性地址空间的第4个GB, 因此, 内核不能直接访问它们. 这意味着返回所分配页框线性地址的页分配器函数不适用于高端内存. 高端内存页框的分配只能通过 alloc_pages/alloc_page 搞定, 这些函数不返回第一个被分配页框的虚拟地址, 因为如果该页框属于高端内存, 那么这样的虚拟地址根本不存在. 这些函数返回的是第一个被分配页框的 struct page*.
内核虚拟地址空间的最后 128MB 的一部分专门用于映射高端内存页框. 当然这种映射不是固定的, 这一部分虚拟地址可以被重复利用, 使得整个高端内存能够在不同的时间被访问.
内核提供三种不同的映射机制将页框映射到高端内存: 永久内核映射, 临时内核映射和非连续内存分配.
这些技术中没有一种可以确保对整个 RAM 同时进行寻址, 毕竟只有 128MB 虚拟地址留给映射高端内存.
下图显示了内核虚拟地址空间的布局, 主要强调这里说的高端内存的映射机制.
永久映射
永久内核映射允许内核建立高端内存页框到内核[虚拟]地址空间的长期映射. 它们使用主内核页表中一个专门的页表, 地址存放在 pkmap_page_table 变量中, 页表项是512项或者1024项(取决于 PAE 是否被激活), 因此内核一次最多访问2MB或4MB的高端内存.
kmap, kunmap 分别用来建立和取消高端内存的永久内核映射, 其本质无非是关联,取消关联高端内存页框和有限的那么一点内核虚拟地址.
临时映射
Linux 在内核地址空间中预留了很少的页表项, 以用作高端内存的临时映射. 内核使用函数 kmap_atomic, kunmap_atomic 建立和取消临时映射.
临时映射的目的是为中断处理程序等函数提供分配高端内存的方式, 因为临时映射从不阻塞当前进程, 而永久映射可能阻塞进程, 因而不能用于中断处理程序等.
非连续内存区管理
非连续内存管理是指用连续的内核虚拟地址来访问非连续的物理页框.
内核用 vm_struct 结构表示每个非连续的物理内存区段, 所有 vm_struct 组成一个链表, 链表的第一个元素放在 vmlist 变量中. 调用 vmalloc() 函数给内核分配一个非连续内存区, 其主要工作是分配一组连续的内核虚拟地址, 然后分配一组非连续的页框映射到这些地址.
此时, 还需要做关键的一步, 就是修改内核使用的页表项, 以此表明分配给非连续内存区的每个页框现在对应着一个虚拟地址. 这个工作通过 map_vm_area() 做到.
用于非连续内存区保留的内核虚拟地址空间的起始位置是 VMALLOC_START, 末尾地址是 VMALLOC_END.
内存分配和伙伴算法
至此, 我们已经大致了解, 低端内存和高端内存大致是如何映射到内核虚拟地址空间的. 很多细节, 如页表的某些变量可以用来表示页表的内容类型, 是否被占用等, 被本文放弃, 但仍不妨碍我们理解内核管理内存的纲领.
处理内存分配请求的是被称为分区页框分配器(zoned page frame allocator)的内核子系统. 其主要组成如图:
其中的"管理区分配器"接受动态内存分配和释放的请求, 所谓动态内存, 是指除被永久占有的内存(如用来存放内核代码和数据的内存)之外的那些内存. 这个分配器从 ZONE_HIGHMEM, ZONE_NORMAL, ZONE_DMA 中搜索一个能满足请求的管理区, 然后在管理区里面申请内存. 一般的搜索顺序是 HIGHMEM > NORMAL > DMA.
而每一个内存管理区里面, 由称之为"伙伴系统"的模块去真正地分配内存. 内核应该为分配一组连续的页框建立一种健壮,高效的分配策略, 能解决著名的内存管理问题, 也就是所谓的外部碎片(external fragmentation)问题. Linux 采用的算法就是著名的伙伴系统(buddy system)算法.
描述伙伴算法如下:
内核把所有空闲页框分组为 11 个块链表, 每个块链表分别包含大小为 1,2,4,8,16,32,64,128,256,512,1024 个连续的页框.
假设要请求一个 256 个页框的块(即1MB), 伙伴算法先在第9个链表, 即256个页框大小的块链表中检查是否有一个空闲块, 如果有则可分配并返回; 如果没有, 则算法查找下一个更大的块, 也就是512个页框大小的块链表. 如果找到, 则内核把一个512的块分成两份, 一份分配给请求者, 另一份256页框插入到256的链表. 依此递推, 如果最终1024大小的块链表都不能满足要求, 算法就放弃并产生出错信号.
这个过程的逆就是伙伴算法的回收过程, 在释放页框时, 伙伴算法将页框插入到对应的块链表, 然后检查这个链表中是否有相邻的空闲块, 如果有则合并它们"两 buddy", 然后将其插入到下一个更大的链表中去.
上面简述了伙伴算法, 还是很好理解的. 当然细节待后续添加.
高速缓存和slab分配器
伙伴算法适合处理大块内存的请求, 而对于小内存的请求, 伙伴算法是不合适的, 比如经常申请和释放几十数百个字节的数据, 使用伙伴算法就会产生很多的内部碎片.
这就引出了高速缓存和 slab 分配器.
内核建立了高速缓存的概念, 可为每个类型的数据申请一个高速缓存区, 高速缓存中包含 slab, 每个 slab 由一个或者多个连续的页框组成, slab 中存放对象. 比如 inode 高速缓存, 在申请新的 inode 结构时, 内核从 slab 中取出一个合法的 inode 对象即可, 用完之后设置标志, 放回 slab, 无需释放内存. 这样就节省了大把的内存申请和释放的时间, 并且多个对象可以位于一个页框之中, 从而减少了内部碎片.
我在"linux 物理内存管理"那篇文章中也简述了 slab 分配机制, 里面有一张不错的示意图. 这里仍然不讨论具体细节了. 仅来了解一下著名的 slab 着色机制.
同一硬件高速缓存行可以映射 RAM 中的多个不同块, 相同大小的对象倾向于存放在高速缓存内相同的偏移量处, 在不同的 slab 中具有相同偏移量的对象最终很可能映射在同一高速缓存行中, 高速缓存的硬件可能因此而花费内存周期在同一高速缓存行与 RAM 内存单元之间来来往往传送两个对象, 而其他高速缓存行并未充分利用. slab 着色(slab coloring)就是为了尽量降低高速缓存的这种不愉快行为.
slab 着色将称为颜色(color)的不同随机数分配给 slab, 从而影响缓存行为. ---- 着色这块内容纯为摘抄, 我没有去试着理解更多.
保留页框池和"内存池"
这二者不是同一个概念, 但是这二者都是为了某些特殊目的而存在的. 本文仅打算非常有限地理解这二者.
内核中有少量的"保留页框池", 只能用于满足中断处理程序或内部临界区发出的原子内存分配请求.
内存池是动态内存的储备, 只能被特定的内核成分(即池的拥有者)使用. 拥有者通常不使用这个储备, 只有动态内存变得极为稀有, 以至于所有普通内存分配请求都将失败的话, 才用作最后的解决手段.
More
how big can a malloc be in c
high memory in the linux kernel
linux 内存管理
关于内存屏障(1, 2)
What Every Programmer Should Know About Memory(pdf, web)
Notes on Linux Kernel (1) — Memory Addressing: 王垠解释了为什么有segmentation这么个"恶心"的内存模式.