Lecture 6

Abstract

我们完成交通灯有限状态机的电路设计,随后建立 ISA 与程序可见机器状态的联系,学习 x86-64 寄存器、AT&T 语法、数据搬运、指针交换和有效地址计算。

我们先把上次的交通灯例子接着做完。前面我们从 R-S latch、D latch、D flip-flop 一直做到寄存器,已经知道怎样让电路记住信息。现在的问题是:给定一段自然语言描述,我们怎样把它变成一个真正可以工作的时序电路?交通灯正好可以把这个过程连起来。

把交通规则变成有限状态机

我们考虑两条相交的道路 A 和 B。每条路上都有传感器,分别给出输入 TAT_A、TBT_B:有交通流时为 1,没有时为 0。输出是两盏交通灯 LAL_A、LBL_B,每盏灯都有绿、黄、红三种颜色。A 路允许通行时,B 路必须保持红灯;切换通行方向时,先经过黄灯状态。

这个系统可以分为四个状态:

状态 A 路交通灯 B 路交通灯 下一状态
S0S_0 绿 红 TA=1T_A=1 时保持;TA=0T_A=0 时到 S1S_1
S1S_1 黄 红 无条件到 S2S_2
S2S_2 红 绿 TB=1T_B=1 时保持;TB=0T_B=0 时到 S3S_3
S3S_3 红 黄 无条件到 S0S_0

复位时进入 S0S_0。“无条件转换”在下一次状态更新时不再检查传感器,状态由时钟控制的寄存器保存。

我们先画状态转移图,再把每条箭头写成表。表里的 XX 表示该输入对这一行没有影响,0 和 1 都满足:

当前状态 TAT_A TBT_B 下一状态
S0S_0 0 XX S1S_1
S0S_0 1 XX S0S_0
S1S_1 XX XX S2S_2
S2S_2 XX 0 S3S_3
S2S_2 XX 1 S2S_2
S3S_3 XX XX S0S_0

传感器本身已经是二进制输入,不需要再编码。四个状态则要用两个 bit 表示。我们取 S0=00,S1=01,S2=10,S3=11S_0=00,S_1=01,S_2=10,S_3=11,把保存状态的两个位写成 s1,s0s_1,s_0,下一状态写成 s1′,s0′s'_1,s'_0,避免和四个状态的名称混淆。

对于下一状态的高位,当前状态为 01 时一定输出 1,为 10 时也一定输出 1,而 00、11 时输出 0。因此

s1′=s1‾s0+s1s0‾=s1⊕s0.s'_1=\overline{s_1}s_0+s_1\overline{s_0}=s_1\oplus s_0.

对于下一状态的低位,只在两种情况下为 1:当前为 00 且 A 路没有交通,或者当前为 10 且 B 路没有交通。因此

s0′=s1‾ s0‾ TA‾+s1s0‾ TB‾.s'_0=\overline{s_1}\,\overline{s_0}\,\overline{T_A} +s_1\overline{s_0}\,\overline{T_B}.

这里用的仍然是前面组合逻辑中的积之和方法:找出结果为 1 的各行,分别写出条件,再用 OR 合在一起。课件这一式第一项的 s1s_1 漏写了取反;按转移表必须是 s1‾\overline{s_1}。 若照着错误式子画电路,00 状态下 A 路没有交通时就不能正确转到 01。我们应以状态规则和真值表相互核对,而不是机械抄公式。

输出同样需要编码。每盏灯有三种颜色,两个 bit 足够。取绿灯 00、黄灯 01、红灯 10,就得到:

s1s0s_1s_0 LA1LA0L_{A1}L_{A0} LB1LB0L_{B1}L_{B0}
00 00 10
01 01 10
10 10 00
11 10 01

从表中直接读出

LA1=s1,LA0=s1‾s0,LB1=s1‾,LB0=s1s0.L_{A1}=s_1,\qquad L_{A0}=\overline{s_1}s_0, \qquad L_{B1}=\overline{s_1},\qquad L_{B0}=s_1s_0.

有了这些表达式,我们就能把整个电路分成三部分。状态寄存器保存 s1,s0s_1,s_0;下一状态组合逻辑读取当前状态和输入 TA,TBT_A,T_B,算出 s1′,s0′s'_1,s'_0;输出组合逻辑读取当前状态,生成两盏灯的编码。时钟到来时,寄存器把下一状态变成当前状态,电路就继续运行。这是一个 Moore 型状态机:灯的输出由当前状态决定,外部输入先影响状态转换。

状态寄存器与输出逻辑
回放 A(170336),29:00。看中间状态寄存器与右侧四个灯色输出表达式,左侧下一状态逻辑通过反馈读取当前状态。

我们现在走通了一条很重要的路线:自然语言问题 → 状态转移图 → 状态转移表 → 输入、状态和输出编码 → 布尔表达式 → 门级电路与状态寄存器。以后看到更复杂的控制器,也可以先按这个过程拆开。

ISA 是软件与硬件之间的约定

接下来我们往上走一层,讨论 Instruction Set Architecture,简称 ISA,也就是指令集体系结构。底层电路怎样和上层程序合作?程序员不可能每写一段程序,都先了解 CPU 中每个门和每条连线。我们需要一个稳定的接口,规定程序可以要求机器做什么,以及执行以后能观察到什么结果。

我们从一个问题出发,先设计算法,再用某种编程语言写程序。编译器把程序翻译成机器相关的指令,汇编语言是这些机器指令的一种可读表示。指令最终以二进制机器码存入内存,由底层微架构和电路执行。ISA 就位于程序与微架构之间:不同的硬件实现,只要遵守同一套约定,就应正确执行这套 ISA 所定义的程序行为。

这个约定要包含哪些东西?首先是内存的组织。我们需要知道地址能区分多少个位置,也要知道每一个地址究竟对应多大的存储单元。后一个概念叫 addressability。例如字节寻址表示每个地址对应一个 byte,即 8 bit;按字寻址则一个地址对应一个 word。不要把“地址有多少位”和“每个地址里有多少位数据”混在一起:前者决定能编号多少个位置,后者决定一个位置的单位。

课堂拿 LC-3、MIPS 和 x86-64 作比较。LC-3 的教学模型有 2162^{16} 个地址,每个位置为 16 bit;x86-64 在我们接下来使用的模型里按字节寻址。课件还列出了 MIPS 的 2322^{32} 和 x86-64 的 2522^{52} 等数字,用来说明不同体系结构会有不同的内存约定。

纠错:课件把 MIPS 的 32 bit word 宽度写成了寻址粒度;通常 MIPS 按字节寻址。x86-64 的 2522^{52} 指所示物理地址位宽对应的空间,须与程序使用的虚拟地址区分。

其次是寄存器集合。我们会用到 x86-64 的 16 个通用整数寄存器,MIPS 的例子则有 32 个。这个数说的是 ISA 暴露给程序的寄存器,不等于 CPU 内部只允许有这么多物理存储位置。微架构可以有自己的实现办法,但软件只能按 ISA 的规定命名和操作这些可见寄存器。

最后是指令本身:opcode 告诉机器要做什么,操作数的数据类型和宽度告诉机器处理多少位、按什么规则处理,addressing mode 告诉机器到哪里找到数据。后面我们会逐条看这些约定如何体现在汇编语法里。

为什么有 CISC 和 RISC

历史上有两种常见的设计思路。CISC 是 Complex Instruction Set Computer,复杂指令集;RISC 是 Reduced Instruction Set Computer,精简指令集。

我们先用教学中的典型特征来区分。RISC 倾向于规则、相对简单的指令,经典设计常采用统一的指令长度,便于取指、译码和流水线组织。CISC 则倾向于提供较丰富的指令,指令可以具有不同长度、不同复杂程度,一条指令可能包含较多工作。我们以 ARM 和 x86 分别作为两类思路的例子。

为什么要设计复杂而且不等长的指令?把时间往前推,内存和存储都很昂贵。如果一个简单操作能用很短的编码表达,就不必为它固定预留和复杂操作一样长的空间;如果一条专用指令能表达原本要写好几条指令的工作,也可能缩小程序。这样,在资源受限的机器中就能容纳更多程序。x86 指令以字节编码,简单指令可以使用较短编码。

但不等长和复杂操作也有代价。处理器必须识别指令边界,判断本条指令到哪里结束;不同指令完成工作所需的过程可能差别很大。这些问题会在后面的微架构和流水线中变得具体。现在先记住,紧凑的编码和规则的硬件处理之间存在取舍。

摩尔定律描述了晶体管数量随年代增长的趋势。随着可以使用的晶体管增加,处理器能够实现更多功能,指令集也不断扩展。但扩展并不是把旧设计全部推倒:过去已经有大量程序依赖原有指令和编码,新的机器仍然需要兼容它们。我们可以把这种演化想成一座不断加建的房子;早期接口一旦形成,后面的设计就受到原有结构约束。

x86 因而很难简单地对外换成完全不同的一套 ISA。一个实际办法是在内部把外部复杂指令翻译成更简单的微操作,再交给内部执行机构。于是,软件看到的仍然是 CISC 风格的 x86 接口,内部实现可以采用较规则的处理方式。我们要区分对程序作出的承诺与兑现承诺的内部结构。兼容性会长期约束 ISA 的演化。

从字节内存理解指针

现在回到程序员实际能看到的机器。我们可以把内存想成一个很长的 byte 数组。每个 byte 都有地址,地址就像数组下标。指针变量也占存储空间,但它存放的内容是地址。

看一个简单的 C 例子:

char a = 4;
char b = 3;
char *c;
c = &a;
b += *c;

假定 a 位于地址 0x10,b 位于 0x11,指针变量 c 自己位于 0x24。这些地址只是为了画图,不是语言规定的实际布局。声明 c 以后,不能把其中未初始化的内容当成合法地址使用;执行 c = &a 后,c 保存的是 0x10。

&a 是取地址,*c 是解引用。最后一句并不是把指针的数值 0x10 与 3 相加,而是先到 c 保存的地址 0x10 去取数据,取到 4,再计算 3+4=73+4=7,写回 b。执行以后,a 仍然是 4,c 仍然保存 0x10,改变的是 b。

我们因此要时时分清三件事:变量自己的存放位置、变量中存的内容,以及把该内容解释为地址后访问到的数据。指针变量的那一行表格不能理解成整个指针只占一个 byte;在接下来使用的 64 位环境里,指针占 8 byte。

汇编程序员看到的机器状态

内存里既有程序的机器码,也有程序处理的数据,还有支持运行时函数调用的栈。课件把它们画成 code、data、stack 三个区域,是为了看清它们各自的用途。CPU 从 code 中取到的是二进制指令,并不是屏幕上显示的 mov、add 这些字符串。

CPU 内部还有寄存器文件,register file。它同样存储数据,但更小、更快。课件用寄存器约 0.5 ns、内存约 15 ns 的访问延迟,以及 128 B 与 16 GB 的容量,来说明数量级差异。16 个通用寄存器,每个 8 byte,合计正好是 16×8=12816\times8=128 byte。我们写汇编或编译器分配数据时,都希望把经常用的数据尽量放进寄存器,减少较慢的内存访问。

16 个通用整数寄存器可以写为:

%rax  %rbx  %rcx  %rdx  %rsi  %rdi  %rbp  %rsp
%r8   %r9   %r10  %r11  %r12  %r13  %r14  %r15

前面一些名称来自历史用途,后来新增的寄存器则直接编号。我们不能仅凭名字把一个寄存器的用途完全固定死,但有些寄存器在调用约定中确实承担特定职责,例如 %rsp 用作栈指针。

每个寄存器有 64 bit。我们还可以通过不同名称访问同一个寄存器的低位部分:

名称 访问范围 大小
%rax 全部 64 位 8 byte
%eax 低 32 位 4 byte
%ax 低 16 位 2 byte
%al 低 8 位 1 byte

它们是同一个寄存器的不同视图,不是四个互不相关的存储单元。在课件使用的数据模型里,char、short、int、long、指针分别占 1、2、4、8、8 byte。浮点数另有相应的寄存器集合,我们这部分主要看整数操作。

我们还需要知道程序执行到了哪里,这就需要 program counter,程序计数器。在 x86-64 中对应 instruction pointer %rip。它保存指令地址,处理器据此取下一条要执行的指令。它不是一个可以像 %rax 那样随意作为普通 mov 目的操作数的通用寄存器;控制流指令会按各自规则改变它。

计算发生在 ALU,即 arithmetic logic unit 中。加减、移位、逻辑运算都属于它所服务的工作。计算还会产生状态信息,例如结果是否为零、是否发生溢出,这些信息保存在 condition codes,也叫 flags 中。后面的条件跳转会读取它们。

我们可以把指令分成几类:计算指令负责算术和逻辑运算;数据搬运指令负责把数据放到合适的存储位置;控制指令改变执行顺序,用来实现条件分支、循环与函数调用。机器执行时,大体要做取指、译码、读取操作数、执行、写回,然后调整 PC,继续处理下一条。适用的计算指令还会更新条件码。

AT&T 语法:先源、后目的

下面我们采用 AT&T 汇编语法。先把最容易混淆的一条规则固定下来:源操作数在前,目的操作数在后。 寄存器名前加 %,立即数前加 $。我们先看

movq %rdx, (%rdi)

它把 %rdx 中的 8 byte 数据复制到内存中,目标地址是 %rdi 保存的数值。这里 mov 名字虽然叫“移动”,源位置的数据不会因此被清除。括号 (%rdi) 表示按地址访问内存;没有括号的 %rdi 才表示寄存器本身。若把 %rdx 的值看成 a,把 %rdi 的值看成指针 p,这一句对应的是 *p = a。

我们不能把它读成 p = a,也不能读成先去 %rdx 所指的位置取值。是否解引用由操作数的写法决定,不是看到一个数值可能是地址,就自动跟着它访问内存。

先算地址,再读取或写入

最简单的内存寻址形式是 (R),有效地址等于寄存器 R 的内容。例如

movq (%rcx), %rax

是到 %rcx 所指内存读取 8 byte,然后写入 %rax。如果在括号前加常数位移,就得到 D(R):

movq 8(%rbp), %rdx

先计算 Reg⁡[%rbp]+8\operatorname{Reg}[\%rbp]+8,再从这个地址读取 8 byte 到 %rdx。它不是先读 (%rbp),然后给读出的数据加 8。这里的 8 是地址偏移,单位为 byte。

为了精确表达,记 R[r]R[r] 为寄存器内容,M8[a]M_8[a] 为从内存地址 aa 开始的 8 byte 数据,上两条分别是

R[%rax]←M8[R[%rcx]],R[%rdx]←M8[R[%rbp]+8].R[\%rax]\leftarrow M_8[R[\%rcx]], \qquad R[\%rdx]\leftarrow M_8[R[\%rbp]+8].

三类操作数与五种常见搬运

操作数主要有立即数、寄存器和内存三类。立即数是直接写在指令里的常数,例如 $0x400、$-533。常数没有可供写入的存储位置,所以只能作为源。寄存器和内存则都可以成为目的。

对这里的一般 movq,我们使用下面五种组合:

源 → 目的 示例 含义
立即数 → 寄存器 movq $0x4, %rax 把 4 放入 %rax
立即数 → 内存 movq $-147, (%rax) 把 -147 写到 %rax 所指位置
寄存器 → 寄存器 movq %rax, %rdx 复制寄存器值
寄存器 → 内存 movq %rax, (%rdx) 写入 %rdx 所指位置
内存 → 寄存器 movq (%rax), %rdx 读取 %rax 所指位置

一般 mov 不允许两个显式操作数同时为内存。如果要把一个内存位置的数据复制到另一个位置,我们先读入临时寄存器,再从临时寄存器写回。

用四次搬运交换两个 long

我们把这些规则放到一个完整例子中。C 程序要交换两个指针指向的数据:

void swap(long *xp, long *yp)
{
    long t0 = *xp;
    long t1 = *yp;
    *xp = t1;
    *yp = t0;
}

在本课使用的调用约定中,第一个参数位于 %rdi,第二个位于 %rsi。所以 %rdi 存的是 xp,%rsi 存的是 yp,并不是两个 long 数据本身。对应代码为:

swap:
    movq (%rdi), %rax    # t0 = *xp
    movq (%rsi), %rdx    # t1 = *yp
    movq %rdx, (%rdi)    # *xp = t1
    movq %rax, (%rsi)    # *yp = t0
    ret

第一句把 xp 所指的原值保存到 %rax,第二句把 yp 所指的原值保存到 %rdx。接着我们用 %rdx 覆盖 *xp,用 %rax 覆盖 *yp。为什么要先保存原值?因为直接写回会覆盖内存,若还没有把原值保存到别处,就会丢掉之后所需要的数据。

执行过程中两个指针的数值没有交换;交换的是它们指向的内存内容。ret 结束这个函数,返回到调用方。返回控制究竟怎样实现,我们后面再看。

swap的寄存器与内存
回放 B(168158),41:00。看四条movq怎样先保存两处原值,再分别写回;交换的是指针指向的内容。

LEA:计算有效地址而不访问内存

我们最后看一条很有用的指令 leaq,全称 load effective address。它的名字容易让人误以为是在加载内存中的数据,但它实际做的是计算地址表达式,把计算结果写入寄存器,不访问那个地址处的内存。

leaq 4(%rsi, %rdi, 2), %rax

这一句的作用是

R[%rax]←R[%rsi]+2R[%rdi]+4.R[\%rax]\leftarrow R[\%rsi]+2R[\%rdi]+4.

我们没有在右侧再套一层 M8[⋅]M_8[\cdot]。同样的括号表达式出现在 movq 中时,是用来定位内存操作数;出现在 leaq 中,则只计算它表示的数值。括号的最终含义必须连同指令一起看。

通用形式为

D(Rb, Ri, S)

其有效地址表达式为

EA⁡=R[Rb]+S⋅R[Ri]+D.\operatorname{EA}=R[R_b]+S\cdot R[R_i]+D.

这里 RbR_b 是 base register,RiR_i 是 index register,DD 是常数 displacement,SS 是 scale,可取 1、2、4、8。常见数据大小正好包括 1、2、4、8 byte,所以数组下标乘以元素大小,就能转成 byte 偏移。一般索引寄存器不能使用 %rsp。

比如基地址指向一个结构,结构内某个数组在偏移 8 byte 的地方,数组元素为 4 byte,那么 8(%rax,%rbx,4) 形式的表达式就能计算第 %rbx 个元素的地址:先从基地址跨过 8 byte 的字段,再移动 4×index4\times\text{index} byte。我们若需要 p = &x[i],只要计算地址;真正读取 x[i] 的数据则还需要相应的内存访问指令。

组成部分也可以省略。没有 displacement 时按 0 处理,没有显式 scale 时按 1 处理:

形式 计算结果
(Rb,Ri) R[Rb]+R[Ri]R[R_b]+R[R_i]
D(Rb,Ri) R[Rb]+R[Ri]+DR[R_b]+R[R_i]+D
(Rb,Ri,S) R[Rb]+SR[Ri]R[R_b]+S R[R_i]

leaq 还可以用来做普通整数运算。寄存器中究竟存的是“地址”还是“整数”,并不会改变这条指令对这些 bit 做的加法和倍乘。因此,当计算正好符合这个表达式时,编译器可以利用 lea 完成算术工作。我们今天先把数据从哪里来、怎样搬运、怎样计算地址这几件事弄清楚;算术、逻辑和条件控制接着往下讲。

LEA有效地址计算
回放 B(168158),47:10。看base、index、scale、displacement及scale可取的1、2、4、8;LEA只计算表达式,不读取该地址中的数据。

来源与补全:据 2026-10-08 A(170336)、B(168158)完整转写,核对 Lecture 6 PDF 215—260 页、Lecture 7 PDF 2—218 页。布尔推导、指针状态及搬运符号补齐中间步骤;汇编按课程的 64 位数据模型及 System V AMD64 约定整理。截图沿用已核实的回放帧。

课堂板书原图