平时我们说计算机的“计算”两个字,其实说的就是两方面,第一,进程和线程对于CPU的使用;第二,对于内存的管理。——这个是对计算机的理解的两个大方面,面试中问到的场景设计题可以尝试从这两个角度出发。
可以把内存比作是每个公司里面独立封闭的会议室,因为如果不隔离,就会不安全、存在泄露,因而每个进程都应该有自己的进程空间,内存空间都是独立的,相互隔离的,对每个进程来讲看起来应该都是独占的。
独享内存空间的原理
内存地址都是被分成了一块一块编号好了的,如果执行程序的进程直接访问了这些内存地址,举个例子,打开了三个相同的程序如计算器,分别输入需要计算的数字是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;
}
这个程序用到内存的方式如下:
代码需要放在内存里面;
全局变量,例如max_length;
常量字符串"Input the string length : ";
函数栈,例如局部变量num是作为参数传给generate函数的,这里面涉及了函数调用,局部变量,函数参数等都是保存在函数栈上面的;
堆,malloc分配的内存在堆里面;
这里面涉及对glibc的调用,所以glibc的代码是以so文件的形式存在的,也需要放在内存里面。
malloc会调用系统调用,进入内核,所以这个程序一旦运行起来,内核部分还需要分配内存:
内核的代码要在内存里面;
内核中也有全局变量;
每个进程都要有一个task_struct;
每个进程还有一个内核栈;
在内核里面也有动态分配的内存;
虚拟地址到物理地址的映射表放在哪里?
上述这么多的需求,哪些应该用到虚拟地址,哪些要用到物理地址?要明确的是,只有“会议室管理部门”能真正的使用物理地址,其他所有设计访问会议室的,都要使用虚拟地址,统统通过会议室管理部门转换,进行统一的控制。
现在站在一个进程的角度去看虚拟空间,如果是32位,有2^32 = 4G的内存空间都是我的,不管内存是不是真的有4G。如果是64位,在 x86_64下面,其实只使用了48位,那也是256T的内存空间了。首先,这么大的虚拟空间一切二,一部分用来放内核的东西,称为内核空间,一部分用来放进程的东西,称为用户空间。用户空间在下,在低地址,我们假设就是0号到29号会议室;内核空间 在上,在高地址,我们假设是30号到39号会议室。对于普通进程来说,内核空间的那部分虽然虚拟地址在那里,但是不能访问。
从最低位开始排起,先是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号。