2. 进程地址空间布局:用户空间与内核空间划分、代码段/数据段/堆/栈/映射段布局
进程地址空间是操作系统为每个进程提供的虚拟内存视图。在 32 位系统中,标准划分将 4GB 虚拟地址空间分为用户空间(0x00000000 – 0xBFFFFFFF,约 3GB)和内核空间(0xC0000000 – 0xFFFFFFFF,约 1GB)。64 位系统(如 x86_64)则使用更大的地址范围,但用户空间与内核空间之间仍存在明确的“金丝雀”隔离区。
核心概念: 用户空间各区域的布局由
mm_struct 中的起始/结束指针(如 start_code、end_data、start_brk、brk 等)定义,内核通过 VMA(vm_area_struct)链表管理这些连续或离散的映射。
2.1 用户空间各区域详解
用户空间布局由 ELF 加载器与内核内存管理协作建立。每个区域对应一个或多个 VMA,具有不同的权限(读/写/执行)和增长方向。
| 区域 | 典型起始地址(32位) | 权限 | 增长方向 | VMA 标志 |
|---|---|---|---|---|
| 代码段 (.text) | 0x08048000 | 读 + 执行 | 固定 | VM_READ | VM_EXEC |
| 数据段 (.data / .bss) | 0x08049000 | 读 + 写 | 固定 | VM_READ | VM_WRITE |
| 堆 (Heap) | 0x0804a000 | 读 + 写 | 向高地址 | VM_READ | VM_WRITE | VM_GROWSUP |
| 内存映射段 | 0xb7f00000 | 读 + 写 + 执行(视情况) | 双向 | VM_READ | VM_WRITE | VM_EXEC(共享库) |
| 栈 (Stack) | 0xbff00000 | 读 + 写 | 向低地址 | VM_READ | VM_WRITE | VM_GROWSDOWN |
避坑提示: 栈的“向低地址增长”意味着
VM_GROWSDOWN 标志允许栈 VMA 在缺页时自动向下扩展。堆则使用 VM_GROWSUP 向上扩展。这两个标志直接影响缺页中断处理中的 VMA 查找与扩展逻辑。
2.2 内核空间布局与进程隔离
内核空间虽然被所有进程共享,但通过页表权限位(如 Supervisor 位)确保用户态无法直接访问。每个进程的 task_struct 中的 mm 指针指向其用户空间描述符,而内核空间映射则在进程切换时通过 switch_mm() 更新页表基址(CR3)。
- 直接映射区(ZONE_NORMAL / ZONE_DMA): 物理内存线性映射到内核虚拟地址空间,例如 0xC0000000 对应物理地址 0x0。
- vmalloc 区: 用于非连续内存分配,内核函数
vmalloc()在此区域分配。 - 固定映射区(Fixmap): 用于特殊目的,如 APIC 寄存器映射。
- 内核栈: 每个进程拥有独立的内核栈(通常 8KB),用于处理系统调用和中断上下文。
2.3 布局中的关键数据结构关联
进程地址空间的每个区域在 Linux 内核中由一个或多个 vm_area_struct(VMA)描述。VMA 通过红黑树 + 双向链表组织在 mm_struct 中。
// mm_struct 中与地址空间布局相关的关键字段(简化)
struct mm_struct {
unsigned long start_code, end_code; // 代码段范围
unsigned long start_data, end_data; // 数据段范围
unsigned long start_brk, brk; // 堆起始与当前 brk
unsigned long start_stack; // 栈起始地址
unsigned long arg_start, arg_end; // 命令行参数
unsigned long env_start, env_end; // 环境变量
struct vm_area_struct *mmap; // VMA 链表头
struct rb_root mm_rb; // VMA 红黑树根
unsigned long total_vm; // 总虚拟页面数
unsigned long locked_vm; // 锁定页面数
};
注意:
start_brk 与 brk 之间的区域是堆的当前范围。当调用 brk() 或 sbrk() 时,内核通过调整 brk 并扩展/收缩堆 VMA 来改变堆大小。如果 brk 超过 start_brk + limit,则返回 ENOMEM。
2.4 布局示例:查看进程地址空间
在 Linux 系统中,可以通过 /proc/[pid]/maps 查看任意进程的地址空间布局。以下是一个典型 C 程序的 maps 输出片段:
# cat /proc/1234/maps
08048000-08049000 r-xp 00000000 08:01 1234567 /bin/hello # 代码段
08049000-0804a000 rw-p 00001000 08:01 1234567 /bin/hello # 数据段
0804a000-0806b000 rw-p 00000000 00:00 0 [heap] # 堆
b7f00000-b7f02000 r-xp 00000000 08:01 7654321 /lib/libc.so # 共享库代码
b7f02000-b7f04000 rw-p 00002000 08:01 7654321 /lib/libc.so # 共享库数据
bffdf000-c0000000 rw-p 00000000 00:00 0 [stack] # 栈
每一行对应一个 VMA,格式为:起始地址-结束地址 权限 偏移 主设备号:次设备号 inode 路径。权限字段的 r、w、x、p/s 分别表示读、写、执行、私有/共享。
2.5 布局对缺页中断的影响
地址空间布局直接决定了缺页中断处理中的 VMA 查找路径:
- 代码段缺页: 通常发生在首次执行指令时,内核从文件系统读取页面到物理内存,并设置只读/可执行权限。
- 数据段缺页: 全局变量首次访问时触发,内核从 ELF 文件读取已初始化数据,或直接分配零填充页面(.bss)。
- 堆缺页: 当访问的地址在
start_brk与brk之间但尚未分配物理页时,内核分配匿名页面。 - 栈缺页: 当访问地址略低于当前栈底(在
VM_GROWSDOWN允许范围内),内核自动扩展栈 VMA 并分配页面。 - 映射段缺页: 共享库或
mmap文件映射的页面按需加载,支持共享物理页。
关键洞察: 缺页中断处理程序首先通过
find_vma() 在红黑树中查找包含故障地址的 VMA。如果找不到,则发送 SIGSEGV。找到后,根据 VMA 的标志(如 VM_GROWSDOWN、VM_SHARED、VM_ANONYMOUS)决定具体的处理路径。因此,地址空间布局的准确性是缺页处理正确性的前提。
2.6 布局中的常见陷阱与调试技巧
- 栈溢出检测: 内核通过
VM_GROWSDOWN和stack_guard_gap(默认 1MB)在栈 VMA 下方预留保护页,防止栈向下增长时与堆或映射段碰撞。 - 堆与 mmap 的竞争: 当
brk接近映射段时,malloc可能自动切换使用mmap分配大块内存(默认阈值 128KB)。 - 地址空间随机化(ASLR): 现代内核随机化堆、栈、映射段的基址,增加安全性的同时使调试时地址不可预测。
- 查看 VMA 数量:
/proc/[pid]/maps的行数即为 VMA 数量。过多的 VMA(如大量mmap调用)会导致find_vma()性能下降。
调试命令: 使用
cat /proc/self/maps 查看当前 shell 进程的地址空间布局;使用 pmap -x [pid] 查看更详细的内存映射统计。