入坑LLVM/Clang LibTooling Out-of-Tree开发之前,我一直以为写一个Clang静态分析工具跟写普通C++程序差不多:装上头文件、写个CMake、直接跑起来。结果第一次照官方教程做,才发现事情没那么简单——官方文档里大半示例是让你把工具代码塞进llvm-project源码树里,跟着Clang一起编译。可我们平时要做的是代码架构巡检、自定义规范检查、批量重构这类活,谁也不想为一个小工具去维护一整套编译器源码。后来我把工程拎出来,改用Out-of-Tree方式重建,前后只花了一个下午就全部跑通。这篇文章就是那次实操的完整记录,内容包括环境选择、CMake工程骨架、AST遍历与匹配、编译数据库、常见坑的排查链路,以及最近编译器社区聊得很多的“算子自发现”思路,给同样准备实战LibTooling的人当一份参考路线图。
1. 认清楚LibTooling在LLVM/Clang生态里的位置
1.1 LLVM、Clang、LibTooling各管什么
很多刚接触这块的人会把LLVM和Clang当成同一个东西,实际上它们是两个层级。LLVM是一个模块化的编译器基础设施,它不负责把C++源码变成CPU指令,而是提供一套抽象度很高的编译后端工具箱:IR生成与优化、指令选型、寄存器分配、汇编相关的东西都在这层。Clang则是LLVM官方维护的C/C++/Objective-C前端,负责词法分析、语法分析、语义分析,最终产出AST和LLVM IR,再把IR交给LLVM后端处理。
LibTooling是Clang专门给工具开发者提供的C++ API层。它把“运行一次Clang前端”这件事封装成了几个稳定入口,比如ClangTool、CommonOptionsParser、ASTConsumer、MatchFinder等。通过这些API,你不需要了解Clang内部那些复杂的编译流程细节,就能拿到某个源文件的完整AST,然后遍历节点、匹配模式、输出分析结果。实际项目里我们常用的clangd、clang-tidy、include-what-you-use,本质上都是LibTooling的消费方。想搞懂LibTooling能干什么,这些工具就是最好的参照物。
1.2 两种“源码树”容易让人绕晕
官方教程里经常出现两种截然不同的开发方式,很多人没意识到它们适用场景完全不同。第一种是源码树内(in-tree)开发:把工具代码放到llvm-project仓库的tools目录下,和Clang一起构建。这种方式的优点是工具能直接碰Clang内部数据结构,改到哪算哪,方便给Clang本身打补丁或者定制语法特性。缺点是工程和llvm-project的Git仓库深度绑定,CI要克隆整个LLVM源码,全量构建一次即使有ninja也要十几分钟起步,哪怕你只改了自己工具里的几行代码,也可能因为依赖关系被迫重编大量Clang组件。更头疼的是版本升级,每年LLVM一个大版本,API动不动换签名,树内工具跟着升级的成本全摊在你头上。
第二种就是本文要讲的Out-of-Tree开发。工具代码完全放在llvm-project之外,LLVM/Clang只是这个独立工程的依赖项,通过CMake的find_package去定位已经构建好的Clang库和头文件。升级编译器时只需要换一套库和头文件,重新编译你的小工程,如果新版API有变化再针对性改代码。这种隔离对业务项目非常友好,也是我把工具从源码树里搬出来的根本原因。
1.3 和GCC、MSVC生态的现实对比
很多做过编译工具的人会拿GCC插件和MSVC来做对比。GCC确实提供插件机制,能注册callbcack去观察AST,但文档少、API不稳定,插件构建版本严格绑定某个gcc版本,gcc一升级插件基本就废。MSVC那边更尴尬,没有公开稳定的C++ AST接口,你想做静态分析要么走调试器那些非公开机制,要么绕道用clang-cl模式让Clang来做前端。这样一来,LLVM把Clang“库化”这件事就变得很有价值:LibTooling成了跨编译器做源码分析的理想切入点,这也是大厂内部自定义代码规范扫描、安全规则检查普遍选择Clang做底层框架的原因。
1.4 实际项目里最常看到的几类用法
- 静态规则检查:实现团队自定义的编码规范、资源管理约束、安全红线规则。clang-tidy的check机制其实就是一套标准化的LibTooling扩展点,你可以把自定义检查做成一个plugin。
- 批量代码重构:跨大量文件的重命名、头文件清理、旧API迁移,例如把某个
getXXX()改成get_xxx(),用LibTooling遍历AST后自动改写源码位置。 - 代码生成:从AST结构自动生成序列化框架、协议绑定代码、文档骨架。
- 指标统计:统计代码中各类表达式的分布、函数圈复杂度、运算符使用频率等,这类需求引出了后面要讲的“算子自发现”。
2. Out-of-Tree开发:为什么我强烈建议与源码树解耦
2.1 树内开发真正的隐性成本
源码树内开发看着“方便”,但工程性代价很容易被低估。LLVM整个仓库从克隆到构建需要占用几十GB磁盘空间,CI流水线会因此变得很重。你可能只是想给代码库加一个检查规则,却被迫每次都在完整编译器源码上进行迭代,编译时间被大量浪费。还有一个不那么明显的问题:树内工具和Clang内部版本强绑定,代码很容易顺手使用一些非公开的内部接口,等版本一升级,工具编译不过,还得去查上游源码变化才能修,非常被动。我的建议是,除非你确实要改Clang本身,否则不要走这条路上来。
2.2 树外开发的技术前提
Out-of-Tree开发的核心前提是你有一套能被链接的Clang库。这套库可以来自三种渠道:源码构建出来的build目录、LLVM官方发布的预编译包、系统包管理器安装的dev包。它们都必须提供对应的CMake配置文件(LLVMConfig.cmake、ClangConfig.cmake),这样外部工程才能通过find_package找到头文件和库。工程结构上也很简洁,就是一个独立仓库或目录:
my-clang-tool/ ├── CMakeLists.txt └── src/ └── mytool.cpp代码里正常#include "clang/Tooling/..."、#include "clang/ASTMatchers/...",通过CMake把编译选项指到Clang头文件目录,再链接Clang库。编译器升级后,只需切换到新的LLVM/Clang安装路径重新配置,你的工具代码本身保持独立演进。
2.3 什么时候仍然需要树内开发
我自己不否认树内开发的价值,只是它服务的目标用户是另一类人。如果你要给Clang增加新的语言特性扩展,或者修改Clang内置的格式化工具、重构工具的行为本身,那就必须在树内。想给clang-tidy贡献一个上游通用check,同样需要进入llvm-project生态里开发,按LLVM代码规范提交pr。但如果你只是要在自己的业务仓库里做一个分析或重构工具,树外方式能把依赖关系控制得最干净。一句话总结:需要改编译器内部,选树内;只依赖编译器的库能力,选树外。
3. 环境准备与最小CMake工程骨架
3.1 三种获取LLVM/Clang环境的路径对比
选环境之前先确定用途。下面这张表是我根据实际经验整理的选择参考:
| 渠道 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| 源码构建 | 需要最新版本、要Debug信息、可能要改Clang本身 | 版本完全可控,API可读源码探索 | 构建时间长,磁盘占用大 |
| 官方预编译包 | Linux/macOS上快速做工具开发 | 下载即用,不用编译 | 版本固定,CMake配置偶尔不完整 |
| 系统包管理(apt/brew/vcpkg) | 和系统工具链集成 | 装完即可,路径统一 | 版本往往偏旧,头文件可能不全 |
源码构建时我常加的CMake参数如下:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS=clang \ -DLLVM_TARGETS_TO_BUILD=X86 \ -DLLVM_ENABLE_RTTI=ON \ -DLLVM_ENABLE_EH=ON这里有两个参数必须解释清楚。LLVM_ENABLE_RTTI=ON和LLVM_ENABLE_EH=ON是LibTooling工具开发最容易踩的配置坑。默认情况下LLVM为了控制二进制体积会关闭RTTI和异常,但你的工具代码本身是普通C++程序,默认开着RTTI和异常。如果LLVM库关闭RTTI而工具打开,最终链接出的可执行文件在运行时很容易遇到typeid结果异常、dyn_cast行为诡异甚至崩溃的问题。所以源码构建时务必把这两个选项打开。
3.2 最小CMakeLists.txt写法
给一份能直接抄的CMake工程文件:
cmake_minimum_required(VERSION 3.20) project(MyClangTool LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(LLVM REQUIRED CONFIG) find_package(Clang REQUIRED CONFIG) message(STATUS "LLVM version: ${LLVM_PACKAGE_VERSION}") add_executable(mytool src/mytool.cpp ) target_include_directories(mytool PRIVATE ${LLVM_INCLUDE_DIRS} ${CLANG_INCLUDE_DIRS} ) target_compile_definitions(mytool PRIVATE ${LLVM_DEFINITIONS}) target_link_libraries(mytool PRIVATE clangTooling clangAST clangASTMatchers clangBasic clangLex clangFrontend clangDriver clangSema clangParse )逐行说明几个关键点。find_package(LLVM REQUIRED CONFIG)必须用CONFIG模式,因为LLVM库本身提供名为LLVMConfig.cmake的配置文件,而不是通常的FindLLVM.cmake模块。如果找不到,说明LLVM_DIR没设置对,源码构建时需要加-DLLVM_DIR=/path/to/llvm/build/lib/cmake/llvm。target_compile_definitions(mytool PRIVATE ${LLVM_DEFINITIONS})这行很关键,它会把LLVM导出的一系列编译宏(比如LLVM_VERSION_MAJOR、_GLIBCXX_ASSERTIONS)传给工程,保证头文件编译时版本判断正确。链接的clangTooling、clangASTMatchers这些target来自Clang的CMake配置文件,它们之间还有传递依赖,实际链接时CMake会自动带出大部分依赖库。
如果你在有的环境里find_package(Clang)失败,试试手动设置Clang_DIR,或者降级方案:include_directories()手写Clang头文件目录,再用target_link_libraries链接具体的libclangTooling.a静态库。不同版本、不同安装方式的target解析行为确实有点差异,所以我更推荐用CLang官方发布的预编译包或源码构建的库,CMake配置最完整。
3.3 编译数据库compile_commands.json
LibTooling工具分析某个文件时,知道自己需要的所有编译参数并不容易:include路径、宏定义、语言标准版本等如果弄错,AST就和实际代码对不上。因此Clang系列工具普遍依赖compile_commands.json,它记录了每个源文件编译时的完整命令行。CMake项目只要加一个参数就能生成:
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON另一个常见做法是用bear包住make命令,非CMake构建系统也能导出。工具运行时通过-p参数指向这个JSON文件所在目录:
./mytool -p build src/demo.cpp一旦没有编译数据库,工具会因为头文件路径不对、宏定义缺失而分析失败,常见的表现就是明明有匹配器却一个都匹配不到。后面排错章节我还会再提。
4. 从零实现一个语法检查工具
4.1 工具入口:CommonOptionsParser与ClangTool
标准LibTooling可执行程序的入口很固定。CommonOptionsParser负责解析命令行参数,区分哪些是工具自身选项、哪些是待分析源文件、编译数据库在哪里。从LLVM 11开始它不再用无参构造函数,而是通过create返回一个Expected对象,我们需要先判错再使用。第一版工具代码框架如下:
#include "clang/Tooling/CommonOptionsParser.h" #include "clang/Tooling/Tooling.h" #include "llvm/Support/CommandLine.h" using namespace clang; using namespace clang::tooling; using namespace llvm; static cl::OptionCategory MyToolCategory("my-tool options"); static cl::extrahelp CommonHelp(CommonOptionsParser::HelpMessage); int main(int argc, const char **argv) { auto ExpectedParser = CommonOptionsParser::create(argc, argv, MyToolCategory); if (!ExpectedParser) { llvm::errs() << ExpectedParser.takeError(); return 1; } CommonOptionsParser &OptionsParser = ExpectedParser.get(); ClangTool Tool(OptionsParser.getCompilations(), OptionsParser.getSourcePathList()); return Tool.run(newFrontendActionFactory(&MyFrontendAction).get()); }一个容易误解的地方是CommonOptionsParser会消费掉--之后的所有位置参数作为源文件列表,而工具的专用选项需要注册到指定的OptionCategory中,否则解析阶段就会报错。工具运行时还支持通过-p指定编译数据库目录,这点对批量扫描很多文件很关键。
4.2 先看清楚AST长什么样
上手写匹配器之前,我强烈建议先用Clang自带功能观察目标代码的AST结构。对单个文件执行:
clang -Xclang -ast-dump -fsyntax-only src/foo.cpp出来的结果会把源文件对应的完整AST节点树打印到终端。比如一个简单的int a = b + c;,你会在VarDecl下面看到BinaryOperator节点,其左右子节点是两个ImplicitCastExpr。这种层级关系是写ASTMatcher的直观依据。需要JSON结构化输出时还可以用-Xclang -ast-dump=json,配合jq做过滤。事先看一遍AST,比直接闷头查API文档效率高得多。
4.3 用RecursiveASTVisitor按类型遍历
有两种主流方式处理AST。第一种是命令式:写一个RecursiveASTVisitor子类,重载特定类型的Visit*方法。比如我想找出所有定义过的Foo类:
#include "clang/AST/RecursiveASTVisitor.h" #include "clang/AST/ASTConsumer.h" #include "clang/Frontend/FrontendActions.h" #include "clang/AST/ASTContext.h" #include "llvm/Support/raw_ostream.h" class FindFooVisitor : public clang::RecursiveASTVisitor<FindFooVisitor> { public: bool VisitCXXRecordDecl(clang::CXXRecordDecl *Decl) { if (Decl->getQualifiedNameAsString() == "Foo" && Decl->isThisDeclarationADefinition()) { llvm::outs() << "Found Foo definition at " << Decl->getLocation().printToString( Decl->getASTContext().getSourceManager()) << "\n"; } return true; } }; class FindFooConsumer : public clang::ASTConsumer { public: void HandleTranslationUnit(clang::ASTContext &Context) override { Visitor.TraverseDecl(Context.getTranslationUnitDecl()); } private: FindFooVisitor Visitor; };注意VisitCXXRecordDecl会收到很多隐式声明、前置声明等。如果不加isThisDeclarationADefinition()过滤,一个Foo很可能会被报告多次。另外每个Visit*方法都必须返回true,表示继续遍历;返回false会中止这棵子树,这是新手经常写错的地方。
4.4 用AST Matcher声明式匹配
第二种方式是声明式的ASTMatcher,它把“我要找什么”直接写成条件表达式。同样是找Foo,匹配器可以写成:
using namespace clang::ast_matchers; auto FooMatcher = cxxRecordDecl(hasName("Foo"), isDefinition()) .bind("classNode");MatchFinder会在遍历AST时自动对每个节点应用匹配规则,命中后回调run方法。这种方式比手写Visitor更紧凑,也是clang-tidy内部的核心机制。我写一个更贴近业务的例子:在代码库中找出所有调用strlen的地方。
#include "clang/ASTMatchers/ASTMatchFinder.h" #include "clang/ASTMatchers/ASTMatchers.h" #include "clang/Tooling/CommonOptionsParser.h" #include "clang/Tooling/Tooling.h" #include "llvm/Support/CommandLine.h" #include "llvm/Support/raw_ostream.h" using namespace clang; using namespace clang::ast_matchers; using namespace clang::tooling; using namespace llvm; static cl::OptionCategory MyToolCategory("my-tool options"); class StrlenFinder : public MatchFinder::MatchCallback { public: void run(const MatchFinder::MatchResult &Result) override { if (const CallExpr *Call = Result.Nodes.getNodeAs<CallExpr>("strlenCall")) { Call->getBeginLoc().print(Result.Context->getSourceManager(), llvm::outs()); llvm::outs() << " : found strlen call\n"; } } }; int main(int argc, const char **argv) { auto ExpectedParser = CommonOptionsParser::create(argc, argv, MyToolCategory); if (!ExpectedParser) { llvm::errs() << ExpectedParser.takeError(); return 1; } CommonOptionsParser &OptionsParser = ExpectedParser.get(); ClangTool Tool(OptionsParser.getCompilations(), OptionsParser.getSourcePathList()); MatchFinder Finder; StrlenFinder Callback; Finder.addMatcher( callExpr(callee(functionDecl(hasName("strlen")))) .bind("strlenCall"), &Callback); return Tool.run(newFrontendActionFactory(&Finder).get()); }这个例子完整展示了从命令行解析到匹配回调的链路。callExpr(callee(...))精确表示“只匹配作为函数调用者的CallExpr,并且被调函数名为strlen”。打印位置时我直接用SourceManager输出行列号,方便IDE定位。
4.5 编译并验证结果
配置并构建工程:
cmake -B build cmake --build build准备一个测试文件src/demo.cpp,内容包含对strlen的调用。然后运行:
./build/mytool -p build src/demo.cpp预期结果会打印出调用位置。如果第一步就想跑通,最好确保demo文件的编译数据库已经通过CMake生成。很多人卡在这里,不是代码写错,而是-p指向的目录里根本没有compile_commands.json。
5. 实测中常见的坑与排查链路
5.1 能编译但匹配不到任何节点:一条完整排查路径
所有LibTooling新手都会遇到的第一个大坑就是“工具能编译,跑起来却什么都没输出”。我把它当成一套标准排查流程来做:
- 检查编译数据库。
-p参数指向的目录里必须存在compile_commands.json,并且里面包含你要分析的源文件。用jq快速确认:jq '.[].file' build/compile_commands.json。 - 单独确认AST形态。直接
clang -Xclang -ast-dump看目标文件,确认你想要的节点确实存在,且类型和你匹配器写的一致。比如你以为CallExpr匹配到了,实际上AST里是一层ExprWithCleanups包着CallExpr,就需要调整匹配器。 - 打印所有候选节点。在
MatchCallback里不要只处理“正确节点”,先打印所有Result.Nodes.getNodeAs都拿不到的节点。直接给匹配器绑一个通用表达式变量,比如expr().bind("e"),把每次命中都输出位置,快速判断偏差。 - 考虑隐式节点。大量AST节点是编译器隐式生成的,例如默认构造函数、类型转换、析构函数。
callExpr(...)可能匹配到编译器插入的调用,而不是源码里写的。这时候加unless(isImplicit())过滤。 - 注意宏展开。如果目标代码的节点来自宏,AST里的位置信息会指向宏展开的位置。需要区分
getExpansionLoc()和getSpellingLoc()。想匹配“源码里真正写的调用”,用getSpellingLoc()做二次过滤会更准确。
这套流程我几乎每次遇到匹配为空都会走一遍,九成问题出在前两步。
5.2 链接阶段一堆undefined reference
链接时会遇到大量的undefined reference to 'clang::tooling::...'类报错。我遇到过三类主要原因:
第一,头文件版本和库版本不一致。比如系统里apt同时装了llvm-14和llvm-17,CMake找到了14的头文件,却链接了17的库,符号表自然对不上。解决办法是清理环境变量,并在CMake里显式指定LLVM_DIR和Clang_DIR,确保头文件和库来自同一套安装。
第二,Clang库没链接全。早期版本的CMake配置文件可能不会自动带出全部传递依赖,你需要自己列全组件。我经验中是至少要包含clangTooling、clangAST、clangASTMatchers、clangBasic、clangLex、clangFrontend、clangDriver、clangSema、clangParse。还缺时,再补llvm_map_components_to_libnames(llvm_libs core support)并链接${llvm_libs}。
第三,Linux下使用预编译包,运行时报找不到libclang-cpp.so。你编译链接没问题,但执行时动态链接器不知道去哪儿找库。临时解决是设置LD_LIBRARY_PATH指向LLVM的lib目录;一劳永逸的做法是在CMake里把RPath固化进去:
set(CMAKE_BUILD_RPATH /path/to/llvm/lib)5.3 遍历顺序和父子节点判断的坑
ASTMatcher和RecursiveASTVisitor都是深度优先遍历,但回调时机不一定符合你的直觉。比如要匹配“最外层调用点”,单独写callExpr(...)会同时匹配到嵌套在参数里的子调用。比如foo(bar())中,foo是一个匹配项,参数里的bar()也会被匹配到。如果我只想关心最外层的foo调用,就需要排除掉所有祖先也是调用的节点:
callExpr(callee(functionDecl(hasName("foo"))), unless(hasAncestor(callExpr())))类似的还有has和hasDescendant的区别。has只检查直接子节点,hasDescendant会检查任意层级后代,用错会导致匹配范围扩大,出现很多重复命中。调试时多用dump()打印节点树,把“预期结构”和“实际结构”对照好再写匹配器,效率最高。
5.4 版本差异,尤其是CommonOptionsParser
LLVM一年一个大版本,API来来回回改。最典型的就是CommonOptionsParser:LLVM 11之前可以直接构造CommonOptionsParser OptionsParser(argc, argv, Category);LLVM 11之后改成create返回Expected,裸构造被移除;到了LLVM 16/17,某些内部头文件路径也调整过,比如部分AST相关头文件被分散到更细的子目录。正因如此,我强烈建议整个团队锁定一个LLVM版本作为基线,不要顺手从网上复制不同年代的代码混用。像std::unique_ptr的newFrontendActionFactory(...).get()这种写法,不同版本差异不大,但凡是涉及cl::opt、Expected、std::error_code的API,都以当前安装版本的include头文件里的声明为准。
6. 进阶:算子自发现,以及向外扩展的思路
6.1 “算子自发现”到底在说什么
近期编译器社区里“算子自发现”相关的讨论不少。放在LLVM/Clang语境里,它可以拆成两层。第一层是源码级:让工具自动找出工程里出现的运算符使用模式,比如内置运算符+、-、*的使用频率,自定义运算符重载的分布,或者某些危险的重载模式。第二层是IR级:在LLVM IR里面识别指令模式,比如判断某个计算能否折叠成一条fma指令,这通常要写LLVM Pass和模式匹配。用LibTooling做源码级算子自发现已经完全够用,而且很适合作为入门到进阶的桥梁。IR级识别更贴近后端优化,是另一套技能体系。
6.2 用LibTooling实现一个简易算子统计器
拿“找出工程里所有运算符重载与二元运算符使用情况”举例。匹配器可以同时挂两类节点:
Finder.addMatcher( binaryOperator().bind("bin"), &BinOpCallback); Finder.addMatcher( cxxOperatorCallExpr().bind("opCall"), &BinOpCallback);binaryOperator覆盖的是内置运算符,比如a + b;cxxOperatorCallExpr覆盖的是用户定义的运算符重载,比如a.operator+(b)或a + b调用重载。在回调里可以拿到BinaryOperator::getOpcodeStr()或者CXXOperatorCallExpr::getOperator(),按运算符名字累加次数。更进一步,可以配合hasLHS/hasRHS分析左右操作数的类型,得到某段代码里的“运算符使用画像”。
实际应用里,我会用它去扫描一个数值计算库:统计哪些类重载了哪些运算符、运算符调用量有多大、有没有隐式转换导致运算符匹配到奇怪的重载。识别出来的模式对做性能优化很有价值,也能发现滥用operator+造成语义混乱的代码坏味道。
6.3 从Demo走向生产级工具
一个工具做完demo之后,要落到实际工作流里,还有不少可扩展点。比较常见的做法有这些:
- 用
PPCallbacks处理宏展开过程的细节。LibTooling不仅能看到最终AST,还能在预处理阶段注册回调,拦截宏定义和宏展开,这对分析大量用宏实现的框架代码特别重要。 - 把诊断接入
DiagnosticsEngine,让工具输出的警告能直接出现在IDE或clangd的诊断面板里。而不是只用llvm::outs()打印文本。 - 考虑做成clang-tidy的check而不是独立可执行文件。如果你有大量现成的clang-tidy基础设施,注册一个自定义check能减少维护成本,但初期调试独立工具更直观。
- 集成进CI。用
compile_commands.json作为输入,在代码提交时运行批量扫描脚本,输出带文件行列号的报告,再推动团队修问题。
我自己在写算子统计器的过程中,最深的感受是:工程并不复杂,复杂度几乎都来自“你的假设和Clang的实际AST不一致”。编译器基础设施是一个非常精确的世界,出问题在多数情况下不是库坏了,而是我没有用ast-dump看清楚节点真实结构。先把最小工具跑通,建立“看懂AST”的能力,再去谈复杂逻辑,这条路最稳。
最后分享一个我反复用的小技巧:在工具代码里遇到看不懂的节点,直接在回调里调用Node->dump(),它会把整个子树的AST结构打印到标准错误流,配合clang -ast-dump对照看,很多匹配器写不出来或者匹配过宽的问题一眼就能定位。做LibTooling开发,不要把时间花在记忆所有API上面,会观察AST、会查头文件,解决问题的速度会快得多。