TinyEMU源码分析之RISCV启动流程

本文详细解析了TinyEMU模拟器中RISC-V的启动流程,包括Bootloader执行过程中的入口点确定、mentry.S的执行、init_first_hart和enter_supervisor_mode函数,以及kernel的执行和TinyEMU处理mret指令的机制。
摘要由CSDN通过智能技术生成


本文属于《 TinyEMU模拟器基础系列教程》之一,欢迎查看其它文章。
本文中使用的代码,均为伪代码,删除了部分源码。

1 从0x1000开始启动

本小节中涉及代码,来自TinyEMU源码。

我们沿着TinyEMU模拟器中,RISCV的启动流程,依次讲解,以便理解其启动原理。

整个虚拟机,到底从哪里,开始执行第一条指令呢?

我们可以在glue函数的,s->pc = GET_PC()位置处,打上断点,查看第一条指令的PC。

static void no_inline glue(riscv_cpu_interp_x, XLEN)(RISCVCPUState *s, int n_cycles1)
{
	for(;;) {
		// 获取PC
		s->pc = GET_PC(); 
		addr = s->pc;
		ptr = (uint8_t *)(s->tlb_code[tlb_idx].mem_addend +
                                  (uintptr_t)addr);
		code_ptr = ptr;
		
		//根据PC获取一条指令机器码
		insn = get_insn32(code_ptr); 
	}
}

可以发现,第一条指令的PC为0x1000。
我们回顾一下,《TinyEMU源码分析之虚拟机初始化》中介绍的5条指令。

auipc t0, jump_addr			// t0 = 0x80000000
auipc a1, dtb				// a1 = PC
addi a1, a1, dtb			// a1 = a1 + 0x3c
csrr a0, mhartid			// a0 = mhartid
jalr zero, t0, jump_addr	// PC = t0

这5条指令,就是从0x1000内存地址,开始存放的。
因此,虚拟机可以确定:

  • 第1条指令,从auipc t0, jump_addr开始执行;
  • 第5条指令jalr zero, t0, jump_addr,将PC设置为0x80000000;
  • 因此,第6条指令,必然跳转到0x80000000处执行。

2 bootloader执行过程

本小节中涉及代码,来自riscv-pk源码。

0x80000000处,我们存放的是BIOS/Bootloader(这里是bbl64.bin)。
因此0x80000000处,为bbl64.bin的第一条指令,我们需要分析bbl64.bin的源码,找到其入口点。

2.1 确定bootloader入口点

bbl64.bin的源码,包括2部分,汇编文件与C文件。
汇编文件为riscv-pk\machine\mentry.S,一般叫启动文件。

Bootloader的.s启动文件,是汇编语言源代码文件,通常用于嵌入式系统的启动过程。
这个文件包含了系统启动时的底层硬件初始化代码,是用汇编语言编写的,以便直接操作硬件寄存器,完成必要的初始化工作。

在链接器的链接脚本.lds文件内,会定义程序的入口点,即程序的第一条可执行指令。

关于链接器脚本,可参考《【裸机开发】链接脚本(.lds文件)的基本语法》

因此,编译器对.s和.c文件,执行编译与链接后,程序就可以从该入口点开始执行。
通常入口点,就在汇编文件中,这里就是mentry.S。

我们看,链接脚本riscv-pk\bbl\bbl.lds,如下:
在这里插入图片描述
ENTRY(symbol),这里的symbol指的是符号表中的符号。汇编阶段会生成符号表,符号表中的符号,包括静态变量、全局变量、函数名等。这是将某一个符号symbol的值,设为入口地址。

我们再看,riscv-pk\machine\mentry.S,如下:
在这里插入图片描述
因此bbl.lds指定的入口点,就是mentry.S中的reset_vector处。
即0x80000000处,指令为j do_reset

2.2 执行mentry.S

执行指令j do_reset,跳转到do_reset地址后,后续执行过程,如下所示:
在这里插入图片描述
汇编代码添加了注释,比较简单,主要有以下操作:

  • 主要是初始化x系列寄存器,以及根据misa寄存器判断;
  • 此外,前面讲过的a0(mhartid)与a1(设备树)寄存器值,作为init_first_hart函数的入参;
  • 保存异常向量表基址trap_vector,到mtvec寄存器;
  • 准备栈指针sp;
  • 最后,跳转到riscv-pk\machine\minit.c中的init_first_hart函数,进入到C代码阶段。

2.3 执行init_first_hart函数

init_first_hart函数,如下所示:

void init_first_hart(uintptr_t hartid, uintptr_t dtb)
{
  // 解析device tree,进行一系列初始化
  query_uart(dtb);
  ...
  query_chosen(dtb);

  // 初始化hart, hls, 内外部中断, 内存等
  hart_init();
  hls_init(0); // this might get called again from parse_config_string
  wake_harts();
  plic_init();
  hart_plic_init();
  //prci_test();
  memory_init();
  boot_loader(dtb);
}

主要是,解析设备树,进行一系列的初始化操作,具体我们暂时不关心。我们只对启动流程关键信息,做介绍,其他忽略。

在query_chosen函数,会从设备树中,解析出kernel_startkernel_end,如下:

void query_chosen(uintptr_t fdt)
{
  struct fdt_cb cb;
  
  fdt_scan(fdt, &cb);
  kernel_start = chosen.kernel_start;
  kernel_end = chosen.kernel_end;
}

经过如下函数,依次将kernel_start传参,如下:

void boot_loader(uintptr_t dtb)
{
  /* Use optional FDT preloaded external payload if present */
  entry_point = kernel_start ? kernel_start : &_payload_start;
  boot_other_hart(0);
}
void boot_other_hart(uintptr_t unused __attribute__((unused)))
{
  entry = entry_point;
  enter_supervisor_mode(entry, hartid, dtb_output());
}

2.4 执行enter_supervisor_mode函数

在enter_supervisor_mode函数中,就是进入S模式前,做的一些操作,为启动kernel做准备,如下所示:

void enter_supervisor_mode(void (*fn)(uintptr_t), uintptr_t arg0, uintptr_t arg1)
{
  setup_pmp();

  uintptr_t mstatus = read_csr(mstatus); 				// 读取mstatus
  mstatus = INSERT_FIELD(mstatus, MSTATUS_MPP, PRV_S);  // mstatus.MPP = PRV_S
  mstatus = INSERT_FIELD(mstatus, MSTATUS_MPIE, 0);	    // mstatus.MPIE = 0
  write_csr(mstatus, mstatus); 							// 写入mstatus
  write_csr(mscratch, MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE); // 写入mscratch
#ifndef __riscv_flen
  uintptr_t *p_fcsr = MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE; // the x0's save slot
  *p_fcsr = 0;
#endif
  write_csr(mepc, fn);		// mepc = kernel_start

  register uintptr_t a0 asm ("a0") = arg0; // a0 = mhartid
  register uintptr_t a1 asm ("a1") = arg1; // a1 = dtb
  asm volatile ("mret" : : "r" (a0), "r" (a1)); // 执行mret指令
  __builtin_unreachable(); // 通知编译器这一行,在CPU运行时永远不会到达
}

这些准备操作,包括配置mstatus、mscratch、mepc寄存器,执行mret指令等。
此外,其中最重要的(这里暂时不关心中断),有3步(与退出异常过程类似):

  • mstatus.MPP = PRV_S
    设置为异常发生前为S模式;以便在异常结束后,能够使用MPP值,恢复出异常发生之前的工作模式S。
  • mepc = kernel_start
    将kernel_start写入mepc寄存器;以便执行mret指令时,可以从mepc中取出地址,赋给pc,实现跳转到kernel。
  • 执行mret指令

此外,linux kernel启动时,还要求有2个参数:

  • a0寄存器,用于保存mhartid;
  • a1寄存器,用于保存设备树的物理内存地址。

因此,我们需要将mhartid和dtb作为参数,传递给kernel。

完成上述准备工作后,bbl64.bin代码,就执行完毕了。
下一节,我们看看TinyEMU,在执行mret指令时,会进行什么处理。

3 tinyemu处理mret指令

tinyemu中,在glue函数mret分支下,处理mret指令,如下:

static void no_inline glue(riscv_cpu_interp_x, XLEN)(RISCVCPUState *s,
                                                   int n_cycles1
{
	...
    case 0x302: /* mret */
           {
               if (insn & 0x000fff80)
                   goto illegal_insn;
               if (s->priv < PRV_M)
                   goto illegal_insn;
               s->pc = GET_PC();
               handle_mret(s);
               goto done_interp;
           }
           break;
}

我们看下,handle_mret函数中,具体处理,如下:

static void handle_mret(RISCVCPUState *s)
{
    int mpp, mpie;
    mpp = (s->mstatus >> MSTATUS_MPP_SHIFT) & 3;   // mpp = mstatus.MPP
    /* set the IE state to previous IE state */
    mpie = (s->mstatus >> MSTATUS_MPIE_SHIFT) & 1;
    s->mstatus = (s->mstatus & ~(1 << mpp)) |
        (mpie << mpp);
    /* set MPIE to 1 */
    s->mstatus |= MSTATUS_MPIE;
    /* set MPP to U */
    s->mstatus &= ~MSTATUS_MPP;
    set_priv(s, mpp);	// 设置为mpp模式
    s->pc = s->mepc;	// pc = mepc
}

主要有以下操作:

  • 恢复mstatus寄存器;
  • 设置工作模式为mstatus.MPP,这里就是S模式;
  • pc = mepc,从mepc中读取值,赋值为pc,这里mepc为kernel_start。

在tinyemu处理mret指令后,就会切换到S模式,并且pc跳转到0x80200000处,开始执行kernel代码

因此,在riscv架构中:

  • Bootloader/固件,运行在M模式下;
  • Linux Kernel,运行在S模式下;
  • 应用程序,运行在U模式下。

4 kernel执行过程

本小节中涉及代码,来自linux源码。
kernel源码,也包括2部分,汇编文件与C文件,启动过程中,我们通常称为汇编阶段和C语言阶段。

4.1 确定kernel入口点

Linux内核的链接脚本文件为,linux源码目录下的arch/riscv/kernel/vmlinux.lds,通过链接脚本,可以找到Linux内核的第一行程序是从哪里执行的。
在这里插入图片描述
kernel的入口点,在arch/riscv/kernel/head.S文件中_start位置处(head.S就是kernel的启动文件),如下:
在这里插入图片描述
因此,kernel的入口点,第一条指令为csrw sie,zero,即该指令pc为0x80200000。

4.2 执行head.S

stvec寄存器,保存了S模式下的异常向量表入口地址,该地址指向arch/riscv/kernel/entry.s中trap_entry位置处。

进入执行kernel代码后,其实涉及的内容,就是linux启动流程,相关的内容了。

由于本文重点,不在探讨linux系统上,因此不做赘述。

可参考以下文档:

5 riscv启动流程图

上述启动流程,可用下图,来简要描述:
在这里插入图片描述

6 调试技巧

  • 如何判断Bootloader,开始执行?

跟踪Bootloader的入口点PC(0x80000000),第一条Bootloader指令,从这里开始执行。

  • 如何判断Bootloader,执行结束?

我们在调试过程中,可以捕捉第一次mret指令,以便跟踪bootloader的终点,以及kernel的起点(因为进入kernel时,一定是从M模式切换到S模式,必须调用mret指令)。

  • 如何判断Kernel,开始执行?

跟踪Kernel的入口点PC(0x80200000),第一条Kernel指令,从这里开始执行。

  • 如何判断TinyEMU中,正在调试执行的Bootloader/Kernel的机器码,是否与预期一致?

比如:
我们跟踪,Bootloader的入口点PC(0x80000000),此时TinyEMU中依次取到的每一条指令,预期应该是riscv-pk\machine\mentry.S文件中reset_vector处,顺序向下的一段指令。


此时,我们可以,查看mentry.S目标文件mentry.o的反汇编信息,得到如下的机器码,此时便可用反汇编信息,与TinyEMU中捕获的机器码,进行一一对比,就能看出是否一致(大部分是一致的,小部分指令反汇编后,可能有差异)。

查看mentry.o的反汇编信息:riscv64-unknown-linux-gnu-objdump -d mentry.o > mentry.txt
在这里插入图片描述

  • 18
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

百里杨

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

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

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

打赏作者

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

抵扣说明:

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

余额充值