【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_val=100;
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’,实际到达302;B团也去自己的‘大厅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 负责在内存和磁盘之间,像搬运工一样分批换入换出,
用户和进程完全感知不到——除了可能会卡。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)