RISC-V-处理器架构笔记-全-

RISC-V 处理器架构笔记(全)

001:课程介绍与寄存器

概述

在本节课中,我们将要学习RISC-V汇编语言编程。课程将从RISC-V处理器架构的基础知识开始,详细介绍其寄存器,为后续深入学习指令集打下基础。

汇编语言与机器代码

首先,我们需要区分汇编语言和机器代码。机器代码是存储在计算机内存中的二进制指令,可以直接由硬件执行。汇编代码则是人类可读的,理想情况下,每条汇编语言指令都对应一条机器代码指令。

每种处理器架构都有其独特的机器代码和相应的汇编语言。本课程将专注于RISC-V架构,并且不预设您对其他架构有任何了解。

RISC-V寄存器

了解任何处理器内核的第一个问题是:它有哪些寄存器?对于RISC-V,它有32个通用寄存器。

RISC-V有两个主要变体:RV32RV64。在RV32中,寄存器大小为32位;在RV64中,寄存器大小为64位。这些是通用寄存器,意味着对于任何使用寄存器的指令,我们可以提供这32个寄存器中的任意一个。就硬件而言,除了一个我们稍后会讨论的例外,没有任何寄存器具有特殊功能。

当然,存在一些编程约定,因此程序员会将某些寄存器用于特定用途。

此外,还有一个程序计数器,它给出正在执行的指令的地址。还有一些所谓的控制和状态寄存器,用于控制处理器内核的操作。

数据大小术语

在继续之前,我们先回顾一下数据大小的基本术语。一个字节当然是8位。一个字是4字节或32位。我们还可以将半字定义为16位,双字定义为64位。

以下是一些不同大小的十六进制示例:

  • 字节:0x12
  • 半字:0x1234
  • 字:0x12345678
  • 双字:0x123456789ABCDEF0

那么,寄存器的大小是多少?这取决于我们处理的是RV32还是RV64。在RV32中,每个寄存器包含一个字(4字节)的数据;在RV64中,寄存器大小为64位,包含8字节的数据。

整数表示

寄存器中的数据可以表示什么?它可能是一个整数,可以用有符号表示法或无符号表示法表示;也可能是一个字符(特别是当我们查看单个字节时);或者是一串比特位。

表示整数有两种方式:有符号表示和无符号表示。这里展示了字节、半字、字和双字整数的范围。

以字大小的整数为例。对于32位,有符号表示的范围大约是从-20亿到+20亿。对于无符号表示,我们消除了负数,并将最大值增加到略高于40亿。

通常,应尽可能使用有符号表示。消除负数是反数学的,但有时你可能试图将整数值塞入一个特别小的空间(如字节或半字),这时消除负数以获得稍大的正数可能是有意义的。

无符号数也用于地址、指针以及实际上并非整数表示的位向量。这在32位字大小的值中尤为常见。对于双字,有符号值的范围已经非常巨大,例如,你可以用微米表示地球到太阳的距离。因此,我认为没有理由使用无符号双字。

RISC-V指令集

RISC-V有两个指令集:完整大小指令集和压缩指令集。

完整大小指令是强制性的,始终包含在任何RISC-V内核中。每条完整大小指令都是一个字,即32位大小。无论我们看的是32位处理器还是64位处理器,指令长度都是32位。每个汇编语言程序员都必须学习完整大小指令集,这也是本视频的重点。

至于压缩指令集,它是可选的,不会包含在一些RISC-V处理器中。压缩指令可以被视为最常见完整大小指令的缩写或简写形式。每条压缩指令的大小是完整大小指令的一半,即16位或两个字节。每条压缩指令都等价于一条完整大小指令,但并非所有完整大小指令都有压缩版本。包含压缩指令的目的是为了提高执行速度。

就汇编语言编程而言,压缩指令很容易识别,因为它们都以 C. 开头。例如,add 指令的压缩版本如下所示:

add t0, t1, t2    # 完整指令
C.add t0, t1      # 压缩指令 (t0 = t0 + t1)

程序计数器

程序计数器不是一个通用寄存器。它保存要执行的下一条指令的地址。因此,当指令一条接一条执行时,程序计数器会递增,内核使用程序计数器作为发送到内存单元以获取指令的地址。

程序计数器的大小与通用寄存器的大小相同。因此,对于RV32内核,它是32位(4字节);对于RV64 RISC-V内核,它是64位(8字节)。实际上,如果内存大小不需要所有位,程序计数器可能比这小一些,高位可能实际上并未实现,这在64位系统中尤其如此。

通用寄存器详解

除了程序计数器,我们还有32个通用寄存器,其名称为 x0x1 直到 x31

以下是我们的第一个RISC-V指令示例。这是一条 add 指令,在这个例子中,它将寄存器 x6x7 的内容相加,并将结果放入寄存器 x20

add x20, x6, x7   # x20 = x6 + x7

像每条指令一样,它以操作码开头,本例中是 add,然后是指令的一些操作数,以及一个可选的注释。

在汇编语言编程中,每条指令都在单独的一行上,每行最多有一条指令。我们还可以在指令前面有一个可选的标签,它为 add 指令将被放置的地址提供一个符号名称。标签由冒号标识,冒号本身不是标签的一部分。

更正式地说,以下是汇编语言程序每一行的语法:

[标签:] 操作码 [操作数] [# 注释]

我们看到了一个可选的标签。如果一行包含一条指令,它必须有一个操作码,有些指令没有操作数,因此操作数是可选的。我们还可以有一个可选的注释,它以井号 # 开头,直到行尾。

如前所述,有32个通用寄存器,名称为 x0x31。这些寄存器中的每一个都有一个备用名称。汇编语言程序员可以使用以 X 开头的名称或这些备用名称之一。可能建议您使用备用名称,因为它们更具描述性。

以下是寄存器及其备用名称的列表:

  • x0 / zero: 零寄存器
  • x1 / ra: 返回地址寄存器
  • x2 / sp: 栈指针
  • x3 / gp: 全局指针
  • x4 / tp: 线程指针
  • x5 / t0: 临时寄存器 0
  • x6-x7 / t1-t2: 临时寄存器 1-2
  • x8 / s0 / fp: 保存寄存器 0 / 帧指针
  • x9 / s1: 保存寄存器 1
  • x10-x17 / a0-a7: 参数寄存器 0-7
  • x18-x27 / s2-s11: 保存寄存器 2-11
  • x28-x31 / t3-t6: 临时寄存器 3-6

现在,我们将更详细地查看这些寄存器中的每一个,看看它们是如何使用的。

零寄存器 (x0)

寄存器 x0 的备用名称是 zero。这个寄存器特别有趣,因为它是唯一一个非通用的寄存器。它被硬件特殊对待,特别是它被硬连线为始终包含值 0。当你在任何指令中需要值 0 时,这个寄存器可以用作源寄存器,这通常非常有用。它也可以在指令中用作目标寄存器,当你不需要结果时,在这种情况下,指令的结果被简单地丢弃。

返回地址寄存器 (x1/ra)

下一个要看的寄存器是返回地址寄存器,备用名称是 ra。它用于调用和返回指令。在RISC-V中,调用指令实际上并不将返回地址压入内存中的栈。相反,它将返回地址保存在 ra 寄存器中,然后返回指令可以简单地跳转到保存在这个 ra 寄存器中的地址,而无需访问内存。

这样做的好处是调用和返回指令不需要访问内存,因此可以执行得相当快。缺点是当你有嵌套调用时,即一个函数调用另一个函数。在这种情况下,你将不得不插入额外的指令来从内存保存和恢复返回地址,也就是说,你必须将其压入栈并从栈中弹出。

但是,一个叶子函数(即不调用任何其他函数的函数)可以非常快速地被调用和返回。这是一个很大的好处。

栈指针 (x2/sp)

在许多计算机体系结构中,有一个专用的硬件寄存器指向内存中的栈,但RISC-V并非如此。在RISC-V中,我们使用一个通用寄存器,按照约定,它将是寄存器 x2。我们给它的名称 sp 支持这个编程约定。但实际上,我们可以使用任何通用寄存器作为栈,并且我们可以将寄存器 x2 用于我们想要的任何其他计算。这只是一个编程约定。

但我想提一下,对于压缩指令集,有些指令假定 x2 将被用作栈指针。例如,有一条加载字的指令,该指令有一个用于目标寄存器的字段和一个来自栈的偏移量,但使用 x2 这一事实在指令中是隐含的。由于我们将只关注完整大小的指令,因此按照约定,x2 将始终被程序员用于指向栈,但这仅仅是约定。

参数寄存器 (x10-x17/a0-a7)

为了向函数传递参数,按照约定,有8个寄存器专门用于此,它们的备用名称是 a0a7。因此,只要我们有一个具有八个或更少参数的函数,这些参数可以直接在寄存器中传递。如果函数接受超过8个参数,或者某些参数大于寄存器的大小,那么你将不得不以某种方式在栈上传递它们,但大多数函数可以仅使用这八个参数寄存器。

如果函数返回一个结果,那么按照约定,它将返回到寄存器 a0 中。

临时寄存器 (x5-x7, x28-x31/t0-t2, t3-t6)

我们还有七个临时寄存器,名称为 t0t6。按照约定,这些用作工作寄存器,即在函数内部,你可以将它们用于你需要进行的任何计算。

但标准的编程约定意味着这些寄存器可以被任何被调用的函数修改。因此,如果你在某个函数中使用它们,当你调用其他函数时,你必须小心,因为它们可能会被被调用函数破坏或修改。所以我们说这些寄存器是调用者保存的。如果你希望它们在调用某个子函数时保留值,那么调用者函数有责任将它们保存在某个地方(可能是在栈上)。

参数寄存器也被称为调用者保存的,因为它们将被用作被调用函数的参数。

保存寄存器 (x8-x9, x18-x27/s0-s1, s2-s11)

还有另一组寄存器称为保存寄存器,名称为 s0s11。这些与临时寄存器非常相似,因为它们可以用作你的函数必须进行的任何计算的一般工作寄存器。

但与临时寄存器不同,这些被称为被调用者保存的。它们不是调用者保存的,而是被调用者保存的。因此,使用这些寄存器是有代价的。如果某个函数要使用这些寄存器,那么该函数必须保存寄存器的先前值,然后在返回之前需要恢复那个旧值。因此,标准调用约定规定,每个函数都将保留寄存器 s0s11 中的值,它们是被调用者保存的。

如果你调用一个函数,你可以根据调用约定保证该函数将保留 S 寄存器的值(与 T 寄存器不同)。因此,如果你需要某个在函数调用中保留的东西,你可能想为此使用一个 S 寄存器。并且你知道,如果该函数遵守约定,那么如果它使用那些 S 寄存器,它将首先保存先前的值,然后在返回之前恢复该值,因此你可以依赖任何被调用的函数不修改 S 寄存器。

全局指针与线程指针 (x3/gp, x4/tp)

最后,我想提一下全局指针和线程指针寄存器。你可能永远不需要使用这些寄存器,但为了完整性,我想提一下它们。

全局指针或 gp 寄存器指向一个将保存全局或静态变量的区域。这个寄存器的目的是使寻址这些变量更容易。例如,加载字指令将一个字从内存复制到某个寄存器(本例中为 t0)。它接受一个偏移量加上一个寄存器。因此,我们可以使用 gp 寄存器来访问全局或静态变量。

与全局指针类似,线程指针寄存器或 tp 指向一个保存变量的区域,用于使寻址更容易。但对于线程指针,这些变量是线程特定的,它们可以包括线程标识符、线程参数或该线程本地的全局变量等内容。

总结

本节课我们一起学习了RISC-V汇编语言编程的入门知识。我们区分了汇编语言与机器代码,介绍了RISC-V的两种主要架构(RV32/RV64)以及数据大小的基本术语。课程重点详细讲解了RISC-V的32个通用寄存器,包括零寄存器(x0)、返回地址寄存器(ra)、栈指针(sp)、参数寄存器(a0-a7)、临时寄存器(t0-t6)和保存寄存器(s0-s11)的功能与使用约定,并简要提及了全局指针(gp)和线程指针(tp)。理解这些寄存器是编写RISC-V汇编程序的基础。在下一节课中,我们将开始深入探讨RISC-V的具体指令。

002:P02-RISC-V汇编代码-#2---ALU、加载、存储指令

在本节课中,我们将学习RISC-V汇编语言的基础知识,重点介绍算术逻辑单元指令以及加载和存储指令。我们将从汇编代码的基本结构开始,然后详细讲解各类指令的格式、功能和使用方法。

上一节我们介绍了RISC-V的32个通用寄存器及其典型用途。本节中,我们来看看如何编写RISC-V汇编指令。

汇编代码结构

RISC-V主要有两个标准:RV32和RV64。RV32的寄存器大小为32位(4字节),而RV64的寄存器大小为64位(8字节)。本节主要讨论RV32 I指令集,但所涉及的指令同样适用于64位的RISC-V核心。

一条汇编指令通常包含以下几个部分:

  • 操作码:指定要执行的操作,如 add
  • 操作数:指令操作的对象,可以是寄存器或立即数。
  • 标签:为内存地址提供符号名称。
  • 注释:解释代码功能。

以下是汇编代码各部分的示例:

# 这是一条注释,以‘#’开头直到行尾
label_name:          # 这是一个标签,以冒号结尾
    add t0, a0, a1   # 操作码为‘add’,操作数为寄存器t0, a0, a1
    .word 0x12345678 # 这是一个汇编器伪指令,以‘.’开头

操作码与操作数

操作码字段可以是机器指令、伪指令或汇编器伪指令。伪指令(如 call)以特定助记符表示,最终会被汇编器翻译成一条或多条机器指令。汇编器伪指令(如 .word)以点号开头,用于指导汇编过程。

操作数可以是寄存器或立即数。立即数可以直接编码到指令中,在汇编代码中可以用十进制、十六进制表示,也可以是一个符号或复杂表达式。

大多数指令的操作数顺序是固定的:目标寄存器在前,源操作数在后。例如,在 add rd, rs1, rs2 中,结果存入 rd,源操作数来自 rs1rs2。但存储指令 sw 的顺序例外,其格式为 sw rs2, offset(rs1)

代码风格建议

编写汇编代码时,清晰的格式能极大提高可读性。建议使用制表符或空格对齐操作码和操作数,并为每行指令添加注释。以下是一个良好格式的循环代码示例:

loop_start:                 # 循环开始标签
    lw   t1, 0(t3)         # 从内存加载一个字到t1
    addi t1, t1, 1         # t1的值加1
    sw   t1, 0(t3)         # 将t1的值存回内存
    addi t3, t3, 4         # 内存地址指针t3增加4(指向下一个字)
    bne  t3, a2, loop_start # 如果t3不等于a2,跳回循环开始

算术与逻辑指令

上一节我们了解了汇编代码的格式,本节中我们来看看具体的算术与逻辑指令。这些指令用于在寄存器之间或寄存器与立即数之间进行计算。

RISC-V提供了丰富的算术逻辑指令。首先从加法指令开始。

加法指令

RISC-V有两条加法指令:

  • 寄存器-寄存器加法add rd, rs1, rs2
    • 将寄存器 rs1rs2 的值相加,结果存入 rd
  • 立即数加法addi rd, rs1, imm
    • 将寄存器 rs1 的值与12位有符号立即数 imm 相加,结果存入 rd。立即数范围是 -2048 到 2047。

对于RV32,这是32位加法;对于RV64,则是64位加法。

指令编码

了解指令的机器编码有助于深入理解处理器。以下是两条加法指令的编码格式示意:

  • add 指令编码格式
    [ funct7 (7位) | rs2 (5位) | rs1 (5位) | funct3 (3位) | rd (5位) | opcode (7位) ]
    
  • addi 指令编码格式
    [ imm[11:0] (12位) | rs1 (5位) | funct3 (3位) | rd (5位) | opcode (7位) ]
    

其中,opcodefunct 字段共同决定了具体是哪条指令。5位的寄存器字段可以寻址32个寄存器(2^5=32)。

其他算术逻辑指令

除了加法,RISC-V还支持以下寄存器-寄存器格式的指令:

以下是主要的算术与逻辑指令列表:

  • 算术运算sub (减法)
  • 逻辑运算and, or, xor (按位与、或、异或)
  • 移位运算
    • sll (逻辑左移):空出位补0。
    • srl (逻辑右移):空出位补0。
    • sra (算术右移):空出位用符号位填充。
    • 移位量由 rs2 寄存器的低5位指定。左移可用于乘以2的幂,右移可用于除以2的幂(无符号数用srl,有符号数用sra)。
  • 比较指令
    • slt (置小于):如果 rs1 < rs2 (有符号比较),则 rd 置1,否则置0。
    • sltu (无符号置小于):进行无符号比较。

上述大多数指令都有对应的立即数版本(指令名后加 i),例如 andi, ori, xori, slli, srli, srai, slti, sltiu。注意,没有 subi 指令,要减去一个常数,可以对该常数取负然后使用 addi

加载与存储指令

掌握了数据处理指令后,我们需要让处理器能够访问内存。本节介绍加载和存储指令,它们负责在寄存器和内存之间传递数据。

加载指令从内存读取数据到寄存器,存储指令将寄存器数据写入内存。内存地址由基址寄存器 rs1 的值加上12位有符号立即数偏移量计算得出。

基本加载/存储指令

以下是RV32I基础指令集中的加载和存储指令:

以下是加载指令列表:

  • lb rd, offset(rs1):加载字节(8位),并进行符号扩展到32位。
  • lh rd, offset(rs1):加载半字(16位),并进行符号扩展到32位。
  • lw rd, offset(rs1):加载字(32位)。
  • lbu rd, offset(rs1):加载无符号字节,零扩展到32位。
  • lhu rd, offset(rs1):加载无符号半字,零扩展到32位。

以下是存储指令列表:

  • sb rs2, offset(rs1):存储字节(将 rs2 的低8位存入内存)。
  • sh rs2, offset(rs1):存储半字(将 rs2 的低16位存入内存)。
  • sw rs2, offset(rs1):存储字(将 rs2 的32位存入内存)。

RV64的扩展指令

对于RV64(64位寄存器),还有额外的指令:

  • lwu rd, offset(rs1):加载无符号字(32位),并零扩展至64位寄存器。
  • ld rd, offset(rs1):加载双字(64位)。
  • sd rs2, offset(rs1):存储双字(64位)。

数据大小与符号处理

当处理小于寄存器位宽的数据(如字节、半字)时,需要注意符号扩展和零扩展的区别:

  • 符号扩展:用于有符号数。将数据的最高位(符号位)复制到寄存器的高位。
  • 零扩展:用于无符号数。将寄存器的高位直接置零。

例如,将一个字节存入32位字变量时,如果是有符号字节,则高位用符号位填充;如果是无符号字节,则高位用零填充。

内存地址对齐

在讨论加载存储时,一个重要的概念是内存地址对齐。对齐要求会影响程序的正确性和性能。

现代计算机通常是字节可寻址的。我们定义:

  • 半字对齐地址:地址是2的倍数(二进制最后一位为0)。十六进制表示时,末尾是0, 2, 4, 6, 8, A, C, E。
  • 字对齐地址:地址是4的倍数(二进制最后两位为00)。十六进制表示时,末尾是0, 4, 8, C。
  • 双字对齐地址:地址是8的倍数(二进制最后三位为000)。十六进制表示时,末尾是0, 8。

RISC-V对齐要求

RISC-V的对齐要求如下:

  1. 指令地址:必须位于半字对齐的地址(因为可能混合使用16位压缩指令和32位标准指令)。
  2. 数据地址
    • 字节操作(lb, sb)可以在任何地址进行。
    • 对于半字和字操作(lh, lw, sh, sw),RISC-V规范指出,是否支持非对齐访问是实现定义的。

硬件处理非对齐访问有三种可能:

  1. 硬件直接支持,指令正常执行。
  2. 发生异常(陷阱),由操作系统陷阱处理程序处理。处理程序可能终止程序,或在软件中模拟该操作后返回。
  3. 根据规范,行为未定义是不被允许的。 硬件要么正确执行,要么触发陷阱。

由于无法预知目标硬件的具体行为,最佳实践是始终使用对齐的地址。例如,字变量应分配在4字节对齐的地址上。

本节课中我们一起学习了RISC-V汇编语言的核心组成部分,包括代码结构、算术逻辑指令以及加载存储指令。我们还探讨了数据大小处理、符号扩展和内存对齐等重要概念。

下一节,我们将介绍控制流指令,包括条件分支和无条件跳转,以及 callreturn 等指令,它们将赋予程序判断和循环的能力。

003:分支、跳转、调用与返回指令

在本节课中,我们将学习RISC-V汇编语言中用于改变程序控制流的指令。这包括条件分支指令、跳转指令、调用指令和返回指令。我们还将了解一些特殊的机器指令,如跳转并链接(JAL)、跳转并链接寄存器(JALR)、高位立即数加载(LUI)和PC高位立即数加(AUIPC),它们如何协同工作以实现强大的功能。课程最后会简要介绍系统调用指令(ECALL)。

在之前的课程中,我们讨论了寄存器以及用于操作数据的指令,包括各种算术逻辑单元(ALU)指令和加载/存储指令。本节中,我们将重点转向控制程序执行流程的指令。

条件分支指令

条件分支指令用于根据特定条件决定是否跳转到目标地址。每条指令会测试两个寄存器的值,如果条件满足,则进行跳转。

以下是条件分支指令的示例:

  • beq rs1, rs2, offset: 如果寄存器rs1的值等于rs2的值,则跳转到PC + offset
  • bne rs1, rs2, offset: 如果寄存器rs1的值不等于rs2的值,则跳转到PC + offset

例如,以下代码使用bne指令实现一个循环:

loop_start:
    addi t0, t0, -1    # 将寄存器t0的值减1
    bne t0, zero, loop_start # 如果t0不等于0,则跳转回loop_start

目标地址是相对于程序计数器(PC)的。指令中编码了一个12位的立即数(偏移量),处理器通过将其符号扩展后与PC相加来计算目标地址。因此,指令中的偏移量表示分支指令本身与目标地址之间的距离。汇编器和链接器知道这两个地址,从而可以计算出正确的偏移量并填入指令中。

除了相等和不等比较,RISC-V还提供有符号和无符号的比较分支指令。

以下是其他分支指令:

  • 有符号比较分支blt(小于)、ble(小于等于)、bgt(大于)、bge(大于等于)。
  • 无符号比较分支bltu(无符号小于)、bleu(无符号小于等于)等。

这些指令根据比较结果决定是否进行跳转。

跳转、调用与返回指令

上一节我们介绍了条件分支,它们用于程序内部的短距离条件跳转。现在,我们来看看用于实现函数调用和长距离跳转的核心指令:跳转(Jump)、调用(Call)和返回(Return)。

RISC-V汇编语言中,jumpcallret是三个非常重要的伪指令(Pseudo-instructions),你会经常使用它们。

  • jump target: 无条件跳转到目标地址。
  • call target: 调用目标地址处的函数。该指令会将返回地址(即call指令下一条指令的地址)保存到ra(返回地址)寄存器中。
  • ret: 从函数返回。该指令没有操作数,它会跳转到ra寄存器中保存的地址。

在代码中,目标地址通常用符号标签表示。例如:

    call my_function  # 调用名为my_function的函数
    ...               # 返回后将执行这里的指令

my_function:
    ...               # 函数体
    ret               # 返回到调用处

这里,call指令将my_function标签后的指令地址存入ra寄存器。ret指令则使用ra寄存器中的地址作为目标跳转回去。

需要注意的是,jumpcallret都是伪指令。因为一个32位的指令无法直接编码一个完整的32位内存地址。汇编器会根据目标地址的距离和性质,将这些伪指令翻译成一条或多条实际的机器指令(如JALJALR等)。

加载立即数与地址指令

为了在寄存器中设置常数或内存地址,RISC-V汇编提供了两个常用的伪指令。

以下是两个重要的数据加载伪指令:

  • li rd, immediate: 加载立即数。将一个在汇编时已知的整数值加载到目标寄存器rd中。
  • la rd, symbol: 加载地址。将一个内存地址(由符号symbol表示,如函数名或变量名)加载到目标寄存器rd中。

例如:

li a0, 42         # 将立即数42加载到寄存器a0
la a1, my_data    # 将my_data标签对应的内存地址加载到寄存器a1

和跳转/调用指令一样,lila也是伪指令。汇编器(对于li)或链接器(对于la,因为地址在链接阶段才最终确定)会根据值的具体大小(如是否在12位立即数范围内)将其翻译成一条或多条机器指令,例如使用lui(加载高位立即数)和addi(加立即数)的组合。

系统调用指令 (ECALL)

有时程序需要请求操作系统内核提供服务,例如输入输出。这需要通过系统调用来实现。

在RISC-V中,使用ecall(环境调用)指令来发起系统调用。以下是一个在RISC-V Linux系统上打印字符串的示例:

.data
msg: .asciz "Hello\n"     # 定义一个以空字符结尾的字符串

.text
    li a0, 1              # 文件描述符:1 代表标准输出(stdout)
    la a1, msg            # 要打印的字符串的地址
    li a2, 6              # 要打印的字节数
    li a7, 64             # Linux系统调用号:64 代表 write
    ecall                 # 执行系统调用

在基于RISC-V的Linux系统中,系统调用遵循特定的调用约定:参数通过a0-a7寄存器传递。系统调用号放在a7寄存器中。ecall指令本身没有操作数,执行时会触发异常,将控制权转交给操作系统内核。

.asciz是一个汇编器指令,用于在内存中存储一个以空字符(\0)结尾的字符串。还有一个类似的指令.ascii,它不会自动添加空字符。

对于更复杂的输出,可以链接C标准库并使用printf等函数,这通常通过普通的call指令完成。

核心机器指令:JAL 与 JALR

前面提到的许多伪指令,其底层实现依赖于两条核心的机器指令。

跳转并链接(JAL)指令的格式为jal rd, offset。它执行两个操作:

  1. 将下一条指令的地址(返回地址)存入目标寄存器rd
  2. 进行一个PC相对跳转,跳转目标为PC + sign-extend(offset << 1)。由于指令是半字(2字节)对齐的,20位的偏移量左移1位后,提供了±1MB的跳转范围。

call指令的目标地址在1MB范围内时,汇编器会将其翻译成jal ra, offset。当jump指令的目标地址在1MB范围内时,则翻译成jal zero, offset(将返回地址丢弃到零寄存器)。

跳转并链接寄存器(JALR)指令的格式为jalr rd, offset(rs1)。它也执行两个操作:

  1. 将下一条指令的地址存入目标寄存器rd
  2. 跳转到目标地址,该地址由rs1寄存器的值加上符号扩展的12位offset计算得出,提供±2KB的跳转范围。

JALR指令用途广泛:

  • 间接调用callr t5 (伪指令)可翻译为jalr ra, 0(t5),调用地址存储在t5寄存器中的函数。
  • 间接跳转jr t5 (伪指令)可翻译为jalr zero, 0(t5),用于实现switch语句等。
  • 返回ret (伪指令)直接翻译为jalr zero, 0(ra)

构建长跳转:LUI 与 AUIPC

当跳转或调用的目标地址超出JAL指令的±1MB范围时,就需要使用“长跳转”。这需要用到另外两条机器指令来构建完整的32位地址。

加载高位立即数(LUI)指令的格式为lui rd, immediate。它将20位的立即数左移12位后,存入寄存器rd的高20位,低12位清零。即:rd = immediate << 12

对于长跳转,汇编器/链接器可以将32位目标地址分解为高20位和低12位。使用lui加载高20位到一个临时寄存器,然后使用jalr加上低12位偏移来完成跳转。例如,伪指令jump far_target可能被翻译为:

lui t0, %hi(far_target)   # 加载目标地址的高20位到t0
jalr zero, %lo(far_target)(t0) # t0加上低12位,并跳转,丢弃返回地址

%hi()%lo()是获取地址高/低部分的汇编器函数。

PC高位立即数加(AUIPC)指令的格式为auipc rd, immediate。它将20位立即数左移12位后,与当前PC值相加,结果存入寄存器rd。即:rd = PC + (immediate << 12)

AUIPC的妙处在于它能生成PC相对的地址。对于长跳转和长调用,使用AUIPC代替LUI可以使代码成为位置无关代码(PIC)。只要跳转/调用点与目标点之间的偏移不变,这段代码可以被加载到内存的任何位置执行,无需修改。这对于动态链接库和运行时加载代码非常重要。

长PC相对跳转的实现类似:

auipc t0, %pcrel_hi(far_target) # t0 = PC + (offset的高20位 << 12)
jalr zero, %pcrel_lo(far_target)(t0) # 跳转到 t0 + offset的低12位

本节课中,我们一起学习了RISC-V中改变控制流的指令。我们涵盖了条件分支指令、以及作为伪指令的跳转、调用和返回指令。我们还深入了解了实现这些功能的底层机器指令:跳转并链接(JAL)、跳转并链接寄存器(JALR)、加载高位立即数(LUI)和PC高位立即数加(AUIPC)。最后,我们简要介绍了用于系统调用的ECALL指令。

在下一节课中,我们将探讨更多的伪指令,并详细介绍汇编器指令(Assembler Directives)。

004:汇编语言与伪指令、汇编器指令详解

在本节课中,我们将学习RISC-V汇编语言中的伪指令和主要的汇编器指令。我们将了解伪指令与机器指令的区别,并探索一系列常用的伪指令及其实现方式。同时,我们也会介绍关键的汇编器指令,它们用于定义数据、控制符号可见性以及组织程序的不同部分。

伪指令概述

上一节我们介绍了控制流指令。本节中,我们来看看伪指令。伪指令并非由硬件直接实现,而是由汇编器在翻译过程中转换为一条或多条真实的机器指令。这使得程序员可以使用更直观或更强大的指令,而无需硬件增加额外的复杂性。

以下是几个简单的伪指令示例:

  • ret:此伪指令被翻译为 jalr x0, 0(x1),用于从子程序返回。
  • neg rd, rs1:此伪指令被翻译为 sub rd, x0, rs1,用于计算源寄存器值的相反数。
  • mv rd, rs1:此伪指令被翻译为 addi rd, rs1, 0,用于将一个寄存器的值复制到另一个寄存器。
  • not rd, rs1:此伪指令被翻译为 xori rd, rs1, -1,用于对源寄存器的所有位进行逻辑取反。
  • jr rs1:此伪指令被翻译为 jalr x0, 0(rs1),用于跳转到寄存器中存储的地址。

分支伪指令

RISC-V硬件只实现了部分比较分支指令,其余的是伪指令。

硬件实现了 blt(小于则分支)和 bge(大于等于则分支)指令。bgt(大于则分支)伪指令由汇编器通过交换操作数顺序并转换为 blt 指令来实现。

# 伪指令
bgt rs1, rs2, label
# 被汇编器转换为
blt rs2, rs1, label

类似地,ble(小于等于则分支)伪指令被转换为 bge 指令。

对于无符号比较,硬件实现了 bltubgeu 指令。汇编器使用它们来创建 bgtubleu 伪指令。

汇编器还提供了一系列与零比较的便捷伪指令,例如 beqzbnezbltzblezbgtzbgez。这些指令通过使用 x0 寄存器作为其中一个操作数,并调用相应的有符号比较指令来实现。

加载立即数伪指令

li(加载立即数)是一个常用且强大的伪指令,用于将常量值加载到目标寄存器中。其转换方式取决于立即数的大小。

如果立即数是一个12位有符号数(范围在-2048到2047之间),汇编器会使用 addi 指令实现。

# 伪指令
li rd, imm12
# 被汇编器转换为(当imm12在12位有符号范围内时)
addi rd, x0, imm12

如果立即数是32位但超出12位范围,汇编器会使用 lui(加载高位立即数)和 addi 指令组合来实现,将数值拆分为高20位和低12位。

在64位RISC-V处理器上,对于更大的数值,汇编器会使用其他指令组合。程序员只需使用 li 伪指令即可。

注意li 指令中的表达式可以包含符号,但汇编器通常要求这些符号在源文件中该指令之前已被定义,因为它可能进行单遍扫描。

地址相关伪指令

以下是三个与地址相关的伪指令:

  • la rd, symbol:将符号(通常是标签)对应的内存地址加载到目标寄存器中。
  • call symbol:调用远距离的子程序。
  • j symbol:跳转到一个远距离的标签。

这些伪指令的翻译取决于目标地址的具体值,可能会使用PC相对寻址或其他指令组合,翻译成一条或多条机器指令。lali 类似,但 la 加载的是地址,而 li 加载的是整型常量。

数据定义汇编器指令

除了指令,汇编语言程序还包含汇编器指令(以点号.开头)。以下指令用于在内存中分配空间,通常用于创建变量。

以下是主要的数据分配指令:

  • .byte:分配1个字节,并用指定的表达式初始化。
  • .half / .hword:分配2个字节(半字)。
  • .word:分配4个字节(字)。
  • .dword:分配8个字节(双字)。
  • .ascii “string”:分配与字符串字符数相等的字节,不添加终止符。
  • .asciz “string” / .string “string”:分配字符串字节,并在末尾添加一个空字节(\0)作为终止符。
  • .skip n:分配 n 个字节的未初始化空间,常用于数组。

这些指令前通常会有标签,以便通过名称引用这些数据。除 .skip 外,其他指令都可以接受一个由逗号分隔的初始化表达式列表,用于初始化连续的多块内存。

符号可见性:.global 指令

为了理解 .global(注意拼写中没有字母‘a’)指令,我们需要了解编译、汇编和链接的过程。

一个大型程序可能由多个C文件(.c)和汇编文件(.s)组成。编译器将C文件编译成汇编文件,汇编器将汇编文件汇编成目标文件(.o),链接器(ld)将多个目标文件以及库文件链接成一个可执行文件。

默认情况下,在一个汇编文件中定义的标签(符号)是该文件私有的。如果希望某个符号(例如一个函数名或全局变量名)能被其他文件使用,就需要用 .global 指令将其声明为全局的。

.global my_function
my_function:
    # 函数代码...

汇编器会将这个符号信息记录在目标文件中。链接时,链接器会解析所有模块中未定义的符号引用,并将其指向在全局符号中定义的地址。如果引用了未在任何模块中定义的全局符号,链接器会报“未定义符号”错误。

特殊符号 _start:可执行程序的入口点地址由符号 _start 定义。链接器会寻找这个符号,因此你的程序中必须在一个模块里定义并用 .global 导出 _start。在C程序中,启动代码会定义 _start,它进行一些初始化后调用 main 函数。

其他汇编器指令

接下来我们看看定义常量和控制内存布局的指令。

  • .equ symbol, value / .set symbol, value:这两个指令功能相同,用于将符号定义为某个值(整数、表达式或其他符号),类似于C语言中的 #define。该符号在定义之后的位置可用。
  • .align n:该指令通过插入填充字节,使接下来的数据或指令在内存中按 2^n 字节对齐。某些架构对数据访问有对齐要求,此指令可确保对齐。
  • 程序段指令:程序被划分为不同的段(或节)。常见段指令包括:
    • .text:此后的内容放入代码段(通常只读、可执行)。
    • .data:此后的内容放入已初始化数据段(可读可写)。
    • .bss:此后的内容放入未初始化数据段(Block Started by Symbol)。该段内的数据默认初始化为0,且在目标文件中不占用实际空间,仅记录大小,可有效减小目标文件体积。

一个汇编源文件中可以包含多个段。操作系统加载程序时,会根据段的属性(如 .text 段标记为可执行,.data 段标记为可读写)设置内存页的访问权限,以提供保护。

总结

本节课中我们一起学习了RISC-V汇编语言的核心扩展部分:伪指令和汇编器指令。我们了解了伪指令如何被汇编器转换为底层的机器指令,从而简化编程。我们还详细介绍了用于定义数据(.byte, .word等)、控制符号导出(.global)以及组织程序结构(.text, .data, .bss)的关键汇编器指令。理解这些内容对于编写和阅读完整的RISC-V汇编程序至关重要。虽然链接等更深层次的主题未完全展开,但本课提供的基础知识足以帮助你开始RISC-V汇编编程。

005:RV32与RV64指令集差异详解 🔍

在本节课中,我们将深入探讨RISC-V汇编语言中RV32与RV64指令集之间的差异。我们将重点关注算术、移位、立即数加载以及加载/存储指令在32位和64位机器上的不同之处,并解释新增指令的用途。

概述

RV32和RV64分别指代寄存器大小为32位和64位的RISC-V机器。两者都拥有32个通用寄存器。后缀“I”代表基本整数指令集(如RV32I或RV64I),这是所有RISC-V核心必须支持的基础指令集。其他字母后缀表示可选扩展,将在后续课程中介绍。

基础指令集旨在让同一份代码能在32位或64位机器上运行,但两者之间存在一些细微差别,并且64位机器增加了一些用于处理32位数据的额外指令。

算术指令的差异

上一节我们介绍了指令集的基本概念,本节中我们来看看算术指令的具体差异。

基础指令集只包含三条算术指令:add(加)、sub(减)和addi(立即数加)。在64位机器上,这些指令执行64位的运算。

此外,64位机器还增加了三条带“W”(代表“Word”,即字)后缀的指令:addwsubwaddiw。这些指令执行32位算术运算。

以下是这些指令的行为:

  • 它们取源操作数的低32位进行运算。
  • 将32位结果存入目标寄存器的低32位。
  • 然后,用这个32位结果的符号位扩展来填充目标寄存器的高32位。

公式addw rd, rs1, rs2 的操作可描述为:rd[31:0] = rs1[31:0] + rs2[31:0]; rd[63:32] = SE(rd[31]),其中SE()表示符号位扩展。

建议:在汇编编程中,除非有特殊原因(如内存限制或需要精确模拟C语言的32位溢出行为),否则应优先使用64位有符号整数运算指令(add, sub等)。即使需要将最终结果以32位字存储回内存,也可以在计算前后使用lw(加载字)和sw(存储字)指令来处理。

这些“W”指令的主要目的是正确实现C/C++等语言,因为这些语言对32位运算有特定的溢出行为要求。它们也能方便地将32位值符号扩展为64位有符号值。

移位指令的差异

了解了算术指令后,我们接下来看看移位指令在两种架构下的区别。

基础指令集包含六条移位指令:sll(逻辑左移)、srl(逻辑右移)、sra(算术右移),以及它们的立即数版本srlisrai

从32位扩展到64位是非常自然的:

  • 在32位机器上,移位量(来自寄存器时)取自源寄存器的低5位;立即数移位量的范围是0到31
  • 在64位机器上,移位量取自源寄存器的低6位;立即数移位量的范围是0到63

同样,64位机器也增加了六条带“W”后缀的移位指令(如sllw, srlw, sraw及其立即数版本)。以下是它们的行为:

  • 它们对操作数的低32位进行32位移位。
  • 结果存入目标寄存器的低32位。
  • 目标寄存器的高32位用结果的符号位扩展填充。
  • 移位量被限制在5位(0-31),忽略源寄存器中的第6位。

这些指令用于实现C/C++编译器所需的32位值移位操作。

立即数加载指令的注意事项

移位指令主要处理寄存器内的数据操作,而立即数加载指令则涉及将常数放入寄存器。本节讨论luiauipc指令。

lui(加载高位立即数)指令将一个20位的立即数左移12位后放入目标寄存器。

在64位机器上,有一个重要注意事项:结果会从32位符号扩展到64位。即,目标寄存器的高32位被设置为低32位字的符号扩展。

影响:如果你的地址空间小于或等于4GB(32位),则没有问题。但如果地址空间大于4GB,且你构造的地址其高20位立即数的符号位为1(即地址 >= 2GB),那么符号扩展会导致高32位被全部置为1,可能无法得到预期的地址。使用此指令时需要小心。

auipc(PC加高位立即数)指令与之类似,它计算一个PC相对地址。在64位版本中,立即数首先被符号扩展为64位,然后再与PC相加。只要偏移量在±2GB范围内,它就能很好地工作。

注意:在实际编程中,你通常不会直接使用luiauipc。汇编器和链接器在翻译伪指令(如la, call)时会自动使用它们。你直接使用伪指令即可,无需过多担心这两条指令的细节。

加载与存储指令的扩展

处理完寄存器内的运算和立即数加载后,我们需要与内存交换数据。以下是加载和存储指令的差异。

以下是32位RISC-V计算机的加载和存储指令:

加载指令:从内存取数据到寄存器。

  • lb / lbu: 加载字节(有符号/无符号)
  • lh / lhu: 加载半字(有符号/无符号)
  • lw: 加载字(4字节)

存储指令:将寄存器数据存入内存。

  • sb: 存储字节
  • sh: 存储半字
  • sw: 存储字

对于64位机器,增加了以下指令:

  • ld: 加载双字(8字节)。从内存取8字节填充整个64位寄存器。
  • lw 现在有两种变体:
    • lw: 加载字,并将高32位进行符号扩展(默认)。
    • lwu: 加载字(无符号),将高32位用0填充
  • sd: 存储双字(8字节)。将寄存器rs2中的8字节存入内存。

其他不受影响的指令

最后,我们来看看在RV32和RV64之间没有变化的指令。

对于基础指令集中的其他指令,寄存器大小无关紧要,在RV32和RV64机器上没有区别:

  • 逻辑指令and, or, xor等)是逐位操作的,如果你想忽略高32位,完全可以。
  • 设置指令slt, sltu)即使对于32位值也能正常工作,只要该值已被符号扩展。
  • 测试与分支指令beq, bne, blt等)无论比较64位值还是已被符号扩展的32位值,都能正确工作。
  • 伪指令(如j, call, ret)会被汇编器翻译成jaljalr指令,这些指令工作方式相同。
  • 系统指令fence, ecall, ebreak)不涉及寄存器操作,因此也不受影响。

总结

本节课中,我们一起学习了RISC-V的RV32与RV64基础指令集(RV32I/RV64I)之间的关键差异:

  1. 算术与移位指令:自然扩展至64位操作,并新增了带“W”后缀的指令,用于精确模拟32位运算的溢出行为,主要供C/C++编译器使用。
  2. 立即数指令luiauipc在64位环境下会将结果符号扩展,在大地址空间编程时需留意。
  3. 加载/存储指令:64位架构增加了ld(加载双字)、lwu(无符号加载字)和sd(存储双字)指令,以支持8字节数据的存取。
  4. 其他指令:逻辑、比较、分支及系统指令在两种架构下行为一致,未发生变化。

理解这些差异有助于编写可移植的汇编代码,并深入理解高级语言编译器在RISC-V平台上的工作方式。

006:乘除指令 🧮

在本节课中,我们将要学习RISC-V架构中用于整数乘法和除法的指令,以及计算除法余数的指令。这些指令属于可选的“M”扩展集。

概述

RISC-V处理器规范可以被视为一系列设计选项和潜在扩展的“菜单”,硬件设计者可以选择实现哪些功能。RISC-V项目使用一组字母代码来指示特定处理器核心实际实现了哪些选项和扩展。作为程序员,你需要了解这个代码系统,以便知道你的核心能做什么。

首先,我们有RV32或RV64来表示寄存器大小,后面可以跟额外的字母来提供更多细节。字母“I”表示实现了基本指令集,这是最低要求。后面可以跟其他字母来表示实现了哪些可选功能。许多非常有用的指令实际上是可选的。例如,用字母“C”表示的压缩指令集就是完全可选的。

在本视频中,我将讨论乘法和除法指令,它们由代码字母“M”表示。“M”既包括乘法指令,也包括除法指令。还有许多其他选项,我计划在未来的视频中介绍。

如果你的核心没有实现“M”选项,那么乘法和除法指令将导致非法指令陷阱。我将在其他视频中讨论陷阱处理,但总结来说,要么操作系统会终止程序,要么操作系统内核会在软件中模拟该指令,然后返回到你的程序,而你的程序永远不会知道该操作不是在硬件中执行的。

需要补充的是,以当今的微电路技术,最简单的乘除法硬件实现几乎可以适配所有核心,除了极微型的核心。因此,我预计你实际购买和运行的任何核心都将在硬件中实现乘法和除法指令。

乘法指令

我想从讨论操作数和结果的大小开始讲解乘法。基本上,结果可能需要两倍于操作数的空间。

以下是一个用十进制表示的示意性例子。这是一个用二进制表示的例子。我们的机器可能是64位机器,但为了示例更简洁,我将使用8位数字,同样的原理也适用于其他大小。这里我们有两个8位数,将它们相乘,当我们将它们解释为有符号数时,得到这个结果。这里我展示了有符号解释。

现在,如果我们将这两个操作数解释为无符号值,那么会得到一个略有不同的结果。这里我展示了这两个值的十进制解释。尽管我们有相同的操作数,但结果的高位部分不同。这不仅仅是这些特定数字和这个特定示例的产物,而是普遍成立的。因此,无论是有符号还是无符号解释,结果的低半部分总是相同的。但对于结果的高半部分,高位比特可能不同,如本例所示。

我还想展示这个例子,我们将两个最大的无符号数相乘。这正好表明,结果所需的比特数恰好是操作数比特数的两倍。

所以,对于乘法,结果所需的比特数是操作数比特数的两倍。例如,如果我们将两个32位数相乘,会得到一个64位的结果,这无法放入一个寄存器中。同样,如果我们在RV64机器上,所有寄存器都包含64位,而结果是128位。因此,我们必须使用几条不同的指令来获取整个结果。

我们要看的第一条指令,将计算结果的低半部分。它将两个数相乘,产生一个结果,并将结果的低半部分存储到目标寄存器中。例如,如果我们在RV32机器上相乘两个32位数,我们得到结果的低32位,并将其移入目标寄存器。

为了获得结果的高半部分,我们根据是有符号乘法还是无符号乘法,使用不同的指令。

以下是计算结果高半部分的指令:

  • mulh: 用于计算有符号乘法结果的高半部分。假设两个操作数都是有符号数,这将把结果的高半部分(例如,在32位机器上是高32位)移入目标寄存器。
  • mulhu: 用于计算无符号乘法结果的高半部分。这将把结果的高半部分移入目标寄存器。
  • mulhsu: 用于计算一个有符号数乘以一个无符号数的结果的高半部分。其中一个操作数被视为有符号数,另一个则被解释为无符号数。

这些指令在RV32机器上(所有操作都是32位,结果是64位)或RV64机器上(所有操作数都是64位,我们计算128位结果)都有效。

如果我们有一台RV64机器,那么所有这些值都是64位,结果是128位。但我们还有一条额外的指令用于执行32位乘法,那就是mulw(字乘法)指令。它将执行32位乘法,具体操作是:取这些寄存器中的值(这些是64位寄存器),忽略寄存器的高半部分,只使用寄存器的低32位。然后将它们相乘,产生结果的低半部分,并将其放入你的目标寄存器,然后进行符号扩展。将寄存器的低32位进行符号扩展,并用符号扩展填充寄存器的高32位。如果你确实想要在RV64上获得完整的64位结果,你可以直接使用乘法指令来获取,但mulw对于实现需要执行32位乘法而非64位乘法的语言会很有用。

除法指令

RISC-V的“M”选项代码包括除法和余数指令。如果它存在,那么除法和余数将在硬件中实现。

至于表示结果所需的比特数,它总是与操作数的比特数大小相同。这里有一些十进制例子,可以直观地说明为什么这是正确的。

然而,当我们仔细查看二进制时,有一个例外情况,那就是当我们处理有符号数,并且用最大负数除以-1时。例如,如果我们有8位,范围是从-128到+127。如果我们取最负的数除以-1,会得到一个刚好超出有符号值可表示范围的数。这将是一个错误条件或我们需要处理的情况。

我们还有除以0的问题,我假设如果你已经学到了这一步,你应该听说过除以0是不允许的,所以我们需要讨论在这两种情况下会发生什么。

现在让我们来看看除法操作。以下是除法和余数操作。

div指令将寄存器rs1中的值除以rs2中的值,并将结果放入目标寄存器。如果结果不是整数,它将向下取整。

rem指令将在这样的除法之后产生余数。对于32位机器,所有值(包括操作数和结果)大小相同,都是32位。而对于RV64,操作数和结果都是64位。

接下来,我们需要讨论有符号数和无符号数之间的区别。这里有一个例子:我们用5除以一个所有位都是1的值。如果我们将该值解释为有符号数,它是-1,结果是-1。如果我们将其解释为无符号数,那么它是一个非常大的无符号值,非常大的正数。结果不同,正如你所见。因此,我们需要不同的指令来处理有符号和无符号数。

所以,默认情况下,divrem作为有符号操作运行。如果操作数是无符号的,则可以使用divu(无符号除法)和remu(无符号余数)。

如果你有一台RV64机器,我们还有四条额外的指令。这些指令将根据寄存器大小进行操作。对于RV64,这些指令接受64位操作数并产生64位结果。

我们还有这些额外的字大小操作,操作码相同,只是后面附加了“W”。这些指令将使用32位执行操作。也就是说,它们将忽略操作数的高半部分或高32位,只查看低32位。它们将产生一个32位结果,然后进行符号扩展并放入目标寄存器。

我们还需要讨论一些与负操作数相关的细节。

以下是除法的定义:a除以b产生商q和余数r,使得这个等式成立。如果操作数是正数,没有问题。只有一个解满足这个等式。但如果我们有负操作数,那么可能有多个解。这里有一个例子表明,多个值将满足这个定义。-7除以-3,在两种情况下,可以产生商为2余数为-1,或者商为3余数为2,这两种情况都满足这里的定义。因此,如果你要使用负值,或者如果你的除法可能涉及负操作数,你需要知道会发生什么。一些架构将其留作实现定义,但在RISC-V的情况下,规范强制要求使用截断除法。

所以,如果你的操作数可能是负数,那么你可能需要更仔细地考虑这个问题,但我只想指出,RISC-V没有将其留作实现依赖,而是强制要求截断除法,这通常更容易实现。

那么,我们之前提到的错误条件呢?那些是除以0和溢出条件。事实证明,RISC-V规范规定了结果应该是什么。例如,当你用一个被除数(如a)除以0时,除法指令应产生一个所有位都设置为1的结果,而余数指令应直接产生被除数本身a。在有符号的世界里,将所有位设置为1的结果解释为-1。作为无符号值,它是最大的正数。

至于溢出,只有当操作数是有符号数,并且我们用最负数除以-1时才会发生。除法指令应产生结果,即最负数本身,而余数指令将产生0。

现在,可以说对于任何指令集架构,确实有两种选择。它可以强制规定错误条件的结果,或者可以将这些留作实现依赖。RISC-V强制规定了结果,我认为这是一个好主意,而不是将这些事情留作实现依赖。理想情况下,没有程序会出现这些错误条件。但实际上,有些程序会。这些程序可能会受到这些错误条件下指令实际操作的影响。根据错误条件的处理方式,它们可能会有不同的结果。因此,为了确保一个可能确实出现这些错误条件的程序在所有RISC-V机器上都能以相同的方式运行,他们强制规定了结果。正如我所说,我认为这是最好的做法。

总结

本节课中我们一起学习了乘法和除法指令。在本视频中,我涵盖了许多乘法指令:我们有产生结果低半部分的基本指令。然后我们有三种不同的指令来产生结果的高半部分,具体取决于我们将操作数视为有符号、无符号还是各一个。对于64位机器,我们还有一条只执行32位乘法的指令。

对于除法,我们既有除法指令也有余数指令,并且对于除法和余数都有有符号和无符号版本。然后对于64位机器,我们还有一个变体,它仅使用32位执行除法操作,产生32位结果,包括有符号值和无符号值。

好了,本视频到此结束,感谢观看,我们下个视频再见。

007:示例程序

在本节课中,我们将学习如何构建一个完整的RISC-V汇编语言程序。我们将以计算斐波那契数列为例,编写一个递归函数,并了解从编译、汇编到链接的完整构建过程。最后,我们还会对比手写汇编代码与编译器生成的代码,分析其中的异同。

概述与准备工作

上一节我们介绍了RISC-V汇编的基础知识,本节中我们来看看如何实际编写一个完整的程序。

我们选择斐波那契数列作为示例。斐波那契数列的定义是:F(0) = 0, F(1) = 1, 对于 n > 1F(n) = F(n-1) + F(n-2)。我们将编写一个名为 Fib 的函数,它接收一个参数 N,并返回第 N 个斐波那契数。例如,Fib(8) 应返回 21

我们将使用递归算法来实现,这有助于演示函数调用、返回、递归以及栈帧的使用。我们还将创建一个 main 函数(用C语言编写)来调用我们的汇编函数,这可以展示C代码与汇编代码如何链接,并用于测试。

在开始编码前,必须彻底理解算法。我们可以先用C语言描述算法并进行测试。

// C语言描述的斐波那契函数
int fib(int n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);
}

为了执行RISC-V汇编代码,需要相应的工具链(编译器、汇编器、链接器)和一个执行环境(如QEMU模拟器)。

程序的构建过程

当我们使用 gcc 这样的命令编译程序时,背后实际上发生了多个步骤。

gcc 是一个包装脚本,它依次调用三个程序:

  1. 编译器:将C源代码(.c)编译成汇编代码(.S)。
  2. 汇编器:将汇编代码(.S)汇编成目标文件(.o),其中包含机器码,但地址尚未确定。
  3. 链接器:将一个或多个目标文件(.o)链接成最终的可执行文件,解析所有符号地址。

我们可以使用特定选项来控制这个过程:

  • -S:只进行编译,生成汇编文件。
  • -c:编译并汇编,生成目标文件,但不链接。
  • 也可以直接调用汇编器(如 riscv64-unknown-elf-as)和链接器(如 riscv64-unknown-elf-ld)。

大型项目通常由多个源文件组成。以下是构建此类项目的一般步骤:

以下是构建多文件项目的步骤:

  1. 分别编译每个C源文件(.c)为目标文件(.o)。
  2. 分别汇编每个汇编源文件(.S)为目标文件(.o)。
  3. 使用链接器将所有目标文件链接成一个可执行文件。

在本例中,我们将创建两个文件:main.c(C代码)和 fib.S(汇编代码)。我们将分别编译/汇编它们,然后链接。

编写汇编函数:Fib

现在,我们开始动手编写 Fib 函数。每个函数开头都应有一个块注释,说明其功能。

我们首先处理递归调用。根据算法 F(n) = F(n-1) + F(n-2),我们需要进行两次递归调用。

# 第一次递归调用:计算 F(n-1)
addi a0, a0, -1       # 参数 n-1
call fib              # 调用 fib(n-1)
mv s1, a0             # 将结果 F(n-1) 保存到 s1

# 第二次递归调用:计算 F(n-2)
addi a0, a0, -2       # 计算 n-2 (注意:此时a0已被第一次调用改变,需要提前计算)
# 我们需要提前计算 n-2 并保存
addi t0, a0, -2       # 假设原始n在a0,计算 n-2
mv a0, t0             # 设置参数
call fib              # 调用 fib(n-2)
# 此时 a0 中是 F(n-2)

# 相加得到结果
add a0, s1, a0        # F(n) = F(n-1) + F(n-2)

接下来,我们需要添加终止条件:当 n <= 1 时,直接返回 n

# 终止条件检查
li a1, 1              # 将立即数1加载到寄存器a1
ble a0, a1, base_case # 如果 n <= 1,跳转到基础情况处理
# 否则,继续执行上述递归代码
...
base_case:
    # 对于 n=0 或 n=1,返回值就是 n 本身,已经在 a0 中
    ret               # 返回

根据RISC-V调用约定,函数如果使用了保存寄存器(如 s1),必须在函数开头保存其原始值,并在返回前恢复。同时,call 指令会改变返回地址寄存器 ra,我们也需要保存它。这些数据通常保存在栈上。

我们需要在栈上分配空间(栈帧)来保存这些寄存器。栈从高地址向低地址增长。

# 函数序言:设置栈帧
addi sp, sp, -24      # 为3个双字(s1, s2, ra)分配栈空间
sd ra, 16(sp)         # 保存返回地址 ra
sd s1, 8(sp)          # 保存寄存器 s1
sd s2, 0(sp)          # 保存寄存器 s2 (我们之后会用s2)

# ... (函数主体代码)

# 函数尾声:恢复寄存器并销毁栈帧
ld s2, 0(sp)          # 恢复 s2
ld s1, 8(sp)          # 恢复 s1
ld ra, 16(sp)         # 恢复返回地址 ra
addi sp, sp, 24       # 释放栈空间
ret                   # 返回

将以上所有部分组合起来,并添加详细的注释,就构成了完整的 fib.S 文件。详细的注释对于理解和维护汇编代码至关重要。

对比编译器生成的代码

我们可以让C编译器为相同的 fib 函数生成汇编代码,使用 gcc -S 选项。将手写代码与编译器生成代码对比,可以发现一些有趣的区别。

编译器生成的代码通常包含大量汇编器指令(以.开头),如 .cfi_*(调用帧信息),用于调试器。其代码结构与手写代码大体相似,但有一些关键差异:

  1. 栈帧管理:编译器生成的代码总是在函数开头分配栈帧并保存寄存器,即使可能立即返回(基础情况)。而手写代码先进行条件判断,仅在需要递归时才设置栈帧。对于斐波那契这种递归深度很大的算法,手写代码在基础情况下的效率更高。
  2. 栈对齐:RISC-V调用约定要求栈指针 sp 必须保持16字节(四字)对齐。编译器生成的代码分配了32字节(16的倍数)的栈帧来满足这一点。手写代码分配了24字节,在仅调用自身的情况下可以工作,但严格来说不符合规范。
  3. 寄存器使用与指令顺序:编译器可能使用不同的寄存器(如用 s2 代替 s1),或调整计算与保存的顺序,但最终逻辑等价。
  4. 冗余代码:编译器有时会生成一些当前函数未使用的设置代码(例如设置帧指针 s0),这是其通用代码生成策略的结果。

总结

本节课中我们一起学习了如何构建一个完整的RISC-V汇编程序。我们以斐波那契数列计算为例,详细介绍了从算法设计、编写汇编函数、管理栈帧和寄存器,到最终编译、汇编和链接的完整流程。

我们特别探讨了递归函数的实现,以及栈帧在支持函数调用和保存上下文中的作用。通过对比手写汇编代码与编译器生成的代码,我们看到了两者在实现细节上的差异,例如栈帧管理策略和对齐要求的处理,这加深了我们对RISC-V调用约定和代码优化的理解。

记住,编写清晰、注释完整的汇编代码,并充分理解底层机制(如栈的操作),是进行高效汇编编程的关键。

008:IEEE 754浮点数标准

在本节课中,我们将要学习计算机中浮点数的表示方法,即IEEE 754标准。无论您是否学习RISC-V,这都是理解现代处理器如何处理小数和极大/极小数值的基础知识。我们将从科学记数法开始,逐步深入到单精度、双精度浮点数的二进制表示,并了解特殊值(如无穷大、NaN)以及舍入和错误处理机制。

科学记数法与二进制浮点数

上一节我们介绍了课程概述,本节中我们来看看浮点数的基本思想。科学记数法用于表示非常大或非常小的数字。在十进制中,一个数可以表示为:
公式:±M × 10^E
其中,M是尾数(有效数字),E是指数。

在二进制浮点数中,我们采用相同的思路,但基数变为2:
公式:±M × 2^E
这里,M是二进制尾数,E是二进制指数。在标准科学记数法中,我们习惯将小数点放在第一个有效数字之后。在二进制中,我们同样将“二进制点”放在第一个1之后。

单精度与双精度浮点数的表示

理解了基本公式后,我们来看看计算机中如何具体存储这两种精度的浮点数。

单精度浮点数使用32位(4字节)存储。其位域划分如下:

  • 1位 用于符号位(s)。
  • 8位 用于指数(E)。
  • 23位 用于尾数(M)。注意,在规范化数中,尾数最高位的1是隐含的,并不实际存储在这23位中。

双精度浮点数使用64位(8字节)存储。其位域划分如下:

  • 1位 用于符号位(s)。
  • 11位 用于指数(E)。
  • 52位 用于尾数(M)。同样,最高位的1是隐含的。

双精度提供了比单精度更大的表示范围和更高的精度。

精度与数值间隙

浮点数的“精度”是一个需要理解的重要概念。由于尾数的位数是固定的,浮点数只能精确表示有限个有理数,数值之间存在“间隙”。

以下是理解精度和间隙的两个例子:

对于一个非常大的单精度数(例如指数为35),其值约为790亿。此时,相邻两个可表示数值之间的间隙是4096(2^12)。尽管如此,这两个数的十进制表示前7位数字是相同的。因此,通常说单精度浮点数约有7位十进制有效数字

对于一个非常小的单精度数(例如指数为-16),其值约为0.000015。此时,相邻两个可表示数值之间的间隙极小(约2^-39)。同样,它们的十进制表示前7位数字也相同。

对于双精度浮点数,约有16位十进制有效数字。关键在于,数值越大,间隙越大;数值越接近0,间隙越小。

特殊值的表示

IEEE 754标准不仅定义了常规数字,还定义了几种特殊值的表示方式,它们通过指数字段的特定模式来区分。

以下是主要的特殊值类型及其含义:

  • :有+0和-0两种表示。指数字段全为0,尾数字段全为0。
  • 无穷大:有+∞和-∞。指数字段全为1,尾数字段全为0。
  • 非数:表示无效操作的结果(如0/0)。指数字段全为1,尾数字段不全为0
  • 非规范数:用于表示非常接近0的数,精度低于规范数。指数字段全为0,尾数字段不全为0,且隐含位为0。

对于单精度浮点数,指数8位字段的编码偏移值为127。因此,规范数的指数实际范围是-126 到 +127。全0和全1的指数模式用于表示上述特殊值。

NaN:静默NaN与信号NaN

在非数(NaN)中,标准进一步区分了静默NaN和信号NaN,但这在RISC-V中通常不是重点。

两者的核心区别在于:

  • 静默NaN:作为操作数参与运算时,结果会安静地传播另一个静默NaN,不会触发“无效操作”异常标志。
  • 信号NaN:作为操作数参与运算时,结果同样传播NaN,但触发“无效操作”异常标志。

信号NaN可用于标记数据流中的缺失值。然而,在RISC-V架构中,通常所有NaN都被视为静默NaN。无效操作标志仅在确实发生非法运算(如对负数开平方根)时才会被设置。

舍入模式与异常条件

由于浮点数表示能力有限,运算结果经常无法精确表示,此时必须进行舍入。同时,运算中也可能出现各种异常情况。

IEEE标准定义了多种舍入模式,由处理器的浮点控制状态寄存器中的字段控制:

  • 向最接近的值舍入(默认且最常用,遇到中间值时向偶数舍入)。
  • 向零舍入。
  • 向正无穷大舍入。
  • 向负无穷大舍入。

在浮点运算过程中,可能会发生以下异常(错误)条件,相应的状态标志位会被置位,且这些标志位是“粘性的”:

  • 不精确:结果被舍入,非精确值。
  • 上溢:结果幅值超出最大可表示范围,返回无穷大。
  • 下溢:结果幅值小于最小可表示规范数,返回0或非规范数。
  • 除零:非零数除以零,返回无穷大。
  • 无效操作:进行了未定义的运算(如0/0,∞-∞),返回NaN。

使用浮点数的注意事项

最后,我们必须警惕,计算机中的浮点数与数学中的实数并不完全相同。

以下是几个重要的注意事项:

  • 存在两个零(+0和-0),它们在比较相等时通常被视为相等,但在某些运算(如1/+0和1/-0)中会产生不同结果(+∞和-∞)。
  • 缺乏结合律:由于舍入误差,(a + b) + c 的结果不一定等于 a + (b + c)
  • 存在舍入误差:许多十进制小数无法用有限二进制精确表示,运算结果也可能需要舍入。
  • 存在表示范围限制:可能发生上溢和下溢。

一个有用的知识点是:任何32位整数(无论有无符号)都可以用64位双精度浮点数精确表示

本节课中我们一起学习了IEEE 754浮点数标准的核心内容。我们了解了单精度和双精度浮点数在二进制中的表示方式,包括规范数、零、无穷大、NaN和非规范数。我们还探讨了舍入的必要性、各种舍入模式以及运算中可能出现的异常条件。理解这些概念是安全、正确进行浮点计算的基础。在下一节中,我们将具体学习RISC-V架构中如何通过指令集来操作这些浮点数。

009:P09-RISC-V-Assembly-Code-#9---Floating-Point-Instructions-(pt.-1)

📋 概述

在本节课中,我们将学习RISC-V处理器架构中的浮点运算指令。这是关于RISC-V浮点指令的第一部分。我们将介绍浮点寄存器、基本的算术运算指令、不同的浮点精度选项,以及浮点控制与状态寄存器。本教程旨在让初学者能够理解这些核心概念。

🧮 浮点寄存器与精度选项

上一节我们介绍了RISC-V的整数寄存器。本节中我们来看看浮点运算专用的寄存器。

如果处理器核心实现了浮点运算扩展,那么除了32个通用整数寄存器外,还会有32个额外的浮点寄存器,命名为 F0F31。与整数寄存器不同,F0 寄存器没有特殊含义。

这些寄存器的大小取决于处理器核心实现了哪些可选扩展。以下是主要的浮点精度选项:

  • F 扩展:单精度浮点数,需要32位。
  • D 扩展:双精度浮点数,需要64位。它依赖于F扩展。
  • Q 扩展:四精度浮点数,需要128位。它依赖于D扩展。
  • Zfh 扩展:半精度浮点数,需要16位。它依赖于F扩展。

无论实现了哪些扩展,都只有一套浮点寄存器,其大小足以容纳所支持的最大精度值。例如,如果实现了F和D扩展,寄存器大小就是64位,可以存放单精度或双精度值。

➕ 基本浮点算术指令

了解了寄存器后,我们来看看如何对它们进行运算。以下是基本的浮点算术指令格式。

浮点指令通常以字母 F 开头,并使用后缀(如 .S.D.H.Q)来指明操作数的精度。例如,单精度加法指令的格式如下:

FADD.S fd, fs1, fs2

这条指令将浮点寄存器 fs1fs2 中的单精度值相加,结果存入目标浮点寄存器 fd

浮点指令的编码与整数指令非常相似,只是操作码不同,处理器据此识别操作的是浮点寄存器。

以下是按精度分类的基本算术指令示例:

  • 单精度(.S)FADD.S, FSUB.S, FMUL.S, FDIV.S, FSQRT.S, FMIN.S, FMAX.S
  • 双精度(.D)FADD.D, FSUB.D, FMUL.D, FDIV.D, FSQRT.D, FMIN.D, FMAX.D
  • 四精度(.Q)FADD.Q, FSUB.Q, FMUL.Q, FDIV.Q, FSQRT.Q, FMIN.Q, FMAX.Q
  • 半精度(.H)FADD.H, FSUB.H, FMUL.H, FDIV.H, FSQRT.H, FMIN.H, FMAX.H

需要注意的是,只有处理器核心实现了相应的扩展,对应的指令才能执行,否则会触发非法指令异常。

📦 浮点数的“装箱”与NaN

在混合使用不同精度时,RISC-V采用了一种称为“装箱”的技术。这涉及到“非数”这个概念。

NaN(Not a Number)是一种特殊的浮点数值,其指数位全为1,且尾数部分至少有一个1。NaN通常用于表示无效操作的结果,例如 0.0 / 0.0

“装箱”是指将一个较低精度的浮点数(如单精度)嵌入到一个较高精度的NaN值中。具体做法是,将低精度数放在高精度寄存器的低有效位,而将高精度数的高位设置为NaN的位模式。

例如,在一个实现了双精度(64位寄存器)的核心上执行单精度(32位)运算时:

  • 对于源操作数,只使用寄存器低32位,高32位被忽略。
  • 对于目标操作数,运算结果放在寄存器的低32位,高32位则被设置为全1(即一个NaN的位模式)。

这样,这个64位的值整体上是一个NaN,但其低32位包含了有效的单精度数值。这种方法允许在统一的64位寄存器中安全地传递和处理单精度值。

🎛️ 浮点控制与状态寄存器

浮点运算的行为由浮点控制与状态寄存器控制。最重要的两个字段是舍入模式字段和异常标志位字段。

舍入模式 决定了当运算结果无法精确表示时该如何处理。RISC-V定义了5种舍入模式,由 FRM 寄存器中的3位编码控制:

  • 000 - RNE (Round to Nearest, ties to Even):向最接近的值舍入,如果恰好居中,则向偶数舍入。
  • 001 - RTZ (Round towards Zero):向零舍入(截断)。
  • 010 - RDN (Round Down / -∞):向下舍入(朝向负无穷)。
  • 011 - RUP (Round Up / +∞):向上舍入(朝向正无穷)。
  • 100 - RMM (Round to Nearest, ties to Max Magnitude):向最接近的值舍入,如果恰好居中,则向绝对值较大的方向舍入。

异常标志位 位于 FFLAGS 寄存器中,用于记录运算过程中发生的异常情况。共有5个标志位:

  • NV:无效操作(如 ∞ + (-∞))。
  • DZ:除零操作。
  • OF:上溢。
  • UF:下溢。
  • NX:结果不精确(需要舍入)。

FCSR 寄存器包含了完整的 FRMFFLAGS 字段。为了方便,也可以单独访问 FRMFFLAGS 寄存器。

以下是用于读写这些寄存器的指令(它们是伪指令,汇编器会将其转换为实际的CSR指令):

  • FRCSR rd:将 FCSR 的值读入整数寄存器 rd
  • FSCSR rd, rs / FSCSR rs:将整数寄存器 rs 的值写入 FCSRrd 可选,用于保存旧值。
  • FRRM rd:将舍入模式寄存器 FRM 的值读入 rd
  • FSRM rd, rs / FSRM rs:设置舍入模式寄存器。
  • FRFLAGS rd:将标志位寄存器 FFLAGS 的值读入 rd
  • FSFLAGS rd, rs / FSFLAGS rs:设置标志位寄存器。

🔍 浮点数分类指令

最后,我们介绍一个有用的诊断指令:FCLASS。这条指令可以检查一个浮点数的类型。

FCLASS 指令将一个浮点寄存器中的值进行分类,并将一个表示其类别的编码存入一个整数寄存器。编码含义如下:

  • 位0:值为 -∞
  • 位1:值为负规约数。
  • 位2:值为负非规约数(次正规数)。
  • 位3:值为 -0
  • 位4:值为 +0
  • 位5:值为正非规约数(次正规数)。
  • 位6:值为正规约数。
  • 位7:值为 +∞
  • 位8:值为发信NaN。
  • 位9:值为静默NaN。

指令格式为 FCLASS.<size> rd, fs1,例如 FCLASS.S rd, fs1

📝 总结

本节课中我们一起学习了RISC-V浮点指令的第一部分内容。我们介绍了32个浮点寄存器,以及支持不同精度(半、单、双、四精度)的选项。我们学习了基本的浮点算术指令,并了解了“装箱”技术如何允许在小精度寄存器缺失时处理小精度数值。我们还探讨了浮点控制与状态寄存器,特别是舍入模式和异常标志位。最后,我们介绍了用于判断浮点数类型的 FCLASS 指令。

在下一部分,我们将深入探讨舍入模式的细节、浮点指令的二进制编码、加载/存储指令、精度转换指令、比较指令以及浮点调用约定等内容。

010:浮点指令(第二部分)

在本节课中,我们将继续学习RISC-V架构中的浮点指令。上一节我们介绍了浮点寄存器、算术指令、不同精度以及浮点控制与状态寄存器。本节中,我们将深入探讨舍入模式、浮点指令的二进制编码、加载/存储指令、类型转换指令、比较指令以及浮点调用约定等核心内容。

舍入模式:动态与静态

上一节我们介绍了浮点运算,本节中我们来看看如何指定舍入模式。RISC-V提供了两种指定舍入模式的方法:动态舍入和静态舍入。

  • 动态舍入:使用浮点控制与状态寄存器中的舍入模式位来决定如何对无法精确表示的结果进行舍入。
  • 静态舍入:直接在指令中指定舍入模式。

以下是汇编语言中指定舍入模式的示例:

  • 若要使用控制与状态寄存器中的舍入模式位,无需在指令中做任何额外操作。
  • 若要在指令中显式指定舍入模式,需添加一个类似第四个操作数的符号。

这些符号即我们之前见过的舍入模式符号:rne(就近舍入)、rtz(向零舍入)、rdn(向下舍入)、rup(向上舍入)和rmm(就近舍入,平局时取最大幅度值)。

舍入模式适用于算术指令以及部分转换指令,并且适用于各种不同的数据大小。对于某些结果总是精确的指令(例如最小值fmin、最大值fmax指令,或将单精度转换为双精度),如果尝试为其指定静态舍入模式,汇编器会报错。

浮点指令编码

了解浮点指令如何编码为二进制机器码涉及许多细节,但我们可以通过一个例子来感受一下。以下是一条浮点减法指令,每条指令都会被编码为一个32位的完整指令。

浮点指令包含多个字段:

  • 棕色字段是操作码位,包括一个主操作码字段和一个更具体的操作码字段(用于确定是加法、减法、乘法等)。
  • 一个2位字段用于确定是单精度、双精度、四精度等。
  • 用于目标寄存器和源寄存器的字段。
  • 一个3位字段用于指定舍入模式。

以这条指令为例,其编码如下。具体来说,舍入模式值001代表向零舍入。我们可以使用任何一种舍入模式。如果不指定任何舍入模式(即使用动态舍入模式),则使用代码111,此时舍入模式将由浮点控制与状态寄存器决定。

加载与存储指令

对于通用寄存器,我们有一系列加载和存储指令,可以在内存和寄存器之间移动字、双字或四字(即4、8或16字节)的数据。

浮点加载和存储指令的概念相同,区别在于我们还有半字版本。因此,我们可以在内存和目标寄存器之间移动2、4、8或16字节的数据。对于加载指令,目标寄存器是浮点寄存器之一;对于存储指令,源寄存器是浮点寄存器之一。地址计算仍然是通过将指令中包含的偏移量与基址寄存器rs1中的值相加来完成,rs1仍然是通用整数寄存器。

需要注意的是,可以使用哪些指令取决于你的核心具体实现了哪些扩展。例如,如果你的核心没有实现四字浮点值,就不能使用加载四字指令。

对于加载到通用整数寄存器的整数加载指令,较小的值会进行符号扩展以填充较大的寄存器。对于浮点加载指令,当数据大小小于寄存器本身时,值会被“装箱”,即作为非数字值的有效载荷被封装起来。

类型转换指令

有一系列转换指令,用于将值从一个源寄存器复制到目标寄存器,并在过程中从一种格式转换为另一种格式。源和目标可以是浮点值或整数值。浮点值使用浮点寄存器指定,整数值使用通用寄存器指定。

以下是转换指令的一般格式。它有一个后缀,用于指明源格式和目标格式,以及源寄存器和目标寄存器。例如,fcvt.s.w 表示从32位整数(源寄存器必须是通用寄存器)转换为单精度浮点值(目标寄存器必须是浮点寄存器)。

关于源和目标格式说明符:

  • 我们可以指定 SDQH 来表示单精度、双精度、四精度或半精度浮点值,此时对应的寄存器必须是浮点寄存器。
  • 或者我们可以指定 WWULLU 来表示32位或64位有符号或无符号整数,此时对应的寄存器应该是通用整数寄存器。

因此,有许多可用的转换指令,具体哪些指令在特定的RISC-V核心上可用,取决于该核心实际实现了哪些可选扩展。

数据移动、绝对值与取反指令

我们还有几条称为浮点移动的指令,可以在通用整数寄存器和浮点寄存器之间直接复制位模式,而不进行任何转换。当我们在通用整数寄存器中有一个位模式时,它会被解释为某个特定的整数。当我们不加任何转换地将这些相同的位移动到浮点寄存器时,它们会被解释为一个单精度浮点数,并具有完全不同的、无关的值。

对于这些移动指令,我们使用格式代码 X 来指示这两个寄存器中哪个是通用整数寄存器。指令 fmv.x.wfmv.w.x 在两者之间移动32位数据。

如果是在RV64机器上并且实现了双精度浮点,我们还有另外两条指令 fmv.x.dfmv.d.x,用于在通用寄存器和双精度浮点寄存器之间移动64位数据。

以下是另外三条有用的浮点指令:

  1. fabs.s:通过清除源浮点寄存器中的符号位来计算绝对值,并将结果移动到目标寄存器。
  2. fneg.s:对一个浮点寄存器中的值取反,并将结果移动到另一个寄存器。
  3. fmv.s:将一个值从一个浮点寄存器移动到另一个浮点寄存器。

值得一提的是,上述三条指令实际上是伪指令,它们由以下三条底层机器码指令实现:fsgnjx.sfsgnjn.sfsgnj.s。如果你关心这些指令的具体作用,可以暂停视频查看,否则我们继续。

浮点比较指令

测试很重要,让我们看看如何比较浮点值。所有比较指令都遵循这种通用方法:检查两个浮点寄存器中的值并进行比较,然后将结果(1或0)移动到目标寄存器(该寄存器是通用整数寄存器之一)。

以下是一个单精度浮点相等性测试的例子,我们也有针对不同大小的变体。执行此指令后,你需要执行一条分支指令来测试该值是否为0。因此,如果两个值相等,这条指令会导致分支;如果你希望在不相等时分支,则使用相反的分支指令。

此时需要指出,由于舍入的存在,浮点数的相等性测试有些棘手。你认为相等的两个值,可能由于舍入而不完全相等。此外,在存在非数字量的情况下,使用关系运算符进行测试也可能有些棘手。

其他关系比较类似。例如,我们有浮点小于测试 flt.s、浮点小于等于测试 fle.s、浮点大于测试 fgt.s 和浮点大于等于测试 fge.s,具体取决于你的核心实现了哪些扩展,它们有各种大小的变体。这些测试都以相同的方式进行:小于、小于等于、大于、大于等于。

值得一提的是,实际上只有小于和小于等于操作是在硬件中实现的。大于和大于等于测试实际上是伪指令,通过交换所涉及的两个寄存器,用小于和小于等于指令来实现。

如前所述,由于舍入问题,用浮点数测试相等性有一定风险。你处理的数字可能并非你想象的那样精确。更好的方法是计算差值,然后与某个阈值(某个epsilon值)进行小于比较。对于非数字量要小心,与非数字值进行小于、小于等于、大于、大于等于的比较被认为是无效操作,控制与状态寄存器中的无效操作位将被设置。但奇怪的是,对于相等比较,这被认为是正常的,无效操作位不会被设置。此外,任何与非数字值的比较都将返回假。这导致了一个反直觉的结果:当你将一个非数字与另一个非数字比较时,结果总是假。所以,如果你取一个代表非数字的位模式,它将与自身(完全相同的位模式)比较为不相等,这并非你所期望的。另外,我们有一个正零和一个负零,这导致了一些不寻常的行为。这两者是相等的,所以它们测试为相等。然而,如果用1除以正零,会得到正无穷大;如果用1除以负零,会得到完全不同的结果——负无穷大。在我看来,这确实挑战了“相等”本身的含义。

融合乘加指令

现在我想谈谈一组称为融合乘加的指令。以下是一个例子,你可以看到这条指令有三个操作数,而大多数RISC-V指令只有一或两个操作数。此外,它执行两个操作:先进行浮点乘法,然后进行浮点加法。

这条特定的指令在许多应用中非常有用,包括数字信号处理等领域。你可能会问,为什么不直接编码两条指令?为什么不使用浮点乘法指令后跟浮点加法指令?一个答案可能是出于性能原因,但我怀疑浮点乘法和加法占据了大部分时间,而指令取指所需的时间并不是这里的关键瓶颈。

还有一个更重要的原因,那就是舍入只在两个操作都执行完成后才进行。通常,舍入是在每个浮点操作之后进行的。但在这里,你延迟了舍入。这将产生更准确的结果,如果你多次重复此操作,这可能至关重要。

融合乘加指令有许多不同的变体,包括针对各种不同精度的变体。我们还有这些变体:融合乘减指令(改变这里的符号)、取反版本(改变这里和这里的符号),最后是结合这两者的版本。

对于三个操作数,很自然会问:我们如何将这个东西编码成二进制机器码?答案如下。你可以看到第三个操作数编码在这里。我们还有两位用于精度,三位用于舍入模式。请记住,每个完整大小的指令在低两位都有两个1,我们需要额外的两位来编码它是哪种融合乘加指令,这为有效操作码只留下了三位。如果你对指令集架构最初是如何设计的以及指令编码是如何选择的感兴趣,你可能会注意到,这意味着所有完整大小的RISC-V指令中有八分之一是融合乘加指令。

浮点寄存器命名与调用约定

到目前为止,我使用诸如 f0f31 这样的名称来指代浮点寄存器。但与通用寄存器一样,浮点寄存器也有备用名称或昵称。我们有用于参数寄存器的特殊名称 fa0fa7,用于临时寄存器的 ft0ft11,以及用于被调用者保存的浮点寄存器的 fs0fs11

以下是这些备用名称与其他名称的对应关系。你不需要记住这些对应关系,因为汇编器会处理这些。你可以直接使用备用名称,而且很可能应该这样做。

这些寄存器以与通用寄存器相同的方式用于相同的目的。如果函数有浮点参数,它们会通过 fa0fa7 寄存器传递。在函数内部,我们可以使用这些寄存器以及临时寄存器,而无需保存先前的值。但我们也有一些被调用者保存的寄存器,因此在使用被调用者保存的寄存器之前,我们需要保存其先前的值,并在返回前恢复该值。

其他可选扩展

在本视频的剩余部分,我想提几个可能不太常见的可选RISC-V扩展。虽然你可能永远不会遇到或需要这些东西,但我想提一下,以防万一。

Zfa 扩展增加了几条新指令,包括这条:浮点加载立即数指令 fli.s。这里的立即数是32个可能的常量值之一,这些值以某种方式编码到指令本身的5位字段中,并将该值移动到目标浮点寄存器。该扩展还包括各种大小的浮点舍入到整数指令 fround,它根据当前舍入模式对浮点寄存器中的值进行舍入,并将结果放入浮点寄存器。如果你必须这样做,可以通过执行转换将其移动到整数通用寄存器,然后再移回浮点寄存器来获得几乎相同的结果。该扩展还包括其他几条更晦涩的指令。

通常,实现浮点的RISC-V处理器会有一组单独的寄存器来保存这些浮点值,这些寄存器就是我们称为 f0f31 的寄存器。但为什么不直接使用通用寄存器(X寄存器)呢?这里的想法是,所有浮点指令的工作方式相同,它们只是对通用X寄存器而不是浮点寄存器进行操作或运算。另一种说法是,浮点寄存器只是X寄存器的别名或其他名称。

那么,为什么为浮点数设置单独的寄存器文件是一个好主意呢?有几个原因。首先,浮点寄存器只能包含浮点数,但没有要求它们必须以IEEE 754规范指定的格式存储这些浮点数。硬件实际上可能使用比IEEE规范更多的位,以使各种操作更快或可能减少硬件需求。例如,指数可能以二进制补码形式存储,而不是IEEE规范中存储的带偏置的奇特方式;此外,指数可能被赋予更多位,从而无需特殊表示非规格化数。另外,我们可能会使通常隐含的前导位变得显式。

尽管如此,我们确实有这里列出的选项。在 FinX 选项中,单精度浮点值存储在通用寄存器中。在 DinX 选项中,双精度浮点值存储在通用寄存器中。最后,在 HinX 选项中,半精度浮点值直接存储在通用寄存器中。

最后,我想提一下 HinXmin 选项,它只为半精度浮点提供非常最小的支持。它只提供几条额外的指令,这些指令可以在半精度和单精度浮点之间进行转换,以及(如果实现了的话)在半精度和双精度浮点之间进行转换。这些指令的理念是,你可以将半精度数转换为更大的精度,对其进行一些算术运算,完成后将其转换回半精度。

总结

本节课中我们一起学习了RISC-V浮点指令的更多细节。

在本视频中,我讨论了舍入的工作原理。我谈到了静态舍入(舍入模式直接包含在指令中)和动态舍入(舍入模式由控制与状态寄存器中的位决定)。我讨论了各种加载和存储指令,用于在内存和浮点寄存器之间移动数据。我讨论了所有形式的转换指令,用于在源精度和目标精度之间进行转换,包括当我们使用 X 时直接复制位而不进行任何转换。我讨论了移动、绝对值和取反指令。我讨论了用于比较两个浮点寄存器的指令,包括相等、小于、小于等于、大于和大于等于。这些指令将通用寄存器设置为真或假以指示结果。我讨论了浮点调用约定,并提到了浮点寄存器的备用名称:以 FA 开头的名称用于参数,以 FT 开头的名称用于临时寄存器,以 FS 开头的名称用于被调用者保存的浮点寄存器。我还谈到了其他一些不太常用的扩展。

011:特权模式与异常处理入门

在本节课中,我们将开始学习RISC-V的特权系统和安全机制。我们将从机器模式、监督者模式和用户模式的介绍开始,讨论控制和状态寄存器,描述直接访问它们的指令,并重点关注状态寄存器。最后,我们将描述陷阱发生时的情况,以及硬件在调用陷阱处理程序代码之前会采取的步骤。

执行模式

在任何时刻,处理器核心都恰好运行在以下三种模式之一。执行模式也被称为特权级别。

  • 机器模式:拥有最高特权,基本上没有安全检查。
  • 监督者模式:拥有中等特权。
  • 用户模式:拥有最低特权级别,许多操作在用户模式下不被允许。

没有任何控制或状态寄存器中的位来指示当前模式。当前特权级别是隐式的。软件被设计为在特定的特权级别下运行,因此当前模式是隐含的。

模式详解

机器模式:这是最高特权级别,允许所有操作和指令。上电或复位线触发时,处理器进入机器模式。对于仅实现机器模式的核心,将始终在此模式下运行。对于更复杂的核心,可以认为运行在机器模式下的代码负责处理陷阱和启动初始化。陷阱包括中断和异常。启动代码在机器模式下运行,执行初始化、设置中断系统,然后通常会跳转到在监督者模式下运行的内核代码。

监督者模式:此模式增加了使用页表实现虚拟内存的能力。任何支持虚拟内存的RISC-V核心都会实现监督者模式。操作系统的内核通常运行在此模式下。内核初始化后,会在用户模式下运行用户代码。

用户模式:这是最低特权级别。在具有操作系统的系统中,所有应用程序和用户级代码都在此模式下运行。在用户模式下,某些操作不被允许。任何执行特权指令的尝试都会导致非法指令异常。当用户模式下发生陷阱时,代码会被中断,陷阱处理程序通常会运行在监督者模式下。

模式组合

一个特定的RISC-V核心可能不会实现所有三种保护级别。以下是允许的组合:

  • 仅机器模式:最简单的RISC-V核心只实现机器模式,完全没有安全性或保护。
  • 机器、监督者、用户模式:更复杂的系统,能够实现操作系统。
  • 机器和用户模式:具有某种程度安全性的系统,但无法实现虚拟内存系统。

控制与状态寄存器

为了管理执行模式和特权机制,RISC-V有许多所谓的控制与状态寄存器。每个CSR都有一个唯一的名称和一个12位的整数编号。用于读写CSR的指令包含一个12位的立即数字段,用于标识要操作的寄存器。

访问控制

CSR的访问受到管制,分为三类:

  1. 仅机器模式:名称通常以M开头。
  2. 监督者或机器模式:名称通常以S开头。
  3. 无限制:通常不以U开头。

寄存器大小

对于32位核心,CSR是32位;对于64位核心,CSR是64位。对于某些需要更大位宽的功能(如周期计数器),在32位核心上使用一对寄存器,例如cycle(低32位)和cycleh(高32位)。

CSR访问指令

以下是访问控制与状态寄存器的机器指令。

基本指令

  • CSR读写指令csrrw rd, csr, rs1
    • 功能:将CSR的旧值读入目标寄存器rd,同时用源寄存器rs1的值更新CSR。这是一个原子操作。如果rdrs1是同一个寄存器,则执行交换操作。
  • CSR读置位指令csrrs rd, csr, rs1
    • 功能:将CSR的旧值读入rd,然后将rs1中为1的位对应的CSR位置1。
  • CSR读清零指令csrrc rd, csr, rs1
    • 功能:将CSR的旧值读入rd,然后将rs1中为1的位对应的CSR位清零。

立即数变体

上述三条指令都有立即数变体(指令名后加i),使用5位立即数字段代替源寄存器,便于访问CSR的低5位。

伪指令

汇编器提供了一些伪指令以简化编程:

  • CSR读csrr rd, csr -> 转换为 csrrs rd, csr, x0
  • CSR写csrw csr, rs1 -> 转换为 csrrw x0, csr, rs1
  • 访问特定CSR:如rdcycle rd, rdinstret rd, rdtime rd用于读取cycle, instret, time寄存器。在32位机器上,还有rdcycleh rd等指令读取高32位。
  • 浮点CSR访问:如frrm rd, fsrm rd, rs1用于浮点舍入模式寄存器。

状态寄存器

最重要的控制与状态寄存器是mstatus。它是一个复杂的寄存器,支持陷阱处理和启动配置。

布局与访问

mstatus寄存器包含多个字段。在机器模式下可以完全访问。在监督者模式下,可以通过sstatus寄存器访问其部分字段(某些字段被屏蔽)。在用户模式下,尝试访问mstatussstatus都会导致非法指令异常。

关键字段

在陷阱处理中,我们主要关注三个位:

  • 中断使能位:控制是否处理异步中断。
  • 先前中断使能位:保存陷阱发生时中断使能位的值。
  • 先前特权位:保存陷阱发生时的执行模式。

陷阱处理

当程序执行时,可能发生异常或中断,它们统称为陷阱。陷阱发生时,当前程序被暂时挂起,调用陷阱处理程序来处理。

异常与中断的区别

  • 异常:与特定指令相关,通常是指令导致的问题(如非法指令、地址违规、对齐问题、页错误)。系统调用指令ecall也会触发异常。
  • 中断:来自外部源,如设备请求、定时器、软件中断或计数器溢出。

陷阱处理流程

陷阱处理分为硬件阶段和软件阶段。

硬件阶段(以用户模式执行ecall指令,由监督者模式内核处理为例):

  1. 禁用中断(将sstatus中的中断使能位置0)。
  2. 保存先前的中断使能状态和执行模式。
  3. 将陷阱原因代码(例如,用户模式ecall的代码是8)写入scause寄存器。
  4. 将发生陷阱的指令地址(ecall的地址)保存到sepc寄存器。
  5. 将程序计数器设置为stvec寄存器中的值(陷阱处理程序的入口地址)。
  6. 开始执行陷阱处理程序的第一条指令。

软件阶段(陷阱处理程序):

  1. 保存通用寄存器(可使用sscratch寄存器辅助)。
  2. 检查scause以确定陷阱原因并处理。
  3. 处理完成后,准备返回:将sepc的值加4(指向ecall之后的下一条指令)。
  4. 恢复通用寄存器。
  5. 执行sret指令。该指令会恢复先前保存的中断使能位和执行模式,并跳转到sepc指向的地址继续执行。

异常原因代码

硬件会将一个代码编号存储在scause(监督者模式)或mcause(机器模式)寄存器中,指示刚刚发生的陷阱类型。以下是一些主要的异常代码示例:

  • 指令地址未对齐
  • 指令访问故障
  • 指令页错误
  • 加载/存储地址未对齐
  • 加载/存储访问故障
  • 加载/存储页错误
  • 断点
  • 环境调用ecall,根据执行模式不同代码不同)
  • 非法指令

总结

本节课我们一起学习了RISC-V的特权模式与异常处理基础。我们了解到:

  1. RISC-V核心在任何时刻都运行在机器、监督者或用户三种模式之一,复位后从机器模式开始。
  2. 存在许多控制和状态寄存器,每个都有12位地址和特定功能,其访问权限取决于当前特权级别。
  3. 状态寄存器mstatus是关键寄存器,其部分内容可通过sstatus在监督者模式下访问。我们重点学习了其中与中断使能和模式保存相关的位。
  4. 我们学习了读写CSR的指令,如csrrwcsrrscsrrc及其伪指令。
  5. 任何违反特权级别的CSR访问尝试都会导致非法指令异常。
  6. 我们详细分析了陷阱处理的流程,包括硬件自动完成的步骤和陷阱处理程序软件需要完成的工作。
  7. 最后,我们列举了RISC-V中定义的各种异常原因代码。

理解这些概念是构建或编写RISC-V系统软件(如操作系统内核)的基础。

012:异常、中断与PLIC

在本节课中,我们将深入学习RISC-V架构中的陷阱处理机制。我们将详细探讨异常与中断的区别,介绍平台级中断控制器,并解释如何将中断和异常从机器模式委托给监管者模式处理。同时,我们也会讲解如何全局或单独地启用或禁用中断。

陷阱处理回顾

上一节我们介绍了不同的特权级别以及陷阱处理的基本流程。本节中,我们将更深入地探讨陷阱处理,并区分机器模式与监管者模式下的陷阱处理。

当陷阱发生时,正在执行的代码会被挂起,并调用陷阱处理程序。每个陷阱处理程序要么在监管者模式下运行,要么在机器模式下运行。

机器模式与监管者模式陷阱处理

之前我们主要关注异常,但本节将更多地讨论中断。我们将概述平台级中断控制器,并讨论中断和异常如何从机器模式委托给监管者模式。

陷阱处理程序在监管者模式下运行时,只能访问监管者模式的控制和状态寄存器,无法访问机器模式的CSR。这些寄存器对监管者模式是不可见的。运行在监管者模式下的陷阱处理程序只能处理发生在用户模式或监管者模式下的中断和异常。

在陷阱发生的瞬间,硬件会执行几个动作。首先,硬件会保存先前的处理器状态,包括程序计数器、特权模式和中断启用位。接着,模式会切换到监管者模式。最后,会跳转到陷阱处理软件例程的开头。陷阱处理程序最终通过执行 SRET 指令结束,该特权指令会恢复先前的特权级别并返回到被挂起的代码。

本节将同时讨论机器模式和监管者模式。虽然之前讨论了监管者模式陷阱,但有些陷阱将由运行在机器模式下的陷阱处理程序处理。机器模式陷阱的处理方式与监管者模式陷阱基本相同。

那么,是什么决定了一个特定的中断或异常是由监管者模式还是机器模式陷阱处理程序处理呢?我们将在本节后面讨论这个问题,但有一点可以明确:在机器模式下运行时发生的陷阱,总是由机器模式陷阱处理程序处理。

实际的陷阱处理过程非常相似。同样,当陷阱发生时,硬件首先保存处理器状态,然后从当前模式切换到机器模式,并跳转到机器模式陷阱处理程序。在处理程序中,代码可以访问机器模式和监管者模式的控制和状态寄存器。处理程序最终通过执行 MRET 指令结束,该指令与 SRET 指令非常相似。

控制与状态寄存器

RISC-V最多支持4096个控制和状态寄存器,因此使用12位地址来寻址各个CSR。有些寄存器是只读的,而其他寄存器也可以被更新。寄存器的模式也很重要。例如,用户CSR可以在任何当前特权级别下访问,而监管者CSR只能在监管者或机器模式下访问。最后,机器模式CSR只能由运行在机器模式下的代码访问。

RISC-V还有几条用于访问控制和状态寄存器的指令。例如,我们有 CSRRS(CSR读置位)和 CSRRC(CSR读清零)指令来分别读取和修改CSR。当前模式决定了给定操作是否被允许。在机器模式下,我们拥有最高特权,可以访问任何类型的寄存器;而在用户模式下,只能访问用户模式CSR。例如,在监管者模式下运行时,如果代码试图读取机器模式的控制和状态寄存器,或试图更新只读寄存器,则会触发非法指令异常。

MSTATUS与SSTATUS寄存器

这是 MSTATUS 寄存器,它包含许多位字段。该寄存器及其字段只能在机器模式下访问。然而,其中一些字段也需要在监管者模式下可访问。为了满足这一点,有一个单独的寄存器叫做 SSTATUS。因此,有些位是共享的,它们同时存在于两个寄存器中。我们可以说这些位是镜像的,底层只有一个副本。该字段可以通过访问 MSTATUS 寄存器或 SSTATUS 寄存器来读取或修改。

以下是镜像并在监管者模式下可访问的位。其他位在读取 SSTATUS 时是不可见的,它们会显示为零,任何修改它们的尝试都会被硬件忽略。

之前,当我们描述监管者模式下如何处理陷阱时,我们看了这三个位:中断启用位、先前中断启用位和先前特权模式。这些位在发生需要由监管者模式处理的陷阱时使用。

但是,当陷阱需要由运行在机器模式下的陷阱处理程序处理时,我们有三个不同但相似的字段:中断启用位、先前中断启用位和先前特权模式,但这些都针对机器模式。请注意,先前特权字段现在是2位而不是1位,因为当我们用机器模式陷阱处理程序处理陷阱时,先前的特权级别可能是用户、监管者或机器模式。

陷阱处理流程详解

陷阱处理包括硬件阶段和软件阶段。这里展示的是当发生监管者级别陷阱且陷阱处理程序在监管者模式下运行时的情况。下一张幻灯片将展示机器级别陷阱且处理程序在机器模式下运行的情况。两者非常相似。

当异常或中断发生时,硬件首先保存当前状态。具体来说,它保存特权级别和中断启用位的先前值,然后通过将中断启用位改为0来禁用中断。这些都是监管者级别的操作。它还将原因寄存器设置为一个代码编号,以指示发生了何种陷阱,并将程序计数器保存在 SEPC 中。最后,它将程序计数器设置为先前存储在 STVEC 控制和状态寄存器中的值。这通常是监管者级别陷阱处理程序第一条指令的地址。

因此,监管者级别陷阱处理程序将开始运行,它首先保存寄存器,然后处理陷阱,查看原因代码寄存器以确定陷阱类型及需要采取的措施。处理完陷阱后,它将通过恢复寄存器并最终执行 SRET 指令返回。SRET 指令会将中断启用位恢复为保存的值,将操作模式恢复为先前的特权级别,并将程序计数器设置为保存在 SEPC 寄存器中的值。

当我们查看机器模式下发生的情况时,会发现它非常相似。唯一的区别在于使用了哪些位和哪些寄存器。当发生机器级别陷阱时,即需要由运行在机器模式下的陷阱处理程序处理时,会发生完全相同的事情。唯一的区别是使用了 MSTATUS 寄存器,以及我们有 MCAUSEMEPCMTVECMSCRATCH 寄存器。同样,我们保存状态,但这次是将状态保存在 MSTATUS 寄存器中,并将机器模式下的中断启用位改为零。我们设置原因寄存器、EPC寄存器,然后跳转到机器模式陷阱处理程序。MTVEC 指向机器模式陷阱的处理程序。同样,它保存寄存器,根据原因代码寄存器的当前值处理陷阱,恢复寄存器,最后执行 MRET 指令。同样,这几乎与 SRET 指令相同,只是它使用 MSTATUS 寄存器中的值,并将PC恢复为保存在 MEPC 寄存器中的值。

陷阱处理模式的决定因素

接下来的问题是,究竟是什么决定了陷阱是在监管者模式还是机器模式下处理?如果核心甚至没有实现监管者模式,那么所有陷阱显然都将在机器模式特权级别处理。否则,决定权在于机器模式代码。我们说机器模式可以将部分或全部陷阱委托或卸载给监管者模式。接下来,我们将讨论这种委托是如何工作的。

首先,正如我们所看到的,监管者模式和机器模式都有自己私有的寄存器集,用于陷阱处理。监管者模式陷阱将使用第一行中的寄存器,而机器模式陷阱将使用名称以M开头的寄存器。此外,还有几个机器模式控制和状态寄存器,一个用于异常委托,一个用于中断委托。这些是名为 MEDELEGMIDEL 的机器模式控制和状态寄存器。通常,它们由启动时运行的机器代码初始化,并且在初始化后通常永远不会更改。

当陷阱发生时,硬件会查询这些委托寄存器,以确定该陷阱是应该调用监管者模式还是机器模式陷阱处理程序。

总结一下,如果陷阱发生在核心在监管者模式或用户模式下执行时,默认情况下,它将由运行在机器模式下的陷阱处理程序处理。但是,陷阱可以被选择性地委托,如果被委托,则该陷阱将由运行在监管者模式级别的陷阱处理程序处理。另一方面,在机器模式下运行时发生的所有陷阱都将由机器模式级别的陷阱处理程序处理。

异常委托寄存器

这是异常委托寄存器 MEDELEG 的布局。我们有多种不同类型的异常。例如,可能有非法指令异常或加载页错误异常等。对于每种不同的异常类型,该寄存器中都有一个对应的位。

如前所述,如果异常发生在机器模式下执行时,那么它总是由运行在机器模式下的陷阱处理程序处理。但是,如果异常发生在用户或监管者模式下执行时,硬件将查询该寄存器,并查看与实际发生的异常对应的位。如果该位为0,则硬件将调用运行在机器模式下的陷阱处理程序;但如果为1,则异常将被委托,硬件将调用运行在监管者模式下的陷阱处理程序。

这些位位置正好对应于异常代码编号。这里列出了所有异常及其对应的代码编号,这是异常发生时将存储在原因寄存器中的代码。

当执行 ECALL 指令时,它可能引发这三种异常中的任何一种。如果在用户模式下执行,它引发此异常;在监管者模式下执行,引发此异常;如果在机器模式下执行,则是代码11。然而,如果在机器模式下执行 ECALL,那么陷阱处理程序将在机器模式下执行,它永远不能被委托。所以在之前的图中,这就是为什么这个位是绿色的,这个位在 MEDELEG 寄存器中不使用。

委托机制的使用场景

以下是我对运行Linux等操作系统的RISC-V系统如何使用异常委托机制的最佳猜测。

Linux内核将在监管者模式下运行,而机器模式仅用于初始化和可能的安全启动。用户模式下可能发生的所有类型的异常都需要委托给监管者模式,以便操作系统内核能够处理它们。这将包括加载、存储和指令获取的问题。

来自用户空间的系统调用需要调用内核例程,因此在用户模式下遇到的 ECALL 将被委托给监管者模式。为了支持使用断点指令调试用户程序,该异常也将被委托。

正如我们所说,来自机器模式的 ECALL 不能被委托。我的猜测是,运行在机器模式下的代码首先根本不需要执行 ECALL 指令。

至于硬件错误,我认为调用机器模式代码是合理的,例如,可能只是重新启动操作系统。来自监管者模式的 ECALL 可能不会被委托。这将允许运行在监管者级别的内核向机器模式代码发出请求。例如,内核可能通过执行 ECALL 指令请求机器模式代码重新启动。

中断委托

中断也可以以类似于异常的方式委托。这是中断委托寄存器 MIDEL。有几种中断源,例如,设备可以发出称为外部中断的信号。对于每种来源,都有一个对应的位来指示是否要委托。

如果中断发生在机器模式,当然不能被委托,并且总是调用机器模式陷阱处理。否则,当中断发生时,硬件将检查该寄存器以确定是调用监管者模式还是机器模式陷阱处理程序。如果该位为1,则中断被委托给将在监管者模式下运行的代码。

基本上有四种类型的中断。每当I/O设备需要关注时,它就会引发或发出外部中断。定时器中断更偏向于核心内部。定时器中断被操作系统内核用于实现时间片。我们还有所谓的软件中断。软件中断可以直接由代码引发,而不是由I/O设备引发,它可能被一个核心用来唤醒另一个核心,或者在该核心需要引起注意时发出信号。最后,核心可能实现许多硬件性能监控计数器。如果其中一个溢出,则会发生本地计数器溢出中断。

中断类型与编号

现在,让我解释一些缩写并介绍中断的编号系统。

这里有四种潜在的中断源:软件中断、定时器中断、外部中断和本地计数器溢出中断。这三种类型的中断实际上各有两种形式,称为监管者模式和机器模式。据我理解,这种监管者模式与机器模式的区别,与中断发生时的特权级别或中断处理程序将运行的模式关系不大。每种中断都可以根据需要在机器模式下处理或委托给监管者模式。

监管者模式软件中断可以由运行在监管者模式或机器模式下的代码引发,而机器模式软件中断只能由运行在机器模式下的代码引发。我们有两个不同的定时器。一个将引发监管者模式定时器中断,另一个将引发机器模式定时器中断。机器模式定时器是不可见的,运行在监管者级别的代码无法看到。

至于设备引发哪种类型的外部中断,这取决于布线方式,我稍后会讨论。

现在让我们看看缩写和相应的代码编号。这些正是我之前展示的 MIDEL 寄存器中的位编号。实际上,更像是这样。我猜在一个典型的RISC-V系统中,所有监管者模式中断都将被委托,而机器模式中断都不会被委托。

平台级中断控制器

接下来,我想介绍并简要描述平台级中断控制器。假设我们有许多核心和许多I/O设备。PLIC本身也是一个硬件设备,其目的是仲裁中断,因此它将是实际在各个核心引发外部中断的设备。

PLIC是一个设备,像每个设备一样,它是内存映射的。这意味着核心可以通过使用内存地址来更新和与设备通信。例如,PLIC必须在启动时初始化,以便它知道如何处理各种中断。

因此,当设备想要中断时,它不直接中断核心。相反,它与PLIC通信,然后PLIC将决定需要通知哪个或哪些核心,并通过在这些核心中引发外部中断来通知它们。这些核心将运行中断处理程序。中断处理程序做的第一件事就是与PLIC通信以认领中断。它基本上是对PLIC说:“我来处理这个中断。”其他核心可能同时尝试认领该中断,但只有一个会成功,其他核心将放弃并返回它们正在做的事情。

因此,认领了中断的核心将运行中断处理程序直至完成,直接通过内存映射地址与I/O设备通信。完成后,它将通知PLIC该中断已完成,我们说该中断已退休。因此,中断处理程序将通过通知PLIC中断现已退休而结束。此时,来自同一设备的新中断可以被重新处理,循环可以重新开始。

PLIC操作流程

当平台级中断控制器在某个核心引发外部中断时会发生什么?这由几个控制和状态寄存器控制。对于每种中断类型,例如外部中断,有两个位:一个是挂起位,一个是启用位。为了发出中断信号,硬件强制挂起位为1。但陷阱处理程序可能不会立即被调用。相反,中断可以被屏蔽。换句话说,中断要么被启用,要么被禁用。如果它们被启用,陷阱处理程序会立即被调用;如果被禁用,中断变为挂起状态,导致陷阱处理程序延迟到以后。

中断是被处理还是变为挂起状态,由全局中断启用位和中断特定的启用位共同控制。全局中断启用位就是状态寄存器中的中断启用位。每种中断类型都有自己的启用位。当陷阱被发出信号时,要么清除挂起位并发生陷阱处理,要么如果这两个位中的任何一个被禁用,则陷阱保持挂起状态。中断将保持挂起状态,直到调用处理程序或挂起位被外部降低,例如当其他核心已认领中断且在此核心上调用陷阱处理程序的需要不再存在时,由PLIC本身降低。

让我用这张图来描述平台级中断控制器的操作。

我们有几个核心、主内存、几个设备,包括PLIC本身。我们假设核心使用内存映射I/O与设备和PLIC通信。因此,这里有某种共享总线,核心可以通过直接使用内存地址读取和修改寄存器来与设备和PLIC通信。这些虚线表示从设备到PLIC的中断请求线。因此,当设备需要关注时,它会拉高这条线。这边的线表示PLIC使外部中断在各个核心挂起的能力,即引发外部中断并使其变为挂起状态。

因此,初始化期间需要做的第一件事是初始化PLIC。PLIC将包含一些配置寄存器等,它会告诉哪些设备应该中断哪些核心以及优先级等。因此,在某个核心使用内存映射I/O初始化PLIC之后,我们开始执行。

假设在某个时间点,某个设备需要关注,因此它拉高中断请求线并通知PLIC。PLIC将查询其配置寄存器等,并确定需要中断哪些核心。有些核心可能能够处理此设备,因此需要中断它们,而其他核心可能无法处理此设备,或者由于其他原因,PLIC可能被配置为当此设备请求中断时不应通知那些核心。

因此,PLIC将向其中一些核心发出外部中断信号,如图所示。实际上,我只显示了一条线,但PLIC可能发出监管者级别外部中断或机器级别外部中断,发出哪种信号可能由启动时在PLIC内初始化的配置参数决定。

但无论如何,其中一个核心将被中断。因此,假设这里的核心是第一个运行其陷阱处理程序的,它将使用内存映射I/O联系PLIC,声明它认领此中断。然后,PLIC很可能会降低到其他核心的外部中断线,即虽然它最初使外部中断在此核心挂起,但它会降低该挂起线并使挂起位为零,这意味着尚未有机会服务中断的核心将根本不会被中断,它可以继续做它正在做的其他事情。

好的,这个核心现在已经认领了中断,所以现在它需要处理设备,它将使用内存映射I/O来做到这一点,因此它将读取和修改I/O设备中的寄存器并执行任何必要的操作。

假设当所有这些发生时,其他设备也在请求中断。PLIC是一种多任务硬件,因此它可以处理这些,并且可能同时通知其他核心。因此,这些可以独立认领,也许它可能通知这个核心,这两个其他设备正在请求关注。但无论如何,这与我们感兴趣的设备和中断大致同时并发进行。

因此,在未来的某个时间,这个核心将完全处理完中断,然后它将停止与设备的通信,并且还将通过内存映射I/O通知PLIC此中断现已退休,因此我们回到起始情况。

中断挂起与启用寄存器

最后,我们可以展示挂起位和启用位。指示中断是否挂起的位位于名为 MIP 的机器模式控制和状态寄存器中。指示中断是否启用的位位于名为 MIE 的寄存器中。正如您所见,位布局与名为 MIDEL 的中断委托寄存器完全相同。每种中断类型都有一个位,因此我们有软件中断、定时器中断和外部中断。我们既有机器模式中断的位,也有监管者模式中断的位,还有一个用于本地计数器溢出中断的位。

挂起位的字段名称都以P结尾,启用位的字段名称都以E结尾。这两个寄存器只能由运行在机器模式下的代码访问。

可以通过将挂起位之一设置为1来发出中断信号。这可以直接由软件完成,也可能由硬件完成。例如,平台级中断控制器可以设置外部中断的挂起位,定时器将设置定时器中断位。这些寄存器中的一些位也可以由运行在监管者模式下的代码读取和更新。

MIPMIE 是机器模式寄存器。为了便于这一点,还有两个额外的控制和状态寄存器在监管者模式下可访问。这些寄存器名为 SIPSIE。可以在监管者模式下读取或写入的位是监管者模式中断,以及本地计数器溢出中断。这些位是镜像的,换句话说,对于同时出现在两个寄存器中的字段只有一个位,并且该位可以通过任一方式访问。

现在我的假设是,通常一个RISC-V系统会在启动时将所有监管者模式中断委托给监管者模式处理。机器模式中断通常不会被委托,并且总是由运行在机器模式下的陷阱处理程序处理。然而,委托寄存器中存在允许委托机器模式中断的位表明,通用的RISC-V设计具有一定的灵活性,可以以我们可能意想不到的方式使用。

中断屏蔽

我想再谈一点关于中断屏蔽的内容。我们已经在刚刚描述的 MIESIE 寄存器中有了位,可以选择性地启用和禁用个别类型的中断,但现在我想关注全局中断启用位,这里我指的是 MSTATUS 寄存器中的 MIE 位和 SSTATUS 寄存器中的 SIE 位。

顺便说一下,这些位的名称在这里是大写的,但具有相似名称的寄存器 MIESIE 是用小写名称书写的。

当核心在用户模式下执行时,这些全局位被忽略。中断不能被禁用,陷阱处理总是会发生。陷阱处理程序将在监管者模式或机器模式下运行,取决于它是否被委托。

当核心在监管者模式下执行时,取决于陷阱发生在监管者模式还是机器模式,即陷阱处理程序将在监管者模式还是机器模式下运行。机器模式陷阱永远不会被屏蔽,这些陷阱总是会被处理。监管者模式代码将被挂起,陷阱处理程序将在机器模式下运行。然而,监管者模式陷阱可以被禁用,因此 SIE 位控制监管者模式代码是否可以被也将运行在监管者模式下的陷阱处理程序中断。

最后,当核心在机器模式下执行时,所有监管者模式陷阱都被禁用。机器模式代码不会被中断以运行监管者模式代码。然而,机器模式陷阱可能被接受也可能不被接受,这由 MSTATUS 寄存器中的 MIE 位决定。

总结

本节课我们涵盖了许多相当复杂的材料。首先,我们回顾了当中断或异常发生时,硬件和软件阶段的处理过程。我们看到陷阱可以由机器模式陷阱处理程序或监管者模式陷阱处理程序处理。机制非常相似,只是使用不同的寄存器集。

我们查看了 MSTATUS 寄存器,发现有些字段在监管者模式下也可访问,这是通过将这些位镜像到 SSTATUS 寄存器中实现的。然后,我们看到了异常和中断如何从机器模式委托给运行在监管者模式下的陷阱处理程序处理。我们还讨论了四种不同类型的中断:外部中断、定时器中断、软件中断和本地计数器溢出中断。

我们还介绍了平台级中断控制器,它是核心外部的一个设备,接收来自各种I/O设备的中断,并将其引导到特定核心,在那里它们导致外部中断变为挂起状态。最后,我们讨论了用于指示哪些中断挂起和哪些中断启用的寄存器。

好的,如果您一直跟随到这里,非常感谢您的观看。让我们在下个视频中再见。

013:mstatus与sstatus寄存器详解 🧠

在本节课中,我们将深入学习RISC-V架构中的mstatussstatus状态寄存器。我们将详细探讨除了陷阱处理之外的所有剩余字段,包括控制寄存器大小、字节序、虚拟内存访问以及加速上下文切换的机制。这些知识对于理解处理器如何管理不同特权模式下的状态至关重要。


寄存器概述

RISC-V架构中存在两个版本的状态寄存器:mstatus(机器模式状态)和sstatus(监管者模式状态)。sstatus包含了监管者模式代码(通常是操作系统内核)所需的字段,并且只能在监管者模式下访问。mstatus则包含了所有这些字段以及一些额外的字段,并且只能在机器模式下访问。

在之前的课程中,我们讨论了陷阱处理,并介绍了图中标红的字段。当发生监管者模式陷阱时,硬件会将中断使能位(SIE)清零以禁用中断,同时将SIE的先前值保存在SPIE字段中,并设置SPP字段为之前的特权级别。对于机器模式陷阱,则使用MIEMPIEMPP字段,其工作原理相同。


寄存器大小控制字段:SXL与UXL

mstatus寄存器中的SXLUXL字段用于控制代码在监管者模式和用户模式下看到的寄存器大小。RISC-V规范支持32位(RV32)、64位(RV64)甚至128位(RV128)核心。寄存器实际大小由机器模式决定(MXLEN),但可以通过这些字段为运行在更低特权级的代码“缩小”寄存器视图。

  • SXL (Supervisor XLEN): 控制监管者模式下代码看到的寄存器大小。只能由机器模式代码修改。
  • UXL (User XLEN): 控制用户模式下代码看到的寄存器大小。可以由机器模式或监管者模式代码修改。

寄存器大小只能缩小,不能放大。必须始终满足:MXLEN >= SXLEN >= UXLEN

SXLUXL字段均为2位,编码如下:

  • 00: Reserved
  • 01: 32位 (RV32)
  • 10: 64位 (RV64)
  • 11: 128位 (RV128)

此功能允许为32位机器编写的软件无需修改即可在64位机器上运行。但请注意,具体核心可能并未实现此功能。


字节序控制字段:MBE、SBE与UBE

RISC-V核心默认是小端序(Little-Endian)机器。指令取指始终使用小端序且不可更改。然而,对于加载(Load)和存储(Store)操作,核心可能提供选项。

状态寄存器中的三个位控制加载和存储指令的行为:

  • MBE: 控制机器模式下执行的加载/存储操作。若为1,则使用大端序(Big-Endian)。
  • SBE: 控制监管者模式下执行的加载/存储操作。
  • UBE: 控制用户模式代码的加载/存储操作。

MBESBE仅存在于mstatus寄存器中,这意味着机器模式和监管者模式代码的字节序只能由机器模式代码改变。内核(运行在监管者模式)获得机器模式代码赋予的字节序,自身无法更改。但是,内核可以为其运行的应用程序进程设置为大端序模式。

与改变寄存器大小一样,此灵活的字节序功能可能并未在您的核心上实现,这些位可能被硬连线为0,核心始终为纯小端序。


内存访问权限字段:MPRV、SUM与MXR

上一节我们了解了如何控制数据访问的字节序,本节我们来看看几个用于在特定场景下调整内存访问权限的字段。

以下是这些字段的详细说明:

  • MPRV (Modify Privilege Level)

    • 问题: 当机器模式陷阱处理程序需要模拟一条(例如,用户模式下发出的)未对齐加载指令时,它需要使用虚拟地址。但机器模式下通常不进行页表转换。
    • 解决方案: 设置MPRV=1。此后,任何加载/存储指令将按照陷阱发生时保存的先前特权级别(MPP字段)来执行,就好像处理器正运行在那个模式一样,从而可以使用虚拟地址和页表。陷阱返回后,此位自动清零。
  • SUM (Supervisor User Memory)

    • 问题: 页表中的页面被标记为用户(U)页面或监管者页面。默认情况下,运行在监管者模式的内核无法访问用户页面,这增强了安全性。但在系统调用时,内核可能需要读写用户虚拟地址空间中的参数或结果。
    • 解决方案: 临时设置SUM=1,允许内核(运行在监管者模式)读写标记为用户(U)的页面。这需要内核的地址映射能够同时涵盖内核代码和用户数据区,在32位地址空间受限的情况下可能存在挑战。
  • MXR (Make eXecutable Readable)

    • 问题: 当内核需要模拟一条用户模式指令时,首先需要读取该指令。但包含该指令的页面可能只标记为可执行(X),而不可读(R)。
    • 解决方案: 设置MXR=1。此后,内核可以从仅标记为可执行(X)的页面加载数据,而不会引发页面错误异常。内核可能会长期保持此位为1。

上下文切换加速字段:FS、VS、XS与SD

在多任务操作系统中,内核需要在不同进程(上下文)之间切换。切换时需要保存和恢复进程的状态。为了加速此过程,RISC-V提供了状态跟踪字段,使得内核可以仅保存和恢复实际被修改过的状态。

这些字段的工作原理类似,我们以FS字段(跟踪浮点寄存器状态)为例进行说明。FS是一个2位字段,其编码定义了四种状态:

  1. Off (00): 浮点寄存器未被使用。任何访问尝试将引发异常。
  2. Initial (01): 进程已声明将使用浮点寄存器,但尚未使用。寄存器内容为初始值(如零)。内核需要在时间片开始时初始化它们,但结束时无需保存。
  3. Clean (10): 寄存器在上一个时间片被修改过(“脏”),但在当前时间片尚未被修改。内核需要在时间片开始时从保存的值中恢复它们,但当前时间片结束时无需再次保存(值未变)。
  4. Dirty (11): 寄存器在当前时间片已被修改。内核需要在时间片开始时恢复它们,并在时间片结束时保存它们。

硬件会监控对浮点寄存器的写操作,并据此自动更新FS字段的状态。这样,对于大多数不使用浮点寄存器的程序,内核可以完全避免保存/恢复这些寄存器带来的开销。

  • VS字段: 以相同方式跟踪向量(Vector)寄存器的状态。
  • XS字段: 用于跟踪任何其他非标准(实现定义)扩展状态的状态。
  • SD字段: 这是一个只读的摘要位(Summary Dirty)。当FSVSXS中任何一个字段表明状态为DirtyClean(即非Off且非Initial)时,SD位被置1。内核可以通过快速检查SD位(它位于寄存器的最高位)来判断在上下文切换时是否有任何额外状态需要处理,从而优化常见情况(无需处理)下的性能。

虚拟机监控程序支持字段:TSR、TVM与TW

RISC-V架构支持虚拟机监控程序(Hypervisor),它是一种运行在机器模式的“超级监督者”,可以托管多个运行在监管者模式的客户操作系统(Guest OS)。为了支持这种虚拟化,需要控制客户操作系统对某些关键操作的访问。

以下是相关的控制位:

  • TSR (Trap SRET)

    • 当客户操作系统(监管者模式)执行SRET指令准备返回到用户模式时,虚拟机监控程序可能需要介入。
    • 设置TSR=1会导致SRET指令引发一个机器模式陷阱,从而使虚拟机监控程序获得控制权。
  • TVM (Trap Virtual Memory)

    • 客户操作系统拥有自己的页表,但虚拟机监控程序需要管理物理内存的分配和隔离,防止客户操作系统访问不属于它的内存。
    • 设置TVM=1会导致任何尝试写入SATP寄存器(页表基址寄存器)的操作引发陷阱,让虚拟机监控程序能够检查和批准新的页表配置。
  • TW (Timeout Wait)

    • RISC-V提供了WFI(Wait For Interrupt)指令,使硬件线程进入低功耗休眠状态,直到中断发生。
    • 在虚拟化环境中,一个客户操作系统执行WFI可能只是因为其当前无任务可做,但其他客户操作系统可能任务繁忙。我们不希望硬件线程因此闲置。
    • 设置TW=1会导致在监管者模式执行WFI指令时引发一个机器模式陷阱。虚拟机监控程序可以接管并决定是否真的让硬件线程休眠,或是切换到其他客户操作系统。

低功耗指令:WFI

WFI(Wait For Interrupt)指令没有操作数。当执行此指令时,处理器会暂停当前硬件线程的指令执行,并可能进入低功耗状态以节省能耗(例如在移动设备上节省电池)。当中断(如定时器中断、I/O中断)发生时,处理器会唤醒并执行相应的陷阱处理程序。

在多核系统中,WFI只暂停执行它的那个特定硬件线程。此指令的具体行为取决于实现,它可能确实使核心进入低功耗状态,也可能被实现为空操作(NOP)。在虚拟化环境中,其行为受上述TW位控制。


总结

本节课我们一起深入学习了RISC-V架构中mstatussstatus状态寄存器的各个字段。我们了解了如何通过SXLUXL字段控制监管者和用户模式下的寄存器视图大小。探讨了通过MBESBEUBE字段控制数据访问的字节序。学习了MPRVSUMMXR字段如何为陷阱处理程序和内核提供特殊的内存访问权限。掌握了FSVSXSSD字段如何通过跟踪状态使用情况来显著加速上下文切换过程。最后,我们简要介绍了为支持虚拟机监控程序而设计的TSRTVMTW字段,以及用于降低功耗的WFI指令。理解这些状态位对于编写高效的系统软件和深入理解RISC-V处理器管理机制至关重要。

014:杂项控制与状态寄存器

在本节课中,我们将要学习RISC-V架构中机器模式和监管者模式下剩余的许多控制与状态寄存器。这包括深入探讨一些与陷阱处理相关的寄存器,并介绍一系列描述、控制核心及其配置的新寄存器,以及用于监控硬件性能的计数器寄存器、与实时时钟和定时器中断相关的寄存器。

概述

上一节我们介绍了中断、异常以及机器模式和监管者模式下的陷阱处理机制,并详细讲解了Mstatus和Sstatus寄存器中的所有字段。本节中,我们来看看其他重要的控制与状态寄存器,它们提供了关于处理器核心配置、陷阱原因、性能监控和定时器管理的关键信息。

MISA寄存器:指令集架构描述

MISA寄存器是一个26位的寄存器,每一位对应字母表中的一个字母,用于指示实现了RISC-V规范的哪些主要扩展。

  • 例如,第5位(对应字母F)为1表示实现了浮点扩展。
  • 第3位(对应字母D)为1表示实现了双精度浮点扩展。

其高2位是一个名为MXL的字段,用于指示当前运行的是RV32、RV64还是RV128核心。机器模式寄存器的长度在符号上表示为MXLEN。MXL字段是只读的,无法通过运行在机器模式的代码改变寄存器大小。此寄存器是必需的,但如果未实现,读取时将返回全0。

以下是如何确定寄存器大小的代码片段,它不依赖MISA寄存器:

# 确定寄存器大小
li a0, 4          # 将值4(二进制...0100)加载到寄存器a0
slli a0, a0, 31   # 左移31位。在32位机器上,这将使1移出,结果为0
beqz a0, regs_are_32 # 如果结果为0,跳转到32位处理标签

slli a0, a0, 31   # 再左移31位。在64位机器上,这将使1移出,结果为0
beqz a0, regs_are_64 # 如果结果为0,跳转到64位处理标签
j regs_are_128    # 否则,是128位机器

MHARTID寄存器:硬件线程ID

在多核系统中,代码需要能够确定它正在哪个核心上运行。MHARTID寄存器提供了当前执行硬件线程的编号。

  • 在一个具有8个简单核心(每个核心只执行单一线程)的系统中,该系统将有8个硬件线程(hart)。
  • 在一个具有8个核心、每个核心可同时执行2个线程的同步多线程系统中,则会有16个硬件线程,编号通常为0到15。

必须有一个硬件线程编号为0,该线程可以作为主线程,负责整个系统和其他核心的初始化和启动协调工作。

MCAUSE与SCAUSE寄存器:陷阱原因

当陷阱发生时,硬件会将一个编号存储到原因寄存器中。机器模式陷阱处理使用MCAUSE寄存器,监管者模式陷阱处理使用SCAUSE寄存器。

陷阱可能由中断或异常引起,原因寄存器最高有效位(符号位)用于区分是中断(1)还是异常(0)导致了陷阱。将重要位放在符号位,便于用单条指令快速检查并分支。

以下是异常和中断的编码:

  • 异常编码示例:0=指令地址未对齐, 1=指令访问错误, 2=非法指令, 3=断点, 4=加载地址未对齐, 5=加载访问错误, 6=存储/AMO地址未对齐, 7=存储/AMO访问错误, 8=环境调用(来自U模式), 9=环境调用(来自S模式), 11=环境调用(来自M模式), 12=指令页错误, 13=加载页错误, 15=存储/AMO页错误。
  • 中断编码示例:1=监管者模式软件中断, 3=机器模式软件中断, 5=监管者模式定时器中断, 7=机器模式定时器中断, 9=监管者模式外部中断, 11=机器模式外部中断。

注意,数字7同时用于“存储原子内存操作访问错误”异常和“机器模式定时器中断”。因此,符号位对于准确判断陷阱类型至关重要。

MTVEC与STVEC寄存器:陷阱向量基址

当陷阱(异常或中断)发生时,硬件会立即跳转到陷阱处理程序的第一条指令。为此,硬件需要从名为MTVEC(机器模式)或STVEC(监管者模式)的陷阱向量寄存器中获取陷阱处理程序的地址。软件应预先将这些寄存器加载为相应模式陷阱处理程序的地址。

陷阱处理程序必须起始于字对齐地址(地址可被4整除,最低两位为0)。实际上,地址的最低两位隐含为0,寄存器中的这两位用于存储一个2位的编码。

  • 编码 00:最简单的模式。所有陷阱都跳转到同一个陷阱处理程序。处理程序需要查看原因寄存器来决定采取何种操作。
  • 编码 01:向量化模式。存在多个陷阱处理程序,硬件根据发生的陷阱类型跳转到不同地址。
    • 所有异常跳转到MTVEC寄存器中直接存储的基地址。
    • 每个中断跳转到由 基地址 + (中断编码 * 4) 计算出的地址。

在向量化模式下,软件通常在基地址处放置一个跳转表。跳转表是一个跳转指令数组。第一个条目(偏移0)跳转到异常处理程序。由于中断编码都是奇数,后续条目间隔8字节,以容纳可能需要两条指令(8字节)的长跳转。

厂商与实现信息寄存器

以下是三个提供核心具体信息的只读寄存器:

  1. MVENDORID寄存器:每个RISC-V芯片制造商被分配一个唯一的ID号。此寄存器是必需的,但如果未实现,可硬连线为0。
  2. MARCHID寄存器:用于进一步通过指定芯片微架构来描述实现。最高位指示实现是开源(0)还是闭源(1)项目。具体编码值由RISC-V组织或制造商分配。
  3. MIMPID寄存器:提供关于芯片IP实现的附加信息。RISC-V规范未具体说明此寄存器的用途。

硬件性能监控计数器

RISC-V支持一系列用于监控和测量性能的硬件计数器,它们会自动递增。

以下是机器模式下可访问的计数器寄存器:

  • mcycle:记录自复位以来的时钟周期数。
  • minstret:记录已执行(或退休)的指令数。
  • mhpmcounter3mhpmcounter31:共29个计数器,用于计数所谓的“硬件事件”。具体计数内容由实现定义,例如页错误异常次数、TLB未命中次数等。

这些计数器都是64位大小。在32位机器上,每个计数器由两个32位的CSR实现:低32位寄存器(如mcycle)和高32位寄存器(如mcycleh)。

此外,每个计数器(3-31)对应一个mhpmevent3mhpmevent31事件寄存器,软件可写入这些寄存器来控制相应计数器具体对什么事件进行计数。具体事件定义由实现决定。

还有一个mcountinhibit寄存器,其32位中的每一位对应一个计数器(第0-2位及第7位对应mcycleminstretmhpmcounter3mtime的占位)。将某位置1可以暂停对应的计数器。

监管者与用户模式下的计数器访问

上面列出的计数器寄存器仅在机器模式下可访问。此外,还存在另一组名称相同但无“m”前缀的寄存器(如cycleinstrethpmcounter3-31time)。它们是机器模式版本寄存器的只读镜像,可能对运行在监管者或用户模式的代码可见。

计数器在监管者模式下的可见性由机器模式寄存器mcounteren控制。该寄存器的每一位对应一个计数器(包括time)。如果机器模式代码将某位置1,则对应的计数器寄存器在监管者模式下可读。

如果核心实现了监管者模式,则计数器在用户模式下的可见性由监管者模式寄存器scounteren控制,其格式与mcounteren相同。内核可以通过设置此寄存器的位,来决定是否将计数器可见性传递给用户模式代码。任何尝试读取未启用(即不可见)的计数器寄存器的操作,都会导致非法指令异常。

实时时钟与定时器中断

实时时钟mtime不是一个控制与状态寄存器。规范规定,应存在一个内存映射的寄存器来保存当前时间,该寄存器为64位只读。具体细节(如时钟滴答间隔、如何设置/复位/校正)由具体实现定义。通常,系统中的所有硬件线程共享一个公共的实时时钟。

还有一个寄存器mtimecmp,用于指示何时产生下一个定时器中断。它也是内存映射的,并且可写。每个硬件线程通常有自己的mtimecmp寄存器。当mtime的当前值达到或超过mtimecmp中设置的值时,硬件会设置机器模式定时器中断挂起位(MTIP),从而触发定时器中断。

对于监管者模式下的定时器中断,最初的做法是机器模式代码模拟:机器模式处理真实的定时器中断,然后手动设置监管者模式定时器中断挂起位(STIP)来通知监管者模式代码。

另一种更高效的方法是使用可选的监管者时间比较扩展。如果实现了此扩展,则会添加一个名为stimecmp的控制与状态寄存器。这是一个64位可读可写的寄存器,监管者模式的内核可以直接写入它来设置下一次定时器中断的时间。当mtime超过stimecmp的值时,STIP位会自动被置位。启用此扩展后,STIP位对所有代码(无论是通过MIP访问的机器模式代码,还是通过SIP访问的监管者模式代码)都变为只读。

其他寄存器

  • M配置指针寄存器:可能包含一个物理地址,指向描述当前系统信息的特殊内存映射硬件或只读存储器。具体细节由实现定义。
  • 环境配置与安全配置寄存器:RISC-V规范中提到了这些机器模式寄存器,用于修改核心的各种行为属性,但具体细节尚未最终确定。

总结

本节课我们一起学习了RISC-V架构中机器模式和监管者模式下的多种控制与状态寄存器。

  • MISA寄存器描述了核心实现的指令集架构。
  • MHARTID寄存器给出了当前执行硬件线程的编号。
  • MCAUSE/SCAUSE寄存器在陷阱发生时由硬件填入,指示陷阱的具体原因。
  • MTVEC/STVEC寄存器存储了陷阱处理程序的基地址,并支持单一处理和向量化两种分发模式。
  • MVENDORIDMARCHIDMIMPID寄存器提供了关于核心制造商、微架构和实现的只读信息。
  • 硬件性能监控计数器(如mcycleminstretmhpmcounter3-31)用于监控时钟周期、指令执行数和各种硬件事件。通过mcounterenscounteren寄存器可以控制它们在监管者模式和用户模式下的可见性。
  • 实时时钟通过内存映射寄存器mtime实现。
  • 定时器中断通过mtimecmp(机器模式)和可选的stimecmp(监管者模式)寄存器进行设置,当mtime的值达到比较寄存器中的值时触发中断。

下一节,我们将深入探讨物理内存保护系统及其相关寄存器。

015:PMP物理内存保护系统

概述

在本节课程中,我们将学习RISC-V架构中的物理内存保护系统。该系统允许运行在机器模式下的代码对地址空间的特定区域施加访问权限和约束,从而保护主内存、只读内存以及内存映射的I/O设备。

物理内存保护系统简介

物理内存保护系统通过将地址空间划分为多个区域,并对每个区域施加读、写和/或执行权限来实现保护。运行在监管模式或用户模式下的内核及其他代码的访问会受到检查,任何违规操作都会导致访问错误异常。

上一节我们介绍了特权模式的基本概念,本节中我们来看看如何利用PMP机制实现内存访问控制。

PMP的应用场景

以下是PMP系统的一些典型应用场景:

  • 托管多个操作系统:运行在机器模式下的虚拟机监控程序可以同时托管多个运行在监管模式下的客户操作系统。PMP可以限制每个操作系统只能访问特定区域,确保它们互不干扰。
  • 实现安全启动:PMP可用于保护启动代码和关键配置区域。
  • 构建实时操作系统:在需要确定性的实时系统中,PMP可以替代或补充虚拟内存分页机制,提供内存隔离。
  • 增强安全基础设施:保护内核代码和数据区域,防止恶意软件篡改或执行非授权代码。

PMP与虚拟内存分页的区别

需要明确的是,物理内存保护机制与虚拟内存分页机制是两个不同的概念。一个特定的RISC-V系统可能同时实现两者,也可能只实现其一,而一些简单的系统可能两者都不提供。

分页机制由运行在监管模式下的内核管理,用于实现虚拟内存。而PMP机制由机器模式代码配置,用于在物理地址空间层面施加访问限制。

PMP的工作原理

为了理解PMP,我们首先需要了解系统的物理地址空间布局。硬件平台会有一个物理地址映射,其中部分地址对应主内存,部分对应只读内存,还有部分映射到各种I/O设备。

PMP系统将整个物理地址空间划分为多个区域。机器模式代码可以为每个区域设置标签,例如标记为不可访问、只读、可读写或可执行等。

例如,机器模式代码可以将其自身所在的区域、只读内存以及某些设备标记为内核不可访问。任何来自内核或用户代码对这些区域的访问尝试都会被禁止并引发异常。

PMP寄存器组

PMP方案通过一组控制和状态寄存器来实现。每个受保护区域对应一个物理内存保护地址寄存器和一个物理内存保护配置寄存器。

配置寄存器只有8位,其中包含读、写和执行权限位,以及一个锁定位和一个地址匹配模式字段。一个RISC-V系统最多可以容纳64个这样的区域。

寄存器命名需要注意:地址寄存器以数字结尾,而配置寄存器则将数字放在中间。例如,地址寄存器名为 pmpaddr0,而对应的配置字节名为 pmp0cfg,它被打包在名为 pmpcfg0 的CSR寄存器中。

区域边界定义

区域的边界由地址寄存器中的值指定。每个区域从前一个区域的结束地址开始,延伸到其自身地址寄存器所包含的地址。

更精确地说,地址寄存器N指向区域N的末尾,即包含该区域之后第一个字的地址。第一个区域从地址0开始,延伸到 pmpaddr0 寄存器包含的地址。

对于32位RISC-V核心,PMP地址寄存器是32位大小。但由于支持分页,物理地址最多可达34位。PMP区域必须至少是字对齐的,因此任何地址的最后两位必须为0。这意味着32位的地址寄存器实际上隐含了额外的两位0,从而能够支持34位的物理地址。

配置寄存器详解

在32位RISC-V核心上,所有CSR都是32位大小。为了容纳64个区域的配置字节,需要将4个配置字节打包到一个CSR寄存器中。因此,总共需要16个CSR寄存器。

每个配置字节包含以下关键字段:

  • R(读)W(写)X(执行):权限位。
  • A:2位的地址匹配模式字段。
  • L:锁定位。

当代码在监管模式或用户模式下尝试访问内存(通过加载、存储或取指)时,硬件会检查对应的权限位。如果相应位为0,则操作不会执行,并会触发访问错误异常。

地址匹配模式

地址匹配模式字段决定了如何解释对应的地址寄存器来定义区域范围。

  • A=0 (OFF):该区域被禁用。
  • A=1 (TOR):顶地址模式。这是我们之前描述的模式,区域N的范围是从 pmpaddr[N-1]pmpaddr[N]
  • A=2 (NA4):自然对齐的4字节区域。区域大小固定为4字节,地址寄存器包含要保护的字地址(最后两位隐含为0)。适用于保护单个I/O设备寄存器。
  • A=3 (NAPOT):自然对齐的2的幂次方区域。区域大小是2的幂(从8字节开始)。地址寄存器中的值同时编码了区域的起始地址和大小。硬件通过查找从最低位开始的第一个0的位置来确定区域大小,并将该位置及更低位的所有比特置零以得到对齐的起始地址。

锁定机制

之前我们忽略了锁定位,假设它为0(未锁定)。当区域未锁定时,监管模式和用户模式的访问会被检查,而机器模式的访问总是被允许,且对应的地址和配置寄存器可以被机器模式代码修改。

一旦锁定位被设置,情况将完全不同:

  1. 地址寄存器和配置字节被固定,即使是机器模式代码也无法更改。它们将保持锁定状态直到下一次系统复位。
  2. 对于已锁定的区域,机器模式下的访问也会像监管模式和用户模式一样受到权限检查。

锁定功能可以用于在需要运行非受信机器模式代码时,保护关键代码或知识产权软件不被读取或修改。

粒度大小

到目前为止,我们假设的粒度大小是4字节(一个字),即所有地址必须字对齐,区域大小是4字节的倍数。但有些系统可能不支持如此精细的控制。

系统的粒度可能更大,例如256字节。在这种情况下:

  • 所有地址必须对齐在256字节边界上。
  • 每个区域的大小必须是256字节的倍数。
  • 地址匹配模式 A=2 (NA4) 将不被允许,因为系统不支持那么小的区域。

可以通过软件方式探测系统的粒度大小:将一个PMP配置字节设置为TOR模式,向对应的地址寄存器写入全1,然后读回该值。硬件会强制将低阶位置零以对齐,通过检查被置零的比特位数,可以推断出粒度大小。

访问检查规则

硬件在每次监管模式或用户模式下的加载、存储或取指操作时,都会按顺序检查PMP寄存器。第一个匹配访问地址的区域将决定该访问的权限。

如果访问的地址不在任何已定义的区域内,则该访问被视为违规。

PMP寄存器都是机器模式寄存器,只能由运行在机器模式下的代码读取或修改。

总结

本节课我们一起学习了RISC-V的物理内存保护系统。我们了解到:

  • PMP通过一组机器模式CSR将物理地址空间划分为多个区域,并为每个区域设置读、写、执行权限。
  • 当监管模式或用户模式代码尝试进行无权限的访问时,会触发访问错误异常。
  • 一个核心最多可实现64个保护区域,也可能完全不实现PMP。
  • 每个区域由一个地址寄存器和一个配置字节描述,支持多种地址匹配模式(TOR, NA4, NAPOT)。
  • 锁定机制可以永久冻结区域的配置,并同时对机器模式访问实施权限检查。
  • PMP可以与虚拟内存分页系统协同工作,从物理层面约束操作系统能访问的地址范围。

PMP系统为构建安全、可靠且支持多租户的RISC-V系统提供了重要的硬件基础。

016:虚拟内存 #1 - 页表、PTE与Sv32

概述

在本节课中,我们将要学习虚拟内存、虚拟地址空间和分页的基本概念。我们将以RISC-V架构,特别是其32位版本RV32的Sv32分页方案为例,进行具体而深入的讲解。这些概念是现代计算机系统的核心,理解它们对于掌握计算机体系结构至关重要。

虚拟地址空间与物理地址空间

每个用户程序都运行在其独立的虚拟地址空间中。这个地址空间包含了该程序所有的代码和数据,但不包含内核或其他进程的代码和数据。

与之相对的是唯一的物理地址空间。物理地址空间包含了主内存和各种内存映射的I/O设备,每个设备都有一个由硬件确定的固定地址。内核负责管理物理地址空间,并用它来为每个用户进程实现一个虚拟地址空间。

分页机制

每个虚拟地址空间被划分为多个虚拟页。同样,物理地址空间也被划分为多个物理页。在RISC-V中,页的大小固定为4KB。每个4KB的虚拟页将被放置到一个4KB的物理页中。

下图展示了一个虚拟地址空间及其到物理地址空间的映射关系:

图中显示,该系统的物理地址空间包含一些内存和位于特定物理地址的I/O设备。虚拟地址空间的页被映射到物理页。例如,当用户程序读写地址0(即用户程序看到的地址空间的第一个页)时,实际上是在读写真实内存中的这个物理页。

我们还可以看到,物理地址空间的某些区域是空的。同时,虚拟地址空间的一些页是未映射的,实际上是空的且不可用。此外,一个包含I/O设备的物理页被映射到了虚拟地址空间的这个页,这使得用户代码能够读写该特定设备。

多进程与地址空间

内核在任何时候都会管理多个虚拟地址空间:一个用于每个用户进程,可能还有一个用于内核本身。上图只展示了两个,但足以说明概念。在实际系统中,地址空间包含的页数远多于图中所示。

为了简化,让我们只关注一个虚拟地址空间,并聚焦于从虚拟页到物理页的映射过程。

地址转换与页表

虚拟地址由硬件转换为物理地址,这个过程使用一个称为页表的数据结构。每个虚拟地址空间都有自己的页表。

页表可以被看作一个函数:输入一个程序生成的虚拟地址,输出一个物理地址和一些保护位。每当用户进程执行加载、存储指令或取指时,都会使用这个映射。加载和存储指令中使用的地址是虚拟地址,它将被硬件转换为物理地址。取指时,程序计数器包含一个虚拟地址,在硬件获取指令之前,必须先将该地址转换为物理地址。

页表与TLB

这个映射由存储在内存中的页表数据结构实现。硬件每次访问内存时都必须读取这个数据结构。实际上,每次取指都可能需要几次额外的内存读取操作。因此,为了使这个方案高效可用,我们还有称为转换后备缓冲器(TLB)的组件。TLB本质上是页表中重要条目的缓存,这使得大多数时候无需从内存读取页表。我们将在后面描述TLB。

页保护机制

每个虚拟页都可以被标记访问权限,以实现安全性和保护。每次查询页表以将虚拟地址映射为物理地址时,也会检索这些安全权限。硬件将检查以确保操作被允许,并阻止任何没有适当权限的访问。

我们可以将一个页标记为可读、可写和/或可执行,这分别决定了我们是否可以从该页加载、向该页存储或从该页取指。

例如,如果程序试图向一个未标记为可写的页进行存储,硬件将产生页错误异常。通常,内核随后会以某种错误信息终止该进程。

我们还可以将页标记为有效或无效。在之前的图中,我们看到一些未映射到物理页的虚拟页,它们被标记为灰色。任何访问此类页的尝试都会导致页错误。虚拟地址空间很大(通常为4GB),但大多数页从未被需要,因此实际上不消耗物理内存。这些页被称为无效页,即未映射的页。

此外,我们可以将每个虚拟页标记为用户页或监管者页。这通过一个U(用户)保护位实现。如果该位设置为1,则这是一个用户页;如果为0,则表示这是一个监管者页。在RISC-V中,用户程序在用户模式下运行,内核在监管者模式下运行。一些页属于内核,这些页将被标记为非用户页。

用户模式下的代码可以访问标记为U的页。但用户模式程序任何访问内核页的尝试都将导致页错误。为了防止恶意软件,我们需要确保用户页中的代码永远不会以监管者权限执行。因此,内核(即在监管者模式下运行的任何代码)只能访问监管者页。违反此规则将导致页错误。

实际上,RISC-V架构阻止内核代码访问所有用户页,包括加载和存储指令。然而,在某些情况下,内核需要从用户的地址空间获取数据或将数据存储回用户的地址空间。例如,系统调用的参数传递和结果返回有时需要这种访问。为此,状态寄存器中有一个称为SUM(监管者用户内存访问)的位,可以用来覆盖此保护。如果内核临时将此位设置为1,那么对标记为用户页的页进行加载和存储现在将被允许。

页大小与地址划分

在RISC-V和一些其他系统中,页大小固定为4KB,即4096字节。由于2^12 = 4096,我们可以使用12位来选择页内的任何字节。所有页都必须在页边界上对齐,即每个页必须位于能被4K整除的地址上,这意味着地址的低12位将为零。

每个地址,无论是虚拟地址还是物理地址,都可以分为两部分。高位可以被视为页号,低12位是页内的字节偏移量。我们将看到,页表实际上映射的是页号到页号,而不是地址到地址。它们将虚拟页号映射到物理页号。地址的偏移部分只是原样复制,保持不变。

RISC-V的分页方案

RISC-V有32位版本RV32和64位版本RV64。关于虚拟内存和分页,有以下选项:

  • 32位机器:分页可以开启或关闭。如果关闭,则没有页表和虚拟内存,所有地址都是物理地址。如果开启,则程序生成的每个内存地址都是某个虚拟地址空间中的虚拟地址。当前生效的页表决定了是哪个地址空间。寄存器是32位大小,因此程序生成的每个地址都是32位。页表会将其映射到一个34位的物理地址。32位地址将地址空间大小限制在4GB,但在开启分页的情况下,32位机器实际上可以访问和使用16GB的物理内存。
  • 64位机器:寄存器当然是64位大小,因此程序生成的地址(即虚拟地址)将是64位。但高位会被忽略。例如,在Sv39方案中,只使用低39位,高25位被忽略。这允许了更大的虚拟地址空间(实际上达到512GB)。虚拟地址被映射到一个56位的物理地址空间,可容纳真正巨大的物理地址空间。

所有这些系统都非常相似,主要区别在于虚拟地址空间的大小。因此:

  • Sv32的虚拟地址是32位。
  • Sv39的虚拟地址是39位。
  • Sv48的虚拟地址是48位。
  • Sv57的虚拟地址是57位。

对于64位机器,所有物理地址都是56位。当然,没有系统可能拥有这么多物理内存,因此物理地址的一些高位将被硬件忽略。一个给定的64位核心可能实现所有这些分页选项,也可能不实现。

我们稍后会看到,每个页表都用一个树形结构表示。所有这些选项之间的主要区别在于页表树的层级数:

  • Sv32使用2级树。
  • Sv39使用3级树。
  • Sv48使用4级树。
  • Sv57使用5级树。

深入Sv32

现在,让我们开始详细描述Sv32。物理地址是34位大小,我们可以看到每个物理地址可以分为两部分:一个22位的页号和一个12位的页内偏移。

22位给了我们大约400万个页(实际上是2的幂次方)。12位的偏移量使每页有4096字节。将400万乘以4096,我们得到一个16GB的物理地址空间。

页表数据结构:基数树

如果你想要一个数据结构来将虚拟页号映射到物理页号(这实际上只是将整数映射到整数),你可能会考虑数组、链表或树。数组不行,因为我们有很大的地址间隙。链表线性搜索太耗时。你也可能想到二叉树或红黑树之类的东西。

在二叉树中,每个节点有两个子节点。而我们实际使用的是称为基数树的结构。在基数树中,每个节点有n个子节点。Sv32方法使用的基数树中,每个节点最多有1024个子节点。这些树扩展得非常快,因此对于Sv32页表,我们只需要一个两级树。注意,2^10 = 1024。仅用两层内部节点,我们就可以达到100万个叶子节点。这正是我们4GB虚拟地址空间所需要的。

这棵树充满了我们称为页表项(PTE)的东西。树包含100万个页表项,虚拟地址空间中的每个页都有一个。因此,对于每一个虚拟页号(共有100万个),硬件将搜索这棵树以获取描述它的页表项。

Sv32页表结构

下图展示了一个Sv32页表,它是一个两级树:

有一个控制和状态寄存器叫做SATP(推测代表监管者地址表指针),它包含这棵树根节点的地址。

每个节点是一个包含1024个页表项的数组。每个页表项指向下一层的一个节点。由于一个页表项是4字节大小,每个节点正好是一个页的大小,即4KB。

根节点中的页表项指向下一层的节点,我们可以称这些为内部页表项。底层(叶子节点)的页表项指向实际的数据页,我们可以称这些为叶子页表项。然后,在页表节点之外,我们有4KB的数据页,这些页包含用户代码和数据,它们是虚拟地址空间中实际存在的字节。

页表项格式

现在让我们仔细看看页表项。在Sv32中,页表项是4字节大小,具有以下格式:

| 物理页号 (22位) | 保留位 (2位) | RSW (2位) | D | A | G | U | X | W | R | V |

字段说明如下:

  • 物理页号:22位,指向物理页。
  • V(有效位):指示此页表项是否有效。无效的项对应之前图中灰色的部分。
  • R(可读)、W(可写)、X(可执行):指示数据页是否可读、可写和/或可执行。
  • U(用户位):指示该页是用户页还是监管者页。
  • G(全局位)、A(访问位)、D(脏位):我们将在后面描述。
  • RSW:两个硬件会忽略的位,内核可以以任何方式使用这些位。

所有这些位对于叶子页表项(即指向数据页的页表项)都很重要。每个数据页必须至少是可读、可写或可执行的。如果你根本无法访问数据页,那将毫无意义。

因此,可读、可写和可执行位也用于区分叶子页表项和内部页表项。内部节点中的页表项应将这三个位设置为零。在Sv32中,它只使用两级树,唯一的内部节点就是根节点。但对于使用更多层级页表树的64位机器,所有内部节点都必须将这三个位设置为零。叶子层的页表项(即指向实际数据页的页表项)如果有效,则这三个位中至少有一个应设置为非零值。

总结

本节课中,我们一起学习了虚拟地址空间和物理地址空间的概念,并了解了它们各自如何被划分为多个页。我们讨论了页表是如何实现从虚拟页号到物理页号的映射。每个页的大小是4KB,因此页内偏移是任何地址的低12位,高位给出页号。

页表由存储在内存中的树形结构表示。在32位机器的Sv32方案中,树有两级,而64位机器使用Sv39、Sv48和Sv57,分别对应3、4和5级树。

页表树中的每个节点都是一个页表项数组。内部页表项指向树中下一层的节点,叶子页表项指向实际的数据页。

最后,我们查看了页表项的格式。每个虚拟页都有多个保护位,用于指示该页是否可读、可写或可执行,以及它必须在用户模式下访问还是由在监管者模式下运行的代码访问。我们还看到,虚拟地址空间中的每个页要么有效,要么无效。

在下一节课中,我们将描述内存管理单元(MMU)如何遍历页表树,以便将虚拟地址转换为物理地址。你可能对这个过程感到好奇,但这将是下一节课的内容。

017:虚拟内存 #2 - MMU,页表遍历,缺页异常,大页

在本节课中,我们将学习虚拟内存和分页机制。这是关于RISC-V架构的系列视频之一,本视频是该系列的第二部分。上一节我们介绍了虚拟内存的主要概念,本节中我们将通过一个具体示例——RISC-V中使用的SV32方案——来详细说明这些概念。

我们将看到虚拟地址如何被划分为多个索引字段和一个页内偏移。首先,我们将通过动画演示页表树的遍历过程,然后给出更精确的算法定义。你将了解内存管理单元如何将虚拟地址转换为物理地址。我们还将描述缺页异常,当用户代码试图访问无效页面或违反页面权限标志时,就会触发此类异常。最后,我们将介绍并讨论大页。

虚拟地址结构

本节我们重点介绍SV32分页方案,它使用两级页表树。

虚拟地址大小为32位(至少在我们现在描述的SV32方案中如此)。这些地址有时被称为程序生成地址,因为它们是程序实际看到和使用的地址,例如在加载和存储指令中。

如前所述,虚拟地址被分为两部分:虚拟页号(VPN)和偏移量(12位)。虚拟页号是20位,而物理页号是22位。20位可以表示大约100万个虚拟页(2^20 ≈ 1,048,576)。每个页面包含4096字节,因此虚拟地址空间大小为4GB。

现在,让我们将这20位的虚拟页号进一步拆分为两个部分,每部分10位。我们可以称它们为VPN1和VPN0。2^10等于1024。第一个字段VPN1将用作页表树根节点的索引。第二个字段VPN0将用作树中第二级节点的索引。

地址转换过程:动画演示

接下来,我们将通过动画演示硬件如何获取一个虚拟地址并将其转换为物理地址。

我们的页表树包含一个根节点、多个第二级节点以及底部的数据页。树的每个节点都是一个包含1024个页表项的数组。这些页表项包含指向下一级的指针。每个页表项包含一个22位的物理页号,这实际上是物理内存中一个页面的地址。

我们暂时假设树中的所有条目都是有效的(即V位设置为1),并且不会遇到导致缺页异常的未映射虚拟地址空间区域。

转换步骤如下:

  1. 硬件从SATP寄存器获取根节点的地址。
  2. 硬件查看虚拟地址中的第一个10位字段VPN1,将其视为根节点的索引。它将此索引与指针相加,然后从该节点的特定槽中获取页表项。
  3. 这将得到一个新的物理页号,即树中下一个节点的地址。
  4. 然后,硬件查看VPN0,即虚拟地址中的第二个10位字段,将其视为第二级节点的索引。
  5. 硬件使用该索引获取下一个页表项的地址,并从第二级节点获取一个页表项。
  6. 这将得到数据页的物理地址,同时也会获取该数据页的保护位,以便检查其合法性并在必要时触发缺页异常。
  7. 接着,硬件构建物理地址:从页表项获取物理页号,然后从虚拟地址获取偏移量,并将它们组合起来。
  8. 最终,我们得到了物理地址,硬件可以使用它来读取或写入内存。

地址转换算法

现在,我们以算法形式(类似代码)再次描述这个过程。虽然这里以程序代码的形式表达,但这些操作实际上是由硬件执行的。这样看比动画演示更精确。

该算法接收一个虚拟地址并计算物理地址。它使用两个变量:A(地址指针,包含物理地址)和PTE(页表项)。

以下是算法的伪代码描述:

// 初始化:A指向页表树根节点
A = satp.ppn << 12; // 根节点的物理地址

// 第一级遍历:使用VPN1索引根节点
index = (va >> 22) & 0x3FF; // 提取VPN1 (10 bits)
pte_addr = A + index * 4;   // 计算PTE地址(每个PTE 4字节)
PTE = memory_load(pte_addr); // 从内存加载PTE
if (!PTE.V) raise_page_fault(LOAD/STORE/FETCH); // 检查有效位
A = PTE.PPN << 12; // 获取并设置下一级节点的物理地址

// 第二级遍历:使用VPN0索引第二级节点
index = (va >> 12) & 0x3FF; // 提取VPN0 (10 bits)
pte_addr = A + index * 4;
PTE = memory_load(pte_addr);
if (!PTE.V) raise_page_fault(LOAD/STORE/FETCH);

// 构建物理地址
pa = (PTE.PPN << 12) | (va & 0xFFF); // 组合物理页号和偏移量

// 更新访问位和脏位
if (!PTE.A) {
    PTE.A = 1;
    if (is_store_operation && !PTE.D) {
        PTE.D = 1;
    }
    // 原子性地写回更新后的PTE
    memory_store_atomic(pte_addr, PTE);
}

算法开始时,A被初始化为指向页表树的根。在第二步,我们从虚拟地址中提取第一个10位索引VPN1。页表项大小为4字节,因此我们将索引乘以4,然后将其加到根节点的起始地址,得到目标页表项的地址。我们从内存中检索该页表项并存储在PTE中。

然后检查其有效位。如果为0,则立即停止并触发缺页异常,这意味着树的这一分支完全未分配且无效。如果正常,则从页表项中提取物理页号。这是物理页地址的高22位。我们将低12位填充为零,得到页面的实际物理地址(可以通过左移12位或乘以4096实现)。现在,我们设置A指向树中的下一个节点。

接着,像之前一样,我们从虚拟地址中提取第二个10位索引VPN0,将其乘以4并加到A上,以此作为地址,从内存中获取下一个页表项并存储在PTE中。同样,如果此页表项无效,则意味着虚拟页未分配,我们立即停止并触发缺页异常。

否则,我们就可以构建物理地址了。硬件通过从新的页表项中提取物理页号,并将其与原始虚拟地址中的12位偏移量连接起来,形成物理地址。

硬件还需要查看访问位和脏位。这可以在构建物理地址的同时进行。我们即将访问一个数据页,因此如果访问位为0,则必须将其更改为1。同样,如果我们要写入数据页且脏位为0,也必须将其更改为1。如果任何一位发生更改,硬件将更新页表项,将更新后的版本写回原处。RISC-V规范要求此更新必须以原子内存操作的方式进行,以确保我们更新的页表项不会被其他核心同时修改。

异常处理与多级页表

现在添加一些细节。首先,每次我们访问内存读取页表项时,都可能失败或出现某种违规。在任何情况下,如果出现问题,我们会立即中止并引发访问异常。这个检查在我们每次读取内存时都会发生。此外,存在一些非法的位组合,它们被规范保留以供将来使用。如果我们在页表项中遇到非法的位模式,也会立即中止并引发缺页异常。这个对非法位模式的检查同样在这里进行。

到目前为止,我们讨论的是使用两级页表的SV32方案。那么,对于其他SV方案中遇到的三级、四级或五级页表呢?如果我们有多个级别,这个算法可以使用一个循环,每次迭代下降一级。因此,如果超过两级,只需继续循环,执行与这里所示相同的操作。实际上,两级的算法也可以写成一个只迭代两次的循环。为了更清晰,我在这里以展开循环的形式重复了代码,这被称为循环展开。

还有一个问题是,如果我们在到达最底层之前遇到了一个叶页表项会怎样?请看这段代码。有可能我们在从根节点向底层遍历页表树时,遇到了一个可读或可执行位被设置为1的页表项。这表明我们遇到了所谓的“大页”。实际上,RISC-V规范要求,如果可写位被设置,那么可读位也必须被设置。因此,要确定这是否是一个叶页表项,只需检查可读和可执行位就足够了。如果仅设置了可写位,那么这将在之前对保留编码的检查中被处理。

如果硬件在从根节点到底层的页表遍历过程中遇到这样的页表项,则表明该页表项指向一个大页,硬件将立即退出循环并在此处完成地址转换。

异常类型与权限检查

在深入讨论大页之前,我想先回顾一下硬件执行页表遍历时可能引发的访问异常和缺页异常。

以下是RISC-V中使用的异常代码。这些是异常及其对应的代码编号,它们会被存储到原因寄存器中。

在RISC-V中,实际上有三种类型的访问错误:加载、存储和指令获取。同样,也有三种类型的缺页异常:加载、存储和指令获取。访问错误是由访问物理内存时的问题引起的,这可能来自PMP系统。缺页异常则是由页表问题引起的,这使得程序无法访问虚拟内存并完成操作。

对于虚拟内存和页表,每当发生访问错误或缺页异常时,异常的确切类型取决于硬件最终尝试执行的操作。换句话说,什么操作(加载、存储或指令获取)需要将虚拟地址转换为物理地址,这将决定在转换虚拟地址为物理地址时如果出现问题会引发何种异常。

页表项详解:有效位与权限位

现在让我们看看每个页表项中都有的有效位。如果有效位为0,则表示该页无效。换句话说,该虚拟页没有映射到任何物理内存,虚拟地址空间的那4096字节根本没有对应的物理内存。如果在页表树遍历的任何时候遇到无效的页表项,则页表项的其余部分将被完全忽略,硬件将立即引发缺页异常。

接下来,我们看看可读、可写和可执行位。如果它们全为0,那么这是一个内部节点,硬件需要继续向页表树的下一级前进。否则,它就是一个叶节点,这意味着物理页号指向一个数据页,而可读、可写和可执行位则给出了访问该数据页所需的权限。

如前所述,在RISC-V中,我们要求如果一个页面是可写的,那么它也必须可读。因此,如果W位被设置,那么R位也必须被设置。其他的位组合被认为是未定义的或保留的,如果遇到它们,将导致缺页异常。

然后,硬件将检查操作是加载、存储还是指令获取。如果是加载,但页面未标记为可读,将引发加载缺页异常。如果是存储,但页面未标记为可写,则将引发存储/原子操作缺页异常。原子内存操作同时进行读取和写入,只需要测试可写位,如果可写位已设置,则无需测试可读位。最后,如果是指令获取,但页面未标记为可执行,则将引发指令缺页异常。

大页

我们还需要考虑其他几种情况。我们说过,对于内部节点,可读、可写和可执行位全为0;而对于指向数据页的叶页表项,它们不会全为零。但如果情况并非如此呢?如果在树底部的页表项中,可读、可写和可执行位全为零,那么肯定出了问题。我们假设已经检查过页表项是有效的,但如果它有效却既不可读、不可写也不可执行,那么这将导致缺页异常。

有趣的情况发生在内部页表项包含非零值时。如果硬件在到达树底部之前遇到了一个页表项,其中可读、可写或可执行位被设置,那么我们就遇到了所谓的“大页”。

让我们回到两级页表树的图示。这里有根节点,以及树的第二级页面,其中所有页表项都是叶页表项。但现在,假设我们在根节点中遇到了一个这样的叶页表项。换句话说,根节点中的一个页表项也是一个叶页表项,它被标记为可读、可写或可执行,这意味着它指向一个数据页,更准确地说,是指向一个大页。

通常,树中这一级的节点会指向1024个数据页,这代表了虚拟地址空间的一大块区域。具体来说,1024个数据页,每页4KB,意味着这里代表的虚拟地址空间块大小为4MB。这正是大页的大小:4MB。因此,根节点中的页表项指向一个巨大的数据页,该数据页的大小为4MB,这就是大页。

让我们再次查看页表项,重点关注可读、可写和可执行位。如果它们全为零,则表示这不是叶节点,页表项指向页表树中的下一个节点。但如果任何一位被设置,则页表项指向一个数据页。如果这样的页表项出现在根节点中,则它指向一个大页。当然,如果它出现在树底部,则指向一个正常的4KB页。

现在看看物理页号。这是一个22位的字段,如果我们更详细地查看,可以将其分为两部分:物理页号部分1和物理页号部分0。如果我们有一个指向大页的页表项,那么部分0必须全为零;如果不是,则是错误,将生成缺页异常。大页必须正确对齐。大页大小为4MB,这意味着低22位必须为零。

对于正常的4KB页,页内字节偏移是12位,但对于4MB的大页,这扩展到22位(因为2^22 = 4,194,304)。对于一个34位的地址,这在高位留下了12位,注意物理页号部分1也是12位。因此,为了形成物理地址,可以像以前一样将整个物理页号复制到高位。这里的区别在于,部分0全为零,强制地址在4MB边界上正确对齐。

以下是构建物理地址的方法:首先,如图所示从页表项中提取大页的地址。然后,我们查看虚拟地址。之前,我们只获取低12位作为正常4KB数据页的偏移量,但对于大页,我们获取低22位作为4MB大页的偏移量。接着,内存管理硬件将这两部分组合起来,给出最终使用的物理地址。

大页的优势

大页的大小为4MB。稍后,当我们讨论64位机器和具有更多级别的页表时,我们会看到所谓的“吉页”甚至“太页”。但现在先不深入讨论这些。

可以说,如果使用更多的大页,我们可以避免在页树底层分配节点。对于每个大页,我们避免了在页表树最底层分配内部节点,这节省了一些内存空间。虽然这很好,但还有一个更重要的原因使大页特别有用。

我还没有描述转换后备缓冲器。但可以说,TLB实际上是最近最常使用的页表项的高速缓存。TLB当然不同于被称为L1、L2和L3的数据和指令缓存,那些是完全不同的。通过缓存页表树的部分内容,硬件避免了每次核心需要将虚拟地址转换为物理地址时都必须从内存读取,这是一个巨大的节省。

但与任何缓存一样,TLB中的条目非常昂贵且数量有限。缓存是由内容可寻址存储器构建的,这种存储器特别昂贵。因此,TLB的大小是一个大问题。对于大页,单个页表项现在覆盖4MB而不是4KB,因此缓存中的单个条目现在覆盖的内存是原来的1000倍,所以大页可以显著减少TLB缓存的压力。

大页还有另一个好处。在RISC-V中,类Unix操作系统的内核将被映射到所有虚拟地址空间中。也就是说,每个虚拟地址空间将包含一堆用户页面(用于用户级进程)和一堆监管者页面(包含内核的代码和数据)。内核并不小,因此想想映射内核内存所需的所有页表项。它将占用很多4KB页,但如果我们改用大页,那么内核可能只适合很少的几个大页。由于这发生在每个地址空间中,因此使用大页将在TLB中节省大量条目。

总结与下节预告

本节课中,我们一起学习了SV32分页方案中虚拟地址的构成、硬件进行页表遍历的详细过程(包括算法描述),以及可能引发的各种异常(访问异常和缺页异常)。我们深入分析了页表项中有效位和权限位(R/W/X)的作用,并重点介绍了大页的概念、其地址转换方式以及使用大页在节省TLB空间和提升性能方面的重要优势。

在下一节视频中,我将介绍并描述按需分页,即某些页面并不驻留在主内存中。我们将看到无效位如何用于在用户进程尝试使用页面时让内核介入,从二级存储中读入页面。我还将描述脏位和访问位,这些在内核执行按需分页时非常有用。

感谢观看,我们下节课再见。

018:虚拟内存 #3 - 按需分页

欢迎来到关于虚拟内存和分页的视频讲座系列。本系列共有六个视频。

本视频是该系列的第三部分。这六个视频是关于RISC5架构的更大系列课程的一部分。

上一节视频描述了页表的使用方式以及页表遍历(page tree walk)的执行过程。

在本节视频中,我们将介绍并描述按需分页(Demand Paging)。

当虚拟页面的数量超过主内存容量时,其中一些页面需要存储在二级存储中。

我们将看到,在必要时,可以利用有效位(valid bit)让内核介入,将页面重新调入主内存。

在本视频中,我们将定义工作集(working set)的概念并讨论页面换出(page eviction)。

我们将了解到,了解哪些页面是最近使用的,哪些是最久未使用的,这一点非常重要。

我们还将讨论脏位(dirty bit),它指示页面是否已被修改,因此是否需要写回存储。

我们也会讨论访问位(accessed bit),以及它如何可能被用来确定哪些页面是最久未使用的。

最后,我将描述RISC-V主规范中的S V A D E扩展。

好的,让我们开始吧。我想描述页表项(page table entry)中的A位和D位。

但为了做到这一点,我们首先需要了解按需分页。

因此,让我们花点时间描述一下什么是按需分页,以及它与我们目前所见有何不同。

我们将通过一个例子来说明。假设我们有一个1GB的物理内存。

在某个时刻,有七个用户进程正在运行。为了简化数字,我们假设每个用户进程需要100MB。

在这个例子中,我们假设内核也恰好需要100MB。

因此,七个进程各100MB,加上内核,总共需要800MB的物理内存。

顺便提一下,在讨论按需分页时,我们忽略大页(mega pages)。这与我们将要讨论的内容是完全独立、不相关的问题。

800MB小于我们拥有的1GB,所以没问题,所有内容都能装入内存。这是我们到目前为止所做的假设,即有足够的物理内存,使得每个虚拟页面都能映射到一个物理内存页。

但现在我们假设启动了更多进程,现在计算机正在运行12个用户进程。

因此,12个进程加上内核,我们现在需要1300MB或1.3GB的内存。这不再小于1GB,所以我们没有足够的物理内存。问题是,内核该怎么办?

解决方案是使用按需分页。当今的操作系统内核实现了所谓的按需分页。

这将允许内核运行一堆进程,即使它们超过了计算机上实际可用的物理内存。

换句话说,如果将所有运行进程的虚拟地址空间大小相加,在按需分页的情况下,这个数字现在可以超过存储这些进程可用的内存量。

我还想提一下“分页”这个术语,它有点模糊。基本上,如果一个系统通过页表实现虚拟地址空间,那么它就实现了分页。

如果它还实现了按需分页(我尚未描述),那么你可能应该说“按需分页”。如果你听到“虚拟内存”或单独的“分页”这些术语,请记住,这并没有告诉你是否实现了按需分页。很可能实现了,但这些术语更通用,可能指的是那些没有实现按需分页、在内存耗尽前只能运行有限数量进程的系统。

同样,你的Linux或类Unix系统几乎肯定实现了按需分页。

按需分页的理念是,在任何时间点,一个进程不需要访问其所有的虚拟页面。当然,它需要一些页面来执行指令,这被称为页面的工作集。但一个进程并非每条指令都需要其整个虚拟地址空间。

现在,我们不再将虚拟地址空间的所有页面都保存在内存中,而是考虑将所有页面存储在二级存储上。

这里的二级存储指的是永久性存储,如闪存驱动器或硬盘。

其理念是,当进程需要某些页面时,内核会将这些页面从二级存储调入主内存。

所有页面都将存储在二级存储上,但对于一些重要的、最近使用的页面,也会在主内存中保留一个副本。主内存中的副本很可能已被更新,不再与存储在二级存储上的数据完全匹配。

因此,当一个页面同时存储在存储器和内存中时,显然主内存中的版本是包含页面当前真实数据的较新版本。

随着时间的推移,内核会将越来越多的页面从存储调入主内存,消耗越来越多的物理内存。在某个时刻,所有的主内存都将被页面占用,内核将耗尽可用的空闲内存来容纳新页面。

因此,在那一刻,内核需要将一些页面从主内存移回永久存储。

所以,内核将选择一些页面。我们说它将把这些页面从主内存中换出(evict),并且如果需要,它需要将它们当前的值从主内存复制回二级存储。

为了在内存中腾出更多空间并继续运行,内核必须做两件事。首先,它必须选择一些内存中的页面进行换出。其次,如果这些页面的数据已被更改,它还必须将它们当前的4KB数据从内存写入存储。

出于效率原因,我们希望将最重要的页面保留在内存中,并选择不重要的页面进行换出并移回存储。

那么,什么是“重要”呢?理想情况下,我们希望将很快再次需要的页面保留在主内存中。显然,我们不希望将一个页面换出,然后紧接着又必须将其换入。

当然,我们无法知道哪些页面在不久的将来会再次需要。因此,我们将尝试换出最近一段时间内未被使用的页面。为了确定要换出哪些页面,我们有一个至关重要的事实:过去最近被使用的页面往往在不久的将来会再次需要。

大多数程序都有这种行为。在任何时候,它们倾向于使用少量页面。如果一个页面正在被使用,那么它最近一定被使用过,并且在不久的将来会再次需要。一个进程当前正在使用的页面被称为其工作集。

大多数程序的工作集只包含其地址空间中大量页面中的一小部分。随着时间的推移,工作集会发生变化和转移,而工作集的大小实际上取决于你观察的时间间隔有多长。

事实上,你可以深入研究这个问题,收集关于工作集大小等的统计数据,但你真正需要知道的是:内核只需要知道哪些页面最近被使用过。

也许我们想知道哪个页面是最近使用的,但我们真正想要的是相反的情况:我们想知道哪些页面是最久未使用的。换句话说,哪个页面已经很长时间没有被使用了?很可能这个特定页面在不久的将来不会再被需要。至少在当前程序执行的时刻,程序显然没有在使用这个页面。

其理念是,由于最久未使用的页面已经很长时间没有被使用,它很可能在不久的将来不会再被需要。这正是我们想要选择进行换出的页面。如果内存紧张,那么最久未使用的页面就是内核应该选择写回二级存储的页面。这是对最不可能在不久的将来被需要的页面的最佳猜测。

当然,问题是,我们如何知道哪些页面是最近使用的,以及相反,哪些页面不是最近使用的。幸运的是,RISC-V硬件提供了一些我们可以用来帮助回答这个问题的支持。

让我们回顾一下页表项中的位。我们看到了指示页面是否可读、可写和/或可执行的位。我们还看到了U位,它决定了页面是用户页面还是监管者(supervisor)页面。

如果试图以禁止的方式访问页面,则访问将被阻止,并发生页面错误(page fault)异常。这将是一次违反权限和保护系统的尝试,因此内核通常会通过终止进程来处理该异常。

我们还有有效位(valid bit),它指示页面是否已被分配。并非虚拟地址空间中的每个页面都映射到任何内容。虚拟地址空间中可能存在空洞,对应于这些空洞的页面是无效的。

实际上,对于一个4GB的虚拟地址空间,地址空间中的大多数页面很可能未分配,因此是无效页面。尝试从无效页面加载、存储或获取指令将导致错误,并引发页面错误异常。

实际上有三种不同类型的页面错误,如果出现任何问题,将发出其中一种信号。具体发生哪种异常类型取决于尝试的操作类型:程序要么试图执行加载指令,要么是存储指令,要么是获取指令。

但这里有一个很酷的地方:有效位也可以用来支持按需分页。

对于实现按需分页的内核,一些页面可能实际上是有效的,但这些页面当前并不驻留在内存中。我们说这些页面被换出(paged out),意味着页面的唯一副本存储在二级存储上,页面当前不驻留在内存中。内核也可以为这些被换出的页面使用有效位。

其理念是,一个不驻留在主内存中的页面,其有效位将被清零为0。如果程序试图访问一个当前不驻留的页面,硬件也会引发页面错误。但这并不是程序的错误。用户程序甚至不知道哪些页面被换出。事实上,它甚至不知道正在发生按需分页。

所以现在,如果遇到无效页面并发生页面错误,陷阱处理程序和内核将运行,内核将查看以确定问题所在。

如果程序试图访问一个从未分配或映射到任何内容的页面,那么这是程序的错误,内核将简单地终止该程序。

另一方面,如果页面存在但当前不驻留在内存中,那么这不是错误。相反,内核需要将这个页面重新调入内存。

因此,内核将从二级存储读取页面到内存中。然后它将调整页表以指向新分配的物理页面。接着,它会将其有效位更改为1,并重新启动程序。最初导致页面错误的指令将被重试。它将再次执行,但这一次,有效位将被设置为1,因此指令将正常执行。

所以,你可以看到有效位实际上有两个功能。首先,它用于标记那些在虚拟地址空间中未映射、未分配的页面。访问这些页面将导致你的程序被终止。其次,有效位用于标记那些属于虚拟地址空间但当前不驻留在主内存中的页面。访问这些页面将导致页面被移回内存,这就是“按需”分页的“按需”部分。这些页面是按需被移回主内存的。

程序将继续执行,它永远不会知道发生了什么。它永远不会知道发生了页面错误或内核曾介入过,除了可能在执行时间上有一点延迟。

现在我们已经了解了按需分页,我们准备好描述脏位(dirty bit)和访问位(accessed bit)。

页表项中的脏位将在页面被修改或写入时由硬件设置。每当一个页面被选择换出时,这将派上用场。也就是说,当它被选择从主内存移出到二级存储时。

内核是否需要将页面写回?嗯,可能需要,也可能不需要。如果页面在内存期间被修改过,那么是的,在我们可以回收它占用的物理页面并将其重用于其他用途之前,必须将新值写入二级存储。

每当一个页面被选择换出时,如果硬件设置了脏位,那么该页面必须被写入二级存储。但如果脏位仍然是0,那么页面没有被修改。存储在二级存储上的副本是相同的,因此不需要写回。每当我们读一个页面回内存时,我们可以继续清除脏位,因为内存中的副本与二级存储上的副本相同。

此外,内核可能不时地决定主动写回一个页面,而不是在特别需要更多内存空间时才这样做。这可以在内核不忙于其他事情时完成,以便在真正需要更多空间时节省一些时间。

如果一个页面被复制到存储,但也保留在内存中,那么脏位也会被清除,因为两个副本再次相同。内存中的页面不再是脏的,我们说它现在是干净的(clean)。如果后来这个页面被选择换出,将不会有任何延迟,因为干净的页面不需要写回,空间可以立即回收。

每个页表项还包含一个称为A位或访问位(accessed bit)的位。当内核试图选择一个页面进行换出时,可以使用这个位。当然,我们希望换出最久未使用的页面。回想一下,我们想知道一个页面在最近是否被使用过,如果是,我们希望将其保留在内存中,并选择其他页面进行换出。访问位可以在这里提供帮助。

因此,每当页面被使用(无论是读取还是写入)时,硬件都会设置访问位。

我还想提一个可选的RISC-V扩展,称为S V A D E,即监管者模式、虚拟内存、访问和脏位异常扩展。默认情况下,如果页面被访问或写入,相关页表项中的A位和D位将被更改为1。

但如果你的核心恰好实现了SVADE扩展,那么硬件仍将像以前一样检查A位和D位。然而,它不会通过将页表项写回页表树节点来修改它们,而是引发页面错误异常。要求硬件执行写回页表树以及对该节点的原子更新,可能对硬件来说负担太重。

因此,有了这个扩展,核心只是引发页面错误,而不是直接设置这些位。然后内核可以采取它想要的任何行动。问题是,正如目前所描述的,简单的“已访问”或“未访问”的区分有点粗糙。如果内核能够准确跟踪页面最后一次被访问的时间,可能会更好。

为此,内核需要获得控制权,然后记录访问的当前时间或类似信息。我在这里勾勒了一个算法,但我不想陷入讨论这个算法的细节。本视频的目标是描述RISC-V硬件做什么,而不是描述可能利用RISC-V硬件的各种算法。

这是一个很好的停顿点。在本视频中,我们涵盖了按需分页背后的基本思想,并讨论了如何使用RISC-V硬件来实现按需分页。

特别是,我们描述了页表项的脏位和访问位。在下一个视频中,我们将介绍地址空间标识符(Address Space Identifiers)并讨论SATP寄存器。它包含的不仅仅是页表树根节点的地址。

然后我们将讨论转换后备缓冲区(Translation Lookaside Buffer, TLB)及其实现方式。一旦我们理解了TLB,我们就能看到全局位(global bit)是什么以及为什么需要它。

在结束之前,如果你喜欢这类YouTube内容,请点赞、订阅、分享和评论以表示支持。

无论如何,感谢观看,希望在下个视频中见到你。

019:虚拟内存 #4 - ASID与TLB设计

在本节课中,我们将要学习虚拟内存系统中的两个核心概念:地址空间标识符(ASID)和转换后备缓冲器(TLB)的设计。我们将了解ASID如何帮助在多任务环境中区分不同的进程地址空间,以及TLB如何通过缓存页表项来加速地址转换过程。课程内容将涵盖SATP寄存器的详细结构、TLB的工作原理,以及全局页面的概念。

概述

上一节我们介绍了请求分页机制,它允许虚拟内存总量超过实际安装的物理内存。当需要页面时会发生缺页异常,内核通过从二级存储读取页面来响应。我们还讨论了脏位和访问位,它们有助于内核选择要换出的页面。

本节中,我们来看看每个虚拟地址空间独有的地址空间标识符(ASID)。我们将详细研究SATP寄存器,了解它除了包含页表树的根地址外,还包含ASID并选择启用的分页方案。然后,我们将描述转换后备缓冲器(TLB)的功能。一旦了解了TLB,ASID的重要性就会变得清晰。我们还将描述全局页面和全局位。

地址空间标识符(ASID)

当操作系统运行多个用户进程时,每个进程都有自己的虚拟地址空间。因此会存在多个地址空间,进而产生多个页表。每个不同的地址空间都有一棵完整的页表树。

为了实现多任务处理,内核会不断地在用户进程之间切换,这称为时间片调度。在内核开始某个特定进程的时间片之前,它需要安装并激活该进程的页表。为此,它会将一个指向页表树根的指针加载到一个名为SATP的控制和状态寄存器中。SATP指向在进程时间片期间将使用的页表树。

为了使虚拟内存可用并避免大量访问页表的内存操作,所有现代处理器都实现了所谓的TLB(转换后备缓冲器),有时也称为地址转换缓存。只要可能,核心就会使用TLB缓存。这使得地址转换更快,并使整个虚拟内存方案变得实用。

但是,这里存在一个问题:哪些TLB条目对应哪些页表?想象一下,内核准备从一个进程切换到另一个进程,因此它将SATP加载为新进程的新页表地址。然而,TLB中充满了来自上一个进程旧页表的缓存页表条目。如果核心使用这些旧的页表条目来避免访问完整的页表,它将使用不正确的页表条目,这将导致混乱。

解决方案非常巧妙。其思想是为每个虚拟地址空间分配一个唯一的编号,因此地址空间被编号为0、1、2、3等。这被称为地址空间标识符(ASID)。对于32位机器,RISC-V使用9位ASID;对于64位机器,使用16位ASID。9位最多可同时容纳512个不同的地址空间,16位最多可容纳64K个地址空间。

转换后备缓冲器将使用地址空间标识符来正确区分页表条目。当一个页表条目存储在TLB中时,其ASID也作为条目的一部分存储在TLB中。然后,在执行期间,每次核心需要转换虚拟地址时,它会首先检查TLB以查看是否存在匹配的条目。如果存在匹配的条目,核心还会检查该条目的ASID是否与当前的ASID匹配。如果匹配,则一切正常,使用缓存的页表条目,核心无需访问内存读取页表。但是,如果没有匹配的条目或ASID不是当前的,那么核心将不得不从内存中的页表树进行读取。

重复一下,TLB中缓存的每个页表条目都用一个ASID进行标记。每次使用地址转换缓存时,核心都会搜索一个同时匹配虚拟页号和当前ASID的条目。

SATP寄存器详解

SATP是一个控制和状态寄存器,可由运行在监管者模式下的内核访问。对于32位机器,它是一个32位寄存器,格式如下:

SATP (32-bit) 格式:
| MODE (1 bit) | ASID (9 bits) | PPN (22 bits) |
  • MODE (位31):指示分页是否开启。如果为0,则没有虚拟内存,其他字段被忽略。每个程序生成的地址将直接用作物理地址。
  • ASID (9位):包含地址空间标识符。
  • PPN (22位):包含页表树根的物理页号。

ASID和指向页表的指针的组合描述了当前的虚拟地址空间。由于RV32只可能有两级树,如果分页开启,我们就得到了我们一直关注的SV32方案。顺便提一下,给定的核心可能根本不实现虚拟内存。在这种情况下,SATP将被硬连线为0,强制分页始终关闭。

页表树中的所有节点必须对齐在页边界上,因此对于页表树根的地址,只需给出物理页号即可,核心将填充低12位以给出一个4KB页对齐的地址。

当内核进行时间片调度并切换到新进程时,它必须用新值加载SATP寄存器,该值既包含新地址空间的ASID,也包含该地址空间页表根的指针。

对于64位机器,情况非常相似。在RV64中,控制和状态寄存器(包括SATP)现在是64位大小。以下是64位机器的SATP布局:

SATP (64-bit) 格式:
| MODE (4 bits) | ASID (16 bits) | PPN (44 bits) |

如您所见,模式字段扩大到4位,ASID字段扩大到16位,树根的物理页号扩大到44位。模式字段指示分页是否开启,如果开启,则指示核心应使用具有3级树的SV39方案、具有4级树的SV48方案还是具有5级树的SV57方案。其他位组合是保留的,不应使用。此外,您的核心可能实现其中一些方案,但不是全部。我怀疑您的核心可能没有实现SV57,毕竟,谁需要如此巨大的虚拟地址空间呢?

如前所述,现在ASID是16位,物理页号是44位。加上强制页对齐地址的12位,我们看到SATP寄存器有效地包含一个56位的物理地址,这就是页表树根的地址。当然,大多数系统几乎肯定会忽略物理地址的一些高位。但所有这些分页方案都支持潜在非常巨大的物理地址空间。实际上,使用56位地址,就是64PB或大约70千万亿字节。

转换后备缓冲器(TLB)设计

为了了解这一切如何运作,我想描述一下转换后备缓冲器的一个实现。让我们将自己限制在使用SV32的32位机器上。需要明确的是,这里描述的实现纯粹是概念性和假设性的。RISC-V规范没有描述任何特定的TLB实现或深入此类细节。但我想向您展示一个具体的TLB设计,以便您了解内存管理单元(MMU)的工作原理。

实际上,我已经在Verilog中实现了一个非常类似的TLB,所以可以说,虽然这个TLB纯粹是概念性和虚构的,但它仍然是现实可行的。

首先,TLB是一个关联存储器。它由许多条目组成,每行一个条目。每个条目与页表条目非常相似,但有几个区别。一方面,为软件使用保留的两个位不存储在TLB中。程序代码无法直接访问这些条目,因此没有理由存储这些位。但最重要的是,与每一行关联的是一个键,该键由地址空间标识符(ASID)和虚拟页号(VPN)组成。这是搜索键。因此,这是一个关联存储器。为了检索一个条目,需要提供一个搜索键,硬件通过提供一个搜索键匹配的条目(如果存在)来响应。因此,搜索可能导致匹配,或者TLB中可能没有匹配的条目,核心将不得不进行页表遍历。

MMU(内存管理单元)是核心的一部分,负责管理转换后备缓冲器,并在需要时执行页表树遍历。MMU被提供一个虚拟地址和当前的SATP,从中可以提取当前的ASID和页表树根节点的地址。内存管理单元将返回物理地址和相关的权限位,这些权限位稍后用于确定是否必须触发异常。

其思想是,内存管理单元将使用来自SATP寄存器的当前ASID和来自虚拟地址的虚拟页号作为TLB的搜索键。如果存在匹配的条目,则MMU将使用它。否则,MMU将遍历页表树。这将涉及几次内存读取,正如我们之前描述的那样,并以检索叶页表条目结束。然后,除了构建要返回的完整物理地址外,内存管理单元还会将页表条目保存在TLB中供下次使用。

这里需要说明几点。当内存管理单元遍历页表树时,它可能检索到一个无效的叶页表条目。这可能不会被保存在TLB中。相反,将生成一个页面错误,并且该进程很可能会被终止。此外,为了在TLB中存储一个新的页表条目,将需要替换一些现有的条目。TLB缓存将相当小,并且具有固定数量的条目。例如,它可能包含128个条目,但不同的实现可能有不同的大小。无论如何,内存管理单元将不得不选择某个条目进行替换。值得一提的是,我实现的TLB在所有TLB条目中维护一个顺序,始终将最近访问的条目保持在顶部,并替换最近最少使用的底部条目。处理刷新条目是一场噩梦,但在这里可以安全地忽略这些细节,只需说新的页表条目被保存在TLB中的某个地方。

SV32方案还允许大页(Mega Pages)。到目前为止,我只处理了正常的4KB页面,没有处理大页。为了在我们的TLB中容纳大页,我们需要在TLB的每个条目中添加另一个位。我们称这个位为M位,当设置为1时,表示TLB中的条目代表一个大页。

让我们修改我们的TLB设计,像这样为TLB中的每个条目添加一个M位:

TLB条目格式(修改后):
| 有效位 | M位 | ASID (9 bits) | VPN (20 bits) | PPN (22 bits) | 权限位 (R/W/X) | 其他位 (A, D, U, G) |

现在,当在地址缓存中关联搜索匹配的页表条目时,匹配过程稍微复杂一些。如果TLB中的一个条目的M位清零为0,那么它是一个普通页。为了确定条目是否匹配,内存管理单元将比较来自SATP寄存器的当前ASID与TLB条目中的ASID,以及来自虚拟地址的20位虚拟页号与TLB条目中的20位虚拟页号。

然而,如果M位设置为1,那么TLB中的条目代表一个大页,因此匹配会有些不同。内存管理单元仍将像以前一样使用ASID进行比较。但现在,只有虚拟页号的第一部分(即来自虚拟地址的上10位索引)将与TLB条目中虚拟页号的上10位进行比较。

我将在这里停下来。关于如何实际实现TLB还有更多细节,但我只是想向您展示TLB硬件内部发生了什么。

全局页面与全局位

页表条目有一个G位,即全局位。我们现在有足够的背景知识来讨论全局位是什么以及如何使用它。

让我们看看虚拟地址空间是如何布局的。下图显示了一个使用SV32方案的32位RISC系统如何安排一个4GB的地址空间。此图中的数字可能与您的特定操作系统的细节不完全匹配,但思想是相同的。

每个虚拟地址空间将被分为两部分:一部分用于用户进程,一部分用于内核。这个特定的操作系统将边界设置在0xC0000000,这意味着每个地址空间都有1GB预留给内核的代码和数据,位于地址空间的上四分之一。而用户页面占据地址空间的下四分之三。

用户进程将有一些用于代码的页面,这些页面将被标记为可执行,并且通常从地址0开始放置。进程还会有一些用于变量数据的页面,这些页面将被标记为可读和可写。数据页面通常紧接在代码页面之后放置。一些程序可能还有一些只读页面,它们也会放在那里。用户进程还会有一个从高地址向下增长的堆栈。在这个特定的操作系统中,用户堆栈将从地址0xC0000000开始并向下增长。因此,在用户区域的高端会有一些标记为可读和可写的页面用于堆栈。

在代码和数据页面与堆栈页面之间,会有一大块未分配的内存。这些页面将被标记为无效,因此任何访问它们的尝试都会导致页面错误。如果堆栈向下增长超出为其分配的页面,就会发生页面错误。内核的响应不是中止程序,而是分配一个新的堆栈页面。这有点像我们之前讨论的请求分页。当程序访问堆栈区域中的无效页面时,内核将分配一个新页面,然后重试或重新执行进行访问的指令。因此,程序永远不会意识到堆栈页面不是一直都在那里。

还有一个堆,它向上朝着堆栈的方向增长。基本上,这涉及为进程分配更多页面。因此,随着堆向上增长,内核会不时地向标记为数据的区域添加更多页面。

每个用户进程都有自己的虚拟地址空间,每个地址空间都有不同的页表。地址空间中的用户区域都将不同。它们将具有不同数量的虚拟页面,并且用户页面的可读、可写、可执行位通常会有很大不同。当然,用户页面将映射到完全不同的物理页面。每个用户进程都需要自己的物理内存,与其他进程分开,以存储自己的代码、数据和堆栈页面。所有用户页面都将设置U位,因为它们是用户页面。

然而,所有虚拟地址映射的上四分之一将是相同的。只有一个内核,并且它只位于物理内存中的一个位置。因此,所有页表的上四分之一将具有完全相同的映射,具有相同的物理页面和相同的读写执行位。地址空间上四分之一的所有页面也将清除U位,以指示这些是监管者页面。

对于64位机器,情况大致相同,但数字非常不同。64位机器的一个可能不同之处是,为内核区域保留的空间可以大于整个物理内存。在这种情况下,内核可能会将整个物理内存映射到内核区域。这将允许内核轻松访问物理内存中的任何字节,这将使某些操作对内核来说更容易一些。

但让我们不要被特定操作系统如何布局虚拟内存地址空间的细节所干扰。正如他们所说,您的细节可能不同。请查阅您的文档。

回顾一下,像Linux或Unix这样的内核通常将内核映射到每个地址空间中。在我们的例子中,在32位机器上,地址空间的前四分之三预留给用户进程及其页面。地址空间的最后四分之一预留给内核,因此其代码和数据将从地址0xC0000000开始。用户代码和数据将从地址0开始放置。这些页面将在页表条目中根据情况标记为可读、可写和/或可执行,并且它们都将被标记为用户页面。内核的代码和数据将放置在从地址0xC0000000开始的虚拟页面中。这些页面中的每一个都将被标记为非用户页面,因此只能由在监管者模式下运行的代码访问。

问题是内核被映射到每个地址空间中。映射是相同的:相同的虚拟页号,相同的物理页号,相同的权限位。但这些都在不同的地址空间中,因此地址空间标识符(ASID)会不同。这意味着TLB缓存通常会包含多个页表条目,这些条目在其他方面是相同的,它们仅在ASID上不同。

这就是G位或全局位发挥作用的地方。内核页面都将被标记为全局页面。其思想是,标记为全局的页面存在于所有虚拟地址空间中。全局页面在所有虚拟地址空间中具有相同的物理页面映射和相同的权限。因此,TLB只需要为此页面包含一个条目。然后,该条目可以被所有地址空间共享。转换后备缓冲器是一种宝贵且昂贵的资源,我们希望尽量减少对它的压力。

让我们考虑内核中的某个页面,我们可能想考虑第一个页面,其虚拟地址为0xC0000000。该页面的虚拟页号为0xC0000。这个虚拟页面将存在于所有地址空间中,因此所有页表中页面0xC0000的页表条目都将被标记为全局。这将允许内存管理单元在TLB缓存中仅存储此页面的一个页表条目。然后,每当我们有一个在此页面内的虚拟地址,并且内存管理单元被要求将虚拟页号映射到物理页号时,它将搜索匹配的条目。如果TLB中的任何条目将其全局位设置为1,那么ASID匹配将被忽略。如果我们正在搜索页面0xC0000,那么TLB中的一个条目将匹配,即使来自SATP寄存器的当前ASID与缓存中页表条目中的ASID不同。这将显著减少我们所需的TLB条目数量。

我们还可以将全局位与大页的概念结合起来。想象一下,内核的大小恰好是7MB。由于大页是4MB大小,这意味着我们可以将内核装入仅两个大页中,并且我们可以将这两个大页都标记为全局。然后,转换后备缓冲器中只需要两个条目就可以将整个内核映射到每个地址空间中。因此,您可以看到大页与全局位的结合产生了巨大的影响。

总结

本节课中我们一起学习了虚拟内存系统中的关键组件。我们了解了地址空间标识符(ASID)如何唯一标识每个进程的地址空间,以及它如何与SATP寄存器协同工作。我们深入探讨了转换后备缓冲器(TLB)的设计,它是一个关联缓存,用于加速虚拟地址到物理地址的转换。TLB条目不仅包含虚拟页号到物理页号的映射和权限位,还包含ASID以区分不同进程的条目。我们还学习了全局页面和全局位的概念,它们允许内核页面在所有地址空间中共享同一个TLB条目,从而显著提高了TLB的利用效率。

到目前为止,我们一直在讨论用于32位RISC-V的SV32方案。在下一节视频中,我们将转向64位核心,讨论用于64位机器的SV39、SV48和SV57页表方案。由于RISC-5逻辑清晰、设计周密,我们将看到所有这些方案都非常相似。我们看到,在SV32中,我们有一些非常大的页面,称为大页。正如我们将在下一节视频中看到的,其他方案非常自然地容纳了称为吉页、太页和拍页的更大页面。

020:Sv39, Sv48, Sv57 分页方案详解 🖥️

在本节课中,我们将学习RISC-V 64位系统中的虚拟内存分页方案。我们将详细描述并比较SV32、SV39、SV48和Sv57这几种分页机制,了解它们如何扩展以支持更大的地址空间和巨型页。

概述

本视频是虚拟内存系列的第5部分,属于RISC-V架构大系列。我们将重点从32位机器的SV32方案转向64位机器的SV39、SV48和Sv57方案。这些方案通过增加页表树的层级,自然地支持了千兆页(Giga Page)、太字节页(Terra Page)和拍字节页(Petta Page)。

从32位到64位的变化

上一节我们介绍了32位机器的SV32分页方案。本节中,我们来看看64位机器的变化。一个64位RISC-V机器可以实现SV39、SV48或Sv57中的一种或多种分页方案。

所有RISC-V机器都使用固定的4 KB页大小。这是RISC-V中少数不可调整的参数之一。

  • 页表项大小:在32位机器上,页表项(PTE)是32位(4字节)。在64位机器上,页表项自然扩展为64位(8字节)。
  • 页表节点容量:页表树中的每个节点本身就是一个页(4 KB)。因此,对于64位机器,一个节点现在只能容纳 4096 bytes / 8 bytes = 512 个页表项,而不是32位时的1024个。
  • 树结构变化:这意味着我们从基数为1024的树(每个节点有1024个子节点)转变为基数为512的树。因此,用于索引页表节点的位数从10位减少到9位。

虚拟地址格式

对于32位机器,虚拟地址是32位,虚拟页号(VPN)为20位,被分成两个10位的部分。对于64位机器,虚拟地址的大小取决于所使用的分页方案。

以下是不同方案下虚拟地址的构成:

  • SV39:页表树有3级,需要3个9位的索引。因此,虚拟页号为 3 * 9 = 27 位。加上12位的页内偏移,虚拟地址总长为 27 + 12 = 39 位。
  • SV48:页表树有4级,需要4个9位的索引。虚拟页号为 4 * 9 = 36 位,虚拟地址总长为 36 + 12 = 48 位。
  • Sv57:页表树有5级,需要5个9位的索引。虚拟页号为 5 * 9 = 45 位,虚拟地址总长为 45 + 12 = 57 位。

公式可以总结为:
虚拟地址长度 = (页表层级数 * 9) + 12

物理地址与页表项格式

无论使用哪种分页方案,64位RISC-V机器的物理地址都是56位。由于页内偏移仍是12位,这意味着物理页号(PPN)现在是 56 - 12 = 44 位。

接下来,我们比较一下32位和64位机器的页表项格式。

32位机器页表项格式
包含22位的物理页号(PPN)和各种权限位。

64位机器页表项格式(SV39/SV48/Sv57通用)
页表项大小为64位。物理页号从22位扩展为44位。权限位与32位完全相同。顶部新增了10位:其中7位保留供未来RISC-V扩展使用,另外3位用于名为SVPBMT(基于页的内存类型)的扩展,该扩展与缓存和内存操作顺序约束有关。

页表树结构与地址转换

让我们回顾一下SV32的两级页表树结构,然后扩展到更多层级。

SV32(两级树)
虚拟地址被分成两部分(VPN[1], VPN[0])和一个偏移量。内存管理单元(MMU)首先使用VPN[1]索引根节点,找到指向二级节点的页表项,然后使用VPN[0]索引二级节点,找到指向最终4 KB数据页的叶页表项。

SV39(三级树)
虚拟地址被分成三部分(VPN[2], VPN[1], VPN[0])和一个偏移量。MMU依次使用VPN[2]、VPN[1]、VPN[0]作为索引,遍历三级页表树,最终定位到数据页。

SV48(四级树)与 Sv57(五级树)
遵循相同的模式。虚拟页号被分解成多个9位的字段,MMU使用这些字段依次索引页表树的每一层,直到找到叶页表项。如果在遍历过程中遇到无效的页表项,则会停止并触发页错误异常。

巨型页(Huge Pages)

页表遍历不一定非要到达最底层。如果在较高层就遇到了一个“叶”页表项(即其R/W/X权限位不全为0),则意味着我们遇到了一个巨型页。巨型页的大小取决于它在树中的位置。

以下是不同方案支持的巨型页类型和大小:

  • SV32
    • 仅支持巨页(Mega Page),大小为 4 MB。偏移量为22位(10 + 12)。
  • SV39
    • 巨页(Mega Page):大小为 2 MB。偏移量为21位(9 + 12)。
    • 千兆页(Giga Page):大小为 1 GB。偏移量为30位(9 + 9 + 12)。
  • SV48
    • 支持巨页(2 MB)、千兆页(1 GB)。
    • 太字节页(Terra Page):大小为 512 GB(约0.5 TB)。偏移量为39位(9 + 9 + 9 + 12)。
  • Sv57
    • 支持巨页(2 MB)、千兆页(1 GB)、太字节页(512 GB)。
    • 拍字节页(Petta Page):大小为 256 TB(约0.25 PB)。偏移量为48位(9 + 9 + 9 + 9 + 12)。

当MMU遇到巨型页时,它从页表项中获取对齐的物理页号高位部分,并从虚拟地址中获取对应的多位偏移量,组合形成最终的物理地址。页表项中对应于下级索引的位必须为0,以确保地址对齐。

总结

本节课中,我们一起学习了RISC-V 64位系统上的分页实现。我们看到,其基本思想与SV32方案几乎相同,但为了适应更大的虚拟地址空间和更多的物理内存,各个字段的位数分配有所不同。关键变化包括页表项扩展为64位、页表节点容量变为512项、以及通过增加页表层级(SV39/SV48/Sv57)来支持更大的地址空间。同时,多级页表树也自然地支持了从巨页到拍字节页的各种巨型页,这有助于减少TLB缺失,提升大内存区域访问的性能。

在下一节视频中,我们将讨论如何刷新转址旁路缓存(TLB),并介绍用于刷新TLB的 sfence.vma 指令。

021:虚拟内存 #6 - TLB刷新与sfence.vma指令

在本节课中,我们将要学习虚拟内存与分页机制的最后一部分内容,重点探讨翻译后备缓冲器的维护机制。我们将详细讲解TLB条目为何以及如何被刷新或置为无效,并深入解析sfence.vma指令的功能与用法。该指令是RISC-V架构中管理虚拟内存一致性的关键。

TLB条目的有效性问题

上一节我们介绍了TLB的基本实现原理。现在,我们来关注TLB条目中的一个细节:有效位。

每个TLB条目都包含一个有效位。一个需要思考的问题是:为什么我们一开始会想要在地址转换缓存中存储一个无效的页表项?

假设程序试图使用某个虚拟地址,而该地址恰好位于一个无效页中。内存管理单元会首先遍历页表树,定位包含该地址的页表项。如果它发现页表中的页表项有效位为0,表明该页无效。那么,内存管理单元是否应该将这个页表项存入TLB呢?

无效页的出现通常有两种原因:

  1. 程序试图访问一个未映射、未分配的内存页,这属于程序错误,内核通常会终止该进程。
  2. 程序试图访问一个有效但当前未驻留在内存中的页(例如,被换出到二级存储)。内核会通过缺页异常处理程序,将页读回物理内存,并更新页表项使其变为有效。

无论哪种情况,那个有效位为0的旧页表项都不会再被需要。因此,没有理由将这个无效的页表项缓存到TLB中。

页表更新与TLB一致性问题

我们面临的第二个问题是:内核需要不时地更新页表。例如,当栈或堆增长时,会向虚拟地址空间添加新页;程序也可能将不再需要的页归还给内核;现代操作系统还允许通过移动整个页的方式在进程间传递大块数据。

内核可以通过存储指令修改页表中的页表项来完成这些操作。但TLB缓存中可能包含正在被修改的页表项的旧版本,这些条目现在已经过时且不正确。因此,内核需要一种方法来使旧的TLB条目失效,即清除TLB中的过时条目。

TLB有效位的重新定义

之前我们没有深入讨论TLB条目中的有效位。实际上,它的含义和用法将与页表中的有效位有所不同。

  • 页表项中的有效位(V)指示该页是否有效,内存管理单元据此决定是否触发缺页异常。
  • TLB条目中的“有效位”将表示完全不同的含义:它指示该TLB条目槽位是空闲还是正在使用

为了避免混淆,我们不应再称TLB中的这个位为“有效位”。让我们将其从V改为N。当TLB条目正在使用时,N位被设置为1。如果设置,N表示该TLB条目非空闲(Not free),或者理解为正在使用(Needed)。

以下是翻译后备缓冲器的简化示意图,它是一个使用地址空间标识符和虚拟页号作为搜索键的关联存储器:

TLB Entry Structure:
+----------------+----------------+----------------+-----+
| ASID (Key)     | VPN (Key)      | PPN (Value)    | N   | ... (其他标志位,如M/G/T/P)
+----------------+----------------+----------------+-----+

对于SV32,我们需要一个位(M)来指示条目是否对应一个巨页。对于64位机器的分页方案(SV39, SV48, SV57),我们可能还需要G、T、P位来处理吉页、太页和拍页。但我们现在关注的是N位。

我们已确定不会在TLB中缓存无效的页表项,因此我们不再需要V位。取而代之的是新的N位,用于指示TLB中的一个槽位是否包含可用数据,或者是否为空闲未用。

总结:如果N=1,则该条目正在使用(非空闲);如果N=0,则该条目空闲,可用于存储新的页表项。

乱序执行与内存操作排序

现代处理器核心通常是流水线的,甚至可能为了更高性能而乱序执行和重叠操作。虽然核心会跟踪指令间的大部分依赖关系(如寄存器使用顺序),以确保最终结果正确,但它可能无法完全理解所有相互依赖关系。

以下是影响虚拟内存系统的几类操作:

  1. 内存访问:加载、存储指令以及取指操作都使用虚拟地址,因此会用到TLB和页表。
  2. 页表更新:软件通过存储指令修改物理内存中的页表。
  3. SATP寄存器修改:软件在切换虚拟地址空间时会修改SATP寄存器。

修改页表或SATP寄存器必然会影响后续的加载、存储和取指操作。问题在于,一个乱序执行的核心可能无法检测或理解这些必须遵守的排序约束。我们需要一种方法来强制对涉及页表的某些操作进行排序,并确保TLB与页表保持同步。

sfence.vma指令的引入

如果软件更改了页表,TLB中缓存的条目就会过时。我们需要一种方法来强制核心不使用旧的、错误的条目。

首先,我们需要一种方法来清除旧的、过时的TLB条目。内核在更改页表时必须能够使某些TLB条目失效。我们讨论了N位如何标记TLB条目为空闲。现在,我们需要一条指令,让内核可以清除这个N位,即将过时的TLB条目标记为不再有效。

其次,我们需要某种方法来强制某些操作按特定顺序执行。我们需要一条新的RISC-V指令,强制核心在开始执行操作X、Y、Z之前,先完成操作A、B、C的执行。通常,这类操作通过所谓的栅栏指令实现。

RISC-V使用一条单独的指令来同时处理这两个任务(使TLB条目失效和强制排序),这条指令就是 sfence.vma

sfence.vma指令详解

在汇编代码中,sfence.vma指令的书写形式如下,它接受两个操作数,必须是寄存器名:

sfence.vma rs1, rs2
  • 第一个寄存器rs1包含一个虚拟地址
  • 第二个寄存器rs2包含一个地址空间标识符

虚拟地址和地址空间标识符共同描述了某个地址空间中的一个特定页。

执行此指令时,核心将:

  1. 首先完成所有先前访问该页的操作。
  2. 如果TLB中包含与该虚拟地址所在页匹配的条目,则消除该条目(更准确地说,将任何匹配条目的N位改为0,表示该条目不再使用,变为空闲)。
  3. 最后,强制核心在继续执行任何可能访问该页的指令之前,完成所有可能影响该特定页的更新。

关于sfence.vma指令的实际实现,大多数硬件可能会将其实现为一个完全栅栏:首先完成所有先前的指令(清空流水线),然后修改相关TLB条目的N位,最后才恢复执行sfence.vma之后的指令。虽然RISC-V规范的规定更为宽松,但完全栅栏的实现方式肯定是正确且有效的。

sfence.vma的使用场景

让我们看一个sfence.vma指令可能被使用的场景。

想象一个用户进程正在执行,突然发生缺页异常,进程被挂起,陷入处理开始,内核获得控制权。假设进程试图访问一个当前被换出、不在内存中的页。内核需要从二级存储将其读入。

当页被读入主内存后,内核将更新页表,用一个指向新分配物理页、且标记为有效的新页表项,替换之前标记为无效的旧页表项。在内核返回到被挂起的用户进程并重试(重新执行引发缺页的指令)之前,它应该先执行sfence.vma指令。

  1. 清除旧TLB条目:这将清除TLB中该页的任何旧条目,迫使内存管理单元在进程下次访问时重新遍历页表,获取新的页表项。
  2. 强制操作排序:这将强制核心在执行页表遍历之前等待,确保对页表的所有修改(例如,内核执行的存储指令)都已完成。

多核系统与TLB击落

如果我们在多核系统上运行,每个核心通常都有自己的私有TLB。当一个核心更新内存中的页表时,其他核心的TLB中可能缓存了该页的旧页表项。

不幸的是,sfence.vma指令只影响执行它的那个核心。其他核心的TLB独立运行,不受该指令影响。这成了一个硬件无法单独解决的问题,软件必须处理它

我们需要的是让一条sfence.vma指令能影响所有核心,刷新所有TLB中的过时页表项,并在任何可能使用该页的地方执行栅栏操作,但该指令的实际功能是局部的。

因此,内核需要以某种方式在每个核心上都执行一条sfence.vma指令。实现这一点需要一些协调和核间通信。最初修改页表的那个核心需要中断其他核心,并请求它们针对特定的虚拟地址和地址空间标识符执行sfence.vma指令。这个过程被称为 TLB击落

sfence.vma指令的变体

处理全局页

如果TLB中包含一个全局位被设置的条目(G=1),那么无论当前地址空间标识符是什么,内存管理单元都可能使用该TLB条目。因此,之前描述的sfence.vma形式不一定能清除可能用于该虚拟页的全局条目。

幸运的是,指令有一个变体可以处理这种情况:

sfence.vma rs1, x0

如果第二个操作数是寄存器x0(硬件恒为零的寄存器),则影响所有地址空间。TLB中任何与该特定虚拟页匹配的条目都将被移除,无论其地址空间标识符是什么,包括那些全局位设置为1的条目。这种形式用于更新全局页。

刷新整个地址空间

另一种常见情况是,某个进程终止,内核需要完全销毁整个虚拟地址空间,并回收相关的地址空间标识符以供其他进程使用。

为此,可以使用以下形式的sfence.vma指令:

sfence.vma x0, rs2

这里,第一个操作数必须是x0寄存器(不能是其他包含0值的寄存器)。使用这种形式,TLB中所有具有匹配地址空间标识符的条目都将被刷新(N位设为0)。当销毁一个虚拟地址空间时,内核会执行这样的指令。

刷新整个TLB

最后,如果两个操作数都是x0寄存器:

sfence.vma x0, x0

这将导致完全刷新整个翻译后备缓冲器。TLB中的每个条目都将被消除,所有N位被改为0,使整个TLB为空,所有条目空闲可供将来使用。内核可能不需要经常这样做,但在系统启动或复位时,为了确保TLB不会包含随机数据,执行此指令进行初始化是个好主意。

总结

本节课中,我们一起学习了RISC-V虚拟内存管理的最后一个关键环节:TLB一致性维护。我们了解到,由于页表会被动态更新,TLB中缓存的条目可能过时,因此需要一种机制来刷新它们。sfence.vma指令应运而生,它兼具两大功能:

  1. 作为内存栅栏,强制核心完成所有先前的页表相关操作,再执行后续可能依赖新页表的操作,确保了操作的顺序性。
  2. 作为TLB刷新指令,可以根据提供的虚拟地址和地址空间标识符,精确地使TLB中一个、多个或全部条目失效,确保地址转换使用最新的页表项。

我们还探讨了该指令在不同场景下的变体用法,包括处理全局页、销毁整个地址空间以及初始化时清空整个TLB。最后,我们指出了在多核系统中面临的“TLB击落”挑战,这需要操作系统内核通过软件协调各核心来共同解决。

通过这六节课的学习,你现在应该对RISC-V架构如何支持操作系统内核实现虚拟内存有了系统的理解。虚拟内存是现代计算系统的基石,掌握其硬件支持机制是深入理解计算机体系结构的关键一步。

022:指令编码详解 🧠

在本节课中,我们将要学习RISC-V指令集架构(ISA)的核心部分——指令编码。我们将详细介绍RV32I规范下的各种指令格式,包括R型、I型、S型、B型、U型和J型指令,并解释它们如何被编码成32位的机器码。理解这些编码是后续进行汇编编程或硬件实现的基础。

寄存器组

RISC-V架构包含32个通用寄存器,每个寄存器在RV32I规范下为32位宽。每个寄存器都有一个编号(x0到x31)和一个可选的别名,这些别名反映了寄存器在编程中的常见用途。例如,x1寄存器也常被称为ra(返回地址寄存器),用于存储函数调用后的返回地址。

所有寄存器都是通用目的,但有一个重要的例外:x0寄存器。它的值被硬件固定为0,且不可更改。它可以作为0值的来源,也可以作为不需要结果的操作的目的寄存器。

除了通用寄存器,还有一个程序计数器(PC),用于指向当前正在执行的指令地址。

指令编码概述

RISC-V指令集主要分为两类:标准指令集压缩指令集。标准指令集是必须实现的,所有指令长度固定为32位。压缩指令集是可选的,指令长度为16位,用于减少代码体积。本教程仅关注32位的标准指令集。

所有32位标准指令的最低两位(bits [1:0])总是11。其他位组合用于标识压缩指令。

指令编码有几种基本格式,它们共享一些共同的字段:

  • rd:5位,目的寄存器编号。
  • rs1:5位,第一个源寄存器编号。
  • rs2:5位,第二个源寄存器编号。
  • opcode:7位,主要操作码,用于区分指令大类。
  • funct3:3位,辅助操作码,用于在同类指令中进一步区分。
  • funct7:7位,另一个辅助操作码字段。

立即数(Immediate)字段在不同格式中的位置和组合方式不同,用蓝色高亮显示。

以下是主要的指令格式类型:

  • R型:用于寄存器到寄存器的操作(如加法)。
  • I型:用于包含立即数的操作(如加立即数)和加载指令。
  • S型:用于存储指令。
  • B型:用于条件分支指令。
  • U型:用于操作大立即数的指令(如加载高位立即数)。
  • J型:用于长跳转指令(跳转并链接)。

指令格式详解

上一节我们介绍了指令编码的概览,本节中我们来看看每种格式的具体构成。

R型指令

R型指令用于两个寄存器之间的算术逻辑单元(ALU)操作。其格式如下:

| funct7 (7 bits) | rs2 (5 bits) | rs1 (5 bits) | funct3 (3 bits) | rd (5 bits) | opcode (7 bits) |

一个典型的例子是add指令,它将rs1rs2寄存器的值相加,结果存入rd寄存器。R型指令还包括减法、位运算(与、或、异或)和移位操作。

I型指令

I型指令包含一个12位的立即数,该立即数会被符号扩展为32位后使用。其格式如下:

| immediate[11:0] (12 bits) | rs1 (5 bits) | funct3 (3 bits) | rd (5 bits) | opcode (7 bits) |

I型指令主要分为三类:

  1. ALU立即数指令:如addi(加立即数)。其funct3字段与对应的R型指令相同。
  2. 加载指令:如lw(加载字)。内存地址由rs1 + 符号扩展的立即数计算得出。
  3. 跳转并链接寄存器指令jalr。目标地址由rs1 + 符号扩展的立即数计算得出,同时将下一条指令的地址(PC+4)存入rd寄存器。

S型与B型指令

S型指令用于存储数据到内存,B型指令用于条件分支。它们都没有rd字段,并且立即数字段被拆分存放。

S型格式

| immediate[11:5] (7 bits) | rs2 (5 bits) | rs1 (5 bits) | funct3 (3 bits) | immediate[4:0] (5 bits) | opcode (7 bits) |

存储地址由rs1 + 符号扩展的立即数计算,将rs2寄存器中的字节、半字或字存入该地址。

B型格式

| immediate[12|10:5] (7 bits) | rs2 (5 bits) | rs1 (5 bits) | funct3 (3 bits) | immediate[4:1|11] (5 bits) | opcode (7 bits) |

B型指令的立即数编码方式比较特殊,它被重组并左移1位(即乘以2),形成最终的偏移量。目标地址为PC + 符号扩展的偏移量。由于指令地址总是2字节对齐,这种编码方式在12位立即数的基础上提供了大约±4KB的跳转范围。

条件包括:等于(beq)、不等于(bne)、小于(blt)、大于等于(bge)以及它们的无符号版本。

U型与J型指令

U型和J型指令包含更大的20位立即数字段,用于构建32位的地址或数据。

U型格式

| immediate[31:12] (20 bits) | rd (5 bits) | opcode (7 bits) |

U型指令有两条:

  • lui(加载高位立即数):将20位立即数左移12位后加载到rd寄存器的高20位,低12位置0。
  • auipc(PC加高位立即数):将20位立即数左移12位后与当前PC值相加,结果存入rd寄存器。

J型格式

| immediate[20|10:1|11|19:12] (20 bits) | rd (5 bits) | opcode (7 bits) |

J型指令只有一条:jal(跳转并链接)。其20位立即数经过复杂的重组和左移1位后,形成PC相对的跳转偏移量。目标地址为PC + 偏移量。同时,它将下一条指令的地址(PC+4)存入rd寄存器。jal可用于函数调用(rd设为ra)或长距离无条件跳转(rd设为x0)。

系统指令与CSR指令

上一节我们介绍了数据处理和流程控制指令,本节中我们来看看与系统控制和状态相关的指令。

系统指令共享相同的opcode,通过funct3字段区分。ecall用于发起系统调用,ebreak用于调试断点。

控制与状态寄存器(CSR)指令用于读写处理器内部的特殊寄存器。CSR由12位地址标识。以下是主要的CSR指令:

  • csrrw:原子性地读取CSR旧值到rd,并将rs1的值写入CSR。
  • csrrs:原子性地读取CSR旧值到rd,并将CSR中对应rs1掩码为1的位设置为1。
  • csrrc:原子性地读取CSR旧值到rd,并将CSR中对应rs1掩码为1的位清除为0。

以上三条指令的变体csrrwicsrrsicsrrci使用5位零扩展的立即数作为源操作数,而非寄存器。

常见的CSR包括cycle(时钟周期计数器)、time(实时时钟)和mstatus(机器模式状态寄存器)。

总结

本节课中我们一起学习了RISC-V RV32I指令集的编码方式。我们从32个通用寄存器开始,详细剖析了R型、I型、S型、B型、U型和J型指令的位域布局和用途。我们还介绍了用于系统调用和访问控制状态寄存器(CSR)的指令。理解这些指令如何从汇编助记符转换为32位的机器码,是进行RISC-V汇编编程或使用Verilog等硬件描述语言实现处理器内核的基石。掌握了这些知识,你就为深入探索RISC-V软硬件生态打下了坚实的基础。

023:Verilog实现 (FemtoRV)

概述

在本教程中,我们将详细解析一个用Verilog实现的RISC-V处理器核心的代码。这个核心名为FemtoRV32,由Bruno Levy和Matthias Cock编写,它实现了完整的RV32I指令集以及一个名为cycles的控制状态寄存器。我们将逐行分析代码,理解其工作原理、状态机设计以及各个模块的功能。

系统连接与接口

在深入核心模块之前,我们先了解它如何与更大的系统连接。一个简单的计算机系统通常包含核心模块、主存储器模块以及一个或多个I/O设备模块,它们通过总线连接,传递地址、数据和控制信号。

以下是FemtoRV32模块的接口信号图:

模块是一个时钟驱动模块,主要接口信号如下:

  • 输入信号
    • clk:时钟输入。
    • resetn:低电平有效的复位信号。
    • mem_rdata:32位,从存储器或I/O设备读取的数据。
    • mem_rbusy:读忙信号,高电平表示存储器/设备需要更多时间响应读请求。
    • mem_wbusy:写忙信号,高电平表示存储器/设备需要更多时间响应写请求。
  • 输出信号
    • mem_addr:32位,输出到存储器或I/O设备的地址。
    • mem_wdata:32位,要写入存储器或I/O设备的数据。
    • mem_wstrb:4位写掩码,指示mem_wdata中哪些字节需要写入。
    • mem_rstrb:读选通信号,高电平时启动读操作。

写掩码mem_wstrb用于控制字节、半字或全字的写入。例如,4‘b0001表示只写入最低字节。

核心架构与状态机概述

该处理器核心通过一个有限状态机(FSM)来控制指令的执行流程。状态机包含四个状态:

  1. FETCH_INSTR:取指状态。将程序计数器(PC)发送到存储器以获取下一条指令。
  2. WAIT_INSTR:等待指令状态。等待存储器返回指令数据。如果mem_rbusy为高,则停留在此状态。
  3. EXECUTE:执行状态。解码指令,执行算术逻辑运算,并决定下一步动作。
  4. WAIT_ALU_OR_MEM:等待ALU或存储器状态。用于处理需要多个周期的操作,如加载(Load)、存储(Store)和移位(Shift)指令。

正常指令周期沿着 FETCH_INSTR -> WAIT_INSTR -> EXECUTE -> FETCH_INSTR 的循环进行。需要额外周期的指令(Load/Store/Shift)会进入 WAIT_ALU_OR_MEM 状态。

核心的主要寄存器包括:

  • state:4位状态寄存器,采用独热编码。
  • PC:程序计数器。
  • instr:当前执行的指令寄存器。
  • rs1, rs2:从寄存器文件中读取的两个源操作数寄存器。
  • ALU_reg, ALU_shift_amount:用于移位操作的寄存器。
  • cycles:周期计数器。

代码结构总览

模块代码主要分为以下几个部分:

  1. 指令解码:从指令字中提取各个字段。
  2. 寄存器文件:32个通用寄存器的定义与读写逻辑。
  3. 算术逻辑单元:执行算术、逻辑和移位运算的组合与时序逻辑。
  4. 条件分支判断:计算分支条件是否成立。
  5. 程序计数器与分支目标计算:计算下一条指令地址和分支/跳转目标地址。
  6. 加载/存储地址与数据处理:计算访存地址,处理字节/半字加载的符号扩展和存储数据的对齐。
  7. 状态机:定义状态和状态转移逻辑。
  8. 周期计数器cycles CSR的实现。

接下来,我们将逐一深入每个部分。

指令解码

指令解码部分负责从32位的指令寄存器instr中提取出各种字段,这些字段将用于后续的控制和执行。

以下是提取的主要字段:

  • rdId:目标寄存器编号(指令位[11:7])。
  • funct3Is:3位功能码funct3(指令位[14:12])的独热编码表示。
  • 各种立即数格式:
    • Iimm:用于I型指令的12位立即数,符号扩展至32位。
    • Simm:用于S型(存储)指令的立即数。
    • Bimm:用于B型(分支)指令的立即数。
    • Uimm:用于U型(高位立即数)指令的立即数。
    • Jimm:用于J型(跳转)指令的立即数。

提取立即数的逻辑遵循RISC-V指令格式规范。例如,Iimm的提取代码如下:

wire [31:0] Iimm = { {21{instr[31]}}, instr[30:25], instr[24:21], instr[20] };

这行代码将指令的第31位(符号位)复制21次进行符号扩展,然后拼接指令中的其他立即数位。

此外,代码还根据指令的操作码(opcode,指令位[6:2])对指令进行分类,生成如isALUreg(寄存器-寄存器ALU操作)、isALUimm(立即数ALU操作)、isLoadisStoreisBranchisJALisJALR等信号。

寄存器文件

寄存器文件包含32个32位寄存器,对应RISC-V的32个通用寄存器。寄存器x0硬连线为0。

寄存器文件的写操作由writeBack信号控制。当writeBack为高且目标寄存器rdId不为0时,在时钟上升沿将writeBackData写入寄存器文件。

从寄存器文件读取rs1rs2的操作在WAIT_INSTR状态完成。当从存储器返回的指令数据有效时(mem_rbusy为低),代码会提取rs1rs2的编号,并同时从寄存器文件中读取对应的值,存入rs1rs2寄存器,供EXECUTE状态使用。

算术逻辑单元

ALU负责执行算术和逻辑运算。它有两个32位输入ALUin1ALUin2,一个32位输出ALUout

  • ALUin1总是来自rs1寄存器。
  • ALUin2根据指令类型选择:对于寄存器-寄存器操作或分支指令,来自rs2;否则,来自指令的立即数Iimm

ALU执行的操作包括加法、减法、各种比较(等于、小于、无符号小于)、位运算(与、或、异或)以及移位。

加法和减法:加法和减法使用相同的硬件逻辑。减法通过将第二个操作数取反加1再与第一个操作数相加来实现。为了正确处理有符号数的比较,操作数被扩展为33位。

比较操作

  • 等于:检查减法结果的低32位是否全为0。
  • 小于(有符号):通过比较操作数符号位和减法结果的符号位来判断。
  • 小于(无符号):直接使用33位减法结果的符号位(最高位)来判断。

移位操作:移位操作(逻辑左移、逻辑右移、算术右移)需要多个时钟周期。它使用两个专用寄存器:

  • ALU_reg:存放待移位的值。
  • ALU_shift_amount:存放需要移位的位数。

移位过程是迭代的:每个时钟周期移一位,并递减ALU_shift_amount,直到其为0。ALU_busy信号在移位期间保持高电平。代码中有一段被注释掉的优化部分,可以实现每次移4位以加速长移位。

ALU的输出ALUout根据funct3选择具体的运算结果。

条件分支判断

这部分组合逻辑根据funct3和ALU计算出的比较结果(equal, lessThan, lessThanU),生成分支指令是否跳转的信号takeBranch

例如,对于BEQ(相等则分支)指令,takeBranch等于equal信号。

程序计数器与访存地址计算

下一条指令地址:通常为PC + 4

分支/跳转目标地址

  • 对于JAL指令,目标地址为 PC + Jimm
  • 对于AUIPC指令,目标地址为 PC + Uimm
  • 对于分支指令,如果takeBranch为真,目标地址为 PC + Bimm
  • 对于JALR指令,目标地址为 (rs1 + Iimm) & ~1(强制地址对齐)。

加载/存储地址:由rs1寄存器的值加上相应的立即数(Iimm用于加载,Simm用于存储)计算得出,结果存储在loadstore_addr中。

输出到存储器的地址mem_addr根据状态选择:在FETCH_INSTRWAIT_INSTR状态,输出PC;在EXECUTEWAIT_ALU_OR_MEM状态,输出loadstore_addr

加载与存储数据处理

加载指令:需要处理字节、半字和字的加载,以及有符号和无符号扩展。

  1. 根据loadstore_addr的最低两位,确定所需数据在返回的32位字中的位置。
  2. 提取出相应的字节或半字。
  3. 根据指令的funct3判断是否为有符号加载,并进行符号扩展或零扩展至32位,生成load_data

存储指令:需要根据存储大小(字节、半字、字)和地址对齐情况,构建32位的mem_wdata和4位的mem_wstrb写掩码。

  1. 总是将源寄存器rs2的最低字节放入mem_wdata的对应位置。
  2. 根据地址位和存储大小,决定mem_wdata中其他字节的来源(来自rs2的不同字节)。
  3. 根据存储大小和地址对齐情况生成mem_wstrb。例如,存储一个字节到地址0x01mem_wstrb可能为4‘b0010

该核心不支持非对齐的半字和字访问。对于半字访问,地址最低位必须为0;对于字访问,地址最低两位必须为00。

状态机详解

状态寄存器state采用独热编码,四个状态分别对应state[0]state[3]

状态转移在时钟上升沿发生,由always块中的case语句描述(实际使用if判断特定位):

  1. FETCH_INSTR:无条件转移到WAIT_INSTR状态,并发出mem_rstrb信号。
  2. WAIT_INSTR:如果mem_rbusy为低,表示指令数据已就绪。此时,将mem_rdata锁存到instr寄存器,同时从寄存器文件读取rs1rs2的值,并转移到EXECUTE状态。如果mem_rbusy为高,则保持在本状态。
  3. EXECUTE
    • 更新PC。根据指令类型,PC可能变为PC+4、分支目标地址或跳转目标地址。
    • 判断是否需要进入WAIT_ALU_OR_MEM状态。如果需要(needToWait为高),则转移。否则,直接转移回FETCH_INSTR状态。
    • 设置writeBackwriteBackData信号,如果当前指令需要写回寄存器(非分支、非存储指令)。
  4. WAIT_ALU_OR_MEM:在此状态等待长周期操作完成。只要mem_rbusymem_wbusyALU_busy中任何一个为高,就保持在本状态。当所有忙信号都为低时,转移回FETCH_INSTR状态,并完成最后的写回操作(如果是加载或移位指令)。

writeBackData的来源根据指令类型选择:可能是ALU结果、PC+4(用于JAL/JALR的返回地址)、加载的数据load_data、大立即数Uimmcycles计数器值。

周期计数器

cycles计数器是一个简单的寄存器,每个时钟周期加1,用于测量自复位以来经过的周期数。它可以通过CSRR指令读取到通用寄存器中。

时序示例

典型指令(如ADD)

  1. 周期1 (FETCH_INSTR):发送PC,启动读指令。
  2. 周期2 (WAIT_INSTR):接收指令数据,读取rs1/rs2
  3. 周期3 (EXECUTE):计算ALUout,设置writeBack。在周期结束时,PC更新为PC+4,结果写回寄存器文件,状态回到FETCH_INSTR

移位指令(如SLLI,移位2位)

  1. 周期1-2:同ADD,取指和读寄存器。
  2. 周期3 (EXECUTE):识别为移位指令,needToWait为高。加载ALU_regALU_shift_amount(值为2),进入WAIT_ALU_OR_MEM
  3. 周期4 (WAIT_ALU_OR_MEM):ALU_shift_amount非零,执行一次移位并递减为1。writeBack有效,但写回的是中间结果(此设计特点)。
  4. 周期5 (WAIT_ALU_OR_MEM):ALU_shift_amount非零,执行第二次移位并递减为0。
  5. 周期6 (WAIT_ALU_OR_MEM):ALU_shift_amount为零,ALU_busy变低。状态转移回FETCH_INSTR,在转移前的时钟沿,最终的移位结果被写回寄存器文件。

总结

本节课我们一起学习了FemtoRV32这个简易但功能完整的RISC-V处理器核心的Verilog实现。我们分析了其系统接口、四状态有限状态机、指令解码、寄存器文件、ALU运算、访存处理以及控制流更新的完整流程。该设计巧妙地使用独热编码和组合逻辑并行性,在有限的硬件资源下实现了RV32I指令集。通过本次学习,你应该对RISC-V处理器核心的基本结构和Verilog硬件描述语言如何实现一个CPU有了更深入的理解。

024:IEEE 754浮点数标准 🧮

在本节课中,我们将要学习计算机中浮点数的表示方法,即IEEE 754标准。无论您使用RISC-V、ARM还是Intel处理器,此标准都是通用的。理解浮点数是学习RISC-V可选F(单精度)和D(双精度)扩展的基础。

概述:科学计数法与二进制

上一节我们介绍了课程背景,本节中我们来看看浮点数的基本思想。它源于科学计数法。

在十进制科学计数法中,一个数由符号、尾数(有效数字)和以10为底的指数组成,例如:-3.456 × 10²

二进制浮点数采用相同的思路,但底数变为2。其通用形式为:

(-1)^s × M × 2^E

其中:

  • s 是符号位(0表示正,1表示负)。
  • M 是尾数(一个二进制小数)。
  • E 是指数。

我们的习惯是将小数点(在二进制中称为二进制点或基数点)放在第一个有效数字之后。对于二进制数,这意味着将点放在第一个1之后。

单精度与双精度表示 🧱

理解了基本形式后,我们来看看计算机中具体的实现方式。IEEE 754标准主要定义了两种格式:单精度(32位)和双精度(64位)。

在C语言中,float类型对应单精度,double类型对应双精度。在RISC-V中,单精度指令以F开头,双精度指令以D开头。如果处理器支持双精度(D扩展),则必定也支持单精度(F扩展)。

以下是两种精度的位字段布局:

单精度(32位)

  • 1位 符号位 (s)
  • 8位 指数位 (exp)
  • 23位 尾数位 (frac)

双精度(64位)

  • 1位 符号位 (s)
  • 11位 指数位 (exp)
  • 52位 尾数位 (frac)

对于规格化(Normal) 数,尾数域存储的是小数点后的部分,而小数点前隐含了一个1(即1.frac)。这种设计节省了一位,提高了精度。

精度与数值范围示例 🔍

浮点数具有固定的位数,因此只能精确表示一部分有理数,数值之间存在间隔。

对于单精度浮点数,我们常说其具有约7位十进制有效数字的精度。对于双精度浮点数,则具有约16位十进制有效数字的精度。

数值之间的间隔并非均匀。指数越大,能表示的数值范围越广,但相邻可表示值之间的间隔也越大;反之,越接近0,间隔越小。

让我们通过两个例子来感受一下:

示例1:大数(间隔大)
假设一个单精度数的二进制表示为:

0 10001100 11001100110011001100110

(符号位0,指数140,尾数0.7999999...)
其表示的十进制值约为 7.9999998 × 10¹⁰(约800亿)。下一个可表示的数是通过将尾数最低位加1得到的,其值约为 7.9999998 × 10¹⁰ + 4096。两者间隔为4096,但前7位十进制数字相同。

示例2:小数(间隔小)
假设一个单精度数的二进制表示为:

0 01101111 01010101010101010101010

(指数111,尾数0.3333333...)
其表示的十进制值约为 1.0000001 × 10⁻⁵。下一个可表示的数约是 1.0000001 × 10⁻⁵ + 1.8 × 10⁻¹²。间隔极小,同样前7位十进制数字相同。

特殊值 ⚠️

除了规格化数字,IEEE 754标准还定义了几种特殊的位模式来表示特定值。

这些特殊值通过指数域的全0或全1来区分:

  • 零(Zero):指数域全0,尾数域全0。有+0-0两种表示。
  • 无穷大(Infinity):指数域全1,尾数域全0。用于表示溢出,有+∞-∞
  • 非数(NaN, Not a Number):指数域全1,尾数域非全0。表示无效操作的结果(如0/0, ∞-∞)。
  • 非规格化数(Denormalized/Subnormal Numbers):指数域全0,尾数域非全0。用于表示非常接近0的数,此时隐含的整数位是0而不是1,精度会降低。

以下是单精度浮点数的特殊值编码示例:

  • +0: 0x00000000
  • -0: 0x80000000
  • +∞: 0x7f800000
  • -∞: 0xff800000
  • NaN: 0x7fc00000 (一种典型值)

舍入与异常处理 ⚙️

由于浮点数表示能力有限,运算结果可能无法精确表示,此时需要进行舍入。

IEEE 754定义了多种舍入模式:

  • 向最近偶数舍入(Round to Nearest, Ties to Even - 默认且最常用)
  • 向零舍入(Round toward Zero)
  • 向下舍入(Round Down / Toward -∞)
  • 向上舍入(Round Up / Toward +∞)

处理器中通常有一个控制状态寄存器(CSR) 来设置当前的舍入模式。

在运算过程中,可能会发生以下异常情况,相应的状态标志位会被置位(且是“粘性的”,直到被手动清除):

  1. 不精确(Inexact):结果被舍入。
  2. 上溢(Overflow):结果幅值太大,返回±∞。
  3. 下溢(Underflow):结果幅值太小,可能返回非规格化数或0。
  4. 除零(Divide by Zero):例如1.0/0.0 = ∞。
  5. 无效操作(Invalid Operation):如0/0、∞-∞,返回NaN。

使用注意事项与总结 📝

最后,我们需要警惕浮点数与数学中实数的区别:

  • 存在±0
  • 运算结果可能不精确。
  • 不满足结合律!例如,(a + b) + c 不一定等于 a + (b + c)
  • 需要注意上溢和下溢。
  • 一个有用的特性是:任何32位整数(有符号或无符号)都可以用双精度浮点数精确表示

本节课中我们一起学习了IEEE 754浮点数标准的核心内容。我们探讨了单精度和双精度浮点数的二进制表示方法,包括规格化数、零、无穷大、NaN和非规格化数等特殊值。我们还了解了舍入模式和各种运算异常条件。掌握这些基础知识,对于理解RISC-V或其他任何架构的浮点运算单元都至关重要。

025:浮点指令(第一部分)

在本节课中,我们将要学习RISC-V处理器架构中的浮点运算实现。这是关于RISC-V浮点指令系列视频的第一部分。上一节我们介绍了浮点数的通用表示方法,本节中我们来看看RISC-V处理器如何具体实现浮点运算。

概述

本视频将介绍浮点寄存器、执行算术运算的基本指令、RISC-V的各种浮点选项(如单精度、双精度等),以及如何将较小精度的值“装箱”到较大的NaN值中。我们还将描述浮点控制和状态寄存器,其中包含了舍入模式等信息。在下一部分视频中,我们将更详细地讨论舍入,并介绍浮点指令的二进制编码、加载/存储指令、精度转换指令、比较指令以及调用约定等主题。

浮点寄存器

如果处理器核心实现了浮点运算,那么除了32个通用整数寄存器外,还会有32个额外的浮点寄存器,命名为 F0F31。与整数寄存器不同,寄存器F0没有特殊含义。

这些寄存器的大小取决于处理器核心实现了哪些选项。以下是相关选项:

  • 选项F:单精度,需要32位。
  • 选项D:双精度,需要64位,并要求已实现选项F。
  • 选项Q:四倍精度,需要128位,并要求已实现选项D。
  • 选项Zfh:半精度,需要16位,并要求已实现选项F。

无论实现哪些选项,都只有一套寄存器集,其大小为所支持的最大精度。例如,如果实现了选项F和D,寄存器将是64位,能够容纳单精度或双精度值。

基本浮点指令

以下是我们的第一条浮点指令示例:

fadd.s fd, fs1, fs2

它将两个浮点寄存器中的值相加,并将结果放入目标浮点寄存器。由于有32个寄存器,这些寄存器在指令中用5位字段编码。浮点指令的编码与整数指令非常相似,只是操作码位不同。

我们可以对浮点指令做一些概括:

  1. 它们都以字母 F 开头,表示浮点。
  2. 它们有一个大小后缀。例如,.s 表示单精度加法。

对于其他精度,也有相应的指令:

  • .h:半精度
  • .d:双精度
  • .q:四倍精度

处理器核心具体实现哪些指令,取决于它实现了哪些可选扩展。如果尝试执行未实现的指令(例如,核心未实现四倍精度却执行 fadd.q),将引发非法指令异常。

浮点指令分类

我们将要描述的浮点指令很多,但可以初步分为以下几类:

以下是主要类别:

  • 算术指令:加、减、乘、除、平方根、最小值、最大值。
  • 分类指令:用于确定浮点数的类型(如正负无穷、零、规格化数、非规格化数、NaN)。
  • 加载/存储指令:在内存和浮点寄存器之间移动浮点数。
  • 比较指令:测试特定条件,并将整数寄存器设置为1或0以表示结果。
  • 转换指令:在整数寄存器的整数值与不同精度的浮点数之间进行转换。
  • 其他杂项指令:例如,在浮点寄存器之间移动值、计算绝对值或取反等。

算术运算指令

基本的浮点算术指令从浮点寄存器中获取参数,并将结果放入目标浮点寄存器。

如果核心实现了浮点运算,那么它将拥有以下单精度指令(后缀为 .s):

fadd.s, fsub.s, fmul.s, fdiv.s, fmin.s, fmax.s, fsqrt.s

如果核心还实现了双精度,则还会有以下指令(后缀为 .d):

fadd.d, fsub.d, fmul.d, fdiv.d, fmin.d, fmax.d, fsqrt.d

如果核心实现了四倍精度,则会有以下指令(后缀为 .q):

fadd.q, fsub.q, fmul.q, fdiv.q, fmin.q, fmax.q, fsqrt.q

如果核心还实现了半精度,则会有以下指令(后缀为 .h):

fadd.h, fsub.h, fmul.h, fdiv.h, fmin.h, fmax.h, fsqrt.h

本视频将描述所有浮点指令,但具体哪些指令可用,取决于您的RISC-V核心实现了哪些可选扩展。

装箱(Boxing)概念

接下来要问的问题是,例如,在实现了双精度的核心上尝试执行单精度算术指令会发生什么?单精度值需要32位,但我们的寄存器是64位。

对于源操作数,只使用低32位,高32位被忽略。对于目标寄存器,结果被放入64位寄存器的低半部分,而高半部分被设置为全1。

这与在RV64机器上进行32位整数运算类似,但区别在于:整数运算的结果会进行符号扩展,而浮点运算的高半部分总是用全1填充。

为了理解这是如何工作的,让我们回顾一下NaN值。如果一个值的指数位全为1,并且尾数部分至少有一个1位,则该值被解释为NaN。符号位无关紧要。

例如,这个双精度值将被解释为NaN。我们甚至不关心这个值的低半部分是什么。实际上,我们可以在低半部分隐藏一些东西,这被称为有效载荷。我们要做的是将一个单精度浮点值隐藏在低半部分。

因此,作为一个双精度值,它被解释为NaN,但如果我们只看低半部分,那么我们就得到了我们的单精度值。这种技术被称为装箱

在同时实现单精度和双精度的核心上,我们可以将一个单精度数装箱在一个双精度数内。我们也可以将一个双精度数装箱在四倍精度数内,或者将一个半精度数装箱在单精度数内。基本上思路相同。

实际上,我们可以递归地进行装箱。例如,一个半精度值可以被装箱在一个单精度数内,而这个单精度数本身又被装箱在一个双精度值内。

关于NaN的补充说明

有时你会遇到静默NaN信号NaN的区别。

  • 静默NaN:如果指令的一个操作数是静默NaN,那么结果将是另一个NaN。静默NaN会被传播。
  • 信号NaN:如果指令的一个操作数是信号NaN,那么计算将停止,指令将引发异常。

在RISC-V中,默认情况下所有的NaN都是静默的。信号NaN是一个可能不会在您的处理器中实现的选项。

分类指令

一个非常有用的指令是 fclass 指令。它获取浮点寄存器中的一个值,判断它是哪种类型的浮点数,并将一个代码值存储到一个通用整数寄存器中。

这个指令有几种变体,对应四种不同的精度后缀。浮点值可以是:正/负无穷、正/负零、规格化数、非规格化数,或者可能是NaN。

以下是所有可能的代码值。其中一个整数值会被移动到目标通用整数寄存器中,以告知您所拥有的值的类型。

浮点控制与状态寄存器

浮点控制和状态寄存器包含两个控制浮点指令操作的字段:

  1. 舍入模式字段:包含一个代码,指示浮点算术指令在结果无法精确表示时应如何舍入。
  2. 标志位字段:包含五个标志位。

舍入模式代码及其符号名如下:

  • RNE:向最接近的值舍入, ties to even(当精确结果恰好位于两个可表示值的中间时,舍入到最低有效位为0的值)。
  • RTZ:向零舍入。
  • RDN:向下舍入(向负无穷舍入)。
  • RUP:向上舍入(向正无穷舍入)。
  • RMM:向最接近的值舍入,ties to max magnitude(当精确结果恰好位于两个可表示值的中间时,舍入到绝对值较大的值)。

标志位会在特定条件发生时由任何指令设置,以便在一系列计算后检查是否发生了任何异常情况:

  • 无效操作位:如果发生无效操作(例如正无穷加负无穷)则置位。
  • 除零位:如果尝试除以零则置位。
  • 上溢位:如果计算发生上溢则置位。
  • 下溢位:如果计算发生下溢则置位。
  • 不精确位:如果精确结果无法用给定精度表示则置位。

还有两个独立的寄存器:fflags(包含五个浮点标志位)和 frm(舍入模式寄存器)。它们包含的位与FCSR寄存器中的相应位完全相同。因此,如果您想修改或查询这些位,使用哪个寄存器都可以。

访问控制与状态寄存器

为了查询或修改这些控制和状态寄存器,我们有以下指令:

  • frcsr rd:读取FCSR到整数寄存器rd。
  • fscsr rd, rs / fscsr rs:将整数寄存器rs的值写入FCSR(可选同时读取旧值到rd)。
  • frmr rd:读取舍入模式寄存器到rd。
  • fsrm rd, rs / fsrm rs:设置舍入模式寄存器。
  • frflags rd:读取标志位寄存器到rd。
  • fsflags rd, rs / fsflags rs:设置标志位寄存器。

需要指出的是,这些指令实际上是伪指令。汇编器会将它们翻译成实际存在的机器指令(如 csrr, csrw, csrrs 等),这展示了伪指令机制的强大之处。

总结

本节课中我们一起学习了RISC-V浮点实现的第一部分内容。我们介绍了浮点寄存器,描述了执行算术运算的基本指令,讨论了单、双、四倍及半精度等不同精度选项。我们解释了如何将较小精度的值装箱到较大精度的NaN值中。最后,我们讨论了用于浮点指令的控制和状态寄存器。

在下一节视频中,我们将更详细地讨论舍入模式,介绍浮点指令的二进制编码,并讲解加载/存储指令、不同精度间的转换指令、移动/绝对值/取反指令以及浮点值比较指令。我们还将讨论浮点调用约定并涵盖其他一些主题。

026:浮点指令(第二部分)

在本节课中,我们将继续学习RISC-V架构中的浮点指令。上一节我们介绍了浮点寄存器、算术指令、不同精度以及浮点控制与状态寄存器。本节中,我们将深入探讨舍入模式、指令编码、加载/存储指令、转换指令、比较指令以及浮点调用约定等核心内容。

舍入模式:动态与静态

上一节我们介绍了浮点运算,本节中我们来看看如何指定舍入模式。RISC-V提供了两种指定舍入模式的方法:动态舍入和静态舍入。

  • 动态舍入:使用浮点控制与状态寄存器中的舍入模式位来决定结果应如何舍入。
  • 静态舍入:直接在指令中指定舍入模式。

以下是这两种方法在汇编语言中的示例:

  • 使用动态舍入(即使用控制与状态寄存器中的设置),无需在指令中做任何额外操作。
  • 使用静态舍入,需要在指令中添加一个额外的操作数,使用以下符号之一:
    • rne:向最接近的值舍入(四舍六入五成双)。
    • rtz:向零舍入。
    • rdn:向下舍入(向负无穷大)。
    • rup:向上舍入(向正无穷大)。
    • rmm:向最接近的值舍入,平局时取最大幅度值。

舍入模式适用于算术指令以及部分转换指令,并且适用于各种精度。对于某些结果总是精确的指令(例如 fminfmax 指令,或单精度转双精度转换),如果尝试指定静态舍入模式,汇编器会报错。

浮点指令编码

了解浮点指令如何编码为二进制机器码有很多细节,但我们可以通过一个例子来感受一下。以下是一条浮点减法指令,每条指令都被编码为一个32位的完整指令。

浮点指令包含多个字段:

  • 操作码字段:决定指令的基本类型和具体操作(如加法、减法、乘法等)。
  • 精度字段:决定是单精度、双精度还是其他精度。
  • 寄存器字段:指定目标寄存器和源寄存器。
  • 舍入模式字段:一个3位的字段,用于指定舍入模式。

以一条静态指定“向零舍入”的减法指令为例,其舍入模式字段会被编码为 001。如果我们不指定任何舍入模式(即使用动态舍入),则编码为 111,此时舍入模式由浮点控制与状态寄存器决定。

加载与存储指令

与通用寄存器类似,浮点寄存器也有一系列加载和存储指令,用于在内存和浮点寄存器之间移动数据。

以下是可用的指令类型:

  • flh / fsh:加载/存储半字(2字节)。
  • flw / fsw:加载/存储字(4字节,单精度)。
  • fld / fsd:加载/存储双字(8字节,双精度)。
  • flq / fsq:加载/存储四字(16字节,四精度)。

地址计算方式为:内存地址 = 基址寄存器(RS1) + 指令中的偏移量。其中,基址寄存器 RS1 使用的是通用整数寄存器。具体能使用哪些指令取决于你的RISC-V核心实现了哪些扩展(例如,没有实现四精度浮点,就不能使用 flq/fsq)。

对于整数加载,较小的值会被符号扩展以填充较大的寄存器。对于浮点加载,当加载的数据大小小于寄存器本身时,值会被“装箱”,即作为非数字值的有效载荷进行封装。

转换指令

转换指令用于将源寄存器中的值复制到目标寄存器,并在过程中进行格式转换。源和目标可以是浮点值或整数值,分别对应浮点寄存器和通用寄存器。

转换指令的一般格式为:fcvt.{目标格式}.{源格式} rd, rs。例如,fcvt.s.w rd, rs 表示将32位有符号整数(源在通用寄存器 rs)转换为单精度浮点数(目标在浮点寄存器 rd)。

格式说明符如下:

  • 浮点格式s(单精度)、d(双精度)、q(四精度)、h(半精度)。对应寄存器必须是浮点寄存器。
  • 整数格式w(32位有符号)、wu(32位无符号)、l(64位有符号)、lu(64位无符号)。对应寄存器必须是通用整数寄存器。

可用的转换指令非常多,具体取决于核心实现的扩展。

移动、绝对值与取反指令

除了转换,还有几条指令用于在通用整数寄存器和浮点寄存器之间直接移动比特位,而不进行任何数值转换。这意味着相同的比特模式在两种寄存器中会被解释为完全不同的值。

以下是相关的移动指令:

  • fmv.x.w / fmv.w.x:在32位通用寄存器(x)和单精度浮点寄存器(w)之间移动32位数据。
  • fmv.x.d / fmv.d.x:在64位通用寄存器(x)和双精度浮点寄存器(d)之间移动64位数据(适用于RV64且实现双精度)。

此外,还有三条实用的浮点伪指令:

  • fabs.s rd, rs:计算绝对值(清除符号位)。
  • fneg.s rd, rs:取反。
  • fmv.s rd, rs:寄存器间移动。

它们的底层实现分别是 fsgnjx.sfsgnjn.sfsgnj.s 指令。

浮点比较指令

测试在编程中很重要,让我们看看如何比较浮点值。所有比较指令都遵循相同的模式:比较两个浮点寄存器中的值,然后将结果(1表示真,0表示假)写入一个通用整数寄存器。

以下是比较指令的示例:

  • feq.s rd, rs1, rs2:单精度相等测试。
  • flt.s rd, rs1, rs2:单精度小于测试。
  • fle.s rd, rs1, rs2:单精度小于等于测试。

执行比较指令后,通常会使用分支指令(如 beqbne)来测试结果寄存器,以决定程序流向。需要注意的是,硬件只直接实现了小于(flt)和小于等于(fle)操作。大于(fgt)和大于等于(fge)是伪指令,通过交换操作数并调用 flt/fle 来实现。

浮点比较的注意事项

  1. 相等性测试:由于舍入误差,两个在数学上相等的数在浮点表示中可能不完全相等,因此直接测试相等性存在风险。更好的做法是计算差值,并与一个很小的阈值(epsilon)进行小于比较。
  2. 与非数字比较:任何与非数字(NaN)进行的 <<=>>= 比较都被视为无效操作,会设置控制与状态寄存器中的无效操作标志。但奇怪的是,相等性比较(feq)不被视为无效操作。
  3. NaN的自比较NaN == NaN 的结果总是假(false),这与直觉相悖。
  4. 正零与负零+0.0-0.0 在比较时是相等的,但 1.0 / +0.0 得到正无穷,而 1.0 / -0.0 得到负无穷,这挑战了“相等”的直观含义。

浮点寄存器命名与调用约定

与通用寄存器(x0-x31)类似,浮点寄存器(f0-f31)也有别名,反映了它们在函数调用中的角色:

  • 参数寄存器fa0 - fa7,用于传递浮点参数。
  • 临时寄存器ft0 - ft11,调用者保存,可在函数内自由使用。
  • 被调用者保存寄存器fs0 - fs11,如果函数要使用它们,必须保存原值并在返回前恢复。

汇编器会自动处理这些别名,建议在编程中使用这些更具描述性的名称。调用约定与整数寄存器类似:浮点参数通过 fa0-fa7 传递,函数内可使用临时寄存器,若需使用被调用者保存寄存器则需保存和恢复其值。

其他可选扩展

最后,我们简要介绍几个可能不太常见但值得了解的可选RISC-V扩展:

  • ZFA扩展:增加了一些指令,如浮点立即数加载(fli)、浮点舍入到整数(fround)等。
  • Zfinx/Zdinx/Zhinx扩展:这些扩展允许浮点指令直接操作通用整数寄存器(x寄存器),而不是独立的浮点寄存器(f寄存器)。这样,浮点寄存器就成为了通用寄存器的别名。这样做的好处是硬件内部可以使用与IEEE 754标准不同的格式(如更多位数、补码指数、显式前导位)来存储浮点数,以优化性能或简化硬件。
  • Zhinxmin扩展:提供对半精度浮点的极简支持,主要提供半精度与单/双精度之间的转换指令,以便在更大精度下进行运算后再转换回来。

总结

本节课中,我们一起深入学习了RISC-V浮点指令的第二部分内容。

我们探讨了动态和静态两种舍入模式,了解了浮点指令的基本编码格式。我们学习了用于在内存和浮点寄存器间传输数据的加载和存储指令,以及在不同格式(浮点与整数,不同精度浮点)间进行转换的转换指令

我们还介绍了移动、绝对值和取反指令,以及用于比较两个浮点值的比较指令,并特别讨论了浮点比较中关于舍入、NaN和零值的注意事项。

我们明确了浮点寄存器的别名fa*, ft*, fs*)及其在函数调用约定中的角色。最后,我们简要了解了一些可选的扩展,如ZFA、Zfinx系列和Zhinxmin。

掌握这些知识,将帮助你更有效地在RISC-V汇编程序中使用浮点运算。

posted @ 2026-03-29 09:25  布客飞龙II  阅读(12)  评论(0)    收藏  举报