这个是CS162的Project 0,也是我们的实验一部分
虽然这个部分不必出现在最后的验收和报告中
但里面的坑还是很多的,我觉得还是要讲一讲
<0x01> 任务说明
这个任务很简单,在pintos的源码中有一些小问题
这会使pintos在启动用户进程时崩溃
你需要找出这个错误并修改
这个任务最简单的解决办法只需要更改一处代码
这个任务需要通过的测试是do-nothing和stack-align-0
<0x02> 省流版
只需要在./userprog/process.c中作这样的修改
static void start_process(void *file_name_)
{
// ...
/* Initialize interrupt frame and load executable. */
if (success)
{
memset(&if_, 0, sizeof if_);
if_.gs = if_.fs = if_.es = if_.ds = if_.ss = SEL_UDSEG;
if_.cs = SEL_UCSEG;
if_.eflags = FLAG_IF | FLAG_MBS;
success = load(file_name, &if_.eip, &if_.esp);
}
// 新加的一句
if_.esp -= 0x14;
/* Handle failure with succesful PCB malloc. Must free the PCB */
if (!success && pcb_success)
{
// Avoid race where PCB is freed before t->pcb is set to NULL
// If this happens, then an unfortuantely timed timer interrupt
// can try to activate the pagedir, but it is now freed memory
struct process *pcb_to_free = t->pcb;
t->pcb = NULL;
free(pcb_to_free);
}
// ...
}
只要在这个位置加上if_.esp -= 0x14;就可以了
<0x03> 原理
需要指出的是,笔者目前没系统学过汇编,对x86调用约定之类的只知道皮毛
下面的内容有部分是我猜测出来的,有概率是错的,如果有误请务必指出
问题原因
是不是感觉很奇怪,为什么加上这句就可以解决加载用户进程的出错
实际上,让我们先删除这句,看看原本的pintos启动do-nothing的时候会报什么错
Executing 'do-nothing':
Page fault at 0xc0000008: rights violation error reading page in user context.
do-nothing: dying due to interrupt 0x0e (#PF Page-Fault Exception).
Interrupt 0x0e (#PF Page-Fault Exception) at eip=0x8048915
cr2=c0000008 error=00000005
eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
esi=00000000 edi=00000000 esp=bfffffe4 ebp=bffffffc
cs=001b ds=0023 es=0023 ss=0023
Execution of 'do-nothing' complete.
我们可以看到,这里报了个Page fault
根据pintos的文档,说明这里是用户进程尝试访问内核空间导致的错误
A user program can only access its own user virtual memory.
An attempt to access kernel virtual memory causes a page fault,
handled by page_fault() in userprog/exception.c, and the process will be terminated.
一个用户程序只能访问自己的用户虚拟内存
如果尝试访问内核虚拟内存会导致页面错误
然后会在 ./userprog/exception.c 的 page_fault() 中处理,用户进程会被终止
我们可以在start_process()打上断点,看看最后的esp是什么
然后我们可以得到这样的数值
esp: 0xc0000000
根据pintos的文档,这个数字也是PHYS_BASE的值
Virtual memory in Pintos is divided into two regions:
user virtual memory and kernel virtual memory.
User virtual memory ranges from virtual address 0 up to PHYS_BASE,
which is defined in threads/vaddr.h and defaults to 0xc0000000 (3 GB).
Kernel virtual memory occupies the rest of the virtual address space,
from PHYS_BASE up to 4 GB.
pintos的虚拟内存有两块组成
用户虚拟内存和内核虚拟内存
用户虚拟内存地址范围是0 - PHYS_BASE
PHYS_BASE在 ./threads/vaddr.h 定义并且默认为 0xc0000000 (3 GB)
内核虚拟内存占据剩下的部分
也就是PHYS_BASE - 4GB
那现在也就不难理解这个报错了
报错信息也指出,当前用户进程尝试访问地址为0xc0000008的内容
为什么会访问这个地址
那么用户进程为什么会在执行前访问这个地址的内容呢
这就要讲到x86的调用约定与用户程序启动过程
x86调用约定
对于我们习惯高级语言的程序员来说,处理参数问题是很简单的
void temp(int a, int b);
这样就说明这这个函数需要两个参数,都是int
如果需要使用直接temp(1, 2)就完成了参数传递
但对于CPU来说,它该怎么理解这两个参数的传递呢
换句话说,对于远古时期没有高级语言的开发者,该怎么解决参数传递的问题呢
这里就是调用约定上场的地方了
调用约定规定了参数在内存中是如何摆放的,寄存器该怎么设置的
在pintos中,使用的x86调用约定应该是cdecl
这个调用约定要求传递的参数从右至左压入内存,并将esp设置在最前面
0xbffffe70 return address <-- esp
0xbffffe74 1
0xbffffe78 2
对于刚刚的例子,内存布局大概就是这样
这里内存的地址是随便编的,表达个意思
用户进程启动过程
在pintos中,内核会这样启动用户进程
void _start (int argc, char *argv[]) {
exit (main (argc, argv));
}
对于内核,它必须把参数在内存合适的地方安排好,然后设置好寄存器状态
参数的内存布局和寄存器状态就是上面提到的调用约定
pintos的文档中给了一个例子
假设内核需要执行/bin/ls -l foo bar
首先内核会将命令行按空格分开,分成一个一个的
这里先展示最后的内存布局
Address Name Data Type
0xbfffffcc return address 0 void (*) () <-- esp
0xbfffffd0 argc 4 int
0xbfffffd4 argv 0xbfffffd8 char **
0xbfffffd8 argv[0] 0xbfffffed char *
0xbfffffdc argv[1] 0xbffffff5 char *
0xbfffffe0 argv[2] 0xbffffff8 char *
0xbfffffe4 argv[3] 0xbffffffc char *
0xbfffffe8 argv[4] 0 char *
0xbfffffec stack-align 0 uint8_t
0xbfffffed argv[0][...] /bin/ls\0 char[8]
0xbffffff5 argv[1][...] -l\0 char[3]
0xbffffff8 argv[2][...] foo\0 char[4]
0xbffffffc argv[3][...] bar\0 char[4]
最下面的部分是实际存放字符串的地方
然后会有个stack-align,这是为了确保四字节对齐来提高性能
再上面是argv参数的部分,需要注意的是argv最后有一个留空的部分
然后是argv本身的地址,指向argv[0]
之后是argc,表示参数个数
最后是返回地址,当然对于用户进程来说main是没返回值的,直接给0即可
esp指向的也是这个返回地址
访问内核地址的原因
我们假设一个最简单的情况,一个参数也不传递
那么内存布局应该是这样的
Address Name Data Type
0xbffffff0 return address 0 void (*) () <-- esp
0xbffffff4 argc 0 int
0xbffffff8 argv 0xbffffffc char**
0xbffffffc argv[0] 0 char*
但我们之前得到的esp指向的是0xc0000000
那么在CPU看来,这个函数的参数布局是这样的
Address Name Data Type
0xc0000000 return address * void (*) () <-- esp
0xc0000004 argc * int
0xc0000008 argv * char**
0xc000000c argv[0] * char*
0xc0000000下面是内核空间的虚拟内存,所以就报错了
因为这一步我们还没实现参数传递的逻辑
所以这一步最简单的办法就是在某个位置把esp指针往前移几个字节
为什么偏移量是0x14
这是为了stack-align-0考虑的
#include "tests/lib.h"
int main(int argc UNUSED, char *argv[] UNUSED)
{
register unsigned int esp asm("esp");
return esp % 16;
}
可以看到,这个测试会对esp取16的余
查看测试文件,也可以知道期望的返回值是12
我们假设我们已经实现了参数传递的逻辑
那么这个测试的内存布局应该是这样的
Address Name Data Type
0xbfffffdc return address 0 void (*) () <-- esp
0xbfffffe0 argc 1 int
0xbfffffe4 argv 0xbfffffe8 char **
0xbfffffe8 argv[0] 0xbffffff2 char *
0xbfffffec argv[1] 0 char *
0xbffffff0 stack-align 0 uint16_t
0xbffffff2 argv[0][...] stack-align-0\0 char[14]
这esp % 16一算,就是12
当然,这里逻辑还没实现,那么就取偏移量20也就是0x14
这样这两个测试也是满足的
说到底还是缓兵之计,等到之后实现参数传递的时候就不需要这样写了