【问题标题】:Does &((struct name *)NULL -> b) cause undefined behaviour in C11?&((struct name *)NULL -> b) 在 C11 中是否会导致未定义的行为?
【发布时间】:2015-01-10 11:09:35
【问题描述】:

代码示例:

struct name
{
    int a, b;
};

int main()
{
    &(((struct name *)NULL)->b);
}

这会导致未定义的行为吗?我们可以争论它是否“取消引用 null”,但是 C11 没有定义术语“取消引用”。

6.5.3.2/4 明确表示在空指针上使用* 会导致未定义的行为;但是它对 -> 的说法并不相同,而且它也没有将 a -> b 定义为 (*a).b ;它对每个运算符都有单独的定义。

6.5.2.3/4 中-> 的语义表示:

后缀表达式后跟 -> 运算符和标识符指定成员 结构或联合对象。该值是对象的命名成员的值 第一个表达式指向的,是一个左值。

但是,NULL 不指向对象,因此第二句话似乎没有指定。

同样相关的可能是 6.5.3.2/1:

约束:

一元& 运算符的操作数应为函数指示符, [] 或一元 * 运算符,或指定对象的左值,该对象不是位域并且是 未使用寄存器存储类说明符声明。

但是我觉得粗体文本是有缺陷的,应该按照 6.3.2.1/1 (lvalue 的定义)阅读 lvalue that potential指定一个对象 - C99 弄乱了左值的定义,所以 C11 不得不重写它,也许这部分漏掉了。

6.3.2.1/1 确实说:

左值是一个表达式(对象类型不是 void),它可能 指定一个对象;如果左值在计算时未指定对象,则 行为未定义

但是& 运算符确实 评估其操作数。 (它不访问存储的值,但这是不同的)。

这个长长的推理链似乎表明代码导致了 UB,但是它相当脆弱,我不清楚标准的编写者的意图是什么。如果事实上他们有任何意图,而不是让我们来辩论:)

【问题讨论】:

  • 当然也可以看看Wikipedia's page on offsetof
  • @unwind 似乎没有提供 C 标准没有的任何见解:)
  • 第 6.5.2.3.4 段的注释说:"96) 如果 &E 是一个有效的指针表达式(其中 & 是 ''address-of'' 运算符,它会生成一个指向其操作数的指针),表达式(&E)->MOSE.MOS 相同。” 我认为这涵盖了.-> 之间的关系。
  • @user694733 我没看到;也就是说,如果上述表达式有效,则(&(((struct name *)NULL)->b))->b(((struct name *)NULL)->b)->b 相同。此注释仅适用于 E 具有结构类型时,但这里 Eint
  • @unwind 已创建循环引用,因为 Wikipedia 的页面现在链接到 此处

标签: c language-lawyer c11 offsetof


【解决方案1】:

从律师的角度来看,表达式&(((struct name *)NULL)->b); 应该导致 UB,因为您找不到没有 UB 的路径。恕我直言,根本原因是您在某个不指向对象的表达式上应用了 -> 运算符。

从编译器的角度来看,假设编译器程序员没有过于复杂,很明显该表达式返回的值与offsetof(name, b) 相同,并且我很确定只要编译没有错误 任何现有的编译器都会给出这个结果。

正如所写,我们不能责怪编译器会注意到在内部部分您使用运算符-> 在表达式上不能指向对象(因为它为空)并发出警告或错误。

我的结论是,除非有一个特殊的段落说,如果它只是获取它的地址是合法的,那么取消引用一个空指针是合法的,这个表达式是不合法的 C。

【讨论】:

  • 或者他们将其用作红旗,意思是“无法访问此代码”。你知道,为了优化:最快的代码是不存在的代码。
  • offsetof 是一个特定于实现的宏。无论它做什么都是特定于提供它的编译器的。您不能从一个或多个编译器的定义概括为一种语言要求。标准标头所做的许多事情通常没有明确定义的行为。这些东西依赖于特定编译器的知识,这就是它们与编译器一起发布的原因。
  • @Deduplicator:我真的很鄙视“优化”这个概念;整数算术尤其可怕(恕我直言,标准委员会应该缩小整数溢出的允许行为列表,其中包括让变量采用任意或“不可能”的值,但不是完整的 UB),但指针访问可能很糟糕也是。虽然在发生空指针取消引用时标准允许不受约束的 UB 是完全合理的,但某些实现可能会指定发生特定行为。一些嵌入式系统上下文定义...
  • ...作为访问物理地址零的空指针取消引用,在某些此类系统上,没有其他方法可以访问该地址。即使标准没有指定空指针取消引用的行为,系统文档也可能会这样做。在这种情况下,试图通过未定义行为“变得聪明”的编译器最终可能会“优化”其行为将由同一硬件平台的其他编译器有用地定义的代码。
  • @supercat 可能存在地址为零的系统,并且空指针不指向地址 0。您仍然可以通过形成指向地址 1 的指针并将其递减来访问地址零。
【解决方案2】:

是的,-> 的这种使用在英语术语 undefined 的直接意义上具有未定义的行为。

仅当第一个表达式指向一个对象时才定义行为,否则未定义 (=undefined)。一般来说,您不应该在未定义一词中进行更多搜索,这意味着:标准没有为您的代码提供含义。 (有时它明确指出它没有定义的情况,但这不会改变该术语的一般含义。)

这是为了帮助编译器构建者处理事情而引入的松弛。 他们 可以定义一种行为,即使是您呈现的代码。特别是,对于编译器实现,将此类代码或类似代码用于 offsetof 宏是非常好的。使此代码违反约束会阻止编译器实现的路径。

【讨论】:

  • 嗯,我的意思是 未定义的行为 由 3.4.3 定义
  • @MattMcNabb,我也是,对于这种情况,只需将其阅读为 “使用本国际标准没有要求的不可移植程序构造时的行为”。 “未定义的行为”这个术语不应该被迷惑,它代表自己。
【解决方案3】:

让我们从间接运算符*开始:

6.5.3.2 p4: 一元 * 运算符表示间接。如果操作数指向一个函数,则结果为 功能指示符;如果它指向一个对象,则结果是一个左值,指定 目的。 如果操作数的类型为“pointer to type”,则结果的类型为“type”。如果 无效值已分配给指针,一元 * 运算符的行为是 不明确的。 102)

*E,其中 E 是一个空指针,是未定义的行为。

有一个脚注说:

102) 因此,&*E 等价于 E(即使 E 是空指针),而 &(E1[E2]) 等价于 ((E1)+(E2))。它是 如果 E 是函数指示符或作为一元 & 的有效操作数的左值,则始终为真 运算符,*&E 是函数指示符或等于 E 的左值。如果 *P 是左值且 T 是 对象指针类型,*(T)P 是一个左值,其类型与 T 指向的类型兼容。

这意味着定义了 &*E,其中 E 为 NULL,但问题是对于 &(*E).m 是否也是如此,其中 E 是空指针,其类型是具有会员m?

C 标准没有定义这种行为。

如果它被定义,就会出现新的问题,下面列出了其中一个问题。 C 标准保持它未定义是正确的,并提供了一个宏 offsetof 来处理内部问题。

6.3.2.3 指针

  1. 值为 0 的整数常量表达式,或转换为类型的此类表达式 void *,称为空指针常量。 66) 如果一个空指针常量被转换为一个 指针类型,结果指针,称为空指针,保证比较不相等 指向任何对象或函数的指针。

这意味着一个值为 0 的整数常量表达式被转换为一个空指针常量。

但是空指针常量的值没有定义为0。该值是实现定义的。

7.19 常用定义

  1. 宏是 空值 扩展为实现定义的空指针常量

这意味着 C 允许一个实现,其中空指针将具有一个设置所有位的值,并且对该值使用成员访问将导致溢出,这是未定义的行为

另一个问题是如何评估 &(*E).m?括号是否适用并且首先评估*。保持未定义可以解决这个问题。

【讨论】:

    【解决方案4】:

    首先,让我们确定我们需要一个指向对象的指针:

    6.5.2.3 结构和联合成员

    4 后缀表达式后跟-> 运算符和标识符指定成员 结构或联合对象。该值是对象的命名成员的值 第一个表达式指向的,并且是一个左值。96) 如果第一个表达式是指向的指针 限定类型,结果具有指定类型的限定版本 会员。

    不幸的是,没有空指针指向一个对象。

    6.3.2.3 指针

    3 值为 0 的整型常量表达式,或转换为类型的此类表达式 void *,称为 空指针常量。66) 如果空指针常量转换为 指针类型,生成的指针,称为空指针保证比较不相等 指向任何对象或函数的指针

    结果:未定义的行为。

    作为旁注,还有一些需要细细琢磨的东西:

    6.3.2.3 指针

    4 将空指针转换为另一种指针类型会产生该类型的空指针。 任何两个空指针应该比较相等。
    5 整数可以转换为任何指针类型。除先前规定外, 结果是实现定义的,可能没有正确对齐,可能不指向 引用类型的实体,可能是陷阱表示。67)
    6 任何指针类型都可以转换为整数类型。除先前规定外, 结果是实现定义的。如果结果不能用整数类型表示, 行为未定义。结果不必在任何整数的值范围内 输入。

    67) 将指针转换为整数或将整数转换为指针的映射函数旨在与执行环境的寻址结构保持一致。

    所以即使 UB 碰巧是良性的这一次,它仍然可能导致一些完全出乎意料的数字。

    【讨论】:

    • 关于转换:这就是 (u)intptr_t 的用途。
    【解决方案5】:

    C 标准中的任何内容都不会对系统可以对表达式执行的操作提出任何要求。在编写标准时,在运行时引发以下事件序列是完全合理的:

    1. 代码将空指针加载到寻址单元中
    2. 代码要求寻址单元添加字段b的偏移量。
    3. 寻址单元在尝试将整数添加到空指针时触发陷阱(应该为了稳健性是一个运行时陷阱,即使许多系统没有捕捉到它)李>
    4. 系统在通过从未设置的陷阱向量调度后开始执行基本上随机的代码,因为设置它的代码会浪费内存,因为不应发生寻址陷阱。

    当时未定义行为的本质。

    请注意,自 C 早期以来出现的大多数编译器都会将位于常量地址的对象成员的地址视为编译时常量,但我不认为这种行为是然后,也没有任何内容被添加到标准中,要求在运行时计算不会定义的情况下定义涉及空指针的编译时地址计算。

    【讨论】:

      【解决方案6】:

      没有。让我们把它分开:

      &(((struct name *)NULL)->b);
      

      等同于:

      struct name * ptr = NULL;
      &(ptr->b);
      

      第一行显然有效且定义明确。

      在第二行中,我们计算相对于地址0x0 的字段地址,这也是完全合法的。例如,Amiga 在地址0x4 中有指向内核的指针。所以你可以使用这样的方法来调用内核函数。

      其实在C宏offsetof(wikipedia)上也使用了同样的方法:

      #define offsetof(st, m) ((size_t)(&((st *)0)->m))
      

      所以这里的困惑围绕着 NULL 指针很可怕这一事实。但从编译器和标准的角度来看,该表达式在 C 中是合法的(C++ 是一种不同的野兽,因为您可以重载 & 运算符)。

      【讨论】:

      • 这个问题是关于 C 标准对原始代码的保证(因此是语言律师标签)。编译器可能会为标准未定义的行为定义行为,因此特定编译器的 offsetof 实现并不能证明任何事情。
      • 该标准确实将空指针与其他指针区别对待;例如它明确表示 *(int *)NULL 未定义,strlen(NULL)memcpy(NULL, NULL, 0); 也是如此
      • 无论如何,空指针上的指针算术是未定义的。空指针是否为绝对地址。
      • @AaronDigulla:不同之处在于不应将空指针视为指向地址 0。从语义上讲,它不指向 任何 有效地址。 (它可以指向的地方,没有对象可以存在。)
      • @AaronDigulla:然后很多代码被破坏了。 NULL 不是 0。(type*)0 只是(不幸的是)恰好是标准拼写“空指针”的方式,并且只有当它是一个常量表达式时。即使(type*)n,其中n == 0,也不是空指针;它是指向地址 0 的指针,这是完全不同的事情(即使您的编译器将它们混为一谈)。空指针常量的实际值可以是任何东西——比如,0x8000000000000000(x86-64 CPU 上的无效地址),它会给你 2^63 左右的“偏移量”。
      猜你喜欢
      • 1970-01-01
      • 2017-11-04
      • 2015-01-10
      • 2021-07-12
      • 2015-07-30
      • 1970-01-01
      • 2019-06-03
      • 1970-01-01
      • 2014-08-11
      相关资源
      最近更新 更多