操作系统的核心任务是对系统资源的管理,而重中之重的是对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上。
而虚拟地址到物理地址映射关系的实现可以称之为地址转换(Address Translation)。
为了实现上述地址转换,操作系统需要借助硬件的帮助,即内存管理单元(Memory Management Unit,MMU)的帮助。
对于MMU应当有如下功能:
要求 | 说明 |
---|---|
特权模式 | 区分内核空间和用户空间,用户进程无法直接访问内核地址空间 |
基址/界限寄存器 | 记录地址转换基址的寄存器,用于寻址地址转换映射表 |
地址转换 | 完成地址转换过程 |
检查越界 | 完成地址转换过程中,可以检查访问是否越界 |
基址/界限寄存器特权操作指令 | 用于修改地址转换基址的寄存器,可以保证不同进程访问的映射表不同,从而映射的结果也不同 |
触发异常 | 发生越权,越界访问时,可以触发异常通知操作系统 |
异常处理特权操作指令 | 操作系统用于处理内存访问异常的入口 |
MMU配合操作系统完成了诸多功能:
本文重点关注地址转换,而地址转换的核心是页表映射。
用户进程虚拟地址空间可以划分为如下几部分:
由于进程地址空间的这种特点,MMU引入了分段的机制,一个段即地址空间中一个连续定长的区域,通常划分为:代码段,栈段和堆段。
分段基址带来的好处:操作系统可以将不同的段放到不同的物理内存区域。
支持分段机制的MMU需要有额外如下功能:
要求 | 说明 |
---|---|
段基址/界限寄存器 | 每个段都应该有基址和界限寄存器 |
段选择 | 根据虚拟地址(逻辑地址)某些位找到对应的段 |
反向增长 | 用于向下增长的栈 |
权限 | 标识段的读写,可执行权限 |
X86架构中存在三种地址:
三种地址之间由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系统中常用的段描述符类型(数据段描述符,代码段描述符,任务状态段描述符):
段描述符中的内容与本文分段一节最后提出的MMU支持分段的要求基本一致。
了解了段选择符,段描述符,GDT/LDT后,即可以理解逻辑地址转换为线性地址的过程:
Linux Kernel X86架构是如何使用分段的?实际上Linux Kernel放弃了X86提供的分段内存管理方式。因为分段虽然可以将不同的段放到不同的物理内存区域,但是分段基址的缺陷也很明显:
分段基址带来的坏处:
Linux Kernel X86架构中GDT的设计,一共有32个GDT表项,比较重要的是:
/*
* 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。