程序地址空间

文章目录

    • [1. 程序地址空间回顾](#1. 程序地址空间回顾)
    • [2. 虚拟地址](#2. 虚拟地址)
      • [2.1 概念](#2.1 概念)
      • [2.2 虚拟地址与进程地址空间](#2.2 虚拟地址与进程地址空间)
      • [2.3 区域划分 与 mm_struct](#2.3 区域划分 与 mm_struct)
      • [2.4 虚拟地址空间的意义](#2.4 虚拟地址空间的意义)
      • [2.5 一些问题](#2.5 一些问题)
      • [2.6 堆区](#2.6 堆区)
    • [3. 作者的感悟](#3. 作者的感悟)

1. 程序地址空间回顾

我们在讲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 = (char*)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;
}

运行结果:

结论:输出结果同图中结果一样,由代码区到环境变量区的地址逐渐增大。其中注意的点:

  1. 代码段(code):地址为0x40055d,属于程序代码的加载区域,通常在较低地址(符合操作系统对代码段的布局习惯)。
  2. 全局数据段:
  • 初始化全局变量(init global)地址0x601034,
  • 未初始化全局变量(uninit global)地址0x601040,
  • 静态变量(test static)地址0x601038

三者属于全局数据段 ,地址高度集中(都在0x6010xx区间),体现了全局数据的连续存储特性。

  1. 只读字符串段:地址0x400800,属于程序的只读数据区(存储字符串常量),和代码段地址(0x40055d)同属0x400xxx区间,说明只读数据与代码在内存中是相邻 / 同区域管理的。
  2. 堆(Heap)的地址规律
    堆地址为0x1415010、0x1415030、0x1415050、0x1415070------每次地址递增0x20(即十进制的 32),体现了堆内存 "按需分配、向上增长" 的特点:堆由程序动态申请(如malloc),每次分配的内存块地址是连续递增的,且块大小固定。
  3. 栈(Stack)的地址规律
    栈地址为0x7ffdccce9b798、0x7ffdccce9b790、0x7ffdccce9b788、0x7ffdccce9b780------每次地址递减0x8(即十进制的 8),体现了栈== "后进先出、向下增长" ==的核心特性:栈用于函数调用、局部变量存储,每次函数调用或局部变量定义会 "压栈",地址向低地址方向递减(0x7ffd...属于用户栈的典型地址范围,在 Linux 系统中栈从高地址向低地址扩展)。
  4. 命令行与环境变量的地址规律
    argv[0]地址0x7ffdccce9c7fc,env系列地址从0x7ffdccce9c803开始,且env[0]到env[22]的地址连续递增(每次递增几个字节到十几个字节不等)。
    这体现了命令行参数和环境变量在内存中是连续存储的,且位于栈的附近区域(0x7ffd...属于用户栈 / 参数区的地址范围),符合操作系统对程序启动参数的布局逻辑。

Q: 程序地址空间是内存吗?

A: 不是,是进程地址空间(虚拟地址空间),是系统的概念,而不是语言的概念。

验证一下:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int gval = 100;

int main()
{
    printf("父进程开始运行,pid: %d\n", getpid());
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 1;
    }
    else if(id == 0)
    {
        printf("子进程, pid: %d, ppidid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);
        // child
        while(1)
        {
            sleep(1);
            gval+=10; // 修改
            printf("子进程, pid: %d, ppidid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);
        }
    }
    else
    {
        //father
        while(1)
        {
            sleep(1);
            printf("父进程, pid: %d, ppidid: %d, gval: %d, &gval: %p\n", getpid(), getppid(), gval, &gval);
        }
    }
    return 0;
}

这段代码会发生写时拷贝,因为进程具有独立性。

Q: 但明明访问的是同一个gval,而且是同一个地址的gval,为什么会显示出不同的值呢?

A: 说明访问的地址根本就不是内存物理地址,而是虚拟地址。

2. 虚拟地址

2.1 概念

  1. 一个进程一个虚拟地址空间。
  • 虚拟地址空间对应的宽度为一字节。
  • 32位地址总有232个地址,共4GB 。
  • 64位地址总有264个地址,共4GB 。
  • 4GB 分为3GB的用户空间和1GB的内核空间,其中用户空间拿着地址就可以直接访问。
  1. 一个进程一个页表。
  • 在创建一个变量时,变量在内存上存在一份,在虚拟地址上也存在一份。
  • 页表的左侧填写的虚拟地址,右侧填写内存的物理地址。
    页表是用来做虚拟地址和物理地址的映射。
  1. 子进程也有自己的页表。
  • 子进程的页表也拷贝自父进程。发生的是简单的浅拷贝。
  • 当子进程对数据进行修改时,操作系统介入,重新开辟一块物理地址给子进程,重新填写页表,但是虚拟地址没有发生改变,这就导致我们看到的实验结果,明明地址一样,却出现不同的值。这就叫做写时拷贝 。

用户是看不到物理地址的,操作系统将物理地址隐藏起来了。

2.2 虚拟地址与进程地址空间

虚拟地址最大的意义是:操作系统让每一个进程都认为自己在独占物理内存。是画大饼的操作。

Q: 操作系统给每个进程一个虚拟地址空间,让其认为自己独占物理内存,但实际不是这样,在系统上,可能同时运行多个进程,并且物理地址只有固定的大小。那么操作系统是不是应该将虚拟地址管理起来呢?如果要管理,应该怎么管理?

A: 先描述,再组织。(简直是六字真言)

所以!虚拟地址空间本质就是一个数据结构,结构体变量:mm_struct

2.3 区域划分 与 mm_struct

现在我们知道,虚拟内存被划分为很多区域,这一操作叫做区域划分。而区域划分的关键就是记录地址空间的开始和结束。另一方面要做调整区域的操作,本质上就是调整开始和结束。

描述linux下进程的地址空间的所有的信息的结构体是mm_struct。每个进程只有⼀个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的mm_struct结构体指针。

c 复制代码
struct task_struct
 {
 /*...*/
 	struct mm_struct  *mm;              
	struct mm_struct  *active_mm;              
/*...*/
 }
c 复制代码
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;
 /*...*/
}

由此我们知道了,当加载一个进程时,操作系统在虚拟地址空间中申请指定大小的空间(调整区域划分),然后将程序加载内存上,在内存上申请物理空间,最后进行页表映射。物理地址转换成虚拟地址,虚拟地址供上层使用。

2.4 虚拟地址空间的意义

Q: 为什么要有虚拟地址空间?
A:

  1. 将地址无序变有序,提升内存利用率。
  2. 虚拟地址转换成物理地址,是由操作系统(实际上是硬件)查找页表的映射。
  3. 页表中还有权限管理,在地址转换的过程中,可以对地址和操作的合法性判定,由此实现对物理内存的保护。
  4. 实现缺页中断机制,动态加载进程数据。
  5. 实现进程管理和内存管理一定程度的解耦合。

看个例子:

c 复制代码
int main()
{
	char *str = "hello world";
	*str = H;
	return 0;
}

以上代码可以通过编译器编译,但是在运行的时候就会崩溃。
原因是: str为字符串常量,在编译时被硬编译在代码区和初始化数据区之间,因此在页表中就只有读权限,所以在执行第二行代码时,映射页表的过程中,发生权限拦截。

2.5 一些问题

  1. 我们可以不加载程序的代码和数据,只有task_struct, mm_struct, 页表。(通过缺页中断处理)
  2. 创建进程时现有task_struct, mm_struct, 等,再有代码和数据。
  3. 理解进程阻塞挂起,操作系统将进程页表的物理地址清空,再将数据唤出到磁盘上,保留虚拟地址,实现挂起。

2.6 堆区

Q: 我在写代码时,动态申请地址,是在堆区上开辟的,那为什么堆区是一整块儿的地址?不止一个堆吧?

A: 是的,存在vm_area_struct *mmap链表,来管理堆区,记录每一个堆的开始与结束。

实际上,每一个区域都有vm_area_struct ,用来表示该区域的开始和结束。

3. 作者的感悟

学Linux的感觉很爽,怎么讲?感觉真的在很认真的了解计算机的每一个动作,我从来没有这么认真的交往过一个朋友,Linux你身上的特性,对我来讲不是负担,而是你独一无二的性格。我觉得,我应该能学好Linux。


相关推荐
勤源科技4 小时前
全链路智能运维中的业务连续性保障与容灾切换机制
运维
梁萌4 小时前
Linux安装BiliNote
linux·运维·服务器·docker·bilinote
带电的小王4 小时前
llama.cpp:Android端测试Qwen2.5-Omni
android·llama.cpp·qwen2.5-omni
Roc-xb4 小时前
解决虚拟机安装的Ubuntu20.04.6 LTS 不能复制粘贴问题
服务器·ubuntu·vmvare
小安运维日记4 小时前
RHCA - DO374 | Day03:通过自动化控制器运行剧本
linux·运维·数据库·自动化·ansible·1024程序员节
明道源码5 小时前
Android Studio 代码编辑区域的使用
android·ide·android studio
无聊的小坏坏5 小时前
从零开始:C++ TCP 服务器实战教程
服务器·c++·tcp/ip
行思理5 小时前
docker新手教程
运维·docker·容器
小墙程序员6 小时前
从隐私协议了解Android App到底获取了哪些信息
android