【问题标题】:Linux memory segmentationLinux内存分段
【发布时间】:2019-10-06 09:20:10
【问题描述】:

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

如果我错了,请纠正我,但 Linux(保护模式)确实使用分页将线性虚拟地址空间映射到物理地址空间。这个由页构成的线性地址空间,对于进程平面内存模型,被分成四段,即:

  • 内核代码段(__KERNEL_CS);
  • 内核数据段(__KERNEL_DS);
  • 用户代码段(__USER_CS);
  • 用户数据段(__USER_DS);

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

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

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

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

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

然而,摘自维基百科here

[...] 许多 32 位操作系统通过将所有段的基数设置为 0 来模拟平面内存模型,以使段与程序无关。

查看 GDT here 的 linux 内核代码:

[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 等等?我倾向于相信它们是段选择器,如果不是,如果它们的寻址空间都从 0 开始,我将如何从内核段访问用户区段?

不使用分段。只使用分页。段将其seg_base 地址设置为0,将其空间扩展到0xFFFFF,从而提供完整的线性地址空间。这意味着逻辑地址与线性地址没有区别。

另外,既然所有的段相互重叠,那么是分页单元提供了内存保护(即内存分离)吗?

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

【问题讨论】:

  • 将检查所请求页面的权限级别。。不,这不是一个很好的表达方式。对于用户空间提供的地址,内核不需要检查它是用户还是内核,它只需要根据任务的逻辑内存映射(任务使用mmapbrk 管理)来检查它。因为我们有一个平面内存模型,它只是简单的整数比较,内核地址永远不会成为任务有效虚拟地址空间的一部分。
  • 内核不依赖硬件在访问无效页面时发出页面错误信号以检测-EFAULT,因此是否映射了用户空间的无效地址并不重要对于内核(例如,在恰好映射到内核内部的内核地址上调用write())。重要的是有效的用户空间地址在内核模式下仍然有效,在系统调用中。
  • 请不要一直尝试编辑问题的答案。如果您有答案,请随意发布作为答案,以便人们可以单独对其进行投票/否决,因此您的答案在其他答案之上没有特殊的位置。在原始问题的某些部分使用删除线是可以的,只要原始问题仍然存在,就可以注意误解,而不会使现有答案无效。通过添加新的误解来重新定义您的问题会为答案创建一个移动的目标。
  • 不,内核跟踪与硬件页表分开的逻辑映射。这就是为什么并非所有页面错误都是无效的(在正常的用户空间执行期间,而不是在系统调用内部);例如软和硬页面错误(写时复制或延迟映射,或页面不存在)是硬件中的#PF 异常,因为 PTE 不存在 + 有效(+ 可写),但内核不提供 SIGSEGV;它执行写时复制或其他操作并返回到用户空间,这将成功重新运行错误指令。这是一个“有效的”页面错误。
  • 不,关于这句话的几乎所有内容都是倒退和/或错误的。通过将错误地址传递给系统调用,您会得到 -EFAULT 返回值。如果您实际上取消引用用户空间中的错误指针,例如mov eax, [0],这不是硬或软页面错误,而是 invalid 页面错误,内核向您的进程提供SIGSEGV 信号。页面错误处理程序必须通过根据逻辑内存映射检查地址来判断它是有效还是无效页面错误,就像内核决定是否返回-EFAULT一样。

标签: linux linux-kernel x86 osdev memory-segmentation


【解决方案1】:

是的,Linux 使用分页,所以所有地址总是虚拟的。 (为了访问已知物理地址的内存,Linux 将所有物理内存 1:1 映射到一系列内核虚拟地址空间,因此它可以使用物理地址作为偏移量简单地索引到那个“数组”。32 的模复杂度位内核在物理 RAM 多于内核地址空间的系统上。)

这个由页面组成的线性地址空间,被分成四段

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

所以红色部分由__KERNEL_CS__KERNEL_DS两段组成

不,这是你出错的地方。 x86 段寄存器用于分段;它们是 x86 遗留包,仅用于 x86-64 上的 CPU 模式和权限级别选择。 AMD 并没有为此添加新机制并完全为长模式删除段,而是在长模式下中性化分段(基本固定为 0,就像每个人在 32 位模式下使用的一样)并继续使用段仅用于机器配置目的,而不是特别有趣,除非您实际上正在编写切换到 32 位模式或其他模式的代码。

(除非您可以为 FS 和/或 GS 设置非零基数,而 Linux 为线程本地存储这样做。但这与 copy_from_user() 的实现方式或其他任何事情无关。它只有检查该指针值,而不是参考任何段或段描述符的 CPL / RPL。)

在 32 位传统模式下,可以编写使用分段内存模型的内核,但主流操作系统都没有真正做到这一点。不过,有些人希望这已经成为一件事,例如。见this answer lamenting x86-64 making a Multics-style OS impossible。但这不是 Linux 的工作原理。

Linux 是https://wiki.osdev.org/Higher_Half_Kernel,其中内核指针具有一个值范围(红色部分),而用户空间地址位于绿色部分。如果正确的用户空间页表被映射,内核可以简单地取消引用用户空间地址,它不需要翻译它们或对段做任何事情; 这就是拥有平坦内存模型的意义。 (内核可以使用“用户”页表条目,但不能反之亦然)。对于 x86-64,具体的内存映射请参见 https://www.kernel.org/doc/Documentation/x86/x86_64/mm.txt


这 4 个 GDT 条目都需要分开的唯一原因是出于特权级别的原因,并且数据与代码段描述符具有不同的格式。 (GDT 条目不仅仅包含基数/限制;那些是需要不同的部分。请参阅https://wiki.osdev.org/Global_Descriptor_Table

尤其是https://wiki.osdev.org/Segmentation#Notes_Regarding_C,它描述了“普通”操作系统通常如何以及为什么使用 GDT 来创建平面内存模型,每个权限级别都有一对代码和数据描述符。

对于 32 位 Linux 内核,只有 gs 获得线程本地存储的非零基数(因此像 [gs: 0x10] 这样的寻址模式将访问取决于执行它的线程的线性地址)。或者在 64 位内核(和 64 位用户空间)中,Linux 使用fs。 (因为 x86-64 通过 swapgs 指令使 GS 变得特殊,旨在与 syscall 一起使用,以便内核找到内核堆栈。)

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


但是那些标志是什么,即0xc09b0xa09b等等?我倾向于相信它们是细分选择器

不,段选择器是 GDT 的索引。内核将 GDT 定义为 C 数组,使用指定初始化器语法,如 [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 位用户空间,iretsysret 会将 CS:RIP 设置为用户模式 ​​32 位段选择器。

认为你也可以让 CPU 处于 16 位兼容模式(如兼容模式不是实模式,但默认操作数大小和地址大小为 16)。不过,Linux 不这样做。

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

每个段描述符包含以下信息:

  • 段的基地址
  • 段中的默认操作大小(16位/32位)
  • 描述符的特权级别(Ring 0 -> Ring 3)
  • 粒度(Segment 限制以字节/4kb 为单位)
  • 段限制(段内的最大合法偏移量)
  • 段存在(是否存在)
  • 描述符类型(0 = 系统;1 = 代码/数据)
  • 分段类型(代码/数据/读取/写入/已访问/符合/不符合/Expand-Up/Expand-Down)

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

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

【讨论】:

【解决方案2】:

免责声明

我发布这个答案是为了清除这个话题的任何误解(正如@PeterCordes 所指出的那样)。

分页

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

内核虚拟地址可以是直接映射到连续物理页面的连续内核逻辑地址;其他内核虚拟地址是完全映射在不连续物理页面中的虚拟地址,用于大缓冲区分配(超过小型内存系统上的连续区域)和/或 PAE 内存(仅限 32 位)。 MMIO 端口(内存映射 I/O)也使用内核虚拟地址进行映射。

每个取消引用的地址必须是一个虚拟地址。无论是逻辑地址还是全虚拟地址,物理 RAM 和 MMIO 端口在使用前都会映射到虚拟地址空间中。

内核使用kmalloc()获取一块虚拟内存,由一个虚拟地址指向,但更重要的是,也是一个内核逻辑地址,意味着它直接映射到连续的物理页面(因此适用于 DMA)。另一方面,vmalloc() 例程将返回一块 完全 虚拟内存,由虚拟地址指向,但仅在虚拟地址空间上连续并映射到不连续的物理页面。

内核逻辑地址在物理地址空间和虚拟地址空间之间使用固定映射。这意味着实际上相邻的区域在本质上也是物理上相邻的。完全虚拟地址不是这种情况,它指向不连续的物理页面。

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

  • 仅映射物理内存的使用部分;
  • 内存不连续;
  • 内存可能被换出;
  • 内存可以移动;

更详细地说,4KiB 的物理内存页面映射到操作系统页表中的虚拟地址,每个映射称为 PTE(页表条目)。然后,CPU 的 MMU 将从 OS 页表中保存每个最近使用的 PTE 的缓存。这个缓存区域,称为 TLB(Translation Lookaside Buffer)。 cr3寄存器用于定位OS页表。

每当需要将虚拟地址转换为物理地址时,都会搜索 TLB。如果找到匹配项(TLB hit),则返回并访问物理地址。但是,如果没有匹配(TLB miss),TLB 未命中处理程序将查找页表以查看是否存在映射(page walk)。如果存在,则将其写回 TLB 并重新启动故障指令,这 随后的翻译将找到一个 TLB hit 并且内存访问将继续。这称为次要页面错误。

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

如果虚拟地址没有可用的映射,翻译过程可能会失败,这意味着虚拟地址无效。这称为 invalid 页面错误异常,操作系统页面错误处理程序将向进程发出 segfault

内存分割

实模式

实模式仍然使用 20 位分段内存地址空间,具有 1MiB 的可寻址内存 (0x00000 - 0xFFFFF) 以及对所有可寻址内存、总线地址、PMIO 端口(端口映射 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 */

截至今天,大多数现代 x86 CPU 仍以实模式启动以实现向后兼容性,然后切换到保护模式。

保护模式

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

平面内存模型的保护模式意味着没有分段。段的基地址设置为非 0 值的唯一例外是涉及线程本地存储时。 FS(和 64 位上的 GS)段寄存器用于此目的。

不过,SS(堆栈段寄存器)、DS(数据段寄存器)或CS(代码段寄存器)等段寄存器仍然存在并用于存储 16 位段选择器,其中包含用于分段 LDT 和 GDT(本地和全局描述符表)中的 描述符 的索引。

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

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

内存保护

分页

CPU 权限级别,也称为模式位或保护环,从 0 到 3,限制了一些在用户模式下允许会破坏保护机制或引起混乱的指令,因此它们保留给内核。尝试在环 0 之外运行它们会导致 general-protection 故障异常,这与发生无效段访问错误(特权、类型、限制、读/写权限)时的情况相同。同样,任何对内存和 MMIO 设备的访问都受到权限级别的限制,并且每次尝试访问没有所需权限级别的受保护页面都会导致页面错误异常。

每当发生中断请求 (IRQ),无论是软件(即系统调用)还是硬件,模式位都会自动从用户模式切换到管理员模式。

在 32 位系统上,只能有效寻址 4GiB 的内存,并且内存以 3GiB/1GiB 的形式进行拆分。 Linux(启用分页)使用称为高半内核的保护架构,其中平面寻址空间分为两个虚拟地址范围:

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

  • 0x00000000 - 0xBFFFFFFF 范围内的地址是用户虚拟地址(绿色区域),用户空间代码、数据和库所在的位置。映射可以在不连续的低内存和高内存页面中。

高内存仅存在于 32 位系统上。所有使用kmalloc() 分配的内存都有一个逻辑 虚拟地址(具有直接物理映射); vmalloc() 分配的内存有一个完全虚拟地址(但没有直接的物理映射)。 64 位系统具有巨大的寻址能力,因此不需要高内存,因为可以有效地寻址物理 RAM 的每一页。

在 Linux 内核中,supervisor 上半部分和 userland 下半部分之间的边界地址被称为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 将调用__bad_area_nosemaphore() routine,最终使用SIGSEGV 发出故障任务信号(使用@987654374 @得到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 */

页面还有一个特权位,称为 User/Supervisor 标志,除了 Read/Write 外,还用于 SMAP(Supervisor Mode Access Prevention) SMEP(Supervisor Mode Execution Prevention)使用的标志。

细分

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

【讨论】:

  • I/O 端口:不,I/O 地址空间(in / out 指令)是独立的且未虚拟化。没有意义,因为实际的最终地址决定了您访问的设备!对于 MMIO 地址(在物理内存地址空间中),是的,您必须通过分页来访问您想要的物理地址。 (页面映射到正确的物理地址)。
  • x86 在硬件中进行页面遍历。您所描述的“次要页面错误”实际上只是 TLB 未命中。重放加载 uop 会发生,但这在很大程度上是一个实现细节!它对软件不可见(性能计数器除外)。实际的次要页面错误是页面遍历没有找到有效的 PTE,并且必须引发#PF 异常,但内核不必等待磁盘 I/O 来解决它。 (例如,只是新分配页面的写入时复制。)再次读取Can a page fault handler generate more page faults?
  • 我指的是MMIO(内存映射I/O),我真的不知道专用总线I/O端口,除了它们使用特殊指令。你有更多信息吗?
  • "I/O port" 表示in / out 指令,而不是MMIO。指令是felixcloutier.com/x86/IN.htmlout。另请参阅opensecuritytraining.info/IntroBIOS_files/…,它看起来像是一组不错的幻灯片,我略读了一分钟。看起来它更详细地介绍了单独的 IO 与内存地址空间,并希望 in/out 在 PCI I/O 地址空间中生成 PCI 事务。 (PCI 有 3 个地址空间:内存、IO 和配置)。没有单独的巴士。
  • re:TLB 未命中:您说“如果存在”...指令重新启动...“这称为次要页面错误。” ,它什么都不知道。操作系统根本不参与此操作,它是纯硬件并且对软件不可见(通过性能计数器除外)。页面错误意味着存在#PF 异常,该异常在操作系统的页面错误处理函数中运行 x86 指令。页面遍历是在硬件中完成的,并且可以推测/无序完成。
猜你喜欢
  • 1970-01-01
  • 2018-03-12
  • 1970-01-01
  • 2013-01-17
  • 2013-08-18
  • 1970-01-01
  • 1970-01-01
  • 2012-03-24
  • 2016-09-30
相关资源
最近更新 更多