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 边界的另一侧将其解包。因为这个库是通用的,所以我使用 MaybeUninit
和 ptr::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
。如果您使用整数类型,目标应该是传输整数值——即数字。
我正在开发一个库,该库可帮助处理适合 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 边界的另一侧将其解包。因为这个库是通用的,所以我使用 MaybeUninit
和 ptr::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
。如果您使用整数类型,目标应该是传输整数值——即数字。