Linux内核笔记002 - i386 的页式内存管理机制

发布者:jmpcall
发布于:2020-05-13 18:34
1. 虚拟地址
    只要不是实际内存的地址,都是虚拟地址,通常就是指逻辑地址,逻辑地址是指指令中的地址,比如写一个下面这样的程序:
#include <stdio.h>

static char ch = 'A';

int main()
{
    printf("%p, %c\n", &ch, ch);
    return 0;
}
    执行"gcc test.c -g -Wall",然后执行"objdump -S a.out",查看main()函数的反编译结果:
int main()
{
 80483e4:	55                   	push   %ebp
 80483e5:	89 e5                	mov    %esp,%ebp
 80483e7:	83 e4 f0             	and    $0xfffffff0,%esp
 80483ea:	83 ec 10             	sub    $0x10,%esp
	printf("%p, %c\n", &ch, ch);
 80483ed:	0f b6 05 14 a0 04 08 	movzbl 0x804a014,%eax    ; 将地址0x804a014处的内容,即'A',扩展成32位的无符号数0x00000041,保存到eax
 80483f4:	0f be d0             	movsbl %al,%edx    ; 将eax最低字节即'A',当作有符号数,扩展成32位,仍然是0x00000041,保存到edx
 80483f7:	b8 f0 84 04 08       	mov    $0x80484f0,%eax    ;将格式字符串"%p, %c\n"地址,保存到eax
 80483fc:	89 54 24 08          	mov    %edx,0x8(%esp)    ; printf()实参:ch
 8048400:	c7 44 24 04 14 a0 04 	movl   $0x804a014,0x4(%esp)    ; printf()实参:&ch
 8048407:	08 
 8048408:	89 04 24             	mov    %eax,(%esp)    ; printf()实参:"%p, %c\n"
 804840b:	e8 f0 fe ff ff       	call   8048300 <printf@plt>
	return 0;
 8048410:	b8 00 00 00 00       	mov    $0x0,%eax
}
 8048415:	c9                   	leave  
 8048416:	c3                   	ret
    其中,指令"0f b6 05 14 a0 04 08",就包含着ch的地址"0x0804a014",并且在执行的时候,会读取这个地址处的内容。
    程序中的"0x0804a014"这个地址,就是逻辑地址,它本质上只是用于描述一段逻辑的标号,表示这段程序执行时,需要一个1字节的实际内存,并且用"0x0804a014"这个值标记它,就相当于上数学课时用的xyz,只不过除了xyz,数学中还可以用α、β、δ、φ、ξ、..等等标号,而二进程程序中,只用数字(指针/地址)作为标号,32位系统中,用32位的数字,64位系统中,用64位的数字。
    Linux内核笔记001中已经学习过X86 CPU的段式管理设计,它实际是提供给软件,通过一种合理的安排,让每个程序都能在自己专属的段上使用内存,那么即使两个不同的程序使用相同的逻辑地址,也会对应到不同的实际内存单元,从而避免冲突。这么看来,前面介绍的越权、越界检查,都是手段,让每个程序使用独立的实际内存是才是目的,接着再把这个任务集中实现在内核层,就解放了应用程序对"内存冲突"的考虑,这正是保护模式的核心思想。
    逻辑地址的集合,可以叫作"虚拟(内存)空间",那么拿32位系统来说,还需要理解2个东西:

  • 每个进程都有4G虚拟空间

        虚拟空间包含的是逻辑地址,只是用于描述程序逻辑的标号,由于指针类型的大小为32位,所以每个进程都有4G个标号可以使用,它跟实际内存有多大毫无关系。
  • 虚拟空间也是资源
         每个进程有4G虚拟空间的同时,也意味着只有4G虚拟空间,比如4G个标号都被无意义的对象占用着,有意义的对象就没有标号去对应了。

2. 物理地址

    物理地址很容易理解,就是指实际内存的地址。物理地址的集合,也相应的叫作"物理空间"。
    另外,有一组容易跟"虚拟空间"/"物理空间"混淆的概念:"虚拟内存"/"实际内存"。虚拟内存是指交换分区,即把硬盘当作内存使用(书2.6~2.9节)。

3. 什么是内核

    上面提到,在内核层集中实现"每个进程使用独立实际内存"的逻辑。那么,什么是内核?
    Linux内核笔记001中介绍过CPU的两种硬件状态:实模式、保护模式。其实,在保护模式状态下,CPU又存在两种硬件状态的区分:内核态、用户态。主要是通过段寄存器中的RPL区分,内核态可以执行特权指令,用户态不可以执行特权指令。有了硬件特性的支持,再加上电脑开机的第一条指令,又是从内核代码开始执行,那么内核的实现只要保证:在跳转到应用程序代码的同时,将CPU状态设置为用户态,用户态向切换回内核态的同时,指令也必须跳转回内核的代码区,就可以将权力掌握在自己手中。具体的实现,涉及到"门"的概念,以有CPL、DPL、CPL复杂的对比逻辑(后期会学习到)。
    我自己在刚开始学习的时候,对"内核"的理解,一直存在一个误区:Linux内核是一个有高权限的进程。
    其实这就涉及到"微内核"/"宏内核"的区别:
  • 微内核
        微内核可以理解成一个独掌大权的独立的进程,通过进程间通信的方式,为用户进程服务,内核的代码也只有"内核进程"可以执行。
  • 宏内核
        Linux内核属于宏内核,宏内核可以理解成,操作所有进程公共内存的一套接口(比如系统调用、异常处理函数),每个用户进程切换到内核态,执行内核的代码。
    上面不是刚说过,每个进程都会使用自己独立的物理内存嘛,再加上按照应用程序开发的经验,在同一个进程中创建的不同线程,才可以共享全局的内存,不同进程共享资源,不是要通过进程间通信的方式嘛,那么,Linux内核操作的所有进程公共内存是什么?
    理解这个问题之前,我们先要承认,所有进程是需要有公共内存的,否则自己占用了哪个部分的物理空间,如果只有自己看得见,那么别的进程进入内核态创建新进程时,怎么知道还有哪些物理空间可以用呢?
    然后再理解,所有进程公共内存是什么的问题:
    笔记第1节,已经介绍了"虚拟空间"的概念,Linux内核的设计,将4G虚拟空间,划分成了内核空间(3G-4G)用户空间(0-3G),既然是对虚拟空间的划分,那么就仍然是程序用于描述逻辑的标号,最终需要通过一个映射表,才能对应到实际的物理内存地址,那么,内核的实现只要保证:3G-4G范围的虚拟地址,使用一张"公共"的映射表,0-3G范围的虚拟地址,为每个进程独立创建一个映射表,即可。
    可能有些人会在这里犯迷惑:问题的起点就是怎么让不同的进程,有公共的内存,而解决方法却要用到一张"公共"的映射表,不是矛盾了吗?
    产生这种问题,是因为没有抓住"开机后内核代码优先执行"这一点,开机后,是内核代码一步步从实模式切换到保护模式、创建了一些公共资源、创建了init进程,init进程又创建了更多的进程,才有的进程切换。

4. i386页式内存管理

    Linux内核笔记001中,把X86 CPU段式内存管理的电路逻辑,按照软件思维理解成一个"API",相应的,段寄存器、GDTR/LDTR寄存器,就相当于是"参数"。同样的,页式内存管理相当于另外一个"API",它的"参数"是:CR0寄存器(最高的PG位是页式映射的开关)、CR3寄存器(指向目录表)、指令中的逻辑地址(页偏移)。
    由于要i386进入页式内存映射的逻辑,它必须先要完成段式内存映射,所以段式管理的"参数"也要设置正确。另外,由于同样的原因,指令中的逻辑地址,要经过2次映射,才能得到物理地址,为了方便整个过程的描述,就将第一次映射得到的结果称为"线性地址",由于它也不是物理地址,从而也属于虚拟地址,这就是为什么一查资料,会同时出现"虚拟地址"、"逻辑地址"、"线性地址"、"物理地址"这么多概念的原因。
  • 线性地址含义设计
    
  • i386 CPU页式映射过程

  • 目录项含义设计
  
        页表项与目录项每个位域的作用,稍有不同,书上已经介绍的很明白了,另外整个过程没有什么理解难度,就不过多笔记了。
  • 为什么要按目录和页分成两级?
        这个设计是为了节省页表占用的内存,随着进程占用内存的增加,可以一页一页的增加页表,否则刚创建进程时,就要分配一个完整的4M页表,而系统中的进程,绝大部分只需要很小的内存,相应的,就只能利用到4M页表中的很小一部分,造成浪费。不过随着生产技术的进步,内存容量已经越来越大,少一步映射,相比浪费点内存,更好。


声明:该文观点仅代表作者本人,转载请注明来自看雪