6. 程序地址空间
目录
五、操作系统中的虚拟内存管理、进程内存布局以及物理内存与磁盘交互的核心原理
5.2. 问题:虚拟地址的区域划分在页表中都有对应的物理内存,请问 char * str = "hello" str = "h" 为什么不能运行成功?
一、知识回归
1.1. 回答下面的问题:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
using namespace std;
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 = (char *)malloc(10);
printf("heap addr: %p\n", heap_mem); // heap_mem1
printf("heap addr: %p\n", heap_mem2); // heap_mem3
printf("heap addr: %p\n", heap_mem3); // heap_mem1
printf("test static addr: %p\n", &test); // heap_mem1
printf("stack addr: %p\n", &heap_mem); // heap_mem2
printf("stack addr: %p\n", &heap_mem3); // heap_mem2
printf("read only string addr: %p\n", str); // heap_mem3
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 (已初始化,int g_val = 100)
-
g_unval (未初始化,int g_unval)
静态局部变量
-
test(static int test = 10,定义在 main 内)
局部变量(自动存储期)
-
str (const char *,指向常量字符串)
-
heap_mem (char *,指向堆内存)
-
heap_mem1 (char *,指向堆内存)
-
heap_mem2 (char *,指向堆内存)
-
heap_mem3 (char *,指向堆内存)
-
argc (int,main 的参数)
-
argv (char **,main 的参数)
-
env (char **,main 的第三个参数)
-
i (for 循环中的 int)
无名内存实体(不是变量,但与变量紧密相关)
-
四次 malloc(10) 申请的堆内存块(分别由 heap_mem、heap_mem1、heap_mem2、heap_mem3 指向)
-
字符串字面量 "helloworld"(由 str 指向)
-
argv 数组指向的命令行参数字符串
-
env 数组指向的环境变量字符串
答案二:
| 变量 / 数据 | 存储区域 | |
|---|---|---|
| main 函数的代码 | 代码段(.text) | |
| "helloworld" 字面量 | 只读数据段(.rodata) | |
| g_val(全局已初始化) | 数据段(.data) | |
| test(静态局部,已初始化) | 数据段(.data) | |
| g_unval(全局未初始化) | BSS 段(.bss) | |
| 四个 malloc(10) 返回的堆块 | 堆(heap) | |
| str 指针变量本身 | 栈(stack) | |
| heap_mem 指针变量本身 | 栈 | |
| heap_mem1 指针变量本身 | 栈 | |
| heap_mem2 指针变量本身 | 栈 | |
| heap_mem3 指针变量本身 | 栈 | |
| argc | 栈 | |
| argv 指针变量本身 | 栈 | |
| env 指针变量本身 | 栈 | |
| i | 栈 | |
| argv[] 中的字符串 | 栈顶部(初始栈) | |
| env[] 中的字符串 | 栈顶部(初始栈) |
1.2. 程序地址分布图

相关区域的解释:
代码段
代码段存放程序的机器指令(编译后的可执行代码),通常是只读的,防止意外修改,并允许多个进程共享同一份代码。包含以下子区域:
-
.init:程序启动时由系统自动调用的初始化代码。
-
.text:主程序的指令,如 main() 函数、自定义函数的机器指令。
-
.rodata:只读数据,如字符串常量 "Hello World!"。
数据段
存放全局变量和静态变量(static),分为两部分:
-
.data:已初始化(显式赋初值)的全局变量和静态变量,如 int x = 5;。
-
.bss:未初始化的全局变量和静态变量,如 int y;。
进程控制块(PCB)
虽然 PCB 是管理进程的核心数据结构,但它不属于进程的虚拟地址空间。它由操作系统内核维护,存放在系统内核区域,对用户进程不可见。
堆
程序运行时动态申请内存的区域,通过 malloc() 等函数分配,用 free() 释放。堆从低地址向高地址增长,大小不固定,适合生命周期不确定的数据。例如,int ptr = (int) malloc(4); 在堆上分配 4 字节。
栈
用于支持函数调用,存放局部变量、函数参数、返回地址等临时信息,由系统自动管理。遵循后进先出(LIFO)原则,从高地址向低地址扩展。例如,调用 add(3, 5) 时,参数和返回地址被压栈,函数返回后弹出。
共享库的存储映射区
共享库用于提供进程所依赖的公共函数代码(如 printf() 等标准库函数)。它们在运行时被动态映射到进程的地址空间,代码是只读的,且可被多个进程共享,从而节省内存资源。
整体概述:
其中,最高地址处是用户栈,随函数调用向下增长;其下方是共享库的映射区域,用于加载动态库;再往下是堆,由 malloc() 等函数动态扩展;堆下方是读/写数据段(.data 和 .bss);底部是只读代码段(.init、.text、.rodata);注意,代码段和数据段的大小在程序加载时就已确定,而堆和栈则可在运行过程中动态扩展和收缩。
上面的内容过于复杂,下面我们会用这个图来代替:

二、回顾之前的一个问题
2.1. 代码
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
using namespace std;
int gval = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
while (1)
{
printf("子进程: gval: %d,&gval: %p,pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
sleep(1);
gval++;
}
}
else
{
while (1)
{
printf("父进程: gval: %d,&gval: %p,pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
sleep(1);
}
}

2.2. 分析
已知知识:
- 进程具有独立性,子进程将数据做修改,即使是全局变量,也并不影响父进程。
- 一台电脑只有一个物理内存,即只有一个物理地址
观察到的信息:
- 可观察到,数据修改,但其地址是一样的,确互不影响
- 父子进程,输出地址是一致的,但是变量内容不一样!
得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在用C/C++等几乎所有语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
- OS必须负责将 虚拟地址(也叫线性地址) 转化成 物理地址 。
三、进程地址空间
3.1 画图
通过上面代码的跑出来的结果,可知,这两个进程的 g_val 指向的 虚拟地址是一样,
但是 g_val 的值是不一样的,说明这个 g_val 在内存中一定存在两份。

通过上面画图的过程和结果,我们可以发现:
- 拷贝的是指针,发生浅拷贝,我们就理解,为什么父进程创建子进程,全部变量 g_val 的地址是一样的,因为发生了浅拷贝,使得他们的虚拟地址空间的值是一样的。
- 同理也知道了,为什么全局变量默认父子共享,是因为映射关系,从虚拟到物理是一样的。指向一个内存
3.2. 引出页表
虚拟地址空间,是如何跟内存联系在一起的?
通过页表
页表是用来将 虚拟地址空间 和 物理内存空间 进行映射的

整理的流程就是:
使用系统调用接口创建新的进程时,fork后的数据代码,父子进程将会同时执行,同时增加新的进程控制块,父子进程通过刚开始相同的页表指向相同的物理空间,其所使用的进程地址空间对应的位置也是相同的,父子进程指向同一个g_val,因此,父进程和子进程对应的g_val的地址是相同的,但是,当子进程尝试修改g_val变量时,为保证进程的独立性,操作系统识别到当前子进程通过页表找到g_val,想修改g_val,此时,操作系统会重新开辟一段空间,将上述值拷贝下来,修改映射关系,因此使用不同的物理内存地址,互不影响,互相独立。
父子进程创建时使用相同的虚拟地址,而进行修改时,经操作系统识别,重新复制一份,并开辟新的空间,经过页表映射的是不同的物理地址,此时修改的是不同的物理地址的数据,其虚拟地址不受影响,这种策略叫做写时拷贝。
再次回答之前,为什么 fork 函数可以返回两个返回值?
fork函数在return时之前,会创建新的进程控制块,而此时return便会同时执行两次,而return的本质,就是对父子进程ret变量进行写入,此时便会进行写时拷贝,父子进程各自拥有属于自己的物理空间,虽然对应虚拟地址相同,但其对应的物理地址的值不同,只不过在用户层我们用同一个变量(虚拟地址)来标识了
四、虚拟地址和物理内存地址
4.1. 举个例子
现在一个老板,有四名员工。
这个老板经常画大饼,
给员工A说,等到年底,干得好的话,给你奖励10w年终奖
给员工B说,等到年底,绩效不赖的话,给你奖励一台奔驰
给员工C说,等到年底,所有工作都做好,给你奖励60m^2的小房子
给员工D说,看你新来的,干得不错,继续保持,等到年底,让你去进修
画大饼,这个事情,懂的都懂,老板容易做不到,或者就是容易记错。
有一天,给员工A说,等到年底,干得不错的话,奖励一个60m^2的房子
给B说,等到年底,干得不错的话,奖励你去进修……
这不都闹乌龙了呀。
因此,作为老板,这个大饼,这个承诺,是不是也要进行管理呢?
需要,那该怎么管理呢?(根据之前的知识)
先描述,在组织!!!
说明,需要有结构体,还需要链表进行维护!!!
4.2. 对应关系
老板:os
员工:进程
大饼:虚拟地址空间
大饼的具体内容:让每个进程都认为自己有4GB的物理内存或者,每个进程都认为自己在独占物理内存。

4.3. 一个空间中,有很多的区域,是怎么进行区域划分的?
举个例子,大家在童年小学肯定遇到过的,三八线问题:
小明和小红做同桌,一个长桌子100cm
其中两个人五五分,各有50cm
现在两个人,要把东西放到桌子上,有书、文具等,该怎么合理的分配自己的空间呢?
有一天,两人生气了,55分,变成了37分,这个新的区域又是如何划分的呢?
- 给桌子的总长度进行刻度
- 把两个人的的分界线画出来即可
- 把各自的长度按照一定的比例,进行刻度,这10cm放书,那10cm放文具
其中,
- 桌子 --- 地址空间
- 量取桌子的长度 --- 对空间进行编址
- 按照一定的规律进行刻度 --- 地址空间上的每个地址
- 使用分界线划分出不同的区域 --- 不同地址的区域划分
- 将之前的55分转化成37分 --- 调整区域
此时就可以上述的逻辑,用计算机进行量化,就可以得到区域划分
struct Destop
{
int size;
int a_start;
int a_end;
}
struct Destop area = {0,49,50,100};
调整区域
area.a_start -= 20;
area.b_end = -=20;
一个整型变量,有四个字节,而我们只获得到一个地址,得到的是地址数最小的。
通过得到的地址+偏移量 就可以得到上层的数值了。
通过这些例子,我们就知道了,这个虚拟地址空间,其实就是个结构体 (mm_struct)
struct mm_struct {
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
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;
.......
}
五、操作系统中的虚拟内存管理、进程内存布局以及物理内存与磁盘交互的核心原理
现在我们把最上面的例子,改成一个 task_struct 和磁盘的代码和数据进行分析

5.1. mm_struct
1. 开辟空间
2. 初始化值从哪里来? 加载的时候,进行初始化
- 会在虚拟地址空间申请申请指定大小的空间(调整区域划分)
- 加载程序,申请物理空间
其中,1 <-> 2 -- > 页表进行映射
这就使得物理地址转化成虚拟地址!!!
提供给上层用户使用
为什么这样子设计?
- 将地址从无序变成有序:磁盘中很多的可执行程序,把对应的数据和代码加载到物理内存,是有序的吗? 不需要,因为可以通过页表进行一一对应
- 地址转换的过程中,也可以对地址和从操作进行合法性判定,进而保护物理内存。例如,为什么在字符常量去写入,会崩溃?查找页表的时候,被权限拦截
- 让进程管理和内存管理,进行一定程度上的解耦!!!
5.2. 问题:虚拟地址的区域划分在页表中都有对应的物理内存,请问 char * str = "hello" str = "h" 为什么不能运行成功?
这是因为在这个页表中,有一个类似备注的一列,显示权限。
这个 str 是常量字符串,是在初始化数据中,不能进行修改,所以他的权限是 r 没有 w
5.3. 动态生命周期(加载、缺页、换出)
我们下面需要用言语说一下整个执行流程:
5.3.1. 惰性加载
操作系统内核开始为它构建虚拟地址空间。此时,进程的 task_struct 结构体中的 struct mm_struct *mm 被正式开辟并初始化,内核会根据可执行文件的头部信息,在虚拟地址空间中划定好清晰的区域:正文代码、初始化数据、未初始化数据、堆、共享区、栈及命令行参数/环境变量;高地址的1G则预留给内核空间
然而,此时物理内存是“零占用”的。内核仅仅创建了页表,并在页表项的“左半边”填上了关键坐标——记录着“该段数据位于磁盘可执行文件的哪个偏移位置”;而指向物理内存页框号的“右半边”全部留空,标记为无效。这个就是操作系统惰性加载(按需调页)的精妙起点。
5.3.2. 缺页中断
当CPU真正开始执行进程指令,访问某个虚拟地址时,硬件MMU查找页表发现“右半边”为空,立即触发缺页中断。进程被暂停,内核接管控制权:
-
内核依据页表“左半边”记录的磁盘偏移,从可执行文件中读取该页数据。
-
分配一个空闲的物理页框,将数据载入。
-
将该物理页框号填入页表的“右半边”,标记为有效,并建立映射关系。此时,物理地址成功转化为进程可用的虚拟地址。
在这个过程中,权限拦截起到了至关重要的保护作用,就像上面一样:内核在填充页表项时,会根据该区域所属的段类型设置权限位(R/W/X)。字符串常量 "hello "位于只读数据段,其页表权限被标记为 只读(r--),当CPU尝试写入时,MMU在地址转换阶段检查权限发现不匹配,直接抛出页错误,内核随即向进程发送 SIGSEGV 信号导致崩溃。物理内存中的数据甚至还没来得及修改,在逻辑转换层面就被坚决拦截了。
5.3.3. 换出与挂起
当物理内存资源极度紧张时,内核的回收机制会被激活。它会挑选合适的物理页进行换出(Swap Out):
-
将该物理页框的数据写入磁盘的Swap分区。
-
清除页表中的“右半边”(物理页框号),并重新标记为无效。
-
关键一步:“左半边”被保留并更新,指向Swap分区中的新存储位置。
如果被换出的页过多,进程甚至可能被挂起(进入挂起态)。这相当于一次逆向的缺页中断—— 拆掉物理映射,为其他进程腾出生存空间。当该进程被再次唤醒,访问那些已被换出的地址时,缺页中断会再次上演,只是这次内核会从Swap分区读回数据,重新分配物理页框,再次填满“右半边”。
此时:
-
变无序为有序:磁盘中的可执行程序和数据加载到物理内存时是杂乱无章的,但通过页表的映射,每个进程都认为自己独享一个从低地址到高地址的连续、有序的3G虚拟空间。
-
完美解耦:进程管理只认虚拟地址,内存管理只关心物理页框和磁盘块,页表则充当了灵活的“翻译官”,让两者互不干扰。创建、加载、换出、再加载,这个闭环循环往复,让有限的物理内存撑起了远超自身容量的程序世界。

六、虚拟内存管理
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct(内存描述符)。每个进程只有一个 mm_struct 结构,在每个进程的 task_struct 结构中,有一个指向该进程的结构。
struct task_struct {
struct mm_struct *mm, *active_mm;
......
}
-
mm (Memory Descriptor):代表进程自己拥有的、真正的用户空间地址空间。
-
active_mm (Active Memory Descriptor):代表进程当前实际正在使用的内存地址空间
6.1. 普通进程 vs. 内核线程
理解二者区别的关键在于区分进程类型:
6.1.1. 对于普通用户进程
对于大多数拥有独立地址空间的进程,这两个指针总是指向同一个 mm_struct 结构体。
-
mm 指向该进程自己的地址空间。
-
active_mm 也指向同一个地址空间,因为该进程运行时,使用的正是它自己的地址空间。
6.1.2. 对于内核线程
内核线程不拥有用户空间,它们只在内核空间运行。
-
mm = NULL:因为内核线程没有自己的用户地址空间。
-
active_mm :指向前一个运行的用户进程的 mm_struct ,即“借用”来的地址空间。这样,当内核线程被调度运行时,它不必切换页表(Page Table),而是直接复用上一个进程的地址空间,从而节省了开销。
这个了解即可,等到讲到 线程的时候,还会重新谈及虚拟内存管理。
6.2. mm_struct的相关介绍
可以说,mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:

定位mm_struct文件所在位置和task_struct所在路径是一样的,不过他们所在文件是不一样的,
mm_struct所在的文件是mm_types.h。
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;
/*...*/
}
6.3. 虚拟空间的组织方式
那既然每一个进程都会有自己独立的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织方式有两种:
-
当虚拟区较少时采取单链表,由mmap指针指向这个链表;
-
当虚拟区多时采取红黑树进行管理,由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; //所属的 mm_struct
struct mm_struct *vm_mm;
pgrpt_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
#ifndef CONFIG_NUMA
struct mempolicy *vm_policy; // NUMA policy for the VMA *
#endif
struct vm_usefulutfdt_ctx vm_usefulutfdt_ctx;
__randomize_layout;
画图所示:


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


所有评论(0)