在计算机系统中,进程的虚拟地址空间布局是理解内存管理的基础。在32位系统中,虚拟地址空间通常为4GB,其中0-3GB为用户空间,3-4GB为内核空间。
而在64位系统上,目前并非完全启用64位全地址宽度,常见的实现有39位(512GB)或48位(256TB)。本文实验环境使用的模拟器采用了39位地址宽度,这意味着用户空间和内核空间各拥有512GB的地址范围。
那么,当一个应用程序在用户空间运行时,其内部的内存是如何组织的呢?我们通过一个简单的C语言程序来详细说明。
#include <stdio.h>
#include <malloc.h>
static int global_data=1;
static int global_data1;
int bss_data;
int bss_data1;
int main()
{
int stack_data = 1;
int stack_data1 = 2;
int data[200*1024];
static int data_val=1;
int* malloc_data=malloc(10);
int* malloc_data1=(int*)malloc(300);
int* malloc_data2=(int*)malloc(300*1024);
// stack segment
printf("stack segment!\n");
printf("\t stack_data=0x%lx\n",&stack_data);
printf("\t stack_data1=0x%lx\n",&stack_data1);
// heap segment
printf("heap segment!\n");
printf("\t malloc_data=0x%lx\n",malloc_data);
printf("\t malloc_data1=0x%lx\n",malloc_data1);
printf("\t malloc_data2=0x%lx\n",malloc_data2);
//code segment
printf("code segment!\n");
printf("\t code_data=0x%lx\n",main);
//data segment
printf("data segment!\n");
printf("\t global_data=0x%lx\n",&global_data);
printf("\t global_data1=0x%lx\n",&global_data1);
printf("\t data_val=0x%lx\n",&data_val);
//bss segment
printf("bss segment!\n");
printf("\t bss_data=0x%lx\n",&bss_data);
printf("\t bss_data1=0x%lx\n",&bss_data1);
return 0;
}
为了获得更清晰的架构视图,我们在ARM64机器上运行上述测试程序,并打印各个内存段的地址。
root:/ # ./data/vma
stack segment!
stack_data=0x7fe8a41e24
stack_data1=0x7fe8a41e20
heap segment!
malloc_data=0x356db9d0
malloc_data1=0x356db9f0
malloc_data2=0x6ff3187010
code segment!
code_data=0x400620
data segment!
global_data=0x48b960
global_data1=0x48d380
data_val=0x48b964
bss segment!
bss_data=0x48e448
bss_data1=0x48e44c
根据打印出的地址,我们可以用一张图来描述各个段在进程虚拟地址空间中的位置。需要注意的是,下图描述的是ARM64架构下的典型布局,不同架构可能略有差异。

将ARM64的用户空间布局放大来看,我们能更清晰地看到各个段的相对位置:
- 代码段(Text Segment):位于用户虚拟地址空间的最低地址处,存放程序的可执行代码。
- 数据段(Data Segment):紧接着代码段上方,存放已初始化的全局变量和静态变量。
- BSS段:位于数据段之上,存放未初始化的全局变量和静态变量,程序加载时会初始化为零。
- 栈段(Stack Segment):用于存放函数调用的局部变量、参数等。从图中和实验结果可以看出,栈是从高地址向低地址增长的。
- 堆段(Heap Segment):对应通过
malloc 等函数动态申请的内存区域。实验结果显示,堆段位于用户空间的中间区域,并且是从低地址向高地址增长的。
- 内存映射段(Memory Mapping Segment):用于文件映射(如动态链接库)和匿名内存映射。当使用
malloc 申请大块内存(例如超过128KB)时,可能会通过 mmap 系统调用在此区域分配。
以上实验结论基于ARM64架构。如果你有兴趣,可以研究一下32位系统的布局。这里直接给出多年前在32位Ubuntu系统上的实验结果供参考。


对应的内存布局图示如下:

可以看到,其内存段布局规律与在ARM64上的表现是一致的。理解这种内核空间与用户空间的划分是掌握操作系统内存管理的起点。
VMA(Virtual Memory Area)虚拟内存区域
上文所述的各个内存段最终需要映射到物理内存,而Linux内核中使用VMA(Virtual Memory Area)来描述和管理这些连续的虚拟内存区域。我们可以通过 cat /proc/pid/maps 命令来验证上面的实验结果。

你可以对比一下之前打印的变量地址,看看它们是否落在 maps 文件列出的对应VMA区域内。
内核为每个进程维护一个mm_struct结构体,其中通过链表和红黑树两种数据结构来组织该进程的所有VMA。

这里不深入代码细节,只简要描述VMA的关键数据结构 vm_area_struct:


我们现阶段需要掌握的是:用户空间中代码段、数据段、栈、堆等区域的布局;以及内核使用vm_area_struct结构体来描述这些区域;这些VMA通过链表(便于插入和删除)和红黑树(便于快速查找)组织起来,链表头和红黑树根节点存放在进程的mm_struct结构中。
了解了VMA的组织方式后,我们通过一个Linux内核模块的例子,来实际获取并打印指定进程的VMA信息。
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/sched/signal.h>
#include <linux/mm.h>
static int mpid=1;
static void print_vma(struct task_struct *task)
{
struct mm_struct *mm;
struct vm_area_struct *vma;
int count=0;
mm = task->mm;
printk("This mm_struct has %d vma\n", mm->map_count);
for(vma = mm->mmap; vma; vma=vma->vm_next){
printk("vma number %d: \n", ++count);
printk("Start address 0x%lx, End address 0x%lx\n", vma->vm_start, vma->vm_end);
}
printk("Code segment start=0x%lx, end=0x%lx\n"
"Data Segment start=0x%lx, end=0x%lx\n"
"Stack segment start=0x%lx\n",
mm->start_code, mm->end_code, mm->start_data, mm->end_data, mm->start_stack);
}
static int vma_start()
{
struct task_struct *task;
printk("Got the process id =%d\n", mpid);
for_each_process(task) {
if(task->pid == mpid){
printk("%s[%d]\n", task->comm, task->pid);
print_vma(task);
}
}
return 0;
}
static void vma_exit()
{
printk("print segment info module exit!\n");
}
module_init(vma_start);
module_exit(vma_exit);
module_param(mpid, int, 0);
该模块通过模块参数mpid接收一个目标进程的PID,然后遍历进程列表找到对应PID的进程。接着,它打印该进程的名称和PID,并遍历其mm_struct中的所有VMA,打印每个VMA的起始和结束地址,以及代码段、数据段和栈段的起止地址。
首先,我们通过maps命令获取目标进程(PID为4766)的VMA信息:
root:/data # cat /proc/4766/maps
00400000-0047c000 r-xp 00000000 103:23 6918 /data/vma
0048b000-0048e000 rw-p 0007b000 103:23 6918 /data/vma
0048e000-0048f000 rw-p 00000000 00:00 0
38382000-383a4000 rw-p 00000000 00:00 0 [heap]
78941af000-78941fb000 rw-p 00000000 00:00 0
78941fb000-78941fc000 r--p 00000000 00:00 0 [vvar]
78941fc000-78941fd000 r-xp 00000000 00:00 0 [vdso]
7fc0ed3000-7fc0f9d000 rw-p 00000000 00:00 0 [stack]
然后,查看内核模块的打印信息:
[ 2432.979096] Got the process id =4766
[ 2432.979495] vma[4766]
[ 2432.979500] This mm_struct has 8 vma
[ 2432.979504] vma number 1:
[ 2432.979508] Start address 0x400000, End address 0x47c000
[ 2432.979511] vma number 2:
[ 2432.979515] Start address 0x48b000, End address 0x48e000
[ 2432.979518] vma number 3:
[ 2432.979522] Start address 0x48e000, End address 0x48f000
[ 2432.979525] vma number 4:
[ 2432.979529] Start address 0x38382000, End address 0x383a4000
[ 2432.979532] vma number 5:
[ 2432.979536] Start address 0x78941af000, End address 0x78941fb000
[ 2432.979539] vma number 6:
[ 2432.979543] Start address 0x78941fb000, End address 0x78941fc000
[ 2432.979547] vma number 7:
[ 2432.979551] Start address 0x78941fc000, End address 0x78941fd000
[ 2432.979554] vma number 8:
[ 2432.979558] Start address 0x7fc0ed3000, End address 0x7fc0f9d000
[ 2432.979564] Code segment start=0x400000, end=0x47b76f
Data Segment start=0x48b770, end=0x48d348
Stack segment start=0x7fc0f9ba00
通过对比可以发现,内核模块打印的8个VMA的地址范围与/proc/4766/maps的内容是完全对应的。这个例子清晰地展示了:操作系统内核正是通过vm_area_struct结构体来管理进程的各个内存区域(段),这些VMA通过双向链表连接,方便遍历和修改。同时,mm_struct中也直接记录了代码段、数据段和栈段的关键边界地址。想要深入理解这些计算机系统底层的核心原理与内存管理机制,可以持续关注云栈社区上的相关技术讨论。