You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

24 KiB

第二章实验1非法指令的截获

2.1 实验环境搭建

实验环境我们推荐采用Ubuntu 16.04LTS或18.04LTSx86_64操作系统我们未在其他系统如archRHEL等上做过测试但理论上只要将实验中所涉及到的安装包替换成其他系统中的等效软件包就可完成同样效果。另外我们在EduCoder实验平台网址https://www.educoder.net 上创建了本书的同步课程课程的终端环境中已完成实验所需软件工具的安装所以如果读者是在EduCoder平台上选择的本课程则可跳过本节的实验环境搭建过程直接进入通过终端命令行进入实验环境。

PKE实验涉及到的软件工具有RISC-V交叉编译器、spike模拟器以及PKE源代码三个部分。假设读者拥有了Ubuntu 16.04LTS或18.04LTSx86_64操作系统的环境以下分别介绍这三个部分的安装以及安装后的检验过程。需要说明的是为了避免耗时耗资源的构建build过程一个可能的方案是从https://toolchains.bootlin.com 下载,但是要注意一些依赖包如GCC的版本号

我们强烈建议读者在新装环境中完整构建buildRISC-V交叉编译器以及spike模拟器。如果强调环境的可移植性,可以考虑在虚拟机中安装完整系统和环境,之后将虚拟机进行克隆和迁移。

2.1.1 RISC-V交叉编译器

RISC-V交叉编译器是与Linux自带的GCC编译器类似的一套工具软件集合不同的是x86_64平台上Linux自带的GCC编译器会将源代码编译、链接成为适合在x86_64平台上运行的二进制代码称为native code而RISC-V交叉编译器则会将源代码编译、链接成为在RISC-V平台上运行的代码。后者RISC-V交叉编译器生成的二进制代码是无法在x86_64平台即x86_64架构的Ubuntu环境下直接运行的它的运行需要模拟器我们采用的spike的支持。

一般情况下我们称x86_64架构的Ubuntu环境为host而在host上执行spike后所虚拟出来的RISC-V环境则被称为target。RISC-V交叉编译器的构建build、安装过程如下

● 第一步,安装依赖库

RISC-V交叉编译器的构建需要一些本地支撑软件包可使用以下命令安装

$ sudo apt-get install autoconf automake autotools-dev curl libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat-dev device-tree-compiler

● 第二步获取RISC-V交叉编译器的源代码

有两种方式获得RISC-V交叉编译器的源代码一种是通过源代码仓库获取使用以下命令

$ git clone --recursive https://github.com/riscv/riscv-gnu-toolchain.git

但由于RISC-V交叉编译器的仓库包含了Qemu模拟器的代码下载后的目录占用的磁盘空间大小约为4.8GB,(从国内下载)整体下载所需的时间较长。为了方便国内用户,我们提供了另一种方式就是通过百度云盘获取源代码压缩包,链接和提取码如下:

链接: https://pan.baidu.com/s/1cMGt0zWhRidnw7vNUGcZhg 提取码: qbjh

从百度云盘下载RISCV-packages/riscv-gnu-toolchain-clean.tar.gz文件大小为2.7GB再在Ubuntu环境下解压这个.tar.gz文件采用如下命令行

$ tar xf riscv-gnu-toolchain-clean.tar.gz

之后就能够看到和进入当前目录下的riscv-gnu-toolchain文件夹了。

● 第三步构建buildRISC-V交叉编译器

$ cd riscv-gnu-toolchain

$ ./configure --prefix=[your.RISCV.install.path]

$ make

$ make install

以上命令中,[your.RISCV.install.path]指向的是你的RISC-V交叉编译器安装目录。如果安装是你home目录下的一个子目录如~/riscv-install-dir则最后的make install无需sudoer权限。但如果安装目录是系统目录如/opt/riscv-install-dir则需要sudoer权限即在make install命令前加上sudo

● 第四步,设置环境变量

$ export RISCV=[your.RISCV.install.path]

$ export PATH=$PATH:$RISCV/bin

以上命令设置了RISCV环境变量指向在第三步中的安装目录并且将交叉编译器的可执行文件所在的目录加入到了系统路径中。这样我们就可以在PKE的工作目录调用RISC-V交叉编译器所包含的工具软件了。这时你也可以在任意目录编写helloworld.c文件并使用以下命令测试下安装是否成功

$ riscv64-unknown-elf-gcc helloworld.c -o helloworld

若编译成功当前的目录下会出现名为helloworld的elf文件。

2.1.2 spike模拟器

接下来安装spkie模拟器。首先取得spike的源代码有两个途径一个是从github代码仓库中获取

$ git clone https://github.com/riscv/riscv-isa-sim.git

也可以从百度云盘中下载spike-riscv-isa-sim.tar.gz文件约4.2MB然后用tar命令解压缩。百度云盘的地址以及tar命令解压缩可以参考2.1.1节RISC-V交叉编译器的安装过程。获得spike源代码或解压后将在本地目录看到一个riscv-isa-sim目录。

接下来构建buildspike并安装

$ cd riscv-isa-sim

$ ./configure --prefix=$RISCV

$ make

$ make install

在以上命令中我们假设RISCV环境变量已经指向了RISC-V交叉编译器的安装目录。如果未建立关联可以将$RISCV替换为2.1.1节中的[your.RISCV.install.path]。

2.1.3 PKE

到github下载课程仓库

$ git clone https://github.com/MrShawCode/pke.git

克隆完成后将在当前目录看到pke目录。这时可以到pke目录下查看pke的代码结构例如

$ cd pke

$ ls

你可以看到当前目录下有如下(部分)内容

.

├── app

├── gradelib.py

├── machine

├── Makefile

├── pk

├── pke-lab1

├── pke.lds

└── util

● 首先是app目录里面存放的是实验的测试用例也就是运行在User模式的应用程序例如之前helloworld.c。

● gradelib.py、与pke-lab1是测试用的python脚本。

● machine目录里面存放的是机器模式相关的代码由于本课程的重点在于操作系统在这里你可以无需详细研究。

● Makefile文件它定义的整个工程的编译规则。

● pke.lds是工程的链接文件。

● util目录下是各模块会用到的工具函数。

● pk目录里面存放的是pke的主要代码。

即使未开始做PKE的实验我们的pke代码也是可构建的可以采用以下命令在pke目录下生成pke代理内核

$ make

以上命令完成后会在当前目录下会生产obj子目录里面就包含了我们的pke代理内核。pke代理内核的构建过程将在2.2节中详细讨论。

2.1.4 环境测试

全部安装完毕后你可以对环境进行测试在pke目录下输入

$ spike ./obj/pke ./app/elf/app1_2

将得到如下输出:

PKE IS RUNNING
user mode test illegal instruction!
you need add your code!

以上命令的作用是首先采用spike模拟一个RISC-V机器该机器支持RV64G指令集并在该机器上运行./app/elf/app1_2应用它的源代码在./app/app1_2.c中。我们知道应用是无法在“裸机”上运行的所以测试命令使用./obj/pke作为应用的代理内核。代理内核的作用是对spike模拟出来的RISC-V机器做简单的“包装”使其能够在spike模拟出来的机器上顺利运行。

这里,代理内核的作用是只为特定的应用服务(如本例中的./app/elf/app1_2应用所以可以做到“看菜吃饭”的效果。因为我们这里的应用非常简单所以pke就可以做得极其精简它没有文件系统、没有物理内存管理、没有进程调度、没有操作终端shell等等传统的完整操作系统“必须”具有的组件。在后续的实验中我们将不断提升应用的复杂度并不断完善代理内核。通过这个过程读者将深刻体会操作系统内核对应用支持的机制以及具体的实现细节。

2.2 实验内容

实验要求在用户模式APP里调用非法指令如S或M级别的指令或进行非法内存访问导致系统报错。如illegal instruction或者内存访问越界报警。

注意:以后的实验,要基于本实验,使得代理内核能够捕捉非法指令和内存访问。

2.2.1 练习一hello world

首先进入app目录下。我们先来编写一个简单的hello world程序我们编写helloworld.c源文件如下

 1 #include <stdio.h>
 2 int global_init=1;
 3 int global_uninit;
 4 int main(){
 5   int tmp;
 6   printf("hello world!\n");
 7   return 0;
 8 }

例2.1 helloworld.c

使用riscv64-unknown-elf-gcc编译该文件得到的ELF文件helloworld。

$riscv64-unknown-elf-gcc helloworld.c -o elf/helloworld

现在回到上一级目录使用pke来运行二进制文件

$spike obj/pke app/elf/helloworld

你可以得到以下输出:

PKE IS RUNNING
hello world!

2.2.2 练习二:中断入口探寻

CPU 运行到一些情况下会产生异常exception 例如访问无效的内存地址、执行非法指令除零、发生缺页等。用户程序进行系统调用syscall 或程序运行到断点breakpoint 时,也会主动触发异常。

当发生中断或异常时CPU 会立即跳转到一个预先设置好的地址执行中断处理程序最后恢复原程序的执行。这个地址。我们称为中断入口地址。在RISC-V中设有专门的CSR寄存器保存这个地址即stvec寄存器。

下面请你阅读pk/pk.c文件找出pk中设置中断入口函数的位置。

2.2.3 练习三:中断过程详究

中断的处理过程可以分为3步骤

  • 保存当前环境寄存器
  • 进入具体的中断异常处理函数
  • 恢复中断异常前环境的寄存器

pk中使用trapframe_t结构体pk/pk.h来保存中断发生时常用的32个寄存器及部分特殊寄存器的值其结构如下。

typedef struct
{
 long gpr[32]; 
 long status;
 long epc;
 long badvaddr;
 long cause;
 long insn;
} trapframe_t;

下面请阅读pk/entry.S详细分析同上述三个过程相对应的代码。

2.2.4 练习四:中断的具体处理(需要编程)

当中断异常发生后中断帧将会被传递给pk/handlers.c中的handle_trap函数。接着通过trapframe中的scause寄存器的值可以判断属于哪种中断异常从而选择相对应的中断处理函数。

在pk/handlers.c中的各种中断处理函数的实现其中segfault段错误的处理函数与illegal_instruction的处理函数并不完善。请你在pk/handlers.c中找到并完善segfault与handle_illegal_instruction两个函数。

提示:

当完成你的segfault代码后,重新make然后输入如下命令

$riscv64-unknown-elf-gcc ../app/app1_1.c -o ../app/elf/app1_1

$spike ./obj/pke app/elf/app1_1

预期的输出如下:

PKE IS RUNNING
APP: addr_u 0x7f7ecc00
APP: addr_m 0x8f000000
z 0000000000000000 ra 0000000000010192 sp 000000007f7ecb30 gp 000000000001da10
tp 0000000000000000 t0 0000000000000000 t1 000000007f7ec9f0 t2 0000219000080017
s0 000000007f7ecb50 s1 0000000000000000 a0 0000000000000017 a1 000000000001e220
a2 0000000000000017 a3 0000000000000000 a4 0000000000000001 a5 000000008f000000
a6 8080808080808080 a7 0000000000000040 s2 0000000000000000 s3 0000000000000000
s4 0000000000000000 s5 0000000000000000 s6 0000000000000000 s7 0000000000000000
s8 0000000000000000 s9 0000000000000000 sA 0000000000000000 sB 0000000000000000
t3 0000000000000000 t4 0000000000000078 t5 0000000000000000 t6 0000000000000000
pc 0000000000010198 va 000000008f000000 insn    ffffffff sr 8000000200046020
User store segfault @ 0x000000008f000000

接着当你完成handle_illegal_instruction函数后输入如下命令

$ riscv64-unknown-elf-gcc ../app/app1_2.c -o ../app/elf/app1_2

$ spike ./obj/pke app/elf/app1_2

预期的输出如下:

PKE IS RUNNING
user mode test illegal instruction!
z 0000000000000000 ra 0000000000010162 sp 000000007f7ecb40 gp 0000000000013de8
tp 0000000000000000 t0 8805000503e80001 t1 0000000000000007 t2 0000219000080017
s0 000000007f7ecb50 s1 0000000000000000 a0 000000000000000a a1 0000000000014600
a2 0000000000000024 a3 0000000000000000 a4 0000000000000000 a5 0000000000000001
a6 0000000000000003 a7 0000000000000040 s2 0000000000000000 s3 0000000000000000
s4 0000000000000000 s5 0000000000000000 s6 0000000000000000 s7 0000000000000000
s8 0000000000000000 s9 0000000000000000 sA 0000000000000000 sB 0000000000000000
t3 0000000000000000 t4 000000005f195e48 t5 0000000000000000 t6 0000000000000000
pc 0000000000010162 va 0000000014005073 insn    14005073 sr 8000000200046020
An illegal instruction was executed!

如果你的两个测试app都可以正确输出的话那么运行检查的python脚本

$./pke-lab1

若得到如下输出,那么恭喜你,你已经成功完成了实验一!!!

build pk : OK
running app1 : OK
 test1 : OK
running app2 : OK
 test2 : OK
Score: 30/30

2.2 基础知识

2.2.1 程序编译连接与ELF文件

ELF的全称为Executable and Linkable Format是一种可执行二进制文件。

在这里我们仅仅之需要简单的了解一下ELF文件的基本组成原理以便之后能够很好的理解内核可执行文件以及其它的一些ELF文件加载到内存的过程。首先ELF文件可以分为这样几个部分ELF文件头、程序头表program header table、节头表section header table和文件内容。而其中文件内容部分又可以分为这样的几个节.text节、.rodata节、.stab节、.stabstr节、.data节、.bss节、.comment节。如果我们把ELF文件看做是一个连续顺序存放的数据块则下图可以表明这样的一个文件的结构。

fig2_1

图2.1 ELF文件结构

从图2.1中可以看出ELF文件中需要读到内存的部分都集中在文件的中间下面我们首先就介绍一下中间的这几个节的具体含义

l .text节可执行指令的部分。

l .rodata节只读全局变量部分。

l .stab节符号表部分。

l .stabstr节符号表字符串部分具体的也会在第三章做详细的介绍。

l .data节可读可写的全局变量部分。

l .bss节未初始化的全局变量部分这一部分不会在磁盘有存储空间因为这些变量并没有被初始化因此全部默认为0于是在将这节装入到内存的时候程序需要为其分配相应大小的初始值为0的内存空间。

l .comment节注释部分这一部分不会被加载到内存。

结合刚才的hellowrold.c文件分析global_init作为初始化之后的全局变量, 存储在.data段中而global_uninit作为为初始化的全局变量存储在.bss段。函数中的临时变量tmp则不会被存储在ELF文件的数据段中。

在pke中ELF文件头结构的定义如下

typedef struct {
	uint8_t e_ident[16];   //ELF文件标识包含用以表示ELF文件的字符
	uint16_t e_type;     //文件类型
	uint16_t e_machine;    //体系结构信息 
	uint32_t e_version;    //版本信息
	uint64_t e_entry;     //程序入口点
	uint64_t e_phoff;     //程序头表偏移量
	uint64_t e_shoff;           //节头表偏移量
	uint32_t e_flags;      //处理器特定标志
	uint16_t e_ehsize;     //文件头长度
	uint16_t e_phentsize;    //程序头部长度
	uint16_t e_phnum;     //程序头部个数
	uint16_t e_shentsize;    //节头部长度
	uint16_t e_shnum;     //节头部个数
	uint16_t e_shstrndx;     //节头部字符索引
} Elf64_Ehdr;

ELF文件头比较重要的几个结构体成员是e_entry、e_phoff、e_phnum、e_shoff、e_shnum。其中e_entry是可执行程序的入口地址即从内存的这个闻之开始执行在这里入口地址是虚拟地址也就是链接地址e_phoff和e_phnum可以用来找到所有的程序头表项e_phoff是程序头表的第一项相对于ELF文件的开始位置的偏移而e_phnum则是表项的个数同理e_ shoff和e_ shnum可以用来找到所有的节头表项。

以例1.1为例我们可以使用riscv64-unknown-elf-objdump工具查看该ELF文件。

首先,使用-x选项查看显示整体的头部内容。

$riscv64-unknown-elf-objdump -x hellowrold >> helloworld.txt

得到的输入文件helloworld.txt的主要内容如下

hellowrold:   file format elf64-littleriscv
architecture: riscv:rv64, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x00000000000100c2

Program Header:
  LOAD off  0x0000000000000000 vaddr 0x0000000000010000 paddr 0x0000000000010000 align 2**12 filesz 0x000000000000258a memsz 0x000000000000258a flags r-x
  LOAD off  0x000000000000258c vaddr 0x000000000001358c paddr 0x000000000001358c align 2**12 filesz 0x0000000000000fb4 memsz 0x000000000000103c flags rw-
hellowrold:   file format elf64-littleriscv

Sections:
Idx Name     Size   VMA        LMA        File off Algn
 0 .text     000024cc 00000000000100b0 00000000000100b0 000000b0 2**1
         CONTENTS, ALLOC, LOAD, READONLY, CODE
 1 .rodata    0000000a 0000000000012580 0000000000012580 00002580 2**3
         CONTENTS, ALLOC, LOAD, READONLY, DATA
 2 .eh_frame   00000004 000000000001358c 000000000001358c 0000258c 2**2
         CONTENTS, ALLOC, LOAD, DATA
 3 .init_array  00000010 0000000000013590 0000000000013590 00002590 2**3
         CONTENTS, ALLOC, LOAD, DATA
 4 .fini_array  00000008 00000000000135a0 00000000000135a0 000025a0 2**3
         CONTENTS, ALLOC, LOAD, DATA
 5 .data     00000f58 00000000000135a8 00000000000135a8 000025a8 2**3
         CONTENTS, ALLOC, LOAD, DATA
 6 .sdata    00000040 0000000000014500 0000000000014500 00003500 2**3
         CONTENTS, ALLOC, LOAD, DATA
 7 .sbss     00000020 0000000000014540 0000000000014540 00003540 2**3
         ALLOC
 8 .bss     00000068 0000000000014560 0000000000014560 00003540 2**3
         ALLOC
 9 .comment   00000011 0000000000000000 0000000000000000 00003540 2**0
         CONTENTS, READONLY
 10 .riscv.attributes 00000035 0000000000000000 0000000000000000 00003551 2**0
         CONTENTS, READONLY

可以看到解析出来的文件结构与图2.1的结构相对应。其中值得注意的是,.bss节与.comment节在文件中的偏移是一样的这就说明.bss在硬盘中式不占用空间的仅仅只是记载了它的长度。

Program Header程序头表实际上是将文件的内容分成了好几个段而每个表项就代表了一个段这里的段是不同于之前节的概念有可能就是同时几个节包含在同一个段里。程序头表项的数据结构如下所示

typedef struct {
 uint32_t p_type;  //段类型
 uint32_t p_flags;  //段标志
 uint64_t p_offset;  //段相对于文件开始处的偏移量
 uint64_t p_vaddr;  //段在内存中地址(虚拟地址)
 uint64_t p_paddr;  //段的物理地址
 uint64_t p_filesz;  //段在文件中的长度
 uint64_t p_memsz; //段在内存中的长度
 uint64_t p_align;  //段在内存中的对齐标志
} Elf64_Phdr;

下面我们通过一个图来看看用ELF文件头与程序头表项如何找到文件的第i段。

fig2_2

图2.2 找到文件第i段的过程

Sections而另一个节头表的功能则是让程序能够找到特定的某一节其中节头表项的数据结构如下所示

typedef struct {
 uint32_t sh_name;        //节名称
 uint32_t sh_type;         //节类型
 uint64_t sh_flags;        //节标志
 uint64_t sh_addr;        //节在内存中的虚拟地址
 uint64_t sh_offset;       //相对于文件首部的偏移
 uint64_t sh_size;       //节大小
 uint32_t sh_link;       //与其他节的关系
 uint32_t sh_info;       //其他信息
 uint64_t sh_addralign;    //字节对齐标志
 uint64_t sh_entsize;      //表项大小
} Elf64_Shdr;

而通过ELF文件头与节头表找到文件的某一节的方式和之前所说的找到某一段的方式是类似的。

2.2.2 代理内核与应用程序的加载

阅读pke.lds文件可以看到整个PK程序的入口为reset_vector函数

3   OUTPUT_ARCH( "riscv" )
4
5   ENTRY( reset_vector )

我们在machine/mentry.S中找的这个符号。

36   reset_vector:
37  j do_reset

首先初始化x0~x31共32个寄存器其中x10a0寄存器与x11a1寄存器存储着从之前boot loader中传来的参数而不复位。

223     do_reset:
224     li x1, 0
  .....
255     li x31, 0

将mscratch寄存器置0

256     csrw mscratch, x0

将trap_vector的地址写入t0寄存器trap_vector是mechine模式下异常处理的入口地址。再将t0的值写入mtvec寄存器中。然后读取mtvec寄存器中的地址到t1寄存器。比较t0于t1。

259     la t0, trap_vector
260     mtvec, t0
261   	rr t1, mtvec
262     1:bne t0, t1, 1b

正常情况下t1自然是的等于t0的于是程序顺序执行将栈地址写入sp寄存器中

264     la sp, stacks + RISCV_PGSIZE - MENTRY_FRAME_SIZE

读取mhartid到a3寄存器调整sp

266     csrr a3, mhartid
267     slli a2, a3, RISCV_PGSHIFT
268    add sp, sp, a2

当a3不等于0时跳转到 init_first_hart

270      # Boot on the first hart
271     beqz a3, init_first_hart

此时进入"machine/minit.c"文件在init_first_hart中对外设进行初始化

154      void init_first_hart(uintptr_t hartid, uintptr_t dtb)
155     {
        …… //初始化外设
180         boot_loader(dtb);
181     }

在init_first_hart的最后一行调用boot_loader函数

160 void boot_loader(uintptr_t dtb)
161 {
     …….   //CSR寄存器设置
169      enter_supervisor_mode(rest_of_boot_loader, pk_vm_init(), 0);
170  }

在boot_loader中经历设置中断入口地址清零sscratch寄存器关中断等一系列操作后。最后会调用enter_supervisor_mode函数正式切换至Supervisor模式。

204     void enter_supervisor_mode(void (*fn)(uintptr_t), uintptr_t arg0, uintptr_t arg1)
205   {
206         uintptr_t mstatus = read_csr(mstatus);
207      mstatus = INSERT_FIELD(mstatus, MSTATUS_MPP, PRV_S);
208      mstatus = INSERT_FIELD(mstatus, MSTATUS_MPIE, 0);
209         write_csr(mstatus, mstatus);
210      write_csr(mscratch, MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE);
211        #ifndef __riscv_flen
212      uintptr_t *p_fcsr = MACHINE_STACK_TOP() - MENTRY_FRAME_SIZE; // the x0's save slot
213      *p_fcsr = 0;
214       #endif
215      write_csr(mepc, fn);
216
217         register uintptr_t a0 asm ("a0") = arg0;
218         register uintptr_t a1 asm ("a1") = arg1;
219       asm volatile ("mret" : : "r" (a0), "r" (a1));
220      __builtin_unreachable();
221     }

在enter_supervisor_mode函数中将 mstatus的MPP域设置为1表示中断发生之前的模式是Superior将mstatus的MPIE域设置为0表示中段发生前MIE的值为0。随机将机器模式的内核栈顶写入mscratch寄存器中设置mepc为rest_of_boot_loader的地址并将kernel_stack_top与0作为参数存入a0和a1。

最后执行mret指令该指令执行时程序从机器模式的异常返回将程序计数器pc设置为mepc即rest_of_boot_loader的地址将特权级设置为mstatus寄存器的MPP域即方才所设置的代表Superior的1MPP设置为0将mstatus寄存器的MIE域设置为MPIE即方才所设置的表示中断关闭的0MPIE设置为1。

于是当mret指令执行完毕程序将从rest_of_boot_loader继续执行。

144     static void rest_of_boot_loader(uintptr_t kstack_top)
145     {
146      arg_buf args;
147      size_t argc = parse_args(&args);
148      if (!argc)
149       panic("tell me what ELF to load!");
150
151      // load program named by argv[0]
152      long phdrs[128];
153      current.phdr = (uintptr_t)phdrs;
154      current.phdr_size = sizeof(phdrs);
155      load_elf(args.argv[0], &current);
156
157      run_loaded_program(argc, args.argv, kstack_top);
158     }

这个函数中我们对应用程序的ELF文件进行解析并且最终运行应用程序。