Lab 4: RV64 用户态程序¶
1 实验目的¶
- 创建用户态进程,并设置
sstatus
来完成内核态转换至用户态。 - 正确设置用户进程的 用户态栈 和 内核态栈 ,并在异常处理时正确切换。
- 补充异常处理逻辑,完成指定的系统调用( SYS_WRITE, SYS_GETPID )功能。
2 实验环境¶
- Same as previous labs.
3 背景知识¶
3.0 前言¶
在 lab3 中,我们开启虚拟内存,这为进程间地址空间相互隔离打下了基础。之前的实验中我们只创建了内核线程,他们共用了地址空间 (共用一个 内核页表 swapper_pg_dir
)。在本次实验中我们将引入用户态进程。当启动用户模式应用程序时,内核将为该应用程序创建一个进程,为应用程序提供了专用虚拟地址空间等资源。因为应用程序的虚拟地址空间是私有的,所以一个应用程序无法更改属于另一个应用程序的数据。每个应用程序都是独立运行的,如果一个应用程序崩溃,其他应用程序和操作系统不会受到影响。同时,用户模式应用程序可访问的虚拟地址空间也受到限制,在用户模式下无法访问内核的虚拟地址,防止应用程序修改关键操作系统数据。当用户态程序需要访问关键资源的时候,可以通过系统调用来完成用户态程序与操作系统之间的互动。
3.1 User 模式基础介绍¶
处理器具有两种不同的模式:用户模式和内核模式。在内核模式下,执行代码对底层硬件具有完整且不受限制的访问权限,它可以执行任何 CPU 指令并引用任何内存地址。在用户模式下,执行代码无法直接访问硬件,必须委托给系统提供的接口才能访问硬件或内存。处理器根据处理器上运行的代码类型在两种模式之间切换。应用程序以用户模式运行,而核心操作系统组件以内核模式运行。
3.2 系统调用约定¶
系统调用是用户态应用程序请求内核服务的一种方式。在 RISC-V 中,我们使用 ecall
指令进行系统调用。当执行这条指令时处理器会提升特权模式,跳转到异常处理函数处理这条系统调用。
Linux 中 RISC-V 相关的系统调用可以在 include/uapi/asm-generic/unistd.h
中找到, syscall(2) 手册页上对RISC-V架构上的调用说明进行了总结,系统调用参数使用 a0 - a5 ,系统调用号使用 a7 , 系统调用的返回值会被保存到 a0, a1 中。
3.3 sstatus[SUM] PTE[U]¶
当页表项 PTE[U] 置 0 时,该页表项对应的内存页为内核页,运行在 U-Mode 下的代码 无法访问 。当页表项 PTE[U] 置 1 时,该页表项对应的内存页为用户页,运行在 S-Mode 下的代码 无法访问 。如果想让 S 特权级下的程序能够访问用户页,需要对 sstatus[SUM] 位置 1 。但是无论什么样的情况下,用户页中的指令对于 S-Mode 而言都是 无法执行 的。
3.4 用户态栈与内核态栈¶
当用户态程序在用户态运行时,其使用的栈为 用户态栈 ,当调用 SYSCALL时候,陷入内核处理时使用的栈为 内核态栈 ,因此需要区分用户态栈和内核态栈,并在异常处理的过程中需要对栈进行切换。
3.5 ELF 程序¶
ELF, short for Executable and Linkable Format. 是当今被广泛使用的应用程序格式。例如当我们运行 gcc <some-name>.c
后产生的 a.out
输出文件的格式就是 ELF。
$ cat hello.c
#include <stdio.h>
int main() {
printf("hello, world\n");
return 0;
}
$ gcc hello.c
$ file a.out
a.out: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=dd33139196142abd22542134c20d85c571a78b0c,
for GNU/Linux 3.2.0, not stripped
将程序封装成 ELF 格式的重要意义是,在其中可以包含如何将程序正确地加载入内存的 metadata,并且在运行时可以由 loader 来将动态链接在程序上的动态链接库(shared library)正确地从磁盘或内存中加载(load),以及,ELF 文件中包含的重定位信息可以让该程序继续和别的可重定位文件和库再次链接,构成新的可执行文件。
为了简化实验步骤,我们使用的是静态链接的程序,所以不会涉及链接动态链接库的内容。
4 实验步骤¶
4.1 准备工程¶
- 此次实验基于 lab4 同学所实现的代码进行。
- 需要修改
vmlinux.lds.S
,将用户态程序uapp
加载至.data
段。按如下修改:
...
.data : ALIGN(0x1000){
_sdata = .;
*(.sdata .sdata*)
*(.data .data.*)
_edata = .;
. = ALIGN(0x1000);
uapp_start = .;
*(.uapp .uapp*)
uapp_end = .;
. = ALIGN(0x1000);
} >ramv AT>ram
...
如果你要使用
uapp_start
这个符号,可以在代码里这样来声明它:extern char uapp_start[]
,这样就可以像一个字符数组一样来访问这块内存的内容。例如,程序的第一个字节就是uapp_start[0]
。
- 需要修改
defs.h
,在defs.h
添加 如下内容:
#define USER_START (0x0000000000000000) // user space start virtual address
#define USER_END (0x0000004000000000) // user space end virtual address
- 从
repo
同步以下文件和文件夹。并按照下面的位置来放置这些新文件。值得注意的是,我们在mm
中添加了buddy system
,但是也保证了原来调用的kalloc
和kfree
的兼容。你应该无需修改原先使用了kalloc
的相关代码,如果出现兼容性问题可以联系助教。为了减小大家的工作量,我们替大家实现了 Buddy System,大家可以直接使用这些函数来管理内存:
// 分配 page_cnt 个页的地址空间,返回分配内存的地址。保证分配的内存在虚拟地址和物理地址上都是连续的
uint64_t alloc_pages(uint64_t page_cnt);
// 相当于 alloc_pages(1);
uint64_t alloc_page();
// 释放从 addr 开始的之前按分配的内存
void free_pages(uint64_t addr);
.
├── arch
│ └── riscv
│ └── Makefile
│ └── include
│ └── mm.h
│ └── stdint.h
│ └── kernel
│ └── mm.c
├── include
│ └── elf.h (this is copied from newlib)
└── user
├── Makefile
├── getpid.c
├── link.lds
├── printf.c
├── start.S
├── stddef.h
├── stdio.h
├── syscall.h
└── uapp.S
- 修改 根目录 下的 Makefile, 将
user
纳入工程管理。 - 在根目录下
make
会生成user/uapp.o
user/uapp.elf
user/uapp.bin
,以及我们最终测试使用的 ELF 可执行文件user/uapp
。 通过objdump
我们可以看到 uapp 使用 ecall 来调用 SYSCALL (在 U-Mode 下使用 ecall 会触发environment-call-from-U-mode异常)。从而将控制权交给处在 S-Mode 的 OS, 由内核来处理相关异常。 - 在本次实验中,我们首先会将用户态程序 strip 成纯二进制文件来运行。这种情况下,用户程序运行的第一条指令位于二进制文件的开始位置, 也就是说
uapp_start
处的指令就是我们要执行的第一条指令。我们将运行纯二进制文件作为第一步,在确认用户态的纯二进制文件能够运行后,我们再将存储到内存中的用户程序文件换为 ELF 来进行执行。
0000000000000004 <getpid>:
4: fe010113 addi sp,sp,-32
8: 00813c23 sd s0,24(sp)
c: 02010413 addi s0,sp,32
10: fe843783 ld a5,-24(s0)
14: 0ac00893 li a7,172
18: 00000073 ecall <- SYS_GETPID
...
00000000000000d8 <vprintfmt>:
...
60c: 00070513 mv a0,a4
610: 00068593 mv a1,a3
614: 00060613 mv a2,a2
618: 00000073 ecall <- SYS_WRITE
...
4.2 创建用户态进程¶
- 本次实验只需要创建 4 个用户态进程,修改
proc.h
中的NR_TASKS
即可。 - 由于创建用户态进程要对
sepc
sstatus
sscratch
做设置,我们将其加入thread_struct
中。 - 由于多个用户态进程需要保证相对隔离,因此不可以共用页表。我们为每个用户态进程都创建一个页表。修改
task_struct
如下。
// proc.h
typedef unsigned long* pagetable_t;
struct thread_struct {
uint64_t ra;
uint64_t sp;
uint64_t s[12];
uint64_t sepc, sstatus, sscratch;
};
struct task_struct {
struct thread_info* thread_info;
uint64_t state;
uint64_t counter;
uint64_t priority;
uint64_t pid;
struct thread_struct thread;
pagetable_t pgd;
};
Warning: 经测试实现中并不需要
thread_info
这个成员,可以考虑不使用这个成员,或者将其删除,并更改switch_to
中各个变量的偏移量,让我们的 OS 保持原来的行为。
- 修改 task_init
- 对每个用户态进程,其拥有两个 stack:
U-Mode Stack
以及S-Mode Stack
, 其中S-Mode Stack
在lab3
中我们已经设置好了。我们可以通过alloc_page
接口申请一个空的页面来作为U-Mode Stack
。 - 为每个用户态进程创建自己的页表 并将
uapp
所在页面,以及U-Mode Stack
做相应的映射,同时为了避免U-Mode
和S-Mode
切换的时候切换页表,我们也将内核页表 (swapper_pg_dir
) 复制到每个进程的页表中。注意程序运行过程中,有部分数据不在栈上,而在初始化的过程中就已经被分配了空间(比如我们的uapp
中的counter
变量),所以二进制文件需要先被 拷贝 到一块某个进程专用的内存之后再进行映射,防止所有的进程共享数据,造成期望外的进程间相互影响。 - 对每个用户态进程我们需要将
sepc
修改为USER_START
,配置修改好sstatus
中的SPP
( 使得 sret 返回至 U-Mode ),SPIE
( sret 之后开启中断 ),SUM
( S-Mode 可以访问 User 页面 ),sscratch
设置为U-Mode
的 sp,其值为USER_END
(即U-Mode Stack
被放置在user space
的最后一个页面)。 - 修改 __switch_to, 需要加入 保存/恢复
sepc
sstatus
sscratch
以及 切换页表的逻辑。 - 在切换了页表之后,需要通过
fence.i
和vma.fence
来刷新 TLB 和 ICache。
PHY_START PHY_END
new allocated memory allocated space end
│ │ │ │
▼ ▼ ▼ ▼
┌───────────┬─────────┬────────────────────────────────┬─────────────────────────────────────────────────┐
PA │ │ │ uapp (copied from uapp_start) │ │
└───────────┴─────────┴────────────────────────────────┴─────────────────────────────────────────────────┘
▲ ▲
┌─────────────────────┘ │
│ (map) │
│ ┌─────────────────────────────┘
│ │
│ │
├────────────────────────┼───────────────────────────────────────────────────────────────────┬────────────┐
VA │ UAPP │ │u mode stack│
└────────────────────────┴───────────────────────────────────────────────────────────────────┴────────────┘
▲ ▲
│ │
USER_START USER_END
4.3 修改中断入口/返回逻辑 ( _trap ) 以及中断处理函数 ( trap_handler )¶
-
与 ARM 架构不同的是,RISC-V 中只有一个栈指针寄存器( sp ),因此需要我们来完成用户栈与内核栈的切换。
-
由于我们的用户态进程运行在
U-Mode
下, 使用的运行栈也是U-Mode Stack
, 因此当触发异常时, 我们首先要对栈进行切换 (U-Mode Stack
->S-Mode Stack
)。同理 让我们完成了异常处理, 从S-Mode
返回至U-Mode
, 也需要进行栈切换 (S-Mode Stack
->U-Mode Stack
)。 -
修改
__dummy
。在 4.2 中 我们初始化时,thread_struct.sp
保存了S-Mode sp
,thread_struct.sscratch
保存了U-Mode sp
, 因此在S-Mode -> U->Mode
的时候,我们只需要交换对应的寄存器的值即可。 -
修改
_trap
。同理 在_trap
的首尾我们都需要做类似的操作。注意如果是 内核线程( 没有 U-Mode Stack ) 触发了异常,则不需要进行切换。(内核线程的 sp 永远指向的 S-Mode Stack, sscratch 为 0) -
uapp
使用ecall
会产生ECALL_FROM_U_MODE
exception。因此我们需要在trap_handler
里面进行捕获。修改trap_handler
如下:
void trap_handler(uint64_t scause, uint64_t sepc, struct pt_regs *regs) {
...
}
这里需要解释新增加的第三个参数 regs
, 在 _trap 中我们将寄存器的内容 连续 的保存在 S-Mode Stack上, 因此我们可以将这一段看做一个叫做 pt_regs
的结构体。我们可以从这个结构体中取到相应的寄存器的值( 比如 syscall 中我们需要从 a0 ~ a7 寄存器中取到参数 )。这个结构体中的值也可以按需添加,同时需要在 _trap
中存入对应的寄存器值以供使用, 示例如下图:
High Addr ───► ┌─────────────┐
│ sstatus │
│ │
│ sepc │
│ │
│ x31 │
│ │
│ . │
│ . │
│ . │
│ │
│ x1 │
│ │
│ x0 │
sp (pt_regs) ──► ├─────────────┤
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
Low Addr ───► └─────────────┘
请同学自己补充 struct pt_regs
的定义, 以及在 trap_handler
中补充处理 SYSCALL 的逻辑。
4.4 添加系统调用¶
- 本次实验要求的系统调用函数原型以及具体功能如下:
- 64 号系统调用
sys_write(unsigned int fd, const char* buf, size_t count)
该调用将用户态传递的字符串打印到屏幕上,此处fd为标准输出(1),buf为用户需要打印的起始地址,count为字符串长度,返回打印的字符数。( 具体见 user/printf.c ) - 172 号系统调用
sys_getpid()
该调用从current中获取当前的pid放入a0中返回,无参数。( 具体见 user/getpid.c ) - 增加
syscall.c
syscall.h
文件, 并在其中实现getpid
以及write
逻辑。 - 系统调用的返回参数放置在
a0
中 (不可以直接修改寄存器, 应该修改 regs 中保存的内容)。 - 针对系统调用这一类异常, 我们需要手动将
sepc + 4
(sepc
记录的是触发异常的指令地址, 由于系统调用这类异常处理完成之后, 我们应该继续执行后续的指令,因此需要我们手动修改sepc
的地址,使得sret
之后 程序继续执行)。
4.5 修改 head.S 以及 start_kernel¶
- 在之前的 lab 中, 在 OS boot 之后,我们需要等待一个时间片,才会进行调度。我们现在更改为 OS boot 完成之后立即调度 uapp 运行。
- 在 start_kernel 中调用 schedule() 注意放置在 test() 之前。
- 将 head.S 中 enable interrupt sstatus.SIE 逻辑注释,确保 schedule 过程不受中断影响。
4.6 测试纯二进制文件¶
-
由于加入了一些新的 .c 文件,可能需要修改一些Makefile文件,请同学自己尝试修改,使项目可以编译并运行。
-
输出示例
OpenSBI v0.9
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|
...
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000b109
...mm_init done!
...proc_init done!
[S-MODE] Hello RISC-V
[U-MODE] pid: 4, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 3, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 2, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 1, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 4, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 3, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 2, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 1, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 4, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 3, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 2, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 1, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 4, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 3, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 2, sp is 0000003fffffffe0, this is print No.*
[U-MODE] pid: 1, sp is 0000003fffffffe0, this is print No.*
...
4.7 添加 ELF 支持¶
ELF Header¶
ELF 文件中包含了将程序加载到内存所需的信息。当我们通过 readelf
来查看一个 ELF 可执行文件的时候,我们可以读到被包含在 ELF Header 中的信息:
$ readelf -a uapp
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x100e8
Start of program headers: 64 (bytes into file)
Start of section headers: 3200 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 3
Size of section headers: 64 (bytes)
Number of section headers: 9
Section header string table index: 8
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 00000000000100e8 000000e8
00000000000006fc 0000000000000000 AX 0 0 4
[ 2] .rodata PROGBITS 00000000000107e8 000007e8
0000000000000078 0000000000000000 A 0 0 8
[ 3] .bss NOBITS 0000000000011860 00000860
00000000000003f0 0000000000000000 WA 0 0 8
[ 4] .comment PROGBITS 0000000000000000 00000860
000000000000002b 0000000000000001 MS 0 0 1
[ 5] .riscv.attributes RISCV_ATTRIBUTE 0000000000000000 0000088b
000000000000002e 0000000000000000 0 0 1
[ 6] .symtab SYMTAB 0000000000000000 000008c0
00000000000002d0 0000000000000018 7 17 8
[ 7] .strtab STRTAB 0000000000000000 00000b90
00000000000000a1 0000000000000000 0 0 1
[ 8] .shstrtab STRTAB 0000000000000000 00000c31
0000000000000049 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), p (processor specific)
There are no section groups in this file.
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
RISCV_ATTRIBUT 0x000000000000088b 0x0000000000000000 0x0000000000000000
0x000000000000002e 0x0000000000000000 R 0x1
LOAD 0x00000000000000e8 0x00000000000100e8 0x00000000000100e8
0x0000000000000778 0x0000000000001b68 RWE 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
Section to Segment mapping:
Segment Sections...
00 .riscv.attributes
01 .text .rodata .bss
02
There is no dynamic section in this file.
There are no relocations in this file.
The decoding of unwind sections for machine type RISC-V is not currently supported.
Symbol table '.symtab' contains 30 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000000100e8 0 SECTION LOCAL DEFAULT 1 .text
2: 00000000000107e8 0 SECTION LOCAL DEFAULT 2 .rodata
3: 0000000000011860 0 SECTION LOCAL DEFAULT 3 .bss
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .comment
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .riscv.attributes
6: 0000000000000000 0 FILE LOCAL DEFAULT ABS start.o
7: 00000000000100e8 0 NOTYPE LOCAL DEFAULT 1 $x
8: 0000000000000000 0 FILE LOCAL DEFAULT ABS getpid.c
9: 00000000000100ec 52 FUNC LOCAL DEFAULT 1 getpid
10: 00000000000100ec 0 NOTYPE LOCAL DEFAULT 1 $x
11: 0000000000010120 0 NOTYPE LOCAL DEFAULT 1 $x
12: 0000000000000000 0 FILE LOCAL DEFAULT ABS printf.c
13: 000000000001017c 0 NOTYPE LOCAL DEFAULT 1 $x
14: 00000000000101d4 1412 FUNC LOCAL DEFAULT 1 vprintfmt
15: 00000000000101d4 0 NOTYPE LOCAL DEFAULT 1 $x
16: 0000000000010758 0 NOTYPE LOCAL DEFAULT 1 $x
17: 0000000000010758 140 FUNC GLOBAL DEFAULT 1 printf
18: 0000000000012060 0 NOTYPE GLOBAL DEFAULT ABS __global_pointer$
19: 0000000000011860 0 NOTYPE GLOBAL DEFAULT 2 __SDATA_BEGIN__
20: 0000000000011860 4 OBJECT GLOBAL DEFAULT 3 tail
21: 00000000000100e8 0 NOTYPE GLOBAL DEFAULT 1 _start
22: 0000000000011868 1000 OBJECT GLOBAL DEFAULT 3 buffer
23: 0000000000011c50 0 NOTYPE GLOBAL DEFAULT 3 __BSS_END__
24: 0000000000011860 0 NOTYPE GLOBAL DEFAULT 3 __bss_start
25: 0000000000010120 92 FUNC GLOBAL DEFAULT 1 main
26: 000000000001017c 88 FUNC GLOBAL DEFAULT 1 putc
27: 0000000000011860 0 NOTYPE GLOBAL DEFAULT 2 __DATA_BEGIN__
28: 0000000000011860 0 NOTYPE GLOBAL DEFAULT 2 _edata
29: 0000000000011c50 0 NOTYPE GLOBAL DEFAULT 3 _end
No version information found in this file.
Attribute Section: riscv
File Attributes
Tag_RISCV_arch: "rv64i2p0_m2p0_a2p0_f2p0_d2p0"
其中包含了两种将程序分块的粒度,Segment(段)和 Section(节),我们以段为粒度将程序加载进内存中。可以看到,给出的样例程序包含了三个段,这里我们只关注 Type 为 LOAD 的段,LOAD 表示他们需要在开始运行前被加载进内存中,这是我们在初始化进程的时候需要执行的工作。
而“节”代表了更细分的语义,比如 .text
一般包含了程序的指令,.rodata
是只读的全局变量等,大家可以自行 Google 来学习更多相关内容。
所以代码怎么写?¶
首先我们需要将 uapp.S
中的 payload 给换成我们的 ELF 文件。
/* user/uapp.S */
.section .uapp
.incbin "uapp"
这时候从 uapp_start
开始的数据就变成了名为 uapp
的 ELF 文件,也就是说 uapp_start
处 32-bit 的数据不再是我们需要执行第一条指令了,而是 ELF Header 的开始。
这时候就需要你对 task_init
中的初始化步骤进行修改。我们给出了 ELF 相关的结构体定义(elf.h
),大家可以直接使用。你可能会使用到的结构体或者域如下:
Elf64_Ehdr // 你可以将 uapp_start 强制转化为改类型的指针,
然后把那一块内存当成此类结构体来读其中的数据,其中包括:
e_ident // Magic Number, 你可以通过这个域来检测自己是不是真的正在读一个 Ehdr,
值一定是 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
e_entry // 程序的第一条指令被存储的用户态虚拟地址
e_phnum // ELF 文件包含的 Segment 的数量
e_phoff // ELF 文件包含的 Segment 数组相对于 Ehdr 的偏移量
Elf64_Phdr // 存储了程序各个 Segment 相关的 metadata
// 你可以将 uapp_start + e_phoff 强制转化为此类型,就会指向第一个 Phdr,
// uapp_start + e_phoff + 1 * sizeof(Elf64_Phdr), 则指向第二个‘
p_filesz // Segment 在文件中占的大小
p_memsz // Segment 在内存中占的大小
p_vaddr // Segment 起始的用户态虚拟地址
p_offset // Segment 在文件中相对于 Ehdr 的偏移量
p_type // Segment 的类型
p_flags // Segment 的权限(包括了读、写和执行)
我们可以按照这些信息,在从 uapp_start
- uapp_end
这个 ELF 文件中的内容 拷贝 到我们开辟的内存中。
其中相对文件偏移 p_offset
指出相应 segment 的内容从 ELF 文件的第 p_offset
字节开始, 在文件中的大小为 p_filesz
, 它需要被分配到以 p_vaddr
为首地址的虚拟内存位置, 在内存中它占用大小为 p_memsz
. 也就是说, 这个 segment 使用的内存就是 [p_vaddr, p_vaddr + p_memsz)
这一连续区间, 然后将 segment 的内容从ELF文件中读入到这一内存区间, 并将 [p_vaddr + p_filesz, p_vaddr + p_memsz)
对应的物理区间清零. (本段内容引用自南京大学PA)
你也可以参考这篇 blog 中关于 静态 链接程序的载入过程来进行你的载入。
这里有不少例子可以举,为了避免同学们在实验中花太多时间,我们告诉大家可以怎么找到实验中这些相关变量被存在了哪里:(注意以下的 uapp_start
类型使用的是 char*
,如果你在使用其他类型,需要根据你使用的类型去调整针对指针的算数运算。)
Elf64_Ehdr* ehdr = (Elf64_Ehdr*)uapp_start
,从地址 uapp_start 开始,便是我们要找的 Ehdr.Elf64_Phdr* phdrs = (Elf64_Phdr*)(uapp_start + ehdr->phoff)
,是一个 Phdr 数组,其中的每个元素都是一个Elf64_Phdr
.phdrs[ehdr->phnum - 1]
是最后一个 Phdr.phdrs[0].p_type == PT_LOAD
,说明这个 Segment 的类型是 LOAD,需要在初始化时被加载进内存。(void*)(uapp_start + phdrs[1].p_offset)
将会指向第二个段中的内容的开头.
剩下的域的用法我们希望同学们通过阅读 man elf
命令和使用 Google(注意不是百度)来获取。大家可以参考以下的代码来将程序 load 进入内存。
static uint64_t load_program(struct task_struct* task) {
Elf64_Ehdr* ehdr = (Elf64_Ehdr*)uapp_start;
uint64_t phdr_start = (uint64_t)ehdr + ehdr->e_phoff;
int phdr_cnt = ehdr->e_phnum;
Elf64_Phdr* phdr;
int load_phdr_cnt = 0;
for (int i = 0; i < phdr_cnt; i++) {
phdr = (Elf64_Phdr*)(phdr_start + sizeof(Elf64_Phdr) * i);
if (phdr->p_type == PT_LOAD) {
// alloc space and copy content
// do mapping
// code...
}
}
// allocate user stack and do mapping
// code...
// following code has been written for you
// set user stack
...;
// pc for the user program
task->thread.sepc = ehdr->e_entry;
// sstatus bits set
task->thread.sstatus = ...;
// user stack for user program
task->thread.sscratch = ...;
}
5. 思考题¶
- 我们在实验中使用的用户态线程和内核态线程的对应关系是怎样的?(一对一,一对多,多对一还是多对多)
- 为什么 Phdr 中,
p_filesz
和p_memsz
是不一样大的? - 为什么多个进程的栈虚拟地址可以是相同的?用户有没有常规的方法知道自己栈所在的物理地址?
作业提交¶
同学需要提交实验报告以及整个工程代码。在提交前请使用 make clean
清除所有构建产物。