内核4 虚拟内存

qemu共配置了128M的内存,在之前的代码中,boot被放在了0x7c00处,loader放在了0x8000处,kernel放在了0x10000处(由Cmake来指定),所以还剩下127M左右的内存没有进行使用。操作系统有一个重要职责是决定如何管理计算机中的整块内存。具体的职责有以下几点。
1.jpg
内存中存在多个进程时,加载进程会存在一些问题,比如要将进程加载到内存中的哪个区域,如何分配和回收内存。这时可以用到虚拟内存来对内存空间进行管理。x86虚拟内存管理硬件将内存看组成相同大小的页,即分页机制。进程看到的存储空间是连续的,但是经过页表的转换后就会将页帧分配到内存中,即将虚拟内存映射到物理内存中。

内存的分配和释放

位图

我们如何知道内存中的一个页是否被使用呢?显而易见的一个方法就是bitmap,页被使用则在位图相应位置中置一,未被使用则置零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct _bitmap_g
{
int bit_count; // 位的数据
uint8_t *bits; // 位空间
} bitmap_t;

int bitmap_byte_count(int bit_count)
{
return (bit_count + 8 - 1) / 8;
}

void bitmap_init(bitmap_t *bitmap, uint8_t *bits, int count, int init_bit)
{
bitmap->bit_count = count;
bitmap->bits = bits;

int bytes = bitmap_byte_count(bitmap->bit_count);
kernel_memset(bitmap->bits, init_bit ? 0xFF : 0, bytes);
}

这样就完成了位图的初始化。

1
2
3
4
int bitmap_get_bit(bitmap_t *bitmap, int index);    //获取位图中某一个位的值
void bitmap_set_bit(bitmap_t *bitmap, int index, int count, int bit); //设置某一个位
int bitmap_is_set(bitmap_t *bitmap, int index); //判断某个位置零或置一
int bitmap_alloc_nbits(bitmap_t *bitmap, int bit, int count); //连续分配多个指定位

bitmap_alloc_nbits函数传入的是想要将其反转的位,比如想找到连续的几个0置为1,函数中要传入0而不是1。所以实现起来也不是很麻烦。

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
int bitmap_alloc_nbits(bitmap_t *bitmap, int bit, int count)
{
int search_idx = 0;
int ok_idx = -1;

while (search_idx < bitmap->bit_count)
{
if (bitmap_get_bit(bitmap, search_idx) != bit)
{
search_idx++;
continue;
}
ok_idx = search_idx;
int i;
for (i = 1; (i < count) && (search_idx < bitmap->bit_count); i++)
{
if (bitmap_get_bit(bitmap, search_idx++) != bit)
{
ok_idx = -1;
break;
}
}
if (i >= count)
{
bitmap_set_bit(bitmap, ok_idx, count, ~bit);
return ok_idx;
}
}

return -1;
}

地址分配器

1
2
3
4
5
6
7
8
9
typedef struct _addr_alloc_t
{
mutex_t mutex; // 地址分配互斥信号量
bitmap_t bitmap; // 分配用的位图

uint32_t page_size; // 页大小
uint32_t start; // 起始地址
uint32_t size; // 地址大小
} addr_alloc_t;

地址分配器存放了需要分配的内存的起始地址、大小等信息,并为其分配了一个位图。

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
static void addr_alloc_init(addr_alloc_t *alloc, uint8_t *bits,
uint32_t start, uint32_t size, uint32_t page_size)
{
mutex_init(&alloc->mutex);
alloc->start = start;
alloc->size = size;
alloc->page_size = page_size;
bitmap_init(&alloc->bitmap, bits, alloc->size / page_size, 0);
}

static uint32_t addr_alloc_page(addr_alloc_t *alloc, int page_count) //分配内存页
{
uint32_t addr = 0;

mutex_lock(&alloc->mutex);

int page_index = bitmap_alloc_nbits(&alloc->bitmap, 0, page_count);
if (page_index >= 0)
{
addr = alloc->start + page_index * alloc->page_size;
}

mutex_unlock(&alloc->mutex);
return addr;
}

现在这些函数只能计算出分配地址的数值,并没有向对应的内存中写入任何数据。位图怎么和分配内存页配套使用呢?位图记录了一个内存页是否被使用,我们找到连续的未分配的内存页之后,将对应的位图位设置为1并将找到的内存的首地址通过函数返回值的形式返回给调用者,这样就可以分配内存了。

内存分页

我们将1M以上地址处供进程使用,将内存分为页来分配内存空间。在内存初始化中,memory_init传入了bootinfo为参数,包含了磁盘信息(int 13H中断读取磁盘信息)。内存初始化时首先要将分配的内存对齐4KB,然后创建paddr_alloc并初始化,进行管理,位图放在bss段之后。这样就初始化完成了内存,并可以使用paddr_alloc进行管理。我们设计的操作系统是32位,内存地址空间一共有4GB,每个内存页是4KB大小,我们需要使用转换表将每个内存页映射到物理内存上,一个表项处理一页内存,所以表项数量是4GB/4KB,每个表项是4字节,所以表的大小是4MB。但是一个4MB的表项大部分情况下会浪费空间,所以intel采用了二级页表的形式。
2.jpg
3.jpg
为了方便起见,我们采用了intel提供的另一种形式。采用这种方式需要打开CR4寄存器的PSE位。
4.jpg

开启内存分页

开启内存分页时需要将第一个页表的地址写入CR3寄存器中,同时开启CR0寄存器的PG位。
5.jpg
我们需要按照上表中的说明将第一个页表进行设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void enable_page_mode(void)
{
#define PDE_P (1 << 0)
#define PDE_PS (1 << 7)
#define PDE_W (1 << 1)
#define CR4_PSE (1 << 4)
#define CR0_PG (1 << 31)

static uint32_t page_dir[1024] __attribute__((aligned(4096))) = {
[0] = PDE_P | PDE_PS | PDE_W,
};

uint32_t cr4 = read_cr4();
write_cr4(cr4 | CR4_PSE);
write_cr3((uint32_t)page_dir);
write_cr0(read_cr0() | CR0_PG);
}

注意:这个页表只是在loader中进行使用,进入内核后我们需要开启二级分页。在内核中创建新的页表的目的是是根据内核lds脚本中的设置,将其各处不同的区域进行映射到不同的权限下,例如代码和只读设置只读,其它设置成可读写。且所有这些内存区域都不能允许用户访问。所有这些功能都可使用页表完成。
6.jpg
7.jpg
按照这个表中的内容来设置表项:

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
typedef union _pde_t
{
uint32_t v;
struct
{
uint32_t present : 1; // 0 (P) Present; must be 1 to map a 4-KByte page
uint32_t write_disable : 1; // 1 (R/W) Read/write, if 0, writes may not be allowe
uint32_t user_mode_acc : 1; // 2 (U/S) if 0, user-mode accesses are not allowed t
uint32_t write_through : 1; // 3 (PWT) Page-level write-through
uint32_t cache_disable : 1; // 4 (PCD) Page-level cache disable
uint32_t accessed : 1; // 5 (A) Accessed
uint32_t : 1; // 6 Ignored;
uint32_t ps : 1; // 7 (PS)
uint32_t : 4; // 11:8 Ignored
uint32_t phy_pt_addr : 20; // 高20位page table物理地址
};
} pde_t;

typedef union _pte_t
{
uint32_t v;
struct
{
uint32_t present : 1; // 0 (P) Present; must be 1 to map a 4-KByte page
uint32_t write_disable : 1; // 1 (R/W) Read/write, if 0, writes may not be allowe
uint32_t user_mode_acc : 1; // 2 (U/S) if 0, user-mode accesses are not allowed t
uint32_t write_through : 1; // 3 (PWT) Page-level write-through
uint32_t cache_disable : 1; // 4 (PCD) Page-level cache disable
uint32_t accessed : 1; // 5 (A) Accessed;
uint32_t dirty : 1; // 6 (D) Dirty
uint32_t pat : 1; // 7 PAT
uint32_t global : 1; // 8 (G) Global
uint32_t : 3; // Ignored
uint32_t phy_page_addr : 20; // 高20位物理地址
};
} pte_t;

我们可以将二级页表的PDE和PTE设置为一个联合体,并在相应字段填入对应的值。PDE为一级页表,PTE为二级页表。同样的将PDE的第一个表项设置到CR3寄存器中就可以开启分页机制。

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
typedef struct _memory_map_t
{
void *vstart; // 虚拟地址
void *vend;
void *pstart; // 物理地址
uint32_t perm; // 访问权限
} memory_map_t;

void create_kernel_table(void)
{
extern uint8_t s_text[], e_text[], s_data[], e_data[];//在lds脚本中指示了各个段的开始和结束位置
extern uint8_t kernel_base[];

static memory_map_t kernel_map[] = {
{kernel_base, s_text, 0, PTE_W}, // 内核栈区
{s_text, e_text, s_text, 0}, // 内核代码区
{s_data, (void *)(MEM_EBDA_START - 1), s_data, PTE_W}, // 内核数据区
{(void *)CONSOLE_DISP_ADDR, (void *)(CONSOLE_DISP_END - 1), (void *)CONSOLE_VIDEO_BASE, PTE_W},
{(void *)MEM_EXT_START, (void *)MEM_EXT_END, (void *)MEM_EXT_START, PTE_W},
};

// 清空页目录表
kernel_memset(kernel_page_dir, 0, sizeof(kernel_page_dir));
for (int i = 0; i < sizeof(kernel_map) / sizeof(memory_map_t); i++)
{
memory_map_t *map = kernel_map + i;
int vstart = down2((uint32_t)map->vstart, MEM_PAGE_SIZE);
int vend = up2((uint32_t)map->vend, MEM_PAGE_SIZE);
int page_count = (vend - vstart) / MEM_PAGE_SIZE;

memory_create_map(kernel_page_dir, vstart, (uint32_t)map->pstart, page_count, map->perm);
}
}

根据figure4.2可知,pde的地址偏移量是虚拟地址的22-31位,pte的地址偏移量是虚拟地址的12-21位,前12位是地址的偏移量。关于figure4.2,GPT有详细的图解说明如下。(要结合Table 4.5,4.6)

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
59
60
61
62
63
64
65
66
1. 线性地址格式
线性地址由CPU 生成,在开启分页机制后,它需要被转换成物理地址。线性地址由三部分组成:

目录索引(Directory, 10位, 31~22位):用于索引页目录中的页目录项(PDE)。
表索引(Table, 10位, 21~12位):用于索引页表中的页表项(PTE)。
偏移量(Offset, 12位, 11~0位):决定在最终物理页面中的位置。
线性地址的结构如下:

+------------+------------+------------+
| Directory | Table | Offset |
| (10b) | (10b) | (12b) |
+------------+------------+------------+
2. 线性地址到物理地址的转换过程
分页机制采用两级页表映射,转换过程如下:

(1)CR3寄存器
CR3(控制寄存器3) 存储 页目录的物理地址(32位)。
这个地址指向 页目录表(Page Directory),用于管理整个内存空间。
(2)查找页目录(Page Directory)
页目录存储多个PDE(页目录项),每个PDE占4字节(32位)。
通过目录索引(Directory, 10位),选取页目录项(PDE)。
PDE 指向 某个页表(Page Table) 的 物理地址。
(3)查找页表(Page Table)
页表存储多个PTE(页表项),每个PTE占4字节(32位)。
通过表索引(Table, 10位),选取页表项(PTE)。
PTE 指向 4KB物理页面的起始地址。
(4)计算物理地址
物理地址 = PTE提供的物理页基地址 + 偏移量(Offset, 12位)
低12位的Offset用于索引4KB页面中的具体字节。
3. 示例
假设:

CR3 = 0x00100000(指向页目录的物理地址)。
线性地址 = 0x12345678:
目录索引(Directory) = 0x12345678 >> 22 = 0x48
表索引(Table) = 0x12345678 >> 12 & 0x3FF = 0xD5
偏移量(Offset) = 0x12345678 & 0xFFF = 0x678
转换过程:

查找页目录项(PDE):

PDE 地址 = CR3 + (0x48 * 4) = 0x00100000 + 0x120 = 0x00100120
PDE 指向的页表地址 = 0x00200000。
查找页表项(PTE):

PTE 地址 = 0x00200000 + (0xD5 * 4) = 0x00200000 + 0x034 = 0x00200034
PTE 指向的物理页地址 = 0x00345000。
计算物理地址:

物理地址 = 0x00345000 + 0x678 = 0x00345678。
最终,线性地址 0x12345678 映射到 物理地址 0x00345678。

4. 关键点总结
分页机制采用两级页表结构:

页目录(Page Directory)
页表(Page Table)
物理页面(Physical Page)
CR3寄存器存储页目录的物理地址。

地址转换的三步过程:

页目录项(PDE) → 页表项(PTE) → 物理地址计算。
最终的物理地址 由 PTE 指定的 4KB页面的起始地址 加上 12位偏移量 计算得到。

这张图清晰地展示了32位操作系统(如x86架构)分页机制下的地址转换过程,用于虚拟内存管理和地址映射。

根据上述描述,我们可以知道二级分页的基本转换关系了。在vaddr中取出高十位加上CR3寄存器中的值我们可以得到PDE的地址,找到PDE中的高20位加上vaddr中的12-21位就是PTE的地址,找到PTE的高二十位加上偏移量就是物理地址的值。理解这一点很关键,这一点是我们理解和编写后续代码所需要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int memory_create_map(pde_t *page_dir, uint32_t vaddr, uint32_t paddr, int count, uint32_t perm)
{
for (int i = 0; i < count; i++)
{

pte_t *pte = find_pte(page_dir, vaddr, 1);
if (pte == (pte_t *)0)
{
return -1;
}

ASSERT(pte->present == 0);

pte->v = paddr | perm | PTE_P;

vaddr += MEM_PAGE_SIZE;
paddr += MEM_PAGE_SIZE;
}

return 0;
}

这个函数的作用是将指定的虚拟地址范围映射到物理地址空间,并为每个虚拟页面创建对应的页表项,是完成映射的一个核心函数。

内存特权级处理

内核可以通过设置不同的页表权限位来建立内存保护机制,比如在内核代码区必须设置只读和可执行的权限,避免内核代码被恶意修改。对应的权限位可以查看Figure 4.6进行相应的配置。这样特权级隔离了之后用户程序访问内核代码会触发页错误。

为进程分配页表

之前的文章中贴了一张TSS段的结构图,其中有一个CR3段,表明TSS段中可以存放页表的起始地址。我们打开分页机制后运行之前设置的两个进程,CPU会进行重启,原因就是我们还没有给进程分配相应的内存页。进程在进行切换时,CPU会读取TSS段中的CR3信息,将页表的起始地址放在CR3寄存器中。所以我们现在的任务是为创建好的进程分配内存页表。

初始化一个进程时,我们需要获取一页内存页传递到tss的cr3段中。现在我们采用的是二级页表结构,所以可以先分配一个PDE表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
uint32_t memory_create_uvm(void)
{
pde_t *page_dir = (pde_t *)addr_alloc_page(&paddr_alloc, 1);
if (page_dir == 0)
{
return 0;
}
kernel_memset((void *)page_dir, 0, MEM_PAGE_SIZE);

uint32_t user_pde_start = pde_index(MEMORY_TASK_BASE);
for (int i = 0; i < user_pde_start; i++)
{
page_dir[i].v = kernel_page_dir[i].v;
}

return (uint32_t)page_dir;
}

关于这个函数为什么只分配了pde而没有分配pte,本人目前也没有太想清楚。分配好pde后将其和内核映射表对应起来,在创建进程的时候调用这个函数就可以分配页表了,这种设计使得所有用户进程都共享同一份内核地址空间映射。

隔离内核与进程

现在我们创建的两个进程在虚拟地址空间处是与内核混在一起的,代码也是和内核混在一起,现在我们要利用分页机制隔离内核与进程。第一个工作就是将代码从内核中分离,我们可以新建一个C文件将初始进程的代码放入其中来达到代码和内核进行分离的效果。接下来是虚拟地址空间的分离,并要将进程的物理地址放在kernel的后面,这个工作要在kerlen.lds中进行。

1
2
3
4
5
6
7
8
(在bss段之后)
. = 0x80000000;
.first_task : AT(e_data) {
*first_task_entry*(.text .data. rodata .data)
*first_task*(.text .data. rodata .data)
}
(内核代码段加上)
*(EXCLUDE_FILE(*first_task* *lib_syscall*).x)

接下来是为进程分配内存空间。

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
uint32_t memory_alloc_for_page_dir(uint32_t page_dir, uint32_t vaddr, uint32_t size, int perm)
{
uint32_t curr_vaddr = vaddr;
int page_count = up2(size, MEM_PAGE_SIZE) / MEM_PAGE_SIZE;
vaddr = down2(vaddr, MEM_PAGE_SIZE);

// 逐页分配内存,然后建立映射关系
for (int i = 0; i < page_count; i++)
{
uint32_t paddr = addr_alloc_page(&paddr_alloc, 1);
if (paddr == 0)
{
log_printf("mem alloc failed. no memory");
return 0;
}

int err = memory_create_map((pde_t *)page_dir, curr_vaddr, paddr, 1, perm);
if (err < 0)
{
log_printf("create memory map failed. err = %d", err);
addr_free_page(&paddr_alloc, vaddr, i + 1);
return -1;
}

curr_vaddr += MEM_PAGE_SIZE;
}

return 0;
}

这个函数可以完成物理页的分配并将其映射到0x80000000(进程空间)以上的区域。

调整特权级

x86架构有四种特权级,ring0-ring3,其中0为最高特权级,3为最低特权级。3特权级无法任意访问os内核的代码、开关中断、写磁盘等,这个功能可以防止进程恶意修改内核的代码。关于权限的配置位如下:
8.jpg
通过CS段寄存器最低两位表示当前执行代码的特权级(CPL)。访问数据时,DS、ES、FS、GS等段寄存器中的选择子包含RPL(请求特权级),段描述符中的DPL(描述符特权级)表示段存储空间的访问权限。CPU会检查CPL、RPL和DPL,确保访问权限匹配。访问数据段时当且仅当DPL>=Max(CPL,RPL)时才能访问,访问SS时要求CPL=RPL=DPL。每个内存页都有一个保护位U/S,U/S为0时只能被操作系统(CPL=0,1,2)所访问,U/S=1时可以被应用程序所访问。
9.jpg
在task中添加特权级字段,初始化时对tss进行判断,分别设置。

1
2
3
4
5
6
7
8
9
10
if (flag & TASK_FLAG_SYSTEM)
{
code_sel = KERNEL_SELECTOR_CS;
data_sel = KERNEL_SELECTOR_DS;
}
else
{
code_sel = task_manager.app_code_sel | SEG_RPL3;
data_sel = task_manager.app_data_sel | SEG_RPL3;
}

高特权级切换到低特权级

高特权级切换到低特权级需要用iret指令,同时还要向栈中压入一些变量的值。

1
2
3
4
5
6
7
8
9
__asm__ __volatile__(
"push %[ss]\n\t"
"push %[esp]\n\t"
"push %[eflags]\n\t"
"push %[cs]\n\t"
"push %[eip]\n\t"
"iret\n\t" ::[ss] "r"(tss->ss),
[esp] "r"(tss->esp), [eflags] "r"(tss->eflags),
[cs] "r"(tss->cs), [eip] "r"(tss->eip));

这样first task就可以切换到ring3。