linux内存细节 内存管理初始化概述

在内存管理的上下文,初始化可以有多重含义。在许多CPU上,必须显式设置适用于Linux内核的内存模型。文章给出了linux系统设计的初始化阶段设计的内存接口与函数。

1. 基本概念

1.1. linux内存管理的层次结构

linux把物理内存划分为三个层次来管理,分别是存储节点,管理区和页面。

层次描述
存储节点(Node)CPU被划分为多个节点(node),内存则被分簇,每个CPU对一个本地物理内存,即一个CPU-node对应一个内存簇bank,即每个内存簇被认为是一个节点
管理区(zone)每个物理节点node被划分为多个内存管理区域(zone),用于表示不同范围的内存,内核可以使用不同的映射方式映射不物理内存。
页面(page)内存被划分为多个页面帧,页面是最基本的内存分配单元

为了支持NUMA模型,也即CPU对不同的内存单元访问时间可能不同,此时系统的物理内存被划分为几个节点(node),一个node对应一个内存簇bank,即每个内存簇都被认为是一个节点。

  • 内存被划分为节点,每个节点关联到系统中的一个处理器,内核中表示为pg_data_t的实例。系统中每个节点被连接到一个以NULL结尾的pgdat_list列表中,而其中的每个节点利用pg_data_tnode_next字段连接到下一节。而对于PC这种UMA结构的机器来说,只是用一个contg_page_data的静态pg_data_t结构
  • 各个节点又被划分为内存管理区域,一个管理区域通过zone_struct描述用来表示内存的某个范围,低端范围的16MB被描述为ZONE_DMA。某些工业标准体系结构中的(ISA)设备需要用到它。然后是可以直接映射到内核内核的普通内存域ZONE_NORMAL。超出了内核段的物理地址域ZONE_HIGHMEM,被称为高端内存区,是系统中预留的可用内存空间,不能被内核直接映射。
  • 页帧(page frame)代表了系统内存的最小单位,堆内存中的每个页都会传建一个struct page的实例。传统上,吧内存是为连续的直接,及内存为字节数组,内存单元的编号(地址)可作为字节数组的索引。分页管理时,将若干直接视为一页,比如4K byte。此时,内存变成了连续的页,即内存为页数组,每一页物理内存叫页帧,以页为单位对内存进行编号,该编号可作为也数组的索引,又称为页帧号。

1.2. 内存节点pg_data_t

linux内核中引入一个数据结构struct pglist_data来描述一个node,定义在include/linux/mmzone.h文件中。这个结构体又被typedef pg_data_t。

  • 对于NUMA系统来讲,整个系统的内存由一个node_data的pg_data_t指针数组来管理
  • 对于UMA系统(PC),使用struct pglist_data contig_page_data,作为系统唯一的node管理所有的内存区域。也就是说UMA系统当中只有一个node。

可以使用NODE_DATA(node_id)来查找系统中编号为node_id的节点,而UMA结构下由于只有一个节点,返回红总是返回全局的contig_page_data,而与参数node_id无关。

extern struct pglist_data *node_data[];
#define NODE_DATA(nid)          (node_data[(nid)])

1.3. 物理内存区域

实际的计算机体系结构有硬件的诸多限制,这限制了页框的使用方式,尤其是在linux内核必须处理80x86体系结构的两种硬件约束。

  • ISA总线的直接内存存储DMA处理器有一个严格的限制,他们只能对RAM的前16MB进行寻址。
  • 在具有到容量RAM的现代32位计算机中,CPU不能直接访问所有的物理地址,因为线性地址空间太小,内核不可能直接映射所有物理内存岛先行地址空间,他们会在后面典型架构(x86)上的内存区域划分

因此,linux内核对不同区域的内存需要采用不同的管理方式和映射方式,内核将物理地址或者用zone_t表示的不同地址区域。对于x86_32的机器,管理区(内存区域)类型分布如下:

类型区域
ZONE_DMA0~15MB
ZONE_NORMAL16MB~895MB
ZONE_HIGHMEM896MB~物理内存结束

1.4. 物理页帧

内核把物理也作为内存管理的基本单位,尽管处理器最小可寻址单位通常是字。但是,内存管理单元MMU通常以页为单位进行处理。因此,从虚拟内存上来看,也就是最小单位。页帧掉膘了系统内存的最小单位,对内存中行的每个页都会穿件struct page的一个实例,内核必须要保证page结构体足够小,否则仅struct page就要占用大量的内存,结构体内部大量使用联合体union节省空间。mem_map是struct page的数组,管理者系统中所有的物理内存页面。在系统启动过程中,穿件和分配mem_map的内存区域。mem_map定义在mm/page_acllo.c。UMA体系结构中,free_area_init函数在系统唯一的struct node对象conting_page_data中node_mem_map成员赋值给全局mem_map变量。

1.5. 启动过程中的内存初始化

在初始化过程中,还必须建立内存管理的数据结构,以及和很多事物。因为内核在内存管理完全初始化之前就需要使用内存。在系统启动过程期间,使用了额外的简化内存管理模块,然后在初始化完成后将旧模块丢弃掉。linux内核内存管理划分为三个阶段:

阶段起点终点描述
第一阶段系统启动bootmem或者memblock初始化完成此阶段只能使用memblock_reserve函数分配内存,早期内核中使用init_bootmem_done=1标识此阶段结束
第二阶段bootmem或者memblock初始化完成buddy完成前引导内存分配器bootmem或者memblock接受内存管理工作,早期内核使用mem_init_done=1标记此阶段结束
第三阶段buddy初始化完成系统停止运行可以使用cache和buddy分配内存

start_kenel完成内存所有的初始化工作,具体将其中与内存相关的街区出来:

start_kernel()
    |---->page_address_init()
    |     考虑支持高端内存,ARM本函数为空
    |     业务:初始化page_address_pool链表;
    |          将page_address_maps数组元素按索引降序插入
    |          page_address_pool链表; 
    |          初始化page_address_htable数组.
    | 
    |---->setup_arch(&command_line);   
    |     初始化特定体系结构的内容
        |---->phys_to_virt(mdesc->boot_params);     
        |     获取物理启动tag位置
        |
        |---->mdesc->fixup(mdesc, tags, &from, &meminfo)或者parse_tags(tags)
        |     通过板提供函数或者是uboot传入的参数进行物理内存参数解析
        |     对于高版本内核还可以从设备树中解析出物理SDRAM参数信息
        |
        |---->paging_init();              [参见分页机制初始化paging_init]
        |     分页机制初始化
            |---->build_mem_type_table()
            |---->prepare_page_table()
            |---->bootmem_init()            [与build_all_zonelist完成内存数据结构的初始化]
            |                               初始化内存数据结构包括内存节点和内存域
            |---->devicemaps_init(mdesc)
    |---->mm_init_cpumask(&init_mm)
    |
    |
    |---->setup_per_cpu_areas();
    |     为per-CPU变量分配空间
    |
    |---->build_all_zonelist()                      [bootmem_init初始化数据结构, 该函数初始化zonelists]
    |     为系统中的zone建立后备zone的列表.
    |     所有zone的后备列表都在
    |     pglist_data->node_zonelists[0]中;
    |
    |     期间也对per-CPU变量boot_pageset做了初始化. 
    |
    |---->page_alloc_init()
         |---->hotcpu_notifier(page_alloc_cpu_notifier, 0);
         |     不考虑热插拔CPU 
         |
    |---->pidhash_init()
    |     详见下文.
    |     根据低端内存页数和散列度,分配hash空间,并赋予pid_hash
    |
    |---->mm_init()
    |     完成新的分配器的初始化,将老分配器boot_mem替换
    |
    |---->kmem_cache_init_late()
    |
    |---->kmemleak_init()
    |
    |---->setup_per_cpu_pageset()
    |
    |
    |---->vfs_caches_init_early()
          |---->dcache_init_early()
          |     dentry_hashtable空间,d_hash_shift, h_hash_mask赋值;
          |     同pidhash_init();
          |     区别:
          |         散列度变化了(13 - PAGE_SHIFT);
          |         传入alloc_large_system_hash的最后参数值为0;
          |
          |---->inode_init_early()
          |     inode_hashtable空间,i_hash_shift, i_hash_mask赋值;
          |     同pidhash_init();
          |     区别:
          |         散列度变化了(14 - PAGE_SHIFT);
          |         传入alloc_large_system_hash的最后参数值为0;
          |
    |---->reset_init()
函数功能
setup_arch是一个特定于体系结构的设置函数, 其中一项任务是负责初始化自举分配器
mm_init_cpumask初始化CPU屏蔽字
setup_per_cpu_areas函数(查看定义)给每个CPU分配内存,并拷贝.data.percpu段的数据. 为系统中的每个CPU的per_cpu变量申请空间.
在SMP系统中, setup_per_cpu_areas初始化源代码中(使用per_cpu宏)定义的静态per-cpu变量, 这种变量对系统中每个CPU都有一个独立的副本.
此类变量保存在内核二进制影像的一个独立的段中, setup_per_cpu_areas的目的就是为系统中各个CPU分别创建一份这些数据的副本
在非SMP系统中这是一个空操作
build_all_zonelists建立并初始化结点和内存域的数据结构
mm_init建立了内核的内存分配器,
其中通过mem_init停用bootmem分配器并迁移到实际的内存管理器(比如伙伴系统)
然后调用kmem_cache_init函数初始化内核内部用于小块内存区的分配器
kmem_cache_init_late在kmem_cache_init之后, 完善分配器的缓存机制, 当前3个可用的内核内存分配器slabslobslub都会定义此函数
kmemleak_initKmemleak工作于内核态,Kmemleak 提供了一种可选的内核泄漏检测,其方法类似于跟踪内存收集器。当独立的对象没有被释放时,其报告记录在 /sys/kernel/debug/kmemleak中, Kmemcheck能够帮助定位大多数内存错误的上下文
setup_per_cpu_pageset初始化CPU高速缓存行, 为pagesets的第一个数组元素分配内存, 换句话说, 其实就是第一个系统处理器分
由于在分页情况下,每次存储器访问都要存取多级页表,这就大大降低了访问速度。所以,为了提高速度,在CPU中设置一个最近存取页面的高速缓存硬件机制,当进行存储器访问时,先检查要访问的页面是否在高速缓存中.

2. 第一阶段(启动过程的内存管理)

内存管理是操作系统资源管理的重点,在操作系统初始化的初期,操作系统知识获取了内存的基本信息,但是内存管理的数据结构没有建立,而我们这些数据结构创建过程本身就是一个内存分配的过程,那么就会出现一个问题。我们还没有一个内存管理器去负责分配和回收内存,而我们又不可能将所有的内存信息都静态创建并初始化。那么我们怎么分配内存管理器所需要的内存呢?现在我们进入一个先有鸡还是先有蛋的怪圈,这种问题一般解决方法是:我们先实现一个满足要求的但是可能效率不高的笨家伙(内存管理器),用它来负责系统初始化早期的内存管理。最重要的用它来初始化我们内存的数据结构,直到我们真正的内存管理器被初始化完成并能投入使用,我们将旧的内存管理器丢掉。

即在系统启动过程期间,内核使用了一个额外的简化形式的内存管理模块早期的引导内存分配器(boot memory allocator-bootmem)或者memblock用于实现启动阶段早期内存分配,而在系统初始化完成以后,该分配器被内核抛弃,然后初始化了一套更加完善的内存分配器。

2.1. 引导内存分配器bootmem

在启动过程期间,尽管内存管理尚未初始化,但是内核需要分配内存以创建各种数据结构,再起的内核中负责初始化阶段的内存分配器称为引导内存分配器(boot memory allocator-bootmem分配器),在耳熟能详的伙伴系统创立之前内存都是利用这个分配器来分配的,伙伴系统创建起来后,bootmem会过度到伙伴系统。显然,对该内存分配器的需求集中于简单性方面,而不是性能和通用性,它仅用于初始化阶段。因此,内核开发者决定实现一个最先适配(first-first)分配器在启动阶段管理内存,这是可能想到的最简单的方式。

引导内存分配器(boot memory allocator-bootmem分配器)基于最先适配(first-first)分配器的原理(这是很多系统的内存分配所使用的原理),使用一个位图来管理页,以位图代替原来的空闲列表结构来表示存储空间,位图的比特位数目与系统中物理内存页数目相同。若为图中某一位是1,则标识该页已经被分配,否则表示未被占用。在需要分配内存时,分配器诸位的扫描位图,直至找到一个能够提供足够连续页的位置,即所谓的最先最佳(first-best)或最先适配位置。该分配机制通过记录上一次分配的页面帧号(PFN)结束时的偏移量来实现分配大小小于一页的空间,连续的小空闲空间将会被合并存储在一页上。

即使是初始化用的最先匹配分配器也必须使用一些数据结构,内核显微系统中每一个节点提供了一个struct bootmem_data结构实例用于bootmem的内存管理。它含有引导内存分配器给节点分配内存时所需要的信息。当然,这个还是后内存管理还没有初始化,因而该结构所需要的内存是无法动态分配的,必须在编译时分配给内核。在UMA系统上该分配的实现与CPU无关,而NUMA系统内存节点与CPU相关联,采用特定体系结构的解决方法。bootmem_data的结构定义在include/linux/bootmem.h

关于引导内存分配器的具体内容, 请参见另外一篇博文

CSDNGitHub
引导内存分配器bootmemstudy/kernel/02-memory/03-initialize/02-bootmem

2.2.  memblock内存分配器

但是bootmem也有很多问题. 最明显的就是外碎片的问题, 因此内核维护了memblock内存分配器, 同时用memblock实现了一份bootmem相同的兼容API, 即nobootmem, Memblock以前被定义为Logical Memory Block( 逻辑内存块),但根据Yinghai Lu的补丁, 它被重命名为memblock. 并最终替代bootmem成为初始化阶段的内存管理器

关于引导内存分配器的具体内容, 请参见另外一篇博文

CSDNGitHub
memblock内存分配器study/kernel/02-memory/03-initialize/03-memblock

 2.3. 两者的区别与联系

bootmem是通过位图来管理,位图存在低地址段,而memblock是在高地址管理内存,维护两个链表,即memory和reserved。memory链表维护系统的内存信息(在初始化阶段通过BIOS获取的),对于任何内存分配,先去查找memory链表,然后再reserve链表上记录(新增一个节点,或者合并)

  • 两者都可以分配小于一页的内存
  • 闲着都是在就近查找可用的内存,bootmem是从低地址到高找,memblock是从高往低找

在boot传递给kernel memory bank相关信息后,kernel这边会以memblock的方式保存这些信息,当buddy system没有起来之前,在kernel中也是需要有一套机制来管理memory的申请和释放。kernel可以选择nobootmem或bootmem来在buddy system起来之前管理memory。

这两种及其对外提供API是一致的,对用户都是透明的。

参见mm/Makefile

ifdef CONFIG_NO_BOOTMEM
    obj-y           += nobootmem.o
else
    obj-y           += bootmem.o
endif

由于接口是一致的, 那么他们共同使用一份

头文件bootmem接口nobootmem接口
include/linux/bootmem.hmm/bootmem.cmm/nobootmem.c

4. 第二阶段(初始化buddy内存管理)

在arm64架构下, 内核在start_kernel()->setup_arch()函数中依次完成了如下工作

前面我们的内核从start_kernel开始, 进入setup_arch(), 并完成了早期内存分配器的初始化和设置工作.

流程描述
arm64_memblock_init初始化memblock内存分配器
paging_init初始化分页机制
bootmem_init初始化内存管理

其中arm64_memblock_init就完成了arm64架构下的memblock的初始化.

而setup_arch则主要完成如下工作

  • 调用arm64_memblock_init来完成了memblock的初始化
  • paging_init初始化内存的分页机制
  • bootmem_init初始化内存管理

4.1. 初始化流程

下面我们就以arm64架构来分析bootmem初始化内存结点和内存域的过程, 在讲解的过程中我们会兼顾的考虑arm64架构下的异同

  • 首先内核从start_kernel开始启动
  • 然后进入体系结构相关的设置部分setup_arch, 开始获取并设置指定体系结构的一些物理信息, 而arm64架构下则对应着rch/arm64/kernel/setup.c
  • 在setup_arch函数内, 通过paging_init函数初始化了分页机制和页表的信息
  • 接着paging_init函数通过bootmem_init开始进行初始化工作

arm64在整个初始化的流程上并没有什么不同, 但是有细微的差别

  • 由于arm是在后期才开始加入了MMU内存管理单元的, 因此内核必须实现mmu和nonmmu两套不同的代码, 这主要是提现在分页机制的不同上, 因而paging_init分别定义了arch/arm/mm/nommu.c和arch/arm/mm/mmu.c两个版本, 但是它们均调用了bootmem_init来完成初始化
  • 也是因为上面的原因, arm上paging_init有两份代码(mmunonmmu), 为了降低代码的耦合性, arm通过setup_arch调用paging_init函数, 后者进一步调用了bootmem_init来完成, 而arm64上不存在这样的问题, 则在setup_arch中顺序的先用paging_init初始化了页表, 然后setup_arch又调用bootmem_init来完成了bootmem的初始化

4.2. paging_init初始化分页机制

paging_init负责建立只能用于内核的页表, 用户空间是无法访问的. 这对管理普通应用程序和内核访问内存的方式,有深远的影响

因此在仔细考察其实现之前,很重要的一点是解释该函数的目的。

在x86_32系统上内核通常将总的4GB可用虚拟地址空间按3:1的比例划分给用户空间和内核空间, 虚拟地址空间的低端3GB

用于用户状态应用程序, 而高端的1GB则专用于内核. 尽管在分配内核的虚拟地址空间时, 当前系统上下文是不相干的, 但每个进程都有自身特定的地址空间.

这些划分主要的动机如下所示

  • 在用户应用程序的执行切换到核心态时(这总是会发生,例如在使用系统调用或发生周期性的时钟中断时),内核必须装载在一个可靠的环境中。因此有必要将地址空间的一部分分配给内核专用.
  • 物理内存页则映射到内核地址空间的起始处,以便内核直接访问,而无需复杂的页表操作.

4.3. 虚拟地址空间(以x86_32为例)

出于内存保护等一系列的考虑, 内核将整个进程的虚拟运行空间划分为内核虚拟运行空间和内核虚拟运行空间

按3:1的比例划分地址空间, 只是约略反映了内核中的情况,内核地址空间作为内核的常驻虚拟地址空间, 自身又分为各个段

地址空间的第一段用于将系统的所有物理内存页映射到内核的虚拟地址空间中。由于内核地址空间从偏移量0xC0000000开始,即经常提到的3 GiB,每个虚拟地址x都对应于物理地址x—0xC0000000,因此这是一个简单的线性平移。

直接映射区域从0xC0000000到high_memory地址,high_memory准确的数值稍后讨论。第1章提到过,这种方案有一问题。由于内核的虚拟地址空间只有1 GiB,最多只能映射1 GiB物理内存。IA-32系统(没有PAE)最大的内存配置可以达到4 GiB,引出的一个问题是,如何处理剩下的内存?

这里有个坏消息。如果物理内存超过896 MiB,则内核无法直接映射全部物理内存。该值甚至比此前提到的最大限制1 GiB还小,因为内核必须保留地址空间最后的128 MiB用于其他目的,我会稍后解释。将这128 MiB加上直接映射的896 MiB内存,则得到内核虚拟地址空间的总数为1 024 MiB = 1GiB。内核使用两个经常使用的缩写normal和highmem,来区分是否可以直接映射的页帧.

内核地址空间的最后128 MiB用于何种用途呢?如图3-15所示,该部分有3个用途.

  • 虚拟内存中连续、但物理内存中不连续的内存区,可以在vmalloc区域分配。该机制通常用于用户过程,内核自身会试图尽力避免非连续的物理地址。内核通常会成功,因为大部分大的内存块都在启动时分配给内核,那时内存的碎片尚不严重。但在已经运行了很长时间的系统上,在内核需要物理内存时,就可能出现可用空间不连续的情况。此类情况,主要出现在动态加载模块时
  • 持久映射用于将高端内存域中的非持久页映射到内核中
  • 固定映射是与物理地址空间中的固定页关联的虚拟地址空间项,但具体关联的页帧可以自由选择。它与通过固定公式与物理内存关联的直接映射页相反,虚拟固定映射地址与物理内存位置之间的关联可以自行定义,关联建立后内核总是会注意到的

同样我们的用户空间, 也被划分为几个段, 包括从高地址到低地址分别为 :

区域存储内容
局部变量, 函数参数, 返回地址等
动态分配的内存
BSS段未初始化或初值为0的全局变量和静态局部变量
数据段一初始化且初值非0的全局变量和静态局部变量
代码段可执行代码, 字符串面值, 只读变量

 4.4. bootmem_init初始化内存的基础数据结构(结点pg_data,内存域zone,页面page)

在paging_init之后, 系统的页帧已经建立起来, 然后通过bootmem_init中, 系统开始完成bootmem的初始化工作.

不同的体系结构bootmem_init的实现, 没有很大的区别, 但是在初始化的过程中, 其中的很多函数, 依据系统是NUMA还是UMA结构则有不同的定义

bootmem_init函数的实现如下

函数实现armarm64
bootmem_initarch/arm/mm/init.c, line 282arch/arm64/mm/init.c, line 306

4.5. build_all_zonelists初始化每个内存节点的zonelist

内核setup_arch的最后通过bootmem_init中完成了内存数据结构的初始化(包括内存结点pg_data_t, 内存管理域zone和页面信息page), 数据结构已经基本准备好了, 在后面为内存管理做得一个准备工作就是将所有节点的管理区都链入到zonelist中,便于后面内存分配工作的进行.

内存节点pg_data_t中将内存节点中的内存区域zone按照某种组织层次存储在一个zonelist中, 即pglist_data->node_zonelists成员信息

//  http://lxr.free-electrons.com/source/include/linux/mmzone.h?v=4.7#L626
typedef struct pglist_data
{
    struct zone node_zones[MAX_NR_ZONES];
    struct zonelist node_zonelists[MAX_ZONELISTS];
}

内核定义了内存的一个层次结构关系, 首先试图分配廉价的内存,如果失败,则根据访问速度和容量,逐渐尝试分配更昂贵的内存.

高端内存最廉价, 因为内核没有任何部分依赖于从该内存域分配的内存, 如果高端内存用尽, 对内核没有副作用, 所以优先分配高端内存

普通内存域的情况有所不同, 许多内核数据结构必须保存在该内存域, 而不能放置到高端内存域, 因此如果普通内存域用尽, 那么内核会面临内存紧张的情况

DMA内存域最昂贵,因为它用于外设和系统之间的数据传输。

举例来讲,如果内核指定想要分配高端内存域。它首先在当前结点的高端内存域寻找适当的空闲内存段,如果失败,则查看该结点的普通内存域,如果还失败,则试图在该结点的DMA内存域分配。如果在3个本地内存域都无法找到空闲内存,则查看其他结点。这种情况下,备选结点应该尽可能靠近主结点,以最小化访问非本地内存引起的性能损失。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值