找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2838

积分

0

好友

380

主题
发表于 5 天前 | 查看: 30| 回复: 0

对于分段机制的理解,需要从 Intel 的 8086 微处理器说起。早期的内存空间较小,采用的是直接访问物理地址的寻址方式。随着技术的发展,程序越来越大,为了获得更大的内存空间,地址总线被扩展到了 20 位。但这里出现了一个尴尬的问题:CPU 的算术逻辑单元 (ALU) 宽度只有 16 位,这意味着它无法直接计算 20 位的地址。

为了兼容早期的设计,段机制被引入来处理这种情况。这种兼容性一直被坚持,甚至在 386 处理器中依然使用段机制,直到现在的 64 位处理器上,段机制的身影才逐渐淡出。

1.1 分段机制产生的原因

为了保持兼容性,分段机制被引入。那么,它到底解决了什么实质性问题呢?在分段机制出现之前,程序运行需要从内存中分配出一块足够大的连续内存空间,然后将整个程序装载进去。

举个例子,如果一个程序大小是 100MB,那么我们就需要找到连续的 100MB 内存空间才能把它装载进去。如果找不到这样一块连续空间,程序就无法运行。

即使我们假设内存总能提供连续的区域来运行程序,依然存在几个关键问题:

地址空间不隔离(安全性问题):假设有两个运行的程序 A 和 B。程序 A 的内存地址为 0x0 到 0x100,程序 B 为 0x100 到 0x199。如果程序员 A 本意是想访问属于自己程序的地址 0x50,却不小心访问到了属于程序 B 的地址 0x150,那么糟糕的事情就会发生,两个程序都可能异常。对于程序员 B 来说,这简直是飞来横祸,而且问题很难定位。这种情况导致程序能访问所有的内存空间,恶意修改数据可能造成严重的安全问题。

程序运行时地址不确定(动态链接问题):程序每次运行都需要装载到内存中。假设你在程序中写死了要操作某个地址,比如 0x150。但问题来了,你能确保操作的这个 0x150 真的是你原本想操作的那个位置吗?很可能程序第一次装载进内存的位置是 0x100-0x199,而第二次运行时,装载位置变成了 0x0-0x100,这时你操作的 0x150 地址根本不属于这个程序占有的内存。

内存使用率低下(内存共享问题):假设我们写了三个程序:A (10M)、B (70M)、C (30M),而计算机总内存只有 100M。这三个程序加起来 110M,显然无法同时存在于内存中,最多只能同时运行两个。可能的情况是:程序 A 占用 0x00000000~0x00000009,程序 B 占用 0x00000010~0x00000079。此时程序 C 要运行怎么办?通常的做法是将其中一个程序换出到磁盘,再装载程序 C。假设换出程序 A,程序 C 依然无法装载,因为内存中空闲的连续区域只有两块:一块是程序 A 释放的 10M,另一块是 0x00000080~0x00000099 的 20M,30M 的程序 C 装不下。唯一的办法是把程序 B 换出,保留程序 A,但这样会有 60M 的内存无法被利用。

为了解决这些问题,分段的概念应运而生。在计算机科学领域,任何问题都可以尝试通过增加一个间接的中间层来解决。为了实现分段技术,就需要引入虚拟地址空间的概念。

简单来说,对于一片可以寻址的空间,如果它是虚拟的,我们就称之为虚拟地址空间;如果它是真实存在的,就称之为物理地址空间。虚拟地址空间是虚拟的,因此它可以非常大;而物理地址空间是真实的,大小由实际硬件决定。

1.2 硬件分段机制

分段是一种用于隔离不同代码、数据和栈模块的机制,能确保不同进程或任务不会互相干扰。我们可以为进程分配属于它自己的段集合,CPU 的硬件机制会保证其代码不会越权访问段,也不会访问到段外的地址。

分段机制将虚拟地址空间组织成一些长度可变的内存单元(段)。在 80386 的虚拟地址空间中,逻辑地址由段部分和段内偏移部分构成,段是虚拟地址到线性地址转换的基础。每个段由三个参数定义:

  • 段基地址:指定段在线性地址空间中的开始地址。基地址对应于段中偏移量为 0 的位置。
  • 段限长:是虚拟地址空间中段内最大可用的偏移地址,它定义了段的长度。
  • 段属性:指定段的特性,如是否可读、可写或可执行,以及段的特权级等。

虚拟地址空间与线性地址空间的映射关系示意图

当处理器需要访问地址空间中的某个字节时,段选择符指定了该字节所在的段,偏移量指定了该字节在段中相对于段基址的位置。处理器将逻辑地址转换为线性地址的过程如下:

  1. 使用段选择符中的索引值在 GDT(全局描述符表)或 LDT(局部描述符表)中定位相应的段描述符。
  2. 利用段描述符校验段的访问权限和范围,确保该段可访问且偏移量在段界限内。
  3. 利用从段描述符中取得的段基地址,加上偏移量,形成最终的线性地址。

x86分段机制中逻辑地址到线性地址的转换示意图

1.2.1 段选择符

段选择符(或段选择子)是一个 16 位的标识符,如下图所示。它并不直接指向段,而是指向在段描述符表中定义的段描述符。

段选择符(16位)结构示意图

段选择符包含 3 个字段:

  • 请求特权级 RPL (位 [0:1])
  • 表指示标志 TI (位 [2]):TI = 0,表示描述符在 GDT 中;TI = 1,表示描述符在 LDT 中。
  • 索引值:给出了描述符在 GDT 或 LDT 表中的索引项号。

下面是一些段选择符的示例:

不同段选择符对应的索引、TI、RPL字段值示例

1.2.2 段描述符

段描述符表是段描述符的一个数组,如下图所示。表的长度可变,最多可包含 8192 个 8 字节的描述符。主要有两种表:全局描述符表 GDT 和局部描述符表 LDT。段选择符的 bit[2] (TI 位) 决定从哪个表中获取段基址。

全局描述符表(GDT)与局部描述符表(LDT)结构示意图

每个段描述符长 8 字节,包含三个主要字段:段基地址、段限长和段属性。段描述符通常由编译器、链接器、加载器或操作系统创建,应用程序无法创建。

段描述符的通用格式如下:

x86保护模式下32位与16位段描述符结构示意图

了解这个过程后,我们来总体梳理一下,如果使用分段机制,虚拟地址空间如何转换到对应的物理地址空间呢?转换过程如下图所示:

使用分段机制将虚拟地址转换为物理地址的完整流程示意图

  1. 取出虚拟地址空间中的段选择符,根据 TI 位判断段描述符存储在 GDT 还是 LDT 中。
  2. 段选择符中的索引值 index * 8(即左移 3 位),就是段描述符在 GDT 中的偏移位置,再加上 GDT 的基地址,就得到了段描述符的地址,从而取出段描述符。
  3. 段描述符中保存了该段的基地址,加上虚拟地址中的偏移量,就得到了对应的物理地址。

二、Linux 中分段的实现原理

上一节讨论了 80x86 硬件如何提供对分段机制的支持,本节我们看看 Linux 是如何使用这个机制的。

最初的操作系统不支持分段,内存的换入换出都以整个进程的内存空间为单位,导致系统耗时且利用率不高。当内存不足时,很容易导致内存交换失败。后来引入分段技术,将内存空间划分为多个模块:代码段、数据段,或大的数据块。段成为了内存交换的单位,这在一定程度上提高了内存利用率。那时还没有分页技术,虚拟地址(线性地址)是直接映射到物理空间的。

引入分页机制后,目前的 Linux 很少使用分段。分段和分页在某些方面是冗余的,因为它们都可以把物理地址空间分割成不同部分:分段给每个进程分配不同的逻辑地址空间,而分页可以把相同的逻辑地址空间映射到不同的物理地址上。因此,Linux 优先采用了分页(分页操作系统),主要基于以下原因:

  • 内存管理更简单:所有进程使用相同的段寄存器值,也就是相同的线性地址集合。
  • 出于兼容性考虑:RISC 架构对分段机制的支持不是很好。

所以,自 x86-64 架构起,除了在“传统模式”下,分段机制已被认为是过时的且不再被支持。虽然在 x86-64 的本机模式下仍有分段机制的某些痕迹,但大多只是为了兼容,并不再起到相同的作用,也不再提供真正的分段。

那么 Linux 内核是如何支持分段机制的呢?我们回顾一下上节的分段机制原理图:

虚拟地址通过段表映射到物理内存的流程图

例如,我们将虚拟地址空间分成 4 个段,编号 0-3,每个段在段表中有一个表项。在物理空间中,段的排列如下图所示:

虚拟内存中四个段(代码、全局变量、堆、栈)的布局示意图

如果要访问段 2 中偏移量为 600 的虚拟地址,我们可以计算出物理地址为:段基地址 + 偏移量 = 2000 + 600 = 2600。

三、Linux 分段机制的软件实现

Linux 对段机制的应用效果几乎等同于绕过了段基址。在 Linux 中仅有 4 个段:用户代码段、用户数据段、内核代码段和内核数据段。

Linux中用户与内核的代码段、数据段描述符表示例

这些段相应的选择器分别由以下宏定义:__USER_CS__USER_DS__KERNEL_CS__KERNEL_DS。例如,要定位内核代码段,内核只需将 __KERNEL_CS 宏的值加载到 cs 寄存器中。

接下来,我们看一下 Linux 的代码。进入保护模式的函数 go_to_protected_mode

void go_to_protected_mode(void)
{
    /* Hook before leaving real mode, also disables interrupts */
    realmode_switch_hook();

    /* Enable the A20 gate */
    if (enable_a20()) {
        puts("A20 gate not responding, unable to boot...\n");
        die();
    }

    /* Reset coprocessor (IGNNE#) */
    reset_coprocessor();

    /* Mask all interrupts in the PIC */
    mask_all_interrupts();

    /* Actual transition to protected mode... */
    setup_idt();
    setup_gdt();
    protected_mode_jump(boot_params.hdr.code32_start,
             (u32)&boot_params + (ds() << 4));
}

函数内部的调用我们简要了解一下:realmode_switch_hook() 根据注释和函数名可知是在实模式切换前的钩子函数调用点;enable_a20() 是开启 A20 地址线;reset_coprocessor() 重置协处理器;mask_all_interrupts() 则是关闭所有中断,避免切换过程中出现意外。其中的 setup_idt()setup_gdt() 是本节的重点,从函数名可知是设置 IDT 和 GDT 的。我们看一下两者的具体实现:

setup_idt() 的实现很简单,纯粹将 IDT 设置为空的描述符表:

static void setup_idt(void)
{
    static const struct gdt_ptr null_idt = {0, 0};
    asm volatile("lidtl %0" : : "m" (null_idt));
}

再看 setup_gdt() 的实现:

static void setup_gdt(void)
{
    /* There are machines which are known to not boot with the GDT
     being 8-byte unaligned. Intel recommends 16 byte alignment. */
    static const u64 boot_gdt[] __attribute__((aligned(16))) = {
        /* CS: code, read/execute, 4 GB, base 0 */
        [GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff),
        /* DS: data, read/write, 4 GB, base 0 */
        [GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff),
        /* TSS: 32-bit tss, 104 bytes, base 4096 */
        /* We only have a TSS here to keep Intel VT happy;
         we don‘t actually use it for anything. */
        [GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103),
    };
    /* Xen HVM incorrectly stores a pointer to the gdt_ptr, instead
     of the gdt_ptr contents. Thus, make it static so it will
     stay in memory, at least long enough that we switch to the
     proper kernel GDT. */
    static struct gdt_ptr gdt;

    gdt.len = sizeof(boot_gdt)-1;
    gdt.ptr = (u32)&boot_gdt + (ds() << 4);

    asm volatile("lgdtl %0" : : "m" (gdt));
}

首先,我们回顾一下之前展示的 GDT entry 结构图(与图 7 相同):

x86保护模式下32位与16位段描述符结构示意图

GDT_ENTRY 宏的定义如下:

    /* Constructor for a conventional segment GDT (or LDT) entry */
    /* This is a macro so it can be used in initializers */
    #define GDT_ENTRY(flags, base, limit)            \
        ((((base) & 0xff000000ULL) << (56-24)) |    \
         (((flags) & 0x0000f0ffULL) << 40) |        \
         (((limit) & 0x000f0000ULL) << (48-16)) |    \
         (((base) & 0x00ffffffULL) << 16) |        \
         (((limit) & 0x0000ffffULL)))

可以清楚地看到,baselimitflags 通过位移和或运算组成了 GDT_ENTRY。其中 flags 代表了第 40-47 位的访问字节 (Access Byte) 和第 52-55 位的标志位 (Flags)。

  • CS 和 DS 的 flags0xc0,所以 G=1(意味着粒度是 4KB),B/D=1(32位段)。
  • CS 的 Access Byte = 0x9b,意味着 P=1(段存在)、DPL=0(特权级 0)、S=1(代码或数据段),这是一个只能在 Ring 0(内核态)下访问的代码段。
  • DS 的 Access Byte = 0x93,意味着 P=1、DPL=0、S=1,这是一个只能在 Ring 0 下访问的数据段。

在 Linux 中,逻辑地址等于线性地址。为什么这么说?因为 Linux 所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都从 0x00000000 开始,长度为 4GB。这样,线性地址 = 逻辑地址 + 0x00000000,也就是说逻辑地址等于线性地址了。

通过分析,我们发现所有段的起始地址都是 0。这算哪门子分段?所以,在 Linux 操作系统中,并没有使用到硬件分段的全部功能。那分段是不是完全没用呢?并非如此,分段机制依然被用来做权限检查。例如,用户态的 DPL 是 3,内核态的 DPL 是 0。当用户态程序试图访问内核态段时,会因为权限不足而触发错误。

Linux中GDT、段描述符与逻辑地址到线性地址转换的示意图

让我们以图中指令 mov 0x80495b0, %eax 中的地址为例,分析一下转换过程:

  1. 首先,段选择符中的 TI 位为 0,表明段描述符在 GDT 表中。使用段选择符中的索引值定位到相应的段描述符,即找到 GDT 表中的第 15 项。
  2. 从第 15 号位置的段描述符中,找到对应的访问权限、基地址 (0) 和访问范围 (0xffff)。
  3. 利用段描述符中得到的段基址 0x00000000,加上逻辑地址偏移 0x80495b0,形成线性地址 0x80495b0

因此,Linux 没有采用严格的分段机制,而是逐渐弱化了它,并用更先进的分页机制来替代分段的核心功能。

四、分段机制的优缺点

现在我们已经大致了解了分段的基本原理。系统运行时,地址空间中的不同段被重定位到物理内存中,与之前整个地址空间只有一个“基地址+偏移量”的方式相比,这大大节省了物理内存。分段管理将一个程序按照逻辑单元分成多个程序段,每个段使用自己独立的虚拟地址空间。

例如,对于编译器,我们可以给它分配 5 个段,占用 5 个虚拟地址空间,如下图所示:

编译器程序被分成五个段(符号表、代码段等)的内存布局示意图

这样,一个段占用一个虚拟地址空间,不会发生某个段空间增长时碰撞到另一个段的问题,从而避免了因空间不够而造成编译失败。当然,如果某个数据结构对空间的需求超过了整个虚拟地址空间所能提供的上限,编译仍将失败。至此,开头提到的问题 1(地址空间隔离)好像得到了完美解决。

正是因为这种虚拟地址到物理地址的映射,使得程序无需关注物理地址具体是多少,只要虚拟地址没有改变,程序就不会操作不当。问题 2(地址不确定)似乎也得到了很好的解决。

但是问题 3(内存使用率/换入换出)的关键在于,能否在换出一个程序后,把另一个程序顺利地换进来。而这种分段机制,存在一个很严重的问题:物理内存很快就会被许多空闲的小块占据,因为很难找到足够大的连续空间分配给新的段,或扩大已有的段。这种问题被称为外部碎片

非紧凑内存布局与紧凑内存布局的对比示意图

分段机制导致了一个问题:已分配的段有大有小,未使用的空闲块也有大有小,而将要分配的段需求也不一。在理想情况下,当系统中的程序较少,内存未完全使用时,分配可能是紧凑的。但在程序运行过程中,有些程序运行完毕会释放内存空间。使用一段时间后,就可能出现图中左侧“非紧凑”的情况。在这个例子中,一个进程需要分配一个 20KB 的段,当前虽有 24KB 的空闲空间,却不连续,因此操作系统无法满足这 20KB 的请求。这就是外部碎片,其特征如下:

外部碎片是指那些尚未被分配出去(不属于任何进程),但由于太小而无法分配给申请内存空间的新进程的内存空闲区域。虽然这些碎片的总和可能满足当前申请的长度要求,但由于它们的地址不连续,系统无法满足当前的连续内存申请。

五、分段机制的改进之路

为了解决外部碎片,有一些改进思路:

紧凑物理内存:重新安排原有的段。例如,操作系统先终止运行的进程,将它们的数据复制到连续的内存区域中,并改变它们段寄存器中的值,使其指向新的物理地址,从而得到足够大的连续空闲空间。但这样做成本极高,系统开销巨大,会占用大量的处理器时间。

软件优化算法:一种更简单的做法是利用空闲列表管理算法,试图保留大的内存块用于分配。相关的算法很多,例如传统的最佳适配(从空闲链表中找到最接近需要分配空间的空闲块返回)、最差适配、首次适配以及伙伴算法等。但遗憾的是,无论算法多么精妙,都无法完全消除外部碎片。

无论如何,分段机制确实解决了前两个核心问题(隔离与重定位),是一个巨大的进步。但对于内存效率问题(外部碎片),它仍然无能为力。为了解决分段机制遗留的问题,更合理的分页机制应运而生。

六、总结

分段机制解决了早期内存管理中的关键问题,帮助我们实现了更高效的虚拟内存。它不仅仅是通过动态重定位来避免程序间相互覆盖,更重要的是,通过避免地址空间中逻辑段之间潜在的大量内存浪费,分段机制更好地支持了大型程序的运行。然而,分段机制有它的局限性,尤其是外部碎片问题。这就催生了我们对更好解决方案的需求,分页机制正是为此而生。




上一篇:微信摇一摇架构拆解:高并发实时匹配系统的设计思路与核心代码
下一篇:Linux进程内存布局详解:栈、堆、bss与数据段剖析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 19:47 , Processed in 1.121414 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表