如何在C++中正确访问映射内存而不产生未定义行为

26

我一直在尝试找出如何在C++17中访问映射缓冲区而不会引发未定义行为。对于此示例,我将使用由Vulkan的vkMapMemory返回的缓冲区。

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

一个C++程序中的构造创建、销毁、引用、访问和操作对象。通过定义(6.1)、new-expression(8.3.4)、当隐式更改联合的活动成员(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*并将表示复制到映射区域。 GPU将按照着色器中的指令(在本例中作为32位 float 数组)解释它,因此问题得以解决。但是,这需要额外的内存和额外的复制,因此我更喜欢避免这种情况。

2. 在放置时使用new创建数组

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

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

指针float_array现在可以安全地访问(在数组边界内或越过一位)。


所以,我的问题如下:

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

顺便说一句,我只是尝试弄清楚根据标准来说这样做的正确方式,我从未遇到过这样的问题,即通过简单的强制类型转换返回指针。


3
请注意,将数组类型用于 placement new 操作似乎会有特定于实现的内存开销。相关问题 - François Andrieux
3
您可能对"bless" proposal感兴趣。我想说它包括这个用例,但我承认我不确定是否完全正确。 - chris
3
"我只是想弄清楚做这件事的正确方法是什么。" 老实说,我不明白为什么要这样做。由于您的编译器无法知道vkMapMemory的工作方式,因此必须假设float已经被正确创建,并且在这种情况下UB不会产生任何后果。 - HolyBlackCat
@HolyBlackCat 这是一个危险的游戏。从经验上看,它现在可能有效,但你真的愿意把赌注押在所有可以想象的“仿佛”优化都不会出问题上吗? - Max Langhof
1
@HolyBlackCat:仅仅因为C++规范中未定义某项内容,并不意味着编译器不能为其定义自己的含义-每个编译器(以及每个操作系统)都会扩展规范以提供额外的功能,所以您需要查阅这些规范。如果OP正在使用'mmap',我将参考定义了它的POSIX规范,但我不知道vkMapMemory来自哪里。可能有一个标准定义了它的作用。 - Chris Dodd
显示剩余3条评论
3个回答

9

简短回答

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


详细回答

虽然标准中规定硬件映射内存是未定义行为,但我们可以想象任何合理的实现都遵守一些共同的规则。因此,某些构造比其他构造更不明确(无论这是否有意义)。

Is the simple static_cast indeed undefined behavior?

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

是的,这是未定义行为,在StackOverflow上已经讨论了很多次。

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可能太小,涉及缓冲区溢出。
那么怎么处理呢?
一个解决方案是手动构建一个浮点数序列:
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);

这段话是关于IT技术的。它描述了在指向 ptr 的内存中构造了连续的 N 个未初始化的 volatile float 对象。这意味着在读取这些对象之前必须对它们进行初始化;读取未初始化的对象是不确定的行为。
“这种技术是否适用于类似情况,例如访问内存映射硬件?”不是的,“这真的是实现定义”。我们只能假设您的实现做出了合理的选择,但您应该检查其文档。

评论不适合进行长时间的讨论;此对话已被移至聊天室 - user3956566

4
C++规范没有关于内存映射的概念,因此在C++规范中,与其相关的所有操作都是未定义行为。因此,您需要查看特定实现(编译器和操作系统)来确定哪些是定义好的并且可以安全地执行哪些操作。
在大多数系统上,映射将返回从别处获取的内存,并且可能已经被初始化为与某些特定类型兼容的方式。通常,如果最初编写内存的类型为float的值,则可以安全地将指针强制转换为 float *并以这种方式访问它。但是您确实需要知道内存映射的原始编写方式。

@curiousguy:几乎正确,因此不需要C++编译器支持它。它确实有“编译单元”的概念,可以以实现定义的方式组合一个以上的编译单元,因此这至少是标准中关于分离编译(separate compilation)的一种方式,即使只是在其缺失中。 - Chris Dodd
你指的是“翻译单元”(TU)。历史上,在标准C++中,纯粹的分离编译(将每个TU编译为可执行代码,然后链接)是不可能的。 - curiousguy

-3

C++ 兼容 C,而操作原始内存正是 C 擅长的。所以不用担心,C++ 完全能够做到你想要的。

  • 编辑:关于 C/C++ 兼容性的简单答案,请参阅 link

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

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

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

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

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

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

最后,如果你想让你的代码符合“现代C++”风格,你应该执行以下步骤。
1)声明自定义的PoD结构来表示你的数据布局。你可以使用static_assert(std::is_pod<MyType>::value)。这将警告你结构是否兼容。
2)声明指向你的类型的指针。(在这种情况下,不要使用智能指针,除非有一种合理的方法来“释放”内存)
3)只通过调用返回此指针类型的函数来分配内存。此函数需要:
a)使用Vulkan API的调用结果初始化你的指针类型。
b)在指针上使用in-place new——如果你只是写入数据,则不需要这样做——但这是一个好习惯。如果你想要默认值,请在你的结构declaration中初始化它们。如果你想保留这些值,只需不给它们默认值,in-place new就什么也不会做。

在读取内存之前使用“获取”栅栏,在写入后使用“释放”栅栏。Vulcan 可能会为此提供特定的机制,但我不确定。然而,所有同步原语(如互斥锁/解锁)都隐含了内存栅栏,因此您可以省略此步骤。

评论不适合进行长时间的讨论;此对话已被移至聊天室 - user3956566

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接