LINUX系统-09-程序地址空间

Linux-程序地址空间

一、程序地址空间分布


我们学习C语言的时候,详细介绍了程序地址空间的分布。

这篇文章详细介绍了内存各个区域的使用。
C\C++内存管理

1、栈区:栈内存由系统自动进行分配和释放。当进入函数时,系统会自动为函数内的局部变量分配内存;函数执行完毕后,这些内存会被自动回收。

栈区主要存储以下两类数据:

函数的局部变量,包括基本数据类型(如 int、float 等)和对象。函数调用的上下文信息,例如返回地址、调用参数等。

2、堆区:堆区(Heap)是程序运行时内存的重要组成部分,用于动态分配内存。由程序员通过new/new[]、malloc\relloc等分配内存,delete/delete[]、free释放内存。若忘记释放,会导致内存泄漏(Memory Leak),之前例子里的func函数就存在内存泄漏的问题。

3、静态区:包含 数据段 (.data)存储已经初始化的变量 + BSS段 (.bss)存储未初始化的变量 + 只读数据段 (.rodata),用于存储具有静态存储期的变量。这些变量的生命周期贯穿整个程序运行期间,它们在程序启动时被分配内存,在程序结束时才释放。主要存储全局变量和被static修饰的变量

4、常量区:常量区(Constant Area)是静态区(Static Area)的一个子区域,用于存储编译时可确定值的字面常量和全局 / 静态的 const 变量。它的特点是只读,程序运行期间无法修改其中的数据。

可是我们对这幅图的理解并不深刻,那么我们写一段程序将各类变量都集合起来,观察他们地址排列的方式,去确定他们是否按照图中从低地址到高地址的方式去排布。

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int g_val = 100; // 在静态区的初始化区.data
int g_unval; //  在静态区的未初始化区 .bss
static int s_val = 200; // 在静态的初始化区 .data

/*
注意:虽然你不使用 argc 和 argv,但如果你想接收第三个参数 env,前两个参数必须作为"占位符"存在。
简单来说,main 函数没有 int main(char* env[])单第三个参数 这种形式,只有 (void)、(int, char**) 或 (int, char**, char**)。
*/
int main(int argc,char* argv[],char* env[])
{
    int a = 10;                  // 局部变量在栈区
    static int s_j_val = 20;     // 静态变量在静态区\数据段区
    char arr[] = {"hello world"};// 字符数组一样是局部变量,在栈区
    const char* str = "hello world"; // str本身在栈区中,str中的地址在常量区中

    int* ptr1 = (int*)malloc(10*sizeof(int)); // ptr1本身在栈区中,ptr1中的地址在堆区中
    int* ptr2 = (int*)malloc(10*sizeof(int)); // ptr2本身在栈区中,ptr2中的地址在堆区中
    int* ptr3 = (int*)malloc(10*sizeof(int)); // ptr3本身在栈区中,ptr3中的地址在堆区中

	for(int i = 0;env[i];i++) // 打印环境变量
    {
        printf("&env[i] = %p\n",&env[i]);
    }
	/*
	&env[0] = 0x7ffee5121938
	&env[1] = 0x7ffee5121940
	&env[2] = 0x7ffee5121948
	&env[3] = 0x7ffee5121950
	&env[4] = 0x7ffee5121958
	&env[5] = 0x7ffee5121960
	&env[6] = 0x7ffee5121968
	&env[7] = 0x7ffee5121970
	&env[8] = 0x7ffee5121978
	&env[9] = 0x7ffee5121980
	&env[10] = 0x7ffee5121988
	*/
	
    // 局部变量在栈区,ptrx在栈区,但是ptrx指向的内容在堆区中
    printf("&a = %p\n",&a);
    printf("&arr = %p\n",arr);
    printf("&ptr1 = %p\n",&ptr1);
    printf("&ptr2 = %p\n",&ptr2);
    printf("&ptr3 = %p\n",&ptr3);
    // 栈堆相对而生,中间是共享段
	/*
	&a    = 0x7ffee512182c
	&arr  = 0x7ffee5121820
	&ptr1 = 0x7ffee5121818
	&ptr2 = 0x7ffee5121810
	&ptr3 = 0x7ffee5121808
	验证了环境变量的地址大于栈区地址
	栈区地址向下生长,所以地址越来越小
	*/

    // 动态开辟的内存都在堆区
    printf("ptr1 = %p\n",ptr1);
    printf("ptr2 = %p\n",ptr2);
    printf("ptr3 = %p\n",ptr3);
    /*
	ptr1 = 0x155c010
	ptr2 = 0x155c040
	ptr3 = 0x155c070
	堆区的地址向上生长,所以地址越来越大
	*/

    printf("&g_unval = %p\n",&g_unval);
    printf("&s_val = %p\n",&s_val);
	/*
	&g_unval = 0x60104c
	&s_val = 0x601040
	未被初始化的全局变量和静态变量在静态区中未初始化区.bss中,
	*/
	
    // 已经初始化的全局变量和静态变量在初始化区
    printf("&g_val = %p\n",&g_val);
    printf("&s_j_val = %p\n",&s_j_val);
    /*
	&g_val = 0x60103c
	&s_j_val = 0x601044
	已经初始化的全局变量和静态变量在静态区中数据段.data区域中
	.data (已初始化) 和 .bss (未初始化),两者地址相近,但具体谁高谁低不重要。
	*/

    // 字符串常量和代码(函数)都在代码段中,不可以被修改
    printf("str = %p\n",str);
    printf("&main = %p\n",&main);
	/*
	str = 0x4007a0
	&main = 0x40057d
	通常紧邻静态区或代码区,存放字符串字面量。
	代码区: 存放函数指令。
	*/
    return 0;
}

二、虚拟地址空间

从上面最上面幅图可以看出,在32位系统中每一个进程都被分配了4G的内存,但是我们实际的物理内存只有2G,怎么办?

其实我们看到的4G内存是虚拟内存,是操作系统为每个进程创建的假象,而实际的物理内存2G是真实的情况。这两者并不冲突,因为操作系统使用了一种叫做虚拟内存管理的技术骗过每一个进程。

虚拟地址空间 (4GB):这是操作系统给每一个进程画的"大饼"。无论你的电脑只有 256MB 内存还是 2GB 内存,Windows 都会告诉每个 32 位进程:"你的内存地址从 0x00000000 到 0xFFFFFFFF,2的32次方字节,总共 4GB,随便用。" 这个 4GB 是逻辑上的,用来解决程序寻址范围的问题。

1、虚拟内存管理的例子

c 复制代码
#include <stdio.h>
#include <unistd.h>

int g_val = 100;    // 设置全局变量

int main()
{
    printf("fork之前g_val的值:%d\n",g_val); 
    pid_t id = fork();

    if(id == 0)//child
    {
        while(1)
        {
            printf("我是子进程,g_val = %d\n",g_val); // 子进程打印g_val的值
            g_val++; // 子进程g_val+1
            printf("子进程&g_val = %p\n",&g_val); // 打印子进程g_val的地址
            sleep(1);
        }
    }
    else
    {
        while(1)
        {
        printf("我是父进程,g_val = %d\n",g_val); // 打印父进程g_val的值
        printf("父进程&g_val = %p\n",&g_val); // 打印父进程g_val的地址
        sleep(1);}
    }

我们把代码跑起来

bash 复制代码
benjiangliu@VM-4-8-centos lesson19]$ ./验证看到的地址是虚拟地址 
fork之前g_val的值:100
我是父进程,g_val = 100
父进程&g_val = 0x601044

我是子进程,g_val = 100
子进程&g_val = 0x601044

我是父进程,g_val = 100
父进程&g_val = 0x601044

我是子进程,g_val = 101
子进程&g_val = 0x601044

我是父进程,g_val = 100
父进程&g_val = 0x601044

我是子进程,g_val = 102
子进程&g_val = 0x601044

通过程序的现象可以看出,在fork以后,父进程和子进程都有自己的g_val,父进程和子进程的g_val的地址却是一样的,但是g_val的值却不一样。为什么?因为子进程以父进程为模版创建,所以他们的地址是一样的,子进程g_val++,不影响父进程的g_val,一样的地址,不可能有2个值, 所以可以知道进程的地址是虚拟的,真实的物理地址是虚拟的,被操作系统隐藏了,我们无法看到!所以我们在C\C++语言中看到的地址,全部都是虚拟地址,物理地址用户一概看不到!

之前说过每一个进程都有自己的虚拟内存,相当于每一个进程都会有4G的内存,而这4G的内存都是虚拟内存,虽然存储在内存上面,但是不与物理内存的地址一一对应。
这就是操作系统内存管理的核心精髓------虚拟化。

2、为什么需要有虚拟地址空间?

1、防止指针越界访问,控制进程的行为(因)

当指针在虚拟内存中有越界访问时,OS可以提前进程判定,防止真的越界。

2、保护内存,拦截进程非法行为(果)

当进程出现非法行为时候,MMU直接报错

3、便于在物理内存上进行管理

OS可以通过虚拟内存将进程的代码和数据放在物理内存的任何位置。在编译时,虽然都在虚拟内存中是从低到高进行排布的,但是通过页表(后面表述)可以将虚拟内存有序的布置变为物理内存无序布置。

4、让进程管理和内存管理解耦合

在linux中,创建了一个进程,是先创建内核数据结构(PCB和进程地址空间),再加载代码和数据的,如果我们不着急使用这个进程,可以在需要的时候再加载代码和数据(懒加载 )。

例如我写了一段程序 int* ptr = (int*)malloc(10000),或者new int(10000),这10000个空间虽然已经申请了,但是我们并没有立刻使用,因为动态内存申请是在虚拟内存上申请的,实际的物理内存上并没有开辟空间,可以先给其他使用中的进程申请空间,当此进制真实使用空间的时候 ,再去申请,这叫缺页中断引起内存二次申请,由OS自动完成。

三、进程地址空间

1、进程地址空间的结构

1、task_struct与mm_struct

从左向右看,先看物理地址左边的空间

最左边是PCB也就是task_struct,是进程控制块

c 复制代码
// 这是 2.6 内核中 task_struct 的简化版结构
// 注意其中的 struct mm_struct *mm; 成员

struct task_struct {
    volatile long state;    // 进程状态
    void *stack;            // 内核栈
    pid_t pid;              // 进程ID
    
    // ... 其他调度、文件系统、信号等成员 ...

    /* 进程地址空间的关键指针 */
    struct mm_struct *mm;       // 指向进程的内存描述符
    struct mm_struct *active_mm;

    // ... 其他成员 ...
};

从task_struct的代码中可以看出,进程地址空间中一个关键指针 struct mm_struct *mm;

这个指针指向虚拟内存结构体的地址。

c 复制代码
// 这是 2.6 内核中 mm_struct 的简化版结构
// 它描述了进程的整个虚拟地址空间

struct mm_struct {
    struct vm_area_struct *mmap;        // 指向虚拟内存区域链表的头指针
    rb_root_t mm_rb;                    // 红黑树根节点 (用于快速查找)
    
    struct vm_area_struct *mmap_cache;  // 上次访问的VMA缓存
    
    pgd_t *pgd;                         // 页全局目录 (页表的基地址)
    
    unsigned long start_code, end_code; // 代码段的起始和结束地址
    unsigned long start_data, end_data; // 数据段的起始和结束地址
    unsigned long start_brk, brk;       // 堆的起始和结束地址
    unsigned long start_stack;          // 栈的起始地址 (注:2.6中栈顶通常动态计算)
    
    // ... 其他统计和锁机制成员 ...
};

所以:

task_struct 指向了 mm_struct,这表示该进程(父进程)拥有一个独立的虚拟地址空间。

mm_struct 内部布局:从上到下依次是栈区、共享区、堆区、未初始化数据区、已初始化数据区、代码区。这是典型的 Linux 进程内存布局。

2、页表

页表(Page Table)就是一个映射表。

它是操作系统和硬件(MMU,内存管理单元)用来实现虚拟地址到物理地址转换的核心数据结构。

你可以把页表理解成一本"字典"或者一个"路由器"。

输入(Key): 虚拟地址

输出(Value): 物理地址

当 CPU 执行指令访问内存时,它给出的是虚拟地址。页表的作用就是查表,找出这个虚拟地址对应的真实物理内存位置。

c 复制代码
// 简化示意
struct mm_struct {
    pgd_t *pgd;  // 指向页全局目录(Page Global Directory)
    // ...
};

页表项

标志位 状态 原因
Present § 1 (存在) 内存就在物理页中,没问题。
Read/Write (R/W) 0 (只读) 这是关键! 内核将这块内存临时设为"只读"。这样,无论是父进程还是子进程,只要试图修改 g_val,MMU 就会拦截并报错。
User/Supervisor (U/S) 0 (内核态) 或 1 (用户态) 取决于具体实现,但在 2.6 内核中,通常为了拦截写操作,可能会将其设为只读,或者配合异常处理。重点是 R/W=0。
Dirty 0 尚未被修改过。

2、父进程视角

task_struct 指向了 mm_struct,这表示该进程(父进程)拥有一个独立的虚拟地址空间。

mm_struct 内部布局:从上到下依次是栈区、共享区、堆区、未初始化数据区、已初始化数据区、代码区。这是典型的 Linux 进程内存布局。

图中红色框标出了 "已初始化数据区",其中包含 父进程g_val 的虚拟地址。

这个虚拟地址通过 页表 映射到了 物理内存 中的 "父进程g_val" 所在的物理页。

此时,虚拟地址 → 物理地址 的映射是唯一的,父进程独占这块物理内存的读写

3、子进程视角

子进程也有自己的 task_struct 和 mm_struct。

mm_struct 布局:与父进程完全相同!这也是 fork() 的核心特性------子进程继承父进程的地址空间布局。

子进程的 g_val 虚拟地址(蓝色框)与父进程的虚拟地址在逻辑上是"相同"的(比如都是 0x601044)。

但是,这个虚拟地址通过子进程的 页表 映射到了 物理内存 中的 "子进程g_val" 所在的物理页。

这说明:虽然虚拟地址相同,但映射的物理地址不同!

3、写时拷贝(coyp of write,COW)

这张图最精髓的地方,在于它隐含了 写时拷贝 的机制:

fork() 初始阶段:

子进程刚创建时,g_val的虚拟地址和物理地址是一样的,它的页表和父进程的页表指向同一个物理页(比如 g_val 的初始值 100)。

为什么呢?学过进程都知道,子进程会共享父进程的代码和数据,之前我们写的这个例子很小,代码和数据都很小,如果给子进程真实开辟一块物理内存,并不影响什么,但是如果父进程很大,光数据就占用了512mb内存,父进程一下开了3个子进程,且继承了父进程的数据,直接在物理内存上开辟3块区域也,那1.5G的内存就没有了,而且他们的数据却是一样的,纯粹浪费资源!

所以:

虚拟地址一样:子进程继承了父进程的地址空间布局,所以 &g_val 打印出来的十六进制数字是一模一样的(比如都是 0x601044)。

物理地址一样:因为刚开始大家都没改数据,为了省事(不申请新内存),内核让这两个一样的虚拟地址,通过各自的页表,映射到了同一个物理页框。

此时,两个进程共享同一块物理内存,为了防止同时修改,这块内存的页表项被标记为只读。

程序运行了一会,子进程的g_val++了,父进程的g_val不变,但是此时父子进程共享物理地址上同一个内存空间,无法再共享内存了,子进程必须修改物理内存,此时在修改的同时CPU 触发缺页中断(因为试图写入页表项中只读页面),因此必须为子进程g_val创建一个新的内存单元存储新的g_val。

发生写操作时:

当子进程尝试修改 g_val(比如 g_val++),CPU 触发 缺页中断(因为试图写入只读页面)。

内核接管,发现这是写时拷贝的情况,于是:

分配一块新的物理页。

将原数据拷贝到新页。

将子进程页表中 g_val 的映射指向新物理页,并设置为可写。

结果:子进程修改的是新页,父进程依然指向旧页,互不干扰。
所以:同一个变量,地址相同个,其本质是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!

让我们提出一个新的问题,当我们创建的父进程有100个变量,这个父进程的子进程只修改了其中2个变量,我们是给100个变量都开辟新物理内存空间,还是只开辟被修改的2个变量?

当然是2个,只会给被修改的 2 个变量所在的内存页分配新的物理内存空间,其余 98 个未修改的变量仍然与父进程共享原来的物理页。这个是写时拷贝的机制构成,核心是为了节省系统资源。

四、虚拟内存管理

描述linux下进程的地址空间所有的信息都存储在结构体mm_struct(内存描述符)中,每一个进程只有一个mm_struct,在每一个进程的task_struct结构中,都有一个指针指向该结构。

c 复制代码
struct task_struct {
    volatile long state;    // 进程状态
    void *stack;            // 内核栈
    pid_t pid;              // 进程ID
    
    // ... 其他调度、文件系统、信号等成员 ...

    /* 进程地址空间的关键指针 */
    struct mm_struct *mm;       // 指向进程的内存描述符
	  // 对于普通用户来说,该字段指向他的虚拟地址空间的用户空间部分,对于内核线程来说这部分为NULL
	  
    struct mm_struct *active_mm;
    // 该字段是内核线程使用的,当该进程是内核线程是,他的mm字段为空,表示没有内存地址空间,可他不是真正没有,这是因为所有有关进程的映射都是一样的,内核线程可以使用任意进程的地址空间

    // ... 其他成员 ...
};

mm_struct结构在广义上是对整个用户空间的描述。

每一个进程都会有一个自己独立的mm_struct,这样每一个进程都会有自己队里的地址空间相互不会干扰。

先看由task_struct到mm_struct,进程的地址空间分布。

定位mm_struct所在的位置在mm_types.h中

c 复制代码
// 这是 2.6 内核中 mm_struct 的简化版结构
// 它描述了进程的整个虚拟地址空间

struct mm_struct {
    struct vm_area_struct *mmap;        // 指向虚拟内存区域链表的头指针
    rb_root_t mm_rb;                    // 红黑树根节点 (用于快速查找)
    
    struct vm_area_struct *mmap_cache;  // 上次访问的VMA缓存
    
    pgd_t *pgd;                         // 页全局目录 (页表的基地址)
    
    unsigned long task_size;              // 具有该结构体进程的虚拟地址空间大小
    
    unsigned long start_code, end_code; // 代码段的起始和结束地址
    unsigned long start_data, end_data; // 数据段的起始和结束地址
    unsigned long start_brk, brk;       // 堆的起始和结束地址
    unsigned long start_stack;          // 栈的起始地址 (注:2.6中栈顶通常动态计算)
    
    // ... 其他统计和锁机制成员 ...
};

既然每一个进程都有自己独立的task_struct和mm_struct,操作系统肯定需要将这么多的进程的mm_struct组织起来,所以常见的虚拟地址空间组织方式有2种:

1、当虚拟区少时,由mmap指针指向这个链表。

2、当虚拟区多时,有红黑树mm_rb进程管理,由mm_rb指向这棵树。

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

我们来看vm_area_struct结构的代码

c 复制代码
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;
#ifdef 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;
};

上面这幅图详细描述了,stask_struct 与mm_struct还有vm_area_struct的关系

每一个逻辑上连续、具有相同访问属性(如读/写/执行、共享/私有等)的内存区域(例如栈区、堆、代码段),都会由一个独立的 vm_area_struct(VMA)来描述和管理。

五、为什么需要虚拟地址空间

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

相关推荐
若风的雨2 小时前
NCCL (NVIDIA Collective Communications Library) 中重要 API 的总结表
linux
历程里程碑2 小时前
子串-----和为 K 的子数组
java·数据结构·c++·python·算法·leetcode·tornado
独自破碎E2 小时前
字符串相乘
android·java·jvm
DokiDoki之父2 小时前
边写软件边学kotlin(一):Kotlin语法初认识:
android·开发语言·kotlin
东东5162 小时前
OA自动化居家办公管理系统 ssm+vue
java·前端·vue.js·后端·毕业设计·毕设
没有bug.的程序员2 小时前
Spring Cloud Alibaba:Nacos 配置中心与服务发现的工业级深度实战
java·spring boot·nacos·服务发现·springcloud·配置中心·alibaba
j_xxx404_2 小时前
Linux:自主shell命令行解释器附源码
linux·运维·服务器
REDcker2 小时前
Telegram Android 源码解析完整文档
android
Big Cole2 小时前
PHP面试题(核心基础篇:垃圾回收+自动加载)
android·开发语言·php