Linux 进程地址空间第一讲
高级语言地址划分复习
下面是一张进程地址空间的分布图,相信学过 C 语言的各位肯定不会陌生:

我们先验证一下进程地址空间是不是这样排布的,先写一个程序:
#include <iostream>
#include <cstdlib>
// 全局变量(模拟原始C代码)
int g_val_1; // 未初始化的全局变量
int g_val_2 = 42; // 已初始化的全局变量
int main(int argc, char* argv[], char* env[]) {
// 打印代码段地址(函数存储位置)
std::cout << "代码段地址: " << reinterpret_cast<void*>(main) << std::endl;
// 打印只读字符串常量地址(存储在.rodata段)
const char* str = "hello linux";
std::cout << "只读字符串地址: " << reinterpret_cast<const void*>(str) << std::endl;
// 打印已初始化全局变量地址
std::cout << "已初始化全局变量地址: " << &g_val_2 << std::endl;
// 打印未初始化全局变量地址
std::cout << "未初始化全局变量地址: " << &g_val_1 << std::endl;
// 在堆上分配内存并打印其地址
char* mem = static_cast<char*>(malloc(100));
std::cout << "堆内存地址: " << static_cast<void*>(mem) << std::endl;
// 打印栈地址(局部变量str的地址)
std::cout << "栈地址: " << &str << std::endl;
// 打印程序名称地址(argv[0])
std::cout << "argv[0]地址: " << static_cast<void*>(argv[0]) << std::endl;
// 打印第一个环境变量地址
std::cout << "env[0]地址: " << static_cast<void*>(env[0]) << std::endl;
// 释放分配的内存
free(mem);
return 0;
}
结果输出如下:
╭─ljx@VM-16-15-debian ~/linux_review/lesson1/dir1
╰─➤ ./pro_add.o
代码段地址: 0x56398dc17199
只读字符串地址: 0x56398dc1801a
已初始化全局变量地址: 0x56398dc1a048
未初始化全局变量地址: 0x56398dc1a194
堆内存地址: 0x56398eacf2c0
栈地址: 0x7fffe4125f60
argv[0]地址: 0x7fffe4128245
env[0]地址: 0x7fffe4128251
你会发现,这些地址的排列和上面那张图预期是一样的
当然,还有一个我们没有验证,那就是堆向上增长,栈向下增长:
#include <iostream>
#include <memory>
#include <vector>
// 全局变量
int g_val_1;
int g_val_2 = 100;
int main() {
// 打印代码段地址(使用reinterpret_cast进行类型转换)
std::cout << "code addr: " << reinterpret_cast<const void*>(main) << '\n';
// 字符串常量(使用string_view避免不必要的拷贝)
constexpr std::string_view str = "hello bit";
std::cout << "read only string addr: " << static_cast<const void*>(str.data()) << '\n';
// 打印全局变量地址
std::cout << "init global value addr: " << &g_val_2 << '\n';
std::cout << "uninit global value addr: " << &g_val_1 << '\n';
// 堆区分配(使用智能指针自动管理内存)
auto mem = std::make_unique<char[]>(100);
auto mem1 = std::make_unique<char[]>(100);
auto mem2 = std::make_unique<char[]>(100);
std::cout << "heap addr: " << static_cast<void*>(mem.get()) << '\n';
std::cout << "heap addr: " << static_cast<void*>(mem1.get()) << '\n';
std::cout << "heap addr: " << static_cast<void*>(mem2.get()) << '\n';
// 栈区变量(使用结构化绑定处理多个变量)
auto [a, b, c, d, f] = std::make_tuple(0, 0, 0, 0, 0);
std::cout << "stack addr: " << &a << '\n';
std::cout << "stack addr: " << &b << '\n';
std::cout << "stack addr: " << &c << '\n';
std::cout << "stack addr: " << &d << '\n';
std::cout << "stack addr: " << &f << '\n';
return 0;
}
编译后运行发现结果符合预期:
╭─ljx@VM-16-15-debian ~/linux_review/lesson1/dir1
╰─➤ ./pro_add.o
code addr: 0x55de7a3341a9
read only string addr: 0x55de7a335018
init global value addr: 0x55de7a337050
uninit global value addr: 0x55de7a337194
# 堆向上增长
heap addr: 0x55de7b9402c0
heap addr: 0x55de7b940330
heap addr: 0x55de7b9403a0
# 栈向下增长
stack addr: 0x7ffef6ed95a0
stack addr: 0x7ffef6ed959c
stack addr: 0x7ffef6ed9598
stack addr: 0x7ffef6ed9594
stack addr: 0x7ffef6ed9590
我们再做一个实验,我们在代码的最后加上一个静态变量,打印一下它的地址:
# 附加代码
static auto e = 0;
std::cout << "static int addr: " << &e << std::endl;
编译后运行
╭─ljx@VM-16-15-debian ~/linux_review/lesson1/dir1
╰─➤ ./pro_add.o
code addr: 0x5577c42971b9
read only string addr: 0x5577c4298018
init global value addr: 0x5577c429a058
uninit global value addr: 0x5577c429a194
heap addr: 0x5577c5e962c0
heap addr: 0x5577c5e96330
heap addr: 0x5577c5e963a0
stack addr: 0x7fffaf91fa60
stack addr: 0x7fffaf91fa5c
stack addr: 0x7fffaf91fa58
stack addr: 0x7fffaf91fa54
stack addr: 0x7fffaf91fa50
static int addr: 0x5577c429a19c
你会发现,a的地址变得很小,可以看到其地址在全局变量地址的范围内,这说明操作系统将 static 变量地址放到全局变量区,从而使得 static 生命周期变为全局有效
进程地址空间
我们知道,通过fork()函数可以创建子进程,子进程和父进程会共同享用这一份代码方案,但数据段是不共享的,接下来我将用一个函数创建子进程,但子进程会更改全局变量的值,看看修改前后父子进程对这个全局变量的读取有何变化。
#include <iostream>
#include <unistd.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 2;
// 子进程
while (1)
{
printf("I am child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
// 两秒后修改cnt的值
if (cnt)
--cnt;
else
{
g_val = 200;
printf("g_val changed\n");
cnt--;
}
sleep(1);
}
}
else
{
// 父进程
while (1)
{
printf("I am parent, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
输出如下:
╭─ljx@VM-16-15-debian ~/linux_review/lesson1/dir1
╰─➤ ./pro_add.o
I am parent, pid: 2302657, ppid: 2225383, g_val: 100, &g_val: 0x5611f94d3050
I am child, pid: 2302658, ppid: 2302657, g_val: 100, &g_val: 0x5611f94d3050
I am parent, pid: 2302657, ppid: 2225383, g_val: 100, &g_val: 0x5611f94d3050
I am child, pid: 2302658, ppid: 2302657, g_val: 100, &g_val: 0x5611f94d3050
I am parent, pid: 2302657, ppid: 2225383, g_val: 100, &g_val: 0x5611f94d3050
I am child, pid: 2302658, ppid: 2302657, g_val: 100, &g_val: 0x5611f94d3050
g_val changed
I am parent, pid: 2302657, ppid: 2225383, g_val: 100, &g_val: 0x5611f94d3050
I am child, pid: 2302658, ppid: 2302657, g_val: 200, &g_val: 0x5611f94d3050
这里我们会发现,子进程和父进程所指向的地址是“相同”的,但是子进程把全局变量修改后,父进程的值不变,这是为什么呢?
首先根据这种现象我们可以推出,这个地址一定不是物理地址,同一个物理地址是不可能有两个不同的值的,这个地址是线性地址or虚拟地址,而我们平时在C/C++等语言里面的地址也不是物理地址。
在进程中,每一个进程都有一份页表,页表是一种key-value模型,页表中key存储的是虚拟地址,而value则是物理地址,也就是说进程读取数据是通过虚拟地址在页表中查找映射的物理地址,而在父进程中创建子进程的时候,最初子进程和父进程会享用同一份内存数据结构以及方案,它们共享的函数方法是不会改变的,因为函数是无法改变的,只有数据是可以改变的,但这并不意味着父子进程需要在子进程创建的时候就单独为子进程再深拷贝一份数据,而是采用写时拷贝的方法,也就是说刚开始父子进程的页表是一样的,但在子进程需要改变g_val时,就需要单独为子进程拷贝一份g_val,也就是单独为子进程开辟一份物理空间,我们只需要在页表中将虚拟地址所映射的物理地址修改成写时拷贝的物理空间的地址就可以了,正是因此,子进程在修改数据后父子进程地址一样数据却不一样,因为它们的虚拟地址是一样的,修改的是虚拟地址所映射的物理地址。下面这张图就是我对这个现象的解释:

那么,什么是进程地址空间呢?所谓的进程地址空间,实际上就是一个描述进程可视范围的大小,地址空间内存在各种区域划分,一部分属于代码区,一部分属于只读数据区等等,这些数据的空间划分通过规定它们地址的start和end就可以了,这样做有三个好处:
1.让进程以统一的视角看待内存。
每个进程都会认为整个内存空间都是进程自己的,从而方便操作系统管理每一个进程,让所有的进程以统一的视角去看待内存。
2.增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转化的过程,在这个转化的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。
3.因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
因为一个页表存储的信息有虚拟地址,所映射的物理地址,访问权限,以及是否在内存中存储,这样的话我们就可以通过判断进程是否在内存中从而对是否需要运行该进程和如何运行该进程做出判断,因此进程管理模块和内存管理模块就可以分开进行管理,不会因为内存管理出现问题而导致进程管理受到限制,这种现象就叫做解耦合。
下面我们来具体看一下进程地址空间:
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀个mm_struct结构,在每个进程的 task_struct 结构中,有⼀个
指向该进程的mm_struct结构体指针,核心结构大概如下:
struct task_struct
{
/*...*/
struct mm_struct *mm; //对于普通的用户进程来说该字段指向他
的虚拟地址空间的用户空间部分,对于内核线程来说这部分为NULL。
struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当
该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因
为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
/*...*/
}
可以说, mm_struct 结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴的 mm_struct ,
这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由 task_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;
/*...*/
}
既然每⼀个进程都会有⾃⼰独⽴的 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;
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;
因此,我们可以通过下面这两张图来更好的理解进程地址空间的结构分布:


所以说为什么需要进程地址空间这个东西呢?
1. 直接操作物理内存的问题
(1) 安全风险
- 问题:所有程序直接访问物理内存,恶意程序(如病毒)可以随意读写其他进程或内核的内存数据,导致系统崩溃或数据泄露。
- 虚拟地址空间的解决方案:
- 隔离性:每个进程拥有独立的虚拟地址空间,进程只能访问自己地址空间内的内存。操作系统通过页表(Page Table)控制虚拟地址到物理地址的映射,确保非法访问被拦截(触发缺页异常或段错误)。
- 权限控制:页表项(PTE)中标记内存区域的读写执行权限(如代码段只读),防止代码被篡改。
(2) 地址不确定
- 问题:程序编译时假设从固定物理地址加载,但实际运行时物理内存可能被其他进程占用,导致加载地址冲突。
- 虚拟地址空间的解决方案:
- 固定虚拟地址:程序编译时使用虚拟地址(如从
0x400000开始),操作系统通过页表动态映射到任意可用的物理地址。 - 动态加载:程序每次运行时,物理内存的分配位置可以不同,但虚拟地址对进程透明,保证一致性。
- 固定虚拟地址:程序编译时使用虚拟地址(如从
(3) 效率低下
- 问题:直接操作物理内存时,进程必须作为连续的整体加载到内存或交换到磁盘,导致内存碎片化和交换效率低。
- 虚拟地址空间的解决方案:
- 分页机制:物理内存和虚拟内存被划分为固定大小的页(如4KB),进程的页可以离散分布在物理内存中,无需连续。
- 按需调页:只有实际访问的页才会被加载到物理内存(延迟分配),减少内存浪费。
- 交换优化:仅将不活跃的页交换到磁盘,而非整个进程。
2. 虚拟地址空间的核心优势
(1) 解耦进程管理与内存管理
- 物理内存的灵活性:物理内存的分配与进程的虚拟地址空间无关,操作系统可以动态调整物理页的分配(如页面置换算法)。
- 进程视角统一:进程看到的虚拟地址空间是连续的,实际物理内存的碎片化对进程透明。
(2) 延迟分配与内存超售
- 示例:
malloc或new申请内存时,仅分配虚拟地址空间,物理内存实际占用为0。首次访问时触发缺页异常,操作系统再分配物理页并建立页表映射。 - 好处:避免提前占用物理内存,提高资源利用率。
(3) 共享内存与高效通信
- 机制:不同进程的虚拟地址可以映射到同一物理页(如共享库、进程间通信的共享内存)。
- 实现:页表项中标记共享属性,避免数据冗余拷贝。
3. 虚拟地址空间的实现关键
(1) 页表(Page Table)
- 作用:记录虚拟地址到物理地址的映射关系,由操作系统维护。
- 多级结构:现代CPU使用多级页表(如x86_64的4级页表:PGD→PUD→PMD→PTE),节省存储空间。
(2) 硬件支持
- MMU(内存管理单元):CPU内置组件,负责虚拟地址到物理地址的转换(查页表)。
- TLB(快表):缓存常用页表项,加速地址转换。
(3) 操作系统协作
- 缺页异常处理:访问未映射的虚拟地址时,CPU触发缺页异常,内核分配物理页并更新页表。
- 页面置换:物理内存不足时,将不活跃的页换出到磁盘(如Linux的Swap机制)。
4. 实际案例
(1) 程序启动过程
- 加载器读取可执行文件,为其分配虚拟地址空间(如代码段
.text映射到0x400000)。 - 操作系统动态分配物理页,建立页表映射。
- 程序从虚拟地址
0x400000开始执行,无需关心实际物理地址。
(2) malloc 的工作原理
char *p = malloc(1GB); // 仅分配1GB的虚拟地址空间
*p = 'a'; // 首次访问时触发缺页异常,分配物理页
(3) 共享库(如libc.so)
- 多个进程的虚拟地址映射到同一物理内存中的库代码,节省内存。
5. 总结:虚拟地址空间的意义
| 问题 | 虚拟地址空间的解决方案 |
|---|---|
| 安全风险 | 进程隔离 + 页表权限控制 |
| 地址不确定 | 固定虚拟地址 + 动态物理映射 |
| 内存效率低下 | 分页机制 + 按需调页 + 离散分配 |
| 进程与内存强耦合 | 解耦虚拟地址与物理地址,支持灵活管理 |
| 内存浪费 | 延迟分配(如malloc) + 共享内存 |
虚拟地址空间不仅是技术实现的进步,更是操作系统设计哲学的体现:通过抽象层隐藏复杂性,为用户和程序提供统一、安全、高效的运行环境。