针对 -fbounds-safety 的实现计划

使用实验性标志进行逐步更新

该功能将作为一系列较小的 PR 实现,并且我们将在可用模型完全可用之前使用实验性标志 -fexperimental-bounds-safety 保护我们的实现。一旦模型准备就绪可以使用,我们将公开标志 -fbounds-safety

可能的补丁集

  • 外部边界注释和(延迟)解析逻辑。

  • 内部边界注释(宽指针)及其解析逻辑。

  • 带有调试信息的宽指针的 Clang 代码生成。

  • 涉及边界注释的指针转换语义(这可以分成多个子 PR)。

  • 针对相关指针和计数赋值对等的 CFG 分析。

  • AST 中的边界检查表达式和 Clang 代码生成(这也可能分成多个子 PR)。

建议的实现

外部边界注释

边界注释是属于指针类型的 C 类型属性。如果属性添加到声明属性的位置,例如 int *ptr __counted_by(size),则属性属于声明的最外层指针类型(int *)。

新的语法糖类型

外部边界注释创建了底层指针类型的类型语法糖。我们将引入一种新的语法糖类型 DynamicBoundsPointerType 来表示 __counted_by__sized_by。使用 AttributedType 将不足够,因为该类型需要保存计数或大小表达式以及分析所需的某些元数据,而该类型可以通过继承 AttributedType 来实现。将注释视为类型语法糖意味着具有不兼容外部边界注释的两种类型可能被认为是规范上的相同类型。这在某些情况下是必要的,例如,为了使 __counted_by 及其朋友不参与函数重载。但是,这种设计需要单独的逻辑遍历整个类型层次结构以检查边界注释的类型兼容性。

C 语言的延迟解析

例如 __counted_by(count) 这样的边界注释可以添加到结构体字段声明类型的类型中,其中 count 是在同一结构体中稍后声明的另一个字段。类似地,该注释可以应用于在同一函数中先于参数计数的函数参数声明的类型。这意味着解析边界注释的参数必须在解析器拥有结构体或函数声明的整个上下文之后完成。Clang 具有针对需要延迟解析的 C++ 声明属性的延迟解析逻辑,而 C 声明属性和 C/C++ 类型属性没有相同的逻辑。这需要为 C/C++ 类型属性引入延迟解析逻辑。

内部边界注释

__indexable__bidi_indexable 会更改指针表示,使其等效于包含指针和相应边界字段的结构体。尽管它们的表示方式不同,但在允许的操作类型及其语义方面,它们仍然是指针。例如,对 __bidi_indexable 指针的指针解引用将返回与普通 C 指针相同的解引用值,但要考虑在解引用宽指针之前执行的额外边界检查。这意味着将宽指针映射到具有等效布局的结构体类型将不足够。为了在 Clang AST 中表示宽指针,我们在 PointerType 类中添加了一个额外的字段来指示指针的内部边界。这确保了具有不同表示方式的指针映射到不同的规范类型,而它们仍然被视为指针。

在 LLVM IR 中,宽指针将作为具有等效表示的结构体发出。Clang CodeGen 将在 TypeEvaluationKind (TEK) 中将它们处理为 Aggregate。AggExprEmitter 已扩展以处理返回宽指针的指针操作。或者,可以引入新的 TEK 和专门用于宽指针的表达式发射器。

默认边界注释

该模型可能会根据具有指针类型的声明的上下文隐式添加 __bidi_indexable__single__bidi_indexable 会隐式地添加到局部变量,而 __single 会隐式地添加到指定结构体字段、函数参数或全局变量的指针类型。这意味着解析器可以首先创建没有任何默认指针属性的指针类型,然后在解析器拥有声明上下文并确定默认属性后重新创建该类型。

这也要求解析器使用新创建的具有正确默认属性的类型来重置声明的类型。

提升表达式

将引入一种新的表达式来表示从具有外部边界注释的指针(例如 __counted_by)到 __bidi_indexable 的转换。这种类型的转换无法通过普通的 CastExpr 处理,因为它需要额外的子表达式来提供创建宽指针所需的边界信息。

边界检查表达式

边界检查是 -fbounds-safety 语言模型中定义的语义的一部分。因此,在 AST 中公开边界检查和其他语义操作是可取的。已将用于边界检查的新表达式添加到 AST。边界检查表达式有一个 BoundsCheckKind 来指示检查的类型,并具有根据类型执行检查所需的额外子表达式。

配对赋值检查

-fbounds-safety 强制要求与相同外部边界注释相关的变量或字段(例如,以下示例中与 __counted_by 相关的 bufcount)必须在同一个基本块内并排更新,并且在两者之间没有副作用。

typedef struct {
   int *__counted_by(count) buf; size_t count;
} sized_buf_t;

void alloc_buf(sized_buf_t *sbuf, sized_t nelems) {
   sbuf->buf = (int *)malloc(sizeof(int) * nelems);
   sbuf->count = nelems;
}

为了实现此规则,编译器需要语句的线性表示来了解顺序和两个或多个赋值之间的相邻关系。Clang CFG 用于实现此分析,因为 Clang CFG 提供了每个 CFGBlock 内语句的线性视图(Clang CFGBlock 代表源代码级 CFG 中的单个基本块)。

边界检查优化

-fbounds-safety 中,如果前端的类型系统或分析无法验证其边界安全,则 Clang 前端将为每次内存解引用发出运行时检查。该实现依赖于 LLVM 优化来删除冗余的运行时检查。使用这种优化策略,如果原始源代码已经具有边界检查,则 -fbounds-safety 将引入的额外检查越少。LLVM ConstraintElimination 过程旨在删除可证明的冗余检查(请查看 Florian Hahn 在 2021 LLVM Dev Meeting 上的演示文稿和实现,以了解更多信息)。在以下示例中,-fbounds-safety 会隐式地添加优化器可以删除的冗余边界检查

void fill_array_with_indices(int *__counted_by(count) p, size_t count) {
   for (size_t i = 0; i < count; ++i) {
      // implicit bounds checks:
      //   if (p + i < p || p + i + 1 > p + count) trap();
      p[i] = i;
   }
}

ConstraintElimination 收集以下事实并确定是否可以安全地删除边界检查

  • 在 for 循环内部,0 <= i < count,因此 1 <= i + 1 <= count

  • if 条件中的指针算术运算 p + count 不会发生溢出。

  • -fbounds-safety 将指针算术溢出视为确定性的二进制补码计算,而不是未定义行为。因此,getelementptr 通常没有 inbounds 关键字。但是,编译器在此情况下会为 p + count 发出 inbounds,因为 __counted_by(count) 具有 p 至少包含与 count 相同数量的元素的不变性。利用此信息,ConstraintElimination 能够确定 p + count 不会发生溢出。

  • 因此,p + ip + i + 1 也不会发生溢出。

  • 因此,p <= p + ip + i + 1 <= p + count

  • if 条件简化为 false,并成为死代码,后续的优化过程可以将其移除。

OptRemarks 可用于提供对性能调优的见解。它能够报告无法消除的检查,并可能给出原因,允许程序员调整代码以解锁进一步的优化。

调试

内部边界注释

内部边界注释将指针更改为宽指针。调试器需要了解宽指针本质上是指针,具有结构布局。为了处理这个问题,宽指针在调试信息中被描述为记录类型。类型名称有一个特殊的名称前缀(例如,__bounds_safety$bidi_indexable),调试信息使用者可以识别它来提供超出显示宽指针的内部结构的支持。不需要 DWARF 扩展来支持宽指针。在我们的实现中,LLDB 通过名称识别宽指针类型,并将它们重建为宽指针 Clang AST 类型,以供表达式求值器使用。

外部边界注释

与内部边界注释类似,外部边界注释在调试信息中被描述为对其底层指针类型的 typedef,并且边界被编码为 typedef 名称中的字符串(例如,__bounds_safety$counted_by:N)。

识别 -fbounds-safety 陷阱

Clang 为 -fbounds-safety 陷阱发出调试信息,作为内联函数,其中函数名称编码错误消息。LLDB 实现了一个帧识别器,以便向最终用户提供人类可读的错误原因。不知道这一点的调试信息使用者会看到一个内联函数,其名称编码错误消息(例如:__bounds_safety$Bounds check failed)。

表达式解析

在我们的实现中,LLDB 的表达式求值器没有启用 -fbounds-safety 语言选项,因为它目前无法完全重建具有外部边界注释的指针,并且因为求值器在 C++ 模式下运行,使用 C++ 引用类型,而 -fbounds-safety 目前不支持 C++。这意味着 LLDB 的表达式求值器只能评估 -fbounds-safety 语言模型的一部分。具体来说,它能够评估源代码中已经存在的宽指针。所有其他表达式都根据 C/C++ 语义进行评估。

C++ 支持

C++ 有多种选项可以以边界安全的的方式编写代码,例如遵循边界安全核心准则和/或使用经过强化的 libc++ 以及 C++ 安全缓冲区模型。但是,这些技术可能需要 ABI 更改,并且可能不适用于与 C 交互的代码。当需要保留现有程序的 ABI 以及用于 C 和 C++ 共享的标头时,-fbounds-safety 提供了一种可能的解决方案。

-fbounds-safety 目前在 C++ 中不受支持,但我们相信这种通用方法适用于未来的努力。