添加std :: cout行可消除内存损坏错误

时间:2019-03-30 02:01:16

标签: c++

这里我有一个类定义。它有点长,但是重点将放在move构造函数和destructor上。在类定义下面是一个简短的测试。

#include <cassert>
#include <iostream>
#include <utility>

template <typename T>
class SharedPtr {
 public:
  SharedPtr() {}

  explicit SharedPtr(T* input_pointer) : raw_ptr_(input_pointer), ref_count_(new size_t(1)) {}

  SharedPtr(const SharedPtr& other) : raw_ptr_(other.raw_ptr_), ref_count_(other.ref_count_) {
    if (ref_count_) {
      ++*ref_count_;
    }
  }

  SharedPtr(SharedPtr&& other) : raw_ptr_(other.raw_ptr_), ref_count_(other.ref_count_) {}

  SharedPtr& operator=(SharedPtr other) {
    swap(other, *this);
    return *this;
  }

  size_t use_count() const {
    return ref_count_ ? *ref_count_ : 0;
  }

  ~SharedPtr() {
    if (ref_count_) {
      --*ref_count_;
      if (*ref_count_ == 0) {
        delete raw_ptr_;
        delete ref_count_;
      }
    }
  }

 private:
  T* raw_ptr_ = nullptr;
  size_t* ref_count_ = nullptr;

  friend void swap(SharedPtr<T>& left, SharedPtr<T>& right) {
    std::swap(left.raw_ptr_, right.raw_ptr_);
    std::swap(left.ref_count_, right.ref_count_);
  }
};

int main() {

  // Pointer constructor
  {
    SharedPtr<int> p(new int(5));
    SharedPtr<int> p_move(std::move(p));
    assert(p_move.use_count() == 1);
  }

  std::cout << "All tests passed." << std::endl;

  return 0;
}

如果运行代码,则会收到一条错误消息,指出内存损坏:

*** Error in `./a.out': corrupted size vs. prev_size: 0x0000000001e3dc0f ***
======= Backtrace: =========
...

======= Memory map: ========
...

Aborted (core dumped)

我们可能会怀疑move构造函数出了点问题:如果我们从SharedPtr中移出,然后在之后破坏该SharedPtr,它仍然会被破坏,就好像它是一个“活动的” {{1 }}。因此,我们可以通过在移动构造函数中将SharedPtr对象的指针设置为other来解决此问题。

但这不是这段代码有趣的事情。有趣的是,如果我不这样做会发生什么,而是将nullptr添加到move构造函数中。

下面给出了新的move构造函数,其余代码未更改。

std::cout << "x" << std::endl;

代码现在在我的机器上运行没有错误,并产生了输出:

  SharedPtr(SharedPtr&& other) : raw_ptr_(other.raw_ptr_), ref_count_(other.ref_count_) {
    std::cout << "x" << std::endl;
  }

所以我的问题是:

  • 您得到与我相同的结果吗?
  • 为什么添加看似无害的x All tests passed. 行会导致程序“成功”运行?

请注意:我丝毫不认为错误消息消失暗示错误已经消失。

2 个答案:

答案 0 :(得分:1)

SharedPtr(SharedPtr&& other) : raw_ptr_(other.raw_ptr_), ref_count_(other.ref_count_) {}

移动时,从对象移出的对象保持不变。这意味着在程序的某个时刻,您将为相同的内存delete raw_ptr_两次。 ref_count_也是一样。这是未定义的行为。

您观察到的行为完全属于“未定义行为”,因为这就是UB的含义:该标准绝对不要求程序中的任何行为。试图理解为什么会发生这种情况,在您的特定编译器和特定平台上带有特定标志的特定版本上发生的事情……毫无意义。

答案 1 :(得分:1)

bolov's answer解释了当SharedPtr的move构造函数没有使移出的指针无效时,导致未定义行为(UB)的原因。

我不同意bolov的观点,即了解UB是毫无意义的。当面对UB时,为什么代码更改会导致不同的行为,这个问题非常有趣。知道发生了什么,一方面可以帮助调试,另一方面也可以帮助入侵者入侵系统。

有问题的代码的不同之处在于添加了std::cout << something。实际上,以下更改也使崩溃消失了:

{
    SharedPtr<int> p(new int(5));
    SharedPtr<int> p_move(std::move(p));
    assert(p_move.use_count() == 1);
    std::cout << "hi\n"; // <-- added
  } 

std::cout <<分配一些内部缓冲区,std::cout <<使用该缓冲区。 cout中的分配仅发生一次,问题是这种分配是在两次释放之前还是之后。如果没有额外的std::cout,则当堆损坏时,这种分配将在两次释放之后发生。当堆损坏时,std::cout <<中的分配会触发崩溃。但是,当双释放前有std::cout <<时,双释放后就没有分配。

让我们进行一些其他实验来验证这一假设:

  1. 删除所有std::cout <<行。一切正常。
  2. 在结束之前将两个呼叫移至new int(some number)

    int main() {
      int *p2 = nullptr;
      int *cnt = nullptr;
      // Pointer constructor
      {
        SharedPtr<int> p(new int(5));
        SharedPtr<int> p_move(std::move(p));
        assert(p_move.use_count() == 1);
      }
      p2 = new int(100);
      cnt = new int(1); // <--- crash
      return 0;
    }
    

    由于在损坏的堆上尝试了new,因此崩溃。

    you can try it out here

  3. 现在将两行new移动到稍微向上的位置,就在内部块的}关闭之前。在这种情况下,new是在堆损坏之前执行的,因此不会触发崩溃。 delete只是将数据放入空闲列表中,该列表没有损坏。只要不触碰损坏的堆,一切都会正常进行。可以调用new int,并获得最新发布的指针之一的指针,然后再没有不好的事情发生。

     {
        SharedPtr<int> p(new int(5));
        SharedPtr<int> p_move(std::move(p));
        assert(p_move.use_count() == 1);
        p2 = new int(100);
        cnt = new int(1);
      }
      delete p2;
      delete cnt;
      p2 = new int(100); // No crash. We are reusing one of the released blocks
      cnt = new int(1);
    

    you can try it out here

    一个有趣的事实是,损坏的堆可以在代码的后面被发现。计算机可能运行数百万条不相关的代码行,并突然崩溃在代码完全不同的部分中的完全不相关的new上。这就是为什么需要valgrind之类的消毒剂之类的东西:调试内存损坏实际上是不可能进行调试的。

现在,真正有趣的问题是“ 除了拒绝服务之外,还可以利用它更多?”。是的,它可以。它取决于两次被销毁的对象的类型以及它在析构函数中的作用。它还取决于第一次破坏指针与释放第二次指针之间发生的情况。在这个琐碎的示例中,似乎没有什么实质性的可能。