【问题标题】:What is a valid pointer in gcc linux x86-64 C++?gcc linux x86-64 C++中的有效指针是什么?
【发布时间】:2019-03-03 09:33:20
【问题描述】:

我正在一个名为 linux x86-64 的不起眼的系统上使用 gcc 对 C++ 进行编程。我希望可能有一些人使用过这个相同的特定系统(并且可能还能够帮助我理解什么是这个系统上的有效指针)。 我不关心访问指针指向的位置,只想通过指针算法计算出来。

根据标准第 3.9.2 节:

对象指针类型的有效值表示内存中字节的地址 (1.7) 或空指针。

并根据[expr.add]/4

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

并根据stackoverflow question on valid C++ pointers in general

0x1 是您系统上的有效内存地址吗?好吧,对于某些嵌入式系统来说确实如此。对于大多数使用虚拟内存的操作系统,从零开始的页面被保留为无效。

嗯,这很清楚!所以,除了NULL,一个有效的指针是内存中的一个字节,不,等等,它是一个数组元素,包括数组后面的元素,不,等等,它是一个虚拟内存页面,不,等等,它是超人!

(我想这里的“超人”是指“垃圾收集器”……不是我在任何地方读到过的,只是闻到了它的味道。但说真的,所有最好的垃圾收集器都不会以严重的方式破坏,如果你周围有虚假的指针;最糟糕的是,他们只是不时不时收集一些死对象。似乎没有什么值得搞乱指针算术的东西。)。

因此,基本上,一个合适的编译器必须支持上述所有类型的有效指针。我的意思是,一个假设的编译器仅仅因为指针 calculation 不好就大胆地生成未定义的行为,至少会躲避上面的 3 个项目符号,对吗? (好吧,语言律师,那是你的)。

此外,编译器几乎不可能知道其中的许多定义。 所以有很多方法可以创建一个有效的内存字节(想想懒惰的段错误陷阱微码,我将要访问数组的一部分的自定义页表系统的边带提示,...),映射一个页面,或者只是创建一个数组。

以我自己创建的一个较大的数组和我让默认内存管理器在其中创建的一个较小的数组为例:

#include <iostream>
#include <inttypes.h>
#include <assert.h>
using namespace std;

extern const char largish[1000000000000000000L];
asm("largish = 0");

int main()
{
  char* smallish = new char[1000000000];
  cout << "largish base = " << (long)largish << "\n"
       << "largish length = " << sizeof(largish) << "\n"
       << "smallish base = " << (long)smallish << "\n";
}

结果:

largish base = 0
largish length = 1000000000000000000
smallish base = 23173885579280

(不要问我怎么知道默认内存管理器会在另一个数组中分配一些东西。这是一个晦涩的系统设置。关键是我经历了数周的调试折磨此示例有效,只是为了向您证明不同的分配技术可以相互忽略)。

考虑到 linux x86-64 支持的内存管理和程序模块组合方式的数量,C++ 编译器确实无法了解所有数组和各种样式的页面映射.

最后,为什么我要特别提到gcc?因为它似乎经常将 any 指针视为有效指针......例如:

char* super_tricky_add_operation(char* a, long b) {return a + b;}

虽然在阅读了所有语言规范之后,您可能希望 super_tricky_add_operation(a, b) 的实现充满未定义的行为,但实际上它非常无聊,只是一个 addlea 指令。太好了,因为我可以将它用于非常方便和实用的事情,例如 non-zero-based arrays,如果没有人使用我的 add 指令只是为了说明无效指针的问题。我 gcc.

总而言之,似乎任何支持 linux x86-64 上的标准链接工具的 C++ 编译器几乎都必须将 any 指针视为有效指针,而 gcc 似乎是那个俱乐部。但我不是 100% 确定(考虑到足够的小数精度)。

那么...谁能给出一个 gcc linux x86-64 中 invalid 指针的可靠示例?固体我的意思是导致未定义的行为。并解释是什么导致了语言规范允许的未定义行为?

(或提供gcc 证明相反的文档:所有指针都是有效的)。

【问题讨论】:

  • 你知道什么是未定义的行为吗?这不是崩溃。它不会让您的计算机着火。不是报警,不是偷女朋友,不是发动核战争。或者所有这些东西。这只是标准拒绝谈论的行为,仅此而已。为什么又期待在super_tricky_add_operation 中找到特别有趣的汇编代码?
  • “我所说的固体是指导致未定义的行为。”您打算如何识别未定义的行为?通过查看您的计算机并观察崩溃?你不能这样做。通过查看您的计算机并观察它是否着火了?你不能这样做。不是看着你的家被打,不是看着你的女朋友离开,不是看着世界在核灾难中终结。您只能通过阅读标准来识别UB。如果标准说你的程序有 UB,它就有 UB(参见前面评论中 UB 的定义)。
  • 指针有效性绝对没有含糊之处。 [basic.compound] 指针类型的每个值都是以下之一: (3.1) — 指向对象或函数的指针(该指针被称为指向对象或函数),或 (3.2) — 一个超过对象末尾的指针 (8.7),或 (3.3) — 该类型的空指针值 (7.11),或 (3.4) — 无效的指针值。 编译器不需要解释这个是什么特殊的方式。它可以假设你做任何事情的所有指针都是有效的。
  • 不,“我们”没有。您可以声明和定义一个对象,也可以使用 new 运算符创建一个。这使得,让我们用拇指数一数,一,二,这是创建对象的两种方式。您不会“发现”对象。你知道他们在哪里。总的来说,我的印象是你不知道你在问什么。是关于 UB 的症状吗?是关于创建对象吗?是关于指针有效性吗?这太宽泛了。请一次回答一个问题。
  • “新位置对于像 int 这样的 C 类型是可选的”不,它不是“因为 C++ 向后兼容 C”不,不是。

标签: c++ gcc language-lawyer x86-64 undefined-behavior


【解决方案1】:

通常,无论指针是否指向对象,指针数学都会完全按照您的预期进行。

UB 并不意味着它必须失败。只是 允许 使整个程序的其余部分以某种方式表现得异常。 UB 并不意味着指针比较结果可能是“错误的”,它意味着整个程序的整个行为是未定义的。这往往发生在依赖于违反假设的优化中。

有趣的极端情况包括位于虚拟地址空间最顶端的数组:指向过去一端的指针会回零,所以start &lt; end 会是假的?!?但是指针比较不必处理这种情况,因为 Linux 内核永远不会映射首页,因此指向它的指针不能指向或只是过去的对象。见Why can't I mmap(MAP_FIXED) the highest virtual page in a 32-bit Linux process on a 64-bit kernel?


相关:

GCC 确实的最大对象大小为PTRDIFF_MAX(这是一个有符号类型)。例如,在 32 位 x86 上,并非所有代码生成情况都完全支持大于 2GB 的数组,尽管您可以mmap 一个。

请参阅我对What is the maximum size of an array in C? 的评论 - 对于比char 更宽的类型(其中 C 减法结果在对象中),此限制允许 gcc 实现指针减法(以获取大小)而不保留高位的进位,而不是字节,所以在 asm 中是 (a - b) / sizeof(T)


不要问我怎么知道默认内存管理器会在另一个数组中分配一些东西。这是一个不起眼的系统设置。关键是我经历了数周的调试折磨才能使这个示例正常运行,只是为了向您证明不同的分配技术可以相互忽略)。

首先,您实际上从未分配空间给large[]。您使用内联 asm 使其从地址 0 开始,但没有做任何实际映射这些页面的操作。

new 使用brkmmap 从内核获取新内存时,内核不会重叠现有的映射页面,因此实际上静态和动态分配不能重叠。

第二,char[1000000000000000000L] ~= 2^59 字节。当前的 x86-64 硬件和软件仅支持规范的 48 位虚拟地址(符号扩展为 64 位)。这将随着未来一代的英特尔硬件而改变,它增加了另一层页表,使我们达到 48+9 = 57 位地址。 (上半部分还是内核用的,中间有个大洞)

从 0 到 ~2^59 的未分配空间涵盖了 x86-64 Linux 上可能存在的所有用户空间虚拟内存地址,因此您分配的任何内容(包括其他静态数组)当然都在这个假的“内部”的某个地方数组。


从声明中删除extern const(因此数组实际分配的,https://godbolt.org/z/Hp2Exc)会遇到以下问题:

//extern const 
char largish[1000000000000000000L];
//asm("largish = 0");

/* rest of the code unchanged */
  • 使用默认代码模型 (-mcmodel=small where all static code+data is assumed to fit in 2GB) 时,相对 RIP 或 32 位绝对 (-fno-pie -no-pie) 寻址无法访问在 BSS 中 large[] 之后链接的静态数据

    $ g++ -O2 large.cpp
    /usr/bin/ld: /tmp/cc876exP.o: in function `_GLOBAL__sub_I_largish':
    large.cpp:(.text.startup+0xd7): relocation truncated to fit: R_X86_64_PC32 against `.bss'
    /usr/bin/ld: large.cpp:(.text.startup+0xf5): relocation truncated to fit: R_X86_64_PC32 against `.bss'
    collect2: error: ld returned 1 exit status
    
  • -mcmodel=medium 编译将large[] 放在一个大数据部分中,它不会干扰寻址其他静态数据,但它本身是使用64 位绝对寻址来寻址的。 (或者-mcmodel=large 对所有静态代码/数据执行此操作,因此每次调用都是间接的movabs reg,imm64 / call reg 而不是call rel32。)

    这让我们可以编译和链接,但随后可执行文件将无法运行,因为内核知道只支持 48 位虚拟地址,并且之前不会将程序映射到其 ELF 加载器中运行它,或者在运行 ld.so 之前用于 PIE。

    peter@volta:/tmp$ g++ -fno-pie -no-pie -mcmodel=medium -O2 large.cpp
    peter@volta:/tmp$ strace ./a.out 
    execve("./a.out", ["./a.out"], 0x7ffd788a4b60 /* 52 vars */) = -1 EINVAL (Invalid argument)
    +++ killed by SIGSEGV +++
    Segmentation fault (core dumped)
    peter@volta:/tmp$ g++ -mcmodel=medium -O2 large.cpp
    peter@volta:/tmp$ strace ./a.out 
    execve("./a.out", ["./a.out"], 0x7ffdd3bbad00 /* 52 vars */) = -1 ENOMEM (Cannot allocate memory)
    +++ killed by SIGSEGV +++
    Segmentation fault (core dumped)
    

(有趣的是,对于 PIE 和非 PIE 可执行文件,我们得到了不同的错误代码,但仍然在 execve() 完成之前。)


asm("largish = 0"); 欺骗编译器 + 链接器 + 运行时并不是很有趣,而且会产生明显的未定义行为。

有趣的事实 #2:x64 MSVC 不支持大于 2^31-1 字节的静态对象。 IDK 如果它有 -mcmodel=medium 等价物。基本上 GCC 无法警告对象对于所选内存模型来说太大了。

<source>(7): error C2148: total size of array must not exceed 0x7fffffff bytes

<source>(13): warning C4311: 'type cast': pointer truncation from 'char *' to 'long'
<source>(14): error C2070: 'char [-1486618624]': illegal sizeof operand
<source>(15): warning C4311: 'type cast': pointer truncation from 'char *' to 'long'

此外,它指出 long 通常是错误的指针类型(因为 Windows x64 是 LLP64 ABI,其中 long 是 32 位)。你想要intptr_tuintptr_t,或者相当于printf("%p") 的东西,它打印一个原始的void*

【讨论】:

  • 感谢您的观点;我同意内核不会分配largish,并且试图让链接器参与largish 会导致更大的问题。但是largish 的目的是满足语言对指针运算的要求,而不是让内核做一些事情。语言规范在哪里说必须由内核分配“数组”(出于 [expr.add]/4 的目的)? (我的意思是,是的,人们在某些假设下是这样解释的,但这不是唯一可能的解释)
  • 就此而言,基本算术是如何与内核交互的?这在.o 文件中不是很明显吗?但是如果我添加指针,我看到的只是leaadd 指令,它们都没有触及内核。
  • @personal_cloud:对,完全不涉及内核。 UB 并不意味着它必须 失败,它意味着它允许 失败和/或超级怪异。您对largish[] 的破解创建了一个实际上并不指向对象的指针。但无论如何,这个答案只是试图解决你的前提中的一个缺陷,以及我引用的问题的一部分。我没有深入了解您实际上还问了什么。
  • C++ 出于指针算术的目的对指针有效性提出了模糊的要求。我要问的是 GCC 如何解释该要求。它显然可以处理许多不涉及内核分配的情况,包括各种自定义分配器、内核本身内的硬件驱动程序、惰性映射方案、到坏文件的 mmap、部分或全部放置在链接器中的自定义定位数组稍后……很多例子。 null 是否有一个总体原则,或者只是一些例外(包括围绕 0 的比较)。
  • @personal_cloud:无论指针是否指向对象,通常指针数学都完全符合您的预期。就像我说的,UB 并不意味着它必须 失败。有趣的极端情况包括位于虚拟地址空间最顶端的数组:指向过去的指针会回零,所以start &lt; end 会是假的?!?但是指针比较不必处理这种情况,因为 Linux 内核永远不会映射首页,因此指向它的指针不能指向或只是过去的对象。 See this Q&A.
【解决方案2】:

该标准预计不会存在任何存储超出实现通过静态、自动或线程持续时间的对象或使用标准库函数(如calloc)提供的存储。因此,它对实现如何处理指向此类存储的指针没有任何限制,因为从它的角度来看,此类存储不存在,有意义地标识不存在的存储的指针不存在,并且不存在的东西不需要有关于他们的规则。

这并不意味着委员会的成员不知道许多执行环境提供了 C 实现可能一无所知的存储形式。然而,预期实际使用各种平台的人会比委员会更好地确定程序员需要对这些“外部”地址做什么样的事情,以及如何最好地支持这些需求。标准不需要关心这些事情。

碰巧的是,在某些执行环境中,编译器将指针算术视为整数数学比其他任何事情都更方便,并且许多此类平台的编译器即使在它们是不需要这样做。对于 32 位和 64 位 x86 和 x64,我认为无效的非空地址没有任何位模式,但有可能形成不表现为指向它们所寻址对象的有效指针的指针.

例如,给定如下内容:

char x=1,y=2;
ptrdiff_t delta = (uintptr_t)&y - (uintptr_t)&x;
char *p = &x+delta;
*p = 3;

即使指针表示的定义方式是使用整数算术将delta 添加到x 的地址会产生y,但这也不能保证编译器会识别@ 上的操作987654327@ 可能会影响y,即使p 持有y 的地址。即使位模式与y 的地址的位模式匹配,指针p 的行为也会像它的地址无效一样有效。

【讨论】:

  • x86-64 仅具有 48 位虚拟地址(或未来硬件上具有 5 级页表的 57 位)。规范地址是正确符号扩展为 64 位的地址,因此可用范围是虚拟地址空间顶部和底部的低 47 位范围和高 47 位范围。您可以将非规范指针称为“无效的非空地址”,但只要您从不取消引用它们,它们仍然可以作为整数工作。另见Should pointer comparisons be signed or unsigned in 64-bit x86?
  • 这是ptrdiff_t delta = (uintptr_t)&amp;y - (uintptr_t)&amp;x; 的一个很好的例子,因为&amp;x&amp;x+delta 都是有效的,但它们并不指向同一个对象,因此巧妙地违反了 [expr.add]/4。也很好地解释了它如何导致别名优化在以后意外地改变程序的结果。谢谢。
  • 出于某种原因,关于(char*)(delta+(uintptr_t)&amp;x); 是否应该能够访问y 似乎存在一些严肃的争论,但我质疑为什么任何实现都不愿意尊重这种语义应该首先定义uintptr_t [它完全是可选的]。恕我直言,整数到指针的转换有很大的霓虹灯标志,应该会导致任何不是故意盲目的编译器认识到生成的指针可能能够访问几乎任何地址已转换为整数类型的对象,我真的想不出来......
  • ...在许多非人为的情况下,这将严重阻碍原本有用的优化。可以肯定的是,标准会允许这样的优化,但这只是因为标准 never 要求通过转换 uintptr_t 生成的指针实际上可用于访问任何对象(它只需要 (char*)(uintptr_t)&amp;x比较等于&amp;x——不是说它可以访问x)。标准的作者天真地认为没有必要说编译器作者不应该做傻事。
【解决方案3】:

以下示例表明 GCC 特别假设至少以下内容:

  • 全局数组不能位于地址 0。
  • 数组不能环绕地址 0。

gcc linux x86-64 C++ 中无效指针的算术导致的意外行为示例(感谢 melpomene):

  • largish == NULL 在问题的程序中计算为 false
  • unsigned n = ...; if (ptr + n &lt; ptr) { /*overflow */ } 可以优化为 if (false)
  • int arr[123]; int n = ...; if (arr + n &lt; arr || arr + n &gt; arr + 123) 可以优化为 if (false).

请注意,这些示例都涉及比较无效指针,因此可能不会影响非零基数组的实际情况。所以我开了一个更实用的new question

感谢聊天中的每个人帮助缩小问题范围。

【讨论】:

  • GCC 知道它(和链接器)永远不会将静态数据放在地址 0,所以 largish == NULL 甚至不需要在运行时检查,它已知是错误的。使用asm("largish=0"); 违反编译器的假设基本上是未定义的行为。
  • @Peter Cordes 正确。我怀疑基本上所有的不连续性都在 0 左右。基本上,假设“有效数组”不是从 0 开始,也不是围绕 0。这就是这个答案所指出的。 ...虽然可以稍微澄清一下。
猜你喜欢
  • 2019-07-24
  • 1970-01-01
  • 2021-01-18
  • 1970-01-01
  • 2011-11-19
  • 2013-06-22
  • 2019-04-26
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多