文章目录
-
- [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;
}
运行结果:

结论:输出结果同图中结果一样,由代码区到环境变量区的地址逐渐增大。其中注意的点:
- 代码段(code):地址为0x40055d,属于程序代码的加载区域,通常在较低地址(符合操作系统对代码段的布局习惯)。
- 全局数据段:
- 初始化全局变量(init global)地址0x601034,
- 未初始化全局变量(uninit global)地址0x601040,
- 静态变量(test static)地址0x601038
三者属于全局数据段 ,地址高度集中(都在0x6010xx区间),体现了全局数据的连续存储特性。
- 只读字符串段:地址0x400800,属于程序的只读数据区(存储字符串常量),和代码段地址(0x40055d)同属0x400xxx区间,说明只读数据与代码在内存中是相邻 / 同区域管理的。
- 堆(Heap)的地址规律
堆地址为0x1415010、0x1415030、0x1415050、0x1415070------每次地址递增0x20(即十进制的 32),体现了堆内存 "按需分配、向上增长" 的特点:堆由程序动态申请(如malloc),每次分配的内存块地址是连续递增的,且块大小固定。 - 栈(Stack)的地址规律
栈地址为0x7ffdccce9b798、0x7ffdccce9b790、0x7ffdccce9b788、0x7ffdccce9b780------每次地址递减0x8(即十进制的 8),体现了栈== "后进先出、向下增长" ==的核心特性:栈用于函数调用、局部变量存储,每次函数调用或局部变量定义会 "压栈",地址向低地址方向递减(0x7ffd...属于用户栈的典型地址范围,在 Linux 系统中栈从高地址向低地址扩展)。 - 命令行与环境变量的地址规律
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 概念
- 一个进程一个虚拟地址空间。
- 虚拟地址空间对应的宽度为一字节。
- 32位地址总有232个地址,共4GB 。
- 64位地址总有264个地址,共4GB 。
- 4GB 分为3GB的用户空间和1GB的内核空间,其中用户空间拿着地址就可以直接访问。
- 一个进程一个页表。
- 在创建一个变量时,变量在内存上存在一份,在虚拟地址上也存在一份。
- 页表的左侧填写的虚拟地址,右侧填写内存的物理地址。
页表是用来做虚拟地址和物理地址的映射。
- 子进程也有自己的页表。
- 子进程的页表也拷贝自父进程。发生的是简单的浅拷贝。
- 当子进程对数据进行修改时,操作系统介入,重新开辟一块物理地址给子进程,重新填写页表,但是虚拟地址没有发生改变,这就导致我们看到的实验结果,明明地址一样,却出现不同的值。这就叫做写时拷贝 。
用户是看不到物理地址的,操作系统将物理地址隐藏起来了。
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:
- 将地址无序变有序,提升内存利用率。
- 虚拟地址转换成物理地址,是由操作系统(实际上是硬件)查找页表的映射。
- 页表中还有权限管理,在地址转换的过程中,可以对地址和操作的合法性判定,由此实现对物理内存的保护。
- 实现缺页中断机制,动态加载进程数据。
- 实现进程管理和内存管理一定程度的解耦合。
看个例子:
c
int main()
{
char *str = "hello world";
*str = H;
return 0;
}
以上代码可以通过编译器编译,但是在运行的时候就会崩溃。
原因是: str为字符串常量,在编译时被硬编译在代码区和初始化数据区之间,因此在页表中就只有读权限,所以在执行第二行代码时,映射页表的过程中,发生权限拦截。
2.5 一些问题
- 我们可以不加载程序的代码和数据,只有
task_struct,mm_struct,页表。(通过缺页中断处理) - 创建进程时现有
task_struct,mm_struct, 等,再有代码和数据。 - 理解进程阻塞挂起,操作系统将进程页表的物理地址清空,再将数据唤出到磁盘上,保留虚拟地址,实现挂起。
2.6 堆区
Q: 我在写代码时,动态申请地址,是在堆区上开辟的,那为什么堆区是一整块儿的地址?不止一个堆吧?
A: 是的,存在vm_area_struct *mmap链表,来管理堆区,记录每一个堆的开始与结束。

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

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