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

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

本文关键字:有效 指针 C++ x86-64 gcc Linux 什么      更新时间:2023-10-16

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

根据标准第3.9.2节:

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

根据[expr.add]/4:

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

并且根据一般有效C++指针上的堆栈溢出问题:

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

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

(我猜这里的"超人"是指"垃圾收集器"......不是我在任何地方读过,只是闻到了。不过,说真的,如果你周围有虚假的指针,所有最好的垃圾收集器都不会严重破坏;在最坏的情况下,他们只是不会时不时地收集一些死物。似乎没有什么值得搞砸指针算术的。

因此,基本上,一个合适的编译器必须支持上述所有有效指针。我的意思是,一个假设的编译器大胆地生成未定义的行为,仅仅因为指针计算不好,至少会躲避上面的 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?因为它似乎经常将任何指针视为有效的指针......举个例子:

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

虽然在阅读了所有语言规范之后,您可能会期望super_tricky_add_operation(a, b)的实现充斥着未定义的行为,但实际上它非常无聊,只是一个addlea指令。这太棒了,因为我可以将其用于非常方便和实用的事情,例如非零基础数组,如果没有人只是为了说明无效指针而使用我的add指令。我gcc.

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

那么......谁能举一个GCC Linux x86-64中无效指针的可靠例子?我所说的固体是指导致未定义的行为。并解释是什么导致了语言规范允许的未定义行为?

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

通常,指针数学完全符合您的期望,无论指针是否指向对象。

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

有趣的极端情况包括虚拟地址空间最顶部的数组:指向"过去一"的指针将换行为零,因此start < end为假?!? 但是指针比较不必处理这种情况,因为 Linux 内核永远不会映射首页,因此指向它的指针不能指向或只是过去的对象。 请参阅为什么我不能在 64 位内核上 mmap(MAP_FIXED) 32 位 Linux 进程中的最高虚拟页面?


相关:

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

请参阅我对 C 中数组的最大大小是多少的评论? - 此限制允许 gcc 实现指针减法(以获得大小),而无需保留高位的结转,对于比char宽的类型,其中 C 减法结果在对象而不是字节中,因此在 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 */
  • RIP相对或32位绝对(-fno-pie -no-pie)寻址无法访问BSS中large[]后链接的静态数据,默认代码模型(-mcmodel=small假定所有静态代码+数据都适合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对所有静态代码/数据执行此操作,因此每个调用都是间接的movabs reg,imm64/call reg而不是call rel32

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

    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,或等效于打印原始void*printf("%p")

该标准预计除了实现通过静态、自动或线程持续时间的对象或使用标准库函数(如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,这也不能保证编译器会认识到对*p的操作可能会影响y,即使p持有y的地址。 指针p的行为就像它的地址无效一样,即使位模式与y的地址相匹配。

以下示例显示 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).

请注意,这些示例都涉及无效指针的比较,因此可能不会影响非从零开始的数组的实际情况。因此,我提出了一个更实际的新问题。

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