階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I

Editor發表於2018-09-05

整數 n 的階乘(factorial)記作“n!”,比如要計算 5!,那麼就是計算 5 * 4 * 3 * 2 * 1 = 120。


在 32 位系統上,“unsigned int(ULONG)”型變數能夠持有的最大 10 進位制值為 4,294,967,295(FFFF FFFF),意味著無符號數最多隻能用來計算 12!(479,001,600 = 1C8C FC00);若計算 13!(6,227,020,800 = 1 7328 CC00)就會發生溢位。


類似地,“int”型變數能夠持有的最大 10 進位制值為 2,147,483,647(7FFF FFFF),意味著有符號數最多也只能用來計算12!;若計算 13! 就會發生下溢(8000 0000 = -2,147,483,648)。


一般的程式設計正規化通常以函式遞迴呼叫自身來實現階乘計算,並在函式內部新增遞迴的終止條件。


下圖是一種叫做“尾遞迴”的階乘計算演算法,從原始碼級別來看,它的巧妙之處在於第二個形參“computed_value”可以用來儲存本次遞迴的計算結果,然後作為下一次的輸入。每次第一個引數“number”的值都遞減,終止條件就是當它降到 1 時,即返回最新的 computed_value值。“tail_recursivef_factorial()”開頭的判斷邏輯確保了我們不會因為計算 13! 或更大數的階乘導致溢位:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


作為對比,下圖則是另一種“基本遞迴”的階乘計算演算法,“recursive_factorial()”只有一個形參,就是要計算階乘的正整數。


前面的邏輯大致與 tail_recursivef_factorial() 相同,除了最後那條 return 語句,它把對自身的遞迴呼叫放進了一個表示式中,這種做法對效能的影響是致命的,因為不得不等待遞迴呼叫終止才能完成整個表示式的求值計算:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


假設我們忽略溢位的情況,或者在 64 位系統上執行這段程式碼,就可以傳入更大的正整數。而從原始碼上看,recursive_factorial() 的效能嚴重依賴於輸入引數——試想要計算 100!,它可能需要反覆地建立,銷燬函式呼叫棧幀 100 次,才能完成表示式求值並返回。


反觀 tail_recursivef_factorial(),因為它引入了一個額外變數儲存每次呼叫的結果,從形式上而言與 for 迴圈並無太大區別,“貌似”編譯器可以優化這段程式碼來生成與 for 迴圈類似的彙編指令,從而避免函式呼叫造成的額外 CPU 時鐘週期開銷(反覆的壓棧彈棧都需要訪問記憶體)。


我們的美好願望是:同樣計算 100!,tail_recursivef_factorial() 無需多餘的 99 次函式呼叫棧幀開銷,在彙編級別直接用與類似 for迴圈的迭代控制結構即可實現相同效果,使得執行時間大幅縮短。


在後面的除錯環節你會看到:這個“美好願望”或許對其它編譯器而言能夠成立,對 Visual C/C++ 編譯器而言則不行——它還不夠智慧來進行尾遞迴優化(或稱尾遞迴“消除”)。


做效能分析就需要計算兩者的執行時間,我們使用核心例程“KeQuerySystemTime()”,分別在兩個函式各自的呼叫前後獲取一次當前系統時間,然後相減得出差值,它就是兩種階乘計算演算法的執行時間,如下圖,注意黃框部分的邏輯,變數“execution_time_of_factorial_algorithm”儲存它們各自的執行時間:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I

階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


圖中以內聯彙編新增的軟體斷點是為了方便觀察 KeQuerySystemTime() 如何使用“LARGE_INTEGER”這個結構體:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I



原始文件寫得很清楚—— KeQuerySystemTime() 輸出的系統時間(由一枚“LARGE_INTEGER”型指標引用)是從 1601年1月1日開始至當前的“100 納秒”數量,通常約每 10 毫秒會更新一次系統時間。


KeQuerySystemTime() 的輸出值是根據 GMT 時區計算的,使用 ExSystemTimeToLocalTime() 可以把它調整為本地時區的值。


既然 1 毫秒 = 1000 微秒 = 1000000 納秒,只需把這個值除以 10000 即可得到“毫秒數”,再除以 1000 即可得出以秒為單位的執行時間。


但是事情沒那麼簡單,你想看看:從 1601年1月1日以來到當前 KeQuerySystemTime() 呼叫經歷了多少個“100 納秒”,無論這個數值為何,肯定不是 32 位系統上的 4 位元組變數能夠容納得下的,所以要麼在 64 位 Windows 上除錯這段程式碼,要麼必須使用LARGE_INTEGER 結構體的QuadPart欄位,該欄位實質上是記憶體中一個連續的 8 位元組區域:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


以 32 位系統而言,ULONG 型變數最多支援 4294967295 個“100 納秒”,亦即 429 秒;換言之,階乘演算法執行超過 7 分鐘,就無法用 ULONG 變數(execution_time_of_factorial_algorithm)儲存執行時間(該值已溢位所以不正確)。這不是問題,我們的測試程式碼載體是核心態驅動程式,沒有核心-使用者模式的切換開銷,加上現代高效能微處理器每秒都能夠執行 上千萬條指令,所以上述兩種演算法再怎麼低效,執行時間應該都在數十毫秒級別,除非我們計算 1000!乃至 10000!——在後面你會看到,從理論上而言(忽略 64 位數能夠表示的上限值,即便連 64 位數也無法存放 21! 和更大的正整數階乘值),recursive_factorial() 求值 10000!所需的執行時間可能緩慢到秒級別,但事實上,每個執行緒的核心棧空間是很狹小的,以至於當我們計算 255! 時就會因為向核心棧上壓入過多的引數而越界,訪問到了無效的記憶體地址,導致頁錯誤,而此後向同一個無效地址壓入異常現場並轉移控制到錯誤處理程式之前,會進一步升級成“double fault”,因為連續兩次訪存操作都是無效的,最終致使系統崩潰藍屏(或者斷入偵錯程式)。


總而言之,兩個從 1601年1月1日以來的歷時是 64 位數,相減後只有低 32 位——多數情況下,高 32 位都是零。這樣我們就能夠比較兩種演算法的效能優劣了。


正如你可能意識到的那樣:當要計算階乘的數太小時,兩者間的效能差距不明顯,所以我把上面計算 12! 的邏輯改成了計算 229!,同時又不會導致核心棧溢位,除錯過程如下,首先來看看 tail_recursivef_factorial() 的反彙編程式碼,它說明了微軟 Visual C/C++ 編譯器是如何實現尾遞迴演算法對應的指令序列: 


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


上圖編號 1 黃框中的彙編程式碼把 ebp+8 處的核心記憶體與立即數 0xe6(230)比較(cmp),如果低於等於 230 就跳轉到 9f52e044地址處執行(jbe),反之則清零 eax 暫存器後跳轉到 9f52e074 地址處,在那裡的“pop ebp”和“ret 8”(圖中沒有繪出)指令序列導致 tail_recursivef_factorial() 返回——因此我們推斷 ebp+8 就是第一個引數number,並對應於原始碼中檢查它是否大於 230 的邏輯;類似地,編號 2 黃框中的彙編程式碼對應原始碼中檢查 number 是否等於 0 的邏輯——如果不等於 0 則跳轉(jne)到 9f52e053地址處(編號 3 黃框),在該處繼續檢查 number 是否等於 1 ——如果 number 已經遞減至 1,表明滿足遞迴退出條件,把ebp + c 處的棧記憶體值(亦即 第二個引數computed_value)拷貝到 eax 暫存器內作為返回值,跳轉到 9f52e074 地址處返回;否則,把 number 移動到 eax 中並與 computed_value 執行有符號乘法(imul),然後把儲存在 eax 中的計算結果壓入棧上,同時 number 遞減 1 後的值移動到 ecx 中(通常被當成迴圈計數器),為下一次的 tail_recursivef_factorial() 呼叫做好準備。


從上圖你可以發現兩件有趣的事情:


其一,儘管我在原始碼中顯示指定了兩個引數的型別,以及返回值均為“ULONG”(無符號),但 Visual C/C++ 編譯器依舊無動於衷,堅持在彙編級別使用有符號數乘法指令“imul”,而非無符號的版本“mul”;而根據 intel 手冊,“imul”指令的雙運算元模式中,如果計算結果超過了目的運算元(本例中是 eax)的大小,則從乘積的最高位開始截斷——若被丟棄的不是符號位,該指令會設定EFLAG 暫存器中的溢位和進位標誌—— 32 位有符號數的上限值為 2,147,483,647(7FFF FFFF),若超出就會下溢,結合上面的反彙編程式碼推算:當第四次遞迴呼叫時(229 * 228 * 227 * 226,亦即當 ecx 值為 0xe2 時)就會發生下溢,從而設定相關標誌位,我們在後面除錯會驗證;


其二,儘管原始碼中的尾遞迴呼叫已經刻意書寫成能夠被編譯器利用等價的迭代控制結構替換,從而節約反覆的函式呼叫開銷,但Visual C/C++ 卻笨得沒有意識到這一點,還是傻傻地照本宣科來翻譯,這導致我們的 tail_recursivef_factorial() 實際執行效能不如理論上那樣比基本遞迴的 recursive_factorial() 優越!


瞭解 tail_recursivef_factorial() 的機器機實現後,接下來就是斷點設定的藝術了——當前觸發的斷點是我在原始碼中指定的,位於 KeQuerySystemTime() 呼叫前,目的是檢查 LARGE_INTEGER 結構體是怎樣被使用的;


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


上圖中 ebp-18 處的核心棧內容是啥?讓我們觀察 DriverEntry() 的區域性變數統計資訊:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


原來 ebp-18 處就是一個 LARGE_INTEGER 例項——current_time_BEFORE_compute_factorial,而指令“lea eax,[ebp-18h]”把它的地址移動到 eax 中,然後壓入棧上,這符合 KeQuerySystemTime() 的形參型別要求—— C 的取地址操作符“&”在彙編級別用“lea”指令實現,形參“PLARGE_INTEGER”需要持有一個 LARGE_INTEGER 例項的地址,單步跟蹤(F8)驗證:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


此刻我們進入了系統例程 KeQuerySystemTime() 內部,我們想知道它當它返回後,變數 current_time_BEFORE_compute_factorial的內部組織形式;同時還要在後續的 tail_recursivef_factorial() 呼叫內部設定幾個斷點,方便研究“imul”指令的行為:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


上圖分別在 KeQuerySystemTime() 返回後(返回地址 9f52e0a1那裡),以及 tail_recursivef_factorial() 內部的“imul”指令地址處(9f52e063處),設定了兩個斷點,我們按下“g”鍵繼續執行以觸發第一個斷點,然後觀察儲存了當前系統時間的 current_time_BEFORE_compute_factorial 結構內部:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I

可以看到 current_time_BEFORE_compute_factorial 的 QuadPart 欄位 10 進位制值為 131633454897796336,它就是自從1601年1月1日以來經過的“100”納秒數量——讓我們轉換成年:131633454897796336 / (10000 * 1000 * 60 * 60 * 24 * 365) =417。最終結果等於 2018 - 1601 = 417 年。至此我們成功通過 KeQuerySystemTime() 獲取到當前系統時間。


此外,ebp-10 處的核心棧儲存另一個 LARGE_INTEGER 例項:current_time_AFTER_compute_factorial,兩者佔用的空間差值(0x8 位元組)就是 LARGE_INTEGER 結構體的大小。先禁用掉 9f52e063 的斷點,然後在9f52e0bb處,也就是第二次 KeQuerySystemTime() 呼叫的返回地址設定第三個斷點,


這樣可以準確地計算出尾遞迴階乘演算法的執行時間,如下圖所示,把這兩個 LARGE_INTEGER 的QuadPart欄位值相減,換算成毫秒,執行時間為:(131633454897826432 - 131633454897796336) / 10000 =3 毫秒;229! 值為零是因為發生了溢位(前面講過,32 位系統上計算 13! 就會溢位)


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I

階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


經過多次反覆除錯,證明 tail_recursivef_factorial() 計算 229! 時的執行時間在 2—4 毫秒之間,看來即便沒有做編譯器優化,CPU 的高速運算能力也讓兩百多次的函式呼叫在毫秒級別就能夠完成。


這一次讓我們在 tail_recursivef_factorial() 內部的“imul”指令地址處設定斷點,由於遞迴呼叫的關係,這個斷點每次都會被觸發,直至滿足終止條件;在經過四次呼叫後的概況如下:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


如上圖所示,在第四次執行“imul”指令前,核心棧上已經有 4 次 tail_recursivef_factorial() 的棧幀記錄;當前的 Computed_Value 值為11,852,124(0xb4d95c),也就是 229 * 228 * 227 ——前三次“imul”指令的執行結果,假設本次再執行“imul”指令把 Computed_Value 與 eax 的當前值(0xe2,亦即 226)相乘,就會發生溢位。“elf = 00000206”是執行前的 EFLAG 暫存器內容,解碼後的標誌位如下圖,表明尚未溢位:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


另一個關鍵資訊是紅框處的 ebp 值,它暗示每次遞迴呼叫都會消耗16 位元組的核心棧空間——這 16 位元組是怎麼來的呢?

再次回顧 tail_recursivef_factorial() 的反彙編程式碼,第一條使用棧上 4 位元組空間的指令是“push ebp”、第二條是“push eax”,第三條是“push ecx”。而在“call computefactorialtail!tail_recursivef_factorial”執行前,會隱式地壓入 4 位元組的返回地址,這是“call”指令內建的功能,不會作為反彙編輸出:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


檢視當前執行執行緒的核心棧,可知其下限在8b715000地址處;而首次的 tail_recursivef_factorial() 呼叫是從 8b717aa8地址處開始消耗棧空間的,換言之:(8b717aa8 - 8b715000) / 0x10 = 0n682,僅能夠供 682 次遞迴呼叫,第 683 次呼叫就會越界,訪問到尚未分配的實體記憶體區域,引發一次頁錯誤,後面我修改原始碼計算 683! 並在除錯時就會出現這種情況,它會升級為“double fault”:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I

 

現在單步執行,然後檢查“imul”指令的效果:


階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I


上圖中的 EFLAG 暫存器內容(0xa83)經解碼後顯示符號位和溢位位都被設定了,表明乘法運算髮生了下溢,觀察 eax 中儲存的計算結果“9fa7e338”,它的 10 進位制值為“-1,616,387,272”,所以後續的計算結果都是錯誤的。


小結:本篇介紹通過獲取當前系統時間來測量程式或一段程式碼塊執行效能的方法,揭示了神祕的“LARGE_INTEGER”工作機制,並且比較原始碼級和機器指令級演算法實現的區別——其差異性完全由編譯器主導;接著演示 32 位有符號數的溢位。所有這些都是在核心態下進行的,因此可謂比一般的使用者態除錯更“底層”。限於篇幅,下一篇將比較另一種階乘演算法“recursive_factorial()”的機器級實現、執行效能,然後通過遞迴呼叫訪問無效的核心棧區域觸發“double fault”並進行故障排查!





原文作者:shayi(看雪ID)

原文連結:[原創]階乘演算法效能分析與 DOUBLE FAULT 藍屏故障排查 PART I

轉載請註明:轉自看雪論壇。





看雪閱讀推薦:


1、[翻譯] 利用DNS重繫結攻擊專用網路


2、[翻譯]利用機器學習檢測惡意PowerShell-『外文翻譯』-看雪安全論壇


3、[原創]看雪.京東 2018CTF 第十五題 智慧裝置 Writeup


4、[原創]淺談編碼與記憶體----自我總結與經驗分享-『程式設計技術』-看雪安全論壇


5、[翻譯]radare2高階-『外文翻譯』-看雪安全論壇


相關文章