0. 启动代码修复
之前在entry.S处理时遗漏了两个功能:BSS段清零和进入Trap时原有栈地址保存。BSS段清零工作比较简单,就是将kernel.ld链接脚本中定义的BSS段起始和结尾地址之间循环清零即可:
_entry: # 设置各个核启动时栈地址 la sp, __stack_start li a0, 4096 csrr a1, mhartid addi a1, a1, 1 mul a0, a0, a1 add sp, sp, a0 # 清零BSS段 la t1, __bss_start la t2, __bss_end bss_clear_loop: sd zero, 0(t1) addi t1, t1, 8 blt t1, t2, bss_clear_loop call init loop: j loop
进入Trap时原有栈地址保存在scratch寄存器中,结束时再恢复到sp栈寄存器中:
# S模式中断和异常入口. .globl supervisor_trap_entry .align 4 supervisor_trap_entry: # 保存原始的栈地址到scratch寄存器中 csrw sscratch, sp # 设置S模式异常处理时栈地址 la sp, __stack_start li a0, 4096 li a1, 10 mul a0, a0, a1 add sp, sp, a0 # 调用C入口 call supervisor_trap # 恢复原始栈地址 csrr sp, sscratch sret
1. 实现内核简单libc接口
设置MMU时需要申请页表并清零,所以需要实现memset这类基础的接口,直接参考xv6相关代码将几个libc接口实现:
2. 实现VMM功能
2.1 系统VMM标志和MMU标志转换
地址映射时需要的是OS定义的标志,比如是否可读可写等等,使用时再转换成架构MMU相关的标志:
对OS VMM标志做简单的说明:
- VMM_FLAG_NONE:取消地址映射关系。
- VMM_FLAG_KTEXT:内核代码,可读可执行,只能内核态访问。
- VMM_FLAG_UTEXT:用户代码,可读可执行,用户态也可以访问。
- VMM_FLAG_KDATA:内核数据,可读写,只能内核态访问。
- VMM_FLAG_UDATA:用户数据,可读写,用户态也可以访问。
- VMM_FLAG_UNCACHE:不带Cache数据,可读写,只能内核态访问。
- VMM_FLAG_DEVICE:设备IO,可读写,只能内核态访问。
转换时依次检测OS VMM对应的标志位,然后设置架构MMU对应的位即可:
// 将OS的VMM标志转换为RISCV MMU标志 // A位和R位要同时设置 // D位和W位要同时设置 static u64 mmu_flag_os2rv(u32 flag) { u64 rv_flag = RV_MMU_GLOBAL; if (flag & MMU_FLAG_VALID) { rv_flag |= RV_MMU_VALID; } if (flag & MMU_FLAG_KACCESS) { rv_flag |= RV_MMU_ACCESS; } if (flag & MMU_FLAG_UACCESS) { rv_flag |= RV_MMU_USER; } if (flag & MMU_FLAG_READ) { rv_flag |= RV_MMU_READ; } if (flag & MMU_FLAG_WRITE) { rv_flag |= RV_MMU_WRITE | RV_MMU_DIRTY; } if (flag & MMU_FLAG_EXEC) { rv_flag |= RV_MMU_EXEC; } if (flag & MMU_FLAG_CACHE) { rv_flag |= RV_MMU_CACHE | RV_MMU_BUFFER; } if (flag & MMU_FLAG_DEVICE) { rv_flag |= RV_MMU_SO; } return rv_flag; }
2.2 页表查找
C906采用的是SV39虚拟地址,也就是虚拟地址有39位,这39位虚拟地址被分成4部分,三个页表索引和一个页内偏移:
C906的页大小固定4KB,采用三级页表,一级页目录通过VPN2索引,二级页表通过VPN1索引,三级页表通过VPN0索引。查表时前两个页表中的页表项属性位只有V位参与检查,最后一级页表的其他属性位参与最终的页面属性设置和检查。参考xv6实现page walk流程代码:
// RISCV SV39 采用三级页表,每级页表项大小8B,一个页表包含512条页表项 // 采用SV39的64位虚拟地址被分为5个部分: // 39..63 -- 必须为0. // 30..38 -- 9bit VPN2索引. // 21..29 -- 9bit VPN1索引. // 12..20 -- 9bit VPN0索引. // 0..11 -- 12bit页内偏移. // 页表项中的RWX三位都是0表示还有下一级页表,否则就是最后一级页表,最后一级的页表项属性参与权限判断 static pte_t *mmu_page_walk(pagetable_t pagetable, u64 va, int alloc) { int level; pte_t *pte; if(va > MAXVA) panic("page_walk"); for(level = 2; level > 0; level--) { // 寻找某级页表项位置 pte = &pagetable[VPN_GET(level, va)]; if(*pte & RV_MMU_VALID) { // 页表项有效则直接取出下一级页表基址 pagetable = (pagetable_t)PTE2PA(*pte); } else { // 页表项无效则可以申请新页内存当作页表 if(!alloc || (pagetable = kalloc()) == 0) return (pte_t *)0; memset(pagetable, 0, PAGE_SIZE); // 设置页表项的有效位,并且RWX为0,表示下一级页表有效 *pte = PA2PTE(pagetable) | RV_MMU_VALID; } #ifdef KERNEL_MMU_DEBUG pr_debug("va %p level %d pte_val %p pte_pa %p.\n", va, level, *pte, PTE2PA(*pte)); #endif } // 返回三级页表项位置 return &pagetable[VPN_GET(0, va)]; }
后两级页表所占用的内存会自动去申请, 一级和二级页表中页表项中的V位会在申请内存成功后自动设置。
2.3 地址映射
做虚拟地址和物理地址映射时需要地址是页对齐的,然后按照每次4KB页进行映射直到所有页映射完毕:
// 建立虚拟地址和物理地址映射关系 // 虚拟地址,物理地址,大小这三个会向下做页对齐处理,使用时最好传入页对齐的数值 int mmu_page_map(pagetable_t pagetable, u64 va, u64 pa, u64 size, u32 flag) { u64 rv_flag; u64 vstart, vend; pte_t *pte; // OS标志转换成RV标志 rv_flag = mmu_flag_os2rv(flag); if(size == 0) panic("page map size = 0"); // 虚拟地址和大小都向下做页对齐处理 vstart = PAGE_ROUNDDOWN(va); vend = PAGE_ROUNDDOWN(va + size); for (; vstart != vend;) { // 根据虚拟地址找到三级页表项位置 if((pte = mmu_page_walk(pagetable, vstart, 1)) == 0) return -1; // 检查虚拟地址是否已经映射 if(*pte & RV_MMU_VALID) panic("virtual address already mapped"); // 设置三级页表项 *pte = PA2PTE(pa) | rv_flag; #ifdef KERNEL_MMU_DEBUG pr_debug("va %p level 0 pte_val %p pte_pa %p.\n", va, *pte, PTE2PA(*pte)); #endif // 处理下一页 vstart += PAGE_SIZE; pa += PAGE_SIZE; } return 0; }
逻辑比较简单,如果地址不对齐,接口内部会自动做对齐处理。
2.4 内核地址映射表
SimpleOS在内核态时采用和xv6相同的地址映射策略,也就是将物理地址和虚拟地址对等映射,这样简化了地址处理。同时定义内核地址映射关系表,用于内核启动时建立必要的映射关系,比如内核代码、数据和外设寄存器映射等:
struct vmm_map_desc { u64 phy_addr; u64 vir_addr; u64 size; u64 flag; }; static struct vmm_map_desc kernel_map_desc[] = { { KERN_BASE, KERN_BASE, 0xdeaddead, VMM_FLAG_KTEXT }, { 0xdeaddead, 0xdeaddead, 0xdeaddead, VMM_FLAG_KDATA }, { UART0_BASE, UART0_BASE, 4 * 1024, VMM_FLAG_DEVICE }, {0 , 0, 0, 0} };
第0项固定为内核代码映射,第1项固定为内核数据映射,剩余的为外设寄存器映射。内核代码和数据一些信息会在初始化时重新计算,定义时在表中先用0xdeaddead初始化。要注意的是外设寄存器空间的映射属性必须要是VMM_FLAG_DEVICE而不能为VMM_FLAG_UNCACHE,因为C906外设的页面属性必须要设置页表项中的SO位:
2.5 MMU初始化设置
MMU初始化就是根据定义好的内核映射表将其中的虚拟地址和物理地址建立映射关系,然后设置页目录基址和使能MMU即可:
void mmu_init(void) { struct vmm_map_desc *desc = kernel_map_desc; // 修正内核代码段信息 kernel_map_desc[0].size = (u64)etext - KERN_BASE; // 修正内核数据段信息 kernel_map_desc[1].phy_addr = (u64)etext; kernel_map_desc[1].vir_addr = (u64)etext; kernel_map_desc[1].size = KERN_STOP - (u64)etext; // 创建内核一级页表 kpgtbl = (pagetable_t)kalloc(); memset(kpgtbl, 0, PAGE_SIZE); // 映射内核地址表 while (desc->size) { kvm_map(kpgtbl, desc->vir_addr, desc->phy_addr, desc->size, desc->flag); desc++; } // 页表改变后需要失效TLB条目 mmu_invalidate_tlb_all(); // 设置页目录基址并使能MMU csr_write(satp, MAKE_SATP(kpgtbl)); }
注意由于改变了MMU页表的内容,所以TLB也必须要被失效,这里为了简单化处理直接失效所有的TLB。
3. 测试
测试很简单,在MMU使能后打印一条信息,如果能打印说明MMU设置基本没啥问题:
void sinit(void) { pr_info("M switch to S success.\n"); supervisor_trap_init(); #ifdef KERNEL_TEST RISCV_ECALL_0(0); #endif kmem_init(); pr_info("kernel memory allocator init success.\n"); #ifdef KERNEL_TEST kmem_test(); #endif mmu_init(); pr_info("mmu init success.\n"); #if 0 mmu_test(); #endif while(1); }
评论