【一生一芯04】RTFSC:TRM和.S文件

2 篇文章 0 订阅
1 篇文章 0 订阅

1 运行时环境

应用程序的运行都需要运行时环境的支持; 另一方面, 只进行纯粹计算任务的程序在 TRM 上就可以运行, 更复杂的应用程序对运行时环境必定还有其它的需求: 例如你之前玩的超级玛丽需要和用户进行交互, 至少需要运行时环境提供输入输出的支持。要运行一个现代操作系统,还要在此基础上加入更高级的功能。

如果我们把这些需求都收集起来, 将它们抽象成统一的API提供给程序,这样我们就得到了一个可以支撑各种程序运行在各种架构上的库了!具体地,每个架构都按照它们的特性实现这组API;应用程序只需要直接调用这组API即可, 无需关心自己将来运行在哪个架构上。由于这组统一抽象的API代表了程序运行对计算机的需求,所以我们把这组API称为抽象计算机。

在PA实验中,AM负责提供程序运行的时环境。

AM = TRM + IOE + CTE + VME + MTE

  • TRM(Turing Machine) - 图灵机, 最简单的运行时环境, 为程序提供基本的计算能力
  • IOE(I/O Extension) - 输入输出扩展, 为程序提供输出输入的能力
  • CTE(Context Extension) - 上下文扩展, 为程序提供上下文管理的能力
  • VME(Virtual Memory Extension) - 虚存扩展, 为程序提供虚存管理的能力
  • MPE(Multi-Processor Extension) - 多处理器扩展, 为程序提供多处理器通信的能力 (MPE超出了ICS课程的范围, 在PA中不会涉及)

 (在NEMU中)实现硬件功能 -> (在AM中)提供运行时环境 -> (在APP层)运行程序
(在NEMU中)实现更强大的硬件功能 -> (在AM中)提供更丰富的运行时环境 -> (在APP层)运行更复杂的程序

在后续输入输出的实验中,就可以了解到软件(AM)和硬件(NEMU)是如何协作的,感受到抽象和软硬件协同的魅力。 

2 TRM

根据KISS法则, 我们先来考虑最简单的运行时环境是什么样的. 换句话说, 为了运行最简单的程序, 我们需要提供什么呢? 其实答案已经在PA1中了: 只要把程序放在正确的内存位置, 然后让PC指向第一条指令, 计算机就会自动执行这个程序, 永不停止.

TRM提供了最简单的运行时环境,因此首先从TRM入手对AM进行了解。 

在梳理代码的过程中,正赶上GPTchat爆红,因此尝试借助ai对代码进行深层次的解读。

2.1 trm.c

#include <am.h>
#include <nemu.h>

extern char _heap_start;
int main(const char *args);

Area heap = RANGE(&_heap_start, PMEM_END);
#ifndef MAINARGS
#define MAINARGS ""
#endif
static const char mainargs[] = MAINARGS;

void putch(char ch) {
  outb(SERIAL_PORT, ch);
}

void halt(int code) {
  nemu_trap(code);

  // should not reach here
  while (1);
}

void _trm_init() {
  int ret = main(mainargs);
  halt(ret);
}

Q:上面这段代码是做什么的

A:这段代码是一个 C 程序的主函数,它的作用是执行 main() 函数,并在函数结束后停止程序的运行。它包含了以下内容:

  • 引入了两个头文件 am.hnemu.h
  • 声明了一个 main() 函数,并定义了 _heap_start 变量和 Area 类型的变量 heap
  • 定义了一个名为 mainargs 的常量字符数组,用于存储 main() 函数的参数。
  • 定义了 putch() 函数,用于向串行端口发送一个字符。
  • 定义了 halt() 函数,用于停止程序的运行。
  • 定义了 _trm_init() 函数,用于调用 main() 函数,并在它结束后停止程序的运行。

Q:代码中的heap变量有什么用?

A:变量 heap 是一个 Area 类型的变量。Area 类型是一个自定义的数据结构,用于表示一个内存区域。它包含两个成员变量:startend,分别表示内存区域的起始地址和结束地址。

在这段代码中,heap 变量被初始化为从 _heap_start 变量所指向的地址开始,到内存结束地址 PMEM_END 的内存区域。这意味着 heap 变量表示了一个用于存储堆内存的区域。

在 C 语言中,堆内存是一种动态分配的内存,用于存储程序运行时产生的数据。它由程序在运行时调用 malloc() 等内存分配函数来分配和释放。heap 变量表示了程序可以使用的堆内存区域,它可以在程序中被用于指定堆内存的起始和结束地址。

例如,如果你想在程序中分配一块大小为 1000 字节的堆内存,你可以使用 heap 变量来实现,如下所示:

int main(const char *args) {
  // 使用 heap 变量中的起始地址和结束地址作为堆内存的起始和结束地址
  void *p = malloc_from_range(heap.start, heap.end, 1000);

Q:在这段代码中,mainargs是如何使用的?

A:在这段代码中,mainargs 常量字符数组用于存储 main() 函数的参数。它是通过 MAINARGS 宏来定义的,在这段代码中,它是一个空字符串。当程序运行时,mainargs 数组将作为 main() 函数的第一个参数,它可以在 main() 函数中被使用。

例如,如果你想让 main() 函数接受一个命令行参数,你可以通过定义 MAINARGS 宏来实现,如下所示:

#define MAINARGS "arg1 arg2 arg3"

在这种情况下,main() 函数将会接受三个命令行参数,即 arg1arg2arg3。它们可以在 main() 函数内部被使用,例如:

int main(const char *args) {
  // 获取第一个命令行参数
  char *arg1 = args[0];
  // 获取第二个命令行参数
  char *arg2 = args[1];
  // 获取第三个命令行参数
  char *arg3 = args[2];

  // 进行相关的处理
  ...
}

Q:关于这段代码,你觉得有什么很有价值的地方吗?

A:这段代码中的函数 _trm_init() 的实现非常有价值。它主要用于启动程序,并管理程序的生命周期。首先,它调用了 main() 函数,并将 mainargs 常量字符数组作为参数传入。这样,在 main() 函数中就可以使用命令行参数了。然后,它调用了 halt() 函数,并将 main() 函数的返回值作为参数传入。这样,在 main() 函数返回之后,程序的退出状态就可以被传递到 halt() 函数中,并被用于通知模拟器停止程序的运行。

另外,这段代码中的 putch() 函数也很有价值。它是一个低级的输出函数,用于将一个字符输出到指定的串口设备中。它接受一个字符参数 ch,表示要输出的字符。在实现中,它通过调用 outb() 函数将字符写入指定的串口设备中,从而实现了对字符的输出。

总的来说,这段代码实现了一个程序的启动和管理流程,通过 _trm_init() 函数管理程序的生命周期,并通过 putch() 函数实现了对字符的输出,这些都是非常有价值的。

Q:你觉得除了我上面问的问题,还有什么细节值得注意吗?

A:除了你上面问的问题之外,这段代码中还有一些值得注意的细节。

首先,这段代码中使用了宏定义 MAINARGS。它是一个可选的宏,用于指定 mainargs 常量字符数组的值。如果没有定义 MAINARGS 宏,则 mainargs 会被初始化为空字符串。这样,你就可以通过定义 MAINARGS 宏来指定 main() 函数的命令行参数。例如,如果你想指定命令行参数为 arg1 arg2,你可以在代码中定义如下:

#define MAINARGS "arg1 arg2"

另外,这段代码中的 heap 变量和 halt() 函数也有一些值得注意的细节。首先,heap 变量被定义为了一个静态变量,它只能在当前源文件中使用。这意味着,如果你想在其他源文件中访问 heap 变量,需要通过声明一个同名的外部变量来实现。例如,如果你想在 foo.c 文件中访问 heap 变量,你可以在 foo.c 文件中声明如下:

extern Area heap;

然后,在 foo.c 文件中就可以通过 heap 变量来访问 heap 变量了。

另外,在这段代码中,heap 变量的值是通过 RANGE() 宏来设置的。它的作用是创建一个新的内存区域。它接受两个参数:第一个参数表示内存区域的起始地址,第二个参数表示内存区域的结束地址。这样,通过 RANGE() 宏就可以将内存区域的起始和结束地址存储在 heap 变量中。例如,在这段代码中,heap 变量的值被设置为了从地址 &_heap_start 开始,到地址 PMEM_END 结束的内存区域。

halt() 函数也有一些值得注意的细节。它的作用是停止程序的运行,并向模拟器发送一个停止信号。它接受一个整型参数 code,表示程序的退出状态。通过调用 nemu_trap() 函数,可以将程序的退出状态发送到模拟器中。这样,模拟器就可以根据程序的退出状态来决定是否停止程序的运行。总的来说,这段代码中包含了一些有用的细节,例如命令行参数的使用、内存区域的创建、程序的退出

 AI对代码理解之深刻超出了我的想象,后续的RTFSC感觉完全可以借助AI实现。

2.2 start.s

.section entry, "ax" //指定了代码的新部分的开始,其名称为 entry,属性为 "ax"
.globl _start //定义了全局符号符号 _start
.type _start, @function //指定符号 _start 是一个函数

_start:
  mv s0, zero //将值(此处为 zero)移入寄存器 s0
  la sp, _stack_pointer //将 _stack_pointer 符号的地址加载到寄存器 sp 中
  jal _trm_init //jal 指令跳转到函数 _trm_init

在trm.c中已经定义了trm_init函数,当执行到jal时就会跳转到trm.c中执行。

2.3 trap.s

#define concat_temp(x, y) x ## y
#define concat(x, y) concat_temp(x, y)
#define MAP(c, f) c(f)

#if __riscv_xlen == 32
#define LOAD  lw
#define STORE sw
#define XLEN  4
#else
#define LOAD  ld
#define STORE sd
#define XLEN  8
#endif

#define REGS(f) \
      f( 1)       f( 3) f( 4) f( 5) f( 6) f( 7) f( 8) f( 9) \
f(10) f(11) f(12) f(13) f(14) f(15) f(16) f(17) f(18) f(19) \
f(20) f(21) f(22) f(23) f(24) f(25) f(26) f(27) f(28) f(29) \
f(30) f(31)

#define PUSH(n) STORE concat(x, n), (n * XLEN)(sp);
#define POP(n)  LOAD  concat(x, n), (n * XLEN)(sp);

#define CONTEXT_SIZE  ((32 + 3 + 1) * XLEN)
#define OFFSET_SP     ( 2 * XLEN)
#define OFFSET_CAUSE  (32 * XLEN)
#define OFFSET_STATUS (33 * XLEN)
#define OFFSET_EPC    (34 * XLEN)

.align 3
.globl __am_asm_trap
__am_asm_trap:
  addi sp, sp, -CONTEXT_SIZE

  MAP(REGS, PUSH)

  csrr t0, mcause
  csrr t1, mstatus
  csrr t2, mepc

  STORE t0, OFFSET_CAUSE(sp)
  STORE t1, OFFSET_STATUS(sp)
  STORE t2, OFFSET_EPC(sp)

  # set mstatus.MPRV to pass difftest
  li a0, (1 << 17)
  or t1, t1, a0
  csrw mstatus, t1

  mv a0, sp
  jal __am_irq_handle

  LOAD t1, OFFSET_STATUS(sp)
  LOAD t2, OFFSET_EPC(sp)
  csrw mstatus, t1
  csrw mepc, t2

  MAP(REGS, POP)

  addi sp, sp, CONTEXT_SIZE
  mret

Q:请解释一下这段代码。

A:这段代码是 RISC-V 汇编语言,用于实现一个“中断处理函数”。它定义了一个名为 __am_asm_trap 的全局函数,并实现了对中断的响应和处理。代码通过调用函数 __am_irq_handle 来处理中断,并保存/恢复寄存器的值。另外,该函数还支持将 xlen 的值传递给宏定义,以便在 32 位和 64 位系统上都能正常工作。

Q:可以再详细点吗。

A:可以,那我来详细解释一下这段代码。

首先,该代码中使用了许多宏定义。这些宏定义用于实现代码的通用性,以便在不同的环境下运行。

例如,代码中的第一个宏定义:

#define concat_temp(x, y) x ## y

定义了一个名为 concat_temp 的宏,它可以将两个参数拼接在一起。例如,如果 x 的值为 "hello",y 的值为 "world",那么 concat_temp(x, y) 的值为 "helloworld"。

第二个宏定义:

#define concat(x, y) concat_temp(x, y)

定义了一个名为 concat 的宏,它可以将两个参数拼接在一起。例如,如果 x 的值为 "hello",y 的值为 "world",那么 concat(x, y) 的值也为 "helloworld"。

第三个宏定义:

#define MAP(c, f) c(f)

定义了一个名为 MAP 的宏,它可以对一个序列的每个元素执行一个操作。例如,如果序列为 [1, 2, 3],操作为乘以 2,那么 MAP(c, f) 的值为 [2, 4, 6]。

接下来是一些条件语句,用于根据 __riscv_xlen 的值来定义一些宏定义。

#if __riscv_xlen == 32
#define LOAD  lw
#define STORE sw
#define XLEN  4
#else
#define LOAD  ld
#define STORE sd
#define XLEN  8
#endif

如果 __riscv_xlen 的值等于 32,则定义了 LOAD、STORE 和 XLEN 三个宏。

LOAD 宏定义了用于从内存中加载值的指令。

STORE 宏定义了用于将值存储到内存中的指令。

XLEN 宏定义了系统支持的整型长度,单位为字节。

如果 __riscv_xlen 的值不等于 32,则定义了 LOAD、STORE 和 XLEN 三个宏,但它们的值与上述不同。例如,LOAD 宏定义为 ld,STORE 宏定义为 sd,XLEN 宏定义为 8。

接下来是另一个宏定义:

#define REGS(f) \
      f( 1)       f( 3) f( 4) f( 5) f( 6) f( 7) f( 8) f( 9) \
f(10) f(11) f(12) f(13) f(14) f(15) f(16) f(17) f(18) f(19) \
f(20) f(21) f(22) f(23) f(24) f(25) f(26) f(27) f(28) f(29) \
f(30) f(31)

宏定义 REGS 定义了一组寄存器的名称,用于遍历所有的寄存器。宏定义 REGS 中定义的寄存器的名称是以 x 开头的整数,例如 x1,x2,x4 等。RISCV中共有32个寄存器,而在此处只定义了30个,省略了x0,x2。这是因为在RISCV中,x0寄存器始终为0,不能被修改因此在这里没必要使用该寄存器;对于寄存器x2,它也不能被普通的指令更改,但是它可以被一些特殊的指令使用,例如跳转指令(jal)。因此,在该函数中没有必要使用寄存器 2。另外,如果在 REGS 宏定义中包含了所有的寄存器,那么在执行该函数时就需要分配更多的内存来保存所有的寄存器值,因此这会增加内存使用量和执行时间。考虑到寄存器 0 和 2 在该函数中没有任何作用,因此在 REGS 宏定义中没有定义这两个寄存器。

接下来是另外两个宏定义:

#define PUSH(n) STORE concat(x, n), (n * XLEN)(sp);
#define POP(n)  LOAD  concat(x, n), (n * XLEN)(sp);

PUSH 宏定义了一个用于将寄存器的值压入堆栈的操作。例如,如果定义为 STORE xn, (n * XLEN)(sp),则 PUSH(n) 的值为将寄存器 xn 中的值压入堆栈。

POP 宏定义了一个用于将寄存器的值从堆栈中弹出的操作。例如,如果定义为 LOAD xn, (n * XLEN)(sp),则 POP(n) 的值为将堆栈中的值弹出到寄存器 xn 中。

借助宏定义 REGS 对所有寄存器进行遍历,并对每个寄存器执行指定的操作。它可以让代码更简洁、可读性更高。例如,如果不使用宏定义 REGS,那么保存所有寄存器的值的代码可能长这样:

PUSH(x1)
PUSH(x3)
PUSH(x4)
...

使用宏定义 REGS 后,可以简化为一行代码:

MAP(REGS, PUSH)

接下来是一个函数定义:

.align 3
.globl __am_asm_trap
__am_asm_trap:

这个定义表示定义了一个名为 __am_asm_trap 的全局函数。该函数是一个中断处理函数,用于处理由外部设备发出的中断。

接下来是函数的主体部分:

首先分配一段内存空间用于保存寄存器的值。

  addi sp, sp, -CONTEXT_SIZE

它先将堆栈指针 sp 减去一个常量 CONTEXT_SIZE,然后将结果保存到堆栈指针 sp 中。这个常量 CONTEXT_SIZE 的值是用于保存寄存器值的内存空间的大小。

通过减少堆栈指针的值,就可以在堆栈的顶部分配一个新的内存空间,用于保存寄存器的值。这样,在函数处理异常或中断时,就可以暂时保存寄存器的值,而不会影响正常的程序流程。

 MAP(REGS, PUSH)

使用 MAP 宏定义,对 REGS 序列的每个元素执行 PUSH 操作,以便在处理中断之前将所有通用寄存器的值保存到堆栈中。

  csrr t0, mcause
  csrr t1, mstatus
  csrr t2, mepc

这段代码的作用是将当前的异常原因、状态和指令指针保存到临时寄存器中。

在 RISC-V 汇编代码中,csrr 指令用于读取或写入控制和状态寄存器(CSR)。CSR 是一类特殊的寄存器,它用于管理处理器的状态和控制处理器的执行。在这段代码中,csrr 指令用于读取三个 CSR:

  • mcause:该寄存器保存了当前异常或中断的原因。
  • mstatus:该寄存器保存了当前处理器的状态。
  • mepc:该寄存器保存了当前指令指针,即当前执行的指令的地址。

在这段代码中,csrr 指令会将每个 CSR 的值读取到一个临时寄存器中,即 t0t1t2。这样就可以在处理异常或中断时使用这些信息,而不会影响正常的程序流程。

  STORE t0, OFFSET_CAUSE(sp)
  STORE t1, OFFSET_STATUS(sp)
  STORE t2, OFFSET_EPC(sp)

这段代码与上一段代码的区别在于,上一段代码将异常原因、状态和指令指针保存到临时寄存器中,而这段代码将它们保存到堆栈中。

具体来说,STORE 指令用于将一个寄存器的值存储到内存中的指定地址。在这段代码中,它会将临时寄存器 t0t1t2 的值分别存储到堆栈的指定位置。这些位置的偏移量由宏定义 OFFSET_CAUSEOFFSET_STATUSOFFSET_EPC 指定。

  # set mstatus.MPRV to pass difftest
  li a0, (1 << 17)
  or t1, t1, a0
  csrw mstatus, t1

这段代码的作用是将 mstatus 寄存器的第 17 位(MPRV 标志位)置为 1。

在 RISC-V 架构中,mstatus 寄存器用于保存当前处理器的状态。它由一些标志位组成,每个标志位都表示一个特定的状态或功能。在这段代码中,它会将 mstatus 寄存器的第 17 位(MPRV 标志位)置为 1。

具体来说,MPRV 标志位表示被中断或异常处理程序是否有权访问本地存储器。如果将该标志位置为 1,则表示被中断或异常处理程序有权访问本地存储器。在这段代码中,它会使用 li 指令将数值 1 << 17 加载到寄存器 a0 中。接着,它会使用 or 指令将寄存器 a0 与寄存器 t1 的值进行按位或操作,并将结果保存到寄存器 t1 中。最后,它会使用 csrw 指令将寄存器 t1 的值写入 mstatus 寄存器。

这样一来,就可以将 mstatus 寄存器的 MPRV 标志位置为 1,以通过 difftest 的测试。difftest 是一个测试工具,它可以检查一个汇编程序是否正确。如果将 MPRV 标志位置为 1,则 difftest 就会认为该程序是正确的。

  mv a0, sp
  jal __am_irq_handle

这段代码的作用是调用 __am_irq_handle 函数,并将堆栈指针作为参数传递给该函数。

在 RISC-V 汇编代码中,mv 指令用于将一个寄存器的值拷贝到另一个寄存器中。在这段代码中,它会将堆栈指针的值拷贝到寄存器 a0 中。

接着,它会使用 jal 指令跳转到函数 __am_irq_handle,并将寄存器 a0 的值作为参数传递给该函数。jal 指令与普通的跳转指令不同,它会将当前指令指针保存到一个临时寄存器中,以便在函数调用结束后恢复正常的程序流程。

  LOAD t1, OFFSET_STATUS(sp)
  LOAD t2, OFFSET_EPC(sp)
  csrw mstatus, t1
  csrw mepc, t2

这段代码的作用是恢复 mstatusmepc 寄存器的原始值。

具体来说,在上一段代码中,__am_irq_handle 函数可能会更改 mstatusmepc 寄存器的值。例如,它可能会更改 mepc 寄存器的值,以指示异常处理程序结束后程序应该从哪个位置开始执行。这段代码的作用就是恢复 mstatusmepc 寄存器的原始值。

具体来说,它会使用 LOAD 指令将堆栈的指定位置的值加载到寄存器

  MAP(REGS, POP)

  addi sp, sp, CONTEXT_SIZE
  mret

使用 MAP 宏定义,对 REGS 序列的每个元素执行 POP 操作,将寄存器从堆栈中弹出,以便在处理完中断后恢复所有通用寄存器的值。将堆栈指针向上移动 CONTEXT_SIZE 个字节,以便释放为中断处理函数分配的空间。最后一步是执行 mret 指令,以便退出中断处理函数并恢复程序的执行。

总的来说,这段代码定义了一个中断处理函数,用于处理外部设备发出的中断。在处理中断之前,它会将所有通用寄存器的值保存到堆栈中,并在处理完中断后恢复它们。它还负责处理中断原因、状态和返回地址寄存器的值,以便在退出中断处理函数后恢复程序的执行。

Q:你觉得代码哪部分是最值得我关注的?

A:中断处理函数是一种比较复杂的程序结构,因此整个代码都值得关注。如果要选择特定部分,可能会关注以下内容:

  • 如何将所有通用寄存器的值保存到堆栈中(例如,通过 PUSH 宏定义)。
  • 如何处理中断原因、状态和返回地址寄存器的值(例如,通过对 mcause、mstatus 和 mepc 的读写操作)。
  • 如何调用用于处理中断的函数(例如,通过 jal 指令调用 __am_irq_handle 函数)。
  • 如何在退出中断处理函数后恢复程序的执行(例如,通过 mret 指令)。

当然,整个代码都应该仔细阅读和理解,以便更好地了解其工作原理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值