iT邦幫忙

2021 iThome 鐵人賽

DAY 15
2
Software Development

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

予焦啦!Golang 記憶體初始化

本節是以 Golang 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗

予焦啦!在昨日基本地完成 sysAllocsysReserve、與 sysMap 之後,仍然餘有問題。看來是 Golang 執行期內部有些機制在防止一般使用者程式錯用過於高位的位址。今天我們除了排除這個問題之外,也看看 Golang 在先前幾日著墨過的記憶體抽象層之上,究竟是怎麼樣利用那些記憶體的。

本節重點概念

  • Golang
    • 作業系統相依的記憶體管理參數
    • 執行期記憶體配置行為

排除問題

根據昨日得到的錯誤訊息,我們直接以「並非可用位址空間(not in usable address space)」作為關鍵字搜尋,可以在 src/runtime/malloc.go 之中找到:

        // Check for bad pointers or pointers we can't use.
        {
                var bad string
                p := uintptr(v)
                if p+size < p {
                        bad = "region exceeds uintptr range"
                } else if arenaIndex(p) >= 1<<arenaBits {
                        bad = "base outside usable address space"
                } else if arenaIndex(p+size-1) >= 1<<arenaBits {
                        bad = "end outside usable address space"
                }
                if bad != "" {
                        // This should be impossible on most architectures,
                        // but it would be really confusing to debug.
                        print("runtime: memory allocated by OS [", hex(p), ", ", hex(p+size), ")
 not in usable address space: ", bad, "\n")
                        throw("memory reservation exceeds address space limit")
                }
        }

這一段對使用者程式來說是天經地義。原因是,在 Sv39 的虛擬記憶體轉換模式裡面,一般來說會讓作業系統掌握 0xffffffc0_00000000 以上的位址,並讓使用者空間使用 0x40_00000000 以下的位址。在有號整數的領域內,前者屬於負數,但後者屬於正數。所以這個部分的各種判斷,對於 Hoddarla/ethanol 來講完全不適用。加上一個條件將它整個略過:

        // Check for bad pointers or pointers we can't use.
-       {
+       if GOOS != "opensbi" {
                var bad string
                ...

超出範圍?

執行之後,錯誤變成:

Alloc: 0x2000000 bytes
Reserve: 0x2000000 bytes, at 0x0 but at 0xffffffce00000000
Map: 0x2000000 bytes, at 0xffffffce00000000
fatal error: index out of range

runtime stack:
runtime.throw({0xffffffc000060a28, 0x12})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60 fp=0xffffffc000001a60 sp=0xffffffc000001a38 pc=0xffffffc00002eda0                                                    
runtime.panicCheck1(0xffffffc0000084f0, {0xffffffc000060a28, 0x12}) 
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:35 +0xcc fp=0xffffffc000001a88 
sp=0xffffffc000001a60 pc=0xffffffc00002c97c
runtime.goPanicIndexU(0x3ffffff400, 0x400000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:92 +0x40 fp=0xffffffc000001ac0 
sp=0xffffffc000001a88 pc=0xffffffc00002cac8
runtime.(*mheap).sysAlloc(0xffffffc0000bcc80, 0x400000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:742 +0x3f0 fp=0xffffffc000001b
70 sp=0xffffffc000001ac0 pc=0xffffffc0000084f0
...

偵測到的錯誤是超出範圍的索引值,出現在 runtime.(*mheap).sysAlloc 函式,

        // Create arena metadata.
        for ri := arenaIndex(uintptr(v)); ri <= arenaIndex(uintptr(v)+size-1); ri++ {
                l2 := h.arenas[ri.l1()]
                if l2 == nil {
                        // Allocate an L2 arena map.
                        ...

先前透過 sys* 等最底層抽象層配置得的 v,在這裡透過 arenaIndex 的轉換之後,作為 h.arenas 的陣列索引而超出範圍。這個陣列代表的是 Golang 記憶體配置器(allocator)當中,作為標記用途的重要結構。

非常簡略地概述會是這個樣子:Golang 可以動態管理記憶體,並以垃圾回收機制做得還不錯聞名。其中許多記憶體的活動會發生在 heap 之上。理論上 heap 的範圍可以遍及所有可用之虛擬記憶體空間。這個被使用的範圍就被稱作競技場(arena),而這裡的 arenas 陣列,就是用以紀錄相關資訊的資料結構。

之所以會有索引超標,問題出在從一般位址轉換到 arenaIndex 的過程:

// If p is outside the range of valid heap addresses, either l1() or 
// l2() will be out of bounds.
...
func arenaIndex(p uintptr) arenaIdx {
      return arenaIdx((p - arenaBaseOffset) / heapArenaBytes)
}

陣列索引的計算是單純的線性公式:減去基底再除以係數。係數本身是常數,但問題是先前我們都沒有特別去關心 arenaBaseOffset 的值是如何設定的,而就如同註解描述的一樣,如果這個值預設是 0 之類的,那這個轉換就會變得極大,畢竟我們的正常位址都是極高位的。檢查一下這個變數,可以發現:

// On other platforms, the user address space is contiguous                             
// and starts at 0, so no offset is necessary.                                          
arenaBaseOffset = 0xffff800000000000*goarch.IsAmd64 + 0x0a00000000000000*goos.IsAix

是,所以這裡我們應該補上,當作業系統指定為 opensbi 之時,就讓這個基底偏移量為 0xffffffc000000000。如下:

arenaBaseOffset = 0xffff800000000000*goarch.IsAmd64 + 0x0a00000000000000*goos.IsAix + 0xffffffc000000000*goos.IsOpensbi

之後,重新編譯執行的話:

...
Map: 0x10000 bytes, at 0xffffffc500000000
Alloc: 0x10000 bytes 
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500010000
Map: 0x10000 bytes, at 0xffffffc500010000
Map: 0x800000 bytes, at 0xffffffd000400000
I000000000000000d
0000000082200000
ffffffc00003dfa0

根據這個例外程式指標之所在可以追溯到,我們在 runtime.goargs 當中觸發讀取錯誤。這個時間點,參考 src/runtime/proc.goschedinit 函數的話,

// The bootstrap sequence is:
//
//      call osinit
//      call schedinit       # 當前執行的函數
//      make & queue new G   # 之後會把新的共常式推入排程之中
//      call runtime·mstart  # 正式啟用初始執行緒(`m0`)
//
// The new G calls runtime·main.
func schedinit() {
...
        mallocinit()
        fastrandinit() // must run before mcommoninit
        mcommoninit(_g_.m, -1) // 整個第二章的記憶體初始化部分,
                               // 可以說是都在解決這裡面發生的事情
        cpuinit()       // must run before alginit
        alginit()       // maps must not be used before this call
        
        // 省略中間一堆 ELF 區段、訊號初始化等的函數

        goargs() // 這是現在出問題的地方
        goenvs()

記憶體初始化的幾個重點函數,如 mallocinitmcommoninit 都已經在我們身後了,也就是說,來到了全新的境界了!回顧一下,每一個 sys* 呼叫大致的原因為何,且順便驗證結果是否合理吧。

分析 Golang 記憶體初始化資料

本日上傳的修補當中,已經解消了下列的部分行為。

第一段

Memory Base:2147483648
Memory Size:536870912
Alloc: 0x40000 bytes
Reserve: 0x40000 bytes, at 0x0 but at 0xffffffc700000000
Map: 0x40000 bytes, at 0xffffffc700000000
Reserve: 0x20000 bytes, at 0x0 but at 0xffffffc600000000
Reserve: 0x100000 bytes, at 0x0 but at 0xffffffc900000000
Reserve: 0x800000 bytes, at 0x0 but at 0xffffffcc00000000
Reserve: 0x4000000 bytes, at 0x0 but at 0xffffffcf00000000
Reserve: 0x20000000 bytes, at 0x0 but at 0xffffffd200000000

最一開始,直接配置 256KB 的區域,這是來自

// addrRanges is a data structure holding a collection of ranges of
// address space.
//
...
func (a *addrRanges) init(sysStat *sysMemStat) {
        ranges := (*notInHeapSlice)(unsafe.Pointer(&a.ranges))
        ranges.len = 0
        ranges.cap = 16
        ranges.array = (*notInHeap)(persistentalloc(unsafe.Sizeof(addrRange{})*uintptr(ranges.ca
p), goarch.PtrSize, sysStat))
        a.sysStat = sysStat
        a.totalBytes = 0
}

這裡的 (*addrRange).init 呼叫 persistentalloc 之後呼叫到的。這函數名就代表了,所配置的記憶體沒有對應的釋放函數。

之後我們看到一連串連續五筆的保留區域,這是來自 src/runtime/mpagealloc_64bit.go 裡面的一個段落:

// sysInit performs architecture-dependent initialization of fields
// in pageAlloc. pageAlloc should be uninitialized except for sysStat
// if any runtime statistic should be updated.
func (p *pageAlloc) sysInit() {
        // Reserve memory for each level. This will get mapped in
        // as R/W by setArenas.
        for l, shift := range levelShift {
                entries := 1 << (heapAddrBits - shift)
                // Reserve b bytes of memory anywhere in the address space.
                b := alignUp(uintptr(entries)*pallocSumBytes, physPageSize)
                r := sysReserve(nil, b)
                ...

每一層保留量相差 8 倍,來自於 heapAddrBits - shift 的差距。

這些分層的保留區域,也是 Golang 記憶體管理機制的一環。若要判別某塊記憶體是否已被佔用,最節省的做法,也勢必需要位元映像(bitmap)對空頁面為 0、已佔用者為 1 來記錄。

但總不成每次檢索都掃過整片位元映像的內容。所以,Golang 實作了分為 5 層的 radix tree 來追蹤,而這裡的五組保留區,是用來當作摘要(summary)使用的資料結構。不僅如此,摘要的型別佔據 8 個位元組,其中編碼了 3 組可以高達 2^21 的整數內容。至於更細節的部分,已經超過本系列的範圍,只能留待日後研究。

如果在這裡插入一些印出訊息來觀察的話,會看到

heapAddrBits: 48
shift 34
Reserve: 0x20000 bytes, at 0x0 but at 0xffffffc600000000
heapAddrBits: 48
shift 31
Reserve: 0x100000 bytes, at 0x0 but at 0xffffffc900000000
heapAddrBits: 48
shift 28
Reserve: 0x800000 bytes, at 0x0 but at 0xffffffcc00000000
heapAddrBits: 48
shift 25
Reserve: 0x4000000 bytes, at 0x0 but at 0xffffffcf00000000
heapAddrBits: 48
shift 22
...

但說實在的,我們能夠用以定址的位元數量最多也就 38 個,這裡怎麼禁得起 48 這個數字呢?這其實也是個與架構相依(architecture-dependent)的一個常數,原本定義在 src/runtime/malloc.go 當中,要改它的值的也和先前 arenaBaseOffset 的改法差不多,這裡先不改動,分析完一般的記憶體用量再說。

第二段

Reserve: 0x4000000 bytes, at 0xc000000000 but at 0xffffffcf04000000  
Reserve: 0x4000000 bytes, at 0x1c000000000 but at 0xffffffcf08000000 
Reserve: 0x4000000 bytes, at 0x2c000000000 but at 0xffffffcf0c000000
...
Reserve: 0x4000000 bytes, at 0x7dc000000000 but at 0xffffffd0f8000000
Reserve: 0x4000000 bytes, at 0x7ec000000000 but at 0xffffffd0fc000000
Reserve: 0x4000000 bytes, at 0x7fc000000000 but at 0xffffffd100000000
Reserve: 0x8000000 bytes, at 0x0 but at 0xffffffd000000000

先說結論的話,這個部分也是錯誤的初始狀態使然。檢視一下為什麼會有這樣大範圍掃過去的行為,相關的程式碼在

// sysAlloc allocates heap arena space for at least n bytes. The
// returned pointer is always heapArenaBytes-aligned and backed by
// h.arenas metadata. The returned size is always a multiple of
// heapArenaBytes. sysAlloc returns nil on failure.
// There is no corresponding free function.
//
// sysAlloc returns a memory region in the Reserved state. This region must
// be transitioned to Prepared and then Ready before use.
//
// h must be locked.
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {

雖然一樣名為 sysAlloc,但這裡是屬於 *mheap 型別的函數。總之是要配置競技場內的空間,並且也沒有相對應的釋放函數。呼叫到 sysReserve 的地方在:

        // Try to grow the heap at a hint address.
        for h.arenaHints != nil {
                hint := h.arenaHints
                p := hint.addr
                if hint.down {
                        p -= n
                }
                if p+n < p {
                        // We can't use this, so don't ask.
                        v = nil
                } else if arenaIndex(p+n-1) >= 1<<arenaBits {
                        // Outside addressable heap. Can't use.
                        v = nil
                } else {
                        v = sysReserve(unsafe.Pointer(p), n)
                }

如果提示位址(p)存在的話,就會進到這個控制區塊內。進行一些檢查都沒有成立之後,最後的 else 區,就會試圖保留這個提示位址。

之所以這個第二段會連續刷出那麼多保留的嘗試,是因為

...
                } else {
                        v = sysReserve(unsafe.Pointer(p), n)
                }
                if p == uintptr(v) {
                        // Success. Update the hint.
                        if !hint.down {
                                p += n
                        }
                        hint.addr = p
                        size = n
                        break
                }
                // Failed. Discard this hint and try the next.
                ...
                                if v != nil {
                        sysFree(v, n, nil)
                }
                h.arenaHints = hint.next
                h.arenaHintAlloc.free(unsafe.Pointer(hint))
        }

從之後的兩個主要區塊看來,只有在 sysReserve 回傳的保留位址同提示位址之時,才能夠算是成功。成功之後,這個提示變數(hint)本身會被更新,供下一次需要在 heap 上配置記憶體之時再使用吧。

但我們實作的 sysReserve 無論如何不會滿足這個需求的。所以就會進到最後的兩行,每次 arenaHints 都會被更新成下一組提示,再重新開始。這也就是為什麼我們會看到一整排的保留嘗試。

而且最後一筆的保留嘗試就是,當所有的提示都失敗之後,難道 Golang 執行期就放棄取得 heap 記憶體了嗎?當然不能。所以稍後有一段接受現實的程式,保留作業系統回傳的位址,並且依據該位址去重新生成提示。

                // All of the hints failed, so we'll take any
                // (sufficiently aligned) address the kernel will give
                // us.
                v, size = sysReserveAligned(nil, n, heapArenaBytes)
                if v == nil {
                        return nil, 0
                }

所以這裡我們必須想辦法解決這件事情才行。從 areanaHint 初始化的地方下手,回顧程式的呼叫軌跡(call trace)的話可以發現,那其實也就是稍早時經過的 mallocinit()

        // Create initial arena growth hints.
        if goarch.PtrSize == 8 {
                // On a 64-bit machine, we pick the following hints
                // because:
                //
                // 1. Starting from the middle of the address space
                // makes it easier to grow out a contiguous range
                // without running in to some other mapping.
                //
                // 2. This makes Go heap addresses more easily
                // recognizable when debugging.
                //
                // 3. Stack scanning in gccgo is still conservative,
                // so it's important that addresses be distinguishable
                // from other data.

我們可以找到這段設置 arenaHints 之前的註解。Golang 設計者也不是隨意胡亂選擇這些提示的位址,而是有三個考量:首先,在位址空間的中間開始的話,應該可以更容易長出一個連續的空間;再者,若是作業系統都能盡量配合這樣的設計,這些提示能夠看起來有區隔性,出問題的時候也比較容易除錯;最後與 gccgo 的堆疊掃描有關,看起來也是區隔性的問題。

後面的註解段落也解釋了偏好 00c000c1 這樣的模式的原因,這裡筆者就跳過了。總之我們都要調整了。

                for i := 0x7f; i >= 0; i-- {
                        var p uintptr
                        switch {
...
                        case GOARCH == "arm64":
                                p = uintptr(i)<<40 | uintptrMask&(0x0040<<32)
                        case GOOS == "aix":
                                if i == 0 {
                                        // We don't use addresses directly after 0x0A00000000000000
                                        // to avoid collisions with others mmaps done by non-go programs.
                                        continue
                                }
                                p = uintptr(i)<<40 | uintptrMask&(0xa0<<52)
+                       case GOOS == "opensbi":
+                               p = uintptr(i)<<26 | uintptr(0xffffffcf00000000)
                        default:
                                p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32)
                        }

原本就是都走到預設的最後一個狀況,所以第二段這裡的連續保留嘗試才會是 c000...1c000... 的模式。這裡我們就比照 AIX 作業系統,也創一個適合我們現在狀況的。

這個魔術數字 0xffffffcf00000000,是先射箭再畫靶。後面第一次需要用到記憶體位址提示的時候,需要配置的頁面大小是 64MB,所以使用 cf 部分。進一步使用 d0 的話也會提前錯誤,抱怨超過定址空間;退一步使用 ce 部分的話,則也沒有解決 64MB 區塊的配置結果無法匹配到所得結果的問題。這筆債也是先欠著了。

今天上傳的修補已經附上了這個調整,所以不會看到這個 i 迴圈從 0x7f 到歸零都無法滿足的狀況。

第三段之前,調整重跑

綜合這個調整與先前的 headAddrBits 的調整,我們繼續檢視接下來的記憶體使用吧:

...
Memory Base:2147483648                          
Memory Size:536870912                           
Alloc: 0x40000 bytes 
Reserve: 0x40000 bytes, at 0x0 but at 0xffffffc700000000
Map: 0x40000 bytes, at 0xffffffc700000000
Reserve: 0x1000 bytes, at 0x0 but at 0xffffffc100000000
Reserve: 0x1000 bytes, at 0x0 but at 0xffffffc100001000 
Reserve: 0x1000 bytes, at 0x0 but at 0xffffffc100002000
Reserve: 0x4000 bytes, at 0x0 but at 0xffffffc300000000
Reserve: 0x20000 bytes, at 0x0 but at 0xffffffc600000000
Reserve: 0x4000000 bytes, at 0xffffffcf04000000 but at 0xffffffcf00000000

第一段加第二段的內容變成以上。可以看到原本的 5 層 radix tree 所需要的結構,已經減少非常多了。至於
最後一行的部份,根據提示去保留記憶體區段的動作,也可以嘗試一次就成功。

第三段

Alloc: 0x210c10 bytes 
Reserve: 0x210c10 bytes, at 0x0 but at 0xffffffcb00000000
Map: 0x210c10 bytes, at 0xffffffcb00000000
Map: 0x400000 bytes, at 0xffffffcf00000000
Map: 0x1000 bytes, at 0xffffffc100000000
Map: 0x1000 bytes, at 0xffffffc100001000
Map: 0x1000 bytes, at 0xffffffc100002000
Map: 0x1000 bytes, at 0xffffffc60001e000

最後的 4 行追溯回去可以發現它們分別對應到 radix tree 資料結構所需要的前 4 層,都是已經保留的狀態,而在這裡進行真正的映射。倒數第 5 行的映射位址則對照到,根據前述的提示所保留的 heap 部分。所以,在我們遭遇錯誤之前的所有 Golang 執行期記憶體活動,就只剩下前 3 行的最後一組,是透過 sysAlloc 進行配置的。

這組配置的需求,來自以下片段的 persistentalloc 函數一路呼叫到 sysAlloc

                var r *heapArena
                r = (*heapArena)(h.heapArenaAlloc.alloc(unsafe.Sizeof(*r), goarch.PtrSize, &memstats.gcMiscSys))
                if r == nil {
                        r = (*heapArena)(persistentalloc(unsafe.Sizeof(*r), goarch.PtrSize, &memstats.gcMiscSys))
                        if r == nil {
                                throw("out of memory allocating heap arena metadata")
                        }
                }

這個 heapArena 資料體的大小,就是這裡看到的 0x210c10 這個數量了。這正是配置競技場的片段。

第四段

Alloc: 0x100 bytes
Reserve: 0x100 bytes, at 0x0 but at 0xffffffc100003000
Map: 0x100 bytes, at 0xffffffc100003000
Alloc: 0x10000 bytes 
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500000000
Map: 0x10000 bytes, at 0xffffffc500000000
Alloc: 0x10000 bytes 
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500010000
Map: 0x10000 bytes, at 0xffffffc500010000
Map: 0x800000 bytes, at 0xffffffcf00400000
Alloc: 0x100 bytes 
Reserve: 0x100 bytes, at 0x0 but at 0xffffffc100004000
Map: 0x100 bytes, at 0xffffffc100004000
I000000000000000d
0000000082200000
ffffffc00003dfa0

除了一個獨立的 sysMap 呼叫取得 8MB 的區塊之外,這個部分的重點是 sysAlloc 的呼叫。兩個區塊大小僅有 256 位元組的配置,前者來自 mpreinit 函數內使用 malg 配置 m0 內訊號處理用的特殊共常式 gsignal 時,配置新物件的需求;後者則是已經渡過大多記憶體初始化階段來到 goargs 函數時,因為需要 Golang 的動態陣列(切片,slice)而產生的需求。兩者都共用 mallocgc 函數作為入口,走訪 Golang 自己的記憶體管理層級之後,抵達 sysAlloc 且只進行 256 位元組的配置。

但我們現在的實作來講,因為以 4KB 為單位,所以這裡會有很嚴重的碎片式浪費。從這裡的回溯訊息可以看到,這兩筆配置都還是分別佔用了一個 4KB 頁面。

中間的其餘的兩筆,也都來自配置 m0.gsignal 時產生的需求,與第一筆 256 位元組的配置相近。最後的話,就是陣亡在 goargs 裡面的狀態了。

筆者打撈這些配置的來源的方法是,這個部分的記憶體都很固定,因此只要針對記憶體位址進行判斷,以決定要不要執行另外插入的 throw 呼叫,即可看到執行時的呼叫回溯。

小結

予焦啦!我們補足了一些 Golang 記憶體的架構相依常數之後,終於可以執行到一個新的地步:schedinitmcommoninit 等記憶體初始化之後的,goargs 函數發生了讀取頁面錯誤。至於為什麼?明日便知分曉!

至此,我們完成了現階段的虛擬記憶體啟用的目標,第二章也就告一段落了。明天開始,筆者打算延續 goargs 的錯誤,小幅度地前進,就像跑者的起跑衝刺總是比較急促,需要再調整節奏一樣。各位讀者,我們明日再會!


上一篇
予焦啦!Ethanol 記憶體映像規劃
下一篇
予焦啦!參數與環境變數
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言