【问题标题】:What is the correct way to allocate and use an untyped memory block in C++?在 C++ 中分配和使用无类型内存块的正确方法是什么?
【发布时间】:2015-10-04 20:39:30
【问题描述】:

到目前为止,我为这个问题得到的答案有两种完全相反的答案:“它是安全的”和“它是未定义的行为”。我决定重写整个问题,以获得更好的澄清答案,对我和任何可能通过谷歌到达这里的人来说。

另外,我删除了 C 标签,现在这个问题是 C++ 特定的

我正在创建一个 8 字节对齐的内存堆,将在我的虚拟机中使用。我能想到的最明显的方法是分配std::uint64_t 的数组。

std::unique_ptr<std::uint64_t[]> block(new std::uint64_t[100]);

假设sizeof(float) == 4sizeof(double) == 8。我想在block 中存储一个浮点数和一个双精度数并打印值。

float* pf = reinterpret_cast<float*>(&block[0]);
double* pd = reinterpret_cast<double*>(&block[1]);
*pf = 1.1;
*pd = 2.2;
std::cout << *pf << std::endl;
std::cout << *pd << std::endl;

我还想存储一个 C 字符串说“你好”。

char* pc = reinterpret_cast<char*>(&block[2]);
std::strcpy(pc, "hello\n");
std::cout << pc;

现在我想存储“Hello, world!”超过 8 个字节,但我仍然可以使用 2 个连续的单元格。

char* pc2 = reinterpret_cast<char*>(&block[3]);
std::strcpy(pc2, "Hello, world\n");
std::cout << pc2;

对于整数,我不需要reinterpret_cast

block[5] = 1;
std::cout << block[5] << std::endl;

我将block 分配为std::uint64_t 的数组,仅用于内存对齐。我也不希望任何大于 8 字节的内容存储在其中。如果保证起始地址为 8 字节对齐,则块的类型可以是任何类型。

有些人已经回答说我正在做的是完全安全的,但有些人说我肯定是在调用未定义的行为。

我是否编写了正确的代码来实现我的意图?如果不是,什么是合适的方式?

【问题讨论】:

  • 任何对指针类型不兼容的对象的访问都违反了别名规则。
  • @xiver77 如果你想要动态内存,那么使用 malloc。它将在堆上分配并分配存储持续时间。 C 有特殊的规则来帮助处理几乎完美地适用于您的案例的别名。
  • 为了规避这一切,只需使用 char* 作为堆的“普通”类型并将索引移动 3 位...
  • @FelixPalmen 这表明您显然不了解标准。 C 允许 char 为任何类型起别名,但反之则不行。
  • 别名是关于读取和写入,而不是关于声明,任何声明都可以,只要它实际上不用于内存访问——如果你在这一点上停止与你“讨论”仍然坚持你的愚蠢想法。

标签: c++ memory-management


【解决方案1】:

更新新问题:

好消息是有一个简单易用的解决方案可以解决您的实际问题:使用new (unsigned char[size]) 分配内存。使用new 分配的内存在标准中保证以适合用作任何类型的方式对齐,您可以安全地使用char* 为任何类型设置别名。

标准参考,3.7.3.1/2,分配函数:

返回的指针应适当对齐,以便它可以 转换为任何完整对象类型的指针,然后用于 访问分配的存储中的对象或数组


原始问题的原始答案:

至少在 3.10/15 的 C++98/03 中,我们有以下内容,这很清楚地使它仍然是未定义的行为(因为您正在通过异常列表中未枚举的类型访问值):

如果程序尝试通过以下方式访问对象的存储值 行为是以下类型之一以外的左值 未定义):

——对象的动态类型,

— 对象动态类型的 cv 限定版本,

——对象的动态类型对应的有符号或无符号类型,

— 有符号或无符号类型,对应于对象动态类型的 cv 限定版本,

——在其成员中包含上述类型之一的聚合或联合类型(递归地,包括子聚合或包含联合的成员),

——一种类型,它是对象动态类型的(可能是 cvqualified)基类类型,

— char 或 unsigned char 类型。

【讨论】:

  • 其中大部分是 C++ 特定的。 C 呢?
  • 声明一个数组(只保留未指定内容的内存)已经是“存储对象”了吗?在我的解释中,这里的第一个“存储对象”是字符'\n',而数组声明只是堆栈分配的一种(愚蠢的)形式,只要它的标识符不用于访问它的内容。如果我错了,请纠正我。
  • “异常列表中未枚举的类型”。至少在 C99 中,上一段中列出了另一个例外来源(其中一个标准中的 6.5.6)。基本上,写入没有 declared 类型的数据可用于合法地更改对象的 有效 类型。因此,类型可以在其生命周期内多次合法地更改类型。只是不要用一种类型写作,然后用另一种类型阅读。这可能不适用于这里,但这意味着我们必须更加小心
【解决方案2】:

全局分配函数

要分配任意(无类型)内存块,全局分配函数(§3.7.4/2);

void* operator new(std::size_t);
void* operator new[](std::size_t);

可用于执行此操作 (§3.7.4.1/2)。

§3.7.4.1/2

分配函数尝试分配请求的存储量。如果成功,它将返回存储块的开始地址,其字节长度应至少与请求的大小一样大。从分配函数返回时分配的存储内容没有限制。由连续调用分配函数分配的存储的顺序、连续性和初始值是未指定的。返回的指针应适当对齐,以便可以将其转换为具有基本对齐要求(3.11)的任何完整对象类型的指针,然后用于访问已分配存储中的对象或数组(直到存储被显式释放调用相应的释放函数)。

而 3.11 对基本对齐要求有这样的说法;

§3.11/2

基本对齐由小于或等于所有上下文中实现支持的最大对齐的对齐表示,等于alignof(std::max_align_t)

只是为了确保分配函数的行为必须像这样;

§3.7.4/3

在 C++ 程序中定义的任何分配和/或解除分配函数,包括库中的默认版本,都应符合 3.7.4.1 和 3.7.4.2 中指定的语义。

来自C++ WD n4527的引用。

假设 8 字节对齐小于平台的基本对齐(看起来确实如此,但这可以在目标平台上使用 static_assert(alignof(std::max_align_t) &gt;= 8) 进行验证)——您可以使用全局 ::operator new 来分配所需的内存。分配后,可以根据您的大小和对齐要求对内存进行分段和使用。

这里的另一种选择是std::aligned_storage,它可以让你的内存按照任何要求对齐。

typename std::aligned_storage<sizeof(T), alignof(T)>::type buffer[100];

根据问题,我在这里假设T 的大小和对齐方式均为 8。


最终内存块的外观示例(包括基本 RAII);

struct DataBlock {
    const std::size_t element_count;
    static constexpr std::size_t element_size = 8;
    void * data = nullptr;
    explicit DataBlock(size_t elements) : element_count(elements)
    {
        data = ::operator new(elements * element_size);
    }
    ~DataBlock()
    {
        ::operator delete(data);
    }
    DataBlock(DataBlock&) = delete; // no copy
    DataBlock& operator=(DataBlock&) = delete; // no assign
    // probably shouldn't move either
    DataBlock(DataBlock&&) = delete;
    DataBlock& operator=(DataBlock&&) = delete;

    template <class T>
    T* get_location(std::size_t index)
    {
        // https://stackoverflow.com/a/6449951/3747990
        // C++ WD n4527 3.9.2/4
        void* t = reinterpret_cast<void*>(reinterpret_cast<unsigned char*>(data) + index*element_size);
        // 5.2.9/13
        return static_cast<T*>(t);

        // C++ WD n4527 5.2.10/7 would allow this to be condensed
        //T* t = reinterpret_cast<T*>(reinterpret_cast<unsigned char*>(data) + index*element_size);
        //return t;
    }
};
// ....
DataBlock block(100);

我用合适的模板constructget 函数等live demo herehere with further error checking etc. 构建了DataBlock 的更详细示例。

关于别名的说明

看起来原始代码中确实存在一些别名问题(严格来说);您分配一种类型的内存并将其转换为另一种类型。

它可能在您的目标平台上按您期望的那样工作,但您不能依赖它。我见过的最实用的评论是:

"Undefined behaviour has the nasty result of usually doing what you think it should do, until it doesn’t” - hvd.

您拥有的代码可能会起作用。我认为最好使用适当的全局分配函数,并确保在分配和使用所需内存时没有未定义的行为。

别名仍然适用;一旦分配了内存 - 别名适用于它的使用方式。一旦您分配了任意内存块(如上使用全局分配函数)并且对象的生命周期开始(第 3.8/1 节) - 别名规则适用。

std::allocator 呢?

虽然std::allocator 用于同构数据容器并且您正在寻找类似于异构分配的内容,但您的标准库中的实现(鉴于Allocator concept)提供了一些关于原始内存分配和相应构造的指导需要的对象。

【讨论】:

【解决方案3】:

我会简短地说:如果您使用分配块,您的所有代码都使用定义的语义

std::unique_ptr<char[], std::free>
    mem(static_cast<char*>(std::malloc(800)));

因为

  1. 每个类型都可以使用char[]
  2. malloc() 保证为所有类型(可能除了 SIMD 类型)返回一个充分对齐的内存块。

我们将std::free 作为自定义删除器传递,因为我们使用malloc(),而不是new[],因此默认调用delete[] 将是未定义的行为。

如果你是纯粹主义者,也可以使用operator new

std::unique_ptr<char[]>
    mem(static_cast<char*>(operator new[](800)));

那么我们就不需要自定义删除器了。或者

std::unique_ptr<char[]> mem(new char[800]);

避免将static_castvoid* 转换为char*。但是operator new可以被用户替换,所以我对使用它总是有点警惕。奥托; malloc 不能被替换(只能以特定于平台的方式,例如LD_PRELOAD)。

【讨论】:

    【解决方案4】:

    如果您将此标记为 C++ 问题, (1) 为什么使用 uint64_t[] 而不是 std::vector? (2)在内存管理方面,你的代码缺乏管理逻辑,应该跟踪哪些块正在使用,哪些是空闲的,跟踪连续块,当然还有分配和释放块的方法。 (3) 代码显示了一种不安全的内存使用方式。例如,char* 不是 const,因此该块可能会被写入并覆盖下一个块。 reinterpret_cast 被认为是危险的,应该从内存用户逻辑中抽象出来。 (4) 代码没有显示分配器逻辑。在 C 世界中, malloc 函数是无类型的,而在 C++ 世界中,运算符 new 是有类型的。您应该考虑类似 new 运算符。

    【讨论】:

    • 我认为您错过了我的问题的重点。我展示的代码不是真正的应用程序级代码,它只是解释我的问题的一个例子。
    • 当然不是真正的生产代码。我评论的要点是贴出的代码离让人理解你的内存管理方法还很远,我的所有观点都是创建内存管理库的基础。是的,无类型内存块是内存管理的基础,但还有更多。
    【解决方案5】:

    C++ 和 CPU 的行为是不同的。尽管该标准提供了适用于任何对象的内存,但 CPU 强加的规则和优化使任何给定对象的对齐方式“未定义”——short 数组合理地是 2 字节对齐的,但 3 字节结构的数组可能是8 字节对齐。可以在存储和使用之间创建和使用所有可能类型的联合,以确保不会破坏对齐规则。

    union copyOut {
          char Buffer[200]; // max string length
          int16 shortVal;
          int32 intVal;
          int64 longIntVal;
          float fltVal;
          double doubleVal;
    } copyTarget;
    memcpy( copyTarget.Buffer, Block[n], sizeof( data ) );  // move from unaligned space into union
    // use copyTarget member here.
    

    【讨论】:

      【解决方案6】:

      这里讨论了很多,给出了一些答案,略有错误,但提出了很好的观点,我只是尝试总结一下:

      • 完全遵循标准的文本(无论是什么版本)......是的,这是未定义的行为。请注意,该标准甚至没有术语严格别名——只是一组规则来强制执行它,无论实现可以定义什么。

      • 了解“严格别名”规则背后的原因,它应该在任何实现上都能很好地工作只要floatdouble 占用的位都不超过 64 位。 p>

      • 该标准不会向您保证floatdouble(有意)的大小,这就是它首先限制的原因。

      • 您可以通过确保您的“堆”是一个分配的对象(例如使用malloc() 获取它)并通过char * 访问对齐的插槽并转移您的偏移 3 位。

      • 您仍然必须确保存储在此类插槽中的任何内容都不会超过 64 位。 (在便携性方面,这是最难的部分)

      简而言之:只要大小限制不成问题,您的代码在任何“健全”的实现中都应该是安全的(意味着:标题中问题的答案很可能是 ),但它仍然是未定义的行为(意味着:您最后一段的答案是

      【讨论】:

      • 我喜欢你关于区分标准和任何理智实施中发生的事情的观点。我怀疑所有实现都隐含地遵循更合理的别名定义。我怀疑我们可以预测地读取任何类型,只要:1)最近的写入是相同类型(或类型char[]),2)对齐得到尊重,3)写入不是为const 对象保留的特殊只读区域。 如果实施者确认他们这样做了,那么我们就可以将其“继承”到标准中。我认为这将是一个很大的改进。
      • 不,不幸的是它不是这样的。您的观点 1) 可能由于重新排序而失败,这首先是 strict aliasing 的原因。但是 OP 想要做的事情不应该失败,因为在他的代码中没有使用别名指针。在这种情况下,声明的类型并不重要,但是允许此类事情的标准的任何更改都将充满约束(考虑标准未定义的类型的大小),所以它可能会变得非常复杂。
      • 确实,在我对标准的(模糊)重新解释下,OP 的代码可能会以疯狂的方式重新排序,但在当前标准下它也会受到影响。但是,如果在 OP 的代码中添加了一些 memmoves(或其他形式的写入),那么它将不再遭受别名混淆(根据我的解释):Demo on ideone - 这个特定的示例代码有点混乱,但是memmoves 让编译器很明显它不能优化太多。并且 memmove “什么都不做”,因为源和目标是相同的(证明是这样,将被优化)。
      • 简单来说,C99 标准中已经有语言(是的,OP 在询问 C++,但它仍然有些相关)允许任何 有效类型对象随时更改,只需通过书写即可。这种“可变性”目前仅限于“无声明类型”的对象。我会将其扩展到任何对象(可能需要对齐)。这个简单的改变让事情有了很大的改善。我认为,它与编译器已经做的一致,所以它不应该是一个真正的问题..
      • @AaronMcDaid 通过编写简单地设置 有效类型 违背了这个概念,重新排序仍然是一个问题。但是我建议对分配的内存的第一次写访问设置有效类型,这可能不会在当前符合的实现中引入任何更改,因为它们不会通过相同类型的指针重新排序读取和写入。
      【解决方案7】:

      pc pfpd 都是访问block 中指定的内存的不同类型uint64_t,所以说'pf 共享类型是floatuint64_t

      一旦使用一种类型写入并使用另一种类型读取,就会违反严格的别名规则,因为编译时我们可以重新排序操作,认为没有共享访问。但是这不是你的情况,因为uint64_t 数组只用于赋值,它与使用alloca 分配内存完全相同。

      顺便说一句,当从任何类型转换为 char 类型时,严格的别名规则没有问题,反之亦然。这是用于数据序列化和反序列化的常见模式。

      【讨论】:

        【解决方案8】:

        是的,因为pf 指向的内存位置可能会重叠,具体取决于floatdouble 的大小。如果他们没有,那么读取*pd*pf 的结果将是明确定义的,但不是从blockpc 读取的结果。

        【讨论】:

        • 好吧,理论上你是对的,所以我不明白为什么这被否决了——但应该评论一下:pc 指针也很好,只要它被使用对于单个字符或最多 8 个字节的 char 数组或最多 7 个字符的字符串。对于floatdouble 的大小:是的,不能保证。你知道double 使用超过 64 位的实现吗?
        • @FelixPalmen 对齐,重叠,无关紧要。 “理论”指出,这些类型的别名是未定义的。句号。
        • “那些类型”在这里没有别名,只要 block 不用于访问内容。制作此 UB 的唯一问题是无法保证 floatdouble 的大小。所以,dbush 是对的,而你(部分)错了。
        • 天哪。声明无关紧要,重要的是读写。该声明不会导致任何读取或写入,它只是保留内存。不要再表现得像在幼儿园一样。
        • 让人们冷静下来。我认为我没有资格帮助解决这个特殊问题。但我可以要求人们一般要更加小心。例如,在 C99 中,人们经常将 6.5.7 解释为使一切都未定义的位。但是,上一节 6.5.6 确实有助于缩小 6.5.7 的范围。基本上,不要太快将所有内容视为违反严格别名规则而忽略,而不考虑标准的所有相关位
        猜你喜欢
        • 1970-01-01
        • 2011-03-28
        • 1970-01-01
        • 1970-01-01
        • 2011-08-29
        • 2019-01-23
        • 1970-01-01
        • 1970-01-01
        • 2013-11-09
        相关资源
        最近更新 更多