Appearance
指令集与寄存器组织
概念
前面的 L2【机器 · 计算机组成原理】讲了一台计算机有哪些部件。但有一件事它一直没说清:
机器"能做什么",到底是谁规定的?
答案是一份"合同"——指令集体系结构(ISA,Instruction Set Architecture)。
契约的两端:
人写的东西
C / Python / 汇编 ─────────┐
│ ① 编译器/汇编器要按 ISA 生成机器码
▼
┌──────────────────────────────────────────┐
│ ISA(指令集) │ ← 本文的主题
│ · 有哪些指令 │
│ · 指令长什么样(格式) │
│ · 有哪些寄存器 │
│ · 地址怎么算(寻址方式) │
│ · 遇到异常会怎样 │
└──────────────────────────────────────────┘
▲
│ ② 硬件要按 ISA 实现这些指令
│
加法器、寄存器、控制器、流水线ISA 与"微架构"必须分清——这是这一篇最重要的一次概念切分:
| ISA(体系结构) | 微架构(microarchitecture) | |
|---|---|---|
| 回答 | "能做什么"(有哪些指令、什么格式) | "怎么做"(几个周期、什么流水线) |
| 谁写 | 厂商定的规格,几十年不变 | 每个型号各写各的,两三年一换 |
| 例子 | "有一条 add,把两个寄存器相加" | "这条 add 走 1 个周期,第 5 级写回" |
| 稳定性 | 稳定(软件兼容靠它) | 变(性能提升靠它) |
同一份 ISA 可以有无限多种微架构:MIPS32 的 add 可以由单周期 CPU、五级流水线 CPU、甚至这篇教程 L1 的 FPGA 电路来实现——对上层的机器码完全一样。这就是"契约"的含义:只要不违约,实现随便换。
MIPS32 的"契约条款"有四条(先记住,后面逐个展开):
| 条款 | MIPS32 的规定 |
|---|---|
| 指令长度 | 一律 32 位,定长 |
| 指令格式 | 只有 3 种:R 型、I 型、J 型 |
| 寄存器 | 32 个 32 位通用寄存器,编号 0~31 |
| 运算风格 | Load/Store 架构:运算只在寄存器之间,访存只有专门的 lw/sw |
为什么本站用 MIPS32 而不是 x86-64?
| 维度 | MIPS32 | x86-64 |
|---|---|---|
| 指令长度 | 定长 4 字节 | 变长 1~15 字节 |
| 格式数量 | 3 种 | 几十种前缀组合 |
| 寄存器 | 32 个,编号规整 | 16 个 + 历史遗留的不规则命名(rax/eax/ax/al) |
| 教学代价 | 可以手工编码一条指令 | 手工编码基本不可能 |
| 408 教材 | 常见(RISC 代表) | 仅作对照 |
这不是说 x86 更差——x86 的变长指令是为了代码密度,几十年积累的兼容性是真金白银。但教学第一个人手算机器码时,MIPS32 的规整性是压倒性的优势。
本篇在主线上的位置:L1 造出了门、ALU、触发器、状态机;L2 会用它们拼出一台 CPU。但"这台 CPU 要执行什么",必须先由 ISA 定下来。所以本篇是【由硅到 C】链条上第一次"从硬件转向人"的转弯:从这里开始,话题从"电路怎么做出来"变成"人要机器做什么、怎么说清楚"。 与 L2 的分工:
arch/20-instruction.md(指令格式与寻址方式)讲通用理论 + 408 考点;本篇讲 MIPS32 具体长什么样,并且真的把机器码算出来。
原理
一、32 个通用寄存器
MIPS32 有 32 个 32 位通用寄存器,用 $0~$31 编号。为什么是 32 个? 因为 5 位二进制正好表示 32 个编号——这是"位宽决定数量"的教科书例子(L1 的编码器一节:
它们不是平权的,而是各有约定(约定由编译器/ABI 制定,硬件只认编号):
| 编号 | 名字 | 用途 | 谁负责保存 |
|---|---|---|---|
$0 | $zero | 恒为 0,写入无效 | — |
$1 | $at | 汇编器保留(给伪指令展开用) | — |
$2~$3 | $v0~$v1 | 函数返回值 / 系统调用号 | 调用者 |
$4~$7 | $a0~$a3 | 函数参数 1~4 | 调用者 |
$8~$15 | $t0~$t7 | 临时寄存器(temporary) | 调用者 |
$16~$23 | $s0~$s7 | 保存寄存器(saved) | 被调用者 |
$24~$25 | $t8~$t9 | 临时寄存器(续) | 调用者 |
$26~$27 | $k0~$k1 | 内核保留(异常处理用) | — |
$28 | $gp | 全局指针 | — |
$29 | $sp | 栈指针 | — |
$30 | $fp/$s8 | 帧指针 | 被调用者 |
$31 | $ra | 返回地址 | 调用者 |
三条必须刻进脑子的:
$zero恒为 0,且写它无效。它不是"一个存着 0 的寄存器",而是硬连线的常量源。 → 用处极大:move $a,$b展开成addu $a,$zero,$b;beq $t0,$zero,L就是"等于 0 就跳"。$t系列调用者保存、$s系列被调用者保存——这是函数调用约定的核心,lang/04-stack.md会详讲。先记结论:跨函数调用还想活着的值,放$s。- MIPS 没有标志位寄存器(没有 x86 那种
ZF/CF/OF)。这是 RISC 的一个著名设计选择,理由很硬:标志位是"全局状态",会把两条本来无关的指令绑在一起——流水线里必须等前一条算完标志位,后一条才能读。用
slt把比较结果写进普通寄存器,依赖就变成显式的、可调度的。
另外还有两个"不在编号里"的寄存器:
| 寄存器 | 用途 |
|---|---|
HI / LO | 乘法、除法的结果:mult 把 64 位积放 HI:LO,div 把余数放 HI、商放 LO,再用 mfhi/mflo 读出来 |
| CP0(协处理器 0) | 状态寄存器 Status、原因 Cause、异常返回地址 EPC 等,只有内核态能访问(见 lang/05-syscall.md) |
为什么乘法结果不直接写通用寄存器? 因为 32 位 × 32 位 = 64 位,一个 32 位寄存器装不下。而"高 32 位"和"低 32 位"分开放,比"占用两个通用寄存器"更省编号、也更符合"编号必须规整"的设计洁癖。
二、三种指令格式
MIPS32 的指令一律 32 位,只有 3 种格式。这一点是它全部易用性的来源。
R 型(Register,寄存器型)
用于"寄存器 → 寄存器"的运算:add、sub、and、slt、sll……
31 26 25 21 20 16 15 11 10 6 5 0
┌───────────┬────────┬────────┬────────┬─────────┬───────────┐
│ op (6) │ rs (5) │ rt (5) │ rd (5) │ shamt(5)│ funct (6) │
└───────────┴────────┴────────┴────────┴─────────┴───────────┘
恒为 0 源1 源2 目的 移位量 具体操作| 字段 | 位数 | 含义 |
|---|---|---|
op | 6 | 操作码,R 型恒为 0 |
rs | 5 | 第一个源寄存器编号 |
rt | 5 | 第二个源寄存器编号 |
rd | 5 | 目的寄存器编号 |
shamt | 5 | 移位量(0~31,只有移位指令用) |
funct | 6 | 真正区分是哪个 R 型操作 |
注意 op = 0 这件事:6 位 op 只有 64 种编码,早就用完了。于是 MIPS 把 op = 0 当成"出口",具体操作交给 funct(另 64 种)。这是"用一级编码不够就加二级"的经典手法。
注意 shamt 只有 5 位:所以移位量只能是 0~31,正好够 32 位数用。要移 32 位,得用可变移位指令 sllv。
注意 rs 和 rt 的角色是可变的:对 add 两个都是源;对 sll 只有 rt 是源、rs 必须填 0;对 mult 是"两个源,目的在 HI/LO 而不是 rd",所以 rd 填 0。
I 型(Immediate,立即数型)
用于"有一个 16 位常量"的场合:addi、lw、sw、beq、lui……
31 26 25 21 20 16 15 0
┌───────────┬────────┬────────┬─────────────────────────┐
│ op (6) │ rs (5) │ rt (5) │ imm (16) │
└───────────┴────────┴────────┴─────────────────────────┘
具体操作 源1/基址 目的/源2 16 位立即数| 指令类别 | rs 含义 | rt 含义 | imm 含义 |
|---|---|---|---|
addi $t0,$t1,100 | 源寄存器 $t1 | 目的 $t0 | 立即数 100 |
lw $t0,4($sp) | 基址 $sp | 目的 $t0 | 偏移量 4 |
sw $t0,4($sp) | 基址 $sp | 源 $t0 | 偏移量 4 |
beq $t0,$t1,L | 比较数 1 | 比较数 2 | 相对偏移(单位:字) |
看出来了吗:rt 有时是目的、有时是源;imm 有时是数、有时是偏移、有时是"以字为单位的相对地址"。同一个位域,语义由 op 决定——这是定长格式必然的代价,也是为什么译码器必须先看 op(L1 的译码器电路在这里首次派上真实用场)。
J 型(Jump,跳转型)
只有两条指令用它:j 和 jal。
31 26 25 0
┌───────────┬────────────────────────────────────────────┐
│ op (6) │ target (26) │
└───────────┴────────────────────────────────────────────┘
j=2 / jal=3 目标地址的一部分只有 26 位,怎么表示 32 位地址? 见下一节。
三种格式对比
| R 型 | I 型 | J 型 | |
|---|---|---|---|
| 操作码在哪 | op=0 + funct | op | op |
| 立即数 | 无 | 16 位 | 26 位地址片段 |
| 访存 | 不访存 | 可访存 | 不访存 |
| 典型指令 | add sub and slt sll jr | addi lw sw beq bne lui | j jal |
| 条数占比 | 约 40% | 约 58% | 2 条 |
"只有 3 种格式"带来什么好处? 一句话:译码器可以硬布线实现。
如果是定长指令,所以只需一刀切下 6 位
op,就能决定往哪条通路走——这正是 L1 里"译码器 + MUX"那一节的直接应用。x86 的变长指令做不到这一点:不先解析前缀和长度,连"下一条指令从哪开始"都不知道,必须串行扫描。
三、立即数:符号扩展还是零扩展?
16 位的 imm 要参与 32 位运算,必须"补齐"到 32 位。补齐有两种方式:
| 方式 | 做法 | 用在哪些指令 |
|---|---|---|
| 符号扩展(sign-extend) | 把第 15 位复制到高 16 位 | addi addiu slti lw sw lb sb beq bne |
| 零扩展(zero-extend) | 高 16 位全填 0 | andi ori xori lui |
为什么要分?理由很干净:
- 算术类的立即数要当成补码的负数用(
addiu $sp,$sp,-32)→ 符号扩展,否则-32会变成+65504,栈指针直接飞走; - 逻辑类的立即数只是"一排位"(
andi $t0,$t1,0x00FF是取低 8 位)→ 零扩展,因为位运算里没有"负数"这个概念。
用数字看差别(imm = 0xFFE0):
| 扩展方式 | 结果 | 含义 |
|---|---|---|
| 符号扩展 | 0xFFFFFFE0 = −32 | addiu 想要的效果 |
| 零扩展 | 0x0000FFE0 = +65504 | 若 addiu 用这个,栈就毁了 |
一条记忆口诀:"算术带符号,逻辑不带"。
顺带解决一个问题:怎么把 32 位常量装进寄存器? I 型只有 16 位,装不下。用两条指令:
asm
li $t0, 0x12345678 # 伪指令
# 展开为:
lui $t0, 0x1234 # 把高 16 位装入高半部,低 16 位清零
ori $t0, $t0, 0x5678 # 把低 16 位或进去为什么用 ori 而不是 addi? 因为若低半部 ≥ 0x8000,addi 会被符号扩展成负数(会往前借位污染高半部)。ori 零扩展,只影响低 16 位——这是"零扩展用于逻辑"这条规则的实际用处。
四、分支与跳转:地址怎么算
分支(beq/bne):PC 相对寻址
beq 的 16 位 imm 不是地址,而是"相对偏移",单位是字(word = 4 字节):
三个细节都说清:
- 为什么是
PC + 4?因为取指阶段 PC 已经指向"下一条指令"了(定长 4 字节)。基准是"下一条指令的地址",不是当前地址。 - 为什么单位是字、要左移 2 位?因为指令一定 4 字节对齐,地址的最低两位永远是 0——这两位是浪费的,干脆不存。于是 16 位能表示
条指令的偏移。 - 范围有多大:
字 字节 = ±128 KB。这是真考点:如果你的循环体跨过了 128 KB,一条
beq就不够用了,汇编器必须改写(插入j中转)。C 里的for循环一般不会这么长,但编译器生成的代码里偶尔会撞上。
跳转(j/jal):伪直接寻址
j 只有 26 位目标,怎么补成 32 位?
即:高 4 位照抄 PC+4 的最高 4 位,中间 26 位来自指令(左移 2 位),最低 2 位固定为 0。
| 部分 | 位数 | 来源 |
|---|---|---|
| 高 4 位 | 4 | PC + 4 的 [31:28](当前代码区段) |
| 中 26 + 低 2 | 28 | target × 4(左移 2 位,因为字对齐) |
| 合计 | 32 |
所以 j 只能在本 256 MB 区域(jr(跳到寄存器里存的地址)——这也是为什么 C 编译器实际生成的大多是 jr:它不受区域限制。
跳转指令族小结
| 指令 | 类型 | 目标地址 | 备注 |
|---|---|---|---|
beq/bne | I | PC+4 + imm×4 | 范围 ±128 KB |
j | J | PC+4[31:28] : target×4 | 范围 256 MB 区域 |
jal | J | 同上,且 $ra = PC+4 | 调用函数 |
jr $ra | R | 寄存器里的地址 | 不受范围限制,返回用它 |
blez/bgtz/bltz/bgez | I | 同 beq | 与 $zero 比较 |
五、伪指令:汇编器帮你写的"简写"
MIPS 真正的指令只有几十条,但你写汇编时能用上百个"指令"——多出来的是伪指令(pseudo-instruction),由汇编器展开成真指令。
| 伪指令 | 展开为 | 说明 |
|---|---|---|
nop | sll $0,$0,0 | 空操作(机器码 0x00000000) |
move $t0,$t1 | addu $t0,$zero,$t1 | 用 $zero 当"加法恒等元" |
li $t0,100 | addiu $t0,$zero,100(小)lui+ori(大) | 按数值大小选择展开方式 |
la $t0,label | lui+ori | 装地址(因为地址是 32 位) |
beqz $t0,L | beq $t0,$zero,L | |
bnez $t0,L | bne $t0,$zero,L | |
blt $t0,$t1,L | slt $at,$t0,$t1 + bne $at,$zero,L | 两条真指令(MIPS 没有"小于就跳") |
bgt $t0,$t1,L | slt $at,$t1,$t0 + bne $at,$zero,L | 注意交换了操作数 |
not $t0,$t1 | nor $t0,$t1,$zero | nor x,0 = 取反 |
neg $t0,$t1 | sub $t0,$zero,$t1 | |
mul $t0,$t1,$t2 | mult + mflo | 两条真指令(mult 结果在 HI/LO) |
div $t0,$t1,$t2 | div + mflo | 三操作数形式是伪指令 |
注意 blt/bgt 的展开里出现了 $at——这就是 $at 叫"汇编器保留"的原因:伪指令展开需要临时寄存器,汇编器自己占用 $at,程序员不要碰它。
"伪指令有代价":一条 blt 变成两条真指令,指令条数和周期数都翻倍。所以在性能敏感的循环里,汇编器生成的代码和手写的会有差别——这也是 C 编译器要"优化"的一个具体落点。
示例
例 1:手工编码 6 条 MIPS32 指令(Python 验算)
任务:给下面这段汇编逐条算出 32 位机器码,并反解回来验证。
asm
add $t0, $t1, $t2 # ① R 型
addi $t0, $t1, 100 # ② I 型(算术,符号扩展)
addiu $sp, $sp, -32 # ③ I 型,立即数为负
lw $t0, 4($sp) # ④ I 型(访存)
beq $t0, $zero, +3 # ⑤ I 型(分支,偏移 3 条指令)
j 0x00400000 # ⑥ J 型python
# MIPS32 机器码编解码器(在浏览器/本地都能跑)
REG = {"zero": 0, "at": 1, "v0": 2, "v1": 3, "a0": 4, "a1": 5, "a2": 6, "a3": 7,
"t0": 8, "t1": 9, "t2": 10, "t3": 11, "t4": 12, "t5": 13, "t6": 14, "t7": 15,
"s0": 16, "s1": 17, "s2": 18, "s3": 19, "s4": 20, "s5": 21, "s6": 22, "s7": 23,
"t8": 24, "t9": 25, "k0": 26, "k1": 27, "gp": 28, "sp": 29, "fp": 30, "ra": 31}
R = lambda n: REG[n]
def enc_r(rs, rt, rd, shamt, funct):
"""R 型:op(6)|rs(5)|rt(5)|rd(5)|shamt(5)|funct(6) op 恒为 0"""
return (0 << 26) | (rs << 21) | (rt << 16) | (rd << 11) | (shamt << 6) | funct
def enc_i(op, rs, rt, imm):
"""I 型:op(6)|rs(5)|rt(5)|imm(16)"""
return (op << 26) | (rs << 21) | (rt << 16) | (imm & 0xFFFF)
def enc_j(op, target):
"""J 型:op(6)|target(26),target 是"字地址"(已去掉低 2 位)"""
return (op << 26) | (target & 0x03FFFFFF)
def dec(w):
"""反解:按 op 判断格式并切出各字段"""
op = w >> 26
if op == 0:
rs, rt, rd = (w >> 21) & 31, (w >> 16) & 31, (w >> 11) & 31
sh, fn = (w >> 6) & 31, w & 63
return f"R rs={rs} rt={rt} rd={rd} shamt={sh} funct=0x{fn:02X}"
if op in (2, 3):
return f"J target=0x{(w & 0x03FFFFFF):07X} -> 绝对地址 0x{((w & 0x03FFFFFF) << 2):08X}"
rs, rt, imm = (w >> 21) & 31, (w >> 16) & 31, w & 0xFFFF
signed = imm if imm < 0x8000 else imm - 0x10000
return f"I rs={rs} rt={rt} imm=0x{imm:04X} (符号扩展后 = {signed})"
CASES = [
("add $t0,$t1,$t2", enc_r(R("t1"), R("t2"), R("t0"), 0, 0x20)),
("addi $t0,$t1,100", enc_i(0x08, R("t1"), R("t0"), 100)),
("addiu $sp,$sp,-32", enc_i(0x09, R("sp"), R("sp"), -32)),
("lw $t0,4($sp)", enc_i(0x23, R("sp"), R("t0"), 4)),
("beq $t0,$zero,+3", enc_i(0x04, R("t0"), R("zero"), 3)),
("j 0x00400000", enc_j(0x02, 0x00400000 >> 2)),
]
print("=== 编码 ===")
for asm, w in CASES:
print(f"{asm:22s} 0x{w:08X} {w:032b}")
print("\n=== 反解验证 ===")
for asm, w in CASES:
print(f"0x{w:08X} -> {dec(w)}")预期输出:
=== 编码 ===
add $t0,$t1,$t2 0x012A4020 00000001001010100100000000100000
addi $t0,$t1,100 0x21280064 00100001001010000000000001100100
addiu $sp,$sp,-32 0x27BDFFE0 00100111101111011111111111100000
lw $t0,4($sp) 0x8FA80004 10001111101010000000000000000100
beq $t0,$zero,+3 0x11000003 00010001000000000000000000000011
j 0x00400000 0x08100000 00001000000100000000000000000000
=== 反解验证 ===
0x012A4020 -> R rs=9 rt=10 rd=8 shamt=0 funct=0x20
0x21280064 -> I rs=9 rt=8 imm=0x0064 (符号扩展后 = 100)
0x27BDFFE0 -> I rs=29 rt=29 imm=0xFFE0 (符号扩展后 = -32)
0x8FA80004 -> I rs=29 rt=8 imm=0x0004 (符号扩展后 = 4)
0x11000003 -> I rs=8 rt=0 imm=0x0003 (符号扩展后 = 3)
0x08100000 -> J target=0x0100000 -> 绝对地址 0x00400000逐条拆一遍(挑三条最典型的):
① add $t0,$t1,$t2 = 0x012A4020
| 字段 | 值 | 二进制 |
|---|---|---|
| op | 0 | 000000 |
| rs | $t1 = 9 | 01001 |
| rt | $t2 = 10 | 01010 |
| rd | $t0 = 8 | 01000 |
| shamt | 0 | 00000 |
| funct | 0x20 | 100000 |
| 合计 | 000000 01001 01010 01000 00000 100000 = 0x012A4020 |
③ addiu $sp,$sp,-32 = 0x27BDFFE0
| 字段 | 值 | 二进制 |
|---|---|---|
| op | 0x09 | 001001 |
| rs | $sp = 29 | 11101 |
| rt | $sp = 29 | 11101 |
| imm | −32 → 0xFFE0 | 1111111111100000 |
注意 −32 必须写成 0xFFE0(补码),不是"32 前面加个负号"。补码的表示法在 L2 的 arch/02-integer.md 讲过,这里第一次落到机器码里。
⑥ j 0x00400000 = 0x08100000
target= 绝对地址右移 2 位 =0x00400000 >> 2=0x00100000- 机器码 =
op(2) : 0x00100000=000010 00000100000000000000000000=0x08100000
反解验证了 J 型的"伪直接":指令里只存了 26 位,高 4 位是运行时从 PC 借的——所以同一条 j 指令,在 0x00400000 段执行和在 0x80000000 段执行,跳向的绝对地址是不同的。
例 2:一条 C 语句怎么变成 MIPS 汇编
任务:把一个简单 C 语句翻译成 MIPS32,并数一数指令条数。
c
a = b + c; /* a、b、c 都是全局 int 变量 */MIPS32 是 Load/Store 架构——add 只能读寄存器、写寄存器,不能像 x86 那样"把内存里的东西直接加起来"。所以必须四步:
asm
lw $t0, b # ① 把 b 从内存读进寄存器
lw $t1, c # ② 把 c 读进寄存器
add $t2, $t0, $t1 # ③ 在寄存器里相加
sw $t2, a # ④ 把结果写回内存对比 x86-64:
asm
mov eax, [b] # 或者直接:
add eax, [c] # x86 允许"寄存器 + 内存"一条搞定
mov [a], eax用 Python 把"指令数与字节数"的差别算清楚:
python
# 定长 vs 变长:同一段代码的"代码体积"对比
print("=== a = b + c; 的翻译结果 ===")
mips = ["lw $t0, b", "lw $t1, c", "add $t2, $t0, $t1", "sw $t2, a"]
print(f"MIPS32(load/store,定长 4 字节): {len(mips)} 条 × 4 = {len(mips) * 4} 字节")
for m in mips:
print(" ", m)
print()
x86 = ["mov eax, DWORD PTR b[rip]",
"add eax, DWORD PTR c[rip]",
"mov DWORD PTR a[rip], eax"]
for name, lens in [("紧凑写法", [6, 6, 6]), ("一般写法", [7, 7, 7])]:
print(f"x86-64(可内存直接参与运算,变长){name}: {len(x86)} 条,"
f"总长 {sum(lens)} 字节(每条 6~7 字节不等)")
print("\n=== 结论 ===")
print(f"MIPS32:指令条数更多({len(mips)} vs {len(x86)}),但每条恒 4 字节 → 译码简单")
print("x86-64:指令条数更少、代码更短,但变长 → 译码必须串行,且要处理对齐")
print("\nLoad/Store 架构的代价:一次赋值要 2 次访存;")
print("换来的是:add 指令永远是'寄存器→寄存器',不访存 → 流水线里延迟可预测。")预期输出:
=== a = b + c; 的翻译结果 ===
MIPS32(load/store,定长 4 字节): 4 条 × 4 = 16 字节
lw $t0, b
lw $t1, c
add $t2, $t0, $t1
sw $t2, a
x86-64(可内存直接参与运算,变长)紧凑写法: 3 条,总长 18 字节(每条 6~7 字节不等)
x86-64(可内存直接参与运算,变长)一般写法: 3 条,总长 21 字节(每条 6~7 字节不等)
=== 结论 ===
MIPS32:指令条数更多(4 vs 3),但每条恒 4 字节 → 译码简单
x86-64:指令条数更少、代码更短,但变长 → 译码必须串行,且要处理对齐
Load/Store 架构的代价:一次赋值要 2 次访存;
换来的是:add 指令永远是'寄存器→寄存器',不访存 → 流水线里延迟可预测。这里要抓住的结论:
| 结论 | 为什么重要 |
|---|---|
| Load/Store 架构让"指令条数变多" | 这是 RISC 被批评的地方,但它换来流水线的规整——访存只在 lw/sw,流水线的"访存级"永远同一个位置 |
| 定长 4 字节让译码变简单 | 译码只需切 6 位 op;取指永远读 4 字节、PC 永远 +4 |
| 代码体积上 MIPS 未必吃亏 | 例子里 16 字节 vs 18~21 字节,MIPS 反而更小——因为 x86 每条指令太长 |
例 3:li $t0, 0x12345678 为什么是两条指令
任务:验证"32 位常量装不进 16 位立即数",并算出两条真指令的机器码。
python
REG = {"t0": 8}
R = lambda n: REG[n]
def enc_i(op, rs, rt, imm):
return (op << 26) | (rs << 21) | (rt << 16) | (imm & 0xFFFF)
VAL = 0x12345678
hi, lo = (VAL >> 16) & 0xFFFF, VAL & 0xFFFF
print(f"目标常量 0x{VAL:08X} = 高 16 位 0x{hi:04X} + 低 16 位 0x{lo:04X}")
w1 = enc_i(0x0F, 0, R("t0"), hi) # lui $t0, 0x1234
w2 = enc_i(0x0D, R("t0"), R("t0"), lo) # ori $t0, $t0, 0x5678
print(f"\nlui $t0,0x{hi:04X} 机器码 0x{w1:08X}")
print(f"ori $t0,$t0,0x{lo:04X} 机器码 0x{w2:08X}")
# 逐步模拟:lui 之后寄存器里是什么?ori 之后是什么?
reg = 0
reg = (hi << 16) | 0 # lui:低 16 位清零
print(f"\n执行 lui 后: $t0 = 0x{reg:08X}")
reg = reg | lo # ori:零扩展后或进去
print(f"执行 ori 后: $t0 = 0x{reg:08X} 与目标一致 = {reg == VAL}")
print("\n=== 如果低 16 位 ≥ 0x8000,为什么不能用 addi? ===")
BAD = 0x1234ABCD
imm_lo = BAD & 0xFFFF
signed = imm_lo if imm_lo < 0x8000 else imm_lo - 0x10000
print(f"低 16 位 = 0x{imm_lo:04X} → addi 会符号扩展成 {signed}(负数!)")
wrong = ((BAD >> 16) << 16) + signed
print(f"用 addi 得到 0x{wrong & 0xFFFFFFFF:08X},比目标少了 0x{0x10000:04X} → 高半部被借位污染")
print(f"用 ori 得到 0x{((BAD >> 16) << 16) | imm_lo:08X},与目标 0x{BAD:08X} 一致")预期输出:
目标常量 0x12345678 = 高 16 位 0x1234 + 低 16 位 0x5678
lui $t0,0x1234 机器码 0x3C081234
ori $t0,$t0,0x5678 机器码 0x35085678
执行 lui 后: $t0 = 0x12340000
执行 ori 后: $t0 = 0x12345678 与目标一致 = True
=== 如果低 16 位 ≥ 0x8000,为什么不能用 addi? ===
低 16 位 = 0xABCD → addi 会符号扩展成 -21555(负数!)
用 addi 得到 0x1233ABCD,比目标少了 0x10000 → 高半部被借位污染
用 ori 得到 0x1234ABCD,与目标 0x1234ABCD 一致结论:
- 32 位常量要靠
lui+ori两条指令拼(li只是伪指令,汇编器替你选); - 必须用
ori而不是addi,因为ori零扩展、只动低 16 位;addi符号扩展,当低半部 ≥0x8000时会从高半部借 1,结果差0x10000; - 这个坑在真实编译器里是"常量池加载"的常见 bug 源——记住规则就够:"算术带符号,逻辑不带"。
考点
考点
1. 寄存器编号表(必背)
$0 $zero恒 0、写入无效;$1 $at汇编器保留;$2-$3 $v0-$v1返回值;$4-$7 $a0-$a3参数;$8-$15 $t0-$t7调用者保存;$16-$23 $s0-$s7被调用者保存;$29 $sp栈指针、$30 $fp帧指针、$31 $ra返回地址;HI/LO存乘除结果;CP0 存异常状态(内核态专用)。
2. 三种格式的位域(必考)
| 格式 | 位域(31→0) |
|---|---|
| R | op(6) · rs(5) · rt(5) · rd(5) · shamt(5) · funct(6),op 恒为 0 |
| I | op(6) · rs(5) · rt(5) · imm(16) |
| J | op(6) · target(26) |
- 5 位 → 32 个寄存器;6 位 op → 64 种主操作码;
funct再给 R 型 64 种。 shamt只有 5 位 → 移位量 0~31,要移 32 位得用sllv。
3. 立即数扩展(高频陷阱)
- 算术类(
addi/addiu/slti/lw/sw/lb/sb/beq/bne)→ 符号扩展; - 逻辑类(
andi/ori/xori/lui)→ 零扩展。 - 口诀:"算术带符号,逻辑不带"。
li大常量 =lui+ori,不能用addi(低 16 位 ≥0x8000时会被借位污染)。
4. 地址计算(必考的两个公式)
- 分支(
beq/bne): ,范围 ±128 KB(目 标 字)。 基准是PC+4(因为取指已完成),不是 PC。 - 跳转(
j/jal): ,只能在同一 256 MB 区域(目 标 )内跳。 jr $ra不受区域限制——这就是 C 编译器实际多用jr的原因。
5. 伪指令展开
| 伪指令 | 真指令 |
|---|---|
nop | sll $0,$0,0(机器码 0x00000000) |
move $a,$b | addu $a,$zero,$b |
blt $a,$b,L | slt $at,$a,$b + bne $at,$zero,L(两条) |
bgt $a,$b,L | slt $at,$b,$a + bne $at,$zero,L(操作数交换) |
neg $a,$b | sub $a,$zero,$b |
mul $a,$b,$c | mult + mflo(两条) |
6. MIPS 无标志位
- 没有
ZF/CF/OF(与 x86 的根本区别);比较靠slt/sltu/slti生成 0/1,条件跳转只认$zero。 - 好处:消除标志位这种"全局隐式依赖",流水线不用为它停等。
7. 定长 vs 变长(常考判断)
- 定长(MIPS):译码只需切 6 位
op、PC 恒+4、流水线友好;代价是指令条数多、代码密度低。 - 变长(x86):代码密度高,但译码要串行解析、处理对齐与前缀,流水线前端复杂。
- 判断口径:"RISC 不一定代码更小,也不一定执行更快;它换来的是规整与可预测。"
小结
- ISA 是软硬件契约:它规定"能做什么"(指令、格式、寄存器、寻址、异常),微架构决定"怎么做"。同一份 ISA 可以有无数种实现——这是 L2 与 L3 分工的根据。
- MIPS32 的四条条款:定长 32 位、3 种格式、32 个通用寄存器、Load/Store 架构。
- 32 个寄存器各有约定:
$zero恒 0、$a0-$a3传参、$v0-$v1返回、$t调用者保存 /$s被调用者保存、$sp/$fp/$ra支撑函数调用。HI/LO存乘除结果,MIPS 没有标志位。 - R/I/J 三种格式的位域必须能默写:R 型
op=0靠funct细分 64 种操作;shamt5 位只能移 0~31。 - 立即数扩展:算术符号扩展、逻辑零扩展("算术带符号,逻辑不带")。大常量 =
lui+ori。 - 地址公式:分支
(±128 KB);跳转 (256 MB 区域)。 - 伪指令是汇编器的简写,展开后可能变成两条真指令,还要占用
$at。
回到主线:ISA 定下了"机器有哪些指令、指令长什么样"。但这些指令怎么访问内存,还没说。
lw $t0, 4($sp)里那个4($sp)到底是什么?为什么不是写一个完整的 32 位地址?一个int数组a[5]怎么变成一条lw?一个struct的字段偏移是怎么算出来的?大小端(big-endian / little-endian)为什么会影响lb和lw的结果?
这就是下一篇的内容:寻址方式与数据传送。
下一篇:内存寻址与数据传送指令
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。