打造基于Clang LibTooling的iOS自动打点系统CLAS(三)

1. 源码变换

第一章我们提到过,CLAS的本质是对源码做一次非常简单的变换(有些文章里称作变形),即Source-Source-Transformation,将打点代码精确地插入到目标函数的首部,保存到临时文件,代替原始文件传递到Clang进行编译。这个变换过程对于Clang的编译流程没有侵入,保证了与不同版本Clang一定的兼容性,即使Clang进行小版本升级CLAS仍然可以正常工作无需重新编译(例如Xcode从8.2.1升级为8.3.3)。围绕着源码变换可以做出许多非常有创意的工具,大家有兴趣可以深入研究这个话题,我们在这里就不展开了。

Clang提供给我们了一个非常好用的类clang::Rewriter用于源码变换。如果你熟悉Clang可能会知道有一个大名鼎鼎的编译选项-rewrite-objc,这个选项可以帮助你将OC代码重写成C++代码,很多对于OC内部运行机制的窥视和分析都是基于这个选项得来的,而它就是基于我们第二章所讲的ASTConsumer以及本章所讲的Rewriter构建出来的。

细看Rewriter的接口会发现,它满足了CLAS对源码内容增删改查的全部需求。例如你可以通过Rewriter向源码内指定位置插入删除任意长度的代码,然后将修改后的内容保存到一个临时文件中。Rewriter的接口在Clang的模块里可以算得上是超级简单易用的了,方法的含义根据方法名就一目了然,而且不需要复杂的上下文参数传递。编译器这种动辄几十人持续很多年维护同一个工程的代码,想要很容易地看懂里面任何一个功能都不是那么简单的事情,Rewriter算得上是Clang里面的异类。

2. 插入代码

既然大致了解了Rewriter,接下来我们就要开始真正的插入代码了。假设我们需要在每个方法的开始加入这么一句话,让每次方法执行时打印出被调用的方法名:

{ NSLog(@"进入方法:%__FUNCNAME__%"); }

第一个问题马上就出现了,插入的代码是预先定义好的,如何能够根据不同的方法名插入不同的代码呢?这个问题很好解决,我们需要定义一些CLAS变量,以%包围,例如上面的%__FUNCNAME__%。在遍历到每一个方法准备插入代码的之前,将%__FUNCNAME__%替换为当前的方法名即可。至于定义哪些变量取决于工具的需要。正式的CLAS系统我们只需要有限的几个变量即可(例如__FUNCNAME__, __CLASSNAME__,__CATEGORYNAME__等),因为需要插入的代码按照第二章的要求都应该是尽可能自包含的静态代码,不需要在插入代码的时候进行过多的人为干预。

在这里我们还要单独说明一下,插入的代码不要包含换行符和制表符等,因为这些符号,尤其是换行符会破坏源码的位置信息(SourceLocation),导致debug的时候指向错误的行数。无论再长的代码,都不要换行,当然避免插入过长的代码才是最好的。

我们把需要插入的代码保存到一个单独的文本文件里,然后让CLAS在启动的时候读取这个文件的内容到内存中,并在遍历到每一个OC方法的时候插入这段代码。至于如何将代码内容从文件中读入内存的细节不在本文讨论范围内,熟悉C++的你可以直接阅读CLAS源代码。我们打开ClangAutoStats.cpp,首先需要引入Rewriter的头文件:

#include "clang/Rewrite/Core/Rewriter.h"

然后我们需要定义一个Rewriter的静态变量:

static clang::Rewriter TheRewriter;

我们假设需要插入的代码片段已经从文件中读入内存,并存入静态变量:

static std::string CodeSnippet;

接下来我们在ClangAutoStatsVisitor的handleObjcMethDecl方法里加入如下代码:

CompoundStmt *cmpdStmt = MD->getCompoundBody();
SourceLocation loc = cmpdStmt->getLocStart(). getLocWithOffset(1);
if (loc.isMacroID()) {
    loc = TheRewriter.getSourceMgr().getImmediateExpansion Range(loc).first;
}

ObjCMethodDecl有一个方法getCompoundBody,会返回当前方法的复合语句节点(Compound Statement)。在AST里,每一条语句(Statement)都是一个Stmt节点,而复合语句从Stmt继承而来,是包含有0至n个Stmt的容器型Stmt,复合语句也可以嵌套包含复合语句。If、For、Switch、While、do、以及OC方法都可以包含一个复合语句。我们插入代码的位置在方法的复合语句大括号后面,例如:

- (void)func {/*在这里插入代码,不会破坏debug信息*/
}

CompoundStmt的getLocStat方法可以返回复合语句的起始位置,这相当于是左大括号的位置,我们在这个位置的基础上再向后偏移1个字节指向大括号后面的位置。在上面的例子,这个位置会是回车‘\n’的位置(行级注释都不会出现在AST里面)。找到这个位置后我们还需要做一个额外的检查,看看这个复合语句是不是从宏定义展开而来的。如果是根据宏定义展开的复合语句,直接调用getLocStart方法会获得定义这个复合语句的宏定义的声明位置,那么我们计算的插入代码的位置就错了。正确的做法是调用SourceMgr的getImmediateExpansionRange方法获取这个复合语句的实际在源码内展开的位置。计算完毕后,我们要调用Rewriter的InsertTextBefore方法进行代码插入。在插入CodeSnippet之前,我们还需要把%__FUNCNAME__%替换为当前方法名(C++操作起字符串来真的是比OC费劲太多了...):

static std::string varName("%__FUNCNAME__%");
std::string funcName = MD->getDeclName().getAsString();
std::string codes(CodeSnippet);
size_t pos = 0;
while ((pos = codes.find(varName, pos)) != std::string::npos) {
    codes.replace(pos, varName.length(), funcName);
    pos += funcName.length();
}
TheRewriter.InsertTextBefore(loc, codes);

我们目前修改了Rewriter的内容,但并没有对源文件有任何影响,按照CLAS的设计要求,我们还需要将修改过后的文件内容保存至临时文件。这个我们选择在ClangAutoStatsAction里重写EndSourceFileAction方法,在这里面我们将Rewriter的内容保存至与原文件同名的.clas后缀的临时文件:

void EndSourceFileAction() override {
  size_t pos = filePath.find_last_of(".");
  if (pos != std::string::npos) {
      ClasFilePath = filePath + ".clas";
  }
  std::ofstream clasFile(ClasFilePath);
  assert(clasFile.is_open());
  FileID fid = getCompilerInstance().getSourceManager(). getMainFileID();
  RewriteBuffer &buffer = LogRewriter.getEditBuffer(fid);
  RewriteBuffer::iterator I = buffer.begin();
  RewriteBuffer::iterator E = buffer.end();
  for (; I != E; I.MoveToNextPiece()) {
      (clasFile << I.piece().str());
  }
  clasFile.flush();
  clasFile.close();
}

3. Clang参数的裁剪和重排

上面的一节,我们基本完成了CLAS的框架结构,能够在OC方法最前面自动插入自定义代码,当然这种插入目前还是无差别的全量插入,肯定还需要根据需求进行针对性的打磨,这种精细化的定制需求就不在本文讨论范围内了,你可以根据这个框架继续改进代码。

接下来我们需要考虑的是如何应对Xcode传入的Clang指令及参数,以符合CLAS的需要。在前一章我们讨论过LibTooling的Fixed Compilation Database,它与Clang的参数形式并不直接兼容。CLAS被定义为一个类似Clang Wrapper的工具,为了避免过多的对编译工具链进行入侵,我们需要将Xcode传入的Clang指令进行精心地裁剪和重新排序,以便让CLAS可以正常工作。

举个很简单的例子,比如我们有一个HelloWorld.m的文件需要处理:

#import <Foundation/Foundation.h>
@interface HelloWorld : NSObject
@end
@implementation HelloWorld
- (void)sayHi:(NSString *)msg {
    NSLog(@"Hello %@", msg);
}
@end

如果在Xcode里编译这个文件,查看Build Log会看到Xcode发出了如下指令及参数给Clang(略去了-W以及-I, -F,否则太长了):

/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -x objective-c -arch x86_64 -std=gnu99 -fobjc-arc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator10.3.sdk -mios-simulator-version-min=8.0 -c /Users/test/HelloWorld/HelloWorld.m -o /Users/test/HelloWorld/HelloWorld.o

如果调用CLAS,则参数列表需要转换为如下格式:

/usr/local/clas/bin/clas /Users/test/HelloWorld/HelloWorld.m -- -x objective-c -arch x86_64 -std=gnu99 -fobjc-arc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator10.3.sdk -mios-simulator-version-min=8.0 -F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator10.3.sdk/System/Library/Frameworks -I/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1 -I/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/usr/include -o /Users/test/HelloWorld/HelloWorld.o

我们可以看到,HelloWorld.m被移到了第二位,后面紧跟了"--"参数,表明后面跟随的都是Clang所需的参数。这些参数多了一个-F和两个-I,分别指向了ios的系统Frameworks目录,以及include目录。之所以我们需要添加这三个参数,是因为苹果的Clang会默认加入对这些目录,而我们从源码编译的LibTooling的工具却不会,如果不添加这些参数会导致LibTooling分析文件的时候因为找不到各种系统头文件而失败。这就是参数裁剪重排的意义。CLAS执行完成后,还有一个非常重要的任务,就是将原文件.m重命名后,将CLAS输出的临时文件重命名为原文件,拼接剩余参数并调用苹果原生的Clang(/usr/bin/clang),clang执行完成后,无论成功与否,将临时文件删除并将原文件.m复原,编译流程至此结束。

如果你熟悉C/C++,这些代码可以在CLAS里完成而保证最高的执行效率,如果不熟悉上面提到的操作完全可以通过脚本来完成,脚本拦截Xcode发出的编译指令,处理参数后传递给CLAS,CLAS处理完成后,在脚本里继续执行苹果的Clang。这里我们就不对这些做详细描述了,如果有兴趣可以直接研究CLAS源码。

4. 最后

到了这里,我们已经构建了一个简单的基于Clang LibTooling的编译前端工具,可以解析AST,并在指定位置插入自定义代码。本文并没有覆盖正式项目所具有的实用性功能,例如针对性的代码插入、灵活的功能配置(例如通过配置文件)等。我们会在接下来的文章里介绍针对性的代码插入以及如果将CLAS集成到Xcode编译链中,敬请期待...

posted @ 2017-09-05 18:45  dechaos  阅读(680)  评论(0编辑  收藏  举报