Linux内存分割

Linux memory segmentation

查看 Linux 和内存管理的内部结构,我偶然发现了 Linux 使用的分段分页模型。

如果我错了请纠正我,但是 Linux(保护模式)确实使用分页将线性虚拟地址 space 映射到物理地址 space。此线性地址space由pages构成,为进程扁平内存模型分为四段,即:

存在称为 Null 段的第五个内存段,但未使用。

这些段的 CPL(当前特权级别)为 0(主管)或 3(用户域)。

为简单起见,我将专注于 32 位内存映射,4GiB 可寻址 space,3GiB 用于用户空间进程 space(以绿色显示),1GiB 用于对于主管内核 space(以红色显示):

所以红色部分由__KERNEL_CS__KERNEL_DS两段组成,绿色部分由__USER_CS__USER_DS两段组成。

这些段相互重叠。分页将用于用户空间和内核隔离。

但是,从维基百科中提取here

[...] many 32-bit operating systems simulate a flat memory model by setting all segments' bases to 0 in order to make segmentation neutral to programs.

查看 GDT linux 内核代码 here:

[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),

正如彼得指出的那样,每个段都从 0 开始,但是这些标志是什么,即 0xc09b0xa09b 等等?我倾向于相信它们是段选择器,如果不是,如果它们的寻址 space 都从 0 开始,我将如何从内核段访问用户空间段?

不使用分段。只使用分页。段将它们的 seg_base 地址设置为 0,将它们的 space 扩展到 0xFFFFF,从而给出完整的线性地址 space。这意味着逻辑地址与线性地址没有区别。

另外,由于所有段都相互重叠,是否是提供内存保护(即内存分离)的分页单元?

分页提供保护,而不是分段。内核将检查线性地址space,并且,根据边界(通常称为TASK_MAX ), 将检查所请求页面的权限级别。

是的,Linux 使用分页,因此所有地址始终是虚拟地址。 (为了访问已知物理地址的内存,Linux 将所有物理内存 1:1 映射到内核虚拟地址范围 space,因此它可以简单地索引到 "array"使用物理地址作为偏移量。在物理 RAM 多于内核地址 space 的系统上,32 位内核的模复杂化 space。)

This linear address space constituted of pages, is split into four segments

不,Linux 使用平面内存模型。所有 4 个段描述符的基数和限制都是 0 和 -1(无限制)。即它们都完全重叠,覆盖了整个32位虚拟线性地址space.

So the red part consists of two segments __KERNEL_CS and __KERNEL_DS

不,这是你出错的地方。 x86 段寄存器用于分段;它们是 x86 遗留包袱,仅用于 CPU 模式和 x86-64 上的 privilege-level 选择。 AMD 没有为此添加新机制并完全删除长模式下的段,而是在长模式下绝育了段(基数固定为 0,就像每个人在 32 位模式下使用的一样)并仅将段用于 machine-config 目的除非您实际上正在编写切换到 32 位模式或其他模式的代码,否则并不是特别有趣。

(除非你可以为 FS and/or GS 设置一个 non-zero 基础,而 Linux 为 thread-local 存储设置一个基础。但这与如何无关copy_from_user() 已实现,或任何东西。它只需要检查指针值,而不是参考任何段或段描述符的 CPL / RPL。)

在 32 位遗留模式下,可以编写使用分段内存模型的内核,但 none 的主流 OSes 实际上是这样做的。不过,有些人希望这已经成为一件事,例如参见 this answer lamenting x86-64 making a Multics-style OS impossible。但这 不是 Linux 的工作方式。

Linux 是 https://wiki.osdev.org/Higher_Half_Kernel, where kernel pointers have one range of values (the red part) and user-space addresses are in the green part. The kernel can simple dereference user-space addresses if the right user-space page-tables are mapped, it doesn't need to translate them or do anything with segments; this is what it means to have a flat memory model. (The kernel can use "user" page-table entries, but not vice versa). For x86-64 specifically, see https://www.kernel.org/doc/Documentation/x86/x86_64/mm.txt 实际内存映射。


这 4 个 GDT 条目都需要分开的唯一原因是 privilege-level 原因,并且数据与代码段描述符具有不同的格式。 (GDT 条目不仅仅包含 base/limit;这些是需要不同的部分。参见 https://wiki.osdev.org/Global_Descriptor_Table

尤其是 https://wiki.osdev.org/Segmentation#Notes_Regarding_C,它描述了 "normal" OS 通常如何以及为什么使用 GDT 来创建平面内存模型,其中包含一对代码和数据描述符每个权限级别。

对于 32 位 Linux 内核,只有 gs 获得用于 thread-local 存储的 non-zero 基址(因此像 [gs: 0x10] 这样的寻址模式将访问一个线性地址,取决于执行它的线程)。或者在 64 位内核(和 64 位 user-space)中,Linux 使用 fs。 (因为 x86-64 使用 swapgs 指令使 GS 变得特殊,旨在与 syscall 一起用于内核查找内核堆栈。)

但是无论如何,FS 或 GS​​ 的 non-zero 基数不是来自 GDT 条目,它们是用 wrgsbase 指令设置的。 (或者在不支持的 CPU 上,写入 MSR)。


but what are those flags, namely 0xc09b, 0xa09b and so on ? I tend to believe they are the segments selectors

不,段选择器是 GDT 的索引。内核将 GDT 定义为 C 数组,使用 designated-initializer 语法,如 [GDT_ENTRY_KERNEL32_CS] = initializer_for_that_selector.

(实际上选择器的低2位,即段寄存器的值,是当前的特权级。所以GDT_ENTRY_DEFAULT_USER_CS应该是`__USER_CS >> 2。)

mov ds, eax 触发硬件对 GDT 进行索引,而不是在内存中线性搜索匹配数据!

GDT 数据格式:

您正在查看 x86-64 Linux 源代码,因此内核将处于长模式,而不是保护模式。我们可以判断,因为 USER_CSUSER32_CS 有单独的条目。 32 位代码段描述符的 L 位将被清除。当前的 CS 段描述是将 x86-64 CPU 置于 32 位兼容模式与 64 位长模式的原因。要输入 32 位 user-space,iretsysret 会将 CS:RIP 设置为 user-mode 32 位段选择器。

认为你也可以在16位兼容模式下使用CPU(比如兼容模式不是实模式,但是默认的operand-size和地址大小为 16)。但是,Linux 不会这样做。

无论如何,如 https://wiki.osdev.org/Global_Descriptor_Table 和分段中所述,

Each segment descriptor contains the following information:

  • The base address of the segment
  • The default operation size in the segment (16-bit/32-bit)
  • The privilege level of the descriptor (Ring 0 -> Ring 3)
  • The granularity (Segment limit is in byte/4kb units)
  • The segment limit (The maximum legal offset within the segment)
  • The segment presence (Is it present or not)
  • The descriptor type (0 = system; 1 = code/data)
  • The segment type (Code/Data/Read/Write/Accessed/Conforming/Non-Conforming/Expand-Up/Expand-Down)

这些是额外的位。我对哪些位是哪些不是特别感兴趣,因为我(认为我)了解不同 GDT 条目的用途和作用的高级图片,而没有深入了解实际编码方式的细节。

但是,如果您查看 x86 手册或 osdev wiki 以及那些 init 宏的定义,您应该会发现它们会导致 GDT 条目为 64 位代码段设置 L 位, 清除 32 位代码段。显然类型(代码与数据)和权限级别不同。

免责声明

我发布这个答案是为了消除对这个主题的任何误解(正如@PeterCordes 所指出的)。

分页

Linux(x86 保护模式)中的内存管理使用分页将物理地址映射到虚拟化的平面线性地址space,从0x000000000xFFFFFFFF(在 32 位上),称为 平面内存模型 。 Linux 与 CPU 的 MMU(内存管理单元)一起,将维护映射 1:1 到相应物理地址的每个虚拟和逻辑地址。物理内存通常被分成 4KiB 页面,以便更容易管理内存。

内核虚拟地址可以将连续的内核逻辑地址直接映射到连续的物理页;其他内核虚拟地址是 完全 虚拟地址映射到 not-contiguous 用于大缓冲区分配的物理页面(超过 small-memory 系统上的连续区域)and/or PAE 内存(仅限 32 位)。 MMIO 端口 (Memory-Mapped I/O) 也使用内核虚拟地址进行映射。

Every dereferenced address must be a virtual address. Either it is a logical or a fully virtual address, physical RAM and MMIO ports are mapped in the virtual address space prior to use.

内核使用kmalloc()获得一块虚拟内存,由一个虚拟地址指向,但更重要的是,也是一个内核逻辑地址,意思是直接映射到 contiguous 物理页面(因此 suitable 用于 DMA)。另一方面,vmalloc() 例程将 return 一块 完全 虚拟内存,由虚拟地址指向,但仅在虚拟地址上连续 space 并映射到 not-contiguous 个物理页面。

Kernel logical addresses use a fixed mapping between physical and virtual address space. This means virtually-contiguous regions are by nature also physically contiguous. This is not the case with fully virtual addresses, which point to not-contiguous physical pages.

用户虚拟地址 - 与内核逻辑地址不同 - 不使用虚拟地址和物理地址之间的固定映射,用户态进程充分利用MMU:

  • 只映射物理内存的已用部分;
  • 内存为not-contiguous;
  • 内存可能被换出;
  • 内存可以移动;

更详细地说,4KiB的物理内存页被映射到OS页table中的虚拟地址,每个映射称为一个PTE(页Table条目)。 CPU 的 MMU 将保存来自 OS 页 table 的每个最近使用的 PTE 的缓存。该缓存区域称为 TLB(Translation Lookaside Buffer)。 cr3寄存器用于定位OS页面table.

每当需要将虚拟地址转换为物理地址时,就会搜索 TLB。如果找到匹配项 (TLB hit),物理地址将被 returned 并访问。但是,如果没有匹配(TLB miss),TLB miss handler 将查找页面 table 以查看是否存在映射(页面 walk )。如果存在,则将其写回 TLB 并重新启动出错指令,这 随后的翻译会找到一个TLB hit,内存访问将继续。这称为 次要 页面错误。

有时,OS 可能需要通过将页面移动到硬盘来增加物理 RAM 的大小。如果虚拟地址解析为映射到硬盘中的页面,则该页面需要在访问之前加载到物理 RAM 中。这称为 主要 页面错误。 OS 页面错误处理程序然后需要在内存中找到空闲页面。

如果没有可用于虚拟地址的映射,则转换过程可能会失败,这意味着虚拟地址无效。这称为 invalid 页面错误异常,并且 OS 页面错误处理程序将向进程发出 segfault

内存分割

实模式

实模式仍然使用20位分段内存地址space,具有1MiB的可寻址内存(0x00000 - 0xFFFFF)和对所有可寻址内存、总线地址、PMIO端口( Port-Mapped I/O) 和外围硬件。实模式提供无内存保护、无特权级别且无虚拟地址。通常,段寄存器包含段选择器值,内存操作数是相对于段基址的偏移值。

为了解决分段问题(C 编译器通常只支持平面内存模型),C 编译器使用非官方的 far 指针类型来表示具有 segment:offset 逻辑地址符号的物理地址。例如,逻辑地址 0x5555:0x0005,在计算 0x5555 * 16 + 0x0005 后产生 20 位物理地址 0x55555,可用于远指针,如下所示:

char far    *ptr;           /* declare a far pointer */
ptr = (char far *)0x55555;  /* initialize a far pointer */

As of today, most modern x86 CPUs still start in real mode for backwards compatibility and switch to protected mode thereafter.

保护模式

在保护模式下,平面内存模型,分段未使用。这四个段,即 __KERNEL_CS__KERNEL_DS__USER_CS__USER_DS 都将其基地址设置为 0。这些段只是前 x86 模型的遗留包袱,其中分段米使用了 mory 管理。在保护模式下,由于所有段基地址都设置为0,所以逻辑地址等价于线性地址

Protected mode with the flat memory model means no segmentation. The only exception where a segment has its base address set to a value other than 0 is when thread-local storage is involved. The FS (and GS on 64-bit) segment registers are used for this purpose.

但是SS(堆栈段寄存器)、DS(数据段寄存器)或CS(代码段寄存器)等段寄存器仍然存在,用于存储16 位段 selectors,其中包含 LDT 和 GDT 中段 descriptors 的索引(局部和全局描述符 Table)。

每个接触内存的指令隐式使用段寄存器。根据上下文,使用特定的段寄存器。例如,JMP 指令使用 CSPUSH 使用 SS。选择器可以用 MOV 之类的指令加载到寄存器中,唯一的例外是 CS 寄存器,它只能被影响 执行流程 的指令修改,例如 CALLJMP.

CS 寄存器特别有用,因为它在其段选择器中跟踪 CPL(当前特权级别),从而保存当前段的特权级别。此 2 位 CPL 值 始终 等同于 CPU 当前特权级别。

内存保护

分页

CPU特权级,也称为模式位或保护环,从0到3,限制一些可以破坏保护机制或导致如果在用户模式下允许混乱,那么它们将保留给内核。尝试在 ring 0 之外 运行 它们会导致 general-protection 故障异常,发生无效段访问错误时的相同情况(特权,类型,限制,read/write 权利)。同样,对内存和 MMIO 设备的任何访问都根据特权级别进行限制,并且每次尝试在没有所需特权级别的情况下访问受保护页面都将导致页面错误异常。

每当中断请求(IRQ),软件(即系统调用[=175]时,模式位将自动从用户模式切换到管理员模式=]) 或硬件,发生。

在32位系统上,只能有效寻址4GiB内存,内存以3GiB/1GiB的形式拆分。 Linux(启用分页)使用一种称为 higher half kernel 的保护模式,其中平面寻址 space 分为两个虚拟地址范围:

  • 0xC0000000 - 0xFFFFFFFF范围内的地址是内核虚拟地址(红色区域)。 896MiB 范围 0xC0000000 - 0xF7FFFFFF 直接将内核逻辑地址 1:1 与内核物理地址映射到连续的 low-memory 页(使用 __pa()__va() 宏)。然后使用剩余的 128MiB 范围 0xF8000000 - 0xFFFFFFFF 将虚拟地址映射到大型缓冲区分配、MMIO 端口(Memory-Mapped I/O)and/or PAE 内存到 not-contiguous high-memory 页(使用 ioremap()iounmap())。

  • 0x00000000 - 0xBFFFFFFF范围内的地址是用户虚拟地址(绿色区域),用户态代码、数据和库所在的位置。映射可以在 not-contiguous low-memory 和 high-memory 页面中。

High-memory is only present on 32-bit systems. All memory allocated with kmalloc() has a logical virtual address (with a direct physical mapping); memory allocated by vmalloc() has a fully virtual address (but no direct physical mapping). 64-bit systems have a huge addressing capability hence does not need high-memory, since every page of physical RAM can be effectively addressed.

主管高半部分和用户空间低半部分之间的边界地址在Linux内核中被称为TASK_SIZE_MAX。内核将检查来自任何用户态进程的每个访问的虚拟地址是否位于该边界以下,如以下代码所示:

static int fault_in_kernel_space(unsigned long address)
{
    /*
     * On 64-bit systems, the vsyscall page is at an address above
     * TASK_SIZE_MAX, but is not considered part of the kernel
     * address space.
     */
    if (IS_ENABLED(CONFIG_X86_64) && is_vsyscall_vaddr(address))
        return false;

    return address >= TASK_SIZE_MAX;
}

如果用户态进程试图访问高于 TASK_SIZE_MAX 的内存地址,do_kern_addr_fault() routine will call the __bad_area_nosemaphore() routine,最终会用 SIGSEGV 向错误任务发出信号(使用 get_current() 得到 task_struct):

/*
 * To avoid leaking information about the kernel page table
 * layout, pretend that user-mode accesses to kernel addresses
 * are always protection faults.
 */
if (address >= TASK_SIZE_MAX)
    error_code |= X86_PF_PROT;

force_sig_fault(SIGSEGV, si_code, (void __user *)address, tsk); /* Kill the process */

Pages also have a privilege bit, known as the User/Supervisor flag, used for SMAP (Supervisor Mode Access Prevention) in addition to the Read/Write flag that SMEP (Supervisor Mode Execution Prevention) uses.

分段

使用分段的旧架构通常使用 GDT 特权位对每个请求的段执行段访问验证。请求段的特权位,称为 DPL(描述符特权级别),与当前段的 CPL 进行比较,确保 CPL <= DPL。如果为真,则允许对请求的段进行内存访问。