try ai
科普
编辑
分享
反馈
  • 存储程序概念

存储程序概念

SciencePedia玻尔百科
核心要点
  • 存储程序概念将指令和数据视为统一内存中可互换的信息,赋予了计算机根本的灵活性。
  • 这种统一设计导致了“冯·诺依曼瓶颈”,即指令和数据在单一共享通路上争夺访问权而造成的性能限制。
  • 将代码视为数据催生了强大的功能,如即时 (JIT) 编译,但同时也带来了严重的安全漏洞,如代码注入攻击。
  • 现代处理器通过指令/数据缓存和非执行 (NX) 位等安全特性重新引入逻辑分离,以管理这些权衡。

引言

存储程序概念是支撑几乎所有现代计算的革命性思想。它假定计算机的指令与其处理的数据在根本上没有区别;两者可以一同存储在单一、统一的内存中。这一优雅的原则将计算机从功能固定的计算器转变为能够执行无限任务的通用机器。然而,这种统一是一把双刃剑,带来了性能和安全方面的固有挑战,这些挑战几十年来一直在塑造计算机体系结构的演进。

本文深入探讨了这一基本概念的深远影响。首先,在“原理与机制”一章中,我们将剖析其核心思想,通过对比冯·诺依曼架构与哈佛模型来理解臭名昭著的“冯·诺依曼瓶颈”。我们还将探讨自修改代码的巨大威力,以及在现代系统中安全管理它所需的复杂的软硬件协同。随后,“应用与跨学科联系”一章将阐明这些架构上的权衡如何在现实世界中产生连锁反应,影响着从解释型语言的性能、安全关键系统的设计,到网络安全领域持续的军备竞赛等方方面面。

原理与机制

统一世界的优雅

如果一台机器的指令不是一成不变,而是像它处理的数据一样可以随意修改,会怎么样?这正是你用过的几乎每一台计算机背后的革命性思想。这个思想被称为​​存储程序概念​​,并体现在​​冯·诺依曼架构​​中,它宣告程序与其处理的数据之间没有根本区别。两者都只是数字——比特模式——一同存放在单一、统一的内存中。

想象一个厨师的厨房。一个较老的设计,即​​哈佛架构​​,可能有一本永久印刷、不可更改的食谱(指令)和一个独立的储藏室存放食材(数据)。厨师只能按照写好的食谱操作。然而,冯·诺依曼的厨房则不同。食谱写在一个简单的笔记本上,就放在购物清单旁边。这位厨师不仅可以阅读食谱,还可以随时修改它,也许是记下一个改进,甚至是根据手头的食材写出一个全新的食谱。

这种将​​代码视为数据​​的简单而优美的思想,赋予了计算机深远的灵活性。一个将人类语言翻译成机器指令的程序——​​编译器​​——之所以能够存在,正是因为它生成的机器代码只是另一种可以写入内存的数据形式。正是这种统一,将计算机从专用计算器转变为通用工具。

统一的代价:冯·诺依曼瓶颈

然而,这种优雅的设计带来了一个根本性的权衡。在冯·诺依曼架构中,中央处理器 (CPU) 通过单一通道或总线与其统一内存通信。由于指令和数据都走这条路,就造成了交通堵塞。CPU 无法在为当前指令提取数据的同时从内存中提取下一条指令。这个固有的性能限制就是著名的​​冯·诺依曼瓶颈​​。

让我们慢动作看一下这个过程。考虑一条简单的指令,如 LOAD R_d, [R_s],它将存储在寄存器 RsR_sRs​ 中的内存地址所指向的数据加载到另一个寄存器 RdR_dRd​ 中。这个过程严格按顺序展开。首先,CPU 必须执行一个​​指令提取周期​​:

  1. 将指令的地址(来自程序计数器 PCPCPC)发送到内存。
  2. 等待内存返回指令。
  3. 将指令放入指令寄存器 (IRIRIR)。

只有在上述步骤完成后,CPU 才能开始​​执行周期​​: 4. 将数据地址(来自寄存器 RsR_sRs​)发送到内存。 5. 等待内存返回数据。 6. 将数据放入目标寄存器 RdR_dRd​。

请注意,步骤2和步骤5都需要使用通向内存的单一共享路径。它们必须一个接一个地发生。这种串行化是该架构中固有的结构性冒险。如果你正在运行一个需要提取 fff 条指令和加载 lll 个数据的循环,冯·诺依曼机器将花费与总内存访问次数 f+lf+lf+l 成正比的时间。而一台拥有独立代码和数据路径的哈佛机器,可以并行执行这些任务,花费的时间仅与两项任务中较长的一项成正比,即 max⁡(f,l)\max(f, l)max(f,l)。因此,哈佛方法的性能增益是一个显著的因子 G=f+lmax⁡(f,l)G = \frac{f+l}{\max(f, l)}G=max(f,l)f+l​。对于一个每次指令提取都执行一次数据加载的程序(f=lf=lf=l),哈佛设计的速度是其两倍。

这个瓶颈意味着任何任务的总时间都是一个简单且不可避免的和:指令提取时间 (tIFt_{IF}tIF​)、数据访问时间 (tMEMt_{MEM}tMEM​) 和纯计算时间 (tEXt_{EX}tEX​)。它们之间没有重叠;总延迟就是 tloop=tIF+tMEM+tEXt_{\text{loop}} = t_{IF} + t_{MEM} + t_{EX}tloop​=tIF​+tMEM​+tEX​。这种限制甚至出现在像过程调用这样的常见操作中。如果一条 call 指令必须将返回地址写入数据栈,它可能会与提取其所调用函数的第一个指令发生冲突,从而引入统一内存模型特有的流水线延迟。

双刃剑:自修改的力量

如果说瓶颈是代价,那么回报是什么?存储程序概念最大的威力在于,如果代码只是数据,那么程序就可以改变自己。它可以将新指令写入内存,然后执行它们。这种能力,被称为​​自修改代码​​,是现代软件动态性的基础。

在最基本的层面上,这种能力使得通用机器得以存在。当我们在像图灵机这样更抽象的模型上模拟冯·诺依曼机器时,程序代码只是磁带上的一串符号模式。图灵机的“CPU”可以在磁带上写入新符号,从而有效地修改程序,之后再将读写头移动到那个位置以执行新指令。

在现实世界中,​​即时 (JIT) 编译器​​利用了这种能力,它们是 Java 和 JavaScript 等高性能语言背后的引擎。当你的浏览器运行一个 Web 应用程序时,JIT 编译器会监视频繁执行的代码片段(“热点”)。然后它就像我们那位富有创造力的厨师一样:它即时将一个新的、高度优化的机器码“食谱”写入内存,然后无缝切换到执行它,使应用程序运行速度显著加快。

驯服野兽:现代复杂性与安全性

这种将代码视为数据的能力非常强大,但在现代高性能处理器中,这就像处理一根带电的电线。CPU 和单一内存的简单模型已被复杂的缓存、流水线和安全机制层次结构所取代,所有这些都使自修改行为变得复杂。

首先是缓存问题。为了对抗冯·诺依曼瓶颈,CPU 使用了独立的、快速的本地内存来分别存放指令(​​I-cache​​)和数据(​​D-cache​​)。这在最高层级上重新引入了类似哈佛架构的分离。当 JIT 编译器写入新的机器码时,它是在写数据,所以新代码会进入 D-cache。但当 CPU 试图执行它时,它会去 I-cache 中查找。I-cache 对这一变化一无所知,可能仍然保留着旧的、过时的指令。在大多数现代处理器上,没有自动的硬件机制来保持 I-cache 和 D-cache 的同步。

为了正确执行新生成的代码,程序必须执行一个谨慎、明确的同步仪式:

  1. ​​提交写入:​​ 首先,它必须确保其新代码已确实离开 CPU 的临时存储缓冲区并已写入 D-cache。这通常需要一条特殊的“存储栅栏”指令 (SFENCE)。
  2. ​​发布到主内存:​​ 其次,由于 I-cache 仅从统一的主内存中重新加载,新代码必须通过从 D-cache 刷新到主内存来“发布” (DCFLUSH)。
  3. ​​使过时指令无效:​​ 程序随后必须明确告知 I-cache 丢弃其旧的、过时的代码副本 (ICINV)。
  4. ​​重置流水线:​​ 最后,因为 CPU 的流水线可能在这一系列操作开始之前就已经提取了一些旧指令,所以必须用“指令同步屏障” (ISB) 将其完全清空。

只有在完成这整个代价高昂的序列之后,程序才能安全地跳转并执行其新代码。这些步骤中的每一步都会引入延迟,总时间可能相当可观,这是安全地运用自修改力量所必须付出的代价。

第二个,也许是更严峻的挑战是安全性。如果一个程序可以将数据变成代码,那如果这些数据来自恶意来源呢?这是最常见的网络攻击之一——​​代码注入​​——的基础。攻击者找到一个漏洞,比如缓冲区溢出,将恶意的载荷数据——​​shellcode​​——注入到程序的内存中。然后他们欺骗程序跳转到这些数据的起始位置,而 CPU 则遵从存储程序概念,愉快地开始执行它。

为了对抗这种情况,现代系统引入了一项关键的硬件强制保护措施:​​非执行 (NX) 位​​,也称为数据执行保护 (DEP)。操作系统可以使用此位将内存页面标记为不可执行。当 CPU 的​​内存管理单元 (MMU)​​ 去提取指令时,它会检查页面的权限。如果 X (执行) 位未设置,即使程序有权限读写该内存,CPU 也会拒绝执行并触发一个故障。

这催生了一种强大的安全策略,称为​​写异或执行 (W^X)​​:一个内存页面可以是可写的或可执行的,但永远不能同时两者兼备。JIT 编译器现在必须遵守这些更安全的规则:它将代码写入一个标记为 W=1, X=0 的页面,然后进行一次安全的系统调用,请求操作系统将权限更改为 W=0, X=1,之后再执行代码。这可以防止攻击者简单地一次性写入并运行他们的代码。

这种分离已变得更加复杂。现代 CPU 可以强制执行​​仅执行​​权限 (X=1,R=0X=1, R=0X=1,R=0),防止程序甚至读取自己的代码作为数据。这是可能的,因为硬件区分了指令提取和数据加载,通常使用独立的转译后备缓冲器(I-TLB 和 D-TLB)。指令提取通过 I-TLB 检查 X 位,这会成功。然而,数据加载通过 D-TLB 检查 R 位,这会失败并导致故障。这有助于挫败那些依赖于读取程序代码来拼凑新攻击的企图。

因此,存储程序概念的旅程已经走完了一个完整的循环。它始于统一代码和数据的革命性创举。此后的历史则是一段迷人而复杂的努力,旨在管理这种统一带来的后果——通过缓存和权限位重新引入逻辑分离以重获性能,并且至关重要地,恢复安全性,所有这些都没有失去使计算机成为通用机器的根本力量。

应用与跨学科联系

我们已经看到,存储程序概念,这个将指令视为另一种数据形式的 brilliantly simple 的思想,是现代计算的基石。这是一个具有深远优雅和统一性的原则。但就像科学中任何真正基本的思想一样,它的后果一点也不简单。它们是广阔、复杂且常常令人惊讶的。要真正欣赏这个概念的天才之处,我们不仅要理解它是如何工作的,还要看到它在现实世界中做了什么。这段旅程将我们从硅芯片的物理限制带到网络安全的抽象战场,揭示这一个架构选择如何塑造我们整个数字世界。

统一的代价:冯·诺依曼瓶颈

想象一位在繁忙厨房里的大厨。这位大厨需要两样东西来工作:食谱(指令)和食材(数据)。现在,如果只有一个储藏室的门,食谱和食材都必须通过这扇门来取,会怎么样?无论大厨切菜和烹饪的速度有多快,他们的速度最终都会受到那扇单门前的交通堵塞的限制。

这正是经典冯·诺依曼机器中的情况。通过将指令和数据放在同一个内存中,通过单一共享通道或总线访问,我们创造了一个根本性的瓶颈。这个通向内存的单一“门口”就是后来著名的​​冯·诺依曼瓶颈​​。

处理器执行的每一个操作——无论是获取下一条要执行的指令,还是加载要处理的数据——都需要经过这条共享总线。如果一个程序同时需要大量指令和大量数据,它们必须排队轮流。这就产生了争用。一个内部能够每秒执行数十亿次操作的处理器,可能大部分时间都在等待,停顿下来,等待总线为其提供下一顿指令或数据餐。

当我们将其与另一种设计——哈佛架构——进行比较时,可以清楚地看到这种效应。哈佛架构提供了两个独立的“储藏室门”——一个用于指令,一个用于数据。在指令获取和数据访问都非常频繁的任务中,哈佛风格的机器可以快得多,仅仅因为这两股数据流不会相互干扰。对于给定的总线速度,如果对指令和数据的需求完全平衡,冯·诺依曼机器可能只能以其潜在速度的一半运行,因为它必须严格地在获取“食谱”和“食材”之间交替进行。

这个瓶颈不仅仅是 CPU 内部的事情。它是一个系统性的挑战。考虑直接内存访问 (DMA),这是一种巧妙的技术,允许硬盘驱动器或网卡等外围设备直接与内存传输数据,而无需 CPU 参与。在冯·诺依曼系统中,当 DMA 控制器接管总线以传输一批数据时,CPU 实际上被锁在自己的储藏室之外。它无法获取指令,也无法访问数据。它只能停顿,等待 DMA 传输完成。CPU 停顿的时间比例与 DMA 控制器独占总线的时间比例成正比。这就是统一的代价:对单一宝贵资源的持续、系统范围的竞争。

统一的力量:软件的终极灵活性

如果冯·诺依曼瓶颈是存储程序概念的代价,那么回报是什么?回报是一种如此深远的灵活性,它支撑起了整个现代软件的大厦。因为指令就是数据,我们可以像处理任何其他信息一样操纵、创建和转换它们。

想一想任何现代编程语言最基本的功能之一:函数或子程序调用。当你调用一个函数时,程序需要知道在完成后返回到哪里。它通过获取程序计数器的当前值(下一条指令的地址)并将其保存在内存中,通常是在一个称为调用栈的特殊数据结构上,来实现这一点。这个返回地址——一段与代码相关的信息——被纯粹地当作数据来处理。它像任何其他变量一样被推入栈中。当函数完成时,这个“数据”从栈中弹出并加载回程序计数器,执行便从离开的地方继续。每一个优雅地展开的递归函数调用,都是将代码地址视为可存储数据的威力的微小证明。

这个原则延伸到更高级的概念,如函数指针。函数指针是一个变量,它不保存数字或字符串,而是保存一段代码的内存地址。通过改变这个指针的值,程序可以在运行时决定接下来执行哪个函数。这非常强大,构成了插件式架构、面向对象编程和无数其他灵活软件设计的基础。但它也带来了源于我们架构的微妙性能成本。要使用函数指针,CPU 必须首先执行一次数据加载以从内存中获取地址,然后才能将其指令获取重定向到那个新地址。这个两步过程可能会引入停顿和缓存未命中,因为处理器预测下一条指令的尝试受挫了。

将这个想法推向其逻辑结论,如果一个程序可以写入数据,而代码就是数据,那么一个程序就可以编写代码。这开启了一个壮观的可能性世界。

  • ​​解释器:​​ 当你运行一个 Python 或 Java 程序时,你并不是直接运行代码。你是在运行一个解释器或虚拟机,这是一个本地程序,它读取你的高级代码(作为数据)并执行相应的低级机器指令。这增加了一层开销;对于每一条高级指令,解释器可能需要获取并执行几十条自己的本地指令,给冯·诺依曼瓶颈带来巨大压力。

  • ​​即时 (JIT) 编译:​​ 这是该概念真正闪耀的地方。JIT 编译器是自引用工程的奇迹。它是一个程序,在运行时分析它即将执行的代码,并将其动态编译成高度优化的本地机器码。它将这个新的、快速的代码写入内存中的一个缓冲区,然后简单地跳转到它。例如,一个科学模拟程序可能会检测到它正在运行的计算机有一个强大的向量处理 (SIMD) 单元。JIT 编译器就可以生成一个专门为使用该硬件量身定制的核心计算内核的自定义版本,从而可能极大地加快计算速度。这种运行时代码生成的行为是存储程序概念的终极体现。当然,它需要小心处理处理器的缓存,以确保 CPU 获取的是新代码而不是过时的旧指令,但正是这种能力使得当今许多高性能软件成为可能。在严格的哈佛架构中,处理器的数据写入部分没有物理路径通向指令内存,如果没有特殊的硬件桥梁,JIT 编译将是不可能的。

跨学科前沿:从功能安全到网络安全

存储程序概念的后果远远超出了计算机科学的范畴,定义了功能安全工程和网络安全等领域的关键挑战。

想象一个交通灯控制器或一个工厂机器人手臂,两者都由一台小型计算机控制。确保红绿灯不会同时在所有方向显示绿色,或者机器人手臂不会挥向工人的程序,存储在内存中。在冯·诺依曼系统中,这段关乎生死的代码只是一堆字节,与任何其他数据没有区别。如果一个维护程序在它运行时试图更新这个程序会发生什么?DMA 传输可能会就地覆盖程序。因为更新不是瞬时的,CPU 可能会获取到新旧指令的无意义混合体。这可能导致程序跳过关键的“等待全红”步骤,从而导致灾难性故障。

这不是一个理论上的担忧;这是安全关键系统面临的一个根本性挑战。解决方案并非放弃存储程序概念,而是在其周围建立稳健的工程实践。工程师们设计了带有​​双缓冲​​的系统,新程序被写入内存的一个独立的、非活动的区域。只有当新代码完全写入并验证完毕,并且系统处于一个保证安全的状态(例如,所有交通灯都为红色)时,才会原子性地翻转一个指针,使新程序生效。这确保了 CPU 永远不会执行一个部分写入的程序。这整个安全软件更新领域的存在,就是为了管理几十年前一个单一架构决策所带来的风险。

最后,我们来到了存储程序概念最具对抗性的应用:计算机安全世界。如果一个程序可以为了好的目的修改自己(比如 JIT 编译器),它也可以为了坏的目的修改自己。这就是​​多态恶意软件​​背后的原理。一个计算机病毒可能通过特定的字节序列——其“签名”——来识别。一个简单的病毒扫描器只是寻找这个模式。但一个多态病毒包含一个小引擎,其工作是在每次感染新系统时重写病毒的主体代码。它可能会插入垃圾指令,重新排序函数,或者使用不同的指令来完成相同的任务。新变体的功能与旧版本完全相同,但其二进制签名却完全不同,使简单的扫描器失效。

这造成了一场数字军备竞赛。恶意软件作者利用存储程序概念创建自修改代码以逃避检测。而安全研究员则必须构建更复杂的工具来分析程序的行为,而不仅仅是其静态签名。这场消耗数十亿美元和无数人类智慧的猫鼠游戏,正是在一个由存储程序概念奠定规则的场地上进行的。一个程序将其自身代码视为数据的能力,既是其最大的优势,也是其最危险的弱点。

从硅总线上的交通堵塞到多态病毒的复杂舞蹈,存储程序概念的应用和联系有力地说明了一个单一、优雅的思想如何能绽放成一个充满复杂而美丽细节的宇宙。