<0x01> 任务说明
这里将实现pintos的用户程序的执行
参数传递
process_execute函数可以在pintos中创建新的用户进程
当前,这个函数还不支持参数的传递
比必须实现参数传递,让用户程序的main中argc和argv可以访问参数
举例来说,如果执行process_execute("ls -ahl")
在用户程序的argc的值是2,argv的值是["ls", "-ahl"]
因为大部分的测试会先打印自己的测试名
这会访问argv[0]
但如果没有实现这个部分,这些测试就会在访问argv[0]时崩溃
所以在实现这个部分前,大部分测试都会直接崩溃
进程控制系统调用
目前,pintos只支持exit这个系统调用
这个调用会终止发起调用的进程
还需要实现practice、halt、exec、wait这几个系统调用
实现指南
要实现这写系统调用,首先需要安全地读写用户虚拟地址空间地内存
系统调用的参数都在用户进程栈中
在处理非法指针与空指针时不能让内核崩溃
另外的,一些系统调用的参数由指针指向另一块内存区域
这个内存区域也可能会是非法的
尽量避免为实现系统调用编写大量重复的代码
每个系统调用参数(无论是整数还是指针)在堆栈上占用 4 个字节
应该能够利用这一点,避免编写大量几乎相同的代码
以便从堆栈中检索每个系统调用的参数。
需要优雅地处理无效内存访问而无法完成系统调用的情况
比如空指针、无效指针(例如指向未映射内存)和非法指针(例如指向内核内存)
注意:如果内存位于页边界上
那么4字节内存区域(例如32位整数)可能由2字节的有效内存和2字节的无效内存组成
这时候应当终止用户进程
我们建议在实现任何其他系统调用功能之前测试代码的这一部分
这部分的系统调用
practice
int practice (int i)
这个系统调用是虚构的,主要是用来熟悉系统调用处理方法的
作用是传入一个数,返回这个数+1
halt
void halt (void)
通过调用device/shutdown.h中的shutdown_power_off函数来终止系统
实际上很少使用
exit
void exit (int status)
终止当前的用户程序,然后将状态返回给内核
当用户程序退出时,还需要打印用户程序的退出状态
格式为(进程名): exit(退出状态)
exec
pid_t exec (const char *cmd_line)
运行cmd_line的可执行文件,并返回新进程的pid
如果没法执行就返回-1
由于父进程实际上是不知道子进程是否执行完毕,所以需要使用合适的同步方法
wait
int wait (pid_t pid)
等待子进程的pid并检索子进程的退出状态
如果pid仍然存在,那么就等待其终止,返回退出的状态
如果pid没有exit就终止了,那么就返回-1
对于A的子进程B的子进程C,A等待C是不合法的,返回值-1
如果某个进程已经被等待了,那么返回的也应当是-1
进程可以产生任意数量的子进程,以任意顺序等待
甚至可能在没有等待子进程的情况下退出
必须考虑所有的可能,确保资源正确的释放
这部分的工作主要在userprog/process.c中的process_wait中完成
实现wait需要更多的工作
文件操作系统调用
除了上面的进程控制系统调用,还需要实现下面的文件操作系统调用
create、remove、open、filesize、read、write、seek、tell、close
在pintos中已经包含了一个基本的文件系统,已经实现了这些功能
这意味着只需要添加系统调用部分的代码即可
需要注意的是,大部分测试靠write系统调用输出内容
如果没有实现write,大部分测试将无法输出内容
实现指南
pintos的文件系统不是线程安全的
因此必须确保文件操作系统调用不会同时调用多个文件系统函数
在filesys中,将为pintos文件系统添加更复杂的同步
目前,可以对文件操作系统调用使用全局锁
我们建议您避免修改此项目中的filesys/目录
当用户进程运行时,必须确保没有人可以修改磁盘上的可执行文件Rox-*测试检查这是否正确实现
File_deny_write和file_allow_write函数可以帮助实现这个特性
拒绝写入支持实时进程的可执行文件非常重要
因为操作系统可能会从文件中延迟加载代码页
或者可能会将一些代码页翻页出来,然后再从文件中重新加载
在pintos中,目前这不是一个问题,因为文件在执行开始之前被完整地加载到内存中
而且pintos不实现任何类型的请求分页
但是,仍然需要实现这一点,因为这是一个很好的实践
项目用户程序的最终代码将用作项目文件系统的起点
项目文件系统的测试依赖于您为该项目实现的一些相同的系统调用
您可能必须修改其中一些系统调用的实现,以支持项目文件系统所需的其他特性
虽然您肯定不能提前很长时间为项目文件系统做计划
但是我们建议您编写好的代码,通过保存好的文档可以很容易地修改这些代码
这部分的系统调用
create
remove
open
filesize
read
write
seek
tell
close
浮点操作
pintos目前不支持浮点操作
你需要实现这个功能,让系统和用户程序可以使用浮点的指令
实现指南
这可能看起来是一项艰巨的任务
但其实,当涉及到实现浮点指令时,操作系统不需要做太多
编译器负责生成浮点指令,硬件负责执行浮点指令
但是,由于CPU上只有一个浮点运算器,这意味着所有线程都必须共享它
因此,操作系统必须在上下文切换、中断和系统调用期间在堆栈上保存和恢复浮点寄存器
就像它对通用寄存器所做的那样
然而,不同的是,当创建新线程或进程时,FPU寄存器需要正确初始化
操作系统也需要在启动期间初始化FPU
本课程的学习目标不是学习浮点操作的细节
而是了解线程上下文切换的细节
由于此任务处理一些操作系统初始化和上下文切换代码
因此这些文件中的错误可能会导致一些严重的、无法破译的错误
这些错误是由损坏线程导致的
我们建议您在完成所有其他任务之后再进行浮点操作
这部分的系统调用
compute_e
<0x02> 代码实现
参数传递
这个部分的理论知识是01-Introduction部分的x86调用约定
这里就不再写了
这部分有下面两步
先是把传入的命令行按空格分隔
然后把分隔出的东西一个一个放入内存中
按空格分隔
这个相对简单,pintos提供了strtok_r函数
这个函数会返回分隔后的const char*串
while (token = strtok_r(cli, " ", &cli))
{
// token就是分隔好的字符串
}
按调用约定放入内存中
这里跟之前的调用约定有些出入
pintos的stack_align-*系列测试中,会检查参数栈是否对齐
具体来说就是最后argc的内存地址可以被16整除
// 将参数压入栈
void process_push_args(const char *cli, void **if_esp)
{
void *esp = *if_esp;
int argc = 0;
char *argv[64];
int args_size = 0;
unsigned int cli_size = strlen(cli) + 1;
char *cli_copy = malloc(cli_size);
strlcpy(cli_copy, cli, cli_size);
// argv[...][...]
char *token = NULL;
while (token = strtok_r(cli_copy, " ", &cli_copy))
{
int size = strlen(token) + 1;
esp -= size;
argv[argc] = esp;
strlcpy(esp, token, size);
args_size += size;
argc++;
}
// stack-align
args_size = args_size + sizeof(char *) * (argc + 1) + sizeof(char **) + sizeof(int);
int temp = args_size % 0x10;
if (temp > 0)
{
int align_size = 0x10 - temp;
esp -= align_size;
memset(esp, 0, align_size);
}
// argv end
esp -= 0x04;
*(char **)esp = 0;
// argv[0]...
for (int i = argc - 1; i >= 0; i--)
{
esp -= sizeof(char *);
*(char **)esp = argv[i];
}
// argv ptr
esp -= 0x04;
*(char ***)esp = (esp + 0x04);
// argc
esp -= 0x04;
*(int *)esp = argc;
// return address
esp -= 0x04;
*(int *)esp = 0;
*if_esp = esp;
}
在start_process的合适位置加上然后传参即可
查看传入的参数
现在我们能传进去了,但我们该怎么检查呢
这时候需要有一个调式函数,用来输出当前的参数堆栈
// debug 打印堆栈中的参数信息
void process_debug_print_argument_stack(void)
{
void *esp = 0xbfffffc0;
const int offset = 0xfc0;
const int show_size = 1 << 6;
void *upage = pg_round_down(esp);
printf("(DEBUG): Print Argument Stack\n");
hex_dump((uintptr_t)upage + offset, upage + offset, show_size, true);
printf("(DEBUG): end\n");
}
传参完调用这个函数就可以打印了,就是提交的时候记得注释
系统调用
这里我把所有系统调用合在一起讲了
用户程序怎么调用系统调用
关于系统调用主要有下面这几个文件
- /lib/user/syscall.*
- /lib/syscall-nr.h
- /userprog/syscall.*
/lib/user/syscall.*是用户空间下系统调用的定义
/lib/syscall-nr.h枚举了所有的系统调用类型
/userprog/syscall.*是内核系统调用的实现
怎么理解呢,首先先看/userprog/syscall.c里面的syscall_init
里面有一句intr_register_int(0x30, 3, INTR_ON, syscall_handler, "syscall");
表示注册中断向量0x30的系统调用中断
这个函数会在/threads/init.c里面调用,所以一启动就会执行
再来看/lib/user/syscall.c的东西
会发现里面有很多汇编的东西,其实问题不大,这些不关键
这些汇编做的事情就是把参数放在内存中,然后触发系统调用中断
当用户程序调用系统调用时
会触发系统调用中断,这时候会跳转到syscall_handler函数中
然后在里面做一些处理
系统调用内存布局与寄存器
根据/lib/user/syscall.c里面的汇编
不难发现参数内存布局是这样的
低地址->高地址
type describe
int syscall_type <-esp
* arg0
* arg1
* arg2
这里举例3个参数的情况,实际上3参数的系统调用就3个
其他情况就少几个传入的参数而已
传入的参数类型是4字节的数或指针
在触发系统调用中断后,我们就可以通过esp指针访问这些参数
如果系统中断有返回值,那么还需要将返回值放到eax中
检查参数是否合法
前面也提到,用户程序使用系统调用时,可能传递非法参数
比如说传递空指针或者尝试访问内核的内存空间
这些是不被允许的,但我们也不能让内核直接崩溃
所以就需要检查参数是否合法
对于不合法的情况,就需要关闭这个用户进程
我们就需要写一个函数来检查指针
bool is_validity(uint32_t *return_ptr, const void *uaddr)
{
// 检查是否是用户的内存空间,检查是否为空指针
if (!is_user_vaddr(uaddr) || (pagedir_get_page(process_current()->pagedir, uaddr) == NULL))
{
syscall_exception_exit(return_ptr);
return false;
}
return true;
}
syscall_exception_exit作用就是退出进程,返回值-1
统一入口处理系统调用
在触发系统调用中断时,实际上就是执行syscall_handle函数
这时候需要通过esp指向的第一个数值来判断系统调用的类型
#define SYSCALL_COUNT 31
static void (*syscall_list[SYSCALL_COUNT])(uint32_t *return_ptr, uint32_t *args_stack);
// ...
static void syscall_handler(struct intr_frame *f UNUSED)
{
uint32_t *args_stack = ((uint32_t *)f->esp);
uint32_t *return_ptr = &f->eax;
// 判断syscall_type的地址是否合法
is_validity(return_ptr, args_stack);
// 判断第一个参数地址是否合法
is_validity(return_ptr, args_stack + 1);
int syscall_type = args_stack[0];
if (syscall_type < 0 || syscall_type > SYSCALL_COUNT)
{
syscall_exception_exit(return_ptr);
}
syscall_list[syscall_type](return_ptr, args_stack);
}
由于C中的enum本质就是int数值,所以syscall_type是当前的系统调用类型
syscall_list是一个函数指针表,根据不同的类型执行不同的代码
比较简单的实现还是看仓库代码吧,这里就不写了
进程的等待
原本的等待实现是非常简单的
全局有个temporary的信号量
原本实现就是等这个信号量释放
而这个信号量的释放就在进程退出的时候
这样的实现是对的
但如果说要实现wait系统调用等功能的话这就不够用了
全局线程查找表
要实现wait的功能,首先需要有个结构保存进程间的父子关系等信息
这里采用全局的list来保存进程间的信息
struct thread_node
{
struct list_elem elem; // 给list用的
tid_t current_tid; // 当前线程tid
tid_t parent_tid; // 父线程tid
int ret_status; // 线程返回值
struct semaphore exit_sema; // 退出信号量
struct semaphore load_sema; // 加载信号量
bool is_already_wait; // 是否已经等待过
bool is_load_success; // 是否加载成功
};
进程的退出
进程的文件操作
浮点操作
这个东西乍一看很头疼
实际上确实头疼,涉及一些汇编的东西
并且如果改错一些东西会导致内核无法启动,调试都很难
x86的fpu
不同于通用地址寄存器,x86的fpu没有可独立寻址的浮点寄存器
但它有一个堆栈寄存器
要操作fpu,就需要在这个堆栈中推送和弹出
比方说计算PI的平方根
那么首先要将PI压入栈,然后使用fsqrt指令
这个指令会弹出一个栈的内容,然后计算完后再放回栈
所以计算完后,栈顶内容就是PI的平方根
在和fpu交互方面,不同于通用寄存器的直接操作的方式
fpu需要使用108字节的结构表示fpu的状态
对fpu的交互需要通过这样的结构
Size Data | Size Data
2 Control Word | 10 ST(0)
2 (unused) | 10 ST(1)
2 Status Word | 10 ST(2)
2 (unusued) | 10 ST(3)
2 Tag Word | 10 ST(4)
2 (unused) | 10 ST(5)
4 Instruction Pointer | 10 ST(6)
2 Code Segment | 10 ST(7)
2 (unused)
4 Operand address
2 Data Segment
2 (unused)
在操作系统启动期间,不需要保存现有的fpu状态集,因为没人要用
但是在另一个线程/进程初始化时,就必须保存fpu状态避免丢失浮点数据
实现这个的最简单的办法是将fpu寄存器状态保存到临时位置,然后初始化
启用fpu
在pintos中,默认不允许使用fpu的,首先要先启用它
// threads/start.s
// line 150
// orl $CR0_PE | CR0_PG | CR0_WP | CR0_EM, %eax
orl $CR0_PE | CR0_PG | CR0_WP, %eax
保存fpu的信息
现在启用了fpu,但在中断处理和线程上下文切换时,fpu的信息没有保存
这里要改很多地方,目的都是添加中断时fpu的信息
(由于笔者超小杯汇编,实在解释不明白,见谅)
// threads/interrupt.h
#define FPU_SIZE 108
struct intr_frame
{
// ...
uint8_t fp_regs[FPU_SIZE]; // 添加浮点寄存器
// ...
}
// threads/intr-stubs.s
.func intr_entry
intr_entry:
/* 新加的保存fpu状态 */
subl $FPU_SIZE, %esp
fsave (%esp)
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
cld
mov $SEL_KDSEG, %eax
mov %eax, %ds
mov %eax, %es
leal 56(%esp), %ebp
pushl %esp
.globl intr_handler
call intr_handler
addl $4, %esp
.endfunc
.globl intr_exit
.func intr_exit
intr_exit:
popal
popl %gs
popl %fs
popl %es
popl %ds
/* 新加的恢复fpu状态 */
frstor (%esp)
addl $FPU_SIZE, %esp
addl $12, %esp
iret
.endfunc
还有线程上下文的部分
// threads/switch.h
#define FPU_SIZE 108
// ...
struct switch_threads_frame
{
// ...
uint8_t fp_regs[FPU_SIZE]; // fpu状态
// ...
};
// ...
// 因为加了108字节的fpu状态,所以就+108
#define SWITCH_CUR 128
#define SWITCH_NEXT 132
// threads/switch.s
.globl switch_threads
.func switch_threads
switch_threads:
/* 新加的保存fpu状态 */
subl $FPU_SIZE, %esp
fsave (%esp)
pushl %ebx
pushl %ebp
pushl %esi
pushl %edi
.globl thread_stack_ofs
mov thread_stack_ofs, %edx
movl SWITCH_CUR(%esp), %eax
movl %esp, (%eax,%edx,1)
movl SWITCH_NEXT(%esp), %ecx
movl (%ecx,%edx,1), %esp
popl %edi
popl %esi
popl %ebp
popl %ebx
/* 恢复fpu状态 */
frstor (%esp)
addl $FPU_SIZE, %esp
ret
.endfunc
.globl switch_entry
.func switch_entry
switch_entry:
/* 初始化fpu */
finit
addl $8, %esp
pushl %eax
.globl thread_switch_tail
call thread_switch_tail
addl $4, %esp
ret
.endfunc
用户线程中恢复fpu状态
现在已经保存了fpu状态了,但还没导入用户进程中
// userprog/process.c
static void start_process(void *cli_)
{
// ...
// 加在最后,导入fpu状态
asm("fsave (%0)" : : "g"(&if_.fp_regs));
/* Start the user process by simulating a return from an
interrupt, implemented by intr_exit (in
threads/intr-stubs.S). Because intr_exit takes all of its
arguments on the stack in the form of a `struct intr_frame',
we just point the stack pointer (%esp) to our stack frame
and jump to it. */
asm volatile("movl %0, %%esp; jmp intr_exit" : : "g"(&if_) : "memory");
NOT_REACHED();
}
完善float库
原本提供的lib/float.h的实现是不完善的,需要进一步实现
// lib/float.h
static inline void fpu_push(int num)
{
unsigned char fpu[108];
// 内联asm获取fpu状态
asm("fsave %0;" : : "m"(fpu));
// 获取栈顶
int status_word = fpu[4];
// 将数字压入
fpu[28 + status_word * 10] = (float)num;
// 确保status_word在0-7中循环
if (status_word == 7)
{
status_word = 0;
}
else
{
++status_word;
}
fpu[4] = status_word;
// 存入fpu
asm("frstor %0;" : : "m"(fpu));
}
// 差不多就不写注释了
static inline int fpu_pop(void)
{
int val;
unsigned char fpu[108];
asm("fsave %0;" : : "m"(fpu));
int status_word = fpu[4];
if (status_word == 0)
{
status_word = 7;
}
else
{
status_word--;
}
val = fpu[28 + status_word * 10];
fpu[4] = status_word;
asm("frstor %0;" : : "m"(fpu));
return val;
}
实现系统调用
比起前面,这步算简单了
跟前面实现系统调用的部分一样,为compute_e开一个函数处理即可
别忘了就行,这里就不讲了