书中讲到从2.2版开始,Linux让所有的进程(或叫任务)都使用相同的逻辑地址空间,每个进程的逻辑地址空间范围为0~4GB,而且段基址也一样。那么不同的进程是如何区分自己的数据段和代码段的呢。看到另外一篇博文介绍了:linux使用了物理内存写时复制的机制。原来是因为写时复制的机制让不同的进程区分自己的代码和数据的。下面分两半部分,分别介绍一下linux的分段机制和写时复制机制。学习就需要融会贯通。
1. 先粘贴一下书中对Linux分段的介绍:
2.3.7 Linux中的段
图2.9 逻辑—线性地址转换
图2.10 段描述符的一般格式
Intel微处理器的段机制是从8086开始提出的,那时引入的段机制解决了从CPU内部16位地址到20位实地址的转换。为了保持这种兼容性,386仍然使用段机制,但比以前复杂得多。因此,Linux内核的设计并没有全部采用Intel所提供的段方案,仅仅有限度地使用了一下分段机制。这不仅简化了Linux内核的设计,而且为把Linux移植到其他平台创造了条件,因为很多RISC处理器并不支持段机制。但是,对段机制相关知识的了解是进入Linux内核的必经之路。
从2.2版开始,Linux让所有的进程(或叫任务)都使用相同的逻辑地址空间,因此就没有必要使用局部描述符表LDT。但内核中也用到LDT,那只是在VM86模式中运行Wine,因为就是说在Linux上模拟运行Winodws软件或DOS软件的程序时才使用。
Linux在启动的过程中设置了段寄存器的值和全局描述符表GDT的内容,段的定义在include/asm-i386/segment.h中:
#define __KERNEL_CS 0x10 /* 内核代码段, index=2,TI=0,RPL=0 */
#define __KERNEL_DS 0x18 /* 内核数据段, index=3,TI=0,RPL=0 */
#define __USER_CS 0x23 /* 用户代码段, index=4,TI=0,RPL=3 */
#define __USER_DS 0x2B /* 用户数据段, index=5,TI=0,RPL=3 */
从定义看出,没有定义堆栈段,实际上,Linux内核不区分数据段和堆栈段,这也体现了Linux内核尽量减少段的使用。因为没有使用LDT,因此,TI=0,并把这4个段都放在GDT中, index就是某个段在GDT表中的下标。内核代码段和数据段具有最高特权,因此其RPL为0,而用户代码段和数据段具有最低特权,因此其RPL为3。可以看出,Linux内核再次简化了特权级的使用,使用了两个特权级而不是4个。
全局描述符表的定义在arch/i386/kernel/head.S中:
ENTRY(gdt_table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* not used */
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
.quad 0x0000000000000000 /* not used */
.quad 0x0000000000000000 /* not used */
/*
* The APM segments have byte granularity and their bases
* and limits are set at run time.
*/
.quad 0x0040920000000000 /* 0x40 APM set up for bad BIOS's */
.quad 0x00409a0000000000 /* 0x48 APM CS code */
.quad 0x00009a0000000000 /* 0x50 APM CS 16 code (16 bit) */
.quad 0x0040920000000000 /* 0x58 APM DS data */
.fill NR_CPUS*4,8,0 /* space for TSS's and LDT's */
从代码可以看出,GDT放在数组变量gdt_table中。按Intel规定,GDT中的第一项为空,这是为了防止加电后段寄存器未经初始化就进入保护模式而使用GDT的。第二项也没用。从下标2到5共4项对应于前面的4种段描述符值。对照图2.10,从描述符的数值可以得出:
·段的基地址全部为0x00000000
·段的上限全部为0xffff(**即每个段的最大值limit为64k,由于粒度是4KB,每个段的最大值是256MB**)
·段的粒度G为1,即段长单位为4KB
·段的D位为1,即对这四个段的访问都为32位指令
·段的P位为1,即四个段都在内存。
由此可以得出,每个段的逻辑地址空间范围为0~4GB。读者可能对此不太理解,但只要对照图2.9就可以发现,这种设置既简单又巧妙。因为每个段的基地址为0,因此,逻辑地址到线性地址映射保持不变,也就是说,偏移量就是线性地址,我们以后所提到的逻辑地址(或虚拟地址)和线性地址指的也就是同一地址。看来,Linux巧妙地把段机制给绕过去了,而完全利用了分页机制。
从逻辑上说,Linux巧妙地绕过了逻辑地址到线性地址的映射,但实质上还得应付Intel所提供的段机制。只不过,Linux把段机制变得相当简单,它只把段分为两种:用户态(RPL=3)的段和内核态(RPL=0)的段,因此,描述符投影寄存器的内容很少发生变化,只在进程从用户态切换到内核态或者反之时才发生变化。另外,用户段和内核段的区别也仅仅在其RPL不同,因此内核根本无需访问描述符投影寄存器,当然也无需访问GDT,而仅从段寄存器的最低两位就可以获取RPL的信息。Linux这样设计所带来的好处是显而易见的,Intel的分段部件对Linux性能造成的影响可以忽略不计。
在上面描述的GDT表中,紧接着那四个段描述的两个描述符被保留,然后是四个高级电源管理(APM)特征描述符,对此不进行详细讨论。
按Intel的规定,每个进程有一个任务状态段(TSS)和局部描述符表LDT,但Linux也没有完全遵循Intel的设计思路。如前所述,Linux的进程没有使用LDT,而对TSS的使用也非常有限,每个CPU仅使用一个TSS。
通过上面的介绍可以看出,Intel的设计可谓周全细致,但Linux的设计者并没有完全陷入这种沼泽,而是选择了简洁而有效的途径,以完成所需功能并达到较好的性能为目的。
2.linux使用的物理内存写时复制的机制介绍
在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。
下面我们将分析 Linux 写时复制(Copy On Write) 机制的原理。
虚拟内存与物理内存
进程的内存可分为 虚拟内存 和 物理内存。
物理内存:就是电脑安装的内存条,如果电脑安装了2GB的内存条,那么系统就用于 0 ~ 2GB 的物理内存空间。
虚拟内存:虚拟内存是使用软件虚拟的,在 32 位操作系统中,每个进程都独占 4GB 的虚拟内存空间。
应用程序使用的是 虚拟内存,比如 C 语言取地址操作符号 & 所得到的地址就是 虚拟内存地址。而 虚拟内存地址 需要映射到 物理内存地址 才能使用,如果使用没有映射的 虚拟内存地址,将会导致 缺页异常。
虚拟内存地址 映射到 物理内存地址 如下图所示:
如上图所示,进程A与进程B的相同 虚拟内存地址 映射到不同的 物理内存地址,这就是不同进程的相同虚拟内存地址互不影响的原因。
写时复制原理
前面介绍了 虚拟内存 与 物理内存 的概念,接下来将会介绍 Linux 写时复制 的原理。
前面说过,虚拟内存 需要与 物理内存 进行映射才能使用,如果不同进程的 虚拟内存地址 映射到相同的 物理内存地址,那么就实现了共享内存的机制。如下图所示:
由于进程A的 虚拟内存M 与进程B的 虚拟内存M’ 映射到相同的 物理内存G,所以当修改进程A 虚拟内存M 的数据时,进程B 虚拟内存M’ 的数据也会跟着改变。
Linux 为了加速创建子进程过程与节省内存使用的原因,实现了 写时复制 的机制。
写时复制 的原理大概如下:
创建子进程时,将父进程的 虚拟内存 与 物理内存 映射关系复制到子进程中,并将内存设置为只读(设置为只读是为了当对内存进行写操作时触发 缺页异常)。
当子进程或者父进程对内存数据进行修改时,便会触发 写时复制 机制:将原来的内存页复制一份新的,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写。
写时复制 过程如下图所示:
当创建子进程时,父子进程指向相同的 物理内存,而不是将父进程所占用的 物理内存 复制一份。这样做的好处有两个:
加速创建子进程的速度。
减少进程对物理内存的使用。
如上图所示,当父进程调用 fork 创建子进程时,父进程的 虚拟内存页M 与子进程的 虚拟内存页M 映射到相同的 物理内存页G,并且把父进程与子进程的 虚拟内存页M 都设置为只读(因为设置为只读后,对内存页进行写操作时,将会发生 缺页异常,从而内核可以在缺页异常处理函数中进行物理内存页的复制)。
当子进程对 虚拟内存页M 进行写操作,便会触发 缺页异常(因为已经将 虚拟内存页M 设置为只读)。在缺页异常处理函数中,对 物理内存页G 进行复制一份新的 物理内存页G’,并且将子进程的 虚拟内存页M 映射到 物理内存页G’,同时将父子进程的 虚拟内存页M 设置为可读写。总结本篇文章主要介绍了 Linux 写时复制 的原理,写时复制 是 Linux 创建子进程高效的关键所在,而且还能节省对物理内存使用
上半部分出处:
Linux中的段
摘抄:陈莉君 《深入分析Linux内核源代码》
https://mrhopehub.github.io/2014/11/22/linux-segment.html
下半部分出处:
作者:Jayden
链接:https://zhuanlan.zhihu.com/p/366707663
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。