理解C++指针生命周期 / 僵尸指针

4

我观看了CppCons 您的代码能否经受住指针攻击的考验?,关于指针的生命周期我有些困惑,希望能得到一些澄清。

首先,这里有一些基本理解,请纠正我如果有任何错误:

int* p = new int(1);
int* q = p;
// 1) p and q are valid and one can do *p, *q
delete q;
// 2) both are invalid and cannot be dereferenced. Also the value of both is unspecified
q = new int(42); // assume it returns the same memory
// 3) Both pointers are valid again and can be dereferenced

我对第二个问题感到困惑。显然它们无法被解引用,但为什么它们的值不能被使用(例如,将一个与另一个进行比较,即使它们是不相关和有效的指针?)这在25:38左右说明了。我在cppreference上找不到任何关于此的内容,而我从那里得到了第三个问题。
注意:不能将返回相同内存的假设概括为可能或不可能发生。对于这个例子,应该认为它“随机”返回了与视频示例中相同的内存,并且(也许?)需要下面的代码才能破坏。
LIFO列表中的多线程示例代码可以模拟为单个线程:
Node* top = ... //NodeA allocated before;
Node* newnodeC = new Node(v); 
newnodeC->next = top;
delete top; top = nullptr;
// newnodeC->next is a zombie pointer
Node* newnodeD = new Node(u); // assume same memory as NodeA is returned
top = newnodeD;
if(top == newnodeC->next) // true
  top = newnodeC;
// Now top->next is (still) a zombie pointer

这应该是有效的,除非 Node 包含了非静态的 const 成员或者引用,根据下面的规则:

如果在原来的对象所占用的地址上创建了一个新对象,则所有指针、引用和原始对象的名称都会自动引用新对象,并且一旦新对象的生命周期开始,它们就可以用于操作新对象,但前提是满足以下条件[它们被满足了]。

那么为什么这是一种僵尸指针并且被认为是未定义行为呢?

如果有 const 成员,那么上述简化代码(单线程)是否可以通过 newnodeC->next = std::launder(newnodeC->next) 来修复问题呢?如下所示:

如果不满足上述列出的条件,则仍然可以通过应用指针优化障碍 std::launder 来获得对新对象的有效指针

我希望这可以修复 "zombie pointer",编译器不会发出指令进行赋值,而只是将其视为优化障碍(例如当函数内联并再次访问 const 成员时)

总之,我以前没有听说过“僵尸指针”。 我正确地认为,除非重新分配指向销毁/删除对象的任何指针(用于读取[指针值]和取消引用[读取指针所指的内容])或者使用相同对象类型重新创建内存(并且没有const /引用成员),否则不能使用它们吗?这不是可以通过C++17的std::launder来解决了吗?(除了多线程问题)

另外:在第一个代码中的3)处,if(p==q)是否普遍有效?因为根据我对视频的理解,读取p是无效的。

编辑:作为我几乎确定会发生UB的解释:再次假设纯粹的机会使new返回相同的内存:

// Global
struct Node{
  const int value;
};
Node* globalPtr = nullptr;
// In some func
Node* ptr = new Node{42};
globalPtr = ptr;
const int value = ptr->value;
foo(value);
// Possibly on another thread (if multi-threaded assume proper synchronisation so that this scenario happens)
delete globalPtr;
globalPtr = new Node{1337}; // Assume same memory returned
// First thread again (and maybe on serial code too)
if(ptr == globalPtr)
  foo(ptr->value);
else
  foo(globalPtr->value);

根据视频,经过delete globalPtr后,ptr也是一个“僵尸指针”,不能使用(也就是“UB”)。一个足够优化的编译器可以利用这一点,并假定指向对象从未被释放(特别是当删除/新建发生在另一个函数/线程时),并将foo(ptr->value)优化为foo(42)
同时注意缺陷报告260:

当指向的对象到达其生命周期的末尾时,指针值变得不确定时,所有有效类型为指针且指向同一对象的对象都会获得不确定的值。因此,在X点的p,以及在Z点的p,q和r都可以改变它们的值。

我认为这是最终的解释:经过delete globalPtr之后,ptr的值也是不确定的。但是这如何与...相符呢?
如果在原本已被占用的地址上创建了一个新对象,那么原对象的所有指针[...]将自动引用新对象,并且[...]可以用于操作新对象。

“假设它返回相同的内存” - 你可以添加:“3)if(p==q)两个指针再次有效,可以进行解引用,对吗?” - Ted Lyngmo
1
视频示例中的假设是,返回相同的指针值,即相同的内存,以便if条件成立。对于简单地查看是否可以使用最近释放的内存块来处理请求的分配器而言,这很容易发生。关于if(p==q):问题是:我可以这样做吗?由p指向的对象已被删除,因此p是“僵尸指针”。我真的建议(至少)看视频的前半部分。非常有趣 :) - Flamefire
2
(3)是错误的。它使q可引用,但不是p - HolyBlackCat
1
因为new返回新创建对象的地址,这个地址不保证与之前被删除的对象相同。所以在第二次new之后,pq不能被认为指向内存中的同一位置。关于更多解释,请参见我的答案。 - Fareanor
// 假设它返回相同的内存 你将如何定义“返回相同的内存”是什么意思?你将如何检查它? - Language Lawyer
显示剩余8条评论
3个回答

3

一个已删除的指针值具有无效的指针值,而不是未指定的值。

从C++17开始,无效指针值的行为在[basic.stc] / 4中得到定义:

通过无效指针值进行间接寻址并将无效指针值传递给解除分配函数会导致未定义的行为。对无效指针值的任何其他使用具有实现定义的行为。

因此,尝试比较两个无效指针具有实现定义的行为。有一个脚注澄清了这种行为可能包括运行时故障。

在您的第一个片段中,delete后p具有无效指针值;对q所做的任何事情都与此无关。无效指针值不能自动变为有效。在标准C++的范围内,没有办法确定新分配是否位于先前分配的“相同位置”。

std::launder也没有帮助;再次使用无效指针值,因此触发实现定义的行为。

也许您可以查阅实现文档,看看它如何定义此行为。

在您的问题中,您提到C DR 260,但这与本题无关。 C是一种不同于C ++的语言。在C ++中,已删除的指针具有无效的指针值,而不是未确定的值。


好的,正确的措辞是“无效指针值”,而不是“未指定的值”。然而,由于任何使用都是未定义行为或实现定义,因此这种差异并不真正重要。(或者有没有地方规定了“无效指针值”是哪个值?)“没有办法确定新分配是否在“相同位置”。”哎呀,这太疯狂了。现在,对对象(指针)的副本进行操作会对另一个对象/值的副本产生副作用,可能使其无法使用。试着向初学者/中级程序员解释这个问题。这就像`a = b; b -= c; //a可能无效 - Flamefire
@Flamefire "无效指针值"是指哪个值。UB和实现定义行为之间的区别很大。C++的奥秘可能很深,尽管在这种情况下可能不是;如果我复制一个文件句柄并关闭它,原始句柄不再有用是否神秘?例如,原始句柄可能与我们从打开不同文件获得的新句柄匹配。 - M.M
想象一个系统,其中硬件会自动检查任何指针寄存器与MMU的活动分配列表进行比对。 - M.M
很容易理解无效的句柄/指针不能被间接引用,但它的值不能再被使用是困难的。文件句柄类比很棒。尽管可以重新打开并指向(另一个或同一)文件,但我无法想出一个要保留可能由别人关闭的文件句柄的用例。 任何指针寄存器都要根据MMU的活动分配列表来注册,那么分配可能会再次激活。它只包含其他内容。 - Flamefire

1

从问题描述开始,已经分配了int* p和:

int* q = p;
q = new int(42); // assume it returns the same memory

我们现在有一个与此完全相同的情景:
int* p = new int(42);
int* q = p;

因为满足我们可以假设 pq 指向相同的内存位置的前提条件,所以这个位置被分配了某些内容然后被删除,然后再次分配就无关紧要了。在这一点之前发生的任何事情都不重要,因为我们假设两个指针的状态与刚才描述的状态相同。
关于步骤#2中“两者的值未指定”,我会说此时q的值是未指定的,因为它已传递给delete,但p的值未更改。
在C++14下,这里的delete行为实际上并非未定义,而是实现定义;来自delete文档的一些位:
任何使用一个以这种方式变得无效的指针,即使将指针值复制到另一个变量中,都是未定义的行为。(直到C++14)
通过以这种方式变得无效的指针进行间接引用并将其传递给解分配函数(双重删除)是未定义的行为。任何其他用途都是实现定义的。(自C++14起)

https://en.cppreference.com/w/cpp/memory/new/operator_delete

因此,回答我认为你的问题,在那种情况下,两者都不是僵尸指针。

那么为什么这是一个僵尸指针,而且据说是UB?

它不是,所以任何导致你得出那个结论的原因都是误解或错误信息。


2
我认为你过于假设他们“依赖”任何功能。问题仅仅是“如果它返回相同的虚拟地址会怎样”。这被表述为“假设它返回相同的内存”-意思是“在这种情况下,请考虑发生了什么”。作者明确承认,这并不意味着我们通常可以假设将返回相同的虚拟地址。就像说“抛硬币并假设它是正面”。我们被要求探索“正面”的情况,而不是假设硬币总是会正面朝上。 - Wyck
没错,这里(指头部情况)是答案的后半部分,它断言两个这样的指针是如何产生的并不重要,因为它们与某些非常规范的东西是相同的。 - CodeClown42
这并不是事实,所以任何导致你得出这个结论的原因都是误解或错误信息。这源于链接视频的第一部分。分配会返回相同的指针,因此在之前获取的指针的副本仍将被使用,并称为“僵尸指针”,因为其指向已删除。我认为对于指针常量成员的情况,这实际上是未定义行为:编译器可能会“缓存”常量成员的值,但新分配的实例可能会有另一个值。我会把这个问题放到问题中。 - Flamefire
你的第一个代码片段没有意义,因为p未定义。此外,你提到了p2,但是没有在任何地方定义它。 - M.M
从一个巧合开始...p和q均来源于原问题。问题中的巧合是“编译器已经这样做了”。不管它们是否根据语言的明确规则(如上例中的第二种情况)或者是像问题中所断言的那样发生偶然事件,它们都是指向同一内存位置的相同类型的两个指针。我觉得用pp2来指代它们有点令人困惑,我会改变这一点(谢谢)。 - CodeClown42

1
我不同意这个观点:

// 3) 两个指针都有效,可以进行解引用操作

事实上,你被愚弄了,因为在第二个new时,程序通常会重新分配相同的内存块,但是不能依赖于此(不能保证将重新使用相同的内存块)。
例如,如果您使用良好的做法将已删除的指针设置为nullptr,则以下程序:
int main()
{
    int * p = new int(1);
    int * q = p;

    std::cout << (p == q) << std::endl;

    delete q;
    q = nullptr;
    p = nullptr;

    q = new int(42);

    std::cout << (p == q) << std::endl;

    delete q;

    return 0;
}

会得到以下结果:

1
0


问题中没有明确说明,但在评论中已经明确指出假设new在两种情况下返回相同的指针。 - Max Langhof
不能保证相同的内存块会被重复使用,对吧。这个例子假设在这种情况下使用相同的内存块,就像视频中一样。我会更新问题以澄清这一点。 - Flamefire
@MaxLanghof 哦,好的,那是我的错,我确实没有理解。 - Fareanor

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