Clang 及 Clang-tidy 与 AST 的关系

保存时间:2026/4/3 07:42:45


Clang 及 Clang-tidy 与 AST 的关系
内容由豆包 AI 生成
就是说在我看来,大语言模型未必能够直接帮助我们进行代码自动化的这种工作,可能还是回归于传统的路径更现实一点,而大语言模型能够作为一个辅助工具,至少是帮助程序员加快知识的理解与这个学习过程,或者是一种编程的概念上的辅助工具工作。所以呢,我的,我们的主题还是回到 C L A N G 这个强大的编译器的使用,以及它的可能做的工作来。那么先从头开始介绍一下 C L A N G 的结构,以及它的重点在于它的扩展性的功能模块上。就比如说 client tidy,它这个是基于哪一个模块做的?我相信是基于 AST 就是抽象语法树,那么能不能具体的做一个详细的介绍?
Clang 是一个 C、C++、Objective - C 等编程语言的编译器前端,具有高度的可扩展性和灵活性。以下是其结构及相关介绍:

Clang 的结构

  • 词法分析器:将输入的源程序字符流转化为单词序列。
  • 语法分析器:基于单词序列构建抽象语法树(AST),表示源程序的语法结构。
  • 语义分析器:对 AST 进行语义检查,确保程序符合语言的语义规则。
  • 中间表示(IR)生成器:将经过语义分析的 AST 转换为中间表示形式,便于后续的优化和代码生成。

Clang 的扩展性

Clang 通过插件系统实现扩展功能。开发者可以编写插件来定制和扩展 Clang 的行为,比如进行特定的代码分析、优化或代码生成。

Clang - tidy 与 AST

Clang - tidy 是 Clang 的一个工具,用于检查和修复 C++ 代码中的常见错误和风格问题。它基于 AST 进行工作,具体过程如下:
  • AST 遍历:Clang - tidy 使用 Clang 的 AST 遍历机制,访问 AST 中的每个节点。例如,它可以遍历函数定义、变量声明、表达式等节点,以检查代码是否符合特定的规则。
  • 规则检查:针对不同的节点类型,Clang - tidy 应用相应的检查规则。比如,检查函数参数是否符合特定的命名规范,变量是否在使用前初始化等。
  • 修复建议:如果发现代码不符合规则,Clang - tidy 可以基于 AST 提供修复建议。它可以修改 AST 中的节点属性或结构,然后将修改后的 AST 重新生成代码,实现代码的自动修复。
通过基于 AST 的工作方式,Clang - tidy 能够深入理解代码的语法和语义结构,从而提供准确的检查和修复功能,帮助开发者提高代码质量和一致性。
相关视频
你这样介绍是比较泛泛的,我还是同意的。但是我们要更深入一层,就从程序员的角度来看,就是说他,我知道他是分成了多个动态库,或者动多个一个 client 编译完以后是多个动态库或者多个静态库,或者是一个动态库。但终究它是有分成若干个所谓的 module 模块,我们应该从模块一个一个来介绍过来,尽量用英文的模块名,以免这个英文翻译成中文的混淆,我们从模块来解释它的各个关联性,以及它这个流水线的输入输出的关系,以及它的功能性,它就是做什么用的?我的目标是,我不关心它在当前平台上产生目标代码那部分,就是最后的生成目标码,而我只关心它在生成所谓的虚超平台的的那种语言的,就是所谓的,我们称之为类似于虚拟机的这种这种层次,这是一个分界线。为什么呢这样呢?因为在我看来,你所有的测试力啊,或者说是语法语义分析啊什么之类的。和最终代码平台生成这部分,关系不大。那个地方是有优化的,可能 95% 的优化都在那里做。这是所有编译器里最困难的地方。但是呢,我们并不想,想去进入到那个复杂的领域,而仅仅是在跨平台或者平台抽象的这个层次去做代码的正确性以及它的重构性,或者说生成测试例来检验它代码,这个符合统一的 C 加加标准的这种语法规则下的这一层次所以呢,我们只关心在最后生成目标代码段之前的所有的流程,它的 pipeline,它的数据流动的输入输出的关系,以及它每一个层,每一个 module 的意义以及重要性以及特点等等。
好的,以下是从程序员角度,针对 Clang 在生成目标代码之前的模块、模块间关联性、流水线输入输出关系以及功能等方面的介绍:
  1. Lexical Analysis Modules(词法分析模块)
    • llvm::clang::Lexer:词法分析是编译的第一步,Lexer 模块将输入的字符流转换为一个个的词法单元(tokens)。例如,将代码中的 “int”“main”“(”“{” 等识别为不同的 token。它为后续的语法分析提供基本的输入单元。其输入是源文件的字符流,输出是词法单元序列。
  2. Syntax Analysis Modules(语法分析模块)
    • llvm::clang::Parser:该模块基于词法分析得到的词法单元构建抽象语法树(AST)。它根据 C++ 语言的语法规则,将词法单元组织成层次化的语法结构。比如,将函数定义、变量声明等构建成相应的 AST 节点。输入是词法单元序列,输出是 AST。AST 是后续语义分析和其他处理的基础,非常重要。
  3. Semantic Analysis Modules(语义分析模块)
    • llvm::clang::Sema:语义分析模块对 AST 进行语义检查。例如,检查变量是否在使用前声明、函数调用的参数类型是否匹配等。它还会进行符号表的管理,记录变量、函数等符号的信息。输入是 AST,输出是经过语义检查和符号信息补充的 AST。这个模块确保代码在语义上的正确性,对于代码的正确性验证和重构至关重要。
  4. AST Manipulation and Analysis Modules(AST 操作和分析模块)
    • llvm::clang::ASTMatchers:这是一个强大的工具,用于匹配和操作 AST 节点。程序员可以使用它编写规则来查找特定类型的 AST 节点,例如查找所有的函数调用节点或者特定类型的变量声明节点。这对于代码分析、生成测试用例以及代码重构等任务非常有用。输入是 AST,输出是匹配到的 AST 节点集合。
    • llvm::clang::Rewriter:基于 ASTMatchers 找到的节点,Rewriter 模块可以对 AST 进行修改,然后重新生成代码。比如,根据特定的规则修改代码的风格或者修复一些常见的代码问题。输入是经过 ASTMatchers 处理的 AST 以及修改规则,输出是修改后的代码。
  5. Type Checking Modules(类型检查模块)
    • llvm::clang::TypeChecker:负责检查代码中类型的正确性。例如,检查类型转换是否合法、模板实例化是否正确等。它依赖于语义分析得到的符号和类型信息,对代码的类型相关部分进行严格检查。输入是包含符号和类型信息的 AST,输出是类型检查的结果,用于保证代码在类型层面的正确性。
  6. Diagnostics Modules(诊断模块)
    • llvm::clang::DiagnosticsEngine:当上述模块(如语义分析、类型检查等)发现问题时,DiagnosticsEngine 模块负责生成相应的诊断信息(错误或警告)。它可以准确地指出问题在代码中的位置,并提供详细的错误描述,帮助程序员定位和解决问题。输入是检查过程中发现的问题信息,输出是格式化的诊断信息。
这些模块在 Clang 的编译流水线中依次执行,前一个模块的输出作为后一个模块的输入,共同完成在生成目标代码之前对代码的分析、检查和处理工作,确保代码在语法、语义和类型等方面的正确性,为后续的优化和目标代码生成打下良好的基础。
这个非常的好,那么流水线是一个非常重要的一个模式,而且是一个很好的理解它这个输入输出关系的流程图,就计算图吧,那么最理想的就是说是一个是方便学习,一个方便 debug 诊断。其实最好的是说每个模块它都有一个输出到文件的这样一个把它的结果输出到文件作为一个事后诊断排查的一个方式。当然运行期我们不需要这种每个模块,而且能够独立运行也是一个很重要的一个机制,就比如方便我们去做某些实验,或者说理解其中的错误提 bug 等等。就比如说我现在有一个 AST 的 dump,我现在需要去独立的去运行这个 diagnostic,或者说就总之就说每个模块我相信他们都是编译成一个一个的动态库,那么每个动态库有没有一个入口啊?能够独立运行啊?当然他们也许还又有所谓的关联性,就是说这个动态库依赖于那个动态库运行,我相信肯定是有的,比如说 type checking,它肯定有些数据查询,它是依赖于那个最早的那个 lex,就是输入的那个那个一些定义,这个都没有问题,当然最好是怎么样每个模块能够有一个哪怕是一个测试的入口函数能够去直接运行,这也是好的,我之所以提出这个意思就是说我们可以分别使用每一个模块独立的使用,哪怕有一些它这种链接的这种依赖性,这都不是问题,因为只要是一起编译的哈部署这是另一回事,主主要是能够独立运行。就意思就是说我前面一个模块已经完成了任务,它的输出然后作为后面一个模块的输出,或者我修改了这个模块的输出输入,能够也能产生一个结果,我能够去做比较,这也是一个很好的工具。
在 Clang 中,确实存在一些方式可以让部分模块在一定程度上独立运行或进行单独测试,尽管不是每个模块都有完全独立且简单的入口:
  1. AST Dump:Clang 提供了直接将 AST 输出到文件的功能。通过命令行参数 -ast-dump 或在代码中使用 ASTContext::dump() 方法,就可以将 AST 以文本形式输出。这对于理解 AST 的结构以及后续模块基于 AST 的操作非常有帮助。
  2. 独立模块测试入口
    • 词法分析器(Lexer):在 Clang 的测试框架中,有针对词法分析器的测试代码。虽然不是一个简单的供外部直接调用的入口函数,但通过查看测试代码可以了解如何输入字符流并获取词法单元输出。例如,在 Clang 的测试目录中,有专门的词法分析测试用例,可以模仿这些用例编写自己的测试程序。
    • 语法分析器(Parser):同样,在测试框架中有相关测试代码。可以构建一个简单的程序,提供词法单元输入(可以基于词法分析的输出),然后调用语法分析器生成 AST。
    • 语义分析器(Sema):语义分析依赖于前面生成的 AST。在 Clang 的代码库中,有一些示例和测试代码展示了如何使用 Sema 对 AST 进行语义分析。可以通过构建合适的 AST 输入来调用语义分析器。
  3. Diagnostics 模块:对于诊断模块,它可以独立使用来生成诊断信息。可以构建一个简单的测试环境,模拟发现问题的情况,然后调用 DiagnosticsEngine 来生成诊断消息。例如,手动创建一些错误的 AST 节点,然后使用 DiagnosticsEngine 来输出相应的错误信息。
  4. 动态库依赖:Clang 的各个模块编译成的动态库之间确实存在依赖关系。例如,类型检查模块(TypeChecker)依赖于词法分析、语法分析和语义分析模块提供的信息。在构建测试环境时,需要确保正确链接和初始化这些依赖的模块。
  5. 测试框架:Clang 使用Google Test 作为其测试框架。许多模块的测试代码都遵循 Google Test 的规范。通过查看这些测试代码,可以了解如何独立测试各个模块,以及如何处理模块之间的依赖关系。
虽然不是每个模块都有一个简单的可供直接调用的入口函数,但通过参考 Clang 的测试代码和相关文档,结合其模块之间的依赖关系,可以构建出能够独立测试和运行各个模块的环境,从而满足学习、调试和实验的需求。
我曾经对一个现象很印象深刻,就是在 Linux 里面有个链接器, ld 点 so,这个看起来它是一个动态链接库,但实际上它是一个可执行程序就意思就是说,它是可以直接运行的。这个是一个非常独特的一个机制。意思就是说,如果我们每一个动态链接库也都能够实现类似于这种 LD 点 SO 这种机制能够既作为动态链接库来使用,又可以在命令行直接有个入口直接运行的话,那就是非常方便的作为每一个独立的测试模块来使用。
在 Clang 中,虽然各个模块编译成的动态库不像ld.so那样直接有既作库又能独立运行的普遍设计,但我们可以通过一些方法来接近这种效果。
对于 Clang 的模块动态库,要让它们能像可执行程序一样在命令行运行,可以利用一些编程手段。比如,为每个模块编写一个简单的 “包装” 程序。以词法分析模块为例,我们可以创建一个可执行程序,在其中加载词法分析相关的动态库,然后设置输入(比如从文件读取的代码字符流),调用动态库中的词法分析函数进行处理,并将结果输出。
在 C++ 中,可以使用dlopendlsym等函数(在 Linux 系统下,用于动态加载库和获取库中函数指针)来实现动态库的加载和函数调用。通过这种方式,我们可以在一个独立的可执行程序中,有针对性地调用动态库中的特定功能,模拟其独立运行的效果。
另外,Clang 本身的设计理念是模块化和可扩展的,虽然原生没有提供每个模块直接作为可执行程序运行的接口,但从其测试框架和代码结构来看,是鼓励开发者进行定制化的测试和扩展的。所以,我们可以借鉴ld.so的思路,结合 Clang 模块的特点,通过编写额外的代码来实现类似既作库又能独立运行的功能,以便更好地对每个模块进行测试和调试。你之前有没有尝试过用类似dlopen这样的函数来加载 Clang 的动态库呢?
我虽然没有尝试使用 d l o l o d 这种方式,就是动态加载方式,但是类似的例子我知道你一说我就理解了,我现在撇开这个具体实现,我只是有点好奇,当时我忘掉了,就是为什什么 ld 点 so 它既可以作为普通的动态库,又是一个可执行程序,因为这个在编译器生成的目标类型里面实际上是有截然不同的这个模式的。就是我可以理解也说 LD 点 so 它是有一个 main,一个 entry,但是呢,如果普通的我们动态库这样编译的话,一定会报冲突,说啊,它有一个等于是一个函,一个程序,可执行程序,它不能有两个 entry,就是不能有两个 main,那么这个机制在 LDSO 它当然是一个非常特殊的一个东西,它是怎么样去解决的呢?它好像是就是解释器有一个,那个就是 SO,就是 DLSO 的这个,它有一个,嗯,就是动态库有一个 segment,就是指向它的解释器的这个路径,是不是在这里面做了一个呃 trick 去,呃,就是解释器是它自己的,我忘掉怎么做到的。
ld.so能既作为动态库又作为可执行程序,主要是通过一些特殊的设计和机制来实现的。
首先,ld.so有自己特殊的入口点,它并非传统意义上与普通可执行程序冲突的main函数作为入口。它有专门的启动代码来处理动态链接相关的任务,比如加载其他共享库、解析符号等。
其次,关于你提到的段(segment),ld.so的二进制文件中包含了特殊的段信息,用于指定动态链接器自身的一些属性和相关路径等信息。它通过这些段信息来找到并加载所需的解释器,而这个解释器负责执行ld.so的主要功能。
另外,ld.so在编译和链接时也经过了特殊的处理,使其能够具备这种双重特性。它被设计成可以在系统中作为动态链接的核心组件,既能够被其他程序调用以进行动态链接操作,又可以自身作为一个可执行程序来完成一些初始化和特定的动态链接任务。
这种特殊的设计使得ld.so在动态链接的过程中扮演着关键的角色,并且能够在不同的场景下发挥作用,而不会出现普通程序中关于入口点和多重定义的冲突问题。
你现在有没有资料?就是把 LD 点 so 的所有的 segment 去列表一下,我们来看一个实例,就知道它有什么特殊处理了。
在 ELF 格式的ld.so中,常见的段(segment)有以下几种:
  • .interp:该段保存着字符串,即动态链接器的路径。例如在 Linux 系统中,通常是/lib/ld-linux.so.2(这是个软链接)。操作系统加载可执行文件时,会依据此段指定的路径寻找并加载动态链接器。
  • .dynamic:此段包含动态链接器所需的基本信息,如程序依赖的共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。通过这些信息,ld.so能够找到并加载程序运行所需的共享库,并完成符号解析和重定位等操作。
  • .dynsym:即动态符号表,保存着与动态链接相关的符号信息,如导入函数和导出函数等,不包含模块内部的私有符号。它与静态链接中的符号表.symtab类似,但仅存储动态链接相关的符号。
  • .dynstr:动态符号字符串表,用于保存.dynsym段中符号的名称。与静态链接中的符号字符串表.strtab相对应,是动态链接下用于辅助动态符号表的字符串表。
  • .hash:也称为符号哈希表,用于加快动态链接时符号的查找过程。通过哈希算法,能够快速定位到所需的符号,提高动态链接的效率。
例如,使用readelf -S命令可以查看ld.so的段信息,以下是一个简化的示例:
plaintext
There are 30 section headers, starting at offset 0x1f7c:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1].interp           PROGBITS        00000238 000238 00001c 00   A  0   0  1
  [ 2].note.gnu.build-id NOTE            00000254 000254 000024 00   A  0   0  4
  [ 3].hash             GNU_HASH        00000278 000278 000090 04   A  4   0  8
  [ 4].dynsym           DYNSYM          00000308 000308 0001a8 10   A  5   0  8
  [ 5].dynstr           STRTAB          000004b0 0004b0 00010f 00   A  0   0  1
  [ 6].gnu.version      VERSYM          000005c0 0005c0 000030 02   A  4   0  2
  [ 7].gnu.version_r    VERNEED         000005f0 0005f0 000040 00   A  5   1  8
  [ 8].rela.dyn         RELA            00000630 000630 000078 0c   A  4   0  8
  [ 9].rela.plt         RELA            000006a8 0006a8 000054 0c  AI  4  20  8
  [10].init             PROGBITS        00000700 000700 000023 00  AX  0   0  4
  [11].plt              PROGBITS        00000728 000728 000060 08  AX  0   0  8
  [12].text             PROGBITS        00000788 000788 00129c 00  AX  0   0  16
  [13].fini             PROGBITS        00001a24 001a24 000019 00  AX  0   0  4
  [14].rodata           PROGBITS        00001a40 001a40 00063c 00   A  0   0  8
  [15].eh_frame_hdr     PROGBITS        0000207c 00207c 00003c 00   A  0   0  4
  [16].eh_frame         PROGBITS        000020b8 0020b8 00019c 00   A  0   0  4
  [17].init_array       INIT_ARRAY      00003c68 003c68 000008 00  WA  0   0  8
  [18].fini_array       FINI_ARRAY      00003c70 003c70 000008 00  WA  0   0  8
  [19].jcr              PROGBITS        00003c78 003c78 000008 00  WA  0   0  8
  [20].dynamic          DYNAMIC         00003c80 003c80 0001c0 08  WA  5   0  8
  [21].got              PROGBITS        00003e40 003e40 000028 08  WA  0   0  8
  [22].got.plt          PROGBITS        00003e68 003e68 000040 08  WA  0   0  8
  [23].data             PROGBITS        00003ea8 003ea8 000028 00  WA  0   0  8
  [24].bss              NOBITS          00003ed0 003ed0 000010 00  WA  0   0  8
  [25].comment          PROGBITS        00000000 003ed0 00003a 00      0   0  1
  [26].shstrtab         STRTAB          00000000 003f0a 00010b 00      0   0  1
  [27].symtab           SYMTAB          00000000 003f40 0008c0 10      28  59  8
  [28].strtab           STRTAB          00000000 004800 0004f4 00      0   0  1
  [29].debug_aranges    PROGBITS        00000000 004cf4 000048 00      0   0  8
上述示例中可以看到.interp.dynamic.dynsym.dynstr.hash等段的相关信息,包括它们的类型、地址、偏移量、大小等。
需要注意的是,不同系统和版本的ld.so可能会有一些差异,但基本的段结构和功能是相似的。
对了,我就想起来我唯一的印象就是这个点 interp 就是 i n t e r p,就是这个 interpreter 这个字段,它指向了一个 slash lib,slash ld hyphen linux,点 so 点二,这是个软链接,就这个它是一个什么样的东西?这个是一个很大的一个黑区,我之前没有仔细看过,好像是可以作为脚本也写它,因为是也可以是一个可执行代码,就是说它是一个解析器。解析器的话它解释些什么东西呢?它是一个作为一个,嗯,包装的一些运行一些简单的办事的一些脚本呢?然后再去真正的运行一个什么东西呢?这个你有没有资料呢?
ld-linux.so.2是 Linux 系统中的动态链接器。它主要负责在程序运行时加载可执行文件并解析其中的动态链接库。以下是其具体介绍:

功能

  • 符号解析:当可执行文件被加载到内存中时,ld - linux.so.2负责解析其中的符号引用,并将它们与相应的动态链接库中的符号进行匹配,让程序能正确找到并调用库中的函数和变量。
  • 库加载:负责加载程序所需的动态链接库,并将它们映射到内存中,使程序可以使用这些库中的功能。
  • 重定位:使用重定位技术来修正程序中的绝对地址,确保程序能够正确地访问内存中的各个函数和变量,适应不同的内存布局。

工作原理

在启动 ELF 二进制文件时,内核处理程序会依据 ELF 文件程序头中interp部分指定的路径找到ld - linux.so.2。它先将二进制文件映射到内存中,接着加载程序中引用的所有库,如libm.so.6等,完成这些操作后,再将控制权交给正在执行的二进制文件的起始地址,从而让程序能够正常运行。
能不能这么理解?就是 ld 点 so 实际上是一个超平台的,跨平台的一个逻辑上的一个算法的这么一个加载过程,而真正那个下面做具体加载识别 ELF 文件格式的就是所谓的 LD Linux 点 so 点二它是真正去读取具体的动态库的结构解析,把它们加载到内存等等等等。然后而这个 LD 点 so 它实际上是在这种具体加载结束之后,它去完成一些逻辑上的这种就超越平台之上的逻辑上的这种做法。因为这有一些所谓的测策略,所谓的深度搜索啊,就是因为这个,这个叫什么?这个 dynamic symbol 有的是所谓的隐性的、显性的、全局的,还有这个你是一一直去深度搜索呢?还是说是 lazy loading?就是说不去一次性全部加载,只是泛泛的第一次使用的时候并不加载全部的,而只是当你真正使用的时候才去加载,去弄一个所谓 got 什么之类的,能不能这么理解?
这种理解不完全准确。实际上,ld.so是 Unix 或类 Unix 系统上动态链接器的统称,ld-linux.sold.so针对 ELF 格式二进制可执行文件的变体,ld-linux.so.2是 Linux 系统中具体实现 ELF 动态链接器功能的库文件。
在 Linux 系统中,ld-linux.so.2负责在程序运行时读取可执行文件的 ELF 头,解析其中.dynamic段记录的所需动态链接库信息,按照搜索路径规则查找并加载这些库到进程地址空间,同时完成符号解析和重定位操作。它也实现了动态链接库的版本控制机制,并支持延迟加载以提高程序启动速度。
所以,ld-linux.so.2既承担了具体的 ELF 文件格式识别、动态库结构解析和加载到内存的工作,也负责处理与动态链接相关的各种逻辑,如符号搜索策略、延迟加载等,而不是存在ld.sold - linux.so.2分别承担不同阶段任务的明确分工。
那如果 LD Linux 点 so 点二做了所有的工作,那么 LD 点 so 究竟做了什么工作呢?它只是解决了一些依赖关系吗?还是什么就说,比如说 LDD 这个 Linux 这个命令,它究竟调用了谁?我相信是调用了 LD 点 so,那隐含着它是不是内部也在去调用 LD Linux 点 so 去做所有具体的工作,那么 LD 点 so 究竟做了些什么呢?你有 LD 点 so 的那个代码说明文档吗?
ld.so是 Unix 或类 Unix 系统上动态链接器的统称,ld - linux.sold.so针对 ELF 格式二进制可执行文件的变体,ld - linux.so.2是 Linux 系统中具体实现 ELF 动态链接器功能的库文件。ld.so主要承担以下工作:
  • 加载共享库:在程序运行时,负责将程序所依赖的共享库加载到内存中,使程序能够找到并使用这些库中的函数和变量。
  • 符号解析与重定位:解析程序中的符号引用,将其与共享库中的符号进行匹配,并完成重定位操作,确保程序能够正确地访问内存中的函数和变量。
  • 搜索路径管理:按照一定的顺序搜索动态链接库的路径,包括环境变量指定的路径、缓存文件以及默认目录等,以找到程序所需的共享库。
ldd命令是一个 shell 脚本,它通过设置一系列环境变量,调用ld - linux.so来实现显示可执行模块的依赖关系。
关于ld.so的代码说明文档,你可以参考GNU C 库(glibc)的官方文档,其中有关于动态链接器ld.so的相关介绍。此外,在 Linux 系统中,也可以通过man ld.so命令查看本地的手册页文档。
参考 14 篇资料
ld.so的工作过程是怎样的?
ld.so和ld-linux.so.2之间的关系是怎样的?