← 返回知识卡片总目录

DS1号 · 自学教材系列 | 计算机软硬件

第 11 章 指令与机器语言

程序说到底,就是一条条指令排成的队列。这一章看指令长什么样、CPU 唯一能听懂的「机器语言」是什么,以及人类如何用「汇编语言」跟它对话——你将会看到,从「加法器」到「软件」之间的最后一座桥,就是指令。

🎯 本章学习目标

  1. 理解一条指令的结构:操作码 + 操作数。
  2. 理解机器语言与汇编语言的区别与关系。
  3. 认识几种基本寻址方式。
  4. 了解 RISC 与 CISC 两种指令集设计哲学。

11.1指令 = 操作码 + 操作数

一条 指令(instruction)是 CPU 能执行的最小单位,它告诉 CPU「做什么、对谁做」。所以任何一条指令都可以拆成两部分:

指令的组成 一条指令由操作码和操作数组成。 操作码 opcode(做什么) 操作数 operand(对谁做) 一条指令 = 操作码 + 操作数
图 11-1一条指令 = 操作码(做什么)+ 操作数(对谁做)。

🧠 一句话记忆 指令 = 动词(操作码)+ 宾语(操作数)。

指令格式的字段化:为什么指令「长得像表格」

一条指令之所以能被 CPU 直接看懂,是因为设计者事先规定了指令格式(instruction format):这一串二进制中,前若干位固定当操作码、后面每若干位固定当寄存器编号或其他信息。译码器因此不需要「理解」语言——它只需按固定位置切开比特串,再把操作码送进一张预先用逻辑门做好的「操作码表」查出对应操作:操作码 0001 一到,ADD 的控制线就被点亮(第 4 章译码器的思想)。可以说,指令格式的本质,是让「理解语言」退化为「按位置切割 + 查表」——这恰好是数字电路最擅长、做得最快的事。这也是为什么指令集设计者宁可把格式规定得死板,也不让指令「自由发挥」:格式越死板,硬件越简单、越快。

操作数的个数本身也是一种设计决策,教科书里常见的四类格式各有取舍:三地址指令(如 ADD R1, R2, R3,意为 R1←R2+R3)语义最清晰、不破坏源操作数,但三个寄存器号让指令变长;二地址指令(如 ADD R2, R3,R2←R2+R3)把目标与第一个源合并,指令短了四分之一,代价是运算结果会覆盖其中一个源——于是真实程序里常先复制再运算;一地址指令默认使用一个固定充当「舞台中心」的累加器(accumulator,常记 ACC),指令只写一个操作数,另一个「就是 ACC」;零地址指令连操作数都不写,操作数自动从「栈顶」弹出、结果自动压回(栈机器)。本章示例采用二地址格式,因为它是当今主流指令集在寄存器运算上的常见形态;11.6 节的例题里你还会看到三地址写法——再次印证「操作数个数是设计决策,不是物理定律」。

还有一个初学者容易忽略的推论:指令在内存里与数据长得一模一样。内存只管存 0 和 1,并不知道某格放的是「命令」还是「数字」。同一串比特 0001 0010 0011,在取指阶段被送往译码器时,它是指令「ADD R2, R3」;而如果某条读数指令恰好把它从内存取回来当整数用,它就是数值 291(十六进制 0x123)。指令与数据的区别不在比特本身,而在 CPU 取出它那一刻的用途。这是「存储程序」思想的自然推论,也是「数据被误当指令执行」这类软件安全漏洞在硬件层面的根源。

同一串比特的两种身份 同一串二进制 0001 0010 0011,在取指阶段被当作指令 ADD R2 R3 解读,在执行阶段被当作数据时就是整数 291。 0001 0010 0011 取指阶段被取出 → 它是指令 ADD R2, R3 操作码 0001|源 R2(0010)|源 R3(0011) 被读数指令取出 → 它是数据 整数 291(0x123) 同一段比特,只是个普通数字 区分指令与数据的,不是比特本身,而是 CPU 取出它那一刻的用途。
图 11-2同一串比特的两种身份:取指时被解读为指令 ADD R2, R3;被读数指令访问时就是一个整数 291。

📐 例题 1 手工编码与解码一条指令

设某简化 CPU 的指令格式固定为 12 位:高 4 位是操作码,中 4 位是操作数 A(寄存器编号),低 4 位是操作数 B。操作码约定(与本章正文一致并顺延扩充):0001=ADD(A←A+B)、0010=LOAD、0011=JUMP、0100=SUB(A←A−B)、0101=STORE、0110=JNZ。请:(1) 把「SUB R3, R5」编码成二进制,再写成十六进制;(2) 解码指令 0001 0100 0110;(3) 这个 12 位格式最多支持多少种操作码、多少个寄存器?

思路 编码就是「查操作码表 + 逐字段填比特」;解码则反过来:按固定位置切开、逐段查表。第 (3) 问考的是字段位数与信息量的关系——n 位二进制能区分 2n 种情况。

解 (1) SUB 的操作码是 0100;R3 的编号 3 写成 4 位二进制是 0011;R5 的编号 5 是 0101。按「操作码|A|B」拼接得 0100 0011 0101;每 4 位转一位十六进制:0100=4、0011=3、0101=5,得 0x435。(2) 切开 0001|0100|0110:操作码 0001 是 ADD;A 字段 0100 是 4 号寄存器;B 字段 0110 是 6 号寄存器,即「ADD R4, R6」,含义 R4←R4+R6。(3) 操作码字段 4 位,能表示 24 = 16 种操作码;寄存器编号字段 4 位,能编号 24 = 16 个寄存器(0~15 号)。

答:(1) 0100 0011 0101 = 0x435;(2) ADD R4, R6(R4←R4+R6);(3) 16 种操作码、16 个寄存器。 ⚠ 易错点:① 拼接顺序是「A 字段在前、B 字段在后」,SUB R3, R5 里先写 0011(R3)再写 0101(R5);② 「n 位能编码 2n 种」常被误记成 n 种或 n2 种;③ 有趣的是,(2) 中解码出的字段值 0100、0110 恰好拼成了 (1) 里 SUB 的操作码——字段值与操作码长相相同纯属巧合,位置决定身份:在操作码位置上就是命令,在操作数位置上就是编号。

工程视角:真实 CPU 的指令格式远比 12 位复杂——x86 的指令长度从 1 字节到 15 字节不等,中间还可能夹带前缀字节和 ModRM 编码字节;ARM 的 32 位指令里条件码、目标寄存器、源寄存器、移位量各有固定「坑位」。但「字段拼接 + 查表译码」这个骨架完全一致。机载计算机、发动机控制单元里的嵌入式处理器同样各自遵循一套字段规定,写固件的工程师几乎天天在对着芯片手册做本例题这种「查编码」的事。

辨析:把「指令字长」与「数据字长」分开记。指令字长是一条指令占的位数(本章示例 12 位);数据字长是寄存器/数据一次能装多少位(示例里可另行约定,比如 6 位或 8 位)。两者互不绑定:6 位寄存器完全配得上 12 位指令——指令只负责说「对哪两个寄存器做什么」,至于寄存器本身多宽,是 ISA 的另一条约定。记住这一条,11.3 节例题里「结果装不装得下、会不会溢出」的讨论才有落点。

11.2机器语言:CPU 唯一能懂的「语言」

CPU 是数字电路,它只认识 0 和 1。所以真正的指令,在 CPU 眼里就是一串二进制数——这种「用二进制写的指令」叫 机器语言(machine language)。

每一家 CPU 都规定了自己的一套「机器语言」规则,叫 指令集架构(ISA,Instruction Set Architecture)。它规定:操作码占几位、操作数占几位、每个操作码代表什么运算。不同 CPU 的指令集不同,所以为一种 CPU 写的机器语言,往往不能在另一种 CPU 上跑——这就是「软件要按 CPU 平台编译」的根源。

核心思想 机器语言 = 二进制指令 = CPU 唯一能直接执行的。其他一切语言(汇编、C、Python……)最终都要翻译成机器语言,CPU 才能跑。这是理解整个「软件世界」的钥匙。

📖 名词溯源:opcode 的 code code 在英语里本义是「代码、密码」,也指「规则系统」。机器语言里,一串 0/1 就像是只有 CPU 才懂的「密码」。你写的程序,本质就是让 CPU 按这套密码去「解题」。

ISA:一份写死的「硬件-软件契约」

指令集架构为什么值得专门起名?因为它是硬件设计师与软件设计师之间的正式契约:芯片一侧承诺「0001 就是加法、2 号寄存器就在运算通路的那一端」,软件一侧(汇编器、编译器、操作系统)则严格按这份契约生成指令。契约一旦发布就不能随意更改——今天新出厂的 CPU 必须还能运行十几年前的老程序,这叫向后兼容(backward compatibility)。x86 自 1978 年的 8086 一路兼容至今,是软件史上最长寿的契约之一;这份「不能改」的固执并非技术保守,而是整个软件生态的价值所系:兼容性一断,用户手里的旧软件一夜作废,谁还敢买你的新芯片?

由此也能真正理解「跨平台」三个字的分量:同一份源码在 ARM 手机与 x86 电脑上必须分别编译成不同的机器码,因为两份契约的「措辞」完全不同——同一个加法,x86 用的操作码字节序列与 ARM 用的操作码字段毫无关系。所谓「软件按平台发布」(Windows 版、macOS 版、安卓版各一个安装包),底层就是在按 ISA 分别交货。反过来说,如果两家 CPU 采用同一套 ISA(例如都兼容 ARM 的某版本),同一份机器码就能直接互换运行——「指令集兼容」是比「同一种编程语言」更底层的兼容。

最后补一个人人要会的实用约定:机器码本质是二进制,但人类实际读写时用十六进制。因为 4 位二进制恰好对应 1 位十六进制,12 位指令就是 3 位十六进制,抄写短、不易错、对齐整齐。0001 0010 0011 写作 0x123(0x 前缀只是「这是十六进制」的记号)。上一节图 11-2 里「指令长得像数据」的现象,在十六进制视角下同样一目了然。

📐 例题 2 十六进制机器码的读写与程序容量估算

沿用例题 1 的 12 位指令格式。(1) 把指令 0001 0010 0011(即 ADD R2, R3)写成十六进制;(2) 把十六进制机器码 0x2A7 展开成二进制并按格式解码;(3) 一段程序共 32 条这种 12 位指令,内存按字节(8 位)编址、指令连续紧凑存放,程序占多少字节?若程序有 33 条指令呢?

思路 二进制↔十六进制按「4 位对 1 位」分组直译,不需要任何算术;容量问题先算总位数再除以 8,除不尽时向上取整——因为哪怕只多 1 位,也要多占一个字节。

解 (1) 分组 0001|0010|0011 → 1、2、3 → 0x123。(2) 0x2A7 逐位展开:2=0010、A=1010、7=0111,得 0010 1010 0111;切开解码:操作码 0010=LOAD,A 字段 1010=10 号寄存器,B 字段 0111=7,即「LOAD R10, 7」——从内存 7 号单元读数装入 R10。(3) 32 条 × 12 位 = 384 位;384 ÷ 8 = 48,整除,占 48 字节。33 条 × 12 位 = 396 位;396 ÷ 8 = 49.5,向上取整占 50 字节——最后一条指令被拆在两个字节里,跨字节存放。

答:(1) 0x123;(2) 0010 1010 0111 = LOAD R10, 7(R10←内存[7]);(3) 32 条占 48 字节,33 条占 50 字节。 ⚠ 易错点:① 十六进制的 A=10、B=11、C=12、D=13、E=14、F=15,展开时最容易在 A~F 上出错;② 396÷8=49.5 时若「四舍五入」成 49 就错了——存储只能整个字节整个字节地占,必须向上取整;③ 真实 x86 指令长度可变(1~15 字节),容量估算要按实际指令长度逐条累加,本章的「等长指令」只是简化模型。

工程视角:嵌入式固件升级包、程序崩溃后的内存转储(core dump)本质上都是十六进制字节流,工程师用「反汇编器」把它们逐条还原成汇编指令来排查问题;逆向分析工具的界面,就是一张「十六进制机器码 ↔ 汇编」的实时对照表。另外,为嵌入式设备估算固件体积是否放得进芯片内的存储空间(flash 余量),用的正是本题第 (3) 问的算法——字节数超了,就得裁功能或换更大存储的芯片。

辨析(衔接第 2 章):bit、byte、word 三级单位在指令语境里各有分工——bit 是晶体管层面的 0/1(第 2 章);byte 是内存编址的基本单位(本题 48 字节的由来);word 是「一次运算的标准宽度」,由 ISA 约定(32 位机一个字 4 字节)。看到「64 位 CPU」应当能翻译成:它的通用寄存器和整数运算通路是 64 bit 宽——但内存地址仍然按 byte 逐个编号,「64 位」与「按字节编址」并不矛盾。

11.3一条具体指令:ADD 是怎么跑的

用一个具体的例子,把前面所有章节串起来。假设某 CPU 规定:

一个简化指令集(示例)
操作码(二进制)含义
0001ADD(把两个寄存器的值相加)
0010LOAD(从内存读入寄存器)
0011JUMP(跳转)

那么一条指令 0001 0010 0011 的意思是:

CPU 执行它的过程,正是第 8 章的四步:取指(把这条指令取出)→ 译码(控制器读出「哦,是 ADD」)→ 执行(ALU 把寄存器 2 和 3 的值加起来,这正是第 5 章的全加器在干活)→ 写回(结果存回寄存器)。

🧠 一句话记忆 一串 0/1 → 译码成「加」→ 加法器真算 → 结果写回。软件与硬件在此合体。

逐拍追踪:把四步拆到硬件信号一级

第 8 章说过「取指→译码→执行→写回」,现在把它落到具体部件上,看看每一步哪些线路上有数据在跑:

指令执行的数据通路 PC 提供地址给内存,内存把指令送入 IR,操作码进译码器点亮控制线,寄存器堆把操作数送入 ALU,ALU 结果写回寄存器堆。 PC 程序计数器 存「下一条指令」的地址 内存 按地址存放指令与数据 IR 指令寄存器 暂存正在执行的指令 译码器 + 控制单元 点亮 ADD 控制线 寄存器堆 R0、R1、R2、R3…… ALU 运算器 15 + 27 = 42 ① 给地址 ② 送指令 操作码 ③ 控制信号(开门/关门) ④ 送操作数 ⑤ 写回结果 42 每一步都发生在时钟节拍里;所有寄存器的写入都在时钟边沿完成。
图 11-3取指→译码→执行→写回的数据通路:PC 给地址、内存回指令、译码器点控制线、ALU 算结果、写回目标寄存器。

有两点值得反复咀嚼。第一,PC 自动加 1 是「顺序执行」的全部秘密:程序默认一条接一条排队跑,跳转指令只是例外——它做的唯一一件事,就是把一个新值强行塞进 PC。于是循环、if 分支、函数调用这些高级结构,在机器层面统统被还原成「改 PC」。第二,所有寄存器的写入都发生在时钟边沿(第 6 章触发器的翻转条件),这保证各部件步调一致,不会出现「旧值还没传走、新值就冲进来」的混叠——指令周期之所以可靠,靠的是节拍纪律。

📐 例题 3 寄存器逐拍追踪:执行 ADD R2, R3

设该 CPU 寄存器宽度为 6 位。初始状态:R2 = 15(二进制 001111),R3 = 27(011011),PC = 0,IR 为空;内存 0 号单元存放指令 ADD R2, R3(编码 0001 0010 0011),其余单元与本题无关。请逐拍(拍 1 取指、拍 2 译码、拍 3 执行、拍 4 写回)写出 PC、IR、R2、R3 与 ALU 输出的变化,并判断结果是否超出 6 位寄存器的表示范围。

思路 按四拍顺序推进,每一拍只回答两个问题:这一拍谁在传数据、谁被写入。凡发生写入的部件记新值,没被碰到的寄存器保持原值。

解 拍 1(取指):MAR←PC=0,内存 0 号单元的内容 0001 0010 0011 送入 IR;同一拍 PC 自动加 1 变为 1。拍 2(译码):IR 高 4 位 0001 查表得 ADD;A 字段 0010 指向 R2、B 字段 0011 指向 R3;控制器打开 R2、R3 通向 ALU 的门。拍 3(执行):ALU 逐位相加 001111 + 011011:十进制即 15 + 27 = 42,得二进制 101010。拍 4(写回):101010 写入 R2;R3 未被指定为目标,保持 27。终态:R2 = 42(101010),R3 = 27,PC = 1。6 位无符号数的上限是 26−1 = 63,42 ≤ 63,未溢出。

答:R2 由 15 变为 42(二进制 101010),R3 保持 27,PC 由 0 变为 1;结果未超出 6 位表示范围(上限 63),无溢出。 ⚠ 易错点:① 「PC 在取指阶段就加 1」常被误以为发生在指令执行完之后;② 二地址指令的写回目标就是第一个操作数——加法覆盖了 R2 的旧值 15,执行后旧值永久消失;③ 若寄存器只有 5 位(上限 31),42 就装不下,高位被丢弃后得到错误结果——溢出不是「报错」,而是静悄悄算错,这正是 CPU 需要标志位的原因。

工程视角:单步调试器(debugger 的「单步执行」按钮)能做到「一步步看程序」,靠的正是硬件本就按拍执行——调试器只是让 CPU 每执行一条指令就暂停一次,把 PC、IR、各寄存器的现场快照展示给你。读懂本例题的推进过程,你就已经改用 CPU 的视角在看程序了;反过来,机载设备出现的「偶发跑飞」类故障,工程上常用的排查手段也是对着逻辑分析仪抓取的逐拍波形,还原指令执行序列。

辨析:标志位,ALU 旁边的四个小灯。真实的 ALU 输出端旁边还有几个 1 位寄存器,记录本次运算的「副产物」:

常见标志位(FLAGS)
标志位名称何时点亮谁用得最多
Z零标志运算结果恰好为 0循环退出判断(「减到 0 了吗」)
N负标志结果的最高位为 1(视为负数)大小比较
C进位标志最高位向上产生进位多字长加减法拼接
V溢出标志带符号运算超出表示范围溢出检测

本例 42 < 63,四个灯全灭。但一旦出现「结果为 0」或「结果为负」,相应标志被置起,后续的条件跳转指令(如 JNZ「非零则跳」)就会检查它们——11.4 节例题 4 的循环,正要靠这盏零标志灯收工。

11.4汇编语言:机器语言的「助记符」

二进制指令对人类太不友好——0001 0010 0011 谁知道是啥?于是人们发明了 汇编语言(assembly language),用人能读懂的单词代替二进制,叫「助记符」。

机器语言 vs 汇编语言
机器语言汇编语言含义
0001 0010 0011ADD R2, R3R2 = R2 + R3
0010 0100 1000LOAD R4, 8从内存地址 8 读入 R4

汇编语言与机器语言是一一对应的:一句汇编,翻译成一句机器指令。翻译工具叫 汇编器(assembler)。

核心思想 汇编 ≈ 机器语言的「人话版」。它换汤不换药,本质还是直接指挥硬件、极其底层,所以写起来繁琐、难移植,但能榨干硬件性能。

🧠 一句话记忆 汇编 = 机器语言的助记符;一句汇编对应一句机器指令。

汇编器的工作方式:两遍扫描与标号

「一句汇编对应一句机器指令」听上去一翻即可,但真实的汇编器(assembler)要做两遍扫描(two-pass):第一遍从头到尾「清点座位」,给每条指令标上它将存放的地址,并把所有标号(label,如 LOOP)登记成「名字→地址」对照表;第二遍才逐句翻译——遇到 JNZ LOOP 时,回头查第一遍记下的表,把 LOOP 的地址填进指令的地址字段。为什么要两遍?因为当翻译器读到 JNZ LOOP 这一行时,LOOP 标号可能定义在后面,此刻还不知道它将落在几号单元。先用名字占位、再回头查表补值——这个「符号→地址」的两步解析思想,后来在编译器的符号表、链接器的重定位里被一再重用。

汇编语言里还有一类特殊成员:伪指令(pseudo-instruction)。它们长得像指令,但不产生任何机器码,只是写给汇编器看的「备注」——比如「在当前单元放一个常量 5」「程序入口在这里」「从这里开始是数据区」。汇编语言由此获得了最初的程序组织能力:数据区、代码区、入口点,全靠伪指令划分。看懂「真指令变机器码、伪指令指挥布局」,你就理解了汇编器与汇编程序的全部关系。

与汇编器相对的还有一个方向:反汇编器(disassembler)——把机器码逐条还原回汇编记号。它没有符号信息,还原出的标号只能叫「地址 2」,但已足够让人读懂程序在做什么。排查无源码固件、分析病毒样本时,工程师面对的就是反汇编清单。今天还需不需要会写汇编?日常应用开发几乎不必,但读懂汇编仍是硬功夫:性能优化要找到最热的几条指令、系统刚上电时连高级语言运行环境都不存在(启动代码只能用汇编搭环境)、安全分析要在指令层判断行为。下面的例题带你亲手把一个循环翻译成机器码——这正是编译器后端每天在做的事。

📐 例题 4 手工汇编一个循环:计算 1+2+3+4+5

给本章简化 ISA 补足操作码(沿用正文约定并顺延):0001=ADD(A←A+B)、0010=LOAD(A←内存[B])、0011=JUMP(PC←B,无条件)、0100=SUB(A←A−B)、0101=STORE(内存[B]←A)、0110=JNZ(若上次 ALU 运算结果非零,则 PC←B,否则顺序执行)。另设伪指令 DATA n 表示「在当前单元放常量 n」。程序从 0 号单元起连续存放:

        LOAD R1, 8     ; R1←内存8号(预置0):累加器清零
        LOAD R2, 9     ; R2←内存9号(预置5):计数器
LOOP:   ADD  R1, R2     ; R1 ← R1 + R2
        SUB  R2, 10    ; R2 ← R2 − 内存10号的值(预置1)
        JNZ  LOOP      ; 上次运算非零?跳回 LOOP
        STORE R1, 11   ; 循环结束:结果存入内存11号
        ; 8、9、10 号单元分别由 DATA 0、DATA 5、DATA 1 预置

思路 第一遍扫描:程序占 0~5 号单元,逐条标地址,LOOP = 2;8~10 号是数据。第二遍扫描:逐行查操作码表编码,JNZ 的 B 字段填 LOOP 的地址 2。最后按轮次追踪 R1、R2 与零标志 Z。

解 (1) 地址分配:0=LOAD R1,8;1=LOAD R2,9;2=ADD R1,R2(LOOP);3=SUB R2,10;4=JNZ;5=STORE R1,11。(2) 逐条编码(操作码|A|B):0010 0001 1000、0010 0010 1001、0001 0001 0010、0100 0010 1010、0110 0000 0010(A 字段空置为 0000,B 字段 0010 即 LOOP 的地址)、0101 0001 1011。(3) 逐轮追踪(每轮执行 ADD、SUB、JNZ 三条):

循环执行追踪(R1 累加、R2 计数)
轮次ADD 后的 R1SUB 后的 R2JNZ 的判断与去向
初始05—
第 1 轮0+5=55−1=44≠0 → 跳回 2 号
第 2 轮5+4=94−1=33≠0 → 跳回
第 3 轮9+3=123−1=22≠0 → 跳回
第 4 轮12+2=142−1=11≠0 → 跳回
第 5 轮14+1=151−1=0结果为 0 → 不跳,顺序落到 5 号

第 5 轮 SUB 算出 0,零标志 Z 置 1,JNZ 不再跳转;程序顺序执行 5 号的 STORE,把 R1=15 写入内存 11 号单元。

答:LOOP 的地址是 2;六条机器码如上;内存 11 号单元最终为 15,与 1+2+3+4+5 = 15 一致。 ⚠ 易错点:① 标号 LOOP 本身不占单元——占 0~5 号的是六条指令;② JNZ 检查的是上一次 ALU 运算(这里是紧邻的 SUB)的结果,不是 R2 的旧值;③ 若把 JNZ 误写成「为零才跳」,第 1 轮 R2=4 非零就不跳回,程序一遍退出、答案错成 5——条件跳转的「方向感」是循环程序的命门;④ STORE 的地址字段只有 4 位(0~15),所以结果只能存进 16 号以内的单元,这正是 11.5 节要讨论的「指令格式限制表达能力」。

工程视角:这个不足十行的小循环浓缩了一切循环的机器级形态——初始化(LOAD)→ 累加(ADD)→ 更新计数(SUB)→ 条件回跳(JNZ)→ 收尾(STORE)。你在高级语言里写的 for i in range(5): s = s + i,编译之后的骨架与这里一模一样(第 13 章会亲眼看到对照)。安全关键领域(航空、汽车电子)的软件评审里有一条常见要求:核心算法必须能在指令层讲清楚循环的入口、边界与退出条件——因为「多转一轮或少转一轮」都可能被放大成不可接受的故障。

辨析:分清「标号」与「地址」的关系。标号是给人看的名字,只在汇编器第一遍扫描时登记为「名字→地址」对照表;机器码里根本不存在 LOOP 这个词,只有冷冰冰的数字 2。同理,伪指令与注释也不进机器码——它们在翻译完成的那一刻功成身退。理解了这一点,就不会疑惑「反汇编出来的代码里为什么找不到我写的注释」:注释的信息在汇编阶段就被永久丢弃了。

11.5寻址方式:找到操作数

指令里说「对谁做」,可这个「谁」可以藏在不同的地方。CPU 如何找到它?这靠 寻址方式(addressing mode)。常见的有:

常见寻址方式
寻址方式意思类比
立即寻址操作数直接写在指令里「加 5」——数字直接给了
寄存器寻址操作数在寄存器里「加 R2 的值」
直接寻址操作数在某个内存地址「加 地址100 里的值」
间接寻址寄存器里存的是「地址」,要再跳一步「加 R2 指向的那个地址里的值」

📖 名词溯源:间接寻址像「藏宝图」 间接寻址(indirect addressing)里,寄存器存的不是数据,而是地址。这就像藏宝图:图(寄存器)上写的是「宝藏在 X 地点」,你得先到 X 地点(内存地址),才找到真正的宝藏(数据)。多转一层,但换来极大的灵活——指针(pointer)就是这么来的。

🧠 一句话记忆 寻址方式 = 「找操作数」的不同找法;间接寻址 = 藏宝图(存的是地址)。

四种寻址的代价账:访存次数与灵活性

把四种寻址方式放在一起算一笔「访存账」,它们的定位就一目了然。CPU 访问寄存器极快(部件就在芯片内部),访问内存则要跨总线、慢上一到两个数量级——所以每多一次访存,指令就慢一截。立即寻址与寄存器寻址执行时不访存(数据已在指令里/寄存器里);直接寻址要访存 1 次(按指令里写的地址取数);间接寻址要访存 2 次(先取「地址的地址」,再取数据本身)。而灵活性排序恰好相反:立即数焊死在指令里改不动;直接寻址换数据得改指令;间接寻址只要改寄存器里的值就能换目标——灵活是用访存次数买来的。

四种寻址方式的代价与收益对照
寻址方式指令里的字段执行时访存次数灵活性典型用途
立即寻址数据本身0 次最低(改数须改指令)小常数:清零、加一
寄存器寻址寄存器编号0 次中(改寄存器即可)循环内的反复运算
直接寻址内存地址1 次中(换地址须改指令)访问固定变量
间接寻址寄存器编号(存地址)2 次最高(改寄存器即换目标)数组遍历、指针、查表

立即数还有一个隐藏限制:宽度被指令格式锁死。本章 12 位指令里 B 字段只有 4 位,立即数最大只能写 15;真实 CPU 同理——32 位 ARM 的立即数编码空间只有 12 位(还带旋转约束),x86 的立即数也要按指令长度屈就。要装大常数怎么办?工程惯例是「先把常数放进某个内存单元,再用直接/间接寻址取」,或者「装载高位 + 移位」两条指令拼出来。指令集的每一处设计,都在「指令长度、可表达范围、执行速度」之间做三角权衡——没有免费的午餐,只有换着花样的取舍。

间接寻址的真正威力在于:它让「数据的地址」本身成为可运算的数据。把 R1 从 200 改成 201,同一条指令就取到了下一个元素——只要让寄存器每轮加 1,一条指令就能依次扫过整片内存。这正是数组遍历、链表行走、查表跳转的机器级原理;高级语言里的「指针」(pointer),本质就是穿了衣服的间接寻址:指针变量是「存着地址的格子」,解引用就是「再跳一级取数据」。

间接寻址的两级跳 寄存器 R1 存 200,先访问内存 200 号取到地址 150,再访问内存 150 号取到数据 7,最后 7 参与加法。 寄存器 R1 = 200 存的不是数据,是地址 内存 [200] = 150 第 1 次访存:取到的还是地址 (藏宝图的下一站) 内存 [150] = 7 第 2 次访存:终于拿到真数据 (宝藏本身) R2 = 40 + 7 = 47 ① 拿 200 找单元 ② 取出 150,再跳一级 ③ 数据 7 参与加法
图 11-4间接寻址的两级跳:R1 存的是地址(200),先访内存 200 号取到第二个地址(150),再访 150 号才拿到数据 7。

📐 例题 5 间接寻址数据追踪

设 R1 = 200、R2 = 40;内存 200 号单元存 150,内存 150 号单元存 7。执行间接寻址指令 ADD R2, (R1)——把「R1 所指内存单元的值」加到 R2 上。请写出执行过程中每次访存取到的值并给出 R2 终值;再问:若换成直接寻址 ADD R2, [200](把内存 200 号的值直接当数据加),结果有何不同?

思路 关键是分清「R1 的值」与「R1 指向的值」。间接寻址 (R1) 要跳两级:读 R1 得 200 → 读内存[200] 得 150 → 读内存[150] 得 7——最后这个 7 才是操作数。直接寻址只跳一级,内存[200] 的内容 150 直接被当数据用。

解 间接寻址:第 1 步读寄存器 R1 = 200(不访存);第 2 步访内存 200 号,取到 150——此刻取到的是地址而非数据;第 3 步访内存 150 号,取到 7——这才是数据;第 4 步 ALU 计算 40 + 7 = 47 写回 R2。直接寻址版:只访存一次,内存 200 号的内容 150 直接进加法器,R2 = 40 + 150 = 190。

答:间接寻址后 R2 = 47;直接寻址后 R2 = 190——一条指令的寻址方式不同,结果差了 143。 ⚠ 易错点:间接寻址里「内存 200 号存的 150」在本条指令语境下被解释为地址;同一段内存在别的指令里完全可以是普通数据——同一个 150,这条指令当地址用、那条指令当数据用,全看寻址方式怎么说。这也是不少软件漏洞的根源:本该是数据的字节被当成了地址或指令来解释。

工程视角:数一数一张数据表有多少个元素,就能掂出间接寻址的分量——遍历 100 个元素若用直接寻址,得写 100 条各自写死地址的指令;用间接寻址,一条循环配上「R1 每轮加 1」就全办了。中断向量表(硬件事件→处理程序入口的对照表)、函数指针(按名字调用函数)这些工程机制,背后全是这套「地址也是数据」的两级跳。

辨析:直接寻址与立即寻址最易混淆。二者「指令里都带一个数」,但立即寻址的数本身就是数据(ADD R2, #5:加的就是 5);直接寻址的数是数据的门牌号(ADD R2, [5]:加的是内存 5 号里的内容)。记法:立即数是「一手交钱」,直接寻址是「按地址取货」。很多汇编语言用 # 号专门标出立即数,就是在源码层面替你把这道辨析题画了重点。

11.6RISC 与 CISC:两种设计哲学

设计指令集时,工程师分成了两大流派:

RISC vs CISC
维度RISCCISC
指令数量少而简单多而复杂
指令长度固定可变
执行速度单条快、适合流水线单条慢
典型代表ARMx86(Intel/AMD)

核心思想 这两种不是「谁对谁错」,而是权衡:RISC 用「简单 + 快速 + 流水线友好」换效率,CISC 用「复杂 + 一条顶多条」换代码密度。现代设计其实在互相借鉴,边界已模糊。

🧠 一句话记忆 RISC 少而快(手机 ARM),CISC 多而全(电脑 x86)。

为什么 RISC 天生适合流水线

RISC 的三个特征——指令定长、指令简单、访存只留给 LOAD/STORE——不是三条孤立的军规,而是同一条设计目标的三个侧面:让每条指令的执行节奏整齐划一。流水线(第 8 章)像工厂装配线,理想状态是每个工位的耗时可预测:定长指令让「取指工位」永远在同一时间装满;简单指令让「执行工位」不出现长尾;访存集中让「要等内存」的工位清晰可辨。CISC 的可变长指令恰恰相反——取指时连这条指令到底多长都要逐字节试探,一条复杂指令的执行时间更没谱,流水线的「整齐」被破坏,硬件只能靠更复杂的乱序调度去补救。

「load/store 架构」还有一个干净的推论:只有 LOAD/STORE 能碰内存,其余运算一律在寄存器之间进行。于是「哪些指令会访存」这个最不可预测的因素被关进了两个笼子,其余指令的节拍整齐得几乎可以背下来。现代 ARM 服务器芯片的流水线深度能达十几级,根基正在于此——流水线越深,越要求各级节拍可预测,而可预测性正是 RISC 从指令集层面送来的礼物。

再补一笔历史与现状。CISC 的「一条顶多条」当年是为代码密度服务的:1970 年代内存按 KB 计价,程序小 30% 就是真金白银;当时还流行「让一条指令对应一条高级语言语句」的思路,编译器写起来省事。后来内存降价、编译器进化,天平倒向 RISC;但 x86 凭兼容性守住了桌面与服务器。今天两派的边界已高度模糊:现代 x86 芯片内部会把复杂指令拆解成类 RISC 的微操作(micro-operation,μop)再进流水线——「外壳 CISC、内核 RISC」;ARM 也陆续加入一次处理大块数据的复杂指令。哲学之争落幕于相互吸收,但两条底层认识永远成立:指令集是契约,流水线爱整齐。

📐 例题 6 同一任务的 RISC 与 CISC 指令序列对比

任务:计算 c = a + b × 3,其中 a、b、c 是内存中的变量,当前 a = 6、b = 4。请分别在两种风格下写出指令序列并数出条数:CISC 风格允许「一条指令同时访存并运算」;RISC 风格规定运算只在寄存器之间进行、访存只允许 LOAD/STORE。最后验算两种写法存入 c 的结果是否一致。

思路 CISC 风格「边取边算」,能省一次搬运;RISC 风格必须「先搬后算」,所有要用的数都先入寄存器,算完再搬回去。数条数时按各自语法诚实清点,不凭感觉。

解 CISC 风格(允许访存与运算一体):

LOAD  R1, [b]    ; R1 ← b                     1 条
MUL   R1, #3     ; R1 ← R1 × 3                 1 条
ADD   R1, [a]    ; R1 ← R1 + a(访存+加法一体) 1 条
STORE [c], R1    ; c ← R1                      1 条      共 4 条

RISC 风格(load/store 架构):

LOAD  R1, [a]    ; 1
LOAD  R2, [b]    ; 2
LOAD  R3, #3     ; 3(立即数也先入寄存器)
MUL   R4, R2, R3  ; 4(三地址形式:R4←R2×R3)
ADD   R5, R1, R4  ; 5(R5←R1+R4)
STORE [c], R5    ; 6                    共 6 条

验算:b × 3 = 4 × 3 = 12;a + 12 = 6 + 12 = 18。两种写法存入 c 的都是 18,语义严格一致。

答:CISC 风格 4 条、RISC 风格 6 条,c = 18。但条数少不等于跑得快:CISC 的 MUL、ADD 内部要访存又要运算,单条节拍长;RISC 的 6 条短指令节拍整齐,在流水线里可以重叠推进(第 8 章),总耗时不一定吃亏。 ⚠ 易错点:① 「指令条数少 = 性能好」是初学最常见的误推——比较性能要数时钟周期,不是数条数;② RISC 版本里立即数 3 也要先 LOAD 进寄存器,这不是笨,而是「只有 LOAD/STORE 访存、运算一律寄存器间」的规则使然;③ 例中三地址写法 MUL R4, R2, R3 与本章二地址示例并不矛盾——11.1 节说过,操作数个数本来就是 ISA 的设计决策之一。

工程视角(衔接第 13 章):这个例题里你其实已经扮演了一次编译器——面对同一个任务,按目标指令集的「方言」组织指令。第 13 章会看到:同一段高级语言源码,编译到 ARM 与编译到 x86,产出的指令序列完全不同,但语义严格一致。ISA 是「方言」,编程语言是「普通话」,而「普通话→方言」的翻译学问,就是编译原理。

本章小结

  1. 指令 = 操作码(做什么)+ 操作数(对谁做)。
  2. 机器语言是二进制指令,是 CPU 唯一能直接执行的;指令集架构(ISA)规定了它的规则。
  3. 一条 ADD 指令的执行,正是「取指→译码→执行(加法器)→写回」四步。
  4. 汇编语言是机器语言的助记符,一一对应,由汇编器翻译。
  5. 寻址方式决定了如何找到操作数;RISC 与 CISC 是两种指令集设计哲学。
  6. 亲手编码与逐拍追踪是「软件驱动硬件」的显微镜:12 位编码要靠字段拼接与查表、汇编器靠两遍扫描解析标号、PC/IR/ALU 按时钟节拍接力、四种寻址各付各的访存代价、RISC 与 CISC 各有取舍——每一步剥开看,都只剩 0/1 与逻辑门在干活。

读完本章,你应该能……

思考题

1. 一条指令由哪两部分组成?各自表示什么?

提示:操作码(做什么)+ 操作数(对谁做)。

2. 为什么说「机器语言是 CPU 唯一能直接执行的」?

提示:CPU 是数字电路,只认 0/1;其他语言都要翻译成机器语言。

3. 汇编语言和机器语言是什么关系?谁负责翻译?

提示:一一对应,汇编是助记符;由汇编器翻译成机器指令。

4. 用「藏宝图」解释间接寻址。

提示:寄存器存的是地址而非数据,要再跳一步才找到真正数据。

5. 12 位指令格式「4 位操作码 + 两个 4 位寄存器号」最多支持多少种操作码、多少个寄存器?

提示:n 位字段能区分 2n 种情况——4 位 → 24 = 16 种操作码、16 个寄存器(编号 0~15)。

6. R2 = 15、R3 = 27,执行二地址指令 ADD R2, R3 后,R2、R3 各是多少?

提示:结果写回第一个操作数——R2 = 15 + 27 = 42,R3 保持 27 不变;旧值 15 被覆盖后不可恢复。

7. 为什么「指令条数少」不代表「程序跑得快」?

提示:CISC 一条复杂指令内部节拍长,RISC 多条短指令能在流水线里重叠执行——比性能要数时钟周期,不是数指令条数。

8. 间接寻址指令 ADD R2, (R1):R1 = 200,内存[200] = 150,内存[150] = 7,R2 = 40。执行后 R2 是多少?若写成直接寻址 ADD R2, [200] 呢?

提示:间接寻址跳两级,40 + 7 = 47;直接寻址跳一级,40 + 150 = 190——同一个内存单元,被当「地址」还是当「数据」,结果天差地别。