一、程序地址空间
程序地址空间是程序运行成为进程之后,进程自身视角下所能访问的全部地址范围。这片地址空间被操作系统划分成多个功能区域,包括代码段(.text)、初始化数据段(.data)、未初始化数据段(.bss)、只读常量段(.rodata)、堆(heap)、栈(stack)以及共享库映射区域等。这些区域分别存放着程序指令、常量、全局变量、动态分配的内存、函数调用栈等运行时数据,共同支撑起一个进程从启动到结束的完整生命周期。
下面通过一段 C 程序,逐一验证各个区域的地址分布与特性:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_unval; // 未初始化全局变量 → BSS 段 int g_val = 100; // 已初始化全局变量 → 数据段 (.data) int main(int argc, char *argv[], char *env[]) { const char *str = "helloworld"; // 字符串字面量 → 只读常量段 (.rodata) static int test = 10; // 静态局部变量 → 数据段 (.data) // 动态申请堆内存 char *heap_mem = (char*)malloc(10); char *heap_mem1 = (char*)malloc(10); char *heap_mem2 = (char*)malloc(10); char *heap_mem3 = (char*)malloc(10); printf("code addr: %p\n", main); // 代码段地址 printf("init global addr: %p\n", &g_val); // 数据段地址 printf("uninit global addr: %p\n", &g_unval); // BSS 段地址 printf("test static addr: %p\n", &test); // 静态变量地址 printf("heap addr: %p\n", heap_mem); // 堆内存地址 printf("heap addr: %p\n", heap_mem1); printf("heap addr: %p\n", heap_mem2); printf("heap addr: %p\n", heap_mem3); printf("stack addr: %p\n", &heap_mem); // 指针变量本身在栈上 printf("stack addr: %p\n", &heap_mem1); printf("stack addr: %p\n", &heap_mem2); printf("stack addr: %p\n", &heap_mem3); printf("read only string addr: %p\n", str); // 只读常量段地址 return 0; }运行上述程序后,结合输出结果可以依次验证各区域:
1.main函数地址:验证代码段 (.text)。代码段存放可执行指令,地址位置固定,权限为只读可执行,程序运行期间不可修改。
2.&g_val(已初始化全局变量):验证数据段 (.data)。数据段存放初始化过的全局变量和静态变量,程序加载时从可执行文件中拷贝到内存。
3.&g_unval(未初始化全局变量):验证BSS 段。BSS 段存放未初始化全局变量,程序加载时由操作系统统一清零,因此不占用可执行文件空间。
4.&test(static 静态局部变量):静态变量不属于栈,存放于数据段 (.data),生命周期随整个进程,而不是函数局部栈,函数返回后依然存在。
5.heap_mem / heap_mem1...(malloc 返回值):验证堆 (heap)。堆由 malloc 动态申请,地址从低往高增长;多次 malloc 得到连续但有间隙的地址(间隙是内存对齐、堆管理元数据)。注意:heap_mem本身是指针变量,存放在栈上;heap_mem的值才是堆内存地址。
6.&heap_mem、&heap_mem1...:取指针变量本身的地址,验证栈 (stack)。局部变量(包括指针变量)都在栈上,栈地址向低地址方向增长,与堆的增长方向相反。
7.str的值(字符串字面量"helloworld"地址):验证只读常量段 (.rodata)。该区域存放字符串常量等只读数据,属于只读区域,任何修改都会导致段错误。
二、虚拟地址空间
2.1 虚拟地址
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int num = 100;//全局变量 int main(int argc, char *argv[], char *env[]) { pid_t id = fork(); if(id == 0) { while(1) { printf("我是子进程,pid:%d,ppid:%d,num:%d,address:%p\n",getpid(), getppid(), num, &num); num++;//将num自增 sleep(1); } } else if(id > 0) { while(1) { printf("我是父进程,pid:%d,ppid:%d,num:%d,address:%p\n",getpid(), getppid(), num, &num); sleep(1); } } return 0; }当我们运行上述代码观察结果时会发现一个奇特现象:子进程内的 num 随着循环不断自增,而父进程中的 num 始终保持原值。同一个变量却同时存在两个不同的值,这本身就令人费解;更让人疑惑的是,对比父子进程中这个变量的地址,二者居然是完全相同的,这看起来似乎违背常理。
而这种现象并不是系统或者编译产生的错误,因为我们通过&获取到的地址本质都是虚拟地址。创建进程时,PCB 中的mm_struct会管理一份属于该进程的独立虚拟地址空间。当调用 fork 创建子进程时,子进程的虚拟地址空间会基于父进程的虚拟地址空间进行拷贝,这正是进程独立性的体现,也解释了父子进程中 num 拥有相同虚拟地址,却能保存不同数值的原因。
那虚拟地址和真实物理地址之间又是如何建立联系的?虚拟地址空间依靠页表完成地址转换,把虚拟地址映射到物理内存地址。fork 拷贝子进程时,会对父进程的页表执行浅拷贝,父子进程此时共享同一份物理内存。一旦子进程试图修改变量 num,就会触发写时复制,操作系统会在物理内存中新开辟一块空间存放修改后的数据,再把子进程中该变量对应的虚拟地址重新映射到这块新的物理内存。
2.2 什么是虚拟地址空间
通过上面 fork 创建子进程的现象,我们就可以引出虚拟地址空间的概念。虚拟地址空间是操作系统借助 MMU 硬件,为每一个进程抽象出来的一套独立连续的地址编号集合,由进程 PCB 中的mm_struct结构体进行管理。它并不是真实存在的物理内存,只是进程视角下看到的地址范围。进程在代码中使用&取到的所有地址,全部都是这类虚拟地址。操作系统通过页表,建立虚拟地址到物理内存地址之间的映射关系。不同进程各自拥有独立的虚拟地址空间,哪怕两个进程内的虚拟地址完全相同,经过页表转换后,也可以指向不同的物理内存,以此实现进程之间的内存隔离。
形象的理解:操作系统(大富翁),系统中的各个进程(私生子)。大富翁向每位私生子许诺可以拥有一份遗产(物理内存),但这份许诺只是给每个私生子单独画下的一张大饼(虚拟地址空间)。由于私生子数量众多,大富翁需要为每个人的大饼单独建立档案来管理(mm_struct),记录这份虚拟地址空间的布局、页表等信息。
虚拟地址空间的区域划分
在虚拟地址(程序地址空间)中包含多个不同功能的区域,因此mm_struct需要对这些区域进行划分,划分的方式就是记录每一块区域的起始地址与结束地址。
struct mm_struct { /*...*/ struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */ struct rb_root mm_rb; /* red_black树 */ unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/ /*...*/ // 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。 unsigned long start_code, end_code, start_data, end_data; unsigned long start_brk, brk, start_stack; unsigned long arg_start, arg_end, env_start, env_end; /*...*/ }2.3 为什么需要虚拟地址空间
1.控制进程的行为,拦截进程的非法访问,以此保护物理内存,避免一个进程破坏其他进程或者内核的数据。
2.依托虚拟地址空间和页表,可以让进程的内存布局从物理上的无序碎片,变为进程视角下有序连续的地址空间。
3.将进程管理和内存管理进行解耦合,让操作系统可以更灵活地管理内存资源。
三、程序地址空间和虚拟地址空间的区别与联系
3.1 联系
程序地址空间本质就是单个进程所拥有的虚拟地址空间。操作系统为每个进程创建独立的虚拟地址空间,这个分配给进程、划分出代码段、数据段、堆、栈等区域的地址范围,就是程序地址空间。我们在代码中用&获取到的地址,都属于这片虚拟地址。mm_struct结构体管理着程序地址空间的各个区域,依靠页表完成虚拟地址到物理内存的映射,fork 创建子进程拷贝的也是父进程的程序地址空间。
3.2 区别
虚拟地址空间是操作系统结合 MMU 硬件提供的通用内存抽象概念,是一套地址映射模型;而程序地址空间是这个模型作用在某一个运行进程上的实例。虚拟地址空间描述的是地址映射这套机制本身,程序地址空间侧重描述一个进程内部的地址分段布局(代码段、BSS 段、堆、栈等)。简单来说,虚拟地址空间是通用概念,程序地址空间是进程实例。
四、小结
本文围绕程序地址空间与虚拟地址空间展开讲解,核心要点如下:
1.程序地址空间:是进程视角下可访问的全部地址范围,被划分为代码段、数据段、BSS 段、堆、栈以及只读常量段等区域,存放程序指令、常量、全局变量、动态分配内存和函数调用栈等运行时数据。
2.虚拟地址:通过 fork 创建子进程时,父子进程变量地址相同但值不同的现象,说明代码中通过&取到的地址本质都是虚拟地址,由 PCB 中的mm_struct管理。
3.地址转换:虚拟地址依靠页表映射到物理内存;fork 时页表浅拷贝使父子进程共享物理内存,子进程修改变量时触发写时复制,重新映射到新开辟的物理内存。
4.虚拟地址空间:是操作系统借助 MMU 为每个进程抽象出的独立连续地址编号集合,由mm_struct记录各区域起始与结束地址,实现进程间内存隔离。
5.为什么需要:一是拦截非法访问保护物理内存;二是把物理无序碎片变为进程视角下有序连续的地址空间;三是将进程管理与内存管理解耦合,提升灵活性。
6.区别与联系:程序地址空间本质是单个进程所拥有的虚拟地址空间实例;虚拟地址空间是通用地址映射模型,程序地址空间侧重描述进程内部的地址分段布局。