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 和类似函数的保证可以得到改进。尽管这与展开 (unwinding)1并不严格相关,但它们关系密切:它们都是阻止函数正常返回的“非局部”控制流机制。由于 Rust 项目的目标之一是让 Rust 与现有的类 C 语言进行互操作,并且这些控制流机制在实践中被广泛使用,我们认为 Rust 必须对它们有一定的支持。

这篇博文将解释问题所在。如果您有兴趣帮助指定此行为,请加入我们的 Zulip 流

longjmp 及其同类

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

通过释放一些栈帧而不执行任何额外的“清理”(例如运行析构函数)来导致控制流中“跳转”的函数

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

我们的约束

退一步说,我们的设计有两个强制约束

  • 必须有一种合理的方式来调用可能 pthread_cancellibc 函数。
  • 必须有一种合理的方式让 Rust 代码调用可能在 Rust 栈帧上进行 longjmp 的 C 代码。

此外,我们希望遵守几项设计原则

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

POF 和栈释放函数

"C unwind" RFC 引入了一个新概念,旨在帮助我们处理强制展开或栈释放函数:POF,或“普通旧栈帧”。这些是可以轻松释放的栈帧,即它们在返回之前不进行任何“清理”(例如运行 Drop 析构函数)。

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

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

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

注释 POF

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

未决问题

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

同样,尚未明确带注释的函数是否应该能够调用任何没有此注释的函数。只要函数调用不返回新的 Drop 资源(使带注释的函数不再是 POF),那么只要我们保证在未注释的函数仍在堆栈上时,带注释的函数不能被取消,它可能是安全的;即,取消必须发生在主动调用带注释的可取消函数期间。

最重要的是,我们没有一个计划来指示一个未注释的函数如何安全地调用一个带注释的函数。使用 setjmp 来确保 longjmp 不会丢弃栈帧的示例并非微不足道

  • setjmp 不是函数而是 C 宏。没有办法在 Rust 中直接调用它。
  • setjmp 不会阻止任意 longjmp 跨越栈帧,就像 C++ 的 catch 可以捕获任何异常一样。相反,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 与展开无关。