一、内存管理全景图:物理内存与虚拟内存、MMU与页表、内核态与用户态内存布局

大家好,我是你们的老朋友。今天咱们正式开讲内核内存管理。说实话,内存管理这块,是Linux内核里最绕、也最考验功底的部分。我当年刚接触内核时,被页表、虚拟地址这些概念搞得晕头转向。后来在项目中调一个内存泄漏的bug,折腾了整整三天,才真正把这块吃透。

嗯,咱们不绕弯子。今天第一讲,先画一张全景图。你把这章吃透了,后面30章都会轻松很多。

1.1 物理内存 vs 虚拟内存:一个经典的障眼法

先问大家一个问题:你的程序能访问多大的内存?

你可能会说,看物理内存多大呗。但实际不是这样。我见过很多新手写代码,malloc一块大内存,以为直接对应物理内存。其实,每个进程看到的地址空间,都是虚拟的。

核心概念:物理内存是硬件真实存在的DRAM颗粒,虚拟内存是操作系统为每个进程伪造的“假地址空间”。

为什么要搞这么复杂?说白了,有三个原因:

  • 隔离性:进程A不能随便读写进程B的内存。虚拟内存让每个进程都以为自己是唯一的主人。
  • 安全性:用户态程序不能直接操作内核数据。虚拟地址空间天然划分了权限。
  • 灵活性:物理内存不够时,可以把不常用的数据换到磁盘上。虚拟内存让你觉得内存很大。

我在项目中遇到过一个问题:一个嵌入式设备只有256MB物理内存,但跑的应用需要1.2GB的虚拟地址空间。怎么办?靠的就是虚拟内存的映射机制,把大部分数据放在闪存上,按需加载。嗯,这就是虚拟内存的威力。

1.2 MMU与页表:地址翻译的幕后英雄

虚拟地址怎么变成物理地址?靠的就是MMU(内存管理单元)和页表。

MMU是CPU内部的一个硬件模块。每次CPU访问内存,MMU都会拦截虚拟地址,查页表,翻译成物理地址。这个过程叫“地址翻译”。

页表是什么?说白了,就是一张映射表。它记录了虚拟页号到物理页框号的对应关系。

个人经验:我建议你把页表想象成一本字典。虚拟地址是你要查的单词,物理地址是单词的解释。MMU就是那个翻字典的人。

在x86_64架构下,页表是四级结构:

虚拟地址(48位) = PGD索引(9位) + PUD索引(9位) + PMD索引(9位) + PTE索引(9位) + 页内偏移(12位)

为什么会设计成四级?直接一级映射不行吗?

你想想看,如果只用一级页表,要覆盖48位地址空间,需要2^36个页表项。每个页表项8字节,光页表就要512GB内存。这显然不现实。

四级页表的好处是:按需分配。只有真正用到的虚拟地址范围,才会创建对应的页表项。大部分页表层级是空的,不占内存。

避坑指南:我曾经在调试一个性能问题时,发现程序频繁触发缺页中断。查了半天,原来是页表项没有预分配,每次访问新页面都要走一遍完整的页表创建流程。后来用madvise提前做了内存预映射,性能提升了30%。

下面我用一张SVG图,帮你理清整个地址翻译的流程:

虚拟地址到物理地址翻译流程 虚拟地址 (48位) MMU 地址翻译 四级页表查询 PGD (页全局目录) PUD (页上级目录) PMD (页中间目录) PTE (页表项) 物理地址 → 访问物理内存 TLB 缓存 TLB命中时跳过页表查询,直接获得物理地址

1.3 内核态与用户态内存布局

每个进程的虚拟地址空间,被分成两半:用户态和内核态。在x86_64上,这个分界线在0x00007fffffffffff。

用户态地址范围:0x0000000000000000 ~ 0x00007fffffffffff
内核态地址范围:0xffff800000000000 ~ 0xffffffffffffffff

为什么内核要占那么高的地址?因为内核需要直接映射所有物理内存。在x86_64上,内核有一个叫“直接映射区”的东西,把物理内存0号页框开始,线性映射到内核虚拟地址空间。

关键点:用户态程序不能直接访问内核地址空间。每次系统调用或中断发生时,CPU会切换特权级,从用户态切换到内核态。这时候才能访问内核数据。

我给大家整理一下典型的内存布局:

区域 起始地址 大小 用途
代码段 0x400000 ~几MB 程序指令
数据段 代码段后 ~几MB 全局变量、静态变量
数据段后 动态增长 malloc分配的内存
0x7fffffffffff向下 ~8MB 函数调用、局部变量
内核直接映射区 0xffff880000000000 与物理内存等大 内核访问所有物理内存
vmalloc区 0xffffc90000000000 ~32TB 内核动态分配非连续内存

嗯,这里要注意一个细节:用户态的栈是从高地址向低地址生长的。而堆是从低地址向高地址生长。两者相向而行,如果相遇了,就会发生栈溢出或者堆溢出。我调试过一个coredump,就是递归调用太深,栈把堆给压爆了。

1.4 一个简单的内存映射示例

光说不练假把式。咱们看一个实际例子,感受一下虚拟地址怎么映射到物理地址。

// 一个简单的C程序,观察虚拟地址
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int global_var = 42;  // 数据段

int main() {
    int local_var = 10;  // 栈
    int *heap_var = malloc(sizeof(int));  // 堆
    *heap_var = 100;
    
    printf("代码段地址: %p\n", main);
    printf("全局变量地址: %p\n", &global_var);
    printf("栈变量地址: %p\n", &local_var);
    printf("堆变量地址: %p\n", heap_var);
    printf("PID: %d\n", getpid());
    
    // 此时可以查看 /proc/PID/maps
    // cat /proc/PID/maps 查看完整映射
    
    free(heap_var);
    return 0;
}

运行这个程序,你会看到类似这样的输出:

代码段地址: 0x400566
全局变量地址: 0x601038
栈变量地址: 0x7ffd8a3b4c2c
堆变量地址: 0x1c3b010

看到了吗?代码段在0x400000附近,全局变量在0x600000附近,栈在0x7ffd开头的高地址,堆在0x1c3b010这种中间位置。这就是典型的用户态内存布局。

小技巧:你可以用 cat /proc/PID/maps 查看进程的完整内存映射。我排查内存泄漏时,经常用这个命令配合pmap工具,一眼就能看出哪个段异常增长。

1.5 总结一下

今天咱们把内存管理的全景图铺开了。核心就三件事:

  • 物理内存 vs 虚拟内存:虚拟内存是操作系统造的“假象”,让每个进程拥有独立的地址空间。
  • MMU与页表:MMU是硬件翻译官,页表是翻译字典。四级页表结构解决了大地址空间的映射问题。
  • 内核态与用户态:地址空间一分为二,用户态不能碰内核态的数据。系统调用是唯一的桥梁。

我个人觉得,理解内存管理的关键,就是时刻记住:你写的代码里所有的指针,都是虚拟地址。真正访问物理内存时,MMU和页表在背后默默做了大量工作。嗯,把这个观念刻在脑子里,后面学slab分配器、页面回收、内存碎片整理,都会轻松很多。

好,今天就到这儿。下一讲咱们深入页表的细节,看看内核是怎么管理这些页表项的。


公众号:蓝海资料掘金营,微信deep3321