【问题标题】:How to properly access mapped memory without undefined behavior in C++如何在 C++ 中正确访问映射内存而没有未定义的行为
【发布时间】:2019-04-19 19:28:36
【问题描述】:

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

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

C++ 中的结构 程序创建、销毁、引用、访问和操作对象。一个 目的 是 由定义 (6.1) 创建,由 新表达 (8.3.4),当隐式改变 的活动成员时 union (12.3),或创建临时对象时 (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。放置-new数组

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

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

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


所以,我的问题如下:

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

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

【问题讨论】:

  • 请注意,带有数组类型的放置 new 似乎具有实现指定的内存开销。 Related question.
  • 您可能对"bless" proposal 感兴趣。我想说它包括这个用例,但我承认我不是 100% 确定。
  • “我只是想弄清楚这样做的正确方法是什么” 老实说,我不明白这样做的意义。由于您的编译器无法知道vkMapMemory 是如何工作的,因此它必须假设floats 已正确创建,并且这种情况下的UB 不会产生任何后果。
  • @HolyBlackCat 这是一个危险的游戏。根据经验,它现在可能有效,但你真的会反对所有可以想象的“好像”优化,这不会变成坏事吗?
  • @HolyBlackCat:仅仅因为 C++ 规范未定义某些东西,这并不意味着编译器不能为它定义自己的某些含义——每个编译器(和每个操作系统)都扩展了规范以提供附加功能,因此您需要检查这些规范。如果 OP 使用mmap,我会参考定义它的 POSIX 规范,但我不知道 vkMapMemory 来自哪里。大概有一个标准来定义它的作用。

标签: c++ language-lawyer c++17 volatile mapped-memory


【解决方案1】:

简答

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


长答案

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

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

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

是的,this is undefined behavior,并且已经在 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);

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

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

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

【讨论】:

【解决方案2】:

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

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

【讨论】:

  • @curiousguy:几乎正确,因此不需要 C++ 编译器来支持它。它确实具有“编译单元”的概念,并且可以以实现定义的方式组合多个单元,因此这至少是对标准中单独编译的一种认可,即使只是在没有它的情况下。
  • 您的意思是“翻译单位”(TU)。历史上纯粹的单独编译(将每个 TU 编译为可执行代码然后链接)在标准 C++ 中是不可能的。
【解决方案3】:

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

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

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

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

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

你可以使用标准函数库object来测试一个类型是否是PoD。

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

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

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

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

【讨论】:

猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-10-16
  • 1970-01-01
  • 2019-07-28
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多