This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
<audio id="audio" title="20 | 内存管理(上):为客户保密,规划进程内存空间布局" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/0e/3dcddd86cdd56441df5f5b5767008d0e.mp3"></audio>
平时我们说计算机的“计算”两个字其实说的就是两方面第一进程和线程对于CPU的使用第二对于内存的管理。所以从这一节开始我们来看看内存管理的机制。
我之前说把内存管理比喻为一个项目组的“封闭开发的会议室”。很显然,如果不隔离,就会不安全、就会泄密,所以我们说每个进程应该有自己的内存空间。内存空间都是独立的、相互隔离的。对于每个进程来讲,看起来应该都是独占的。
## 独享内存空间的原理
之前我只是简单地形容了一下。这一节,我们来深入分析一下,为啥一定要封闭开发呢?
执行一个项目,要依赖于项目执行计划书里的指令。项目只要按这些指令运行就行了。但是,在运行指令的过程中,免不了要产生一些数据。这些数据要保存在一个地方,这个地方就是内存,也就是我们刚才说的“会议室”。
和会议室一样,**内存都被分成一块一块儿的,都编好了号**。例如3F-10就是三楼十号会议室。内存也有这样一个地址。这个地址是实实在在的地址通过这个地址我们就能够定位到物理内存的位置。
使用这种类型的地址会不会有问题呢?我们的二进制程序,也就是项目执行计划书,都是事先写好的,可以多次运行的。如果里面有个指令是,要把用户输入的数字保存在内存中,那就会有问题。
会产生什么问题呢我举个例子你就明白了。如果我们使用那个实实在在的地址3F-10打开三个相同的程序都执行到某一步。比方说打开了三个计算器用户在这三个程序的界面上分别输入了10、100、1000。如果内存中的这个位置只能保存一个数那应该保存哪个呢这不就冲突了吗
如果不用这个实实在在的地址,那应该怎么办呢?于是,我们就想出一个办法,那就是**封闭开发**。
每个项目的物理地址对于进程不可见谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的里面的内存都是从0开始编号。
在程序里面指令写入的地址是虚拟地址。例如位置为10M的内存区域操作系统会提供一种机制将不同进程的虚拟地址和不同内存的物理地址映射起来。
当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
## 规划虚拟地址空间
通过以上的原理,我们可以看出,操作系统的内存管理,主要分为三个方面。
第一,物理内存的管理,相当于会议室管理员管理会议室。
第二,虚拟地址的管理,也即在项目组的视角,会议室的虚拟地址应该如何组织。
第三,虚拟地址和物理地址如何映射,也即会议室管理员如何管理映射表。
接下来,我们都会围绕虚拟地址和物理地址展开。这两个概念有点绕,很多时候你可能会犯糊涂:这个地方,我们用的是虚拟地址呢,还是物理地址呢?所以,请你在学习这一章节的时候,时刻问自己这个问题。
我们还是切换到外包公司老板的角度。现在,如果让你规划一下,到底应该怎么管理会议室,你会怎么办?是不是可以先听听项目组的意见,收集一下需求。
于是,你看到了项目组的项目执行计划书是这样一个程序。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
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&lt;length; i++){
buffer[i]=rand()%26+'a';
}
buffer[length]='\0';
return buffer;
}
int main(int argc, char *argv[])
{
int num;
char * buffer;
printf (&quot;Input the string length : &quot;);
scanf (&quot;%d&quot;, &amp;num);
if(num &gt; max_length){
num = max_length;
}
buffer = generate(num);
printf (&quot;Random string is: %s\n&quot;,buffer);
free (buffer);
return 0;
}
```
这个程序比较简单就是根据用户输入的整数来生成字符串最长是128。由于字符串的长度不是固定的因而不能提前知道需要动态地分配内存使用malloc函数。当然用完了需要释放内存这就要使用free函数。
我们来总结一下,这个简单的程序在使用内存时的几种方式:
<li>
代码需要放在内存里面;
</li>
<li>
全局变量例如max_length
</li>
<li>
常量字符串"Input the string length : "
</li>
<li>
函数栈例如局部变量num是作为参数传给generate函数的这里面涉及了函数调用局部变量函数参数等都是保存在函数栈上面的
</li>
<li>
malloc分配的内存在堆里面
</li>
<li>
这里面涉及对glibc的调用所以glibc的代码是以so文件的形式存在的也需要放在内存里面。
</li>
这就完了吗还没有呢别忘了malloc会调用系统调用进入内核所以这个程序一旦运行起来内核部分还需要分配内存
<li>
内核的代码要在内存里面;
</li>
<li>
内核中也有全局变量;
</li>
<li>
每个进程都要有一个task_struct
</li>
<li>
每个进程还有一个内核栈;
</li>
<li>
在内核里面也有动态分配的内存;
</li>
<li>
虚拟地址到物理地址的映射表放在哪里?
</li>
竟然收集了这么多的需求,看来做个内存管理还是挺复杂的啊!
我们现在来问一下自己,上面的这些内存里面的数据,应该用虚拟地址访问呢?还是应该用物理地址访问呢?
你可能会说,这很简单嘛。用户态的用虚拟地址访问,内核态的用物理地址访问。其实不是的。你有没有想过,内核里面的代码如果都使用物理地址,就相当于公司里的项目管理部门、文档管理部门都可以直接使用实际的地址访问会议室,这对于会议室管理部门来讲,简直是一个“灾难”。因为一旦到了内核,大家对于会议室的访问都脱离了会议室管理部门的控制。
所以,我们应该清楚一件事情,真正能够使用会议室的物理地址的,只有会议室管理部门,所有其他部门的行为涉及访问会议室的,都要统统使用虚拟地址,统统到会议室管理部门那里转换一道,才能进行统一的控制。
我上面列举出来的,对于内存的访问,用户态的进程使用虚拟地址,这点毫无疑问,内核态的也基本都是使用虚拟地址,只有最后一项容易让人产生疑问。虚拟地址到物理地址的映射表,这个感觉起来是内存管理模块的一部分,这个是“实”是“虚”呢?这个问题先保留,我们暂不讨论,放到内存映射那一节见分晓。
既然都是虚拟地址,我们就先不管映射到物理地址以后是如何布局的,反正现在至少从“虚”的角度来看,这一大片连续的内存空间都是我的了。
如果是32位有2^32 = 4G的内存空间都是我的不管内存是不是真的有4G。如果是64位在x86_64下面其实只使用了48位那也挺恐怖的。48位地址长度也就是对应了256TB的地址空间。我都没怎么见过256T的硬盘别说是内存了。
现在,你可比世界首富房子还大。虽然是虚拟的。下面你可以尽情地去排列咱们要放的东西。请记住,现在你是站在一个进程的角度去看这个虚拟的空间,不用管其他进程。
首先,这么大的虚拟空间一切二,一部分用来放内核的东西,称为**内核空间**,一部分用来放进程的东西,称为**用户空间**。用户空间在下在低地址我们假设就是0号到29号会议室内核空间在上在高地址我们假设是30号到39号会议室。这两部分空间的分界线因为32位和64位的不同而不同我们这里不深究。
对于普通进程来说内核空间的那部分虽然虚拟地址在那里但是不能访问。这就像作为普通员工你明明知道财务办公室在这个30号会议室门里面但是门上挂着“闲人免进”你只能在自己的用户空间里面折腾。
<img src="https://static001.geekbang.org/resource/image/af/83/afa4beefd380effefb0e54a8d9345c83.jpeg" alt="">
我们从最低位开始排起,先是**Text Segment、Data Segment和BSS Segment**。Text Segment是存放二进制可执行代码的位置Data Segment存放静态常量BSS Segment存放未初始化的静态变量。是不是觉得这几个名字很熟悉没错咱们前面讲ELF格式的时候提到过在二进制执行文件里面就有这三个部分。这里就是把二进制执行文件的三个部分加载到内存里面。
接下来是**堆**Heap**段**。堆是往高地址增长的是用来动态分配内存的区域malloc就是在这里面分配的。
接下来的区域是**Memory Mapping Segment**。这块地址可以用来把文件映射进内存用的如果二进制的执行文件依赖于某个动态链接库就是在这个区域里面将so文件映射到了内存中。
再下面就是**栈**Stack**地址段**。主线程的函数调用的函数栈就是用这里的。
如果普通进程还想进一步访问内核空间,是没办法的,只能眼巴巴地看着。如果需要进行更高权限的工作,就需要调用系统调用,进入内核。
一旦进入了内核就换了一种视角。刚才是普通进程的视角觉着整个空间是它独占的没有其他进程存在。当然另一个进程也这样认为因为它们互相看不到对方。这也就是说不同进程的0号到29号会议室放的东西都不一样。
但是到了内核里面无论是从哪个进程进来的看到的都是同一个内核空间看到的都是同一个进程列表。虽然内核栈是各用各的但是如果想知道的话还是能够知道每个进程的内核栈在哪里的。所以如果要访问一些公共的数据结构需要进行锁保护。也就是说不同的进程进入到内核后进入的30号到39号会议室是同一批会议室。
<img src="https://static001.geekbang.org/resource/image/4e/9d/4ed91c744220d8b4298237d2ab2eda9d.jpeg" alt="">
内核的代码访问内核的数据结构大部分的情况下都是使用虚拟地址的虽然内核代码权限很大但是能够使用的虚拟地址范围也只能在内核空间也即内核代码访问内核数据结构。只能用30号到39号这些编号不能用0到29号因为这些是被进程空间占用的。而且进程有很多个。你现在在内核但是你不知道当前指的0号是哪个进程的0号。
在内核里面也会有内核的代码同样有Text Segment、Data Segment和BSS Segment别忘了咱们讲内核启动的时候内核代码也是ELF格式的。
内核的其他数据结构的分配方式就比较复杂了,这一节我们先不讲。
## 总结时刻
好了,这一节就到这里了,我们来总结一下。这一节我们讲了为什么要独享内存空间,并且站在老板的角度,设计了虚拟地址空间应该存放的数据。
通过这一节,你应该知道,一个内存管理系统至少应该做三件事情:
<li>
第一,虚拟内存空间的管理,每个进程看到的是独立的、互不干扰的虚拟地址空间;
</li>
<li>
第二,物理内存的管理,物理内存地址只有内存管理模块能够使用;
</li>
<li>
第三,内存映射,需要将虚拟内存和物理内存映射、关联起来。
</li>
## 课堂练习
这一节我们讲了进程内存空间的布局,请找一下,有没有一个命令可以查看进程内存空间的布局,打印出来看一下,这对我们后面解析非常有帮助。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,125 @@
<audio id="audio" title="21 | 内存管理(下):为客户保密,项目组独享会议室封闭开发" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/83/34e45393bcf240883825c37698181683.mp3"></audio>
上一节,我们讲了虚拟空间的布局。接下来,我们需要知道,如何将其映射成为物理地址呢?
你可能已经想到了咱们前面讲x86 CPU的时候讲过分段机制咱们规划虚拟空间的时候也是将空间分成多个段进行保存。
那就直接用分段机制呗。我们来看看分段机制的原理。
<img src="https://static001.geekbang.org/resource/image/96/eb/9697ae17b9f561e78514890f9d58d4eb.jpg" alt="">
分段机制下的虚拟地址由两部分组成,**段选择子**和**段内偏移量**。段选择子就保存在咱们前面讲过的段寄存器里面。段选择子里面最重要的是**段号**,用作段表的索引。段表里面保存的是这个段的**基地址**、**段的界限**和**特权等级**等。虚拟地址中的段内偏移量应该位于0和段界限之间。如果段内偏移量是合法的就将段基地址加上段内偏移量得到物理内存地址。
例如我们将上面的虚拟空间分成以下4个段用03来编号。每个段在段表中有一个项在物理空间中段的排列如下图的右边所示。
<img src="https://static001.geekbang.org/resource/image/7c/04/7c82068d2d6bdb601084a07569ac8b04.jpg" alt="">
如果要访问段2中偏移量600的虚拟地址我们可以计算出物理地址为段2基地址2000 + 偏移量600 = 2600。
多好的机制啊我们来看看Linux是如何使用这个机制的。
在Linux里面段表全称**段描述符表**segment descriptors放在**全局描述符表GDT**Global Descriptor Table里面会有下面的宏来初始化段描述符表里面的表项。
```
#define GDT_ENTRY_INIT(flags, base, limit) { { { \
.a = ((limit) &amp; 0xffff) | (((base) &amp; 0xffff) &lt;&lt; 16), \
.b = (((base) &amp; 0xff0000) &gt;&gt; 16) | (((flags) &amp; 0xf0ff) &lt;&lt; 8) | \
((limit) &amp; 0xf0000) | ((base) &amp; 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操作系统中并没有使用到全部的分段功能。那分段是不是完全没有用处呢分段可以做权限审核例如用户态DPL是3内核态DPL是0。当用户态试图访问内核态的时候会因为权限不足而报错。
其实Linux倾向于另外一种从虚拟地址到物理地址的转换方式称为**分页**Paging
对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为**换出**。一旦需要的时候,再加载进来,叫做**换入**。这样可以扩大可用物理内存的大小,提高物理内存的利用率。
这个换入和换出都是以页为单位的。页面的大小一般为4KB。为了能够定位和访问每个页需要有个页表保存每个页的起始地址再加上在页内的偏移量组成线性地址就能对于内存中的每个位置进行访问了。
<img src="https://static001.geekbang.org/resource/image/ab/40/abbcafe962d93fac976aa26b7fcb7440.jpg" alt="">
虚拟地址分为两部分,**页号**和**页内偏移**。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。
下面的图,举了一个简单的页表的例子,虚拟内存中的页通过页表映射为了物理内存中的页。
<img src="https://static001.geekbang.org/resource/image/84/eb/8495dfcbaed235f7500c7e11149b2feb.jpg" alt="">
32位环境下虚拟地址空间共4GB。如果分成4KB一个页那就是1M个页。每个页表项需要4个字节来存储那么整个4GB空间的映射就需要4MB的内存来存储映射表。如果每个进程都有自己的映射表100个进程就需要400MB的内存。对于内核来讲有点大了 。
页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。
那怎么办呢我们可以试着将页表再分页4G的空间需要4M的页表来存储映射。我们把这4M分成1K1024个4K每个4K又能放在一页里面这样1K个4K就是1K个页这1K个页也需要一个表进行管理我们称为页目录表这个页目录表里面有1K项每项4个字节页目录表大小也是4K。
页目录有1K项用10位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项也即4K的页表项。每个页表项也是4个字节因而一整页的页表项是1K个。再用10位就可以表示访问页表项的哪一项页表项中的一项对应的就是一个页是存放数据的页这个页的大小是4K用12位可以定位这个页内的任何一个位置。
这样加起来正好32位也就是用前10位定位到页目录表中的一项。将这一项对应的页表取出来共1k项再用中间10位定位到页表中的一项将这一项对应的存放数据的页取出来再用最后12位定位到页中的具体位置访问数据。
<img src="https://static001.geekbang.org/resource/image/b6/b8/b6960eb0a7eea008d33f8e0c4facc8b8.jpg" alt="">
你可能会问如果这样的话映射4GB地址空间就需要4MB+4KB的内存这样不是更大了吗 当然如果页是满的,当时是更大了,但是,我们往往不会为一个进程分配那么多内存。
比如说上面图中我们假设只给这个进程分配了一个数据页。如果只使用页表也需要完整的1M个页表项共4M的内存但是如果使用了页目录页目录需要1K个全部分配占用内存4K但是里面只有一项使用了。到了页表项只需要分配能够管理那个数据页的页表项页就可以了也就是说最多4K这样内存就节省多了。
当然对于64位的系统两级肯定不够了就变成了四级目录分别是全局页目录项PGDPage Global Directory、上层页目录项PUDPage Upper Directory、中间页目录项PMDPage Middle Directory和页表项PTEPage Table Entry
<img src="https://static001.geekbang.org/resource/image/42/0b/42eff3e7574ac8ce2501210e25cd2c0b.jpg" alt="">
## 总结时刻
这一节我们讲了分段机制、分页机制以及从虚拟地址到物理地址的映射方式。总结一下这两节,我们可以把内存管理系统精细化为下面三件事情:
<li>
第一,虚拟内存空间的管理,将虚拟内存分成大小相等的页;
</li>
<li>
第二,物理内存的管理,将物理内存分成大小相等的页;
</li>
<li>
第三,内存映射,将虚拟内存页和物理内存页映射起来,并且在内存紧张的时候可以换出到硬盘中。
</li>
<img src="https://static001.geekbang.org/resource/image/7d/91/7dd9039e4ad2f6433aa09c14ede92991.jpg" alt="">
## 课堂练习
这一节我们说一个页的大小为4K有时候我们需要为应用配置大页HugePage。请你查一下大页的大小及配置方法咱们后面会用到。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,445 @@
<audio id="audio" title="22 | 进程空间管理:项目组还可以自行布置会议室" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/2a/761be85e2464e0a69089889d24b39b2a.mp3"></audio>
上两节,我们讲了内存管理的三个方面,虚拟内存空间的管理、物理内存的管理以及内存映射。你现在对进程内存空间的整体布局应该有了一个大致的了解。今天我们就来详细看看第一个方面,进程的虚拟内存空间是如何管理的。
32位系统和64位系统的内存布局有的地方相似有的地方差别比较大接下来介绍的时候请你注意区分。好我们现在正式开始
## 用户态和内核态的划分
进程的虚拟地址空间其实就是站在项目组的角度来看内存所以我们就从task_struct出发来看。这里面有一个struct mm_struct结构来管理内存。
```
struct mm_struct *mm;
```
在struct mm_struct里面有这样一个成员变量
```
unsigned long task_size; /* size of task vm space */
```
我们之前讲过整个虚拟内存空间要一分为二一部分是用户态地址空间一部分是内核态地址空间那这两部分的分界线在哪里呢这就要task_size来定义。
对于32位的系统内核里面是这样定义TASK_SIZE的
```
#ifdef CONFIG_X86_32
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE PAGE_OFFSET
#define TASK_SIZE_MAX TASK_SIZE
/*
config PAGE_OFFSET
hex
default 0xC0000000
depends on X86_32
*/
#else
/*
* User space process size. 47bits minus one guard page.
*/
#define TASK_SIZE_MAX ((1UL &lt;&lt; 47) - PAGE_SIZE)
#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
......
```
当执行一个新的进程的时候,会做以下的设置:
```
current-&gt;mm-&gt;task_size = TASK_SIZE;
```
对于32位系统最大能够寻址2^32=4G其中用户态虚拟地址空间是3G内核态是1G。
对于64位系统虚拟地址只使用了48位。就像代码里面写的一样1左移了47位就相当于48位地址空间一半的位置0x0000800000000000然后减去一个页就是0x00007FFFFFFFF000共128T。同样内核空间也是128T。内核空间和用户空间之间隔着很大的空隙以此来进行隔离。
<img src="https://static001.geekbang.org/resource/image/89/59/89723dc967b59f6f49419082f6ab7659.jpg" alt="">
## 用户态布局
我们先来看用户态虚拟空间的布局。
之前我们讲了用户态虚拟空间里面有几类数据例如代码、全局变量、堆、栈、内存映射区等。在struct mm_struct里面有下面这些变量定义了这些区域的统计信息和位置。
```
unsigned long mmap_base; /* base of mmap area */
unsigned long total_vm; /* Total pages mapped */
unsigned long locked_vm; /* Pages that have PG_mlocked set */
unsigned long pinned_vm; /* Refcount permanently increased */
unsigned long data_vm; /* VM_WRITE &amp; ~VM_SHARED &amp; ~VM_STACK */
unsigned long exec_vm; /* VM_EXEC &amp; ~VM_WRITE &amp; ~VM_STACK */
unsigned long stack_vm; /* VM_STACK */
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;
```
其中total_vm是总共映射的页的数目。我们知道这么大的虚拟地址空间不可能都有真实内存对应所以这里是映射的数目。当内存吃紧的时候有些页可以换出到硬盘上有的页因为比较重要不能换出。locked_vm就是被锁定不能换出pinned_vm是不能换出也不能移动。
data_vm是存放数据的页的数目exec_vm是存放可执行文件的页的数目stack_vm是栈所占的页的数目。
start_code和end_code表示可执行代码的开始和结束位置start_data和end_data表示已初始化数据的开始位置和结束位置。
start_brk是堆的起始位置brk是堆当前的结束位置。前面咱们讲过malloc申请一小块内存的话就是通过改变brk位置实现的。
start_stack是栈的起始位置栈的结束位置在寄存器的栈顶指针中。
arg_start和arg_end是参数列表的位置 env_start和env_end是环境变量的位置。它们都位于栈中最高地址的地方。
mmap_base表示虚拟地址空间中用于内存映射的起始地址。一般情况下这个空间是从高地址到低地址增长的。前面咱们讲malloc申请一大块内存的时候就是通过mmap在这里映射一块区域到物理内存。咱们加载动态链接库so文件也是在这个区域里面映射一块区域到so文件。
这下所有用户态的区域的位置基本上都描述清楚了。整个布局就像下面这张图这样。虽然32位和64位的空间相差很大但是区域的类别和布局是相似的。
<img src="https://static001.geekbang.org/resource/image/f8/b1/f83b8d49b4e74c0e255b5735044c1eb1.jpg" alt="">
除了位置信息之外struct mm_struct里面还专门有一个结构vm_area_struct来描述这些区域的属性。
```
struct vm_area_struct *mmap; /* list of VMAs */
struct rb_root mm_rb;
```
这里面一个是单链表,用于将这些区域串起来。另外还有一个红黑树。又是这个数据结构,在进程调度的时候我们用的也是红黑树。它的好处就是查找和修改都很快。这里用红黑树,就是为了快速查找一个内存区域,并在需要改变的时候,能够快速修改。
```
struct vm_area_struct {
/* The first cache line has the info for VMA tree walking. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next, *vm_prev;
struct rb_node vm_rb;
struct mm_struct *vm_mm; /* The address space we belong to. */
struct list_head anon_vma_chain; /* Serialized by mmap_sem &amp;
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
} __randomize_layout;
```
vm_start和vm_end指定了该区域在用户空间中的起始和结束地址。vm_next和vm_prev将这个区域串在链表上。vm_rb将这个区域放在红黑树上。vm_ops里面是对这个内存区域可以做的操作的定义。
虚拟内存区域可以映射到物理内存也可以映射到文件映射到物理内存的时候称为匿名映射anon_vma中anoy就是anonymous匿名的意思映射到文件就需要有vm_file指定被映射的文件。
那这些vm_area_struct是如何和上面的内存区域关联的呢
这个事情是在load_elf_binary里面实现的。没错就是它。加载内核的是它启动第一个用户态进程init的是它fork完了以后调用exec运行一个二进制程序的也是它。
当exec运行一个二进制程序的时候除了解析ELF的格式之外另外一个重要的事情就是建立内存映射。
```
static int load_elf_binary(struct linux_binprm *bprm)
{
......
setup_new_exec(bprm);
......
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
......
error = elf_map(bprm-&gt;file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
......
retval = set_brk(elf_bss, elf_brk, bss_prot);
......
elf_entry = load_elf_interp(&amp;loc-&gt;interp_elf_ex,
interpreter,
&amp;interp_map_addr,
load_bias, interp_elf_phdata);
......
current-&gt;mm-&gt;end_code = end_code;
current-&gt;mm-&gt;start_code = start_code;
current-&gt;mm-&gt;start_data = start_data;
current-&gt;mm-&gt;end_data = end_data;
current-&gt;mm-&gt;start_stack = bprm-&gt;p;
......
}
```
load_elf_binary会完成以下的事情
<li>
调用setup_new_exec设置内存映射区mmap_base
</li>
<li>
调用setup_arg_pages设置栈的vm_area_struct这里面设置了mm-&gt;arg_start是指向栈底的current-&gt;mm-&gt;start_stack就是栈底
</li>
<li>
elf_map会将ELF文件中的代码部分映射到内存中来
</li>
<li>
set_brk设置了堆的vm_area_struct这里面设置了current-&gt;mm-&gt;start_brk = current-&gt;mm-&gt;brk也即堆里面还是空的
</li>
<li>
load_elf_interp将依赖的so映射到内存中的内存映射区域。
</li>
最终就形成下面这个内存映射图。
<img src="https://static001.geekbang.org/resource/image/7a/4c/7af58012466c7d006511a7e16143314c.jpeg" alt="">
映射完毕后,什么情况下会修改呢?
第一种情况是函数的调用,涉及函数栈的改变,主要是改变栈顶指针。
第二种情况是通过malloc申请一个堆内的空间当然底层要么执行brk要么执行mmap。关于内存映射的部分我们后面的章节讲这里我们重点看一下brk是怎么做的。
brk系统调用实现的入口是sys_brk函数就像下面代码定义的一样。
```
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
unsigned long retval;
unsigned long newbrk, oldbrk;
struct mm_struct *mm = current-&gt;mm;
struct vm_area_struct *next;
......
newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm-&gt;brk);
if (oldbrk == newbrk)
goto set_brk;
/* Always allow shrinking brk. */
if (brk &lt;= mm-&gt;brk) {
if (!do_munmap(mm, newbrk, oldbrk-newbrk, &amp;uf))
goto set_brk;
goto out;
}
/* Check against existing mmap mappings. */
next = find_vma(mm, oldbrk);
if (next &amp;&amp; newbrk + PAGE_SIZE &gt; vm_start_gap(next))
goto out;
/* Ok, looks good - let it rip. */
if (do_brk(oldbrk, newbrk-oldbrk, &amp;uf) &lt; 0)
goto out;
set_brk:
mm-&gt;brk = brk;
......
return brk;
out:
retval = mm-&gt;brk;
return retval
```
前面我们讲过了堆是从低地址向高地址增长的sys_brk函数的参数brk是新的堆顶位置而当前的mm-&gt;brk是原来堆顶的位置。
首先要做的第一个事情将原来的堆顶和现在的堆顶都按照页对齐地址然后比较大小。如果两者相同说明这次增加的堆的量很小还在一个页里面不需要另行分配页直接跳到set_brk那里设置mm-&gt;brk为新的brk就可以了。
如果发现新旧堆顶不在一个页里面麻烦了这下要跨页了。如果发现新堆顶小于旧堆顶这说明不是新分配内存了而是释放内存了释放的还不小至少释放了一页于是调用do_munmap将这一页的内存映射去掉。
如果堆将要扩大就要调用find_vma。如果打开这个函数看到的是对红黑树的查找找到的是原堆顶所在的vm_area_struct的下一个vm_area_struct看当前的堆顶和下一个vm_area_struct之间还能不能分配一个完整的页。如果不能没办法只好直接退出返回内存空间都被占满了。
如果还有空间就调用do_brk进一步分配堆空间从旧堆顶开始分配计算出的新旧堆顶之间的页数。
```
static int do_brk(unsigned long addr, unsigned long len, struct list_head *uf)
{
return do_brk_flags(addr, len, 0, uf);
}
static int do_brk_flags(unsigned long addr, unsigned long request, unsigned long flags, struct list_head *uf)
{
struct mm_struct *mm = current-&gt;mm;
struct vm_area_struct *vma, *prev;
unsigned long len;
struct rb_node **rb_link, *rb_parent;
pgoff_t pgoff = addr &gt;&gt; PAGE_SHIFT;
int error;
len = PAGE_ALIGN(request);
......
find_vma_links(mm, addr, addr + len, &amp;prev, &amp;rb_link,
&amp;rb_parent);
......
vma = vma_merge(mm, prev, addr, addr + len, flags,
NULL, NULL, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma)
goto out;
......
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
INIT_LIST_HEAD(&amp;vma-&gt;anon_vma_chain);
vma-&gt;vm_mm = mm;
vma-&gt;vm_start = addr;
vma-&gt;vm_end = addr + len;
vma-&gt;vm_pgoff = pgoff;
vma-&gt;vm_flags = flags;
vma-&gt;vm_page_prot = vm_get_page_prot(flags);
vma_link(mm, vma, prev, rb_link, rb_parent);
out:
perf_event_mmap(vma);
mm-&gt;total_vm += len &gt;&gt; PAGE_SHIFT;
mm-&gt;data_vm += len &gt;&gt; PAGE_SHIFT;
if (flags &amp; VM_LOCKED)
mm-&gt;locked_vm += (len &gt;&gt; PAGE_SHIFT);
vma-&gt;vm_flags |= VM_SOFTDIRTY;
return 0;
```
在do_brk中调用find_vma_links找到将来的vm_area_struct节点在红黑树的位置找到它的父节点、前序节点。接下来调用vma_merge看这个新节点是否能够和现有树中的节点合并。如果地址是连着的能够合并则不用创建新的vm_area_struct了直接跳到out更新统计值即可如果不能合并则创建新的vm_area_struct既加到anon_vma_chain链表中也加到红黑树中。
## 内核态的布局
用户态虚拟空间分析完毕,接下来我们分析内核态虚拟空间。
内核态的虚拟空间和某一个进程没有关系,所有进程通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。
这里强调一下,千万别以为到了内核里面,咱们就会直接使用物理内存地址了,想当然地认为下面讨论的都是物理内存地址,不是的,这里讨论的还是虚拟内存地址,但是由于内核总是涉及管理物理内存,因而总是隐隐约约发生关系,所以这里必须思路清晰,分清楚物理内存地址和虚拟内存地址。
在内核态32位和64位的布局差别比较大主要是因为32位内核态空间太小了。
我们来看32位的内核态的布局。
<img src="https://static001.geekbang.org/resource/image/83/04/83a6511faf802014fbc2c02afc397a04.jpg" alt="">
32位的内核态虚拟地址空间一共就1G占绝大部分的前896M我们称为**直接映射区**。
所谓的直接映射区就是这一块空间是连续的和物理内存是非常简单的映射关系其实就是虚拟内存地址减去3G就得到物理内存的位置。
在内核里面,有两个宏:
<li>
__pa(vaddr) 返回与虚拟地址 vaddr 相关的物理地址;
</li>
<li>
__va(paddr) 则计算出对应于物理地址 paddr 的虚拟地址。
</li>
```
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
#define __pa(x) __phys_addr((unsigned long)(x))
#define __phys_addr(x) __phys_addr_nodebug(x)
#define __phys_addr_nodebug(x) ((x) - PAGE_OFFSET)
```
但是你要注意这里虚拟地址和物理地址发生了关联关系在物理内存的开始的896M的空间会被直接映射到3G至3G+896M的虚拟地址这样容易给你一种感觉这些内存访问起来和物理内存差不多别这样想在大部分情况下对于这一段内存的访问在内核中还是会使用虚拟地址的并且将来也会为这一段空间建设页表对这段地址的访问也会走上一节我们讲的分页地址的流程只不过页表里面比较简单是直接的一一对应而已。
这896M还需要仔细分解。在系统启动的时候物理内存的前1M已经被占用了从1M开始加载内核代码段然后就是内核的全局变量、BSS等也是ELF里面涵盖的。这样内核的代码段全局变量BSS也就会被映射到3G后的虚拟地址空间里面。具体的物理内存布局可以查看/proc/iomem。
在内核运行的过程中如果碰到系统调用创建进程会创建task_struct这样的实例内核的进程管理代码会将实例创建在3G至3G+896M的虚拟空间中当然也会被放在物理内存里面的前896M里面相应的页表也会被创建。
在内核运行的过程中会涉及内核栈的分配内核的进程管理的代码会将内核栈创建在3G至3G+896M的虚拟空间中当然也就会被放在物理内存里面的前896M里面相应的页表也会被创建。
896M这个值在内核中被定义为high_memory在此之上常称为“高端内存”。这是个很笼统的说法到底是虚拟内存的3G+896M以上的是高端内存还是物理内存896M以上的是高端内存呢
这里仍然需要辨析一下,高端内存是物理内存的概念。它仅仅是内核中的内存管理模块看待物理内存的时候的概念。前面我们也说过,在内核中,除了内存管理模块直接操作物理地址之外,内核的其他模块,仍然要操作虚拟地址,而虚拟地址是需要内存管理模块分配和映射好的。
假设咱们的电脑有2G内存现在如果内核的其他模块想要访问物理内存1.5G的地方应该怎么办呢如果你觉得我有32位的总线访问个2G还不小菜一碟这就错了。
首先你不能使用物理地址。你需要使用内存管理模块给你分配的虚拟地址但是虚拟地址的0到3G已经被用户态进程占用去了你作为内核不能使用。因为你写1.5G的虚拟内存位置一方面你不知道应该根据哪个进程的页表进行映射另一方面就算映射了也不是你真正想访问的物理内存的地方所以你发现你作为内核能够使用的虚拟内存地址只剩下1G减去896M的空间了。
于是,我们可以将剩下的虚拟内存地址分成下面这几个部分。
<li>
在896M到VMALLOC_START之间有8M的空间。
</li>
<li>
VMALLOC_START到VMALLOC_END之间称为内核动态映射空间也即内核想像用户态进程一样malloc申请内存在内核里面可以使用vmalloc。假设物理内存里面896M到1.5G之间已经被用户态进程占用了并且映射关系放在了进程的页表中内核vmalloc的时候只能从分配物理内存1.5G开始,就需要使用这一段的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。
</li>
<li>
PKMAP_BASE到FIXADDR_START的空间称为持久内核映射。使用alloc_pages()函数的时候在物理内存的高端内存得到struct page结构可以调用kmap将其映射到这个区域。
</li>
<li>
FIXADDR_START到FIXADDR_TOP(0xFFFF F000)的空间,称为固定映射区域,主要用于满足特殊需求。
</li>
<li>
在最后一个区域可以通过kmap_atomic实现临时内核映射。假设用户态的进程要映射一个文件到内存中先要映射用户态进程空间的一段虚拟地址到物理内存然后将文件内容写入这个物理内存供用户态进程访问。给用户态进程分配物理内存页可以通过alloc_pages()分配完毕后按说将用户态进程虚拟地址和物理内存的映射关系放在用户态进程的页表中就完事大吉了。这个时候用户态进程可以通过用户态的虚拟地址也即0至3G的部分经过页表映射后访问物理内存并不需要内核态的虚拟地址里面也划出一块来映射到这个物理内存页。但是如果要把文件内容写入物理内存这件事情要内核来干了这就只好通过kmap_atomic做一个临时映射写入物理内存完毕后再kunmap_atomic来解映射即可。
</li>
32位的内核态布局我们看完了接下来我们再来看64位的内核布局。
其实64位的内核布局反而简单因为虚拟空间实在是太大了根本不需要所谓的高端内存因为内核是128T根本不可能有物理内存超过这个值。
64位的内存布局如图所示。
<img src="https://static001.geekbang.org/resource/image/7e/f6/7eaf620768c62ff53e5ea2b11b4940f6.jpg" alt="">
64位的内核主要包含以下几个部分。
从0xffff800000000000开始就是内核的部分只不过一开始有8T的空档区域。
从__PAGE_OFFSET_BASE(0xffff880000000000)开始的64T的虚拟地址空间是直接映射区域也就是减去PAGE_OFFSET就是物理地址。虚拟地址和物理地址之间的映射在大部分情况下还是会通过建立页表的方式进行映射。
从VMALLOC_START0xffffc90000000000开始到VMALLOC_END0xffffe90000000000的32T的空间是给vmalloc的。
从VMEMMAP_START0xffffea0000000000开始的1T空间用于存放物理页面的描述结构struct page的。
从__START_KERNEL_map0xffffffff80000000开始的512M用于存放内核代码段、全局变量、BSS等。这里对应到物理内存开始的位置减去__START_KERNEL_map就能得到物理内存的地址。这里和直接映射区有点像但是不矛盾因为直接映射区之前有8T的空当区域早就过了内核代码在物理内存中加载的位置。
到这里内核中虚拟空间的布局就介绍完了。
## 总结时刻
还记得咱们上一节咱们收集项目组需求的时候,我们知道一个进程要运行起来需要以下的内存结构。
用户态:
<li>
代码段、全局变量、BSS
</li>
<li>
函数栈
</li>
<li>
</li>
<li>
内存映射区
</li>
内核态:
<li>
内核的代码、全局变量、BSS
</li>
<li>
内核数据结构例如task_struct
</li>
<li>
内核栈
</li>
<li>
内核中动态分配的内存
</li>
现在这些是不是已经都有了着落?
我画了一个图总结一下进程运行状态在32位下对应关系。
<img src="https://static001.geekbang.org/resource/image/28/e8/2861968d1907bc314b82c34c221aace8.jpeg" alt="">
对于64位的对应关系只是稍有区别我这里也画了一个图方便你对比理解。
<img src="https://static001.geekbang.org/resource/image/2a/ce/2ad275ff8fdf6aafced4a7aeea4ca0ce.jpeg" alt="">
## 课堂练习
请通过命令行工具查看进程虚拟内存的布局和物理内存的布局,对照着这一节讲的内容,看一下各部分的位置。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,440 @@
<audio id="audio" title="23 | 物理内存管理(上):会议室管理员如何分配会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/63/d1e56b8a6774d6e32a0425bbf1eada63.mp3"></audio>
前一节,我们讲了如何从项目经理的角度看内存,看到的是虚拟地址空间,这些虚拟的地址,总是要映射到物理的页面。这一节,我们来看,物理的页面是如何管理的。
## 物理内存的组织方式
前面咱们讲虚拟内存涉及物理内存的映射的时候我们总是把内存想象成它是由连续的一页一页的块组成的。我们可以从0开始对物理页编号这样每个物理页都会有个页号。
由于物理地址是连续的页也是连续的每个页大小也是一样的。因而对于任何一个地址只要直接除一下每页的大小很容易直接算出在哪一页。每个页有一个结构struct page表示这个结构也是放在一个数组里面这样根据页号很容易通过下标找到相应的struct page结构。
如果是这样,整个物理内存的布局就非常简单、易管理,这就是最经典的**平坦内存模型**Flat Memory Model
我们讲x86的工作模式的时候讲过CPU是通过总线去访问内存的这就是最经典的内存使用方式。
<img src="https://static001.geekbang.org/resource/image/fa/9b/fa6c2b6166d02ac37637d7da4e4b579b.jpeg" alt="">
在这种模式下CPU也会有多个在总线的一侧。所有的内存条组成一大片内存在总线的另一侧所有的CPU访问内存都要过总线而且距离都是一样的这种模式称为**SMP**Symmetric multiprocessing即对称多处理器。当然它也有一个显著的缺点就是总线会成为瓶颈因为数据都要走它。
<img src="https://static001.geekbang.org/resource/image/8f/49/8f158f58dda94ec04b26200073e15449.jpeg" alt="">
为了提高性能和可扩展性,后来有了一种更高级的模式,**NUMA**Non-uniform memory access非一致内存访问。在这种模式下内存不是一整块。每个CPU都有自己的本地内存CPU访问本地内存不用过总线因而速度要快很多每个CPU和内存在一起称为一个NUMA节点。但是在本地内存不足的情况下每个CPU都可以去另外的NUMA节点申请内存这个时候访问延时就会比较长。
这样,内存被分成了多个节点,每个节点再被分成一个一个的页面。由于页需要全局唯一定位,页还是需要有全局唯一的页号的。但是由于物理内存不是连起来的了,页号也就不再连续了。于是内存模型就变成了非连续内存模型,管理起来就复杂一些。
这里需要指出的是NUMA往往是非连续内存模型。而非连续内存模型不一定就是NUMA有时候一大片内存的情况下也会有物理内存地址不连续的情况。
后来内存技术牛了,可以支持热插拔了。这个时候,不连续成为常态,于是就有了稀疏内存模型。
### 节点
我们主要解析当前的主流场景NUMA方式。我们首先要能够表示NUMA节点的概念于是有了下面这个结构typedef struct pglist_data pg_data_t它里面有以下的成员变量
<li>
每一个节点都有自己的IDnode_id
</li>
<li>
node_mem_map就是这个节点的struct page数组用于描述这个节点里面的所有的页
</li>
<li>
node_start_pfn是这个节点的起始页号
</li>
<li>
node_spanned_pages是这个节点中包含不连续的物理内存地址的页面数
</li>
<li>
node_present_pages是真正可用的物理页面的数目。
</li>
例如64M物理内存隔着一个4M的空洞然后是另外的64M物理内存。这样换算成页面数目就是16K个页面隔着1K个页面然后是另外16K个页面。这种情况下node_spanned_pages就是33K个页面node_present_pages就是32K个页面。
```
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES];
struct zonelist node_zonelists[MAX_ZONELISTS];
int nr_zones;
struct page *node_mem_map;
unsigned long node_start_pfn;
unsigned long node_present_pages; /* total number of physical pages */
unsigned long node_spanned_pages; /* total size of physical page range, including holes */
int node_id;
......
} pg_data_t;
```
每一个节点分成一个个区域zone放在数组node_zones里面。这个数组的大小为MAX_NR_ZONES。我们来看区域的定义。
```
enum zone_type {
#ifdef CONFIG_ZONE_DMA
ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
ZONE_DMA32,
#endif
ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
ZONE_HIGHMEM,
#endif
ZONE_MOVABLE,
__MAX_NR_ZONES
};
```
ZONE_DMA是指可用于作DMADirect Memory Access直接内存存取的内存。DMA是这样一种机制要把外设的数据读入内存或把内存的数据传送到外设原来都要通过CPU控制完成但是这会占用CPU影响CPU处理其他事情所以有了DMA模式。CPU只需向DMA控制器下达指令让DMA控制器来处理数据的传送数据传送完毕再把信息反馈给CPU这样就可以解放CPU。
对于64位系统有两个DMA区域。除了上面说的ZONE_DMA还有ZONE_DMA32。在这里你大概理解DMA的原理就可以不必纠结我们后面会讲DMA的机制。
ZONE_NORMAL是直接映射区就是上一节讲的从物理内存到虚拟内存的内核区域通过加上一个常量直接映射。
ZONE_HIGHMEM是高端内存区就是上一节讲的对于32位系统来说超过896M的地方对于64位没必要有的一段区域。
ZONE_MOVABLE是可移动区域通过将物理内存划分为可移动分配区域和不可移动分配区域来避免内存碎片。
这里你需要注意一下,我们刚才对于区域的划分,都是针对物理内存的。
nr_zones表示当前节点的区域的数量。node_zonelists是备用节点和它的内存区域的情况。前面讲NUMA的时候我们讲了CPU访问内存本节点速度最快但是如果本节点内存不够怎么办还是需要去其他节点进行分配。毕竟就算在备用节点里面选择慢了点也比没有强。
既然整个内存被分成了多个节点那pglist_data应该放在一个数组里面。每个节点一项就像下面代码里面一样
```
struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;
```
### 区域
到这里,我们把内存分成了节点,把节点分成了区域。接下来我们来看,一个区域里面是如何组织的。
表示区域的数据结构zone的定义如下
```
struct zone {
......
struct pglist_data *zone_pgdat;
struct per_cpu_pageset __percpu *pageset;
unsigned long zone_start_pfn;
/*
* spanned_pages is the total pages spanned by the zone, including
* holes, which is calculated as:
* spanned_pages = zone_end_pfn - zone_start_pfn;
*
* present_pages is physical pages existing within the zone, which
* is calculated as:
* present_pages = spanned_pages - absent_pages(pages in holes);
*
* managed_pages is present pages managed by the buddy system, which
* is calculated as (reserved_pages includes pages allocated by the
* bootmem allocator):
* managed_pages = present_pages - reserved_pages;
*
*/
unsigned long managed_pages;
unsigned long spanned_pages;
unsigned long present_pages;
const char *name;
......
/* free areas of different sizes */
struct free_area free_area[MAX_ORDER];
/* zone flags, see below */
unsigned long flags;
/* Primarily protects free_area */
spinlock_t lock;
......
} ____cacheline_internodealigned_in_
```
在一个zone里面zone_start_pfn表示属于这个zone的第一个页。
如果我们仔细看代码的注释可以看到spanned_pages = zone_end_pfn - zone_start_pfn也即spanned_pages指的是不管中间有没有物理内存空洞反正就是最后的页号减去起始的页号。
present_pages = spanned_pages - absent_pages(pages in holes)也即present_pages是这个zone在物理内存中真实存在的所有page数目。
managed_pages = present_pages - reserved_pages也即managed_pages是这个zone被伙伴系统管理的所有的page数目伙伴系统的工作机制我们后面会讲。
per_cpu_pageset用于区分冷热页。什么叫冷热页呢咱们讲x86体系结构的时候讲过为了让CPU快速访问段描述符在CPU里面有段描述符缓存。CPU访问这个缓存的速度比内存快得多。同样对于页面来讲也是这样的。如果一个页被加载到CPU高速缓存里面这就是一个热页Hot PageCPU读起来速度会快很多如果没有就是冷页Cold Page。由于每个CPU都有自己的高速缓存因而per_cpu_pageset也是每个CPU一个。
### 页
了解了区域zone接下来我们就到了组成物理内存的基本单位页的数据结构struct page。这是一个特别复杂的结构里面有很多的unionunion结构是在C语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了union是因为一个物理页面使用模式有多种。
第一种模式要用就用一整页。这一整页的内存或者直接和虚拟地址空间建立映射关系我们把这种称为匿名页Anonymous Page。或者用于关联一个文件然后再和虚拟地址空间建立映射关系这样的文件我们称为内存映射文件Memory-mapped File
如果某一页是这种使用模式则会使用union中的以下变量
<li>
struct address_space *mapping就是用于内存映射如果是匿名页最低位为1如果是映射文件最低位为0
</li>
<li>
pgoff_t index是在映射区的偏移量
</li>
<li>
atomic_t _mapcount每个进程都有自己的页表这里指有多少个页表项指向了这个页
</li>
<li>
struct list_head lru表示这一页应该在一个链表上例如这个页面被换出就在换出页的链表中
</li>
<li>
compound相关的变量用于复合页Compound Page就是将物理上连续的两个或多个页看成一个独立的大页。
</li>
第二种模式仅需分配小块内存。有时候我们不需要一下子分配这么多的内存例如分配一个task_struct结构只需要分配小块的内存去存储这个进程描述结构的对象。为了满足对这种小内存块的需要Linux系统采用了一种被称为**slab allocator**的技术用于分配称为slab的一小块内存。它的基本原理是从内存管理模块申请一整块页然后划分成多个小块的存储池用复杂的队列来维护这些小块的状态状态包括被分配了/被放回池子/应该被回收)。
也正是因为slab allocator对于队列的维护过于复杂后来就有了一种不使用队列的分配器slub allocator后面我们会解析这个分配器。但是你会发现它里面还是用了很多slab的字眼因为它保留了slab的用户接口可以看成slab allocator的另一种实现。
还有一种小块内存的分配器称为**slob**,非常简单,主要使用在小型的嵌入式系统。
如果某一页是用于分割成一小块一小块的内存进行分配的使用模式则会使用union中的以下变量
<li>
s_mem是已经分配了正在使用的slab的第一个对象
</li>
<li>
freelist是池子中的空闲对象
</li>
<li>
rcu_head是需要释放的列表。
</li>
```
struct page {
unsigned long flags;
union {
struct address_space *mapping;
void *s_mem; /* slab first object */
atomic_t compound_mapcount; /* first tail page */
};
union {
pgoff_t index; /* Our offset within mapping. */
void *freelist; /* sl[aou]b first free object */
};
union {
unsigned counters;
struct {
union {
atomic_t _mapcount;
unsigned int active; /* SLAB */
struct { /* SLUB */
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
int units; /* SLOB */
};
atomic_t _refcount;
};
};
union {
struct list_head lru; /* Pageout list */
struct dev_pagemap *pgmap;
struct { /* slub per cpu partial pages */
struct page *next; /* Next partial slab */
int pages; /* Nr of partial slabs left */
int pobjects; /* Approximate # of objects */
};
struct rcu_head rcu_head;
struct {
unsigned long compound_head; /* If bit zero is set */
unsigned int compound_dtor;
unsigned int compound_order;
};
};
union {
unsigned long private;
struct kmem_cache *slab_cache; /* SL[AU]B: Pointer to slab */
};
......
}
```
## 页的分配
好了,前面我们讲了物理内存的组织,从节点到区域到页到小块。接下来,我们来看物理内存的分配。
对于要分配比较大的内存,例如到分配页级别的,可以使用**伙伴系统**Buddy System
Linux中的内存管理的“页”大小为4KB。把所有的空闲页分组为11个页块链表每个块链表分别包含很多个大小的页块有1、2、4、8、16、32、64、128、256、512和1024个连续页的页块。最大可以申请1024个连续页对应4MB大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。
<img src="https://static001.geekbang.org/resource/image/27/cf/2738c0c98d2ed31cbbe1fdcba01142cf.jpeg" alt="">
第i个页块链表中页块中页的数目为2^i。
在struct zone里面有以下的定义
```
struct free_area free_area[MAX_ORDER];
```
MAX_ORDER就是指数。
```
#define MAX_ORDER 11
```
当向内核请求分配(2^(i-1)2^i]数目的页块时按照2^i页块请求处理。如果对应的页块链表中没有空闲页块那我们就在更大的页块链表中去找。当分配的页块中有多余的页时伙伴系统会根据多余的页块大小插入到对应的空闲页块链表中。
例如要请求一个128个页的页块时先检查128个页的页块链表是否有空闲块。如果没有则查256个页的页块链表如果有空闲块的话则将256个页的页块分成两份一份使用一份插入128个页的页块链表中。如果还是没有就查512个页的页块链表如果有的话就分裂为128、128、256三个页块一个128的使用剩余两个插入对应页块链表。
上面这个过程我们可以在分配页的函数alloc_pages中看到。
```
static inline struct page *
alloc_pages(gfp_t gfp_mask, unsigned int order)
{
return alloc_pages_current(gfp_mask, order);
}
/**
* alloc_pages_current - Allocate pages.
*
* @gfp:
* %GFP_USER user allocation,
* %GFP_KERNEL kernel allocation,
* %GFP_HIGHMEM highmem allocation,
* %GFP_FS don't call back into a file system.
* %GFP_ATOMIC don't sleep.
* @order: Power of two of allocation size in pages. 0 is a single page.
*
* Allocate a page from the kernel page pool. When not in
* interrupt context and apply the current process NUMA policy.
* Returns NULL when no page can be allocated.
*/
struct page *alloc_pages_current(gfp_t gfp, unsigned order)
{
struct mempolicy *pol = &amp;default_policy;
struct page *page;
......
page = __alloc_pages_nodemask(gfp, order,
policy_node(gfp, pol, numa_node_id()),
policy_nodemask(gfp, pol));
......
return page;
}
```
alloc_pages会调用alloc_pages_current这里面的注释比较容易看懂了gfp表示希望在哪个区域中分配这个内存
<li>
GFP_USER用于分配一个页映射到用户进程的虚拟地址空间并且希望直接被内核或者硬件访问主要用于一个用户进程希望通过内存映射的方式访问某些硬件的缓存例如显卡缓存
</li>
<li>
GFP_KERNEL用于内核中分配页主要分配ZONE_NORMAL区域也即直接映射区
</li>
<li>
GFP_HIGHMEM顾名思义就是主要分配高端区域的内存。
</li>
另一个参数order就是表示分配2的order次方个页。
接下来调用__alloc_pages_nodemask。这是伙伴系统的核心方法。它会调用get_page_from_freelist。这里面的逻辑也很容易理解就是在一个循环中先看当前节点的zone。如果找不到空闲页则再看备用节点的zone。
```
static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
const struct alloc_context *ac)
{
......
for_next_zone_zonelist_nodemask(zone, z, ac-&gt;zonelist, ac-&gt;high_zoneidx, ac-&gt;nodemask) {
struct page *page;
......
page = rmqueue(ac-&gt;preferred_zoneref-&gt;zone, zone, order,
gfp_mask, alloc_flags, ac-&gt;migratetype);
......
}
```
每一个zone都有伙伴系统维护的各种大小的队列就像上面伙伴系统原理里讲的那样。这里调用rmqueue就很好理解了就是找到合适大小的那个队列把页面取下来。
接下来的调用链是rmqueue-&gt;__rmqueue-&gt;__rmqueue_smallest。在这里我们能清楚看到伙伴系统的逻辑。
```
static inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
int migratetype)
{
unsigned int current_order;
struct free_area *area;
struct page *page;
/* Find a page of the appropriate size in the preferred list */
for (current_order = order; current_order &lt; MAX_ORDER; ++current_order) {
area = &amp;(zone-&gt;free_area[current_order]);
page = list_first_entry_or_null(&amp;area-&gt;free_list[migratetype],
struct page, lru);
if (!page)
continue;
list_del(&amp;page-&gt;lru);
rmv_page_order(page);
area-&gt;nr_free--;
expand(zone, page, order, current_order, area, migratetype);
set_pcppage_migratetype(page, migratetype);
return page;
}
return NULL;
```
从当前的order也即指数开始在伙伴系统的free_area找2^order大小的页块。如果链表的第一个不为空就找到了如果为空就到更大的order的页块链表里面去找。找到以后除了将页块从链表中取下来我们还要把多余部分放到其他页块链表里面。expand就是干这个事情的。area就是伙伴系统那个表里面的前一项前一项里面的页块大小是当前项的页块大小除以2size右移一位也就是除以2list_add就是加到链表上nr_free++就是计数加1。
```
static inline void expand(struct zone *zone, struct page *page,
int low, int high, struct free_area *area,
int migratetype)
{
unsigned long size = 1 &lt;&lt; high;
while (high &gt; low) {
area--;
high--;
size &gt;&gt;= 1;
......
list_add(&amp;page[size].lru, &amp;area-&gt;free_list[migratetype]);
area-&gt;nr_free++;
set_page_order(&amp;page[size], high);
}
}
```
## 总结时刻
对于物理内存的管理的讲解,到这里要告一段落了。这一节我们主要讲了物理内存的组织形式,就像下面图中展示的一样。
如果有多个CPU那就有多个节点。每个节点用struct pglist_data表示放在一个数组里面。
每个节点分为多个区域每个区域用struct zone表示也放在一个数组里面。
每个区域分为多个页。为了方便分配空闲页放在struct free_area里面使用伙伴系统进行管理和分配每一页用struct page表示。
<img src="https://static001.geekbang.org/resource/image/3f/4f/3fa8123990e5ae2c86859f70a8351f4f.jpeg" alt="">
## 课堂练习
伙伴系统是一种非常精妙的实现方式,无论你使用什么语言,请自己实现一个这样的分配系统,说不定哪天你在做某个系统的时候,就用到了。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,490 @@
<audio id="audio" title="24 | 物理内存管理(下):会议室管理员如何分配会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/e9/3042e6821d9fb594b50a7454c3ffafe9.mp3"></audio>
前一节,前面我们解析了整页的分配机制。如果遇到小的对象,物理内存是如何分配的呢?这一节,我们一起来看一看。
## 小内存的分配
前面我们讲过如果遇到小的对象会使用slub分配器进行分配。那我们就先来解析它的工作原理。
还记得咱们创建进程的时候会调用dup_task_struct它想要试图复制一个task_struct对象需要先调用alloc_task_struct_node分配一个task_struct对象。
从这段代码可以看出它调用了kmem_cache_alloc_node函数在task_struct的缓存区域task_struct_cachep分配了一块内存。
```
static struct kmem_cache *task_struct_cachep;
task_struct_cachep = kmem_cache_create(&quot;task_struct&quot;,
arch_task_struct_size, align,
SLAB_PANIC|SLAB_NOTRACK|SLAB_ACCOUNT, NULL);
static inline struct task_struct *alloc_task_struct_node(int node)
{
return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node);
}
static inline void free_task_struct(struct task_struct *tsk)
{
kmem_cache_free(task_struct_cachep, tsk);
}
```
在系统初始化的时候task_struct_cachep会被kmem_cache_create函数创建。这个函数也比较容易看懂专门用于分配task_struct对象的缓存。这个缓存区的名字就叫task_struct。缓存区中每一块的大小正好等于task_struct的大小也即arch_task_struct_size。
有了这个缓存区每次创建task_struct的时候我们不用到内存里面去分配先在缓存里面看看有没有直接可用的这就是**kmem_cache_alloc_node**的作用。
当一个进程结束task_struct也不用直接被销毁而是放回到缓存中这就是**kmem_cache_free**的作用。这样新进程创建的时候我们就可以直接用现成的缓存中的task_struct了。
我们来仔细看看缓存区struct kmem_cache到底是什么样子。
```
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab;
/* Used for retriving partial slabs etc */
unsigned long flags;
unsigned long min_partial;
int size; /* The size of an object including meta data */
int object_size; /* The size of an object without meta data */
int offset; /* Free pointer offset. */
#ifdef CONFIG_SLUB_CPU_PARTIAL
int cpu_partial; /* Number of per cpu partial objects to keep around */
#endif
struct kmem_cache_order_objects oo;
/* Allocation and freeing of slabs */
struct kmem_cache_order_objects max;
struct kmem_cache_order_objects min;
gfp_t allocflags; /* gfp flags to use on each alloc */
int refcount; /* Refcount for slab cache destroy */
void (*ctor)(void *);
......
const char *name; /* Name (only for display!) */
struct list_head list; /* List of slab caches */
......
struct kmem_cache_node *node[MAX_NUMNODES];
};
```
在struct kmem_cache里面有个变量struct list_head list这个结构我们已经看到过多次了。我们可以想象一下对于操作系统来讲要创建和管理的缓存绝对不止task_struct。难道mm_struct就不需要吗fs_struct就不需要吗都需要。因此所有的缓存最后都会放在一个链表里面也就是LIST_HEAD(slab_caches)。
对于缓存来讲,其实就是分配了连续几页的大内存块,然后根据缓存对象的大小,切成小内存块。
所以我们这里有三个kmem_cache_order_objects类型的变量。这里面的order就是2的order次方个页面的大内存块objects就是能够存放的缓存对象的数量。
最终,我们将大内存块切分成小内存块,样子就像下面这样。
<img src="https://static001.geekbang.org/resource/image/17/5e/172839800c8d51c49b67ec8c4d07315e.jpeg" alt="">
每一项的结构都是缓存对象后面跟一个下一个空闲对象的指针,这样非常方便将所有的空闲对象链成一个链。其实,这就相当于咱们数据结构里面学的,用数组实现一个可随机插入和删除的链表。
所以这里面就有三个变量size是包含这个指针的大小object_size是纯对象的大小offset就是把下一个空闲对象的指针存放在这一项里的偏移量。
那这些缓存对象哪些被分配了、哪些在空着,什么情况下整个大内存块都被分配完了,需要向伙伴系统申请几个页形成新的大内存块?这些信息该由谁来维护呢?
接下来就是最重要的两个成员变量出场的时候了。kmem_cache_cpu和kmem_cache_node它们都是每个NUMA节点上有一个我们只需要看一个节点里面的情况。
<img src="https://static001.geekbang.org/resource/image/45/0a/45f38a0c7bce8c98881bbe8b8b4c190a.jpeg" alt="">
在分配缓存块的时候,要分两种路径,**fast path**和**slow path**,也就是**快速通道**和**普通通道**。其中kmem_cache_cpu就是快速通道kmem_cache_node是普通通道。每次分配的时候要先从kmem_cache_cpu进行分配。如果kmem_cache_cpu里面没有空闲的块那就到kmem_cache_node中进行分配如果还是没有空闲的块才去伙伴系统分配新的页。
我们来看一下kmem_cache_cpu里面是如何存放缓存块的。
```
struct kmem_cache_cpu {
void **freelist; /* Pointer to next available object */
unsigned long tid; /* Globally unique transaction id */
struct page *page; /* The slab from which we are allocating */
#ifdef CONFIG_SLUB_CPU_PARTIAL
struct page *partial; /* Partially allocated frozen slabs */
#endif
......
};
```
在这里page指向大内存块的第一个页缓存块就是从里面分配的。freelist指向大内存块里面第一个空闲的项。按照上面说的这一项会有指针指向下一个空闲的项最终所有空闲的项会形成一个链表。
partial指向的也是大内存块的第一个页之所以名字叫partial部分就是因为它里面部分被分配出去了部分是空的。这是一个备用列表当page满了就会从这里找。
我们再来看kmem_cache_node的定义。
```
struct kmem_cache_node {
spinlock_t list_lock;
......
#ifdef CONFIG_SLUB
unsigned long nr_partial;
struct list_head partial;
......
#endif
};
```
这里面也有一个partial是一个链表。这个链表里存放的是部分空闲的内存块。这是kmem_cache_cpu里面的partial的备用列表如果那里没有就到这里来找。
下面我们就来看看这个分配过程。kmem_cache_alloc_node会调用slab_alloc_node。你还是先重点看这里面的注释这里面说的就是快速通道和普通通道的概念。
```
/*
* Inlined fastpath so that allocation functions (kmalloc, kmem_cache_alloc)
* have the fastpath folded into their functions. So no function call
* overhead for requests that can be satisfied on the fastpath.
*
* The fastpath works by first checking if the lockless freelist can be used.
* If not then __slab_alloc is called for slow processing.
*
* Otherwise we can simply pick the next object from the lockless free list.
*/
static __always_inline void *slab_alloc_node(struct kmem_cache *s,
gfp_t gfpflags, int node, unsigned long addr)
{
void *object;
struct kmem_cache_cpu *c;
struct page *page;
unsigned long tid;
......
tid = this_cpu_read(s-&gt;cpu_slab-&gt;tid);
c = raw_cpu_ptr(s-&gt;cpu_slab);
......
object = c-&gt;freelist;
page = c-&gt;page;
if (unlikely(!object || !node_match(page, node))) {
object = __slab_alloc(s, gfpflags, node, addr, c);
stat(s, ALLOC_SLOWPATH);
}
......
return object;
}
```
快速通道很简单取出cpu_slab也即kmem_cache_cpu的freelist这就是第一个空闲的项可以直接返回了。如果没有空闲的了则只好进入普通通道调用__slab_alloc。
```
static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,
unsigned long addr, struct kmem_cache_cpu *c)
{
void *freelist;
struct page *page;
......
redo:
......
/* must check again c-&gt;freelist in case of cpu migration or IRQ */
freelist = c-&gt;freelist;
if (freelist)
goto load_freelist;
freelist = get_freelist(s, page);
if (!freelist) {
c-&gt;page = NULL;
stat(s, DEACTIVATE_BYPASS);
goto new_slab;
}
load_freelist:
c-&gt;freelist = get_freepointer(s, freelist);
c-&gt;tid = next_tid(c-&gt;tid);
return freelist;
new_slab:
if (slub_percpu_partial(c)) {
page = c-&gt;page = slub_percpu_partial(c);
slub_set_percpu_partial(c, page);
stat(s, CPU_PARTIAL_ALLOC);
goto redo;
}
freelist = new_slab_objects(s, gfpflags, node, &amp;c);
......
return freeli
```
在这里我们首先再次尝试一下kmem_cache_cpu的freelist。为什么呢万一当前进程被中断等回来的时候别人已经释放了一些缓存说不定又有空间了呢。如果找到了就跳到load_freelist在这里将freelist指向下一个空闲项返回就可以了。
如果freelist还是没有则跳到new_slab里面去。这里面我们先去kmem_cache_cpu的partial里面看。如果partial不是空的那就将kmem_cache_cpu的page也就是快速通道的那一大块内存替换为partial里面的大块内存。然后redo重新试下。这次应该就可以成功了。
如果真的还不行那就要到new_slab_objects了。
```
static inline void *new_slab_objects(struct kmem_cache *s, gfp_t flags,
int node, struct kmem_cache_cpu **pc)
{
void *freelist;
struct kmem_cache_cpu *c = *pc;
struct page *page;
freelist = get_partial(s, flags, node, c);
if (freelist)
return freelist;
page = new_slab(s, flags, node);
if (page) {
c = raw_cpu_ptr(s-&gt;cpu_slab);
if (c-&gt;page)
flush_slab(s, c);
freelist = page-&gt;freelist;
page-&gt;freelist = NULL;
stat(s, ALLOC_SLAB);
c-&gt;page = page;
*pc = c;
} else
freelist = NULL;
return freelis
```
在这里面get_partial会根据node id找到相应的kmem_cache_node然后调用get_partial_node开始在这个节点进行分配。
```
/*
* Try to allocate a partial slab from a specific node.
*/
static void *get_partial_node(struct kmem_cache *s, struct kmem_cache_node *n,
struct kmem_cache_cpu *c, gfp_t flags)
{
struct page *page, *page2;
void *object = NULL;
int available = 0;
int objects;
......
list_for_each_entry_safe(page, page2, &amp;n-&gt;partial, lru) {
void *t;
t = acquire_slab(s, n, page, object == NULL, &amp;objects);
if (!t)
break;
available += objects;
if (!object) {
c-&gt;page = page;
stat(s, ALLOC_FROM_PARTIAL);
object = t;
} else {
put_cpu_partial(s, page, 0);
stat(s, CPU_PARTIAL_NODE);
}
if (!kmem_cache_has_cpu_partial(s)
|| available &gt; slub_cpu_partial(s) / 2)
break;
}
......
return object;
```
acquire_slab会从kmem_cache_node的partial链表中拿下一大块内存来并且将freelist也就是第一块空闲的缓存块赋值给t。并且当第一轮循环的时候将kmem_cache_cpu的page指向取下来的这一大块内存返回的object就是这块内存里面的第一个缓存块t。如果kmem_cache_cpu也有一个partial就会进行第二轮再次取下一大块内存来这次调用put_cpu_partial放到kmem_cache_cpu的partial里面。
如果kmem_cache_node里面也没有空闲的内存这就说明原来分配的页里面都放满了就要回到new_slab_objects函数里面new_slab函数会调用allocate_slab。
```
static struct page *allocate_slab(struct kmem_cache *s, gfp_t flags, int node)
{
struct page *page;
struct kmem_cache_order_objects oo = s-&gt;oo;
gfp_t alloc_gfp;
void *start, *p;
int idx, order;
bool shuffle;
flags &amp;= gfp_allowed_mask;
......
page = alloc_slab_page(s, alloc_gfp, node, oo);
if (unlikely(!page)) {
oo = s-&gt;min;
alloc_gfp = flags;
/*
* Allocation may have failed due to fragmentation.
* Try a lower order alloc if possible
*/
page = alloc_slab_page(s, alloc_gfp, node, oo);
if (unlikely(!page))
goto out;
stat(s, ORDER_FALLBACK);
}
......
return page;
}
```
在这里我们看到了alloc_slab_page分配页面。分配的时候要按kmem_cache_order_objects里面的order来。如果第一次分配不成功说明内存已经很紧张了那就换成min版本的kmem_cache_order_objects。
好了,这个复杂的层层分配机制,我们就讲到这里,你理解到这里也就够用了。
## 页面换出
另一个物理内存管理必须要处理的事情就是页面换出。每个进程都有自己的虚拟地址空间无论是32位还是64位虚拟地址空间都非常大物理内存不可能有这么多的空间放得下。所以一般情况下页面只有在被使用的时候才会放在物理内存中。如果过了一段时间不被使用即便用户进程并没有释放它物理内存管理也有责任做一定的干预。例如将这些物理内存中的页面换出到硬盘上去将空出的物理内存交给活跃的进程去使用。
什么情况下会触发页面换出呢?
可以想象最常见的情况就是分配内存的时候发现没有地方了就试图回收一下。例如咱们解析申请一个页面的时候会调用get_page_from_freelist接下来的调用链为get_page_from_freelist-&gt;node_reclaim-&gt;__node_reclaim-&gt;shrink_node通过这个调用链可以看出页面换出也是以内存节点为单位的。
当然还有一种情况,就是作为内存管理系统应该主动去做的,而不能等真的出了事儿再做,这就是内核线程**kswapd**。这个内核线程,在系统初始化的时候就被创建。这样它会进入一个无限循环,直到系统停止。在这个循环中,如果内存使用没有那么紧张,那它就可以放心睡大觉;如果内存紧张了,就需要去检查一下内存,看看是否需要换出一些内存页。
```
/*
* The background pageout daemon, started as a kernel thread
* from the init process.
*
* This basically trickles out pages so that we have _some_
* free memory available even if there is no other activity
* that frees anything up. This is needed for things like routing
* etc, where we otherwise might have all activity going on in
* asynchronous contexts that cannot page things out.
*
* If there are applications that are active memory-allocators
* (most normal use), this basically shouldn't matter.
*/
static int kswapd(void *p)
{
unsigned int alloc_order, reclaim_order;
unsigned int classzone_idx = MAX_NR_ZONES - 1;
pg_data_t *pgdat = (pg_data_t*)p;
struct task_struct *tsk = current;
for ( ; ; ) {
......
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
classzone_idx);
......
reclaim_order = balance_pgdat(pgdat, alloc_order, classzone_idx);
......
}
}
```
这里的调用链是balance_pgdat-&gt;kswapd_shrink_node-&gt;shrink_node是以内存节点为单位的最后也是调用shrink_node。
shrink_node会调用shrink_node_memcg。这里面有一个循环处理页面的列表看这个函数的注释其实和上面我们想表达的内存换出是一样的。
```
/*
* This is a basic per-node page freer. Used by both kswapd and direct reclaim.
*/
static void shrink_node_memcg(struct pglist_data *pgdat, struct mem_cgroup *memcg,
struct scan_control *sc, unsigned long *lru_pages)
{
......
unsigned long nr[NR_LRU_LISTS];
enum lru_list lru;
......
while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] ||
nr[LRU_INACTIVE_FILE]) {
unsigned long nr_anon, nr_file, percentage;
unsigned long nr_scanned;
for_each_evictable_lru(lru) {
if (nr[lru]) {
nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX);
nr[lru] -= nr_to_scan;
nr_reclaimed += shrink_list(lru, nr_to_scan,
lruvec, memcg, sc);
}
}
......
}
......
```
这里面有个lru列表。从下面的定义我们可以想象所有的页面都被挂在LRU列表中。LRU是Least Recent Use也就是最近最少使用。也就是说这个列表里面会按照活跃程度进行排序这样就容易把不怎么用的内存页拿出来做处理。
内存页总共分两类,一类是**匿名页**,和虚拟地址空间进行关联;一类是**内存映射**,不但和虚拟地址空间关联,还和文件管理关联。
它们每一类都有两个列表一个是active一个是inactive。顾名思义active就是比较活跃的inactive就是不怎么活跃的。这两个里面的页会变化过一段时间活跃的可能变为不活跃不活跃的可能变为活跃。如果要换出内存那就是从不活跃的列表中找出最不活跃的换出到硬盘上。
```
enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};
#define for_each_evictable_lru(lru) for (lru = 0; lru &lt;= LRU_ACTIVE_FILE; lru++)
static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
struct lruvec *lruvec, struct mem_cgroup *memcg,
struct scan_control *sc)
{
if (is_active_lru(lru)) {
if (inactive_list_is_low(lruvec, is_file_lru(lru),
memcg, sc, true))
shrink_active_list(nr_to_scan, lruvec, sc, lru);
return 0;
}
return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);
```
从上面的代码可以看出shrink_list会先缩减活跃页面列表再压缩不活跃的页面列表。对于不活跃列表的缩减shrink_inactive_list就需要对页面进行回收对于匿名页来讲需要分配swap将内存页写入文件系统对于内存映射关联了文件的我们需要将在内存中对于文件的修改写回到文件中。
## 总结时刻
好了,对于物理内存的管理就讲到这里了,我们来总结一下。对于物理内存来讲,从下层到上层的关系及分配模式如下:
<li>
物理内存分NUMA节点分别进行管理
</li>
<li>
每个NUMA节点分成多个内存区域
</li>
<li>
每个内存区域分成多个物理页面;
</li>
<li>
伙伴系统将多个连续的页面作为一个大的内存块分配给上层;
</li>
<li>
kswapd负责物理页面的换入换出
</li>
<li>
Slub Allocator将从伙伴系统申请的大内存块切成小块分配给其他系统。
</li>
<img src="https://static001.geekbang.org/resource/image/52/54/527e5c861fd06c6eb61a761e4214ba54.jpeg" alt="">
## 课堂练习
内存的换入和换出涉及swap分区那你知道如何检查当前swap分区情况如何启用和关闭swap区域如何调整swappiness吗
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,666 @@
<audio id="audio" title="25 | 用户态内存映射:如何找到正确的会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/a7/758c60f8395f324ea87d74301cbe8da7.mp3"></audio>
前面几节,我们既看了虚拟内存空间如何组织的,也看了物理页面如何管理的。现在我们需要一些数据结构,将二者关联起来。
## mmap的原理
在虚拟地址空间那一节我们知道每一个进程都有一个列表vm_area_struct指向虚拟地址空间的不同的内存块这个变量的名字叫**mmap**。
```
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
......
}
struct vm_area_struct {
/*
* For areas with an address space and backing store,
* linkage into the address_space-&gt;i_mmap interval tree.
*/
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_chain; /* Serialized by mmap_sem &amp;
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
```
其实内存映射不仅仅是物理内存和虚拟内存之间的映射,还包括将文件中的内容映射到虚拟内存空间。这个时候,访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射,是一种特殊情况。
<img src="https://static001.geekbang.org/resource/image/f0/45/f0dcb83fcaa4f185a8e36c9d28f12345.jpg" alt="">
前面咱们讲堆的时候讲过如果我们要申请小块内存就用brk。brk函数之前已经解析过了这里就不多说了。如果申请一大块内存就要用mmap。对于堆的申请来讲mmap是映射内存空间到物理内存。
另外如果一个进程想映射一个文件到自己的虚拟内存空间也要通过mmap系统调用。这个时候mmap是映射内存空间到物理内存再到文件。可见mmap这个系统调用是核心我们现在来看mmap这个系统调用。
```
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, off)
{
......
error = sys_mmap_pgoff(addr, len, prot, flags, fd, off &gt;&gt; PAGE_SHIFT);
......
}
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, pgoff)
{
struct file *file = NULL;
......
file = fget(fd);
......
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
return retval;
}
```
如果要映射到文件fd会传进来一个文件描述符并且mmap_pgoff里面通过fget函数根据文件描述符获得struct file。struct file表示打开的一个文件。
接下来的调用链是vm_mmap_pgoff-&gt;do_mmap_pgoff-&gt;do_mmap。这里面主要干了两件事情
<li>
调用get_unmapped_area找到一个没有映射的区域
</li>
<li>
调用mmap_region映射这个区域。
</li>
我们先来看get_unmapped_area函数。
```
unsigned long
get_unmapped_area(struct file *file, unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags)
{
unsigned long (*get_area)(struct file *, unsigned long,
unsigned long, unsigned long, unsigned long);
......
get_area = current-&gt;mm-&gt;get_unmapped_area;
if (file) {
if (file-&gt;f_op-&gt;get_unmapped_area)
get_area = file-&gt;f_op-&gt;get_unmapped_area;
}
......
}
```
这里面如果是匿名映射则调用mm_struct里面的get_unmapped_area函数。这个函数其实是arch_get_unmapped_area。它会调用find_vma_prev在表示虚拟内存区域的vm_area_struct红黑树上找到相应的位置。之所以叫prev是说这个时候虚拟内存区域还没有建立找到前一个vm_area_struct。
如果不是匿名映射而是映射到一个文件这样在Linux里面每个打开的文件都有一个struct file结构里面有一个file_operations用来表示和这个文件相关的操作。如果是我们熟知的ext4文件系统调用的是thp_get_unmapped_area。如果我们仔细看这个函数最终还是调用mm_struct里面的get_unmapped_area函数。殊途同归。
```
const struct file_operations ext4_file_operations = {
......
.mmap = ext4_file_mmap
.get_unmapped_area = thp_get_unmapped_area,
};
unsigned long __thp_get_unmapped_area(struct file *filp, unsigned long len,
loff_t off, unsigned long flags, unsigned long size)
{
unsigned long addr;
loff_t off_end = off + len;
loff_t off_align = round_up(off, size);
unsigned long len_pad;
len_pad = len + size;
......
addr = current-&gt;mm-&gt;get_unmapped_area(filp, 0, len_pad,
off &gt;&gt; PAGE_SHIFT, flags);
addr += (off - addr) &amp; (size - 1);
return addr;
}
```
我们再来看mmap_region看它如何映射这个虚拟内存区域。
```
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
struct mm_struct *mm = current-&gt;mm;
struct vm_area_struct *vma, *prev;
struct rb_node **rb_link, *rb_parent;
/*
* Can we just expand an old mapping?
*/
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma)
goto out;
/*
* Determine the object being mapped and call the appropriate
* specific mapper. the address has already been validated, but
* not unmapped, but the maps are removed from the list.
*/
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma) {
error = -ENOMEM;
goto unacct_error;
}
vma-&gt;vm_mm = mm;
vma-&gt;vm_start = addr;
vma-&gt;vm_end = addr + len;
vma-&gt;vm_flags = vm_flags;
vma-&gt;vm_page_prot = vm_get_page_prot(vm_flags);
vma-&gt;vm_pgoff = pgoff;
INIT_LIST_HEAD(&amp;vma-&gt;anon_vma_chain);
if (file) {
vma-&gt;vm_file = get_file(file);
error = call_mmap(file, vma);
addr = vma-&gt;vm_start;
vm_flags = vma-&gt;vm_flags;
}
......
vma_link(mm, vma, prev, rb_link, rb_parent);
return addr;
.....
```
还记得咱们刚找到了虚拟内存区域的前一个vm_area_struct我们首先要看是否能够基于它进行扩展也即调用vma_merge和前一个vm_area_struct合并到一起。
如果不能就需要调用kmem_cache_zalloc在Slub里面创建一个新的vm_area_struct对象设置起始和结束位置将它加入队列。如果是映射到文件则设置vm_file为目标文件调用call_mmap。其实就是调用file_operations的mmap函数。对于ext4文件系统调用的是ext4_file_mmap。从这个函数的参数可以看出这一刻文件和内存开始发生关系了。这里我们将vm_area_struct的内存操作设置为文件系统操作也就是说读写内存其实就是读写文件系统。
```
static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
{
return file-&gt;f_op-&gt;mmap(file, vma);
}
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
......
vma-&gt;vm_ops = &amp;ext4_file_vm_ops;
......
}
```
我们再回到mmap_region函数。最终vma_link函数将新创建的vm_area_struct挂在了mm_struct里面的红黑树上。
这个时候从内存到文件的映射关系至少要在逻辑层面建立起来。那从文件到内存的映射关系呢vma_link还做了另外一件事情就是__vma_link_file。这个东西要用于建立这层映射关系。
对于打开的文件会有一个结构struct file来表示。它有个成员指向struct address_space结构这里面有棵变量名为i_mmap的红黑树vm_area_struct就挂在这棵树上。
```
struct address_space {
struct inode *host; /* owner: inode, block_device */
......
struct rb_root i_mmap; /* tree of private and shared mappings */
......
const struct address_space_operations *a_ops; /* methods */
......
}
static void __vma_link_file(struct vm_area_struct *vma)
{
struct file *file;
file = vma-&gt;vm_file;
if (file) {
struct address_space *mapping = file-&gt;f_mapping;
vma_interval_tree_insert(vma, &amp;mapping-&gt;i_mmap);
}
```
到这里,内存映射的内容要告一段落了。你可能会困惑,好像还没和物理内存发生任何关系,还是在虚拟内存里面折腾呀?
对的,因为到目前为止,我们还没有开始真正访问内存呀!这个时候,内存管理并不直接分配物理内存,因为物理内存相对于虚拟地址空间太宝贵了,只有等你真正用的那一刻才会开始分配。
## 用户态缺页异常
一旦开始访问虚拟内存的某个地址如果我们发现并没有对应的物理页那就触发缺页中断调用do_page_fault。
```
dotraplinkage void notrace
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
unsigned long address = read_cr2(); /* Get the faulting address */
......
__do_page_fault(regs, error_code, address);
......
}
/*
* This routine handles page faults. It determines the address,
* and the problem, and then passes it off to one of the appropriate
* routines.
*/
static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long error_code,
unsigned long address)
{
struct vm_area_struct *vma;
struct task_struct *tsk;
struct mm_struct *mm;
tsk = current;
mm = tsk-&gt;mm;
if (unlikely(fault_in_kernel_space(address))) {
if (vmalloc_fault(address) &gt;= 0)
return;
}
......
vma = find_vma(mm, address);
......
fault = handle_mm_fault(vma, address, flags);
......
```
在__do_page_fault里面先要判断缺页中断是否发生在内核。如果发生在内核则调用vmalloc_fault这就和咱们前面学过的虚拟内存的布局对应上了。在内核里面vmalloc区域需要内核页表映射到物理页。咱们这里把内核的这部分放放接着看用户空间的部分。
接下来在用户空间里面找到你访问的那个地址所在的区域vm_area_struct然后调用handle_mm_fault来映射这个区域。
```
static int __handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
unsigned int flags)
{
struct vm_fault vmf = {
.vma = vma,
.address = address &amp; PAGE_MASK,
.flags = flags,
.pgoff = linear_page_index(vma, address),
.gfp_mask = __get_fault_gfp_mask(vma),
};
struct mm_struct *mm = vma-&gt;vm_mm;
pgd_t *pgd;
p4d_t *p4d;
int ret;
pgd = pgd_offset(mm, address);
p4d = p4d_alloc(mm, pgd, address);
......
vmf.pud = pud_alloc(mm, p4d, address);
......
vmf.pmd = pmd_alloc(mm, vmf.pud, address);
......
return handle_pte_fault(&amp;vmf);
}
```
到这里终于看到了我们熟悉的PGD、P4G、PUD、PMD、PTE这就是前面讲页表的时候讲述的四级页表的概念因为暂且不考虑五级页表我们暂时忽略P4G。
<img src="https://static001.geekbang.org/resource/image/9b/f1/9b802943af4e3ae80ce4d0d7f2190af1.jpg" alt="">
pgd_t 用于全局页目录项pud_t 用于上层页目录项pmd_t 用于中间页目录项pte_t 用于直接页表项。
每个进程都有独立的地址空间为了这个进程独立完成映射每个进程都有独立的进程页表这个页表的最顶级的pgd存放在task_struct中的mm_struct的pgd变量里面。
在一个进程新创建的时候会调用fork对于内存的部分会调用copy_mm里面调用dup_mm。
```
/*
* Allocate a new mm structure and copy contents from the
* mm structure of the passed in task structure.
*/
static struct mm_struct *dup_mm(struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm = current-&gt;mm;
mm = allocate_mm();
memcpy(mm, oldmm, sizeof(*mm));
if (!mm_init(mm, tsk, mm-&gt;user_ns))
goto fail_nomem;
err = dup_mmap(mm, oldmm);
return mm;
}
```
在这里除了创建一个新的mm_struct并且通过memcpy将它和父进程的弄成一模一样之外我们还需要调用mm_init进行初始化。接下来mm_init调用mm_alloc_pgd分配全局页目录项赋值给mm_struct的pgd成员变量。
```
static inline int mm_alloc_pgd(struct mm_struct *mm)
{
mm-&gt;pgd = pgd_alloc(mm);
return 0;
}
```
pgd_alloc里面除了分配PGD之外还做了很重要的一个事情就是调用pgd_ctor。
```
static void pgd_ctor(struct mm_struct *mm, pgd_t *pgd)
{
/* If the pgd points to a shared pagetable level (either the
ptes in non-PAE, or shared PMD in PAE), then just copy the
references from swapper_pg_dir. */
if (CONFIG_PGTABLE_LEVELS == 2 ||
(CONFIG_PGTABLE_LEVELS == 3 &amp;&amp; SHARED_KERNEL_PMD) ||
CONFIG_PGTABLE_LEVELS &gt;= 4) {
clone_pgd_range(pgd + KERNEL_PGD_BOUNDARY,
swapper_pg_dir + KERNEL_PGD_BOUNDARY,
KERNEL_PGD_PTRS);
}
......
}
```
pgd_ctor干了什么事情呢我们注意看里面的注释它拷贝了对于swapper_pg_dir的引用。swapper_pg_dir是内核页表的最顶级的全局页目录。
一个进程的虚拟地址空间包含用户态和内核态两部分。为了从虚拟地址空间映射到物理页面页表也分为用户地址空间的页表和内核页表这就和上面遇到的vmalloc有关系了。在内核里面映射靠内核页表这里内核页表会拷贝一份到进程的页表。至于swapper_pg_dir是什么怎么初始化的怎么工作的我们还是先放一放放到下一节统一讨论。
至此一个进程fork完毕之后有了内核页表有了自己顶级的pgd但是对于用户地址空间来讲还完全没有映射过。这需要等到这个进程在某个CPU上运行并且对内存访问的那一刻了。
当这个进程被调度到某个CPU上运行的时候咱们在[调度](https://time.geekbang.org/column/article/93251)那一节讲过要调用context_switch进行上下文切换。对于内存方面的切换会调用switch_mm_irqs_off这里面会调用 load_new_mm_cr3。
cr3是CPU的一个寄存器它会指向当前进程的顶级pgd。如果CPU的指令要访问进程的虚拟内存它就会自动从cr3里面得到pgd在物理内存的地址然后根据里面的页表解析虚拟内存的地址为物理内存从而访问真正的物理内存上的数据。
这里需要注意两点。第一点cr3里面存放当前进程的顶级pgd这个是硬件的要求。cr3里面需要存放pgd在物理内存的地址不能是虚拟地址。因而load_new_mm_cr3里面会使用__pa将mm_struct里面的成员变量pgdmm_struct里面存的都是虚拟地址变为物理地址才能加载到cr3里面去。
第二点用户进程在运行的过程中访问虚拟内存中的数据会被cr3里面指向的页表转换为物理地址后才在物理内存中访问数据这个过程都是在用户态运行的地址转换的过程无需进入内核态。
只有访问虚拟内存的时候发现没有映射到物理内存页表也没有创建过才触发缺页异常。进入内核调用do_page_fault一直调用到__handle_mm_fault这才有了上面解析到这个函数的时候我们看到的代码。既然原来没有创建过页表那只好补上这一课。于是__handle_mm_fault调用pud_alloc和pmd_alloc来创建相应的页目录项最后调用handle_pte_fault来创建页表项。
绕了一大圈终于将页表整个机制的各个部分串了起来。但是咱们的故事还没讲完物理的内存还没找到。我们还得接着分析handle_pte_fault的实现。
```
static int handle_pte_fault(struct vm_fault *vmf)
{
pte_t entry;
......
vmf-&gt;pte = pte_offset_map(vmf-&gt;pmd, vmf-&gt;address);
vmf-&gt;orig_pte = *vmf-&gt;pte;
......
if (!vmf-&gt;pte) {
if (vma_is_anonymous(vmf-&gt;vma))
return do_anonymous_page(vmf);
else
return do_fault(vmf);
}
if (!pte_present(vmf-&gt;orig_pte))
return do_swap_page(vmf);
......
}
```
这里面总的来说分了三种情况。如果PTE也就是页表项从来没有出现过那就是新映射的页。如果是匿名页就是第一种情况应该映射到一个物理内存页在这里调用的是do_anonymous_page。如果是映射到文件调用的就是do_fault这是第二种情况。如果PTE原来出现过说明原来页面在物理内存中后来换出到硬盘了现在应该换回来调用的是do_swap_page。
我们来看第一种情况do_anonymous_page。对于匿名页的映射我们需要先通过pte_alloc分配一个页表项然后通过alloc_zeroed_user_highpage_movable分配一个页。之后它会调用alloc_pages_vma并最终调用__alloc_pages_nodemask。
这个函数你还记得吗就是咱们伙伴系统的核心函数专门用来分配物理页面的。do_anonymous_page接下来要调用mk_pte将页表项指向新分配的物理页set_pte_at会将页表项塞到页表里面。
```
static int do_anonymous_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf-&gt;vma;
struct mem_cgroup *memcg;
struct page *page;
int ret = 0;
pte_t entry;
......
if (pte_alloc(vma-&gt;vm_mm, vmf-&gt;pmd, vmf-&gt;address))
return VM_FAULT_OOM;
......
page = alloc_zeroed_user_highpage_movable(vma, vmf-&gt;address);
......
entry = mk_pte(page, vma-&gt;vm_page_prot);
if (vma-&gt;vm_flags &amp; VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));
vmf-&gt;pte = pte_offset_map_lock(vma-&gt;vm_mm, vmf-&gt;pmd, vmf-&gt;address,
&amp;vmf-&gt;ptl);
......
set_pte_at(vma-&gt;vm_mm, vmf-&gt;address, vmf-&gt;pte, entry);
......
}
```
第二种情况映射到文件do_fault最终我们会调用__do_fault。
```
static int __do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf-&gt;vma;
int ret;
......
ret = vma-&gt;vm_ops-&gt;fault(vmf);
......
return ret;
}
```
这里调用了struct vm_operations_struct vm_ops的fault函数。还记得咱们上面用mmap映射文件的时候对于ext4文件系统vm_ops指向了ext4_file_vm_ops也就是调用了ext4_filemap_fault。
```
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = ext4_filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
int ext4_filemap_fault(struct vm_fault *vmf)
{
struct inode *inode = file_inode(vmf-&gt;vma-&gt;vm_file);
......
err = filemap_fault(vmf);
......
return err;
}
```
ext4_filemap_fault里面的逻辑我们很容易就能读懂。vm_file就是咱们当时mmap的时候映射的那个文件然后我们需要调用filemap_fault。对于文件映射来说一般这个文件会在物理内存里面有页面作为它的缓存find_get_page就是找那个页。如果找到了就调用do_async_mmap_readahead预读一些数据到内存里面如果没有就跳到no_cached_page。
```
int filemap_fault(struct vm_fault *vmf)
{
int error;
struct file *file = vmf-&gt;vma-&gt;vm_file;
struct address_space *mapping = file-&gt;f_mapping;
struct inode *inode = mapping-&gt;host;
pgoff_t offset = vmf-&gt;pgoff;
struct page *page;
int ret = 0;
......
page = find_get_page(mapping, offset);
if (likely(page) &amp;&amp; !(vmf-&gt;flags &amp; FAULT_FLAG_TRIED)) {
do_async_mmap_readahead(vmf-&gt;vma, ra, file, page, offset);
} else if (!page) {
goto no_cached_page;
}
......
vmf-&gt;page = page;
return ret | VM_FAULT_LOCKED;
no_cached_page:
error = page_cache_read(file, offset, vmf-&gt;gfp_mask);
......
}
```
如果没有物理内存中的缓存页那我们就调用page_cache_read。在这里显示分配一个缓存页将这一页加到lru表里面然后在address_space中调用address_space_operations的readpage函数将文件内容读到内存中。address_space的作用咱们上面也介绍过了。
```
static int page_cache_read(struct file *file, pgoff_t offset, gfp_t gfp_mask)
{
struct address_space *mapping = file-&gt;f_mapping;
struct page *page;
......
page = __page_cache_alloc(gfp_mask|__GFP_COLD);
......
ret = add_to_page_cache_lru(page, mapping, offset, gfp_mask &amp; GFP_KERNEL);
......
ret = mapping-&gt;a_ops-&gt;readpage(file, page);
......
}
```
struct address_space_operations对于ext4文件系统的定义如下所示。这么说来上面的readpage调用的其实是ext4_readpage。因为我们还没讲到文件系统这里我们不详细介绍ext4_readpage具体干了什么。你只要知道最后会调用ext4_read_inline_page这里面有部分逻辑和内存映射有关就行了。
```
static const struct address_space_operations ext4_aops = {
.readpage = ext4_readpage,
.readpages = ext4_readpages,
......
};
static int ext4_read_inline_page(struct inode *inode, struct page *page)
{
void *kaddr;
......
kaddr = kmap_atomic(page);
ret = ext4_read_inline_data(inode, kaddr, len, &amp;iloc);
flush_dcache_page(page);
kunmap_atomic(kaddr);
......
}
```
在ext4_read_inline_page函数里我们需要先调用kmap_atomic将物理内存映射到内核的虚拟地址空间得到内核中的地址kaddr。 我们在前面提到过kmap_atomic它是用来做临时内核映射的。本来把物理内存映射到用户虚拟地址空间不需要在内核里面映射一把。但是现在因为要从文件里面读取数据并写入这个物理页面又不能使用物理地址我们只能使用虚拟地址这就需要在内核里面临时映射一把。临时映射后ext4_read_inline_data读取文件到这个虚拟地址。读取完毕后我们取消这个临时映射kunmap_atomic就行了。
至于kmap_atomic的具体实现我们还是放到内核映射部分再讲。
我们再来看第三种情况do_swap_page。之前我们讲过物理内存管理你这里可以回忆一下。如果长时间不用就要换出到硬盘也就是swap现在这部分数据又要访问了我们还得想办法再次读到内存中来。
```
int do_swap_page(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf-&gt;vma;
struct page *page, *swapcache;
struct mem_cgroup *memcg;
swp_entry_t entry;
pte_t pte;
......
entry = pte_to_swp_entry(vmf-&gt;orig_pte);
......
page = lookup_swap_cache(entry);
if (!page) {
page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE, vma,
vmf-&gt;address);
......
}
......
swapcache = page;
......
pte = mk_pte(page, vma-&gt;vm_page_prot);
......
set_pte_at(vma-&gt;vm_mm, vmf-&gt;address, vmf-&gt;pte, pte);
vmf-&gt;orig_pte = pte;
......
swap_free(entry);
......
}
```
do_swap_page函数会先查找swap文件有没有缓存页。如果没有就调用swapin_readahead将swap文件读到内存中来形成内存页并通过mk_pte生成页表项。set_pte_at将页表项插入页表swap_free将swap文件清理。因为重新加载回内存了不再需要swap文件了。
swapin_readahead会最终调用swap_readpage在这里我们看到了熟悉的readpage函数也就是说读取普通文件和读取swap文件过程是一样的同样需要用kmap_atomic做临时映射。
```
int swap_readpage(struct page *page, bool do_poll)
{
struct bio *bio;
int ret = 0;
struct swap_info_struct *sis = page_swap_info(page);
blk_qc_t qc;
struct block_device *bdev;
......
if (sis-&gt;flags &amp; SWP_FILE) {
struct file *swap_file = sis-&gt;swap_file;
struct address_space *mapping = swap_file-&gt;f_mapping;
ret = mapping-&gt;a_ops-&gt;readpage(swap_file, page);
return ret;
}
......
}
```
通过上面复杂的过程,用户态缺页异常处理完毕了。物理内存中有了页面,页表也建立好了映射。接下来,用户程序在虚拟内存空间里面,可以通过虚拟地址顺利经过页表映射的访问物理页面上的数据了。
为了加快映射速度,我们不需要每次从虚拟地址到物理地址的转换都走一遍页表。
<img src="https://static001.geekbang.org/resource/image/94/b3/94efd92cbeb4d4ff155a645b93d71eb3.jpg" alt="">
页表一般都很大,只能存放在内存中。操作系统每次访问内存都要折腾两步,先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。
为了提高映射速度,我们引入了**TLB**Translation Lookaside Buffer我们经常称为**快表**专门用来做地址映射的硬件设备。它不在内存中可存储的数据比较少但是比内存要快。所以我们可以想象TLB就是页表的Cache其中存储了当前最可能被访问到的页表项其内容是部分页表项的一个副本。
有了TLB之后地址映射的过程就像图中画的。我们先查块表块表中有映射关系然后直接转换为物理地址。如果在TLB查不到映射关系时才会到内存中查询页表。
## 总结时刻
用户态的内存映射机制,我们解析的差不多了,我们来总结一下,用户态的内存映射机制包含以下几个部分。
<li>
用户态内存映射函数mmap包括用它来做匿名映射和文件映射。
</li>
<li>
用户态的页表结构存储位置在mm_struct中。
</li>
<li>
在用户态访问没有映射的内存会引发缺页异常分配物理页表、补齐页表。如果是匿名映射则分配物理内存如果是swap则将swap文件读入如果是文件映射则将文件读入。
</li>
<img src="https://static001.geekbang.org/resource/image/78/44/78d351d0105c8e5bf0e49c685a2c1a44.jpg" alt="">
## 课堂练习
你可以试着用mmap系统调用写一个程序来映射一个文件并读取文件的内容。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,364 @@
<audio id="audio" title="26 | 内核态内存映射:如何找到正确的会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/13/96/1378c56588e2a2a95c61f04d963df796.mp3"></audio>
前面讲用户态内存映射机制的时候,我们已经多次引申出了内核的映射机制,但是咱们都暂时放了放,这一节我们就来详细解析一下,让你彻底搞懂它。
首先,你要知道,内核态的内存映射机制,主要包含以下几个部分:
<li>
内核态内存映射函数vmalloc、kmap_atomic是如何工作的
</li>
<li>
内核态页表是放在哪里的如何工作的swapper_pg_dir是怎么回事
</li>
<li>
出现了内核态缺页异常应该怎么办?
</li>
## 内核页表
和用户态页表不同,在系统初始化的时候,我们就要创建内核页表了。
我们从内核页表的根swapper_pg_dir开始找线索在arch/x86/include/asm/pgtable_64.h中就能找到它的定义。
```
extern pud_t level3_kernel_pgt[512];
extern pud_t level3_ident_pgt[512];
extern pmd_t level2_kernel_pgt[512];
extern pmd_t level2_fixmap_pgt[512];
extern pmd_t level2_ident_pgt[512];
extern pte_t level1_fixmap_pgt[512];
extern pgd_t init_top_pgt[];
#define swapper_pg_dir init_top_pgt
```
swapper_pg_dir指向内核最顶级的目录pgd同时出现的还有几个页表目录。我们可以回忆一下64位系统的虚拟地址空间的布局其中XXX_ident_pgt对应的是直接映射区XXX_kernel_pgt对应的是内核代码区XXX_fixmap_pgt对应的是固定映射区。
它们是在哪里初始化的呢在汇编语言的文件里面的arch\x86\kernel\head_64.S。这段代码比较难看懂你只要明白它是干什么的就行了。
```
__INITDATA
NEXT_PAGE(init_top_pgt)
.quad level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE
.org init_top_pgt + PGD_PAGE_OFFSET*8, 0
.quad level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE
.org init_top_pgt + PGD_START_KERNEL*8, 0
/* (2^48-(2*1024*1024*1024))/(2^39) = 511 */
.quad level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE
NEXT_PAGE(level3_ident_pgt)
.quad level2_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE
.fill 511, 8, 0
NEXT_PAGE(level2_ident_pgt)
/* Since I easily can, map the first 1G.
* Don't set NX because code runs from these pages.
*/
PMDS(0, __PAGE_KERNEL_IDENT_LARGE_EXEC, PTRS_PER_PMD)
NEXT_PAGE(level3_kernel_pgt)
.fill L3_START_KERNEL,8,0
/* (2^48-(2*1024*1024*1024)-((2^39)*511))/(2^30) = 510 */
.quad level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE
.quad level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE
NEXT_PAGE(level2_kernel_pgt)
/*
* 512 MB kernel mapping. We spend a full page on this pagetable
* anyway.
*
* The kernel code+data+bss must not be bigger than that.
*
* (NOTE: at +512MB starts the module area, see MODULES_VADDR.
* If you want to increase this then increase MODULES_VADDR
* too.)
*/
PMDS(0, __PAGE_KERNEL_LARGE_EXEC,
KERNEL_IMAGE_SIZE/PMD_SIZE)
NEXT_PAGE(level2_fixmap_pgt)
.fill 506,8,0
.quad level1_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE
/* 8MB reserved for vsyscalls + a 2MB hole = 4 + 1 entries */
.fill 5,8,0
NEXT_PAGE(level1_fixmap_pgt)
.fill 51
```
内核页表的顶级目录init_top_pgt定义在__INITDATA里面。咱们讲过ELF的格式也讲过虚拟内存空间的布局。它们都有代码段还有一些初始化了的全局变量放在.init区域。这些说的就是这个区域。可以看到页表的根其实是全局变量这就使得我们初始化的时候甚至内存管理还没有初始化的时候很容易就可以定位到。
接下来定义init_top_pgt包含哪些项这个汇编代码比较难懂了。你可以简单地认为quad是声明了一项的内容org是跳到了某个位置。
所以init_top_pgt有三项上来先有一项指向的是level3_ident_pgt也即直接映射区页表的三级目录。为什么要减去__START_KERNEL_map呢因为level3_ident_pgt是定义在内核代码里的写代码的时候写的都是虚拟地址谁写代码的时候也不知道将来加载的物理地址是多少呀对不对
因为level3_ident_pgt是在虚拟地址的内核代码段里的而__START_KERNEL_map正是虚拟地址空间的内核代码段的起始地址这在讲64位虚拟地址空间的时候都讲过了要是想不起来就赶紧去回顾一下。这样level3_ident_pgt减去__START_KERNEL_map才是物理地址。
第一项定义完了以后接下来我们跳到PGD_PAGE_OFFSET的位置再定义一项。从定义可以看出这一项就应该是__PAGE_OFFSET_BASE对应的。__PAGE_OFFSET_BASE是虚拟地址空间里面内核的起始地址。第二项也指向level3_ident_pgt直接映射区。
```
PGD_PAGE_OFFSET = pgd_index(__PAGE_OFFSET_BASE)
PGD_START_KERNEL = pgd_index(__START_KERNEL_map)
L3_START_KERNEL = pud_index(__START_KERNEL_map)
```
第二项定义完了以后接下来跳到PGD_START_KERNEL的位置再定义一项。从定义可以看出这一项应该是__START_KERNEL_map对应的项__START_KERNEL_map是虚拟地址空间里面内核代码段的起始地址。第三项指向level3_kernel_pgt内核代码区。
接下来的代码就很类似了,就是初始化个表项,然后指向下一级目录,最终形成下面这张图。
<img src="https://static001.geekbang.org/resource/image/78/6d/78c8d44d7d8c08c03eee6f7a94652d6d.png" alt="">
内核页表定义完了一开始这里面的页表能够覆盖的内存范围比较小。例如内核代码区512M直接映射区1G。这个时候其实只要能够映射基本的内核代码和数据结构就可以了。可以看出里面还空着很多项可以用于将来映射巨大的内核虚拟地址空间等用到的时候再进行映射。
如果是用户态进程页表会有mm_struct指向进程顶级目录pgd对于内核来讲也定义了一个mm_struct指向swapper_pg_dir。
```
struct mm_struct init_mm = {
.mm_rb = RB_ROOT,
.pgd = swapper_pg_dir,
.mm_users = ATOMIC_INIT(2),
.mm_count = ATOMIC_INIT(1),
.mmap_sem = __RWSEM_INITIALIZER(init_mm.mmap_sem),
.page_table_lock = __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
.mmlist = LIST_HEAD_INIT(init_mm.mmlist),
.user_ns = &amp;init_user_ns,
INIT_MM_CONTEXT(init_mm)
};
```
定义完了内核页表接下来是初始化内核页表在系统启动的时候start_kernel会调用setup_arch。
```
void __init setup_arch(char **cmdline_p)
{
/*
* copy kernel address range established so far and switch
* to the proper swapper page table
*/
clone_pgd_range(swapper_pg_dir + KERNEL_PGD_BOUNDARY,
initial_page_table + KERNEL_PGD_BOUNDARY,
KERNEL_PGD_PTRS);
load_cr3(swapper_pg_dir);
__flush_tlb_all();
......
init_mm.start_code = (unsigned long) _text;
init_mm.end_code = (unsigned long) _etext;
init_mm.end_data = (unsigned long) _edata;
init_mm.brk = _brk_end;
......
init_mem_mapping();
......
}
```
在setup_arch中load_cr3(swapper_pg_dir)说明内核页表要开始起作用了并且刷新了TLB初始化init_mm的成员变量最重要的就是init_mem_mapping。最终它会调用kernel_physical_mapping_init。
```
/*
* Create page table mapping for the physical memory for specific physical
* addresses. The virtual and physical addresses have to be aligned on PMD level
* down. It returns the last physical address mapped.
*/
unsigned long __meminit
kernel_physical_mapping_init(unsigned long paddr_start,
unsigned long paddr_end,
unsigned long page_size_mask)
{
unsigned long vaddr, vaddr_start, vaddr_end, vaddr_next, paddr_last;
paddr_last = paddr_end;
vaddr = (unsigned long)__va(paddr_start);
vaddr_end = (unsigned long)__va(paddr_end);
vaddr_start = vaddr;
for (; vaddr &lt; vaddr_end; vaddr = vaddr_next) {
pgd_t *pgd = pgd_offset_k(vaddr);
p4d_t *p4d;
vaddr_next = (vaddr &amp; PGDIR_MASK) + PGDIR_SIZE;
if (pgd_val(*pgd)) {
p4d = (p4d_t *)pgd_page_vaddr(*pgd);
paddr_last = phys_p4d_init(p4d, __pa(vaddr),
__pa(vaddr_end),
page_size_mask);
continue;
}
p4d = alloc_low_page();
paddr_last = phys_p4d_init(p4d, __pa(vaddr), __pa(vaddr_end),
page_size_mask);
p4d_populate(&amp;init_mm, p4d_offset(pgd, vaddr), (pud_t *) p4d);
}
__flush_tlb_all();
return paddr_l
```
在kernel_physical_mapping_init里我们先通过__va将物理地址转换为虚拟地址然后再创建虚拟地址和物理地址的映射页表。
你可能会问怎么这么麻烦啊既然对于内核来讲我们可以用__va和__pa直接在虚拟地址和物理地址之间直接转来转去为啥还要辛辛苦苦建立页表呢因为这是CPU和内存的硬件的需求也就是说CPU在保护模式下访问虚拟地址的时候就会用CR3这个寄存器这个寄存器是CPU定义的作为操作系统我们是软件只能按照硬件的要求来。
你可能又会问了按照咱们讲初始化的时候的过程系统早早就进入了保护模式到了setup_arch里面才load_cr3如果使用cr3是硬件的要求那之前是怎么办的呢如果你仔细去看arch\x86\kernel\head_64.S这里面除了初始化内核页表之外在这之前还有另一个页表early_top_pgt。看到关键字early了嘛这个页表就是专门用在真正的内核页表初始化之前为了遵循硬件的要求而设置的。早期页表不是我们这节的重点这里我就不展开多说了。
## vmalloc和kmap_atomic原理
在用户态可以通过malloc函数分配内存当然malloc在分配比较大的内存的时候底层调用的是mmap当然也可以直接通过mmap做内存映射在内核里面也有相应的函数。
在虚拟地址空间里面有个vmalloc区域从VMALLOC_START开始到VMALLOC_END可以用于映射一段物理内存。
```
/**
* vmalloc - allocate virtually contiguous memory
* @size: allocation size
* Allocate enough pages to cover @size from the page level
* allocator and map them into contiguous kernel virtual space.
*
* For tight control over page level allocator and protection flags
* use __vmalloc() instead.
*/
void *vmalloc(unsigned long size)
{
return __vmalloc_node_flags(size, NUMA_NO_NODE,
GFP_KERNEL);
}
static void *__vmalloc_node(unsigned long size, unsigned long align,
gfp_t gfp_mask, pgprot_t prot,
int node, const void *caller)
{
return __vmalloc_node_range(size, align, VMALLOC_START, VMALLOC_END,
gfp_mask, prot, 0, node, caller);
}
```
我们再来看内核的临时映射函数kmap_atomic的实现。从下面的代码我们可以看出如果是32位有高端地址的就需要调用set_pte通过内核页表进行临时映射如果是64位没有高端地址的就调用page_address里面会调用lowmem_page_address。其实低端内存的映射会直接使用__va进行临时映射。
```
void *kmap_atomic_prot(struct page *page, pgprot_t prot)
{
......
if (!PageHighMem(page))
return page_address(page);
......
vaddr = __fix_to_virt(FIX_KMAP_BEGIN + idx);
set_pte(kmap_pte-idx, mk_pte(page, prot));
......
return (void *)vaddr;
}
void *kmap_atomic(struct page *page)
{
return kmap_atomic_prot(page, kmap_prot);
}
static __always_inline void *lowmem_page_address(const struct page *page)
{
return page_to_virt(page);
}
#define page_to_virt(x) __va(PFN_PHYS(page_to_pfn(x)
```
## 内核态缺页异常
可以看出kmap_atomic和vmalloc不同。kmap_atomic发现没有页表的时候就直接创建页表进行映射了。而vmalloc没有它只分配了内核的虚拟地址。所以访问它的时候会产生缺页异常。
内核态的缺页异常还是会调用do_page_fault但是会走到咱们上面用户态缺页异常中没有解析的那部分vmalloc_fault。这个函数并不复杂主要用于关联内核页表项。
```
/*
* 32-bit:
*
* Handle a fault on the vmalloc or module mapping area
*/
static noinline int vmalloc_fault(unsigned long address)
{
unsigned long pgd_paddr;
pmd_t *pmd_k;
pte_t *pte_k;
/* Make sure we are in vmalloc area: */
if (!(address &gt;= VMALLOC_START &amp;&amp; address &lt; VMALLOC_END))
return -1;
/*
* Synchronize this task's top level page-table
* with the 'reference' page table.
*
* Do _not_ use &quot;current&quot; here. We might be inside
* an interrupt in the middle of a task switch..
*/
pgd_paddr = read_cr3_pa();
pmd_k = vmalloc_sync_one(__va(pgd_paddr), address);
if (!pmd_k)
return -1;
pte_k = pte_offset_kernel(pmd_k, address);
if (!pte_present(*pte_k))
return -1;
return 0
```
## 总结时刻
至此,内核态的内存映射也讲完了。这下,我们可以将整个内存管理的体系串起来了。
物理内存根据NUMA架构分节点。每个节点里面再分区域。每个区域里面再分页。
物理页面通过伙伴系统进行分配。分配的物理页面要变成虚拟地址让上层可以访问kswapd可以根据物理页面的使用情况对页面进行换入换出。
对于内存的分配需求,可能来自内核态,也可能来自用户态。
对于内核态kmalloc在分配大内存的时候以及vmalloc分配不连续物理页的时候直接使用伙伴系统分配后转换为虚拟地址访问的时候需要通过内核页表进行映射。
对于kmem_cache以及kmalloc分配小内存则使用slub分配器将伙伴系统分配出来的大块内存切成一小块一小块进行分配。
kmem_cache和kmalloc的部分不会被换出因为用这两个函数分配的内存多用于保持内核关键的数据结构。内核态中vmalloc分配的部分会被换出因而当访问的时候发现不在就会调用do_page_fault。
对于用户态的内存分配或者直接调用mmap系统调用分配或者调用malloc。调用malloc的时候如果分配小的内存就用sys_brk系统调用如果分配大的内存还是用sys_mmap系统调用。正常情况下用户态的内存都是可以换出的因而一旦发现内存中不存在就会调用do_page_fault。
<img src="https://static001.geekbang.org/resource/image/27/9a/274e22b3f5196a4c68bb6813fb643f9a.png" alt="">
## 课堂练习
伙伴系统分配好了物理页面之后如何转换成为虚拟地址呢请研究一下page_address函数的实现。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">