------ 新春第一炮:階乘算法性能分析與 double fault 藍屏故障排查 Part I ------

——————————————————————————————————————————————————————————————————————————算法

春節期間閒來無事想研究下算法,上機測試代碼卻遇到了意外錯誤,在此記錄整個過程,祝各位新的一年在算法設計和故障排查編程

面的思惟敏銳度媲美 dog 的嗅覺!函數

——————————————————————————————————————————————————————————————————————————性能

 

整數 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)就會發生溢出。spa

相似地,「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! 或更大數的階乘致使溢出:

 

 

做爲對比,下圖則是另外一種「基本遞歸」的階乘計算算法,「recursive_factorial()」只有一個形參,就是要計算階乘的正整數。

前面的邏輯大體與 tail_recursivef_factorial() 相同,除了最後那條 return 語句,它把對自身的遞歸調用放進了一個表達式

中,這種作法對性能的影響是致命的,由於不得不等待遞歸調用終止才能完成整個表達式的求值計算:

 

 ————————————————————————————————————————————————————————————————————————————————————

假設咱們忽略溢出的狀況,或者在 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」存儲它們各自的運行時間:

 

 

 

 

 圖中之內聯彙編添加的軟件斷點是爲了方便觀察 KeQuerySystemTime() 如何使用「LARGE_INTEGER」這個結構體:

 

 

 

 

 

原始文檔寫得很清楚—— 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 字節區域:

 

 

以 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++ 編譯器是如何實現尾遞歸

算法對應的指令序列:

 

上圖編號 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 結構體是怎樣被使用的;

 

上圖中 ebp-18 處的內核棧內容是啥?讓咱們觀察 DriverEntry() 的局部變量統計信息:

 

 

原來 ebp-18 處就是一個 LARGE_INTEGER 實例—— current_time_BEFORE_compute_factorial,而指令「lea eax,[ebp-18h]」

把它的地址移動到 eax 中,而後壓入棧上,這符合 KeQuerySystemTime() 的形參類型要求—— C 的取地址操做符「&」在彙編級別用「lea」指令實

現,形參「PLARGE_INTEGER」須要持有一個 LARGE_INTEGER 實例的地址,單步跟蹤(F8)驗證:

 

此刻咱們進入了系統例程 KeQuerySystemTime() 內部,咱們想知道它當它返回後,變量 current_time_BEFORE_compute_factorial

的內部組織形式;同時還要在後續的 tail_recursivef_factorial() 調用內部設置幾個斷點,方便研究「imul」指令的行爲:

 

 

上圖分別在 KeQuerySystemTime() 返回後(返回地址 9f52e0a1 那裏),以及 tail_recursivef_factorial() 內部的「imul」指令地址處(9f52e063

處),設置了兩個斷點,咱們按下「g」鍵繼續執行以觸發第一個斷點,而後觀察存儲了當前系統時間的 current_time_BEFORE_compute_factorial 結

構內部:

 

能夠看到 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! 就會溢出)

 

 

 

 

通過屢次反覆調試,證實 tail_recursivef_factorial() 計算 229! 時的運行時間在 2—4 毫秒之間,看來即使沒有作編譯器優化,

CPU 的高速運算能力也讓兩百屢次的函數調用在毫秒級別就可以完成。

 👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽👽

這一次讓咱們在 tail_recursivef_factorial() 內部的「imul」指令地址處設置斷點,因爲遞歸調用的關係,這個斷點每次都會

被觸發,直至知足終止條件;在通過四次調用後的概況以下:

 

 

如上圖所示,在第四次執行「imul」指令前,內核棧上已經有 4 次 tail_recursivef_factorial() 的棧幀記錄;

當前的 Computed_Value 值爲 11,852,124(0xb4d95c),也就是 229 * 228 * 227 ——前三次「imul」指令的執行結果,假設

本次再執行「imul」指令把 Computed_Value 與 eax 的當前值(0xe2,亦即 226)相乘,就會發生溢出。

「elf = 00000206」是執行前的 EFLAG 寄存器內容,解碼後的標誌位以下圖,代表還沒有溢出:

另外一個關鍵信息是紅框處的 ebp 值,它暗示每次遞歸調用都會消耗 16 字節的內核棧空間——這 16 字節是怎麼來的呢?

再次回顧 tail_recursivef_factorial() 的反彙編代碼,第一條使用棧上 4 字節空間的指令是「push ebp」、第二條是「push eax」,第三條是「push

ecx」。。。而在「call computefactorialtail!tail_recursivef_factorial」執行前,會隱式地壓入 4 字節的返回地址,這是「call」指令內建的功能,不會做爲

反彙編輸出:

 

 

查看當前執行線程的內核棧,可知其下限在 8b715000 地址處;而首次的 tail_recursivef_factorial() 調用是從 8b717aa8 地址處開始消耗棧空間的,換言

之:(8b717aa8 - 8b715000) / 0x10 = 0n682,僅可以供 682 次遞歸調用,第 683 次調用就會越界,訪問到還沒有分配的物理內存區域,引起一次頁錯誤,後

面我修改源碼計算 683! 並在調試時就會出現這種狀況,它會升級爲「double fault」:

 

 

 如今單步執行,而後檢查「imul」指令的效果:

 

 

上圖中的 EFLAG 寄存器內容(0xa83)經解碼後顯示符號位溢出位都被設置了,代表乘法運算髮生了下溢,觀察 eax 中

存儲的計算結果「9fa7e338」,它的 10 進制值爲「-1,616,387,272」,因此後續的計算結果都是錯誤的。

 ——————————————————————————————————————————————————————————————————————————————————————

小結:本篇介紹經過獲取當前系統時間來測量程序或一段代碼執行性能的方法,揭示了神祕的「LARGE_INTEGER」工做機制,而且比較源碼級和機

指令級算法實現的區別——其差別性徹底由編譯器主導;接着演示 32 位有符號數的溢出。。。全部這些都是在內核態下進行的,所以可謂比通常的用

態調試更「底層」。限於篇幅,下一篇將比較另外一種階乘算法「recursive_factorial()」的機器級實現、執行性能,而後經過遞歸調用訪問無效的內核

棧區域觸發「double fault」並進行故障排查!

————————————————————————————————————————————————————————————————————————————————————————

相關文章
相關標籤/搜索