【问题标题】:Is it legal for a pointer to point to a C++ register?指向 C++ 寄存器的指针是否合法?
【发布时间】:2021-02-14 22:21:25
【问题描述】:

假设 C++ 编译器为 CPU 寄存器未映射到内存的架构编译代码。并且假设同一个编译器为 CPU 寄存器保留了一些指针值。

例如,如果编译器出于某种原因(例如优化原因)对变量使用寄存器分配(不是在谈论寄存器关键字),并且我们打印对该变量的引用的值,编译器将返回保留的“地址值”之一。

该编译器会被视为符合标准吗?

据我所知(我还没有阅读全部内容 - Working Draft, Standard for Programming Language C++),我怀疑该标准没有提到 RAM 内存或操作内存之类的东西,而且它而是定义了自己的内存模型,并将指针定义为地址的表示(可能是错误的)。

既然寄存器也是一种内存形式,我可以想象将寄存器视为内存模型一部分的实现可能是合法的。

【问题讨论】:

  • 指针不能指向我所知道的任何 CPU 架构上的寄存器,只能指向内存位置。诚然,我并不了解所有 CPU 架构,比如大铁系统或古代架构。但这在任何普通的 PC 型 CPU 上是不可能的。
  • @Someprogrammerdude 据我所知,寄存器是在 C64 上映射的内存。但是不能引用/引用任何东西......(请不​​要把它算在古代,否则你也会让我感觉。)
  • 我可能是错的。不打扰。但我记得当时想“酷,我可以查看 CPU。” (大约完全是 A,X,Y)作为一个青少年。我改变了关于被称为“古代”(或“复古”)的想法。这实际上听起来有点尊重和“敬畏”。没关系。
  • @Someprogrammerdude 据我了解,内存映射一词指的是一种架构模型,其中寄存器是“物理内存地址空间”的一部分(不确定 CPU 寄存器是否有意义)因为我认为无论如何他们都可以到达)。问题是关于 C++ 内存模型的逻辑表示的指针。(同样,可能是错误的)
  • @Someprogrammerdude,AVR 微控制器具有内存映射的主寄存器。它们用于例如用 C++ 编程的 Arduino 板。不是大铁,不是古代,虽然也不是 PC,因为这实际上意味着 x86(-64),但也不是那么晦涩的架构。

标签: c++ language-lawyer cpu-registers memory-mapping


【解决方案1】:

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

是的。

该编译器会被视为符合标准吗?

当然。

C++ 不知道“寄存器”,不管它是什么。指针指向对象(和函数),而不是“内存位置”。该标准描述了程序的行为以及如何实现它。描述行为使其变得抽象 - 以何种方式使用什么以及如何使用无关紧要,只有结果才是重要的。如果程序的行为符合标准所说的,那么对象的存储位置就无关紧要了。

我可以提intro.memory

  1. 内存位置可以是不是位域的标量类型对象,也可以是宽度均非零的相邻位域的最大序列。

compund:

复合类型可以通过以下方式构造:

  • 指向给定类型的 cv void 或对象或函数(包括类的静态成员)的指针,

[...] 指针类型的每个值都是以下之一:

  • 指向对象或函数的指针(该指针被称为指向对象或函数),或
  • 超过对象末尾的指针 ([expr.add]),或
  • 该类型的空指针值,或
  • 一个无效的指针值。

[...] 指针类型的值表示是实现定义的。 [...]

要对指针做任何有用的事情,例如应用 * 运算符 unary.op 或比较指针 expr.eq,它们必须指向某个对象(边缘情况除外,如比较情况下的 NULL)。确切地存储对象“在哪里”的符号相当模糊 - 内存存储“对象”,内存本身可以在任何地方。


例如,如果编译器出于某种原因(例如优化原因)对变量使用寄存器分配(不是在谈论寄存器关键字),我们打印对该变量的引用的值,编译器将返回一个保留的“地址值”

std::ostream::operator<< 调用std::num_put 并且void* 的转换是%p facet.num.put.virtuals。 来自C99 fprintf

[转化率%]p

参数应该是一个指向void的指针。指针的值以实现定义的方式转换为打印字符序列。

但请注意,来自C99 fscanf

[指定的转化率%]p

匹配一个实现定义的序列集,它应该与 %p 可能产生的序列集相同 fprintf 函数的转换。相应的论点应 是一个指向 void 的指针。输入项被转换为 以实现定义的方式的指针值。如果输入项 是在同一程序执行期间较早转换的值, 结果应与该值比较的指针;否则 %p 转换的行为未定义。

打印的内容对于该对象来说必须是唯一的,仅此而已。因此,编译器必须为寄存器中的地址选择一些唯一值,并在请求转换时打印它们。从/到uintptr_t 的转换也将以实现定义的方式实现。但这一切都在实现中——如何实现代码行为的实现细节对于 C++ 程序员是不可见的。

【讨论】:

  • 这已经很有说服力了。但是,您能否通过引用标准中的一些引号来结束它?
  • 我有类似的直觉,但对这篇文章的一些回复似乎表明它取决于架构 stackoverflow.com/questions/3937171/…
  • 好吧,这取决于架构是否可能,而不是 C++ 标准是否允许。在某些架构上,物理上不可能获取寄存器的地址。所以......在这些架构上,编译器只是不这样做。编译器仍然可以将*pointer 之类的代码转换为if (pointer == 0x1) { use_EAX_register; } elseif (pointer == 0x2) { use_another_register; } elseif ( etc. etc. etc. for each register } else { dereference_the_actual_pointer; } 之类的伪代码,但是生成的代码会非常慢。
  • fscanf 的规范似乎排除了符合 C++ 实现的任何类型垃圾收集器的可能性,因为程序可以将指针的地址输出到打印机,然后在没有实现的情况下放弃它有任何可能的方式知道是否有人稍后会读取该地址并将其放置在fscanf 会收到它的地方。如果发生这种情况,由该指针标识的对象应该仍然是活动的,即使在此之前机器中的任何地方都不存在地址副本。
  • @supercat:GC 实现可以依赖std::declare_reachable。您认为对象应该保持活动状态的假设是不正确的。
【解决方案2】:

在 CPU 具有内存映射寄存器的大多数情况下,使用其中一些寄存器的编译器会指定它们使用哪些寄存器。编译器文档说它不使用的寄存器可以使用volatile-qualified 指针访问,就像任何其他类型的 I/O 寄存器一样,只要它们不会以编译器不期望的方式影响 CPU 状态.对编译器可能使用的寄存器的读取通常会产生编译器生成的代码碰巧留在那里的任何值,这不太可能有意义。编译器使用的寄存器写入可能会以无法有效预测的方式破坏程序行为。

【讨论】:

    【解决方案3】:

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

    是和不是。在 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 指令。因此对于 ARM 处理器,如果代码使用变量的地址,编译器会将变量的分配从寄存器更改为内存(处理器外部)。

    【讨论】:

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

    理论上是的,但只有永久固定到该寄存器的全局才真正合理
    (当然,首先假设一个带有内存映射 CPU 寄存器的 ISA1;通常只有微控制器 ISA 是这样的;这使得高性能实现变得更加困难。)

    当您将指针传递给qsortprintf 等函数或您自己的函数时,指针必须保持有效(保持指向同一个对象)。但是复杂的函数通常会将一些寄存器保存到内存(通常是堆栈)to be restored at the end of the function,并且在该函数内部会将它们自己的值放入这些寄存器中。

    所以指向 CPU 寄存器的指针将指向其他东西,可能是函数的局部变量之一,当该函数取消引用您传递给它的指针时,如果您只选择一个普通的调用保留寄存器。

    我看到解决此问题的唯一方法是为程序范围内的特定 C++ 对象保留一个寄存器。就像在全局范围内类似于 GNU C/C++ register char foo asm("r16"); 的东西,但带有一个假设的编译器,不会阻止你获取它的地址。这样一个假设的编译器必须比 GCC 更严格,以确保对于通过指针进行的每次内存访问,全局的值始终在该寄存器中,这与 GCC documents for register-asm globals 不同。您必须重新编译库以不将该寄存器用于任何内容(例如 gcc -ffixed-r16 或让他们查看定义。)

    当然,C++ 实现也可以决定自己为某个 C++ 对象(可能是全局对象)执行所有这些操作,包括生成所有库代码以尊重整个程序的寄存器分配。

    如果我们只讨论在有限范围内执行此操作(不适用于调用未知函数),那么编译 int *p = &x; 以获取 CPU 寄存器的地址肯定是安全的x 当前在,如果escape analysis 证明p 的所有使用都是有限的。我想说这将是无用的,因为任何此类证明都会为您提供足够的信息来优化间接并编译 *p 以作为寄存器而不是内存访问,但有一个用例:

    如果您有两个或更多变量并且在取消引用p之前执行if (condition) p = &y;,编译器可能知道x在评估*p时肯定仍然在同一个寄存器中,但知道p 是指向x 还是y。因此,将xy 保留在寄存器中可能很有用,特别是如果它们也被其他混合有p derefs 的代码直接读/写。


    当然,我一直在假设一个“正常”的 ISA 和一个“正常”的调用约定。可以想象奇怪而奇妙的机器,和/或它们或普通机器上的 C++ 实现,它们的工作方式可能会有很大不同。


    ISO C++ 对此有何评论:不多

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

    所以是的,在像 AVR(8 位 RISC 微控制器)或 8051 这样的机器中,一些 CPU 寄存器是内存映射的,C++ 指针可以指向它们1。在 AVR2 等一些微控制器上,拥有内存映射的 CPU 寄存器是一件事情。 (例如What is the benefit of having the registers as a part of memory in AVR microcontrollers? 有一个图表。(并且问了一个奇怪的问题,为什么我们有寄存器,而不是仅仅使用内存地址,如果它们将被内存映射。)

    这个AVR Godbolt link 并没有真正显示太多,主要是在玩弄一个 GNU C register-asm global。


    脚注 1:在普通 ISA 的普通 C++ 实现中,C++ 指针非常直接地映射到可以从 asm 中以某种方式取消引用的机器地址。 (Perhaps very inconveniently 在 6502 等机器上,但仍然如此)。

    在没有虚拟内存的机器中,这样的指针通常是物理地址。 (假设是普通的平面内存模型,而不是分段的。)我不知道任何具有虚拟内存 内存映射 CPU 寄存器的 ISA,但是有很多我不知道的晦涩的 ISA .如果存在,将寄存器映射到 virtual 地址空间的固定部分可能是有意义的,因此可以在 TLB 查找的同时检查该地址的寄存器访问。无论哪种方式,它都会使 ISA 的流水线实现成为一个巨大的痛苦,因为检测像 RAW hazards 这样需要绕过转发(或停止)的危害现在涉及检查内存访问。普通 ISA 只需要在解码机器指令时将寄存器编号相互匹配。由于内存允许通过寄存器进行间接寻址,memory disambiguation/存储转发需要与检测指令何时读取先前寄存器写入的结果进行交互,因为读取或写入可能是通过内存进行的。

    有旧的非流水线 CPU 具有虚拟内存,但流水线是您永远希望在现代 ISA 上对寄存器进行内存映射的主要原因之一,并希望将其用作主要内存与性能相关的台式机/笔记本电脑/移动设备的 CPU。如今,包含虚拟内存的复杂性但流水线化设计已经没有什么意义了。有一些流水线微控制器/低端CPU没有虚拟内存。

    脚注 2:内存映射 CPU 寄存器在现代主流 32 位和 64 位 ISA 上基本上不存在。 Do general purpose registers are generally memory mapped?

    具有内存映射 CPU 寄存器的微控制器通常将寄存器文件实现为内部 SRAM 的一部分,无论如何它们都必须充当常规内存。

    在 ARM、x86-64、MIPS 和 RISC-V 以及所有类似的 ISA 中,寻址寄存器的唯一方法是将寄存器编号编码为指令的机器代码。寄存器间接只能通过自修改代码实现,而 C++ 并不需要这些代码,并且正常的实现不使用这些代码。此外,寄存器编号是与内存分开的地址空间。例如ARM 有 16 个基本整数 reg,因此像 add r0, r1, r2 这样的指令将在该机器指令的编码中具有三个 4 位字段,每个操作数一个。 (在 ARM 模式下,不是 Thumb。)这些寄存器编号与内存地址 012 无关。

    请注意,memory-mapped I/O 寄存器在所有现代 ISA 上都很常见,通常与 RAM 共享物理地址空间。 I/O 地址通常称为寄存器,但寄存器在外围设备中,如网卡,而不是在 CPU 中。读取或写入它会产生一些副作用,因此在 C++ 中,您通常会使用 volatile int *constexpr ioport = 0x1234; 或其他用于 MMIO 的东西。 MMIO 寄存器绝对不是您可以在 AArch64 add w0, w1, w2 等指令中使用的通用整数寄存器之一。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2021-06-10
      • 1970-01-01
      • 1970-01-01
      • 2011-04-24
      • 1970-01-01
      • 2012-12-07
      • 1970-01-01
      相关资源
      最近更新 更多