24

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

因此,根据N4659(最终的 C++17 工作草案),第[intro.object]部分(强调添加):

C++ 程序中的构造创建、销毁、引用、访问和操作对象。 当隐式更改联合的活动成员(12.3) 或创建临时对象时(7.4, 15.2) ,通过定义(6.1)、 新表达式(8.3.4) 创建对象。

显然,这些是创建 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.放置-new数组

似乎[new.delete.placement]部分没有对如何获得放置地址施加任何限制(无论实现的指针安全性如何,它都不必是安全派生的指针)。因此,应该可以通过放置创建一个有效的浮点数组new,如下所示:

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

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


所以,我的问题如下:

  1. 简单的static_cast确实是未定义的行为吗?
  2. 这种展示位置new的使用是否定义明确?
  3. 这种技术是否适用于类似情况,例如访问内存映射硬件

作为旁注,我从来没有通过简单地转换返回的指针而遇到问题,我只是想根据标准的字母找出正确的方法是什么。

4

3 回答 3

9

简短的回答

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


长答案

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

简单的static_cast确实是未定义的行为吗?

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

是的,这是未定义的行为,并且已在 StackOverflow 上多次讨论过。

这种放置新用法是否定义明确?

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

不,即使这看起来定义明确,这也是依赖于实现的。碰巧的operator ::new[]是,允许保留一些开销1, 2,除非您检查工具链文档,否则您无法知道多少。因此,::new (dst) T[N]需要大于或等于未知数量的内存,N*sizeof T并且您分配的任何内存dst都可能太小,涉及缓冲区溢出。

那怎么进行呢?

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

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);

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

这种技术是否适用于类似情况,例如访问内存映射硬件?

不,这又是真正的实现定义。我们只能假设您的实现采取了合理的选择,但您应该检查其文档的内容。

于 2018-11-16T15:37:14.597 回答
4

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

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

于 2018-11-17T20:43:35.937 回答
-2

C++ 与 C 兼容,并且操作原始内存是 C 最适合的。所以不用担心,C++ 完全有能力做你想做的事。

  • 编辑:-点击此链接以获得 C/C++ 兼容性的简单答案。-

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

并非 C++ 中的所有对象都需要构造。这些被称为PoD(plain-old-data)类型。他们是

1)基本类型(浮点数/整数/枚举等)。
2)所有指针,但不是智能指针。3) PoD 类型的数组。
4) 仅包含基本类型或其他 PoD 类型的结构。
...
5) 类也可以是 PoD 类型,但约定是任何声明为“类”的东西都不应被依赖为 PoD。

您可以使用标准函数库对象测试一个类型是否为 PoD 。

现在,关于将指针转换为 PoD 类型的唯一未定义的事情是结构的内容不是由任何东西设置的,因此您应该将它们视为“只写”值。在您的情况下,您可能已经从“设备”写入它们,因此初始化它们将破坏这些值。(顺便说一句,正确的演员表是“reinterpret_cast”)

担心对齐问题是对的,但认为这是 C++ 代码可以解决的问题是错误的。对齐是内存的属性,而不是语言特征。要对齐内存,您必须确保“偏移量”始终是结构“对齐”的倍数。在 x64/x86 上,这个错误不会产生任何问题,只会减慢对内存的访问。在其他系统上,它可能会导致致命异常。
另一方面,您的内存不是“易失的”,它由另一个线程访问。该线程可能在另一个设备上,但它是另一个线程。您需要使用线程安全内存。在 C++ 中,这是由原子变量提供的。但是,“原子”不是 PoD 对象!你应该使用记忆栅栏反而。这些原语强制从内存中读取内存。volatile 关键字也这样做,但允许编译器重新排序 volatile 写入,这可能会导致意外结果。

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

在读取内存之前使用“获取”栅栏,在写入后使用“释放”栅栏。Vulcan 可能会为此提供一个特定的机制,我不知道。尽管所有同步原语(例如互斥锁/解锁)都暗示内存栅栏是正常的,因此您可能会在没有这一步的情况下逃脱。

于 2018-11-17T19:44:07.073 回答