【BotR】CLR堆疊遍歷(Stackwalking in CLR)

2022-09-26 06:02:58

前言

在上一篇文章CLR型別系統概述裡提到,當執行時掛起時, 垃圾回收會執行堆疊遍歷器(stack walker)去拿到堆疊上值型別的大小和堆疊根。這裡我們來翻譯BotR裡一篇專門介紹Stackwalking的文章,希望能加深理解。

順便說一句,StackWalker在中文裡似乎還沒有統一的翻譯,Java裡有把它翻譯成堆疊步行器,微軟有的(機翻)檔案把它翻譯為堆疊檢視器,我這裡暫且將它翻譯為堆疊遍歷器,如有更合適的翻譯,歡迎評論區指出。

.NET執行時之書(Book of the Runtime,簡稱BotR)是一系列描述.NET執行時的檔案,2007年左右在微軟內部建立,最初目的是為了幫助其新員工快速上手.NET執行時;隨著.NET開源,BotR也被公開了出來,如果想深入理解CLR,這系列文章不可錯過。

BotR系列目錄:
[1] CLR型別載入器設計(Type Loader Design)
[2] CLR型別系統概述(Type System Overview)
[3] CLR堆疊遍歷(Stackwalking in CLR)

CLR堆疊遍歷(Stackwalking in CLR)

原文:https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/stackwalking.md
作者: Rudi Martin - 2008
翻譯:幾秋 (https://www.cnblogs.com/netry/)

CLR大量使用了一種稱為堆疊遍歷(或者也叫stack crawling)的技術,這涉及迭代特定執行緒的呼叫幀(call frames)序列,從最近的呼叫幀(執行緒的當前函數)後退到堆疊的底部。

執行時出於多種目的使用堆疊遍歷:

  • 在垃圾回收期間,執行時遍歷所有執行緒的堆疊尋找託管根(區域性變數在託管方法的幀中擁有物件的參照,需要被報告給GC,以保持物件活躍和跟蹤,並且如果GC決定壓縮堆,則可能跟蹤它們的動向)。
  • 在一些平臺上,例外處理的過程中,會使用堆疊遍歷器(第一遍尋找控制程式碼,第二遍展開堆疊(unwinding the stack))。
  • 各種各樣的方法,通常是那些靠近某些公共託管API的方法,執行堆疊遍歷以獲取有關其呼叫者的資訊(例如呼叫者的方法、類或者程式集)。

堆疊模型

在這裡,我們定義了一些常用術語並描述了執行緒堆疊的典型佈局。
邏輯上,一個堆疊被拆分成若干個幀(frame),每一幀代表若干函數(託管或非託管),這些函數要麼是當前正在執行的,要麼是已經呼叫了其它函數,正在等待返回。幀包含了其關聯函數的特定呼叫所需的狀態。通常包括區域性變數的空間、呼叫另一個函數的推播引數、儲存的呼叫者暫存器等。

幀的具體定義因平臺而異,在很多平臺上,並沒有一個所有函數都嚴格遵守的幀格式定義(x86平臺就是其中一個例子)。相反,編譯器通常可以自由優化幀的具體格式,在這樣的系統上,無法保證堆疊遍歷返回100正確或者完整的結果(出於偵錯目的,會使用像pdb檔案這樣的符號表來填補空白,以便偵錯程式可以生成更準確的堆疊跟蹤)。

然而這對CLR來說不是一個問題,因為我們不需要完全廣義(fully generalized)的的堆疊遍歷,相反我們只對來自以下情況的幀感興趣:

  • 被託管的方法
  • 在某種程度上,來自用於實現執行時本身的非受控程式碼

特別是不保證第三方非託管幀的保真度(fidelity),除非知道到這些幀在何處轉換到執行時本身或從執行時本身轉換出來(也就是我們感興趣的一種幀)。

因為我們控制我們感興趣幀的格式(我們稍後再詳細討論這個問題),我們可以確保這些幀可抓取(crawlable),且具有100%的保真度。唯一的額外要求是一種將不相交的執行時幀(disjoint groups of runtime frames)連結在一起的機制,這樣我們就可以跳過任何干預的非託管幀(和不可抓取的)。

下圖說明了包含所有幀型別的堆疊(請注意,本檔案使用了一種慣例,即堆疊向葉(page)頂部增長):

使幀可抓取

託管幀

因為執行時擁有和控制JIT(Just-in-Time編譯器),它可以安排託管方法始終留下可以抓取的幀。這裡的一種解決方案是對所有方法使用嚴格的(rigid)幀格式。然而在實踐中,這可能低效,尤其是對於小葉子(small leaf)方法(例如典型的屬性存取器)。

因為方法的呼叫次數通常多於其幀被抓取的次數(抓取堆疊在執行時中是相對較少的,至少就通常呼叫方法的速率而言),用方法呼叫效能換取一些額外的抓取時間是有合理的。因此,JIT會為其編譯的每個方法生成額外的後設資料,其中包括足夠的資訊,供堆疊爬蟲解碼屬於該方法的堆疊幀。

這些後設資料可以通過以方法中某處的指令指標(instruction pointer)作為鍵,查詢雜湊表得到。JIT使用壓縮技術來最小化這種額外的每方法後設資料的影響。

給定幾個重要暫存器的初始值(例如,基於 x86 的系統上的 EIP、ESP 和 EBP),堆疊爬蟲可以定位託管方法和其關聯的JIT後設資料,並使用這些資訊將暫存器值回滾到方法呼叫者中的當前值。用這種方式,可以從最近的呼叫者到最老的呼叫者,遍歷一系列託管方法幀,此操作有時稱為虛擬展開(virtual unwind)(虛擬的是因為我們實際上並沒有更新ESP等的真實值,堆疊保持不變)。

執行時非託管幀

執行時(有)部分是以非受控程式碼實現的(例如coreclr.dll). 大多數這些程式碼的特殊之處在於,它是作為手動託管的程式碼執行,也就是說,它遵守受控程式碼的許多規則和協定,但以顯式控制的方式。例如,此類程式碼可以顯式地啟用或禁用GC搶佔模式(pre-emptive mode),並且需要相應地管理其物件參照的使用。

與受控程式碼進行這種謹慎互動的另一個區域是在堆疊遍歷過程中。由於大多數執行時的非受控程式碼是用C++編寫的,因此我們對方法幀格式的控制不如受控程式碼。同時,在很多情況下,執行時非託管幀包含了堆疊遍歷期間非常重要的資訊,這包括非託管函數在區域性變數中儲存物件參照(必須在垃圾回收期間報告)和例外處理的情況。

非託管函數不是試圖使每個非託管幀變得抓取,而是將有趣的資料包告到堆疊爬蟲,

與其試圖使每個非託管幀可抓取,帶有有趣資訊的非託管函數,堆疊爬取將資訊捆綁到資料結構中,將資訊捆綁到稱為Frame的資料結構中,這個名稱非常有歧義,因此本檔案總是將該資料結構變數稱為大寫的Frame。

Frame實際上是整個Frame型別層次結構的抽象基礎類別。 Frame被子型別化,以表達堆疊遍歷可能感興趣的不同型別的資訊。但是堆疊遍歷器如何找到這些Frame,並且它們與託管方法使用的幀有何關係?

每個Frame都是單連結串列的一部分,單連結串列有一個next指標,指向這個執行緒的堆疊上下一個更老的Frame(或者是null,如果這個Frame以及是最老的了)。CLR Thread結構持有一個指向最新Frame的指標。非託管執行時程式碼可以根據需要通過操作執行緒(Thread)結構和Frame列表來推播(push)或彈出(pop)Frame。

按照這種方式,堆疊遍歷器可以按照最新到最舊的順序迭代非託管Frames, 但是託管和非託管的方法可以被交叉使用,並且處理後面跟著非託管Frames的所有託管幀將會出錯,反之亦然,因為它不能準確地表示真正的呼叫序列。

為了解決這個問題,Frame被進一步限制,它們必須被分配到堆疊上的方法幀中,該方法幀將它們推播到Frame列表中。由於堆疊遍歷器知道每個託管幀的堆疊邊界,因此它可以執行簡單的指標比較,以判斷給定Frame是否比給定託管幀舊或新。

本質上,堆疊遍歷器在解碼當前幀後,對於下一個(更老的)幀總是有兩種可能選擇:通過暫存器集(register set)的虛擬展開(virtual unwind)確定下一個託管幀,或者執行緒Frame列表上的下一個更老的Frame。這可以通過判斷哪個佔用更靠近棧頂的棧空間來決定哪個合適。所涉及的(involved)實際計算是平臺相關的,但通常轉移(devolves)到一個或兩個指標比較上。

當受控程式碼呼叫非託管執行時時,非託管目標方法通常會推播數種形式的轉換Frame中的一種,這被下面兩種情況需要:

  • 記錄呼叫託管方法的暫存器狀態(以便堆疊遍歷器在完成列舉(enumerating)非託管Frames後可以恢復託管幀的虛擬展開)。
  • 許多情況下因為託管物件參照作為引數傳遞給非託管方法,必須在垃圾回收時報告給GC。

可用Frame型別及其用途的完整描述超出了本檔案的範圍,更多的細節可以在frames.h標頭檔案裡找到。

堆疊遍歷器介面

完整的堆疊遍歷介面僅公開給執行時非受控程式碼(System.Diagnostics.StackTrace類是一個對受控程式碼可用的簡化子集),典型的入口點是通過執行時 Thread類上的StackWalkFramesEx()方法,這個方法的呼叫者要提供下面三個主要的輸入:

  1. 一些上下文指示遍歷的起點。 這是一個初始暫存器集(例如,如果你已暫停目標執行緒並可以在其上呼叫GetThreadContext())或一個初始Frame(在你知道有問題的程式碼是在執行時非受控程式碼中的情況下)。 儘管大多數堆疊遍歷都是從堆疊頂部進行的,但如果你可以確定正確的起始上下文,則可以從較低位置開始。
  2. 一個函數指標和其關聯的上下文。函數是堆疊遍歷器為每個有趣的幀呼叫提供的函數(按從最新到最舊的順序), 提供的上下文值被傳遞給回撥的每次呼叫,以便它可以在遍歷期間記錄或建立狀態。
  3. 指示應觸發回撥的幀型別的標誌。 這允許呼叫者指定僅應報告的純託管方法幀。完整的列表請看threads.h (就在StackWalkFramesEx()宣告的上方).

StackWalkFramesEx()返回一個列舉值,該值指示遍歷是否正常終止(到達堆疊基並用完要報告的方法),是否被某一種回撥中止(回撥函數將同一型別的列舉返回到堆疊遍歷)或遇到一些其它錯誤。

除了傳遞給StackWalkFramesEx() 的上下文值之外,堆疊回撥函數還傳遞了另一段上下文:CrawlFrame,這個類定義在 stackwalk.h ,這個類包含了在堆疊遍歷過程中收集的各種上下文。例如,CrawlFrame為託管幀指示 MethodDesc* ,為非託管Frames指示 Frame*。它還提供了通過虛擬展開幀推斷出的當前暫存器集到該點。

實現細節

堆疊遍歷實現的更多低階細節目前不在本檔案的範圍內。 如果您瞭解這些知識並願意分享這些知識,請隨時更新此檔案。