概述
Clang是LLVM編譯器工具集的前端部分,也就是涵蓋詞法分析、語法語義分析的部分。而LLVM是Apple在Mac OS上用于替代GCC工具集的編譯器軟件集合。Clang支持類C語言的語言,例如C、C++、Objective C。Clang的與眾不同在于其模塊化的設計,使其不僅實現編譯器前端部分,并且包裝成庫的形式提供給上層應用。使用Clang可以做諸如語法高亮、語法檢查、編程規范檢查方面的工作,當然也可以作為你自己的編譯器前端。
編程規范一般包含編碼格式和語義規范兩部分。編碼格式用于約定代碼的排版、符號命名等;而語義規范則用于約定諸如類型匹配、表達式復雜度等,例如不允許對常數做邏輯運算、檢查變量使用前是否被賦值等。本文描述的主要是基于語義方面的檢查,其經驗來自于最近做的一個檢查工具,該工具實現了超過130條的規范。這份規范部分規則來自于MISRA C
編程模式
編譯器前端部分主要是輸出代碼對應的抽象語法樹(AST)。Clang提供給上層的接口也主要是圍繞語法樹來做操作。通過google一些Clang的資料,你可能會如我當初一樣對該如何正確地使用Clang心存疑惑。我最后使用的方式是基于RecursiveASTVisitor。這是一種類似回調的使用機制,通過提供特定語法樹節點的接口,Clang在遍歷語法樹的時候,在遇到該節點時,就會調用到上層代碼。不能說這是最好的方式,但起碼它可以工作?;赗ecursiveASTVisitor使用Clang,程序主體框架大致為:
// 編寫你感興趣的語法樹節點訪問接口,例如該例子中提供了函數調用語句和goto語句的節點訪問接口
class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
bool VisitCallExpr(CallExpr *expr);
bool VisitGotoStmt(GotoStmt *stmt);
...
};
class MyASTConsumer : public ASTConsumer {
public:
virtual bool HandleTopLevelDecl(DeclGroupRef DR) {
for (DeclGroupRef::iterator b = DR.begin(), e = DR.end(); b != e; ++b) {
Visitor.TraverseDecl(*b);
}
return true;
}
private:
MyASTVisitor Visitor;
};
int main(int argc, char **argv) {
CompilerInstance inst;
Rewriter writer;
inst.createFileManager();
inst.createSourceManager(inst.getFileManager());
inst.createPreprocessor();
inst.createASTContext();
writer.setSourceMgr(inst.getSourceManager(), inst.getLangOpts());
... // 其他初始化CompilerInstance的代碼
const FileEntry *fileIn = fileMgr.getFile(argv[1]);
sourceMgr.createMainFileID(fileIn);
inst.getDiagnosticClient().BeginSourceFile(inst.getLangOpts(), &inst.getPreprocessor());
MyASTConsumer consumer(writer);
ParseAST(inst.getPreprocessor(), &consumer, inst.getASTContext());
inst.getDiagnosticClient().EndSourceFile();
return 0;
}
以上代碼中,ParseAST為Clang開始分析代碼的主入口,其中提供了一個ASTConsumer。每次分析到一個頂層定義時(Top level decl)就會回調MyASTConsumer::HandleTopLevelDecl,該函數的實現里調用MyASTVisitor開始遞歸訪問該節點。這里的decl
實際上包含定義。
這里使用Clang的方式來源于Basic source-to-source transformation with Clang。
語法樹
Clang中視所有代碼單元為語句(statement),Clang中使用類Stmt
來代表statement。Clang構造出來的語法樹,其節點類型就是Stmt
。針對不同類型的語句,Clang有對應的Stmt
子類,例如GotoStmt
。Clang中的表達式也被視為語句,Clang使用Expr
類來表示表達式,而Expr
本身就派生于Stmt
。
每個語法樹節點都會有一個子節點列表,在Clang中一般可以使用如下語句遍歷一個節點的子節點:
for (Stmt::child_iterator it = stmt->child_begin(); it != stmt->child_end(); ++it) {
Stmt *child = *it;
}
但遺憾的是,無法從一個語法樹節點獲取其父節點,這將給我們的規范檢測工具的實現帶來一些麻煩。
TraverseXXXStmt
在自己實現的Visitor中(例如MyASTVisitor),除了可以提供VisitXXXStmt系列接口去訪問某類型的語法樹節點外,還可以提供TraverseXXXStmt系列接口。Traverse系列的接口包裝對應的Visit接口,即他們的關系大致為:
bool TraverseGotoStmt(GotoStmt *s) {
VisitGotoStmt(s);
return true;
}
例如對于GotoStmt節點而言,Clang會先調用TraverseGotoStmt,在TraverseGotoStmt的實現中才會調用VisitGotoStmt。利用Traverse和Visit之間的調用關系,我們可以解決一些因為不能訪問某節點父節點而出現的問題。例如,我們需要限制逗號表達式的使用,在任何地方一旦檢測到逗號表達式的出現,都給予警告,除非這個逗號表達式出現在for語句中,例如:
a = (a = 1, b = 2); /* 違反規范,非法 */
for (a = 1, b = 2; a < 2; ++a) /* 合法 */
逗號表達式對應的訪問接口為VisitBinComma,所以我們只需要提供該接口的實現即可:
class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
...
bool VisitBinComma(BinaryOperator *stmt) {
/* 報告錯誤 */
return true;
}
...
};
(注:BinaryOperator用于表示二目運算表達式,例如a + b,逗號表達式也是二目表達式)
但在循環中出現的逗號表達式也會調用到VisitBinComma。為了有效區分該逗號表達式是否出現在for語句中,我們可以期望獲取該逗號表達式的父節點,并檢查該父節點是否為for語句。但Clang并沒有提供這樣的能力,我想很大一部分原因在于臆測語法樹(抽象語法樹)節點的組織結構(父節點、兄弟節點)本身就不是一個確定的事。
這里的解決辦法是通過提供TraverseForStmt,以在進入for語句前得到一個標識:
class MyASTVisitor : public RecursiveASTVisitor<MyASTVisitor> {
public:
...
// 這個函數的實現可以參考RecursiveASTVisitor的默認實現,我們唯一要做的就是在for語句的頭那設定一個標志m_inForLine
bool TraverseForStmt(ForStmt *s) {
if (!WalkUpFromForStmt(s))
return false;
m_inForLine = true;
for (Stmt::child_range range = s->children(); range; ++range) {
if (*range == s->getBody())
m_inForLine = false;
TraverseStmt(*range);
}
return true;
}
bool VisitBinComma(BinaryOperator *stmt) {
if (!m_inForLine) {
/* 報告錯誤 */
}
return true;
}
...
};
(注:嚴格來說,我們必須檢查逗號表達式是出現在for語句的頭中,而不包括for語句循環體)
類型信息
對于表達式(Expr
)而言,都有一個類型信息。Clang直接用于表示類型的類是QualType
,實際上這個類只是一個接口包裝。這些類型信息可以用于很多類型相關的編程規范檢查。例如不允許定義超過2級的指針(例如int ***p):
bool MyASTVisitor::VisitVarDecl(VarDecl *decl) { // 當發現變量定義時該接口被調用
QualType t = decl->getType(); // 取得該變量的類型
int pdepth = 0;
// check pointer level
for ( ; t->isPointerType(); t = t->getPointeeType()) { // 如果是指針類型就獲取其指向類型(PointeeType)
++pdepth;
}
if (pdepth >= 3)
/* 報告錯誤 */
}
可以直接調用Expr::getType
接口,用于獲取指定表達式最終的類型,基于此我們可以檢查復雜表達式中的類型轉換,例如:
float f = 2.0f;
double d = 1.0;
f = d * f; /* 檢查此表達式 */
對以上表達式的檢查有很多方法,你可以實現MyASTVisitor::VisitBinaryOperator(只要是二目運算符都會調用),或者MyASTVisitor::VisitBinAssign(賦值運算=調用)。無論哪種方式,我們都可以提供一個遞歸檢查兩個表達式類型是否相同的接口:
bool HasDiffType(BinaryOperator *stmt) {
Expr *lhs = stmt->getLHS()->IgnoreImpCasts(); // 忽略隱式轉換
Expr *rhs = stmt->getRHS()->IgnoreImpCasts();
if (lhs->getType() == rhs->getType())) {
if (isa<BinaryOperator>(lhs) && HasDiffType(cast<BinaryOperator>(lhs)))
return true;
if (isa<BinaryOperator>(rhs) && HasDiffType(cast<BinaryOperator>(rhs)))
return true;
return false;
}
return true;
}
(注:此函數只是簡單實現,未考慮類型修飾符之類的問題)
該函數獲得二目運算表達式的兩個子表達式,然后遞歸檢測這兩個表達式的類型是否相同。
Expr
類提供了更多方便的類型相關的接口,例如判定該表達式是否為常數,是否是布爾表達式,甚至在某些情況下可以直接計算得到值。例如我們可以檢查明顯的死循環:
可以使用:
ASTContext &context = inst.GetASTContext();
bool result;
// 假設stmt為WhileStmt
if (stmt->getCond()->EvaluateAsBooleanCondition(result, context)) {
if (result)
/* 死循環 */
符號表
符號表這個概念比較廣義,這里我僅指的是用于保存類型和變量信息的表。Clang中沒有顯示的符號表數據結構,但每一個定義都有一個DeclContext
,DeclContext
用于描述一個定義的上下文環境。有一個特殊的DeclContext
被稱為translation unit decl
,其實也就是全局環境。利用這個translation unit decl,我們可以獲取一些全局符號,例如全局變量、全局類型:
// 獲取全局作用域里指定名字的符號列表
DeclContext::lookup_result GetGlobalDecl(const std::string &name) {
ASTContext &context = CompilerInst::getSingleton().GetASTContext();
DeclContext *tcxt = context.getTranslationUnitDecl();
IdentifierInfo &id = context.Idents.get(name);
return tcxt->lookup(DeclarationName(&id));
}
// 可以根據GetGlobalDecl的返回結果,檢查該列表里是否有特定的定義,例如函數定義、類型定義等
bool HasSpecDecl(DeclContext::lookup_result ret, Decl::Kind kind) {
for (size_t i = 0; i < ret.size(); ++i) {
NamedDecl *decl = ret[i];
if (decl->getKind() == kind) {
return true;
}
}
return false;
}
有了以上兩個函數,我們要檢測全局作用域里是否有名為”var”的變量定義,就可以:
HasSpecDecl(GetGlobalDecl("var"), Decl::Var);
每一個Decl
都有對應的DeclContext
,要檢查相同作用域是否包含相同名字的符號,其處理方式和全局的方式有點不一樣:
// 檢查在ctx中是否有與decl同名的符號定義
bool HasSymbolInContext(const NamedDecl *decl, const DeclContext *ctx) {
for (DeclContext::decl_iterator it = ctx->decls_begin(); it != ctx->decls_end(); ++it) {
Decl *d = *it;
if (d != decl && isa<NamedDecl>(d) &&
cast<NamedDecl>(d)->getNameAsString() == decl->getNameAsString())
return true;
}
return false;
}
bool HasSymbolInContext(const NamedDecl *decl) {
return HasSymbolInContext(decl, decl->getDeclContext());
}
可以看出,這里檢查相同作用域的方式是遍歷上下文環境中的所有符號,但對于全局作用域卻是直接查找。對于DeclContext
的詳細信息我也不甚明了,只能算湊合使用。實際上,這里使用“作用域”一詞并不準確,在C語言中的作用域概念,和這里的context
概念在Clang中并非等同。
如果要檢查嵌套作用域里不能定義相同名字的變量,例如:
通過Clang現有的API是無法實現的。因為Clang給上層的語法樹結構中,并不包含作用域信息(在Clang的實現中,用于語義分析的類Sema實際上有作用域的處理)。當然,為了實現這個檢測,我們可以手動構建作用域信息(通過TraverseCompoundStmt)。
宏
宏的處理屬于預處理階段,并不涵蓋在語法分析階段,所以通過Clang的語法樹相關接口是無法處理的。跟宏相關的接口,都是通過Clang的Preprocessor
相關接口。Clang為此提供了相應的處理機制,上層需要往Preprocessor
對象中添加回調對象,例如:
class MyPPCallback : public PPCallbacks {
public:
// 處理#include
virtual void InclusionDirective(SourceLocation HashLoc, const Token &IncludeTok,
StringRef FileName, bool IsAngled, CharSourceRange FilenameRange,
const FileEntry *File, StringRef SearchPath, StringRef RelativePath, const Module *Imported) {
}
// 處理#define
virtual void MacroDefined(const Token &MacroNameTok, const MacroInfo *MI) {
}
virtual void MacroUndefined(const Token &MacroNameTok, const MacroInfo *MI) {
}
}
inst.getPreprocessor().addPPCallbacks(new MyPPCallback());
即,通過實現PPCallbacks
中對應的接口,就可以獲得處理宏的通知。
Clang使用MacroInfo去表示一個宏。MacroInfo將宏體以一堆token來保存,例如我們要檢測宏體中使用##
和#
的情況,則只能遍歷這些tokens:
// 分別記錄#和##在宏體中使用的數量
int hash = 0, hashhash = 0;
for (MacroInfo::tokens_iterator it = MI->tokens_begin(); it != MI->tokens_end(); ++it) {
const Token &token = *it;
hash += (token.getKind() == tok::hash ? 1 : 0);
hashhash += (token.getKind() == tok::hashhash ? 1 : 0);
}
其他
在我們所支持的編程規范中,有些規范是難以支持的,因此我使用了一些蹩腳的方式來實現。
手工解析
在針對函數的參數定義方面,我們支持的規范要求不能定義參數為空的函數,如果該函數沒有參數,則必須以void
顯示標識,例如:
int func(); /* 非法 */
int func(void); /* 合法 */
對于Clang而言,函數定義(或聲明)使用的是FunctionDecl
,而Clang記錄的信息僅包括該函數是否有參數,參數個數是多少,并不記錄當其參數個數為0時是否使用void
來聲明(記錄下來沒多大意義)。解決這個問題的辦法,可以通過SourceLocation
獲取到對應源代碼中的文本內容,然后對此文本內容做手工分析即可。
(注:SourceLocation
是Clang中用于表示源代碼位置的類,包括行號和列號,所有Stmt
都會包含此信息)
通過SourceLocation
獲取對應源碼的內容:
std::pair<FileID, unsigned> locInfo = SM.getDecomposedLoc(loc);
bool invalidTemp = false;
llvm::StringRef file = SM.getBufferData(locInfo.first, &invalidTemp);
if (invalidTemp)
return false;
// tokenBegin即為loc對應源碼內容的起始點
const char *tokenBegin = file.data() + locInfo.second;
要手工分析這些內容實際上還是有點繁雜,為此我們可以直接使用Clang中詞法分析相關的組件來完成這件事:
Lexer *lexer = new Lexer(SM.getLocForStartOfFile(locInfo.first), opts, file.begin(), tokenBegin, file.end());
Token tok;
lexer->Lex(tok); // 取得第一個tok,反復調用可以獲取一段token流
Diagnostic
Clang中用Diagnostic來進行編譯錯誤的提示。每一個編譯錯誤(警告、建議等)都會有一段文字描述,這些文字描述為了支持多國語言,使用了一種ID的表示方法??傊?,對于一個特定的編譯錯誤提示而言,其diagnostic ID是固定的。
在我們的規范中,有些規范檢測的代碼在Clang中會直接編譯出錯,例如函數調用傳遞的參數個數不等于函數定義時的形參個數。當Clang編譯出錯時,其語法樹實際上是不完善的。解決此問題的最簡單辦法,就是通過diagnostic實現。也就是說,我是通過將我們的特定規范映射到特定的diagnostic,當發生這個特定的編譯錯誤時,就可以認定該規范實際上被檢測到。對于簡單的情況而言,這樣的手段還算奏效。
// `TextDiagnosticPrinter`可以將錯誤信息打印在控制臺上,為了調試方便我從它派生而來
class MyDiagnosticConsumer : public TextDiagnosticPrinter {
public:
// 當一個錯誤發生時,會調用此函數,我會在這個函數里通過Info.getID()取得Diagnostic ID,然后對應地取出規范ID
virtual void HandleDiagnostic(DiagnosticsEngine::Level DiagLevel,
const Diagnostic &Info) {
TextDiagnosticPrinter::HandleDiagnostic(DiagLevel, Info);
// 例如檢查三字母詞(trigraph)的使用
if (Info.getID() == 816)
/* 報告使用了三字母詞 */
}
};
// 初始化時需傳入自己定義的diagnostic
inst.createDiagnostics(0, NULL, new MyDiagnosticConsumer(&inst.getDiagnosticOpts()));
該例子代碼演示了對三字母詞(wiki trigraph)使用限制的規范檢測。
全文完。