?作者 | werat
譯者 | 言征
策劃 | 云昭
當(dāng)人們說(shuō)“調(diào)試器是無(wú)用的,使用日志和單元測(cè)試更好”時(shí),我懷疑他們中的許多人認(rèn)為調(diào)試器只能在某些行上設(shè)置斷點(diǎn),一步一步地通過(guò)代碼,并檢查變量值。雖然任何合理的調(diào)試器都可以做到這一切,但這只是冰山一角。想想看;40年前,我們就已經(jīng)可以通過(guò)這一代碼了,當(dāng)然有些事情已經(jīng)改變了嗎?
1、斷點(diǎn)
每個(gè)調(diào)試器都支持?jǐn)帱c(diǎn)。在代碼中的某一行上設(shè)置斷點(diǎn),當(dāng)執(zhí)行到達(dá)該行時(shí),程序?qū)⑼V?。但現(xiàn)代調(diào)試器可以做的遠(yuǎn)遠(yuǎn)不止這些。
列斷點(diǎn)。你知道不僅可以在特定的行上設(shè)置斷點(diǎn),還可以在行+列上設(shè)置斷點(diǎn)嗎?如果一行源代碼包含多個(gè)表達(dá)式(例如,foo() + bar() + baz()等函數(shù)的調(diào)用) ,那么可以在行的中間放置一個(gè)斷點(diǎn),并直接跳到該執(zhí)行點(diǎn)。LLDB已經(jīng)支持了一段時(shí)間,而IDE支持可能會(huì)有所欠缺。Visual Studio有一個(gè)名為Stepinto-specific的命令,它解決了一個(gè)類似的問(wèn)題——如果在同一行上有多個(gè)調(diào)用,它允許你選擇單步執(zhí)行哪個(gè)函數(shù)。
條件斷點(diǎn)。通常,你可以在斷點(diǎn)上設(shè)置一系列額外的選項(xiàng)。例如,你可以指定“命中計(jì)數(shù)”條件,以僅在命中某一次數(shù)或每N次迭代后觸發(fā)斷點(diǎn)?;蛘呤褂酶鼜?qiáng)大的概念——條件表達(dá)式——在應(yīng)用程序處于特定狀態(tài)時(shí)觸發(fā)斷點(diǎn)。例如,只有在主線程和monster->name == "goblin"上發(fā)生命中時(shí),才能觸發(fā)斷點(diǎn)。Visual Studio調(diào)試器還支持“when changes”類型的條件表達(dá)式–當(dāng) monster->hp 的值與上次命中斷點(diǎn)時(shí)相比,發(fā)生變化時(shí)觸發(fā)斷點(diǎn)。
跟蹤斷點(diǎn)(或跟蹤點(diǎn))。但如果斷點(diǎn)沒(méi)有中斷呢??? 不要再說(shuō)了,我們可以向輸出輸出一條消息,而不是停止執(zhí)行。而不僅僅是一個(gè)簡(jiǎn)單的字符串,比如“getherelol”;消息可以包含計(jì)算和嵌入程序值的表達(dá)式,例如“iteration #{i},當(dāng)前monster是{monster->name}”。本質(zhì)上,我們將printf調(diào)用注入到程序中的隨機(jī)位置,而無(wú)需重新構(gòu)建和重新啟動(dòng)程序。這樣代碼就會(huì)很整潔。
數(shù)據(jù)斷點(diǎn)。斷點(diǎn)也不必位于特定的行、地址或函數(shù)上。所有現(xiàn)代調(diào)試器都支持?jǐn)?shù)據(jù)斷點(diǎn),這意味著每當(dāng)內(nèi)存中的某個(gè)特定位置被寫入時(shí),程序都可以停止。你不明白為什么這個(gè)怪物會(huì)隨機(jī)死亡嗎?在monster->hp的位置設(shè)置一個(gè)數(shù)據(jù)斷點(diǎn),并在值發(fā)生變化時(shí)得到通知。這在調(diào)試某些代碼正在寫入不應(yīng)該寫入的內(nèi)存的情況下尤其有用。將其與打印消息相結(jié)合,你將獲得一個(gè)強(qiáng)大的日志記錄機(jī)制,這是printf無(wú)法實(shí)現(xiàn)的!
2、數(shù)據(jù)可視化
另一個(gè)基本的調(diào)試功能——數(shù)據(jù)檢查。任何調(diào)試器都可以顯示變量的值,但好的調(diào)試器為自定義可視化工具提供了豐富的功能。GDB有外觀漂亮的打印,LLDB有數(shù)據(jù)格式化程序,Visual Studio有NatVis。所有這些機(jī)制都非常靈活,在可視化對(duì)象時(shí)幾乎可以做任何事情。對(duì)于檢查復(fù)雜的數(shù)據(jù)結(jié)構(gòu)和不透明的指針來(lái)說(shuō),這是一個(gè)非常寶貴的功能。例如,開(kāi)發(fā)者不必?fù)?dān)心哈希圖的內(nèi)部表示,只需查看鍵/值條目的列表即可。
這些可視化工具非常有用,但好的調(diào)試器可以做得更好。如果你有一個(gè)GUI,為什么只局限于“文本”可視化?調(diào)試器可以顯示數(shù)據(jù)表和圖表(例如SQL查詢的結(jié)果)、渲染圖像(例如圖標(biāo)或紋理)、播放聲音等。圖形界面在這里打開(kāi)了無(wú)限的可能性,這些可視化工具甚至不難實(shí)現(xiàn)。
Visual Studio 中的 Image Watch
3、表達(dá)式求值
大多數(shù)現(xiàn)代調(diào)試器都支持表達(dá)式求值。其思想是,你可以鍵入表達(dá)式(通常使用程序的語(yǔ)言),調(diào)試器將使用程序狀態(tài)作為上下文對(duì)其進(jìn)行評(píng)估。例如,鍵入 monsters[i]->get_name() ,調(diào)試器顯示“goblin”(其中monsters和i是當(dāng)前范圍中的變量)。顯然,在不同的調(diào)試器和不同的語(yǔ)言中,實(shí)現(xiàn)有很大的差異。
例如,Visual Studio C++調(diào)試器實(shí)現(xiàn)了C++的推理子集,甚至可以執(zhí)行函數(shù)調(diào)用(有一些限制)。它使用基于解釋器的方法,因此它非常快速且“安全”,但不允許執(zhí)行真正的任意代碼。GDB也做了同樣的事情。另一方面,LLDB使用實(shí)際的編譯器(Clang)將表達(dá)式編譯為機(jī)器代碼,然后在程序中執(zhí)行它(盡管在某些情況下,它可以使用解釋作為優(yōu)化)。這實(shí)際上允許執(zhí)行任何有效的C++!
表達(dá)式求值是一個(gè)非常強(qiáng)大的功能,它為程序分析和實(shí)驗(yàn)開(kāi)辟了許多可能性。通過(guò)調(diào)用函數(shù),你可以探索程序在不同情況下的行為,甚至可以更改其狀態(tài)和執(zhí)行。調(diào)試器還經(jīng)常使用表達(dá)式求值來(lái)增強(qiáng)其他功能,如條件斷點(diǎn)、數(shù)據(jù)監(jiān)視和數(shù)據(jù)格式化程序。
4、并發(fā)和多線程
開(kāi)發(fā)和調(diào)試多線程應(yīng)用程序很困難。許多與并發(fā)相關(guān)的錯(cuò)誤很難再現(xiàn),尤其在調(diào)試器下運(yùn)行時(shí),程序運(yùn)行的行為飄忽不定。不過(guò),好的調(diào)試器可以在這里提供很多幫助。
調(diào)試器可以節(jié)省大量時(shí)間。一個(gè)很好的例子是調(diào)試死鎖。如果你設(shè)法使應(yīng)用程序處于死鎖狀態(tài),那么你就幸運(yùn)了!一個(gè)好的調(diào)試器將顯示所有線程的調(diào)用堆棧以及它們之間的依賴關(guān)系。很容易看出哪些線程正在等待哪些資源(例如互斥鎖)以及誰(shuí)在占用這些資源。不久前,我寫了一篇關(guān)于在VisualStudio中調(diào)試死鎖的案例的文章,看看它有多簡(jiǎn)單。
開(kāi)發(fā)和調(diào)試多線程應(yīng)用程序的一個(gè)非常常見(jiàn)的問(wèn)題是,很難控制執(zhí)行哪些線程的時(shí)間和順序。許多調(diào)試器都遵循“全有或全無(wú)”策略,這意味著當(dāng)斷點(diǎn)命中時(shí),整個(gè)程序(即其所有線程)都會(huì)停止。如果單擊“繼續(xù)”,所有線程將再次開(kāi)始運(yùn)行。如果程序中的線程不重疊,這可以正常工作,但當(dāng)相同的代碼由不同的線程執(zhí)行,并且以隨機(jī)順序命中相同的斷點(diǎn)時(shí),這會(huì)變得非常煩人。
一個(gè)好的調(diào)試器可以凍結(jié)和解凍線程。你可以選擇哪些線程應(yīng)該執(zhí)行,哪些線程應(yīng)該休眠。這使得調(diào)試高度并行化的代碼更加容易,而且你還可以模擬不同的競(jìng)爭(zhēng)條件和死鎖。在Visual Studio中,你可以在UI中凍結(jié)和解凍線程,而GDB有一種叫做不停止模式的功能。RemedyBG有一個(gè)非常方便的UI,你可以快速切換到“solo”模式并返回。
之前提到,調(diào)試器可以顯示線程之間的依賴關(guān)系。一個(gè)好的調(diào)試器還支持協(xié)同程序(綠色線程、任務(wù)等),并提供一些工具來(lái)可視化當(dāng)前程序狀態(tài)。例如,Visual Studio有一個(gè)叫做并行堆棧的功能。在此窗口中,你可以快速了解整個(gè)程序狀態(tài),并查看不同線程正在執(zhí)行的代碼。
5、熱重載
想象一個(gè)典型的調(diào)試會(huì)話。你運(yùn)行程序,加載數(shù)據(jù),執(zhí)行一些操作,最后到達(dá)發(fā)現(xiàn)錯(cuò)誤的位置。你設(shè)置了一些斷點(diǎn),一步一步,突然意識(shí)到某個(gè)“if”條件是錯(cuò)誤的——它應(yīng)該是 >= 而不是 > 。你接下來(lái)要做什么?停止程序,修復(fù)條件,重建程序,運(yùn)行它,加載數(shù)據(jù),執(zhí)行一些操作…等等?,F(xiàn)在是2023年,你下一步要做什么?
修復(fù)條件并保存文件。很輕松動(dòng)兩下,程序就會(huì)接收代碼中的更改!它沒(méi)有重新啟動(dòng),也沒(méi)有失去狀態(tài),它就在你離開(kāi)它的地方。你立即發(fā)現(xiàn)你的修復(fù)程序不正確,實(shí)際上應(yīng)該是 == 。再次修復(fù)。
這種神奇的特性被稱為熱重載——一個(gè)好的調(diào)試器可以在不重新啟動(dòng)的情況下獲取源代碼中的更改并將其應(yīng)用于實(shí)時(shí)運(yùn)行的程序。許多使用動(dòng)態(tài)或基于VM的語(yǔ)言(如JavaScript、Python或Java)的人都知道這是一件事,但并不是所有人都意識(shí)到C++或Rust等編譯語(yǔ)言也有可能這樣做!例如,Visual Studio支持通過(guò)“編輯并繼續(xù)”對(duì)C++進(jìn)行熱重新加載。它確實(shí)有一長(zhǎng)串的限制和不支持的更改,但它在許多常見(jiàn)場(chǎng)景(演示)中仍能正常工作。
另一項(xiàng)令人驚嘆的技術(shù)是Live++——可以說(shuō)是當(dāng)今最好的熱重載解決方案。它支持不同的編譯器和構(gòu)建系統(tǒng),可以與任何IDE或調(diào)試器一起使用。不受支持的場(chǎng)景列表要短得多,其中許多都不是基本的限制——只要付出足夠的努力,熱重新加載幾乎可以處理任何類型的更改。
熱重新加載不僅僅是將更改應(yīng)用于實(shí)時(shí)程序。一個(gè)好的熱重新加載實(shí)現(xiàn)可以幫助從諸如訪問(wèn)違規(guī)之類的致命錯(cuò)誤中恢復(fù),或者改變不同編譯單元的優(yōu)化級(jí)別(以及可能的任何其他編譯器標(biāo)志)。它還可以遠(yuǎn)程執(zhí)行,同時(shí)執(zhí)行多個(gè)進(jìn)程。
6、Time travel
有沒(méi)有遇到過(guò)這樣的問(wèn)題,就是你在代碼中踩得太遠(yuǎn)了?只是一點(diǎn)點(diǎn),但傷害已經(jīng)造成了。這時(shí)候,我們只能重新啟動(dòng)程序并重試,并后退幾步。這可能比熱重載更神奇,但一個(gè)好的調(diào)試器實(shí)際上可以及時(shí)運(yùn)行。后退一步或設(shè)置一個(gè)斷點(diǎn),然后反向運(yùn)行,直到它被擊中,就像是2023年,而不是1998年一樣。
許多調(diào)試器都支持這種操作。GDB通過(guò)記錄每個(gè)指令所做的寄存器和內(nèi)存修改來(lái)實(shí)現(xiàn)時(shí)間旅行,這使得撤消更改變得很簡(jiǎn)單。然而,這會(huì)導(dǎo)致顯著的性能開(kāi)銷,因此在非交互模式下可能不太實(shí)用。另一種流行的方法,則基于大多數(shù)程序執(zhí)行是確定性的觀察。每當(dāng)發(fā)生不確定的事情(系統(tǒng)調(diào)用、I/O等)時(shí),我們都可以對(duì)程序進(jìn)行快照,然后通過(guò)將其倒回到最近的快照并從那里執(zhí)行代碼,隨時(shí)重建程序狀態(tài)。這基本上就是UDB、WinDBG和rr所做的。
↑ 使用 Time Travel Debug for C/C++
7、全方位調(diào)試
最后一件事,是在調(diào)試場(chǎng)景中徹底改變游戲規(guī)則。傳統(tǒng)調(diào)試有很多缺點(diǎn)。記錄和回放是向前邁出的一大步,但如果除了記錄可再現(xiàn)的程序跟蹤之外,我們還預(yù)先計(jì)算了所有單獨(dú)的程序狀態(tài),將它們存儲(chǔ)在數(shù)據(jù)庫(kù)中,并建立了索引以進(jìn)行有效查詢,會(huì)怎么樣?
這聽(tīng)起來(lái)是不可能的,但實(shí)際上卻出奇地可行。結(jié)果表明,程序狀態(tài)壓縮得很好,每條指令的存儲(chǔ)量小于1bit!
這種方法被稱為全知調(diào)試,它不僅解決了傳統(tǒng)調(diào)試器所面臨的一系列問(wèn)題(例如堆棧展開(kāi)),而且還打開(kāi)了我們以前認(rèn)為不可能實(shí)現(xiàn)的可能性。隨著整個(gè)程序歷史記錄和索引,你可以問(wèn)一些問(wèn)題,比如“變量寫了多少次,寫在哪里?”、“哪個(gè)線程釋放了這塊內(nèi)存?”甚至“這個(gè)特定的像素是如何渲染的?”。
還推薦觀看羅伯特·奧卡拉漢(Robert O'Callahan,rr的作者)的《2022年的調(diào)試狀態(tài)》(The State Of Debugging in 2022),這本書很好地說(shuō)明了為什么全方位調(diào)試是未來(lái),我們應(yīng)該對(duì)工具提出更高的要求。
盡管這個(gè)想法可以追溯到幾十年前,但高效實(shí)用的實(shí)現(xiàn)很難。現(xiàn)代全知調(diào)試器的一個(gè)很好的例子是Pernosco。它有一長(zhǎng)串受支持的功能和用例,甚至簡(jiǎn)單的演示看起來(lái)都難以置信。
另一個(gè)很棒的工具是WhiteBox。它在編寫代碼時(shí)編譯、運(yùn)行和“調(diào)試”代碼,為開(kāi)發(fā)者提供對(duì)程序流程和結(jié)構(gòu)的寶貴見(jiàn)解。它記錄執(zhí)行情況,并允許你隨時(shí)檢查程序狀態(tài)。不過(guò)它仍然處于測(cè)試階段。
7、調(diào)式or不調(diào)試?
每個(gè)現(xiàn)有的調(diào)試器都有其優(yōu)缺點(diǎn),不存在真正的銀彈。在某些情況下,日志記錄更方便,而在其他情況下,使用Time Travel調(diào)試器則可以將錯(cuò)誤調(diào)查的時(shí)間,從幾天縮短到幾分鐘。調(diào)試技術(shù)已經(jīng)取得了長(zhǎng)足的進(jìn)步,有很多有趣的特性值得一看。開(kāi)發(fā)者在使用過(guò)程中也可以從本地調(diào)試器供應(yīng)商那里,提出改善的需求。
那么,你最喜歡調(diào)試器的哪項(xiàng)功能呢?
參考鏈接:https://werat.dev/blog/what-a-good-debugger-can-do/