【问题标题】:Is adding to a "char *" pointer UB, when it doesn't actually point to a char array?是否添加到“char *”指针 UB,但它实际上并不指向 char 数组?
【发布时间】:2018-05-09 23:06:31
【问题描述】:

C++17 (expr.add/4) 说:

当具有整数类型的表达式被添加或减去时 从一个指针,结果具有指针操作数的类型。如果 表达式 P 指向具有 n 的数组对象 x 的元素 x[i] 元素,表达式 P + J 和 J + P(其中 J 的值为 j) 如果 0≤i+j≤n,则指向(可能是假设的)元素 x[i+j]; 否则,行为未定义。同样,表达式 P - J 如果 0≤i−j≤n,则指向(可能是假设的)元素 x[i−j]; 否则,行为未定义。

struct Foo {
    float x, y, z;
};

Foo f;
char *p = reinterpret_cast<char*>(&f) + offsetof(Foo, z); // (*)
*reinterpret_cast<float*>(p) = 42.0f;

标有 (*) UB 的行吗? reinterpret_cast&lt;char*&gt;(&amp;f) 不指向 char 数组,而是指向浮点数,因此它应该根据引用的段落进行 UB。但是,如果是UB,那么offsetof的用处就有限了。

是 UB 吗?如果没有,为什么不呢?

【问题讨论】:

  • @StoryTeller: line (*) 不访问它,它只是一个指针操作。
  • 这不是 UB.. 您只是获取变量的地址并将其转换为 char* 然后返回其原始类型。它指向一个有效的对象(地址为z)。
  • 您将 f 与 p 别名,这已经是允许的。可以按照[intro.object](字符数组或std::byte)中指定的方式查看对象的存储。那么有什么问题呢?
  • @StoryTeller 我给出了一个非常特定的连续对象的反例,显然不能将其视为数组。还是我错了,你是说他们可以吗?

标签: c++ language-lawyer


【解决方案1】:

见CWG 1314

根据 6.9 [basic.types] 第 4 段,

类型 T 的对象的对象表示是类型 T 的对象占用的 N 个 unsigned char 对象的序列,其中 N 等于 sizeof(T)。

和 4.5 [intro.object] 第 5 段,

普通可复制或标准布局类型(6.9 [basic.types])的对象应占用连续的存储字节。

这些段落是否使标准布局对象中的指针算术(8.7 [expr.add] 第 5 段)定义明确(例如,用于编写自己的 memcpy 版本?

理由(2011 年 8 月):

当前的措辞非常清楚,允许这种用法。

我强烈不同意 CWG 的说法,即“当前的措辞足够清晰”,但无论如何,这是我们的裁决。

我将 CWG 的回复解释为建议将指向 unsigned char 的指针转换为可简单复制或标准布局类型的对象,出于指针算术的目的,应该将其解释为指向 unsigned char 数组的指针,其size 等于相关对象的大小。我不知道他们是否打算使用 char 指针或(从 C++17 开始)std::byte 指针也可以工作。 (也许如果他们决定真正澄清它而不是声称现有的措辞足够清楚,那么我就会知道答案。)

(一个单独的问题是是否需要std::launder 才能使 OP 的代码明确定义。我不会在这里讨论这个问题;我认为它值得一个单独的问题。)

【讨论】:

【解决方案2】:

任何不允许offsetof 的预期用法的解释都必须是错误的:

#include <assert.h>
#include <stddef.h>
struct S { float a, b, c; };

const size_t idx_S[] = {
    offsetof(struct S, a),
    offsetof(struct S, b),
    offsetof(struct S, c),
};

float read_S(struct S *sp, unsigned int idx)
{
    assert(idx < 3);
    return *(float *)(((char *)sp) + idx_S[idx]); // intended to be valid
}

但是,任何允许越过显式声明的数组末尾的解释也一定是错误的:

#include <assert.h>
#include <stddef.h>
struct S { float a[2]; float b[2]; };

static_assert(offsetof(struct S, b) == sizeof(float)*2,
    "padding between S.a and S.b -- should be impossible");

float read_S(struct S *sp, unsigned int idx)
{
    assert(idx < 4);
    return sp->a[idx]; // undefined behavior if idx >= 2,
                       // reading past end of array
}

而我们现在正处于两难境地,因为 C 和 C++ 标准中的措辞原本打算禁止第二种情况,但很可能也不允许第一种情况。

这就是俗称的“什么是对象?”问题。自 1990 年代以来,包括 C 和 C++ 委员会成员在内的人们一直在争论这个问题和相关问题,并且已经多次尝试修正措辞,据我所知,没有一个成功(从某种意义上说,所有现有的“合理”代码呈现绝对符合要求,并且仍然允许所有现有的“合理”优化)。

(注意:以上所有代码都是用 C 编写的,以强调两种语言都存在相同的问题,并且可以在不使用任何 C++ 结构的情况下遇到。)

【讨论】:

  • 我相信旨在禁止第二种情况的措辞实际上确实 not 禁止第一种情况,尽管它肯定可以更清楚。在第一种情况下,您将 S 占用的内存转换为有效的unsigned char[sizeof(S)]。 “对象存储”是这样的数组在[basic.types] 第 4 段中定义。因此,您在一个 char 数组中,并且算法定义明确。
  • @JanHudec 很多人都同意你的观点。大约有相同数量的人不同意您的观点。
  • 为什么*(float *)(((char *)sp) + idx_S[idx]) 是有效的?您可以将指针转换为uintptr_t 并进行算术运算,然后将其转换回指针。这是定义明确的行为(尽管是实现定义的)。我认为offsetof 就是打算这样使用的。
  • @xskxzr uintptr_t 在 offsetof 之后很久才被添加到 C 和 C++ 标准中; offsetof 一定是为了在 C89 的上下文中有用。在这一点上,我们都在一起做考古——到目前为止,即使是 C89 的原始作者也可能不记得他们的意图是什么——但是像我展示的idx_S 示例一样的代码,使用char *,但是手工完成 offsetof 的胆量,出现在 1980 年代编写的数十个程序中,因此我们可以确信 C 委员会在发明 offsetof 时就考虑到了这一点。
  • @xskxzr:不能保证,给定char *p; size_t x;,(char*)(((uintptr_t)p)+x) 的值与p+x 有任何关系,即使在两个表达式都会产生定义值的情况下也是如此。此外,还有一些建议允许符合标准的编译器跟踪指针的出处,即使它们是通过uintptr_t 转换的,一些编译器编写者几乎肯定会将其解释为...
【解决方案3】:

是的,这是未定义的。正如您在问题中所说,

reinterpret_cast&lt;char*&gt;(&amp;f) 不指向字符数组,而是指向浮点数,...

...reinterpret_cast&lt;char*&gt;(&amp;f)does even not point to a char,所以即使对象表示是 char 数组,行为仍然是未定义的。

对于offsetof,你仍然可以像这样使用它

struct Foo {
    float x, y, z;
};

Foo f;
auto p = reinterpret_cast<std::uintptr_t>(&f) + offsetof(Foo, z); 
                       // ^^^^^^^^^^^^^^
*reinterpret_cast<float*>(p) = 42.0f;

【讨论】:

【解决方案4】:

据我所知,您的代码是有效的。根据 § 3.10 ¶ 10.8:

明确允许将对象别名为 char 数组

如果程序尝试通过以下类型之一以外的左值访问对象的存储值,则行为未定义:

  • […]
  • char 或 unsigned char 类型。

另一个问题是将char* 指针转换回float* 并通过它进行分配是否有效。由于您的 Foo 是 POD 类型,因此没关系。您可以计算 POD 成员的地址(假设计算本身不是 UB),然后通过该地址访问该成员。例如,您不得滥用它来访问非 POD 对象的 private 成员。此外,如果您要转换为int* 或写入不存在float 类型的对象的地址,那么它将是UB。这背后的原因可以在上面引用的部分中找到。

【讨论】:

  • 他们只是说char,而不是char 数组。这就是区别。
  • char[N] 及其子对象不同。就像您不希望 struct { char a0, a1; } 能够为 int16_t 起别名一样,即使它没有任何填充。
【解决方案5】:

添加的目的是有效的,但我不认为标准能够说得足够清楚。引用 N4140(大约 C++14):

3.9 类型 [basic.types]

2 对于普通可复制类型T 的任何对象(基类子对象除外),无论该对象是否拥有T 类型的有效值,构成该对象的底层字节(1.7)都可以被复制到一个数组中 char 或 unsigned char.42 [...]

42) 例如,通过使用库函数 (17.6.1.2) std::memcpy 或 std::memmove。

它说“例如”,因为std::memcpy 和std::memmove 并不是允许复制底层字节的唯一方式。手动逐字节复制的简单for 循环也应该是有效的。

为了使其工作,必须为组成对象的原始字节的指针定义加法,并且表达式的定义方式工作,加法的定义不能取决于加法的结果是否随后将用于将字节复制到数组中。

这是否意味着这些字节已经形成了一个数组,或者这是否是 + 运算符的一般规则的一个特殊例外,在运算符描述中以某种方式省略了,我不清楚(我怀疑前者),但无论哪种方式都会使您在代码中执行的添加有效。

【讨论】:

  • 您的措辞似乎暗示即使在当前措辞下它也是有效的,尽管您的 cmets 似乎另有说法?你能澄清一下吗?
  • @PasserBy 你的意思是我的 cmets 回答 StoryTeller 的问题吗?在那些中,我说我相信 StoryTeller 的逻辑是有缺陷的,但这并没有说明结论。有缺陷的逻辑可能导致正确的结论,也可能导致错误的结论。
  • 我认为 [basic.types] 的另一部分,即关于 对象存储 的部分,更相关,因为它涉及所有对象并将对象存储定义为无符号字符序列。这最接近于说任何对象都可以被视为规范获得的(无符号)字符数组。
  • @JanHudec 这意味着更多,但我认为它保证更少:数组是对象序列,但没有任何东西说所有对象序列都是数组,是吗?跨度>
  • 恕我直言,解决此类问题的唯一真正一致的方法是认识到 C89 及其继任者都没有试图描述实现必须做的所有事情以适合任何特定目的,因此 - 可能为了“节省墨水”——他们并不总是费心去定义那些显然应该(至少按照当时的标准)由旨在用于各种目标和目的的质量实现有效处理的行为的行为,或者在某些情况下,基本上所有的非垃圾实现。
猜你喜欢
  • 2021-01-26
  • 1970-01-01
  • 2018-04-05
  • 2012-03-19
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多