# 8.11 内核内存管理 ## 内存编址类型 早期 CPU 位宽较小,比如 16 位机,如果只使用 16 位地址总线的话,只能 2^16=65536 byte 大小的内存。此时如果再增加一组 16 位寄存器,则最大可以访问 2^32 内存空间空间。 早期的 Intel 8086 处理器使用了类似机制,由段地址寄存器提供基地址(又称为段地址),程序基于基地址的内存访问偏移量,此时由基地址+偏移量共同实现内存的寻址,而程序地址与实际内存地址被分割开来,程序使用的地址称为逻辑地址,实际内存地址称为物理地址。 Intel 引入段寄存器的另一个好处是能够将内粗数据段、代码段等隔离开来,Intel 采用冯诺依曼结构,其代码和数据共用同一个存储器,如果代码和数据不进行分割,则错误的数据操作很容易引起程序指令的错误。 基地址+偏移其实形成了线性地址,可以看出,早期的线性地址就等于物理地址。 随着内存管理需求的增加,Intel 又引入的分页机制。同时,为了保持前向兼容,分页机制又与之前的分段机制组合,形成了现代计算机的内存管理机制。能够看出,当引入分页机制后,线性地址不再等于物理地址,线性地址必须经过页表的翻译后才能得到真实的物理地址。至此,计算机内存产生了物理地址、逻辑地址、段地址、偏移地址、虚拟地址、线性地址等概念: 1. 物理地址:是内存中最真实的地址,CPU 在外部地址总线上寻址物理内存的地址信号。 2. 逻辑地址:指访内指令给出的地址 (操作数) ,也叫相对地址,也就是机器语言指令中,用来指定一个操作数或是一条指令的地址。 3. 虚拟地址:是由程序产生的由段选择符和段内偏移地址组成的地址。 4. 线性地址:是逻辑地址到物理地址变换之间的中间层。(在分段部件中基地址加上段中的偏移地址就是逻辑地址) ![逻辑地址转换](./imgs/8.11_内核内存管理/001.drawio.png) ## 硬件内存管理机制和内存安全 x86/64 系列处理器的内存管理是一套非常有代表性的内存管理机制,该机制主要包括分段机制和分页机制两种。x86/64 系列处理器有多种运行模式,在进入保护模式时必须开启段式管理机制;进入长模式时必须开启分页机制。 ### 分段内存管理和内存安全 x86/64 系列处理器在实模式下,其段地址+偏移地址即可以得到线性地址。而进入保护模式后,段寄存器中的数据被分割为不同的功能区域: ![Code-Segment Descriptor](./imgs/8.11_内核内存管理/002.png) 其中,Index 域用于在段描述符表中定位具体的表项。通过表项中的基地址+程序给出的偏移地址才能够得到真正的线性地址。 ![Code-Segment Descriptor](./imgs/8.11_内核内存管理/003.png) ![Code-Segment Descriptor](./imgs/8.11_内核内存管理/004.png) x86/64 系列处理器通过段描述符、段寄存器和段选择子中的如下信息去实现系统内存安全机制: * DPL(Descriptor Privilege Level):段描述符的 DPL 域。 * CPL(Currnet Privilege Level):CS 或 SS 寄存器的 Bit0 和 Bit1 位。 * RPL(Request Privilege Level):段选择子的 Bit0 和 Bit1。 所谓段选择子,即一个带有固定结构的数据(被加载到对应的段寄存器之后才有实际意义),它指定了将要使用 GDT(全局段描述符表) 或 LDT(局部段描述符表) 表中的那一项(段寄存器指定了正在使用的表项)。 GDTR 和 LDTR 指定了当前所用 GDT 和 LDT 所在内存的起始位置。 程序运行时,必然有需要修改段寄存器的需求,此时需要通过指令将载段选择子加载到段寄存器,而段选择子的 RPL 代表了请求的操作级别。Intel 有 4 个操作级别(运行级别/安全级别),数字越大,运行级别越低: ![Code-Segment Descriptor](./imgs/8.11_内核内存管理/005.png) 当使用类似下列指令去修改段寄存器时,处理器将加载段选择子到对应的段寄存器: ```asm mov ax, 0008H ;段选择子为 0008H 的数据 mov ds, ax ;加载段选择子到 DS 段寄存器 ``` 在处理器加载段选择子到段寄存器之前,会先检查 CPL、RPL 以及 DPL 的权限,只有当 DPL 同时大于或等于 CPL 和 RPL 时,才允许将段选择子加载进段寄存器,否则将产生通用保护异常。 段描述符不仅包含了该段允许的访问级别,也包含了段长度等信息,从而避免越界访问高权限内存的可能,伴随的其他安全机制共同起到了内存保护的作用。 通过段保护机制,可以将内核代码段与用户代码段分割开来。用户代码只有经过特殊的门调用,并进行足够的安全检查后,才能够提升权限,进入内核空间。 ***注:与内存类似的,Intel 架构下还有一组为中断和任务准备的数据结构,用于保证中断和任务安全。*** ### 分页内存管理和内存安全 Intel 分页机制的实现,采用了页目录、页表和页的机制,同时将线性地址拆分成不同的区域,用于实现页目录、页表、页的寻址。Intel 4k 页分页机制如下: ![分页机制](./imgs/8.11_内核内存管理/006.png) 页可以在真实物理内存中,也可以被换出到外部存储器中。如果程序需要访问该页,则被标记为“脏”,从而引起操作系统内核将改页唤入到物理内存中。 x86/64 分页机制实现了内存的以下权限的管理: * 只读权限 * 读写权限 该权限由页目录和页表的 RW 域决定: ![Code-Segment Descriptor](./imgs/8.11_内核内存管理/007.png) ## Linux 内存空间的划分 对 32 位操作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。具体的实现方式基本都是由操作系统将虚拟地址空间划分为两部分,一部分为内核空间,另一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。而较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。 对上面这段内容我们可以这样理解: 每个进程的 4G 地址空间中,最高 1G 都是一样的,即内核空间。只有剩余的 3G 才归进程自己使用。 换句话说就是, 最高 1G 的内核空间是被所有进程共享的! ![Linux 虚拟内存空间划分](./imgs/8.11_内核内存管理/008.drawio.png) 用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。用户进程只有通过系统调用(代表用户进程在内核态执行)等方式才可以访问到内核空间。 每个进程的用户空间都是完全独立、互不相干的,用户进程各自有不同的页表。而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表,内核的虚拟空间独立于其他程序。 Linux 中 1GB 的内核地址空间又被划分为: * 直接映射区/物理内存映射区 * 动态内存映射区 * 永久内存映射区 * 固定内存映射区 * 系统保留映射区 ![Linux 内核内存映射](./imgs/8.11_内核内存管理/009.drawio.png) ### 直接映射区与高端内存 一般情况下,直接映射区最大长度为 896MB,系统的物理内存被顺序映射在内核空间的这个区域中。当系统物理内存大于 896MB 时,超过直接映射区的那部分内存称为高端内存(而未超过直接映射区的内存通常被称为常规内存)。 内核在存取高端内存时必须将它们映射到高端页面映射区。 引入高端内存映射这样一个概念的主要原因就是我们所安装的内存大于 1G 时,内核的 1G 线性地址空间无法建立一个完全的直接映射来触及整个物理内存空间,而对于 80x86 开启 PAE 的情况下,允许的最大物理内存可达到 64G,因此内核将自己的最后 128M 的线性地址空间腾出来,用以完成对高端内存的暂时性映射。而在 64 位的系统上就不存在这样的问题了,因为可用的线性地址空间远大于可安装的内存。 ### 保留区 Linux 保留内核空间最顶部 FIXADDR_TOP~4GB 的区域作为保留区,一般为 4KB。 ### 固定内存映射区 紧接着最顶端的保留区以下的一段区域为固定内存映射区(FIXADDR_START~FIXADDR_TOP),它的总尺寸和每一页的用途由 fixed_address 枚举结构在编译时预定义,用 __fix_to_virt(index) 可获取专用区内预定义页面的逻辑地址。其开始地址和结束地址宏定义如下: ```cpp #define FIXADDR_START (FIXADDR_TOP - __FIXADDR_SIZE) #define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP) #define __FIXADDR_TOP 0xfffff000 ``` 固定内存映射区有一部分用于高端内存的临时映射,具有如下特点: 1. 每个 CPU 占用一块空间. 2. 在每个 CPU 占用的那块空间中,又分为多个小空间,每个小空间大小是 1 个 page,每个小空间用于一个目的,这些目的定义在 kmap_types.h 中的 km_type 中。 通过 kmap_atomic() 可实现固定内存的临时映射。该函数可以用在中断处理函数和可延迟函数的内部,从不阻塞。 ### 永久内存映射区 接下来,如果系统配置了永久内存映射区,则位于固定内存映射区之下的就是一段永久内存映射区,其范围为 PKMAP_BASE~FIXADDR_START,定义如下: ```cpp #define PKMAP_BASE ((FIXADDR_BOOT_START - PAGE_SIZE*(LAST_PKMAP + 1)) & PMD_MASK ) #define FIXADDR_BOOT_START (FIXADDR_TOP - __FIXADDR_BOOT_SIZE) #define LAST_PKMAP PTRS_PER_PTE #define PTRS_PER_PTE 512 #define PMD_MASK (~(PMD_SIZE-1)) #define PMD_SIZE (1UL << PMD_SHIFT) #define PMD_SHIFT 21 ``` 通常情况下,这个空间是 4M 大小,因此仅仅需要一个页表即可,内核通过 pkmap_page_table 来寻找这个页表。通过 kmap(),可以把一个 page 映射到这个空间来。由于这个空间是 4M 大小,最多能同时映射 1024 个 page。因此,对于不使用的的 page,及应该时从这个空间释放掉(也就是解除映射关系),通过 kunmap() ,可以把一个 page 对应的线性地址从这个空间释放出来。 可用 alloc_pages() 函数获得永久内存映射。 ### 动态内存映射区 在物理区和高端映射区之间为动态内存映射区(VMALLOC_START~VMALLOC_END),用于 vmalloc() 函数,它的前部与直接映射区有一个隔离带,后部与高端映射区也有一个隔离带,vmalloc() 区域定义如下: ```cpp #define VMALLOC_OFFSET (8*1024*1024) #define VMALLOC_START (((unsigned long) high_memory + vmalloc_earlyreserve + 2*VMALLOC_OFFSET-1) & ~(VMALLOC_OFFSET-1)) #ifdef CONFIG_HIGHMEM // 支持高端内存 #define VMALLOC_END (PKMAP_BASE-2*PAGE_SIZE) #else // 不支持高端内存 #define VMALLOC_END (FIXADDR_START-2*PAGE_SIZE) #endif // CONFIG_HIGHMEM ``` 动态内存映射区由内核函数 vmalloc() 来分配,特点是:线性空间连续,但是对应的物理空间不一定连续。vmalloc() 分配的线性地址所对应的物理页可能处于低端内存,也可能处于高端内存。 ## Linux 内核内存的分配 ### page **头文件:** ```cpp #include ``` #### get_zeroed_page **函数原型:** ```c unsigned long get_zeroed_page(gfp_t gfp_mask); ``` **说明:** 该函数返回一个指向新页的虚拟地址指针并且将该页清零。 **参数:** gfp_mask:要分配内存的类型。 较常用的 gfp_mask(分配内存的方法)如下: * GFP_ATOMIC —— 分配内存的过程是一个原子过程,分配内存的过程不会被(高优先级进程或中断)打断; * GFP_KERNEL —— 正常分配内存; * GFP_DMA —— 给 DMA 控制器分配内存,需要使用该标志(DMA要求分配虚拟地址和物理地址连续)。 gfp_mask 的参考用法: | 说民 | Flag | |-----------------------|-----------------------| | 进程上下文,可以睡眠 | GFP_KERNEL | | 进程上下文,不可以睡眠(中断处理程序、软中断、Tasklet) | GFP_ATOMIC | | 用于DMA的内存,可以睡眠 | GFP_DMA \| GFP_KERNEL | | 用于DMA的内存,不可以睡眠 | GFP_DMA \|GFP_ATOMIC | **返回值:** 申请成功返回有效的内核虚拟地址,失败返回 0。 #### __get_free_pages **函数原型:** ```c unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order); ``` **说明:** 该函数可分配多个连续的页并返回首页的指针,分配的页数为 2order,分配的页不清零。order 允许的最大值是 10(即 1024 页)或者 11(即 2048 页),依赖于具体的硬件平台。 **参数:** gfp_mask:与 get_zeroed_page 中的 gfp_mask 一致; order:要分配的页数。 **返回值:** 申请成功返回有效的内核虚拟地址,失败返回 0。 #### alloc_pages **函数原型:** ```c struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); ``` **说明:** 与 __get_free_pages 函数一样可分配多个连续的页,但它返回分配的第一个页的描述符而非首地址。分配的页数为 2order,分配的页不清零。order 允许的最大值是 10(即 1024 页)或者 11(即 2048 页),依赖于具体的硬件平台。 **参数:** gfp_mask:与 get_zeroed_page 中的 gfp_mask 一致; order:要分配的页数。 **返回值:** 申请成功返回有效的内存首地址,失败返回 NULL。 #### free_pages/__free_pages **函数原型:** ```c void __free_pages(struct page *page, unsigned int order); void free_pages(unsigned long addr, unsigned int order); void free_page(unsigned long addr); ``` **说明:** * get_zeroed_page 分配的内存页用 free_page 释放; * __get_free_pages 分配的内存页用 free_pages 释放; * alloc_pages 分配的页用 __free_pages 释放。 **参数:** page/addr:要释放的内存首地址; order:要释放的页面数。 **返回值:** 无 ### pfn/page/virt 转换方法 **头文件:** ```cpp #include // 或 #include ``` #### pfn_to_kaddr/pfn_to_virt/virt_to_pfn **函数原型:** ```c #define pfn_to_kaddr(pfn) #define pfn_to_virt(pfn) #define virt_to_pfn(kaddr) ``` **说明:** pfn_to_kaddr/pfn_to_virt 用于将 pfn 转换为内核虚拟地址。有的平台只实现了 pfn_to_kaddr。 virt_to_pfn 用于获取该内核虚拟地址的 pfn 值。 **参数:** pfn:页面标号; kaddr:内核虚拟地址。 **返回值:** pfn_to_virt 返回内核虚拟地址。 virt_to_pfn 返回该内核虚拟地址的 pfn。 #### pfn_to_page/page_to_pfn **函数原型:** ```c #define pfn_to_page(pfn) #define page_to_pfn(page) ``` **说明:** 将 pfn 和 struct page* 进行相互转换。 **参数:** pfn:页面标号; page:struct page* 页面描述结构体。 **返回值:** pfn 或 struct page* #### page_to_virt/virt_to_page **函数原型:** ```c #define page_to_virt(page) #define virt_to_page(kaddr) ``` **说明:** 将 struct page* 和内核虚拟地址进行相互转换。 **参数:** page:struct page* 页面描述结构体; kaddr:内核虚拟地址。 **返回值:** struct page* 或内核虚拟地址。 ### reserved **头文件:** ```cpp #include ``` #### mark_page_reserved **函数原型:** ```c void mark_page_reserved(struct page *page); ``` **说明:** 标记内存页为 reserved,避免被换出到外存。 **参数:** page:要标记为 reserved 的内存页。 **返回值:** 无 #### free_reserved_page **函数原型:** ```c void free_reserved_page(struct page *page); ``` **说明:** 释放 reserved 内存。 **参数:** page:要释放的 reserved 内存页。 **返回值:** 无 ### kmalloc/kzalloc/kfree 通过 kmalloc/kzalloc 分配内核空间,通过 kfree 释放。 **头文件:** ```cpp #include ``` #### kmalloc **函数原型:** ```c void *kmalloc(size_t size, gfp_t flags); ``` **说明:** kmalloc() 申请的内存位于直接映射区域,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因为存在较简单的转换关系,所以对申请的内存大小有限制,最大只能开辟 128k-16 字节空间,16 个字节是被页描述符结构占用了。 **参数:** size:要分配的内存大小,以字节为单位; flags:与 gfp_mask 含义一致。 **返回值:** 申请成功返回有效的内存首地址,失败返回 NULL。 #### kzalloc **函数原型:** ```c /** * kzalloc - allocate memory. The memory is set to zero. * @size: how many bytes of memory are required. * @flags: the type of memory to allocate (see kmalloc). */ static inline void *kzalloc(size_t size, gfp_t flags) { return kmalloc(size, flags | __GFP_ZERO); } ``` **说明:** kzalloc() 函数与 kmalloc() 非常相似,参数及返回值是一样的,可以说是前者是后者的一个变种,因为 kzalloc() 实际上只是额外附加了 __GFP_ZERO 标志。所以它除了申请内核内存外,还会对申请到的内存内容清零。 **参数:** size:要分配的内存大小,以字节为单位; flags:要分配内存的类型。 **返回值:** 申请成功返回有效的内存首地址,失败返回 NULL。 #### kfree **函数原型:** ```c void kfree(const void *addr); ``` **说明:** 用于释放由 kmalloc/kzalloc 申请的内存。 **参数:** addr:要释放的内存首地址指针。 **返回值:** 无 ### vmalloc/vfree 通过 vmalloc() 分配内核空间,通过 vfree() 释放。可以分配较大内存。使用 vmalloc() 最著名的实例是内核对模块的实现,当模块被动态加载到内核当中时,就把模块装载到由 vmalloc() 分配的内存上。注意:vmalloc() 和 vfree() 可以睡眠,因此不能从中断上下文调用。 #### vmalloc **函数原型:** ```c void *vmalloc(unsigned long size); ``` **说明:** vmalloc() 函数则会在虚拟内存空间给出一块连续的内存区,但这片连续的虚拟内存在物理内存中并不一定连续。由于 vmalloc() 没有保证申请到的是连续的物理内存,因此对申请的内存大小没有限制,如果需要申请较大的内存空间就需要用此函数了。 **参数:** size:要分配的内存大小,以字节为单位。 **返回值:** 申请成功返回有效的内存首地址,失败返回 NULL。 #### vfree **函数原型:** ```c void vfree(const void *addr); ``` **说明:** 释放由 vmalloc() 分配的内核空间。 **参数:** addr:要释放的内存首地址指针。 **返回值:** 无 ### kmalloc/kzalloc/vmalloc 的比较 kmalloc()、kzalloc()、vmalloc() 的共同特点是: 1. 用于申请内核空间的内存; 2. 内存以字节为单位进行分配; 3. 所分配的内存虚拟地址上连续。 kmalloc()、kzalloc()、vmalloc() 的区别是: 1. kzalloc() 是强制清零的 kmalloc() 操作;(以下描述不区分 kmalloc() 和 kzalloc()) 2. kmalloc() 分配的内存大小有限制(128KB),而 vmalloc() 没有限制; 3. kmalloc() 可以保证分配的内存物理地址是连续的,但是 vmalloc() 不能保证; 4. kmalloc() 分配内存的过程可以是原子过程(使用 GFP_ATOMIC),而 vmalloc() 分配内存时则可能产生阻塞; 5. kmalloc() 分配内存的开销小,因此 kmalloc() 比 vmalloc() 要快。 6. kmalloc() 申请一段物理地址和逻辑地址连续的内存空间。 7. vmalloc() 申请一段逻辑地址连续、物理地址不连续的内存空间。 一般情况下,内存只有在要被 DMA 访问的时候才需要物理上连续,但为了性能上的考虑,内核中一般使用 kmalloc(),而只有在需要获得大块内存时才使用 vmalloc()。 ### devm_kmalloc 函数 **函数原型:** ```cpp void *devm_kmalloc(struct device *dev, size_t size, gfp_t gfp); ``` **说明:** 与 kmalloc 类似,用于申请内存。 **参数:** dev:设备结构体; size:要分配的内存大小,以字节为单位; 其他:参见 kmalloc 的参数说明。 **返回值:** 申请成功返回有效的内存首地址,失败返回 NULL。 ### devm_kzalloc 函数 **函数原型:** ```cpp static inline void *devm_kzalloc(struct device *dev, size_t size, gfp_t gfp); ``` **说明:** 与 kzalloc 类似,用于申请内存,实际上只是额外附加了 __GFP_ZERO 标志。所以它除了申请内核内存外,还会对申请到的内存内容清零。 **参数:** dev:设备结构体; size:要分配的内存大小,以字节为单位; 其他:参见 kmalloc 的参数说明。 **返回值:** 申请成功返回有效的内存首地址,失败返回 NULL。