如何在 C++ 中没有未定义行为的情况下正确访问映射内存

How to properly access mapped memory without undefined behavior in C++

我一直在尝试找出如何在不调用未定义行为的情况下从 C++17 访问映射缓冲区。对于此示例,我将使用 Vulkan 的 vkMapMemory.

返回的缓冲区

因此,根据 N4659 (the final C++17 working draft), section [intro.object](重点添加):

The constructs in a C++ program create, destroy, refer to, access, and manipulate objects. An object is created by a definition (6.1), by a new-expression (8.3.4), when implicitly changing the active member of a union (12.3), or when a temporary object is created (7.4, 15.2).

显然,这些是创建 C++ 对象的唯一有效方法。因此,假设我们得到一个 void* 指针,指向主机可见(和连贯)设备内存的映射区域(当然,假设所有必需的参数都具有有效值并且调用成功,并且返回的块内存足够大小并且正确对齐):

void* ptr{};
vkMapMemory(device, memory, offset, size, flags, &ptr);
assert(ptr != nullptr);

现在,我希望以 float 数组的形式访问此内存。显而易见的事情是 static_cast 指针并继续我的快乐方式如下:

volatile float* float_array = static_cast<volatile float*>(ptr);

(包含 volatile,因为它被映射为 连贯 内存,因此可以在任何时候由 GPU 写入)。但是,float 数组在该内存位置 技术上 不存在,至少在引用摘录的意义上不存在,因此通过这样的指针访问内存会是未定义的行为。因此,根据我的理解,我有两个选择:

1。 memcpy数据

应该始终可以使用本地缓冲区,将其转换为 std::byte*memcpy 表示 到映射区域。 GPU 将按照着色器中的指示对其进行解释(在本例中,作为 32 位 float 的数组),从而解决问题。但是,这需要额外的内存和额外的副本,所以我宁愿避免这种情况。

2。 placement-new数组

[new.delete.placement] doesn't impose any restrictions on how the placement address is obtained (it need not be a safely-derived pointer 部分似乎与实现的指针安全无关)。因此,应该可以通过 placement-new 创建一个有效的 float 数组,如下所示:

volatile float* float_array = new (ptr) volatile float[sizeInFloats];

指针 float_array 现在应该可以安全访问(在数组的范围内,或过去一次)。


所以,我的问题如下:

  1. 简单的 static_cast 确实是未定义的行为吗?
  2. 这个展示位置-new 用法是否定义明确?
  3. 这种技术是否适用于类似的情况,例如

作为旁注,我从来没有通过简单地转换返回的指针而遇到问题,我只是想弄清楚正确的方法会怎样是,按照字母的标准。

简答

根据标准,所有涉及硬件映射内存的行为都是未定义的行为,因为抽象机不存在该概念。您应该参考您的实施手册。


长答案

尽管硬件映射内存是标准未定义的行为,但我们可以想象任何提供一些遵守通用规则的理智实现。一些结构比其他结构更多未定义行为(无论那意味着什么)。

Is the simple static_cast indeed undefined behavior?

volatile float* float_array = static_cast<volatile float*>(ptr);

是的,并且已在 Whosebug 上讨论过多次。

Is this placement-new usage well-defined?

volatile float* float_array = new (ptr) volatile float[N];

不,尽管这看起来定义得很好,这取决于实现。碰巧的是,operator ::new[] 被允许预留一些开销 1, 2,除非你检查你的工具链文档,否则你不知道有多少。因此,::new (dst) T[N] 需要大于或等于 N*sizeof T 的未知内存量,并且您分配的任何 dst 都可能太小,涉及缓冲区溢出。

How to proceed, then?

一种解决方案是手动构建一系列浮点数:

auto p = static_cast<volatile float*>(ptr);
for (std::size_t n = 0 ; n < N; ++n) {
    ::new (p+n) volatile float;
}

或者等效地,依赖于标准库:

#include <memory>
auto p = static_cast<volatile float*>(ptr);
std::uninitialized_default_construct(p, p+N);

这会在 ptr 指向的内存中连续构造 N 个未初始化的 volatile float 对象。这意味着您必须在读取它们之前初始化它们;读取未初始化的对象是未定义的行为。

Is this technique applicable to similar situations, such as accessing memory-mapped hardware?

不,又是这确实是实现定义的。我们只能假设您的实施做出了合理的选择,但您应该查看其文档中的内容。

C++ 与 C 兼容,操作原始内存正是 C 所擅长的。所以不用担心,C++ 完全有能力做你想做的事。

  • 编辑:- 按照此 link 获得 C/C++ 兼容性的简单答案。 -

在你的例子中,你根本不需要调用new!解释...

并非 C++ 中的所有对象都需要构造。这些被称为 PoD(普通旧数据)类型。他们是

1) 基本类型(floats/ints/enums,等)。
2) 所有指针,但不是智能指针。 3) PoD 类型数组。
4) 只包含基本类型的结构,或者其他PoD类型。
...
5) class 也可以是 PoD 类型,但惯例是任何声明 "class" 的东西都不应依赖于 PoD。

您可以使用标准函数库测试类型是否为 PoD object

现在唯一 undefined 关于将指针转换为 PoD 类型的事情是结构的内容未由任何设置,因此您应该对待它们作为 "write-only" 值。在您的情况下,您可能已经从 "device" 写信给他们,因此初始化它们会破坏这些值。 (顺便说一下,正确的转换是 "reinterpret_cast")

您担心对齐问题是对的,但您认为这是 C++ 代码可以解决的问题是错误的。对齐是内存的属性,而不是语言功能。要对齐内存,您必须确保 "offset" 始终是结构的 "alignas" 的倍数。在 x64/x86 上,出错不会产生任何问题,只会减慢对内存的访问。在其他系统上,它可能会导致致命异常。
另一方面,你的内存不是"volatile",它是由另一个线程访问的。这个线程可能在另一个设备上,但它是另一个线程。您需要使用线程安全内存。在 C++ 中,这是由 atomic variables. However, an "atomic" is not a PoD object! You should use a memory fence 提供的。这些原语强制从内存中读取内存或从内存中读取内存。 volatile 关键字也这样做,但允许编译器对 volatile 写入重新排序,这可能会导致意外结果。

最后,如果你希望你的代码是 "modern C++" 风格,你应该执行以下操作。
1) 声明您的自定义 PoD 结构以表示您的数据布局。您可以使用 static_assert(std::is_pod::value)。如果结构不兼容,这将警告您。
2)声明一个指向你的类型的指针。 (仅在这种情况下,不要使用智能指针,除非有办法 "free" 有意义的内存)
3) 仅通过调用 which returns 这个指针类型来分配内存。此功能需要
a) 使用调用 Vulkan API.
的结果初始化指针类型 b) 在指针上使用就地 new - 如果您只写入数据则不需要这样做 - 但这是一种很好的做法。如果您想要默认值,请在您的结构 declaration 中初始化它们。如果你想保留这些值,只需不要给它们默认值,就地新的将不会做任何事情。

读内存前使用"acquire"栅栏,写入后使用"release"栅栏。 Vulcan 可能会为此提供特定的机制,我不知道。尽管所有同步原语(例如互斥量 lock/unlock)暗示内存栅栏是正常的,因此您可能没有这一步就逃脱了。

C++ 规范没有映射内存的概念,因此就 C++ 规范而言,与它有关的一切 都是未定义的行为。因此,您需要查看您正在使用的特定实现(编译器和操作系统)以查看定义的内容以及您可以安全地执行的操作。

在大多数系统上,映射将 return 来自其他地方的内存,并且可能(或可能没有)以与某些特定类型兼容的方式进行初始化。通常,如果内存最初被写为 float 值的正确、受支持的形式,那么您可以安全地将指针转换为 float * 并以这种方式访问​​它。但是你确实需要知道被映射的内存最初是如何写入的。