计算机系统的组成
计算机是一种能够按照程序约定,自动、高速、精确地完成各种信息存储、数据处理、数值计算、过程处理和数据传输的电子设备
一个完整的系统由硬件和软件组成。
计算机硬件的组成
中央处理器(CPU)、主板、内存(RAM)、存储设备(如硬盘或SSD)、电源、显卡、声卡、网卡以及外设如键盘、鼠标、显示器等。
- 中央处理器(CPU)
- 功能:作为计算机的“大脑”,负责执行指令和进行数据处理。CPU的速度和性能直接影响计算机的运行效率。
- 类型:常见的有Intel和AMD两大品牌,且分为不同架构(如x86、ARM)和核心数(单核、多核)。
- 主板
- 功能:连接和支持所有硬件组件的核心板卡,确保各部分协同工作。
- 组件:包括芯片组(北桥和南桥)、插槽(用于安装CPU、内存、扩展卡)、端口(USB、HDMI等)、电源接口等。
- 内存
- RAM
- 功能:临时存储正在运行的程序和数据,提升计算机的运行速度。
- 类型:常见的有DRAM和NAND闪存,按生成分类有DDR3、DDR4、DDR5等,速度和容量影响性能。
- ROM
- 功能:是一种非易失性存储器,即使在断电情况下也能保存数据。ROM 通常用于存储计算机启动时需要的固件、BIOS(基本输入输出系统)以及嵌入式系统中的程序。
- 类型:
- Mask ROM:在制造时写入数据,无法更改。
- PROM:可编程ROM,可一次性写入数据。
- EPROM:可擦除可编程ROM,可以使用紫外线擦除数据并重新编程。
- EEPROM:电可擦除可编程ROM,可以通过电信号擦除和重新编程。
- Flash ROM:一种特殊的EEPROM,擦除和写入速度更快,常用于固态硬盘和USB驱动器。
- RAM
- 存储设备
- 功能:保存操作系统、程序和数据,分为内部存储和外部存储。
- 类型:
- 硬盘驱动器(HDD):机械结构,存储数据依赖于磁头和磁盘,价格较低,速度较慢。
- 固态硬盘(SSD):基于闪存技术,速度快,无机械部件,抗震性强。
- 光盘驱动器(如CD、DVD、Blu-ray):用于读取或写入光盘数据,逐渐被SSD和云存储取代。
- NVMe SSD:采用PCIe接口,速度更快,适合高性能需求。
- 电源
- 功能:为计算机提供稳定的电力供应,确保所有组件正常运行。
- 类型:
- 普通电源:适用于台式机,功率足够支持CPU、GPU等组件。
- 交换式电源(PSU):可更换设计,灵活性高,适合定制或升级计算机。
- 笔记本电源适配器:为便携式设备供电,通常较小且功率较低。
- 显卡(GPU)
- 功能:负责处理图形和视频数据,提升显示效果和性能。
- 类型:
- 集成显卡:内置于主板或CPU,适合基本的图形处理,节能但性能有限。
- 独立显卡:单独的显卡,拥有更多处理单元和专用显存,适合游戏、视频编辑等高需求任务。
- 专业显卡:如NVIDIA的Quadro系列,专为专业图形工作设计,提供更高精度和稳定性。
- 声卡
- 功能:处理音频信号,负责声音的输入和输出。
- 类型:
- 集成声卡:内置于主板,提供基本的音频功能。
- 独立声卡:提供更好的音质和更多音频接口,适合音乐制作和高保真音频需求。
- 外部声卡:通过USB或其他接口连接,灵活性高,适合录音或直播使用。
- 网卡
- 功能:处理网络数据,连接计算机到局域网或互联网。
- 类型:
- 有线网卡:通过以太网缆连接,提供稳定的高速网络连接。
- 无线网卡:支持Wi-Fi连接,提供便携性和灵活的网络访问。
- 扩展网卡:如蓝牙适配器,支持更多无线设备的连接。
- 外设
- 功能:与用户交互或扩展计算机功能。
- 类型:
- 输入设备:键盘、鼠标、触控板、扫描仪等,用于输入指令或数据。
- 输出设备:显示器、打印机、扬声器等,用于输出计算机处理的结果。
- 存储设备:USB驱动器、移动硬盘、NAS(网络附加存储)等,扩展存储容量。
- 多媒体设备:摄像头、麦克风,用于视频会议和音频录制。
Transclude of 计算机硬件系统组成示意图
计算机软件的组成
系统软件
- 操作系统
- 语言处理系统
- 编译程序
- 解释程序
- 汇编程序
- 系统管理系统
- 监控程序
- 编辑程序
- 调试程序
- 装配程序
- 数据库管理系统
应用软件
- 文字处理程序、电子表格程序、演示程序
- 图形图像处理软件
- 辅助设计软件
- 实时控制软件
- 网络通信软件
- 媒体播放软件
信息
信息就是位+上下文
hello 程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创建并保存的文本文件,文件名是 hello.c 。源程序实际上就是一个由值 组成的位(又称为比特)序列, 个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。
大部分的现代计算机系统都使用 ASCII 标准来表示文本字符,这种方式实际上就是用一个唯一的单字节大小的整数值气来表示每个字符 比如,图 中给出了 hello.c 程序ASCII 码表示。

程序被其他程序翻译成不同的格式
hello 程序的生命周期是从一个高级 语言程序开始的,因为这种形式能够被人读懂。然而,为了在系统上运行 hello 程序,每条 语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。
- 预处理阶段。预处理器 (cpp) 根据以字符#开头的命令,修改原始的 程序。比如hello 中第 行的五nclude <stdio. h> 命令告诉预处理器读取系统头文件stdio.h 的内容,并把它直接插入程序文本中。结果就得到了另一个 程序,通常是以 .i 作为文件扩展名。
- 编译阶段。编译器 (eel) 将文本文件 hello.i 翻译成文本文件 hello.s, 它包含一个汇编语言程序。该程序包含函数 main 的定义,如下所示:

操作系统管理硬件
进程
像hello 这样的程序在现代系统上运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和 I/0 设备。处理器看上去就像在不间断地一条接一条地执行程序中的指令,即该程序的代码和数据是系统内存中唯一的对象。这些假象是通过进程的概念来实现的,进程是计算机科学中最重要和最成功的概念之一。
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多千可以运行它们的CPU 个数的。传统系统在一个时刻只能执行一个程序,而先进的多核处理器同时能够执 行多个程序。无论是在单核还是多核系统中,一个 CPU 看上去都像是在并发地执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。为了简化讨论,我们只考虑包含一个 CPU 的单处理器系统的情况。
shell 进程和 hello 进程。最开始,只有 shell 进程在运行,即等待命令行上的输入。当我们让它运行 hello 程序时, shell 通过调用一个专门 的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存 shell 进程的上下文,创建一个新的 he 进程及其上下文,然后将控制权传给新 hello 进程。 hello 进程终止后,操作系统恢复 shell 进程的上下文,并将控制权传回给它, shell 进程会继续等待下一个命令行输入。
从一个进程到另一个进程的转换是由操作系统内核 (kernel) 管理的。内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,比如读写文件,它就执行一条特殊的系统调用 (system call) 指令,将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。注意,内核不是一个独立的进程。相反,它是系统管理全部进程所用代码和数据结构的集合。

线程
尽管通常我们认为一个进程只有单一的控制流,但是在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法
虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。在 Linux 中,地址空间最上面的区域是保留给操作系统中的代码和数据的,这对所有进程来说都是 样。地址空间的底部区域存放用户进程定义的代码和数据。请注意,图中的地址是从下往上增大的。

- 程序代码和数据。对所有的进程来说,代码是从同 固定地址开始,紧接着的是和全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的,在示例中就是可执行文件 hello 。在第 章我们研究链接和加载时,你会学习更多有关地址空间的内容。
- 堆。代码和数据区后紧随着的是运行时堆。代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像 malloc free 这样的 标准库函数时,堆可以在运行时动态地扩展和收缩。在第 章学习管理虚拟内存时,我们将更详细地研究堆。
- 共享库。大约在地址空间的中间部分是一块用来存放像 标准库和数学库这样的共享库的代码和数据的区域。共享库的概念非常强大,也相当难懂。
- 栈。位千用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩 特别地,每次我们调用 个函数时,栈就会增长;从一个函数返回时,栈就会收缩
- 内核虚拟内存。地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数 相反,它们必须调用内核来执行这些操作。
文件
文件就是字节序列,仅此而已。每个 I/0 设备,包括磁盘、键盘、显示器,甚至网络,都可以看成是文件 系统中的所有输入输出都是通过使用一小组称为 Unix I/0 的系统函数调用读写文件来实现的 文件这个简单而精致的概念是非常强大的,因为它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的 I/0 设备。同一个程序可以在使用不同磁盘技术的不同系统上运行。
信息储存
大多数计算机使用 位的块,或者宇节 (byte), 作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存(virtual memory) 内存的每个字节都由 个唯一的数字来标识,称为它的地址(adress), 所有可能地址的 合就称为虚拟地址空间 (vi tu address space) 顾名思义,这个虚拟地址空间只是一个 机器级程序的概念性映像 实际的实现(见第 章)是将动态随机访问存储器 DRAM 、闪存、磁盘存储器、特 硬件和 作系统软件结合起来,为序提 个看上去统一的字节数组
十六进制表示法
一个字节由 位组成。在二进制表示法中,它的值域是 ~ 。 两种符号表示法对于描述位模式来说都不是非常方便。二进制表示法太冗长,而十进制表示法与位模式的互相转化很麻烦。替代的方法是,16 为基数,或者叫做 六进制 (hexadecimal) 数,来表示位模式。十六进制(简写为 “hex”)使用数字 ‘O''9’ 以及字符 ‘A''F’ 来表示 16 个可能的值。图 2-2 展示了 16 个十六进制数字对应的十进制值和二进制值。用十六进制书写,一个字节的值域为 ~
字数据大小
每台计算机都有一个字长 (word size), 指明指针数据的标称大小 (nominal size) 因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为 位的机器而言,虚拟地址的范围为 0~ ,程序最多访问个字节。
寻址和字节顺序
对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。例如,假设 个类型为 int 的变量x的地址为0x100,也就是说,地址表达式 &x 的值为 0x100 。那么,(假设数据类型 让为 32 位表示) 节将被存储在内存的 0x100、 0x101、0x102、0x103 位置。
假设变量x的类型为int,位于地址 0x10 处,它的十六进制值为 0x0,址范围 0x100~ 0x103 的字节顺序依赖千机器的类型:

运算
逻辑运算
C语言还提供一组逻辑运算符||、&&和!,分别对应千命题逻辑中的 OR、AND、NOT 运算。逻辑运算很容易和位级运算相混淆,但是它们的功能是完全不同的。逻辑运算认为所有非零的参数都表示 TRUE, 而参数 表示 FALSE 。它们返回1或者0, 分别表示结果为 TRUE 或者为 FALSE 。以下是一些表达式求值的示例。
逻辑运算符&&和 II 与它们对应的位级运算&和 之间第二个重要的区别是,如果对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。
例如,表达式 a&&S/a 将不会造成被零除,而表达式p&&*p++ 不会导致间接引用空指针。
逻辑左移/算数左移
在 C 语言中,逻辑左移和算术左移的底层操作是完全相同的。它们都会将操作数向左移动 位,丢弃最高的 位,并在右侧(低位)补入 个 。
正如你在移位运算中写的,其结果的位表示始终为:
逻辑右移
逻辑右移会将操作数向右移动 位,丢弃最低的 位,并在左侧(高位)统统补入 个 。在 C 语言中,对**无符号数(unsigned)**进行右移通常会执行逻辑右移。
表达式生成的值,其位表示为:
算数右移
算术右移同样将操作数向右移动 位,丢弃最低的 位。但为了保持数值的符号不变,它会在左侧(高位)补入 个最高有效位(即原来的符号位 )。在 C 语言中,对**有符号数(signed)**进行右移通常会执行算术右移。
表达式生成的值,其位表示为:
整数表示
在本节 ,我们描述用位来编码整数的两种不同的方式 :一 种只能表示非负数,而另一种能够表示负数 零和正数。后面我们将会看到它们在数学属性和机器级实现方面密切相关。我们还会研究扩展或者收缩 编码整数以适应不同长度表示的效果。

整数数据类型
C语言支持多种整数数据类型——表示有限范围的整数。每种类型都能用关键字来指定大小,这些关键字包括 char short long, 同时还可以指示被表示的数字是非负数(声明为 unsigned), 或者可能是负数(默认)。如图 2-3 所示,为这些不同的大小分配的字节数根据程序编译为 32 位还是 64 位而有所不同。根据字节分配,不同的大小所能表示的值的范围是不同的 这里给出来的唯一一个与机器相关的取值范围是大小指示符 long的。大多数 64 位机器使用 个字节的表示,比 32 位机器上使用的 个字节的表示的取值范围大很多。


C语言中的有符号数与无符号数
C语言支持所有类型的有符号和无符号运算。尽管C语言标准没有指定由符号数要采用某种表示,但是几乎所有的机器都使用补码。通常,大多数数字都默认为是有符号的。要创建一个无符号常量,必须加上后缀字符’U’或者’u’
C语言允许无符号数和有符号数之间的转换转换。虽然C标准没有精确规定如何进行这种转换,但是大多数系统遵循的原则是底层的位表示保持不变。因此,在一台采用补码的机器上,当从无符号数转换为有符号数时,效果就是应用函数,其中,其中表示数据类型的位数
显式的强制类型转换就会导致转换发生,就像下面的代码:
另外,当一种类型的表达式被赋值给另外一种类型的变量时,转换是隐式发生的,就像下面的代码
当用printf输出数值时,分别用指示符%d、%u、%x以有符号十进制、无符号十进制和十六进制格式输出一个数字。
程序的机器级表示
历史观点
Interl处理器系列俗称x86,经历了一个长期的、不断发展过程。开始时,它是第一代单芯片、16位微处理器之一。由于当时集成电路技术水平十分有限,其中做了很多妥协。
以下列举了一些Intel处理器模型
8086它是第一代单芯片、 16 位微处理器之一。 8088 8086的一个变种,在 8086 上增加了一个 位外部总线,构成最初的 IBM 个人计算机的心脏。IBM 与当时还不强大的微软签订合同,开发 MS-DOS 操作系统。最初的机器型号有 32 768节的内存和两个软驱(没有硬盘驱动器)。从体系结构上来说,这些机器只有 655 360 字节的地址空间——地址只有 20 位长(可寻址范围为 1048576 字节),而操作系统保留了 393 216节自用。 1980 年, Intel 提出了 8087 浮点协处理器 (45K 个晶体管),它与一个 8086 8088处理器一同运行,执行浮点指令。 8087 建立了 x86 系列的浮点模型,通常被称为 “x87”
80286(1982 年, 134K 个品体管)。增加了更多的寻址模式(现在巳经废弃了),构成IBM PC-AT 个人计算机的基础,这种计算机是 MS Windows 最初的使用平台。
i386(1985 年, 275K 个晶体管)。将体系结构扩展到 32 位。增加了平坦寻址模式 (flat addressing model) , Linux 和最近版本的 Windows 操作系统都是使用的这种模式。这是Intel 系列中第一台全面支持 Unix 操作系统的机器。
i486(1989 年, 1. 2M 个晶体管)。改善了性能,同时将浮点单元集成到了处理器芯片上,但是指令集没有明显的改变。
Pentium(l993 年, 3. lM 个晶体管)。改善了性能,不过只对指令集进行了小的扩展。
PentiumPro(l995 年, 5. 5M 个晶体管)。引入全新的处理器设计,在内部被称为 P6 微体系结构。指令集中增加了一类”条件传送 (conditional move)” 指令。
Pentium/MMX(1997 年, 4. 5M 个晶体管)。在 Pentium 处理器中增加了一类新的处理整数向量的指令。每个数据大小可以是 字节。每个向量总长 64 位。
Pentium II(1997 年, 7M 个晶体管)。 P6 微体系结构的延伸。
Pentium III(1999 年, 8. 2M 个晶体管)。引入了 SSE, 这是一类处理整数或浮点数向最的指令。每个数据可以是 个字节,打包成 128 位的向量。由千芯片上包括了二级高速缓存,这种芯片后来的版本最多使用了 24M 个品体管。
Pentium 4(2000 年, 42M 个晶体管)。 SSE 扩展到了 SSE2, 增加了新的数据类型(包括双精度浮点数),以及针对这些格式的 144 条新指令。有了这些扩展,编译器可以使用SSE 指令(而不是 x87 指令),来编译浮点代码。
Pentium 4E(2004 年, 125M 个品体管)。增加了超线程 (hyperthreading) , 这种技术可以在一个处理器上同时运行两个程序;还增加了 EM64T, 它是 Intel AMD 提出的对IA32 64 位扩展的实现,我们称之为 x86-64
Core 2(2006 年, 291M 个晶体管)。回归到类似于 P6 的微体系结构。 Intel 的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。
Core i7, Nehalem(2008 年, 781M 个晶体管)。既支持超线程,也有多核,最初的版本支持每个核上执行两个程序,每个芯片上最多四个核。
Core i7, Sandy Bridge(2011 年, 1. 17G 个晶体管)。引入了 AVX, 这是对 SSE 的扩展,支持把数据封装进 256 位的向量。
Core i7, Haswell(2013 年, 1. 4G 个晶体管)。将 AVX 扩展至 AVX2, 增加了更多的指令和指令格式。
每个后继处理器的设计都是后向兼容的一较早版本上编译的代码可以在较新的处理器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东西。 Intel 处理器系列有好几个名字,包括 IA32, 也就是 “Intel 32 位体系结构< Intel Architecture 32-bit) ”, 以及最新的 Intel64, IA32 64 位扩展,我们也称为 x86-64。最常用的名字是 “x86”, 我们用它指代整个系列,也反映了直到凶86 处理器命名的惯例。
程序编码
命令 gcc 指的就是 GCC 编译器。因为这是 Linux 上默认的编译器,我们也可以简单地用 cc 来启动它。编译选项 -Og 告诉编译器使用会生成符合原始 代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。因此我们会使用 -Og 优化作为学习工具,然后当我们增加优化级别时,再看会发生什么 实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项 -01 -02 指定)被认为是较好的选择
实际上 gcc 命令调用了一整套的程序,将源代码转化成可执行代码。首先, C预处理器扩展源代码,插入所有用巨nelude 命令指定的文件,并扩展所有用 #define 声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为 pl. p2.s 。接下来,汇编器会将汇编代码转化成二进制目标代码文件 pl.a p2.o 目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后,链接器将两个目标代码文件与实现库函数(例如 printf) 的代码合并,并产生最终的可执行代码文件(由命令行指示符 -o p 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。我们会在第 章更详细地介绍这些不同形式的机器代码之间的关系以及链接的过程。
机器级代码
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。
第一种是由指令集体系结构或指令集架构来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA, 包括 x86-64, 将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与 ISA 指定的顺序执行的行为完全一致。
第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
在整个编译过程中,编译器会完成大部分的工作,将把用 语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令 汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编代码以及它与原始 代码的联系,是理解计算机如何执行程序的关键一步。
虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。 语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用 malloc 库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如, x86-64 的虚拟地址是由 64 位的字来表示的。在目前的实现中,这些地址的高 16 位必须设置为 0, 所以一个地址实际上能够指定的是 或 64TB 范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
有一类称为反汇编器 (disassem bier) 的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。在 Linux 系统中,带’-d’命令行标志的程序 OBJDUMP( 表示 “object dump”) 可以充当这个角色:
objdump -d mstore.o
其中一些关千机器代码和它的反汇编表示的特性值得注意:
- x86-64 的指令长度从 15 个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
- 设计指令格式的方式是,从某个给定位置开始 ,可以将字节唯一地解码成机器指令。例如,只有指令 pushq %rbx 以字节值 53 开头的。设计指令格式的方式是,从某个给定位置开始 ,可以将字节唯一 地解码成机器指令。
- 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
- 反汇编器使用的指令命名规则与 GCC 生成的汇编代码使用的有些细微的差别。
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个 main 函数。假设在文件 main.c 中有下面这样的函数:

然后,我们用如下方法生成可执行文件 prog:
gee -Og -o prog main.e mstore.e
文件 prog 变成了 8655 个字节,因为它不仅包含了两个过程的代码,还包含了用来启动和终止程序的代码,以及用来与操作系统交互的代码。我们也可以反汇编 prog 文件:
objdump -d prog
反汇编器会抽取出各种代码序列,包括下面这段:

关于格式的注解
GCC 产生的汇编代码对我们来说有点儿难读。一方面,它包含一些我们不需要关心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。

所有以.,开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分伪指令,但包括行号和解释性说明。对于我们的示例,带解释的汇编代码如下:
通常我们只会给出与讨论内容相关的代码行。每一行的左边都有编号供引用,右边是注释,简单地描述指令的效果以及它与原始 语言代码中的计算操作的关系。这是一种汇编语言程序员写代码的风格。
我们还提供网络旁注,为专门的机器语言爱好者提供一些资料。一个网络旁注描述的 IA32 机器代码。有了 x86-64 的背景,学习 IA32 会相当简单。另外一个网络旁注简要描述了在 语言中插入汇编代码的方法。对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性。一种方法是用汇编代码编写整个函数,在链接阶段把它们和C函数组合起来。另一种方法是利用 GCC 的支持,直接在C程序中嵌入汇编代码。
Intel和ATT 格式在如下方面有所不同: Intel 代码省略了指示大小的后缀。我们看到指令 push mov, 而不是 pushq movq Intel 代码省略了寄存器名宇前面的”%“符号,用的是 rbx, 而不是 %rbx Intel 代码用不同的方式来描述内存中的位置,例如是 ‘
QWORD PTR [ rbx]’ 而不是 , ’(%rbx)’ 在带有多个操作数的指令情况下,列出操作数的顺序相反 当在两种格式之间进行转换的时候,这一点非常令人困惑
数据格式
由于是从 16 位体系结构扩展成 32 位的, Intel 用术语”字 (word)” 表示 16 位数据类型。因此,称 32 位数为“双字 (double words)”, 64 位数为“四字 (quad words)”。

访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。它们的名字都以%r开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的8086中有8个16位的寄存器,即%ax到%bp。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到IA32架构时,这些寄存器也扩展成32位寄存器,标号从%eax到%ebp。扩展到x86-64后,原来的8个寄存器扩展成64位,标号从rax到rbp。除此之外,还增加了8个新的寄存器,它们的标号是按照新的命名规则制定的:从r8到r15。
指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,而64位操作可以访问整个寄存器。
就像图3-2右边的解释说明的那样,在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针%rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外15个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。
操作数指示符
大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64 支持多种操作数格式。源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。
第一种类型是立即数,用来表示常数值。在 Intel 格式的汇编代码中,立即数的书写方式是直接写出纯数值(不需要任何前缀符号),通常使用标准 C 表示法或带有进制后缀的整数,比如,-577 或 0x1F(或 1Fh)。 不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
第二种类型是寄存器,它表示某个寄存器的内容,16 个寄存器的低位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数,这些字节数分别对应于 8 位、16 位、32 位或 64 位。(在 Intel 格式中,寄存器直接使用名称表示,例如 rax、ebx,无需任何前缀。) 我们用符号 来表示任意寄存器 a,用引用 来表示它的值,这是将寄存器集合看成一个数组 R,用寄存器标识符作为索引。
第三种类型是内存引用,它会根据计算出来的地址访问某个内存地址。因为将内存看成一个很大的字节数组,我们用表示对存储在内存中从地址Addr开始的b个字节值的引用
操作数有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法Imm(rb,ri,s)表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器rb,个变址寄存器ri和一个比例因子s,这里s必须是1、2、4或者8。基址和变址寄存器都必须是64位寄存器。有效地址被计算为Imm十Rr6]十R[r]·s。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。

数据传送指令
最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。在我们的讲述中,把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
图 3-4 列出的是最简单形式的数据传送指令——MOV 类。这些指令把数据从源位置复制到目的位置,不做任何变化。在 Intel 格式中,MOV 类不再使用四个不同的指令名(movb、movw、movl 和 movq),而是统一只使用一条指令:mov。 它执行同样的操作;主要区别在于它们操作的数据大小不同(1、2、4 和 8 字节),而这个大小是由跟在它后面的寄存器位宽,或者内存操作数前的 PTR 关键字来自动决定的。
图 3-4 简单的 MOV 数据传送指令 (Intel 格式)
⚠️ 核心注意: 原图表格中的操作数顺序是 S, D (源,目的)。在 Intel 格式中,顺序必须反转为 D, S (目的,源)。
| 指令 (Intel 格式) | 效果 | 描述 |
|---|---|---|
mov D, S | 传送 (将源操作数 S 复制到目的操作数 D) | |
mov (配合 8 位寄存器或 BYTE PTR) | 传送字节 (相当于原 movb) | |
mov (配合 16 位寄存器或 WORD PTR) | 传送字 (相当于原 movw) | |
mov (配合 32 位寄存器或 DWORD PTR) | 传送双字 (相当于原 movl) | |
mov (配合 64 位寄存器或 QWORD PTR) | 传送四字 (相当于原 movq) | |
mov R, I | 传送绝对的四字立即数 (相当于原 movabsq,在 Intel 中只要目标 R 是 64 位寄存器,直接用 mov 即可) |
汇编操作数格式与 MOV 指令核心规则 (Intel 语法版)
1. 操作数的三种基本类型
在 x86-64 架构下,mov Destination, Source 指令的源操作数和目的操作数通常分为三种类型:
- 立即数 (Immediate): 纯常数值,可直接存入寄存器或内存(例如
0x1F,100)。 - 寄存器 (Register): 16 个通用寄存器的内容(例如
rax,ebx,al)。 - 内存地址 (Memory): 内存中存储的数据,用
[]表示(例如[rax],[0x400000])。
2. 核心硬性限制:禁止内存到内存的直接传送
x86-64 架构在硬件层面上规定了一条死命令:传送指令的两个操作数,绝对不能同时指向内存位置。
想要将数据从内存地址 A 复制到内存地址 B,必须借助寄存器作为“中转站”,分为两条指令完成:
; ❌ 错误的写法 (CPU 会直接报错,非法指令)
mov [rax], [rbx]
; ✅ 正确的写法 (借助 rcx 作为中转站)
mov rcx, [rbx] ; 1. 将源内存 [rbx] 的值加载到寄存器 rcx 中
mov [rax], rcx ; 2. 将寄存器 rcx 的值写入目标内存 [rax] 中