檢測原理與工具落地指南)
編譯通過不等于代碼健康C靜態(tài)檢測到底在查什么我做C開發(fā)這些年越來越覺得一個(gè)道理編譯器不報(bào)錯(cuò)只是說你的代碼符合語法和類型規(guī)則它壓根不關(guān)心你的邏輯是不是對的、指針是不是懸空的、資源有沒有泄漏。很多問題要等到運(yùn)行時(shí)才炸炸的時(shí)候往往已經(jīng)晚了。所以后來我在項(xiàng)目里強(qiáng)制推行C代碼靜態(tài)檢測把它當(dāng)成和單元測試一樣重要的質(zhì)量關(guān)卡。這篇文章想把靜態(tài)檢測這件事講透——它是什么原理、為什么編譯器做不到、工具怎么選、怎么真正落地到項(xiàng)目里以及你一定會(huì)遇到的誤報(bào)問題。不管你是剛接觸C的學(xué)生還是維護(hù)著幾十萬行存量代碼的開發(fā)者看完應(yīng)該都能找到自己能上手的切入點(diǎn)。1. 編譯器漏掉的坑為什么C格外需要靜態(tài)檢測1.1 編譯器只關(guān)心語法對不對不關(guān)心代碼對不對先說個(gè)我經(jīng)常舉的例子。你把一段代碼交給編譯器它在意的核心就兩件事語法是否符合語言規(guī)范、類型是否匹配。只要這兩關(guān)過了哪怕你的代碼明顯有邏輯問題它也會(huì)給你生成一個(gè)清清爽爽的可執(zhí)行文件。比如這樣一段非常典型的錯(cuò)誤代碼char* getBuffer() { char buf[256]; // 填充buf... return buf; // 返回了棧內(nèi)存的地址 }編譯器編譯這段代碼不會(huì)報(bào)任何錯(cuò)誤最多給個(gè)warning有些編譯器連warning都不給。但等你調(diào)用這個(gè)函數(shù)拿到的指針指向的是一塊已經(jīng)被回收的棧內(nèi)存接下來發(fā)生什么完全看運(yùn)氣——可能立刻崩潰可能數(shù)據(jù)被篡改也可能恰好還能跑然后在完全沒有關(guān)聯(lián)的地方出現(xiàn)詭異問題。這就是C里著名的懸空指針問題而靜態(tài)檢測工具能通過分析變量的生命周期直接指出你返回了一個(gè)局部變量的地址。C為什么比別的語言更需要靜態(tài)檢測因?yàn)樗o了程序員極大的自由度同時(shí)也把很多安全責(zé)任交給了程序員。手動(dòng)管理內(nèi)存、指針運(yùn)算、隱式轉(zhuǎn)換、位操作……這些特性讓編譯器很難在生成代碼之前判斷邏輯上的錯(cuò)誤。靜態(tài)檢測就是在編譯和運(yùn)行之間加了一層專門盯著邏輯和潛在風(fēng)險(xiǎn)的眼睛。1.2 動(dòng)態(tài)檢測和靜態(tài)檢測是兩種完全不同的思路很多同事會(huì)問我有Valgrind也有AddressSanitizer跑起來能直接看到內(nèi)存泄漏和越界訪問為什么還要用靜態(tài)檢測這就要說清楚兩類工具的定位差異。動(dòng)態(tài)檢測工具如Valgrind、ASan的思路是運(yùn)行代碼然后在運(yùn)行時(shí)觀察它的行為。它的優(yōu)點(diǎn)是準(zhǔn)確一旦報(bào)錯(cuò)基本就是實(shí)錘缺點(diǎn)是必須讓程序真正跑起來還要覆蓋到那一條出問題的執(zhí)行路徑。如果你的某個(gè)bug只在一百萬次運(yùn)行里出現(xiàn)一次而且只在特定輸入下出現(xiàn)動(dòng)態(tài)檢測可能永遠(yuǎn)等不到那一刻。靜態(tài)檢測的思路恰恰相反不運(yùn)行代碼直接把源代碼讀進(jìn)去沿著所有可能的執(zhí)行路徑分析一遍。它不需要輸入數(shù)據(jù)、不需要啟動(dòng)程序、不需要特定的運(yùn)行環(huán)境所以能覆蓋到那些平時(shí)很難觸發(fā)的分支。代價(jià)是它只能通過推斷來發(fā)現(xiàn)問題這一推斷就可能產(chǎn)生誤報(bào)——這個(gè)后面細(xì)說。兩者不是替代關(guān)系是互補(bǔ)關(guān)系靜態(tài)檢測負(fù)責(zé)在代碼提交時(shí)大面積掃雷動(dòng)態(tài)檢測負(fù)責(zé)在出現(xiàn)可疑行為時(shí)精確確診。2. 靜態(tài)檢測工具的原理一條C代碼在檢測器內(nèi)部走什么樣的流程2.1 從源碼到AST檢測器先要讀懂你的代碼很多人以為靜態(tài)檢測就是拿正則表達(dá)式匹配代碼字符串這是最大的誤解。正則那種做法只能抓點(diǎn)皮毛比如檢查函數(shù)名是不是駝峰式、有沒有多余的空白字符。真正能發(fā)現(xiàn)深層bug的靜態(tài)檢測第一步是把源代碼解析成一棵抽象語法樹AST。AST是什么可以理解為代碼的骨架結(jié)構(gòu)。比如一個(gè)簡單的表達(dá)式if (a b) { return a; }在AST里它不是一串字符而是一個(gè)樹狀結(jié)構(gòu)根節(jié)點(diǎn)是if語句它有兩個(gè)子樹——條件部分是大于比較表達(dá)式比較表達(dá)式的左右子節(jié)點(diǎn)分別是變量a和變量bthen分支是return語句return語句的子樹里有變量a。檢測器拿到這棵樹就能精確地回答這里的變量a是什么類型它的生命周期是什么范圍它有沒有被初始化過這類語義問題。Clang-Tidy這類基于Clang的工具會(huì)直接把Clang解析源碼后生成的AST拿過來用。這意味著它的解析能力和編譯器本身是同一套不存在檢測器的理解和編譯器不一致這種低級(jí)問題。2.2 控制流圖與數(shù)據(jù)流分析檢測器怎么看見運(yùn)行時(shí)的錯(cuò)誤光有AST還不夠。很多問題需要在所有可能的執(zhí)行路徑上去判斷這時(shí)候檢測器會(huì)把AST轉(zhuǎn)換成控制流圖CFG。你可以把控制流圖想象成一張地圖上面每個(gè)節(jié)點(diǎn)是一段代碼箭頭表示可能的執(zhí)行去向。if-else會(huì)產(chǎn)生兩個(gè)分支循環(huán)會(huì)形成回路switch會(huì)產(chǎn)生多條岔路。有了控制流圖檢測器就能做數(shù)據(jù)流分析——跟蹤一個(gè)變量的定義、賦值、使用、銷毀的完整過程。舉個(gè)例子int* p nullptr; if (condition()) { p new int(42); } *p 0; // 如果condition()返回falsep一直是個(gè)空指針數(shù)據(jù)流分析會(huì)沿著condition()為false這條路徑一路走下去發(fā)現(xiàn)p在被賦值之前就被解引用了。這個(gè)分析不需要真實(shí)運(yùn)行程序它只是把代碼里的路徑全部走了一遍。這就是為什么靜態(tài)檢測能捕捉到那些動(dòng)態(tài)檢測很難觸發(fā)的邊界條件。從技術(shù)角度看Clang-Tidy的檢查項(xiàng)分了好幾個(gè)層次有純AST結(jié)構(gòu)檢查比如某個(gè)類是否缺少虛析構(gòu)函數(shù)、有基于控制流圖的檢查比如未初始化變量使用、也有基于符號(hào)執(zhí)行的檢查比如數(shù)值溢出范圍。越深的分析越強(qiáng)大但也越容易誤報(bào)這也是為什么不同檢查項(xiàng)的效果差異會(huì)那么大。2.3 模式匹配與規(guī)則引擎檢測器批量識(shí)別壞味道前面說的數(shù)據(jù)流分析適合抓通用bug但團(tuán)隊(duì)里有很多問題是高度個(gè)性化的。比如你們約定禁止在業(yè)務(wù)代碼里直接使用new必須走統(tǒng)一的內(nèi)存管理接口或者規(guī)定所有多態(tài)類必須有虛析構(gòu)函數(shù)。這類規(guī)則沒法靠通用分析覆蓋得靠模式匹配。Clang-Tidy提供了非常靈活的AST匹配器可以把它理解為針對代碼樹的SQL查詢。你寫一個(gè)匹配條件找出所有類定義包含虛函數(shù)沒有虛擬析構(gòu)函數(shù)的節(jié)點(diǎn)它就快速在整個(gè)代碼庫里篩選出符合條件的類。這種規(guī)則寫起來并不困難后面我專門寫一節(jié)怎么自定義團(tuán)隊(duì)的檢測規(guī)則。想清楚這些底層原理之后你再看工具的輸出就不會(huì)覺得神秘了——每一條警告背后都對應(yīng)著某種具體的分析邏輯搞清楚這套邏輯你才知道哪些警告要無條件修改哪些警告可以合理忽略。3. 主流工具怎么選Clang-Tidy、Cppcheck和商業(yè)方案的真實(shí)差異3.1 三款常用工具的對比我用過的靜態(tài)檢測工具不算少主流的幾個(gè)都實(shí)際跑過大型項(xiàng)目這里直接給結(jié)論。工具底層技術(shù)優(yōu)勢劣勢適合場景Clang-TidyClang AST解析精準(zhǔn)、規(guī)則豐富、可自定義、與編譯診斷一致配置復(fù)雜、需要編譯數(shù)據(jù)庫、掃描較慢中大型C項(xiàng)目的常態(tài)化檢查Cppcheck自帶詞法與語法分析輕量、無需編譯數(shù)據(jù)庫、開箱即用深層分析較弱、自定義能力有限快速掃描、跨平臺(tái)小項(xiàng)目PVS-Studio數(shù)據(jù)流分析為核心bug檢出率高、誤報(bào)率低、報(bào)告可讀性好商業(yè)授權(quán)、對大型代碼庫資源占用高安全關(guān)鍵型項(xiàng)目、質(zhì)量要求嚴(yán)格的團(tuán)隊(duì)我知道很多團(tuán)隊(duì)是從Cppcheck入門的畢竟它在老舊的代碼庫上也能直接跑不需要生成編譯數(shù)據(jù)庫。但用久了你會(huì)發(fā)現(xiàn)Cppcheck對C11以后的新特性支持得總有點(diǎn)滯后而且它不基于Clang解析器的話模板、智能指針這類現(xiàn)代C語法在它的分析模型里比較粗糙。我最推薦的組合是用Clang-Tidy做主力用Cppcheck做快速補(bǔ)充掃描如果你有預(yù)算或者項(xiàng)目安全等級(jí)高再加一個(gè)商業(yè)工具做交叉驗(yàn)證。3.2 決定選型的核心因素編譯數(shù)據(jù)庫在實(shí)際評(píng)估工具的時(shí)候我建議大家先想清楚一個(gè)問題項(xiàng)目能不能方便地生成compile_commands.json這是Clang系列工具使用的前提它記錄了項(xiàng)目里每一個(gè)源文件編譯時(shí)的完整參數(shù)——頭文件路徑、宏定義、標(biāo)準(zhǔn)版本等等。用CMake的項(xiàng)目很容易只需要打開一個(gè)開關(guān)cmake -DCMAKE_EXPORT_COMPILE_COMMANDSON ..生成之后Clang-Tidy才能準(zhǔn)確地分析你的代碼。如果項(xiàng)目是用別的構(gòu)建系統(tǒng)比如老式Makefile你可能需要借助Bear這類工具來捕獲編譯命令bear -- make -j8這一步很多人嫌麻煩但其實(shí)它是靜態(tài)檢測能落到實(shí)處的關(guān)鍵。沒有編譯數(shù)據(jù)庫的Clang-Tidy面對一堆找不到頭文件的報(bào)錯(cuò)基本等于廢了。所以我的建議是決定選型之前先花一天時(shí)間把編譯數(shù)據(jù)庫跑通。跑通了Clang-Tidy就是你的主力跑不動(dòng)那就老老實(shí)實(shí)用Cppcheck它不依賴編譯數(shù)據(jù)庫直接分析源文件也能找出不少問題。3.3 工具的檢出能力差異實(shí)測印象沒有比較就沒有鑒別。我在某個(gè)舊項(xiàng)目上做過一次對比那個(gè)項(xiàng)目的代碼大概有20萬行C標(biāo)準(zhǔn)是C11歷史包袱很重。Cppcheck跑完報(bào)了600多個(gè)warning但我去翻了翻大量是style級(jí)別的問題真正能構(gòu)成缺陷風(fēng)險(xiǎn)的不到20%。同一份代碼Clang-Tidy開上它最有價(jià)值的幾個(gè)檢查報(bào)了80多個(gè)問題其中一半以上都是實(shí)打?qū)嵉目罩羔樈庖?、資源泄漏、邏輯錯(cuò)誤。這是因?yàn)镃lang-Tidy能真正理解代碼的語義和生命周期而Cppcheck很多時(shí)候還是在做語法模式上的淺層推斷。商業(yè)工具的檢出率和誤報(bào)率確實(shí)控制得好但價(jià)格擺在那里。我在實(shí)踐里發(fā)現(xiàn)一個(gè)比較平衡的做法常規(guī)開發(fā)用Clang-Tidy做持續(xù)集成每個(gè)季度或者每次大版本發(fā)布前用商業(yè)工具做一次全面審計(jì)復(fù)查。這樣既能把日常質(zhì)量線托住又不會(huì)讓團(tuán)隊(duì)在工具上投入過高的成本。4. 把靜態(tài)檢測接進(jìn)真實(shí)項(xiàng)目從編譯數(shù)據(jù)庫到CI流水線的完整鏈路4.1 第一步先讓你的項(xiàng)目能被檢測很多人裝了工具第一步就卡住了因?yàn)樗麄兊捻?xiàng)目根本沒生成編譯數(shù)據(jù)庫。我見過太多項(xiàng)目代碼庫能編譯但一跑Clang-Tidy就報(bào)幾百個(gè)file not found絕大多數(shù)都是頭文件路徑解析不了。所以第一步不是急著開檢查項(xiàng)而是把可重復(fù)的編譯信息導(dǎo)出流程建立起來。拿CMake項(xiàng)目舉例最穩(wěn)妥的方式是單獨(dú)建一個(gè)構(gòu)建目錄專門用來導(dǎo)出編譯命令cmake -S . -B build-analysis -DCMAKE_EXPORT_COMPILE_COMMANDSON -DCMAKE_BUILD_TYPERelease ln -s build-analysis/compile_commands.json .compile_commands.json生成之后隨便挑一個(gè)源文件試試clang-tidy -p . src/core/network.cpp這個(gè)命令的意思是告訴工具編譯數(shù)據(jù)庫在當(dāng)前目錄然后分析src/core/network.cpp。如果一切正常你會(huì)第一次看到工具對單個(gè)文件的檢測結(jié)果。這里有個(gè)小技巧不要急著對全項(xiàng)目掃描先玩轉(zhuǎn)單個(gè)文件這樣能快速感受到工具的輸出格式、開啟規(guī)則的方式也不會(huì)被海量結(jié)果淹沒。4.2 配置文件怎么寫從全部禁用到按需開啟Clang-Tidy的默認(rèn)配置其實(shí)是規(guī)則全關(guān)你需要明確告訴它開哪些檢查。我用過的項(xiàng)目中最常用的配置文件.clang-tidy長這樣放在項(xiàng)目根目錄--- Checks: clang-analyzer-*, bugprone-*, performance-*, modernize-*, readability-* WarningsAsErrors: HeaderFilterRegex: AnalyzeTemporaryDtors: false FormatStyle: none CheckOptions: - key: readability-identifier-naming.ClassCase value: CamelCase - key: bugprone-sizeof-expression.WarnOnSizeOfConstant value: true ---配置文件的意義在于把規(guī)則沉淀下來讓所有成員用同一套標(biāo)準(zhǔn)。注意這里我把modernize-*和readability-*也開了但如果你在存量項(xiàng)目上第一次引入靜態(tài)檢測最好不要全開——不然第一次掃描那上萬條告警會(huì)把整個(gè)團(tuán)隊(duì)嚇退。我建議第一次先只開clang-analyzer-*和bugprone-*這兩組它們抓的是真正的bug和隱患風(fēng)格類和現(xiàn)代化重構(gòu)類的規(guī)則等團(tuán)隊(duì)適應(yīng)之后再分階段開啟。4.3 增量掃描策略讓CI在幾分鐘內(nèi)給出反饋靜態(tài)檢測有一個(gè)很現(xiàn)實(shí)的問題掃描速度快不了。一個(gè)10萬行的項(xiàng)目全量跑Clang-Tidy輕松就要半小時(shí)以上。如果每次提交代碼都全量掃描開發(fā)和CI流程都會(huì)被拖垮。我在實(shí)踐中采用的方案是提交時(shí)增量掃描 夜間全量掃描。增量掃描只針對本次改動(dòng)涉及的文件可以在CI流水線里實(shí)現(xiàn)先用git diff --name-only origin/master...HEAD拿到改動(dòng)文件列表再過濾出.cpp和.h文件然后逐個(gè)交給Clang-Tidy分析。git diff --name-only origin/master...HEAD | grep -E \.(cpp|cc|cxx|hpp|h)$ | xargs clang-tidy -p build-analysis -quiet再加上一個(gè)限制規(guī)則新增的警告一律視為錯(cuò)誤已有的存量警告放進(jìn)基線里單獨(dú)跟蹤。這樣新提交就不會(huì)引入新的問題團(tuán)隊(duì)不用一下子處理歷史欠賬。夜間的全量掃描則負(fù)責(zé)找出那些跨文件的、依賴全局?jǐn)?shù)據(jù)流的問題第二天早上大家看報(bào)告改就行。4.4 存量代碼的治理節(jié)奏從不許新增到逐步清零我見過兩種極端做法。一種是從不引入項(xiàng)目爛到?jīng)]法看另一種是一上來就要求存量代碼清零結(jié)果團(tuán)隊(duì)花了兩周改了一堆跟業(yè)務(wù)無關(guān)的warning核心需求倒沒排期了。兩種都是錯(cuò)的。正確的方式是分三個(gè)階段走。第一階段引入工具設(shè)立基線規(guī)則上只做不許新增這個(gè)階段的核心目標(biāo)是讓流水線跑起來讓大家接受工具的存在。第二階段選擇高收益規(guī)則比如空指針、內(nèi)存泄漏、并發(fā)相關(guān)的檢查項(xiàng)定期挑一批存量告警修掉這個(gè)節(jié)奏可以跟著迭代走每次修個(gè)幾十條。第三階段當(dāng)存量告警數(shù)量降到可控水平后再逐步開放風(fēng)格類和現(xiàn)代化重構(gòu)類規(guī)則把門禁從不許新增收緊到一個(gè)都不能有。這個(gè)節(jié)奏看起來很慢但其實(shí)是最穩(wěn)的。我經(jīng)手的一個(gè)項(xiàng)目20萬行存量代碼用這個(gè)節(jié)奏跑了三個(gè)季度存量告警從三千多條降到了兩百條以內(nèi)而且過程中沒有發(fā)生過一次工具導(dǎo)致的發(fā)布延期。5. 誤報(bào)處理不是妥協(xié)工程如何讓檢測器學(xué)會(huì)閉嘴5.1 誤報(bào)的典型來源與抑制手段用了靜態(tài)檢測之后你一定會(huì)遇到這種場景工具報(bào)了一個(gè)問題你看了一眼覺得這代碼明明沒問題啊。恭喜你遇到了誤報(bào)。誤報(bào)處理得好不好直接決定工具是團(tuán)隊(duì)的質(zhì)量護(hù)法還是狼來了小孩——天天瞎報(bào)警最后沒人認(rèn)真對待。誤報(bào)的常見來源有三個(gè)。第一是宏展開帶來的誤判C的宏是文本替換展開之后很多信息丟失了工具可能把看似獨(dú)立的兩段代碼當(dāng)成有邏輯關(guān)聯(lián)。第二是外部接口的約定比如你的模塊和底層平臺(tái)約定這個(gè)回調(diào)永遠(yuǎn)在特定條件下觸發(fā)工具不知道這個(gè)約定自然判斷不出你的代碼是安全的。第三是模板實(shí)例化的復(fù)雜性工具在做數(shù)據(jù)流分析時(shí)對某些模板參數(shù)的推導(dǎo)不夠準(zhǔn)確就得出了過于悲觀的結(jié)論。處理方式上我建議按嚴(yán)重程度分級(jí)。對于那種安全但工具不理解的代碼用行內(nèi)注釋告訴工具這里是有意為之a(chǎn)uto result unsafe_call(); // NOLINT(clang-analyzer-core.uninitialized.UndefReturn)對于這一類檢查項(xiàng)在你的項(xiàng)目里普遍不適用的情況直接在.clang-tidy配置里關(guān)掉它千萬別逐行注釋否則代碼里到處是NOLINT也夠煩的。對于偶爾出現(xiàn)但確認(rèn)是誤報(bào)的問題可以建一個(gè)清單集中記錄這些誤報(bào)的性質(zhì)和理由讓后續(xù)審閱代碼的人不用每次重新爭論一遍。5.2 怎么判斷一條告警是真bug還是誤報(bào)我經(jīng)常被問怎么區(qū)分真bug和誤報(bào)這個(gè)問題沒有銀彈但我有一套自己的判斷流程準(zhǔn)確率還挺高。第一步看告警說的是哪一類問題。如果是空指針解引用、數(shù)組越界、使用未初始化內(nèi)存這類問題的實(shí)錘率非常高基本可以直接認(rèn)定為真bug只是觸發(fā)路徑可能很隱蔽。第二步看告警處代碼的上下文。如果變量在之前的代碼里明明被賦值過那有可能是數(shù)據(jù)流分析遺漏了某種情況屬于誤報(bào)如果賦值條件和當(dāng)前路徑確實(shí)互斥那要仔細(xì)想想是不是真的有一條路徑漏了。第三步實(shí)在判斷不了就本地跑動(dòng)態(tài)檢測寫一個(gè)能觸發(fā)該路徑的最小復(fù)現(xiàn)樣例用ASan跑一遍。動(dòng)態(tài)檢測在這個(gè)場景下是靜態(tài)檢測的仲裁者。我記得有一次Clang-Tidy在一個(gè)網(wǎng)絡(luò)模塊里報(bào)了一條取消引用了空指針的告警代碼大概長這樣void handlePacket(Packet* p) { if (p ! nullptr) { process(payload); } sendToWire(p-id); // 這里p可能為空 }你一眼看過去好像有保護(hù)但函數(shù)最后的p-id確實(shí)沒在保護(hù)范圍內(nèi)。這是典型的開發(fā)者加了防御但漏了一條路徑的問題——工具的分析是精確的反而是寫代碼的人想當(dāng)然了。像這種情況靜態(tài)檢測的價(jià)值就表現(xiàn)得非常明顯。5.3 自定義規(guī)則把團(tuán)隊(duì)自己的規(guī)范寫進(jìn)檢測器真正用好Clang-Tidy就要學(xué)會(huì)寫自定義檢查。它提供了完整的API你可以注冊一個(gè)ClangTidyCheck在registerMatchers里聲明你想匹配的代碼模式在check回調(diào)里拿到匹配到的節(jié)點(diǎn)、檢查上下文、輸出診斷信息。舉個(gè)例子。假設(shè)團(tuán)隊(duì)里規(guī)定所有自定義類型都要用shared_ptr管理不允許裸new出現(xiàn)在A和B模塊中。你可以寫一個(gè)最簡規(guī)則匹配new表達(dá)式節(jié)點(diǎn)class NoNakedNewCheck : public ClangTidyCheck { public: void registerMatchers(ast_matchers::MatchFinder* Finder) override { Finder-addMatcher( ast_matchers::newExpr().bind(newExp), this); } void check(const ast_matchers::MatchFinder::MatchResult Result) override { const auto* E Result.Nodes.getNodeAsCXXNewExpr(newExp); if (E) { diag(E-getBeginLoc(), 禁止使用裸new請使用shared_ptr); } } };真實(shí)的規(guī)則會(huì)比這復(fù)雜要考慮排除頭文件、排除已經(jīng)授權(quán)的代碼、處理模板代碼里的new。但思路就是這樣的——把團(tuán)隊(duì)規(guī)范變成機(jī)器可執(zhí)行的檢查這個(gè)價(jià)值遠(yuǎn)遠(yuǎn)超過工具自帶的幾百條通用規(guī)則因?yàn)樗菫槟銏F(tuán)隊(duì)量身定做的紀(jì)律守門員。寫這類插件的門檻沒有想象中高懂基本的C和AST概念就能上手而且社區(qū)里有大量現(xiàn)成的AST matcher例子可以參考。6. 靜態(tài)檢測抓到的真實(shí)bug每個(gè)都值得認(rèn)真對待6.1 從真實(shí)項(xiàng)目里摘出的幾類典型缺陷光說理論和工具不如看些實(shí)際案例。這些是我在不同項(xiàng)目里通過靜態(tài)檢測抓到過的既有bug已做了脫敏簡化每一類都很有代表性。第一類是未初始化變量。有個(gè)模塊的日志打印偶爾會(huì)輸出亂碼排查了很久。后來Clang-Tidy直接在代碼里指出來某個(gè)結(jié)構(gòu)體的int status;字段只有部分分支里賦了值另外幾個(gè)分支直接用了未初始化的內(nèi)存。這類問題編譯器通常只會(huì)給個(gè)可能未初始化的warning容易被忽略但實(shí)際后果很隨機(jī)。第二類是生命周期懸空。有一個(gè)函數(shù)里返回了局部容器的迭代器容器銷毀之后迭代器變成了懸空指針程序跑了一段時(shí)間之后在完全無關(guān)的模塊里崩潰。Cppcheck和Clang-Tidy都能識(shí)別這種return引用局部變量或容器成員迭代器的模式。第三類是并發(fā)場景下的數(shù)據(jù)競爭。工具從代碼路徑上發(fā)現(xiàn)某個(gè)成員變量在一個(gè)線程里被執(zhí)行寫操作在另一個(gè)線程里被無鎖讀取沒有同步機(jī)制。這種問題在單測和動(dòng)態(tài)檢測中很難穩(wěn)定復(fù)現(xiàn)但靜態(tài)分析可以從路徑上直接指出來。下面是這類問題的對比總結(jié)問題類型靜態(tài)檢測的表現(xiàn)動(dòng)態(tài)檢測的表現(xiàn)嚴(yán)重度未初始化變量直接定位到變量聲明處僅當(dāng)路徑被觸發(fā)時(shí)偶爾崩潰高內(nèi)存泄漏與懸垂分析生命周期可發(fā)現(xiàn)Valgrind可穩(wěn)定發(fā)現(xiàn)高空指針解引用沿所有路徑分析需要特定輸入觸發(fā)高并發(fā)數(shù)據(jù)競爭路徑分析可發(fā)現(xiàn)TSan可發(fā)現(xiàn)但依賴運(yùn)行覆蓋中高邏輯邊界錯(cuò)誤部分可發(fā)現(xiàn)很難通過動(dòng)態(tài)檢測接到中性能隱患識(shí)別復(fù)制、低效容器使用需性能測試工具輔助低6.2 靜態(tài)檢測覆蓋不到的邊界靜態(tài)檢測不是萬能的它抓不到規(guī)范實(shí)現(xiàn)但需求理解錯(cuò)了的邏輯錯(cuò)誤也抓不到兩個(gè)模塊接口設(shè)計(jì)合理但業(yè)務(wù)語義上不可行的問題。比如你的訂單狀態(tài)機(jī)狀態(tài)轉(zhuǎn)移邏輯寫反了工具只會(huì)檢查狀態(tài)變量初始化和使用是否安全不會(huì)判斷你的狀態(tài)流轉(zhuǎn)語義是否正確。算法復(fù)雜度和架構(gòu)合理性它也幫不上忙。一個(gè)O(n^2)的循環(huán)在代碼層面完全合法但性能就是慢得離譜靜態(tài)檢測給你報(bào)不了這段應(yīng)該換哈希表。此外一些在運(yùn)行時(shí)注入的依賴、插件式架構(gòu)里的動(dòng)態(tài)行為靜態(tài)檢測基本無能為力。所以我一直跟團(tuán)隊(duì)強(qiáng)調(diào)一個(gè)觀念靜態(tài)檢測是質(zhì)量體系里的一層網(wǎng)它負(fù)責(zé)攔截高頻、低級(jí)的錯(cuò)誤讓人的注意力能集中在那些工具攔不了的更高級(jí)的問題上。工具是輔助不是銀彈最終質(zhì)量責(zé)任還是在寫代碼的人手里。在我實(shí)際推動(dòng)靜態(tài)檢測落地的經(jīng)驗(yàn)里最大的挑戰(zhàn)其實(shí)不是工具本身而是團(tuán)隊(duì)習(xí)慣的變化——從編譯過了就提交到編譯過了、靜態(tài)檢測也干凈了才提交這個(gè)轉(zhuǎn)變需要一段時(shí)間的適應(yīng)。我個(gè)人用的一個(gè)行之有效的辦法是在所有g(shù)it提交前的鉤子里加上增量掃描讓反饋來得盡量早。這種前置攔截帶給人的體驗(yàn)是非常直接的幾次下來大家就會(huì)自覺地遵守規(guī)則?,F(xiàn)在每次我打開一個(gè)舊項(xiàng)目第一件事就是搭一層靜態(tài)檢測的門禁哪怕規(guī)則少一點(diǎn)也比完全沒有強(qiáng)。