结构破坏中的奇怪行为

Weird behavior in struct destruction

我正在尝试将结构写入文件并读回。这样做的代码在这里:

#include <fstream>
#include <iostream>
#include <cstring>

using namespace std;

struct info {
  int id;
  string name;
};

int main(void) {
  info adam;
  adam.id = 50;
  adam.name = "adam";

  ofstream file("student_info.dat", ios::binary);
  file.write((char*)&adam, sizeof(info));
  file.close();

  info student;
  ifstream file2("student_info.dat", ios::binary);
  file2.read((char*)&student, sizeof(student));
  cout << "ID =" << student.id << " Name = " << student.name << endl;

  file2.close();
  return 0;
}

但是最后我遇到了一个奇怪的分段错误。

输出是:

ID =50 Name = adam
Segmentation fault (core dumped)

在查看核心转储时,我发现在结构信息的破坏过程中发生了一些奇怪的事情。

(gdb) bt
#0  0x00007f035330595c in ?? ()
#1  0x00000000004014d8 in info::~info() () at binio.cc:7
#2  0x00000000004013c9 in main () at binio.cc:21

我怀疑字符串破坏过程中发生了一些奇怪的事情,但我无法找出确切的问题。任何帮助都会很棒。

我使用的是 gcc 8.2.0.

你不能这样serialize/deserialize。在这条线上:

file2.read((char*)&student, sizeof(student));

您只是在 info 的实例上写入 1:1,其中包括 std::string。这些不仅仅是字符数组——它们在堆上动态分配存储空间并使用指针进行管理。因此,如果您这样覆盖它,该字符串将变得无效,这是未定义的行为,因为它的指针不再指向有效位置。

相反,您应该保存实际的字符,而不是字符串对象,并在加载时创建一个包含该内容的新字符串。


一般情况下,您可以对琐碎的对象进行这样的复制。你可以这样测试:

std::cout << std::is_trivially_copyable<std::string>::value << '\n';

简单地从概念上思考,当adam.name = "adam";完成后,在内部为adam.name分配了适当的内存。

file2.read((char*)&student, sizeof(student)); 完成后,您正在写入内存位置,即地址 &student 尚未正确分配以容纳正在读取的数据。 student.adam 没有分配足够的有效内存。将 read 放入 student 对象的位置实际上会导致内存损坏。

添加到已接受的答案中,因为提问者仍然对"why does it crash on the deletion of the first object?"感到困惑:

让我们看看反汇编,因为它不能说谎,即使面对展示 UB 的错误程序(与调试器不同)。

https://godbolt.org/z/pstZu5

(请注意,rsp - 我们的堆栈指针 - 除了在 main 的开头和结尾进行调整外,从未更改过)。

下面是adam的初始化:

    lea     rax, [rsp+24]
    // ...
    mov     QWORD PTR [rsp+16], 0
    mov     QWORD PTR [rsp+8], rax
    mov     BYTE PTR [rsp+24], 0

似乎 [rsp+16][rsp+24] 保存字符串的大小和容量,而 [rsp+8] 保存指向内部缓冲区的指针。该指针设置为指向字符串对象本身。

然后adam.name"adam"覆盖:

   call    std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_replace(unsigned long, unsigned long, char const*, unsigned long)

由于小字符串优化,[rsp+8] 处的缓冲区指针可能仍然指向同一个地方(rsp+24)以指示我们有一个小缓冲区且没有内存分配的字符串(那是我的猜测很清楚)。

稍后我们以相同的方式初始化 student

    lea     rax, [rsp+72]
    // ...
    mov     QWORD PTR [rsp+64], 0
    // ...
    mov     QWORD PTR [rsp+56], rax
    mov     BYTE PTR [rsp+72], 0

注意 student 的缓冲区指针如何指向 student 以表示一个小缓冲区。

现在你粗暴地将 student 的内部结构替换为 adam 的内部结构。突然,student 的缓冲区指针不再指向预期的位置。有问题吗?

    mov     rdi, QWORD PTR [rsp+56]
    lea     rax, [rsp+72]
    cmp     rdi, rax
    je      .L90
    call    operator delete(void*)

是的!如果 student 的内部缓冲区指向 任何其他地方 而不是我们最初将其设置为 (rsp+72) 的位置,它将 delete 该指针。在这一点上我们不知道 adam 的缓冲区指针(你复制到 student 中)究竟指向哪里,但它肯定是错误的地方。如上所述,"adam" 可能仍被小字符串优化覆盖,因此 adam 的缓冲区指针可能与之前完全相同的位置:rsp+24。由于我们将其复制到 student 并且它不同于 rsp+72,因此我们调用 delete(rsp+24) - 它位于我们自己的堆栈的中间。环境并不认为这很有趣,你在那里遇到了段错误,在第一次重新分配时(第二次甚至 delete 什么都没有,因为那里的世界仍然很好 - adam没有被你伤害)。


底线:不要试图智取编译器 ("it can't segfault because it'll be on the same heap!")。你会输的。遵守语言规则,没有人会受到伤害。 ;)

旁注:gcc 中的这种设计甚至可能是故意的。我相信他们可以很容易地存储一个 nullptr 而不是指向字符串对象来表示一个小的字符串缓冲区。但在那种情况下,你不会因为这种不当行为而出现段错误。