Linux内存管理

平时我们说计算机的“计算”两个字,其实说的就是两方面,第一,进程和线程对于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号。

虚拟地址空间如何映射位物理地址

 分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。段选择子就保存在咱们前面讲过 的段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的 基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于0和段界限之间。如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址,那么在Linux中是如何实现这个机制的呢?在Linux里面,段表全称段描述符表(segment descriptors),放在全局描述符表GDT (Global Descriptor Table)里面,会有下面的宏来初始化段描述符表里面的表项:

 

#define GDT_ENTRY_INIT(flags, base, limit) { { { \
                .a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \
                .b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
                    ((limit) & 0xf0000) | ((base) & 0xff000000), \
} } }

一个段表项由段基地址base、段界限limit,还有一些标识符组成。

DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
#ifdef CONFIG_X86_64
    [GDT_ENTRY_KERNEL32_CS] = GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
    [GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
    [GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER32_CS] = GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
#else
    [GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
    [GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
    ......
#endif
} };
EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);

这里面对于64位的和32位的,都定义了内核代码段、内核数据段、用户代码段和用户数据段。 

另外,还会定义下面四个段选择子,指向上面的段描述符表项。

#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS*8)
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)

通过分析,我们发现,所有的段的起始地址都是一样的,都是0。在 Linux操作系统中,并没有使用到全部的分段功能。通过分析,我们发现,所有的段的起始地址都是一样的,都是0。其实Linux倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)。

对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面 长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫作换入。这 样可以扩大可用物理内存的大小,提高物理内存的利用率。

这个换入和换出都是以页为单位的。页面的大小一般为4KB。为了能够定位和访问每个页,需要 有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的 每个位置进行访问了。

 虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内 存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。

32位环境下,虚拟地址空间共4GB。如果分成4KB一个页,那就是1M个页。每个页表项需要4个 字节来存储,那么整个4GB空间的映射就需要4MB的内存来存储映射表。如果每个进程都有自己 的映射表,100个进程就需要400MB的内存。对于内核来讲,有点大了 。

页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里 面的页号找到对应的页表项了。我们可以试着将页表再分页,4G的空间需要4M的页表来存储映射。我们把这4M分成1K(1024)个4K,每个4K又能放在一页里面,这样1K个4K就是1K个页,这1K个页也需要 一个表进行管理,我们称为页目录表,这个页目录表里面有1K项,每项4个字节,页目录表大小也是4K。

页目录有1K项,用10位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项,也即4K的页表项。每个页表项也是4个字节,因而一整页的页表项是1K个。再用10位就可以表示访问页表项的哪一项,页表项中的一项对应的就是一个页,是存放数据的页,这个页的大小是4K,用12位可以定位这个页内的任何一个位置。

这样加起来正好32位,也就是用前10位定位到页目录表中的一项。将这一项对应的页表取出来共1k项,再用中间10位定位到页表中的一项,将这一项对应的存放数据的页取出来,再用最后12位定位到页中的具体位置访问数据。

 当然对于64位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项PGD(Page Global Directory)、上层页目录项PUD(Page Upper Directory)、中间页目录项PMD (Page Middle Directory)和页表项PTE(Page Table Entry)。

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值