深入Linux内核(内存篇)—页表映射分段

深入Linux内核(内存篇)—页表映射

  • 一、分段
  • 二、X86中的分段
  • 三、Kernel中的分段

操作系统的核心任务是对系统资源的管理,而重中之重的是对CPU和内存的管理。为了使进程摆脱系统内存的制约,用户进程运行在虚拟内存之上,每个用户进程都拥有完整的虚拟地址空间,互不干涉。

而实现虚拟内存的关键就在于建立虚拟地址(Virtual Address,VA)与物理地址(Physical Address,PA)之间的关系,因为无论如何数据终究要存储到物理内存中才能被记录下来。
如下图所示,进程1和进程2拥有完整的虚拟地址空间,虚拟地址空间分为了用户空间和内核空间,对于不同的进程面对的都是同一个内核,其内核空间的地址对于的物理地址都是一样的,因而进程1和进程2中内核空间的VA K地址都映射到了物理内存的PA K地址。而不同的进程的用户空间是不同的,进程1和进程2相同的虚拟地址VA 1和VA 2分别映射到了不同的物理地址PA 1和PA 2上。
深入Linux内核(内存篇)—页表映射分段_第1张图片
而虚拟地址到物理地址映射关系的实现可以称之为地址转换(Address Translation)
为了实现上述地址转换,操作系统需要借助硬件的帮助,即内存管理单元(Memory Management Unit,MMU)的帮助。
对于MMU应当有如下功能:

要求 说明
特权模式 区分内核空间和用户空间,用户进程无法直接访问内核地址空间
基址/界限寄存器 记录地址转换基址的寄存器,用于寻址地址转换映射表
地址转换 完成地址转换过程
检查越界 完成地址转换过程中,可以检查访问是否越界
基址/界限寄存器特权操作指令 用于修改地址转换基址的寄存器,可以保证不同进程访问的映射表不同,从而映射的结果也不同
触发异常 发生越权,越界访问时,可以触发异常通知操作系统
异常处理特权操作指令 操作系统用于处理内存访问异常的入口

MMU配合操作系统完成了诸多功能:

  • 用户空间和内核空间,通过特权模式划分了内核空间和用户空间,用户空间无法直接访问内核空间,必须通过某些手段(系统调用,异常,中断等)切换到特权模式才能间接访问内核。
  • 地址转换,通过基址/界限寄存器记录的转换映射表基址,结合虚拟地址,可以完成地址转换的功能,从而实现通过虚拟地址访问到物理地址。
  • 进程独立的虚拟地址空间,通过基址/界限寄存器的访问指令,在进程切换时修改基址/界限寄存器的值,从而使MMU在做地址转换时找到各个进程对应的地址映射表,从而实现不同进程虚拟地址完全独立。
  • 缺页异常,对于进程申请的内存,并不需要在其申请内存时即建立地址转换映射表,同时分配对应的物理空间,而是在进程真正访问内存地址时,MMU上报缺页异常再分配对应的物理空间。当然虚拟地址到物理地址映射表中的一些标志区可以实现更多的缺页异常类型,例如读写权限错误,特权错误,越界错误等异常。

本文重点关注地址转换,而地址转换的核心是页表映射。

一、分段

用户进程虚拟地址空间可以划分为如下几部分:

  • 代码段和数据段,代码段即程序运行的主体,数据段包括全局变量和静态变量等。这部分区域在可执行文件加载时,内核就为其分配了页表及物理页面;
  • 栈,该部分保存程序运行时的局部变量等信息;
  • mmap区,用于mmap系统调用映射page cache或者匿名页面;
  • 堆区,用于用户进程动态申请内存的区域。

深入Linux内核(内存篇)—页表映射分段_第2张图片
由于进程地址空间的这种特点,MMU引入了分段的机制,一个段即地址空间中一个连续定长的区域,通常划分为:代码段,栈段和堆段。
分段基址带来的好处:操作系统可以将不同的段放到不同的物理内存区域。
支持分段机制的MMU需要有额外如下功能:

要求 说明
段基址/界限寄存器 每个段都应该有基址和界限寄存器
段选择 根据虚拟地址(逻辑地址)某些位找到对应的段
反向增长 用于向下增长的栈
权限 标识段的读写,可执行权限

二、X86中的分段

X86架构中存在三种地址:

  • 逻辑地址(logical address),分段机制中使用,由一个段(segment)和一个偏移量(offset)组成,偏移量即逻辑地址距段起始地址的长度。如下图为一个逻辑地址的表示方式。
    深入Linux内核(内存篇)—页表映射分段_第3张图片
  • 线性地址(liner address),分段机制中由逻辑地址转换后得到的地址,为32位无符号整数。
  • 物理地址(physical address),分页基址中由线性地址转换得到的地址,对应物理内存芯片的地址。

三种地址之间由MMU进程转换,其关系如下所示。

在这里插入图片描述
逻辑地址由段选择符和偏移组成,段选择符的作用是选择某一个段描述符(Segment Descriptor),段描述符才是记录段信息的地方。
段选择符如何选择某一个段,由其16bit的决定。

字段 说明
index 选择GDT/LDT中的段描述符
TI 为0表示选择GDT,为1表示选择LDT
RPL 请求特权等级

上表中的GDT/LDT为全局描述符表(Global Descriptor Table)和局部描述符表(Local Descriptor Table)。其存放着段描述符。GDT存放在内存中,其基地址和大小存放在gdtr寄存器中。

X86架构Linux系统中常用的段描述符类型(数据段描述符,代码段描述符,任务状态段描述符):
深入Linux内核(内存篇)—页表映射分段_第4张图片
段描述符中的内容与本文分段一节最后提出的MMU支持分段的要求基本一致。

  • BASE,即段基址;
  • LIMIT,即段的长度;
  • DPL,Descriptor Privilege Level,特权等级;
  • G,段的粒度,为0则段大小是字节单位,为1则以4K字节为大小。

了解了段选择符,段描述符,GDT/LDT后,即可以理解逻辑地址转换为线性地址的过程:
深入Linux内核(内存篇)—页表映射分段_第5张图片

  • 通过段选择符中的TI字段选择使用GDT还是LDT;
  • 通过段选择符中的Index字段计算出段描述符在GDT/LDT中的位置,由于每个段描述符是8字节,因而GDT或LDT的内存物理基地址(gdtr/ldtr寄存器中存放)加上Index * 8可以得到段描述符地址;
  • 通过段描述符在的Base字段与逻辑地址中的offset相加,即得到最终的线性地址。

三、Kernel中的分段

Linux Kernel X86架构是如何使用分段的?实际上Linux Kernel放弃了X86提供的分段内存管理方式。因为分段虽然可以将不同的段放到不同的物理内存区域,但是分段基址的缺陷也很明显:

分段基址带来的坏处:

  1. 进程切换时,需要将各个段寄存器的内存保存并恢复。
  2. 内存外部碎片问题,每个进程都拥有自己的段地址,最终可能导致系统内存分布着大量小的空隙,而没有连续的物理内存。
  3. 此外,Linux支持的CPU体系架构非常庞大,RISC架构CPU普遍不支持分段,为了保持良好的兼容性设计,分段基址可以弃用。

Linux Kernel X86架构中GDT的设计,一共有32个GDT表项,比较重要的是:

  • kernel code segment
  • kernel data segment
  • default user CS
  • default user DS
/*
 * The layout of the per-CPU GDT under Linux:
 *
 *   0 - null								<=== cacheline #1
 *   1 - reserved
 *   2 - reserved
 *   3 - reserved
 *
 *   4 - unused								<=== cacheline #2
 *   5 - unused
 *
 *  ------- start of TLS (Thread-Local Storage) segments:
 *
 *   6 - TLS segment #1			[ glibc's TLS segment ]
 *   7 - TLS segment #2			[ Wine's %fs Win32 segment ]
 *   8 - TLS segment #3							<=== cacheline #3
 *   9 - reserved
 *  10 - reserved
 *  11 - reserved
 *
 *  ------- start of kernel segments:
 *
 *  12 - kernel code segment						<=== cacheline #4
 *  13 - kernel data segment
 *  14 - default user CS
 *  15 - default user DS
 *  16 - TSS								<=== cacheline #5
 *  17 - LDT
 *  18 - PNPBIOS support (16->32 gate)
 *  19 - PNPBIOS support
 *  20 - PNPBIOS support						<=== cacheline #6
 *  21 - PNPBIOS support
 *  22 - PNPBIOS support
 *  23 - APM BIOS support
 *  24 - APM BIOS support						<=== cacheline #7
 *  25 - APM BIOS support
 *
 *  26 - ESPFIX small SS
 *  27 - per-cpu			[ offset to per-cpu data area ]
 *  28 - stack_canary-20		[ for stack protector ]		<=== cacheline #8
 *  29 - unused
 *  30 - unused
 *  31 - TSS for double fault handler
 */

Linux Kernel初始化GDT表如下。

#define GDT_ENTRY_INIT(flags, base, limit)			\
	{							\
		.limit0		= (u16) (limit),		\
		.limit1		= ((limit) >> 16) & 0x0F,	\
		.base0		= (u16) (base),			\
		.base1		= ((base) >> 16) & 0xFF,	\
		.base2		= ((base) >> 24) & 0xFF,	\
		.type		= (flags & 0x0f),		\
		.s		= (flags >> 4) & 0x01,		\
		.dpl		= (flags >> 5) & 0x03,		\
		.p		= (flags >> 7) & 0x01,		\
		.avl		= (flags >> 12) & 0x01,		\
		.l		= (flags >> 13) & 0x01,		\
		.d		= (flags >> 14) & 0x01,		\
		.g		= (flags >> 15) & 0x01,		\
	}

DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
#ifdef CONFIG_X86_64
	/*
	 * We need valid kernel segments for data and code in long mode too
	 * IRET will check the segment types  kkeil 2000/10/28
	 * Also sysret mandates a special GDT layout
	 *
	 * TLS descriptors are currently at a different place compared to i386.
	 * Hopefully nobody expects them at a fixed place (Wine?)
	 */
	[GDT_ENTRY_KERNEL32_CS]		= GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
	[GDT_ENTRY_KERNEL_CS]		= GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
	[GDT_ENTRY_KERNEL_DS]		= GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
	[GDT_ENTRY_DEFAULT_USER32_CS]	= GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
	[GDT_ENTRY_DEFAULT_USER_DS]	= GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
	[GDT_ENTRY_DEFAULT_USER_CS]	= GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
#else
	[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),
	/*
	 * Segments used for calling PnP BIOS have byte granularity.
	 * They code segments and data segments have fixed 64k limits,
	 * the transfer segment sizes are set at run time.
	 */
	/* 32-bit code */
	[GDT_ENTRY_PNPBIOS_CS32]	= GDT_ENTRY_INIT(0x409a, 0, 0xffff),
	/* 16-bit code */
	[GDT_ENTRY_PNPBIOS_CS16]	= GDT_ENTRY_INIT(0x009a, 0, 0xffff),
	/* 16-bit data */
	[GDT_ENTRY_PNPBIOS_DS]		= GDT_ENTRY_INIT(0x0092, 0, 0xffff),
	/* 16-bit data */
	[GDT_ENTRY_PNPBIOS_TS1]		= GDT_ENTRY_INIT(0x0092, 0, 0),
	/* 16-bit data */
	[GDT_ENTRY_PNPBIOS_TS2]		= GDT_ENTRY_INIT(0x0092, 0, 0),
	/*
	 * The APM segments have byte granularity and their bases
	 * are set at run time.  All have 64k limits.
	 */
	/* 32-bit code */
	[GDT_ENTRY_APMBIOS_BASE]	= GDT_ENTRY_INIT(0x409a, 0, 0xffff),
	/* 16-bit code */
	[GDT_ENTRY_APMBIOS_BASE+1]	= GDT_ENTRY_INIT(0x009a, 0, 0xffff),
	/* data */
	[GDT_ENTRY_APMBIOS_BASE+2]	= GDT_ENTRY_INIT(0x4092, 0, 0xffff),

	[GDT_ENTRY_ESPFIX_SS]		= GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
	[GDT_ENTRY_PERCPU]		= GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
	GDT_STACK_CANARY_INIT
#endif
}

由宏GDT_ENTRY_INIT可知GDT_ENTRY_KERNEL_CS,GDT_ENTRY_KERNEL_DS,GDT_ENTRY_DEFAULT_USER_CS,GDT_ENTRY_DEFAULT_USER_DS对段描述符的赋值为:

BASE Limit G S DPL P Type
KERNEL_CS 0 0XFFFF 1 1 0 1 0xA
KERNEL_DS 0 0XFFFF 1 1 0 1 0x2
USER_CS 0 0XFFFF 1 1 3 1 0xA
USER_DS 0 0XFFFF 1 1 3 1 0x2

显然内核代码段,内核数据段,用户代码段,用户数据段的段描述符基址都是0,即Linux中逻辑地址中偏移量和线性地址是一样的。
DPL字段反应了特权等级,显然内核态为0,用户态为3。

鉴于分段机制的缺陷,内核中都没有真正使用分段机制,操作系统对页表的管理实际是使用了分页机制。

分页机制请看文章《深入Linux内核(内存篇)—页表映射分页》

本文内核版本为Linux5.6.4。

你可能感兴趣的:(深入Linux内核,内核,操作系统,linux)