予焦啦!Ethanol 记忆体映像规划

本节是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 为基准做的实验

本节所对应的修补当中,有些额外的修改偏於细节,便不在本文中探讨。

予焦啦!昨日从装置树中正式取得记忆体节点中的资讯,如此一来才有可能完全支配所有的记忆体。今天我们就来规划一下,一个 Hoddarla/ethanol 核心记忆体映像应该要长什麽样子,以及现阶段在 Golang 记忆体抽象层该置入什麽样的实作。

本节重点概念

  • Golang
    • 记忆体阶层抽象
    • 执行期记忆体配置行为
  • Ethanol
    • 记忆体映像

回顾页表设置

我们前几日设置页表的时候,虚拟位址尚未启用,因此当然也没有探讨过这个问题:在虚拟位址启用之後需要设置页表的时候,具体来说应当怎麽样设置?届时,整个系统已经回不去使用实体位置存取的旧时光,但也如同我们前几日展示的那样,页表项在建立的时候仍然相当仰赖物理位址的计算。

答案也很简单,就是,页表所在的区域,也应该要有虚拟位址的映射才行,否则 ethanol 作为一个作业系统核心又该怎麽管理记忆体呢?若是等到虚拟记忆体启用之後,原本没有配置的部分也无法再被存取到了。总之,我们也得为早期页表建立映射才行。

先前,我们的根页表,置放在 0x80100000。这是 OpenSBI 到 ethanol 之间的空洞,既不被前者控制,也不属於後者管辖。但如果未来的所有页表项都在这里管控,总共也只能置放 512(每个 4KB 页面的页表项数)x 258(到 0x80202000 之间的 4KB 页面数量)个页表项。

为早期页表配置虚拟页面

笔者想要将这一块早期页表区对应在虚拟位址的最後的部分,也就是 0xffffffffffe00000 开始的 2MB 部分。

这里参照原本的做法即可,得以下实作:

+TEXT map_page_table(SB),NOSPLIT|NOFRAME,$0
+       // 0xffffffff_fff00000 to 0x80100000
+       //         7F_C
+       //            4FE
+       // Level 2
+       MOV     $0x80102FF8, T0
+       MOV     ZERO, T1
+       ADD     $0x2000000f, T1, T1
+       SD      T1, 0(T0)
+       // Level 1
+       MOV     $0x80100FF8, T0
+       MOV     ZERO, T1
+       ADD     $0x20040801, T1, T1
+       SD      T1, 0(T0)
+       RET

注解部分是真正的位址对应,也就是最後 1MB 的部份,但必须先对齐到 2MB(这才是一个中型页面的大小),才能制造有效的页表项。

原先在写入 stvec 状态暂存器之前,只有一组页面的映射,如今我们也将上述区段加入之後,透过虚拟位址存取页表的最基本要求,就完成了。然而,我们当然不想一直在组合语言的领域内处理这些事情。该如何在 Golang 档案中也能存取这些位址,就是接下来的问题。

笔者希望,能够用 [512]uint64 型别来表示一个页表,因为如此一来,虚拟位址的各个段落,就可以很直觉地作为阵列索引了。因此在 src/runtime/ehtanol/early_mm.go 里面,准备两个变数:

+var PageTableRoot *[256][512]uint64
+var NextPageTable uint

之所以多了一个维度且大小为 256,是因为我们只有 1MB 的空间放置这些页表,每张页表为 4KB。在开启虚拟位址之前,为这两个变数赋值:

+       // Setup page table
+       MOV     EARLY_PT_HI32, T0
+       SLL     $32, T0, T0
+       MOV     EARLY_PT_LO32, T1
+       MOV     $runtime∕ethanol·PageTableRoot(SB), T2
+       ADD     T0, T1, T0
+       MOV     T0, 0(T2)
+       MOV     $3, T0
+       MOV     $runtime∕ethanol·NextPageTable(SB), T2
+       MOV     T0, 0(T2)

其中,有些常数定义在 src/runtime/ethanol/config.h 里面:

#define EARLY_PT_HI32   $0xffffffff
#define EARLY_PT_LO32   $0xfff00000

当然,有太多不明所以的数字写死在这里不是太理想,但笔者打算先欠下这笔债继续前进。这里 NextPageTable 的用意是,纪录下一个可用的物理页面的索引;在此之前,0x80100000 已经是根页表,0x80101000 是对应到程序码区段以降的 ethanol 映像档,0x80102000 则是对应到页表本身。

作为范例,我们可以简单展示,在一般 Golang 档案中,如何使用这些值:

+       print("kernel mapping:", (*ethanol.PageTableRoot)[0][256], "->", (*ethanol.PageTableRoot
)[1][0], "\n")
+       print("page table mapping:", (*ethanol.PageTableRoot)[0][511], "->", (*ethanol.PageTable
Root)[2][511], "\n")

运行後结果为

...
Boot HART MEDELEG         : 0x000000000000a109
Memory Base:2147483648 
Memory Size:536870912
kernel mapping:537134081->537395407
page table mapping:537135105->536870991
fatal error: runtime: cannot allocate memory
...

可以使用 GDB 检证

(gdb) p/x 537134081 >> 10 << 12
$10 = 0x80101000
(gdb) p/x 537395407 >> 10 << 12
$11 = 0x80200000
(gdb) p/x 537135105 >> 10 << 12
$12 = 0x80102000
(gdb) p/x 536870991 >> 10 << 12
$13 = 0x80000000

之所以要先右移 10 位,是因为页表项里面的低位元有权限控制与属性,将之移除後即成为实体页面编号,再左移 12 位即为实体页面位址。

实作 Golang 记忆体管理抽象层

我们终於来到这个部分了,先前有所欠缺的环节都已一一补上。缺乏对记忆体的理解?我们实作了暴力 DTB 剖析机制,姑且先取得了记忆体大小与位址,得以掌控全局;缺乏启用记忆体後的页面映射管理能力?我们多配置了对应到页表的部分,确保能够在虚拟位址启用後仍能在 Golang 档案内操作

前日介绍的转换函数当中,做为范本的 src/runtime/mem_js.go,实际上做得很简单。从 mem_bsd.go 当中,或许可以窥得更多资讯。以下分别探讨之。

进入可用状态的 sysAllocsysMap

func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {
        v, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
        if err != 0 {
                return nil
        }
        sysStat.add(int64(n))
        return v
}
...
func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {
        sysStat.add(int64(n))

        p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
        if err == _ENOMEM || ((GOOS == "solaris" || GOOS == "illumos") && err == _sunosEAGAIN) {
                throw("runtime: out of memory")
        }
        if p != v || err != 0 {
                throw("runtime: cannot map pages in arena address space")
        }
}

sysStat 是留个纪录,有多少记忆体变为可用。除了针对 n 的处理,再除去错误处理的话,剩下的就是核心的 mmap 这个重要的抽象层。

先聊聊 mmap

使用 man 3 mmap,可以取得 POSIX 手册的说明:

NAME
       mmap — map pages of memory

SYNOPSIS
       #include <sys/mman.h>

       void *mmap(void *addr, size_t len, int prot, int flags,
           int fildes, off_t off);

用这个呼叫的话,可以通知作业系统,这个使用者行程,希望 addr 位址起始,len 大小的虚拟记忆体区块,能够对应到 fildes 这个档案描述子所代表的内部记忆体结构的 off 偏移量起始的同样大小空间。

存取这个区块时的读、写、执行等权限,规定在 prot 之中,而 flags 可以指定这个映射的一些属性。

这里的两组 mmap 呼叫,都开了读写权限,且值得注意的是 flides 给了 -1 ,表示不需要对应到真正的装置或是结构,而是单纯配置匿名页面(anonymous page,对应到 MAP_ANON 属性)。

换作是 ethanol 的场合,我们没有能够服务 mmap 呼叫的作业系统,但我们能够自行建立映射以供使用。所以,针对这两个呼叫,我们的策略是,在 sysMap 当中建立映射,然後让 sysAlloc 呼叫 sysReserve 再呼叫 sysMap

确实,这样还不能抵达理论上 Golang 记忆体抽象里面的就绪状态,但是我们可以不实作 sysUsed 函式与 sysUnused,利用已准备状态允许未定义行为的特性,直接了当地偷懒。

说到 sysReservesrc/runtime/mem_bsd.go 里面 BSD 作业系统的实作,也是使用 mmap 呼叫,但不给予任何权限,因此能够符合 Golang 记忆体抽象层的规定:若是存取该段记忆体,则会发生严重错误。

为何不讨论收拾善後的 sysFree 或是 sysUnused 函式?因为笔者确定这三十天的篇幅注定没有办法完成一个够完整的记忆体管理子系统,所以不如快一点前进,先确定 Golang 自己的执行期初始化有记忆体能用就好,其他的之後再说。

事实上,即使只是这麽消极地先求有再求好,要支援 Golang 完成初始化所需要的记忆体处理,也不是简单了当可以解决的事情。且看後续便知。

ethanol 的实作

同上所述,ethanol 只实作 sysAllocsysReservesysMap 三个函数。前者只是单纯呼叫後两者,所以接下来主要介绍的是後两者与背後的其余机制。

sysAlloc

func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {
        p := sysReserve(nil, n)
        sysMap(p, n, sysStat)
        return p
}

先透过 sysReserve 函式帮忙寻得一个可用的虚拟位址,然後直接要求将该处对应大於 n 的最小的 2 的幂次方数量的记忆体映射到
合法的物理位址处。

sysReserve

func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
        // Let's ignore the v anyway.
        // Check ethanol/README for memory map.

        // 4K is the basic unit.
        order := n >> 12
        i := uintptr(1)
        j := uintptr(1)
        for i < order {
                i = i << 1
                // 0xffffff_c0_00000000 is for base kernel
                //           ^ j = 1(4K) ~ 19(1G)
                j += 1
        }
...

第一部分,是先针对非幂次方的 n 做一点处理。注解写得很简略,笔者希望的是,透过一种一目了然的方式,来分配虚拟位址空间。策略如下:

  • 0xffffffc1_00000000:保留给所有 4KB 记忆体单位的配置。
  • 0xffffffc2_00000000:保留给所有 8KB。
  • 0xffffffc3_00000000:保留给所有 16KB。
    ...
  • 0xffffffd3_00000000:保留给所有 1GB。
    理解这个意图之後,索引变数 ij 应该就很理所当然了。前者代表的是一整个记忆体单位的大小(以 4KB 为基本单位),後者则相当於是对前者取 2 为底的对数,也就是数量级。
...
        ret := base2PowVA[j-1]
        base2PowVA[j-1] += uintptr(i << 12)
        print("Reserve: ", unsafe.Pointer(n), " bytes, at ", v, " but at ", unsafe.Pointer(ret),
 "\n")
        return unsafe.Pointer(ret)
}

介绍新资料阵列:base2PowVA,这个阵列里面会初始化成存放上述的几个虚拟位址。当有新的记忆体区块在这里被保留之後,这个阵列的资料就会更新,以备下次保留下一块区域之需。

当然,这完全没有任何保护是没有办法用在多核心或是多执行绪环境,也没有确保永续性(比方说,回收回来的记忆体怎麽处理呢?放任原本的虚拟记忆体区段出现空洞吗?)。这只是一个阶段性的暴力作法

简单来说,笔者用 sysReserve 来决定该笔记忆体配置该使用的虚拟位址与区块。

sysMap

func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {
        ptr := uintptr(v)
        sysStat.add(int64(n))
        print("Map: ", unsafe.Pointer(n), " bytes, at ", unsafe.Pointer(v), "\n")
        i := uintptr(1)
        for i < n {
                i = i << 1
        }0
        n = i
        if n <= ethanol.PAGE_TYPE_4K {
                ethanol.MemoryMap(ptr, pageBase[INDEX_4K].next, ethanol.PAGE_TYPE_4K)
                updatePageBase(INDEX_4K)
        } ...

第一阶段,也是先处理 n 使之对齐 2 的幂次数量。然後开始针对 n 的数量判定该做的事情。第一段是 n 为 4KB 的页面对应,这里於是将代表虚拟位址的 ptr 对应到 pageBase[INDEX_4K].next 物理位址。使用的函式是位在 src/runtime/ethanol/early_mm.goMemoryMap,然後再呼叫 updatePageBase 更新下一个能够用於映射的新页面。

一次出现三种新东西,之後再分段介绍。

之後也是简单的分配,

        } else if n < ethanol.PAGE_TYPE_2M {
                for n > 0 {
                        ethanol.MemoryMap(ptr, pageBase[INDEX_4K].next, ethanol.PAGE_TYPE_4K)
                        updatePageBase(INDEX_4K)
                        ptr += 0x1000
                        n -= 0x1000
                }
        } else {
                for n > 0 {
                        ethanol.MemoryMap(ptr, pageBase[INDEX_2M].next, ethanol.PAGE_TYPE_2M)
                        updatePageBase(INDEX_2M)
                        ptr += 0x200000
                        n -= 0x200000
                }
        }
}
        ethanol.sfencevma()

最後则是使用权限指令 sfence.vma,通知硬体刷新 TLB 或相关结构,使刚建立的页表生效。
这里牵涉到较大范围,所以有回圈的逻辑涉入,但没有什麽新东西,应该是很直接的实作。

pageBase

这个阵列用来纪录两组实体记忆体区段,一组是 4KB 页面的基底(0x80400000)与下一个可用页面的实体位址,另一组则是 2MB 的(0x80600000)。初始化时,与先前的 base2PowVA 一起,

func baseInit() {
        base := uintptr(0xffffffc100000000)
        step := uintptr(0x0000000100000000)
        for j := 1; j < 20; j++ {
                base2PowVA[j-1] = base
                base += step
        }
        pageBase[INDEX_4K].base = 0x80400000
        pageBase[INDEX_4K].next = 0x80400000
        pageBase[INDEX_2M].base = 0x80600000
        pageBase[INDEX_2M].next = 0x80600000
}

这两组量就像两个跑者在同一条直线跑道上前後两个不同的起点开始起跑,每个当下的瞬间都是可用的物理记忆体位址,当後者追上前者的起跑点之後,就需要更新了,而这也是下一小节 updatePageBase 的功能。

updatePageBase

概念很简单,继续以跑者当作类比的话,一旦落後者抵达领先者的起点,它就立刻被传送到领先者下一步的位址,继续跑下去;实际上,当然有一些对齐的问题要处理,但也只是乘乘除除而已。程序码一样在 src/runtime/mem_opensbi.go 当中,这里就略过了。

MemoryMap

参数中直接给定虚拟位址与实体位址,但是要令两者对应起来,最简单也必须走过以下描述的部分

  • 分段虚拟位址为 vpn2vpn1vpn0
  • 检视根页表 0x80100000,里面对应於 vpn2 的页表项。
    • 若是尚未建立,则再分为两个状况
      • 若是这次要建立的页表是 2MB 页面,则取得一个新的页表,将 vpn2 对应的页表项指向这个新页表,再将新页表偏移量 vpn1 的页表相对应到要建立映射的实体位址去。
      • 若是要建立的页表是 4KB,则上述步骤需再做两次,才能够完成三层的对应。
    • 若是已经建立,则从下一层开始,重复类似上述步骤。
func MemoryMap(va, pa uintptr, pt pageType) {
        vpn2 := (va & 0x0000007FC0000000) >> 30
        vpn1 := (va & 0x000000003FE00000) >> 21
        if (*PageTableRoot)[0][vpn2] == 0 {
                pt2 := uintptr(NextPageTable*0x1000 + PAGE_TABLE_PA)
                (*PageTableRoot)[0][vpn2] = pt2>>12<<10 | PTE_V
                if pt == PAGE_TYPE_2M {
                        (*PageTableRoot)[NextPageTable][vpn1] = pa>>12<<10 | PTE_XWRV
                } else {
                        pt1 := uintptr((NextPageTable+1)*0x1000 + PAGE_TABLE_PA)
                        (*PageTableRoot)[NextPageTable][vpn1] = pt1>>12<<10 | PTE_V
                        vpn0 := (va & 0x00000000001FF000) >> 12
                        (*PageTableRoot)[NextPageTable+1][vpn0] = pa>>12<<10 | PTE_XWRV
                        NextPageTable += 1
                }
                NextPageTable += 1
                return
        } else {
                pt1 := ((*PageTableRoot)[0][vpn2]>>10<<12 - PAGE_TABLE_PA) / 0x1000
                if (*PageTableRoot)[pt1][vpn1] == 0 {
                        if pt == PAGE_TYPE_2M {
                                (*PageTableRoot)[pt1][vpn1] = pa>>12<<10 | PTE_XWRV
                        } else {
                                pt0 := uintptr((NextPageTable+1)*0x1000 + PAGE_TABLE_PA)
                                (*PageTableRoot)[pt1][vpn1] = pt0>>12<<10 | PTE_V
                                vpn0 := (va & 0x00000000001FF000) >> 12
                                (*PageTableRoot)[pt0][vpn0] = pa>>12<<10 | PTE_XWRV
                                NextPageTable += 1
                        }
                        return
                } else if pt == PAGE_TYPE_4K {
                        pt0 := ((*PageTableRoot)[pt1][vpn1]>>10<<12 - PAGE_TABLE_PA) / 0x1000
                        vpn0 := (va & 0x00000000001FF000) >> 12
                        (*PageTableRoot)[pt0][vpn0] = pa>>12<<10 | PTE_XWRV
                        return
                }
        }
        print("weird: ", unsafe.Pointer(va), " to ", unsafe.Pointer(pa), " of type ", pt, "\n")
}

最後留了一行展示比较奇怪的例外状况,就是 Golang 在使用的时候重复映射了的状况,但应当是不会发生才对。

NextPageTable 一直加上去,实际上也不能超过 256,所以当然不是一个永续的解决方案。之後再想办法吧。

试跑

以下的实验可以从 Hoddarla repo 取得。

试跑之後,可以看到一堆来自记忆体抽象层的追踪输出。但最後出现以下错误:

...
Reserve: 0x4000000 bytes, at 0x7ec000000000 but at 0xffffffd0fc000000
Reserve: 0x4000000 bytes, at 0x7fc000000000 but at 0xffffffd100000000
Reserve: 0x8000000 bytes, at 0x0 but at 0xffffffd000000000
runtime: memory allocated by OS [0xffffffd000000000, 0xffffffd008000000) not in usable address space: base outside usable address space
fatal error: memory reservation exceeds address space limit

看来还有些手续必须进行,才能够完全说服 Golang 执行期来使用 ethanol 的实作。

小结

予焦啦!花了大把力气只想要暴力地先解决这个部分,但还是有些问题横亘在前。不过,相信记忆体的部分已经接近尾声了。各位读者,我们明日再会!


<<:  [C 语言笔记--Day12] system call 的执行步骤

>>:  基本操作 - 历史资讯

#4 Array & Object in JavaScript

因为感觉在操作上还蛮常用到 array 及 object 的各种方法,所以这篇就来说说 JavaSc...

前端工程师也能开发全端网页:挑战 30 天用 React 加上 Firebase 打造社群网站|Day10 发表文章功能

连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...

周末雨会(四):自定义资料类别 Defined Data Class

担心晚上天气可能会变糟,两人选择外带饮料。 「刚刚阵列里放的只有价钱,怎麽分辨饮料的名字?」诗忆啜饮...

【Day17】Git 版本控制 - 多人协作 Fork(2)

在上一篇笔记中已经提到 Fork 的功能以及使用办法了,那本篇就来实际发个 Pull request...