【问题标题】:What treatment can a pointer undergo and still be valid?指针可以经过哪些处理并且仍然有效?
【发布时间】:2017-05-13 21:22:38
【问题描述】:

以下哪些处理和尝试恢复 C 指针的方法可以保证有效?

1) 转换为 void 指针并返回

int f(int *a) {
    void *b = a;
    a = b;
    return *a;
}

2) 转换为适当大小的整数并返回

int f(int *a) {
    uintptr_t b = a;
    a = (int *)b;
    return *a;
}

3) 几个简单的整数运算

int f(int *a) {
    uintptr_t b = a;
    b += 99;
    b -= 99;
    a = (int *)b;
    return *a;
}

4) 整数运算非常重要,足以掩盖出处,但仍会使值保持不变

int f(int *a) {
    uintptr_t b = a;
    char s[32];
    // assume %lu is suitable
    sprintf(s, "%lu", b);
    b = strtoul(s);
    a = (int *)b;
    return *a;
}

5) 更多的间接整数运算将保持值不变

int f(int *a) {
    uintptr_t b = a;
    for (uintptr_t i = 0;; i++)
        if (i == b) {
            a = (int *)i;
            return *a;
        }
}

显然情况 1 是有效的,情况 2 肯定也是。另一方面,我看到了 Chris Lattner 的一篇文章——很遗憾我现在找不到了——说类似于案例 5 的内容 not 有效,标准许可编译器只编译它到一个无限循环。然而,每个案例看起来都像是前一个案例的无可争议的延伸。

有效案例和无效案例之间的界限在哪里?

根据 cmets 中的讨论添加:虽然我仍然找不到启发案例 5 的帖子,但我不记得涉及哪种类型的指针;特别是,它可能是一个函数指针,这可能就是为什么该案例显示无效代码而我的案例 5 是有效代码的原因。

第二个补充:好的,这是另一个说有问题的来源,我确实有这个链接。 https://www.cl.cam.ac.uk/~pes20/cerberus/notes30.pdf - 关于指针出处的讨论 - 说,并用证据支持,不,如果编译器忘记了指针的来源,这是未定义的行为。

【问题讨论】:

  • Case 5 不会产生无限循环——当然,除非编译器坏了。循环完成可能需要足够的时间才能被观察到,具体取决于 a 的值,尤其是在没有优化的情况下编译。但是编译器也可以简单地完全优化循环。
  • 我怀疑你记错了 Chris Lattner 说的话。案例 5 不能是无限循环,因为 uintrptr_t 应该能够表示数据指针值可以是的所有可能值(除非整个 [u]intrptr_t 机制完全损坏)。你最好找到他说的实际例子
  • @rwallace [u]intptr_t 不能存储函数指针(它们仅为数据指针定义)。所以,是的,如果将函数指针转换为 [u]intptr_t,它不能保证工作。
  • @EugeneSh。后者不遵循前者。 void* 不能保证保存函数指针。
  • 完全模糊出处的整数运算可能不像那些涉及编译器可以看到的“不相关”但实际上指向同一个对象的整数的问题那么大。我不确定是否有任何有用的规则来说明大多数编译器能够可靠地处理哪些情况。

标签: c pointers undefined-behavior


【解决方案1】:

根据C11 draft standard

示例 1

根据 §6.5.16.1 有效,即使没有显式转换。

示例 2

intptr_tuintptr_t 类型是可选的。将指针分配给整数需要显式强制转换(第 6.5.16.1 节),尽管 gcc 和 clang 只会在您没有的情况下警告您。有了这些警告,往返转换在 §7.20.1.4 中有效。 预计到达时间: John Bellinger 指出,仅当您对void* 进行双向转换时,才会指定行为。但是,gcc 和 clang 都允许将直接转换作为文档扩展。

示例 3

安全,但这只是因为您使用的是无符号算术,它不会溢出,因此可以保证返回相同的对象表示。 intptr_t 可能会溢出!如果您想安全地进行指针运算,您可以将任何类型的指针转​​换为char*,然后在同一结构或数组中添加或减去偏移量。请记住,sizeof(char) 始终是 1ETA:该标准保证两个指针比较相等,但是您与 Chisnall et al. 的链接给出了编译器仍然假定两个指针不互为别名的示例。

示例 4

始终、始终、始终在读取时检查缓冲区溢出,尤其是在写入缓冲区时!如果您可以通过静态分析在数学上证明溢出不会发生?然后写出明确证明这一点的假设,以及assert()static_assert() 他们没有改变的假设。使用snprintf(),而不是弃用的、不安全的sprintf()!如果您从这个答案中什么都不记得,请记住!

绝对迂腐,可移植的方法是使用<inttypes.h> 中的格式说明符,并根据任何指针表示的最大值定义缓冲区长度。在现实世界中,您会以%p 格式打印指针。

不过,您想问的问题的答案是肯定的:重要的是您得到相同的对象表示。这是一个不太人为的例子:

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

int main(void)
{
    int i = 1;
    const uintptr_t u = (uintptr_t)(void*)&i;
    uintptr_t v;

    memcpy( &v, &u, sizeof(v) );
    int* const p = (int*)(void*)v;

    assert(p == &i);
    *p = 2;
    printf( "%d = %d.\n", i, *p ); 

    return EXIT_SUCCESS;
}

所有重要的是对象表示中的位。此代码还遵循 §6.5 中的严格别名规则。它在给 Chisnall et al 带来麻烦的编译器上编译和运行良好。

示例 5

这行得通,和上面一样。

一个永远不会与您的编码相关的极其迂腐的脚注:一些过时的深奥硬件具有带符号整数的补码或符号和大小表示,并且在这些上,可能有一个不同的负零值可能会也可能不会陷阱。在某些 CPU 上,这可能是与正零不同的有效指针或空指针表示。在某些 CPU 上,正零和负零可能比较相等。

PS

标准说:

两个指针比较相等当且仅当两个指针都是空指针,都是指向同一个对象(包括一个指向一个对象和一个在其开头的子对象)或函数的指针,两者都是指向最后一个元素的指针相同的数组对象,或者一个是指向一个数组对象末尾的指针,另一个是指向另一个数组对象的开头的指针,该数组对象恰好紧跟在地址空间中的第一个数组对象之后。

此外,如果两个数组对象是同一多维数组的连续行,则第一行末尾之后的一个是指向下一行开头的有效指针。因此,即使是故意导致标准允许的尽可能多的错误的病态实现也只能在您操作的指针比较等于数组对象的地址时才会这样做,在这种情况下,实现在理论上可能决定将其解释为取而代之的是某个其他数组对象的末尾。

预期的行为显然是指针比较等于&amp;array1+1&amp;array2 等于两者:这意味着让您将其与array1 中的地址进行比较或取消引用它以获取array2[0]。但是,标准实际上并没有这么说。

PPS

标准委员会has addressed some of these issues 并建议 C 标准明确添加有关指针来源的语言。这将确定是否允许符合要求的实现假定由位操作创建的指针不会别名另一个指针。

具体来说,提议的勘误将引入指针出处,并允许具有不同出处的指针不能比较相等。它还将引入-fno-provenance 选项,这将保证任何两个指针比较相等当且仅当它们具有相同的数字地址时。 (如上所述,两个比较相等的对象指针相互别名。)

【讨论】:

  • @supercat 我非常不同意这种解释。根据标准,任何与非数组对象的对象的地址相比较至少等于地址的指针必须是指向同一对象的指针。数组对象不是对象的同义词。
  • @supercat 我不同意,有几个原因。最简单的:“指向一个数组对象末尾的指针”漏洞仅适用于“指向不同数组对象开头的指针”。如果你有一个指向任何其他类型对象的指针,标准保证任何与它相等的指针都是指向同一个对象的指针(除非程序中有其他未定义的行为)。
  • @supercat 否。标准要求,如果 &amp;foo.bar[7] 比较等于 &amp;foo.boz,则两者都是指向同一个对象 foo.baz 的指针,并且两者也是 @ 末尾的后一位987654345@。与foo.bar 中的指针相比,它可以取消引用以访问foo.boz,并且还可以从中减去最多7 个偏移量。完全一样,如果您声明 some_type a[2][1];&amp;(a[0][0]) + 1 == &amp;a[1][0] 并且两个指针是等价的。
  • @supercat 标准说,“下标运算符 [] 的定义是 E1[E2] 等同于 (*((E1)+(E2)))”。因此,根据标准,数组第一行末尾的指针和指向数组第二行第一个元素的指针是等价的。 .
  • @supercat 这有点扯远了,但是,底线:标准保证对象指针或过去的指针的往返转换可用于同样的方法。一个指向数组对象的指针,并且只有一个数组对象,可能与一个指向另一个数组末尾的指针比较相等。在这种情况下,理论上允许实现选择两种解释中的一种有效,而另一种无效。但没有人会
【解决方案2】:

1) 转换为 void 指针并返回

这会产生一个与原始指针相等的有效指针。标准的第 6.3.2.3/1 段对此很清楚:

指向 void 的指针可以转换为指向任何对象类型的指针或从指向任何对象类型的指针转​​换。指向任何对象类型的指针都可以转换为指向 void 的指针并再次返回;结果应与原始指针比较。


2) 转换为适当大小的整数并返回

3) 几个简单的整数运算

4) 整数运算非常重要,足以掩盖出处,但仍会使值保持不变

5) 更多的间接整数运算将保持值不变

[...] 显然 case 1 是有效的, case 2 肯定也是。另一方面,我看到了 Chris Lattner 的一篇帖子——很遗憾我现在找不到——说案例 5 无效,标准许可编译器将其编译为无限循环。

在指针和整数之间转换时,C 确实需要强制转换,并且您在示例代码中省略了其中的一些。从这个意义上说,您的示例 (2) - (5) 都是不合格的,但是对于这个答案的其余部分,我会假装需要的演员在那里。

尽管如此,非常迂腐,所有这些示例都具有实现定义的行为,因此它们并不严格符合。另一方面,“实现定义的”行为仍然是定义的行为;这是否意味着您的代码“有效”取决于您使用该术语的含义。无论如何,编译器可能为任何示例生成什么代码是另一回事。

这些是第 6.3.2.3 节中标准的相关规定(强调添加):

整数可以转换为任何指针类型。除非前面指定,结果是实现定义的,可能未正确对齐,可能未指向引用类型的实体,并且可能是陷阱表示。

任何指针类型都可以转换为整数类型。除非前面指定,结果是实现定义的。如果结果不能以整数类型表示,则行为未定义。结果不必在任何整数类型的值范围内。

uintptr_t 的定义也与您的特定示例代码相关。标准是这样描述的(C2011, 7.20.1.4/1;强调):

无符号整数类型,其属性是任何有效的 指向 void 的指针都可以转换为此类型,然后再转换回指针 指向 void ,结果会和原来的指针比较。

您正在int *uintptr_t 之间来回转换。 int * 不是 void *,因此 7.20.1.4/1 不适用于这些转换,并且行为是根据第 6.3.2.3 节的实现定义的。

但是,假设您通过中间 void * 来回转换:

uintptr_t b = (uintptr_t)(void *)a;
a = (int *)(void *)b;

在提供uintptr_t(可选)的实现上,这将使您的示例 (2 - 5) 都严格符合。在这种情况下,整数到指针的转换结果仅取决于uintptr_t 对象的值,而不取决于该值的获取方式。

至于您对 Chris Lattner 的声明,它们基本上是不正确的。如果您准确地表示了它们,那么它们可能反映了实现定义的行为和定义的行为之间的混淆。如果代码表现出未定义的行为,那么该声明可能会成立,但实际上并非如此。

不管它的值是如何获得的,b 都有一个明确的uintptr_t 类型的值,并且循环必须最终将i 增加到该值,此时if 块将运行。原则上,从uintptr_t 直接转换为int * 的实现定义的行为可能是一些疯狂的事情,例如跳过下一条语句(从而导致无限循环),但这种行为完全不可信。你遇到的每一个实现要么在那个时候失败,要么在变量a中存储一些值,然后,如果它没有崩溃,它将执行return语句。

【讨论】:

  • 很好地抓住了丢失的演员表。假设我们根据需要插入这些内容,我明白你在说什么,这就是我希望的情况。但是,我现在发现另一个消息来源说存在问题,并且我确实有一个链接 - 请参阅第二个添加的段落 - cl.cam.ac.uk/~pes20/cerberus/notes30.pdf
  • 好吧,即使没有显式强制转换,整数和指针之间的转换也是有效的简单赋值,但编译器会警告它们,我们希望抑制警告消息,以便真正的错误脱颖而出。仅通过void* 保证往返转换的标准很好。
  • 标准对uintptr_t有点草率;它保证通过 uintptr_t 进行的往返转换将产生一个与原始指针比较相等的指针,但没有说明该指针是否可以与任何或所有与它比较相等的指针相同的方式使用。
  • 不是这样,@Davislor。阅读section 6.5.16.1/1 of the standard,描述简单赋值运算符的操作数的约束。将整数分配给指针或指向整数的指针是违反约束的。因此,不仅此类赋值的行为未定义,而且符合要求的编译器也需要发出关于它的诊断信息。
  • @supercat,该标准确实允许比较相等的值可能具有不同的表示形式,但它也认为“在将运算符应用于具有多个对象表示的值的情况下,哪个对象表示使用不应影响结果的值”(C2011,6.2.6.1/8)。这不是说通过uintptr_t 来回传递指针所产生的值可以像原始指针值一样在所有方面发挥作用吗?
【解决方案3】:

由于不同的应用程序领域需要以不同方式操作指针的能力,并且由于某些用途的最佳实现可能完全不适合其他用途,因此 C 标准将支持(或缺乏)各种操作视为实施质量问题。一般来说,为特定应用领域编写实现的人应该比标准的作者更熟悉哪些特性对该领域的程序员有用,并且人们会真诚地努力产生适合编写应用程序的高质量实现。无论标准是否要求,该字段都将支持此类功能。

在 Dennis Ritchie 发明的前标准语言中,所有被标识为相同地址的特定类型的指针都是等价的。如果指针上的任何操作序列最终会产生另一个相同类型的指针来标识相同的地址,那么该指针将 - 基本上根据定义 - 等同于第一个指针。然而,C 标准规定了一些情况,其中指针可以标识存储中的相同位置,并且彼此无法区分而不是等效的。例如,给定:

int foo[2][4] = {0};
int *p = foo[0]+4, *q=foo[1];

pq 将相互比较,并与 foo[0]+4foo[1] 进行比较。另一方面,尽管p[-1]q[0] 的评估会定义行为,但p[0]q[-1] 的评估将调用UB。不幸的是,虽然标准明确指出 pq 不等价,但它并没有说明是否对例如执行各种操作序列。 p 将产生一个在 p 可用的所有情况下都可用的指针,一个在所有情况下都可用的指针 pq 可用,一个指针仅在 q 可用的情况下可用,或者指针仅在 pq 都可用的情况下可用。

用于低级编程的质量实现通常应该处理指针操作,而不是那些涉及restrict 指针的操作,其方式将产生一个指针,该指针在任何情况下都可以使用,如果一个比较等于它的指针可以使用。不幸的是,该标准没有提供程序可以确定它是否由适合低级编程的质量实现处理并拒绝运行的方法,因此大多数形式的系统编程必须依赖于质量实现即使标准没有强加任何要求,也要以环境特征的书面方式处理某些行为。

顺便说一句,即使用于操作指针的常规构造在不适用等价原则的情况下没有任何方法可以创建指针,但某些平台可能会定义创建“有趣”指针的方法。例如,如果一个通常会在空指针上捕获操作的实现在有时可能需要访问地址为零的对象的环境中运行,它可能会定义一种特殊语法来创建可用于访问的指针在创建它的上下文中的任何地址,包括零。 “指向地址零的合法指针”可能会比较等于空指针(即使它们不等价),但执行到另一种类型的往返转换可能会将合法指针转换为地址零变成一个空指针。如果标准规定 any 指针的往返转换必须产生一个可以与原始指针相同的方式使用的指针,这将要求编译器忽略任何指针上的空陷阱以这种方式生成,即使它们更有可能是通过往返空指针生成的。

顺便说一句,从实际的角度来看,“现代”编译器,即使在 -fno-strict-aliasing 中,有时也会尝试通过指针-整数-指针转换来跟踪指针的出处,这种方式使得通过强制转换相等整数产生的指针有时可能是假定不能混叠。

例如,给定:

#include <stdint.h>

extern int x[],y[];
int test(void)
{
    if (!x[0]) return 999;

    uintptr_t upx = (uintptr_t)x;
    uintptr_t upy = (uintptr_t)(y+1);

    //Consider code with and without the following line
    if (upx == upy) upy = upx;

    if ((upx ^ ~upy)+1) // Will return if upx != upy
        return 123;

    int *py = (int*)upy;
    *py += 1;
    return x[0];
}

在没有标记线的情况下,gcc、icc 和 clang 都将假定——即使使用-fno-strict-aliasing,对*py 的操作也不会影响*px,即使唯一的方法是如果upxupy 保持相同的值(这意味着pxpy 都是通过转换相同的uintptr_t 值产生的),则可以达到代码。添加标记的行会导致 icc 和 clang 识别出 px 和 py 可以识别相同的对象,但 gcc 假设可以优化分配,即使它应该意味着 py 将派生自 px--a在这种情况下,质量编译器应该可以毫不费力地识别出可能的别名。

我不确定编译器编写者对跟踪 uintptr_t 值的来源的努力有什么实际好处,因为除了转换结果可能用于“有趣”的方式。但是,考虑到编译器的行为,我不确定是否有任何好的方法可以保证整数和指针之间的转换将以与所涉及的值一致的方式运行。

【讨论】:

    猜你喜欢
    • 2014-08-03
    • 2010-11-13
    • 1970-01-01
    • 1970-01-01
    • 2022-01-24
    • 2021-08-11
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多