【Linux】十.进程概念--程序地址空间

📅 2026/7/5 15:26:16
【Linux】十.进程概念--程序地址空间
引言学习C/C将解最离不开的就是地址分有堆区栈区代码区局/静态区、字符常量区等但这个理解远远不够接下来就是如下我们学习的进程址空间分布如图进程空间的地址时虚拟地址不是物理内存进程地址空间会在进程的整个生命周期内一直存在直到进程退出。解释为什么全局/静态变量的生命周期是整个程序因为随着进程一直存在的内核空间用户不能读写操作系统用的字符常量区代码区只可读数据区静态数据全局数据一.检验进程地址空间的分布#include stdio.h #include unistd.h #include stdlib.h int g_unval; int g_val 100; int main(int argc, char *argv[], char *env[]) { const char *str helloworld; printf(code addr: %p\n, main); printf(init global addr: %p\n, g_val); printf(uninit global addr: %p\n, g_unval); static int test 10; char *heap_mem (char*)malloc(10); char *heap_mem1 (char*)malloc(10); char *heap_mem2 (char*)malloc(10); char *heap_mem3 (cchar*)malloc(10); printf(heap addr: %p\n, heap_mem); //heap_mem(0), heap_mem(1) printf(heap addr: %p\n, heap_mem1); //heap_mem(0), heap_mem(1) printf(heap addr: %p\n, heap_mem2); //heap_mem(0), heap_mem(1) printf(heap addr: %p\n, heap_mem3); //heap_mem(0), heap_mem(1) printf(test static addr: %p\n, test); //heap_mem(0), heap_mem(1) printf(stack addr: %p\n, heap_mem); //heap_mem(0), heap_mem(1) printf(stack addr: %p\n, heap_mem1); //heap_mem(0), heap_mem(1) printf(stack addr: %p\n, heap_mem2); //heap_mem(0), heap_mem(1) printf(stack addr: %p\n, heap_mem3); //heap_mem(0), heap_mem(1) printf(read only string addr: %p\n, str); for(int i 0 ;i argc; i) { printf(argv[%d]: %p\n, i, argv[i]); } for(int i 0; env[i]; i) { printf(env[%d]: %p\n, i, env[i]); } return 0; }运行结果根据运行结果就可以看出栈区代码区堆区初始化数据全局变量的地址都有相似的地方大致属于同一类别。二.虚拟地址和物理地址定义一个全局变量 g_val然后创建子进程父子进程分别打印出变量值和变量地址。1 #include stdio.h 2 #include unistd.h 3 #include stdlib.h 4 int g_val 0; 5 int main() 6 { 7 pid_t id fork(); 8 if(id 0){ 9 perror(fork); 10 return 0; 11 } 12 else if(id 0){ 13 g_val100; 14 printf(child[%d]: %d : %p\n, getpid(), g_val, g_val); 15 }else{ 16 //parent 17 sleep(3); 18 printf(parent[%d]: %d : %p\n, getpid(), g_val, g_val); 19 } 20 sleep(1); 21 return 0; 22 }运行结果子进程肯定先跑完也就是子进程先修改完成之后父进程再读取。可以发现父子进程打印的变量值是不一样的但变量地址是一样的。父子进程代码共享数据各自独有一份写时拷贝。变量结果不一样说明父子进程中的变量绝对不是同一个变量。理论上相同变量打印的地址值是一样的说明绝对不是物理地址。因为在同一物理地址处不可能读取出两个不同的值。我们曾经在 C/C 语言或其它语言中有个比如取地址符号全都是虚拟地址而非物理地址因为物理地址用户是一概看不到的由操作系统统一管理。OS通常负责将虚拟地址转化成物理地址 。注意程序的代码和数据一定是存在物理内存上的。想要运行程序就必须先将代码和数据加载到物理内存中所以需要操作系统负责转换。从现在开始我们要把程序地址空间改成进程地址空间理解虚拟地址和物理地址从这张图去我们可以看出子进程进行写时拷贝后面会详细讲解父进程然后二者的虚拟地址相同然后经过页表进行映射得到物理内存相同的变量但是物理内存不同。三.理解地址空间3-1举例子大富翁画饼假设有一个富豪他有 10 亿美元的家产而他有 4个私生子但这 4 个私生子彼此之间并不知道对方的存在。这个富豪此刻就给他的孩子画饼对他的每个私生子都说过同一句话“孩子等以后我老了这 10 亿的家产未来都是你的”。站在每个私生子的视角来看每个私生子都认为自己可以拥有 10 亿美元。假设现在每个私生子都单独找父亲一次性要 10 个亿那么这个富豪是拿不出来的。但实际上这是不可能说一次性就给的一般情况下每个私生子找父亲要钱只会几千几万这样一点点去要但这个富豪只要有就一定会给。一旦私生子要的钱太多富豪不给私生子也只会认为是父亲不想给。换而言之这个富豪给每个私生子画饼让他们在大脑中建立一个虚拟的概念都认为自己父亲拥有 10 亿美元。总结人物关系到计算机大富豪——操作系统私生子——进程富豪给私生子画的 10 亿美金的饼——进程的地址空间得出的结论操作系统默认会给每个进程构建一个地址空间的概念如上图在 32 位下把物理内存资源抽象成 4G 的一个线性的虚拟地址空间此时的每个进程都会认为自己有 4G 的物理内存资源。也就是OS 在画饼3-2认识地址空间在 Linux 中地址空间其实是实质上内核中的一种数据结构。描述linux下进程的地址空间的所有的信息的结构体是 mm_struct 内存描述符。每个进程只有⼀个mm_struct结构在每个进程的 task_struct 结构中有⼀个指向该进程的mm_struct结构体指针。有了地址空间那问题来了如进行区域的划分空间的本质无非就是多个区域的集合。那么在 struct mm_struct 结构体中OS 就需要将这些地址划分定义 start 和 end 变量来表示每个区域起始和结束的虚拟地址。然后通过设置这些 start 和 end 的值对这个线性的虚拟地址空间进行区域划分。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; 1 struct vm_area_struct { unsigned long vm_start; //虚存区起始 unsigned long vm_end; //虚存区结束 struct vm_area_struct *vm_next, *vm_prev; //前后指针 struct rb_node vm_rb; //红⿊树中的位置 unsigned long rb_subtree_gap; struct mm_struct *vm_mm; //所属的 mm_struct pgprot_t vm_page_prot; unsigned long vm_flags; //标志位 struct { struct rb_node rb; unsigned long rb_subtree_last; } shared; struct list_head anon_vma_chain; struct anon_vma *anon_vma; const struct vm_operations_struct *vm_ops; //vma对应的实际操作 unsigned long vm_pgoff; //⽂件映射偏移量 struct file * vm_file; //映射的⽂件 void * vm_private_data; //私有数据 atomic_long_t swap_readahead_info; #ifndef CONFIG_MMU struct vm_region *vm_region; /* NOMMU mapping region */ #endif #ifdef CONFIG_NUMA struct mempolicy *vm_policy; /* NUMA policy for the VMA */ #endif struct vm_userfaultfd_ctx vm_userfaultfd_ctx; } __randomize_layout;3-3什么是进程地址空间页表完成虚拟地址和内存地址之间的映射地址空间是什么地址空间的本质操作系统让进程看待物理内存的方式(抽象概念)。地址空间是内核中的一种数据结构即 struct mm_struct 结构体。由 OS 给每个进程创建这样每个进程都认为自己独占系统内存资源。区域划分是什么?划分区域的本质把线性的地址空间划分成了一个个的区域通过设置结构体内的 start 和 end 的值来表示区域的起始和结束。为什么要进行区域划分呢因为可执行程序在磁盘中是被划分成一个个的区域存储起来的所以进程的地址空间才有了区域划分这样的概念方便进程找到代码和数据也方便查找是否越界。虚拟地址的本质每个区域 [start, end] 之间的各个地址就是虚拟地址之间的虚拟地址是连续的。四.存在地址空间的原因存在地址空间是为了让每个进程都感觉自己独占了整个内存同时操作系统在背后安全、高效地把它们映射到有限的、可能有碎片的物理内存上。直接让进程访问物理内存不好吗想象一下一个大酒店物理内存有很多房间物理地址。没有地址空间直接访问来了一群旅游团进程。前台直接分配房间比如“302号房给A团303号房给B团”。那么A团的客人在楼道里乱跑就可能跑进B团的房间干扰。而且每个团的导游在设计旅游路线时必须知道酒店的真实房间号非常麻烦。有地址空间每个旅游团都有一个自己的“导游图”页表。在A团的导游图上写着“去我们的‘大厅1’它在真实酒店的302房间”。在B团的导游图上也写着“去我们的‘大厅1’它在真实酒店的406房间”。每个团的成员都只会看到和使用自己地图上的‘大厅1’虚拟地址。隔离A团按地图去自己的‘大厅1’实际到达302B团也去自己的‘大厅1’实际到达406。他们永远不可能走到对方的房间里去。方便每个团的导游在设计路线时只需要画自己的地图完全不用关心真实的房间号是几零几。灵活一个团需要一个巨大的“宴会厅”酒店没有单个那么大的房间但可以把三个相邻的小房间如302、303、304通过地图映射成一个“连续的”‘宴会厅’给该团。为什么还要存在地址空间(1有效的保护物理内存。因为地址空间和页表是 OS 创建并维护的也就是想使用地址空间和页表进行映射也就一定要在 OS 的监督之下来进行访问也保护了物理内存中的所有合法数据,以及内核里面重要的相关数据切记进程内不能非法访问或映射因为 OS 会进行监督检测如果非法则终止进程。那OS如何检测合法检测一通过划分区域中虚拟地址的起始和结束即 start 和 end 的值来判断当前访问的地址是否合法比如如果用户想在某个虚拟地址处写入但检测到该虚拟地址在字符常量区的 start 到 end 之间而字符常量区是只读的说明非法越界访问了OS 会直接终止进程。char *str hello OS; *str H; // 报错检测二通过页表中的权限属性来判断当前访问的地址是否合法。页表完成虚拟地址到物理地址之间的映射而页表中除了有基本的映射关系之外还可以进行读写等权限的管理。比如如果用户想在某个虚拟地址处写入通过页表进行虚拟地址到物理地址的转换时发现该地址处只有读权限说明非法访问页表拒绝转换OS 直接终止进程。2将内存管理模块和进程管理模块在系统层面上进行解耦合。操作系统的核心功能内存管理、进程管理、文件管理、驱动管理。解耦前没有地址空间内存管理需要知道每个进程的生命周期、每个进程需要多少内存、每个进程的内存布局。进程管理需要知道内存的每个物理页框在哪里、如何分配连续内存。结果两个模块互相依赖修改一个必然影响另一个。比如内存管理算法改变所有进程管理代码都得改。内存管理只管“有没有页表映射”不关心这个映射属于一个正在运行的进程还是一个已经死亡的僵尸进程僵尸进程的页表已经被清空或标记为无效。也就是说现在有了进程地址空间内存管理只需要知道哪些内存区域是被页表映射的哪些是没有被页表映射的不需要知道每个进程的生命状态。想要释放内存当进程管理想要申请内存资源时让内存管理通过页表建立映射即可资源时通过页表取消映射即可。解耦的本质也就是减少模块与模块之间的关联性。在物理内存中可以对未来的数据进行任意位置的加载物理内存的分配可以和进程的管理做到没有关系。在 C/C 语言上 new/malloc 出一块新的空间时本质是在虚拟地址空间申请空间。假设申请了空间但不立马使用这块空间 是对空间造成了浪费地址空间的作用进程向操作系统申请内存时只是在虚拟地址空间上完成了申请操作系统此时可以不分配任何物理内存。缺页中断的触发当进程真正要访问这个虚拟地址对应的物理内存时会触发缺页中断由操作系统自动执行内存管理算法分配物理内存并建立页表映射。延迟分配策略把物理内存的分配推迟到真正需要访问的时候这样能极大提升内存有效使用率接近100%而且进程/用户完全感知不到这个过程。好记地址空间 让进程“先借钱记账”缺页中断 让OS“等真要花钱时再掏钱”进程以为自己占了一大片内存实际OS只在进程用到哪块内存时才分配哪块物理内存最终效果用户无感内存不浪费这就是虚拟内存的“延迟分配”魔法。3通过页表映射到不同的有序区域来实现进程的独立性1)在进程的视角所有的内存分别都可以是有序的。(2)让每个进程以同样的方式来看待代码和数据。因为可执行文件在磁盘上就是按区域代码段、数据段等组织存储的所以进程地址空间也按同样的区域划分。这样加载时可以直接映射运行时可以统一管理。可执行文件按段组织存储链接时合并代码/数据 ↓ 为了便于加载和访问进程地址空间按同样的段划分 ↓ 每个进程都看到相同的“代码段 → 数据段 → BSS → 堆 → 栈”布局 ↓ 进程设计时不需要关心物理内存细节只需要按这个虚拟布局访问 ↓ OS 负责把磁盘上的段加载到虚拟地址空间对应的位置可执行程序形成时有一个链接的过程会把用户代码和库的代码把用户数据和库的数据分别合并在一起。否则可执行程序的代码和数据如果是混着存放在一起的会导致链接过程变得很复杂。所以进程的地址空间才有了区域划分这样的概念方便进程找到代码和数据。总结地址空间 页表的存在可以将内存分布有序化。结合2进程要访问物理内存中的数据和代码可能目前并没有在物理内存中。同样的也可以让不同的进程映射到不同的物理内存便很容易做到进程独立性的实现。进程的独立性可以通过进程空间 页表的方式实现。好处不用在物理内存中找一块连续的区域。站在进程的角度所有进程的代码存放的区域虚拟地址是连续的可以被方便顺序执行。五.重新理解什么是挂起进程和程序有什么区别呢加载的本质就是创建进程。程序是进程的子集问题一进程创建时是否必须立刻把所有代码和数据都加载到物理内存并建好所有页表映射不是。最极端的情况下只创建内核数据结构如 PCB、页表框架物理内存一个字节都不给。进程处于“新建”状态等真正被调度执行、访问某个虚拟地址时才通过缺页中断从磁盘加载对应的代码/数据页。缺页中断就像是进程要访问的内容不在物理内存里CPU就会暂停当前进程向操作系统发“请求”“我要的这个页不在内存里快帮我从磁盘调进来”等操作系统把对应页加载到物理内存、更新页表后再让进程继续执行。问题二、16GB 的游戏4GB 的物理内存能跑吗可以运行。原理就是分批/分时加载游戏启动时只加载最开始需要的 200MB 代码和数据。CPU 执行这部分指令。当执行到还没加载的地址时触发缺页中断 → OS 从磁盘把那一小块比如 4KB 或几 MB加载进来。之前执行过的、暂时不用的部分可以被换出到磁盘腾出空间给新加载的部分。效果物理内存里始终只放“当前正在活跃使用”的部分不活跃的部分在磁盘上。代价物理内存越小换入换出越频繁 → 游戏卡顿因为磁盘比内存慢几万倍。加载的本质就是换入的过程。问题三既然可以分批加载那可以分批换出吗可以。换出的单位也是页通常 4KB甚至这个进程短时间不会再被执行比如挂起 / 阻塞。也就相当于其对应的代码和数据占着空间却不创造价值所以 OS 就可以将它换出一旦被换出那么此时这个进程就叫被挂起。本质把物理内存当成磁盘的“缓存”只放最需要的数据。总结物理内存不是程序的“容器”而是程序的“工作缓存”。程序多大都能跑只要当前需要执行的那一小块在内存里就行。OS 负责在内存和磁盘之间像搬运工一样分批换入换出用户和进程完全感知不到——除了可能会卡。