使用64位指针中的额外16位

35

我看到一篇文章说64位机器实际只使用了48位地址(特别地,我正在使用Intel Core i7)。

我原本认为额外的16位(第48-63位)对地址无关紧要,并且会被忽略。但是当我试图访问这样的地址时,我得到了一个EXC_BAD_ACCESS信号。

我的代码是:

int *p1 = &val;
int *p2 = (int *)((long)p1 | 1ll<<48);//set bit 48, which should be irrelevant
int v = *p2; //Here I receive a signal EXC_BAD_ACCESS.

为什么会这样?有没有办法使用这16位?

这可以用于构建更加缓存友好的链表。不再需要使用8字节作为下一个指针和8字节作为键(因为对齐限制),可以将键嵌入到指针中。


9
这些位不会被忽略,而是会被检查以确定地址是否符合规范。 - harold
使用的位数取决于架构。例如,ARM64上的iOS仅使用33位地址。目前在x86_64上仅使用48位。 - phuclv
2
如果你想的话,可以打包结构体,这样就不会浪费字节用于填充。x86具有快速的非对齐访问。 - Peter Cordes
1
我可以使用一些指针的位(x86_64)来存储自定义数据吗?如果可能的话,怎么做? - phuclv
6个回答

58

高位保留是为了以后地址总线增加的情况下使用,因此您不能简单地使用它。

AMD64架构定义了一种64位虚拟地址格式,其中当前实现中使用低48位(...) 架构定义允许在未来实现中将此限制提高到完整的64位,将虚拟地址空间扩展到16 EB(2^64字节)。 这与x86的仅4 GB(2^32字节)相比。

http://en.wikipedia.org/wiki/X86-64#Architectural_features

更重要的是,根据同一篇文章 [强调我的]:

在架构的最初实现中,虚拟地址的最低有效48位将用于地址转换(页表查找)。此外,任何虚拟地址的第48到63位必须是第47位的副本(类似于符号扩展),否则处理器会引发异常。符合此规则的地址称为“规范形式”。 由于即使未使用,CPU也会检查高位,因此它们并不是真正的“无关紧要”。在使用指针之前,您需要确保地址是规范的。一些其他的64位架构,如ARM64,有忽略高位的选项,因此可以更轻松地在指针中存储数据。

话虽如此,在x86_64中,如果需要,仍然可以自由地使用高16位(如果虚拟地址不超过48位,请参见下文),但在解除引用之前,必须检查和修复指针值通过符号扩展它。

请注意,将指针值强制转换为long并不是正确的方法,因为long不能保证足够宽以存储指针。您需要使用uintptr_tintptr_t

int *p1 = &val; // original pointer
uint8_t data = ...;
const uintptr_t MASK = ~(1ULL << 48);

// === Store data into the pointer ===
// Note: To be on the safe side and future-proof (because future implementations
//     can increase the number of significant bits in the pointer), we should
//     store values from the most significant bits down to the lower ones
int *p2 = (int *)(((uintptr_t)p1 & MASK) | (data << 56));

// === Get the data stored in the pointer ===
data = (uintptr_t)p2 >> 56;

// === Deference the pointer ===
// Sign extend first to make the pointer canonical
// Note: Technically this is implementation defined. You may want a more
//     standard-compliant way to sign-extend the value
intptr_t p3 = ((intptr_t)p2 << 16) >> 16;
val = *(int*)p3;

WebKit的JavaScriptCore和Mozilla的SpiderMonkey引擎以及LuaJITnan-boxing技术中使用了这个。如果该值为NaN,则低48位将存储对象指针,高16位用作标记位,否则它是双精度值。

以前Linux也使用GS基地址的第63rd来指示该值是否由内核编写

实际上,您通常也可以使用第48th位。因为大多数现代64位操作系统将内核空间和用户空间平分,所以第47位始终为零,您有17个顶部位可供使用


你还可以使用低位来存储数据,这被称为tagged pointer。如果int是4字节对齐的,则2个低位始终为0,您可以像在32位架构中那样使用它们。对于64位值,您可以使用3个低位,因为它们已经对齐到8字节。同样,在引用之前,您也需要清除这些位。
int *p1 = &val; // the pointer we want to store the value into
int tag = 1;
const uintptr_t MASK = ~0x03ULL;

// === Store the tag ===
int *p2 = (int *)(((uintptr_t)p1 & MASK) | tag);

// === Get the tag ===
tag = (uintptr_t)p2 & 0x03;

// === Get the referenced data ===
// Clear the 2 tag bits before using the pointer
intptr_t p3 = (uintptr_t)p2 & MASK;
val = *(int*)p3;

One famous user of this is the V8 engine with SMI (small integer) optimization. The lowest bit in the address will serve as a tag for type:
  • if it's 1, the value is a pointer to the real data (objects, floats or bigger integers). The next higher bit (w) indicates that the pointer is weak or strong. Just clear the tag bits and dereference it.
  • if it's 0, it's a small integer. In 32-bit V8 or 64-bit V8 with pointer compression it's a 31-bit int, do a signed right shift by 1 to restore the value; in 64-bit V8 without pointer compression it's a 32-bit int in the upper half.
   32-bit V8
                           |----- 32 bits -----|
   Pointer:                |_____address_____w1|
   Smi:                    |___int31_value____0|
   
   64-bit V8
               |----- 32 bits -----|----- 32 bits -----|
   Pointer:    |________________address______________w1|
   Smi:        |____int32_value____|0000000000000000000|

https://v8.dev/blog/pointer-compression


所以根据下面的评论,英特尔已发布了PML5,提供了57位虚拟地址空间,如果你在这样的系统上,只能使用7个高位。
你仍然可以采用一些变通方法来获取更多的免费位数。首先,您可以尝试在64位操作系统中使用32位指针。在Linux中,如果允许x32abi,则指针只有32位长。在Windows中,只需清除/LARGEADDRESSAWARE标志,指针现在仅具有32个有效位,您可以使用上32位进行您的目的。请参阅 如何在Windows上检测X32?。另一种方法是使用一些指针压缩技巧:V8中压缩指针实现与JVM的压缩Oops有何不同?

您可以通过请求操作系统仅在低地址区域分配内存来获得更多的位。例如,如果您可以确保您的应用程序永远不会使用超过64MB的内存,则只需要26位地址。如果所有的分配都是32字节对齐的,那么您可以再使用5个位,这意味着您可以在指针中存储64-21=43位信息!

我猜ZGC就是其中一个例子。它仅使用42位寻址,允许242字节= 4 × 240字节= 4 TB。

因此,ZGC仅保留了16TB的地址空间(但实际上并未使用所有这些内存),从4TB地址开始。

A first look into ZGC

它使用指针中的位如下:

 6                 4 4 4  4 4                                             0
 3                 7 6 5  2 1                                             0
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
|                   | |    |
|                   | |    * 41-0 Object Offset (42-bits, 4TB address space)
|                   | |
|                   | * 45-42 Metadata Bits (4-bits)  0001 = Marked0
|                   |                                 0010 = Marked1
|                   |                                 0100 = Remapped
|                   |                                 1000 = Finalizable
|                   |
|                   * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)

如需更多信息,请参见


小提示:对于与指针相比具有微小键值的情况使用链表会浪费大量内存,并且由于缓存局部性不佳而更慢。实际上,在大多数现实生活问题中都不应该使用链表。


6
一个非常非常非常重要的警告:规范形式存在的原因是为了使那16位难以被重新用于其他目的。总有一天,它们会打开所有64位,然后你的代码将会出问题。 - Karl
4
@Karl,你可以使用从最高有效位开始的比特位,而不是从第48位开始。这样可以降低代码在不太遥远的未来被破解的风险。在可预见的未来,个人 CPU 拥有完整的64位总线宽度的可能性极小。 - phuclv
4
无论如何,使用低位总是安全的,如果不需要那么多位,请使用低位。 - phuclv
3
警告!如果这些顶部16位中的任何一位不为零,则代码“intptr_t p3 = ((intptr_t)p2 << 16) >> 16;”是未定义行为,因为C++认为它是有符号溢出。您需要使用无符号数。不幸的是,要进行符号扩展,您需要使用带符号数。同样不幸的是,带符号右移是实现定义的。无论如何,您应该使用intptr_t p3 = (intptr_t)((uintptr_t)p2 << 16) >> 16; 这在所有已知的x86-64编译器上都有效,或者如果您想要真正定义良好,请使用除法:intptr_t p3 = (intptr_t)((uintptr_t)p2 << 16) / 65536; https://godbolt.org/g/5P4tJF - jorgbrown
2
PML5已经有文档记录,甚至可能已经在硬件上可用。额外的页表级别为我们提供了57位虚拟地址,如果操作系统选择启用它的话,会留下7个高位。(对于16字节对齐的分配,还有4个低位可以更轻松、更有效地清除。)此外,请注意,最好情况下,在解引用之前修改指针将指针追踪延迟从4个周期增加到6个周期:1个ALU操作加上击败SnB系列的快速路径简单的[reg+0..2047]寻址模式。 - Peter Cordes
显示剩余13条评论

5

我猜在这种情况下没有人提到可能使用位域 (https://en.cppreference.com/w/cpp/language/bit_field),例如:

template<typename T>
struct My64Ptr
{
    signed long long ptr : 48; // as per phuclv's comment, we need the type to be signed to be sign extended
    unsigned long long ch : 8; // ...and, what's more, as Peter Cordes pointed out, it's better to mark signedness of bit field explicitly (before C++14)
    unsigned long long b1 : 1; // Additionally, as Peter found out, types can differ by sign and it doesn't mean the beginning of another bit field (MSVC is particularly strict about it: other type == new bit field)
    unsigned long long b2 : 1;
    unsigned long long b3 : 1;
    unsigned long long still5bitsLeft : 5;

    inline My64Ptr(T* ptr) : ptr((long long) ptr)
    {
    }

    inline operator T*()
    {
        return (T*) ptr;
    }
    inline T* operator->()
    {
        return (T*)ptr;
    }
};

My64Ptr<const char> ptr ("abcdefg");
ptr.ch = 'Z';
ptr.b1 = true;
ptr.still5bitsLeft = 23;
std::cout << ptr << ", char=" << char(ptr.ch) << ", byte1=" << ptr.b1 << 
  ", 5bitsLeft=" << ptr.still5bitsLeft << " ...BTW: sizeof(ptr)=" << sizeof(ptr);

// The output is: abcdefg, char=Z, byte1=1, 5bitsLeft=23 ...BTW: sizeof(ptr)=8
// With all signed long long fields, the output would be: abcdefg, char=Z, byte1=-1, 5bitsLeft=-9 ...BTW: sizeof(ptr)=8 

如果我们真的想要节省一些内存,我认为利用这16位可能是一种非常方便的方法。所有的按位(& and |)操作和转换为完整的64位指针都由编译器完成(尽管当然是在运行时执行)。


1
你需要使用 long long ptr : 48 而不是 unsigned long long 来进行指针的符号扩展。 - phuclv
1
我建议您使用前7位来处理自己的数据,而不是48位以上的第7或8位。使用移位操作可以更便宜地提取顶部位,而不会留下任何需要清除的高垃圾位。(对于另一级页表的PML5,虚拟地址宽度为57位,只剩下7个未使用的位。但如果您假设指针都在较低的规范范围内,则可以使用8个高位并通过使用“unsigned long long ptr”进行零扩展,始终清除最高有效位。) - Peter Cordes
请注意,在 C++14 之前,位域的有符号性不是保证的,除非您在使用之前明确指定。因此,最好使用 signed long long。 (请参见 https://en.cppreference.com/w/cpp/language/bit_field 底部的注释) - Peter Cordes
我没有看到cppreference提到所有位域成员都必须是相同类型。clang for x86-64 System V (https://godbolt.org/z/djP86v)仍将它们打包成一个8字节的对象,即使您有一个混合了`signed long long ptrunsigned int的情况,即使在字节边界处存在类型差异。 ISO C ++没有保证这一点吗?哦,显然不是; MSVC使结构在具有signed long longunsigned int成员时为16字节。但是当它是signed和unsignedlong long`成员时仍为8:https://godbolt.org/z/6GWrM4 - Peter Cordes
因此,成员类型是否打包在一起或者是否开始新的块是ABI的选择。我不确定仅有符号不同是否也是一种选择,x86-64 SysV和Windows x64可能会选择相同。但无论如何,这些都是唯一的x86-64 ABI,所以你仍然可以使用它。(除了x32,在长模式下ILP32中,指针的所有32位都是有效的,但你可以将它们存储在8字节的ptr:tag对中。) - Peter Cordes
显示剩余3条评论

4
符合标准的 AMD/Intel x64 指针规范化方法(基于当前规范指针和 48 位寻址的文档)是:
int *p2 = (int *)(((uintptr_t)p1 & ((1ull << 48) - 1)) |
    ~(((uintptr_t)p1 & (1ull << 47)) - 1));

首先清除指针的高16位。然后,如果第47位为1,则设置第47到63位,但如果第47位为0,则进行0的逻辑或运算(不改变值)。


5
请注意,在大多数操作系统的用户空间中,您可以将指针视为在虚拟地址空间的低半部分,其中符号扩展等同于零扩展。因此,您实际上只需要 AND 掩码部分。 - Peter Cordes
如果你要处理完整的一般情况,最有效的方法是重新使用((intptr_t)p1 << 16) >> 16进行符号扩展。 (或者如果你关心那些不定义有符号整数向左移位行为的编译器,可以使用uintptr_t进行左移位)。这样也避免了需要任何64位常量,尽管它的关键路径延迟比(uintptr_t)p1 & ((1ULL<<48) - 1)要差。 - undefined
@PeterCordes 这个问题不仅仅是你指出的有符号类型左移的未定义行为,还有(在C和C++20之前的任何C++中)负数右移的实现定义行为(它不保证是算术右移,因此不能保证对数字进行符号扩展!)。 - undefined
如果你担心这种可移植性水平,你就不能对指针的位模式和从T*uintptr_t的转换做出假设。所以你一开始就不能真正地这样做。所有人关心的x86-64编译器都对有符号类型使用算术右移。GNU C文档中有说明,当然MSVC也是这样做的,除非有意为之,否则我会感到震惊。避免未定义行为可能是有用的,因为过于激进的优化器,不像依赖于实现定义的行为那样。 - undefined
让我们在聊天中继续这个讨论。 - undefined
显示剩余6条评论

2
根据英特尔手册(卷1,第3.3.7.1节),线性地址必须采用规范形式。这意味着只使用48位,并且额外的16位进行符号扩展。此外,实现需要检查地址是否符合该形式,如果不符合则生成异常。这就是为什么没有办法使用这16个附加位的原因。
采取这种方式的原因很简单。目前48位虚拟地址空间已经足够了(由于CPU生产成本,扩大它没有意义),但毫无疑问,在未来将需要额外的位数。如果应用程序/内核要将它们用于自己的目的,则会出现兼容性问题,这就是CPU供应商想要避免的问题。

4
there is no way to use those additional 16 bits不正确。在可预见的未来中有几种可以使用的方式。” - phuclv

-1
尝试在位移后打印更改后的指针ptr: ```cpp int var{ 1 }; int* p{ &var }; cout << p; p = (int*)((uintptr_t)p | 1ll << 50); cout << " shifted: " << p; ```
我得到了这个输出: enter image description here 所以指针的值改变了,但是为什么会出现"访问冲突"的错误?
这个错误意味着有人试图访问未被保留的内存:https://stackoverflow.com/a/16071764/11844611。当你在第三行解引用时,就会出现这个错误。 例如,当i == 10时,我遇到了这个错误:
for (int i = 1; i < 64; i++) { p = (int*)((uintptr_t)p | 1ll << i); int v = *p; }

你没有理解重点。对于i == 48或更大的情况,地址是非规范的,因此根本就不可能被保留。硬件故障是#GP(0)而不是#PF(页面错误),因为无论页表中的内容是什么,该地址都不可能有效;它超出了页表覆盖的虚拟地址范围。另请参阅为什么在x86-64中,虚拟地址比物理地址短4位(48位对52位)?。你对小的i进行的测试与设置高位有本质的不同。 - undefined
而且,我很惊讶你在i == 10这里出现了错误;那只是在地址的页面内偏移部分设置一个位而已。对于任何你可以解引用的有效pp | (1<<10)也应该是一个你可以解引用而不会出错的有效地址。 - undefined
哦,你在循环中每次更新相同的p,所以指针的低位被设置,并且你正在进行一个未对齐的双字(4字节)加载。因此,只需设置所有页面内偏移位,然后从页面倒数第二个字节开始进行4字节加载将导致错误。(当我测试时,i==11,这是有意义的,而i==10则不是,因为4K页面意味着12个页面偏移位。)你从i=1开始,所以指针的最低位不会被设置,因此仍然是2字节对齐的。当我使用int x = 0; int *p = &x;作为局部变量进行测试时,错误地址是0x7fffffffeffe - undefined

-2

物理内存是48位寻址的。这足以寻址大量RAM。然而,在CPU核心上运行的程序和RAM之间是内存管理单元,它是CPU的一部分。您的程序正在寻址虚拟内存,而MMU负责在虚拟地址和物理地址之间进行转换。虚拟地址为64位。

虚拟地址的值并不能告诉您有关相应物理地址的任何信息。实际上,由于虚拟内存系统的工作方式,没有保证相应的物理地址会时刻相同。如果您使用mmap()进行创意操作,可以使两个或多个虚拟地址指向相同的物理地址(无论它在哪里)。如果您随后写入其中任何一个虚拟地址,则实际上只写入一个物理地址(无论它在哪里)。这种技巧在信号处理中非常有用。

因此,当您篡改指针的第48位(该指针指向虚拟地址)时,MMU无法在操作系统(或使用malloc()自己分配的内存表)的内存分配表中找到该新地址。它会抗议地引发中断,操作系统捕获该中断并使用您提到的信号终止您的程序。

如果您想了解更多信息,建议您搜索“现代计算机体系结构”,并阅读有关支撑您的程序的硬件的相关内容。


5
在当前的x86_64实现中,虚拟内存实际上是48位寻址(参见Intel手册,卷1,3.3.7.1),其余的16位是符号扩展的。物理地址范围的大小取决于具体的实现(参见Intel手册,卷3,3.3.1)。 - Paweł Dziepak
相关内容:为什么在x86-64中,虚拟地址比物理地址少4位(48位 vs. 52位)? - 物理地址空间的上限由页表项格式确定,虚拟地址的48个有效位由页表深度确定。(4级或使用PML5的5级可以获得57位的虚拟地址。) - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接