一、计算机的核心架构
1、计算模型
计算模型是一组基本操作及其各自的成本。这些成本通常是整数,是通过计算算法所有操作的次数来推断算法的复杂性,此处不做深入讨论。
大多数计算模型都是抽象机器,表示它们描述了一台虚拟的计算机,其指令对应于模型的基本操作。还有其它模型,例如决策树等等。
2、冯诺依曼架构
计算机架构描述了计算机系统的功能、组织和实现。与计算模型相比,这是一个相对高级的描述,具有无数的细节。
冯诺依曼架构有两个关键优势:它稳定(在电子元件高度不稳定且寿命短的时代)和易于编程。
简而言之,这是一台由一个处理器和一个存储区域组成的计算机,连接到总线。中央处理器(CPU)可以执行由控制单元从内存中获取的指令。算术逻辑单元(ALU)执行所需的计算,内存用来存储数据。
以下是此体系结构的主要特点:
- 内存仅存储二进制位(信息单位bit,等于0或1的值)
- 存储器存储指令和操作数,不能区分数据和代码,两者实际上都是二进制串
- 内存被分成许多存储单元,这些存储单元用各自的索引标记(例如单元格#43跟随单元格#42),从0开始。存储单元大小可能会变化(约翰·冯·诺依曼(John von Neumann)认为每个二进制位都应该有地址);现代计算机将一个字节(八位)作为存储单元大小。因此,第0个字节包含内存的前8位,依此类推。
- 该程序由连续的指令组成,除非执行特殊的跳转指令,否则它们的执行是顺序的。
汇编语言是一种编程语言,由每个可能的二进制编码指令(机器码)的助记符组成。它使机器码编程变得更加容易,因为程序员不必记住指令的二进制编码,只需记住它们的名称和参数。
与计算模型不同,计算机架构并不总是定义精确的指令集。
如今,现代个人计算机是从旧的冯诺依曼架构的计算机演变而来的,因此我们将研究这种演变,看看现代计算机与下图简单原理的区别。
注意:内存状态和寄存器的值完全描述了 CPU 状态(从一个程序员的角度来看)。理解指令意味着了解它对内存和寄存器的影响。
二、计算机架构的演变
1、冯诺依曼架构的缺点
首先,这种架构根本没有交互性。程序员受到很大限制,需要进行手动内存编辑和以某种方式可视化其内容。在计算机的早期却非常简单,因为电路很粗,可以徒手进行操作。
此外,这种架构对多任务处理不友好。想象一下,您的计算机正在执行一项非常缓慢的任务(例如,控制打印机),然而打印机比最慢的 CPU 还要慢得多。CPU 等待设备反应的时间百分比会接近99%,这是对资源的浪费(即 CPU 时间)。
另一个问题是内存和 CPU 性能差异会很大。在过去,计算机更简单:它们被设计为完整的实体。内存、总线、网络接口——一切都由同一个工程团队创建。每个零件都专门用于此特定模型。因此,零件是注定不能更换的。在这些情况下,没有人愿意创造一个具有比其他部件更高性能的部件,因为它不可能提高计算机的整体性能。但随着计算机架构变得稳定,硬件开发人员开始独立研究在计算机的不同部分。当然,他们也考虑成本因素。然而,并非所有零件都可以轻松且便宜地加速,虽然可以通过选择其他类型的底层电路来加速内存,但成本要高得多。这就是 CPU 很快变得比内存快得多的原因。当系统由不同的部件组成并且它们的性能特征差异很大时,最慢的部件可能会成为瓶颈。这意味着,如果将最慢的部分替换为更快的模拟部分,则整体性能将显着提高。这就是必须对架构进行大量修改的地方。
2、英特尔® 64 架构
自1970年以来,英特尔一直在开发主要处理器。每个模型都旨在保留与老的计算机模型的二进制兼容性。这意味着即使是现代处理器也可以执行旧模型编写和编译的代码。它导致了大量的硬件遗留。处理器可以在多种模式下运行:实模式、保护模式、虚拟8086模式、长模式等。
3、计算机架构扩展
英特尔® 64 架构集成了冯诺依曼架构的多个扩展。此处列出了最重要的内容概述。
寄存器 直接放置在 CPU 芯片上的存储单元。在电路方面,它们要快得多,但更复杂和昂贵。寄存器访问不使用总线,响应时间非常短,通常相当于几个 CPU 周期。
硬件堆栈 堆栈通常是一种数据结构,它支持两种操作:将元素推到其顶部和弹出最上面的元素。硬件堆栈通过特殊指令和在内存之上的寄存器实现这种抽象操作,指向最后一个堆栈元素。堆栈不仅用于计算,还用于存储局部变量并在编程语言中实现函数调用序列。
中断 此功能允许根据程序本身外部的事件更改程序执行顺序。捕获信号(外部或内部)后,程序的执行将暂停,一些寄存器被存储,CPU 开始执行一个特殊的流程来处理这种情况。以下是发生中断的原因:
- 外部设备的信号
- 除以0
- 无效指令(当 CPU 无法通过二进制表示或识别指令时)
- 尝试在非特权模式下执行特权指令。
保护环 CPU始终与保护环处于相对应的状态。每个环会定义一组可执行的指令。Ring 0允许执行整个 CPU 指令集中的任何指令,所以它是最优先的。Ring 3只允许执行最安全的指令。尝试执行特权指令会导致中断。大多数应用程序都在Ring 3内工作,以确保它们不会修改关键的系统数据结构(例如页表),并且不会绕过操作系统与外部设备一起使用。另外两个环(Ring 1和Ring 2)是中间环,现代操作系统不使用它们。
虚拟内存 这是物理内存的抽象,能以更安全、更有效的方式在程序之间分配内存,它还把程序彼此隔离。
下表总结了现代计算机中对某些对冯诺依曼架构进行扩展的信息。
存在的问题 | 解决方案 |
---|---|
内存读取速度太慢 | 寄存器,缓存 |
交互性差 | 中断 |
不支持程序中的代码隔离,也不支持代码保存 | 硬件堆栈 |
多任务处理:任何程序都可以执行任何指令 | 保护环 |
多任务处理:程序之间不是相互隔离的 | 虚拟内存 |
参考文献:关于英特尔® 64 架构的更多信息,详见 Intel® 64 and IA-32 Architectures Software Developer Manuals
三、寄存器
CPU 和内存之间的数据交换是冯诺依曼计算机计算的关键部分。指令和操作数必须从内存中获取,一些指令也会将结果存储在内存中。但它存在缺点,在等待内存芯片的数据响应时会浪费 CPU 时间。为了避免持续的等待,处理器配备了自己的存储单元,称为寄存器。虽然寄存器数量很少,但速度很快,因此程序通常以大多数时候存储单元的工作集足够小的方式编写,即使用寄存器。
寄存器使用晶体管,而主存储器使用电容。我们本可以在晶体管上实现主存储器从而使电路更快,但工程师更喜欢用其他方法来加快计算,原因有:
- 寄存器更贵
- 指令将寄存器的编号变成为代码的一部分。为了处理更多的寄存器,指令的大小不得不增加
- 为了解决这些问题,寄存器的电路设计得更复杂,但速度会更慢
在最坏的情况下,寄存器的使用会降低计算机的速度。如果在进行计算之前必须将所有内容提取到寄存器中,然后在计算之后存储到内存中,那有什么优点吗?
程序通常有一个特定的属性,它是使用通用编程模式(如循环、函数和数据重用)的结果。此属性称为局部性,它有两种主要类型:时间和空间。
时间局部性:被引用过一次的内存位置很可能在不远的将来被再次引用。
空间局部性:被引用过的内存位置附近很可能被再次引用。
这些类型不是分立的,因此您可以编写一个时间局部性更强或空间局部性更弱的程序。
典型的程序使用以下模式:数据集很小,可以存储在寄存器中。将数据提取到寄存器中后,我们将持续使用它们,然后结果将被存储到内存中。存储在内存中的数据很少会被程序使用。如果我们需要处理这些数据,程序可能会崩溃,因为
- 需要将数据提取到寄存器中
- 如果所有寄存器都被以后仍然需要的数据占用,它们会溢出部分数据,这意味着将它们的内容会保存到临时分配的存储单元中。
注意:在工程师中,一个比较普遍的做法是:在最坏的情况下降低性能,在正常情况下提高性能。一般情况下它确实有用,但在构建实时系统时是不允许的,因为实时系统对最坏的系统反应时间施加了限制。此类系统需要在不超过一定的时间内对事件做出反应,因此在最坏的情况下降低性能并不是一种选择。
1、通用寄存器
大多数时候,程序员使用通用寄存器。它们是可互换的,可以在许多不同的命令中使用。
这些寄存器都是64位的,名称为 r0、r1、…、r15。其中前八个有其它的名称(较为常见),这些名称代表了它们对某些特殊说明的含义。例如,r1 也称为 rcx,其中 c 代表“循环”。循环指令使用 rcx 作为循环计数器,但不显式接受任何操作数。当然,这种特殊的寄存器的含义可以查询关于相应命令的文档(例如,作为循环指令的计数器)。
事实上,由于历史原因,这些寄存器的别名更为常见,这些语义描述仅供参考。
注意:与在主存储器上的硬件堆栈不同,寄存器是一种完全不同的存储器。因此,它们没有地址,就像主存储器的单元一样。
名称 | 别名 | 描述 |
---|---|---|
r0 | rax | 有点像累加器,用于算术指令(例如div) |
r3 | rbx | 基址寄存器,在早期处理器型号中用于基本寻址 |
r1 | rcx | 用来循环(例如loop) |
r2 | rdx | 在输入/输出操作期间用来存储数据 |
r4 | rsp | 存储硬件堆栈中最顶层元素的地址(使用不当会损坏堆栈) |
r5 | rbp | 用于存储当前函数的栈帧的基址(使用不当会损坏栈帧) |
r6 | rsi | 用于存储字符串操作指令(例如 movsd)中的源索引 |
r7 | rdi | 用于存储字符串操作命令(如 movsd)中的目标索引 |
r8 | ||
r9…r15 | no | 用于存储时间变量 |
可以对寄存器的一部分进行寻址,对于每个寄存器,您可以以其最低32位、最低16位或最低8位进行寻址。
当使用名称 r0,…,r15 时,它是通过在寄存器的名称中添加后缀来完成的:
- d 表示double - 表示最低 32 位;
- w 表示word - 表示最低 16 位;
- b 表示byte - 表示最低 8 位;
举个例子:
- r7b 表示寄存器 r7 的最低 8 位;
- r3w 表示寄存器 r3 的最低 16 位;
- r0d 表示寄存器 r0 的最低 32 位;
寄存器的别名还允许对更小的部分进行寻址。
访问 rax、rbx、rcx 和 rdx 更小部分的命名遵循相同的规则,只有中间字母在变化。其他四个寄存器不允许访问其第二小的字节(就像 rax 的更小部分 ah 一样)。rsi、rdi、rsp 和 rbp 的最小 8 位命名略有不同。
正常情况下,很少能看到名称 r0-r7,因为程序员坚持使用前八个通用寄存器的别名。这样做不仅是约定俗成的,也有语义原因:rsp 比 r4 包含了更多的信息。当然,其他 8 个寄存器(r8-r15)是使用索引 8-15 命名的。
不一样的读取:从寄存器较小的部分读取都会有一定影响。当写入寄存器 32 位的部分时,会用符号位填充完整寄存器的上 32 位。例如,将 eax 归零会使整个 rax 归零,将 -1 存储到 eax 中将用 1 填充上面的 32 位。但是,其他写入(例如,在 16 位的部分中)时所有其他位将不受影响。
2、其它寄存器
其他寄存器具有特殊含义。某些寄存器具有关乎系统的重要性,因此除了操作系统都不能能修改。
程序员可以访问 rip 寄存器。它是一个 64 位寄存器,每次执行任何指令时,它始终存储要执行的下一条指令的地址,分支指令(例如jmp)会修改它。
注意:所有的指令都有不同的大小。
另一个可访问的寄存器称为 rflags。它存储反映当前程序状态的标志。例如,最后一个算术指令的结果是什么,是否为负数,是否发生了溢出等。它较小的部分称为 eflags(32 位)和 flags(16 位)。
关于程序状态标志的更多内容,详见 汇编语言中的标志位:CF、PF、AF、ZF、SF、TF、IF、DF、OF
除了这些核心寄存器之外,还有一些寄存器使用浮点指令或特殊的并行指令,同时对多组操作数执行类似的操作。这些指令通常用于多媒体(有助于加快多媒体解码),相应的寄存器宽度为 128 位,命名为 xmm0 - xmm15。
一些寄存器起初是作为非标准扩展出现,但不久后就被标准化。这些也被称为特殊模块寄存器。
3、系统寄存器
有些寄存器是专门为操作系统设计的,它们不保存计算时使用的值。相反,它们存储系统范围内数据结构的信息。因此,它们的作用是支持某个框架,这个框架诞生于操作系统和CPU的合作。所有应用程序都在此框架内运行,寄存器确保应用程序与系统之间能够很好地隔离。它们让程序员用较为透明的方式来管理资源。
应用程序本身无法访问和修改这些寄存器,这体现了操作系统中的特权模式。
下面展示一些系统寄存器:
- cr0、cr4 存储不同处理器模式和虚拟内存的标志
- cr2、cr3 用于支持虚拟内存
- cr8(别名为 tpr)用于对中断机制进行微调
- efer 是另一个用于控制处理器模式和扩展的标志寄存器
- idtr 存储中断描述符表的地址
- gdtr 和 ldtr 存储描述符表的地址
- cs、ds、ss、es、gs、fs 叫做段寄存器,它们提供的分段规则多年来一直是约定俗成的,其中一部分仍用于实现特权模式
四、保护环
为了保证安全性和稳健性,保护环被用来限制应用程序功能。保护环最初用于 Multics OS ,该操作系统是 Unix 的前身,每个环对应于特定的权限级别。每种指令类型都与一个或多个权限级别相关联,并且不能在其他级别的指令上执行。当前权限的级别以某种方式存储(例如,存储在特殊寄存器中)。
Intel 64 有四个特权级别,但只有两个在实践中使用:ring-0(最高特权)和 ring-3(最低特权)。中间环用于驱动程序和操作系统,但现代主流操作系统没有采用。
在长模式下,保护环编号被存储在寄存器 cs 的最低两位中(还有一份拷贝在 ss 中)。只有在处理中断或系统调用时才能更改它。因此,应用程序无法穿过高级权限执行任何代码:它只能调用中断处理程序或执行系统调用。
五、硬件堆栈
如果我们讨论基本数据结构,那么堆栈就是其中之一,它是一个具有两个操作的容器:一个新元素可以放在堆栈的顶部(push),顶部元素可以从堆栈中取出(pop)。
这种数据结构有硬件支持,但这并不意味着有一个单独的堆栈内存,它只是用两个机器指令(push 和 pop)和一个寄存器(rsp)模拟的。rsp 寄存器保存堆栈最顶层元素的地址,它的工作流程如下:
- 压入元素
1、分别根据参数大小(允许 2、4 和 8 个字节),减少 rsp 中的值(减少 2、4 或 8)
2、从变化后的 rsp 中取出参数,存储在内存中,指向地址 - 弹出元素
1、最顶层的堆栈元素被复制到寄存器/内存中
2、rsp 的值按参数的大小增加
在高级语言中,硬件堆栈实现函数调用十分有用。当函数 A 调用另一个函数 B 时,它使用堆栈来保存计算的上下文,以便在 B 终止后返回其计算值。
以下是有关硬件堆栈的一些重要事实,描述如下:
- 即使我们一直不执行 push 操作,也不会出现堆栈为空的情况。然而无论何时,pop 操作都可以执行,但可能会返回一个最顶层的“垃圾”堆栈元素
- 堆栈元素增加时,地址趋于0
- 几乎所有类型的操作数都被认为是有符号整数,因此可以使用符号位进行扩展。例如,使用参数 B9(16进制)执行 push 操作将导致以下数据单元存储在堆栈上:
0xff b9,0xffffffb9 或 0xff ff ff ff ff ff ff b9
默认情况下,push 后操作数大小为 8 字节。因此,指令 push -1 将在堆栈上存储0xff ff ff ff - 大多数支持堆栈的计算机架构都使用相同的原理,其栈顶由某个寄存器定义,但不同的是地址的含义。在某些计算机架构上,它是下一个元素的地址,将在下一次 push 操作时写入。在其他情况下,它是已经压入到堆栈中的最后一个元素的地址
使用英特尔文档——如何阅读指令说明:打开 Intel® 64 and IA-32 Architectures Software Developer Manuals 并下载 Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4,找到第二卷中 push 指令对应的页面,它从一张表格开始。我们可以先研究 Opcode、Instruction、64-Bit Mode 和 Description 部分。Opcode 定义指令(操作代码)的机器编码。当然,如你所见,每个Opcode对应于不同的 Description。这意味着有时不仅操作数会发生变化,而且操作的机器编码本身也会发生变化。
Instruction 描述指令的助记符和允许的操作数类型。这里 r 代表任何通用寄存器,m 代表存储器位置,imm 代表瞬时值(例如,整数常数,如42或1337),数字表示操作数大小。如果只允许使用特定的寄存器,则对它们进行命名。例如:
- PUSH r/m16:将通用 16 位寄存器或从存储器中获取的 16 位的数字压入到堆栈中
- PUSH CS:压入段寄存器 cs
Desecription 列简要说明了指令的效果。通常,理解和使用指令就足够了。
六、总结
在本章中,我们简要概述了冯诺依曼架构并了解其特点,然后现代处理器。最后,我们仔细研究了寄存器和硬件堆栈,之后就可以开始汇编编程了。
七、参考文献
What Every Programmer Should Know About Memory
Intel® 64 and IA-32 Architectures Software Developer Manuals