TL:DR:C++ 抽象机是PRAM (Parallel Random Access Machine) 的一种类型。
来自您链接的 Von Neumann Languages 维基百科文章:
许多广泛使用的编程语言,如 C、C++ 和 Java,通过添加对并行处理的支持,以线程的形式不再严格地遵循冯诺依曼。
停止描述了从存在到不存在的转变。所以是的,根据维基百科,在 C++11 添加线程之前,C++严格是一种冯诺依曼语言。 (在它基本上仍然是一种 VN 语言之后;让多个线程共享相同的地址空间并不会从根本上改变 C++ 的工作方式。)
在这种情况下成为冯诺依曼架构的有趣部分:
- 拥有可寻址的 RAM,允许随时高效访问(模缓存/分页)任何对象
- 将程序存储在 RAM 中:函数指针是可行且高效的,无需解释器
- 有一个程序计数器,可以逐步执行存储程序中的指令:自然模型是一种命令式编程语言,一次只做一件事。这是非常基本的,很容易忘记它不是唯一的模型! (与 FPGA 或 ASIC 或所有门可能在每个时钟周期并行执行某些操作的东西。或 MIMD GPU 中,您编写的计算“内核”可能并行运行在所有数据上,而没有隐式排序每个命令的顺序元素被处理。或者Computational RAM:将 ALU 放入内存芯片以绕过冯诺依曼瓶颈)
IDK 为什么 wiki 文章提到了自修改代码;与大多数语言一样,ISO C++ 没有对其进行标准化,并且与 split-bus / split-address-space Harvard architecture 的提前编译完全兼容。 (没有 eval 或其他任何需要解释器或 JIT 的东西。)或者在普通 CPU (Von Neumann) 上,严格的 W^X 内存保护并且从不使用 mprotect 将页面权限从可写更改为可执行。
当然,大多数真正的 C++ 实现确实提供了将机器代码写入缓冲区并转换为函数指针的定义良好的方法,作为扩展。 (例如 GNU C/C++ 的 __builtin___clear_cache(start, end) 以 I-cache 同步命名,但定义是为了安全地将数据调用为函数 wrt。死存储消除优化,因此代码可能在没有它的情况下中断即使在具有一致 I-caches 的 x86 上。)因此实现可以扩展 ISO C++ 以利用冯诺依曼架构的这一特性; ISO C++ 有意限制范围以允许操作系统和类似的东西之间存在差异。
请注意,作为冯诺依曼并不严格暗示支持间接寻址模式。一些早期的 CPU 没有,而自修改代码(重写指令中硬编码的地址)对于实现我们现在使用间接的东西是必要的。
还要注意,约翰·冯·诺依曼是一个非常有名的人,他的名字与很多基本的东西有关。冯诺依曼架构(与哈佛相反)的一些内涵并非在所有情况下都真正相关。例如“冯诺依曼语言”一词并不太关心冯诺依曼与哈佛的对比;它关心带有程序计数器的存储程序与像元胞自动机或图灵机(带有真实磁带)之类的东西。通过使用单独的总线(或仅拆分缓存)来获取指令(哈佛)获得额外带宽只是一种性能优化,而不是根本性的变化。
什么是抽象机器模型/计算模型?
首先,有一些models of computation 比图灵机弱,比如Finite State Machines。还有非顺序的计算模型,例如Cellular Automata (Conway's Game of Life),其中每个“步骤”并行发生多件事。
Turing machine 是最广为人知的(数学上最简单的)连续abstract machine,正如我们所知道的那样“强大”。没有任何绝对的内存寻址,只是磁带上的相对移动,它自然地提供了无限的存储空间。这很重要,并且使所有其他类型的抽象机器在某些方面与真正的 CPU 非常不同。请记住,这些计算模型用于理论计算机科学,而不是工程。有限的内存或性能等问题与可计算的内容在理论上无关,仅在实践中。
如果您可以在图灵机上计算某些东西,那么您可以在任何其他图灵完备的计算模型上进行计算(根据定义),可能使用更简单的程序,也可能不使用。图灵机不是很好编程,或者至少与任何真正 CPU 的汇编语言非常不同。最值得注意的是,内存不是随机访问的。而且他们不能轻易地为并行计算/算法建模。 (如果你想在抽象中证明某个算法,那么为某种抽象机器实现它可能是件好事。)
证明抽象机器需要具备哪些特性才能图灵完备也可能很有趣,因此这是开发更多抽象机器的另一个动机。
在可计算性方面还有很多其他的等价物。 RAM machine model 最类似于具有内存阵列的真实 CPU。但作为一个简单的抽象机器,它不会打扰寄存器。事实上,为了让事情更混乱,它把它的内存单元称为 registers 数组。 RAM 机器支持间接寻址,因此与现实世界 CPU 的正确类比肯定是内存,而不是 CPU 寄存器。 (并且有无限数量的寄存器,每个寄存器的大小都是无限的。地址永远存在,每个“寄存器”都需要能够保存一个指针。)RAM 机器可以是哈佛:程序存储在一个单独的有限状态部分机器。把它想象成一台具有内存间接寻址模式的机器,因此您可以将“变量”保存在已知位置,并将其中一些用作指向无限大小数据结构的指针。
The program for an abstract RAM machine 看起来像汇编语言,带有 load/add/jnz 以及您希望它拥有的任何其他指令选择。操作数可以是立即数或寄存器数字(普通人称之为绝对地址)。或者,如果模型有一个累加器,那么你就有一个带有累加器的加载/存储机器,它更像是一个真正的 CPU。
如果您想知道为什么像 MIPS 这样的“3-address”机器被称为而不是 3-operand,它可能是 1。因为指令编码需要空间/I-fetch 带宽,因为 3 显式操作数位置(寄存器编号)和 2. 因为在 RAM 抽象机中,操作数是内存地址 = 寄存器编号。
C++ 不可能是图灵完备的:指针的大小是有限的。
当然,C++ 与 CS 抽象机器模型有巨大的不同:C++ 要求每种类型都有一个编译时间常数有限 sizeof,因此 C++ 可以如果包含无限存储要求,则不会是图灵完备的。 cs.SE 上Is C actually Turing-complete? 中的所有内容也适用于 C++:类型具有固定宽度的要求是无限存储的障碍。另见https://en.wikipedia.org/wiki/Random-access_machine#Finite_vs_unbounded
那么计算机科学抽象机很傻,那么 C++ 抽象机呢?
他们当然有自己的目的,但是我们可以说更多关于 C++ 的有趣的东西,以及如果我们稍微不那么抽象它假设什么样的机器,并谈论什么是机器可以有效地。一旦我们谈论有限机器机器和性能,这些差异就变得相关了。
首先,完全运行 C++,其次,在没有巨大和/或不可接受的性能开销的情况下运行。 (例如,硬件将需要相当直接地支持指针,可能不需要将指针值存储到使用它的每个加载/存储指令中的自修改代码。这在线程是其中一部分的 C++11 中不起作用语言:相同的代码可以同时操作 2 个不同的指针。)
我们可以更详细地了解 ISO C++ 标准所假设的计算模型,该标准描述了该语言如何根据抽象机上发生的情况来工作。真正的实现需要在真正的硬件上运行代码,运行“好像”抽象机器正在执行 C++ 源代码,再现任何/所有可观察的行为(无需调用 UB 即可被程序的其他部分观察到)。
C/C++ 有内存和指针,所以它绝对是一种 RAM 机器。
或者现在,Parallel random-access machine,将共享内存添加到 RAM 模型,并为每个线程提供自己的程序计数器。鉴于std::atomic<> release-sequences 使 所有 以前的操作对其他线程可见,“建立先发生关系”同步模型基于 一致 共享内存。在需要手动触发同步/刷新的东西之上模拟它对性能来说是可怕的。 (非常聪明的优化可以证明什么时候可以延迟,所以不是每个发布存储都必须受到影响,但 seq-cst 可能会很糟糕。seq-cst 必须建立一个所有线程都同意的全局操作顺序;这很难,除非一个商店同时对所有其他线程可见。)
但请注意,在 C++ 中,实际同时访问是 UB,除非您使用 atomic<T> 进行操作。这个allows the optimizer to freely use CPU registers 适用于本地人、临时人员,甚至是全局人员,而不会将寄存器作为语言功能公开。 UB allows optimization 一般;这就是现代 C/C++ 实现不是可移植汇编语言的原因。
C/C++ 中历史悠久的register 关键字意味着变量无法获取其地址,因此即使是非优化编译器也可以将其保存在 CPU 寄存器中,而不是内存中。我们'正在谈论 CPU 寄存器,而不是计算机科学 RAM 机器“寄存器 = 可寻址内存位置”。 (例如 x86 上的 rax..rsp/r8..r15 或 MIPS 上的 r0..r31)。现代编译器会逃避分析并自然地将本地人正常保存在寄存器中,除非他们必须溢出它们。其他类型的 CPU 寄存器也是可能的,例如像 x87 FP 寄存器这样的寄存器堆栈。 不管怎样,register 关键字的存在是为了针对这种类型的机器进行优化。但不排除在没有寄存器,只有内存-内存指令的机器上运行。
C++ 被设计为在具有 CPU 寄存器的冯诺依曼机器上运行良好,但 C++ 抽象机(该标准用于定义语言)不允许将数据作为代码执行,或者说关于寄存器的任何事情。但是,每个 C++ 线程都有自己的执行上下文,并且对 PRAM 线程/内核进行建模,每个线程/内核都有自己的程序计数器和调用堆栈(或实现用于自动存储和确定返回位置的任何东西。)在真实机器中对于 CPU 寄存器,它们对每个线程都是私有的。
所有现实世界的 CPU 都是 Random Access Machines,并且 CPU 寄存器与可寻址/可索引 RAM 分开。即使 CPU 只能使用单个累加器寄存器进行计算,通常也至少有一个指针或索引寄存器,至少允许一些有限的数组索引。至少所有 CPU 都可以很好地用作 C 编译器目标。
没有寄存器,每个机器指令编码都需要所有操作数的绝对内存地址。 (可能像 6502 一样,其中的“零页”,即内存的低 256 字节是特殊的,并且存在使用零页中的字作为索引或指针的寻址模式,以允许 16 位指针没有任何 16位架构寄存器。或类似的东西。)请参阅Why do C to Z80 compilers produce poor code? on RetroComputing.SE 了解有关真实世界 8 位 CPU 的一些有趣内容,其中完全兼容的 C 实现(支持递归和重入)实现起来非常昂贵。很多缓慢是因为 6502 / Z80 系统太小而无法承载优化编译器。但即使是假设的现代优化交叉编译器(如 gcc 或 LLVM 后端)也会在某些事情上遇到困难。另请参阅What is an unused memory address? 上的最新回答,以获得对 6502 的零页索引寻址模式的很好解释:来自内存中绝对 8 位地址的 16 位指针 + 8 位寄存器。
完全没有间接寻址的机器不能轻易地支持数组索引、链表,而且绝对不能将指针变量作为一等对象。 (反正效率不高)
在真实机器上什么是有效的 -> 什么习语是自然的
大部分 C 的早期历史都在 PDP-11,这是一个普通的内存 + 寄存器机器,任何寄存器都可以作为指针工作。当需要溢出时,自动存储映射到寄存器或调用堆栈上的空间。内存是一个扁平的字节数组(或char 的块),没有分段。
数组索引只是根据指针算法定义的,而不是它自己的东西,也许是因为 PDP-11 可以有效地做到这一点:任何寄存器都可以保存地址并被取消引用。 (相对于一些机器只有几个指针宽度的特殊寄存器,其余的更窄。这在 8 位机器上很常见,但早期的 16 位机器(如 PDP-11)的 RAM 足够少,只有一个 16 位寄存器一个地址就够了)。
查看 Dennis Ritchie 的文章 The Development of the C Language 了解更多历史; C 在 PDP-7 Unix 上从 B 发展而来。 (第一个 Unix 是用 PDP-7 asm 编写的)。我对 PDP-7 了解不多,但显然 BCPL 和 B 也使用只是整数的指针,而数组是基于指针算术的。
PDP-7 is an 18-bit word-addressable ISA。这可能就是 B 没有 char 类型的原因。但是它的寄存器足够宽,可以保存指针,所以它自然支持 B 和 C 的指针模型(指针并不是很特别,你可以复制它们并取消引用它们,你可以获取任何东西的地址)。如此平坦的内存模型,没有像您在分段机器或一些零页的 8 位微控制器上找到的“特殊”内存区域。
诸如 C99 VLA(以及无限大小的局部变量)和无限重入和递归之类的东西意味着函数局部变量上下文(也就是使用堆栈指针的普通机器上的堆栈帧)的调用堆栈或其他分配机制。