3.2. 内联

有几个选项控制分析器将考虑内联哪些调用。主要的是 -analyzer-config ipa

目前,-analyzer-config ipa=dynamic-bifurcate 是默认模式。

虽然 -analyzer-config ipa 通常决定分析器将尝试内联函数的积极程度,但一些额外的选项控制可以内联的函数类型,以全有或全无的方式。这些选项使用分析器的配置表,因此它们都按以下方式指定

-analyzer-config OPTION=VALUE

3.2.1. c++-inlining

此选项控制可以内联哪些 C++ 成员函数。

-analyzer-config c++-inlining=[none | methods | constructors | destructors]

这些模式中的每一种都意味着所有先前的成员函数类型也将被内联;例如,在不内联构造函数的情况下内联析构函数是没有意义的。

默认的 c++-inlining 模式是 'destructors',这意味着所有具有可见定义的成员函数都将被考虑内联。在某些情况下,分析器可能仍然选择不内联函数。

请注意,在 'constructors' 下,具有非平凡析构函数的类型的构造函数将不会被内联。此外,在 -analyzer-config ipa=none 或 -analyzer-config ipa=basic-inlining 下,无论 c++-inlining 模式的设置如何,都不会内联任何 C++ 成员函数。

3.2.1.1. c++-template-inlining

此选项控制是否可以内联 C++ 模板函数。

-analyzer-config c++-template-inlining=[true | false]

目前,默认情况下,模板函数被考虑内联。

此选项背后的动机是,非常通用的代码可能是误报的来源,要么是通过考虑调用者认为不可能的路径(通过一些未声明的先决条件),要么是通过内联某些但不是所有函数的深层实现。

3.2.1.2. c++-stdlib-inlining

此选项控制是否应考虑从 C++ 标准库内联函数,包括标准模板库中容器类的成员方法。

-analyzer-config c++-stdlib-inlining=[true | false]

目前,默认情况下,C++ 标准库函数被考虑内联。

标准库函数,特别是 STL,被普遍使用到足以让我们对误报的容忍度更低。由于 STL 的建模不佳而导致的误报会导致糟糕的用户体验,因为大多数用户不会乐意在系统头文件中添加断言以消除分析器警告。

3.2.1.3. c++-container-inlining

此选项控制是否应考虑内联“容器”类型的构造函数和析构函数。

-analyzer-config c++-container-inlining=[true | false]

目前,默认情况下,这些构造函数和析构函数不考虑内联。

此设置的当前实现检查类型是否具有名为“iterator”或名为“begin”的成员;这些名称在 C++ 中是惯用的,后者在 C++11 标准中指定。分析器目前在对类似容器的对象的某些数据结构不变式进行建模方面做得相当糟糕。例如,以下三个表达式应该等效

std::distance(c.begin(), c.end()) == 0
c.begin() == c.end()
c.empty()

如果容器始终具有未知的符号状态,则可以避免许多这些问题,这就是在它们的构造函数被视为不透明时发生的情况。将来,我们可能会决定某些容器是“安全”的,可以通过内联进行建模,或者选择使用检查器直接对其进行建模。

3.2.2. 实现基础

内联函数的底层机制在 ExprEngine::inlineCall 和 ExprEngine::processCallExit 中处理。

如果条件适合内联,则会创建一个 CallEnter 节点并将其添加到分析工作列表中。CallEnter 节点标记对表示被调用函数的新 LocationContext 的更改,其状态包括新堆栈帧的内容。当 CallEnter 节点实际被处理时,其单个后继将是到函数中第一个 CFG 块的边。

退出内联函数需要更多工作,幸运的是,它被分解成合理步骤

  1. CoreEngine 意识到我们已到达内联调用的末尾,并生成一个 CallExitBegin 节点。

  2. ExprEngine 接管(在 processCallExit 中)并找到函数的返回值(如果有)。这与触发调用的表达式绑定。(对于没有原始表达式的调用,例如析构函数,此步骤将被跳过。)

  3. 从状态中清理掉死符号和绑定,包括任何本地绑定。

  4. 生成一个 CallExitEnd 节点,它标记回到调用者的 LocationContext 的过渡。

  5. 处理自定义调用后检查,并将最终节点推回工作列表中,以便可以继续评估调用者。

3.2.2.1. 不内联重试

在某些情况下,我们希望在不内联特定调用时重试分析。

目前,我们使用此技术来恢复覆盖范围,以防我们由于超过内联函数中的最大块数而停止分析路径。

当检测到这种情况时,我们沿着路径向上走,找到内联启动前的第一个节点,并将其排队到工作列表中,并添加一个特殊的 ReplayWithoutInlining 位(ExprEngine::replayWithoutInlining)。然后从该点重新分析路径,而不内联该特定调用。

3.2.2.2. 决定何时内联

通常,分析器尝试尽可能多地内联,因为它提供了对程序中实际发生情况的更好摘要。然而,在某些情况下,分析器选择不内联

  • 如果找不到被调用函数或方法的定义。在这种情况下,没有内联的机会。

  • 如果无法为被调用函数构建 CFG,或者无法计算活跃性。这些是分析函数体(内联或不内联)的先决条件。

  • 如果给定 ExplodedNode 的 LocationContext 链达到最大截止深度。这可以防止由于无限递归导致的无限分析,但也作为性能原因的有用截止点。

  • 如果函数是可变参数的。这不是一个硬限制,而是一个工程限制。

    跟踪:<rdar://problem/12147064> 支持内联可变参数函数

  • 在 C++ 中,构造函数不会被内联,除非析构函数调用将由 ExprEngine 处理。因此,如果在没有隐式析构函数节点的情况下构建 CFG,或者如果给定对象的析构函数没有在 CFG 中表示,则构造函数将不会被内联。(作为例外,具有平凡构造函数的对象的构造函数仍然可以被内联。)参见下面的“C++ 注意事项”。

  • 在 C++ 中,ExprEngine 不会内联 operator 'new' 或 operator 'delete' 的自定义实现,也不会内联与其相关的构造函数和析构函数。参见下面的“C++ 注意事项”。

  • 导致“动态调度”的调用被专门处理。参见下面的更多信息。

  • FunctionSummaries 映射存储有关声明的额外信息,其中一些信息是在运行时根据之前的分析收集的。我们不会内联在其他上下文中内联不划算的函数(例如,如果超过了最大块数;参见“不内联重试”)。

3.2.2.3. 动态调用和去虚拟化

“动态”调用是在运行时解析的调用,例如 C++ 虚拟方法调用和 Objective-C 消息发送。由于分析的路径敏感性,分析器可能能够推理正在调用的方法的对象的动态类型,从而“去虚拟化”该调用。

当分析器能够确定在运行时实际调用哪个方法时,就会发生这种路径敏感的去虚拟化。当类型信息足够约束以用于分析器可以做出此类决定的模拟 C++/Objective-C 对象时,这是可能的。

3.2.2.4. DynamicTypeInfo

当分析器分析路径时,它可能会累积信息以完善对对象的类型的了解。然后,这可以用于更好地决定调用的目标方法。

此类类型信息被跟踪为 DynamicTypeInfo。这是存储在 ProgramState 中的路径敏感数据,它定义了从 MemRegions 到(可选)DynamicTypeInfo 的映射。

如果没有为 MemRegion 显式设置 DynamicTypeInfo,它将从该区域的类型或关联的符号中延迟推断。来自符号区域的信息比来自真实类型区域的信息弱。

示例:声明为“A obj”的 C++ 对象已知具有类 'A',但

引用“A &ref”可能在动态上是 'A' 的子类。

DynamicTypePropagation 检查器收集和传播 DynamicTypeInfo,在观察到可以完善该区域的类型信息的路径时更新它。

警告:并非所有现有的分析器代码都已改造为使用

DynamicTypeInfo 并非总是适用,它也并非普遍适用。特别是,DynamicTypeInfo 总是应用于去除所有强制转换后的区域,但有时强制转换提供的信息可能很有用。

3.2.2.5. RuntimeDefinition

反虚拟化的基础是 CallEvent 的 getRuntimeDefinition() 方法,该方法返回一个 RuntimeDefinition 对象。当被要求提供定义时,动态调用的 CallEvent 将使用其 ProgramState 中的 DynamicTypeInfo 尝试反虚拟化该调用。在没有动态调度或完全约束的反虚拟化的情况下,生成的 RuntimeDefinition 包含一个 Decl,对应于被调用函数的定义,并且 RuntimeDefinition::mayHaveOtherDefinitions 将返回 FALSE。

在动态调度情况下,如果我们的信息不完善,CallEvent 可以进行猜测,但 RuntimeDefinition::mayHaveOtherDefinitions 将返回 TRUE。然后,RuntimeDefinition 对象还将包含一个 MemRegion,对应于正在被调用的对象(即,Objective-C 中的“接收者”),ExprEngine 使用它来决定是否应该内联该调用。

3.2.2.6. 内联动态调用

-analyzer-config ipa 选项有五种不同的模式:none、basic-inlining、inlining、dynamic 和 dynamic-bifurcate。在 -analyzer-config ipa=dynamic 下,所有动态调用都被内联,无论我们是否确定这实际上是在运行时使用的定义。在 -analyzer-config ipa=inlining 下,只有“接近完美”的反虚拟化调用被内联*,其他动态调用以保守的方式进行评估(就好像没有定义可用一样)。

  • 目前,即使我们对接收者的类型相当有信心,在 -analyzer-config ipa=inlining 下,也不会内联任何 Objective-C 消息。我们计划在更彻底地测试我们的启发式方法后启用此功能。

最后一个选项 -analyzer-config ipa=dynamic-bifurcate 的行为类似于“dynamic”,但除了内联之外,它还在一般虚拟情况下执行保守的失效。这将在下面详细讨论。

如上所述,-analyzer-config ipa=basic-inlining 不会内联任何 C++ 成员函数或 Objective-C 方法调用,即使它们是非虚拟的或可以安全地反虚拟化。

3.2.2.7. 分支

ExprEngine::BifurcateCall 实现 -analyzer-config ipa=dynamic-bifurcate 模式。

当对具有不精确动态类型信息的(RuntimeDefinition::mayHaveOtherDefinitions() 评估为 TRUE)对象进行调用时,ExprEngine 会将路径分叉,并在 ProgramState 中使用路径敏感的“模式”标记该对象的区域(从 RuntimeDefinition 对象中检索)。

目前,有 2 种模式

  • DynamicDispatchModeInlined - 模拟动态类型信息

    接收者(MemoryRegion)被认为是完全受约束的,因此预计方法的特定定义将是实际调用的代码。当设置此模式时,ExprEngine 将使用 RuntimeDefinition 中的 Decl 内联发送到此接收者的任何动态调度调用,因为函数定义被认为已完全解析。

  • DynamicDispatchModeConservative - 模拟动态类型信息

    被认为是错误的,例如,这意味着方法定义在子类中被覆盖。在这种情况下,ExprEngine 不会内联发送到接收者(MemoryRegion)的方法,即使有候选定义可用。此模式对于模拟调用的效果是保守的。

在符号执行路径中继续前进时,ExprEngine 会查询接收者的 MemRegion 的模式,以决定是否应该内联调用,这确保每个区域最多只有一个拆分。

从高层次来看,“分支模式”允许在父方法包含仅在对类进行子类化时执行的代码的情况下,提高语义覆盖率。此模式的缺点是(相当大)的性能损失,以及在使用保守模式的路径上出现误报的可能性。

3.2.2.8. Objective-C 消息启发式方法

ExprEngine 依赖一组启发式方法来将 Objective-C 方法调用集划分为需要分支的调用和不需要分支的调用。以下是对象 DynamicTypeInfo 被认为是精确的(不能是子类)的情况

  • 如果对象是使用 +alloc 或 +new 创建并使用 -init 方法初始化的。

  • 如果调用是使用点语法进行的属性访问。这是基于子类很少覆盖属性或以基本兼容的方式进行覆盖的假设。

  • 如果类接口是在主源文件中声明的。在这种情况下,它不太可能被子类化。

  • 如果方法不是在主源文件之外声明的,无论是接收者类的还是任何超类的。

3.2.2.9. C++ 注意事项

C++11 [class.cdtor]p4 描述了在对象正在构造或析构时如何修改对象的 vtable;也就是说,对象的类型取决于哪些基类构造函数已完成。这使用 DynamicTypePropagation 检查器中的 DynamicTypeInfo 进行跟踪。

当前实现存在一些限制

  • 临时变量现在建模得很差,因为我们对它们的析构函数在 CFG 中的放置没有信心。我们目前不会内联它们的构造函数,除非析构函数是平凡的,并且根本不处理它们的析构函数,甚至不使区域失效。

  • 由于一些令人讨厌的 CFG/设计问题,‘new’ 建模得很差。这在 PR12014 中跟踪。‘delete’ 根本没有建模。

  • 对象数组现在建模非常差。ExprEngine 目前仅模拟第一个构造函数和第一个析构函数。因此,ExprEngine 不会内联数组的任何构造函数或析构函数。

3.2.2.10. CallEvent

CallEvent 代表对函数、方法或其他代码体的特定调用。它是路径敏感的,包含当前状态(ProgramStateRef)和堆栈空间(LocationContext),并提供对调用参数值和返回值类型的统一访问,无论调用在源代码中如何编写或调用的是什么类型的代码体。

注意:对于熟悉 Cocoa 的人来说,CallEvent 大致相当于

NSInvocation。

只要有处理函数调用的逻辑,而不需要关心调用是如何发生的,就应该使用 CallEvent。

例如,检查参数是否满足先决条件(例如 __attribute__((nonnull))),以及尝试内联调用。

CallEvent 是由 CallEventManager 管理的引用计数对象。虽然在持久化它们(例如,在 ProgramState 的 GDM 中)方面没有内在问题,但它们旨在用于短暂使用,并且可以从 CFGElements 或非顶级 StackFrameContexts 中相当容易地重新创建。