计算机体系结构-全-

计算机体系结构(全)

原文:zh.annas-archive.org/md5/b14ff1eb996529def4fd52514e8be44c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Image

本书探讨了计算机架构领域,研究计算机硬件的基本原理和设计。该领域涵盖了多个层次的硬件组件和技术,从基本的硅和晶体管,到逻辑门、简单的计算机、汇编语言,再到复杂的处理器和内存。

这本书还追溯了计算历史,从古希腊的机制到二战时期的破译机器,再到复古的 8 位游戏主机、经过高度优化的现代 CPU 和深度学习 GPU、嵌入式物联网设备、云服务器,甚至未来的架构,如量子计算机。计算机架构识别了这些不同机器和组件之间的联系趋势。正如你将看到的,一些计算机原理比你想象的要古老得多。

这本书适合谁阅读?

计算机架构是少数几门能将完整的计算机科学家与普通程序员区分开来的学科之一。如果你是计算机科学的本科生,这可能是你学位课程的必修内容。如果你是自学的程序员或黑客,这可能是你希望深入了解的一个学科,既能让你的程序与硬件更和谐地运行,也能作为一种专业身份的象征,许多雇主对此有所关注。本书假设你知道一些基础的高中编程、数学和物理知识,但除此之外,本书是自成一体的。它既可以作为本科计算机架构学位的硬件要求教材,也可以作为独立学习者的第一手资源。

为什么要学习计算机架构?

在 1980 年代,我还是一名年轻程序员时,编程和使用计算机与对计算机设计的理解是紧密相连的。例如,在 1980 年代,编写 8 位微型计算机游戏的艺术在很大程度上就是要与家用微型计算机中的特定 CPU 和芯片组融为一体;我们对自己选择的架构有着极高的忠诚度。那时计算机资源非常有限,因此游戏是为了利用架构的特定特性,并最大限度地挤压出计算机的性能。许多那个时代的游戏概念就是根据特定的架构结构和技巧产生的。

今天的编程与以前大不相同。在大多数应用层编程中,有许多软件层次结构将程序员与硬件隔离开来。你可能会在一种与处理器和内存的类型几乎没有关系的语言中编程。这些类型让你不必考虑内存地址——或者至少它们位于一个操作系统上,操作系统将物理内存地址替换为虚拟内存地址,并禁止通过抽象的系统调用接口访问存储在硬件中的程序。因此,当来自 8 位时代的程序员看到今天重建的 Android 和 JavaScript 版本的他们的游戏时,他们可能会觉得这些版本不够真实。游戏失去了与硬件的紧密联系,而这种联系曾经是它们的灵感来源并对其进行约束。

一些人,系统程序员,设计和维护介于硬件和软件之间的工具栈,但其他人都处于该栈之上。尽管如此,这些工具仍然将你连接到底层硬件,尽管是间接的,如果你理解硬件的结构,你通常可以更有效地利用各个层次的工具。你还可以更好地衡量工具的表现,并利用这些信息在程序中做出更智能的选择。你可能会使用更高效的算法或改变某个进程的实现方式。

真正关心高性能的程序员,比如游戏引擎和科学金融模拟的作者,可以通过打破一些堆栈层次,直接与“裸金属”硬件进行交互,受益于此种编程方式。这种编程被称为 汇编语言编程,在今天已经很少见,因为优化过的编译器通常能够超越大多数手写的汇编语言代码;然而,仍然有一些程序员喜欢接近硬件层次。他们可能会通过从一种内存管理语言切换到一种基于指针的语言,或者从使用符号类型的语言切换到使用机器本身类型的语言来实现这一点。几十年来,C 语言一直是首选的低级语言,尽管 Rust 等新兴语言也在不断涌现。

计算机架构与计算机安全性也直接相关。攻击通常涉及在比假定为安全的层次更低的层次工作。虽然计算机可以在某个层次上被证明是安全的,例如操作系统的用户态,但像 CPU 组件的精确时序和不同内存位置的访问速度等低层次的细节则为利用提供了新的可能性。例如,Spectre 和 Meltdown 漏洞存在于 CPU 层次,但当程序员理解要寻找什么时,用户态代码可以测量并利用这些漏洞。

最后,通过研究计算机架构的历史并观察这一领域如何不仅在几十年间,而且跨越几个世纪的发展,我们可以从过去的错误中汲取教训,并发现旧思想的新用途。历史架构中的一些概念在经过长时间后重新被使用是非常常见的。例如,要处理一个数字,查尔斯·巴贝奇的机械计算机必须将数字从内存中移到处理器中;这意味着数字在任何时候只能存在一个地方,而不是被复制。如今,我们在量子计算的研究中看到了这种结构,巴贝奇为解决该问题而提出的一些思想可能会获得新的生命。架构的历史就像是一个思想的宝库,我们可以根据需要从中汲取灵感。

领域变化

直到最近,计算机架构还是一个枯燥、成熟的学科。它起源于 1950 年代和 1960 年代,其基本原理长时间未变。书籍会定期更新,展示最新的产品示例,例如更快的 CPU 和使用更小晶体管的计算机,但架构原理始终未变。然而,自 2010 年以来,这一切发生了变化:这一学科进入了新的“黄金时代”,部分原因是计算领域其他分支需求的变化。最近有一股趋势,传统桌面计算机逐渐被两种截然不同的方向取代。

首先,计算机正在变得不那么强大,无论是计算能力还是能耗方面。我们现在希望拥有更多、更小、更便宜、低能耗的计算机来支持我们生活的各个方面。这些设备使得智能城市智能农业智能交通物联网成为可能。与此同时,这些设备收集了大量数据——我们现在所称之为大数据——而处理这些数据需要一种全新的计算机类型:极为庞大的超级计算机或计算集群,这些计算机通常位于如工厂般的专用场地内。大楼内部没有人,只有一排排闪烁的服务器指示灯。

你可能听说过深度学习,这是对 60 年历史的神经网络算法的重新包装。可以说,深度学习根本不是机器学习或人工智能理论的一个分支,而是计算机架构的一个分支。毕竟,正是基于大规模硬件并行化、通过 GPU 集群和定制硅芯片的新架构,使得这一古老算法得以快速发展,并能够在比以前大得多的规模上运行。得益于计算机架构的这些进展,我们终于可以利用神经网络解决现实世界中的问题,比如视频中的物体识别和与人类进行自然语言对话的聊天机器人。

另一个架构变革动摇了长期以来的信念。数十年来,程序员们信奉摩尔定律,该定律指出,无论你信的是哪一种说法,要么是晶体管的数量,要么是处理器的速度每 18 个月就会翻倍。这使得程序员产生了自满情绪,认为在常规架构上速度会不断增加。然而,最近,能源使用的考量让摩尔定律的速度形式走到了尽头。尽管我们仍然能够构建越来越多的晶体管,但自维多利亚时代以来的第一次,我们现在需要将计算机重新概念化为固有的并行模式,以便利用这些晶体管。

关于未来并行架构是否会对程序员可见,从而要求程序员在日常编程中具备固有的并行思维,仍然是一个悬而未决的问题;或者人们是否会编写新的编译器,在常规串行程序和新型并行架构之间进行翻译。无论哪种方式,都将有令人兴奋的新职业在等待着探索这一问题。我们正在寻找可能来自非常古老的源头(如钟表和水计算机)或非常新的源头(如神经计算、光学计算和量子计算)的新思想。

最后,最近在线协作工具的广泛可用性促进了新一波开源架构系统和工具的兴起。RISC-V、BOOM、Chisel 以及过去、现在和未来机器的模拟器都使得计算机架构的学习变得更加容易、快捷和可获取。在本书中,您将会接触到这些工具中的许多。时隔多年,研究和教授架构变得如此令人兴奋!

如何使用本书

架构通常是必修课或专业要求,许多不喜欢这一科目的学生仍然不得不学习——我应该知道,我曾经就是其中之一!为了帮助这些学生更轻松地接受这门课程,我为他们添加了一些“糖”:我会将这门学科与其他你可能更感兴趣的话题联系起来。如果你讨厌硬件,但喜欢音乐、机器人、人工智能、历史,甚至是高级乐高拼装,那么这本书可能适合你。你甚至可能通过这些联系开始喜欢架构;或者,如果你只是需要通过考试,也许这本书会比其他一些书更轻松地帮助你通过考试。

尽管计算机架构的未来新颖且令人兴奋,但了解过去也很重要,因此本书采用了广泛的历史性方法。计算机随着时间的推移变得越来越复杂;通过追溯它们的历史,我们可以逐步建立这种复杂性。例如,你将通过学习如何在蒸汽朋克风格的维多利亚时代分析机上编程,了解 CPU 的基本结构——这些结构至今仍在使用。我将向你展示如何将它的机械部件转化为基于逻辑门的等效物,并且如何构建并编程一个版本的曼彻斯特婴儿,这是最早的电子计算机之一。然后,你将扩展电子计算机到 8 位和 16 位复古游戏计算机,学习编程 Commodore 64 和 Amiga。接下来,我将介绍现代桌面计算机和智能计算机,包括 x86 和 RISC-V 架构,然后讨论云计算和超级计算机。最后,我们将探讨未来技术的理念。

本书将研究许多示例系统,但它们主要是为了阐明一般概念,而不是作为现代产品的具体指南。当你读完本书后,你应该具备足够的理解,例如,能够在面包板上构建一个 8 位微处理器,用汇编语言编写复古的 8 位游戏,编写基本的嵌入式和并行程序,理解历史的发展,并预测架构的未来。你也应该为阅读未来学习和工作所需的经典参考书籍做好准备。

如果你尝试深入研究每一章,你将能从本书中获得最大收获——不要仅仅接受它们的表面内容。例如,可以在 LogiSim 中设计大规模的 CPU,将其烧录到便宜的现场可编程门阵列(FPGA)上,并实际运行。另一个例子是,你可以使用本书中介绍的所有架构和汇编器来编写你自己的视频游戏。本书讨论的 LogiSim 文件和汇编代码片段都可以下载;请参阅本书的网页,* nostarch.com/computerarchitecture *,获取下载链接。我还鼓励你通过使用图书馆、维基百科和更广泛的网络,进一步了解本书的主题,并查看每章末尾列出的参考书目;然后找到它们引用的有趣资源。同样,尝试以新的方式使用本书章节末的练习中展示的工具,并留意网上的其他有趣项目创意。例如,许多 YouTuber 通过从 eBay 上购买 6502 处理器、RAM 芯片和电线,制作了简单的 8 位计算机。架构是一个特别具有视觉性、适合写博客和制作 YouTube 视频的主题,因此,如果你创造了有趣的东西,一定要分享你的成果。

开始学习架构的一个好方法是购买一套小型螺丝刀,打开你的电脑、笔记本电脑和智能手机,甚至一些不太显眼的设备,如路由器、电视和洗衣机,从而使你的产品保修失效。在接下来的部分,我们将看到你可能在这些设备内部发现的一些例子,并学习如何探索它们。

常见设备内部

计算机架构的范围从晶体管的原子尺度到互联网格计算的行星尺度。为了尽早感受这个主题,我们将在这里从最接近人的层面开始:当我们打开家用计算机的外壳并看里面时所看到的内容。通常,裸眼能看到的主要组件是排列在印刷电路板上的硅芯片。书中的后面部分,我们将深入探讨芯片、逻辑门和晶体管,并从这些层次向上构建到集群和网格。

台式电脑

在过去的几十年里,大多数台式电脑都采用了标准化尺寸的组件和外壳,因此你可以从多个竞争厂商生产的组件中组装一台电脑,而无需担心它们是否能够匹配。IBM 在 1980 年代开始了这一趋势。由于这种标准化,如果你卸下螺丝和外壳并打开台式电脑,通常会看到类似于图 1 所示的结构。

Image

图 1:台式电脑内部

关键特征是一个叫做主板(也叫母板系统板)的大型印刷电路板,其他较小的电路板以直角插入主板。主板,如图 2 所示,包含计算机的核心部件,包括中央处理器 (CPU),有时简称为处理器,以及主内存;其他电路板是可选的扩展。

你通常可以通过眼睛找到 CPU:它位于系统的中心,看起来像地图上一个国家的首都,所有的道路都通向它。它通常位于一个非常大的风扇下方,以散热 CPU 中所有晶体管产生的热量。内存是下一个最重要的组件。主内存通常是显而易见的,呈现出一些物理上较大但均匀的区域;这是因为主内存在计算上是庞大且均匀的。在台式机中,主内存通常出现在几块板上,每块板上都有多个相同的 RAM 芯片,排列得整整齐齐。

Image

图 2:一块台式电脑的主板

印刷电路板(PCBs),例如主板,采用类似于丝网印刷海报或 T 恤的过程制作,如图 3 所示。在丝网印刷中,你为设计选择多个颜色,并为每种颜色购买一罐油漆。然后,使用 CAD 程序将设计分成这些颜色的区域块。你为每种颜色打印出二进制图像到不同的透明片上,标明该颜色油漆的位置,然后为每种颜色制作一个丝网印刷遮罩。遮罩起初是一个丝织物片,你在其上涂上一层光敏胶。将你打印出的透明片放在遮罩上,然后照射强光。

Image

图 3:为了打印这个海报,将丝网遮罩放置在纸张上,通过刮板将油漆推过它。

光敏胶在透明区域反应,而在黑色区域不反应。然后,你将遮罩放入水中,水洗去反应过的胶层,保留未反应的部分。剩余的胶层不允许油漆通过,而暴露的丝织物区域则允许油漆通过。现在,你可以将遮罩放在一张空白的纸张或 T 恤上,倒上油漆,油漆将仅通过设计中的指定区域。一旦让这层颜色干透后,你就可以对剩余的每种颜色重复整个过程,直到完成设计。

PCB(印刷电路板)的制作过程类似。首先,你从一个防酸的玻璃纤维绝缘板开始,完全覆盖一层铜。你在 CAD 程序中设计电路布局,将其打印到透明片上,然后通过透明片照射光线,选择性地遮蔽板上的光敏化学物质。接着,将板浸入酸中,去除未被遮蔽的铜层部分。剩下的铜形成 PCB 的线路。接下来,你将电子元件焊接到板上的适当位置。焊接以前是手工完成的,但现在是通过机器人完成的,机器人更加精准,且能处理更小的元件。

除了主板,PC 机箱的其他部分包含电力变压器,将家庭电源转换为计算机所需的各种电压,还有大容量存储设备,如硬盘和光盘驱动器——即 CD、DVD 或蓝光驱动器。

在过去的几十年里,个人电脑(PC)有许多扩展卡用于与显示器、音响设备和网络接口,但最近这些标准接口已转移到主板上的芯片上。标准尺寸的 PC 机箱(称为 ATX 外形规格)在现代台式机中通常包含大量空余空间,因为零部件已被微型化并集成到主板上。唯一明显例外的是显卡(图形处理单元,GPU),在高端机器中,显卡可能与主板一样大,甚至更大,以支持快速的 3D 视频游戏和科学计算。玩家喜欢通过 LED 灯照亮显卡,并使用透明 PC 机箱来展示这些显卡。

笔记本电脑

笔记本电脑的逻辑结构与台式机相同,但它们使用更小、功耗更低的组件,尽管这样牺牲了计算能力并增加了制造成本。图 4 展示了一个笔记本电脑主板的示例。

笔记本电脑主板并不是完全矩形的,而是根据可用空间的形状来设计的。由于没有空间容纳大型连接器,许多组件直接焊接在一起。与台式机不同,这里没有可交换的扩展卡垂直插入主板,而是选择了适合所有部件在键盘下整齐布置的外形规格。笔记本电脑的外形规格和组件标准化程度也低于台式机,每个制造商根据自己的需要选择组件。所有这些特点使得笔记本电脑更加昂贵,并且难以升级或更换部件。

Image

图 4:笔记本电脑主板

近年来,笔记本电脑中的安全启动系统为计算机架构带来了安全应用的挑战。过去,拿着雇主的笔记本电脑,卸下某些专有操作系统并替换为开源系统(如 Linux)非常容易。专有操作系统的开发商试图通过支付硬件制造商实施安全启动系统,限制你进行这种操作,甚至声称这是雇主的要求。这些系统在操作系统或引导加载程序有机会加载之前,就会将用户锁定在无法访问硬盘启动扇区的状态。你现在需要在硬件级别绕过安全启动,比如通过将专用芯片上的两个引脚短接来恢复出厂设置。由于这些引脚现在非常小,有时需要显微镜和精密焊接来完成短接。(这纯粹是假设的,因为篡改雇主设备或硬件制造商与操作系统供应商之间的协议可能是非法的。)

智能手机

在计算领域,智能一词如今意味着“是一个计算机”。历史上,消费电子设备如手机和电视都是为单一目的设计的,但近年来的趋势是将完整的计算能力融入其中。曾几何时,这是一种新奇事物,但现在,世界上很大一部分人群都将一台完整的计算机放在口袋里。因此,我们需要像对待传统桌面电脑和笔记本电脑一样,认真看待智能手机和其他智能设备作为计算机。图 5 展示了一款智能手机的主板。

ImageImage

图 5:Wileyfox Swift 智能手机内部,展示了主板的上下两面

该设计基于 ARM Cortex CPU。其他一些芯片则专门用于手机特定的功能,包括 Wi-Fi 和蜂窝网络(GSM)无线通信、电池管理以及位置和环境感应(如惯性测量单元、温度和压力传感器)。内存与桌面和笔记本电脑不同——这里使用了低功耗 RAM(LPDDR)。这种内存通过在不需要时清除并关闭内存部分,减少了电池使用。

计算机如今已小型化到几乎所有连接器可能成了占用最多空间的瓶颈,而不是计算机本身。例如,将手机的 3.5 毫米耳机插孔连接器换成更小的端口,一直是一个持续的争论。没有标准耳机插孔可能会带来不便,但拥有耳机插孔却会限制手机的体积。

洗衣机

如果我们的手机和电视是计算机,那么我们是否也可以考虑现在的洗衣机是计算机呢?图 6 展示了一台典型现代洗衣机的主板。

板上有一个小处理器,可能包含固件,即“烧录”到芯片中的一个单一程序,执行单一任务。这是我们将在第十二章讨论的嵌入式系统的一个例子。

Image

图 6:洗衣机的主板

像洗衣机和冰箱这样的消费电子设备如今受到关注,因为它们像手机一样,可能会成为下一代“智能”设备——即能够运行任意程序的设备。当“智能家居”完成时,用户将期望能够远程拨入洗衣机,查看其状态并下达命令。智能洗衣机甚至可能配有应用商店,允许用户下载和运行额外功能,如机器学习工具。这些工具可以识别并适当清洗不同的衣物,从而节省金钱和地球的能源与水资源。

这就是我们对一些设备的概览。随着我们进入接下来的计算机架构章节,我们对这些设备如何工作以及如何组织的理解将逐渐加深。在开始之前,这里有本书的简要概述以及一些供你尝试的练习。

本书概览

第一部分介绍了所有架构背后的基本概念。

第一章:历史架构 描述了计算机发展的历史演变,不仅仅是为了教授历史本身,更因为许多概念随着时间的推移不断重复并增加复杂性;这将帮助你通过先理解它们的简单前身,进而了解复杂的现代系统。

第二章:数据表示 讨论了如何使用二进制编码方案表示数据,这些方案稍后将通过数字逻辑实现。

第三章:基本的基于 CPU 的架构 探讨了 CPU 是什么,它的基本子组件以及它的机器代码用户界面。

一旦你理解了第一部分中的概念,计算机架构的核心结构从根本上是分层的;第二部分将逐步深入这一层级。

第四章:开关 介绍了开关,这是现代计算机的基本构建模块。

第五章:数字逻辑 从这些开关中构建逻辑门。

第六章:简单机器 将这些逻辑门组合成简单的机器。

第七章:数字 CPU 设计 使用这些简单的机器构建 CPU 组件,最终完成一个完整的小型 CPU。

第八章:高级 CPU 设计 介绍了更先进的现代 CPU 特性,如流水线和乱序执行。

第九章:输入/输出 增加了输入/输出(I/O),这是从 CPU 到完整计算机的又一步。

第十章:内存 介绍了内存,这是构建完整计算机的最后一个必要部分。

第三部分由逐渐复杂的实例和应用组成,基本上按照它们的历史顺序展开;这些实例旨在加强你对第二部分所学结构的理解。

第十一章:复古架构 从 8 位和 16 位时代相对简单且完整的复古计算机开始,包括向你展示如何在其汇编语言中编写复古视频游戏。

第十二章:嵌入式架构 展示了现代低功耗物联网设备如何与复古设备具有相似的结构、功能和编程风格。

第十三章:桌面架构 研究了 x86 架构的复杂指令集及其历史,这可能是你主力桌面计算机的基础。这将使你能够在“裸机”上(即不依赖操作系统的情况下)使用汇编语言编程。你还将探索你桌面计算机可能包含的常见 PC I/O 标准和外设。

第十四章:智能架构 转向了越来越多的小型智能设备,它们正在取代桌面计算机。这些设备的特点是采用诸如 RISC-V 等 RISC 架构,并结合汇编编程和数字逻辑设计工具。

第十五章:并行架构 讨论了并行架构,随着 CPU 运行速度的瓶颈,越来越多的并行架构变得常见。

第十六章:未来架构 通过对可能的未来架构进行推测进行总结,包括神经网络、DNA 和量子计算。

架构、组织还是设计?

计算机架构传统上与计算机组织有所区分,前者指的是程序员可见的硬件-软件接口设计,后者则指这些接口的硬件实现,程序员不可见。在这个语境下,程序员被认为是在汇编语言级别工作,汇编语言承担着程序员接口的角色。然而,在现代世界中,程序员很少直接接触到汇编语言级别,因为他们几乎总是使用编译语言工作。编译器,现在的操作系统,甚至像库和游戏引擎这样的更高层结构,都将用户抽象到了比旧有汇编接口更高的层次。因此,传统的架构与组织的区分变得不再那么有意义。

在本书中,我们将架构用来指代上述所有内容,并将指令集架构(ISA)用来表示更具体的硬件与程序员接口的研究。我们的架构定义还包括对计算机硬件各部分的研究,尤其是 CPU 之外的部分,如内存和 I/O 系统,这有时被称为计算机设计。现代计算机越来越多地以集群和云的形式互联,因此现在可能很难或没有意义区分一组紧密连接的计算机和一台大型计算机。因此,我们的架构概念也扩展到这类系统。

架构层次结构这两个词都包含词缀arch。它们之间的联系并不简单:架构主要涉及层次结构。层次结构是将完整的结构组织成组件和子组件的方式。没有人能够理解芯片上十亿个晶体管的结构,但是如同软件设计一样,我们通过将其分块为多层抽象来理解。我们将晶体管分成大约四到五个一组的逻辑门;然后将逻辑门分成像加法器这样的简单机器;接着将这些机器分成 CPU 的组件,然后再是 CPU 本身。这样,每个层次都可以从数十到数百个可理解的组件设计,设计者只需在他们工作的单一层次上思考。正如前面提到的,本书的第二部分的结构遵循这种层次结构,从晶体管开始逐层构建,引入越来越大和更高的结构。

练习

每章末尾都有一些练习,帮助您将所学应用到现实世界的系统中。一些任务标有“挑战”标题,提出额外挑战,因此更难。在“更具挑战性”的标题下的任务非常困难或耗时较长,更多是针对大型个人项目的建议。

在您自己的设备内部

  1. 如果您愿意违反设备的保修条款,购买一组小螺丝刀并打开台式机,查看内部结构。请小心只打开外壳而不要干扰电路板本身。试图识别主要组件,包括电源供应器,主板,CPU,RAM,GPU 和通信设备,就像我们之前讨论的示例一样。如果您对自己的螺丝刀技能不确定,您可能希望在旧的牺牲设备上练习,然后再处理主要设备,或者搜索其他人打开类似设备的互联网视频。

  2. 您在内部找到的大多数组件都会有品牌名称和型号编号。搜索互联网获取这些组件的正式产品资料表。使用资料表来识别部分关键属性,例如 CPU 核心数量和速度,RAM 及其缓存的大小,GPU 内存大小,存在的输入和输出设备以及它们的能力和速度。(如果由于散热器而难以访问您的 CPU,您通常可以在主板资料表上找到其品牌和型号。)

软件设备检查

  1. 您还可以使用软件工具检查硬件,而无需在许多机器上违反保修。例如,如果您在运行 Linux,请尝试以下命令:

    lscpu

    cat /proc/cpuinfo

    lshw

    free

    hwinfo

    lspci

    lsusb

    nvidia-smi

    clinfo

    在 Windows 上,从“开始”菜单运行“设置”程序,然后在系统设置中查找类似的信息。

  2. 进行一些互联网研究,以解读结果。

  3. 如果你实际打开了设备,请检查设备内部的品牌和型号是否与软件报告的相符——它们不匹配是相当常见且有趣的现象,如果你看到这样的例子,研究一下为什么会这样!

具有挑战性

如果你习惯了打开台式电脑并查看内部,可以购买一些小型螺丝刀,对旧笔记本电脑做同样的操作。

更具挑战性

如果你习惯了拆解笔记本电脑,可以购买更小的螺丝刀,尝试对你的手机或游戏机做同样的操作。有些手机可以用 Torx 螺丝刀打开,虽然其他手机可能需要你购买一些在线几美元的手机修理工具包。有些日本游戏机使用的是日本标准螺丝,而不是西方标准螺丝。你也可以为这些设备订购修理工具包,同样也只需要几美元。(有些设备并不打算让用户自行打开或维修,因此它们通常使用胶水粘合,导致很难进行拆解。)

进一步阅读

本书主要面向那些希望了解架构并成为其用户的读者。然而,对于那些希望在架构领域工作的人,比如芯片设计师,本书也应该有所帮助。如果你是这样的读者,你可能想至少快速浏览一下专为工作中的建筑师准备的大部头、较为困难的标准教材,以便更好地了解他们的工作内容:

John Hennessy 和 David Patterson,计算机架构:定量方法,第 6 版(美国剑桥:摩根·考夫曼出版社,2017)。

这是由 Turing 奖得主、RISC 和 RISC-V 发明者编写的经典权威参考书。目前建议仅快速浏览它。你很可能在完成本书后作为准备工作再回过头来阅读它。

第一章:第一部分

基础概念

第二章:## 历史建筑

图片

计算机科学的历史比许多人想象的要悠久得多。本章将从 40,000 年前开始,直到今天为止。现代微芯片乍一看可能显得难以理解且陌生,但如果了解其历史,你就能通过逐渐发展了几千年的结构来理解它的各个小组成部分。

学习该领域历史的原因还有很多。了解计算机科学的悠久历史能为我们作为一个独立于数学或工程等领域的学科增加更多的可信度和权威性。看到思想是如何逐步发展的,通过彼此的积累,也能帮助我们摆脱“孤独天才”的神话,揭示这些人可能和我们一样。最后,追溯“历史的轨迹”不仅能解释我们是如何走到今天的,还能预测我们未来的方向,帮助我们预测或创造未来。

什么是计算机?

当我们今天想象“计算机”时,可能会想到台式电脑、游戏机或智能手机等设备。但这些并不是人类唯一用于计算的机器。要追溯计算机的历史,我们首先需要决定什么算作计算机,以及计算机与普通计算器或计算机械有什么不同。这是一个出乎意料的难题,至今仍有争议。我的判断标准是:你能在上面编程 太空入侵者 吗?一个简单的计算器做不到这一点,所以它不是计算机;而一个可编程的计算器通常可以,因此它是计算机。

让我们来看一些常常用来定义计算机的概念。一些资料——包括牛津英语词典——要求计算机必须是电子设备。但类似的机器也可以用其他材料制造,比如水。考虑一下MONIAC,即货币国民收入模拟计算机,这是对稍后我们将在本章中讨论的 ENIAC 计算机的双关语。MONIAC 于 1949 年建成,见图 1-1,它是一台模拟水计算机,用于模拟货币在经济中的流动,并展示经济干预对经济模型的影响。

图片

图 1-1:MONIAC 水计算机及其创造者 Bill Phillips

MONIAC 让你可以调节利率,并观察其对失业率的影响。水箱展示了经济中各个部门(如中央银行、储蓄和投资)中资金的位置,这是根据机器内置的经济学理论来设计的。

有些人认为计算机必须是数字的,而非模拟的。数字机器是通过数字表示数据的机器,数字是离散的符号集,例如二进制数字 0 和 1。与此相对,模拟机器具有无限、连续的可能状态集,例如 MONIAC 储液池中的水量,这使得 MONIAC 成为一台模拟机器。

MONIAC 在我原本的太空侵略者测试中处于什么位置?它只计算单一经济模型的结果,尽管如果我们能重新配置一些管道和储液池,使它们具有不同的尺寸和连接方式,它也许能运行其他经济模型。从这个角度来看,也许 MONIAC 可以通过更为严苛的这种重新配置来实现任何计算任务,比如运行太空侵略者。但那时我们是否依然会有一台在新配置下的相同计算机,还是会得到一台新的、不同的机器,只能计算另一个不同的任务?换句话说,MONIAC 是可重新编程的吗?

我一直在使用太空侵略者作为测试程序,但很容易得出结论:为了让某物成为一台计算机,它必须能够重新编程来做任何事情。然而,计算理论表明,这不能作为定义。对于任何候选计算机,总是可以找到它无法解决的问题。这些问题通常是关于预测候选计算机自身未来行为的问题,这可能会导致它进入无限循环。

深入一些计算理论,我们可以得到丘奇命题,它是对计算机的更严谨的定义,现代计算机科学家普遍认同。它可以这样表述:

计算机是一种可以模拟任何其他机器的机器,只要提供足够的内存。

我们将满足丘奇命题的机器称为丘奇计算机。特别地,显然存在能够完成以下任务的机器,因此丘奇计算机也必须能够执行这些任务:

  • 读写和处理数据

  • 读写并执行程序

  • 加法(因此能够进行算术运算)

  • 跳转(goto语句)

  • 分支(if语句)

我们现在可以看到,太空侵略者的定义在很多情况下是丘奇命题的合理近似:虽然太空侵略者是一个简单的电子游戏,但它恰好需要完成所有上述任务,这些任务也是许多其他计算任务和机器的基本组成部分。因此,一台能够重新编程(而不是硬接线)来玩太空侵略者的机器,通常也足够强大,能够模拟任何其他机器(只要我们提供它所要求的足够内存)。

本章的其余部分按时间顺序追溯计算机及计算机类设备的历史,从石器时代开始。在阅读时,问问自己是谁发明了第一台计算机,并记下你认为计算机发明的时刻。人们常常基于自己对计算机定义的不同,争论应该在什么地方划定这条界限。会在哪里划定这条界限,为什么?

工业革命之前

本节我们将探讨各种可能或不可能被视为计算机的前工业机器。在此过程中,我们将看到人类使用类似计算机的机制的历史比我们想象的要长。

石器时代

我们的解剖学物种智人大约有 20 万年的历史,但广泛认为我们在大约公元前 40,000 年的认知革命之前缺乏现代智力。我们并不确切知道这一过程是如何发生的。一种当前的理论认为,FOXP2 基因中的一次单一基因突变发生,并在冰河时期的极端进化压力下被选择。这突然使大脑能够形成任意新的层次化概念,从而催生了语言和技术。根据这一理论,从那时起人类的智力就和我们现在一样。假如他们能接触到现代设施和信息,他们应该能够学习,比如量子计算。

这种转变的一个标志可能是莱邦博骨,见于图 1-2—这是一块有刻痕的骨头,可能在公元前 40,000 年左右用作计数棒。在计数中,每一刻痕代表一个物理对象。也许这些刻痕表示动物、食物、某人欠另一个人的恩惠,或者用于记录某次狩猎或社交项目的天数。

Image

图 1-2:莱邦博骨

伊尚戈骨,见于图 1-3,是另一块含有人类刻画的类似计数标记的骨头,日期大约在冰河时代晚期,公元前 20,000 年左右。与莱邦博骨不同,伊尚戈骨上的标记似乎被分成了以 3 到 19 之间的素数为主的计数簇,并且这些簇被分为三行,分别总和为 60 或 48。

和莱邦博骨一样,伊尚戈骨上的标记可能完全是随机的位置,并且是为了某种物理目的而制作的,比如改善手部抓握。但几位作者研究了伊尚戈骨的模式,认为这些标记起到了计数、辅助计算、农历或月经周期日历的作用,或者更具推测性地,它是一个素数表。60 和 48 的总和是 12 的倍数,而 12 被认为是后期文明中算术的原始基础,直到我们转向了十进制。

Image

图 1-3:伊尚戈骨,一些人认为它从计数发展到计算

莱博姆博骨可能是数据表示的一个例子。可以说,它可能用于一种简单的计算方式,比如每次新刻一个标记时就将总数加一。对伊香戈骨的某些解释表明它可能用于更复杂的计算,也许是互动式的,就像使用钢笔和纸来执行并跟踪数学问题的多个步骤。

你能编程让一根骨头玩 太空入侵者 吗?你可以设计一组规则让人类按照这些规则做刻痕来更新游戏角色的表示。游戏进程会非常缓慢,而且人类必须在那里进行更新。没有证据表明人类曾以这种可编程的方式使用过骨头——虽然或许有一天会发现另一根骨头,并将其刻痕解码为供人类操作员遵循的指令。

青铜时代

大约公元前 4000 年,冰雪融化,促使了第一个城市的诞生。城市的发展需要新的、更大的组织形式,比如记录贸易和税收。为了实现这一点,公元前 3000 年,美索不达米亚的苏美尔城市文化(今伊拉克)开发出了最早的文字系统,公元前 2500 年,它拥有了第一个无可争议的计算工具——算盘(图 1-4)。算盘 这个词意为“沙箱”,这表明在此之前,相同的机制可能是使用沙子中的简单石块来实现的。我们在考古学中发现的最古老的算盘是那些由木头和珠子制成的更为先进的版本。

Image

图 1-4:算盘

在通常的使用中,图 1-4 中的算盘状态表示(十进制,自然数)070710678。共有九列,每一列代表该数字中的一个数字。每一列被分为下部盒子,里面有五颗珠子,上部盒子里有两颗珠子。下部盒子中珠子的默认位置是下方,而上部盒子中珠子的默认位置是上方。在这种状态下,一列表示数字 0。每颗从下往上推到下部盒子顶部的珠子值为 1。每颗从上往下推到上部盒子底部的珠子值为 5。

要在算盘上加 1(即 增量),你需要从最右列的下部盒子中抬起一颗珠子。如果某一列下部盒子中的所有五颗珠子都被抬起,你就把它们都推回去,并通过将同一列上部盒子中的一颗珠子下移来替代它们。如果上部盒子的两颗珠子都被下移,你就把它们推回去,并通过从左边相邻列的下部盒子中抬起一颗珠子来替代它们。将数据从一列移到左边的列称为 进位 操作。

要将两个数字 a + b 相加,你首先设置算盘来表示 a 的数字。然后你执行 b 次增量操作,如上所述。然后,算盘的状态就代表了结果。

这种计算方式——其中第一个数字“加载”到设备上,第二个数字则“加到”其中,只留下最终结果作为系统的状态——被称为累加器架构,直到今天仍然广泛使用。它“累积”一系列计算的结果;例如,我们可以通过依次将每个数字加到状态中,并在每次加法后查看最新的累积总和,从而将一系列数字加在一起。

注意

本例中的算盘使用十进制数字以便理解。原始的苏美尔版本使用的是 12 进制。

算法的概念可以追溯到这一时期。刻在粘土板上的计算,例如图 1-5 中的那些,显示出当时具备数字识别能力的人们更多是以计算的方式思考,而非数学,被教导执行算术运算的算法并进行实践,而不是进行证明。

图片

图 1-5:显示长除法算法步骤的平板

黏土板上显示了一步步的算术运算,可能是利用这些板子本身作为数据存储来执行的。或者,这些板子可能是用来记载算盘的状态,以便教学使用。

算盘曾经——并且在一些地方仍然——最常用于加法运算,例如求购物车中物品价格的总和,但也已知有其他古代算盘算法,包括减法、乘法和长除法。这些算法的执行方式与现代的笔纸运算类似。现代的爱好者(你可以在 YouTube 上找到他们)也展示了如何用算盘进行更复杂的算法,如求平方根和计算π的数字。随着这些算法变得越来越复杂,算盘的记忆往往需要通过增加额外的列来扩展。就像石器时代的骨头一样,如果告诉人类在算盘上执行哪些操作,它可以用作任何算法的数据存储。如果你想争辩说它是计算机,可能还需要考虑人类在其中的角色。

铁器时代

美索不达米亚及其邻国的青铜时代城市文明在公元前 1200 年左右神秘地崩溃。随后进入了一个“黑暗时代”,直到公元前 500 年到公元前 300 年左右古典希腊崛起:这是毕达哥拉斯、柏拉图和亚里士多德的时代。从公元前 300 年到公元 400 年,希腊的力量逐渐被罗马共和国和罗马帝国取代。

安提凯希拉机制(图 1-6)的历史可以追溯到公元前 100 年左右。它是在 1901 年从一艘沉船中被发现的;这艘沉船似乎是从希腊驶往罗马,机制可能是要出售或作为贡品。直到 2008 年,人们才真正理解并逆向工程了这个机制。我们现在知道,它是一个机械的、钟表式的模拟机器,用于预测天文(并且可能是占星)事件,包括五颗行星的位置、月亮的相位、日食的时机以及奥林匹克运动会的时间安排。它由 37 个铜齿轮组成,用户通过转动一个手柄来模拟未来的天体运动。结果通过机械齿轮的比例计算并显示在时钟面上。最近,爱好者们使用乐高重建了一个可以正常工作的版本(图 1-6)。

图片

图 1-6:安提凯希拉机制的遗物,发现于地中海的沉船(左),以及使用乐高重建的安提凯希拉机制(右)

里程表是古希腊和古罗马用来测量远距离的工具,用于勘测和绘制他们的帝国地图。大约公元前 300 年左右有间接证据表明它们被使用,因为存在一些非常精确的距离测量数据,这些数据用其他方法难以获得。图 1-7 中的重建是基于公元 50 年左右的直接考古遗物。

这种里程表的工作原理类似于你在小学可能用过的测量轮,每当推动一定的距离时就会“咔哒”作响,通常是 1 码或 1 米。它也与现代汽车和机器人使用的里程表有关。

图片

图 1-7:一辆罗马里程测量车

里程表由一匹马拉动,就像一辆车。多个金属球存放在一个圆形木齿轮的腔体内。其中一个轮子上有一个插销,轮子每转动一次,插销就会轻轻地碰击并使齿轮转动一个固定的小角度。齿轮下方的一个与球大小相当的孔允许一个球从齿轮的腔体中掉落到下面的收集箱里。这样,旅行结束时,记录的总行程就是通过计数箱中球的数量来统计的。

这些机器是计算机吗?显然它们涉及到用数据表示世界中的物体,并且有自动化和计算的形式。但是,就像 MONIAC 一样,每台机器只能做一件事:预测日食或测量距离。你不可能轻易地重新编程它们来玩太空入侵者

与 MONIAC 类似,安提基特拉机制是一个模拟机器:它的齿轮持续旋转,可以处于任何位置。相比之下,计程器是数字化的,像算盘一样。每次“点击”时,当插销通过时,它的齿轮只会前进一个离散的单位,且收集箱总是保持一个离散数量的球。然而,不同于算盘,计程器是自动的;它不需要人工操作,只需要马作为动力源。

如果允许你完全重新配置所有齿轮,包括添加和移除任意位置和大小的齿轮,你可能能够重新编程安提基特拉机制——并且凭借一些创造力,也许能够重新编程计程器。然后你可以尝试表示和模拟其他物理系统,或者执行其他计算。与 MONIAC 类似,有人认为通过这种方式物理性地重新配置硬件是在作弊。他们会认为这创造了一个新的、不同的机器,而不是在原始机器上运行一个不同的程序。

伊斯兰黄金时代

罗马帝国在公元 476 年灭亡后,西欧进入了所谓的黑暗时代,持续了千年,西欧的计算机历史在这段时间里几乎没有任何进展。

然而,罗马帝国继续在其新的东部首都拜占庭(今土耳其的伊斯坦布尔)运作。拜占庭、希腊和伊斯兰世界之间有思想交流,后者成为当时新的知识中心。这种文化中的一个特别音乐理念引入了编程这一重要概念。

古希腊人曾拥有一种便携式水力管风琴乐器,类似于现代的教堂风琴。它由一组管道组成,通过键盘演奏,并通过仆人抽水的空气储存器提供动力。希腊人显然具备制造自奏版水力管风琴的技术,但没有证据表明他们曾这样做。

是伊斯兰学者穆萨兄弟(Banu Musa)在公元 900 年左右创造了第一台已知的自动化音乐乐器:巴格达自动长笛演奏机,如图 1-8 所示。

图像

图 1-8:一台希腊水力管风琴(左)和巴格达自动长笛演奏机的草图(右)

这一创新是使用一个缓慢旋转的圆筒,其边缘有可移动的钉子,用来指示音符的位置。随着圆筒的旋转,钉子与杠杆接触,允许空气流入乐器以发出音符。钉子的可移动性使得不同的乐曲可以被编程到设备中,这使它成为已知的第一台可编程自动化机器。这些钉子今天可以视为二进制代码:在每个时刻和音高处,要么有音符(1),要么没有音符(0)。

这是一台计算机吗?与铁器时代的机器不同,它显然可以运行多个程序。然而,它没有计算或决策的概念:一旦程序开始,它就会按预定方式运行,无法根据任何输入或甚至自身状态改变其行为。

文艺复兴与启蒙时代

拜占庭帝国在 1453 年灭亡,将许多学者和他们的书籍送回西欧,帮助西欧从黑暗时代觉醒。列奥纳多·达·芬奇是那个时代名副其实的“文艺复兴人”:一位多产的科学家、艺术家和工程师。他拥有许多这些古老的书籍,并以此为灵感。他可能通过这些书籍熟悉了类似安提凯希拉机制的系统。大约在 1502 年,他的一本手稿《马德里法典》包含了一个基于类似安提凯希拉原理的机械模拟计算器设计(图 1-9)。

Image

图 1-9:达·芬奇计算器的原始手稿

这一设计在 1968 年被重新发现并成功构建。设备有 13 个轮子,每个轮子代表十进制数字的一个列。它们的位置是连续的:它们不是突然从一个十进制数字跳到另一个,而是通过齿轮平滑地转动。每对列之间的齿轮比为 1:10,因此每一列的轮子转动速度是其右侧列轮子的十分之一。

像算盘一样,计算器也是一个累加器,其在任何时刻的状态都代表一个数字,再次以列的形式表示数字。一个数字a可以加到另一个数字b上。第一个数字a可以通过将机制调到相应位置来加载到机器中,表示其数字。然后,它将再转动一定的量b,使得总数增加到a + b

例如,要计算 2,130 + 1,234,我们首先将 2,130 加载到设备上,然后通过 1,234 的转动得到 3,364。由于轮子的持续旋转,计算结束时数字可能不会精确对齐。例如,十位上的 6 几乎位于 6 和 7 之间,因为它后面的数字是 4,几乎到达下一个进位的位置。从某种意义上说,这是一种比罗马里程表更“弱”的机器,因为里程表有通过销钉和球机制将连续轮位转换为离散符号的概念。

达·芬奇的概念在 1642 年由布莱兹·帕斯卡尔扩展。图 1-10 展示了帕斯卡尔的计算器设计和它的现代重建版本。(最近有人提出,帕斯卡尔的计算器实际上是在 1623 年由威廉·席卡德发明的。)

Image

图 1-10:帕斯卡尔的计算器:原始设计和 2016 年用乐高重建的版本

帕斯卡的计算器包含一个数字机制,类似于里程表(而不是达芬奇的模拟齿轮机构)来实现进位机制。当一列的数字达到 9 并且再加上一个单位时,它会触发下一列的单位转动,同时将该列的数字归零。

与表示物理(天文)物体状态的安提凯瑟机械不同,达芬奇和帕斯卡的机器操作的是纯粹的数字。你可以说,这使得它们比安提凯瑟机械更具通用性。话虽如此,它们的计算范围仅限于加法,从某种意义上来说,这使得它们比算盘的能力要弱,因为算盘有其他算术运算的算法。另一方面,像安提凯瑟机械一样,这些计算器所需的人力工作比算盘要少。

有些人认为从达芬奇的模拟操作到帕斯卡的数字操作的转变非常重要。数字操作似乎涉及机器做出“决策”的简单概念:在每一步中,进位要么进行,要么不进行。决策在某些任务中确实很重要,但显然对加法而言并不那么重要,因为这两种计算器在加法上表现一样好。

蒸汽时代

蒸汽动力在古希腊和古罗马时代被视为一种好奇现象,任何曾用盖子煮水的人都会注意到蒸汽能让盖子动起来。但直到大约 1700 年,英国才开始真正利用蒸汽来推动工业革命。这一变革受到启蒙时代思想的启发,特别是牛顿的物理学,这成为一个积极反馈循环,机器和煤炭被用来制造更多的机器并提取更多的煤。煤被燃烧来加热水并转化为蒸汽,蒸汽最初用于从煤矿抽水。随着时间的推移,蒸汽动力开始驱动许多其他机器,其中一些具有类似计算机的特征。

雅卡尔织机

纺织品生产是蒸汽时代新机器的一个主要应用。但与简单的棉布衣物不同,传统的织物图案极为复杂。因此,它们被认为更有价值,因为它们更稀有、更昂贵。

1804 年,约瑟夫·雅卡尔(Joseph Jacquard)发明了一种变种的当时织布机,采用可更换的打孔卡片来引导织物中的钩子和针的位置(图 1-11)。

Image

图 1-11:雅卡尔织机

打孔卡片可以“链”在一起,形成长条带,以较低的价格制作复杂的可重用图案。

注意

“链”成为了后来电子设备中从磁带加载下一个程序的标准命令,这一命令一直使用到 1990 年代。织布的概念,如“纱线”和“经线”,也被用作现代多线程编程和并行 GPU 中的隐喻。

维多利亚时期的管风琴和音乐盒

19 世纪流行的基于桶的音乐器乐,类似于巴格达自动长笛演奏机,如图 1-12 所示。一个“风琴手”的工作是把一个便携的手摇风琴推到主要街道,然后手动转动它的手柄以提供动力。一个旋转的桶上的钉子标记了音符的位置,然后允许空气进入风琴管,就像巴格达的版本一样。

图像

图 1-12:两台维多利亚风格的手摇风琴(左和中)和音乐盒(右)

同样的机制在这个时期的音乐盒中(至今仍在使用),其中一个弹簧被卷起来储存能量,然后释放以驱动一个较小的有钉筒,其钉子直接击打小型木琴式金属条,播放一些音乐片段,比如芭蕾舞剧中的著名主题。旋转的桶常常顶着一个小雕塑,如一个芭蕾舞者,随着音乐一起旋转。

查尔斯·巴贝奇讨厌风琴手在他家门外演奏,他发起了一场公开运动,要求从伦敦街头清除他们。但他们的手摇风琴却对他的工作产生了深远的影响。

巴贝奇的差分机

巴贝奇设计了两台不同的机器,分别是差分机和分析引擎。前者(图 1-13)首先被建造出来,并于 1855 年由乔治·舍茨等人成功商业化,广泛应用于工业直至 1930 年代。最近还有乐高重建版本存在。

差分机的设计用于生成任意多项式函数的数值表。大多数数学函数可以通过泰勒级数展开来很好地近似为多项式,因此这台机器可以用来为任何这样的函数制作数值表。在现代考试中,当不允许使用计算器时,你可能会使用类似的表格查找三角函数或统计函数的值。在巴贝奇的时代,这些表格的杀手级应用是在航海中,用于导航目的。以前的表格是手工计算的,包含许多昂贵的错误,因此通过机器完善它们有着很大的经济需求。

该机器可以通过蒸汽或人工摇曳手柄来驱动。与帕斯卡的计算器类似,差分机通过齿轮的离散旋转来表示十进制数字。数字由这些数字的垂直列表示(类似于帕斯卡的计算器侧倾)。然后,差分机将此扩展为二维平行体系结构,多个垂直列水平排列。每列代表一个不同的数字。

图像

图 1-13:巴贝奇的差分机的金属重建

差分机有两个并行化的维度:按数字和按项。按数字加法,例如,是与高中顺序加法方法不同的算法。它不是从最右边的列开始,向左移动并进行进位,而是同时加上每一对数字,然后再处理进位。例如,要加 364 + 152,三个加法 3 + 1、6 + 5 和 4 + 2 会同时进行,结果为 416。然后,6 + 5 = 11 的进位会被加上,得到 516。进位是一个在这种情况下难以正确执行的操作,巴贝奇将大部分工程时间都投入在这上面。差分机在 YouTube 视频中显示的进位效果是信息在机器二维表面上传播的可见波纹。这种波纹在现代并行 GPU 的计算中也可以看到。

差分机算是计算机吗?它可以运行不同的“程序”来计算不同的方程式,但这些方程式没有明显的概念来在计算过程中改变其行为;没有类似 if 语句那样的东西来测试中间结果并根据它们做出不同的操作。它更像是一个现代的媒体流设备,其中数字平滑地通过处理管道流动。

巴贝奇的分析机

差分机仅限于计算多项式函数的表格,但巴贝奇的第二个项目——分析机(图 1-14)被设计为一台完全通用、可编程的机器。

图片

图 1-14:巴贝奇分析机的现代部分重建

为了实现这种普适性,分析机提供了一系列算术和其他操作作为简单的机械装置,并配备了用于存储数据的内存以及从打孔卡片读取程序的能力。这些程序规定了内存读取和写入的顺序、算术运算,并允许根据计算状态进行分支——一个 if 语句。

巴贝奇在纸上进行了多次分析机设计的变更,但在他去世前,实际上只建造了其中的一小部分。他在进位机制的细节上走了很多弯路,痴迷于不断重新设计组件,而不是坚持一个版本并将其整合起来使其真正运作。(今天,这种项目管理风格被称为yak shaving。)这让当时的研究资金机构很不满,使得巴贝奇很难获得资金来建造任何东西。因此,与差分机不同的是,我们没有分析机的工作版本,甚至没有一份完整的最终设计文档。然而,最近,通过现代制造技术,已经根据巴贝奇的计划重建了一些组件。

分析机拥有比差分机更多的活动部件,因此需要更多的动力;这必须来自蒸汽机,而不是手动曲柄。它还需要更精密加工的齿轮,因为计算需要通过一系列更长的齿轮传递。像当时的工厂机器和蒸汽机车一样,它将散发出油烟、蒸汽的气味,并在抛光的黄铜表面闪闪发光:巴贝奇是最初的蒸汽朋克。

分析机的核心包含许多独立的简单机器,每台机器执行某种功能,例如加法运算和测试一个数字是否等于另一个数字。加法机大致是帕斯卡计算器的复制品,其他简单机器是它的变种。

分析机引入了现代计算机内存的概念。它的“存储”部分将包含大量简单机器的副本,再次类似于帕斯卡计算器,每台机器都可以保留不同的数字。每台机器都会被分配一个数字标识符或“地址”,以指定读取或写入的正确机器。

一系列指令将以二进制编码,并通过纸带打孔的方式输入,使用的机制来自于雅卡尔织布机。每条指令将告诉引擎激活其中一台简单机器。通常,在每条指令之后,机器会通过行进方式将打孔的纸带送到下一条(有点像打字机)。然而,机器也具备检查最新简单机器结果的能力,并根据其值跳转到纸带的不同位置。这使得程序能够根据中间结果改变其行为。

通过将打孔纸带的底部粘到顶部,形成一个物理循环,程序也可以无限运行,正如在图 1-15 中所示的(后来的)打孔纸带机。

Image

图 1-15:一个打孔纸带程序循环

我们没有任何实际为分析机编写的程序示例。相反,巴贝奇和他的合作者阿达·洛夫莱斯记录下了虚拟运行的状态输出,以长表格的形式显示程序执行的每一步。这类似于巴比伦人泥板上的标注,展示算法的效果,而不是生成这些效果的指令。从这些执行痕迹中,现代读者可以大致推测出程序和构建它们的机器指令集的内容。

巴贝奇为一些小的、几乎微不足道的数学函数写了这些示例跟踪,粗略展示了正在使用的完整指令集。但巴贝奇是硬件专家,更关心设计机器本身,且从未写过更长的程序,认为编程相对于设计架构来说是相对简单的。洛夫莱斯则是软件专家,她为复杂的函数写了更长的跟踪。她还写了关于更大程序能够实现的推测,包括关于人工智能的想法。如果说巴贝奇是“第一个程序员”,那么洛夫莱斯可能就是“第一个软件工程师”,因为她更认真地考虑了大规模编程。

解析机是丘奇计算机吗?它的设计包含了现代计算机的所有基本特征:CPU、内存、总线、寄存器、控制单元和算术单元。它可以读取、写入和处理数据。它可以进行算术运算。与之前纯粹的计算机器不同,它可以跳转(goto)和分支(if),根据计算的状态跳到程序中的不同指令。

然而,要能够模拟任何其他机器,它需要能够读取、写入和执行程序,并且能够读取、写入和处理数据。但它的程序是固定在穿孔纸上的,而不是像现代 PC 一样存储在内存中。这种数据和程序被分开存储的架构,通常程序是作为固件固定的,叫做哈佛架构,与冯·诺依曼架构不同,后者将程序和数据存储在一起。

今天,哈佛架构被广泛应用于嵌入式系统,特别是在数字信号处理芯片中。可以建立一个哈佛架构,模拟其他计算机,包括那些能够修改自身程序的计算机。这可以通过在固定程序穿孔卡(或现代固件)上编写一个单一的虚拟机(VM)程序来完成。虚拟机读取、执行并在内存中写入更多程序。

洛夫莱斯或巴贝奇本可以为解析机编写一个虚拟机程序,但他们并没有考虑这一点。许多其他机器也可以这样说。例如,如果程序员愿意的话,虚拟机可以为苏美尔算盘编写并在其上执行。丘奇的命题是关于机器模拟任何其他机器的潜力,而不是它实际做到这一点的实现。但这取决于我们考虑的是“哪个层次”的机器:底层硬件还是运行在更高软件层次上的虚拟机。

当然,解析机从未完全建造或测试过——要证明“它是计算机”是否需要做到这一点,还是仅凭其基本设计就足够了?

机械差分分析仪

工业革命主要是通过实践中的“黑客”们根据直觉建造机器,然后测试它们是否有效的过程推进的。但随着时间的推移,数学理论被用来描述和预测许多工程系统的行为,促成了学术工程学的发展。这些理论大多采用了微积分。微积分早期由戈特弗里德·威廉·莱布尼茨和(独立地)艾萨克·牛顿为了不同的目的发展,很快作为一种通用工具,在建模各类系统(包括工业机械)如何随时间变化时,得到了广泛应用,通过诸如以下方程:

图片

其中 x 是被建模的世界状态的一部分,f 是它的某个函数,dx/dtx 的变化率。这类方程可以通过迭代计算 dx/dt 并使用它来更新 x,从而数值模拟世界随时间变化的状态。就像制作差分机的多项式表格一样,这是一个高度重复且容易出错的过程,非常适合机械自动化。

1836 年,即解析机被开发的同一年,加斯帕尔-古斯塔夫·科里奥利斯意识到,由于机械设备的行为可以用微分方程描述,因此同样的设备可以看作是计算该方程的解。因此,为了解决一个新的方程,可以设计一个与之匹配的物理设备,然后运行该设备一段时间以给出所需的答案。

更一般的微分方程可以涉及加速度、高阶导数以及多个变量。科里奥利斯的想法被其他人扩展,包括 1872 年的凯尔文勋爵和 1876 年的詹姆斯·汤姆森,旨在通过构造匹配这些方程的模拟机械设备来求解这些系统。这些机器的关键部件是球盘积分器(图 1-16),其中一个可移动的球体将旋转盘的运动传递到输出轴。

图片

图 1-16:凯尔文差分分析仪中的球盘积分器

像差分机一样,这些机器仅仅是为了求解一类特定的问题:微分方程。然而,世界及其问题的大部分,甚至可能是全部,都可以通过微分方程来建模。作为本质上模拟机器,它们可以被视为继续达芬奇类比计算机的传统,而巴贝奇的机器则是基于帕斯卡的数字计算器。

使用世界物理属性来模拟自身的概念,最近在量子计算中得到了复兴,在那里模拟物理和化学量子系统似乎是一个主要应用,特别适合量子计算机的工作方式。

柴油时代

在工业革命的纯机械机器和后来的电子机器之间,有一个混合时期,电力与机械运动结合,制造出了电机机械化的机器。

关键的电机机械技术是继电器,它是电路中的一个机械开关,其物理位置通过一个磁铁来控制,而磁铁又由另一个电信号来控制。继电器是一种特殊类型的电磁铁,它是一种线圈,当电流通过时产生线性磁场。这个磁场可以用来物理移动线圈内的磁铁(称为铁芯),这种运动可以用来打开和关闭水管中的阀门,或启动汽车发动机。把水管换成第二个电路,把阀门换成电开关,你就得到了一个继电器。

继电器至今仍在使用中(见图 1-17)。例如,在机器人安全系统中,我们常常需要物理地连接和断开主电池与机器人电机之间的电源。安全监视器检查一切是否正常,如果正常,则建立物理继电器连接;如果发现问题,则断开连接。当电流变化并且铁芯发生物理移动时,你可以听到继电器的点击声。

图片

图 1-17:显示线圈的继电器

电机机械化的机器比纯机械机器更高效,并在 20 世纪初及两次世界大战期间得到了广泛的商业和军事应用。在下一节中你将看到的一些机器,在 1980 年代仍在使用。而其他机器由于持续的政府保密,命运不明,因为这一时期还包括了第二次世界大战的密码学机器。

IBM 霍勒里斯制表机

美国宪法要求每十年进行一次人口普查并处理相关数据,到 1890 年时,人口已经增长到人力处理统计数据已不可能的程度。这导致政府出现了尴尬的工作积压,同时也对自动化解决方案产生了强烈需求。

赫尔曼·霍勒里斯设计了一台机器来自动化数据处理,并成功地在 1890 年的人口普查中使用它,对 6200 万公民的信息进行大数据分析。每个公民的数据由人工职员将书面人口普查表格转移到打孔卡片上。这似乎并非受到雅卡尔(Jacquard)和巴贝奇(Babbage)机器的启发,而是受到检票员在火车票上打孔的启发,用以表示不同的行程或时间。人口普查中的每个问题都是多项选择题,并通过打孔卡片上的一个选项来编码。 图 1-18 展示了这个例子。

图片

图 1-18:IBM 霍勒里斯机的复制品(左)和打孔卡片(右)

一叠叠卡片可以被读取到机器中,机器会检查是否存在某些特征或特征组合,然后使用帕斯卡计算器的电气模拟来累计具有这些特征的卡片总数。正如霍勒里斯(1894 年)所解释的:

仅仅知道男性和女性的数量是不够的,我们必须知道,例如,每个年龄段有多少男性,以及每个年龄段有多少女性;换句话说,我们必须将年龄和性别结合起来计数。通过简单使用著名的电气继电器,我们可以得到这个或任何其他可能的组合。必须理解的是,组合的不仅仅是两项内容;以这种方式,可以将任意数量的项进行组合。我们唯一的限制是计数器和继电器的数量。

这意味着该机器大致能够执行现代 SQL 查询,包括SELECT, WHERE, GROUP BYORDER BY

在机器在 1890 年人口普查中的广泛成功报道后,霍勒里斯于 1896 年成立了制表机公司。它于 1911 年改名为计算制表记录公司,1924 年更名为国际商业机器公司(IBM)。在 1931 年,《纽约世界报》称 IBM 进行“超级计算”,并在 1936 年前为许多政府和公司提供类似的商业大数据分析服务。它至今仍在继续这一工作。

电机机械差分分析仪

当可以用电力为模拟机械差分分析仪提供动力时,它们开始得到广泛的实际应用。电气电路也为差分分析仪提供了一个重要的新应用,因为它们通常使用与力学中使用的相同类型的差分方程来描述。哈泽恩和布什于 1928 年在麻省理工学院建造的系统,通常被认为是电机机械差分分析仪普及的推动者,其概念很快传播到英国曼彻斯特和剑桥大学的研究团队(见图 1-19)。一些英国的研究机器使用梅卡诺(类似于结构玩具)建造,预算比美国版本低。

Image

图 1-19:莫里斯·威尔克斯(右)与机械版剑桥差分分析仪,1937 年

类似的机器在第二次世界大战期间被大量使用,用于求解差分方程,例如计算炮弹轨迹。通过将钢笔连接到机器的运动部件,一些团队还为机器添加了模拟绘图仪,在纸上绘制图表。这些机器的版本在 1970 年代仍被用作导弹制导系统。

第二次世界大战中的电机机械机器

许多流行的历史书籍集中于第二次世界大战期间用于密码学(传输者和接收者的消息加密和解密)和密码分析(破解密码)的机器。合起来,这些领域被称为密码学。破解密码比加密和解密它们更为困难。因此,密码分析机器通常是更大、更有趣的那些。是否可以将这两类机器中的任何一类视为“计算机”?它们的历史一直被政府机密掩盖,我们仍在随着文件的公开而继续了解。这种不确定性对一些有偏见的历史学家和电影制片人有利,他们希望自己的国家或社区能被认为是计算机的发明者。

原始的 Enigma(图 1-20)是一款 1923 年推出的机电德国商业密码学产品,销售给多个国家的银行和政府,包括美国和英国。

图片

图 1-20:德国 Enigma 的电路图,显示了四个输入键(2),四个输出灯(9),三个转子(5,5,5),一个插头板(8)和一个反射器(6)

Enigma 由一个打字机键盘、输出字母灯、三个转子和电线组成。每个转子会将一个字母替换成另一个字母。输入字母a依次通过三个转子,然后被“反射”(替换为 26 - a),再通过三个转子反向传递。每次执行这一过程时,最后一个转子会向前推进 1 步,转子之间会有进位,类似于帕斯卡计算器的工作方式。每个转子的配置都会产生一组特定的替换。所有 Enigma 操作都是对称的:相同的机器状态会对其加密的文本进行解密。战争中使用了多个版本的 Enigma 机器。

德国军事用 M3 Enigma 在使用插头板时增加了一个阶段,交换字母对。在战争前七年,波兰人在 Marian Rejewski 的领导下,通过设计并使用一台单用途的机电设备——Bomba,破解了其加密。这台设备使用了物理的 Enigma 转子,通过强行破解所有已知消息头的可能编码。然后,使用反向索引文件卡数据库查找每日密钥。波兰人将这一系统交给了位于布莱切利公园的英国人(该地后来成为 GCHQ)。

1938 年,德国人改变了协议——而非硬件——移除了已知的消息头。波兰数学家和密码学家 Henryk Zygalski 随后再次破解了 Enigma,使用了光学计算。信息被转存到打孔卡片上,卡片被堆叠起来并对着光线快速找到光穿过整个堆叠的位置。

1939 年,德国将可插入三槽的转子数量从三个增加到五个。这使得破解的复杂性超出了齐加尔斯基方法的能力。为了破解这一版本,英国转而使用 IBM Hollerith 机器,以更高的速度执行类似的计算。

Dolphin 是一种更强的 M3 协议,德国 U 型潜艇使用了这一协议,包括更多可交换的转子和不同的头信息。英国 Bombe 是基于波兰的 Bomba 设计的,并进行了更新以适应新的任务。额外的密码学工作由艾伦·图灵(Alan Turing)、戈登·韦尔奇曼(Gordon Welchman)等人完成,随后该机器由 IBM 的哈罗德·基恩(Harold Keen)设计并制造。

Typex 是英国版的恩尼格玛。像德国人一样,他们对商业版恩尼格玛进行了修改,用于军事通信。Typex 经常被 B-Dienst(德国相当于布莱切利园的组织)破解,B-Dienst 使用了 IBM Hollerith 机器。

1937 年,IBM 总裁托马斯·沃森(Thomas Watson)会见了希特勒,并因 Hollerith 机器对“帝国的贡献”获得了一项奖项。德国集中营后来向 IBM 租借了 Hollerith 机器,用以实施大屠杀的精准化——“时机非常精确,受害者能够直接从货车走进等待的毒气室。”这些机器被用来合并人口普查和医疗记录等大数据源,以生成受害者的姓名和身份状态列表。IBM 提供了 IT 顾问,帮助软件设计,并定期访问现场为机器提供维修服务。

Zuse Z3

康拉德·祖泽(Konrad Zuse)是德国工程师,1941 年他与纳粹党合作,为其军方制造了 Z3 机器。Z3 是一台电子机械计算机,使用了 2000 个电子机械继电器开关和一个机械二进制内存,具有 64 个地址,每个地址 22 位。它每秒能执行最多 10 条指令。

1998 年,Z3 被证明从理论上讲是一个教会计算机,但这一结论仅仅基于一个非常晦涩且不切实际的技术细节。它也有可能非常缓慢地模拟冯·诺依曼机,但实际上并没有用于此目的。

电气时代

真空管(又称 电子管)由约翰·弗莱明(John Fleming)于 1904 年发明,作为继电器的高效替代品。与继电器不同,真空管没有任何活动部件;它们是纯电气的,因此比电子机械继电器切换速度更快。今天它们仍被用于模拟音频放大,例如在管式或电子管吉他放大器中(图 1-21)。

图像

图 1-21:使用真空管制作的吉他放大器

真空管外形和工作原理类似于爱迪生的灯泡。在密封的玻璃管内创建了真空。管内有三个组件:阳极、阴极和加热器。阳极和阴极是电路的端子,用于开关电流,因此分别具有正负电压。加热器是开关。当加热器开启时,热量使得电子从阴极逃逸,并通过真空移动到阳极,允许电流流动,从而打开电路。当加热器关闭时,电子没有足够的能量执行这一过程,电路因此关闭。

当我们将加热器限制为开启或关闭时,我们就得到一个数字开关,其功能类似于继电器,构成纯电子计算的基本单元。(另外,为了音频和其他信号的放大,我们可能允许加热器具有连续的热量水平,从而在主电路中产生连续的电流大小,产生模拟放大效应:小的加热器控制电流使主电路中的大电流上下波动。)

第二次世界大战的纯电子密码学

纯电子机器出现在二战中,比更著名的机电机器晚。它们也一直处于保密状态,但有时被认为是“第一台计算机”。

1942 年,德国海军恩尼格玛(Enigma)升级为使用四个转子槽而不是三个(德国人称之为“M4 型号”;盟军称其通信为“Shark”)。破解这一层次的密码复杂性需要美国的方法,即通过向计算能力投入资金,支付 IBM 生产数百台新的快速全电子真空管美国炸弹机

Fish是由另一种德国密码机——洛伦茨 SZ42 产生的密文;这不是一台恩尼格玛(Enigma),但它使用了类似的转子。由于其通信最初仅通过地面电报线路而非无线电发送,因此更难以拦截,盟军直到战争后期才发现它。它是由由马克斯·纽曼(Max Newman)领导的布莱切利团队破译的,使用的是 1944 年由汤米·弗劳尔斯(Tommy Flowers)及其团队设计和建造的科洛萨斯机器,见图 1-22。

Image

图 1-22:科洛萨斯,布莱切利公园,1943 年,操作员多萝西·杜·博伊森(Dorothy Du Boisson)和埃尔西·布克(Elsie Booker)

科洛萨斯是一台完全电子化、基于真空管的机器,类似于美国炸弹机,但如果重新接线,它也能执行不同的功能。英国人一直使用科洛萨斯解密俄国的密码,直到 1960 年代。像 Z3 一样,科洛萨斯最近才被证明理论上是教会计算机(Church computer),但这需要将 10 台机器连接在一起,并用一种新颖的虚拟机(VM)进行编程,这在当时并没有实现。

ENIAC

ENIAC(电子数值积分计算机) 是一台由 John Mauchly 和 J. Presper Eckert 在二战末期开发的美国真空管计算机。它于 1945 年完成,并被美国军方用于弹道计算。战后,它继续用于氢弹计算。

Mauchly 和 Eckert 明确表示,他们的设计是基于 Babbage 的分析机,将其每个机械组件转化为等效的真空管。像分析机一样,这使得 ENIAC 成为一台完全通用的计算机,可以编程执行任意的指令程序。

ENIAC 通过物理连接电缆到面板上的插槽来编程,这种方式今天有时仍用于“编程”电子合成器“补丁”。其程序员以这种方式编写程序的原始照片(见图 1-23)有时被误认为是技术员只是在维护机器或设置它以运行其他人编写的程序。我们现在知道,这就是实际编程的方式,这些照片展示的正是程序员们在工作时的情景。就像 Lovelace 和 Babbage 时代以及 Bletchley 时代一样,人们普遍认为编程是“女性的工作”,而硬件则是“男性的工作”。

Image

图 1-23:ENIAC 和程序员 Betty Jean Jennings 及 Frances Bilas 在 1940 年代的工作照

ENIAC 可以运行任何程序(只要有足够的内存),但像分析机一样,它采用的是哈佛架构;有人可能会争辩,程序需要物理接线这一点限制了它成为第一台计算机的资格。对于许多其他机器,我们可以回应,理论上来说,某人本可以编写一个虚拟机来解决这个问题。直到最近,计算机历史学家才重新发现,实际上有人为 ENIAC 做到了这一点!

虚拟机 ENIAC

ENIAC 的程序员 Betty Jean Jennings、Marlyn Wescoff、Ruth Lichterman、Betty Snyder、Frances Bilas 和 Kay McNulty 最终厌倦了每次编写新程序时都要物理重新接线。因此,作为一个快速的解决方案,她们设计了一个程序,利用这些电缆使客户端程序能够从开关面板中读取。这创造了一个虚拟机,其中单一的固定硬件程序模拟了一个能够从开关中读取更高级程序的计算机。

有些人认为“第一台计算机”是在这个时刻被创造出来的,作为一种软件而非硬件的创造。这个说法可能是个美丽的故事,但仍然存在一个问题:其架构仍然是哈佛架构,因为用户程序被存储在物理开关中,而不是计算机的主内存里。这意味着程序无法修改自身的代码,而有些人认为这正是“第一台计算机”的必要条件。

程序修改自身代码的能力是一个相当晦涩的需求,除了少数不太正当的安全应用和混淆编码比赛,通常不需要这种能力。从理论上讲,ENIAC 的程序员本可以继续创建第二层虚拟机,这层虚拟机可以将高级程序表示为数据而非程序内存。这将创建一种冯·诺依曼架构,程序可以使用程序员已经发明的虚拟机概念来修改自身代码。但他们从未觉得有这个需求。反对者认为,ENIAC 程序员有潜力做到这一点,并不比 Z3 程序员有潜力构建虚拟机更能证明 ENIAC 是“第一台计算机”,因此他们断言虚拟 ENIAC 距离成为第一台计算机仅差一只小飞虫的胡须。

注意

说到小飞虫,世界上第一个计算机“故障”——也是现代“bug”一词的起源——是在 1947 年由另一台机器哈佛 Mark II 的程序员捕捉并记录下来的。那是一只被卡在机器内部的飞蛾,导致了机器的故障。

曼彻斯特婴儿

1948 年,弗雷德里克·威廉姆斯、汤姆·基尔本和杰夫·图蒂尔在现今的曼彻斯特大学展示了第一台“电子存储程序计算机”。存储程序指的就是我们现在称之为冯·诺依曼架构的东西。这台机器被正式命名为“小规模实验机”,并被昵称为“婴儿机”(图 1-24)。

婴儿计算机的 CPU 使用了大约 500 个真空管,配合二极管和其他组件。它实现了七条指令集。用现代的术语来说,婴儿计算机是一个 32 位的机器,拥有 32 个地址,每个地址存储一个 32 位的字。

婴儿计算机由包括当时已拆解的 Bletchley Colossus 机器的部件构建而成;它很快被报废并自我拆解,为后来的曼彻斯特 Mark I 机器提供了部件。如今,婴儿计算机的复制品可以在曼彻斯特的科学与工业博物馆看到。这个博物馆特别有趣,因为它还包含了来自工业革命时期的纺织加工机器,而工业革命正是在曼彻斯特开始的。这些机器在雅卡尔织机与曼彻斯特计算机之间建立了文化联系。

图像

图 1-24:曼彻斯特婴儿重新建立在英国曼彻斯特的科学与工业博物馆中。请注意中央的 CRT 存储器,也用作显示器。

婴儿计算机或许可以被编程来在其绿色 CRT 屏幕上玩太空入侵者:自从现代重建以来,类似的游戏已经在仿真和真实机器上演示过,或许这就是复古游戏最极端的例子。

采用冯·诺依曼架构,Baby 也能够运行修改自身代码的程序。因此,当我们到达 Baby 时,我们似乎拥有了一台无可争议的 Church 计算机,只要我们接受它可以“要求多少内存就给多少内存”。不过,如何做到这一点并非 trivial,因为 Baby 的架构是如此特定于 32×32 位内存设计。你可以用更大的内存重新设计它,但那样的话,还是同一个 Baby 吗,还是另一台机器?

1950 年代与商业计算

UNIVAC(通用自动计算机;图 1-25)于 1951 年 3 月交付给第一位客户。它是 Mauchly 和 Eckert 之前 ENIAC 的商业化版本,使其成为第一台商业化的通用存储程序计算机。像 ENIAC 一样,UNIVAC 使用的是真空管。CBS 使用它成功地预测了 1952 年美国总统选举的统计结果,带来了名声和销售额。Mauchly 和 Eckert 的公司仍然存在,现为现代的 Unisys 公司。

图片

图 1-25:UNIVAC

IBM 对 UNIVAC 和其他电子计算机会摧毁他们仍然盈利的制表机业务的认识较慢,CEO Thomas Watson 在 1948 年做出了人类历史上最糟糕的未来预测:“我认为全球市场大约只有五台计算机。”在意识到新技术的重要性后,IBM 于 1952 年生产了自己的第一台商业电子计算机——IBM 701。

晶体管时代

晶体管执行与真空管相同的功能,但它更小、更快、更便宜,且消耗更少的电力,更加可靠。像真空管一样,晶体管既可以用于模拟任务,也可以用于数字任务(它们出现在诸如晶体管收音机和吉他放大器这样的模拟音频放大器中),但在计算中,它们仅用于其数字特性。

William Shockley、John Bardeen 和 Walter Brattain 于 1947 年发现了晶体管效应,并因此获得了 1956 年的诺贝尔物理学奖。1950 年代,硅谷现在所在的地方开始了晶体管的商业化工作,这项技术在 1960 年代成为主流。晶体管今天依然是计算机的基础技术。

图片

图 1-26:一个大型晶体管

1960 年代与大型晶体管

1960 年代的晶体管“迷你计算机”没有使用微芯片,而是由“大型”晶体管制成,约 1 厘米长,就像你今天会放在面包板电路上的晶体管一样(图 1-26)。仍然可以用这种晶体管制作 CPU,且一些爱好者为了好玩会这么做(例如,Eric Schlaepfer 和 Evil Mad Scientist Laboratories 的 MOnSter 6502 项目)。

这些计算机填满了机架,包括早期人工智能研究中广泛使用的经典 PDP 机器(见图 1-27)。这也是 Seymour Cray 开始建设 Cray 超级计算机的时期,旨在为高端用户制造最大、最快的机器。

Image

图 1-27:一台基于晶体管的 1960 年代 PDP-11 迷你计算机

1960 年代,晶体管计算机的应用包括为 ARPANET 供电,ARPANET 是今天基于 TCP/IP 协议的互联网的前身,还有 1969 年 Margaret Hamilton 编写的阿波罗登月程序,它使用了汇编语言(见图 1-28)。后者是真正的火箭科学,要求她在寻找方法使这一高度关键的代码更准确的同时,创建了现代软件工程领域。

Image

图 1-28:Hamilton 与她完整的阿波罗 11 号汇编程序打印件

1965 年,英特尔首席执行官戈登·摩尔提出了一个观察结果,后来被称为摩尔定律。正如你在介绍中看到的,根据不同的人和计算方法,这一定律表示计算机的速度或每单位面积的晶体管数量每 18 个月或每 2 年就会翻倍。

1970 年代与集成电路

1970 年代见证了集成电路(也叫ICs微芯片芯片)的广泛商业化。集成电路最早由 Geoffrey Dummer 在 1952 年于英国提出理论,尽管 2000 年诺贝尔物理学奖颁给了 Jack Kilby——他在 1952 年曾听 Dummer 讲解过集成电路——因其在 1958 年于德州仪器公司发明并申请了实用版本的专利。

集成电路技术使基于晶体管的电路得以微型化,从而使得原本需要填满 1960 年代机架柜的同样接线可以装入一个指甲大小的硅“芯片”中。从架构角度来看,芯片并不算特别吸引人——如果你拿一张 1940 年代真空管机器的接线图,然后把它缩小,你就得到了一个芯片。如果你用显微镜看芯片,你会看到类似于 1940 年代、1950 年代或 1960 年代机架背面接线的图案。硅芯片通常被“封装”在一个较大的,通常是黑色的塑料块内,较大的金属引脚将芯片的精细输入输出与外部世界(通常是印刷电路板)连接起来(见图 1-29)。

Image

图 1-29:一颗 Intel 4004 芯片及其封装

1970 年代诞生了许多今天仍在使用的最古老的软件。UNIX 操作系统由 Kenneth Thompson 和 Dennis Ritchie 在这段时间内开发(见图 1-30),并演变成了当前的 Linux、FreeBSD 和 macOS 系统。

Image

图 1-30:Thompson 和 Ritchie 在打字机终端上创建 UNIX

当时的 UNIX 终端使用类似打字机的打印头和纸卷——就像巴贝奇的差分机一样——程序员通过键盘输入命令与机器互动;他们输入的命令会被打印出来,并伴随其结果输出。这种电传打字系统是今天 UNIX 类系统中使用的 x-terminals 的起源。

与基于终端的交互方式不同,施乐公司(Xerox)在其帕洛阿尔托研究中心(Xerox PARC)研究了图形用户界面。这包括开发了第一款鼠标,以及“桌面”隐喻,包含基于物理文件柜的文件和文件夹。

这种将计算机界面建立在中层管理办公室、带有办公桌和文件柜的选择——而不是例如学校、艺术画廊或商店——自那时起便一直伴随着我们,并使计算变得比应有的更为乏味。随着 Android 等手持界面的兴起,以及基于电视的“10 英尺”界面(如 Kodi)的出现,这一现象可能正在开始发生变化,后者提供了基于“应用程序”的可行替代方案。

1980 年代的黄金时代

任何讲述计算机历史的作者,最终都会遇到故事与自己一生重叠的时刻,从那时起,他们可能会变得有些偏颇。对于本书作者来说,这个时刻就发生在这里,所以你可能希望找到其他人的替代性叙述,以平衡我的观点。

1980 年代是计算机架构的黄金时代:电子计算机首次变得足够便宜且小巧,能够大规模生产并被普通人购买,用于家庭中。如图 1-31 所示,这可能是人类历史上最适合对计算机感兴趣的孩子的时代,因为你能在圣诞节收到一台真正的计算机,直接接触到其架构,那时操作系统还没有将架构隐藏在用户面前。

Image

图 1-31:1980 年代的家庭计算机:一个开心的孩子与他们的第一台计算机

这些机器最初基于 8 位 CPU,例如在 Commodore 64 和 Apple II 中使用的 6502,然后基于 16 位 CPU,例如在 Amiga 和 Atari ST 中使用的摩托罗拉 68000。这一时期——尤其是在复古游戏中——被称为 8 位时代,后来是 16 位时代;许多曾经经历过的人以及许多没有经历过的人,都会怀念并带着怀旧之情回顾那个时期。

1981 年推出的 IBM 5150 PC,基于 Intel 8088 芯片。IBM 及其他公司在 1980 年代销售这款及其他 PC,用于商业办公。PC 概念与异构、架构驱动的家庭计算机市场正好相反,原因有两个。首先,它在计算机组件上强制执行标准化架构,以便多个制造商能够生产互相兼容的组件。其次,它将所有硬件封装在严格的操作系统下,操作系统通过标准化接口控制对硬件的所有访问。IBM 能够利用其市场影响力强制执行组件标准,从而可以从最便宜的供应商处购买并通过在组装的 PC 上加盖品牌赚钱。

为了应对在 PC 和大型计算机上安装专有操作系统的情况,GNU(递归代表“GNU 不是 Unix”)项目和自由软件运动在这一十年间由理查德·斯托曼创建——这后来促成了我们今天使用的基于 Linux 的系统和理念。

我们将在第十一章中更详细地研究这一时期。

1990 年代的单调

1990 年代是一个单调、无聊、米色的十年。这个十年的特点是商业化的行业焦点,从将用户视为程序员和社区成员转变为将用户视为客户和软件产品(如文字处理器和电子表格)的消费者。在此期间,学校停止教授计算机科学,受其创作者的公司游说的重压,转而教授商业办公软件的使用。

计算机架构变得由个人计算机(PC)标准架构主导,这种架构在 1980 年代用于办公计算,但现在被 PC 公司推向各个领域,包括家庭和学校。封闭源代码的操作系统作为 PC 套餐的一部分被推广,使得用户很难看到机器的“内部”。

从外观上看,这些机器几乎是完全相同的“米色盒子”,如图 1-32 所示,这种中层管理风格的计算文化的普遍单调,后来被苹果的“我是 PC”电视广告夸张地讽刺,广告中将 PC 描绘成一个穿着无聊米色套装的普通中层经理。

正如摩尔定律可靠预测的那样,处理器速度每 18 个月翻一番;这是衡量计算机性能的标准,许多人会每隔几年就组装一台新电脑,以利用新的速度提升。

图片

图 1-32:1990 年代的桌面

与操作系统的转变相关的是,从汇编语言和解释型语言(如 BASIC)编程到编译语言编程的转变。当语言被编译时,作者可以选择隐藏源代码,使用户无法再查看其工作原理或通过修改代码来学习它们。编译器自 20 世纪 50 年代 Grace Hopper 的工作以来便不断发展,最初应用于高端计算,但这是它们及其生成的代码首次进入普通家庭。

计算机游戏产业同样实现了职业化,将只能购买并玩专用游戏机和游戏的消费者与能够接触到专业编程工具的商业开发者区分开来。游戏有时很有趣,但不像以前那样写起来那么有趣。

万维网于 1990 年在 CERN 上线并逐渐普及,最终引发了 90 年代末的互联网泡沫投资狂潮。随着更多黑客和最终消费者加入互联网,专用的机架式服务器设计逐渐流行,始于 1993 年的 Compaq ProLiant。像曼彻斯特宝宝和 1960 年代的迷你计算机一样,这些设计被构建成可以堆叠在 19 英寸的机架单元中,且必须具备高度的可靠性,始终保持在线状态。

对于早期通过调制解调器连接的精英群体,1993 年也是 Linux 诞生的一年,GNU 启发的国际作者们开始探索如何在架构和系统编程的层面上相互沟通和编写代码。

2000 年代与重建社区

由通用组件构成的 PC 架构以及操作系统在 2000 年代持续发展。摩尔定律和每隔几年就要购买或组建一台速度翻倍的新计算机的趋势依旧持续。机器使用相同的基本 PC 计算机设计,不同的接口和组件逐步升级以提高速度。互联网速度也在不断提升,推动了视频流媒体以及文本和图像的传输。服务器的体积被缩小成刀片,其中许多可以被集中安装在一个机架单元中。

借助这些进展,Linux 逐渐发展成了一个现实的操作系统替代方案,成为了之前与 PC 捆绑的专有操作系统的替代品。许多参与过早期计算机社区的人回归并加入了 Linux 运动。我们意识到,未来的发展必须依赖于操作系统,而非原始架构;对自由软件的倡导者来说,这是一个好事:它消除了我们对任何特定硬件厂商的依赖。现在可以接受这一点,因为操作系统是自由软件,因此没有人会被迫购买任何特定厂商的产品。从这个角度回看,1980 年代或许并不那么美好,因为每个人都被迫在某种非自由的架构平台上开发,完全依赖于这些平台的公司所有者。1990 年代则看到自由度的降低,众多公司和平台被一个主导的 PC 操作系统公司和平台取代,但自那时以来,Linux 的生活比 1980 年代和 1990 年代更加美好,因为我们拥有一个开放的平台,并且许多硬件供应商都在实施它。

我们今天使用的许多其他开源软件也在 Linux 的推动下迅速发展,如 Firefox、Python、MySQL 和 Apache。虽然在许多情况下,这些工具有更早的起源,但它们直到 2000 年代才迎来了开发者和用户的关键群体。

为 Linux 操作系统本身工作的程序员得以看到并处理底层架构,但对其他人来说,架构一般仍然隐藏在幕后,就像在 1990 年代一样。

2010 年代与摩尔定律的终结

在 1990 年代和 2000 年代,我们愉快地假设处理器的时钟频率每隔几年就会翻倍——而它们确实翻倍了。摩尔定律成为了一个自我实现的预言,硅谷的芯片制造商将其作为目标来追求。

然而,所有这一切在 2010 年代崩溃了。晶体管制造技术虽然依然持续每单位面积翻倍,但时钟频率在 2010 年左右达到了极限,约为 3.5 GHz。突然间,处理器不再变得更快了。这是由于计算速度和热量的物理学基本定律。在摩尔定律时期,处理器的温度随着速度的提升而上升;因此需要更大、更强力的风扇和其他冷却系统,如水冷。晶体管变得更小,但风扇却变得更大。如果这种趋势在 2010 年代继续下去,我们现在的处理器温度可能已经比太阳表面还要高。

一个紧密相关的概念是功耗。随着芯片释放更多的热量,它们消耗的电力也更多,这一年代也见证了向低功耗、更便携的计算转型的开端,特别是在智能手机的形式上。这是我们从仰望天空转向低头看手中屏幕的十年。

如前言所述,摩尔定律的结束催生了图灵奖得主约翰·亨内西和大卫·帕特森所描述的“计算架构的新黄金时代”。在过去的二十年里,计算架构作为一个领域停滞不前,依赖于制造技术的进步来获得稳定的进展,而现在该领域再次对激进的新想法敞开大门。我们不能通过摩尔定律的速度形式使计算机变得更快,但我们仍然可以通过其密度形式将更多的晶体管集成到芯片上。现在我们可以考虑将所有操作都并行执行,一次进行多个操作,而不是一个一个地执行。

正如你可能预期的那样,2010 年代的特征是新想法、架构、硬件和软件的爆炸式增长,所有这些都旨在实现并行化。我们时代的一个关键计算机科学问题是,程序员需要在多大程度上关心这一点。在某种可能的未来,程序员将继续编写顺序程序,而新的并行编译器将能够将逐步指令转化为并行执行。另一个可能的未来是,我们可能发现这种方法不可行,程序员必须自己编写明确的并行程序。这将完全改变编程的性质以及程序员所需的技能和思维方式。

虽然仍有许多并行架构尚待探索——目前有成百上千的大学研究人员和创业公司在尝试探索并利用这些架构——但 2010 年代见证了三种主要的新型并行架构在现实世界中的成功应用。

首先,也是最基本的,多核处理器实际上是制造含有多个 CPU 设计副本的芯片。这一十年以双核系统开始,逐步发展到四核、八核,甚至更多的核心。如果你只在这些机器上运行一个程序,那么程序员就需要关心并行性。但大多数当前的计算机运行操作系统程序,这个程序使得许多程序能够并行运行,共享计算机的资源。典型的桌面机器在正常操作时可能同时运行 10 到 20 个进程,通过这种方式安排,因此增加N个多核处理器可以提供N倍的加速,但仅限于这些数量的进程。如果要求多核处理器运行普通程序,它们的扩展性就不会很好。

其次,集群计算,如图 1-33 所示,是另一种并行处理形式,其中许多传统的单核或多核机器被弱连接在一起。计算工作被分割成许多独立的块,每个块可以分配给一台机器。这要求程序以特定的方式编写,围绕着将任务分割成独立工作进行,并且只适用于某些类型的任务,在这些任务中,分割是可能的。

Image

图 1-33:2010 年代的并行超算集群

集群计算在“大数据”任务中特别有用,通常我们希望在许多数据项上独立重复相同的处理,然后合并结果(这被称为map-reduce)。外星智慧生命搜索项目(SETI@home)在 1990 年代率先采用了这种方法,利用成千上万台用户捐赠的家用计算机的计算时间在后台运行,分析来自射电望远镜的大数据,寻找外星信息。这种方法也被搜索引擎公司采用:例如,一家公司可能会从一个大型仓库中的许多普通戴尔 PC 中,指定一台专门存储包含某个特定词的所有网页位置并处理关于该词的查询。在 2010 年代,Hadoop 和 Spark 项目将基础的 map-reduce 过程进行了抽象并开源,使得每个人都能轻松地设置和使用类似的集群。

第三种方法,最具建筑意义的,是显卡(也称为图形处理单元,或 GPU)演变成通用并行计算设备。这提出了一种全新的硅级设计概念,同时也需要一种新的编程方式,类似于集群编程。由于其图形根基已被抛弃,这一概念不断演变为许多新型架构,例如最近在手机上找到的张量和神经处理单元。

如果这些新的并行架构中的一些变得主导,那么“程序员”这一概念是否还能存续尚不明确;例如,我们可能通过在硬件中创建特定的并行电路来“编程”,在这种情况下,一切同时发生,而不是把“程序”视为一组需要顺序执行的指令。

2020 年代、云计算和物联网

这是在写作时的当前十年,因此所识别的任何趋势都有些许推测性。话虽如此,我们可以在当前开发实验室中看到的系统表明,当前十年将会看到架构在两种主要类型之间的根本性分裂。

首先,越来越小且廉价的设备将被嵌入到现实世界中的越来越多物体中。这个概念被称为物联网(IoT),预计将在城市、工厂、农场、家庭以及几乎所有其他地方看到智能传感器和计算机。

“智慧城市”将被这些设备覆盖,用于监控每一辆车和行人的动态,使交通管理和城市设施的使用更加高效。“智慧工厂”将为每件库存物品配备微型设备,并在整个制造过程中追踪它们。智慧交通、零售和家庭将追踪同样的物品,贯穿整个供应链,对于食品而言,就是“从农场到餐桌”。例如,你的冰箱会感知到你快没有奶酪了,通过称量奶酪盒的重量或机器视觉识别奶酪,并自动向当地超市下单补充。超市会聚合这些订单,并将需求与来自配送中心的订单进行平衡。小型自动化机器人随后会把奶酪从超市送到你的家门口。

第二个趋势则是朝着相反的方向发展。低功耗物联网设备不会进行大量计算,而是主要用于收集和处理世界上的“大数据”。这些数据将会在专门的计算中心进行大规模处理:这些计算中心是类似仓库大小的建筑,内满了计算能力。

计算中心与数据中心相关,数据中心是一些外观相似的建筑,主要用于存储数据并通过网络提供数据,而不是进行重计算。这种计算方式最早出现在搜索引擎公司,它们使用许多便宜的商品 PC 联合运行,用来处理网页爬虫和搜索。搜索公司及其在线购物同行发现,他们可以通过租出闲置的机器来为客户提供一般计算服务,从中获得利润。这种计算方式与 1960 年代和 1970 年代的大型机器非常相似,那时的用户通过终端拨号共享计算时间。(也许托马斯·沃森曾猜测全球只需要五台计算机,如果我们把这些云计算中心看作是一台计算机,忽略物联网设备,这个猜测或许最终会成为现实。)

物联网设备引发了对低能耗设计的特别关注,但相关的能源问题同样出现在巨大的云计算中心。这些建筑物的用电量可以与工厂相当,产生大量热量,而且运行成本也相当高。计算中心在 COVID-19 大流行期间为全球的视频通话和协作工具提供了大部分计算支持,使许多工作第一次转向远程工作。一些计算中心由于极端热浪在 2022 年出现了停运的情况。最近,一些计算中心故意建在北极等地方,以利用自然降温。

所以,就像摩西一样,在这个十年里,我们将从云端下载到我们的平板电脑上。 物联网和云的两个趋势在 2020 年代可能会继续并变得更加极端,将架构拉向两个相反的方向。 中型台式计算机看起来可能会在重要性上下降。

我们已经开始习惯在诸如平板计算机和任天堂 Switch 等体积较小的设备上进行计算,这开始让较大的台式机看起来有些愚蠢。“每个桌子上都有一台计算机”是 1990 年代的目标,但现在这些设备正在消失,并被我们口袋里、街上和云中心的计算机所取代。 以前不时提到过类似的设置,包括 1950 年代的拨号主机和 1990 年代的“瘦客户端”,但在 2020 年代,它们似乎通过手机、亚马逊 Echo、Nest 家庭自动化和 Arduino,以及亚马逊网络服务、微软 Azure 和谷歌云平台开始起飞。

那么,谁发明了计算机?

现代计算概念由 Church 定义。 20 世纪 50 年代的商业电子设备,从 UNIVAC 开始,到 60 年代的小型计算机和 70 年代的微处理器直至今天,都清晰地可识别为计算机。 但是在它们之前,是否应该认为有什么东西是“第一台计算机”?

如果您愿意,曼彻斯特 Baby 就是 Church 计算机,因为它可以“提供所需的内存”,但如何做到这一点并不十分清楚。 查看后来的商业机器更容易让人感觉到它们可以轻松地通过插入额外的电路板或硬盘进行扩展,例如,通过插入额外的电路板或硬盘。 但原则上它们仍然与 Baby 有相同的问题。

如果以某种虚拟机方式对 ENIAC-初始编程,ENIAC-VM 实际上以这种方式编程的,但仍然是哈佛架构。 它需要另一层未实现的虚拟机才能达到 RAM 程序。 科洛索斯和祖斯 Z3 程序员理论上也可以完成所有这些工作,但他们没有。 分析引擎程序员也是如此。

IBM 自 1890 年代以来一直在描述为“超级计算机”的机器上进行大数据分析,但除非您找到一种方法使任何问题看起来像是 SQL 查询,否则数据分析并不是一般的 Church 计算。

人们可能自 40,000 年前就开始计算了,使用算盘、机械计算器、纸张、笔、粘土板、骨头、岩石、手指和头脑中的自然数。 所有这些理论上都是 Church 计算机,因为如果以某种方式编程,它们可以模拟任何机器。 所以也许我们一直都有计算机——只是 Church 是第一个注意到它们的人。

摘要

在本章中,我们快速浏览了计算机历史。从骨头到云计算,我们考虑了一些可能或者不可能被称为计算机的发明。我们还看到了几个关于什么构成计算机的假设。最初,我们提出计算机是任何可以编程来玩太空入侵者的设备。然后,我们通过查看丘奇的命题来正式化这一假设,丘奇认为计算机是任何能够模拟其他机器的设备,前提是它拥有足够的内存。

我们对计算机历史的调查简要介绍了架构的重大思想。在接下来的章节中,我们将深入探讨数据表示和 CPU 计算的细节,看看一些历史系统是如何更详细地工作的。这将为我们第二部分研究现代电子层级结构和第三部分中众多现代架构做好铺垫。

练习

使用算盘模拟器进行计算

  1. 使用算盘模拟器(如果你有实物算盘,也可以使用)和教程来理解算盘算术。这些操作仍然是一些现代 CPU 操作的基础,学习如何在算盘上进行这些操作将帮助你理解它们在 CPU 中的运作。你可以在这里找到一个模拟器:www.mathematik.uni-marburg.de/~thormae/lectures/ti1/code/abacus/soroban.html,并在www.wikihow.com/Use-an-Abacus上找到使用它的教程。

  2. 将你电话号码的最后三位数字作为一个数字,前面三位数字作为第二个数字,然后在算盘上加起来。

  3. 将相同的一对数字进行相减,从较大的数字中减去较小的数字。

  4. 将你电话号码的最后两位数字作为一个两位数,前面两位数字作为第二个两位数,然后用算盘进行乘法运算。

推测历史

  1. 你认为如果安提基特拉机制安全到达罗马,并激发罗马帝国使用类似机器,世界历史会有什么不同?

  2. 你认为如果分析引擎在大英帝国被完全建造并商业化,世界历史会有什么不同?

具有挑战性

在互联网上搜索使用算盘进行高级运算的例子,比如平方根或素因数分解,并尝试运行它们。你可能需要使用多个算盘,以提供足够的列来完成某些运算。

更具挑战性

  1. 写一篇基于“推测历史”练习中提出的一个前提的科幻短篇小说或小说。

  2. 如何使用算盘实现一台教会计算机?

  3. 研究 Hollerith 机器上可用的类似 SQL 的功能。能否用它们制造一台教会计算机?

进一步阅读

  • 要了解霍勒里斯机的详细信息,请参见 H. Hollerith 的《电动制表机》,《皇家统计学会杂志》 57 卷,第 4 期(1894 年):678–689,www.jstor.org/stable/2979610

  • 要了解霍勒里斯机在第二次世界大战中的作用,请参见 Edwin Black 的《IBM 与大屠杀:纳粹德国与美国最强大公司之间的战略联盟》(华盛顿特区:Dialog Press,2012 年)。

  • 要了解更多关于 2020 年代物联网计算的信息,请参见 S. Madakam, R. Ramaswamy 和 S. Tripathi 的《物联网(IoT):文献综述》,《计算机与通讯杂志》 3 卷,第 5 期(2015 年),dx.doi.org/10.4236/jcc.2015.35021

  • 要了解更多关于 2020 年代云计算的信息,请参见 I. Hashem, I. Yaqoob, N.B. Anuar 等人的《‘大数据’在云计算中的崛起:综述与开放的研究问题》,《信息系统》 47(2015 年):98–115。

  • 若要阅读一部涉及第二次世界大战密码学的柴油朋克小说,请参见 Neal Stephenson 的《密码帝国》(纽约:Avon,1999 年)。

第三章:## 数据表示

图片

计算机是一种表示现实世界中的事物并对这些表示进行操作的设备。我们可能希望表示并进行计算的实体包括物理对象、数字、词语、声音和图片。本章将研究每种实体的表示系统。

我们将从探索物体、数字和文本表示的发展历史开始。然后,我们将研究现代符号系统如何用于表示数字——包括十进制、二进制和十六进制——并利用数字表示法构建进一步的实体表示,例如文本、音频和视频。

本章中,现代表示法是由 0 和 1 构建的,这些 0 和 1 本身作为符号保留。在后续章节中,我们将讨论如何在数字电子学中实例化这些零和一的符号,并在计算中加以利用。

数据表示的简史

表示和计算的概念密切相关。人类常常需要表示世界某一部分的状态,作为自己记忆的辅助工具,或者作为向其他人证明某事已经发生或即将发生的证据。一旦有了表示,你也可以利用它进行计算,模拟如果执行某些操作会发生什么,或者从已知信息中推导出结论。

例如,我们常常需要追踪谁拥有什么以及谁欠债。静态表示对于这些目的非常有用,一旦有了这些表示,我们就可以利用它们进行计算,回答例如如果我们买了某样东西,或者还债需要多长时间等问题。因此,表示在计算之前,不论是在概念上还是在历史上,都是先行的。让我们追溯它的发展,从人类的第一次尝试到我们今天使用的符号系统。

刻痕棒和交易代币

最古老的已知数据表示法是使用刻痕棒,例如在第一章中展示的 Lebombo 骨。刻痕棒是简单的棍子,上面有若干标记,每个标记代表一个物体。例如,数字 13 用 13 个标记表示,通常是按顺序排列的,如图 2-1 所示。

图片

图 2-1:一个简单的计数

到了苏美尔时代(公元前 4000 年),人们开始使用物理代币来表示物体,如图 2-2 所示。例如,一只小型的泥土动物模型代表了实际的动物,并且可能可以用它交换。这样可以简化交易,你可以从城市乌尔(Ur)带着 10 个动物代币到城市乌鲁克(Uruk),然后用这些代币交换 20 个啤酒代币,实际物品的转移可能会在交易成功后再进行。这些代币也可以在不同的群体间分配,或者作为税收交给国王。

图片

图 2-2:苏美尔贸易代币

然而,使用计数棒和代币进行计算是非常缓慢的。要将m个计数或代币加到n上,你必须逐一地将每个m加入到n中。如果你研究过复杂性理论,这意味着加法的时间复杂度是 O(m),与被加数字的大小有关。

到公元前 3000 年——仍在算盘之前——苏美尔人通过将多个代币密封在一个称为bulla的泥土“信封”中,加速了他们的计算过程,如图 2-3 所示。bulla 既在物理上封闭,通过将泥土粘合在一起以封存内容,也在信息上通过在其上印上复杂且难以伪造的印记来封存。(这就是今天仍在王室和政府文件上使用的仪式印章的起源,比如美国的大印。这也是后来的数字签名的起源。)印章保证,可能是以国王或其他有权威且可信之人的名义,bulla 内部包含了特定数量的代币。这样,你不必再逐个计数 12 个动物代币,而是可以一次性交付包含 12 个动物代币的 bulla。bulla 就像一枚包含 12 个代币的硬币或纸币,但其内部实际包含着 12 个代币。

Image

图 2-3:一枚封印

与 bulla 的类似发展可以在这一时期的计数棒中找到,那里计数标记开始被分组在一起,如图 2-4 所示。逐个数出n个刻痕通常需要n次操作,但如果我们将每第五个竖直划线替换为一条穿过前四个的斜线,我们就能快速地数出有多少组五个。

Image

图 2-4:分组计数

罗马数字

在与分组计数密切相关的符号中,我们可以将第五条划线替换为两条较短的斜线,形成 V,第十条则替换为 X,如图 2-5 所示,这标志着罗马数字的开始。

Image

图 2-5:早期罗马数字

罗马数字进一步发展,以更准确地反映人类对数字的感知。人类似乎能够直接并立刻感知 1、2、3 和 4 个物体的大小。超过这个范围,我们的即时感知是数量感或近似大小,而非确切数字,这些数字大致围绕着 5、10、20、50、100 和 1,000。大多数数字符号字母表反映了这一点,埃及、中文和阿拉伯数字都有 1、2、3 和 4 的特殊符号,这些符号显示了相应的笔画数,而对于 5 及以上则使用更抽象的符号。罗马数字系统也使用符号来表示这些“标志性”数字,例如 V = 5,X = 10,L = 50,C = 100,以及 M = 1,000,较小的符号则位于标志性符号之前或之后,以表示对标志性数字的调整,例如 VI = 6 和 IX = 9。

罗马数字的优点在于,它们与人类实际思考数字的方式非常接近,但如果你尝试进行大规模的算术运算,如加法和乘法,你很快就会遇到困难。这是一个经典的例子,说明了表示方法的选择如何显著影响你进行某些类型计算的能力。

分裂记账符号

分裂记账符号是记账棍的一种变体,记账棍上做标记后沿其长度将其分裂成两部分,如图 2-6 所示。

Image

图 2-6:分裂记账符号

两个部分包含相同的刻痕,且可以重新结合,证明它们是真正匹配的。它们被用于记录贷款,长短两部分(原件副本)分别交给借款人和贷款人,这也成为我们现代金融中多头空头头寸的起源。英国政府直到 1836 年左右,现代化其信息技术系统时,才停止使用分裂记账棍,并烧毁了最后一批木制记账符号,那时正是巴贝奇的分析机时期。

阿拉伯数字及其他数字

其他文明发展了使用符号副本表示大数字的系统,如图 2-7 所示。

例如,古埃及人有 10、100、1,000 和 10,000 的符号。数字 23 会用两个 10 的符号(一个脚跟)和三个 1 的符号(一个记账符)表示。数字 354,000 会用三个 100,000 的符号(一个蝌蚪)、五个 10,000 的符号(一个手指)和四个 1,000 的符号(一个莲花)表示。

东方阿拉伯数字出现在伊斯兰黄金时代,基于公元 500 年左右的早期印度数字系统。该系统引入了我们今天使用的基数方法,并且有固定的列,用于表示 1、10、100、1,000 等数字。重要的是,这一系统引入了“零”的概念和符号,用来填补没有计数的列,这在古埃及和类似的系统中是没有的。这些符号演变成了今天西方使用的阿拉伯数字(1、2、3 等)。

Image

图 2-7:现代阿拉伯数字、古埃及数字、苏州汉字数字和东阿拉伯数字

苏州的汉字数字源自与前面在图 1-4 中看到的十进制算盘相关的古代汉字符号,并且至今偶尔仍在使用。你可以看到 1 到 4 的符号基于记数的划痕,而 5 到 9 的符号则是类似的符号放置在一个“珠子”下方,表示 5。对于一些重要的数字,苏州使用一种类似阿拉伯数字的列表示法。然而,对于较大的数字,它采用更先进的表示方式,显示前几个有效数字,然后是一个单独的符号,表示它们乘以的是 10 的多少次方。在英语中,我们有时会写成 354 thousand354k,而不是 354,000

数字的 表示 历史更适合归属于计算机科学,而非数学。我们可以看到,历史上,像“5 头牛加 3 头牛”这样的 有形 数量曾被表示和计算,而不是像“5 加 3”这样的抽象数学数字概念。数学通常将数字视为理所当然,并对它们的性质进行证明。相比之下,数字表示的事务,无论是实际物体还是从中推导出来的抽象数字概念,属于计算机科学范畴,如何基于这些表示构建算法和机器也是计算机科学的内容。

现代数字系统

我们已经看到,现代数字概念是如何从记号演变为今天日常生活中使用的符号性阿拉伯数字系统的。阿拉伯系统的关键创新在于使用列来表示基数中的数字。正如我们在开始计算时会看到的,这使得算法运算更加简便,也减少了表示的大小。例如,你只需要四个符号就可以表示数字 2,021,而不需要 2,021 个粘土代币。

我们日常使用的阿拉伯数字是十进制的,使用基数 10,但这并不一定是计算机使用的基数。本节将概括基数和指数的概念,并介绍一些在计算机中有用的相关系统。

基数和指数

在表示数字时,我们将大量使用指数运算。指数运算 是基数的重复乘法,例如:

2³ = 2 × 2 × 2

这里,2 是基数,3 是指数。它也可以写作 2³。在某些计算机语言中,它以 2**3 的形式出现,或者通过幂函数表示,如 pow(2,3)。指数运算有时被称为“提升到某次幂”,例如“2 的三次幂”。

更一般地,我们将基数 bn 次幂写作

b^n = b × b × b × . . . × b

这意味着有 nb。零和负指数定义如下:

Image

如果我们选择一个基数 b,那么可以定义一个 数字系统,它是从一组 数字符号 到一个 数字 的映射。符号是写在纸上的标记或存储系统中的条目;数字是实际的数学对象。

要表示b进制的数字,我们需要一个包含b个符号的字母表。由N个符号组成的字符串可以有b^N种不同的状态,用来表示从 0 到b^N – 1 的数字。

当我们处理不同进制的符号时,有时会使用下标来表示符号的进制。例如,123[10]表示十进制的 123,而 1001[2]表示在二进制中一个 8,一个 1,没有 4 和 2(即 9[10])。在其他情况下,如果上下文中已经清楚进制,可能会省略下标。

十进制:Decimal

日常算术使用的是十进制,其中例如符号串 7、4、3,写作 743,表示数字七百四十三。我们可以通过 10 的指数来数学地理解这一点:

743 = 7 × 10² + 4 × 10¹ + 3 × 10⁰

使用小数点表示法和负指数,我们可以表示分数。例如:

743.29 = 7 × 10² + 4 × 10¹ + 3 × 10⁰ + 2 × 10^(–1) + 9 × 10^(–2)

对于十进制,我们有一个包含 10 个符号的字母表:0、1、2、3、4、5、6、7、8 和 9。由n个符号组成的字符串可以指定 10^(n)个数字;例如,当n = 4 时,总共有 10,000 个数字,从 0 到 9,999(包含)。

二进制:Binary

二进制(Base 2)被称为二进制,几乎所有现代计算机都使用它。它的字母表由两个符号组成,通常写作 0 和 1,有时也用 T 和 F 分别表示。在电子计算机中,这两个符号通过高电压和低电压来表示。电压通常是系统的正电压,例如 5 V 或 3.3 V,而电压通常是地电压或 0 V。二进制对电气机器很有用,因为真实的电压信号有噪声,而尝试加入像电压这样的额外符号通常会失败。但可以更容易、更便宜地分成两个清晰的类别。

在二进制中,一个符号叫做比特,是进制数位(binary digit)的缩写。一串N个比特可以表示 2^(N)个数字,例如从 0 到 2^(N) – 1。该字符串的列代表 2 的幂。例如:

Image

在此计算中出现的 2 的幂(0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1,024, 2,048 等等)对于任何习惯于计算的人来说都是非常容易识别的。它们常常作为内存容量的大小出现,也常出现在硬件级别数据结构的比特或字节大小中。如果你计划从事或接触硬件工作,你需要记住这些 2 的幂以供日常使用。

要将二进制转换为十进制,计算每一列为 1 的二进制位所对应的 2 的幂之和。要将十进制转换为二进制,每一步尝试从十进制中减去最大的 2 的幂,并记录已减去的 2 的幂。把这些列标记为 1,其他列标记为 0。

在不同进制下,一些数学操作的速度可能会有所不同。在 10 进制中,你可以通过将小数点(基数)向左或向右移动一位来快速乘以或除以 10。在二进制中,你可以使用类似的技巧来快速乘以或除以 2。这被称为二进制移位,大多数 CPU 都通过硬件实现了它。例如,在 C 语言中,快速乘以 8(2³)可以通过写成y=x>>3;来完成。

在一些书籍和编程语言中,二进制的替代表示法包括 1110[2]、0b1110 和 1110b。

注意

有一个著名的计算机科学笑话说:“有 10 种计算机科学家:那些一无所知的和那些懂二进制的。”

1,000 进制

作为引入其他符号表示法(如十六进制和字节码)的方式,让我们从不同的角度来看十进制表示法,我们将其称为复合表示法。通常会通过将大数字分成每三位一组并用逗号分隔来使其更易于阅读。例如,数字 123,374,743,125 表示一百二十三十亿,三百七十四百万,七百四十三,一百二十五。(这里的“一百二十五”指的是个位的数目。)

想象一下,这些块是由包含从 0 到 999 共 1,000 个符号的字母表中的个别符号组成。不要把 999 看作三个 9,而是看作一个符号。在这种观点下,我们可以把用逗号分隔的字符串看作是一个由 4 个符号组成的 1,000 进制字符串,而不是 12 个 10 进制符号:

123,374,743,125 = 123 × 1,000³ + 374 × 1,000² + 743 × 1,000¹ + 125 × 1,000⁰

这比将数字想成 10 进制更能准确地反映我们的口语语言:我们有 1,000 的幂次的名称(千、百万、十亿、万亿),但我们没有 10,000、100,000 或 10,000,000 的名称。科学单位也遵循这一 1,000 进制的惯例:千、兆、吉等。

有趣的是,1,000 进制与 10 进制之间有一种特殊的关系。通常,当我们改变进制时,我们会预期在两个进制中符号的外观完全不同。但是,当我们在 10 进制和 1,000 进制之间切换时,书写符号是完全不变的。我们只是从把 123 看作是 10 进制中的三个符号,转变为把它看作是 1,000 进制中的一个符号。这使得在这两个进制之间转换非常简单方便,就像我们在日常生活中看到或听到大数字时会在脑中进行的转换一样。

60 进制:六十进制

让我们谈谈六十进制,也叫做60 进制。这个系统与现代计算相关的原因有两个:首先,像 1,000 进制一样,它是我们稍后将要探讨的一种复合符号表示的例子;其次,它今天仍在广泛使用于计算中。

我们相信一些史前人类群体使用的是 12 为基数的计数方法。当我们进入第一个城市的时代(公元前 4000 年)时,苏美尔人为了科学研究(包括天文学和毕达哥拉斯定理的算法版本的发明)改用了 60 为基数的系统。这可能是通过使用 10 和 12 为基数的不同人群之间的融合、碰撞或妥协而形成的,因为 60 可以被 10 和 12 都整除。

苏美尔人并没有发明 60 个不同符号的字母表,这样做将需要大量的学习努力,他们使用了一种混合符号表示法。他们在现有的十进制系统中写下 0 到 59(包括 59)的数字,但将这些复合符号视为 60 进制系统中的独立数字。例如,符号(使用现代阿拉伯数字,复合符号用冒号分隔)11:23:13 表示以下数字:

11 × 60² + 23 × 60¹ + 13 × 10⁰ = 39,889[10]

我们今天仍然使用性别进制系统来表示时间:上面的数字意味着 11 点 23 分 13 秒,即一天中 39,889 秒的时间。因此,现代数据库、数据科学系统和日期时间库需要仔细设计,以处理性别进制、二进制和十进制之间的转换。

基数 16:十六进制

让我们来谈谈十六进制吧!十六进制是十六进制十六进制代码的缩写,是一种以 16 为基数的系统。它的符号是 0 到 9 的数字和字母 a 到f(代表十进制的数字 10 到 15)的混合,通常以 0x 为前缀来表示它们是十六进制数。

你可能已经在任何允许直接访问和使用内存的计算机程序中见过十六进制数,包括 C 语言和汇编语言。它们也出现在高级语言中,作为区分具有相同属性的对象副本的一种方式。例如,如果你复制一个Cat对象(在面向对象的语言中),它的属性是numberOfLegs = 4age = 6,你将得到一个具有相同属性的第二个Cat对象,但这两个副本是不同的,因为它们有不同的名称,并且存储在内存中的位置不同。一些调试工具会显示这些内存位置,以便你看到哪个对象是哪个。例如,当你要求 Python 打印一个对象时,你会看到一个十六进制地址,像这样:

<< print(cat)
<__main__.Cat at 0x7f475bbf6860>

人类与低级计算机架构的接口,例如内存位置,通常使用十六进制作为一种替代、更易于人类阅读的方式来显示二进制信息。上面输出的地址实际上是一串长长的 0 和 1 的二进制数,但人类很难识别它们,例如,在比较两个地址时是否相同。比较十六进制数则要容易得多。

16 进制用于显示二进制,而不是其他系统,因为它与二进制的关系类似于 1,000 与 10 的关系。因为 16 是 2 的幂次,就像 1,000 是 10 的幂次一样,二进制中的列组和 16 进制中的列之间存在一一对应关系。这使得两者之间的转换非常快速和简便。考虑一个将二进制数字按四位一组排列的例子:0010,1111,0100,1101。我们可以将其视为

0010[2] × 2¹² + 1111[2] × 2⁸ + 0100[2] × 2⁴ + 1101[2] × 2⁰

这与以下表达式相同:

2[10] × 16³ + 15[10] × 16² + 4[10] × 16¹ + 13[10] × 16⁰

每个 16 的幂次都对应一个从 0 到 15 的数字,所以如果我们用字母 a[16]到 f[16]表示 10[10]到 15[10],那么我们可以将数字写成 16 进制的 2f4d[16]。二进制数中的每 4 个比特(有时称为nybble)对应一个 16 进制数字:16 进制中的 2 正好对应二进制中的前 4 个比特,即 0010;f 对应 1111;4 对应 0100;d 对应 1101。这种四比特对一位的对应关系使得 16 进制和二进制之间的转换变得容易——比起十进制和二进制之间的转换要简单得多。

16 进制编辑器

16 进制编辑器(例如,在 Vim 中使用%!xxd,如下图所示)以字节表示法显示文件或内存的内容,有时还会显示其他翻译内容,如 ASCII 字符。它们允许你直接编辑相应的二进制数据。这对于编辑磁盘上的二进制数据和可执行文件(已编译的程序文件),或在计算机内存中进行“修改”(覆盖程序和数据)非常有用,比如当前运行的程序。这些编辑器有许多与安全相关的有趣应用。例如,你可能会用它来尝试寻找并绕过某个专有程序中检查验证购买的部分,或者覆盖你在计算机游戏中的生命数,将其从 3 改为 255。

Image

16 进制是人类用来理解计算机中二进制数字的方便工具,但重要的是要认识到,计算机本身并不使用 16 进制作为工具。我们并不是用 16 进制作为基数来构建物理计算机;我们是使用二进制来构建它们的。然后我们将计算机的二进制数字分成四位一组,并将它们转化为 16 进制,这样更适合人类理解。毕竟,16 比 10 稍大,正是人类更容易习惯思考的数字,而不是二进制。

一些书籍和编程语言中用于表示 16 进制的替代符号包括 2F4D[16]、0x2f4d、2F4Dh、&2F4D 和$2F4D。

基数 256:字节

再次使用千进制技巧,常见的做法是将十六进制代码分为一对十六进制数字,例如 2D 4F 13 A7。在这里,每对十六进制数字可以视为 256 个符号字母表中的一个符号,每个符号表示 8 位,称为字节。字节是 8 位时代的主要计算单位。四位半字节(nybble)之所以得名,是因为它是一个字节的一半。记住,一个 nybble 是一个十六进制数字;一个字节是两个十六进制数字。

如何在进制之间转换

要将任何基数b表示转换为十进制,可以将每个基数b列的十进制值相加:

x[n]b^(n) + x[n – 1]b^(n – 1) + . . . + x[0]b

例如,下面是如何将一个数字从 19 进制转换为十进制:

Image

要将十进制转换为基数b,可以通过对b进行反复的整数除法,并记录余数。表 2-1 显示了将 186[10]转换为二进制的步骤。

表 2-1: 将 186 转换为二进制

步骤 结果 余数
186/2 93 0
93/2 46 1
46/2 23 0
23/2 11 1
11/2 5 1
5/2 2 1
2/2 1 0
1/2 0 1

这里,186[10]的二进制形式是通过读取余数列得到的:10111010[2]。

大多数编程语言提供了自动执行常见转换的函数,如bin2hexhex2dec

数据表示

一旦你为整数建立了基本表示方法,比如我们讨论的任何一种进制系统,你就可以用它作为构建其他事物表示的基础:更复杂的数字类型、文本、多媒体以及任何通用的层次数据结构。这里我们将看到这种表示方法,通常使用我们已定义的系统作为其他更高级系统的组成部分。这可以简单到用一对整数表示一个分数,或者复杂到使用数十亿个浮点数组成时空层次结构,来表示你的视频播放器中的多媒体流,包括视频、多语言音频和字幕。

自然数

自然数(传统上用符号 N 表示)是 0、1、2、3、4 等等的数字。它们通常用来表示世界上物理事物的数量,比如石头或牛。

自然数可以通过多种方式表示,包括计数法和罗马数字。在计算机架构中,最明显的方式是使用我们讨论过的某种基数指数系统。一些计算机使用十进制系统(见“十进制计算机”框),而大多数现代机器使用二进制。例如,通过使用可以开关的灯泡,我们可以表示数字 74 的二进制列(一组 64、8、2),如图 2-8 所示。

Image

图 2-8:数字 74 的二进制表示

这里有一些微妙之处,随着更复杂的表示方法的出现,这一点将变得很重要。首先,您需要选择一个读取灯泡的约定。在这种情况下,我们选择将最高位放在左侧,就像人类可读的十进制数字一样。我们本来也可以选择反过来,将最高位放在右侧。其次,我们在示例中假设有八个灯泡可用并正在使用。这意味着我们只能表示 0 到 255 之间的数字。如果我们想表示更大的数字,或者甚至传达我们已经没有足够的灯泡来表示更大的数字,我们就需要一个新方案。

十进制计算机

十进制计算机有着悠久的历史,跨越了机械时代和电子时代。以下是它们工作原理的一些细节。

巴贝奇的分析引擎

和巴贝奇的差分机一样,他的分析引擎也采用了十进制表示法,齿轮上包含数字 0 到 9。齿轮的朝向表示特定的十进制数字d,当该数字朝向固定标记时,如下图所示。与帕斯卡尔的计算器类似(不同于达芬奇的计算器),齿轮不会在数字之间的连续角度上停下;它只有 10 个离散的状态。

Image

齿轮是空心的,内部有一个轴。这齿轮和轴通过两个凸轮连接,分别附着在它们的圆周上。这些凸轮的排列方式是,如果轴旋转一整圈,凸轮将在圆的一部分区域接触,从而使齿轮旋转的角度等于它的数字值,而不是旋转完整的一圈。要读取表示的数字,您需要将轴旋转一整圈。在旋转的第一部分,凸轮不接触,齿轮不动。在旋转的第二部分,凸轮接触并使旋转的轴带动齿轮旋转,旋转的角度为n分之一圈,其中n是表示的数字。齿轮的旋转使您能够访问该数字。例如,如果您先将齿轮连接到第二个齿轮,它将使第二个齿轮的数字按n的值向前移动。

重要的是,数据在被读取时会从第一个齿轮中丢失,因为凸轮在旋转的第二部分总是将齿轮移动到零位置。因此,读取数据的过程是一个移动,而不是复制

许多齿轮可以垂直堆叠起来表示更大的十进制数字。同样,这些垂直堆叠的齿轮会在分析引擎中水平排列,以同时表示多个数字。

电子十进制机器

历史上较不为人知的是早期电子时代的十进制机器。上一章讨论的第一台商业计算机 UNIVAC(1951 年)就是其中之一。它的主控制台(见下图)以许多 10 个灯泡的组合为特点,用于显示各种十进制数。

Image

以下图所示的 IBM 650 计算机,诞生于 1953 年,以其使用“二五制”表示法而闻名。与算盘一样,这种表示法涉及将单位和五个组成十进制列。

Image

二进制并不是数字系统(如灯泡)表示自然数的唯一方式。有时,一对N的表示法更加实用,正如图 2-9 所示。

Image

图 2-9:在一个一对N系统中表示数字 5(最左边的灯泡表示 0)

在这里,我们假设有N个灯泡可用并正在使用,并且任何时候只有一个灯泡亮起。这种方法可能很浪费,因为我们并没有使用灯泡组的绝大多数可能状态。但它也可能很有用:例如,如果我们想在现实世界中为某个物体照明,比如排队中的第五辆车,我们现在有一个专门用于该目的的物理灯泡。这在计算机架构中会非常有用,因为我们经常希望以类似的方式开关N个物理电路。与二进制一样,我们需要约定一个从左到右或相反的约定,并且如果数字太大,无法指示灯泡用尽,也没有办法解决。

整数

整数(集合符号 Z)是这些数字:. . . , –3, 2, –1, 0, 1, 2, 3,依此类推。它们可以定义为将自然数与正负符号配对(除了零,+0 = –0)。表 2-2 展示了三种不同的二进制编码方式。

表 2-2: 三种可能的整数二进制编码方式

整数 带符号 一的补码 二的补码
3 011 011 011
2 010 010 010
1 001 001 001
0 000 和 100 000 和 111 000
–1 101 110 111
–2 110 101 110
–3 111 100 101
–4 不适用 不适用 100

表示整数的一种简单方法是使用与其绝对值对应的自然数的二进制编码,再加上一个额外的位来表示符号,就像表 2-2 中的带符号列(最左边的位表示符号)。然而,构建能够正确处理这些表示方式的机器是困难的,因为符号必须单独处理,并且用于选择如何处理剩余的数字。拥有两个不同的 0 表示方式也可能成为一个问题,需要额外的设备来解决。

想一想表中给出的同一整数的反码表示法(虽然很少有人使用,但它能帮助你理解下一个方法)。在这种表示法中,正整数的编码与自然数的编码相同,但负数的编码是通过反转其对应自然数的所有位得到的。例如,要得到 -2 的编码,我们从 +2 的编码 010 开始,将所有位反转得到 101。

现在考虑表格中的二补码表示法。这是通过对负数使用反码并加 1 得到的。例如,-2 变为 110,即 101 + 1。看起来这可能是随机的做法,但正如你稍后会看到的,二补码方法非常有用。它简化了所需的算术操作,这是今天的计算机普遍使用它的原因。

有理数

有理数(集合符号 ℚ)被定义为并可以由整数对 a/b 表示,其中 b ≠ 0。例子包括 1/2、-3/4、50/2、-150/2 和 0/2。许多有理数是等价的,例如 4/2 和 2/1。检测和简化等价关系需要专门的计算工作,如果没有这项工作,有理数往往会扩展到荒谬的规模,比如 1,000,000,000/2,000,000,000 表示的是数字 1/2。

表示有理数是我们首次结合多种现有表示法的例子:我们需要使用一对整数。例如,考虑 图 2-8,我们之前将其解释为一个单一的自然数;这个图像也可以视为表示有理数 4/10 = 2/5,假设前后两组四个灯泡分别表示 4 和 10。

这里有一些微妙之处:我们需要达成一致,前四个灯泡代表第一个整数,后四个灯泡代表第二个整数,并且我们还需要就整数本身的表示方式达成一致(如何表示正负值),正如之前讨论的那样。如果我们想要检查两个有理数是否相等,我们会遇到许多不同的表示方式,例如 4/10 和 2/5,这可能最初会让我们困惑。

定点

定点数,如 4.56、136.78 和 -14.23,是具有有限位数的小数点前后数字的数字。在这些例子中,点后始终有两个数字。严格来说,定点数是有理数的一个子集,因为它们总是可以表示为整数除以 10 的某个幂。只要我们就它们的顺序和大小达成一致,并且就整数本身的表示达成一致,它们就可以很容易地在计算机中表示为一对整数,分别对应小数点前后的两部分数字。

例如,图 2-8 中的灯泡现在可以代表定点二进制数Image,如果我们同意在第四个灯泡后固定小数点。注意,这些灯泡与我们之前用来表示有理数 4/10 和整数 74 的灯泡完全相同;要将数据解释为某种表示方式,我们需要约定使用的是哪种表示系统。

浮动点数

浮动点数,例如 4.56 × 10³⁴和–1.23 × 10^(–2),是之前在图 2-7 中看到的苏州位值计数法系统的计算版本,由定点尾数(这里是 4.56)和整数指数(这里是 34)组成。它们通过将整数表示和定点表示配对,能够轻松在计算机中表示。

在实践中,为了实现这一点,你需要选择特定的表示方式来表示定点和整数部分,设定具体的比特长度,并指定如何将它们按特定顺序打包成一个对。预留一些比特串用于特殊编码也是很有用的,比如正负无穷(可以用来编码 1/0 和-1/0 的结果)和“非数字”(NaN,用于编码诸如计算 0/0.0 时的异常)。IEEE 754是一个常用的标准,用于做出这些选择。它包括一组比特顺序,旨在最有效地利用 8、16、32、64、128 或 256 位作为浮动点表示。例如,IEEE 754 的 64 位标准规定,前 53 位应作为带符号编码的定点尾数,其中第一位表示符号;其余 11 位应作为二进制补码整数指数。一些比特模式被保留用于无穷大和 NaN。

可计算实数

除了浮动点数,计算机科学还拥有自己对可计算实数的定义,有时写作𝕋,它们不同于——并且优于——数学中使用的实数,后者用ℝ表示。可计算实数是所有可以通过程序定义的数字。与此相比,数学家们使用的更大的实数集合是无用的,因为它们不能单独定义或用于计算。

想象一个物理乌龟机器人,使用类似 Scratch 的语言控制,沿着数轴左右移动。可计算实数就是你可以为乌龟编写程序使其停在的所有数轴上的位置。具体来说,它们是所有能通过某些有限长度的计算机程序来指定其n位数字的数字。

例如,我们可以编写一个函数pi(n),它以整数 n 为输入并返回π的第n位数字。同样,我们可以通过从程序a(n)b(n)形成一个新程序来将两个可计算实数a(n) + b(n)相加。新程序将以n作为输入,并调用a()b()一次或多次来生成输出的n位数字。

可计算实数具有许多迷人且几乎是自相矛盾的特性,这对计算机和人类算术都有深远的影响。例如,通常无法(不可计算地)知道两个可计算实数是否相等或不同!通过对可计算实数进行一些基本的算术运算形成的程序,很快就会变得庞大且笨拙。如果我们能够通过用更短(或最短)的程序替代它们并得到相同的输出,那将是很好的,但这在理论上是不可能的。可计算实数的数量是“可数”的,这与整数的“大小”相同。这与数学家定义的实数不同,后者具有更大的“大小”,被称为“不可数”。

艾伦·图灵在他的伟大论文《可计算数的论述》中定义了可计算实数,因此使用了字母𝕋。可计算实数是他对计算机科学的真正天才贡献,而不是“发明计算机”(该论文的标题提示它是关于可计算数的,而非计算机)。图灵的理论仍然被低估。如果这一理论得到更广泛的发展和应用,我们可能有一天能够摆脱浮点近似带来的误差,进行完全准确的计算。

数组

一维数组R个值的序列:

{a[r]}[r=0:R–1]

二维数组是一个R × C个值的集合(表示行数和列数),其中:

{a[r,c]}[r=0:R–1,c=0:C–1]

D维数组是一个具有D个索引的值集合,例如具有以下元素的三维R × C × D数组:

{t[r,c,d]}[r=0:R–1,c=0:C–1,d=0:D–1]

数组中的值可以是数字(任何我们讨论过的数字类型)或其他类型的数据。

数值数组通常用于表示向量、矩阵和张量。这些是扩展数据结构的数学概念,带有特定的数学操作。例如,向量是一个一维数组,具有特定的加法规则、标量乘法规则以及计算点积和范数的规则。矩阵是一个二维数组,具有特定的规则,例如乘法和求逆。张量是一个N维数组,具有特定的协变和逆变坐标变换规则,除了乘法和求逆外。向量和矩阵是张量的特例。(许多计算机科学家错误地使用张量一词来仅指N维数据结构,而忽略了真正张量的其他数学要求。)

所有类型数组的基本数据表示方法是将它们“打包”成一系列在计算机内存中连续区域中的单个数字。例如,图 2-8 可能表示一个由整数[1,0,2,2]组成的一维数组,如果我们同意一个约定,即每个整数由两个灯泡表示。同样,它也可以表示这个二维整数数组:

图片

在这种情况下,我们将每个二维数组的行视为一维数组,[1,0] 和 [2,2]。我们使用每个整数两颗灯泡进行编码,并按顺序存储行的编码序列。通过扩展,对于一般的N维数组,我们可以做同样的事情:将其分割成一系列的(N – 1)维数组,对每个数组进行编码,并按顺序存储编码序列。

优化数据表示和计算架构以处理向量、矩阵和张量已经成为技术行业的主要驱动力。GPU 最初是为实时 3D 游戏中的快速 3D 向量矩阵运算而设计的,最近它们已经被广泛应用于快速张量计算,这在神经网络加速中发挥了重要作用。谷歌的张量处理单元(TPU)就是专为这个任务设计的。

文本

让我们来谈谈文本。一旦你有了有限的、离散的符号字母表,比如我们用来书写人类可读文本的字符,你可以为每个符号分配一个自然数来表示它。然后,你可以使用一组自然数按顺序表示文本串。这个思想已经从长期标准但现在过时的 ASCII 发展到了现代的 Unicode。

文本的历史

数字本身并不太有用:我们需要知道是什么在被计数。苏美尔的交易代币是“打字的”——三枚牛代币代表三头牛。但当我们从代币转向更抽象的数字时,我们失去了数字所代表的内容。数字需要附加额外的符号来描述类型,比如“3 头牛”。因此,书写是从与数字相同的交易代币中发展出来的,但它分化成了象形符号,然后成为了文本。

第一种书写出现在公元前 4000 年左右的苏美尔。它使用物体的图像(象形符号)来代表这些物体。象形符号在许多文化中出现过,后来逐渐转化为表音符号。表音和语义用途可能会共存一段时间——比如现代汉语——但表音用途通常会变得占主导地位。文本符号也随着时间的推移逐渐简化,变得更容易书写,失去了最初与其物体的图像相似性。在刻写石头上的文字时,符号逐渐演变为由直线构成,这样更容易刻画。最常见的符号最快地演变成了易于书写的形状。因此,它们成了音标转录中最方便使用的符号,经过从图像到声音的转变后,幸存下来的表音字母往往来自最常用的单词。

文本并不总是从左到右书写。阿拉伯语和希伯来语是从右到左书写的,许多东亚语言可以从上到下书写。

摩尔斯电码是在 1836 年左右开发的,旨在让维多利亚时代的互联网——电报的操作员能够快速通讯。塞缪尔·摩尔斯研究了英语中字母的使用频率,以便给常用字母分配最短的表示方式。摩尔斯几乎是二进制代码,因为它使用两个符号的序列来表示字母,但通常与第三个符号——空格一起使用,以表示单词之间的间隔。

布莱叶盲文也是在 1836 年左右由路易·布莱叶开发的。它是一个真正的二进制代码,每个字母由 2×3 网格的二进制状态表示。它最初是为士兵的秘密用途开发的,但后来因其在盲人读者中的广泛使用而流行开来。

ASCII

美国信息交换标准代码(ASCII),如图 2-10 所示,将每个字符表示为一个独特的 7 位代码,这意味着它可以表示总共 128 个字符。这可以表示大写和小写字母、数字、符号和标点符号,以及历史上的控制符,如删除、回车、换行符和响铃。

在旧版电子邮件系统中,ASCII 控制代码有时会作为电子邮件消息的一部分传输和显示,而不是实际执行。尤其是退格控制符容易出现这种效果,因此你会收到类似以下的电子邮件:

团队已识别出计划中几个错误HHHHHH^H 挑战。

今天,老一辈人有时故意打出类似的“退格失败”作为幽默。

一些操作系统使用不同的约定来表示行末,涉及文本文件中的换行符(代码 10)和回车符(代码 13),如果你在系统之间移动文本文件,可能需要修复这些问题。在打字机和电传打字机的时代,这两个符号是两种不同的物理控制,一个用于将纸张向机器中推进一行,另一个用于将打印头的打印架返回到纸张的左侧。

ASCII 代码 0 通常用于表示字符串的结束。如果字符串以某种方式在内存中布局,程序需要一种逐个字符处理字符串的方法。约定是,当程序遇到零时,它就知道该停止了。

作为 1960 年代的美国标准,ASCII 是全球化和互联网之前的产物,现在已经显示出它的年代感。它只能表示拉丁字母中的字符,因此无法直接表示非英语语言所需的字符。例如,许多欧洲语言需要带有重音符号的拉丁字符,而像中文和阿拉伯语这样的语言使用完全不同的字母表。

然而,在计算机史上最具前瞻性的设计决策之一,结合偶然性,ASCII 的设计者们意识到了这一潜在的未来问题,并为此做了规划。偶然性在于,当时的计算机使用的是 8 位组,而英语所需的字符集大小仅略低于 7 位。设计决策是使用 8 位表示 ASCII 字符,但始终将第一个位设为 0。将来,如果需要更多字符,可以将这个第一个位用于其他目的。现在,这一设计已实现,促成了现代 Unicode 标准的出现。

Image

图 2-10:ASCII 字符表示

ASCII 作为 256 进制

假设你用你最喜欢的语言写了一个程序,比如下面这个 BASIC 程序:

10 PRINT "HELLO"
20 GOTO 10

然后假设你将这个程序的字符编码为 ASCII 字符并保存在文本文件中。每一个字符都是一个字节。如果你在十六进制编辑器中打开你的程序,而不是普通的文本编辑器,你将看到程序被表示为一系列字节码,例如:

31 30 20 50 52 49 4E 54 ... 30

想一想我们之前在千进制、六十进制和字节码中使用的复合表示法概念,并将其应用于这整组字节码。将每个字节码视为一个 256 进制数字,将整个程序形成一个非常大的单一数字,例如:

31[256] × 256²⁷ + 30[256] × 256²⁶ + 20[256] × 256²⁵ + 50[256] × 256²⁴ . . . + 30[256] × 256⁰

这个计算将得到一个单一的天文数字大小的整数。这意味着我们在程序与整数之间建立了映射关系:我们可以用一个整数来表示任何程序。当你编写程序时,你只是选择要应用的整数。这个视角在计算理论中是有用的,因为它允许通过数字的数学运算来讨论程序。

Unicode

我们通常所说的Unicode实际上指的是 1991 年定义的三种不同但相关的标准:UTF-8、UTF-16 和 UTF-32。UTF-8通过利用之前未使用的第八位来扩展 ASCII。如果它是 1,则跟随一个第二个字节来扩大符号空间。如果第二个字节以 1 开头,那么接着还有一个第三个字节。如果第三个字节以 1 开头,则使用最后一个第四个字节。因此,UTF-8 支持超过一百万个不同的字符。其标准并未使用全部字符,但包含了所有主要语言所需的符号映射。现在有这么多字符编码可用,以至于国际社区能够将符号添加到标准中,包括一些使用较少的语言符号、古老语言(如楔形文字)、虚构语言(如克林贡语)、数学和音乐符号,以及大量的表情符号(见图 2-11)。

Image

图 2-11:Unicode 泰语、数学符号、表情符号和楔形文字领域

为了提高效率,最广泛使用的语言被分配到只需要 2 个字节的符号,而较少使用的符号需要 3 个字节,稀有的符号则需要 4 个字节。有时会进行激烈的辩论,讨论一个新提议的字符集应当分配到哪个类别。下次你发送一条恰到好处的表情符号来表达你的感情时,可以感谢 ASCII 设计者的远见。

UTF-32 是一种固定宽度编码,它在每个字符中使用所有四个可用字节。从存储的角度来看,这显然是低效的,但对于某些应用程序,它可能加速符号查找的过程。例如,如果你想读取第 123 个符号,你可以直接在字节 123×4 到 123×5 中找到它。

UTF-16 类似于 UTF-8,但即使是 ASCII 字符也始终使用至少 2 个字节。这覆盖了全球范围内常用的符号集,因此它通常可以像固定宽度编码一样工作,从而实现快速查找,类似于 UTF-32。它是一种折衷的编码方式。

在不同的 UTF 格式之间转换文件是我们曾经在 ASCII 中遇到的回车和换行问题的现代版。特别是在 CSV 表格文件中,使用错误的 UTF 导入可能会导致本应良好的文件看起来像垃圾。

多媒体数据表示

数据表示变得更加有趣,当我们处理图像、视频和音频时,可以让我们的计算机“栩栩如生”。这些表示都是建立在我们先前构建的数字数组之上的。

图像数据

灰度图像可以通过二维数字数组表示,每个元素代表一个像素,其值表示灰度。这个数组中使用的整数表示类型会影响图像的质量:如果使用 1 位整数,那么每个像素只能是黑色(0)或白色(1),而如果使用 8 位整数,则可以在黑色(0)和白色(255)之间获得 256 种灰度。

人眼对三种主要的光频率有反应:红色、绿色和蓝色。这意味着看到彩色图像的体验可以通过从每个像素处发出这些频率的光来再现。为了表示彩色图像,我们可以因此采用三种灰度图像表示;将它们分别表示图像的红色、绿色和蓝色通道;并以某种方式将它们存储在一起。不同的系统可能采用不同的方法来存储这些数据。例如,我们可能先存储完整的红色图像,然后存储绿色图像,再存储蓝色图像。但如果我们交错存储通道,某些计算可能会更快,即首先存储左上角像素的红色、绿色和蓝色值,依次存储接下来像素的红色、绿色和蓝色值,依此类推。

对于某些应用,添加一个名为alpha的第四通道来表示每个像素的透明度是很有用的。这种表示方式被称为 RGBA。例如,在基于精灵的游戏中,这告诉图形代码如何遮罩每个精灵,保持其形状后面的背景不变。非二进制的 alpha 值也可以用来将图像混合在一起,使它们在不同程度上部分透明。包括 alpha 通道尤其方便,因为拥有四个通道是 2 的幂次方,这与二进制架构兼容。例如,在 32 位机器上,通常使用具有四个 8 位通道的 32 位颜色,而不是使用三个 8 位通道的 24 位颜色。当然,这需要更多的存储,因此像素值可能以 24 位存储,并在加载到内存时转换为 32 位。(由于 24 位 RGB 通常被认为是人类可分辨的最大色深,即使在 64 位机器上,也没有太大意义使用 64 位颜色。)

视频可以最基本地表示为一系列按时间顺序排列的静态图像。

音频数据

连续的声波可以表示为一系列离散的样本。这些样本需要以信号中最高频率的两倍速率进行采样。人类的听力范围大约是 20 到 20,000 赫兹,因此常见的音频采样率约为 40,000 赫兹。每个样本是一个数字,与色深一样,分配给每个样本的位数选择会影响音质。消费者媒体如蓝光使用 24 位深度,这是人类可区分的最大值,而 32 位深度则可能在内部或音频制作人中使用,因为它是 2 的幂次方,且在编辑操作中提供了更强的鲁棒性。

立体声或多声道音频可以看作是一起播放的声波集合。这些可以在内存中作为一个完整的波形一次性存储,或者随着时间的推移交错存储,每个采样时间从每个通道存储一个样本。

几乎所有的声音表示方式都使用整数或定点表示法来处理每个样本。其结果是,样本有明确的最小值和最大值。如果信号超出了这个“头部空间”范围,它将被削波,丢失信息并产生失真。音乐家和配音演员常常抱怨这种数据表示方式,如果他们刚完成完美的录音,但却被削波了,只好重新录制。最近,专业音频系统的一个趋势是转向全浮动点表示,这虽然计算密集,但能够解放艺术家免于削波问题。

处理媒体时,例如包含视频和音频的电影,交错表示的概念通常会被扩展,使得每个时刻的各类媒体数据被一起编码到内存中的一个连续区域——例如,某一视频帧的所有数据,再加上该帧时长的音频段。这些交错方案被称为容器OggMP4是用于电影的两种知名容器数据表示格式。

压缩

我们刚才讨论的关于图像、视频和音频的简单媒体表示在计算过程中是有效的,但通常在存储方面并不理想。为了提高效率,我们通常会寻找方法来压缩数据,同时不改变人类对其的体验。

自然界通常包含大量冗余——即,某些空间和时间区域由相似的内容组成。例如,在一段投掷红色球的视频中,如果你看到一个属于球的红色像素,那么周围的像素也很可能是红色的,而且这个像素或附近的像素在下一帧中也会是红色的。此外,人类的感官有特定的关注点和盲区,例如对音频频率的振幅敏感,而对其相位不敏感,以及在某些频率存在时,无法听到背景中的其他频率。

信息理论解释了如何通过利用和去除冗余数据以及感知盲点来压缩媒体数据。这样,使用更少的位可以以更复杂的方式表示相同或感知上相似的媒体数据。这对于减少物理存储需求(如蓝光光盘的大小)以及减少流媒体传输时的网络使用非常有用。然而,这也带来了额外的计算成本:我们通常需要将压缩后的表示恢复为原始数据,这可能相当复杂,具体取决于使用的压缩方案。大多数方案依赖于像傅里叶变换这样的数学操作来寻找空间或时间频率。这些操作对传统的 CPU 来说计算成本较高,且已成为推动专用信号处理架构加速计算的主要因素。压缩算法的实现被称为编解码器

数据结构

任何数据结构,例如大多数编程语言中的结构体和对象,都可以通过序列化来表示,序列化是将数据转化为一系列的比特以存储在内存中。序列化可以分层进行:如果一个复杂的结构由多个较小的结构组成,我们首先序列化每个组件,然后将它们的表示按顺序连接起来,形成整体的表示。如果这些组件本身也是复杂的结构,过程会变成递归的,但最终我们总会到达一些简单的元素层级,比如数字或文本,而我们已经讨论过如何将这些表示为一系列比特(也就是对其进行序列化)。

举个例子,假设我们有以下数据结构:

class Cat:
  int age
  int legs
  string name

这将被序列化为一个比特序列,首先是整数age的编码,接着是整数legs的编码,然后可能是字符串name的 Unicode 序列。

假设一个Cat对象被包含在另一个结构中:

class Game:
  Cat scratch
  int lives
  int score

Game对象将被序列化,其第一个比特将是Cat对象的编码(它本身是多个组件的序列化),接着是livesscore整数的编码。我们可以继续以这种方式构建更高层次的结构,这也是现实世界大型程序的工作方式。

测量数据

数据的基本单位是比特(b),它可以有两种可能的状态,通常表示为 0 和 1。我们在研究数据时,通常会处理大量的比特,因此我们需要符号和可视化工具来处理这些。

SI (国际单位制) 是一个国际科学家和工程师组织,负责设定科学计量单位的通用标准。这包括为千的幂定义标准前缀,如表 2-3 所示。

表 2-3: 大型 SI 前缀

名称 符号 数值
kilo k 10³ = 1,000
mega M 10⁶ = 1,000,000
giga G 10⁹ = 1,000,000,000
tera T 10¹² = 1,000,000,000,000
peta P 10¹⁵ = 1,000,000,000,000,000
exa E 10¹⁸ = 1,000,000,000,000,000,000
zetta Z 10²¹ = 1,000,000,000,000,000,000,000

为了形象化 SI 前缀所表示的大规模数据,可以想象基于立方米的三维立方体。也许我们给千的幂特别命名并添加前缀的原因是,1,000 是 10 的三次幂,在三维空间中意味着将物体在三个维度上都按 10 的比例放大。

根据国际单位制(SI)的规定,使用位的 SI 前缀应该是描述数据量的首选标准——例如,5 兆比特表示 5000 位。实际上,网络速度通常以每秒 5 兆比特来衡量。然而,在体系结构层面上,我们更常需要处理的是精确的 2 的幂,而不是 10 的幂。例如,10 位地址空间提供 2¹⁰ = 1,024 个地址,而 16 位地址空间提供 2¹⁶ = 65,536 个地址。在架构师采用 SI 标准之前——例如,在 8 位时代——架构师通常滥用“千”这个前缀来表示 1,024 而不是 1,000。

这自然导致了许多混乱。数据大小变得更大,大多数计算机人员在更高的层次上工作,在那里使用正确的 SI 单位更有意义。作为折衷方案,国际电工委员会在 1998 年定义了一套替代前缀,用以区分二的幂和 SI 前缀。这些前缀包含了词根bi,源自binary一词。例如,2¹⁰已变为 kibi,2²⁰已变为 mebi,以此类推,如表 2-4 所示。

表 2-4: 大型二进制前缀

名称 符号
kibi k[2], ki 2¹⁰ = 1,024
mebi M[2], Mi 2²⁰ = 1,048,576
gibi G[2], Gi 2³⁰ = 1,073,741,824
tebi T[2], Ti 2⁴⁰ = 1,099,511,627,776
pebi P[2], Pi 2⁵⁰ = 1,125,899,906,842,624
exbi E[2], Ei 2⁶⁰ = 1,152,921,504,606,846,976
zebi Z[2], Zi 2⁷⁰ = 1,180,591,620,717,411,303,424

二进制前缀稍微大于它们的 SI 对应前缀。并不是所有人都在使用它们,许多老旧的设备和用户仍然使用 SI 单位来表示二进制单位。不道德的硬件制造商经常利用这种模糊性,选择那些能使他们产品上显示出最漂亮数字的 SI 名称解释。

总结

计算机通常需要表示各种类型的数字、文本和媒体数据。现代计算机使用二进制来进行这些表示非常方便。十六进制表示将二进制数据组织在一起,使其对人类更具可读性。不同的表示方式使得不同的计算变得更容易进行。

一旦我们有了表示数据的方法,就可以开始构建使用数据进行计算的方法。在下一章,我们将预览一个简单但完整的计算机,它能够完成这个过程。然后,我们将构建一个更详细的现代电子计算机来执行类似的操作。

练习

进制系统转换

  1. 将你的电话号码转换为二进制。

  2. 将你的电话号码转换为十六进制。使用之前得到的二进制可能会有所帮助。然后再将其转换为字节码,并将字节转换为 ASCII 字符。它们拼写出来的是什么?

  3. 取你的电话号码的负数,并将这个负数转换为其二补数。

  4. 在你的电话号码中间放置一个小数点,将其转化为浮动点数。按照 IEEE 754 标准以二进制表示。

文本和媒体

找出如何在您的计算机上输入 Unicode。例如,在许多 Linux 系统上,您可以按下并释放 SHIFT-CTRL-U,然后键入一系列十六进制数字,如 131bc,在命令行或编辑器中输入一个古埃及数字。

测量数据

获取你熟悉区域的街道、航空和卫星照片,并在上面绘制一个立方米、兆立方米、吉立方米、太立方米、拍立方米、艾克立方米和泽塔立方米,例如,一个立方米的每边长为 10 米。

更具挑战性

使用十六进制编辑器和互联网来逆向工程和修改一些你喜欢的媒体文件。

进一步阅读

  • 有关数字心理学模型和讨论,请参阅 Stanislas Dehaene 的数字感(牛津:牛津大学出版社,2011 年)以及 Douglas R. Hofstadter 的流体概念与创造性类比(纽约:基础书籍,1995 年)中的“Numbo”章节。

  • 对于一个充满浮点数细节的先进但经典的论文,请参阅 D. Goldberg 的“每位程序员应知的浮点数算术” (ACM 计算调查 23 卷 1 期(1991 年):5-48)。

  • 对于图灵实数的一个极其先进但令人眼花缭乱的美丽书籍,请参阅 Oliver Aberth 的可计算微积分(圣地亚哥:学术出版社,2001 年)。

第四章:## 基于 CPU 的基本架构

Image

现代 CPU 是人类已知的最复杂的结构之一,但它们背后的基本概念,如按顺序执行指令或跳转到不同的指令,其实非常简单,并且在过去 150 多年里一直没有变化。为了帮助我们更容易理解 CPU 架构,本章通过研究一个相关但更简单的系统——机械音乐播放器,来介绍这些基本概念。然后,你将看到这些概念与 RAM 一起,如何构成查尔斯·巴贝奇的分析机的基础。研究和编程这个机械系统将使我们在转向电子系统时(见第四章)更容易理解它们的运作。

音乐处理单元

为了使一台机器成为计算机,它需要是通用的,意味着它必须能够根据用户的要求执行不同的任务。实现这一点的一种方法是让用户编写一系列指令——程序——并让机器执行这些指令。乐谱可以视为一种程序,因此我们可以把一种读取并执行乐谱的机器看作是一种音乐计算机。我们将这种设备称为音乐处理单元

在第一章中,我们简要地看了像风琴和音乐盒这样的音乐处理单元。在巴贝奇之后,音乐自动机及其程序继续发展。大约在 1890 年,“书籍风琴”用连续的、连接的打孔卡片组(“书籍音乐”)代替了滚筒,这样可以容纳任意更长的乐曲,而不受滚筒大小限制。到了 1900 年,这些装置发展成了钢琴自鸣琴或自动钢琴(见图 3-1),它们用打孔的钢琴卷轴代替卡片,驱动家用钢琴,而非教堂风琴。自动钢琴至今仍然存在;你可能会在一些中等档次的酒店里听到它提供背景爵士乐,这些酒店能够负担钢琴,但负担不起钢琴家。

Image

图 3-1:自动钢琴(1900 年)

让我们考虑一下音乐乐谱中可能出现在这些机器上的指令类型。这些指令与我们稍后需要用来制造计算机的概念相似,但可能更加熟悉。我们这里只考虑单音乐器,这意味着它一次只能演奏一个音符。

我们可以给自动音乐仪器的一组可能指令通常包含每个可用音符一个指令。这可能是一个指令,比如“演奏中音 C”或“演奏中音 C 上方的 G”。自动钢琴的每一行纸卷代表一个时间点,并包含每个音高的一列,指示在该时间点是否打开(打孔)或关闭(未打孔)。现代计算机音乐软件,如 2018 年发布的 Ardour 5,继续使用这种类型的钢琴卷轴符号(旋转侧面供人类观看,使时间从左到右滚动更加直观)来生成电子音乐(图 3-2)。

图片

图 3-2:2018 年的 Ardour 5 钢琴卷轴界面

当自动钢琴读取钢琴卷轴时,一次会放入一行到读取设备中。我们称之为获取指令。然后,指令会被一些机械设备解码,这些设备查看打孔编码并将其转化为激活某些机械设备的物理过程,这些设备会演奏音符,比如通过打开管道让空气流入风管。然后这些机械设备实际上会执行音符的演奏。

通常,当人类或机械音乐播放器在演奏音乐程序(乐谱)时,它们会执行(演奏)每个指令(音符),然后继续到下一个指令,通过一个指令一个指令地推进程序的位置。但有时也会有一些特殊的附加指令,告诉它们跳跃到程序中的另一个位置,而不是继续到下一个指令。例如,重复dal segno (D.S.) 用来跳回早期的指令并从那里继续执行,而 尾声 则是跳到一个特殊结束部分的指令。图 3-3 显示了一个音乐程序。

图片

图 3-3:一个包含 G、A、B、高音 C 和低音 C 音符的音乐程序,并通过重复、dal segno 和 coda 显示跳跃

你可以通过在打孔卡片中使用额外的非音符列来构建一个管风琴或自动钢琴,编码这些跳跃指令。当其中一列被打孔时,它可能会被解释为一个指令,要求将鼓轮或打孔卡片快进或倒回到之前或之后的某一行。图 3-3 可以用打孔表示类似以下内容:

1\.  play note: G
2\.  play note: A
3\.  check if you have been here before
4\.  if so, jump to instruction 10
5\.  play note: B
6\.  check if you haven't been here before
7\.  if so, jump to instruction 5
8\.  play note: high C
9\.  jump to instruction: 2
10\. play note: low C
11\. halt

如果你不懂音乐,这个程序会准确地解释乐谱的作用!

从音乐到计算

从这个音乐处理单元到构建一个执行算术而非音乐操作的机器,只是一个小的概念步骤。

假设你已经制造了几个小型机械装置,每个装置执行某种算术操作。例如,帕斯卡的计算器是一台执行整数加法的机器。经过思考,我们也可以类似地构建像帕斯卡计算器那样的机器,用于执行整数乘法、减法、除法和列移位。然后,我们可以编写一个程序,像音乐乐谱一样,指定我们希望按顺序激活这些简单机器的顺序。

假设你的算术机器都共享一个累加器,用于存储每次操作的结果,你可以将计算描述得像在计算器上按键的指令序列一样,例如:

1\. enter 24 into the accumulator
2\. add 8
3\. multiply by 3
4\. subtract 2
5\. halt

这个程序将在累加器中停止,结果为 94。该程序可以由人类执行,通过顺序激活简单机器,或者我们可以使用类似于玩家钢琴的打孔卡卷来指定指令的顺序,并使用雅卡尔织机式的机械读取器来读取这些卡片并依次自动激活相应的简单机器。

从计算到计算机

要制造一台教堂计算机,仅仅运行固定顺序的算术指令程序是不够的。计算理论告诉我们,某些功能只能通过决策和跳转来计算,因此我们需要添加类似于我们音乐处理单元的指令,以便实现重复、尾奏等功能。这将使得以下程序成为可能:

1\. enter 24 into the accumulator
2\. add 8
3\. multiply by 3
4\. subtract 2
5\. check if the result is less than 100
6\. if so, jump to instruction 2
7\. halt

计算理论还告诉我们,某些计算需要内存来存储中间结果。为了区分这些结果,我们将给每个值一个地址,目前它只是一个整数标识符。以这种方式可寻址的内存通常称为随机访问内存 (RAM)。(这并不是 RAM 的完全正确定义,但你会在第十章中了解更多。)

拥有 RAM 意味着我们可以添加加载(读取)和存储(写入)到地址的指令,如下程序所示:

1\. store the number 24 into address 1
2\. store the number 3 into address 2
3\. load the number from address 1 into the accumulator
4\. add 8
3\. multiply by the number in address 2
4\. subtract 2
5\. check if the result is less than 100
6\. if so, jump to instruction 4
7\. halt

计算理论告诉我们,如果我们拥有我刚才展示的三种指令,我们可以模拟任何机器:那些执行实际算术运算的指令;那些做决策和跳转的指令;以及那些从 RAM 中存储和加载的指令。这正是巴贝奇的分析机的设计方式。

巴贝奇的中央处理单元

尽管已经有些年代,巴贝奇的分析机仍然是一个引人注目的现代设计:其基本架构至今仍在所有现代 CPU 中使用。同时,它仅具备最基本的 CPU 功能,因此研究它为我们提供了一个简化的介绍,帮助理解现代 CPU 的基本概念。与今天的电子计算机相比,分析机的机械部件的运动也使得它的工作原理更容易被形象化。

在这一节中,我使用现代术语来描述分析机的各个部分和功能。这些不是巴贝奇使用的术语,但它们在后续将帮助我将概念转移到现代计算机中。(一些巴贝奇的原始术语以括号的形式附在旁边,以防它们对你有兴趣。)巴贝奇和洛夫莱斯从未留下过关于他们指令集的文档,但它们大多是从其他文献中推测或幻想出来的。我假设 Fourmilab 模拟器使用的指令集和汇编语言符号,四米实验室是分析机的在线再现(* www.fourmilab.ch/babbage/*)。

我的演示和 Fourmilab 模拟器都对历史事实有所修改。这很容易做到,因为原始的文献资料杂乱且常常相互矛盾。并没有一个明确的设计,因此我们可以选择最适合我们叙事的版本。我们在这里的目的实际上是理解现代 CPU 概念,所以我有时会简化、现代化,或者直白地编造一些关于引擎的细节,以便让这项研究变得更容易。

高级架构

分析机由三部分组成:一个执行程序的 CPU;一个存储数据并允许 CPU 读写的 RAM;一个连接它们的总线。如果这听起来和现代单核计算机的总体架构类似,那是因为它确实类似!这并不是巧合:分析机的架构在将其机械结构转换为电子后,明确地应用于 ENIAC,ENIAC 随后成为我们现代电子计算机的模板。

从物理结构上看,分析机由 50 个“切片”(巴贝奇称之为“笼子”)组成,如图 3-4 所示,这些切片垂直堆叠,一个接一个,如图 1-14 所示。

图像

图 3-4:巴贝奇的分析机架构(1836 年)

圆圈是机械齿轮。CPU、RAM 和总线每个都贯穿所有的切片,我们可以在图 3-4 中看到它们的身影。对于机器中每个结构表示的每个数字,切片显示并处理它的一个数字。所有切片堆叠在一起,共同处理所有的数字。

RAM(“存储轴”)由 100 堆齿轮组成,每堆代表一个 50 位的十进制整数。这些齿轮堆在图 3-4 中的切片上显示为右侧的大型均匀区域。RAM 中的每个位置都有一个地址,编号从 0 到 99,这个地址使得每个位置与其他位置区分开来,并用于识别它。

RAM 的位置都物理上接近,但通常不会接触到机械总线(“机架”)。总线是一个机架齿轮——完全像现代汽车转向机架和乐高技术系列中的齿轮(图 3-5)。

图像

图 3-5:一组齿轮(线性齿轮)和小齿轮(旋转齿轮)

这个齿轮架可以物理地向左或向右移动。每个 RAM 地址都可以通过杠杆与齿轮架接触。该 RAM 地址中的齿轮就像小齿轮一样运作,从而使得从该位置输出数字时,数据总线会向左移动相应的距离。反之,若从其他地方将数据总线向右移动,则会将数字写入内存位置。

CPU(“磨坊”)是机器的活跃部分。它请求数据并通过总线将数据发送到 RAM,然后以各种方式处理数据。

程序员接口

与差分引擎不同,分析引擎被设计为通用计算机。这意味着我们可以要求它按不同的顺序执行不同的操作。为了做到这一点,我们需要一种方法来指定这些操作和顺序。

让我澄清一下我之前随意使用的一些术语。一系列按顺序执行的指令被称为程序。执行程序的行为称为执行运行。所有可用指令的集合被称为指令集

程序以代码的形式存储在穿孔卡片上,就像我们之前在图 1-11 中看到的织布机的 Jacquard 卡片那样。每张卡片包含一排孔和非孔,它们共同编码一条指令。通常,指令按顺序执行,卡片按顺序推进,但有些指令会让卡片倒退或快进,以便在程序中跳跃。我们来看看有哪些特定的指令可用。

常量

一个基本的指令是将某个 RAM 地址设置为给定的整数。例如,“将整数 534 放入 RAM 地址 27。”这将移动第 27 个 RAM 地址所在列的齿轮到(十进制)数字 534,高位的齿轮上将显示零。我们首先用易于理解的符号表示这一点:

N27 534

这里,N(表示数字)告诉我们这是一个设置 RAM 整数的指令。接下来的数字(27)告诉我们要设置哪个 RAM 地址,最后一个数字(534)是我们要设置的值。一个典型的程序开始时,通常会以这种方式将许多 RAM 地址设置为特定的值。例如:

N27 534
N15 123
N99 58993254235
N0  10
N2  5387

一旦我们有了一些初始值,我们就可以使用进一步的指令进行计算,如下节所示。

加载与存储

为了处理来自 RAM 的值,它们必须被移入 CPU。要将值从 RAM 加载到 CPU 中,我们写 L 表示加载,后跟值存储的 RAM 地址。例如,这个程序将第 27 个 RAM 地址设置为 534 的值,然后将该值从这个位置加载到 CPU 中:

N27 534
L27

为了将 CPU 的最新结果存储到 RAM 地址 35,我们写S表示存储,后跟所需的地址:

S35

存储(S)与将 RAM 设置为常量(N)不同,因为它涉及到 CPU 的累加器。它将累加器中的值转移到 RAM 中,而不是将固定常量放入 RAM。

现在我们可以移动数据了,我们希望对其进行算术运算形式的计算。

算术

分析引擎能够执行基本的算术运算:加法、减法、乘法和除法,所有运算都基于整数。这些运算分别由指令+-*/表示。

要进行算术运算,首先必须设置模式,以告诉引擎你想进行哪种运算。例如,要加两个数字,你需要将模式设置为加法模式,然后依次将这两个参数加载到 CPU 中。考虑以下程序:

N0 7
N1 3
+
L0
L1
S2

该程序首先将整数73分别放入地址 0 和地址 1。然后,它将 CPU 置于加法模式,使用+指令并从这些地址加载数字。最后,它将加法的结果存储到地址 2。

现在我们有了算术运算,接下来我们需要通过添加跳转和分支来从计算转向计算机运算。

跳转

如果你希望程序的一部分永远重复,一种简单的方法是将最后一张打孔卡片的末端粘到第一张卡片的顶部,形成一个物理循环,如图 1-15 所示。然而,这种方法不易推广,因此最好是使用一种指令,可以在需要时将卡片回卷或快进到程序的其他行。我们称这种指令为C,表示控制。接下来,我们会说明是想要向后(B)还是向前(F)跳转,跳多少步。我们还会在数字前加上符号+(具体原因将在下一节中说明)。将这些组合起来,CB+4例如,是一种控制指令,表示向后跳转四张卡片。

以下程序使用CB+4实现无限循环:

N46 0
N37 1
+
L46
L37
S46
CB+4

在这里,我们使用地址 46 作为计数器,每次循环时将其值加 1。

分支

永远循环通常不是很有用;我们通常希望循环直到某些事情发生,然后停止循环并进入程序的下一部分。这是通过条件分支实现的,它会检查条件是否成立,只有在条件为真时才跳转。

我们将使用与跳转相同的CFCB符号表示法,不过用符号?替换+,表示跳转是有条件的。例如,CB?4是控制指令,表示仅当某个条件为真时,才向后跳转四张卡片。

以下程序结合了条件分支和无条件跳转,用于计算两个数字和的绝对值(始终为正数)。

N1 -2
N2 -3
N99 0
+
L1
L2
S3
+
L99
L3
CF?1
CF+4
-
L99
L3
S3

这个程序使用+指令将 RAM 位置 1 和 2 中的两个数字相加,将结果存储在位置 3 中。然后它将零(从地址 99 加载)加到该结果中,再从位置 3 加载回来。在幕后,这个加法操作还会将一个特殊的状态标志设置为 1,如果结果的符号与第一个输入的符号不同(零被认为是正数)。条件指令(CF?1)然后使用这个状态标志来决定该做什么。如果标志是 1,我们跳过下一条指令,这样就到了-指令,并执行从 0 开始的减法操作以交换符号。如果状态标志是 0,条件跳转不会发生,我们就继续执行下一条指令(CF+4)。这是一个无条件跳转,跳过四行减法代码,以避免交换符号。最终结果存储在地址 3 中。

分支完成了分析引擎的指令集,并且(假设始终有足够的内存可用)使其成为一台教堂计算机。你现在可以尝试解决章节末尾的练习并编程分析引擎,或者,如果你有兴趣了解机器内部是如何工作的,可以继续阅读。

内部子组件

让我们看看 CPU 内部需要执行这些程序的子组件。本节描述了它们的静态结构;我们将在下一节中介绍这些子组件是如何移动并相互作用的。

一个 CPU 由许多独立的简单机器组成,每个简单机器由几个数字表示和操作它们的机械装置组成。简单机器被分为三种类型:寄存器、算术逻辑单元和控制单元。

如图 3-4 所示,这些简单机器被安排成一个圆圈,围绕着一个叫做中央齿轮的大齿轮。就像公交车一样,中央齿轮在组件之间建立和断开任意数据连接,在这里是指 CPU 内部的简单机器之间的连接。这些连接是通过杠杆来完成的,杠杆将小的附加齿轮与中央齿轮和各个机器之间接触。

寄存器

寄存器(巴贝奇称之为“轴”)是位于 CPU 内部的小型存储单元,而不是位于主 RAM 中。CPU 中只有少量寄存器,而 RAM 地址则有很多。

回想一下第二章中提到的,整数在分析引擎中是通过数字十进制齿轮表示的。一个数字d是通过旋转轴一整圈来读取的,这样齿轮会旋转d十分之一圈。为了表示一个N位整数,我们只需将N个齿轮垂直堆叠,跨越机器的N个舱位。寄存器就是这些齿轮堆叠之一。

输入寄存器(“输入轴”)接收来自 RAM 的输入数据。输出寄存器(“输出轴”)暂时存储(或缓冲)来自 CPU 工作的结果,然后将其传输到 RAM。其他寄存器则在计算过程中用于其他目的。

算术逻辑单元

算术逻辑单元(ALU)是由一组独立的简单机器组成,每台机器执行一个单一的算术运算。例如,一台与帕斯卡尔计算器类似的简单机器用于执行加法。通过m的乘法可以通过一台机器触发m次加法运算。乘法或除法以 10 的n次幂为基数,可以通过一台特别简单的机器实现,该机器将所有数字移动n列,相当于机械上的“在末尾加个零”。

除了将结果发送到输出寄存器外,一些 ALU 操作还可以设置一个单独的状态标志,作为额外的副作用输出。在分析引擎中,状态标志是一个单独的机械杠杆,可以处于上(1)或下(0)位置。它可能上面有一个红色布制旗帜,用来直观地提醒人类和机械观察者,“ALU 中刚刚发生了某个有趣的事情”。

ALU 机制

当齿轮D通过旋转D齿轮的d十分之一圈来读取时,一个数字d被传递出来。这个数字可以通过将齿轮A和齿轮D放在一起,使它们的齿相互啮合,从而将数字添加到齿轮A上,然后从D传递出来。随着齿轮D旋转d十分之一圈,齿轮A也会被带动旋转相同的角度,因此齿轮A最终将储存数字a + d。我们称A累加器,因为我们可以继续向其中添加许多数字,并且它会累积这些数字的总和——直到总和超过 9。

大于 9 的整数通过齿轮堆叠表示,就像在寄存器中一样。将它们相加的方法类似于用笔和纸逐列加法:每列中的两个数字需要相加,但我们还需要在数字超过 9 时进行进位,将 1 传递到下一列。帕斯卡尔在他的计算器中已经开发出一种基本的机械进位系统,这使得数字可以加到累加器中,巴贝奇的进位机制就是基于这个系统。下图展示了巴贝奇设计的一部分。

Image

当齿轮达到数字 9 并在加法运算中再旋转一个位置时,例如由传入的进位(c)触发时,凸轮(f)会与另一个凸轮(e)连接。后者连接到一根杆(m),该杆将进位“传送到楼上”的下一个笼子,在那里它作为(c)出现在下一列。准确把握进位长时间波动的时机是非常困难的,这也是巴贝奇在设计中花费最多时间的部分。

控制单元

控制单元(CU)从内存中的程序中读取指令,解码后将控制传递给 ALU 或其他部件以执行指令。然后,它根据正常的顺序执行或跳转来更新程序中的位置。控制单元就像一个指挥家,在正确的时机协调所有其他组件的动作。巴贝奇的控制单元如图 3-6 所示。

Image

图 3-6:分析引擎控制单元

一个机械的圆桶,类似于风琴的圆桶,随着时间的推移旋转,圆桶的每一列都有几个插槽,用于放置可能存在或不存在的钉子。这些钉子触发拨片,通过复杂的机械杠杆系统激活 CPU 中的其他简单机械。这使得控制单元的每个工作阶段都能按顺序触发,类似于风琴演奏一系列音符。圆桶的旋转速度可以通过反馈机制控制,直到当前步骤完成,下一步才会开始。

圆桶钉子的配置不是用户的程序,而是定义 CPU 本身顺序的较低级别微程序:我们接下来将讨论的获取-解码-执行周期。随着微程序的执行,它将用户更高级程序中的单个命令从打孔卡片读取到寄存器中,然后通过 CPU 中的简单机械执行这些命令。

内部操作

控制单元——在巴贝奇的情况下是旋转的圆桶——触发一个规律的活动周期。这些活动通常分为三个主要阶段:获取、解码和执行。所有控制单元的操作必须精确地按照正确的顺序进行。让我们逐个来看这三个阶段。

获取

获取指的是将下一条指令的机器码读取到 CPU 中。回想一下,像N37 1CB+4这样的可读汇编语言指令实际上在打孔卡片上是以二进制机器码表示的。对于分析引擎,获取过程可以像雅卡尔织布机一样进行,通过尝试将一组物理钉子插入卡片上的位置。如果有打孔,钉子可以穿过,但没有孔的位置,钉子会卡在卡片上,无法再移动。然后,这些钉子的物理位置可以通过金属杠杆放大并传送到 CPU 中。

卡片读取器是一个物理设备,类似于打字机,其中有一行电流可供钉子使用。要读取其他行,需要将打孔卡片串通过这个读取器,直到所需的行排到合适位置。打孔卡片的当前物理状态——即当前在读取器中的卡片——因此充当了一种内存形式。我们将这种物理状态称为程序计数器

金属杠杆的物理位置也可以视为一种内存形式,包含 CPU 内当前指令的副本。我们将其称为指令内存

解码

二进制编码在打孔卡上对人类或机器来说并不立即显现其含义:在此阶段,它们只是 0 和 1 的模式。解码意味着弄清楚这些代码的含义。进入 CPU 的卡片读取杠杆可以激活那里的不同机械装置,具体取决于杠杆的组合是上还是下。例如,如果加载指令(L)表示为二进制 010,则机器可能被设置为仅在三个取回杠杆分别处于下、上、下的位置时响应。同样,指令中包含的数字地址需要解码,从十进制代码转换为它们所表示地址的机械激活。解码器是一组机器,每台机器都会在获取到的信号中寻找特定的模式,并在看到时激活某些功能。

执行:加载和存储

执行是指执行解码后的指令。如何执行取决于指令的类型。每种执行形式由不同的简单机器实现,解码器将选择并激活适当的机器。

当 CPU 需要使用某个值时,可以将其从 RAM 加载到 CPU 寄存器中,例如作为计算的一部分。CPU 的工作结果也会放置在寄存器中,然后通过将其复制到 RAM 地址来进行存储

要加载一个值,控制单元(CU)在 RAM 地址的齿轮和总线之间建立机械连接,并在 CPU 端的总线与输入寄存器之间建立连接。然后,它会触发 RAM 地址上的放电,旋转齿轮一整圈,使得它们使总线物理上向 CPU 移动n步,其中n是所表示的数字。这是并行发生的,每一列数字都有自己独立的 RAM 齿轮、总线和输入寄存器齿轮。

当需要存储一个值时,控制单元触发一组相反的步骤。存储假设要存储的值已经在输出寄存器中。首先,它通过将所有数字旋转为零来清除目标地址处的 RAM。然后,它将输出寄存器与总线连接起来,并将总线连接到 RAM 中所需的地址。接着,它将输出寄存器旋转一整圈,物理上将总线向 RAM 移动n步,进而旋转 RAM 齿轮n步,将数字存储在那里。

执行:算术指令

当需要执行算术指令(例如加法)时,合适的简单机器(例如加法器)会与输入和输出寄存器机械连接并启动。在分析机中,这是通过插入齿轮(齿轮)实现的,齿轮将寄存器与简单机器物理连接,然后传递动力给简单机器使其运转。巴贝奇的加法器类似于帕斯卡计算器,将第一个参数加载进去,再加上第二个参数,然后将结果传输到输出寄存器。当计算完成时,这些齿轮会被拉开,从而禁用简单机器。

除了影响输出寄存器外,ALU 的简单机器还可以在算术过程中如果发生某些有趣的情况时抬高或降低状态标志。ALU 中的不同简单机器有各自对“有趣”事件的定义,并可以根据这些兴趣设置标志:+- 只有当它们的结果符号与第一个输入符号不同才会将状态标志设置为真,而 / 在尝试除以零时会将状态标志设置为真。

执行:程序流

在每条指令的执行结束时,控制单元(CU)必须完成取指-解码-执行周期,并为下一周期的开始做好准备。如何完成这一过程取决于我们处理的是普通指令(例如加载、存储或算术逻辑单元(ALU)指令),还是用于改变程序流的指令——即跳转和分支指令。

正常执行中,当一条指令完成时,我们希望继续执行程序中的下一条指令,对于巴贝奇来说,这就是通过绳子将当前指令的穿孔卡片底部与下一个指令的穿孔卡片顶部连接起来的那张穿孔卡片。这将为下一次取指做好准备,即下一条新指令。为此,控制单元需要触发并增加程序计数器。在分析机中,这是通过建立机械连接来为穿孔卡片读取器供电,以执行行进送纸操作,将卡片堆通过读取器推送一张卡片。

跳转指令意味着按要求快速前进或倒退程序。考虑指令CF+4,其意思是向前推进四行。当控制单元看到此指令时,它会再次修改程序计数器,但不是简单地增加它,而是按请求的行数进行推进或倒退。在分析机中,这是通过将动力送到行进送纸装置,并延长时间来完成比单行推进更长的推进,同时机械地切换送纸方向,以便前进或后退。

分支指令(例如CB?4)的执行方式有所不同,取决于状态标志的状态。例如,这条指令告诉控制单元(CU)跳转,如果状态标志为启用,则将程序计数器减去四,否则指令无效,正常执行用于递增程序计数器并跳转到下一条指令。这种分支是分析引擎与早期的桶式和打孔卡片程序机器(如音乐播放器和贾卡尔织机)的重要区别。除非历史学家发现其他能够实现这一功能的机器,否则这台引擎标志着第一次有机器被设计用来修改其自身程序的执行,而不是始终按照相同的顺序执行。能够查看事物的状态并根据状态做出决策是教会计算机的关键要求。

总结

我们在本章中研究了巴贝奇的分析引擎,因为它是所有后继计算机的蓝图,包括现代个人计算机。其高层架构包括一个中央处理单元(CPU)、随机存取存储器(RAM)和连接它们的总线。在 CPU 内部,有算术逻辑单元(ALU)、寄存器和执行获取-解码-执行周期的控制单元(CU)。指令集包括加载和存储、算术运算、跳转和分支指令。还有一个程序计数器,存储当前程序行号,以及一个状态标志,如果最近的算术操作发生了有趣的事情,则会设置该标志。所有这些功能在现代个人计算机中基本保持不变。

作为一个机械系统,分析引擎比电子设备更容易可视化和理解。但电子计算机的基础只是将巴贝奇的每个组件转化为更快、更小的电子开关实现,这些开关被组合成逻辑门。在本书的第二部分,你将看到如何通过从开关到 CPU 逐步构建现代电子层级结构。现在你已经了解了 CPU 需要做什么,你应该对这个电子层级结构的未来发展有更清晰的认识。

练习

编程分析引擎

  1. www.fourmilab.ch/babbage 安装 Fourmilab 分析引擎模拟器,或者使用其网页界面。

  2. 输入并运行本章讨论的分析引擎程序。如果你使用 java aes -t test.card 命令运行程序,那么-t选项将打印出每一步机器状态变化的跟踪信息。

洛夫莱斯的阶乘函数

为分析引擎编写一个阶乘函数。艾达·洛夫莱斯编写了其中一个,它后来成为了每当遇到新架构时都会尝试的标准“Hello, world!”练习。(实际上,打印“Hello, world!”通常更为复杂,因为它涉及到 ASCII 码和屏幕输出——你将在第十一章中看到如何做。)

进一步阅读

  • 有关分析机的更准确历史描述,请参见 A. Bromley,"查尔斯·巴贝奇的分析机,1838 年",计算机历史年鉴 4,第 3 期(1982 年):196-217。

  • 如需了解更具虚构性的版本,请参见威廉·吉布森和布鲁斯·斯特林,差异引擎(伦敦:维克托·戈兰茨,1990 年)。这是原版蒸汽朋克小说,书中有巴贝奇、洛夫莱斯以及一台正在运行的分析机。

第五章:第二部分

电子等级体系

第六章:## 开关

图片

通过响应另一个信号的状态来开关一个信号的开关是计算的基本组成部分。这就是为什么像雅卡尔织机这样有限的机器与像巴贝奇的分析引擎这样的一般用途机器之间的区别所在。雅卡尔织机只能执行预定的操作序列,并且不能根据发生的事情来改变该序列,而分析引擎则有能够评估寄存器状态并根据该评估跳转程序的指令。正是开关使得这一切成为可能。

从基本的开关出发,我们可以构建更复杂的设备,如逻辑门、简单的机器和中央处理器。正如我们在第一章中所见,当今计算机中使用的主要开关类型是晶体管。晶体管通过混合方向性和特定实现这些概念的基本物理原理来工作,这些实现依赖于像硅这样的物质的特性。然而,如果我们直接讨论晶体管,这两条线索可能很难分开。

因此,在本章中,我们将首先考虑一个更简单的方向性系统中的基本物理概念:水流通过管道。我们将看到一个阀门如何启动和停止水流,然后将我们所学到的知识转移到使用真空管和半导体制作的电气二极管上。接着我们将构建更复杂的开关,再次从水的类比开始,并将其应用于现代硅中的晶体管。最后,我们将探讨现代硅芯片是如何制造的,让你能深入了解计算机,甚至到它们所用的沙粒。

方向性系统

开关是一个方向性系统:它接收输入并执行某些操作,产生输出。这听起来相当直观,但在物理学中,你可以反转任何描述物理系统的方程的时间方向,它仍然是成立的。那么为什么当我们把一个玻璃杯摔掉时,它的原子通常不会跳回去恢复成一个新的玻璃杯呢?答案是,或者说能量的组织性;玻璃中的化学和势能以微小的热量形式散失到大气中。实际上确实有一个小的概率,玻璃杯会重新组合成原状,但能量更可能以热量的形式扩散开来,而不是集中成有组织的结构。在大爆炸初期,宇宙中的所有能量都集中在一个非常有组织的地方,从那时起,它一直在扩散并变得越来越无序。

正是熵使我们能够体验时间的方向性和因果关系的感觉。过去比未来更有组织,因此我们的脑袋更容易存储过去的信息,而不是未来的。因为我们有过去的记忆,我们可以将其与现在看到的事物联系起来,并描述过去的事件是如何导致现在的事件的。

熵也使我们能够制造机器,包括计算机,借此我们能够使事件序列沿着特定方向发生。为了让机器在特定方向上运行的概率非常高,而不是在正反方向之间随机切换,我们将它设计成一个高度有序的能量状态,并设定所需的状态序列,使得每一步都消耗一些有序能量并将其以热量的形式释放出来。这就是为什么计算机必须消耗能量并释放热量——为了让它们的程序随时间按正确的方向运行。

水阀

日常管道中使用的水阀是一个简单的方向性系统的例子。例如,通常在管道中会有一个阀门,它将水从当地供水系统引入您的家庭。它确保水只能通过管道进入您的家庭,而不能流出。这可以防止您通过下水道倒入化学物质,从而毒害您街道上的其他人。这个水阀的工作原理如图 4-1 所示。

图片

图 4-1:一个单向水阀。当水流是正向偏置(左)时,水会推开阀门并通过。当是反向偏置(右)时,水会将阀门推入阻挡物,密封住阀门,阻止水流。

在这里,一个安装在铰链上的闸门可以自由地向右摆动,但无法向左摆动,因为它在这个方向的运动被阻挡了。如果水压从右向左施加,它会使闸门更加紧密地关闭,从而阻止水流。这种流动方向我们称之为反向偏置。当水压从左向右施加时,它会推开闸门,让水流通过。我们称这种流动方向为正向偏置

这个阀门并不像它最初看起来的那么简单。想象一下,水由个别的质量粒子组成,它们正推着通过。每当一个粒子撞击闸门时,一些动能就会从粒子转移到闸门上。粒子通过后,闸门依然保持着这部分能量。如果没有任何阻尼作用,闸门会反弹到管道顶部,再反弹到底部的阻挡物上,持续不停地开合。一个更现实的模型包括了阻尼效应,在这个模型中,闸门可能开始振动,但它的动能会迅速被阻挡物吸收,并以热量的形式从系统中散失掉。一个系统必须以发热的形式付出代价,才能实现单向流动。

与此同时,已经通过的粒子在将能量传递给闸门时失去了一部分速度。由于输出粒子的能量低于输入粒子的能量,如果我们想将它们用作第二个阀门的输入,我们就需要为它们做功,重新补充一些能量。添加这部分能量就是为了弥补失去的热量。

如果你真的努力推动电流,它可以在反向偏置方向流动。你需要足够用力地推,甚至可能把元件弄坏。这可能会发出一声巨响,并永久损坏设备。接下来我们将看到类似的行为,出现在水阀的电气模拟中。

热二极管

二极管是任何允许电流在一个正向偏置方向流动,而在另一个反向偏置方向不(容易)流动的电气系统。最早的二极管是真空管热二极管,它们诞生于电机时代(“柴油时代”);与现代二极管相比,它们更容易理解,因此我们从这里开始。

一个真空管二极管(图 4-2)看起来有点像老式的灯丝灯泡。两个外部导线连接到元件:阴极(发射电子的部件)和阳极(吸收电子的部件)。这里,阴极是一个金属核心,阳极是围绕它的圆柱形包裹物,中间被真空隔开。阴极由外部能源加热。

Image

图 4-2:一个真空管二极管

当电压施加在正向偏置方向时,电子从外部流入阴极。它们被加热器加热,获得足够的能量飞出金属阴极,穿过真空被阳极吸收。这就是电流通过二极管的过程。

当电压施加在反向偏置方向时,电子从外部流向阳极,但它们没有足够的能量穿出金属并跨越真空,因为阳极没有加热。在这个方向上电流不会流动。

为了在这个系统中创造方向性,我们必须通过加热的方式为其提供能量。这些热能随后会被散发到外部环境中。

如果你真的努力推动电流,你可以让它在真空管中反向偏置方向流动。你需要一个非常高的电压才能让电子从阳极上跳出来。这很可能会发出一声巨响并永久损坏设备。

p-n 结二极管

目前使用的大多数二极管不是真空管,而是由硅上的p-n 结构成,其中pn分别代表正负电荷区域。为了理解 p-n 结的工作原理,我们需要对半导体化学和物理学做一个简短的速成课程。

半导体速成课程

基础电子学将材料分为绝缘体(不导电)和导体(导电)。半导体是那些在常态下是绝缘体,但通过非常小的变化可以被诱导成导体的材料。硅(Si),周期表中的第 14 号元素,就是一种半导体。你可以在图 4-3 中看到硅原子的示意图。

Image

图 4-3:硅(Si)原子有 14 个电子,其中 4 个电子位于外壳,参与相互作用。

硅原子有 14 个正电质子和 14 个负电电子。电子分布在三个同心壳层中。最内层是一个完整的两电子壳层,中层是一个完整的八电子壳层,最外层是一个半满壳层,包含八个电子中的四个。

量子力学(这是本书范围之外的话题)表明,当原子的最外壳满时,它处于低能状态。非正式地,低能状态称为幸福状态,高能状态则称为不幸福状态。这种拟人化反映了物理系统“想”从不幸福状态移动到幸福状态的表现。“想要”是统计物理的结果,统计物理显示,幸福状态的可能方式比不幸福状态的方式更多,因此系统更可能找到进入幸福状态的途径。

幸福状态是高度可能的,因为进入这些状态是一个定向系统。当电子从不幸福的状态移动到幸福状态时,它们会将多余的能量以光子的形式释放出来,通常以热的形式丧失。要使电子回到高能状态,你需要找到一个相似或更高能量的光子并将其射回原子,这种情况不太可能发生,除非你付出努力让它发生。这些概率作为化学力作用于电子,推动它们进入具有完整外壳的配置。

一组硅原子的最幸福状态是通过共价键在它们的外壳中共享电子。每个原子通过共享一对电子与四个邻居形成键,表面上为每个原子提供了一个完整的外壳,包含八个电子。这可以在二维中绘制成一个规则的方形原子网格,如图 4-4 所示。

Image

图 4-4:硅原子形成晶格,分享电子以填充其外壳,形成八个电子。

然而,在真实的三维世界中,结构是四面体的,四个邻居被安排在不同的三维方向,如图 4-5 所示。这个结构被称为晶格,它非常强大且稳定。(对于碳来说,晶体形式被称为钻石。硅晶体具有一些类似的属性,但它更容易加工且价格便宜得多。)

Image

图 4-5:硅晶格实际上是三维的,并且呈四面体结构。

硅晶体本身并不导电,因为所有电子都安居在它们当前的位置,不需要移动来降低能量。然而,我们可以通过向硅晶体的晶格中加入少量不同的原子,使其像金属一样导电。这个过程称为掺杂。考虑使用硅的邻近元素进行掺杂:铝(Al,元素 13),外层有三个电子,和磷(P,元素 15),外层有五个电子。用铝掺杂会导致晶体中电子的净短缺,称为p 型掺杂p表示正)。用磷掺杂则会导致晶体中电子的净过剩,称为n 型掺杂n表示负)。掺杂的晶体仍然是电中性的:它们包含相等数量的质子和电子。短缺和过剩仅与原子希望填满外层电子壳的化学状态有关。

在 p 型掺杂中,某些原子会在外层电子壳中出现“空穴”,即缺少电子。在 n 型掺杂中,某些原子会有多余的电子,导致出现一个第四个未满的电子壳,其中只有一个电子(在三个已满的内层电子壳(分别有 2、8、8 个电子)之外)。这两种掺杂的硅表现得像金属。在 n 型掺杂的硅中,多余的电子并没有紧密绑定在稳定的结构中,而是自由地在不同的原子之间流动。这意味着它们可以穿过晶体流动,因此硅变成了导体。类似地,空穴可以在 p 型掺杂的硅中流动,使其变成导体。即使掺杂的原子数量相比于硅原子的数量非常少,这种情况仍然有效。

p-n 结的工作原理

p-n 结由一个 p 型掺杂区域和一个 n 型掺杂区域相邻组成,正如在图 4-6 中所示。

Image

图 4-6:在高能态下,由两种不同掺杂区域的硅构成的 p-n 结

在这里,我们看到 n 区中磷原子(元素 15)周围的多余电子,以及 p 区中铝原子(元素 13)周围的电子短缺。在这种状态下,两个区域都是导体,因为它们要么拥有自由电子,要么有空穴,因此电流可以跨越结点流动。

当结点形成时,远离接触边界的晶体部分没有任何影响。但在接近边界的区域——称为耗尽区——几乎瞬间就发生了一些有趣的事情。在这个区域,n 型掺杂侧多余的电子受化学力的作用,被吸引跨越边界,填补 p 型掺杂侧外层电子壳,正如在图 4-7 中所示。

Image

图 4-7:在低能态下,p-n 结中电子跨越结点时释放能量的过程

这种化学力足够强大,可以克服一些电力,通常电力会将电子保持在原本与质子配对的附近。化学力和电力在一个点上相互平衡,在这个点上,电子仅仅在耗尽区内穿越。(如果它们穿越得更远,电力将比化学力更强,电子就会被推回去。)化学力足够强大,能够产生一个稳定的低能状态,其中耗尽区内的原子具有完整的外层电子壳。由于原子具有不同数量的质子和电子,它们也被电离了:n 区有净正电荷,p 区有净负电荷。由于这是比起始状态能量更低的状态,光子被发射出来并以热的形式失去,当电子进入它们的新位置时。因为耗尽区内的所有原子都有完整的外层壳,这个区域充当了绝缘体(像纯硅一样),因此电流不能穿过结。

p-n 结的功能就像水阀:在它的高能状态下,它就像一个打开的阀门,允许电流流动;在低能状态下,它就像一个关闭的阀门,阻止电流流动。就像水流推动阀门并使其抗重力升起一样,在正向偏置下流动的电流做了一些功,将能量重新注入系统,并将其推入开放的高能状态。而在反向偏置中,像水流推动阀门紧闭一样,流动的电流将系统推入低能状态,这种状态下不导电。其工作原理如下。

在正向偏置下,额外的电子从外部被注入到 n 区。n 区中耗尽的部分只对接收这些电子略感不满,因为它们可以与元素 15 的离子壳结合,这些离子的电子之前已经被丢失到 p 区。它们不再是离子,恢复成普通的原子。现在,随着一个新的电子开始形成新的壳层,它们感到不满,因为它们没有完整的外壳,但这一点几乎被它们变得电中性所弥补。因此,只需要一点点的工作就能克服这种轻微的不满,把电子推入其中。

在 p 区,由于正向偏置,电子被拉出时会发生类似的情况;它略感不满,因为它失去了完整的壳层,但它获得了电中性,这几乎——但不完全——抵消了这种失落感。

总结一下,电子已进入 n 区并从 p 区离开,这意味着它们有效地从 n 区流向了 p 区,形成了电流。现在我们也将系统恢复到原来的高能导电状态,如图 4-6 所示,因为我们回到了每侧电子的原始数量。我们必须做一些小工作来克服原子因被改变而感到不满,这些工作等于我们从高能状态转到低能状态时失去的作为热量的光子。几乎在这一切发生后,系统将再次回到低能状态,并在此过程中释放新的光子作为热量。为了继续推动电子通过系统,我们需要继续对系统做功,并将这些功以光子热的形式释放出来。

在反向偏置下,我们尝试将电子从外部注入到 p 区域一侧。在这种情况下,p 区耗尽区的原子非常强烈地不希望接收电子。它们不仅仅是不太高兴,而是非常不高兴接收电子,因为这样做会破坏它们完整的外层电子壳,并且还会将它们双重电离成 13^(2–) 离子。因此,进入的电子不会进入这个区域。相反,p 区未耗尽部分将接收它们,因为该区域包含 13 号元素的原子,这些原子乐于被电离为 13^– 离子,因为这填满了它们的外层电子壳。这样,进入的电子会扩大耗尽区,因为每个新电离的原子由于外壳已满而停止导电。这使得 p 区更加不导电,就像反向偏置的水流压在阀门上使其更加紧闭一样。

在 n 区,也会发生类似的事情,当我们尝试将电子从该区域抽出时。在这里,15^+ 离子真的不想放弃电子,因为这会破坏它们完整的外层电子壳,并将它们转化为更加带电的 15^(2+) 离子。因此,我们最终从 n 区未耗尽部分抽取电子。这使得 15 号元素的原子很高兴拥有完整的外层壳,但也使它们变成了绝缘体,并再次使耗尽区变大。

通过施加非常大的力,可以强制电子进入这些非常不开心的状态,然后在反向偏置下越过结区,但这只有在施加非常大的力的情况下才能实现。系统会抵抗这种力一段时间,进入一个非常高能的状态,然后最终会突破,释放出所有的能量,电子越过结区。这很可能会发出巨大的爆炸声,并永久摧毁设备。

发光二极管(LED)中,当电子穿过结并降到较低能级时所发出的光子具有可见的光频率。这里特别明显的是,你必须通过向系统输入能量来做一些小工作,才能让电子穿过结并发出光子。如果你试图将电流反向通过 LED,你还可能看到更多的光、声音和烟雾的释放。

Image

图 4-8:二极管符号

请注意,在这段讨论中,我们故意考虑了“电子流”而非“电流”,以便使水流的类比尽可能简单易懂。由于一次非常不幸的历史事故,“电流”被定义为“电子流”的否定,并且被认为是从阳极流向阴极,而不是从阴极流向阳极。这一点在二极管符号中有所体现(见图 4-8),其中箭头显示了电流—而非电子—流动的方向。箭头尖端的横杠表示电流在相反方向的流动会被阻止。要将“电流”这一固有定义改为反映电子流动,将与让英国所有人改为在右侧道路行驶一样困难。

开关

定向系统是我们下一级建筑结构的基本单元:开关。开关使我们能够通过另一个流自动打开和关闭流动。再次,我们将通过一个简单的水流例子来考虑这一通用原理,然后再将其转移到电子学中。

水流开关

考虑两个按顺序排列的水阀,每个阀的阻塞部分都被一个弹簧加载的可移动平台替代,如图 4-9 所示。

Image

图 4-9:水压开关

当平台升起时,左阀门不能向右打开,右阀门也不能向左打开,因此水流无法在任一方向流动。我们将两个阀门之间的区域称为基础,从左侧进入的水管称为发射器,从右侧排出的水管称为收集器

如果我们将一个额外的小水管连接到基础部分(如图 4-9 所示),我们就可以迫使水流通过这根管道进入基础部分。这将推动弹簧加载平台,使两个阀门打开,从而允许水流在主管道中双向流动。这就创建了一个开关:通过控制连接到基础部分的小管道中的水流,我们可以控制主管道中水流的通断。

考虑一下这个系统中使用的能量。我们必须将能量输入到开关电流中,这些能量最终必须流向某个地方。在这种情况下,能量已经进入了弹簧,当我们停止向基座注入水时,弹簧会弹回并释放热量,直到它逐渐减震。此外,请注意我们注入基座的水也需要流向某个地方:它与来自发射管的主要水流合流,并通过集电管流出。

电管开关

就像水开关扩展了水阀一样,电管开关通过使用一种电流控制另一种电流的流动来扩展电热二极管。这是通过在阴极和阳极之间的真空中插入金属栅格来实现的,正如 图 4-10 所示。

像热二极管一样,管式开关看起来也像一个灯丝电灯泡。添加的金属栅格连接到一个第三根“基极”导线。如果你将电子推动到这根导线中,进入栅格,它会使栅格带上负电荷。负电荷会排斥负电荷,从而阻止电子从阴极跳跃到阳极。如果你释放基极的电子,那么真空管就会像热二极管一样工作,允许电流从阴极流向阳极。

Image

图 4-10:真空管开关

电管开关在某些情况下令人困惑地被称为 阀门,尽管它们更类似于水流开关,而不是水阀。这些开关曾用于早期的电子计算机,如 ENIAC。然而,它们并不适合实际计算,因为它们需要在脆弱的玻璃泡内保持真空,并且还需要与热量打交道,这可能导致过热并发生爆炸;正如你可以想象的那样,它们需要频繁更换。电管开关出现在电吉他管放大器(或阀门放大器)中,在那里它们主要用于模拟特性,而不是与计算机相关的数字特性。(所以你可以用旧的 Marshall 放大器管来建造一台计算机——这会是一个不错的项目!)

p-n-p 晶体管

p-n-p 晶体管 是一种更好的电开关方式;它避免了真空管的许多实际问题。它的设计基于 p-n 结二极管。就像水流开关可以看作是两个镜像水阀连接在一起,中间附加一个基座管道一样,p-n-p 晶体管也可以看作是两个镜像 p-n 二极管连接在一起,形成一个 p-n-p 序列,并且有一根基极导线连接到中央的 n 区域。晶体管在其高能状态下如 图 4-11 所示。

Image

图 4-11:处于高能状态的 p-n-p 晶体管

p 区和 n 区之间的两个结点类似于水阀中的阀门。将电流开关接入基区(n 区)之间的边界,就像是将水管接入水阀之间的基区一样。左侧进入的含电流的线是发射极,而右侧输出电流的线是集电极。

就像水注入水阀的基区能推动两只阀门打开一样,注入基区的电子也会打开两个 p-n 结。将电子推入基区所做的工作将系统提升到更高能量的导电状态,使得电子能够从发射极流过晶体管到达集电极。晶体管因此充当了一个电气开关,注入基区的电子控制了从发射极到集电极的电子流动。

注意

像真空管一样,晶体管也具有可以用于音频放大器的模拟特性,例如晶体管收音机和更现代的吉他放大器。与真空管一样,我们这里只关注它们的数字特性。

与水开关类似,这一过程也有代价。将电流注入基区并激活两个结进入其高能量导电状态需要能量。这部分能量在两个二极管回落至低能量状态时作为光子(热量)释放出来,如图 4-12 所示。

Image

图 4-12:p-n-p 晶体管在其低能量状态下

像水开关一样,注入的基区电流也必须流向某个地方,其电子会与从发射极流出的主电流一起流出集电极。

晶体管的标准符号,E、C 和 B 分别代表发射极、集电极和基极,如图 4-13 所示。

Image

图 4-13:p-n-p 晶体管符号

和二极管一样,我们在讨论晶体管时用的是“电子流”而非“电流”,以保持水类比的比喻。实际上,在我们的 p-n-p 晶体管中,电流从集电极流向发射极,而电子则是反方向流动的。

注意

也可以制造 n-p-n 晶体管,其使用的区域方向相反——即它们通过从基区抽取电子来打开阀门。同样,在这种情况下,电流被认为是流入基区以打开 n-p-n 晶体管。

早期的硅芯片使用 p-n-p 晶体管,但由于基区电子流失到集电极,这种方式效率较低。现代芯片使用一种改进的设备——场效应晶体管,来提高效率。我们将再次通过水类比介绍这一概念,然后将其转换为半导体的形式。

水压效应开关

在水开关中,基座的水由于被推入开关并加入到从发射极来的主水流中而流失到集电极。我们可以通过在基座管道与基区连接的地方覆盖一个橡胶膜来解决这一效率低下的问题,如图 4-14 所示。

Image

图 4-14:水压开关

橡胶膜可以拉伸,但不允许水流通过。当对基座管道进行加压时,产生的压力将使膜被拉伸,扩展到基区中。这将迫使基区中的水通过两个阀门,从而再次使主水流从发射极流向集电极。当基座上的压力被释放时,膜会收缩,释放对阀门的压力,使其关闭并切断主水流。

添加橡胶膜的优点是,泵入基座的水不再流失到集电极。没有水离开基座。基座中的水仅暂时作用于阀门施加压力。较少的活动物体意味着浪费的能量较少,因此系统运行更顺畅、更快速。

需要做功将水推入基座。这转化为潜在能量,用来克服重力提升阀门。然后,当阀门关闭、弹跳并减震时,能量以热的形式损失掉。

场效应晶体管

场效应晶体管(FETs) 是水压开关的精确类比,就像 p-n-p 晶体管是水流开关一样。FET 通过在基座电线与 n 区连接的地方覆盖一个电绝缘体(如二氧化硅 SiO[2])来改进 p-n-p 晶体管,如图 4-15 所示。

Image

图 4-15:低能状态下的场效应晶体管

绝缘体允许电通过,但不允许电子通过它。这意味着一侧的电子可以推开另一侧的电子,而电子本身并没有跨越。当对基座做功将电子推入时,基座积累的负电荷会推开 n 区的电子,迫使它们通过两个 p-n 结,从而再次使电子从发射极流向集电极。当基座上的电压被释放时,绝缘体上的电场会缩小,释放 p-n 结上的电压,使它们关闭并切断主电子流。

添加绝缘体的优点是,泵入基座的电子不再流失到集电极。没有电子离开基座。基座中的电子仅暂时作用于 p-n 结上的电压,而不是电子流。较少的活动物体意味着浪费的能量较少,系统可以运行得更顺畅、更快速。

需要做功将电子推入基极。这会转化为势能,将 p-n 结从低能态提升到高能态。然后,当结从高能态回落到低能态时,能量以热量的形式释放出来,并发出光子。

时钟

我们常常希望自动地定期开关一个信号。这样的信号被称为时钟,它可以是一个二进制输入,随着时间的推移以方波形式振荡,如图 4-16 所示。

图片

图 4-16:方波时钟

快速的电子时钟可以通过具有压电特性的材料制作,这意味着它们会对施加在其上的电压产生机械振荡。这些振荡反过来会改变材料的电阻,并产生振荡电压。石英晶体和一些陶瓷具有这种特性,其振荡频率在兆赫(MHz)到千兆赫(GHz)范围内,具体取决于它们的结构和施加的电压。通过添加硬件施加所需电压并将其信号整流为所需的方波时钟信号,可以将其制作成时钟单元。

我们将依赖时钟来驱动第六章中的“顺序逻辑”结构。这些结构的状态可以在固定的时间间隔内更新。顺序逻辑结构又构成了 CPU 的子组件。因此,物理时钟对于计算非常重要,并且可以在现代电子计算机的主板上找到,正如图 4-17 所示。

图片

图 4-17:石英晶体振荡器

这些时钟可以在 eBay 上以几美元的价格购买,用于在你的项目中将其安装到面包板上(搜索“石英晶体振荡器”等术语)。

制造晶体管

现代集成电路使用的是在硅芯片上制造的 FET 晶体管。一个“芯片”是一个小而非常薄的硅片,类似于薯片(在英国称为“脆片”)。硅是一种丰富的元素,可以通过沙子从海滩上获得。经过提纯后,它可以被制成香肠状的块状物,称为铸锭。铸锭像切萨拉米香肠一样被切成大而薄的片,这些薄片称为晶圆。每个晶圆后来会被切割成许多小而薄的方形芯片。

创建晶体管和电线并将它们连接在晶圆上的过程叫做制造。晶圆提供了一个二维表面,在上面布置了晶体管。添加微小的金属电线将它们连接在一起。

我们在介绍中讨论过的用于印刷 T 恤和 PCB 的相同掩模概念也用于制造应用特定集成电路(ASIC)硅芯片,只不过是微型化的。与 PCB 不同,组件本身——晶体管——是与线路一起制造的。你在 CAD 程序中设计电路布局,使用固定数量的掺杂化学物质来形成每个晶体管的不同区域,使用铜形成连接晶体管的线路。然后你为每种化学物质打印出一个二进制图像,打印在透明膜上,显示其原子将如何分布到二维硅表面上。这个透明膜用于创建物理掩模,允许粒子通过所需区域,并在不需要的区域阻止它们。

你将掩模放在一个空白的硅片上,然后将原子撒在其上。这些原子只会在你设计中允许的区域通过并落到硅片上。你让这个化学层干燥,然后对每种化学物质重复整个过程,以逐步构建设计。通常,晶体管是通过掩模首先铺设的,这些掩模在硅表面上创建掺杂区域。接下来使用更多的掩模来构建金属线,这些金属线连接在硅表面上方的晶体管。图 4-18 展示了一个单一的 FET 晶体管及其在硅芯片上的线路。

图片

图 4-18:在芯片上形成的单个 n-p-n FET 晶体管,显示为硅表面的横截面,上面和表面上有化学层

制造过程既困难又昂贵。与其仅仅“撒”原子到掩模上,它们需要更多的能量才能穿透硅晶格,这可能需要粒子加速器。除了铝和磷外,还有许多其他化学物质用于掺杂,例如锗、硼、砷、镓、锂、铟以及重金属锑和铋。这些化学物质具有与铝和磷相似的性质,但它们更容易处理。与 T 恤印刷不同,制造过程还大量使用减法工艺,这些工艺使用类似的掩模施加去除而非添加层的化学物质。

传统制造要求线路不能交叉;它们必须以二维电路布局进行布置,线路之间需要绕行。这是网络理论算法研究的主要推动力之一,旨在寻找最佳布局。现代制造允许线路有限交叉,例如通过 20 层交替的铜和绝缘体,这些层通过掩模以类似 3D 打印的方式铺设,如图 4-19 所示。

图片

图 4-19:一些 3D 铜线铺设在硅上的晶体管之上

大多数系统从特定的化学物质中制造 FET 晶体管,因此这些设备被称为MOSFETs(金属氧化物半导体场效应晶体管)。它们通常使用一种称为CMOS(互补金属氧化物半导体)的特定掩模序列。现代 CMOS 工艺可能会应用大约 300 个掩模,并按特定的加法和减法层顺序进行处理。2018 年,建造一个制造厂的成本大约为 50 亿美元,而生产一套掩模的成本大约为 500 万美元。你真的不希望在将电路设计送往制造厂时发现有任何问题,否则你将需要支付另外 500 万美元来重新制作掩模集。

摩尔定律

在晶体管时代,制造技术迅速发展,大约每两年就能使单位面积硅片上能够制造的晶体管数量翻倍。正如你之前看到的,这一经验观察被称为摩尔定律,以英特尔的戈登·摩尔命名,他是最早注意到这一点的人。早期的芯片有几千个 MOSFET,如图 4-20 中显示的 4 位英特尔 4004 芯片,其有大约 2,250 个 MOSFET,通过不重叠的铜线连接成电路。现代芯片有数十亿甚至万亿个 MOSFET,通过重叠的 3D 铜线连接。

随着晶体管变得更小,它们也变得更快,时钟速度更高,因此直到 64 位时代,“摩尔定律对于时钟速度”通常被表述为每两年时钟速度翻倍一次。

Image

图 4-20:4 位英特尔 4004 处理器芯片的布局,拍摄时与其设计师 Federico Faggin 一起

有些人认为摩尔定律在两种形式上会永远持续下去,但正如我们在研究方向系统时看到的那样,开关、计算、功耗和热量之间存在着根本的联系。我们越快地开关晶体管,就会产生更多的热量,因为时钟频率f、电容C、电压V和功率使用P之间存在关系:

P = CV²f

因此,摩尔定律的两种形式自 64 位时代开始以来已经发生了分化。这被称为“遇到功率墙”,并且它是你将在第十五章中遇到的架构变化的重要驱动因素。原始定律,即晶体管密度的翻倍,依然成立;尽管需要供电的晶体管更多,但它们也变得更小,因此每个晶体管使用的功率更少,总的功率消耗保持相似。与此同时,时钟频率在大约 3.5 GHz 时趋于平稳。你已经可以在一个 3.5 GHz 的 CPU 上煎个蛋了。但如果摩尔定律在 64 位时代继续适用于时钟速度,那么到 2010 年,CPU 的温度将会达到太阳表面的温度。

总结

开关是方向性系统,用来引发所需的计算结果。方向性系统必须消耗有组织的能量并释放热量。现代电子计算机是由场效应晶体管(FET)作为开关组成,通过铜线连接在一起。这些晶体管和电线通过昂贵且复杂的掩膜工艺制造到硅芯片上。在晶体管时代,摩尔定律观察到晶体管密度每两年翻一番,这得益于制造工艺的进步。虽然时钟速度的提升在 64 位时代因能量和热量的限制而停止,但这一现象仍然成立。

为了理解大规模、复杂的晶体管网络,架构师将它们分解成更高层次的结构,从逻辑门开始,我们将在下一章学习这些内容。

练习

穷人的与门

为什么不能将单个晶体管用作与门?(提示:考虑进出的能量、电子和热量。还可以考虑水开关的等效物作为一个简化的案例。)

挑战

尝试参观一个制造工厂。某些工厂可能提供参观团,尤其是当你作为学生社团等组织团体提出请求时。它们不仅仅位于硅谷;例如,英国就有几个。有关国际名单,请参见en.wikipedia.org/wiki/List_of_semiconductor_fabrication_plants

进一步阅读

  • 如果你想了解关于计算基础物理的美妙书籍,可以参考理查德·费曼的《费曼计算讲义》(博卡拉顿:CRC 出版社,2018 年)。这本书详细但直观地讨论了能量、热量、信息和计算之间的联系。它也是量子计算概念的原始来源,量子计算依赖于这些思想。

  • 另一本精彩的读物是理查德·费曼的《讲座 46:棘轮和棘爪》,见于《费曼物理学讲义》(波士顿:阿迪森-韦斯利,1964 年),www.feynmanlectures.caltech.edu/I_46.html。这节讲座探讨了方向性系统的一般物理学。

  • 想了解芯片是如何制造的,可以观看英特尔的《芯片制造过程》YouTube 视频,时长 2:41,2012 年 5 月 25 日,www.youtube.com/watch?v=d9SWNLZvA8g

  • 如果你对管子和晶体管的模拟特性感兴趣,包括它们在音频放大器和数字开关中的应用,可以参考保罗·霍洛维茨和温菲尔德·希尔的《电子学艺术》(剑桥:剑桥大学出版社,1980 年)。

第七章:## 数字逻辑

Image

如前一章所见,前面提到的半导体晶体管等开关是现代电子计算的最基本构建块。但架构师通常并不以开关的形式思考。相反,他们从开关构建出更复杂的层次结构,最终形成中央处理器(CPU)。这个层次结构的下一层由逻辑门组成:这些设备由少数开关组成的标准电路构成,表示基本的布尔函数,如与(AND)和或(OR)。逻辑门又可以用来构建更大的结构,例如简单的算术和存储机器。

本章中,我们将研究一些常见的逻辑门类型,并了解它们是如何由开关构建的。我们将讨论如何使用像 NAND 这样的通用门来替代其他所有门,并讨论如何利用布尔逻辑来简化由逻辑门组成的电路。但首先,我们先了解一点历史。

克劳德·香农与逻辑门

到 1936 年,复杂的电子开关电路在电话交换机中得到广泛应用。这些电路自动化了以前由人工电话接线员完成的工作,通过连接和断开用户的电话线路来实现通话。例如,一个电路可能会计算以下功能:“如果呼叫者发送了一系列脉冲,编码为 024 680 2468,并且从这里到管理 024 号码的交换机有可用线路,那么就将呼叫者连接到该可用线路,并通过该线路以二进制方式传输 680 2468,直到交换机回复连接代码,并开始计费。否则,将呼叫者连接到忙线信号。”随着越来越多的电话、线路、交换机和公司接入网络,这些呼叫路由功能变得越来越复杂。迫切需要减少电路的布线和复杂性,尽可能简化它们。许多临时解决方法存在,用于用看似具有相同功能的更简单开关替换复杂的开关组,但如何可靠或最优地做到这一点仍未被理解。

正如我们在上一章所看到的,开关设备使用能量,因此它们的输出能量低于输入能量;这使得将一个开关的输出重新用于下一个开关的输入变得困难。例如,使用 0 伏和 5 伏代表二进制 0 和 1 作为输入的电气实现会产生类似 0 伏和 4.9999 伏的输出,因为开关机制损失了一部分能量和电压。如果你从许多开关构建一个大型系统,这些电压下降会逐渐积累,直到输出不再能识别为二进制代码。

这一切在伟大的计算年份 1936 年发生了变化,那时克劳德·香农开始在麻省理工学院攻读硕士学位,这篇论文可以说是有史以来最伟大的硕士论文。香农的论文为计算机架构引入了两个创新,解决了开关简化的问题。

首先,它定义了一种方法,将一组开关组织成一种新的、更高层次的抽象——逻辑门。逻辑门被定义为接收一个或多个二进制变量的表示作为输入,并使用相同的表示产生一个或多个二进制输出的设备。简单的开关不是逻辑门,因为它们会损失能量,导致输出表示的能量低于输入表示的能量,因此与输入表示不同。相比之下,逻辑门必须补充开关过程中损失的能量,使得其输出编码与输入编码完全相同。这个特性使得一个门的输出可以干净利落地作为下一个门的输入,因此可以将任意长的逻辑门序列连接起来,而不必担心每一步中能量损失所带来的噪声。

对电路设计师来说,使用逻辑门思考要容易得多,因为他们不再需要关注低层次的能量问题。香农展示了如何利用他那个时代的开关技术(机电继电器)来实现逻辑门,但这些逻辑门也可以通过多种技术来实现,包括水开关和现代金属氧化物半导体场效应晶体管(MOSFET)。

其次,香农证明了任何计算都可以通过组合标准逻辑门的小集合来实现,例如与门、或门和非门。他展示了如何将这些逻辑门及其连接映射到乔治·布尔的数学逻辑上,布尔代数,该代数大约在 1836 年由布尔发现。布尔的理论可以用来找到复杂电路的等效表达式,简化它们,并使用更少的门和开关保持相同的功能。

注意

如果说这项工作还不足以填满一生,那么香农还发明了通信理论,这是计算机科学领域的另一个完全独立且同样卓越的贡献。真是个聪明人。

逻辑门

用现代术语来说,逻辑门是任何具有一些二进制输入和一些二进制输出且不包含任何存储器的设备,其中输入和输出使用完全相同的物理表示来表示两个符号,0 和 1。逻辑门的功能可以通过真值表完全且确定性地描述,真值表列出了每种输入配置下的输出结果。你很快会看到一些例子。

有可能发明无限多种不同的逻辑门,但今天最常见的、香农研究过的逻辑门,都是只有一个或两个输入、一个输出的逻辑门。这些标准的逻辑门包括与门、或门、非门、异或门、或非门和与非门。图 5-1 至 5-6 展示了这些逻辑门及其真值表。

Image

图 5-1:与门及其真值表

Image

图 5-2:或门及其真值表

Image

图 5-3:非门(反向器)及其真值表

图像

图 5-4:一个 XOR(排他或)门及其真值表

图像

图 5-5:一个 NOR 门及其真值表

图像

图 5-6:一个 NAND 门及其真值表

每个门的真值表列出了左侧列中的所有可能输入组合,并在最右侧列中显示相应的输出。例如,只有当与门的两个输入 X 和 Y 都为 1 时,其输出才为 1;对于任何其他输入组合,输出为 0。

这些逻辑门的名称和功能旨在模仿我们人类对逻辑组合的感知,其中 1 对应于真,0 对应于假。例如,和门(AND)表示 X 和 Y 的与为真,仅当 X 和 Y 都为真时才成立。异或门(XOR),即“排他或”,要求其两个输入中恰好有一个为真;如果两个输入都为真,则输出为假。这与普通的或门(OR)不同,后者只要其中一个或两个输入为真时,就输出为真。(数字逻辑的学生有时会对类似“你想喝啤酒还是葡萄酒?”这样的问题回答“是”。)NOR 代表“既不是 X 也不是 Y”,只有当两个输入都为假时,输出才为真。NAND 可以解释为“非 X 和 Y”,它的真值表与与门(AND)相反。

逻辑门可以连接成网络,表示更复杂的表达式。例如,图 5-7 表示 X 或(Z 与非 Y),如果 X 为 1,或者如果 Z 为 1 且 Y 为 0,则输出为 1。请注意,这里的“或”是包括的。

图像

图 5-7:逻辑门用于 F(X, Y, Z) = X + YZ

图 5-7 中的逻辑门网络可以用于例如香农的电话交换应用中,其中可能是一个电路,如果接听方在 30 秒内没有接起电话(X),或者如果电话之前已经开始(Z),并且呼叫者没有剩余余额(Y),则断开电话。

识别通用逻辑门

在他的研究中,香农希望识别一组通用逻辑门,这是一组可以重新配置以构建任何硬件级机器的不同类型的逻辑门。他证明了存在多个通用集。例如,如果你有一个抽屉,里面装有无限数量的与门(AND)和非门(NOT),你可以用它们构建任何东西。你也可以使用无限数量的或门(OR)和非门(NOT)来做到这一点,但仅使用与门和或门的抽屉,你无法构建任意函数。最有趣的是,只有 NAND 门或 NOR 门的抽屉是通用的。例如,图 5-8 展示了如何仅通过 NAND 门构建标准的非门、与门和或门。你将在本章末尾的一个练习中有机会进一步探索这个图。

图像

图 5-8:从通用 NAND 门构建非门、与门和或门

通用门之所以重要,是因为它们允许我们将所需制造的物理门的类型数量减少到仅剩一个。这正是我们在现代集成电路(IC)中所做的。

用晶体管制造逻辑门

你可能最初会认为我们可以仅使用一个单独的电气开关,比如晶体管,作为一个 AND 门。毕竟,一个开关接受两个输入:发射极和基极,并且仅当两个输入都打开时,才会将一个输出(集电极)导出,这正是逻辑 AND 的定义。然而,我们已经看到一个开关必须将一些输入能量转换为热量,因此输出的形式与输入不完全相同,不能直接作为下一个逻辑门的输入。为了保持输出与输入相同的形式,我们反而将几个开关组合在一起,同时使用外部电源不断补充它们因热量损失的能量。

有很多不同的方法可以做到这一点。香农的最初设计是针对电磁继电器开关而非晶体管优化的。现代芯片使用所谓的CMOS(互补金属氧化物半导体)样式,它由两个正型晶体管和两个负型晶体管组成 NAND 门,如图 5-9 所示。由于 NAND 是一个通用门,你可以用这些 CMOS NAND 门构建所有其他逻辑门。

图片

图 5-9:由 p 型和 n 型晶体管构成的 NAND 门

电气电路是一个存在于晶体管层次上的概念,其中电子从电源流向地面,然后通过电源将其从地面泵回电源,形成一个闭合回路。虽然我们通常非正式地将逻辑门网络称为“数字逻辑电路”,但从技术上讲,这是不正确的,因为在更高层次的抽象下,这些网络通常不会形成闭合电路,反而可能具有任意的网络拓扑结构。如果我们使用非电子的逻辑门实现相同的网络,在更低层次的抽象下甚至可能根本没有电路。因此,当我们绘制图表并构建由逻辑门组成的系统时,应该更准确地称它们为“数字逻辑网络”而不是“数字逻辑电路”。

用台球制造逻辑门

逻辑门不一定必须由晶体管或电流构成。例如,台球计算机是一种理论发明,其中计算是在一个复杂的几何迷宫环境中通过台球进行的,像 AND 和 OR 这样的逻辑门版本是通过几何结构和力学实现的。这些门的排列方式是,例如,AND 门检测两个台球的碰撞,并且只有当碰撞发生时,才会将其中一个台球导向正输出,如下图所示。

图片

由于能量守恒的机械定律,台球计算机模型可以用来表明,计算需要相同数量的能量,因此也需要相同数量的比特信息来进出。这与普通的与门(AND gate)不同,后者有两个输入和一个输出。模型表明,我们应该为消耗的第二比特添加一个额外的“垃圾”输出。这具有一个有趣的特性,即使得计算在某种意义上是可逆的,即如果我们知道输出,就可以重建输入。这使得我们能够反向运行机器。如果这听起来很奇怪,可以考虑到许多编程场景中,拥有一个反向调试器来撤销最近几行代码的效果将是非常有用的。

台球计算机的目的是帮助我们清楚地思考能量使用和热量在计算中的角色。随着依赖小型电池的便携式计算机的兴起,越来越需要节省计算能量,尤其是随着环境问题、燃料资源和成本、碳排放和热污染的关注不断增加,这个话题变得更加重要。传统的与门有两个输入和一个输出,因此每次执行与操作时都会有一个台球的能量作为物理热量损失。台球模型表明,如果我们跟踪门的第二个输出比特——这个比特同样用于使计算可逆——我们可以构建不会浪费能量的电气与门。热量实际上是我们丢失的能量,在这种情况下是通过丢弃信息造成的。这就是为什么你的手机在进行大量计算时会变热,也解释了为什么你的处理器需要一个大风扇。风扇正把废弃的信息以比特的形式从计算机的通风口排出。(从这个意义上说,世界上燃料枯竭不是能源危机,而是信息危机。能量不能被创造或摧毁,但我们可以丢失关于能量去向的信息——这是我们需要的信息,才能让能量为我们做有用的工作。)

将逻辑门放入芯片中

当你第一次通过显微镜查看芯片时,或者查看任何由逻辑门构建的计算机时,你无法指向单个组件并说:“那是一个逻辑门。”你实际上会看到一堆晶体管,它们被组织成门。例如,看看图 5-10,这张图片展示了一个非常简单的硅芯片的显微镜照片(“die shot”),其中只包含四个 CMOS NAND 门,每个门由四个晶体管构成(正如你在图 5-9 中看到的)。

Image

图 5-10:包含四个 CMOS NAND 门的简单硅芯片的显微镜照片

图 5-11 是一个单一 CMOS NAND 门的掩模集,展示了如何物理布局 p 型和 n 型掺杂区以及铜线。

现代处理器可能包含数十亿个晶体管,这些晶体管被分组到逻辑门中。但仍然生产包含少量逻辑门的老式集成电路,这些电路对于构建你自己的电路非常有用。7400 TTL 系列就是这样简单芯片的一个著名例子。最初由德州仪器在 1960 年代生产,它们现在已作为通用产品广泛生产。该系列的大多数芯片只包含少量单一类型的逻辑门,例如四个与门、四个与非门或四个或非门,正如图 5-12 所示。

Image

图 5-11:用晶体管和铜线制作的 CMOS NAND 门芯片布局

Image

图 5-12:一些 7400 TTL 系列芯片,每个芯片包含几个逻辑门

这些图表展示了芯片的物理布局和引脚排列;要连接逻辑门,你需要将物理导线连接到适当的引脚。你可以在 eBay 上花几美元购买这些芯片的袋装,然后在面包板上将它们与 5V 电源和接地连接,就像在图 5-13 中那样,制作你自己的物理数字逻辑网络。

Image

图 5-13:使用 TTL 7400 系列芯片上的逻辑门在面包板上构建数字逻辑网络(形成一个 4 位 CPU)

从图 5-13 中你可以看到,数字逻辑网络的接线可能会变得非常复杂。如果我们有一种方法,能够简化网络,使用更少的逻辑门和导线,同时仍然实现相同的功能,那该多好?这正是香农创新的下一部分:如何利用乔治·布尔的逻辑来进行这种简化。

布尔逻辑

逻辑允许我们形式化关于真与假的陈述和推理。它由古希腊人发明,并一直保持不变,直到乔治·布尔在 1836 年左右的工作。布尔的工作被香农于 1936 年采纳,他意识到它可以用来建模、简化和验证由他的逻辑门构建的电路。

布尔逻辑使用变量名称来表示概念性命题,且这些命题的要么为真,要么为假。接着,它提供了 AND、OR 和 NOT 的连接符号,以及用于给由变量和这些连接符构建的表达式赋予真值的规则。

以以下示例为例。我们有两个变量:X 代表命题“上帝存在”,Y 代表“雪是白色的”。那么我们可以用 X AND Y 来表示“上帝存在且雪是白色的”。或者我们可以用(NOT X AND Y)OR(X AND Y)来表示“要么上帝不存在且雪是白色的,要么上帝存在且雪是白色的”。

现在让我们来看一下如何处理这些陈述。

逻辑即算术

布尔发现了逻辑与算术之间的结构相似性。以前,这两者是完全不同的学科。逻辑是通过自然语言文本和研究规则来分析论证的“文科科目”。算术是由数字和方程式组成的“STEM 科目”。正如数学家们成功地统一了几何学和代数一样,布尔成功地统一了逻辑和算术。

他通过注意到真值(用符号 T 表示)像数字 1 一样运作,而假值(用符号 F 表示)像数字 0 一样运作,来完成这一点。如果我们将与运算用乘法代替,将或运算用加法代替,将非运算用对 1 的取反代替,就能得出这一结论。

就像在算术中我们用 x + y 表示加法,用 xy 表示乘法一样,我们可以用这些相同的符号表示或运算和与运算。当使用这种符号时,通常也会将 x 写作非 x,这对应算术运算(1 – x)。

这种相似性并不完全相同,因为在算术中 1 + 1 = 2,但在逻辑中我们需要 1 + 1 = 1。布尔通过选择仅使用两个数字 0 和 1 的数字系统,并定义在此系统内 1 加任何数都等于 1,来绕过了这个问题。

使用布尔的系统,逻辑论证可以转化为简单的算术。这样做的优点是我们已经知道很多算术知识,特别是如何使用如结合律、交换律等表 5-1 中列出的规律来简化表达式。

表 5-1: 简化布尔逻辑的有用算术定理

名称 与运算形式 或运算形式
恒等律 1A = A 0 + A = A
零律 0A = 0 1 + A = 1
幂等律 AA = A A + A = A
反演律 AA = 0 A + A = 1
交换律 AB = BA A + B = B + A
结合律 (AB)C = A (BC) (A + B) + C = A + (B + C)
分配律 A + BC = (A + B)(A + C) A(B + C) = AB + AC
吸收律 A(A + B) = A A + AB = A
德摩根定律 AB = A + B (A + B) = AB

例如,假设我们想计算以下的真值:

(F AND (T OR F)) OR ((F OR NOT T) AND T)

我们可以通过将逻辑转化为算术,然后使用标准的算术规则来简化表达式:

(0(1) + 0(0)) + ((0)1 + (1 – 1)1)

= (0 + 0) + (0 + (0)1)

= (0) + (0 + 0)

= 0 + 0

= 0

最后,我们将得到的数字 0 转回逻辑值假。

这也适用于使用变量而不是特定值;例如,之前关于上帝和雪的陈述可以写成并以算术方式操作:

((1 – x)y) + (xy)

= (yxy) + (xy)

= yxy + xy

= y

这样可以将算术数字y转换回逻辑值Y。这表明命题的真值实际上与上帝的存在(X)无关,仅依赖于雪是否是白色(Y)。所以假设雪确实是白色的,那么命题就是真的。

注意

现在,往返于逻辑真值和整数 0、1 之间的能力通常在诸如 C 等语言中使用(或者可以说是误用),这些语言在处理这些类型时非常灵活。

模型检查与证明

我们经常需要知道两个布尔表达式是否相等。确定这一点有两种主要的方法。

第一个叫做模型检查。给定一个潜在的等式,我们只需计算潜在等式左右两边的真值表。如果真值表完全匹配,那么表达式是相等的。例如,我们来检查表 5-1 中的分配律的与形式是否始终成立。首先,我们计算并计算等式左边的表格,即 A + BC。我们从三列变量开始:ABC。然后我们为中间项BC添加一列,并用此列计算整个表达式在最右边列的值,如表 5-2 所示。

表 5-2: A + BC 的真值表

A B C BC A + BC
0 0 0 0 0
0 0 1 0 0
0 1 0 0 0
0 1 1 1 1
1 0 0 0 1
1 0 1 0 1
1 1 0 0 1
1 1 1 1 1

接下来,我们对等式右边的表达式(A + B)(A + C)进行相同的操作,见表 5-3。

表 5-3: (A + B)(A + C) 的真值表

A B C (A + B) (A + C) (A + B)(A + C)
0 0 0 0 0 0
0 0 1 0 1 0
0 1 0 1 0 0
0 1 1 1 1 1
1 0 0 1 1 1
1 0 1 1 1 1
1 1 0 1 1 1
1 1 1 1 1 1

最后,我们比较这些表格。在这里我们可以看到,对于每种可能的变量值分配,两张表格中的结果值是相同的。因此,通过模型检查,左边等于右边。

模型检查利用了项的。如果通过模型检查已经证明了一个等式,我们就说它已经被蕴含,并且它是真的,我们写作 ⊧ A + BC = (A + B)(A + C)。

证明等式的第二种方式是通过证明。如果一些等式已经建立,如表 5-1 中的法则,我们可以在符号上利用它们的结果,而不必对所有内容逐一进行真值表推导。证明是从第一个表达式到第二个表达式的转换列表,每个转换都通过说明应用了哪条法则来证明。如果通过证明展示了某个等式,我们说它已经被证明,并写作 ⊢ A + BC = (A + B)(A + C)。

例如,这里有一种方法可以证明 A + BC = (A + B)(A + C):

A + BC = (1A) + (BC) : 根据与的恒等律
= (A(1 + B)) + (BC) : 根据或的空律
= (A1) + (AB) + (BC) : 根据或的分配律
= (A(1 + C)) + (AB) + (BC) : 根据或的空律
= (A(A + C)) + B(A + C) : 根据或的分配律
= (A + C)(A + B) : 根据或的分配律

对于布尔逻辑,可以证明任何通过模型检查建立的等式也可以通过证明得出,反之亦然,所以你可以根据个人喜好选择任何一种方法。虽然在布尔逻辑中,模型检查和证明给出的答案看似是一样的,但对于其他逻辑系统,这并不总是成立,正如哥德尔后来发现的那样。

检查两个表达式是否相等的能力并非纯粹是学术问题。香农认识到它在简化数字逻辑网络中的价值。

乔治·布尔

乔治·布尔在巴贝奇的机械计算机问世几年后,出版了他的著作《逻辑的数学分析》(1847 年)和《思想的法则》(1854 年)。布尔在英国林肯成长并形成了他的思想。与大多数历史上来自富裕家庭、能够买通进入剑桥的学术英雄不同,如巴贝奇和图灵,布尔来自一个普通且贫困的家庭。他的父亲是一个鞋匠。布尔没有接受正式教育,而是去了公共图书馆,通过阅读书籍自学,就像今天你可以做的那样

布尔在没有学术体系的约束下创造了许多新的思想。他没有受到该体系对思维的限制。尤其是没有人告诉他,艺术和科学应该分开,因此他会在图书馆的两个部分之间走动,进行比较。虽然他的名字与布尔逻辑和现代编程语言中的布尔或(bool)数据类型密切相关,但他也研究了概率学和其他形式的推理,并且他受到理解和建模人类智能的动机,正如现代的人工智能和认知科学所做的那样。他研究逻辑和其他推理形式的真正动机是为了形式化、分析和检验经典哲学中的许多论证,尤其是关于上帝存在的论证。他想弄明白这些论证是否有效,通过将它们拆解成各个部分并测试每一步,看看哪些结论是真实的,哪些是值得相信的。

例如,这里是布尔关于上帝存在的逻辑(摘自《思维的法则》,第十三章):

x = 某物一直存在。

y = 曾经存在某个不变且独立的存在。

z = 曾经存在一系列可变化且相互依赖的存在。

p = 该系列具有外部原因。

q = 该系列具有内部原因。

然后我们得到以下方程组,即:1st. x = 1;

2nd. x = v{y(1 –z) + z (1 –y)};

3rd. z = v{p(1 –q) + (1 –p)q};

4th. p = 0;

5th. q = 0;

布尔的短暂一生——现代逻辑的创始人被他妻子用冰冷的毛毯包裹以治疗肺炎的顺势疗法所致死——是巴贝奇的一部分,因此他们很可能会阅读彼此的作品。然而,布尔对计算机科学并不感兴趣。他的最终兴趣是哲学性的,他对理解和建模智能的工作主要是为了为哲学方法做出贡献。尽管如此,他仍然会意识到,创造这样的形式化方法也能够使其机械化为人工智能,正如洛夫莱斯所讨论的那样。很遗憾,二人从未共同合作开发这一思想。

使用布尔逻辑简化逻辑电路

香农抛弃了布尔对变量的概念性解释,转而展示了布尔代数可以用来简化由逻辑门组成的物理数字逻辑网络。简化可以包括减少门的数量,以及减少门的类型,例如将所有逻辑门简化为 NAND 门。

这是通过将逻辑门网络转换为布尔表达式,使用算术法则简化表达式,然后将结果转换回更小的逻辑门网络来完成的。简化网络是有用的,因为它减少了所需的晶体管数量,从而降低了制造成本和能源使用。如今,已经有了自动执行简化的 CAD 软件:你输入你的数字逻辑网络,点击一个图标,便能得到一个更小、更高效的版本。

例如,假设我们已经设计了图 5-14 左侧的数字逻辑网络。利用布尔理论,这可以转换为算术表达式并简化为(A + B)(A + C) = A + BC。这对应于图 5-14 右侧的较小网络。

Image

图 5-14:逻辑网络 (A + B)(A + C)(左图)和简化后的 A + BC 形式(右图)

我们可以利用布尔逻辑进一步简化逻辑网络,使其仅使用通用的 NAND 门,然后减少 NAND 门的数量,如图 5-15 所示。

Image

图 5-15:将 A + BC 转换为 NAND 门(左图)并进行简化(右图)

这样,我们就能将任何网络转换为一个更容易构建的网络,只需使用一种类型的门电路,并且尽可能少地使用它们。

布置数字逻辑

一旦你设计并简化了数字逻辑网络,通常你会希望将其转移到实际硬件上。这里有几种方法,我们将在接下来的部分进行讨论。

7400 芯片

实现一个简单逻辑网络的方法自 1960 年代以来一直没有变化:将它布置在一堆 7400 芯片上,并用一堆乱七八糟的电线将它们连接起来。

如你之前所见,每个 7400 系列芯片包含多个门电路,通常都是相同类型的。不幸的是,单个芯片通常并不对应电路中的某个特定拓扑区域。你需要考虑电路中的每个门电路,并选择一个特定芯片上的特定门电路来实例化它。你可以完全任意地选择各个元件的位置,电路仍然可以正常工作,但如果你在布局中应用一些巧妙的方法,你将能够显著减少连接所需的电线长度。

例如,假设你想构建图 5-16 左上角所示的网络,并且你有两个 TTL 芯片可用:一个包含四个 XOR 门,另一个包含四个 NAND 门。图 5-16 右上角展示了使用布尔逻辑将网络转换为使用可用门电路的结果,图的下部则显示了如何将其布置到两个 TTL 芯片上的一种可能方式。

你可以购买 TTL 芯片,再加上面包板、开关、LED、9V 电池以及电阻来将电池降至 TTL 芯片使用的 5V(每个 LED 还需要一个电阻以防止它们爆炸),并按照图 5-17 中的示意图连接它们,来实现你的设计。

Image

图 5-16:将网络转换为 NAND 门并使用 TTL 芯片布置的方案

你确实可以通过这种方式用 TTL 芯片构建整个 CPU,事实上,许多早期的 CPU 就是通过这种方式构建的。

Image

图 5-17:使用面包板布置的 TTL 方案(使用 Fritzing)

注意

“巧妙”的布线优化方法在你尝试为更大电路进行优化时,所需的技巧会迅速增加规模。类似的巧妙方法也适用于优化我们接下来要讨论的其他硬件方法的物理布局。设计自动化且大规模执行这些任务的算法是计算机科学的一个主要领域,并且被芯片公司广泛使用、研究和开发。

光刻技术

在第四章中描述的 ASIC 工艺是实现数字逻辑网络的最重型方法,制作掩模集的成本为 500 万美元。在这里,准备了包含形成逻辑门所需晶体管布局的掩模。该工艺提供了最小、最快的硬件,但只有在大规模生产时,才能通过其设定成本来证明其经济性。

可编程逻辑阵列

可编程逻辑阵列(PLA) 是一种芯片,具有多个输入和多个输出,通过光刻技术制造,使得每个输入和每个输入的反向信号都通过保险丝与一系列与门和或门相连。图 5-18 展示了一个小型 PLA 结构的例子。图中的平面被多次叠加,每层共享相同的与门和或门。圆圈表示保险丝。

Image

图 5-18:展示输入和输出互联性的 PLA 原理图

从这个结构开始,你可以通过断开某些保险丝来制作任何布尔逻辑功能,从而有效地移除那些连接。如果你有一个足够大的 PLA,你可以将任何数字逻辑设计进行一些布尔逻辑变换,以使其达到最佳形式,然后通过熔断保险丝将其“烧录”到 PLA 中。这是很好的,因为你不需要定制设计你的芯片,也不需要花费 500 万美元去制作一套光刻掩模,只需要一套掩模——用于制作通用的 PLA。然后你可以从大规模生产商那里购买通用 PLA,并将它们转化为自己的芯片。

现场可编程门阵列

现场可编程门阵列(FPGA) 类似于 PLA,但你可以随时用新的数字逻辑重写它,而不仅仅是一次性烧录。这是因为,FPGA 并不是通过物理熔断保险丝来实现,而是通过电子方式切换标准逻辑块之间的连接开关。每个逻辑块都可以配置成某种小型的简单机器。图 5-19 展示了这种设计的例子。

Image

图 5-19:由可配置模块和它们之间的连接构成的 FPGA 芯片结构

布尔逻辑再次被用来将任何初始的数字逻辑设计转化为一系列简单的机器及其之间的连接。这几乎是一种软件方法,通过向 FPGA 板上的固件内存发送启用和禁用的连接列表,然后用这些连接来配置电子结构。

FPGAs 通常以开发板的形式出售,FPGA 芯片周围有额外的硬件,用来帮助将其连接到 PC 并进行编程。你可以购买便宜的、适合创客的消费级 FPGA 开发板,价格大约从$30 起。有两家主要的 FPGA 制造商:Xilinx 和 Altera(前者现在是 AMD 的一部分;后者现在是 Intel 的一部分)。另外,面向生产用途的 FPGA 可以在没有任何支持结构的情况下购买,这时需要一个外部编程器。FPGAs 有多种尺寸;其中较大的芯片用于在进行更昂贵的 ASIC 光刻之前对 CPU 设计进行原型验证,而较小的芯片则用于嵌入式系统。

图 5-20 展示了在物理 FPGA 表面上某些数字逻辑的典型布局,以及用于将其放置在该位置的开发板。

Image

图 5-20:显示 FPGA 内部逻辑配置(左)和 FPGA(中央的大芯片)在其开发板上的位置(右)

当人类手动布置数字逻辑时,他们倾向于通过空间布局组织它,使得不同的区域对应不同的结构。而 FPGA 内部看到的自动布局通常视觉上没有结构,因此对于人类来说,往往难以理解甚至不可能理解。

总结

逻辑门是抽象概念:它们是将小组开关(如晶体管)组织成功能单元的一种方式。人类设计师倾向于在这一层次上思考,而不是在开关层次上,因此他们使用逻辑门设计电路。每个逻辑门随后被“编译”成小组的开关。(一些专业的芯片设计师确实能“看到”硅片上的逻辑门。他们已经习惯于观察由逻辑门创建的标准晶体管模式,这些模式在他们的感知中会自动跳出来。但对我们大多数人来说,我们只能看到晶体管。)

与简单开关不同,逻辑门具有一个关键特性,即它们的输出保持与输入相同的表示。例如,基于晶体管的逻辑门不会在其输出端产生比输入端更低的电压。这意味着它们可以组合成复杂的逻辑网络。

克劳德·香农向我们展示了我们可以使用乔治·布尔的代数来简化逻辑门电路,通常减少所需的门数量,并将所有其他类型的门替换为仅使用 NAND 门。这减少了我们需要在硅片上放置的晶体管数量,并简化了设计。

练习

通用门

计算 图 5-8 中每个基于 NAND 门的电路的真值表,或者以其他方式证明它们与标准的 NOT、AND 和 OR 门等效。

设置 LogiSim Evolution

LogiSim Evolution 是一款图形化数字逻辑模拟器。它被用来创建本书中的数字逻辑电路图。它可以模拟你设计的电路,并且稍后将这些电路转移到真实的芯片上。

  1. github.com/logisim-evolution/logisim-evolution 安装并运行 LogiSim Evolution。

  2. 创建一个项目,尝试创建一些门并连接它们。组件通过点击一个门的输出然后连接到另一个门的输入来连接。点击组件或电线来激活它。使用 DEL 键删除组件,使用 ESC 删除最新的电线。点击仿真按钮运行仿真。电线上的电压以黑色表示 0,红色表示 1。一些组件可以通过右键点击来编辑其属性。

  3. 使用常量输入和 LED 输出构建并测试图 5-14 中的电路。

简化电路

  1. 在 LogiSim 中,仅使用 NAND 门来构建其他所有类型的门。

  2. 使用模型检测或证明来展示“使用布尔逻辑简化逻辑电路”一节中的电路为何是等效的。你如何从左侧的形式找到右侧的形式?是否存在一种算法能够保证得到最小的 NAND 形式?

  3. 计算布尔函数如 W(YZ + XY) 的真值表,并通过在 LogiSim 中构建并模拟等效电路来检查。

  4. 使用布尔恒等式简化前一个问题中的函数,并构建简化版本的新 LogiSim 电路。通过模拟检查真值表是否保持不变。

深入阅读

  • 要直接了解布尔逻辑(以及神学),请参见 George Boole 的《思想法则》(1854 年),* www.gutenberg.org/ebooks/15114*。

  • 对于可能是有史以来最伟大的硕士论文,请参见 Claude Shannon 的《继电器和开关电路的符号分析》(硕士论文,麻省理工学院,1940 年),* dspace.mit.edu/handle/1721.1/11173#files-area*。

第八章:## 简单机器

Image

在机械工程中,简单机器是一个众所周知的标准设计集,包括杠杆、轴、螺丝和滑轮等,每种都执行一个功能,并且可以组合在一起形成更大的机器。类比地,计算机的简单机器是一些标准设计,通常作为计算机的子组件。例如,现代 CPU 中的算术逻辑单元——正如巴贝奇的分析机一样——由许多这样的简单机器组成,每个机器执行一种算术操作,如加法、乘法或移位。

本章介绍了一系列简单机器,作为逻辑门之上的下一个架构级别。然后,在下一章中,我们将利用这些简单机器作为 CPU 的组成部分。我们将讨论的简单机器分为两大类:组合机器,它们可以写成布尔表达式,以及时序机器,它们需要反馈和时序逻辑,扩展了布尔逻辑,加入了时间元素。反馈和时序逻辑是创建内存所必需的。

组合逻辑

组合逻辑指的是那些可以用常规布尔逻辑描述的数字逻辑网络,而无需考虑时间的作用。在这一节中,我们将看到几种组合简单机器的示例,稍后我们将在构建 CPU 结构时依赖它们。

按位逻辑操作

前一章的单独逻辑门作用于单个数据位:它们通常接收一个或两个单比特输入,并生成一个单比特输出。将多个相同的门并联起来非常简单,从而创建一个数组操作符,这是一个简单的机器,可以同时对输入数组的每个位执行相同的操作,生成输出数组,如图 6-1 所示。

Image

图 6-1:一些按位逻辑操作

在这里,输入数组xy(或者在 NOT 操作的情况下仅x)通过一系列相同的门,生成z作为输出。这些数组操作对于低级 C 程序员来说非常熟悉,因为 C 语言中包含了这些操作,并且为它们分配了符号。如果目标 CPU 中存在这样的简单机器,C 编译器最终将使用这种简单机器来执行这些指令。

多输入逻辑操作

我们可以通过将两个输入版本的与门按层次结构组合来创建多输入版本的与门,如图 6-2 所示的八输入与门。

Image

图 6-2:由两个输入与门(左)组合而成的八输入与门及其符号(右)

该结构将仅在其所有输入都是 1 时输出 1。相同的结构也适用于创建多输入或门,当其一个或多个输入为 1 时,它将输出 1。

移位器

在十进制中,有一个快速的技巧来将整数乘以 10:只需在末尾附加一个零。我们还可以通过附加 n 个零来乘以更高的自然幂 10(*n*)。与其把它看作是附加一个零,不如把它看作是将每一位数字向左移一位。这样,技巧也适用于将非整数乘以 10 的幂。我们也可以通过将数字向右移动,来轻松快速地进行 10 的幂除法。这些技巧消除了传统的慢速手工计算中涉及的多次单数字乘法、加法和进位操作。

相同的技巧也适用于二进制,快速进行整数 2 的幂的乘法和除法。要将数字乘以或除以 2^(n),只需将数字的位移 n 位到左边或右边。

图 6-3 展示了一个简单的机器,当它被启用时,会执行左移操作,从而将输入数字乘以 2。该机器通过将 S(移位)输入开关设置为 true 来启用。如果 S 输入没有启用,机器将输出原始输入而不做任何改变。

图像

图 6-3:由逻辑门构成的左移位器

移位器的设计基于一个子机器,它由两个与门(AND)、一个非门(NOT)和一个或门(OR)组成。数字的每一列(每一位)都有一个此子机器的副本,它要么允许该列的位不变地通过,要么将该列的位替换为右边列的位。

当你在 C 等高级语言中使用类似 x>>2 的操作进行乘法时,你的 CPU 可能包含一个专用的移位器,它会被激活,而不是使用通常的乘法数字逻辑。这使得乘法操作比非二的幂的乘法操作更快。这就是了解架构如何帮助你编写更快程序的一个例子。你会经常看到为了利用这个技巧而设计的速度关键代码,比如游戏和媒体编解码器强制将值设为二的幂。

注意

移动超过一位可以通过几种方式来实现。你可以多次重复使用相同的移位网络,这样可以节省晶体管,但运行时间会更长。或者你可以使用更多的晶体管来实现许多不同的开关,这些开关可以请求不同类型的移位,并立即执行它们。决定是否通过这种方式在晶体管和速度之间做出权衡是一个常见的架构难题。

解码器和编码器

假设你有一个正整数 x,它表示为一个 M 位二进制数。计算机通常需要将这种二进制表示转换为另一种 1-of-N表示法,其中 N = 2^M 位,除了 x 位是 1 之外,其他位都是 0。例如,一个 M = 3 位输入,如 101(编码数字 5[10]),将被转换为 00000100,具有 2³ = 8 位,只有第五位为高电平(从左到右,从 0 开始计数)。一种叫做解码器的简单机器可以执行这种转换。图 6-4 展示了一个 3 位解码器的数字逻辑电路。

Image

图 6-4:3 位解码器

每个输入首先被复制并反转。然后,一组与门被连接到每个输入位的未反转或反转版本,连接模式模拟二进制数字编码的模式。

编码器执行反向操作:它将一个 1-of-N表示作为输入,并将其转换为一个二进制数字编码。

多路复用器和解多路复用器

我们已经看到,分析机由许多子组件组成,这些子组件根据需要动态地连接和断开以执行计算。在分析机中,这些连接的建立和断开是机械完成的。例如,当我们想要进行加法时,机制会将齿轮物理地接触在寄存器和算术逻辑单元(ALU)之间。或者当我们从 RAM 加载数据时,机制会物理地将所需的 RAM 位置连接到总线上。这种思想的数字逻辑版本就是多路复用和解多路复用。

多路复用器使我们能够选择将多个可能的源中的一个连接到单个输出。例如,我们可能有八个寄存器,并希望选择其中一个连接到 ALU 输入。图 6-5 展示了一个八源多路复用器。它由一个解码器和八个数据输入 D[0]到 D[7]组成,还包括额外的与门和或门。

Image

图 6-5:多路复用器

如果我们希望将特定源(如 D[3])连接到输出线,我们将其代码 011[2](对应 3[10])放到解码器输入 C[0]到 C[2]。解码器仅将第三行设为高电平,并与 D[3]通过与门进行逻辑与操作作为开关。然后,或门将 D[3]复制到输出线,因为它们的其他输入都是低电平。

解多路复用器执行与多路复用器相反的功能。它接受一个输入线和一个代码 n,并将输入信号的副本发送到多个输出线中的第 n 根。

多路复用器和解多路复用器通常一起使用,因此我们可以选择将多个可能的源中的一个连接到多个可能的目的地中的一个。在这些情况下,共享的线称为总线

加法器

在 第二章 中,你已经了解了如何在二进制中表示整数。我们可以构建简单的机器,利用这种表示法执行算术运算,例如执行加法的 加法器

这是加法两个二进制数 001100 和 011010 的例子:

Image

你可以手动执行这个加法,使用与儿童学习十进制加法时相同的算法:从最右边的列开始,计算该列的列和,将结果写在下方作为该列的输出和。如果出现进位,比如 1 + 1 = 10,则将结果的低位(10 的 0)作为和,并将高位(10 的 1)进位到下一列,在下一列中将其作为第三个输入进行加法。在这个例子中,从右数的前三列没有产生进位,但第四列和第五列产生了进位。(进位在最终和的下方显示。)

如果回顾 图 5-1 和 图 5-4 中的与(AND)和异或(XOR)真值表,并将其与二进制加法的过程进行比较,你会发现,只要没有输入进位(如例子中的前四列),XOR 的结果与列加法完全相同,而 AND 的结果则与进位操作完全相同。因此,我们可以使用一个 XOR 和一个 AND 来构建一个简单的机器,称为 半加法器,如 图 6-6 所示,当没有进位输入时,它可以计算列的和。

Image

图 6-6:半加法器

单独使用半加法器并不非常有用,因为我们通常无法知道是否会有进位输入。然而,如果我们将两个半加法器与一个或门(OR gate)结合,如 图 6-7 所示,就能得到一个更有用的网络,称为 全加法器

Image

图 6-7:由两个半加法器和一个或门组成的全加法器

全加法器的真值表见 表 6-1。

表 6-1: 全加法器真值表

X Y C[in] C[out]
0 0 0 0 0
0 0 1 1 0
0 1 0 1 0
0 1 1 0 1
1 0 0 1 0
1 0 1 0 1
1 1 0 0 1
1 1 1 1 1

完整加法器连续执行两次单比特加法,第一次是对主输入(X 和 Y)的加法,第二次是对主输入加上进位(C[in])的和进行加法。最终结果是一个单列的和,如下所示:

Image

这是正确找到每一列二进制加法的二进制数字和所需的完整过程。除了加上来自两输入数字该列的两个二进制数字,它还会在有进位数字时加上传入的进位。完整加法器的两个输出是该列的和(S)和该列的进位输出(C[out])。

完整的加法器网络通常由图 6-8 中所示的单个符号表示。

图片

图 6-8:加法器符号

一个完整的加法器执行单列的加法,但要将整数加在一起,我们需要加许多列。实现这一目标的一种方法是为每一列创建一个完整的加法器,并将每列的进位输出连接到下一列的进位输入。这就是波纹进位加法器。 图 6-9 展示了一个 3 位的例子,计算 Z = X + Y。

图片

图 6-9:一个波纹进位加法器计算 Z = X + Y 的 3 位数

下标表示该列所代表的 2 的幂次;例如,这里 X[0]表示个位(因为 2⁰ = 1),X[1]表示十位(因为 2¹ = 2),X[2]表示百位(因为 2² = 4)。最后的进位输出还会有额外的输出,用来指示是否发生了溢出。在某些情况下,这会被解读为错误。在其他情况下,它可能会连接到其他系统,这些系统可以共同处理更大的数字。

图 6-8 中的加法器符号也可以用于表示多位加法器,例如波纹进位加法器,其中输入和输出线假定表示一组电缆,而不是单根电缆。

波纹进位加法器与进位保存加法器的比较

当你在学校学习加法时,你会学习到一种串行加法算法,从右边开始加,并移动到左边,同时将进位数字传递到下一步。波纹进位加法器就是这个思想在数字逻辑中的直接二进制翻译。

想一想这个过程的效率;假设两个输入都是n位长,我们可以看到这种加法方法会随着n的增加而线性扩展,随着位数的增加,加法的运行时间大约是 O(n)。

但加法不一定非得像这样进行或教导。试想一下,不是教孩子们分别将数字加起来,而是教他们从一开始就作为一个团队合作,每个人执行加法的一小部分。怎样才能让这些数字尽可能快地并行加起来?你可能会让每个孩子拿到加法中一对列数字,让他们同时进行加法运算,然后让他们将进位传递给左边的人。接着,每个人从右边接过进位,并将其加到自己的结果中进行更新(如果需要的话),有时还会更新他们的进位输出并再次传递给左边,直到每个人都满意为止。这种方式叫做进位保存加法器

估算这种并行加法中需要执行的进位步骤数是相当具有挑战性的。粗略估计,大约四分之一的初始加法会产生进位。但接下来,你需要考虑在后续接收到进位时第二次或第三次进位的概率。

为了正确地进行概率估算,你应该考虑参与加法的数字分布。大多数自然量的高位数字(十进制中的 5+,二进制中的 1)的概率低于低位数字(十进制中的 4 以内,二进制中的 0)。这一现象在物理量和纯数学量中都有发现(例如普朗克常数、πe 的数字),尽管其原因相当复杂。

进位保存加法器可以在 O(log n) 时间内进行加法运算。它们仍然进行与波纹进位加法器相同的 O(n) 总工作量,但通过并行处理更多的工作,使用了更多的硅片。更多的硅片需要更多的空间和成本,但在这种情况下能提供更快的性能。再一次,使用硅片换取时间是一个常见的架构难题。

进位保存加法器被广泛应用于现代算术逻辑单元(ALU)。它们并不是新发明,实际上在分析引擎设计中就有体现。这也是该机器从未被建成的主要原因之一:巴贝奇反复改进进位机制的效率,甚至到了痴迷的程度。如果他坚持某个设计,可能会完成该机器。

取反器和减法器

如果我们使用二的补码数据表示法表示整数,那么取反一个数字(即将其乘以 -1)可以通过翻转其比特位,然后加 1 来完成。执行此操作的机器称为取反器。图 6-10 展示了一个 3 位取反器。

Image

图 6-10:3 位取反器

该图中的粗线代表了多个单独导线的束,通常用于表示三根导线的束。 (另一种常见的表示束的符号是画一条斜线并在旁边写上导线数量。) 左下角的开关指定了输入数字,从最低有效位开始。需要加的数字通过电源和地面输入进行编码,同样从最低有效位开始。这里的加法器符号不仅表示一个全加器,还表示一个 3 位加法器,比如一个波纹进位加法器。

一旦我们有了取反器,就可以制作一个减法器,即从一个数字中减去另一个数字的机器。单比特和多比特减法器使用图 6-11 中的符号表示。

Image

图 6-11:减法器符号

我们可以通过将 b 传递给取反器,然后使用加法器将结果加到 a 上来制作一个二的补码减法器,从而计算 c = ab

从组合逻辑到时序逻辑

到目前为止,我们看到的组合电路可以看作是瞬时计算的。每个电路都精确对应一个布尔逻辑表达式,该表达式具有一个明确的、数学上的真值,表示电路的输出。这个输出仅依赖于输入值,输入和输出的配对可以列出在真值表中。

我们已经看到,香农的组合逻辑电路可以用来构建许多简单的机器,如多路复用器和加法器。香农在 1936 年提出了他的逻辑门理论,这一年也是丘奇和图灵提出计算的定义的年份。如果你愿意将“程序”视为一组指示如何物理连接一堆逻辑门的指令,那么你也可以把香农的逻辑门视为这一年的另一个竞争性的计算模型,就像在编程虚拟机 ENIAC 之前的那种方式。

然而,丘奇计算机需要能够模拟任何其他机器(前提是有足够的内存),而我们知道一些其他机器有内存来存储数据。组合逻辑电路中没有内存的概念,因为内存意味着数据随时间的存储,而这些电路可以看作是瞬时作用的,因此没有时间的概念。丘奇计算机需要有时间和内存,并能够计算不仅依赖于当前输入的输出,还依赖于从先前输入中推导出的状态。

如果我们允许逻辑门网络的输出反馈到它们的输入中,我们就可以扩展香农的逻辑门,加入这些额外的概念。香农的原始组合逻辑中是不允许这样的网络的,因为它们会导致自相矛盾的布尔表达式。例如,图 6-12 中的电路似乎实例化了布尔语句 X = NOT X。这个布尔语句表示,如果 X 为真,那么 X 为假;但如果 X 为假,那么 X 为真。如果你连接这个电路,你觉得它在实践中会发生什么?也许它会振荡或爆炸?

Image

图 6-12:一个矛盾的电路

在计算机科学中,反馈常常被认为是邪恶的或自相矛盾的,是需要避免的:逻辑和可计算性理论中的许多定理都在讲如何通过将程序、证明和机器的输出或它们的描述反馈到输入中来摧毁它们。但是,反馈在计算机科学中是一个重要的概念,学会控制它并将其用于正面用途,是我们成功和文化的重要组成部分。创建记忆就是反馈的一个积极且受控的应用。

让我们用吉他手的例子来说明这个概念。吉他手对反馈有一个更实际的担忧,因为他们的吉他弦可能与从放大器发出的声音产生共鸣。这些共鸣反过来又被放大,形成了恶性循环,从而发出一种可怕的(或者从音乐的角度看,可能是美妙的)单频尖叫声。考虑一下这个过程究竟在什么时候发生。你可以将同样的吉他放在放大器前的完全相同的位置,却依然保持系统完全安静,如果没有初始的声音的话。反馈只有在有声音—甚至是微小的声音—的情况下才会出现,启动反馈过程。因此,我们可以用这个吉他-放大器系统来存储 1 位信息。我们将吉他小心地靠近放大器,使其不发出任何声音,系统保持安静,代表 0。如果我们稍后想存储 1,我们就拨动琴弦开始反馈,反馈会永远持续下去,代表 1。要将其改回 0,我们可以将放大器关掉再打开。

图 6-13 中的电路是试图将相同的概念做成数字逻辑版本。如果我们尝试将其映射到布尔逻辑,它似乎不像图 6-12 中的电路那样矛盾,而是似乎实现了布尔表达式 Q = G OR Q。(G 代表吉他,Q 是传统的静态或系统状态符号。)你几乎可以说服自己,当 G = Q = 0 或 G = Q = 1 时,这个系统是稳定的。

Image

图 6-13:类似吉他的反馈电路

然而,这仍然没有给我们带来时间或记忆的概念,因为布尔逻辑本质上是静态的。为了完全捕捉这些概念,我们需要超越布尔逻辑和香农门,考虑一种新的逻辑门,它在不同的时间具有不同的状态。我们需要使用时序逻辑来区分不同时间的状态,例如写出Q[t] ≠ Q[t-1]来表示时间t和时间t之前的状态。这对布尔和香农来说是陌生的,事实上,它是他们理论的扩展。它可以用来赋予数字逻辑电路在他们的理论无法处理的意义,例如将图 6-12 映射为X[t] = NOT X[t-1],并将图 6-13 映射为 Q[t] = G OR Q[t-1]。后者现在是吉他反馈记忆的准确类比,即使 G 稍后降到 0,Q 也能够保持从 G 复制的 1 值。

这仍然不是一个非常有用的记忆,因为一旦 Q 被设为高电平,就无法将其重新设置为低电平。我们需要添加一个相当于放大器电源开关 A 的部分,如图 6-14 所示。

Image

图 6-14:类似吉他和放大器的反馈电路

图 6-15 中的SR 触发器是由两个 NAND 门构成的变种,NAND 门是最常见的通用逻辑门。

Image

图 6-15:SR 触发器

S 和 R 代表置位重置。当 S 为高电平时,它将输出 Q 设置为 1;当 R 为高电平时,它将输出 Q 重置为 0。(这也有一个优点,即可以在 Q^′输出端作为副产物获得 NOT Q,这在某些情况下很有用。)

时钟逻辑

如果没有一个明确定义的离散信号告诉我们何时t已经变为t + 1,顺序逻辑行为可能会变得不可预测。这可以通过时钟信号来实现,传统上称为clk,它在 0 和 1 之间稳定振荡,如第四章中讨论的那样。

根据传统,clk 的上升沿时刻被用作t增加 1 的时刻;这被称为滴答。我们随后设计电路的时间部分,以便在每个滴答时更新其状态。clk 的副本可以接入系统的多个点,使它们在每个滴答时同步更新。

与组合逻辑部分类似,我们现在将通过一系列时钟触发逻辑机器进行讲解。

时钟触发触发器

大多数顺序简单机器可以通过添加与时钟信号相与的门将其转换为时钟触发形式。图 6-16 展示了如何以这种方式扩展一个 SR 触发器。

Image

图 6-16:时钟触发 SR 触发器

只需要 S 或 R 中的一个高电平脉冲即可翻转存储器的状态,随后该状态将保持,直到接收到新的 S 或 R 信号。变化仅在时钟脉冲期间发生,因为时钟上的与门作用是禁用其他时间的 S 和 R 输入。

时钟触发版的简单机器通过标记为三角形的时钟输入来绘制,如图 6-17 所示。

Image

图 6-17:时钟触发 SR 触发器符号

SR 是最简单理解的触发器类型,因此通常用于介绍该概念,但 SR 触发器在实践中并不常用。这是因为当两个输入都为 1 时,它们会有不希望出现的未定义行为。D 型触发器有一个修改过的设计,解决了这个问题;它在实践中被广泛使用。与 SR 不同,它采用了基于时钟的固有方法。

D 型触发器(D 代表数据)只有一个数据输入和一个时钟输入。在时钟周期的某一时刻,例如上升沿,它捕获 D 输入上的数据。在其余的时钟周期中,它将在输出 Q 上输出该值。这只存储一个时钟周期的数据——如果你希望保持更长时间,你需要安排外部连接,使得 D[t +1] = Q[t]。D 型触发器的多种可能实现方式之一如图 6-18 所示。

Image

图 6-18:D 型触发器

标准的 D 型触发器符号如图 6-19 所示。

Image

图 6-19:D 型触发器符号

在这里,标准的三角形符号用于时钟输入,反相输出通过圆圈表示,正如在 NAND 和 NOR 门符号中所使用的。

计数器

计数器 是帕斯卡计算器的数字逻辑版本。我们使用 D 型触发器来存储每列中的值,并将其输出连接到自己的数据输入(以刷新存储)以及下一个列触发器的时钟输入作为进位。这如图 Figure 6-20 所示。

Image

Figure 6-20: 一个 4 位二进制计数器

如果第一个列的输入是时钟信号,则计数器将计算已发生的时钟滴答次数。如果你从计数器的某一列取出输出线,你就得到了一个时钟分频器,它将时钟频率降低到二的幂次。这在你拥有一个快速时钟并想从中创建一个慢时钟时非常有用,例如用作较慢硬件部分的时钟。

另外,第一个列的输入可以是任何任意信号,例如来自手动控制开关的线或数字电路中的其他事件,在这种情况下,计数器将计算这些事件发生的次数。

序列发生器

序列发生器 是一种在特定时间触发其他设备的装置。例如,交通灯序列发生器将按照特定的、重复的顺序打开和关闭不同颜色的灯。一个序列发生器可以由计数器和解码器组成,如图 Figure 6-21 所示,它简单地使用计数器的输出作为解码器的输入。

Image

Figure 6-21: 一个八状态序列发生器,使用 3 位计数器和解码器

随机存取存储器

随机存取存储器 (RAM) 是由地址组成的存储器,每个地址包含一组数据位,称为 ,并且可以在相同的时间成本下读取或写入任何地址。巴贝奇的分析机具有机械式 RAM;让我们看看如何用数字逻辑构建一个简单机器来实现相同的结构。

基本的 RAM 接口有三组线。首先,N 个地址线传输一个二进制自然数表示,指定哪个 2^(N) 地址是感兴趣的。每个地址存储一个长度为 M 的字,因此,第二组 M 根数据线传输数据到 RAM 指定地址或从该地址读取数据。最后,一根单独的控制线,称为 write,传输一个单比特,控制指定地址是进行读取还是写入操作。

Figure 6-22 显示了一个(玩具大小的)RAM,其中 N = 2 且 M = 2。地址线标记为 A0 和 A1,数据线为 D0 和 D1。

Image

Figure 6-22: 一个简单的 RAM,地址字通过触发器实现。这个玩具示例有一个 2 位地址空间,存储 2 位字。

每个 2² = 4 个地址存储一个 2 位字。每个字的每个位由一个 D 型触发器存储。地址选择是通过解码器从地址线中选择的。

硬件描述语言

本书使用的工具主要集中在 LogiSim 和仿真上。然而,大规模架构通常是通过一系列基于文本的语言,如网表、Verilog 和 Chisel 来完成的。为了防止你在使用 LogiSim 时受限,接下来我们简要了解这些格式,以便你能够在自己的项目中探索更大、更复杂的设计。

掩模文件

掩模文件是芯片描述的最低级别,包含了组件如晶体管和导线的物理位置、大小和形状。这些文件用于制造过程中所需的掩模。

网表文件

网表文件包含了物理组件和导线之间连接的描述,但这种连接是抽象的,而非物理布局。你使用布局引擎程序来布置和连接这些连接——也就是将网表文件转化为掩模文件。(这是一个 NP 难度问题,因此布局程序使用复杂的启发式算法,这些算法直到最近还被视为商业机密。)

Verilog 和 VHDL 文件

VerilogVHDL是基于文本的硬件描述语言,用于设计电子系统。在它们最基本的形式中,它们与 LogiSim 的功能相似,可以实例化并连接各种电子组件。但不同的是,它们使用类似软件编程语言的语法的文本文件,而不是图形界面。与像 C 这样的命令式语言不同,Verilog 和 VHDL 本质上描述的是静态对象及其之间的关系。从这个意义上说,它们的结构更像是 XML 或数据库,包含事实的列表,而不是去某些事情的指令。例如,这里是一个表示全加器的 Verilog 模块:

module FullAdder( input io_a,
                  input io_b,
                  input io_cin,
                  output io_sum,
                  output io_cout
                );

  assign io_sum = io_a ^ io_b ^ io_cin;
  assign io_cout = io_a & io_b | io_a & io_cin | io_b & io_cin;
endmodule

一旦你编写了 Verilog 或 VHDL 描述,编译器会将其转化为网表。这个编译过程被称为综合,因为源代码中表达的逻辑是通过门电路合成的。也有软件模拟器可以用于在不实际制造硬件的情况下测试 Verilog 或 VHDL 硬件设计。

虽然仍有一些人手动编写 Verilog 或 VHDL 来设计数字逻辑,但使用更高层次的工具,如 LogiSim 或 Chisel(接下来会讨论),并将其编译成 Verilog 或 VHDL 的做法变得更加常见。Verilog 还增加了一些高级语言结构,使得一些类似 C 的命令式编程成为可能,并且可以编译成数字逻辑结构。LogiSim Evolution 能够将你的设计导出为 Verilog 或 VHDL,这样你就可以将其编译成网表并用来制造实际的芯片。

Chisel

Chisel是一种为通用架构设计使用而开发的高级硬件语言。Chisel 使用面向对象的方式描述硬件类别;例如,你可以创建一个FullAdder类来表示全加器的类别,该类别可以像通常的面向对象方式那样被抽象化并继承:

class FullAdder extends Module {
  val io = IO(new Bundle {
    val a = Input(UInt(2.W))
    val b = Input(UInt(2.W))
    val cin = Input(UInt(2.W))
    val sum = Output(UInt(2.W))
    val cout = Output(UInt(2.W))
  })
  // Generate the sum
  val a_xor_b = io.a ^ io.b
  io.sum := a_xor_b ^ io.cin
  // Generate the carry
  val a_and_b = io.a & io.b
  val b_and_cin = io.b & io.cin
  val a_and_cin = io.a & io.cin
  io.cout := a_and_b | b_and_cin | a_and_cin
}

Chisel 类可能有输入和输出线数的参数,例如,可以启用循环生成N个全加器,从而构建一个波纹加法器。

Chisel 是一种硬件语言,但它与高级的 Scala 软件语言紧密相关。Scala 又受到λ演算、函数式编程和 Java 的强烈影响;这些语言通常不与硬件设计相关联,因此引入它们使得 Chisel 能够在更高层次上运作,而不再局限于过去必须在 Verilog 中进行硬件设计的时代。在开始使用 Chisel 之前,定期学习 Scala 教程可能会对你有所帮助。

总结

逻辑门可以组合成网络以执行更复杂的功能。简单机器是某些著名的网络类型,通常会在架构中反复出现。组合逻辑机器——包括移位器、编码器、多路复用器和加法器——使用香农的原始理论,而不依赖于反馈或时序。当允许反馈和时钟时,还可以创建额外的顺序和时钟逻辑简单机器。这些机器能够在一段时间内保持数据存储。触发器是简单的机器,用于存储 1 位内存。它们可以作为计数器、序列器和 RAM 的子组件来使用。

现在我们已经拥有了一系列简单机器,我们可以在下一章中将它们组合起来,构建一个数字逻辑 CPU。

练习

在 LogiSim Evolution 中构建简单机器

在做以下练习时,请记住,你可以在 LogiSim 中创建子电路的层次结构。例如,你可能会这样做,以便将你的移位器作为一个单一组件,用于更高级别的网络。要创建子电路,点击+按钮。然后,为了使用这个新组件,返回到主电路并像添加其他组件一样添加它。如果你希望输入和输出在主电路的外部接口中显示,可以在子电路内使用引脚。

  1. 构建本章前面显示的左移器(图 6-3)、解码器(图 6-4)和多路复用器(图 6-5)。

  2. 设计并构建一个右移器、编码器和解多路复用器。这些分别执行左移器、解码器和多路复用器的逆向功能。

  3. 构建并测试一个 8 位的波纹进位加法器。使用它进行减法和加法运算,采用二补码表示。

  4. 构建并测试无时钟和有时钟的 SR 触发器,以及 D 型触发器。

  5. 构建并测试一个计数器,使用时钟作为输入。

  6. 使用 2 位计数器和解码器构建一个交通灯序列器。使用它按照英国标准顺序点亮红灯、黄灯和绿灯,顺序如下:(1)红灯(停止);(2)红灯和黄灯一起亮(准备前行);(3)绿灯(前行);(4)黄灯(准备停车)。这大致就是 CPU 控制单元的工作方式。

预构建的 LogiSim 模块

LogiSim 提供了许多简单机器的预构建模块。例如,在菜单中的 Memory 下可以找到一个预构建的 RAM 模块,如 图 6-23 所示。

Image

图 6-23:一个八地址、2 位字长的 RAM

该版本有两个控制输入,一个用于写使能,另一个用于读使能。图中使用了一个 NOT 门来从单个控制线创建这两个信号。

  1. 探索 LogiSim 中的预构建模块,这些模块与您在前面的练习中实现的机器相对应。检查它们是否与您自己的实现结果相同。

  2. 探索 图 6-23 中显示的 RAM 模块。使用模块选项指定 RAM 的字长和地址长度。您可以通过右键单击并选择“编辑内容”来手动编辑 RAM 的内容,使用内置的十六进制编辑器。一个分离器(在 Wiring 菜单中)用于捆绑和解捆数据和地址线组。可以使用探针或 LED 来显示输出;常数、DIP 开关或引脚可以用作输入。

具有挑战性

  1. 在 LogiSim 中设计并构建一个自然数乘法器。这可以通过遵循学校里教给您的常规乘法算法来完成,不过是用二进制表示的。您可以使用移位器将其中一个输入乘以所有不同的二的幂,然后使用加法器将第二个数字中存在的幂加起来。使用与门来启用和禁用相关的幂。像在架构设计中经常发生的那样,您可以选择使用多个硅片副本来实现所需结构,或者使用一个副本加上时序逻辑来多次运行它。

  2. 扩展您的乘法器,使其能够处理负整数,使用二进制补码数据表示法。

更具挑战性

在 LogiSim 中设计、构建并测试一个 8 位的进位保存加法器。与传统的波纹进位加法器相比,它效率提高了多少?

深入阅读

有关如何使用 LogiSim 的完整细节,包括高级功能,请参阅 George Self 的 LogiSim Evolution Lab Manual(2019 年 7 月),* www.icochise.com/docs/logisim.pdf *。

第九章:## 数字 CPU 设计

Image

我们一直在构建越来越大的数字电子计算机组件,从晶体管、逻辑门到简单的机器,如解码器和加法器。现在是时候将所有这些组件组合起来,构建数字电子 CPU 了。至少直到最近,CPU 一直是数字电子计算机的核心。

本章的目标是克服你对数字电子 CPU 的恐惧。现代 CPU 可能是人类已知的第二复杂的设备,仅次于人脑。如果你在显微镜下观察 CPU 电路,并看到所有的接线而没有事先准备好自己的思维,你很可能会崩溃。就像开车一样,你不会直接学习现代最先进的机器;你从自行车开始,然后是破旧的老车,随着你对系统的熟悉,你会逐步过渡到更强大、更现代的机器。同样,我们将在本章中使用最初和最简单的数字电子 CPU 系统作为例子:曼彻斯特婴儿。现代的 CPU 复杂得多,可能会偏离或打破这里介绍的许多设计原则,但它们仍然基于经典的理念。通过观察历史机器如婴儿是如何实现这些理念的,我们可以帮助自己理解基本概念。

我们已经了解并理解了来自巴贝奇分析机的 CPU 基本结构,因此这里我们将重点介绍相同总体设计的数字电子实现。像巴贝奇一样,我们会对曼彻斯特婴儿的实际细节和历史稍微有些松散的处理;重点是用它来体验数字 CPU 的基本特征。原始婴儿的数字电子元件是用真空管而非晶体管构建的,并且不一定使用我们今天重新实现其功能时所用的相同结构。尽管如此,我们研究过的简单机器可以用来构建婴儿的现代化实现。我们将在这里构建这样一个实现,能够运行实际程序,使用 LogiSim。但首先,我们将通过将婴儿作为用户进行编程,来了解婴儿需要具备哪些能力。

婴儿的程序员接口

与分析机不同,像婴儿这样的冯·诺依曼架构将指令和数据存储在相同的 RAM 空间中。一个程序因此就是一个指令列表,所有指令都会被复制到 RAM 中。每一行都有一个编号,它会被复制到该编号对应的 RAM 地址。像分析机一样,程序由二进制机器代码组成,对应着指令集中的一系列指令。

这是婴儿的完整指令集。我们将在接下来的章节中更详细地讨论每一条指令。

HLT 停止婴儿并点亮停止灯

LDN 加载指定地址的取反内容

STO 将最新结果存储到指定地址

SUB 从结果中减去指定地址的内容

JMP 跳转到给定地址中存储的行号

JRP 跳转到给定地址中存储的行数

SKN 比较结果:如果小于 0,则跳过下一条指令

注意

Baby 的设计者们真心希望能有一种像分析引擎和大多数现代计算机中那样的常规加载指令,用于加载存储在某个地址的数据副本。但由于当时的技术限制,他们不得不将其替换为 LDN,即“加载取反”,在加载数据时会反转每一位。这是 Baby 的一个著名特性,使得它的编程具有独特的解决问题的风格。

停止

HLT 指令使机器停止。这会阻止任何进一步指令的执行,并点亮一个灯泡,告诉用户工作已经完成,这样他们就知道何时检查结果。最简单的 Baby 程序就是这样:

01: HLT

指令左侧的行号 01 也是该指令将存储的 RAM 地址。当这个程序被加载到 Baby 的 RAM 中时,RAM 的地址 1 将包含 HLT 的二进制机器码。加载程序后,可以执行该程序。Baby 从地址 1 开始执行(不是从地址 0,因为程序计数器在每次取指之前都会增加 1),所以 HLT 指令将会执行,导致 Baby 停止运行。

常量

带有 NUM 的行并不是真正的指令,而是在代码首次加载到 RAM 中时,用来将 数据 放置到它们的地址上的。例如,考虑以下代码:

01: HLT
02: NUM 10
03: NUM 5
04: NUM 0

当这个程序加载到 RAM 中时,常量 10、5 和 0 将分别被放置到地址 2、3 和 4,而 HLT 指令则被放置到地址 1。

如果你实际运行这个程序,它将从第 1 行开始,执行 HLT 指令,并立即停止。这里的 HLT 指令非常重要;CPU 会按照顺序从地址 1 开始读取并执行指令,但我们放入地址 2、3 和 4 的值本意是作为数据使用,而不是指令。HLT 指令阻止 CPU 访问地址 2 及之后的地址,从而防止数据值被当作指令执行。

这种将程序和数据写在一起并存储在相同 RAM 中的方法,是冯·诺依曼架构的定义特征。在编程冯·诺依曼机器时,非常重要的一点是,我们只执行指令行,而不要尝试执行数据行。写出试图将数据当作程序的一部分执行的代码是一种编程错误,也就是 bug。

注意

数据的执行可能会导致不可预测和危险的行为。这就是为什么它常常作为一种安全攻击技术被使用:如果你想入侵他人的程序并执行你自己的代码,有时可以通过将代码作为数据输入到该程序中,然后以某种方式欺骗程序执行它。

加载与存储

上述代码中的常量从未进入 CPU;相反,整个代码在 CPU 启动之前通过其他机制被加载到给定的 RAM 位置中,位置是由行号指定的。为了在实际计算中使用 RAM 中的数据,我们需要执行加载和存储指令,它们将数据从 RAM 复制到 CPU,再从 CPU 复制回 RAM。

例如,以下 Baby 程序将地址 20 中的(取反后的)数字加载到 CPU,然后将其副本存储到地址 21:

01: LDN 20
02: STO 21
03: HLT
20: NUM -10
21: NUM 0

在这个例子中,数字–10 最初被放置在地址 20 处,但由于 Baby 的自动数据取反,它被加载到 CPU 时变为+10,即其逆值。然后,这个数字 10 被存储到地址 21,覆盖了最初存放在那里 的 0。请注意,可执行程序存储在地址 01 到 03 之间,并以HLT结束;更高的地址则用于数据存储,以避免执行数据的风险。

算术

Baby 只有一条算术指令:减法。它的工作原理类似于 Pascal 的计算器:首先使用加载指令将一个数字加载到 CPU 中,然后使用SUB指令将第二个数字从中减去。例如,以下程序计算 10–3=7:

01: LDN 20
02: SUB 21
03: STO 22
04: LDN 22
05: HLT
20: NUM -10
21: NUM 3
22: NUM 0

整数–10 和 3 在程序加载到内存时,分别由第 20 行和第 21 行放置到地址 20 和 21 中。第 1 行将(取反后的)整数从地址 20 加载到 CPU。第 2 行从中减去地址 21 中的整数。第 3 行将结果存储到地址 22,覆盖了最初存放在该位置的 0。

跳转

JMP指令使得程序执行跳转到指令中给定地址的地址所存储的数字加一的那一行。这一操作称为间接跳转,与直接跳转相对,后者会将目标地址本身编码为指令的一部分,而不是像在本例中那样,编码目标地址的位置。例如,考虑以下程序:

01: LDN 20
02: SUB 21
03: SUB 22
04: STO 20
05: JMP 23
06: HLT
20: NUM 0
21: NUM 0
22: NUM -1
23: NUM 0

这里,第 05 行的JMP 23指令会导致跳转到第 01 行,因为整数 0 存储在地址 23,而 1 是 0 之后的数字。由于JMP指令的存在,该程序会不断循环并永远运行下去。

分支

Baby 中的分支由SKN指令(跳过下一个)执行,该指令没有操作数。SKN会检查当前结果是否为负数。如果是,它会跳过下一条指令,继续执行其后的指令。SKN通常与下一条指令中的跳转(JMP)配合使用,创建类似于 if 语句的功能。如果结果为负数,则SKN会跳过下一行的JMP指令,程序会从下一行继续执行。如果结果为正数,则会执行跳转,程序会继续在其他地方运行。例如,考虑以下 Baby 程序:

01: LDN 20
02: STO 23
03: SKN
04: JMP 21
05: LDN 22
06: SUB 23
07: STO 23
08: HLT
20: NUM -10
21: NUM 6
22: NUM 0
23: NUM 0

这个程序计算来自地址 20 的整数输入的绝对值,并将结果存储在地址 23 中。也就是说,如果输入为-10 或 10,输出将是 10;任何负号都会被去掉。第 03 行和第 04 行是SKN-JMP指令对。

汇编器

我们所看过的宝宝程序——以及之前看到的分析机程序——是使用人类可读的 ASCII 符号编写的,这些符号拼写出了像LDN(表示“加载取反”)这样的助记符,并使用十进制或十六进制数字。这些符号被称为汇编语言,或简称汇编。CPU 并不理解这些符号;它们需要将这些符号转换为二进制编码,即机器码

对于分析机,机器码的形式是打孔卡上的孔位,程序员需要手动将人类可读的助记符转换成这些二进制孔位,然后才能运行程序。类似地,对于冯·诺依曼架构的机器(如宝宝),程序需要先被转换成二进制机器码,然后加载到 RAM 中,CPU 才能执行它们。最初的宝宝程序员必须手动完成这些操作,使用铅笔计算机器码,然后通过一套电子开关系统将机器码复制到 RAM 中,再启动 CPU。

如果你今天正在为宝宝或任何其他计算机编写汇编程序,你不需要手动进行转换;还有其他程序,称为汇编器,能够自动化这个过程,将人类可读的汇编程序翻译成机器码给你。一个由 0 和 1 组成的文件,对应机器码,被称为可执行文件,因为它可以在复制到 RAM 后直接由 CPU 执行。对于同一目标机器,可以使用多种汇编语言。例如,它们可能使用不同的助记符来表示指令(就像本书中与其他宝宝实现相比所做的那样)。

宝宝机的机器码每条指令使用一个 32 位的字。最低的 13 位被称为操作数,用于编码指令使用的数值(对于不带数值的指令,这部分会被忽略)。接下来的 3 位称为操作码,用于编码指令类型,直接通过汇编助记符的翻译得到,如表 7-1 所示。剩下的 16 位被忽略。

表 7-1: 曼彻斯特宝宝操作码

操作码 助记符
0 JMP
1 JRP
2 LDN
3 STO
4 SUB
5 SUB
6 SKN
7 HLT

以下是一个用 Python 编写的宝宝汇编器。如果你懂 Python,你将看到如何使用字典将指令转换为操作码,以及如何对操作数进行十进制、十六进制和二进制之间的转换。

import re
f = open("TuringLongDivision.asm")
for_logisim = False #change to True to output hex for logisim RAM
dct_inst = dict()
dct_inst['JMP'] = 0
dct_inst['JRP'] = 1
dct_inst['LDN'] = 2
dct_inst['STO'] = 3
dct_inst['SUB'] = 4
dct_inst['SKN'] = 6
dct_inst['HLT'] = 7
loc = 0
if for_logisim:
  print("v2.0 raw")    #header for logisim RAM image format
def sext(num, width):
    if num < 0:
        return bin((1 << (width + 1)) + num)[3:]
    return bin(num)[2:].zfill(width)
def out(binary):
  if for_logisim:
    print(hex(int(binary,2))[2:].zfill(8))
  else:
    print(binary[::-1]) #Baby convention: show bit 0 on the left
for line in f:
    asm = re.split('\s*--\s*', line.strip())[0]
    parts = asm.split()
    thisloc = int(parts[0][:-1])
    if parts[1] == 'NUM':      #data line
        code2 = sext(int(parts[2], 10), 32)
    else:                      #instruction line
        inst2 = bin(dct_inst[parts[1]]).zfill(3)[2:]
        if len(parts) < 3:
            parts.append('0')
        operand2 = sext(int(parts[2], 10), 13)
        code2 = (inst2 + operand2).zfill(32)
    for addr in range(loc, thisloc):
      out('0'.zfill(32)) #fill in zeros where lines not given
    out(code2)
    loc = thisloc + 1

以下是阿兰·图灵在曼彻斯特测试和记录宝宝时编写的长除法宝宝程序:

00: NUM 19   -- jump address
01: LDN 31   -- Accumulator := -A
02: STO 31   -- Store as -A
03: LDN 31   -- Accumulator := -(-A) i.e., +A
04: SUB 30   -- Subtract B*2^n ; Accumulator = A - B*2^n
05: SKN      -- Skip if (A-B*2^n) is Negative
06: JMP 0    --   otherwise go to line 20 ( A-B*2^n >= 0 )
07: LDN 31   -- Accumulator := -(-A)
08: STO 31   -- Store as +A
09: LDN 28   -- Accumulator := -Quotient
10: SUB 28   -- Accumulator := -Quotient - Quotient (up-shift)
11: STO 28   -- Store -2*Quotient as Quotient (up-shifted)
12: LDN 31   -- Accumulator := -A
13: SUB 31   -- Accumulator := -A-A (up-shift A)
14: STO 31   -- Store -2*A (up-shifted A)
15: LDN 28   -- Accumulator := -Quotient
16: STO 28   -- Store as +Quotient (restore shifted Quotient)
17: SKN      -- Skip if MSB of Quotient is 1 (at end)
18: JMP 26   --   otherwise go to line 3 (repeat)
19: HLT      -- Stop ; Quotient in line 28
20: STO 31   -- From line 6 - Store A-B*2^n as A
21: LDN 29   -- Routine to set bit d of Quotient
22: SUB 28   --   and up-shift
23: SUB 28   --   Quotient
24: STO 28   -- Store -(2*Quotient)-1 as Quotient
25: JMP 27   -- Go to line 12
26: NUM 2    -- jump address
27: NUM 11   -- jump address
28: NUM 0    -- (Answer appears here, shifted up by d bits)
29: NUM 536870912 -- 2^d where d=31-n, see line 30 for n
30: NUM 20   -- B (Divisor*2^n) (example: 5*2²=20)
31: NUM 36   -- A (initial Dividend) (example: 36/5=7)

以下是图灵程序的机器码,由 Python 汇编器生成:

11001000000000000000000000000000
11111000000000100000000000000000
11111000000001100000000000000000
11111000000000100000000000000000
01111000000000010000000000000000
00000000000000110000000000000000
00000000000000000000000000000000
11111000000000100000000000000000
11111000000001100000000000000000
00111000000000100000000000000000
00111000000000010000000000000000
00111000000001100000000000000000
11111000000000100000000000000000
11111000000000010000000000000000
11111000000001100000000000000000
00111000000000100000000000000000
00111000000001100000000000000000
00000000000000110000000000000000
01011000000000000000000000000000
00000000000001110000000000000000
11111000000001100000000000000000
10111000000000100000000000000000
00111000000000010000000000000000
00111000000000010000000000000000
00111000000001100000000000000000
11011000000000000000000000000000
01000000000000000000000000000000
11010000000000000000000000000000
00000000000000000000000000000000
00000000000000000000000000000100
00101000000000000000000000000000
00100100000000000000000000000000

在这个二进制机器码的显示中,位的位置是按从左到右的顺序打印的第零位(与现代惯例相反),所以操作码出现在每个字的中间偏左的三个位中,操作数在它们的左侧。这是历史上 Baby 使用的格式,因此我们可以使用这种约定输入机器码。

测试程序和机器码各有 32 行,这样它们恰好并且明确地填充了 Baby 的 32 个内存地址。程序员需要将某些内容放入所有 32 个地址,因此未使用的地址需要显式填充为零。请注意,行号并未编码在机器码中;而是指定机器码将放置在哪个地址。另外,请注意,数据行被转换为单个 32 位数字,因为 NUM 不是指令,而只是一个注释,告诉汇编器该行包含原始数据。Baby 使用补码表示,因此像 ffff0000 这样的十六进制值表示负整数。

注意

直到 1990 年代中期,许多大型应用程序和游戏是由人工程序员使用汇编语言编写的,包括 街头霸王 II 和 RISC OS 操作系统。大多数现代编程不再使用汇编语言,而是使用像 C、C++ 或 Python 这样的高级语言编写。用这些语言编写的程序首先通过编译器转换为汇编代码,然后由汇编器汇编。

Baby 的内部结构

现在我们将讨论 Baby 的内部结构。与分析引擎一样,我们首先介绍数字 CPU 中的子组件,然后考虑它们如何相互作用以执行程序。主要的数字 CPU 子结构与分析引擎完全相同:寄存器、算术逻辑单元(ALU)和控制单元(CU)。它们在功能上与分析引擎中的相同,但它们是由我们在上一章学习的数字逻辑简单机器构建的,而不是由巴贝奇的机械简单机器构建的。

我们在这里不会完全按照原始曼彻斯特 Baby 的实现方式进行,而是展示可以用于更现代风格实现 Baby 程序员接口的通用数字逻辑实现。这些实现由数字逻辑的简单机器构建,而这些简单机器又是由逻辑门构建的,后者可以使用现代晶体管或 Baby 原始的真空管同样很好地实现。

寄存器

寄存器是快速的字长内存,通常今天由 D 型触发器阵列组成,位于 CPU 内部,控制单元(CU)和算术逻辑单元(ALU)可以读取和写入它们。大多数 CPU 包含几种不同类型的寄存器,用于不同的目的。

CPU 中寄存器的大小通常用来定义 CPU 的字长;例如,8 位机器使用 8 位字,它们存储在 8 位寄存器中,而 32 位机器使用 32 位字,它们存储在 32 位寄存器中。从这个角度看,Baby 是一台 32 位机器。字使用在 第二章 中看到的数据表示方法,需要一个比特数组来存储数字、文本和其他数据。

就像组成寄存器的各个触发器一样,寄存器必须同步时序,以确保读写的正确同步。一个更新信号可以发送到构成寄存器的所有触发器的时钟输入。通常对寄存器的写入是在该信号的上升沿进行的。每个寄存器还会持续输出其最新存储的值,供读取,作为一组并行电线,无论是否有更新。寄存器结构如 图 7-1 所示。写入操作是在按下按钮时触发的。

Image

图 7-1:由触发器组成的 3 位寄存器

寄存器也可以使用单个符号表示,暗示由触发器堆叠而成,如 图 7-2 所示。

Image

图 7-2:作为单个符号的 4 位寄存器,连接到输入开关、写入更新按钮和输出 LED

在这里,D 输入和 Q 输出是各自的一组电线,显示为粗线条,然后分成单根电线。

用户寄存器

用户寄存器 通常是汇编语言程序员可以看到的唯一寄存器,他们可以向其内容发出指令。

累加器 是一个既作为输入又用于存储计算结果的用户寄存器。如我们所见,Pascal 的计算器就是一个大型累加器,因为它既存储加法的输入之一,也存储其结果,在此过程中销毁输入的原始表示。你的桌面计算器也是一个累加器:它只存储一个数字,即当前显示在屏幕上的结果,你可以将其加上或乘以,并更新存储结果。例如,如果你输入 2,它会被存储在累加器中。如果你再输入 +3,累加器会存储并显示结果 5。原始值 2 和操作 +3 会丢失,只有累加的结果可用。

累加器架构 是指只有一个用户寄存器作为累加器的架构。Baby 就使用这种简单的架构风格。这迫使所有计算都必须在累加器样式中进行,因为没有其他寄存器来将输入与输出分开。相反,更复杂的 CPU 可能除了累加器外,还会有其他用户寄存器。

内部寄存器

除了用户寄存器外,大多数 CPU 还需要一些额外的寄存器来执行内部操作。这些 内部寄存器 可能对用户不可见,因此你无法编写访问或修改它们的汇编程序。相反,它们用于使 CPU 本身工作,并使其能够读取和执行用户程序。让我们来看一下两个最重要的内部寄存器。

CPU 需要跟踪它当前在程序执行中的位置。在分析引擎中,程序的当前行通过机械的程序卡片读取器状态来存储。就像打字机纸一样,程序通过机械方式快进和倒带,以便将当前行定位到读取器上。在电子 CPU 中,没有机械移动的状态,因此我们必须通过将当前行号存储在一个寄存器中来跟踪程序的位置,这个寄存器被称为 程序计数器(在本章的列表中为 PC)。正如我们所见,冯·诺依曼架构——例如 Baby 计算机和大多数现代计算机——将程序存储在主存储器中,连同其他数据一起存储,因此这些“行号”实际上是存储程序指令的内存地址。

指令寄存器(IR) 存储当前指令的副本,该副本是从其地址(如程序计数器中保存的地址)在内存中复制过来的。

算术逻辑单元

就像分析引擎的 ALU 一样,基于数字逻辑的 ALU 由一组简单的机器组成,每个机器执行一种算术操作。由于硬件上的一个特殊情况,原始的 Baby 计算机只有一个减法器,但在这里我们将构建一个更通用且强大的 ALU,其中还包括加法、乘法和除法。 图 7-3 展示了一个具有这些操作的 32 位 ALU。

图片

图 7-3:一个 32 位的四操作 ALU

这里有两个数据输入,ab,每个输入都包含一个 32 位的二进制补码整数。它们都被发送到四个简单的算术机器——减法器、加法器、乘法器和除法器,每个机器都计算一个输出。然后只选择其中一个输出并将其传递到 ALU 的输出 r。要选择你想要执行的算术操作,将其 2 位代码放置在 c 输入上。解码器随后激活四个 32 位多路复用器中的一个,使得所需操作的输出可以传递到 32 位的 OR 门阵列中。最终输出从 OR 门阵列传递到 比较器,后者测试该数字是否为零,并输出一个带有该布尔值的单一状态标志。

比较器可以通过将数字中的所有位使用 NOR 门连接在一起来简单地实现。更先进的 ALU 通常会测试结果的其他有趣属性,比如是否为正数或负数,是否发生了溢出(可以在简单机器的进位线中看到),或者是否发生了除零错误;然后它们会输出一组标志,而不仅仅是零测试。

请注意,我们可以通过使用一个具有 2 比特输入的单一多路复用器直接在四个算术机器之间进行选择,从而减少硅的使用。通过在操作之间共享结构,可以减少数字逻辑的重复——例如,使用二补码使得加法器可以作为减法器重复使用。你可能还会担心在每组输入上运行所有算术选项并丢弃所有结果,除了一个结果之外,造成的能量浪费。你可以通过重新设计网络来减少这种能量消耗。然而,我选择了当前的结构是出于教育目的,因为它将帮助你在下一节中更容易理解控制单元。

控制单元

数字逻辑控制单元(CU)实现了与巴贝奇计时桶相同的概念,像指挥家一样在正确的时间触发所有其他 CPU 组件。实现这一功能的方法有很多种,因此控制单元的设计比寄存器和 ALU 更加多样化。它们通常被认为是 CPU 设计中最难且最核心的部分。为了易于理解,我们在这里选择一种特定风格,而非为了计算或能效。

这种风格基于两个结构:首先是一个计数器,像巴贝奇的计数桶一样定期旋转,其值用于定时所需的事件;其次是一个开关机制,用于确定触发事件的类型,并根据需要在组件之间建立临时连接——例如寄存器、算术逻辑单元(ALU)和随机存取存储器(RAM)。在巴贝奇的机器中,这些连接是通过机械杠杆来实现的。对于我们的数字逻辑版本,我们将使用多路复用器,就像在 ALU 中那样。这些多路复用器有两个数据输入,每个输入的字长为 32 位(对于 Baby 计算机而言)。其中一个接地为零,另一个来自临时输入源。它们有一个单比特开关,用于在将临时输入传送到输出和将全零传送到输出之间进行切换。图 7-4 展示了这一工作原理。

Image

图 7-4:用于启用或禁用 32 根电缆连接的多路复用器

如同在 ALU 中,多个源可能连接到同一个目标,它们每个都会有自己的多路复用器。然后,使用一个或门(OR)阵列将这些多路复用器的输出组合起来,允许非零输出通过。

我们将创建一个组件之间的临时连接序列。其中一些连接仅通过计数器上显示的时间来触发。这可以通过解码器来实现,解码器将时间作为输入,并激活一个特定的触发线作为输出。其他连接需要通过时间与其他值的组合来触发,例如当前指令的标识。这些可以通过将适当的触发线与代表其他所需条件的信号进行与门(AND)连接来实现。图 7-5 展示了这种结构的一个示例。

Image

图 7-5:基于 3 位计数器、解码器和多路复用器开关的最简 CU

左侧的 3 位计数器和解码器构成了序列发生器。使用 3 位可以给我们 2³ = 8 个时间段,从 0 到 7,每个 7 后循环回 0。图中只显示了在 0、1 和 7 时刻的触发信号。在时刻 0 和 1 的触发信号(来自解码器的上两个输出)仅依赖于时间,并在 32 位电缆组之间建立连接。在时刻 7,有两个可能的触发信号,分别依赖于条件 cond1 和 cond2 是否满足,这些条件由图左下角的两个开关表示。请注意,这些条件(因此触发信号)可能处于激活、未激活或同时激活的状态。这里的 OR 符号代表 32 个 OR 门的阵列。它允许将两个不同的输入连接到不同触发信号上的同一共享输出(SO)。(图中省略了时刻 2 到 6 的触发信号,但你可以想象从解码器出来的电缆连接到类似的触发信号。)

现在我们在此引入一些额外的符号,以帮助使我们的图示更易读。图 7-6 显示了完全相同的最简 CU,但引入了隧道,这些隧道是命名的点(t0 到 t7 和 c0 到 c1),取代了电缆。所有具有相同名称的隧道被假定是相互连接的。例如,从解码器出来的 t0 隧道连接到图中右上角靠近多路复用器的 t0 隧道。

ImageImage

图 7-6:与图 7-5 相同的最简 CU,使用隧道符号重新绘制

这种隧道符号避免了绘制复杂的电缆图,避免了 CU 在整个 CPU 中发送触发信号所形成的“鼠巢”电线。我们还将 3 位计数器封装成了一个单独的模块 CTR3,这是 LogiSim 现成提供的。(这个模块有一些额外的输入和功能,在这里我们没有使用。)

将所有内容结合起来

现在我们已经看到了 Baby 实现的每个基本组成部分,让我们将它们全部结合起来——并适当安排 CU 的动态——来构建一个完整的、功能齐全的 Baby。我们将依次考虑操作的三个主要阶段——取指、解码和执行,就像我们在第三章讨论分析引擎时所做的那样。

取指

取指阶段的目的是将下一条指令的副本从 RAM 带入 CPU 的 IR。取指假设下一条指令的地址已经在程序计数器中。当 CPU 首次开启时,程序计数器——像所有寄存器一样——初始化为 0,但立即递增到 1,因此第一条指令必须存储在地址 1,并会被取出。

为了执行取指操作,程序计数器在第 1 个时钟周期暂时连接到 RAM 的地址线。RAM 的数据输出线可以永久连接到 IR 的数据输入,但只有在写使能且在第 2 个时钟周期时,IR 才会从这些线复制字数据。图 7-7 中的网络设置为执行 Baby 的 32×32 RAM 的取指操作(32 = 2⁵个地址,每个地址包含一个 32 位字),通过在其八个计数周期的第 1 和第 2 个时钟周期建立这些连接,并在其他步骤中断开连接。在我们的 Baby 中,程序计数器是一个 5 位寄存器,IR 是一个 32 位寄存器。

Image

图 7-7:取指操作,在第 1 和第 2 个时钟周期触发

我们可以将取指序列写成:

t1: RAM_A <- PC
t2: IR <- RAM_Dout

这种符号风格是一种寄存器传输语言(RTL)。每行冒号前的符号是触发器,在本例中是第 1 和第 2 个时钟周期。箭头表示当触发器处于活动状态时,从源到目的地的临时连接被建立。因此,箭头对应于我们实现风格中使用的多路复用器,而触发器对应于这些多路复用器的切换输入。

注意

RTL 不是汇编语言或机器码。它是 CPU 工作方式的低级描述,其功能最终是执行由用户编写并存储在 RAM 中的机器码程序。

解码

我们现在有了下一条指令的副本,它存储在 IR 中。它由一组机器码组成,其中一些位指定了操作码,其他位可能包含一个、零个或多个操作数。在 Baby 中,第 13 至 15 位是操作码,第 0 至 12 位是某些指令的单一操作数,剩下的 16 位未被使用。现在需要对这种编码进行解码。我们需要将操作码和操作数分开,然后将操作码转换为激活信号。图 7-8 展示了我们的实现。

Image

图 7-8:解码操作,在第 2 个时钟周期触发

IR 输出首先被分为三组线,分别对应前 13 位、接下来的 3 位和剩余的 16 位。中间的 3 位包含操作码,它们连接到一个 3 位解码器。解码器激活其 2³ = 8 个输出线之一,这些输出线连接到一个隧道,并将作为触发其他步骤中的多路复用器的条件。这些隧道被命名为它们对应的汇编语言助记符。IR 的 13 个操作数字段进一步分为 5 个低位,用于地址选择,并将在稍后连接相应的线路,以及 8 个高位,这些高位没有地址用途,因此会被忽略。

这里没有使用顺序逻辑,因此一旦 IR 内容在第 2 个时钟周期更新,解码过程几乎是瞬间完成的。

执行

与获取和解码不同,执行阶段发生的事情取决于已获取和解码的指令。不同的指令指定不同结构的激活,这些结构执行不同的操作:加载、存储、算术操作和程序流程控制。我们将依次查看如何执行这些可能的操作。

加载

要执行加载操作,我们在第 3 时钟周期临时将操作数连接到 RAM 的地址输入,然后在第 4 时钟周期将 RAM 的数据输出临时连接到累加器(Acc)。这些时钟周期被选择在前一个获取和解码操作之后。我们可以将其写成 RTL 风格如下:

t3, LDN: RAM_A <- IR[operand]
t4, LDN: Acc <- -RAM_Dout

请注意,冒号前的触发条件现在包括时钟周期和 LDN 条件。在 IR[operand] 中的方括号表示只使用 IR 的操作数字段,而不是整个寄存器内容。

图 7-9 显示了我们 Baby 实现的加载数字逻辑。(由于 Baby 的加载操作还会对加载的值进行取反,因此我们将 RAM 数据通过一个取反器传送到累加器。这在现代计算机中通常不会这样做。)

在像 Baby 这样的累加器架构中,加载操作总是将数据从 RAM 放入累加器寄存器。在具有更多用户寄存器的更复杂架构中,需要一个额外的操作数来指定目标寄存器,并且需要更多的数字逻辑来将正确的寄存器连接到数据线。

存储

将值从 CPU 存储到内存的过程与加载相似,但相反。在 Baby 中,要存储的值始终来自累加器。

在第 3 个时钟周期,我们临时将 STO 指令的操作数(存储地址)连接到 RAM 的地址线。累加器的输出可以永久连接到 RAM 数据输入,但仅在第 3 个时钟周期时启用写入。该 RTL 为:

t3, STO: RAM_A <- IR[operand]
t3, STO: RAM_Din <- Acc

图 7-10 显示了实现这一操作的 Baby 数字逻辑。

Image

图 7-9:执行加载操作,在第 3 和第 4 时钟周期触发

Image

图 7-10:执行存储操作,在第 3 时钟周期触发

在具有更多用户寄存器的架构中,可以使用另一个操作数来指定要存储的寄存器内容,然后需要更多的切换逻辑来将正确的寄存器连接到数据线。

算术

要执行 ALU 操作,CU 会临时连接 CPU 寄存器到 ALU 的输入,并创建并发送 ALU 命令到 ALU 的命令输入。ALU 的输出会临时连接到目标寄存器。

Baby 的算术逻辑单元(ALU)特别简单,因为它只包含一个减法器。SUB 指令触发从 RAM 读取数据,类似于加载指令,但 RAM 数据被发送到减法器,而不是累加器。减法器将其另一个输入来自累加器,并将输出写回累加器。

图 7-11 展示了我们的 Baby ALU 实现。RAM 读取在第 3 个时钟周期触发,累加器更新在第 4 个时钟周期触发。减法器位于图的最左侧。

图片

图 7-11:执行 ALU 操作,在第 3 和第 4 时钟周期触发

这也可以用 RTL 描述为:

t3, SUB: RAM_A <- IR[operand]
t4, SUB: Acc <- Acc - RAM_Dout

更复杂的架构,拥有比减法更多的算术操作,会将它们打包成一个单一的 ALU 结构,通过选择线来指定激活哪个操作,就像在图 7-3 中看到的那样。解码器随后需要识别多个不同的算术操作码,并通过某些逻辑将每个操作路由到对应的选择逻辑。

流程控制

在每条指令开始时,Baby 会移动到程序的下一个地址(行)。这可以通过在第 0 个时钟周期将程序计数器加 1 来实现。

如果当前指令是流程控制指令——即跳转或分支——则其执行步骤也需要更新程序计数器,以便为下一条指令做好准备。

现代(直接)跳转指令的操作数中包含跳转到的行号,因此它们可以通过将操作数直接复制到程序计数器来实现。然而,正如我们所看到的,Baby 使用了间接跳转指令JMP,其中操作数包含一个地址,该地址进一步包含实际的跳转目标。为了实现这种间接跳转,我们首先在第 4 个时钟周期将操作数附加到 RAM 地址线,然后在第 5 个时钟周期将 RAM 数据线连接到程序计数器。

Baby 还有一个相对跳转JRP,其工作方式类似于JMP,只不过操作数中的地址包含了一个表示程序计数器应该前进多少行的数字,而不是一个绝对地址。

对于分支指令SKN,我们检查其条件,如果为假,则按常规行为执行;如果为真,则额外增加一次程序计数器,以跳过一行代码。(通常跳过的代码行是由程序员选择的,通常是跳转到程序的其他部分。)为了实现这一点,我们将累加器的输出发送到一个比较器,该比较器测试累加器是否小于零。该条件的真假随后(借助布尔代数)作为整数 0 或 1 使用,在分支指令激活时,在第 5 个时钟周期加到程序计数器上。

如果当前指令不是控制流指令(即它是SUBLDNSTO),则程序计数器不会再发生任何变化。这通过在第 5 个时钟周期将程序计数器的输出直接连接到其输入来实现。

图 7-12 展示了我们 Baby 实现的流程控制。用 RTL 表示法,这对应于:

t0: PC <- PC + 1
t4, JMP: RAM_A <- IR[operand]
t4, JRP: RAM_A <- IR[operand]
t5, SKN, (Acc<0): PC <- PC + 1
t5, JMP: PC <- RAM_Dout
t5, JRP: PC <- PC + RAM_Dout

一旦程序计数器通过上述任一方式更新,取指-解码-执行周期就完成了,一切准备就绪,下一周期可以开始。

图片

图 7-12:程序流程控制,触发时刻为 0、4 和 5

完整的 Baby 实现

图 7-13 显示了我们完整的、可工作的 Baby CPU,所有上述系统都一同展示。在左下角,它增加了一个寄存器和灯,当 halt 指令被执行时,它们会被激活,防止任何进一步的执行。如果你厌倦了手动触发时钟,它还增加了一个开关,将时钟信号连接到振荡器。

Image

图 7-13:一个完整、可工作的 Baby 实现,包括序列发生器、取指、解码、执行和控制流逻辑。现在 RAM 内容已完整显示。

t0: PC <- PC + 1
t1: RAM_A <- PC
t2: IR <- RAM_Dout
t3, LDN: RAM_A <- IR[operand]
t3, STO: RAM_A <- IR[operand]
t3, STO: RAM_Din <- Acc
t3, SUB: RAM_A <- IR[operand]
t4, SUB: Acc <- Acc - RAM_Dout
t4, LDN: Acc <- -RAM_Dout
t4, JMP: RAM_A <- IR[operand]
t4, JRP: RAM_A <- IR[operand]
t5, SKN, (Acc<0): PC <- PC + 1
t5, JMP: PC <- RAM_Dout
t5, JRP: PC <- PC + RAM_Dout

我们现在已经拥有了一台完整的数字逻辑计算机,能够在 RAM 中执行机器码程序。

小结

数字逻辑 CPU 的目的是执行机器码程序,这些程序可以通过人类可读的汇编语言进行组装。这些程序需要在 CPU 开始工作之前被加载到内存中。它们由一系列指令组成,这些指令依次被读取到 CPU 并执行。

CPU 最初可能会让那些试图理解它的人感到害怕。即使像我们的 Baby 这样的最小示例也可能需要成千上万的晶体管;现代的 CPU 芯片则可以包含数十亿个晶体管。但你在本章中已经看到,如果从层次化的角度思考,就不会觉得基本结构那么复杂,像一个建筑师那样思考。从这个角度来看,你已经学会了如何构建多种执行基本任务的简单机器;然后一个基础的 CPU 只是将少数这些简单机器连接起来。

控制单元(CU)可以由一个序列发生器构成,它触发取指、解码和执行阶段。执行阶段是最难实现的,因为它涉及根据解码的指令执行不同的操作。因此,执行阶段的子步骤需要一些额外的逻辑来激活不同的选项。

本章大致展示了曼彻斯特 Baby 是如何被构建的,并且它是如何被拼装的。我们所构建的架构仍然构成了许多现代 CPU 的基本计划。然而,摩尔定律的压力使得这一计划变得更加复杂。它们阻止了现代机器仅仅通过提高时钟速度来提升性能,但却允许它们使用更多的晶体管。在下一章中,你将看到现代 CPU 如何利用这些额外的晶体管进行更复杂的应用。

练习

构建一个 Baby

  1. 在 LogiSim Evolution 中构建 图 7-13 中的 Baby 设计。

  2. 一旦你开始在这种复杂度的顺序逻辑层面上工作,很容易且常见会在触发定时上产生硬件错误。硬件架构师通常会花费大量时间调试定时问题。硬件调试器的对应工具是时序图(图 7-14),它绘制了系统中几条电线随时间变化的状态。LogiSim Evolution 有一个内建的工具来生成这些时序图(模拟 ▸ 时序图)。了解如何使用它来测试 Baby 中的一些顺序子电路。回想一下,顺序逻辑——RAM 和寄存器的写使能,RAM 读取地址——通常在时钟信号从 0 上升到 1 的瞬间触发,而组合逻辑则始终处于活动状态。也有硬件逻辑分析仪可以捕捉并显示来自面包板的类似数据,无论是独立使用还是将数据发送到你的 PC 进行分析。

Image

图 7-14:LogiSim 时序图

编程 Baby 计算机

  1. 汇编本章讨论的测试程序——包括图灵的除法程序——并在 LogiSim Baby 中运行它们。使用提供的 Python 汇编器,确保第 3 行的 for_logisim 标志设置为 True。将输出保存在文本文件中,并通过右键点击 RAM 选择 加载映像 将其作为 RAM 映像加载到 LogiSim 中。你可以通过点击时钟手动步进 CPU 循环,或者通过在菜单栏中选择 模拟 ▸ 自动步进 来设置时钟自动跳动。图灵的程序将 36 除以 5,结果是 7(111[2]),并将其存储——用零填充——在地址 28,所以它显示为 E000000[16]。尝试编辑第 29 到 31 行执行不同的除法操作。

  2. 你能解释一下图灵的代码是如何工作的?记住,Baby 计算机有两个主要特点:加载时会否定值,并且它只有一个减法器,而不是加法器。这些特点导致了一些编程习惯。

具有挑战性

我们在 CPU 设计中使用了多个层次的符号抽象:将晶体管、门电路和简单的机器打包成盒子。估算一下在我们的最终设计中使用了多少逻辑门,再估算一下使用了多少晶体管。这些晶体管的数量与第一章中实际历史设计使用的数量相比如何?如果我们更倾向于使用较少硅片而非容易理解的教育性设计,如何减少这些数量?

更具挑战性

Baby 计算机虽然很小,且结构简单,但通过修改我们的设计,它是有可能扩展成一台相当强大的现代计算机的。尝试按照以下步骤进行操作。

  1. 增加 RAM 的大小。为此,你需要在整个设计中增加地址的大小。

  2. 将宝贝的 LDN 指令替换为更常见的 LOAD 指令,该指令仅加载而不进行取反。或者,如果你想保留与旧代码的向后兼容性,可以保留 LDN 并创建一个新的 LOAD 指令。这会增加更多的复杂性和硅片,但能让现有用户满意,因此这也是架构师面临的典型困境。

  3. 将单一的减法模块替换为完整的二进制补码整数 ALU,包括加法、减法、乘法和除法。创建额外的指令以触发这些操作。

  4. 查看后来的曼彻斯特 Mark I 和费兰蒂 Mark I 机器,了解原始宝贝计算机如何扩展到商业化。尝试在 LogiSim 中模拟它们。

进一步阅读

  • 关于我们所拥有的最接近曼彻斯特宝贝的官方现代手册,请参阅曼彻斯特大学当前的网页,* curation.cs.manchester.ac.uk/computer50/www.computer50.org/mark1/prog98/ssemref.html*。

  • 有关描述真正的宝贝计算机的原始文献,请参见 F.C. Williams、T. Kilburn 和 G.C. Tootill 在《Proceedings of the IEE Part II: Power Engineering》第 98 卷,第 61 期(1951 年):第 13–28 页中的“通用高速数字计算机:一种小规模实验机器”一文。

  • 有关后期曼彻斯特 Mark I 的详细信息,请参见 R.B.E. Napper 在《The First Computers: History and Architectures》一书中的“曼彻斯特 Mark 1 计算机”(编辑 Raúl Rojas 和 Ulf Hashagen,剑桥,马萨诸塞:MIT 出版社,2000 年),第 365–377 页。

第十章:## 高级 CPU 设计

图片

上一章介绍了数字逻辑中的最小化 CPU 设计。在本章中,我们将探讨如何扩展该基础设计以提高性能。这些扩展包括使用更多寄存器,使用堆栈架构来提高子程序能力和速度,添加中断请求以启用 I/O 和操作系统,浮点硬件,以及流水线和乱序执行以支持“超标量”执行,每个时钟周期执行多个指令。在这种复杂性级别上,我们不会详细介绍如何使用数字逻辑实现这些扩展,但你可以尝试自己动手!

用户寄存器的数量

如我们所讨论的,Baby 是一种累加器架构的例子,这意味着它只有一个可供用户访问的寄存器:累加器。所有的加载操作都进入累加器,所有的存储操作都从累加器中取出,当我们进行两个元素的算术运算时,比如减法,第一个元素来自累加器,第二个直接来自 RAM,就像加载操作一样。

累加器架构相对简单实现,并且产生简单的指令集。加载、存储和算术指令每次只需要一个操作数。例如,为了将存储在地址 50A3[16] 和 463F[16] 的数字相加,我们首先将第一个地址的内容加载到累加器中,然后执行“累加加法”(AADD) 指令,将第二个地址的内容加到累加器中:

LOAD $50A3
AADD $463F

一旦这些指令执行完成,累加器将包含加法的结果。

另一方面,累加器架构要求每次需要使用数据时,数据都必须在 CPU 内外移动。这可能会使系统变慢,因为 RAM 通常比 CPU 慢。为了避免这种减速,提供额外的用户寄存器在 CPU 内部可能会有所帮助。这些额外的寄存器允许一次将多个数据载入 CPU,从而使得可以在不需要进一步访问 RAM 的情况下执行多个计算。20 世纪 80 年代的 8 位计算机通常只有少量的额外用户寄存器,而现代计算机可能拥有几十个甚至上百个用户寄存器。

特别是在科学数值计算中,汇编程序员的理想通常是在计算开始时将所有相关数据加载到多个寄存器中;这允许在 CPU 内部进行大量繁重的数字运算,而无需进一步访问内存。从某种意义上说,拥有更多寄存器使得汇编编程更加容易,也使得编写更快速运行的程序成为可能。

然而,关于 CPU 应该有多少个用户寄存器总是存在权衡,因为额外的寄存器是有代价的:它们占用了大量额外的硅,这增加了设计和制造的成本。它们还更大,使用更多的能量。然后是指令集的复杂性增加,这反过来要求控制单元(CU)中使用更多的硅,同样增加了成本。同样,指令集的复杂性增加使得汇编程序员的工作变得更加复杂,无论是人类程序员还是编译器。现在,加载、存储和算术指令需要额外的操作数来指定哪些寄存器或寄存器组将被使用,例如:

LOAD  X, $50A3    // load into register X from address 50A3
STORE $463F, Y    // store to address 463F from register Y
ADD   Z, $463F, Y // add register Y to data from 463F, store in register Z

一些架构让我们两者兼得:它们提供了专用的累加器寄存器和累加算术指令,以及一组常规的用户寄存器。这使得汇编程序员可以利用累加器上的可能更简单、更快速的指令,同时保留与其他寄存器一起工作的灵活性。

指令数量

CPU 的可用指令集,被称为其指令集架构(ISA),定义了程序员可以看到和使用的内容与需要由 CPU 设计师实现的内容之间的接口。与任何接口一样,设计 ISA 总是涉及权衡。在这种情况下,存在着在让汇编语言程序员(或如今更常见的编译器编写者)生活变得轻松愉快与让数字逻辑实现者的工作更容易且无错误之间的权衡。一个包含符合人类思维方式的指令的 ISA 更容易编程和编写编译器,但可能很难在数字逻辑中实现。一个反映在数字逻辑中最容易实现的 ISA 则容易构建和测试,但可能不容易编程或编译。而且,也存在着让人类汇编程序员开心与让编译器编写者开心之间的权衡。

CISC 和 RISC 是两种历史上对立的架构哲学。大多数系统实际上以不同的方式模糊了这两者的元素,但 CISC 与 RISC 的区分仍然有助于结构化我们的思维,并考虑哪些实际设计方面更具“CISC”或更具“RISC”特征。

CISC,发音为“sisc”,代表复杂指令集计算。CISC 强调在 ISA 中创建大量指令。这些指令可以包括对基本指令的多种变体,每个变体都作为新的指令。例如,加载、存储和加法可以通过不同的指令以不同的方式进行。CISC 风格还可能创建执行比我们目前见过的更复杂算术运算的新指令,例如在科学计算器中找到的那类指令,甚至是用于信号处理或加密的特定操作的专用指令。

争论的对立面是精简指令集计算RISC),它认为硬件复杂、开发成本高且调试困难,因此我们应该尽量简化处理器,尽可能地精简指令集,然后将更多易出错的工作交给软件,因为软件更易于创建和调试,且成本较低。RISC 的风格是将指令集保持尽可能小,然后专注于让其运行得尽可能快。CISC 中的单条复杂指令可以通过 RISC 使用更长的基础指令序列来执行,借助其简单性,力求使这些基础指令的执行速度与单条 CISC 指令一样快。

指令的持续时间

在我们的 Baby 实现中,控制单元(CU)基于一个定期重复的计数器周期,该周期独立于它触发的任何事件运行。一旦启动计数器,它的动作就遵循一个固定的顺序,这个顺序是完全预定的,并且不受 CPU 其余部分的影响。这种结构被称为开环架构,因为计数器并没有收到关于 CPU 其他状态的反馈。由于这种独立性,开环架构相对容易设计和调试,这也是我们为 Baby 采用这种风格的原因。分析引擎也采用了这种风格,通过其定期旋转的桶形控制单元(CU)。

与此相对,在闭环架构中,触发器的时序不是由中央计数器设置的。相反,每个工作阶段负责在准备好时触发下一阶段。例如,而不是由中央计数器触发解码阶段,可以通过提取阶段激活的线路来触发解码阶段,当提取阶段的工作完成时,解码阶段就会被触发。

闭环方法的优势在于,有些指令可能比其他指令更简单,所需的时钟周期较少。这些指令只使用必要的时钟周期,然后尽快触发下一条指令,而不是无所事事。例如,在我们的 Baby 实现中,一些指令(如 SUBLDN)需要在第 4 个时钟周期执行,而其他指令(如 JMP)则在该时钟周期内不执行任何操作。

开环风格通常与 RISC 相关,因为 RISC 强调让所有指令简单且快速。闭环风格则与 CISC 相关,因为 CISC 可能希望包含执行大量复杂工作的单条指令,并且这些指令需要多个时钟周期来完成,同时也有一些短小、快速的指令。

不同的寻址模式

RISC 和 CISC 在如何分配单条指令应完成的工作量,以及应提供多少种不同版本的指令上有不同的看法。特别是,可以创建多个变体指令,将内存访问与算术操作结合在一起。

RISC 的目标是通过保持内存访问指令和算术指令之间的清晰区分,来减少指令集的规模。例如,一段用于将两个数字相加的程序会使用两条指令将这两个数字分别加载到寄存器中,第三条指令执行加法并将结果放入另一个寄存器中,然后使用第四条指令将结果存储到内存中,例如:

LOAD  R1 $50A3  // load to register R1 the value from address 50A3
LOAD  R2 $463F  // load to register R2 the value from address 463F
ADD   R3 R1 R2  // put into register R3 the result of adding R1 and R2
STORE $A4B5 R3  // store to address A4B5 the result in register R3

这种分离通常被视为 RISC 的主要定义特征。

与此相反,CISC 的目标是提供多种变体的 ADD 指令,以使程序员的工作更轻松。除了 ADD(将两个寄存器的内容相加)外,我们还可以创建另一条指令,例如 ADDM(“从内存加法”),使得这四行的 RISC 风格加法程序可以用单条指令实现:

ADDM $A4B5 $50A3 $463F

我们可以将其解释为“将存储在内存地址 50A3[16] 和 463F[16] 中的值相加,并将结果存储在 A4B5[16] 中。”这使得汇编程序员的工作更加轻松,但也让架构师的工作变得更加困难,因为他们现在需要在解码器中构建额外的数字逻辑来解码这条额外的指令,并且还需要在控制单元(CU)中增加额外的数字逻辑来安排加载、算术运算和存储操作的顺序,而这些操作在 RISC 版本中已经明确编码。这种设计通常被视为 CISC 的定义特征。

CISC 风格的指令集架构(ISA)可能还包括更多的变体,例如一条指令将一个内存位置(50A3[16])的内容加到一个寄存器(R1)中的内容,然后将结果存储到另一个寄存器(R3)中。例如:

ADDRMR R3 $50A3 R1

它同样可能包括一条指令,将一个内存位置(50A3[16])的内容加到一个寄存器(R1)中的内容,然后将结果存储到一个内存位置(A4B5[16])中:

ADDRMR $A4B5 $50A3 R1

另一种常见的变体是增加使用 间接寻址 的指令,这意味着指令的操作数包含 地址的地址,即将使用的地址。例如:

ADDI  $A4B5 $50A3 $463F

这意味着“将存储在地址 50A3[16] 的值加到存储在地址 463F[16] 的值,并将结果存储在地址 A4B5[16] 中。”这是一个相当复杂的指令,它要求将 50A3[16] 和 463F[16] 的内容加载到寄存器中,但这些值本身又需要被解释为地址,然后再将这些地址中的值加载到寄存器中,最后执行加法并存储结果。这听起来像是一个相当晦涩的操作,但它在编译高级语言(如 C)时非常常见且有用,因为 C 中有 指针。间接寻址操作使得指针指令能够高效地在硬件上实现。

当然,这种间接寻址形式的指令也有一些变体,例如一条指令只对一个操作数进行间接寻址,并将结果加到寄存器的内容中:

ADDIR  $A4B5 $50A3 R1

我们可以想出更多的变体,例如使用寄存器的内容作为间接寻址的内存地址,执行多于两层的间接寻址,等等。

偏移寻址(也叫索引寻址)模式是另一种常见的 ISA 功能。这种模式的核心思想是,汇编程序员常常需要多次使用许多变量,而这些变量通常被存储在内存中的相近位置。如果他们可以首先使用一条新指令来指定这个变量存储区域的地址,比如 A7B2[16],然后通过这个地址与存储区地址的差值来引用每个变量,如 0[16]、1[16]、2[16]、3[16],以此类推,按顺序选取各个变量,那么他们的工作就会变得更轻松。在这里,我们会使用像这样的新偏移指令:

SETOFFSET $A7B2
ADDOOO $2 $0 $1

这将把地址 A7B2[16] 和 A7B3[16] 的内容相加,并将结果存储在 A7B4[16] 中。

偏移寻址对 1980 年代的汇编程序员来说尤其有用,那时他们在 8 位字长但 16 位地址空间的机器上工作。这是因为,否则他们每次想要表示一个 16 位的地址时,就需要使用两个字和两个寄存器。使用偏移量后,他们可以将内存划分为 256 个页面,每个页面包含 256 个地址。然后,他们可以选择一次只处理一个页面,使用页面的起始地址作为偏移量。这样,他们只需要一个字来指定页面内的地址。例如,A7B4[16] 就会被认为是 A7[16] 页面上的位置 B4[16]。

为了实现偏移寻址,通常会在 CPU 设计中添加一个额外的寄存器,并用它来存储偏移量。当需要时,其值可以与新的操作数结合,形成完整的地址,以供后续指令使用。

很容易看出,一旦所有这些变化因素都被考虑进来,指令集的大小会变得非常庞大。我们只考虑了单一指令ADD的变体。为了保持一致,ISA(指令集架构)通常必须为每一个算术指令类型创建相同的变体,这可能会导致总共新增数百条指令。

子程序

子程序这个词是许多名称之一——子程序、过程、函数、方法——它们描述的是一个非常相似的概念:一段代码存放在内存中的某个地方,当你的主程序调用它时,它会执行某些操作,并在执行完成后返回到主程序的同一行。这最后一点区分了子程序与简单的跳转语句(如goto语句),后者在执行完后会忘记它被调用的地方。子程序的发明通常归功于莫里斯·威尔克斯(Maurice Wilkes)和他的团队,时间大约在 1950 年左右。

在早期的高级语言中,只有一个子程序调用级别是允许的。你有一个主程序,可以调用子程序,但子程序不能再调用其他子程序。现代的高级语言依赖于子程序能够层次化地调用其他子程序——包括递归(recursion)——来封装复杂性。

子程序 这个术语通常用于架构和汇编语言层面。其他名称则用于高级语言,并且历史上有着不同的含义,这些含义从未被正式定义或在大多数语言中一致使用。以下列表试图定义这些词如果它们在语言设计者一致使用时的含义:

函数 这是最容易正式定义的概念,因为它在最正式化的函数式编程语言中使用。函数是(理想情况下)一个数学对象,它接受作为输入的参数,并仅从这些输入中返回一个计算值,而不依赖于任何其他因素。函数不应该有任何其他副作用,即它不应影响其他任何事物。

过程 这是某些语言中对子程序的称呼,这些子程序可能会或不会接受输入,通常不返回输出,因此它们仅通过副作用起作用。一些较旧的语言只允许一种调用级别,这意味着过程不能调用其他过程。

方法 这是面向对象编程中的一个概念,用于命名与对象关联的子程序。方法可以像函数一样返回一个值,也可以像过程一样具有副作用。

所有这些名称在实际编程语言中都被滥用和混淆;例如,语言中常常存在“函数”产生副作用且不返回值的情况。函数式编程语言更有可能执行数学概念,但即使是一些函数式编程语言也允许副作用。

现在让我们看看如何实现子程序。

无堆栈架构

可以纯粹通过软件实现子程序,而不需要任何额外的硬件或指令。你可以通过编写包含跳转指令的程序,然后采用某种约定来跟踪返回地址。然而,这对程序员来说是困难的,对计算机来说也是缓慢的。

早期具有子程序功能的架构,如 ENIAC,为调用和返回添加了专用的 CPU 指令,并在 CPU 中增加了简单的硬件来执行这些指令。一种方法使用硬件中的单一返回地址位置。可以很容易地在 CPU 中构建一个专用的内部寄存器来存储返回地址。这使得主程序能够一次调用并返回一个子程序;一旦进入子程序,就无法调用并返回另一个子程序,因为这会覆盖单一的返回地址。

为了使子程序能够相互调用(包括递归调用自身),架构可以增加一个硬件堆栈。

堆栈架构

堆栈是一种简单的数据结构,具有两个操作:推送(push)和弹出(pop)。它的行为就像你桌面上的一叠文件。你可以在新文档到达时推送它到堆栈的顶部,并且只能通过取下并移除堆栈顶端的文件来弹出堆栈中的文件。你不能从堆栈下方取出文件。

使用堆栈,你可以创建一条完整的地址追踪路径,以便在嵌套子程序的情况下进行返回。每次调用子程序时,其返回地址都会被推送到堆栈中。当子程序返回时,这个地址会从堆栈中弹出,并用于设置程序计数器。

通过这种方式跟踪子程序在递归的情况下特别有用。堆栈在递归执行过程中通常会增长得非常大,并且(希望)在程序完成并从堆栈顶部读取和移除数据时会缩小。堆栈溢出错误是一个失败条件,表示堆栈空间不足;如果你使用特定的内存块来存储堆栈,并且空间用尽,当你尝试在堆栈边界外写入时,程序将产生堆栈溢出错误。这通常发生在函数无限递归调用或彼此调用时出了问题。在现代计算机中,堆栈的实现比以前资源开销要小得多,因此它们已经成为存储返回地址的标准方式。

硬件堆栈存在于大多数现代计算机中,从 8 位时代起就已经有了。它们在控制单元中使用硬件数字逻辑实现堆栈概念,从而以增强的速度和安全性支持任意子程序调用。这些堆栈架构有一个额外的专用堆栈指针寄存器,这是一个内部寄存器,包含指向堆栈顶部的指针。堆栈本身可能存储在 RAM 的某个区域内,并且通常在硬件级别限制对该部分 RAM 的访问。例如,某些堆栈架构可能会有专用的数字逻辑,用于测试所有来自用户的加载和存储指令,以确保它们不会尝试访问堆栈的 RAM 部分。这可以防止恶意程序员干扰堆栈。

一些堆栈架构会隐藏其内部工作原理,只在指令集中提供新的调用和返回风格的指令。执行时,调用指令会激活数字逻辑,自动将程序计数器推送到堆栈,递增堆栈指针,并跳转到子程序。同样,返回指令会弹出程序计数器,递减堆栈指针,并跳转回调用函数。

其他堆栈架构允许用户完全访问堆栈的内容,除了或代替调用和返回。例如,一些设计提供了诸如PHA(Push Accumulator)和POPA(POP Accumulator)之类的指令。前者将累加器中的内容推送到堆栈上并递增堆栈指针,后者将堆栈中的内容弹出到累加器并递减堆栈指针。这种设计提供了一种通过将参数和返回地址一起存储在堆栈上来传递子例程参数的方法。

调用约定

无论使用哪种堆栈或无堆栈架构来实现子例程,都需要程序员维持一致的调用约定,这样程序的不同部分在传递和接收参数时才能正确理解彼此。这一点尤其重要,当子例程由与调用代码不同的作者编写时,尤其是在提供通用子例程库的情况下。调用约定包括以下内容:

  • 参数、返回值和返回地址的位置:在寄存器中、在调用堆栈中、两者的混合,或者在其他内存结构中

  • 参数传递的顺序和格式

  • 返回值如何从被调用者传递回调用者:通过寄存器、堆栈,或者在 RAM 的其他地方

  • 设置和清理函数调用任务的过程如何在调用者和被调用者之间分配

  • 是否以及如何传递描述参数的元数据

调用约定不是 CPU 架构的一部分。相反,它们是程序员之间的社会协议。给定的架构通常可以与多种不同的调用约定之一一起使用。在某些情况下,CPU 架构师会建议一种约定,以尽量避免用户之间的碎片化。在其他(或者有时是相同的!)情况下,程序员会创建自己的约定,当他们需要使程序互通时,标准战争就会爆发。

调用约定还定义了现代高级语言和编译器之间的兼容性附加功能。例如,来自两种语言(如 C 和 C++)的编译后可执行代码,可以互相链接并调用对方的子例程,只要它们遵循相同的调用约定。

浮点运算单元

我们在第二章中看到,浮点数是通过符号、指数和尾数来表示的。浮点寄存器是专门设计的用户寄存器,用于存储浮点数据表示,以便在浮点计算中使用。

对这些表示形式进行算术运算比到目前为止在整数上进行的算术逻辑单元(ALU)操作要复杂。例如,要乘以两个浮点数,我们需要先乘以它们的尾数,再加上它们的指数,最后乘以它们的符号。要加两个浮点数,我们需要先按指数的差异对其中一个进行移位,然后进行加法操作,可能还需要再次移位并更新指数。当一个大数被小数除时,除法可能会出错,还可能产生定义为无穷大或 NaN(不是一个数字)的特殊情况。

这一切都可以通过将简单的 ALU 风格操作组合在一起,并使用数字逻辑的新组件来完成。最终形成的结构被称为浮点单元(FPU)。FPU 是复杂的数字逻辑部件,设计起来非常昂贵;它们还占用了大量的硅资源,并且容易出现故障。1994 年,英特尔在其 Pentium 芯片中实现 FPU 时出现了错误,导致了 5 亿美元的召回损失和声誉损害。

FPU 出现在 1980 年代,不是在 CPU 内部,而是作为可选的附加芯片。例如,Intel 8086 CPU 可以搭配一个可选的 FPU 芯片,即鲜为人知的 8087。如今,FPU 已完全移至 CPU 并且与其 ALU 部分类似。

如果你想看看现代 FPU 上的专用寄存器和指令是什么样的,它们占据了大部分的书本内容,具体来说是 amd64 参考手册的第三卷,你将在第十三章中接触到它们。

流水线

到目前为止,我们所讨论的内容都涉及用汇编语言编写程序,将其编译为机器码,并从头到尾执行这些机器码,过程中有一些分支和循环。基本上,指令是一次一条地引入,每条指令在下一条指令引入之前都会被执行。大多数现代 CPU 并不是这样工作的。相反,它们会并行处理多条指令的部分内容。我们将在第十五章中看到更多的并行性形式,但在 CPU 层面操作的并行性被称为指令级并行性,我们将在这里进行研究。

流水线是一种出现在 32 位时代的指令级并行性形式。流水线就像一条生产线,在这里有多个工人同时执行任务,就像亨利·福特 1913 年的汽车工厂一样,如图 8-1 所示。

Image

图 8-1:福特 1913 年生产线

福特将每个工人分配一个专门的任务,并将他们安置在传送带上的固定位置。汽车零件沿着传送带移动,每个工人轮流在每个汽车零件上进行工作。

现在,将福特的汽车零件换成你的机器代码程序中的指令,并想象这些指令在生产线上运行。你不再有人工工人,而是由 CPU 的各个部分执行取指、解码和执行等任务。假设你已经编写了 20 行代码,并假设没有跳转或分支。在我们迄今为止看到的 CPU 设计中,一条指令会被放入生产线,并依次通过取指、解码和执行工序。一旦它到达生产线的末端,下一条指令就会放在生产线的起始处。因此,大部分工人在没有轮到自己工作的情况下,通常会处于空闲状态。

我们可以通过保持输送带上始终满载指令,来提高工作效率,而不是等一个指令完成后再开始下一个指令。一个工人可以在执行一条指令的同时,另一个工人解码下一条指令,第三个工人获取之后的指令。

将 CPU 的工作分解成多个阶段有很多不同的方法,这取决于架构类型。经典的划分方法包括取指、解码和执行阶段。我们的 LogiSim Baby 使用了五个时钟周期。现代 CPU 的流水线有更多的细分——现代 Intel 处理器的流水线有大约 37 个阶段!

在数字逻辑层面,流水线可以通过让控制单元(CU)同时触发多个组件来实现,而不是一次触发一个组件。通常会用类似 图 8-2 的示意图来展示流水线。

图片

图 8-2:展示基本流水线如何处理指令的示意图

在 图 8-2 中,时钟周期从左到右展示,四条指令(用方框表示)显示通过流水线。这是一个四阶段流水线,因此它一次可以处理多达四条指令(如时钟周期 4 所示),每条指令处于不同的处理阶段。

流水线对于开放式 RISC 架构来说更简单且高效,因为在这种架构中,所有指令的执行时间相等,因此它们可以均匀地沿流水线推进。对于闭环 CISC 架构来说,流水线可能会变得复杂且效率低下,因为必须考虑不同指令的不同执行时间,并且一些流水线的部分会在较短指令和较长指令一起执行时处于空闲状态。

危险

在流水线执行过程中,有几种广为人知的情形,称为 危险,会导致问题发生。让我们来看看这些主要的危险类型,然后讨论如何解决它们。

分支危险

分支风险 发生在管道中的某个地方,当遇到一个 if 语句时,其他早期阶段正在努力完成分支的一个结果,但你却需要转到另一个结果。当条件分支被执行时,在分支执行前,你无法知道将遵循哪个条件。

数据风险

如果两个工人试图访问相同的内存位置——例如进行取值和输出——那么会发生不好的情况。我们可以将这些 数据风险 分为三类:

写后读 这是指在有两个指令的情况下,第一个尝试写入内存,第二个尝试从相同地址读取。程序的逻辑应该是读取到的值应该与刚刚写入的值相等。但当管道化(pipelining)在使用时,读取的 RAM 访问可能发生在写操作更新 RAM 之前。

读后写 这是反过来的情况:这里,两个指令应该首先读取旧的 RAM 值,然后通过写操作更新它。但当它们被管道化交错时,可能会发生写操作中实际更改 RAM 值的部分,发生在读取操作访问 RAM 的部分之前。

写后写 这是指两个写入指令在试图写入相同地址时互相干扰。程序的逻辑应该是第一个写入,然后第二个写入,最后地址包含第二个值。但同样,由于管道化的存在,它们的执行阶段可能会交错,在某些情况下,第二个写入操作会先执行,从而在第一个写入之后才执行预期的写操作。

结构风险

第三种类型的风险是 结构风险,即多个阶段同时争夺资源。在生产线示例中,工厂可能在工人们后面的货架上放置一个共享的物理计算器。可能会有某个时刻,两个工人都想同时使用这个计算器。例如,一个工人可能需要检查某个值是否为零,而另一个工人需要执行加法操作。在数字 CPU 中的类似情况是,两个数字逻辑区域正在计算两个流水线状态,它们都需要同时访问算术逻辑单元(ALU)或内存。

风险修正

管道化通常在所有类型的信号处理(包括音频、视频和广播处理)中表现良好,因为没有太多分支需要处理。预期相同类型的数据会实时通过流水线流动,并始终以相同的方式处理。例如,数字电视或笔记本中的解码器用于解码和显示电影,它们将可靠地处理一帧又一帧的输入视频和音频,执行相同的操作来解码并显示每一帧,顺序一致。它们通常不需要查看信号内容,并根据内容变化调整行为。

当你进行持续检查结果并根据这些状态改变流程的计算时,危害会变得更加严重。一旦程序中有了分支——在某种程度上还有跳转和子程序——你就必须考虑如何解决这些危害。让我们来看一些通用策略。

避免危害的编程

熟练的汇编程序员如果了解体系结构,可以编写汇编代码来避免许多危害。这通常需要考虑相邻指令组,并思考它们如何在流水线中相互影响,同时调整某些指令的顺序,将它们分得更远,从而减少相互影响的可能性。

如今,大多数编程都是在编译语言中进行的,因此曾经由终端用户程序员使用的一些技巧已经转移到编译器中。一个好的编译器可以检查它生成的汇编代码,并寻找可能导致危害的地方。它可以像人类程序员一样调整这些代码,以减少危害发生的可能性。例如,彼此不影响的指令的顺序可以交换,从而使两次访问同一数据的操作在执行中分得更远。当然,这些优化背后仍然有一个人类在操作:编译器的作者,他们很可能对危害以及如何避免它们产生了浓厚的兴趣。

一些指令集架构(ISA)提供空操作(NOP)作为一条额外的指令,表示“什么也不做”。NOP 指令仍然会经过流水线,占用时间槽,因此程序员或编译器可以将它们插入到可能引发危害的指令之间,以将它们分散开来,从而避免危害。这通常比重新排序指令所需的智能要少,但会因为 NOP 指令在流水线中的处理而降低执行速度。

停顿

停顿(有时称为冒泡)意味着将流水线的结果暂时挂起,直到某个阶段完成其工作。例如,如果发生结构性危害并且两个阶段同时想使用算术逻辑单元(ALU),我们只让其中一个阶段使用它,告诉其他阶段什么都不做,直到 ALU 空闲。在生产线类比中,这就像工厂中的系统,工人遇到问题时可以按下按钮停止传送带,从而给他们时间来解决问题。

为了实现停顿,可以向 CPU 中添加额外的数字逻辑来检测即将发生的潜在危害——例如,当看到跳转或分支即将发生时,暂时停止触发下一条指令的阶段。这是一种重量级的解决方案,如果频繁使用,会带来较大的时间成本。与 NOP 类似,整个流水线系统实际上在危害发生时被禁用,所以如果我们需要经常这样做,那么我们不如直接使用非流水线 CPU。不过,从硅片和设计时间的角度来看,停顿相对简单且便宜。

重做工作

重新做工作意味着在处理潜在危险指令时,我们允许后续指令正常开始其周期,希望该危险不会真正发生。如果我们稍后完成潜在危险指令的执行,并发现确实发生了危险,那么我们就丢弃在下一条指令上已完成的工作,并重新做一次。

例如,当一个分支指令到达时,我们假设它不会被执行,并在测试分支条件的同时,开始获取和解码后续指令。如果我们发现分支不会被执行,那么已经做的工作是有用的,并且保留这些工作,继续执行。但如果我们发现分支会被执行,我们则丢弃后续指令的工作,开始从分支目标地址获取并解码不同的指令。

这种策略比停滞更高效,因为在每个潜在危险发生时,停滞都会带来性能损失,即使有些危险并没有真正发生。假设你的程序中有 100 个分支,其中只有一半会被执行;停滞会导致所有 100 个分支都延迟,而重新做工作只会延迟其中的 50 个。

急切执行

急切执行意味着在短时间内同时执行两个可能的分支,然后在我们确定应该选择哪个分支后,后续会丢弃未被执行的那个分支。例如,急切执行的典型应用出现在指令序列中,如(使用 Baby 汇编):

1: SKN 3
2: LDN 10
3: LDN 11

这个序列首先询问一个条件是否为真;根据结果,它从地址 10 或地址 11 加载数据。在急切执行中,我们在执行第 1 行的同时,开始获取、解码并执行第 2 行和第 3 行。只有在第 1 行比较结果已知之后,我们才决定是选择第 2 行还是第 3 行。我们保留在所需行上已完成的工作,并丢弃在不需要行上的工作。

实现急切执行需要将我们的物理数字逻辑翻倍,以在不确定期间并行执行更多计算。这可能需要有两个物理副本的算术逻辑单元(ALU)、寄存器和执行逻辑。这可以很好地利用摩尔定律当前提供的额外硅资源,而不需要更快的时钟。

分支预测

分支预测是我们尝试预测一个分支是否会被执行的过程,实际执行之前进行预测。这样的预测最初听起来似乎不可能(毕竟执行的含义就是要找出分支会做什么),但我们通常可以利用先前的知识,至少做出比随机猜测更好的预测。

对于分支风险,重新执行工作的方法可以被视为始终预测分支不会被执行。它开始从下一个数值地址获取和解码指令,同时执行分支指令。分支预测通过尝试更准确地预测分支是否会被执行,从而推广了重新执行工作的方法。然后,可以根据预测的分支开始获取和解码,只有在预测错误时才重新执行工作。

分支预测仍然是一个活跃的研究领域,正在调查多种策略。其中一种是假设所有的分支都会被执行——这实际上是重新执行工作方法的对立假设。如果用户只编写那些分支来自if语句的程序,那么在没有其他信息的情况下,这些分支有 50/50 的机会被执行。然而,许多或大多数出现在实际机器代码中的分支来自于循环,而不是if语句,而且循环的通常目的是重复多次,而不是零次或一次。因此,当分支来自循环时,它们通常被执行,因为用户希望循环几次。

对实际机器代码进行的大规模统计研究已经证实了这一点:它们的估计显示 50%到 90%的分支会被执行。

在某些情况下,另一种策略是由人工程序员或编译器提供关于将会执行哪些分支的提示。这可以包括人工程序员在汇编代码或高级代码中添加特殊注释,或者编译器使用代码分析来生成自己的预测和注释。对于某些编译任务,这很容易做到——例如,如果用户程序中写着“重复 100 次”,那么我们可以做出一个不错的预测。在while循环的情况下,预测则更难,甚至是不可计算的。

第三种最先进的方法是使用动态运行时分支预测,这涉及将统计或机器学习分类器构建到 CPU 数字逻辑中,并使用它们进行实时预测。与所有预测系统一样,这需要选择一些程序特征,这些特征可能与时间和空间上邻近的分支行为相关。

简单的情况包括在执行用户程序期间,记录每个分支指令的分支执行频率日志,并使用这些频率作为预测分支会执行哪个方向的概率预测,假如相同的指令再次执行时。

更先进的案例现在包括线性回归甚至神经网络分类器,这些分类器通过数字逻辑构建,并在从实际程序中收集的大规模机器代码库上进行预训练。这些分类器可能会基于机器代码的各种特征进行训练,例如在分支指令前后多行中的操作码和值。

操作数转发

操作数转发 是一种通过添加数字逻辑,直接将指令的结果路由到下一条或附近的指令输入,避免数据危害的技术。例如,考虑以下程序:

1: ADD R3 R1 R2
2: ADD R4 R3 R1

这计算 R3 = R1 + R2,然后是 R4 = R3 + R1,其中所有操作数都是寄存器。这里,指令 2 需要指令 1 的结果放入 R3 后才能执行指令 2。这对于大多数流水线来说会导致数据危害。然而,实际上,指向 R3 的值可能在指令 1 执行过程中就已经可以通过 ALU 的输出线获得,而不是等到它出现在目标寄存器中。通过直接将一根物理线连接从 ALU 输出到数据需要的地方(例如 ALU 输入),我们可以跳过等待结果存储和重新读取的过程,而是立即开始使用它。

乱序执行

乱序执行(OOOE) 是比流水线更高级的指令级并行形式。它涉及在指令进入 CPU 时实际交换指令的顺序,使得它们以不同于程序中出现的顺序执行。乱序执行架构最早由 Tomasulo 算法于 1966 年理论上实现,并于 1990 年代在商业上出现。

乱序执行的关键在于认识到串行程序中的指令通常可以交换位置,而不改变它们的结果。例如,如果我们有一些变量需要赋值,我们可以在这些变量下一次使用之前的任何时刻进行赋值,而不会影响最终结果;最终我们会得到相同的状态。这给了我们调整指令顺序的自由,以防止流水线危害的发生,并最大化效率。无论我们是否拥有单个或多个硬件副本的 CPU 子结构,我们都可以选择合适的顺序,尽可能充分利用这些资源,让它们保持尽可能繁忙。

为了了解乱序执行是如何工作的,考虑以下程序:

1: DIV R1 R4 R7
2: ADD R8 R1 R2
3: ADD R5 R5 R9
4: SUB R6 R6 R3
5: ADD R4 R5 R6
6: MUL R7 R8 R4

这里有六条指令。例如,指令 1 将寄存器 R1 的内容设置为将寄存器 R4 的内容除以寄存器 R7 的内容的结果。我们假设有简单的除法和乘法机器可用,但它们比加法和减法机器更慢。图 8-3 的左侧描绘了指令之间的依赖关系。

Image

图 8-3:样本程序的数据流图(左)和调度图(右),实现了乱序执行(OOOE)

以依赖关系为例,假设指令 2 在指令 1 完成之前无法开始执行,因为指令 1 写入寄存器 1,而指令 2 需要该寄存器作为输入。依赖关系图表明,我们不需要按照原始顺序精确执行指令。只要任何指令在图中的所有父指令之后执行,结果将是一样的。我们可以重新排序指令的执行顺序和/或将它们并行执行,只要图中的箭头关系得到尊重。

图 8-3 的右侧显示了指令执行的一种可能顺序。在这里,指令 1、3 和 4 首先并行执行。指令 5 在 3 和 4 之后执行,但仍可以与指令 1 并行执行(因为指令 1 较复杂,执行时间较长)。指令 2 在指令 1 完成后执行,而指令 6(另一个较长的指令,由于乘法操作)必须最后执行,因为它需要指令 2 和指令 5 的结果。根据可用的 ALU 数量,我们可以比原始程序的单一执行序列甚至流水线更快地执行这种或类似的调度。

OOOE 通常由 CPU 中的数字逻辑在程序执行过程中实时完成。通常只考虑程序中当前指令周围的短时间窗口——比如 10 条或 20 条指令——进行重新排序。

注意

如果你将 OOOE 的思想扩展到重新排序和并行化整个程序,你将接触到 GPU 数据流,详情见第十五章。

超线程技术

在基本的 CPU 中,取指阶段只有取指硬件在工作,译码阶段只有译码器在工作,执行阶段只有 ALU 或 CU 在工作。流水线和超越顺序执行(OOOE)是两种更好地利用 CPU 硬件资源的方法,它们通过让多个指令的不同部分同时工作,来避免在取指-译码-执行周期中硬件资源处于空闲状态。

超线程技术是另一种利用 CPU 资源的方法,避免它们在周期中空闲。与其处理同一程序中的连续指令,我们将它们汇集在一起,形成第二个虚拟 CPU 核心,操作不同的一组指令。这个虚拟核心的每个组件与主 CPU 核心的使用是错开运行的,通常在主 CPU 核心空闲时运行。通过将所有这些组件汇聚在一起并使它们错开运行,我们就创造了一个额外的 CPU,使所有硅芯片始终处于工作状态。

超线程技术于 1970 年代提出,并在 2000 年代广泛应用于商业 CPU 中。它通过使设备中显现的核心数翻倍,而有效地提高了实际物理核心数,这也是你经常看到计算机报告的核心数是硬件广告中所述的两倍的原因。

超线程相对于流水线的优势在于你不再需要担心危害(hazards),因为两个核心可以完全独立地操作。另一方面,它并不会增加任何单一程序的速度。它还需要额外的数字逻辑来在适当的时刻读取、存储和写入两个虚拟 CPU 的状态,并且需要复制一些硬件组件,以确保一个核心不会影响另一个核心。实际上,流水线和超线程可能会一起使用,特别是当流水线被分解成许多更小的阶段时。如何平衡它们是一个高级问题,超出了本书的范围。

总结

除了像 Baby CPU 这样的最小化 CPU 外,架构师还面临着许多决策,必须在速度、可用性、硅片大小和能量成本之间做出权衡。为 CPU 添加更多功能,如更多的寄存器、算术逻辑单元(ALU)和浮点简单机器、栈以及不同的寻址模式,可以使用户程序员或编译器的工作更轻松、更快速,但代价是硅片和能源。同样,添加更多的指令可以让一些程序员和编译器受益,但对于其他必须应对额外复杂性的人员来说,则变得更为困难。使所有指令具有相同固定时长可以让流水线和乱序执行设计师及 CPU 调试员的工作变得更简单,但如果某些复杂指令需要较长的执行时间,则可能效率较低。

RISC 是一种通常旨在保持指令和指令集简单小巧的风格,同时通过更多的寄存器、流水线和乱序执行(OOOE)来利用额外的硅片加速指令。CISC 是相对的风格:它倾向于利用额外的硅片增加更复杂的指令并创建更大的指令集。正如我们将在第十三章和第十四章中看到的,这两种风格往往适用于不同的应用。

即使是最先进的 CPU 设计,如果没有输入、输出和内存,你的计算体验也会非常有限,接下来的两章将探讨如何将这些添加到计算机中。

练习

混淆流水线

设计一个最简单的汇编程序,目的是使基本的流水线混乱,使其运行速度与非流水线系统一样慢。尝试扩展该程序,尽可能多地混淆每种危害处理策略。这类程序在实际中出现的可能性有多大,如何避免它们?

具有挑战性

尝试在 LogiSim 中构建一个 FPU,基于第二章中看到的浮点数据表示。考虑如何将之前看到的简单机器在加法、减法、乘法和除法的每个算术操作中进行组合。例如,当两个浮点数相乘时,它们的指数会相加。

更具挑战性

尝试在之前的 LogiSim Baby 设计中加入一些最小化的流水线。例如,你可以尝试在当前指令执行时,递增程序计数器并开始执行下一个取指。困难的部分在于处理分支风险。你可以假设最初分支会被采取,然后添加逻辑以在发现判断错误时清除并重新开始。

进一步阅读

  • 关于有争议的“冯·诺依曼”架构的起源,参见 John von Neumann 的《EDVAC 报告初稿》,1945 年 6 月 30 日,* history-computer.com/Library/edvac.pdf*。

  • 关于子程序的发明,参见 Maurice Wilkes、David Wheeler 和 Stanley Gill 的著作 The Preparation of Programs for an Electronic Digital Computer: With Special Reference to the EDSAC and the Use of a Library of Subroutines(剑桥,马萨诸塞州:Addison-Wesley,1951 年)。

  • Human Resource MachineShenzen I/OTIS-100 是教育类视频游戏,它们呈现了类似 CPU 的环境,具有不同的指令集和目标供你探索。

第十一章:## 输入/输出

Image

你已经学会了如何构建一个基本的 CPU 和 RAM,它们可以一起运行程序。CPU 和 RAM 非常适合执行计算,但要让计算机具备图形、声音、操纵杆和与现实世界其他交互的能力,我们还需要输入和输出,统称为I/O。在本章中,你将学习如何使用总线、I/O 模块、设备和外设添加 I/O 功能。

基本的 I/O 概念

为了详细讨论 I/O,首先让我们定义一些术语。I/O 模块是数字电子设备,像 RAM 一样,它们被分配并连接到计算机的地址空间中的地址,地址空间是 CPU 可以访问的地址范围。I/O 模块也与设备相连接,设备是包括数字和模拟电子学在内的电子系统,这些设备并不直接连接到计算机的地址空间,但可以通过附加的 I/O 模块与其通信。设备可以物理地位于计算机内部,例如控制 CRT 显示器扫描束的模拟电路;也可以在外部,例如打印机内部的电子电路。

外设是大多数计算机用户最直观的 I/O 元件:它们是与计算机连接的物理对象,例如鼠标、操纵杆、显示器和打印机。外设被封装在自己的塑料外壳中,通过一根用户可以轻松插拔的电缆连接到计算机的机箱。有些外设,比如打印机,内部包含其设备。其他外设,比如显示器,则依赖于计算机机箱内的设备(例如显示器的 CRT 控制器)。

在 8 位时代,计算机设计意味着通过购买并连接 CPU、内存、设备和逻辑芯片来构建一台完整的计算机,并可能需要定制设计输入/输出(I/O)模块。例如,图 9-1 中显示的 Commodore 64 主板展示了机器中很大一部分是用于 I/O 的。

Image

图 9-1:C64 主板,显示 CPU、内存(RAM 和 ROM)、I/O 模块和设备

图中左上方的 I/O 部分包含两个复杂接口适配器(CIA)芯片,每个芯片包含多个 I/O 模块。右下角的设备部分包括图形和声音芯片。

如今,类似于 Commodore 64 主板的整体结构已经缩小为手机中的单一系统芯片(SoC),但是当各个部件是以物理分开的集成电路(IC)封装形式存在时,结构更容易理解和学习。请在我们继续阅读本章时牢记这一形象。

对于 CPU 来说,I/O 模块看起来就像是 RAM 的一部分。它们有可以读取和写入的地址,使用与读取和写入 RAM 相同的加载和存储指令。现在,RAM 和 I/O 模块都连接到相同的 CPU 地址和数据线,我们需要一种方式让它们共享这些资源。这可以通过下节中将介绍的总线架构来实现。讨论完总线之后,我们将深入了解 I/O 模块,并详细介绍如何与它们通信。

总线

总线架构是一种特定类型的网络架构,其中每个参与通信的设备都可以平等地访问一个共享的电线或一组电线,这些电线称为总线。就像同名的公共交通工具一样,计算机总线之所以得名是因为它是一个公共场所(它缩写自拉丁语omnibus,意思是“为所有人”)。为了说明总线架构的公共性质,可以考虑一个监狱水管窃听系统的例子:监狱中的所有囚犯都有连接到同一管道的水管,因此,任何人敲击一根管道,利用摩尔斯电码传输越狱计划,必然会广播该消息给所有监听管道的人。在总线架构中没有隐私(除非使用加密),如果允许不信任的设备访问总线,可能会带来有趣的安全隐患。

总线是最简单的网络形式,缺乏互联网的分包、错误处理和路由等复杂性。例如,可能有两个囚犯会试图同时窃听同一根管道,造成碰撞,导致他们的消息都被破坏。

一般来说,总线由几个节点(即希望互相通信的设备)和它们之间的通信线路(电线)组成。现代总线通常有许多并行使用的线路,但也有只有一根线路的总线。我们可以将这些线路分为控制线、地址线和数据线。

需要一种协议来确保信号不会与其他节点发送的信号发生冲突,因此,节点在向另一个节点发送消息之前,必须首先宣布消息的接收者——即地址,通过地址线传递——并宣布消息的类型——即控制信息,通过控制线传递。然后,它在数据线上广播数据。你可以让其中一个节点负责总线,通过仅在允许时才允许节点写入来强制执行该协议,或者你可以信任节点自行实现协议,并彼此配合良好。

维多利亚时代的互联网

巴贝奇和布尔时代的电报系统被称为“维多利亚时代的互联网”。它是一种总线架构,连接着英国、美国和大英帝国的各个站点。当地车站的人工操作员会为顾客敲击莫尔斯电码文本消息(电报),并监听从其他地方发送到其站点的消息。所有消息都通过同一条电缆传输,所有操作员都可以读取和写入这些信息。这些操作员花费了成千上万的小时在电缆上收听和写作,精通莫尔斯电码,并发展出自己独特的莫尔斯“说话”风格,可以用来识别是谁在讲话。他们还会在不为顾客发送消息时相互聊天,进行典型的现代聊天室行为,比如使用简化俚语(短信语言)、谈恋爱,甚至在没有见过面的情况下与其他大陆的操作员结婚。

总线架构的一个优点是,添加新设备到总线非常容易,因为相同的一组共享线路连接所有组件。共享线路还使得总线的实现成本较低。另一方面,总线可能成为瓶颈,限制系统的性能。如果你优化了 CPU 或内存的速度,非常快速,但数据最终还是通过总线传输,导致速度变慢,那就特别令人烦恼。总线性能还可能受到物理因素的限制,如线路长度和连接数量。

总线线路

总线上的线路与我们之前在 CPU(或其缓存)与 RAM 之间点对点连接的线路相同。它们有三种不同的类型:

地址线 这些用于指定数据总线上数据的来源或目的地。地址总线的宽度决定了系统的最大内存容量(即系统可以寻址的内存量)。例如,一个 32 位地址总线的系统可以寻址 2³²(4,294,967,296)个内存位置。如果每个内存位置存储一个 8 位字(字节),则可寻址的内存空间为 4 GiB。对于 64 位地址空间的 64 位字,正好可以使用 1 泽比特(2⁶× 2⁶⁴ = 2⁷⁰)的内存,这足以使搜索引擎大小的数据中心中的所有数据都有自己的 RAM 地址。

数据线 这些线路提供了在节点之间实际传输数据的路径。一个关键的性能因素是数据总线的宽度(即数据线的数量)。一个典型的数据总线由 32、64、128 或更多条独立的线路组成。为了发送超过数据线宽度的消息,你需要将它们分拆并通过多个周期传输。例如,如果数据总线宽度是 32 位,每条指令是 64 位长,那么 CPU 在每个指令周期内必须访问内存模块两次。

控制线 这些用于控制数据和地址线的访问和使用。例如,在我们讨论 Baby 存储操作时,之前在图 7-10 中使用的写使能线就是一个简单的控制线。更一般地说,由于数据线和地址线被许多组件共享,因此必须有一种方式来控制它们的使用,以避免多个组件同时尝试写入这些线路。进一步的控制线可以用于请求和协商访问权限。

CPU-总线接口

大多数 CPU 设计为通过 CPU 芯片上的引脚连接到主板上的插座,以便与主板上的外部总线连接。在总线电缆与 CPU 物理连接的地方,这种连接被称为 前端总线(FSB)。正如在图 9-2 中的 Commodore 64 的 8 位 6502 CPU 引脚图和 1990 年代的 32 位英特尔 Socket2 引脚图所示,绝大多数 CPU 的引脚都被 FSB 占据。

Image

图 9-2:8 位 6502(左)和 32 位 Socket2 芯片(中)的引脚图,以及 64 位 LGA1155 插座 CPU 的引脚照片(右)

这里的 6502 使用 16 位地址和 8 位数据,因此它有 16 个地址(A)引脚和 8 个数据(D)引脚。R/W 引脚用于读/写控制线。总的来说,芯片的 40 个引脚中有超过一半用于总线。Socket2 用于 32 位地址空间和 32 位数据字,因此它有 32 个 A 引脚和 32 个 D 引脚(分别在图中以白色和黑色表示)。同时,64 位 CPU 芯片和插座需要每种引脚数量的两倍,因此它们必须更小且更脆弱。

CPU 需要与总线通信,但总线通常比 CPU 慢。因此,CPU 设计师倾向于使用寄存器来缓存进出 CPU 的数据(如分析机中的输入/输出轴,连接 CPU 和其机械架构总线)。总线是一种稀缺资源,因此我们不希望将其使用时间过长;如果数据被缓存,它可以在总线可用时随时放入或取出。通常,这些缓存机制包括 内存地址寄存器(MAR),它存储我们要读取或写入的地址,以及 内存缓冲寄存器(MBR),它存储正在写入或读取该地址的数据副本,如图 9-3 所示。

Image

图 9-3:一种总线架构,包括一个 CPU、一个 RAM 模块和两个 I/O 模块

为了执行加载指令,包含要加载地址的操作数会暂时从其指令寄存器(IR)位连接到 MAR,创建 MAR 中的副本。当副本完成时,MAR 会暂时连接到内存,发出读取请求,并且内存中的数据会暂时连接到 MBR,MBR 获取该数据的副本。然后,控制单元(CU)可以暂时将 MBR 连接到累加器或其他用户寄存器。用寄存器传输语言(RTL)风格表示,这可以写成:

t2, LOAD: MAR <- IR[operand]
t3, LOAD: BUS_A <- MAR
t4, LOAD: MBR <- BUS_D
t5, LOAD: ACC <- MBR

相同的 MAR 和 MBR 寄存器也可以用来执行存储指令。CU 会暂时将 MAR 连接到 IR 中包含要写入地址的操作数字段;MAR 获取该地址的副本。然后,CU 会暂时将 MBR 连接到包含要写入值的寄存器;MBR 获取该值的副本。现在,MAR 和 MBR 包含描述存储操作所需的所有信息。最后,CU 会暂时将 MAR 连接到 RAM 的地址线,将 MBR 连接到 RAM 的数据线,并将其命令线设置为存储,这样就会在 RAM 中执行存储操作。在 RTL 中,这可以写成:

t2, STORE: MAR <- IR[operand]
t3, STORE: MBR <- ACC
t4, STORE: BUS_A <- MAR
t4, STORE: BUS_D <- MBR
t4, STORE: BUS_C <- True

拥有 MAR 和 MBR 也简化了设计具有多个用户寄存器而非仅一个累加器的 CPU。它们使得从选择连接到总线的寄存器的逻辑和时序,分离出数据从总线传输到寄存器的逻辑和时序变得容易。

I/O 模块

设备通常通过 I/O 模块连接到计算机。这是一个芯片,坐落在总线上,位于某些地址;它对 CPU 来说看起来就像 RAM。如果你从本章中学到的唯一一件事,那应该是这一点:I/O 模块对 CPU 和汇编程序员来说,表现得像一个可读写的内存区域,就像主 RAM 一样。与 RAM 不同,I/O 模块的另一侧还连接着通往设备的线路。该模块为 CPU 提供了一个标准化的接口,并将 CPU 的请求转换为通过这些线路传送到特定设备的信号。因此,我们可以在 eBay 上购买任何设备,例如声音芯片,并将其安装在特定类型的计算机中,只要我们制造一个 I/O 模块,提供适当的地址空间,并发送声音芯片所期望的信号。

存储到这些地址可能会向设备发送命令;它可能会写入类似汇编的指令,这些指令是针对模块的,用于进一步转化为设备命令(例如,在现代显卡中使用),或者它可能会向设备发送数据(例如,播放什么音频)。从这些地址加载可能会从设备读取数据,例如键盘按键或麦克风声波,或者读取设备的状态信息,例如是否有打印机卡纸。I/O 模块的设计者可以根据需要解释这些加载和存储命令。

一些 I/O 地址可能由模块内部的实际 RAM 实现(不同于常规 RAM 芯片,因为这种专用 RAM 具有额外的连接与其他 I/O 电路相连);有时,它也可能只是没有 RAM 的直接数字逻辑。两种方法都向 CPU 提供相同的接口,CPU 并不知道那里是否有真实内存或其他东西。

除了设备通信,I/O 模块通常还会处理控制和时序、数据缓冲以及设备错误。我们现在来谈谈这些内容。

控制和时序

I/O 模块必须能够协调内部资源和外部设备之间的数据流。外部设备可能较慢,因此模块独立于 CPU 管理它们。这使得 CPU 在等待时可以去做其他事情。这是一种非 CPU 级并行性。

I/O 模块通过使用数据缓冲来实现独立管理,将数据转入或转出主内存或 CPU。缓冲意味着使用专用的内存区域,称为 缓冲区,作为暂存区。慢速设备可以在独立于 CPU 的情况下,花时间向缓冲区写入或从缓冲区读取数据。快速的 CPU 也可以独立于设备读取或写入同一个缓冲区。

环形缓冲区用于音频和类似的实时信号处理 I/O。概念上,环形缓冲区是一个数据区域,其中数据项以圆形排列,每个数据项都有前一个和下一个邻居,如图 9-4 所示。

图片

图 9-4:一个环形缓冲区。两个指针顺时针移动。字符串 0123456789 已经写入,最初的 01 现在被 89 覆盖。其中,01234 已经被读取。

两个指针——可以形象地看作时钟指针——跟踪读取点和写入点。当新数据实时到达时,它会被写入到写入点,之后写入点递增指向下一个槽。最终,写入指针会绕环一圈,开始覆盖旧数据。用户程序可以随时请求读取下一个可用项。当发生这种情况时,读取指针的数据会被复制出去,读取指针递增,直到满足请求的项数,或者读取指针与写入指针重合,意味着没有更多的新数据可用。

双缓冲区通常用于图形渲染。在这里,维护了两个缓冲区,每个缓冲区代表屏幕的布局,如图 9-5 所示。

图片

图 9-5:双缓冲

在任何时刻,一个缓冲区存储着完全渲染的图像,并通过图中显示的粗黑线连接到图形显示硬件,后者负责物理显示该图像。与此同时,另一个缓冲区则用于逐步构建下一张将要显示的图像——例如,先绘制背景,然后在其上添加精灵和覆盖物。只有当新的缓冲区完成时,输出线路才会交换,连接到新图像的显示器上;然后,原来的缓冲区被清空,并用于开始绘制序列中的第三张图像。这种方法确保只有完成的图像才会显示在屏幕上,从而避免了实时构建图像时的闪烁现象。(在某些情况下,也会使用三重缓冲技术,允许 两个 未来的帧并行绘制,同时当前帧正在显示。只要提前知道需要显示什么内容,这可以实现更高的帧率。)

错误检测

I/O 模块的另一个主要功能是设备错误处理。如果 CPU 请求 I/O 模块执行某个操作,但 I/O 模块在执行时发现了设备的错误,应该怎么办?这个错误可能是设备的机械故障或电气故障(例如打印机卡纸或硬盘损坏的磁道),也可能是由于设备与 I/O 模块之间的比特传输发生了无意的变化,这通常是由于外部电缆的噪音干扰。

通常情况下,设备不应直接向 CPU 报告错误,因为 CPU 可能正在忙于其他任务。相反,设备会将错误报告给 I/O 模块,然后 I/O 模块将错误传递给 CPU。

I/O 模块技术

将数据从外部设备传输到 CPU 需要几个步骤。首先,CPU 向总线写入,要求 I/O 模块检查设备的状态。接下来,I/O 模块回复设备状态,并同样向总线写入。如果设备准备好了,CPU 会再次通过总线写入请求数据传输。然后,I/O 模块从设备中获取一单元数据。最后,这些数据通过总线从 I/O 模块传输到 CPU。

如果该过程需要等待现实世界中的某些事情,它将变得很慢。例如,如果一颗千兆赫兹的 CPU 要求读取 100 个音频样本,但音频样本的到达速率只有 44 kHz,那么它将不得不花费大部分时间什么都不做,等待每一个样本到达并通过 I/O 模块发送到总线上。我们希望 CPU 在等待请求的 I/O 操作时,能做其他事情。这个目标可以通过三种常见的技术来实现。我们将逐一讨论每种技术。

轮询

假设你的老板需要你完成一份报告。他们可以使用的一种管理策略是 轮询,也就是他们每隔一段时间(每小时、每天或每月)就回来看你一次,问你:“你完成那个任务了吗?”

CPU 也可以类似地使用轮询来检查 I/O 请求是否已完成。CPU 通过总线向 I/O 模块请求执行某个操作。I/O 模块开始执行请求的操作,并在过程中设置内部 I/O 模块状态寄存器中的适当位。然后,CPU 定期检查(或轮询)I/O 模块的状态,读取该状态寄存器,直到发现操作已完成。

例如,CPU 可以要求一个网络摄像头的 I/O 模块抓取一帧新的视频数据。然后,它可以进行轮询,直到状态报告完成,再从模块加载数据,知道数据已经准备好。

轮询的优点在于它易于实现,且 CPU 可以直接控制 I/O 操作,几乎不需要硬件支持。缺点——就像人类老板的例子一样——是 CPU 必须定期轮询模块以检查其状态。这占用了 CPU 的时间,造成了长时间的无效工作。CPU 的速度被外设的速度限制,效率低下。就像人类一样,每天都需要记得询问你是否完成工作,并且还要实际进行询问,这非常疲惫。它干扰了管理者和工人的其他任务的思维流程。

中断

大多数经理更倾向于让你告诉他们何时完成工作,这样他们可以忘记它,直到你主动告诉他们已经完成。这种方法是一个中断架构的例子,它使经理能够专注于其他有用的工作。

在计算中断架构中,CPU 通过扩展来实现——例如,添加一个额外的寄存器和一个指令来设置其内容——以使程序员能够告知它一个特殊子程序(称为处理程序)的地址。CPU 还通过增加一个额外的专用物理引脚,称为中断请求(IRQ)输入,并在控制单元(CU)中增加数字逻辑以利用该引脚。IRQ 上的高电压通知 CU 通过立即调用处理程序子程序来改变程序流程。

要使用中断架构,IRQ 引脚必须连接到 I/O 模块的专用输出。程序员创建一个处理程序子程序,用于在 I/O 工作完成后执行,并将其地址告诉 CPU。然后,程序员编写主程序,指示 I/O 模块执行某些操作。当命令被发送到 I/O 模块时,CPU 会忘记所有相关内容并继续执行主程序。I/O 模块让设备执行任务,这可能需要一些时间。当设备完成时,I/O 模块通过将 IRQ 线设置为来中断 CPU。这会调用处理程序子程序,处理程序子程序会利用来自设备的新数据,或告诉设备接下来做什么。像所有子程序一样,调用处理程序包括保存并返回中断指令的程序计数器的值,以便主程序在中断处理后能够恢复执行。

中断的优点在于它们快速且高效,无需 CPU 等待或管理轮询请求。中断的缺点在于它们可能很难编写,尤其是当多个 I/O 模块同时工作、并且都在发送中断信号时。可重入架构允许中断处理子程序被更高优先级的 IRQ 中断,而不可重入架构可能会忽略或延迟这些元中断。可重入架构的代码需要非常仔细地考虑如何正确处理元中断,这是一种并发编程的形式。

CPU 的物理 IRQ 引脚数量是有限的——有时引脚的数量比需要使用它们的设备还要少。引脚是现代 CPU 的一项宝贵且有限的“资源”,因为增加更多引脚会迫使芯片的物理封装尺寸增大。

IRQ 地狱

中断在 1990 年代对计算机音乐创作者来说是一个主要的痛点,因为他们需要同时使用大量外部设备,例如多个声卡、MIDI 卡和输入控制器设备。你的 Intel CPU 芯片上会有几个物理 IRQ 线,每条 IRQ 线代表一个连接到计算机的物理设备。如果设备数量超过了可用的 IRQ 引脚,就需要通过黑客手段绕过这一限制。黑客方法包括试图说服不同厂商生产的硬件和驱动程序共享同一条 IRQ 线,或禁用系统硬件使用的 IRQ,将它们释放给音频设备使用。有时,后者可能会产生摧毁系统的副作用。

直接内存访问

对于涉及将大量数据从设备(例如硬盘)传输到 RAM 的任务,轮询和中断都非常慢。例如,如果我们请求 1Mb 的传输,I/O 模块将去执行这个任务,使 CPU 保持空闲和愉快,但当中断发生时,这将为 CPU 创建一个大而慢的任务,需要将每一位数据加载到寄存器中,然后发送到 RAM。直接内存访问(DMA)是一种避免此问题的技术。

DMA 需要一个专用的硬件 DMA 控制器(一个 I/O 模块本身)被放置在系统总线上。到目前为止,我们使用系统总线的所有方式都涉及 CPU 与总线上另一个节点的通信,该节点可能是 RAM 或 I/O 模块,但总线也允许非 CPU 节点之间直接通信,而不需要 CPU 的介入。任何节点都可以将消息发送到总线上的任何其他节点,这在 DMA 中是这样做的:CPU 授权 I/O 模块通过总线直接与 RAM 通信,读取或写入内存而无需任何 CPU 干预。

这使得 CPU 可以做其他事情;就像 IRQ 一样,CPU 可以“设置并忘记”。DMA 通常在任务完成时发送中断,因此 CPU 只在传输的开始和结束时参与。这对于大数据量的移动尤其有用,因为数据不必经过 CPU。

无模块的 I/O

I/O 模块是大多数情况下首选的 I/O 架构,但也存在其他没有模块的 I/O 架构,并且它们在某些场合中也有其存在的价值。我们现在将考虑其中的一些。

CPU I/O 引脚

一些较旧的 CPU,以及一些现代嵌入式 CPU,放弃了 I/O 模块和基于总线的 I/O,直接通过专用引脚与少数特定设备进行通信。这种方法不可扩展,因为引脚是有限且宝贵的 CPU 资源(它们决定了封装的物理大小)。但是,在我们明确知道将来只会连接少数特定设备的情况下,这种方法可以减少架构的复杂性。如果整个 I/O 系统以这种方式设计,它可以去除 IRQ 引脚和控制逻辑的需求。它还腾出了总线用于其他活动。

内存映射

与其拥有一个可寻址的 I/O 模块,一些架构使用常规 RAM 的区域作为 CPU 与设备之间的接口。在这些架构中,RAM 可以被 CPU 和设备同时读取和写入(因此它需要一些额外的非总线连接线将引脚连接到设备和总线)。通过这种设置,CPU 像往常一样直接写入实际的 RAM,然后设备(或者一个在设备和 RAM 之间进行接口的模块化芯片,但不在总线本身上)从 RAM 中读取并将其转换为类似 I/O 模块的设备命令。对于程序员来说,是否写入的实际上是以这种方式使用的常规 RAM,还是 I/O 模块的一部分,这可能是不可见的。

总线层次结构

在现代架构中,我们通常有多个总线,形成总线层次结构,如图 9-6 所示。

Image

图 9-6:总线层次结构

上层显示的是与图 9-3 中相同的总线。然而,I/O 模块 IO2 是连接到一个较低级别总线的接口,后者承载着三个更低级别的组件。这种结构可以提高可用性和速度。传统上,每个 I/O 模块只连接一个设备,并且在计算机开机时必须安装在地址空间中的特定地址。开机后很难添加或移除(“即插即用”)设备。通过引入一个单一的 I/O 模块,如固定地址的 USB 集线器,我们可以允许多个即插即用设备通过较低级别的协议(如 USB)连接到同一个 I/O 模块。这个安排还解决了 IRQ 困境问题,因为 I/O 模块可以使用一个重要的 IRQ 线路来提醒 CPU 这些设备的中断。较低级别的总线可以使用比系统总线更慢且更便宜的技术,因为它只需要在数据实际可用的速度下运行(例如,可能会受到等待真实世界音频或旋转硬盘的限制)。

总结

为了使计算机与外部世界互动,如通过图形和声音,它需要输入和输出。这可以通过 I/O 模块来实现,I/O 模块是数字逻辑组件,在 CPU 看来就像 RAM。发送到它们地址的存储被解释为控制外部设备的命令,而从它们读取的数据则用于发送从外部世界传感器获取的数据。

CPU、内存和 I/O 共享相同的地址空间,并通过一个公共的总线进行通信,该总线包括地址、数据和控制线路。CPU 通过暂存寄存器 MAR 和 MBR 与总线进行接口。

CPU 还可以通过中断线路直接与有限数量的 I/O 模块进行接口,I/O 模块使用这些中断线路请求 CPU 跳转到一个处理子程序。I/O 模块也变得越来越独立于 CPU,并且可以使用诸如 DMA 等方法,通过总线与彼此和 RAM 进行通信,而无需涉及 CPU。

总线和 I/O 的一个重要用途是管理现实世界中的内存,包括多个物理 RAM 和 ROM 模块,以及硬盘和光盘设备。我们将在下一章学习这些内容。

习题

挑战

  1. 从图 7-13 中的 LogiSim Baby 开始进行扩展,使得存储到其地址之一时,能够控制模拟 LED 的开关。

  2. 再次扩展,使得从另一个地址加载时能够读取模拟开关的状态。你可以通过将 RAM 的大小减少两个地址,然后向总线添加一个新的数字逻辑 I/O 模块,该模块监听这些地址并作出相应的反应。

  3. 再次扩展,使得 I/O 模块能够解码存储指令中作为数据发送的多个命令,并利用这些命令让 LED 执行不同的操作,例如以不同的速度闪烁。在这里,LED 和开关代表可以通过这种方式控制的通用设备。

进一步阅读

请参阅 Tom Standage 的《维多利亚时代的互联网》(伦敦:Weidenfeld & Nicolson,1998),其中对比了 19 世纪的电报与现代互联网。

第十二章:## 内存

Image

到目前为止,我们已经构建了寄存器和一个小型的、婴儿级别的 RAM 来作为内存。我们使用触发器(flip-flops)制造了这些内存。然而,更大的内存通常不能使用触发器,所以它们通常使用其他技术,如 DRAM 和硬盘。这些其他技术较慢,因此在速度和容量之间形成了一个权衡。在本章中,我们将深入了解更大内存的细节。我们将讨论主内存、缓存、以及二级和离线内存,并从内存层次结构开始。

内存层次结构

在任何时刻,通常只有一部分数据是重要的,并且在频繁使用。其他数据偶尔使用,有些数据完全不再使用。我们通常希望将数据安排在快速且易于访问的内存中,以便工作中的数据能够高效访问,而其他数据则保存在较慢且便宜的内存中。这种安排被称为内存层次结构

内存层次结构在数字化之前的生活中也存在。例如,人们过去会将购物清单和重要的电话号码写在纸片上,方便即时和经常使用。在他们的办公桌上,通常会有较大的纸质文件,只有在工作时才会用到。更远的地方是架子和柜子,里面存放着不常用的书籍和文件。更远的地方是阁楼里的存储箱,然后是本地和国家的图书馆和档案馆,访问这些地方需要更多时间。数据可以在这些不同的存储之间进行提升和降级。例如,一本书可能多年未被使用,静静地放在图书馆里,之后在需要时被提升到桌面上使用几周。桌面上不再使用的文件可以降级到文件柜,再到阁楼中。

相同的概念也适用于计算机内存。当同一技术有快速和慢速版本时,快速版本更好,因此它可以要求更高的价格,这意味着与较慢版本相比,你能购买的量较少。在有预算的情况下,你可以在速度和容量之间进行权衡。由于大多数人希望某些数据比其他数据更容易访问,因此购买和使用不同类型的内存(从小而快速的工作数据到大而慢的很少使用的数据)是有经济意义的。图 10-1 展示了本章中将讨论的每个内存层次的大致速度和容量。

Image

图 10-1:内存层次结构

这些层次可以定义如下:

寄存器 位于 CPU 内部的内存,如第七章所描述。

缓存 位于 CPU 外部但靠近 CPU 的内存,包含主内存的快速副本。

主内存 存储在地址空间中,可以通过 CPU 的加载和存储指令直接访问的内存。

次级内存 是 CPU 无法通过其寄存器和地址空间直接访问的内存,但可以通过 I/O 传输到主内存中以实现访问。

三级内存 是不直接连接到地址空间或 I/O 的内存,但可以通过机械方式连接到 I/O,无需人工干预。

离线内存 是只能通过人工干预连接到计算机的内存。

根据丘奇对计算机的定义,任何依赖固定长度地址的机器——比如我们在第七章中构建的曼彻斯特宝宝——都不完全算是计算机。丘奇计算机需要能够模拟任何其他机器,并且为了做到这一点,它必须能够按需请求并获取更多的存储空间。然而,基于固定大小地址的 CPU 和总线构建的机器,无法轻松扩展超出该固定大小的内存。为了绕过这个问题,并支持无限的内存,我们需要使用主内存以下的内存级别,例如在图 10-1 中显示的次级和三级内存。这些较低级别的内存不能直接从 CPU 寻址,而是通过 I/O 模块与 CPU 连接的设备。

主内存

主内存(也称为系统内存)是存储在地址空间中的内存,这些地址空间可以被 CPU 的加载和存储指令直接访问。这包括 RAM 和 ROM。大多数现代计算机使用冯·诺依曼架构;记住,这意味着程序和数据存储在同一主内存中。

在主内存中,每个内存位置都有一个唯一的地址。例如,一个 16 位的地址空间有 2¹⁶ = 65,536[10]个唯一地址,编号从 0000[16]到 FFFF[16]。每个地址存储一个固定大小的位数组,称为。通常,虽然不总是如此,字长被选择为与地址长度相同,例如在现代笔记本电脑的 64 位地址空间中存储 64 位字。你在第六章中看到了实现这种结构的简单方法;在第七章中你看到如何将其直接连接到 CPU,在第九章中则是通过总线间接连接。

字节与字节序

关于国际单位制(SI)与二进制前缀的争论,涉及到是否应以比特(b)、字节(B)或字(W)来衡量内存。比特是最基本的单位,它与国际单位制单位配合使用效果很好。

在现代使用中,一个字节表示 8 位,这一术语来源于 8 位时代,当时现在所称的字是按定义为 8 位的。一个字节是存储在一个内存地址中的数据,也是被带入 CPU 的一个寄存器进行处理的数据。术语字节应该意味着 CPU 从内存中“咬取”最小的一块进行处理。为了避免与比特这一术语混淆,它故意拼写错了。“字节”最初是指任何这种自然的 CPU 大小,早期 1950 年代的处理器中,字节的位数范围从 1 到 6 位。直到后来才标准化为 8 位。

在 8 位时代,用字节和现在所谓的千字节(kibibyte)来衡量主存储器是非常自然的。你会计算地址的数量,比如 2¹⁶表示 16 位长的地址,然后在这个数字后加上字“bytes”来得到总的可寻址内存大小。例如,一台“64 千字节”的机器,如 Commodore 64,具有 2¹⁶个地址,每个地址包含 1 个字节。

在现代 64 位时代,字节的意义应该几乎没有,尤其是在现在字长是 64 位而不是 8 位的情况下。如果我们在 2³² = 4 gibi 地址处存储 64 位字,我们将讨论像“4 gibiwords”这样的主存储器大小。

然而,大多数实际的现代计算机并不按字存取内存。出于历史原因,它们通常仍然按字节存取内存,就像在 8 位时代那样。这被称为字节寻址,意思是,在例如 32 位架构中,一个字被分散存储在 4 个字节中,每个字节有独立的地址。假设我们要存储一个 32 位字,如 12B4A85C[16]。我们可以使用 4 个字节,分别包含 12[16],B4[16],A8[16],和 5C[16]。

关于这些字节应该以何种顺序存储在内存地址中,经历了几十年的标准战争。这个顺序被称为字节序大端序认为字节应该按照顺序(12[16],B4[16],A8[16],5C[16])存储,因为这看起来像人类可读的数字 12B4A85C[16]。大端序认为,这种方式能让看到架构的人,包括架构师和汇编程序员,感到更轻松和更好。

然而,小端序认为数字应该按(5C[16],A8[16],B4[16],12[16])的顺序存储。这最初让大多数西方人觉得很疯狂。特别是,如果你按这个顺序将字节串联起来,你得到的将是没有意义的数字 5CA8B412[16],而不是期望的 12B4A85C[16]。然而,小端序指出,这样的排序是基于某些文化偏见的。

西方使用的是阿拉伯十进制数字系统,数字从左到右按从高到低的幂次排列。这个系统是从原始的阿拉伯数字中引入的,保持不变。但阿拉伯文本是从右到左书写和阅读的,正好与西方的文本相反。在阿拉伯语中,像“24”这样的数字字符串写法是相同的,数值也和西方一样是 24,但它是从右到左读作“four and twenty”。零位列是单位,第一列是十位。这样做在进行算术运算时是有意义的,因为几乎所有的算术算法都是从零位列开始操作,并逐步处理较高的位列。这些列的数字与基础被提升的幂次相对应——例如,零位列是单位,或者说是零次幂。

小端系统分配数值地址,使得第零字节位于字的地址零偏移处,第 n 个字节位于 n 字节的偏移位置。这在某些情况下可以使机器的算术运算更简便、更快速。例如,如果机器在加法运算中处理两个不同字节长度的字(比如,一个短整型和一个长整型),它就能快速且轻松地找到每个字节的 n 位。同样的问题也可能出现在包含可变长度指令的字中:采用小端格式时,你总能确保操作码位于零偏移位置,而无需去寻找它。小端格式在商业架构中现在占主导地位,因此它实际上已经赢得了这场“战争”。

内存模块

RAM 和 ROM 通常以离散模块的形式出现,可以通过添加和移除来更改可用内存的数量。采用总线架构时,这些模块可以很方便地连接或断开。例如,图 10-2 显示了一个 ROM 模块和两个 RAM 模块与 CPU 共享同一总线的情况。

图片

图 10-2:包括 CPU、两个 RAM 模块和一个 ROM 模块的总线架构

一般来说,RAM 和 ROM 都可以有多个模块。所有的 RAM 模块都可以看到沿总线传输的相同信号,但每个模块被配置为与地址空间的不同部分对应,因此只有托管指定地址的单个模块会作出响应。

所有总线模块——包括内存和 I/O 模块——通常都会制造为响应某些默认的地址空间,比如从地址 0 开始。然而,当它们被安装到总线上时,这些地址需要重新映射,以便与其他模块进行比较时具有唯一性。这一重新映射是由称为 内存控制器 的数字逻辑组件完成的,它们监听总线上的全局地址,并将其路由到相应的模块,转换为该模块的本地地址。

随机访问存储器

随机访问 意味着可以快速选择并访问内存中的任何位置,而不需要某些区域比其他区域访问更快。相比之下,像磁带或打孔卡片那样的存储方式就不是随机访问,因为在顺序访问数据时,跳到远距离位置的速度通常比快进或倒带要慢。虽然 RAM 代表“随机访问存储器”,但它是一个历史上的误称,并未全面描述其特点。根据现代约定,RAM 指的是不仅具有随机访问能力,还可以读写,并且是 易失性 的内存,这意味着机器断电后数据会丢失。许多 ROM 也是随机访问的,但由于它们不符合该术语的其他定义,因此通常不被视为 RAM。

历史上的 RAM

我们已经在第三章中讨论了巴贝奇的解析机内存,这也是今天 RAM 架构的基础。在解析机中,每个内存地址对应一堆齿轮的堆叠,其旋转代表一个字。一时间只能有一个地址与总线物理连接。一旦连接,任何齿轮的旋转都会首先传递到总线的线性运动,再传递到 CPU 中寄存器的旋转,反之亦然。现在让我们来看一些其他历史上的 RAM 实例。

声学汞延迟线 RAM

在《从组合逻辑到顺序逻辑》一节中,第 144 页我们讨论了由电吉他和功放反馈回路产生的音频反馈的有无如何用于存储 1 位信息。事实上,这正是 UNIVAC 时代计算机内存的实现方式,采用了汞延迟线,如下图所示。

Image

延迟线实际上是将麦克风和扬声器放置在一定距离处,通过反馈来存储一位信息。通过将它们放置在管道的两端,并将管道填充汞,可以延迟声音的传播速度,从而使管道比使用空气的早期版本更短。

在这个时代的机器中,延迟线可以像在解析机中那样组织成地址空间。当 CPU 执行加载或存储操作时,会通过断开和连接电路来实现,将所需的延迟线连接到总线,断开其他线,并将数据的副本放到总线上进行传输。

威廉姆斯管 RAM

曼彻斯特宝贝机是为研究一种新型 RAM——威廉姆斯管而建造的。该技术如下面所示,诞生于 1946 年,基于旧电视屏幕中使用的阴极射线管(CRT)。

Image

与 CRT 屏幕一样,威廉姆斯管通过电子束发射一束电子流,并利用可调磁铁来偏转电子束,使其一次落在一个像素上,扫描模式覆盖整个屏幕。屏幕由荧光材料制成,这意味着每个像素在吸收电子束时会发光。与 CRT 电视和显示器不同,威廉姆斯管的目的是作为实际的 RAM 存储,而不是供人类读取的显示器。像素在被电子束击中后,会在短时间内保持其电荷和颜色。这意味着它们可以用作反馈系统:我们使用扫描电子束写入满屏像素,快速读取屏幕状态,然后将读取的数据传回扫描电子束,再次写入屏幕。这种方式刷新了屏幕上的数据,使其保持活跃,直到我们希望它保持为止,而不是让像素逐渐消失。

原始的威廉姆斯管屏幕包含 32 个字,每个字为 32 位,每行屏幕为一个字,每列屏幕为字中的一个比特。因此,整个系统存储了 32×32 = 1,024 位。荧光材料采用磷光体,当电子束撞击时,它会发出绿色光。

静态 RAM

我们之前在图 6-22 中看到的由触发器构成的 RAM 被称为 静态 RAMSRAM(发音为“es-ram”)。由于 SRAM 是由触发器(与 CPU 寄存器相同的结构)构成的,它既快速又昂贵。触发器通常由大约四到六个晶体管组成(具体取决于触发器类型以及逻辑门的实现方式)。它们具有稳定的内存状态,这意味着它们不需要主动刷新。写入后几乎可以立即读取它们。SRAM 与 CPU 寄存器的不同之处在于,SRAM 是寻址的,而 CPU 寄存器则不是。

SRAM 通常用于实现缓存,如本章后续所讨论的那样。它通常不用于主内存,除非在一些特殊且昂贵的机器中,例如高速路由器,在这些机器中,主内存访问速度至关重要。图 10-3 显示了一个 SRAM 芯片。

Image

图 10-3:一个 SRAM 芯片

类似这样的缓存芯片可以放置在 CPU 和 RAM 之间。或者,类似的 SRAM 缓存也可能与 CPU 在同一硅片上。

动态 RAM

动态 RAM (DRAM) 比 SRAM 更便宜且更紧凑,但速度较慢。它不是由触发器构成,而是使用更便宜且较慢的电容器。电容器是用于储存电荷的组件。它由两块金属板和一层绝缘物质隔开。电流不能穿过这两块板,但在其上施加电流会使它们积累电荷,直到它们充满电。电容器通常不会出现在 CPU 设计中,它们是另一种电子元件。一个 DRAM 存储位由一个晶体管和一个电容器组成。电容器可以使用与晶体管制造相似的掩膜工艺在硅上制造。

作为 RAM,DRAM 采用与 SRAM 相同的寻址系统,其电路图与 SRAM 基于存储在地址中的字的总体结构相同。不同之处在于,字是由电容器而非触发器实现的(图 10-4)。

DRAM 结构为一个 2D 数组,由字或字节组成,每个字或字节位于一个“行”和“列”中。请求的地址由内存控制芯片转换为每行和每列的两个较小的地址,这些地址通过一个单一的晶体管在组合地址上进行与门操作。这节省了大量的数字逻辑,但将地址分成两部分所需的工作使得 DRAM 的寻址速度比 SRAM 慢。

由于电容器的特性,读取 DRAM 会使其放电并销毁存储的信息(就像分析引擎的 RAM 一样)。读取和写入电容器状态是一个模拟过程,需要一定的时间来完成。由于电容器是模拟设备,电荷也可能随时间泄漏。为了处理这些相关问题,DRAM 必须定期刷新,例如,在 2018 年版的 DRAM 上,大约每 64 毫秒刷新一次。(不断刷新是“动态”DRAM 的根源。)像水银线路和威廉姆斯管一样,刷新过程会读取当前状态,然后在短时间内重写它。刷新必须小心计时,有时可能与 CPU 的读取或写入发生冲突,导致 CPU 需要等待刷新完成后才能重新尝试。

Image

图 10-4:一个 DRAM 电路,展示了电容器和寻址

DRAM 受益于预充电,这大致是一种在使用之前“预热”的方式;这样可以避免与访问发生冲突的重新充电。因此,现代的 CPU 和内存控制器会协作,尽量预测——提前几个指令——哪些内存在使用前应该被“预热”。

现代的 DRAM 芯片通常被打包在大约八个芯片的印刷电路板模块上,每个芯片共享一部分地址空间,如图 10-5 所示。这些模块通过标准接口连接到主板,如之前在介绍中所见(图 2)。可以通过将更多 DRAM 模块添加到台式机的内存插槽中,来增加额外的内存。

Image

图 10-5:一个 DRAM 模块

单列内存模块(SIMMs)具有 32 位总线宽度,它们曾是 1990 年代 PC 的标准。双列内存模块(DIMMs)在 2000 年代取代了 SIMMs。它们具有 64 位总线宽度,每个 DIMM 存储多达几千兆字节。双倍数据速率(DDR)DRAM 通过一种使数据能够在时钟的上升沿和下降沿同时传输的技术,使 DRAM 的速度翻倍。这使得带宽翻倍(因为带宽 = 总线宽度 × 时钟速度 × 数据速率)。SIMMs 和 DIMMs 经历了几个改进的标准,可以通过不同的缺口位置直观区分,设计目的是使它们只能插入到正确类型的插槽中。

错误更正码 RAM

RAM 像其他芯片一样,已经被微型化到接近原子尺度。在这些尺度下,量子效应和粒子物理学开始发挥作用。量子效应可能包括各种类型的固有噪声和关于用于内存的粒子位置的不确定性。宇宙射线是最常见的随机粒子,通常包括电子、α粒子和μ子,它们以高速穿越太空,来源可能是太阳或银河系的其他地方。如果宇宙射线与 RAM 的敏感组件发生碰撞,它可以破坏该组件并翻转其布尔状态。

错误校正码内存 (ECC-RAM) 在 DIMM 上有额外的芯片,这些芯片存储数据的额外副本或校验和,并利用它们在硬件级别自动修正类似的错误。ECC-RAM 主要应用于太空领域,那里计算机位于地球大气层的保护之外,因此更容易受到宇宙射线的影响。随着价格的下降,ECC-RAM 也可能出现在其他高价值、关键安全系统中。

ROWHAMMER 漏洞

Rowhammer 指的是当前影响计算机安全的一组内存硬件漏洞。DRAM 电容器现在非常小且密集,以至于它们的电场可能会影响邻近的内存行。安全研究人员已经开始利用这一效应来读取和写入目标程序的内存。研究人员编写新程序,并安排将它们存储在内存中物理上紧邻目标程序的区域,例如存储在线银行密码的地址。然后,他们在自己程序的位置加载并存储数据,方式上可能会触发自己与目标内存中电容器的物理交互。例如,这可能包括将自己的地址置于可能引起目标内存中类似宇宙射线风格错误的状态。或者,他们可能通过观察自己读写中的相似错误或由于目标电容器状态引起的微小时间延迟来推断目标内存的状态。

当前的研究正在进行,以防御 rowhammer 攻击。方法包括使用 ECC-RAM 来修正任何恶意引发的宇宙射线风格错误,使用更高的内存刷新率,以及通过操作系统代码等软件层面的解决方案来随机化程序在内存中的位置,从而防止代码与目标程序的交付性共同定位。

只读存储器

只读存储器 (ROM) 传统上指的是只能读取而不能写入的内存芯片,这些芯片由制造商预先编程,内含永久性的子程序集合,然后被安装在主内存中的固定地址处。ROM 随着时间的推移,已经发展出包括其他类型的内存,这些内存并不完全符合这一传统定义或名称。

首先,ROM 与 RAM 的区别从未真正存在,因为如前所述,ROM 芯片和 RAM 一样是随机访问的:它们被安装在主地址空间中,访问其中的任何地址所需的时间都是一样的。ROM 和 RAM 的区别在于,RAM 是可读写的,而 ROM 传统上只是可读的。

其次,ROM 随着时间的推移发展出了越来越容易重写的特性,存储在 ROM 中的程序现在可以以某种方式被重写,这些程序通常被称为 固件。以下部分描述了这一演变的主要步骤,如图 10-6 所示。

图片

图 10-6:只读存储器的演变:MROM、PROM、EPROM、EEPROM 和 SD 卡挂载的闪存。请注意,不同寻常的是,EPROM 封装中实际的硅片是可见的,通过一个透明窗口,可以将其暴露在光下。

让我们来了解一下这些不同类型的只读存储器。

掩膜只读存储器(Mask ROM)

掩膜只读存储器(MROM)是一种由制造商通过光刻技术编程的只读存储器。它永远是只读的,无法覆盖。如果你想更新 MROM 芯片,你必须将其取出、丢弃,然后插入一个包含新内容的全新芯片。光刻技术非常昂贵,因此 MROM 的生产和升级都很困难。

可编程只读存储器(Programmable ROM)

可编程只读存储器(PROM)是对 MROM 的巨大改进。与第五章中讨论的可编程逻辑阵列(PLAs)类似,PROM 是通过光刻技术制造的芯片,包含一个通用电路和多个保险丝。程序员可以选择性地熔断保险丝来创建不同的结构。虽然 PLAs 使得可以以这种方式烧录任意数字逻辑网络,但 PROM 则包含一个固定的地址和字结构,只允许烧录组成字的位来制作只读存储器。通常,每个位在其保险丝完好时包含 1,若其保险丝熔断则变为 0。像 PLAs 一样,PROM 一旦编程后就不能被擦除。

可擦除可编程只读存储器(Erasable Programmable ROM)

可擦除可编程只读存储器(EPROM)类似于 PROM,但该芯片的数据可以通过紫外线照射来擦除。然后可以重新写入新的数据。这个过程可以反复进行很多次。尽管擦除过程相当复杂,需要将芯片从计算机中取出并放入光照盒中,但这仍然是你作为一名熟练的最终用户客户可以做的,无需计算机制造商的帮助。

电可擦可编程只读存储器(Electrically Erasable Programmable ROM)

电可擦可编程只读存储器(EEPROM)类似于 EPROM,你可以擦除整个芯片并重新写入它,但在这里你只需要使用电流来擦除和重新编程。这消除了物理操作 ROM 的需要;它可以保持在计算机内部。今天,EEPROM 被用于那些可以升级固件的 ROM。如果你曾经进行过固件更新,你会看到它完全可以通过软件完成,而无需物理接触任何东西。你不希望每天都更新固件,但可能每年更新一次,或者当发现有 bug 修复时。

闪存(Flash Memory)

闪存是可以按块擦除和重写的 EEPROM,这意味着你可以选择性地擦除和重写内存的一个小部分或块。这样,你就可以保持大部分 ROM 不变,不像常规 EEPROM 那样,每次必须擦除和重写整个 ROM 芯片,如固件更新时的做法。闪存使得在芯片在线时,频繁重写 ROM 的一部分变得更加容易,在某些情况下,它几乎像 RAM 一样起作用。

缓存

缓存是 CPU 的快速寄存器和较慢的 RAM 之间的内存金字塔中的额外层级。它存储最常用的内存内容的副本,使它们可以快速检索。(缓存是一个过时的词,指的是存储食物、武器或海盗宝藏等物品的地方。)没有缓存时,RAM 会直接连接到 CPU,要么是直接连接,如第七章所讨论,要么是通过带有控制(C)、地址(A)和数据(D)线的总线连接,如第九章所讨论,并在图 10-7 中总结。

Image

图 10-7:基本的 CPU、总线和 RAM 架构

这种无缓存架构的问题在于,大多数程序需要频繁访问 RAM,但实现 DRAM 的电容器比实现 CPU 寄存器的触发器要慢。因此,RAM 成为系统速度的主要瓶颈。如果 RAM 的速度慢得多,而 CPU 需要等待每次加载和存储操作完成,那么即使有一个快速的千兆赫 CPU 也没有用。为了避免这些瓶颈,在 CPU 和 RAM 之间添加一个基于 SRAM 的缓存(如图 10-8 所示)有助于解决这个问题。

Image

图 10-8:带有缓存的基本 CPU、总线和 RAM 架构

当 CPU 需要加载一些数据时,缓存会检查它是否已有该数据,如果有,则快速返回。如果没有,缓存会查阅下一级内存(如图 10-8 所示的 RAM),并从该级别获取数据。缓存也可以发生在所有内存层级中,从寄存器到硬盘和自动唱机(关于后者将在“三级存储”部分进一步讨论)。然而,在这里我们主要讨论的是在寄存器和主 DRAM 内存之间的主内存级别的缓存。

初始设计使用了一个由 SRAM 构成的单一缓存。近年来的机器利用摩尔定律中晶体管密度的提升,将硅芯片填充上更大的缓存和更多级别的缓存。如今,至少有三个缓存层级——L1、L2 和 L3——这种设计已经很常见,如图 10-9 所示。

Image

图 10-9:带有 L1、L2 和 L3 缓存的基本 CPU、总线和 RAM 架构

所有这些位于 CPU 和 DRAM 内存之间的缓存层通常使用 SRAM 制造,但它们有不同的操作策略,通过不同的数字逻辑实现,权衡了大小和速度。在历史上,缓存通常位于 CPU 外部的专用芯片上。虽然较低级别的缓存仍然如此,但一个主要趋势是将更大、更高级别的缓存直接集成到 CPU 硅片中。

了解你机器的缓存有助于你编写更快的程序。通常,每一级缓存的速度是下一级缓存的 10 倍,因此当你填满某一级缓存时,你会看到内存访问的突然变慢。如果你知道缓存的大小,你可以重新设计代码,将正在使用的数据保持在已知缓存级别的限制内,从而利用缓存的速度。

缓存概念

缓存基于局部性原理,该原理指出,在任何给定时刻,只有少量的内存空间被访问,并且该空间中的值会被反复访问。因此,将最近访问的值及其邻近值从较大、较慢的内存复制到较小、较快的内存是有用的。有多种不同的方式来理解“邻近”和“局部性”。时间局部性是指值倾向于在相近的时间内被反复访问。顺序局部性是指某些序列倾向于以相同的顺序多次重新访问。空间局部性是指内存中相邻的值倾向于一起被访问。这些概念适用于指令和数据,通常由于循环和子程序的存在而产生。

缓存内存由许多缓存行组成。每一行包含一个,该块包含多个来自内存的连续字的副本,以及一个标签,它是一个地址或其他标识符,描述了哪个内存位置的值被复制到了该块中。每一行还具有一个脏位,用于跟踪 CPU 是否已更改缓存中的值,使其与内存中相应的值不同。表 10-1 显示了一些示例缓存行。

表 10-1: 缓存行

标签 脏位
$08F4 01101100 01101100 10011010 1
$2AD5 10010101 11100110 00110110 0

表中显示的每个缓存行包含一个由三个 8 位字组成的块,一个由 16 位地址空间中的完整地址构成的标签,以及一个脏位。第一行的脏位为 1,表示该行已被更新,而第二行的脏位为 0,表示该行未被更新。

我们不是缓存单个地址,而是缓存缓存行,因为移动较大块的内存比单个字更便宜。通过将目标字周围的整行数据带入缓存,我们利用了空间局部性——相邻位置的数据和程序很可能会被接下来使用。这一行已经为此做好准备。

一些缓存系统使用“哈希函数”来选择缓存中存储数据的位置,通常是基于数据在低级内存中的地址。哈希函数是一种多对一的函数,它将一个大的输入数字映射到一个较小的输出数字,即哈希值。通常无法从哈希值恢复出原始值。例如,取一个十六进制数字的最后两个十六进制数字是一个简单的哈希函数:hash(9A8E[16]) = 8E[16]。对数字的所有二进制位执行布尔与运算的函数是另一个哈希函数:hash(01101001[2]) = 0&1&1&0&1&0&0 = 0。缓存中常用的哈希函数是计算地址对缓存行数取模的值。

在缓存中找到一个项目叫做命中。在缓存中找不到一个项目叫做未命中。当发生未命中时,我们必须回到底层内存去查找该项目,通常会在缓存中为将来使用创建一个新的副本。命中率是命中次数与尝试次数(命中和未命中)的比率。它衡量了成功的缓存查找的比例。未命中率是未命中次数与尝试次数的比率。它衡量了不成功的缓存查找的比例。命中时间是访问请求数据所需的时间,如果发生命中的话,未命中惩罚是处理未命中所需的时间。

缓存只有有限数量的行,当我们存储从底层内存访问的所有数据副本时,这些行很快就会被填满。一旦缓存满了,我们将继续请求新的地址。初始时,这些请求会错过缓存,但时间局部性表明,这些新地址比缓存中的旧地址更可能被重用。因此,我们应该选择缓存中的某些行进行覆盖,丢弃它们之前缓存的地址,并用新地址替换它们。被覆盖行的内容称为牺牲品

一旦我们有了缓存结构,就需要使用快速数字逻辑实现算法来管理它。我们需要决定如何最好地利用可用的缓存行,以及如何创建和查找标签。与大多数数字逻辑设计一样,简单的方法和快速的方法之间总是存在权衡。后者通常需要更多的硅片,导致它们更加复杂、容易出错且成本更高。让我们来看看一些使用缓存的选项。

缓存读取策略

从缓存读取比写入缓存要简单,因此我们将首先研究一些缓存读取算法的选项。

直接映射

直接映射是最简单、最容易理解且最便宜的缓存读取策略。它在图 10-10 中有所展示。

Image

图 10-10:直接映射缓存读取策略(显示查找和缓存)

本质上,我们存储或查找标签的行是使用该标签的固定哈希值来寻址的。带有此标签的行将始终只存储在一个位置。如果多个行争夺该位置,新的行将替换旧的行。例如,假设我们从地址 67AB[16]加载数据。我们可能计算出哈希(67AB[16]) = 4[16],这意味着该地址及其内容将缓存到行 4[16],并覆盖该行之前的任何内容。

缺点是直接映射如果多个地址共享相同的哈希值,无法将它们的多个在用地址保存在缓存中。假设我们的程序有一个紧密的循环,反复读取和写入两个交替的地址 67AB[16]和 12C9[16]。这里的问题是哈希(67AB[16]) = 哈希(12C0[16]) = 4[16]。这两个地址将不断相互竞争,互相覆盖行 4[16],即使在循环中没有使用其他地址或缓存行的情况下也是如此。在这种情况下,缓存根本不会带来任何好处,因为每次访问都会失败。

完全关联

为了解决直接映射的问题,我们希望地址能够根据行的使用情况使用不同的缓存行,从而使得最少使用的行受到影响,正如在图 10-11 中的完全关联缓存示意图所示。

Image

图 10-11:完全关联缓存示意图

在这里,每一行缓存 RAM 都有自己的数字逻辑模块,包括比较器、选择器和 OR 阵列。这里只为说明 purposes 展示了三个这样的模块,但对于一个 256 行的缓存,实际上会有 256 个这样的模块,全部并行工作。

我们希望能够在任何可用行上存储标签、数据块和脏位,并能快速找到它。缓存是这里的简单部分:我们只需创建一些数字逻辑来统计每一行的使用频率,并选择使用频率最低的行。

缓存查找是更难的部分。在直接映射中,我们只需计算与缓存使用相同的哈希函数,来告诉我们在哪一行找到所需的地址。现在它可能位于缓存中的任何位置,因此我们需要添加大量额外的数字逻辑,以检查每一行的标签是否与所需标签匹配,并在存在匹配时激活该行。以并行方式执行此操作(这是唯一可以让其足够快以便有用的方法)需要N个匹配数字逻辑的副本,每个缓存行有一个副本,使得系统变得更大且更耗能。

集合关联

集合关联缓存读取是一种试图同时兼得上述两种方法优点的做法。在这里,我们将N行缓存分成几个较小的行集合。我们使用地址哈希来计算一个集合编号,而不是计算行号。在缓存过程中,我们通过这个哈希找到集合编号,类似于直接映射方法,然后选择该集合中使用最少的行作为替换行,类似于完全关联方法。在查找时,我们再次通过哈希找到集合编号,然后在该集合中并行匹配所有项,以快速找到匹配的行。

这种方法意味着我们只需要激活单个集合中的比较器,而不是整个缓存,但我们仍然避免了紧密循环共享哈希值的直接映射问题。实际上,这通常被认为是一种良好的平衡。

缓存写入策略

当我们进行存储操作时,缓存会变得更加复杂,因为存储操作会改变内存的状态。假设我们最近从地址 540A[16]加载了一个整数 17,并在加载时缓存了一个副本。我们想将这个整数递增到 18,并将结果存回到 540A[16]。由于局部性原则,我们很可能在不久的将来继续从 540A[16]加载和存储,因此与其直接将 18 存储到 540A[16],不如只将它存储在当前缓存 540A[16]的缓存行中。这样,所有未来的加载和存储操作都可以直接命中缓存,而不需要访问主存。

问题在于,最终这行数据会被替换,我们会丢失对值所做的所有更改;主存中仍然包含旧值 17。为了避免这种情况,在某个时刻我们需要将修改后的值复制回主存。前面在表 10-1 中显示的脏位会跟踪是否需要进行此操作。如果缓存行中的值与内存中的值相同,脏位被设置为 0;如果缓存行中的值已更新,但内存中的值没有变化,脏位则设置为 1。名为缓存写入策略的算法利用这个脏位来管理回写到内存。我们来看两种不同的方式:写回和写穿透。

写回

写回是更简单的缓存写入方法:只有当缓存行被替换时,它才会将缓存块的内容复制回 RAM。然而,这相对较慢,因为替换只有在指令急于执行时才会发生。我们被要求在替换被宣布后开始写回,而替换的指令将不得不等待我们进行一次缓慢的 RAM 访问,才能覆盖我们的替换行。

写穿透

写直达 是一种可能比写回更快的替代方案,尽管它使用更多的资源。在写直达中,我们不会等到行被淘汰后才将行的块复制回 RAM;相反,我们会在后台不断地多次执行这个操作,使用附加在缓存行和总线上的数字逻辑。这些逻辑的作用类似于 SyncThing 或 Dropbox 这样的应用程序,不断监视缓存版本中的任何更改,并将其复制回 RAM 中的主版本。由于这些额外的数字逻辑位于缓存本身,因此不会给 CPU 增加额外工作。然而,这会导致总线上的流量增多,因为我们比起写回方式要更多次地发送这些更新。

高级缓存架构

请考虑缓存如何与第八章中高级 CPU 技术互动。管道化 CPU 需要非常关注缓存未命中,因为它们会形成另一种可能的危险。高效的管道可能会假设内存访问将会被缓存,如果发生未命中,它们将需要停顿或以其他方式处理这种危险。

你在第八章中看到过,分支预测试图猜测程序的执行流程,以便使管道和乱序执行更顺利。这可以与缓存配合使用,预先 获取和存储数据——也就是说,在实际的加载和存储指令到达之前就开始进行。这些指令的执行时间远长于 CPU 内部操作,因此提前启动它们是有用的。CPU 可以提前预测程序中哪些主内存的部分可能在后续指令中需要,从而提前开始缓存这些部分,以使 CPU 的获取速度更快。

如前所述,缓存的每一层——L1、L2 和 L3——相较于其下层提供大约十倍的速度提升,因此,预先将数据移到更高层次的内存层次结构中所带来的潜在收益并非微不足道。如果抢占发生错误,缓存可以随时回滚,CPU 会停顿。如果我们把错误的数据带入缓存,并不意味着世界末日:缓存是一个很大的地方,改变其中的内容是可以接受的。

由于 DRAM 地址的行列结构,在一次性读取单个 DRAM 行中的多个项比单独读取更快。(一旦激活一行,读取多个列几乎是免费的,而读取单个列则相对较慢。)因此,现代 DRAM 控制器通常会与缓存协同工作,将大块的 DRAM 行移入缓存行。

如果我们事先知道数据不需要很快再次读取,缓存写入可能会不必要地拖慢系统速度。在这种情况下,写入缓存然后再传输到主内存的速度可能比直接写入主内存要慢。现代 CPU 可能会提供专门的无缓存写入指令,机智的程序员和编译器开发者可以利用这些指令来加速程序执行。

经验表明,L1 缓存如果分为两个独立的并行缓存,一个用于指令,一个用于数据,通常能更平稳地工作。这种分离可以出现在哈佛架构中,其中指令和数据已经在 RAM 中分开存储,也可以出现在冯·诺依曼架构中,在这种架构中,指令和数据可以通过控制单元(CU)请求它们的部分来区分(指令在取指阶段请求,而数据在执行阶段请求)。这种分离只发生在 L1 级别,更低级的缓存共享指令和数据,如图 10-12 所示。

Image

图 10-12:一种基本的 CPU、总线和 RAM 架构,具有独立的 L1 缓存用于指令和数据,以及共享的 L2 和 L3 缓存

在 L1 级别分离指令和数据似乎是有效的,因为指令和数据各自都有空间局部性,但它们之间几乎没有局部性。而且,指令通常不会被覆盖,而数据经常会,因此将指令分离出来可以简化缓存写入过程。

二级存储和离线存储

二级存储是可以通过 I/O 快速加载到地址空间中的存储。二级存储中的数据项在主存地址空间中没有地址。相反,它们是通过 I/O 访问的,通常通过一个位于主地址空间中的 I/O 模块,该模块将请求转发到二级存储。二级存储有时被称为在线存储,以强调它在计算机开启时有电、处于活动状态并且随时可用。

离线存储是指无法在没有手动干预的情况下自动加载到主存中的存储。通常这包括物理上可弹出和更换的二级存储介质,如磁带、光盘和 USB 设备。连接到计算机时,这些介质是二级存储,断开时则是离线存储。离线存储通常用于备份和归档,以及运输。将 PB 级数据快速移动到世界各地的最快方法仍然是将其作为离线存储装载到卡车上,然后运输到目的地。

现在,二级存储和离线存储应该用比特和国际单位制(SI 单位)来衡量——例如,描述“8.8 太比特硬盘”而不是“1 tebibyte 硬盘”。这是因为它们不是主存地址空间的一部分,因此不使用主存的字或字节地址进行寻址。与现代主存相比,字节的概念在这里甚至更不相关。然而,由于主存仍然通常按字节寻址并以字节为单位进行测量,大多数人对字节单位的大小感知更好,因此他们选择以相同的单位来度量二级存储。

辅助(以及离线)内存通常的特点是需要一些机械运动来查找数据,而不是随机访问。这包括通过磁带滚动或旋转由各种材料制成的盘片。接下来,我们将详细了解这些技术的一些细节。

磁带

磁带 是一维数据存储设备,必须左右滚动以定位所需的数据。你可以将人类手写的纸卷,如《托拉》经文,视为最初的磁带。磁带不是随机访问的,因为读取设备在磁带的某个位置,且移动磁带(或读取设备)以访问远距离位置所需的时间,比访问附近位置要长。使用磁带存储的快速算法需要考虑这种结构,并优化内存访问,以减少大的地址跳跃。

打孔卡片

打孔卡片 是最初的计算辅助存储设备,如在贾卡尔织机和分析机中使用(见图 1-11)。它们继续在 IBM 霍勒里特机器中使用,并用于存储和读取 20 世纪 60 年代早期电子机器的程序。偶尔的工业使用甚至持续到 1980 年代,且据说至少有一个英国委员会至今仍在使用它们。在打孔卡片中,数据的二进制数字通过在卡片或纸张的系列物理位置上打孔或不打孔来表示。孔的大小通常和你买来为文件存档用的桌面打孔机的孔大小相似。

卡片是二维的,具有行和列。通常每行存储一个字,行号作为地址(在辅助地址空间中,而不是主 RAM 地址)。从概念上讲,有时甚至在物理上,卡片的叠加是链式连接的,形成了实际上是二维的磁带。

打孔带

打孔带 是打孔卡片的一种替代品。这种带子曾被英国邮政局使用,构成了图灵机的灵感,并且也曾用于科洛苏斯(见图 1-22)。根据你的视角,磁带在概念上比卡片更简单,因为它只是单一的一维位数组;或者它比卡片更复杂,因为你必须更多地关注对齐和读取单词,而卡片则可以轻松地按行呈现。

磁带

磁带 在 1920 年代为模拟音频录制而开发,1960 年代商业化为家庭使用的 8 轨系统,然后在 1980 年代广泛用于 4 轨的紧凑型磁带中。模拟磁带在 1980 年代也被广泛用于家庭视频录制,伴随了现代数据标准战争中竞争的 VHS 和 Betamax 格式。

在这些系统中,像氧化铁这样的可磁化材料被形成磁带结构,并通过磁带上每个点的磁化水平来存储数据。与打孔纸不同,磁带容易重新磁化,可以反复写入多次。

相同的磁带可以以多种方式用于存储数字信息。例如,0 和 1 可以通过两种不同的可听频率的单个周期进行编码——这是一种对大多数磁带设备添加的重噪声具有较强抗干扰性的技术。为优化打孔带访问开发的算法直接应用于磁带,正如在 1980 年代的图 10-13 中的机器所示。

Image

图 10-13:1980 年代的紧凑型卡带及播放/录音机,用于模拟音乐和数字文件存储

磁带今天仍然被用于离线存储,特别是用于公司系统的每周或每日备份。磁带价格便宜、成本效益高,适合大规模存储,且对访问时间要求较低。因此,磁带非常适合用于日常备份任务,因为你希望尽可能长时间保留大量的旧备份。尤其是,如果有人以比单纯删除所有文件更微妙的方式攻击你的公司——例如,逐步对数据库进行小的修改——拥有一系列长期备份非常有用,这样你就可以从不同的日子、周、月、年甚至几十年前恢复系统的状态。你可以每天花几美元购买一卷新的磁带来确保这一点。拥有大量磁带的另一个好处是它们可以存放在比硬盘更多的地点——例如,每天让不同的员工带回一卷磁带,这样即使一半员工的家在同一天着火,你仍然可以拥有许多近期的备份。

当前最流行的磁带存储标准是线性磁带开放式(LTO),见图 10-14。

Image

图 10-14:IBM Ultrium 线性磁带开放式盒和驱动器

LTO 是一种开源标准,截至 2020 年,在一卷适合放入口袋的磁带中,大约存储了 36TB 的数据,写入过程大约需要 12 小时。这对大多数小型企业来说是一个很好的尺寸和时间;他们可以在一晚之间将整个系统备份到单个磁带中。

磁盘

音频录制始于 19 世纪 70 年代的蜡筒,见图 10-15。

Image

图 10-15:蜡筒音频存储设备

在这里,声波进入声学号角并被集中,振动针头,将声波刻录成旋转的热蜡筒上的螺旋形轨迹,同时蜡筒缓慢地从左向右移动。当蜡筒冷却后,可以再次旋转通过针头,使其以相同的方式振动,并通过号角放大其运动,从而重播声音。

蜡筒唱片曾在商业上使用,直到 1898 年被旋转每分钟 78 转的唱片取代(图 10-16,左)。这些“78”转盘使用了将模拟声音波形直接刻入螺旋槽中的相同思想,它们的乙烯基后代——现在带有电放大——至今仍被 DJ 使用(图 10-16,右)。

图片

图 10-16:留声机(左)和现代的 Technics SL-1200 转盘(右)

与音频光盘不同,音频光盘只有一条从边缘到中心螺旋的轨道,大多数数据磁盘实际上是二维的,因为它们有许多独立的轨道,每个轨道在固定的半径上,如图 10-17 所示。

图片

图 10-17:音频光盘的单轨(左)和数据磁盘的二维轨道(右)。后者展示了轨道(A)、扇区(B)、几何扇区(C)和簇(D)。

靠近边缘的轨道比中心的轨道更大,因此它们能存储更多的数据。轨道沿着其圆周被划分为固定数据大小的扇区。每个扇区都有一个地址,由轨道 ID 和轨道内的位置信息组成。在大多数系统中,扇区会存储一些位来表示它们的位置,这样我们就可以知道正在查看磁盘的哪一部分。它们还可能存储冗余位,用以补偿磁盘的物理损坏,利用香农的通信理论。扇区可以被组合成连续的,簇是可以一起读取或写入的最小单元。

磁盘上的数据可以以几乎随机访问的方式进行访问:各个扇区可以按任意顺序存储或检索,不仅限于顺序访问,但由于磁盘和磁头的运动,访问相邻的扇区和轨道会更快。从同一轨道上依次读取扇区是非常容易且快速的,因为它们在旋转时经过磁头。如果你需要读取同一轨道但与当前扇区不同角度的数据,你必须等待磁盘旋转到该扇区下方。如果需要从不同的轨道读取数据,则需要将磁头沿半径移动,这个过程非常慢,因为它是一个物理装置。因此,控制旋转磁盘的 I/O 模块需要考虑访问时间——即读取或写入一个扇区所需的时间。访问时间由两个主要因素组成:寻道时间是磁臂定位到轨道上的时间,旋转延迟是所需扇区旋转到磁头下方所需的时间。

软盘

磁盘驱动器使用与磁带相同的技术来表示数据,但它们将磁性材料排列成二维的磁盘,而不是一维的磁带。磁盘通过安装在臂上的磁头进行读取和写入,就像留声机的唱针一样。软盘(图 10-18)最早出现在 1960 年代。之所以叫“软盘”,是因为它们在物理上可以弯曲。

Image

图 10-18:三代软盘:8 英寸(1970 年代)、5 1/4 英寸(1980 年代)和 3 1/2 英寸(1990 年代)

软盘容易受到损坏,因此通常被包裹在塑料外壳中,如图所示。

硬盘

硬盘由非柔性材料制成,能够存储比软盘更高的信息密度,并且转速比软盘快。这些设备通常需要将读写头封闭在一个与磁盘一起的封装内,如图 10-19 所示,而不是像软盘那样允许更换磁盘。

Image

图 10-19:磁性硬盘的内部结构

硬盘驱动器通常包含多个硬盘磁盘,这些磁盘被包装在一起,每个磁盘都有自己的读写头,并且所有磁盘共享一个地址空间。这有助于减少访问时间,因为各个读写头可以一起进行读写操作。硬盘的转速通常在 90 到 250 Hz 之间,这会使一层空气将读写头抬离表面,从而避免了读写头与盘片的物理接触。这意味着硬盘的头部和盘片都不会受到物理磨损。设计师在技术上投入了大量资金,以便在硬盘处于物理危险中时,自动且迅速地停放读写头,例如在硬盘被撞击或推挤时。如果没有这种技术,读写头会在这种情况下撞到磁盘并将其摧毁。

光盘

光盘是现代版的巴比伦泥板,如图 1-5 所示。与这些泥板类似,光盘也是实心物体,表面有小的空洞—称为坑—用于表示数据,如图 10-20 所示。和穿孔卡片一样,光盘采用二进制编码,因此每个位置要么包含一个坑,要么不包含坑。这些坑通过激光读取,并且它们的纳米级尺寸与激光光线的波长相当。

Image

图 10-20:光盘存储的四个发展阶段

激光光盘(1978 年)是第一种光盘,直径为 12 英寸,像黑胶唱片一样,并且主要用于家庭视频播放。光盘,或称CD(1982 年),使用约 800 纳米的凹坑,通过激光头读取,用于存储最多 700MB 的音频数据。光盘在 1988 年开始用于一般数据存储,而不仅仅是音频数据存储,随着CD-ROM规范的出现。与光盘类似,这些光盘在初始刻录凹坑后变为只读。CD-R是简化录制过程的版本,允许用户在家中“刻录”自己的 CD-ROM,且仅能刻录一次。这些光盘在 1990 年代后期被用于复制音频音乐收藏,最初使用 CD 音频格式,后来使用大容量 MP3 存储。它们通常在可刻录面为蓝色,顶部为金色。它们的“刻录”是一个物理过程,涉及激光和热量;这也是现代俚语“刻录”一词的来源,现在被用于写入其他类型的只读存储介质,如闪存或 FPGA。CD-RW是改进版的 CD-ROM,可以被多次重写。

数字多功能光盘(DVD)(1995 年),是一种数量级的改进,减小了凹坑的大小至 400 纳米,使得光盘容量可达到 4.7GB,且使用与 CD 相同大小的物理光盘。DVD 最初用于视频,但很快也用于一般数据存储。与 CD 类似,开发了可一次写入的 DVD-R 和可重写的 DVD-RW。蓝光光盘(如同其短命竞争对手 HD-DVD)再次减小了凹坑的大小,这次减小到 150 纳米,使得同样大小的光盘能够存储最多 25GB 的数据。由于这些凹坑较小,它们需要短波长的蓝色激光光来读取,而不是红外或红色激光,因此得名。

固态硬盘

对于二级存储,大多数当前计算机已从硬盘驱动器转向固态硬盘(SSDs)。这些硬盘的制造方式使其具有与硬盘相同的外形尺寸和 I/O 接口,并且具有相似的存储容量,但没有活动部件。这使得它们更快、更可靠、功耗更低、噪音更小、更小巧,并且在掉落时不易损坏。由于没有活动部件,它们可以实现真正的随机访问。SSDs 是闪存,如我们之前所回顾的。

相同的闪存技术也被用于离线存储,其中 SSD 驱动器是易于移除的,例如通过 USB(称为 USB 闪存)或 SD 卡(称为 SD 卡)连接到 I/O 时。

第三存储

第三存储是最近提出的内存层级中的一个新层级。它位于二级存储下方,但高于离线存储,旨在描述那些曾经是离线的内存——需要人类手动加载和弹出介质,如光盘和磁带——但现在通过机械过程自动化。例如,自动化的蓝光和 LTO 磁带机器人,正如在图 10-21 中所示,构成了第三存储。

图片

图 10-21:数据中心中的机器人磁带库

在图中,使用了机器人臂——就像 1950 年代的黑胶唱片点唱机——来取放磁带并将其放入读写器和存储容器中。类似的机器人系统也可以围绕蓝光光盘构建。如今,驱动硬盘篮子的移动机器人也可以被视为第三层内存。

数据中心

当你将成千上万,甚至是数十万或数百万个辅助和第三层内存放在一个仓库大小的建筑物里时,你就得到了一个数据中心。搜索引擎、社交网络、在线零售商、媒体流媒体提供商和政府如今都需要以这种规模存储和访问数据。一个典型的数据中心将包含许多不同层次的低级内存结构。例如,磁带比磁盘更耗时进行快进和倒带,因此它们更可能作为长期备份系统而不是用于提供最新的社交媒体帖子。一旦你从较慢的备份系统中访问数据,它将被缓存到内存层次结构中更高的地方,比如 SSD 硬盘,这样下次检索就会更快。

数据中心可能会以极高的安全性和韧性为设计目标。例如,汇丰银行的“数据矿”被广泛认为将其全球金融数据的备份存储在英国的一个废弃煤矿中。你可以通过地面上升的巨大空气管道辨认出它是一个数据中心,这些管道用于散热。该矿据说能抵御核、化学和生物攻击。在核战争爆发时,其他人类可能会被迫回到用伊尚戈骨头进行计算,但这家银行仍然能够追讨你的抵押贷款偿还。

总结

内存架构受到经济因素的驱动:你可以购买大而慢、便宜的内存;小而快、昂贵的内存;或者两者的混合。经验表明,大多数程序表现出空间、顺序和时间局部性,其中不同的小部分内存在不同的时间会被频繁和反复使用。因此,内存架构以适应经济和使用模式的层次结构设计,包括各层之间的缓存,以将当前使用的内存提升到更高层级。主内存是由 CPU 直接寻址的内存,通过总线进行访问,而辅助内存则通过 I/O 连接。辅助内存通常以旋转磁盘的形式存在,可以断开并更换,如果涉及到人工操作,则成为离线内存,如果过程由机器人自动化,则为第三层内存。

练习

你计算机的内存

  1. 尝试查找自己计算机中每种内存的大小和速度,包括缓存、RAM 和二级存储。如果你能打开电脑,查看内部并找到它们的品牌和型号,然后在网上查找它们的技术规格。大多数操作系统都有可以显示内存有用信息的工具;例如,Linux 可以使用 lscpucat /proc/cpuinfo 查看缓存,使用 free -h 查看 RAM,使用 lsblk 查看二级存储。

在 LogiSim 中构建静态 RAM

  1. 在 LogiSim 中构建 图 6-22 所示的静态随机存取存储器(SRAM)。它应能在四个内存位置存储并读取 2 位字。

  2. 扩展你的 LogiSim SRAM,使其具有更长的字长和更多的地址。

挑战

  1. 制作四个 SRAM 副本,表示多个 RAM 芯片。每个副本将具有相同的地址空间,从地址零开始。设计一个内存控制器模块,将来自更大全局地址空间(具有两个额外位)的地址转换为特定 RAM 芯片的部分以及这些 RAM 内部的本地地址。

  2. 尝试将该系统连接到曼彻斯特婴儿计算机模型中,替换其原有的 LogiSim RAM。

更具挑战性

  1. 在 LogiSim 中设计并构建一个直接映射缓存,并将其与之前任务中的 LogiSim RAM 连接起来。(这不会加速该 RAM,因为它已经是快速的 SRAM,但它可以让该 SRAM 被更大、更便宜但较慢的 DRAM 所替代。)

  2. 如果你觉得有挑战,可以尝试构建其他类型的缓存。使用本章提供的示意图作为起点。

延伸阅读

要了解关于内存的最新经典资料,请参考 U. Drepper 的文章《每个程序员应该了解的内存知识》,2007 年 11 月 21 日,* people.freebsd.org/~lstewart/articles/cpumemory.pdf *。事实上,这篇文章包含了关于内存的知识,远超任何正常人所需了解的内容。

第十三章:第三部分

示例架构

第十四章:## 复古架构

图片

现在你已经掌握了理论部分,接下来让我们开始一些有趣的实践。第三部分将通过在一系列真实的模拟架构上编程,来巩固你的理论知识。你可以根据自己的兴趣跳过某些章节,但这些章节大致按照复杂性和历史的顺序呈现,因此查看并编程较早的系统,可能有助于你理解后续的系统。

我们之前研究过分析机和曼彻斯特婴儿计算机,而在本章中,我们将进阶到 1980 年代的 8 位系统,然后是 16 位系统。现代嵌入式系统与这些复古系统有些相似,因此我们将在下一章对它们进行研究。接下来,我们将研究 1990 年代的桌面 PC,然后是现代智能和并行架构。在每个阶段,我们将介绍一些在现代设计中依然沿用的新特性。

从 1836 年到 1990 年,经典 CPU 的基本结构变化不大。这个设计伴随我们走过了从分析机到现在所说的 1980 年代黄金时代的架构。在本章中,我们将看两个来自这一黄金时代的设计:著名的 8 位 6502 处理器,曾用于 Commodore 64、任天堂娱乐系统(NES)和 BBC Micro;以及 16 位 68000 处理器,它定义了 16 位机器的一代,包括 Commodore Amiga 和 Sega Megadrive。我们将把这些作为相对简单的经典 CPU 示例进行研究,了解在事物变得复杂之前的样貌。这些示例将帮助你巩固前面章节所学的内容,因此在进行时如果有任何需要查阅的地方,随时可以回头参考。

1980 年代黄金时代的编程

1980 年代的编程由架构主导。1980 年代的硬件市场高度异质化,许多竞争公司设计并生产不同且不兼容的机器。图 11-1 展示了十年间发布的几款不同机器。

在没有现代操作系统的情况下,你可以购买满是打印汇编代码的杂志,将其输入以运行简单的游戏和应用程序。这些代码能够读取和写入机器的整个内存空间,因此你可以精确地看到机器中的一切,感受到与其架构的融合。

像 Commodore 这样的计算机设计公司,使用可编程 ROM 或 PLA 比起自己进行光刻,能够以更低的成本生产自定义 ROM,这些技术是大量家庭计算机系统得以实现的关键。用今天的话来说,这些 ROM 是基本输入输出系统(BIOS),它们是一些子例程,举个例子,打印 ASCII 文本到屏幕;绘制点、线和三角形;以及发出声音。程序员也可以通过 I/O 直接执行这些任务——也就是直接加载和存储到 I/O 模块地址——但为了方便起见,提供了子例程来自动化这个过程。你可以通过将必要的参数放入 CPU 寄存器,然后跳转到 ROM 中的子例程地址,来调用 ROM 芯片上的子例程。

ROM 和 RAM 同样重要,它们协同工作。RAM 是用户数据和用户程序的宝贵资源,这些程序频繁调用 ROM 中的子例程。除了记住 ROM 子例程地址,程序员和社区通常还会为不同任务约定 RAM 的常用区域,因此他们通常能够熟悉整个计算机的内存映射。

由于这些约定,用户可以更加直接地访问他们的计算机。地址数量相当小:32,768(32 k[2]B)或 65,536(64 k[2]B),这意味着你可以找到像游戏中的生命数量这样的变量存储位置,然后直接进入内存进行编辑。直接覆盖内存的操作被称为poke,成功的 poke 会被收集到作弊器磁盘中并传播开来,以修改游戏。

Image

图 11-1:1980 年代黄金时代的不同计算机

8 位时代

1980 年代初期是 8 位时代:这是 Commodore 64 和 Atari 2600 的时代,也是像 Sega Master System 和 Nintendo NES 这样的游戏主机时代,还有英国的 BBC Micro 和 ZX Spectrum。

这些机器共享了一些子组件;例如,6502 处理器被用于 Commodore 64 和 BBC Micro,而 Spectrum 的 Z80 芯片可以作为第二处理器添加到 BBC Micro 中,这样你就可以在这个层面上与朋友分享程序。但这些机器的图形和声音芯片不同,包含不同功能,位于不同的地址上,因此不兼容,通常每台机器都会有自己的朋友、用户组以及围绕它形成的杂志。

这个时代的计算机图形和音乐看起来(参见图 11-2)和听起来像计算机,因为它们反映了计算机的架构,创造了一种今天已经失传的计算机文化。你实际上可以感觉到 8 位游戏的“8 位性”,而今天的 64 位游戏中却无法感受到那种“64 位性”。

Image

图 11-2:典型的 8 位游戏图形示例

即便只是玩游戏——而不是编写——也能潜移默化地学到架构知识。游戏通常是专门为了与架构互动和探索架构而编写的,目的是将其推到极限,并展示编程技巧。例如,8 位架构鼓励游戏使用特定尺寸的精灵和特定布局的关卡。你可以通过覆盖内存中定义字符A的区域,将其替换为一个 8×8 像素的外星人,从而轻松地给Space Invaders动画,而无需任何图形命令。然后,只需将A字符打印到屏幕上,就能移动它。(这样做的缺点是,当你列出程序进行调试时,所有的A也会变成外星人了。)

16 位时代

1980 年代末期推出了 16 位机器,并延续了这种汇编编程风格,但额外的位数和更先进的 I/O 模块使得图像和声音的采样成为可能,而不再像 8 位机器那样纯粹由计算机生成。这些发展催生了具有鲜明 16 位美学的精灵式游戏,比如SonicMario(图 11-3),以及 The Prodigy 等艺术家创作的基于采样的音乐和如Streets of Rage 2等游戏的原声带。

Image

图 11-3:典型 16 位游戏图形示例

流行的机器包括 Commodore Amiga、Atari ST、Sega Megadrive 和 Nintendo SNES。像游戏和演示程序等高性能程序仍然主要用汇编语言编写,能够完全访问内存,但它们会更多地使用对额外图形和声音硬件的调用。

各公司继续在 1990 年代初期生产 16 位机器,包括许多如今已经成为经典的游戏。但此时大多数程序员已转向使用 C 语言,它能够编译成多个机器的汇编代码,使得在不同平台间移植软件变得更加容易。程序员们开始更多依赖重量级操作系统,这些操作系统主要通过 C 库进行访问。C 语言和操作系统共同作用,封装并隐藏了硬件架构,提供了更高级且更便携的接口,但也终结了 1980 年代架构编程的黄金时代。

美好的时光!让我们通过在两个经典系统上学习编程来重温这些时光:8 位 6502 架构的 Commodore 64(C64)和 16 位 68000 架构的 Commodore Amiga。对于每个系统,我们首先会独立学习其 CPU,然后研究它的计算机设计。在练习中,我们将编写 C64 动画文本演示和简单的 Amiga 游戏的汇编程序。

使用 MOS 6502 8 位 CPU 进行编程

MOS 科技的 MOS 6502 是一个 8 位处理器,由 Chuck Peddle 于 1975 年设计。MOS代表金属氧化物半导体,指的是公司使用的 MOS 场效应晶体管(MOSFET)。6502 在 1980 年代许多经典的 8 位微型计算机中得到了应用:如 Commodore 64、NES、Atari 2600、Apple II 和 BBC Micro;它还被用于第一代街机游戏机,如Asteroids

在这里,我们将通过与分析引擎(Analytical Engine)和曼彻斯特宝宝(Manchester Baby)相同的步骤来研究 6502。我们将首先检查其结构,包括寄存器、算术逻辑单元(ALU)、解码器和控制单元(CU)。然后我们将研究其指令集,包括内存访问、算术运算和控制流的指令。

内部子组件

6502 有 3000 个晶体管和连接它们的导线。这些组件的布局是通过透明纸张手工设计并绘制的,使用钢笔和胶带,然后通过光刻技术直接制作成芯片。

注意

“Taping out”一词仍然用于指代现代计算机化的光刻掩模设计完成过程。对于芯片设计师来说,taping out 标志着他们工作的结束,并交接给制造厂。就像软件公司中的“发布”一样,taping out 也是举办大型派对的理由,直到芯片通过邮寄到达并无法正常工作为止。

物理上,6502 呈现为一个长约 2 厘米的塑料封装集成电路(IC),有 40 个引脚,如图 9-2 所示。这些引脚中有 8 个是数据引脚,标记为 D0 到 D7。这些引脚用于读取和写入 8 位数据字到内存中,也定义了 CPU 为 8 位机器。6502 使用一个 16 位的地址空间,通过在 16 个地址引脚 A0 到 A15 上写入 16 位地址来进行访问。这使得最多可以寻址 64 k[2]B 的内存。R/W 是控制线,指示我们是否要读取或写入地址。封装还具有地线、供电电压、时钟和 IRQ(中断请求)引脚。时钟设定 CPU 的速度,通常为 1 到 3 MHz 之间。

实际的硅芯片比外包装小得多,约为 5 mm²。图 11-4 展示了芯片在显微镜下的照片(称为晶片图)。

Image

图 11-4:6502 芯片显微镜照片

这个芯片的设计细节在几十年间丢失,但最近在 Visual 6502 项目中通过英雄般的努力,成功地在晶体管级别进行了完全的逆向工程 (visual6502.org)。该项目的工作人员通过施加酸性物质溶解部分塑料外壳来暴露硅片,然后拍摄芯片的晶片图像,逆向工程其电路图。

该电路仅包含晶体管和铜线,但一些非常擅长芯片阅读的人已经学会了如何观察这些电路并将它们在脑中分块成逻辑门。从那里,它们被分块成著名的简单机器。通过这项艰苦的工作,借助图 11-5 所示的生还区块图,这整个架构得以反向工程并重建。

Image

图 11-5:原始 6502 区块图

图 11-5 中的电路展示了一些典型的子组件,这些组件在大多数经典设计的芯片中都很常见。每个组件都是一个数字逻辑简单机器。我们将逐一检查这些主要的子组件。

怪兽 6502

由于视觉 6502 项目的成果,6502 现在再次被生产出来,用作廉价的嵌入式处理器——例如,在物联网(IoT)设备中——以及用于教育,比如我们目前的研究。该设计还被 Eric Schlaepfer 和 Evil Mad Scientist Laboratories 用来制造一个完全功能性的、但比原版慢的 MOnSter 6502 重建版,这个版本使用了大晶体管而非集成电路(ICs),如下所示。

Image

用户寄存器

在图 11-4 中,寄存器和算术逻辑单元(ALU)位于芯片的下半部分。8 位数据按垂直堆叠排列,类似于巴贝奇的机器。有三个 8 位用户寄存器:两个通用寄存器,分别称为 X 和 Y,以及一个称为 A 的累加器。

X 和 Y 寄存器设计为可以一起使用,表示 16 位地址,其中前 8 位存储在 X 中,后 8 位存储在 Y 中。单独操作这两部分比较困难,因此架构通常提供方法,能够一起操作这两个 8 位部分的 16 位地址。

通常将 8 位内存想象成被划分为 256 页,每页 256 字节。例如,8 位十六进制编辑器可能一次在屏幕上显示一页内存,就像一本书的页面那样。以这种方式看,其中一个字节是页码,另一个字节表示该页上使用的是哪一行。

注意

在 6502 编程实践中,通常使用内存第 0 页的 256 个地址,仿佛它们是额外的寄存器。这样比使用实际寄存器要慢,因此在首选情况下,还是使用 A、X 和 Y 寄存器。

内部寄存器

回想一下,程序计数器用于跟踪当前行号。此处,程序计数器存储一个 2 字节地址。它在执行每条指令后,由控制单元(CU)自动递增,除非是流程控制指令。它可以作为两个独立的字节(PCH,PCL)访问,分别存储 16 位地址的高位和低位。在开机时,6502 将 FFFC 和 FFFD 地址的内容(通常是 ROM,并指向一个 ROM 子程序)复制到程序计数器中,指示程序从哪里开始执行。

这里,堆栈指针是一个字节,它被假定为指向内存第 1 页的行;注意这是第二页,紧跟着第 0 页。在大多数编程风格中,堆栈不是由程序员直接访问的,而是由子程序指令内部使用,用于推入和弹出调用行的地址。然而,它也可以通过指令(PHAPLA)直接访问,这些指令将累加器的内容推入和弹出堆栈。

指令寄存器保存当前指令的副本;在 6502 中,操作码为 8 位,可能需要 0、1 或 2 个字节的操作数。由于数据总线是 8 位的,因此获取指令通常需要多个步骤;操作码和操作数需要逐字节复制。这是 8 位机器速度较慢的原因之一:具有较大字长的机器可以将整个指令(包括操作码和操作数)作为单个字取出。

状态寄存器包含 8 位标志,可以被控制流指令测试和使用。这些标志在 ALU 中设置,我们接下来将讨论它。

算术逻辑单元

在 6502 中,ALU 的物理结构围绕寄存器构建,使得 8 位数据水平流动,就像巴贝奇的机器一样。寄存器-ALU 区域看起来很像巴贝奇的差分机,并且包含类似的并行传播位和进位。如果你把巴贝奇的金属机器微型化到一块芯片上,它的外观大致就是这样;只是尺度发生了变化。

ALU 提供整数加法和减法简单运算,通过指令(ADCSBC)激活,以及专门的增量和减量指令(INCDEC)。还有位移和按位布尔运算指令(ASLASRANDORAEOR)。没有乘法或除法指令——这些必须通过可用的软件来构建。同时也没有浮动点运算。

图 11-6 显示了 ALU 在状态寄存器中分配的位的含义,这些位是作为其操作的副作用写入的。

图片

图 11-6:状态寄存器标志

ALU 的操作包括标记结果是否为零(Z)或负数(N),是否发生溢出(V),以及是否有进位(C)。

解码器

在图 11-4 中,解码器位于芯片照片的上五分之一处,呈现为半规则的二进制结构。它看起来像是存储在数组中的大量二进制数字,实际上就是这样。操作码为 8 位,意味着最多可以有 256 条不同的指令。每个操作码都会被解码并用于激活控制线。

控制单元

在图 11-4 中,CU 形成了芯片的中间区域。它在视觉上呈现为一个非常不规则的区域。这是因为每个操作都不同,因此它是用完全不同的电路实现的。6502 的 CU 通常需要比后来的 16 位机器做更多的工作,因为 6502 使用 16 位地址,并且有时使用 16 位或 24 位指令,CU 必须将它们分解成 8 位的块,并通过 8 位总线传输。

程序员接口

图 11-7 显示了 6502 的完整指令集。

Image

图 11-7:完整的 6502 指令集。欲了解这些指令的完整定义,请参见 en.wikibooks.org/wiki/6502_Assembly

由于操作码是 8 位的,因此有空间容纳 256 条指令;然而,指令集架构实际上包含的指令略少,所以表格中有一些空隙。

加载与存储

从三个用户寄存器(X、Y 和 A)加载(LD)和存储(ST)是通过以下指令完成的:

LDA #$00  ; load to accumulator the constant 8-bit hex integer 00
STA $0200 ; store accumulator contents to 16-bit hex address 0200
LDX $0200 ; load contents of address 0200 to register X
STX $0201 ; store contents of X into address 0201
LDY #$03  ; load 8-bit constant hex 03 to register Y
STY $0202 ; store contents of Y to address 0202

偏移寻址使得用户寄存器的值可以作为偏移量应用到给定的地址。这对于遍历数组非常有用。例如:

LDX #$01
STA $0200,X ; store the value of A at memory location $0201

间接寻址允许我们指定一个地址,这个地址包含另一个我们实际想要加载或存储的地址:

LDA ($c000) ; load to A from the address stored at address C000

间接寻址和偏移可以一起使用,例如:

LDA ($01),Y

零页 是 6502 的约定,表示内存的第 0 页功能类似于额外的 256 个寄存器。这要求仅指定并移动 1 字节的地址,如下所示:

LDA $12   ; single byte address assumed to be from page 0

这比单独移动 2 字节更快。

算术

ADC 指令表示“带进位的加法”。它将地址操作数的整数内容与状态寄存器中的进位位加到累加器中。以下程序应该以累加器中十六进制值 0A[16](十进制 10)结束:

CLC       ; clear content of carry flag in status register
LDA #$07  ; load constant 07 to accumulator
STA $0200 ; store content of accumulator to address 0200
LDA #$03  ; load constant 03 to accumulator
ADC $0200 ; add with carry the content of 0200 into accumulator

CLC 清除进位标志;在进行任何新的加法之前,清除进位是很重要的,除非你希望将前一个操作的进位也加上。

要添加两个 16 位整数,我们可以利用进位状态标志的状态,而不是将其清除。每次 ADC 操作都会读取和写入进位标志,因此我们可以将 16 位加法拆分为两次 8 位加法,并带上进位。这里,两个输入 num1num2 以及输出 result 都被分为低字节和高字节:

CLC
LDA num1_low
ADC num2_low
STA result_low
LDA num1_high
ADC num2_high
STA result_high

类似地,SBC 是“带借位的减法”,所以以下代码计算 7 – 3,结果是累加器中的值为 4:

SEC       ; set carry flag to 1 (needed to init subtraction)
LDA #$03  ; load constant 3 to accumulator
STA $0200 ; store constant 3 to address 0200
LDA #$07  ; load constant 7 to accumulator
SBC $0200 ; subtract content of 0200 from accumulator

我们可以使用指令,如:INCIN 来递增地址和寄存器内容,使用 DECDE 来递减。

LDX #$02
LDY #$04
INX
DEY
LDA #$07
STA $0200
INC $0200
DEC $0200

这里,# 表示操作数是常量,其他操作数是地址。

跳转与分支

JMP 是跳转指令。以下程序不断递增寄存器 X,当其超过 FF[16] 时,会回绕至 00[16]:

LDX #$02
mylabel:
  INX
  JMP mylabel

与当时的 BASIC 程序员可能直接指定跳转的行号不同,这种符号首先用一个标签标记目标行——在这个例子中是mylabel——然后在跳转指令中指定这个标签的名称。标签行不会被编译成机器码;当汇编器首次看到它时,它会被忽略。但是,当汇编器在跳转指令中再次看到标签时,它会将其替换为标签后面指令的地址。

条件分支可以分为两个阶段。首先,比较指令检查某个条件是否为真,并将结果存储在状态寄存器中。然后,分支指令会查询状态寄存器,以决定何时分支。例如,以下程序使用寄存器 X 从 5 递减到 2,然后停止,通过将 X 与 2 进行比较(CPX),如果不相等则跳转(BNE):

LDX #$05
mylabel:
  DEX
  CPX #$02
  BNE mylabel

你还可以根据比较结果进行分支(B),例如如果相等则跳转(BEQ),负值(即负数,BMI),或正值(即正数,BPL)。或者,如果进位标志(C)或溢出标志(V)为清除(C)或设置(S):分别使用BCCBVCBCSBVS

子程序

JSRRTS分别是跳转到子程序和从子程序返回的指令。例如,以下程序使用一种常见约定,将子程序的参数放入内存起始地址中,然后由子程序代码取出。BRK是“中断”指令,类似于 6502 的停止指令(实际上是一个中断)。它用于防止主程序执行时超出子程序的代码区域。

LDA #$5    ; load first argument to accumulator
STA $0001  ; put it in address 1 for sub to pick up
LDA #$4    ; load second argument to accumulator
STA $0002  ; put it in address 2 for sub to pick up
JSR mysub  ; call the subroutine
STA $0200  ; use subroutine's result, is in accumulator
BRK        ; halt
mysub:
  LDA #$00   ; reset the accumulator
  CLC        ; reset the carry
  ADC $0001  ; add in the first argument
  ADC $0002  ; add in the second argument
  RTS        ; return from subroutine

在练习中,你将看到如何在模拟的独立 6502 上运行上述以及类似的示例。单独的 6502 并不是特别激动人心,我们需要设计一个计算机系统来为 CPU 增加内存和 I/O 功能,因此现在让我们从 6502 的视角缩小,来看一看基于 6502 的完整计算机设计——Commodore 64。

基于 Commodore 64 的 8 位计算机设计

基于 6502 的 Commodore 64(C64)是有史以来销量最高的计算机型号。它于 1982 年发布,通过将游戏功能与商业和创意应用的潜力结合,定义了全球大部分地区的 8 位家用计算机市场。Commodore 这个名字源于它的创始人、充满传奇的二战幸存者 Jack Tramiel,他原本想取名为“General Computers”,就像“General Electric”一样,但“General”已经被使用。Commodore 比 General 低一级,是一个次选的等级。C64 的电路板在图 9-1 中已经展示过。它的名字来自于它使用了其 16 位地址空间中的 64 k[2]B 可用内存,采用 8 位字(2¹⁶个地址 × 8 位 = 64 k[2]B),与其他一些基于 6502 的机器不同。

理解架构

MOS 生产了几种 6502 的变体,并为每个变体分配了不同的型号。与 7400 逻辑芯片类似,"6502" 是一个模糊的术语,有时指的是原始的编号 CPU 设计,有时则指所有该家族的成员,每个成员都有相关的编号。用于 Commodore 64 的 6502 家族成员更准确地称为 6510。

除了具有完整的 64 k[2]B 实际 RAM 外,C64 还增加了设备、I/O 模块及其包含与设备通信的子程序库的 ROM(我们现在称之为 BIOS)。正是这种配置使得 C64 在编程平台上与其他基于 6502 的机器有所不同。

物理板布局与 图 11-8 的框图连接。总线——由 16 位寻址和 8 位数据组成——主导了该图,并连接了 CPU、RAM、ROM 和 I/O。

图片

图 11-8:C64 框图

图 11-9 显示了 C64 的内存映射。

图片

图 11-9:C64 内存映射

在此内存映射中,RAM、ROM 和 I/O 分别在 16 位地址空间内分配了地址范围。I/O 地址空间被划分为各个 I/O 模块和芯片使用的范围。(因为在当时地址空间是稀缺资源,所以 C64 使程序员能够暂时断开 ROM,将额外的 RAM 挂载到其位置。)

PETSCII 字符

Commodore 64 以不同于 Unicode 的方式扩展了 ASCII,采用了现在已不再使用的 PETSCII 字符集,通过将第一个数字作为“移位”字符,并定义了第二组类似 ASCII 的符号。它还在未移位区定义了新的视觉符号,代替了控制代码,包括 C64 标志性的扑克牌符号和用于绘图的位图图形元素,如图所示。

图片

C64 编程

C64 的编程使用 6502 汇编语言,如在 第 255 页“程序员接口”部分讨论的那样,但它还与特定的 ROM 和 I/O 模块进行交互,这些模块在地址空间中被挂载。ROM 包含 Commodore 自己的子程序库(称为“KERNAL”,带 A)。I/O 包括一个内存映射的屏幕显示,可以在字符模式和像素模式之间切换。字符模式允许通过将 PETSCII 字符的代码直接写入该内存空间,在屏幕位置绘制 PETSCII 字符。可以通过读取键盘的内存映射空间来读取键盘状态,但提供了 ROM 子程序来简化此过程并将其状态解码为 PETSCII 字符代码。

以下程序演示了这些结构。它在彩色屏幕上显示滚动消息,并在按下 A 键时退出。

screenbeg = $0400           ; const, beginning of screen memorymap
screenend = $07E7           ; const, end of screen memorymap
screenpos = $8000           ; variable, current position in screen
main:
    LDA #$02                ; black color code
    STA $D020               ; I/O border color
    STA $D021               ; I/O background color
    STA screenpos           ; screen position
loop:                       ; main game loop, once per frame
    JSR $E544               ; ROM routine, clears screen
    JSR drawframe           ; most of the work is done here
    JSR check_keyboard
    INC screenpos           ; increment current screen position
    JMP loop                ; do the loop, forever
drawframe:
    LDX #$00                ; regX tracks idx of char in the string
    LDY screenpos           ; regY keeps scrolling screen position
    CPY #$20                ; compare Y with constant 20
    BCS resetscreenpos      ; branch if Y>20 (stored in carry bit)
drawmsgloop:                ; drop through to here if not branching
    LDA msg,X               ; load the xth char of the message
    BEQ return              ; exit when zero char (end of string)
    AND #$3F                ; convert ASCII to PETSCII
    STA screenbeg,Y         ; VDU: write char in A to memorymap offset Y
    INX                     ; increment idx of char in message
    INY                     ; increment location on screen
    CPY #$20                ; are we trying to write offscreen?
    BCS wraparound_y        ; if so, shift offset by screen width
    JMP drawmsgloop         ; loop (until all chars are done)
resetscreenpos:
    LDY #$00
    STY screenpos           ; reset the screenpos to 0
    JMP drawmsgloop
wraparound_y:               ; if Y trying to write off screen, wrap
    TYA                     ; transfer Y to accumulator
    SBC #$20                ; subtract with carry
    TAY                     ; transfer accumulator to Y
    JMP drawmsgloop
check_keyboard:
    JSR $FF9F               ; ROM SCANKEY IO, writes keybdmatrix to 00CB
    JSR $FFE4               ; ROM GETIN, convert matrix to keycode in acc
    CMP #65                 ; compare accumulator to ASCII 'A'
    BNE return
    BRK                     ; if 'A' pressed, quit
return:
    RTS
msg:
    .byte "HELLO C64!\0"    ; this is data, not an instruction

这将创建一个滚动文本效果,如 图 11-10 所示。

图片

图 11-10:C64 上的 hello 结果,文本滚动穿过屏幕。

该程序可以作为编写游戏的起点,因为它包含了所有基本的游戏元素:循环、显示、键盘读取和状态更新。

芯片音乐

在 8 位时代,声音芯片是真正的合成器,一种通过硬件制作并放入计算机中的实际音乐乐器。

生成音调的最简单方法是使用方波。这是一个非音乐家出身的架构师构建声音芯片时的常见做法,比如德州仪器的 SN76489。方波在给定频率(音乐音高)下在数字 0 和 1 之间交替,因此它们完全可以由数字逻辑构成,而无需其他波形所需的模拟电压。将芯片限制为方波使得那个时代的设备具有了其典型的原始 8 位声音。

由于康莫多尔收购了 MOS,他们在 C64 中使用了 MOS 最新的音调生成器——6581 声音接口设备(SID)。SID 比以前的声音芯片要强大得多。它是由一位音乐合成器设计师设计的,作为一款真正的音乐乐器。它将模拟锯齿波和正弦波加入到音频中,并通过给这些波形添加模拟滤波器,彻底革新了 8 位音频。滤波器能够强调或消除音乐信号中的谐波频段。方波和锯齿波都有无限的谐波,这为滤波器提供了良好的处理原料。滤波器可以以多种方式在音符上滑动,创造出许多不同的效果,这使得 C64 拥有了丰富的音乐表现力。

SID 包含模拟设备和一个 I/O 模块,将其与地址空间连接,因此它附加到总线上。在 C64 中,通过向其分配的地址空间(D400 到 D7FF)写入频率、音量和滤波器截止等参数来控制它,以下示例通过通道 1 播放方波:

main:
    LDA #$0F
    STA $D418 ; I/O SID volume
    LDA #$BE  ; attack duration = B, decay duration = E
    STA $D405 ; I/O SID ch1 attack and decay byte
    LDA #$F8  ; sustain level = F, release duration = 8
    STA $D406 ; I/O SID ch1 sustain and release byte
    LDA #$11  ; frequency high byte = 11
    STA $D401 ; I/O SID ch1 frequency high byte
    LDA #$25  ; frequency low byte = 25
    STA $D400 ; I/O SID ch1 frequency low byte
    LDA #$11  ; id for square wave waveform
    STA $D404 ; I/O SID ch1 ctl register
loop:
    JMP loop

在 SID 发布后,像 Rob Hubbard 这样的伟大 8 位“芯片音乐”作曲家找到了高度创意的方式,通过黑客技术让它播放样本,并且让它看起来拥有比硬件上实际提供的三个声音更多的音轨。SID 提供了一个有限且受限的音色调色板,促使了极简主义和数学美学的发展。Hubbard 受到了 Philip Glass、Jean-Michel Jarre 和 Kraftwerk 的影响。近年来,像 Max Martin 和 Dr. Luke 这样的音乐制作人在 2010 年代使用 SID 来营造复古游戏音效。

与摩托罗拉 68000 16 位 CPU 协同工作

16 位时代有些名不副实:它本应被称为“16/32 位时代”。这是因为这个时代的代表性芯片是摩托罗拉 68000,它被用于 Commodore Amiga、Atari ST、Apple Macintosh、Sega Megadrive 以及街机游戏如街头霸王 II。68000 使用 16 位数据字,但也拥有 32 位寄存器和 CPU 内的 ALU。Atari ST 的名字正是来源于 68000 的这种混合“Sixteen/Thirty-two”特性。68000,也被称为 68k,于 1979 年发布,并在 1980 年代后期的计算机中出现,定义了 16 位时代。

6502 和 68000 都源自早期的摩托罗拉 6800,在不同的进化分支上发展。它们的名字反映了这一点,并共享一些结构和指令。这意味着学习 68000 通常是对我们在 6502 中学到的内容的扩展。如果你不确定如何在 68000 中完成某项任务,通常可以根据 6502 的等效操作做出合理的猜测。

内部子组件

图 11-11 展示了摩托罗拉 68000 的芯片照。图中可以看到与 6502 相同的基本结构,寄存器和算术逻辑单元(ALU)位于底部,控制逻辑位于中央,解码器位于顶部附近。

Image

图 11-11:68000 芯片照

可以看到,寄存器和 ALU 部分现在有更多重复的行,因为它们的位数超过了 8 位。与 6502 不同的是,当整个 CPU 在打印页面上显示时,其数字逻辑现在已经小到无法看到。

有 16 个用户寄存器,都是 32 位,其中 8 个被称为 D0 到 D7,用作“数据寄存器”,其余的被称为 A0 到 A7,用作“地址寄存器”。A7 被用作堆栈指针。还有一个 16 位状态寄存器,包含类似 6502 的状态位,并带有一些额外信息。

总线有 16 条数据线和 24 条地址线。然而,这些地址指向的是字节位置,而不是 16 位字,因此可以寻址的字节数是 2²⁴,即 16M[2]B 的可寻址内存。24 位地址以六个十六进制字符表示,例如 DFF102[16]。

68000 有一个两阶段的流水线,在解码和执行当前指令的同时,预取下一条指令。

程序员接口

正如我们在其他机器中所做的那样,在了解 68000 的结构之后,我们现在将审视它所启用的指令集——通过内存访问、算术运算和流程控制——这些指令集可以帮助你编写自己的程序。16 位时代在编程中广泛地从使用大写字母转向了小写字母,从此我们也将尊重这一点。

数据移动

单条move指令用于加载、存储和寄存器数据传输:

move.l d0, d1         ; copy from register d0 to register d1
move.l #$1a2, d1      ; copy hex constant $1a2 to register d1
move.l $0a3ff24, d1   ; load longword from address 0a3ff24 to d1
move.l d1, $0a3ff24   ; store longword from d1 to address 0a3ff24

这里的l代表“长字”(longword),每次移动 32 位。这在寄存器之间非常快速。访问内存时,32 位数据必须被 CPU 拆分,并通过 16 位总线分两步发送,由控制单元(CU)按顺序执行。

如果你只想移动 16 位字(w)或 8 位字节(b),你可以使用 move 的变种:

move.b d0, d1
move.w $0a3ff24, d1

间接寻址通过括号指定:

move.l ($0a3ff24), d1  ; load content from addr stored at addr 0a3ff24, to d1

偏移寻址包括以下内容:

move.l (pc, 2), d1      ; load content from program counter plus 2
move.l (a1, a2), d1     ; load content from addr formed as sum of regs a1+a2
move.l (a1, a2, 2), d1  ; load content from addr formed as sum of regs a1+a2+2

更复杂且不常见的 68000 寻址模式将间接寻址与寄存器递增结合使用;这对于遍历存储在连续地址中的数据非常有用:

move.l (a1)+, d1        ; load content from addr stored in register a1, to d1,
                        ; then increment a1 by number of bytes in a longword
move.l -(a1), d1        ; decrement a1 by number of bytes in a longword,
                        ; then load content from addr stored in register a1

对于 C 程序员:这大致是 *(a++)*(--a) 编译后的结果。推送和弹出堆栈不需要专门的指令,因为可以使用此模式与堆栈指针寄存器来实现:

move.w (sp)+, d0       ; push from register d0 to stack
move.w d0, -(sp)       ; pop from stack to register d0

有效地址加载指令(lea)是 68000 的相关指令,可以加载间接寻址的地址。例如:

lea (pc, 2), a1     ; put address of program counter +2 bytes into a1
lea (a1, 2), a3     ; put address a1+2 into a3
lea (a1, a2, 2), a3 ; put address a1+a2+2 into a3

注意,lea 加载的是数值地址本身,而不是地址的内容。

流程控制

由于历史渊源,68000 的跳转、子程序和分支与 6502 相同。例如:

start:
   jsr mysub        ; jump to subroutine

   cmp #2, d0       ; compare values
   beq mylabel      ; branch if equal
   ble start        ; branch if less than or equal
   bne start        ; branch if not equal

mylabel:
   jmp mylabel      ; infinite loop

mysub:
   rts              ; return from subroutine

话虽如此,堆栈逻辑有所改进:在 68000 上,你可以将一系列参数推送到堆栈,跳转到子程序,然后从子程序内将它们弹出。这使得子程序能够像具有参数的函数一样工作。

算术

以下是一些算术指令的示例:

add.b d0, d4  ; add d0 to d4, store result in d4
sub.w #43, d4 ; subtract constant 43 from d4, store result in d4
muls d0, d4   ; multiply (signed) d0 with d4, store result in d4
mulu d0, d4   ; multiply (unsigned) d0 with d4, store result in d4
divs d0, d4   ; divide (signed) d0 by d4, store result in d4
divu d0, d4   ; divide (unsigned) d0 by d4, store result in d4
and d0, d1    ; bitwise and d0 with d1, store result in d1
asr d0, d1    ; arithmetic shift right d1 by d0 bits, store result in d1

加法和减法指令与 6502 类似。但与 6502 不同,68000 可以通过硬件执行乘法和除法。

使用 Commodore Amiga 的 16 位计算机设计

Amigaamigo 的阴性形式,意为 朋友,而 Commodore 的 1985 年推出的 Amiga 意在与其用户建立这样的关系。早期版本的 Amiga 旨在作为高端图形工作站,面向自称为“创意人士”的市场——如今 Apple 面向的正是这一市场。然而,现在经典的 A500 型号迅速成为了标准的大众市场游戏平台。这一现象自我实现,因为开发者和玩家群体一同增长。随着(非法)破解和复制游戏磁盘的便利,世界许多城市的酒吧举行“Amiga 之夜”,在其中交换这些磁盘,增长速度进一步加快。在欧洲,Amiga 被“演示场景”(demo scene)接受,这是一群艺术性汇编程序员组成的亚文化,他们聚集在一起竞赛,推动图形和声音的极限,目标不是在游戏中,而是在多媒体演示中。这些场景有交集,破解者将演示加到新破解游戏的启动序列中(那些移除了复制保护的游戏)。Commodore 管理层忽视了这一切,并试图将 Amiga 推向商业市场,最终它和公司都被“米色箱”PC 摧毁了。

理解架构

经典的 A500 配备了 0.5 M[2]B 的 RAM,尽管它和后续型号可以升级为几个 Mebibyte。(这仍然远小于 CPU 可寻址的 16 M[2]B。)图 11-12 显示了 A500 的主板。

图片

图 11-12:一块 Amiga A500 主板

设计基于四个大型定制芯片,赋予它们人类名字:

Agnus 这款芯片包含了一个协处理器(“铜处理器”),该协处理器有自己的独立 RAM 和总线,除了主 CPU 系统外,铜处理器负责图形处理。铜处理器的机器码可以作为数据行编写,并通过主 CPU 程序将数据传输给铜处理器。(今天的 GPU 也使用类似的系统。)Agnus 还包含一个基于 DMA 的“blitter”,用于将精灵图像复制到视频内存中,而无需 CPU 介入。

Paula 这款芯片包含一个音频设备及其 I/O 模块,此外还有其他多个 I/O 模块,如磁盘和通信端口。它使用 DMA 从 RAM 中读取音频样本和其他 I/O 数据,而无需 CPU 介入。

Denise 这款芯片是视频显示单元(VDU)芯片,负责从 RAM 中读取精灵图像和位平面,根据不同的屏幕模式将它们合成在一起,并输出 CRT 显示控制。

Gary 这是一款内存控制器,用于将总线上的地址翻译并路由到特定的芯片及其内部地址。

A500 的 BIOS(称为 Kickstart)提供了访问 I/O 的子程序,如图形和声音。它存储在一个芯片中,通常被描述为 ROM,但更准确地说,它应该被视为一个 I/O 模块。这是因为,与 C64 的 BIOS 不同,这些子程序并没有直接映射到地址空间中。相反,它们被存储在芯片的一部分,这部分并没有直接映射到地址空间。当需要子程序的一个子集(库)时,会向芯片的较小、已映射部分发送命令,将子程序复制到 RAM 中的新位置。

整台计算机与电视 CRT 扫描显示的时钟频率同步,这意味着它(以及它的游戏)在英国和美国运行的速度不同,因为它们使用不同的电视标准!

Amiga 被设计为一款多媒体机器,尤其是当时的 16 位游戏,基本要求之一就是能够快速绘制精灵图像——如游戏角色等小图像——这些精灵图像会覆盖在背景上以构建场景。

一种简单的精灵绘制方法是将精灵的主副本存储在 RAM 中的固定位置,然后用汇编语言编写子程序,将每个像素逐一复制到视频内存中的参数化位置。然而,这种方法非常慢,因为精灵中的每个像素都需要加载到 CPU 中,并按顺序重新写入视频内存。

“Blitting” 是 Amiga 铜处理器利用 DMA 更高效地渲染精灵图像的一种著名应用。铜处理器可以通过 CPU 指令启动一个完整的精灵“blit”操作,利用 DMA 将精灵(或 blitter 对象,“bob”)逐像素从常规 RAM 中读取,并复制到视频 RAM 中,无需 CPU 进一步干预。

“硬件精灵”是第二种方法,其中精灵的主副本在游戏开始时被加载到 VDU 中。VDU 包含其专用的数字逻辑,用于内部实现类似的位图命令。VDU 内部存在严格的内存限制,只允许使用八个硬件精灵,这些精灵通常用于游戏中主角的动画帧或鼠标指针符号。

对于 2D 游戏的背景图,“游戏背景”是另一种硬件加速,允许背景图像被存储并滚动。多个背景可以与透明遮罩叠加,以创建视差效果。

Amiga 编程

以下是一个简单的程序,用于在屏幕上显示一个太空船精灵:

custom      equ   $dff000     ; custom chips
bplcon0     equ   $100        ; bitplane control register 0 (misc, control bits)
bplcon1     equ   $102        ; bitplane control register 1 (horizontal, scroll)
bplcon2     equ   $104        ; bitplane control register 2 (priorities, misc)
bpl1mod     equ   $108        ; bitplane modulo
ddfstrt     equ   $092        ; data-fetch start
ddfstop     equ   $094        ; data-fetch stop
diwstrt     equ   $08E        ; display window start
diwstop     equ   $090        ; display window stop
copjmp1     equ   $088        ; copper restart at first location
cop1lc      equ   $080        ; copper list pointer
dmacon      equ   $096        ; DMA controller
sprpt       equ   $120        ; sprite pointer

COLOR00     equ   $180        ; address to store COLOR00 (background)
COLOR01     equ   COLOR00+$02 ; address to store COLOR01 (foreground)
COLOR17     equ   COLOR00+$22 ; etc
COLOR18     equ   COLOR00+$24
COLOR19     equ   COLOR00+$26
BPL1PTH     equ   $0E0        ; bitplane 1 pointer hi byte
BPL1PTL     equ   BPL1PTH+$02 ; bitplane 1 pointer lo byte
SPR0PTH     equ   sprpt+$00   ; sprite0 pointer, hi byte
SPR0PTL     equ   SPR0PTH+$02 ; sprite0 pointer, lo byte
SPR1PTH     equ   sprpt+$04   ; sprite1 etc
SPR1PTL     equ   SPR1PTH+$02
SPR2PTH     equ   sprpt+$08
SPR2PTL     equ   SPR2PTH+$02
SPR3PTH     equ   sprpt+$0C
SPR3PTL     equ   SPR3PTH+$02
SPR4PTH     equ   sprpt+$10
SPR4PTL     equ   SPR4PTH+$02
SPR5PTH     equ   sprpt+$14
SPR5PTL     equ   SPR5PTH+$02
SPR6PTH     equ   sprpt+$18
SPR6PTL     equ   SPR6PTH+$02
SPR7PTH     equ   sprpt+$1C
SPR7PTL     equ   SPR7PTH+$02

SHIPSPRITE equ $25000         ; address to store our ship sprite
DUMMYSPRITE equ $30000        ; address to store our dummy sprite
COPPERLIST equ $20000         ; address to store our copper list
BITPLANE1   equ $21000        ; address to store our bitplane data

; Define bitplane1
        lea     custom,a0               ; a0 := address of custom chips
        move.w  #$1200,bplcon0(a0)      ; 1 bitplane color
        move.w  #$0000,bpl1mod(a0)      ; modulo := 0
        move.w  #$0000,bplcon1(a0)      ; horizontal scroll value := 0
        move.w  #$0024,bplcon2(a0)      ; give sprites priority over playfields
        move.w  #$0038,ddfstrt(a0)      ; data-fetch start
        move.w  #$00D0,ddfstop(a0)      ; data-fetch stop

; Define display window
        move.w  #$3c81,diwstrt(a0)      ; set window start (hi byte = vertical, lo = horiz*2)
        move.w  #$ffc1,diwstop(a0)      ; set window stop (hi byte = vertical, lo = horiz*2)

; Put RGB constants defining colors into the color registers
        move.w  #$000f,COLOR00(a0)      ; set color 00 (background) to blue (00f)
        move.w  #$0000,COLOR01(a0)      ; set color 01 (foreground) to black (000)
        move.w  #$0ff0,COLOR17(a0)      ; Set color 17 to yellow (ff0)
        move.w  #$00ff,COLOR18(a0)      ; Set color 18 to cyan (0ff)
        move.w  #$0f0f,COLOR19(a0)      ; Set color 19 to magenta (f0f)

; Copy copper list data to addresses starting at COPPERLIST
        move.l #COPPERLIST,a1           ; a1 := copper list destination
        lea     copperl(pc),a2          ; a2 := copper list source
cloop:
        move.l  (a2),(a1)+              ; copy DMA command
        cmp.l   #$fffffffe,(a2)+        ; end of list?
        bne     cloop                   ; loop until whole list moved

; Copy sprite to addresses starting at SHIPSPRITE
        move.l  #SHIPSPRITE,a1          ; a1 := sprite destination
        lea     sprite(pc),a2           ; a2 := sprite source
sprloop:
        move.l  (a2),(a1)+              ; copy DMA command
        cmp.l   #$00000000,(a2)+        ; end of sprite?
        bne     sprloop                 ; loop until whole sprite moved

; All eight sprites are activated at the same time but we will only use one
; Write a blank sprite to DUMMYSPRITE, so the other sprites can point to it
        move.l #$00000000,DUMMYSPRITE

; Point copper at our copper list data
        move.l #COPPERLIST,cop1lc(a0)

gameloop:

; Fill bitplane pixels with foreground color (1-bit plane in fore/background colors)
        move.l #BITPLANE1,a1            ; a1 := bitplane
        move.w #1999,d0                 ; 2000-1(for dbf) long words = 8000 bytes
floop:
        move.l #$ffffffff,(a1)+         ; put bit pattern $ffffffff as next row of 16*8 pixels
        dbf    d0,floop                 ; decrement, repeat until false

; start DMA, to blit the sprite onto the bitplane
        move.w  d0,copjmp1(a0)          ; force load to copper program counter
        move.w #$83A0,dmacon(a0)        ; bitplane, copper, and sprite DMA

    ;**your game logic would go here---read keyboard, move sprites**

    jmp gameloop

; Copper list for one bitplane, and eight sprites. Bitplane is at BITPLANE1
; Sprite 0 is at SHIPSPRITE; other (dummy) sprites are at DUMMYSPRITE
copperl:
        dc.w    BPL1PTH,$0002           ; bitplane 1 pointer := BITPLANE1
        dc.w    BPL1PTL,$1000
        dc.w    SPR0PTH,$0002           ; sprite 0 pointer := SHIPSPRITE
        dc.w    SPR0PTL,$5000
        dc.w    SPR1PTH,$0003           ; sprite 1 pointer := DUMMYSPRITE
        dc.w    SPR1PTL,$0000
        dc.w    SPR2PTH,$0003           ; sprite 2 pointer := DUMMYSPRITE
        dc.w    SPR2PTL,$0000
        dc.w    SPR3PTH,$0003           ; sprite 3 pointer := DUMMYSPRITE
        dc.w    SPR3PTL,$0000
        dc.w    SPR4PTH,$0003           ; sprite 4 pointer := DUMMYSPRITE
        dc.w    SPR4PTL,$0000
        dc.w    SPR5PTH,$0003           ; sprite 5 pointer := DUMMYSPRITE
        dc.w    SPR5PTL,$0000
        dc.w    SPR6PTH,$0003           ; sprite 6 pointer := DUMMYSPRITE
        dc.w    SPR6PTL,$0000
        dc.w    SPR7PTH,$0003           ; sprite 7 pointer := DUMMYSPRITE
        dc.w    SPR7PTL,$0000
        dc.w    $ffff,$fffe             ; copper list end

; Sprite data. Stores (x,y) screen coordinate and image data
sprite:
        dc.w    $6da0,$7200             ; 6d = y location; a0 = x location; 72-6d = 5 = height)

        dc.w    $0000,$0ff0             ; image data, 5 rows x 16 cols x 2 bit color
        dc.w    $0000,$33cc             ; each line describes one row of 16 pixels
        dc.w    $ffff,$0ff0             ; each pixel is described by a 2-bit color
        dc.w    $0000,$3c3c             ; the low pixel bits form the first word
        dc.w    $0000,$0ff0             ; the high pixel bits form the second word

        dc.w    $0000,$0000             ; ... all zeros marks end of image data

这里,精灵在数据段的末尾定义。Amiga 程序通常涉及大量定义常量,用于调用其图形功能的许多复杂 ROM I/O 子程序。在现实生活中,库文件会被包含进来,以便最大化这些定义的使用,但在这里,它们作为完整程序的示例被展示出来。

结果的截图如图 11-13 所示。请注意,精灵还没有移动,但可以添加更多命令来创建一个游戏循环,循环地读取键盘、更新精灵位置,然后进行绘制。在真实的游戏中,精灵通常不会作为数据行在汇编语言中定义;相反,它们会在著名的像素艺术程序Deluxe Paint中绘制,然后从文件加载到类似的内存区域。

图片

图 11-13:Amiga 精灵游戏结果

复古外设

8 位和 16 位时代引入了许多外设,这些外设要么至今仍在使用,要么对现代标准产生了强烈影响。让我们在这里看看一些最重要的外设,来完成我们对复古计算的研究。

阴极射线管显示器

虽然曼彻斯特婴儿机的威廉姆斯管,如第 220 页“历史 RAM”框中所示,最初并非作为人类显示设备设计,但它的程序员迅速意识到它作为显示设备的潜力,并很快开始在屏幕的某些部分编写易于人类阅读的模式作为输出,而屏幕的其余部分则存储内部数据,这些数据表现为随机的开关像素模式。在近几十年中,黑客们编写了简单的复古街机游戏,在婴儿机上播放,展示了太空侵略者等游戏,显示在威廉姆斯管的某些部分作为显示。

这些黑底绿字的像素是后来的阴极射线管(CRT)绿色屏幕以及之后的彩色显示器的起源,这些设备在复古时代作为人类显示器使用,如图 1-31 所示。

程序员逐渐习惯了绿色配色方案,在 1980 年代,常通过硬件开关将 RGB 显示器切换到高分辨率的绿底黑字模式,以帮助集中注意力和提高熟悉度。有些人声称,只有使用绿色像素可以提高显示精度,因为红色和蓝色子像素与绿色的距离较远,使用时容易导致像素模糊。今天,当我们将终端模拟器和文本编辑器(如 Vim)设置为绿底黑字模式时,依然遵循这一传统。这种经典的编程方案在电影《攻壳机动队》和《黑客帝国》中以风格化的计算机代码形式得到了庆祝。

为了降低成本,黄金时代的家用计算机通常设计为使用消费级电视 CRT 作为 RGB 显示器。为了在 CRT 显示器或电视上显示,像 C64 这样的 8 位机器首先需要从视频内存中读取所需的像素值,然后安排将其映射到 CRT 束的强度上,在显示器扫描屏幕的列和行时定期进行调整。

CRT 显示器在每个像素周围产生复杂的视觉光晕,这些光晕会与邻近的像素混合,而当时游戏的像素艺术正是为适应这种模糊效果而优化的,这与在现代平面显示器上玩复古游戏的效果完全不同。街机游戏小行星极端地利用了这一效果,将子弹的亮度调到最大,导致 CRT 射线像一种死亡射线直射玩家眼睛——这种效果在仿真中是无法捕捉的。

用户输入

复古时代的键盘通常是内存映射的,每个键都直接连接到内存空间中的一个地址,看起来像 RAM。地址分成一组,每个地址都映射到一个键,加载其中一个地址就可以判断键是按下还是松开。

复古时代的鼠标如图 11-14 所示。

图片

图 11-14:球形鼠标拆解图

像图 11-14 中的鼠标一样,通过在桌面上滚动一个拇指大小的橡胶球来工作,这个球会旋转两个滚轮传感器,检测它的水平和垂直旋转。传感器将旋转转换为模拟信号,再转为数字信号,通过电缆传输到计算机。

串口

串口曾经是,且仍然是,一种简单的通信协议(正式名称为 RS232 标准),曾出现在复古机器中,但在嵌入式系统中仍然非常重要。串口的核心是两根线,分别称为 RX 和 TX,代表接收发送。它们通过时间上的数字电压来传输 0 和 1,因此有一根线在一个方向上传输信息,另一根线则在另一个方向上传输信息。历史上的串口还包含许多其他线路,因为在早期它们被用作控制许多设备的信号,但如今我们通常只使用 RX 和 TX。串口连接器仍然保留着这些额外的大多未使用的引脚,正如图 11-15 所示。

图片

图 11-15:传统的串口连接器

串口可以以不同的速度运行。它们可能还使用不同的错误检查约定,这可能会增加额外的冗余位,以及停止位,用来标示字符在一串 0 和 1 中的边界。你需要确保线路两端的设备使用相同的速度和约定。

MIDI 接口

MIDI(音乐设备接口,如图 11-16 所示)自 1983 年标准化以来,一直是音乐键盘、合成器、采样器和 1980 年代键琴的标准总线,用于实时传输符号化的音乐输入和输出。它是总线层次结构的早期示例,其中一个可选的 MIDI 接口可以连接到主总线上;它还提供了一个次级 MIDI 总线,使多个音乐设备能够进行通信。

图片

图 11-16:MIDI 连接器

MIDI 连接由一对单向总线组成。一条是用于管理器向设备发送消息,另一条是用于设备向管理器发送消息。它们被称为总线,因为所有设备使用相同的物理线缆,并且可以看到这些线上所有的消息,因此设备必须注意哪些消息是发送给它们的,只处理这些消息。

每个方向的总线都有自己的连接器,并通过三根物理线进行传输。(实际上,一个标准的 MIDI 连接器有五个引脚,其中两个备用引脚有助于处理相关工作,比如为设备提供“虚拟”电源。)一根线是 5V,另一根是地线,还有一根是 UART(通用异步接收发送器)数据线。总线性质通过 MIDI 规范可以看出,所有设备都有三个插口:“in”(输入)、“out”(输出)和“thru”(通道),其中“thru”将所有“in”消息转发给下一个设备,采用串联接线方案;其他硬件适配器可以将多个设备的“out”消息合并到一根线中,这种情况相对较少见。作为 1980 年代的标准,所有消息都是 8 位字(称为“MIDI 字节”),以类似串口连接的方式传输,标准传输速率为 31.25Kbps。

MIDI,包括最近的 MIDI 2.0 扩展,至今仍然在使用。

概述

对于某个年龄段的读者来说,理解并编程那些黄金时代的机器可以是重温青春并理解当时老旧机器内部运作的美好方式。但对于其他人来说,这些机器依然值得学习,因为它们架起了最简单电子计算机(如 Baby 机)与如今桌面和口袋中的现代计算机之间的桥梁。这些现代机器有许多更多的功能,可能令人应接不暇,因此,通过在越来越强大的旧机器上练习,你可以建立自己的信心。为此,本章研究了一台 8 位系统——Commodore 64 和一台 16 位系统——Commodore Amiga。这两台机器通过它们的 CPU 的共同祖先相关联,意味着它们共享一些指令和风格。这些经典系统所引入的许多理念至今仍在使用,正如我们将在接下来的章节中看到的那样。

练习

6502 编程

  1. Easy6502 是一个开源的 6502 模拟器,可以在浏览器中运行。它由 Nick Morgan 编写,Nick 是《JavaScript for Kids》和《JavaScript Crash Course》的作者,这两本书也由 No Starch Press 出版。可以通过以下链接下载 Easy6502:

    > git clone [`github.com/charles-fox/easy6502.git`](https://github.com/charles-fox/easy6502.git)
    > cd easy6502
    
  2. 在浏览器中打开下载的emulator.html文件来运行 Easy6502。然后输入并运行本章中的示例 6502 程序。模拟器在右侧显示寄存器的内容。

  3. 尝试在 Easy6502 中编写一个 16 位乘法子程序,使用 6502 汇编语言。

  4. Nick 的个人教程可以在下载的tutorial.html文件中找到。该教程提供了更多 6502 编程的细节,并逐步介绍如何编写一个复古的蛇形游戏。尝试学习足够的内容以理解这个游戏的工作原理,然后尝试以某种方式修改它,既可以改变游戏规则,也可以将其转变为另一个复古游戏,比如太空侵略者俄罗斯方块。在这个模拟器中编写的代码可以移植到 C64 或其他基于 6502 的机器上,只需进行一些额外工作来替换图形和 I/O,使用它们特定设计的调用。

C64 编程

  1. 现在,我们可以在现代机器上进行 C64 编程和汇编,然后仅需在 C64 模拟器(如开源的 VICE 模拟器)上运行生成的可执行机器代码,该模拟器可以本地安装。要开始使用,首先安装 dasm-assembler.github.io 上的 Dasm 汇编器。

  2. 将你的汇编代码放在一个文件中,如 hello.asm。Dasm 需要在文件开头添加以下两行,以告诉它生成 C64 的可执行文件,而不是其他机器的可执行文件。它们必须精确地有八个空格的缩进:

          processor 6502          ; define processor family for das
          org $C000               ; memory location for our code
    
  3. 使用以下命令将你的代码组装成 C64 程序 (.prg):

    > dasm hello.asm -ohello.prg
    
  4. .prg 文件可以导入到 C64 模拟器中,例如基于 JavaScript 的在线模拟器 c64emulator.111mb.de,或者 VICE。(对于 SID 程序:某些模拟器,如这里提到的 JavaScript 模拟器,默认情况下禁用了声音,因此你需要手动启用它。)

  5. 如果你足够幸运,能拥有一台真实的物理 C64 和磁带驱动器,你还可以尝试使用程序 tap2wav.py(可在 github.com/Zibri/C64 上找到)将你的 .prg 文件转换为磁带镜像 (.tap),然后再转换成声音波形 (.wav)。然后将 .wav 文件录制到物理磁带上,加载到读取机上。尝试检查 .tap.wav 文件,看看其中的 0 和 1 是如何表示的。

在 Amiga 上编程基于精灵的游戏

按照 第 271 页 “编程 Amiga” 部分中的方法,组装并运行飞船代码,步骤如下:

  1. sun.hasenbraten.de/vasm 下载 vasm 交叉汇编器。以 Amiga 模式构建它,命令如下:

    > make CPU=m68k SYNTAX=mot
    
  2. 使用 vasm 来组装你的汇编程序,命令如下:

    > ./vasmm68k_mot -kick1hunks -Fhunkexe -o myexe -nosym myprog.asm
    
  3. pypi.org/project/amitools/ 安装 Python 版本的 amitools。创建一个磁盘映像并将文件写入其中,使磁盘映像可以启动,命令如下:

    > xdftool mydisc.adf create
    > xdftool mydisc.adf format "title"
    > xdftool mydisc.adf write myexe
    > xdftool mydisc.adf boot install
    > xdftool mydisc.adf makedir S
    > echo myexe > STARTUP-SEQUENCE
    > xdftool mydisc.adf write STARTUP-SEQUENCE S/
    
  4. fs-uae.net/download 下载并安装 FS-UAE Amiga 模拟器。运行它并从你的虚拟 mydisk.adf 磁盘映像启动。

更具挑战性

  1. 研究如何读取 Amiga 键盘或操纵杆,然后将飞船示例扩展为一个简单的游戏,使用键盘来控制精灵的移动。研究如何添加双缓冲以去除屏幕重绘时的闪烁。

  2. 最近,构建自己的基于 6502 的计算机已经成为一种流行的爱好。可以在 YouTube 和 hackaday.com 上查看“6502 面包板计算机”的示例,并了解它们是如何制作的。你可以尝试重建这些现有设计中的一个,或者自己设计一个。

进一步阅读

第十五章:## 嵌入式架构

Image

计算机现在在汽车、机器人、工厂、艺术画廊和家用电器中非常常见。这些环境给计算带来了特定的约束和挑战,而为这些环境设计的架构被称为嵌入式系统。绝大多数生产的处理器—约 98%的处理器—都用于嵌入式系统。这是一个巨大的市场,在 2020 年代初期,其价值约为 2500 亿美元,因此,花时间研究这些系统是值得的。

本章将帮助你理解嵌入式系统,从而能够构建你自己的机器人、家居自动化黑客、电子音乐乐器或艺术装置,以及工业应用。我们将从比较通用计算机和嵌入式系统的主要区别开始,包括典型微控制器的结构及其 I/O 功能。然后,我们将介绍 Arduino,这是计算机科学家最常用的嵌入式系统,并展示如何在仿真和实际中用汇编语言编程,在那里它的架构最为清晰。最后,我们将探索一些 Arduino 的替代品,包括没有 Arduino 的 AVR、PIC、DSP 和 PLC。

设计原则

有几个众所周知的设计原则,将嵌入式系统与其他架构区分开来。让我们现在来逐一讲解。

单一用途

与 PC 不同,嵌入式系统通常是为了单一目的购买和使用的。一个嵌入式系统通过运行一个程序来控制你的机器人或洗衣机,这意味着你不需要操作系统来切换程序,而且你不常—甚至永远—需要更改程序。因此,嵌入式设备可能很难升级。你可以偶尔尝试要求所有用户升级他们电视或音乐播放器上的固件,但要广泛地推广并解释这个概念以使许多用户实际操作,会非常昂贵。相反,大多数用户通常会将这些设备丢弃并购买新的。根据你的角度来看,这可能是对地球资源的巨大浪费,或者是一个高利润的商业模式。

可靠性

对于嵌入式系统来说,可靠性通常比通用计算更为重要—它甚至可能关乎生死。考虑一下心脏起搏器及其嵌入式系统,这些系统会在手术过程中被植入人体内。你必须非常确定它能正常工作,因为你真的不希望为了修复一个 bug 而重新打开病人,或者将设备关掉再打开。其他嵌入式系统控制着工厂中的重型机械、公共交通信号系统以及核导弹发射,这些系统同样对错误的容忍度非常低。

移动性与功率

嵌入式系统通常被设计为物理机器的计算部分,这比通用计算机更严格地限制了其物理形态。通常会先设计物理机器,然后根据剩余空间来设计嵌入式系统。有些嵌入式系统也涉及到移动性问题:如果嵌入式系统必须随身携带,比如穿戴设备,它必须足够小巧轻便(并且最好看起来也不错)。

还有电力方面的考虑,尤其是当主机运行在电池上,而不是插入电源时。设计人员必须考虑消耗多少电力以及持续多久,电池需要多大。设计嵌入式处理器时需要尽量减少能量消耗,往往需要付出大量的努力。

封装

由于它们是为单一目的而设计的,嵌入式系统通常不需要将大部分或任何功能暴露给用户,这个概念被称为封装。相反,用户可能会得到一个简单的界面,只有几个按钮和一些 LED 灯,或者根本没有界面,如果系统设计为无需人工干预工作的话。通常,用户甚至不会意识到他们的设备里有一台计算机。

仔细调试

尽管完成的嵌入式系统通常被设计得非常坚固、安全且容错性强,但作为计算机科学家,你会发现,在开发过程中,它们可能会显得非常脆弱。我们习惯于处理可以快速、安全地进行“黑客攻击”的系统;如果某个功能不起作用,我们会修复它并重新运行,直到它正常工作。但在嵌入式系统的开发中,一次故障可能会物理性地损坏一个组件,这个组件可能难以、更换昂贵,或者耗时,所以你通常需要更加小心和有组织地规划测试。

微控制器

微控制器(又称为微控制单元MCU,或µC)是一种包含 CPU 的芯片,专为嵌入式应用而设计和销售。微控制器可能看起来像图 12-1 中的那种。

图片

图 12-1:一款 Atmel ATmega328P 微控制器芯片

在接下来的几个章节中,我们将介绍一些微控制器的常见特性。

CPU

微控制器围绕 CPU 构建。与桌面计算机相比,这些 CPU 的计算能力和能耗通常要低得多。它们通常是 8 位的,行为与复古的 8 位架构非常相似,而且通常没有浮点运算——与复古计算机一样,你需要使用整数或定点数进行工作。

微控制器通常还将内存和输入输出(I/O)组件与 CPU 集成在同一块硅片上。这种布局消除了对外部总线的需求,并减少了微控制器上的引脚数量。相比需要分开芯片和总线布线的方式,使用单一的 MCU 芯片构建物理系统更为简单。

内存

由于微控制器通常用于运行单一、固定的程序,它们通常使用哈佛架构,将程序存储为 ROM 中的固件,而 RAM 仅用于程序数据的工作内存。以这种方式使用 ROM 使得程序在系统断电时仍能保留在内存中,并在重新开机时立即可用。像所有 CPU 一样,微控制器设计时会从上电时的硬件初始化地址获取数据,第一条指令将被放置在 ROM 的这个地址处。

由于需要适应单芯片的限制,微控制器的内存远小于桌面 PC。

定时器和计数器

由于许多现实世界的控制任务需要基于时间和现实世界中的事件进行操作,因此微控制器中常常包含定时器和计数器。它们通常作为额外的简单机器出现在微控制器的 CPU 中,拥有自己的专用寄存器和指令。

你已经在第六章中学会了如何使用数字逻辑构建计数器。如果你将外部世界的一个线连接到计数器上,你可以使用该计数器来计数某些物理事件的发生次数,比如按钮按下的次数。

定时器测量自初始化以来经过的实际时间。在此上下文中,实际时间通常被称为“挂钟时间”,指的是人类观察墙上的物理时钟所报告的时间差。定时器可以通过将电子时钟(用于控制 CPU 的周期)连接到计数器来实现。

看门狗是一种特殊的定时器,在发生故障时会自动重置微控制器。这在需要在现实世界中保持可靠性的系统中使用。如果发生故障,你需要一种无需触碰机器的方式来重置系统(想想起搏器的例子)。重置是在数字逻辑层面完成的,不是 CPU 程序的一部分。

嵌入式输入输出

嵌入式系统存在的目的是控制物理设备,因此输入输出(I/O)尤为重要。我们通常会发现 I/O 模块、端口和一些非常基础、缓慢的串行通信被集成到芯片本身。由于微控制器不在外部引脚上暴露它们的总线,有限的引脚空间资源可以用来暴露 I/O 连接。一些微控制器放弃了 I/O 模块,而是使用直接 I/O 指令与这些引脚进行通信——类似于你在 Commodore 64 6510 中看到的那样。

输入输出不仅对实时执行很重要;它还提供了一种将程序上传到嵌入式系统的方法。与 PC 不同,通常无法在嵌入式设备上进行开发工作,因为这需要图形界面、键盘、操作系统和编译器都在低功耗设备上运行。相反,我们通常在台式机上进行开发工作,可能还会在台式机上使用仿真或模拟进行测试,然后再将最终的二进制可执行文件传输到嵌入式设备。微控制器有专门的模式来进行此操作:通常它们可以通过 USB、串口或其他方式连接到台式机,然后进入“固件升级”模式,通过这种连接和台式机上的软件设备驱动程序将可执行文件复制到它们的非易失性程序存储器中。

模拟-数字转换

许多微控制器需要处理进出模拟信号,但在控制器内部,信号必须是数字信号;这需要在两端进行转换。所需的转换器可以位于微控制器外部,通过其引脚连接,或者在某些情况下位于微控制器的芯片上。

模拟-数字转换(ADC)的经典案例是音频处理。来自麦克风的模拟信号被发送到数字处理器,数字处理器对音频进行特效处理后,再将处理过的模拟信号传送回扬声器。这是通过将连续的模拟信号波形量化来完成的,转换为数字信号的过程是通过在固定时间间隔内采样来实现的,如图 12-2 所示。你可以通过更频繁或更不频繁地采样来在不同的分辨率下进行此操作。

Image

图 12-2:将模拟信号量化为数字信号

在进行反向转换时,即数字-模拟转换(DAC),一些设备(如 Arduino Due)可以将数字整数真正转换为模拟电压。较便宜的设备(如 Arduino Uno)则通过脉冲宽度调制(PWM)来近似完成转换。在这种情况下,输出信号只有 0 V 或 5 V。如果需要 3 V,输出会在 0 V 和 5 V 之间迅速振荡,其中三分之五的时间为 5 V,二分之五的时间为 0 V,从而给出一个时间平均值为 3 V。对于一些应用,这不会造成明显的差异,但对于其他应用,它可能会破坏输出结果。

嵌入式串口

前一章中提到的串行端口由于其简单性和稳定性,今天仍在嵌入式系统中广泛使用。在这里做的项目中,你更可能看到这种协议的虚拟化形式,因为如今现代计算机上不常见物理串行端口。相反,你可以使用类似 USB 的方式来模拟传统的串行端口协议。类似地,Zigbee无线协议作为一个虚拟串行端口,在特定的无线频率上运行;它被嵌入式设备如可编程灯泡、交通和农业传感器网络使用。

互集成电路总线

互集成电路总线,读作“eye-two-see”(写作 I²C,有时读作“eye-squared-see”),是连接芯片的标准。它在机器人技术中非常常见。该标准由 NXP(前身为菲利普斯)拥有并授权。

I²C 通信仅通过两根线进行:数据(SDA)和时钟(SCL),如图 12-3 所示。

Image

图 12-3:I²C 架构

I²C 可以使用 5V 或 3.3V 作为高电压,并且在多种速度模式下运行,从 100Kbps 到 3Mbps 不等。总线上可能有多个设备,每个设备都有一个 7 位的许可设备地址。一个节点必须承担管理者角色,生成时钟并发起通信。其他节点则是工作节点,回复管理者。基本的消息碰撞避免是通过规则“只有在总线空闲时才说话”来实现的。

实际上,I²C 设备可以通过标准的 FTDI(未来技术设备国际有限公司)芯片访问,FTDI 提供硬件和软件接口,通常通过串行连接(通常是通过 USB 端口)。I²C 设备(惯性测量单元传感器)与用于接口的 FTDI 的例子如图 12-4 所示。

Image

图 12-4:I²C 设备(左)与 FTDI 接口(右)

不需要额外的设备驱动程序,FTDI 设备对用户来说表现得像一个串行端口。

控制器局域网总线

车载总线是一个专门的内部通信网络,连接车辆内部的各个组件,如汽车、火车、船舶、飞机或机器人。控制器局域网(CAN)总线是一种车载总线,具有所有设备共享的单一公共串行通道。CAN 没有标准连接器,因为它并非面向消费者,而是用于车辆的内部。通常,它的线路直接焊接在车辆中许多设备的印刷电路板(PCB)上。如果你移开汽车副驾驶座前的塑料盖,通常会发现一个布线束,其中包含可接入的 CAN 线路。请查阅你的车辆维修手册以定位和连接这些线路。

CAN 通常有四根内部线,这些线使用差分电压来抵御车辆中预计会出现的强外部电磁场,特别是在电动机和发动机周围。

CAN 安全是当前的一个关注点。由于它是一个总线,所有设备都可以读取和写入数据。当安全关键设备,如防抱死刹车,和非关键设备,如媒体播放器,连接到同一总线上时,这可能会导致问题。担忧的地方在于,媒体和类似设备的安全性通常不如安全设备严格。黑客可能会控制一个非关键设备,并利用它向关键设备发送恶意指令,或通过填充总线垃圾消息来拒绝服务。对于自动驾驶车辆而言,转向和加速也通过 CAN 总线管理,其后果可能特别严重。

现在你已经了解了嵌入式系统的一般概念,接下来让我们探讨这些概念在实际应用中的体现,最著名的例子就是 Arduino。

Arduino

Arduino,如图 12-5 所示,是黑客、创客和机器人研究人员的标准嵌入式系统,因为它将微控制器与所有需要的电源管理和输入输出功能封装在一个 PCB 上,你可以通过 USB 直接将其连接到桌面计算机并开始编程,而无需担心模拟电源或自行设置其 USB 输入输出系统。

Image

图 12-5:一个 Arduino 开发板。ATmega328P 微控制器是位于右下角的大芯片。

Arduino PCB 是一个基于闭源 Atmel AVR 系列微控制器(通常是 ATmega328 型号)的开源硬件设计。微控制器周围是一些额外的硬件,这些硬件使得给其供电和与之接口变得既简单又标准化。这些组件曾是计算机科学家编程微控制器的传统障碍,因为它们需要在每个项目中通过面包板或 PCB 来搭建,需要模拟电子学技能。Arduino 设计的巧妙之处在于选择并标准化了一套通常适用于多种应用的组件,并将其大规模低价生产,这样最终用户就不再需要为此担心。Arduino 提供了开源软件,允许用户通过 USB 轻松地将程序传输到固件中。(这里还有类似 C 的语言和编译器,但由于本书侧重于架构层次,我们这里只研究 Arduino 的汇编级编程。)

你可以单独编程 Arduino——例如,读取从桌面通过 USB 发送给它的数字,对其进行算术运算,并将结果发送回桌面。然而,Arduino 通常用于与其他电子传感器和执行器接口,最初是 LED 和开关。通常你将这些组件布局在面包板上,然后将电线从面包板连接到 Arduino,如图 12-6 所示。

Image

图 12-6:一个连接 LED 和按钮到 Arduino 的 I/O 电路,使用面包板和电线

无需焊接,因为组件和电线可以直接插入面包板和 Arduino 的连接器中。

ATmega328 微控制器

经典的 Arduino 微控制器——Atmel AVR ATmega328,如图 12-1 所示,表现得有些像旧式的 8 位系统,如 6502。它有 32 个 8 位用户寄存器(比 6502 的三个要多)。它有一个算术逻辑单元(ALU),包括整数乘法和除法,但没有浮点运算。类似于 6502,它有一个 8 位状态寄存器,包含告诉你算术计算结果的标志位,以便进行分支。指令集架构(ISA)包括间接寻址和硬件栈。它的时钟频率通常约为 20 MHz。

引脚分配,如图 12-7 所示,与典型的 CPU 不同,因为它没有外部总线。

Image

图 12-7:ATmega328 的引脚分配(注意没有 A 和 D 总线引脚)

不同于外部总线,14 个 I/O 引脚被直接暴露。引脚会增加芯片封装的尺寸,因此它们是一种稀缺资源。每个 I/O 引脚都可以配置为输入或输出。它们的配置通过专用的数据方向寄存器(DDR)设置并存储。

芯片照片(图 12-8)显示该微控制器不仅仅包含一个 CPU。

Image

图 12-8:ATmega328 的芯片照片

除了 CPU 外,该芯片还拥有 2 k[2]B 的 SRAM、32 k[2]B 的闪存和 1 k[2]B 的 EEPROM,所有这些都集成在硅芯片上。从这个意义上来说,这颗芯片更像是一台完整的复古计算机,而不仅仅是一个 CPU。

Arduino 采用哈佛架构。你发送到板上的程序通过主机 PC 上的软件写入闪存,而 RAM 则用于数据存储。(EEPROM 是用户可写的,提供给需要在断电时存储小型配置数据的应用。)哈佛架构使用两条独立的总线:一条 8 位数据总线和一条 16 位程序总线。没有外部内存或总线;所有内存都在芯片上。

微控制器包含串口引脚和 I/O 模块。在上电时,微控制器首先运行一个小的内部 ROM 程序,检查它的串口。如果串口上有等待的数据,则认为这是一个新的用户程序,并将其加载到闪存中。程序计数器被设置为启动用户程序。

Arduino 板的其他部分

尽管可以直接从 ATmega 的串口引脚编程,但大多数桌面计算机现在没有物理串口了,因此用户使用通过 USB 运行的虚拟串口会更方便。Arduino 板包含一个 USB 连接器和一个专用芯片(实际上是另一个较小的微控制器),它读取 USB 线并将其转换为串口信号,传递到 ATmega 的引脚。

板上的大多数模拟电子元件用于电源管理。微控制器只需要一个简单的 5 V 电源。如果提供一个稳定的 5 V 电源,那么就不需要其他电子元件。然而,Arduino 设计时考虑了多种不同的使用场景。特别是,它可以通过电池供电,也可以通过 USB 电缆获取电源。额外的组件调节这些电源,保护板免受电压波动的影响,并使它能够在它们之间切换。(否则,如果让来自其他地方的电流返回 USB 电缆进入连接的桌面计算机,那会非常糟糕。)

I²C 总线使得可以为 Arduino 插入额外的扩展模块。你可以获取其他物理板(“扩展板”),它们可以以一种整齐、可堆叠的方式插入到 I²C 总线的端口中。

由于它是一个开源平台,Arduino 已经被许多设计师修改过。例如,Ruggeduino 是一个强化版(因此也更贵),它包含了额外的保护措施,以防止你以愚蠢的方式损坏它。Arduino 团队也有官方的变种。Due 是一个用真实 DAC 替代 PWM 的版本,Mega 和 Giga 有更大的 PCB,以支持更多连接,而 Nano 则有更小的尺寸。一些变种使用不同的微控制器,为需要或偏好这些功能的人提供更多的计算能力和不同的指令集。

编程 Arduino

像所有的 CPU 一样,Atmel AVRs 执行来自指令集的机器码,你可以通过从人类可读的汇编语言中汇编程序来进行编程。Arduino 的汇编器与我们迄今为止看到的其他汇编器没有太大区别。你可以在桌面 PC 上编写、编辑并汇编这些汇编代码。Arduino 的经典 "Hello, world!" 程序是打开其内建的 13 号引脚上的 LED:

.global main
main:
  ldi r16,0b00100000   ; load bits describing eight AVR PB pins into r16
  out 0x04,r16         ; set AVR pin PB5 (Arduino pin 13) to output mode
  out 0x05,r16         ; set output on AVR pin PB5 (Arduino pin 13) to ON
.global loop
loop:
  jmp loop

全局main标签在 Arduino 开机时会自动调用。ldi指令是“加载立即数”,它将一个常数加载到寄存器中。这个特定的常数包含 8 位,每一位对应 AVR 的一个数字 I/O 引脚(在图 12-7 中标记为 PB0 到 PB7)。它们的初始值都设为 0,除了 PB5 引脚(从右到左数,从 PB0 开始,沿着二进制位),它被设为 1。AVR 的 PB5 引脚接到 Arduino PCB 的 13 号引脚,进而连接到 LED。第一个out指令将 r16 中的位复制到 0x04,即数据方向寄存器,用来配置引脚为 I/O 模式。这会将 PB5 设置为输出,其他七个引脚设置为输入。第二个out指令将相同的位从 r16 写入 0x05,即“PortB”寄存器,从而设置 8 个 PB 引脚的输出值。这会将 1 写入 PB5,引发高电压点亮 Arduino 13 号引脚上的 LED。

与许多 CPU 程序不同,loop标签和跳转指令很重要,因为它们让程序永远运行下去。如果没有这些,LED 灯只会亮几分之一秒,然后在程序结束时熄灭。嵌入式程序通常需要像这样永远运行。

程序的一个更复杂版本使 LED 灯闪烁:

#define DDRB 0x04
#define PINB 0x03
.global main
main:
  sbi   DDRB, 5         ; set bit IO; port b 5th pin (make pin 13 an output)
blink:
  sbi   PINB, 5         ; set bit IO; to toggle PINB
  ldi   r25, hi8(1000)  ; 1,000 ms delay as argument, hi byte
  ldi   r24, lo8(1000)  ; 1,000 ms delay as argument, lo byte
  call  delay_ms
  jmp   blink
delay_ms:               ; delay about (r25:r24)*ms. Clobbers r30, and r31
  ldi   r31, hi8(4000)
  ldi   r30, lo8(4000)
innerloop:
  sbiw    r30, 1        ; subtract immediate value from word
  brne    innerloop     ; branch if not equal to zero status flag
  sbiw    r24, 1
  brne    delay_ms
  ret

为了让代码更易读,我在这里定义了 DDRB 和 PINB,分别表示数据方向寄存器和 PortB 寄存器。以 16 MHz 的频率,1 毫秒大约是 16,000 个周期。内循环需要四个周期,因此我们重复执行 3000 次。

注意

AVR 也有一些 16 位指令,像 6502 一样,它们操作一对 8 位寄存器。

其他基于 CPU 的嵌入式系统

Arduino 并不是唯一的基于 CPU 的嵌入式系统。让我们来看看你可能遇到的其他替代方案。

没有 Arduino 的 Atmel AVR

Arduino 是为计算机科学家设计的,而非工程师。你通常不会将一个完整的 Arduino 板做为产品出售。相反,你会创建一个包含 AVR 芯片以及仅所需电子元件的定制 PCB,这些电子元件既包括 Arduino 板上的组件,也包括你自己设计的部分。

作为一个中间步骤,你可以使用没有 Arduino 的面包板来安装 AVR 和其他电子元件,如图 12-9 所示。

Image

图 12-9:使用 AVR 微控制器的面包板实现

一旦你确认设计工作正常,就可以使用像KiCAD这样的程序将其转换为 PCB 设计,提交给 PCB 制造公司,并通过他们的网站获得 PCB,几天后通过邮寄收到。现在你不需要自己焊接了,PCB 制造公司有机器人会帮你完成这个工作。

PIC 微控制器

PIC是另一系列微控制器,类似于但又不同于 AVR 系列。与没有 Arduino 的 AVR 一样,PIC 微控制器也需要面包板、PCB 设计和串口。

PIC(可编程接口控制器)由美国公司 Microchip 设计,该公司在 2016 年收购了其竞争对手 Atmel。PIC 广泛应用于许多消费类和工业嵌入式系统中。市场上有多种 PIC 可供选择;你可以根据需求(如速度、功耗、成本和物理尺寸)来决定购买哪一款。由于有广泛的选择,PIC 在生产工程中比 Arduino/AVR 更受欢迎。这种灵活性使得选择的 PIC 版本可以与其应用需求紧密匹配。

数字信号处理器

数字信号处理器(DSP) 是一种专门的微控制器,设计用于处理实时信号,如音频。处理此类信号的嵌入式系统有特定的要求,因为它们基本上处理的是长时间——实际上是无限的——实时数据流,这些数据格式相同且需要以相同方式反复处理。这意味着不会有太多分支;相反,数据会顺畅地流过每个阶段,始终以相同方式处理。

例如,吉他手通常购买并使用数字效果盒,它们连接在吉他和放大器之间,用于修改声音(例如,通过添加压缩、失真、延迟或混响)。这些盒子是嵌入式系统,包含一个或多个 DSP,例如图 12-10 所示的芯片。

Image

图 12-10:吉他数字效果单元中的 DSP 芯片

然而,DSP 不仅仅用于音频信号。还有许多其他类型的信号具有类似的特性,如视频、雷达以及来自各种医疗和科学监测仪器的数据流。在音频和类似音频的数据中,你通常可以通过直接量化的声波表示从 ADC 获取的声音。然而,在视频处理中,数据通常非常庞大,需要在存储和传输过程中进行压缩,这意味着许多 DSP 单元主要用于执行压缩和解压缩。

DSP(数字信号处理器)通常使用定点数表示法(如第二章中讨论的),而不是整数或浮点数。这是因为大多数信号具有明确的、固定的上下限,可以重新缩放到+1.0 和-1.0。例如,音乐音频通常以这种方式录制,任何超出这些范围的信号都会被剪切。定点数比浮点数更便宜、实现更简单,但对于这些类型的信号,能够给出相似的质量结果。

DSP 使用其可用的硅片提供额外的指令,专门用于信号处理。(回想一下第八章,增加这种额外的特定领域指令通常被认为是 CISC 哲学的一部分。)例如,针对快速傅里叶变换和卷积的特殊指令出现在为嵌入式音频设计的 DSP 中,因为这些操作构成了许多标准音频处理算法的基础。它们通常在定点模式下操作。由于 DSP 设计用于处理大量数据流,它们有时会包含额外的指令,加载和存储比单个字更大的数据块。这些指令可能会触发从一系列相邻内存位置到一组寄存器的传输序列。同样,I/O 指令可能会触发一系列的 ADC 与这些寄存器组之间的数据交换。

与标准的微控制器类似,DSP(数字信号处理器)采用哈佛架构,因此固件可以在制造时存储在 ROM 中,然后永远运行。

没有 CPU 的嵌入式系统

迄今为止我们看到的嵌入式系统都是基于微控制器的,这意味着它们仍然依赖于一个执行机器代码指令的 CPU。但也存在其他更简单的嵌入式系统,它们没有 CPU、没有程序、没有指令集。只有通过数字逻辑电路布局的硬件,用于计算你需要计算的内容。这些系统包括 PLC 和 FPGA。

可编程逻辑控制器

可编程逻辑控制器(PLCs) 是一种嵌入式系统,旨在执行简单的计算以控制工业环境中的机械设备,具有非常高的可靠性。它们通常出现在充满灰尘、化学品、食物碎片、高低温以及其他极端条件的工厂环境中,这些条件使得普通芯片难以生存。其设计理念是安装一种耐用的设备,能够持续运行 20 年,永不宕机。系统必须几乎不可摧毁,绝对可靠,并且尽可能简化,以避免任何错误的潜入。在这种工业自动化的背景下,嵌入式系统有时被称为监控控制与数据采集(SCADA)系统。

你会在这些环境中看到 PLC,通常它们被打包成所谓的 DIN 模块,并安装在标准的 DIN 轨道上,如图 12-11 所示。

在你的家里——通常是在地下室或楼梯下——你可能会有一个 DIN 风格的模块,它充当整个房屋的熔断器或断路器(也称为剩余电流装置,RCD)。同样,它是经过坚固设计的,旨在在任何正常操作情况下都不会失败。DIN 设计在 1970 年代标准化,至今仍然在使用。

Image

图 12-11:安装在 DIN 轨道上的 DIN 模块

PLC 不像运行一系列指令的程序那样运行;相反,它的功能通常是通过一种名为梯形逻辑的可视化系统来指定的,如图 12-12 所示。

图片

图 12-12:嵌入式设备的梯形逻辑配置示例

从本质上讲,梯形逻辑是一组“如果-那么”规则,它表示如果一个输入为高电平,则连接一根线到另一根线。没有程序从顶部开始并通过指令列表进行工作;每个单元都按照周围规则的逻辑运行。它来源于早期通过物理机电继电器构建计算机的时代。

梯形逻辑如此简单,甚至工程师都能使用。但这种简易性也意味着既可以使用正式方法,也可以通过直观检查来验证系统是否确切地做了它们应该做的事。如果在一个操作系统和现代编程语言及编译器中有任何微小的错误,可能导致核燃料棒移到错误的位置,那么你肯定不希望系统那么复杂;一切都必须是绝对可靠和可理解的。

PLC(可编程逻辑控制器)简单、完全透明且可验证。你可能会惊讶地发现,编写这些设备程序的工程师往往赚得比大多数计算机科学程序员还多,但他们也承担着安全责任。这个程序可能存在于一个核电站中,即使程序非常简单,也必须确保它的正确性。你不再需要通过直接配置梯形逻辑来设计 PLC;现在有编译器和汇编器可以将 C 代码转换为这些配置。当然,做到这一点需要信任编译器和汇编器程序,以及你自己的代码。

嵌入式安全

SCADA 系统绝不应该连接到公共互联网。在安全审计中,有一个著名的引导性问题,问 SCADA 管理员:“在紧急情况下,当所有工作人员都不在现场,正在家中工作时,你如何远程接入系统,接管控制,以防核燃料棒临界?”令人担忧的是,很多管理员会自豪地解释他们确实有这样的连接,而这些连接当然会被黑客利用。

即使没有互联网连接,系统通过“气隙”与网络隔离,仍然有可能被访问。2010 年的 Stuxnet 蠕虫就是通过在国际学术会议上散发的 USB 闪存驱动器传播的。它通过 USB 闪存驱动器在全球范围内自我复制,直到到达伊朗核武器燃料浓缩离心机的嵌入式系统。Stuxnet 然后只影响它们特定型号和配置的 PLC,微妙且几乎不可察觉地改变离心机的定时,从而摧毁它们并防止燃料浓缩。

嵌入式 FPGAs

第五章中讨论的 FPGA(现场可编程门阵列)芯片可以用于实现任何数字逻辑设计——不仅限于那些用于或与 CPU 配合使用的设计。这可以包括类似 PLC 的结构和许多其他数字逻辑网络设计。

由于嵌入式系统执行单一功能,能够运行任意指令程序的 CPU 设计可能既过于复杂又低效。相反,特定的算术或其他变换序列可以直接作为一系列简单机器实现,并在 FPGA 中通过流水线连接。例如,可以通过将多个加法器和乘法器按照实现信号处理算法所需的特定顺序连接在一起。此外,这种方式不仅减少了所需的 CPU 风格硬件,还能使系统运行得非常快,因为所有这些算术操作可以并行执行。

硬件描述语言在创建此类设计时尤其有用。例如,它们可以将算术步骤用类似 C 语言的语言表达,然后自动编译成适当的数字逻辑。

普适计算与有意识计算

普适计算(Ubicomp),即普及计算,是由马克·韦瑟(Marc Weiser)在 1980 年代的施乐帕洛阿尔托研究中心(Xerox PARC)创立的一种嵌入式设计哲学(同一地方发明了鼠标和图形桌面)。马克·韦瑟概述的核心理念是:“计算机的目的是帮助你做其他事情。最好的计算机是一个安静、隐形的仆人。你通过直觉做得越多,你就越聪明;计算机应该扩展你的无意识。技术应该带来宁静。”

普适计算体现在像亚马逊的 Alexa 等产品中。它隐形地存在于你的家中,当你想要什么时,你大声说出来,它就为你完成。你无需坐在计算机前思考如何做。普适计算的理念也在近年来的“普遍计算”和物联网等领域重新出现。

最近出现了一种反对普适计算的运动,我们可以称之为有意识计算。它的支持者认为,用户不希望被不确定的、无法理解的公司云端做决定。他们对失去对这些机器的控制感到恐慌。因此,有意识计算做了相反的事情,故意将注意力集中在技术上,迫使用户思考并理解他们正在使用的机器。

根据一种普适计算的哲学,灯光开关可能会消失,因为机器可以自动预测何时应该打开或关闭灯光,而无需用户输入。根据有意识计算的理念,灯光开关应当保留,用户应当在触摸开关时,专注地将自己的意识与开关融为一体。

摘要

嵌入式架构构成了世界上大多数计算机的核心,但由于其本质,它们通常对大多数用户来说是隐形的。它们的应用存在于计算与工程之间的交界处,但它们的架构与复古计算机的架构非常相似,而且它们为喜欢这种计算风格的粉丝提供了一个有趣的工作领域。大多数嵌入式系统基于微控制器,这些芯片将低功耗的 CPU 与板载内存、I/O 及其他有用功能结合在一起。Arduino 是一个标准的嵌入式平台,它封装了计算机科学家与硬件(如机器人、工厂、汽车和艺术装置)进行接口所需的大部分工程工作。

练习

模拟 Arduino 编程

  1. 使用开源的 Wokwi Arduino 仿真器运行本章中展示的示例 Arduino 程序。要使用汇编器,请访问* wokwi.com/arduino/projects/290348681199092237 中的 blink.S 标签,或在 github.com/arcostasi/avr8js-electron-playground *找到离线版本。

  2. 记住,Arduino 的 I/O 引脚可以配置为输入或输出。如果引脚的读写行为不如预期,请首先检查它是否被设置为正确的模式。

  3. 与 LED 闪烁程序中使用的嵌套延迟循环相比,使用 AVR 内建的定时器编写闪烁灯光程序是一种更漂亮且更节能的方式。研究实现这种方式所需的寄存器和命令,并实现这种替代版本。

具有挑战性

  1. 有许多价格实惠的 Arduino 入门套件;购买一个并尝试在真实硬件上运行示例程序。当使用真实的 LED 时,记住它们是二极管,有方向性,必须正确连接,并且始终与电阻并联;否则,它们会爆炸!

  2. 大多数套件都附带一些用 Arduino C 编写的示例程序;尝试用你自己手写的 AVR 汇编代码来复现它们的功能。(如果遇到困难,可以尝试将 Arduino C 编译成汇编代码,并检查它以获取灵感。)

  3. 如果你更喜欢命令行工具而非 Arduino IDE,AVRA 是 AVR 汇编器,而 AVRDUDE 是 AVR 下载/上传工具。

进一步阅读

第十六章:## 桌面架构

Image

“每个桌面上都有一台计算机”是比尔·盖茨在 1990 年代 32 位时代的愿景,尽管当前的趋势是物联网和云计算,但个人计算机(PC)今天仍然能在许多桌面和膝上看到。PC 并不是单一的计算机设计;它是通过将来自不同制造商的许多不同组件结合在一起,围绕 x86 系列 CPU 构建的一套松散的约定。

由于商业驱动的向后兼容性,现代 PC 保留了许多早期阶段的特性,因此在本章中,我们将研究这些约定是如何形成的,以及它们如何影响 x86 架构和 PC 计算机设计。我们将考察 x86 的 CISC 哲学及其硅谷历史和指令集,然后看一下用于围绕它构建现代 PC 的一些计算机设计元素。

CISC 设计哲学

大多数桌面计算机使用来自 x86 系列的 CPU,这些 CPU 通常被描述为 CISC 架构。我们已经见过几次 CISC 架构,但让我们更详细地看看出现在 x86 中的一些 CISC 原则。

在 CISC 架构中,你试图在一个大而复杂的芯片上做尽可能多的、巧妙的事情,芯片包含大量的硅。你设计许多不同的小型机器,每个机器都执行不同的专门功能;你还为每个机器提供专门的指令。正如你可以想象的那样,这非常难以设计,最终你不得不支付给你的架构师大量的钱——特别是当所有新的复杂功能需要与其他创新(如流水线和乱序执行(OOOE))良好协作时。使用大量硅通常会消耗大量功率,因此 CISC 处理器通常需要插入电源插座,配备强大的电源变压器和大型冷却系统,如风扇。这些要求在桌面环境中比在嵌入式和智能设备环境中更容易满足。

CISC 哲学的一个经典特征是有大量指令将内存访问与算术逻辑单元(ALU)指令结合,例如“将第一个地址的内容与第二个地址的内容相乘,并将结果存储在第三个地址”,其中地址位于 RAM 中。实际上,这是一个涉及多个步骤的复合指令:我们需要加载这两个地址,乘以它们的值,将结果存入寄存器,然后再次将其存储到内存中。

CISC 还强调在硬件中实现新指令,基本上是在说:“更多的硅解决问题。”例如,如果用户需求大量的视频编解码流媒体,你可以创建专门的指令来执行视频编解码中使用的特定数学运算,并在数字逻辑中构建许多新的简单机器来实现它们。

一个“解码我的视频”指令将需要超过一个时钟周期,并且处理不同指令所需的时间差异是 CISC 架构中面临的主要挑战。特别是,当指令的执行时间不同的时候,流水线和乱序执行(OOOE)就更难正确实现。这个问题可以通过增加更多硅片来解决:你可以在控制单元(CU)中创建更复杂的数字逻辑来识别这些时间差异,并根据它们进行调度。

CISC 架构的一个假定优势是,编译器几乎不需要做任何工作就可以将常见的高级语言语句翻译成汇编语言;这是因为指令集架构(ISA)为诸如“解码我的视频”这样的命令提供了专门的指令,这些指令通常有一个简单的一对一的翻译。但是这些指令让编译器编写者的工作变得更加困难,他们现在需要为每个目标后端 CPU 通读五卷指令集;他们现在还需要尝试针对每个特定的 ISA 来优化编译器。对于编译器开发者来说,使用一本指令集并忽略所有高级指令会更容易。实际上,这意味着 CISC 架构更有可能配备由同样开发 CPU 的团队编写的编译器,因为没有其他人愿意为某个特定 CPU 进行优化。这些编译器通常是专有的,并且由于涉及的复杂性,比开源版本运行得更快;只有那些构建系统的人才完全理解所有的特性。

另一个优点是汇编程序可以很短,因为每条指令做了很多工作。在 1980 年代,这一点很重要:RAM 受限,因此较短的程序释放了更多的 RAM 用于数据。今天,这点已经不那么重要了。

CISC 是由一位英国人莫里斯·威尔克斯(Maurice Wilkes)发明的,如前所示在图 1-19,但它是在美国得到商业化的。典型的 CISC 架构师和用户是以商业驱动为主的,CISC 在现实世界的桌面计算中占据主导地位。你今天可能就在使用 CISC 架构的桌面计算机。如果一个 CISC 客户要求添加一个新的指令来加速他们特定的多媒体应用程序,那么 CISC 公司通常会为他们设计并添加这个指令——当然是有成本的。这些新特性通常是以这种方式附加上的,并不一定设计得与之前的系统完美契合。然而,为了避免破坏其他客户的现有系统,旧特性通常会被保留。

微程序设计

在硬件中构建新 CPU 既困难又昂贵。制作一个芯片掩膜集的成本大约为 500 万美元,如果某个地方出现错误,就需要新的掩膜。由于 CISC 设计复杂,这个问题尤为严重。微程序设计是解决这个问题的一种方法,它使得架构由许多简单的机器组成,这些机器可以通过基本的开关连接和断开。指令则被定义为连接和断开序列。例如,要将两个寄存器相加,你首先将其中一个寄存器连接到 ALU 输入,然后将另一个寄存器连接到另一个 ALU 输入。接着,你将 ALU 连接到一个信号,指示它进行加法运算,最后将 ALU 输出的结果连接到一个寄存器。

这个想法让人想起了巴贝奇的分析机中的旋转桶控制单元(CU)。桶中有触发器,通过它们可以触发一系列简单机器的工作。如果触发器位置发生变化,就可以轻松创建不同的指令和架构。现代电子微程序设计——因此也包括 CISC——被归功于威尔克斯(Wilkes),他研究并教授计算历史,并且非常坦率地承认从巴贝奇的机械桶中获得了这个想法。这是一个典型的例子,说明了研究历史的轨迹如何促成现代架构中的重大、图灵奖获奖的进展。

巴贝奇桶中触发器的电子版本通常是固件,称为微代码,它存在于 CPU 内部,包含每条指令中要顺序连接和断开的连接列表。(这不是 CPU 地址空间中的 ROM,而是 CPU 内部一个不可寻址的独立区域。)作为固件,它可以随时进行电子重编程。这大大减少了修复 CPU 硬件故障的成本,因为可以通过固件更新来修正问题,而不必返回并重新制造芯片。

微程序不是机器代码程序;它们存在于更低的层次,定义了机器代码运行的机器。微程序的动作可以使用寄存器传输语言(RTL)进行标注,如在第七章中所示。现代 CISC 芯片可能有成千上万条复杂的指令,这些指令都通过微代码来定义。如果你愿意,你可以重新编写你的 CPU 微程序,实施完全不同的指令集,比如将 x86 转换为复古的 6502!现在的可重配置性已经非常强大,微程序几乎可以像 FPGA 一样运行。

现在我们已经了解了一些设计概念,让我们来看看 x86 的历史。了解这些历史将帮助你理解现代 x86 处理器中仍然存在的、通过这段历史积累的特性。

x86 历史

x86 架构是迄今为止最具商业成功和韧性的 CPU 架构,2023 年迎来了它的第 45 个周年纪念。x86 是一系列 CISC 架构,其设计和名称来源于英特尔处理器前几代的型号:8086、80286、80386 和 80486。x86 跨越了三代字长:16 位、32 位和 64 位架构。作为一个商业产品,它特别强调与所有前代架构的严格向后兼容性,尽管这增加了设计的复杂性,包括数字逻辑,以确保历史遗留的 bug 得以保留,从而使得那些利用这些 bug 作为特性的旧游戏仍能继续运行。你仍然可以将 1970 年代的可执行机器代码拿到现代 x86 上运行,它会“顺利运行”。(这与商业操作系统中的软件设计方法类似,后者也同样膨胀到巨大的规模,以维护客户兼容性,代价是性能和美观的牺牲。)由于不断添加新的 CISC 指令并保留所有旧指令,最新版本的 x86——amd64指令集架构——如今包含超过 3000 条指令,这些指令被记录在五卷的参考书中。

史前时期

x86 设计的历史是硅谷架构和政治的历史,特别是英特尔和 AMD 两家公司之间的历史。这两家公司使用相同的专有指令集制造处理器,而且它们之间不断进行法律斗争,这些争斗已经持续了几十年。

威廉·肖克利、约翰·巴丁和沃尔特·布拉廷因发明晶体管而获得了 1956 年诺贝尔物理学奖,发明地是在新泽西州的贝尔实验室。肖克利的家族来自加利福尼亚州的帕洛阿尔托,尽管他出生在伦敦。获奖之后,你可以在任何地方生活和工作,因此肖克利决定从新泽西迁往加利福尼亚州的山景城,因为他希望能靠近位于帕洛阿尔托的母亲。他在这里成立了肖克利半导体公司,继续进行晶体管的研究和商业化。

到了 1957 年,由于诺贝尔奖得主的傲慢与对一些被员工认为是边缘话题的痴迷的混合,Shockley 已经成为一个很难合作的人。一群员工,所谓的“背叛的八人组”——包括戈登·摩尔和罗伯特·诺伊斯——离开了 Shockley,成立了竞争公司费尔柴尔德半导体。这在当时的商业文化中几乎被视为亵渎,因为人们普遍认为,员工会加入大公司,并忠诚地为公司服务整个职业生涯。此后,这种做法成为了硅谷创业文化的蓝图,认为员工会并且应该离开大公司,自己创办公司。

飞兆半导体公司(Fairchild)创造了集成电路(芯片)的第一个商业版本。此时,对计算的需求几乎完全来自美国军方,军方利用纳税人的资金资助研究,并购买芯片制造商的产品,为冷战中的导弹和飞机提供动力。这些政府资金推动了硅行业的发展,加速了飞兆半导体的成长,并促使许多竞争对手的崛起,因为飞兆的员工复制了飞兆的模式,离开公司创办了自己的竞争性芯片公司,最终催生了现代硅谷。

1968 年,飞兆半导体的内部政治导致戈登·摩尔和罗伯特·诺伊斯再次辞职——这次他们离开飞兆成立了英特尔(Integrated Electronics 的缩写)。AMD(Advanced Micro Devices)于次年由杰里·桑德斯创立。AMD 早期的目标是复制英特尔的产品,并作为第二来源以更低的成本生产这些产品。在 x86 系列正式推出之前,英特尔于 1971 年生产了 4 位的 4004 芯片,随后 AMD 于 1975 年将其克隆为 Am9080。英特尔在 1974 年通过推出 8 位版本的 8080(3 MHz)抢先一步,随后 AMD 也复制了这一款。

16 位经典时代

x86 家族的第一个成员——由现代向后兼容性定义——是英特尔于 1978 年推出的 16 位、5 MHz 的 8086 芯片。这是一款采用微程序设计的 CISC 芯片。x86 的名称来源于其最后两位数字。

英特尔与 AMD 之间的竞争在 1982 年通过英特尔、AMD 和 IBM 之间的三方合同正式化,当时 IBM 的业务是制造计算机。IBM 希望购买用于其计算机的 CPU,但不想被单一公司设计的专有技术锁定,因为这样一来,该公司可能通过锁定政策勒索 IBM 并提高价格。作为一个巨大的公司,IBM 拥有足够的购买力来通过与供应商的博弈获取其真正想要的东西——让多家公司竞争生产相同的芯片作为通用商品;这将压低价格并使 IBM 能够长期以低价获得这些芯片。IBM 对英特尔说:“我们想买你们的芯片,但只有在你签署这份合同,允许 AMD 复制它们的情况下我们才会购买。如果你不签署,我们就不从你们任何一方购买。”三家公司达成一致,最终创立了著名的英特尔-AMD 交叉许可协议,允许两家公司设计和销售实现相同 x86 指令集架构(ISA)的芯片。

注意

这是计算机经济学的一般教训:在销售之后,硬件或软件平台的卖方可以通过锁定效应对买方施加极大的控制力。因此,平台的卖方应在初期尽可能免费或大幅折扣地提供其平台,以便让用户锁定在平台上,然后再提高销售条件,一旦他们掌控了买方。但在买方选择平台之前,买方才是拥有所有权力并决定一切的人。因此,买方应当积极谈判,以形成一份合同,从而在以后减轻卖方对他们的控制力。一旦你付了钱,除了合同中约定的内容,你将不再拥有任何权力。

IBM 的交易将两大芯片制造商推向了商务计算市场,使他们能够快速扩张。交易之后,英特尔用 80186(1982 年;6 MHz)更新了 8086,随后推出了 80286(1982 年;8 MHz),首次增加了操作系统支持的保护模式。AMD 随后快速复制了 80286,推出了 Am286(1982 年;8 MHz)。这些 16 位设备出现在 1980 年代初期,作为高端商务机器,与此同时,8 位的黄金时代也在家庭中到来。

32 位克隆战争时代

32 位时代始于英特尔的 386(1985 年;16 MHz),它引入了 32 位指令集 x86 IA-32。 在这一时代,两个主要芯片制造商之间的竞争和法律诉讼不断,这一过程在赛瑞克斯和威雅等额外竞争者加入后变得更具娱乐性,这些公司也生产 x86 兼容芯片。表 13-1 总结了这些发展。

表 13-1: 32 位时代 x86 发展

年份 制造商 架构 特性
1985 英特尔 386 16 MHz
1989 英特尔 486 50 MHz, 流水线设计, FPU
1991 AMD Am386 386 的克隆
1993 英特尔 Pentium 75 MHz, 超标量
1993 AMD Am486 486 的克隆(最后一个克隆)
1995 英特尔 P5 150 MHz, MMX SIMD “Pentium MMX”
1995 英特尔 P6 (i686) 200 MHz, SSE SIMD, OOOE, “Pentium Pro”
1996 AMD K5 133 MHz, 类似 Pentium
1995 赛瑞克斯 Cx5x86 140 MHz, 类似 Pentium
1996 赛瑞克斯 6x86 140 MHz, 类似 Pentium
1997 AMD K6 300 MHz, 3D-NOW, 对抗 SIMD
2001 VIA C3 500 MHz, 类似 Pentium
2001 AMD Athlon 2 GHz

英特尔通常是技术领导者,创造了诸如流水线设计和扩展指令等新技术,其他公司在一到两年后跟进以降低价格。在每个阶段,时钟速度都稳定地变得更快,遵循摩尔定律。这就是“乏味的 1990 年代”,当时客户认为他们需要每 18 个月购买一台新的米色台式电脑,以跟上时钟速度的翻倍。

在 486 处理器之后,英特尔厌倦了竞争对手复制无法注册商标的 86 名称,因此他们改用了可注册商标的品牌名称“奔腾”。这一芯片在一段时间内占据主导地位,但后来 AMD 通过其 Athlon 处理器在 2001 年率先达到了 1 GHz 的速度,取得了领先。

64 位品牌化时代

x86 的 64 位时代在 2000 年到来,当时 AMD 正式定义了 amd64 ISA,随后大多数 CISC 处理器都采用了这一架构。这是一场革命:x86 ISA 家族之前一直由英特尔定义,其他厂商只能将自己的产品与其挂钩。

英特尔曾试图定义自己失败的 64 位竞争对手指令集架构(ISA),名为 IA-64,但该架构在 amd64 发布之后才面世,并未获得广泛认可;如今,大家都使用 amd64。然而,英特尔拒绝承认 amd64 这一名称,而是称其为 x86_64。令人困惑的是,你会看到这两个名称都被用来描述该 ISA 的软件执行文件下载,比如在 Linux 发行版的软件包名称中。

64 位时代的特点是市场营销术语与基础技术的分离,同一市场品牌常常用来标示完全不同的架构。与之前的 32 位奔腾处理器不同,品牌名称不再与特定的设计相联系。你可能已经习惯看到像奔腾、赛扬和至强这样的 64 位产品品牌。你也可能会在品牌名称中看到数字 3、5、7 和 9,如 Core i3、Core i5 等。对于英特尔来说,这些数字除了暗示哪些产品更好之外,并没有其他意义;而 AMD 则用相同的数字来暗示哪些产品与英特尔的产品类似。

表格 13-2 展示了英特尔和 AMD 发布的一些例子及其在 64 位时代的显著特点。

在这一时期,管道阶段数通常在 14 到 20 之间,并且始终使用了超标量执行(OOOE)。AMD 的 Piledriver 架构首次引入了基于神经网络的分支预测硬件。

时钟频率在 64 位时代初期达到了 3.5 GHz,并且自此停滞不前,这是由于摩尔定律对时钟频率的限制。然而,摩尔定律在晶体管大小上的影响仍然存在,并且通常通过晶体管的尺度(单位:纳米)来定义机器,而不是通过时钟频率,以展示技术的持续进步。在 2006 年到 2016 年期间,英特尔采用了“tick-tock”周期,在此周期中,他们的新产品交替采用新的数字逻辑设计(tock)和采用新的晶体管技术使相同设计更小更快(tick)。Boost是 Nehalem 架构首次引入的功能,它能在高负载计算的瓶颈处暂时提升时钟频率,超越通常的 3.5 GHz 热限制,持续短时间。

表格 13-2: 64 位时代 x86 发展历程

年份 制造商 架构 晶体管大小(nm) 品牌
2003 AMD Hammer (K8) 130 Opteron
2005 AMD Hammer (K8) 90 Athlon 64 X2
2006 Intel Core 65 Celeron/Pentium/Xeon
2007 AMD 10h (K10) 65 Opteron
2008 Intel Nehalem 45 Pentium, Xeon, Core(第一代)
2011 Intel Sandy Bridge 32 第二代 Core i3/i5/i9;Xeon
2012 AMD Piledriver 32 Opteron
2013 Intel Haswell 22 第四代 Core i3/5/7;Celeron/Pentium/Xeon
2015 Intel Skylake 14 第六代 Core i3/5/7;Celeron/Pentium/Xeon;CoreM
2017 Intel Coffee Lake 14 第八代 Core i3/5/7;Celeron/Pentium Gold/Xeon
2017 AMD Zen 14 Ryzen 3/5/7 1000 系列
2018 AMD Zen+ 12 Ryzen 3/5/7 2000 系列
2019 AMD Zen2 7 Ryzen 3/5/7 3000 系列
2020 AMD Zen3 7 Ryzen 5/7/9 5000 系列
2021 Intel Cypress Cove 14 第十一代 Core i5/7/9;Xeon
2021 Intel Golden Cove 7 第十二代 Core i5/7/9;Xeon
2022 AMD Zen4 5 Ryzen 5/7/9 7000 系列

现在我们已经了解了 x86 的演变过程,接下来让我们看看它的指令集,并学习如何编程。这将是一个比我们之前研究过的其他架构更为复杂的过程,但希望通过了解历史,你至少可以明白为什么事情会发展成现在这个样子。

编程 x86

x86 架构庞大且复杂,它的代码通常是由编译器生成,而不是手工编写的。不过,如果你想更好地理解你的编译器和计算机是如何工作的,或者如果你想编写编译器或其他系统软件,比如操作系统和引导程序,那么学习 x86 仍然值得花时间。因为 x86 是一种广泛使用的架构,理解它在安全应用中也很有用,例如破解和防护代码,包括游戏的作弊和反作弊系统。

作为一种 CISC 架构,x86 通常会有多种不同的指令变体,它们接受不同类型的操作数,如常量、寄存器和内存位置。不同时间点添加了不同的指令组,这些指令组并不总是遵循相同的约定:例如,整数加法、整数乘法和浮点操作向程序员呈现的接口差异很大。你不会从零开始设计一个使用如此不同接口的 CPU;这种混乱正是架构随着时间发展所带来的结果。

这不会是一次详尽的 x86 特性之旅。相反,我们将通过几个例子,展示 CISC 扩展是如何创建的,以及它们是如何运作的。

寄存器

由于 x86 随着时间的演变以及其对向后兼容性的需求,它的寄存器集已经发展成一种特定的形式。寄存器大体上分为两类,我们来看看每一类。

通用寄存器

在 x86 架构中有八个通用用户寄存器。它们的名称反映了它们的传统用途。表 13-3 显示了这些寄存器。

表 13-3: x86 通用寄存器

寄存器 含义 用途
AX 累加寄存器 算术运算
BX 基址寄存器 指向数据的指针
CX 计数寄存器 移位、旋转和循环指令
DX 数据寄存器 算术运算和 I/O 操作
SP 栈指针寄存器 指向栈顶的指针
BP 栈基指针寄存器 指向栈底的指针
SI 源索引寄存器 指向数据复制的源
DI 目标索引寄存器 指向数据复制的目标

在原始的 16 位 8086 中,通用寄存器都具有 16 位。为了与之前的 8 位 8080 保持部分向后兼容,前四个寄存器—AX、BX、CX 和 DX—也可以拆分成两个 8 位寄存器,分别命名为 H 和 L,代表高字节和低字节,可以独立访问。

IA-32 将这八个寄存器扩展为 32 位。为了保持兼容性,它们仍然可以像以前一样以 16 位或 8 位寄存器的形式访问。要以完整的 32 位模式访问它们,我们在它们的名字前加上前缀 E(表示扩展):EAX、EBX、ECX 等。

amd64 再次扩展了这八个寄存器,至 64 位。与之前一样,32 位、16 位和 8 位版本保持不变以保持兼容性。要以 64 位模式访问它们,我们在它们的名字前加上前缀 R:RAX、RBX、RCX 等。amd64 还增加了八个额外的 64 位通用寄存器,命名为 R8 到 R15。

由于 x86 被定义为基于 16 位系统的架构,并且需要保持向后兼容,因此在 x86 中, 仍然表示 16 位数据,而不是现代寄存器的完整大小。双字dword 表示 32 位,四字qword 表示 64 位。

图 13-1 总结了通用 x86 寄存器的发展历程。

Image

图 13-1:x86 寄存器。寄存器名称显示在每个寄存器的左侧,8 位寄存器名称显示在寄存器的中央。

为了兼容不同的字长,内存寻址始终是按字节进行的,即使在现代的 amd64 上也是如此。这与寻址 64 位 (不重叠)的内存有所不同。字以小端字节的方式存储在内存中。

内部寄存器

程序计数器在 x86 中称为 指令指针,在使用其 16 位、32 位或 64 位形式时,分别标识为 IP、EIP 或 RIP。

状态寄存器称为 FLAGS、EFLAGS 或 RFLAGS,取决于其使用的 16 位、32 位或 64 位形式。其结构见 图 13-2。

Image

图 13-2:x86 状态寄存器(与 图 11-6 比较)

这与 6502 的状态寄存器非常相似,且具有类似的助记符。与 6502 一样,这些标志通过比较指令设置,然后通过单独的分支指令进行查询。也有指令用来清除标志。两个重要的标志,像其他架构一样,是零标志(ZF)和符号标志(SF)。

Netwide Assembler 语法

由于其悠久的历史,x86 形成了几种不同的汇编语言和语法,它们都能汇编成相同的机器代码。在这里,我们将使用 Netwide Assembler (NASM) 风格,它是最不糟糕的一种。

x86 指令通常有两个操作数。在 NASM 语法中,第一个通常是目标操作数,有时也作为输入操作数来更新以存储结果,就像一个累加器;第二个操作数是输入操作数。

和大多数汇编语言一样,NASM 允许我们通过插入文本标签并加上冒号来为程序的每一行标记文本标签,像这样:

mylabel:

如果在第 5 行插入一个标签,我们可以通过使用其标签名称而不是数字 5 来跳转到或从第 5 行加载数据。

数据移动

要在寄存器和 RAM 之间复制常量或寄存器内容,可以使用相同的 mov(移动)指令。这将加载、存储和移动的操作统一化。提供了几种不同的寻址模式。

立即寻址将常量放入寄存器。例如:

mov rbx, 123         ; place decimal 123 into register RBX
mov ebx, 4c6h        ; place hex 4c6 into register EBX
mov bh, 01101100b    ; place binary 01101100 into register BH

寄存器寻址将数据从一个寄存器复制到另一个寄存器内部,例如:

mov rax, rbx         ; copy to RAX from RBX

直接寻址通过指定的地址从内存加载或存储数据。可以使用标签代替数字地址,在这种情况下它们被称为 变量。例如:

mov rbx, [1000h]      ; load to RBX from hex address 1000
mov [1000h], rbx      ; store to hex address 1000 from RBX
mov rbx, [1000h+20h]  ; load from an address with offset
mov [1000h+20h], rbx  ;  store to an address with offset
mov rbx, myvar        ; load a labeled address (address, not its content)
mov rbx, [myvar]      ; load content of a labeled address
mov [myvar], rbx      ; store to a labeled address from RBX

寄存器间接寻址使用方括号表示,例如:

mov rax, [rdi]         ; copy to RAX, from content of the address in RDI
mov [rdi], rax         ; copy to address in RDI, from RAX

在这两条指令中,假设 RDI 包含一个地址,该地址被用来加载或存储来自 RAX 的值。

数据创建

RAM 中的数据位置可以被赋予名称,并且可以是初始化或未初始化的。为了用一个值初始化一个位置并为其创建一个名称,我们使用以 d 开头的命令,表示 定义。例如:

mybyte: db 15          ; define byte
myword: dw 452         ; define word (2 bytes)
mydword: dd 478569     ; define doubleword (4 bytes)
myqword: dq 100000000  ; define quadword (8 bytes)

为了命名一个未初始化的位置,我们使用以 r 开头的命令,表示 保留

mybyte:  resb 1        ; reserve uninitialized 1 byte
myword:  resw 1        ; reserve uninitialized 1 word
mydword: resw 1        ; reserve uninitialized 1 doubleword
myqword: resw 1        ; reserve uninitialized 1 quadword

请注意,这些不是 x86 指令,而只是数据的标记区域,指令告诉 NASM 将它们视为数据区域。

要创建数组,我们只需分配一组连续的地址。例如:

myarray:  dq 1, 2, 3, 4 ; define 4 quadwords, myarray addresses first element
myzeros:  times 4 dw 0  ; define 4 doublewords all to 0
mywords:  resw 100      ; reserve uninitialized 100 words
mystring: db "hello", "world", 10, 0    ; define a single 12-char ASCII string

NASM 还提供宏指令,允许你定义数值 (equ) 和字符串 (%define) 常量:

SCREEN_WIDTH equ 1920
%define isTrue 1

在汇编之前,NASM 会替换这些常量的值。这些宏指令不是 x86 指令集的一部分,但 NASM 提供它们以方便使用。

算术和逻辑

由于 x86 指令通常设计为接受两个参数,因此大多数算术操作是以累加器风格进行的。虽然没有单一的累加器寄存器,但任何寄存器都可以充当累加器。例如,在这里我们将值 1 放入 RBX 中,并将 2 加到其中,因此最终它存储了结果 3:

mov rbx, 1
add rbx, 2

作为一种 CISC 架构,算术指令通常有变种,将从内存加载数据与算术操作结合。例如,下面是如何将地址 1000h 和 2000h 中的两个数字相加,并将结果放入 RBX 寄存器:

mov rbx, [1000h]
add rbx, [2000h]

请注意,x86 包括最极端的 CISC 风格加法,例如 [3000h] := [1000h]+[2000h],它将两个加载、一次加法和一次存储结合在一个指令中。

减法的工作方式与加法相似:

sub ax, 5

可以使用 incdec 指令对 8 位、16 位或 32 位操作数进行增量和减量:

dec ax                ; decrement content of register
inc [mybyte]          ; increment content of variable mybyte

要对整数操作数进行乘法或除法,x86 提供了 muldiv 指令。与加法和减法不同,这些指令始终使用 A 寄存器作为累加器(因此得名),并作用于给定指令的操作数。例如:

; 64-bit multiplication
mov rax, 2
mov rbx, 3
mul rbx      ; result 6 is in accumulator RAX
; 16-bit multiplication
mov ax, 20   ; first operand
mov bx, 4    ; second operand
mul bx       ; result is stored in AX
; 8-bit division
mov al, 10   ; dividend
mov bl, 2    ; divisor
div bl       ; result stored in AL
; 16-bit signed division
mov ax, -48 ; dividend is negative, need signed version
cwd          ; extend AX into DX
mov bx, 5
idiv bx      ; result in AX, remainder in DX

在上述最后一个示例中,div 指令前缀加上了 i,表示使用的是有符号整数。cwd 指令通过允许 DX 寄存器作为 AX 的扩展来转换字为双字,以容纳符号信息。

按位逻辑指令包括 andornotxor。例如:

and ax, 01h
or  ax, bx
not ax

与加法相同,第一个操作数充当累加器,因此会被结果覆盖。

流程控制

NASM 提供了两种类型的标签:符号标签和数字标签,两者都可以用于跳转和分支。符号标签由一个标识符后跟冒号(:)组成。它们必须仅定义一次,因为它们具有全局作用域。如果标签标识符以句点(.)开头,则被视为局部标签,仅在当前文件中使用。以下是使用符号标签和跳转的无限循环示例:

mylabel:
    jmp mylabel

数字标签由一个 0 到 9 范围内的单个数字后跟冒号组成。数字标签被视为局部标签。它们也有有限的作用域,因此可以反复重新定义。当数字标签用作引用(例如作为指令的操作数)时,应在数字标签后添加后缀 b(向后)或 f(向前)。例如,对于数字标签 1,引用 1b 指的是定义在引用之前的最近标签 1,而引用 1f 指的是定义在引用之后的最近标签 1。例如:

main:
    1:                ; define new numeric label
    ; do something
    jmp 1f            ; jump to first numeric label "1" defined
    1:                ; redefine existing label
    ; do something
    jmp 1b            ; jump to last numeric label "1" defined

条件跳转是通过指令对来执行的。首先,我们使用 cmp 指令比较两个值。它需要两个操作数来比较,并在状态寄存器中设置适当的标志。接下来,条件跳转指令会检查状态寄存器,以决定是否执行跳转。某些可用的条件跳转类型列出了表 13-4。

表 13-4: x86 条件跳转指令

指令 条件
je 如果 cmp 相等则跳转
jne 如果 cmp 不相等则跳转
jg 有符号 >(大于)
jge 有符号 >=
jl 有符号 <(小于)
jle 有符号 <=
ja 无符号 >(以上)
jae 无符号大于等于
jb 无符号小于(以下)
jbe 无符号小于等于
jc 如果进位则跳转(用于无符号溢出或多精度加法)
jo 如果发生带符号溢出则跳转

为了说明,程序使用cmpje指令在比较的值相等时进行跳转:

cmp 15, 10
je equal              ; jump to "equal" label if equal
; continue if jump condition is false
cmp 10,10
je equal
equal:
    ; they are equal

子程序的调用和返回方式如下:

main:
    call somefunction

somefunction:
    ; some content
    ret

call指令跳转到带有指定标签的子程序,ret则从子程序返回到调用位置。

堆栈

子程序的调用和返回是通过堆栈内部实现的。如果你只是写简单的调用和返回,如我们刚才看到的例子,你不需要自己处理堆栈。然而,x86 也允许你直接访问堆栈来传递参数或用于其他目的。具体来说,寄存器 SS 和 ESP(或 SP)被提供并用于实现堆栈。堆栈仅限于存储字和双字。其工作方式如下:

; save register values
push ax
push bx
; perform whatever you want with these registers
; restore the value
pop bx
pop ax

在这里,寄存器 AX 和 BX 的内容被推入堆栈,这意味着这些寄存器可以被覆盖并用于其他目的,之后再通过 pop 指令恢复。

X86 调用约定

x86 架构在其历史上曾使用过许多不同的调用约定。由于架构寄存器数量较少,并且历史上注重简化和小的代码体积,许多 x86 调用约定将参数通过堆栈传递。返回值(或指向它的指针)通过寄存器返回。一些约定使用寄存器传递前几个参数,这可能提升性能,尤其是对于那些非常频繁调用的短小简单的叶子例程(即不调用其他例程的例程)。

对于 amd64,有两种目前广泛使用的调用约定,一种由 System V UNIX 设计者提出,另一种由微软提出。它们一致认为,堆栈清理应该由调用者而非被调用者负责。它们都要求将前几个参数通过寄存器传递,其余的参数则通过堆栈从右到左传递,尽管它们在使用哪些寄存器以及多少个寄存器上存在分歧。它们在哪些寄存器是临时的(即可以被被调用者在函数调用过程中覆盖)上存在分歧。这与那些被认为是安全的寄存器相对立,后者在函数调用过程中保证不会被更改。

BIOS 输入输出

我们可以从 ROM 调用 BIOS 例程与屏幕和键盘进行通信,就像在复古计算机上一样。例如:

; BIOS Character display
mov ah, 0eh      ; set mode
mov al, 'H'      ; char 'H' to print
int 10h          ; ask BIOS to display letter on screen
; BIOS Character input
mov ah, 00h
int 16h          ; ask BIOS to read a keypress char to AL
; BIOS Graphics   (only works in 16-bit mode)
mov al, 13h      ; desired graphics mode
mov ah, 0        ; set graphics mode
int 10h          ; ask BIOS to set graphics mode
mov al, 1100b    ; desired pixel RGB color
mov cx, 10       ; desired pixel x coordinate
mov dx, 20       ; desired pixel y coordinate
mov ah, 0ch      ; ask BIOS to light the pixel
int 10h

这设置了一个屏幕模式,将一个 ASCII 字符打印到屏幕的某个位置,从键盘读取一个 ASCII 字符,并设置像素颜色。这些都是制作 8 位风格视频游戏所需的基本要素。这里的 int 指令生成中断请求,传递控制权给 BIOS,操作数告诉 BIOS 运行它的哪一个子程序。这些子程序假设在中断发生前,相关的参数已被放入特定的寄存器,如 AH 和 AL。

浮点数

x86 浮点架构源自 8086 的旧协处理器 8087。8087 是一个单独的可选芯片,用于加速数值计算。从 486 开始,FPU 被集成到主 x86 架构中,并且被称为 x87 扩展

x87 扩展增加了专用的浮点寄存器,称为 ST0 到 ST7,它们作为栈使用(因此前缀为 ST);该栈最多可以有八个元素,ST0 是栈顶。新的浮点指令以字母 F 开头,并将数据移动到栈中或从栈中移出;它们指示 FPU 使用栈顶的元素进行算术运算。

你可以将浮点数推送到 x87 栈中,对它们进行算术运算,然后将结果弹出,示例如下:

a: dw 1.456            ; a word (16-bit) float
b: dd 1.456            ; a doubleword (32-bit) float
c: resq 1              ; reserve for output float
;FP add
fld qword [a]          ; load a (pushed on flt pt stack, st0)
fadd qword [b]         ; floating add b (to st0)
fstp qword [c]         ; store result into c (pop flt pt stack)
;FP multiply
fld qword [a]          ; load a (pushed on flt pt stack, st0)
fmul qword [b]         ; floating multiply by b (to st0)
fstp qword [c]         ; store result into c (pop flt pt stack)

在这里,当你将一个浮点数的 ASCII 表示传递给 NASM 时,NASM 会知道将其转换为 IEEE 二进制表示。

分段

x86 程序可以被写成多个 的集合,这些段是程序的独立部分,可以存储在内存的不同位置。例如,如果你希望将指令与数据分开存储(就像哈佛架构一样),你可以使用一个单独的代码段和数据段。还可以使用堆栈段,将硬件堆栈数据与其他数据分开存储。所有段都位于相同的全局地址空间中,但通过将每个段的起始地址存储在专用寄存器中,可以仅通过段的偏移量来引用其中的地址。该系统旨在使 16 位 CPU 能够处理超过 64 KB 的内存。它仍然存在,但在现代 64 位 x86 中使用得不多,因为 64 位地址空间已经非常大。六个 段寄存器,即 CS、SS、DS、ES、FS 和 GS,被指定用来保存段的起始地址。

如果你使用段系统,NASM 指令 section 用于指定代码段和数据段。在某些设置中,一些汇编器仍然会查找段并假设 section .text 是只读的,而 section .data 是读写的,尽管这些概念在 amd64 硬件层面已经不再使用。如果你尝试访问汇编器不希望你访问的段,将会发生 段错误

向后兼容模式

x86 标准的一部分是所有 CPU 必须向后兼容最初的 16 位 8086 处理器。这意味着当它们首次启动时,必须以 16 位模式启动,并且行为必须完全像 8086 处理器。

从那里开始,32 位 x86 处理器有指令可以将其切换到 32 位模式,而 64 位 x86 处理器则有进一步的指令可以将 32 位模式切换到 64 位模式。因此,要启动 amd64 处理器,你需要逐步切换到 32 位,然后是 64 位模式,在几分之一秒内重现其架构的历史。

现在我们已经了解了 x86 架构,让我们放眼整个 PC 计算机设计,看看它如何将 x86 作为 CPU 组件。

PC 计算机设计

台式电脑与我们研究过的其他计算机概念不同:它并不是指定一种特定的计算机设计,而是一个松散的正式与非正式标准的集合。第一台 PC 由 IBM 于 1981 年设计并定义为此,最初的型号为 IBM 5150,如图 11-1 所示;随后,其他厂商也开始使用类似的兼容组件进行复制。

在 1990 年代,任何能够运行 Microsoft DOS 或 Windows 操作系统的 x86 CPU 计算机通常都被视为 PC。微软选择了在其软件中支持哪些计算机设计特性,因此它实际上设定了标准定义。其他操作系统也可以在这些机器上运行,但会做出不同的支持选择。通常,计算机设计特性存在多个竞争标准,决定哪些标准被 PC 社区采纳,成为一个政治性和技术性的问题。

因此,编程和使用 PC 与更标准化的平台感觉不同。例如,为特定机器(如 Commodore 64)创建的游戏可以假定精确的硬件特性,并且可以在任何 Commodore 64 上完美运行。这使得游戏设计师能够像艺术家一样工作,使游戏的外观和感觉完全按照他们的意图呈现。但为 PC 设计的游戏会因为不同 PC 具有不同的特性而表现不同,这就要求游戏设计师实际上要创建一整套类似的游戏,其中有些他们自己永远不会见到,只能猜测如何实现。同样,玩家可能需要更深入地参与配置硬件和软件,以自定义他们想玩的游戏版本。

在这里,我们将具体介绍当前台式 PC 中使用的一些总线、I/O 模块和设备。这些常常成为现代 PC 的瓶颈——如果 CPU 需要花费时间等待系统中的其他部件,那么拥有高度优化的 CPU 也没什么意义。在购买计算机时,不仅要关注 CPU 速度——也要考虑这些支持结构。

总线层次结构

与 CPU 类似,数据总线也在不断改进和替代,因此 PC 架构随着时间的推移使用了各种标准总线层次结构。总线可以在桌面 PC 中的多个层次找到;每一层有不同的用途和带宽,并且为不同的目的进行了优化。表 13-5 展示了一些近期标准的速度和典型用途。

表 13-5:PC 总线速度和用途

标准 带宽(GBps) 用途
千兆以太网 1 网络
USB3 5 外围设备
SATA3 6 辅助存储
NVMe 32 辅助存储
PCI Express 5.0 x16 63 显卡

你可以看到,通过以太网与外界通信是较慢的,地方外围设备和辅助存储位于中间,而显卡经过大量优化,使其能够快速通信。

经典的 PC 架构使用了两个结构,称为北桥和南桥——合称为芯片组——作为总线层次结构的主要骨架。这在图 13-3 中有所展示。

Image

图 13-3:北桥-南桥总线架构

北桥 直接连接到 CPU 的 FSB(前端总线),并通过 PCIe 总线将其连接到 RAM 和快速的 I/O 模块,使用相同的地址空间。它还连接到南桥。北桥快速且强大。传统上,它是构建在与 CPU 分开的芯片上,并且该芯片还承载了一些内存缓存层次。最近,北桥已经在许多系统中迁移到了 CPU 硅片上。

南桥 第二次桥接,从北桥到较慢的 I/O 总线层次。它通常仍然位于自己的专用硅芯片上(即使北桥位于 CPU 芯片上时,它有时也被称为“芯片组”)。南桥包含许多不同的标准 I/O 模块,所有这些模块都印刷在同一个硅片上。在这里,你会看到 USB 控制器、硬盘控制器和较老的 PCI(非 PCIe)总线等结构。

介绍中的图 2 展示了这一设计在 2010 年代 PC 主板上的物理布局。在图中,北桥和南桥都被大型散热片覆盖,显示出它们是主要的功耗和热量源,就像 CPU 一样。与复古计算机相比,主板上剩下的其他芯片很少,因为它们的大多数功能已经迁移到南桥、北桥或 CPU 上。主板的其余部分大多被用于电源管理中使用的物理连接器和模拟组件。

随着北桥现在在许多情况下与 CPU 位于同一个硅片上,它在现代主板上变得更加难以识别。

标准化的输入输出(I/O)

当前桌面 PC 的趋势是朝着标准化 I/O 发展。在过去的坏时光里,每个设备都有自己的 I/O 模块,一个物理组件连接到总线上。这意味着每个设备都有自己的 IRQ(中断请求)线路连接到处理器。你需要一个特定的 I/O 级别驱动程序来管理该模块,而这可能会让配置变得非常麻烦。

USB 等总线层级结构现在在 PC 中大多解决了这个问题。这些总线使用一个单独的 I/O 模块,如 USB 控制器,该控制器只需配置一次并且只使用一个 IRQ。所有设备随后通过一个低级总线与该控制器连接,并且该总线拥有自己的协议,可以包括向控制器传达设备类型的通信。它们可以轻松共享分配给控制器的单个 IRQ。

快速串行总线

在黄金时代,总线意味着一堆并行电缆,通常以带状电缆的形式出现,如图 13-4 左侧所示。

Image

图 13-4:1980 年代的并行总线带状电缆,电缆上有很多导线(左)与 2020 年代的快速串行连接器,电缆上有较少的导线(右)

如今很少见到带状电缆,因为大多数总线都是串行的,只使用一条通信线以及几条控制和电源线,如图 13-4 右侧所示。例如,SATA、SSA-SCSI、USB 和 CAN 都是串行总线。

这一变化是由于并行总线在速度超过约 1Gbps 后遇到的技术问题所引起的。并行电缆上的延迟微小差异可能导致不同的信号线不同步,而重新同步这些数据非常困难。另一方面,串行总线由于不需要同步多条电缆,因此可以越来越快。

向层级结构迁移

随着 I/O 模块变得更快,它们希望向总线层级结构上移,更靠近 CPU。曾经挂在标准化总线(如 USB)上的设备希望直接连接到南桥;曾经挂在南桥上的设备希望升到北桥;曾经挂在北桥上的设备希望升到系统芯片(SoC)硅片上。同时,北桥、南桥和标准化总线都希望提高自己的速度,这意味着希望从南桥迁移到北桥的设备,可能会被一个新的、更快的南桥所超越,这使得其迁移变得不再必要。由于摩尔定律停止了中央 CPU 时钟的进一步加速,所有这些层级的创新推动变得更加重要,这或许使得非 CPU 架构师的工作变得更加光彩照人。

向上迁移到总线层级并进入硅片使计算机设计的经济学和法律结构变得更难理解。在 8 位时代,不同的公司可以制作独立的物理芯片,例如 CPU 和 I/O 模块。计算机制造商购买这些芯片,然后设计和构建 PCB 以将它们集成在一起。如今,由于这些结构需要在同一块硅片上共同制造,CPU 和 I/O 模块公司需要与计算机制造商共享他们的设计,使用类似于 LogiSim 设计的软件文件。制造商随后将设计添加到这些文件中,将它们链接在一起,然后将文件发送给制造公司。每家公司提供的数字逻辑设计单元被称为IP(知识产权)核心,这些设计需要由律师和专利代理人严密保护,而不是像物理芯片一样随意买卖。

常见总线

现在大多数主板上的空间都被连接器而非芯片占据,正如你在图 2 的介绍中看到的那样。该图中看到的连接器是总线层级其他部分的典型代表。接下来,我们将详细检查其中的一些主要连接器。

外设组件互联快速通道总线

PCIe(不要与较旧的 PCI 混淆)代表外设组件互联快速通道(Peripheral Component Interconnect Express),是一种用于连接图形卡和其他卡的通用总线。PCIe 有多种不同的类型,如图 13-5 所示;这些连接器有不同的物理宽度,因为它们具有不同数量的传输通道。

Image

图 13-5:一些 PCIe 总线连接器

根据你想要传输的数据量,你可以获得从 1 到 32 条传输通道的不同 2 的幂次方数量。PCIe 也有不同的代际版本,速度从每条通道 250MBps 到 2GBps 不等。

像许多现代“总线”一样,PCIe 最初是一个实际的总线——多个节点共享相同的一组线路,每个节点都有自己的地址——但它已经发展成了一个网状网络,节点现在执行一些路由操作,以避免总线的拥塞。

SCSI 和 SATA 总线

SCSI 和 SATA 是用于大容量存储设备(例如硬盘)的竞争性总线。小型计算机系统接口(SCSI,发音为“scuzzy”)是一个非常古老、经典、经过充分测试、可靠且昂贵的标准,起源于 1980 年代。它开创了将 I/O 控制的计算工作从 CPU 转移到 I/O 模块中的数字逻辑的做法,从而解放了 CPU,使其能够更快速地处理其他任务。如今它仍在服务器中使用。SCSI 经历了许多版本;最新的更新是串行存储架构(SSA),它是一个串行总线版本。

串行高级技术附件(SATA)比 SCSI 更便宜、更简单。正因为如此,它在大多数消费系统中取代了 SCSI。

统一串行总线

通用串行总线(USB) 是你最熟悉的那种。然而,USB 实际上并不是总线——它甚至不是一个网状网络。它实际上是一个点对点连接器,旨在升级旧式的串行端口。

在 USB 发明之前,每次获得一件新硬件时,你需要花一天时间去使设备驱动程序正常工作并配置 IRQ 线路。而现在,USB 使这一切变得瞬间完成,你可以“即插即用”许多设备。USB 设计使得设备可以在计算机开机的情况下随时连接和断开,并且它的标准部分定义了一种通用的方法,允许设备通过基础的 USB 协议声明其类型和型号,而不需要设备驱动程序。这使得计算机软件能够自动识别已连接的设备,并在许多情况下自动下载并运行相应的驱动程序,而无需人工干预。

USB 还定义了请求和传输电力的标准。一个 USB 电缆有四条线路,其中两条用于传输串行信号,另外两条用于电力供应。电缆内有 5V 电压和接地线,因此你可以使用同一根 USB 电缆为手机充电并与其交换数据。

所有这些操作都是通过一个集中式的 USB 控制器完成的,它是一个单一的 I/O 模块,因此你不再需要担心 IRQ 问题。USB 控制器本身有一个 IRQ,但之后其他一切都通过 USB 网络连接。USB 有多个版本,包括 USB 1,传输速度为 12Mbps,以及 USB 3,传输速度为 5Gbps。

与某些点对点网络不同,USB 连接有一个管理端和一个从属端,管理端负责通信协议。如果你把 USB 存储棒插入计算机,那么计算机就是管理器。作为从属设备的 USB 存储棒不能自行发起请求来复制计算机中的数据。这就是为什么 USB 电缆有不同接口的原因:一端插入管理端,控制它,另一端插入从属端,你不能将它们反过来连接。

即插即用(OTG) 是 USB 协议的一部分,允许从属设备通过物理适配器充当管理器。有时候你确实希望它们连接的方式相反。例如,当你将智能手机连接到计算机时,你通常希望手机作为从属设备,像一个 USB 存储棒,而计算机作为管理器。但在其他情况下,你希望手机作为管理器,例如,当你将一个存储棒或声卡连接到手机时。

以太网

以太网,在其最古老和最简单的形式下,是一个真正的总线,局域网中的多个个人电脑都在公共电缆上进行读写。每条消息都被打包成一个“帧”,包含接收方的地址(媒体访问控制地址,或称 MAC 地址)。发送者必须小心避免碰撞——也就是,避免在同一时间有人同时发话——通过观察总线并等待合适的时机进行传输。每个人都可以看到总线上的所有内容,因此很容易“嗅探”总线并监视其他用户。

现代网络在基本的以太网总线结构之上构建了非总线特性。例如,现在不是将建筑物中的所有计算机连接到一个共享的以太网总线,而是每台计算机通常只通过专用以太网电缆连接到一个中心的交换机。交换机接收所有发送的消息,但与其将它们像总线那样转发给网络上的所有机器,不如仅将它们转发到预定的目的地。

标准设备

如果没有其他一些标准设备,你的台式电脑将不完整。为了完成我们对个人电脑的研究,让我们快速了解这些设备是如何发展的。

平面显示器

现代平面显示器被广泛应用于手机屏幕、电视和大型显示器。它们由晶体管和电容器组成,通过光刻掩膜和气体工艺像芯片一样被布局。许多稀有元素用于生产特定的红、绿、蓝发光像素,包括钇、镧、铽、铈、欧、镝和钆。其中一些元素如此稀有,只有在一两个地方可以开采。许多特定的电子元件和元素组合被用于显示“技术”,其中包括 TFT。写作时,最新的技术是有机 LED(OLED)。

显卡

在 1980 年代,图形非常简单。会分配一块内存区域来表示屏幕上的像素阵列。用户程序像访问其他内存区域一样写入其中。然后,图形芯片从中读取数据,将其转化为 CRT 扫描命令,并发送给显示器。现在情况更为复杂,因为程序员希望图形硬件提供复杂的 2D 和 3D 图形渲染命令,而不占用 CPU 时间。

为了响应这一需求,现代图形处理单元(GPU)由 1980 年代的视觉显示单元(VDU)演变而来。与其接受点亮像素的命令,GPU 通常接受渲染 3D 三角形并为其添加类似精灵的纹理的命令,并使用复杂的光照模型为其着色。

如果你在过去几十年里玩过视频游戏,你一定看到了图形处理单元(GPU)的视觉能力随着摩尔定律的发展而不断演进,质量翻倍,越来越接近光学逼真、实时渲染的效果。

GPU 传统上位于主板的某个总线上,如 PCI、AGP 或 PCIe。多年来,GPU 一直是计算机架构中唯一一个在物理上不断变大的部件,它最初是一个小芯片,现在大多数可能是一个完整的卡片(图 13-6)。

图片

图 13-6:2022 年 Nvidia RTX 3080 GPU

然而,最近也有一个趋势是将 GPU 缩小回主板上的单一芯片,或者与 CPU 集成在同一块硅片上。这在一些机器中尤为常见,特别是那些 GPU 不是主要关注点的机器,如图形需求不超过显示桌面的通用商务 PC。

显卡位于系统总线上,作为 I/O 模块。重要的是,它们可以使用直接内存访问(DMA)。例如,图像可以被放置在常规 RAM 中,然后可以给 GPU 发出一个命令,将它从主内存加载到 GPU 中。这种 DMA 操作不经过 CPU,因此从 CPU 的角度来看几乎是瞬时的。(然而,如果总线需要用于其他任务,如从网络摄像头向主内存传输额外的 DMA 数据时,速度会变慢。)

早期的 GPU 旨在通过直接在硬件中实现 OpenGL 3D 图形 API 的命令来加速渲染,最初是从内存映射区域开始,然后使用一个芯片读取该区域并找出如何将该内存块显示在屏幕上。在 2000 年代,除了或代替内存映射图形外,选配的插件显卡作为 I/O 模块位于系统总线上,根据通过系统总线发送的 OpenGL 或 DirectX 等图形语言编译和组装的命令来绘制图形。显卡被标记并作为实现一个或多个这些语言接口的产品出售。

3D 图形语言通常假设 3D 物体由许多小三角形构成。选择三角形是因为它的三个顶点总是位于一个平面上,从而简化了数学计算。它们的实现,无论是在硬件还是软件中,通常分为两个主要部分,称为着色器。首先,顶点计算将每个顶点的 3D 坐标转换为 2D 像素坐标。其次,像素计算计算每个显示像素的颜色(阴影)。

后者可以通过多种不同的方式实现,具体取决于表面与光源交互的数学模型。大多数着色器允许三角形具有半透明(部分透明)效果,这通常通过它们的 RGBA 颜色中的 alpha 通道来建模,如第 68 页所述。一些着色器允许为每个三角形描述法线(正交)向量,作为它们是平滑连续表面一部分的提示。

图 13-7 展示了三种早期 OpenGL 实现中内置的传统着色器的结果,它们将相同的三角网格近似渲染成一个球体。

图片

图 13-7:传统的 OpenGL 着色器:平面着色(左)、Gouraud 着色(中)、和 Phong 着色(右)

图形用户要求着色器具有更多灵活性。新的着色模型经常在图形研究中提出,用户希望它们能快速应用到自己的系统中。图形语言在后续版本中迅速增加了许多扩展命令,以支持特定的附加着色器,并且图形卡架构师们努力跟上设计新的硬件来实现它们,并确保它们彼此兼容。这些架构师转而开始开放新的、更简单的着色器语言(如 GLSL),以使这些以及其他任意着色器能够在用户程序中实现,并通过它们自己的 ISA 在图形卡——现在称为 GPU——上执行。这使得程序员——特别是游戏设计师和电影工作室——能够创建自定义着色器,赋予他们的创作更具个性化的风格,如图 13-8 中的示例所示。

图片

图 13-8:自定义着色器:来自 0 A.D. 的水面效果(左)、“卡通”着色(中)、以及复古 CRT 模拟(右)

今天的图形系统延续了这一架构趋势,GPU 现在作为自己的指令集的高度通用的并行处理器,并且图形专用的着色器已经转移到软件中。以前的硬件接口,包括 OpenGL 和 DirectX,现在都以软件的形式实现,编写在 GPU 自身的汇编和机器代码中。这些代码现在也可以由其他图形工具直接生成,例如 Wayland 合成器和 Vulkan SPIR-V 语言。生成的 GPU 机器代码通过总线发送到图形卡,在 GPU 上运行。我们将在第十五章中更详细地学习这些代码。

声卡

不像复古的声音芯片,例如 SID,现代的声卡根本不生成信号。相反,它们管理量化的数字声音波形信号的流动。因此,计算机失去了它们特有的音效和音乐文化:现代游戏音乐可以仅仅是普通的交响乐团或摇滚乐队的录音,而不再是任何特定的“计算机音乐”。像图形卡一样,声卡现在总是由操作系统控制,所以用户程序员不太可能看到它们的架构。

现代声卡实际上只是由一组数模转换器(DAC)组成,实际上你可以使用任何 DAC 来制作自己的声卡,比如在 Labjack、软件定义无线电或 Arduino Due 上找到的那种。通常,专业声卡针对低延迟、音质和多个通道进行了优化,而消费级声卡则注重降低成本。人耳的最大听力频率约为 20 kHz,因此需要 40 kHz 的采样率才能准确表示。常见的做法是使用 48 kHz,以留出一些余地,并且因为它几乎是 2 的幂次。专业系统可能会使用更高的采样率,以减少重复处理带来的可听错误的积累。

声卡硬件通常由每个通道的环形缓冲区和 DAC 硬件组成,DAC 硬件用来读取或写入缓冲区。环形缓冲区维护一个指向下一个写入位置的指针,并将存储空间环绕起来,以免空间耗尽。缓冲区的大小提供了延迟和丢失之间的权衡。较小的缓冲区意味着较低的延迟,但也有丢失的风险。我们还可以选择音频的位深度。

声卡像显卡一样连接到系统总线。它们对带宽的需求比视频小,因此通常位于南桥挂接的总线上,例如 PCI 用于内部卡,或 USB 或 Firewire 用于外部卡。

声卡的输入输出协议因制造商而异,像 GPU 一样,它们的细节可能是专有的,只为公司内部的驱动程序编写者所知,然后他们提供软件 API。与 GPU 一样,硬件或软件接口随后被开源驱动程序编写者反向工程化,并通过通用软件 API(如ALSA)进行封装。

键盘和鼠标

现代键盘与 1980 年代的内存映射键盘完全不同。它们现在包含小型的嵌入式计算机(见图 13-9)。

图片

图 13-9:现代键盘内部的按键压力传感器和嵌入式系统

键盘的嵌入式计算机实际上做了很多工作,类似于典型的 Arduino 应用。它读取按键矩阵,将其转换为按键码数据表示方案,并通过 USB 协议包装,传输到虚拟串口。

鼠标也发生了类似的变化。现代光学鼠标在一个专用的内嵌系统中执行一些极其复杂的实时机器视觉处理,称为光流。如果你尝试在软件中实现光流,会发现很难做到快速。它仍然是一个研究领域,最近的实现出现在像 OpenCV 这样的软件库中。然而,在鼠标中,它是通过低级数字电子元件直接实现的,如图 13-10 所示。

图片

图 13-10:光学鼠标内部结构

这种数字逻辑足够简单,你仍然能够看到连接。你可以从整体、相对均匀的结构中看出,它正在处理一个二维空间的区域——鼠标下方的图像。它跟踪图像中的明暗区域如何移动,并据此推断鼠标的运动。

通常设备上还会附加一个 USB 控制器。实际上,这是一个复杂的嵌入式系统——可能是一个独立的计算机——而且它现在只需要几美元就能集成到每个鼠标中,着实令人印象深刻。

PC 启动过程

启动(booting)这一术语源自自相矛盾的表达“拉自己一把(pulling yourself up by your bootstraps)”。它意味着从无到有,通过执行小程序依次加载稍大一些、功能更强大的程序,从而进入一个复杂的计算机系统。在复古系统和现代 PC 上,这一过程从 CPU 从硬件 ROM 地址获取指令开始。

与复古计算机不同,现代 PC 并非由标准组件构成,而是由许多不同的可选组件组装而成,例如各种类型的 RAM 模块、缓存和 I/O 扩展卡。最初并不明显这些组件的位置、它们应该如何初始化或如何在地址空间中安装。为了解决这个问题,现代 PC 的启动过程分为两个部分。

首先,启动加载程序coreboot 等)被烧录到 ROM 固件中,位于 CPU 初始程序计数器的地址。对于 x86,这个地址是 ffff,fff0[16]。这是一个 16 位地址,因为 x86 处理器总是在“传统模式”(英特尔称之为“实模式”)下启动,这使得它们像 1980 年代的 16 位芯片一样工作,以实现向后兼容。在这种模式下,只有 1 MB[2] 的 ROM 和 RAM 内存是可寻址的,而初始程序计数器地址接近内存的顶部。启动加载程序从这里运行,负责检查、初始化并分配可用硬件的地址。启动加载程序不会在屏幕上显示任何内容,因为此时还没有任何可用于输入输出的例程。由于它不可见,所以很难理解启动加载程序正在执行的所有复杂工作。

第二,在完成初始化后,启动程序会跳转到 BIOS 中的代码。BIOS,就像复古计算机一样,包含了基本输入输出的子例程,如 ASCII 字符显示、键盘读取和硬盘访问。在这个阶段,你的 PC 看起来和感觉上都很像一台复古计算机。

通常,从启动加载程序跳转到 BIOS 代码时,BIOS 会在屏幕上打印一些字符串,如 BIOS 的名称和标志。这里展示了一个 PC BIOS ROM 和一个示例的 BIOS 显示 I/O 能力。

Image

BIOS 通常会首先让用户有机会通过按某个键进入“BIOS”,这时会调用图形例程来设置配置选项。通常,其中的一个选项是指定一个存储设备的名称,该设备的第一个数据包含接下来需要加载并跳转的程序,通常是在地址 7c00[16]。这个程序做什么由你决定——一个常见的第一步是将 x86 切换到 32 位模式,然后是 64 位模式。

曾经,不同的 x86 BIOS 制造商各自开发了不同且不兼容的例程库,但现在它们已趋于两种标准。其一,PCBIOS,是 IBM(他们称之为“BIOS”)在早期的 x86 PC 中定义的。其他制造商进行了克隆,至今许多 x86 机器仍在使用。SeaBIOS 是一个开源实现。另一种标准,UEFI,则是更现代的标准。它假设提供了更先进的图形和输入输出功能,因此它的例程库包括了更高分辨率和更多彩的图形,并且能访问 USB 等额外设备。TianoCore 是一个开源实现。

摘要

如果能够从零开始设计,没人会将现代桌面 PC 设计成现在的样子。就像许多成功的商业实际系统一样,PC 随着时间推移不断发展,新的功能被要求并附加上去,而现有用户又要求向后兼容。因此,x86 架构和 PC 计算机设计积累了许多遗留功能。CISC(复杂指令集计算机)理念非常适合这种环境。在单一设计中,通常会支持多个竞争标准,甚至包括多个 x86 汇编器的选择,其中之一就是但不限于 NASM。最近的 x86 进一步扩展了本章所介绍的功能,增加了并行化,我们将在第十五章中进行探讨。但在此之前,我们将稍作休息,下一章将探讨更加简洁、美丽的 RISC 世界。

练习

创建可引导的 ISO 镜像

在这里,你将创建一个简单的 16 位“Hello, world!”程序,使用 NASM 将其汇编成可执行的机器代码,然后将这些机器代码存储到 ISO 文件中,这是物理二级存储设备内容的镜像,可以用来启动真实的 PC 或虚拟机。

  1. 创建以下的hello16bit.asm文件:

    bits 16                ; tell NASM we're only using 16-bit x86
    org 0x7c00             ; base address for bootloader to place this code
    section .data          ; this segment is read-write data
    message db 'Hello, World!', 13, 10, 0
    section .text          ; this segment is read-only code
    entry:
      jmp start
    printer:               ; subroutine for printing ASCII strings
      lodsb                ; load SI into AL and increment SI [next char]
      or al, al            ; check if the end of the string
      jz printer_end;
      int 0x10             ; otherwise, call interrupt to print char
      jmp printer          ; loop
    printer_end:
      ret                  ; return flow
    start:
      mov si, message      ; say what we want to print
      mov ah, 0x0e
      call printer         ; print it
                           ; ** add your own code here ... **
      hlt
    times 510-($-$$) db 0  ; zero out rest of 512-byte boot sector
    dw 0xaa55              ; code to mark sector as bootable
    
  2. 运行以下命令:

    mkdir -p cd/boot
    nasm hello16bit.asm -o cd/boot/loader.sys
    mkisofs -R -J -c boot/bootcat -b boot/loader.sys -no-emul-boot -o cd.iso cd
    

注意

如果你使用的是 Microsoft Windows,可以通过安装并使用 Windows Subsystem for Linux 来运行这些命令。如果你还没有安装 NASM,可以从 nasm.us 下载安装。你可能还需要为系统安装 mkisofs。

  1. 如果一切顺利,你现在应该拥有一个cd.iso文件,可以用来启动物理或虚拟的 x86 机器。这将允许你在没有操作系统干扰的情况下在“裸机”上运行 x86。

我们将在接下来的练习中讨论如何从 ISO 文件启动。当你启动时,应该会在屏幕上看到类似图 13-11 的内容。

Image

图 13-11:启动裸机测试程序的结果

在继续之前,让我们来看一下hello16bit.asm究竟做了什么。除了实际的 x86 指令助记符外,NASM 程序通常还包括一些指令,这些指令不是由 NASM 本身汇编的,而是告诉 NASM 以各种方式更改其行为。section指令告诉 NASM 改变输出文件的哪个段来写入接下来的汇编指令。在某些文件格式中,段的数量和名称是固定的;而在其他格式中,用户可以根据需要自定义段的数量。Unix 对象文件和 bin 格式都支持标准化的段名称.text(包含可执行指令)、.data(包含初始化变量)和.bss(包含未初始化变量)。ASCII 字符串在可读字符后包含特殊的 ASCII 代码 13、10 和 0。它们是什么?(提示:参见第二章。)

在虚拟 x86 上启动

这个 ISO 文件可以像物理磁盘一样在虚拟机上启动。按照以下步骤,尝试在 VirtualBox 虚拟机上运行它。(开源 Linux 用户可能更喜欢使用* virt-manager.org *来代替。)

  1. 访问* www.virtualbox.org *以获取如何在你的系统上安装 VirtualBox 的说明。

  2. 安装完成后,点击新建图标创建一个新虚拟机;使用默认设置。

  3. 启动你的虚拟机,并在系统提示时通过选择你的cd.iso文件来“插入”可启动的虚拟光盘。

在物理 x86 上启动

如果你首先将 ISO 文件“烧录”到一个物理 USB 闪存盘上,它也可以在物理 x86 机器上启动。操作步骤如下:

  1. 使用诸如 Etcher(* www.balena.io *)等程序,将 ISO 文件烧录到 USB 闪存盘上,适用于你当前的操作系统。

  2. 一旦你有了可启动的 USB 闪存盘,你需要告诉你的 PC 从它启动。你的 PC 目前可能配置为从硬盘启动,但它会有某种方法——不同厂商可能会有所不同——通过其 BIOS 配置工具切换到从 USB 启动。编辑这些设置的过程称为“进入 BIOS”。在大多数机器上,你可以在开机时按住某个特定的键几秒钟来进入 BIOS。这通常是 ESC、DEL、F1、F2、F8、F10 或 F11,具体取决于制造商(如果没有明确说明是哪一个,试着把手指在键盘的顶行上滑动,逐一按下)。你通常会看到一些低分辨率的 BIOS 菜单:如果你细心查找,会找到某种方法来指定启动顺序,并将 USB 置于顶部。有些机器可能会有额外的安全功能,需要在从新设备启动之前先禁用它们。

以 64 位模式启动并进行编程

将现代 x86 切换到 32 位和 64 位模式并不简单。由于历史遗留问题,这需要几屏的指令和数据。它是如何工作的相当晦涩,但幸运的是,现在可以使用下面展示的模板代码来完成这一过程:

org 0x7c00          ; base address where this code will be placed (by bootloader)
entry:
    jmp real_to_protected
GDT32:              ; Global Descriptor Table for 32-bit mode
    .Null: equ $ - GDT32
    dq 0            ; defines 32 bits of zeros for the null entry
    .Code: equ $ - GDT32
    dw 0xFFFF       ; segment limit
    dw 0            ; base address
    db 0            ; base address (again)
    db 0b10011010   ; binary flags describing mode
    db 0b11001111   ; binary flags describing mode
    db 0            ; last remaining 8 bits on the base address
    .Data: equ $ - GDT32
    dw 0xFFF        ; --|
    dw 0            ;   | - identical to code segment
    db 0            ; --|
    db 0b10010010
    db 0b11001111
    db 0
    .Pointer:
    dw $ - GDT32 - 1
    dd GDT32
GDT64:                 ; Global Descriptor Table for 64-bit mode
    .Null: equ $ - GDT64
    dw 0xFFFF
    dw 0
    db 0
    db 0
    db 1
    db 0
    .Code: equ $ - GDT64
    dw 0
    dw 0
    db 0
    db 10011010b       ; binary flags describing mode
    db 10101111b       ; binary flags describing mode
    db 0
    .Data: equ $ - GDT64
    dw 0
    dw 0
    db 0
    db 10010010b       ; binary flags describing mode
    db 00000000b       ; binary flags describing mode
    db 0
    .Pointer:
    dw $ - GDT64 - 1
    dq GDT64
bits 16                ; tells NASM the following is 16-bit x86 code
real_to_protected:     ; switch from 16 bits to 32 bits
    mov ax, 0x2401
    int 0x15           ; enable a20 gate
    mov ax, 0x3
    int 0x10           ; change video mode
    cli
    lgdt [GDT32.Pointer]
    mov eax, cr0
    or eax, 1
    mov cr0, eax
    jmp GDT32.Code:protected_to_long    ; perform long jump
bits 32                 ; tells NASM the following is 32-bit x86 code
protected_to_long:      ; switch from 32 bits to 64 bits
    mov ax, GDT32.Data
    mov ds, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    ; root table - page-map level-4 table (PM4T)
    mov edi, 0x1000     ; starting address of 0x1000
    mov cr3, edi        ; base address of page entry into control register 3
    xor eax, eax        ; set EAX to 0
    mov ecx, 4096
    rep stosd
    mov edi, cr3        ; restore original starting address
    mov dword [edi], 0x2003
    add edi, 0x1000
    mov dword [edi], 0x3003
    add edi, 0x1000
    mov dword [edi], 0x4003
    add edi, 0x1000
    mov ebx, 0x00000003 ; used to identity map the first 2MiB
    mov ecx, 512
    .set_entry:
        mov dword [edi], ebx
        add ebx, 0x1000
        add edi, 8
        loop .set_entry
    mov eax, cr4
    or eax, 1 << 5
    mov cr4, eax
    mov ecx, 0xC0000080 ; magic value actually refers to the EFER MSR
    rdmsr               ; read model-specific register
    or eax, 1 << 8      ; set long-mode bit (bit 8)
    wrmsr               ; write back to model-specific register
    mov eax, cr0
    or eax, 1 << 31 | 1 << 0   ; set PG bit (31st) & PM bit (0th)
    mov cr0, eax
    lgdt [GDT64.Pointer]
    jmp GDT64.Code:real_long_mode
bits 64                 ; tells NASM the following is 64-bit x86 code
printer:                ; subroutine for printing ASCII strings
    printer_loop:
        lodsb
        or al, al
        jz printer_exit
        or rax, 0x0F00
        mov qword [rbx], rax
        add rbx, 2
        jmp printer_loop
    printer_exit:
        ret
real_long_mode:
    cli
    mov ax, GDT64.Data
    mov ds, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    xor rax, rax         ; clears register rax
    mov rsi, boot_msg    ; say what we want to print
    mov rbx, 0xb8000
    call printer         ; print it
    mov rsi, l_mode      ; say what we want to print
    mov rbx, 0xb80A0
    call printer         ; print it
                         ; ** add your own code here ... **
    hlt
boot_msg db "Hello, world!",0
l_mode db "This is 64-bit (long mode) !",0
times 510 - ($-$$) db 0  ; zero out rest of 512-byte boot sector
dw 0xaa55                ; code to mark sector as bootable

如果你保存这个文件、汇编它,并像 16 位版本一样放入 ISO,它将启动你的真实或虚拟 x86 到 64 位模式,并打印另一个“Hello, world!”消息。然后,你可以使用这个“Hello, world!”程序作为起点,将其修改成自己的可启动程序,用于以下任务:

  1. 编写一个子程序,读取整数并将其转换为 ASCII 字符串。扩展到浮点数。用它打印一些数字和“Hello, world!”消息。

  2. 尝试将之前的程序从分析机和曼彻斯特宝宝移植到 x86 上运行。与这些系统相比,现代 x86 在做某些事情时变得更容易或更难了?

  3. 调用 BIOS 例程多次点亮屏幕上的像素,绘制一个简单的图形。

更具挑战性

使用上述 BIOS 调用,在裸机 x86 上编写一个简单的游戏,比如太空入侵者

进一步阅读

第十七章:## 智能架构

图片

智能计算意味着内置于低功耗和/或移动设备中的通用计算机,例如手机、平板电脑、电视、路由器和冰箱。与嵌入式系统不同,它们运行多个易于安装和升级的程序,通常被称为应用程序。与桌面系统不同,它们需要减少功耗,因为它们通常依赖电池供电。精简指令集计算(RISC)非常适合这些要求,因此 RISC 架构通常出现在智能系统中。本章探讨了 RISC 理念、智能设备以及特定 RISC 架构 RISC-V 的细节。由于 RISC 在设计上比 CISC 更简单,本章比桌面系统的章节要短。

智能设备

早期的移动电话是嵌入式系统,其主要目的是作为语音电话使用。它们配备了微控制器,用于管理电话功能和通过按钮与简单数字显示屏进行用户交互。随着时间的推移,这些微处理器和交互设备不断发展,固件也扩展到了包括联系人簿、闹钟和简单游戏,如

现代智能手机现在提供这样的功能,以及更多功能,作为软件应用而非固件。它们已将微控制器替换为完整的通用架构,通常运行操作系统,如 LineageOS、Replicant 或 Android 来托管应用程序,类似于桌面 PC。

智能这个前缀最初用于描述这些手机,但现在它被应用于任何曾经是嵌入式系统的设备,但已升级为通用计算机。例如,智能电视(图 14-1 左侧)和智能冰箱已经超越了微控制器和固件,能够轻松安装和运行多个应用程序。

右侧显示的现代消费级互联网连接设备(图 14-1 右侧),通常但错误地被称为“路由器”,是另一个智能计算机的例子。这些设备现在通常包含运行多个服务的操作系统,包括路由、Wi-Fi、防火墙和 Web 服务器(至少用于运行其配置页面)。因此,出于这个原因,它们应该被重新命名为“智能路由器”。

图片

图 14-1:东芝智能电视内部(左)和 Zyxel 路由器(右)

“智能家居”是计算机行业多年来的一个目标,指的是将大部分或所有常见家用电器升级为网络化的通用计算机。例如,智能洗衣机和智能中央暖气控制器将使基于机器学习的创新应用竞争,通过传感器监控待洗衣物的状况、房间的温度和使用情况,从而最佳利用能源。将这些系统连接起来将实现自动化链条,比如智能冰箱预测今天晚些时候牛奶会用完,并自动向本地超市下单,可能通过最后一公里的配送机器人和智能投递箱接收货物。或者,在洗衣机开启时,您的加热系统和冰箱可以暂时关闭,从而完全依靠停放的电动汽车电池供电,而电池是通过您的太阳能电池板充电的,无需使用电网电力。

智能设备的架构在可靠性和能效方面与嵌入式系统有相似的要求。然而,它们比嵌入式微控制器需要更多的计算能力。这些需求与 RISC 哲学完美契合,因此让我们更详细地探讨这一哲学。

RISC 哲学

RISC 概念是由美国人 David Patterson 发明的,但最成功地被英国人商业化。Patterson 对架构的定量方法涉及对 1990 年代处理器上实际使用的指令进行统计分析。他发现,复杂的指令使用非常少,部分原因是编译器后端设计人员不知道或者不愿意学习如何使用这些指令。他确定,大约 90%的工作是由大约 10%的资源完成的。这使他得出 RISC 的核心原则,即占据了很少使用的指令的硅芯片,最好用来让最常用的 10%的指令运行得非常快速,而其他指令则完全移除。Patterson 和他的联合架构师 John Hennessy 因他们利用定量方法指导 RISC 架构设计的研究,获得了 2017 年图灵奖。

RISC 通常旨在让每一条精简指令都能在一个 CPU 周期内执行。由于可用的指令较少,RISC 汇编程序通常会非常冗长,但每条指令都简单、快速且低功耗。用 RISC 汇编编写程序和为 RISC 编写编译器既简单又有趣,因为指令集架构(ISA)小巧、简单且易于理解。

然而,RISC 处理器本身不一定简单。虽然指令集的定义较小,但设计师们找到了其他方法来高效利用可用的硅片。例如,RISC 处理器通常比 CISC 处理器拥有更多的寄存器。额外的寄存器在 RISC 中尤其有用,因为它们有助于将内存访问与算术操作分离。在 RISC 编程和 RISC 编译器中,通常会尝试在子程序开始时将所有相关变量加载到寄存器中,然后在寄存器中完成整个函数的计算,仅将结果存回主内存。这与 CISC 形成对比,CISC 中可能会在整个子程序中持续进行加载和存储操作。

因为 RISC 哲学的一部分是每条指令都应在恰好一个时钟周期内执行,借助流水线、分支预测和乱序执行(OOOE),指令级并行性更容易管理。每条指令的取指-解码-执行步骤持续时间相同,每个阶段都可以在开放环路方式下定期触发。与 CISC 架构相比,CISC 架构中的不同指令步骤可能需要不同的时长,并且必须通过闭环触发相互作用,以便告知何时完成。

RISC 传统上被视为一种非常学术化的哲学,其设计精美、执行高效,并且通常抵制通过新增功能来取悦特定客户、快速赚钱的诱惑。RISC 常与英国及其公司 ARM 相关联,尽管它起源于加利福尼亚大学伯克利分校——该校靠近硅谷但又与之隔开。典型的 RISC 倡导者更关注设计的美观和巧妙,而非实用主义,而长期以来,这些人常常被更具商业头脑的硅谷架构师嘲笑。然而,这种情况现在正在发生变化:RISC 的美学正在得到回报。如今,大多数制造的处理器都是 RISC 架构。这主要得益于智能和嵌入式设备取代桌面电脑的趋势,虽然现在对于云服务器的 RISC 考虑也在逐渐增多。2020 年,苹果公司还将其桌面计算机迁移到了基于 RISC 的 M1 架构。

从 ACORN 到 ARM

英国公司 Acorn 在其 BBC Micro(“Beeb”)中使用了 6502 芯片,这是一款经典的英国工程作品,设计精妙,但商业时机不对,市场定位失误。像许多英国技术一样,Micro 是政府资助的,这里是通过国家广播公司 BBC 的资助,他们希望为一档教育电视系列节目提供一款定制设计的大众市场计算机。

如今的黑客们经常尝试购买 6502 处理器,并围绕它在面包板上搭建 8 位计算机,这正是 Beeb 设计师们所做的。Acorn 由一群剑桥大学的人创办,他们利用这一背景说服了 BBC 选择他们的设计。BBC 给出了一个巨大的规范清单,列出了他们为电视系列所需的计算机功能。这个清单受到了强烈的教育和科学影响——而非游戏影响。例如,他们没有包括操控杆端口,但却提供了协处理器和与创客风格电子设备连接的选项。

在 Beeb 发布一年后,Commodore 64 问世,设计目标是“面向大众,而非精英阶层”。它在游戏图像和声音方面表现出色,且价格远低于竞争对手。C64 填满了 64 k[2]B 的可寻址内存,而 Beeb 只有 32 k[2]B。C64 配备了出色的 SID 音频芯片,而 Beeb 则只能发出简单的方波、白噪声和来自较差的 SN76489 的振幅包络。到那时,Commodore 已收购了 MOS,因此它能够直接将 6502 及相关芯片的设计师——包括 Chuck Peddle——融入其计算机设计中,充分发挥这些芯片的先进特性。

C64 迅速让 Beeb 显得配置过高且定价过高。然而,Acorn 在内部使用 Beeb 设计了他们的第一款 RISC 处理器——Acorn RISC Machine (ARM),用于下一代计算机 Archimedes,这是一台完全 32 位的机器,1987 年发布。Archimedes 在技术上领先其时代十年,尽管从时间和文化角度来看,它仍属于“16/32 位时代”。它再次被遗憾地错营销,配置过高且定价过高——例如,尽管它有八声道音频,而 Amiga 只有四声道,但没有游戏所需的操控杆端口或电视输出。

Acorn 随后成立了一家公司,ARM,专注于其 ARM 芯片的设计。ARM 取得了成功,因为它的芯片现在为全球大多数智能手机提供动力,包括 Apple 平板和桌面电脑中使用的 M1 芯片,以及许多智能设备,如图 14-1 中看到的芯片。

如果 BBC 多等一年,把 C64 引入每所英国学校,计算机历史可能会大不相同。Commodore 的企业管理面临的挑战最终导致了 1994 年破产,但若得到英国官方的支持,或许能够提供必要的稳定性,帮助它度过难关。

一项奇特的遗产是,Acorn 为 Archimedes 开发的操作系统 RISCOS,至今仍与最新的 ARM 指令集架构(ISA)兼容。它是用 ARM 汇编语言手工编码的,以从 1990 年代初的 CPU 中榨取最大性能,因此它现在在现代设备(如树莓派)上运行极为快速。围绕这一点,旧的 Acorn 用户组出现了复兴,有时甚至促成了 30 年未见的老朋友重逢。

RISC-V

ARM 的 RISC ISA 设计有大量专利,但业内其他公司现在希望有完全开源的 RISC ISA 替代方案。像 IBM-Intel-AMD 许可协议一样,计算机制造商希望将 ISA 泛化,并通过多种实现推动竞争,降低处理器价格。因此,一大批硅谷主要公司现在支持 Patterson 最新的、完全开源的 RISC ISA 系列设计,称为 RISC-V(V 代表 "五" 并发音为“five”),作为 RISC 的下一个标准。请注意,RISC-V 是一系列 ISA,而不是这些 ISA 的硬件实现。公司可以在专有硬件中实现开源 ISA,并通过实现质量进行竞争。Patterson 的团队和其他 RISC-V 运动成员也提供了完全开源的实现。

理解架构

RISC-V 被设计为一系列指令集架构(ISA),而不是单一的 ISA。这个系列包括适用于嵌入式、移动、桌面和服务器机器的多个版本,其中包括适用于 32 位、64 位和 128 位 ISA 的版本。RISC-V 定义了一种核心 ISA,其中的指令是所有 RISC-V 系统都需要实现的。像 x86 一样,RISC-V 使用逐字节的小端寻址方式。RISC-V 使用 RISC 风格的指令,将内存访问与算术逻辑单元(ALU)操作分离。它不是累加器架构,ALU 指令明确指定输出寄存器,因此通常具有三个参数。

正如我们所讨论的,RISC 哲学的核心是尽可能减少指令的数量。这意味着,如果某些操作可以通过其他方式实现(例如通过以稍微不同的方式调用其他指令),你通常会发现 ISA 中缺少某些操作。当需要这种不寻常的指令使用时,RISC-V 汇编器有时会提供看似经典的伪指令。然后,汇编器将它们转换为底层的、有些笨拙的 RISC 指令。到一定程度,某些 CISC 数字逻辑的复杂性被转移到汇编器中,同时保持机器码本身的简洁。

RISC-V 寄存器有标准名称,并且对于其典型用途有标准约定,例如如何通过将参数存储在寄存器中来传递参数给子例程。例如,整型寄存器始终被称为 x0 到 x32(其中 x0 始终包含常数 0)。RISC 设计者为整型寄存器赋予了次要的助记符别名,如表 14-1 所示,以鼓励它们的使用约定。

表 14-1: RISC-V 整型寄存器

名称 助记符 预期约定
x0 zero 值始终为零
x1 ra 子例程调用的返回地址
x2 sp 栈指针
x3 gp 全局指针
x4 tp 线程指针
x5–x7 t0–t2 临时寄存器
x8–x9 s0–s1 保存寄存器
x10–x14 a0–a7 子例程调用的参数
x18–x27 s2–s11 保存寄存器
x28–x31 t3–t6 临时

与指令和伪指令一样,这样的设计旨在保持底层架构非常简洁,同时也提供了思考和编程的能力,允许采用类似 CISC 的风格,但仅限于程序员希望这样做的情况下。

可能还会有另外 32 个可选的浮点寄存器,同样也有助记符,你可以在表 14-2 中查看。

ts开头的寄存器的调用约定是临时的和安全的——这一概念在第十三章中的 x86 调用约定中也有所体现,但没有那么标准化。

表 14-2: RISC-V 浮点寄存器

名称 助记符 预定约定
f0–7 ft0–7 浮点暂存器
f8–9 fs0–1 浮点保存寄存器
f10–11 fa0–1 浮点参数/返回值
f12–17 fa2–7 浮点参数
f18–27 fs2–11 浮点保存寄存器
f28–31 ft8–11 浮点暂存器

和 x86 一样,用户无法直接访问内部寄存器。这些内部寄存器包括程序计数器和状态寄存器。

编程核心 RISC-V

现在我们已经看到基本结构,让我们在一些指令中使用它们来编写 RISC-V 程序。与我们学习的其他架构一样,我们将首先介绍数据移动和控制流。然后,RISC-V 有各种可选扩展,包括算术运算,我们也会进行探讨。

数据移动

作为一个 RISC 系统,涉及主存的数据移动与所有其他操作的清晰分离,其他操作仅在寄存器中的数据上执行。从内存加载到寄存器,以及从寄存器存储到内存,是通过以下指令完成的:

lw x5, x6, 0            ; load word to x5, from content at address x6+0
sw x5, x6, 0            ; store value from reg x5 to address x6+0
la x6, mylabel          ; load address of mylabel (not its content) to x6

请注意,像 NASM 和 Arduino 汇编一样,RISC-V 汇编首先写目标寄存器,然后是输入寄存器。你可以在lwsw指令中看到 RISC 特点,它要求所有三个操作数,即使第三个操作数是 0,因此没有被使用,而不是提供这两条指令的另一种形式,只有两个操作数。

为了方便,提供了一个伪指令来从带标签的地址加载内容:

lw x5, mylabel          ; load content at address mylabel to register x5

这实际上会被汇编成两条指令。首先,la获取标签的地址,然后lw加载该地址的内容。

重要的是,与 CISC 不同,这些指令不会被重用来在寄存器之间复制数据。内存访问指令和寄存器间操作的清晰分离通常被认为是 RISC 的一个定义特征。我们将在第 347 页的“算术”部分讨论这如何实现。

控制流

无条件跳转有两种形式:

j mylabel               ; jump to address mylabel
jr x5                   ; jump to address in x5

条件跳转包括:

beq x1, x2, mylabel     ; branch if x1==x2
bne x1, x2, mylabel     ; branch if not x1==x2
blt x1, x2, mylabel     ; branch if x1<x2
bge x1, x2, mylabel     ; branch if x1>=x2

子程序通过“跳转和链接”来调用。这里的“链接”指的是将程序计数器保存在一个寄存器中。例如:

jal x1, mylabel         ; store current PC in x1 and jump to mylabel
jalr x1, x2, 0          ; store current PC in x1 and jump to address x2+0

这就是为什么 x1 被昵称为 ra,代表返回地址(return address)。

因为可以通过重用跳转指令实现返回,所以没有返回指令,RISC 风格的返回是跳转到之前保存在 x1 中的地址:

jalr x0, x1, 0

也就是说,ret 伪指令可能会被提供并汇编成相应的 jalr 指令。

使用 ret 允许你从单个子程序调用并返回,返回地址保存在 x1 中。然而,要调用嵌套函数,你还需要一个堆栈。约定是将寄存器 x2(sp)作为堆栈指针。这里我们将一个 4 字节的字推入堆栈:

addi sp, sp, -4 ; grow stack
sw   a0, sp, 0 ; store a0 onto stack

addi 指令表示“加立即数”,在本例中是将常量(–4)加到堆栈指针。“立即数”意味着操作数本身包含值,而不是包含值的地址或寄存器。同样,这里我们从堆栈弹出:

lw   a0, sp, 0 ; retrieve data to a0 from stack
addi sp, sp, 4 ; shrink stack

请注意,RISC 风格下通过重用现有指令来完成此操作。与 CISC 风格不同,RISC 风格没有像 pushpop 这样的额外堆栈指令。相反,你必须自己管理堆栈,并使用简化的指令集。

扩展 RISC-V

RISC-V 还定义了许多插件或附加指令库,可以选择性地实现。每个扩展都被赋予一个单字母代码,包括:

I     基本整数加/减/移位/位运算逻辑

M     整数乘法和除法

B     位运算布尔值

F     单倍精度浮点数

D     双倍精度浮点数

Q     四倍精度浮点数

为了指定特定的 ISA,我们写“RV”表示 RISC-V,然后是字长,再然后是使用的扩展。例如,RV64IMF 表示“RISC-V,64 位,带有 I、M 和 F 扩展。”这个设计旨在使 RISC-V 能够涵盖从嵌入式系统(例如 RV8I)到高端科学计算集群的所有应用。可能会提出新的标准扩展,作为新的字母或以 Z 开头的任意字符串,而本地实验性扩展可能会作为以 X 开头的任意字符串提出。

算术运算

整数运算使用三个操作数。例如:

add x6, x7, x8  ; x6 := x7 + x8
sub x6, x7, x8  ; x6 := x7 - x8
mul x6, x7, x8  ; x6 := x7 * x8
div x6, x7, x8  ; x6 := integer of x7 / x8
rem x6, x7, x8  ; x6 := remainder of x7 / x8

位运算布尔操作类似:

and x6, x7, x8  ; x6 := x7 bitwise-and x8
or x6, x7, x8   ; x6 := x7 bitwise-or x8
xor x6, x7, x8  ; x6 := x7 bitwise-xor x8
not x6, x7      ; x6 := bitwise-not x7

类似除以零和溢出等特殊情况会报告在状态寄存器中,后续指令可以查询该寄存器。

在 RISC 架构中,寄存器到寄存器的操作属于算术运算,而不是数据传输,以减少指令和变体的数量。因此,没有额外的指令用于将常量放入寄存器或在寄存器之间复制数据。相反,我们将这些视为加法操作,始终为零的 x0 寄存器作为其中一个操作数。例如:

addi x1, x0, 3   ; load immediate integer 3 to x1
add x2, x1, x0   ; copy x1 to x2

主流 RISC-V 项目开发的 RISC-V 汇编器包括这些操作的替代伪指令,以方便程序员使用:

li x2, 3         ; load integer 3 into x2
mv x2, x1        ; copy x1 to x2

这些在后台被汇编成相应的加法指令。

浮点数

浮点指令以 f 开头,作用于浮点寄存器 f0 到 f31,例如:

fadd f6, f7, f8  ; f6 := f7 + f8
fsub f6, f7, f8  ; f6 := f7 - f8
fmul f6, f7, f8  ; f6 := f7 * f8
fdiv f6, f7, f8  ; f6 := f7 / f8
fsqrt f6, f7     ; f6 := sqrt(f7)

还有一些指令用于加载、存储、比较浮点数,并将它们转换为整数,或从整数转换回来:

flw f1, t0, 0    ; load float word to f1 from address t0+0
fsw t0, f1, 0    ; store float word to address t0+0 from f1
flt.s x6, f1, f2 ; x6 := (f1 < f2)
fcvt.w.s x6, f1  ; convert float f1 to int x6
fcvt.s.w f1, x7  ; convert int x7 to float f1

这里的 .s.w 分别代表单精度和字长精度。还有 d 代表双精度。这些后缀类似于,甚至可能借鉴自在第十一章中讨论的 68000。与整数一样,RISC 风格可以从明确区分内存访问(加载和存储)与执行的算术操作中看出来。

不同的 RISC-V 实现

正如我之前提到的,RISC-V 是一个机器代码接口的 ISA 家族规范,位于程序员与 CPU 之间。它并不规定指令应如何实现。架构师可以自由设计他们自己的 RISC-V 实现作为 CPU,基于数字逻辑(或其他任何方式)。

到目前为止,RISC-V 有三个主要的开源硬件实现。这些实现完全开源,任何人都可以免费下载、编辑并制造描述 CPU 布局的文件,且无需支付费用。具体实现如下:

伯克利教育核心 这些是一些有限的 RISC-V ISA 的简化实现,专为教育用途设计,目的是更容易理解和修改。它们包括非流水线版本和简易流水线版本。

火箭 这是一系列使用专业质量流水线的 CPU 实现。它是一个系列而非单一 CPU,因为有不同字长和大多数 ISA 扩展的版本。Rocket 芯片生成器程序可以为大多数 RISC-V 描述符生成特定的芯片设计和布局。

BOOM(伯克利乱序机器) 这是一种高性能实现,使用最先进的乱序执行(OOOE)。它是进行 OOOE 及其他硬件加速研究的平台。

RISC-V 工具链与社区

RISC-V 不仅仅是一个架构:它是一个开源社区和生态系统。历史上第一次出现了完全开源的架构和工具链,任何人都可以下载、修改并烧录到廉价的现场可编程门阵列(FPGA)上。这类技术过去是架构公司中一小部分专业人士的专属领域,且是严格保密的。现在,任何人都可以使用与这些大公司相同的工具。由于工具链的开放,再加上摩尔定律的终结带来的压力,迫使人们开发全新的架构,目前硅谷已有超过 700 家架构初创公司,全球更多。Hennessy 和 Patterson 在 2017 年图灵奖讲座中宣布了 2020 年代架构的“新黄金时代”,并鼓励大家加入这个社区。

若要亲自参与,您需要下载 RISC-V 社区的工具和教程。RISC-V 的开发由位于硅谷附近伯克利大学的 Patterson 团队事实上主导。该团队已制作了一套标准工具,社区用来从晶体管布局到完整的 CPU 构建设计结构。RISC-V 的开发通常在程序 Chisel 中进行,事实上 Chisel 是由与 RISC-V 相同的一些人开发的。

智能计算机设计

对于智能计算应用,通常希望将 RISC CPU 与所有其他组件(如内存和 I/O)放置在同一硅片上,以构建完整的计算机。这样的芯片称为系统芯片(SoC)。这与嵌入式微控制器在表面上相似,但设计大且更为强大。SoC 通常被安装在一个非常小的 PCB 上,配有仅用于电源管理和物理 I/O 连接的模拟电子元件。

计算机设计师现在正在将 RISC-V 芯片设计应用到 SoC 和硬件板上。有多个商业和研究系统使用 RISC-V 的硅制造技术,以下是一些例子:

HiFive 这款由 SiFive 设计的闭源商业 RISC-V 产品是首个面向公众的实用 RISC-V 硬件。它是一块价值 50 美元的类 Raspberry Pi 风格板,采用 OOOE 实现,能够运行 Linux 进行应用,类似于 Raspberry Pi。

Mango Pi 这是一款 RISC-V 板,具有与 Raspberry Pi Zero 类似的小型外形和功能。

lowRISC 这是一个正在进行的项目,旨在设计和生产基于 Rocket CPU 的完全开源硬件计算机作为 SoC。为了制造完整的计算机,所有其他非 CPU 组件也需要作为开源硬件进行设计,特别是 I/O 和通信设备,如 USB 和以太网控制器。

ROMA 这是基于 RISC-V 的首款笔记本设计。由 Xcalibyte 于 2022 年发布。

除了 RISC CPU,智能计算的需求还推动了内存和 I/O 的相关发展。让我们来看看一些由此产生的常见计算机设计元素。

低功耗 DRAM

手机和其他移动设备中使用的 DRAM 是一种特殊的低功耗(LP)类型,称为LP-DRAM。LP-DRAM 的设计目的是通过减少电池使用来牺牲一定的速度和便利性。这主要是通过关闭不使用的大块内存电源来实现的。这会破坏它们的易失性内容,但大大减少了功耗,因为不需要持续刷新内存。主要的成本是当这些区域再次需要时,重新激活它们的延迟。例如,当你关闭所有不必要的应用时,你手机的电池会在 LP-DRAM 机器上使用更久,因为操作系统会释放它们占用的内存,这些内存可以关闭以节省电力。

与 DRAM 类似,LP-DRAM 也经历了多个标准迭代。除了功率切换外,还包括在降低电压(如 1.8 V)下工作,按照温度调整刷新率以减少不必要的刷新工作,以及多个级别的关机模式。后者可以用于区分用户将手机放入口袋长时间不使用,或者在继续使用手机其他部分时暂时释放某个应用的内存。

摄像头

摄像头传感器,如图 14-2 所示,是由类似 CMOS 的芯片构成的主动像素传感器。

Image

图 14-2:左侧为摄像头传感器,右侧为其像素的特写图

摄像头传感器是由通过光刻工艺创建的二维光传感器阵列(像素)构成的,类似于芯片。通常每个像素包含三个子像素,用于感应红色、绿色和蓝色光,就像显示屏一样。

触摸屏

手机或平板使用的触摸屏是作为一层透明的独立层,与其下方的显示屏分开生产的。像芯片一样,触摸屏通过光刻工艺生产;不同材料的层被铺设成小半电容的像素网格,正如图 14-3 所示。

Image

图 14-3:由半电容器阵列构成的触摸屏,具有二维寻址

当人类皮肤接近这些像素时,皮肤作为电容器的另一半,使得网格具有触摸敏感性。

为了让触摸屏能够作为显示屏上方的一层工作,我们需要用一种既导电(是金属)又对人眼可见的红、绿、蓝光透明的材料来构建这些半电容器及其连接的电线。这是一个非常难的要求,因为金属通常会反射所有光频。氧化铟锡(ITO)是一种非常特殊的化合物,基于稀有元素铟,恰好具有这种所需的性质,因此它被广泛应用于大多数触摸屏。

最终并没有那么不同吗?

与 CISC 不同,RISC 并不会添加额外的指令来简化程序员的工作,程序员往往必须利用更基本的、通用的指令序列以及特定的操作数。这可能会使手动编写 RISC 汇编程序比 CISC 程序更不那么有趣,但 RISC 汇编器可以提供伪指令,这些指令的功能类似于 CISC 指令,但会被汇编成多个 RISC 指令的序列。也可以构建类似的 CISC 架构,通过数字逻辑方式获取 CISC 风格的指令,再将其解码成RISC 指令序列,然后按 RISC 风格执行。这样的设计在内部看起来与 CISC 微代码结构非常相似,表明 CISC 和 RISC 其实并不需要那么不同。

总结

随着通用计算成本和功耗的下降以及电池技术的进步,智能设备正在取代许多应用中的嵌入式系统。RISC 架构非常适合智能计算需求,因为其简单性可以降低物理体积、成本和功耗要求。

RISC 架构使用一小组简单的指令。它们通常在内存访问和算术指令之间做出明确的区分。它们试图使所有指令在相同的时间内执行,从而简化执行并实现更顺畅的流水线和乱序执行(OOOE)。RISC 汇编代码通常以看起来相似的指令列表形式出现,每条指令有三个操作数。

RISC-V 是一个开放源代码的 RISC 指令集架构(ISA)系列,提供开放和闭源的数字逻辑实现以及设计工具链。RISC-V 包括一个核心指令集和各种可选的扩展指令集,因此可以用于小型、廉价的智能设备,一直到更高功率的服务器。

练习

RISC-V 编程

  1. github.com/andrescv/Jupiter安装并运行 Jupiter RISC-V 模拟器。

  2. 从本章的示例中输入一些简单的程序。Jupiter 要求定义并使__start(带有两个下划线)成为全局标签;当程序运行时,这将作为入口点。例如:

    .globl __start
    __start:
      li t0,0
    
  3. 如果你的程序中有数据行,那么默认情况下 Jupiter 假设采用哈佛式分段,这需要.data.txt部分,例如:

    .globl __start
    .data
      mylabel: .word 17
      myfloat: .float 34.56
    .text
    __start:
      lw x5, mylabel ; load word to register x5, from content at address mylabel
      la x6, mylabel ; load address to x6, of mylabel (not its content)
    myloop:
      sw x5, 0(x6)   ; store value from reg x5 to address 0+x6 (= mylabel)
      j myloop
    
  4. 保存并组装每个程序(运行 ▸ 组装),并在模拟器中运行它。你可以随时通过左侧行的勾选框设置断点,并通过右侧的 GUI 查看寄存器和内存。要返回代码,点击左上角的编辑器

具有挑战性

  1. 尝试将之前的程序从分析机和曼彻斯特宝宝移植到 RISC-V 上运行。在现代 RISC-V 系统上与那些系统相比,哪些事情变得更容易或更困难?与 x86 相比感觉如何?

  2. 获取一块物理 RISC-V 板,并使用其工具和文档在上面运行相同的程序。

更具挑战性

想在你的卧室里制作一台真正可工作的 CPU 吗?使用 RISC-V 和 Chisel,你可以做到。

  1. 可以在github.com/ucb-bar/chisel-tutorial找到完整的 Chisel 教程。安装 Chisel 并完成该教程。

  2. Rocket 和 BOOM 中使用的所有微电路——包括 ALU、FPU 和控制单元——都可以作为 Chisel 库使用。下载并构建其中一些,尝试了解它们的工作原理。

  3. Rocket Chip 生成器 (github.com/chipsalliance/rocket-chip) 是一个工具,接受 RISC-V CPU 描述代码(如 RV64IMFP)作为输入,并输出所需 CPU 的 Chisel 和 Verilog 文件(或 C++模拟)。安装并运行 Rocket Chip。研究输出结果,找出你在前一个问题中查看的微电路如何在生成的 CPU 中使用。

  4. Torture (github.com/ucb-bar/riscv-torture) 是 RISC-V 社区提供的一个工具,用于测试硬件设计中 RISC-V 执行的正确性,并帮助定位错误。安装该工具,故意在 Rocket Chip 设计中引入一个错误,并使用该工具来研究错误。

  5. 买一个便宜的 FPGA 开发板,使用 RISC-V 文档和邮件列表档案,弄清楚如何将你的 Rocket Chip 网表烧录到板子上,制作一个真正的物理 CPU。

  6. 加入 RISC-V 社区讨论,访问 riscv.org,并研究开放的 lowRISC 设计,网址是 github.com/lowrisc。利用这些资源,找到需要完成的有趣工作并为 RISC-V 社区做出贡献。

进一步阅读

第十八章:## 并行架构

Image

正如我们讨论过的,计算正沿着两条路径发展:低功耗系统,形成物联网;以及高功耗计算中心,形成云计算。在前面的章节中,我们已经看过了物联网这一低功耗系统的分支:嵌入式和智能系统。本章将重点讨论云中存在的高功耗、高性能系统。具体来说,我们将探讨并行性——云计算的骨干。

并行化的兴起与摩尔定律的两条法则有关。摩尔定律中的密度法则表明我们仍然可以在芯片上放置越来越多的晶体管,而关于时钟速度的摩尔定律已经结束,这意味着我们无法再让单个 CPU 的时钟速度更快。每秒的取指-解码-执行周期数不再增加,因此我们需要为额外的可用晶体管找到新的用途,尝试在每个周期内做更多的工作,而不是加快周期速度。

一段时间里,我们通过使用额外的硅来提升传统的串行架构:我们增加了越来越复杂的 CISC 指令,以每条指令获得更多的工作;我们在 CPU 硅片上增加了更多和更大的寄存器级缓存;我们复制了算术逻辑单元(ALU)等结构,以支持分支的同时执行;我们还构建了更复杂的流水线和乱序执行机器。总的来说,这些技术最近实现了指令每周期(IPC)的双位数年增长,而不是每秒周期数的增长。但在这些领域,我们可能已经接近用尽轻松获得进展的空间,因此我们必须更多地思考数字逻辑本身是并行的。幸运的是,它确实是。

我们已经遇到过寄存器级和指令级并行性。寄存器级并行性是指对寄存器位执行数字逻辑的每列同时执行。例如,可以同时对一个字中的所有位进行取反,而不是依次进行。指令级并行性包括流水线、分支预测、急切执行和乱序执行(OOOE)。这些概念在指令集架构(ISA)层面上并不显现;它们对汇编程序员来说是隐形的。从程序员的角度来看,它们只是让串行程序执行得更快。

本章将重点讨论在 ISA 中可见的较高层次的并行性,这些并行性可能需要汇编程序员甚至高级语言程序员的关注。我们将从并行基础开始思考,然后转向两种主要的并行类型:单指令、多数据(SIMD),这种并行性在现代 CPU 和 GPU 中存在,以及多指令、多数据(MIMD),这种并行性出现在多核处理器和云计算中心。最后,我们将通过考虑一些更激进的、无指令的并行形式,来讨论可能将架构推向超越 CPU 和程序概念的方向。

串行与并行思维

串行计算中,大部分硅材料用于构建内存,这些内存大部分时间处于空闲状态,直到需要从 CPU 加载或存储数据时才会被调用。从这个角度看,串行计算就像是让 1,000 个人将所有的工作交给一个工人,然后站在那里等待结果。这种现象被称为串行瓶颈

并行计算使得这 1,000 个人可以各自独立工作。每个人都成为一个活跃的计算单元:他们根据需要直接交换数据,从而比站着等待那个唯一工人的时候完成更多的工作。如果我们利用计算机中的所有数字逻辑来不断执行计算,而不是等待 CPU,也可以获得类似的效率提升。

因此,显然并行计算比串行计算要快得多,效果更好。但是至少在 2010 年代之前,计算机科学家们大多仍然停留在“串行思维”中。大多数人都曾在某个阶段学到过用食谱进行编程的概念,假设只有你一个人在厨房工作,你将执行一系列任务,例如:

1\. chop vegetables
2\. boil water
3\. chop chicken
4\. brown chicken
5\. add vegetables to pot
6\. add chicken to pot
7\. simmer pot
8\. chop herbs...

这在小规模情况下没问题,但如果你是一个负责管理一连串热门餐厅的主厨,你就得负责一个团队,并且必须以最优方式安排工作,才能更高效地制作食物。运筹学就是专门研究如何像这样优化工作调度的领域。

我们如何像鸡汤食谱那样将一系列指令处理完,并在最短时间内完成所有工作呢?有一些著名的算法可以实现这一点。例如,亨利·甘特(Henry Gantt)的甘特图,如图 15-1 所示,常用于展示和分析任务在时间上并行运行的顺序。

图片

图 15-1:用于烹饪鸡汤的并行甘特图

存在一些简单的算法,可以根据依赖关系列表(即哪些任务必须在其他任务完成之前才能开始)生成任务的最优时间。可以为任务网络计算出一个关键路径,即需要按时完成的工作序列,因为它们是瓶颈任务。

布莱切利公园(Bletchley Park)大量使用这种计算方式。那里不仅使用了机器作为计算工具,仍然是人类计算的时代,“计算机”这个职称指的是人类的工作。人类计算员会坐在一个计算部门(图 15-2)里,在一位经理的安排下,分工合作并行进行计算。这些程序经理会考虑如何将一个大型的数学计算任务分解成多个组件,分配任务,并将结果汇总。

图片

图 15-2:一个人类计算部门在并行工作,经理(站立者)安排工作

鉴于并行工作团队的管理已经存在很长时间,并且其基础是如何设计工作程序以高效完成任务,为什么那么多程序员基本上忽视它,而是转而思考食谱和串行计算呢?如果计算机历史从运筹学的角度开始,而不是从串行算法开始,我们可能会有一个更好的基础。由于摩尔定律对时钟速度的影响已经结束,编程——甚至可能是计算机科学的基础——现在不得不转向并行思维。例如,今天的孩子们可能会在 Scratch 中编写他们的第一个程序,所有的角色(精灵)都在并行运行代码。而专业程序员则越来越需要以 SIMD 和 MIMD 为思维方式进行思考,这就是我们接下来要学习的内容。

CPU 上的单指令多数据

我们的第一种并行处理类型——单指令多数据——意味着我们将采取单一指令(例如,“加一”)并在多个数据项上同时执行该指令。我们可以将 SIMD 系统分为基于 CPU 和基于 GPU 的实现。这里我们将关注基于 CPU 的实现;在下一部分,我们将探讨基于 GPU 的实现。

SIMD 简介

CPU 上的 SIMD 是一种非常 CISC 风格的方法:它涉及创建额外的指令和数字逻辑,以便将并行操作作为单个指令执行。SIMD 指令将多个数据项打包到一个字中,然后定义指令对每个数据项并行应用相同的操作。例如,在一个 64 位的机器上,我们可以将一个 64 位寄存器分割成四个 16 位的块,每个块存储一个 16 位的整数。然后我们可以使用理解这种打包方式的指令,同时对四个块进行操作。

在标准的 CPU 中,你可能有一个ADD指令,它将寄存器 r1 和 r2 中的整数相加,并将结果存储在 r3 中。然而,在 SIMD 机器中,你将有一个类似SIMD-ADD的指令,依然使用相同的三个寄存器,但使用不同的数据表示法同时对来自两个寄存器的 16 位值对进行加法运算;然后将结果以类似的方式存储在第三个寄存器中。

注意

SIMD 指令起源于早期的超级计算机,如著名的 1960 年代 Cray 超级计算机。SIMD 最早是通过英特尔的 MMX 指令从超级计算机引入桌面计算机的。

SIMD 可以将一个 64 位寄存器分割为两个 32 位块、四个 16 位块或八个 8 位块。四分法对于 3D 游戏特别有用。3D 程序员通常使用维向量表示 3D 坐标,其中第四维作为缩放因子,用于实现仿射变换。这些是通过简单的矩阵-向量乘法计算的变换,如平移和旋转。对于游戏来说,通常 16 位精度的数字是可以接受的(虽然对于严肃的科学 3D 模拟可能不够)。我们很幸运生活在一个经过仿射变换后,维度数是 2 的幂的世界!

SIMD 也非常适合图像和视频处理,其中像素颜色通常由四个数字表示,分别代表 RGB 和 alpha 通道(如在第二章中讨论的)。更一般来说,对于大多数类型的多媒体,包括音频,通常需要对信号处理执行许多相同操作的拷贝,因此即使没有明显的 4D 结构,SIMD 也能加速这一过程。

SIMD 指令可以为任何普通寄存器创建,但随着寄存器大小增加到 64 位,它们变得更加有趣。一些架构还包括比其字长更长的额外寄存器,称为向量寄存器;这些寄存器可以存储 128、256 或 512 位,主要用于 SIMD 指令。

现在我们理解了 CPU SIMD 的理论,接下来我们来看一个 x86 中如何实现它的具体例子。

x86 上的 SIMD

我们在第十三章中看到了 64 位时代各种 x86 架构的名称。除了基本的 amd64 指令集之外,这些架构大多数集中在使用不同形式的并行性添加扩展。这些想法大多源自高性能计算和高端服务器,但也已引入到桌面架构中。

经典的 CISC 方法是利用额外的晶体管增加更多简单的机器和指令到 ISA 中,每个指令旨在比常规指令做更多的工作。这导致了成千上万的新 CISC 指令的出现,针对各种特殊情况,如加密、多媒体处理和机器学习。对于这些扩展的标准存在一些争议。每个人都实现了相同的基础 amd64 ISA,但不同的制造商以不同的方式扩展它,添加各自的扩展。它们试图让用户依赖自己的版本,并抛弃竞争对手(这是一种著名的策略,叫做拥抱-扩展-消灭)。这给编译器开发者带来了麻烦,因为他们必须为不同的扩展创建多个后端来进行优化。

在 64 位时代,x86 新增的大部分寄存器和指令都与 SIMD 有关。图 15-3 展示了现代 amd64 的完整用户寄存器集。

Image

图 15-3:amd64 的完整寄存器集

SIMD 寄存器是那些名称中包含“MM”的寄存器。注意到,随着时间的推移,新的 SIMD 寄存器逐渐出现,通常通过扩展现有的寄存器来增加更多的位数。当进行扩展时,x86 向后兼容性要求原本较短的形式仍然要有名字并且可用,同时也要有扩展后的形式。这就要求提供多种版本的指令。

MMX

MMX 是第一个 x86 SIMD 扩展。至今没有正式定义 MMX 代表什么,事实上,这一直是英特尔和 AMD 之间关于商标的法律争议问题。有人提出的建议包括“矩阵数学扩展”和“多媒体扩展”。

MMX 将之前的 amd64 浮点寄存器扩展到了 64 位,类似于 32 位寄存器(如 EAX)扩展为 64 位的 RAX。新的寄存器命名为 MM0 到 MM7,并且在现代机器上仍然存在。

每个 MMX 寄存器可以作为一个单独的 64 位整数、两个 32 位整数、四个 16 位整数或八个 8 位整数来进行仅整数的 SIMD 操作。整数 SIMD 特别适用于处理图像,包括 2D 精灵游戏和视频编解码器。

MMX 指令以 p 表示“打包”,例如 paddd 表示“打包加双字”。新的移动指令——movb, movwmovd——将字节、字或双字数组复制到单个 MMX 寄存器中。例如,下面定义了两个 32 位双字数组:a = [4, 3] 和 b = [1, 5]。它将 a 作为打包的双字加载到 MM0 中,将 b 加载到 MM1 中。然后,它对这些双字进行打包加法运算,最终将 [5, 8] 存储在 MM0 中:

a:     dd 4, 3
b:     dd 1, 5
main:
       movd   mm0, [a]
       movd   mm1, [b]
       paddd  mm0, mm1

MMX 添加了大量的新指令,因为每个算术操作必须在字节、字(word)和双字(double)打包形式中都存在。

SSE

英特尔的 x86 SIMD 版本自 MMX 以来已经多次扩展,分别为 SSE、SSE2、SSE3、SSE4 和 SSE4.2(其中 SSE 代表流式 SIMD 扩展)。AMD 最新的、不可兼容的竞争版本令人困惑地被称为 SSE4a。与 MMX 不同,SSE 系列不仅支持整数的 SIMD 还支持浮点数。这使得它在加速游戏和其他物理仿真中的 3D 数学计算时特别有用。(根据当时的基准测试,MMX 在 3D 游戏 Quake 中的表现并不成功。)

与 MMX 扩展旧的浮点寄存器不同,SSE 添加了完全新的 128 位向量寄存器,命名为 XMM0 到 XMM31。这些寄存器的数量随着 SSE 版本的更新而增加。它们可以被拆分为 8 位、16 位、32 位或 64 位的块,每个块可以表示浮点数或整数。因此,每个算术操作会有多种指令,具体取决于这些选择。

大多数 SSE 指令在助记符的开头或结尾加上字母 p 来表示“打包(packed)”。例如,图 15-4 的左上方展示了用于相等比较的 SSE 指令 cmpeqps。该名称来自于标准的 x86 指令 cmpeq,再加上 ps 表示“打包,单精度”。

Image

图 15-4:两个 SSE 寄存器 XMM0 和 XMM1 中的内容,执行 SSE 指令时,以不同方式比较这两组数据:相等(左上)、不等(右上)、小于(左下)和不小于(右下)

在图 15-4 的右上角,compneqps 指令类似地将 cmpneq(不等比较)扩展到 SSE。

以下代码展示了如何将浮点数数组从 SSE 的 XMM 寄存器中读出并写入,并对其执行算术运算:

;from en.wikibooks.org/wiki/X86_Assembly/SSE, CC BY 3
section .data
    v1: dd 1.1, 2.2, 3.3, 4.4    ; first set of four numbers
    v2: dd 5.5, 6.6, 7.7, 8.8    ; second set

section .bss
    v3: resd 4    ; result

section .text
    _start:

    movups xmm0, [v1]   ; load v1 into xmm0
    movups xmm1, [v2]   ; load v2 into xmm1

    addps xmm0, xmm1    ; add
    mulps xmm0, xmm1    ; multiply
    subps xmm0, xmm1    ; subtract
    movups [v3], xmm0   ; store result in v3

    ret

在这里,addps 指令将 XMM1 中的四个数字加到 XMM0 中的四个数字,并将结果存储回 XMM0。对于第一个浮点数,结果将是 1.1 + 5.5 = 6.6。mulps 指令将 XMM1 中的四个数字与先前计算的结果(XMM0 中的结果)相乘,并将结果存储回 XMM0。对于第一个浮点数,结果将是 5.5 × 6.6 = 36.3。subps 指令将来自 v2(仍未改变的 XMM1 中)的四个数字从先前计算的结果(XMM0 中)中减去。对于第一个浮点数,结果将是 36.3 – 5.5 = 30.8。

AVX

两代 高级矢量扩展(AVX) 增加了比 SSE 更长的向量,具有 256 位和 512 位的长度。新的 256 位寄存器称为 YMM0 到 YMM31,新的 512 位寄存器称为 ZMM0 到 ZMM31。

AVX 指令通常与 SSE 指令具有相同的名称,并且行为类似,但它们以 v 开头。例如,要使用 AVX-256 对八对 32 位(双精度)浮点数进行加法,我们可以这样做:

    v1: dd 0.50, 0.25, 0.125, 0.0625, 0.03125, 0.015625, 0.0078125, 0.00390625
    v2: dd 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0
    v3: dd 0, 0, 0, 0, 0, 0, 0, 0
main:
    vmovups ymm0, [v1]
    vmovups ymm1, [v2]
    vaddpd ymm3, ymm1, ymm2
    vmovups [v3], ymm3

注意 AVX 算术的形式与 MMX 和 SSE 的不同,现在加法需要三个操作数,而不是两个。

使用 SIMD 的领域特定指令

如前所述,SIMD 通常被认为是一种非常 CISC 的方法,因为它涉及向 ISA 添加大量新指令。最初,这些指令来源于许多不同的打包方式、数据类型和算术操作的组合。在简单地跨块复制算术操作的基础上,CISC SIMD 还倾向于创建更复杂的指令。这些指令可能包括 水平 SIMD,意味着那些 合并 来自同一寄存器中多个块的信息的指令。例如,有一些指令可以找到寄存器中多个块的最小值:SSE 中的 phminposuw 或 AVX 中的 vphminposuw

水平 SIMD 指令有时也会将简单的 SIMD 指令串联在一起。例如,“打包双精度浮点值的点积” (dppd 在 SSE 上;vdppd 在 AVX 上) 是一条执行完整向量点积的单一指令,常用于游戏、3D 仿真和机器学习中。它首先执行 SIMD 相乘一对对的块,然后在寄存器中水平地对结果进行求和。

加密学一直是 CISC SIMD 扩展的一个重要来源。例如,128 位 AES 是 NSA 批准的互联网加密标准。它通过四个步骤计算:ShiftRows、SubBytes、MixColumns 和 AddRoundKey。英特尔为这些步骤中的每一步添加了 CISC 指令,并且还有一个组合它们的单一大指令来执行完整的一轮(SSE 上的aesenc;AVX 上的vaesenc)。如果像大多数终端用户一样,你的大部分计算时间都用于通过 HTTPS 流式传输视频,那么这种 CISC 方法为你的使用场景提供了有用的加速。但英特尔的扩展一直颇具争议,Linus Torvalds 曾表示,NSA 和英特尔可能已在数字逻辑中留下后门,并建议 Linux 程序员不要使用这些扩展。

机器学习——特别是神经网络——操作已成为最新的 SIMD CISC 目标,借助英特尔的向量神经网络指令(AVX512-VNNI)和大脑浮动点(AVX512-BF16)扩展到 AVX-512,这些扩展出现在 Golden Cove 架构中,并作为DL Boost一起推广。例如,“乘法和加法无符号和有符号字节并进行饱和”(vpdpbusds)通过单条指令执行整个神经元的类似 Sigmoid 激活操作,包括输入和权重。部分研究人员通过使用这些和类似的 SIMD CISC 指令,能够比在 GPU 上训练神经网络更快,因此现在这已经成为 CPU 和 GPU 架构之间的竞争。

编译器开发者与 SIMD

唯一理解并关心 x86 SIMD 的编译器开发者是为英特尔和 AMD 工作的人员,因此,专有的 CISC 编译器在 CISC 架构上的数值代码处理速度可能会比开源或第三方编译器(如 gcc)更快。

英特尔发布了各种 C 库,使用其自有编译器实现,将高级数值代码转换为 SIMD 指令。这些库包括集成性能原语(IPP)、数学核心库(MKL)和用于神经网络的 IPEX PyTorch-to-AVX 编译器。

开源编译器的开发者通常对特定的专有硬件扩展和 CISC 不感兴趣,他们一般更愿意将宝贵的时间花费在更具通用性的工作上,旨在造福更广泛的社区,比如生成精美的 RISC 代码,并通过流水线和超指令执行(OOOE)等方法加速。

SIMD 与 RISC-V

SIMD 指令本质上是 CISC 的思想——它们增加了大量新的指令和数字逻辑,使得指令集变得更加复杂。然而,SIMD 扩展也已经被提出用于 RISC-V,例如用于并行 SIMD 指令的 P 扩展和用于向量指令的 V 扩展。

现在没有任何现实世界中的架构是纯粹的 RISC 或 CISC,而且没有法律禁止像 RISC-V 这样主要采用 RISC 风格的架构加入一些 CISC 特性,特别是因为 RISC-V 的扩展系统使得这些特性完全是可选的。然而,在开源 RISC-V 社区中确实有一些强烈反对的声音,他们对这种潜在的 CISC 插入感到不满。即使是其创始人也发布了“SIMD 被认为有害”的警告。

好的 RISC 风格实际上是利用额外的可用硅来优化流水线和超出顺序执行(OOOE),例如通过复制 ALU、寄存器和其他组件来支持多个分支的并行执行。SIMD 指令的存在,特别是最极端的 CISC 风格的多步指令,如点积(既进行乘法又进行加法),可能会使这种方法变得更加困难。多核通常更适合 RISC,RISC-V 也有一个用于原子内存指令的 A 扩展,提供了多核事务支持。

GPU 上的 SIMD

SIMD 在 GPU 中出现的规模要大得多。CPU 上的 SIMD 提供了 2 到 64 倍的加速,具体取决于每个字中打包的块的数量。相比之下,GPU 可以扩展到数千条相同的指令同时在数据上运行。

在 第十三章 中,我们看到显卡是如何发展的,从提供图形命令的硬件实现,到为非图形计算提供自己的并行机器码。最初,这是一项非常困难、极客化的工作,涉及将大规模的计算算法编码成着色器,仿佛它们是图形计算,利用高度并行的 3D 渲染硬件,然后解码生成的图像以获取计算结果。

GPU 制造商迅速意识到这是一个新市场,并重新设计了他们的着色器语言,转变为通用 SIMD 计算的通用 GPU 指令集。这些指令集不仅可以用于实现之前的图形着色器,现在还可以用于实现通用的非图形 SIMD 计算。这一演变迅速发生,形成了不再专为图形设计的 GPU,而是为了通用更高功率的科学计算和机器学习计算,尤其是神经网络。因此,“图形处理单元”现在已经是一个误称;现代 GPU 更像是一个“通用并行单元”。

GPU 架构

过去,讨论 GPU 架构通常很困难,因为每个 GPU 都由不同的公司根据不同且保密的设计开发。然而,一些制造商联合起来达成了Khronos标准,定义了一种思考 GPU 硬件架构的方式,这种方式在大多数厂商的抽象层次上是通用的。这使得大多数 GPU,以及一些其他设备,能够被视为其硬件实现了标准架构,这样程序员就不需要过多关注它们的个别细节。只要新厂商提供将程序从 Khronos 标准转换为其更具体机器代码的软件工具,程序员也可以轻松地将一个 GPU 替换成另一个,甚至是跨厂商的更换。

Khronos 定义了一种命名实体的层次结构。我们有一个单一的主机(计算机),它可能包含多个计算设备(物理 GPU 卡或芯片)。这些设备中有多个计算单元(CUs),每个计算单元包含处理元素(PEs)

主要结构是 CU,其 PE 包含自己的寄存器和算术逻辑单元(ALU),但共享一个程序计数器、指令寄存器和控制单元。这创建了 SIMD,因为 CU 中的每个处理元素都并行执行来自 PC 的相同指令,但使用其自身寄存器中的数据。CU 还可能包含其他结构,如缓存和共享内存,允许 PE 彼此通信。计算设备通常将多个独立的 CU 打包在一起。SIMD 只存在于单一的 CU 内部。

注意

Khronos 标准旨在具有普适性,不仅适用于多种不同类型的 GPU,还适用于任何实现 SIMD 的技术。例如,在某些情况下,它们也可以在 FPGA 或 SIMD CPU 上实现。这就是为什么使用通用名称“计算设备”来替代“GPU”的原因。

图 15-5 中的硅片照片展示了 GPU 硅片的实际样子。它显示出硅片的排列比 CPU 的布局更加规律,方形的 CU 均匀分布在各处,中央有一个通用缓存。

Image

图 15-5:一张来自 Nvidia Pascal GPU 芯片的硅片照片

Nvidia GPU 汇编编程

在 CPU 中,SIMD 通过执行单条指令来并行完成固定数量(例如,四个或八个)的相同操作。程序以一系列此类指令编写,每次执行的指令由程序计数器引用。然而,在 GPU 中,SIMD 通常以不同的方式表达:我们希望使大量和任意数量的指令副本并行运行,而不是固定数量。

Khronos 定义了软件级的概念,用来表达 GPU 的 SIMD 程序。内核是用户程序员编写的通常较小的函数,目的是让代码中的每一行(汇编后的)作为单个指令在多个数据上运行。工作项是内核的一个实例——也就是说,通过在一个处理元素上运行,指令序列应用到单个数据项上。工作组是多个工作项实例的集合,它们在多个数据项上并行运行。与 CPU 的 SIMD 不同,内核代码是通过描述其对单个工作项的影响来编写的。当你运行一个内核时,你可以选择并指定要并行启动多少个工作项。

图形着色器通常是小型、简单的程序,它们对每个像素执行一系列固定的操作。因此,它们非常适合 SIMD,每个像素的工作项按照相同的顺序执行相同的指令。然而,其他类型的计算内核可能需要分支。这就会出现一个问题,类似于流水线冒险的情况,不同的工作项需要执行不同的分支。执行不同的分支会破坏 SIMD,因为工作项不再执行相同的指令。处理内核分支有两种方法:屏蔽和子组。

就像 1980 年代的 CPU 设计师一样,现代 GPU 设计师每个人都维护自己的、不兼容的 ISA(指令集架构),这些 ISA 定义了他们的平台。Nvidia 是撰写本文时最流行的 GPU 设计公司,因此我们将以编程 Nvidia 的 ISA 为例,学习如何编程 GPU。与我们在本书中编程的其他系统一样,为了简化学习,我们将略微简化事实。我们假设所有通用的 Nvidia GPU 都实现了一个名为 PTX(并行线程执行)的单一 ISA,我们将学习如何用 PTX 汇编编程。你可以在任何通用的 Nvidia GPU 上汇编和运行 PTX 程序。

数据移动和算术运算

以下是一个简单的 PTX 内核程序。像所有内核一样,构成工作组的多个副本旨在并行运行,因此这段代码仅描述了单个工作项的操作:

       mov.u32          %r1, %tid.x;       // r1 := my threadID
       cvt.rn.f64.s32   %fd1, %r1;         // convert threadID to float
       mul.wide.s32     %rd4, %r1, 8;      // id times 8 = address offset
       add.s64          %rd5, %rd3, %rd4;  // global address to store result
       st.global.f64    [%rd5], %fd1;      // store threadID in result address
       ret;

PTX 汇编代码的行尾使用分号,并且用双斜杠表示注释。寄存器名称通常以百分号符号(%)开头。我们将使用四组寄存器:r表示 32 位整数寄存器;rd表示 64 位(双精度)整数寄存器;fd表示 64 位(双精度)浮点寄存器;第四组,tid,表示用于存储并行性信息的内部寄存器。

像往常一样,大多数指令使用三个操作数,第一个是目标,其他的是输入。由于我们有多种寄存器可用,大多数指令名称使用类似 Amiga 的后缀,通过句点分隔,用来表示使用的是哪一版本。例如,add.s64 表示对 64 位有符号整数的加法运算,而 mult.wide.f64 表示对 64 位浮点数进行宽(全)乘法运算。加载 (ld) 和存储 (st) 指令有后缀,表示是否使用全局内存或本地内存。cvt 指令表示转换,通过各种后缀,它可以在不同位大小的有符号和无符号整数与浮点数之间转换。像往常一样,ret 表示返回。

上面的程序以及这里展示的其他 PTX 程序都假设开始时寄存器 rd1 到 rd3 包含三个双精度数组的全局内存地址,其中 rd1 和 rd2 是输入,我们将其昵称为 xw,而 rd3 是输出,我们将其昵称为 out。(这些约定的原因稍后会变得清晰,尤其是在我们谈到神经元时。)

该程序的功能非常简单。它完全忽略了两个输入。然后它获取其 threadID,这是分配给工作组中每个工作项的唯一整数。例如,如果我们启动一个包含 5000 个程序副本的工作组,每个副本在启动时都会在其 tid.x 寄存器中获得一个唯一的 threadID,范围从 0 到 4999。然后,工作项将其 threadID 的副本写入输出数组的相应元素。例如,第 573 个工作项,threadID 为 573,将浮点数 573.0 写入 out 数组的第 573 个元素。如果我们以 SIMD 启动一个包含 5000 个副本的工作组,它们每个都会同时将这样一个数字写入 out,因此当它们一起完成时,out 数组将包含从 0 到 4999 的数字列表。

尽管 PTX 使用 64 位字(如在示例中所见,它可以限制为 32 位),但它仍然使用字节寻址。这意味着地址加 1 会使内存向前移动 8 位。要按 64 位字进行移动,我们必须将地址加 8。因此,程序的工作原理是获取其 threadID,将其乘以 8,将结果加到 out 的地址上,并在该地址存储 threadID 的浮点版本。最终的 out 数组因此包含 [0, 1, 2, 3, 4, 5, . . .]。

分支

SIMD 的定义是内核的并行副本在程序执行时一起执行相同的指令。如果所有副本都走相同的分支,GPU 中的分支在 SIMD 中顺利运行,但如果它们需要走不同的分支,则变得复杂。

例如,下面的 PTX 内核使用了分支:

      mov.u32        %r1, %tid.x;    // r1 := my thread ID (an int)
      cvt.rn.f64.s32 %fd4, %r1;      // fd4 := convert threadID to doublefloat

      setp.lt.s32    %p1, %r1, 4;    // set predicate1 to "threadID is less than 4"

      // lines starting @%p1 only execute if predicate1 is true
@%p1 mov.f64         %fd2, 0d4008CCCCCCCCCCCD; // load doublefloat 3.1 to fd2
@%p1 add.f64         %fd1, %fd2, %fd4;         // and add it to the id
      // lines starting @!%p1 only execute if predicate1 is false
@!%p1 mov.f64        %fd3, 0d4024000000000000; // load float 10.0 to fd3
@!%p1 mul.f64        %fd1, %fd3, %fd4;         // and multiply it by thread ID

      mul.wide.s32   %rd4, %r1, 8;     // id times 8 = address offset
      add.s64        %rd5, %rd3, %rd4; // global address to store result
      st.global.f64  [%rd5], %fd1;     // result address := fd1
      ret;

前两行和最后四行与之前的程序相同。但在前两行之后,程序测试线程 ID 是否小于 4。如果是,它会将 3.1 加到线程 ID 上。如果不是,它会将线程 ID 乘以 10.0。无论哪个结果被得到,都会像以前一样将其放入 out 数组中对应的线程 ID 元素。完成后,out 将包含:

3.1, 4.1, 5.1, 6.1, 40.0, 50.0, 60.0\. 70.0, 80.0, 90.0 --snip--

这里的复杂性在于,我们只希望在某个条件为真或假的情况下,某个工作项执行某些行。在 PTX 中,我们首先测试条件(小于,lt),并将一个谓词寄存器设置为真或假来存储结果。谓词寄存器是内部寄存器,通常写作p1, p2等,可以像我们在分析引擎和其他系统中看到的状态标志一样进行设置和测试。与那些标志不同的是,谓词寄存器有很多,每个寄存器可以长时间存储谓词,而不会覆盖先前的比较结果。一旦我们设置了一个谓词,就可以指示某些行仅在谓词为真或为假时才应执行。这些指示符被称为谓词保护,在 PTX 汇编中,通常写作@%p1,位于某行的开头。不同的 GPU,包括不同的 Nvidia 型号,可能以两种方式处理谓词保护:掩码或子组。

掩码是一种简单的、纯粹的 SIMD 方法,适用于小的分支结构,例如没有跳转的if...else语句。内核始终以 SIMD 模式执行,这意味着所有副本共享相同的程序计数器,并同时执行相同的代码行。如果某行被保护,PE 会测试谓词,如果该行不应执行,那么 PE 会用 NOP(无操作指令)替换它,就像 CPU 管道停顿一样。这使得多个工作项能够保持同步,需要执行指令的工作项会执行,而其他工作项则通过这些 NOPs 等待它们。这样会浪费一些时间,PE 会从一个分支执行 NOP,而从另一个分支执行真正的指令,但它使得所有工作项始终保持同步,保持 SIMD 模式。

子组(Khronos 术语,也被一些厂商称为“局部组”、“波”或“波前”)是一种更为复杂的解决方案,超越了纯粹的 SIMD 以适应条件跳转。所有工作组中的工作项开始时都是在纯粹的 SIMD 中运行,直到遇到谓词保护。当这种情况发生时,工作组被分成两个子组,一个子组的工作项执行分支,而另一个子组的工作项不执行分支。然后,这些子组被视为两个独立的 SIMD 程序,并独立执行,若有可用的两个计算单元(CU),则在两个 CU 上执行;如果只有一个 CU 可用,则在同一个 CU 上按顺序执行。

程序中的每个分支都会产生一个额外的子组拆分,例如,一个包含四个分支的串行程序可能会导致 2⁴ = 16 个子组。在此时,物理可用的 CUs 数量决定了执行效率,而不是 CU 中的 PEs。显然,对于具有多个分支序列的大型程序来说,这种方式是不可持续的。然而,如果程序员能找到方法,子组可以重新合并(“重新同步”)。通常,当分支由于不同的工作项执行了不同数量的循环重复次数时,可以实现此操作。在这种情况下,程序员可以要求先完成循环的工作项等待,直到其他工作项也完成;这被称为同步屏障,并通过汇编和机器代码中的特殊屏障指令来表示。

分支的挑战使得 SIMD 成为一种相对受限的编程方式。它非常适合图形着色器,因为图形着色器通常没有或只有极少的分支,但对于需要并行线程执行多个不同分支的程序来说,这种方式比较棘手。神经网络和物理模拟是两类代码,它们与图形代码类似,具有最小分支结构,因此它们从 GPU 加速中受益匪浅。然而,如果你需要不同的线程做完全不同的事情,那么 SIMD 就不合适了。这时你需要 MIMD,如本章后面所见。

大型工作组

有时我们需要运行比 CU 中可用的 PEs 更多的内核副本。例如,一个像素着色器需要为 4K 显示器的每一个大约八百万个像素运行,而可用的 PEs 可能只有几千或几万个。

在这些情况下,可以使用类似于分支的子组方法:将工作组拆分为多个较小的子组,使得每个子组在 PEs 上物理地以 SIMD 方式一起运行,多个子组可以在单个 CU 上串行运行,也可以在多个可用的独立 CU 上同时运行。与分支不同,这些子组的选择是为了精确匹配 PEs 的总数。这些最大大小的子组被一些厂商称为,而子组集合被称为网格

每个子组中的工作项将分配相同的线程 ID 集合,表示 PE 在其 CU 中的位置。通常需要在这些线程 ID 和全局“作业 ID”之间进行转换。例如,如果你需要计算八百万个像素,而有 10,000 个 PE,那么第四百万个作业需要知道它应该写入第四百万个像素,而不是它的线程 ID 对应的像素,后者的最大值为 10,000。

这是一个常见的需求,因此 PTX 提供了一些额外的机制来帮助编程,如以下示例所示:

mov.u32          %r4, %ctaid.x;      //r4:=which subgroup is this?
mov.u32          %r2, %ntid.x;       //r2:=subgroup size
mov.u32          %r3, %tid.x;        //as well as the usual local thread ID
mad.lo.s32       %r1, %r2, %r4, %r3; //compute the global job ID as
                                     // jobID = r1 := r2 x r4 + r3
cvt.rn.f64.s32   %fd1, %r1;          //fd1 := convert global jobID to float

mul.wide.s32     %rd4, %r1, 8;       //id times 8 = address offset
add.s64          %rd5, %rd3, %rd4;   //global address to store result
st.global.f64    [%rd5], %fd1;       //store threadID in result address
ret;

在这里,两个额外的内部寄存器 ntid.xctaid.x 在内核启动时自动加载,包含子组大小和一个新 ID,表示当前正在运行的是哪个子组。通过使用专门的 mad 指令将它们相乘和相加,我们可以恢复全局作业 ID,并像往常一样继续执行。(程序的其余部分与第一个程序相同,存储此作业 ID 的浮点版本于 out 中的 jobID-th 位置。不同之处在于,这现在适用于更大的 out 数组——包含数百万个元素。)

GPU 神经元

现在让我们看一个更大的示例内核,它计算卷积深度神经网络(CNN)的一个神经元。这大致就是 GPU 在机器学习中的使用方式:

      mov.u32        %r1, %tid.x;       //r1 := thread ID
      mov.u32        %r2, 0;            //r2 = i = input counter := 0
      mov.f64        %fd1, 0d0000000000000000; //cumsum:=doublefloat(0)
      mul.wide.s32   %rd4, %r1, 8;
  //id x 8 = addr offset from threadID
MYLOOP:
      mul.wide.s32   %rd5, %r2, 8;      //i times 8
                                        //=adr offset from conv iteration
      add.s64        %rd8, %rd1, %rd4;  //rd8:=adr of id-th element of
                                        // x=&x+jobIDoffset
      add.s64        %rd8, %rd8, %rd5;  // + convoffset
      ld.global.f64 %fd3, [%rd8];       //fd3:=x_(job+i)

      add.s64        %rd9, %rd2, %rd4;  //rd9:=adr of id-th element of
                                        // w=&w+convoffset
      ld.global.f64 %fd2, [%rd9];       //fd2:=w_i
      mul.f64       %fd4, %fd3, %fd2;   //fd4:=x_(job+i) * w_i
      add.f64       %fd1, %fd1, %fd4;   //cumsum += fd4

      add.u32       %r2, %r2, 1;        //i++
      setp.ne.s32   %p1, %r2, 10;       //test if i==10
@%p1 bra   MYLOOP;                  //stop if so, else loop, using pred guard
      //reLU
      setp.lt.f64   %p0, %fd1, 0d3DA5FD7FE1796495; //pred0:=(cumsum<double 0)
@%p0 mov.f64        %fd1, 0d3DA5FD7FE1796495;      //predicate guard:
                                                   // if p0, cumsum:=0
      add.s64       %rd5, %rd3, %rd4;    //global address to store result
      st.global.f64 [%rd5], %fd1;        //store cumsum in result address
      ret;

在这里,0d3DA5FD7FE1796495 是浮点零。与我们所有的示例一样,我们假设开始时 rd1 到 rd3 寄存器包含三个双精度数组的全局内存地址,其中 rd1 和 rd2 是输入,我们称之为 xw;rd3 是输出,我们称之为 out。我们选择 xw 作为别名,因为神经元计算如下:

Image

在这里,reLU(a) = a 如果 a > 0,否则为 0(reLU 代表修正线性单元)。x 是一维信号,如声音波形,而 w 是权重,这些权重在神经元工作组中共享并进行卷积。

该程序基于一个循环,该循环遍历上述方程式中的和项。在每次迭代中,i,它将 w[i] 和 x[i] 载入寄存器并进行相乘。每个 w[i]x[x] 项随后被加到累积和 (cumsum) 中。谓词 p1 用于确定循环的结束。reLU 函数特别容易实现且运行速度快,这也是它被使用的原因。我们使用另一个谓词 p0 来检查 cumsum 是否大于 0。如果是,fd1 中的 reLU 输出被设置为 cumsum,否则设置为零。

机器学习中最近的“深度学习革命”更多归功于 GPU SIMD 在大规模运行类似模型的能力,而非任何新算法。

SASS 方言

随着制造商发布新型号,他们可能会修改其 ISA,通常通过扩展额外的指令来实现,但也常常会与旧版本打破向后兼容性(这与 x86 在任何情况下都保持向后兼容的传统不同)。例如,Nvidia 的 ISA 以著名科学家的名字命名,如 Tesla(2006)、Fermi(2010)、Kepler(2012)、Maxwell(2014)、Pascal(2016)、Volta(2017)、Turing(2018)、Ampere(2020)、Lovelace(2022)和 Hopper(2022)。它们共享一组核心的类似指令,但在它们之间存在一些差异。

每个这些 ISA 都有自己的汇编语言方言,称为 SASS,其指令直接对应于机器代码。这些汇编语言仅与其特定架构兼容,因此每隔几年就会变化。它们没有官方文档,也没有为用户程序员提供稳定的平台进行学习。Nvidia 开发了 PTX,作为一个稳定的汇编表示形式,供人类程序员使用,并在汇编期间被翻译成适当的 SASS 方言。

以下代码展示了 Turing SASS 与相应的 Turing 可执行机器代码,作为从前面展示的神经网络 PTX 示例汇编而来,并包含与输入和输出参数接口的包装代码:

0000 MOV R1, c[0][28] ;                00000a0000017a02  003fde0000000f00
0010 MOV R2, 160 ;                     0000016000027802  003fde0000000f00
0020 LDC.64 R2, c[0][R2] ;             0000000002027b82  00321e0000000a00
0030 MOV R12, R2 ;                     00000002000c7202  003fde0000000f00
0040 MOV R13, R3 ;                     00000003000d7202  003fde0000000f00
0050 MOV R12, R12 ;                    0000000c000c7202  003fde0000000f00
0060 MOV R13, R13 ;                    0000000d000d7202  003fde0000000f00
0070 MOV R2, 168 ;                     0000016800027802  003fde0000000f00
0080 LDC.64 R2, c[0][R2] ;             0000000002027b82  00321e0000000a00
0090 MOV R10, R2 ;                     00000002000a7202  003fde0000000f00
00a0 MOV R11, R3 ;                     00000003000b7202  003fde0000000f00
00b0 MOV R10, R10 ;                    0000000a000a7202  003fde0000000f00
00c0 MOV R11, R11 ;                    0000000b000b7202  003fde0000000f00
00d0 MOV R2, 170 ;                     0000017000027802  003fde0000000f00
00e0 LDC.64 R2, c[0][R2] ;             0000000002027b82  00321e0000000a00
00f0 MOV R8, R2 ;                      0000000200087202  003fde0000000f00
0100 MOV R9, R3 ;                      0000000300097202  003fde0000000f00
0110 MOV R8, R8 ;                      0000000800087202  003fde0000000f00
0120 MOV R9, R9 ;                      0000000900097202  003fde0000000f00
0130 MOV R12, R12 ;                    0000000c000c7202  003fde0000000f00
0140 MOV R13, R13 ;                    0000000d000d7202  003fde0000000f00
0150 MOV R10, R10 ;                    0000000a000a7202  003fde0000000f00
0160 MOV R11, R11 ;                    0000000b000b7202  003fde0000000f00
0170 MOV R8, R8 ;                      0000000800087202  003fde0000000f00
0180 MOV R9, R9 ;                      0000000900097202  003fde0000000f00
0190 S2R R4, SR_TID.X ;                0000000000047919  00321e0000002100
01a0 MOV R4, R4 ;                      0000000400047202  003fde0000000f00
01b0 MOV R0, RZ ;                      000000ff00007202  003fde0000000f00
01c0 CS2R R2, SRZ ;                    0000000000027805  003fde000001ff00
01d0 IMAD.WIDE R4, R4, 8, RZ ;         0000000804047825  003fde00078e02ff
01e0 MOV R14, R4 ;                     00000004000e7202  003fde0000000f00
01f0 MOV R15, R5 ;                     00000005000f7202  003fde0000000f00
0200 MOV R14, R14 ;                    0000000e000e7202  003fde0000000f00
0210 MOV R15, R15 ;                    0000000f000f7202  003fde0000000f00
0220 MOV R12, R12 ;                    0000000c000c7202  003fde0000000f00
0230 MOV R13, R13 ;                    0000000d000d7202  003fde0000000f00
0240 MOV R10, R10 ;                    0000000a000a7202  003fde0000000f00
0250 MOV R11, R11 ;                    0000000b000b7202  003fde0000000f00
0260 MOV R8, R8 ;                      0000000800087202  003fde0000000f00
0270 MOV R9, R9 ;                      0000000900097202  003fde0000000f00
0280 MOV R0, R0 ;                      0000000000007202  003fde0000000f00
0290 MOV R2, R2 ;                      0000000200027202  003fde0000000f00
02a0 MOV R3, R3 ;                      0000000300037202  003fde0000000f00
02b0 IMAD.WIDE R4, R0, 8, RZ ;         0000000800047825  003fde00078e02ff
02c0 MOV R6, R4 ;                      0000000400067202  003fde0000000f00
02d0 MOV R7, R5 ;                      0000000500077202  003fde0000000f00
02e0 IADD3 R4, P0,R12, R14, RZ;        0000000e0c047210  003fde0007f1e0ff
02f0 IADD3.X R5,R13,R15,RZ,P0,!PT;     0000000f0d057210  003fde00007fe4ff
0300 IADD3 R4, P0, R4, R6, RZ ;        0000000604047210  003fde0007f1e0ff
0310 IADD3.X R5,R5,R7,RZ,P0,!PT;       0000000705057210  003fde00007fe4ff
0320 MOV R4, R4 ;                      0000000400047202  003fde0000000f00
0330 MOV R5, R5 ;                      0000000500057202  003fde0000000f00
0340 MOV R4, R4 ;                      0000000400047202  003fde0000000f00
0350 MOV R5, R5 ;                      0000000500057202  003fde0000000f00
0360 LDG.E.64.SYS R4, [R4] ;           0000000004047381  00321e00001eeb00
0370 IADD3 R6, P0, R10, R14, RZ;       0000000e0a067210  003fde0007f1e0ff
0380 IADD3.X R7,R11,R15,RZ,P0,!PT;     0000000f0b077210  003fde00007fe4ff
0390 MOV R6, R6 ;                      0000000600067202  003fde0000000f00
03a0 MOV R7, R7 ;                      0000000700077202  003fde0000000f00
03b0 MOV R6, R6 ;                      0000000600067202  003fde0000000f00
03c0 MOV R7, R7 ;                      0000000700077202  003fde0000000f00
03d0 LDG.E.64.SYS R6, [R6] ;           0000000006067381  00321e00001eeb00
03e0 DMUL R4, R4, R6 ;                 0000000604047228  00321e0000000000
03f0 DADD R2, R2, R4 ;                 0000000002027229  00321e0000000004
0400 IADD3 R0, R0, 1, RZ ;             0000000100007810  003fde0007ffe0ff
0410 ISETP.NE.AND P0,PT,R0,a,PT;       0000000a0000780c  003fde0003f05270
0420 MOV R2, R2 ;                      0000000200027202  003fde0000000f00
0430 MOV R3, R3 ;                      0000000300037202  003fde0000000f00
0440 MOV R0, R0 ;                      0000000000007202  003fde0000000f00
0450 @P0 BRA 2b0 ;                     fffffe5000000947  003fde000383ffff
0460 DSETP.LT.AND P0,PT,R2,c[2][0],PT; 008000000200762a  00321e0003f01000
0470 MOV R4, e1796495 ;                e179649500047802  003fde0000000f00
0480 MOV R5, 3da5fd7f ;                3da5fd7f00057802  003fde0000000f00
0490 MOV R0, R4 ;                      0000000400007202  003fde0000000f00
04a0 MOV R4, R5 ;                      0000000500047202  003fde0000000f00
04b0 MOV R5, R2 ;                      0000000200057202  003fde0000000f00
04c0 MOV R2, R3 ;                      0000000300027202  003fde0000000f00
04d0 FSEL R0, R0, R5, P0 ;             0000000500007208  003fde0000000000
04e0 FSEL R2, R4, R2, P0 ;             0000000204027208  003fde0000000000
04f0 MOV R3, R2 ;                      0000000200037202  003fde0000000f00
0500 MOV R2, R0 ;                      0000000000027202  003fde0000000f00
0510 IADD3 R4, P0, R8, R14, RZ ;       0000000e08047210  003fde0007f1e0ff
0520 IADD3.X R5,R9,R15,RZ,P0,!PT;      0000000f09057210  003fde00007fe4ff
0530 MOV R4, R4 ;                      0000000400047202  003fde0000000f00
0540 MOV R5, R5 ;                      0000000500057202  003fde0000000f00
0550 MOV R4, R4 ;                      0000000400047202  003fde0000000f00
0560 MOV R5, R5 ;                      0000000500057202  003fde0000000f00
0570 STG.E.64.SYS [R4], R2 ;           0000000204007386  0033de000010eb00
0580 MOV R2, R2 ;                      0000000200027202  003fde0000000f00
0590 MOV R3, R3 ;                      0000000300037202  003fde0000000f00
05a0 EXIT ;                            000000000000794d  003fde0003800000
05b0 BRA 5b0;                          fffffff000007947  000fc0000383ffff

这里看到的十六进制是实际的可执行代码,它通过总线传输并在 GPU 上运行,用于神经网络;它是 SASS 汇编的直接翻译。(与第七章中看到的 Baby 机器代码进行对比——其实并没有那么不同!)

SASS 方言没有官方文档,但我们——以及互联网——可以根据我们在相应的 PTX 中看到的内容,猜测一些指令可能的含义。MOV是一个移动指令,操作数可以是寄存器或内存位置,例如c[][],用于获取内核调用的输入。LDG从全局内存加载数据,STG将数据存储到全局内存,并用于返回内核调用的输出。TID是线程 ID,告诉我们正在运行哪个工作项。IADDFADD分别是整数和浮点数加法。SHLSHR分别是左移和右移。XMAD是“整数短乘加”。BRA是分支,NOP是空操作。@P0是一个谓词保护,其中P0的值在前一行由ISETP指令设置。常见的JMPCALLRET也用于控制流。

SASS 方言还具有专门用于图形操作的指令。例如,有SUST,即表面存储,用于实际写入图形表面,还有用于加载和查询纹理以及屏障同步(BAR)的指令。

要将可执行代码传送到 GPU,并指定何时以及多少副本进行启动,主机需要一个 CPU 程序。对于通用计算,您需要自己编写这个程序,使用 GPU 制造商提供的工具。对于图形,像 Vulkan 这样的驱动程序软件将完成这项工作,只要您告诉它您的内核(在此上下文中称为着色器)的位置以及它执行的着色类型(顶点或像素)。

注意

最近的 GPU 可能具有许多额外的功能和优化,包括许多类似 CISC 的专用指令,甚至还有自己的 CPU SIMD 样式指令,将寄存器拆分成多个部分并一起操作。最近的分支处理方法开始完全放弃 SIMD,并为 PEs 分配独立的程序计数器,从而使得该机器更像以下章节中描述的 MIMD 系统,而非传统的 SIMD GPU。

高级 GPU 编程

在某些情况下,PTX 和偶尔的 SASS 代码仍由人工编写,借助人的创造力和对底层架构的了解,能够实现速度优化。然而,更常见的是使用高级语言将代码编译为 GPU 汇编语言,以实现不同 GPU 之间的可移植性,并简化编程工作。

CUDA 是 Nvidia 专有的类似 C 的语言,编译成 PTX 然后是 SASS,但不能用于其他厂商的 GPU。例如,这个 CUDA 程序逐元素相加两个向量:

__global__ void myKernel(double *x, double *w, double *out) {
    int id = threadIdx.x;   //get my ID
    out[id] = x[id] + w[id];
}

它可以通过 Nvidia 的 nvcc 编译器编译成 PTX:

> nvcc -arch=sm_75 -ptx kernel.cu

SPIR-V(发音为“spear vee”,与 RISC-V 不同,这个 V 代表“Vulkan”)是 Khronos 标准,用于表示 GPU 内核的类似汇编语言。由于 PTX 是针对许多 Nvidia 架构的通用化,SPIR-V 旨在对 所有 厂商的架构进行通用化。与 PTX 一样,它旨在被转换成每个特定架构的汇编语言。由于不同架构的寄存器数量可能不同,SPIR-V 根本不描述寄存器。相反,每条指令的结果会被赋予一个唯一的 ID 编号,这个编号可以类似于寄存器 ID 使用。当有人为新架构编写转换程序时,他们需要考虑如何最好地利用可用的寄存器来实现以这种方式描述的计算。英特尔也在致力于将 SPIR-V 转换为 x86 SIMD,使其 CPU 能够与 GPU 竞争执行相同的代码。以下展示了一个大致等同于 CUDA 示例中向量加法的 SPIR-V 代码:

EntryPoint Kernel 9
MemoryModel Physical64 OpenCL1.2
Name 4 "LocalInvocationId"
Name 9 "add"
Name 10 "in1"
Name 11 "in2"
Name 12 "out"
Name 13 "entry"
Name 15 "call"
Name 16 "arrayidx"
Name 18 "arrayidx1"
Name 20 "add"
Name 21 "arrayidx2"
Decorate 4(LocalInvocationId) Constant
Decorate 4(LocalInvocationId) Built-In LocalInvocationId
Decorate 10(in1) FuncParamAttr 5
Decorate 11(in2) FuncParamAttr 5
Decorate 12(out) FuncParamAttr 5
Decorate 17 Alignment 4
Decorate 19 Alignment 4
Decorate 22 Alignment 4
1: TypeInt 64 0
2: TypeVector 1(int) 3
3: TypePointer UniformConstant 2(ivec3)
5: TypeVoid
6: TypeInt 32 0
7: TypePointer WorkgroupGlobal 6(int)
8: TypeFunction 5 7(ptr) 7(ptr) 7(ptr)
4(LocalInvocationId): 3(ptr) Variable UniformConstant
9(add): 5 Function NoControl 8
10(in1): 7(ptr) FunctionParameter
11(in2): 7(ptr) FunctionParameter
12(out): 7(ptr) FunctionParameter
13(entry): Label
14: 2(ivec3) Load 4(LocalInvocationId)
15(call): 1(int) CompositeExtract 14 0
16(arrayidx): 7(ptr) InBoundsAccessChain 10(in1) 15(call)
17: 6(int) Load 16(arrayidx)
18(arrayidx1): 7(ptr) InBoundsAccessChain 11(in2) 15(call)
19: 6(int) Load 18(arrayidx1)
20(add): 6(int) IAdd 19 17
21(arrayidx2): 7(ptr) InBoundsAccessChain 12(out) 15(call)
Store 22 21(arrayidx2) 20
Return
FunctionEnd

在本文写作时,第三方开源项目正在进行中,旨在将 CUDA 编译成 SPIR-V,尽管这些项目并未得到 Nvidia 的支持。然而,Nvidia 确实接受 SPIR-V 作为输入,提供封闭工具通过 PTX 和另一种中间语言 NVVM 将其编译为 SASS。

OpenCL 是另一个 Khronos 开放标准,定义了一种类似于 Nvidia CUDA 的语言。OpenCL 到 SPIR-V 的开源编译器可用。以下是一个大致等同于 CUDA 示例的 OpenCL 内核:

#pragma OPENCL EXTENSION cl_khr_fp64 : enable
__kernel void vecAdd( __global double *a,
__global double *b,
__global double *c,
const unsigned int n) {
  int id = get_global_id(0);
  if (id < n)
    c[id] = a[id] + b[id];
}

GLSL 是 Khronos 标准的图形着色语言,它也可以编译成 SPIR-V。这里展示了一个实现 Gouraud 着色的 GLSL 示例(来自 www.learnopengles.com/tag/gouraud-shading/):

precision mediump float;       // default precision to medium
uniform vec3 u_LightPos;       // the position of the light in eye space
varying vec3 v_Position;       // interpolated position for this fragment
varying vec4 v_Color;          // color interpolated across the triangle
varying vec3 v_Normal;         // interpolated normal for this fragment
void main() {
    float distance = length(u_LightPos - v_Position);
    vec3 lightVector = normalize(u_LightPos - v_Position);
    float diffuse = max(dot(v_Normal, lightVector), 0.1);
    diffuse = diffuse*(1.0/(1.0+(0.25*distance*distance))); //attenuation
    gl_FragColor = v_Color * diffuse;
}

这里,lightVector是从光源到顶点的向量,而diffuse是通过光照向量与顶点法线的点积得到的漫反射成分。如果法线和光照向量朝同一方向指向,则会得到最大照明。颜色会与漫反射照明水平相乘,得出最终的显示颜色。

多指令、多数据

SIMD 就像是许多人同时执行相同的指令。多指令多数据(MIMD)则像是许多人同时执行不同的指令。可以将 SIMD 类比为一个健身课,教练在喊指令,整个班级一起做动作。而 MIMD 更像是一个健身房,每个人都有自己的私人教练,指示他们同时做不同的练习。像 SIMD 一样,MIMD 也有多种不同的类型,我们将在这里探讨。

单处理器上的 MIMD

最简单的 MIMD 可以在单个 CPU 上实现,这种架构称为超长指令字(VLIW)。VLIW 架构与 SIMD 中的向量架构相关。向量架构将多个数据项打包到一个大寄存器中,并且单一的指令作用于寄存器中所有的实体。在 VLIW 中,寄存器中的每个实体执行不同的操作。例如,我们不一定将 1 加到所有数字上,而是可以将 1 加到第一个数字,将第二个数字除以 7,将最后两个数字相乘,并将结果存储到其他位置。

这可能看起来有些反直觉,但有些指令组合往往会反复出现。例如,在编写视频编解码器时,有一些标准的复杂数学操作会在不同数据上反复执行。你可以设计一个单一的长指令字来执行这一特定的操作序列。例如,一个单独的 VLIW 指令ADDABCFPMDEFINCGSFTH可能意味着“将寄存器 A 加到寄存器 B,结果存储到 C;浮点数乘法,将寄存器 D 和 E 相乘,结果存储到 F;递增寄存器 G;并对寄存器 H 进行位移操作”——这一切都在一个指令中完成!这可能是一个视频编解码计算中标准但密集的部分。

共享内存 MIMD

单 CPU MIMD 的上一级是共享内存 MIMD,其中多个 CPU 共享一个地址空间。它们可以通过在此空间内加载和存储数据来相互通信。如果 CPU 是相同的,那么这种并行方式称为对称多处理(SMP)。如果 CPU 不同,则这种并行方式称为非对称多处理(AMP)。当多个 CPU 位于同一块硅片上时,它们被称为核心,而这种并行方式被称为多核

AMP 共享内存可以追溯到 1980 年代,当时有时会将独立的协处理器芯片与主 CPU 一起插入,用于执行额外的操作,例如浮点计算。例如,世嘉 Megadrive 使用 Z80 作为第二处理器来负责声音处理,从而释放其主处理器 68000 的负担。

SMP 共享内存计算机设计自 x86 历史以来就一直存在,最早的设计是在主板上放置两个或更多物理 8086 芯片,共享总线和内存。

在共享内存 MIMD 中,我们需要考虑缓存层级应如何共享。通常,L1 和可能的 L2 缓存存储在单个 CPU 内,并且是特定于该 CPU 的,而 L3 和可能的 L2 缓存则在多个 CPU 之间共享。这使得缓存管理变得相当复杂。想象一下,当两个 CPU 正在访问相同的 RAM 地址并独立地缓存它时,第一个 CPU 写入缓存,改变了该地址的值。记住我们在第十章中讨论过的不同缓存写入算法:当 CPU 告诉缓存更新该位置时,缓存会怎么做?它会只更新本地缓存吗?它会直接将更改发送回主内存,还是等到缓存行被淘汰时才这么做?如果第二个 CPU 尝试从主内存中读取相同的地址,我们如何确保它获得最新的更新版本?当有多个 CPU 时,我们必须小心,因为它们都有同等的能力写入共享内存,这需要 CPU 之间进行额外的通信,以便更新值并保持所有 CPU 及其共享缓存的数据同步。

x86 上的多核

多核硅片现在是最常见的共享内存 MIMD 类型,可能出现在你的桌面电脑、笔记本电脑和手机中。第一款双核 x86 处理器是 AMD Athlon X2,它由两个 Hammer K8 核心组成,位于同一块硅片上。紧接着,英特尔推出了双核 Core 2。两家公司很快推出了 4 核、8 核和 16 核处理器——包括超线程带来的额外核心——到 2020 年,已经能够在高端处理器中制造出 64 核处理器。图 15-6 展示了一个八核 Zen2 芯片的晶片图像。

像这样的芯片单元是最近的创新,它将一个大型芯片拆分成几个较小的硅片,并将它们放置在同一个塑料封装中。这是因为如今的芯片非常大且复杂,制造过程中出现错误的统计概率变得相当显著。传统上,如果芯片出现任何错误,整个芯片都会被丢弃。而通过使用芯片单元,只有发生错误的单个芯片单元需要被丢弃。像这里展示的芯片单元可以组合在一起——并与额外的 I/O 芯片单元一起——将更多的 CPU 放入单一封装中,这在过去是无法可靠实现的。

在图 15-6 中,每个核心都有自己的 L1 和 L2 缓存,L3 缓存则由它们共享。注意到核心的子组件布局几乎呈现出一种有机的特质,就像生长的霉菌。这是因为——与我们在切片图中看到的老式 CPU 不同——这些核心的布局不是由人类设计师完成的,而是由自动化布线算法生成的,优先考虑效率而非美观或人类理解。

这些核心独立运行,软件的任务是利用它们进行 MIMD(多指令流多数据流)处理。为了简化这种编程,x86 指令集架构(ISA)增加了一些新的指令,例如 Intel 的事务同步扩展(TSX)。

Image

图 15-6:AMD Zen2 芯片的切片图,显示了八个核心(四个矩形位于顶部,四个位于底部)和一个 L3 缓存(八个矩形位于垂直中心)

循环与映射编程

顺序和并行思维导致了两种不同的编程方式来处理多份工作:循环和映射。例如,在下面的代码中,我们有一个包含四个元素的数组需要处理。按顺序思考时,你会创建一个循环,并依次处理每个元素:

data = [1,2,3,4]
for i in data:
    doSomething(i)

使用循环来处理这类工作的问题在于,它混淆了两个概念。首先,它表达了我们希望处理每一个数据元素的需求。但其次,它还指定了处理顺序——在这个例子中,是从最左侧的元素开始,逐个向右处理。第一个概念通常是我们实际想要表达的,如果可以使用并行处理,我们并不关心顺序;相反,我们希望允许机器以最有效的方式安排任务顺序。

一些编程语言现在提供了库,将常规代码并行化到多核处理器上,用于程序的某些部分;这是一个来自 Python 的示例:

from multiprocessing import Pool
data = [1,2,3,4]
pool(4).map(doSomething , data)

这意味着我们希望有一个包含四个计算的池,这些计算可以按任何顺序进行,以便每个计算都在数据中的一个元素上执行。将数据元素分配给任务的过程称为映射,因此这里使用了map函数。

如果你的计算机有四个核心,你可以运行这段 Python 代码,并且如果系统设置正确,它会自动知道并行地在四个核心上运行以完成任务。

非统一内存访问

非统一内存访问(NUMA)架构是共享内存设计,其中访问内存的速度取决于访问的是哪一部分内存,以及由哪个 CPU 进行访问。

NUMA(非统一内存访问)要求专业程序员理解其架构,并手动设计程序以充分利用其优势。这包括考虑数据在内存中的位置,并尝试将数据和处理器组合在一起,以便在内存的最快部分执行加载和存储操作。

以 NUMA 为例,假设我们有四个物理机箱(外壳),每个机箱内有多个 CPU 和 RAM。最初,这看起来像四台独立的计算机,但四个机箱的内存被连接在一起,并映射到一个共享地址空间。这些并不是独立的计算机;可以说,它们是单一的多核计算机。然而,与常规的共享内存机器不同,CPU 访问另一个机箱中的内存所需的时间比访问自己机箱中的内存要长。

注意

你可以使用 64 位寻址访问 16 exiwords 的内存,如果使用字节寻址,则为 16 exibytes。这足够大,可以覆盖当前超级计算机的整个共享内存。然而,如果我们希望高效的共享内存计算超过这个范围,我们可能需要转向 128 位架构。

NUMA 用于高性能计算(HPC),这些设备也被称为超级计算机或“大铁”。它们由许多物理封闭的计算机组成,称为节点,每个节点包含一个或多个 CPU 以及内存,所有节点通过电缆连接。与常规网络不同,这些互联连接和控制它们的数字逻辑设计用于直接访问每台计算机的地址空间。一种互联的可能性是通过电缆延伸一个单一的主总线,连接所有机器,使每个 CPU、RAM 和 I/O 模块共享同一总线。另一种选择是将所有外部地址映射到该机器中的一个单一 I/O 模块,该模块缓存所有对这些地址的加载和存储,并通过与远程机器上的类似 I/O 模块进行通信来安排它们的执行。这种通信还可以包括远程 DMA(RDMA),以在节点之间进行大规模、无 CPU 的批量数据传输。大多数 NUMA 架构包括一个额外的缓存层,用于本地缓存来自远程机器的数据。这被称为缓存一致性或 cc-NUMA。

2022 年世界上最强大的公开已知超级计算机是AMD Frontier,位于美国能源部的橡树岭国家实验室,如图 15-7 所示。

Image

图 15-7:AMD Frontier 超级计算机

Frontier 由 74 个液冷 HPE Cray EX 机柜组成,每个机柜内包含八个机箱,每个机箱有八个计算节点。每个节点配备两颗 AMD CPU 和八个 GPU,总共约有 9,400 个 CPU 和 37,000 个 GPU。其计算能力可以达到每秒执行一千亿次浮点运算,称为exaFLOP。它具有 700PB 的存储,使用 Lustre 文件系统进行管理。其核心技术是互连系统,称为 HPE Slingshot,结合了 HyperTransport 协议和超过 90 英里的电缆布线——包括每一对节点之间的直接点对点连接——提供了 NUMA 式的内存架构,使得远程节点上的内存看起来就像本地内存一样。Slingshot 使用的空间、电子设备和电力消耗与计算节点相似。

NUMA 超级计算机用于天气和气候预测、物理模拟和大脑建模等任务,利用这些领域的拓扑特性,并将其与 NUMA 系统的拓扑层级相连接。拓扑指的是物理空间中的连接性;在气候预测的上下文中,我们指的是模拟地球大气的三维空间。每个点与相邻点有着强烈的交互,交互的强度随着点与点之间的距离增大而减少。每个点都有自己的数据属性,如风速、温度、湿度和气压。为了预测未来几天或几个月内的情况,我们将空间离散化为小块,并将每块分配给一个处理器来管理,相邻的大气块分配给 NUMA 层级中的相邻处理器。每个处理器计算细节并进行预测,考虑来自其他本地块的数据。

NUMA 有时也在单一物理计算机外壳内实现,常见于高端工作站和服务器。这些系统更可能运行多个小型、相互独立的程序,而非单一的大型科学程序,因此不太需要专业的编程。

MIMD 分布式计算

分布式计算意味着我们有多个 CPU,每个 CPU 有自己的地址空间,且这些地址空间彼此不可直接访问。通常,这些地址空间被分别放置在不同的物理设备中,如服务器或 ATX 机箱。不同地址空间中的 CPU 只能通过 I/O 进行通信。根据对计算机的定义,这些系统可能看起来像是多个独立的计算机,通过网络 I/O 松散连接。但在其他情况下,它们的工作紧密结合,以至于把它们看作一台具有多个地址空间并通过较慢的 I/O 网络连接的单一多核机器(像极端形式的 NUMA)更为合理。

服务器是始终保持开启的计算机,通常用于分布式计算以及提供在线服务,如网站和数据库。任何连接到互联网的计算机都可以作为服务器使用,包括台式电脑和树莓派,但为更好地满足服务器的高可靠性要求,已经发展出了专用的计算机设计。这些设计包括双电源供应、停电后自动开机以减少因电网故障导致的停机时间;高效的散热设计和使用 ECC-RAM(如在第十章所述)以减少内部故障;19 英寸的机架式单元尺寸;以及各种形式的物理安全措施,以减少人为干扰。

让我们来看看几种分布式计算的形式。

集群计算

集群中的节点之间可能会有大量的持续通信。Beowulf 是构建集群的一个非正式标准,通常由商品计算机(往往是许多旧的回收台式机)组成。集群计算,特别是 Beowulf,往往非常“黑客”式、业余且临时,但可以从低端机器中构建出强大的系统。

网格计算

网格计算,也称为单程序多数据(SPMD)编程风格,是指将相同的程序但不同的数据提供给多台相同的计算机。计算机并不会同步执行程序指令;相反,它们可以根据数据的不同而产生不同的分支,运行同一程序的多个副本,并且在执行过程中处于不同的地方。网格计算非常适合应用于数据科学、语音识别、数据挖掘、生物信息学和媒体处理等领域。在这种计算方式下,您可能有多个 TB 的数据,并希望机器能同时处理其中的不同数据块。

这种风格的一个特点是所有机器完全相同,并且由专门的技术人员保持在相同的状态,这些技术人员将它们托管在一个安全的环境中。大量相同规格的高性能服务器被堆叠在机架中,以确保您的程序实例能够以与其他实例完全相同的方式快速运行。

网格计算机不使用共享内存;相反,它们通过 I/O 连接网络。网络容量主要用于计算节点访问存储节点上的硬盘数据,而不是节点之间的通信。通常,工作会被划分为数据块并发送到计算节点,然后计算节点独立地处理各自的数据块——这与超级计算机不同,后者强调处理器之间的密集通信。

由于节点之间的连接相对较弱,网格有时由位于地理位置分散的节点构成。例如,欧洲核子研究组织(CERN)的超大网格将全球多个大学的小型网格连接起来,使它们能够根据需要在分析数百万粒子物理实验的大数据时分摊负载,从而找到希格斯玻色子的微妙统计证据。

去中心化计算

随着并行计算类型的逐渐松散,我们进入了去中心化计算。这有点像多站点网格计算,但设备之间的连接更加脆弱。网格是由相同的机器组成的堆栈,由专业的 IT 技术人员维护,保持运行、健康且操作相同。相比之下,去中心化计算利用大量消费者级别的、非相同的计算机,可能这些计算机属于不同国家、不同人的所有,然后通过公共互联网将它们连接在一起。这些计算机没有共享内存,不能被视为可信,也没有节点间的多余通信。没有专业人员维护这些计算机,也没有购买相同组件的高昂成本。

去中心化计算在 1990 年代因著名的“搜索外星智慧”(SETI)项目而流行起来。SETI 从大型射电望远镜收集来自候选夜空区域的大数据,然后分析这些数据,寻找外星通讯信号。你可以在桌面上下载 SETI 程序,当计算机开机但没有其他任务时,它会作为屏幕保护程序运行(见图 15-8)。

Image

图 15-8:SETI 软件在家用计算机上分析射电望远镜数据,寻找外星通讯信号

该程序会连接到主 SETI 服务器进行注册,并接收一个或多个数据块进行分析。分析结果会返回给服务器,服务器将其与其他计算机的结果一起收集。

HTCondor 是现代软件,能够让任意计算任务在常规桌面 PC 上去中心化地在后台运行,例如将办公室或教室中未使用的桌面转化为网格。

与网格计算不同,工作机器不再由中央管理员控制,因此不能假设它们是可信的。管理员可能会将任务分配给一些没有返回答复或者返回虚假结果的工作者。一个标准的应对方法是将相同的任务分配给三个工作者,并检查它们是否返回相同的结果——如果有两个一致,那么第三个可能在作弊。

云计算

去中心化计算的逻辑发展应该是,或者说仍然可能是,全球的普通计算机用户定期连接在一起,并相互交换他们未使用的 CPU 计算周期。这样,当你需要运行一个巨大的机器学习模型时,你可以在全球范围内的百万个 CPU 上运行这些模型,这些 CPU 原本除了显示屏保外几乎处于空闲状态,而不是必须购买自己的个人计算网格。然后,在其他 99.9%的时间里,你也可以允许其他人在你的 CPU 空闲时使用它来进行他们的大规模计算。这种现象为什么还没有发生,是一个有趣的社会和经济问题。

相反,我们已经看到——就像互联网的其他方面一样——一些大公司通过维护自己松散连接的机器集群,称为云计算,开始主导分布式计算市场。这些公司消除了开放式分布式计算中的一些信任、可靠性和支付问题,但也带来了隐私问题、控制权丧失和自由的担忧。

计算与存储

在分布式计算中,一个长期存在的争论是,是否更好将数据存储在进行计算的机器上,还是存储在单独的机器上。

将计算与存储分离意味着在分布式网络中拥有两种不同类型的机器:一些专门用于存储数据,另一些专门用于计算。这种做法的优点是任何可用的计算节点都可以用于对任何数据进行计算。通常,存储节点上使用软件文件系统,使其看起来并表现得像是一个单一的、非常大的硬盘。分离使得这两种类型的机器可以更好地专注于各自的任务,并且能够独立升级;它还使得存储与计算能力的比例更容易平衡。当不需要计算时,计算节点可以关闭以节省能源,或者提供给其他用户使用。当存储的数据长时间未被访问时,它可以被转移到内存金字塔中的三级或离线存储中,之后根据需要再调回。该方法的缺点是,它需要大量的网络通信来不断地将数据从存储位置移动到使用位置,这可能会成为瓶颈。

另一方面,联合定位意味着使用一种类型的机器,该机器在本地硬盘上存储部分数据,并且对其进行计算。一个大的数据集可以分割到许多这样的机器中,每台机器在本地进行对其托管的数据的所需计算,网络仅用于传输结果和更新数据。其优势在于最小化了网络通信,但劣势在于数据块的计算只能由托管该数据的单一机器完成,导致机器容易被过度使用或不足使用。

哪种方法最有效取决于网络、存储和计算技术的相对速度与成本,而这些技术会随时间变化,这也为 IT 顾问提供了大量的就业机会,他们在这些技术之间来回切换。传统的集群倾向于使用分离式架构,依赖于快速网络(如 InfiniBand)迅速移动数据。然而,程序员有时会切换到联合定位,将数据从存储中提取本地缓存副本,用于需要快速多次重新读取数据的应用程序。在 2000 年代,联合定位变得更加流行,map-reduce算法通过开源软件 Hadoop 和 Spark 被广泛应用于搜索引擎。Map-reduce 使用了之前讨论的替代循环的 map 方式,但采用递归方式,作业递归地将其工作分解并传递给其他机器,然后将其结果汇总和合并(即减少)。

最近,云计算的兴起带来了数据中心网络速度的提升,存储和计算分离带来的更明确的成本节省,以及动态重新分配用户和工作到不同物理机器的需求,这一切使得分离式架构再次变得更加吸引人。一些系统尝试结合两种风格的元素,允许数据通过网络传输到可用的计算节点,但如果可能的话,更倾向于在原始节点上计算数据。未来如果云计算转向去中心化计算(其网络速度低于云数据中心),可能会再次促进联合定位的回归。

无指令并行处理

SIMD 和 MIMD 都扩展了经典 CPU 的概念,即提取、解码并执行一系列顺序指令程序。但实际上,有许多方法可以并行使用数字逻辑,而不涉及创建 CPU 和指令程序。我们在这里来看一下其中的一些方法。

数据流架构

与计算机科学家不同,工程师一开始就没有被图灵机的串行主义所困扰。由于他们处理的是物理世界,工程师倾向于将电子信息处理系统,无论是模拟的还是数字的,视为连接在一起并始终根据物理法则操作的物理设备群体,就像机械机器一样。对于他们来说,这些系统一直都是并行的,并且是通过电路图(如 图 15-9)进行设计的,而不是作为一系列指令的顺序程序。

图片

图 15-9:显示模拟吉他失真踏板中并行信息处理的示意图

将 图 15-9 中的结构重写为顺序程序通常会让工程师觉得这是一种荒谬、低效的计算机科学疯狂。这些电路由硬件组件组成,每个组件同时执行各自的任务,数据不断在它们之间的连接处流动。处理数字逻辑时也呈现出类似的世界观,直到我们进入 第七章,在这里我们选择使用这种逻辑来实现串行 CPU。但数字逻辑不必仅用于此目的。它也可以用于以继续设计更高层次并行机器的工程风格,这些机器协同运行,并且它们之间有连接。大多数工程师的电路设计,无论是模拟的还是数字的,都可以转换为这种形式的 LogiSim(或 Verilog、VHDL、Chisel)网络。模拟数据值可以转换为我们已经看到的数字表示之一,模拟操作也可以转换为数字算术简单机器。与 CPU 一样,这些设计可以被烧录到 ASIC 或 FPGA 芯片上。

这种方法对于信号处理计算特别高效,其中需要一系列的处理步骤。例如,一个吉他效果器可能需要压缩、失真、延迟和混响等步骤。与其将这些步骤按顺序实现,不如让它们在一个管道中一起存在,就像吉他手将多个模拟硬件踏板串联在一起,每个踏板实现一个效果一样。

数据流编译器

数据流语言,例如 PyTorch、TensorFlow 和 Theano,以及 MATLAB 的 Simulink,是用于指定并行信息处理的高级语言。这些语言使程序员能够表示符号数学计算的元素及其相互依赖关系,然后使用针对各种类型并行硬件的专用编译器对其进行排序和并行化。例如, 图 15-10 显示了一个神经网络计算的图形数据流描述。

图片

图 15-10:PyTorch 神经网络计算的数据流

像硬件描述语言一样,这些不是编程语言,而是声明式语言,更像是编写 XML 或数据库,而不是编写传统的命令式程序,作为指令序列。(SPIR-V 也可以被认为是一种中级数据流语言,因为它将寄存器抽象为标识符。)从数据流语言到硬件描述语言(如 Verilog 和 VHDL),以及到 GPU、CPU SIMD 或串行 CPU 指令的编译器也可能存在。

一个正在进行的研究领域是如何将常规的串行 C 语言自动编译为数据流语言。OOOE 可能只是冰山一角,它仅在短时间窗口内优化机器代码指令,但我们可以想象,未来某一天,整个程序会通过使用先进的并行算法和复杂性理论,自动地、类似地转化为 Verilog 或 SPIR-V,以提取程序最并行化的形式。现代编译器可以处理一些“简单”的情况,比如将循环转化为映射,当循环的迭代彼此没有明显影响时。然而,一般来说,这项工作是困难的,尤其是因为计算机科学理论大多基于串行机器;可能需要未来的重大创新,才能在并行性作为更基本的起点的框架下重建这个学科。函数式编程语言可能是解决方案的一部分,因为它们限制了可见状态的数量,使得将工作分解为独立且可并行化的部分变得更容易。

硬件神经网络

数据流架构的一个特定应用是实现反向传播神经网络算法的快速硬件实现。自 20 世纪 60 年代以来,我们就知道这个算法能够识别和分类任何模式,只要提供足够的数据和计算时间。我们还知道它具有高度的并行性,神经网络由许多可以独立计算并向邻居传递信息的“神经元”单元构成。

在 2010 年代,GPU 架构首次使得这些计算能够以低成本并行实现,并且能够成功且准确地识别复杂的模式,如图像中的人脸和语音中的单词。这催生了对更快、更专业化架构的巨大商业需求,以比 GPU 更高效地实现反向传播算法。

当前使用的硬件神经网络主要有两种方法:FPGA 和 NPU。我们现在来讨论一下这两者。

反向传播在 FPGA 上的应用

研究人员已经在并行 FPGA 上构建反向传播神经网络几十年了。FPGA 设计可能尝试以每个神经元的组件模块物理布局电路,或者可能将布局交给 Chisel 或 Verilog 编译器,这样通常会生成随机外观的电路,虽然它们实现相同的逻辑,有时效率更高。在 2010 年代,这些系统以更大规模进行商业化,特别是由“大型科技”公司用于训练神经网络,以便对其大数据进行预测。

神经处理单元上的反向传播

最近的架构趋势是生产类似的并行神经网络硬件,这些硬件运行速度比 FPGA 更快,通常称为神经处理单元(NPU)张量处理单元(TPU)

这些系统中的一些被设计为高功率系统,用于训练神经网络模型,通常在计算中心的机架中以集群形式部署。另一些则被设计为低功耗嵌入式系统,用于运行预训练网络进行实时模式识别,并被包括在智能手机和物联网设备中(例如,英特尔神经计算棒和基于 Arduino 的 Genuino)。这些单元可以为应用提供支持,如 Snapchat 的实时面部识别和滤镜。摩尔定律在时钟速度和晶体管尺寸上的差异是推动这些系统的主要因素,手机设计师有大量多余的硅材料可供使用,并寻找利用它的方式。NPU 最初是由制造商推动到手机上的,寻找应用,而不是由消费者需求推动。

总结

在之前的章节中,我们已经看到几种形式的并行性,从巴贝奇的并行算术指令和寄存器级并行性(波纹进位加法器),到指令级并行性,如流水线和超出顺序执行(OOOE)。在这些层面,程序员仍然编写串行程序,不需要知道或关心并行性如何加速程序的运行。

相比之下,本章中看到的并行性,SIMD 和 MIMD,确实会影响程序员,程序员需要理解它们的细节并编写程序,以充分利用这些并行性。我们按照并行性的紧密程度顺序查看了架构,首先是那些显然是单一计算机的系统,然后逐渐让并行执行变得更加独立,直到系统看起来更像是通过网络连接的多个计算机。

SIMD 是指单条指令在多个不同的数据项上并行执行多次。它可以在 CPU 和 GPU 中找到。通常,CPU 的用户汇编编程需要考虑并行 SIMD 指令,使用固定的、二的幂次的并行副本,而在 GPU 上,指令集架构(ISA)可能以单个线程的指令为结构,允许线程数目更多样化。CPU 风格不容易支持带有分支的程序,而 GPU 风格则通过掩码或串行拆分子组执行来实现这一点。

MIMD 是一种较为松散的并行方式,可以使不同的程序在不同的机器上运行。这包括共享内存系统,其中所有处理器可以在同一地址空间内加载和存储数据。这些系统可以是位于同一物理机箱内的多核 CPU,也可以是大型 NUMA 超级计算机,其中位于物理位置较远的内存访问时间比附近的内存更长。分布式系统则更加松散,因为每个处理器或小组处理器都有自己的地址空间,节点之间的通信仅通过网络 I/O 进行。

单台计算机与多台计算机之间的边界似乎有些模糊。大多数人会认为具有 SIMD 指令的 CPU 是一台单独的计算机。对于 NUMA 超级计算机或网格系统来说,划分就更为困难。像 SETI 和比特币这样的去中心化系统,将来自世界各地的机器资源结合起来,表现得与网格系统相似。今天,几乎每台计算机都曾连接过互联网,在此过程中,它可能与其他计算机进行了通信,甚至成为了全球计算与计算机的一部分。

仍然有许多程序员未接受过并行算法的训练,他们认为这些算法只是研究生学位的奇特内容。传统的并行编程观念是,“等你完成了你那复杂的并行程序,英特尔就会推出一个更快的处理器,使我的串行 C 代码比你的快。”这种观念现在已经不再适用。现在编程必须采用并行方式,因为基于硅的串行架构已经达到了极限。这可能需要计算机科学整体的某些基础性变革。

作为程序员,你是否需要关心并行编程?这里有几个可能的未来。在其中一个未来中,你继续像现在一样编写串行程序,聪明的程序员们编写编译器,将它们转换为并行系统。另一个现在正在发生的场景是,少数程序员创建特定的库来执行并行计算操作,而你则在串行程序中调用单一函数来运行每一个操作。第三种情况是,你将需要自己编写越来越多的 SIMD 程序,这将要求你显著改变编程风格。第四种情况是,你需要成为 MIMD 程序员,这可能是一个更大的风格变化。在这个过程中,你可能会将循环转换为映射,或许会从命令式编程转向函数式编程。或者,也许你会完全停止编程,就像工程师一样,仅仅设计硬件电路,通过声明式语言执行计算。这现在是一个很大的悬而未决的问题,许多程序员通过选择学习哪种编程风格来为自己的职业生涯下注。

练习

x86 SIMD

尝试运行本章中展示的 x86 MMX、SSE 和 AVX 代码。你可以像在第十三章中那样,使用 .iso 文件在裸机上运行它们。或者,如果你对操作系统有一些了解,参见附录了解如何在操作系统内部运行它们。这是一种更快速的 x86 汇编开发方式。

Nvidia PTX 编程

  1. 如果你有访问 Nvidia GPU 的权限——无论是你自己 PC 上的,还是通过像 colab.google 这样的提供 GPU 选项的免费云服务——你可以编译、编辑并运行本章中的 PTX 示例。在示例中,我们假设会有某些人或程序调用内核并将输入传递给它们。为了建立这种关联,创建一个名为 mykernel.ptx 的文件,并在其中写入以下代码:

    // directives to tell the assembler what versions to use
    .version 7.1
    .target sm_75
    .address_size 64
    // this describes how the code will interface with C code on the host
    .visible .entry _Z8myKernelPdS_S_(
        .param .u64 _Z8myKernelPdS_S__param_0,
        .param .u64 _Z8myKernelPdS_S__param_1,
        .param .u64 _Z8myKernelPdS_S__param_2
    )
    {
        // directives to say how many registers we will be using
        .reg .pred %p<2>;     // predicate reg
        .reg .b32   %r<5>;    // regs of 32-bit ints
        .reg .f64   %fd<5>;   // regs of 64-bit (double) floats
        .reg .b64   %rd<10>;  // regs of 64-bit (double) ints
        // generic part to load argument pointers to rd1-3 and jobID to r1
        ld.param.u64 %rd4, [_Z8myKernelPdS_S__param_0]; //rd1:=pointer arg0
        ld.param.u64  %rd5, [_Z8myKernelPdS_S__param_1];
        ld.param.u64  %rd6, [_Z8myKernelPdS_S__param_2];
        // convert address of pointers, generic to global memory
        cvta.to.global.u64  %rd1, %rd4;   // rd1 stores global address of x
        cvta.to.global.u64  %rd2, %rd5;   // re2 stores global address of w
        cvta.to.global.u64  %rd3, %rd6;   // rd3 stores global address of out
        //------put your chosen example code below------
    }
    
  2. 将下列示例中的代码粘贴到指定行之后,以完成它们。然后使用以下命令将其汇编为 Nvidia 可执行文件(cubin)代码:

    > ptxas -arch=sm_75 --opt-level 0 "mykernel.ptx" -o "mykernel.cubin"
    

    这里,-arch 参数是你使用的 Nvidia 模型的代码——例如,sm_75 是 Turing 的真实名称。

  3. 如果你想查看可执行文件的人类可读的十六进制和 SASS 格式,可以使用以下命令:

    > cuobjdump -sass -ptx mykernel.cubin
    
  4. 你现在需要一些代码在主机 CPU 上运行,以管理将此可执行文件发送到 GPU 的过程。你还需要为其运行提供数据输入,并且需要发送命令以启动所需数量的内核并打印出它们的结果。以下代码将完成所有这些操作,并且可以与任何我们讨论过的方式包装的内核一起使用。

    #include <stdio.h>
    #include <stdlib.h>
    #include <math.h>
    #include "cuda.h"
    int main(int argc, char* argv[]) {
        cuInit(0); CUcontext pctx; CUdevice dev;
        cuDeviceGet(&dev, 0);   cuCtxCreate(&pctx, 0, dev);
        CUmodule module; CUfunction vector_add;
        const char* module_file = "mykernel.cubin"; int err;
        err = cuModuleLoad(&module, module_file); // load cubin executable
        const char* kernel_name = "_Z8myKernelPdS_S_";
        err = cuModuleGetFunction(&vector_add, module, kernel_name);
        int n = 100000;                  // size of vectors
        double *h_x, *h_w, *h_out;       // host in and out vectors
        double *d_x, *d_w, *d_out;       // device in and out vectors
        size_t bytes = n*sizeof(double); // size, in bytes, of each vector
        h_x=(double*)malloc(bytes);      // allocate memory for vectors on host
        h_w=(double*)malloc(bytes);h_out=(double*)malloc(bytes);
        // allocate memory for each vector on GPU
        cudaMalloc(&d_x,bytes);
        cudaMalloc(&d_w, bytes);
        cudaMalloc(&d_out, bytes);
        int i; for(i = 0; i < n; i++) // init host vecs to arbitrary values
            {h_x[i] = sin(i)*sin(i); h_w[i] = cos(i)*cos(i);}
        cudaMemcpy(d_x, h_x, bytes, cudaMemcpyHostToDevice); // device<-host
        cudaMemcpy(d_w, h_w, bytes, cudaMemcpyHostToDevice);
        // set arguments and launch the kernels on the GPU
        int blockSize, gridSize;      // threads in block, blocks in grid
        blockSize = 1024;   gridSize = (int)ceil((float)n/blockSize);
        void *args[3] = { &d_x , &d_w, &d_out };
        cuLaunchKernel(vector_add, gridSize,1,1, blockSize,1,1, 0,0,args,0);
        cudaMemcpy(h_out,d_out,bytes,cudaMemcpyDeviceToHost); // host<-result
        for(i=0; i<10; i++) printf("out: %f\n", h_out[i]);    // print result
        cudaFree(d_x); cudaFree(d_w); cudaFree(d_out);        // free device mem
        free(h_x); free(h_w); free(h_out); return 0;          // free host mem
    }
    
  5. 使用 Nvidia 的 nvcc 工具编译并运行:

    > nvcc myptxhost.cu -lcuda
    > ./a.out
    

    你应该能在主机终端看到打印出的结果。

更具挑战性

  1. 如果你想尝试在 SASS 中编程,甚至是 Nvidia 机器代码,许多 Nvidia 架构都有第三方 SASS 汇编器,并且有文档说明。在github.com/daadaada/turingas网站上,你可以找到适用于 Volta、Turing 和 Ampere 的 SASS 汇编器;该网站还提供了 SASS 汇编代码示例,并链接到 Fermi、Maxwell 和 Kepler 的类似汇编器。Nvidia 提供了一个 SASS 调试工具,地址是docs.nvidia.com/gameworks/content/developertools/desktop/ptx_sass_assembly_debugging.htm,并且提供了一个 GPU 模拟器,地址是github.com/gpgpu-sim/gpgpu-sim_distribution。Nvidia 在docs.nvidia.com/cuda/cuda-binary-utilities/index.html列出了 SASS 助记符的含义,但没有提供它们的参数和语义。一些第三方 SASS 汇编器还包括有用的 SASS 程序示例。

  2. 如果你可以访问非 Nvidia GPU,找出其品牌和型号,看看是否有类似 PTX 或 SASS 的公开 ISA 和汇编器可用。有时,第三方逆向工程师会为 GPU 制造商未提供或未文档化的 ISA 和汇编器创建并记录相关内容。与您曾见过的 CPU ISA 相比,它是如何的?

  3. 通过运行多个 Virtual-Box 实例来模拟一群 PC,就像在第十三章中使用的那样。研究如何在这些实例上安装和运行 SGE、MPI 或 HTCondor。

  4. 如果你了解神经网络理论,可以为 GPU 神经元添加反向传播。添加代码以创建并运行多个神经元层,每层包含多个神经元,用于学习并运行某些模式识别任务。

进一步阅读

  • 若想了解 Nvidia Kepler 架构的逆向工程及第三方开源汇编器,请参阅 X. Zhang 等人的文章,“理解 GPU 微架构以实现裸机性能调优”,该文发表于第 22 届 ACM SIGPLAN 并行编程原理与实践研讨会论文集(纽约:计算机协会,2017)。

  • 如果你对完全开源的硬件 GPU 架构感兴趣,可以查看 MIAOW,* raw.githubusercontent.com/wiki/VerticalResearchGroup/miaow/files/MIAOW_Architecture_Whitepaper.pdf*。

  • 欲了解更多关于 SPIR-V 的信息,请参考 J. Kessenich 的文章,“SPIR-V 简介”,* registry.khronos.org/SPIR-V/papers/WhitePaper.pdf*。

  • 若想了解如何将 Python 编译为无 CPU 的数据流数字逻辑,请参见 K. Jurkans 和 C. Fox 的文章,“Python 子集到数字逻辑数据流编译器,用于机器人和物联网”,该文发表于国际智能与可信计算、通信与网络研讨会(ITCCN-2023)(英国埃克塞特:IEEE,2023)。

第十九章:## 未来架构

图片

从历史上看,研究那些接近转向产业的学术成果,往往能够准确预测未来十年的发展趋势。目前,研究仍然主要集中在基于半导体的技术上,但也有一些研究人员正在寻找替代方案。虽然很难预测十年以外的事情,我们还是会看看一些当前的研究方向,这些方向有可能最终突破当前基于电力的计算时代。我们将大致按不确定性的顺序进行,从一些与当前“新黄金时代”架构密切相关的接近市场的进展开始,然后讨论研究实验室中的光学和 DNA 架构、神经架构和量子计算,最后探讨基于更遥远物理理论的推测性想法。

《新黄金时代》

架构再次变得酷炫!在 2010 年代,像是创客、开源和“深思熟虑设计”运动等趋势,推动了人们对架构重新产生兴趣。反叛那些被卖给他们的预包装黑盒接口,艺术家、创新者、嬉皮士和蒸汽朋克们选择通过打开这些盒子,观察和修改里面的内容,从而获得对生活中技术的更深理解、更多控制以及更高的满足感。在专业领域,未来十年的商业架构职业可能会集中在低成本、低功耗的嵌入式和智能系统上,而不是桌面、笔记本和服务器。

2010 年代也是并行化和集中计算的十年,计算从桌面计算转向了“云端”,进入了专用的集中计算和数据中心。普遍预计,计算机演变的下一步将是桌面甚至笔记本电脑的消失,取而代之的是在现实世界中无处不在的小型低功耗设备,这些设备与云端保持持续通信,将数据传输到云端进行处理。智能手机和平板电脑是这种设备的早期版本,但我们预计未来将在现实世界中看到更多更便宜、更小巧的设备,推动智能家居、智能农业和智能城市的发展。

赫内西和帕特森最近发现的一个趋势是对定制化、领域特定架构的需求。在这种观点下,GPU 和 NPU 只是加速特定单一任务的定制硅芯片新潮流的开始。很可能,架构师们将与更大的团队合作,从而推动这些设计的实施——例如,与机器学习工程师和密码学家更紧密地合作,理解并加速他们的算法。这将带来计算机科学的文化转变,重新将架构师带回主流,要求其他所有人像 1980 年代那样理解并与他们的工作互动。

开源架构

在建筑历史上,开源思维首次扩展到完全开源的硬件和软件工具堆栈的创建——RISC-V、BOOM 和 Chisel——用于专业级、最先进的芯片设计。加上新型廉价的 FPGA,这些使任何人都能获得以前仅限少数保密且精英的架构公司使用的设备。现在几乎任何人都可以成为任何事物的创造者,看到并修改整个技术堆栈,从晶体管级别到操作系统。因此,现在是参与架构设计的最佳时机——甚至比 8 位时代还要好,那时黑客们只能看到指令集架构(ISA),但仍然只是芯片制造商的客户。

开源硬件设计甚至开始出现在整个消费级 PC 上,例如基于 ARM 的 Olimex TERES 笔记本,用户通常通过 PCB 设计软件和 3D 打印进行修改。开源兴趣的推动力也来自终端用户,他们对个别 CPU 的专有架构感到越来越不安,担心其可能在数字逻辑层面上有后门。例如,英特尔曾被指控在其处理器内部隐藏并运行基于 MINIX 的整个操作系统,该操作系统能够与英特尔的服务器通信,可能会泄露机器正在做的任何事情。开源架构可能会成为标准并被广泛期待——这将是一场类似于 2000 年代开源软件革命的架构革命。

尽管大规模生产仅能在昂贵的工厂中实现,但有一些公司足够大,可以定期制作新掩膜并生产实验芯片。这些大公司现在有时允许研究人员和爱好者通过将他们的设计包含在掩膜和晶圆的未使用角落里,免费或低价地制造自己的真实 ASIC(例如,* developers.google.com/silicon *)。最近也有进展,允许创客使用开源硬件方法在自家车库中制造更简单的芯片。Sam Zeloof 开创了这一方法,并在 2021 年成功地将 1,200 个晶体管放置并连接到一颗芯片上——大约是英特尔 4004 所使用晶体管数量的一半。

开放性问题在云计算领域也变得日益重要。目前,有关从桌面计算——每个人都拥有自己的计算机——到 2020 年代云计算的转变,存在显著的担忧,云计算的计算机由少数几家大而强的公司拥有。这引发了一些问题:谁将控制这些计算机及其上的数据?用户如何确保他们的计算和数据不会被这些公司或其他方窃取或转售?

这些问题可能推动新的架构趋势。开放云概念提议用普通公民家庭中的机器组成的共享、松散、去中心化的联邦网络来取代专门计算中心中的企业云。每个人的家中都会有一个小型、始终在线的服务器,它介于高端路由器、NAS 驱动器和 Intel NUC 之间。这些服务器将使非技术性的家庭互联网用户能够轻松托管自己的网页和媒体流。它们还将使完全开源的搜索引擎(YaCy)、社交媒体(Mastodon)、视频存储和流媒体(PeerTube)、视频会议(Matrix)和实物商品市场(OpenBazaar)通过分散计算并使用加密方法和货币来确保信任,从而取代大型科技公司。这一概念的实际软件分发已经在 FreedomBox 网站上发布,你可以在今天的 Raspberry Pi 上运行来实现其中的一些功能。可能需要新的架构来优化这些使用场景。

虽然黑客和制造者现在可以使用这些很好的工具,但拥有巨大资源的大公司并没有停下脚步。它们继续开发更小、更先进的系统,以保持领先地位,正如我们接下来将看到的那样。

原子尺度晶体管

我们在第四章中看到,摩尔定律在时钟速度方面已经结束,但摩尔定律在硅晶体管密度方面依然有效。不过,密度法则也不会永远持续下去,因为我们将会遇到一个临界点,届时晶体管将与原子大小相同,而此时半导体的尺寸将无法再缩小。当我们接近这个点时,量子效应也会开始发挥作用,导致关于物体位置和其含义的固有不确定性。摩尔定律关于密度的预示是,这一现象大约将在 2060 年发生。

目前,IBM 可以操控单个原子形成简单的形状。例如,图 16-1 显示了一张电子显微镜图像,图中铜表面的每个点都是一个单独的原子,使用他们的技术进行放置和读取。

图片

图 16-1:IBM 操控单个原子创建图像

这幅图像模糊且呈波浪状的特性源于量子效应。在这个尺度下,原子的位置和它们的运动方式变得固有地不确定。这些原子目前尚未作为晶体管或计算机使用,但它们可以用于数据存储,例如,IBM 最终希望将这项技术发展为基于单个原子的计算。

在我们达到这个尺度之前,但在传统半导体达到基本尺寸限制之后,像碳纳米管和石墨烯这样的纳米技术可能会被用来制造更小的晶体管;这是目前的研究领域之一。2022 年,清华大学的研究人员制造了一种大小相当于单个碳原子的石墨烯晶体管,其运行速度比硅晶体管快上百万倍。

3D 硅架构

传统的芯片布局是 2D 的,需要大量的图论和复杂性理论来优化设计并最小化布线。正如我们在图 4-19 中看到的,当前的 CPU 可以通过几层重叠的铜线制作,其 3D 结构大大减少了布线。现代芯片仍然将晶体管放置在芯片的单层底部,但允许在其上形成几层(通常为 2 到 10 层)导线,导线之间通过填充材料隔离。

今天的基础层叠技术有可能逐步发展,添加越来越多的导线和晶体管层,最终从 2D 硅芯片过渡到完全的 3D 硅立方体。

然而,硅立方体将会在电源供应和热量处理方面产生问题,需要类似大脑血液供应系统的东西,围绕计算元素来输入能量并将热量从密集的 3D 结构中排出。目前我们还不知道该如何实现这一点。芯片设计界一直专注于 2D 布局问题,以至于不清楚如何转向 3D 思维。

RAM 通常比处理器有更低的使用率和热量需求,因为在大多数时间里,它只是在串行计算机中处于空闲状态。因此,制作 3D RAM 比制作 3D CPU 要容易。近期已有商业尝试做 3D RAM,如美光的混合内存立方体。

3D CPU 设计的一个灵感来源可能来自今天的Minecraft游戏社区。Minecraft可以充当一台强大的计算机,使用其红石元素作为开关。粉丝们已经在其中构建了几个功能齐全的 CPU 组件,类似于图 4-19,甚至还有完整的 CPU,如“ANDROSII”。与以前的世代不同,这些玩家在Minecraft的固有三维性中成长,因此他们不再将处理器布局在 2D 电路板或集成电路中,而是本能地发展出固有的 3D 架构来优化布局,完全摆脱了制造限制和硅工业中固有的 2D 思维。

10,000 年记忆

当你去世后,你的数据会发生什么?会有人能够在数千年后读取你的文件或查看你的录像吗?甚至是在 10 年后?

我们之前看到的 4,000 年前的泥板(图 1-5)至今仍然完全可以阅读。相比于泥板,纸张在速度和容量上是一次进步,但它的保存时间较短。随着存储技术的发展和微型化,它变得更快,容量更大,但也牺牲了耐用性,无论是对物理腐蚀的抵抗力,还是对“位腐化”或其他技术不兼容的抗性。我们看到的所有三级和离线存储选项,在 100 年后都会衰退。商业数据中心通过不断将数据复制到新的物理介质上来保持数据“活着”。旋转硬盘会损坏并被更换;磁带和光盘会退化并被更换。但这依赖于人类维护者的持续关注,这些维护者由一家依然存在且不会破产或被新老板收购的公司雇佣,而新老板又不想继续维护它。

目前的研究工作正在努力寻找像泥板一样耐用,但能够适应现代数据规模的长期存储选项。M-disc 是一种新的光盘格式,向后兼容 Blu-ray,声称可以将 100GB 数据保存 1,000 年。2018 年,Arch Mission Foundation 将一张 DVD 大小的镍盘存放在月球表面,盘中包含了维基百科的完整备份以及其他一些被认为在地球发生全面数据丧失时能够帮助重启人类的文档。他们声称这项存储可以持续至少 10,000 年。南安普顿大学开发的玻璃激光纳米结构技术,可能在 1 英寸立方体的硬玻璃中存储 350TB 数据,且寿命可达 140 亿年。这一概念类似于你在玻璃奖杯中看到的 3D 刻痕,激光深刻地刻入其结构中。

激光也可能被用来进行计算,就像在光学架构中一样;我们现在将讨论这些内容。

光学架构

我们大多关注的是基于电子流动的计算机。电子有质量,因此它们的速度必须低于光速。为了赋予电子动量,必须提供能量以使它们能够移动。另一方面,光没有质量,因此它的速度比电子快,以光速(约每秒 3 亿米)传播。由于这是宇宙中所有事物的物理速度极限,自 1960 年代以来,研究人员就一直在探讨我们是否可以用光而非电子进行计算。像电流中的电子一样,光也是以离散的单元——光子——存在的,研究如何操控光子的工程学科被称为光子学

光学晶体管

电流的速度与电子本身的速度不同;电流通常在每个电子只移动少量距离的情况下流动,通过推动下一个电子前进。电子通过导线时,它们处于一个复杂的环境中,经历许多碰撞,随机地来回运动。单个电子在导线中漂移的速度因此非常慢,大约是每小时 1 米,而铜线中电流的速度可以接近光速的 90%。因此,天真的期待光速比电子计算快似乎过于乐观;为了仅仅提高 10%的速度,从 90%到 100%的光速,全盘更换硬件技术似乎并不太有用。

然而,光学系统有不同的优势:由于光传播中的噪声较低,相比电子,光学系统具有更高的吞吐量和更低的能耗。这就是为什么我们已经使用光来进行常规的高带宽、长距离网络通信——即光纤。光学计算似乎并不那么遥不可及,尤其是当你记住你大部分的互联网和电话流量已经通过光纤在全球传输时。

仅仅是信息传输与实际计算的区别在于,计算中,数据元素需要通过某种类似晶体管的设备进行物理互动,从而构建逻辑门和其他架构层级。然而,光学计算的关键问题在于,光子不会自然相互作用。在物理学术语中,光子是玻色子而不是费米子,这意味着如果两个光子“碰撞”,它们会直接穿过彼此,而不是相互反弹。这对于光通信来说是好的,但对于光计算来说就不太合适。

要制造光学晶体管,我们需要某种形式的电光混合技术,其中光子可以与电子相互作用,反之亦然,以执行计算。然而,光子与电子之间的能量传递是缓慢的,而且会消耗能量。目前,这样的设备存在于大型光子实验室中,使用激光和精密设备在光学工作台上搭建。这些系统占据整个房间,仅实现了几个混合的光电晶体管。它们的规模让人想起 20 世纪初的早期电子计算机。但像那些大型电子计算机一样,研究的目标也是在基本原理确定后将它们微型化,可能通过类似于传统电子技术中使用的光刻(芯片掩膜)工艺;目前的计划是使用硅作为电子基板,类似于传统芯片。

光学相关器

光学相关器(或4f 系统)是光学计算的一个特殊案例,近年来变得非常实用。与其说它是“强大的教堂计算模型”,不如说光学相关器用于一个单一目的:实现并加速一个单一算法,离散傅里叶变换(DFT)。DFT 将空间和时间序列数据流(例如声音和视频编解码器中的数据)转换为基于频率的表示。它使用这个方程:

Image

对于音频信号,DFT 的结果大致对应于生成信号的基础频率。对于图像和视频,它们大致对应于在识别和压缩中有用的不同纹理。这是一个如此基础的操作,且使用如此广泛,以至于值得使用专用硬件进行优化,正如目前许多 CISC 和 DSP 指令所做的那样。

DFT(离散傅里叶变换)的一个关键计算特性是它加速了卷积(或滤波)的常见操作。对于一维信号,卷积被定义为:

Image

这里,N 是信号 y 的长度。直接实现这个方程会导致 O(N²) 算法,尽管基于数学上等效的方程重排,快速傅里叶变换(FFT) 是一个更快的 O(N log N) 算法。FFT 是已知的最快的离散傅里叶变换实现方法,适用于串行计算机;它被称为“我们这一代人最重要的数值算法”。

源域中的卷积相当于傅里叶域中的乘法。因此,与其在原始域中卷积两个原始信号,不如同时对它们进行傅里叶变换,乘积这些变换,然后使用最终的 DFT 将其转换回原始域:

Image

当一束激光光线通过一个微小的孔时,它会被衍射,在另一侧产生衍射图样。可以证明,如果这束光信号通过一个透镜,并且透镜位于距离图像焦距 f 处,那么在透镜的另一侧同样距离 f 处,会形成一个图像,这个图像恰好是原始图像的 DFT。这是衍射和透镜数学的一个意外且巧合的特性,但一旦发现,它提供了一个超快速的、O(1) 的物理设备,能够以光速计算 DFT。

一旦我们得到了这个傅里叶图像X,就可以通过与滤波器Y的离散傅里叶变换(DFT)逐点相乘来实现卷积,时间复杂度为O(1)。我们在离线时预计算Y并制造一个物理滤波器——就像放置在舞台灯光前的彩色滤光片,用来改变灯光的特性。对于大多数数字信号处理(DSP)应用,如视频处理,我们希望在快速的序列中将相同的滤波器y应用于多个图像x,因此我们只需计算一次Y。通过这个物理滤波器传递光图像X的效果,相当于将其乘以Y,这在原始领域中等同于卷积xy。离散傅里叶变换(DFT)是自反的,因此为了获得最终的卷积结果,我们将图像通过第二个镜头,该镜头具有相同的焦距,再次位于输入和输出距离f的地方。最终结果可以视为位于原始输入距离 4f的位置(因此称为4f 系统)。完整的系统,如图 16-2 所示,以O(1)时间计算固定Y的整个卷积,速度为光速。

Image

图 16-2:一个 4f 滤波器结构

这种结构自 1960 年代以来就已为人所知,但直到最近才通过借助资金充足的商业智能手机屏幕技术变得实际可行。它需要通过非常小且高分辨率的图像来过滤激光光线,既要创建初始输入图像x,也要创建可变的滤波器模式Y空间光调制器(SLMs)是一种类似于 4K 智能手机显示屏的显示技术,最初是为高端数字幻灯片投影仪设计的。这些投影仪的 SLM 几乎可以直接拿来使用,用于创建快速、高效的输入和滤波显示,适用于 4f 滤波器。为了完成设置,还需要一个图像传感器来读取最终卷积图像。智能手机数字相机 CMOS 传感器的开发几乎与显示技术对称,提供了所需的相似分辨率和帧率,并且也可以直接使用。以本文撰写时的技术水平,这些组件构建的系统可能使用 4 百万像素,并以 15 kHz 的帧率运行。

光学神经网络

实际的光学相关器与深度学习共同出现,后者彻底改变了商业机器学习。到目前为止,深度学习一直是运行 1970 年代的神经网络算法,依赖于快速的并行 GPU 架构。然而,在许多情况下,使用光学相关器可以大大加速这些过程。这是因为许多问题,尤其是图像和视频中的物体识别,具有空间不变的结构,这意味着图像的特性不会因为观察图像的不同部分而有显著变化;类似的物体会出现在图像的各个位置。这种结构使得卷积神经网络(CNNs)可以在网络每一层的所有节点中使用相同的权重。数学上,这种效果是,每一层网络可以看作是用一个单一的权重向量对该层的输入进行卷积。因此,计算这些卷积成为这些神经网络的主要工作操作。

光学卷积神经网络(CNN)首次的实际演示出现在 2018 年,英国公司 Optalysys 目前正在通过生产消费级光学相关器原型来实现这一技术的商业化,如图 16-3 所示。

Image

图 16-3:光学相关器 PCIe 卡

该设备现在可以插入桌面 PCIe 插槽,替代 GPU 用于深度学习和其他应用。

DNA 架构

从 2000 年左右开始,实验室开始研究DNA 计算,作为通过大规模生物并行性解决复杂计算问题的一种方式。DNA 分子,如同在活细胞中发现的那样,可以被视为(根据对表征的理解)执行计算,并且已经证明它们可以编码并高效地解决计算上 NP 难的难题,例如旅行推销员问题。为了理解 DNA 计算,我们需要一些背景信息。

DNA(脱氧核糖核酸)是地球上生命的“源代码”。在细胞生物体中,每个细胞包含一套完整的整个生物体代码(基因组),并以大分子的双螺旋结构(染色体)存在于细胞核内。

DNA 分子中编码的信息的部分小片段(基因)被复制(转录)到 RNA(核糖核酸)分子上,RNA 分子随后离开细胞核,形成特定蛋白分子构建的工作站。这些蛋白分子构成了生物体的实际形态。这个过程被称为分子生物学的中心法则:DNA 制造 RNA;RNA 制造蛋白质。

DNA 双螺旋梯子的每一阶由一对匹配的核苷酸构成,这些小型有机分子(大约 20 个原子)有四种类型:A、T、C 和 G。每种类型都有一个配对伙伴:A 和 T 配对;C 和 G 配对。人类有 23 对染色体,共包含大约 30 亿对核苷酸。因此,DNA 使用基数为 4 的数据表示法,符号为 A、T、C 和 G,人类基因组的源代码约为 6 吉比特。这与操作系统的大小相似,而且像操作系统一样,人类基因组已被分发到一张单独的 CD-ROM 上。

DNA 技术曾经非常昂贵;例如,在 2001 年,测序第一个人类基因组花费了 1 亿美元。但近年来,其价格迅速下降,2015 年降至 1,000 美元,2023 年降至 100 美元。这一价格下降意味着现在是考虑将 DNA 作为计算介质的好时机。

合成生物学

与自然界中使用 DNA 存储用于制造蛋白质的源代码不同,合成生物学家可以利用 DNA 来表示、编辑、选择和复制任意数据。这使得可以使用 DNA 数据表示和处理来构建“教堂计算机”。

DNA 的 ATCG 字符串可以通过剪切、拼接和插入符号来编辑,就像在 ASCII 文本编辑器中一样。这是通过使用定制的酶来促进所需的反应来完成的。现在,一小部分这些酶已为人熟知,并且可以常规使用来执行这些操作。

至于 DNA 链本身,现在令人惊讶的是,生产你自己任意序列的 DNA 已经变得异常容易,这些序列可以用来以 ATCG 符号的字符串形式在基数为 4 的系统中存储和计算。现在几乎可以在家中完成这一操作,方法是使用经过改装的消费级喷墨打印机,其中的青色、品红、黄色和黑色(CMYK)墨水被 ATCG 分子溶液取代。DNA 制造也可以在工业化化学规模上进行,制造大量相同或相关的分子,液体的体积可以达到一个游泳池的大小。想象一下,一杯水中大约包含 10²⁴ 个水分子,这比世界上所有数据位还要多。

信息可以通过电泳从物理 DNA 中读取,电泳是用于犯罪现场调查中的 DNA 指纹识别的相同技术。聚合酶链式反应(PCR)也提供了一种方法,从大量不同的 DNA 链溶液中选择并复制某一特定 DNA 链,这相当于从字符串中提取子字符串。

DNA 计算

在计算上,PCR 提供了一种快速搜索算法。如果我们能够制造一种液体,包含数十亿条链,每条链编码计算问题的不同候选答案,那么我们就可以使用 PCR 快速筛选并读取正确答案。

PCR 是一种链式反应,意味着它会持续进行,并且随着时间的推移呈指数级扩展其效果。如果混合物中仅包含一个含有搜索字符串的 DNA 链,那么该链会被复制,然后每个副本也会被复制,依此类推,直到几乎整个液体中充满了数十亿个答案的副本。这意味着,通过电泳分析的液体样本几乎肯定会显示出所需的结果。

1994 年,Leonard Adelman 成功使用 DNA 计算解决了七城市旅行推销员问题。旅行推销员问题是一个经典的 NP-困难问题,要求找到一条最短路线,使得某人可以访问每个 N 个城市并返回家中,给定它们之间的距离矩阵。Adelman 用一短 DNA 字符串表示每个城市的身份,然后将这些标识符连接起来表示路线。

与标准旅行推销员问题的表述一样,最短路线问题被重新表述为 O

(n) 一系列布尔问题,形式为“是否存在一条长度小于 n 的路线?”这个问题,以及城市之间的距离度量,被编码为引物,只与表示具有所需属性(即长度小于 n)的路线的 DNA 链结合。对于每个 n,准备了一种化学溶液,其中包含每条可能路线的多个副本,在一个人类规模的槽中。引物被混合其中,然后应用 PCR 放大任何成功的结果。使用电泳读取结果。这种方法能够为 N = 7 个城市找到最短路线。

这并不意味着 DNA 计算机上 P = NP;随着时间的推移,这是 O (n),但仍然需要按分子数量呈指数级的资源。只是 DNA 中有很多分子可用。因此,DNA 能够解决比其他技术更大规模的 NP-困难问题,但像所有技术一样,仍然会有由于 NP-困难的特性而导致问题规模过大的情况。

当前的研究正在尝试将 DNA 计算架构从生物实验室中的槽中转移到微型化的生化芯片上,这些芯片将更像正常的硅计算机运行。DNA 计算似乎不太可能取代电子学来处理日常计算任务,例如运行桌面应用程序,但它可能作为科学计算中的协处理器,在解决大型、困难的计算问题时变得有用。

神经架构

神经科学自至少约翰·冯·诺依曼的EDVAC 草案报告以来,便对计算机架构产生了重要影响,该报告直接从许多神经学的思想中获得灵感。硬件神经网络已经研究了几十年,但 2010 年代它们随着用于深度学习的 GPU 的出现而快速发展。在 2020 年代,NPUs 开始出现在移动电话和云端,专门用于机器学习。计算神经科学研究仍在继续,可能会激发出根本不同的计算机架构,超越目前深度学习中使用的神经网络。像所有计算机架构一样,我们将在多个层次的层级中考虑大脑的架构,从其晶体管的等效物,到神经元(脑细胞),再到其计算机设计的等效物。

晶体管与离子通道

回想一下,晶体管是一种数字开关,现代芯片中直径大约为 10 纳米。它有输入和输出,如果你激活开关,电流就会在它们之间流动。我们已经看到,晶体管通过平衡几种化学和物理力来工作,开关通过打破这一平衡来允许电流流动。晶体管(以及一般的芯片)是由以硅为基础的半导体制成的,硅与邻近的原子形成四个化学键。真正理解晶体管需要化学和量子力学的知识。

脑中类似于晶体管的结构不是神经元,而是离子通道,它是神经元的一个子组件,如图 16-4 所示。

Image

图 16-4:离子通道的闭合状态(左)和开放状态(右)。配体(3)与通道(1)结合,打开通道,允许离子(2)通过。

离子通道是单分子数字开关,直径约为 10 纳米,由蛋白质构成,嵌入神经元的膜中。根据其开关状态,它们要么允许某些化学物质在神经元的内外之间流动,要么不允许。它们的开关状态由电力和化学力的平衡决定,当另一个化学物质与离子通道结合,或者当电压施加到通道上时,这一平衡可能被打破。

离子通道(以及大脑整体)是基于碳的,碳与邻近的原子形成四个化学键。与晶体管类似,真正理解离子通道需要化学和量子力学的知识。

逻辑门与神经元

神经元(图 16-5)通常被认为是大脑中计算的基本单元。

Image

图 16-5:神经元

神经元的直径约为 1 微米。从计算角度来看,它们由许多离子通道构成。它们还拥有许多其他支持其存在和能量需求的细胞结构。它们像盒子一样,接收多个数字输入并产生一个数字输出,稍微类似于图 6-2 中看到的多输入与门。多输入与门的功能可以通过布尔代数方程式来数学化表示:

Image

这里,b 是布尔“求和压缩函数”:

b (x) = (xN)

诸如多输入与门这样的逻辑门是时钟控制的,因此它们的输入和输出仅在短时间内有效,直到下一个计算开始。

在简单的计算模型中,通常用于当前机器学习神经网络—并像第十五章中的 GPU 内核那样编码—一个单一神经元的功能假定由以下方程给出:

Image

这里的 w 是在学习过程中调整的权重值,f 是相同的压缩函数:

f (x) = (xN)

这个符号表示法假设其中一个输入被永久设置为 1,而不是包含任何实际数据。这个特殊的输入被称为 偏置附加 输入;它是大多数神经网络模型正常工作的必要条件。

神经元通常会“激发”短时间,因此它们的输入和输出仅在短时间内有效,直到下一个计算开始。与逻辑门不同,神经元通常会有大量噪声,这可以通过向其输入添加随机数来建模。一些模型认为这些噪声是其计算中的一个重要概率性因素。

这是一个非常简单的神经元功能模型,与其密切相关的模型在当前的机器学习应用中表现良好,比如我们在第十五章中构建的 f = reLU GPU 神经元。然而,真实的生物神经元有数百种不同的形态和大小,可能具有更复杂的行为,这些行为被认为包括更复杂的计算,如求和、乘法、除法、指数运算、对数、时间记忆和过滤。这个学派强调神经元作为完整的活细胞和计算单元的复杂性,并提醒我们其他单细胞生物如细菌和海绵细胞所执行的复杂计算。

铜线与化学信号

让我们将大脑的线路与芯片的线路进行比较。在芯片中,我们首先通过光刻技术在二维平面上铺设一层层的晶体管。在现代芯片中,我们接着在晶体管上方铺设几层重叠的铜线,以便它们之间相互连接,正如我们在图 4-19 中看到的那样。通过这些线路的通信非常快速且精确,因为它完全是电气传输。信息是数字化的,这意味着线路的电压要么高,要么低,可以看作代表 1 或 0。

神经元通常是长的延伸细胞,包括一个长长的轴突部分,它充当信息传递的电线。人类的轴突长度从 1 微米到 2 米不等——最长的是连接你的脚趾和大脑的轴突。通信过程缓慢且噪声较大,因为信息沿着轴突传递时需要通过复杂的生化过程,这个过程中离子通道的开闭将化学物质带进或带出细胞。当轴突的末端与另一个神经元连接(在一个叫做突触的接点处)时,发生第二个生化过程,第一细胞释放的化学物质进入第二个细胞。信息是数字化的:轴突要么放电,要么不放电,这可以看作代表 1 或 0。从架构角度看,一个完整的神经元就像一个逻辑门,只有一根长输出线。

简单机器与皮层柱

下一个架构层次是简单机器层次。在人工设计的计算机中,简单机器由若干逻辑门组成,共同执行某个单一的有用功能。有许多不同类型的标准简单机器,如加法器、解码器和寄存器,每种机器都专门用于某一特定任务。典型的简单机器可以通过 TTL 芯片布置,就像我们在图 5-13 中看到的那样。

这是大脑架构中理解最少的层次,因此也是科学研究中最激动人心的话题。一些研究者认为,人类的皮层完全由重复的皮层柱微电路组成,每个微电路由几百到几千个不同类型的生物神经元实例构成,这些神经元占据直径约 20 微米、深度为 2 毫米的圆柱形结构。

形成皮层柱微电路的神经元排列在六个不同的皮层层次中,并且总是以相同的特定方式连接,正如在图 16-6 中所示。我们了解不同类型的神经元群体之间的连接,但并不了解单个神经元之间的连接方式或这些连接的权重。图 16-6 中的结构与图 6-22 中看到的 RAM 在某种程度上有一些表面上的相似性。

Image

图 16-6:皮层微电路架构

一些计算机科学家推测,这些微电路可能作为概率计算或其他计算的构建块。模块电路的精确接线仍不明确,需要脑成像技术的进步,才能运行“调试器”来了解它究竟在做什么。与数字逻辑微电路不同,似乎只有这一种皮层微电路,它在整个皮层中都被使用。逆向工程皮层微电路是 21 世纪最大的科学挑战之一。这需要计算机架构师和他们的经验来帮助建议计算功能,生物神经科学家来收集数据并与生物学知识相结合,物理学家来设计能够看到这些数据的新实验设备。破解其密码的人,可能会获得诺贝尔奖。

芯片与皮层

在结构的最高层次,皮层与芯片惊人地相似。这是因为它们都布局在二维平面上,并由多个相互独立的模块组成,这些模块之间有连接。对于芯片,我们习惯于看到像图 11-5 这样的二维布局。而对于大脑来说,这一点不太明显,因为皮层的二维薄片像一张废弃的纸一样被揉成三维形状。不过,它可以很容易地被展开,平铺在二维表面上,展示出其真正的结构(图 16-7)。正是这张薄片包含了前面提到的六层微电路。

Image

图 16-7:皮层看起来是三维的(左),但展开后它变成了二维薄片,就像纸页或硅芯片(右)。这些模块,称为布罗德曼区域,用数字标注。

皮层的模块被称为区域,大多数已与特定功能和活动相关联,比如视觉、听觉、触觉和规划。在每个模块内,连接性总是遵循皮层微电路架构,并且(可以说)有柱状结构,(可以说)每一列内的连接性较强,而列与列之间的连接性较弱。大多数模块有大量的轴突束,将输出信息发送到其他模块。投射总是从这些模块的相同层发送并接收,作为微电路的一部分。我们知道哪些模块向其他模块发送输出,但并不清楚它们内部哪些神经元之间有具体的连接。

这与芯片架构非常相似,芯片通常也有数十个模块化组件,每个组件内部有强连接,而组件之间的连接则是有限的束状连接。现代芯片有几层 3D 打印的铜线,将组件连接在一起,共享大脑的基本 2D 布局和 3D 连接,将不同区域联系起来。然而,一个重要的区别在于,大脑的所有组件区域共享相同的层级和柱状结构,而芯片的组件区域通常包含完全不同的设计。

并行计算与串行计算

想想 CPU 或大脑皮层中模块是如何连接的。CPU 本质上是串行机器,设计用来按顺序执行指令程序。因此,CPU 有一个明确的设计层级“顶端”,即控制单元,作为执行单元,告诉其他模块该做什么以及何时做;我们在图 7-13 中看到过这个。

大脑皮层也有层级结构,额叶区域被认为与执行控制相关,而后部(后方)区域更多与感知和行动的执行有关。每种感觉的感知和行动(如视觉、触觉等)被认为由一系列层级区域组成;例如,低级视觉区域处理边缘和角落,高级区域则识别面孔和命名的人物。这些区域都是并行工作的,并且它们由并行运行的柱状结构组成。额叶区域似乎协调整体活动,但当额叶区域受损时,感知和行动区域仍能独立运作。

这些模块都不会主动工作,除非由丘脑触发,丘脑在这个上下文中看起来和作用类似于一个 CPU 控制单元,可以在图 16-6 的下部看到。

虽然模块们直接传递信息,但它们也与丘脑的区域进行通信,丘脑似乎镜像了这些区域的结构,并起到开关它们并解决它们之间冲突的作用。

当你反思自己在计算复杂的高级感知和行动规划问题时的主观经验时,可能会发现你的大脑像一个串行机器一样运作,按顺序想象和测试不同的假设和行动。这一观察可以通过更客观的证据得到支持:在实验室中,其他人执行这些任务时通常需要O(N)的时间。然而,从内部来看,我们也认为大脑是一个大规模并行系统,所有神经元可能同时都在工作。这与最初把 CPU 看作一个串行处理器,然后再将其看作一个大规模并行的数字逻辑电路类似,在这个电路中,所有数十亿个晶体管也可能同时工作。大脑和 CPU 外部都拥有外部模块,无论是通过脊髓还是总线连接。

海马体是皮层中的一个特殊部分:它位于层次结构的最顶端,其微电路与其他部分稍有不同。海马体没有处理数据并向更高层发送数据的皮层层次,而是有不同的层次,分别称为 DG、CA1 和 CA3,这些层次包含反馈连接,将通常是输出的部分连接回自身。与其将计算结果发送到更抽象的处理层次,不如将其通过时间传送到皮层中的同一、功能上最高的区域。基于海马体的计算架构已经被开发出来,假设它作为一种时空记忆的形式。这些架构使机器人能够导航并绘制周围的空间和物体。

在电子时代,建筑师一直被大脑所吸引并受到启发。当前对深度学习的兴趣将这些联系带入了主流架构,例如现在许多手机中都配备的神经处理单元。这些架构大致上基于神经元模型和层次化的皮层区域。但正如我们所看到的,真实的大脑包含了更多复杂性——离子通道、皮层微电路和从并行结构中涌现的串行计算——这些可能为未来的进展提供灵感。哲学家们常常争论,任何基于硅的脑结构模拟是否能够完全复制人类的智能或意识。反对者通常会引用一些物理学特性,这些特性在硅中通常并不出现,比如量子效应。然而,计算机科学家们也开始探索在计算中应用这些效应,我们将在接下来的部分看到。

量子架构

量子计算基于量子力学的物理学,而量子力学以其著名的奇异性和反直觉性为人所知。在量子力学中,物体不再具有精确的位置或速度;相反,它们以波动状态存在,涵盖了许多可能的位置和速度。这些状态定义了在你观察物体时,物体出现在某个位置或速度的概率。量子概念真是令人震撼,并且会从根本上改变你对现实、因果关系和时间的整体看法。

量子力学或量子计算的完整介绍超出了本书的范围。在这里,我只能提供一些概念的概述,并简要展示基本方程的样子。然而,值得指出的是,现代量子计算可以在几乎不参考物理学中通常介绍的量子力学的情况下进行研究,这使得该领域相对更易于接触。特别是,计算机科学主要是离散的,处理的是 0 和 1 以及加法,而不是量子力学中典型的连续实数和积分。量子计算中使用的离散化数学只需要高中线性代数、矩阵代数、复数和概率。

量子力学的卡通版

以下内容不是量子力学的正确展示,仅作为卡通形式介绍一些关键概念。

假设世界中的物体不仅仅在某一时刻存在于单一状态。例如,盒子里的猫可能同时既站着活着,也躺着死了。这个著名的例子被称为叠加态猫。假设这只猫被锁在盒子里,旁边还有一块放射性物质。放射性物质的衰变是完全随机的:它的行为无法以任何方式预测。在它旁边放置一个辐射衰变探测器,并与一个机制连接,该机制在探测到辐射时释放毒气到盒子里,杀死猫;如果没有探测到辐射,猫则仍然活着。

你将这个实验装置放置一旁,比如说 10 分钟。你可能对辐射的强度有所了解,因此可以说在 10 分钟后,某种概率(比如 20%)下发生了衰变,猫已经死了,另外某种概率(比如 80%)下衰变没有发生,猫还活着。我们可能会用以下分布表示猫的当前“状态”:

= {活着 : 0.8, 死了 : 0.2}

按经典物理学的理解——即不考虑量子力学——你通常会认为这个分布是你自己知识的属性,而不是世界的属性。你会假设猫实际上只处于一个状态,要么活着,要么死了。只是你的大脑不知道是哪种状态,因此(你的大脑)包含一个模型,带有这两种状态和它们的概率。

在量子力学中,情况绝对且明确地不是这样。猫的两个版本不仅仅存在于你的脑海中,它们在某种意义上也都真实地存在于这个世界上。大致而言,我们想象有两个现实版本——一个是猫活着,另一个是猫死了——它们共同存在,直到你打开盒子的那一刻。当那个时刻到来时,现实“决定”哪个状态将成为实际状态,随机地但根据概率,另一个状态则永远消失。我们说,你观察猫的行为改变了它的状态,从两个版本的存在转变为单一版本的存在。

现在我们有了基本的概念,让我们来看一下数学版本。你不需要理解本节其余部分中所有的数学符号、技术术语或命令。如果你熟悉线性代数和复数,那么你可以跟随细节,但如果不熟悉也没关系,只需大致浏览一遍,感受一下这个领域的风貌。

量子力学的数学版本

量子力学的正确表述由四条规则组成:叠加、观察、作用和组合。

叠加规则

物体存在于状态的叠加中,每个状态都有一个复数幅度,其平方模长的总和为 1。例如:

Image

在这里,Image和向量的行表示猫死(二维状态 0)和活着(二维状态 1)的幅度,分别是:

观察规则

当你在某一基底中观察状态时,它们会根据其平方幅度的模长随机地坍缩为观察基底中的某个基态。例如:

Image

这些结果始终是介于 0 和 1 之间的实数,表示观察每种可能性的概率。

作用规则

对系统进行的任何物理操作,包括计算—除了观察—都可以通过一个幺正矩阵来建模。该矩阵通过普通的矩阵乘法作用于状态。例如,一个 NOT 门的操作通过一个矩阵来建模,该矩阵交换“死”和“活”状态的幅度:

Image

在这里,像所有幺正矩阵一样,NOT 矩阵保持了状态向量的特性,即根据观察规则,它们的概率总和为 1。

组合规则

将两个物体一起考虑时,它们的状态是由它们的张量积形成的联合状态:

Image

量子力学和量子计算中使用的 |⟩ 符号称为 Ket 符号。对于离散计算机科学家而言,这仅仅表示一个列向量,在其他学科中,有时用下划线、箭头或粗体来表示。这个名字来自对单词 bracket(括号)的双关语。内积有时写作 ⟨a| b ⟩ = a^Tb,这称为“括积”(braket)。如果我们写 ⟨a| = a ^T 和 |b⟩ = b,那么我们可以将这两个新符号称为“bra”和“ket”,它们合起来就是“braket”。

量子寄存器与量子比特

我们能否利用平行世界中相互作用的平行现实的表面存在,作为一种平行计算的形式?如果我们能像在单一现实中分配多核 CPU 那样,把计算工作分布到平行现实中,并且找到一种方法将这些结果合并到我们自身所在的现实中,那么我们就可以利用这些平行现实中的巨大额外计算资源。我们可以构建一个单一的 CPU,让它在这些平行现实中同时计算许多事情,而不需要构建多个 CPU。这个思想最早由理查德·费曼在 1988 年提出,是量子计算的开端。

考虑叠加态的猫。我们可以利用猫的生死状态作为 1 位数据表示,通过编码 dead = 0 和 alive = 1 来表示。我们称之为 量子比特qubit)。然后,我们可以通过将 N 只这种猫放在盒子里排列成一行,来建立一个 N 位寄存器,用来存储数据,就像基于触发器的经典寄存器一样。在打开寄存器中的盒子之前,存在多个现实,其中猫可能是生的,也可能是死的。当我们打开盒子时,我们只能看到一个现实版本,并且这成为了我们自己所经历的现实。

类似于经典寄存器,N 比特量子寄存器有 2^(N) 种可能的状态。整个寄存器的每一种状态都可以同时存在于一个“平行世界”中。这是比单纯的 N 只猫更多的状态集合。

你可以通过使用量子计算机模拟器(例如 QCF,量子计算函数)来进行实验。在 QCF 中,你可以从创建非叠加的寄存器状态开始,例如:

>> phi_1 = bin2vec('011')
[0 0 0 1 0 0 0 0]

结果输出显示了一个 3 位寄存器的状态向量,完全处于 011 状态(表示十进制数字 3)。在零状态 000 中的振幅为零;在第一状态 001 中的振幅为零;在第二状态 010 中的振幅为零;在第三状态 011 中的振幅为 1;其他状态(直到第七个状态 111)中的振幅也为零。

QCF 还具有一个命令,可以直接从表示的十进制数字创建类似的非叠加状态;例如,要创建一个完全处于表示十进制数字 5 的状态的 3 位寄存器,可以使用此命令:

>> phi_2 = dec2vec(5, 3)
[0 0 0 0 0 1 0 0]

到目前为止,这些只是一个经典 3 位寄存器能够存在的相同单一状态。接下来,我们可以模拟一个寄存器,它同时处于这两种状态的叠加态中,例如:

>> psi = [1/sqrt(2)*phi_1 + 1/sqrt(2)*phi_2]
[0 0 0 0.7071 0 0.7071 0 0 ]

要模拟对该寄存器的测量(观察),我们可以这样做:

>> psi = measure(psi)

这将随机产生以下两种输出之一,概率由平方幅度给出:

[0 0 0 0 0 1 0 0]
[0 0 0 1 0 0 0 0]

各个量子比特的状态不是独立的;它们是纠缠的。在我们的 QCF 示例中,前两位观察到的比特必须是 01 或 10;它们不能是 11 或 00。因此,如果你最初查看第一位并看到 0,那么当你稍后查看第二位时,你会看到 1,反之亦然。即使量子比特在两次观察之前被物理地传输到数百万英里远的地方,这种关系仍然成立。

我们示例中的寄存器可以被建模为同时存在于八个(即 2 的 3 次方位,3 位二进制数)状态中,跨越一组八个“平行世界”。随着寄存器大小的增加,世界的数量呈指数增长;例如,一个 64 位量子寄存器有 2⁶⁴ ≈ 2 × 10¹⁹种状态,这与 64 位机器的整个地址空间中地址的数量相同,所有这些状态都同时存在于一个寄存器中。

物理学家通常不喜欢用“平行世界”来讨论问题。相反,他们更喜欢“闭嘴并计算”,以预测特定情境的结果:一旦给定了系统进行分析,这一切都只是数学。然而,为了创建新的量子程序,计算机科学家将寄存器的每个状态视为存在于平行世界中是很有用的。以这种方式思考有助于你可视化你正在创建的内容,并为下一步的创造提供灵感。

跨世界计算

状态的幅度,而不是内容,可以以某些方式相互影响,这些影响在量子力学的规则下是非常有限的,从而使得在计算过程中平行世界之间能够相互作用。量子计算中的亿万富翁问题始终是:我们如何读取回结果?我们只观察一个平行世界,且所得到的是随机的,因此我们需要找到机制,确保我们想要看到的结果存在于所有世界中,或者我们观察到的世界恰好是那个包含单一结果副本的世界。

例如,我们可能尝试通过叠加一个寄存器来并行化旅行商问题,以便每个世界都将寄存器的一部分编码为不同可能路线的路径。在每个世界内,我们计算该路径的长度并将其存储在寄存器的另一部分中。然后,我们回答一个问题,比如“这条路径的长度是否小于 5?”并将结果作为一个比特存储在寄存器的第三部分中。但是,我们的任务是回答关于所有可能路径的问题,例如“是否有任何路径的长度小于 5?”这是存储在所有世界中的信息的函数。

找到一种方法,将所有信息集中到一个地方,这样我们就能保证,或者至少有可能,在进行观察时能够看到这些信息,是量子算法设计的难点。根据我们目前的理解,这些方法都会引入巨大的计算复杂度开销。因此(再次强调,根据我们目前的理解),量子计算机无法使P = NP,但它们可以加速NP问题,将其复杂度降低到NP内部。大多数量子算法,如格罗夫算法,通过逐步更新状态振幅来工作,从而使所有不希望看到的世界互相抵消,只有我们希望看到的世界以较大的概率出现在实际世界中。这与 DNA 计算中的 PCR 方法类似,它也通过时间推移来进行计算,以放大所需的解,牺牲其他的解。一些研究人员认为,量子计算机通过这种方法将提供一个通用的加速效果!图像,但仍需要理论来确认这一点。

一些特定问题,例如破解公钥加密,已知由于其结构特别与量子规律匹配,具有更大的加速效应。找到并分类这些特殊案例是当前的研究课题。

实用量子架构

小规模量子计算机,只有几个量子比特,已经成功构建并展示了其概念的可行性。更大规模实用量子计算机的主要障碍是去相干。这是一个问题,任何超叠加系统与外界之间的相互作用都倾向于将超叠加扩展到那个事物上,再扩展到周围的世界。超叠加的量值大致像是一个固定资源,因此一旦它从计算机中泄漏出去,它就消失了,无法再用于计算。量子工程师正在努力设计方法,将量子系统与外界的影响隔离开来。这与核聚变问题有些相似,在核爆炸发生后,我们尝试用磁场来控制和保持它与周围环境的隔离。

绝热量子计算有时在媒体中报道——特别是由 D-Wave Systems 公司报道,该公司已成功将设备销售给谷歌和美国政府——成功地使用 1000 个或更多比特进行量子计算。然而,绝热量子计算并不是我们所讨论的那种量子计算。它是一种基于完全不同数学模型的物理过程,假设时间是连续的,而非离散的,因此在任何给定的时间间隔内可以进行无限次观察。与量子计算完全相反,它依赖于观察(或者在某些观点中是退相干)持续进行,而不是试图将系统与观察隔离开;观察本身构成了实际计算的核心部分。许多量子计算研究人员对此类说法持高度怀疑态度,指出在计算机科学的历史上,有许多自称通过类似假设在有限时间内进行无限计算的模型解决了P = NP问题的“异端”。

罗斯定律被提议作为摩尔定律的量子版本,假设目前在工作中的量子计算机中的量子比特数量每两年翻一番。

未来物理架构

除了目前所称的量子计算之外,我们还可以更广泛地转向现代物理学,问问它发现了什么其他内容,也许可以用于构建计算机器。

我们当前最佳的物理学理论——标准模型,基于量子场论(QFT),将量子力学与特殊相对论(但非广义相对论)结合起来,将现实世界建模为由一组场组成,每个场覆盖空间并相互作用。每个场大致对应一种类型的粒子,和基础量子力学一样,其幅度表示在我们寻找粒子时,在某个位置发现该粒子的概率。与基础量子力学不同的是,这些场还能够表示在不同位置发现多个粒子的概率,而且这些粒子能够相互作用并以各种方式相互转化。

标准模型规定了特定的场和相互作用,形成了一个含有 17 种粒子的量子场论。(更准确地说:这些场是一个规范量子场,包含了单位积群SU(3) × SU(2) × U(1)的内部对称性,17 种粒子类型则作为这些场中的模式出现。)标准模型自 1960 年代以来就已通过实验验证,并且自那时以来没有改变。CERN 于 2012 年确认了最终的希格斯场。现在已知一些异常现象,提示可能会有更好的模型在未来被发现。

像 CERN 这样的粒子加速器已经完善了不仅能够观察,而且能够控制场中个别粒子的能力。不同类型粒子的束流可以可靠地产生,彼此碰撞或与测试物体碰撞,且可以观察到从碰撞中飞出的单个粒子。

因此,粒子物理学催生了粒子工程,在这一领域,这项技术不再用于做科学,而是用于为其他目的构建实用的工程系统。各国政府已经资助粒子物理学研究了许多年,这并不是因为对世界的组成有固有兴趣,而是因为它的武器化潜力。CERN 周围发射的束流可以摧毁它们路径上的任何东西。美国 1980 年代的 BEAR 实验将一个加速器送入太空,能够产生并发射射程极远的束流,尝试摧毁卫星——最终摧毁地面目标——并精确到激光水平。加速器和探测器也被重新利用于治疗脑癌。通过向大脑发射质子束并检测其速度变化,我们可以比其他方法更准确、更少损伤地推断肿瘤的结构。一旦这些结构被识别出来,束流强度可以调高,用来摧毁肿瘤,再次比其他方法更精确。

既然粒子工程已经开始发展,那么自然会有人问,像机械、电气和电子工程那样,它是否可以用来构建新的计算机硬件。未来可能有一天可以使用标准模型中除了电子和光子以外的粒子来存储和计算数据——例如,创建基于希格斯玻色子的计算机。这些计算机可能通过加速粒子来构建,然后利用它们的相互作用形成计算,或许像第五章中看到的那样,使用微观台球逻辑门。

QFT 不是一个完整的物理理论,因为它忽略了引力,而引力则是由爱因斯坦的广义相对论 (GR) 来描述的。GR 与 QFT 不兼容,因为与 QFT 不同,它允许空间和时间发生形状变化,围绕质量弯曲。我们每天都依赖于相对论工程——例如,用于 GPS 卫星之间的时间校正,纠正望远镜图像的扭曲,以及为火星等地的任务校正路径。正如爱因斯坦预测的那样,引力波在 2016 年被观测到,现在它们正成为天文学的新工具。这些效应很小且微妙。相比之下,虽然在理论上可以工程化系统来主动操控和利用弯曲的时空,但它需要天文规模的能量和质量。可能需要几个世纪甚至千年,或者根本无法获得这些能量和质量。哥德尔的“闭合类时曲线”在 GR 中可能会发生,如果时空自我环绕形成一个“虫洞”,成为在时空中的某些点之间的捷径路径,甚至可能实现倒流时间旅行。

在广义相对论(GR)中,观察者可能会看到事件以不同的时间顺序发生,具体取决于他们的位置和运动方式。如果观察者无法就指令执行的顺序达成一致,那么顺序的程序概念就变得有问题,后期执行的阶段看似引起了前期的事件。不同的 GR 观察者时间流逝速度不同,因此,如果我们生活在一个大质量物体附近,我们可以通过将计算机远离该物体来加速它的运行。然而,加速和减速的过程会导致时间流逝变慢,这一点需要与可能的收益相平衡。

超计算理论家声称存在比丘奇计算机更强大的理论机器,它们可以利用 GR 通过观察自身过去在闭合时间曲线中的行为来预测自己的未来行为,从而解决停机问题。这将需要彻底更新我们对计算的概念。

量子场论(QFT)和广义相对论(GR)显然不兼容,因此我们没有一个有效的“万有统一理论”(GUT)来解释现实的结构。目前的尝试包括“弦理论/M 理论”、“环量子引力”和“扭量理论”,但这些理论都尚未成功。其中一些理论假设存在额外的维度。有些理论试图将“引力子”建模为一种额外的粒子,将引力视为与标准模型中的其他力相似的力。这对于计算可能具有兴趣,因为任何引力子必须是零质量并以光速传播,像光子一样,但它们还必须能够相互作用,这与光子不同。这可以避免光速光子计算机的非交互问题。

与此同时,关于星系和星系超级团结构及运动的发现正挑战着量子场论和相对论。观察结果似乎要求要么发明新的“暗物质”和“暗能量”粒子,比如“轴子”,要么用新的理论取代相对论。如果我们发现世界是由超弦、扭量、引力子或轴子构成的,那么我们也可以寻找利用它们的性质来表示数据和执行计算的方法。

总结

一个新的黄金时代的架构正在到来。现在是参与架构的最佳时机,无论是作为用户还是架构师。开源硬件和软件如今使你能够在家设计和构建真正的 CPU,并将其贡献给社区。

从计算机历史的长远视角来看,正如第一章所述,现代集成电路(IC)只是众多计算技术中的一种,它们会不断兴衰。摩尔定律对于时钟速度的终结已经迫使我们转向并行架构,但随着我们达到单个原子和量子效应的尺度,摩尔定律对于晶体管密度的限制也必须结束。这可能迫使我们转向全新的技术。

光学计算受到光子之间不相互作用的限制,尽管在卷积滤波器的特殊情况下,利用其波内的相互作用是可能的,而这些相互作用恰好适合当前的深度学习计算。

DNA 计算似乎不太可能出现在消费者桌面上,但可能在解决大型一次性 NP 难题时找到自己的小众市场。未来,你的大学或公共交通时刻表可能会由一个充满 DNA 的游泳池来优化。

人类大脑继续激发新的架构思想。超越当前的深度学习架构,它可能会带来基于微电路的简单机器的创意,并促使从大规模并行系统中出现串行行为。

量子计算现在已经是一个被充分理解的理论,但关于其艰难实施的研究仍在进行,而且我们对于它能够提供的加速的理解仍然是理论性的。量子计算基于量子力学,量子力学已经被量子场论(QFT)所超越,或许还被大统一理论(GUTs)的尝试所超越。这些理论中的一些仍然是物理学家眼中的闪光点,但正如每一项技术一样,从石块到齿轮到硅芯片,它们也可能有一天成为未来计算机架构的基础。

练习

曲柄加速

如果假设你可以以任意越来越高的速度转动其曲柄,你可以在 1 秒钟的实际时间内解决任何计算问题,使用的是分析引擎。为什么这行不通?这可能告诉我们关于绝热量子计算的哪些声明?

挑战

  1. github.com/charles-fox/qcf 下载 QCF,并按照“量子架构”部分的示例进行练习,查看 第 414 页。

  2. QCF 提供了一个更长的教程,逐步演示如何运行 Grover 算法;请按照这个教程进行操作。

  3. 你认为哪项技术首先会带来实际的新计算机:量子、光学、DNA、神经网络,还是其他?写一篇博客文章阐述你的观点。

进一步阅读

  • 有关 DIY 制造的信息,请参见 Stephen Cass,"车库制造",IEEE Spectrum 55, no. 1 (2018): 17–18。

  • 关于石墨烯晶体管,请参见 F. Wu 等人,"具有小于 1 纳米栅长的垂直 MoS2 晶体管",Nature 603 (2022): 259–264。

  • 3D 集成电路的一个例子是 Vasilis Pavlidis、Ioannis Savidis 和 Eby Friedman 的《三维集成电路设计》第二版(伯灵顿:摩根·考夫曼出版社,2017 年)。

  • 关于 10,000 年存储的详细信息,请参见 J. Zhang 等人,"通过超快激光纳米结构在玻璃中实现 5D 数据存储",此论文发表于 CLEO: 科学与创新大会,2013 年 6 月,美国圣荷西。

  • 关于光学计算的概述,请参见 Jürgen Jahns 和 Sing H. Lee 编,《光学计算硬件》(波士顿:学术出版社,1994 年)。

  • 欲了解带有光学相关器的深度学习,请参阅 J. Chang 等人,“优化的衍射光学用于图像分类的混合光学-电子卷积神经网络”,《科学报告》 8, 第 12324 号(2018 年)。

  • 想了解 DNA 计算的科普介绍,请参阅马丁·阿莫斯,《基因机器:生物计算的新科学》(伦敦:大西洋书籍,2006 年)。

  • 欲了解通过 DNA 计算解决旅行推销员问题的详细信息,请参阅 J. Lee 等人,“利用编码数值值的 DNA 分子解决旅行推销员问题”,《生物系统》 781,第 3 期(2004 年):39-47。

  • 欲了解 DNA 喷墨打印的详细信息,请参阅 T. Goldmann 和 J. Gonzalez,“DNA 打印:利用标准喷墨打印机将核酸转移到固体支持物上”,《生化与生物物理方法杂志》 42,第 3 期(2000 年):105-110。

  • 量子计算的权威著作是迈克尔·A·尼尔森和艾萨克·L·庄,《量子计算与量子信息》(剑桥:剑桥大学出版社,2000 年)。

  • 欲了解量子计算的起源,包括计算中与热、能量和信息相关的问题链接,请参阅理查德·费曼,《费曼计算讲座》(伦敦:Westview 出版社,1996 年)。

  • 欲了解生物神经架构的概览,请参阅拉里·斯旺森,《大脑架构:理解基本计划》(牛津:牛津大学出版社,2011 年)。

  • 欲了解单个神经元执行的众多复杂计算的权威指南,请参阅 Christof Koch,《计算的生物物理学》(牛津:牛津大学出版社,1999 年)。

  • 欲了解单细胞生物执行高级计算的例子,请参阅 R. Lahoz-Beltra、J. Navarro、P. Marijuan,“细菌计算:一种自然计算形式及其应用”,《微生物学前沿》 5,第 101 号(2014 年)。

  • 请参见* ai.googleblog.com/2021/06/a-browsable-petascale-reconstruction-of.html *查看人类皮层微电路连接的互动 3D 视图。

  • 想了解未来物理学的科普介绍,请参阅布赖恩·格林,《优雅的宇宙:超弦、隐藏维度与终极理论的探索》(纽约:Vintage,2000 年)。

第二十章:附录

操作系统支持

Image

本书避免讨论操作系统,以便更清楚地看到“裸机”架构。操作系统是一个独立的研究领域,有专门的书籍。通常是先学习架构,再学习操作系统。然而,操作系统的需求促使架构层面加入了若干特性,而这些确实属于架构书的范畴。

这个附录是为你在学习操作系统时回顾的,它涵盖了两个领域交集的部分内容。我们将回顾操作系统的一些基本特性,然后看看现代架构是如何在硬件层面上支持这些特性的。

并发

操作系统最基本的功能是创建多个用户程序在单个 CPU 上同时运行的假象。执行这一功能的操作系统程序通常被称为内核。由内核运行的用户程序被称为进程

内核轮流运行每个进程一小段时间,然后切换到下一个进程;这种方式叫做周期,这种执行方式被称为并发。这意味着进程看起来像是并行运行的,但实际上是通过时间切片方式按顺序执行的。并发大致上是与并行计算相对的概念。并行计算通常需要多个 CPU,使用它们同时执行一个程序。而并发则使用单个 CPU,在同一时间内执行多个进程。

内核通常使用架构定时器、IRQ 线和 IRQ 回调来控制进程之间以及内核代码本身的切换。在启动时,内核设置一个硬件定时器,定期向 CPU 发出 IRQ。内核还设有一个子程序,我们称之为回调,它在 IRQ 到达时被调用。内核被分配了一组需要运行的进程。它将所有进程加载到内存中,分别放置在不同的位置。然后,它跳转到第一个进程的主子程序,交出控制权让它正常运行。

第一个进程会运行一段时间,然后之前设置的定时器将触发一个 IRQ。IRQ 硬件会检测到这个信号,并将程序计数器的副本存储在某个地方(例如在专用的内部寄存器中),然后将程序计数器设置为回调的地址。

回调函数通常会先在一个为内核保留的 RAM 区域内保存每个寄存器的副本以及先前保存的程序计数器(即不被任何进程使用的区域)。然后,它决定(调度)下一个要运行的进程。最简单的方法是让进程按固定顺序轮流执行。新的进程的保存寄存器和程序计数器状态将被加载到寄存器和程序计数器中。更新后的程序计数器将控制权转交给新进程,直到定时器触发下一个 IRQ 并再次调用回调函数。

内核模式和用户模式

内核可以正常工作,只要进程能够相互信任——即,只要它们只访问各自独立的内存区域。如果进程存在恶意行为,系统将无法正常工作。显而易见的安全问题是,任何进程都可能读写本应由其他进程或内核使用的内存,这可能包括窃取数据、覆盖数据或覆盖代码,包括覆盖内核代码以完全控制机器。

现代 CPU 通过提供两种(或更多)CPU 模式,即内核模式用户模式,从架构层面防止了这一点。在内核模式下,CPU 的所有特性都可以供内核使用,这包括对 RAM 的完全访问。在用户模式下,会强制执行限制,防止访问分配给用户进程的内存区域之外的指令和内存位置。

虚拟内存

现代操作系统不允许用户进程访问彼此的数据或内核的数据。操作系统为每个用户进程提供一个虚拟内存空间,这对进程来说看起来就像是裸机上的内存,与其他进程隔离。例如,所有进程可能都认为它们在使用地址 0x00000000 到 0xffffffff 之间的内存位置。物理内存地址对用户程序来说是不可见的,进程之间是相互隔离的,无法读写彼此的内存。用户程序中的加载和存储指令完全使用虚拟内存地址。

通过利用交换空间,虚拟内存的大小也可以比物理 RAM 大得多,这包括使用次级和主存储器。在这里,主存和次存被划分为标准大小的块,称为页面。缓存被用来根据最近的使用情况在主存和次存之间移动整个页面。

与我们之前看到的硬件 CPU 和 RAM 缓存不同,这通常是一个较慢的过程,至少部分由操作系统软件管理。硬件内存管理单元(MMU)可能会被添加到架构层面,以执行操作系统配置的物理地址和虚拟地址之间的转换。

不同的 CPU 和操作系统组合将以不同的方式使用虚拟内存。例如,一个关键的架构设计决策是是否在不同的 CPU-RAM 缓存中使用物理地址还是虚拟地址。

翻译查找缓冲区(TLB) 缓存是一个专门的缓存,它在架构级别上为操作系统提供虚拟内存实现所用的缓存。它可以作为第三个专业的 L1 缓存,与图 10-12 中看到的指令和数据 L1 缓存一起存在。当用户程序提到一个虚拟地址时,TLB 缓存会查找并将其转换为物理地址,用户无法看到。如果虚拟地址在 TLB 缓存中不存在,TLB 会通过中断请求(IRQ)调用操作系统代码,询问该如何处理。操作系统要么找到所需的虚拟-物理映射并将其添加到 TLB 缓存中,要么如果该映射不存在或不允许,就会给出访问冲突错误——通常称为段错误——如果它不可用或不被允许。如果你曾经在 C 代码中遇到段错误,它就是在这里出现的,当你试图访问未分配给你的内存时。

设备驱动程序

现代操作系统也不允许用户进程直接访问 I/O 地址。相反,它们必须调用被称为设备驱动程序的操作系统子程序,通过操作系统的 API 礼貌地请求 I/O 功能。与其他进程的内存一样,用户模式会阻止进程加载或存储到其指定地址空间之外的内存,如果尝试这样做,它会引发异常——例如段错误。

I/O 模块和设备驱动程序是不同的概念。I/O 模块是连接到总线的硬件。设备驱动程序是一个更高层次的概念,是一段软件,负责与 I/O 模块或通过它连接的设备(之一)进行所有通信;它还提供更高层次的接口(如 C 或 C++ 库),将内存映射指令进行封装。在 8 位时代,这些程序通常位于 ROM 中或加载到 RAM 中,供用户程序访问。今天,它们通常作为内核模块实现,只能由操作系统访问,用户程序通过操作系统请求它们的使用。

架构操作系统安全

学习架构级别为操作系统安全打开了许多有趣的机会。操作系统通常尝试限制用户程序访问计算机的大部分部分,但如果你能够访问架构级别,你可能能够绕过这一限制。例如,如果你能物理控制操作系统定时器回调使用的 IRQ 线路,通过打开计算机、将电线连接到 IRQ 引脚,并在你选择的时间施加电压,你能做什么呢?

一个持续的安全问题是设备驱动程序是否应该在内核模式或用户模式下运行。通常,它们被作为操作系统的一部分并获得对机器的完全访问权限,但这可能是危险的,因为它使得驱动程序的编写者能够访问你整个机器。这在以前只涉及少数几个声誉良好的打印机制造商,要求从打印机盒子中的 CD 上安装他们的驱动程序时是可以接受的,但现在问题更加严重,因为有越来越多的国际和不受信任的硬件制造商在运营,更不用提那些声称托管其产品驱动程序的无品牌网站了。

加载器

在没有操作系统的 8 位机器上,运行可执行文件只需将其内容复制到内存中的某个位置,然后将 CPU 的程序计数器设置为指向文件的第一行。这是由一个简单的程序,称为加载器,存储在 ROM 中完成的。在现代机器上有操作系统的情况下,加载器则更复杂:可执行文件将与其他进程一起运行,在虚拟内存区域中而非真实内存中运行。因此,加载器需要做一些工作来设置这一切,并修改可执行文件,使其使用虚拟地址而非程序所认为使用的物理地址。在 Linux 上,加载器通过类似./myexecutable的命令来调用,其中./在技术上出于安全原因是必须的,但实际上它充当了加载器命令的角色。

让我们尝试从操作系统内部编写、加载和运行一个“Hello, world!”程序。(我们之前在 BIOS 上做过这件事。)特别是,以下程序能够在像 X Window 系统这样的窗口系统中运行,并安排在终端中显示文本,而不是直接点亮 ASCII 模式的屏幕像素。它通过调用内核函数——而不是 BIOS 函数——来请求文本显示。操作系统的加载器假设有一个名为_start的外部可见(global)标签,加载器加载代码后会跳转到该标签:

          global    _start

_start:   mov       rax, 1                  ; system call for write
          mov       rdi, 1                  ; file handle 1 is stdout
          mov       rsi, message            ; address of string to output
          mov       rdx, 13                 ; number of bytes
          syscall                           ; invoke OS to do the write
          mov       rax, 60                 ; system call for exit
          xor       rdi, rdi                ; exit code 0
          syscall                           ; invoke OS to exit

message: db         "Hello, Kernel!", 10    ; note the newline at the end

这段代码仅在 64 位 Linux 上运行。要进行汇编和运行,请使用以下命令:

> nasm -felf64 hellok.asm && ld -o hellok hellok.o && ./hellok

这段代码应该仅通过系统调用将Hello, Kernel!写入控制台。

链接器

当操作系统托管的可执行文件调用其他库中的子程序时,虚拟内存地址需要进一步重新定位。这是为了确保每个库的可执行机器代码被加载到内存中的合适位置,即不与其他库发生冲突的位置。调整这些地址还确保库之间可以互相找到。如果一个程序或库调用另一个程序中的函数,则需要将目标子程序的地址更改为目标实际加载的位置。进行这些调整的过程叫做链接,通常由一个链接器程序完成,加载器通常会隐式地调用它。

作为链接的一个例子,这里有另一种向终端写入的方式,这次是通过调用标准 C 库的printf子程序:

global main
extern printf

msg: db "Hello libC!", 0   ; 0 = ASCII endofstring
fmtstr: db "%s", 10, 0     ; ASCII newline and endofstring
fmtint: db '%10d', 10, 0   ; ASCII newline and endofstring

main:
    mov rdi,fmtstr
    mov rsi,msg     ; pointer to msg
    mov rax,0       ; num of extra stack args used (none)
    call printf     ; call C function

    mov rdi,fmtint
    mov rsi,124     ; 124 is an int to print out
    mov rax,0       ; num of extra stack args used (none)
    call printf     ; call C function
    ret

采用这种方式,你可以从汇编程序中调用任何 C 库,只要你遵守它们的调用约定。因为它是 C 编译器栈的一部分,gcc编译器会寻找一个名为main的外部可见(global)子程序,和 C 语言中的做法一样。它会创建自己的低级_start子程序,并设置为调用main;它还会设置 C 库所需的任何结构。

请注意,由于printf可以接受可变数量的参数,我们必须告诉它在栈上使用了多少个额外的参数,并预期会出现多少个参数;我们在 RAX 中设置这个数字。这在大多数 x86 调用约定中是处理可变参数的标准做法。

要在 64 位 Linux 上汇编、链接并运行,请使用以下命令:

> nasm -felf64 helloc.asm ; gcc -no-pie -o helloc helloc.o ; ./helloc

你可以通过反汇编来查看链接器添加了哪些额外的代码——也就是将机器代码转换回人类可读的汇编代码。你可以使用像objdump这样的工具来做到这一点:

> objdump -d helloc

一些操作系统利用 x86 段——或者至少是它们的汇编指令——来强制代码中的.text部分为只读。它们通常允许在.data部分进行写操作。

额外的启动序列阶段

大多数系统在开机时无法直接启动操作系统。操作系统负责加载和配置设备驱动程序,而这些驱动程序在操作系统还没有加载时并不可用。相反,它们是在启动过程的后续阶段逐步加载的。

我们在第十三章中已经介绍过 BIOS 和 UEFI。通常,只有两个程序会在 BIOS 中运行:操作系统加载程序和操作系统加载程序选择器程序,比如 GRUB2(GRUB 版本 2)。PCBIOS 会从一个特定的硬盘位置,即主引导记录,运行第一个这样的程序。UEFI 现在对文件系统有比这更高层次的视图,它包括一个硬盘上的特定路径,用于寻找并运行这些程序中的第一个。GRUB2 提供了一个基于文本的用户界面,显示硬盘上可用的操作系统列表,并允许用户使用光标和其他按键输入他们的选择。GRUB2 会检查可用的 BIOS 类型,然后调用该 BIOS 中的可用子程序来在屏幕上显示字符并读取键盘。当用户做出选择时,它会加载相应的操作系统加载程序,并将控制权交给它。

操作系统加载器因此成为操作系统的第一个程序。它最初将依赖 BIOS 库来访问计算机,特别是硬盘,这个硬盘包含了其余操作系统的代码。操作系统可能有自己的驱动程序,最好比 BIOS 的驱动程序更好,并且它将逐步加载并切换到这些驱动程序。例如,BIOS 图形本身分辨率较低,以便在任何显示器上都能正常工作,但一旦操作系统加载,它可以考虑显示器的具体品牌和型号,并加载一个新的定制驱动程序,以便利用显示器的所有功能。

现代的启动过程因安全原因而备受争议。启动过程发生在操作系统启动之前,这意味着它可以访问整个计算机。UEFI 在操作系统启动后继续在后台运行,允许操作系统调用它的子程序。但这也意味着,任何嵌入 UEFI 固件的恶意代码都可能在正常操作系统运行期间保持对整个机器的访问权限。

UEFI 是由一个委员会设计的,委员会成员包括成功游说将“安全启动”作为标准一部分的专有操作系统供应商。这使得启动过程可以被锁定,以至于预装机器的买家无法安装 GRUB2 和其他操作系统。如果你能够重置安全启动系统本身,就有可能修复这个标准中的漏洞。通常这是通过将两根电线焊接到 UEFI 芯片上,并施加电压进行出厂重置。

自 2008 年左右以来,关于英特尔主板包含一个基于 MINIX3 的完整操作系统的传言开始流传,这个操作系统在 UEFI 和主操作系统之间的启动过程中运行,被称为“英特尔管理引擎”。如果这些传言属实,那么它们暗示了一个重大的安全漏洞,因为这个操作系统将拥有对整台机器的完全访问权限,包括互联网通信和自动更新系统,这将使得英特尔或其他方能够随时通过网络推送代码,并以完全的读写权限在你的计算机上运行。这些传言还暗示,MINIX 现在可能是世界上使用最广泛的操作系统——这有点讽刺,因为 MINIX 最初是作为一款教育操作系统创建的,而 Linux 则被认为是它的“现实世界”进化。

虚拟机监控器模式、虚拟化和容器

内核模式有时被称为监控模式,其中“监控者”是内核,它控制着正在运行的进程之间的切换。虚拟监控器模式是一个相关但更高级的概念,在该模式下,CPU 不是在操作系统内部切换多个进程,而是在多个操作系统之间切换,这些操作系统并行运行。这个概念在当前的云计算中尤为重要,因为计算机中心中的众多机器就是以这种方式共享的,旨在为每个用户提供类似于根用户操作可扩展机器组的体验。

相似的操作系统共享也可以仅通过软件实现:有些程序能够模拟或仿真虚拟机。然而,这样做会带来性能损失,而虚拟监控器则不会。有了虚拟监控器,每个操作系统实际上是直接运行在硬件上的。专用的虚拟监控器架构被用来管理硬件状态的进出交换,类似于软件监控器如何在执行中交换进程。一些虚拟机程序,比如第十三章中使用的 VirtualBox 程序,可以利用虚拟监控器在虚拟化处理器上运行其虚拟机。

容器化是虚拟化的替代方案。它并不是创建一套完全隔离的虚拟机,而是与额外的软件一起工作,创建出许多虚拟机的外观,同时让它们实际共享一个操作系统和其他组件,例如软件库。(这可以说是操作系统最初的设计目的。但与操作系统不同的是,容器能够让不同用户体验系统、库和已安装软件的不同安装和版本。)这是比虚拟机更轻量的解决方案,它可以让成千上万的容器在一台计算机上为不同的用户一起运行。容器化在云计算中特别有用,因为在云计算中,成千上万的用户希望运行隔离的程序,而服务提供商则希望通过让这些程序共享一台物理机器来最小化成本。

实时操作系统

大多数嵌入式系统只运行一个小而简单的程序,因此不需要操作系统。然而,随着某些嵌入式系统需求的复杂化,将它们编程为多个进程变得更加容易和常见。在这个阶段,开始在嵌入式系统上运行一个小型操作系统来管理这些多个进程是有意义的。

嵌入式环境通常对操作系统有特殊要求,最常见的是对所谓的硬实时的需求。普通操作系统可能会以一种看似随机的方式在进程之间切换;它们的设备驱动程序通常也会使用缓冲和中断来读取和写入数据,这些操作看起来也像是随机的。这样的行为对于比如说精密工业机器人控制器来说是灾难性的,因为它们需要在微秒和微米级别的精度下工作,这些行为会干扰其在现实世界中的精确运动要求。硬实时操作系统(RTOS)——例如 SMX、QNX、FreeRTOS 或 Zephyr——是专门从头开始设计的操作系统,旨在绝对保证此类任务的时间精度。这要求在调度和 I/O 方面采取不同的方法。通常,嵌入式微控制器的功耗远低于桌面计算机,因此操作系统设计要求还必须包括低计算开销。

为了在安全关键的环境中使用,像微控制器一样运行的 RTOS 通常会经历一个昂贵且严格的安全保障过程,该过程要么依赖于广泛的测试,要么在最严格的情况下,依靠形式化规范和验证,使用数学和逻辑来证明其在各种假设下都能始终可靠运行。

RTOS 与实时操作系统有所区别,后者如为计算机音频制作任务修改过的 Linux 变种。在这些系统中,实时性是理想的,但并非绝对必要——如果不能每次都绝对保证,譬如说,它不会让核电站爆炸——因此偶尔的延迟是可以容忍的。

投机执行漏洞

在我们对架构的研究中,我们发现计算机会将程序转换为成千上万条不同的指令,混乱地调整这些指令执行的顺序,尝试同时执行多个指令的部分内容,在指令之间传递不完整的结果,并秘密地更新其微代码以采用新的执行方式。

这些行为及其相互作用在芯片设计和功能上产生了巨大的复杂性。由此产生的芯片设计是人类已知的最复杂系统之一,没有任何个人能够完全理解 CPU 中发生的所有事情。提出一个问题是很自然的——在如此多可能出错的部分下,我们能否确信我们的 CPU 设计是安全且可靠的?

最近发现这个问题的答案是“否”——这就是为什么我们现在有了推测执行漏洞,架构错误可能允许一个进程读取属于另一个进程的数据,如密码和银行信息。在大多数情况下,这包括不同用户在物理云机器上运行的虚拟化系统相互间窃听的能力。这被许多制造商认为是灾难性的安全威胁;一些人认为它是有史以来最严重的硬件问题。针对这些漏洞的软件补丁会导致性能降低 5%到 30%,而架构师目前正在努力重新设计硬件,以避免在下一代处理器中出现这些问题。

推测执行漏洞首次在 2018 年被发现,作为名为 Spectre 和 Meltdown 的错误,新的变种在撰写本文时仍在不断被发现。为了基本了解这一类大量的漏洞,我们将在这里讨论 Meltdown 变种。

Meltdown 是由多个现代架构特性之间复杂的意外交互引起的:推测执行、虚拟内存、CPU 内核模式切换、缓存时间效应以及间接寻址中的竞态条件。假设目标进程与我们自己的进程在操作系统下同时运行。操作系统为两个进程定义了独立的内存区域,并限制每个进程只能访问自己的内存空间。内存空间如下所示:

地址 数据
1
2
3=基础
4=测试 1 FOO
5=测试 2 FOO
6=测试 3 FOO
地址 目标数据
--- ---
7
8=目标 密码
9
10
11
6

在这里,我们假设我们可以访问目标程序的源代码,这样我们就知道用户密码会存储在它的内存中的位置,因此TARGET地址的内容,即*TARGET,是PASSWORD,我们假设它已知并且是一个从 1 到 3 之间的整数。我们想从我们自己的进程中读取这个密码。我们自己进程的地址空间包含一系列标记为TEST1TEST2TEST3的地址。我们可以在这些位置存储任何虚拟数据,标记为FOO。我们将读取这些数据作为攻击的一部分,但我们其实并不关心它们的具体值。我们将把这些地址前面的地址称为BASE,因为它将作为基地址,我们可以使用偏移量来引用每个TEST地址。

为了进行攻击,我们首先执行一个间接偏移寻址指令,并带有一个条件:

if (0) LOAD BASE+(*TARGET) else LOAD 1

尽管if (0)的语义意味着条件永远不会为真——这意味着LOAD BASE+(*TARGET)在程序中不会完全执行——但急切执行(如在第八章中)最初会同时运行两个分支。当它这样做时,BASE+(*TARGET)将被评估,得到的地址必须是 4、5 或 6 之一。这个地址上的数据内容(其中之一为三个FOO项)将被加载到缓存中。(地址 1 的FOO也从分支的另一侧加载到缓存。)在此过程中,条件被测试并发现为假。此时,LOAD BASE+(*TARGET)指令被中止,但其值已经被加载到缓存中,尽管之后不会再被使用。

请注意,如果条件为真而非假,那么LOAD BASE+(*TARGET)将尝试完成,此时且仅此时,会发生安全异常,因为TARGET地址会被测试安全性,并发现它位于另一个进程的地址空间中。但因为条件实际上是假的,所以这个测试从未执行。

一旦值被加载到缓存中,我们就进行缓存计时攻击:

for (i=1:3) time(LOAD BASE+i)

循环中的三个指令都成功执行,将三个FOO值从它们的三个内存位置加载到寄存器中。但是如果我们为这三个LOAD操作计时,会发现其中一个比其他的更快,因为它在猜测执行期间被缓存了。如果PASSWORD=i,那么LOAD BASE+i会很快,因为(BASE+i)已被缓存。通过测量这些时间并找出最快的一个,可以揭示出i的值,它等于PASSWORD,这是所需要的。

Meltdown 漏洞在几乎所有主要商业 CPU 中存在了 20 年而未被发现!在此期间,可能有秘密的国家行为者知道并利用了这一漏洞,但据我们所知,尚未被任何其他恶意软件利用。

Meltdown 的公开披露过程是 2017 年一个关于道德安全漏洞披露的典范。在安全研究人员公开发现之后,制造商首先被秘密通知。研究人员、CPU 制造商和操作系统程序员随后合作,在操作系统软件层面为所有主要操作系统修补了该漏洞。这些操作系统通过向用户推送自动更新,在现场完成了更新。

操作系统级别的软件补丁叫做 KAISER。在这里,操作系统随机化进程的内存位置,以防止 Meltdown 攻击知道要搜索哪些地址来寻找目标数据。这仍然不是完全安全的,但使得该漏洞更难被利用。在用户计算机安装了 KAISER 补丁之后,Meltdown 的发现者在 2018 年发布了他们的研究成果,首先是在 arXiv 服务器上的预印本发布,然后提交进行正式的学术同行评审,最终在 2020 年完成并发表。

CISC 处理器采用微码构造,允许通过 CPU 固件更新在一定程度上“重新连接”硬件;这为 CISC 用户提供了更强的修复方法。推送微码更新比修补操作系统软件更为复杂和危险,开发硬件补丁也需要更长时间,部分原因是需要进行广泛的测试,确保补丁能够安全发布。将数百万用户的处理器“砖化”的成本远高于损坏操作系统,因为操作系统在发生错误更新时更容易重新安装。因此,微码补丁的开发在 Meltdown 论文发布后继续进行,并后来作为固件更新推送给 CISC 用户。

新的微码增加了清除缓存的逻辑,以清除所有推测执行后的漏洞。然而,这也带来了显著的性能损失,通常会导致 5% 到 30% 的性能下降。将这种性能损失强加给用户——通常是自动进行,而不告知或询问他们——引发了一些激烈的辩论,特别是在操作系统程序员之间,他们的软件级补丁工作被微码补丁所替代。

在撰写本文时,CPU 架构师正在努力重新设计其基本架构,以便在硬件层面正确修复 Meltdown 问题。2022 年,研究人员报告称,修复 Meltdown 的一些方法引入了一个新的推测执行漏洞,命名为 Retbleed。这可能会成为一场持续不断的“打地鼠”游戏,为架构师提供多年的就业机会。

练习

6502 内核

阅读 Joachim Deboy 的最小化 6502 内核的汇编代码,见 6502.org/source/kernels/minikernel.txt。解释 IRQ、保存和恢复的发生位置。尝试制作一个 x86 或 RISC-V 版本的相同概念。

推测执行漏洞审计

查找并确认您的计算机是否以及如何修复了推测执行漏洞。在 Linux 系统中,lscpu 命令可能会显示一些相关信息。

进一步阅读

  • 操作系统的经典教材是 Andrew Tanenbaum 和 Herbert Bos 编写的 《现代操作系统》(第 4 版,霍博肯:皮尔逊出版社,2014 年)。

  • 要查看 Linux 为您提供的所有子程序,并从您的 x86 代码中调用,参见 R.A. Chapman 的“Linux 系统调用表(x86)”,* blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/*。

  • 欲了解更多有关 Meltdown 漏洞的信息,参见 M. Lipp 等人,"Meltdown:从用户空间读取内核内存",《ACM 通讯》 63,6 期(2020 年):46-56。

posted @ 2025-11-30 19:34  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报