Skip to main content

20-20-内存管理(上):为客户保密,规划进程内存空间布局

平时我们说计算机的“计算”两个字,其实说的就是两方面,第一,进程和线程对于CPU的使用;第二,对于内存的管理。所以从这一节开始,我们来看看内存管理的机制。

我之前说把内存管理比喻为一个项目组的“封闭开发的会议室”。很显然,如果不隔离,就会不安全、就会泄密,所以我们说每个进程应该有自己的内存空间。内存空间都是独立的、相互隔离的。对于每个进程来讲,看起来应该都是独占的。

独享内存空间的原理

之前我只是简单地形容了一下。这一节,我们来深入分析一下,为啥一定要封闭开发呢?

执行一个项目,要依赖于项目执行计划书里的指令。项目只要按这些指令运行就行了。但是,在运行指令的过程中,免不了要产生一些数据。这些数据要保存在一个地方,这个地方就是内存,也就是我们刚才说的“会议室”。

和会议室一样,内存都被分成一块一块儿的,都编好了号。例如3F-10,就是三楼十号会议室。内存也有这样一个地址。这个地址是实实在在的地址,通过这个地址我们就能够定位到物理内存的位置。

使用这种类型的地址会不会有问题呢?我们的二进制程序,也就是项目执行计划书,都是事先写好的,可以多次运行的。如果里面有个指令是,要把用户输入的数字保存在内存中,那就会有问题。

会产生什么问题呢?我举个例子你就明白了。如果我们使用那个实实在在的地址,3F-10,打开三个相同的程序,都执行到某一步。比方说,打开了三个计算器,用户在这三个程序的界面上分别输入了10、100、1000。如果内存中的这个位置只能保存一个数,那应该保存哪个呢?这不就冲突了吗?

如果不用这个实实在在的地址,那应该怎么办呢?于是,我们就想出一个办法,那就是封闭开发

每个项目的物理地址对于进程不可见,谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的,里面的内存都是从0开始编号。

在程序里面,指令写入的地址是虚拟地址。例如,位置为10M的内存区域,操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

规划虚拟地址空间

通过以上的原理,我们可以看出,操作系统的内存管理,主要分为三个方面。

第一,物理内存的管理,相当于会议室管理员管理会议室。

第二,虚拟地址的管理,也即在项目组的视角,会议室的虚拟地址应该如何组织。

第三,虚拟地址和物理地址如何映射,也即会议室管理员如何管理映射表。

接下来,我们都会围绕虚拟地址和物理地址展开。这两个概念有点绕,很多时候你可能会犯糊涂:这个地方,我们用的是虚拟地址呢,还是物理地址呢?所以,请你在学习这一章节的时候,时刻问自己这个问题。

我们还是切换到外包公司老板的角度。现在,如果让你规划一下,到底应该怎么管理会议室,你会怎么办?是不是可以先听听项目组的意见,收集一下需求。

于是,你看到了项目组的项目执行计划书是这样一个程序。

#include <stdio.h>
#include <stdlib.h>

int max_length = 128;

char * generate(int length){
int i;
char * buffer = (char*) malloc (length+1);
if (buffer == NULL)
return NULL;
for (i=0; i<length; i++){
buffer[i]=rand()%26+'a';
}
buffer[length]='\0';
return buffer;
}

int main(int argc, char *argv[])
{
int num;
char * buffer;

printf ("Input the string length : ");
scanf ("%d", &num);

if(num > max_length){
num = max_length;
}

buffer = generate(num);

printf ("Random string is: %s\n",buffer);
free (buffer);

return 0;
}

这个程序比较简单,就是根据用户输入的整数来生成字符串,最长是128。由于字符串的长度不是固定的,因而不能提前知道,需要动态地分配内存,使用malloc函数。当然用完了需要释放内存,这就要使用free函数。

我们来总结一下,这个简单的程序在使用内存时的几种方式:

  • 代码需要放在内存里面;
  • 全局变量,例如max_length;
  • 常量字符串"Input the string length : ";
  • 函数栈,例如局部变量num是作为参数传给generate函数的,这里面涉及了函数调用,局部变量,函数参数等都是保存在函数栈上面的;
  • 堆,malloc分配的内存在堆里面;
  • 这里面涉及对glibc的调用,所以glibc的代码是以so文件的形式存在的,也需要放在内存里面。

这就完了吗?还没有呢,别忘了malloc会调用系统调用,进入内核,所以这个程序一旦运行起来,内核部分还需要分配内存:

  • 内核的代码要在内存里面;
  • 内核中也有全局变量;
  • 每个进程都要有一个task_struct;
  • 每个进程还有一个内核栈;
  • 在内核里面也有动态分配的内存;
  • 虚拟地址到物理地址的映射表放在哪里?

竟然收集了这么多的需求,看来做个内存管理还是挺复杂的啊!

我们现在来问一下自己,上面的这些内存里面的数据,应该用虚拟地址访问呢?还是应该用物理地址访问呢?

你可能会说,这很简单嘛。用户态的用虚拟地址访问,内核态的用物理地址访问。其实不是的。你有没有想过,内核里面的代码如果都使用物理地址,就相当于公司里的项目管理部门、文档管理部门都可以直接使用实际的地址访问会议室,这对于会议室管理部门来讲,简直是一个“灾难”。因为一旦到了内核,大家对于会议室的访问都脱离了会议室管理部门的控制。

所以,我们应该清楚一件事情,真正能够使用会议室的物理地址的,只有会议室管理部门,所有其他部门的行为涉及访问会议室的,都要统统使用虚拟地址,统统到会议室管理部门那里转换一道,才能进行统一的控制。

我上面列举出来的,对于内存的访问,用户态的进程使用虚拟地址,这点毫无疑问,内核态的也基本都是使用虚拟地址,只有最后一项容易让人产生疑问。虚拟地址到物理地址的映射表,这个感觉起来是内存管理模块的一部分,这个是“实”是“虚”呢?这个问题先保留,我们暂不讨论,放到内存映射那一节见分晓。

既然都是虚拟地址,我们就先不管映射到物理地址以后是如何布局的,反正现在至少从“虚”的角度来看,这一大片连续的内存空间都是我的了。

如果是32位,有2^32 = 4G的内存空间都是我的,不管内存是不是真的有4G。如果是64位,在x86_64下面,其实只使用了48位,那也挺恐怖的。48位地址长度也就是对应了256TB的地址空间。我都没怎么见过256T的硬盘,别说是内存了。

现在,你可比世界首富房子还大。虽然是虚拟的。下面你可以尽情地去排列咱们要放的东西。请记住,现在你是站在一个进程的角度去看这个虚拟的空间,不用管其他进程。

首先,这么大的虚拟空间一切二,一部分用来放内核的东西,称为内核空间,一部分用来放进程的东西,称为用户空间。用户空间在下,在低地址,我们假设就是0号到29号会议室;内核空间在上,在高地址,我们假设是30号到39号会议室。这两部分空间的分界线因为32位和64位的不同而不同,我们这里不深究。

对于普通进程来说,内核空间的那部分虽然虚拟地址在那里,但是不能访问。这就像作为普通员工,你明明知道财务办公室在这个30号会议室门里面,但是门上挂着“闲人免进”,你只能在自己的用户空间里面折腾。

我们从最低位开始排起,先是Text Segment、Data Segment和BSS Segment。Text Segment是存放二进制可执行代码的位置,Data Segment存放静态常量,BSS Segment存放未初始化的静态变量。是不是觉得这几个名字很熟悉?没错,咱们前面讲ELF格式的时候提到过,在二进制执行文件里面,就有这三个部分。这里就是把二进制执行文件的三个部分加载到内存里面。

接下来是(Heap)。堆是往高地址增长的,是用来动态分配内存的区域,malloc就是在这里面分配的。

接下来的区域是Memory Mapping Segment。这块地址可以用来把文件映射进内存用的,如果二进制的执行文件依赖于某个动态链接库,就是在这个区域里面将so文件映射到了内存中。

再下面就是(Stack)地址段。主线程的函数调用的函数栈就是用这里的。

如果普通进程还想进一步访问内核空间,是没办法的,只能眼巴巴地看着。如果需要进行更高权限的工作,就需要调用系统调用,进入内核。

一旦进入了内核,就换了一种视角。刚才是普通进程的视角,觉着整个空间是它独占的,没有其他进程存在。当然另一个进程也这样认为,因为它们互相看不到对方。这也就是说,不同进程的0号到29号会议室放的东西都不一样。

但是到了内核里面,无论是从哪个进程进来的,看到的都是同一个内核空间,看到的都是同一个进程列表。虽然内核栈是各用各的,但是如果想知道的话,还是能够知道每个进程的内核栈在哪里的。所以,如果要访问一些公共的数据结构,需要进行锁保护。也就是说,不同的进程进入到内核后,进入的30号到39号会议室是同一批会议室。

内核的代码访问内核的数据结构,大部分的情况下都是使用虚拟地址的,虽然内核代码权限很大,但是能够使用的虚拟地址范围也只能在内核空间,也即内核代码访问内核数据结构。只能用30号到39号这些编号,不能用0到29号,因为这些是被进程空间占用的。而且,进程有很多个。你现在在内核,但是你不知道当前指的0号是哪个进程的0号。

在内核里面也会有内核的代码,同样有Text Segment、Data Segment和BSS Segment,别忘了咱们讲内核启动的时候,内核代码也是ELF格式的。

内核的其他数据结构的分配方式就比较复杂了,这一节我们先不讲。

总结时刻

好了,这一节就到这里了,我们来总结一下。这一节我们讲了为什么要独享内存空间,并且站在老板的角度,设计了虚拟地址空间应该存放的数据。

通过这一节,你应该知道,一个内存管理系统至少应该做三件事情:

  • 第一,虚拟内存空间的管理,每个进程看到的是独立的、互不干扰的虚拟地址空间;
  • 第二,物理内存的管理,物理内存地址只有内存管理模块能够使用;
  • 第三,内存映射,需要将虚拟内存和物理内存映射、关联起来。

课堂练习

这一节我们讲了进程内存空间的布局,请找一下,有没有一个命令可以查看进程内存空间的布局,打印出来看一下,这对我们后面解析非常有帮助。

欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。