一个指针指向 C++ 寄存器是否合法?

29
假设C ++编译器为CPU寄存器未映射到内存的架构编译了代码。同样,让我们假设同一编译器为CPU寄存器保留了一些指针值。
例如,如果编译器出于某种原因(例如优化原因)对变量使用寄存器分配(不是register关键字),并且我们打印该变量的引用值,则编译器将返回其中一个保留的“地址值”。
那么,这个编译器是否被认为是符合标准的?
从我所了解的(我没有阅读整个文件-Working Draft,Programming Language C ++的标准),我怀疑标准没有提到RAM内存或操作内存,而是定义了自己的内存模型和指针作为地址表示(可能是错误的)。
现在,由于寄存器也是一种形式的内存,我可以想象将寄存器视为内存模型的一部分的实现可能是合法的。

据我所知,在任何CPU架构上,指针都不能指向寄存器,只能指向内存位置。当然,我并不了解所有的CPU架构,比如大型机系统或古老的架构。但在任何常见的PC类型的CPU上都是不可能的。 - Some programmer dude
@Yunnosch 也许你在想零页的“寄存器”?因为标准累加器(A)和索引寄存器(YX)以及其他寄存器并不是。我记得没有这样的情况,现在也找不到任何相关信息。 - Some programmer dude
1
我可能错了,不要在意。但是我记得十几岁时曾想过“酷,我可以查看CPU。”(关于A、X、Y)。我改变了对被称为“古老”(或“复古”)的看法。实际上这听起来有点尊重和敬畏。没问题。 - Yunnosch
1
据我理解,内存映射这个术语是指一种体系结构模型,其中寄存器是“物理内存地址空间”的一部分(不确定CPU寄存器是否应该成为其一部分,因为我认为它们可以被任意访问)。问题是关于指针的,它们是C++内存模型的逻辑表示。(同样可能是错误的) - wolfofuniverse
4
@Someprogrammerdude,AVR 微控制器具有内存映射的主要寄存器。它们用于例如 Arduino 板,这些板是用 C++ 编程的。不是大型计算机,也不是古老的设备,虽然也不是 PC,因为那几乎意味着 x86(-64),但也不是如此模糊的架构。 - ilkkachu
显示剩余8条评论
4个回答

40
指针指向C++寄存器是否合法?
是的。
那编译器是否符合标准?
当然。
C++不知道“寄存器”是什么。指针指向对象(和函数),而不是“内存位置”。标准描述了程序的行为,而不是如何实现它。描述行为使其抽象化-使用什么以及如何使用无关紧要,只有结果才重要。如果程序的行为与标准所述的匹配,则对象存储在何处无关紧要。
我可以提到intro.memory
3. 内存位置是标量类型的对象,不是位域或所有具有非零宽度的相邻位域的最大序列之一。
compund
可以通过以下方式构建复合类型:
指向给定类型的cv void或对象或函数的指针(包括类的静态成员),
[...]指针类型的每个值都是以下内容之一:
指向对象或函数的指针(指针被称为指向对象或函数),或
超过对象的末尾的指针([expr.add]),或
该类型的空指针值,或
无效的指针值。
[...]指针类型的值表示是实现定义的。 [...]
要对指针执行任何有用的操作,例如应用 * 运算符unary.op或比较指针expr.eq,它们必须指向某些对象(除了边缘情况,例如在比较中的 NULL )。“对象”存储的“位置”表示法相当模糊-内存存储“对象”,内存本身可以位于任何地方。
例如,如果编译器出于优化原因(例如)对变量进行寄存器分配(不是指寄存器关键字),我们打印对该变量的引用值,则编译器会返回其中一个保留的“地址值”。
std::ostream::operator<<调用std::num_put,而void*的转换为%p facet.num.put.virtuals。来自C99 fprintf
[The conversion %]p 参数必须是void指针。指针的值按照实现定义的方式转换为一系列打印字符。
但请注意,来自C99 fscanf
[The conversion specified %]p 匹配实现定义的一组序列,应与fprintf函数的%p转换可以生成的序列相同。相应的参数应该是指向指针的指针。输入项以实现定义的方式转换为指针值。如果输入项是在同一程序执行期间先前转换的值,则结果指针应与该值相等;否则%p转换的行为未定义。
所打印的内容必须对该对象是唯一的,仅此而已。因此,编译器必须为寄存器中的地址选择某些唯一值,并在请求转换时打印它们。从/to uintptr_t的转换也将以实现定义的方式实现。但这将全部是实现细节 - 实现代码行为的方式的实现细节对C ++程序员不可见。

这已经很有说服力了。但是您能否通过引用一些标准来进一步加强它呢? - Yunnosch
我有类似的直觉,但是这篇帖子上的一些回复似乎表明它取决于架构。https://dev59.com/S1HTa4cB1Zd3GeqPPCZS。 - wolfofuniverse
9
这取决于计算机架构是否“可能”,而不是C++标准是否允许。在某些计算机架构中,从寄存器取地址是不可能的,因为物理限制。因此,在这些计算机架构上,编译器就不能这样做。尽管如此,编译器仍然可以将类似 *pointer 的代码转换为伪代码,例如 if (pointer == 0x1) { use_EAX_register; } elseif (pointer == 0x2) { use_another_register; } elseif ( 对于每个寄存器等等 } else { dereference_the_actual_pointer; },但生成的代码会非常慢。 - KamilCuk
1
fscanf的规范似乎排除了符合C++标准的实现中任何垃圾收集器的可能性,因为程序可以将指针地址输出到打印机,然后放弃它,实现无法知道是否有人会稍后读取该地址并将其放置在fscanf将接收它的位置。 如果发生这种情况,则由该指针标识的对象仍应处于活动状态,即使在此之前机器上不存在地址的副本。 - supercat
2
@supercat:GC实现可以依赖于std::declare_reachable。你认为对象应该保持存活的假设是不正确的。 - MSalters
显示剩余10条评论

8

在C++中,如果register关键字没有被弃用,它只是向编译器提出的一个建议而不是强制要求。能否实现指向寄存器的指针取决于平台是否支持指向寄存器的指针或者这些寄存器是否内存映射。有些平台存在一些内存映射的寄存器。

当编译器遇到POD变量声明时,编译器可以使用寄存器来存储该变量。但是,如果平台不支持指向寄存器的指针,编译器可能会将变量分配到内存中,特别是当获取变量的地址时。

给出一个例子:

int a; // Can be represented using a register.  

int b;
int *p_b = &b;  // The "b" variable may no longer reside in a register
               // if the platform doesn't support pointers to registers.  

在许多常见平台上,例如ARM处理器,寄存器位于处理器的内存区域(特殊区域)内。这些寄存器没有从处理器输出的地址线或数据线。因此,它们不占用处理器地址空间中的任何空间。还没有针对ARM处理器返回寄存器地址的指令。因此,如果代码使用变量的地址,编译器会将变量的分配从寄存器更改为存储在处理器外部的内存。

1
处理器的内存区域 - 我认为称其为“寄存器文件”会更清晰,尽管这会混淆物理和逻辑描述。但是,在大多数CPU中,寄存器是一个单独的地址空间,与内存分开。(因此我不喜欢“内存区域”中的“内存”一词)。是的,对于寄存器,没有间接寻址是可能的,只能通过指令本身的小整数字段进行机器代码编码,例如ARM add r0,r1,r2具有三个4位字段,每个字段选择一个16个通用整数寄存器作为操作数。 - Peter Cordes
此外,int *p_b必须是指针而不是整数,才能编译通过。 - Peter Cordes
当编译器遇到POD变量声明时,...这不是现代编译器的工作方式。现代编译器可以并且会重新排列变量,并且相关的触发器是对变量的赋值。它们甚至可以延迟对p_b的值的分配,直到不可避免的时候;例如,在上面的代码片段中根本没有必要给p_b分配一个值。即使您添加了std::cout << *p_b;,也不必要,因为编译器可以检测到b仍未初始化。 - MSalters
ARM 没有返回寄存器地址的指令并不重要。它也没有返回内存中对象地址的指令。想一想:你为什么要知道 R2 的地址呢?你已经知道它是 2 了。而内存地址 0x00000008 的地址同样只是 0x00000008。C 需要 &foo 是因为 & 是编译器操作。编译器把 foo 放在哪里了? - MSalters

4
在大多数情况下,如果CPU具有内存映射寄存器,则使用其中一些寄存器的编译器将指定它们使用哪些寄存器。 编译器文档未使用的寄存器可以使用volatile限定符指针访问,就像任何其他类型的I / O寄存器一样,前提是它们不会以编译器不期望的方式影响CPU状态。 可能被编译器使用的寄存器的读取通常会产生编译器生成的代码留在那里的任何值,这个值不太可能具有实际意义。 使用编译器的寄存器进行写入可能会以无法有用地预测的方式干扰程序行为。

4
理论上是可以的,但只适用于全局寄存器固定的情况。
(当然,前提是ISA本身具有内存映射CPU寄存器;通常只有微控制器ISA才是这样的;这使得高性能实现更加困难。)

指针在传递给像qsortprintf这样的函数时必须保持有效(继续指向相同的对象),或者传递给您自己的函数。但是,复杂的函数通常会将一些寄存器保存到内存中(通常是堆栈),以在函数结束时恢复, 并在该函数内部将它们自己的值放入这些寄存器中。

因此,如果您选择普通的调用保留寄存器,则当该函数解引用您传递给它的指针时,指向CPU寄存器的指针将指向其他内容,可能是函数的某个局部变量。

我认为解决这个问题的唯一方法是为特定的C++对象在整个程序范围内保留一个寄存器。类似于GNU C/C++中的register char foo asm("r16");,但在假想编译器中,这并不会阻止您获取其地址。这样的假想编译器必须比GCC更严格,以确保全局变量的值始终在该寄存器中,以便通过指针进行的每个内存访问都能够实现。这与GCC文档中对于寄存器asm全局变量的说明不同。您需要重新编译库以确保它们不使用该寄存器进行任何操作(例如gcc -ffixed-r16或让它们查看定义)。
当然,C++实现可以自行决定为某些C++对象(可能是全局对象)执行所有这些操作,包括生成所有库代码以遵守整个程序的寄存器分配。
如果我们只讨论在有限范围内进行此操作(而不是针对未知函数的调用),那么,如果逃逸分析证明所有使用p的地方都是有限的,将int *p = &x;编译为获取CPU寄存器x的地址是安全的。我原本想说这样做没有意义,因为任何这样的证明都会给你足够的信息来优化间接性,并将*p编译为访问寄存器而不是内存,但是有一个用例:
如果你有两个或更多的变量,在解引用p之前执行if(condition)p =&y;编译器可能知道当评估*p时,x肯定仍然在同一个寄存器中,但不知道p是否指向x还是y。因此,保留x或y在寄存器中可能是有用的,特别是如果它们也被其他与p的解引用混合读/写的代码直接读/写。

当然,我一直假设使用的是“正常”的ISA和“正常”的调用约定。可以想象出奇怪和美妙的机器,以及在它们上面或正常机器上实现的C++,可能会有非常不同的工作方式。


ISO C++对此的看法:不多

ISO C++抽象机器具有内存,每个对象都有一个地址。(如果从未使用该地址,则受as-if规则的限制。)将数据加载到寄存器中是实现细节。

因此,在像AVR(8位RISC微控制器)或8051这样的机器上,C++指针可以指向它们的某些CPU寄存器1。在一些微控制器(如AVR2)上,具有内存映射的CPU寄存器是一种事物。例如,AVR微控制器中将寄存器作为内存的好处是什么?有一个图表。(并询问为什么我们需要寄存器,而不是只使用内存地址,如果它们将被内存映射。)

这个AVR Godbolt 链接并没有展示太多内容,主要只是在玩弄 GNU C 寄存器-汇编全局变量。


注1:在普通的C++实现中,对于普通的ISA,C++指针可以直接映射到一个机器地址,可以通过汇编语言进行间接引用。(在像6502这样的机器上可能非常不方便, 但仍然可以)。

在没有虚拟内存的机器上,这样一个指针通常是一个物理地址。(假设使用普通的平面内存模型,而不是分段)。我不知道有任何带有虚拟内存和内存映射CPU寄存器的ISA,但是有很多我不知道的晦涩的ISA。如果存在这样的ISA,将寄存器映射到虚拟地址空间的固定部分可能是有意义的,以便可以并行地检查地址以进行寄存器访问和TLB查找。无论哪种方式,都会使ISA的流水线实现变得非常困难,因为检测需要绕过转发(或暂停)的RAW hazards等冲突现在涉及到检查内存访问。普通的ISA只需要在解码机器指令时相互匹配寄存器号码。随着内存允许通过寄存器进行间接寻址,memory disambiguation / store forwarding需要与检测指令何时读取先前寄存器写入的结果进行交互,因为该读取或写入可能是通过内存进行的。
有一些旧的非流水线CPU带有虚拟内存,但是流水线是你永远不会想要在现代ISA上将寄存器内存映射为主CPU用于桌面/笔记本电脑/移动设备等性能相关领域的主要原因之一。现在,包含虚拟内存的复杂性却没有流水线设计几乎没有意义。有一些带有虚拟内存的流水线微控制器/低端CPU。

注2:在现代主流32位和64位ISA上基本不存在内存映射CPU寄存器。通用寄存器通常是否被内存映射?

带有内存映射CPU寄存器的微控制器通常将寄存器文件实现为内部SRAM的一部分,它们以任何方式作为常规内存。

在ARM、x86-64、MIPS和RISC-V以及所有类似的ISA中,寻址寄存器的唯一方式是通过将寄存器编号编码到指令的机器代码中。只有自修改代码才可能进行寄存器间接寻址,而C++并不需要这样做,正常实现也不使用这种技术。此外,寄存器编号是一个与内存分开的单独地址空间。例如,ARM有16个基本整数寄存器,因此像add r0, r1, r2这样的指令将在该机器指令的编码中有三个4位字段,每个操作数一个。(在ARM模式下,不是Thumb模式。)这些寄存器编号与内存地址012没有任何关系。
请注意,内存映射的I/O寄存器在所有现代ISA上都很常见,通常与RAM共享物理地址空间。I/O地址通常被称为寄存器,但寄存器位于外设中,例如网络卡,而不是CPU中。读取或写入它将产生一些副作用,因此在C++中,您通常会使用volatile int *constexpr ioport = 0x1234;或类似的内容进行MMIO。MMIO寄存器绝对不是可以在像AArch64 add w0, w1, w2这样的指令中使用的通用整数寄存器之一。

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