realloc()、生命周期和 UB

realloc(), lifetime and UB

最近有一个 CppCon2016 演讲 My Little Optimizer: Undefined Behavior is Magic,其中显示了以下代码(演讲进行了 26 分钟)。我美化了一下:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
  int* p = malloc(sizeof(int));
  int* q = realloc(p, sizeof(int));
  *p = 1;
  *q = 2;
  if (p == q)
  {
    printf("%d %d\n", *p, *q);
  }
  return 0;
}

该代码具有未定义的行为(即使 realloc() returns 相同的指针,p 在 realloc() 之后变得无效)并且在编译时可能不仅打印“2 2”,而且打印“1 2” .

稍微修改一下代码怎么样?:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

int main(void)
{
  int* p = malloc(sizeof(int));
  uintptr_t ap = (uintptr_t)p;
  int* q = realloc(p, sizeof(int));
  *(int*)ap = 1;
  *q = 2;
  if ((int*)ap == q)
  {
    printf("%d %d\n", *(int*)ap, *q);
  }
  return 0;
}

为什么我仍然可以打印“1 2”?整数变量 ap 是否也以某种方式变得无效或 "tainted"?如果是这样,这里的逻辑是什么? ap不应该从p变成"decoupled"吗?

P.S。添加了 C++ 标签。此代码可以简单地重写为 C++,同样的问题也适用于 C++。我对 C 和 C++ 都感兴趣。

如前所述,在 C 中,代码具有未定义的行为,因为 realloc 可能 return 不同的内存块。在这种情况下,*(int *)ap 将形成一个无效指针。

一个更有趣的问题是如果我们更改代码以使其仅在 realloc 未更改块时才尝试继续:

int* p = malloc(sizeof(int));
uintptr_t ap = (uintptr_t)p;
int* q = realloc(p, sizeof(int));

if ( (uintptr_t)q == ap )
{
    *(int*)ap = 1;
    // ...
}

对于 C2X,有 a proposal N2090 在通过整数类型传递时指定 指针出处

在当前的 C 标准中,有一些与指针出处相关的规则,但它没有说明当指针通过整数类型来回传递时出处会发生什么。

根据这个提案,我的代码仍然是未定义的行为:ap 获得与 p 相同的出处令牌,当块被释放时它变成无效令牌。 (int *)ap 然后使用了来源无效的指针。

该提案旨在通过 uintptr_t 等中间操作避免指针出处 "hacked around" 等。在这种情况下,它指定 (int *)ap 具有与 p 完全相同的行为。 (即使块没有移动也是未定义的,因为 prealloc 之后的无效指针,无论它是否物理移动了块)。在 C 抽象机中,意图是无法判断该块是否由 realloc 移动。

指针出处的背景

"Pointer provenance"表示指针值和它们指向的内存块之间的关联。如果指针值指向一个对象,则从该值派生的其他指针值(例如通过指针算法)必须位于该对象的范围内。

(当然,指针 变量 可能会被重新分配以指向不同的对象 - 从而获得新的出处 - 这不是我们正在谈论的)。

这不是出现在已编译的可执行文件中的东西,而是编译器在编译期间可能会跟踪的东西,以便执行优化。具有不同来源的两个指针可能具有相同的内存表示(例如,pq 在实现使用相同物理内存块的情况下)。

一个简单的例子说明了为什么指针出处提供了有用的优化机会:

char p[8];
int q = 5;

*(p+10) = 123;
printf("%d\n", q);

出处的想法允许优化器在代码 p + 10 上注册未定义的行为,因此它可以将此代码段转换为 puts("5"),例如,即使 q 恰好立即在记忆中跟随p。 (另外 - 我想知道 DJ Bernstein 的 boringcc 编译器是否真的无法执行此优化)。

有关指针边界检查的现有规则 (C11 6.5.6/8) 确实已经涵盖了这种情况,但在更复杂的情况下它们还不清楚,因此提出了 N2090 提案。例如,if ( p + 8 == (void *)&q ) *(char *)((uintptr_t)p + 10) = 123; 在 N2090 下仍然是未定义的行为。

Why can I still get "1 2" printed?

出于与原始代码相同的原因,优化器知道 *p 无效并在该假设下向后工作。为什么无效?因为...

J.2 Undefined behavior

1 The behavior is undefined in the following circumstances:

The value of a pointer that refers to space deallocated by a call to the free or realloc function is used (7.20.3).

*p 无效。优化器知道 *(int*)ap 实际上是 *p 所以它也是无效的。

使用 clang -S -O3 -emit-llvm 查看原始代码和您的代码的 IR,它们几乎完全相同。 12 都硬编码到 printf.

%7 = tail call i32 (i8*, ...) @printf(i8* nonnull getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 1, i32 2)

这是你的 main IR。

define i32 @main() #0 {
  %1 = tail call i8* @malloc(i64 4)
  %2 = tail call i8* @realloc(i8* %1, i64 4)
  %3 = bitcast i8* %2 to i32*
  %4 = bitcast i8* %1 to i32*
  store i32 1, i32* %4, align 4, !tbaa !2
  store i32 2, i32* %3, align 4, !tbaa !2
  %5 = icmp eq i8* %1, %2
  br i1 %5, label %6, label %8

; <label>:6                                       ; preds = %0
  %7 = tail call i32 (i8*, ...) @printf(i8* nonnull getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 1, i32 2)
  br label %8

; <label>:8                                       ; preds = %6, %0
  ret i32 0
}

除了尾调用和位广播的顺序不同外,几乎完全一样。这是你的。

  %1 = tail call i8* @malloc(i64 4)
  %2 = tail call i8* @realloc(i8* %1, i64 4)
  %3 = bitcast i8* %2 to i32*
  %4 = bitcast i8* %1 to i32*

这是他们的。

  %1 = tail call i8* @malloc(i64 4)
  %2 = bitcast i8* %1 to i32*
  %3 = tail call i8* @realloc(i8* %1, i64 4)
  %4 = bitcast i8* %3 to i32*

其他都一样。


正如演讲中提到的,if 是一个转移注意力的问题。这只是为了说明这种行为显然是多么荒谬。它仍然存在于 IR 中。

  %5 = icmp eq i8* %1, %2
  br i1 %5, label %6, label %8

如果打印出 pq(int*)ap,您可以看到优化器在工作。它们都是第一个 malloc 的结果。

  %1 = tail call i8* @malloc(i64 4)
  ...
  %8 = tail call i32 (i8*, ...) @printf(i8* nonnull getelementptr inbounds ([10 x i8], [10 x i8]* @.str.1, i64 0, i64 0), i8* %1, i8* %1, i8* %1)

指针没问题。这就是问题所在。

最新的 C 标准使这个问题变得模棱两可。 N2090 states that the DR260 委员会回应

has not been incorporated in the standard text, and it also leaves many specific questions unclear...

因此,假设实际上存在未定义的行为是合理的,即使它没有明确记录在适当的标准中。

原始问题中给出的代码调用未定义行为,因此编译器有权做任何它想做的事。下面给出了这种形式的未定义行为的一些背景。

Clang 会表现得很奇怪,但是,给定与您的代码相似但不调用未定义行为的代码。除非有人认为标准中的某些语言毫无意义,否则 clang 在这方面似乎是不符合要求的。有些人想更改标准以使以下调用 UB,从而证明 clang 的行为是合理的,但我认为此类提议从根本上是错误的。

#include <stddef.h>
#include <stdlib.h>
#include <stdint.h>

uintptr_t gap,gaq;
int test(void)
{
  int x=0;
  uint8_t *p = calloc(4,1);
  uintptr_t ap = (uintptr_t)p;
  uint8_t *q = realloc(p,4);
  // p is no longer valid after this, but ap still holds some number.
  uintptr_t aq = (uintptr_t)q;
  *q=1;
  if (ap == aq)
  {
    x=256;
    // Nothing in the Standard would say that the result of casting a
    // uintptr_t to a pointer is affected by anything other than the
    // numerical value of the uintptr_t in question.  If aq happened
    // to equal e.g. 8675309, then casting any expression equal to
    // 8675309 into an int* should yield the same value as casting aq;
    // since were here, we'd know that ap was also equal to 8675309, and
    // thus that (int*)ap is equivalent to (int*)aq.
    *(uint8_t*)ap = 123;
  }
  gap=ap;
  gaq=aq;
  return *q+x;
}

Clang 3.9.0 在带有选项 -xc -O3 -pedantic 的 Godbolt 上调用生成的代码将 return 1 或 257 取决于 apaq 比较是否相等,甚至尽管本标准中的任何内容都不允许 ap 与恰好具有相同值的 uintptr_t 类型的任何其他变量有任何区别。代码的编写方式,因为没有任何外部代码有权观察 p,编译器可以生成将 ap 设置为不等于 [ 的任意值的代码=14=] 然后完全忽略比较,但标准中没有任何内容允许实现除了将相同的值写入 gapgaq 和 return 379 (123 +256) 或将不同的值写入 gapgaq 和 return 1.

将无效指针与有效指针进行比较的 UB-ness 背景

在某些处理器上,尝试将指针加载到寄存器中会导致处理器对其有效性进行一些验证。例如,在 80286 上,每个指针都包含一个段选择器和一个偏移量,加载段选择器将导致处理器从 table 个有效段中获取一些信息。

一些 C 实现会在对它们进行 任何事情 时将指针加载到寄存器中,无论它们是否将用于访问内存,80286 的一些 C 实现可能会失效如果相应段中唯一的东西是已释放的内存块,则为段描述符。 C 标准的作者不希望要求 C 实现在指针未取消引用的情况下花费大量精力避免寄存器加载,也不希望要求实现为已释放的指针维护有效的段描述符。施加任一要求的最简单方法是在代码执行任何可以释放指针然后执行任何可能导致指针加载到寄存器的情况下避免要求任何内容。

有许多实现将指针加载到寄存器中是安全的,即使由此占用的存储空间已被释放,或者在指针不会被释放的情况下可以避免 "trappable" 寄存器加载取消引用(将指针加载到通用寄存器中进行比较比将它们加载到段寄存器中并将它们传输到通用寄存器中进行比较更便宜),我认为没有理由相信该标准的作者打算使用目标代码专门实现上述任何一个都不能利用以下技术的实现:

void do_realloc(int new_size)
{
  void *new_ptr = realloc(old_ptr, new_size);
  if (!new_ptr) fatal_error;
  if (new_ptr != old_pointer)
    update_pointers();
}

在 realloc 很可能成功的情况下 "in-place"(例如 因为块正在缩小)以及可能的地方——但是 昂贵——重新生成指向已分配存储中的事物的指针 对象最终被移动了。尽管如此,因为标准没有 即使在以下情况下,也需要任何实现来支持此类技术 这样做不会花费任何成本,一些实现(即使是那些 支持是免费的)竭尽全力不提供支持。