内核4 虚拟内存 qemu共配置了128M的内存,在之前的代码中,boot被放在了0x7c00处,loader放在了0x8000处,kernel放在了0x10000处(由Cmake来指定),所以还剩下127M左右的内存没有进行使用。操作系统有一个重要职责是决定如何管理计算机中的整块内存。具体的职责有以下几点。 内存中存在多个进程时,加载进程会存在一些问题,比如要将进程加载到内存中的哪个区域,如何分配和回收内存。这时可以用到虚拟内存来对内存空间进行管理。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采用了二级页表的形式。 为了方便起见,我们采用了intel提供的另一种形式。采用这种方式需要打开CR4寄存器的PSE位。
开启内存分页 开启内存分页时需要将第一个页表的地址写入CR3寄存器中,同时开启CR0寄存器的PG位。 我们需要按照上表中的说明将第一个页表进行设置。
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脚本中的设置,将其各处不同的区域进行映射到不同的权限下,例如代码和只读设置只读,其它设置成可读写。且所有这些内存区域都不能允许用户访问。所有这些功能都可使用页表完成。 按照这个表中的内容来设置表项:
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 ; uint32_t write_disable : 1 ; uint32_t user_mode_acc : 1 ; uint32_t write_through : 1 ; uint32_t cache_disable : 1 ; uint32_t accessed : 1 ; uint32_t : 1 ; uint32_t ps : 1 ; uint32_t : 4 ; uint32_t phy_pt_addr : 20 ; }; } pde_t ; typedef union _pte_t { uint32_t v; struct { uint32_t present : 1 ; uint32_t write_disable : 1 ; uint32_t user_mode_acc : 1 ; uint32_t write_through : 1 ; uint32_t cache_disable : 1 ; uint32_t accessed : 1 ; uint32_t dirty : 1 ; uint32_t pat : 1 ; uint32_t global : 1 ; uint32_t : 3 ; uint32_t phy_page_addr : 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[]; 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内核的代码、开关中断、写磁盘等,这个功能可以防止进程恶意修改内核的代码。关于权限的配置位如下: 通过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时可以被应用程序所访问。 在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。