物理内存管理

物理内存管理机制

虚拟内存

虚拟内存存在的意义:抽象,隔离,移植。MCU这类单进程的,一般用物理地址。

现代操作系统肯定要支持多任务,同时运行多个进程。如果直接为进程分配物理内存,就要面临诸多问题:每个进程分配多少物理内存?分配少不够用,分配多则没几个进程物理内存就耗尽了。如果动态分配物理内存,则进程拿到的内存在空间上不连续,系统还要解决碎片的问题。还有安全问题,怎么保证一个进程不越界访问其它进程甚至是操作系统自己的内存,一个程序的错误可能导致整个系统的错误。

虚拟内存系统,是对内存系统和文件系统(可选)的抽象。

将内存资源进行了抽象:可以按需为进程分配 page,甚至可以把长时间不用的 page 缓存在磁盘上节省物理内存;由于每个进程有自己的空间,做到了很好的隔离。而每个进程都以为自己有连续的内存空间可以使用。

物理内存不够的情况下,利用磁盘系统,虽然性能会降低,但是不至于程序无法运行;而增加新的物理内存,就能使程序性能提升。

保护

80X86支持两类保护:

  1. 任务之间保护
    80X86 使用的方法是通过把每个任务放置在不同的虚拟地址空间中,并给予每个任务不同的逻辑地址到物理地址的变换映射。每个任务中的地址变换功能被定义成一个任务中的逻辑地址映射到物理内存的一部分区域,而另一个任务中的逻辑地址映射到物理内存中的不同区域中。这样,因为一个任务不可能生成能够映射到其他任务逻辑地址对应使用的物理内存部分,所以所有任务都被隔绝开了。

  2. 特权级保护
    特权级 0 是最高的特权级别,用于可靠性最高的程序.
    通常,操作系统是为所有的程序服务的,它的可靠性最高,并且操作系统要负责对软硬件的控制,所以操作系统的主体必须拥有特权级 0.

    特权级 1, 2 是次于最高的特权级别(特权级 2 的特权级低于特权级 1),用于可靠性不如操作系统(或说是内核 Kernel)的系统服务程序.
    比较典型的是设备驱动程序.

    特权级 3是最低的特权级别,用于可靠性最低的程序.
    应用程序的可靠性被视为最低的,通常不需要直接访问硬件和一些敏感的系统资源,调用设备驱动程序或操作系统实例能完成绝大多数工作.

分段分页

分段机制把逻辑地址转换成线性地址,而分页则把线性地址转换成物理地址。

双重映射其实是毫无必要的,也使映射的过程变得不容易理解。

分页 分段
目的 页是信息的物理单位,分页是为实现离散分配方式,以减少内存的外零头,提高内存的利用率。或者说,分页仅仅是由于系统管理的需要而不是用户的需要 是信息的逻辑单位,它含有一组其意义相对完整的信息。分段的目的是为了能更好地满足用户的需要。
长度 页的大小固定且由系统决定,由系统把逻辑地址化分为页号和页内地址两部分,由机器硬件实现,因而在系统中只能有一种大小的页面 段的长度不固定,决定于用户编写的程序,通常由编译程序在对流程序进行编译时,根据信息的性质来划分
地址空间 作业地址空间是一维的,即单一的线性地址空间,程序员只需要利用一个记忆符,即可表示一个地址。 作业地址空间是二维的,程序员在标识一个地址时,即需给出段名,又需给出段内地址
碎片 有内部碎片,无外部碎片 有外部碎片,无内部碎片
共享和动态链表 不容易实现 容易实现

img

图 4 段页式管理总体框架图

​ 分段 分页
​ ↓​ ↓
逻辑地址 - 线性地址(虚拟地址) - 物理地址

img

图 5 分页机制管理

多级页表

避免把全部页表一直保存在内存中是多级页表的关键所在。特别是那些不需要的页表就不应该保留。
通过一个顶级页表为真正有用的页表提供索引,这是我所理解的二级页表的本质。

32位计算机,4GB内存,每页大小4KB($2^{12}$),需要$2^{20}$个页表项。
每页4KB($2^{12}$)需要用到$2^{8}$个页来存放这些页表项。一般计算机有1M的cache存放这些页表项。

img

对应用程序来说段选择符是作为指针变量的一部分而可见,但选择符的值通常由链接编辑器或链接加载器进行设置或修改,而非应用程序

物理内存管理步骤

探测系统物理内存布局

一般来说,获取内存大小的方法由 BIOS 中断调用和直接探测两种。
BIOS 中断调用方法是一般只能在实模式下完成,而直接探测方法必须在保护模式下完成。通过 BIOS 中断获取内存布局有三种方式,都是基于INT 15h中断,分别为88h e801h e820h。但是并非在所有情况下这三种方式都能工作。在 Linux kernel 里,采用的方法是依次尝试这三种方法。而在本实验中,我们通过e820h中断获取内存信息。因为e820h中断必须在实模式下使用,所以我们在 bootloader 进入保护模式之前调用这个 BIOS 中断,并且把 e820 映射结构保存在物理地址0x8000处。

实现物理内存探测

物理内存探测是在bootasm.S中实现的,相关代码很短,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
probe_memory:
//对0x8000处的32位单元清零,即给位于0x8000处的
//struct e820map的成员变量nr_map清零
movl $0, 0x8000
xorl %ebx, %ebx
//表示设置调用INT 15h BIOS中断后,BIOS返回的映射地址描述符的起始地址
movw $0x8004, %di
start_probe:
movl $0xE820, %eax // INT 15的中断调用参数
//设置地址范围描述符的大小为20字节,其大小等于struct e820map的成员变量map的大小
movl $20, %ecx
//设置edx为534D4150h (即4个ASCII字符“SMAP”),这是一个约定
movl $SMAP, %edx
//调用int 0x15中断,要求BIOS返回一个用地址范围描述符表示的内存段信息
int $0x15
//如果eflags的CF位为0,则表示还有内存段需要探测
jnc cont
//探测有问题,结束探测
movw $12345, 0x8000
jmp finish_probe
cont:
//设置下一个BIOS返回的映射地址描述符的起始地址
addw $20, %di
//递增struct e820map的成员变量nr_map
incl 0x8000
//如果INT0x15返回的ebx为零,表示探测结束,否则继续探测
cmpl $0, %ebx
jnz start_probe
finish_probe:

上述代码正常执行完毕后,在0x8000地址处保存了从BIOS中获得的内存分布信息,此信息按照struct e820map的设置来进行填充。这部分信息将在bootloader启动ucore后,由ucore的page_init函数来根据struct e820map的memmap(定义了起始地址为0x8000)来完成对整个机器中的物理内存的总体管理。

BIOS通过系统内存映射地址描述符(Address Range Descriptor)格式来表示系统物理内存布局,其具体表示如下:

1
2
3
4
Offset  Size    Description
00h 8字节 base address #系统内存块基地址
08h 8字节 length in bytes #系统内存大小
10h 4字节 type of address range #内存类型

看下面的(Values for System Memory Map address type)

1
2
3
4
5
6
Values for System Memory Map address type:
01h memory, available to OS
02h reserved, not available (e.g. system ROM, memory-mapped device)
03h ACPI Reclaim Memory (usable by OS after reading ACPI tables)
04h ACPI NVS Memory (OS is required to save this memory between NVS sessions)
other not defined yet -- treat as Reserved

INT15h BIOS中断的详细调用参数:

1
2
3
4
5
eax:e820h:INT 15的中断调用参数;
edx:534D4150h (即4个ASCII字符“SMAP”) ,这只是一个签名而已;
ebx:如果是第一次调用或内存区域扫描完毕,则为0。 如果不是,则存放上次调用之后的计数值;
ecx:保存地址范围描述符的内存大小,应该大于等于20字节;
es:di:指向保存地址范围描述符结构的缓冲区,BIOS把信息写入这个结构的起始地址。

此中断的返回值为:

1
2
3
4
5
6
7
8
9
10
11
eflags的CF位:若INT 15中断执行成功,则不置位,否则置位;

eax:534D4150h ('SMAP') ;

es:di:指向保存地址范围描述符的缓冲区,此时缓冲区内的数据已由BIOS填写完毕

ebx:下一个地址范围描述符的计数地址

ecx :返回BIOS往ES:DI处写的地址范围描述符的字节大小

ah:失败时保存出错代码

这样,我们通过调用INT 15h BIOS中断,递增di的值(20的倍数),让BIOS帮我们查找出一个一个的内存布局entry,并放入到一个保存地址范围描述符结构的缓冲区中,供后续的ucore进一步进行物理内存管理。这个缓冲区结构定义在memlayout.h中:

1
2
3
4
5
6
7
8
struct e820map {
int nr_map;
struct {
long long addr;
long long size;
long type;
} map[E820MAX];
};

链接地址/虚地址/物理地址/加载地址以及edata/end/text的含义

链接脚本简介

ucore kernel各个部分由组成kernel的各个.o或.a文件构成,且各个部分在内存中地址位置由ld工具根据kernel.ld链接脚本(linker script)来设定。ld工具使用命令-T指定链接脚本。链接脚本主要用于规定如何把输入文件(各个.o或.a文件)内的section放入输出文件(lab2/bin/kernel,即ELF格式的ucore内核)内, 并控制输出文件内各部分在程序地址空间内的布局。下面简单分析一下/lab2/tools/kernel.ld,来了解一下ucore内核的地址布局情况。kernel.ld的内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/* Simple linker script for the ucore kernel.
See the GNU ld 'info' manual ("info ld") to learn the syntax. */

OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(kern_entry)

SECTIONS {
/* Load the kernel at this address: "." means the current address */
. = 0xC0100000;

.text : {
*(.text .stub .text.* .gnu.linkonce.t.*)
}

PROVIDE(etext = .); /* Define the 'etext' symbol to this value */

.rodata : {
*(.rodata .rodata.* .gnu.linkonce.r.*)
}

/* Include debugging information in kernel memory */
.stab : {
PROVIDE(__STAB_BEGIN__ = .);
*(.stab);
PROVIDE(__STAB_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}

.stabstr : {
PROVIDE(__STABSTR_BEGIN__ = .);
*(.stabstr);
PROVIDE(__STABSTR_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}

/* Adjust the address for the data segment to the next page */
. = ALIGN(0x1000);

/* The data segment */
.data : {
*(.data)
}

PROVIDE(edata = .);

.bss : {
*(.bss)
}

PROVIDE(end = .);

/DISCARD/ : {
*(.eh_frame .note.GNU-stack)
}
}

其实从链接脚本的内容,可以大致猜出它指定告诉链接器的各种信息:

  • 内核加载地址:0xC0100000
  • 入口(起始代码)地址: ENTRY(kern_entry)
  • cpu机器类型:i386

其最主要的信息是告诉链接器各输入文件的各section应该怎么组合:应该从哪个地址开始放,各个section以什么顺序放,分别怎么对齐等等,最终组成输出文件的各section。除此之外,linker script还可以定义各种符号(如.text、.data、.bss等),形成最终生成的一堆符号的列表(符号表),每个符号包含了符号名字,符号所引用的内存地址,以及其他一些属性信息。符号实际上就是一个地址的符号表示,其本身不占用的程序运行的内存空间。

链接地址/加载地址/虚地址/物理地址

ucore 设定了ucore运行中的虚地址空间,具体设置可看 lab2/kern/mm/memlayout.h 中描述的”Virtual memory map “图,可以了解虚地址和物理地址的对应关系。lab2/tools/kernel.ld描述的是执行代码的链接地址(link_addr),比如内核起始地址是0xC0100000,这是一个虚地址。所以我们可以认为链接地址等于虚地址。在ucore建立内核页表时,设定了物理地址和虚地址的虚实映射关系是:

phy addr + 0xC0000000 = virtual addr

即虚地址和物理地址之间有一个偏移。但boot loader把ucore kernel加载到内存时,采用的是加载地址(load addr),这是由于ucore还没有运行,即还没有启动页表映射,导致这时采用的寻址方式是段寻址方式,用的是boot loader在初始化阶段设置的段映射关系,其映射关系(可参看bootasm.S的末尾处有关段描述符表的内容)是:

linear addr = phy addr = virtual addr

查看 bootloader的实现代码 bootmain::bootmain.c

readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);

这里的ph->p_va=0xC0XXXXXX,就是ld工具根据kernel.ld设置的链接地址,且链接地址等于虚地址。考虑到ph->p_va & 0xFFFFFF == 0x0XXXXXX,所以bootloader加载ucore kernel的加载地址是0x0XXXXXX, 这实际上是ucore内核所在的物理地址。简言之: OS的链接地址(link addr) 在tools/kernel.ld中设置好了,是一个虚地址(virtual addr);而ucore kernel的加载地址(load addr)在boot loader中的bootmain函数中指定,是一个物理地址。

小结一下,ucore内核的链接地址==ucore内核的虚拟地址;boot loader加载ucore内核用到的加载地址==ucore内核的物理地址。

edata/end/text的含义

在基于ELF执行文件格式的代码中,存在一些对代码和数据的表述,基本概念如下:

  • BSS段(bss segment):指用来存放程序中未初始化的全局变量的内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。
  • 数据段(data segment):指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
  • 代码段(code segment/text segment):指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。

在lab2/kern/init/init.c的kern_init函数中,声明了外部全局变量:

1
extern char edata[], end[];

但搜寻所有源码文件*.[ch],没有发现有这两个变量的定义。那这两个变量从哪里来的呢?其实在lab2/tools/kernel.ld中,可以看到如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

.text : {
*(.text .stub .text.* .gnu.linkonce.t.*)
}

.data : {
*(.data)
}

PROVIDE(edata = .);

.bss : {
*(.bss)
}

PROVIDE(end = .);

这里的“.”表示当前地址,“.text”表示代码段起始地址,“.data”也是一个地址,可以看出,它即代表了代码段的结束地址,也是数据段的起始地址。类推下去,“edata”表示数据段的结束地址,“.bss”表示数据段的结束地址和BSS段的起始地址,而“end”表示BSS段的结束地址。

这样回头看kerne_init中的外部全局变量,可知edata[]和 end[]这些变量是ld根据kernel.ld链接脚本生成的全局变量,表示相应段的起始地址或结束地址等,它们不在任何一个.S、.c或.h文件中定义。

自映射机制

这是扩展知识。 上一小节讲述了通过boot_map_segment函数建立了基于一一映射关系的页目录表项和页表项,这里的映射关系为:

virtual addr (KERNBASE~KERNBASE+KMEMSIZE) = physical_addr (0~KMEMSIZE)

这样只要给出一个虚地址和一个物理地址,就可以设置相应PDE和PTE,就可完成正确的映射关系。

如果我们这时需要按虚拟地址的地址顺序显示整个页目录表和页表的内容,则要查找页目录表的页目录表项内容,根据页目录表项内容找到页表的物理地址,再转换成对应的虚地址,然后访问页表的虚地址,搜索整个页表的每个页目录项。这样过程比较繁琐。

我们需要有一个简洁的方法来实现这个查找。ucore做了一个很巧妙的地址自映射设计,把页目录表和页表放在一个连续的4MB虚拟地址空间中,并设置页目录表自身的虚地址<–>物理地址映射关系。这样在已知页目录表起始虚地址的情况下,通过连续扫描这特定的4MB虚拟地址空间,就很容易访问每个页目录表项和页表项内容。

具体而言,ucore是这样设计的,首先设置了一个常量(memlayout.h):

VPT=0xFAC00000, 这个地址的二进制表示为:

1111 1010 1100 0000 0000 0000 0000 0000

高10位为1111 1010 11,即10进制的1003,中间10位为0,低12位也为0。在pmm.c中有两个全局初始化变量

pte_t const vpt = (pte_t )VPT;

pde_t const vpd = (pde_t )PGADDR(PDX(VPT), PDX(VPT), 0);

并在pmm_init函数执行了如下语句:

boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W;

这些变量和语句有何特殊含义呢?其实vpd变量的值就是页目录表的起始虚地址0xFAFEB000,且它的高10位和中10位是相等的,都是10进制的1003。当执行了上述语句,就确保了vpd变量的值就是页目录表的起始虚地址,且vpt是页目录表中第一个目录表项指向的页表的起始虚地址。此时描述内核虚拟空间的页目录表的虚地址为0xFAFEB000,大小为4KB。页表的理论连续虚拟地址空间0xFAC00000~0xFB000000,大小为4MB。因为这个连续地址空间的大小为4MB,可有1M个PTE,即可映射4GB的地址空间。

但ucore实际上不会用完这么多项,在memlayout.h中定义了常量

#define KMEMSIZE 0x38000000

表示ucore只支持896MB的物理内存空间,这个896MB只是一个设定,可以根据情况改变。则最大的内核虚地址为常量

#define KERNTOP (KERNBASE + KMEMSIZE)=0xF8000000

所以最大内核虚地址KERNTOP的页目录项虚地址为

vpd+0xF8000000/0x400000=0xFAFEB000+0x3E0=0xFAFEB3E0

最大内核虚地址KERNTOP的页表项虚地址为:

vpt+0xF8000000/0x1000=0xFAC00000+0xF8000=0xFACF8000

在pmm.c中的函数print_pgdir就是基于ucore的页表自映射方式完成了对整个页目录表和页表的内容扫描和打印。注意,这里不会出现某个页表的虚地址与页目录表虚地址相同的情况。

print_pgdir函数使得 ucore 具备和 qemu 的info pg相同的功能,即print pgdir能 够从内存中,将当前页表内有效数据(PTE_P)印出来。拷贝出的格式如下所示:

1
2
3
4
5
PDE(0e0)  c0000000-f8000000  38000000  urw
|-- PTE(38000) c0000000-f8000000 38000000 -rw
PDE(001) fac00000-fb000000 00400000 -rw
|-- PTE(000e0) faf00000-fafe0000 000e0000 urw
|-- PTE(00001) fafeb000-fafec000 00001000 -rw

上面中的数字包括括号里的,都是十六进制。

主要的功能是从页表中将具备相同权限的 PDE 和 PTE 项目组织起来。比如上表中:

1
PDE(0e0) c0000000-f8000000 38000000 urw

• PDE(0e0):0e0表示 PDE 表中相邻的 224 项具有相同的权限; • c0000000-f8000000:表示 PDE 表中,这相邻的两项所映射的线性地址的范围; • 38000000:同样表示范围,即f8000000减去c0000000的结果; • urw:PDE 表中所给出的权限位,u表示用户可读,即PTE_U,r表示PTE_P,w表示用 户可写,即PTE_W。

1
PDE(001) fac00000-fb000000 00400000 -rw

表示仅 1 条连续的 PDE 表项具备相同的属性。相应的,在这条表项中遍历找到 2 组 PTE 表项,输出如下:

1
2
|-- PTE(000e0) faf00000-fafe0000 000e0000 urw
|-- PTE(00001) fafeb000-fafec000 00001000 -rw

注意:

  1. PTE 中输出的权限是 PTE 表中的数据给出的,并没有和 PDE 表中权限做与运算。 2. 整个print_pgdir函数强调两点:第一是相同权限,第二是连续。 3. print_pgdir中用到了vpt和vpd两个变量。可以参 考VPT和PGADDR两个宏。

自映射机制还可方便用户态程序访问页表。因为页表是内核维护的,用户程序很难知道自己页表的映射结构。VPT 实际上在内核地址空间的,我们可以用同样的方式实现一个用户地址空间的映射(比如 pgdir[UVPT] = PADDR(pgdir) | PTE_P | PTE_U,注意,这里不能给写权限,并且 pgdir 是每个进程的 page table,不是 boot_pgdir),这样,用户程序就可以用和内核一样的 print_pgdir 函数遍历自己的页表结构了。

坚持原创技术分享,您的支持将鼓励我继续创作!