一文说清 OCLint 源码解析及工作流分析

原创
02/03 08:25
阅读数 2.3K

目标读者

一线工程师,架构师

预计阅读时间

15-20min 🐢

完成阅读的收获

  1. 了解静态代码审核技术的原理

  2. 了解静态代码审核技术工作流

不得不提的 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 <stdio.h>
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<oclint::Results> results(std::move(getResults()));

ostream *out = outStream();
// 输出报告
reporter()->report(results.get(), *out);
disposeOutStream(out);

return handleExit(results.get());
}
  1. 接着查看核心的 Driver 类的关键代码片段,有三个比较核心的方法 constructCompilers(),invoke(),run()

// 构建编译器
static void constructCompilers(std::vector<oclint::CompilerInstance *> &compilers,
CompileCommandPairs &compileCommands,
std::string &mainExecutable)

{
for (auto &compileCommand : compileCommands) // 遍历编译命令集
{
std::vector<std::string> 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<oclint::CompilerInstance *> compilers; // 编译器容器
constructCompilers(compilers, compileCommands, mainExecutable); // 构建编译器

// collect a collection of AST contexts
std::vector<clang::ASTContext *> 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<std::string> 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 的静态分析器
}
}
  1. 最后一个就是 RulesetBasedAnalyzer 类,这个类的代码量非常少,如下所示

void RulesetBasedAnalyzer::analyze(std::vector<clang::ASTContext *> &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 在编写规则时非常有帮助: 
http://docs.oclint.org/en/stable/devel/clang.html

AST 匹配器规则是基于匹配模式,你需要构造一些匹配器并加载。只要找到匹配项,callback 就以该 AST 节点作为参数调用 method,你就可以在 callback 中收集违例信息。(关于匹配器的更多信息看https://clang.llvm.org/docs/LibASTMatchersReference.html )

这里简单就说这么多,我们只需要知道 oclint 提供了抽象类,用于实现自定义规则。关于如何编写一个规则的部分会在下一节展开。

创建规则——scaffoldRule 脚本

这是由 oclint 提供的一个脚手架。相关介绍如下使用脚手架创建规则 可以使用该脚本可以方便的创建自定义规则。(参考链接: http://docs.oclint.org/en/stable/devel/scaffolding.html#creating-rules-with-scaffolding)

编写规则

通过阅读 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。

  1. 需要检验的节点名称已经确定,就是 IfStmt。

  2. 接下来,在已经生成的规则模板中找对应的回调方法。
    我推测,应该叫做 VisitXXIfStmt 之类的。
    果然不出所料,我们找到了!VisitIfStmt 这个方法,看起来正是我们所需要的。

  3. 紧接着,我们需要获取节点名称和节点描述。(详细的代码可以参看下方提供的完整规则文件)

  4. 最后是判断这里的方法名是否符合规则。(可以使用 llvm,Clang,以及 std 提供的各种函数,如果有你需要的)

  5. 如果检测出来的方法名是不符合规范的,将节点及描述信息加入 violationSet。

到这里,整体的编写流程已经完成了。相信你看完下方的实例代码,以及再多读几个官方提供的规则代码之后,很快就可以举一反三的写出自己的规则了。

这里直接给出上文规则的完整实现:

#include "oclint/AbstractASTVisitorRule.h"
#include "oclint/RuleSet.h"

using namespace std;
using namespace clang;
using namespace oclint;

class KirinzerTestRule : public AbstractASTVisitorRule<KirinzerTestRule>
{
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
  1. 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 放入默认的规则加载目录即可。当然这里的规则目录也是可以配置的。一个项目可以使用多个规则搜索路径,可以为不同的项目指定不同的规则加载路径。

更多详细的配置参考这里的官方文档: 选择OCLint检查规则
http://docs.oclint.org/en/stable/howto/selectrules.html

总结

使用静态代码检查工具,可以高效的检查出代码中的潜在问题,在做持续的业务交付过程中,提高开发同学们对于编码规范的重视,防止代码的劣化,减少一些由于粗心导致的错误。希望本文提及的静态检查工具,以及自定义规则的编写的说明,能帮助大家写出更高质量,更优雅,更美观的代码。

参考资料

- 简述 LLVM 与 Clang 及其关系
https://xuhehuan.com/2738.html
Clang Tutorial
http://swtv.kaist.ac.kr/courses/cs453-fall13/Clang%20tutorial%20v4.pdf
Clang Users Manual
https://clang.llvm.org/docs/UsersManual.html
oclint-docs v20.11
https://readthedocs.org/projects/oclint-docs/downloads/pdf/v20.11/



本文分享自微信公众号 - 大头兄弟技术团队(bhbdev)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
打赏
0
1 收藏
分享
加载中
打赏
0 评论
1 收藏
0
分享
返回顶部
顶部