从0实现32位操作系统-实现分页机制

从0实现32位操作系统-实现分页机制

本部分内容是本人学习了李述铜老师的操作系统课程之后想巩固知识点的笔记分享。有任何问题都可以在评论区或者直接加我的qq:2511010742联系。我也是在学习的小白,希望可以和大家共同进步。

一、前情回顾

在上一节中我们已经完善了进程任务调度的代码,增加了信号量和锁结构,曾加了第一个以后会改为系统调用的 sleep 函数,基本上可以满足正常的使用。但是,进程是可以随便改变操作系统代码的,这是非常危险的,我们想让进程不能直接调用操作系统的函数,并且还想让不同进程之间能够隔离。接下来我们就来碰一下内存管理的分页机制。

二、分页机制介绍

我们知道我们的系统运行在内存上,内存上很容易出现一些内存碎片,当我们需要一个很大的内存空间时,一般是不能利用这些内存碎片,这就使得内存的利用率降低。

而分页机制则可以很好的解决这个问题,分页机制是计算机系统的一项关键技术,它将整个内存空间划分成固定大小的页面,通常为4KB或更大。这种划分使得操作系统能够更灵活地管理内存资源。每个进程的地址空间也被划分为相同大小的页面,操作系统通过一个称为页表的数据结构来维护逻辑地址到物理地址的映射关系。 分页机制还可以简化内存管理操作,可以更高效的进行内存分配和释放。还可以隔离进程的地址空间,防止他们相互干扰。还可以为多个进程创建相同的页面,使得他们可以共享内存。而且,在物理内存不足时,分页机制还可以通过页面替代算法将不常用的内存页放到磁盘上,这样就可以为新的页面腾出空间。

当然,我们的操作系统并没有实现这些复杂的操作,仅实现了二级页表结构并为不同进程创建了自己的结构。

三、内存管理

1. 初始化位图管理

位图是一种常见的用于标识状态的数据结构,只有0和1两个值,我们使用这种结构来标识一个物理内存页是否被使用。在这里插入图片描述
位图结构的每一个比特位都对应一个内存页,分配某个内存页时就将对应的比特位置1,反之释放则置0.

// 位图结构
typedef struct _bitmap_t {
    int bit_count;
    uint8_t* bits;
}bitmap_t;

void bitmap_init(bitmap_t* bitmap, uint8_t* bits, int count, int init_bit);
int bitmap_byte_count(int bit_count);
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);

// 计算需要的字节数
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);
}

// 获得某个比特位的值
int bitmap_get_bit(bitmap_t* bitmap, int index) {
    return (bitmap->bits[index / 8] & (1 << (index % 8))) ? 1 : 0;
}

// 设置比特位的值
void bitmap_set_bit(bitmap_t* bitmap, int index, int count, int bit) {
    for(int i = 0; (i < count) && (index < bitmap->bit_count); i++, index++) {
        if(bit) {
            bitmap->bits[index / 8] |= (1 << (index % 8));
        } else {
            bitmap->bits[index / 8] &= ~(1 << (index % 8));
        }
    }
}

// 判断某个比特位是否被被设置
int bitmap_is_set(bitmap_t* bitmap, int index) {
    return bitmap_get_bit(bitmap, index) ? 1 : 0;
}

// 分配n个字节
int bitmap_alloc_nbits(bitmap_t * bitmap, int bit, int count) {
    int search_idx = 0;
    int ok_index = -1;

    while(search_idx < bitmap->bit_count) {
        if(bitmap_get_bit(bitmap, search_idx) != bit) {
            search_idx++;
            continue;
        }

        ok_index = search_idx;

        int i;
        for(i = 1; (i < count) && (search_idx < bitmap->bit_count); i++) {
            if(bitmap_get_bit(bitmap, search_idx++) != bit) {
                ok_index = -1;
                break;
            }
        }

        if(i >= count) {
            bitmap_set_bit(bitmap, ok_index, count, ~bit);
            return ok_index;
        }
    }

    return -1;
}

上述为位图结构的一些操作方法。

2. 创建地址分配结构

上面我们只是创建了位图结构,并没有将真正的内存结构与之关联,接下来我们将创建一个内存地址分配结构将内存与位图关联起来。

typedef struct _addr_alloc_t {
    mutex_t mutex;
    bitmap_t bitmap;

    uint32_t start;
    uint32_t size;
    uint32_t page_size;
}addr_alloc_t;

// 初始化
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);
}

我们在操作系统的bss段后面放置我们的位图结构,根据加载内核时向内核传递的boot_info信息可以得到空闲内存的大小,我们将boot_info结构中的内存大小信息加在一起就可以得到空闲大小信息,我们希望不去操作在1MB以下的内存,所以要将这段空闲内存减去1MB,再将这段大小进行调整,调整到4kB整数倍大小,然后将这段内存的起点就设在1MB位置处,以下时代码展示。

void memory_init(boot_info_t* boot_info) {
	// 获得bss之后的地址
    extern uint8_t* mem_free_start;
    log_printf("mem init");

    show_mem_info(boot_info);

	// 代表空闲内存开始处,也将位图放置在此处
    uint8_t* mem_free = (uint8_t*)&mem_free_start;

	// 获得1MB以上的空闲内存大小
    uint32_t mem_up1MB_free = total_mem_size(boot_info) - MEM_EXT_START;
    // 调整到4KB大小
    mem_up1MB_free = down2(mem_up1MB_free, MEM_PAGE_SIZE);
    log_printf("free mem : 0x%x, size : 0x%x", MEM_EXT_START, mem_up1MB_free);

	// 进行内存地址分配,位图放置在mem_free处,内存其实设置在MEM_EXT_START 1MB处,大小为mem_up1MB_free
    addr_alloc_init(&paddr_alloc, mem_free, MEM_EXT_START, mem_up1MB_free, MEM_PAGE_SIZE);
    // 更新空闲内存地址起始处
    mem_free += bitmap_byte_count(paddr_alloc.size / MEM_PAGE_SIZE);

    // 操作系统只在1M之下,80000以上的内存有其他用,要判断位图是否超过80000
    ASSERT(mem_free < (uint8_t*)MEM_EBDA_START);

    create_kernel_table();  // 创建页表,在下面再详细介绍
    mmu_set_page_dir((uint32_t)kernel_page_dir);    // 加载页表到CR3
}

以上介绍了如何将内存页信息绑定到位图中,这样我们就可以利用位图去分配内存页了。

四、分页机制设计

分页机制是非常复杂的,我们要接受一点,就是一旦分页机制启动后,应用程序就看不到机器的物理内存了,取而代之的则是虚拟内存。切记,虚拟内存并不是真正的物理内存,我们的机器是32位的,所以这片虚拟内存大小则为   0 到 2 32 − 1   \ 0到2^{32}-1\,  02321 ,这块虚拟内存空间也是划分为一个个4KB的内存页。操作系统会通过页表将这些在虚拟内存中的连续的内存页,映射到物理内存上,在物理内存上可能就不是连续的,但是目前程序是看不到物理内存的,只能看到虚拟内存,只要虚拟内存满足程序的各种要求就可以。物理内存页是与虚拟内存页一一对应的。这样就可以避免出现大量的物理内存碎片并且提高内存的利用率。

1. 两级页表介绍

当我们使用两级页表时,整个虚拟地址空间被分为两个层次的结构:页目录(Page Directory)和页表(Page Table)。

(1) 页目录(Page Directory)

  • 页目录是一个数组,通常包含1024个页目录项。
  • 每一个页目录项都是32位的,其中高20位是二级页表的基地址,而低12位通常用于标志和控制位。
  • 每个页目录项的高20位都指向一个二级页表

(2) 页表(Page Table)

  • 页表也同样是一个数组,同样是1024个页表项。
  • 每个页表项也是32位的,其中高20位是物理内存页的基地址,低12位通常用于标志和控制位。
  • 每个页表项的高20位都指向一个实际的物理内存页。

2. 虚拟地址解析过程

  • 对于一个给定的32位虚拟地址,高10位用于索引页目录,中间10位用于索引页表,低12位用作地址偏移。
  • 页目录一共有1024个元素,虚拟地址高10位刚好可以保证全部索引,可以在页目录表中找到对应的页目录项。
  • 再根据此页目录项的高20位找到对应的二级页表的基地址。
  • 根据虚拟地址的中间10位,可以索引到对应的页表项。
  • 再根据页表项的高20位找到对应的物理页基地址。
  • 最后根据虚拟地址的后12位就可以得到最终的物理地址。

一个物理页大小位4KB,虚拟地址的低12位刚好可以索引此物理页全部的内存。

3. 代码实现

页目录和页表都是具有1024个元素的数组结构,下面定义他们的结构

// 页目录结构
typedef union _pde_t {
    uint32_t v;
    struct {
        uint32_t present : 1;       // 是否有效
        uint32_t write_enable : 1;  // 1 写,0 不写
        uint32_t user_mode_acc : 1; // 1用户也可以访问,0普通用户不可访问
        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_enable : 1;  // 1 写,0 不写
        uint32_t user_mode_acc : 1; // 1用户也可以访问,0普通用户不可访问
        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_CNT为1024,并将这个表对齐到4KB处
static pde_t kernel_page_dir[PDE_CNT] __attribute__((aligned(MEM_PAGE_SIZE)));

建立映射

// 页目录表  虚拟地址  物理地址  页块数量  属性
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++) {
        // log_printf("create map: v-0x%x, p-0x%x, perm:0x%x", vaddr, paddr, perm);

        // 根据虚拟内存寻找对应的 pte表
        pte_t* pte = find_pte(page_dir, vaddr, 1);
        if(pte == (pte_t*)0) {
            log_printf("create pte failed.pte == 0");
            return -1;
        }

        // log_printf("pte addr:0x%x", (uint32_t)pte);

        ASSERT(pte->present == 0);
        // 设置pte页表,将物理地址以及属性设置到 pte中。
        pte->v = paddr | perm | PTE_P;
		
		// 更新内存地址用于创建下一个页
        vaddr += MEM_PAGE_SIZE;
        paddr += MEM_PAGE_SIZE;
    }
}

pte_t* find_pte(pde_t* page_dir, uint32_t vaddr, int alloc) {
    pte_t* page_table;
    
    // 先根据 vaddr找到对应的 pde项
    // pde_index返回对应的 vaddr位于哪一个 pde项
    pde_t* pde = page_dir + pde_index(vaddr);

    if(pde->present) {	// 如果此 pde项存在
    	// 就可以找到对应的 pte表项
        page_table = (pte_t*)pde_paddr(pde);        
    } else {
        if(alloc == 0) {
            return (pte_t*)0;
        }

        // 创建这个页目录表结构
        uint32_t pg_paddr = addr_alloc_page(&paddr_alloc, 1);
        if(pg_paddr == 0) {
            return (pte_t*)0;
        }

        // 设置属性
        // 将 pte的基地址赋给 pde
        pde->v = pg_paddr | PDE_P | PDE_W | PDE_U;

        page_table = (pte_t*)pg_paddr;
        kernel_memset(page_table, 0, MEM_PAGE_SIZE);
    }
	
	// 返回对应的 pte项
    return page_table + pte_index(vaddr);
}

以上实现了如何将虚拟地址映射到物理地址上,下面我们去调用这些方法,为了方便创建一个内存映射结构

typedef struct _memory_map_t {
    void* vstart;			// 虚拟地址起始
    void* vend;				// 虚拟地址结束
    void* pstart;			// 物理地址起始
    uint32_t perm;			// 属性
}memory_map_t;

接下来为内核创建这种映射页表结构
我们将操作系统内核这部分直接映射到物理地址同样的位置,并且希望代码段和只读数据段设置为只读,data和bss段设置为可读写。

void create_kernel_table(void) {
	// 只读数据段开始地址、结束地址,读写数据段开始地址,内核基地址。			
    extern uint8_t s_text[], e_text[], s_data[], kernel_base[];

	// 创建内存映射表
    static memory_map_t kernel_map[] = {
        // 0-10000映射到物理内存0-10000,PTE_W为可读写
        {kernel_base, s_text, kernel_base, PTE_W},
        // 10000-代码段和只读数据段结尾,属性为0在这里可以保证为只读
        {s_text, e_text, s_text, 0},
        // 数据段-80000地址处映射到物理地址相同处,可读写。
        {s_data, (void*)MEM_EBDA_START, s_data, PTE_W},
        // 1MB-127MB映射到物理地址相同处,可读写。
        {(void*)MEM_EXT_START, (void*)MEM_EXT_END, (void*)MEM_EXT_START, PTE_W},
    };

	// 根据上面映射信息,逐内存页建立映射。
    for(int i = 0; i < sizeof(kernel_map) / sizeof(memory_map_t); i++) {
        memory_map_t* map = kernel_map + i;

        uint32_t vstart = down2((uint32_t)map->vstart, MEM_PAGE_SIZE);
        uint32_t vend = up2((uint32_t)map->vend, MEM_PAGE_SIZE);
        uint32_t paddr = down2((uint32_t)map->pstart, MEM_PAGE_SIZE);
        int page_count = (vend - vstart) / MEM_PAGE_SIZE;

        // 建立地址映射
        memory_create_map(kernel_page_dir, vstart, paddr, page_count, map->perm);
    }
}

在x86架构下,启用或禁用分页机制(包括二级分页)涉及到对控制寄存器 CR0 进行设置。对于二级分页,需要设置 CR0 寄存器的相应标志位。将 CR0 寄存器的 PG 位设置位1就可以启用分页机制。再将页目录表的物理地址设置到 CR3 寄存器中,就实现了二级页表机制的开启。

mov eax, cr0; 				将 CR0 寄存器的值加载到 eax 寄存器
or eax, 0x80000000;			设置 PG 位为 1
mov cr0, eax; 				将修改后的值写回 CR0 寄存器

mov eax, [页目录表的物理地址];将页目录表的物理地址加载到 eax 寄存器
mov cr3, eax; 				将 eax 寄存器中的值写入 CR3 寄存器

// 以下是c语言函数实现
uint32_t cr0 = read_cr0();
write_cr0(cr0 | CR0_PG);
mmu_set_page_dir((uint32_t)kernel_page_dir);    // 加载页表到CR3

在这里插入图片描述
我们在代码中建立了四个映射结构,都显示在这张图中。

五、完善进程的分页操作

在开启分页操作之后,每个任务进程都需要创建自己二级分页结构。主要原因是实现进程之间的内存隔离和独立性。每个进程都有自己的地址空间,包括代码段、数据段、堆、栈等,而这些地址空间需要映射到物理内存中。通过为每个进程创建独立的页表结构,操作系统可以有效地管理进程的内存,并确保一个进程不能直接访问另一个进程的私有内存。

在本节中我们只实现进程的二级分页结构实现,进程地址隔离和特权级相关的操作,我们在下一篇中讲解。

大家还记得我们的任务初始化吗?哈哈,是不是很久远了,我带大家熟悉一下。我们的进程任务切换是利用的 TSS 任务状态段进行的,之前初始化 tss 时并没有对 CR3 寄存器进行操作。以下是之前的 tss 初始化。这部分的入口在这里

// 初始化tss信息
static int tss_init(task_t* task, uint32_t entry, uint32_t esp) {
    int tss_sel = gdt_alloc_desc();	// 在GDT表中找到一个空闲的描述符
    if(tss_sel < 0) {
        log_printf("alloc tss failed.\n");
        return -1;
    }
    
    // 对段信息进行设置,选择子为tss选择子,基地址为每个任务task结构体中的tss结构体
    // 段存在   最高优先级   TSS段描述符模式
    segment_desc_set(tss_sel, (uint32_t)&task->tss, sizeof(tss_t),
        SEG_P_PRESENT | SEG_DPL0 | SEG_TYPR_TSS);

	// 将tss信息清零
    kernel_memset(&task->tss, 0, sizeof(tss_t));
    task->tss.eip = entry;	// tss.eip设置为任务的入口地址
    task->tss.esp = task->tss.esp0 = esp;   // 特权及0,所以是esp0
    task->tss.ss = task->tss.ss0 = KERNEL_SELECTOR_DS;	// 数据段
    task->tss.es = task->tss.ds = task->tss.fs = task->tss.gs = KERNEL_SELECTOR_DS;
    task->tss.cs = KERNEL_SELECTOR_CS;	// 代码段
    task->tss.eflags = EFLAGS_IF | EFLAGS_DEFAULT;

    task->tss_sel = tss_sel;
    return 0;
}

可以看到并没有设置分页相关的程序,TSS 结构中通常包含了一个字段,用于存储任务切换时需要加载的页目录表的物理地址。这个字段就是 TSS 的 cr3 字段。

以下我们在这段程序中加入了创建页表结构相关的代码

// 初始化tss信息
static int tss_init(task_t* task, uint32_t entry, uint32_t esp) {
    int tss_sel = gdt_alloc_desc();	// 在GDT表中找到一个空闲的描述符
    if(tss_sel < 0) {
        log_printf("alloc tss failed.\n");
        return -1;
    }
    
    // 对段信息进行设置,选择子为tss选择子,基地址为每个任务task结构体中的tss结构体
    // 段存在   最高优先级   TSS段描述符模式
    segment_desc_set(tss_sel, (uint32_t)&task->tss, sizeof(tss_t),
        SEG_P_PRESENT | SEG_DPL0 | SEG_TYPR_TSS);

	// 将tss信息清零
    kernel_memset(&task->tss, 0, sizeof(tss_t));
    task->tss.eip = entry;	// tss.eip设置为任务的入口地址
    task->tss.esp = task->tss.esp0 = esp;   // 特权及0,所以是esp0
    task->tss.ss = task->tss.ss0 = KERNEL_SELECTOR_DS;	// 数据段
    task->tss.es = task->tss.ds = task->tss.fs = task->tss.gs = KERNEL_SELECTOR_DS;
    task->tss.cs = KERNEL_SELECTOR_CS;	// 代码段
    task->tss.eflags = EFLAGS_IF | EFLAGS_DEFAULT;

	/* 进程页表相关的内容 */
    uint32_t page_dir = memory_create_uvm();
    if(page_dir == 0) {
        // 创建失败的话,则应该释放这个段
        gdt_free_sel(tss_sel);
        return -1;
    }
    task->tss.cr3 = page_dir;

    task->tss_sel = tss_sel;
    return 0;
}

可以看出我们创建了一个 page_dir 表,并将其设置到了 TSS 的 CR3 寄存器中。是通过下面方法实现的

uint32_t memory_create_uvm(void) {
	// 分配一个 pde项
    pde_t* page_dir = (pde_t*)addr_alloc_page(&paddr_alloc, 1);
    if(page_dir == 0) {
        return 0;
    }
	
	// 将这个 pde项清零
    kernel_memset((void*)page_dir, 0, MEM_PAGE_SIZE);
    // 我们的进程地址空间的地位设置成与操作系统相同,我们的操作系统放在低位
    // 在进程任务基地址以下的地址直接复用内核的页表结构。
    uint32_t user_pde_start = pde_index(MEM_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;
}

这样,就可以为每个进程创建属于自己的分页机制了。触发任务切换的事件,例如任务切换指令或中断,当触发任务切换时,CPU 会自动保存当前任务的上下文到当前 TSS,并加载新任务的上下文。新任务的 TSS 中包含了新任务的页目录表的物理地址,在任务切换时,CPU 会自动将新任务的页目录表的物理地址加载到 CR3 寄存器中,实现页表的切换。

总结

本节实现了操作系统的分页机制,使得内核和进程都能有属于自己的地址空间,还有一些任务需要去完善,我们还没有设置特权级,也没有进行进程地址隔离。在下一节的内容中将去做这些工作。希望这篇博客能够给大家带来帮助,希望与大家一同成长。

  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值