计算机工作原理指南-全-
计算机工作原理指南(全)
原文:
zh.annas-archive.org/md5/b0dd78ca4dcffaa78c5784941d6ec2f4译者:飞龙
前言

你是否对计算机如何工作感到好奇?获得广泛的计算理解往往是一个漫长而曲折的过程。问题不在于缺乏文献。在线快速搜索就能发现,关于计算的书籍和网站有很多很多。编程、计算机科学、电子学、操作系统……信息极为丰富。这是一件好事,但也可能让人感到望而却步。那么,应该从哪里开始呢?一个主题如何与另一个主题联系起来?本书的写作目的是为你提供一个学习计算关键概念的入口,并展示这些概念是如何结合在一起的。
当我担任工程经理时,我定期面试求职软件开发岗位的人。我和很多会写代码的候选人交谈过,但其中有相当一部分人似乎并不了解计算机的真正运作原理。他们知道如何让计算机按自己的要求执行任务,但却不了解背后发生了什么。回顾这些面试经历,以及我自己在学习计算机过程中的困惑,促使我写了这本书。
我的目标是以一种易于理解、实践性强的方式呈现计算的基本原理,使抽象的概念变得更加真实。本书并未深入探讨每个主题,而是介绍了计算的基础概念,并将这些概念之间的联系串联起来。我希望你能够构建出一个关于计算如何工作的心理图像,从而能够在你感兴趣的主题上深入挖掘。
计算无处不在,随着我们的社会越来越依赖技术,我们需要那些对计算有广泛理解的人。我希望这本书能帮助你获得这种广阔的视野。
本书适合谁阅读?
本书适合任何想要了解计算机如何工作的人。你不需要具备任何相关的先验知识——我们从基础开始。另一方面,如果你已经具备编程或电子学的背景,本书也能帮助你拓展其他领域的知识。本书面向的是具有自学能力的读者,读者应对基础数学和科学有一定了解,并且已经熟悉使用计算机和智能手机,但仍然对它们如何工作存在疑问。教师们也应该能从中受益;我相信这些项目非常适合课堂使用。
关于本书
本书将计算机技术作为一个技术栈来讲解。一台现代计算设备,如智能手机,是由多层技术组成的。在技术栈的最底层是硬件,而最顶层是应用程序,中间有多个技术层。层次模型的美妙之处在于,每一层都能受益于下层所有的功能,但每一层只需要基于直接下方的那一层构建。在介绍了一些基础概念之后,我们将从底层向上逐步探讨技术栈,从电路开始,逐步深入到支持互联网和应用程序的技术。以下是各章节的内容:
第一章: 计算概念 涵盖了基础概念,如理解模拟与数字、二进制数字系统和国际单位制前缀
第二章: 二进制应用 讲解了如何使用二进制表示数据和逻辑状态,并介绍了逻辑运算符
第三章: 电路 解释了电力和电路的基本概念,包括电压、电流和电阻
第四章: 数字电路 介绍了晶体管和逻辑门,并整合了第二章和第三章的概念
第五章: 数字电路中的数学 展示了如何通过数字电路进行加法运算,并详细介绍了计算机中数字的表示方式
第六章: 内存与时钟信号 介绍了内存设备和时序电路,并通过时钟信号展示了同步过程
第七章: 计算机硬件 涵盖了计算机的主要部件:处理器、内存和输入/输出
第八章: 机器码与汇编语言 介绍了处理器执行的低级机器码,并讲解了机器码的人类可读形式——汇编语言
第九章: 高级编程 介绍了与具体处理器无关的编程语言,并包括了 C 语言和 Python 的示例代码
第十章: 操作系统 涵盖了操作系统的种类以及操作系统的核心功能
第十一章: 互联网 讲解了互联网如何工作,包括它所使用的常见网络协议套件
第十二章: 万维网 解释了万维网的工作原理,并讲解了它的核心技术:HTTP、HTML、CSS 和 JavaScript
第十三章: 现代计算 提供了若干现代计算主题的概述,如应用程序、虚拟化和云服务
当你阅读本书时,你会遇到电路图
和用于说明概念的源代码。这些都旨在作为教学工具,注重清晰性而非性能、安全性以及工程师在设计硬件或软件时考虑的其他因素。换句话说,本书中的电路和代码能帮助你了解计算机是如何工作的,但它们不一定是做事的最佳方式。同样,本书的技术解释也倾向于简洁而非完整。我有时会略过某些细节,以避免陷入复杂性。
关于练习和项目
在各章中,你会找到练习和动手项目。练习是让你用脑力或铅笔纸解决的问题。项目则超出了脑力练习,通常涉及到构建电路或编程计算机。
你需要购买一些硬件来进行项目(所需组件的清单可以在附录 B 中找到)。我之所以加入这些项目,是因为我相信最好的学习方式就是亲自尝试,我鼓励你完成这些项目,如果你希望从这本书中获得最大的收获。也就是说,我已经以一种方式呈现了本章内容,即使你不构建任何电路或编写一行代码,也能跟得上。
你可以在附录 A 中找到练习的答案,每个项目的详细信息可以在相应章节的末尾找到。附录 B 包含帮助你开始项目的信息,当需要时,项目文本会将你引导到那里。
项目中使用的源代码副本可以在www.howcomputersreallywork.com/code/上找到。你还可以访问本书页面nostarch.com/how-computers-really-work/,我们会在那里提供更新。
我的计算机之旅
我对计算机的兴趣可能始于我小时候玩的电子游戏。当我去祖父母家时,我会花几个小时玩在我阿姨的 Atari 2600 上玩《青蛙过河》、《吃豆人》和《大金刚》。后来,在五年级时,我的父母在圣诞节给我送了一台任天堂娱乐系统,我高兴极了!虽然我喜欢玩《超级马里奥兄弟》和《双截龙》,但在某个时候,我开始想知道电子游戏和计算机是如何工作的。不幸的是,我的任天堂游戏主机没有给我提供多少线索来了解它里面的运作。
大约在同一时期,我的家人购买了我们的第一台“真正的”计算机,一台 Apple IIGS,这为我打开了新天地,让我可以深入探究这些机器是如何运作的。幸运的是,我的中学提供了 Apple II 计算机的 BASIC 编程课程,我很快意识到我对编程上瘾了!我会在学校写代码,把我的作品拷贝到软盘上,带回家继续工作。在中学和高中期间,我学到了更多关于编程的知识,且非常享受这过程。我还开始意识到,虽然 BASIC 和其他类似的编程语言使得告诉计算机该做什么相对容易,但它们也隐藏了计算机如何工作的许多细节。我渴望更深入地了解。
在大学时,我学习了电气工程,开始理解电子学和数字电路。我上了 C 编程和汇编语言的课程,最终了解了计算机是如何执行指令的。计算机工作原理的低级细节开始变得有意义。大学期间,我还开始学习一个叫做万维网的新事物;那时我甚至自己制作了一个网页(当时这看起来是个大事)!我开始编程 Windows 应用程序,并接触到了 Unix 和 Linux。这些话题有时看起来与数字电路和汇编语言的硬件特定细节相距甚远,我很好奇它们是如何融合在一起的。
大学毕业后,我有幸在微软找到了一份工作。在微软的 17 年里,我担任了各种软件工程角色,从调试 Windows 内核到开发 Web 应用程序。这些经历帮助我对计算机有了更广泛和更深入的理解。我和许多非常聪明和有知识的人一起工作,我学到的是,计算机领域总有更多东西可以学习。理解计算机如何工作对我来说是一个终身的旅程,我希望通过这本书,把我所学到的一些知识传递给你。
第一章:计算机概念**

计算机如今无处不在:在我们的家里、学校、办公室——你可能在口袋里、手腕上,甚至在冰箱里找到计算机。如今,找到并使用计算机比以往任何时候都容易,但很少有人真正理解计算机是如何工作的。这并不令人惊讶,因为学习计算机的复杂性可能会让人不知所措。本书的目标是以一种任何有好奇心并具备一些技术倾向的人都能理解的方式,阐明计算机的基本原理。在我们深入了解计算机如何工作之前,让我们花点时间了解一些计算机的主要概念。
在本章中,我们将从讨论计算机的定义开始。从这个定义出发,我们将介绍模拟数据和数字数据之间的差异,然后探讨数字数据的数制和术语。
定义计算机
让我们从一个基本问题开始:什么是计算机?当人们听到计算机这个词时,大多数人会想到笔记本电脑或台式电脑,有时被称为个人计算机或 PC。这是本书讨论的其中一种设备类型,但让我们再扩大一些思考。考虑一下智能手机。智能手机当然是计算机;它们执行与 PC 相同类型的操作。事实上,对于今天的许多人来说,智能手机是他们的主要计算设备。如今,大多数计算机用户还依赖互联网,而互联网是由服务器提供支持的——另一种计算机类型。每次你访问网站或使用连接到互联网的应用程序时,你都在与一个或多个连接到全球网络的服务器互动。视频游戏机、健身追踪器、智能手表、智能电视……这些都是计算机!
一个计算机是任何能够被编程以执行一组逻辑指令的电子设备。根据这个定义,很明显许多现代设备实际上都是计算机!
练习 1-1:找出你家里的计算机
花一点时间,看看你能在家里找出多少台计算机。当我和家人一起做这个练习时,我们很快找到了大约 30 个设备!
模拟与数字
你可能听说过计算机被描述为数字设备。这与模拟设备,如机械钟表,是相对的。那么这两个术语到底是什么意思呢?理解模拟和数字之间的差异是理解计算机的基础,因此让我们仔细看看这两个概念。
模拟方法
环顾四周,挑选一个物体。问问自己:它是什么颜色的?它有多大?它重多少?通过回答这些问题,你正在描述该物体的属性,或者说是数据。现在,挑选另一个物体并回答相同的问题。如果你对更多的物体重复这个过程,你会发现,对于每个问题,潜在的答案是非常多的。你可能挑选一个红色物体,一个黄色物体,或者一个蓝色物体,或者物体可能是几种基本颜色的混合。这种变化不仅仅适用于颜色。对于给定的属性,我们世界上物体间的变化是潜在无限的。
用语言描述一个物体是一回事,但假设你想要更精确地测量它的某个属性。例如,如果你想测量物体的重量,你可以将它放在一个秤上。秤根据放置在其上的重量,移动指针沿着标有数字的线,直到指针停在与重量相对应的位置。从秤上读出数字,你就得到了物体的重量。
这种测量方法很常见,但让我们稍微思考一下我们是如何测量这些数据的。刻度盘上指针的位置实际上不是重量,它是重量的一个表示。指针指向的数字线为我们提供了一种便捷的方式,将指针的位置(表示重量)与该重量的数值相互转换。换句话说,尽管重量是物体的属性,但在这里我们可以通过其他方式来理解这个属性:即指针在数字线上的位置。指针的位置会随着物体放置在秤上的重量成比例地变化。因此,秤起到了类比的作用,我们通过指针在刻度线上的位置来理解物体的重量。这就是为什么我们称这种测量方法为模拟方法。
另一个类比测量工具的例子是水银温度计。水银的体积随着温度的变化而增加。温度计制造商利用这一特性,将水银放入一个玻璃管中,管上有与不同温度下水银体积相对应的刻度标记。因此,水银在管中的位置就作为温度的一个表示。注意,在这两个例子中(秤和温度计),当我们进行测量时,我们可以利用仪器上的刻度将位置转换为一个具体的数值。但从仪器上读取的数值只是一个近似值。指针或水银的真实位置可能处于仪器范围内的任何位置,我们将其四舍五入到最接近的标记值。因此,尽管这些工具似乎只能产生有限的测量结果,但这是由转换为数字所施加的限制,而不是由类比本身的限制。
在人类历史的大部分时间里,人类一直通过模拟方法来测量事物。但人类不仅仅用模拟方法来测量,他们还设计出了巧妙的方法来以模拟方式存储数据。唱片利用调制的槽纹作为录制音频的模拟表示。槽纹的形状沿着路径变化,呈现出音频波形随时间变化的情况。槽纹本身不是音频,但它是原始声音波形的类比。基于胶片的相机做了类似的事情,通过短暂地将胶片暴露在相机镜头的光线下,导致胶片发生化学变化。胶片的化学性质不是图像本身,而是捕捉到的图像的表示,是图像的类比。
走向数字化
这一切与计算有什么关系?事实证明,那些数据的模拟表示方式对于计算机来说难以处理。所使用的模拟系统类型差异巨大且变化多端,几乎不可能创建一个通用的计算设备来理解所有这些系统。例如,制作一个能够测量水银体积的机器和制作一个能够读取黑胶唱片槽纹的机器是完全不同的任务。此外,计算机需要高度可靠和准确的数据表示,像数字数据集和软件程序等类型的数据。数据的模拟表示可能难以精确测量,随着时间的推移会衰减,且在复制时会失去保真度。计算机需要一种方法,以一种可以准确处理、存储和复制的格式来表示所有类型的数据。
如果我们不想将数据表示为具有潜在无限变化的模拟值,我们该怎么办?我们可以改用数字化方法。数字系统将数据表示为一系列符号,其中每个符号是有限集合中的一个值。现在,这个描述可能听起来有点正式,也有些让人困惑,所以与其深入探讨数字系统的理论,我将解释它在实际中的含义。在今天几乎所有的计算机中,数据是通过两个符号的组合来表示的:0 和 1。仅此而已。虽然数字系统可以使用超过两个符号,但添加更多符号会增加系统的复杂性和成本。仅使用两个符号的集合可以简化硬件设计,并提高可靠性。在大多数现代计算设备中,所有数据都表示为 0 和 1 的序列。从此书开始,当我谈论数字计算机时,您可以假设我指的是只处理 0 和 1 的系统,而不是其他符号集合。简单明了!
需要强调的一点是:你计算机上的一切都以 0 和 1 存储。你在智能手机上拍的最后一张照片?你的设备将这张照片存储为一串 0 和 1。你从互联网流媒体播放的歌曲?0 和 1。你在计算机上写的文档?0 和 1。你安装的应用程序?一堆 0 和 1。你访问的网站?0 和 1。
说我们只能使用 0 和 1 来表示自然界中的无限值,可能听起来有些限制。如何将一段音乐录音或一张详细的照片浓缩成 0 和 1 呢?许多人觉得这样有限的“词汇”能够表达复杂的思想是直觉上难以理解的。关键在于,数字系统使用的是一串 0 和 1。举个例子,一张数字照片通常由数百万个 0 和 1 组成。
那么,这些 0 和 1 究竟是什么呢?你可能会看到其他术语用来描述这些 0 和 1:假和真,关和开,低和高等等。这是因为计算机并不直接存储数字0或1。它存储的是一串条目,其中每个条目只有两种可能的状态。每个条目就像一个开关,要么是开,要么是关。实际上,这些 1 和 0 的序列以各种方式存储。在 CD 或 DVD 上,0 和 1 通过凸起(0)或平坦的空间(1)存储在光盘上。在闪存驱动器中,1 和 0 以电荷的形式存储。在硬盘驱动器中,0 和 1 通过磁化存储。正如你将在第四章中看到的,数字电路通过电压水平来表示 0 和 1。
在继续之前,最后一个关于术语模拟的说明——它通常用来表示“非数字化”。例如,工程师可能会说“模拟信号”,意思是信号是连续变化的,并不与数字值对齐。换句话说,它是一个非数字信号,但不一定表示其他事物的类比。因此,当你看到模拟这个术语时,考虑到它可能并不总是意味着你想的那样。
数值系统
到目前为止,我们已经确定计算机是处理 0 和 1 的数字机器。对于许多人来说,这个概念似乎很奇怪;他们习惯了在表示数字时使用 0 到 9。如果我们只限于使用两个符号,而不是十个,我们应该如何表示大数字呢?为了回答这个问题,让我们回顾一下小学数学中的一个基本话题:数值系统。
十进制数
我们通常使用一种叫做十进制位置值表示法的方式来写数字。让我们来解析一下。位置值表示法(或位置表示法)意味着写下的每个数字位置代表不同的数量级;十进制,或者说基数 10,意味着数量级是 10 的倍数,每个位置可以有十个不同的符号,0 到 9。请参见图 1-1 中的位置值表示法示例。

图 1-1:二百七十五在十进制位置值表示法中的表示
在图 1-1 中,数字二百七十五在十进制表示法中写作 275。5 位于个位,因此它的值是 5 × 1 = 5。7 位于十位,因此它的值是 7 × 10 = 70。2 位于百位,因此它的值是 2 × 100 = 200。总值是所有位置值的和:5 + 70 + 200 = 275。
很简单,对吧?你可能从一年级起就已经理解了这个概念。但让我们更仔细地分析一下。为什么最右边的位置是个位?为什么下一个位置是十位,依此类推?这是因为我们使用的是十进制,或者说是基数 10,因此每个位置代表的是 10 的幂,换句话说,10 是自乘若干次的结果。如图 1-2 所示,最右边的位置是 10 的 0 次方,等于 1,因为任何数的 0 次方都等于 1。下一个位置是 10 的 1 次方,等于 10,接下来的位置是 10 的 2 次方(10 × 10),等于 100。

图 1-2:在十进制位置值表示法中,每个位置是 10 的幂。
如果我们需要表示一个比 999 更大的数字,我们会在左侧加一个位置,即千位,其权重等于 10 的 3 次方(10 × 10 × 10),也就是 1,000。这个模式继续延续,因此我们可以通过添加更多的位置来表示任何大数字。
我们已经解释了为什么不同的位置有不同的权重,但让我们继续深入。为什么每个位置使用符号 0 到 9?在十进制中,我们只能使用十个符号,因为根据定义,每个位置只能表示十个不同的值。当前使用的是 0 到 9 这些符号,但实际上任何一组十个独特的符号都可以使用,每个符号对应某个数字值。
大多数人偏好使用十进制(基数 10)作为数字系统。有些人说这是因为我们有十个手指和十个脚趾,但无论原因如何,在现代世界,大多数人都是用十进制来读、写和思考数字。当然,这只是我们共同选择的代表数字的约定。如前所述,这一约定并不适用于计算机,计算机只使用两个符号。让我们看看如何在只使用两个符号的情况下应用位置值系统的原理。
二进制数字
由两个符号组成的数字系统是二进制。二进制仍然是一个位置值系统,因此其基本原理与十进制相同,但有一些变化。首先,每个位置代表的是 2 的幂,而不是 10 的幂。其次,每个位置只能有两个符号中的一个,而不是十个符号。这两个符号是 0 和 1。图 1-3 展示了我们如何使用二进制表示一个数字。

图 1-3:五的十进制在二进制位值表示法中的表现
在图 1-3 中,我们有一个二进制数:101。看起来像一百零一,但在二进制中,这实际上表示的是五!如果你想口头表达,“一零一二进制”是个很好的表达方式。
就像在十进制中一样,每一位都有一个与基数的幂次相关的权重。由于我们使用的是二进制,因此最右边的位是 2 的 0 次方,即 1。接下来的位是 2 的 1 次方,即 2,再接下来的位是 2 的 2 次方(2 × 2),即 4。同样地,就像在十进制中那样,要获得总值,我们将每一位的符号与该位的权重相乘,并将结果相加。所以,从右开始,我们有(1 × 1) + (0 × 2) + (1 × 4) = 5。
现在你可以尝试自己从二进制转换成十进制了。
练习 1-2:二进制转十进制
将这些以二进制表示的数字转换成它们的十进制等价物。
10(二进制) = ______ (十进制)
111(二进制) = ______ (十进制)
1010(二进制) = ______ (十进制)
你可以在附录 A 中检查你的答案。你做对了吗?最后一个可能有点棘手,因为它引入了另一个位置,即八位位置。现在,尝试反向操作,从十进制转换成二进制。
练习 1-3:十进制转二进制
将这些以十进制表示的数字转换成它们的二进制等价物。
3(十进制) = ______ (二进制)
8(十进制) = ______ (二进制)
14(十进制) = ______ (二进制)
希望你也做对了!立刻你就会发现,同时处理十进制和二进制可能会让人感到混淆,因为像 10 这样的数字,在十进制中表示十,而在二进制中表示二。从现在开始,在本书中,如果有可能造成混淆,二进制数字将会带有 0b 前缀。我之所以选择 0b 前缀,是因为许多编程语言都使用这种方式。前导的 0(零)字符表示数字值,而 b 是 binary(二进制)的缩写。举个例子,0b10 表示二进制的二,而 10 没有前缀则表示十进制的十。
比特与字节
十进制数字中的每一位或符号叫做数字。例如,数字 1,247 是一个四位数。类似地,二进制数字中的每一位或符号叫做比特(二进制数字)。每个比特的值可以是 0 或 1。像 0b110 这样的二进制数是一个三比特数。
单个比特不能传递太多信息;它要么是关(0),要么是开(1)。我们需要一系列比特来表示更复杂的信息。为了更方便地管理这些比特序列,计算机将比特按八个一组进行分组,这些组叫做字节。以下是一些比特和字节的例子(省略了 0b 前缀,因为它们都是二进制):
1 这就是一个比特。
0 这也是一个比特。
11001110 这是一个字节,或 8 个比特。
00111000 这也是一个字节!
10100101 另一个字节。
0011100010100101 这是两个字节,或者说 16 位。
注意
有趣的事实:4 位二进制数,即半个字节,有时被称为 nibble (有时拼写为 nybble 或 nyble)。
那么,我们能在一个字节中存储多少数据呢?换句话说,我们可以用 8 位二进制表示多少种不同的 0 和 1 的组合?在回答这个问题之前,我先用 4 位二进制来说明,因为这样更容易理解。
在表格 1-1 中,我列出了 4 位二进制数的所有可能组合,并包括了该数的对应十进制表示。
表格 1-1: 所有可能的 4 位二进制数值
| 二进制 | 十进制 |
|---|---|
| 0000 | 0 |
| 0001 | 1 |
| 0010 | 2 |
| 0011 | 3 |
| 0100 | 4 |
| 0101 | 5 |
| 0110 | 6 |
| 0111 | 7 |
| 1000 | 8 |
| 1001 | 9 |
| 1010 | 10 |
| 1011 | 11 |
| 1100 | 12 |
| 1101 | 13 |
| 1110 | 14 |
| 1111 | 15 |
如表格 1-1 所示,我们可以在 4 位二进制数中表示 16 种不同的 0 和 1 的组合,十进制值从 0 到 15 不等。看到这些位组合的列表有助于理解这一点,但我们也可以通过其他几种方法来推导出这个结论,而不必列举每一种可能的组合。
我们可以通过将所有位设置为 1 来确定 4 位可以表示的最大数字,这样得到 0b1111。它的十进制值为 15;如果我们加 1 来表示 0,那么总共有 16 种组合。另一种简便的方法是将 2 的位数次方,4 在这里,就是 2⁴ = 2 × 2 × 2 × 2 = 16 种 0 和 1 的组合。
看 4 位二进制数是一个很好的开始,但我们之前谈的是字节,字节包含 8 位。使用前面的方式,我们可以列出所有 0 和 1 的组合,但我们跳过这一过程,直接进入一个简便的方法。将 2 的 8 次方计算出来,得到 256,这就是一个字节中 0 和 1 的唯一组合数。
现在我们知道,4 位二进制数可以表示 16 种 0 和 1 的组合,而一个字节则可以表示 256 种组合。这与计算机有什么关系呢?假设一个电脑游戏有 12 个关卡;游戏只需要 4 个位来存储当前的关卡编号就可以了。另一方面,如果游戏有 99 个关卡,4 位就不够用了……只能表示 16 个关卡!而一个字节则能够满足 99 个关卡的需求。计算机工程师有时需要考虑存储数据时需要多少位或字节。
前缀
表示复杂的数据类型需要大量的位。像数字 99 这样的简单东西不会超过一个字节;而数字格式的视频,另一方面,可能需要数十亿个位。为了更容易地传达数据的大小,我们使用类似吉和兆的前缀。国际单位制(SI),也叫公制系统,定义了一组标准前缀。这些前缀用于描述任何可以量化的事物,不仅仅是位。我们将在接下来的章节中再次看到它们,特别是涉及电路时。表 1-2 列出了常见的 SI 前缀及其含义。
表 1-2: 常见的 SI 前缀
| 前缀名称 | 前缀符号 | 值 | 十进制 | 英文单词 |
|---|---|---|---|---|
| 太 | T | 1,000,000,000,000 | 10¹² | 万亿 |
| 吉 | G | 1,000,000,000 | 10⁹ | 十亿 |
| 兆 | M | 1,000,000 | 10⁶ | 百万 |
| 千 | k | 1,000 | 10³ | 千 |
| 分 | c | 0.01 | 10^(-2) | 百分之一 |
| 毫 | m | 0.001 | 10^(-3) | 千分之一 |
| 微 | μ | 0.000001 | 10^(-6) | 百万分之一 |
| 纳 | n | 0.000000001 | 10^(-9) | 十亿分之一 |
| 皮 | p | 0.000000000001 | 10^(-12) | 万亿分之一 |
使用这些前缀时,如果我们想说“30 亿字节”,可以使用简写 3GB。或者,如果我们想表示 4 千位,可以说 4kb。注意字节(B)使用大写,位(b)使用小写。
你会发现,这种约定通常用来表示位和字节的数量。不幸的是,它也常常在技术上是错误的。原因如下:在处理字节时,大多数软件实际上是按二进制而不是十进制工作。如果你的计算机告诉你某个文件的大小是 1MB,实际上它是 1,048,576 字节!这大约是百万,但并不完全是百万。看起来这个数字很奇怪,不是吗?那是因为我们在用十进制看它。在二进制中,这个数字表示为 0b100000000000000000000。它是 2 的幂,具体来说是 2²⁰。表 1-3 显示了处理字节时如何解释 SI 前缀。
表 1-3: 当应用于字节时,SI 前缀的含义
| 前缀名称 | 前缀符号 | 值 | 二进制 |
|---|---|---|---|
| 太 | T | 1,099,511,627,776 | 2⁴⁰ |
| 吉 | G | 1,073,741,824 | 2³⁰ |
| 兆 | M | 1,048,576 | 2²⁰ |
| 千 | k | 1,024 | 2¹⁰ |
关于位和字节的另一个混淆点是与网络传输速率相关。互联网服务提供商通常以每秒位数(十进制)做广告。因此,如果你的互联网连接速度是 50 兆位每秒,这意味着你只能传输大约 6 兆字节每秒。也就是说,50,000,000 位每秒除以每字节 8 位,得到每秒 6,250,000 字节。将 6,250,000 除以 2²⁰,我们得到大约 6 兆字节每秒。
二进制数据的 SI 前缀
为了消除由前缀多重含义引起的混淆,2002 年(在 IEEE 1541 标准中)引入了一组新的前缀,用于二进制场景。在处理 2 的幂次时,应使用 kibi- 代替 kilo-,mebi- 代替 mega-,以此类推。这些新的前缀对应基数 2 的值,旨在用于以前旧前缀被错误使用的场景。例如,由于千字节可能被解释为 1,000 或 1,024 字节,标准建议使用 kibibyte 来表示 1,024 字节,而 kilo- 保留其原意,使得千字节等于 1,000 字节。
这看起来是个不错的主意,但在写这篇文章时,这些符号尚未被广泛采用。表 1-4 列出了新的前缀及其含义。
表 1-4: IEEE 1541-2002 二进制数据的前缀
| 前缀名称 | 前缀符号 | 值 | 基数 2 |
|---|---|---|---|
| tebi | Ti | 1,099,511,627,776 | 2⁴⁰ |
| gibi | Gi | 1,073,741,824 | 2³⁰ |
| mebi | Mi | 1,048,576 | 2²⁰ |
| kibi | Ki | 1,024 | 2¹⁰ |
这个区别很重要,因为在实际操作中,大多数显示文件大小的软件使用的是旧的 SI 前缀,但计算文件大小时却采用基数 2。换句话说,如果你的设备显示一个文件的大小是 1KB,那就意味着 1,024 字节。另一方面,存储设备的制造商倾向于使用基数 10 来宣传其设备的容量。这意味着一个标称为 1TB 的硬盘可能存储 1 万亿字节,但如果你将该设备连接到计算机,计算机将显示大约 931GB(1 万亿除以 2³⁰)。鉴于新的前缀尚未被广泛采用,在本书中,我将继续使用旧的 SI 前缀。
十六进制
在我们离开二进制思维的话题之前,我还要介绍一个数字系统:十六进制。快速回顾一下,我们的“正常”数字系统是十进制,或基数 10。计算机使用二进制,或基数 2。十六进制是基数 16!根据你在本章已经学到的内容,你大概知道这意味着什么。十六进制,简称hex,是一种位置值系统,每个位置表示 16 的幂次,每个位置可以是 16 个符号之一。
在所有位值系统中,最右边的位仍然是个位。接下来的位是十六位,然后是 256 位(16 × 16),然后是 4,096 位(16 × 16 × 16),依此类推。很简单。那么每个位可以是 16 个符号中的一个的另一个要求怎么办呢?我们通常有十个符号来表示数字,0 到 9。我们需要添加六个符号来表示其他值。我们可以选择一些随机符号,如& @ #,但这些符号没有明显的顺序。相反,标准做法是使用 A、B、C、D、E 和 F(大小写都可以!)。在这个方案中,A 表示十,B 表示十一,依此类推,到 F 表示十五。这是有道理的;我们需要表示从零到比基数少一的符号。所以我们的额外符号是 A 到 F。通常在需要明确区分时,使用前缀 0x 来表示十六进制。表 1-5 列出了 16 个十六进制符号及其对应的十进制和二进制值。
表 1-5: 十六进制符号
| 十六进制 | 十进制 | 二进制(4 位) |
|---|---|---|
| 0 | 0 | 0000 |
| 1 | 1 | 0001 |
| 2 | 2 | 0010 |
| 3 | 3 | 0011 |
| 4 | 4 | 0100 |
| 5 | 5 | 0101 |
| 6 | 6 | 0110 |
| 7 | 7 | 0111 |
| 8 | 8 | 1000 |
| 9 | 9 | 1001 |
| A | 10 | 1010 |
| B | 11 | 1011 |
| C | 12 | 1100 |
| D | 13 | 1101 |
| E | 14 | 1110 |
| F | 15 | 1111 |
当你需要数到超过十进制的 15 或 0xF 时会发生什么?就像在十进制中一样,我们加上了另一个位。0xF 之后是 0x10,即十进制的 16。然后是 0x11、0x12、0x13,依此类推。现在看看图 1-4,我们看到一个更大的十六进制数,0x1A5。

图 1-4:十六进制数 0x1A5 按位值拆解
在图 1-4 中,我们有一个十六进制的数字 0x1A5。这个数字在十进制中的值是多少?最右边的位是 5。接下来的位的权重是 16,那里是 A,它在十进制中是 10,所以中间位的值是 16 × 10 = 160。最左边的位的权重是 256,那里是 1,所以这一位的值是 256。总值是 5 + 160 + 256 = 421,换算成十进制就是 421。
为了进一步强调这一点,这个例子展示了像 A 这样的新符号,根据它们出现的位置,值会有所不同。0xA 是十进制的 10,但 0xA0 是十进制的 160,因为 A 出现在十六位的位置。
到这一点,你可能会在心里想:“很好,但这有什么用呢?”很高兴你问了这个问题。计算机并不使用十六进制,大多数人也不使用。然而,十六进制对于需要处理二进制的人来说非常有用。
使用十六进制有助于克服处理二进制时的两个常见困难。首先,大多数人不擅长阅读长串的 0 和 1。过一段时间后,比特位就会混在一起。处理 16 个或更多比特对人类来说既繁琐又容易出错。第二个问题是,虽然人们擅长使用十进制,但在十进制和二进制之间转换并不容易。大多数人很难看着一个十进制数字,迅速判断出如果这个数字用二进制表示,哪些比特位是 1,哪些是 0。但使用十六进制,二进制到十六进制的转换就变得更加直接。表 1-6 提供了一些 16 位二进制数字的例子,以及它们对应的十六进制和十进制表示。请注意,我已在二进制值中加入空格,以提高清晰度。
表 1-6:16 位二进制数字的十进制和十六进制示例
| 示例 1 | 示例 2 | |
|---|---|---|
| 二进制 | 1111 0000 0000 1111 | 1000 1000 1000 0001 |
| 十六进制 | F00F | 8881 |
| 十进制 | 61,455 | 34,945 |
看看表 1-6 中的示例 1。在二进制中,序列非常清晰:前四个比特是 1,接下来的八个比特是 0,最后四个比特是 1。在十进制中,这个序列就变得模糊了。从 61,455 看不出哪些比特可能是 0 或 1。而十六进制则很好地反映了二进制中的序列。第一个十六进制符号是 F(在二进制中是 1111),接下来的两个十六进制符号是 0,最后一个十六进制符号是 F。
继续看示例 2,前三组四个比特都是 1000,最后一组四个比特是 0001。在二进制中很容易看出这一点,但在十进制中就比较难以看清。十六进制提供了一个更清晰的表示,其中十六进制符号 8 对应于二进制的 1000,而十六进制符号 1 则对应于 1!
我希望你能看到一个规律:二进制中的每四个比特对应十六进制中的一个符号。如果你记得的话,四个比特是半个字节(或者说是半个 Nibble)。因此,一个字节可以用两个十六进制符号来表示。一个 16 位的数字可以用四个十六进制符号表示,一个 32 位的数字可以用八个十六进制符号表示,依此类推。我们来看图 1-5 中的 32 位数字作为例子。

图 1-5:每个十六进制字符映射到 4 个比特
在图 1-5 中,我们可以每次处理半个字节来理解这个相对较长的数字,而用十进制表示同一个数字(2,320,695,040)时是无法做到的。
因为在二进制和十六进制之间转换相对容易,许多工程师通常会同时使用这两种表示方式,只有在必要时才转换为十进制数字。本书稍后会在适当的地方使用十六进制。
尝试从二进制转换到十六进制,而不通过先转换为十进制的中间步骤。
练习 1-4:二进制转十六进制
将这些以二进制表示的数字转换为其十六进制等价物。如果可以避免,请不要转换为十进制!目标是直接从二进制转换为十六进制。
10 (二进制) = ______ (十六进制)
11110000 (二进制) = ______ (十六进制)
你可以在附录 A 中查看你的答案。
一旦你掌握了从二进制到十六进制的转换,试着反过来做,从十六进制转换到二进制。
练习 1-5:十六进制转二进制
将这些以十六进制表示的数字转换为其二进制等价物。如果可以避免,请不要转换为十进制!目标是直接从十六进制转换为二进制。
1A (十六进制) = _____ (二进制)
C3A0 (十六进制) = ______ (二进制)
你可以在附录 A 中查看你的答案。
总结
在本章中,我们介绍了一些计算机基础概念。你学到了计算机是任何可以被编程以执行一系列逻辑指令的电子设备。接着你了解到现代计算机是数字设备,而不是模拟设备,并学会了它们之间的区别:模拟系统使用广泛变化的值来表示数据,而数字系统则将数据表示为符号的序列。之后,我们探讨了现代数字计算机如何仅依赖于两个符号,0 和 1,并了解了由这两个符号组成的数字系统,即二进制。我们介绍了位(bit)、字节(byte)以及你可以用来更轻松描述数据大小的标准国际单位前缀(如千兆(giga-)、兆(mega-)、千(kilo-)等)。最后,你学到了十六进制如何帮助那些需要在二进制中工作的人员。
在下一章中,我们将更详细地研究二进制在数字系统中的应用。我们将看看如何使用二进制表示各种类型的数据,并了解二进制逻辑是如何工作的。
第二章:二进制的实际应用**

在上一章中,我们将计算机定义为一种可以编程执行一系列逻辑指令的电子设备。随后,我们以高层次了解了计算机中所有内容,从它使用的数据到它执行的指令,都是以二进制形式存储的,即 0 和 1。在本章中,我将进一步阐明 0 和 1 是如何被用来表示几乎任何类型的数据。我们还将讨论二进制如何适用于逻辑操作。
数字化数据表示
到目前为止,我们关注的是如何在二进制中存储数字。更具体地说,我们讨论了如何存储正整数(有时称为整数)和零。然而,计算机将所有数据都存储为比特:负数、分数、文本、颜色、图像、音频和视频,等等。让我们来考虑一下如何使用二进制表示各种类型的数据。
数字文本
让我们以文本为例,看看比特、0 和 1 如何表示数字以外的其他东西。在计算机的语境中,文本指的是一组字母数字和相关符号,也称为字符。文本通常用来表示单词、句子、段落等等。文本不包括格式(粗体、斜体)。为了便于讨论,我们将字符集限定为英语字母和相关字符。在计算机编程中,术语字符串通常用来指代一串文本字符。
保持对文本的这个定义,我们究竟需要表示什么呢?我们需要大写和小写的 A 到 Z,即 A 和 a 是不同的符号。我们还需要标点符号,如逗号和句号。我们需要表示空格的方式。我们还需要数字 0 到 9。数字的需求可能会让人困惑;这里我所指的是包括表示数字 0 到 9 的符号或字符,这与存储数字 0 到 9 是不同的概念。
如果我们将所有需要表示的独特符号加起来,刚才提到的,差不多有 100 个字符。那么,如果我们需要为每个字符拥有一个独特的比特组合,我们每个字符需要多少比特呢?一个 6 位数给我们 64 种独特的组合,这还不够。但一个 7 位数给我们 128 种组合,足以表示我们需要的 100 个左右的字符。然而,由于计算机通常以字节为单位工作,因此直接向上取整,使用一个完整的 8 位字节来表示每个字符是有意义的。使用字节,我们可以表示 256 个独特字符。
那么我们该如何用 8 个比特来表示每个字符呢?正如你可能预料到的那样,已经有一种标准的方法可以用二进制表示文本,我们很快就会讲到。但在此之前,重要的是要理解,我们可以制定任何方案来表示每个字符,只要计算机上运行的软件知道我们的方案。话虽如此,一些方案比其他方案更适合表示某些类型的数据。软件设计师更喜欢那些使常见操作易于执行的方案。
假设你负责创建自己的系统,将每个字符表示为一组比特。你可能决定将 0b00000000 表示字符 A,0b00000001 表示字符 B,依此类推。将数据转换为数字格式的过程称为编码;当你解释这些数字数据时,它被称为解码。
练习 2-1:创建你自己的文本表示系统
定义一种方法,将大写字母 A 到 D 表示为 8 位数字,然后使用你的系统将单词DAD编码为 24 位。这个问题没有唯一的正确答案;请参见附录 A 获取示例答案。额外任务:同时展示你的 24 位编码数字的十六进制表示。
ASCII
幸运的是,我们已经有几种标准的数字文本表示方法,因此我们不必自己发明!美国信息交换标准代码(ASCII)是一种格式,它使用每个字符 7 个比特表示 128 个字符,尽管每个字符通常使用完整的字节(8 个比特)存储。使用 8 个比特而不是 7 个比特,意味着我们多了一个前导比特,值为 0。ASCII 处理英语所需的字符,另一个标准叫做Unicode,它处理几乎所有语言(包括英语)中使用的字符。现在,我们先集中在 ASCII 上,以保持简单。表 2-1 显示了部分 ASCII 字符的二进制和十六进制值。前 32 个字符未显示,它们是控制码,如回车和换页,最初用于控制设备,而不是存储文本。
练习 2-2:编码与解码 ASCII
使用表 2-1,将以下单词编码为 ASCII 二进制和十六进制,每个字符使用一个字节。记住,大写字母和小写字母的值不同。
-
你好
-
5 只猫
使用表 2-1,解码以下单词。每个字符由一个 8 位的 ASCII 值表示,空格用于增加清晰度。
-
01000011 01101111 01100110 01100110 01100101 01100101
-
01010011 01101000 01101111 01110000
使用表 2-1,解码以下单词。每个字符由一个 8 位的十六进制值表示,空格用于增加清晰度。
- 43 6C 61 72 69 6E 65 74
答案见附录 A。
表 2-1: ASCII 字符 0x20 至 0x7F
| 二进制 | 十六进制 | 字符 | 二进制 | 十六进制 | 字符 | 二进制 | 十六进制 | 字符 |
|---|---|---|---|---|---|---|---|---|
00100000 |
20 |
[空格] |
01000000 |
40 |
@ |
01100000 |
60 |
` |
00100001 |
21 |
! |
01000001 |
41 |
A |
01100001 |
61 |
a |
00100010 |
22 |
" |
01000010 |
42 |
B |
01100010 |
62 |
b |
00100011 |
23 |
# |
01000011 |
43 |
C |
01100011 |
63 |
c |
00100100 |
24 |
` | 二进制 | 十六进制 | 字符 | 二进制 | 十六进制 | 字符 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
00100000 |
20 |
[空格] |
01000000 |
40 |
@ |
01100000 |
60 |
` |
00100001 |
21 |
! |
01000001 |
41 |
A |
01100001 |
61 |
a |
00100010 |
22 |
" |
01000010 |
42 |
B |
01100010 |
62 |
b |
00100011 |
23 |
# |
01000011 |
43 |
C |
01100011 |
63 |
c |
01000100 |
44 |
D |
01100100 |
64 |
d |
|||
00100101 |
25 |
% |
01000101 |
45 |
E |
01100101 |
65 |
e |
00100110 |
26 |
& |
01000110 |
46 |
F |
01100110 |
66 |
f |
00100111 |
27 |
' |
01000111 |
47 |
G |
01100111 |
67 |
g |
00101000 |
28 |
( |
01001000 |
48 |
H |
01101000 |
68 |
h |
00101001 |
29 |
) |
01001001 |
49 |
I |
01101001 |
69 |
i |
00101010 |
2A |
* |
01001010 |
4A |
J |
01101010 |
6A |
j |
00101011 |
2B |
+ |
01001011 |
4B |
K |
01101011 |
6B |
k |
00101100 |
2C |
, |
01001100 |
4C |
L |
01101100 |
6C |
l |
00101101 |
2D |
- |
01001101 |
4D |
M |
01101101 |
6D |
m |
00101110 |
2E |
. |
01001110 |
4E |
N |
01101110 |
6E |
n |
00101111 |
2F |
/ |
01001111 |
4F |
O |
01101111 |
6F |
o |
00110000 |
30 |
0 |
01010000 |
50 |
P |
01110000 |
70 |
p |
00110001 |
31 |
1 |
01010001 |
51 |
Q |
01110001 |
71 |
q |
00110010 |
32 |
2 |
01010010 |
52 |
R |
01110010 |
72 |
r |
00110011 |
33 |
3 |
01010011 |
53 |
S |
01110011 |
73 |
s |
00110100 |
34 |
4 |
01010100 |
54 |
T |
01110100 |
74 |
t |
00110101 |
35 |
5 |
01010101 |
55 |
U |
01110101 |
75 |
u |
00110110 |
36 |
6 |
01010110 |
56 |
V |
01110110 |
76 |
v |
00110111 |
37 |
7 |
01010111 |
57 |
W |
01110111 |
77 |
w |
00111000 |
38 |
8 |
01011000 |
58 |
X |
01111000 |
78 |
x |
00111001 |
39 |
9 |
01011001 |
59 |
Y |
01111001 |
79 |
y |
00111010 |
3A |
: |
01011010 |
5A |
Z |
01111010 |
7A |
z |
00111011 |
3B |
; |
01011011 |
5B |
[ |
01111011 |
7B |
{ |
00111100 |
3C |
< |
01011100 |
5C |
\ |
01111100 |
7C |
| |
00111101 |
3D |
= |
01011101 |
5D |
] |
01111101 |
7D |
} |
00111110 |
3E |
> |
01011110 |
5E |
^ |
01111110 |
7E |
~ |
00111111 |
3F |
? |
01011111 |
5F |
_ |
01111111 |
7F |
[删除] |
以数字格式表示文本相当简单。像 ASCII 这样的系统将每个字符或符号映射到一个独特的位序列。然后,计算设备解释这些位序列并显示相应的符号给用户。
数字颜色与图像
现在我们已经看到了如何以二进制表示数字和文本,让我们探索另一种数据类型:颜色。任何具有彩色图形显示的计算设备都需要有一种描述颜色的系统。如你所料,就像文本一样,我们已经有了标准的颜色数据存储方式。我们会介绍这些方法,但首先让我们设计自己的数字颜色描述系统。
让我们将颜色范围限制为黑色、白色和不同深浅的灰色。这种有限的颜色集合称为 灰度。就像我们处理文本一样,首先决定我们要表示多少种不同的灰度。我们保持简单,使用黑色、白色、深灰色和浅灰色。这总共是四种灰度颜色,那么我们需要多少位来表示这四种颜色呢?只需要 2 位。因为 2 的 2 次方等于 4,所以 2 位数字可以表示四个唯一的值。
练习 2-3:创建你自己的灰度表示系统
定义一种数字方式来表示黑色、白色、深灰色和浅灰色。没有单一的正确答案;请参见 附录 A 获取示例答案。
一旦你设计了表示灰度的二进制系统,你可以在此基础上构建自己的系统来描述一个简单的灰度图像。图像本质上是颜色在二维平面上的排列。这些颜色通常以一个由单色方块组成的网格排列,这些方块称为 像素。这里是 图 2-1 中的一个简单例子。

图 2-1:一个简单的图像
图 2-1 中的图像宽度为 4 像素,高度为 4 像素,总共有 16 个像素。如果你眯起眼睛并发挥想象力,你可能会看到一个白色的花朵和背后的暗天空。该图像由三种颜色组成:白色、浅灰色和深灰色。
注意
图 2-1 由一些非常大的像素组成,用来说明一个观点。现代电视、计算机显示器和智能手机屏幕也可以看作是像素网格,但每个像素非常小。例如,高清显示屏通常为 1920 像素(宽度)乘 1080 像素(高度),总共有大约 200 万个像素!再举一个例子,数字照片通常包含超过 1000 万个像素。
练习 2-4:创建你自己的简单图像表示方法
第一部分 基于你之前表示灰度颜色的系统,设计一个表示由这些颜色组成的图像的方法。如果你想简化问题,可以假设图像总是 4 像素乘 4 像素,就像 图 2-1 中的那个图像。
第二部分 使用第一部分中的方法,写出 图 2-1 中花朵图像的二进制表示。
第三部分 解释你表示图像的方法给朋友听。然后把你的二进制数据给朋友,看看她是否能在不看原始图像的情况下画出上面的图像!
这个问题没有唯一的正确答案;可以参考附录 A 中的示例答案。
在练习 2-4 的第二部分,你像一个计算机程序一样负责将图像编码成二进制数据。在第三部分,你的朋友像一个计算机程序一样负责解码,将二进制数据还原成图像。希望她能够解码你的二进制数据并画出一朵花!如果她成功了,那太好了,你们一起展示了软件如何编码和解码数据!如果情况不太顺利,她画出的可能更像是腌黄瓜而不是花朵,那也没关系;你们展示了有时软件存在缺陷,导致意外结果。
表示颜色和图像的方法
如前所述,已经有标准的方法定义了以数字方式表示颜色和图像。对于灰度图像,一种常见的方法是每个像素使用 8 位,允许表示 256 种灰度级别。每个像素的值通常表示光的强度,因此 0 代表无光强度(黑色),255 代表最大光强度(白色),介于两者之间的是不同的灰度,从深到浅。图 2-2 展示了使用 8 位编码方案的不同灰度级别。

图 2-2:使用 8 位表示的灰度,显示为二进制、十六进制和十进制
表示除了灰度以外的颜色采用类似的方法。虽然灰度图像可以通过一个 8 位数表示,称为 RGB 的方法使用三个 8 位数表示红色、绿色和蓝色的强度,这三种颜色组合起来形成一个单一的颜色。为每种颜色分配 8 位意味着需要 24 位来表示整体颜色。
注
RGB 基于一种 加色模型,其中颜色由红色、绿色和蓝色光的混合组成。这与绘画中使用的 渐变色模型 不同,在后者中,混合的颜色是红色、黄色和蓝色。
例如,红色在 RGB 中通过将所有 8 位红色设为 1,其余 16 位的两个颜色设为 0 来表示。或者,如果你想表示黄色,这是一种红色和绿色的组合,但没有蓝色,你可以将红色和绿色的位设为 1,蓝色的位保持为 0。这个过程在图 2-3 中有说明。

图 2-3:使用 RGB 表示的红色和黄色
在图 2-3 中的两个例子中,所有“开启”的颜色都是 1,但 RGB 系统也允许红色、蓝色和绿色的组成颜色部分强度不一。每个组成颜色的值可以从 00000000(0 十进制/0 十六进制)到 11111111(255 十进制/FF 十六进制)。较低的值表示该颜色的较暗阴影,较高的值表示该颜色的较亮阴影。通过这种颜色混合的灵活性,我们几乎可以表示任何想象得到的颜色。
不仅有标准的颜色表示方法,还有多种常用的方法来表示整个图像。正如你在图 2-1 中看到的,我们可以通过一个像素网格来构建图像,每个像素设置为特定的颜色。多年来,已经设计出了多种图像格式来实现这一点。一种简单的表示图像的方法称为位图。位图图像存储每个单独像素的 RGB 颜色数据。其他图像格式,如 JPEG 和 PNG,使用压缩技术来减少存储图像所需的字节数,相比位图格式更为高效。
解读二进制数据
让我们再来分析一个二进制值:011000010110001001100011。你认为它代表什么?如果我们假设它是一个 ASCII 文本字符串,那么它表示“abc”。另一方面,也许它表示一个 24 位 RGB 颜色,从而是某种灰度色调。或者它可能是一个正整数,在这种情况下,它在十进制下的值是 6,382,179。这些不同的解释在图 2-4 中有所展示。

图 2-4:011000010110001001100011 的解释
那么它到底代表什么呢?它可以是这些中的任何一种,或者完全是其他的东西。这完全取决于数据被解释的上下文。文本编辑器程序会假设数据是文本,而图像查看器可能会假设它是图像中某个像素的颜色,计算器则可能会假设它是一个数字。每个程序都被设计为期望某种特定格式的数据,因此一个二进制值在不同的上下文中有不同的含义。
我们已经展示了如何使用二进制数据来表示数字、文本、颜色和图像。从这些例子中,你可以对如何存储其他类型的数据,如视频或音频,做出一些推测。数字化表示的数据类型没有限制。数字表示不一定是原始数据的完美复制,但在许多情况下这并不是问题。能够将任何事物表示为一串 0 和 1 是极其有用的,因为一旦我们构建了能够处理二进制数据的设备,我们就可以通过软件将其适配为处理任何类型的数据!
二进制逻辑
我们已经建立了使用二进制表示数据的实用性,但计算机不仅仅是存储数据。它们还让我们能够处理数据。在计算机的帮助下,我们可以读取、编辑、创建、转换、分享以及以其他方式操作数据。计算机赋予我们以多种方式处理数据的能力,利用硬件,我们可以编写程序来执行一系列简单的指令——像“将两个数字相加”或“检查两个值是否相等”这样的指令。实现这些指令的计算机处理器本质上基于二进制逻辑,这是一种描述逻辑陈述的系统,其中变量只能是两个值之一——真或假。现在,让我们来研究一下二进制逻辑,在这个过程中,我们将再次看到计算机中的一切都归结为 1 和 0。
让我们考虑一下二进制如何自然地与逻辑契合。通常,当有人提到逻辑时,他们指的是推理,或者说通过已知的事实进行思考,以得出有效的结论。当面对一组事实时,逻辑帮助我们判断另一个相关的陈述是否也为事实。逻辑关注的是真理——什么是真的,什么是假的。同样,一个位(bit)只能有两个值之一:1 或 0。因此,单个比特可以表示逻辑状态中的“真”(1)或“假”(0)。
让我们来看一个逻辑陈述的例子:
GIVEN a shape has four straight sides,
AND GIVEN the shape has four right angles,
I CONCLUDE that the shape is a rectangle.
这个例子有两个条件(四条边,四个直角),它们必须都为真,结论才能为真。对于这种情况,我们使用逻辑运算符 AND 将两个陈述连接在一起。如果其中任何一个条件为假,那么结论也为假。我在表 2-2 中表达了相同的逻辑。
表 2-2: 矩形的逻辑陈述
| 四条边 | 四个直角 | 是矩形 |
|---|---|---|
| 假 | 假 | 假 |
| 假 | 真 | 假 |
| 真 | 假 | 假 |
| 真 | 真 | 真 |
使用表 2-2,我们可以按如下方式解释每一行:
-
如果这个形状不有四条边且不有四个直角,那么它不是矩形。
-
如果这个形状不具有四条边且有四个直角,那么它不是矩形。
-
如果这个形状有四条边且没有四个直角,那么它不是矩形。
-
如果这个形状有四条边且有四个直角,那么它是矩形!
这种类型的表格被称为真值表:一种展示所有可能的条件组合(输入)及其逻辑结论(输出)的表格。表 2-2 是专门为我们关于矩形的陈述编写的,但实际上,这张表适用于任何通过 AND 连接的逻辑陈述。
在表 2-3 中,我将这个表格做得更加通用,使用 A 和 B 来表示我们的两个输入条件,使用 Output 来表示逻辑结果。具体来说,对于这个表格,Output 是 A AND B 的结果。
表 2-3: 与(AND)真值表(使用真和假)
| A | B | 输出 |
|---|---|---|
| 假 | 假 | 假 |
| 假 | 真 | 假 |
| 真 | 假 | 假 |
| 真 | 真 | 真 |
在表 2-4 中,我对我们的表格做了一点修改。由于本书讲的是计算机,我将假表示为 0,将真表示为 1,就像计算机一样。
表 2-4: 与(AND)真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
表 2-4 是当你处理使用 0 和 1 的数字系统时,标准的与(AND)真值表。计算机工程师使用这样的表格来表达组件在面对某一组输入时的行为。现在让我们看看这如何与其他逻辑运算符和更复杂的逻辑表达式一起工作。
假设你在一家商店工作,该商店仅对两种类型的顾客提供折扣:儿童和戴太阳镜的人。其他人都不符合折扣条件。如果你想用逻辑表达式陈述商店的政策,你可以这样说:
GIVEN the customer is a child,
OR GIVEN the customer is wearing sunglasses,
I CONCLUDE that the customer is eligible for a discount.
在这里,我们有两个条件(儿童,戴太阳镜),其中至少一个条件必须为真,结论才为真。在这种情况下,我们使用逻辑运算符“或”(OR)将两个语句连接起来。如果其中任何一个条件为真,那么结论也为真。我们可以将其表示为一个真值表,如表 2-5 所示。
表 2-5: 或(OR)真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
通过观察表 2-5 中的输入和输出,我们可以快速看到,当顾客是儿童(A = 1)或顾客戴太阳镜(B = 1)时,会给予折扣(输出 = 1)。请注意,A 和 B 的输入列值在表 2-4 和表 2-5 中完全相同。这是有道理的,因为这两张表都有两个输入,因此有相同的输入组合。不同之处在于输出列。
让我们将与(AND)与或(OR)结合起来,形成一个更复杂的逻辑表达式。为了这个例子,假设我每天都去海滩,如果天气晴朗且温暖,而且假设我每年生日时也去海滩。事实上,我只在这些特定情况下去海滩——我妻子说我在这方面太固执了。将这些想法结合起来,我们得到以下逻辑表达式:
GIVEN it is sunny AND GIVEN it is warm,
OR GIVEN it is my birthday,
I CONCLUDE that I am going to the beach.
让我们标记输入条件,然后为这个表达式写一个真值表。
条件 A 天气晴朗。
条件 B 天气温暖。
条件 C 今天是我的生日。
我们的逻辑表达式看起来像这样:
(A AND B) OR C
就像在代数表达式中,A 且 B 周围的括号意味着该部分表达式应首先进行评估。表 2-6 为此逻辑表达式提供了一个真值表。
表 2-6: (A 且 B) 或 C 真值表
| A | B | C | 输出 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 |
| 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 |
| 1 | 1 | 0 | 1 |
| 1 | 1 | 1 | 1 |
表 2-6 比简单的 AND 真值表稍微复杂一些,但仍然可以理解。表格格式使得查找特定条件并查看结果变得容易。例如,第三行告诉我们,如果 A = 0(今天不是晴天),B = 1(今天很暖和),C = 0(今天不是我的生日),那么输出 = 0(我今天不去海滩)。
这种逻辑是计算机经常需要处理的。事实上,正如之前提到的,计算机的基本功能归结为一组逻辑操作。虽然一个简单的 AND 运算符看起来与智能手机或笔记本电脑的功能相差甚远,但这些逻辑运算符是所有数字计算机的概念性构建块。
练习 2-5:编写一个逻辑表达式的真值表
表 2-7 显示了一个逻辑表达式的三个输入。完成表达式 (A 或 B) 且 C 的真值表输出。答案见 附录 A。
表 2-7: (A 或 B) 且 C 真值表
| A | B | C | 输出 |
|---|---|---|---|
| 0 | 0 | 0 | |
| 0 | 0 | 1 | |
| 0 | 1 | 0 | |
| 0 | 1 | 1 | |
| 1 | 0 | 0 | |
| 1 | 0 | 1 | |
| 1 | 1 | 0 | |
| 1 | 1 | 1 |
除了 AND 和 OR,还有一些其他常用的逻辑运算符用于数字系统的设计。在接下来的几页中,我将介绍每个运算符,并为每个运算符提供一个真值表。在 第四章 关于数字电路的部分,我们将再次使用这些运算符。
逻辑运算符 NOT 就是它听起来的意思,输出是输入条件的反面。也就是说,如果 A 为真,则输出为 不 真,反之亦然。如 表 2-8 所示,NOT 仅接受一个输入,而不是两个输入。
表 2-8: NOT 真值表
| A | 输出 |
|---|---|
| 0 | 1 |
| 1 | 0 |
运算符 NAND 意味着 NOT AND,因此输出是 AND 的反向。如果两个输入都为真,则结果为假。否则,结果为真。这一点在 表 2-9 中有显示。
表 2-9: NAND 真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
NOR 运算符意味着 NOT OR,因此输出是 OR 的反向。如果两个输入都为假,则结果为真。否则,结果为假。表 2-10 显示了这一点。
表 2-10: NOR 真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 0 |
XOR 是“异或”运算,意味着只有一个(排他性)输入为真时,结果才为真。也就是说,输出为真时,只有 A 为真或者只有 B 为真,而当两个输入都为真时,输出为假。详情见 表 2-11。
表 2-11: XOR 真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
研究二值变量(真或假)逻辑功能的学科称为 布尔代数 或 布尔逻辑。乔治·布尔在 19 世纪描述了这种逻辑方法,早于数字计算机的出现。他的工作为数字电子学的发展,包括计算机的诞生,奠定了基础。
总结
在本章中,我们介绍了二进制如何用来表示数据和逻辑状态。你学习了如何使用 0 和 1 来表示几乎任何类型的数据。我们以文本、颜色和图像作为二进制格式数据的示例。你还了解了各种逻辑运算符,如与(AND)和或(OR),并学习了如何使用真值表来表达逻辑语句。理解这些非常重要,因为今天计算机中复杂的处理器是基于复杂的逻辑系统。
当我们在 第四章讨论数字电路时,我们将回到二进制的主题,但在此之前,为了为你准备好这个话题,我们将在 第三章中绕道讲解电路基础知识。我们将探讨电学定律,了解电路是如何工作的,并熟悉一些在许多电路中常见的基本元件。你甚至还会有机会构建自己的电路!
第三章:电气电路**

我们已经在概念层面上讨论了计算的某些方面;现在让我们改变方向,看看计算的物理基础。让我们从回顾计算机的定义开始。计算机是一种电子设备,可以通过编程执行一组逻辑指令。
计算机是遵循由人类设计规则的设备,但最终计算机必须根据另一组规则行事:自然法则。计算机只是一个机器,就像所有机器一样,它利用自然法则来完成任务。特别是,现代计算机是电子设备,因此电学法则是构建这些设备的自然基础。为了全面理解计算,您需要掌握电力和电路的基础知识;这就是本章要讲解的内容。让我们从电气术语和概念开始,学习一些电学法则,了解电路图,最后构建一些简单的电路。
电气术语定义
为了讨论电路,您首先需要熟悉几个关键概念和术语。我将现在讲解这些电气概念,并解释它们如何相互关联。本节内容非常详细,所以让我们先通过表 3-1 进行概览,再深入探讨细节。
表 3-1: 关键电气术语汇总
| 术语 | 解释 | 单位 | 水的类比 |
|---|---|---|---|
| 电荷 | 使物质受到力的作用 | 库仑 | 水 |
| 电流 | 电荷的流动 | 安培 | 水通过管道的流动 |
| 电压 | 两点之间的电位差 | 伏特 | 水压 |
| 电阻 | 衡量电流通过物质的难易程度 | 欧姆 | 管道的宽度 |
表 3-1 提供了每个术语的简单解释,列出了其计量单位,并将每个术语与水系统中的类比进行了对比(如本章后面图 3-2 所示)。如果这暂时不完全明白,别担心!我们将在接下来的页面中更深入地讲解每个术语。
电荷
在学校里,您可能学过原子由带正电的质子、带负电的电子和不带电的中子组成。电荷使物质受到力的作用;不同电荷相吸,相同电荷相斥。在解释电路的概念时,我喜欢使用水流过管道的类比。在这个类比中,电荷就像水,电线就像管道。
电荷的计量单位是库仑。质子或电子的电荷量是一个微小的量,与单个库仑代表的电荷量相比几乎可以忽略不计。
电流
对我们讨论特别相关的是电荷的传输或移动,称为电流。电荷通过电线的流动类似于水流通过管道。在日常使用中,包括在本书中,我们说“电流在流动”或“电流流动”,虽然更准确地说,是电荷在流动,而电流是衡量流动强度的量。
在方程中表示电流时,使用符号I或i。电流以安培为单位,有时简称为安,缩写为A。1 安培等于每秒 1 库仑。假设你有两根电线,第一根电线通过 5A 电流,第二根电线通过 1A 电流。由于安培代表的是电流的流动速率,因此第一根电线的电荷流动速率是第二根电线的五倍。
电压
由于电荷通过电线流动类似于水流通过管道,让我们进一步扩展这个类比。到目前为止,我们有水(电荷)、一根简单的管道(一根铜线)和水流速率(电流)。接下来,我们加上一个泵,连接到管道上,用来推动水流动。水压越大,水流通过管道的速度越快。将这个类比应用到电路中,水泵代表的是电源,即提供电能的源,如电池。
在这个类比中,水压代表了一个新的概念:电压。就像水压影响水流通过管道的速率一样,电压影响电流——电荷流通过电路的速率。为了理解电压,回想一下科学课上学到的势能,它以焦耳为单位,是做功的能力。就电学而言,功是指将电荷从一个点移动到另一个点的过程。电势是每单位电荷的势能,单位是焦耳每库仑。电压被定义为两个点之间的电势差。也就是说,电压是将电荷从一个点移动到另一个点所需的每库仑功。
在方程中表示电压时,使用符号V或v。电压以伏特为单位,缩写为V。电压总是测量两个点之间的差异,例如电池的正负端子之间的电压。端子在此上下文中指的是电气连接点。电压越高,推动电荷从一个端子到另一个端子的“压力”就越大,因此,当电压源连接到电路中时,电流也越大。然而,即使没有电流流动,电压也可以存在。例如,一个 9 伏的电池在其端子之间的电压为 9V,即使没有连接到任何设备。
电阻
让我们回到水流类比。影响水流的另一个因素是管道的宽度。非常宽的管道允许水流动不受限制,而狭窄的管道则会阻碍水流。如果我们将这一类比应用于电路,管道的宽度就代表了电路中的电阻。电阻是衡量电流通过导体(允许电流流动的材料)时难易程度的指标。材料的电阻越高,电流通过它时的难度就越大。
在表示电阻的方程中,使用符号R。电阻的单位是欧姆,简称Ω(希腊字母欧米伽)。铜线的电阻非常低;为了简化起见,我们假设它的电阻为零,这意味着电流可以自由流过它。电路中使用一种叫做电阻器的电气元件来引入特定数量的电阻,满足需要。请参见图 3-1 查看典型电阻器的照片。

图 3-1:电阻器
水流类比
现在我们已经讲解了电气的关键概念,接下来让我们回顾一下我们一直用来解释电路工作原理的水流类比,如图 3-2 所示。

图 3-2:使用水流类比描述的电路
水泵通过管道推动水流,就像电池通过电路推动电荷流动一样。就像水流一样,电荷也会流动;我们称之为电流。水压影响水流速率,同样地,电压影响电流——电压越高,电流越大。水会自由地通过宽管道流动,就像电流通过铜线一样。狭窄的管道会限制水流,就像电阻器限制电流一样。
现在,让我们将你学到的知识应用于一个示例电路,该电路由一个电池和连接到其端子的导体组成。电池中储存有势能。电池端子之间的电压是一定数量的伏特,代表电势差。当导体连接到电池端子时,电压就像一种压力,推动电荷通过导体,形成电流。导体有一定的电阻;低电阻会导致较大的电流,而高电阻会导致较小的电流。
欧姆定律
电流、电压和电阻之间的关系由欧姆定律定义,欧姆定律告诉我们,从一个点流到另一个点的电流等于这两个点之间的电压除以它们之间的电阻。或者用方程表示:
I = V / R
假设你有一个 9 伏特的电池,电池两端连接着一个 10,000Ω的电阻。欧姆定律告诉我们,流过电阻的电流将是 9V / 10,000Ω = 0.0009 安培,或者说是 0.9 毫安(mA)。请参考表 1-2 了解我们为什么在这里使用“milli-”前缀。请注意,在本章中,我们再次使用的是 10 进制,所以你可以暂时放下二进制的思维,以正常人的方式思考数字!
交流电和直流电
现在值得简单了解一下交流电(AC)和直流电(DC)。不是说澳大利亚的摇滚乐队。AC代表交流电,是一种周期性改变方向的电流。与之相对的是DC,即直流电,电流只沿一个方向流动。交流电用于从电厂将电力传输到家庭和企业。插座直接连接的电器、灯具、电视等设备运行在交流电上。像笔记本电脑和智能手机这样的小型电子设备则使用直流电。当你为设备充电时,适配器将来自插座的交流电转换为设备所需的直流电。电池也提供直流电。AC 和 DC 这两个术语也用于电压(例如,直流电压源),在这种情况下,它们基本上意味着“交流电压”或“直流电压”。我们在本书中涉及的所有电路都是直流电,所以除了了解 AC 和 DC 之间的区别外,你不需要担心 AC 的细节。
电路图
当我们描述电路时,图示可以是一个非常有用的视觉辅助工具。电路图使用标准符号表示各种电路元件。连接这些符号的线代表电线。让我们来看一下常见电路元件在电路图中的表示方式。图 3-3 显示了电阻器和电压源(如电池)的符号。+表示电压源的正端,–表示负端。换句话说,+端相对于–端具有正电压。

图 3-3:电阻器符号(左)和电压源符号(右)
使用这两个符号,我们可以绘制一个电路图,表示我们之前提到的示例电路(一个 9 伏特的电池和一个 10,000Ω电阻连接在其端子之间)。这个电路图如图 3-4 所示。请注意电阻器上的 10kΩ标记;这是 10,000Ω的简写(换句话说,k 代表千,即千的意思)。根据我们之前的欧姆定律计算,我们知道电阻器中有 0.9 毫安(mA)的电流流动,所以这一点也在电路图中标示出来。

图 3-4:一个 9 伏特的电池和一个 10,000Ω电阻连接在其端子之间
我们还可以将电流表示为一个回路,如图 3-5 所示。这种可视化帮助传达了电流流经整个电路,而不仅仅是电阻器的概念。

图 3-5:电流流动的回路示意
说到电路,这是一个好时机,回顾一下已经提到过好几次但我还没有定义的术语。电路是由一组电气元件组成,这些元件以某种方式连接,使得电流在回路中流动,从电源出发,经过电路元件,再回到电源。撇开电力不谈,电路这个通用术语指的是从起点到终点的路径。这是一个重要的概念,因为没有电路,电流就无法流动。带有断路的电路称为开路电路,当电路是开路时,电流不会流动。另一方面,短路是电路中一种允许电流以极小或没有阻力流动的路径,通常是无意的。
习题 3-1:使用欧姆定律
看看图 3-6 中的电路。电流是什么,I?答案在附录 A 中。

图 3-6:使用欧姆定律求电流。
在直流电路的上下文中,地面,有时缩写为 GND,是指我们用来与电路中其他电压进行比较的一个点。换句话说,地面被认为是 0V,我们测量电路中其他电压时是相对于地面而言的。正如我们之前所讲,电压总是测量两个点之间的差异,因此重要的是电势的差,而不是某一个点的电势。通过将地面作为 0V 的参考点,我们可以更容易地讨论电路中其他点的相对电压。在我们这里讨论的简单直流电路中,电池或其他电源的负极通常被认为是地面。
地面这个术语来源于某些电路物理上与大地相连的事实。它们字面上与地面连接,而这种连接作为一个 0V 的参考点。便携式或电池供电的设备通常没有物理的地面连接,但我们仍然在这些电路中称指定的 0V 参考点为地面。
有时,工程师们不会将电路图绘制成回路的形式;相反,他们会专门使用图 3-7 中显示的符号来标识地面和电压源的连接。这使得电路图更加简洁,但并不会改变电路的物理连接方式;电流仍然从电源的正极流向负极。

图 3-7:地面符号(左)和相对于地面的电压源符号(右)
作为示例,图 3-8 展示了我们之前讨论的电路,左侧是原电路,右侧则是使用图 3-7 中介绍的地和电压符号表示的相同电路。

图 3-8:这两种电路是等效的。
图 3-8 中的两个电路在功能上是等效的;只是图示表示不同。
基尔霍夫电压定律
解释电路行为的另一个原理是基尔霍夫电压定律,它告诉我们,电路中的电压总和为零。这意味着,如果一个电压源向电路提供 9V,那么电路中的各个元件必须共同“使用”9V。电路环路中的每个元件都会导致电势的降低。当这种情况发生时,我们说电压在每个元件上被降落。让我们以图 3-9 中的电路为例。

图 3-9:基尔霍夫电压定律示意电路
在图 3-9 中,我们有一个 10 伏的电源连接到三个电阻上。当电阻按单一路径(串联)连接时,总电阻就是各个电阻值的总和。在这种情况下,总电阻为 4kΩ + 6kΩ + 10kΩ = 20kΩ。根据欧姆定律,我们可以计算出通过该电路的电流为 10V / 20kΩ = 0.5mA。电路中有四个测量电压的点,分别标记为 V[A]、V[B]、V[C]和 V[D]。我们将根据电源的负端来确定每个点的电压。
让我们从简单的开始:V[D]直接连接到电源的负端,因此 V[D] = 0V,或地。类似地,V[A]连接到电源的正端,因此 V[A] = 10V。现在我们从基尔霍夫电压定律得知,每个电阻都会导致电压降落,所以 V[B]必须小于 10V,V[C]必须小于 V[B]。
在 4kΩ电阻上降落了多少电压?欧姆定律指出V = I × R,所以电压降是 0.5mA × 4kΩ = 2V。这意味着 V[B]将比 V[A]少 2V。因此,V[B] = 10 – 2 = 8V。类似地,6kΩ电阻上的电压降为 3V。因此,V[C] = 8 – 3 = 5V。即使不进行计算,我们也知道根据基尔霍夫电压定律,10kΩ电阻上必须降落 5V 电压,因为它是接近负端(0V)的最后一个电路元件。在图 3-10 中,我们的图示已更新,包含了电压和电压降。

图 3-10:简单电路中的电压降落
回顾这个例子,电压源提供 10V,我们认为这是一个正电压。每个电阻都会导致电压降,我们认为这些电压降是负的。如果我们将正电压源与负电压降相加,就得到 10V - 2V - 3V - 5V = 0V。电路中电压的总和为 0,这符合基尔霍夫的电压定律。
你可能会想,这是否只适用于特定值的电阻。毕竟,在给出的例子中,数学运算非常顺利,可能有点太顺利了!在下面的练习中,我们将示例电路中的一个电阻从 4kΩ 改为 24kΩ,你可以看到基尔霍夫的电压定律依然成立。
练习 3-2:找出电压降
给定图 3-11 中的电路,I 是多少电流?每个电阻的电压降是多少?找到标记的电压:V[A]、V[B]、V[C] 和 V[D],每个电压相对于电源的负端测量。答案见附录 A。

图 3-11:另一个说明基尔霍夫电压定律的电路
现实世界中的电路
让我们来看一下我们简单的 9V/10kΩ 电路(来自图 3-4)如何在现实世界中构建。在图 3-12 中的照片中,我已经用鳄鱼夹将 10kΩ 电阻连接到 9 伏电池上。

图 3-12:一个 10kΩ 电阻通过鳄鱼夹连接到 9 伏电池
这种方法可行,但还有更好的方式来构建电路。面包板是用于电路原型制作的基础板。历史上,类似用来烤面包的板子曾用于此目的,但遗憾的是,今天的面包板与面包没有任何关系!面包板(图 3-13)可以方便地连接各种电子元件。

图 3-13:面包板的一部分
如图 3-13 所示,面包板的边缘通常有长列,通常标有+和-。这些列通常也有颜色编码,红色表示正极,蓝色表示负极。沿着这样的边缘列的所有孔是电气连接的,这些列用于为电路提供电源,因此通常将电池或其他电源连接到这些列。类似地,通常有五个孔的行(在图 3-13 中,这些行位于边缘列的右侧)也会连接在一起。两个组件可以通过将每个组件的一个端插入相同的行来连接。无需焊接、夹子或电气胶带!
图 3-14 是图 3-4 中电路在面包板上构建的照片。

图 3-14:一个在面包板上构建的简单电路
如你所见,这是连接电子元件的更整洁、更简单的方式。我做了一些优化,把电阻的两端插入电源柱,直接与电池连接。
注意
请参考项目 #1,位于第 45 页,开始本书的第一个项目!前面的练习要求你从思维上解决问题,而项目则要求你做得更多,包括获取一些硬件。当然,这需要一定的努力和费用,但我相信动手实践是深入理解本书所涵盖概念的最佳方式。翻到本章末尾,你可以找到项目部分,在那里你可以自己动手制作电路!
发光二极管
我们到目前为止讨论的简单电路展示了电流和电压的基本原理,但它们没有什么视觉上的趣味。我发现,让一个沉闷的电路变得有趣的最简单方法就是加入一个发光二极管(LED)。典型 LED 的照片见于图 3-15。

图 3-15:LED
让我们先了解 LED 的基础知识,然后将其加入电路中。“发光”这一部分名字是显而易见的;这是一个发光的电路元件。具体来说,它是一个发光的二极管。二极管是一个电子元件,它只允许电流单向流动。与允许电流双向流动的电阻不同,二极管在一个方向上电阻极低(允许电流流动),而在另一个方向上电阻极高(阻碍电流流动)。LED 是特殊的二极管类型,当电流流过它时,它会发光。LED 有多种颜色。LED 的电路符号见于图 3-16。

图 3-16:LED 的符号
为了让 LED 发光,我们需要确保适量的电流流过它。标准的红色 LED 最大电流为约 25mA;我们不希望超过这个最大电流,因为过大电流会损坏 LED。我们设定目标电流为 20mA,这样电流就能流过我们的 LED。较低的电流值也能正常工作,但 LED 不会那么亮。
那么,我们如何确保适量的电流流过 LED 呢?我们只需要选择一个合适的电阻来限制电路中的电流。但在此之前,你需要了解 LED 的另一个特性——正向电压,它描述了当电流流过 LED 时,LED 上会掉落多少电压。典型的红色 LED 的正向电压大约为 2V。正向电压通常表示为 V[f]。
我们的电路图中包含了一个电池、LED 和限流电阻,见于图 3-17。图中还显示了所需的 20mA 电流。

图 3-17:一个带 LED 的基础电路
在图 3-17 中,我们有一个 9 伏电池,一个前向电压为 V[f] 的 LED,以及一个电阻值为 R 的电阻器。电阻器上的电压降是 V[R]。请记住,电阻器上的电压降会随流过它的电流变化,而 LED 的电压降是由其前向电压特性定义的。在我们之前的电路中,只有电池和电阻器(图 3-4),整个 9V 电压都降落在电阻器上。现在我们有两个电路元件连接到电池上,基尔霍夫电压定律告诉我们,部分电压会降落在 LED 上,其余的电压会降落在电阻器上。作为提醒,你可以将电池视为提供电压,其他元件则是使用电压。如果我们将这个应用于我们的电路(图 3-17),那么 V[f] + V[R] = 9V。
假设我们使用一个标准前向电压为 2V 的 LED,那么 V[R] = 9V – 2V = 7V。让我们用这些电压值更新我们的电路图,如图 3-18 所示。

图 3-18:一个带 LED 的基础电路,显示了电压降
这让我们只剩下一个未知数,R,电阻的电阻值。我们可以通过欧姆定律来计算:I = V / R,或者R = V / I。所以我们得到 R = 7V / 20mA = 350Ω。这样,我们就解决了如何确保正确的电流通过 LED 的最后一块拼图。我们需要一个大约 350Ω 的电阻,连接到我们的电池和 LED 上。
注意
请参阅项目 #2,见第 50 页,在这里你可以自己搭建 LED 电路并看到它亮起来!
总结
在本章中,我们介绍了电路,这是现代计算设备的物理基础。你了解了电学概念,如电荷、电流、电压和电阻。我们还介绍了两个支配电路行为的定律——欧姆定律和基尔霍夫电压定律。你学到了电路图以及如何构建自己的电路。理解电路的基础将帮助你从根本上理解计算机的工作原理。下一章将介绍数字电路,将二进制逻辑和电路概念结合起来。
项目 #1:搭建并测量电路
现在你已经掌握了足够的知识来构建自己的电路。没有比自己动手更好的学习方式了!为了开始,你需要一些硬件,这些都可以在线购买,或者如果你运气好,在附近的电子商店购买。请参阅“购买电子元件”部分,位于第 333 页,了解如何获取这些零件。以下是你在本项目和下一个项目中需要的材料:
-
面包板(400 点或 830 点型号)
-
电阻(多种电阻。在这个项目中你将特别使用一个 10kΩ电阻。务必使用 10kΩ电阻,而不是 10Ω电阻。使用阻值过低的电阻会产生过多电流,可能会导致电路过热!)
-
数字万用表(你将需要它来检查电路的电压、电流和电阻。)
-
9 伏电池
-
9 伏电池夹连接器(这使得连接电池变得更容易。)
-
至少一个 5mm 或 3mm 的红色 LED(发光二极管)
-
可选:剥线钳(你可能需要一个来剥去导线两端的塑料并露出铜线。)
-
可选:鳄鱼夹(这些可以使电池与面包板或万用表与电路的连接更为简便。)
-
可选:面包板跳线(将它们连接到 9 伏电池夹的端口,以便更容易插入面包板。)
即使在我们使用的低电压下,电路组件也有可能会变得很热。考虑到这一点,我建议你在连接电路时先断开电源(此处是电池),直到最后一步才连接电源。
一旦你准备好所有组件,连接它们:
-
将一个 10kΩ电阻的任意一端连接到正电源列。
-
将电阻的另一端连接到负电源列。
-
将电池夹的红色/正极导线连接到面包板的正电源列。
-
将电池夹的黑色/负极导线连接到面包板的负电源列。
-
将电池夹连接到 9 伏电池的端子。
9 伏电池夹的接线有时比较脆弱,这使得它很难插入面包板。如果你遇到这个问题,可以尝试将跳线的一端连接到脆弱的电池线,另一端连接到面包板。你可以用电工胶带或鳄鱼夹连接这两根线(见图 3-19),如果你懂得使用电烙铁,也可以将它们焊接在一起。如果你尝试这些方法,请小心保持负极和正极导线的金属部分分开。意外连接它们会导致电池短路,使导线变得非常热,并迅速耗尽电池电量。

图 3-19:使用鳄鱼夹和跳线解决脆弱的 9 伏电池夹接线问题
你可能会想知道如何确定电阻的欧姆值。电阻是通过颜色编码的;色环表示倍数,颜色代表数值。网上有许多免费的电阻色环计算器和图表,所以我在这里就不详细说明了。对于 10kΩ电阻,寻找具有棕色、黑色和橙色色环的电阻,顺序如上。第四个色环通常是金色或银色,表示制造商的公差,即允许的偏差值。
现在你已经搭建好了电路,但如何判断电路是否在工作呢?不幸的是,这个电路并没有直观的方式表明它是否工作,因此是时候拿出万用表,测量电路的各个特性了。使用万用表时,你需要两根测试线(用于测量的电缆)。除非你的万用表的测试线是固定的,否则它可能有两个或三个输入端口来连接测试线,如图 3-20 所示。
如图 3-20 所示,将一根测试线连接到标有“COM”(表示“公共端”)的输入端口。通常我们将黑色测试线连接到 COM 端口。如果你的万用表只有两个输入端口,只需将第二根测试线(通常是红色的)连接到第二个端口即可。具有三个端口的万用表通常有一个 COM 输入端口、一个高电流输入端口和一个低电流输入端口。在这种情况下,你需要将第二根测试线连接到低电流输入端口;它通常标有支持的各种测量类型,例如 V Ω mA。通常,高电流输入端口标有 A、10A、10A 最大或类似字样。再次强调,这不是你需要使用的端口。有些万用表有四个输入端口;如果是这种情况,你必须使用不同的输入端口来进行低电流测量与电压和电阻测量。无论你使用哪种类型的万用表,如果有任何疑问,请查看使用手册。

图 3-20:将测试线连接到万用表。具有两个输入端口的万用表(左);具有三个输入端口的万用表(右)。
你的万用表将提供选择功能,让你决定是测量电压、电流还是电阻。我们先从电压开始。将万用表设置为测量直流电压。通常,直流电压会用 V 和旁边的 DC 字母表示,或者你可能会看到 V 旁边有一系列的破折号表示直流(而波浪线表示交流)。
一旦你将万用表设置为测量直流电压,通过将一根测试线接触到电阻的左侧(金属对金属),另一根接触到右侧,来测量电阻两端的电压。记住,电压总是测量两个点之间的,所以我们需要在电阻的两端进行测量。由于我们使用的是 9 伏的电池为电路供电,且电路中唯一的元件是电阻,因此你预计会测得大约 9V 的电压。在测量过程中,你可能会注意到万用表显示的数值会有所变化(通常只是最小的有效数字,即右侧的数字)。这是数字万用表工作原理的结果,并不意味着万用表或电路有故障。
现在试着交换表笔,将左侧的表笔接到电阻器的右侧,右侧的表笔接到电阻器的左侧。你应该会看到电压值变为负值(或者如果之前是负值,则变为正值)。这是因为仪表正在测量电势差,并将连接到 COM 的表笔视为 0V;显示的测量值是另一个表笔的电压差。在图 3-21 中,你可以看到我的电路的电压测量值——9.56V。这个值听起来差不多,因为新的电池通常比广告上标示的电压略高。

图 3-21:测量电压
接下来让我们测量电阻器的电阻。首先,将万用表从电路中断开,以防在更改设置时发生意外损坏。然后将万用表设置为电阻档,这通常标记为Ω。未连接任何东西时,万用表很可能会显示左侧的 1 或 OL,这表示电阻值太大无法显示——空气的电阻非常高!将万用表的两个表笔接触在一起时,显示屏应显示零。要测量电阻器的电阻,你需要将其从电池中断开,但如果有帮助的话,你可以将其保留在面包板上。为了确保你看到准确的读数,测量时避免触碰电路元件和导线的金属部分;如果触碰了,你身体的电阻可能会改变测量值。你可以在图 3-22 中看到我的电阻测量值;在我的情况下,值是 9.88kΩ。我使用的电阻器的颜色带为棕色、黑色和橙色(表示 10,000Ω),后面有一条金色带(表示 5%的公差),所以这个测量值是合适的。也就是说,9.88kΩ在 10kΩ的 5%公差范围内。
在这个阶段,你已经知道了电压和电阻的测量值,因此可以通过欧姆定律计算预期的电流——电流等于电压除以电阻。对于我的电路,这个值将是 9.56V / 9,880Ω = 0.97mA。在测量电路中的电流之前,使用你的电压和电阻测量值进行相同的计算,这样你就可以知道你应该期待在电路中流动的电流大小。

图 3-22:测量电阻
现在测量电路中的电流,看看它与计算值有多接近。测量电流与测量电压或电阻有所不同。为了让万用表能够测量电流,电流必须通过它。换句话说,仪表必须成为电路的一部分,如图 3-23 所示。
记得在设置万用表测量直流电流之前断开连接。直流电流符号可能是 A 或 mA,后面带有 DC,或者是一系列短横线表示直流。一些万用表有一个设置用于同时测量直流和交流电流;在这种情况下,符号可能同时显示直线和波浪线。将万用表设置为正确的档位后,将其连接到电路中,如图 3-23 所示。

图 3-23:测量电流时如何连接万用表
希望您的测量值接近计算值。如图 3-24 所示,我的电流测量值为 0.97mA,这与我之前的计算相同。

图 3-24:测量电流。电阻的右侧和电池的黑线不需要连接到面包板上。
如果您已经跟随到此步骤,那么恭喜您!您刚刚构建并测量了一个电路,或者至少,您了解了我是如何做到的。然而,您可能会感到有些失望。我理解。说实话,除了它具有极高的教育价值外,这个电路其实有些无聊且没什么实际用途!在下一个项目中,我们来做一些更有趣的事情吧。
项目 #2:构建一个简单的 LED 电路
现在轮到您自己构建一个 LED 电路并让它亮起来了!假设您完成了之前的项目,您可以将 9 伏电池夹保持连接到您的面包板上,但让我们用更合适的电阻替换 10kΩ电阻。根据我们在本章“发光二极管”一节中的计算,在第 42 页中我们发现,我们需要一个 350Ω的电阻来提供 20mA 的电流。如果我查看我的大电阻包,我不太可能找到一个 350Ω的电阻,您也不太可能找到。您可能会找到的最接近的电阻是 330Ω。
现在我们可以使用多个电阻来精确得到 350Ω,但有必要吗?330Ω是否已经足够接近?让我们来看看。如果我们保持使用 9 伏电池,并且仍然假设我们需要在电阻上降压 7V,那么欧姆定律告诉我们,电路中的电流将是I = V / R = 7V / 330Ω = 21.2mA。这样就可以了,因为典型的红色 LED 最大电流大约为 25mA。
值得指出的是,您购买的任何 LED 可能与我描述的特性有所不同。如果您的 LED 附带了数据表,或者在线列出了规格,请检查这些规格并进行自己的计算:您的具体 LED 的实际最大电流或期望电流是多少?您的具体 LED 的实际正向电压是多少?请注意,这通常会因 LED 颜色而有所不同。
在构建电路之前,你需要了解关于 LED 的一件事。与电阻不同,电流可以双向流动,LED 设计只允许电流单向流动,因此你需要知道哪个端口是正极,哪个是负极。正极(阳极)通常有较长的引脚,负极(阴极)有较短的引脚,如图 3-25 所示。电流从正极流向负极。

图 3-25:识别 LED 的阳极和阴极
一旦你确定了数值并知道要使用的电阻值,将电路按下述方式连接,并如图 3-26 中的电路图所示。
-
临时将 9 伏电池从面包板上断开。
-
将 LED 的较长引脚连接到正电源列。
-
将 LED 的较短引脚连接到面包板上任何空闲的排针。
-
将电阻的一端连接到 LED 的较短引脚所在的同一排针。
-
将电阻的另一端连接到负电源列。
-
重新连接 9 伏电池到你的面包板。

图 3-26:简单的 LED 电路
图 3-27 是我的 LED 的照片,按照描述连接。看看它的光芒!希望你的 LED 也在电路完成后亮起来。

图 3-27:发光的 LED;未显示连接的电池
第四章:数字电路

到目前为止,我们已经讨论了计算机的两个方面。首先,计算机工作在一个由 0 和 1 组成的二进制系统中。其次,计算机是建立在电子电路上的电子设备。现在是时候将这两个方面结合起来了。在本章中,我们定义了一个电路成为数字电路的含义。我们将探讨实现数字电路的方法,包括晶体管所扮演的角色。最后,我们将考察逻辑门和集成电路,它们是更复杂组件的构建模块,这些内容将在后续章节中讨论。
什么是数字电路?
你可能注意到,在上一章中我们构建的电路并不是数字电路——它们是模拟电路。在那些电路中,电压、电流和电阻可以在一个广泛的范围内变化。这并不令人惊讶;我们的世界本来就是模拟的!然而,计算机在数字领域工作,要理解计算机,我们需要了解数字电路。如果我们希望电路是数字的,我们必须首先定义在电子学中数字电路的含义,然后我们可以利用模拟元件构建数字电路。
数字电路 处理表示有限状态数的信号。本书讨论的是二进制数字电路,因此 0 和 1 是唯一需要考虑的两种状态。我们通常使用电压来表示数字电路中的 0 或 1,其中 0 代表低电压,1 代表高电压。通常,低电压意味着 0V,而高电压则可能是 5V、3.3V 或 1.8V,这取决于电路的设计。实际上,数字电路并不需要精确的电压来判断为高或低。通常,一定范围的电压可以被认为是高或低。例如,在一个标称为 5 伏特的数字电路中,输入电压在 2V 到 5V 之间被认为是高电压,而在 0V 到 0.8V 之间则被认为是低电压。任何其他电压级别都会导致电路中不确定的行为,应当避免。
通常,地面是数字电路中最低的电压,电路中的所有其他电压都相对于地面为正。如果数字电路由电池供电,我们认为电池的负极端子是地面。对于其他类型的直流电源也是如此;负极端子或电线被视为地面。
当提到数字电路中的 0 和 1 状态时,常常会出现许多术语和缩写,它们的意思相同。这些术语经常可以互换使用。以下是一些常见的低电压和高电压术语:
低电压 低、LO、关闭、地面、GND、假、零、0
高电压 高、HI、开启、V+、真、1、一
机械开关的逻辑
现在我们已经确定高电压和低电压分别表示数字电路中的 1 和 0,接下来让我们考虑如何构建一个数字电路。我们希望电路的输入和输出电压始终保持预定的高或低值,或者至少在允许的范围内。为了帮助我们实现这一点,让我们引入一个非常简单且熟悉的电路元件:机械开关。开关 很有用,因为它本质上是数字化的。它要么开,要么关。当开关打开时,它就像一根简单的铜线,电流可以自由流过。当开关关闭时,它就像一个开路,电流无法通过。我们用图 4-1 中所示的符号表示开关。

图 4-1:开关的电路图符号——开/关(左),闭/开(右)
开关符号传达了这样的概念:当开关处于关闭位置时,它是开路,当开关处于开启位置时,它是闭路。你可以把开关符号和开关本身看作一个栅栏门,要么是开着的,要么是关着的。电流在开关关闭时会流过它。在现实中,开关有多种形状和大小,如图 4-2 所示。

图 4-2:一些电气开关
注意,在图 4-2 中,两个靠近我们的开关是按键开关,这类开关你可能不常把它当作普通的开关。按键开关也叫做瞬时开关,因为它只有在按钮被按下时才闭合。释放按钮的压力后,开关会断开。
现在我们引入了一个可以轻松打开和关闭的电路元件,让我们用开关构建一个像逻辑与操作符一样的数字电路。如果你记得第二章的内容,一个两输入的逻辑与操作符当两个输入都是 1 时输出 1,否则输出 0。作为提示,AND 的真值表已在表 4-1 中重复。
表 4-1: 与(AND)真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
现在让我们根据以下指南将其转化为电路:
-
输入 A 和 B 用机械开关表示。我们用一个打开的开关表示 0,用一个闭合的开关表示 1。
-
输出由我们电路中特定点的电压决定,称为 V[out]。
-
如果 V[out] 大约为 5V,输出为逻辑 1;如果 V[out] 大约为 0V,输出为逻辑 0。
考虑图 4-3 中所示的电路。这是使用开关实现的逻辑与(AND)。

图 4-3:使用开关实现的逻辑与(AND)
如果图 4-3 中的任一开关处于关闭状态(开/0),则电流不会流动,V[out]的电压为 0V。如果两个开关都处于开启状态(闭合/1),则形成通向地的路径,电流流动,V[out]的电压为 5V。换句话说,如果 A 和 B 都为 1,则输出为 1。
我们采用相同的方法来处理逻辑或门。或门的真值表,如第二章中首先介绍的,见表 4-2。
表 4-2: 或门真值表
| A | B | 输出 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
看一下图 4-4 所示的电路,这是一个用开关实现的逻辑或门。

图 4-4:用开关实现的逻辑或门
在图 4-4 中,当两个开关都处于关闭状态(开/0)时,电流不流动,V[out]的电压为 0V,即逻辑 0。当任一开关被打开(闭合/1)时,电流流动,V[out]的电压为 5V。换句话说,如果 A 或 B 为 1,则输出为 1。
神奇的晶体管
在我们设计数字电路的过程中,刚刚讨论过的基于开关的电路是一个很好的概念起点。然而,在计算设备中,我们不能实际使用机械开关。计算机的输入非常多,使用开关来控制这些输入并不是一个很好的设计。此外,计算设备需要将多个逻辑电路连接在一起——一个电路的输出需要成为另一个电路的输入。为了实现这一点,我们的开关需要通过电控,而不是机械控制。我们不需要机械开关,我们需要电子开关。幸运的是,有一种电路元件可以充当电子开关:晶体管!
晶体管是一种用于开关或放大电流的设备。就我们来说,我们主要关注晶体管的开关能力。晶体管是现代电子学的基础,包括计算设备。晶体管有两种主要类型:双极性结晶体管(BJT)和场效应晶体管(FET)。这两种类型的区别在这里并不重要;为了简化起见,我们仅关注一种类型:BJT。
BJT(双极性晶体管)有三个端子:基极、集电极和发射极。BJT 有两种类型:NPN 和 PNP。这两者在基极端子施加电流时的响应方式不同。就我们讨论的目的而言,我们关注 NPN 型 BJT。请参见图 4-5 中的电路图和 NPN 晶体管的照片。

图 4-5:NPN 晶体管的符号(左)和照片(右)
在 NPN 晶体管中,向基极施加小电流可以使较大的电流从集电极流向发射极。换句话说,如果我们把晶体管看作一个开关,那么在基极施加电流就像是打开晶体管,而去掉电流则关闭晶体管。让我们来看一下如何将晶体管接成电子开关,如图 4-6 所示。

图 4-6:NPN 晶体管作为开关
在图 4-6 中,一个 NPN 晶体管与几个带有不同标注电压的电阻相连。V[cc]是施加在集电极端子的正电源电压,这为我们的电路提供电力。如果你有疑问,V[cc]中的“cc”代表“共集电极”,V[cc]是 NPN 电路中正电源电压的典型表示。V[out]是我们希望控制的电压;当晶体管作为开关打开时,我们希望此电压为高,当开关关闭时,该电压为低。V[in]作为控制开关的电压。与翻动机械设备不同,我们可以使用电压 V[in]来控制开关。
让我们考虑如果将 V[in]设置为低或高电压时会发生什么。如果 V[in]为低,接地连接,那么没有电流流向晶体管的基极。在基极没有电流的情况下,晶体管就像一个开路,集电极和发射极之间没有电流流动。这意味着 V[out]也为低。图 4-7 说明了这一点。

图 4-7:NPN 晶体管作为关闭状态的开关
在图 4-7 的左侧是我们正在讨论的基于晶体管的电路,右侧是一个基于开关的电路,表示相同的状态。换句话说,左侧的电路与右侧的电路在效果上是一样的;我只是将晶体管替换成了开关,以清楚地说明晶体管在此状态下就像是一个开路的开关。
如果 V[in]为低,则没有电流流动。另一方面,如果 V[in]为高,则会有电流流向晶体管的基极。这个电流使得晶体管从集电极到发射极导电。也就是说,V[out]实际上连接到 V[cc],因此输出为高,如图 4-8 所示。

图 4-8:NPN 晶体管作为打开状态的开关
在图 4-8 的左侧是我们正在讨论的基于晶体管的电路,右侧是一个基于机械开关的电路,具有相同的有效状态。晶体管在此状态下就像是一个闭合的开关。
逻辑门
现在我们已经确定晶体管可以作为电控开关使用,接下来我们可以构建实现逻辑功能的电路元件,其中输入和输出为高低电压。这些元件被称为逻辑门。我们先从之前设计的与门电路开始,将机械开关替换为晶体管。这样做的好处是,我们只需改变电压就能改变电路的输入,而不需要翻动机械开关。虽然机械开关是人类与电路交互的好方法,但电子开关允许多个电路相互作用——一个电路的输出可以轻松地成为另一个电路的输入。
我们之前使用机械开关构建了与门电路(见图 4-3)。现在我们使用晶体管作为开关来实现相同的功能,如图 4-9 所示。

图 4-9:使用晶体管实现的逻辑与门
在图 4-9 中,如果 V[A]和 V[B]是高电平(逻辑 1),那么电流会通过两个晶体管,V[out]也将是高电平(逻辑 1)。如果 V[A]或 V[B]是低电平(逻辑 0),则电流不会流动,V[out]也会是低电平(逻辑 0)。这个电路实现了逻辑与门。
可以使用类似的方法来实现逻辑或门。这个设计练习和项目留给你来完成。
练习 4-1:用晶体管设计一个逻辑或门
绘制一个逻辑或门电路图,使用晶体管作为输入 A 和 B。将使用机械开关的图 4-4 电路进行改造,但使用 NPN 晶体管代替。解决方案请见附录 A。
注意
请参阅项目 #3,在第 66 页中,你可以构建使用晶体管的逻辑与门和逻辑或门电路。
我们刚刚看到,如何用晶体管和电阻器构建一个实现逻辑功能的逻辑门。从现在开始,我将隐藏逻辑门的实现细节;我们将把整个逻辑门看作一个单独的电路元件。这不仅仅是一种理论上看待逻辑门的方式;它也与这些电路元件在现实世界中的使用方式一致。逻辑门已经作为一个完整的电路元件被组装并包装出售,所以通常你不需要自己从晶体管开始构建它们,除非作为学习练习。标准的电路符号已经为各种逻辑门定义。你可以在图 4-10 中看到一些最常见的符号。
在回顾图 4-10 中的各种逻辑门时,请注意在各种符号上添加的小圆圈,用来表示 NOT 或反转。NOT 门是这种情况的最简单例子,它接受一个输入,其输出是该输入的反转。因此,1 变为 0,0 变为 1。NAND 门的输出与 NOT AND 相同;它的输出是普通 AND 门输出的反转。NOR 门也是如此。您将在其他逻辑符号的地方看到小圆圈,它们表示 NOT 或反转。
在继续之前,让我们在这里暂停一下,反思一下我们刚才讲解的内容的某些方面。首先,我们研究了逻辑门是如何在内部工作的。接着,我们将这个设计打包,赋予它一个名称和一个符号。我们故意隐藏了电路的实现细节,同时继续记录其预期行为。我们将逻辑门的设计细节放入了一个被称为黑盒子的元素中,黑盒子是指已知输入和输出,但内部细节被隐藏的元素。另一种对这种方法的称呼是封装,这是一种设计选择,它隐藏了组件的内部细节,同时记录了如何与该组件交互。

图 4-10:常见逻辑门
封装是一种贯穿现代技术的设计原则。当一个组件的设计者希望其他人能够轻松地使用他们的创作并在此基础上进行扩展,而无需完全理解其实现细节时,便使用了这种方法。该方法还允许在“盒子”内部进行改进,只要输入和输出行为保持不变,盒子就可以继续像以往一样使用。封装的另一个优势是,团队可以在一个大型项目中协作工作,项目的部分内容被封装起来。这使得个人不必了解每个组件的每一个细节。在本书中,逻辑门中的晶体管封装是封装的第一个例子,但随着我们继续前进,您会看到它在多个地方出现。
使用逻辑门设计
在第二章中,我们看到如何将多个逻辑运算符组合起来创建更复杂的逻辑语句。现在我们将这一思想扩展到逻辑门。一个逻辑语句或真值表一旦写好,就可以使用逻辑门在硬件中实现。我们现在将这一原理应用于我们之前创建的真值表(表 2-6),用于以下语句:
IF it is sunny AND warm, OR it is my birthday, THEN I am going to the beach.
我们将其简化为
(A AND B) OR C
现在让我们使用一个包含逻辑门的图表来表示这个语句,如图 4-11 所示。

图 4-11:用于 (A AND B) OR C 的逻辑门图
如果 A 和 B 都是 1,那么 AND 门的输出将是 1。AND 门的输出连接到 OR 门的输入,并与 C 一起传入。如果 AND 的输出或 C 的值为 1,那么整体输出将是 1。
当我们以某种方式将逻辑门组合,使得输出仅仅是当前输入的函数时,电路被称为组合逻辑电路。也就是说,一组特定的当前输入将始终产生相同的输出。这与时序逻辑相对,后者的输出不仅取决于当前输入,还与过去的输入有关。我们将在本书后面讨论时序逻辑。现在,请尝试设计一个电路,表示练习 4-2 中描述的逻辑表达式。
练习 4-2:使用逻辑门设计电路
在第二章的练习 2-5 中,你为(A OR B) AND C 创建了真值表。现在,基于之前的工作,将这个真值表和逻辑表达式转换成电路图。绘制一个逻辑门图(类似于图 4-11 中的电路图),使用逻辑门来表示该电路。答案请参见附录 A。
集成电路
如前所述,很多公司生产并销售即用型数字逻辑门。硬件设计师可以购买这些逻辑门,开始构建他们的逻辑电路,而不必担心逻辑门电路的内部工作原理。这些逻辑门就是集成电路的一个例子。集成电路(IC)将多个元件集成在单片硅上,封装中具有外部电气接触点或引脚。IC 也被称为芯片。
本书主要介绍的是采用双列直插封装(DIP)的 IC,这是一种矩形外壳,具有两排平行的引脚。这些引脚的间距设计使其可以方便地用于面包板。制造商通过使用微小的晶体管来制造 IC,这些晶体管比之前在图 4-5 中展示的分立晶体管要小得多。分立元件是一种仅包含单一元件(如电阻器或晶体管)的电子设备。IC 能使电路更小、运作更快且成本低于由分立晶体管构建的同样电路。
我们之前讨论过的使用电阻器和晶体管的逻辑电路被称为电阻–晶体管逻辑(RTL)电路。制造商最初就是通过这种方式构建数字逻辑电路,但后来他们采用了其他方法,包括二极管–晶体管逻辑(DTL)和晶体管–晶体管逻辑(TTL)。7400 系列是最流行的 TTL 逻辑电路系列。这个集成电路系列包括逻辑门和其他数字元件。7400 系列及其后代自 1960 年代问世以来,依然是数字电路的标准。我将重点介绍 7400 系列,给你提供一些关于集成电路在实际应用中的例子。
让我们来看看一个具体的 7400 系列集成电路。7432 芯片,如图 4-12 所示,包含四个或门。

图 4-12:SN7432N 集成电路,采用双列直插封装(两者),显示在面包板(右)上
7432 集成电路采用 14 引脚封装。每个或门需要 3 个引脚,因此需要 12 个引脚,再加上 1 个引脚用于正电压(V[cc]),1 个引脚用于接地,最终得到 14 个引脚。说到电压,7400 系列的工作电压通常是 5V。也就是说,高电压,逻辑值 1,理想状态下为 5V,低电压则为 0V。然而,实际上,任何 2V 到 5V 之间的输入电压都被视为高电平,而任何 0V 到 0.8V 之间的输入电压都被视为低电平。
在图 4-12 中,您可以看到 7432 封装每边有 7 个引脚,并且可以整齐地插入面包板。在将此类芯片放入面包板时,请确保将芯片放置在面包板中心的间隙上,以确保直接相对的引脚(例如:引脚 1 和 14)不会被意外连接。请注意封装上的半圆形凹口;它可以帮助您确定芯片的正确方向,以便识别引脚。
在图 4-13 中,您可以看到该封装内部电路的排列。这里是一个引脚图——一个标注了组件电气接触点或引脚的图示。此类图示的目的是显示组件的外部连接点,但通常引脚图并不会记录电路的内部设计。

图 4-13:显示 7432 集成电路引脚排列的引脚图
假设您想使用 7432 芯片中的四个或门之一,并且选择了图 4-13 中引脚图左下角的那个与引脚 1、2 和 3 相连的或门。要使用这个门,您需要按照表 4-3 中所示的方式连接引脚。
表 4-3: 连接 7432 集成电路中的单个或门
| 引脚 | 连接 |
|---|---|
| 1 | 这是或门的 A 输入。连接 5V 或 GND,分别表示 1 或 0。 |
| 2 | 这是或门的 B 输入。连接 5V 或 GND,分别表示 1 或 0。 |
| 3 | 这是或门的输出。期望其为 5V 或 GND,分别表示 1 或 0。 |
| 7 | 连接到地。 |
| 14 | 连接到 5V 电源。 |
7400 系列包含数百个组件。我们在这里不会一一介绍,但在图 4-14 中,您可以看到该系列中四种常见逻辑门的引脚图。您也可以快速在线搜索,找到其他 7400 集成电路的引脚图。
凭借每个集成电路的引脚图,您现在掌握了物理构建您之前在练习 4-2 中设计的电路所需的知识。

图 4-14:7400 系列常见集成电路的引脚图
注意
请参见项目 #4 第 68 页,在那里您可以构建一个实现逻辑表达式 (A 或 B) 与 C 的电路。
总结
在本章中,我们介绍了二进制数字电路,电路中我们使用电压水平表示逻辑 1 或 0。你学到了如何使用开关来构建逻辑运算符,例如与运算,在物理电路中。我们讨论了使用机械开关的局限性,并引入了一种新的电路元件,作为电控开关——晶体管。你了解了逻辑门,这是实现逻辑功能的电路元件。我们还介绍了集成电路,包括 7400 系列。
在下一章,我们将探索如何使用逻辑门来构建处理计算机基本功能之一——数学运算——的电路。你将看到,当简单的逻辑门组合在一起时,可以实现更复杂的功能。我们还将讨论计算机内部整数的表示方法,包括有符号和无符号数字。
项目 #3:用晶体管构建逻辑运算符(与、或)
在本项目中,你将使用晶体管构建逻辑与和逻辑或的物理电路。构建这些电路,你将需要以下元件:
-
面包板(可以是 400 点或 830 点型号)
-
电阻(各种电阻)
-
9 伏电池
-
9 伏电池夹连接器
-
5mm 或 3mm 红色 LED
-
跳线(设计用于面包板)
-
两个晶体管(型号为 2N2222,TO-92 封装[也叫 PN2222])
请参见第 333 页的“购买电子元件”部分,获取如何购买这些部件的帮助。
在开始连接之前,你需要知道一些晶体管和集成电路是静电敏感器件,这意味着它们可能会被静电放电损坏。你是否曾在地毯上走动后,触摸某个物体时感受到静电冲击?那一阵电击可能对电子元件致命。即使是你自己感觉不到的微小静电放电,也可能损坏电子元件。
电子行业的专业人士通过佩戴接地腕带、在防静电工作区工作以及穿戴特殊服装来避免这个问题。大多数爱好者并不会采取这些预防措施,但你至少应该意识到静电放电可能会损坏你的晶体管或集成电路。尽量避免静电积聚,在处理静电敏感元件之前,触摸一个接地表面(如固定电源插座盖的螺丝)以释放任何静电。
现在让我们回到正在进行的项目。TO-92 封装的 2N2222 晶体管有三个引脚。如果你将晶体管的平面朝向自己,且引脚朝下,那么左边的引脚是发射极,中间的引脚是基极,右边的引脚是集电极(见图 4-5)。你还可以在网上搜索“2N2222 TO-92”获取更多关于此晶体管的详细信息。
按照 图 4-15 中的电路图,用 9 伏电池、晶体管、电阻器和 LED 来构建一个 AND 电路。

图 4-15:带有建议电阻器、晶体管和输出 LED 的 AND 电路图
A 和 B 应该连接到 9V(表示 1)或地线(表示 0)来测试输入。当预期输出为 1 时,LED 应该亮起。查看 表 4-1,该表格列出了各种输入组合对应的预期输出。记住,图中的 +9V 表示连接到电池的正极端子,而地线符号表示连接到电池的负极端子。另外,记得 LED 只允许电流朝一个方向流动,所以一定要把较短的引脚连接到地线。如果电路没有按预期工作,可以查看 “电路故障排除” 部分,见 第 340 页。
构建好的电路应该类似于 图 4-16 中的样子。当然,你在面包板上的零件布局可能与我的有所不同。

图 4-16:在面包板上搭建的 图 4-15 中的 AND 电路
一旦 AND 电路完成并且正常工作,我们就可以开始构建类似的 OR 电路。按照 图 4-17 中的电路图,用 9 伏电池、晶体管、电阻器和 LED 来搭建一个 OR 电路。

图 4-17:带有建议电阻器、晶体管和输出 LED 的 OR 电路
和之前的电路一样,A 和 B 应该连接到 9V(表示 1)或地线(表示 0)来测试输入。当预期输出为 1 时,LED 应该亮起。查看 表 4-2 以获取各种输入组合的预期输出。 项目 #4:构建一个具有逻辑门的电路
在 练习 4-2 中,你画出了 (A OR B) AND C 的电路图。如果你跳过了那个练习,我建议你回去完成它,然后再继续。结果应该类似于 图 4-18 中所示的电路图。

图 4-18:逻辑门图,表示 (A OR B) AND C
现在让我们开始实际构建电路吧!将输出引脚连接到 LED(记得同时加入电阻器),这样你就能看到输出是 0 还是 1。你的三个输入(A、B、C)可以直接连接到 5V 或地线。尝试连接不同的输入组合,以确保你的逻辑按预期工作。
本项目需要以下组件:
-
面包板
-
LED
-
一个用于 LED 的限流电阻器;约 220Ω
-
跳线
-
7408 集成电路(包含四个 AND 门)
-
7432 集成电路(包含四个 OR 门)
-
5 伏电源
-
三个适合面包板的按钮开关或滑动开关(用于加分项目)
-
三个 470Ω 电阻器(用于加分问题)
由于电路需要使用 5 伏电源,而不是 9 伏电池,请查看第 336 页的“为数字电路供电”部分,了解如何设置电源的几种选项。另外,如前所述,您也可以参考第 333 页的“购买电子元件”部分,了解如何获取这些零件。
您可能已经注意到,组件列表推荐使用 220Ω电阻,而不是我们之前使用的 330Ω电阻。这是因为我们将源电压从 9V 降到了 5V。电路所需的具体电阻值将取决于您使用的 LED 的正向电压,正如我们在第三章中讨论过的那样。尽管如此,这个电阻值不必非常精确。您可以使用 220Ω、200Ω或 180Ω电阻——这些值都很常见。图 4-19 中的接线图展示了如何构建该电路的详细信息。
请记住,一旦将芯片放置到面包板上,芯片的引脚会与整个一行电气连接。请确保将集成电路放置在面包板中央的间隙上,以确保彼此直接相对的引脚不会意外连接。如果电路是根据面包板搭建的,完成后的电路应该类似于图 4-20 中所示的照片。

图 4-19: (A 或 B) 与 C 的接线图

图 4-20:带有 A、B 和 C 输入未连接的 (A 或 B) 与 C 的面包板实现
请注意,在图 4-20 中,7432 集成电路位于左侧,而 7408 集成电路位于右侧。在这个布局中,顶部的电源列连接到 5V,底部的负电源列连接到地面,但这两者在照片中没有显示。同时注意,输入 A、B 和 C 在这里未连接;它们需要连接到地面或 5V,以测试不同的输入。
一旦您构建了这个电路,可以尝试连接输入的不同组合,以确保逻辑按预期工作。将输入连接到 5V 表示逻辑 1,连接到地面表示逻辑 0。根据表 4-4 中显示的 (A 或 B) 与 C 的真值表检查电路的行为。如果电路没有按预期工作,请查看第 340 页的“电路故障排除”部分。
表 4-4: (A 或 B) 与 C 的真值表
| A | B | C | 输出 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 |
| 1 | 1 | 0 | 0 |
| 1 | 1 | 1 | 1 |
手动将输入线在地和 5V 之间切换并不理想。更好的设计是将输入 A、B 和 C 连接到机械开关,这样你可以轻松改变输入,而不需要重新接线电路。作为一个附加项目,让我们添加一些机械开关来控制我们的输入。你的第一反应可能是将开关连接在输入和 V[cc] 之间,如图 4-21 所示,关闭开关时将输入连接到 5V,逻辑 1。

图 4-21:V[cc] 和输入之间的开关。提示:不要这样做。
不幸的是,图 4-21 中所示的方法有问题。闭合的开关按预期工作,但打开的开关则不然。你可能认为打开的开关会导致输入 A 处于 0V,但情况不一定如此。当开关打开时,输入 A 的电压会“漂浮”,是一个不可预测的值。记住,图 4-21 中的输入 A 代表的是 7432 或门的输入引脚。该输入设计用于连接高电压或低电压;如果不连接,它会使逻辑门处于未定义的状态。我们需要重新接线,使得当开关关闭时输入能够保持一个可预测的低电压。正如图 4-22 所示,我们可以使用下拉电阻来实现这一点——这是一种常规电阻,用于在输入未连接到高电压时将其“拉”到低电压。

图 4-22:使用下拉电阻确保数字输入正确。
让我们考虑一下在图 4-22 所示添加下拉电阻时会发生什么。为了了解 7432 集成电路在不同条件下的输入响应,我们可以查阅制造商的数据手册中描述的电压和电流特性。我在这里不详细讨论(可以在线查找你具体的 7432 芯片的数据手册),但简而言之,当开关打开时,会有一小部分电流从输入 A 流过电阻器到地。
如果我们使用一个低阻值的电阻器,从输入 A 流过的电流会在输入端产生一个低电压,足以被识别为逻辑 0。当开关闭合时,输入直接连接到 V[cc],输入将是逻辑 1。对于 74LS 系列元件(在附录 B 中讨论),470Ω 或 1kΩ 的下拉电阻通常适用于逻辑门输入。我推荐这些特定的值,因为它们常见且符合我们的要求。大于 1kΩ 的值不适合作为 74LS 元件的下拉电阻。当使用下拉电阻时,你可以按照图 4-23 所示构建带有开关的完整电路。

图 4-23:控制输入的开关添加到 (A OR B) AND C 的接线图
完成的电路,如果在面包板上搭建,将类似于图 4-24 中显示的照片。在我的电路中,我使用了按键作为开关,如左下角所示。如果你仔细观察,你可能会发现这张照片中的下拉电阻是 1kΩ,和图中的建议值 470Ω不同,但两者都可以使用。

图 4-24:面包板上实现的(A 或 B)与 C
一旦你按照图 4-24 所示搭建了电路,一定要检查不同输入的组合,并查看它是否与表 4-4 中的(A 或 B)与 C 的真值表相符。如果电路没有按预期工作,请查看 “电路故障排除” 在第 340 页上的内容。
第五章:数字电路中的数学**

在上一章中,我们介绍了逻辑门和数字电路,它们使我们能够在硬件中实现逻辑表达式。在本书的早些时候,我们将计算机定义为可以被编程执行一组指令的电子设备。在本章中,我将通过向你展示简单的逻辑门如何为计算机执行的操作铺平道路,来桥接这些概念。我们将讨论所有计算机都能执行的一个特定操作——加法。首先,我们将回顾二进制加法的基础知识。然后,我们使用逻辑门构建加法硬件,演示简单的逻辑门如何在计算机中协同工作以执行有用的操作。最后,我们将讨论整数在计算机中的有符号和无符号表示。
二进制加法
让我们来看一下二进制加法的基础。加法的基本原理在所有位置值系统中是相同的,因此你已经知道如何在十进制中加法,所以你有一定的基础!为了避免抽象的概念,我们来看一个具体的例子:加法两个特定的 4 位数 0010 和 0011,如图 5-1 所示。

图 5-1:加法两个二进制数
就像在十进制中一样,我们从最右侧的位开始,称为最低有效位,并将两个值相加(图 5-2)。这里,0 + 1 等于 1。

图 5-2:加法两个二进制数的最低有效位
现在让我们向左移动一位并将这些值相加,如图 5-3 所示。

图 5-3:加法中的二进制位
如你在图 5-3 中看到的,这一位要求我们加 1 + 1,这给我们带来了一个有趣的转折。在十进制中,1 + 1 用符号 2 来表示,但在二进制中我们只有两个符号,0 和 1。在二进制中,1 + 1 是 10(详见第一章的解释),这需要两个比特来表示。我们只能在这一位放一个比特,所以 0 放在当前位,1 被进位到下一位,如图 5-3 所示。现在我们可以移到下一位(参见图 5-4),当我们加上这些比特时,必须包括从上一位进位的比特。这给我们 1 + 0 + 0 = 1。

图 5-4:加法四位数
最后,我们加上最高有效位,如图 5-5 所示。

图 5-5:加法八位数
一旦我们加完所有位,完整的二进制结果是 0101。我们检查工作的一个方法是将所有内容转换为十进制,如图 5-6 所示。

图 5-6:加法二进制数,然后转换为十进制
正如你在图 5-6 中看到的,我们的二进制答案(0101)与十进制(5)一致。很简单吧!
练习 5-1:二进制加法练习
现在你可以练习你刚学到的内容。尝试以下加法问题:
0001 + 0010 = ______
0011 + 0001 = ______
0101 + 0011 = ______
0111 + 0011 = ______
请参见附录 A 查找答案。
幸运的是,不管你使用的是哪个进制,加法的方式都是一样的。不同的进制唯一的区别是可用的符号数量。二进制使得加法特别简单,因为每个位的加法总是会产生两个输出位,每个输出位只有两个可能的值:
输出 1 一个 和 位(S),值为 0 或 1,表示加法操作结果的最低有效位
输出 2 一个 进位输出 位(C[out])为 0 或 1
半加器
现在假设我们要构建一个数字电路,来加和两个二进制数的 单个位。我们最初关注的是最低有效位。对两个数的最低有效位进行加法只需要两个二进制输入(我们称之为 A 和 B),而二进制输出是一个和位(S)和一个进位输出位(C[out])。我们称这样的电路为 半加器。图 5-7 显示了半加器的符号。

图 5-7:半加器符号
为了澄清半加器如何与我们之前添加两个二进制数的示例相适应,图 5-8 将这两个概念联系起来。

图 5-8:半加器工作示意图
如图 5-8 所示,第一个数字的最低有效位是输入 A,第二个数字的最低有效位是输入 B。和位是输出 S,进位输出也是输出。
在内部,半加器可以实现为一个组合逻辑电路,因此我们也可以用真值表来描述它,如表 5-1 所示。请注意,A 和 B 是输入,而 S 和 C[out] 是输出。
表 5-1:半加器的真值表
| 输入 | 输出 |
|---|---|
| A | B |
| 0 | 0 |
| 0 | 1 |
| 1 | 0 |
| 1 | 1 |
让我们通过表 5-1 中的真值表进行讲解。将 0 和 0 相加的结果是 0,没有进位。将 0 和 1(或反之)相加的结果是 1,没有进位。将 1 和 1 相加的结果是 0,进位为 1。
现在,我们如何使用数字逻辑门来实现这一点呢?如果我们逐个检查输出,解决方案就很简单,如图 5-9 所示。

图 5-9:半加器的真值表;输出与 XOR,AND 相匹配
仅从图 5-9 中的输出 S 来看,我们可以看到它与异或门的真值表完全匹配(参见第四章)。仅从 C[out]来看,我们可以观察到它与与门的输出相匹配。因此,我们可以仅使用两个门:异或门和与门,来实现一个半加器,如图 5-10 所示。

图 5-10:用两个逻辑门(异或门和与门)实现的半加器
如图 5-10 所示,数字输入 A 和 B 作为异或门和与门的输入。然后,这些门产生所需的输出 S 和 C[out]。
注意
请参见项目 #5 在第 89 页,在那里你可以构建一个半加器的电路。
全加器
半加器可以处理两位二进制数最低有效位的加法逻辑。然而,每一位之后的位需要一个额外的输入:进位输入 C[in]。这是因为除了最低有效位之外的每个位,都需要处理上一位加法结果产生的进位,这个进位会成为当前位的进位输入。为加法器组件添加一个 C[in]输入需要新的电路设计,我们将这个电路称为全加器。全加器的符号,如图 5-11 所示,类似于半加器的符号,唯一的区别是多了一个输入 C[in]。

图 5-11:全加器的符号
在图 5-12 中,我们看到一个关于二进制加法和全加器之间关系的例子。

图 5-12:全加器的工作过程
全加器处理一个位的加法,包括进位输入位。在图 5-12 所示的例子中,我们加的是 4 位的数字。由于前一位的两个数字是 1 和 1,因此有一个进位输入 1。全加器接受所有三个输入(A = 0, B = 0, C[in] = 1),并输出 S = 1 和 C[out] = 0。
为了完整展示全加器的可能输入和输出,我们可以使用真值表,如表 5-2 所示。这个表有三个输入(A、B、C[in])和两个输出(S、C[out])。请花一点时间考虑各种输入组合下的输出。
表 5-2: 全加器真值表
| 输入 | 输出 |
|---|---|
| A | B |
| 0 | 0 |
| 0 | 0 |
| 0 | 1 |
| 0 | 1 |
| 1 | 0 |
| 1 | 0 |
| 1 | 1 |
| 1 | 1 |
那么,我们如何实现全加器呢?顾名思义,全加器可以通过将两个半加器结合来实现(见图 5-13)。

图 5-13:由两个半加法器和一个 OR 门实现的全加法器电路
一个全加法器的和输出(S)应该是 A 和 B 的和(我们可以使用一个半加法器 HA1 来计算)加上 C[in](我们可以使用第二个半加法器 HA2 来计算),如 图 5-13 所示。
我们还需要全加法器输出一个进位位。这个实现起来很简单,因为如果任意半加法器的进位为 1,那么全加法器的 C[out] 值就为 1。因此,我们可以使用一个 OR 门来完成全加法器电路,如 图 5-13 所示。
这里我们看到另一个封装的例子。一旦这个电路构建完成,使用全加法器的功能时,就无需了解具体的实现细节。在下一部分,让我们看看如何将全加法器和半加法器结合起来,以便对多个位的数字进行加法运算。
一个 4 位加法器
一个全加法器允许我们加两个 1 位数字,再加上一个进位输入位。这样我们就可以构建一个电路模块,用于加多个数字位的二进制数。现在,让我们将多个 1 位加法器电路组合在一起,构建一个 4 位加法器。对于最低有效位,使用半加法器(因为它不需要进位输入),对于其他位使用全加法器。我们的思路是将加法器连接在一起,使每个加法器的进位输出流入下一个加法器的进位输入,正如 图 5-14 所示。

图 5-14:一个 4 位加法器
为了与人们书写数字的方式保持一致,我将 图 5-14 安排成最低有效位位于右侧,并且图表的流向是从右到左。这意味着我们的加法器框图将具有与之前显示的不同的输入和输出位置;不要因此而感到困惑!
在 图 5-15 中,我将之前提到的两个(0010)加三个(0011)应用到这个 4 位加法器中。

图 5-15:4 位加法器的工作过程
在 图 5-15 中,我们可以看到输入 A(0010)和输入 B(0011)中的每一位是如何依次传递到每个加法单元的,从右边的最低有效位开始,向左移动到最高有效位。你可以通过从右到左阅读图表,跟踪信号流动。首先加上最右侧的位,0(A0)和 1(B0);结果是 1(S0)和进位 0。
最右侧加法器的输出进位位作为 C1 传递到下一个加法器,在这里,1(A1)和 1(B1)被相加,同时进位为 0。结果是 0(S1),并产生进位 1(C2)。该过程会继续,直到最左侧的加法器完成。最终结果是一组输出位,0101(S3 到 S0),以及进位 0(C4)。如果我们需要处理更多位的数字,可以通过简单地增加更多的全加法器来扩展 图 5-15 中的设计。
这种类型的加法器需要进位比特通过电路传播,或称为“波纹”传播。因此,我们称这种电路为波纹进位加法器。每个传播到下一个全加器的进位比特都会引入小的延迟,因此,将设计扩展到处理更多的比特会使电路变慢。直到所有进位比特有时间传播完毕,电路的输出才会准确。
7400 系列集成电路中有几种版本的 4 位加法器。如果你的项目需要一个 4 位加法器,你可以使用这样的集成电路,而不必通过单独的逻辑门来构建加法器。
让我们在这里暂停一下,思考我们刚才讨论的内容对更广泛的计算机应用有什么影响。是的,你学会了如何构建一个 4 位加法器,但这与计算机有什么关系呢?回想一下,计算机是可以编程的电子设备,能够执行一系列逻辑指令。这些指令包括数学运算,我们刚才看到,由晶体管构建的逻辑门可以组合在一起,执行其中的一个操作——加法。我们以加法为具体例子来说明计算机操作,虽然本书中我们不详细介绍,但你也可以使用逻辑门实现其他基本的计算机操作。这就是计算机的工作原理——简单的逻辑门通过协作来完成复杂的任务。
带符号数
到目前为止,在本章中我们只关注了正整数,但如果我们还想处理负整数该怎么办呢?首先,我们需要考虑如何在像计算机这样的数字系统中表示负数。正如你所知道的,计算机中的所有数据都是由一串 0 和 1 来表示的。负号既不是 0 也不是 1,因此我们需要采用一种约定来表示数字系统中的负值。在计算中,带符号数是一串比特,可以用来表示负数或正数,具体取决于这些比特的值。
一个数字系统的设计必须定义用于表示整数的比特数。通常,我们使用 8、16、32 或 64 个比特来表示整数。可以为其中一个比特分配来表示负号。例如,我们可以规定,如果最高有效位是 0,则该数为正数;如果最高有效位是 1,则该数为负数。剩余的比特用于表示数值的绝对值。这种方法被称为带符号的大小表示法。这种方法是可行的,但它要求系统设计增加额外的复杂性,以考虑那个具有特殊意义的符号位。例如,我们之前构建的加法器电路需要进行修改,以考虑符号位。
一种更好的计算机中表示负数的方法被称为二进制补码。在这个上下文中,一个数字的二进制补码表示该数字的负数。找到一个数字的二进制补码的最简单方法是将每个 1 替换为 0,每个 0 替换为 1(换句话说,翻转位),然后加上 1。稍微耐心一下,起初这看起来可能过于复杂,但如果你按照细节操作,它就会变得清晰。
让我们来看一个 4 位的例子,数字 5,或者在二进制中表示为 0101。图 5-16 展示了找到这个数字的二进制补码的过程。

图 5-16:找到 0101 的二进制补码
首先,我们翻转位,然后加上 1,得到 1011 二进制。因此,在这个系统中,5 表示为 0101,而-5 表示为 1011。请记住,1011 仅在 4 位带符号数字的上下文中表示-5。这个二进制序列在不同的上下文中可能有不同的解释,正如我们稍后将看到的那样。如果我们想从负值开始,反向操作怎么办?过程是相同的,如图 5-17 所示。

图 5-17:找到 1011 的二进制补码
正如你在图 5-17 中看到的,取-5 的二进制补码会让我们回到原来的 5。这是有道理的,因为-5 的负数是 5。
练习 5-2:寻找二进制补码
找到 6 的 4 位二进制补码。答案请参见附录 A。
现在我们知道如何使用二进制补码将数字表示为正数或负数,但这怎么有用呢?我认为看清这个系统的好处最简单的方式就是亲自试试。假设我们要加 7 和-3(即从 7 中减去 3)。我们期望结果是正数 4。首先让我们确定二进制形式的输入,如图 5-18 所示。

图 5-18:找到 7 和-3 的 4 位二进制补码形式。
我们的两个二进制输入将是 0111 和 1101。现在,暂时忘记我们正在处理的是正值和负值。只需将两个二进制数相加。不用担心这些位代表什么,直接加,准备好惊讶吧!做完二进制计算后,请查看图 5-19。

图 5-19:两二进制数相加,作为带符号的十进制数解释
正如你在图 5-19 中看到的,这个加法操作导致了超出 4 位数字能表示范围的进位位。我稍后会更详细地解释这个问题,但目前我们可以忽略那个进位位。这样我们得到的 4 位结果是 0100,表示正数 4,正是我们预期的数字!这就是二进制补码表示法的魅力所在。在加法或减法操作中,我们无需做任何特殊处理,它自然就能奏效。
让我们暂停一下,反思一下这意味着什么。还记得我们之前构建的加法电路吗?它们同样适用于负值!任何设计用来处理二进制加法的电路都可以使用二进制补码来处理负数或减法。详细的数学解释超出了本书的范围;如果你有兴趣,可以在网上找到很多好的解释。
二进制补码术语
“二进制补码”这个术语实际上指的是两个相关的概念。二进制补码是一种表示法,用于表示正整数和负整数。例如,数字 5,在 4 位二进制补码表示法中为 0101,而-5 表示为 1011。同时,二进制补码也是一种运算,用于取反存储在二进制补码格式中的整数。例如,取 0101 的二进制补码得到 1011。
另一种看待二进制补码表示法的方式是:最高有效位的权重等于该位的负值,而其他所有位的权重等于该位的正值。因此,对于一个 4 位数字,位的权重如图 5-20 所示。

图 5-20:使用二进制补码表示法的有符号 4 位数字的位值权重
如果我们将这种方法应用于-3 的二进制补码表示(1101),则可以按照图 5-21 所示计算十进制值。

图 5-21:使用二进制补码位权查找 1101 的有符号十进制值。
在处理二进制补码时,我发现将最重要的位权看作该位的负值是一个方便的思维捷径。现在我们已经涵盖了 4 位有符号数的所有位的权重,我们可以检查这种数字所能表示的全部值范围,如表 5-3 所示。
表 5-3: 4 位有符号数的所有可能值
| 二进制 | 有符号十进制 |
|---|---|
| 0000 | 0 |
| 0001 | 1 |
| 0010 | 2 |
| 0011 | 3 |
| 0100 | 4 |
| 0101 | 5 |
| 0110 | 6 |
| 0111 | 7 |
| 1000 | –8 |
| 1001 | –7 |
| 1010 | –6 |
| 1011 | –5 |
| 1100 | –4 |
| 1101 | –3 |
| 1110 | –2 |
| 1111 | –1 |
根据表 5-3,我们可以观察到,对于一个 4 位有符号数,最大值是 7,最小负值是-8,总共有 16 个可能的值。请注意,每当最高有效位为 1 时,值就会是负数。我们可以将其概括为:对于一个n位有符号数:
-
最大值: (2^(n–1)) – 1
-
最小值: –(2^(n–1))
-
唯一值的个数: 2^(n)
所以,对于一个 8 位有符号数(举例来说),我们可以发现
-
最大值 = 127
-
最小值 = –128
-
唯一值的个数 = 256
无符号数
使用二进制补码表示负值的带符号整数,是处理负数的一种便捷方式,无需专用的加法器硬件。我们之前讲解的加法器同样适用于负值,就像它适用于正值一样。然而,在计算中,某些情况下负值根本不需要,若我们将数字视为带符号的,那么大约有一半的值区间将被浪费(所有负值都不使用),同时最大值也会被限制为本应能表示的值的一半。因此,在这种情况下,我们希望将数字视为无符号,即比特序列始终表示一个正值或零,但绝不表示负值。
再次查看一个 4 位数字,表 5-4 展示了如果我们将其解释为带符号或无符号时,每个 4 位二进制值所代表的内容。
表 5-4: 一个 4 位数字的所有可能值,带符号或无符号
| 二进制 | 带符号十进制 | 无符号十进制 |
|---|---|---|
| 0000 | 0 | 0 |
| 0001 | 1 | 1 |
| 0010 | 2 | 2 |
| 0011 | 3 | 3 |
| 0100 | 4 | 4 |
| 0101 | 5 | 5 |
| 0110 | 6 | 6 |
| 0111 | 7 | 7 |
| 1000 | –8 | 8 |
| 1001 | –7 | 9 |
| 1010 | –6 | 10 |
| 1011 | –5 | 11 |
| 1100 | –4 | 12 |
| 1101 | –3 | 13 |
| 1110 | –2 | 14 |
| 1111 | –1 | 15 |
我们可以为 n 位无符号数做出以下概括:
-
最大值: (2^(n)) – 1
-
最小值:0
-
唯一值的个数:2^(n)
那么,我们来看一个例子,4 位值 1011。查看表 5-4,它代表什么?是代表 –5 还是 11?答案是“取决于情况!”它可以代表 –5 或 11,取决于上下文。从加法器电路的角度来看,这无关紧要。对加法器而言,4 位值就是 1011。无论执行什么加法运算,过程是一样的,唯一的区别是我们如何解释结果。我们来看一个例子。在图 5-22 中,我们将两个二进制数 1011 和 0010 相加。

图 5-22:加法运算的两个二进制数,解释为带符号或无符号
如图 5-22 所示,添加这两个二进制数的结果是 1101,无论我们是在处理带符号数还是无符号数。计算完成后,我们可以决定如何解释这个结果。我们要么认为是将 –5 和 2 相加,结果为 –3,要么认为是将 11 和 2 相加,结果为 13。在这两种情况下,数学运算都没有问题,问题只是出在如何解释结果!在计算机科学中,正确解释加法运算结果是带符号还是无符号的责任归程序。
习题 5-3:加两个二进制数并解释为带符号和无符号
将 1000 与 0110 相加。将你的结果解释为有符号数。然后将其解释为无符号数。结果是否有意义?请参阅附录 A 以获得答案。
到目前为止,我们大多数情况下忽略了最重要的进位输出位,但它有一个需要理解的意义。对于无符号数,进位输出为 1 意味着发生了整数溢出。换句话说,结果太大,无法用分配给整数的位数表示。对于有符号数,如果最重要的进位输入位不等于最重要的进位输出位,则发生了溢出。对于有符号数,如果最重要的进位输入位等于最重要的进位输出位,则没有发生溢出,进位输出位可以被忽略。
整数溢出是计算机程序中的一种错误来源。如果程序没有检查是否发生了溢出,那么加法运算的结果可能会被错误地解释,导致意外的行为。一个著名的整数溢出错误出现在街机游戏《吃豆人》中。当玩家到达 256 级时,屏幕的右侧会充满乱码图形。发生这种情况是因为级别数被存储为 8 位无符号整数,当它的最大值 255 加 1 时发生了溢出。游戏的逻辑没有考虑到这种情况,从而导致了这个故障。
总结
在本章中,我们使用加法作为计算机如何基于逻辑门进行复杂任务的示例。你学习了如何在二进制中进行加法,以及如何从逻辑门构建能够进行二进制加法的硬件。你看到了半加器如何对 2 位进行加法并生成和与进位输出位,而全加器可以对 2 位加上进位输入位进行加法。我们讨论了如何将单比特加法器组合起来进行多比特加法。你还学习了计算机中整数是如何使用有符号和无符号数字表示的。
在下一章中,我们将超越组合逻辑电路,学习顺序逻辑。使用顺序逻辑,硬件可以具有存储功能,允许存储和检索数据。你将看到如何构建存储电路。我们还将介绍时钟信号,这是一种同步计算机系统中多个组件状态的方法。
项目 #5:构建一个半加器
在这个项目中,你将使用异或门和与门构建一个半加器。输入将通过开关或按钮控制。输出应连接到 LED,以便轻松观察其状态。这个项目需要以下组件:
-
面包板
-
两个 LED
-
两个限流电阻,用于 LED(大约 220Ω)
-
跳线
-
7408 集成电路(包含四个与门)
-
7486 集成电路(包含四个异或门)
-
两个适合面包板的按钮或开关
-
两个 470Ω电阻
-
5 伏电源
提醒一下,如果你需要有关这些主题的帮助,请参见“购买电子元件”一节,第 333 页和“为数字电路供电”一节,第 336 页。有关 7408 IC 引脚编号的回顾,请参见图 4-14。7486 IC 之前没有涉及,因此在此我将它的引脚图包含在图 5-23 中。

图 5-23:7486 XOR 集成电路的引脚图
图 5-24 提供了半加器的接线图。继续阅读图示后面的内容,了解如何构建这个电路的更多细节。

图 5-24:由 XOR 和 AND 门构建的半加器
图 5-24 展示了开关的连接,带有下拉电阻,以及 LED 的连接,带有限流电阻。还请注意 7486 和 7408 IC 上的引脚编号,显示在方框内。注意连接 A 和 B 到电阻和 IC 的电线上的黑点。黑点表示连接点——例如,开关 A、470Ω电阻、7486 IC 的引脚 1 和 7408 IC 的引脚 4 都连接在一起。不要忘记将 7486 和 7408 IC 分别通过引脚 14 和 7 连接到 5V 和地(这在图 5-24 中没有显示)。
图 5-25 展示了当这个电路在面包板上实现时的样子。

图 5-25:由 XOR 和 AND 门构建的半加器
一旦你完成了这个电路的构建,尝试所有 A 和 B 的输入组合,以确认输出符合预期值,如半加器真值表中所示(表 5-1)。
第六章:内存与时钟信号

在前几章中,我们看到如何将数字逻辑门组合起来,产生有用的组合逻辑电路,其中输出是输入的函数。在本章中,我们将讨论时序逻辑电路。这些电路具有内存,可以存储过去的记录。我们将介绍一些特定类型的内存设备:锁存器和触发器。我们还将了解时钟信号,这是一种同步多个电路组件的方法。
时序逻辑电路与内存
现在让我们来研究一种数字电路,称为时序逻辑电路。时序逻辑电路的输出不仅取决于当前的输入集,还取决于电路的过去输入。换句话说,时序逻辑电路对自身的历史或状态有一定的记忆。数字设备通过一种叫做内存的组件来存储过去的状态,这个组件允许存储和检索二进制数据。
让我们考虑一个简单的时序逻辑示例:一个投币式自动售货机。自动售货机至少有两个输入:一个投币口和一个售货按钮。为了简化起见,假设自动售货机只售卖一种商品,并且该商品的价格是一个硬币。售货按钮只有在投币后才会起作用。如果自动售货机是基于组合逻辑的,那么状态仅由当前输入决定,那么投币必须在按下售货按钮的同时完成。
幸运的是,自动售货机并不是这样工作的!它们有内存,可以记录是否已插入硬币。当我们按下售货按钮时,自动售货机中的时序逻辑会检查其内存,看是否之前已插入硬币。如果是,机器就会发放商品。我们将在本章后面进一步探讨这个时序逻辑示例。
时序逻辑之所以可能,是因为有了内存。内存存储二进制数据,并且其存储容量以位或字节为单位来衡量。现代计算设备,如智能手机,通常至少有 1GB 的内存。那可是超过 80 亿位!让我们从一个简单的设备开始:一个只有 1 位内存的存储设备。
SR 锁存器
锁存器是一种记住 1 位的内存设备。SR 锁存器有两个输入:S(设定)和 R(复位),以及一个输出 Q,表示“记住”的单个位。当 S 设置为 1 时,输出 Q 也变为 1。当 S 变为 0 时,Q 保持为 1,因为锁存器记住了这个之前的输入。这就是内存的本质——组件记住了一个先前的输入,即使该输入发生变化。当 R 设置为 1 时,这是一个复位/清除内存位的指示,因此输出 Q 变为 0。即使 R 返回到 0,Q 也会保持为 0。
我们在表 6-1 中总结了 SR 锁存器的行为。
表 6-1: SR 锁存器的操作
| S | R | Q (输出) | 操作 |
|---|---|---|---|
| 0 | 0 | 保持先前值 | 保持 |
| 0 | 1 | 0 | 重置 |
| 1 | 0 | 1 | 设置 |
| 1 | 1 | X | 无效 |
根据设计,同时将 S 和 R 都设置为 1 是一个无效输入,在这种情况下 Q 的值是未定义的。实际上,尝试这样做会导致 Q 变为 1 或 0,但我们无法可靠地说出是哪一个。而且,试图同时设置和重置触发器是没有意义的。SR 触发器的电路符号见图 6-1。

图 6-1:SR 触发器的电路符号
在图 6-1 中,有一个额外的输出:Q。将其理解为“Q 的补码”,“非 Q”,或“Q 的反转”。它只是 Q 的相反值。当 Q 为 1 时,Q 为 0,反之亦然。拥有 Q 和 Q 两个输出是很有用的,正如你将看到的,这种电路的设计本身就适合包括这个输出,且无需额外努力。
我们可以通过仅使用两个 NOR 门和一些电线简单实现 SR 触发器。也就是说,理解设计的工作原理需要一些思考。考虑图 6-2 所示的电路,它是 SR 触发器的实现。

图 6-2:通过交叉耦合的 NOR 门实现的 SR 触发器
在图 6-2 中,我们有两个 NOR 门,采用的是交叉耦合配置。提醒一下,NOR 门只有在两个输入都是 0 时才输出 1;否则,它输出 0。N1 的输出送入 N2 的输入,而 N2 的输出送入 N1 的输入。输入是 S 和 R,输出是 Q 和 Q。让我们通过激活和清除各种输入来检查电路如何工作,并在过程中检查输出。假设最初 S 为 0,R 为 1。

图 6-3:SR 触发器,初始状态
初始状态 (S = 0, R = 1)
-
R = 1,因此 N2 的输出为 0。
-
N2 的输出送入 N1。
-
S = 0,因此 N1 的输出为 1。
-
初始时,Q = 0。
总结:当 R 变高时,输出变低(见图 6-3)。

图 6-4:SR 触发器,输入为低
接下来,清除所有输入 (S = 0, R = 0)
-
R 变为 0。
-
N2 的另一个输入仍然是 1,因此 N2 的输出仍然是 0。
-
因此,Q 仍然等于 0。
总结:电路记住了之前的输出状态(见图 6-4)。

图 6-5:SR 触发器,S 变高
接下来,激活 S 输入 (S = 1, R = 0)
-
S 变为 1。
-
这导致 N1 的输出为 0。
-
N2 的输入现在是 0 和 0,因此 N2 的输出为 1。
-
因此,Q 现在等于 1。
总结:将 S 设置为高会导致输出为高(见图 6-5)。

图 6-6:SR 触发器,S 变低
最后,再次清除所有输入 (S = 0, R = 0)
-
S 变为 0。
-
N1 的另一个输入仍然是 1,因此 N1 的输出仍然是 0。
-
N2 的输入没有改变。
-
因此,Q 仍然等于 1。
总结:电路记住了之前的输出状态(见图 6-6)。
将这些信息整合起来,我们刚刚描述了 SR 锁存器的预期行为,如表 6-1 中之前总结的。当 S(设置)为 1 时,输出 Q 为 1,并且即使 S 返回为 0,Q 也会保持为 1。当 R(重置)为 1 时,输出 Q 为 0,并且即使 R 返回为 0,Q 也会保持为 0。通过这种方式,电路记住了 1 或 0,因此我们有一个 1 位内存的设备!尽管有两个输出(Q 和 Q),它们只是同一存储位的不同表示。记住,同时将 S = 1 和 R = 1 设置为输入是无效的。
为了理解 SR 锁存器的行为,我们已经看过了当输入保持为高电平再变为低电平时电路的表现。然而,S 和 R 通常只需要“脉冲”一下。当电路处于静止状态时,S 和 R 都为低电平。当我们想改变其状态时,不需要长时间保持 S 或 R 为高电平;我们只需要快速将其设置为高电平,再返回低电平——一个简单的输入脉冲。
通用逻辑门
我们刚刚展示了如何使用 NOR 门构建 SR 锁存器。事实上,NOR 门可以用来创建任何其他逻辑电路,而不仅仅是 SR 锁存器。NOR 门被称为通用逻辑门;它可以用来实现任何逻辑功能。NAND 门也是如此。
现在我们已经研究了 SR 锁存器的内部设计,我们可以选择回到使用图 6-1 中的符号来表示 SR 锁存器。当我们这样做时,我们不再需要关心锁存器的内部结构。这是封装的另一个例子!我们将一个设计放入“黑盒子”中,这使得使用该设计变得更简单,而无需担心内部细节。我发现将 SR 锁存器简单地理解为一个 1 位内存设备是很有帮助的:它有一个 Q 状态,值为 1 或 0。S 输入将 Q 设置为 1,R 输入将 Q 重置为 0。
注意
请参见项目 #6(在第 104 页上),您可以在那里构建一个 SR 锁存器。
在电路中使用 SR 锁存器
现在我们有了一个基本的存储设备——SR 锁存器,让我们在一个示例电路中使用它。让我们回到自动售货机的例子,设计一个使用锁存器的自动售货机电路。该电路有以下要求:
-
电路有两个输入:一个 COIN 按钮和一个 VEND 按钮。按下 COIN 表示插入硬币,按下 VEND 则使机器售出物品(电路将点亮一个 LED 表示物品正在售出)。
-
电路有两个 LED 输出:COIN LED 和 VEND LED。插入硬币时,COIN LED 亮起;VEND LED 亮起表示物品正在售出。
-
机器在插入硬币之前不会售出物品。
-
为了简单起见,假设每次只能投入一枚硬币。投入额外的硬币不会改变电路的状态。
-
通常,在完成售货操作后,我们期望电路能够重置,并恢复到“无硬币”状态。然而,为了简化设计,我们将跳过自动重置,而采用手动重置。
从概念上讲,我们的自动售货机电路将按图 6-7 所示实现。

图 6-7:概念性的自动售货机电路,带手动重置
让我们来一步步解析图 6-7。当你按下 COIN 按钮时,COIN 存储器(一个 SR 触发器)会记录硬币已被投放的事实。然后,存储器输出 1,表示硬币已被投放,COIN LED 亮起。当你按下 VEND 按钮时,如果之前已经投放了硬币,与门的输出为 1,VEND LED 亮起。另一方面,如果你在没有投币的情况下按下 VEND 按钮,什么也不会发生。要清除 COIN LED 并重置设备,你必须手动将 Reset 输入设置为 1。
注意
请参阅项目 #7 在第 105 页,您可以在此处构建刚刚描述的自动售货机电路。
这个基本的自动售货机电路展示了在电路中实际使用存储器的应用。由于我们的电路设计包括一个存储元件,因此 VEND 按钮的行为会根据是否曾投放过硬币而有所不同。然而,一旦 COIN 位在存储器中被设置,它将一直保持直到电路被手动重置。这并不理想,因此我们将更新电路,使其在售货操作完成后自动重置。
一旦机器完成售货操作,我们期望 COIN 位被重置为 0,因为售货的行为“消耗”了硬币。换句话说,售货操作也应该导致硬币存储器的重置。为了实现这一逻辑,我们可以将与门的输出连接到存储器重置,如图 6-8 所示。这样,当 VEND LED 亮起时,COIN 存储器会被重置。

图 6-8:概念性的自动售货机电路,带自动重置
如图 6-8 所示的系统将在售货过程中重置电路,但此设计存在一个问题。你能找出来吗?问题可能并不显而易见。如果你完成了上一个项目,可能想在刚刚构建的电路上尝试这种重置方式。将一根电线从与门的输出端连接到 SR 触发器的 R 输入端,按下 COIN 按钮,再按下 VEND 按钮。以下有剧透,除非你已经在脑中或在面包板上试过,否则请不要继续阅读!
问题在于,虽然复位按预期工作,但它发生得非常迅速,以至于 VEND LED 立即熄灭,或者更可能是,VEND LED 根本没有亮起。在这里,我们有一个技术上可行但执行过快的设计例子,以至于设备的用户无法看到发生了什么。这在用户界面设计中是一个相当常见的问题。我们构建的设备和程序通常运行得太快,以至于必须故意减慢一些速度,以便用户能跟得上。在这种情况下,一种解决方案是在线路上引入延迟,使得 VEND LED 能够在复位发生之前点亮一到两秒钟。这在图 6-9 中展示了出来。

图 6-9:概念性自动售货机电路与自动延迟复位
我们如何添加延迟呢?一种方法是使用电容器。电容器是一种储存能量的电气元件。它有两个端子。当电流流向电容器时,电容器充电。电容器储存电荷的能力叫做电容,其单位是法拉(farads)。1 法拉是一个非常大的数值,所以我们通常用微法(μF)来表示电容器的容量。
当电容器没有充电时,它就像是一个短路。一旦电容器充电,它就像是一个开路。电容器充电或放电的时间由电容器的电容值和电路中的电阻决定。更大的电容和电阻值会导致电容器需要更长时间才能充电。所以我们可以使用电容器和电阻器来引入电路中的延迟,这是由于电容器充电所需的时间。
注意
请参见项目 #8 在第 107 页,在这里你可以为自动售货机电路添加延迟复位功能。
到目前为止,在本章中我们只探讨了单比特设备的存储。虽然 1 比特的内存适用性有限,但在第七章中,我们将看到如何利用多个单比特内存单元结合起来表示更多的数据。
时钟信号
随着电路变得更加复杂,我们通常需要保持各种元素同步,以确保它们能同时改变状态。对于具有多个内存设备的电路,我们可能需要确保所有存储的比特可以同时设置。特别是在我们需要一起考虑一组比特时,这一点尤为重要。我们可以用时钟信号来同步多个电路组件。时钟信号,简称时钟,其电压水平在高低之间交替。通常,信号按照规则的节奏交替,其中信号的一半时间是高电平,另一半时间是低电平。我们称这种类型的信号为方波。图 6-10 展示了一个 5V 的方波时钟信号在时间上的图像。

图 6-10:一个 5V 方波时钟信号
电压上升和下降的单次迭代叫做一个脉冲。从低到高再回到低(或反之)的完整振荡叫做一个周期。我们用每秒钟的周期数来衡量时钟信号的频率,单位是赫兹(Hz)。在图 6-11 中,显示的时钟信号频率是 2Hz,因为该信号在一秒钟内完成了两次完整的振荡。

图 6-11:2Hz 时钟信号
当电路使用时钟时,所有需要同步的组件都连接到时钟。每个组件的设计都允许仅在时钟脉冲发生时才进行状态变化。时钟驱动的组件通常会在脉冲的上升沿或下降沿触发状态变化。一个在上升脉冲边缘变化状态的组件称为正沿触发,而一个在下降脉冲边缘变化状态的组件称为负沿触发。图 6-12 提供了一个上升沿和下降沿的例子。

图 6-12:脉冲边缘示意图
本书中的图形将脉冲边缘表示为垂直线;这意味着从低到高或反之的瞬时变化。然而,实际上,状态的变化是需要时间的,但为了简化讨论,我们假设状态变化是瞬时发生的。
注意
请参阅项目#9 在第 109 页,你可以将你的 SR 触发器用作手动时钟。
JK 触发器
使用时钟的 1 位存储设备叫做触发器。术语latch和触发器有时会有些重叠,但在这里我们使用latch来指没有时钟的存储设备,使用触发器来指带时钟的存储设备。你可能会看到这些术语在其他地方互换使用或有不同的含义。
让我们来看看一个特定的时钟记忆设备——JK 触发器。JK 触发器是 SR 触发器的概念性扩展,因此我们可以将它们进行比较。SR 触发器有输入 S 来设置存储位,输入 R 来重置存储位;类似地,JK 触发器有输入 J 来设置,输入 K 来重置。SR 触发器在 S 或 R 被设为高电平时立即改变状态,而 JK 触发器只有在时钟脉冲时才会改变状态。JK 触发器还增加了一个附加功能:当 J 和 K 都设为高电平时,输出会在低到高或高到低之间切换一次。这一点在表 6-2 中做了总结。
表 6-2: SR 触发器与 JK 触发器的比较
| SR 触发器 | JK 触发器 | |
|---|---|---|
| 状态变化 | 当 S 或 R 为高时立即改变 | 只有在时钟脉冲时,J 或 K 为高时才改变 |
| 设置 | S = 1 | J = 1 |
| 重置 | R = 1 | K = 1 |
| 切换 | 不适用 | J = 1 且 K = 1 |
在图示中表示 JK 触发器时,可以使用图 6-13 中的符号。

图 6-13:JK 翻转触发器,正边缘触发(左),负边缘触发(右)
图 6-13 显示了两种版本的 JK 翻转触发器。左侧的是正边缘触发式,意味着它在时钟脉冲的上升沿发生状态变化。右侧的是负边缘触发式的 JK 翻转触发器(注意 CLK 输入上的圆圈);它在时钟脉冲的下降沿发生状态变化。除此之外,这两个设备的行为完全相同。
因此,JK 翻转触发器是一个 1 位存储设备,只有在接收到时钟脉冲时才会改变状态。它与 SR 锁存器非常相似,唯一的区别是它的状态变化由时钟控制,并且能够切换其值。表 6-3 总结了 JK 翻转触发器的行为。
表 6-3: JK 翻转触发器功能总结
| J | K | 时钟 | Q(输出) | 操作 |
|---|---|---|---|---|
| 0 | 0 | 脉冲 | 保持先前值 | 保持 |
| 0 | 1 | 脉冲 | 0 | 复位 |
| 1 | 0 | 脉冲 | 1 | 置位 |
| 1 | 1 | 脉冲 | 上一个值的反向 | 切换 |
我们不会像在 SR 锁存器中那样逐步讲解 JK 翻转触发器。相反,理解 JK 翻转触发器的最佳方法是直接操作它。
注意
请参见项目 #10,该项目位于第 111 页,你可以在其中动手操作 JK 翻转触发器。
T 翻转触发器
将 J 和 K 连接并将其视为一个输入,创建一个在时钟脉冲到来时只执行两种操作之一的翻转触发器:它要么切换,要么保持其值。要理解为什么会这样,请查看表 6-3,并注意当 J 和 K 都为 0 或者都为 1 时的行为。将 J 和 K 连接是一种常用的技术,具有这种行为的翻转触发器叫做 T 翻转触发器。图 6-14 显示了 T 翻转触发器的符号。

图 6-14:J 和 K 连接的 JK 翻转触发器被称为 T 翻转触发器。
因此,T 翻转触发器只会在时钟脉冲时切换其值,当 T 为 1 时。表 6-4 总结了 T 翻转触发器的行为。
表 6-4: T 翻转触发器功能总结
| T | 时钟 | Q | 操作 |
|---|---|---|---|
| 0 | 脉冲 | 保持先前值 | 保持 |
| 1 | 脉冲 | 上一个值的反向 | 切换 |
在 3 位计数器中使用时钟
为了说明时钟在电路中的应用,让我们构建一个 3 位计数器——一个二进制从 0 到 7 计数的电路。这个电路有三个存储元件,每个元件代表一个 3 位数字的一位。电路接受时钟输入,当时钟脉冲发生时,3 位数字会递增(增加 1)。由于所有位都代表一个数字,因此重要的是我们要使它们的状态变化与时钟同步。让我们使用 T 翻转触发器来实现这一点。
首先,查看表 6-5,复习一下如何使用 3 位数字进行二进制计数。
表 6-5: 使用 3 位进行二进制计数
| 二进制 | 十进制 |
|---|---|
| 000 | 0 |
| 001 | 1 |
| 010 | 2 |
| 011 | 3 |
| 100 | 4 |
| 101 | 5 |
| 110 | 6 |
| 111 | 7 |
表 6-5 将我们的 3 位数字作为每一行的单一值展示。现在,我们将每一位分配给标记为 Q0、Q1 和 Q2 的内存元素。Q0 是最不重要的位,Q2 是最重要的位,如表 6-6 所示。
表 6-6: 二进制计数,每一位分配给一个单独的内存元素
| 所有 3 位 | Q2 | Q1 | Q0 | 十进制 |
|---|---|---|---|---|
| 000 | 0 | 0 | 0 | 0 |
| 001 | 0 | 0 | 1 | 1 |
| 010 | 0 | 1 | 0 | 2 |
| 011 | 0 | 1 | 1 | 3 |
| 100 | 1 | 0 | 0 | 4 |
| 101 | 1 | 0 | 1 | 5 |
| 110 | 1 | 1 | 0 | 6 |
| 111 | 1 | 1 | 1 | 7 |
如果我们单独查看表 6-6 中的 Q 列,就能看到一个模式。当我们计数时,Q0 每次都会切换。当 Q0 之前是 1 时,Q1 才会切换。当 Q0 和 Q1 都是 1 时,Q2 才会切换。换句话说,除了 Q0,每一位在下一个计数时会在前面的所有位为 1 时切换。T 触发器非常适合实现这个计数器,因为它们正是用于切换的!让我们看看如何构建一个电路来实现这个功能,如图 6-15 所示。

图 6-15:由 T 触发器构建的 3 位计数器
在图 6-15 中,所有三个 T 触发器使用相同的时钟信号,因此它们是同步的。T0 连接到 5V,因此 Q0 每次时钟脉冲时都会切换。T1 连接到 Q0,因此只有当 Q0 为高电平时,时钟脉冲才会使 Q1 切换。T2 连接到 Q0 和 Q1,因此当 Q0 和 Q1 都为高电平时,时钟脉冲才会使 Q2 切换。
注
请参见项目 #11 以及第 113 页,在这里你可以构建你自己的 3 位计数器。
想一想我们如何将这样的计数器与我们之前设计的自动售货机电路结合使用。我们可以不单纯地跟踪是否投币,而是跟踪投币的数量,至少可以跟踪到七个硬币!为了使自动售货机计数器有用,它还需要能够进行倒计数,因为售卖商品应该减少硬币数量。这里我不会详细讲解如何将计数器添加到自动售货机电路中,但你可以自己进行实验。网上有计数器电路的设计,可以进行加法和减法计数,或者你可以使用像 74191 这样的加/减计数器 IC。
我们已经从 T 触发器构建了一个计数器,而 T 触发器又是由 JK 触发器构建的,JK 触发器是基于晶体管的数字逻辑电路!这再次展示了封装如何使我们能够构建复杂的系统,同时隐藏细节。
总结
本章内容介绍了顺序逻辑电路和时钟信号。你了解了与组合逻辑电路不同,顺序电路具有记忆功能,能记录过去的状态。你学习了 SR 锁存器,一种简单的单比特存储设备。我们看到如何通过时钟信号同步多个电路组件,包括存储设备,时钟信号是一种电气信号,其电压水平在高和低之间交替变化。一个带时钟的单比特存储设备被称为触发器,它使状态变化只在与时钟信号同步时发生。你了解了 JK 触发器的工作原理,如何通过 JK 触发器构建 T 触发器,最后,如何将时钟和 T 触发器结合使用来创建一个 3 位计数器。
内存和时钟是现代计算设备的关键组件,在下一章中,我们将看到它们如何在今天的计算机中发挥作用。在那里,你将学习到计算机硬件——内存、处理器和 I/O。
项目 #6:使用 NOR 门构建 SR 锁存器
在本项目中,你将在面包板上构建一个 SR 锁存器。你将输出 Q 连接到一个 LED,以便轻松观察状态。你应测试将 S 和 R 设置为高或低,并观察输出。
本项目需要以下组件:
-
面包板
-
LED
-
用于 LED 的限流电阻(约 220Ω)
-
跳线
-
7402 IC(包含四个 NOR 门)
-
5 伏电源
-
两个 470Ω电阻
-
两个适合面包板的开关或按键
-
可选:一个额外的 220Ω电阻和另一个 LED
提醒一下,如果你需要关于这些主题的帮助,可以参考第 333 页的“购买电子元件”和第 336 页的“为数字电路供电”部分。同时,复习第 68 页的项目 #4,提醒你如何使用带下拉电阻的按钮/开关。按照图 6-16 所示连接组件,构建 SR 锁存器。请注意,7402 IC 内的 NOR 门布局与其他 IC(如 7408(与门)和 7432(或门))中的门布局不同,因此务必使用正确的引脚连接输入和输出。

图 6-16:由 7402 IC 构建的 SR 锁存器接线图
在你按照图 6-16 所示构建 SR 锁存器电路后,将 S 和 R 连接到带有下拉电阻的按钮(或开关),如图 6-17 所示。这使得你可以通过按下按钮轻松设置 S 或 R 的值。

图 6-17:使用按钮和下拉电阻控制输入 S 和 R
连接按钮到 SR 锁存器后,尝试通过按下和释放按钮将 S 或 R 设置为高电平或低电平。观察结果。当您按下 S 时,Q 是否打开并在释放 S 后保持打开?当您按下 R 时,Q 是否关闭并在释放 R 后保持关闭?如果您想要查看 Q 的值,它应该始终与 Q 相反,只需将另一个 220Ω电阻器和 LED 连接到 IC 的引脚 1 和 6。
当您初始应用电源时,输出将处于不可预测状态。也就是说,电路可能以 Q = 0 或 Q = 1 的任一值启动。或者,您的电路可靠地以某个特定值的 Q 启动。这种不可预测性的原因是这种设计导致了竞争条件。如果 S = 0 并且 R = 0 在应用电源时,N1 和 N2 都试图输出 1。其中一个稍快地做到了这一点(因此是竞赛)。如果 N1 首先输出 1,N2 变低,Q 为 0。如果 N2 首先输出 1,N1 变低,Q 为 1。可以通过在启动期间按住 R 按钮(以强制 Q = 0)然后在启动后释放 R 按钮来解决此问题。
保留此电路,我们将在下一个项目中使用它。
项目#7:构建基本自动售货机电路
在这个项目中,您将构建本章前述的自动售货机电路。您可以重复使用上一个项目中的 SR 锁存器作为存储单元。确保在 LED 上使用限流电阻器,并为按钮输入使用下拉电阻器。测试电路以确保其按预期工作。要重置电路,请按 SR 锁存器上的 R 按钮。
对于这个项目,您将需要以下组件:
-
在您构建的面包板上的 7402 SR 锁存器,见项目#6
-
一个额外的 LED
-
使用额外的限流电阻器来与您的 LED 一起使用(约为 220Ω)
-
连接线
-
7408 IC(包含四个与门)
-
适合面包板的额外按钮或开关
-
使用额外的下拉电阻器来与您的按钮一起使用(约为 470Ω)
提醒,请参阅章节“购买电子元件”在第 333 页和“供电数字电路”在第 336 页如果您需要帮助这些主题。
在显示于图 6-18 中的电路图中,IC 引脚号码显示在方框中。虽然它们在图表中未显示,但确保将 7402 和 7408 芯片都连接到 5V 和地(分别是引脚 14 和 7)。

图 6-18:基本自动售货机电路的接线图
图 6-18 的底部部分是您在上一个项目中构建的电路。唯一的区别是现在 S 按钮代表硬币按钮,而输出 Q LED 现在代表硬币指示 LED。要构建完整的电路,您只需添加电路的顶部部分并将两部分连接如图所示。
一旦电路搭建完成,你应该能看到,当你按下 COIN 按钮时,COIN LED 灯会亮起。按下 VEND 按钮时,VEND LED 灯应亮起,但仅当 COIN LED 灯已经亮起时才会如此。按下 RESET 按钮以重置电路。
保留这个电路,我们将在下一个项目中使用它。
项目 #8:为自动售货机电路添加延迟复位
在这个项目中,你将为项目 #7 中的自动售货机电路添加一个延迟复位。你将需要以下组件:
-
你在项目 #7 中构建的自动售货机电路
-
4.7kΩ电阻
-
220μF 电解电容器
-
跳线
电容器有多种类型,关于各种类型的讨论超出了本书的范围。对于这个项目,你将使用电解电容器(图 6-19)。连接电容器时,请注意电解电容器是有极性的,这意味着一根引脚是负极,另一根是正极。寻找标示负极的负号或箭头。有时,负极端子较短。在图 6-21 中,负极端应连接到地。

图 6-19:一个电解电容器。带有条纹/箭头的较短引脚是负极引脚。
图 6-20 展示了电容器的电路符号。左侧是非极性电容器的符号。中间和右侧是用于表示极性电容器的符号。两个极性符号都提供了一种识别电容器正负端的方法。

图 6-20:电容器的电路符号
图 6-21 展示了如何将基于电容的延迟复位添加到自动售货机电路中,替代手动复位。继续阅读图示后面的内容,了解如何构建这个电路的更多细节。

图 6-21:带延迟复位的自动售货机电路接线图
如果你仍然有手动复位开关或按钮连接到 R(7402 芯片的第 5 引脚),请务必断开它,因为它的存在会干扰延迟复位的操作。在图 6-21 中,请注意我们的电路的 VEND 输出(7408 芯片的第 3 引脚),当发生售货操作时会变高,并通过一个新的延迟组件连接到锁存器的复位输入。这个新组件由一个电阻和电容器组成,它们共同引入约 1 秒的延迟来进行复位。接下来让我们看看这里发生了什么:
-
当发生售货操作时,7408 与门的输出会变高。
-
初始时,未充电的电容器表现得像是一个接地的短路,复位 R 到锁存器的电平保持低,因此最初不会发生复位。
-
由于复位尚未发生,VEND LED 有机会亮起。
-
如果按住 VEND 按钮,AND 输出保持高电平,电容开始充电。
-
经过大约 1 秒钟后,电容就充足了,表现得像一个开路,从而有效地断开了与地的连接。
-
触发器的重置输入 R 变为高电平,重置发生。
关于这个设计有几点需要注意:
-
必须按住 VEND 按钮,以便电容有时间充电。
-
电路可能仍然会在 COIN LED 已经亮起的情况下启动。只需按住 VEND 按钮进行重置。这可以通过电源开启重置电路来解决,但这超出了本项目的范围。
-
如果添加重置组件导致整个售货机电路无法工作,可能是 R 输入被卡在高电压状态。检查 7402 芯片第 5 脚的电压,看看当它应该是低电压时,是否过高(高于 0.8V)。如果遇到这个问题,请再次检查 4.7kΩ电阻和 220μF 电容的数值。还要检查接线,松动的连接或错误排位的跳线可能会导致问题。
-
我选择了电容和电阻的数值,因为它们能产生大约 1 秒的延时。你可以使用其他数值。然而,改变这些数值可能会导致 R 输入的电压过高,正如前面提到的那样。
你完成的电路应该与图 6-22 所示的电路相似,尽管你的具体布局可能会有所不同。

图 6-22:面包板上的带延时重置的售货机电路
我建议你保留电路中的 SR 触发器部分,因为你将在接下来的项目中再次使用它。你可以移除电路板上的其他组件,但请保留项目 #6 部分。或者,当需要时,你也可以重新构建一个 SR 触发器。
项目 #9:使用触发器作为手动时钟
你将需要一个时钟信号来进行本章后面的项目。在这个项目中,你将把之前构建的 SR 触发器配置为手动时钟。
正如你之前学到的,时钟输入需要在高电压和低电压之间交替。你可以尝试通过将一根线在地和 5V 之间移动来实现时钟。这样当然能使电压交替变化,但并不是你想要的那种方式。当你移动电线时,有时电线并没有连接到任何地方。在这些时刻,时钟输入引脚上的电压会“漂浮”,并且电路行为会变得不可预测。这不是一个好的选择。
或者,你可以添加一个振荡器,自动生成有规律的脉冲,比如每秒一个脉冲。这就是现实世界中时钟的典型工作方式。一种常见的集成电路专门用于这个目的:555 定时器。然而,在接下来的练习中,你需要仔细观察电路中的状态变化,因此你真正需要的是一个手动时钟,也就是说,只有在你指示时它才会变高或变低。从某种意义上说,这种手动时钟甚至不算真正的时钟,因为它不会按规律交替状态。话虽如此,无论它是否技术上算作时钟都不是特别重要——我们需要一个可以用来手动触发状态变化的设备。
你可能会想尝试使用普通的按键和下拉电阻作为时钟,如图 6-23 所示。毕竟,按下按钮会使电压变高,松开按钮会使电压变低。

图 6-23:带下拉电阻的简单开关作为时钟输入(这个方法效果不好)
不幸的是,图 6-23 中的设计实际上并不是一个好的手动时钟。问题在于,机械按钮和开关往往会“抖动”。开关内部有金属接点,在开关闭合时会连接。关闭开关时,接点会首次连接,但随后接点会分开并再次接触,有时会多次发生,直到开关最终稳定在关闭状态。当开关打开时,也会发生类似的情况,只是方向相反。简单的按钮按下或开关翻转会导致电压多次上下跳动。这种现象叫做开关抖动,如图 6-24 所示。

图 6-24:开关抖动,这不是我们在时钟中想要的效果
去抖电路是去除抖动的硬件选项。一个这样的去抖电路基于 SR 锁存器,而巧合的是,你已经构建了一个!如果你将 S 和 R 连接到开关,这些输入到锁存器仍然会抖动,但锁存器的输出(Q)会保持其值,如图 6-25 所示。这是一种有效去除开关抖动的方法。

图 6-25:即使输入发生抖动,SR 锁存器也能输出干净的信号。
要使用 SR 锁存器作为时钟,按下 S 将时钟信号设为高电平,然后按下 R 将时钟信号设为低电平。只要不要同时按下两个按钮!你可以使用在项目#6 中构建的 SR 锁存器作为时钟。如果你之前在项目#8 中将复位按钮/开关从引脚 5 中移除,重新连接它。完整的 SR 锁存器作为手动时钟应该按图 6-26 所示进行布线。

图 6-26:由两个按钮/开关和 SR 锁存器组成的去抖动手动时钟
按下 S 键将时钟脉冲设置为高电平,按下 R 键将时钟脉冲设置为低电平。现在你有了一个可以在以下项目中使用的手动时钟。
项目 #10:测试一个 JK 触发器
尽管你可以用其他门电路构建 JK 触发器,但它作为集成电路出售,非常方便,这样可以省去一些麻烦。7473 芯片包含两个负边沿触发的 JK 触发器。在这个项目中,你将使用这个集成电路来测试单个 JK 触发器的功能。你将尝试将 J 和 K 设置为高或低,然后通过电路发送时钟脉冲。将 LED 连接到输出 Q,便于观察状态变化。
对于这个项目,你将需要以下组件:
-
将 SR 锁存器配置为时钟(在项目 #9 中讨论)
-
7473 集成电路(包含两个 JK 触发器)
-
跳线
-
LED
-
用于 LED 的限流电阻(约 220Ω)
提醒一下,如果你在这些话题上需要帮助,请参考第 333 页的“购买电子元件”和第 336 页的“为数字电路供电”。图 6-27 显示了 7473 集成电路的引脚图。

图 6-27:7473 集成电路的引脚图
7473 集成电路包含两个 JK 触发器,如图 6-27 所示。注意电压和接地连接不在“常规”位置,而是分别在引脚 4 和引脚 11。同时,注意 CLK(时钟)输入用圆圈标记,表示该电路是负边沿触发的;你应该预期当时钟脉冲下降时,状态会发生变化。由于你使用 SR 锁存器作为手动时钟,这意味着当你按下 SR 锁存器的 R 输入按钮时,你会看到 JK 状态变化。
章节中没有提到每个 JK 触发器的额外输入:CLR。当这个引脚被设置为低电平时,触发器会清除保存的位(Q = 0)。CLR 是异步的,这意味着它不依赖时钟脉冲。上面显示的 CLR 线表示它是低有效的,这意味着当输入被设置为低电平时,保存的位会被清除。CLR 有时也叫做复位(Reset)或 R,不能与我们 SR 锁存器的 R 输入混淆。将 JK 触发器的 CLR 输入(引脚 2)连接到 5V,以防止触发器复位。为了测试单个触发器,你可以按图 6-28 所示连接芯片。

图 6-28:一个简单的 JK 测试电路
使用你之前构建的 SR 锁存器作为时钟,将 SR 锁存器的输出 Q(7402 上的引脚 3 和引脚 4)连接到 7473 的时钟输入(引脚 1)。现在,尝试将 7473 上的输入 J(引脚 14)和 K(引脚 3)设置为 5V 或接地。你会看到,直到时钟从高电平过渡到低电平之前,这样做对 JK 触发器的输出 LED 没有任何影响。提示:通过先按下 S 再按下 R 来为 SR 锁存器时钟提供脉冲信号,使时钟信号从高电平变为低电平。返回查看表 6-3,查看 JK 触发器的预期功能,并确保你的电路按预期工作。
保持该电路不变,用于下一个项目。
项目#11:构建一个 3 位计数器
在本项目中,你将构建本章前面描述的 3 位计数器。将 Q 输出连接到 LED,以便轻松观察输出。
对于本项目,你将需要以下组件:
-
在项目#10 中构建的电路(包括来自项目#9 的手动时钟)
-
一个额外的 7473 集成电路
-
7408 集成电路(包含四个与门)
-
47kΩ电阻
-
10μF 电解电容
-
一个额外的按钮或开关
-
跳线
-
两个额外的 LED
-
两个额外的限流电阻,用于 LED(每个大约 220Ω)
按照图 6-29 中的示意图连接所有部件。集成电路的引脚编号显示在框内。

图 6-29:由 T 触发器构建的 3 位计数器,显示引脚编号
除了图 6-29 中显示的引脚连接外,请确保还要做以下连接:
-
两个 7473 集成电路的引脚 4 和引脚 11 分别需要连接到 5V 和接地。
-
7408 的引脚 7 应连接到接地,引脚 14 应连接到 5V。
-
Q0、Q1 和 Q2 应该通过 220Ω电阻连接到 LED,以便你可以看到比特的更新。
-
手动时钟输出(7402 上的引脚 3 和引脚 4)应连接到所有三个触发器的 CLK(第一个 7473 的引脚 1 和 5,第二个 7473 的引脚 1)。
该电路以不可预测的状态启动。你可以通过手动重置所有三个触发器来纠正这一点,但这很繁琐。相反,可以添加一个上电复位电路,确保所有触发器在输出 = 0 时启动。每个 7473 集成电路中的触发器都有一个 CLR 输入,当该输入为低电平时,会重置触发器,无论时钟状态如何。你希望 CLR 在启动时短暂地变为低电平,然后变为高电平并保持在那里。这样可以确保计数器在上电时从零开始。为了更好地控制,你还可以添加一个计数器复位按钮,当按下时手动重置计数器。该复位功能如图 6-30 所示。

图 6-30:3 位二进制计数器的上电复位电路
当电源最初施加到图 6-30 所示的电路时,电容器表现得像一个短路,CLR 保持低电平,将电路设置为初始状态。一旦电容器充电完成,它就像一个开路,CLR 变为高电平,电路准备好使用。当按下计数器重置按钮或开关时,CLR 也会变为低电平,电路被重置。此电路需要连接到 CLR 输入:第一个 7473 芯片的 2 脚和 6 脚,以及第二个 7473 芯片的 2 脚。在项目 #10 中,第一个 7473 的 2 脚连接到 5V;在连接上电复位电路之前,一定要先将其断开。记得正确放置电解电容器的引脚——负极应连接到地。
在设置好电源复位后,电路应以计数器从 000 开始。向电路发送时钟脉冲应导致计数器在时钟边沿下降时增加 1。提醒:通过按下 S 设置时钟信号为高电平,按下 R 设置时钟信号为低电平,来脉冲 SR 锁存器的时钟。测试计数从 000 到 111,并确保计数器按预期工作。
第七章:计算机硬件**

前几章介绍了计算的基础元素——二进制、数字电路、内存。现在让我们看看这些元素如何在计算机中结合起来,计算机是一个超越其各个部分的设备。在本章中,我首先提供计算机硬件的概述。然后我们将深入探讨计算机的三个部分:主内存、处理器以及输入/输出。
计算机硬件概述
让我们从计算机与其他电子设备的区别概述开始。此前,我们已经看到如何使用逻辑电路和内存设备构建执行有用任务的电路。我们使用逻辑门构建的电路具有一些硬接入的特性。如果我们想添加或修改某个特性,就必须更改电路的物理设计。在面包板上这是可能的,但对于已制造并交付给客户的设备来说,通常无法更改硬件。仅通过硬件定义设备的特性限制了我们快速创新和改进设计的能力。
到目前为止,我们构建的电路让我们初步了解了计算机是如何工作的,但我们还缺少计算机的一个关键元素:可编程性。也就是说,计算机必须能够执行新的任务,无需改变硬件。为了实现这一目标,计算机必须能够接受一组指令(程序),并执行这些指令中指定的操作。因此,它必须拥有能够按照程序指定的顺序执行各种操作的硬件。可编程性是区分计算机与非计算机设备的关键特征。在本章中,我们将讨论计算机的硬件,即计算机的物理元素。与此相对的是软件,即告诉计算机该做什么的指令,我们将在下一章讨论软件。
运行软件的能力将计算机与固定用途设备区分开来。也就是说,软件仍然需要硬件,那么我们需要什么样的硬件来实现通用计算机呢?首先,我们需要内存。我们已经介绍了诸如锁存器和触发器之类的单比特内存设备;计算机中使用的内存是这些简单内存设备的概念扩展。计算机中使用的主要内存被称为主内存,但通常简称为内存或随机存取内存(RAM)。它是易失性的,意味着它只在电源开启时保持数据。RAM 中的“随机存取”意味着任何任意的内存位置都可以在大致相同的时间内访问,和其他位置一样。
我们需要的第二个关键组件是 中央处理单元,或称 CPU。通常简称为 处理器,这个组件执行软件中指定的指令。CPU 可以直接访问主内存。今天的大多数处理器都是微处理器,即集成电路上的 CPU。单芯片集成的处理器具有成本较低、可靠性更高和性能更强的优点。CPU 是我们之前讨论过的数字逻辑电路的概念性扩展。
虽然主内存和 CPU 是计算机的最低硬件要求,但实际上,大多数计算设备都需要与外部世界进行交互,这些交互通过输入/输出(I/O)设备实现。在本章中,我们将更详细地讨论主内存、CPU 和 I/O。这三个元素在图 7-1 中有示意。

图 7-1:计算机硬件元素
主内存
在执行程序时,计算机需要一个地方来存储程序的指令和相关数据。例如,当计算机运行文字处理器来编辑文档时,计算机需要一个地方来保存程序本身、文档的内容以及编辑状态——文档的哪一部分是可见的、光标的位置等等。所有这些数据最终都是一系列位,CPU 需要能够访问它们。主内存负责存储这些 1 和 0。
让我们来探索计算机中主内存是如何工作的。计算机内存有两种常见类型:静态随机存取内存 (SRAM) 和 动态随机存取内存 (DRAM)。在这两种类型中,内存存储的基本单位是 存储单元,它是一个能够存储单个位的电路。在 SRAM 中,存储单元是一种触发器。SRAM 是静态的,因为其触发器存储单元在电源持续供电时会保留其位值。另一方面,DRAM 存储单元是通过晶体管和电容器实现的。电容器的电荷会随着时间的推移而泄漏,因此数据必须定期重新写入单元。这种对内存单元的刷新使得 DRAM 成为动态内存。今天,DRAM 由于其相对较低的价格,通常被用于主内存。SRAM 虽然更快,但成本较高,因此通常用于对速度要求极高的场景,例如缓存内存,我们稍后会讨论到。一个 RAM“条”示例如图 7-2 所示。

图 7-2:随机存取内存
一般来说,你可以把 RAM 的内部结构看作是由内存单元组成的网格。网格中的每一个单比特单元都可以通过二维坐标来标识,即它在网格中的位置。一次访问一个比特并不是非常高效,因此 RAM 会并行访问多个 1 比特内存单元的网格,从而实现一次读取或写入多个比特——例如,一个完整的字节。内存中一组比特的位置被称为 内存地址,它是一个数字值,用于标识一个内存位置。内存通常是 按字节寻址 的,这意味着一个内存地址对应 8 位数据。内存的内部布局或内存单元的实现对于 CPU(或程序员)来说并不是必须知道的知识!关键点是,计算机会给内存的字节分配数字地址,CPU 可以读取或写入这些地址,正如 图 7-3 所示。

图 7-3:CPU 从内存地址读取一个字节。
让我们考虑一个假设的计算机系统,它最多可以寻址 64KB 的内存。以今天的标准来看,这对一台计算机来说是一个非常小的内存,但它仍然对我们作为示例很有用。我们还假设这台假设的计算机的内存是按字节寻址的;每个内存地址代表一个字节。这意味着我们需要为每个字节分配一个唯一的地址,并且由于 64KB 等于 64 × 1024 = 65,536 字节,我们需要 65,536 个唯一的地址。每个地址只是一个数字,内存地址通常从 0 开始,因此我们的地址范围是 0 到 65,535(或者 0xFFFF)。
由于我们的假设 64KB 计算机是数字设备,内存地址最终以二进制表示。那么我们需要多少位来表示这个系统的内存地址呢?一个二进制数可以表示的唯一值的数量为 2^(n),其中 n 为位数。所以我们想知道 n 的值,使得 2^(n) = 65,536。2 的某个指数的反运算是以 2 为底的对数。因此 log2) = n,而 log2 = 16。所以换句话说,2¹⁶ = 65,536。因此,表示 65,536 字节需要 16 位的内存地址。
或者更简单地说,既然我们已经知道我们最高的内存地址是 0xFFFF,并且知道每个十六进制符号代表 4 位,我们可以看出需要 16 位(4 个十六进制符号 × 每个符号 4 位)。再次说明,我们的假设计算机能够寻址 65,536 字节,每个字节被分配一个 16 位的内存地址。表 7-1 显示了一个 16 位内存布局,包含一些示例数据。
表 7-1: 一个 16 位内存地址布局,跳过中间地址,包含示例数据
| 内存地址(以二进制表示) | 内存地址(以十六进制表示) | 示例数据 |
|---|---|---|
0000000000000000 |
0000 |
23 |
0000000000000001 |
0001 |
51 |
0000000000000010 |
0002 |
4A |
---------------- |
---- |
-- |
1111111111111101 |
FFFD |
03 |
1111111111111110 |
FFFE |
94 |
1111111111111111 |
FFFF |
82 |
为什么位数很重要?用于表示内存地址的位数是计算机系统设计的关键部分。它限制了计算机可以访问的内存量,并且影响程序在底层如何处理内存。
现在让我们假设我们的虚拟计算机已经从内存地址 0x0002 开始存储 ASCII 字符串“Hello”。由于每个 ASCII 字符都需要 1 个字节,因此存储“Hello”需要 5 个字节。查看内存时,通常会使用十六进制来表示内存地址以及这些内存地址的内容。表格 7-2 提供了从地址 0x0002 开始存储的“Hello”在内存中的可视化表示。
表格 7-2: “Hello” 存储在内存中
| 内存地址 | 数据字节 | 数据(ASCII) |
|---|---|---|
0000 |
00 |
|
0001 |
00 |
|
0002 |
48 |
H |
0003 |
65 |
e |
0004 |
6C |
l |
0005 |
6C |
l |
0006 |
6F |
o |
0007 |
00 |
|
---- |
-- |
|
FFFF |
00 |
使用这种格式可以清楚地看出,每个地址只存储 1 个字节,因此存储所有 5 个 ASCII 字符需要地址从 0x0002 到 0x0006。请注意,表格中显示其他内存地址的值为 00,但实际上不能假设随机地址会存储 0;它可以是任何值。不过,在某些编程语言中,通常会以空字符(字节值为 0)结束文本字符串,在这种情况下,我们确实会期望在地址 0x0007 看到 00。
允许查看计算机内存的应用程序通常会将内存内容表示为类似图 7-4 所示的格式。

图 7-4:内存字节的典型视图
图 7-4 中的最左列是以十六进制表示的内存地址,接下来的 16 个值表示该地址及其后 15 个地址的字节。这种方法比表格 7-2 更紧凑,但这意味着每个地址没有被单独标出。在此图中,我们再次看到从地址 0x0002 开始存储的 ASCII 字符串“Hello”。
我们的假设计算机具有 64KB 的 RAM,作为示例非常有用,但现代计算设备通常具有更大的内存。到 2020 年为止,智能手机通常至少有 1GB 内存,而笔记本电脑通常至少有 4GB。
练习 7-1:计算所需的位数
使用刚才描述的技术,确定寻址 4GB 内存所需的位数。你需要回头查看表格 1-3,参考一下国际单位制(SI)前缀。记住,每个字节都有一个唯一的地址,这只是一个数字。答案在附录 A 中。
中央处理单元(CPU)
内存为计算机提供了一个存储数据和程序指令的地方,但执行这些指令的是 CPU 或处理器。正是处理器使得计算机能够运行在设计处理器时尚未构想到的程序。处理器实现了一组指令,程序员可以利用这些指令来构建有意义的软件。每条指令都很简单,但这些基本指令是所有软件的构建块。
以下是 CPU 支持的一些指令类型示例:
内存访问 读取、写入(到内存)
算术 加法、减法、乘法、除法、自增
逻辑 与、或、非
程序流程 跳转(到程序的特定部分)、调用(子程序)
我们将在第八章中详细介绍具体的 CPU 指令,但现在重要的是要理解,CPU 指令只是处理器可以执行的操作。它们相对简单(加两个数字、从内存地址读取、执行逻辑与等)。程序由这些指令的有序集合组成。用做饭的类比,CPU 就是厨师,程序是食谱,程序中的每条指令就是厨师知道如何执行的食谱步骤。
程序指令驻留在内存中。CPU 读取这些指令以便运行程序。图 7-5 展示了一个简单的程序,CPU 从内存中读取该程序。

图 7-5:一个示例程序从内存中读取并在 CPU 上运行。
图 7-5 中的示例程序是用伪代码编写的,这是一种人类可读的程序描述,但并不是用真实的编程语言编写的。程序中的步骤属于刚才描述的几类(内存访问、算术、逻辑和程序流程)。在第一步中,程序从内存中的某个地址读取一个数字。然后程序将 3 加到这个数字上。接着,它执行两个条件的逻辑与操作。如果逻辑结果为真,则程序执行“这个”;否则,执行“那个”。信不信由你,所有程序本质上只是这些基本操作的各种组合。
指令集架构
尽管所有 CPU 都实现了这些类型的指令,不同处理器上可用的具体指令是不同的。有些指令在某一类型的 CPU 上存在,但在其他类型的 CPU 上根本不存在。即使是几乎所有 CPU 都有的指令,也不是以相同的方式实现的。例如,用于表示“加两个数字”的特定二进制序列,在不同的处理器类型之间并不相同。使用相同指令的 CPU 家族被称为共享 指令集架构(ISA),或者简称 架构,它是描述 CPU 如何工作的模型。为某个特定 ISA 开发的软件可以在任何实现该 ISA 的 CPU 上运行。多个处理器型号,即使来自不同的制造商,也有可能实现相同的架构。这些处理器可能在内部工作方式上有很大不同,但通过遵循相同的 ISA,它们可以运行相同的软件。如今,存在两种流行的指令集架构:x86 和 ARM。
大多数桌面计算机、笔记本电脑和服务器都使用 x86 CPU。这个名字源自英特尔公司为其处理器制定的命名惯例(每个处理器型号都以 86 结尾),从 1978 年发布的 8086 开始,一直到 80186、80286、80386 和 80486。继 80486(或者更简单地说是 486)之后,英特尔开始使用 Pentium 和 Celeron 等品牌命名其 CPU;这些处理器尽管更名,但仍然是 x86 CPU。除了英特尔,其他公司也生产 x86 处理器,尤其是超威半导体公司(AMD)。
x86 这个术语指的是一组相关的架构。随着时间的推移,新的指令被加入到 x86 架构中,但每一代都试图保持向后兼容性。这通常意味着为较旧的 x86 CPU 开发的软件可以在较新的 x86 CPU 上运行,但针对较新的 x86 CPU 开发的,利用新 x86 指令的软件将无法在不支持这些新指令的旧 x86 CPU 上运行。
x86 架构包括三代主要的处理器:16 位、32 位和 64 位。让我们停下来仔细分析一下,当我们说一个 CPU 是 16 位、32 位还是 64 位处理器时是什么意思。与处理器相关的位数,也称为 位宽 或 字长,指的是处理器一次可以处理的位数。因此,一个 32 位的 CPU 可以处理 32 位长度的值。更具体地说,这意味着计算机架构有 32 位寄存器、32 位地址总线或 32 位数据总线,或者这三者都是 32 位。我们稍后会详细讨论寄存器、数据总线和地址总线。
回到 x86 及其各代处理器,最初的 8086 处理器于 1978 年发布,是一款 16 位处理器。受到 8086 成功的鼓舞,Intel 继续生产兼容的处理器。Intel 后续的 x86 处理器也是 16 位,直到 1985 年发布 80386 处理器,这款处理器引入了新的 32 位 x86 架构。这个 32 位版本的 x86 有时被称为 IA-32。得益于向后兼容性,现代 x86 处理器仍完全支持 IA-32。一个 x86 处理器的例子见图 7-6。
有趣的是,带领 x86 进入 64 位时代的是 AMD,而非 Intel。1990 年代末,Intel 的 64 位重点放在一种新 CPU 架构上,名为 IA-64 或 Itanium,这种架构并非x86 指令集架构(ISA),最终成为仅限服务器使用的小众产品。Intel 专注于 Itanium 时,AMD 抓住机会扩展了 x86 架构。2003 年,AMD 发布了 Opteron 处理器,这是第一个 64 位 x86 CPU。AMD 的架构最初被称为AMD64,后来 Intel 也采用了这一架构,并将其实现命名为Intel 64。这两种实现大体上功能相同,如今 64 位 x86 通常被称为x64或x86-64。

图 7-6:一款 Intel 486 SX,32 位 x86 处理器
尽管 x86 在个人计算机和服务器领域占据主导地位,ARM 处理器却统治着智能手机和平板等移动设备的领域。多家公司生产 ARM 处理器。一家公司名为 ARM Holdings,开发 ARM 架构并将其设计授权给其他公司实现。ARM CPU 常被用于系统级芯片(SoC)设计中,其中一个集成电路不仅包含 CPU,还包含内存和其他硬件。ARM 架构起源于 1980 年代,是一个 32 位的指令集架构(ISA)。2011 年,ARM 架构推出了 64 位版本。由于 ARM 处理器在移动设备中相较于 x86 处理器具有更低的功耗和成本,因此更受青睐。ARM 处理器也可以用于 PC,但这一市场仍然主要集中在 x86 上,以保持与现有 x86 PC 软件的向后兼容性。然而,2020 年,苹果宣布将把其 macOS 电脑从 x86 转移到 ARM CPU。
CPU 内部结构
在内部,CPU 由多个组件组成,这些组件共同工作以执行指令。我们将重点介绍三个基本组件:处理器寄存器、算术逻辑单元和控制单元。处理器寄存器是 CPU 内部存储数据的位置,数据在处理过程中保存在这里。算术逻辑单元(ALU)执行逻辑和数学运算。处理器的控制单元指挥 CPU,负责与处理器寄存器、算术逻辑单元和主存进行通信。图 7-7 展示了 CPU 架构的简化视图。

图 7-7:CPU 架构的一个大大简化的视图
让我们来看一下处理器寄存器。主内存存储正在执行程序的数据。然而,当程序需要对一块数据进行操作时,CPU 需要在处理器硬件中为数据提供一个临时存储位置。为此,CPU 具有称为处理器寄存器的小型内部存储位置,或简称寄存器。与访问主内存相比,访问寄存器是 CPU 的一种非常快速的操作,但寄存器只能容纳非常少量的数据。我们用位而不是字节来衡量单个寄存器的大小,因为寄存器非常小。例如,一个 32 位 CPU 通常具有 32 位“宽”的寄存器,这意味着每个寄存器可以容纳 32 位数据。这些寄存器由一个称为寄存器文件的组件实现(不要与数据文件,如文档或照片混淆)。寄存器文件中使用的存储单元通常是某种类型的 SRAM。
ALU 负责 CPU 内部的逻辑和数学运算。我们之前讲解过组合逻辑电路,这些电路的输出是输入的一个函数。处理器的 ALU 就是一个复杂的组合逻辑电路。ALU 的输入是称为操作数的值和一个表示要对这些操作数执行什么操作的代码。ALU 输出操作结果及一个状态,提供执行操作的更多细节。
控制单元充当 CPU 的协调者。它以一个循环重复执行:从内存中获取指令,解码指令,然后执行指令。由于正在运行的程序存储在内存中,控制单元需要知道读取哪个内存地址才能获取下一条指令。控制单元通过查看一个称为程序计数器(PC)的寄存器来确定这一点,在 x86 架构上也叫做指令指针。程序计数器保存下一条要执行的指令的内存地址。控制单元从指定的内存地址读取指令,将指令存储到一个称为指令寄存器的寄存器中,并更新程序计数器以指向下一条指令。然后,控制单元解码当前指令,理解表示指令的 1 和 0。一旦解码完成,控制单元执行该指令,这可能需要与 CPU 中的其他组件协调。例如,执行加法操作时,控制单元需要指示 ALU 执行所需的数学运算。指令执行完毕后,控制单元重复这一循环:获取、解码、执行。
时钟、核心与缓存
由于 CPU 执行的是有序的指令集,你可能会想知道是什么导致 CPU 从一条指令跳转到下一条指令。我们之前已经演示了如何利用时钟信号使电路从一个状态转换到另一个状态,比如在计数器电路中。相同的原理也适用于这里。CPU 接收输入的时钟信号,如图 7-8 所示,时钟脉冲作为信号,指示 CPU 在状态之间进行转换。

图 7-8:时钟为 CPU 提供震荡信号。
认为 CPU 每个时钟周期执行恰好一条指令是一种过于简单化的看法。有些指令需要多个周期才能完成。此外,现代 CPU 采用了一种叫做pipelining(流水线技术)的方法,将指令分解成更小的步骤,这样多个指令的部分可以由单个处理器并行执行。例如,当一条指令正在解码时,另一条指令可以被获取,而第三条指令可以被执行。尽管如此,考虑到每个时钟脉冲都可以作为指示 CPU 向前执行程序的信号,依然是有帮助的。现代 CPU 的时钟频率以吉赫兹(GHz)为单位。例如,一颗 2GHz 的 CPU 时钟每秒会震荡 2十亿次!
增加时钟频率可以让 CPU 每秒执行更多的指令。不幸的是,我们不能让 CPU 在任意高的时钟频率下运行。CPU 对输入时钟频率有实际的上限,超过该限制会导致过多的热量产生。而且,CPU 的逻辑门可能无法跟上,导致意外的错误和崩溃。多年来,计算机行业见证了 CPU 时钟频率上限的稳步提升。这一增长主要得益于制造工艺的持续改进,提升了晶体管的密度,使得 CPU 能够在功耗几乎不变的情况下,提供更高的时钟频率。1978 年,英特尔 8086 的时钟频率为 5MHz,而到了 1999 年,英特尔奔腾 III 的时钟频率达到了 500MHz,20 年内增长了 100 倍!直到 2000 年代初,CPU 时钟频率持续快速增长,突破了 3GHz 的门槛。从那时起,尽管晶体管数量继续增长,但与微小晶体管尺寸相关的物理限制使得时钟频率的大幅提升变得不再实际。
由于时钟频率停滞不前,处理器行业转向了一种新方法来提高 CPU 的工作效率。与其专注于增加时钟频率,CPU 设计开始聚焦于并行执行多条指令。多核CPU 的概念应运而生,这是一种拥有多个处理单元,称为核心的 CPU。CPU 核心实际上是一个独立的处理器,和其他独立的处理器共同存在于单一的 CPU 封装中,如图 7-9 所示。

图 7-9:四核 CPU——每个核心都有自己的寄存器、算术逻辑单元和控制单元
请注意,多个核心并行运行不同于流水线处理。多核并行意味着每个核心处理不同的任务,即一组独立的指令。与此不同,流水线处理发生在每个核心内部,允许多个指令的部分在同一个核心上并行执行。
每增加一个核心到处理器,就意味着计算机可以并行运行更多指令。也就是说,向计算机的 CPU 添加多个核心并不意味着所有应用程序都会立即或均等地受益。软件必须被编写以充分利用指令的并行处理,才能最大限度地发挥多核硬件的优势。然而,即使单个程序没有针对并行性进行设计,计算机系统整体也能受益,因为现代操作系统会同时运行多个程序。
我之前描述过 CPU 如何从主存储器加载数据到寄存器中进行处理,然后再将数据从寄存器存回内存以备后用。事实证明,程序往往会反复访问相同的内存位置。正如你可能预料的那样,多次从主存取回相同的数据是低效的!为了避免这种低效,在 CPU 内部有一小块内存,用于存储频繁访问的主存数据副本。这块内存被称为CPU 缓存。
处理器检查缓存,以查看它希望访问的数据是否已经在其中。如果是这样,处理器可以通过读取或写入缓存来加速操作,而不是直接访问主存。当所需数据不在缓存中时,处理器可以在从主存读取数据后,将其移动到缓存中。现代处理器通常具有多个缓存级别,通常有三级。我们将这些缓存级别分别称为 L1 缓存、L2 缓存和 L3 缓存。CPU 首先检查 L1 缓存是否有所需的数据,然后是 L2,再是 L3,最后才是主存,如图 7-10 所示。L1 缓存访问速度最快,但也最小。L2 缓存较慢且较大,而 L3 缓存则更慢且更大。请记住,即使是这些逐渐变慢的缓存级别,访问主存的速度仍然比访问任何缓存级别都要慢。

图 7-10:具有三级缓存的单核 CPU
在多核 CPU 中,一些缓存是每个核心特有的,而另一些则在各个核心之间共享。例如,每个核心可能有自己的 L1 缓存,而 L2 和 L3 缓存是共享的,如图 7-11 所示。

图 7-11:具有缓存的双核 CPU。每个核心都有自己的 L1 缓存,而 L2 和 L3 缓存是共享的。
超越内存和处理器
我已经概述了计算机所需的两个基本组件:内存和处理器。然而,如果一台设备仅由内存和处理器组成,仍然存在一些需要填补的空白,以便我们能够得到一台实用的设备。第一个空白是内存和 CPU 都是易失性的;当电源断开时,它们会丧失状态。第二个空白是,仅有内存和处理器的计算机无法与外界进行交互。现在让我们来看一下辅助存储和 I/O 设备如何填补这些空白。
辅助存储
如果计算机仅包含内存和处理器,那么每次设备断电时,它都会丢失所有数据!为了强调这一点,这里的数据不仅仅指用户的文件和设置,还包括任何已安装的应用程序,甚至操作系统本身。这样一台相当不便的计算机会要求每次开机时都需要加载操作系统和任何应用程序。这可能会让用户不愿意关闭设备。信不信由你,过去几代计算机确实是这样工作的,但幸运的是,今天的计算机不再如此。
为了解决这个问题,计算机使用辅助存储。辅助存储是非易失性的,因此即使系统断电,它也能记住数据。与 RAM 不同,辅助存储不能直接被 CPU 寻址。这类存储通常比 RAM 便宜得多,每字节的价格较低,使得与主内存相比,辅助存储可以提供更大的容量。然而,辅助存储的速度也比 RAM 慢得多;它不能作为主内存的替代品。
在现代计算设备中,硬盘驱动器和固态硬盘是最常见的辅助存储设备。硬盘驱动器(HDD)通过在快速旋转的盘片上利用磁性存储数据,而固态硬盘(SSD)则通过在非易失性存储单元中利用电荷存储数据。与 HDD 相比,SSD 速度更快、更安静、且对机械故障更具抵抗力,因为 SSD 没有任何活动部件。图 7-12 是几种辅助存储设备的照片。
有了辅助存储设备,计算机可以按需加载数据。当计算机开机时,操作系统从辅助存储加载到主内存;任何设置为在启动时运行的应用程序也会加载。启动后,当应用程序被启动时,程序代码会从辅助存储加载到主内存。任何本地存储的用户数据(如文档、音乐、设置等)也是如此;它必须从辅助存储加载到主内存才能使用。在常见用法中,辅助存储通常简称为存储,而主存储/主内存则直接称为内存或 RAM。

图 7-12:1997 年款的 4GB 硬盘驱动器与现代 32GB microSD 卡(固态存储的一种类型)并排展示
输入/输出
即使有了二级存储,我们假设的计算机仍然存在问题。一个由处理器、内存和存储组成的计算机并没有与外部世界进行交互的方式!这就是输入/输出设备的作用。输入/输出(I/O)设备是一个组件,允许计算机接收来自外部世界的输入(键盘、鼠标)、向外部世界发送数据(显示器、打印机),或者两者都做(触摸屏)。人类与计算机的交互需要通过 I/O。计算机之间的交互也需要通过 I/O,通常是通过计算机网络,如互联网。二级存储设备实际上是一种 I/O 设备。你可能不会认为访问内部存储是 I/O 操作,但从 CPU 的角度来看,读写存储只是另一个 I/O 操作。从存储设备读取数据是输入,而写入存储设备则是输出。图 7-13 提供了一些输入和输出的示例。

图 7-13:常见的输入和输出类型
那么,CPU 是如何与 I/O 设备进行通信的呢?计算机可以连接多种多样的 I/O 设备,而 CPU 需要一种标准的方式与这些设备进行通信。要理解这一点,我们首先需要讨论物理地址空间,即计算机可用的硬件内存地址范围。在本章的“主存储器”一节中,第 119 页介绍了内存字节如何分配地址。计算机系统上的所有内存地址将使用一定数量的位来表示。这个位数不仅决定了每个内存地址的大小,还决定了计算机硬件可用地址的范围——物理地址空间。地址空间通常比计算机上安装的 RAM 容量大,因此会有一些物理内存地址未被使用。
举个例子,对于一台具有 32 位物理地址空间的计算机,其物理地址范围是从 0x00000000 到 0xFFFFFFFF(这是用 32 位数字表示的最大地址)。这大约是 40 亿个地址,每个地址表示一个字节,或者 4GB 的地址空间。假设这台计算机有 3GB 的 RAM,那么可用的物理内存地址的 75%被分配给了 RAM 字节。
现在让我们回到 CPU 如何与 I/O 设备通信的问题。物理地址空间中的地址并不总是指向内存字节;它们也可以指向 I/O 设备。当物理地址空间映射到 I/O 设备时,CPU 可以通过读取或写入其分配的内存地址来与该设备通信;这称为内存映射 I/O (MMIO),如图 7-14 所示。当计算机像对待主内存一样对待 I/O 设备的内存时,CPU 无需任何特殊指令进行 I/O 操作。
然而,一些 CPU 家族,特别是 x86,确实包括用于访问 I/O 设备的特殊指令。当计算机采用这种方法时,设备不会映射到物理内存地址,而是分配一个I/O 端口。端口就像内存地址,但它不是指向内存中的某个位置,而是指向 I/O 设备。你可以将 I/O 端口的集合视为一个与内存地址不同的地址空间。这意味着端口 0x378 并不指向物理内存地址 0x378。通过单独的端口地址空间访问 I/O 设备被称为端口映射 I/O (PMIO)。今天的 x86 CPU 支持端口映射 I/O 和内存映射 I/O 两种方式。
I/O 端口和内存映射 I/O 地址通常指的是设备控制器,而不是直接指向存储在设备上的数据。例如,在硬盘驱动器的情况下,磁盘的字节并不是直接映射到地址空间中的。相反,硬盘控制器提供了一个接口,可以通过 I/O 端口或内存映射 I/O 地址访问,该接口允许 CPU 请求对磁盘上的位置进行读写操作。

图 7-14:内存映射 I/O
练习 7-2:了解你生活中的硬件设备
选择你拥有或使用的几台计算设备——比如笔记本电脑、智能手机或游戏机。回答以下关于每台设备的问题。你可以通过查看设备本身的设置来找到答案,或者可能需要在线进行一些研究。
-
该设备使用的是什么类型的 CPU?
-
CPU 是 32 位还是 64 位(或其他)?
-
CPU 的时钟频率是多少?
-
CPU 是否有 L1、L2 或 L3 缓存?如果有,是多少?
-
CPU 使用哪种指令集架构?
-
CPU 有多少个核心?
-
该设备有多少内存?是什么类型的主内存?
-
该设备拥有多少存储空间,是什么类型的二级存储?
-
该设备有哪些 I/O 设备?
总线通信
到目前为止,我们已经讲解了内存、CPU 和 I/O 设备在计算机中的作用。我们还讨论了 CPU 如何通过内存地址空间与内存和 I/O 设备通信。接下来,让我们更深入地看看 CPU 如何与内存和 I/O 设备进行通信。
总线是计算机组件之间用于硬件通信的系统。总线有多种实现方式,但在计算机早期,总线只是由一组并行电线组成,每根电线传输一个电信号。这使得多个数据位可以并行传输;每根电线上的电压代表一个单独的位。今天的总线设计不总是那么简单,但其目的依然相似。
在 CPU、内存和 I/O 设备之间的通信中,有三种常见的总线类型。地址总线作为 CPU 希望访问的内存地址的选择器。例如,如果程序希望写入地址 0x2FE,CPU 会将 0x2FE 写入地址总线。数据总线传输从内存读取的值或要写入内存的值。因此,如果 CPU 希望将值 25 写入内存,那么 25 将被写入数据总线。或者如果 CPU 正在从内存中读取数据,CPU 则从数据总线上读取该值。最后,控制总线管理其他两个总线上的操作。例如,CPU 使用控制总线来指示即将进行写入操作,或者控制总线可以传输指示操作状态的信号。图 7-15 展示了 CPU 如何使用地址总线、数据总线和控制总线来读取内存。

图 7-15:CPU 请求读取地址 3F4 处的值,返回的值是 84。
在图 7-15 中显示的示例中,CPU 需要读取存储在内存地址 000003F4 处的值。为此,CPU 将 000003F4 写入地址总线。CPU 还会在控制总线上设置一个特定的值,表示它希望执行读取操作。这些总线更新作为输入传递给内存控制器(管理与主内存交互的电路),告诉它 CPU 希望读取主内存中地址 000003F4 处存储的值。作为响应,内存控制器从地址 000003F4 处检索存储的值(在此示例中为 84),并将其写入数据总线,CPU 可以从数据总线上读取这个值。
总结
本章内容涵盖了计算机硬件:中央处理单元(CPU)用于执行指令,随机存取存储器(RAM)在通电时存储指令和数据,输入/输出(I/O)设备与外界交互。你学到了内存由单一比特的内存单元组成,这些单元在 SRAM 中通过一种触发器实现,在 DRAM 中通过晶体管和电容实现。我们讲解了内存寻址的工作原理,其中每个地址指向一个字节的内存。你了解了 CPU 架构,包括 x86 和 ARM 架构。我们探索了 CPU 内部的工作方式,查看了寄存器、算术逻辑单元(ALU)和控制单元。我们还讨论了二级存储和其他类型的 I/O,最后,我们讲解了总线通信。
在下一章中,我们将超越硬件,探讨使计算机在各种设备中独具特色的东西——软件。我们将研究处理器执行的低级指令,并且看到这些指令如何组合以执行有用的操作。你将有机会使用汇编语言编写软件,并使用调试器探索机器代码。
第八章:机器码和汇编语言**

我们已经讨论了计算机的硬件部分:CPU、主内存和 I/O 设备。理解计算机硬件很重要,但硬件只是故事的一半。计算机的魔力在于软件。正是软件将计算机从一个固定用途的设备变成了一个高度灵活的设备,能够轻松获得新功能!在这一章中,我们讨论低级软件——机器码和汇编语言。我发现这些主题最好通过互动的方式来理解,所以本章的主要内容是在项目中展示的。
软件术语定义
要讨论软件,我首先需要介绍几个术语。指示计算机做什么的指令称为软件;这与硬件(计算机的物理部件)相对。完成一项任务的有序软件指令集称为程序,而编程则是编写此类程序的行为。
应用程序这个术语有时与程序同义使用,尽管应用程序通常指的是直接与人类交互的程序,而不是与软件或硬件交互的程序。一个应用程序也可以由多个程序协同工作组成。应用一词大约在 2008 年开始广泛使用,并且带有其他含义,我将在第十三章中详细讨论。
软件指令集的另一种名称是计算机代码,简称代码。CPU 执行机器码,而软件开发人员通常使用高级编程语言编写源代码。源代码一词指的是开发人员最初编写的程序文本。这些代码通常不是以 CPU 直接理解的形式编写的,因此在计算机上运行之前必须采取额外步骤。我将在第九章中详细讨论源代码和高级编程语言,但现在让我们先来看一下软件的基础:机器码。
机器码是以二进制机器语言指令形式存在的软件。如第七章所述,CPU 的架构决定了该 CPU 理解哪些指令。就像人类语言是由词汇构成的,机器语言是由 CPU 家族已知的指令列表构成的。词汇单词按顺序排列成句子传达意义,CPU 指令按顺序排列成程序也能做到这一点。
无论程序最初是如何编写的(实际上有很多种编写程序的方法),它最终需要作为一系列机器语言指令在 CPU 上执行。正如你可能预料的那样,CPU 指令归结为一系列 0 和 1,就像计算机处理的其他所有内容一样。值得重复的是:无论程序最初是如何编写的,无论使用了什么编程语言,无论涉及了什么技术,最终,这个程序会变成一系列 0 和 1,代表 CPU 可以执行的指令。
几年前,我有一份工作,涉及诊断软件故障。通常,我分析的问题出现在由其他公司编写的软件中。我没有该软件的源代码,也没有关于该软件应如何工作的太多信息,但我的工作就是确定软件为什么会失败!我有一位同事,他对此泰然处之,并经常提醒我“这只是代码”。换句话说,故障软件只是一些 1 和 0,CPU 将其解释为指令。如果 CPU 能理解代码,你也能理解。
一个示例机器指令
我认为跳入机器代码话题最简单的方法是看一个例子。让我们看看 ARM 处理器家族理解的一个具体机器指令。正如你可能记得的那样,ARM 处理器广泛应用于大多数智能手机中,因此这条指令可能是你的手机能够理解的。
我们的示例指令告诉处理器将数字 4 移动到r7寄存器中,这是 ARM 处理器上的几个通用寄存器之一。回想一下我们之前讨论的计算机硬件,寄存器是 CPU 内部的小型存储位置。执行此操作的 ARM 指令在二进制中看起来是这样的:
11100011101000000111000000000100
让我们分析 ARM CPU 如何解读这条指令,如图 8-1 所示。请注意,我们略过了一些与我们讨论无关的位。

图 8-1:解码 ARM 指令
条件部分指定了指令应该在什么条件下执行。1110表示该指令没有条件,因此 CPU 应该始终执行它。虽然在这个例子中不是这样,但有些指令只在特定条件下才会执行。接下来的两位,示例中的00与我们讨论无关,因此我们将跳过它们。立即位告诉我们是否在访问寄存器中的值,或者访问指令本身指定的值(称为立即数)。在本例中,立即位是1,所以我们使用的是指令中指定的数字。如果立即位是0,应该访问的寄存器将通过指令中的其他位来指定。操作码表示 CPU 要执行的操作。在本例中,它是mov,表示 CPU 需要移动一些数据。0111的目标寄存器告诉我们要将值移动到寄存器r7中(0111是 7 的二进制表示)。最后,立即值00000100是十进制的 4,这是我们想要移动到r7寄存器中的数字。总而言之,这个二进制序列告诉 ARM CPU 将数字 4 移动到r7寄存器中。
CPU 始终以二进制处理所有内容,但大多数人对那些 0 和 1 感到难以理解。为了让它更容易阅读,我们用十六进制来表示相同的指令:
e3a07004
现在是不是更好一些了?嗯,可能不完全是。它比二进制更紧凑且更容易区分,但它的意义依然不那么明显。幸运的是,我们还有另一种表示这种指令的方法:汇编语言。汇编语言(或称汇编程序语言)是一种编程语言,每条语句直接代表一条机器语言指令。每种机器语言都有相应的汇编语言——例如 x86 汇编、ARM 汇编等。汇编语言语句由一个助记符组成,助记符代表 CPU 的操作码,以及任何所需的操作数(如寄存器或数字值)。助记符是操作码的可读形式,使得汇编语言程序员可以在代码中使用mov而不是1101。之前讨论的相同 ARM 指令,也可以通过以下汇编语言语句表示:
mov r7, #4
与相应的二进制和十六进制表示相比,这个表达方式无疑是更好的方式来表示“将 4 移入 r7 寄存器”!至少对于人类来说更容易阅读。话虽如此,记住汇编语言语句仅仅是为了方便人类。CPU 从不以文本格式执行指令,它只处理指令的二进制形式。如果程序员用汇编语言编写程序,那么汇编指令仍然必须转换为机器码,计算机才能运行该程序。这是通过使用汇编器来完成的,汇编器是一个将汇编语言语句翻译为机器码的程序。一个汇编语言文本文件被输入到汇编器中,输出则是一个包含机器码的二进制目标文件,如图 8-2 所示。

图 8-2:汇编器将汇编语言转换为机器码。
用机器码计算阶乘
现在我们已经研究了一个单一的 ARM 指令,让我们看看如何将多个指令组合在一起执行有用的任务。我们来看看一些计算整数阶乘的 ARM 机器码。正如你可能还记得的数学课上,n的阶乘(记作n!)是小于或等于n的所有正整数的乘积。所以举个例子,4 的阶乘是
4! = 4 × 3 × 2 × 1 = 24
既然我们已经有了阶乘的定义,接下来我们来看一下 ARM 机器码中阶乘计算的实现。为了简单起见,我们不会审视完整的程序代码,只看实现阶乘算法的部分。我们假设最初n的值存储在 r0 寄存器中,代码执行完毕时,计算结果也存储在 r0 寄存器中。
机器码和计算机处理的其他数据一样,必须先加载到内存中,CPU 才能访问它。以下是我们机器码的一个视图,以 32 位(4 字节)十六进制值表示,并附带每个值的内存地址。
Address Data
0001007c e2503001
00010080 da000002
00010084 e0000093
00010088 e2533001
0001008c 1afffffc
当我们的代码加载到内存中时,阶乘逻辑从地址0001007c开始。让我们检查从该地址开始的内存内容。请注意,0001007c并不是一个神奇的地址,它只是恰好是代码在这个示例中加载的地方。另外要注意的是,内存地址的值每次增加 4,因为每个数据值需要 4 字节的存储空间。每个 ARM 指令的长度为 4 字节,因此这些数据代表了五个 ARM 指令。
通过将这些指令看作十六进制值,我们无法深入理解它们的含义,因此让我们解码这些指令,以便理解这个程序。在接下来的列表中,我已经将十六进制数据值转换为对应的汇编语言助记符。顺便提一句,手动将机器语言翻译成汇编语言不是你需要掌握的技能!我们有一个叫做反汇编器的软件来做这件事。目前,这本书充当你的反汇编器。下面是每条指令及其汇编语句的配对:
Address Data Assembly
0001007c e2503001 subs r3, r0, #1
00010080 da000002 ble 0x10090
00010084 e0000093 mul r0, r3, r0
00010088 e2533001 subs r3, r3, #1
0001008c 1afffffc bne 0x10084
00010090 ---
CPU 按顺序执行这些指令,直到遇到分支指令(例如ble或bne),这可能会导致它跳转到程序的另一部分。地址00010090标记了阶乘逻辑的结束。一旦到达该地址,阶乘结果已经存储在r0中。此时,CPU 执行地址00010090处的任何指令。
你可能会想,这些指令是如何表示阶乘计算的。对于大多数人来说,粗略看一下这些指令不足以理解其背后的意图。采取逐步的方法并跟踪每条指令执行时寄存器的值,可以帮助你理解程序。我将提供一些必要的背景信息,然后你可以尝试评估这个程序是如何工作的。
要理解这个程序,你首先需要了解每条指令的描述。在表 8-1 中,我为你提供了该程序中每条指令的解释。在这个表格中,我使用了寄存器的占位符名称,如Rd和Rn。当你查看汇编代码时,你会看到实际的寄存器名称,如r0或r3。代码中列出的操作数顺序对应于表 8-1 中操作数的顺序。例如,subs r3, r0, #1表示从r0中减去 1,并将结果存储在r3中。
表 8-1: 一些 ARM 指令的解释
| 指令 | 详细信息 |
|---|---|
subs *Rd*, *Rn*, #Const |
减法从寄存器*Rn*中存储的值中减去常量值Const,并将结果存储在寄存器*Rd*中。换句话说,*Rd* = *Rn* – Const |
mul *Rd*, *Rn*, *Rm* |
乘法将寄存器*Rn*中存储的值与寄存器*Rm*中存储的值相乘,并将结果存储在寄存器*Rd*中。换句话说,*Rd* = *Rn* × *Rm* |
ble *Addr* |
小于或等于跳转如果上一个操作的结果小于或等于 0,则跳转到地址*Addr*处的指令。否则,继续执行下一条指令。 |
bne *Addr* |
不等跳转如果上一个操作的结果不为 0,则跳转到地址*Addr*处的指令。否则,继续执行下一条指令。 |
分支和状态寄存器
分支指令实际上并不查看上一条指令的数值结果。像大多数 CPU 一样,ARM 处理器有一个专门的寄存器来跟踪状态。这个状态寄存器有 32 位,每一位对应一个特定的状态标志。例如,第 31 位是N标志,当指令的结果为负数时,该标志被设置为 1。只有某些指令会影响这些标志的状态。例如,subs指令会改变标志的状态。如果某个减法操作的结果为负数,N标志被设置为 1;否则,它会被清除。其他指令,包括分支指令,会查看这些状态标志来决定接下来要做什么。这个方法看起来可能有些迂回,但实际上,它简化了像bne这样的指令——处理器可以根据单个位的值决定是否分支。
我们已经讲解完了这一主题的内容;本章的其余部分是一个练习和两个项目。在练习 8-1 中,你将使用在表 8-1 中找到的细节来逐步理解示例阶乘程序如何工作。
练习 8-1:用你的大脑当作 CPU
尝试在脑海中运行以下 ARM 汇编程序,或者使用纸笔进行演练:
Address Assembly
0001007c subs r3, r0, #1
00010080 ble 0x10090
00010084 mul r0, r3, r0
00010088 subs r3, r3, #1
0001008c bne 0x10084
00010090 ---
假设输入值n = 4 最初存储在r0中。当程序执行到00010090的指令时,说明代码已经执行完毕,r0应该是期望的输出值 24。我建议你在每条指令前后,跟踪r0和r3的值。逐步执行指令直到达到00010090,看看是否得到了期望的结果。如果一切正常,你应该会多次循环执行相同的指令;这是故意的。答案可以在附录 A 找到。
在纸上演练汇编语言是一个很好的开始,但在计算机上尝试汇编语言会更好。
注意
请参见项目 #12(第 145 页),你可以在其中组装阶乘代码并在运行时进行查看。同时,请参见项目 #13(第 155 页),在其中你可以学习一些额外的机器码分析方法。
总结
本章内容涵盖了机器码,它是以字节的形式存储在内存中的一系列特定于 CPU 的指令。你学习了一个 ARM 处理器指令如何被编码,并且看到了如何将该指令表示为汇编语言。你了解到汇编语言是一种源代码,具体来说,它是机器码的人类可读形式。我们看到了如何将多个汇编语言语句结合起来执行有用的操作。
在下一章,我们将讲解高级编程语言。此类语言提供了一种对 CPU 指令集的抽象,使得开发者可以编写更易理解并能跨不同计算机硬件平台移植的源代码。
项目 #12:汇编中的阶乘程序
前提条件:一台运行 Raspberry Pi OS 的树莓派。我建议你翻到 附录 B,阅读第 341 页 上的“树莓派”部分。这将帮助你设置树莓派并引导你使用 Raspberry Pi OS,包括如何处理文件,这在本章的项目中将大量使用。
在这个项目中,你将编写一个汇编语言的阶乘程序,类似于我们在本章之前讲解过的程序。然后你将检查生成的机器码。这个阶乘程序包括了一些额外的代码,超出了本章内容。具体来说,程序还从内存中读取 n 的初始值,将结果写回内存,并在最后将控制权交还给操作系统。
汇编指令与指令集
由于你包含了这段额外的代码,我提供了 表 8-2 来解释代码中使用的各种指令。你已经在 表 8-1 中看到了其中一些指令,但我这里把所有内容都列出来,方便查阅。
表 8-2: 在 项目 #12 中使用的 ARM 指令
| 指令 | 详细说明 |
|---|---|
ldr *Rd*, Addr |
从内存加载寄存器 读取地址 *Addr* 处的值,并将其放入寄存器 *Rd* 中。 |
str *Rd*, *Addr* |
将寄存器存储到内存 将寄存器 *Rd* 中的值写入地址 *Addr*。 |
mov *Rd*, #*Const* |
将常数移入寄存器 将常数值 *Const* 移动到寄存器 *Rd* 中。 |
svc |
发起系统调用 向操作系统请求服务。 |
subs *Rd*, *Rn*, #*Const* |
减法运算 从寄存器 *Rn* 中存储的值减去常数值 *Const*,并将结果存储到寄存器 *Rd* 中。换句话说,*Rd* = *Rn* – *Const* |
mul *Rd*, *Rn*, *Rm* |
乘法运算 将寄存器 *Rn* 和寄存器 *Rm* 中存储的值相乘,并将结果存储到寄存器 *Rd* 中。换句话说,*Rd* = *Rn* × *Rm* |
ble *Addr* |
小于或等于时跳转 如果前一操作的结果小于或等于 0,则跳转到地址 *Addr* 处的指令。否则,继续执行下一条指令。 |
bne *Addr* |
不相等时跳转 如果前一操作的结果不为 0,则跳转到地址 *Addr* 处的指令。否则,继续执行下一条指令。 |
在编写汇编语言代码时,开发人员还使用汇编指令。这些不是 ARM 指令,而是发送给汇编器的命令。这些指令以句点(.)开头,因此与指令区分开来。在以下代码中,您还会看到后面跟着冒号的文本——这些是标签,用于给内存地址命名。由于在编写代码时我们不知道指令在内存中的具体位置,因此我们通过标签而不是内存地址来引用内存位置。还需要注意的一点是,@符号表示它后面的文本(同一行内)是注释。我已经在程序中添加了注释以做解释,但如果您不需要,可以跳过输入这些注释。
输入并查看代码
背景信息已足够;毕竟这是一个项目!现在开始输入代码吧。使用您喜欢的文本编辑器在主文件夹的根目录中创建一个名为fac.s的新文件。Raspberry Pi 操作系统中使用文本编辑器的详细步骤可以在 Raspberry Pi 文档的“工作文件和文件夹”部分的第 346 页找到。将以下 ARM 汇编代码输入到您的文本编辑器中(您不必保留缩进和空行,但要确保保持换行符,尽管额外的换行符不会有坏处)。如果您还不完全理解这段代码也不用担心;我会在代码后面解释您需要了解的部分。
.global _start❶
.text❷
_start:❸
ldr r1, =n @ set r1 = address of n❹
ldr r0, [r1] @ set r0 = the value of n
subs r3, r0, #1 @ set r3 = r0 - 1
ble end @ jump to end if r3 <= 0
loop:
mul r0, r3, r0 @ set r0 = r3 x r0
subs r3, r3, #1 @ decrement r3
bne loop @ jump to loop if r3 > 0
end:
ldr r1, =result @ set r1 = address of result❺
str r0, [r1] @ store r0 at result
@ Exit the program
mov r0, #0❻
mov r7, #1
svc 0
.data❼
n: .word 5❽
result: .word 0
输入代码后,在文本编辑器中将其保存为fac.s,并存储在主文件夹的根目录中。让我们从指令和标签开始,逐步讲解这段代码。
如前所述,后跟冒号的文本,如_start:,是内存位置的标签❸。_start标签标记程序开始执行的地方。.global指令使得_start标签对链接器可见❶(我们稍后会讲到链接器),以便将其设置为程序的入口点。.text指令告诉汇编器接下来的行是指令❷。
在代码的末尾,.data指令告诉汇编器接下来的行是数据❼。在数据部分,程序存储了两个 32 位的值,每个值由.word指令❽指示。第一个是n的值,初始设为 5。第二个是result,初始设为 0。在此上下文中,“word”表示 4 个字节,或 32 位。
现在,让我们来看一下代码中除本章内容之外的功能性增加部分。我们现在有了加载n值到内存、将阶乘结果保存到内存并退出程序的代码。_start中的前两条指令从内存中的位置❹加载n的值。ldr指令将一个值加载到寄存器中。我们用=n引用n的地址。在下一行,[r1]用方括号括起来,因为程序正在访问存储在r1地址中的值。
紧接在 end 后的两条指令将结果保存到内存中的某个位置 ❺。第一条指令将名为 result 的内存位置的地址移动到 r1 寄存器中。之后,代码将 r0 寄存器中的值(即计算得到的阶乘)存储到 r1 所引用的 result 内存地址中。
.text 部分的最后三条指令用于干净地退出程序 ❻。这需要操作系统的帮助,因此在我们讲解操作系统相关内容之前,我会跳过这些指令的细节,直到我们在第十章中讲到操作系统。
汇编、链接并运行
现在你拥有的是一个包含汇编语言指令的文本文件,但这不是计算机可以运行的格式。你需要通过一个两步过程将汇编语言指令转换成机器代码字节。首先,你需要使用 汇编器 将指令转换(汇编)成机器代码字节。这个过程的结果是一个 目标文件,它包含程序的字节,但还不是最终可运行的格式。接下来,你需要使用一个名为 链接器 的程序将目标文件转换为操作系统可以运行的可执行文件。这个过程在图 8-3 中有说明。

图 8-3:汇编和链接生成可执行文件
你可能会想知道为什么需要这两步过程。如果你正在汇编多个源文件,并将它们组合成一个完整的程序,那么每个源文件会汇编成一个目标文件。链接器随后会将这些目标文件组合成一个可执行文件。这允许先前创建的目标文件根据需要进行链接。在这个例子中,你只有一个目标文件,链接器会简单地将其转换为可执行格式。
现在,汇编你的代码:
$ as -o fac.o fac.s
as 工具是 GNU 汇编器,它将你的汇编语言语句转换为机器代码。这个命令将生成的机器代码写入名为 fac.o 的文件,这是一个目标文件。如果你的 fac.s 文件没有以换行符结尾,汇编器可能会给出警告——你可以放心忽略这个警告。
一旦你的源代码被汇编成目标文件,你就需要使用 GNU 链接器(ld)将目标文件转换成可执行文件:
$ ld -o fac fac.o
这个命令以 fac.o 为输入,输出一个名为 fac 的可执行文件。到此为止,你可以通过以下命令运行你的程序:
$ ./fac
这个命令应该立即返回到下一行,且没有任何输出。这是因为你的程序实际上并没有在屏幕上显示任何文本。它只是计算了一个阶乘,将结果保存在内存中,然后退出。如果程序需要与用户互动,它将需要操作系统的帮助。然而,由于我们尽量保持程序的最简化,你无需做任何额外操作。
使用调试器加载程序
由于你的程序没有输出任何内容,如何知道它在做什么?你可以使用调试器,这是一种可以在程序运行时检查进程的工具。调试器可以附加到运行中的程序上并暂停其执行。程序暂停时,调试器可以检查目标进程的寄存器和内存。在这里,你使用 GNU 调试器gdb作为调试工具,而目标程序是fac。
要开始,只需执行以下命令:
$ gdb fac
当你运行这个命令时,gdb会加载fac文件,但尚未执行任何指令。在(gdb)提示符下,输入以下命令查看程序的起始地址:
(gdb) info files
你应该看到类似下面的一行,尽管具体地址可能不同:
Entry point: 0x10074
这告诉你程序的入口点地址是0x10074。记住,当你编写程序时,你并不知道将使用哪些内存地址,因此你使用了标签。现在程序已经构建并加载到内存中,你可以检查真实的内存地址。这个入口点地址对应于_start标签,因为程序就是从那里开始的。你现在可以使用gdb反汇编从程序入口点开始的机器码。反汇编是将机器码字节视为汇编语言指令的过程。以下命令使用0x10074作为起始地址;如果你的入口点不同,请使用那个地址。
(gdb) disas 0x10074
Dump of assembler code for function _start:
0x00010074 <+0>: ldr r1, [pc, #40] ; 0x100a4 <end+20>
0x00010078 <+4>: ldr r0, [r1]
0x0001007c <+8>: subs r3, r0, #1
0x00010080 <+12>: ble 0x10090 <end>
运行这个命令后,你应该看到前四条指令的反汇编,如下所示。默认情况下,gdb只会反汇编少量指令。这是一个好的开始,但最好能看到整个程序。为了做到这一点,你需要告诉gdb你想要查看的代码的结束地址。如果你回头看看之前输入到fac.s中的代码,你会看到程序一共有 12 条指令。每条指令占 4 个字节,所以程序的总长度应该是 48 个字节。这意味着程序应该在起始地址之后 48 个字节结束,所以结束地址应该是 0x00010074 + 48。你可以手动计算这个加法,也可以使用计算器程序,但由于你已经在gdb中,你可以要求它为你做这个运算并找到程序的结束地址(如果需要,替换0x10074为你的入口点地址):
(gdb) print/x 0x00010074 + 48
$1 = 0x100a4
print命令的输出一开始可能有些让人困惑。命令中的/x表示“以十六进制打印结果”。如果你查看输出,左边的值($1)是一个便利变量,它是gdb中的临时存储位置。将一个值保存在便利变量中是gdb让你以后方便地返回这个结果的方法。等号后的值是打印的结果,计算结果,在这个例子中是0x100a4。
现在你已经知道了结束地址(0x100a4),你可以要求gdb反汇编整个程序。注意,如果你的起始地址与我的不同,你需要在下面的命令中替换这两个地址。
(gdb) disas 0x10074,0x100a4
Dump of assembler code from 0x10074 to 0x100a4:
0x00010074 <_start+0>: ldr r1, [pc, #40] ; 0x100a4 <end+20>❶
0x00010078 <_start+4>: ldr r0, [r1]
0x0001007c <_start+8>: subs r3, r0, #1
0x00010080 <_start+12>: ble 0x10090 <end>
0x00010084 <loop+0>: mul r0, r3, r0
0x00010088 <loop+4>: subs r3, r3, #1
0x0001008c <loop+8>: bne 0x10084 <loop>
0x00010090 <end+0>: ldr r1, [pc, #16] ; 0x100a8 <end+24>❷
0x00010094 <end+4>: str r0, [r1]
0x00010098 <end+8>: mov r0, #0
0x0001009c <end+12>: mov r7, #1
0x000100a0 <end+16>: svc 0x00000000
这看起来很像你最初输入并汇编的fac.s,不过现在每条指令都被分配了一个地址,n和result的引用被替换成了相对于程序计数器寄存器的内存偏移(例如,[pc, #40] ❶)。程序计数器寄存器,或者称为指令指针,保存当前指令的内存地址。为了简单起见,我不深入讲解为什么在这里使用程序计数器的偏移量,但只要知道,0x10074 ❶ 和 0x10090 ❷ 处的指令分别将n和result的内存地址加载到r1寄存器中即可。
使用调试器断点运行并检查程序
现在你可以看到程序已加载到内存中,让我们看看程序是否按预期工作。为此,你将会在某些指令上设置断点,这样可以在该点检查程序的状态。断点告诉调试器当到达某个地址时暂停执行。设置在某个地址的断点会在执行相应指令之前立即暂停执行。在以下示例命令中,我使用的是我系统上的地址,但如果你的内存地址不同,请务必使用那些地址。
你将要设置以下断点:
0x10074 程序的起始位置。
0x1007c 阶乘逻辑的开始,距第一条指令 8 个字节。当程序到达此指令时,寄存器r0应为输入值n,该值在程序中被硬编码为 5。
0x10090 阶乘逻辑的结束,距第一条指令 0x1C 字节。当程序到达此指令时,寄存器r0应保存已计算的阶乘值。
0x100a0 程序的最终指令。当程序到达此指令时,标记为result的内存位置应保存阶乘结果。
按如下方式设置断点(如果你的起始地址不是0x10074,请调整地址):
(gdb) break *0x10074
(gdb) break *0x1007c
(gdb) break *0x10090
(gdb) break *0x100a0
现在开始运行程序:
(gdb) run
Starting program: /home/pi/fac
Breakpoint 1, 0x00010074 in _start ()
你应该看到像这样输出的内容,表示执行在第一个断点处停止。此时,程序已经准备好执行第一条指令,你可以查看当前的状态。首先,检查寄存器,实际上,当前唯一需要关心的寄存器是程序计数器(pc),因为我们需要确认当前指令的起始地址是 0x10074。现在,要求调试器显示pc寄存器的值:
(gdb) info register pc
pc 0x10074 0x10074 <_start>
这告诉你程序计数器指向了起始地址和第一个断点,正如预期的那样。确认当前指令的另一种方法是简单地反汇编当前代码,方法如下:
(gdb) disas
Dump of assembler code for function _start:
=> 0x00010074 <+0>: ldr r1, [pc, #40] ; 0x100a4 <end+20>
0x00010078 <+4>: ldr r0, [r1]
0x0001007c <+8>: subs r3, r0, #1
0x00010080 <+12>: ble 0x10090 <end>
注意=>符号表示当前指令。
现在你已经确认程序准备好执行其第一条指令,你可以检查两个标记的内存地址的当前值:n和result。这两个值应该分别为 5 和 0,因为这就是你在fac.s源代码中定义的初始值。你可以再次使用print命令查看这些值。执行时,你需要指定数据类型为int(32 位整数),这样print命令才知道如何显示这些值。
(gdb) print (int)n
$2 = 5
(gdb) p (int)result
$3 = 0
请注意,在第二个命令中,p被替代为print。gdb支持命令的简化版本;这可以节省你一些输入。如你所见,print命令使打印标记内存位置的值变得非常容易!
尽管打印标记内存位置的值很方便,但它也引出了一个问题:gdb中的print命令是如何知道你在原始fac.s文件中给这些内存位置命名的标签的呢?CPU 并不使用这些标签,它只使用内存地址。机器码也不通过名称引用这些内存位置。调试器能够做到这一点,是因为存储机器码的文件fac也包含了符号信息。这些调试符号告诉调试器有关某些命名内存位置的信息,例如n和result。通常,符号信息会在可执行文件分发给最终用户之前被移除,但符号信息仍然存在于你的fac可执行文件中。
记住,n和result只是内存位置的标签,那么如何找到这些变量的实际内存地址呢?一种方法是使用&运算符打印地址,这在gdb中表示“地址”。所以&n表示“n 的地址”。现在打印n的地址和result的地址。
(gdb) p &n
$4 = (<data variable, no debug info> *) 0x200ac
(gdb) p &result
$5 = (<data variable, no debug info> *) 0x200b0
这告诉你,n的值存储在地址0x200ac,result的值存储在地址0x200b0。请注意,这两个值在内存中是连续的,因为n和result的长度都是 4 字节。你可以使用x命令查看这些内存:
(gdb) x/2xw 0x200ac
0x200ac: 0x00000005 0x00000000
x/2xw命令表示检查两个连续的值,这些值以十六进制显示,每个值为“字”大小(4 字节),从地址0x200ac开始。所以在这里你可以看到n是 5,result是 0。这只是一种查看内存的不同方式,这次不使用命名标签。
回到程序—你现在已经确认了初始内存值已按预期设置。继续执行,直到下一个断点,在那里你可以验证r0已被设置为n的初始值。
(gdb) continue
Continuing.
Breakpoint 2, 0x0001007c in _start ()
(gdb) disas
Dump of assembler code for function _start:
0x00010074 <+0>: ldr r1, [pc, #40] ; 0x100a4 <end+20>
0x00010078 <+4>: ldr r0, [r1]
=> 0x0001007c <+8>: subs r3, r0, #1
0x00010080 <+12>: ble 0x10090 <end>
End of assembler dump.
(gdb) info registers r0
r0 0x5 5
从之前的输出可以看出,程序已经按预期向前推进到指令0x1007c,并且r0的值是预期的 5(n的值)。到目前为止,一切顺利。现在,继续前进到下一个断点,在那里r0的值应该是计算出的 5 的阶乘值,即 120。你可以将continue命令简化为c,将info registers命令简化为i r。
(gdb) c
Continuing.
Breakpoint 3, 0x00010090 in end ()
(gdb) disas
Dump of assembler code for function end:
=> 0x00010090 <+0>: ldr r1, [pc, #16] ; 0x100a8 <end+24>
0x00010094 <+4>: str r0, [r1]
0x00010098 <+8>: mov r0, #0
0x0001009c <+12>: mov r7, #1
0x000100a0 <+16>: svc 0x00000000
0x000100a4 <+20>: andeq r0, r2, r12, lsr #1
0x000100a8 <+24>: strheq r0, [r2], -r0 ; <UNPREDICTABLE>
End of assembler dump.
(gdb) i r r0
r0 0x78 120
一切看起来都很不错。请记住,此时阶乘输出还没有保存到result内存地址。现在验证一下result是否未改变:
(gdb) p (int)result
$6 = 0
虽然你暂时将阶乘输出存储在r0中,但它还没有写入内存。继续执行程序,直到最后一个断点,看看result内存位置是否已更新。
(gdb) c
Continuing.
Breakpoint 4, 0x000100a0 in end ()
(gdb) p (int)result
$7 = 120
你应该看到result的值为 120。如果是这样,做得不错,你的程序按预期工作!
破解程序以计算不同的阶乘
这个程序是硬编码的,计算的是 5 的阶乘。如果你想让它计算其他数字的阶乘怎么办?你可以修改硬编码的值,修改fac.s源代码,重新构建代码并再次运行。或者,你可以写一些代码,让用户在运行时输入所需的n值。但假设你现在无法访问源代码,并且只想在程序运行时快速修改其行为,将硬编码的n值替换为其他值,而不是 5。
首先,使用运行命令重新启动程序,并回答问题中的 y:
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/pi/fac
Breakpoint 1, 0x00010074 in _start ()
现在你回到了程序的起始位置,在断点 1 处。你可以编辑内存中的n值,将它设置为 7 而不是 5。首先,获取n的内存地址,然后将该地址的值设置为 7。接下来,你可以打印出n以确保修改生效。
(gdb) p &n
$8 = (<data variable, no debug info> *) 0x200ac
(gdb) set {int}0x200ac = 7
(gdb) p (int)n
$9 = 7
现在,去程序的结尾,看看result是否更新为预期的 7 的阶乘值,即 5,040。你可以去掉中间的两个断点(2 号和 3 号),因为你想直接跳到最后:
(gdb) disable 2
(gdb) disable 3
(gdb) c
Continuing.
Breakpoint 4, 0x000100a0 in end ()
(gdb) p (int)result
$10 = 5040
你应该看到result的值为 5,040。如果是这样,你刚刚成功地破解了一个程序,让它按照你的意图运行——完全不需要触碰源代码!
现在你可能想尝试将n设置为其他值,看看是否得到预期的结果。为此,使用运行命令重新启动程序,编辑内存中的n值,继续到最后的断点,并检查result的值。然而,如果你使用大于 12 的n值,将会得到不正确的结果。有关原因,请参见练习 8-1 的答案,详见附录 A。
如果你允许程序continue到达结尾,进程将退出,并且你应该看到类似Inferior 1 (process 946) exited normally的信息。这并不是对你代码的侮辱,而是gdb用来指代被调试目标的术语——“inferior”!你可以随时通过在gdb中输入quit来退出调试器。项目 #13:检查机器代码
前提:参见项目 #12。
假设你得到了fac可执行文件,但没有原始的汇编语言源文件。你想知道程序的功能,但没有源代码。正如你在项目 #12 中看到的,你可以使用gdb调试器来检查fac可执行文件。在本项目中,我将向你展示一组用于检查机器码的不同工具。
打开你树莓派上的终端。默认情况下,终端应该打开到主文件夹,以~字符表示。在这个文件夹中,你应该有来自上一个项目的三个与阶乘相关的文件。可以通过以下命令检查:
$ ls fac*
你应该看到
fac 可执行文件
fac.o 汇编过程中生成的目标文件
fac.s 汇编语言源代码
在我们设想的场景中,你只有可执行的fac文件,并且想要了解从这个文件的内容中可以学到什么。首先,通过使用hexdump工具查看文件中包含的字节,以十六进制值的形式显示:
$ hexdump -C fac
hexdump的输出开始应该类似于图 8-4(没有注释),显示的是fac可执行文件中的字节。

图 8-4:Linux 可执行文件的十六进制转储
你看到的只是文件字节的顺序列出,每个字节以两个字符的十六进制值显示。如果这个命令的输出太大,无法适应你的终端窗口,可以向上滚动查看起始字节。左侧一列的八个字符的十六进制数字表示每行第一个字节在文件中的偏移量。每行有 16 个字节,这意味着每行的偏移量(左侧)增加 0x10。在输出的右侧是相同的字节,以 ASCII 字符的形式表示。无法对应打印 ASCII 字符代码的字节用句点表示。
在偏移量00000000处,即文件的开头,你应该看到7F,接着是45 4c 46,或者用 ASCII 表示为ELF。这是一个指示符,表示这是一个可执行与可链接格式(ELF)的文件。ELF 文件是 Linux 中用于可执行程序的标准格式。这 4 个字节标记了ELF 头部的开始,头部包含描述文件内容的属性。紧随 ELF 头部的是程序头部,提供操作系统运行程序所需的详细信息。
现在跳过头部,找到包含程序机器指令的文本部分。在我的系统中,偏移量00000074是文本部分的开始,它以字节28 10 9f e5开始。如果你将这些字节按最后一个到第一个的顺序重新排列,你会得到e59f1028,这就是ldr r1, [pc, #40]的机器码指令。该部分中的每 4 个字节都是一条机器指令。从这个角度看程序是一个很好的提醒,fac程序的代码只是以字节序列的形式表示。参见图 8-1 以了解机器码如何以二进制表示。
在输出的后面,在我的系统中,偏移量为000000ac的地方,你应该能够看到文件的数据部分,其中包含程序定义的两个初始 4 字节值。你在这里看不到n和result标签,但你应该能看到05 00 00 00和00 00 00 00。这些字节在你系统中的偏移量可能与我的不同。
顺便提一下,计算机存储更大数值数据字节的顺序被称为字节序(endianness)。当计算机首先存储最低有效字节(在最低地址处),这叫做小端(little-endian)。如果首先存储的是最高有效字节,那就叫做大端(big-endian)。在hexdump输出中,你看到的是小端存储,因为 32 位机器指令e59f1028是按这个顺序存储为字节的:28 10 9f e5。最低有效字节先存储。n和result的值也是如此存储。n的值存储为05 00 00 00,在作为 32 位整数时,它表示00000005。
如果你想查看这些十六进制数据的某些部分,但按部分分组,你可以使用objdump工具:
$ objdump -s fac
这会以分组形式显示一些与之前相同的字节,像这样:
Contents of section .text:
10074 28109fe5 000091e5 013050e2 020000da (........0P.....
10084 930000e0 013053e2 fcffff1a 10109fe5 .....0S.........
10094 000081e5 0000a0e3 0170a0e3 000000ef .........p......
100a4 ac000200 b0000200 ........
Contents of section .data:
200ac 05000000 00000000 ........
Contents of section .ARM.attributes:
0000 41130000 00616561 62690001 09000000 A....aeabi......
0010 06010801 ....
注意左侧的数字是如何变化的。.text部分(即代码)不再从0074开始,而是从10074开始。.data部分(包含n和result的值)不再从00ac开始,而是从200ac开始。hexdump工具仅显示文件中的字节偏移量,而objdump输出则表示程序运行时字节加载到内存中的地址。查看 ELF 可执行文件中各个部分地址的另一种方式是使用readelf -e fac。该命令显示文件中的头部信息。
现在你可以尝试objdump的另一个功能,反汇编机器码,这样你就能看到汇编语言指令和机器码字节值并排显示。
$ objdump -d fac
fac: file format elf32-littlearm
Disassembly of section .text:
00010074 <_start>:
10074: e59f1028 ldr r1, [pc, #40] ; 100a4 <end+0x14>❶
10078: e5910000 ldr r0, [r1]
1007c: e2503001 subs r3, r0, #1
10080: da000002 ble 10090 <end>
00010084 <loop>:
10084: e0000093 mul r0, r3, r0
10088: e2533001 subs r3, r3, #1
1008c: 1afffffc bne 10084 <loop>
00010090 <end>:
10090: e59f1010 ldr r1, [pc, #16] ; 100a8 <end+0x18>
10094: e5810000 str r0, [r1]
10098: e3a00000 mov r0, #0
1009c: e3a07001 mov r7, #1
100a0: ef000000 svc 0x00000000
100a4: 000200ac .word 0x000200ac
100a8: 000200b0 .word 0x000200b0
你应该看到类似于这里展示的输出。请注意,地址10074 ❶处的指令与图 8-4 中高亮显示的字节序列相同,即机器代码的前 4 个字节。这个输出与上一项目中来自gdb的输出非常相似。考虑一下这意味着什么:使用像gdb或objdump这样的工具,你可以轻松查看任何可执行文件的机器代码和相应的汇编语言!
使用我在前几页中描述的技术,你可以查看 ELF 可执行文件的内容。这适用于 Linux 系统上的任何标准 ELF 文件,而不仅仅是你自己写的代码。你可以随意探索计算机上任何 ELF 文件的机器代码。例如,假设你想查看ls的机器代码——就是你之前用来列出目录内容的工具。首先,你需要找到ls ELF 文件的文件系统位置,如下所示:
$ whereis ls
ls: /bin/ls /usr/share/man/man1/ls.1.gz
这告诉我们ls的二进制可执行文件位于/bin/ls(你可以忽略返回的其他结果)。现在你可以运行objdump(或已经讲解过的其他工具)来查看ls的机器代码:
$ objdump -d /bin/ls > ls.txt
这个命令的输出相当长,因此它被重定向到一个名为ls.txt的文件中。你在终端窗口中看不到反汇编的代码;它被写入到ls.txt文件中,你可以使用你选择的文本编辑器查看。 当然,由于 Linux 是开源的,你可以在线查看ls工具的源代码。然而,并不是所有的内容都是开源的,这个项目应该能让你了解如何查看任何 Linux 可执行程序的反汇编代码。
第九章:高级编程**

在上一章中,我们学习了软件的基础:运行在处理器上的机器代码,以及汇编语言,它是机器代码的人类可读表示。虽然最终所有软件都必须以机器代码的形式存在,但大多数软件开发人员在更高、更抽象的层次上工作。本章将介绍高级编程。我们将概述高级编程,讨论各种编程语言中常见的元素,并查看一些示例程序。
高级编程概述
尽管可以用汇编语言(甚至机器代码!)编写软件,但这样做既耗时又容易出错,而且结果是难以维护的软件。此外,汇编语言特定于某个 CPU 架构,因此如果汇编开发人员希望在另一种类型的 CPU 上运行程序,代码必须重写。为了克服这些不足,高级编程语言应运而生;它们允许程序以不依赖于特定 CPU 的语言编写,并且语法上更接近人类语言。这些语言中的许多需要一个编译器,即将高级程序语句转换为特定处理器的机器代码的程序。使用高级语言,软件开发人员可以编写一次程序,然后将其编译为多种类型的处理器,有时源代码几乎不需要改变或根本不需要改变。
编译器的输出是一个目标文件,包含特定处理器的机器代码。正如我们在项目 #12 中介绍的那样,目标文件并不是计算机可以执行的正确格式。另一个程序,称为链接器,用于将一个或多个目标文件转换为可执行文件,操作系统可以运行该文件。链接器还可以在需要时引入其他已编译的代码库。编译和链接的过程在图 9-1 中进行了说明。

图 9-1:从源代码构建可执行软件
编译和链接的过程被称为构建软件。然而,在日常使用中,软件开发人员有时会说他们在编译代码,但实际上是指整个编译、链接以及将代码转换为最终形式所需的其他步骤。编译器通常会自动调用链接步骤,使其对软件开发人员来说不太可见。
C 和 Python 简介
学习高级编程的最佳方式是研究编程语言并用这些语言编写一些程序。在本章中,我选择了两种高级语言:C 和 Python。它们都是功能强大且实用的,并且能够展示编程语言如何以不同的方式提供类似的功能。我们首先简要介绍每种语言。
C 编程语言起源于 1970 年代初期,当时用于编写 Unix 操作系统的一个版本。尽管 C 是一种高级语言,但它与底层机器代码之间的距离并不遥远,这使得它成为操作系统开发或其他与硬件直接交互的软件的理想选择。80 年代时,C 的更新版本 C++ 出现了。C 和 C++ 是功能强大的语言,几乎可以完成任何任务。然而,这些语言也很复杂,并且没有为程序员错误提供太多的保护措施。它们仍然是需要与硬件交互或者对性能要求高的软件(如游戏)的流行选择。C 也因其提供了从低级到高级概念的直观映射,在教育中非常有用,这也是我在本章选择它的原因。
与 C 语言相比,Python 编程语言与底层硬件的距离更远。Python 最初于 1990 年代发布,随着时间的推移,它的流行程度逐渐上升。它因易于阅读和对初学者友好而著称,同时仍提供了支持复杂软件项目所需的一切功能。Python 有着“自带电池”的理念,这意味着标准版 Python 包含了一系列开发者可以轻松使用的库,帮助他们在项目中快速开发。Python 的简洁特性使其成为教授编程概念的良好选择。
现在,让我们来看一下大多数高级编程语言中的一些元素。目标不是教你使用某一特定语言编程,而是让你熟悉编程语言中常见的思想。记住,高级编程语言中的功能是 CPU 指令的抽象。正如你所知道的,CPU 提供了内存访问、数学和逻辑运算、程序流控制等指令。我们来看看高级语言是如何暴露这些底层能力的。
注释
让我们从编程语言的一个特点开始,这个特点实际上并不会指示 CPU 执行任何操作!几乎所有的编程语言都提供了一种在代码中加入注释的方式。注释是源代码中的文本,用于提供对代码的某些解释。注释是供其他开发者阅读的,通常会被编译器忽略;它们对编译后的软件没有任何影响。在 C 语言中,注释的书写方式如下:
/*
This is a C-style comment.
It can span multiple lines.
*/
// This is a single-line C comment, originally introduced in C++.
Python 使用井号字符(#)来表示注释,像这样:
# This is a comment in Python.
Python 并未提供对多行注释的特殊支持;程序员可以简单地使用多个单行注释,一行接一行。
变量
内存访问是处理器的基本功能,因此它必须是高级语言的一项特性。编程语言暴露内存的最基本方式就是通过变量。变量是内存中一个命名的存储位置。变量允许程序员为内存地址(或一段内存地址范围)指定一个名称,然后可以访问该地址上的数据。在大多数编程语言中,变量都有一个类型,表示它们保存的数据类型。例如,一个变量可以是整数类型或文本字符串类型。变量还具有值,即存储在内存中的数据。尽管这通常对程序员是隐藏的,但变量也有一个地址,即存储变量值的内存位置。最后,变量具有作用域,意味着它们只能从程序的某些部分访问,通常是它们“有效”的部分。
C 语言中的变量
让我们看一个 C 语言中变量的例子。
// Declare a variable and assign it a value in C.
int points = 27;
这段代码声明了一个名为 points 的变量,类型为 int,在 C 语言中,这意味着该变量保存一个整数。然后该变量被赋值为 27。当这段代码运行时,27(十进制)的值将被存储在一个内存地址中,但开发者不需要担心该变量被存储的具体地址。如今,大多数 C 语言编译器将 int 视为 32 位数值,因此在运行时(程序执行时),为该变量分配 4 字节内存(4 字节 × 每字节 8 位 = 32 位),并且该变量的内存地址指向第一个字节。
现在我们来声明第二个变量并赋值;然后我们可以查看这两个变量是如何在内存中分配的。
// Two variables in C
int points = 27;
int year = 2020;
现在我们有两个变量,points 和 year,它们一个接一个地声明。两个变量都是整数类型,因此每个变量需要 4 字节的存储空间。变量可以按照表 9-1 所示存储在内存中。
表 9-1: 存储在内存中的变量
| 地址 | 变量名称 | 变量值 |
|---|---|---|
0x7efff1cc |
? |
? |
0x7efff1d0 |
year |
2020 |
0x7efff1d4 |
points |
27 |
0x7efff1d8 |
? |
? |
在表 9-1 中使用的内存地址只是示例;实际地址会根据硬件、操作系统、编译器等不同因素而有所不同。请注意,地址是按四字节递增的,因为我们存储的是 4 字节的整数。已知变量之前和之后的地址,其变量名称和值用问号表示,因为根据前面的代码,我们无法知道这些位置可能存储的内容。
注意
请参见项目 #14,位于第 184 页,在那里你可以查看内存中的变量。
正如变量一词所暗示的,变量的值是可以改变的。如果我们之前的 C 程序需要将 points 的值设置为另一个值,我们可以在程序的后续部分简单地实现这一点:
// Setting a new points value in C
points = 31;
请注意,与我们之前的 C 代码示例不同,这段代码在变量名前并没有指定 int 或其他类型。我们只需要在变量首次声明时指定类型。在这种情况下,变量已经提前声明过,因此这里只是给它赋值。然而,C 语言要求变量的类型保持不变,所以一旦 points 被声明为 int,只能给该变量赋值整数类型。如果尝试赋值其他类型,比如文本字符串,则在编译代码时会失败。
Python 中的变量
并不是所有语言都要求声明类型。例如,Python 允许像这样声明并赋值一个变量:
# Python allows new variables without specifying a type.
age = 22
现在,在这种情况下,Python 识别到数据的类型是整数,但程序员并不需要指定这一点。与 C 不同,变量的类型可以随着时间变化,因此在 Python 中,以下代码是有效的:
# Assiging a variable a value of a different type is valid in Python.
age = 22
age = 'twenty-two'
让我们更详细地了解一下这个示例中实际发生了什么。一个 Python 变量没有类型,但它所引用的值有类型。这是一个重要的区别:类型是与值关联的,而不是与变量关联的。一个 Python 变量可以引用任何类型的值。因此,当给变量赋予新值时,实际上并不是变量的类型发生了变化,而是变量被绑定到了一个不同类型的值上。与此对比,在 C 中,变量本身具有类型,并且只能保存该类型的值。这一差异解释了为什么 Python 中的变量可以赋值不同类型的值,而 C 中的变量则不能。
注意
请参见 项目 #15 和 第 186 页,在那里你可以更改 Python 中变量引用的值的类型。
栈内存和堆内存
当程序员使用高级语言访问内存时,内存的管理细节会有所隐藏,这取决于所使用的编程语言。例如,像 Python 这样的编程语言使内存分配的细节几乎对程序员不可见,而像 C 这样的语言则暴露了一些底层的内存管理机制。无论这些细节是否对程序员可见,程序通常都会使用两种类型的内存:栈内存和堆内存。
栈内存
栈是一个按照后进先出(LIFO)模型工作的内存区域。也就是说,最后放入栈中的项是第一个被移除的项。你可以把内存栈想象成一叠盘子。当你向栈中添加新的盘子时,你把最新的盘子放到最上面。当你需要从栈中取出盘子时,你会首先取出最上面的盘子。这并不意味着栈中的项只能按 LIFO 顺序被访问(读取或修改)。实际上,栈中任何当前的项都可以随时被读取或修改。然而,在需要移除不再需要的项时,项会从栈顶向下被丢弃,意味着最后放入栈中的项会首先被移除。
栈顶值的内存地址存储在一个称为栈指针的处理器寄存器中。当一个值被添加到栈顶时,栈指针的值会被调整,以增加栈的大小并为新值腾出空间。当一个值从栈顶被移除时,栈指针会被调整,以减小栈的大小。
编译器生成的代码利用栈来跟踪程序执行的状态,并作为存储局部变量的地方。这些机制对于使用高级语言的程序员来说是透明的。图 9-2 展示了 C 程序如何利用栈存储我们在表 9-1 中提到的两个局部变量。

图 9-2:栈内存用于存储用 C 语言编写的程序中的变量值。
在图 9-2 中,points变量首先被声明,并被赋值为27,这个值存储在栈上。接下来,year变量被声明并赋值为2020。第二个值被放置在栈上第一个值的“上方”。更多的值将继续被添加到栈的顶部,直到不再需要为止,这时它们将从栈中移除。请记住,图中的每个槽只是内存中的一个位置,并分配有内存地址,尽管图中没有显示这些地址。你可能会感到惊讶,许多架构中,栈分配的内存地址实际上是递减的。以这个例子来说,意味着year变量的内存地址低于points变量的地址。
栈内存速度快,非常适合于具有有限作用范围的小内存分配。每个执行线程在程序中都会有一个单独的栈。我们将在第十章中更详细地讨论线程,但现在你可以把线程看作是程序中的并行任务。栈是一种有限的资源;分配给栈的内存是有上限的。如果将太多的值放入栈中,会导致一种叫做栈溢出的错误。
堆
堆栈用于存储只需要临时使用的小型值。对于需要更大内存或需要长期存在的内存分配,堆更为合适。堆是程序可用的一块内存池。与堆栈不同,堆内存并不采用先进后出(LIFO)模型;堆内存的分配没有标准模型。而堆栈内存特定于某个线程,来自堆的内存分配可以被程序的任何线程访问。
程序从堆中分配内存,且该内存的使用会持续到程序释放或程序终止。释放内存分配意味着将其归还给可用内存池。当某个分配不再被引用时,一些编程语言会自动释放堆内存;这种方法通常被称为垃圾回收。其他编程语言,如 C,则要求程序员编写代码来释放堆内存。如果未释放未使用的内存,就会发生内存泄漏。
在 C 编程语言中,有一种特殊的变量叫做指针,用于追踪内存分配。指针实际上是一个存储内存地址的变量。指针值(即内存地址)可以存储在堆栈上的局部变量中,而该值可以指向堆中的某个位置,正如图 9-3 所示。

图 9-3:名为data的指针变量位于堆栈上,并指向堆中的某个地址。
在图 9-3 中,我们有一段声明了名为data的变量的代码。该变量类型为void *,这意味着它是一个指针(由*表示),指向一个可以存储任何类型数据的内存地址(void表示类型未指定)。因为data是一个局部变量,它被分配在堆栈上。接下来的代码调用了malloc,这是 C 语言中一个从堆中分配内存的函数。程序请求分配 512 字节的内存,malloc函数返回新分配内存的第一个字节的地址。这个地址存储在堆栈上的data变量中。最终,我们在堆栈上的某个地址处得到了一个局部变量,这个变量存储着堆内存分配的地址。
注意
请参见项目#16(在第 187 页),你可以亲自查看程序运行时变量是如何分配的。
数学
由于处理器提供了执行数学运算的指令,高级语言也提供了相应的指令。与汇编语言编程不同,汇编语言中需要针对数学运算指定具体的命名指令(例如在 ARM 处理器上使用subs指令进行减法),高级语言通常使用符号来表示常见的数学运算,这使得在代码中进行数学运算变得非常简单。许多编程语言,包括 C 语言和 Python,使用相同的运算符表示加法、减法、乘法和除法,具体如表 9-2 所示。
表 9-2: 常见数学运算符
| 操作 | 运算符 |
|---|---|
| 加法 | + |
| 减法 | - |
| 乘法 | * |
| 除法 | / |
另一个在多种编程语言中常见的约定是使用等号(=)表示赋值,而不是表示相等。也就是说,像x = 5这样的语句意味着将 x 的值设置为 5。将数学运算的结果赋给一个变量的表示方式是自然的,比如在这些语句中:
// Addition is easy in C.
cost = price + tax;
# Addition is easy in Python too.
cost = price + tax
到目前为止,我们关注的是在计算中常见的整数运算。然而,计算机和高级语言也支持一种叫做浮点运算的数学运算。与表示整数的整数不同,浮点值可以表示小数。一些编程语言隐藏了这部分细节,但在内部,CPU 使用与整数运算不同的指令来执行浮点运算。在 C 语言中,浮点变量通过浮点类型声明,如float或double,如下所示:
// Declaring a floating-point variable in C
double price = 1.99;
另一方面,Python 会推断变量类型,因此整数和浮点值的声明方式是相同的:
# Declaring integer and floating-point varibles in Python
year = 2020 # year is an int
price = 1.99 # price is a float
整数和浮点数之间的差异有时会导致意外的结果。例如,假设你有以下的 C 语言代码:
// Dividing integers in C
int x = 5;
int y = 2;
int z = x / y;
你期望z的值是多少?结果发现,由于所有参与运算的数值都是整数,z最终的值是2。不是2.5,而是2。作为整数,z不能保存小数值。
现在如果我们稍微改变一下代码,像这样:
// Dividing integers in C, result stored in a float
int x = 5;
int y = 2;
float z = x / y;
请注意,z现在是float类型。现在你期望z的值是多少?有趣的是,z现在等于2.0,它依然不是2.5!这是因为除法运算发生在两个整数之间,所以结果也是一个整数。除法的结果是2,当这个值赋给浮点类型的变量z时,它被赋值为2.0。C 语言是非常字面化的;它被编译成紧密反映程序员指令的机器码。这对于需要精确控制处理过程的程序员来说是很棒的,但对于那些希望编程语言行为更直观的程序员来说,则不总是那么理想。
Python 尝试更智能地处理,自动分配一个允许小数结果的类型,在这种情况下。如果我们在 Python 中写出等效的代码,存储在 z 中的结果将是 2.5。
# Dividing integers in Python
# z will be 2.5 and its inferred type is float
x = 5
y = 2
z = x / y
一些编程语言提供了数学运算符,这些运算符是表示操作的一种简洁方式。例如,C 语言提供了增量(加 1)和减量(减 1)运算符,如下所示:
// In C, we can add one to a variable the long way,
x = x + 1;
// or we can use this shortcut to increment x.
x++;
// On the other hand, this will decrement x.
x--;
注意
有趣的事实:编程语言 C++ 的名称意味着它是在 C 语言的基础上进行改进或增量的语言。
Python 还提供了一些数学运算的快捷方式。+= 和 -= 运算符允许程序员对变量进行加法或减法。例如:
# In Python, we can add 3 to a variable like this...
cats = cats + 3
# Or we can do the same thing with this shortcut...
cats += 3
+= 和 -= 运算符在 C 语言中也同样适用。
逻辑
如前所述,处理器非常擅长执行逻辑运算,因为逻辑是数字电路的基础。正如你所预期的那样,编程语言也提供了处理逻辑的能力。大多数高级语言提供了两种操作逻辑的运算符:位运算符,作用于整数的比特;布尔运算符,作用于布尔(真/假)值。这里的术语可能会让人困惑,因为不同的编程语言使用不同的术语。Python 使用“位运算”和“布尔”,而 C 语言使用“位运算”和“逻辑”,其他语言则使用其他术语。在这里我们坚持使用“位运算”和“布尔”。
位运算符
位运算符 作用于整数值的单个比特,并返回一个整数值。位运算符就像数学运算符,但它不是进行加法或减法,而是在整数的比特上执行与(AND)、或(OR)或其他逻辑操作。这些运算符依据第二章中讨论的真值表工作,平行地对整数的所有比特执行操作。
许多编程语言,包括 C 和 Python,使用表 9-3 中显示的运算符集合来进行位运算。
表 9-3: 编程语言中常见的位运算
| 位运算 | 位运算符 |
|---|---|
| 与 | & |
| 或 | | |
| 异或 | ^ |
| 非(补码) | ~ |
让我们来看一个 Python 中的位运算示例。
# Python does bitwise logic.
x = 5
y = 3
a = x & y
b = x | y
上述代码的结果是 a 为 1,b 为 7。让我们通过二进制(图 9-4)来看这些操作,以便清楚地了解为什么会是这样。

图 9-4:5 和 3 的位与、位或运算
首先查看图 9-4 中的与运算;回想一下第二章中提到的与运算规则,当两个输入都为 1 时,结果为 1。我们逐列查看这些位,如你所见,只有最右侧的位在两个输入中都是 1。因此,与运算的结果是 0001 二进制,或 1 十进制。因此,在之前的代码中,a 被赋值为 1。
另一方面,OR 表示如果任一输入(或两个输入中的任何一个)为 1,则结果为 1。在这个例子中,右边三个二进制位中有一个输入或另一个输入的位都为 1,所以结果是二进制 0111,或十进制 7。因此,b 在前面的代码中被赋值为 7。
练习 9-1:位运算符
请考虑以下 Python 语句。这段代码执行后,a、b 和 c 的值会是什么?
x = 11
y = 5
a = x & y
b = x | y
c = x ^ y
答案可以在 附录 A 找到。
布尔运算符
高级编程语言中的另一类逻辑运算符是 布尔运算符。这些运算符作用于布尔值并返回布尔值。
让我们花一点时间讨论布尔值。布尔值 只能是真或假。不同的编程语言以不同的方式表示真或假。布尔变量 是一个命名的内存地址,它保存一个布尔值,即真或假。例如,我们可以在 Python 中定义一个变量来追踪某个物品是否在销售:item_on_sale = True。
表达式可以在不将结果存储到变量中的情况下评估为真或假。例如,表达式 item_cost > 5 在运行时根据 item_cost 变量的值评估为真或假。
布尔运算符允许我们对布尔值执行逻辑操作,例如 AND、OR 或 NOT。例如,我们可以使用 Python 的布尔 AND 运算符检查两个条件是否都为真:item_on_sale 和 item_cost > 5。and 左右两边的表达式会评估为布尔值,整个表达式的结果也将是布尔值。在这里,C 和 Python 使用不同的运算符,如 表 9-4 所示。
表 9-4: C 和 Python 编程语言中的布尔运算符
| 布尔运算 | C 运算符 | Python 运算符 |
|---|---|---|
| 与 | && |
and |
| 或 | || |
or |
| 非 | ! |
not |
在我们讨论返回布尔值的运算符时,比较运算符 会比较两个值,并根据比较结果评估为真或假。例如,大于运算符 允许我们比较两个数字并确定其中一个是否大于另一个。表 9-5 显示了 C 和 Python 中使用的比较运算符。
表 9-5: C 和 Python 编程语言中的比较运算符
| 比较操作 | 比较运算符 |
|---|---|
| 等于 | == |
| 不等于 | != |
| 大于 | > |
| 小于 | < |
| 大于或等于 | >= |
| 小于或等于 | <= |
你已经看到过其中一个例子,我们之前提到的item_cost > 5。请注意相等运算符。C 语言和 Python 都使用双等号表示相等比较,使用单等号表示赋值。这意味着x == 5是一个比较,返回true或false(x是否等于5?),而x = 5是一个赋值,将x的值设为5。
程序流程
布尔运算符和比较运算符让我们能够评估表达式的真假,但仅此并不十分有用。我们需要一种方式来响应这些评估结果!程序流程,或称控制流语句,使我们能够做出响应,在某些条件下改变程序的行为。让我们来看看不同编程语言中常见的程序流构造。
If 语句
if 语句,通常与else 语句一起使用,允许程序员在某个条件为真时执行某些操作。反过来,else语句允许程序在条件为假时执行不同的操作。以下是 Python 中的一个例子:
# Age check in Python
❶ if age < 18:
❷ print('You are a youngster!')
❸ else:
❹ print('You are an adult.')
在这个例子中,第一个if语句 ❶ 检查age变量是否小于 18。如果是,它会打印一条信息,表示用户很年轻 ❷。else语句 ❸ 告诉程序,如果age大于或等于 18,则打印另一条消息 ❹。
这里是相同的“年龄检查”逻辑,这次用 C 语言编写:
// Age check in C
if (age < 18)
❶ {
printf("You are a youngster!");
❷ }
else
{
printf("You are an adult.");
}
在 C 语言示例中,注意在if语句后使用的大括号 ❶❷。这些大括号标记了一段应该在if语句响应下执行的代码块。在 C 语言中,代码块可以由多行代码组成,尽管在代码块只有一行时,可以省略大括号。Python 不使用大括号来限制代码块,而是使用缩进。在 Python 中,连续在同一缩进级别(比如四个空格)的行被视为同一代码块的一部分。
Python 还包含了elif语句,表示“else if”。只有在前面的if或elif语句为假时,elif语句才会被评估。
# A better age check in Python
if age < 13:
print('You are a youngster!')
elif age < 20:
print('You are a teenager.')
else:
print('You are older than a teen.')
在 C 语言中,使用else和if结合的方式可以完成相同的功能:
// A better age check in C
if (age < 13)
printf("You are a youngster!");
else if (age < 20)
printf("You are a teenager!");
else
printf("You are older than a teen.");
请注意,我也省略了大括号,因为我的所有代码块都是单行的。
循环
有时候,程序需要反复执行某个操作。while 循环允许代码重复运行,直到某个条件满足。在下面的 Python 示例中,使用while循环打印从 1 到 20 的数字。
# Count to 20 in Python.
n = 1
while n <= 20:
print(n)
n = n + 1
最初,变量n被设置为1。while循环开始,表示循环应该在n小于或等于20时运行。由于n是1,它满足条件,因此while循环的主体开始执行,打印n的值并将其加 1。现在n等于2,代码返回到while循环的顶部。这个过程会一直继续,直到n等于21,此时它不再满足while循环的要求,所以循环结束。
以下是相同功能在 C 语言中的实现。
// Count to 20 in C.
int n = 1;
while(n <= 20)
{
printf("%d\n", n);
n++;
}
在这两个示例中,while循环的主体会递增n的值。实际上,有一种更简洁的方式来做这件事。for 循环允许对一系列数字或值的集合进行迭代,从而使程序员能够对每个项执行某些操作。这里有一个 C 语言示例,它会打印从 1 到 10 的数字。
// C uses a for loop to iterate over a numeric range.
// This will print 1 through 10.
for(❶int x = 1; ❷x <= 10; ❸x++)
{
❹ printf("%d\n", x);
}
for循环声明了x并将其初始值设置为1❶,指定循环将在x小于或等于10时继续❷,最后声明在循环体执行后应递增x的值❸。通过将所有这些信息写在一行for语句中,我们可以更容易地看到循环运行的条件。for循环的主体只是打印出x的值❹。
Python 采用了不同的方式处理for循环,允许程序对集合中的每个项执行重复操作。以下是一个 Python 示例,它会打印出列表中的动物名称。
# Python uses a for loop to iterate over a collection.
# This will print each animal name in animal_list.
animal_list = ['cat', 'dog', 'mouse']
for animal in animal_list:
print(animal)
首先,声明一个动物名称的列表并将其赋值给名为animal_list的变量。在 Python 中,列表是一个有序的值集合。接下来,for循环声明代码块会对animal_list中的每个项执行一次,每次运行时,列表中的当前值会被赋给animal变量。因此,第一次执行循环时,animal等于cat,程序会打印出cat。下一次执行时打印出dog,最后一次执行时打印出mouse。
函数
循环允许一组指令连续执行多次。然而,程序中也常常需要执行一组特定的指令多次,但不一定在循环中执行。相反,这些指令可能需要从程序的不同部分、不同时间、以及不同的输入和输出中调用。当程序员意识到某些代码在多个地方都需要时,他们可能会将这些代码编写成一个函数。函数是一组程序指令,可以被其他代码调用。函数可以选择接受输入(称为参数)并返回输出(称为返回值)。不同的高级编程语言可能使用不同的术语来描述函数,包括子例程、过程或方法。在某些情况下,这些不同的名称可能传达略有不同的含义,但为了简化,我们就使用函数这一术语。
将字符字符串转换为小写、将文本打印到屏幕以及从互联网下载文件,都是你可以通过可重用的代码(以函数形式)实现的例子。程序员希望避免多次键入相同的代码。这样做会导致维护多个相同代码的副本,并增加程序的整体大小。这违反了一个软件工程原则,即不要重复自己(DRY),该原则鼓励减少重复代码。
函数是封装的另一个例子。我们之前在硬件上下文中看到过封装,在这里我们再次看到它,这次是在软件中。函数封装了代码块的内部细节,同时提供了一个接口来使用该代码。希望使用函数的开发者只需要了解其输入和输出;而不需要完全理解函数内部的工作原理。
定义函数
函数必须在使用之前定义。一旦定义,你可以通过调用它来使用函数。函数定义包括函数名称、输入参数、函数的程序语句(称为函数体),以及在某些语言中,返回值类型。这里我们有一个示例 C 函数,用于计算给定半径的圆的面积。
// C function to calculate the area of a circle
❶ double ❷areaOfCircle(❸double radius)
{
double area = 3.14 * radius * radius;
❹ return area;
}
double 类型在开头 ❶ 表明该函数返回一个浮动点数(double 是 C 语言中的浮动点类型之一)。该函数有一个名称,areaOfCircle ❷,旨在传达该函数的作用——在本例中,计算圆的面积。该函数接受一个输入参数,名为 radius ❸,其类型也是 double。
在大括号之间是函数的主体,定义了函数的具体实现。我们声明了一个名为 area 的局部变量,它的类型也是 double。面积通过 π × radius² 来计算并赋值给 area 变量。最后,函数返回 area 变量的值 ❹。请注意,area 变量的作用域是有限的;它无法在函数外部访问。当函数返回时,局部变量 area 会被丢弃(它可能被存储在栈上),但其值会通过处理器寄存器返回给调用者。
以下是一个类似的面积函数,这次是用 Python 编写的。
# Python function to calculate the area of a circle
def area_of_circle(radius)
area = 3.14 * radius * radius
return area
让我们对比这两个函数示例。它们都通过 π × radius² 来计算 area,然后返回该值。两者都接受一个名为 radius 的输入参数。C 语言版本显式定义了返回类型为 double,以及 radius 的类型为 double,而 Python 版本则不需要声明类型。Python 用 def 关键字来标识函数定义的开始。
调用函数
在程序中定义一个函数并不足以确保该函数能够执行。函数定义只是让其他代码可以在需要时调用该函数。这种调用被称为函数调用。调用代码传递所需的参数并将控制权交给函数。然后,函数执行其代码并将控制权(及任何输出)返回给调用者。下面演示了如何在 C 中调用我们的示例函数:
// Calling a function twice in C, each time with a different input
double area1 = areaOfCircle(2.0);
double area2 = areaOfCircle(38.6);
并且在 Python 中:
# Calling a function twice in Python
area1 = area_of_circle(2.0)
area2 = area_of_circle(38.6)
一旦函数返回,调用代码需要将返回值存储到某个地方。在两个示例中,都声明了变量area1和area2来保存函数调用的返回值。在两种语言中,area1的值为 12.56,area2的值为 4,678.4744。实际上,调用代码可以忽略返回值,而不将其赋给变量,但考虑到该函数的用途,这样做并不太有用。图 9-5 展示了调用函数如何暂时将控制权交给该函数。

图 9-5:调用一个函数
在图 9-5 中,左侧的 Python 代码调用了area_of_circle函数,并将输入参数radius的值设置为2.0。左侧的代码随后等待右侧函数完成工作。一旦函数返回,左侧的代码将返回值存储在变量area1中,然后继续执行。
使用库
尽管程序员会为自己的使用定义函数,但编程中的一个重要部分是知道如何最好地利用其他人已经编写的函数。编程语言通常包括一套称为标准库的函数集合。在这个上下文中,库是供其他软件使用的代码集合。C 和 Python 都包括标准库,提供诸如打印到控制台、处理文件和文本处理等功能。Python 的标准库尤其庞大且广受好评。尽管并非总是如此,大多数编程语言的实现都会包含该语言的标准库,因此程序员可以依赖这些函数。
注意
请参见项目 #17 在第 189 页,您可以在这里运用所学知识编写一个简单的猜数字游戏。这包括使用 Python 标准库。
除了标准库之外,还有许多编程语言的附加函数库可供使用。开发人员编写库供他人使用,并以源代码或编译文件的形式共享这些库。这些库有时以非正式方式共享,某些编程语言具有已知且被接受的库发布机制。共享的库集合称为包,而用于共享这些包的系统称为包管理器。C 语言有多个包管理器,但没有一个被 C 程序员普遍接受为标准。Python 自带的包管理器叫做pip。pip使得安装社区开发的 Python 软件库变得非常简单,Python 开发者常常使用它。
面向对象编程
编程语言旨在支持特定的范式,或者说是编程的方法。例如,包括过程式编程、函数式编程和面向对象编程等。一个语言可能被设计来支持一种或多种范式,而软件开发者则需根据某种范式使用该语言。让我们来看一个流行的范式:面向对象编程,这是一种将代码和数据组合在一起的编程方法,这种组合被称为对象。对象旨在以一种模拟现实世界概念的方式,表示数据和功能的逻辑分组。
面向对象编程语言通常采用基于类的方法。类是对象的蓝图。从类创建的对象被称为该类的实例。在类中定义的函数称为方法,而在类中声明的变量称为字段。在 Python 中,对于每个类实例有不同值的字段称为实例变量,而在所有实例中具有相同值的字段则称为类变量。
例如,可以编写一个描述银行账户的类。这个银行账户类可能有一个表示余额的字段,一个表示持有人姓名的字段,以及用于取款和存款的方法。该类描述的是一个通用的银行账户,但直到从该类创建一个银行账户对象,才会存在特定的银行账户实例。这在图 9-6 中得到了说明。

图 9-6:银行账户对象是从银行账户类创建的。
正如你在图 9-6 中看到的,BankAccount类描述了银行账户的字段和方法,帮助我们理解银行账户的样子。已经创建了两个BankAccount类的实例。这些对象是具体的银行账户,并且已分配了名称和余额。我们可以使用每个对象的withdraw或deposit方法来修改其balance字段。在 Python 中,向名为myAccount的银行账户对象存款的代码如下所示,这将使其balance字段增加 25:
myAccount.deposit(25)
注意
请参见项目#18,该项目在第 190 页中,您可以尝试实现刚才描述的银行账户类的 Python 实现。
编译或解释
如前所述,源代码是开发者最初编写的程序文本,通常不是用 CPU 直接理解的编程语言编写的。CPU 只理解机器语言,因此需要额外的步骤:源代码必须被编译成机器码,或在运行时通过其他代码解释。
在编译型语言中,例如 C,源代码会被转换为机器指令,可以直接由处理器执行。这个过程在本章的“高级编程概述”一节中已经描述过,见第 160 页。源代码在开发过程中被编译,编译后的可执行文件(有时称为二进制文件)会被交付给最终用户。当最终用户运行二进制文件时,他们不需要访问源代码。编译后的代码通常运行得更快,但只在为其编译的架构上运行。图 9-7 展示了开发者如何使用 GNU C 编译器(gcc)从命令行编译并运行 C 程序的示例。

图 9-7:将 C 源文件编译为可以独立运行的可执行文件
在解释型语言中,例如 Python,源代码不会提前编译。相反,它会被一个叫做解释器的程序读取,并执行程序的指令。实际上运行在 CPU 上的是解释器的机器代码。使用解释型语言的开发者可以分发源代码,最终用户可以直接运行,而无需复杂的编译步骤。在这种情况下,开发者不需要担心为多个平台编译代码——只要用户的系统中安装了适当的解释器,就可以运行代码。这样分发的代码是平台无关的。
解释型代码通常比编译型代码运行得慢,因为在运行时需要进行解释。分发解释型代码时,最好是在用户已经安装了所需的解释器,或者用户技术足够熟练,安装解释器不是问题。否则,开发者需要将解释器与软件打包,或者指导用户安装解释器。图 9-8 展示了从命令行运行 Python 程序的示例,假设已经安装了 Python 3 版本的解释器。请注意,Python 源代码文件 hello.py 直接提供给了解释器——无需中间步骤。

图 9-8:Python 解释器运行 Python 源代码。
有些语言使用的是这两种方法的混合体。这些语言会编译成中间语言或字节码。字节码类似于机器代码,但它不是针对特定硬件架构,而是设计成在虚拟机上运行,如图 9-9 所示。

图 9-9:字节码编译器将源代码转化为在虚拟机中运行的字节码。
在此上下文中,虚拟机是一种旨在运行其他软件的软件平台。虚拟机提供虚拟的 CPU 和执行环境,抽象出真实底层硬件和操作系统的细节。例如,Java 源代码被编译成 Java 字节码,然后在 Java 虚拟机中运行。类似地,C# 源代码被编译成通用中间语言(CIL),并在 .NET 公共语言运行时(CLR)虚拟机中运行。CPython,Python 的原始实现,实际上在运行前将 Python 源代码转换为字节码,尽管这是 CPython 解释器的实现细节,对 Python 开发者来说大多是隐藏的。使用字节码的编程语言在保持解释型语言的跨平台独立性的同时,也保留了一些编译代码的性能提升。
在 C 中计算阶乘
为了总结我们对高级编程的了解,让我们来看看阶乘算法的实现,这次使用 C 语言。我们之前在 ARM 汇编中做过类似的事情,所以看到 C 中的相同逻辑可以很好地对比汇编语言和高级语言之间的差异。这个 C 代码使用了我们刚刚介绍的一些概念。我选择使用 C 而不是 Python,因为 C 是编译语言,我们可以检查编译后的机器代码。以下是一个简单的 C 函数,用于计算一个数的阶乘:
// Calculate the factorial of n.
int factorial(int n)
{
int result = n;
while(--n > 0)
{
result = result * n;
}
return result;
}
其他代码可以调用这个函数,传递n参数作为需要计算阶乘的值。然后函数会在内部计算阶乘值,将其存储在局部变量result中,并将计算结果返回给调用者。就像我们在第八章中使用汇编代码一样,接下来我们通过练习深入探索这段代码。
练习 9-2:在脑海中运行 C 程序
尝试在脑海中运行前面的阶乘函数,或者使用铅笔和纸。假设输入值为n = 4。当函数返回时,返回的结果应该是预期的值 24。我建议你在每一行之前和之后,跟踪n和result的值。按步骤执行代码,直到你到达while循环的末尾,看看是否得到预期的结果。答案在附录 A 中。
请注意,while 循环的条件(--n > 0)将递减操作符(--)放在变量 n 之前。这意味着 n 会在与 0 比较之前被递减。每次评估 while 循环条件时,都会发生这种情况。
我希望你能觉得我们算法的 C 版本比 ARM 汇编版本更易读!这个版本的阶乘代码的另一个主要优点是它不依赖于特定的处理器类型。只要有合适的编译器,它就可以为任何处理器编译。如果你将之前的 C 代码编译到 ARM 处理器上,你会看到生成的机器代码与我们之前分析的 ARM 汇编非常相似。你将在项目 #19 中有机会亲自尝试,但现在我已经为你编译并反汇编了代码:
Address Assembly
0001051c sub r3, r0, #1
00010520 cmp r3, #0
00010524 bxle lr
00010528 mul r0, r3, r0
0001052c subs r3, r3, #1
00010530 bne 00010528
00010534 bx lr
如你所见,从 C 源代码生成的代码与我们在第八章中讨论的汇编阶乘示例非常相似。虽然有一些差异,但这些细节与我们的讨论无关。这里需要注意的是,一个程序可以用像 C 这样的高级语言编写,编译器可以做繁重的工作,把高级语句翻译成机器代码。你可以看到,使用高级语言进行开发可以简化开发者的工作,但最终我们仍然得到的是机器代码的字节,因为处理器需要的是这些。
注意
请参阅项目 #19 中的内容,位于第 191 页,你可以尝试编译然后反汇编一个 C 语言的阶乘程序。
这里发生了一些有趣的事情,我希望你没有错过。我们从用 C 编写的源代码开始,编译成机器代码,然后反汇编成汇编语言。这意味着,如果你电脑上有一个编译后的程序或软件库,你可以把它当作汇编语言来查看!你可能无法访问原始源代码,但程序的汇编版本就在你手边。
我们一直在研究专门为 ARM 处理器编写的机器代码和汇编语言,但正如前面所提到的,使用像 C 这样的高级语言进行开发的一个优点是,同样的代码可以为不同的处理器编译。事实上,只要代码没有使用特定操作系统的功能,同样的代码甚至可以为另一个操作系统编译。为了说明这一点,我已经将相同的阶乘 C 代码为 32 位 x86 处理器编译,这次是在 Windows 而不是 Linux 上。以下是生成的机器代码,以汇编语言显示:
Address Assembly
00406c35 mov ecx,dword ptr [esp+4]
00406c39 mov eax,ecx
00406c3b jmp 00406c40
00406c3d imul eax,ecx
00406c40 dec ecx
00406c41 test ecx,ecx
00406c43 jg 00406c3d
00406c45 ret
我不会详细说明这段代码的细节,但你可以随意研究 x86 指令集并自行解读代码。我希望你从这个例子中获得的主要收获是,高级语言,如 C,允许开发者编写比汇编更易于理解的代码,而且可以轻松地为各种处理器编译。
总结
本章介绍了高级编程语言。这些语言独立于特定的 CPU,语法上更接近人类语言。你了解了编程语言中常见的元素,如注释、变量、函数和循环功能。你看到这些元素在两种编程语言中的表现:C 和 Python。最后,我们检查了一个 C 语言的示例程序,并看到了通过编译高级代码生成的反汇编机器码。
在下一章,我们将介绍操作系统。我们将首先概述操作系统提供的功能,了解不同种类的操作系统家庭,并深入探讨操作系统的工作原理。过程中,你将有机会探索 Raspberry Pi OS,它是为 Raspberry Pi 定制的 Linux 版本。
项目 #14:检查变量
前提条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。如果你还没看过,建议你翻到附录 B,阅读第 341 页中关于“Raspberry Pi”的全部内容。
在这个项目中,你将编写使用变量的高级代码,并检查它在内存中的工作原理。使用你喜欢的文本编辑器,在主文件夹的根目录下创建一个名为 vars.c 的新文件。将以下 C 代码输入到文本编辑器中(你不必保留缩进和空行,但要确保保持换行)。
#include <stdio.h>❶
#include <signal.h>
int main()❷
{
int points = 27;❸
int year = 2020;❹
printf("points is %d and is stored at 0x%08x\n", points, &points);❺
printf("year is %d and is stored at 0x%08x\n", year, &year);
raise(SIGINT);❻
return 0;
}
在继续之前,让我们检查一下源代码。它首先包含了几个头文件 ❶。这些文件包含了 C 编译器所需的有关 printf 和 raise 函数的细节,这些函数将在程序中稍后使用。接下来你会看到定义了 main 函数 ❷;这是程序的入口点,执行从这里开始。程序随后声明了两个整型变量,points ❸ 和 year ❹,并为它们赋值。然后它打印出变量的值及其内存地址(以十六进制表示) ❺。raise(SIGINT) 语句会导致程序停止执行 ❻。这不是你在用户运行的代码中通常会做的事情;这是一种我们用来帮助调试的技术。
文件保存后,使用 GNU C 编译器(gcc)将你的代码编译成可执行文件。在 Raspberry Pi 上打开终端并输入以下命令以调用编译器。该命令以 vars.c 作为输入,编译并链接代码,并输出名为 vars 的可执行文件。
$ gcc -o vars vars.c
现在尝试使用以下命令运行编译后的代码。程序应该会打印出程序中两个变量的值和地址。
$ ./vars
在确认程序工作正常后,通过 GNU 调试器(gdb)运行它,并检查内存中的变量。
$ gdb vars
此时 gdb 已经加载了文件,但还没有执行任何指令。在 (gdb) 提示符下,输入以下命令来运行程序,直到执行 raise(SIGINT) 语句为止。
(gdb) run
一旦程序返回到(gdb)提示符,你应该会看到几行输出,其中打印了变量的值和内存地址。接下来,你可能还会看到一条可能让你担心的消息:“没有此类文件或目录”——你可以忽略它。这只是调试器试图查找某个在你的系统上不存在的源代码。你需要关注的输出应该是类似这样的内容:
Starting program: /home/pi/vars
points is 27 and is stored at 0x7efff1d4
year is 2020 and is stored at 0x7efff1d0
现在你已经知道了内存地址,并且由于你恰好在调试器中,你可以开始检查这些地址处存储的内容。在这个输出中,你可以看到year存储在较低的地址处,points存储在 4 个字节之后,因此你将从year变量的地址开始转储内存,在我的例子中是0x7efff1d0。你的地址可能会不同。以下命令会从地址0x7efff1d0开始,在内存中转储三个 32 位的值,以十六进制表示。如果地址不同,请将0x7efff1d0替换为你系统上year的地址。
(gdb) x/3xw 0x7efff1d0
0x7efff1d0: 0x000007e4 0x0000001b 0x00000000
你可以看到,存储在0x7efff1d0的值是0x000007e4。这在十进制中是 2020,正是预期的year值。而存储在 4 个字节之后的值是0x0000001b,即 27 十进制,正是预期的points值。内存中的下一个值恰好是 0,并不是我们的变量之一。内存通常以十六进制形式进行检查,但如果你想看到这些值的十进制表示,可以使用以下命令:
(gdb) x/3dw 0x7efff1d0
0x7efff1d0: 2020 27 0
你正在查看 32 位(4 字节)内存块,因为这是这个程序中使用的变量大小。但实际上内存是按字节寻址的,也就是说,每个字节都有自己的地址。这就是为什么points的地址比year的地址大 4 个字节的原因。让我们改为按字节查看相同的内存范围:
(gdb) x/12xb 0x7efff1d0
0x7efff1d0: 0xe4 0x07 0x00 0x00 0x1b 0x00 0x00 0x00
0x7efff1d8: 0x00 0x00 0x00 0x00
查看year的值,这里加以强调。请注意,最低有效字节(0xe4)首先出现。这是由于小端数据存储方式,正如在第 156 页的项目 #13 中讨论的那样。你可以使用q退出gdb(即使调试会话仍然处于活动状态,它会询问你是否要退出;回答y)。项目 #15:更改 Python 中变量引用值的类型
前提条件:一台运行 Raspberry Pi OS 的树莓派。如果你还没有阅读过,我建议你翻到附录 B,阅读第 341 页上的“树莓派”部分。
在这个项目中,你将编写代码,将一个 Python 变量设置为某种类型的值,然后将该变量更新为引用另一种类型的值。使用你喜欢的文本编辑器,在主目录下创建一个名为vartype.py的新文件。在文本编辑器中输入以下 Python 代码:
age = 22
print('What is the type?')
print(type(age))
age = 'twenty-two'
print('Now what is the type?')
print(type(age))
这段代码将名为age的变量设置为一个整数值,然后打印该值的类型。接着,它将age设置为一个字符串值,并再次打印类型。
文件保存后,你可以通过 Python 解释器在终端窗口中运行该文件,方法如下:
$ python3 vartype.py
你应该看到如下输出:
What is the type?
<class 'int'>
Now what is the type?
<class 'str'>
你可以通过简单地为变量赋予一个新值,看到类型从整数变为字符串。不要让“class”这个术语让你困惑;在 Python 3 中,内置类型如 int 和 str 被视为类(在 第 177 页 的“面向对象编程”中讲解)。在 Python 中,将变量设置为不同类型的值是很容易的,但在 C 中根本不允许。
PYTHON 版本
当前有两个主要版本的 Python,Python 2 和 Python 3。自 2020 年 1 月 1 日起,Python 2 不再获得支持,这意味着不会再对其进行任何新的 bug 修复。Python 开发者被鼓励将旧项目迁移到 Python 3,并且新项目应当面向 Python 3。因此,本书中的项目使用的是 Python 3。在 Raspberry Pi OS 和一些其他 Linux 发行版中,直接从命令行运行 python 会调用 Python 2 解释器,而运行 python3 会调用 Python 3 解释器。这就是为什么本书中的项目要求你特别运行 python3 而不是 python。也就是说,在其他平台上,或者在未来版本的 Raspberry Pi OS 上,可能会有所不同,输入 python 可能会调用 Python 3。你可以通过以下方式检查调用的 Python 版本:
$ python --version
或
$ python3 --version
项目 #16:堆栈还是堆
先决条件:项目 #14。
在本项目中,你将查看在运行中的程序中,变量是分配在堆栈内存还是堆内存中。打开 Raspberry Pi 上的终端,开始调试你之前在 项目 #14 中编译的 vars 程序:
$ gdb vars
此时 gdb 已加载文件,但尚未执行任何指令。从 gdb 提示符下,输入以下内容运行程序,该程序将继续直到执行 SIGINT 语句。
(gdb) run
再次查看 points 和 year 变量的内存地址。在我的情况下,这些变量的地址分别是 0x7efff1d4 和 0x7efff1d0,但你的地址可能不同。现在,使用以下命令查看你运行程序的所有映射内存位置:
(gdb) info proc mappings
输出列出了该程序使用的各种内存范围的起始和结束地址。找到包含你变量地址的那个范围。两个变量的地址应该都落在同一个范围内。对我来说,这个条目匹配:
0x7efdf000 0x7f000000 0x21000 0x0 [stack]
如你所见,gdb 指出该内存范围被分配给堆栈,这正是我们期望局部变量所在的地方。你可以通过输入 q 退出 gdb(即使调试会话处于活动状态,它会询问你是否要退出;回答 y)。
现在我们来看看堆上分配的内存。你需要修改 vars.c 文件并重新编译,使程序分配一些堆内存。使用你喜欢的文本编辑器打开现有的 vars.c 文件。将以下代码行作为第一行添加:
#include <stdlib.h>
然后在 SIGINT 行前立即添加这两行:
void * data = malloc(512);
printf("data is 0x%08x and is stored at 0x%08x\n", data, &data);
让我们来解释一下这些更改的含义。我们调用内存分配函数 malloc 从堆中分配 512 字节的内存。malloc 函数返回新分配内存的地址。该地址存储在一个新的局部变量 data 中。然后程序打印两个内存地址:一个是新堆分配的地址,另一个是 data 变量本身的地址,它应该位于栈上。
文件保存后,使用 gcc 编译你的代码:
$ gcc -o vars vars.c
现在再次运行程序:
$ gdb vars
(gdb) run
检查新打印的值。对我来说,值如下:
data is 0x00022410 and is stored at 0x7efff1ac
我们预计第一个地址,也就是从 malloc 返回的地址,应该在堆上。第二个值,即 data 局部变量的地址,应该在栈上。再次运行以下命令,查看该程序的内存范围,看看这两个地址位于何处。
(gdb) info proc mappings
...
0x22000 0x43000 0x21000 0x0 [heap]
...
0x7efdf000 0x7f000000 0x21000 0x0 [stack]
查找系统中匹配的地址范围,并确认这些地址是否位于预期的堆和栈的地址范围内。你可以使用 q 退出 gdb。
项目 #17:编写一个猜数字游戏
在这个项目中,你将编写一个猜数字游戏,基于我们在本章中所讲的内容。使用你选择的文本编辑器,在你的主文件夹根目录创建一个名为 guess.py 的新文件。将以下 Python 代码输入到文本编辑器中。在 Python 中,缩进非常重要,请确保正确缩进。
from random import randint❶
secret = randint(1, 10)❷
guess = 0❸
count = 0❹
print('Guess the secret number between 1 and 10')
while guess != secret:❺
guess = int(input())❻
count += 1
if guess == secret:❼
print('You got it! Nice job.')
elif guess < secret:
print('Too low. Try again.')
else:
print('Too high. Try again.')
print('You guessed {0} times.'.format(count))❽
让我们来看看这个程序是如何工作的。这个代码首先导入一个名为 randint 的函数,用于生成随机整数 ❶。这是一个使用别人编写的函数的例子;randint 是 Python 标准库的一部分。调用 randint 函数返回一个范围为 1 到 10 的随机整数,我们将其存储在一个名为 secret 的变量中 ❷。接着,代码将一个名为 guess 的变量设置为 0 ❸。这个变量保存玩家的猜测,初始值为 0,这个值肯定与秘密值不匹配。另一个名为 count 的变量 ❹ 用来跟踪玩家已猜测的次数。
while 循环会一直运行,直到玩家的 guess 与 secret 匹配 ❺。循环中的代码调用内置函数 input 从控制台获取用户的猜测 ❻,并将结果转换为整数并存储在 guess 变量中。每次输入猜测后,它会与 secret 变量进行比较,判断是否匹配,还是太低或太高 ❼。一旦玩家的 guess 与 secret 匹配,循环退出,程序打印玩家猜测的次数 ❽。
文件保存后,你可以使用 Python 解释器运行它,方法如下:
$ python3 guess.py
多次运行这个程序;每次运行时,秘密数字应该会改变。你可能想尝试修改程序,使得允许的整数范围更大,或者你可能想放入你自己的自定义消息。作为挑战,尝试修改程序,当猜测非常接近时,程序打印不同的消息。项目 #18:在 Python 中使用银行账户类
在这个项目中,你将用 Python 编写一个银行账户类,并基于该类创建一个对象。使用你选择的文本编辑器,在你主文件夹的根目录下创建一个名为bank.py的新文件。将以下 Python 代码输入到文本编辑器中。如果你愿意,可以跳过输入注释(以#开头的行)。请注意,__init__前后有两个下划线字符。
# Define a bank account class in Python.
class BankAccount:❶
def __init__(self, balance, name):❷
self.balance = balance❸
self.name = name❹
def withdraw(self, amount):❺
self.balance = self.balance - amount
def deposit(self, amount):❻
self.balance = self.balance + amount
# Create a bank account object based on the class.
smithAccount = BankAccount(10.0, 'Harriet Smith')❼
# Deposit some additional money to the account.
smithAccount.deposit(5.25)❽
# Print the account balance.
print(smithAccount.balance)❾
这段代码定义了一个新的类,名为BankAccount ❶。它的__init__函数 ❷会在创建类的实例时自动调用。这个函数将实例变量balance ❸和name ❹设置为传入初始化函数的值。变量对于创建的每个类的对象实例都是唯一的。类定义还包括两个方法:withdraw ❺和deposit ❻,它们简单地修改余额。类定义之后,代码继续创建类的一个实例 ❼。现在可以通过访问其变量和方法来使用这个银行账户对象。在这里,进行了一次存款操作 ❽,然后获取新余额并打印 ❾。
文件保存后,你可以像下面这样使用 Python 解释器运行它:
$ python3 bank.py
你应该看到账户余额 15.25 打印到终端窗口。事实上,这种计算银行余额的方法过于复杂!所有的数字在程序中都是硬编码的,我们实际上并不需要使用面向对象的方法来解决这个问题。然而,我希望这个例子能帮助你理解类和对象是如何工作的。项目 #19:C 语言中的阶乘
先决条件:项目 #12 和 #13
在这个项目中,你将使用 C 语言编写一个阶乘程序,和本章之前我们介绍的程序类似。然后,你将查看代码编译后生成的机器代码。使用你选择的文本编辑器,在你主文件夹的根目录下创建一个名为fac2.c的新文件。将以下 C 代码输入:
#include <stdio.h>
// Calculate the factorial of n.
int factorial(int n)❶
{
int result = n;
while(--n > 0)
{
result = result * n;
}
return result;
}
int main()❷
{
int answer = factorial(4);❸
printf("%d\n", answer);❹
}
你可以看到,factorial函数 ❶与本章之前给出的 C 示例完全相同;这是计算阶乘的核心代码。然而,为了使这个程序可用,我们还定义了一个main函数 ❷,作为程序的入口点——程序从这里开始执行。从main,程序调用factorial函数,传入 4 的值,将结果存储在名为answer的局部变量中 ❸。然后,程序打印answer的值到终端 ❹。
文件保存后,使用gcc将代码编译成可执行文件。以下命令以fac2.c为输入,输出一个名为fac2的可执行文件。不需要单独的链接步骤。还请注意-O(是大写字母 O)命令行选项:这意味着启用编译器优化。我在这里添加了这个选项,因为在这种情况下它生成的代码更接近第 12 项目中的汇编代码。
$ gcc -O -o fac2 fac2.c
现在尝试使用以下命令运行代码。如果一切按预期工作,程序应该在下一行打印出计算结果 24。
$ ./fac2
现在你已经有了一个fac2可执行文件,使用你在第 12 和第 13 项目中使用过的相同技术来检查已编译的文件。我不会再逐一讲解所有细节,但你之前使用过的方法在这里同样有效。以下是一些命令,帮助你入门:
$ hexdump -C fac2
$ objdump -s fac2
$ objdump -d fac2
$ gdb fac2
你应该立刻看到fac2文件里有很多内容!编译后的 ELF 二进制文件包含了 C 语言程序所需的一些开销。在我的电脑上,原始的fac ELF 文件为 940 字节,而fac2 ELF 文件为 8,364 字节,增加了 9 倍!当然,C 版本包括了额外的功能,用来打印值,因此某些大小的增加是可以预见的。
在查看反汇编代码时,首先要检查的是factorial函数。将其与在第八章中用汇编语言写的阶乘代码进行对比。你可能会注意到,gdb显示的入口点与main不同。这是因为 C 程序在调用main入口点之前有一些初始化代码。如果你想跳过这部分代码,直接进入factorial函数,可以设置一个断点(break factorial),然后运行,再进行反汇编。
在你的机器上生成的机器指令可能会有所不同,但以下是我电脑上生成的factorial函数的机器代码和相应的汇编语言。这是使用 objdump -d fac2 命令的输出:
00010408 <factorial>:
10408: e2403001 sub r3, r0, #1❶
1040c: e3530000 cmp r3, #0❷
10410: d12fff1e bxle lr❸
10414: e0000093 mul r0, r3, r0❹
10418: e2533001 subs r3, r3, #1❺
1041c: 1afffffc bne 10414 <factorial+0xc>❻
10420: e12fff1e bx lr❼
在调用此函数之前,n的值已经存储在r0寄存器中。当函数开始时,立即对n进行递减,并将结果存储在r3中 ❶。接着程序比较r3(也就是n)与零 ❷。如果n小于或等于零 ❸,程序将从函数中返回。否则,存储在r0中的result将被计算为result × n ❹。接着n递减 ❺,如果n不为零 ❻,程序将再次进入循环,跳转回地址10414 ❹。一旦n达到零,循环结束,函数返回 ❼。
第十章:操作系统**

到目前为止,我们已经研究了计算机的硬件和软件。在本章中,我们将关注一种特定类型的软件:操作系统。首先,我们将讨论没有操作系统(OS)时编程的挑战。接着,我们将概述操作系统。我们将在本章的大部分内容中详细介绍操作系统的核心功能。在项目中,你将有机会深入了解 Raspberry Pi OS 的工作原理。
没有操作系统的编程
让我们先考虑一下在没有操作系统的设备上使用和编程的情况。正如你马上会看到的,操作系统提供了硬件与其他软件之间的接口。然而,在没有操作系统的设备上,软件可以直接访问硬件。有许多计算机就是这样工作的,但我们特别关注其中的一种类型:早期的电子游戏主机。如果我们回顾像雅达利 2600、任天堂娱乐系统或世嘉 Genesis 这样的游戏主机,我们会发现这些硬件从游戏卡带中运行代码,没有操作系统。图 10-1 展示了游戏软件直接在主机硬件上运行的概念,中间没有任何介入。

图 10-1:早期的视频游戏直接在游戏主机硬件上运行,没有操作系统。
使用这种系统时,你只需插入一个卡带并打开系统以开始游戏。这款游戏主机一次只能运行一个程序——即当前插入卡带中的游戏。在大多数此类系统中,如果没有插入卡带,打开系统不会有任何反应,因为 CPU 没有任何指令可供执行。要切换到另一个游戏,你需要关掉系统,交换卡带,再重新开机。系统运行时没有切换程序的概念,也没有任何程序在后台运行。一个程序,即游戏,完全占用了硬件的所有资源。
作为程序员,为这种系统制作游戏意味着必须负责直接通过代码控制硬件。系统启动后,CPU 开始运行卡带中的代码。游戏开发者不仅需要为游戏的逻辑编写软件,还必须初始化系统、控制视频硬件、读取控制器输入的硬件状态等。不同的游戏主机硬件设计差异极大,因此开发者需要了解他们所针对硬件的复杂性。
幸运的是,对于老派的游戏开发者来说,游戏主机在制造的过程中,硬件设计几乎没有变化。例如,所有的任天堂娱乐系统 (NES) 游戏主机都拥有相同类型的处理器、内存、图像处理单元 (PPU) 和音频处理单元 (APU)。要成为一名成功的 NES 开发者,你必须对所有这些硬件有深刻的理解,但至少每台出售给玩家的 NES 主机的硬件都是一样的。开发者知道系统中会有什么硬件,因此可以将代码针对特定硬件进行优化,这样他们就能从系统中榨取每一分性能。然而,要将他们的游戏移植到另一种类型的游戏主机时,他们通常不得不重写大量代码。此外,每个游戏卡带必须包含类似的代码来完成基本任务,比如初始化硬件。尽管开发者可以重用他们之前为其他游戏编写的代码,但这仍然意味着不同的开发者一次又一次地解决相同的挑战,且成功的程度各不相同。
操作系统概述
操作系统提供了一种不同的编程模型,并通过这种方式,解决了许多与直接针对特定硬件编写代码相关的挑战。操作系统 (OS) 是与计算机硬件通信并为程序执行提供环境的软件。操作系统允许程序请求系统服务,如从存储设备读取数据或通过网络进行通信。操作系统负责计算机系统的初始化,并管理程序的执行。这包括并行运行多个程序,或称为多任务处理,确保多个程序能够共享处理器时间和系统资源。操作系统设置边界,确保程序之间以及程序与操作系统之间相互隔离,并确保共享系统的用户获得适当的访问权限。你可以将操作系统视为硬件和应用程序之间的代码层,如图 10-2 所示。

图 10-2:操作系统充当硬件和应用程序之间的层。
这一层提供了一套抽象底层硬件细节的功能,使软件开发者能够专注于软件的逻辑,而不是与特定硬件进行通信。正如你可能预料的那样,考虑到当今计算设备的多样性,这非常有用。考虑到智能手机和个人电脑中硬件的惊人多样性,为每种设备编写代码是不切实际的。操作系统隐藏了硬件的细节,并提供了应用程序可以构建的通用服务。
从高层次来看,操作系统包含的组件可以分为两个主要类别:
-
内核
-
其他一切
操作系统的内核负责管理内存、促进设备 I/O,并为应用程序提供一组系统服务。内核允许多个程序并行运行并共享硬件资源。它是操作系统的核心部分,但单独并不能为最终用户提供与系统交互的方式。
操作系统还包括一些非内核组件,这是系统有用的必备部分。包括shell,即与内核交互的用户界面。shell和内核这两个术语是操作系统的隐喻,其中操作系统被视为一个坚果或种子。内核处于核心位置,shell 环绕其周围。shell 可以是命令行界面(CLI)或图形用户界面(GUI)。一些 shell 的例子包括 Windows 的 shell GUI(包括桌面、开始菜单、任务栏和文件资源管理器)以及 Linux 和 Unix 系统中的 Bash shell CLI。
操作系统的一些功能由在后台运行的软件提供,这些软件与内核不同,称为守护进程或服务(不要与前面提到的内核系统服务混淆)。这种服务的一个例子是 Windows 上的任务调度程序或 Unix 和 Linux 上的 cron,它们都允许用户在特定时间安排程序运行。
操作系统还通常包括软件库,供开发人员进行构建。这些库包含许多应用程序可以利用的通用代码。此外,操作系统本身的组件,如 shell 和服务,也使用这些库提供的功能。
在与硬件交互时,内核与设备驱动程序协作。设备驱动程序,或简称驱动程序,是用于与特定硬件交互的软件。操作系统的内核需要与各种硬件设备协作,因此,软件开发人员将与特定设备交互的代码实现为设备驱动程序,而不是让内核了解如何与世界上每个硬件设备交互。操作系统通常包括一套常见硬件的设备驱动程序,并提供安装额外驱动程序的机制。
大多数操作系统都包括一些基本应用程序,如文本编辑器和计算器,通常统称为实用程序。网络浏览器也是许多操作系统的标准组件。这些实用程序可以说并不真正属于操作系统,而只是应用程序,但实际上,大多数操作系统都包括这种软件。图 10-3 提供了操作系统中组件的简要概述。

图 10-3:操作系统包含多个组件。
如你在图 10-3 中所见,在软件栈的基础部分,硬件之上,是内核和设备驱动程序。库提供应用程序构建所需的功能,因此库被显示为位于内核和应用程序之间的层。Shell、服务和实用工具也基于库构建。
操作系统家族
今天,主流的操作系统家族有两个:类 Unix 操作系统和 Microsoft Windows。如其名所示,类 Unix操作系统表现得像 Unix 操作系统。Linux、macOS、iOS 和 Android 都是类 Unix 操作系统的例子。Unix最初在贝尔实验室开发,其历史可以追溯到 1960 年代。Unix 最初运行在 PDP-7 小型计算机上,但后来被移植到许多种计算机上。Unix 最初是用汇编语言编写的,后来被重写成 C 语言,从而使其能够在各种处理器上编译运行。今天,Unix 被用于服务器,并且由于苹果的 macOS 和 iOS(这两者都基于 Unix),它在个人计算机和智能手机上也占有重要地位。Unix 支持多用户、多任务处理和统一的层次化目录结构。它有一个强大的命令行 Shell,并通过一套明确标准的命令行工具支持,可以将这些工具结合使用,完成复杂任务。
Linux内核最初是由 Linus Torvalds 开发的,他的目标是创建一个类似 Unix 的操作系统。Linux 不是 Unix,但它无疑是类 Unix 的。它的行为很像 Unix,但不包含任何 Unix 源代码。Linux 发行版是将 Linux 内核与其他软件捆绑在一起的操作系统。Linux 内核是开源的,这意味着其源代码是公开的。许多 Linux 发行版是免费的。典型的 Linux 发行版包括一个 Linux 内核和来自 GNU 项目的一些类 Unix 组件(发音为“guh-new”)。
GNU是一个递归首字母缩略词,代表GNU 不是 Unix,这是一个始于 1980 年代的软件项目,目标是将 Unix-like 操作系统作为自由软件创建。GNU 项目和 Linux 是两个独立的努力,但它们已紧密相关。1991 年 Linux 内核发布后,促使了将 GNU 软件移植到 Linux 的努力。当时,GNU 没有完整的内核,而 Linux 缺乏 Shell、库等。因此,Linux 为 GNU 代码提供了内核,而 GNU 项目则为 Linux 提供了 Shell、库和实用工具。通过这种方式,这两个项目互为补充,共同形成了一个完整的操作系统。
今天,人们通常用Linux一词来指代由 Linux 内核和 GNU 软件组合而成的操作系统。这有些争议,因为将整个操作系统称为“Linux”没有体现出 GNU 软件在许多 Linux 发行版中所起的重要作用。尽管如此,在本书中,我遵循普遍的惯例,将整个操作系统称为 Linux,而非 GNU/Linux 或类似的名称。
如今,Linux 通常用于服务器和嵌入式系统,并且在软件开发者中非常流行。Android 操作系统基于 Linux 内核,因此 Linux 在智能手机市场占有重要地位。Raspberry Pi OS(前称 Raspbian)也是一个包括 GNU 软件的 Linux 发行版,我们将使用 Raspberry Pi OS 来进一步探索 Linux。总体来说,在本书中,我将更多依赖 Linux,而非 Unix,来举例说明类 Unix 行为。
微软 Windows 是个人计算机上占主导地位的操作系统,包括台式机和笔记本电脑。它在服务器领域(Windows Server)也有着强大的影响力。Windows 的独特之处在于它并不追溯至 Unix。Windows 的早期版本基于 MS-DOS(微软磁盘操作系统)。尽管在家用计算机市场广受欢迎,这些早期版本的 Windows 在服务器或高端工作站市场上并不足够强大,无法与类 Unix 操作系统竞争。
与 Windows 的发展并行,微软在 1980 年代与 IBM 合作创建了 OS/2 操作系统,这是 MS-DOS 在 IBM PC 上的继任者。微软和 IBM 在 OS/2 项目的方向上产生了分歧,1990 年,IBM 接管了 OS/2 的开发,而微软则将精力转向了另一款已经在开发中的操作系统——Windows NT。与基于 MS-DOS 的 Windows 版本不同,Windows NT 基于全新的内核。Windows NT 的设计旨在能够在不同的硬件上移植,兼容多种类型的软件,支持多用户,并提供高度的安全性和可靠性。微软从数字设备公司(DEC)聘请了 Dave Cutler 来领导 Windows NT 的开发工作。他带领了一批来自 DEC 的前工程师,NT 内核设计的部分元素可以追溯到 Dave Cutler 在 DEC 开发 VMS 操作系统时的工作。
在早期版本中,Windows NT 被定位为一款面向商业的 Windows 版本,旨在与面向消费者的 Windows 版本共存。这两个 Windows 版本在实现上有很大不同,但它们共享相似的用户界面和编程接口。用户界面的相似性意味着熟悉 Windows 的用户可以迅速在 Windows NT 系统上投入生产工作。通用的编程接口使得为基于 DOS 的 Windows 开发的软件能够在 Windows NT 上运行,有时甚至无需修改。随着 2001 年 Windows XP 的发布,微软将 NT 内核带入了面向消费者的 Windows 版本。从 Windows XP 发布起,所有桌面和服务器版 Windows 都是基于 NT 内核构建的。
表 10-1 列出了今天常用的一些操作系统和设备,以及它们所属的操作系统家族。
表 10-1: 常见操作系统
| 操作系统或设备 | 家族 | 备注 |
|---|---|---|
| Android | 类 Unix | Android 使用 Linux 内核,尽管在其他方面,它并不算非常 Unix-like。其用户体验和应用编程接口与典型的 Unix 系统大相径庭。 |
| iOS | 类 Unix | iOS 基于类 Unix 的开源 Darwin 操作系统。与 Android 相似,iOS 的用户体验和编程接口与典型的 Unix 系统不同。 |
| macOS | 类 Unix | macOS 基于类 Unix 的开源 Darwin 操作系统。 |
| PlayStation 4 | 类 Unix | PlayStation 4 操作系统基于类 Unix 的 FreeBSD 内核。 |
| Raspberry Pi OS | 类 Unix | Raspberry Pi OS 是一个 Linux 发行版。 |
| Ubuntu | 类 Unix | Ubuntu 是一个 Linux 发行版。 |
| Windows 10 | Windows | Windows 10 使用 Windows NT 内核。 |
| Xbox One | Windows | Xbox One 的操作系统使用 Windows NT 内核。 |
练习 10-1:了解你生活中的操作系统
选择几台你拥有或使用的计算设备,例如笔记本电脑、智能手机或游戏机。每个设备运行的操作系统是什么?它们属于哪种操作系统家族(Windows、类 Unix、其他)?
内核模式与用户模式
操作系统的责任是确保其上运行的程序行为良好。实践中这意味着什么呢?我们来看几个例子。每个程序不得干扰其他程序或内核。用户不应能够修改系统文件。应用程序不得直接访问硬件;所有此类请求必须通过内核处理。考虑到这些要求,操作系统如何确保非操作系统代码遵守操作系统的规定呢?这通过利用一种 CPU 能力来实现,该能力赋予操作系统特殊权限,同时对其他代码施加限制;这就是特权级别。处理器可能提供超过两个特权级别,但大多数操作系统仅使用两个级别。较高的特权级别称为内核模式,较低的特权级别称为用户模式。内核模式也被称为监督模式。在内核模式下运行的代码可以完全访问系统,包括访问所有内存、I/O 设备和特殊的 CPU 指令。而在用户模式下运行的代码则有着有限的访问权限。通常情况下,内核和许多设备驱动程序在内核模式下运行,而其他所有程序则在用户模式下运行,如图 10-4 所示。

图 10-4:用户模式与内核模式下运行的代码的划分
在内核模式下运行的代码是可信的,而用户模式下的代码是不可信的。在内核模式下运行的代码可以完全访问系统中的所有内容,因此它必须是值得信赖的!通过仅允许可信代码在内核模式下运行,操作系统可以确保用户模式下的代码行为规范。
Windows 中的内核模式组件
值得注意的是,微软 Windows 还有一些其他主要组件运行在内核模式下。在 Windows 中,基础的内核模式功能实际上分布在两个组件中:内核和执行组件。这种区分仅在讨论 Windows 的内部架构时才有意义;对于大多数软件开发人员或用户而言,这种分离并不重要。事实上,内核和执行组件的编译机器代码都包含在同一个文件中(ntoskrnl.exe)。在本书的其余部分,我将不区分 Windows NT 内核和执行组件。除了内核、执行组件和设备驱动程序之外,Windows 还有其他几个主要组件运行在内核模式下。硬件抽象层(HAL)将内核、执行组件和设备驱动程序与低级硬件的差异(如主板的变化)隔离开来。窗口和图形系统(win32k)提供绘制图形和通过编程与用户界面元素交互的能力。
进程
操作系统的主要功能之一是为程序提供运行平台。正如我们在上一章所看到的,程序是机器指令的序列,通常存储在可执行文件中。然而,存储在文件中的一组指令本身并不能执行任何工作。必须有某些东西将文件中的指令加载到内存中,并指导 CPU 执行程序,同时确保程序不出现异常行为。这正是操作系统的工作。当操作系统启动一个程序时,它会创建一个 进程,即该程序的一个运行实例。在前面的内容中,我们讨论了运行在用户模式中的事物(例如 shell、服务和实用工具)——这些都在进程中执行。如果代码在用户模式下运行,它就在进程内运行,如 图 10-5 所示。
进程是程序运行的容器。这个容器包括一个私有的虚拟内存地址空间(稍后会详细介绍),加载到内存中的程序代码的副本,以及关于进程状态的其他信息。一个程序可以被启动多次,每次执行都会导致操作系统创建一个新的进程。每个进程都有一个唯一的标识符(一个数字),称为 进程标识符、进程 ID,或简称 PID。

图 10-5:在用户模式下运行的进程
除了由内核启动的初始进程外,每个进程都有一个父进程,也就是启动它的进程。父子进程的这种关系构成了进程树。如果一个子进程的父进程在子进程之前终止,那么这个子进程就成了 孤儿进程,也就是说,毫不奇怪,它没有父进程。在 Windows 上,孤儿进程的子进程将保持没有父进程。而在 Linux 上,孤儿进程通常会被 init 进程 收养,init 进程是 Linux 系统上启动的第一个用户模式进程。
图 10-6 显示了 Raspberry Pi OS 上的进程树。这个视图是使用 pstree 工具生成的。

图 10-6:通过 pstree 显示的 Linux 进程树示例
在 图 10-6 中,我们看到 init 进程是 systemd;它是第一个启动的进程,接着它又启动了其他进程。子线程使用大括号显示(稍后会详细讨论线程)。为了生成这个输出,我在命令行 shell 中运行了 pstree 命令,在输出中,你可以看到 pstree 本身正在运行,正如预期的那样。它是 bash(shell)的子进程,而 bash 又是 sshd 的子进程。换句话说,从这个输出可以看出,我是在一个远程安全 Shell(SSH)会话中打开的 Bash shell 中运行 pstree。
要查看运行 Windows 的计算机上的进程树,我建议你使用可以从 Microsoft 下载的 Process Explorer 工具。它是一个图形界面应用程序,能够让你深入查看计算机上正在运行的进程。
注意
请参见 项目 #20 以及 第 218 页,在那里你可以查看设备上运行的进程。
线程
默认情况下,程序按顺序执行指令,一次处理一个任务。但如果程序需要并行执行两个或更多任务怎么办?例如,假设一个程序需要在执行长期计算的同时更新用户界面,也许是为了显示进度条。如果程序是完全顺序执行的,那么一旦开始计算,用户界面就会被忽视,因为分配给程序的 CPU 时间必须被用在其他地方。期望的行为是,在计算运行的同时更新 UI——这两个任务需要并行发生。操作系统通过 执行线程,或简称 线程,提供了这种能力。线程是进程内可调度的执行单元。线程在进程内运行,并且可以执行加载到该进程中的任何程序代码。
线程执行的代码通常包括程序希望完成的特定任务。由于线程属于进程,它们与该进程中的其他线程共享地址空间、代码和其他资源。一个进程从一个线程开始,可能会根据需要创建其他线程,以便并行处理工作。每个线程都有一个标识符,称为 线程 ID 或 TID。内核也会创建线程来管理其工作。图 10-7 说明了线程、进程和内核之间的关系。
在 Windows 中,线程和进程是不同的对象类型。进程对象是一个容器,线程属于一个进程。在 Linux 中,这种区分更为微妙。Linux 内核使用单一的数据类型来表示进程和线程,该类型既可以表示进程,也可以表示线程。在 Linux 中,一组共享地址空间并具有相同进程标识符的线程被视为一个进程;没有单独的进程类型。

图 10-7:线程属于用户模式进程或内核。
Linux 中用于表示进程和线程标识符的术语可能会让人有些困惑。在用户模式下,进程有进程 ID(PID),线程有线程 ID(TID)。这与 Windows 类似。然而,Linux 内核将线程的 ID 称为 PID,而将进程的 ID 称为 线程组标识符(TGID)!
注意
请参见 项目 #21 以及 第 220 页,在那里你可以创建自己的线程。
多个线程并行运行到底意味着什么呢?假设你的计算机有 10 个进程在运行,每个进程有 4 个线程。仅用户模式下就有 40 个线程在运行!我们说线程是并行运行的,但 40 个线程真的能够同时运行吗?不,除非你的计算机有 40 个处理器核心,而这通常不可能。每个处理器核心一次只能运行一个线程,因此设备中的核心数量决定了可以同时运行多少个线程。
物理核心与逻辑核心
并非所有核心都具备相同的并行能力。物理核心是 CPU 内部核心的硬件实现。逻辑核心表示一个物理核心能够同时运行多个线程的能力(每个逻辑核心运行一个线程)。英特尔称这种能力为超线程技术。举个例子,我现在用来写这本书的计算机有两个物理核心,每个物理核心有两个逻辑核心,总共有四个逻辑核心。这意味着我的计算机可以同时运行四个线程,尽管逻辑核心无法实现物理核心的完全并行性。
如果我们有 40 个线程需要运行,但只有 4 个核心,会发生什么?操作系统实现了一个调度器,这是一个负责确保每个线程都能轮流运行的软件组件。操作系统采用不同的方法来实现调度,但根本目标是相同的:给线程分配运行的时间。每个线程会获得一个短暂的运行时间(称为时间片),然后线程会被挂起,以便让另一个线程运行。之后,第一个线程会被重新调度,并从它中断的地方继续执行。这一过程大部分对线程的代码以及编写应用程序的开发者是隐藏的。从线程代码的角度来看,它是连续运行的,开发者在编写多线程应用时,通常认为所有线程都是在并行运行的。
虚拟内存
操作系统支持多个正在运行的进程,每个进程都需要使用内存。大多数情况下,一个进程不需要读写另一个进程的内存,事实上,这通常是不希望发生的。我们不希望一个有问题的进程窃取或覆盖另一个进程的数据,甚至更糟糕的是,覆盖内核中的数据。此外,开发者也不希望他们进程的地址空间因为其他进程的内存使用而变得碎片化。基于这些原因,操作系统并不允许用户模式进程访问物理内存,而是为每个进程提供了虚拟内存——这一抽象概念为每个进程提供了一个独立且庞大的私有地址空间。
在第七章中,我们讲解了内存寻址,其中硬件中的每个物理字节都被分配了一个地址。这些硬件内存地址被称为物理地址。这些地址通常对用户模式进程是隐藏的。操作系统则向进程呈现虚拟内存,每个地址都是一个虚拟地址。每个进程都被分配了自己的虚拟内存空间来工作。对单个进程来说,内存表现为一个很大的地址范围。当一个进程写入某个虚拟地址时,这个地址并不直接指向硬件内存位置。虚拟地址在需要时会被转换为物理地址,如图 10-8 所示,但这种转换的细节对进程是隐藏的。
这种方法的优点是,每个进程都被分配了一个大型、私有的虚拟内存地址范围来使用。通常,系统中的每个进程都会呈现相同的内存地址范围。例如,每个进程可能会被分配 2GB 的虚拟地址空间,从地址 0x0000000 到 0x7FFFFFFF。这看起来可能会有问题;当两个程序尝试使用相同的内存地址时会发生什么?一个程序能否覆盖或读取另一个程序的数据?多亏了虚拟地址,这并不是问题。
多个程序的相同虚拟地址映射到不同的物理地址,因此不会发生一个程序意外访问另一个程序数据的情况。这意味着,在不同进程中,存储在某个虚拟地址处的数据是不同的——虚拟地址可能相同,但存储的数据不同。也就是说,若程序需要共享内存,是有机制支持的。在较旧的操作系统中,内存空间并没有如此清晰的划分,这导致了程序在其他程序甚至操作系统中的内存损坏的机会。幸运的是,所有现代操作系统都确保了进程之间内存的隔离。

图 10-8:每个进程的虚拟地址空间映射到物理内存
重要的是要理解,尽管一个进程的地址范围可能是 2GB(例如),但这并不意味着所有 2GB 的虚拟内存都会立即可供进程使用。只有其中一部分地址由物理内存支持。回想一下你在第八章和第九章中进行的项目;你当时实际上是在检查虚拟内存地址,而不是物理地址。
内核有一个单独的虚拟地址空间,用来处理一个与分配给用户模式进程的地址范围不同的地址范围。与用户模式地址空间不同,内核地址空间由所有在内核模式下运行的代码共享。这意味着任何在内核模式下运行的代码都可以访问内核地址空间中的所有内容。这也使得这些代码有机会修改任何内核内存的内容。这加强了一个观点,即在内核模式下运行的代码必须是值得信任的!
那么虚拟地址空间是如何在用户模式和内核模式之间划分的呢?让我们以 32 位操作系统为例。如第七章所述,对于 32 位系统,内存地址被表示为 32 位数字,这意味着总共 4GB 的地址空间。这个地址空间的地址范围必须在内核模式和用户模式之间进行划分。对于 4GB 的地址空间,Windows 和 Linux 都允许通过配置设置进行 2GB 用户/2GB 内核或 3GB 用户/1GB 内核的划分。图 10-9 展示了虚拟内存的均等 2GB 划分。

图 10-9:32 位系统上虚拟地址空间的 2GB/2GB 均等划分
请记住,我们这里只关注的是虚拟地址。无论物理内存有多少,32 位系统的虚拟地址空间始终为 4GB。假设一台计算机只有 1GB 的 RAM,它在 32 位操作系统下仍然有 4GB 的虚拟地址空间。回想一下,虚拟地址范围并不代表映射的物理内存,它仅仅是物理内存可以被映射的范围。也就是说,内核和所有运行中的进程完全可以请求更多的虚拟内存字节,超过 RAM 的总大小。在这种情况下,操作系统可以将内存字节移动到辅助存储中,以腾出 RAM 空间供新请求的内存使用,这个过程被称为分页。通常,最少使用的内存首先会被分页,以便活动内存可以保持在 RAM 中。当需要分页的内存时,操作系统必须将其重新加载到 RAM 中。分页使得可以使用更大的虚拟内存,但代价是当字节在辅助存储和 RAM 之间移动时,会造成性能上的损失。请记住,辅助存储的速度远远低于 RAM。
注意
请参见项目#22,在第 222 页上,您可以查看虚拟内存的相关内容。
随着 64 位处理器和操作系统的到来,出现了更大地址空间的潜力。如果我们用完整的 64 位表示内存地址,虚拟地址空间将是 32 位地址空间的约 4 billion倍!然而,今天并不需要如此大的地址空间,因此 64 位操作系统使用较少的位数来表示地址。不同的 64 位操作系统在不同的处理器上使用不同的位数来表示地址。64 位 Linux 和 64 位 Windows 都支持 48 位地址,这意味着虚拟地址空间为 256TB,约为 32 位地址空间的 65,000 倍——这对于今天的典型应用来说空间已经足够大了。
应用程序编程接口(API)
当大多数人想到操作系统时,他们会想到用户界面,即 Shell。Shell 是人们看到的部分,它影响人们如何看待系统。例如,Windows 用户通常认为 Windows 就是任务栏、开始菜单、桌面等等。然而,用户界面实际上只是操作系统代码的一小部分,它只是一个界面,系统和用户的接触点。从应用程序(或软件开发人员)的角度来看,与操作系统的交互并不是由 UI 定义的,而是由操作系统的应用程序编程接口(API)定义的。API 不仅仅适用于操作系统;任何希望提供程序化交互方式的软件都可以提供 API,但我们这里的重点是操作系统 API。
操作系统 API 是一种规范,定义在源代码中并在文档中描述,详细说明了程序应该如何与操作系统交互。一个典型的操作系统 API 包括一系列函数(包括其名称、输入和输出)以及与操作系统交互所需的数据结构。操作系统随附的软件库提供了 API 规范的实现。软件开发人员通常将“调用”或“使用”API 作为一种简便的说法,表示他们的代码正在调用 API 中指定的某个函数(并由软件库实现)。
就像用户界面为用户定义了操作系统的“个性”一样,API 为应用程序定义了操作系统的个性。图 10-10 展示了用户和应用程序如何与操作系统交互。

图 10-10:操作系统接口:用户的 UI;应用程序的 API
如 图 10-10 所示,用户通过操作系统用户界面与操作系统进行交互,这就是 Shell。Shell 将用户的命令转化为 API 调用。API 然后调用操作系统的内部代码来执行请求的操作。应用程序不需要通过用户界面;它们只需要直接调用 API。从这个角度来看,Shell 与操作系统的 API 交互方式和其他应用程序一样。
让我们看一个通过 API 与操作系统接口的示例。创建文件是操作系统的常见功能,用户和应用程序都需要进行此操作。图形化 Shell 和命令行 Shell 提供了简单的方式供用户创建文件。然而,应用程序不需要通过图形用户界面(GUI)或命令行界面(CLI)来创建文件。让我们探讨一下应用程序如何通过编程方式创建文件。
对于 Unix 或 Linux 系统,你可以使用一个名为open的 API 函数来创建文件。以下 C 语言示例使用 open 函数创建一个名为 hello.txt 的新文件。O_WRONLY 标志表示只写操作,O_CREAT 表示要创建一个文件。
open("hello.txt", O_WRONLY|O_CREAT);
在 Windows 上,可以使用 CreateFileA API 函数来完成相同的操作:
CreateFileA("hello.txt", GENERIC_WRITE, 0, NULL,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
这两个示例都使用了 C 编程语言。操作系统通常使用 C 语言编写,因此它们的 API 天然适用于 C 程序。在其他语言编写的程序中,当程序运行时,仍然需要调用操作系统的 API,但是该编程语言会将该 API 调用封装在其自身的语法中,隐藏 API 的细节。这使得代码可以跨操作系统移植。即使是 C 语言也这样做,提供了一组可以在任何操作系统上运行的标准库函数。这些函数反过来必须在运行时调用特定操作系统的 API。再考虑一下创建文件的例子;在 C 语言中,我们可以使用 fopen 函数,如以下代码所示。该函数是 C 语言标准库的一部分,适用于任何操作系统。
fopen("hello.txt", "w");
作为另一个示例,我们可以使用以下 Python 代码来创建一个新文件。这段代码可以在任何安装了 Python 解释器的操作系统上运行。Python 解释器会代替应用程序调用适当的操作系统 API。
open('hello.txt', 'w')
对于类 Unix 操作系统,API 会根据具体的 Unix 或 Linux 版本以及内核的版本有所不同。然而,大多数类 Unix 操作系统都遵循一个标准规范,无论是完全遵守还是部分遵守。这个标准被称为可移植操作系统接口(POSIX),它不仅为操作系统 API 提供标准,也为 Shell 的行为和包含的工具提供标准。POSIX 为类 Unix 操作系统提供了基准,但现代类 Unix 操作系统通常有自己的 API。Cocoa是 Apple 为 macOS 提供的 API,iOS 也有一个类似的 API,称为Cocoa Touch。Android 也有自己的编程接口,统称为Android 平台 API。
另一大操作系统家族 Windows 有其自己的 API。Windows API随着时间的发展不断增长和扩展。Windows API 的最初版本是一个 16 位版本,现在被称为Win16。当 Windows 在 1990 年代更新为 32 位操作系统时,发布了 32 位版本的 API,即Win32。现在 Windows 已经是 64 位操作系统,对应的 API 是Win64。微软还在 Windows 10 中推出了一个新的 API,通用 Windows 平台(UWP),旨在使 Windows 上运行的各种类型设备的应用开发保持一致。
注意
请参见项目 #23,位于第 224 页,在那里你可以尝试与 Linux 操作系统 API 互动。
用户模式泡泡与系统调用
如前所述,用户模式下运行的代码对系统的访问权限是有限的。那么,用户模式代码能做什么呢?它可以读写自己的虚拟内存,并可以执行数学和逻辑运算。它可以控制自己代码的程序流。另一方面,运行在用户模式下的代码不能访问物理内存地址,包括用于内存映射 I/O 的地址。这意味着它无法自行将文本打印到控制台窗口,获取键盘输入,绘制屏幕上的图形,播放声音,接收触摸屏输入,通过网络通信,或从硬盘读取文件!我喜欢说“用户模式代码运行在一个泡泡中”(图 10-11)。它无法与外部世界互动,至少没有一些帮助的话是做不到的。另一种说法是,用户模式代码不能直接执行 I/O 操作。实际上,这意味着运行在用户模式下的代码可以做有用的工作,但无法在没有帮助的情况下共享这些工作成果。

图 10-11:一个进程运行在用户模式泡泡中。它可以进行数学运算、执行逻辑、访问虚拟内存并控制程序流,但无法直接与外部世界互动。
你可能会想知道,用户模式应用程序是如何与用户进行交互的。自然,应用程序以某种方式能够与外部世界交互,但这是如何实现的呢?答案是,用户模式代码有一个其他重要的能力:它可以请求内核模式代码代表它执行工作。当用户模式代码请求内核模式代码代表它执行特权操作时,这被称为系统调用,如图 10-12 所示。

图 10-12:用户模式进程可以通过发出系统调用,借助内核与外部世界交互。
例如,如果用户模式代码需要从文件中读取数据,它会发出系统调用,请求内核从某个文件中读取特定的字节。内核与存储设备驱动程序协同工作,执行必要的 I/O 操作以读取文件,然后将请求的数据返回给用户模式进程。这如图 10-13 所示。

图 10-13:内核作为中介,帮助用户模式代码访问硬件资源,如二级存储。
用户模式代码不需要了解任何关于物理存储设备或相关设备驱动程序的信息。内核提供了一个抽象,封装了细节,让用户模式代码可以简单地完成任务。我们之前提到的示例 API 函数open和CreateFileA在幕后就是这样工作的,它们通过系统调用请求特权操作。当然,内核会对允许的操作有所限制。例如,用户模式进程不能读取没有访问权限的文件。
CPU 提供了专门的指令来简化系统调用。在 ARM 处理器上,使用SVC指令(以前称为SWI),它被称为监控调用。在 x86 处理器上,提供了SYSCALL和SYSENTER指令来实现这一目的。Linux 和 Windows 都实现了大量的系统调用,每个调用都有一个唯一的编号。例如,在 Linux 的 ARM 版本中,write系统调用(用于写入文件)的编号是 4。要进行系统调用,程序需要将所需的系统调用编号加载到某个处理器寄存器中,将任何附加参数放入其他特定的寄存器,然后执行系统调用指令。
尽管软件开发人员可以直接在机器代码或汇编语言中进行系统调用,但幸运的是,在大多数情况下并不需要这么做。操作系统和高级编程语言提供了以自然的方式进行系统调用的能力,通常通过操作系统 API 或语言的标准库来实现。程序员只需编写代码来执行某个操作,可能甚至没有意识到在幕后正在进行系统调用。
注意
请参见 项目 #24,位于 第 226 页,你可以在那里观察从程序中发起的系统调用。
API 和系统调用
我们之前讨论了操作系统的 API 主题,并且刚刚看过系统调用。操作系统 API 和系统调用有什么不同?二者是相关的,但并不等同。系统调用定义了一种机制,允许用户模式代码请求内核模式服务。API 描述了应用程序如何与操作系统交互,无论是否调用了内核模式代码。有些 API 函数会发起系统调用,而另一些 API 函数则不需要系统调用。这取决于操作系统的具体实现。
我们先来看一下 Linux。如果我们将 Linux 的定义限制在内核层面,那么可以说 Linux API 实际上是使用 Linux 系统调用的规范,因为系统调用是与内核交互的编程接口。然而,基于 Linux 的操作系统不仅仅是内核。例如,考虑使用 Linux 内核的 Android。Android 有自己的一套编程接口,即 Android 平台 API。
在 Microsoft Windows 中,Windows NT 内核提供了一组系统调用,通过一种称为原生 API 的接口提供。应用程序开发人员很少直接使用原生 API;它主要供操作系统组件使用。相反,开发人员使用 Windows API,它是原生 API 的封装。然而,并非所有 Windows API 函数都需要系统调用。我们来看看 Windows API 的几个例子。Windows API 函数CreateFileW用于创建或打开文件。它是原生 API NtCreateFile 的封装,后者会向内核发起系统调用。相比之下,Windows API 函数PathFindFileNameW(用于在路径中查找文件名)不与原生 API 交互,也不发起任何系统调用。创建文件需要内核的帮助,而在路径字符串中查找文件名只需要虚拟内存访问,这可以在用户模式下完成。
总结一下,操作系统 API 描述了操作系统的编程接口。系统调用提供了一种机制,允许用户模式的代码请求特权的内核模式操作。某些 API 函数依赖于系统调用,而另一些则不依赖。
操作系统软件库
如前所述,操作系统 API 描述了与操作系统交互的程序接口。尽管技术接口描述对程序员有帮助,但当程序运行时,它需要一个具体的方法来调用 API。这是通过软件库来实现的。操作系统的软件库是操作系统随附的代码集合,提供操作系统 API 的实现。也就是说,库包含执行 API 规范中描述的操作的代码。在第九章中,我们讨论了编程语言的库:包括语言的标准库以及由使用该语言的开发者社区维护的附加库。我们在这里讨论的软件库类似;唯一的区别是这些库是操作系统的一部分。
操作系统库类似于可执行程序;它是一个包含机器码字节的文件。然而,它通常没有入口点,因此通常无法独立运行。相反,库导出(使可用)一组可以被程序使用的函数。使用软件库的程序导入该库中的函数,并被称为链接到该库。
操作系统包含一组库文件,导出 API 定义的各种函数。这些函数中的一些只是包装器,立即执行内核系统调用。其他函数则完全在用户模式代码中实现,并包含在库文件本身中。还有一些介于两者之间,在用户模式中实现一些逻辑,同时也进行一个或多个系统调用,如图 10-14 所示。
在典型的 Linux 发行版中,许多可用的 Linux 内核系统调用通过GNU C 库(或glibc)提供。这个库还包括 C 编程语言的标准库,其中包括不需要系统调用的函数。主要的glibc文件通常命名为libc.so.6,其中so表示共享对象,而6表示版本。通过这个库,C 或 C++开发者可以轻松使用 Linux 内核和 C 运行时库提供的功能。鉴于该库在大多数 Linux 发行版中的普遍存在,可以合理地将glibc中的函数视为标准 Linux API 的一部分。

图 10-14:操作系统 API 通过一组库来实现。这些库中的一些函数会调用内核的系统调用;而另一些则不会。用户模式程序与 API 进行交互。
注意
请参阅项目#25 在第 227 页,在这里你可以尝试 GNU C 库。
微软 Windows API 相当广泛,随着时间的推移,它已经包含了许多库。三个基本的 Windows API 库文件是kernel32.dll、user32.dll和gdi32.dll。通过kernel32.dll,NT 内核导出的系统调用可以提供给用户模式程序。通过user32.dll和gdi32.dll,win32k(窗口和图形系统)导出的系统调用可以提供给用户模式程序。
这些文件上的dll扩展名表示它们是动态链接库,类似于 Linux 中的共享对象(.so)文件。也就是说,dll文件扩展名表示该文件包含共享库代码,进程可以加载并运行。文件名中的32后缀是在 16 位到 32 位 Windows 过渡期间添加的。如今,64 位版本的 Windows 仍然在这些文件上保留32后缀,以保持兼容性。实际上,64 位版本的 Windows 包括这两个版本的文件(相同的名称,不同的目录),一个用于 32 位应用程序,一个用于 64 位应用程序。
注意
程序有可能在不通过软件库的情况下调用系统调用。通过设置处理器寄存器中的值并发出特定处理器的指令,例如 ARM 上的SVC或 x86 上的SYSCALL,程序可以直接进行系统调用。然而,这需要用汇编语言进行编程,导致的源代码不能跨处理器架构工作。此外,操作系统的 API 可能包含一些不是通过系统调用实现的函数,因此直接调用系统调用并不能替代操作系统的软件库。
Windows 子系统 for Linux
Linux 内核和 Windows NT 内核暴露不同的系统调用,并且它们的可执行文件存储在不同的格式中,这使得为一个操作系统编译的软件与另一个操作系统不兼容。然而,在 2016 年,微软宣布了Windows 子系统 for Linux (WSL),这是一个 Windows 10 功能,允许许多 64 位 Linux 程序在 Windows 上运行,而无需修改。在 WSL 的第一个版本中,这是通过拦截 Linux 可执行文件发出的系统调用并在 NT 内核中处理它们来实现的。WSL 的第二个版本依赖于一个真实的 Linux 内核来处理系统调用。这个 Linux 内核在虚拟机中运行,并与 NT 内核一起运行。我们将在第十三章中详细介绍虚拟机。
应用程序二进制接口
现在我们已经讨论了应用程序编程接口(API)的概念以及它如何与系统调用和库相关联,接下来让我们来探讨一个相关的概念,即 ABI。应用程序二进制接口(ABI)定义了软件库的机器代码接口。这与 API 相对,API 定义的是源代码接口。一般来说,API 在各种处理器系列之间是一致的,而 ABI 则在处理器系列之间有所不同。开发人员可以编写利用操作系统 API 的代码,然后将代码编译为多种处理器类型。源代码针对的是一个通用的 API,而编译后的代码则针对特定架构的 ABI。
一旦编译,生成的机器代码会遵循目标架构的 ABI。这意味着在执行时,真正定义已编译程序和软件库之间交互的是 ABI,而不是 API。操作系统库所暴露的 ABI 保持一致性非常重要。这种一致性使得较旧的程序能够在操作系统的新版本中继续运行,而无需重新编译。
设备驱动程序
当今的计算机支持各种各样的硬件设备,例如显示器、键盘、摄像头等。这些设备各自实现了输入/输出接口,允许设备与系统的其他部分进行通信。不同类型的设备使用不同的方法进行输入/输出;例如,Wi-Fi 适配器与游戏控制器的需求差异很大。即使是相同类型的设备,也可能实现不同的输入/输出方法。例如,两种不同型号的显卡与系统的其他部分进行通信的方式可能完全不同。与硬件的直接交互仅限于在内核模式下运行的代码,但不可能指望操作系统内核知道如何与每一种设备通信。这就是设备驱动程序的作用。设备驱动程序是与硬件设备交互并为该硬件提供编程接口的软件。
通常,设备驱动程序作为一个内核模块实现,这是一个包含代码的文件,内核可以加载并在内核模式下执行。这是允许驱动程序访问硬件所必需的。因此,设备驱动程序具有广泛的访问权限,类似于内核本身,所以只有受信任的驱动程序应该被安装。内核与设备驱动程序协同工作,代表用户模式下运行的代码与硬件进行交互。这使得硬件交互可以在操作系统或应用程序不了解如何与特定硬件交互的情况下进行。这是一种封装的形式。在某些情况下,驱动程序可以在用户模式下执行(例如使用微软的用户模式驱动程序框架),但这种方法仍然需要内核模式中的某些组件,通常由操作系统提供,用于处理硬件交互。
注意
请参见项目 #26 在第 230 页,在那里你可以查看包括设备驱动程序在内的加载的内核模块,在 Raspberry Pi OS 上。
文件系统
几乎每台计算机都有某种类型的二级存储设备,通常是硬盘驱动器(HDD)或固态硬盘(SSD)。这些设备实际上是可以读取和写入的比特容器,而且即使系统关闭电源,数据依然可以保存。存储设备被划分为称为分区的区域。操作系统实现文件系统,将存储设备上的数据组织成文件和目录。在分区被操作系统使用之前,必须先用特定的文件系统进行格式化。不同的操作系统使用不同的文件系统。Linux 通常使用 ext(扩展)系列文件系统(ext2、ext3、ext4),而 Windows 使用 FAT(文件分配表)和 NTFS(NT 文件系统)。一些操作系统将存储呈现为卷,这是基于一个或多个分区构建的逻辑抽象。在这种系统中,文件系统位于卷上,而不是分区上。
文件是数据的容器,目录(也称为文件夹)是文件或其他目录的容器。文件的内容可以是任何东西;文件中存储的数据结构由写入文件的程序决定。类 Unix 系统将其目录结构组织为统一的目录层次结构。该层次结构从根目录开始,根目录用一个斜杠(/)表示,所有其他目录都是根目录的子目录。例如,库文件存储在/usr/lib中,其中usr是根目录的子目录,lib是usr的子目录。这种统一的层次结构即使在系统中有多个存储设备时也适用。额外的存储设备会映射到目录结构中的某个位置;这被称为挂载设备。例如,一个 USB 驱动器可以挂载到/mnt/usb1。
相比之下,微软 Windows 为每个卷分配一个驱动器字母(A–Z)。因此,与统一的目录结构不同,每个驱动器都有自己的根目录和目录层次结构。Windows 在目录路径中使用反斜杠(\),并在驱动器字母后面加上冒号(:)。例如,存储在 C 驱动器上的 Windows 系统文件通常位于C:\windows\system32目录下。这种约定源于 DOS(以及更早的版本),当时 A 和 B 驱动器被保留给软盘,而 C 驱动器表示内部硬盘。直到今天,C 驱动器通常用作 Windows 安装所在卷的驱动器字母。
注意
请参见项目 #27 在第 230 页,在那里你可以查看 Raspberry Pi OS 上存储和文件的详细信息。
服务与守护进程
操作系统提供了让进程在后台自动运行而无需用户交互的功能。这类进程在 Windows 上称为 services,在类 Unix 系统上称为 daemons。典型的操作系统包括多个默认运行的此类服务,比如用于配置网络设置的服务,或者按计划运行任务的服务。服务用于提供不依赖于特定用户、不需要在内核模式下运行但又需要随时可用的功能。
操作系统通常包括一个负责管理服务的组件。有些服务需要在操作系统启动时启动;其他服务则需要响应特定事件运行。通常在发生意外故障时,服务应重新启动。在 Windows 中,服务控制管理器 (SCM) 执行这些功能。SCM 的可执行文件是 services.exe,它在 Windows 启动过程中很早就开始运行,并在 Windows 运行时继续运行。许多现代 Linux 发行版已经将 systemd 作为管理守护进程的标准组件,尽管 Linux 中也可以使用其他机制来启动和管理守护进程。如前所述,systemd 还充当初始化进程,因此它在 Linux 启动过程中非常早就开始运行,并在系统运行时继续执行。
Unix 和 Linux 中的术语 daemon 源自物理学思想实验中描述的假想生物 Maxwell's demon。这个生物像计算机的守护进程一样在后台工作。在计算机之外,daemon 通常发音为“demon”,但在指代后台进程时,“DAY-mon”也是一种可以接受的发音。历史上,service 是一个特定于 Windows 的术语,但现在它在 Linux 上也被使用,通常指由 systemd 启动的守护进程。
注意
请参阅 第 28 号项目,以及 第 231 页,你可以在这里查看 Raspberry Pi OS 上的服务。
安全性
操作系统为在其上运行的代码提供安全模型。在此背景下,安全性 意味着软件及其用户应该只访问系统的适当部分。这对像笔记本电脑或智能手机这样的个人设备来说,可能看起来不是什么大问题。如果只有一个用户登录系统,难道他们不应该能够访问所有内容吗?嗯,不,至少默认情况下不是这样。用户会犯错误,包括运行不可信的代码。如果用户不小心在设备上运行了恶意软件,操作系统可以通过限制该用户的访问权限来帮助减少损害。在共享系统上,多个用户登录时,用户不应该能够读取或修改另一个用户的数据,至少默认情况下不应该。
操作系统使用多种技术来提供安全性。这里我们仅介绍其中一些。简单地将应用程序放入用户模式的隔离环境中,能够大大确保软件不会故意或无意地干扰其他应用程序或内核。操作系统还实现了文件系统安全,确保存储在文件中的数据只能被合适的用户和进程访问。虚拟内存本身也可以被保护——内存区域可以被标记为只读或可执行,从而帮助限制内存的滥用。为用户提供登录系统,使操作系统能够根据用户身份管理安全性。这些都是现代操作系统的基本安全要求。不幸的是,操作系统中经常会发现安全漏洞,允许恶意行为绕过操作系统的防御机制。保持现代互联网连接操作系统的最新更新对于确保安全至关重要。
总结
在本章中,我们讨论了操作系统,操作系统是与计算机硬件通信并为程序执行提供环境的软件。你了解了操作系统内核、非内核组件以及内核模式与用户模式的分离。我们回顾了两大主流操作系统家族:类 Unix 操作系统和 Microsoft Windows。你了解到,程序在一个名为进程的容器中运行,并且多个线程可以在该进程内并行执行。我们查看了与操作系统编程交互的各个方面:API、系统调用、软件库和 ABI。在下一章中,我们将超越单设备计算,研究互联网,看看使互联网成为可能的各种层次和协议。
项目 #20:检查正在运行的进程
前提条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。如果你还没有阅读过,建议翻到附录 B 并阅读整个“Raspberry Pi”部分。
在这个项目中,你将查看 Raspberry Pi 上运行的进程。ps 工具提供了多种查看运行中进程的方式。让我们从以下命令开始,它提供了进程的树形视图。
$ ps -eH
输出应该类似于以下文本。我这里只复制了一部分。
1 ? 00:00:10 systemd
93 ? 00:00:09 systemd-journal
133 ? 00:00:01 systemd-udevd
233 ? 00:00:01 systemd-timesyn
274 ? 00:00:02 thd
275 ? 00:00:01 cron
276 ? 00:00:00 dbus-daemon
286 ? 00:00:03 rsyslogd
287 ? 00:00:01 systemd-logind
291 ? 00:00:08 avahi-daemon
296 ? 00:00:00 avahi-daemon
297 ? 00:00:01 dhcpcd
351 tty1 00:00:00 agetty
352 ? 00:00:00 agetty
358 ? 00:00:00 sshd
5016 ? 00:00:00 sshd
5033 ? 00:00:00 sshd
5036 pts/0 00:00:00 bash
5178 pts/0 00:00:00 ps
缩进级别表示父子关系。例如,在上述输出中,我们可以看到 systemd 是 systemd-journal、systemd-udevd 等的父进程。或者反过来,我们可以看到 ps(当前运行的命令)是 bash 的子进程,而 bash 是 sshd 的子进程,依此类推。
显示的列如下:
PID 进程 ID
TTY 关联的终端
TIME 累计 CPU 时间
CMD 可执行文件名称
当你以这种方式运行ps时,正在运行的进程数量可能会让你感到惊讶!操作系统处理了很多事情,因此在任何给定时刻,运行大量进程是很正常的。通常,你会看到列出的第一个进程是 PID 2,kthreadd。这是内核线程的父进程,你在kthreadd下看到的子进程是运行在内核模式中的线程。另一个需要注意的进程是 PID 1,初始化进程,第一个启动的用户模式进程。在之前的输出中,初始化进程是systemd。Linux 内核按照顺序启动初始化进程和kthreadd,确保它们分别被分配 PID 1 和 2。
让我们来看一下初始化过程。这是第一个启动的用户模式进程,具体执行的可执行文件在不同版本的 Linux 中可能有所不同。你可以使用ps命令查找用于启动 PID 1 的命令:
$ ps 1
你应该看到如下的输出:
PID TTY STAT TIME COMMAND
1 ? Ss 0:03 /sbin/init
这告诉你,启动初始化进程的命令是/sbin/init。那么,运行/sbin/init如何导致systemd执行,就像你在之前的ps输出中看到的那样?这是因为/sbin/init实际上是指向systemd的符号链接。符号链接引用另一个文件或目录。你可以使用以下命令看到这一点:
$ stat /sbin/init
File: /sbin/init -> /lib/systemd/systemd
Size: 20 Blocks: 0 IO Block: 4096 symbolic link
在这个输出中,你可以看到/sbin/init是指向/lib/systemd/systemd的符号链接。
另一种方便查看进程树的方法是使用pstree工具,正如本章前面提到的那样。运行pstree会呈现一个格式化良好的用户模式进程树,从初始化进程开始。试试看:
$ pstree
或者,如果你的树莓派配置为启动到桌面环境,你可能还想尝试使用树莓派操作系统中包含的任务管理器应用程序。它提供了一个正在运行的进程的图形化视图,如图 10-15 所示。

图 10-15:树莓派操作系统中的任务管理器
项目 #21:创建一个线程并观察它
前提条件:一台运行树莓派操作系统的树莓派。请参见第 341 页中的“树莓派”。
在这个项目中,你将编写一个创建线程的程序。然后,你将观察线程的运行。使用你选择的文本编辑器,在你的主文件夹的根目录下创建一个名为threader.c的新文件。将以下 C 代码输入到你的文本编辑器中(你不必保留缩进和空行,但要确保保留换行符)。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>
void * mythread(void* arg)❶
{
while(1)❷
{
printf("mythread PID: %d\n", (int)getpid());❸
printf("mythread TID: %d\n", (int)syscall(SYS_gettid));
sleep(5);❹
}
}
int main()❺
{
pthread_t thread;
pthread_create(&thread, NULL, &mythread, NULL);❻
while(1)❼
{
printf("main PID: %d\n", (int)getpid());❽
printf("main TID: %d\n", (int)syscall(SYS_gettid));
sleep(10);❾
}
return 0;
}
在继续之前,我们来看看源代码。我在这里不会讲解所有细节,但总结来说,程序从main函数❺开始,创建一个运行mythread函数❶的线程❻。这意味着有两个线程,main线程和mythread线程。两个线程都在无限循环中运行❷❼,并且时不时地打印当前线程的 PID 和 TID❸❽。为了变化,mythread大约每 5 秒打印一次❹,而main大约每 10 秒打印一次❾。这有助于说明两个线程实际上是并行运行的,并且各自按自己的节奏工作。我们来试试看。
文件保存后,使用 GNU C 编译器(gcc)将代码编译成可执行文件。以下命令将threader.c作为输入,输出一个名为threader的可执行文件。
$ gcc -pthread -o threader threader.c
现在尝试使用以下命令运行代码:
$ ./threader
运行中的程序应该输出类似以下内容,尽管 PID 和 TID 数字会有所不同:
main PID: 2300
main TID: 2300
mythread PID: 2300
mythread TID: 2301
随着程序的运行,预期两个线程会继续打印它们的 PID 和 TID 信息。由于这是同一个进程和线程在整个时间内运行,所以 TID 和 PID 数字不会改变。你应该看到mythread打印的频率是main的两倍——每 5 秒一次,而main每 10 秒打印一次。
保持该程序运行,并查看正在运行的进程和线程列表。为此,你需要打开第二个终端窗口并运行以下命令(|符号可以通过按 SHIFT 和反斜杠键(位于 ENTER 键上方)来输入,在美国键盘上)。
$ ps -e -T | grep threader
2300 2300 pts/0 00:00:00 threader
2300 2301 pts/0 00:00:00 threader
在ps命令中添加T选项,可以同时显示线程和进程。grep命令用于过滤输出,只显示threader进程的信息。在这个输出中,第一列是 PID,第二列是 TID。所以你可以看到,ps的输出与程序输出匹配。两个线程共享 PID,但有不同的 TID。还要注意,main线程的 TID 与其 PID 相同。这是进程中第一个线程的预期行为。
要停止线程程序的执行,可以在其运行的终端窗口按 CTRL-C,或者从第二个终端窗口使用kill命令,指定主线程的 PID,如下所示:
$ kill 2300
项目 #22:检查虚拟内存
前提条件:一台运行 Raspberry Pi OS 的树莓派。请参见第 341 页的“树莓派”。
在本项目中,你将研究树莓派操作系统上的虚拟内存使用情况。让我们从查看地址空间是如何在内核模式和用户模式之间分配开始。本项目假设你正在运行 32 位版本的树莓派操作系统,这意味着虚拟地址空间为 4GB。Linux 允许将这 4GB 的内存划分为 2GB 的用户空间和 2GB 的内核空间,或者 3GB 的用户空间和 1GB 的内核空间。较低的地址用于用户模式,较高的地址用于内核模式。这意味着,在 2:2 划分下,内核模式地址从0x80000000开始,而在 3:1 划分下,内核模式地址从0xC0000000开始。你可以使用以下命令查看内核模式地址空间的起始位置:
$ dmesg | grep lowmem
如果dmesg命令没有任何输出,只需重新启动你的树莓派,然后再次运行dmesg命令。该命令应该会产生类似以下的输出。
lowmem : 0x80000000 - 0xbb400000 ( 948 MB)
如果你在疑惑为什么如果该命令返回为空需要重启树莓派,以下是一些背景信息。Linux 内核将诊断信息写入一个叫做内核环形缓冲区的地方,dmesg工具会显示该缓冲区中的信息。这些信息旨在让用户了解内核的工作原理。这里只存储有限数量的消息;当有新消息添加时,旧的消息会被删除。我们想要查看的特定信息(关于lowmem)是在系统启动时写入的,因此如果你的系统已经运行了一段时间,这条信息可能已经被覆盖。重启系统确保该消息会重新写入。
如你所见,在我的系统中,内核lowmem从0x80000000开始,表示使用了 2:2 的内存划分方式。这意味着用户模式进程可以使用地址0x00000000到0x7fffffff之间的地址。这一地址范围可以引用 2GB 的内存,尽管整个地址空间对每个进程都是可用的,但典型进程只需要使用这一范围中的一部分。某些地址被映射到物理内存,但其他地址则保持未映射状态。
如果你的系统在lowmem的起始值返回0xc0000000,那么你的系统正在使用 3:1 的内存分配方式。这将为用户模式进程提供 3GB 的虚拟地址空间,从0x00000000到0xbfffffff。
让我们选择一个进程并检查它的虚拟内存使用情况。树莓派操作系统使用 Bash 作为默认的 Shell 进程,因此,如果你在树莓派操作系统的命令行工作,至少会有一个bash实例在运行。让我们找到一个bash实例的 PID:
$ ps | grep bash
这将输出类似以下的文本:
2670 pts/0 00:00:00 bash
在我的案例中,bash的 PID 是2670。现在,运行以下命令查看bash进程中的虚拟内存映射。输入命令时,请确保将*<pid>*替换为你系统上返回的 PID。
$ pmap <pid>
输出将类似以下内容,其中每一行代表进程地址空间中的一个虚拟内存区域。
2670: -bash
00010000 872K r-x-- bash
000f9000 4K r---- bash
000fa000 20K rw--- bash
000ff000 36K rw--- [ anon ]
00ee7000 1044K rw--- [ anon ]
76b30000 36K r-x-- libnss_files-2.24.so
76b39000 60K ----- libnss_files-2.24.so
76b48000 4K r---- libnss_files-2.24.so
76b49000 4K rw--- libnss_files-2.24.so
...
7ec2c000 132K rw--- [ stack ]
7ec74000 4K r-x-- [ anon ]
7ec75000 4K r---- [ anon ]
7ec76000 4K r-x-- [ anon ]
ffff0000 4K r-x-- [ anon ]
total 6052K
第一列是区域的起始地址,第二列是区域的大小,第三列表示区域的权限(r = 读,w = 写,x = 执行,p = 私有,s = 共享),最后一列是区域名称。区域名称要么是文件名,要么是标识内存区域的名称(如果它不是从文件映射的)。
你可以看到输出中的几乎所有区域都在期望的用户模式范围0x00000000到0x7fffffff内。唯一的例外是最后一项,它对应于 ARM CPU 向量页面,并表示一个特殊情况,因为它位于标准用户模式地址范围之外。如前面的输出所示,这个特定实例的 bash 仅映射了6052K(大约 6MB)的虚拟内存,总共可用 2GB,大约为 0.3%。
项目 #23:尝试操作系统 API
前提条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。
在这个项目中,你将尝试以不同方式调用操作系统 API。你将特别关注创建一个文件并向其写入一些文本。使用你喜欢的文本编辑器,在你的主文件夹根目录下创建一个名为newfile.c的文件,并在其中输入以下 C 代码。
#include <fcntl.h>
#include <unistd.h>
#define msg "Hello, file!\n"❶
int main()❷
{
int fd;❸
fd = open("file1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);❹
write(fd, msg, sizeof(msg) - 1);❺
close(fd);❻
return 0;❼
}
在继续之前,让我们检查一下源代码,确切了解它的功能。简而言之,程序使用三个 API 函数:open、write和close,来创建一个新文件、向其中写入一些文本,并最终关闭文件。我们在这里的重点是查看操作系统的 API 如何允许程序与计算机的硬件交互,特别是存储设备。让我们更详细地了解一下程序。
在必要的包含语句之后,下一行将msg定义为一个文本字符串 ❶,稍后将写入新创建的文件。接下来,代码定义了main,程序的入口点 ❷。在main中,声明了一个名为fd的整数 ❸。然后,调用操作系统 API 的open函数来创建一个名为file1.txt的新文件 ❹。传递给open函数的其他参数指定了文件打开的详细信息。为了简便起见,我这里不涉及这些细节,但你可以自由研究这些参数的含义。open函数返回一个文件描述符,该描述符保存在fd变量中。
接下来,write函数被用来将msg文本写入file1.txt(由存储在fd中的文件描述符标识) ❺。write函数需要输入写入的数据(msg)和写入的字节数,字节数由sizeof(msg) - 1决定。你要减去 1,因为 C 语言使用空字符终止字符串,而你不需要将那个字节写入输出文件。程序现在已完成文件操作,并调用close函数关闭文件描述符,表示文件不再使用 ❻。最后,程序以返回代码 0 退出 ❼,表示成功。
一旦文件保存好,使用 GNU C 编译器(gcc)将代码编译成可执行文件。下面的命令以 newfile.c 为输入,生成一个名为 newfile 的可执行文件。
$ gcc -o newfile newfile.c
现在尝试使用以下命令运行代码。你不会看到任何输出,因为文本被写入了文件,而不是终端。
$ ./newfile
要确定程序是否成功运行,你需要检查是否创建了一个文件。文件应该被命名为 file1.txt 并存在于你当前的目录中。你可以使用 ls 命令列出当前目录的内容,并查找该文件。假设 file1.txt 存在,你可以使用 cat 命令查看文件内容。
$ ls
$ cat file1.txt
该命令应打印 Hello, file! 到终端,因为这就是程序写入文件的文本。或者,你也可以在 Raspberry Pi OS 桌面的文件管理器应用中查看文件属性,打开 file1.txt 文件并用你喜欢的文本编辑器查看。
当你使用 C 编程语言时,你会看到操作系统 API 函数的具体实现,因为 open、write 和 close 被定义为 C 函数。然而,和操作系统交互时你并不仅限于使用 C 语言。其他语言提供了自己的一层封装,隐藏了部分复杂性,方便软件开发者使用。为了说明这一点,让我们用 Python 编写一个等效的程序。
使用你喜欢的文本编辑器,在主文件夹的根目录创建一个名为 newfile.py 的文件。将以下 Python 代码输入到你的文本编辑器中。
f = open('file2.txt', 'w')❶
f.write('Hello from Python!\n');❷
f.close()
在继续之前,我们先来看看源代码。这个程序的功能和之前的程序几乎一样,唯一的不同是输出的文件名不同(file2.txt)❶,以及写入文件的文本也有所不同❷。在这个例子中,Python 恰好使用了与操作系统 API 相同的名称(open、write、close),但这些并不是直接的操作系统调用,而是调用了 Python 标准库中的函数。
一旦你保存了这段代码,就可以运行它。记住,Python 是一种解释型语言,所以你不需要编译 Python 代码,只需使用 Python 解释器运行它,如下所示:
$ python3 newfile.py
要确定程序是否成功运行,你需要检查 file2.txt 是否被创建并包含预期的内容。你可以再次使用 ls 和 cat 命令来验证,或者直接在桌面文件管理器中查看文件。
$ ls
$ cat file2.txt
尽管看起来你只是利用 Python 的能力来操作文件,但请记住,Python 无法独立完成这项工作。当你运行代码时,Python 解释器实际上是在代表你进行系统 API 调用。你将在下一个项目中观察到这一点。
项目 #24:观察系统调用
前提条件:完成 项目 #23。
在这个项目中,你将观察到你在 项目 #23 中编写的程序所做的系统调用。为此,你将使用一个叫做 strace 的工具,它可以追踪系统调用并将输出打印到终端。
在您的树莓派上打开一个终端,并使用strace运行您之前编写并编译的newfile程序:
$ strace ./newfile
strace工具启动一个程序(在本例中为newfile),并显示该程序运行时所做的所有系统调用。在输出的开头,您可以看到表示加载可执行文件和所需库的多个系统调用。这是代码运行之前所发生的工作;您可以跳过这些文本。在输出的末尾,您应该会看到类似以下的文本:
openat(AT_FDCWD, "file1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 3
write(3, "Hello, file!\n", 13) = 13
close(3) = 0
这应该看起来很熟悉;它几乎与您用于创建file1.txt并向其写入文本的那三个 API 函数相同。您从程序中调用的 C 函数只是围绕同名的系统调用的薄包装器,除了open,它调用的是openat系统调用。等号后面的值是三个系统调用的返回值。在我的系统中,openat函数返回了3,这是一个称为文件描述符的数字,用来引用已打开的文件。您可以看到文件描述符值作为参数传递给随后的write和close调用。write函数返回13,表示写入的字节数。close函数返回0,表示成功。
现在使用相同的方法来检查您在项目 #23 中编写的 Python 程序所做的系统调用。
$ strace python3 newfile.py
预计这里会看到更多的输出,因为strace实际上在监控 Python 解释器,而 Python 解释器必须加载newfile.py并运行它。如果您查看输出的末尾,应该会看到对openat、write和close的调用,就像在 C 程序中一样。这表明,尽管 C 和 Python 的源代码存在差异,但最终为了与文件交互,调用的仍然是相同的系统调用。
strace工具可以快速了解一个程序如何与操作系统交互。例如,在本章前面,我们使用了ps工具来获取进程列表。如果您想了解ps是如何工作的,您可以在strace下运行ps,就像这样:
$ strace ps
查看此命令的输出,以查看ps执行了哪些系统调用。
项目 #25:使用 GLIBC
先决条件:一台运行树莓派操作系统的树莓派。
在这个项目中,您将编写代码使用 C 库并检查其工作原理的详细信息。使用您选择的文本编辑器在主文件夹根目录中创建一个名为random.c的新文件。将以下 C 代码输入到您的文本编辑器中。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
srand(time(0));❶
printf("%d\n", rand());❷
return 0;
}
这个小程序只是简单地将一个随机整数值打印到终端。程序首先调用 srand 函数来为随机数生成器设定种子❶,这是确保生成唯一数字序列的必要步骤。当前时间(由 time 函数返回)被用作种子值。接下来的一行打印出由 rand 函数返回的随机值❷。为了完成这些操作,程序使用了 C 库中的四个函数(time、srand、rand 和 printf)。
一旦文件保存,你可以使用 GNU C 编译器(gcc)将代码编译成可执行文件。以下命令以 random.c 作为输入,输出一个名为 random 的可执行文件。
$ gcc -o random random.c
现在尝试使用以下命令运行代码。程序应该输出一个随机数。多次运行它来确认输出的是不同的数字。然而,快速运行两次可能会产生相同的结果,因为从 time 函数返回的种子值每秒才会增加一次。
$ ./random
一旦你确保程序正常工作,查看程序导入的库。可以通过运行 readelf 工具来实现,像这样:
$ readelf -d random | grep NEEDED
查找输出中的 NEEDED 部分,如下所示:
0x00000001 (NEEDED) Shared library: [libc.so.6]
这告诉你,程序运行需要 libc.so.6 库。这是预期的,因为这是 GNU C 库(也称为glibc)。换句话说,由于程序依赖于 C 标准库中的函数,操作系统必须加载 libc.so.6 库文件,以便提供库代码。这是一个好的开始,但如果你想查看 random 程序从这个库中使用的具体函数列表怎么办?你可以通过以下方式查看:
$ objdump -TC random
这会给你如下的输出:
random: file format elf32-littlearm
DYNAMIC SYMBOL TABLE:
00000000 w D *UND* 00000000 __gmon_start__
00000000 DF *UND* 00000000 GLIBC_2.4 srand
00000000 DF *UND* 00000000 GLIBC_2.4 rand
00000000 DF *UND* 00000000 GLIBC_2.4 printf
00000000 DF *UND* 00000000 GLIBC_2.4 time
00000000 DF *UND* 00000000 GLIBC_2.4 abort
00000000 DF *UND* 00000000 GLIBC_2.4 __libc_start_main
在前面的输出中,在最右侧的列中,你可以看到预期的四个函数(srand、rand、printf 和 time)以及一些额外的函数。
现在你已经确定了 glibc 函数被 random 程序导入,可能你想查看 glibc 导出的所有函数列表。这些是该库为程序提供的可用函数。你可以通过以下命令获取这个信息:
$ objdump -TC /lib/arm-linux-gnueabihf/libc.so.6
有时候在调试运行中的进程时,查看已加载库的信息是很有用的。让我们通过调试 random 程序来试试。首先,输入以下命令:
$ gdb random
此时 gdb 已经加载了文件,但还没有运行任何指令。从 (gdb) 提示符下,输入以下命令以开始运行程序。调试器将在到达 main 函数的开头时暂停执行。
(gdb) start
查看已加载的共享库:
(gdb) info sharedlibrary
From To Syms Read Shared Object Library
0x76fcea30 0x76fea150 Yes /lib/ld-linux-armhf.so.3❶
0x76fb93ac 0x76fbc300 Yes (*) /usr/lib/arm-linux-gnueabihf/libarmmem-v71.so❷
0x76e6e050 0x76f702b4 Yes /lib/arm-linux-gnueabihf/libc.so.6❸
(*): Shared library is missing debugging information.
第一个库 ld-linux-armhf.so.3 ❶ 是 Linux 动态链接器库。它负责加载其他库。Linux ELF 二进制文件被编译成使用特定的链接器库;此信息包含在已编译程序的 ELF 头中。你可以使用以下命令在终端窗口(不是在 gdb 中)中查找 random 程序的链接器库:
$ readelf -l random | grep interpreter
[Requesting program interpreter: /lib/ld-linux-armhf.so.3]
如你在前面的输出中所见,random 程序指定的链接器库是 ld-linux-armhf.so.3,这正是我们刚才讨论过的动态链接器库。
回到 gdb 中的 info sharedlibrary 输出,你可以看到第二个列出的库是 libarmmem-v71.so ❷。这个库在文件 /etc/ld.so.preload 中指定,这是一个列出了每个执行的程序需要加载的库的文本文件。
现在进入第三个库,即我们关注的库 libc.so.6 ❸,GNU C 库(glibc)。在之前的 readelf 和 objdump 输出中,你看到该库已被可执行文件导入,在这里你可以看到它确实在运行时成功加载。你还可以看到它加载的具体地址范围(0x76e6e050 到 0x76f702b4),以及加载的具体目录路径。
你可以随时通过在 gdb 中输入 quit 来退出调试器。
项目 #26:查看已加载的内核模块
前提条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。
在这个项目中,你将查看 Raspberry Pi OS 上加载的内核模块,包括设备驱动程序。设备驱动程序通常作为内核模块在 Linux 上实现,尽管并非所有内核模块都是设备驱动程序。要列出已加载的模块,你可以检查 /proc/modules 文件的内容,或者像这样使用 lsmod 工具:
$ lsmod
要查看某个特定模块的更多细节,使用 modinfo 工具,如下所示(以 snd 模块为例):
$ modinfo snd
项目 #27:检查存储设备和文件系统
前提条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。
在这个项目中,你将了解存储设备和文件系统。我们首先列出块设备,这是 Linux 对存储设备的描述方式。
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
mmcblk0 179:0 0 29.8G 0 disk❶
|−mmcblk0p1 179:1 0 256M 0 part /boot❷
|_mmcblk0p2 179:2 0 29.6G 0 part /❸
在这里我们看到一个名为 mmcblk0 ❶ 的单一“磁盘”,它是 Raspberry Pi 中的 microSD 卡。你可以看到它被分成两个大小不一的分区。分区 1 被映射到统一目录结构中的 /boot 目录 ❷,而分区 2 被映射到根目录(/) ❸。
现在使用 df 命令查看存储设备的整体使用情况:
$ df -h -T
Filesystem Type Size Used Avail Use% Mounted on
/dev/root ext4 30G 3.0G 25G 11% /❶
devtmpfs devtmpfs 459M 0 459M 0% /dev
tmpfs tmpfs 464M 0 464M 0% /dev/shm
tmpfs tmpfs 464M 6.3M 457M 2% /run
tmpfs tmpfs 5.0M 4.0K 5.0M 1% /run/lock
tmpfs tmpfs 464M 0 464M 0% /sys/fs/cgroup
/dev/mmcblk0p1 vfat 253M 52M 202M 21% /boot❷
tmpfs tmpfs 93M 0 93M 0% /run/user/1000
这个命令让你查看各种已挂载的文件系统、它们的大小以及占用情况。只有 root ❶ 和 /boot ❷ 目录被映射到存储设备。其他的是驻留在内存中的临时文件系统,而非持久存储设备。
你可以通过运行tree命令查看系统上的目录。这里使用的参数限制了输出仅显示目录,并且目录层级最多为三层。
$ tree -d -L 3 /
你也可以通过桌面环境中的文件管理器应用程序查看类似的视图。
项目 #28: 查看服务
先决条件:一台运行 Raspberry Pi OS 的树莓派。
在这个项目中,你将查看服务/守护进程。Raspberry Pi OS 使用systemd初始化系统,它包括一个名为systemctl的工具,你可以用它来查看服务的状态:
$ systemctl list-units --type=service --state=running
这将生成类似以下的输出:
UNIT LOAD ACTIVE SUB DESCRIPTION
avahi-daemon.service loaded active running Avahi mDNS/DNS-SD Stack
bluealsa.service loaded active running BluezALSA proxy
bluetooth.service loaded active running Bluetooth service
cron.service loaded active running Regular background ...
dbus.service loaded active running D-Bus System Message Bus
dhcpcd.service loaded active running dhcpcd on all interfaces
getty@tty1.service loaded active running Getty on tty1
hciuart.service loaded active running Configure Bluetooth Modems ...
rsyslog.service loaded active running System Logging Service
ssh.service loaded active running OpenBSD Secure Shell server
systemd-journald.service loaded active running Journal Service
systemd-logind.service loaded active running Login Service
systemd-timesyncd.service loaded active running Network Time Synchronization
systemd-udevd.service loaded active running udev Kernel Device Manager
triggerhappy.service loaded active running triggerhappy global hotkey daemon
user@1000.service loaded active running User Manager for UID 1000
如果你没有自动返回到终端提示符,可以在终端中按 Q 退出服务视图。要查看特定服务的详细信息,可以尝试使用以下命令,以cron.service为例:
$ systemctl status cron.service
该命令的输出包括与服务关联的进程的路径和 PID。以cron.service为例,我系统上的路径是/usr/sbin/cron,并且它的 PID 恰好是 367。
查看守护进程的另一种方法是查看所有systemd的子进程,即 PID 1。这很重要,因为服务是由systemd启动的,作为 PID 1 的子进程出现。注意,这个输出可能不仅仅包括服务/守护进程,因为孤立进程会被 PID 1 收养。
$ ps --ppid 1
第十一章:互联网

到目前为止,我们关注的是单一设备上的计算。在本章和下一章中,我们将讨论跨多个设备的计算。我们将研究计算领域中的两项重要创新——互联网和万维网,它们是不同的!本章主要聚焦于互联网,我们首先定义一些关键术语。然后我们将介绍网络的分层模型,并深入探讨互联网使用的一些基础协议。
网络术语定义
要讨论互联网和网络,首先需要了解一些概念和术语,这里我们将进行介绍。一个计算机 网络是一个允许计算设备相互通信的系统,如图 11-1 所示。网络可以通过无线方式连接,使用诸如 Wi-Fi 等技术,它通过无线电波传输数据。网络也可以通过电缆连接,如铜线或光纤。网络上的计算设备必须使用一个共同的通信协议,即一套描述信息如何交换的规则。

图 11-1:计算机网络
互联网是一个全球互联的计算机网络系统,所有网络都使用一套共同的协议。互联网是一个网络的网络,连接了来自世界各地各种组织的网络,如图 11-2 所示。

图 11-2:互联网:网络的网络
一个主机或节点是连接到网络的单个计算设备。主机可以作为网络上的服务器或客户端,或有时两者兼具。网络服务器是一个主机,它监听入站的网络连接并为其他主机提供服务。例子包括网页服务器和电子邮件服务器。网络客户端是一个主机,它发起出站连接并从网络服务器请求服务。客户端的例子包括运行网页浏览器或电子邮件应用程序的智能手机或笔记本电脑。客户端向服务器发出请求,服务器则通过响应进行回复,如图 11-3 所示。

图 11-3:客户端向服务器发出请求,服务器作出响应
如上所述,服务器是指任何接受入站请求并为客户端提供服务的设备。然而,服务器也可以指一类专门设计用于作为网络服务器的计算机硬件。这些专用计算机通常被设计成可以安装在数据中心的计算机机架中,并且通常包含典型个人电脑中没有的硬件冗余和管理能力。然而,任何具备正确软件的设备都可以作为网络上的服务器。
互联网协议套件
仅仅将世界各地的网络物理连接起来并不足以让这些网络上的设备相互通信。所有参与的计算机需要以相同的方式进行通信。互联网协议套件标准化了互联网中的通信方式,确保网络上的所有设备使用相同的语言进行交流。互联网协议套件中的两个基础协议是传输控制协议(TCP)和互联网协议(IP),统称为TCP/IP。
网络协议在分层模型中运行,而这种模型的实现被称为网络栈(与第九章中讨论的内存栈不同)。最底层的协议与底层网络硬件进行交互,而应用程序则与上层的协议进行交互。中间层的协议提供诸如寻址和数据可靠传输等服务。某一层的协议不需要关心整个网络栈,只需要关心与其交互的层,从而简化了整体设计。这是封装的另一个例子。
互联网协议套件是围绕四层模型设计的。这有时被称为TCP/IP 模型。TCP/IP 模型的四层,从下到上依次是链路层、互联网层、传输层和应用层,如图 11-4 所示。

图 11-4:互联网协议套件的网络模型
OSI—另一个网络模型
另一个常用的网络协议模型是开放系统互联(OSI)模型。OSI 模型将协议划分为七层,而不是四层。这个模型在技术文献中经常被提及,但互联网是基于互联网协议套件的,因此本书将重点讨论 TCP/IP 模型。
这些网络层代表了一种抽象,是我们讨论互联网运作时使用的模型。在实际操作中,每一层都是通过特定的网络协议来实现的。每一层网络代表了一定的责任范围,协议必须履行其分配的层级责任。表 11-1 提供了每一层的描述。
表 11-1: 互联网协议套件四层的描述
| 层级 | 描述 | 示例协议 |
|---|---|---|
| 应用层 | 在应用层运行的协议提供特定应用功能,如发送电子邮件或检索网页。这些协议完成终端用户(或后端服务)希望完成的任务。应用层协议构造了用于网络中进程对进程通信的数据。所有较低层的协议作为“管道”来支持应用层。 | HTTP, SSH |
| 传输层 | 传输层协议为应用提供了在主机之间发送和接收数据的通信通道。应用根据应用层协议构造数据,然后将这些数据交给传输层协议,送往远程主机。 | TCP, UDP |
| 网络层 | 网络层协议提供了一种跨网络通信的机制。该层负责通过地址标识主机,并使数据能够在互联网各网络之间进行路由。传输层依赖于网络层进行地址分配和路由。 | IP |
| 链路层 | 链路层协议提供了一种在本地网络上进行通信的方式。该层的协议与本地网络上的网络硬件类型(如 Wi-Fi)紧密相关。网络层协议依赖链路层协议在本地网络上进行通信。 | Wi-Fi, Ethernet |
每一层的协议与相邻层的协议进行通信。来自主机的外发传输通过网络层逐层传递,从应用层协议,到传输层协议,再到网络层协议,最后到链路层协议。主机接收的传输则从链路层协议开始,逐层向上传递,按照刚才描述的顺序逆向进行。
尽管网络主机(如客户端或服务器)会使用四层协议,但其他类型的网络硬件(如交换机和路由器)只使用与低层协议相关的协议。这些设备可以执行其工作,而无需查看网络传输中包含的高层协议数据。
来自客户端到服务器的请求以及其与网络层的关系,如图 11-5 所示。

图 11-5:网络请求穿越各网络层
让我们一步步了解图 11-5 的流程。客户端设备上的应用通过应用层协议生成一个请求。这个请求被交给传输层协议,再传递到网络层协议,最后到链路层协议。所有这些操作都在客户端设备上完成。此时,请求被传输到本地网络,如图中标示的网络 1。请求跨越互联网,依次通过各个网络。在此例中,路由器 A 将请求从网络 1 路由到网络 2,路由器 B 将请求从网络 2 路由到网络 3。请求到达目标服务器后,会通过各网络协议向上传递,从链路层协议开始,到达应用层协议。服务器上运行的进程接收该请求,该请求按照客户端原本使用的应用层协议格式化。服务器进程解析请求并做出适当的响应。
现在,让我们从底层开始,逐层查看。
链路层
互联网协议栈的最低层是链路层。主机之间的物理和逻辑连接被称为网络链路。链路层协议被同一网络上的设备用来相互通信。链路上的每个设备都有一个唯一标识其身份的网络地址。对于许多链路层协议,这个地址被称为媒体访问控制地址(或MAC 地址)。链路层数据被分成称为帧的小单元,每个帧包括一个描述帧的头部、一个数据负载和最后用于检测错误的帧尾。具体内容如图 11-6 所示。

图 11-6:链路层帧
帧头包含源和目的 MAC 地址。头部还包括一个描述帧数据部分所承载数据类型的说明。
如果你的家里有 Wi-Fi 网络,Wi-Fi 就是你网络上主机之间的链路。Wi-Fi 协议由 IEEE 802.11 规范定义,它并不关心或知道无线网络上传输的数据类型;它只是使设备之间能够通信。每个连接到 Wi-Fi 网络的设备都有一个 MAC 地址,并接收发送到其地址的帧。MAC 地址只在本地网络上有效;远程网络上的计算机不能直接向本地网络上的 MAC 地址发送数据。
另一个值得注意的链路层技术是以太网,它用于有线物理连接。以太网由 IEEE 802.3 标准定义。以太网通常使用内含铜线对的电缆,端口使用常见的RJ45连接器,如图 11-7 所示。

图 11-7:以太网常用的电缆
所有连接到互联网的设备都参与链路层。这是必要的,因为正是链路层提供了本地网络的连接(有线或无线)。像笔记本电脑或智能手机这样的主机参与所有层次,但某些网络设备只在链路层工作。最基本的例子是集线器。网络集线器是一种网络设备,用于连接本地网络上的多个设备,但不具备处理发送帧的智能功能。一个简单的集线器可能提供多个以太网端口来连接设备。集线器只是将它接收到的每个帧从一个物理端口转发到所有其他端口。更智能的链路层设备是网络交换机,它会检查接收到的帧中的 MAC 地址,并将这些帧发送到目标 MAC 地址所在设备的物理端口。
注意
请参见项目#29 在第 254 页,在那里你可以查看链路层设备和 MAC 地址。
互联网层
互联网层允许数据超越本地网络进行传输。该层使用的主要协议被称为互联网协议(IP)。它支持路由,即确定在网络之间传输数据路径的过程。互联网上的每个主机都分配一个IP 地址,这是一个唯一标识全球互联网中主机的数字。也可以有私有 IP 地址,它们不会直接暴露在互联网上。IP 地址通常由本地网络中的服务器分配,且设备的 IP 地址在连接到新网络时通常会发生变化。关于地址分配和私有 IP 地址的内容,我们将在后续部分进行详细介绍。
通过互联网层发送的数据称为数据包,它被封装在链路层的帧中。图 11-8 说明了数据包如何适应帧的数据部分。
IP 数据包头包含源 IP 地址和目标 IP 地址。头部还包括描述数据包的信息,例如正在使用的 IP 版本和头部长度。IP 数据包的数据部分包含 IP 层携带的有效载荷。

图 11-8:数据包包含在帧的数据部分中
目前互联网上使用两种版本的互联网协议。互联网协议版本 4(IPv4)是当前使用的主要版本,另一个活跃的版本是互联网协议版本 6(IPv6)。你可能会想,IPv5 发生了什么?其实并没有这样的协议,但一个叫做互联网流协议的实验性协议将其 IP 版本标识为 5,因此在 IPv4 的继任者开发时跳过了 IPv5。IPv4 和 IPv6 之间的一个重要区别是 IP 地址的大小。IPv4 地址的长度为 32 位,而 IPv6 地址的长度为 128 位。这一差异使得 IPv6 可以提供更多的地址。这种地址大小的变化旨在帮助解决 IPv4 地址短缺的问题。在本书中,我们将重点介绍 IPv4 地址(并称之为IP 地址),因为它们仍然是目前互联网上最主要的寻址方式。
一个 32 位的 IP 地址通常以点分十进制表示法显示,意味着 32 位被分成四组,每组 8 位,8 位数字以十进制显示(而不是十六进制或二进制),四个十进制数字之间用句点(点)分隔。一个示例 IP 地址,以点分十进制表示法显示为 192.168.1.23。每个 8 位的十进制数字可以称为字节。
连接到同一本地网络的计算机具有以相同前导位开头的 IP 地址,且被认为处于同一个子网中。处于同一子网的计算机能够直接在链路层进行通信,因为它们在同一物理网络上运行。处于不同子网的计算机必须通过路由器发送其流量,路由器是一个连接子网并在互联网层上工作的设备。
子网划分将 IP 地址分为两部分:网络前缀,同一子网中的所有设备共享此前缀;以及主机标识符,它是该子网上每台主机的唯一标识。网络前缀中包含的位数根据网络配置的不同而有所不同。
让我们看一个例子。假设一个子网使用 24 位网络前缀,剩余 8 位用于表示主机。还假设此子网中的一台主机使用之前的示例 IP 地址——192.168.1.23。根据这个 IP 地址和网络前缀,IP 地址的划分如 图 11-9 所示。
在此示例中,所有本地子网中的主机都有一个以 192.168.1\ 开头的 IP 地址。每台主机的最后一个八位字节的值不同,23 被分配给这台特定的主机。此示例使用了一个 24 位前缀长度,意味着前缀与 IP 地址的前三个八位字节对齐。这是一个很好的示例,但前缀长度并不总是与八位字节边界对齐。例如,一个 25 位前缀也会包括最后一个八位字节的第一个位,留给主机的则只有 7 位。

图 11-9:使用 24 位网络前缀的示例 IP 地址
用于表示网络前缀的位数通常有两种方式。无类域间路由(CIDR)符号列出了一个 IP 地址,后跟一个斜杠(/),然后是用于网络前缀的位数。在我们的示例中,这将是 192.168.1.23/24。表示前缀位数的另一种常见方法是使用子网掩码,它是一个 32 位的数字,其中每个属于网络前缀的位都用二进制 1 表示,而属于主机号的位用二进制 0 表示。子网掩码也以点分十进制表示,因此我们这个 24 位网络前缀的示例会导致一个子网掩码为 255.255.255.0,如 图 11-10 所示。

图 11-10:表示为子网掩码的 24 位网络前缀
让我们看看这在实践中如何发挥作用。假设你的计算机 IP 地址是 192.168.0.133,子网掩码是 255.255.255.224,或者以 CIDR 表示法表示为 192.168.0.133/27\。你的计算机希望连接到另一台 IP 地址为 192.168.0.84\ 的计算机。如前所述,如果两台计算机在同一子网中,它们可以直接通信;如果不在同一子网中,它们必须通过路由器通信。那么,如何判断另一台计算机是否在同一子网呢?
对 IP 地址和其子网掩码执行按位逻辑与操作,得到子网中的第一个地址。这个第一个地址,其中主机位全为 0,作为子网本身的标识符。通常称之为网络 ID。共享网络 ID 的两台计算机在同一子网中。主机可以对其自身的 IP 地址和希望连接的 IP 地址执行此与操作,以查看它们是否共享网络 ID,从而是否在同一子网中。我们可以用示例计算机的 IP 地址来试一下,如下所示:
IP = 192.168.0.133 = 11000000.10101000.00000000.10000101
MASK = 255.255.255.224 = 11111111.11111111.11111111.11100000
AND = 192.168.0.128 = 11000000.10101000.00000000.10000000 = The network ID
现在对我们示例中的第二台计算机执行相同的操作:
IP = 192.168.0.84 = 11000000.10101000.00000000.01010100
MASK = 255.255.255.224 = 11111111.11111111.11111111.11100000
AND = 192.168.0.64 = 11000000.10101000.00000000.01000000 = The network ID
如你所见,通过这个操作,得出了两个不同的网络 ID(192.168.0.128 和 192.168.0.64)。这意味着第二台计算机不在与你的计算机同一子网中。为了进行通信,这两台计算机需要通过连接两个子网的路由器来发送消息。
练习 11-1:哪些 IP 在同一子网?
IP 地址 192.168.0.200 是否与你的计算机在同一子网中?假设你的计算机 IP 地址为 192.168.0.133,子网掩码为 255.255.255.224。
另一种看待这个问题的方法是:网络前缀描述了子网中可以使用的地址范围。该范围中的第一个地址被定义为网络前缀位,后跟所有主机标识符的二进制 0。继续以我们示例中的计算机 192.168.0.133 为例,它所在子网的第一个地址是 192.168.0.128\。该范围中的最后一个地址是网络前缀位,后跟所有主机标识符的二进制 1。在我们的示例中,这个地址是 192.168.0.159\。第一个和最后一个地址有特殊含义——第一个标识网络,最后一个是广播地址(用于向子网上所有主机发送消息)。其间的所有地址可以用于子网中的主机。我们示例中的 IP 地址 192.168.0.133 显然在此范围内(从 192.168.0.128 到 192.168.0.159),而另一台 IP 地址为 192.168.0.84 的计算机则不在该范围内。
你也可以通过查看为主机标识符保留的位数来确定子网中可用于主机的 IP 地址数量。在我们的示例中,27 位保留用于网络前缀,剩余 5 位用于主机标识符。这 5 位提供了 32 个可能的主机地址,因为 2⁵ 等于 32。然而,正如前面所提到的,第一个和最后一个地址有特殊用途,因此实际上只能使用这个网络前缀识别 30 个主机。这与我们之前的结论一致:第一个主机标识符是 128,128 + 32 给出的是 160,这是下一个子网中的第一个地址,因此 159 将是我们的地址范围中的最后一个主机。
注意
请参见 项目 #30 在 第 255 页,你可以通过你的 Raspberry Pi 查看互联网层
传输层
传输层 提供了一个通信通道,应用程序可以使用它来发送和接收数据。传输层有两个常用的协议:传输控制协议(TCP)和 用户数据报协议(UDP)。TCP 提供两个主机之间的可靠连接。它确保最小化错误,数据按顺序到达,丢失的数据会被重发,等等。使用 TCP 发送的数据被称为 段。另一方面,UDP 是一种“尽力而为”的协议,这意味着它的传输是不可靠的。UDP 在速度比可靠性更重要的情况下更受青睐。使用 UDP 发送的数据被称为 数据报。这两种协议各有其用,但为了简单起见,接下来的章节中我只讲解 TCP。 图 11-11 展示了一个 TCP 段如何嵌入到数据包的数据部分中,而数据包又嵌入到帧的数据部分中。
正如我们之前看到的,链路层在帧头中包括目标 MAC 地址来识别本地网络接口,互联网层在数据包头中包括目标 IP 地址来识别互联网中的主机。这些信息足以将数据包传送到互联网中的特定设备。一旦数据包到达目标主机,传输层头部会包括目标网络 端口号,用于标识将接收数据的特定服务或进程。一个拥有单一 IP 地址的主机可以有多个活跃端口,每个端口用于执行不同类型的网络活动。

图 11-11:TCP 段包含在 IP 数据包的数据部分中
用一个类比来说明,IP 地址就像办公大楼的街道地址,而网络端口号就像该办公大楼中某个员工的办公室号码。IP 地址唯一标识一台主机计算机,就像街道地址唯一标识一座办公大楼一样。通过互联网协议,数据包可以像包裹送到办公大楼一样送到主机。然而,一旦数据包到达计算机,操作系统必须决定如何处理它。数据包并不是送给操作系统本身的,而是送给计算机上某个正在运行的进程。同样,包裹到达办公大楼后,可能并不是送给邮局工作人员的,而是送给大楼里其他某个人的。操作系统检查端口号,将传入的数据送到在指定端口上监听的进程,就像邮局工作人员检查包裹上的姓名或办公室号码,将包裹送到正确的人手中。
端口号范围从 0 到 1,023 的网络端口称为知名端口,而端口号范围从 1,024 到 49,151 的端口可以在互联网分配号码局(IANA)注册,称为注册端口。大于 49,151 的端口是动态端口。从技术上讲,任何具有足够权限的进程都可以在任何未被系统占用的端口上监听,可能会忽略该端口号的典型使用案例。然而,当客户端应用程序希望连接到另一台计算机上的远程服务时,它需要知道使用哪个端口,因此标准化端口号是有意义的。例如,Web 服务器通常监听端口 80 和端口 443(用于加密连接)。除非另行指示,Web 浏览器会假定应使用端口 80 或 443。
练习 11-2:研究常见端口
查找常见应用层协议的端口号。域名系统(DNS)、安全外壳协议(SSH)和简单邮件传输协议(SMTP)的端口号是多少?你可以通过在线搜索或查看 IANA 注册表找到这些信息,网址是:www.iana.org/assignments/port-numbers。IANA 列表有时会使用意想不到的术语来表示服务名称。例如,DNS 仅被列为“domain”。
服务器使用知名端口以方便客户端连接。然而,大多数网络通信是双向的(客户端发送请求,服务器响应),因此客户端也需要有一个开放的端口,以便接收来自服务器的数据。客户端只需暂时打开这样的端口,足够完成与服务器的通信即可。这样的端口称为临时端口,由操作系统中的网络组件分配。例如,客户端网页浏览器连接到服务器的 80 端口,客户端上也会打开一个临时端口,假设是端口号 61,348。客户端将其网页请求发送到服务器的 80 端口,服务器则将响应发送到客户端的 61,348 端口。
一个 IP 地址加上一个端口号构成一个端点,而一个端点的实例称为套接字。一个套接字可以监听新的连接,或者它可以代表一个已建立的连接。如果多个客户端连接到同一个端点,每个客户端都有自己独立的套接字。
注意
请参阅项目 #31 在第 256 页,在这里您可以查看 Raspberry Pi 的端口使用情况。
应用层
应用层是互联网协议栈的最终、最上层。虽然下三层为互联网通信提供了通用的基础,但应用层的协议专注于完成特定的任务。例如,Web 服务器使用超文本传输协议(HTTP)来检索和更新网页内容;邮件服务器使用简单邮件传输协议(SMTP)来发送和接收电子邮件;文件传输服务器使用文件传输协议(FTP)来,没错,就是传输文件!换句话说,应用层是我们接触到描述应用程序行为的协议的地方,而栈的较低层则是使应用程序能够在互联网上完成其任务的“管道”。图 11-12 提供了四个层次的完整视图。

图 11-12:应用层的数据包含在段的数据部分中。
图 11-12 展示了每个层次如何适配低层数据负载的视图。将所有层次组合在一起,参见图 11-13,我们可以看到发送到互联网设备的帧所包含的内容。

图 11-13:包含 IP 数据包、TCP 段和应用数据的帧
我们已经从下到上地走了一遍网络帧的内容,从最接近硬件的层开始。当一个帧被主机接收时,它会按照相同的顺序处理,从链路层到应用层。相反,当一个帧从主机发送时,帧会以相反的顺序组装。一个过程准备好应用数据,数据最终被封装在一个段、一个包,最后是一个帧中。
互联网之旅
现在你已经熟悉了 TCP/IP 网络模型中的四个层次,让我们通过一个例子来看数据是如何在互联网中传输的。我们将看到沿途的各种设备如何与每个网络层进行交互。图 11-14 说明了这一点,展示了左上方的客户端如何与左下方的服务器进行通信。

图 11-14:不同设备在网络栈的不同层次进行交互
我将在 图 11-14 中设置场景。一个客户端设备(图示左上角)连接到一个无线 Wi-Fi 网络。该网络通过路由器连接到互联网。在图示的另一个位置,我们有一台服务器(图示左下角),它通过交换机和路由器与互联网建立有线连接。客户端设备的用户打开网页浏览器,向服务器请求一个网页。为了简便起见,我们假设客户端已经知道服务器的 IP 地址。
客户端上的网页浏览器使用 HTTP 语言,这是 web 的应用层协议,因此它会形成一个面向目标服务器的 HTTP 请求。浏览器随后将 HTTP 请求交给操作系统的 TCP/IP 软件栈,请求将数据传送到服务器——特别是服务器的 IP 地址和端口 80,HTTP 的标准端口。客户端操作系统上的 TCP/IP 软件栈随后将 HTTP 负载封装在一个 TCP 段中(传输层),并在段头中设置目标端口为 80。如果需要,TCP 会将应用层数据分割成多个段,每个段都带有自己的头部。客户端的互联网层软件随后将 TCP 段封装在一个 IP 包中,该包在头部包含服务器的目标 IP 地址。如果需要,IP 会将数据包分割成多个更小的片段,为通过网络链路的传输做好准备。在客户端的链路层,IP 包被封装在一个帧中,帧头部包含本地路由器的 MAC 地址。该帧通过客户端设备的 Wi-Fi 硬件无线传输。
无线接入点接收该帧。接入点在链路层操作,将帧发送给路由器。路由器检查互联网层的数据包以确定目标 IP 地址。为了到达服务器,请求需要通过互联网上的多个路由器。当地路由器将数据包封装成一个新的帧,带有新的目标 MAC 地址(下一个路由器的地址),并将新帧发送出去。这个路由过程会在互联网上经过多个路由器,直到请求到达与服务器连接的子网上的路由器。
最后的路由器将数据包封装成适合服务器本地网络的帧。这个帧的头部包含服务器的 MAC 地址。服务器子网上的交换机查看帧中的 MAC 地址,并将帧转发到合适的物理端口。交换机无需查看更高层的信息。服务器接收到帧后,网络接口的驱动程序将 TCP/IP 数据包传递给 TCP/IP 软件栈,接着将 HTTP 数据交给监听 TCP 端口 80 的进程。监听 80 端口的 Web 服务器软件处理请求。这包括回复客户端,为此,整个过程会再次发生,只不过顺序是反向的。
注意
请参见项目 #32,该项目位于第 258 页,在其中你可以看到从你的 Raspberry Pi 到互联网上主机的路由
基础互联网功能
虽然 TCP/IP 为可靠的数据传输提供了必要的基础设施,但其他协议则提供了额外的基础互联网功能。这些功能作为应用层协议来实现。现在让我们看看两个这样的协议(DHCP 和 DNS)以及一种用于转换 IP 地址的系统(NAT)。
动态主机配置协议
互联网上的每个主机都需要一个 IP 地址、一个子网掩码以及它的路由器 IP 地址(也叫默认网关),才能与其他主机进行通信。IP 地址是如何分配的呢?设备可以被赋予一个静态 IP 地址,这需要有人编辑设备的配置并手动设置 IP 信息。这有时是有用的,但它要求用户确保该 IP 地址没有被占用,并且对该子网是有效的。大多数终端用户没有手动配置设备 IP 设置的专业知识,也不想处理手动配置的麻烦。幸运的是,大多数 IP 地址是通过动态主机配置协议(DHCP)动态分配的。通过 DHCP,当设备连接到网络时,它会在没有用户干预的情况下接收 IP 地址和相关信息。
为了使 DHCP 在网络上可用,网络上的设备必须被配置为DHCP 服务器。该服务器有一个可分配给网络上设备的 IP 地址池。DHCP 的流程在图 11-15 中进行了说明。

图 11-15:一个 DHCP 对话
让我们来逐步了解图 11-15。当一个设备连接到网络时,它会广播一条消息以发现任何 DHCP 服务器。广播是一种特别的包,它的目标是本地网络上的所有主机。当 DHCP 服务器收到这个广播时,它会向客户端设备提供一个 IP 地址。如果客户端希望接受这个提供的 IP 地址,它会向服务器回复一个请求,要求分配该地址。然后,DHCP 服务器会确认请求,并将该 IP 地址分配给客户端。该 IP 地址会被租赁给客户端,如果客户端没有续租,地址最终会过期。
注意
请参阅项目 #33 和第 258 页,在那里你可以看到你的 Raspberry Pi 使用 DHCP 租赁的 IP 地址。
私有 IP 地址和网络地址转换
可用的 IP 地址数量是有限的,因此大多数家庭互联网服务提供商(ISP)只会分配一个 IP 地址给客户。这个 IP 地址被分配给直接连接到 ISP 网络的设备,通常是一个路由器。然而,许多客户家中有多个设备。让我们看看如何通过利用私有 IP 地址和网络地址转换,使多个设备共享一个公共 IP 地址。
某些范围的 IP 地址被认为是私有 IP 地址,这些地址是为私有网络设计的,例如家庭或办公室中的网络,在这些网络中,设备并没有直接连接到互联网。任何符合 10.x.x.x、172.16.x.x 或 192.168.x.x 格式的地址都是私有 IP 地址。任何人都可以在不需要许可的情况下使用这些地址范围。问题是私有 IP 地址是不可路由的——它们不能在公共互联网中使用。家庭网络中的 DHCP 服务器可以分配这些地址,而不必担心其他网络是否也在使用相同的地址。与必须唯一的公共 IP 地址不同,私有 IP 地址设计上可以在多个私有网络中同时使用。即使多个网络使用相同的地址,也没有关系,因为这些地址永远不会出现在私有网络之外。私有 IP 地址解决了 ISP 仅为家庭或企业提供一个公共 IP 地址的问题,但如果私有 IP 地址无法在互联网上路由,它们又如何有用呢?
网络地址转换 (NAT) 允许在私有网络(通常是家庭网络)上的设备使用相同的公共 IP 地址访问互联网。当数据包通过 NAT 路由器时,路由器会修改数据包中的 IP 地址信息。当来自私有家庭网络的数据包到达 NAT 路由器时,路由器会修改源 IP 地址字段,以匹配公共 IP 地址,如图 11-16 所示。

图 11-16:NAT 路由器将私有 IP 地址替换为其自己的公共 IP 地址
当响应返回到路由器时,路由器将目标 IP 地址设置为发起请求的主机的私有地址。通过这种方式,家庭网络中的所有流量看起来都来自同一个公共 IP 地址,即使实际在私有网络上有多个设备。NAT 还具有额外的安全性好处:私有网络上的设备不会直接暴露在公共互联网中,因此互联网中的恶意用户无法直接发起连接到私有设备。大多数面向消费者销售的家用路由器都是 NAT 路由器,通常还具备内置无线接入点功能。
私有 IP 地址不仅对家庭网络有价值,对于不希望将计算机暴露于公共互联网的企业来说也同样重要。许多企业网络使用代理服务器而不是 NAT 路由器。代理服务器类似于 NAT 路由器,它允许私有网络上的设备访问互联网,但代理服务器通常在应用层而非互联网层运行。代理服务器通常还提供额外的功能,如用户认证、流量日志记录和内容过滤。
注意
请参见项目 #34 以及第 259 页,在其中你可以看到分配给你设备的 IP 地址是公共 IP 地址还是私有 IP 地址。
域名系统
我们已经看到,互联网上的主机通过 IP 地址进行标识。然而,互联网的多数用户很少,甚至从不直接处理 IP 地址。虽然 IP 地址对于计算机来说是有效的,但它们对用户来说并不友好。没有人愿意记住由点分隔的四组数字。幸运的是,我们有域名系统 (DNS) 来简化这一切。DNS 是一个将名称映射到 IP 地址的互联网服务。这使得我们可以通过像 www.example.com 这样的名称来引用主机,而不是通过其 IP 地址。
计算机的完整 DNS 名称被称为 完全限定域名,或 FQDN。像 travel.example.com 这样的名称就是一个 FQDN。这个名称由一个简短的本地 主机名(travel) 和一个域后缀 (example.com) 组成。术语 主机名 常常交替使用,指的是计算机的短名称或 FQDN。本文接下来会使用 主机名 来指代计算机的 FQDN。域名,如 example.com,代表一个由组织管理的网络资源组。example.com 和 travel.example.com 都是域名。前者代表一个网络域;后者代表该域中的特定主机。
软件需要能够查询 DNS 将主机名转换为 IP 地址——这被称为 解析 主机名。为了实现这一功能,主机会配置一个 DNS 服务器 IP 地址的列表。这个列表通常由 DHCP 提供,通常包含由互联网服务提供商维护或在本地网络上运行的 DNS 服务器。当客户端想通过名称连接到服务器时,它会向 DNS 服务器请求与该名称对应的 IP 地址。如果服务器能提供,它会返回请求的 IP 地址。这个过程如 图 11-17 所示。

图 11-17:简化的 DNS 查询。 example.com 的 IP 地址并不准确。
一旦客户端获取到服务器的 IP 地址,它就会使用该地址与服务器进行通信,如前所述。我曾听说 DNS 被比作互联网的电话簿,尽管这种类比对一些读者来说可能不太恰当,因为电话簿不再像以前那样普及!
你可能会认为 IP 地址和名称之间存在一对一的映射关系。实际上并非如此。一个名称可以映射到多个 IP 地址。在这种情况下,多个客户端查询 DNS 获取某个名称时,它们可能会收到不同的 IP 地址作为响应。这对于需要将某个服务的负载分配到多个服务器的情况非常有用。负载分配可以基于地理位置进行,比如欧洲的客户端可能会得到一个不同于亚洲客户端的 IP 地址,这样可以让各地区的客户端连接到离它们物理上更近的服务器 IP 地址。
反向查询也是可能的:多个名称可以映射到相同的 IP 地址。在这种情况下,查询不同名称时,可能会返回同一个 IP 地址。这对于服务器托管多个相同类型的服务实例(每个实例通过名称标识)非常有用。此情况在网站托管中很常见,一台服务器可能托管多个网站,每个网站通过其 DNS 名称进行标识。
DNS 中的每个条目被称为记录。记录有多种类型;最基本的是A 记录,它只是将主机名映射到 IP 地址。其他例子包括将一个主机名映射到另一个主机名的CNAME(规范名称)记录,以及用于电子邮件服务的MX(邮件交换器)记录。
没有任何一个组织愿意承担管理当今存在的众多 DNS 记录的任务。幸运的是,这并不需要;DNS 的实现方式允许共享责任。像www.example.com这样的 DNS 名称实际上代表了一个记录层次结构,不同的 DNS 服务器负责维护该层次结构中不同级别的记录。应用于www.example.com的 DNS 层次结构,如图 11-18 所示。

图 11-18:示例 DNS 层次结构,突出显示 www.example.com
在这个层次结构的顶部是根域名。根域名在 DNS 名称中不会像www.example.com那样有文本表示,但它是 DNS 层次结构中不可或缺的一部分。根域名包含所有顶级域名(TLD)的记录,例如.com、.org、.edu、.net等。截至 2020 年,全球有 13 个根名称服务器,每个根名称服务器负责了解所有顶级域名服务器的详细信息。假设你想查找一个以.com结尾的域名的记录,根服务器可以将你引导到一个知道.com下域名信息的 TLD 服务器。
顶级 DNS 服务器负责了解其层次结构下所有二级域名的信息。一个.com的顶级 DNS 服务器可以将你引导到example.com的二级 DNS 服务器。二级域名的 DNS 服务器维护着主机和三级域名的记录,这些主机和三级域名位于二级域名下。这意味着example.com的 DNS 服务器负责维护诸如www.example.com和mail.example.com等主机的记录。这个模式继续下去,允许域名嵌套。一旦一个域名在顶级域名下注册,域名的拥有者可以在其域名下创建任意多的记录。
如前所述,当计算机需要为 FQDN 查找 IP 地址时,它会向配置的 DNS 服务器发送请求。那么 DNS 服务器如何处理这个请求呢?如果服务器最近已经查找过该记录,它可能会将该记录存储在缓存中,并立即返回 IP 地址给客户端。如果 DNS 服务器没有缓存响应,它可能会根据需要查询其他 DNS 服务器来获取答案。这涉及从根服务器开始,逐步向下查询服务器层次,直到找到该记录。一旦服务器找到记录,它可以将其缓存起来,以便未来查询该记录时能够立即回应。最终,缓存的记录会被删除,以确保服务器提供的是较为最新的数据。
注意
请参见项目 #35,在第 260 页上查看有关 DNS 的信息。
网络即计算
让我们花点时间来思考互联网如何融入我们在本书中已经讨论的更广泛的计算概念。网络看起来可能是一个边缘话题,但实际上它与计算并没有多远的关系。互联网由硬件和软件共同工作,允许设备之间的通信。通过互联网发送的数据最终归结为 0 和 1,以各种形式表示,例如电线上的电压。从计算机的角度来看,像 Wi-Fi 或以太网适配器这样的网络接口只是另一个 I/O 设备。操作系统通过设备驱动与这些适配器进行交互,操作系统还包含了软件库,允许应用程序轻松地通过互联网进行通信。像路由器和交换机这样的网络设备也是计算机,尽管它们是高度专业化的。互联网以及网络本身,实际上是本地计算的延伸,它允许数据在超出单一设备范围的情况下进行传输和处理。
总结
本章我们讨论了互联网,一个全球互联的计算机网络集合,所有这些网络都使用一套通用的协议。你学习了互联网协议套件的四个层次——链路层、互联网层、传输层和应用层。你了解了数据如何在互联网中传输,以及设备如何在各个层次上进行交互。你学习了 DHCP 如何提供网络配置信息,NAT 如何让私有网络上的设备连接到互联网,以及 DNS 如何提供友好的名称,可以代替 IP 地址使用。在下一章中,你将学习万维网,这是一组通过 HTTP 在互联网中传输的资源。
项目 #29:检查链路层
先决条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。如果你还没有,建议你翻到附录 B,阅读完整的“Raspberry Pi”部分。
在这个项目中,你将使用你的树莓派检查本地网络的链路层。我们先从以下命令开始,它可以列出你以太网适配器的 MAC 地址:
$ ifconfig eth0 | grep ether
输出结果应该类似于以下内容:
ether b8:27:eb:12:34:56 txqueuelen 1000 (Ethernet)
在这个例子中,MAC 地址是b8:27:eb:12:34:56。这是一个 48 位数字的十六进制表示。记住,每个十六进制字符表示 4 位,因此 12 个字符 × 4 位 = 48 位。
MAC 地址的前 24 位表示硬件的供应商/制造商。这个数字被称为组织唯一标识符(OUI),由电气和电子工程师协会(IEEE)管理。在这个例子中,OUI 是 B827EB,它分配给了树莓派基金会。你可以在这里查看当前的 OUI 列表:standards-oui.ieee.org/oui.txt。
你的树莓派 Wi-Fi 适配器有它自己的 MAC 地址。你可以像这样查看它:
$ ifconfig wlan0 | grep ether
ether b8:27:eb:78:9a:bc txqueuelen 1000 (Ethernet)
在我的系统上,Wi-Fi 适配器的 OUI(MAC 地址的前 24 位)与以太网适配器的 OUI 相同。这是因为这两个适配器都是树莓派的内部硬件,使用树莓派基金会的 OUI。
从你的树莓派上,你还可以查看本地网络上其他设备的 MAC 地址。你可以使用一个名为arp-scan的工具,它尝试连接本地网络上的每台计算机并检索其 MAC 地址。
首先,安装该工具:
$ sudo apt-get install arp-scan
然后运行这个命令(命令末尾是小写字母 L,而不是数字 1):
$ sudo arp-scan -l
你应该会看到一个 IP 地址列表(我们将在本章的其他部分进行讲解)和 MAC 地址,还有一列尝试将 MAC 前缀与制造商匹配。我在我的本地网络上得到了 10 个结果,其中一些我没有立即认出来。你可能会看到一些重复的结果,它们会在第三列中标记为DUP。返回的列表通常不包括你运行扫描的计算机的地址。
你可能会在第三列中看到一些显示为(Unknown)的结果。这意味着arp-scan工具无法将 OUI 号码与已知制造商匹配,可能是因为该工具使用的是过时的 OUI 列表版本。你可以尝试通过下载 IEEE 当前的 OUI 号码列表并重新运行扫描来修复此问题,命令如下:
$ get-oui
$ sudo arp-scan -l
当我在家里的网络上看到多个设备,我无法立即识别时,我会立刻产生想要弄清楚它们是什么的冲动!作为额外的挑战,找出所有从arp-scan返回的设备。现在,如果你在一个你无法控制的网络上运行这个工具(比如咖啡店或图书馆),这可能不太实际,但如果你在家里,这是你可以做的事。你可能需要登录到网络中的每个设备,翻查其设置,找到它的 IP 地址或 MAC 地址,并查看它是否与arp-scan返回的某个条目匹配。提示:在 Linux 或 Mac 上使用ifconfig工具,或在 Windows 上使用ipconfig工具。在移动设备上,可以查看网络设置的用户界面。
项目 #30:检查互联网层
先决条件:一台运行 Raspberry Pi OS 的树莓派。
在这个项目中,你将使用树莓派查看互联网层。让我们从以下命令开始,它列出了设备上所有的网络接口及其关联的 IP 地址。
$ ifconfig
你通常会看到三个接口:eth0、lo和wlan0。lo接口是一个特殊的接口,它是环回接口。它用于树莓派上运行的进程,这些进程希望使用 TCP/IP 相互通信,但不需要通过网络实际发送任何流量。也就是说,流量会留在设备上。环回接口的 IP 地址是 127.0.0.1。这个地址是一个特殊地址,无法路由,也不能作为本地子网中的地址使用,因为任何尝试将消息发送到该地址的行为都会导致消息立即返回给发送计算机。换句话说,每台计算机都认为 127.0.0.1 是自己的 IP 地址。
正如我们在上一个项目中提到的,eth0是有线以太网接口,wlan0是无线 Wi-Fi 接口。如果你通过这些接口之一或两个连接到网络,你应该能在ifconfig的输出中看到inet旁边的 IP 地址。你可能还会看到列在inet6旁边的 IPv6 地址。以下是ifconfig输出中的示例wlan0信息:
wlan0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 192.168.1.138 netmask 255.255.255.0 broadcast 192.168.1.255
inet6 fe80::8923:91b2:13e0:ed2a prefixlen 64 scopeid 0x20<link>
在这个输出中,你可以看到分配的 IP 地址是192.168.1.138。netmask值(子网掩码)是255.255.255.0,而broadcast地址是192.168.1.255。
ifconfig命令为我们提供了有关树莓派各种网络接口的信息,但它并没有告诉我们路由是如何配置的。让我们使用ip route命令来看一下。这里包含了一个示例输出;你的结果可能有所不同。
$ ip route
default via 192.168.1.1 dev wlan0 src 192.168.1.138 metric 303
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.138 metric 303
该命令的输出可能有些难以理解。简而言之,第一行给出了默认路由。当没有特定的路由适用时,数据包应该发送到这里。在这个特定的例子中,所有不匹配特定路由规则的数据包应发送到192.168.1.1。这意味着192.168.1.1是本地路由器的 IP 地址,也被称为默认网关。
下一行是一个路由条目,告诉你,任何发送到192.168.1.0/24范围内的 IP 地址的数据包,应该通过设备wlan0发送。这是本地子网的 Wi-Fi 适配器。换句话说,这条路由规则确保与本地子网 IP 地址的通信直接发生,而无需经过路由器。
总结来说,这个输出告诉你,任何发送到与192.168.1.0/24匹配的 IP 地址的数据包,都应该通过wlan0接口直接发送到目标地址。其他的流量则使用默认路由,将流量发送到192.168.1.1的路由器。最终结果是,本地子网的流量直接发送到目标设备,而发送到其他子网上的设备的流量,通常是互联网设备,将通过默认网关发送。
项目 #31:检查端口使用情况
前提条件:一台运行 Raspberry Pi OS 的树莓派。
在这个项目中,你将查看树莓派上哪些网络端口在使用。然后你将检查其他计算机上的端口。让我们从以下命令开始,它将展示树莓派上监听和已建立的 TCP 套接字。
$ netstat -nat
让我们分析一下命令中使用的-nat选项。n选项表示使用数字输出以显示端口号。a选项表示显示所有连接(包括监听和已建立的连接),t表示将输出限制为 TCP。在我的设备上,我看到如下的列表:
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp 0 36 192.168.1.138:22 192.168.1.125:52654 ESTABLISHED
tcp 0 0 192.168.1.138:22 192.168.1.125:51778 ESTABLISHED
tcp6 0 0 :::22 :::* LISTEN
这里你看到四个与 SSH 相关的套接字。我知道它们与 SSH 相关,因为所有套接字都使用端口22。我在树莓派上启用了 SSH,以允许远程终端连接。第一行和最后一行显示树莓派在端口22上监听新的传入 SSH 连接,使用 TCP 和 IPv6 上的 TCP 协议。中间的两行显示我有两个已经建立的 SSH 连接到这个设备,它们都来自我的笔记本(IP 为192.168.1.125)到树莓派(IP 为192.168.1.138)。注意到,两个已建立的连接都指向树莓派的相同服务器端口(22),而我笔记本上的客户端端口则不同(52654和51778),因为它们是临时端口。
再次运行命令,这次添加-p选项并在命令前加上sudo:
$ sudo netstat -natp
这将给你相同的列表,但还会显示与该套接字相关的进程 ID(PID)和程序名称。任何发送到该套接字的流量都会被定向到 PID,后者处理流量并根据需要响应。在我的计算机上,我看到使用此端口的程序是sshd—SSH 的守护进程。
现在你已经检查了树莓派上哪些端口在使用,接下来让我们检查远程计算机上的端口。为此,你将使用一个名为nmap的工具,首先需要在树莓派上安装它:
$ sudo apt-get install nmap
一旦工具安装完成,选择一个你希望扫描的目标主机。这个主机可以是你网络上的设备(比如你的路由器或笔记本电脑),也可以是互联网上的主机。注意,反复扫描一个你无法控制的主机可能会引起该服务器管理员的怀疑,所以我强烈建议你只扫描你拥有的设备。
在我的案例中,我决定扫描我的默认网关,恰好它位于 192.168.1.1。以下nmap命令扫描指定 IP 地址上开放的 TCP 端口。尝试在你的树莓派上运行此命令,将 IP 地址替换为你希望扫描的设备的地址。如果你想扫描你自己的路由器,请参阅项目 #30,了解如何获取默认网关的 IP 地址。
$ nmap -sT 192.168.1.1
我扫描的结果的部分列表显示了以下端口:
PORT STATE SERVICE
53/tcp open domain
80/tcp open http
这告诉我,这个设备不仅充当路由器,还充当 DNS 服务器(端口53)和 Web 服务器(端口80)。对于家庭路由器来说,提供这些服务是正常的。 项目 #32:追踪互联网主机的路由
前提条件:一台运行 Raspberry Pi OS 的树莓派。
在这个项目中,你将检查数据包从树莓派到互联网上某个主机的路由。首先,你需要选择一个互联网上的主机。这个主机可以是像www.example.com这样的网站,也可以是你知道的任何互联网主机的 IP 地址或完全限定域名(FQDN)。一旦你决定了主机,输入以下命令,将www.example.com替换为你希望查看的主机的名称或 IP 地址。
$ traceroute www.example.com
traceroute工具尝试显示在数据包跨越互联网时遇到的路由器。输出应该逐行阅读。每一行都有序号,并显示该数据包在旅行过程中遇到的路由器的名称(如果有的话)和 IP 地址。如果短时间内没有响应,输出将显示一个星号(*),并跳到下一个路由器。你也可能看到每一行显示多个 IP 地址,表示可能有多个路由。
项目 #33:查看你的租用 IP 地址
前提条件:一台运行 Raspberry Pi OS 的树莓派。
在这个项目中,你将查看与树莓派的 IP 地址相关的租用信息,该 IP 地址是从 DHCP 服务器获取的。当然,这假设你的树莓派配置为使用 DHCP(这是默认设置),而不是静态 IP 地址。要做到这一点,请查看系统日志:
$ cat /var/log/syslog | grep leased
预计会看到类似以下的输出:
Jan 24 19:17:09 pi dhcpcd[341]: eth0: leased 192.168.1.104 for 604800 seconds
在这里,你可以看到 IP 地址192.168.1.104是从 DHCP 服务器租用的,供网络接口eth0使用,这是树莓派上的以太网接口。你的输出可能显示不同的 IP 地址,也许是不同的接口,可能是wlan0。
默认情况下,syslog 文件会定期清除,其内容会被移动到备份文件中。因此,您可能无法在 syslog 文件中看到 DHCP 条目。您可以释放当前的 IP 地址,申请一个新的,并再次查看租赁条目,方法如下:
$ sudo dhclient –r wlan0
$ sudo dhclient wlan0
$ cat /var/log/syslog | grep leased
如果您想查看以太网而不是 Wi-Fi 的地址,请将 wlan0 替换为 eth0。
项目 #34:您的设备的 IP 地址是公有的还是私有的?
前提条件:一台运行 Raspberry Pi OS 的 Raspberry Pi。
在这个项目中,您将检查 Raspberry Pi 的 IP 地址是公有的还是私有的。如果您的设备有一个私有 IP 地址,您还可以找到用于互联网通信的公有 IP 地址。如同之前,您可以使用以下工具查看设备的分配 IP 地址。
$ ifconfig
在查找设备的分配 IP 地址时,您可能会看到一个 127.0.0.1 的条目;可以忽略它,因为它用于环回(见 项目 #30)。如前所述,任何符合 10.x.x.x、172.16.x.x 或 192.168.x.x 格式的地址都是私有 IP 地址。现在,即使您拥有像这些这样的私有 IP 地址,当您访问互联网资源时,您也间接地使用了一个公有 IP 地址。这是网站或其他互联网服务在您连接时看到的地址。如果您在家庭网络中,这个公有 IP 地址很可能是分配给您的路由器的。如果您在企业网络中,这个公有 IP 地址可能是分配给您公司网络边缘的代理设备的。在这两种情况下,从您本地网络到互联网的所有网络流量都起源于这个公有地址。
要查找设备在连接互联网设备时使用的公有 IP 地址,一种选择是登录到您的路由器或代理服务器并检查其网络配置。如果您知道如何查询路由器或代理服务器以获取这些信息,随时可以进行操作。不过,由于每种网络设备的型号略有不同,我在这里不会一一讲解步骤。
一个更通用的选择是查询一个可以返回您当前公有 IP 地址的在线服务。之所以可以实现,是因为每个您的设备连接的互联网服务器都知道您的 IP 地址;关键是找到一个愿意告诉您它看到的 IP 地址的服务。如果您在设备上使用的是网页浏览器,或许最简单的做法是查询 Google,搜索类似“我的 IP 地址”这样的内容。通常这会返回您想要的信息。
如果您正在使用终端工作,比如在 Raspberry Pi 上,您可以使用 curl 工具向一个返回当前 IP 地址的网页发出 HTTP 请求。以下是一些当前可以使用的服务示例:
$ curl http://ipinfo.io/ip
$ curl http://checkip.amazonaws.com/
$ curl http://ipv4.icanhazip.com/
$ curl http://ifconfig.me/ip
任何一个命令都应该返回你的公共 IP 地址到终端窗口。将此地址与之前通过ifconfig获得的地址进行比较。如果它们相同,则说明你的设备直接分配了公共 IP 地址。如果不同,则说明你的设备可能被分配了私有 IP 地址,你正在通过 NAT 路由器或代理服务器连接到互联网。
项目 #35:在 DNS 中查找信息
前提条件:一台运行 Raspberry Pi OS 的树莓派。
在这个项目中,你将使用树莓派查询 DNS 记录。让我们从查找一个网站的 IP 地址开始。你将使用host工具来完成此操作。以下命令返回我感兴趣的网站www.example.com的 IP 地址。你可以自由替换为你希望查找的其他主机名。
$ host www.example.com
你应该会看到输出,显示该主机的 IP 地址。你可能还会看到一个 IPv6 地址。根据你查询的主机名,可能会返回多个记录,因为一个 DNS 名称可以映射到多个 IP 地址。你还可能会发现你输入的名称实际上是另一个名称的别名,而该名称又映射到一个 IP 地址。
DNS 还支持反向查找,你可以指定一个 IP 地址,返回与之对应的主机名。这并不总是有效,因为需要 DNS 记录来支持此功能。想尝试的话,只需使用host命令并提供一个 IP 地址。在以下命令中,将a.b.c.d替换为你在项目 #34 中找到的公共 IP 地址,或者任何其他你想查询的公共 IP 地址。同样,这仅对那些已有 DNS 记录支持反向查找的 IP 地址有效。
$ host a.b.c.d
默认情况下,host工具使用你的设备配置的 DNS 服务器。你也可以通过指定该服务器的 IP 地址,使用host查询特定的 DNS 服务器。互联网服务提供商为其客户提供 DNS 服务,但也有许多免费的替代 DNS 服务可用。例如,在本文撰写时,Google 提供了一个 DNS 服务器,地址为 8.8.8.8,Cloudflare 提供了一个 DNS 服务器,地址为 1.1.1.1。如果你想使用 1.1.1.1 的 DNS 服务器查找www.example.com,你可以输入如下命令:
$ host www.example.com 1.1.1.1
这应该会输出与之前相同的 IP 地址信息,并附带一些文本,指示用于查找的 DNS 服务器。
如果你对 DNS 查询的详细信息感兴趣,可以在使用host命令时加上-v选项,这样会提供详细输出。
$ host -v www.example.com
第十二章:万维网**

前一章描述了互联网,即一组全球连接的计算机网络,使用一套协议共享资源。万维网是建立在互联网之上的一个系统,由于其广泛的普及,常常与互联网本身混淆。在这一章中,我们深入探讨网络的细节。我们首先看看它的关键属性和相关的编程语言,然后我们再讨论 web 浏览器和 web 服务器。
万维网概述
万维网,通常简称为网,是一组通过超文本传输协议(HTTP)在互联网上提供的资源。一个网络资源是任何可以通过网络访问的内容,如文档或图片。托管网络资源的计算机或软件程序称为web 服务器,而web 浏览器是常用来访问网络内容的一种应用程序。浏览器用于查看称为网页的文档,一组相关的网页被称为网站。网络具有分布性、可寻址性和链接性。让我们从检查这些核心属性开始。
分布式网络
万维网是分布式的。没有集中式的组织或系统来管理哪些内容可以发布到网络上。任何连接到互联网的计算机都可以运行 web 服务器,而该计算机的所有者可以公开任何他们希望发布的内容。尽管如此,组织或国家可能会选择阻止用户访问某些内容,而政府可以关闭托管非法内容的网站。除了这些情况,网络是一个开放的平台,允许人们发布任何他们愿意发布的内容,没有单一的组织控制可用内容。
可寻址的网络
网络使用统一资源定位符(URL)为网络上的每个资源提供一个唯一的地址,该地址包括其位置和如何访问它。URL 通常被称为网络地址或简写为地址。为了说明这些地址的结构,让我们以一个虚构的旅游网站的 URL 为例,如图 12-1 所示。这个 URL 标识了一个关于前往卡罗莱纳的旅游信息页面。

图 12-1:一个示例 URL
一个 URL 由多个部分组成。URL 的协议标识了用于访问资源的应用层协议。在这种情况下,协议是 HTTP,我们将在后续部分详细讨论。冒号(:)字符表示协议部分的结束。接着是两个斜杠(//),这是 URL 的权限部分。在这个示例中,权限部分包含了服务器的 DNS 主机名,即资源所在的服务器(travel.example.com)。这里也可以使用 IP 地址。除主机外,其他信息也可以出现在这一部分,例如用户名(位于主机之前并以@符号分隔)或端口号(位于主机之后,并以冒号前缀)。接下来是 URL 的路径部分,它指定了 Web 服务器上资源的位置。URL 路径类似于文件系统路径,将资源组织成逻辑层级。在我们的示例中,路径/destinations/carolinas表示该网站有一个描述旅行目的地的页面集合,且 URL 指定的特定页面是关于卡罗莱纳州的页面。如果该网站有一个描述佛罗里达州作为目的地的页面,那么它可能位于/destinations/florida。最后,URL 的查询部分作为修改返回给客户端的资源的修饰符。在我们的示例中,查询指示carolinas页面应显示海滩上的位置。URL 查询部分的格式和含义因网站而异。
该 URL 包含了大量信息,所以让我用简单的语言重新陈述如何读取它。一个网站运行在名为travel.example.com的计算机上。该网站使用 HTTP 协议,因此在连接到该网站时需要使用此协议。该网站上有一个名为carolinas的页面,它是destinations集合的一部分。查询字符串指示该页面仅显示位于海滩的地点。
一个 URL 不必包含图 12-1 中示例的每个元素。它也可以包含该示例中未包括的某些元素。只包含协议和权限的 URL 也是完全有效的,例如travel.example.com。在这种情况下,网站会提供其默认页面,因为没有提供路径。
习题 12-1:识别 URL 的各个部分
对于以下 URL,识别其中的协议、用户名、主机、端口、路径和查询。并非所有 URL 都包含所有这些部分。
你可以在附录 A 中检查你的答案。
网络浏览器通常会在其地址栏中显示当前的 URL,如图 12-2 所示。

图 12-2:地址栏
今天,浏览器通常会在地址栏中省略 URL 的方案、冒号和正斜杠。这并不意味着这些 URL 元素不再被浏览器使用。浏览器只是想简化用户体验。URL 的显示方式会随着时间的推移而不断变化,不同的浏览器表现也各不相同。
图 12-3 显示了 Google Chrome(版本 77)如何在其地址栏中显示 URL 的示例。

图 12-3:Chrome 地址栏
图 12-3 上方的图像显示了加载 HTTP 网站时的地址栏。Chrome 在其地址栏中不显示http://前缀。请注意Not secure文本。下方的图像显示了加载 HTTPS 网站时的地址栏。HTTPS 是 HTTP 的安全版本。Chrome 省略了https://前缀,但显示一个锁形图标,表示这是一个 HTTPS 网站。
我们一直在讨论以网页为背景的 URL,但 URL 也扩展到 Web 上的其他资源。例如,网页上显示的图像有其自己的 URL,脚本文件或 XML 数据文件也是如此。网页浏览器只在地址栏显示网页的 URL,但典型的网页通过 URL 引用各种其他资源;浏览器会自动加载这些资源。
有时在 URL 中不需要包含方案、主机名,甚至完整路径。当 URL 省略其中一个或多个元素时,这种 URL 被称为相对 URL。相对 URL 被解释为相对于其所在的上下文。例如,如果在网页中使用一个像/images/cat.jpg的 URL,加载该页面的浏览器会假设猫照片的方案和主机名与该页面的方案和主机名相匹配。
链接的 Web
URL 的特点是 Web 上的每个资源都有一个唯一的地址,这使得一个 Web 资源可以轻松地引用另一个资源。从一个网页文档到另一个网页文档的引用被称为超链接,或者简称链接。这种链接是单向的;任何网页都可以链接到另一个页面,而无需许可或互链。网页相互链接的这种系统就是让“Web”成为万维网的原因。像网页这样的文档,如果可以通过超链接连接,就被称为超文本文档。
Web 协议
Web 使用超文本传输协议(HTTP)及其安全变种HTTPS进行传输。
HTTP
尽管名称中有“超文本”,但 HTTP 不仅用于传输超文本;它还用于读取、创建、更新和删除 Web 上的所有资源。HTTP 通常依赖于 TCP/IP。TCP 确保数据可靠传输,IP 负责主机寻址。HTTP 本身基于请求和响应的模型。HTTP 请求发送到 Web 服务器,服务器会回复一个响应。
每个 HTTP 请求都包括一个HTTP 方法,也非正式地称为HTTP 动词,它描述了客户端请求服务器执行的操作类型。
一些常用的 HTTP 方法:
GET 检索资源而不修改它。
PUT 在服务器的特定 URL 上创建或修改资源。
POST 在服务器上创建一个资源,作为现有 URL 的子资源。
DELETE 从服务器上删除资源。
任何 HTTP 方法都可以在任何资源上尝试,但托管特定资源的服务器通常不会允许某些方法在该资源上使用。例如,大多数网站不允许客户端删除资源。那些允许删除的站点几乎总是要求用户以拥有删除权限的帐户登录。
在典型网站上最常用的方法是 GET。当网页浏览器访问网站时,浏览器会对请求的页面执行 GET 请求。该页面可能包含对脚本、图片等的引用,浏览器随后也使用 GET 方法获取这些资源,直到页面能够完全显示。
每个 HTTP 响应都包括一个HTTP 状态码,它描述了服务器的响应。每个状态码是一个三位数,其中最显著的数字表示响应的总体类别。100 范围内的响应是信息性的,200 范围的响应表示成功,300 范围的响应表示重定向,400 范围的响应表示客户端出现错误——请求未被客户端正确形成,500 范围的响应表示服务器发生了错误。
一些常用的 HTTP 状态码:
200 成功。服务器能够完成请求。
301 永久移动。浏览器应将请求重定向到响应中指定的不同 URL。
401 未经授权。需要身份验证。
403 禁止访问。用户没有权限访问请求的资源。
404 未找到。服务器未能找到请求的资源。
500 服务器内部错误。服务器发生了意外错误。
HTTP 是相当容易理解的。它使用人类可读的文本来描述请求和响应。请求的第一行包括 HTTP 方法、资源的 URL 和请求的 HTTP 版本。以下是一个例子:
GET /documents/hello.txt HTTP/1.1
这意味着客户端请求服务器通过 HTTP 版本1.1发送 /documents/hello.txt 的内容。在请求行之后,HTTP 请求通常包括一些头部字段,用以提供更多关于请求的信息,此外还可以有一个可选的消息体。
类似地,HTTP 响应也使用简单的文本格式。第一行包括 HTTP 版本、状态码和响应短语。以下是一个 HTTP 响应第一行的例子:
HTTP/1.1 200 OK
在这个响应示例中,服务器返回了状态码 200 和响应短语 OK。就像 HTTP 请求一样,响应也可以包括头信息值和消息体。图 12-4 提供了一个更详细,但仍然简化的 HTTP 请求和响应示例。

图 12-4:简化的 HTTP 请求和响应
注意
请参见 项目 #36 在 第 283 页,你可以查看 HTTP 网络流量。
HTTPS
一种安全变体的 HTTP 称为HTTPS(超文本传输协议安全),通常用于加密通过互联网发送的数据。加密是将数据编码成无法读取的格式的过程。解密是加密的反过程,使加密数据重新变得可读。加密算法使用称为加密密钥的秘密字节序列来加密和解密数据。由于密钥可以保持秘密,因此算法本身可以是公开的。
HTTPS 使用两种加密方式。对称加密使用一个共享密钥来加密和解密信息。非对称加密使用两把密钥(一个密钥对):公钥用于加密数据,私钥用于解密数据。非对称加密允许公钥自由共享,以便任何人都可以加密和发送数据,而私钥则仅与需要接收和解密数据的信任方共享。
没有 HTTPS 时,网页流量以“明文”方式传输,这意味着它是未加密的,可能会在传输过程中被恶意方截获或修改。HTTPS 有助于减少这些风险。在 HTTPS 中,整个 HTTP 请求都会被加密,包括 URL、头信息和主体。HTTPS 响应也是如此,它是完全加密的。HTTPS 将 HTTP 请求使用一种叫做传输层安全协议(TLS)的协议进行加密。过去,使用类似的协议叫做安全套接字层(SSL),但由于安全问题,它已被弃用,取而代之的是 TLS。当我们谈到 HTTPS 时,指的就是使用 TLS 加密的 HTTP。
当一个 HTTPS 会话开始时,客户端会通过一个包含安全通信方式的 客户端 hello 消息连接到服务器。服务器则以 服务器 hello 消息回应,确认通信的方式。服务器还会发送一组称为 数字证书 的字节,其中包括服务器的公钥,用于非对称加密。然后,客户端检查服务器的证书是否有效。如果有效,客户端使用服务器的公钥加密一串字节,并将加密后的消息发送给服务器。服务器使用其私钥解密这些字节。客户端和服务器都利用先前交换的信息来计算一个共享的秘密密钥,用于对称加密。一旦客户端和服务器都拥有了共享密钥,所有在会话期间的通信都将使用该密钥进行加密和解密。
HTTPS 之前只在有限的情况下使用,通常用于处理特别敏感信息的网站。然而,网络正在向 HTTPS 成为常态而非例外的方向发展。越来越多的观点认为,HTTPS 的安全性和隐私保护对大多数,甚至是所有网络流量都是有意义的。谷歌通过在 Chrome 浏览器中将 HTTP 网站标记为“不安全”并利用 HTTPS 作为正面信号来提升其搜索引擎排名,推动了这一变化。
注意
请参见项目 #37,它介绍了如何在本地网络上设置一个简单的 Web 服务器,详细内容见第 285 页。
可搜索的网络
对于许多人来说,访问网络的典型入口是通过搜索。用户不需要直接访问特定的 URL,而是将一些搜索词输入到浏览器中,查看搜索结果。浏览器设计鼓励这种做法,因为浏览器通常会将地址栏也当作搜索框使用。即使用户想访问某个特定网站,他们通常会先搜索该网站,然后点击搜索结果中的链接,而不是在地址栏中输入完整的 URL。这种设计增强了浏览器的可用性,即使它模糊了 URL 和搜索词、浏览器和搜索引擎之间的区别。
尽管搜索网络的普遍性和实用性,搜索功能并不是网络的原生特性。没有标准的规范来定义搜索应该如何工作。这意味着,作为网络的关键特性之一,搜索依赖于非标准的、专有的搜索引擎。在本文写作时,谷歌主导着网络搜索领域,尽管也有一些不错的替代搜索引擎,但它们的全球使用量仅为谷歌的一小部分。
网络的语言
任何可以保存为文件的内容都可以托管在网上。例如,Web 服务器可以托管一组 Excel 文件,这些文件可以从网站上下载并在 Excel 中打开。然而,网页浏览器不仅仅是一个下载文件并在其他应用程序中打开的工具。网页浏览器不仅下载内容,还会渲染网页。这些网页可以是简单的文档,也可以是互动的网页应用程序。为了实现这一点,浏览器理解三种计算机语言,这些语言用于构建网站。
超文本标记语言 (HTML) 定义了网页的结构。换句话说,它定义了页面上的内容。例如,HTML 可以指定网页上存在一个按钮。
层叠样式表 (CSS) 定义网页的外观。换句话说,它定义了页面的外观。例如,CSS 可以指定前述按钮的宽度为 30 像素,并且是蓝色的。
JavaScript 定义网页的行为。换句话说,它定义了页面的功能。例如,JavaScript 可以在点击按钮时将两个数字相加。
这三种语言一起用于创建网页内容。值得注意的是,网页浏览器也能够渲染其他数据类型,特别是某些图像、视频和音频格式,但我们不会详细讨论这些内容。现在,让我们深入了解构成网页的三种基础语言:HTML、CSS 和 JavaScript。
使用 HTML 构建网页结构
HTML 是一种标记语言,用于描述网页的结构。请注意,HTML 不是一种编程语言。编程语言描述的是计算机应该执行的操作,而标记语言描述的是数据的结构。就 HTML 而言,这些数据代表的是网页。一个网页可以包含多种元素,如段落、标题和图片。以下是一个用 HTML 描述的简单网页示例。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A Cat</title>
</head>
<body>
<h1>Thoughts on a Cat</h1>
<p>This is a cat.</p>
<img src="cat.jpg" alt="cat photo">
</body>
</html>
你会看到许多内容被小于符号 (<) 和大于符号 (>) 包围。这些被称为 HTML 标签,是用于定义 HTML 文档部分的文本字符集。举个例子,表示段落开始的标签是 <p>,用于表示段落结束的标签是 </p>。请注意结束标签中的斜杠,它与开始标签不同。HTML 元素 是指以开始标签开始,以结束标签结束,并包括标签之间的内容的网页部分。例如,这是一个 HTML 元素:<p>这是一个猫。</p>。事实上,并不是所有元素都需要结束标签。例如,用于表示图像的 img 元素就不需要结束标签。你可以在前面的 HTML 代码示例中看到这一点。
图 12-5 显示了该 HTML 示例在网页浏览器中的渲染效果。

图 12-5:我们的示例网页在网页浏览器中的渲染效果
该文档故意没有包含任何关于如何呈现的信息,因此浏览器会使用默认的字体和大小来显示标题和段落。在这个例子中,浏览器还默认使用黑色文字和白色背景——同样,这在文档中并没有指定。由于这个 HTML 示例没有包含样式信息,不同的浏览器可能会略微不同地呈现这个页面。
让我们更仔细地看看 HTML 代码示例。HTML 文档的第一行声明该文件是一个 HTML 文档,如下所示:<!DOCTYPE html>。之后,HTML 文档被结构化为一棵树,包含父元素和子元素。<html>标签是顶级父标签;所有内容都包含在<html>和</html>之间。你可以将这两个标签解释为“HTML 从这里开始”和“HTML 到此结束”。这很有道理——HTML 文档中的一切都应该是 HTML!<html>标签还包含一个名为lang的属性,它标识该文档的语言为en,即英语的代码。
html元素有两个子元素:head和body。位于 head 中的元素(在<head>和</head>之间)描述文档,而位于 body 中的元素(在<body>和</body>之间)构成文档的内容。
在我们的示例中,head 包含两个元素:一个meta元素,用于描述用于编码文档的字符集,还有一个title。浏览器通常会在页面的标签上显示标题文本,并在用户添加书签或收藏夹时将其作为默认名称。搜索引擎在显示结果时也会使用标题文本。因此,网页开发人员为页面提供有意义的标题非常重要。
在我们的示例中,body 部分包含一个<h1>标签,用于表示标题元素。<h1>至<h6>有不同级别的标题标签,其中h1用于最高级别的章节标题,h6用于最低级别的标题。标题之后是一个段落,用<p>标签表示,接着是一个通过<img>标签包含的图像。请注意,图像本身的字节并没有出现在 HTML 中。相反,<img>标签仅通过相对 URL(cat.jpg)引用图像文件。为了完全加载此页面,浏览器需要发送一个单独的 HTTP 请求来下载图像。在此示例中,图像的 URL 仅是文件名,意味着它托管在与文档相同的服务器和路径下。如果图像托管在其他地方,则可以使用带有路径或服务器名称的 URL。<img>标签还有一个alt属性,它提供描述图像的替代文本。这在图像无法呈现时使用,例如当使用纯文本浏览器或朗读页面内容的屏幕阅读器时。
你可能注意到,之前的 HTML 代码使用了缩进来显示页面上各种元素的嵌套关系。例如,<h1>和<p>标签被缩进到相同的级别,表明它们是<body>标签的子元素。这是网页开发中的一种常见做法,用于提高 HTML 的可读性,但并不是强制要求的。事实上,HTML 文档中的空格除了单个空格或制表符之外都不重要!我们可以移除所有多余的空格、制表符和换行符,将 HTML 放在一行中,文档在浏览器中的呈现效果也不会改变。网页浏览器会忽略多余的空白,因此页面元素的排版对开发者有帮助,但对浏览器无影响。
注意
请参见项目#38,该项目在第 287 页展示了如何使本地网站返回使用 HTML 结构的文档,而非简单文本。
我们目前所讲解的 HTML 元素只是网页浏览器识别的所有元素的一小部分。我们在这里不会详尽地覆盖所有 HTML 内容;这些内容在网上有充分的文档资料。HTML 的规范以前由两个组织维护:万维网联盟(W3C)和网页超文本应用技术工作组(WHATWG)。HTML 的最后一个获得 W3C“推荐”状态的主要版本是HTML5。2019 年,这两个组织达成协议,HTML 标准的持续开发将主要由 WHATWG 负责,这被称为HTML 生活标准,并且会不断维护。
现代浏览器尝试支持当前版本和较早版本的 HTML,因为许多网页内容是基于早期 HTML 标准编写的。过去,浏览器引入了非标准的 HTML 元素,其中一些最终成为标准化元素,而其他一些则不再使用并失去支持。网页浏览器开发者必须在创新与遵循标准之间找到平衡,同时仍然支持互联网上有时会遇到的不完美 HTML。网页浏览器不断进化,不同的浏览器有时会以不同的方式呈现相同的内容。这意味着网页开发者通常需要在多个浏览器中测试他们的作品,以确保一致的行为。
用 CSS 美化网页
在我们之前的 HTML 示例中,我们使用了描述文档结构的标签,但这些标签并没有传达文档应该如何呈现的信息。这是有意为之;我们希望保持结构和样式的分离。结构与样式之间的分离使得相同的内容可以在不同的上下文中以不同的样式呈现。例如,大多数网页内容在大屏 PC 显示器上和小屏移动设备上应该有不同的呈现方式。
层叠样式表(CSS) 是用于描述网页样式的语言。样式表是一个规则列表。每个规则描述了应该应用于页面某个部分的样式。每条规则包括一个选择器,指示哪些页面元素应该应用该样式。层叠 术语指的是多个规则可以应用于同一个元素的能力。让我们看一个简单的例子:
p {
font-family: Arial, Helvetica, sans-serif;
font-size: 11pt;
margin-left: 10px;
color: DimGray;
}
h1 {
font-family: 'Courier New', Courier, monospace;
font-size: 18pt;
font-weight: bold;
}
在此示例中,为段落(p)元素和一级标题(h1)元素定义了样式规则。当此 CSS 应用到页面时,页面上的所有段落都使用指定的字体,字号为 11 磅,左边距为 10 像素,文本为灰色。同样,h1 标题使用指定的加粗字体,字号为 18 磅。请注意,font-family 是一个字体列表,而不仅仅是单一字体。这意味着网页浏览器应该尝试找到匹配的字体,从最左边的字体开始,向右查找,直到找到匹配的字体为止。并非每个客户端设备都安装了首选字体;指定多个字体可以增加找到匹配字体的机会。
你可以通过几种方式将样式表应用于网页。一个选择是将 CSS 规则包含在页面上的 style 元素中。例如:
<style>p {color: red};</style>
这样做并不理想,因为样式和结构现在紧密相关。更好的做法是将 CSS 规则指定在一个单独的文件中,该文件也托管在网络上。这种方法使我们的 HTML 和 CSS 完全分离,并允许多个 HTML 文件使用同一个样式表。这样我们可以修改一个 CSS 规则,并使其立即应用到多个页面。在 HTML 的头部部分,我们可以使用一个元素来应用来自 CSS 文件的样式表规则,如下所示(其中 style.css 是要应用的 CSS 文件的 URL):
<link rel="stylesheet" type="text/css" href="style.css">
如果我们将此样式表应用于我们的示例猫页面,则会看到标题和段落文本发生这些变化,如 图 12-6 所示。

图 12-6:我们的示例网页,已应用 CSS 样式
注意
请参见 项目 #39 第 288 页,你可以通过一些 CSS 更新你的猫网页。
这个 CSS 示例很简单,但 CSS 也允许进行更为复杂的样式设置。如果你熟悉网页上各种令人惊叹的视觉样式,那么你已经看到了 CSS 强大功能的体现。
使用 JavaScript 脚本编写网页
Web 最初是作为通过超文本文档共享信息的方式构想的。HTML 给我们提供了这个能力,CSS 则为我们提供了控制这些文档展示的方法。然而,Web 逐渐演变成了一个交互内容的平台,JavaScript 成为了启用交互的标准方式。JavaScript 是一种编程语言,它使网页能够响应用户的操作,并以编程方式执行各种任务。使用 JavaScript,Web 浏览器不仅仅是一个文档阅读器,而是一个完整的应用程序开发平台。
JavaScript 是一种解释型语言;它在传递给浏览器之前并不会编译成机器代码。Web 服务器以文本格式托管 JavaScript 代码,浏览器会下载这些代码并在运行时进行解释。也就是说,一些浏览器使用即时编译器 (JIT),在运行时编译 JavaScript,从而提高性能。一些开发者会在部署之前对 JavaScript 进行压缩,去除空白符、注释,并通常会减小脚本的大小。压缩 JavaScript 可以提高网站的加载速度。压缩并不等同于编译;压缩后的文件仍然是高级代码,而非编译后的机器代码。
JavaScript 的语法类似于 C 语言以及其他从 C 借用的语言(如 C++、Java 和 C#)。然而,这种相似性仅仅是表面的,因为 JavaScript 与这些语言有很大的不同。不要让名字迷惑你:JavaScript 与 Java 几乎没有关系。该语言是面向对象的,但根本上依赖于原型而不是类。也就是说,现有的对象而非类,作为其他对象的模板。
JavaScript 使用浏览器提供的页面表示与 HTML 页面进行交互,这种表示被称为文档对象模型 (DOM)。DOM 是一个页面元素的层级树结构,并且可以通过编程进行修改。对 DOM 中元素的更新会导致浏览器在显示的网页上更新该元素。JavaScript 包含用于处理 DOM 的方法,利用这些方法,JavaScript 代码不仅可以响应页面上发生的事件(例如按钮的点击),还可以改变渲染页面的内容。
让我们看一下与我们示例页面交互的简单脚本的一部分。每次点击猫咪照片(或在触摸屏上轻触)时,脚本都会将文本Meow!添加到页面的段落中。
document.getElementById('cat-photo').onclick = function() {
document.getElementById('cat-para').innerHTML += ' Meow!';
};
这里的第一行添加了一个事件处理器,当点击猫咪照片时会触发。事件处理器的代码在下一行定义,它告诉浏览器将文本Meow!添加到段落中。由于这是一个事件处理器,它只有在图片点击事件发生时才会运行。请注意,脚本通过 ID 引用照片和段落,分别是cat-photo和cat-para。HTML 元素可以设置 ID;这使我们可以轻松地通过编程引用它们。我们的脚本只有在我们将这些 ID 添加到 HTML 中时才有效。以下是更新后的 HTML,它引用了脚本(名为cat.js)并添加了所需的 ID。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A Cat</title>
<link rel="stylesheet" type="text/css" href="style.css">
<script src="cat.js"></script>
</head>
<body>
<h1>Thoughts on a Cat</h1>
<p id="cat-para">This is a cat.</p>
<img id="cat-photo" src="cat.jpg" alt="cat photo">
</body>
</html>
一旦脚本代码保存为cat.js,并且 HTML 按照所示方式更新后,重新加载页面并点击猫咪图片,就会将Meow!附加到段落中。如果我们多次点击该图片,我们最终会得到类似图 12-7 所示的效果。

图 12-7:运行 JavaScript 代码并附加文本后的示例网页
注意
请参见项目 #40,在第 289 页,你可以使用 JavaScript 更新你的网页。
JavaScript 可以用来构建在网页浏览器中运行的完整应用程序。前面的例子只是它能做的冰山一角。JavaScript 以 ECMAScript 规范为标准化。各种浏览器实现了脚本引擎,力求遵守 ECMAScript 标准的全部或部分内容,该标准会定期更新。
使用 JSON 和 XML 构建网络数据结构
网站并不是网络上唯一的内容类型。Web 服务通过 HTTP 提供数据,旨在通过编程方式进行交互。这与返回 HTML(及相关资源)并旨在通过网页浏览器供用户消费的网站不同。大多数终端用户从不直接与 Web 服务交互,尽管我们使用的网站和应用通常是由 Web 服务支撑的。
想象一下,你经营着一个关于本地乐队信息的网站,网站包含每个乐队的资料,包括乐队成员、背景、演出地点等。终端用户可以访问你的网站,轻松阅读自己喜欢的音乐人的信息。现在,假设有一位应用开发者联系你,希望将你网站上的最新信息纳入他们的应用中。然而,该应用有自己完全不同的展示方式——开发者并不想仅仅在应用中展示你的网站页面。他们需要一种方式来获取你网站上底层的数据。他们可以尝试通过编程读取你的网站页面并提取相关信息,但这个过程复杂且容易出错,特别是如果你网站的布局发生变化时。
通过提供一个将网站数据以非 HTML 格式展示的 Web 服务,可以使得这个开发者的工作变得更加轻松。尽管 HTML 确实提供了一定的结构,但它描述的是一个文档的结构(如标题、段落等),并且对文档中引用的数据类型几乎没有提供任何见解。HTML 对于人类读者是有意义的,但对软件来说解析起来困难。那么,你的 Web 服务应该使用什么格式来构建关于乐队的数据呢?当前 Web 服务中最常用的一般用途数据格式是 XML 和 JSON。
可扩展标记语言 (XML) 自 1990 年代以来就已出现,并且是一种流行的 Web 数据交换方式。像 HTML 一样,XML 是基于文本的标记语言,但与其预定义标签不同,XML 允许使用自定义标签来描述数据。在我们虚构的乐队信息服务中,我们可能会定义一个 <band> 标签和一个 <concert> 标签。让我们看一下使用 XML 描述的虚构乐队:
<band name="The Highbury Musical Club">
<bandMembers>
<member name="Jane Fairfax" instrument="Piano" />
<member name="Emma Woodhouse" instrument="Guitar" />
<member name="Harriet Smith" instrument="Percussion" />
<member name="Frank Churchill" instrument="Vocals" />
</bandMembers>
<upcomingConcerts>
<concert location="Donwell Abbey" date="August 14, 2020" />
<concert location="Hartfield" date="November 20, 2020" />
</upcomingConcerts>
</band>
如你所见,具体的 XML 标签及其属性是根据我们的需求定制的,而开始标签、结束标签和树形结构的通用结构与 HTML 类似。XML 的灵活性使得标签可以被任意定义,这意味着 XML 的生产者和消费者需要就预期的标签及其含义达成一致。HTML 也存在这个问题,但 HTML 中所有方参与方都同意一个标准。而在 XML 的情况下,只有通用格式是标准化的,具体标签则有所不同。
XML 是一种流行的 Web 数据共享方法,许多 Web 服务将 XML 作为其主要的数据表示方式。然而,XML 的冗长性和正确解析它的难度是一个挑战。
JavaScript 对象表示法 (JSON),和 XML 一样,是一种以文本格式描述数据的方法。JSON 避免使用标记标签,而是采用了一种类似于 JavaScript 语法描述对象的风格,因此得名。在 JSON 中,对象用大括号({ 和 })包裹,数组(对象的集合)用方括号([ 和 ])括起来。它的语法比 XML 更简洁,这有助于减少通过网络传输的数据大小。JSON 的流行始于 2010 年代,当时它开始取代 XML 成为新 Web 服务首选的数据格式。以下是使用 JSON 描述的相同虚构乐队:
{
"name": "The Highbury Musical Club",
"bandMembers": [
{
"name": "Jane Fairfax",
"instrument": "Piano"
},
{
"name": "Emma Woodhouse",
"instrument": "Guitar"
},
{
"name": "Harriet Smith",
"instrument": "Percussion"
},
{
"name": "Frank Churchill",
"instrument": "Vocals"
}
],
"upcomingConcerts": [
{
"location": "Donwell Abbey",
"date": "August 14, 2020"
},
{
"location": "Hartfield",
"date": "November 20, 2020"
}
]
}
XML 和 JSON 都会忽略多余的空白字符,因此,和 HTML 一样,我们可以去掉所有多余的空格、制表符和换行符,而不会影响数据的解释。这样做能生成相当紧凑的数据表示,尤其是在 JSON 的情况下。
XML 和 JSON 并不是为在网页浏览器中直接渲染而设计的格式。在某些浏览器中打开 JSON 或 XML 内容,可能会显示一些内容(也许是稍微格式化过的数据版本),但实际上,JSON 和 XML 并不是为了被网页浏览器直接消费而设计的。它们是供代码读取,然后用数据做一些有用的事情。也许这些代码是一个智能手机应用,展示附近正在演出的乐队信息,就像我们示例中的情况。或者,也可能是客户端 JavaScript 代码,将 JSON 转换为 HTML 供浏览器展示。
网页浏览器
现在我们已经讲解了用于描述网页的语言,让我们来看看网页客户端的软件 —— 网页浏览器。第一个网页浏览器叫做 WorldWideWeb(不要与本章讨论的主题混淆)。它是由蒂姆·伯纳斯-李(Tim Berners-Lee)于 1990 年开发的。这个第一个浏览器是第一个网页服务器 CERN httpd 的客户端。几年后,WorldWideWeb 被 Mosaic 浏览器取代,后者帮助普及了互联网。接下来的主要浏览器发布是 Netscape Navigator,它也有大量的用户群体。1995 年,微软发布了他们的第一个浏览器 Internet Explorer,作为 Netscape Navigator 的直接竞争对手,Internet Explorer 成为了当时的主流浏览器。如今,浏览器市场发生了巨大变化,目前主流的浏览器是 Google Chrome、Apple Safari 和 Mozilla Firefox。
渲染页面
现在,让我们来看一下网页浏览器渲染页面的过程。访问网站的典型流程从请求网站的默认页面(比如 www.example.com/)开始,或者请求网站上的特定页面(比如 www.example.com/animals/cat.html)。用户可以直接在地址栏输入这个 URL,或者通过点击链接来到这个 URL。无论哪种方式,浏览器都会请求指定 URL 的内容。如果 URL 是有效的并且代表一个网页,服务器就会返回 HTML 内容。
然后,网页浏览器必须将返回的 HTML 解析并生成页面的 DOM 表示。HTML 可能包含对其他资源的引用,比如图片、脚本和样式表。每个资源都有自己的 URL,浏览器会为每个资源单独发起请求,如图 12-8 所示。

图 12-8:网页浏览器请求一个页面及其引用的内容
一旦浏览器检索到页面的各种资源,它会显示 HTML,并使用任何指定的 CSS 来确定适当的呈现方式。任何脚本都会交给 JavaScript 引擎运行。JavaScript 代码可能立即对页面进行更改,或者它可能注册事件处理程序,在某些事件发生时再执行。JavaScript 代码也可以请求来自 Web 服务的数据,并使用这些数据来更新页面。
Web 浏览器由一个渲染引擎(用于 HTML 和 CSS)、一个 JavaScript 引擎和一个将这些功能连接起来的用户界面组成。虽然用户界面提供了浏览器本身的外观和感觉(例如后退按钮和地址栏的外观),但正是渲染引擎和 JavaScript 引擎决定了网站的呈现方式和行为(这包括页面如何布局以及如何响应输入等)。由于每个渲染引擎和 JavaScript 引擎的处理方式略有不同,网页在不同浏览器中的显示或行为可能会有所不同。理想情况下,所有浏览器应该以相同的方式渲染内容,完全按照网站开发者的意图,但现实情况并非总是如此。在撰写本文时,只有三个主要的渲染引擎正在积极开发:WebKit、Blink 和 Gecko。
WebKit 是苹果 Safari 浏览器的渲染引擎和 JavaScript 引擎。它也被应用于 iOS 应用商店中的应用程序,因为苹果要求所有展示 Web 内容的 iOS 应用都必须使用这个引擎。Blink,这是 WebKit 的一个分支,是 Chromium 开源项目的渲染引擎,该项目还包括 V8 JavaScript 引擎。Chromium 是 Google Chrome 和 Opera 的基础。在 2018 年 12 月,微软宣布其 Microsoft Edge 浏览器也将基于 Chromium;微软选择停止开发自己的渲染引擎和 JavaScript 引擎。现在只剩下一个主要的浏览器没有追溯到 WebKit——Mozilla Firefox,它拥有自己的 Gecko 渲染引擎和 SpiderMonkey JavaScript 引擎。
注意
软件分支(fork)发生在开发者复制一个项目的源代码并对其进行更改时。这使得原始项目和分支项目可以作为独立的软件共存。
用户代理字符串
Web 浏览器的正式技术术语是用户代理。这个术语也可以应用于其他软件(任何代表用户执行操作的软件),但在这里我们特别讨论的是 Web 浏览器。这个术语出现在有关 Web 的技术文档中,尽管在正式的交流之外很少使用。也就是说,术语实际使用的一个地方是用户代理字符串。当浏览器向 Web 服务器发出请求时,通常会包括一个名为User-Agent的头部值,用以描述浏览器。例如,下面是 Windows 10 上 Chrome(版本 71)发送的用户代理字符串:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/71.0.3578.98 Safari/537.36
这看起来似乎有些矛盾。这到底是什么意思呢?
第一个条目 Mozilla/5.0 是网络早期的遗留物。Mozilla 曾是 Netscape Navigator 的用户代理名称,许多网站专门在用户代理字符串中寻找“Mozilla”以指示它们向浏览器发送网站的最新版本。那时,其他浏览器也希望获得网站的最佳版本,因此它们也自称是 Mozilla,尽管根本不是 Mozilla。时至今日,几乎每个浏览器都自称是 Mozilla,我们发现这一部分的用户代理字符串其实没有多大意义。
括号中的下一部分 (Windows NT 10.0; Win64; x64) 指定了浏览器运行的平台。
紧接着是渲染引擎,这里是 AppleWebKit/537.36。如前所述,Chrome 的 Blink 引擎是 WebKit 的一个分支,并且仍然自称如此。接下来的文本 (KHTML, like Gecko) 进一步阐明了这一点;KHTML 是 WebKit 基于的一个遗留引擎。
现在我们来看实际的浏览器名称和版本,Chrome/71.0.3578.98。
最后,我们看到一个尴尬的提及——苹果浏览器 Safari/537.36,它出现在某些网站需要对 Safari 特殊处理时。通过包括这段文本,Chrome 尝试确保这些网站发送给它与 Safari 所接收的内容相同。
这是一个相当复杂的方式来标识 Chrome,但其他浏览器也做同样的事情,以确保与各种网站兼容。这种复杂性是不同浏览器和网站之间历史上功能碎片化的副作用,这些网站试图根据特定浏览器发送定制的内容。随着浏览器的发展,今天浏览器功能的差异性较小。然而,许多网站没有随之发展,仍然会为特定浏览器发送定制内容,迫使现代浏览器继续让旧网站相信它们正在与另一个浏览器通信。
网页服务器
到目前为止,我们主要关注的是用于网页客户端的技术。网页浏览器使用三种常见的语言:HTML、CSS 和 JavaScript。那么,网页的服务器端呢?用于支持网页服务器的是什么语言和技术呢?简而言之,任何编程语言或技术都可以用于网页服务器,只要该技术能够通过 HTTP 进行通信,并返回客户端能够理解的数据格式。
广义上讲,网站可以分为静态和动态两种。静态网站返回的是提前构建好的 HTML、CSS 或 JavaScript。通常,网站内容存储在服务器上的文件中,服务器仅返回这些文件的内容而不做修改。这意味着任何需要的运行时处理必须通过浏览器中的 JavaScript 来实现。另一方面,动态网站则在服务器上进行处理,当请求到来时生成 HTML。
在早期的网络时代,几乎所有的内容都是静态的。页面是简单的 HTML,几乎没有交互性。随着时间的推移,开发人员开始添加在 Web 服务器上运行的代码,使得服务器能够返回动态内容,或者接受用户上传的文件或表单提交。这一趋势持续发展,服务器端处理请求成为常见做法,直到服务器才响应。
让我们看看动态网站的服务器端处理通常是如何工作的,如图 12-9 所示。假设图 12-9 中的动态网站是一个博客。浏览器请求一篇博客文章。当 Web 服务器收到请求时,它会读取请求的 URL,并确定需要生成 HTML。服务器上的代码然后查询数据库(可能是在 Web 服务器上,也可能在其他服务器上),获取相关的博客文本数据,将文本格式化为 HTML,并将该 HTML 返回给客户端。这种方法很有用,因为它允许网站内容与网站代码分开管理,但动态网站也有一些缺点。服务器端的复杂性增加意味着设置工作更多,运行时响应更慢,服务器负载可能较重,而且安全问题的风险也增大。

图 12-9:典型的动态网站处理请求
最近,出现了一种趋势,即尽可能回归静态网站。静态网站中页面请求的流动如图 12-10 所示。

图 12-10:静态网站处理请求
如图 12-10 所示,静态网站的服务器端处理相比于动态网站更为简化。静态网站的服务器端处理仅仅是返回与请求的 URL 匹配的静态文件。内容已经构建好,服务器无需再获取原始数据并格式化。减少服务器端的复杂性通常意味着网站更简单、更快速,并且更加安全。
需要理解的是,在这种语境下,静态和动态是从服务器的角度来看,而非用户的角度。静态网站的内容来自服务器上未更改的文件,而动态网站的内容则是由服务器生成的。这些术语并不描述用户体验网站的方式,例如网站是否具有交互性,或者内容是否会自动更新。这些体验可以通过浏览器中的 JavaScript 来实现,有时还会与独立的 Web 服务结合使用,无论网站本身是静态的还是动态的。
如果你托管的是静态网站,你只需要一个能够响应静态文件请求并提供这些文件内容的网页服务器软件。无需编写自定义代码。许多软件包和在线服务都可以用于托管静态网站。通常,用于服务静态网站的软件会配置为指向服务器上的一个文件目录,当请求某个文件时,服务器只需返回该文件的内容。例如,如果位于example.com的网页文件存放在服务器上的目录/websites/example中,那么对* example.com/images/cat.jpg的请求将映射到/websites/example/images/cat.jpg*。网页服务器只需从本地目录读取匹配的文件,并将该文件中包含的字节返回给客户端。在项目#37 到#40 中开发的网站就是一个静态网站的例子。
如果你正在构建一个动态网站或网络服务,你可以选择使用现有的软件来管理你的内容并提供动态页面,或者你可以编写自定义代码来生成网页内容。假设你在编写自定义代码,你会发现与客户端开发相比,服务器端的情况截然不同。任何编程语言、任何操作系统、任何平台都可以用作网页服务器。只要网页服务器通过 HTTP 响应并返回客户端可以理解的格式的数据,几乎什么技术都可以!客户端并不关心生成 HTML 或 JavaScript 时使用了什么技术,它只需要一个它能够处理的格式的响应。
由于客户端并不关心网页服务器端使用了什么技术,因此希望编写在服务器上运行代码的开发者有很多选择。客户端网页开发仅限于 HTML、CSS 和 JavaScript 三者,而服务器端网页开发可以使用 Python、C#、JavaScript、Java、Ruby、PHP 等多种语言。服务器端网页开发通常包括与某种数据库的交互。就像任何编程语言都可以用于服务器一样,任何类型的数据库也可以用于服务器端网页开发。
总结
本章我们讨论了网页——一个分布式的、可寻址的、相互链接的资源集合,通过 HTTP 在互联网中传输。你学习了网页如何使用 HTML 进行结构化,使用 CSS 进行样式化,使用 JavaScript 进行脚本化。我们还探讨了网页浏览器,它们用于访问网页上的内容,并且我们检查了网页服务器——托管网页资源的软件。在下一章中,我们将探讨一些现代计算的趋势,并且你将有机会完成一个最终项目,将本书中各个概念结合起来。
项目 #36:检查 HTTP 流量
在这个项目中,你将使用 Google Chrome 或 Chromium 来检查 web 浏览器与 web 服务器之间的 HTTP 流量。你可以在 Windows PC 或 Mac 上使用 Chrome,或者在 Raspberry Pi 上使用 Chromium 浏览器。以下步骤假设你在使用 Raspberry Pi,但在 Windows PC 或 Mac 上的过程类似,只需使用 Chrome 代替 Chromium。
-
如果你没有使用 Raspberry Pi 的图形桌面界面,赶紧切换过去。与之前的项目不同,这个项目不能仅通过终端窗口完成。
-
点击Raspberry(左上角的图标)▶ Internet▶ Chromium Web Browser。
-
访问一个网站,如
www.example.com。 -
按下 F12 键(或 CTRL-SHIFT-I)打开开发者工具(DevTools),如图 12-11 所示。
![image]()
图 12-11:Chromium 中的开发者工具
-
在 DevTools 菜单中,选择Network菜单项。
-
按下 F5 键(或点击重载图标)重新加载页面。你将看到加载当前页面时发出的 HTTP 请求。
-
如果你实际使用www.example.com,你可能会看到一个相当简单的请求。如果你想看到更有趣的内容,可以访问一个更复杂的网站,并观察网络请求,如图 12-12 所示。
![image]()
图 12-12:Chromium 开发者工具中显示的一个网站的 HTTP 流量示例
-
每一行代表对 web 服务器的一个请求。你可以看到请求的资源名称、请求的状态(
200表示成功)等信息。 -
你可以点击每一行,查看请求的具体内容,例如请求头和返回的内容。
我建议你在多个网站上尝试一下,了解网站请求的数量。你可能会对传输的内容量感到惊讶! 项目#37:运行你自己的 Web 服务器
在这个项目中,你将设置一个 Raspberry Pi 作为 web 服务器。你将使用 Python 3 来完成这一过程,因此你实际上可以在任何安装了 Python 3 的设备上跟随这些步骤,尽管这些步骤是针对 Raspberry Pi 编写的。我们简单的网站将在收到请求时返回一个文件的内容。
打开一个终端窗口,创建一个目录来存放你的网站文件,然后将该新目录设置为当前工作目录。
$ mkdir web
$ cd web
当向你网站的根目录发起请求时,web 服务器软件会查找名为index.html的文件,并将该文件的内容返回给客户端。让我们创建一个非常简单的index.html文件:
$ echo "Hello, Web!" > index.html
该命令创建了一个名为index.html的文本文件,文件中包含Hello, Web!的文本。你可以通过文本编辑器打开文件来查看文件内容,或者像这样在终端中显示文件内容:
$ cat index.html
一旦文件就绪,让我们使用 Python 内建的 web 服务器将 Hello, Web! 信息提供给任何连接的用户。
$ python3 -m http.server 8888
此命令告诉 Python 在端口8888上运行一个 HTTP 服务器。让我们测试一下,看看它是否按预期工作。打开树莓派上的另一个终端窗口。从这个第二个终端窗口,输入以下命令向你新网站的根目录发起一个 GET 请求:
$ curl http://localhost:8888
curl 工具可以用来发起 HTTP GET 请求,而 localhost 是指当前正在使用的计算机的主机名。此命令告诉 curl 工具向本地计算机的端口 8888 发起一个 HTTP GET 请求。你应该会看到返回的文本是 Hello, Web!。同时,在原始终端中,你应该看到一个 GET 请求已经到达。
现在,让我们尝试通过网页浏览器连接到你的网站。在树莓派桌面上,打开 Chromium 浏览器。在地址栏中输入 http://localhost:8888。你应该会看到网站中的文本出现在浏览器中,如图 12-13 所示。

图 12-13:使用 Chromium 浏览器连接到本地 web 服务器
现在尝试从另一台设备连接到你的网站。为了使这项工作正常进行,第二台设备必须与树莓派在同一网络上。例如,它们应该都连接到同一个 Wi-Fi 网络。或者,如果你的树莓派有公共 IP 地址(参见项目 #34 在第 259 页),那么你的网站可以供互联网上的任何设备访问!首先,通过在第二个终端窗口中运行以下命令获取树莓派的 IP 地址:
$ ifconfig | grep inet
这通常会返回几个 IP 地址。从远程设备连接时,你不能使用 127.0.0.1,因此选择分配给你树莓派的另一个 IP 地址。拿到 IP 地址后,打开另一台设备上的浏览器。可以是智能手机、笔记本电脑,或者任何在你网络上的设备,只要它有网页浏览器。在浏览器窗口的地址栏中输入以下内容:http://w.x.y.z:8888(将 w.x.y.z 替换为你设备的 IP 地址)。按下回车键或浏览器中的相应按钮以导航到该地址。你应该会在浏览器中看到 Hello, Web! 的字样。
如果这对你不起作用,且你的树莓派没有公共 IP 地址,请确保两台设备在同一物理局域网中。此外,有时 Python web 服务器会对新请求无响应。如果 web 服务器停止响应,你可以重启它。要停止 web 服务器,请转到执行服务器命令的终端,按下 CTRL-C 键。然后通过再次运行 python3 -m http.server 8888 命令重新启动服务器(按键盘上的上箭头键可以获取上一个命令)。
一旦你的网站运行正常,尝试编辑index.html文件,并将信息更改为你想要的任何内容。你可以使用任何你喜欢的文本编辑器来完成此操作。更新完index.html文件后,重新加载网页以查看你的更改!
如果你不希望其他设备能够访问你的网站,你可以限制只允许来自树莓派本身的请求获得响应。通过使用--bind选项运行 Python 网页服务器可以实现这一点,方法如下:
$ python3 -m http.server 8888 --bind 127.0.0.1
要使用--bind选项运行网页服务器,首先需要停止任何正在运行的网页服务器实例(按键盘上的 CTRL-C)。
项目 #38:从网页服务器返回 HTML
前提条件:项目 #37。
在本项目中,你将更新本地网页服务器,使其返回 HTML 而不是简单的文本。使用你喜欢的文本编辑器打开index.html(在项目 #37 中创建的文件),并用以下 HTML 代码替换文件中的所有文本。这是本章中讨论的相同 HTML 代码。你无需担心每行的缩进,因为 HTML 中多余的空白字符是无关紧要的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A Cat</title>
</head>
<body>
<h1>Thoughts on a Cat</h1>
<p>This is a cat.</p>
<img src="cat.jpg" alt="cat photo">
</body>
</html>
一旦文件更新完成,你将再次使用 Python 的内置网页服务器。如果它还未运行,使用此命令启动它。在运行命令之前,请确保你的终端窗口当前位于web目录中。
$ python3 -m http.server 8888
现在,使用网页浏览器像在项目 #37 中那样连接到你的网页服务器。你应该看到页面被渲染,但没有猫咪照片。如果你查看运行 Python 网页服务器命令的终端窗口,你应该看到一个尝试获取猫咪照片但失败的请求,如下所示:
192.168.1.123 - - [31/Jan/2020 17:38:56] "GET /cat.jpg HTTP/1.1" 404 -
404错误代码表示无法找到资源,这很合理,因为你在这个目录中没有名为cat.jpg的文件!为什么网页浏览器会请求一张猫咪照片呢?如果你回顾页面的 HTML,会看到一个 HTML <img>标签,它指示浏览器渲染cat.jpg图片。浏览器请求该图片,但由于文件缺失,无法成功获取。
让我们解决丢失的猫咪图片问题。你需要下载一张猫的图片(或者任何东西的图片都可以),格式为 JPEG,并将其保存为~/web/cat.jpg。为了简化,你可以使用以下命令下载本章中使用的图片。在运行命令之前,请确保你的终端窗口当前位于web目录中。
$ wget https://www.howcomputersreallywork.com/images/cat.jpg
现在,你应该在web目录中存有cat.jpg文件。重新加载网页,查看页面中的猫咪图片。提醒:如果网页服务器似乎卡住了,按照项目 #37 中的描述重新启动它。
值得注意的是,你不仅可以在页面中查看猫的图片,还可以直接从服务器请求该图片,因为它有自己的 URL。试着将浏览器指向以下 URL(将SERVER替换为你为网站使用的主机名或 IP 地址):http://*SERVER*:8888/cat.jpg。你应该能看到猫的图片在浏览器中显示出来,且不依赖于网页的内容。网页上引用的每个资源都有自己的 URL,可以直接访问!
项目 #39:为你的网页添加 CSS
前提条件:项目 #38。
在这个项目中,你将使用 CSS 来设计你的网页。首先,使用你选择的文本编辑器,在web目录下创建一个名为style.css的文件。这个文件将包含你的 CSS 规则。确保文件名为style.css,并将其保存到web目录,与index.html和cat.jpg文件一起。style.css的内容应如下所示:
p {
font-family: Arial, Helvetica, sans-serif;
font-size: 11pt;
margin-left: 10px;
color: DimGray;
}
h1 {
font-family: 'Courier New', Courier, monospace;
font-size: 18pt;
font-weight: bold;
}
一旦style.css创建完成,就像在上一个项目中那样,打开index.html进行编辑。保留现有的 HTML 内容。我们只需要在头部区域添加一行,如下所示:
<head>
<meta charset="utf-8">
<title>A Cat</title>
<link rel="stylesheet" type="text/css" href="style.css">❶
</head>
一旦你完成了对index.html的更新❶,启动你的 web 服务器(如果它还没有运行的话),并在浏览器中重新加载页面。你应该能看到页面样式的更新。提醒:如果 web 服务器似乎卡住了,按照项目 #37 中描述的步骤重新启动它。
随意编辑style.css以尝试不同的样式。也许你想把段落字体设得非常大,或者换个颜色!根据自己的喜好编辑样式,保存style.css并在浏览器中重新加载页面。
如果你在浏览器中没有看到更新,可能是因为浏览器加载了你网站的缓存版本,而不是下载最新的版本。试着在新标签页中打开页面,或者完全重新启动浏览器。你还可以告诉浏览器在重新加载时跳过本地缓存。为此,请导航到页面,然后按 CTRL-F5 强制页面重新加载。大多数 Windows 和 Linux 浏览器都支持此操作。在 Mac 上,Chrome 和 Firefox 可以通过 CMD-SHIFT-R 强制刷新。有时需要多次刷新才能让浏览器渲染最新的内容。
项目 #40:为你的网页添加 JavaScript
前提条件:项目 #39。
在这个项目中,你将使用 JavaScript 让你的网页具备交互性。首先,使用你选择的文本编辑器,在web目录下创建一个名为cat.js的文件。这个文件将包含 JavaScript 代码。确保文件名为cat.js,并将其保存到web目录,与index.html和cat.jpg文件一起。cat.js的内容应如下所示:
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('cat-photo').onclick = function() {
document.getElementById('cat-para').innerHTML += ' Meow!';
};
});
一旦cat.js被保存,像在上一个项目中那样,打开index.html进行编辑。保留现有的 HTML 内容,并按照下面所示进行更改:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A Cat</title>
<link rel="stylesheet" type="text/css" href="style.css">
<script src="cat.js"></script>❶
</head>
<body>
<h1>Thoughts on a Cat</h1>
<p id="cat-para"❷>This is a cat.</p>
<img id="cat-photo"❸ src="cat.jpg" alt="cat photo">
</body>
</html>
这些更改引用了脚本 ❶,并给段落 ❷ 和图片 ❸ 添加了 ID。
一旦你更新了index.html,启动你的 web 服务器(如果它尚未运行),并在浏览器中重新加载页面。现在你应该能够点击(或触摸)猫的照片,并看到单词Meow!被附加到段落中。提醒:如果 web 服务器似乎卡住,按项目 #37 中的描述重启它。
第十三章:现代计算**

本章概述了现代计算中的几个选定领域。考虑到计算的多样性和广度,我有许多主题可以选择。我选择的这些领域绝不是当前计算中所有有趣事物的详尽列表。相反,它们代表了我认为值得你关注的一些话题。在本章中,我们将讨论应用程序、虚拟化、云计算、比特币等内容。最后,我们通过一个最终项目,将本书中涉及的多个主题汇集在一起。
应用程序
自计算机早期以来,人们一直将直接供用户使用的软件程序称为应用程序。这个术语被缩写为app,以前这两个术语可以互换使用。然而,自从 Apple 在 2008 年推出 iPhone App Store 以来,app 一词便赋予了新的含义。尽管没有标准的技术定义来区分什么软件程序算作一个 app,但应用程序通常具有一些共同特征。
应用程序是为最终用户设计的。应用程序通常面向移动设备,如智能手机或平板电脑。应用程序通常通过基于互联网的数字商店(即应用商店)分发,如 Apple 的 App Store、Google Play Store 或 Microsoft Store。应用程序对其运行的系统的访问权限有限,通常必须声明它们运行所需的具体功能。应用程序通常使用触摸屏作为主要的用户输入方式。单独使用时,app 一词通常指安装在设备上的软件,并直接利用操作系统的 API。换句话说,app 一词通常指的是原生应用程序,即为特定操作系统构建的应用程序。相对而言,Web 应用程序是使用 Web 技术(HTML、CSS 和 JavaScript)设计的应用程序,并且不依赖于特定的操作系统。图 13-1 提供了原生应用程序和 Web 应用程序的概述。

图 13-1:原生应用程序是为特定操作系统构建的。Web 应用程序则是使用 Web 技术构建的。
如图 13-1 所示,原生应用程序通常是通过应用商店安装的,旨在利用特定操作系统的功能。Web 应用程序通常通过网站运行,旨在使用 Web 技术。Web 应用程序在浏览器或其他呈现 Web 内容的进程中运行。接下来,我们将更详细地了解原生应用程序和 Web 应用程序。
原生应用程序
如前所述,原生应用是为特定操作系统构建的。苹果的 App Store 和随之而来的类似应用商店开启了原生软件开发的新纪元,为开发者提供了新的平台、新的软件分发方式,以及通过软件赚钱的新方式。目前,原生应用开发的现状主要集中在两个平台:iOS 和 Android。虽然软件仍然会为其他操作系统开发,但这些软件通常没有应用程序的典型特征(如移动友好、触摸输入、通过应用商店分发等)。
Android 和 iOS 在编程语言、API 等方面有所不同。因此,要编写一个同时适用于 iOS 和 Android 的应用程序,需要么维护独立的代码库,要么使用像 Xamarin、React Native、Flutter 或 Unity 这样的跨平台框架。这些跨平台解决方案抽象了每个操作系统 API 的底层细节,使开发者能够编写能够在多个平台上运行的代码。许多原生应用也依赖于网络服务,这意味着应用开发者不仅需要为 iOS 和 Android 编写和维护代码,还必须构建或与网络服务集成。
开发一个跨平台的、与网络连接的应用程序需要大量的工作和专业知识!过去,开发者通常只专注于一个平台,例如 Windows PC 或 Mac。今天,对于目标多个平台和网络的开发者来说,事情肯定更复杂了。平台竞争通常对用户是好事,但这也意味着开发者需要付出更多的工作。
有趣的是,当前的应用开发状态本可以完全不同。当 iPhone 在 2007 年 1 月发布时,时任 Apple CEO 的 Steve Jobs 曾就 iPhone 上的第三方应用开发发表过以下言论:
整个 Safari 引擎都在 iPhone 内。因此,你可以编写令人惊叹的 Web 2.0 和 Ajax 应用,这些应用看起来和行为完全像 iPhone 上的应用。这些应用可以与 iPhone 服务完美集成。它们可以打电话,发送电子邮件,查找 Google 地图上的位置。猜猜看?你不需要任何 SDK!
注意
一个SDK(软件开发工具包)是开发者用于为特定平台构建应用程序的软件集合。
根据这段话,苹果最初的第三方应用开发计划是让开发者只需构建类似应用的网站,这些网站可以利用 iPhone 的功能。原生应用开发将仅限于苹果开发并随 iPhone 一起提供的应用程序,如相机、邮件和日历应用。
当时,使用网络作为应用开发平台并不常见。苹果的立场具有前瞻性。不幸的是,2007 年时,网络的基础技术可以说还不够成熟,无法将网络定位为真正的应用平台。到 2007 年 10 月,苹果改变了其立场,宣布允许开发者为 iPhone 开发本地应用。苹果在 2008 年推出了 App Store,这是唯一支持将本地 iPhone 应用分发给用户的机制。
苹果的政策逆转使公司受益,因为 App Store 成为苹果的一项收入来源。注册成为 App Store 开发者需要支付费用,而且苹果会从每笔销售中抽取一定比例。App Store 和本地 iPhone 开发还为独占内容打开了大门,推出了仅能在苹果设备上运行的应用。
App Store 也为最终用户带来了好处。带有评分的精选应用列表很有帮助,且商店提供了一定程度的消费者信任。进入 App Store 的应用必须符合某些质量标准。集中化的支付服务意味着用户无需将支付信息提供给多个公司。应用会自动更新,这相较于传统的 PC 软件是一大优势,尽管相较于网络应用,这并不算优势,因为网络应用也会在没有用户干预的情况下进行更新。
随着苹果 App Store 的成功,其他公司也创建了类似的数字商店来分发软件。Google Play 商店、Microsoft Store 和 Amazon Appstore 都采用了与苹果商店相似的模式,并提供类似的好处。尽管这一系统通常对这些公司和最终用户来说运作良好,但它也为开发者创造了一个复杂的环境:多个商店、多个平台和不同的技术。每个数字市场都有自己的要求,应用开发者必须满足这些要求,而且每个商店都会从销售收入中抽取一定的比例。
网页应用
随着本地应用的兴起,网络逐渐发展成为一个非常适合运行应用的平台。HTML5 作为 HTML 的成熟版本被引入,网页浏览器在处理内容方面变得更加高效和一致。浏览器开发者使得他们的 JavaScript 实现符合 ECMAScript 5 标准,为 JavaScript 代码提供了更好的基础。除了浏览器更新外,网页开发者社区接受并继续采用一种叫做响应式网页设计的概念,这种方法确保无论显示内容的屏幕大小如何,网页内容都能良好呈现。通过使用响应式设计技术,网页开发者可以维护一个适用于各种设备的单一网站,而不是为不同设备创建多个网站。此外,近年来发布了多个网页开发框架,如 Angular 和 React。这些框架使得开发者更容易编写和维护网页应用——表现得像应用程序的网页。
开发者已经意识到,现代网页技术可以用来构建与本地应用程序非常相似的体验,许多开发者构建了像应用程序一样运行的网站。有些开发者选择完全放弃本地应用程序,只构建网页应用程序。这个方法的优点是,网页应用程序可以在任何具有现代网页浏览器的设备上运行,且代码只需要编写一次。然而,网页应用程序也有一些缺点。网页应用程序无法访问设备的全部功能,通常比本地应用程序慢,要求用户保持在线,并且通常不会列在应用商店中。
为了弥补网页应用程序的一些缺点,渐进式网页应用程序(PWAs) 提供了一套技术和指南,帮助缩小本地应用程序和网页应用程序之间的差距。PWA 仅仅是一个具有一些额外功能的网站,这些功能使其更像一个应用程序。一个渐进式网页应用程序必须通过 HTTPS 提供服务,能够在移动设备上适当地渲染,下载后能够离线加载,提供一个描述该应用程序的清单文件给浏览器,并且在页面之间快速过渡。对最终用户来说,运行一个 PWA 应该和运行一个本地应用程序一样响应迅速且自然。如果一个网站符合 PWA 的标准,现代的网页浏览器会给用户提供将 PWA 图标添加到主屏幕或桌面的选项。这样做意味着用户可以像启动本地应用程序一样启动网页应用程序。该应用程序将在自己的窗口中打开,而不是浏览器窗口中,并且通常表现得像一个本地应用程序。
渐进式网页应用程序可能为那些希望使用网页技术构建应用程序但又不想为不同平台构建多个应用程序的开发者提供巨大好处。然而,PWA 仍然存在一些缺点。一个显著的缺点是,PWA 不会出现在应用商店中。移动操作系统多年来一直在训练用户,应用程序应该通过应用商店获取。用户不习惯通过浏览网页来获得应用程序。在撰写本文时,只有 Microsoft Store 允许将 PWA 直接发布到商店。其他平台则要求 PWA 从浏览器安装或重新打包为本地应用程序来渲染网页内容。这个重新打包的应用程序可以提交到商店。另一个潜在的缺点是,PWA 可能看起来不像本地应用程序;它们通常在所有平台上看起来基本相同,尽管有些人可能认为这是好事。PWA 仍然无法达到本地应用程序的性能,也无法访问底层平台的所有功能,但根据应用程序的需求,这不一定是一个问题。
虚拟化与仿真
计算机何时不是一个物理设备?当然是当它是一个虚拟计算机时!虚拟化是利用软件创建计算机虚拟表示的过程。另一种相关技术,仿真,允许为某种类型的设备设计的应用程序在完全不同类型的设备上运行。本节我们将探讨虚拟化和仿真两者。
虚拟化
虚拟计算机,称为虚拟机(VM),运行操作系统,就像物理计算机一样。接着,应用程序在该操作系统上运行。从应用程序的角度来看,虚拟化的硬件就像物理计算机一样。虚拟化使得几种有用的场景成为可能。运行一个操作系统的计算机可以在虚拟机中运行另一个操作系统。例如,运行 Windows 的计算机可以在虚拟机中运行 Linux 的实例。虚拟机还允许数据中心在单一物理服务器上托管多个虚拟服务器。这为互联网托管公司提供了一种简单快捷的方式来为客户提供专用服务器,只要客户可以接受虚拟服务器。虚拟机可以轻松备份、恢复和部署。
虚拟机监控器(Hypervisor)是运行虚拟机的软件平台。如图 13-2 所示,虚拟机监控器有两种类型。

图 13-2:类型 1 和类型 2 虚拟机监控器
如图 13-2 左侧所示,虚拟机监控器可以直接与底层硬件交互,实际上将虚拟机监控器放置在技术栈中的内核下方。虚拟机监控器与物理硬件通信,并将虚拟化硬件呈现给操作系统内核。这被称为类型 1 虚拟机监控器。相比之下,类型 2 虚拟机监控器,如图 13-2 右侧所示,作为操作系统上的应用程序运行。微软的 Hyper-V 和 VMware ESX 是类型 1 虚拟机监控器,而 VMware Player 和 VirtualBox 是类型 2 虚拟机监控器的例子。
另一种流行的虚拟化方法是使用容器。容器提供了一个隔离的用户模式环境,在其中运行应用程序。与虚拟机不同,容器与主机操作系统共享内核,并与同一计算机上运行的其他容器共享内核。运行在容器中的进程只能看到物理机上可用资源的一个子集。例如,每个容器可以被授予自己的隔离文件系统。容器提供了虚拟机的隔离功能,但没有为每个虚拟机运行独立内核的开销。一般来说,容器限于运行与主机相同的操作系统,因为内核是共享的。一些容器技术,如 OpenVZ,用于虚拟化操作系统的整个用户模式部分,而其他容器技术,如 Docker,用于在隔离容器中运行单独的应用程序。
注意
你可能还记得,在第十章中,操作系统进程被描述为一个“容器”——这与虚拟化容器是不同的。
仿真
仿真是使用软件使一种设备表现得像另一种设备。仿真和虚拟化的相似之处在于,它们都提供一个虚拟环境来运行软件,但虚拟化提供的是底层硬件的一部分,而仿真则呈现出与正在使用的物理硬件不同的虚拟硬件。例如,运行在 x86 处理器上的虚拟机或容器运行为 x86 编译的软件,直接利用物理 CPU。相比之下,运行在 x86 硬件上的仿真器(执行仿真的程序)可以运行为完全不同处理器编译的软件。仿真器通常还会提供除处理器之外的其他虚拟硬件。
例如,完整的 Sega Genesis(1990 年代的视频游戏系统)仿真器将模拟摩托罗拉 68000 处理器、雅马哈 YM2612 音频芯片、输入控制器和 Sega Genesis 中所有其他硬件。在运行时,这样的仿真器将原本设计用于 Sega Genesis 的 CPU 指令转换为在 x86 代码中实现的功能。这引入了显著的开销,因为每条 CPU 指令都必须进行转换,但足够快速的现代计算机仍然可以以全速仿真远比 Sega Genesis 慢得多的硬件。结果是可以在完全不同的平台上运行为某个平台设计的软件,如图 13-3 所示。

图 13-3:为系统 A 编译的代码可以在系统 A 的仿真器上运行
仿真在保护为过时平台设计的软件方面起着重要作用。随着计算平台的老化,找到可以正常工作的硬件变得越来越困难。软件开发人员常常使用仿真技术来将旧软件移植到现代平台上。原始的源代码可能已经丢失,或者更新它的任务可能会非常繁重。在这种情况下,投资仿真器可以让原始的已编译代码在不修改的情况下在新平台上运行。
进程虚拟机
还有一种与仿真器有些相似的虚拟机类型。进程虚拟机在一个执行环境中运行应用程序,该环境抽象了底层操作系统的细节。它类似于仿真器,因为它提供了一个与运行它的硬件和操作系统解耦的平台执行环境。然而,不同于仿真器,进程虚拟机并不试图模拟真实的硬件。相反,它提供了一个为运行平台无关的软件而设计的环境。正如我们在第九章中讨论的,Java 和.NET 利用了运行字节码的进程虚拟机。
云计算
云计算是通过互联网提供计算服务。在本节中,我们将探讨各种类型的云计算,但首先让我们快速回顾一下远程计算的历史。
远程计算的历史
自计算机诞生以来,我们可以观察到从远程集中式计算(通过终端访问服务器)到本地计算(桌面计算机),再到现在的远程计算(通过智能本地设备如智能手机访问网络)的摆动。今天,许多应用程序依赖于远程计算和本地计算的结合。在网络的情况下,部分代码运行在浏览器中,部分代码运行在网络服务器上。我们今天口袋里携带的设备,比起计算机诞生初期的占地一间房大小的机器,要强大得多,但我们希望在这些设备上做的许多事情,仍然涉及与其他计算机的通信,因此将处理任务分担给本地设备和远程服务器是合情合理的。
随着远程计算的重新兴起,组织开始需要维护服务器。过去,这意味着购买一台物理服务器,根据需要进行配置,将其连接到网络,并让它在某个储物间中运行。组织可以直接访问机器,并完全控制其配置。然而,维护服务器(或一组服务器)可能是一项复杂且昂贵的工作。这包括购买和维护硬件、跟进软件更新、处理安全问题和容量规划问题、管理网络配置等等。通常,这项工作所需的技能和专业知识与组织的主要业务并不匹配。即使是一个技术驱动的公司,也不一定希望从事维护服务器的工作。这就是云计算的出现背景。
云计算通过互联网(即云)提供远程计算能力。底层硬件由云服务公司(即云提供商)维护,解放了需要这些计算能力的组织或用户(即云消费者)免于维护服务器。云计算允许按需购买计算服务,随需随取。对于云消费者来说,这意味着放弃对某些事务的控制,信任第三方提供可靠的服务。云计算有多种形式;我们将在这里了解其中的一些形式。
云计算的类别
云计算的不同类别通常由划分云提供商和云消费者责任的界限定义。图 13-4 提供了四种云计算类别(IaaS、PaaS、FaaS 和 SaaS)及其各自责任划分的概述。我们接下来将逐一介绍这些类别。

图 13-4:不同类型云服务中的责任分配
图 13-4 中的垂直堆栈表示运行应用程序所需的组件。无论使用哪种类型的云计算,所有组件都必须存在——各类别之间的区别在于云服务提供商或云消费者是否负责管理每个组件。每个堆栈中的不同组件应该是熟悉的,因为我们已经覆盖了这些主题。然而,运行时 需要解释。运行时环境 是应用程序执行的环境,包括所需的库、解释器、进程虚拟机等。接下来,让我们从左到右依次介绍图 13-4 中展示的四种云计算类别。
基础设施即服务(IaaS) 是一种云计算场景,其中云服务提供商仅管理硬件和虚拟化,允许消费者管理操作系统、运行时环境、应用代码和数据。IaaS 的消费者通常会获得一台连接互联网的虚拟计算机,按照自己的需求使用,通常作为某种类型的服务器。这台虚拟计算机通常实现为基于虚拟机监控程序的虚拟机或用户模式部分的 Linux 发行版容器。IaaS 虚拟服务器的消费者可以访问虚拟计算机的操作系统,并可以根据需要配置它。这为消费者提供了最大灵活性,但也意味着维护系统软件(包括操作系统、第三方软件等)的责任完全落在消费者肩上。IaaS 提供了一台虚拟计算机,消费者负责运行在该计算机上的所有内容。以下是一些 IaaS 的例子:亚马逊弹性计算云(EC2)、微软 Azure 虚拟机和谷歌计算引擎。
平台即服务(PaaS)赋予云服务提供商更多的责任。在 PaaS 场景中,云服务提供商不仅管理硬件和虚拟化,还管理消费者希望使用的操作系统和运行时环境。PaaS 消费者开发的应用程序是为了在他们选择的云平台上运行,并利用该平台独特的各种功能。PaaS 产品的云消费者无需担心维护底层操作系统或运行时环境。云消费者可以只专注于他们的应用程序代码。尽管提供商抽象化了底层系统的细节,消费者仍然需要管理由提供商配置的用于处理其应用程序的资源。这包括所需的存储量和分配的虚拟机类型。PaaS 提供了一个托管平台来运行代码,消费者负责运行在该平台上的应用程序。以下是一些 PaaS 的例子:Amazon Web Services Elastic Beanstalk、Microsoft Azure App Service 和 Google App Engine。
函数即服务(FaaS)将 PaaS 模型进一步发展。它不要求消费者部署完整的应用程序或提前配置平台实例。相反,消费者只需要部署他们的代码(一个函数),该函数在响应特定事件时运行。例如,开发者可以编写一个函数,返回距离最近超市的距离。该函数可以在浏览器向 URL 发送当前 GPS 坐标时运行。这个事件驱动的模型意味着云服务提供商负责按需调用消费者的代码。消费者不再需要让应用程序代码始终运行,等待请求的到来。这可以简化消费者的操作并降低成本,尽管如果函数代码尚未运行,处理请求时可能会有较慢的响应时间。
FaaS 是一种无服务器计算,一种云计算模型,在这种模型中,消费者不需要处理服务器或虚拟机的管理。当然,这个术语并不完全准确;实际上运行代码是需要服务器的,只不过消费者不需要考虑它们!FaaS 提供了一个事件驱动的平台来运行代码,消费者负责响应事件时运行的代码。一些 FaaS 的例子包括 Amazon Web Services Lambda、Microsoft Azure Functions 和 Google Cloud Functions。
软件即服务 (SaaS) 是一种本质上与其他云服务不同的服务类型。SaaS 为消费者提供的应用程序完全托管在云端。而 IaaS、PaaS 和 FaaS 是面向希望在云中运行自己代码的软件工程团队,SaaS 则向最终用户或组织提供一个已经编写好的完整云应用程序。如今,许多软件都在云端运行,因此这看起来似乎没有什么特别的,但它与用户或组织在本地设备和网络上安装和维护软件形成鲜明对比。SaaS 提供一个完全在云中管理的应用程序,消费者只需负责存储在该应用程序中的数据。甚至数据的管理也部分由提供商负责,包括数据存储、备份等细节。SaaS 的一些例子包括 Microsoft 365、Google G Suite 和 Dropbox。
云服务提供商领域的一些主要参与者包括亚马逊 Web 服务、微软 Azure、谷歌云平台、IBM 云、甲骨文云和阿里巴巴云。
深网与暗网
你可能已经读过有关暗网或深网的恶性事件的新闻。不幸的是,这两个术语经常被混淆,但它们有着不同的含义。网络可以分为三个大致的部分:表层网络、深网和暗网,如图 13-5 所示。

图 13-5:表层网络、深网和暗网
任何人都可以自由访问的内容属于表层网络。公共博客、新闻网站和公共推特帖子都是表层网络内容的例子。表层网络是被搜索引擎索引的,有时,表层网络被定义为可以通过搜索引擎找到的内容。
深网 是指需要登录网站或网络服务才能访问的网络内容。大多数互联网用户都定期访问深网内容。查看银行余额、通过像 Gmail 这样的网页读取电子邮件、登录 Facebook、查看自己在亚马逊上的个人购物历史——这些都是深网活动的例子。深网仅仅是指那些不可公开访问的内容,通常需要某种密码才能访问。大多数用户不希望他们的电子邮件或银行余额公开,因此这些内容不会公开,并且无法被搜索引擎索引。
暗网是需要专门软件才能访问的网络内容。你不能仅通过标准网页浏览器访问暗网。最常见的暗网技术是Tor(洋葱路由器)。通过加密和中继系统,Tor 使得用户能够匿名访问网络,防止用户的 ISP 监控访问的站点,也防止网站知道访客的 IP 地址。此外,Tor 还允许用户访问一些称为洋葱服务的网站,这些网站如果没有 Tor 无法访问——这些网站属于暗网的一部分。Tor 还隐藏洋葱服务的 IP 地址,使其匿名。如你所料,暗网的匿名性有时被犯罪分子利用。然而,暗网所提供的隐私也有合法用途,比如举报和政治讨论。在访问暗网内容时,我建议谨慎。
比特币
加密货币是一种数字资产,旨在用于金融交易,作为传统货币(如美元)的替代品。加密货币的用户维持该货币的余额,类似于传统银行账户,并可以用其购买商品和服务。有些用户将加密货币主要作为投资,而非交易手段,这使得它对于这些用户来说更像是黄金。与传统货币不同,加密货币通常是去中心化的,没有任何单一组织控制其使用。
比特币基础
比特币于 2009 年推出,是第一种去中心化的加密货币,今天也是最为人知的。自那时以来,大量的替代加密货币(被称为山寨币)相继出现,但没有一种能够挑战比特币的主导地位。比特币的主要货币单位也叫做比特币,缩写为BTC。
比特币和类似的加密货币基于区块链技术。在区块链中,信息被分组到叫做区块的数据结构中,区块按时间顺序连接在一起。也就是说,当新块创建时,它会被添加到区块链的末端。以比特币为例,区块中保存着交易记录,跟踪比特币的流动。图 13-6 展示了比特币区块链的情况。

图 13-6:比特币的区块链将按时间顺序链接交易记录块。
区块链通过像互联网这样的网络运行,多个计算机共同处理交易并更新区块链。一起处理比特币交易的计算机被称为比特币网络。连接到比特币网络的计算机称为节点,某些节点保存区块链的副本;没有单一的主副本。加密和解密被用来确保交易的完整性,并防止篡改区块链中的数据。一旦写入,区块链数据是不可更改的——它不能被修改。比特币的区块链是一个公共的、去中心化的、不可变的交易账本。这个账本用来记录所有在比特币网络上发生的事件,比如比特币的转账。
比特币钱包
最终用户的比特币存储在比特币钱包中。然而,更准确地说,比特币钱包持有一组加密密钥对,如图 13-7 所示。

图 13-7:一个比特币钱包包含密钥对。比特币地址是由公钥派生出来的。
如图 13-7 所示,钱包中的每个密钥对由两个数字组成——一个私钥和一个公钥。私钥是一个随机生成的 256 位数字。这个数字必须保密;任何知道私钥的人都可以花费与该密钥对相关联的比特币。公钥用于接收比特币,是从私钥派生出来的。在接收比特币时,公钥以比特币地址的形式表示,这是从公钥生成的文本字符串。这里是一个比特币地址的例子:13pB1brJqea4DYXkUKv5n44HCgBkJHa2v1。
假设我有一个比特币想要发送给你。这个比特币与我控制的地址相关联。也就是说,我拥有这个地址的私钥。如果你给我你控制的比特币地址的文本字符串表示,我可以将我的比特币发送到你的地址。你不需要(也不应该)将私钥发给我。我之所以能将比特币发送给你,是因为我拥有我地址的私钥,这使我能够花费我的比特币。反之,我无法将任何比特币转出你的地址,因为我没有你的私钥。
比特币交易
让我们仔细看看它是如何工作的。比特币的转账被称为交易。要发送比特币,钱包软件构建一个指定转账细节的交易,用私钥对其进行数字签名,然后将交易广播到比特币网络中。比特币网络中的计算机验证交易,并将其添加到区块链中的新区块。 图 13-8 说明了一个比特币交易。

图 13-8:一个比特币交易将 0.5 比特币转移到地址 B(忽略任何交易费用)。
如图 13-8 所示,交易包含输入和输出,分别表示比特币的来源和去向。在该图的左侧,我们可以看到一个先前的交易,只有输出被显示;先前交易的输入与我们的讨论无关。在先前的交易中,0.5 BTC 被发送到地址 A。
在图 13-8 的右侧,我们可以看到一个新交易,它将 0.5 BTC 从地址 A 转移到地址 B。为了简便起见,这笔交易只有一个输入和一个输出。输入表示要转移的比特币的来源。你可能会认为这应该是一个比特币地址,但事实并非如此。相反,输入是先前交易的输出。假设地址 A 是我的地址,而我想将 0.5 比特币发送到你的地址 B。那么,我知道之前 0.5 BTC 已被发送到我的地址,因此我可以将先前交易的输出作为新交易的输入,从而将那 0.5 比特币发送给你。交易的输出部分包含比特币被发送到的地址。
尽管你可以认为一个地址有比特币余额,但与地址相关的比特币数量并不是存储在比特币钱包中的,也不是直接存储在区块链中的。相反,关联到该地址的交易历史存储在区块链中,从这些历史记录中可以计算出某个地址的余额。提醒一下,比特币钱包只是包含了用于执行比特币交易的密钥。
比特币挖矿
维护比特币区块链的过程被称为比特币挖矿。全球的计算机将交易区块添加到区块链中——这些计算机被称为矿工。图 13-9 展示了比特币挖矿的过程。

图 13-9:比特币挖矿
为了将一个交易区块添加到区块链中,矿工必须验证区块中包含的交易(确保每个交易在语法上正确,输入的比特币尚未被花费等等),并且还必须完成一个计算上困难的问题。要求矿工解决这样的问题可以防止篡改区块链,因为修改一个区块需要重新解决该区块及其后续所有区块的问题。这种通过解决困难问题来防止不当行为的系统被称为工作量证明。
到达计算问题的解决方案需要大量的试验和错误计算。解决方案难以产生,但容易验证。第一个完成问题的矿工将获得一笔比特币奖励。这就是新比特币生成并被引入系统的方式。这样,比特币挖矿类似于传统矿业——矿工进行工作,在合适的情况下可能会“淘金”。除了获得新铸造的比特币外,矿工还可以为每个包含在区块中的交易收取手续费,该费用从交易中发送的比特币总额中扣除。比特币的设计只允许总共挖掘 2100 万个币。一旦这个数字达到,比特币矿工将不再获得比特币奖励,而是依靠交易费用来资助他们的运营。
比特币的起源
比特币区块链的开始可以追溯到 2009 年,当时第一个区块,也就是创世区块,被挖掘出来。这个区块是由中本聪挖掘的,中本聪被认为是比特币的发明者。“中本聪”被认为是一个化名;至本文写作时,这个人的身份仍然存在争议。
要使比特币挖矿有利可图,操作挖矿硬件的成本不能超过获得的比特币的价值。比特币挖矿硬件通常非常耗电,因此挖矿者的电费可能很高。比特币最初是在普通计算机上挖掘的,但如今使用专用的、高成本的硬件来尽可能快地挖矿(记住,奖励会给予第一个解决问题的计算机)。这些成本,加上比特币价格的高度波动,意味着比特币挖矿并不是一条保证盈利的路!
比特币区块链是公开的——所有交易都可以被任何人查看。然而,区块链中没有记录转账比特币的人的个人身份。因此,尽管一个地址的余额和交易历史是公开的,但没有简单的方法将该地址与某个人挂钩。因此,比特币对那些希望保持匿名的人具有吸引力,比如那些在暗网运营商业网站的人。
区块链技术与加密货币密切相关,在加密货币中作为财务账本使用,但区块链也可以用于其他目的。任何需要防篡改记录历史的系统都可以利用区块链。时间将证明比特币或其他加密货币是否能够在长期内成功,但无论如何,我们可能会看到区块链技术在其他新颖的方式中得到应用。
虚拟现实与增强现实
两项有可能从根本上改变我们与计算机互动方式的技术是虚拟现实(VR)和增强现实(AR)。虚拟现实是一种将用户沉浸在三维虚拟空间中的计算形式,通常通过头显显示。虚拟现实允许用户通过多种输入方式与虚拟物体交互,包括用户的目光、语音命令以及专用的手持控制器。与此相反,增强现实通过头显或用户通过手持便携设备(如智能手机或平板)“透视”来将虚拟元素叠加到现实世界中。虚拟现实将用户沉浸在另一个世界中;增强现实则改变了现实世界。
尽管几十年来已经有各种虚拟现实尝试,但直到 2010 年代,虚拟现实才开始进入主流。2014 年,Google 通过Google Cardboard帮助普及了虚拟现实,Cardboard 这个名字源于这样一个想法:虚拟现实头显可以由纸板、镜头和智能手机构成。专为 Cardboard 设计的应用通过在智能手机屏幕的左半部分渲染左眼内容、右半部分渲染右眼内容,向用户呈现虚拟现实内容,正如图 13-10 所示。

图 13-10:为 Google Cardboard 设计的应用以虚拟现实模式呈现
为 Cardboard 设计的应用依赖于智能手机的陀螺仪检测能力,使得显示屏可以随着用户头部的移动而更新。这样的头显被称为具有3 自由度(3DoF);头显能够追踪有限的头部运动,但无法追踪其他空间中的运动。这使得用户可以环顾四周,但不能使用头显移动。Cardboard 还支持基本的单按钮输入。Cardboard 简单但有效,它让许多可能不会尝试虚拟现实的用户第一次体验到了虚拟现实。
更具沉浸感的体验需要6 自由度(6DoF);用户可以通过在现实空间中身体的物理移动来在虚拟现实中自由移动。一些虚拟现实头显支持 6DoF,而虚拟现实控制器则可能是 3DoF 或 6DoF。6DoF 控制器由用户手持,可以追踪控制器在虚拟现实空间中的位置,从而实现与虚拟环境的更自然互动。
自 Google Cardboard 发布以来,消费市场出现了多种虚拟现实解决方案。一些依赖于智能手机(如 Samsung Gear VR、Google Daydream)。另一些则使用个人计算机进行处理,通过连接的虚拟现实头显和控制器(如 Oculus Rift、HTC Vive、Windows Mixed Reality)。还有一些是独立设备,无需智能手机或个人计算机(如 Oculus Go、Oculus Quest、Lenovo Mirage Solo)。通常,连接 PC 的解决方案提供最高的图形保真度,同时也是最昂贵的,特别是在考虑到所需计算机的成本时。
如前所述,增强现实(AR)是一种类似但不同的技术。虚拟现实试图让用户完全沉浸在虚拟世界中,而增强现实则是在现实世界上叠加虚拟元素。这可以通过移动设备实现,移动设备的后置摄像头用于观察现实世界,同时将虚拟元素叠加在摄像头所看到的画面上。先进的 AR 技术可以使软件理解房间中的物理元素,从而使叠加的虚拟元素能够与环境无缝互动。增强现实以基本形式在移动应用中实现,但在像 Google Glass、Magic Leap 头戴设备和微软 HoloLens 这样的专用设备中得到了更充分的应用。这些 AR 设备佩戴在头部,将计算机生成的图形叠加在用户的视野中。用户可以通过语音命令或手部追踪等多种方式与虚拟元素进行互动。
各种虚拟现实(VR)和增强现实(AR)技术(统称为XR)为软件开发人员提供了多个目标平台。许多 VR 开发者依赖于现有的游戏引擎,这些引擎通常用于构建 3D 游戏,比如 Unity 游戏引擎或 Unreal 游戏引擎。这些引擎对游戏开发者来说已经很熟悉,并且使得开发者能够相对轻松地为多个 VR 平台构建他们的软件。Web 开发者可以使用被称为WebVR和WebXR的 JavaScript API 来开发 VR 和 AR 内容。二者中,WebVR 首先出现,并专注于 VR;而 WebXR 则紧随其后,支持 AR 和 VR。
物联网
传统上,我们认为服务器提供互联网上的服务,用户通过互联网连接的个人计算设备,如 PC、笔记本电脑和智能手机,与这些服务器进行交互。近年来,我们看到了更多新型设备连接到互联网——扬声器、电视、恒温器、门铃、汽车、灯泡,等等!这种将各种设备连接到互联网的概念被称为物联网(IoT)。
电子元件的成本和物理尺寸在减少,Wi-Fi 和蜂窝网络的互联网接入也已普及,消费者期待他们的设备“更智能”。所有这些因素共同推动了将一切连接到互联网的趋势。物联网设备通常离不开某种支持它们的网络服务,因此,云计算的兴起也促进了物联网的传播。对于消费者来说,物联网设备在“智能家居”中尤为突出,其中各种家电可以被监控和控制。在商业领域,物联网设备可以在制造、医疗保健、交通等多个领域找到。
尽管这些类型的联网设备带来了明显的好处,但它们也引入了风险。此类设备的安全性是一个特别需要关注的问题。并不是每个物联网设备都能很好地防御恶意攻击。即使设备上的数据对攻击者没有兴趣,设备也可能成为进入本来防守严密的网络的突破口,或者被用作对其他目标进行远程攻击的发起点。尤其是对于消费者来说,物联网设备看起来足够无害,在将其连接到家庭网络时,安全问题往往不会成为优先考虑的事项。
隐私是物联网设备带来的另一个风险。许多这类设备由于其性质,会收集数据。这些数据通常会被发送到云服务进行处理。最终用户应该如何信任这些运营服务的组织处理他们的个人数据呢?即使是一个有良好意图的组织,也可能成为数据泄露的受害者,用户数据可能以意想不到的方式暴露。像智能音响这样的设备必须随时监听,等待语音命令。这带来了意外录音私密对话的风险。现代读者阅读乔治·奥威尔的小说1984时,可能会发现,当今的消费者自愿为了便利而交换隐私,具有一定的讽刺意味。
物联网设备的另一个风险是,它们的完整功能通常依赖于云服务。如果设备的互联网连接中断,该设备可能会暂时变得不那么有用。更大的担忧是,设备的制造商可能在某一天永久关闭支持该设备的服务。到那时,智能设备将变回“傻”设备!
注意
请参考项目 #41 在第 311 页,在这里你可以运用你学到的硬件、软件和网络知识,构建一个联网的“自动售货机”物联网设备。
总结
在本章中,我们涵盖了与现代计算相关的各种话题。你了解了应用程序,包括本地应用和基于网络的应用。你探索了虚拟化和仿真如何使计算机能够在虚拟化硬件上运行软件。你看到了云计算如何为软件运行提供新的平台。你了解了表面网络、深网和暗网的区别,以及像比特币这样的加密货币如何实现去中心化支付系统。我们简要介绍了虚拟现实和增强现实,以及它们如何为计算提供独特的用户界面。你学习了物联网,并有机会构建一个联网的“自动售货机”。
当我们接近本书的结尾时,让我们回顾一些主要的计算机概念,并看看它们是如何相互关联的。计算机是二进制数字设备,所有事物都以 0 或 1、开或关的形式表示。二进制逻辑,也称为布尔逻辑,为计算操作提供了基础。计算机通过使用数字电路来实现,其中电压水平代表二进制状态——低电压表示 0,高电压表示 1。数字逻辑门是基于晶体管的电路,能够执行布尔操作,如 AND 和 OR。这样的逻辑门可以排列组合,创建更复杂的电路,如计数器、存储设备和加法电路。这些类型的电路为计算机硬件提供了概念基础:执行指令的中央处理单元(CPU)、在电源开启时存储指令和数据的随机存取内存(RAM)以及与外界交互的输入/输出(I/O)设备。
计算机是可编程的;它们可以在不更改硬件的情况下执行新的任务。指示计算机该做什么的指令被称为软件或代码。CPU 执行机器码,而软件开发人员通常使用高级编程语言编写源代码。计算机程序通常运行在操作系统上——操作系统是与计算机硬件通信并为程序执行提供环境的软件。计算机通过互联网进行通信,互联网是一个全球互联的计算机网络集合,所有这些网络都使用 TCP/IP 协议套件。互联网的一个流行应用是万维网,它是一组分布式、可寻址、互联的资源,通过 HTTP 协议在互联网上传输。所有这些技术为现代计算创新提供了一个蓬勃发展的环境。
我希望本书已经让你对计算机的工作原理有了更全面的了解。我们覆盖了大量内容,但大多数主题我们只是触及了皮毛。如果某个特定领域引起了你的兴趣,我鼓励你继续深入学习这个主题——在网上阅读相关资料,参加课程,观看视频,或购买另一本书!关于计算的知识有很多可以探索的地方。
项目 #41:使用 Python 控制自动售货机电路
先决条件:项目 #7(见第 105 页)和#8(见第 107 页),在这些项目中,你构建了一个自动售货机电路。你需要一台运行 Raspberry Pi OS 的树莓派。如果你还没有这样做,我建议你翻到附录 B,阅读第 341 页上的“树莓派”部分。
在这个项目中,你将利用你所学到的硬件、软件和网络知识,构建一个网络连接的“自动售货机”物联网设备。在第六章中,你使用按键、LED 和数字逻辑门搭建了一个自动售货机电路。对于这个项目,你将更新该设备。你将保留按钮和 LED,但用运行在 Raspberry Pi 上的 Python 代码替换逻辑门。这将使你能够轻松地通过软件添加功能,例如能够通过网络连接到设备。
对于这个项目,你将需要以下组件:
-
面包板
-
LED
-
用于 LED 的限流电阻;大约 220Ω
-
两个适合面包板的开关或按钮
-
跳线,包括 4 根公对母跳线
-
Raspberry Pi
GPIO
除了其小巧的尺寸和低廉的成本,Raspberry Pi 还有一个特点,使其与普通计算机区别开来——那就是它的 GPIO 引脚。每个通用输入/输出(GPIO)引脚可以指定为电气输入或输出。当引脚作为输入时,运行在 Raspberry Pi 上的代码可以将其读取为高电平 3.3V 或低电平 0V。Raspberry Pi 甚至内置了上拉和下拉电阻,通过软件启用,因此你不再需要为输入按钮额外添加这些电阻。当引脚作为输出时,它可以设置为高电平(3.3V)或低电平(0V),这一切都可以通过软件控制。某些引脚始终设置为接地、5V 或 3.3V。这些引脚在软件中通过编号引用。图 13-11 显示了 GPIO 引脚的指定。
如你所见,在图 13-11 中,GPIO 编号与引脚编号并不对应。引脚,如灰色框中的显示,从 1 到 40 按顺序编号,从左上角开始,右下角结束。例如,左侧第二个引脚是 GPIO 2,而引脚编号是 3。在代码中引用这些 GPIO 引脚时,你需要使用 GPIO 编号,而不是引脚编号。

图 13-11:Raspberry Pi GPIO 引脚
搭建电路
在编写任何代码之前,请按照图 13-12 所示,将电路组件连接到面包板和 Raspberry Pi 的 GPIO 引脚。

图 13-12:Raspberry Pi 自动售货机电路图
我建议在连接任何东西到 GPIO 引脚之前,先关闭你的 Raspberry Pi。对于 GPIO 引脚和面包板之间的连接,使用公对母跳线。你可以将跳线的母头连接到 GPIO 引脚,公头连接到面包板。
如果你使用图 13-12 中显示的引脚编号,VEND LED、VEND 按钮和 COIN 按钮会连接到 Raspberry Pi 上连续的三个 GPIO 引脚。你还需要将其中一个 GND 引脚(我建议使用引脚 9)连接到面包板的负电源列,这样你就可以轻松将按钮和 LED 连接到地。
你可能已经注意到,这个电路的输入开关与第 7 项目(第 105 页)和第 8 项目(第 107 页)中使用的开关接线方式不同。在这些项目中,你将开关的一侧连接到 5V,另一侧连接到下拉电阻/输入引脚。电路的设计是预期开关打开时为低电压,而关闭时为高电压。而在这里,情况正好相反——开关关闭时为低电压,打开时为高电压。从内部来看,当开关打开(或没有连接任何东西)时,树莓派将 GPIO 引脚拉高。
图 13-13 展示了在面包板上搭建的这个电路。

图 13-13:在面包板上搭建的树莓派自动售货机电路
一旦你的电路连接好并且你验证了连接,开启树莓派的电源。你可能会看到 LED 亮起,这没关系,因为你还没有运行任何代码来设置 LED 的状态。
测试你的电路
在进入自动售货机代码之前,我们先写一个简单的程序来测试电路是否正确连接,并让你熟悉在 Python 中使用 GPIO 的操作。为了与 GPIO 引脚进行交互,我们将使用GPIO Zero,这是一个 Python 库,可以轻松与按钮、LED 等物理设备进行交互。使用你喜欢的文本编辑器,在主目录下创建一个名为gpiotest.py的新文件。在文本编辑器中输入以下 Python 代码。Python 中缩进非常重要,请确保正确缩进。
from time import sleep❶
from gpiozero import LED, Button❷
button = Button(3)❸
led = LED(2)❹
while True:❺
led.off()
button.wait_for_press()
led.on()
sleep(1)
这个简单的程序导入了sleep函数❶和来自 GPIO Zero 库的LED和Button类❷。然后,它创建一个名为button的变量,代表 GPIO 3 上的物理按钮❸。类似地,创建了一个led变量,代表连接到 GPIO 2 的 LED❹。程序随后进入一个无限循环❺,在该循环中,LED 先熄灭,等待按钮被按下,然后亮起一秒钟后再次进入循环。
一旦文件保存,你可以通过 Python 解释器运行它,如下所示:
$ python3 gpiotest.py
当你启动程序时,最初应该不会发生任何事情,除了如果 LED 之前是亮的,可能会熄灭。如果你按下连接到 GPIO 3 的按钮,LED 应该会亮起一秒钟,然后熄灭。只要程序在运行,你可以重复这个操作。
我们的简单程序没有包含任何优雅的退出方式,因此要结束程序,请按下键盘上的 CTRL-C。当你以这种方式退出程序时,Python 解释器会显示“追溯”(Traceback)最近的函数调用——这是正常现象。
如果程序没有按预期工作,请仔细检查你输入的代码,并查看《电路故障排除》在第 340 页的内容。
一个自动售货机程序
在 项目 #7(见 第 105 页)和 #8(见 第 107 页)中,自动售货机的逻辑由 SR 锁存器、与门和电容器控制。现在,你可以用 Raspberry Pi 上的程序来替代这一切。这种新设计还去除了 COIN LED。之前,如果投入了硬币,COIN LED 会亮起。在新的设计中,程序会打印硬币积分数。每次投入硬币时,积分数应该增加 1,每次发生售货操作时,积分数应该减少 1。
该设备的要求如下:
-
按下 COIN 按钮会使积分数增加 1。
-
按下 VEND 按钮模拟售货操作。如果积分数大于 0,VEND LED 会短暂亮起,积分数减少 1。如果积分数为 0,按下 VEND 按钮时不会发生任何事情。
-
每次按下可操作的按钮,无论是 COIN 还是 VEND,都会导致程序打印当前的积分数。
使用你选择的文本编辑器,在你的主文件夹根目录下创建一个名为 vending.py 的新文件。将以下 Python 代码输入到文本编辑器中:
from time import sleep❶
from gpiozero import LED, Button
vend_led = LED(2)❷
vend_button = Button(3)
coin_button = Button(4)
coin_count = 0❸
vend_count = 0
def print_credits():❹
print('Credits: {0}'.format(coin_count - vend_count))
def coin_button_pressed():❺
global coin_count
coin_count += 1
print_credits()
def vend_button_pressed():❻
global vend_count
if coin_count > vend_count:
vend_count += 1
print_credits()
vend_led.on()
sleep(0.3)
vend_led.off()
coin_button.when_pressed = coin_button_pressed❼
vend_button.when_pressed = vend_button_pressed
input('Press Enter to exit the program.\n')❽
首先,代码导入了 sleep 函数、LED 类和 Button 类 ❶,这些将在程序中后续使用。接下来,声明了三个变量,代表连接到 GPIO 引脚的物理组件——vend_led 连接到 GPIO 2,vend_button 连接到 GPIO 3,coin_button 连接到 GPIO 4 ❷。声明了变量 coin_count 用于跟踪按下 COIN 按钮的次数,变量 vend_count 用于跟踪发生了多少次自动售货操作 ❸。这两个变量用于计算积分数。
print_credits 函数 ❹ 打印可用积分数,计算方法是 coin_count 和 vend_count 之间的差值。
coin_button_pressed 函数 ❺ 是当按下 COIN 按钮时运行的代码。它增加 coin_count 并打印当前的积分数。global coin_count 语句允许在 coin_button_pressed 函数中修改全局变量 coin_count。
vend_button_pressed 函数 ❻ 是当按下 VEND 按钮时运行的代码。如果还有积分剩余(coin_count > vend_count),则该函数增加 vend_count,打印当前的积分数,并让 LED 灯亮起 0.3 秒。
设置 coin_button.when_pressed = coin_button_pressed ❼ 将 coin_button_pressed 函数与 GPIO 4 上的 coin_button 关联,以便在按下按钮时运行该函数。类似地,vend_button_pressed 与 vend_button 关联。
最后,我们调用input函数❽。这个函数会在屏幕上打印一条消息,并等待用户按下 ENTER 键。这是一种保持程序运行的简单方法。如果没有这一行代码,程序会在用户有机会与按钮交互之前就结束并停止运行。
文件保存后,你可以像这样使用 Python 解释器运行它:
$ python3 vending.py
当你启动程序时,你应该立即看到Press Enter to exit the program显示在终端窗口上。这时尝试按下连接到 GPIO 4 的 COIN 按钮。你应该看到程序打印出Credits: 1。接下来,尝试按下 VEND 按钮。LED 应该会短暂点亮,程序应打印出Credits: 0。再按一次 VEND 按钮——应该什么也不会发生。多次按下 COIN 和 VEND 按钮,确保程序按预期工作。当你完成测试时,按下 ENTER 键结束程序。
如你所见,一台树莓派或类似的设备,可以在软件中复制我们之前在硬件中实现的相同逻辑。然而,基于软件的解决方案更容易修改。可以通过改变几行代码来添加新功能,而不是添加新的芯片和布线。对于我们在这里想要做的事情来说,树莓派实际上有点“杀鸡用牛刀”;同样的事情可以通过一个更不强大的计算设备以更低的成本完成,但原理是一样的。
一个物联网自动售货机
假设自动售货机的操作员希望能够通过互联网远程检查机器的状态。由于你使用的是树莓派来处理自动售货机的逻辑,你可以更进一步,让它成为一台物联网自动售货机!你可以在程序中添加一个简单的 Web 服务器,使得有人可以通过 Web 浏览器连接到设备的 IP 地址,并查看已插入硬币的次数以及发生了多少次售货操作。
Python 使这变得相对简单,因为它包含了一个简单的 Web 服务器库http.server。你只需要构建一些包含你要发送的数据的 HTML,并为传入的GET请求编写一个处理程序。你还需要在程序启动时启动 Web 服务器。
使用你选择的文本编辑器编辑你家目录根目录下现有的vending.py文件。首先在文件的第一行插入以下导入语句(保持现有代码不变,只是向下移一行):
from http.server import BaseHTTPRequestHandler, HTTPServer
接下来,删除文件底部的整个input行,并将以下代码添加到文件末尾:
HTML_CONTENT = """
<!DOCTYPE html>
<html>
<head><title>Vending Info</title></head>
<body>
<h1>Vending Info</h1>
<p>Total Coins Inserted: {0}</p>
<p>Total Vending Operations: {1}</p>
</body>
</html>
"""
class WebHandler(BaseHTTPRequestHandler):❶
def do_GET(self):❷
self.send_response(200)❸
self.send_header('Content-type', 'text/html')❹
self.end_headers()
response_body = HTML_CONTENT.format(coin_count, vend_count).encode()❺
self.wfile.write(response_body)
print('Press CTRL-C to exit program.')
server = HTTPServer(('', 8080), WebHandler)❻
try:❼
server.serve_forever()❽
except KeyboardInterrupt:
pass
finally:
server.server_close()❾
HTML_CONTENT 是一个多行字符串,定义了程序通过网络发送的 HTML 代码。该 HTML 代码块表示一个简单的网页,包含一个 <title>、一个 <h1> 标题和两个描述自动售货机状态的 <p> 段落。这些段落中的具体值由占位符 {0} 和 {1} 表示。这些值将在程序运行时由程序填充。由于这是 HTML,字符串中的空格和换行不重要。
WebHandler 类 ❶ 描述了网页服务器如何处理传入的 HTTP 请求。它继承自 BaseHTTPRequestHandler 类,这意味着它拥有与 BaseHTTPRequestHandler 相同的方法和字段。然而,这仅提供了一个通用的 HTTP 请求处理器;你仍然需要指定程序如何响应特定的 HTTP 请求。在这种情况下,程序只需要响应 HTTP GET 请求,因此代码定义了 do_GET 方法 ❷。当收到一个 GET 请求时,会调用该方法,并以以下内容进行回复:
-
200状态码,表示成功 ❸ -
Content-type: text/html头部,告诉浏览器期望响应为 HTML ❹ -
先前定义的 HTML 字符串,但两个占位符已被
coin_count和vend_count的值替换 ❺
使用 HTTPServer 类 ❻ 创建一个网页服务器实例。在这里,你指定服务器名称可以是任何内容,并且 HTTP 服务器监听 8080 端口 ('', 8080)。在此处,你还指定了使用 WebHandler 类来处理传入的 HTTP 请求。
网页服务器通过 server.serve_forever() 启动 ❽。这被放置在一个 try/except/finally 块 ❼ 中,以便服务器可以持续运行直到发生 KeyboardInterrupt 异常(由 CTRL-C 触发)。发生这种情况时,调用 server.server_close() 进行清理,然后程序结束 ❾。
一旦文件保存完毕,你可以像这样使用 Python 解释器运行该文件:
$ python3 vending.py
程序在按下 COIN 或 VEND 按钮时应与之前的行为相同。然而,现在你还可以通过网页浏览器连接到设备,并查看有关自动售货机的数据。为此,你需要确保设备与树莓派在同一局域网内,除非你的树莓派直接连接到互联网并具有公共 IP 地址,在这种情况下,任何互联网设备都应能连接到它。如果没有其他设备,你可以在树莓派本身上启动网页浏览器,让树莓派同时充当客户端和服务器。
你需要找到树莓派的 IP 地址。如果你想查看详细信息,我们在项目#30 中已经讲解过,位于第 255 页,但你需要使用的命令是:
$ ifconfig
一旦你知道了树莓派的 IP 地址,在你想用作客户端的设备上打开一个网页浏览器。在地址栏中输入以下内容:http://IP:8080,将IP替换为树莓派的 IP 地址。最终结果应类似于:http://192.168.1.41:8080。当你将这个地址输入到浏览器的地址栏后,你应该看到网页加载,显示硬币数量和售货操作。每次你请求此页面时,应该能在终端看到 Python 程序打印有关请求的信息。一旦网页加载完成,它不会自动重新加载,因此如果你多次按下 COIN 或 VEND 按钮并希望查看最新的数值,你需要刷新浏览器中的页面视图。要停止此程序,可以使用键盘上的 CTRL-C。
回想一下第十二章,网站可以是静态的或动态的。你在第十二章项目中运行的网站是静态的——它提供了预先构建好的内容。与此不同,本章中的售货机网站是动态的。它在收到请求时生成 HTML 响应。具体来说,它会在响应之前更新 HTML 内容中的硬币和售货机数值。
作为一个额外的挑战,尝试修改程序,使其也在网页上显示“积分”值。这个值应该与上次打印到终端的积分值相匹配。
第十四章:**A
答案

这里你将找到本书中练习的题目和答案。有些问题没有唯一的正确答案;对于这些问题,我提供了一个示例答案。如果在阅读这里的解答之前自己先想出答案,你将从这些练习中受益最多!
1-2: 二进制到十进制
练习: 将这些以二进制表示的数字转换为其十进制等效值。
解答:
10 (二进制) = 2 (十进制)
111 (二进制) = 7 (十进制)
1010 (二进制) = 10 (十进制)
1-3: 十进制到二进制
练习: 将这些以十进制表示的数字转换为其二进制等效值。
解答:
3 (十进制) = 11 (二进制)
8 (十进制) = 1000 (二进制)
14 (十进制) = 1110 (二进制)
1-4: 二进制到十六进制
练习: 将这些以二进制表示的数字转换为其十六进制等效值。如果可以,尽量不要转换为十进制!您可以使用表 1-5 来帮助您。目标是直接从二进制转换为十六进制。
解答:
10 (二进制) = 2 (十六进制)
11110000 (二进制) = F0 (十六进制)
1-5: 十六进制到二进制
练习: 将这些以十六进制表示的数字转换为其二进制等效值。如果可以,尽量不要转换为十进制!您可以使用表 1-5 来帮助您。目标是直接从十六进制转换为二进制。
解答:
1A (十六进制) = 0001 1010 (二进制)
C3A0 (十六进制) = 1100 0011 1010 0000 (二进制)
2-1: 创建您自己的文本表示系统
练习: 定义一种方法,将大写字母 A 到 D 表示为 8 位数字,然后使用您的系统将单词 DAD 编码为 24 位。附加题:也请将您编码后的 24 位数字显示为十六进制。表 A-1 给出了一个示例答案;没有唯一的正确答案。
解答:
表 A-1: 用字节表示 A-D 的自定义系统
| 字符 | 二进制 |
|---|---|
| A | 00000001 |
| B | 00000010 |
| C | 00000011 |
| D | 00000100 |
使用此系统编码后的 DAD 为 00000100 00000001 00000100(为清晰起见添加了空格)。以十六进制表示为:0x040104。
2-2: 编码和解码 ASCII
练习: 使用表 2-1,将以下单词编码为 ASCII 二进制和十六进制,每个字符使用一个字节。请记住,大写字母和小写字母有不同的编码。
解答:
文本 Hello
二进制 01001000 01100101 01101100 01101100 01101111
十六进制 0x48656C6C6F
文本 5 只猫
二进制 00110101 00100000 01100011 01100001 01110100 01110011
十六进制 0x352063617473
请注意,“5 只猫”的编码给我们得到了 0b00110101 作为字符 5 的二进制表示。这与数字 5(0b101)不同。字符代表我们用来表示数字五的符号(5),而数字则表示数量。从“5 只猫”的同一编码中,还可以注意到,甚至空格字符,你可能认为它是空的,仍然需要一个字节来表示。
练习: 使用表 2-1,解码以下单词。每个字符都以 8 位 ASCII 值表示,并添加空格以便清晰显示。
解答:
二进制 01000011 01101111 01100110 01100110 01100101 01100101
文本 咖啡
二进制 01010011 01101000 01101111 01110000
文本 商店
练习: 使用表 2-1,解码以下单词。每个字符都以 8 位十六进制值表示,并添加空格以便清晰显示。
解答:
十六进制 43 6C 61 72 69 6E 65 74
文本 长笛
2-3: 创建你自己的灰度表示系统
练习: 定义一种方法来表示黑色、白色、深灰色和浅灰色。
解答: 如果我们使用 2 位系统,2 位数的四个唯一值为 00、01、10 和 11。然后,可以将这四个二进制数映射到一个颜色:黑色、白色、深灰色和浅灰色——具体的映射由你决定,因为你正在设计自己的系统。表 A-2 展示了一个示例答案;没有唯一正确的答案。
表 A-2: 表示灰度的自定义系统
| 颜色 | 二进制 |
|---|---|
| 黑色 | 00 |
| 深灰色 | 01 |
| 浅灰色 | 10 |
| 白色 | 11 |
2-4: 创建你自己的简单图像表示方法
练习:第一部分 基于你之前设计的灰度颜色表示系统,设计一种表示由这些颜色组成的图像的方法。如果你想简化,可以假设图像始终是 4x4 像素,就像图 2-1 中的图像。
解答: 假设图像始终为 4x4 像素,因此我们需要表示 16 个像素,每个像素一个颜色。使用之前定义的表示灰度颜色的系统(参见表 A-2),我们需要 2 位来表示每个像素的颜色。因此,要表示完整的 16 像素图像,每个像素 2 位,我们需要 16 × 2 = 32 位总位数。
当我们将数据编码为二进制时,应该如何表示这 16 个像素的顺序?这个决定在某种程度上是任意的,但在这个例子中,我们按从左到右、从上到下的顺序排列数据,如图 A-1 所示。

图 A-1:示例图像格式中的像素顺序
使用在 图 A-1 中展示的方法,当我们将数据编码为二进制时,前 2 位是像素 1 的颜色,接下来的 2 位是像素 2 的颜色,依此类推。然后,我们使用前一个练习中定义的颜色代码来定义每个像素的颜色。例如,如果像素 1 是白色,像素 2 是黑色,像素 3 是深灰色,那么我们图像数据的前 6 位是 110001。
练习:第二部分 使用第一部分的方法,写出 第二章 中的花朵图像的二进制表示,图 2-1。
解答: 在这里,我提供了一个示例,说明如何通过应用第一部分的示例方法来解决这个问题。为了帮助可视化,图 A-2 将编号的像素网格叠加在花朵图像上。

图 A-2:叠加在花朵图像上的 4X4 像素网格
现在我们已经为网格中的每个像素分配了一个编号,可以参考 表 A-2 为每个方格应用 2 位值,从方格 1 到 16 依次填充。最终结果是以下二进制序列,表示灰度花朵图像:
11111101111011011111110101100101
注意
我写了一个简单的网页,模拟了这个由 16 个像素和 2 位灰度级组成的系统。请在这里尝试: www.howcomputersreallywork.com/grayscale/.
2-5:为逻辑表达式写真值表
练习: 表 2-7 展示了一个逻辑表达式的三个输入。请完成表达式 (A OR B) AND C 的真值表输出。
解答:
表 A-3: (A OR B) AND C 真值表解答
| A | B | C | 输出 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 0 |
| 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 |
| 1 | 1 | 0 | 0 |
| 1 | 1 | 1 | 1 |
3-1:使用欧姆定律
练习: 看一下 图 A-3 中的电路。电流 I 为多少?

图 A-3:使用欧姆定律找出电流。
解答: 欧姆定律告诉我们电流是电压除以电阻。因此,I 是 0.2 毫安,如下所示:

3-2:找出电压降
练习: 给定 图 3-11 中的电路,电流 I 为多少?每个电阻上的电压降是多少?找出标记的电压:V[A]、V[B]、V[C] 和 V[D],每个电压相对于电源的负端子测量。
解答: 总电阻是 24kΩ + 6kΩ + 10kΩ = 40kΩ。这影响电路中的电流,我们可以使用欧姆定律来计算:10V / 40kΩ = 0.25 mA,如 图 A-4 所示。

图 A-4:电路中的电压降
现在使用欧姆定律计算 24kΩ电阻上的电压降:V = 0.25mA × 24kΩ = 6V。这意味着 V[B]将比 V[A]低 6V。所以 V[B] = 10V – 6V = 4V。6kΩ电阻降压 0.25mA × 6kΩ = 1.5V。因此,V[C] = V[B] – 1.5V = 2.5V。剩余 2.5V 将在 10kΩ电阻上降压,这可以通过基尔霍夫电压定律推导出来,或者使用欧姆定律计算。
4-1:使用晶体管设计逻辑或电路
练习: 绘制一个使用晶体管作为输入 A 和 B 的逻辑或电路图。将图 4-4 中使用机械开关的电路进行改编,但使用 NPN 晶体管替代。
解答: 图 A-5 展示了使用 NPN 晶体管实现逻辑或的解决方案。

图 A-5:使用 NPN 晶体管实现逻辑或
4-2:设计一个带逻辑门的电路
练习: 在第二章中,练习 2-5 你已经创建了(A OR B) AND C 的真值表。现在基于这个工作,将该真值表和逻辑表达式转化为电路图。绘制一个类似于图 4-11 的逻辑门电路图。
解答: 图 A-6 展示了实现(A OR B) AND C 的解决方案。

图 A-6:(A OR B) AND C 的逻辑门图
5-1:练习二进制加法
练习: 尝试以下加法问题。
解答: 答案中的前导 0 是可选的。
0001 + 0010 = 0011
0011 + 0001 = 0100
0101 + 0011 = 1000
0111 + 0011 = 1010
5-2:求二的补码
练习: 求 6 的 4 位二的补码。
解答: 请参见图 A-7。

图 A-7:求 6 的二的补码
5-3:加两个二进制数并解释为带符号数和无符号数
练习: 将 1000 和 0110 相加。将你的工作解释为带符号数。然后将其解释为无符号数。结果是否合理?
解答: 请参见图 A-8。

图 A-8:加 1000 和 0110
7-1:计算所需的位数
练习: 使用第七章中描述的技巧,确定为寻址 4GB 内存所需的位数。请记住,每个字节都分配一个唯一地址,这只是一个数字。
解答: 参考第一章中的 SI 前缀,1GB 内存是 2³⁰或 1,073,741,824 字节。所以 4GB 是 4 倍这个数字,或者是 4,294,967,296 字节。如果我们计算 log2,结果是 32。所以,使用 32 位我们可以为 4GB 内存中的每个字节表示一个唯一的地址。
如果你的计算器或应用程序没有提供 log[2]函数,请注意:

有了这些信息,你可以通过取 log(4,294,967,296)并除以 log(2)来计算 log2。这应该给出 32 的结果。
我们也可以通过另一种方法得到这个解答。由于内存地址从 0 而不是 1 开始分配,因此 4GB 内存的内存地址范围是从 0 到 4,294,967,295(比字节数少 1)。在十六进制中,4,294,967,295 是 0xFFFFFFFF。这是 8 个十六进制数字,每个十六进制符号代表 4 位,我们可以很容易地看到,4 × 8 = 32 位是需要的。
8-1: 用大脑代替 CPU
练习: 尝试在脑海中运行以下 ARM 汇编程序,或者使用铅笔和纸进行计算:
Address Assembly
0001007c subs r3, r0, #1
00010080 ble 0x10090
00010084 mul r0, r3, r0
00010088 subs r3, r3, #1
0001008c bne 0x10084
00010090 ---
假设输入值n = 4 最初存储在r0中。当程序执行到指令00010090时,你已到达代码的结束,r0应为预期的输出值 24。我建议你在每个指令之前和之后跟踪r0和r3的值。执行指令直到到达00010090并查看你是否得到了预期的结果。如果一切正常,你应该已经多次执行了相同的指令;这是有意为之的。
解答: 一旦你完成了这个练习,可以查看表 A-4,查看每一步执行汇编代码的情况。表中的每一行代表一次单独指令的执行。对于每个指令,我们跟踪r0和r3的值。箭头(→)表示寄存器的值从左边的值变化到右边的值。在说明列中,我使用等号表示“被设置为”而不是作为数学上的等式检查。例如,r0 = r3 × r0表示“r0被设置为r3和r0的积”。
表 A-4: 阶乘汇编代码,逐步执行
| 地址 | 指令 | r0 | r3 | 说明 |
|---|---|---|---|---|
| 4 | ? | 你想计算 4 的阶乘,所以在代码运行之前,将r0设为 4。r3最初是未知的。 |
||
0001007c |
subs r3, r0, #1 |
4 | ? → 3 | r3 = r0 – 1 = 4 – 1 = 3 |
00010080 |
ble 0x10090 |
4 | 3 | r3 > 0,因此不跳转,而是继续到 10084。 |
00010084 |
mul r0, r3, r0 |
4 → 12 | 3 | r0 = r3 × r0 = 3 × 4 = 12 |
00010088 |
subs r3, r3, #1 |
12 | 3 → 2 | 将r3减 1。 |
0001008c |
bne 0x10084 |
12 | 2 | r3不为 0,跳转到 10084。 |
00010084 |
mul r0, r3, r0 |
12 → 24 | 2 | r0 = r3 × r0 = 2 × 12 = 24 |
00010088 |
subs r3, r3, #1 |
24 | 2 → 1 | 将r3减 1。 |
0001008c |
bne 0x10084 |
24 | 1 | r3不为 0,跳转到 10084。 |
00010084 |
mul r0, r3, r0 |
24 → 24 | 1 | r0 = r3 × r0 = 1 × 24 = 24 |
00010088 |
subs r3, r3, #1 |
24 | 1 → 0 | 将r3减 1。 |
0001008c |
bne 0x10084 |
24 | 0 | r3为 0,因此不跳转,而是继续到 10090。 |
00010090 |
24 | 0 | 我们已经完成了算法,结果可以在 r0 中找到,当前 r0 的值为 24,正如预期的那样。 |
希望这个表格与你自己尝试时看到的结果一致。现在我们已经走过了 n = 4 的代码,接下来请考虑以下问题:
-
如果我们通过将
r0初始化为 1 来计算 1 的阶乘,会发生什么? -
阶乘的数学定义指出,0 的阶乘是 1. 我们的算法在这种情况下能正常工作吗?如果我们最初将
r0设置为 0,结果会是什么? -
你可能已经注意到,预期的结果 24 在倒数第二次循环时已经存储在
r0中。也就是说,程序额外循环了一次,但这并不影响r0的值。你认为为什么代码是这样写的? -
考虑到我们使用的是 32 位寄存器,是否存在 n 的实际上限?也就是说,是否可以提供一个 n 值,导致结果太大,无法适配 32 位寄存器?
以下是这些问题的答案:
-
第一次
subs指令将r3设置为 0,接下来的ble指令跳转到0x10090,因为r3为 0. 此时r0中的结果仍然是 1,这就是预期的输出。 -
不,我们的算法无法工作。第一次
subs指令将r3设置为 -1,接下来的ble指令跳转到0x10090,因为r3是负数。此时r0中的结果仍然是 0,这不是预期的输出。 -
n 的阶乘是小于或等于 n 的所有正整数的乘积。遵循这个定义意味着要将
r0乘以 1,尽管这样做并不会改变最终结果。这意味着在r3为 1 时,代码会多执行一遍循环。我们可以通过跳过乘以 1 来提高代码的效率,但我保留了这一操作,以保持阶乘的数学定义。 -
32 位整数能够表示的最大值是 2³² – 1 = 4,294,967,295. 如果我们还需要表示负数,那么最大值是 2,147,483,647. 所以如果我们尝试计算一个结果大于约 40 亿(或 20 亿)的阶乘,我们会得到一个不准确的结果。事实证明,n = 12 是我们可以使用的最大 n 值。13 的阶乘超过了 60 亿,已经太大,无法存储在 32 位整数中。
9-1:位运算符
练习: 请考虑以下 Python 语句。执行完这段代码后,a、b 和 c 的值是多少?
x = 11
y = 5
a = x & y
b = x | y
c = x ^ y
解答: 图 A-9 显示了当对 11 和 5 的值应用与、或、异或位操作时的工作方式。

图 A-9:对两个值进行位操作
所以,a 的值是 1. b 的值是 15(1111 二进制)。c 的值是 14(1110 二进制)。
9-2:在脑海中运行 C 程序
练习: 尝试在脑海中运行以下函数,或者使用笔和纸。假设输入值为 n = 4。当函数返回时,返回的结果应该是预期的 24。我建议您在每一行代码前后,记录下 n 和 result 的值,直到您走完整个 while 循环,看看是否得到预期结果。
请注意,while 循环的条件 (--n > 0) 将递减操作符 (--) 放在变量 n 之前。这意味着在将 n 的值与 0 比较之前,n 会被递减。每次评估 while 循环条件时,都会发生这种情况。
// Calculate the factorial of n.
int factorial(int n)
{
int result = n;
while(--n > 0)
{
result = result * n;
}
return result;
}
解答: 在继续阅读之前,我强烈建议您尝试完成这个练习!如果自己动手做,您会学到更多。完成这个练习后,请查看表 A-5,查看每一步执行示例 C 代码的过程。箭头 (→) 表示变量值从左侧的值变为右侧的值。
表 A-5: 阶乘 C 代码,逐步解析
| 语句 | 结果 | n | 备注 |
|---|---|---|---|
int factorial(int n) |
? | 4 | 我们想计算 4 的阶乘,因此将 n 设置为 4 作为函数的输入。 |
int result = n; |
? → 4 | 4 | 初始时,将 result 设置为 n 的值。 |
while(--n > 0) |
4 | 4 → 3 | 减少 n。n > 0,因此执行 while 循环体。 |
result = result * n; |
4 → 12 | 3 | result = 4 × 3 |
while(--n > 0) |
12 | 3 → 2 | 减少 n。n > 0,因此再次执行 while 循环体。 |
result = result * n; |
12 → 24 | 2 | result = 12 × 2 |
while(--n > 0) |
24 | 2 → 1 | 减少 n。n > 0,因此再次执行 while 循环体。 |
result = result * n; |
24 → 24 | 1 | result = 24 × 1 |
while(--n > 0) |
24 | 1 → 0 | 减少 n。n = 0,因此退出 while 循环。 |
return result; |
24 | 0 | 我们已经完成了函数,计算结果可以在 result 中找到,结果是 24,符合预期。 |
希望这张表与您自己尝试时看到的结果相匹配。
11-1:哪些 IP 位于同一子网中?
练习: IP 地址 192.168.0.200 是否与您的计算机位于同一子网中?假设您的计算机的 IP 地址是 192.168.0.133,子网掩码是 255.255.255.224。
解答: 正如我们在第十一章中发现的,您计算机子网的网络 ID 是 192.168.0.128。假设两台设备在同一子网中,它们将共享子网掩码和网络 ID。将另一台计算机的 IP 地址与我们的子网掩码进行逻辑与运算,得到网络 ID。
IP = 192.168.0.200 = 11000000.10101000.00000000.11001000
MASK = 255.255.255.224 = 11111111.11111111.11111111.11100000
AND = 192.168.0.192 = 11000000.10101000.00000000.11000000 = The network id
另一台计算机的网络 ID (192.168.0.192) 与您计算机的网络 ID (192.168.0.128) 不匹配,因此它们位于不同的子网中。这意味着这两台主机之间的通信需要经过路由器。
11-2:研究常见端口
练习: 查找常见应用层协议的端口号。域名系统(DNS)、安全外壳协议(SSH)和简单邮件传输协议(SMTP)的端口号分别是多少?你可以通过搜索在线资料或查阅 IANA 注册表来找到这些信息,地址是:www.iana.org/assignments/port-numbers。IANA 注册表有时会用一些意外的术语来表示服务名称。例如,DNS 通常仅列为“domain”。
解决方案:
-
DNS: 53
-
SSH: 22
-
SMTP: 25
12-1: 识别 URL 的各个部分
练习: 对于以下 URL,识别出协议、用户名、主机、端口、路径和查询。并非所有 URL 都包含所有这些部分。
解决方案:
example.com/photos?subject=cat&color=black
协议 https
主机 example.com
路径 photos
查询 subject=cat&color=black
192.168.1.20:8080/docs/set5/two-trees.pdf
协议 http
主机 192.168.1.20
端口 8080
路径 docs/set5/two-trees.pdf
协议 mailto
用户名 someone
主机 example.com
第十五章:**B
资源**

本附录包含帮助你开始项目的信息。我们介绍了如何寻找可购买的电子元件,如何为数字电路供电,以及如何设置树莓派。
购买项目所需电子元件
通过动手操作电子元件和编程,你能帮助你更好地理解本书中的概念,但获取各种组件可能让人感到有些吓人。本节将帮助你找到项目所需的电子元件。
以下是所有项目中使用的组件的完整清单,以防你想一次性购买所有材料:
-
面包板(至少一个 830 点的面包板。如果你打算在每次练习之间拆解电路,使用一个面包板就足够了。如果你希望保持电路完整,您需要多个面包板。)
-
电阻器(一组电阻器。这里是使用的具体数值:47kΩ,10kΩ,4.7kΩ,1kΩ,470Ω,330Ω,220Ω。)
-
数字万用表
-
9 伏电池
-
9 伏电池夹连接器
-
一包 5mm 或 3mm 的红色 LED(发光二极管)
-
两个 NPN BJT 晶体管,型号 2N2222,TO-92 封装(也称为 PN2222)
-
适用于面包板的跳线(包括公对公和公对母)
-
按钮开关或滑动开关,适合面包板使用
-
7402 集成电路
-
7408 集成电路
-
7432 集成电路
-
两个 7473 集成电路
-
7486 集成电路
-
220μF 电解电容
-
10μF 电解电容
-
5 伏电源(详情请参见第 336 页的“数字电路供电”部分。)
-
树莓派及相关设备(详情请参见第 341 页的“树莓派”部分。)
-
推荐:鳄鱼夹(这些可以使你更容易将电池连接到面包板或将万用表连接到电路。)
-
可选:剥线器(你可能需要一个剥线器来剥去电线末端的塑料,暴露出铜线。)
虽然这个清单列出了某些元件的具体数量,但对于一些零件,你可能想多买一些以防损坏,或者如果你想做实验的话。我建议你准备一些备用的晶体管和每种集成电路的一个备用。
7400 零件编号
寻找适合的 7400 系列集成电路(IC)可能会很有挑战性,因为这些芯片的零件编号比仅仅 74xx标识符包含更多的细节。7400 系列有许多子系列,每个子系列都有自己的零件编号方案。此外,制造商还会在零件编号前后添加自己的前缀和后缀。一开始可能会很混乱,所以让我们看一个例子。我最近想购买一个 7408 集成电路,但我实际订购的零件编号是 SN74LS08N。让我们在图 B-1 中分解这个零件编号。

图 B-1:解读 7400 系列零件编号
因此,SN74LS08N 是由德州仪器生产的 7408 与门,属于低功耗肖特基子系列,采用通孔封装。无需担心“低功耗肖特基”的细节,只需要知道这是一个常见的零件子系列,适用于我们的目的。
本书中的项目要求你确保所使用的零件相互兼容。项目假设你使用的零件兼容原始的 7400 逻辑电平(5V)。考虑到现有的零件,我建议你购买 LS 或 HCT 系列的零件。所以如果你需要 7408,你可以购买 SN74LS 08N 或 SN74HCT 08N。一般来说,你应该能够在同一电路中混合使用 LS 和 HCT 系列的零件。前缀字母(例如此处的 SN)对兼容性没有影响;你不需要购买特定厂商的零件。后缀则非常重要,因为它表示封装类型。确保购买适合面包板的零件——N 系列的零件适用。
购物
如果附近恰好有一家能够提供这些零件的本地电子商店,我建议你去看看。店员可以帮助确保你得到需要的零件。然而,我发现这类商店越来越稀少;你可能无法在本地找到所有需要的零件。
你的下一个选择是在线购物。为了方便你,我整理了一个网页,提供了来自不同商店的所需零件链接: www.howcomputersreallywork.com/parts/。或者,如果你想自己挑选,表 B-1 列出了一些你可以购买零件的热门商店;我还为每个商店添加了备注。我并不特别推荐这些商店,你也许能在其他地方找到零件。当然,这些在线商店的状态可能会在本书出版后发生变化。
表 B-1: 在线购买电子元件
| 商店 | 备注 |
|---|---|
Adafruitwww.adafruit.com/ |
Adafruit 提供了丰富的电子元件选择;不过,最后一次检查时,他们没有单独销售 7400 系列的逻辑门。 |
Amazonwww.amazon.com/ |
Amazon 销售流行商品,如树莓派,但集成电路的订购通常不可选或者价格较高。例如,一些商品只能按 50 或 100 个为一包出售。你可能不需要 100 个晶体管,但如果你是 Prime 会员,实际上从 Amazon 购买 100 个晶体管比从其他商店购买 5 个要更划算,尤其是考虑到运费。 |
Digi-Key Electronicswww.digikey.com/ |
Digi-Key 主要面向专业人士而非爱好者,网站可能会让人感到有些难以应对,但你可以在这里找到集成电路,包括 7400 系列的逻辑电路。 |
Mouser Electronicswww.mouser.com/ |
Mouser 类似于 Digi-Key,主要面向专业人士,并销售集成电路。 |
SparkFunwww.sparkfun.com/ |
和 Adafruit 一样,你可以在这里找到很多电子产品,但至少我上次检查时,找不到 7400 逻辑门。 |
德州仪器store.ti.com/ |
德州仪器生产 7400 系列逻辑电路,并通过其官网直接销售这些产品。我发现他们的价格合理,且运输选项很好。 |
许多网站专门帮助人们找到电子零件。这些网站提供易于导航的用户界面,并允许跨多个零售商进行价格比较。我发现两个非常有用的网站是 Octopart (octopart.com) 和 Findchips (www.findchips.com)。
为数字电路供电
7400 系列逻辑芯片需要 5V 电压,因此使用 9 伏电池不能为这些集成电路供电。我们来看一下为你的 7400 电路供电的几种选项。
USB 充电器
2010 年以来,许多智能手机充电器都配备了微型 USB 连接器。大约在 2016 年,USB Type-C(或称 USB-C)连接器开始变得更为普及。幸运的是,无论是 USB 类型的哪种,都提供 5V 直流电。因此,像图 B-2 中展示的 USB 充电器,都是为你的 7400 系列集成电路供电的好选择。如果你像我一样,家里可能已经有一堆旧的微型 USB 手机充电器。

图 B-2:一款微型 USB 手机充电器
然而,存在一个挑战;微型 USB 连接器不能直接插入面包板,至少没有一些额外的帮助!一个不错的选择是购买一个微型 USB 分解板,就像在图 B-3 中展示的那样。这样你可以将 USB 充电器插入分解板,分解板再插入面包板。Adafruit、SparkFun 和亚马逊都出售这些产品。可能需要一些焊接。这些板通常有五个引脚,但对于这个用途,你只需要关注 VCC(5V)引脚和 GND(地)引脚。当将其连接到面包板时,记得调整引脚的方向,确保它们不会相互连接,正如图 B-3 中所示。

图 B-3:插入面包板的微型 USB 分解板(右侧)
面包板电源供应
另一种选择是购买一个面包板电源供应器,比如 DFRobot DFR0140 或 YwRobot Power MB V2 545043。这些方便的设备插入你的面包板,并由一个带 2.1mm 圆柱插孔的墙面直流电源供电。该直流电源的电压应在 6V 到 12V 之间(确保验证你所使用的特定板子所允许的电压)。这些 2.1mm 直流电源在为消费电子产品供电时很常见——你可能已经有几个了——这种类型的电源板使得转换电压为 5V 并连接到面包板变得非常简单。图 B-4 展示了一个带有 2.1mm 圆柱插孔的常见直流电源和一个面包板电源。

图 B-4:一个带 2.1mm 圆柱插孔的电源和一个面包板电源
有一点需要注意:我个人曾见过这些电源板的电压调节器发生故障,导致板子输出的电压超过了 5V。在连接这些电源时,不要假设输出电压一定是 5V。在连接电路之前,务必测试输出电压!使用较低的输入直流电压有助于降低此风险,因此我建议你使用 9 伏或更低的直流电源,考虑到允许的电压范围是 6V 到 12V。这些电源板也可以选择输出 3.3V 而不是 5V,可以通过板上的跳线设置来控制,所以请确保跳线放置在正确的位置。
来自树莓派的电源
如果你打算为第八章的项目购买一台树莓派,你真幸运;它还有一个附加好处,就是可以当作 5 伏电源使用!树莓派的 GPIO 引脚有多种功能,但对于这个用途,你只需要知道引脚 6 是地线,引脚 2 提供 5V。你可以将这些引脚连接到你的面包板上,作为电源使用。请参阅图 13-11 和第 312 页的 GPIO 引脚图。甚至无需安装任何树莓派软件,因为一旦树莓派开机,5 伏引脚会自动启用。只需将树莓派连接到电源即可。作为额外的好处,引脚 1 还可以提供 3.3V(如果需要的话)。需要澄清的是,如果你这样做,你并没有使用树莓派的计算能力;它只是充当一个 5 伏电源。树莓派提供的电流是有限的。你为树莓派使用的电源适配器会有一个最大电流额定值,而树莓派本身也会消耗一些电流,可能在空闲时为 300mA 左右。这可能是显而易见的,但如果你选择这个选项,请小心确保你的电路连接正确;你可不想不小心损坏你的树莓派!图 B-5 展示了树莓派作为电源使用的情况。

图 B-5:将树莓派用作电源
AA 电池
你也可以使用 AA 电池为数字电路供电。单个 AA 电池提供 1.5V 电压,因此可以将三节 AA 电池串联连接,提供 4.5V 电压。虽然这个电压低于 7400 系列元件推荐的电压,但对于本书中的电路来说应该是可行的,尽管你的结果可能会有所不同。你可以购买一个适用于三节 AA 电池的电池座,并将其输出电线连接到你的面包板,如图 B-6 所示。

图 B-6:使用三节 AA 电池为面包板上的电路供电
电路故障排除
有时候你搭建电路时,预期电路按某种方式工作,但结果却完全不同。也许电路看起来什么都不做,或者它的行为与你的预期不一致。别担心,这种情况每个做电路的人都会遇到!接线错误或松动的连接很容易导致问题,打乱整个电路的工作。故障排除和诊断电路问题是一项非常有价值的技能,实际上能帮助你更好地理解事物的工作原理。在这里,我分享一些当我的电路不能正常工作时,我常用的故障排除方法。
如果电路中的任何元件过热到无法触摸的程度,请立即将电路与电源断开。接线错误可能导致元件过热。如果电路继续连接超过几秒钟,往往会损坏元件。
你进行电路故障排除的主要工具是万用表。使用万用表,你可以轻松检查电路中各个点的电压。问问自己:“我预期电路中这个点或那个点的电压应该是多少?”对于 5 伏的数字电路,预期的电压通常是大约 0V 或大约 5V。如果你的万用表显示电路中的某个点电压异常,问问自己,“是什么因素可能影响这个电压?”然后检查这些因素。
对于数字电路,我通常采取“逆向工作”的方法,从表现异常的元件开始。确认它的输出电压是否有问题,然后检查它的输入。是否有某个输入电压也不正常?如果是,回溯到为该输入提供电压的元件,检查其输出。重复此过程,直到找到问题的根源。
在检查电压时,我发现最简单的方法是将黑色/负极/COM 探头连接到电路中的接地点,并保持连接。如果没有明显的接地点,可以在面包板上的接地点加一个跳线,然后用鳄鱼夹将跳线连接到 COM 探头。将 COM 探头固定在地面后,你可以轻松地使用红色的正极探头,在电路的各个点进行测量,检查与地面相比的电压。
我在故障排除时经常使用万用表检查的另一项是电阻。有时我知道两个点之间预期的电阻值,并且希望验证这个电阻值。如果连接这两个点有多个路径,请确保你知道预期的电阻,以便正确解释你的测量结果。
通常我检查电阻是为了确保两个点连接,这时我预期的电阻值大约是 0Ω。相反,有时我希望确保两个点没有连接,这时我会寻找一个非常高的电阻,即开路。一些万用表还包括连续性检查功能,当两个点连接时,万用表会发出声音。如果你只是检查连接性,有时这种方式比检查电阻更为方便。
故障排除时需要验证的一些具体事项:
面包板电源 你的面包板在长电源列上是否有合适的电压?正电压列应该等于你的电源电压(例如,9 伏电池或 5 伏电源)。如果两个面包板两边都在使用,一定要检查两侧的电压。
面包板连接 验证面包板上的接线是否稳固。导线是否完全插入,还是有松动的连接?再次检查面包板上连接的对齐情况;导线是否插在正确的行?你检查的行中是否有多余的连接?
电阻器 你的电阻器的数值是否正确?如有需要,逐一将它们从电路中取出,并使用万用表进行验证。
LED 灯 你的 LED 是否正确安装?短引脚应该连接到地线。
电容器 如果你的电容器是有极性的,确保正负引脚正确连接。还要检查电容值。
集成电路 你的 IC 是否正确连接到地线和正电压?芯片是否完全插入面包板,并且位于中央的间隙中?通过检查缺口来确认 IC 是否正确对齐。你是否使用了正确的零件编号?
数字输入开关/按钮 使用下拉电阻时,开关的一侧是否连接到正电压,另一侧是否通过下拉电阻连接到地线?相关芯片上的数字输入引脚是否连接到与下拉电阻相同的一侧?
树莓派
树莓派是一款小巧、廉价的计算机。它的开发目的是促进计算机科学的教学,并且在技术爱好者中获得了广泛关注。它是我们本书中使用的计算机,所以在这里我们将介绍设置和使用树莓派的基本知识。
为什么选择树莓派
在详细讲解如何配置你的 Raspberry Pi 之前,我想先解释一下为什么我选择 Raspberry Pi 作为本书的主要工具。一些项目需要某种计算机设备来进行交互。现在,你可能会想:“我已经有计算机了,为什么还需要一个?”是的,既然你正在阅读一本关于计算的书,你可能已经拥有计算机,甚至多台!然而,并不是每个人都拥有相同类型的计算机,有些类型的计算设备在教育方面比其他设备更为适合。此外,本书中的一些项目涉及计算机的底层细节,因此每个参与的人都需要使用相同类型的设备。
选择 Raspberry Pi 是一个自然的决定,因为它价格低廉(大约$35)且专为计算机教育设计。我的目标不是让你把 Raspberry Pi 作为主力电脑,或者让你成为 Raspberry Pi 专家。相反,我们使用 Raspberry Pi 来学习一些核心概念,然后你可以将这些概念应用到任何计算设备上。Raspberry Pi 使用 ARM 处理器,我们将运行Raspberry Pi OS(之前称为Raspbian),这是为 Raspberry Pi 优化的 Linux 版本。
所需配件
首先,你需要获得一个 Raspberry Pi 和一些配件。以下是你需要的东西:
-
Raspberry Pi。这些设备通常售价约为$35,可以在线购买。本文撰写时,最新型号为 Raspberry Pi 4 Model B,本书中的练习已在该版本和 Raspberry Pi 3 Model B+上进行了测试。如果发布了新型号,考虑到 Raspberry Pi 具有向后兼容的良好记录,它也应该是可用的。Raspberry Pi 4 Model B 有多个内存配置(1GB、2GB、4GB 和 8GB),本书中的任何一个都适用。
-
USB-C 电源适配器(仅适用于 Raspberry Pi 4)。Raspberry Pi 4 使用 USB-C 电源适配器。电源适配器需要提供 5V,并且至少 3A。某些 USB-C 电源适配器与一些 Raspberry Pi 4 设备不兼容,因此我建议你购买专为 Raspberry Pi 4 设计的 USB-C 电源适配器。
-
Micro-USB 电源适配器(仅适用于 Raspberry Pi 3)。与 Raspberry Pi 4 不同,Raspberry Pi 3 使用 micro-USB 电源适配器供电,类似于许多智能手机所使用的适配器。如果你已经有智能手机充电器,它可能也适用于 Raspberry Pi。只需确保连接器是 micro-USB。此类充电器的标准电压输出为 5V,但它们提供的最大电流不同。对于 Raspberry Pi 3,建议使用能够提供至少 2.5A 电流的电源适配器。电流需求根据你连接到 Raspberry Pi 的设备而有所不同。因此,请检查你的智能手机充电器,看它能够提供多少电流;你可能需要购买专为 Raspberry Pi 设计的 micro-USB 电源适配器。
-
MicroSD 卡,8GB 或更大。 树莓派没有自带存储器,因此你需要通过 microSD 卡来添加存储。这些卡片通常用于智能手机和相机,所以你可能已经有一张闲置的卡。安装树莓派操作系统的过程会清除已有的数据,请确保备份你在 microSD 卡上存储的任何内容。
-
USB 键盘和 USB 鼠标。 任何标准的 USB 键盘和 USB 鼠标都可以使用。
-
支持 HDMI 的电视或显示器。 所有现代电视和许多计算机显示器都支持 HDMI 连接。
-
HDMI 电缆。 树莓派 3 使用标准的全尺寸 HDMI 电缆,但树莓派 4 有一个 micro-HDMI 端口。如果你的显示设备支持全尺寸 HDMI 输入,那么树莓派 4 需要一个 micro-HDMI 到 HDMI 的电缆或适配器。
-
可选:树莓派机箱。 这不是必须的,但有的话会很不错。请注意,树莓派 3 和树莓派 4 的物理布局不同,因此它们需要不同形状的机箱。
请参阅本附录中“购买电子组件”部分的“购物”小节,以获取如何购买这些零件的帮助。
设置树莓派
树莓派网站 (www.raspberrypi.org) 提供了详细的设置指南,带你一步步完成树莓派的设置。我在这里不会覆盖所有细节,因为在线文档已经存在,并且随着时间变化会更新。让我为你简要概述一下所需的步骤。
你有多种选择可以在树莓派上安装树莓派操作系统。如果你有一台带有 microSD 卡读写器的计算机,最简单的方式是使用 树莓派镜像工具。下面是使用这个工具快速启动树莓派的方法:
-
将你的 microSD 卡插入计算机。
-
从
www.raspberrypi.org/downloads下载树莓派镜像工具。 -
在你的计算机上安装并运行树莓派镜像工具。
-
选择操作系统:树莓派操作系统(32 位)。
-
选择你想使用的 SD 卡。
-
点击“写入”,树莓派操作系统将被复制到你的 microSD 卡中。
-
从你的计算机中取出 microSD 卡。
-
将 microSD 卡插入树莓派。
-
使用 HDMI 将树莓派连接到 USB 键盘、USB 鼠标以及显示器或电视,最后连接电源。
-
树莓派应该会启动到树莓派操作系统。
安装树莓派操作系统的另一种好方法是使用树莓派的新盒子软件(NOOBS)。要使用 NOOBS,请从 www.raspberrypi.org/downloads 下载它并复制到一张空白的 microSD 卡上。如果你没有其他电脑来进行这项操作,你也可以购买一张预加载了 NOOBS 的 microSD 卡。在任何一种情况下,一旦你将 NOOBS 放到 microSD 卡上,插入树莓派并开启电源。按照屏幕上的指示来安装树莓派操作系统。
注意
在撰写本书时,Raspberry Pi OS 的 64 位版本已经作为测试版发布。然而,本书中的项目是基于 32 位 Raspberry Pi OS 测试的,因此我建议你在进行项目时使用 32 位版本。
使用 Raspberry Pi OS
一旦你的 Raspberry Pi 设置完成,我建议你花点时间熟悉一下 Raspberry Pi OS 的用户界面。如果你之前使用过 Mac 或 Windows PC,Raspberry Pi OS 的桌面环境应该会有些熟悉。你可以在窗口中打开应用程序,移动这些窗口,关闭它们,等等。
也就是说,本书中的大多数项目并不需要你使用任何 Pi 的图形化应用程序。几乎所有的操作都可以通过终端完成,而且大多数项目至少需要使用终端,因此我们先花一点时间来熟悉它。从 Raspberry Pi OS 的桌面上,你可以通过点击 Raspberry(左上角的图标)▶ 附件 ▶ 终端 来打开一个终端窗口,如 图 B-7 所示。

图 B-7:打开 Raspberry Pi 终端
终端是一个 命令行界面(CLI),你在其中所做的一切操作都是通过输入命令来完成的。Raspberry Pi OS 和所有版本的 Linux 一样,都对 CLI 提供了出色的支持。如果你知道正确的命令,你几乎可以从终端做任何事情。默认情况下,Raspberry Pi OS 的终端运行一个叫做 bash 的 Shell。Shell 是操作系统的用户界面,可以是图形化的(如桌面)也可以是基于命令行的。bash 命令行中的初始文本应该像这样:
pi@raspberrypi:~ $
让我们逐个分析该文本字符串的各个部分:
pi 这是当前登录用户的用户名。默认用户的名称是“pi”。
raspberrypi 用@符号与用户名分隔,这是计算机的名称。
~ 这表示你正在工作的当前目录(文件夹)。~ 字符有特别的含义,它指的是当前用户的主目录。
$ 美元符号是 CLI 提示符,表示你可以在此处输入命令。
在本书中,当我列出应该在终端中输入的命令时,我会在命令前加上一个 $ 提示符。例如,以下命令列出了当前目录中的文件:
$ ls
要运行命令,你不需要输入美元符号;只需输入其后面的文本,然后按下 ENTER 键。如果你想运行之前输入过的命令,可以按键盘上的上箭头键浏览之前输入的命令。
如果你更喜欢在终端中工作,你可以设置树莓派直接启动到命令行界面,而不是桌面环境:Raspberry ▶ Preferences ▶ Raspberry Pi Configuration ▶ System tab ▶ Boot ▶ To CLI。完成这个配置更改后,系统下次启动时会直接进入 CLI,而不是桌面环境。如果你在 CLI-only 环境中,并且想要启动桌面环境,只需运行以下命令:
$ startx
作为终端用户,另一种选择是通过网络使用安全外壳(SSH)从另一台计算机甚至手机控制你的树莓派。这种方法的最终结果是,你的树莓派可以在网络中的任何地方运行,即使没有连接显示器或键盘,你也可以使用其他设备的键盘和显示器来控制它。要实现这一点,你必须在树莓派上启用 SSH(Raspberry ▶ Preferences ▶ Raspberry Pi Configuration ▶ Interfaces tab ▶ SSH ▶ Enable),然后在第二台设备上运行 SSH 客户端应用。我不会提供详细的设置步骤,但网上有很多指南可以帮助你完成此操作。
当你完成使用树莓派一段时间后,你应该优雅地关闭它,以避免数据损坏,而不是直接关机。在桌面环境下,你可以通过Raspberry ▶ Shutdown… ▶ Shutdown来关闭系统。或者在终端中,你可以使用以下命令来停止系统:
$ sudo shutdown -h now
当你看到附加的显示器不再显示任何内容,并且树莓派板上的活动指示灯停止闪烁时,就说明系统已经完全关机。然后你可以拔掉树莓派的电源。
文件和文件夹操作
本书中的项目通常会指导你创建或编辑文本文件,并在文件上运行一些终端命令。我们将讨论如何在树莓派操作系统中操作文件和文件夹,既可以通过命令行也可以通过图形桌面来操作。操作系统通过文件系统来组织存储设备上的数据,就像树莓派中的 microSD 卡。文件是数据的容器,文件夹(也称为目录)是文件或其他文件夹的容器。文件系统的结构是一个层级,一个文件夹的树状结构。在 Linux 系统中,这个层级的根是表示为/。根文件夹是最上层的文件夹,所有其他文件夹和文件都在根文件夹“之下”。
根文件夹下的一个文件夹会像这样表示:/
在 Raspberry Pi OS 中,每个用户都有一个用于工作的主文件夹。Raspberry Pi OS 的默认用户名为 pi,pi 用户的主文件夹位于 /home/pi。当你以 pi 用户身份登录时,该主文件夹也可以用 ~ 字符来表示。假设你在主文件夹中创建了一个名为 pizza 的文件夹,它的完整路径为 /home/pi/pizza,或者当以 pi 用户身份登录时,你可以将其称为 ~/pizza。让我们尝试从终端窗口创建一个 pizza 文件夹,使用 mkdir 命令,缩写自“make directory”。输入命令后不要忘记按回车键。
$ mkdir pizza
在终端中,你可以使用 ls 命令查看你新创建的文件夹:
$ ls
当你输入 ls 并按下回车键时,你应该会看到 pizza 文件夹,旁边还有一些已经存在于你的主文件夹中的其他文件夹,如 Desktop、Downloads 和 Pictures。
终端并不是查看文件夹中文件的唯一方式。你还可以使用文件管理器应用程序,该应用程序可以通过 Raspberry ▶Accessories ▶File Manager 启动。如 Figure B-8 所示,文件管理器应用程序以默认视图打开,显示你的主目录。

Figure B-8: Raspberry Pi OS 文件管理器
文件管理器的左侧显示了完整的文件系统文件夹层次结构,当前选中的文件夹会被高亮显示——在此例中是 pi。顶部的地址栏显示 /home/pi,表示当前文件夹。现在,尝试双击 pizza 文件夹;它应该是空的。让我们回到终端窗口,在该文件夹中创建一些文件。首先,使用 cd 命令(change directory 变更目录)更改文件夹,使得当前文件夹为 pizza 文件夹。然后,使用 touch 命令创建两个空文件。最后,我们使用 ls 命令列出目录内容,应该能看到这两个新文件名。
$ cd pizza
$ touch cheese.txt
$ touch crust.txt
$ ls
请注意,当你切换到 pizza 文件夹时,你的 bash 提示符也应已更改。现在它应该在 $ 之前包含 ~/pizza,表示当前文件夹。现在查看文件管理器应用程序窗口,它应该也会显示 pizza 文件夹下的两个新文件,如 Figure B-9 所示。

Figure B-9: Raspberry Pi OS 文件管理器:pizza 文件夹中的文件
现在我们在 pizza 文件夹中有两个空文件。让我们往文件中添加文本内容。首先,我们将使用一种命令行文本编辑器 nano 编辑 cheese.txt 文件。
$ nano cheese.txt
当终端中的 nano 编辑器窗口打开时,你可以输入将保存到 cheese.txt 的文本。请记住,nano 是一个命令行应用程序——你不能使用鼠标。你需要使用箭头键来移动光标。尝试输入一些文本,如 Figure B-10 所示。

Figure B-10: 使用 nano 编辑 cheese.txt
在nano中输入一些文本后,按下 CTRL-X 退出nano。编辑器会提示你保存工作(“保存已修改的缓冲区?”)。这看起来可能是个奇怪的问题,但不要让“缓冲区”这个术语困扰你——nano只是问你是否想将输入的文本保存到文件中。按 Y,然后按 ENTER 接受建议的文件名(cheese.txt)。
我经常使用nano,因为它可以在终端中工作,但你也许更喜欢使用图形化文本编辑器编辑文件。Raspberry Pi OS 桌面环境中包含一个方便的文本编辑器,你可以通过Raspberry ▶Accessories ▶Text Editor来启动。写这本书时,启动的编辑器是 Mousepad。接下来,我们可以尝试在这个文本编辑器中进一步修改cheese.txt。首先,我们需要通过以下步骤在文本编辑器中打开文件:File ▶Open ▶Home ▶pizza ▶cheese.txt ▶Open(按钮),如图 B-11 所示。

图 B-11:在文本编辑器中打开 cheese.txt
一旦你在文本编辑器中打开了文件,你应该会看到你之前输入的文本。你可以根据需要编辑文本。然后通过File ▶Save保存更改。
除了编辑现有文件外,你还可以使用文本编辑器创建新文件。只需启动一个新的编辑器窗口(Raspberry ▶Accessories ▶Text Editor),然后点击File ▶Save。这将提示你将文件保存到你选择的文件夹中,并指定一个文件名。你可以编辑新文件的内容,并在需要时通过File ▶Save保存更改。如果你想用新名称保存现有文件,可以选择File ▶Save As…。
如果你更喜欢使用nano创建新文件,首先在终端窗口中切换到你希望保存文件的文件夹(如果需要),然后输入nano filename,如下所示:
$ nano new-file.txt
输入你的文本,当你退出nano时,它会提示你保存这个新文件。
我们刚刚介绍了在 Raspberry Pi OS 中查看、编辑和创建文本文件的方法。如果你想留在终端中,nano是一个不错的选择。如果你更喜欢桌面环境,那么文本编辑器 Mousepad 应该能满足你的需求。Raspberry Pi OS 还包含其他编辑器。Geany 是一款程序员使用的文本编辑器,而 Thonny Python IDE 则专为 Python 编程设计。两者都可以在Raspberry ▶Programming下找到。在本书的项目中,我会让你自行决定使用哪个文本编辑器。
你也可以通过其他方式管理你的文件和文件夹——移动文件、删除文件等。你可以通过文件管理器完成所有这些操作,或者也可以通过终端窗口来完成。下面是一些你可以在 bash 提示符下使用的命令,帮助你入门:
**cd *folder*** 改变当前目录(文件夹)。
**mkdir *folder*** 创建一个目录。
**rm *file*** 删除一个文件。
**rm -rf *folder*** 删除文件夹及其内容,包括子文件夹。
**mv *file file2*** 重命名文件。
**mv *file folder*/** 将文件从一个位置移动到另一个位置。
**cp *file folder*/** 将文件从一个位置复制到另一个位置。




浙公网安备 33010602011771号