L02 RISC-V haskell and Binary Notation
MIT 6.004 Spring 2019 L02 RISC-V haskell,由Silvina Hanono Wachman讲述。
主要内容包括RISC-V汇编语言的继续讨论和二进制表示的深入探讨。
RISC-V汇编语言讨论
- 介绍了RISC-V指令集架构(ISA),这是软件和硬件之间的一种契约,规定了处理器可以进行的所有操作、可用的存储空间以及如何利用这些硬件资源。
- 讨论了存储结构,包括32位宽的寄存器文件和主存储器,以及用于访问常数和内存中数据的指令格式。
- 详细说明了如何执行计算指令、控制流指令(条件分支和无条件跳转)以及如何进行加载和存储操作,包括使用基址加偏移量的方法来访问内存。
指令和操作
- 通过示例详细讲解了RISC-V汇编语言中包括算术运算、逻辑运算、移位操作、加载和存储数据、以及如何实现条件分支和跳转等指令的使用。
- 本节只做简要讲解,更多内容在后续章节。
- 强调了汇编语言中使用的二进制和十六进制表示法,以及如何将高级语言中的表达式和控制流结构翻译成汇编指令。
课程结束部分
- 课程的最后部分讨论了使用二进制表示负数的方法,即二进制补码表示法,并指出所有汇编指令中使用的常数都采用二进制补码形式。
- 虽然时间有限,未能覆盖所有内容,但计划在下次课堂讨论中继续深入讲解。
分页知识点
微处理器的组件
- 寄存器文件(Register File):存储临时数据,可以快速访问。
- 算术逻辑单元(ALU):执行算术和逻辑操作。
- 主内存(Main Memory):存储程序和数据,由地址和数据组成,每个内存位置是 32 位宽。
RISC-V 处理器存储
- 主内存中的每个位置是 32 位宽(4 字节)。
- 寄存器:32 个通用寄存器,每个寄存器是 32 位宽。
- x0 寄存器:硬编码为 0,任何写入 x0 的操作都不会改变其值。
RISC-V 指令集架构(ISA)
- ISA 的定义:软件和硬件之间的契约,定义操作和存储位置的功能描述。
- RISC-V ISA:一种来自伯克利的新开放 ISA,包括不同的数据宽度和指令。
- 指令类型:
- 计算指令:在寄存器上执行算术和逻辑操作。
- 加载和存储指令:在寄存器和主内存之间移动数据。
- 控制流指令:更改指令执行顺序,支持条件语句和循环。
计算指令
-
算术、比较、逻辑和移位操作:
-
寄存器-寄存器指令:使用两个源操作数寄存器和一个目标寄存器。
- 算术操作:加法(
add
)、减法(sub
) - 比较:设置小于(
slt
)、设置小于无符号(sltu
) - 逻辑:与(
and
)、或(or
)、异或(xor
) - 移位:逻辑左移(
sll
)、逻辑右移(srl
)、算术右移(sra
)
- 算术操作:加法(
-
例子:
add x3, x1, x2
表示 x3 = x1 + x2。slt x3, x1, x2
表示如果 x1 < x2 则 x3 = 1,否则 x3 = 0。and x3, x1, x2
表示 x3 = x1 & x2。sll x3, x1, x2
表示 x3 = x1 « x2。
二进制运算
- 所有值都是二进制:
- 例如:x1 = 00101; x2 = 00011
add x3, x1, x2
表示 x3 = x1 + x2。与十进制中的 5 + 3 = 8 相对应,二进制计算结果为 01000。sll x3, x1, x2
表示将 x1 向左移动 x2 位,结果为 01000。
二进制模运算
- 如果我们使用固定数量的位进行计算,加法和其他操作可能会产生超出范围的结果。这称为溢出。
- 通常的做法是忽略额外的位,采用模运算。对于 N 位数字,等同于执行
mod 2^N
。 - 视觉上,可以看到数值在最大和最小值之间“环绕”。
十六进制表示法
- 长二进制串手工转换容易出错,因此常用高基数(radix)表示法,十六进制是一种流行的选择。
- 十六进制使用基数16,每4个相邻的二进制位编码为一个十六进制数字。
- 示例:二进制串 011110100000 转换为十六进制为 0x7D0。
寄存器-立即数指令
- 一种操作数来自寄存器,另一个是编码在指令中的小常数。
- 格式:操作 目标寄存器,源寄存器1,常数
addi x3, x1, 3
// x3 = x1 + 3andi x3, x1, 3
// x3 = x1 & 3slli x3, x1, 3
// x3 = x1 « 3
- 注意没有subi指令,而是使用负数常量。
addi x3, x1, -3
// x3 = x1 - 3
- 格式:操作 目标寄存器,源寄存器1,常数
复合计算
- 执行 a = ((b+3) » c) - 1;
- 将复杂表达式分解为基本计算。我们的指令只能指定两个源操作数和一个目标操作数(也称为三地址指令)。
- 假设 a, b, c 存在寄存器 x1, x2, 和 x3 中,分别使用 x4 作为 t0, x5 作为 t1。
t0 = b + 3;
t1 = t0 >> c;
a = t1 - 1;
控制流指令
- 根据条件执行不同的操作:
- if a < b: c = a + 1
- else: c = b + 2
- 需要条件分支指令:
- 格式:条件 源寄存器1 比较 源寄存器2,标签
- 如果比较结果为True,则跳转到标签处执行,否则按顺序执行程序。
无条件控制指令:跳转
-
jal
:无条件跳转并链接- 例子:
jal x3, label
- 跳转目标指定为标签,标签被编码为当前指令的偏移量
- 链接(在下次讲座中讨论):存储在 x3 中
- 例子:
-
jalr
:通过寄存器和链接进行无条件跳转- 例子:
jalr x3, 4(x1)
- 跳转目标指定为寄存器值加上常数偏移量
- 例子:跳转目标 = x1 + 4
- 可以跳转到任何 32 位地址 - 支持长跳转
- 例子:
以上解释中,“无条件跳转并链接”(jal
)是一种跳转指令,用于无条件地将程序的执行流跳转到指定的地址,并将下一条指令的地址保存在寄存器中,通常用于函数调用。 “通过寄存器和链接进行无条件跳转”(jalr
)是一种变体,允许跳转到一个基于寄存器值和常数偏移量计算的地址。这些都是 RISC-V 指令集中控制程序流的基础操作。
在内存中执行计算
当我们需要对存储在内存中的值执行计算时,我们会用到加载(load)和存储(store)指令。以计算 a = b + c
为例,我们会按以下步骤进行:
- 从内存地址 Mem[b] 加载 b 的值到寄存器 x1。
- 从内存地址 Mem[c] 加载 c 的值到寄存器 x2。
- 将寄存器 x1 和 x2 中的值相加,并将结果存储到寄存器 x3。
- 将寄存器 x3 的值存储回内存地址 Mem[a]。
例如,如果 b 和 c 分别位于内存地址 0x4 和 0x8,而我们想要将它们的和存储在地址 0x10,对应的汇编代码如下:
lw x1, 0x4(x0)
lw x2, 0x8(x0)
add x3, x1, x2
sw x3, 0x10(x0)
RISC-V 加载和存储指令
由于一些很好的技术原因,RISC-V 指令集架构不允许我们直接将内存地址写入指令。我们需要以 <基址, 偏移量>
的形式指定地址:
- 基址始终存储在一个寄存器中。
- 偏移量是一个小常数。
- 格式示例:
lw dest, offset(base)
或sw src, offset(base)
。
汇编示例中的行为如下:
lw x1, 0x4(x0) // x1 <- load(Mem[x0 + 0x4])
lw x2, 0x8(x0) // x2 <- load(Mem[x0 + 0x8])
add x3, x1, x2 // x3 <- x1 + x2
sw x3, 0x10(x0) // store(Mem[x0 + 0x10]) <- x3
程序求和数组元素
假设我们有一个求和函数,计算数组 a
的所有元素之和,其中 a[0]
到 a[n-1]
。假设基地址(地址 100)已经加载到寄存器 x10 中,对应的汇编代码如下:
常量和指令编码限制
- 指令被编码为 32 位。
- 需要指定操作码(10位)。
- 需要指定2个源寄存器(10位)或1个源寄存器加上一个小常数。
- 需要指定1个目的寄存器(5位)。
- 指令中的常数必须小于12位;更大的常数需要存储在内存或寄存器中并显式使用。
- 正是由于这个限制,我们从不在指令中直接指定内存地址。
伪指令
- 伪指令是简化汇编编程的别名,它们映射到实际的汇编指令。
- 例如,
mv x2, x1
伪指令等价于addi x2, x1, 0
汇编指令。
- 例如,
伪指令 | 等效汇编指令 |
---|---|
mv x2, x1 | addi x2, x1, 0 |
ble x1, x2, label | bge x2, x1, label |
bgez x1, label | bge x1, x0, label |
bnez x1, label | bne x1, x0, label |
j label | jal x0, label |
负数的编码
- 表达负数的方式包括符号大小表示法和二进制补码表示法。
符号大小表示法
- 我们使用符号大小表示法来表达十进制数,用 “+” 和 “-“ 分别代表正负号。
- 例如:对于二进制数
11111110000
,0
表示 “+”,1
表示 “-“,这表示 -2000。 - 这种编码可能会有哪些问题?会有两种表示方式表示 0(+0 和 -0),而且为加减法设计电路比无符号数复杂。
寻找更好的编码方法
- 你能否简单地重新标记一些数字,以保留模数运算的好属性?答案是肯定的。
例如:
-1
用111
表示-2
用110
表示-3
用101
表示-4
用100
表示- 这就是所谓的二进制补码编码。
二进制补码编码
- 在二进制补码编码中,N 位数的最高位用于表示符号(正数为 0,负数为 1)。
- 负数的最高位是“1”。
- 最负数是
10...000 -> -2^N-1
- 如果所有位都是 1,则是
-1
。 - 指令中编码的常量使用二进制补码编码。
Take home
- 用 6 位二进制补码表示 -4。
- 将下面的代码片段翻译成 RISC-V 汇编:
sum = 0;
for (i = 0; i < 10; i++) {
sum = sum + i;
}
end
裸机编程的针对补充
启动代码:系统的自举过程( 启动入口和初始化代码)
在深入探索RISC-V指令集和二进制表示法后,我们在这里补充讨论计算机系统开始运行时最先发生的过程——启动。启动代码,或称为自举代码(Bootstrap Code),是放置在预定义内存地址的一组指令,负责初始化硬件并将系统带入一个已知的状态,从而允许加载和执行更复杂的程序,比如操作系统。
处理器重置行为
当电源打开或系统被重置时,处理器执行的第一个操作是将程序计数器(PC)设置为一个预定的启动地址。在RISC-V架构中,这个地址通常是0x1000
。从这个地址开始执行的代码负责建立系统运行所需的环境。
; 例子:RISC-V启动地址跳转指令
; 假定0x1000是启动地址
lui t0, %hi(0x1000) ; 加载上位地址到临时寄存器t0
jr t0 ; 跳转到该地址开始执行代码
寄存器初始化
一旦PC设置为启动地址,接下来要执行的代码通常包括初始化寄存器,尤其是堆栈指针(SP)。堆栈是程序中函数调用和返回时存储临时变量和返回地址的区域。
; 例子:初始化堆栈指针
li sp, %top_of_stack ; 将堆栈指针设置为堆栈的顶部
清零.bss段
启动代码还负责清零.bss段,这是程序未初始化全局变量的内存区域。在程序开始执行前,这些变量必须被清零以避免不确定性。
; 例子:清零.bss段
la t0, _sbss ; 加载.bss段起始地址到t0
la t1, _ebss ; 加载.bss段结束地址到t1
bge t0, t1, done_clear ; 如果t0不小于t1,跳到清零结束
clear_loop:
sw zero, 0(t0) ; 将零存到t0指向的地址
addi t0, t0, 4 ; t0指向下一个字
blt t0, t1, clear_loop; 如果t0仍小于t1,继续循环
done_clear:
在完成了寄存器和内存的初始化之后,启动代码会继续加载操作系统或其他应用程序,这样系统就准备好执行更复杂的任务了。
扩展
处理器重置行为
在讨论自举过程的第一步之前,我们首先了解一下什么是自举(Bootstrapping)过程。简单来说,自举是计算机启动并加载操作系统的过程。这个过程开始于一个固定的点,即处理器重置行为。
当电源被打开或计算机系统被重置时,处理器(CPU)必须从一个已知的状态开始执行指令。这个状态是由处理器的设计者预先确定的。对于大多数微处理器来说,这通常意味着将程序计数器(PC)设置到一个特定的地址,这个地址被编程为指向存储有启动代码的内存位置。
在RISC-V架构中,这个预定的启动地址通常设定为0x1000
。这意味着一旦电源开启或系统重置,处理器会自动将其程序计数器(PC)指向地址0x1000
,然后开始执行那里的指令。因此,启动代码必须放置在这个地址,以便处理器能够找到并执行它。
这段启动代码,有时也被称为初始化代码或固件,它的任务是建立一个基本的运行环境,为操作系统的加载和运行做好准备。这通常包括初始化内存控制器、设置栈空间、初始化硬件设备,并可能包括从非易失性存储器(如硬盘、固态硬盘或闪存)中加载操作系统到内存中。
在这个过程中,启动代码作为软硬件之间的桥梁,执行必要的硬件配置,以及为软件提供执行的条件。这个过程至关重要,因为任何错误都可能导致系统无法启动,或在后续的操作中出现不可预知的行为。
为了确保这段代码能够被正确执行,硬件制造商和软件开发者需要紧密合作,确保启动代码能够与特定硬件紧密配合,这就需要对硬件的细节有深入的理解,包括但不限于处理器的启动序列、内存映射、硬件初始化协议等。在RISC-V开放架构中,这些内容可能会有所不同,因此开发者在编写启动代码时,必须参考特定的硬件实现细节来进行
以下是一个示例,演示了如何在RISC-V架构中,使用汇编语言来处理重置后的行为:
; 示例:RISC-V的启动地址跳转指令
; 假设0x1000是启动地址
lui t0, %hi(0x1000) ; 将启动地址的高位加载到临时寄存器t0中
jr t0 ; 跳转到t0寄存器指向的地址,也就是0x1000,开始执行启动代码
在这个例子中,lui(加载上位立即数)指令用于将地址0x1000的高20位加载到寄存器t0中。紧接着,jr(跳转寄存器)指令使用t0寄存器中的地址值作为跳转目的地。当这条跳转指令执行后,程序计数器(PC)将指向0x1000,并开始执行该地址处的代码
寄存器初始化
在程序计数器(PC)被设置到启动地址之后,自举过程的下一个关键步骤是寄存器的初始化。这些寄存器包括通用寄存器和特殊功能寄存器,它们对于处理器开始执行代码至关重要。在这些寄存器中,堆栈指针(SP)的初始化尤其重要,因为它指向了堆栈的顶部,堆栈是一种特殊的数据结构,用于存储临时数据,比如函数调用的参数、局部变量和返回地址。
在许多架构中,包括RISC-V,当一个函数被调用时,它的返回地址和一些必要的寄存器值会被推送(push)到堆栈上。当函数返回时,这些值会被弹出(pop)以恢复到函数被调用前的状态。因此,堆栈指针的正确设置是函数调用能够正常工作的基础。
以下是RISC-V汇编语言中设置堆栈指针的示例:
; 示例:初始化堆栈指针
li sp, %top_of_stack ; 将堆栈指针sp初始化到堆栈的顶部
在这段代码中,li
(加载立即数)指令被用来将堆栈的顶部地址加载到堆栈指针sp
中。这个地址通常是一个高地址,因为在RISC-V架构中,堆栈是向下增长的,也就是说,随着数据被推入堆栈,堆栈指针会向更低的地址移动。
初始化寄存器的过程还包括设置其他重要寄存器的初始值,例如程序状态寄存器,它控制处理器的模式(比如用户模式和超级用户模式)和中断使能。
这些初始设置确保了程序能够在一个干净、一致的环境中执行,无论之前系统的状态如何。这就是为什么自举过程中寄存器初始化是如此重要的原因。通过对硬件有深入的理解,软件开发者可以编写出更可靠的启动代码,这在嵌入式系统和操作系统的开发中尤为关键。
清零.bss段
在完成堆栈指针初始化之后,自举过程的另一个关键步骤是清零.bss段。在程序的内存空间中,.bss段用于存储未初始化的全局变量和静态变量。在程序开始执行前,这些变量的内存区域必须被清零。这是因为在编程语言中,如C和C++,未初始化的全局和静态变量的默认值应该是零。确保这一点对于避免程序行为的不确定性至关重要。
以下是在RISC-V架构中使用汇编语言来清零.bss段的步骤:
; 示例:清零.bss段
la t0, _sbss ; 将.bss段的起始地址加载到寄存器t0
la t1, _ebss ; 将.bss段的结束地址加载到寄存器t1
bge t0, t1, done_clear ; 如果起始地址不小于结束地址,跳过清零过程
clear_loop:
sw zero, 0(t0) ; 将0写入t0当前指向的地址,zero寄存器始终保持为0
addi t0, t0, 4 ; 将t0增加4,即指向下一个32位字
blt t0, t1, clear_loop; 如果还没有到达结束地址,继续循环
done_clear:
; 清零结束,继续后续初始化过程
在这个过程中,我们首先使用la
(加载地址)指令来获取.bss段的起始和结束地址。然后,使用一个循环结构来遍历这段内存,使用sw
(存储字)指令将每个字的内容设置为0。这里使用了RISC-V中的zero
寄存器,它是一个特殊的寄存器,用于提供常数0。
此清零过程确保了所有的全局变量和静态变量在程序开始执行任何其他代码前都有了一个确定的初始状态。这对于程序的稳定运行和预期的行为是必要的,尤其是在嵌入式系统和操作系统的上下文中,这些系统通常需要从一个确定的状态开始执行。
通过这种方式,启动代码将系统内存中的关键部分准备就绪,创建了一个可预测和可控的执行环境,为加载操作系统或其他应用程序打下坚实的基础。
附:RV32I指令集
运算指令(Arithmetic Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
ADD | Add | 加法 |
SUB | Subtract | 减法 |
ADDI | Add Immediate | 加立即数 |
逻辑指令(Logical Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
XOR | XOR | 异或 |
XORI | XOR Immediate | 立即数异或 |
OR | OR | 或 |
ORI | OR Immediate | 立即数或 |
AND | AND | 与 |
ANDI | AND Immediate | 立即数与 |
移位指令(Shift Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
SLL | Shift Left Logical | 逻辑左移 |
SLLI | Shift Left Logical Immediate | 逻辑左移立即数 |
SRL | Shift Right Logical | 逻辑右移 |
SRLI | Shift Right Logical Immediate | 逻辑右移立即数 |
SRA | Shift Right Arithmetic | 算术右移 |
SRAI | Shift Right Arithmetic Immediate | 算术右移立即数 |
分支指令(Branch Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
BEQ | Branch if Equal | 相等则分支 |
BNE | Branch if Not Equal | 不相等则分支 |
BLT | Branch if Less Than | 小于则分支 |
BGE | Branch if Greater or Equal | 大于等于则分支 |
BLTU | Branch if Less Than Unsigned | 无符号小于则分支 |
BGEU | Branch if Greater or Equal Unsigned | 无符号大于等于则分支 |
跳转指令(Jump Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
JAL | Jump And Link | 跳转并链接 |
JALR | Jump And Link Register | 寄存器跳转并链接 |
加载和存储指令(Load and Store Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
LB | Load Byte | 加载字节 |
LH | Load Halfword | 加载半字 |
LW | Load Word | 加载字 |
LBU | Load Byte Unsigned | 加载无符号字节 |
LHU | Load Halfword Unsigned | 加载无符号半字 |
SB | Store Byte | 存储字节 |
SH | Store Halfword | 存储半字 |
SW | Store Word | 存储字 |
环境指令(Environment Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
ECALL | Environment Call | 环境调用 |
EBREAK | Environment Break | 环境断点 |
FENCE | Fence | 屏障 |
内存访问指令(Memory Access Instructions)
缩写 | 原文 | 中文翻译 |
---|---|---|
LUI | Load Upper Immediate | 加载高位立即数 |
AUIPC | Add Upper Immediate to PC | 加载高位立即数加PC |
伪指令
伪指令(pseudo-instructions)是汇编语言中用于简化编程的指令,它们实际上并不是处理器的机器指令,而是编译器或汇编器将其转换为一个或多个实际的机器指令。以下是RV32I指令集中的一些常用伪指令,包括它们的原文和中文翻译:
RV32I 伪指令表
伪指令 | 转换后的实际指令 | 中文翻译 |
---|---|---|
li rd, imm | lui rd, imm[31:12]; addi rd, rd, imm[11:0] | 加载立即数到寄存器 |
mv rd, rs | addi rd, rs, 0 | 寄存器间的移动 |
not rd, rs | xori rd, rs, -1 | 取反 |
neg rd, rs | sub rd, x0, rs | 取负 |
nop | addi x0, x0, 0 | 空操作 |
ret | jalr x0, ra, 0 | 返回 |
call offset | auipc ra, offset[31:12]; jalr ra, offset[11:0] | 调用函数 |
tail offset | auipc x0, offset[31:12]; jalr x0, offset[11:0] | 尾调用 |
j offset | jal x0, offset | 无条件跳转 |
jr rs | jalr x0, rs, 0 | 寄存器无条件跳转 |
seqz rd, rs | sltiu rd, rs, 1 | 等于零设置 |
snez rd, rs | sltu rd, x0, rs | 不等于零设置 |
sltz rd, rs | slt rd, rs, x0 | 小于零设置 |
sgtz rd, rs | slt rd, x0, rs | 大于零设置 |
beqz rs, offset | beq rs, x0, offset | 等于零则分支 |
bnez rs, offset | bne rs, x0, offset | 不等于零则分支 |
blez rs, offset | bge x0, rs, offset | 小于等于零则分支 |
bgez rs, offset | bge rs, x0, offset | 大于等于零则分支 |
bgtz rs, offset | blt x0, rs, offset | 大于零则分支 |
bltz rs, offset | blt rs, x0, offset | 小于零则分支 |
伪指令解释
- li rd, imm:将立即数
imm
加载到寄存器rd
。这通常被转换为lui
和addi
指令的组合。 - mv rd, rs:将寄存器
rs
的值复制到寄存器rd
,实际是addi rd, rs, 0
。 - not rd, rs:对寄存器
rs
的值取反,并将结果存入寄存器rd
,实际是xori rd, rs, -1
。 - neg rd, rs:将寄存器
rs
的值取负,并将结果存入寄存器rd
,实际是sub rd, x0, rs
。 - nop:无操作指令,实际是
addi x0, x0, 0
。 - ret:从函数返回,实际是
jalr x0, ra, 0
。 - call offset:调用函数,实际是
auipc ra, offset[31:12]; jalr ra, offset[11:0]
。 - tail offset:尾调用,实际是
auipc x0, offset[31:12]; jalr x0, offset[11:0]
。 - j offset:无条件跳转,实际是
jal x0, offset
。 - jr rs:寄存器无条件跳转,实际是
jalr x0, rs, 0
。 - seqz rd, rs:如果
rs
等于零,rd
置1,否则置0,实际是sltiu rd, rs, 1
。 - snez rd, rs:如果
rs
不等于零,rd
置1,否则置0,实际是sltu rd, x0, rs
。 - sltz rd, rs:如果
rs
小于零,rd
置1,否则置0,实际是slt rd, rs, x0
。 - sgtz rd, rs:如果
rs
大于零,rd
置1,否则置0,实际是slt rd, x0, rs
。 - beqz rs, offset:如果
rs
等于零,跳转到偏移量offset
,实际是beq rs, x0, offset
。 - bnez rs, offset:如果
rs
不等于零,跳转到偏移量offset
,实际是bne rs, x0, offset
。 - blez rs, offset:如果
rs
小于或等于零,跳转到偏移量offset
,实际是bge x0, rs, offset
。 - bgez rs, offset:如果
rs
大于或等于零,跳转到偏移量offset
,实际是bge rs, x0, offset
。 - bgtz rs, offset:如果
rs
大于零,跳转到偏移量offset
,实际是blt x0, rs, offset
。 - bltz rs, offset:如果
rs
小于零,跳转到偏移量offset
,实际是blt rs, x0, offset
。
这些伪指令使得编写和阅读汇编代码更加简便,并且编译器会将它们转换成实际的机器指令以执行。