std::ptr::write 是否传输它写入的字节的 "uninitialized-ness"?

Does std::ptr::write transfer the "uninitialized-ness" of the bytes it writes?

我正在开发一个库,该库可帮助处理适合 FFI 边界上的指针大小 int 的类型。假设我有这样的结构:

use std::mem::{size_of, align_of};

struct PaddingDemo {
    data: u8,
    force_pad: [usize; 0]
}

assert_eq!(size_of::<PaddingDemo>(), size_of::<usize>());
assert_eq!(align_of::<PaddingDemo>(), align_of::<usize>());

这个结构有 1 个数据字节和 7 个填充字节。我想将此结构的一个实例打包到 usize 中,然后在 FFI 边界的另一侧将其解包。因为这个库是通用的,所以我使用 MaybeUninitptr::write:

use std::ptr;
use std::mem::MaybeUninit;

let data = PaddingDemo { data: 12, force_pad: [] };

// In order to ensure all the bytes are initialized,
// zero-initialize the buffer
let mut packed: MaybeUninit<usize> = MaybeUninit::zeroed();
let ptr = packed.as_mut_ptr() as *mut PaddingDemo;

let packed_int = unsafe {
    std::ptr::write(ptr, data);
    packed.assume_init()
};

// Attempt to trigger UB in Miri by reading the
// possibly uninitialized bytes
let copied = unsafe { ptr::read(&packed_int) };

那个 assume_init 调用是否触发了未定义的行为?换句话说,当 ptr::write 将结构复制到缓冲区时,它是否复制填充字节的未初始化状态,将初始化状态覆盖为零字节?

目前,当此代码或类似代码在 Miri 中为 运行 时,它不会检测到任何未定义的行为。但是,根据关于此问题的讨论 on github, ptr::write is supposedly allowed to copy those padding bytes, and furthermore to copy their uninitialized-ness. Is that true? The docs for ptr::write don't talk about this at all, nor does the nomicon section on uninitialized memory.

Does that assume_init call triggered undefined behavior?

是的。 "Uninitialized" 只是 Rust 抽象机中一个字节可以具有的另一个值,仅次于通常的 0x00 - 0xFF。让我们将这个特殊字节写为 0xUU。 (参见 this blog post for a bit more background on this subject。)0xUU 由副本保留,就像一个字节可以具有的任何其他可能值由副本保留一样。

但细节有点复杂。 在 Rust 中有两种方法可以在内存中复制数据。 不幸的是,Rust 语言团队也没有明确说明这方面的细节,因此以下是我个人的解释。我认为除非另有说明,否则我所说的是没有争议的,但当然这可能是一种错误的印象。

无类型/按字节复制

一般来说,当复制一个字节范围时,源范围只是覆盖目标范围——所以如果源范围是“0x00 0xUU 0xUU 0xUU”,那么在复制之后目标范围将具有确切的字节列表。

这就是 C 中的 memcpy/memmove 的行为(在我对标准的解释中,不幸的是这里不是很清楚)。在 Rust 中,ptr::copy{,_nonoverlapping} 可能 执行按字节复制,但现在实际上并没有精确指定,有些人可能想说它也是类型化的。对此进行了一些讨论 in this issue

打字复制

替代方案是 "typed copy",这是在每个正常赋值 (=) 和传递值 to/from 函数时发生的情况。类型化副本解释源内存中某种类型 T,然后 "re-serializes" 类型 T 的值进入目标内存。

与按字节复制的主要区别在于,与类型 T 无关的信息会丢失。这基本上是一种复杂的说法,即键入副本 "forgets" 填充,并有效地将其重置为未初始化。与未打字的副本相比,打字的副本丢失了更多信息。 非类型化副本保留底层表示,类型化副本仅保留表示值。

因此,即使您将 0usize 转换为 PaddingDemo,该值的键入副本也可以将其重置为“0x00 0xUU 0xUU 0xUU”(或任何其他可能的填充字节)--假设 data 位于偏移量 0,这是无法保证的(如果需要保证,请添加 #[repr(C)])。

在您的例子中,ptr::write 接受类型为 PaddingDemo 的参数,并且该参数通过类型化副本传递。所以在那个时候,填充字节可能会任意改变,特别是它们可能变成 0xUU。

未初始化usize

你的代码是否有UB取决于另一个因素,即在usize中有一个未初始化的字节是否是UB。问题是,(部分)未初始化的内存范围 是否代表 某个整数?目前,它还没有,因此 there is UB. However, whether that should be the case is heavily debated 看来我们最终会允许它。

许多其他细节仍然不清楚,但是 - 例如,将“0x00 0xUU 0xUU 0xUU”转换为整数可能会导致 完全 未初始化的整数,即整数可能无法保存 "partial initialization"。为了在整数中保留部分初始化的字节,我们基本上不得不说整数没有抽象 "value",它只是一个(可能未初始化的)字节序列。这并不反映整数在 / 等操作中的使用方式。 (其中一些还取决于关于 poison and freeze 的 LLVM 决策;LLVM 可能会决定在以整数类型进行加载时,如果任何输入字节为 poison,则结果完全为 poison。)所以即使代码不是 UB 因为我们允许未初始化的整数,它也可能不会按预期运行,因为您要传输的数据正在丢失。

如果您想传输原始字节,我建议使用适合的类型,例如 MaybeUninit。如果您使用整数类型,目标应该是传输整数值——即数字。