1. 程序地址空间回顾

在学习C语言过程中,有提到过程序地址空间,如下图所示:

可是我们对他并不理解!可以先对其进⾏各区域分布验证:

  1 #include <stdio.h>                                                                                                                                                                                           
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 int g_unval;
  5 int g_val = 100;
  6 int main(int argc, char *argv[], char *env[])
  7 {
  8  const char *str = "helloworld";
  9  printf("code addr: %p\n", main);
 10  printf("init global addr: %p\n", &g_val);
 11  printf("uninit global addr: %p\n", &g_unval);
 12  static int test = 10;
 13  char *heap_mem = (char*)malloc(10);
 14  char *heap_mem1 = (char*)malloc(10);
 15  char *heap_mem2 = (char*)malloc(10);
 16  char *heap_mem3 = (char*)malloc(10);
 17  printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
 18  printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
 19  printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
 20  printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
 21  printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
 22  printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
 23  printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
 24  printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
 25  printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
 26  printf("read only string addr: %p\n", str);
 27  for(int i = 0 ;i < argc; i++)
 28  {
 29  printf("argv[%d]: %p\n", i, argv[i]);
 30  }
 31  for(int i = 0; env[i]; i++)
 32  {
 33  printf("env[%d]: %p\n", i, env[i]);
 34  }
 35  return 0;
 36 }

编译运行的输出结果如下:

code addr: 0x40057d
init global addr: 0x60103c
uninit global addr: 0x601048
heap addr: 0x2265010
heap addr: 0x2265030
heap addr: 0x2265050
heap addr: 0x2265070
test static addr: 0x601040
stack addr: 0x7ffc572b7078
stack addr: 0x7ffc572b7070
stack addr: 0x7ffc572b7068
stack addr: 0x7ffc572b7060
read only string addr: 0x400820
argv[0]: 0x7ffc572b7778
env[0]: 0x7ffc572b777f
env[1]: 0x7ffc572b7794
env[2]: 0x7ffc572b77ab
env[3]: 0x7ffc572b77b6
env[4]: 0x7ffc572b77c6
env[5]: 0x7ffc572b77d4
env[6]: 0x7ffc572b77f7
env[7]: 0x7ffc572b7808
env[8]: 0x7ffc572b781b
env[9]: 0x7ffc572b7824
env[10]: 0x7ffc572b7867
env[11]: 0x7ffc572b7e03
env[12]: 0x7ffc572b7e1c
env[13]: 0x7ffc572b7e76
env[14]: 0x7ffc572b7ea4
env[15]: 0x7ffc572b7ebb
env[16]: 0x7ffc572b7ecb
env[17]: 0x7ffc572b7ed3
env[18]: 0x7ffc572b7ee2
env[19]: 0x7ffc572b7eee
env[20]: 0x7ffc572b7f1e
env[21]: 0x7ffc572b7f41
env[22]: 0x7ffc572b7fb3
env[23]: 0x7ffc572b7fd2
env[24]: 0x7ffc572b7fe8

从上面的例子中可以引申出一些结论如下:

(1)在 Linux 等现代操作系统中,进程的内存布局呈现出明显的“两极分化”。用户栈(Stack)随着函数调用不断向低地址延伸,而动态内存堆(Heap)则随着系统调用向高地址扩张。为了防止这两个区域发生内存越界冲突,内核在两者之间保留了庞大的虚拟地址空洞,这种机制巧妙地实现了内存的动态伸缩与隔离保护。

(2)对于代码中直接写出的字符串字面量(如 "Hello"),编译器并不会将其编译成机器指令,而是将其放置在独立的只读数据段(.rodata section)中。得益于 MMU(内存管理单元)的页表权限控制,任何试图修改该区域数据的操作都会触发硬件级别的保护异常(Segmentation Fault)。

(3)当一个局部变量被声明为 static时,它完成了一次从“运行时栈帧”到“映像静态区”的根本跨越:​ 原本它作为自动变量,寄居于函数调用时创建的栈帧中,随函数返回而烟消云散;加上 static后,编译器会将其剥离栈区,转而在程序加载时就将其安置于虚拟地址空间的静态存储区(已初始化的放入 .data段,零初始化的放入 .bss段)。这意味着它在编译期就拥有了一个固定的内存地址和贯穿进程生命周期的“永生”属性,但在语法层面,它依然被囚禁在原函数的词法作用域内,这种“全局的生命周期 + 局部的访问权限”正是 static最精妙的封装哲学。

2. 虚拟地址

        此外,我们必须升级对“地址空间”的认知。早期教材中常用的“程序地址空间”一词,本质上是为了降低学习门槛的简化模型。在操作系统理论中,这一概念必须严格修正为“进程地址空间”“虚拟地址空间”。它是一个由内核数据结构(如 mm_struct)严格描述的系统抽象,代表了进程对内存资源的一种独占性虚拟视图,而非单纯的物理内存映射。他是一个系统的概念,需要从系统的层面去理解这个名称,而不是语言层的概念。

证明这个结论,先写一串代码:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
 pid_t id = fork();
 if(id < 0){
 perror("fork");
 return 0;
 }
 else if(id == 0){ //child
 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }else{ //parent
 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }
 sleep(1);
 return 0;
}

编译运行得到输出如下:

//与环境相关,观察现象即可 
parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8

        我们发现,输出出来的变量值和地址是⼀模⼀样的,很好理解呀,因为⼦进程按照父进程为模版,父子进程并没有对变量进⾏进⾏任何修改。可是将代码稍加改动:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
 pid_t id = fork();
 if(id < 0){
 perror("fork");
 return 0;
 }
 else if(id == 0){ //child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程
再读取 
 g_val=100;
 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }else{ //parent
 sleep(3);
 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }
 sleep(1);
 return 0;
}

输出结果如下:

//与环境相关,观察现象即可 
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8

补充:编译运行这段代码时会有一个坑:使用make指令时会报错,因为c99不认识"pid_t",所以需要把makefile文件中的"-std=c99"去掉才可以正常的编译运行,这是一个小坑,可以留意一下:

我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量!

  • 但地址值是一样的,说明,该地址绝对不是物理地址!

  • 在Linux地址下,这种地址叫做 虚拟地址,

  • 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。

  • OS必须负责将 虚拟地址​ 转化成 物理地址

3. 虚拟(进程)地址空间

3.1 宏观视角:进程与虚拟地址空间

        在 Linux 操作系统中,每一个进程都拥有一个独立的身份标识——task_struct(进程控制块)。这个结构体不仅描述了进程的状态,还对应着一块专属的虚拟地址空间

这就好比每个进程都拿到了一张巨大的、私有的“地图”:

  • 对于 32 位​ 的系统,这张地图的总大小是 232个地址,即 4GB。按照 Linux 的经典划分,低 3GB​ 是用户空间(User Space),进程可以直接访问和读写;高 1GB​ 则是内核空间(Kernel Space),存放着操作系统内核的代码和数据。

  • 对于 64 位​ 系统,理论上地址空间极其庞大(264),实际上大部分高位地址目前并未使用。

需要明确的是,在这张“地图”上标记的地址(例如 0x12345678),仅仅是虚拟地址,并不是真实的物理内存地址。

3.2 桥梁:页表与地址映射

        既然进程拿到的都是虚拟地址,操作系统是如何找到真实的物理内存呢?这全靠页表(Page Table)

页表就像是地图上密密麻麻的“导航坐标”,它负责将虚拟地址翻译成物理地址:

  • 左侧(虚拟页号):记录进程视角看到的虚拟地址。

  • 右侧(物理页框号):记录该虚拟地址实际映射到的真实物理内存地址。

注:这里为了便于理解做了简化

        当 CPU 想要访问一个变量时,它会拿着这个变量的虚拟地址去查询页表。找到对应的物理地址后,MMU(内存管理单元)才能真正去物理内存中把数据取出来。

3.3 为什么 int 变量有 4 个地址?

这里需要澄清一个常见的误区。假设我们定义了一个整型变量 int a = 10;,它占用 4 个字节。

        虽然虚拟地址空间是连续的,但我们讨论变量的“起始虚拟地址”时,通常是指这 4 个字节中数值最小的那个地址。由于内存是按“页(Page)”来管理的(通常一页是 4KB),这 4 个字节通常会落在同一个物理页中。系统只需要记录这个物理页的起始地址,再加上这 4 个字节的偏移量,就能完整地定位到这个变量。

3.4 fork 是如何实现“一本万利”的?(写时拷贝)

当我们调用 fork()创建一个子进程时,底层的运行就开始了。

1. 初始阶段:完全共享

        操作系统非常聪明,为了节省内存和提高效率,子进程的 task_struct、虚拟地址空间以及页表,都是直接复制(浅拷贝)自父进程的。

        这就是为什么在刚创建子进程时,父子进程访问同一个全局变量的虚拟地址是完全一样的,因为它们共用同一套页表项,指向同一块物理内存。此时,数据是共享且只读的。

2. 进化阶段:写时拷贝(Copy-On-Write)

        如果父子进程只是读数据,它们将永远共享同一块物理内存。但一旦子进程试图修改某个全局变量,就会触发保护机制:

  1. 系统检测到该内存页是共享且只读的,触发缺页异常。

  2. 操作系统会在物理内存中新开辟一块空间,并将父进程的数据拷贝到这块新空间。

  3. 关键一步:修改子进程的页表,让它指向这块新的物理空间

  4. 最后,子进程在新开辟的内存中进行修改。

        正是因为这种机制,我们才会看到:fork()后,父子进程的变量虚拟地址依然相同(因为是各自页表独立映射的结果),但内容却不一样(因为指向了不同的物理副本)。

3. 最终结论:fork 的两个返回值

理解了写时拷贝,就能彻底解释为什么 fork()函数会有两个返回值:

  • 在父进程中,fork()返回子进程的 PID(大于 0),此时父进程继续运行原代码。

  • 在子进程中,fork()返回 0,此时子进程也拿到了一份父进程的代码副本开始运行。

因为它们在底层是两块独立的内存空间(尽管一开始共享),所以它们可以各自保存不同的执行状态和返回值。

 4. 虚拟内存管理

4.1 Linux 内存揭秘:大富翁的“画饼”艺术与虚拟地址空间管理

        在 Linux 的世界里,有一句名言:“在 Linux 中,所见皆虚幻。” 这里的“虚幻”,指的就是虚拟地址空间。
为了让大家轻松理解,我们先讲一个“大富翁与私生子”的故事:

        有一位坐拥 19 亿美金资产的大富翁(操作系统),他有四个互不相认的私生子(四个独立的进程)。大富翁为了激励他们,给每个人画了一张“藏宝图”,承诺每人拥有价值 10 亿美金的财富(每个进程都认为自己的虚拟地址空间是 4GB)。
        当只有一个私生子时,这张图是管用的。但如果四个私生子都要把“财富”兑现(申请物理内存),大富翁的金库(真实的物理内存)可能根本不够分,甚至会因为图纸乱画导致冲突。

        这时候,大富翁意识到,必须对每张“画饼”的图纸进行严格管理。在 Linux 系统中,管理这张图纸的工具,就是一个名为 struct mm_struct的数据结构对象。
那么,mm_struct到底长什么样?

struct task_struct
{
 /*...*/
 struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他
的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。 
 struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当
该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因
为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。 
 /*...*/
}

        它就像是图纸上的“区域规划红线”。为了精确定位,它不仅记录了整个虚拟地址空间的起点和终点,更重要的是,它详细划分了空间内的各个功能区域。

        总的来说:mm_struct并不只是一个简单的起始和结束地址。它的核心职责是对地址空间进行逻辑划分。内核将连续的虚拟地址空间切分为一个个独立的区间,称为 VMA(Virtual Memory Area)。常见的 VMA 包括:

  • 代码段(text segment)
  • 数据段(data segment)
  • 堆区(heap)
  • 栈区(stack)

4.2 精确的区域划分

        为了精确描述这些区域的边界,mm_struct中会记录每个 VMA 的 vm_startvm_end需要特别注意的是,这些区域的起始和结束地址,在 64 位系统中是用长整型(unsigned long来精确保存的。通过这种精细的结构化管理,操作系统确保了每个进程都在自己的“幻觉”中安全运行,互不干扰。

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;
/*...*/
}

4.3 VMA 的双轨制:链表遍历与红黑树查找

那既然每一个进程都会有自己独立的 mm_struct,操作系统肯定是要将这么多进程的 mm_struct组织起来的!虚拟空间的组织方式有两种:

  1. 当虚拟区较少时采取单链表,由 mmap 指针指向这个链表;

  2. 当虚拟区间多时采取红黑树进行管理,由 mm_rb 指向这棵树。

        linux内核使用 vm_area_struct结构来表示一个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个 vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是 vm_area_struct结构来连接各个 VMA,方便进程快速访问。

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;

所以我们可以对上图在进⾏更细致的描述,如下图所示:

4.4 虚拟内存的分配与映射全过程

        在进程的虚拟地址空间中,内存区域的划分并非一成不变。内核允许在运行时动态调整这些区域的边界。这通常发生在程序运行过程中需要申请更多内存(例如 malloc扩容)时。操作系统只需修改 struct mm_struct中对应区域的 startend指针,并调整相应的偏移量,即可完成空间的伸缩。

一个完整的进程加载与内存映射过程,远比单纯的“填空”要精妙得多。其完整生命周期如下:

1. 空间规划(构建蓝图)

        在程序加载初期,操作系统首先会为进程创建 struct mm_struct结构体,并在其中初始化各个内存区域的占位符(如代码区、堆区、栈区等)。

2. 虚拟地址预留

        当程序(如 ELF 可执行文件)被读取进内存时,操作系统会在虚拟地址空间中申请指定大小的连续虚拟地址范围。这一步纯粹是在逻辑层面上“画地盘”,此时并没有真实的物理内存被分配。

3. 物理内存分配与页表填充

        随后,内核根据程序的实际大小,向物理内存管理器申请真实的物理页帧。紧接着,内核会建立映射关系:将上一步预留的虚拟地址与刚刚分配的物理地址一一对应,“填写”进该进程的页表中。

        正是通过这一系列操作,虚拟地址空间不再是空中楼阁,而是变成了真正可访问的物理存在。对用户层程序而言,它们看到的仅仅是那个从 mm_struct中分配出来的、连续的虚拟地址;而底层复杂的物理内存拼接工作,则被操作系统悄无声息地屏蔽了。

5.为什么要有虚拟地址空间?

这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?

        在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。

        那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110M。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。

这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

•   安全风险

    ◦   每个进程都可以访问任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内存区域,如果是一个木马病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。

•   地址不确定

    ◦   众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有在运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程在运行了,那执行a.out的时候,内存地址就不一定了

•   效率低下

    ◦   如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存时间,但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。

存在这么多问题,有了虚拟地址空间和分页机制就能解决了吗?当然!

•   地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,也一定要在OS的监管之下进行访问!!也顺便保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!

•   因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配 和 进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。

•   因为有地址空间的存在,所以我们在C,C++语言上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知!!

•   因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。

6. 知识拓展

  1. 按需加载(缺页中断)

    进程的代码和数据无需在创建时立即载入物理内存,仅需初始化 task_struct(进程控制块)、mm_struct(内存描述符)及页表结构。后续当进程访问未被映射的虚拟地址时,系统会通过缺页中断自动触发数据加载。

  2. 进程创建的“先搭骨架,后填血肉”

    进程创建时,操作系统会先初始化 task_structmm_struct等核心数据结构(搭建“进程骨架”),之后再按需将程序的代码和数据载入内存(填充“血肉”)。

  3. 如何理解进程挂起(Process Suspension)?

    进程挂起并非简单的“卡住”,而是操作系统对进程生命周期的主动管理行为。当系统资源紧张、进程等待 I/O 完成、或需要调试/快照保存时,OS 会将进程从“就绪/运行态”转移至“挂起态”:

    • 挂起状态下,进程指令不会被调度执行;

    • PCB(进程控制块)仍存在,但进程可能被换出到磁盘(交换区),不再占用物理内存,从而降低系统负载;

    • 条件满足(如资源可用、用户唤醒)时,OS 会重新将进程加载回内存并恢复执行。

    ⚠️ 注意:挂起 ≠ 阻塞。阻塞是进程因等待资源(如 I/O)暂时无法运行,但仍可能驻留内存;挂起则是进程被“冷冻”,甚至可能脱离物理内存。

  4. 多堆与多栈的现代内存布局

    在 Linux 等现代操作系统中,一个进程可包含多个堆区与栈区(例如多线程场景下,每个线程独立拥有栈),因此存在多个起始虚拟地址。尽管各堆/栈的虚拟地址空间不连续,但 mm_struct会通过 vm_area_struct链表,为每个区域记录起始/终止虚拟地址及访问权限,实现对非连续内存的精细化管理。

  5. 进程具有独立性

    1.内核数据结构独立

    2.加载进入内存的代码和数据独立 

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐