一、内存管理全景图:物理内存与虚拟内存、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图,帮你理清整个地址翻译的流程:
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