iT邦幫忙

2021 iThome 鐵人賽

DAY 14
1
Software Development

予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索系列 第 14

予焦啦!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 的實作。

小結

予焦啦!花了大把力氣只想要暴力地先解決這個部分,但還是有些問題橫亙在前。不過,相信記憶體的部分已經接近尾聲了。各位讀者,我們明日再會!


上一篇
予焦啦!裝置樹(DTB)解析
下一篇
予焦啦!Golang 記憶體初始化
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

1 則留言

0
henryandjay
iT邦新手 5 級 ‧ 2021-10-06 10:13:19

MOV $runtime∕ethanol·PageTableRoot(SB), T2
在Golang裡面, 是將前值指定給後值對吧, 但是這樣如何初始化呢? 還是說傳入的只是位址?

高魁良 iT邦新手 4 級 ‧ 2021-10-06 11:40:36 檢舉

因為 Golang 裡面型別轉換很嚴格,然後這裡是憑空想要安一個數值當作指標,所以繞路去組語完成。

我要留言

立即登入留言