从 malloc 到物理页:把 Linux 内存管理链路串起来

从 malloc 到物理页:把 Linux 内存管理链路串起来

前七篇已经把 Linux 内存管理拆成了几块:

  • 第 1 篇讲进程看到的是虚拟地址,不是物理内存。
  • 第 2 篇讲物理内存被组织成 node、zone、PFN 和 struct page
  • 第 3 篇讲 zone 里面的水位线、free_area、buddy 和回收入口。
  • 第 4 篇vm_area_struct 是进程地址空间的合法区间表。
  • 第 5 篇讲 VMA 和页表如何在缺页异常里配合。
  • 第 6 篇讲 slab/SLUB 如何给内核小对象分配空间。
  • 第 7 篇task_structmm_struct、用户栈、内核栈、页表和 VMA 在进程/线程身上如何组织。

这一篇不再引入新的核心结构,而是用一个问题把它们串起来:

一次 malloc 返回的地址,什么时候只是虚拟地址,什么时候真正对应物理页?这条路上,VMA、页表、buddy、slab、task/mm 又分别出现在哪里?

先把结论放前面:

c 复制代码
   malloc 返回的指针
        │
        │  先是用户态 allocator 的结果
        ▼
   必要时通过 brk / mmap 扩展进程地址空间
        │
        │  这一步主要创建或扩大 VMA
        ▼
   第一次真正读写某个页
        │
        ▼
   CPU page walk 失败,触发缺页异常
        │
        ▼
   内核查 VMA,确认地址和权限合法
        │
        ▼
   分配用户数据页,必要时分配页表页
        │
        ▼
   填 PTE:虚拟页 -> 物理页框
        │
        ▼
   出错指令重新执行,这次访问成功

这里最容易混的是两条分配路径:

rust 复制代码
   用户数据页:page fault -> alloc_pages -> zone / buddy -> struct page -> PTE

   内核元数据:vm_area_struct / task_struct / inode ...
        -> kmem_cache_alloc -> slab / SLUB -> 必要时向 buddy 要页

malloc 返回给用户程序的那块内存,不是 slab 直接切出来的对象。slab 管的是内核自己的小对象;用户数据页通常来自 buddy,只是要等到缺页时才真正兑现。

一、把前七篇放到一张总图里

下面这张图故意很大。它不是精确源码调用图,而是把前七篇的结构关系放到一次动态路径里。这里用纯文本图,避免依赖 Markdown 预览器是否支持 Mermaid。

text 复制代码
┌──────────────────────────────────────────────────────────────────────────────┐
│                         一次 malloc 串起的完整链路                          │
└──────────────────────────────────────────────────────────────────────────────┘

  用户线程正在执行
  第 7 篇:线程是一个 task,普通用户线程有 task_struct、用户栈、内核栈
        │
        ▼
  current task_struct
        │
        ├─ task->stack --------------------------► 当前 task 的内核栈
        ├─ thread / thread_info -----------------► 架构相关线程状态
        └─ task->mm
              │
              ▼
        mm_struct
              │
              ├─ VMA tree / maple tree ----------► 第 4 篇:合法区间表
              │       │
              │       ├─ code VMA
              │       ├─ heap VMA
              │       ├─ mmap anonymous VMA
              │       ├─ file mapping VMA
              │       └─ user stack VMA
              │
              └─ pgd ----------------------------► 第 5 篇:页表根
                      │
                      ▼
              pgd / p4d / pud / pmd / pte


┌──────────────────────────────────────────────────────────────────────────────┐
│                           1. 用户态 malloc 路径                              │
└──────────────────────────────────────────────────────────────────────────────┘

  malloc(size)
        │
        ├─ 小块,并且 libc arena 里已有空间
        │      │
        │      ▼
        │   只在用户态 allocator 里切一块
        │   不一定进入内核,不一定新增 VMA
        │
        └─ arena 不够,或者分配很大
               │
               ▼
            brk / mmap 系统调用
               │
               ▼
            进入内核,找到 current->mm
               │
               ▼
            创建、扩大、合并或拆分 VMA
               │
               ├─ 需要 vm_area_struct 元数据
               │      │
               │      ▼
               │   kmem_cache_alloc(vm_area_struct cache)
               │      │
               │      ▼
               │   slab / SLUB
               │      │
               │      └─ slab page 不够时,继续向 buddy 要页
               │
               ▼
            返回用户虚拟地址 p

  注意:
  p 是用户虚拟地址。到这里为止,VMA 可以已经存在,
  但 p 覆盖的每个虚拟页不一定已经有 present PTE。


┌──────────────────────────────────────────────────────────────────────────────┐
│                         2. 第一次访问触发缺页路径                            │
└──────────────────────────────────────────────────────────────────────────────┘

  用户代码执行:p[i] = 1
        │
        ▼
  CPU / MMU 查 TLB
        │
        ├─ TLB 命中
        │      │
        │      ▼
        │   直接得到物理页框,访问成功
        │
        └─ TLB 未命中
               │
               ▼
            hardware page walk 查页表
               │
               ├─ PTE present
               │      │
               │      ▼
               │   建立 TLB,访问成功
               │
               └─ PTE not present / 权限异常
                      │
                      ▼
                   page fault 进入内核
                      │
                      ▼
                   current task_struct
                      │
                      ▼
                   task->mm
                      │
                      ▼
                   find_vma / lock_vma_under_rcu
                      │
                      ├─ 找不到覆盖地址的 VMA
                      │      └─ SIGSEGV
                      │
                      ├─ 找到 VMA,但访问权限不匹配
                      │      └─ SIGSEGV
                      │
                      └─ VMA 合法,权限允许
                             │
                             ▼
                         handle_mm_fault
                             │
                             ├─ 匿名页第一次写
                             │      └─ 分配匿名用户页,清零,填 PTE
                             │
                             ├─ 匿名页第一次读
                             │      └─ 可能映射共享零页
                             │
                             ├─ 文件映射第一次访问
                             │      └─ filemap_fault,找 page cache,必要时读文件
                             │
                             ├─ fork 后 COW 写
                             │      └─ do_wp_page,复制或复用页,更新 PTE
                             │
                             └─ swap entry
                                    └─ do_swap_page,换入后填 PTE


┌──────────────────────────────────────────────────────────────────────────────┐
│                          3. 用户数据页从哪里来                               │
└──────────────────────────────────────────────────────────────────────────────┘

  匿名写缺页 / COW / 页表页分配
        │
        ▼
  alloc_pages / folio_alloc
        │
        ▼
  选择 NUMA node
        │
        ▼
  选择 zone:DMA / DMA32 / Normal / Movable ...
        │
        ▼
  检查 zone watermark:min / low / high
        │
        ├─ 水位不足
        │      │
        │      ├─ 唤醒 kswapd
        │      ├─ 当前线程 direct reclaim
        │      ├─ 回收 page cache 或换出匿名页
        │      ├─ compaction 整理连续页
        │      └─ 仍失败时可能走 OOM / 分配失败路径
        │
        └─ 水位足够
               │
               ▼
            per-cpu pageset 快路径
               │
               ├─ 有合适页
               │      └─ 返回 page frame
               │
               └─ 没有合适页
                      │
                      ▼
                   buddy allocator
                      │
                      ▼
                   free_area[order]
                      │
                      ├─ 当前 order 有空闲块
                      │      └─ 取出 2^order 个连续页
                      │
                      └─ 当前 order 没有
                             │
                             ▼
                          找更高 order
                             │
                             ▼
                          拆大块
                             │
                             ├─ 一半返回
                             └─ 剩余部分挂回低 order 链表
                                    │
                                    ▼
                               struct page 描述得到的物理页
                                    │
                                    ├─ 用户数据页:映射给 p[i]
                                    ├─ 页表页:保存 PTE/PMD 等页表项
                                    └─ slab page:给内核对象分配器继续切对象


┌──────────────────────────────────────────────────────────────────────────────┐
│                         4. 页表如何把结果兑现给用户                          │
└──────────────────────────────────────────────────────────────────────────────┘

  得到用户数据页 / page cache 页 / COW 新页 / swap 换入页
        │
        ▼
  必要时补齐中间页表页
        │
        ▼
  填写 PTE
        │
        ├─ present
        ├─ writable / readonly
        ├─ user / supervisor
        ├─ dirty / accessed
        └─ PFN:指向物理页框
        │
        ▼
  刷新或更新 TLB 相关状态
        │
        ▼
  从 page fault 返回用户态
        │
        ▼
  重新执行刚才失败的指令:p[i] = 1
        │
        ▼
  这次页表能翻译成功,写入真正落到物理页


┌──────────────────────────────────────────────────────────────────────────────┐
│                         5. slab 这一侧到底负责什么                           │
└──────────────────────────────────────────────────────────────────────────────┘

  内核需要小对象
        │
        ├─ vm_area_struct:描述一段 VMA
        ├─ task_struct:描述一个 task
        ├─ dentry / inode:文件系统对象
        └─ kmalloc-64 / kmalloc-512 等通用小块
        │
        ▼
  kmem_cache_alloc
        │
        ▼
  SLUB per-cpu freelist
        │
        ├─ freelist 有空闲对象
        │      └─ 直接弹出对象,快路径返回
        │
        └─ freelist 没有空闲对象
               │
               ▼
            找当前 CPU 的 partial slab
               │
               ├─ CPU partial 有可用 slab
               │      └─ 从 slab page 里拿一个对象
               │
               └─ CPU partial 没有或不合适
                      │
                      ▼
                   找当前 NUMA node 的 partial slab
                      │
                      ├─ node partial 有可用 slab
                      │      └─ 从 slab page 里拿一个对象
                      │
                      └─ node partial 也没有可用 slab
                             │
                             ▼
                          向 buddy 要一个或多个页
                             │
                             ▼
                          切成固定大小对象
                             │
                             ▼
                          返回其中一个对象

  关键分工:

  用户数据页:
      p[i] 的内容
      page fault -> alloc_pages -> buddy -> 填 PTE

  内核元数据对象:
      vm_area_struct / task_struct / dentry / inode ...
      kmem_cache_alloc -> slab / SLUB -> 必要时向 buddy 要 slab page

  页表页:
      PTE/PMD 等页表项所在的页
      页表分配路径 -> buddy

如果把图压缩成一句话,就是:

rust 复制代码
   task_struct 找到 mm_struct
   mm_struct 管 VMA 和页表根
   VMA 判断虚拟地址是否合法
   页表记录这一页是否已经兑现
   缺页路径让合法虚拟页兑现成物理页
   buddy 从 zone 里分配页框
   slab 在 buddy 页上切内核小对象

二、一次 malloc 至少有三笔账

malloc 这个词容易让人把所有内存都混在一起。更准确的拆法是三笔账。

账本 典型对象 谁负责 什么时候发生
用户虚拟地址范围 heap、匿名 mmap 区间 VMA / mm_struct brkmmapmprotectmunmap 时变化
用户数据物理页 真正存放 p[i] 的页框 buddy / struct page / 页表 第一次读写触发缺页时兑现
内核元数据对象 vm_area_structtask_struct、文件对象等 slab / SLUB 内核需要维护结构本身时分配

所以 malloc(128 * 1024 * 1024) 成功后,不能直接说"内核已经分给我 128MB 物理内存"。更准确的是:

  1. libc 先决定这次分配是从已有 arena 切,还是通过 brk / mmap 扩展地址空间。
  2. 如果进入内核,内核通常先改 VMA:让一段虚拟地址范围变成合法。
  3. 用户真正触碰某个页时,页表才需要 present 映射。
  4. 缺页路径会为用户数据页分配物理页,必要时也会分配页表页。
  5. 创建 VMA、文件映射、task 等内核对象时,内核小对象可能来自 slab。

三、小 malloc、大 malloc 和第一次触碰

glibc 的 malloc 只是用户态 allocator。它通常维护自己的 arena;arena、top chunk、fastbin、small bin、large bin、unsorted bin 这些用户态分配器内部结构,可以对照 malloc 系列里的《多线程 malloc 为什么会变慢------glibc 的 arena 到 bins 全景》整篇看。

rust 复制代码
   malloc(32KB)
        │
        ├─ arena 里已有空闲块 -> 直接返回
        └─ arena 不够 -> 可能 brk 扩 heap,或 mmap 新区域

大块分配更可能走 mmap

scss 复制代码
   malloc(128MB)
        │
        ▼
   libc 调 mmap
        │
        ▼
   内核创建匿名 VMA
        │
        ▼
   返回用户虚拟地址

但这个 VMA 只是"这段地址可以用"。它不是 128MB 物理页的同义词。真正的用户数据页要等这里:

css 复制代码
   p[i] = 1
        │
        ▼
   CPU 查页表:PTE not present
        │
        ▼
   page fault
        │
        ▼
   find_vma:地址在 malloc 对应 VMA 内,权限允许写
        │
        ▼
   匿名页缺页处理
        │
        ▼
   alloc_pages 从 zone/buddy 拿物理页
        │
        ▼
   清零,填 PTE,更新 RSS
        │
        ▼
   重新执行 p[i] = 1

如果内存压力很大,这条路还可能绕到回收:

复制代码
   alloc_pages
        │
        ▼
   zone watermark 不足
        │
        ├─ 唤醒 kswapd
        ├─ 当前线程 direct reclaim
        ├─ 回收 page cache / 匿名页换出
        ├─ compaction 尝试整理连续页
        └─ 仍失败则分配失败或触发 OOM 路径

这就是第 3 篇里的水位线和回收入口,重新回到一次用户态写内存的路径上。

四、VMA 元数据和用户数据不要混

创建一段匿名映射时,内核至少要做两类事情:

rust 复制代码
   mmap
     │
     ├─ 创建 VMA 元数据
     │     └─ vm_area_struct -> kmem_cache_alloc -> slab
     │
     └─ 等用户访问
           └─ page fault -> alloc_pages -> buddy -> 用户数据页

也就是说,vm_area_struct 这个对象本身是内核元数据,常见情况下来自 slab;而 VMA 覆盖的用户数据页不是 slab 对象。用户写入匿名内存时,最终需要的是 buddy 分出来的页框,然后页表把虚拟页映射过去。

页表页也是一类单独开销:

css 复制代码
   用户数据页:保存 p[i] 的内容
   页表页:保存 PTE/PMD 等页表项
   VMA 对象:保存这个虚拟区间的规则

这三者都和一次 malloc 相关,但它们不是同一种内存。

五、实验:一口气观察 malloc、VMA、fault、slab 和 buddy

下面这个实验做几件事:

  1. 打印机器架构、内核版本、页大小。
  2. 记录 /proc/self/status 里的 VmSizeVmRSSRssAnonVmData
  3. getrusage 记录 minor/major fault。
  4. 统计 /proc/self/maps 的 VMA 行数。
  5. /proc/self/smaps 找出覆盖大块 malloc 指针的 VMA,观察 SizeRss
  6. /proc/slabinfo 观察 vm_area_struct cache。
  7. /proc/buddyinfo 汇总几个 order 的空闲块数量。
  8. 创建 1200 个一页大小、权限交替的匿名映射,尽量避免 VMA 被合并,用来观察 VMA 对 slab 的影响。

完整代码如下,也放在同目录的 08-malloc-chain-demo.c

c 复制代码
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <malloc.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/utsname.h>
#include <unistd.h>

#define BUDDY_ORDERS 11

struct slab_row {
    char name[96];
    unsigned long active_objs;
    unsigned long num_objs;
    unsigned long objsize;
    unsigned long objperslab;
    unsigned long pagesperslab;
};

static void die(const char *what)
{
    fprintf(stderr, "%s: %s\n", what, strerror(errno));
    exit(1);
}

static unsigned long count_self_maps(void)
{
    FILE *fp = fopen("/proc/self/maps", "r");
    char line[512];
    unsigned long count = 0;

    if (!fp)
        die("open /proc/self/maps");

    while (fgets(line, sizeof(line), fp))
        count++;

    fclose(fp);
    return count;
}

static void print_status_memory(const char *tag)
{
    FILE *fp = fopen("/proc/self/status", "r");
    char line[256];

    if (!fp)
        die("open /proc/self/status");

    printf("[%s] status\n", tag);
    while (fgets(line, sizeof(line), fp)) {
        if (strncmp(line, "VmSize:", 7) == 0 ||
            strncmp(line, "VmRSS:", 6) == 0 ||
            strncmp(line, "RssAnon:", 8) == 0 ||
            strncmp(line, "VmData:", 7) == 0) {
            fputs(line, stdout);
        }
    }

    fclose(fp);
}

static void print_faults(const char *tag)
{
    struct rusage ru;

    if (getrusage(RUSAGE_SELF, &ru) != 0)
        die("getrusage");

    printf("[%s] faults minor=%ld major=%ld\n",
           tag, ru.ru_minflt, ru.ru_majflt);
}

static int read_slab_row(const char *cache, struct slab_row *out)
{
    FILE *fp = fopen("/proc/slabinfo", "r");
    char line[512];

    if (!fp)
        return -1;

    while (fgets(line, sizeof(line), fp)) {
        struct slab_row row;

        if (line[0] == '#' || strncmp(line, "slabinfo", 8) == 0)
            continue;

        memset(&row, 0, sizeof(row));
        if (sscanf(line, "%95s %lu %lu %lu %lu %lu",
                   row.name, &row.active_objs, &row.num_objs,
                   &row.objsize, &row.objperslab,
                   &row.pagesperslab) != 6)
            continue;

        if (strcmp(row.name, cache) == 0) {
            *out = row;
            fclose(fp);
            return 0;
        }
    }

    fclose(fp);
    return 1;
}

static void print_slab_cache(const char *tag, const char *cache)
{
    struct slab_row row;
    int ret = read_slab_row(cache, &row);

    if (ret < 0) {
        printf("[%s] slab %-18s unavailable: %s\n",
               tag, cache, strerror(errno));
        return;
    }
    if (ret > 0) {
        printf("[%s] slab %-18s not found\n", tag, cache);
        return;
    }

    printf("[%s] slab %-18s active=%lu total=%lu objsize=%lu "
           "objperslab=%lu pagesperslab=%lu\n",
           tag, row.name, row.active_objs, row.num_objs, row.objsize,
           row.objperslab, row.pagesperslab);
}

static void print_buddy_summary(const char *tag)
{
    FILE *fp = fopen("/proc/buddyinfo", "r");
    unsigned long long orders[BUDDY_ORDERS] = {0};
    char line[512];

    if (!fp) {
        printf("[%s] buddy unavailable: %s\n", tag, strerror(errno));
        return;
    }

    while (fgets(line, sizeof(line), fp)) {
        char *p = strstr(line, "zone");
        int order = 0;

        if (!p)
            continue;

        p += 4;
        while (*p == ' ' || *p == '\t')
            p++;
        while (*p && *p != ' ' && *p != '\t')
            p++;

        while (order < BUDDY_ORDERS) {
            char *end;
            unsigned long long value;

            while (*p == ' ' || *p == '\t')
                p++;
            if (*p == '\0' || *p == '\n')
                break;

            errno = 0;
            value = strtoull(p, &end, 10);
            if (errno != 0 || end == p)
                break;

            orders[order++] += value;
            p = end;
        }
    }

    fclose(fp);
    printf("[%s] buddy free blocks order0=%llu order1=%llu order2=%llu "
           "order9=%llu order10=%llu\n",
           tag, orders[0], orders[1], orders[2], orders[9], orders[10]);
}

static int parse_maps_header(const char *line, unsigned long *start,
                             unsigned long *end)
{
    return sscanf(line, "%lx-%lx", start, end) == 2;
}

static void print_smaps_for_addr(void *addr, const char *tag)
{
    FILE *fp = fopen("/proc/self/smaps", "r");
    char line[512];
    unsigned long target = (unsigned long)addr;
    int in_target = 0;
    int printed = 0;

    if (!fp)
        die("open /proc/self/smaps");

    printf("[%s] smaps for %p\n", tag, addr);

    while (fgets(line, sizeof(line), fp)) {
        unsigned long start, end;

        if (parse_maps_header(line, &start, &end)) {
            in_target = (start <= target && target < end);
            if (in_target) {
                fputs(line, stdout);
                printed = 1;
            }
            continue;
        }

        if (in_target &&
            (strncmp(line, "Size:", 5) == 0 ||
             strncmp(line, "Rss:", 4) == 0 ||
             strncmp(line, "Private_Dirty:", 14) == 0 ||
             strncmp(line, "Anonymous:", 10) == 0 ||
             strncmp(line, "VmFlags:", 8) == 0)) {
            fputs(line, stdout);
            if (strncmp(line, "VmFlags:", 8) == 0)
                break;
        }
    }

    if (!printed)
        printf("address is not covered by any current VMA\n");

    fclose(fp);
}

static void snapshot(const char *tag)
{
    print_status_memory(tag);
    print_faults(tag);
    printf("[%s] self_vma_count=%lu\n", tag, count_self_maps());
    print_slab_cache(tag, "vm_area_struct");
    print_buddy_summary(tag);
}

static void touch_each_page(char *p, size_t len, size_t page_size)
{
    for (size_t i = 0; i < len; i += page_size)
        p[i] = (char)(i / page_size);
}

static void advise_no_hugepage(void *addr, size_t len, size_t page_size)
{
    uintptr_t raw = (uintptr_t)addr;
    uintptr_t base = raw & ~((uintptr_t)page_size - 1);
    size_t offset = raw - base;
    size_t rounded = (offset + len + page_size - 1) & ~(page_size - 1);

    if (madvise((void *)base, rounded, MADV_NOHUGEPAGE) != 0)
        printf("madvise(MADV_NOHUGEPAGE) failed: %s\n", strerror(errno));
}

static void **create_sparse_vmas(int mappings, size_t page_size)
{
    void **addr = calloc((size_t)mappings, sizeof(*addr));

    if (!addr)
        die("calloc vma table");

    for (int i = 0; i < mappings; i++) {
        int prot = (i % 2 == 0) ? PROT_NONE : PROT_READ;

        addr[i] = mmap(NULL, page_size, prot,
                       MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
        if (addr[i] == MAP_FAILED) {
            fprintf(stderr, "mmap %d failed: %s\n", i, strerror(errno));
            exit(1);
        }
    }

    return addr;
}

static void destroy_sparse_vmas(void **addr, int mappings, size_t page_size)
{
    for (int i = 0; i < mappings; i++) {
        if (munmap(addr[i], page_size) != 0) {
            fprintf(stderr, "munmap %d failed: %s\n", i, strerror(errno));
            exit(1);
        }
    }
    free(addr);
}

int main(int argc, char **argv)
{
    struct utsname uts;
    size_t page_size = (size_t)sysconf(_SC_PAGESIZE);
    size_t big_len = 128UL * 1024 * 1024;
    int mappings = 1200;
    char *small;
    char *big;
    void **sparse_vmas;

    if (argc > 1)
        big_len = (size_t)strtoull(argv[1], NULL, 0) * 1024 * 1024;
    if (argc > 2)
        mappings = atoi(argv[2]);
    if (big_len == 0 || mappings <= 0) {
        fprintf(stderr, "usage: %s [big_mib] [vma_count]\n", argv[0]);
        return 2;
    }

    if (uname(&uts) != 0)
        die("uname");

    mallopt(M_MMAP_THRESHOLD, 128 * 1024);

    printf("machine=%s sysname=%s release=%s\n",
           uts.machine, uts.sysname, uts.release);
    printf("page_size=%zu big_len=%zu bytes (%zu MiB) sparse_vmas=%d\n",
           page_size, big_len, big_len / 1024 / 1024, mappings);

    snapshot("start");

    small = malloc(32 * 1024);
    if (!small)
        die("malloc small");
    memset(small, 0x5a, 32 * 1024);
    printf("small malloc ptr=%p len=32768\n", small);
    snapshot("after small malloc+touch");

    big = malloc(big_len);
    if (!big)
        die("malloc big");
    printf("big malloc ptr=%p requested=%zu usable=%zu\n",
           big, big_len, malloc_usable_size(big));
    advise_no_hugepage(big, big_len, page_size);
    snapshot("after big malloc before touch");
    print_smaps_for_addr(big, "after big malloc before touch");

    touch_each_page(big, big_len, page_size);
    snapshot("after touching big malloc");
    print_smaps_for_addr(big, "after touching big malloc");

    printf("creating %d one-page VMAs with alternating permissions\n",
           mappings);
    sparse_vmas = create_sparse_vmas(mappings, page_size);
    snapshot("after sparse mmap");

    destroy_sparse_vmas(sparse_vmas, mappings, page_size);
    snapshot("after sparse munmap");

    free(big);
    snapshot("after free big malloc");

    free(small);
    snapshot("after free small malloc");

    return 0;
}

编译运行命令:

bash 复制代码
docker run --rm --platform linux/amd64 \
  -v /Users/xyzjiao/article:/work -w /work \
  gcc:13 \
  bash -lc 'gcc -O2 -Wall -Wextra os/memory/08-malloc-chain-demo.c -o /tmp/malloc-chain-demo && /tmp/malloc-chain-demo 128 1200'

我在 x86_64 容器里跑到的一次真实输出如下:

text 复制代码
machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096 big_len=134217728 bytes (128 MiB) sparse_vmas=1200
[start] status
VmSize:	  287404 kB
VmRSS:	    4552 kB
RssAnon:	    2548 kB
VmData:	  284548 kB
[start] faults minor=1967 major=0
[start] self_vma_count=33
[start] slab vm_area_struct     active=3034 total=3302 objsize=152 objperslab=26 pagesperslab=1
[start] buddy free blocks order0=6093 order1=3345 order2=4226 order9=10 order10=12
small malloc ptr=0x4068a0 len=32768
[after small malloc+touch] status
VmSize:	  287432 kB
VmRSS:	    4676 kB
RssAnon:	    2608 kB
VmData:	  284576 kB
[after small malloc+touch] faults minor=1981 major=0
[after small malloc+touch] self_vma_count=33
[after small malloc+touch] slab vm_area_struct     active=3034 total=3302 objsize=152 objperslab=26 pagesperslab=1
[after small malloc+touch] buddy free blocks order0=6093 order1=3345 order2=4226 order9=10 order10=12
big malloc ptr=0x7ffff75dc010 requested=134217728 usable=134221808
[after big malloc before touch] status
VmSize:	  418508 kB
VmRSS:	    4680 kB
RssAnon:	    2612 kB
VmData:	  415652 kB
[after big malloc before touch] faults minor=1982 major=0
[after big malloc before touch] self_vma_count=34
[after big malloc before touch] slab vm_area_struct     active=3034 total=3302 objsize=152 objperslab=26 pagesperslab=1
[after big malloc before touch] buddy free blocks order0=6093 order1=3345 order2=4226 order9=10 order10=12
[after big malloc before touch] smaps for 0x7ffff75dc010
7ffff75dc000-7fffff5dd000 rw-p 00000000 00:00 0
Size:             131076 kB
Rss:                   4 kB
Private_Dirty:         4 kB
Anonymous:             4 kB
VmFlags: rd wr mr mw me ac nh
[after touching big malloc] status
VmSize:	  418512 kB
VmRSS:	  135752 kB
RssAnon:	  133684 kB
VmData:	  415656 kB
[after touching big malloc] faults minor=34750 major=0
[after touching big malloc] self_vma_count=34
[after touching big malloc] slab vm_area_struct     active=3033 total=3302 objsize=152 objperslab=26 pagesperslab=1
[after touching big malloc] buddy free blocks order0=5239 order1=2782 order2=1571 order9=10 order10=12
[after touching big malloc] smaps for 0x7ffff75dc010
7ffff75dc000-7fffff5dd000 rw-p 00000000 00:00 0
Size:             131076 kB
Rss:              131072 kB
Private_Dirty:    131072 kB
Anonymous:        131072 kB
VmFlags: rd wr mr mw me ac nh
creating 1200 one-page VMAs with alternating permissions
[after sparse mmap] status
VmSize:	  423312 kB
VmRSS:	  135932 kB
RssAnon:	  133864 kB
VmData:	  415656 kB
[after sparse mmap] faults minor=34795 major=0
[after sparse mmap] self_vma_count=1234
[after sparse mmap] slab vm_area_struct     active=4238 total=4238 objsize=152 objperslab=26 pagesperslab=1
[after sparse mmap] buddy free blocks order0=5239 order1=2751 order2=1571 order9=10 order10=12
[after sparse munmap] status
VmSize:	  418516 kB
VmRSS:	  135936 kB
RssAnon:	  133868 kB
VmData:	  415660 kB
[after sparse munmap] faults minor=34796 major=0
[after sparse munmap] self_vma_count=34
[after sparse munmap] slab vm_area_struct     active=4238 total=4238 objsize=152 objperslab=26 pagesperslab=1
[after sparse munmap] buddy free blocks order0=5239 order1=2745 order2=1543 order9=10 order10=12
[after free big malloc] status
VmSize:	  287440 kB
VmRSS:	    4864 kB
RssAnon:	    2796 kB
VmData:	  284584 kB
[after free big malloc] faults minor=34796 major=0
[after free big malloc] self_vma_count=33
[after free big malloc] slab vm_area_struct     active=4238 total=4238 objsize=152 objperslab=26 pagesperslab=1
[after free big malloc] buddy free blocks order0=6989 order1=4298 order2=4203 order9=10 order10=12
[after free small malloc] status
VmSize:	  287440 kB
VmRSS:	    4864 kB
RssAnon:	    2796 kB
VmData:	  284584 kB
[after free small malloc] faults minor=34796 major=0
[after free small malloc] self_vma_count=33
[after free small malloc] slab vm_area_struct     active=4238 total=4238 objsize=152 objperslab=26 pagesperslab=1
[after free small malloc] buddy free blocks order0=6989 order1=4298 order2=4203 order9=10 order10=12

六、这组结果怎么读

第一,实验确实跑在 x86_64 容器里:

text 复制代码
machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096

第二,小块 malloc(32KB) 没有制造新的 VMA:

text 复制代码
[start] self_vma_count=33
[after small malloc+touch] self_vma_count=33

VmSizeVmData 只小幅变化,说明这类分配首先是 libc arena 行为。它不一定对应一次新的 mmap,也不应该把它理解成 slab 给用户切了一个对象。

第三,大块 malloc(128MB) 后,虚拟地址空间明显变大,但 RSS 还没跟着变大:

text 复制代码
[after big malloc before touch] VmSize: 418508 kB
[after big malloc before touch] VmRSS:    4680 kB

覆盖 big 指针的 smaps 也说明了同一件事:

text 复制代码
Size: 131076 kB
Rss:       4 kB

这里 Size 是 VMA 的虚拟范围,Rss 才是已经驻留的物理页。刚 malloc 完只有 4KB RSS,主要是 allocator 元数据或少量已触碰页,不是 128MB 全部落到物理内存。

第四,逐页写入后,RSS 才涨到接近 128MB:

text 复制代码
[after touching big malloc] VmRSS: 135752 kB
[after touching big malloc] smaps Rss: 131072 kB

minor fault 也从 1982 增加到 34750,增加量是 32768,正好对应 128MiB / 4KiB 的页数:

text 复制代码
34750 - 1982 = 32768

实验里对这段 VMA 做了 MADV_NOHUGEPAGE,所以这次输出更接近"一次 4KB 页触碰对应一次 minor fault"的教学模型。没有这个提示时,现代内核可能因为 THP 或大 folio 让 fault 次数不再严格等于 4KB 页数。核心结论不变:VMA 先存在,物理页在第一次触碰时通过缺页路径兑现。

第五,大量创建 VMA 会影响 vm_area_struct cache:

text 复制代码
[after touching big malloc] self_vma_count=34
[after sparse mmap] self_vma_count=1234

[after touching big malloc] slab vm_area_struct active=3033 total=3302
[after sparse mmap] slab vm_area_struct active=4238 total=4238

这说明 mmap 不只是"给用户一个地址"。内核还要维护 VMA 元数据,而这些 vm_area_struct 对象来自 slab/SLUB。

第六,munmap 后当前进程的 VMA 数回去了,但 /proc/slabinfo 不一定立刻回到原值:

text 复制代码
[after sparse munmap] self_vma_count=34
[after sparse munmap] slab vm_area_struct active=4238 total=4238

这不矛盾。/proc/slabinfo 是全局视图,不是当前进程私有账本;SLUB、RCU 延迟释放、cache 保留空闲对象、其他进程活动都会影响它。这里应该读出的结论是:进程 VMA 数和全局 slab cache 不是一一对应的即时计数,但制造大量 VMA 会清楚地推动 vm_area_struct cache 增长。

第七,buddy 数字只能辅助观察:

text 复制代码
[after big malloc before touch] buddy free blocks order2=4226
[after touching big malloc]     buddy free blocks order2=1571
[after free big malloc]         buddy free blocks order2=4203

/proc/buddyinfo 也是全局视图,系统里其他分配、回收、合并、拆分都会改变它。不要把某个 order 的差值当成这次程序精确消耗的页数。它适合用来辅助理解:触碰匿名页以后,确实会向物理页分配器施压;释放大块映射后,物理页可以回到 buddy 管理的空闲池。

七、把前七篇逐章对回这次路径

前文 在这次 malloc 路径里的位置
第 1 篇:虚拟地址不是内存 malloc 返回的 0x7ffff75dc010 是用户虚拟地址;smaps Size 变大不等于物理页已分配
第 2 篇:node、zone、PFN、struct page 匿名页缺页后,物理页框由 struct page 描述,并属于某个 zone/node
第 3 篇:zone、水位线、buddy alloc_pages 要检查 zone 水位线,再从 buddy 的 free_area[order] 拿页;压力大时可能回收
第 4 篇:vm_area_struct 大块 malloc 对应匿名 VMA;大量 mmap 会制造大量 VMA
第 5 篇:VMA 和页表 smaps Size=131076KB, Rss=4KB 正是"VMA 已有,页表未全部兑现"的表现
第 6 篇:slab/SLUB vm_area_struct 这类内核元数据对象由 slab cache 管理,不是用户数据页
第 7 篇:task/mm/栈/页表 当前线程通过 current->mm 找到同一个 mm_struct,再进入 VMA 和页表路径

这张表其实就是全系列的最终地图:

arduino 复制代码
   用户线程
      │
      ▼
   task_struct
      │
      ▼
   mm_struct
      │
      ├─ VMA:虚拟区间是否合法
      │
      └─ pgd:页表根
             │
             ▼
   page fault:把 VMA 承诺兑现成页表映射
             │
             ▼
   buddy:分配用户页 / 页表页
             │
             ▼
   struct page:描述物理页框

   另一侧:

   vm_area_struct / task_struct / inode / dentry
      │
      ▼
   slab / SLUB
      │
      ▼
   buddy 提供 slab page

八、几个最终容易踩错的点

第一,malloc 成功不等于物理页已经全部分配

大块匿名内存经常先表现为 VmSize 增加。只有访问后,RSS 和 minor fault 才明显增加。

第二,VMA 不是页表

VMA 说"这段虚拟地址可以这样用";页表说"这一页现在翻译到哪个物理页框"。smaps SizeRss 的差异,就是这句话的可观察版本。

第三,slab 不是用户态 malloc 的后端

用户态 malloc 的后端是 libc arena 加 brk / mmap。slab 是内核对象分配器,给 vm_area_structtask_struct、dentry、inode、kmalloc-* 等对象使用。

第四,buddy 管页,slab 管对象

slab 也需要页,它不绕过 buddy。区别在于 buddy 回答"从哪个 zone 拿几页",slab 回答"这一页里哪个对象槽位可以给内核用"。

第五,线程共享地址空间,不代表共享所有执行上下文

同一进程里的线程通常共享 mm_struct、VMA 和页表,所以一个线程 mmap 的区域其他线程也能看到。但每个线程仍然有独立 task_struct、用户栈和内核栈。

第六,/proc 观察口要分清进程视图和全局视图

/proc/self/maps/proc/self/smaps 是当前进程地址空间视图;/proc/slabinfo/proc/buddyinfo 是全局内核分配器视图。前者适合观察 VMA/RSS,后者适合观察趋势,不适合做单进程精确记账。

九、全系列收束

如果只记一条线,就记这一条:

arduino 复制代码
   指针值
      │
      ▼
   用户虚拟地址
      │
      ▼
   VMA 判断是否合法
      │
      ▼
   页表判断是否已经兑现
      │
      ▼
   缺页路径分配或找到物理页
      │
      ▼
   struct page 描述页框
      │
      ▼
   zone / buddy 管理空闲页来源
      │
      ▼
   slab 在页上切内核小对象
      │
      ▼
   task_struct / mm_struct 把这一切挂回当前线程和进程

Linux 内存管理不是一条"malloc 直接拿物理内存"的直线,而是一组账本协作:

  • 用户态 allocator 管用户程序看到的小块分配。
  • VMA 管虚拟地址区间是否合法。
  • 页表管已经兑现的页级映射。
  • buddy 管物理页框。
  • slab 管内核小对象。
  • task_structmm_struct 把这些结构挂到当前执行上下文上。

这样再看一次 p = malloc(128 * 1024 * 1024); p[0] = 1;,它背后就不再是一句"申请内存",而是一条完整链路:用户态 allocator 可能扩展 VMA,CPU 第一次访问触发缺页,内核查 VMA 和页表,从 buddy 拿物理页,建立 PTE,最后让同一条用户指令重新执行成功。

相关推荐
无忧.芙桃4 小时前
线程同步与线程互斥(上)
linux·运维·操作系统·运维开发
zandy10112 天前
从可用到好用:2026年信创改造建设之邮件系统经验分享
经验分享·中间件·操作系统·信创·国产cpu
岑梓铭3 天前
考研408《操作系统》复习笔记,第四章《4.2.2 文件操作、文件共享、文件保护》
linux·考研·操作系统·文件系统·408
CS创新实验室3 天前
现代操作系统与大学教材的鸿沟:从理论抽象到工程实践的跨越
操作系统
明朝冰红茶4 天前
《30天自制操作系统》 Day1学习笔记
操作系统
小宇子2B4 天前
vm_area_struct:进程地址空间的合法区间表
操作系统
星马梦缘5 天前
操作系统计算题专项练习(15题)
操作系统·os·银行家算法·段页式
小宇子2B6 天前
物理内存的地图:node、zone、page frame 和 struct page
操作系统
阿昭L6 天前
操作系统复习(九)
操作系统