我们知道,在大部分程序运行的时候,几乎都离不开堆(heap)和栈(stack),所有数据结构的分配也都是在堆和栈上进行的,堆和栈都是建立在内存之上的。
很多时候,内存几乎对程序员来讲是透明的,你只管使用,而不需要对其背后的管理机制做更加深入的了解,比如以 Java 为代表的运行在虚拟机上的语言,都有内存管理器来进行垃圾回收的机制。但是不幸的是,很多时候我们还是会遇到一些内存溢出的问题(out-of-memory),真是闻者伤心,听者流泪。这个世界没有无缘无故的伤害,背后肯定是有原因的。
从某种角度来讲,操作系统就是内存的加工商,它管理着物理内存,并对它进行一定的规格化加工,然后出售给上层应用,当然它自己有时候也会拿来使用。最后,它还会负责回收不需要的内存。只要我们搞清楚了 Linux 对内存的管理方法,那么很多问题也就迎刃而解了,其他操作系统只是算法不同,但是思路都是大同小异的。
内存在计算机中应该是个比较重要且特殊的器件,重要是因为内存是计算机中不可或缺的部件,是和 CPU 进行沟通的桥梁,所有的代码都得装载到内存之后才能让 CPU 通过指令寄存器找到相应地址进行访问。特殊性体现在以下几点:
内存资源是稀缺资源,需要特殊管理和对待。
CPU 单独设计了 MMU(内存管理单元)与内存进行沟通。
内存空间有限,而操作系统不只运行一个进程,直接进行物理地址寻址肯定会出现地址空间不够的情况,需要专门的方案解决这个问题。
针对内存的管理不仅是 MMU,操作系统针对内存又单独进行了很多管理的工作。
虽然操作系统已经对内存进行了管理工作,还有很多内存管理的应用程序在用户态对内存进行管理。
本章我们将探讨以下问题:
1)内存在计算机体系结构中的作用,解决什么问题,如何使用,在使用中会遇到什么问题,为什么需要管理。
2)MMU 本身对内存有管理机制,操作系统为什么还要在 MMU 的基础上进行管理。
3)内存涉及的地址、线性地址空间、物理地址空间、虚拟地址空间之间是什么关系,如何对应。
4)Linux 是如何进行内存管理的,整体架构如何,以及伙伴算法、slab 分配器、kmalloc、vmalloc、malloc 的实现。
5)Linux 栈内存如何分配,内核栈和线程栈 Linux 又是如何区分和管理的。
6)既然 Linux 内核已经管理了内存,Memcached、Redis 这样的软件为何还要自己管理内存。
我们已经知道了内存在计算机中重要且特殊的地位,下面我们来分析为什么需要对内存进行管理。
首先还是先来研究一下历史。在早期,计算机只能跑单个进程,就算是批处理程序,也是先来后到进行排队。所以,对内存的使用也比较简单,直接把物理地址拿来使用就可以了。
当支持多进程的系统出现之后,这种玩法就不适用了,多个进程都在同一个物理地址空间内玩耍,很容易互相影响而导致崩溃。
当然,可以简单地把内存平均分成 N 块,然后每个进程只能使用其中一块。看起来解决了问题,但是每个进程能使用的物理范围就很小了。
为了解决这个问题,CPU 内存管理单元(MMU)帮我们引入了虚拟地址空间的概念,以32位的系统为例(图3-1),每个进程都可以拥有 4G 的寻址空间,当在该空间需要物理内存的时候,再通过相应的转换技术和虚拟地址空间进行关联。
这样看起来解决了问题,但是仔细思考一下,又会有几个问题:
既然物理内存是所有虚拟地址空间共享的,那么如何分配,如何归还,这些问题都得想办法解决。
每次向物理地址申请的内存大小肯定不一样,多次分配再归还之后,导致内存碎片严重,无法申请到连续空间怎么办?
那么多进程都拥有独立的地址空间,但是物理地址再大还是有限的,难道不会出现物理地址不够,然后进程申请不到引起挂掉的情况?
物理内存都是按照页来组织的,页的粒度虽然可以配置,但是最小也是 4K,假如应用程序需要的内存小于 4K,那么不是存在浪费吗?如何管理小块内存的申请呢?
以上这4个问题肯定不是一个 MMU 就能搞定的,需要我们在系统层面再抽象一层,单独进行内存管理的相应工作。
图3-1 进程虚拟地址与物理地址空间映射
通过以上分析,参照图3-2,我们可以对 Linux 内存管理体系结构进行如下分层:
内存管理单元(MMU),通过分段分页的机制,提供虚拟地址到物理地址的映射方法。
段页机制是 MMU 提供的,Linux 是使用者,搞清楚 Linux 如何进行段页管理很重要。
Linux 物理地址管理,因为物理地址空间有限,系统会统一对物理地址进行管理,便于申请和归还。
Linux 内核态进程之间共享地址空间,如何进行管理?
Linux 用户态进程之间的地址空间是隔离的,如何进行管理?
结合这个分层架构,本章后续部分具体分析每部分是怎么做的。
图3-2 Linux 内存管理体系结构
在了解操作系统如何进行内存管理之前,必须首先了解硬件(MMU)提供给我们的内存管理机制是怎样的,管理的边界能到哪里。然后我们才能基于这些基本的认知进行深层次的探讨。MMU 是 Memory Management Unit 的缩写,是 CPU 的一部分,用来管理内存的控制线路,提供把虚拟地址映射为物理地址的能力。
3.2.1 虚拟地址、线性地址、物理地址
在了解了什么是 MMU 之后,理解虚拟地址、线性地址和物理地址这三个概念尤为重要。在 x86 体系结构下,CPU 对内存的寻址都是通过分段的方式来进行的。在保护模式下对段的概念进行了扩展,一个段可以理解为:
基地址+段的界限+类型
所以,在保护模式下的偏移就是在这个段中的偏移。
下面我们分别来理解3个地址:
虚拟地址:在段中的偏移地址。
线性地址:在某个段中“基地址+偏移地址”得出的地址。
物理地址:在 x86 中,MMU 还提供了分页机制,假如未开启分页机制,那么线性地址就等于物理地址;否则,需要经过分页机制换算后,线性地址才能转换成物理地址。
注意
保护模式是 Intel CPU 特有的一种工作模式。目的是在 Intel 新系列的产品升级到32位以上系统的时候,对老产品工作模式能兼容。所以老的模式又叫作实模式。
以 Intel 的80386为例,当工作在实模式下的时候,CPU 最大可用的地址总线为20位(0~19),因为像8086这样的 CPU 地址总线一共就20条,但是80386却有32条地址总线,假如在实模式下只用20条,那么最大寻址空间只有 1MB,若要扩大寻址范围,就要充分利用剩余的地址总线。这个时候 A20 地址总线(从0开始数第20根)就成为是否可超越 1MB 寻址的开关。假如在实模式下,A20 地址总线是关闭的,在保护模式下则打开,这样在保护模式下我们就可以进行 4GB 的寻址。
保护模式下做 IO 操作的时候,eflag 寄存器(见图3-3)上有2个关键位12和13位为 IOPL。只有当 CPL≤IOPL 的时候才可以进行 IO 操作。
保护模式概括起来3句话:
1)突破了 1MB 的寻址,对实模式的兼容。
2)对数据和代码的访问提供了保护机制。
3)对 IO 的操作提供了保护机制。
总之,关键是保护二字。
图3-3 eflag 寄存器结构
3.2.2 MMU 的内存管理机制
MMU 对内存的管理主要是分段和分页,下面通过分析几个问题来了解这两个机制。
1.段存储在什么地方?
因为一个段是由“基地址+段界限+类型”等数据组成,所以段是由全局描述符表(GDT)中的描述符结构来定义的(见图3-4)。
注意
其中局部描述符表(LDT)和 GDT 的结构是一样的,一般和 GDT 表项在一起。
图3-4 全局描述符表(GDT)结构
GDT 表项的说明如下。
1)P:存在(Present)位。
P=1,表示描述符对地址转换是有效的,或者说该描述符所描述的段存在,即在内存中。
P=0,表示描述符对地址转换无效,即该段不存在。使用该描述符进行内存访问时会引起异常。
2)DPL:描述符特权级(Descriptor Privilege level),共2位。它规定了所描述段的特权级,用于特权检查,以决定对该段能否访问。
3)S:说明描述符的类型。对于存储段描述符而言,S=1,为系统段描述符;S=0,为门描述符。
4)TYPE:说明存储段描述符所描述的存储段的具体属性。
数据段类型:
代码段类型:
系统段类型:
5)G:段界限粒度(Granularity)位。
G=0 表示界限粒度为字节。
G=1 表示界限粒度为 4K 字节。
注意,界限粒度只对段界限有效,对段基地址无效,段基地址总以字节为单位。
6)D:这是一个很特殊的位,在描述可执行段、向下扩展数据段或由 SS 寄存器寻址的段(通常是栈段)的三种描述符中的意义各不同。
a)在描述可执行段的描述符中,D 位决定了指令使用的地址及操作数所默认的大小。
D=1 表示默认情况下指令使用32位地址及32位或8位操作数,这样的代码段也称为32位代码段。
D=0 表示默认情况下使用16位地址及16位或8位操作数,这样的代码段也称为16位代码段,它不与80286兼容。可以使用地址大小前缀和操作数大小前缀分别改变默认的地址或操作数的大小。
b)在向下扩展数据段的描述符中,D 位决定段的上部边界。
D=1 表示段的上部界限为 4GB。
D=0 表示段的上部界限为 64KB,这是为了与80286兼容。
c)在描述符由 SS 寄存器寻址的段描述符中,D 位决定隐式的栈访问指令(如 PUSH 和 POP 指令)使用何种栈指针寄存器。
D=1 表示使用32位栈指针寄存器 ESP。
D=0 表示使用16位栈指针寄存器 SP,这与80286兼容。
7)AVL:软件可利用位。80386对该位的使用未做规定,Intel 公司也保证今后开发生产的处理器只要不与80386兼容,就不会对该位的使用做任何定义或规定。
上面的描述符定义看起来很复杂,其实主要目标有以下几点:
指定段的起始地址。
确定段的界限(长度)
确定段的属性,是否可读,可写,段的基本粒度单位,表述数据段还是代码段等等。
2.段是如何装载的?
我们通过图3-5来分析两种装载段的场景。
图3-5 装载段的场景
1)通过 lgdt 指令,我们把全局描述符表的基地址和界限存入了 GDTR 寄存器(参见图3-6),假如需要使用指定的某段,那么段寄存器的 index 可以设置为你想使用段的选择子。
注意
下面举例来说明一个 GDT 描述符:
gdt:.quad 0x0000000000000000
.quad 0x00c09a00000007ff #代码段
.quad 0x00c09200000007ff #数据段
.quad 0x00c0920b80000002 #显存段,界限为2*4k
end_gdt:
.fill 128,4,0
上述代码分别定义了代码段、数据段、显存段。
当需要装载 GDT 的时候,执行如下命令:
lgdt lgdt_opcode
lgdt_opcode 内容定义如下:
lgdt_opcode:
.word (end_gdt-gdt)-1
.long gdt
图3-6 GDTR 寄存器格式
2)假如是多个 task 切换的场景,可以通过 TSS(Task-State Stack)来设置指定 ldt 的选择子,然后等到切换到该 task 的时候,ldtr 就会装载在 tss 中设置的 LDT 选择子 TSS 格式参见图3-7。
图3-7 TSS 格式
注意
低权限级向高权限级切换的时候,栈也发生了变化,所以 TSS 需要把低权限级的栈复制保存起来。以下是 TSS 数据结构的例子:
tss0: .long 0 // 上一个任务链接
.long krn_stk0, 0x10 // esp0, ss0
.long 0, 0, 0, 0, 0 // esp1, ss1, esp2, ss2, cr3
.long 0, 0, 0, 0, 0 // eip, eflags, eax, ecx, edx
.long 0, 0, 0, 0, 0 // ebx esp, ebp, esi, edi
.long 0, 0, 0, 0, 0, 0 // es, cs, ss, ds, fs, gs
.long LDT0_SEL, 0x8000000 /* ldt, trace bitmap 这边 bitmap 的地址无效,而且
任务也没有 IO 操作,随便乱写也无所谓,具体设置方
法可以参考 INTEL 开发手册*/
3.分页机制是怎样的?
在 x86 系统中,MMU 支持多级的分页模型,分为三种情况:
32位系统,则为2级分页模型。
32位系统开启了物理地址扩展模式(PAE),则为3级分页模型。
64位系统,则为4级分页模型。
我们以32位系统为例(见图3-8)来说明分页机制的原理。还是通过问题出发:
1)分页机制如何开启?
80x86的分页机制由 CR0 中的 PG 位开启。如 PG=1,开启分页机制,并使用本节要描述的机制,把线性地址转换为物理地址。如 PG=0,禁用分页机制,直接把前面段机制产生的线性地址当作物理地址使用。
2)线性地址如何组织?
32位的线性地址分为三个部分:
22~31位指向页目录表中的某一项,页目录表中每一项存有4字节(32位)的地址,指向页表。所以页目录表的大小是4*2^10=4K。
12~21位指向页表中的某一项,页表的大小和也目录表一样,也是 4K。
一个物理页为 4K。刚好0~11位指向页表中的偏移,一个页表正好为 4K(2^12)。
图3-8 32位系统分页机制
3)页目录表和页表存在哪里?
页目录表和页表可以存在内存的任何地方。当分页机制开启之后,需要让 CR3 寄存器指向页目录表的起始地址,这样整个分页系统就可以正常进行工作了。
注意
CR0~CR4 这几个寄存器是系统内的控制寄存器,与分页机制密切相关,在进程管理及虚拟内存管理中会涉及这几个寄存器,读者要记住 CR0、CR2、CR3 及 CR4 这三个寄存器的内容。
CR0 控制寄存器是一些特殊的寄存器,可以控制 CPU 的一些重要特性,例如:
第0位是保护允许位(Protected Enable,PE),用于启动保护模式,如果 PE=1,则启动保护模式;如果 PE=0,则启动实模式。
第1位是监控协处理位(Monitor coprocessor,MP),它与第3位一起决定:当 TS=1 时操作码 WAIT 是否产生一个“协处理器不能使用”的出错信号。
第3位是任务转换位(Task Switch,TS),当一个任务转换完成之后,TS=1,就不能使用协处理器。
第2位是模拟协处理器位(Emulate coprocessor,EM),如果 EM=1,则不能使用协处理器;如果 EM=0,则允许使用协处理器。
CR1 是未定义的控制寄存器,供将来的处理器使用。
CR2 是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址。
CR3 是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以 4K 字节为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。
CR4 在 Pentium 系列(包括486的后期版本)处理器中才实现,处理的事务包括何时启用虚拟8086模式等。
在了解了 MMU 的工作原理之后,我们知道了其核心功能就是分段和分页。下面我们来了解 Linux 如何利用 MMU 进行内存管理的。
3.3.1 分段机制
上一节我们已经了解了 MMU 在保护模式下的分段数据主要定义在 GDT 中,那么我们先来看 Linux 中的 GDT 定义:
DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
…
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
…
} };
EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);
我们重点关注代码段和数据段:
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
这里段都是通过 GDT_ENTRY_INIT 来定义的:
struct desc_struct {
union {
struct {
unsigned int a;
unsigned int b;
};
struct {
u16 limit0;
u16 base0;
unsigned base1: 8, type: 4, s: 1, dpl: 2, p: 1;
unsigned limit: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
};
};
#define GDT_ENTRY_INIT(flags, base, limit) { { { \
.a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \
.b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
((limit) & 0xf0000) | ((base) & 0xff000000), \
} } }
结合图3-4中 GDT 结构的描述,我们可以得出如下结论:
内核代码段的 flags 为 0xc09a。
内核数据段的 flags 为 0xc092。
a 的二进制为1010说明 type 位为代码段。
2的二进制位0010说明 type 位为数据段。
用户数据段为:0xc0f3,f3 转换成二进制是11110011,说明是数据段,并且 DPL=3。
另外我们发现,这些段的基地址都是0,界限为 4G。说明 Linux 只定义了一个段,并没有真正利用分段机制,只是装装样子糊弄一下硬件而已。
3.3.2 分页机制
因为 Linux 中只用了一个段,而且基地址从0开始,那么在程序中使用的虚拟地址就是线性地址了,至于线性地址到物理地址的转换就交给分页机制来完成了。Linux 为了兼容32位、64位系统,以及32位 PAE 扩展的情况,在代码中通过4级分页机制来做兼容(参见图3-9)。
下面介绍几个概念:
PGD:页全局目录,对应32位系统中的页目录号。
PUD:页上级目录,一般在64位系统中使用。
PMD:页中间目录,一般在开启PAE功能后使用。
PTE:页表项,对应32位系统中的页号。
OFFSET:对应32位系统中的页面偏移量。
注意
从 Pentiun Pro 处理器开始,Intel 引入一种叫作物理地址扩展(Physical Address Extension,PAE)的机制。通过设置 CR4 控制寄存器中的物理地址扩展(PAE)标志,页目录项中的页大小标志 PS 启用大尺寸页(在 PAE 启用时为 2MB)。
假如一个32位的线性地址为 0x08147258,换成二制进如下所示:
0000100000 0101000111 001001011000
在4级分页机制下分别对应如下:
PGD = 0000100000
PUD = 0
PMD = 0
PTE = 0101000111
offset = 001001011000
这样就很好地兼容了32位系统。
图3-9 Linux 4级别分页机制
为了便于后续的计算和操作,Linux 又提出了如下三个概念(见图3-10):
SHIFT
SIZE
MASK
图3-10 SHIFT、SIZE、MASK 概念说明
以64位系统的 PGD 为例子:
arch/x86/include/asm/pgtable_64_types.h
#define PGDIR_SHIFT 39
#define PTRS_PER_PGD 512
#define PGDIR_SIZE (_AC(1, UL) << PGDIR_SHIFT)
#define PGDIR_MASK (~(PGDIR_SIZE - 1))
比如要计算 PGD 对应的全局页目录表项的线性地址,计算方法如下:
#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD-1))
#define pgd_offset(mm, address) ((mm)->pgd+pgd_index(address))
其他关于 PUD、PMD、PTE 等计算也是大同小异,可以结合代码对比分析。
现在,了解了 Linux 分段和分页机制以及虚拟地址、线性地址和物理地址转换的知识后,就可以去分析 Linux 如何进行内存管理了。
注意
因为在 Linux 中虚拟地址就是线性地址,所以后面章节统一用线性地址这个概念,不再用虚拟地址这个概念了。
3.4.1 物理内存管理
不管线性地址如何扩展,真正的物理内存是有限的,假如我是 Linux 开发者,肯定会在系统启动之后,先把物理内存统一放到一个地方(比如 map)管理和维护起来。
在这之前,我们先了解两个概念:UMA 和 NUMA(如图3-11所示)。
图3-11 UMA 与 NUMA
我们经常使用的 SMP(Symmetric Multi-Processor)多核 CPU,将多个处理器与一个集中的存储器和 I/O 总线相连。所有处理器只能访问同一个物理存储器,因此 SMP 系统有时也称为一致性存储器访问(UMA)体系结构,一致性意指无论在什么时候,处理器只能为内存的每个数据保持或共享唯一一个数值。很显然,SMP 的缺点是可伸缩性有限,因为在存储器和 I/O 接口达到饱和的时候,增加处理器并不能获得更高的性能。
与 SMP 对应的有 AMP 架构,不同核之间有主从关系,如一个核控制另外一个核的业务,可以理解为多核系统中控制平面和数据平面。
NUMA 模式是一种分布式存储器访问方式,处理器可以同时访问不同的存储器地址,大幅度提高了并行性。NUMA 模式下,处理器被划分成多个“节点”(node),每个节点被分配了本地存储器空间。所有节点中的处理器都可以访问全部的系统物理存储器,但是访问本节点内的存储器所需要的时间,比访问某些远程节点内的存储器所花的时间要少得多。
NUMA 系统(尤其是具有超过八个 CPU 的系统)通常比一致性内存访问系统更加经济且性能更高。一致性内存访问系统必须平等地为所有 CPU 提供内存,而 NUMA 系统则能够为直接连接到 CPU 的内存提供高速互连,同时为与 CPU 相隔较远的内存提供较为便宜但更高延迟的连接,为能在 NUMA 系统中有效扩展,操作系统或应用程序必须了解节点拓扑结构,以便使计算过程能够在包含计算数据和代码的内存附近执行。
NUMA 的主要优点是可伸缩性。NUMA 体系结构在设计上已超越了 SMP 体系结构在伸缩性上的限制。通过 SMP,所有的内存访问都传递到相同的共享内存总线。这种方式非常适用于 CPU 数量相对较少的情况,但不适用于具有几十个甚至几百个 CPU 的情况,因为这些 CPU 会相互竞争对共享内存总线的访问。NUMA 通过限制任何一条内存总线上的 CPU 数量并依靠高速互连来连接各个节点,从而缓解了这些瓶颈状况。
因为非一致性存储架构的存在,内存可能不是单一的一个节点,为了同时兼容 NUMA 和 UMA 架构的处理器,Linux 对物理内存的管理组织如图3-12所示。
图3-12 Linux 物理内存管理架构
1.pg_data
pg_data 代表一个存储节点,在 NUMA 存储结构下可能会存在多个 pg_data 结构。
该结构定义为:
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 该节点管理的 zone,ZONE_DMA、
// ZONE_NOMAL,和 ZONE_HIGHMEM
struct zonelist node_zonelists[MAX_ZONELISTS];
// 按分配时的顺序排列的 zone 列表
int nr_zones; // zone 总数
struct page *node_mem_map; // 指向该节点中的第一个页面
int node_id; // 节点 id
unsigned long node_start_pfn; // 该节点起始物理页框
unsigned long node_present_pages; // 该节点总共的物理页面数量
unsigned long node_spanned_pages; // 该节点总共的物理页面数量,包含空洞
…
} pg_data_t;
2.zone
在一个存储节点下包含了 ZONE_DMA、ZONE_NOMAL 和 ZONE_HIGHMEM 三个管理区,其中:
ZONE_DMA 的范围是 0~16M,该区域的物理页面专门供 I/O 设备的 DMA 使用。之所以需要单独管理 DMA 的物理页面,是因为 DMA 使用物理地址访问内存,不经过 MMU,并且需要连续的缓冲区。为了能够提供物理上连续的缓冲区,必须从物理地址空间专门划分一段区域用于 DMA。
ZONE_NORMAL 的范围是 16M~896M,该区域的物理页面是内核能够直接使用的。
ZONE_HIGHMEM 的范围是 896M~结束,该区域即为高端内存,内核不能直接使用。
该结构定义为:
struct zone {
…
#ifdef CONFIG_NUMA
int node; // 对应的管理节点 id
#endif
struct pglist_data *zone_pgdat; // 该区域对应的 pgdat 指针
unsigned long zone_start_pfn; // 该区域起始的物理页框
…
struct free_area free_area[MAX_ORDER]; // 该区域物理页空闲区域位图,由伙伴系统使用
unsigned long spanned_pages; // 该区域中页的总数,但并非所有的都可用,因为有空洞
unsigned long present_pages; // 该区域中实际上可用的页数目
…
const char *name; // 保存该内存域的惯用名称,目前有3个选项可用:
NORMAL、DMA、HIGHMEM
} ____cacheline_internodealigned_in_smp;
3.page
page 代表每一个物理页面,内核用数据结构 page 描述一个页框的状态信息,所有的页描述符存放在 pgdata 的 node_mem_map 数组中,其数组的下标为页框号(pfn)。
该结构定义为:
struct page {
unsigned long flags; // 这些标志位用于描述页面的状态
atomic_t _count; // 页面使用计数器
…
struct address_space *mapping; // 物理页映射的线性地址空间
…
};
我们再来提个问题,内核如何把这些物理页的信息获取到,并且初始化 pg_data 这个结构的呢?
1)系统会在启动的时候调用 detect_memory 函数,探测可用内存布局:
// 本方法用于检测内存可用大小和可用的区域
// 通过 int 0x15 BIOS 中断来获取内存参数
int detect_memory(void )
{
int err = -1;
if (detect_memory_e820() > 0)
err = 0;
if (!detect_memory_e801())
err = 0;
if (!detect_memory_88())
err = 0;
return err;
}
注意
探测一个 PC 机内存的最好方法是通过调用 INT 0x15,eax=0xe820 来实现。这个功能在2002年以后被所有 PC 机使用,这是唯一能够探测超过 4GB 大小内存的方案,当然,也可以认为这个方法是内存的最终检测方法。
实际上,这个函数返回一个非排序列表,这个列表包含了那些没有使用的项,并且可能返回存在覆盖的区域。在 Linux 中每个列表项存放在 ES:EDI 指定的内存区域中,每个项均有一定的格式:即2个8字节字段,一个2字节字段。我们前面看见了,对于内存探测的实现由函数 detect_memory_e820 来实现,在这个函数中,使用了一个 do...while()循环来实现,并将所探测的内容写入 boot_params.e820_map 数组中。
e820_map 中保存的数据结构为:struct e820entry。该结构用来保存一个物理内存段的地址信息以及类型:
struct e820entry {
__u64 addr; // 该内存段的起始地址
__u64 size; // 该内存段的大小
__u32 type; // 该内存段的类型
} __attribute__((packed));
2)内核通过 start_kernel()->paging_init()->free_area_init_node()这个调用过程做了如下事情:
paging_init:初始化 pglist_data,初始化 zone,初始化 page 数据结构。
free_area_init_node:函数将内存节点各个域做相应的初始化,并初始化 page 数据结构。
3.4.2 进程地址空间管理
1.进程地址空间划分
我们现在已经知道了 Linux 如何识别物理内存,并且对它进行管理。但是,我们在程序中直接操作的是线性地址,如何和物理地址进行转换呢?
我们在第1章介绍进程的时候就已经知道,每个进程的地址空间是独立的。内核是所有进程共享的,在内核态共享地址空间。
我们以32位的 x86 实现为例(见图3-13),Linux 给每个进程分配的线性地址空间都是 0~4GB。其中:
0~3GB 用于用户态空间使用。
3GB~3GB+896MB 映射到物理地址的 0~896MB 处,作为内核态空间。
3GB+896MB~4GB 之间的 128MB 空间,用于 vmalloc 保留区域,该区域用于 vmalloc、kmap 固定地址映射等功能,可以让内核访问高端物理地址空间。
内存的 0~8MB 之间保存了内核映象。
图3-13 Liunx 进程线性地址空间管理
2.进程线性地址相关数据结构
进程的地址空间由 mm_struct 来描述,一个进程只会有一个 mm_struct:
struct mm_struct {
struct vm_area_struct *mmap; // 虚拟地址区间列表
struct rb_root mm_rb; // 用于虚拟地址区间查找的红黑树
…
pgd_t * pgd; // pgd 指针
atomic_t mm_users; // 访问用户空间的总用户数
atomic_t mm_count; // 用户使用计数器
atomic_long_t nr_ptes; // 页表页面总数
…
int map_count; // 正在被使用的 VMA 数量
…
struct list_head mmlist; // 所有的 mm_struct 都通过它链接在一起
…
unsigned long total_vm; // 所有 vma 区域加起来的内存综合
…
unsigned long start_code, end_code, start_data, end_data;
// 代码段起始地址,结束地址,数据段起始地址,结束地址
unsigned long start_brk, brk, start_stack;
// 堆起始地址,结束地址,栈起始地址
unsigned long arg_start, arg_end, env_start, env_end;
// 命令行参数起始地址和结束地址,环境变量起始地址和结束地址
…
};
在 mm_struct 中维护了所有虚拟地址空间的虚拟内存区域 vm_area_struct:
struct vm_area_struct {
unsigned long vm_start; // 该虚拟地址空间区域起始地址
unsigned long vm_end; // 该虚拟地址空间区域结束地址
struct vm_area_struct *vm_next, *vm_prev; // 下一块虚拟地址空间区域,上一块虚拟地址
空间区域
struct rb_node vm_rb; // 虚拟地址空间区域也维护了一颗红黑树
…
struct mm_struct *vm_mm; // 该地址空间所属的 mm_struct
…
};
图3-14描述了上述结构之间的关系,要让线性地址空间有效,必须要设置分页机制,mm->pgd 指向了 cr3 寄存器设置的全局页目录表起始地址,mm_struct 结构维护了进程下面的线性地址空间区域。
图3-14 进程虚拟地址空间数据结构
注意
mm_struct 线性地址空间只有用户线程才需使用,内核线程不需要,因为内核态是共享的,不会发生缺页或者访问用户空间。所以内核线程的 task_struct->mm 为 NULL。
3.地址相关的重要概念
在了解了线性地址空间相关的数据结构之后,我们再通过几个问题来整理几个重要概念,便于后续理解。
1)内核线性地址如何找到物理地址?
在32位内核中,线性地址为 3~4G 之间,并且和物理地址 0~1G 之间是直接对应的。所以内核的线性地址和物理地址的转换关系为:
#define __pa(x) ((unsigned long) (x) - PAGE_OFFSET) // 线性地址转物理地址
#define __va(x) ((void *)((unsigned long) (x) + PAGE_OFFSET)) // 物理地址转线性
地址
2)内核物理地址如何找到页面(page 结构)?
在内核中,全局维护了一份物理地址页面数组 vmem_map,所以只要获得页号(pfn)就能得到页面结构,下面是一组系统提供的转换页号的宏定义:
pa(kaddr) >> PAGE_SHIFT 计算得到 pfn
# define pfn_to_page(pfn) (vmem_map + (pfn)) // 根据 pfn 得到 page
# define page_to_pfn(page) ((unsigned long) (page - vmem_map))// page 得到 pfn
# define __pfn_to_phys(pfn) PFN_PHYS(pfn) // 页号转物理地址,可以先通过页号转换成线性地址,然后减去 PAGE_OFFSET 就是物理地址了。大家可以自己去代码中验证
3)页表什么时候设置?
页表的分配分为两个部分:
内核页表,也就是系统在启动中,最后会在 paging_init 函数中,把 ZONE_DMA 和 ZONE_NORMAL 区域的物理页面与线性地址空间的 3G~3G+896M 进行直接映射。
内核高端地址(比如 vmalloc)和用户态地址,都是通过 MMU 机制修改线性地址和物理地址的映射关系,然后刷新页表缓存来达到目的的。
4)为何用 TLB?
TLB(Translation Lookaside Buffer,转换检测缓冲区)是一个内存管理单元,用于改进虚拟地址到物理地址转换速度的缓存。线性地址到物理地址每次转换都需要通过多级分页机制来转换,开销较大,所以 MMU 提供了后援缓冲区 TLB 来缓存这个映射关系,没必要每次都映射了。由于内核的线性地址空间是固定的,映射的物理地址空间也是固定的,没必要每次进程切换都刷新 TLB。所以 task_struct->active_mm 结构就是为此增加的,每次进程切换到内核进程都是用前一个任务的 mm 来设置 active_mm。
5)页表缓存的作用是什么?
页表的数据缓存到 CPU 的一级缓存当中,目的是提升性能。