目标读者
一线工程师,架构师
预计阅读时间
15-20min
完成阅读的收获
- 了解静态代码审核技术的原理
- 了解静态代码审核技术工作流
不得不提的 Clang
由于 OCLint 是一个基于 Clang tool 的静态代码分析工具,所以不得不提一下 Clang。
Clang 作为 LLVM 的子项目, 是一个用来编译 c,c++,以及 oc 的编译器。
OCLint 本身是基于 Clang tool 的,换句话说相当于做了一层封装。
它的核心能力是对 Clang AST 进行分析,最后输出违反规则的代码信息,并且导出指定格式的报告。
接下来就让我们看看作为输入信息的 Clang AST 是什么样子的。
Clang AST
Clang AST 是在编译器编译时的一个中间产物,从词法分析,语法分析(生成 AST),到语义分析,生成中间代码。
抽象语法树示例
这里先对抽象语法树有一个初步的印象。
//Example.c
#include
int global;
void myPrint(int param) {
if (param == 1)
printf("param is 1");
for (int i = 0 ; i < 10 ; i++ ) {
global += i;
}
}
int main(int argc, char *argv[]) {
int param = 1;
myPrint(param);
return 0;
}
这里可以清晰的看到,这一段代码的每一个元素与其子节点的关系。其中的节点有两大类型,一个是 Stmt 类,包括 Expr 表达式类也是继承于 Stmt,它是语句,有一定操作;另一大类元素是 Decl 类,即定义。所有的类,方法,函数变量均是一个 Decl 类 (这两个类互不兼容,需要特殊容器节点来转换,比如 DeclStmt 节点) 。另外从数据结构中可以看到,这个树是单向的,只有从某一个顶层元素向下访问。
在终端中可以用如下指令查看语法树:
clang -Xclang -ast-dump -fsyntax-only Example.c
访问抽象语法树
无论是 Stmt 还是 Decl 都自带迭代器,可以方便的遍历所有节点元素,再判断其类型进行操作。不过在 Clang 中还有更方便的方法:继承 RecursiveASTVisitor 类。
它是一个 AST 树递归器,可以递归的访问一个 AST 树的所有节点。最常用的方法是 TraverseStmt 和 TraverseDecl。
例如我要访问这么一段代码中所有的函数,即 FunctionDecl,并且输出这些函数的名字,我就要重写 (通过自定义 checker) 这么一个方法:
bool VisitFunctionDecl(FunctionDecl *decl){
string name = decl->getNameAsString();
printf(name);
return true;
}
这样,我们就能够访问到这棵 AST 树中所有的 FunctionDecl 节点,并且把其中函数名字给输出出来了。
接下来我们看看 OCLint 的源码,看看 OCLint 到底是如何工作的!
OCLint 源码解析
首先看一下核心类关系图,有一点初步的印象后,我们开始看代码
1 首先找到入口文件 oclint/driver/main.cpp,及入口函数 main()
该文件的精简后的代码框架如下所示:
int main(int argc, const char **argv)
{
llvm::cl::SetVersionPrinter(oclintVersionPrinter);
// 构造 parser 分析程序
CommonOptionsParser optionsParser(argc, argv, OCLintOptionCategory);
// 配置
oclint::option::process(argv[0]);
...
// 构造 analyzer
oclint::RulesetBasedAnalyzer analyzer(oclint::option::rulesetFilter().filteredRules());
// 构造 driver
oclint::Driver driver;
// 执行分析
driver.run(optionsParser.getCompilations(), optionsParser.getSourcePathList(), analyzer);
std::unique_ptr results(std::move(getResults()));
ostream *out = outStream();
// 输出报告
reporter()->report(results.get(), *out);
disposeOutStream(out);
return handleExit(results.get());
}
2 接着查看核心的 Driver 类的关键代码片段,有三个比较核心的方法 constructCompilers(),invoke(),run()
// 构建编译器
static void constructCompilers(std::vector &compilers,
CompileCommandPairs &compileCommands,
std::string &mainExecutable)
{
for (auto &compileCommand : compileCommands) // 遍历编译命令集
{
std::vector adjustedCmdLine =
adjustArguments(compileCommand.second.CommandLine, compileCommand.first);
#ifndef NDEBUG
printCompileCommandDebugInfo(compileCommand, adjustedCmdLine);
#endif
LOG_VERBOSE("Compiling ");
LOG_VERBOSE(compileCommand.first.c_str());
std::string targetDir = stringReplace(compileCommand.second.Directory, "\\ ", " ");
if(chdir(targetDir.c_str()))
{
throw oclint::GenericException("Cannot change dictionary into \"" +
targetDir + "\", "
"please make sure the directory exists and you have permission to access!");
}
clang::CompilerInvocation *compilerInvocation =
newCompilerInvocation(mainExecutable, adjustedCmdLine);// 创建 CompilerInvocation 对象
oclint::CompilerInstance *compiler = newCompilerInstance(compilerInvocation);
// 使用 clang 的 CompilerInvocation 对象 创建 oclint 的 CompilerInstance 对象,oclint 做了封装
compiler->start(); // clang::FrontendAction 核心是获取到 action 并执行
if (!compiler->getDiagnostics().hasErrorOccurred() && compiler->hasASTContext())
{
LOG_VERBOSE(" - Success");
compilers.push_back(compiler); // oclint 封装的 CompilerInstance 对象放入集合中
}
else
{
LOG_VERBOSE(" - Failed");
}
LOG_VERBOSE_LINE("");
}
}
// 实际的进行分析的唤起方法
static void invoke(CompileCommandPairs &compileCommands,
std::string &mainExecutable, oclint::Analyzer &analyzer)
{
std::vector compilers; // 编译器容器
constructCompilers(compilers, compileCommands, mainExecutable); // 构建编译器
// collect a collection of AST contexts
std::vector localContexts;
for (auto compiler : compilers) // 遍历编译器集合
{
localContexts.push_back(&compiler->getASTContext()); // 将 AST 上下文放入 上下文集合
}
// use the analyzer to do the actual analysis
analyzer.preprocess(localContexts); // 将上下文集合送入分析器 预处理
analyzer.analyze(localContexts); // 分析
analyzer.postprocess(localContexts); // 发送处理
// send out the signals to release or simply leak resources
for (size_t compilerIndex = 0; compilerIndex != compilers.size(); ++compilerIndex)
{
compilers.at(compilerIndex)->end();
delete compilers.at(compilerIndex);
}
}
// main.cpp 调用的核心方法,执行分析
void Driver::run(const clang::tooling::CompilationDatabase &compilationDatabase,
llvm::ArrayRef sourcePaths, oclint::Analyzer &analyzer)
{
CompileCommandPairs compileCommands; // 生成编译指令对容器
constructCompileCommands(compileCommands, compilationDatabase, sourcePaths); // 构造编译指令对
static int staticSymbol; // 静态符号
std::string mainExecutable = llvm::sys::fs::getMainExecutable("oclint", &staticSymbol);// 获取 oclint 可执行程序的路径
if (option::enableGlobalAnalysis()) // 启用全局分析的情况
{
invoke(compileCommands, mainExecutable, analyzer);// 调用 invoke 方法,注意 analyzer 也一并入参
}
else
{ // 非全局分析的情况 逐个 compileCommand 进行分析
for (auto &compileCommand : compileCommands)
{
CompileCommandPairs oneCompileCommand { compileCommand };
invoke(oneCompileCommand, mainExecutable, analyzer);
}
}
if (option::enableClangChecker()) // 启用 clang checker
{
invokeClangStaticAnalyzer(compileCommands, mainExecutable); // 调用 clang 的静态分析器
}
}
3 最后一个就是 RulesetBasedAnalyzer 类,这个类的代码量非常少,如下所示
void RulesetBasedAnalyzer::analyze(std::vector &contexts)
{
for (const auto& context : contexts)
{
LOG_VERBOSE("Analyzing ");
auto violationSet = new ViolationSet();
auto carrier = new RuleCarrier(context, violationSet); // 规则运载者,context 是传递给规则来分析的数据,violationSet 是用于存放处理好的结果集
LOG_VERBOSE(carrier->getMainFilePath().c_str());
for (RuleBase *rule : _filteredRules) // 遍历已经过滤的规则集合
{
rule->takeoff(carrier); // 调用规则的 takeoff
}
ResultCollector *results = ResultCollector::getInstance(); // 取得结果收集器实例
results->add(violationSet); // 将规则处理好的数据加入收集器
LOG_VERBOSE_LINE(" - Done");
}
}
从上面的代码可以看出 analyzer 会遍历规则集合,来调用 rule 的 takeoff 方法。rule 的基类是 RuleBase,这个基类含有一个 RuleCarrier 的示例作为成员,RuleCarrier包含了每个文件对应的 ASTContext 和 violationSet,violationSet 用来存放违例的相关信息。
rule 的职责就是,检查其成员变量 ruleCarrier 的 ASTContext,有违例的情况,就将结果写入 ruleCarrier 的 violationSet 中。
高级:自定义规则
到目前为止,我们已经了解到 oclint 的基本用法,以及工作流程。
接下来更灵活也是有更高的使用难度的部分--自定义规则。
规则必须实现 RuleBase 类或其派生的抽象类。不同的规则专注于不同的抽象级别,例如,某些规则可能必须非常深入地研究代码的控制流,相反,某些规则仅通过读取源代码的字符串来检测缺陷。
oclint 提供了三个抽象类,以便我们来编写自定义规则。
AbstractSourceCodeReaderRule(源代码读取器规则),AbstractASTVisitorRule(AST 访问者规则),以及 AbstractASTMatcherRule(AST 匹配器规则)。
按照官方文档的说法,由于 AST 匹配器规则 具有良好的可读性,除非性能是个大问题,我们可能大多数时候都会选择编写AST匹配器规则。
AST 访问者规则是基于访问者模式,你只需要重载某些方法(该抽象类提供了一系列节点被访问的接口),即可处理相应节点内的校验逻辑。(由于 OCLint 使用的是 Clang 生成的抽象语法树,因此了解 Clang AST 的 API 在编写规则时非常有帮助相关链接)。
AST 匹配器规则是基于匹配模式,你需要构造一些匹配器并加载。只要找到匹配项,callback 就以该 AST 节点作为参数调用 method,你就可以在 callback 中收集违例信息。(关于匹配器的更多信息看这里)
这里简单就说这么多,我们只需要知道 oclint 提供了抽象类,用于实现自定义规则。关于如何编写一个规则的部分会在下一节展开。
创建规则——scaffoldRule 脚本
这是由 oclint 提供的一个脚手架。相关介绍如下使用脚手架创建规则
可以使用该脚本可以方便的创建自定义规则。
编写规则
通过阅读 oclint 的官方文档,以及阅读 Clang AST 的介绍。现在我们已经知道了,oclint 的大致工作方式。首先通过调用 Clang 的 api 把源文件一个个的生成对应的 AST;其次遍历 AST 中的每个节点,并根据相应的规则将违例情况写入违例结果集;最后根据配置的报告类型,将违例结果输出成指定的报告格式。
先上一个 oclint 规则编写思路的脑图,有个初步的印象即可。
按照上文,我们现在已经得到了一个 xcodeproj 工程。现在可以打开我们创建的规则的 cpp 源文件。
首先我们可以看到,使用脚手架生成的规则,模板代码有近 2000 行,是不是有点慌? 不用担心。这些模板里,大多都是 Visit 开头的方法,这是 oclint 提供给我们的回调方法, 也就是说在访问到 AST 上相应的节点时就会触发的方法。
下面我们来看一个实际的案例,已经用在 iOS 组的代码检查中的一个规则。
这个规则所做的工作大致如下,按照 cocoa 的规范要求来检查 if else 条件分支的格式。
具体的格式要求是这样的,if else 和后面跟着的括号以及花括号要分割开,可以使用空格和换行符。
示例代码如下:
void example()
{
int a = 1;
if(a > 0) { // (左侧无空格或换行不合规
a = 10;
}
if (a > 0){ // )右侧无空格或换行不合规
a = 10;
}
if (a > 0)
{
a = 10;
}else { // }右侧无空格或换行不合规
a = -1;
}
if (a > 0)
{
a = 10;
} else{ // {左侧无空格或换行不合规
a = -1;
}
}
1 首先在终端中使用 dump 查看 AST(上文已经介绍了如何查看 AST,如果没看过建议先看看)。
屏幕上一连串花花绿绿的字符闪过,最后停在了这里!
没错,这正是我们需要找的。
可以很清楚的看到,最上方的变量声明 VarDecl,以及下方的条件语句 IfStmt。
2 需要检验的节点名称已经确定,就是 IfStmt。
3 接下来,在已经生成的规则模板中找对应的回调方法。
我推测,应该叫做 VisitXXIfStmt 之类的。
果然不出所料,我们找到了!VisitIfStmt 这个方法,看起来正是我们所需要的。
4 紧接着,我们需要获取节点名称和节点描述。(详细的代码可以参看下方提供的完整规则文件)
5 最后是判断这里的方法名是否符合规则。(可以使用 llvm,Clang,以及 std 提供的各种函数,如果有你需要的)
6 如果检测出来的方法名是不符合规范的,将节点及描述信息加入 violationSet。
到这里,整体的编写流程已经完成了。相信你看完下方的实例代码,以及再多读几个官方提供的规则代码之后,很快就可以举一反三的写出自己的规则了。
这里直接给出上文规则的完整实现:
#include "oclint/AbstractASTVisitorRule.h"
#include "oclint/RuleSet.h"
using namespace std;
using namespace clang;
using namespace oclint;
class KirinzerTestRule : public AbstractASTVisitorRule
{
public:
virtual const string name() const override
{
return "if else format";
}
virtual int priority() const override
{
return 2;
}
virtual const string category() const override
{
return "controversial";
}
#ifdef DOCGEN
virtual const std::string since() const override
{
return "20.11";
}
virtual const std::string description() const override
{
return "用于检查 if else 条件分支中的括号是否符合编码规范";
}
virtual const std::string example() const override
{
return R"rst(
.. code-block:: cpp
void example()
{
int a = 1;
if(a > 0) { // (左侧无空格或换行不合规
a = 10;
}
if (a > 0){ // )右侧无空格或换行不合规
a = 10;
}
if (a > 0)
{
a = 10;
}else { // }右侧无空格或换行不合规
a = -1;
}
if (a > 0)
{
a = 10;
} else{ // {左侧无空格或换行不合规
a = -1;
}
}
)rst";
}
#endif
bool VisitIfStmt(IfStmt *node)
{
clang::SourceManager *sourceManager = &_carrier->getSourceManager();
SourceLocation begin = node->getIfLoc();
SourceLocation elseLoc = node->getElseLoc();
SourceLocation end = node->getEndLoc();
int length = sourceManager->getFileOffset(end) - sourceManager->getFileOffset(begin) + 1; // 计算该节点源码的长度
string sourceCode = StringRef(sourceManager->getCharacterData(begin), length).str(); // 从起始位置按指定长度读取字符数据
// printf("%s\n", sourceCode.c_str());
// 检查 if 左括号
std::size_t found = sourceCode.find("if (");
if (found==std::string::npos) {
// printf("if ( 格式不正确\n");
AppendToViolationSet(node, Description());
}
// 检查 if 右括号
found = sourceCode.find(") {");
if (found==std::string::npos) {
found = sourceCode.find(")\n");
if (found ==std::string::npos) {
// printf("if 右括号 格式不正确\n");
AppendToViolationSet(node, Description());
}
}
// 没有 else 分支就不再进行检查
if (!elseLoc.isValid()) {
return true;
}
// 检查 else 左括号
found = sourceCode.find("} else");
if (found==std::string::npos) {
found = sourceCode.find("}\n");
if (found==std::string::npos) {
// printf("} else 格式不正确\n");
AppendToViolationSet(node, Description());
}
}
// 检查 else 右括号
found = sourceCode.find("else {");
if (found==std::string::npos) {
found = sourceCode.find("else\n");
if (found==std::string::npos) {
// printf("else { 格式不正确\n");
AppendToViolationSet(node, Description());
}
}
return true;
}
// 将违例信息追加进结果集
bool AppendToViolationSet(IfStmt *node, string description) {
addViolation(node, this, description);
}
string Description() {
return "格式不正确";
}
};
static RuleSet rules(new KirinzerTestRule());
调试规则
根据前面的所学到的内容,我们知道了规则的实际体现形式为 dylib 文件。那么如果编写 cpp 的时候没办法调试,那真的是噩梦一般的体验。将我们现在遇到的问题,如何调试 oclint 规则?
1 首先需要一个 Xcode 工程。
oclint 工程使用 CMakeLists 来维护依赖关系。我们也可利用 CMake 来将 CMakeLists 生成 xcodeproj。你可以对每个文件夹生成一个 Xcode 工程,在这里我们对 oclint-rules 生成对应的 Xcode 工程。
// 在OCLint源码目录下建立一个文件夹,我这里命名为oclint-xcoderules
mkdir oclint-xcoderules
cd oclint-xcoderules
// 执行如下命令
cmake -G Xcode -D CMAKE_CXX_COMPILER=../build/llvm-install/bin/clang++ -D CMAKE_C_COMPILER=../build/llvm-install/bin/clang -D OCLINT_BUILD_DIR=../build/oclint-core -D OCLINT_SOURCE_DIR=../oclint-core -D OCLINT_METRICS_SOURCE_DIR=../oclint-metrics -D OCLINT_METRICS_BUILD_DIR=../build/oclint-metrics -D LLVM_ROOT=../build/llvm-install/ ../oclint-rules
2 Xcode 工程创建好之后,我们需要对指定的 Scheme 添加启动参数。并且在 Scheme 的 Info 一栏选择 Executable ,选择上文中编译完成的 oclint 可执行文件。
Tip: 编译生成的oclint可执行文件在根目录下 build/oclint-release/bin 目录下,以最新版的 oclint 20.11 为例,生成的文件名为 oclint-20.11,会被 Finder 识别为 Document 类型。(.11被识别为了后缀),虽然并不影响在终端的直接调用,但是我们后续的调试中会需要在 Xcode 中通过 Finder 来选取这个可执行文件,但是由于类型被识别错误,会导致无法点击选中。所以在这里我们就删除小数点,修改可执行文件名为 oclint-2011 并且没有任何后缀即可。(注意修改的时候,右键getInfo,在文件名和扩展名那一栏来修改,还有注意是否隐藏了拓展名)。
启动参数如下:
(第一个参数是规则加载路径,第二个是测试规则用文件)
>-R=/Users/developer/TempData/oclint/oclint-xcoderules/rules.dl/Debug /Users/developer/TempData/oclint/oclint-xcoderules/test2.m -- -x objective-c -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
准备完成后即可运行规则,在控制台中可以输出你的规则运行的结果以及调试信息。
使用规则
使用 Xcode 编写的规则完成编译后,可以在 Xcode 的 Products group 中找到相应的 dylib 文件。
默认情况下,规则将从$(/path/to/bin/oclint)/../lib/oclint/rules目录中加载,我们将其命名为“ 规则搜索路径”或“ 规则加载路径”。规则搜索路径由一组动态库组成,这些库在Linux,macOS和 Windows中具有扩展名 so, dylib 以及 dll。
通过将新规则拖放到规则加载路径中,可以立即使用它们。 因此,只需要将我们自定义规则生成的 dylib 放入默认的规则加载目录即可。当然这里的规则目录也是可以配置的。一个项目可以使用多个规则搜索路径,可以为不同的项目指定不同的规则加载路径。
更多详细的配置参考这里的官方文档:
总结
使用静态代码检查工具,可以高效的检查出代码中的潜在问题,在做持续的业务交付过程中,提高开发同学们对于编码规范的重视,防止代码的劣化,减少一些由于粗心导致的错误。希望本文提及的静态检查工具,以及自定义规则的编写的说明,能帮助大家写出更高质量,更优雅,更美观的代码。
参考资料
简述 LLVM 与 Clang 及其关系
Clang Tutorial
Clang Users Manual
oclint-docs v20.11