3.1.3 Linux的地址空间

从对内存的使用上来看,分段与分页在思路上是一脉相承的,分段使程序可以将相同的逻辑地址映射到不同的线性地址,而分页可以让程序将相同的线性地址映射到不同的物理地址,二者都提供了为程序隔离物理内存的功能。但二者的设计目的却稍有不同:

  • 分段的目的在于鼓励程序设计者们将程序划分成彼此关联的逻辑区块,例如将代码与数据分开

  • 分页的目的在于提供对物理内存使用的虚拟化手段,将物理内存与内存地址空间进行解耦

Linux对分段的使用非常有限,主要使用了分页功能。重要的段有4个:Linux中所有的应用程序都使用相同的段,叫着用户代码段(user code segment)与用户数据段(user data segment);内核的所有程序(kernel thread)也使用相同的段,叫着内核代码段(kernel code segment)与内核数据段(kernel data segment)。

Linux中对段的完整划分可参见这里, 我们在这里只关心重要的这4个段,其余的不再详细展开。

这4个段的Segment Descriptor初始化代码如下:

/* file: arch/x86/kernel/cpu/common.c */

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_INIT 用来初始化GDT中的Segment Descriptor, 定义如下:

/* file: arch/x86/include/asm/desc_defs.h */

#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,             \
}

这里我们只关心这四个段的几个重要属性:

可以看出,4个段的基址与大小都是一样的,也就是说Linux中所有的代码使用的都是相同的逻辑地址空间。其中基址为0, 这意味着逻辑地址中的段偏移量恰好就是虚拟地址,进而说明逻辑地址也恰好就是虚拟地址。综合起来看其实相当于整个系统就只有一个段,所以我们可以理解为Linux几乎跳过了分段功能。这意味着在Linux中,不管是内核还是用户程序都拥有相同的虚拟地址空间,只是权限(DPL)不同。也就是说Linux段寄存器中的内容不会随着进程的切换而改变,系统只会在User Mode 与 Kernel Mode 之间切换时更新段寄存器的内容。

内核为这4个段的Segment Selector分别定义了一个宏:

/* arch/x86/include/asm/segment.h */
#define GDT_ENTRY_KERNEL_CS     12
#define GDT_ENTRY_KERNEL_DS     13
#define GDT_ENTRY_DEFAULT_USER_CS   14
#define GDT_ENTRY_DEFAULT_USER_DS   15

#define __KERNEL_CS         (GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS         (GDT_ENTRY_KERNEL_DS*8)
#define __USER_DS           (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS           (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)

当系统在User Mode 与 Kernel Mode 之间切换时,将对应宏加载到对应的段寄存器即可。例如系统进入Kernel Mode 时,就需要将 __KERNEL_CS 加载到寄存器 cs 中。

Linux 不喜欢分段主要有如下两个原因:

  1. 所有程序使用相同的地址空间方便管理

  2. 跨平台的兼容性,Linux会运行在各种不同的设备上,并不是所有的硬件体系都能够很好地支持分段功能。例如RISC架构的CPU就会分段支持不是很好

另外,内核与用户程序使用相同的虚拟地址空间也可以最大程度保证TLB内容的有效性

由于内核与用户程序使用的是相同的虚拟地址空间,系统需要将整个虚拟地址空间划分成不同的区域,并规定好每个区域的用途。首先Linux区分了内核空间与用户空间,一个32位系统中二者通常以1:3的比例进行划分,其中高位的1G用于内核,低位的3G给用户程序使用。示意图如下:

系统通过权限来对不同区域的访问进行控制,内核程序可以访问整个虚拟地址空间,而用户程序无法直接访问内核的地址空间。

虽然Linux没怎么使用CPU本身提供的分段功能,但一个可执行程序本身还是由多个逻辑上独立开来的部分(Section)组成的,Linux上可执行文件的格式是 ELF(Executable and Linkable Format), 一个ELF文件会包含如下Section:

  • .text: 用于存放编译后的程序代码,即指令部分

  • .data: 用于存放已经初始化了的变量

  • .rodata: 用于存放只读数据,例如程序中的字符串

  • .bss: 用于存放未初始化的变量

既然没法为ELF中的不同Section分配独立的Segment, 系统就需要约定一个内存布局,以便在同一个物理的内存段中合理地存放ELF的不同Section. 32位Linux用户空间的内存布局如下:

至此,我们就弄清楚了Linux对内存地址的使用方式,我们几乎可以忽略段的存在,只考虑分页功能,因此当我们说地址空间时,指的都是虚拟地址空间,并且所有的程序都使用统一的虚拟地址空间。

Linux通过约定的内存布局来划分内存空间,不同硬件建构的内存布局不一样,使用的页表级数也不一样,但总体设计思路都是一致的,我们不再这里一一深入讨论。

Last updated