【Create my OS】0 前置知识

November 01, 2024

Github代码仓库链接

0.1 RISC-V硬件机制

1 概述和特权级

RISC-V(发音为“risk-five”)是一个基于精简指令集(RISC)原则的开源指令集架构(ISA),简易解释为开源软件运动相对应的一种“开源硬件”。

指令集架构(Instruction Set Architecture,简称ISA)指的是计算机中处理器的指令集和处理器的内部结构,即处理器是如何执行指令的。

1、RISC-V 是一个典型的 Load-Store 类型的指令集架构,这意味着几乎所有的指令的操作数都只能是立即数或者寄存器,而不能是某个内存地址。如果想要修改内存某处的值,只能通过 Load 指令将某个内存地址处的值加载到寄存器中,并在修改完成后通过 Store 指令将其写入内存。 2、RISC-V 采用了模块化设计,包括几个可以互相替换的基本指令集, 以及额外可以选择的扩展指令集。本章及其后的内容将基于 RV64I,并且不会涉及扩展指令集 3、(核心的RISC-V指令集(即RV32IRV64I)包含了大约40-50条基本指令,这些指令主要用于整数计算、内存访问、分支、比较等基础操作) 4、RISC-V 硬件线程(hart)都运行在某个特权等级下:

  • 机器模式(M-Mode)3) 可以用来管理RISC-V上的安全执行环境。所有的硬件实现都必须提供 M-Mode,这是唯一可以自由访问整个机器的模式
  • 监管者模式(S-Mode)1)用于操作系统。系统实现还可以添加监管者模式(S-Mode),以隔离监管者级操作系统和监管者执行环境(SEE)。
  • 用户模式(U-Mode)0)用于传统的应用程序。许多 RISC-V 实现还将至少支持用户模式,以保护系统的其余部分不受应用程序代码的影响。

![[Pasted image 20250222155705.png]]

等级 编码 名称 简写
0 00 用户/应用程序(User/Application) U
1 01 监管者(Supervisor) S
2 10 未定义 /
3 11 机器(Machine) M

2 寄存器组

1、通用寄存器

在这里插入图片描述

  • 变量 XLEN 指指令集位宽
  • 整数调用约定提供了 8 个参数寄存器 a0 ~ a7,前两个寄存器也用于保存返回值
  • 单个参数寄存器中最多存储 XLEN 位宽的数据,如果没有可用的寄存器,那么参数就会被保存在栈上
  • 当将参数存储到寄存器或是栈上时,小于 XLEN 位的整数会根据其类型扩展到 32 位,接着再符号扩展到 XLEN 位
  • 单个长度为 2*XLEN 参数会存储在一对参数寄存器中,其中低 XLEN 位存储在编号较小的寄存器中,高 XLEN 位存储在编号较大的寄存器。。如果没有可用的寄存器,那么参数就会被保存在栈上,如果恰好只有一个寄存器可用,那么低 XLEN 位会保存在寄存器上而高 XLEN 位会保存在栈上。
  • 宽度超过 2*XLEN 位的参数会以引用的方式传递
  • 当一个聚合体(如数组或者结构体等)作为一个参数传递时,用于对齐填充的位,以及超过聚合体的末尾且其大小不能被 XLEN 整除的位,其行为是未定义的,由编译器实现。
  • 由栈传递的聚合体或是整数会对齐到类型对齐要求或是 XLEN 中较大的一个,但是不会超过栈的对齐要求。
  • 位字段以小端序存储。如果某个位字段超出了其整数类型的边界,将填充空余的位到下一个对齐边界。

位字段:是数据结构中的一部分,它允许开发者指定特定的位数来存储数据,通常用于优化内存使用。例如,如果一个变量只需要几个比特(如只需要4位来表示一个状态),就可以通过位字段将多个值打包在一起,节省内存空间。如:

struct {     
	unsigned int a : 3;  // 3 bits for 'a'     
	unsigned int b : 5;  // 5 bits for 'b' 
};  // `a`占用3位,`b`占用5位,总共是8位。

小端序存储:低位字节存储在内存的低地址端,高位字节存储在高地址端。如果一个16位的值 0x1234 需要存储在内存中:将 0x34 存储在较低的内存地址,而 0x12 存储在较高的内存地址。则内存顺序为:0x34 0x12(说明内存从低往高) 填充对齐边界:如上述例子,若存储在一个32位的unsigned int类型中,剩下的24位内存位置会被填充为“空位”(通常是0),直到下一个对齐边界

  • 可变参数的传递方式和命名参数相同,但是由一个例外:以 2*XLEN 位对齐且大小最多为 2*XLEN 位的可变参数通过一对参数寄存器传递,或者在没有寄存器可用的情况下通过栈传递。当一个可变参数被通过栈传递后,所有后续的参数也都会通过栈传递(例如,最后一个参数寄存器可能由于对齐原因未被使用)
  • 返回值的传递方式和传递第一个命名参数的方式相同。如果这样的参数是以引用方式传递的,则由调用者为返回值分配内存(指:将其内存地址传递给函数返回),并将其地址作为隐式的第一个参数传递。
  • 栈向下增长(向低地址方向),栈顶指针在过程(函数)入口应当对齐到 128 位(16字节)边界。

2、控制寄存器

  • 标准 RISC-V 指令集使用 12 位编码空间来编码控制寄存器(CSR),使得指令集最多支持 4096 个控制寄存器。控制寄存器只有特殊的指令才可以读写,这些指令都是原子指令。
  • csrrw dst, csr, src
    先将 csr 的值写入 dst,并将 src 的值写入 csr
  • csrr dst, csr
    将 csr 的值读到 dst
  • csrw csr, src
    将 src 的值写入 csr
  • csrc(i) csr, rs1
    将 csr 中指定的位清零,csrc 使用通用寄存器作为 mask,csrci 则使用立即数。
  • csrs(i) csr, rs1
    将 csr 中指定的位置 1,csrc 使用通用寄存器作为 mask,csrci 则使用立即数。

3 中断机制

1、微观上,我们使用异常来表示当前硬件线程中执行代码发生的特殊情况,而中断则表示一个外部的异步事件,这个事件可能引起控制转移,通常与当前执行代码无关。宏观上,我们将其统称为中断。

2、mstatus 全局中断使能

在这里插入图片描述

  • mstatus 寄存器记录了当前硬件线程的状态,由多个状态位组成
  • 在 S-Mode 下以 sstatus 被访问
  • 在 U-Mode 下以 ustatus 被访问
  • 是同一个寄存器的不同视图,高权限级的视图可以访问更多的状态位,低权限级低视图只能访问受限的位。
  • MIE、SIE 和 UIE,分别是不同权限等级的全局中断使能位。
  • (w < x < y)如果 xIE=1,低特权级 w 的中断会被全局禁用,无论是否设置 wIE 位(优先高特权级)。且高特权级 y 的终端会被全局启用,无论是否设置 yIE。(所以高权限级代码可以使用单独的中断启用位来禁用选定的高权限模式中断,然后将控制权让与低权限模式)
  • xPIE 存储了中断处理发生前的中断使能位的值,即进入该模式前的中断使能值
  • xPP 存储了上一个特权模式,比如特权模式 y 进入 x,则 xPP = y。所以 MPP 是两位宽表示进入机器模式之前的特权级别(存储监管者和用户),SPP 是一位宽(用户)
  • 嵌套中断处理:处理中断时来一个更高优先级的中断。所以需要两套中断使能位才能正确恢复。

当发生中断时,系统会根据当前的特权模式 y(如监管模式)进入另一个特权模式 x(如机器模式)来处理该中断。

  • xPIE 被设置为 xIE 的值:进入中断处理时,系统会将当前中断使能位 xIE 的值(表示当前中断使能的状态)保存到 xPIE 中。
  • xIE 被设置为 0:然后,系统会将 xIE 设置为 0,禁用中断,防止发生其他中断,确保当前中断能够被完全处理。
  • xPP 被设置为 y:接着,系统会将 xPP 设置为当前的特权模式 y,表示在进入 x 模式之前,系统处于 y 模式。这允许中断处理完后可以恢复到 y 模式。

中断处理完成后恢复:当中断处理完成时,系统会使用保存的 xPIExPP 的值来恢复:

  • 恢复中断使能位:恢复中断使能位 xIExPIE,恢复进入该模式之前的中断使能状态。
  • 恢复特权模式:恢复特权模式为 xPP,即返回到上一个特权模式(y 模式)。
  • 指令 MRETSRET 和 URET 分别用于从 M-Mode、S-Mode 和 U-Mode 的中断处理中返回。
  • xPP 字段是 WARL(Write any values, read legal values)字段,只能保存低于或等于 x 的特权模式,如果系统没有实现 x 模式,xPP 必须被硬连接到 0。

2、具体类型中断使能 alt text alt text

  • mip 存储了未决中断(pending interrupts)的相关信息(未决中断是已经发生、但没有被处理的中断)
  • mie 存储了具体类型的中断使能信息(指示特定中断是否被允许发生)
  • MTIP、STIP、UTIP 位分别用于标识特定模式(M-Mode、S-Mode、U-Mode)下是否发生时钟中断的标志位。MTIP 位是只读的,只能通过写入内存映射的 M-Mode 时钟比较寄存器来清除。UTIP 和 STIP 位可以被 M-Mode 代码修改,用来设置低权限级的时钟中断
  • 不同权限模式的多个并发中断按照权限模式递减的顺序进行处理,同一个特权模式下的多个并发中断按照以下优先级递减顺序处理:MEI、MSI、MTI、SEI、SSI、STI、UEI、USI、UTI。(EI-外部中断,SI-软件中断,TI-定时器中断)
  • 异步中断:是由外部事件触发的中断
  • 同步异常:是由程序内部状态或执行错误触发的异常
  • 同步异常的优先级低于所有异步中断。

3、中断处理程序

  • mtvec 寄存器配置控制转移过程(中断函数入口),该寄存器由一个向量基地址(BASE)和向量模式(MODE)组成

![[Pasted image 20250223111340.png]] 4、中断信息

  • mcause(Machine Cause Register)当控制转移到 M-Mode 中时,寄存器会被自动填入代表中断类型的代码

alt text

  • 同步异常优先级:下面的优先级表决定了 mcause 展示哪个异常

alt text

  • mepc(Machine Exception Program Counter) 寄存器会被自动填入被异步中断打断的指令地址或者导致同步异常的指令地址(中断返回地址)
  • mtval(Machine Trap Value)当控制转移到 M-Mode 中时,寄存器会被自动填入 0 或是异常特有的信息,来辅助处理程序处理。
    • 硬件断点:当程序执行时遇到预设的硬件断点时,mtval 会存储故障时的虚拟地址。
    • 地址未对齐:当发生地址未对齐异常(例如对不满足对齐要求的内存地址进行 Load 或 Store 操作时),mtval 会存储出错的虚拟地址。
    • 访问故障(Access Fault):当访问的地址无效(例如非法地址或不允许访问的内存区域),mtval 会存储故障的虚拟地址。
    • 页故障(Page Fault):当发生页故障时(例如试图访问未映射的页或页权限不足),mtval 存储的是导致页故障的虚拟地址

5、中断相关指令

  • ECALL 指令用于向支持执行环境发出请求。在 U-Mode、S-Mode 和 M-Mode 下执行该指令,会分别生成 environment-call-from-U-mode 异常、environment-call-from-S-mode 异常和 environment-call-from-M-mode 异常。(ECALL 指令会导致被请求的特权模式的 epc 寄存器被设置为 ECALL 指令本身的地址,而不是下一条指令的地址)
  • 中断返回:每个特权模式都各有中断处理返回指令:MRET、SRET 和 URET。除了 mstatus 一节中描述都操作以外,该条指令还会将 pc 寄存器设置为 xepc 的值。
  • 等待中断:WFI 指令会暂停当前硬件线程,直到一个异步中断到来

4 内存管理

1、satp(Supervisor Address Translation and Protection)寄存器,控制了 S-Mode 下的地址翻译和保护。

alt text

  • 该寄存器的 PPN 字段(物理页号)保存着根页表的物理页号;
  • ASID 字段(地址空间标识符)是可选的,用来降低上下文切换的开销,在实现进程时需要使用该字段;
  • MODE 字段用来开启分页并选择分页系统。[[Pasted image 20250223124745.png]]
MODE 名称 描述
0 Bare 不开启分页
1 Sv32 基于页的32位虚拟地址系统
8 Sv39 基于页的39位虚拟地址系统
9 Sv48 基于页的48位虚拟地址系统
  • 对于 RV32 来说,开启分页的模式只有 Sv32。而 RV64 可以选用 Sv39 和 Sv48
  • 在 Sv39 中,物理地址有 56 位,而虚拟地址有 64 位。虽然虚拟地址有这么多位,但是其中只有低 39 位有效,第 63 ~ 39 位的值必须等于第 38 位。

alt text

  • 页表项 PTE(Page table entry)

alt text - V 为 Valid,表示这个页表项是否生效。 - X、W、R 分别是 Execute、Writable 和 Readable,表示这个页表项所代表的物理页是否可执行、可写或可读,如果这三个位都置为 0,表示这个页表项并不是指向最终的物理页,而是指向下一级页表。 - U 为 User,表示 U-Mode 下的程序是否可以通过该页表项进行地址映射。如果 U 置为 0,那么 U-Mode 下的程序无法使用该页表项。注意,如果 U 置为 1 时,只有 sstatus 寄存器的 SUM 位也置为 1,S-Mode 下的程序才可以访问该页表项,否则访问会出现错误。 - G 为 Global,表示该页表项在所有地址空间都有效,通常我们不使用这个标记。 - A 为 Accessed,表示自从上次该位被置 0 后,是否有程序访问这个页表项(访问包括读、写或取指)。 - D 为 Dirty,用于在虚拟内存置换时标记脏页。 - RSW 位暂时保留,留给 S-Mode 程序使用。

  • 三级页表和二级页表的每个页表项的 X、W 和 R 位都要置为 0,表示该条页表项指向下一级页表,而非最终的物理页。
  • 在修改 satp 寄存器后,需要通过执行 SFENCE.VMA 指令来刷新相关的地址翻译缓存(如 TLB)中的值以防止出现未定义行为。

0.2 RISV-V汇编

1 RISC-V汇编

1、RISC-V寄存器:

  • 以 t 开头的寄存器表示临时寄存器,可以被用于任何用途;
  • 以 a 开头的寄存器用于向函数传递参数;
  • 以 s 开头的寄存器(sp 除外)将在过程调用过程中不被修改。

2、RISC-V 指令集包含了整数指令、逻辑指令以及一些内存指令

指令示例 描述
lb t0, 8(sp) 将内存地址 sp+8 处的值Jokerix加载Jokerix(解引用)到寄存器 t0 中。lb 加载一个字节,Jokerixlh 加载半字,lw 加载一个字,ld 加载双字Jokerix
sb t0, 8(sp) 将寄存器 t0 的值Jokerix存储Jokerix(解引用)到内存地址 sp + 8 处,sb 存储一个字节,sh 存储半字,sw 存储一个字,sd 存储双字
add a0, t0, t1 将 t0 和 t1 的值相加,结果存储在 a0 中
addi a0, t0, -10 将 t0 的值与 -10 相加,结果存储在 a0 中
(Jokerix用于寄存器和立即数相加Jokerix,i-immediate)
sub a0, t0, t1 t0 的值减去 t1 的值,结果存储在 a0 中
mul a0, t0, t1 将 t0 的值与 t1 的值相乘,结果存储在 a0 中
div a1, s3, t3 s3 的值除以 t3 的值,Jokerix结果Jokerix存储在 a1 中
rem a1, s3, t3 s3 的值除以 t3 的值,Jokerix余数Jokerix存储在 a1 中
and a3, t3, s3 将 t3 和 s3 的值做与运算,结果存储在 a3 中
or a3, t3, s3 将 t3 和 s3 的值做或运算,结果存储在 a3 中
xor a3, t3, s3 将 t3 和 s3 的值做异或运算,结果存储在 a3 中
3、伪指令:由汇编器转换为真正存在的指令

alt text alt text

4、分支指令,有三个参数:要比较的两个操作数(寄存器),如果比较为真,则是要执行的指令的内存标签

  • beq - Branch if Equal 如果两个寄存器的值Jokerix相等Jokerix,则跳转。
  • bne - Branch if Not Equal 如果两个寄存器的值Jokerix不相等Jokerix,则跳转。
  • bgt - Branch if Greater Than 如果第一个寄存器的值Jokerix大于Jokerix第二个寄存器的值,则跳转。
  • bge - Branch if Greater or Equal 如果第一个寄存器的值Jokerix大于等于Jokerix第二个寄存器的值,则跳转。
  • blt - Branch if Less Than 如果第一个寄存器的值Jokerix小于Jokerix第二个寄存器的值,则跳转。
  • ble - Branch if Less or Equal 如果第一个寄存器的值Jokerix小于等于Jokerix第二个寄存器的值,则跳转。
例:
bge t0, t2, loop_end  // t0 >= t2 则跳转到 loop_end

5、栈

  • 从栈上分配空间,我们需要将 sp 减去一个值。
  • 从栈上回收空间,我们需要将 sp 加上一个值。
  • 注意我们没有“清理”栈空间,这就是为什么 JokerixC 语言中不能直接使用未初始化的局部变量Jokerix。
  • Jokerix栈必须对齐到 8 字节边界,这意味着我们只能分配或回收 8 字节倍数的内存Jokerix。
addi    sp, sp, -8   // 分配栈空间
sd      ra, 0(sp)    // 保存返回地址(因为printf函数可能修改ra)
call    printf       // 调用函数
ld      ra, 0(sp)    // 加载保存的返回地址
addi    sp, sp, 8    // 释放栈空间
ret     // 此时可以用原来的ra,来回到调用当前函数(整段代码)的地址

6、c 语言到汇编的转换

  • 编译器的工作是将 .c 文件转换成汇编文件
  • 汇编器则是将汇编文件作为目标文件汇编成机器码
  • 链接器就会将所有的目标文件链接到一个可执行文件

7、函数汇编:应用程序二进制接口(ABI)指定哪些寄存器获得哪些参数,以及如何来回返回内容,所有的函数包含两部分:

  • 开场白(Prologue),为本地存储设置一个栈帧
  • 结语(Epilogue),通常需要加载保存的寄存器和返回地址,并在返回之前移动栈指针
my_function:
    # Prologue
    addi    sp, sp, -32  // 分配32字节栈空间,
    sd      ra, 0(sp)    // 存储调用者的寄存器值
    sd      a0, 8(sp)
    sd      s0, 16(sp)
    sd      s1, 24(sp)

    # Epilogue
    ld      ra, 0(sp)    // 还原调用者寄存器
    ld      a0, 8(sp)
    ld      s0, 16(sp)
    ld      s1, 24(sp)
    addi    sp, sp, 32
    ret

8、调用 printf 函数:任何时候看到函数调用,都应该考虑保存返回地址寄存器

.section .rodata
prompt: .asciz "Value of t0 = %ld and value of t1 = %ld\n"
.section .text
myfunc:
	// 分配栈空间存放返回地址
    addi    sp, sp, -8   // 分配栈空间
    sd      ra, 0(sp)    // 存放返回地址
    // 传递参数、调用
    la      a0, prompt   // 将 printf 的第一个参数(字符串)放入 a0 
    mv      a1, t0       // 将想输出的 t0 放入 a1
    mv      a2, t1       // 将想输出的 t1 放入 a2
    call    printf
    // 还原返回地址,为了让 myfunc() 函数可以正确返回
	ld      ra, 0(sp)    // 加载保存的返回地址
	addi    sp, sp, 8    // 释放栈空间
	ret     // 此时可以用原来的ra,来回到调用当前函数(整段代码)的地址

9、应用程序二进制接口(ABI,Application Binary Interface)

  • 8 个参数寄存器 a0 到 a7,这将是传递给函数的 8 个非浮点数参数。参数可能是指针,这种情况下 aX 将包含一个内存地址,或者直接传递数值,这时 aX 将包含实际的值。
  • ABI 进一步要求,我们必须通过 Jokerixa0 返回一个整数值作为返回值Jokerix。

2 C语言内联汇编

1、基本内联,格式为:asm("汇编代码")

asm ("movl %ecx %eax");  /* 将 ecx 寄存器的内容移至 eax */
asm ("movb %bh (%eax)"); /* 将 bh 的一个字节数据 移至 eax 寄存器指向的内存 */
  • asm 和 __asm__ 这两个关键字都是有效的,设立两个关键字主要是为了防止标识符冲突。如果汇编指令多于一条,可以每个指令一行,并用双Jokerix引号圈起Jokerix,同时为Jokerix每条指令添加 '\n' 和 '\t' 后缀Jokerix。gcc 会通过使用换行符或制表符发送正确格式化后的行给汇编器。
asm ("movl %eax, %ebx\n\t"
     "movl $56, %esi\n\t"
     "movl %ecx, $label(%edx,%ebx,$4)\n\t"
     "movb %ah, (%ebx)");

2、拓展汇编,可以同时指定操作数,可以指定Jokerix输入寄存器、输出寄存器以及修饰寄存器列表Jokerix。扩展汇编基本格式为:

asm ( 汇编语句
: 输出操作数 /* 可选的 */
: 输入操作数 /* 可选的 */
: 修饰寄存器列表(会被修改的寄存器) /* 可选的 */
);
  • 输出寄存器:表示嵌入汇编执行完后,哪些寄存器用于Jokerix存放输出数据Jokerix
  • 输入寄存器:表示开始执行汇编代码时,指定的一些寄存器Jokerix存放输入值Jokerix
  • 修饰寄存器列表:表示已经对列出的寄存器Jokerix值进行改动Jokerix,gcc编译器不能再依赖于原先这些寄存器所加载的值。(不用在这个列表里列出输入、输出寄存器,因为它们被显式地指定了约束,GCC 可以推断 asm 使用了它们。 如果指令隐式或显式地使用了任何除此之外的其他寄存器,那么就需要在修饰寄存器列表中指定这些寄存器)
asm ("cld\n\t"
     "rep\n\t"
     "stosl"
    :                                       /* 无输出 */
    :"c"(count), "a"(fill_value), "D"(dest) /* 输入列表 */
    :"%ecx", "%edi"                         /* 修饰寄存器列表 */
    );
  • 以上的内联汇编是将 fill_value 的值连续 count 次拷贝到寄存器 edi 所指位置(每执行 stosl 一次,寄存器eax值会拷贝到寄存器 edi指向的位置,计数值ecx递减;edi 的值会递增或递减,这取决于是否设置了 direction 标志,因此以上代码实则Jokerix初始化一个内存块Jokerix)。 它也告诉 GCC 寄存器 ecx 和 edi 一直无效。
  • 如果指令会修改Jokerix条件码寄存器Jokerix(Condition Code Register)(又称状态寄存器或标志寄存器),则必须将 %cc 添加进修饰寄存器列表。
  • 如果我们的指令以不可预测的方式修改了内存,那么需要将 memory 添加进修饰寄存器列表。 这可以使 GCC 不会在汇编指令间保持缓存于寄存器的内存值。如果被影响的内存不在汇编的输入或输出列表中,我们也必须添加 volatile 关键词。

3、volatile:如果汇编语句必须Jokerix在放置它的地方执行Jokerix(例如,不能为了优化而被移出循环语句),使用volatile 以避免编译器不可预知的优化,防止它被移动、删除或者其他操作。内核源码经常会有这种写法。

asm volatile ( 汇编程序模板
             : 输出操作数     /* 可选的 */
             : 输入操作数     /* 可选的 */
             : 修饰寄存器列表  /* 可选的 */
             );

4、常用约束

  • 寄存器操作数约束r:变量 myval 保存在寄存器中,寄存器 eax 的值被复制到该寄存器中,然后 myval 的值从寄存器更新到了内存。
r Register(s)
a %eax %ax %al
b %ebx %bx %bl
c %ecx %cx %cl
d %edx %dx %dl
S %esi %si
D %edx %di
asm ("movl %%eax, %0\n" :"=r"(myval));
// 将寄存器eax保存在任一寄存器r中,再将该寄存器值输出到myval
  • 内存操作数约束m:约束允许一个内存操作数,可以使用机器普遍支持的任一种地址。
asm ("sidt %0\n" : :"m"(loc));  // 这里 `loc` 是一个变量(通常是一个内存缓冲区),其数据类型是指向某个内存区域的指针,用来存储 `sidt` 的结果
// loc是一个内存操作数,slit将中断描述符表的基址存储到loc这个内存地址上
  • 匹配(数字)约束:在某些情况下,一个变量可能既充当输入操作数,又充当输出操作数。
asm ("incl %0" :"=a"(var):"0"(var)); //  `0` 用于指定与第 `0` 个输出变量相同的约束
// 寄存器 `%eax` 既用作输入变量,也用作输出变量。
// `var` 输入被读进 `%eax`,并且等递增后更新的 `%eax` 再次被存储进 `var`
  • 其他通用约束:

alt text

- `o` 约束:允许一个内存操作数,但只有当地址是可偏移的时。即,该地址加上一个小的偏移量可以得到一个有效地址
- `V` 约束:一个不允许偏移的内存操作数。换言之,任何适合 "m" 约束而不适合 "o" 约束的操作数
- `i` 约束:允许一个(带有常量)的立即整形操作数,这包括其值仅在汇编时期知道的符号常量
- `n` 约束:允许一个带有已知数字的立即整形操作数。许多系统不支持汇编时期的常量,因为操作数少于一个字宽。对于此种操作数,约束应该使用 `n` 而不是 `i`
- `g` 约束:允许任一寄存器、内存或者立即整形操作数,不包括通用寄存器之外的寄存器
  • 约束修饰符:
    • = 约束修饰符:意味着对于这条指令,操作数为只写的,旧值会被忽略并被输出数据所替换。(即意味着这是一个输出寄存器)
    • & 约束修饰符

0.3 SBI规范

本章将对 SBI(Supervisor Binary Interface,监管者模式二进制接口)规范进行简要介绍。图文主要来自 RISC-V 官方的 (SBI 文档)[https://github.com/riscv/riscv-sbi-doc]。

RISC-V SBI 规范描述了 RISC-V 监管者模式二进制接口(SBI)的实现规范。SBI 通过定义平台(或虚拟机管理器)特定功能的抽象,Jokerix来允许监管者模式(S-Mode 或 VS-Mode)软件可以在所有 RISC-V 平台进行移植Jokerix。SBI 的设计遵循了RISC-V 的设计哲学,拥有一个小的核心和一组可选的模块扩展。

  • (Jokerix理解Jokerix:应该是设计监管者模式和 RISC-V 底层核心机器模式进行交互的接口,需要遵循一些规范,使得监管者模式软件能够在 所有 RISC-V 平台 上运行)
  • SBI 的主要目的是为监管者模式软件提供一个统一的接口,这样上层的软件(如操作系统、虚拟机监控器等)可以在不同的硬件平台上执行。通过这种方式,SBI 能够解决不同 RISC-V 平台在硬件实现上的差异。

1、二进制编码

  • SBI 调用与标准 RISC-V 函数调用完全相同,除了:
    • 使用 ECALL 指令作为控制流程转移指令,而不是 CALL 指令。
    • a7 寄存器编码了 SBI 扩展 ID (EID),它与 Linux 系统调用 ABI 中系统调用号的编码方式相匹配。
  • JokerixSBI 函数必须返回一对值Jokerix,存储在 a0 和 a1 寄存器中,其中 a0 是一个错误代码。这类似于 C 结构体:
struct sbiret {
    long error;
    long value;
};
  • SBI 错误代码编码如下:
错误类型
SBI_SUCCESS 0
SBI_ERR_FAILED -1
SBI_ERR_NOT_SUPPORTED -2
SBI_ERR_INVALID_PARAM -3
SBI_ERR_DENIED -4
SBI_ERR_INVALID_ADDRESS -5
SBI_ERR_ALREADY_AVAILABLE -6

不带 H 扩展的 RISC-V 系统 alt text

带 H 扩展的 RISC-V 系统 alt text

0.4 GDB调试

GDB,即 GNU Project Debugger,允许你在程序运行时检查内存和寄存器中的内容。JokerixLinux开发调试工具Jokerix

1 GNU调试

  • 由于我们的操作系统是运行在虚拟机中的,所以调试的方式与调试普通程序有略微不同。我们需要借助远程调试的方式,将虚拟机当作远程机器,用本地 gdb 进行连接。
  1. 运行提前在 Makefile 中写入代码来启动qemu
make qemu-gdb
  1. qemu会暂停启动,等待 gdb 连接。这时可以开启另一个终端,启动一个 gdb 来连接。
$ gdb-multiarch kernel/Kernel
(gdb) set architecture riscv:rv64
(gdb) target remote 127.0.0.1:26000   // 手动链接本机qemuu
// 26000 是调试端口号,在 `make qemu-gdb` 日志的最下方可以找到该端口号
  • 注:xv6直接把第二步几条指令写入.gdbinit文件中,运行gdb-multiarch -x .gdbinit即可

Jokerix注意Jokerix:遇到报错qemu-system-riscv64: -gdb tcp::26000: Failed to find an available port: Address already in use

  1. 使用 netstatlsof 命令查看端口 26000 是否被占用:lsof -i :26000
  2. 如果有其他进程占用了该端口,可以尝试终止该进程:kill <PID>

官方文档:Top (Debugging with GDB) (sourceware.org)

2 断点相关命令

1、break:创建断点

break [函数名]        例    b main
break [文件名]:[行号]  例   b main.c:4
break *[地址]         例   b *0xffffffff80202bd0

2、info breakpoints:查看断点信息

info breakpoints        例  info b
info breakpoints [序号]  例  info b 2

3、delete:删除断点

delete          // 删除所有断点
delete [序号]    例  d 2

4、continue:继续运行程序直到遇到断点或错误

continue         例  c 

5、step:从一个函数跳进另一个函数,即持续运行Jokerix直到当前函数运行结束或当前函数调用别的函数Jokerix。

step             例  s

6、stepi:执行一条机器指令并暂停,这个命令会执行一条机器指令。当指令包含一个函数调用时,命令就会进入被调用的函数。对于多线程应用,stepi 命令会让当前线程执行一条指令,同时挂起其他线程。

stepi             例  si

7、next:单步执行程序,但如果遇到函数调用时,不会进入该函数。

next              例  n

3 输出信息相关指令

1、x:用指定格式输出制定内存地址处的内容。

x/[格式控制] [地址表达式]           例  x/wx 0x80000000
x [地址表达式]                     例  x 0x80000000
x/[长度][格式控制] [地址表达式]
  • o:八进制
  • x:十六进制
  • u:无符号十进制
  • t:二进制
  • f:浮点类型
  • a:地址类型
  • c:字符类型
  • s:字符串类型

2、print:输出给定表达式的值

Print [表达式]
例:
	print i
	print $pc
	print *0x80000000

3、info address:输出给定符号(变量或函数)的地址

info address [符号名]   例  info address main

4、info registers:输出所有寄存器的内容

info reg / i r / info registers       // 输出除控制寄存器CSR和浮点寄存器以外的所有寄存器
info all-registers / i all-registers  // 输出所有寄存器
info registers [寄存器名称]            // 输出指定寄存器

4 GUI客户端——cgdb

alt text

  • 左侧的窗口被称为代码窗口,右侧为 gdb 窗口。
  • 打开 cgdb 时,默认两个窗口是上下分隔的,可以通过 ctrl+w切换成左右分隔模式
  • 由于默认的 gdb 只能调试 x86 程序,所以我们需要在启动时附带参数来改变默认的 gdb
$ cgdb -d gdb-multiarch kernel/Kernel
  • 按 esc 键可以将焦点从 gdb 窗口转移到代码窗口
  • 在代码窗口可以上下翻看源码,空格键可以在焦点行设置一个断点
  • 按 i 键可以将焦点从代码窗口转移到 gdb 窗口

Jokerix补充Jokerix

B站视频:Linux 使用gdb调试入门。哔哩哔哩_bilibili

本地直接进行调试,直接通过

gcc -g test.c -o test.out     // -g 生成含debug信息的可执行文件
gdb ./test.out

xv6:Debug XV6_哔哩哔哩_bilibili

操作系统是运行在虚拟机中的,所以调试的方式与调试普通程序有略微不同。需要借助远程调试的方式,将虚拟机当作远程机器,用本地 gdb 进行连接。

// 终端1
make qemu-gdb CPUS=1
// 终端2
gdb-multiarch -x .gdbinit
layout src    // 边显示源码边调试

0.5 Jokerix 体系结构

alt text

Jokerix 操作系统由两个部分组成:监管者模式部分和用户模式部分。

  • 监管者模式部分即Jokerix操作系统内核Jokerix,用于对硬件资源进行抽象和调度访问,向下沟通位于机器模式的 SBI,向上应答用户模式的服务请求。
  • 用户模式部分,即Jokerix操作系统服务Jokerix,目前主要实现的是内核编程接口,用户编写的程序不会直接向监管者模式请求服务,而是通过调用内核编程接口函数,由这些函数代为请求。

1 体系结构

Jokerix 整体采用了宏内核模式,宏内核的优点是执行速度快,缺点则是层次结构性不强(内核代码维护性弱)。可以将其大概划分为以下四个模块:中断处理模块、内存管理模块、进程调度模块和文件系统模块。

  • Jokerix中断处理模块Jokerix:用于控制操作系统对内外部中断的响应。操作系统通过响应时钟中断,来定时检查进程的运行状态,可以说中断处理是进程调度的基础。
  • Jokerix内存管理模块Jokerix:主要通过虚拟内存管理的方式,保证各个进程能够安全共享物理内存,互不干扰。同时,由于各个进程都运行在独立的虚拟内存空间,使得各个程序实现时不必考虑实际的物理内存状态,降低了程序实现的难度。
  • Jokerix进程调度模块Jokerix:用来控制进程对 CPU 的使用,通过 Round-Robin 算法,来保证各个进程能够公平地使用 CPU 资源,同时内核也能够及时地响应外部中断。
  • Jokerix文件系统模块Jokerix:屏蔽了不同文件系统的细节,提供了一个通用的文件接口。

2 中断处理模块

1、内部中断

  • 异常(Exception):在执行一条指令时发生错误,这个错误是由这个指令本身引起的,这时就需要进入中断来处理错误。有些错误是可以恢复的,例如缺页异常,中断处理程序将需要的页面调入内存后再次执行导致异常的指令即可。而有些异常不可恢复,如除零异常,遇到这种异常中断处理程序会直接终止当前程序。
  • 陷阱(Trap):陷阱是由程序主动引发的中断,程序通过陷阱机制可以主动向操作系统请求服务。常见的有通过 ecall 指令进行系统调用,或者通过 ebreak 指令进入断点。

2、外部中断(Interrupt):狭义的中断专指外部中断,这类中断和当前运行的程序无关,是由外部设备引发的。例如时钟中断或外部设备发送数据等。 3、中断处理模块最主要的用途,就是通过处理时钟中断,来暂时打断当前进程以进行进程调度,同时通过处理陷阱,来处理来自 U-Mode 下应用程序发起的环境调用。

  • RISC-V 已经规定了中断发生时的 scause 寄存器被设置的值,操作系统可以根据这个寄存器中的值来判断中断类型。
  • Jokerix 采取 Direct 模式处理中断,这意味着无论发生什么中断,Jokerix控制都会跳转到同一个位置Jokerix。

3 内存管理模块

1、Jokerix 操作系统目前只能运行在 QEMU 模拟的 virt 计算机上,并且以 OpenSBI 作为 SBI

  • 0x00000000 ~ 0x80000000:各种设备内存映射
  • 0x80000000 ~ 0x80200000:OpenSBI 占据
  • 0x80200000 ~ kernel_end:Jokerix内核空间,kernel/kernel.ld 链接脚本定义了一个全局符号 kernel_end,标识了内核的结束位置。
  • kernel_end ~ 0x88000000:可以任意使用的物理内存
  • 0x80000000 ~ 0x88000000:这 128 MB 内存是可供操作系统使用的物理内存空间)!

alt text

2、Jokerix 目前有两种内存分配方式:

  • 一种是在上面提到的空闲内存上按Jokerix页Jokerix(4 KB)进行分配
  • 一种是在内核Jokerix堆Jokerix(定义在 .bss 段上 8 MB 大小的字节数组)上进行动态内存分配

3、内核分页管理

  • Jokerix 采用 RISC-V 提供的 Sv39 分页系统,虚拟地址有效位是 39 位,这意味着虚拟内存空间共 2^39 字节(512 GB),远远大于可用的物理内存空间。具体可参考[[pre1-RISC-V 硬件机制#4 内存管理]]

alt text

4 进程调度模块

1、在 Jokerix 实现中采用了一个很简单的进程模型,一个进程中只包含一个线程,故进程调度等于线程调度。除去用于调度的 idle 线程外,最多可以支持 64 个线程同时存在。内核通过线程 id(pid)来标识每个线程。每个进程资源包括:

  • 一个线程
  • 一个页表
  • 一个打开的文件描述符表

2、调度:使用 Round-Robin 算法,将 CPU 运行时间划分为时间片,每个线程一次可以在一个时间片内运行。内核维护一个线程队列,当队首线程的时间片用完后,调度线程会将其移动到队尾,并让下一个线程开始占用 CPU 运行。 3、线程与进程

  • 线程结构体定义:
typedef struct {
    usize contextAddr;  /* 线程上下文存储的地址 */
    usize kstack;       /* 线程栈底地址 */
    Process process;    /* 所属进程 */
    int wait;           /* 等待该线程退出的线程的 Tid */
} Thread;
  • 进程看上去更像是线程的附属,维护一个线程的相关资源,进程结构体:
typedef struct {
    usize satp;         /* 页表寄存器 */
    File oFile[16];     /* 文件描述符 */
    uint8 fdOccupied[16];   /* 文件描述符是否被占用 */
} Process;
  • Jokerix上下文Jokerix:在一个线程运行时,CPU 中寄存器的值以及线程上的值,称为这个线程的上下文。在线程切换时,需要将上一个线程的上下文保存起来,以便在未来再次切换到这个线程时,能够继续之前的状态运行下去。在线程创建时,就会在线程栈上分配一个空间用于存放线程上下文,在未来线程切换时,上下文都会保存在线程栈上的同一位置。

4、线程状态:线程 Thread 结构体本身并不保存其状态,状态的维护由线程池来完成

alt text

/* 线程状态 */
typedef enum {
    Ready,
    Running,
    Sleeping,
    Exited
} Status;
  • 就绪状态(Ready):线程处于就绪队列中,等待被 CPU 调度执行
  • 运行状态(Running):线程此时正占有 CPU 运行
  • 睡眠状态(Sleeping):线程Jokerix等待某个条件满足Jokerix,此时线程不会被调度执行,直到条件满足后才会被加入就绪队列
  • 退出状态(Exited):线程已经完成了任务执行,但是Jokerix还没有回收资源Jokerix

5 文件系统模块

1、Jokerix 中采用的文件系统,是 SimpleFileSystem 的一个魔改版本。名称仍然延用 SimpleFS,SimpleFS 将磁盘分成了多个大小为 4096 字节的磁盘块:

alt text

  • 超级块(SuperBlock),它记录了整个文件系统的基本信息,例如总磁盘块数、未使用的磁盘块数、Freemap 块个数等。
  • 若干个 Freemap 块,它记录了整个文件系统中Jokerix磁盘块的占用情况Jokerix。Freemap 块使用一个 bit 表示一个块,0 为未被占用,1 为已被占用。通过 Freemap 块我们就可以快速找到空闲可用的磁盘块。
  • 根文件系统的 Inode 块,SimpleFS 也是以树状结构组织文件的,Root 即为 “/” 文件夹。
  • 其他文件的 Inode 或者数据块,这些块不会做特殊的排序等,查找文件需要从根目录开始查找。
  • 目前的文件系统实现是在链接时直接将文件系统镜像链接到 .data 段,在运行时文件系统就会和内核一起直接被加载进内存。

0.6 实验环境

实验所需的工具主要分为两个部分:交叉编译工具链和 QEMU 。

1 交叉编译工具链

交叉编译器(Cross compiler)是指一个在某个系统平台下可以Jokerix产生另一个系统平台的可执行文件Jokerix的编译器。交叉编译器在目标系统平台(开发出来的应用程序序所运行的平台)难以或不容易编译时非常有用。 主要包括gcc、ld、objdump 和 gdb 等

2 安装 QEMU

由于实际上我们并没有 RISC-V 架构的机器,即使真的编写出了什么操作系统或者程序,也没有硬件平台来运行。好在,随着 RISC-V 架构的流行,很多虚拟机开始支持Jokerix对 RISC-V 架构硬件的模拟Jokerix,如 Bochs 和 QEMU 等。这里我们选用 QEMU。

alt text


Profile picture

Written by JokerDebug who works at Southeast University, Nanjing, China You can follow me on Github