3. 内存描述符mm_struct:进程地址空间布局

大家好,我是你们的Linux内核讲师。今天我们来聊聊进程地址空间的核心——mm_struct。说实话,这个结构体我研究了很久,每次看都觉得自己又学到了新东西。它就像是进程的「虚拟地址空间地图」,内核靠它来管理每个进程能看到的内存。

3.1 进程地址空间长什么样?

先问大家一个问题:你写一个C程序,printf打印一个指针,那个地址是真实的物理地址吗?

当然不是。那是虚拟地址。每个进程都以为自己独占整个4GB(32位)或超大空间(64位)。内核怎么做到的呢?靠的就是mm_struct

一个典型的进程地址空间布局是这样的:

0xFFFFFFFF  ┌──────────────────────┐
            │      内核空间        │
            │  (用户不可访问)    │
0xC0000000  ├──────────────────────┤
            │      栈区            │
            │      ↓               │
            │      ↑               │
            │      堆区            │
            │      BSS段           │
            │      数据段          │
            │      代码段          │
0x08048000  ├──────────────────────┤
            │      保留区          │
0x00000000  └──────────────────────┘

嗯,这个图我画了很多次。你想想看,每个进程都有这么一套布局,但实际物理内存只有一份。这就是虚拟内存的魔力。

3.2 mm_struct关键字段解析

我直接上干货。这是mm_struct里我最常用的几个字段:

字段名 类型 作用
pgd pgd_t * 页全局目录指针,地址转换的入口
mmap struct vm_area_struct * 虚拟内存区域链表头
mm_rb struct rb_root 红黑树根,用于快速查找VMA
start_code, end_code unsigned long 代码段起始和结束地址
start_data, end_data unsigned long 数据段起始和结束地址
start_brk, brk unsigned long 堆的起始和当前结束地址
start_stack unsigned long 栈的起始地址
arg_start, arg_end unsigned long 命令行参数区域
env_start, env_end unsigned long 环境变量区域
map_count atomic_t 当前VMA的数量
total_vm unsigned long 进程使用的总虚拟页面数

我个人习惯把pgd叫做「大门钥匙」。为什么?因为每次CPU访问内存,都要通过它找到页表。我在项目中遇到过一个问题:某个驱动在内核态访问用户空间地址时,pgd指向了错误的进程页表,结果直接Oops。从那以后,我每次切换进程上下文都会检查pgd是否更新。

3.3 虚拟内存区域(VMA)

每个mm_struct里都挂着一堆vm_area_struct,简称VMA。说白了,VMA就是进程地址空间里的一块连续区域。比如代码段是一个VMA,堆是一个VMA,栈也是一个VMA。

VMA的关键字段:

struct vm_area_struct {
    unsigned long vm_start;      // 起始地址
    unsigned long vm_end;        // 结束地址
    unsigned long vm_flags;      // 权限标志(读、写、执行)
    struct file *vm_file;        // 映射的文件(如果有)
    unsigned long vm_pgoff;      // 文件内的偏移
    struct vm_operations_struct *vm_ops; // 操作函数集
};

你想想看,内核怎么快速找到一个地址属于哪个VMA?它用了两种数据结构:链表和红黑树。链表用于遍历,红黑树用于查找。我刚开始学的时候觉得这有点冗余,后来在调试一个内存泄漏问题时才发现——没有红黑树,查找一个地址要遍历几百个VMA,性能根本扛不住。

重点:每个VMA都有vm_flags,它决定了这块内存能不能读、写、执行。我曾经见过一个驱动把可执行权限给了数据段,结果被攻击者利用执行了恶意代码。所以,权限设置一定要谨慎。

3.4 进程地址空间的创建与销毁

进程创建时,内核会调用copy_mm()来复制父进程的mm_struct。如果是fork(),子进程会共享父进程的地址空间(写时复制)。如果是exec(),内核会调用exec_mmap()来替换全新的地址空间。

销毁时呢?当进程退出,内核调用exit_mmap(),遍历所有VMA,释放物理页面。这里有个坑:如果某个VMA映射了物理内存,但进程异常退出,内核必须确保这些页面被正确回收。否则就会内存泄漏。

注意:我曾经调试过一个内核模块,它创建了匿名映射但没有注册vm_opsclose回调。结果进程退出时,那些页面没有被释放,系统内存一点点被吃光。嗯,这个问题查了我整整两天。

3.5 实战:如何查看进程地址空间

在用户空间,你可以通过/proc/[pid]/maps查看进程的地址空间布局。比如:

$ cat /proc/1/maps
555555554000-555555555000 r-xp 00000000 08:01 123456 /usr/lib/systemd/systemd
555555754000-555555755000 r--p 00000000 08:01 123456 /usr/lib/systemd/systemd
555555755000-555555756000 rw-p 00001000 08:01 123456 /usr/lib/systemd/systemd
7ffff7d5e000-7ffff7d5f000 rw-p 00000000 00:00 0 [vdso]
7ffff7d5f000-7ffff7d80000 r-xp 00000000 08:01 789012 /usr/lib/x86_64-linux-gnu/libc.so.6

每一行就是一个VMA。第一列是地址范围,第二列是权限(r=读,w=写,x=执行,p=私有,s=共享),第三列是文件偏移,第四列是设备号,第五列是inode,最后一列是文件名。

我在调试驱动时经常用这个文件。比如,我想知道用户空间的缓冲区是不是在栈上,直接看maps就能判断。如果地址在栈区,那就要小心了——栈空间有限,别传太大的数据。

3.6 避坑指南

最后分享几个我踩过的坑:

  • 不要直接访问用户空间指针:在内核态,永远不要直接解引用用户空间传过来的指针。要用copy_from_usercopy_to_user。我刚开始写驱动时犯过这个错,结果系统直接挂掉。
  • 注意VMA的权限检查:在实现mmap回调时,一定要检查vma->vm_flags。如果用户请求写权限但你的设备不支持,要返回错误。
  • 红黑树和链表要同步:当你手动添加或删除VMA时,记得同时更新mmap链表和mm_rb红黑树。否则查找时会出问题。
  • 进程退出时清理:如果你在内核模块中创建了VMA,一定要在模块卸载或进程退出时清理干净。否则系统会慢慢变得不稳定。

小技巧:在调试内存相关问题时,我经常用show_map()函数来打印进程的地址空间。这个函数在fs/proc/task_mmu.c里,你可以直接调用它来查看VMA的详细信息。

好了,关于mm_struct就讲到这里。记住,它是进程地址空间的总管家。理解了它,你就理解了Linux内存管理的半壁江山。下一节我们会深入页表,看看虚拟地址是怎么一步步变成物理地址的。