物理布局探测

Linux 的内存管理是操作系统核心功能之一,负责高效、安全地分配和回收物理内存资源,同时为应用程序提供抽象的虚拟内存空间.其复杂性源于现代计算机系统的多样化需求、硬件架构的差异以及性能优化的权衡. 涵盖了诸如:

  1. 虚拟内存与物理内存的映射
    • 虚拟内存:每个进程拥有独立的虚拟地址空间(通常为4GB或更大),通过页表(Page Table)映射到物理内存.这种抽象隔离了进程,防止非法访问.
    • 分页机制:物理内存被划分为固定大小的页(通常4KB),由MMU(内存管理单元)负责虚拟地址到物理地址的转换.
  1. 内存分配策略
    • 伙伴系统(Buddy System):管理物理页的分配与释放,通过合并相邻空闲页减少外部碎片.
    • Slab分配器:针对小内存对象(如内核数据结构)优化,减少内部碎片,提高分配速度.
  1. 内存回收与交换
    • 页面缓存(Page Cache):将磁盘文件数据缓存在内存中,加速I/O操作.
    • 交换空间(Swap):当物理内存不足时,将不活跃的页面换出到磁盘(通过kswapd守护进程).
    • OOM Killer:在内存耗尽时,选择"最不重要"的进程终止以释放内存.
  1. 进程地址空间管理
    • 用户空间:包含代码段、堆、栈、共享库等,通过mmap动态扩展.
    • 内核空间:直接映射物理内存(ZONE_DMA、ZONE_NORMAL等区域),处理中断和内核线程.
  1. NUMA支持
    • 针对多处理器架构,优先在本地内存节点分配内存,减少跨节点访问延迟.

等等方面的问题, 本节我们从最基本的物理内存布局探测入手, 在本系列中逐步分析这些内容


对于一个操作系统, 在启动之初有两个非常关键的问题

获取设备总内存

内存在硬件上的表现为内存条, 以及内存条上的内存颗粒. 从软件角度来看可以理解为是一大块连续的数组, 每个数组元素占 1 字节. 内存总大小等信息作为设备的关键信息,应该在硬件启动初期就由CPU获得并存储,操作系统只需要通过CPU的相关协定读取即可,这个协定就是BIOS中断

在x86芯片中,探测物理内存布局用的BIOS中断向量是0x15,根据ax寄存器值的不同,有三种常见的方式:0xE820,0xE801和0x88.

其中,0xE820 是主探测接口,0xE801、0x88 作为 0xE820 接口的补充.

// arch/x86/boot/main.c
void main() {
    // ...
    /* Detect memory layout */
    detect_memory();
    // ...
}

在 detect_memory() 函数中,依次调用了 detect_memory_e820() ,detect_memory_e801() 以及 detect_memory_88() 函数,每个函数对应着上文介绍的一种接口协议

// arch/x86/boot/memory.c
void detect_memory(void) {
    detect_memory_e820(); /* 使用e820 BIOS中断获取物理内存布局 */
    detect_memory_e801(); /* 使用e801 BIOS中断获取物理内存布局 */
    detect_memory_88(); /* 使用88 BIOS中断获取物理内存布局 */
}

E820

前文提到三种探测方式的区别在于根据 AX 寄存器值的不同.其中 e820 需要设置 AX 向量号为 0xe820,

uruk mem64mb

该接口返回已安装的内存映射以及为 BIOS 保留的物理内存区域.每次调用该 API,只会返回一段物理内存的信息,信息中会指示内存类型.为了获取完整的内存映射,需要多次调用该接口.

该接口通过寄存器传参,共有 5 个参数:

寄存器描述
EAX 功能码,值为 E820.
EBX 首次调用时,必须设置为 0.当调用后,EBX 中包含下次运行的物理地址,如果该值为 0,说明完成探测.
ES:DI Buffer 指针,BIOS 会将探测结果填充到指针指向的 Buffer 中.
ECX 以字节为单位的 Buffer 大小,最小为 20 字节.
EDX 签名,ASCII 码 "SMAP",用来验证调用者.

接口的输出结果,也保存在 5 个寄存器中:

寄存器描述
CF 状态寄存器 EFLAGSCF 标志位,用来指示请求是否出错.当 CF 为 0 时,指示未发生错误.
EAX 签名,ASCII 码 "SMAP".
ES:DI Buffer 指针,与输入一致.
ECX Buffer 大小,BIOS 返回数据的大小.
EBX 指示是否需要继续查询.当该值为 0 时,表示已查询到最后一段内存.

[!NOTE] NOTE
简而言之, 参数需要传入一个 buffer, BIOS内存探测的结果会保存在 buffer 中(ecx)

需要连续调用, 直到 ebx == 0

我们来结合这部分的代码看一下

// arch/x86/boot/memory.c
#define SMAP    0x534d4150  /* ASCII "SMAP" */

// Input:
// AX = E820h
// EAX = 0000E820h
// EDX = 534D4150h ('SMAP')
// EBX = continuation value or 00000000h to start at beginning of map
// ECX = size of buffer for result, in bytes (should be >= 20 bytes)
// ES:DI -> buffer for result (see #00581)
// int 0x15
static void detect_memory_e820(void)
{
    int count = 0;
    struct biosregs ireg, oreg;
    struct boot_e820_entry *desc = boot_params.e820_table;
    static struct boot_e820_entry buf; /* static so it is zeroed */

    initregs(&ireg);
    ireg.ax  = 0xe820;
    ireg.cx  = sizeof(buf);
    ireg.edx = SMAP;
    ireg.di  = (size_t)&buf;

    do {
        intcall(0x15, &ireg, &oreg);
        ireg.ebx = oreg.ebx; /* for next iteration... */

        if (oreg.eflags & X86_EFLAGS_CF)
            break;

        if (oreg.eax != SMAP) {
            count = 0;
            break;
        }

        *desc++ = buf;
        count++;
    } while (ireg.ebx && count < ARRAY_SIZE(boot_params.e820_table));

    boot_params.e820_entries = count;
}

初始化阶段将参数 ireg 的 ax 设置为 e820 标记这是一个 BIOS E820 的中断. 并将结果保存在 buf 中, edx 设置为 ASCII 的 "SMAP"

调用 intcall 0x15 获取结果, 循环调用直到 ireg.ebx 的值为 0, 或者超过 e820_table 的数组大小

E801

相比E820中断,E801的输出和输出就简单了不少, 输入只需要传入一个 AX 值

寄存器描述
AX 功能码,值为 E801.

接口的输出结果,也保存在 5 个寄存器中:

寄存器描述
CF 状态寄存器 EFLAGSCF 标志位,用来指示请求是否出错.当 CF 为 0 时,指示未发生错误.
AX 在 1MB 到 16 MB 之间的内存,以 KB 为单位.最大为 0x3C00,即 15 MB 内存.
BX 在 16 MB 到 4 GB 之间的内存,以 64 KB 为单位
CX 同AX
DX 同BX

为什么 AX 的最大值是 0x3C00 ?这是由于历史原因导致的.在 80286 时代,ISA 总线由 8 位扩展到了 24 位,24 位的地址线最大寻址空间为 16M,而15 MB ~ 16 MB 的空间要用于 ISA 设备的内存映射,不能自由使用,这段内存也被称为 "ISA Memory Hole"

static void detect_memory_e801(void)
{
    struct biosregs ireg, oreg;

    initregs(&ireg);
    ireg.ax = 0xe801;
    intcall(0x15, &ireg, &oreg);

    if (oreg.eflags & X86_EFLAGS_CF)
        return;

    /* Do we really need to do this? */
    if (oreg.cx || oreg.dx) {
        oreg.ax = oreg.cx;
        oreg.bx = oreg.dx;
    }

    if (oreg.ax > 15*1024) {
        return; /* Bogus! */
    } else if (oreg.ax == 15*1024) {
        boot_params.alt_mem_k = (oreg.bx << 6) + oreg.ax;
    } else {
        /*
         * This ignores memory above 16MB if we have a memory
         * hole there.  If someone actually finds a machine
         * with a memory hole at 16MB and no support for
         * 0E820h they should probably generate a fake e820
         * map.
         */
        boot_params.alt_mem_k = oreg.ax;
    }
}

代码中有一段很有意思的注释(高亮部分), 这里的两个字段 cx 和 dx 的设计可能是由于历史原因

由于 ax 的最大值为 15MB,如果大于 15MB,说明出错了,返回 -1;如果 ax 等于 15MB,说明内存大于 16MB,需要计算 bx 寄存器中的内存.由于 bx 中的内存单位为 64KB,所以左移 6 位转换成 KB,再加上 ax 的值,就得到以 KB 为单位的总内存大小,并将结果保存到 boot_params.alt_mem_k 中;如果 ax 小于 15 MB,说明内存不超过 16 MB,ax 的值就是内存总大小.

88

相比E820,E88中断可以说非常简单了. 输入只有 AH

寄存器描述
AH 功能码,值为 88.

输出只需要读取 AX 即可

寄存器描述
CF 状态寄存器 EFLAGSCF 标志位,用来指示请求是否出错.当 CF 为 0 时,指示未发生错误.
AX >1MB, 以KB为单位

这是一个比较原始的接口. 该接口返回 1 MB 以上的连续内存值,但是由于返回的是 16 位值(以 KB 为单位),因此其能返回的最大值会略低于 64 MB.也就是说,该接口会返回 1MB ~ 64 MB 之间的内存

static void detect_memory_88(void)
{
    struct biosregs ireg, oreg;

    initregs(&ireg);
    ireg.ah = 0x88;
    intcall(0x15, &ireg, &oreg);

    boot_params.screen_info.ext_mem_k = oreg.ax;
}

0x88 接口探测的是 1MB ~ 64 MB 之间的内存.探测完成后,将结果保存到 boot_params.screen_info.ext_mem_k 中

探测结果

对比上面三种内存探测方式, 可以看到 e820 探测结果最为重要, 几乎涵盖了系统使用到的大部分内存, 其中 buf 对于的结构体字段如下, 分别对应起始地址, 大小和内存类型

struct boot_e820_entry {
    __u64 addr;
    __u64 size;
    __u32 type;
} __attribute__((packed));

其中内存类型包括以下几种:

其中,ACPI reclaimable memory(ACPI 可回收内存)中保存着 ACPI 表(ACPI tables),当 ACPI 表使用完成后,这部分内存就是可用的,所以被称为可回收内存.在分配物理内存时,内存类型 2、4、5(Reserved,ACPI Non-Volatile-Sleeping,bad )不应被分配.

由于该接口返回的是未排序的内存列表,可能包含不可用的或重叠的内存区域,所以需要对该列表进行后期处理.

你可以在启动时看到对应的内存区间检测输出结果

20230628161738

就是在检测后由下面的函数负责输出

static void __init e820_print_type(enum e820_type type)
{
    switch (type) {
    case E820_TYPE_RAM:     /* Fall through: */
    case E820_TYPE_RESERVED_KERN:   pr_cont("usable");          break;
    case E820_TYPE_RESERVED:    pr_cont("reserved");            break;
    case E820_TYPE_SOFT_RESERVED:   pr_cont("soft reserved");       break;
    case E820_TYPE_ACPI:        pr_cont("ACPI data");           break;
    case E820_TYPE_NVS:     pr_cont("ACPI NVS");            break;
    case E820_TYPE_UNUSABLE:    pr_cont("unusable");            break;
    case E820_TYPE_PMEM:        /* Fall through: */
    case E820_TYPE_PRAM:        pr_cont("persistent (type %u)", type);  break;
    default:            pr_cont("type %u", type);       break;
    }
}

void __init e820__print_table(char *who)
{
    int i;

    for (i = 0; i < e820_table->nr_entries; i++) {
        pr_info("%s: [mem %#018Lx-%#018Lx] ",
            who,
            e820_table->entries[i].addr,
            e820_table->entries[i].addr + e820_table->entries[i].size - 1);

        e820_print_type(e820_table->entries[i].type);
        pr_cont("\n");
    }
}

memblock

memblock 子系统主要用于引导过程中的物理内存管理, 在操作系统内核早期的启动阶段(即尚未进入 mm_core_init)时, 此时内核尚未完全初始化和建立内存管理器. 此时内核的主要工作只是简单的读取并处理一些系统硬件的信息. 一旦内核初始化完成,memblock 子系统的功能通常会被更高级的内存管理机制所取代,如 buddy allocator(伙伴系统)或 slab allocator(SLAB 系统)

20230628182631

memblock 的功能主要包括

/**
 * struct memblock - memblock allocator metadata
 * @bottom_up: is bottom up direction? 用于判断记录的内存是否从底部往顶部增长
 * @current_limit: physical address of the current allocation limit 当前内存管理器管理的物理地址上限
 * @memory: usable memory regions 操作系统可用内存,即E820探测物理布局时,flags为usable的内存区域
 * @reserved: reserved memory regions 在boot阶段保留的内存,包括E820探测物理布局时,flags为reserved的内存区域,boot阶段分配出去的内存区域
 */
struct memblock {
    bool bottom_up;  /* is bottom up direction? */
    phys_addr_t current_limit;
    struct memblock_type memory;
    struct memblock_type reserved;
};
/**
 * struct memblock_type - collection of memory regions of certain type
 * @cnt: number of regions 记录的内存区域(memblock_region)的数量
 * @max: size of the allocated array 最多能使用的内存区域数,当预留的内存区域不足时,管理器会扩展
 * @total_size: size of all regions 所有内存区域的内存之和
 * @regions: array of regions 内存区域数组,每一项代表usable或保留的内存区域
 * @name: the memory type symbolic name 内存管理器类型的名称,例如"memory","reserved"等
 */
struct memblock_type {
    unsigned long cnt;
    unsigned long max;
    phys_addr_t total_size;
    struct memblock_region *regions;
    char *name;
};
/**
 * struct memblock_region - represents a memory region
 * @base: base address of the region 内存区域的起始地址,类型为u64或u32,表示64位/32位架构的支持最大地址长度
 * @size: size of the region 内存区域的大小
 * @flags: memory region attributes 内存区域的类型表示,有四种类型:MEMBLOCK_NONE(普通内存),MEMBLOCK_HOTPLUG(可热拔插内存),MEMBLOCK_MIRROR(镜像内存),MEMBLOCK_NOMAP(非内核直接映射内存),相同类型的相邻内存,条件合适时可以被合并
 * @nid: NUMA node id 暂时略去与NUMA相关的内容
 */
struct memblock_region {
    phys_addr_t base;
    phys_addr_t size;
    enum memblock_flags flags;
#ifdef CONFIG_NUMA
    int nid;
#endif
};


/**
 * enum memblock_flags - definition of memory region attributes
 * @MEMBLOCK_NONE: no special request
 * @MEMBLOCK_HOTPLUG: memory region indicated in the firmware-provided memory
 * map during early boot as hot(un)pluggable system RAM (e.g., memory range
 * that might get hotunplugged later). With "movable_node" set on the kernel
 * commandline, try keeping this memory region hotunpluggable. Does not apply
 * to memblocks added ("hotplugged") after early boot.
 * @MEMBLOCK_MIRROR: mirrored region
 * @MEMBLOCK_NOMAP: don't add to kernel direct mapping and treat as
 * reserved in the memory map; refer to memblock_mark_nomap() description
 * for further details
 * @MEMBLOCK_DRIVER_MANAGED: memory region that is always detected and added
 * via a driver, and never indicated in the firmware-provided memory map as
 * system RAM. This corresponds to IORESOURCE_SYSRAM_DRIVER_MANAGED in the
 * kernel resource tree.
 */
enum memblock_flags {
    MEMBLOCK_NONE       = 0x0,  /* No special request */
    MEMBLOCK_HOTPLUG    = 0x1,  /* hotpluggable region */
    MEMBLOCK_MIRROR     = 0x2,  /* mirrored region */
    MEMBLOCK_NOMAP      = 0x4,  /* don't add to kernel direct mapping */
    MEMBLOCK_DRIVER_MANAGED = 0x8,  /* always detected via a driver */
};

总结

操作系统通过BIOS 0x15中断,常见有E820、E801和E88子中断号获取设备总内存大小, 内存类型为usable的才能被操作系统所使用

memblock 子系统主要用于引导过程中的物理内存管理

参考

zood