和各種詭異?Bug?打交道?13?年,我總結(jié)了?18?個(gè)經(jīng)驗(yàn)
Bug 是絕佳的學(xué)習(xí)機(jī)會(huì)。所以我們?cè)鯓颖M可能多地從修復(fù)的 bug 中吸取經(jīng)驗(yàn)?我至今已經(jīng)用了 13 年的一個(gè)方法是,寫下這個(gè) bug 的簡(jiǎn)單描述、修復(fù)方法以及吸取的經(jīng)驗(yàn)。來源:CPP開發(fā)者
遙想 2002 年,我偶然發(fā)現(xiàn)一篇描述這個(gè)方法的博客(很不幸,我沒再找到)。我從那時(shí)起就使用這個(gè)方法了,并且相信它能幫助我成為一個(gè)更出色的程序員。
每當(dāng)我修復(fù)一個(gè)特別棘手或有趣的 bug,我總會(huì)花幾分鐘寫下一些這個(gè) bug 的信息。這有一個(gè)典型記錄的例子:
例子
【日期】:2004-08-17
【問題】:當(dāng)解碼 Q.931 信令時(shí)無限循環(huán)
【原因】:當(dāng)在Q.931信令中發(fā)現(xiàn)一個(gè)未知的元素id時(shí),我們?cè)噲D通過讀取它的長(zhǎng)度來跳過它,并且將位置指針遷移幾個(gè)字節(jié)。但是,在這個(gè)例子中的長(zhǎng)度是零,導(dǎo)致我們反復(fù)跳過相同的元素id。
【怎么發(fā)現(xiàn)的】:在解碼一個(gè) Ethereal 從 Nortel 追蹤到的安裝信息時(shí)發(fā)現(xiàn)了這個(gè)問題。他們的信息是 1016 字節(jié)長(zhǎng)度(包含大量快速啟動(dòng)元素),但我們的 MSG_MAX_LEN 是 1000。通常我們會(huì)收到一條來自 common/Communication.cxx 的信息,但現(xiàn)在,當(dāng)直接輸入需要解析的數(shù)據(jù)時(shí),數(shù)組末端內(nèi)存訪問越界,其恰好是 0,暴露了這個(gè)問題。
為了找到它,我僅僅在 9931 解碼中添加一些打印輸出。但很幸運(yùn)數(shù)據(jù)恰好是零。
【修復(fù)】:如果長(zhǎng)度是零,設(shè)置為 1。這方式總是行得通。
【在哪些文件修改了】:
callh/q931_msg.cxx
callh/q931_msg.cxx
【我導(dǎo)致的】:是的
【解決Bug的時(shí)間】:1小時(shí)
【教訓(xùn)】:信任收到信息中獲得的數(shù)據(jù)。不僅僅是產(chǎn)生大量可能導(dǎo)致問題的數(shù)據(jù)。顯示長(zhǎng)度為 0 也同樣不好。
實(shí)施方式
我有一個(gè)命名為 bugs.txt 的純文本文件。在文件的頂部是一個(gè)具備所有標(biāo)題的模板,但是沒有包含信息。當(dāng)我添加一個(gè)新的記錄,我復(fù)制模板部分,粘貼在模板下面。然后完成記錄并且填滿信息。大多數(shù)展示在上面例子中的 fields 應(yīng)該是不需要聲明的。有必要指出這并不是 bug 追蹤器。我并沒有添加所有我修復(fù)的 bug 。例如,如果我只是忘記在代碼中添加聲明,一旦發(fā)生這種情況,我就意識(shí)到問題出在哪里了,我并不會(huì)添加這條記錄。僅僅當(dāng) bug 本身,或是修復(fù),或是調(diào)試過程特別有趣,我才會(huì)添加一條新的記錄。通常,是我導(dǎo)致了這個(gè) bug。但是偶爾,特別是花幾天時(shí)間才追蹤到的困難 bug,盡管不是我造成的我也會(huì)添加一條記錄。
一旦修復(fù)了一個(gè) bug,我的第一反應(yīng)是松了口氣然后繼續(xù)前進(jìn)。我試著一更正后就寫下這個(gè)記錄。這時(shí)我的腦海中依然清晰保留所有的細(xì)節(jié)。拖延會(huì)讓精確回憶發(fā)生的事情變得很困難(或者我壓根忘記寫下這條記錄)。
至今,我已經(jīng)有 194 條記錄,平均每個(gè)月有一條新的記錄。最重要的是教訓(xùn)部分。這里需要自我反省。是什么導(dǎo)致這個(gè) bug 的特殊性?我發(fā)現(xiàn)經(jīng)驗(yàn)常常來自于三個(gè)不同的方面:
編碼。我在代碼中犯了什么錯(cuò)誤?我是否忘記了 else 部分代碼?是否系統(tǒng)調(diào)用失敗,但沒有檢查 response?我在未來應(yīng)該怎么調(diào)整代碼來防止這些問題?
測(cè)試。一般不包括本可以在測(cè)試中捕獲的 bug 。要是這樣的話,應(yīng)該在哪個(gè)階段測(cè)試——單元、功能、系統(tǒng)?丟失了哪個(gè)測(cè)試用例?
調(diào)試。我本可以怎樣快速定位 bug?我是否使用了正確的方法?我是否假定了太多?我是否需要在代碼中使用更好的日志記錄?
益處
Nassim Nicholas Taleb 在 《Antifragile》中寫到:“錯(cuò)誤包含豐富的信息”。我完全同意這個(gè)觀點(diǎn)。Bug 幫助我們更好地理解系統(tǒng),告訴我們?cè)鯓犹岣呔幋a、測(cè)試和調(diào)試技巧。所以我認(rèn)為盡可能從 bug 中學(xué)習(xí)經(jīng)驗(yàn),是再正常不過的事了。我發(fā)現(xiàn)為每個(gè)有趣的 bug 記錄下來,讓我輕易學(xué)習(xí)到很多。在記錄的行為中我會(huì)對(duì)發(fā)生的事情思考得更深刻。同樣,一旦記錄下來,我可以在之后檢查發(fā)生的事情。偶爾,我也會(huì)瀏覽文件,只閱讀教訓(xùn)部分,對(duì)我認(rèn)為是從 bug 中學(xué)到的最有價(jià)值的經(jīng)驗(yàn)加強(qiáng)記憶。
我記錄 bug 文件至今已經(jīng)有 13 年了。這是一段漫長(zhǎng)的時(shí)間,但是我堅(jiān)持下來了,因?yàn)樽鳛橐幻绦騿T,它幫助我進(jìn)步。嘗試一下吧,看看它是否也對(duì)你有益!
在上面,我寫了我是怎樣追蹤這些年遇到的最有趣 bug 的。最近我重新瀏覽了這所有的 194 個(gè)條目(歷時(shí) 13 年),看看我從這些 bug 中學(xué)到了學(xué)到了那些重要的經(jīng)驗(yàn)教訓(xùn)。我分為編碼、測(cè)試和調(diào)試三大類。
編碼
這些都是過去給我?guī)砑?bug 的問題:
1.事件順序
當(dāng)處理事件時(shí),問以下問題富有成效:事件是否可以以不同的順序到達(dá)?如果沒收到這些事件怎么辦?如果事件在同一行出現(xiàn)兩次怎么辦?即使這通常不會(huì)發(fā)生,在系統(tǒng)的其他部分(或交互系統(tǒng))中的bug也會(huì)導(dǎo)致它發(fā)生。
2.處理太早
這是上述“事件順序”中的一個(gè)特殊情況,但是它已導(dǎo)致了一些棘手的bug,所以它自成一派。例如,如果信令信息接收得過早,在配置和啟動(dòng)程序完成之前接收,許多奇怪的行為就會(huì)發(fā)生。另一個(gè)例子,當(dāng)一個(gè)連接在被放入空閑列表之前就被標(biāo)記為斷開。當(dāng)我們處理這個(gè)問題時(shí),我們通常假設(shè)它處在空閑列表狀態(tài)時(shí)被標(biāo)記為斷開(但是當(dāng)時(shí)它為什么沒有從這個(gè)列表上撤下?) 沒考慮到事情有時(shí)發(fā)生過早是由于我們沒有想到。
3.隱蔽故障
例如,一些最難找的的 bug 是由于出現(xiàn)了隱蔽故障而繼續(xù)執(zhí)行而不是給出錯(cuò)誤的代碼導(dǎo)致的。例如,系統(tǒng)調(diào)用(如綁定)返回未檢查的錯(cuò)誤代碼。另一個(gè)例子:當(dāng)遇到一個(gè)錯(cuò)誤元素時(shí),直接返回而不是給出錯(cuò)誤的解析代碼。調(diào)用在故障的狀態(tài)下持續(xù)了一段時(shí)間,使得調(diào)試的難度加大。一旦故障被檢測(cè)出,最好要及時(shí)返回這個(gè)錯(cuò)誤。
4.If 語句
含有多個(gè)條件的If語句(if (a or b),尤其是當(dāng)嵌套時(shí),if (x) else if (y)),給我導(dǎo)致了許多 bug。即使If語句在概念上很簡(jiǎn)單,當(dāng)它有多個(gè)條件需要追蹤時(shí),很容易出錯(cuò)。最近我嘗試重新把代碼寫得簡(jiǎn)潔,避免出現(xiàn)復(fù)雜的If語句。
5.Else
有一些bug的產(chǎn)生是由于沒有恰當(dāng)?shù)乜紤]如果條件為假,什么應(yīng)該發(fā)生。在幾乎所有的情況下,每個(gè)If語句都應(yīng)該有個(gè)else部分。而且,如果你在If語句的一個(gè)分支中設(shè)置了一個(gè)變量,你也許應(yīng)該在其他分支也設(shè)置該變量。與此相關(guān)的是標(biāo)志(flag)被設(shè)定的情況。僅僅添加設(shè)定標(biāo)志的條件很容易,但是容易忘了添加應(yīng)該重新設(shè)定標(biāo)志的條件。任由永久性設(shè)定的標(biāo)志留在那里可能會(huì)在將來導(dǎo)致 bug。
6.改變假設(shè)
一開始最難預(yù)防的許多bug是由不斷變化的假設(shè)引起的。例如,最初僅僅只有一個(gè)客戶,在這個(gè)假設(shè)下寫了很多代碼。后來某個(gè)時(shí)候,設(shè)計(jì)發(fā)生了變化,允許每天有多個(gè)客戶事件。當(dāng)這種情況發(fā)生,就很難改變受到新設(shè)計(jì)影響的所有情況。很容易找到顯式依賴該變化的所有項(xiàng),但是難的部分是,找到隱式依賴舊設(shè)計(jì)的所有情況。例如,可能有代碼讀取給定某一天的所有客戶事件。一個(gè)隱式的假設(shè)可能是,結(jié)果集中元素的數(shù)量絕對(duì)不會(huì)大于客戶數(shù)量。我沒有好的方法可以預(yù)防這類問題,歡迎讀者建議。
7.日志記錄
深入了解程序所做的任務(wù)是至關(guān)重要的,尤其是當(dāng)邏輯復(fù)雜的時(shí)候。確保添加足夠的(但也別太多)日志記錄。那樣你就能弄清楚為什么程序在執(zhí)行它執(zhí)行的任務(wù)。讓一切運(yùn)轉(zhuǎn)良好時(shí),它無關(guān)緊要。但是只要問題發(fā)生(這不可避免),你會(huì)很慶幸你添加了合適的日志記錄。
測(cè)試
作為一名開發(fā)者,除非進(jìn)行了測(cè)試,否則我不會(huì)說完成一項(xiàng)功能。起碼這意味著每一行新代碼或更改后的代碼至少執(zhí)行了一次。此外,單元測(cè)試或功能測(cè)試也很好,但不夠。新功能還必須在類似產(chǎn)品的環(huán)境下進(jìn)行測(cè)試和探究。唯有這樣,我才可以說完成了一項(xiàng)功能。下面是 bug 在測(cè)試方面給予我的一些重要的經(jīng)驗(yàn)教訓(xùn):
8.零(zero)和空(null)
務(wù)必要以零和空(合適的情況下)來進(jìn)行測(cè)試。對(duì)于字符串而言,這意味著既指長(zhǎng)度為零的字符串,又指內(nèi)容為空的字符串。另一個(gè)例子:在發(fā)送任何數(shù)據(jù)(零字節(jié))之前,測(cè)試 TCP 連接的斷開。沒有使用這些組合來測(cè)試是 bug 悄然出現(xiàn)的頭號(hào)原因,我在測(cè)試時(shí)是原本可以發(fā)現(xiàn)這些 bug 的。
9.添加和刪除
新功能常常需要能夠?yàn)橄到y(tǒng)添加新配置,比如說用于電話號(hào)碼翻譯的新配置文件。我們會(huì)自然而然的添加一個(gè)配置文件,來驗(yàn)證功能是否正常。然而,我發(fā)現(xiàn)很容易忘了還要測(cè)試配置文件的刪除。
10.錯(cuò)誤處理
處理錯(cuò)誤的代碼常常很難測(cè)試。最好由自動(dòng)測(cè)試來檢查錯(cuò)誤處理代碼,但有時(shí)這不可能。這種情況下,我有時(shí)采用的一招就是,臨時(shí)修改代碼,讓錯(cuò)誤處理代碼運(yùn)行。要做到這一點(diǎn),最容易的方法就是反轉(zhuǎn)if語句,比如說將if語句由 error_count > 0反轉(zhuǎn)為 error_count == 0。另一個(gè)例子是誤拼數(shù)據(jù)庫列名,讓所需的錯(cuò)誤處理代碼運(yùn)行。
11.隨機(jī)輸入
另一種往往能夠發(fā)現(xiàn) bug 的測(cè)試方法是進(jìn)行隨機(jī)輸入。例如,H.323 協(xié)議的 ASN.1 解碼可處理二進(jìn)制數(shù)據(jù)。通過發(fā)送有待解碼的隨機(jī)性字節(jié),我們發(fā)現(xiàn)了解碼器中的幾個(gè) bug。另一個(gè)例子是使用測(cè)試調(diào)用生成腳本,其中調(diào)用持續(xù)時(shí)間、回復(fù)延遲、第一方掛斷等都是隨機(jī)生成的內(nèi)容。這些測(cè)試腳本暴露了無數(shù) bug,尤其是接踵而至的事件引起的干擾。
12.檢查什么不該發(fā)生
通常測(cè)試包括檢查一些需要的行為發(fā)生。但是很容易忽略他的對(duì)立面——檢查不該發(fā)生的事確實(shí)沒發(fā)生。
13.自制工具
通常,我創(chuàng)建了自己的小工具來使測(cè)試更簡(jiǎn)易。例如,當(dāng)我處理面向 VoIP 的 SIP 協(xié)議時(shí),我寫了一個(gè)小的腳本可以返回正標(biāo)題和值。這個(gè)工具使得測(cè)試許多個(gè)別場(chǎng)景變得簡(jiǎn)單。另一個(gè)例子是可以調(diào)用 API 的命令行工具。從小的開始,逐漸添加一些需要的功能,我最終有許多有用的工具,寫自己的小工具的優(yōu)勢(shì)是我得到我想要的功能。
在測(cè)試中要發(fā)現(xiàn)所有的bug幾乎不可能。有一次,我在一種情況下,我對(duì)處理關(guān)聯(lián)號(hào)碼做了改變,包括兩部分:路由地址前綴(總是相同),和從000到999的動(dòng)態(tài)分配號(hào)碼。問題是,當(dāng)查找相關(guān)性時(shí),動(dòng)態(tài)分配的數(shù)字的第一個(gè)數(shù)字在查找之前被錯(cuò)誤地刪除。所以,不是尋找637之類的號(hào)碼,你尋找的是37,而這個(gè)號(hào)碼不在表中。這意味著,它一直尋找到100,所以前100個(gè)調(diào)用正常,而之余的所有900個(gè)調(diào)用失效。所以除非我在重新啟動(dòng)之前測(cè)試了100多次,否則在測(cè)試時(shí)發(fā)現(xiàn)不了這個(gè)問題。
調(diào)試
14.討論
在過去對(duì)我?guī)椭畲蟮恼{(diào)試方法就是與同事討論問題。我常常只要向同事描述問題,就足以認(rèn)識(shí)到問題是什么。此外,即使同事不是很熟悉相應(yīng)代碼,常常也能給出好主意,表明哪里可能有問題。我在處理最棘手的 bug 時(shí),與同事討論特別有效。
15.密切注意
往往是當(dāng)調(diào)試一個(gè)問題很長(zhǎng)時(shí)間時(shí),是因?yàn)槲易隽隋e(cuò)誤的假設(shè)。例如,我認(rèn)為這個(gè)問題發(fā)生在一個(gè)特定的方法中,事實(shí)上,這個(gè)問題甚至根本不會(huì)出現(xiàn)在這個(gè)方法中?;蛘邟伋龅漠惓2⒉皇俏艺J(rèn)為的那個(gè)?;蛘呶艺J(rèn)為最新版的軟件在運(yùn)行,但它其實(shí)是較老的版本。因此,一定要驗(yàn)證細(xì)節(jié),而不是假設(shè)。它使你容易看到你所期望看到的問題,而不是實(shí)際發(fā)生的問題。
16.最近的一次改動(dòng)
本該運(yùn)行的程序停止了,它通常是由最后的一次變動(dòng)導(dǎo)致。有一次,最近的一次變動(dòng)僅僅是日志,但是日志中的一個(gè)錯(cuò)誤導(dǎo)致了更大的問題。為了讓諸如此類的回歸更容易找到,有必要在不同的提交代碼中實(shí)行不同的變更,并且要清楚說明變更。
17.相信用戶
有時(shí)當(dāng)一個(gè)用戶反饋問題時(shí),我的本能反應(yīng)是:這不可能,他們一定搞錯(cuò)了。但是我已經(jīng)意識(shí)到我不應(yīng)該這樣做。我也不想這樣,但更多次,事實(shí)證明他們報(bào)告的問題實(shí)際上發(fā)生了。所以這些天,我認(rèn)真對(duì)待他們的反饋。當(dāng)然,我仍然反復(fù)測(cè)試所有的一切被正確地設(shè)置了。但是我碰過好多情況下,之所以發(fā)生奇怪的問題,是由于不同尋常的配置或意料之外的使用,而我的默認(rèn)假設(shè)是他們是對(duì)的,程序是錯(cuò)的。
18.測(cè)試修復(fù)的效果
如果你已經(jīng)修復(fù)了 bug,還需要再測(cè)試。首先運(yùn)行修復(fù)前的代碼,然后觀察 bug。然后運(yùn)用修復(fù)再次測(cè)試?,F(xiàn)在 bug 的問題應(yīng)該被消除了。繼續(xù)這些步驟確保它確實(shí)是一個(gè) bug,確保你的修復(fù)已經(jīng)修復(fù)這個(gè)問題。簡(jiǎn)單但很必要。
其他心得
過去13年,我一直在記錄我遇到的最棘手的bug,很多事情發(fā)生了改變。從小的嵌入式系統(tǒng),到大的電信系統(tǒng),網(wǎng)頁系統(tǒng)都做過。我使用的語言包括 C 、Ruby、Java 和 Python,若干類的 bug 在我使用 C 的日子里就已經(jīng)不再出現(xiàn)了。像堆棧溢出,內(nèi)存損壞,字符串的問題以及某些形式的內(nèi)存泄漏。
其他的問題,像回路錯(cuò)誤和極端案例,我見的少得多,因?yàn)槲覇卧獪y(cè)試了更多邏輯,但這并不意味著那里沒有 bug。這篇文章總結(jié)的經(jīng)驗(yàn)教訓(xùn),幫助我在編碼、測(cè)試和調(diào)試這三個(gè)階段盡量減小破壞。如果你發(fā)現(xiàn)了其他的技巧或者有用的技巧來預(yù)防或者找到 bug,請(qǐng)?jiān)谠u(píng)論區(qū)留言。