文章目录
- 一、引言
- 二、Linux进程地址空间的概念
- 1、进程地址空间定义
- 2、进程地址空间的组成
- 3、进程地址空间与物理内存的关系
- 三、页表与内存映射
- 1、页表的定义及作用
- 2、页表的缺页中断
- 三、进程的写时拷贝
一、引言
在Linux中,进程管理是其核心功能之一,它负责创建、调度、执行和终止各种程序,确保它们能够有序、高效地运行。
进程地址空间是Linux操作系统为每个进程分配的一个独立的虚拟内存空间。这个空间是进程在内存中的逻辑表示,它包含了程序执行所需的各种资源和数据。页表则是Linux操作系统实现进程地址空间与物理内存映射的关键数据结构。通过页表,Linux能够确保每个进程都拥有自己独立的地址空间,实现进程的隔离和保护。
在Linux中,进程地址空间和页表的作用不仅仅是为了管理内存资源,它们还对于实现进程间的通信、内存保护、以及优化系统性能等方面具有重要意义。
二、Linux进程地址空间的概念
1、进程地址空间定义
进程地址空间是指每个进程在计算机内存中所占用的地址空间。它本质上是一种虚拟地址空间,是进程看待内存的方式,抽象出来的一个概念,由操作系统提供。这种地址空间不是物理地址,而是由内核中的结构体mm_struct
表示,用于在进程控制块task_struct
中完成各个数据区域的划分,并通过页表映射到物理内存上。
每个进程都有自己独立的地址空间,意味着每个进程都有自己的内存地址范围,不会与其他进程冲突。这种独立性有助于防止地址的随意访问,保护物理内存与其他进程。同时,将进程管理和内存管理进行解耦合,保证进程的独立性。
进程间通信时,必须通过操作系统提供的机制来实现,因为不同进程之间的地址空间是独立的。每个进程都以统一的视角看待自己的代码和数据,这得益于地址空间的独立性。
总的来说,进程地址空间是操作系统为进程提供的一种抽象和隔离机制,使得每个进程都有自己独立的内存视图,从而确保进程的安全性和独立性。
2、进程地址空间的组成
进程地址空间是Linux系统中每个进程所拥有的一个独立、私有的虚拟内存空间。它包含了程序执行所需的各种资源,如代码、数据、堆、栈等,这些资源在地址空间中按照特定的规则进行布局和管理。下面详细介绍进程地址空间的组成:
在32位机器下:Linux中每个进程都有4G的虚拟地址空间,(独立的3G用户空间和共享的1G内核空间)。
⚠️ 下图不是内存,而是进程地址空间:
- 1G内核空间既然是所有进程共享,因此fork()创建的子进程自然也将拥有。
- 3G的用户空间是从父进程进程而来。
1. 代码段(Text Segment)
代码段,也被称为文本段,是存放程序二进制代码的区域。它包含了程序执行所需的机器指令,这些指令在程序加载到内存时就已经确定,并且在程序执行过程中不会改变。代码段通常是只读的,以防止程序意外地修改其指令。
2. 数据段(Data Segment)
数据段是存放程序中已初始化全局变量和静态变量的区域。这些变量在程序加载到内存时就已经被赋予了初值,并且在程序执行过程中可以读写。数据段位于代码段之后,与代码段相邻。
3. BSS段
BSS段是存放程序中未初始化全局变量和静态变量的区域。与数据段不同,BSS段在程序加载到内存时并不占据实际的物理内存空间,而只是在虚拟地址空间中预留了位置。这些变量在程序开始执行前会被自动初始化为0。
4. 堆区
堆区是程序运行时动态申请内存的区域。程序员可以使用如malloc
、calloc
等函数在堆上申请任意大小的内存块,并在不再需要时使用free
函数释放这些内存。堆从低地址向高地址增长,为程序提供了灵活的内存管理机制。
5. 栈区
栈区是存储局部变量和函数调用的区域。每个函数在调用时都会在栈上创建一个栈帧(Stack Frame),用于存储该函数的局部变量、参数以及返回地址等信息。栈遵循后进先出(LIFO)的原则,函数返回时其对应的栈帧会被销毁,局部变量和参数等也会被释放。栈从高地址向低地址增长。
6. 内核空间
内核空间是操作系统内核代码运行的地方,它拥有最高的权限级别。内核负责管理系统的硬件资源,如CPU、内存、硬盘等,并确保它们得到合理的分配和使用。内核空间中的代码可以直接访问硬件和进行低级操作,因此它具有很高的权限和安全性要求。内核空间中的代码通常是预编译和高度优化的,以提高系统的性能和稳定性。
这些区域通过内存映射的方式与物理内存进行关联,使得程序能够直接访问文件或共享库的内容。
需要注意的是,进程地址空间是虚拟的,并不直接对应物理内存。操作系统通过页表等机制将虚拟地址映射到物理地址,实现进程对内存的访问。这种虚拟内存机制提供了内存保护、地址空间隔离等功能,增强了系统的安全性和稳定性。
我们通过如下代码来深入理解:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
printf("code addr:\t %p\n", main);
printf("init data addr:\t %p\n", &g_val);
printf("uninit data addr:%p\n", &g_unval);
char *heap = (char *)malloc(20);
char *heap1 = (char *)malloc(20);
char *heap2 = (char *)malloc(20);
char *heap3 = (char *)malloc(20);
static int s_val;
printf("heap addr:\t %p\n", heap);
printf("heap1 addr:\t %p\n", heap1);
printf("heap2 addr:\t %p\n", heap2);
printf("heap3 addr:\t %p\n", heap3);
printf("stack addr:\t %p\n", &heap);
printf("stack addr:\t %p\n", &heap1);
printf("stack addr:\t %p\n", &heap2);
printf("stack addr:\t %p\n", &heap3);
printf("s_val addr:\t %p, s_val: %d\n", &s_val, s_val);
for (int i = 0; argv[i]; i++)
printf("argv[%d]=\t%p\n", i, argv[i]);
for (int i = 0; argv[i]; i++)
printf("&argv[%d]=\t%p\n", i, argv + i);
for (int i = 0; env[i]; i++)
printf("env[%2d]=\t%p\n", i, env[i]);
for (int i = 0; env[i]; i++)
printf("&env[%2d]=\t%p\n", i, env + i);
/*
第一个循环打印的是环境变量的值(字符串)的地址。
第二个循环打印的是指向环境变量值(字符串)的指针的地址。
*/
return 0;
}
上述代码段主要展示了如何获取和打印程序不同部分的地址,从而从进程地址空间的角度观察这些部分的布局。以下是从进程地址空间角度对代码的解释:
-
全局变量:
int g_unval;
和int g_val = 100;
分别定义了未初始化和已初始化的全局变量。在进程地址空间中,这些变量通常位于数据段。已初始化的全局变量(如g_val
)存储在数据段的一个区域,而未初始化的全局变量(如g_unval
)则通常位于BSS段,这是一个为未初始化的静态和全局变量保留的空间。
-
main
函数地址:printf("code addr:\t %p\n", main);
打印了main
函数的地址。在进程地址空间中,main
函数通常位于代码段(或称为文本段),这是一个只读的段,用于存储程序的指令。
-
堆内存分配:
- 通过
malloc
函数分配了四块堆内存,并打印了它们的地址。在进程地址空间中,堆通常位于数据段之上,用于动态内存分配。每次调用malloc
时,都会从堆中分配一块内存,并返回其地址。并且很容易观察出堆从低地址向高地址增长。
- 通过
-
静态局部变量:
static int s_val;
定义了一个静态局部变量。静态局部变量在进程的生命周期内持续存在,但其值只在函数被调用时初始化。在地址空间中,静态局部变量通常位于数据段的一个特定区域。
-
栈内存:
- 变量
heap
,heap1
,heap2
,heap3
是局部变量,它们存储在栈上。通过打印这些变量的地址(实际上是它们的指针的地址),我们可以观察到栈的内存布局。栈通常从高地址向低地址增长,用于存储局部变量、函数调用的返回地址等。
- 变量
-
命令行参数和环境变量:
-
argv
和env
是main
函数的参数,分别用于存储命令行参数和环境变量的指针。通过循环遍历这些指针并打印它们的地址,我们可以观察到命令行参数和环境变量在进程地址空间中的位置。通过观察可知,无论是命令行参数和环境变量的表,还是表中指向的内容,都在栈上。for (int i = 0; env[i]; i++) printf("env[%2d]=\t%p\n", i, env[i]); for (int i = 0; env[i]; i++) printf("&env[%2d]=\t%p\n", i, env + i);
第一组循环打印每个环境变量的地址(即字符串的地址)。第二组循环打印指向环境变量指针的指针的地址。
简而言之,第一组循环关注的是字符串本身(命令行参数或环境变量)在内存中的位置,而第二组循环关注的是指向这些字符串的指针数组在内存中的位置。对于
argv
的循环,情况与env
类似。
-
从进程地址空间的角度来看,代码段、数据段(包括BSS段和数据段本身)、堆和栈是主要的组成部分。代码段包含程序的指令,数据段包含静态数据(包括全局变量和静态变量),堆用于动态内存分配,而栈则用于存储局部变量和函数调用信息。这些部分在进程地址空间中有相对固定的位置,但具体的布局和大小取决于操作系统、编译器以及程序的特定需求。
命令行参数和环境变量在进程运行期间是一直存在的。它们作为进程启动时的配置信息,被传递给
main
函数,并在整个进程的生命周期内保持可用。命令行参数通过
argv
(argument vector)数组传递给main
函数,每个数组元素都是一个指向命令行参数的指针。这些指针指向在进程地址空间中分配的字符串,这些字符串包含了用户启动程序时提供的命令行参数。环境变量通过
env
(environment vector)数组传递给main
函数,与argv
类似,每个env
数组元素都是一个指向环境变量字符串的指针。环境变量通常包含了操作系统提供的配置信息,例如路径、用户设置等,这些信息在程序运行期间可以被程序读取和使用。在进程运行期间,这些命令行参数和环境变量一直存在于进程的地址空间中,直到进程结束。程序可以根据需要随时访问它们,获取启动时的配置信息或环境设置。然而,需要注意的是,对
argv
和env
数组内容的修改通常是不安全的,因为它们可能指向只读的内存区域,或者修改它们可能导致未定义的行为。总结来说,命令行参数和环境变量作为进程启动时的配置信息,在进程运行期间一直存在,并且可以通过
argv
和env
数组在程序中进行访问。
3、进程地址空间与物理内存的关系
简单来说,进程地址空间是操作系统为每个进程提供的一个虚拟的、逻辑上连续的内存视图,而物理内存则是计算机系统中实际的、硬件层面的内存资源。
首先,进程地址空间是虚拟的,它并不直接对应物理内存中的某个具体区域。每个进程都有自己独立的地址空间,这个地址空间的大小通常远大于实际的物理内存容量。这种设计使得操作系统能够灵活地管理内存资源,避免不同进程之间的内存冲突,同时提供更大的内存空间供进程使用。
其次,进程地址空间与物理内存之间的映射关系是由操作系统内核通过页表等机制来实现的。当进程访问其地址空间中的某个地址时,操作系统会查询页表,找到该地址对应的物理内存页,并进行相应的访问操作。这种映射关系是动态的,操作系统可以根据需要进行调整和优化,以充分利用物理内存资源。
此外,由于进程地址空间是虚拟的,因此它还具有一些特殊的属性。例如,每个进程都认为自己的地址空间是连续的,但实际上物理内存可能是分散的;进程地址空间中的地址是虚拟的,与实际的物理地址不同;进程地址空间的大小可以远大于实际的物理内存容量,因为操作系统使用了虚拟内存技术来扩展进程的可用内存空间。
因此,进程地址空间与物理内存之间的关系是操作系统内存管理的基础。通过虚拟内存技术和页表等机制,操作系统能够为每个进程提供一个独立、安全、可扩展的内存环境,从而确保系统的稳定性和高效性。
下面,我们根据下面代码来深入理解他们之间的关系:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 0;
while (1)
{
printf("child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
cnt++;
if (cnt == 5)
{
g_val = 200;
printf("child change g_val: 100->200\n");
}
}
}
else
{
while (1)
{
printf("father, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
}
对于上述代码,当调用fork()
时,会创建一个新的进程,这个新进程(子进程)是原进程(父进程)的一个副本。
观察如图的结果,我们发现,子进程将g_val从100改为200后,父子进程的g_val值不同,但g_val的地址却是相同的。这是为什么呢?
在Linux系统中,fork()
会复制父进程的地址空间到子进程,这意味着子进程开始时具有与父进程相同的内存布局和内容。然而,这两个进程地址空间是独立的,对其中一个进程的修改不会影响另一个进程。
关于进程地址空间与物理内存的关系,以下几点需要注意:
- 虚拟内存:每个进程都有一个虚拟地址空间,这个空间的大小通常远大于物理内存的大小。操作系统使用虚拟内存技术,将进程的虚拟地址空间映射到物理内存和磁盘上的交换空间(swap space)。
- 页面映射:虚拟地址空间被划分为多个固定大小的页面(通常是4KB),每个页面可以映射到物理内存的一个页面,或者被交换出去存放在磁盘上。当进程访问某个页面时,如果该页面不在物理内存中,操作系统会触发一个页面错误(page fault),然后将该页面从磁盘加载到物理内存中。
- 写时复制(Copy-on-Write):在
fork()
调用后,子进程和父进程的地址空间是共享的,但使用了写时复制技术。这意味着,除非其中一个进程试图写入其地址空间中的某个页面,否则这两个进程仍然共享相同的物理内存页面。一旦有写入操作发生,操作系统会为该页面创建一个新的物理内存副本,并将写入操作应用到该副本上,以确保两个进程不会相互干扰。 - 地址空间保护:操作系统会确保每个进程只能访问其自己的地址空间,而不能直接访问其他进程的地址空间,这保证了进程之间的独立性。
- 动态内存分配:进程可以在运行时动态地请求和释放内存。操作系统会管理这些请求,并在物理内存中为进程分配和回收页面。
对于上述代码中的g_val
变量,虽然它在父进程和子进程的地址空间中都有相同的地址,但由于写时复制机制,在子进程修改g_val
之前,父进程和子进程实际上共享相同的物理内存页面。然而,一旦子进程修改了g_val
,操作系统会为该页面创建一个新的物理内存副本,并在子进程的地址空间中更新映射,以确保父进程中的g_val
保持不变。
因此,进程地址空间是虚拟的,并且与物理内存通过复杂的映射和管理机制相关联,以确保进程之间的隔离性和安全性,同时实现高效的内存使用。
三、页表与内存映射
1、页表的定义及作用
页表是操作系统中用于实现虚拟内存管理的重要数据结构。它建立起了虚拟地址与物理地址之间的映射关系,使得进程可以使用连续且统一的虚拟地址空间,而实际上这些虚拟地址在物理内存中可能是分散且不连续的。
具体来说,页表将虚拟内存划分为固定大小的页面(通常为4KB),并将每个虚拟页面的地址映射到物理内存中的某个页帧。每个页表项包含了虚拟页面的页号以及对应的物理页帧号,以及其他一些属性信息,如访问权限、是否驻留在内存中等。
页表在内存管理中的作用主要体现在以下几个方面:
- 地址转换:页表是操作系统实现虚拟地址到物理地址转换的关键。当进程尝试访问某个虚拟地址时,操作系统会查找页表,找到对应的物理地址,然后执行实际的内存访问。
- 内存保护:页表中的每个页表项都包含了一些访问权限信息,如只读、只写或执行权限。通过检查这些权限,操作系统可以防止进程访问不允许的内存区域,从而提高了系统的安全性。
- 实现虚拟内存:通过页表,操作系统可以为进程提供一个远大于物理内存的虚拟地址空间。当进程访问的页面不在物理内存中时,操作系统可以触发页面置换(page fault)机制,将所需的页面从磁盘加载到内存中。这种按需加载的方式使得进程能够使用比物理内存更大的地址空间,提高了内存的利用率。
- 支持内存共享:多个进程可以通过页表共享相同的物理页面。这有助于节省内存空间,并允许进程之间高效地共享数据。
- 动态内存管理:页表使得操作系统能够动态地为进程分配和释放物理内存页面。通过跟踪页表的状态,操作系统可以高效地管理物理内存资源。
我们从页表的角度来理解下面代码:
#include <stdio.h>
int main()
{
char *str="hello world";
*str= 'H';
return 0;
}
在上述的C程序中,尝试修改字符串字面量 "hello world"
指向的内容会导致未定义行为,并且在大多数现代系统上,尝试这样做会触发运行时错误,比如段错误(segmentation fault)。从页表的角度来看,报错的原因如下:
- 字符串字面量的存储:在C语言中,字符串字面量(如
"hello world"
)通常被存储在程序的只读数据段中。这意味着这块内存是标记为只读的,程序不应该尝试去修改它。 - 页表与内存保护:操作系统使用页表来管理虚拟地址到物理地址的映射,并且可以为每个页面设置访问权限(如读、写、执行)。对于存储字符串字面量的页面,操作系统会设置它为只读。
- 尝试修改只读内存:在
*str = 'H';
这一行,程序试图修改只读数据段的内容。当CPU执行这条指令时,它会检查页表中的对应条目以确认是否有写权限。由于该页面是只读的,CPU会触发一个页面错误(page fault)。 - 操作系统响应页面错误:操作系统捕获到这个页面错误,并检查触发错误的原因。由于这是一个尝试写入只读页面的错误,操作系统会向程序发送一个信号(通常是
SIGSEGV
,即段错误信号),通知它发生了不可恢复的错误。 - 程序终止:默认情况下,当程序接收到
SIGSEGV
信号时,它会立即终止执行,并输出一个错误信息(如果操作系统配置了相应的错误报告机制)。这就是为什么你会看到程序崩溃并报告一个段错误。
从页表角度看,报错的原因是程序试图写入一个标记为只读的内存页面,而操作系统通过页表机制检测到这个非法写入操作,并导致程序崩溃。这是操作系统内存保护机制的一部分,用于防止程序错误地修改其不应该修改的数据。
2、页表的缺页中断
malloc
或new
申请内存时得到的虚拟地址在初始时,并没有映射到物理内存。只有当进程实际访问这块内存时,操作系统才会建立相应的页表映射,并进行物理内存的分配。
为了避免不必要的物理内存分配和页表建立,操作系统通常会采用延迟分配的策略。也就是说,当进程申请虚拟内存时,操作系统不会立即分配物理内存,也不会立即建立页表映射。只有当进程真正访问这块虚拟内存时(例如,通过解引用指针),操作系统才会发生缺页中断,并在此时分配物理内存,并建立相应的页表映射。
即,当进程尝试访问一个尚未映射到物理内存的虚拟地址时,CPU会触发一个缺页中断。操作系统会捕获这个中断,并检查页表以确定这个虚拟地址是否有效。如果有效,操作系统会分配一块物理内存,更新页表以建立映射,并将相应的数据(如果有的话)从磁盘或其他地方加载到物理内存中。然后,中断处理完毕,进程可以继续执行。
三、进程的写时拷贝
如何进行写时拷贝的?
当调用fork()
创建子进程时,并不会立即把父进程所有占用的内存页复制一份给子进程。相反,子进程继承了父进程的数据段、代码段、栈、堆,注意从父进程继承来的是虚拟地址空间,同时也复制了页表(没有复制物理块)。因此,此时父子进程拥有相同的虚拟地址,映射的物理内存也是一致的(独立的虚拟地址空间,共享父进程的物理内存)。
只有当其中一个进程(父进程或子进程)尝试对这些共享的内存页进行修改时,操作系统才会进行实际的拷贝操作,为修改者创建一个新的页面副本。这就是写时拷贝机制的核心思想。
此时,父进程和子进程共享页表中的内容,为了确保父子进程之间的独立性,内核将其标记为“只读”,父子双方均无法对其修改。
无论父进程和子进程何时试图对一个共享的页面执行写操作,就产生一个错误,内核得到这个错误后,内核会更新触发写操作的进程的页表,将原来的只读页表项替换为指向新分配页面的可读写页表项。同时,另一个进程(父或子)的页表项仍然保持指向原来的只读页表项,并把原来的只读页表项标记为“可读写”,留给另外一个进程使用(写时复制技术)。
如下图:
这种写时拷贝的策略可以显著减少内存拷贝的开销,特别是在创建大量相似进程(如服务器进程)时。它允许操作系统延迟实际的拷贝操作,直到真的需要修改页面时才进行,从而提高了内存使用效率和系统性能。