从 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_struct、mm_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 |
brk、mmap、mprotect、munmap 时变化 |
| 用户数据物理页 | 真正存放 p[i] 的页框 |
buddy / struct page / 页表 |
第一次读写触发缺页时兑现 |
| 内核元数据对象 | vm_area_struct、task_struct、文件对象等 |
slab / SLUB | 内核需要维护结构本身时分配 |
所以 malloc(128 * 1024 * 1024) 成功后,不能直接说"内核已经分给我 128MB 物理内存"。更准确的是:
- libc 先决定这次分配是从已有 arena 切,还是通过
brk/mmap扩展地址空间。 - 如果进入内核,内核通常先改 VMA:让一段虚拟地址范围变成合法。
- 用户真正触碰某个页时,页表才需要 present 映射。
- 缺页路径会为用户数据页分配物理页,必要时也会分配页表页。
- 创建 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
下面这个实验做几件事:
- 打印机器架构、内核版本、页大小。
- 记录
/proc/self/status里的VmSize、VmRSS、RssAnon、VmData。 - 用
getrusage记录 minor/major fault。 - 统计
/proc/self/maps的 VMA 行数。 - 从
/proc/self/smaps找出覆盖大块malloc指针的 VMA,观察Size和Rss。 - 从
/proc/slabinfo观察vm_area_structcache。 - 从
/proc/buddyinfo汇总几个 order 的空闲块数量。 - 创建 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
VmSize 和 VmData 只小幅变化,说明这类分配首先是 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 Size 和 Rss 的差异,就是这句话的可观察版本。
第三,slab 不是用户态 malloc 的后端。
用户态 malloc 的后端是 libc arena 加 brk / mmap。slab 是内核对象分配器,给 vm_area_struct、task_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_struct和mm_struct把这些结构挂到当前执行上下文上。
这样再看一次 p = malloc(128 * 1024 * 1024); p[0] = 1;,它背后就不再是一句"申请内存",而是一条完整链路:用户态 allocator 可能扩展 VMA,CPU 第一次访问触发缺页,内核查 VMA 和页表,从 buddy 拿物理页,建立 PTE,最后让同一条用户指令重新执行成功。