gcc linux x86-64 C++ 中的有效指针是什么?

What is a valid pointer in gcc linux x86-64 C++?

我正在一个名为 linux x86-64 的模糊系统上使用 gcc 编写 C++ 程序。我希望可能有一些人使用过这个相同的特定系统(并且可能还能够帮助我理解什么是这个系统上的有效指针)。 我无意访问指针指向的位置,只想通过指针运算计算。

根据标准第 3.9.2 节:

A valid value of an object pointer type represents either the address of a byte in memory (1.7) or a null pointer.

并且根据[expr.add]/4

When an expression that has integral type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the expression P points to element x[i] of an array object x with n elements, the expressions P + J and J + P (where J has the value j) point to the (possibly-hypothetical) element x[i + j] if 0 ≤ i + j ≤ n; otherwise, the behavior is undefined. Likewise, the expression P - J points to the (possibly-hypothetical) element x[i − j] if 0 ≤ i − j ≤ n; otherwise, the behavior is undefined.

并且根据 :

Is 0x1 a valid memory address on your system? Well, for some embedded systems it is. For most OSes using virtual memory, the page beginning at zero is reserved as invalid.

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

(我猜 "Superman" 在这里我的意思是 "garbage collectors"... 不是我在任何地方读到的,只是闻到了它的味道。不过说真的,所有最好的垃圾收集器都不会坏如果你周围有伪造的指针,这是一种严肃的方式;在最坏的情况下,他们只是不时不时地收集一些死对象。似乎没有什么值得搞砸指针算法的。)。

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

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

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

#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操作说明。这太棒了,因为我可以将它用于非常方便和实用的事情,例如 如果没有人使用我的 add 指令只是为了说明无效指针。我gcc.

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

所以...任何人都可以给出 gcc linux x86-64 中 无效 指针的可靠示例吗?我所说的坚实是指导致未定义的行为。并解释是什么导致了语言规范允许的未定义行为?

(或提供 gcc 证明相反的文件:所有指针均有效)。

以下示例表明 GCC 至少明确假定以下内容:

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

在 gcc linux x86-64 C++(谢谢你的 melpomene)中由对无效指针的算术引起的意外行为的例子:

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

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

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

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

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

有趣的极端情况包括虚拟地址最顶部的数组 space:指向末尾后一位的指针会回绕到零,所以 start < end 会是假的?! ?但是指针比较不必处理这种情况,因为 Linux 内核永远不会映射首页,因此指向它的指针不能指向或只是指向对象。参见


相关:

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

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


Don't ask how I knew that the default memory manager would allocate something inside of the other array. It's an obscure system setting. The point is I went through weeks of debugging torment to make this example work, just to prove to you that different allocation techniques can be oblivious to one another).

首先,您实际上从未分配 space large[]。您使用内联 asm 使其从地址 0 开始,但实际上没有做任何事情来映射这些页面。

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

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

你未分配的 space 从 0 到 ~2^59 涵盖了所有用户-space 虚拟内存地址,这些地址在 x86-64 Linux 上是可能的,所以当然你分配的任何东西(包括其他静态数组)将在某处 "inside" 这个假数组。


从声明中删除extern const(所以数组实际分配的,https://godbolt.org/z/Hp2Exc)运行s分为以下问题:

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

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

    $ 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 对所有静态 code/data 执行此操作,因此每次调用都是间接的 movabs reg,imm64 / call reg 而不是 call rel32。)

    这让我们可以编译 link,但是可执行文件不会 运行 因为内核知道只有 48 位虚拟地址是支持并且不会在 运行ning 之前将程序映射到其 ELF 加载器中,或者在 运行ning 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"); 欺骗编译器 + linker + 运行time 不是很有趣,并且会产生明显的未定义行为。

有趣的事实 #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*.

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

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

碰巧的是,在某些执行环境中,编译器将指针算术像整数数学一样处理比做任何其他事情更方便,而且许多用于此类平台的编译器将指针算术有用地处理,即使在它们的情况下也是如此不需要这样做。对于 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,这也无法保证编译器会认识到对 *p 的操作可能会影响 y,即使 p 持有 y 的地址。即使位模式与 y 的地址相匹配,指针 p 的有效行为就好像其地址无效一样。