在本章中,你將學習如何使用 LLVM 提供的工具集來識別應用程序中的某些錯誤。所有這些工具都利用了 LLVM 和 Clang 庫。
你將學習如何使用 sanitizers 對應用程序進行檢測,以及如何使用最常見的 sanitizer 來識別廣泛的錯誤類型。然后,你將為你的應用程序實現模糊測試(fuzz testing),這有助于你發現通常在單元測試中未被發現的錯誤。你還將學習如何識別應用程序中的性能瓶頸,運行靜態分析器以識別編譯器通常未發現的問題,并創建你自己的基于 Clang 的工具,你可以在其中擴展 Clang 的新功能。
本章將涵蓋以下主題:
到本章結束時,你將知道如何使用各種 LLVM 和 Clang 工具來識別應用程序中的大量錯誤類型。你還將獲得擴展 Clang 的新功能的知識,例如,強制執行命名約定或添加新的源代碼分析。
技術要求 為了在“使用 XRay 進行性能分析”部分創建火焰圖,你需要從 https://github.com/brendangregg/FlameGraph 安裝腳本。一些系統,如 Fedora 和 FreeBSD,提供了這些腳本的軟件包,你也可以使用。
為了在同一部分查看 Chrome 可視化,你需要安裝 Chrome 瀏覽器。你可以從 https://www.google.com/chrome/ 下載瀏覽器,或者使用你的系統的包管理器安裝 Chrome 瀏覽器。
此外,要通過 scan-build 腳本運行靜態分析器,你需要在 Fedora 和 Ubuntu 上安裝 perl-core 包。
使用 sanitizers 對應用程序進行檢測 LLVM 提供了幾個 sanitizers。這些是將中間表示(IR)進行檢測以檢查應用程序的某些不當行為的傳遞。通常,它們需要庫支持,這是 compiler-rt 項目的一部分。可以在 Clang 中啟用 sanitizers,這使得它們非常容易使用。要構建 compiler-rt 項目,我們可以在構建 LLVM 時簡單地添加 -DLLVM_ENABLE_RUNTIMES=compiler-rt CMake 變量到初始的 CMake 配置步驟中。
在接下來的部分中,我們將查看地址、內存和線程 sanitizers。首先,我們將查看地址 sanitizer。
使用地址 sanitizer 檢測內存訪問問題 你可以使用地址 sanitizer 來檢測應用程序中的不同類型的內存訪問錯誤。這包括常見的錯誤,例如在釋放動態分配的內存后使用它,或在分配的內存邊界之外寫入動態分配的內存。
啟用時,地址 sanitizer 將對 malloc() 和 free() 函數的調用替換為自己的版本,并使用檢查守衛對所有內存訪問進行檢測。當然,這為應用程序增加了大量的開銷,你只在應用程序的測試階段使用地址 sanitizer。如果你對實現細節感興趣,那么你可以找到 pass 的源代碼在 llvm/lib/Transforms/Instrumentation/AddressSanitizer.cpp 文件中,以及在 https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm 上實現的算法描述。
讓我們運行一個簡短的示例來展示地址 sanitizer 的能力!
以下示例應用程序 outofbounds.c 分配了 12 個字節的內存,但初始化了 14 個字節:
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
char *p = malloc(12);
memset(p, 0, 14);
return (int)*p;
}
你可以編譯并運行這個應用程序而不會注意到問題,因為這種行為對于這種類型的錯誤是典型的。即使在更大的應用程序中,這類錯誤也可能長時間不被注意到。然而,如果你使用 -fsanitize=address 選項啟用了地址 sanitizer,那么應用程序在檢測到錯誤后會停止。
同時,啟用調試符號與 –g 選項也很有用,因為它有助于識別源代碼中錯誤的位置。以下代碼是如何使用地址 sanitizer 和啟用調試符號編譯源文件的示例:
$ clang -fsanitize=address -g outofbounds.c -o outofbounds
現在,當你運行應用程序時,會得到一個冗長的錯誤報告:
$ ./outofbounds
==============================================================
==1067==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x00000023a6ef bp 0x7fffffffeb10 sp 0x7fffffffe2d8
WRITE of size 14 at 0x60200000001c thread T0
#0 0x23a6ee in __asan_memset /usr/src/contrib/llvm-project/compiler-rt/lib/asan/asan_interceptors_memintrinsics.cpp:26:3
#1 0x2b2a03 in main /home/kai/sanitizers/outofbounds.c:6:3
#2 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7
報告還包含有關內存內容的詳細信息。重要信息是錯誤類型——在這種情況下是堆緩沖區溢出——以及違規的源代碼行。要找到源代碼行,你必須查看位置 #1 的堆棧跟蹤,這是地址 sanitizer 攔截應用程序執行的最后一個位置。它顯示了 outofbounds.c 文件的第 6 行,即包含對 memset() 的調用的行。這是發生緩沖區溢出的確切位置。
如果你將 outofbounds.c 文件中包含 memset(p, 0, 14); 的行替換為以下代碼,那么你可以引入一旦你釋放了內存就訪問內存的情況。你需要將源代碼保存在 useafterfree.c 文件中:
memset(p, 0, 12);
free(p);
再次編譯并運行它,sanitizer 會檢測到釋放內存后使用的指針:
$ clang -fsanitize=address -g useafterfree.c -o useafterfree
$ ./useafterfree
==============================================================
==1118==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 at pc 0x0000002b2a5c bp 0x7fffffffeb00 sp 0x7fffffffeaf8
READ of size 1 at 0x602000000010 thread T0
#0 0x2b2a5b in main /home/kai/sanitizers/useafterfree.c:8:15
#1 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7
這次,報告指向了包含 p 指針解引用的第 8 行。
在 x86_64 Linux 和 macOS 上,你還可以啟用泄漏檢測器。如果你在運行應用程序之前將 ASAN_OPTIONS 環境變量設置為 detect_leaks=1,那么你還可以得到有關內存泄漏的報告。
在命令行上,你可以這樣做:
$ ASAN_OPTIONS=detect_leaks=1 ./useafterfree
地址 sanitizer 非常有用,因為它捕獲了一類別通常難以檢測到的錯誤。內存 sanitizer 執行類似的任務。在下一節中,我們將檢查它的用例。
使用內存 sanitizer 查找未初始化的內存訪問 使用未初始化的內存是另一類難以發現的錯誤。在 C 和 C++ 中,常規的內存分配例程不使用默認值初始化內存緩沖區。自動變量在棧上也是如此。
這里有很多出錯的機會,內存 sanitizer 可以幫助發現這些錯誤。如果你對實現細節感興趣,你可以在 llvm/lib/Transforms/Instrumentation/MemorySanitizer.cpp 文件中找到內存 sanitizer pass 的源代碼。文件頂部的注釋解釋了實現背后的想法。
讓我們運行一個小型示例,并將以下源代碼保存為 memory.c 文件。請注意,變量 x 未初始化并用作返回值:
int main(int argc, char *argv[]) {
int x;
return x;
}
如果沒有 sanitizer,應用程序將正常運行。然而,如果你使用 -fsanitize=memory 選項,你將得到一個錯誤報告:
$ clang -fsanitize=memory -g memory.c -o memory
$ ./memory
==1206==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x10a8f49 in main /home/kai/sanitizers/memory.c:3:3
#1 0x1053481 in _start /usr/src/lib/csu/amd64/crt1.c:76:7
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/kai/sanitizers/memory.c:3:3 in main
Exiting
像地址 sanitizer 一樣,內存 sanitizer 在發現第一個錯誤時停止應用程序。如上所示,內存 sanitizer 提供了一個使用初始化值的警告。
最后,在下一節中,我們將看到如何使用線程 sanitizer 來檢測多線程應用程序中的競態條件。
使用線程 sanitizer 指出數據競爭 為了利用現代 CPU 的能力,應用程序現在使用多個線程。這是一種強大的技術,但它也引入了新的錯誤來源。多線程應用程序中一個非常常見的問題是對全局數據的訪問未受保護,例如,使用互斥鎖或信號量。這被稱為數據競爭。線程 sanitizer 可以檢測基于 Pthreads 的應用程s/Instrumentation/ThreadSanitizer.cpp 文件中找到實現。
為了演示線程 sanitizer 的功能,我們將創建一個非常簡單的生產者-消費者風格的應用程序。生產者線程增加一個全局變量,而消費者線程減少同一個變量。對全局變量的訪問沒有受到保護,因此這是一次數據競爭。
你需要將以下源代碼保存在 thread.c 文件中:
#include <pthread.h>
int data = 0;
void *producer(void *x) {
for (int i = 0; i < 10000; ++i) ++data;
return x;
}
void *consumer(void *x) {
for (int i = 0; i < 10000; ++i) --data;
return x;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, producer, NULL);
pthread_create(&t2, NULL, consumer, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return data;
}
在前面的代碼中,data 變量在兩個線程之間共享。這里,它被聲明為 int 類型,以簡化示例,因為在實際情況下,通常會使用類似 std::vector 類或類似的數據結構。此外,這兩個線程運行 producer() 和 consumer() 函數。
producer() 函數只增加 data 變量,而 consumer() 函數減少它。沒有實現訪問保護,所以這是一次數據競爭。main() 函數使用 pthread_create() 函數啟動兩個線程,使用 pthread_join() 函數等待線程結束,并返回 data 變量的當前值。
如果你編譯并運行這個應用程序,你將不會注意到錯誤——也就是說,返回值總是零。如果將執行的循環次數增加 100 倍,那么就會出現錯誤——在這種情況下,將出現其他值。
我們可以使用線程 sanitizer 來識別程序中的數據競爭。要啟用線程 sanitizer 編譯,你需要傳遞 -fsanitize=thread 選項給 clang。加上生成調試符號的 -g 選項可以在報告中給出行號,這也很有幫助。注意,你還需要鏈接 pthread 庫:
$ clang -fsanitize=thread -g thread.c -o thread -lpthread
$ ./thread
==================
..........
ThreadSanitizer: reported 1 warnings
報告指向源文件的 6 行和 11 行,全局變量被訪問的地方。它還顯示了兩個名為 T1 和 T2 的線程訪問了變量,以及 pthread_create() 函數調用的文件和行號。
通過這些,我們學會了如何使用三種不同類型的 sanitizers 來識別應用程序中的常見問題。地址 sanitizer 幫助我們識別常見的內存訪問錯誤,例如越界訪問或在釋放內存后使用內存。使用內存 sanitizer,我們可以找到未初始化內存的訪問,線程 sanitizer 幫助我們識別數據競爭。
在接下來的部分中,我們將嘗試通過在隨機數據上運行應用程序來觸發 sanitizers。這個過程被稱為模糊測試。
使用 libFuzzer 查找錯誤 要測試你的應用程序,你需要編寫單元測試。這是一個很好的方式來確保你的軟件按照預期正確運行。然而,由于可能的輸入數量呈指數級增長,你可能會錯過某些奇怪的輸入,以及一些錯誤。
模糊測試可以在此處提供幫助。其思想是向應用程序呈現隨機生成的數據,或基于有效輸入但帶有隨機變化的數據。這是重復進行的,以便你的應用程序被大量輸入測試,這就是為什么模糊測試可以成為一種強大的測試方法。已有記錄顯示,模糊測試幫助在網絡瀏覽器和其他軟件中發現了數百個錯誤。
有趣的是,LLVM 附帶了自己的模糊測試庫。最初是 LLVM 核心庫的一部分,libFuzzer 實現最終被移到了 compiler-rt 中。該庫旨在測試小型和快速的函數。
讓我們通過一個小型示例來看看 libFuzzer 的工作原理。首先,你需要提供 LLVMFuzzerTestOneInput() 函數。這個函數由模糊測試驅動調用,并為你提供一些輸入。下面的函數計算輸入中的連續 ASCII 數字。完成這個之后,我們將向它提供隨機輸入。
你需要將示例保存在 fuzzer.c 文件中:
#include <stdint.h>
#include <stdlib.h>
int count(const uint8_t *Data, size_t Size) {
int cnt = 0;
if (Size)
while (Data[cnt] >= '0' && Data[cnt] <= '9') ++cnt;
return cnt;
}
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
count(Data, Size);
return 0;
}
在前面的代碼中,count() 函數計算 Data 變量指向的內存中的數字數量。數據的大小只檢查是否有可用的字節。在 while 循環中,不檢查大小。
使用常規的 C 字符串時,不會有錯誤,因為 C 字符串總是以 0 字節結尾。LLVMFuzzerTestOneInput() 函數是所謂的模糊測試目標,它是由 libFuzzer 調用的函數。它調用我們想要測試的函數,并返回 0,目前是唯一允許的值。
要使用 libFuzzer 編譯文件,你必須添加 -fsanitize=fuzzer 選項。建議同時啟用地址 sanitizer 和生成調試符號。我們可以使用以下命令來編譯 fuzzer.c 文件:
$ clang -fsanitize=fuzzer,address -g fuzzer.c -o fuzzer
當你運行測試時,它會發出一個詳細的報告。報告包含比堆棧跟蹤更多的信息,讓我們仔細看看它:
第一行告訴你初始化隨機數生成器所使用的種子。你可以使用 –seed= 選項來重復此執行:
INFO: Seed: 1297394926
默認情況下,libFuzzer 將輸入限制在最多 4096 字節。你可以通過使用 –max_len= 選項來更改默認值:
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
現在,我們可以在不提供示例輸入的情況下運行測試。所有示例輸入的集合稱為語料庫,對于這次運行它是空的:
INFO: A corpus is not provided, starting from an empty corpus
接下來是一些關于生成的測試數據的信息。它顯示你嘗試了 28 個輸入,并找到了 6 個輸入,這些輸入的總長度為 19 字節,它們共同涵蓋了 6 個覆蓋點或基本塊:
#28 NEW cov: 6 ft: 9 corp: 6/19b lim: 4 exec/s: 0 rss: 29Mb L: 4/4 MS: 4 CopyPart-PersAutoDict-CopyPart-ChangeByte- DE: "1\x00"-
在此之后,檢測到緩沖區溢出,并跟隨來自地址 sanitizer 的信息。最后,報告告訴你導致緩沖區溢出的輸入保存在哪里:
artifact_prefix='./'; Test unit written to ./crash-17ba0791499db908433b80f37c5fbc89b870084b
使用保存的輸入,可以使用相同的崩潰輸入再次執行測試用例:
$ ./fuzzer crash-17ba0791499db908433b80f37c5fbc89b870084b
這有助于識別問題,因為我們可以利用保存的輸入作為直接的復制品來修復可能出現的任何問題。然而,僅使用隨機數據通常在每種情況下并不是很有幫助。如果你嘗試模糊測試 tinylang 詞法分析器或解析器,那么純隨機數據會導致輸入立即被拒絕,因為找不到有效的標記。
在這種情況下,提供一組有效的輸入更有用,稱為語料庫。在這種情況下,語料庫中的文件被隨機變異并用作輸入。你可以認為輸入大部分是有效的,只是翻轉了一些位。這對于必須具有特定格式的其他輸入也有效。例如,對于處理 JPEG 和 PNG 文件的庫,你將提供一些小的 JPEG 和 PNG 文件作為語料庫。
提供語料庫的示例如下。你可以在一個或多個目錄中保存語料庫文件,你可以使用 printf 命令的幫助為我們的模糊測試創建一個簡單的語料庫:
$ mkdir corpus
$ printf "012345\0" >corpus/12345.txt
$ printf "987\0" >corpus/987.txt
運行測試時,你必須在命令行上提供目錄:
$ ./fuzzer corpus/
然后,語料庫被用作生成隨機輸入的基礎,報告告訴你:
INFO: seed corpus: files: 2 min: 4b max: 7b total: 11b rss: 29Mb
此外,如果你正在測試一個在標記或其他魔術值上工作的函數,例如一個編程語言,那么通過提供一個包含標記的字典,可以加快進程。對于編程語言,字典將包含語言中使用的所有關鍵字和特殊符號。此外,字典定義遵循一個簡單的鍵值風格。例如,要在字典中定義 if 關鍵字,你可以添加以下內容:
kw1="if"
然而,鍵是可選的,你可以省略它。現在,你可以在命令行上使用 –dict= 選項指定字典文件。
現在我們已經介紹了如何使用 libFuzzer 查找錯誤,讓我們看看 libFuzzer 實現的限制和替代方案。
限制和替代方案 libFuzzer 實現很快,但對測試目標提出了幾個限制。它們如下:
被測試的函數必須接受內存中的數組作為輸入。一些庫函數需要數據的文件路徑,它們不能使用 libFuzzer 進行測試。 不應調用 exit() 函數。 不應改變全局狀態。 不應使用硬件隨機數生成器。 前兩個限制是 libFuzzer 作為庫實現的含義。后兩個限制是為了避免評估算法中的混淆。如果這些限制中的任何一個沒有得到滿足,那么兩個相同的對模糊測試目標的調用可能會產生不同的結果。
最著名的替代模糊測試工具是 AFL,可以在 https://github.com/google/AFL 上找到。AFL 需要一個有工具的二進制文件(提供 LLVM 插件進行工具化),并要求應用程序在命令行上將輸入作為文件路徑。AFL 和 libFuzzer 可以共享相同的語料庫和相同的字典文件。因此,可以使用這兩種工具測試應用程序。此外,在 libFuzzer 不適用的情況下,AFL 可能是一個很好的替代品。
還有很多方法可以影響 libFuzzer 的工作方式。你可以在 https://llvm.org/docs/LibFuzzer.html 上閱讀參考頁面以獲取更多詳細信息。
在接下來的部分中,我們將查看應用程序可能存在的不同問題 - 我們將嘗試使用 XRay 工具識別性能瓶頸。
使用 XRay 進行性能分析 如果你的應用程序運行緩慢,那么你可能想要知道代碼中的時間花費在哪里。在這里,使用 XRay 對代碼進行檢測可以幫助完成這項任務。基本上,在每個函數的入口和出口處,都會插入一個特殊的調用到運行時庫。這允許你計算函數被調用了多少次,以及在函數中花費了多少時間。你可以在 llvm/lib/XRay/ 目錄中找到工具傳遞的實現。運行時部分是 compiler-rt 的一部分。
在以下示例源代碼中,通過調用 usleep() 函數來模擬實際工作。func1() 函數睡眠 10 μs。func2() 函數根據 n 參數是奇數還是偶數,調用 func1() 或睡眠 100 μs。在 main() 函數中,兩個函數都在循環中被調用。這已經足夠獲得有趣的信息了。你需要將以下源代碼保存在 xraydemo.c 文件中:
#include <unistd.h>
void func1() { usleep(10); }
void func2(int n) {
if (n % 2) func1();
else usleep(100);
}
int main(int argc, char *argv[]) {
for (int i = 0; i < 100; i++) { func1(); func2(i); }
return 0;
}
要在編譯期間啟用 XRay 檢測,你需要指定 -fxray-instrument 選項。值得注意的是,少于 200 條指令的函數不會被檢測。這是因為這是開發者定義的任意閾值,在我們的案例中,函數將不會被檢測。可以使用 -fxray-instruction-threshold= 選項來指定閾值。
或者,我們可以添加一個函數屬性來控制是否應該檢測一個函數。例如,添加以下原型將始終導致我們檢測該函數:
void func1() __attribute__((xray_always_instrument));
同樣,通過使用 xray_never_instrument 屬性,你可以關閉一個函數的檢測。
現在,我們將使用命令行選項并編譯 xraydemo.c 文件,如下所示:
$ clang -fxray-instrument -fxray-instruction-threshold=1 -g\
xraydemo.c -o xraydemo
在生成的二進制文件中,默認情況下檢測是關閉的。如果你運行二進制文件,你會注意到與非檢測二進制文件相比沒有區別。XRAY_OPTIONS 環境變量用于控制運行時數據的記錄。要啟用數據收集,你可以按如下方式運行應用程序:
$ XRAY_OPTIONS="patch_premain=true xray_mode=xray-basic"\
./xraydemo
xray_mode=xray-basic 選項告訴運行時我們想要使用基本模式。在這種模式下,收集了所有的運行時數據,這可能會導致大型日志文件。當給出 patch_premain=true 選項時,也會檢測在 main() 函數之前運行的函數。
運行此命令后,將在目錄中創建一個新文件,存儲收集的數據。你需要使用 llvm-xray 工具從該文件中提取任何可讀信息。
llvm-xray 工具支持各種子命令。首先,你可以使用 account 子命令提取一些基本統計信息。例如,要獲取最常調用的前 10 個函數,你可以添加 -top=10 選項來限制輸出,并使用 -sort=count 選項指定函數調用計數作為排序標準。你還可以使用 -sortorder= 選項影響排序順序。
以下命令可以運行以獲取我們程序的統計信息:
$ llvm-xray account xray-log.xraydemo.xVsWiE --sort=count\
--sortorder=dsc --instr_map ./xraydemo
具有延遲的函數:3 函數id 計數 總和 函數 1 150 0.166002 demo.c:4:0: func1 2 100 0.543103 demo.c:9:0: func2 3 1 0.655643 demo.c:17:0: main
如你所見,func1() 函數被調用得最頻繁;你還可以看到在這個函數中花費的累積時間。這個例子只有三個函數,所以 –top= 選項在這里沒有可見效果,但對于真實應用程序,它非常有用。
從收集的數據中,可以重建運行時發生的所有堆棧幀。你使用 stack 子命令來查看前 10 個堆棧。這里顯示的輸出已經簡化,以便于理解:
$ llvm-xray stack xray-log.xraydemo.xVsWiE –instr_map\
./xraydemo
Unique Stacks: 3 Top 10 Stacks by leaf sum: Sum: 1325516912 lvl function 計數 總和 #0 main 1 1777862705 #1 func2 50 1325516912 Top 10 Stacks by leaf count: Count: 100 lvl function 計數 總和 #0 main 1 1777862705 #1 func1 100 303596276
堆棧幀是函數調用的序列。func2() 函數由 main() 函數調用,這是累積時間最長的堆棧幀。深度取決于調用了多少個函數,堆棧幀通常很大。
這個子命令也可以用來從堆棧幀創建火焰圖。使用火焰圖,你可以很容易地識別哪些函數有大量的累積運行時間。輸出是帶有計數和運行時間信息的堆棧幀。使用 flamegraph.pl 腳本,你可以將數據轉換為可縮放矢量圖形(SVG)文件,你可以在瀏覽器中查看。
使用以下命令,你指示 llvm-xray 輸出所有堆棧幀與 –all-stacks 選項。使用 –stack-format=flame 選項,輸出格式符合 flamegraph.pl 腳本的預期。此外,使用 –aggregation-type 選項,你可以選擇按總時間或按調用次數聚合堆棧幀。llvm-xray 的輸出被管道傳輸到 flamegraph.pl 腳本,并將結果輸出保存在 flame.svg 文件中:
$ llvm-xray stack xray-log.xraydemo.xVsWiE --all-stacks\
--stack-format=flame --aggregation-type=time\
--instr_map ./xraydemo | flamegraph.pl >flame.svg
運行命令并生成新的火焰圖后,你可以在瀏覽器中打開生成的 flame.svg 文件。圖形如下所示:
圖 10.1 - llvm-xray 生成的火焰圖
火焰圖乍一看可能會讓人感到困惑,因為 X 軸沒有經過時間的通常意義。相反,函數按名稱字母順序簡單排序。此外,火焰圖的 Y 軸顯示堆棧深度,從零開始計數。顏色被選擇為具有良好的對比度,沒有其他含義。從前面的圖中,你可以輕松地確定函數的調用層次結構和在函數中花費的時間。
將鼠標光標移動到表示框架的矩形上,只會顯示有關堆棧框架的信息。通過單擊框架,你可以放大此堆棧框架。火焰圖對于幫助你識別值得優化的函數非常有用。要了解有關火焰圖的更多信息,請訪問火焰圖的發明者 Brendan Gregg 的網站:http://www.brendangregg.com/flamegraphs.html。
此外,你可以使用 convert 子命令將數據轉換為 .yaml 格式或 Chrome Trace Viewer Visualization 使用的格式。后者是另一種很好的方式,可以從數據中創建圖形。要將數據保存在 xray.evt 文件中,你可以運行以下命令:
$ llvm-xray convert --output-format=trace_event\
--output=xray.evt --symbolize --sort\
--instr_map=./xraydemo xray-log.xraydemo.xVsWiE
如果你沒有指定 –symbolize 選項,那么結果圖形中將不顯示函數名稱。
一旦你這樣做了,在 Chrome 中打開并輸入 chrome:///tracing。接下來,點擊加載按鈕加載 xray.evt 文件。你將看到以下數據的可視化:
圖 10.2 - llvm-xray 生成的 Chrome Trace Viewer 可視化
在這個視圖中,堆棧框架按函數調用發生的時間排序。有關可視化的進一步解釋,請閱讀 https://www.chromium.org/developers/how-tos/trace-event-profiling-tool 上的教程。
提示
llvm-xray 工具具有更多適用于性能分析的功能。你可以在 LLVM 網站上的 https://llvm.org/docs/XRay.html 和 https://llvm.org/docs/XRayExample.html 上閱讀有關它的更多信息。
在本節中,我們學習了如何使用 XRay 對應用程序進行檢測,如何收集運行時信息,以及如何可視化這些數據。我們可以使用這些知識來識別應用程序中的性能瓶頸。
識別應用程序錯誤的另一種方法是分析源代碼,這是使用 Clang 靜態分析器完成的。
使用 Clang 靜態分析器檢查源代碼 Clang 靜態分析器是一個工具,它對 C、C++ 和 Objective-C 源代碼執行額外的檢查。靜態分析器執行的檢查比編譯器執行的檢查更徹底。它們在時間和所需資源方面也更昂貴。靜態分析器有一組檢查器,用于檢查某些錯誤。
該工具執行源代碼的符號解釋,它查看應用程序的所有代碼路徑,并從中推導出應用程序中使用的值的約束。符號解釋是編譯器中常用的一種技術,例如,用于識別常量值。在靜態分析器的上下文中,檢查器應用于推導出的值。
例如,如果除法的除數為零,靜態分析器會警告我們。我們可以通過存儲在 div.c 文件中的以下示例來檢查這一點:
int divbyzero(int a, int b) { return a / b; }
int bug() { return divbyzero(5, 0); }
靜態分析器將警告此示例中的除以零錯誤。然而,當使用命令 clang -Wall -c div.c 編譯文件時,將不顯示任何警告。
有兩種方式可以從命令行調用靜態分析器。較舊的工具是 scan-build,它包含在 LLVM 中,并且可以用于簡單場景。較新的工具是 CodeChecker,可在 https://github.com/Ericsson/codechecker/ 上獲得。要檢查單個文件,scan-build 工具是最簡單的解決方案。你只需將編譯命令傳遞給工具;其他所有事情都會自動完成:
$ scan-build clang -c div.c
scan-build: 使用 '/usr/home/kai/LLVM/llvm-17/bin/clang-17' 進行靜態分析 div.c:2:12: 警告:零除錯誤 [core.DivideZero] return a / b; ~~^~~~ 1 個警告已生成。 scan-build: 分析運行完成。 scan-build: 發現 1 個錯誤。 scan-build: 運行 'scan-view /tmp/scan-build-2021-03-01-023401-8721-1' 以檢查錯誤報告。 屏幕上的輸出已經告訴你發現了一個錯誤 - 也就是說,觸發了 core.DivideZero 檢查器。然而,這還不是全部。在 /tmp 目錄的指定子目錄中,你將找到一個完整的 HTML 報告。然后,你可以使用 scan-view 命令查看報告,或者在子目錄中找到的 index.html 文件在你的瀏覽器中打開。
報告的第一頁向你展示了發現的錯誤的摘要:
圖 10.3 - 摘要頁面
對于發現的每個錯誤,摘要頁面顯示錯誤類型、源代碼中的位置以及分析器發現錯誤后的路徑長度。還提供了指向錯誤詳細報告的鏈接。
以下截圖顯示了錯誤的詳細報告:
圖 10.4 - 詳細報告
有了這個詳細報告,你可以通過跟隨編號的氣泡來驗證錯誤。我們的簡單示例展示了如何將 0 作為參數值傳遞導致除以零錯誤。
因此,需要人工驗證。如果某個檢查器的派生約束不夠精確,則可能會出現誤報 - 也就是說,對于完全正常的代碼報告了一個錯誤。根據報告,你可以使用它們來識別誤報。
你不限于工具提供的檢查器 - 你還可以添加新的檢查器。下一節展示了如何做到這一點。
向 Clang 靜態分析器添加新檢查器 許多 C 庫提供必須成對使用的函數。例如,C 標準庫提供了 malloc() 和 free() 函數。由 malloc() 函數分配的內存必須由 free() 函數精確地釋放一次。不調用 free() 函數或調用多次是編程錯誤。還有更多這種編碼模式的實例,靜態分析器為其中一些提供了檢查器。
iconv 庫提供將文本從一種編碼轉換為另一種編碼的函數 - 例如,從 Latin-1 編碼轉換為 UTF-16 編碼。執行轉換時,實現需要分配內存。為了透明地管理內部資源,iconv 庫提供了 iconv_open() 和 iconv_close() 函數,它們必須成對使用,類似于內存管理函數。這些函數沒有實現檢查器,所以讓我們實現一個。
要向 Clang 靜態分析器添加新檢查器,你必須創建一個 Checker 類的新子類。靜態分析器嘗試所有可能的代碼路徑。分析器引擎在某些點生成事件 - 例如,在函數調用之前或之后。此外,你的類必須為這些事件提供回調,如果你需要處理它們。Checker 類和事件的注冊在 clang/include/clang/StaticAnalyzer/Core/Checker.h 頭文件中提供。
通常,檢查器需要跟蹤一些符號。然而,檢查器不能管理狀態,因為它不知道分析器引擎當前正在嘗試哪個代碼路徑。因此,跟蹤的狀態必須使用 ProgramStateRef 實例向引擎注冊。
為了檢測錯誤,檢查器需要跟蹤從 iconv_open() 函數返回的描述符。分析器引擎為 iconv_open() 函數的返回值返回一個 SymbolRef 實例。我們將此符號與狀態關聯,以反映 iconv_close() 是否被調用。對于狀態,我們創建了 IconvState 類,它封裝了一個布爾值。
新的 IconvChecker 類需要處理四種類型的事件:
PostCall,在函數調用后發生。在調用 iconv_open() 函數后,我們檢索了返回值的符號,并將其記住為處于“打開”狀態。 PreCall,在函數調用前發生。在 iconv_close() 函數被調用之前,我們檢查描述符的符號是否處于“打開”狀態。如果沒有,那么 iconv_close() 函數已經對該描述符調用過了,我們已經檢測到了對該函數的重復調用。 DeadSymbols,當未使用的符號被清理時發生。我們檢查一個未使用的描述符符號是否仍然處于“打開”狀態。如果是,那么我們就檢測到了對 iconv_close() 的缺失調用,這是一個資源泄漏。 PointerEscape,當符號不再能被分析器跟蹤時調用。在這種情況下,我們從狀態中刪除符號,因為我們不能再推斷描述符是否已關閉。 我們可以創建一個新目錄來實現新檢查器作為一個 Clang 插件,并在 IconvChecker.cpp 文件中添加實現:
為了實現,我們需要包括幾個頭文件。BugType.h 頭文件需要用于發出報告。Checker.h 頭文件提供了 Checker 類的聲明和事件的回調,這些回調在 CallEvent 文件中聲明。此外,CallDescription.h 文件有助于匹配函數和方法。最后,CheckerContext.h 文件需要用于聲明 CheckerContext 類,這是提供對分析器狀態訪問的中心類:
#include "clang/StaticAnalyzer/Core/BugReporter/BugType.h"
#include "clang/StaticAnalyzer/Core/Checker.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallDescription.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CallEvent.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/CheckerContext.h"
#include "clang/StaticAnalyzer/Frontend/CheckerRegistry.h"
#include <optional>
為了避免輸入命名空間名稱,我們可以使用 clang 和 ento 命名空間:
using namespace clang;
using namespace ento;
我們為每個表示 iconv 描述符的符號關聯一個狀態。狀態可以是打開或關閉的,我們使用一個布爾類型變量,對于打開狀態,其值為 true。狀態值被封裝在 IconvState 結構體中。這個結構體使用一個 FoldingSet 數據結構,這是一個哈希集合,可以過濾重復條目。為了能夠使用這個數據結構的實現,這里添加了 Profile() 方法,它設置了這個結構體的唯一位。我們把結構體放在一個匿名命名空間中,以避免污染全局命名空間。類不直接暴露布爾值,而是提供了 getOpened() 和 getClosed() 工廠方法以及 isOpen() 查詢方法:
namespace {
class IconvState {
const bool IsOpen;
IconvState(bool IsOpen) : IsOpen(IsOpen) {}
public:
bool isOpen() const { return IsOpen; }
static IconvState getOpened() {
return IconvState(true);
}
static IconvState getClosed() {
return IconvState(false);
}
bool operator==(const IconvState &O) const {
return IsOpen == O.IsOpen;
}
void Profile(llvm::FoldingSetNodeID &ID) const {
ID.AddInteger(IsOpen);
}
};
} // namespace
IconvState 結構體表示一個 iconv 描述符的狀態,該狀態由 SymbolRef 類的符號表示。這最好通過一個映射來完成,映射的鍵是符號,值是狀態。正如前面解釋的,檢查器不能持有狀態。相反,狀態必須使用 REGISTER_MAP_WITH_PROGRAMSTATE 宏與全局程序狀態注冊。這個宏引入了 IconvStateMap 名稱,我們將在后面用來訪問映射:
REGISTER_MAP_WITH_PROGRAMSTATE(IconvStateMap, SymbolRef,
IconvState)
我們還在一個匿名命名空間中實現了 IconvChecker 類。請求的 PostCall、PreCall、DeadSymbols 和 PointerEscape 事件是 Checker 基類的模板參數:
namespace {
class IconvChecker
: public Checker<check::PostCall, check::PreCall,
check::DeadSymbols,
check::PointerEscape> {
// ...
};
} // namespace
IconvChecker 類有 CallDescription 類型的字段,用于識別程序中對 iconv_open()、iconv() 和 iconv_close() 的函數調用:
CallDescription IconvOpenFn, IconvFn, IconvCloseFn;
類還持有檢測到的錯誤類型的引用:
std::unique_ptr<BugType> DoubleCloseBugType;
std::unique_ptr<BugType> LeakBugType;
最后,類有幾個方法。除了構造函數和用于調用事件的方法,我們還需要一個方法來發出錯誤報告:
void report(ArrayRef<SymbolRef> Syms,
const BugType &Bug, StringRef Desc,
CheckerContext &C, ExplodedNode *ErrNode,
std::optional<SourceRange> Range =
std::nullopt) const;
public:
IconvChecker();
void checkPostCall(const CallEvent &Call,
CheckerContext &C) const;
void checkPreCall(const CallEvent &Call,
CheckerContext &C) const;
void checkDeadSymbols(SymbolReaper &SymReaper,
CheckerContext &C) const;
ProgramStateRef
checkPointerEscape(ProgramStateRef State,
const InvalidatedSymbols &Escaped,
const CallEvent *Call,
PointerEscapeKind Kind) const;
我們可以開始實現 IconvChecker 類的構造函數,使用函數名稱初始化 CallDescription 字段,并創建表示錯誤的 BugType 對象:
IconvChecker::IconvChecker()
: IconvOpenFn({"iconv_open"}), IconvFn({"iconv"}),
IconvCloseFn({"iconv_close"}, 1) {
DoubleCloseBugType.reset(new BugType(
this, "Double iconv_close", "Iconv API Error"));
LeakBugType.reset(new BugType(
this, "Resource Leak", "Iconv API Error",
/*SuppressOnSink=*/true));
}
現在,我們可以實施第一個調用事件方法 checkPostCall()。此方法在分析器執行函數調用后被調用。如果執行的函數不是全局 C 函數且名稱不是 iconv_open,則無需進行任何操作:
void IconvChecker::checkPostCall(
const CallEvent &Call, CheckerContext &C) const {
if (!Call.isGlobalCFunction())
return;
if (!IconvOpenFn.matches(Call))
return;
否則,我們可以嘗試將函數的返回值獲取為一個符號。為了將符號與打開狀態存儲在全局程序狀態中,我們需要從 CheckerContext 實例中獲取一個 ProgramStateRef 實例。狀態是不可變的,所以向狀態中添加符號會得到一個新的狀態。最后,通過調用 addTransition() 方法通知分析器引擎新的狀態:
if (SymbolRef Handle =
Call.getReturnValue().getAsSymbol()) {
ProgramStateRef State = C.getState();
State = State->set<IconvStateMap>(
Handle, IconvState::getOpened());
C.addTransition(State);
}
同樣,checkPreCall() 方法在分析器執行函數之前被調用。我們只對名為 iconv_close 的全局 C 函數感興趣:
void IconvChecker::checkPreCall(
const CallEvent &Call, CheckerContext &C) const {
if (!Call.isGlobalCFunction()) {
return;
}
if (!IconvCloseFn.matches(Call)) {
return;
}
如果已知函數的第一個參數的符號(即 iconv 描述符),那么我們可以程序狀態中檢索符號的狀態:
if (SymbolRef Handle =
Call.getArgSVal(0).getAsSymbol()) {
ProgramStateRef State = C.getState();
if (const IconvState *St =
State->get<IconvStateMap>(Handle)) {
如果狀態表示已關閉狀態,那么我們已經檢測到一個雙重關閉錯誤,可以為其生成一個錯誤報告。調用 generateErrorNode() 可能會返回一個空值,如果已經為這個路徑生成了錯誤報告,所以我們需要檢查這種情況:
if (!St->isOpen()) {
if (ExplodedNode *N = C.generateErrorNode()) {
report(Handle, *DoubleCloseBugType,
"Closing a previous closed iconv "
"descriptor",
C, N, Call.getSourceRange());
}
return;
}
否則,我們必須為符號設置狀態為“已關閉”狀態:
State = State->set<IconvStateMap>(
Handle, IconvState::getClosed());
C.addTransition(State);
checkDeadSymbols() 方法被調用以清理未使用的符號。我們循環遍歷我們跟蹤的所有符號,并詢問 SymbolReaper 實例當前符號是否已死亡:
void IconvChecker::checkDeadSymbols(
SymbolReaper &SymReaper, CheckerContext &C) const {
ProgramStateRef State = C.getState();
SmallVector<SymbolRef, 8> LeakedSyms;
for (auto [Sym, St] : State->get<IconvStateMap>()) {
if (SymReaper.isDead(Sym)) {
如果符號已死亡,那么我們需要檢查狀態。如果狀態仍然是打開的,那么這是一個潛在的資源泄漏。有一個例外:iconv_open() 在出錯時返回 -1。如果分析器處于處理此錯誤的代碼路徑中,那么假設資源泄漏是錯誤的,因為函數調用失敗了。我們嘗試從 ConstraintManager 實例獲取符號的值,如果這個值是 -1,我們不考慮符號作為資源泄漏。我們向 SmallVector 實例添加一個泄漏符號,稍后生成錯誤報告。最后,我們從程序狀態中刪除死亡符號:
if (St.isOpen()) {
bool IsLeaked = true;
if (const llvm::APSInt *Val =
State->getConstraintManager().getSymVal(
State, Sym))
IsLeaked = Val->getExtValue() != -1;
if (IsLeaked)
LeakedSyms.push_back(Sym);
}
State = State->remove<IconvStateMap>(Sym);
}
}
循環結束后,我們調用 generateNonFatalErrorNode() 方法。這個方法轉換到新的程序狀態,并在此路徑上還沒有錯誤節點時返回錯誤節點。LeakedSyms 容器保存了(可能是空的)泄漏符號列表,我們調用 report() 方法來生成錯誤報告:
if (ExplodedNode *N =
C.generateNonFatalErrorNode(State)) {
report(LeakedSyms, *LeakBugType,
"Opened iconv descriptor not closed", C, N);
}
checkPointerEscape() 函數在分析器檢測到一個函數調用,其參數不能被跟蹤時被調用。在這種情況下,我們必須假設我們不知道 iconv 描述符是否會在函數內部關閉。例外情況是對 iconv() 的調用,它執行轉換,已知不會調用 iconv_close() 函數,以及我們在 checkPreCall() 方法中處理的 iconv_close() 函數本身。如果調用在系統頭文件中,并且我們知道調用的函數中參數不會逃逸,我們也不會改變狀態。在所有其他情況下,我們從狀態中刪除符號:
ProgramStateRef IconvChecker::checkPointerEscape(
ProgramStateRef State,
const InvalidatedSymbols &Escaped,
const CallEvent *Call,
PointerEscapeKind Kind) const {
if (Kind == PSK_DirectEscapeOnCall) {
if (IconvFn.matches(*Call) ||
IconvCloseFn.matches(Call))
return State;
if (Call->isInSystemHeader() ||
!Call->argumentsMayEscape())
return State;
}
for (SymbolRef Sym : Escaped)
State = State->remove<IconvStateMap>(Sym);
return State;
}
report() 方法生成一個錯誤報告。該方法的重要參數是符號數組、錯誤類型和錯誤描述。在方法內部,為每個符號創建一個錯誤報告,并將符號標記為該錯誤的相關項。如果提供了源范圍作為參數,也會將其添加到報告中。最后,發出報告:
void IconvChecker::report(
ArrayRef<SymbolRef> Syms, const BugType &Bug,
StringRef Desc, CheckerContext &C,
ExplodedNode *ErrNode,
std::optional<SourceRange> Range) const {
for (SymbolRef Sym : Syms) {
auto R = std::make_unique<PathSensitiveBugReport>(
Bug, Desc, ErrNode);
R->markInteresting(Sym);
if (Range)
R->addRange(*Range);
C.emitReport(std::move(R));
}
}
現在,需要在 CheckerRegistry 實例中注冊新檢查器。當插件加載時,使用 clang_registerCheckers() 函數執行注冊。每個檢查器都有一個名稱并屬于一個包。我們稱 IconvChecker 檢查器,將其放入 unix 包中,因為 iconv 庫是一個標準的 POSIX 接口。這是 addChecker() 方法的第一個參數。第二個參數是功能的簡要文檔,第三個參數可以是提供有關檢查器的更多信息的文檔的 URI:
extern "C" void
clang_registerCheckers(CheckerRegistry ?istry) {
registry.addChecker<IconvChecker>(
"unix.IconvChecker",
"Check handling of iconv functions", "");
}
最后,需要聲明我們使用的靜態分析器 API 的版本,這允許系統確定插件是否兼容:
extern "C" const char clang_analyzerAPIVersionString[] =
CLANG_ANALYZER_API_VERSION_STRING;
這完成了新檢查器的實現。要構建插件,我們還需要在與 IconvChecker.cpp 相同的目錄中創建 CMakeLists.txt 文件的構建描述:
首先定義所需的 CMake 版本和項目名稱:
cmake_minimum_required(VERSION 3.20.0)
project(iconvchecker)
接下來,包含 LLVM 文件。如果 CMake 無法自動找到文件,則需要設置 LLVM_DIR 變量,使其指向包含 CMake 文件的 LLVM 目錄:
find_package(LLVM REQUIRED CONFIG)
將包含 CMake 文件的 LLVM 目錄添加到搜索路徑,并包含 LLVM 的所需模塊:
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(AddLLVM)
include(HandleLLVMOptions)
然后,加載 Clang 的 CMake 定義。如果 CMake 無法自動找到文件,則需要設置 Clang_DIR 變量,使其指向包含 CMake 文件的 Clang 目錄:
find_package(Clang REQUIRED)
接下來,定義頭文件和庫文件的位置,并確定要使用的定義:
include_directories("${LLVM_INCLUDE_DIR}"
"${CLANG_INCLUDE_DIRS}")
add_definitions("${LLVM_DEFINITIONS}")
link_directories("${LLVM_LIBRARY_DIR}")
前面的定義設置了構建環境。插入以下命令,它定義了你的插件的名稱、插件的源文件,并指出它是一個 Clang 插件:
add_llvm_library(IconvChecker MODULE IconvChecker.cpp
PLUGIN_TOOL clang)
在 Windows 上,插件支持與 Unix 不同,必須鏈接所需的 LLVM 和 Clang 庫。以下代碼確保了這一點:
if(WIN32 OR CYGWIN)
set(LLVM_LINK_COMPONENTS Support)
clang_target_link_libraries(IconvChecker PRIVATE
clangAnalysis
clangAST
clangStaticAnalyzerCore
clangStaticAnalyzerFrontend)
endif()
現在,我們可以配置并構建插件,假設 CMAKE_GENERATOR 和 CMAKE_BUILD_TYPE 環境變量已設置:
$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \
-DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \
-B build
$ cmake --build build
這些步驟在構建目錄中創建了 IconvChecker.so 共享庫。
要測試新檢查器,請將以下源代碼保存在 conv.c 文件中,該文件對 iconv_close() 函數進行了兩次調用:
#include <iconv.h>
void doconv() {
iconv_t id = iconv_open("Latin1", "UTF-16");
iconv_close(id);
iconv_close(id);
}
要使用插件與 scan-build 腳本,你需要通過 -load-plugin 選項指定插件的路徑。使用 conv.c 文件運行看起來像這樣:
$ scan-build -load-plugin build/IconvChecker.so clang-17 \
-c conv.c
scan-build: 使用 '/home/kai/LLVM/llvm-17/bin/clang-17' 進行靜態分析 conv.c:6:3: 警告: 關閉之前已關閉的 iconv 描述符 [unix.IconvChecker] 6 | iconv_close(id); | ^~~~~~~~~~~~~~~~ 1 個警告已生成。 scan-build: 分析運行完成。 scan-build: 發現 1 個錯誤。 scan-build: 運行 'scan-view /tmp/scan-build-2023-08-08-114154-12451-1' 以檢查錯誤報告。
有了這個,你已經學會了如何使用自己的檢查器擴展 Clang 靜態分析器。你可以利用這些知識創建新的通用檢查器并為社區做出貢獻,或者專門為你的需求創建檢查器,以提高你的產品質量。
靜態分析器是利用 Clang 基礎設施構建的。下一節將向你介紹如何構建你自己的 Clang 插件。
創建你自己的基于 Clang 的工具 靜態分析器是一個令人印象深刻的例子,展示了你可以用 Clang 基礎設施做什么。你也可以通過插件擴展 Clang,以便你可以向 Clang 添加你自己的功能。這種技術與向 LLVM 添加傳遞插件非常相似。
讓我們通過一個簡單的插件探索這個功能。LLVM 編碼標準要求函數名稱以小寫字母開始。然而,編碼標準已經發展,有許多函數名稱以大寫字母開始的情況。一個警告命名規則違規的插件可以幫助解決這個問題,讓我們試試。
因為你想在 AST 上運行用戶定義的操作,你需要定義一個 PluginASTAction 類的子類。如果你使用 Clang 庫編寫自己的工具,那么你可以為操作定義 ASTFrontendAction 類的子類。PluginASTAction 類是 ASTFrontendAction 類的子類,具有解析命令行選項的額外能力。
你還需要一個 ASTConsumer 類的子類。AST 消費者是一個類,通過它你可以在 AST 上運行一個操作,無論 AST 的來源如何。對于我們的第一個插件,不需要更多的東西。你可以在 NamingPlugin.cpp 文件中按如下方式創建實現:
首先包括所需的頭文件。除了提到的 ASTConsumer 類,你還需要一個編譯器實例和插件注冊表:
#include "clang/AST/ASTConsumer.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
使用 clang 命名空間,并將你的實現放入匿名命名空間以避免名稱沖突:
using namespace clang;
namespace {
接下來,定義你的 ASTConsumer 類的子類。稍后,你將希望在檢測到命名規則違規時發出警告。為此,你需要一個 DiagnosticsEngine 實例的引用。你需要在類中存儲一個 CompilerInstance 實例,之后你可以請求一個 DiagnosticsEngine 實例:
class NamingASTConsumer : public ASTConsumer {
CompilerInstance &CI;
public:
NamingASTConsumer(CompilerInstance &CI) : CI(CI) {}
ASTConsumer 實例有幾個入口方法。HandleTopLevelDecl() 方法適合我們的目的。這個方法對于每個頂層聲明都會被調用。這不僅僅是函數 - 例如,變量。所以,你必須使用 LLVM RTTI dyn_cast<>() 函數來確定聲明是否是函數聲明。HandleTopLevelDecl() 方法有一個聲明組作為參數,其中可以包含多個聲明。這需要一個循環來遍歷聲明。以下代碼顯示了 HandleTopLevelDecl() 方法:
bool HandleTopLevelDecl(DeclGroupRef DG) override {
for (DeclGroupRef::iterator I = DG.begin(),
E = DG.end();
I != E; ++I) {
const Decl *D = *I;
if (const FunctionDecl *FD =
dyn_cast<FunctionDecl(D)) {
在找到函數聲明后,你需要檢索函數的名稱。你還需要確保名稱不為空:
std::string Name =
FD->getNameInfo().getName().getAsString();
assert(Name.length() > 0 &&
"Unexpected empty identifier");
如果函數名稱不以小寫字母開頭,那么你發現了命名規則的違規:
char &First = Name.at(0);
if (!(First >= 'a' && First <= 'z')) {
要發出警告,你需要一個 DiagnosticsEngine 實例。此外,你需要一個消息 ID。在 clang 內部,消息 ID 被定義為一個枚舉。因為你的插件不是 clang 的一部分,你需要創建一個自定義 ID,然后使用它來發出警告:
DiagnosticsEngine &Diag = CI.getDiagnostics();
unsigned ID = Diag.getCustomDiagID(
DiagnosticsEngine::Warning,
"Function name should start with "
"lowercase letter");
Diag.Report(FD->getLocation(), ID);
除了關閉所有打開的大括號,你需要從這個函數返回 true 以表示可以繼續處理:
}
}
}
return true;
}
};
接下來,你需要創建 PluginASTAction 子類,它實現了 clang 調用的接口:
class PluginNamingAction : public PluginASTAction {
public:
你必須實現的第一個方法是 CreateASTConsumer(),它返回你的 NamingASTConsumer 類的實例。這個方法由 clang 調用,傳遞的 CompilerInstance 實例讓你可以訪問編譯器的所有重要類。以下代碼演示了這一點:
std::unique_ptr<ASTConsumer>
CreateASTConsumer(CompilerInstance &CI,
StringRef file) override {
return std::make_unique<NamingASTConsumer>(CI);
}
插件還可以訪問命令行選項。你的插件沒有命令行參數,你只會返回 true 以表示成功:
bool ParseArgs(const CompilerInstance &CI,
const std::vector<std::string> &args)
override {
return true;
}
插件的操作類型描述了何時調用操作。默認值是 Cmdline,這意味著必須在命令行上命名插件才能調用它。你需要重寫方法并更改值為 AddAfterMainAction,這將自動運行操作:
PluginASTAction::ActionType getActionType() override {
return AddAfterMainAction;
}
你的 PluginNamingAction 類的實現完成了;缺少的只是類和匿名命名空間的關閉大括號。按如下方式將它們添加到代碼中:
};
}
最后,需要注冊插件。第一個參數是插件的名稱,而第二個參數是幫助文本:
static FrontendPluginRegistry::Add<PluginNamingAction>
X("naming-plugin", "naming plugin");
這完成了插件的實現。要編譯插件,請在與 IconvChecker.cpp 相同的目錄中創建 CMakeLists.txt 文件的構建描述。插件位于 Clang 源代碼樹之外,因此你需要設置一個完整的項目。你可以通過按照以下步驟來完成:
首先定義所需的 CMake 版本和項目名稱:
cmake_minimum_required(VERSION 3.20.0)
project(naminglugin)
接下來,包含 LLVM 文件。如果 CMake 無法自動找到文件,則需要設置 LLVM_DIR 變量,使其指向包含 CMake 文件的 LLVM 目錄:
find_package(LLVM REQUIRED CONFIG)
將包含 CMake 文件的 LLVM 目錄添加到搜索路徑,并包含一些所需的模塊:
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(AddLLVM)
include(HandleLLVMOptions)
然后,加載 Clang 的 CMake 定義。如果 CMake 無法自動找到文件,則需要設置 Clang_DIR 變量,使其指向包含 CMake 文件的 Clang 目錄:
find_package(Clang REQUIRED)
接下來,定義頭文件和庫文件的位置,并確定要使用的定義:
include_directories("${LLVM_INCLUDE_DIR}"
"${CLANG_INCLUDE_DIRS}")
add_definitions("${LLVM_DEFINITIONS}")
link_directories("${LLVM_LIBRARY_DIR}")
前面的定義設置了構建環境。插入以下命令,它定義了你的插件的名稱、插件的源文件,并指出它是一個 Clang 插件:
add_llvm_library(NamingPlugin MODULE NamingPlugin.cpp
PLUGIN_TOOL clang)
在 Windows 上,插件支持與 Unix 不同,必須鏈接所需的 LLVM 和 Clang 庫。以下代碼確保了這一點:
if(WIN32 OR CYGWIN)
set(LLVM_LINK_COMPONENTS Support)
clang_target_link_libraries(NamingPlugin PRIVATE
clangAST clangBasic clangFrontend clangLex)
endif()
現在,我們可以配置并構建插件,假設 CMAKE_GENERATOR 和 CMAKE_BUILD_TYPE 環境變量已設置:
$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \
-DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \
-B build
$ cmake --build build
這些步驟在構建目錄中創建了 NamingPlugin.so 共享庫。
要測試插件,請將以下源代碼保存為 naming.c 文件。函數名稱 Func1 違反了命名規則,但 main 名稱沒有:
int Func1() { return 0; }
int main() { return Func1(); }
要調用插件,你需要指定 –fplugin= 選項:
$ clang -fplugin=build/NamingPlugin.so naming.c
naming.c:1:5: warning: Function name should start with lowercase letter
int Func1() { return 0; }
^
1 warning generated.
這種調用方式需要你重寫 PluginASTAction 類的 getActionType() 方法,并返回一個不同于默認 Cmdline 值的值。
如果你沒有這樣做 - 例如,因為你想要對插件操作的調用有更多的控制 - 那么你可以按如下方式從編譯器命令行運行插件:
$ clang -cc1 -load ./NamingPlugin.so -plugin naming-plugin\
naming.c
恭喜 - 你已經構建了你的第一個 Clang 插件!
這種方法的缺點是它有一些限制。ASTConsumer 類有不同的入口方法,但它們都是粗粒度的。這可以通過使用 RecursiveASTVisitor 類來解決。這個類遍歷所有 AST 節點,你可以覆蓋你感興趣的 VisitXXX() 方法。你可以通過按照以下步驟將插件重寫為使用訪問者:
你需要一個額外的 include,用于 RecursiveASTVisitor 類的定義。按如下方式插入:
#include "clang/AST/RecursiveASTVisitor.h"
然后,在匿名命名空間中定義訪問者作為第一個類。你將只存儲對 AST 上下文的引用,這將讓你訪問所有重要的 AST 操作方法,包括 DiagnosticsEngine 實例,這是發出警告所需的:
class NamingVisitor
: public RecursiveASTVisitor<NamingVisitor> {
private:
ASTContext &ASTCtx;
public:
explicit NamingVisitor(CompilerInstance &CI)
: ASTCtx(CI.getASTContext()) {}
在遍歷過程中,每當發現函數聲明時,都會調用 VisitFunctionDecl() 方法。將 HandleTopLevelDecl() 函數內部的內層循環的主體復制到這里:
virtual bool VisitFunctionDecl(FunctionDecl *FD) {
std::string Name =
FD->getNameInfo().getName().getAsString();
assert(Name.length() > 0 &&
"Unexpected empty identifier");
char &First = Name.at(0);
if (!(First >= 'a' && First <= 'z')) {
DiagnosticsEngine &Diag = ASTCtx.getDiagnostics();
unsigned ID = Diag.getCustomDiagID(
DiagnosticsEngine::Warning,
"Function name should start with "
"lowercase letter");
Diag.Report(FD->getLocation(), ID);
}
return true;
}
};
這完成了訪問者的實現。在你的 NamingASTConsumer 類中,你現在只需要存儲一個訪問者實例:
std::unique_ptr<NamingVisitor> Visitor;
public:
NamingASTConsumer(CompilerInstance &CI)
: Visitor(std::make_unique<NamingVisitor>(CI)) {}
移除 HandleTopLevelDecl() 方法 - 功能現在在訪問者類中,所以你需要重寫 HandleTranslationUnit() 方法。這個類針對每個翻譯單元調用一次。你將在這里開始 AST 遍歷:
void
HandleTranslationUnit(ASTContext &ASTCtx) override {
Visitor->TraverseDecl(
ASTCtx.getTranslationUnitDecl());
}
這個新實現具有相同的功能。優點是它更容易擴展。例如,如果你想檢查變量聲明,那么你必須實現 VisitVarDecl() 方法。或者,如果你想處理語句,那么你必須實現 VisitStmt() 方法。通過這種方法,你有了一個訪問者方法,用于 C、C++ 和 Objective-C 語言的每個實體。
擁有對 AST 的訪問權限,你可以構建執行復雜任務的插件。正如本節所描述的,強制執行命名約定是 Clang 的一個有用補充。作為插件,你也可以實現計算軟件度量,例如圈復雜度。你也可以添加或替換 AST 節點,允許你,例如,添加運行時插樁。添加插件允許你以你需要的方式擴展 Clang。
總結 在本章中,你學習了如何應用各種 sanitizers。你使用地址 sanitizer 檢測指針錯誤,使用內存 sanitizer 檢測未初始化的內存訪問,并使用線程 sanitizer 執行數據競爭。應用程序錯誤通常由格式錯誤的輸入觸發,你實現了模糊測試,以隨機數據測試你的應用程序。
你還使用 XRay 對應用程序進行了檢測,以識別性能瓶頸,并學習了如何以各種方式可視化數據。本章還教你如何利用 Clang 靜態分析器通過解釋源代碼來識別潛在錯誤,以及如何創建你自己的 Clang 插件。
這些技能將幫助你提高所構建應用程序的質量,因為在應用程序用戶抱怨之前發現運行時錯誤肯定是好的。應用你在本章中學到的知識,你不僅可以找到廣泛的常見錯誤,還可以通過新功能擴展 Clang。
在下一章中,你將學習如何向 LLVM 添加新的后端。
電纜識別儀,又名多功能電纜識別儀、智能電纜識別儀,是為電力電纜工程師和電纜工解決電纜識別的技術問題而設計的。
用戶通過儀器從多根電纜中準確識別出其中某一根目標電纜,避免誤鋸帶電電纜而引發嚴重事故。電纜識別是從電纜兩端的操作開始的,必須保證電纜兩端的雙重編號準確無誤,本儀器設計采用了PSK技術,結合精準算法。無論現場工作人員的記憶多么可靠,都不能代替專業儀器的識別。本產品同時具有帶電電纜識別、停電電纜識別、交流電流測試、交流電壓測試功能,由發射機、發射電流鉗、接收機、接收柔性電流鉗等組成。
電纜識別儀
發射機:帶電電纜識別、停電電纜識別時發射信號給目標電纜,內置大功能率可充鋰電池,自動阻抗匹配,全自動保護。發射機采用一體化專用工具箱式設計,用聚丙烯塑膠作為原料,添加新型復合填充料一次注塑成形,密度小、強度、剛度、硬度、耐磨性、耐熱性、絕緣性能更優越,其箱體能承受約200kg的壓力,主機超大LCD實時顯示剩余電池電量,白色背光、發射信號動態指示,一目了然。
發射鉗:帶電電纜識別時,發射鉗將發射機發出的信號耦合到目標電纜上,鉗口尺寸Φ120mm,發射鉗具有方向性,發射信號從發射鉗上箭頭指示方向流入。
帶電識別時:采用卡鉗耦合輸出脈沖電流,發射四種頻率:625Hz、1562Hz、2500Hz、10kHz,通過發射鉗耦合到目標電纜上(目標電纜為三芯帶鎧電纜),給電纜線芯注入復合脈沖電流信號,該脈沖電流在目標電纜周圍產生電磁場,供接收機和柔性電流鉗檢測和識別;因為脈沖電流有方向性,所以檢測也具有方向性。
停電識別時:采用直連輸出脈沖電流,給電纜線芯注入脈沖編碼電流信號,該電流在目標電纜周圍產生電磁場,供接收機和柔性電流鉗檢測、解碼、識別;因為電流有方向性,所以檢測也具有方向性。
接收機:為手持設備,3.5寸彩色液晶屏,內置高速微處理器,結合精準算法,對發射機的脈沖編碼電流信號進行識別并解碼,同時具有信號強度標定功能,顯示信號強度和檢測結果,精美直觀;彩色刻度條動態顯示,一目了然,電纜識別成功打√,非目標電纜打×,能快速自動識別目標電纜。同時可測試電壓量程為AC 0.00V~600V(50Hz/60Hz),可測交流電流量程為AC 0.00A~5000A(50Hz/60Hz),可測電流頻率45Hz~70Hz。
柔性電流鉗:為洛氏線圈,具有極佳的瞬態跟蹤能力,能快速識別發射機產生的脈沖編碼電流,適用于粗電纜或形狀不規則的導體。其鉗口內徑為約200mm,可鉗Φ200mm以下的電纜,不必斷開被測線路,非接觸測量,安全快速。
特別提示:本電纜識別儀同時具有帶電電纜識別及停電電纜識別功能,停電電纜識別時:嚴禁接入帶電電纜中。帶電電纜識別只適用于三芯帶鎧電纜。識別時,發射鉗、接收鉗不能混用,同時要保證輸入信號方向的一致。
下面以DS2131D智能帶電電纜識別儀(柔性鉗)分別介紹帶電電纜識別及停電電纜識別的兩種方法:
一、停電電纜識別的接線方法及使用方法
1. 芯線和大地相接(抗干擾能力強,推薦使用)
1、拆開電纜兩端的接地銅辮子(如果有困難,可以直接拆開近端的)。
2、發射機黑色測試線接大地網或使用接地針接地,使用接地針時盡量插入肥沃或潮濕的泥土中,以降低接地電阻值保證測試的電流幅值更大,更容易識別。
3、發射機紅色測試線接電纜線芯,接一相即可。
如果識別故障電纜,紅色測試線要接在絕緣電阻最高的那一相上。需要先做導通試驗,確保該相沒有斷線。如果是故障電纜,故障點處一般燒毀得很嚴重。使用電纜故障測試儀的高壓單元,采用周期放電模式給故障相施加高壓脈沖。故障點處會發出響亮的放電聲、并伴有放電火花。這種情況就沒有必要使用本儀器識別了。
4、把紅色測試線所接的那一相線的遠端(接收端)接地,推薦使用接地針接地,接地針不能與原地網接觸,盡量遠離原地網,避免在電纜上引起地線回流干擾。
接線參考圖:
無論是單芯或三芯電纜,如果兩端有接地銅辮子,請務必按照以下圖示接線:電纜兩端的鎧裝和銅屏蔽的接地線要斷開。
如果目標電纜沒有銅屏蔽和鎧裝,請按照下面的圖示接線:
芯線和大地相接的方法使用較繁瑣,但目標電纜上的有效電流最大,且不易受鄰近電纜干擾,若電纜絕緣好,發射電流就更不會流到交叉的其他金屬管線上,所以在特別復雜的環境應優先采用本方法。
由于芯線和大地之間存在電阻和分布電容,隨著距離的增加,電流會逐漸減小。但若接地良好,可以不予考慮。
2. 護層和大地相接(有潛在問題,不建議使用)
1、拆開電纜近端的接地銅辮子,低壓電纜的零線和地線的接地也應解開,對遠端處的接地銅辮子保持接地。
2、發射機黑色輸出端連接黑色測試線再接到近端的接地針上,芯線懸空(近端接地用接地針)。
3、發射機紅色輸出端連接紅色測試線接護層。
接線參考圖:
發射機電流流經屏蔽層,在遠端地進入大地,再經過大地返回到發射機。同樣,由于屏蔽層和大地之間存在電阻和分布電容,隨著距離的增加,電流會逐漸減小。
潛在問題:若護層(鎧裝和銅屏蔽)外部的絕緣層有破損,部分電流將由破損點流入大地形成分流,造成破損點后的電流突然減小影響接收。
3. 芯線和護層相接(接線簡單,但難以排除鄰線干擾)
1、不用拆開電纜兩端的接地銅辮子,護層接地。
2、近端發射機紅色輸出端連接紅色測試線再接芯線的一端,發射機黑色輸出端連接黑色測試線接到護層。
3、對端遠處的芯線和護層短路。
接線參考圖:
如果是單條電纜敷設,信號自發射機流經芯線,再經護層和大地兩個回路返回。因為護層(鎧裝及銅屏蔽層)由連續金屬組成,電阻很小;大地回路由于存在兩端接地電阻,再加土壤電阻,總阻值較大;另外由于芯線-護層回路和護層-大地回路存在互感,通過電磁感應也能夠在護層-大地回路產生感生電流。導致有效電流大幅減少,信號較弱,而且有效電流中含有感應電流成分,目標電纜和鄰近管線的感應信號相位相同,有可能無法根據電流方向排除鄰線干擾。
如果存在同路徑敷設(兩端位置均相同)的其他電纜,則返回電流主要被幾條電纜的護層分流。導致各條電纜信號相差不大,難以僅靠接收電流大小區分。
二、帶電電纜識別的接線及使用方法
1. 卡鉗耦合法(結果明確、抗干擾能力強)
卡鉗耦合法接線步驟:
1、無需對被測電纜進行任何操作,直接將發射電流鉗卡在電纜上測試。
2、電纜護層兩端必須良好接地,否則耦合電流隨接地電阻的增大而減小。
3、如果兩端護層沒有接地或護層中間斷開,則不可以使用卡鉗耦合法。
4、發射鉗卡入線纜時,鉗上的箭頭方向指向電纜的末端。
5、標定時,接收鉗與發射鉗盡量保持2米遠。
2. 發射頻率的選擇
由于帶電電纜具有一定的感應電流信號,會對接收信號產生干擾。
故選擇不同的發射頻率,讓接收機達到最好的接收效果。一般默認使用1562Hz作為發射頻率,可完成大部分測試;其次使用2500Hz;625Hz適用于長距離管線識別,因低頻信號傳播距離長,但是625Hz的抗干擾能力較弱;10KHz的電流不具有方向性,無法排除鄰線干擾。所以不推薦使用625Hz和10KHz,在條件滿足的情況下才選擇性使用。
發射機頻率選好后,接收機的頻率要一致,必須在同頻率下標定。
注意:使用任何頻率都要確保電纜和管線接地良好,如果接地回路總電阻超過200歐姆時將無法識別。接收器測試到的發射電流信號越大,表示接地回路的總電阻值越小,接地越好;接收器測試到的發射電流信號越小,表示接地回路的總電阻值越大,此辦法可以粗略判斷接地狀況的好壞(耦合電流小于0.2mA時不能使用)。
接線參考圖:
本方法適用于普通三相統包運行電纜的探測。發射機輸出接卡鉗,將卡鉗卡住電纜本體(注意不能卡接地線以上部分),卡鉗等效為變壓器的初級,電纜金屬護套-大地回路等效為變壓器的次級(單匝),次級耦合電流的大小與回路電阻(主要是兩端的接地電阻)密切相關,電阻越小,電流越大,如果接地回路總電阻超過200歐姆時將無法識別。電纜通過卡鉗耦合得到的電流較小,為加強探測效果,接收機增益檔位調至最大檔,耦合電流小于0.2mA時不能使用。
本方法不適于識別超高壓單芯運行電纜。由于單芯電纜芯線流過的工頻電流很強,而且沒有三芯統包電纜的三相抵消效果(對外表現為相對很小的零序電流),如果將卡鉗卡住電纜本體,則很容易造成卡鉗的磁飽和,無法正確接收電流信號。