本節是以 Golang 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗
予焦啦!在昨日基本地完成 sysAlloc
、sysReserve
、與 sysMap
之後,仍然餘有問題。看來是 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.go
的 schedinit
函數的話,
// 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()
記憶體初始化的幾個重點函數,如 mallocinit
與 mcommoninit
都已經在我們身後了,也就是說,來到了全新的境界了!回顧一下,每一個 sys*
呼叫大致的原因為何,且順便驗證結果是否合理吧。
本日上傳的修補當中,已經解消了下列的部分行為。
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 的堆疊掃描有關,看起來也是區隔性的問題。
後面的註解段落也解釋了偏好 00c0
、00c1
這樣的模式的原因,這裡筆者就跳過了。總之我們都要調整了。
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 記憶體的架構相依常數之後,終於可以執行到一個新的地步:schedinit
的 mcommoninit
等記憶體初始化之後的,goargs
函數發生了讀取頁面錯誤。至於為什麼?明日便知分曉!
至此,我們完成了現階段的虛擬記憶體啟用的目標,第二章也就告一段落了。明天開始,筆者打算延續 goargs
的錯誤,小幅度地前進,就像跑者的起跑衝刺總是比較急促,需要再調整節奏一樣。各位讀者,我們明日再會!