Rust 与消失的栈帧之谜

2021 年 1 月 26 日 · Kyle Strand 代表 FFI-unwind 项目组

既然 FFI-unwind 项目组 已经合并了 一份 RFC,该 RFC 规范了 "C unwind" ABI 并移除了 "C" ABI 中一些未定义行为的实例,我们已经准备好为该小组制定新的目标了。

当然,我们最重要的任务是实现新规范的行为。这项工作由 Katelyn Martin 负责,其进展可以在 此处 查看。

我们当前章程以及 创建该小组的 RFC 中的要求,通过规范 "C unwind" 已经得到了有效满足,所以一个选择是简单地解散这个项目组。然而,在起草 "C unwind" RFC 的过程中,我们发现围绕 longjmp 和类似函数的现有保证可以得到改进。尽管这与栈展开1 不直接相关,但它们密切相关:它们都是阻止函数正常返回的“非局部”控制流机制。由于 Rust 项目的目标之一是 Rust 与现有的类 C 语言互操作,并且这些控制流机制在实践中被广泛使用,我们认为 Rust 必须对它们提供一定程度的支持。

这篇博文将解释问题领域。如果你对此行为的规范化工作感兴趣,请加入我们 在 Zulip 聊天频道中

longjmp 及其同类

上文我提到了 longjmp 和“类似函数”。在 "C unwind" PR 的上下文中,这指的是在不同平台上具有不同实现、并且在 某些 平台上依赖 强制栈展开 的函数。然而,在我们的下一次规范工作中,我们希望完全忽略与栈展开的联系,并定义一类具有以下特征的函数:

一个通过释放一定数量的栈帧而导致控制流发生“跳转”的函数,该函数不执行任何额外的“清理”操作,例如运行析构器。

这是我们希望解决的一类函数。另一个主要例子是 pthread_exit。作为我们规范工作的一部分,我们想为这类函数创建一个名称,但尚未确定;目前,我们将它们称为“可取消的”、“类 longjmp 的”或“栈释放”函数。

我们的约束条件

回过头来看,我们的设计有两个强制约束条件:

  • 必须存在一种可靠的方式来调用可能执行 pthread_cancellibc 函数。
  • Rust 代码必须存在一种可靠的方式来调用可能 longjmp 跨过 Rust 栈帧的 C 代码。

此外,我们希望遵循以下几项设计原则:

  • 规范的行为不能是目标平台特定的;换句话说,我们对 Rust 与 longjmp 交互的规范不应取决于 longjmp 是释放栈帧还是启动强制栈展开。然而,优化 可以 是目标平台特定的。
  • longjmp 执行的栈帧释放行为与 pthread_cancel 执行的行为在规范上不应有区别。
  • 我们只允许取消 POF(“朴素老旧栈帧”,下一节解释)。

POF 和栈释放函数

"C unwind" RFC 引入了一个新概念,旨在帮助我们处理强制栈展开或栈释放函数:POF,即“朴素老旧栈帧”(Plain Old Frame)。这些是能够简单释放的栈帧,也就是说,它们在返回前不执行任何“清理”操作(例如运行 Drop 析构器)。

从定义中应该清楚的是,在可能销毁非 POF 栈帧的上下文中调用栈释放函数是危险的。那么,Rust 与栈释放函数交互的一个简单规范可以是,只要释放的都是 POF,调用这些函数就是安全的。这将使 Rust 对 longjmp 的保证与 C++ 基本相同。

然而,目前我们认为 POF 是“必要但不充分”的。我们认为一个更严格的规范可能会提供以下优势:

  • 更多有用的编译器警告或错误的机会,以防止滥用栈释放函数
  • 语义可追溯性:我们可以使所有相关函数对栈帧释放的依赖可见
  • 在清理操作“有保证”时增加优化潜力(即,如果编译器知道这是安全的,并且新插入的清理操作对于优化是必要的,则可以将 POF 转换为非 POF)

标注 POF

我们当前的计划是为那些旨在安全可取消的栈帧引入一个新的标注。当然,这些函数必须是 POF。这种标注将是“传递性的”,就像 async 一样:没有这个标注的函数要么不能调用任何有此标注的函数,要么必须保证它们会导致栈释放终止(例如,一个非 POF、没有标注的函数可以调用 setjmp)。

待解决的问题

标注的名称应基于用于指代安全可释放函数的术语。由于该术语尚未最终确定,我们还没有为标注命名。

annotated 函数是否应该能够调用没有此标注的任何函数,目前尚不清楚。只要函数调用不返回新的 Drop 资源(这会使 annotated 函数不再是 POF),它可能是安全的,前提是我们能保证在未标注的函数仍在栈上时,annotated 函数不会被取消;即,取消必须发生在对一个有标注的可取消函数的活跃调用期间。

最重要的是,我们还没有一个计划来表明一个没有标注的函数可以安全地调用一个有标注的函数。使用 setjmp 来确保 longjmp 不会丢弃栈帧的例子并不简单:

  • setjmp 不是一个函数,而是一个 C 宏。在 Rust 中没有办法直接调用它。
  • setjmp 不像 C++ 的 catch 可以捕获任何异常那样,能够阻止任意的 longjmp 跨越一个栈帧。相反,setjmp 创建一个类型为 jmp_buf 的对象,该对象必须传递给 longjmp;这会导致跳转在相应的 setjmp 调用处停止。

当然,setjmp/longjmp 也不是这类机制的唯一例子!因此,编译器可能没有办法保证这是安全的,而且不清楚可以应用哪些启发式方法使其尽可能安全。

示例

让我们使用 #[pof-longjmp] 作为占位符,来表示一个可以安全释放的函数的标注,并且假设下面的函数是对 longjmp 的包装。

extern "C" {
    #[pof-longjmp]
    fn longjmp(CJmpBuf) -> !;
}

编译器不允许这样做:

fn has_drop(jmp_buf: CJmpBuf) {
    let s = "string data".to_owned();
    unsafe { longjmp(jmp_buf); }
    println!("{}", s);
}

在这里,s 实现了 Drop,所以 has_drop 不是一个 POF。由于 longjmp 带有 #[pof-longjmp] 标注,未标注的函数 has_drop 不能调用它(即使在 unsafe 块中)。然而,如果 has_drop 有标注:

#[pof-longjmp]
fn has_drop(jmp_buf: CJmpBuf) {
    let s = "string data".to_owned();
    unsafe { longjmp(jmp_buf); }
    println!("{}", s);
}

...会出现另一个错误:#[pof-longjmp] 只能应用于 POF,由于 s 实现了 Drophas_drop 不是一个 POF。

一个允许的 longjmp 调用示例是:

#[pof-longjmp]
fn no_drop(jmp_buf: CJmpBuf) {
    let s = "string data";
    unsafe { longjmp(jmp_buf); }
    println!("{}", s);
}

加入我们!

如果你想帮助我们创建这个规范并为其撰写 RFC,请加入我们 在 Zulip 中

脚注

1: 如 RFC 中所述,在 Windows 上,longjmp 实际上 一个栈展开操作。然而,在其他平台上,longjmp 与栈展开无关。