NJU PA3思路(riscv32)

 异常响应机制(riscv32架构)

riscv32提供ecall指令作为自陷指令, 并提供一个mtvec寄存器来存放异常入口地址. 为了保存程序当前的状态, riscv32提供了一些特殊的系统寄存器, 叫控制状态寄存器(CSR寄存器). 在PA中, 我们只使用如下3个CSR寄存器:

  • mepc寄存器 - 存放触发异常的PC
  • mstatus寄存器 - 存放处理器的状态
  • mcause寄存器 - 存放触发异常的原因

riscv32触发异常后硬件的响应过程如下:

  1. 将当前PC值保存到mepc寄存器
  2. 在mcause寄存器中设置异常号
  3. 从mtvec寄存器中取出异常入口地址
  4. 跳转到异常入口地址

需要注意的是, 上述保存程序状态以及跳转到异常入口地址的工作, 都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个简化后的过程, 在真实的计算机上还要处理很多细节问题, 比如x86和riscv32的特权级切换等, 在这里我们就不深究了. ISA手册中还记录了处理器对中断号和异常号的分配情况, 并列出了各种异常的详细解释, 需要了解的时候可以进行查阅.

由于异常入口地址是硬件和操作系统约定好的, 接下来的处理过程将会由操作系统来接管, 操作系统将视情况决定是否终止当前程序的运行(例如触发段错误的程序将会被杀死). 若决定无需杀死当前程序, 等到异常处理结束之后, 就根据之前保存的信息恢复程序的状态, 并从异常处理过程中返回到程序触发异常之前的状态.

具体地:riscv32通过mret指令从异常处理过程中返回, 它将根据mepc寄存器恢复PC.

先看Nanos-lite的main.c代码,只需要关注init_irq以及yield。init_irq里调用的cte_init,其实就是操作系统向硬件注册事件发生(如中断)的回调函数do_event(不是异常处理入口函数,这个回调函数就是真正把异常交给操作系统处理的地方,其中的参数为事件和相关的程序上下文。那么这个回调函数什么时候被调用呢,显然是异常发生的时候。

#include <common.h>

//初始化内存管理子系统
void init_mm(void);
void init_device(void);
void init_ramdisk(void);
void init_irq(void);
void init_fs(void);
void init_proc(void);

int main() {
  extern const char logo[];
  printf("%s", logo);
  Log("'Hello World!' from Nanos-lite");
  Log("Build time: %s, %s", __TIME__, __DATE__);

  init_mm();

  init_device();

  init_ramdisk();

#ifdef HAS_CTE//此时已经定义
  init_irq();
#endif

  init_fs();

  init_proc();
//输出一个日志消息,表示初始化过程已经完成。
  Log("Finish initialization");

#ifdef HAS_CTE
  yield();
#endif

  panic("Should not reach here");
}

我们接着查看cte_init的代码,其功能简单地说就是保存异常处理入口函数地址,以及保存用户回调函数即上述的do_event 

bool cte_init(Context*(*handler)(Event, Context*)) {
  // initialize exception entry        内联汇编,异常处理的入口地址设置为__am_asm_trap。
  //%0 是内联汇编中的操作数占位符,它表示内联汇编指令中的第一个操作数。在这里的内联汇编指令中,%0 用来引用第一个输入操作数,即 "r"(__am_asm_trap) 中的 __am_asm_trap。
  //"r" 约束表示将一个寄存器作为输入操作数
  asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));

  // register event handler 行将用户提供的事件处理程序函数指针handler赋值给全局变量user_handler。这个事件处理程序将在事件发生时被调用,用于处理事件和返回上下文。
  user_handler = handler;

  return true;
}

接下来在main函数的最后调用了yield(), yield只有两句汇编指令,将异常种类存放到a7寄存器中,以及发起自陷,其中ecall会使得程序流程转到之前注册的异常处理入口函数中去执行,即__am_asm_trap__am_asm_trap简单来说是提供了统一的异常入口地址,主要作用是将csr和gpr的内容作为参数调用__am_irq_handle,最终将事件和上下文一并通过回调函数hander传给操作系统

li a7, -1
ecall

相关的汇编代码 

Context* __am_irq_handle(Context *c) {
  if (user_handler) {
    Event ev = {0};
    switch (c->mcause) {
      default: ev.event = EVENT_ERROR; break;
    }

    c = user_handler(ev, c);
    assert(c != NULL);
  }

  return c;
}

总的来说,Nanos-lite先调用cte_init()在汇编代码中是把a5寄存器存的地址加载到mtvec寄存器,即注册异常处理入口函数,在指令模式匹配要实现csrw指令,并给cpu_statu结构体加上一个csr.mtvec字段,把a5寄存器存的地址赋值给这个字段,并注册回调函数。这个cte_init()是am层提供给Nanos-lite的接口,可以让Nanos-lite与架构无关,当移植到不同架构中只要调用相同接口就行。然后Nanos-lite调用yield(),会执行ecall指令,就会调用要求实现的isa_raise_intr,参数NO(异常种类)和epc(触发异常的指令地址)赋值给之前cpu_statu新增的csr字段,然后跳到csr.mtvec(注册的异常处理入口函数,在cte_init()中已经被赋值为<__am_asm_trap> ),主要作用是将csr和gpr的内容作为参数调用__am_irq_handle并在其返回后把csr和gpr的新值再存回去,__am_irq_handle这个函数也是定义在抽象硬件层(am)中的,通过判断程序上下文内容(比如csr.mcause)来构造事件,最终将事件和上下文一并通过回调函数传给操作系统,开始真正的异常处理(执行cte_init()注册的hander)。我理解的整个流程就是这样,也按照思路实现了

先给处理器添加上述的几个CSR

nemu/src/isa/riscv32/include/isa-def.h

typedef struct {
  word_t mcause;
  vaddr_t mepc;
  word_t mstatus;
  word_t mtvec;
} riscv32_CSRs;

typedef struct {
  word_t gpr[32];
  vaddr_t pc;
  riscv32_CSRs csr;
} riscv32_CPU_state;

csr的读写指令和ecall指令

static vaddr_t *csr_register(word_t imm) {
  switch (imm)
  {
  case 0x341: return &(cpu.csr.mepc);
  case 0x342: return &(cpu.csr.mcause);
  case 0x300: return &(cpu.csr.mstatus);
  case 0x305: return &(cpu.csr.mtvec);
  default: panic("Unknown csr");
  }
}

#define ECALL(dnpc) { bool success; dnpc = (isa_raise_intr(isa_reg_str2val("a7", &success), s->pc)); }
#define CSR(i) *csr_register(i)

INSTPAT("??????? ????? ????? 001 ????? 11100 11", csrrw  , I, R(dest) = CSR(imm); CSR(imm) = src1);
INSTPAT("??????? ????? ????? 010 ????? 11100 11", csrrs  , I, R(dest) = CSR(imm); CSR(imm) |= src1);
INSTPAT("0000000 00000 00000 000 00000 11100 11", ecall  , I, ECALL(s->dnpc));

isa_raise_intr,这个函数其实就是模拟了ecall的功能,即使得程序跳转到异常处理中,根据注释提示我们知道NO对应异常种类,epc对应触发异常的指令地址,最后返回异常入口地址

word_t isa_raise_intr(word_t NO, vaddr_t epc) {
  cpu.csr.mcause = NO;
  cpu.csr.mepc = epc;

  return cpu.csr.mtvec;
}

重新组织Context结构体

通过观察trap.s中我们发现上下文存储的方式为先存储32个通用寄存器随后存储mcause mstatus mepc

struct Context {
  // TODO: fix the order of these members to match trap.S
  uintptr_t gpr[32];
  uintptr_t mcause, mstatus, mepc;
  void *pdir;
  uintptr_t np;
};

实现正确的事件分发

__am_irq_handle()的代码会把执行流切换的原因打包成事件, 然后调用在cte_init()中注册的事件处理回调函数, 将事件交给Nanos-lite来处理. 在Nanos-lite中, 这一回调函数是nanos-lite/src/irq.c中的do_event()函数. do_event()函数会根据事件类型再次进行分发.
 

Context* __am_irq_handle(Context *c) {
  if (user_handler) {
    Event ev = {0};
    switch (c->mcause) {
      case 0:
        ev.event=EVENT_YIELD;break;
      default: ev.event = EVENT_ERROR; break;
    }
    //user_handler是cte_init中注册的回调函数
    c = user_handler(ev, c);
    assert(c != NULL);
  }
  
  return c;
}


///以下是定义在nanos-lite/
static Context* do_event(Event e, Context* c) {
   switch (e.event) {
    case 1:printf("event ID=%d\n",e.event);break;
    default: panic("Unhandled event ID = %d", e.event);break;
   }
  //返回输入的上下文 c,表示处理事件后的上下文状态。
  return c;
}

 恢复上下文

恢复上下文最重要的是恢复pc,触发中断的指令地址保存在mepc中,那么mret就负责从用这个寄存器恢复pc。需要注意的是自陷是其中一种异常类型.,mepc存放的是自陷指令的pc,需要+4才能得到下一条指令的地址,有一种故障类异常,此异常返回的PC无需加4,所以根据异常类型的不同, 有时候需要加4, 有时候则不需要加,在什么地方做这个+4的决定呢?我们在ecall时判断中断类型并+4即可

nemu/src/isa/riscv32/system/intr.c

word_t isa_raise_intr(word_t NO, vaddr_t epc) {
  /* TODO: Trigger an interrupt/exception with ``NO''.
   * Then return the address of the interrupt/exception vector.
   */
  //NO对应异常种类,epc对应触发异常的指令地址,最后返回异常入口地址
  //0是自陷
  if(NO==0){
    epc+=4;
  }
  cpu.csr.mcause = NO;
  cpu.csr.mepc = epc;
   
  return cpu.csr.mtvec;
}

遇到问题需要调试可以用printf(),打印csr之类的

ABI

您已经熟悉 API 的概念。如果您想使用某些库或操作系统的功能,您将针对 API 进行编程。API 由数据类型/结构、常量、函数等组成,您可以在代码中使用它们来访问该外部组件的功能。

ABI 非常相似。将其视为 API 的编译版本(或机器语言级别的 API)。当您编写源代码时,您可以通过 API 访问该库。代码编译完成后,您的应用程序将通过 ABI 访问库中的二进制数据。ABI 仅在较低级别定义了编译的应用程序将用于访问外部库的结构和方法(就像 API 所做的那样)。您的 API 定义了将参数传递给函数的顺序。您的 ABI 定义了如何传递这些参数的机制(寄存器、堆栈等)。您的 API 定义了哪些函数属于您的库的一部分。您的 ABI 定义了代码如何存储在库文件中,以便使用您的库的任何程序都可以找到所需的函数并执行它。

 实现loader 

ELF从两个视角组织一个可执行文件:

  1. 一个是面向链接过程的section视角,这个视角提供了用于链接与重定位的信息,例如符号表
  2. 一个是面向执行的segment视角,这个视角提供了用于加载可执行文件的信息,我们要从这下手

segmentELF里被抽象为Program Headers,我们只要先提取ELFElf32_Ehdr/Elf64_Ehdr,得到Elf32_Phdr/Elf64_Phdr的偏移量。

再根据偏移量提取Phdr数组,如果满足phdr.p_type == PT_LOAD,就需要装载到内存对应的位置,并将[VirtAddr + FileSiz, VirtAddr + MemSiz)清零。

我们需要做的是把程序加载到它应该处于的内存段中,以及将.bss段(全局变量区)清零,其中phdr(program header)中的p_vaddr即程序段应该加载到的内存段的首地址 

通过 readelf 命令

readelf -a ramdisk.img 

 得到

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000000 0x83000000 0x83000000 0x04df8 0x04df8 R E 0x1000
  LOAD           0x005000 0x83005000 0x83005000 0x00898 0x008d4 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
  1. 内存位置的解释:

    • 可执行段的VirtAddr指定了在内存中的虚拟地址,即程序入口点。
    • naive_uload 函数中,将程序入口点设置为这个虚拟地址。
  2. 所以只要对 segment 中的 VirtAddr 直接赋值就可以了。
static uintptr_t loader(PCB *pcb, const char *filename) {
      //Elf_Ehdr ehdr; - 声明一个Elf_Ehdr类型的结构体变量ehdr,用于存储ELF文件的头部信息。
      Elf_Ehdr ehdr;
      //使用ramdisk_read函数从ramdisk中读取ELF文件的头部信息,从偏移量0开始读取sizeof(Elf_Ehdr)字节的数据。
      ramdisk_read(&ehdr, 0, sizeof(Elf_Ehdr));
      // 用于检查ELF文件的合法性
      //assert((*(uint32_t *)ehdr.e_ident == 0x7F454C46));
      //创建一个Elf_Phdr类型的数组phdr,用于存储ELF文件的程序头表信息。ehdr.e_phnum表示头部表的数量
      Elf_Phdr phdr[ehdr.e_phnum];
      //使用ramdisk_read函数从ramdisk中读取程序头表信息。ehdr.e_ehsize指示了程序头表在文件中的偏移量,
      //sizeof(Elf_Phdr)*ehdr.e_phnum表示要读取的字节数,将所有程序头表都读取到数组phdr中。
      ramdisk_read(phdr, ehdr.e_ehsize, sizeof(Elf_Phdr)*ehdr.e_phnum);
      for (int i = 0; i < ehdr.e_phnum; i++) {
        //检查当前程序头表条目的类型是否为PT_LOAD,表示这是一个需要加载到内存中的段
      if (phdr[i].p_type == PT_LOAD) {
        //使用ramdisk_read函数将当前段的内容从ramdisk中读取到内存中。phdr[i].p_vaddr表示段的虚拟地址,
        //phdr[i].p_offset表示段在文件中的偏移量,phdr[i].p_memsz表示段在内存中的大小。
          ramdisk_read((void*)phdr[i].p_vaddr, phdr[i].p_offset, phdr[i].p_memsz);
          // 如果段的文件大小小于内存大小,这个代码用于将未初始化部分(即.bss部分)填充为零。
          memset((void*)(phdr[i].p_vaddr+phdr[i].p_filesz), 0, phdr[i].p_memsz - phdr[i].p_filesz);
        }
      }
      //返回ELF文件的入口地址,表示加载并准备执行的程序的入口点。
      return ehdr.e_entry;
}

系统调用

dummy.c

其执行过程大概如下:

_start -> call_main -> main -> _syscall_(SYS_yield, 0, 0, 0) -> exit -> _exit

可以看到dummy程序就调用了一个_syscall_(SYS_yield, 0, 0, 0);

#define SYS_yield 1
extern int _syscall_(int, uintptr_t, uintptr_t, uintptr_t);

int main() {
  return _syscall_(SYS_yield, 0, 0, 0);
}

再看_syscall_()函数的定义

intptr_t _syscall_(intptr_t type, intptr_t a0, intptr_t a1, intptr_t a2) {
  //使用 register 关键字将 _gpr1、_gpr2、_gpr3、_gpr4 和 ret 分别分配到寄存器中。这些寄存器用于传递参数和接收系统调用的返回值。
  //_gpr1 变量将与 GPR1 寄存器相关联,这个寄存器将用于存储 _gpr1 变量的值
  register intptr_t _gpr1 asm (GPR1) = type;
  register intptr_t _gpr2 asm (GPR2) = a0;
  register intptr_t _gpr3 asm (GPR3) = a1;
  register intptr_t _gpr4 asm (GPR4) = a2;
  register intptr_t ret asm (GPRx);
  asm volatile (SYSCALL : "=r" (ret) : "r"(_gpr1), "r"(_gpr2), "r"(_gpr3), "r"(_gpr4));
  return ret;
}

根据内联汇编语句,它会执行ecall指令,然后就会到异常处理入口函数,再到__am_irq_handle这里,在这里它会根据c->mcause也就是上面的SYS_yield的值(该值通过ecall指令赋值给寄存器a7,值为1)设置ev.event=EVENT_SYSCALL

static Context* (*user_handler)(Event, Context*) = NULL;

/*EVENT_NULL = 0,
    EVENT_YIELD, EVENT_SYSCALL, EVENT_PAGEFAULT, EVENT_ERROR,
    EVENT_IRQ_TIMER, EVENT_IRQ_IODEV,*/
Context* __am_irq_handle(Context *c) {
  if (user_handler) {
    Event ev = {0};
    printf("__am_irq_handle中c->mcause为%d\n",c->mcause);
    switch (c->mcause) {
      case 0:case 1:case 2:case 3:case 4:case 5:case 6:case 7:case 8:case 9:case 10:case 11:case 12:case 13:case 14:case 15:case 16:case 17:case 18:case 19:ev.event=EVENT_SYSCALL;break;
      default: ev.event = EVENT_ERROR; break;
    }
    //user_handler是cte_init中注册的回调函数
    c = user_handler(ev, c);
    assert(c != NULL);
  }
  
  return c;
}

然后执行handler函数

static Context* do_event(Event e, Context* c) {
  printf("do_event中e.event=%d\n",e.event);
   switch (e.event) {
    case 1:printf("event ID=%d\nc->GPRx=%d\n",e.event,c->GPRx);halt(0);printf("执行了halt之后");break;//EVENT_YIELD
    case 2:do_syscall(c);break;//EVENT_SYSCALL
    default: panic("Unhandled event ID = %d", e.event);break;
   }
  //返回输入的上下文 c,表示处理事件后的上下文状态。
  return c;
}

handler函数会根据ev.event的值(为EVENT_SYSCALL时)执行do_syscall()函数

void do_syscall(Context *c) {
  uintptr_t a[4];
  a[0] = c->GPR1; //#define GPR1 gpr[17] // a7
 printf("执行到do_syscall,此时根据c->GPR1的值来判断属于哪个系统调用 c->GPR1=a7=%d\n",a[0]);
  switch (a[0]) {
    case 0:c->GPRx=0;printf("SYS_exit, do_syscall此时 c->GPRx=%d\n",c->GPRx);halt(c->GPRx);//SYS_exit系统调用
    case 1:printf("SYS_yield, do_syscall此时c->GPRx=%d\n",c->GPRx);yield(); //SYS_yield系统调用
    default: panic("Unhandled syscall ID = %d", a[0]);
  }
}

之后根据c->GPR1也就是 _syscall_函数的第一个参数系统调用号来判断走哪一步,这里值为1所以走case 1,执行yield,也就是asm volatile("li a7, 0; ecall");然后它又会走到这里且走case 0,就会调用halt(0)退出,最后输出HIT GOOD TRAP的信息.

在Nanos-lite上运行Hello world

do_syscall()中识别出系统调用号是SYS_write之后, 检查fd的值, 如果fd12(分别代表stdoutstderr), 则将buf为首地址的len字节输出到串口(使用putch()即可). 最后还要设置正确的返回值, 否则系统调用的调用者会认为write没有成功执行, 从而进行重试,返回值是成功写入的字节数

int _write(int fd, void *buf, size_t count) {
  assert(fd == 1 || fd == 2);
  _syscall_(SYS_write, (intptr_t)buf, count,0);
  
  return count;
  
}

/
//SYS_write()
SYS_write(intptr_t *buf, size_t count){
      for (int i = 0; i < count; i++) {
    putch(*((char*)buf + i));
  }
  
}

此时运行hello.c发现之后的printf无法正常输出,这个需要实现堆区管理

堆区

  1. program break一开始的位置位于_end
  2. 被调用时, 根据记录的program break位置和参数increment, 计算出新program break
  3. 通过SYS_brk系统调用来让操作系统设置新program break
  4. SYS_brk系统调用成功, 该系统调用会返回0, 此时更新之前记录的program break的位置, 并将旧program break的位置作为_sbrk()的返回值返回
  5. 若该系统调用失败, _sbrk()会返回-1

SYS_brk()把c->GPRx设置为0就可以了 ,实现这个之后printf可以正常输出

extern char _end;
static intptr_t cur_brk = (intptr_t)&_end;

void *_sbrk(intptr_t increment) {
  intptr_t old_brk = cur_brk;
  intptr_t new_brk = old_brk + increment;
  if (_syscall_(SYS_brk, new_brk, 0, 0) != 0) {
    return (void*)-1; 
  }
  cur_brk = new_brk;
  return (void*)old_brk;
}

无法用文件名来标识less工具的标准输入

cat file | less

这个管道的作用是将 cat 命令的输出(file 文件的内容)传递给 less 命令,使你可以在 less 中逐页查看文件内容。在这种情况下并没有像 cat fileless 这样的文件名来标识 less 命令的输入,这就是所谓的标准输入,在这个特定的情况下,标准输入不是一个文件,而是另一个命令的输出。所以,无法使用文件名来标识 less 命令的标准输入,因为它不是一个文件,而是另一个命令的输出

 实现完整的文件系统

在文件读写操作中,各个库函数最终会调用read/write/open/close函数进行文件操作。而上述几个函数又会分别调用_read/_write/_read/_write函数,后者中又调用了_syscall_函数来编译出ecall指令并设置好系统调用的寄存器参数。

陷入内核态后,经由do_syscall处理分发,识别出调用类型,再分别调用sys_read/sys_write/sys_open/sys_close,在这里面最终调用fs_read/fs_write/fs_open/fs_close,也就是我们要分别实现的这四个函数

Finfo中添加一个open_offset字段来记录文件当前读写位置,并更新维护它

fs_open()
  1. 找到指定文件名,跳2,否则跳3;
  2. 将该项读写指针设为0,返回什么?查阅man 2 open
  3. 没有找到文件,注意一定要直接panic掉程序。
fs_read()
  1. 必须先验证文件描述符大于2
  2. 根据传入文件号从文件记录表中找到对应记录;
  3. 比较要读的长度剩余字节长度的大小,取其者为读的长度。为什么?可以回答到报告中。
  4. ramdisk中对应地址处读这么多的数据到buf中;
  5. 更新当前该文件的读写指针
  6. 返回一个值。这个值代表什么?查阅man 2 read
fs_close()

我们的简易文件系统并没有维护文件的打开状态,所以返回0表示成功关闭即可。

fs_lseek() 
  1. 首先,根据传入的文件号 fd 从文件记录表 file_table 中找到相应的文件记录项,即 Finfo 结构。

  2. 然后,获取当前文件的读写指针位置 file->open_offset 和当前文件的大小 file->size。读写指针记录了文件中正在读写的位置,文件大小表示文件的总字节数。

  3. 接下来,根据 whence 参数的不同含义,计算出新的读写指针位置 new_offsetwhence 参数表示定位方式,可能的值有:

    • SEEK_SET:将读写指针设置为 offset,即距离文件开头 offset 字节处。
    • SEEK_CUR:将读写指针设置为当前位置加上 offset 字节,offset 可以为正数或负数,分别表示向文件尾或文件头移动。
    • SEEK_END:将读写指针设置为文件末尾再加上 offset 字节,同样 offset 可以为正数或负数。
  4. 在计算出新的读写指针位置 new_offset 后,需要确保它在文件范围内,不能小于 0(文件开头)或大于当前文件的大小(文件末尾)。这是为了避免越界访问文件内容,保证读写操作的合法性。

  5. 如果新的读写指针位置 new_offset 超出文件范围,需要将它设置为合法的边界位置。例如,如果 new_offset 小于 0,它将被设置为 0(文件开头),如果 new_offset 大于文件大小,它将被设置为文件末尾。

  6. 最后,将新的读写指针位置 new_offset 存入文件记录表 file_table 中当前文件对应的项的读写指针字段 file->open_offset 中。

  7. 返回值是一个表示新的读写指针位置的无符号整数。根据 man 2 lseek,返回值通常是新的文件读写指针位置(距离文件开头的偏移量)。这是 fs_lseek 函数的返回值,供后续文件读写操作使用。

#define NR_FILES 24

int fs_open(const char *pathname, int flags, int mode){
  for (int i = 3; i < NR_FILES; i++) {
        if (strcmp(file_table[i].name, pathname) == 0) {
            file_table[i].open_offset = 0;
            return i;
        }
    }
  panic("file %s not found", pathname);
}


int fs_close(int fd){
  return 0;
}

size_t fs_read(int fd, void *buf, size_t len){
   if (fd <= 2) {
        Log("ignore read %s", file_table[fd].name);
        return 0;
  }
  size_t read_len = len;
  size_t open_offset = file_table[fd].open_offset;
  size_t size = file_table[fd].size;
  size_t disk_offset = file_table[fd].disk_offset;
  if (open_offset > size) return 0;
  if (open_offset + len > size) read_len = size - open_offset;
  ramdisk_read(buf, disk_offset + open_offset, read_len);
  file_table[fd].open_offset += read_len;
  return read_len;

}

size_t fs_write(int fd, const void *buf, size_t len) {
    if (fd == 0) {
        Log("ignore write %s", file_table[fd].name);
        return 0;
    }

    if (fd == 1 || fd == 2) {
        for (size_t i = 0; i < len; ++i)
            putch(*((char *)buf + i));
        return len;
    }
    size_t write_len = len;
    size_t open_offset = file_table[fd].open_offset;
    size_t size = file_table[fd].size;
    size_t disk_offset = file_table[fd].disk_offset;
    if (open_offset > size) return 0;
    if (open_offset + len > size) write_len = size - open_offset;
    ramdisk_write(buf, disk_offset + open_offset, write_len);
    file_table[fd].open_offset += write_len;
    return write_len;
}

size_t fs_lseek(int fd, size_t offset, int whence){
  if (fd <= 2) {
        Log("ignore lseek %s", file_table[fd].name);
        return 0;
  }

  Finfo *file = &file_table[fd];
  size_t new_offset;
  // 根据 whence 参数来计算新的指针位置
    if (whence == SEEK_SET) {
        new_offset = offset;
    } else if (whence == SEEK_CUR) {
        new_offset = file->open_offset + offset;
    } else if (whence == SEEK_END) {
        new_offset = file->size + offset;
    } else {
        Log("Invalid whence value: %d", whence);
        return -1;
    }
     // 检查新的指针位置是否在文件范围内
    if (new_offset < 0 || new_offset > file->size) {
        Log("Seek position out of bounds");
        return -1;
    }
     // 设置新的文件读写指针
    file->open_offset = new_offset;
    
    return new_offset;
}
实现Loader() 
  1. 根据要执行的程序的文件名,如/bin/hello,得到文件号;
  2. fs_read读得到的文件号的文件大小个字节,读到默认程序入口点附近;
  3. 关闭文件;
  4. 返回程序入口点。

实现完Loader之后,以后更换用户程序只需要修改传入 naive_uload() 函数的文件名即可

static uintptr_t loader(PCB *pcb, const char *filename) {
  
  int fd = fs_open(filename, 0, 0);
  if (fd < 0) {
    panic("should not reach here");
  }
  Elf_Ehdr elf;

  assert(fs_read(fd, &elf, sizeof(elf)) == sizeof(elf));
  // 检查魔数
  assert(*(uint32_t *)elf.e_ident == 0x464c457f);
  
  Elf_Phdr phdr;
  for (int i = 0; i < elf.e_phnum; i++) {
    uint32_t base = elf.e_phoff + i * elf.e_phentsize;

    fs_lseek(fd, base, 0);
    assert(fs_read(fd, &phdr, elf.e_phentsize) == elf.e_phentsize);
    
    // 需要装载的段
    if (phdr.p_type == PT_LOAD) {

      char * buf_malloc = (char *)malloc(phdr.p_filesz);

      fs_lseek(fd, phdr.p_offset, 0);
      assert(fs_read(fd, buf_malloc, phdr.p_filesz) == phdr.p_filesz);
      
      memcpy((void*)phdr.p_vaddr, buf_malloc, phdr.p_filesz);
      memset((void*)phdr.p_vaddr + phdr.p_filesz, 0, phdr.p_memsz - phdr.p_filesz);
      
      free(buf_malloc);
    }
  }

  assert(fs_close(fd) == 0);
  
  return elf.e_entry;
}

记得在用户层也就是navy-apps/libs/libos/src/syscall.c添加相应的系统调用

 VFS

文件的本质就是字节序列,计算机系统中到处都是字节序列,这些五花八门的字节序列应该都可以看成文件因此有"一切皆文件"(Everything is a file)的说法. 为了实现一切皆文件的思想,对之前实现的文件操作API的语义进行扩展, 让它们可以支持任意文件(包括"特殊文件")的操作

int fs_open(const char *pathname, int flags, int mode);
size_t fs_read(int fd, void *buf, size_t len);
size_t fs_write(int fd, const void *buf, size_t len);
size_t fs_lseek(int fd, size_t offset, int whence);
int fs_close(int fd);

也就是说系统调用不需要管文件是普通文件还是特殊文件,只要调用上面的API来实现读写操作就行了,例如读操作对于普通文件最后会调用ramdisk_read(),而特殊文件(设备),是调用io_read()->ioe.read()读PA2中提到的抽象寄存器。实现VFS的关键就是Finfo结构体中的两个读写函数指针:

typedef struct {
  char *name;         // 文件名
  size_t size;        // 文件大小
  size_t disk_offset;  // 文件在ramdisk中的偏移
  ReadFn read;        // 读函数指针
  WriteFn write;      // 写函数指针
} Finfo;

 也就是说,当我们给特殊文件的read和write成员变量,系统调用调用fs_read()和fs_write()函数时,就会根据文件结构体的read和write成员变量来决定执行文件相对应的读写函数

把串口抽象成文件

在 nanos-lite/src/device.c 中实现 serial_write()

size_t serial_write(const void *buf, size_t offset, size_t len) {
  for (size_t i = 0; i < len; ++i) putch(*((char *)buf + i));
  return len;
}

然后在文件记录表中设置相应的写函数:

static Finfo file_table[] __attribute__((used)) = {
  [FD_STDIN]  = {"stdin", 0, 0, invalid_read, invalid_write},
  [FD_STDOUT] = {"stdout", 0, 0, invalid_read, serial_write},
  [FD_STDERR] = {"stderr", 0, 0, invalid_read, serial_write},
#include "files.h"
};

相应的修改 fs_read 和 fs_write

size_t fs_read(int fd, void *buf, size_t len) {
    ReadFn readFn = file_table[fd].read;
    if (readFn != NULL) {
        return readFn(buf, 0, len);
    }
    ...//原来的代码
}

size_t fs_write(int fd, const void *buf, size_t len) {
    WriteFn writeFn = file_table[fd].write;
    if (writeFn != NULL) {
        return writeFn(buf, 0, len);
    }
    ...//原来的代码
}

 实现gettimeofday 

先在用户层添加该系统调用然后实现系统调用的分发到此函数

int sys_gettimeofday(struct timeval *tv, struct timezone *tz) {
    uint64_t us = io_read(AM_TIMER_UPTIME).us;
    tv->tv_sec = us / 1000000;
    tv->tv_usec = us - us / 1000000 * 1000000;
    return 0;
}

 实现NDL的时钟 

navy-apps的make在编译链接的时候,没有加入libndl这个库,得打开Makefile把这个库加进去

#include <sys/time.h>
#include <assert.h>

uint32_t NDL_GetTicks() {
  struct timeval tv;
  assert(gettimeofday(&tv, NULL) == 0);
  return tv.tv_sec * 1000 + tv.tv_usec / 1000;
}

 把按键输入抽象成文件 

主要就是实现AM_INPUT_KEYBRD寄存器读取

size_t events_read(void *buf, size_t offset, size_t len) {
  AM_INPUT_KEYBRD_T t = io_read(AM_INPUT_KEYBRD);
  return snprintf((char *)buf, len, "%s %s\n", 
    t.keydown ? "kd" : "ku",
    keyname[t.keycode]);
}

然后在file_table 中添加/dev/events

再然后添加用户层NDL的代码,这里open和read最终会跳到系统调用_open,_read

int NDL_PollEvent(char *buf, int len) {
  int fd = open("/dev/events", 0, 0);
  int ret = read(fd, buf, len);
  assert(close(fd) == 0);
  return ret == 0 ? 0 : 1;
}

VGA

屏幕显示呢?读写显存。

现在,我们要将显存抽象为一个文件,对这个文件进行写入,就是更新屏幕显示;与此同时,这个文件应支持移动读写指针,因为我们需要往屏幕的各个位置写像素,也就是往文件中不同的地方写数据。这个文件就是:

/dev/fb

屏幕的信息存储在

/proc/dispinfo

navy-apps/README.md 中对这个文件内容的格式进行了约定:

procfs 文件系统:所有的文件都是 key-value pair,格式为 [key] : [value], 冒号左右可以有任意多 (0 个或多个) 的空白字符 (whitespace).

  • /proc/dispinfo: 屏幕信息,包含的 keys: WIDTH 表示宽度,HEIGHT 表示高度。 

 dispinfo_read()读取屏幕参数就是从AM_GPU_CONFIG寄存器读取

size_t dispinfo_read(void *buf, size_t offset, size_t len) {
  AM_GPU_CONFIG_T t = io_read(AM_GPU_CONFIG);
  return snprintf((char *)buf, len, "WIDTH:%d\nHEIGHT:%d\n", t.width, t.height);
}

实现NDL_OpenCanvas()

NDL函数外定义好需要的变量

//屏幕大小
static int screen_w = 0, screen_h = 0;
//画布大小
static int canvas_w=0,canvas_h=0;
//相对于屏幕左上角的画布位置坐标
static int canvas_x=0,canvas_y=0;

这里从/proc/dispinfo 中解析出屏幕的宽和高,然后赋值, 根据屏幕的值和画布的宽高算出画布的原点(相对于屏幕左上角的位置坐标)

 int buf_size = 1024; 
  char * buf = (char *)malloc(buf_size * sizeof(char));
  int fd = open("/proc/dispinfo", 0, 0);
  int ret = read(fd, buf, buf_size);
  assert(ret < buf_size); // to be cautious...
  assert(close(fd) == 0);

  int i = 0;
  int width = 0, height = 0;
//使用 strncmp 函数检查字符串 "WIDTH" 是否位于 buf 中 i 处开始的位置,以确保文件内容的格式正确。
  assert(strncmp(buf + i, "WIDTH", 5) == 0);
  //这一行将 i 增加 5,以跳过字符串 "WIDTH"。
  i += 5;
  for (; i < buf_size; ++i) {
      if (buf[i] == ':') { i++; break; }
      assert(buf[i] == ' ');
  }
  for (; i < buf_size; ++i) {
    //检查当前字符是否是数字字符。如果是,它跳出循环以开始解析宽度值。
      if (buf[i] >= '0' && buf[i] <= '9') break;
      assert(buf[i] == ' ');
  }
  for (; i < buf_size; ++i) {
    
      if (buf[i] >= '0' && buf[i] <= '9') {
        //检查当前字符是否是数字字符。如果是,它将当前字符的数字值添加到 width 变量中。
          width = width * 10 + buf[i] - '0';
      } else {
          break;
      }
  }
  assert(buf[i++] == '\n');

  assert(strncmp(buf + i, "HEIGHT", 6) == 0);
  i += 6;
  for (; i < buf_size; ++i) {
      if (buf[i] == ':') { i++; break; }
      assert(buf[i] == ' ');
  }
  for (; i < buf_size; ++i) {
      if (buf[i] >= '0' && buf[i] <= '9') break;
      assert(buf[i] == ' ');
  }
  for (; i < buf_size; ++i) {
      if (buf[i] >= '0' && buf[i] <= '9') {
          height = height * 10 + buf[i] - '0';
      } else {
          break;
      }
  }

  free(buf);

  screen_w = width;
  screen_h = height;
if (*w == 0 && *h == 0) {
    *w = screen_w;
    *h = screen_h;
  }
  canvas_w = *w;
  canvas_h = *h;
  canvas_x=(screen_w - canvas_w) / 2;
  canvas_y=(screen_h - canvas_h) / 2;
init_fs()(在nanos-lite/src/fs.c中定义)中对文件记录表中/dev/fb的大小进行初始化 

显存的大小为屏幕尺寸×像素大小(uint32_t)

void init_fs() {
  // TODO: initialize the size of /dev/fb
   AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG);
  int width = ev.width;
  int height = ev.height;
  file_table[FD_FB].size = width * height * sizeof(uint32_t);

}
实现fb_write(),NDL_DrawRect()

做到后面发现在native上测试bmp-test 反映出 VGA 的实现有问题。 lseek 和 write 是以字节寻址的,而 pixels 的单位为 4 字节,因此才跑回来修改了这里的内容。

做这题前要理解清楚PA2中关于VGA寄存器的写入操作,从用户层(NDL)开始解析这整个过程发生了什么,首先是NDL_DrawRect(),像素是一行一行写入

// 向画布`(x, y)`坐标处绘制`w*h`的矩形图像, 并将该绘制区域同步到屏幕上
// 图像像素按行优先方式存储在`pixels`中, 每个像素用32位整数以`00RRGGBB`的方式描述颜色
void NDL_DrawRect(uint32_t *pixels, int x, int y, int w, int h) {
  int fd = open("/dev/fb", 0, 0);
  for (int i = 0; i < h && y + i < canvas_h; ++i) {
    lseek(fd, ((y + canvas_y + i) * screen_w + (x + canvas_x)) * 4, SEEK_SET);
    write(fd, pixels + i * w, 4 * (w < canvas_w - x ? w : canvas_w - x));
  }
  assert(close(fd) == 0);
}

这调用lseek()把文件(这里是显存)指针移动到了相应位置,也就是得到了画布相对于屏幕的x,y坐标,然后调用write()对显存进行写入

先是lseek(),经过系统调用,最终会调用fs_lseek(),把/dev/fb的open_offset参数赋值为正确的值

size_t fs_lseek(int fd, size_t offset, int whence){
  if (fd <= 2) {
        Log("ignore lseek %s", file_table[fd].name);
        return 0;
  }

  Finfo *file = &file_table[fd];
  size_t new_offset;
  // 根据 whence 参数来计算新的指针位置
    if (whence == SEEK_SET) {
        new_offset = offset;
    } else if (whence == SEEK_CUR) {
        new_offset = file->open_offset + offset;
    } else if (whence == SEEK_END) {
        new_offset = file->size + offset;
    } else {
        Log("Invalid whence value: %d", whence);
        return -1;
    }
     // 检查新的指针位置是否在文件范围内
    if (new_offset < 0 || new_offset > file->size) {
        Log("Seek position out of bounds");
        return -1;
    }
     // 设置新的文件读写指针
    file->open_offset = new_offset;
    
    return new_offset;
}

然后是write(),经过系统调用,最终会调用fb_write(),在这里我们对之前NDL_DrawRect()传入的参数进行解码,得到需要的值,然后调用io_write()用来写入缓存,

size_t fb_write(const void *buf, size_t offset, size_t len) {
  AM_GPU_CONFIG_T ev = io_read(AM_GPU_CONFIG);
  int width = ev.width;

  offset /= 4;
  len /= 4;

  int y = offset / width;
  int x = offset - y * width;

  io_write(AM_GPU_FBDRAW, x, y, (void *)buf, len, 1, true);

  return len;
} 

io_write(AM_GPU_FBORAW...)又会调用__am_gpu_fbdraw()把需要绘制的区域的像素点数据放到frame buffer中,最终是在vga.c的vga_update_screen()中从frame buffer中取出数据并实现绘制(这部分是在PA2 IOE部分实现的)

  • AM_GPU_FBDRAW, AM帧缓冲控制器, 可写入绘图信息, 向屏幕(x, y)坐标处绘制w*h的矩形图像. 图像像素按行优先方式存储在pixels中, 每个像素用32位整数以00RRGGBB的方式描述颜色. 若synctrue, 则马上将帧缓冲中的内容同步到屏幕上.
void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) {
  int x = ctl->x, y = ctl->y, w = ctl->w, h = ctl->h;
  if (!ctl->sync && (w == 0 || h == 0)) return;
  uint32_t *pixels = ctl->pixels;
  uint32_t *fb = (uint32_t *)(uintptr_t)FB_ADDR;
  uint32_t screen_w = inl(VGACTL_ADDR) >> 16;
  for (int i = y; i < y+h; i++) {
    for (int j = x; j < x+w; j++) {
      fb[screen_w*i+j] = pixels[w*(i-y)+(j-x)];
    }
  }
  if (ctl->sync) {
    outl(SYNC_ADDR, 1);
  }
}

结果展示 

miniSDL

miniSDL 中的 API 和 SDL 同名,通过 RTFM 了解 SDL API 的行为来编写,这里实现有歧义,对于Navy中的app只成功运行了NSlider,MENU,NTerm 。

Flappy Bird和PAL在native可以运行

PAL(仙剑奇侠传)数据文件下载地址:南大云盘 NJU Box

  • 9
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

idMiFeng

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值