【问题标题】:Does the pointer arithmetic in this usage cause undefined behavior这种用法中的指针算术是否会导致未定义的行为
【发布时间】:2021-05-07 10:47:32
【问题描述】:

这是对following question 的跟进。我假设我最初使用的指针算法会导致未定义的行为。但是,一位同事告诉我,该用法实际上已明确定义。下面是一个简化的例子:

typedef struct StructA {
    int a;
} StructA ;

typedef struct StructB {
    StructA a;
    StructA* b;
} StructB;

int main() {
    StructB* original = (StructB*)malloc(sizeof(StructB));
    original->a.a = 5;
    original->b = &original->a;

    StructB* copy = (StructB*)malloc(sizeof(StructB));
    memcpy(copy, original, sizeof(StructB));
    free(original);
    ptrdiff_t offset = (char*)copy - (char*)original;
    StructA* a = (StructA*)((char*)(copy->b) + offset);
    printf("%i\n", a->a);
    free(copy)
}

根据 C++11 规范的§5.7 ¶5:

如果指针操作数和结果都指向同一个数组对象的元素,或者超过数组对象的最后一个元素,则计算不应产生溢出;否则,行为未定义。

我假设,以下部分代码:

ptrdiff_t offset = (char*)copy - (char*)original;
StructA* a = (StructA*)((char*)(copy->b) + offset);

导致未定义的行为,因为它:

  1. 减去两个指向不同数组的指针
  2. 偏移量计算的结果指针不再指向同一个数组。

这会导致未定义的行为,还是我误解了 C++ 规范?这同样适用于 C 语言吗?

编辑:

根据 cmets,我假设以下修改仍然是未定义的行为,因为生命周期结束后对象的使用:

ptrdiff_t offset = (char*)(copy->b) - (char*)original;
StructA* a = (StructA*)((char*)copy + offset);

在使用索引时是否会定义它:

typedef struct StructB {
    StructA a;
    ptrdiff_t b_offset;
} StructB;

int main() {
    StructB* original = (StructB*)malloc(sizeof(StructB));
    original->a.a = 5;
    original->b_offset = (char*)&(original->a) -  (char*)original

    StructB* copy = (StructB*)malloc(sizeof(StructB));
    memcpy(copy, original, sizeof(StructB));
    free(original);
    StructA* a = (StructA*)((char*)copy + copy->b_offset);
    printf("%i\n", a->a);
    free(copy);
}

【问题讨论】:

  • 你的假设是正确的,因为你提到的确切原因。而且,是的,C 也一样。
  • 另外,在 malloc 的位置没有 StructB 对象。您需要使用placement new 创建一个。
  • @maxbachmann 不,您分配了内存但没有创建对象
  • 停止使用那个同事,换一个新的。
  • @Jean-BaptisteYunès:这不是一个很好的推理基础。很常见的是,可以保证分配的差异是结构大小的倍数:结构的大小是 8 字节(两个 4 字节成员)或 16(指针是 8 字节,八字节对齐) ),而malloc 返回的地址是 16 的倍数。要断定行为是未定义的,观察original 在它指向的内存被释放后是无效的就足够了,而且,减法不在指针之间进入同一个数组。

标签: c++ c undefined-behavior


【解决方案1】:

这是未定义的行为,因为对指针运算可以完成的操作有严格的限制。您所做的修改和建议的修改无法解决此问题。

另外的未定义行为

StructA* a = (StructA*)((char*)copy + offset);

首先,由于添加到copy,这是未定义的行为:

当一个整数类型的表达式 J 被添加到一个指针类型的表达式 P 中或从一个表达式 P 中减去时,结果具有 P 的类型。

  • (4.1) 如果 P 计算结果为空指针值且 J 计算结果为 0,则结​​果为空指针值。
  • (4.2) 否则,如果 P 指向具有 n 个元素的数组对象 x 的数组元素 i ([dcl.array]),则表达式 P + J 和 J + P(其中 J 的值为 j)指向如果 0 ≤ i + j ≤ n,则 x 的(可能是假设的)数组元素 i+j 并且表达式 P - J 指向如果 0 ≤ i - j ≤ n 的 x 的(可能是假设的)数组元素 i - j。
  • (4.3) 否则,行为未定义。

https://eel.is/c++draft/expr.add#4

简而言之,对非数组和非空指针执行指针运算始终是未定义的行为。即使copy 或其成员是数组,添加到指针上使其变为:

  • 数组末尾两个或多个
  • 在第一个元素之前至少有一个

也是未定义的行为。

减法中的未定义行为

ptrdiff_t offset = (char*)original - (char*)(copy->b);

你的两个指针的减法也是未定义的行为:

当两个指针表达式P和Q相减时,结果的类型是实现定义的有符号整数类型; [...]

  • (5.1) 如果 P 和 Q 都计算为空指针值,则结果为 0。
  • (5.2) 否则,如果 P 和 Q 分别指向同一个数组对象 x 的数组元素 i 和 j,则表达式 P - Q 的值为 i - j。
  • (5.3) 否则,行为未定义。

https://eel.is/c++draft/expr.add#5

因此,当它们不是空指针或指向同一数组元素的指针时,相互减去指针是未定义的行为。

C 中的未定义行为

C 标准也有类似的限制:

(8) [...] 如果指针操作数指向一个元素 一个数组对象,并且数组足够大,结果指向一个元素的偏移量 原始元素使得结果和原始的下标的差异 数组元素等于整数表达式。

(标准没有提到非数组指针添加会发生什么)

(9) 当两个指针相减时,都指向同一个数组对象的元素, 或者数组对象的最后一个元素; [...]

请参阅C11 standard (n1570) 中的 §6.5.6 加法运算符。

改用数据成员指针

在 C++ 中一个干净且类型安全的解决方案是使用数据成员指针。

typedef struct StructB {
    StructA a;
    StructA StructB::*b_offset;
} StructB;

int main() {
    StructB* original = (StructB*) malloc(sizeof(StructB));
    original->a.a = 5;
    original->b_offset = &StructB::a;

    StructB* copy = (StructB*) malloc(sizeof(StructB));
    memcpy(copy, original, sizeof(StructB));
    free(original);
    printf("%i\n", (copy->*(copy->b_offset)).a);
    free(copy);
}

注意事项

标准引用来自 C++ 草案。您引用的 C++11 似乎对指针运算没有任何更宽松的限制,只是格式不同。见C++11 standard (n3337)

【讨论】:

  • 据我了解,您回答这意味着即使是我最后一种存储偏移量的方法也会导致未定义的行为。有没有办法在不复制结构和更新所有指针的情况下实现这一点?
  • @maxbachmann 您可以在 C++ 中使用数据成员指针来避免所有指针运算。
【解决方案2】:

该标准明确规定,在它表征为未定义行为的情况下,实现可以“以环境的文档化方式特征”运行。根据基本原理,这种特征化的目的除其他外,是为了确定“符合语言扩展”的途径;何时实现支持这种“流行的扩展”的问题是最好留给市场的实现质量问题。

许多旨在和/或配置用于普通平台上的低级编程的实现通过指定以下等价来扩展语言,对于任何类型为T* 的指针pq 和整数表达式i

  • p(uintptr_t)p(intptr_t)p 的位模式相同。
  • p+i 等价于 (T*)((uintptr_t)p + (uintptr_t)i * sizeof (T))
  • p-i 等价于 (T*)((uintptr_t)p - (uintptr_t)i * sizeof (T))
  • 在除法没有余数的所有情况下,p-q 等价于 ((uintptr_t)p - (uintptr_t)q) / sizeof (T)
  • p>q 等价于(uintptr_t)p > (uintptr_t)q,同样适用于所有其他关系和比较运算符。

标准不承认任何类别的实现始终支持这些等价性,有别于那些不支持的实现,部分原因是他们不希望将这种支持等价性不切实际的不寻常平台描述为“劣质”实现.相反,它希望在有意义的实现上支持此类实现,并且程序员会知道他们何时瞄准此类实现。为 68000 或小型 8086(自然会存在这种等价性)编写内存管理代码的人可以编写可在这些等价性成立的其他系统上互换运行的内存管理代码,但有人为大模型 8086 需要为该平台明确设计它,因为这些等价不成立(指针是 32 位,但单个对象限制为 65520 字节,大多数指针操作仅作用于指针的底部 16 位)。

不幸的是,即使在此类等价通常成立的平台上,某些类型的优化也可能产生与这些等价所暗示的行为不同的极端情况行为。商业编译器通常秉承 C 精神原则“不要阻止程序员做需要做的事情”,即使启用了大多数优化,也可以配置为支持等价性。然而,gcc 和 clang C 编译器不允许对语义进行这种控制。当所有优化都被禁用时,它们将在普通平台上支持这些等效性,但没有其他优化设置可以阻止它们做出与它们不一致的推论。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2019-09-26
    • 2018-08-19
    • 2019-08-02
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-08-12
    相关资源
    最近更新 更多