【问题标题】:On what architectures is calculating invalid pointers unsafe?在哪些架构上计算无效指针不安全?
【发布时间】:2011-09-27 11:38:18
【问题描述】:
int* a = new int[5] - 1;

根据 C++ 标准,这一行本身会调用未定义的行为,因为 a 是一个无效指针,而不是一个过去的指针。同时,这是制作基于 1 的数组(第一个元素是 a[1])的零开销方式,我需要 project of mine

我想知道这是否是我需要避免的事情,或者 C++ 标准是否只是保守地支持一些我的代码无论如何都不会运行的奇怪架构。所以问题是,这在哪些架构上会成为问题?这些是否普遍?

编辑:要查看上面的行确实调用了未定义的行为,请查看this question

编辑:Dennis Zickefoose 指出,当调用未定义的行为时,允许编译器做任何事情,因此编译器和 CPU 都必须提供超出 C++ 标准的保证,这样的代码才能正常工作。我将问题扩展到任何现代 C++ 编译器是否存在此问题。

【问题讨论】:

  • 计算永远不会不安全。可以取消引用。
  • @Ignacio Vazquez-Abrams 不正确。例如,允许 CPU 具有特殊的指针寄存器,如果您将某些无效的指针值加载到其中,则会发出错误。
  • Ignacio 的评论应作为答案发布并被接受。
  • Bjarke:如果您告诉我们您在谈论什么架构,那么这将很好地回答这个问题。
  • 技术上,作为未定义的行为,即使硬件不会出错,如果编译器注意到你这样做,它也可以生成不正确的代码。并且一些编译器出于优化目的在分析中考虑了未定义的行为。鉴于您的具体情况,我不确定这是否可能 [new T[5] - 1 很可能是先前分配的 T 对象,在这种情况下你没问题],但在其他情况下,它可能会在没有硬件支持。

标签: c++ cpu-architecture


【解决方案1】:

您在这里问了几个问题。一个是“这是我需要避免的事情”。我对此的回答是肯定的。您正在创建一个指向您不拥有的内存的指针。你应该避免这种情况。使用 a[0],完全合法的语句会导致未定义的行为。 你对应的 delete[] 是什么样的?

另一个问题取决于您认为什么奇怪。具有用于检查有效性的指针的专用寄存器的硬件是否符合奇怪的条件?真正的问题似乎是你想要可移植的代码吗?如果是这样,那么避免这种情况。如果没有,那么您仍然可能希望避免这种情况。使用它取决于你对代码维护的态度,因为这让我觉得很容易让继承你的代码的人感到悲伤。

【讨论】:

  • 我认为真正的问题是发帖人问的问题:现有的架构会进行指针验证,即使从不取消引用无效指针也会导致错误?有吗? (是否有这样的指针是一个好主意是一个不同的问题)
  • 数组及其索引都封装在一个隐式二叉树类中。该类的客户端只能看到具有私有索引字段的不透明索引标记,因此无法从类外部请求 a[0]。如果客户端代码以某种方式设法构造了一个索引标记为 0 的索引标记,这将导致使用该标记时出现断言错误。在不了解数组是基于 1 的情况下修改类本身的人会犯许多其他错误,而不仅仅是尝试访问 a[0]。不过,这与所提出的问题无关。
  • @Jeremy,我知道您认为“真正的”问题是帖子的标题,但正文中有其他问题。我应该用不同的措辞来表达我的回答,“需要避免”听起来太像绝对了。我只是想指出牺牲了可移植性,并且可维护性的难度将会增加(对于不是 Bjarke 的未来维护者)。 Bjarke 似乎已经考虑了这些问题,并发现这些权衡是值得的,对我来说已经足够好了。抱歉,每个人都觉得我浪费了带宽,但 SO 问题可以持续很长时间,我觉得这点值得提出。
  • @Tod 我实际上并不认为这是一种权衡。我希望封装使用数组而增加的复杂性,这导致我将堆代码拆分为单独的堆和“完整二叉树”类。我认为最终效果是代码质量的提高——我什至能​​够在项目的其他地方重用树类。它并不总是这样,但我经常发现我对正在优化的代码的额外关注会导致净代码质量提高。所以我的权衡通常是时间与性能+质量,而不是质量与性能。
【解决方案2】:

用于检查的硬件存在于所有 x86 处理器中,只是目前我们没有在最流行的操作系统中使用它。

如果您使用分段内存架构(我们为 16 位系统所做的),则分配返回地址 segment:0 的可能性不大。在这种情况下,您不能从该地址中减去任何内容!

这里是阅读分段内存以及为什么无法加载无效段的起点:

http://en.wikipedia.org/wiki/Segment_descriptor

您必须确定您的代码是否不太可能发生这种情况,或者您是否可以定义一个重载的 operator[] 来为您处理偏移量。

【讨论】:

  • 不太同意这一点。恕我直言,segment:0 的减法有可能与随后的加法一致。 IE。偏移量将在减法和加法中环绕。另外,我从未听说过在 x86 上隐式检查指针有效性(无需取消引用或其他特殊指令,如 probe)。
  • 这只是一个例子,但来自当前的硬件。如果我提出 68000 有人会说它们不再使用了。该语言表示实现可以测试有效性,并且存在执行此操作的硬件。如果我们有稍微未定义的行为 :-) 它可以变成实现定义,然后就可以了。如果不是,至少存在潜在的可移植性问题。
  • 好吧,如果可以可以执行此操作的 CPU 范围包括“所有 x86 CPU”,那么我将不得不使用普通的基于 0 的 CPU数组而不使用第一个条目。谢谢。
  • @valdo :减法结果为segment-1:14int 在 16 位系统上为 2 个字节)。稍后添加将导致segment-1:16,这将是相同的线性地址。然而,问题是segment-1 可能不是一个有效的段。
【解决方案3】:

无论哪种方式,创建从 1 开始的数组的定义明确、零开销的方法如下:

int* a = new int[6];

到了,问题解决了。 ;-)(但有趣的问题仍然存在。)

【讨论】:

  • 零开销?我想不是。您分配的内存比实际需要的多
  • @valdo 一个元素。所以呢? Bjarke 的答案还需要计算一个额外的地址一次,所以从您的角度来看,这也不是零开销吗?
  • 当然。此外,这取决于确切的问题定义:您是否需要最小化内存利用率或 CPU 周期,以及 开销 是什么意思。但在知道这一点之前,您可能不会声称内存浪费是零开销。另外,这不是作者提出的问题(确切的架构是什么......)。
  • 那不是基于 -1 的数组吗?第一个元素位于 a[-1] 处。如果您省略第二行,那么如果您不使用第一个条目,那么您将得到一个基于 1 的 5 个元素的数组。我认为这就是您的意思-这是一个很好的解决方法。实际上我问这个问题是因为我想避免这样做,但这似乎是更好的方法。
【解决方案4】:

我认为这在一些旧的 16 位 x86 系统上可能是不安全的。 地址在地址寄存器和段寄存器之间“拆分”,我猜这可能会导致将无效值加载到段寄存器中,从而导致异常。

可能不是问题,因为它现在不是一种常见的架构。

【讨论】:

    【解决方案5】:

    注意:我不会回答你的问题。但是从 cmets 来看,一些有经验的 SO 成员似乎并不知道这种行为实际上是 未定义的......所以在这里的某个地方我们应该引用一些标准中的章节。

    所以...

    来自C++0x standard (draft),第 5.7 (5) 节,讨论指针加法的行为(强调我的):

    当一个表达式具有整数 类型被添加到或减去 指针,结果的类型为 指针操作数。如果指针 操作数指向一个元素 数组对象,并且数组很大 够了,结果指向一个 元素与原始元素的偏移 元素使得结果的下标和 原始数组元素等于 积分表达式。换一种说法, 如果表达式 P 指向第 i 个 数组对象的元素, 表达式 (P)+N(等价于, N+(P)) 和 (P)-N(其中 N 具有 值 n) 分别指向 i + n 和 i - 数组对象的第 n 个元素,前提是它们存在。 此外,如果表达式 P 指向 到数组的最后一个元素 对象,表达式 (P)+1 分 过去数组的最后一个元素 对象,如果表达式 Q 指向 一个数组的最后一个元素 对象,表达式 (Q)-1 指向 数组对象的最后一个元素。 如果指针操作数和 结果指向相同的元素 数组对象,或最后一个 数组对象的元素, 评估不应产生 溢出;否则,行为是 未定义。

    类似的语言出现在每个版本的 C 和 C++ 标准中。产生超出数组边界(加一)的结果的指针算术是未定义的行为,即使您从未取消引用指针,而且一直如此。

    ...

    现在,这是我自己对您的问题的回答:您问错了问题。即使这不会在任何当前机器和编译器上产生问题,您也应该担心未来机器和编译器,因为所有软件的 90% 成本都是维护成本。通过严格按照规范进行编码,您可以保证您的代码可以在任何符合标准的系统上运行,无论是现在还是 50 年后。为将来试图维护您的代码的人引入细微错误的风险几乎永远不会被今天的任何好处所抵消。

    另外,我对现实世界的性能差异持怀疑态度。 (我并不是说你错了;我只是说我持怀疑态度。)虽然我很欣赏你创建“世界上最快的二进制堆”的尝试,但我怀疑你低估了内存层次结构的影响。当然,我相信“乘以二”的速度是“乘以二加一”的两倍。但我也相信从主内存中获取缓存行的速度比两者都慢数百倍。

    如果您通过反复对堆执行一小组操作来对堆进行基准测试,而不执行任何其他操作,那么您可能完全在 L1 缓存之外进行操作。但这完全不现实。在现实生活中,堆只是大型算法的一小部分;除非该算法完全微不足道,否则整个事物将始终位于 L1 缓存中的可能性非常低。因此,在任何实际场景中,内存访问的成本都可能占主导地位。

    【讨论】:

    • 感谢您确定它确实是未定义的。我这样做是为了应用多项式除法,所以我不是在做微基准测试。像你一样,我还不知道这种技术对我的应用程序有多大的影响,所以我正在实施它来找出答案。我的主要目标是弄清楚堆是否适合多项式除法。如果没有良好的堆实现,我无法对这个问题给出明智的答案,这需要我了解什么是好的堆实现。我宁愿从有关该主题的文章中了解这一点,但我还没有找到。
    【解决方案6】:

    人们已经提到 (a) 对标准的严格语言律师解释,以及 (b) x86 段,通常在 16 位代码中(但也在一些较旧的 32 位操作系统上)。

    让我再举一个例子,贴近我的心:

    安迪·格鲁。 1990. OR 索引的实证研究。 SIGMETRICS 执行。评估。 Rev. 17, 2(1990 年 1 月),41-49。 DOI=10.1145/378893.378896http://doi.acm.org/10.1145/378893.378896

    那是我。

    在这篇论文中,我提出了一个指令集,它使用 OR 而不是 ADD 作为其内存寻址方式。

    现在,因为 C 程序员总能做到

    int* a = new int[5] + 1;
    

    编译器必须正确处理这类事情。

    但这样做可能会降低效率。

    事实证明,我不是唯一一个想到这一点的人。一些真正的运输计算机已经使用了这种技术,虽然我手头没有参考资料。

    无论如何,这只是一个例子。


    总的来说

    a) 您的建议适用于大多数机器(当然,大多数机器是运行 Windows 或 Linux 的 x86,或者运行 UNIX 衍生产品(如 iOS 和 Android)的 ARM)。

    b) 但它可以说是非法的。它可能会破坏一些编译器优化,被一些编译器优化破坏等等。

    顺便说一句,在基于 x86 1 的数组上,编码成本略高,而机器代码几乎没有成本。如果你说类似

    template<typename T,uint size>
    class One_Based_Array {
    private:   T array[size];
    public:    T& operator[](uint i) { return array[i-1]; }
    };
    

    习惯了

    One_Based_Array<int,100> a;
    int tmp = a[i];
    

    机器码看起来像

    MOV EAX, a.array-4(ebx)
    

    即基于 1 的东西通常可以折叠成 x86 的 basereg+indexreg+offset 寻址模式。 在某些机器上,这通常不会花费任何成本,尽管代码可能会有点大。

    事实上,英特尔的编译器经常发出看起来像

    的代码
    ebx = -i
    MOV EAX, address_of_end_of_array[ebx]
    

    即使用减法索引而不是加法索引,或者添加负数,因为针对 0 的测试比针对 +N 的测试更有效。

    【讨论】:

      猜你喜欢
      • 2018-11-23
      • 2015-08-22
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2018-07-29
      • 2014-01-10
      相关资源
      最近更新 更多