Zjut-Os-01 Introduction 记录

这个是CS162的Project 0,也是我们的实验一部分
虽然这个部分不必出现在最后的验收和报告中
但里面的坑还是很多的,我觉得还是要讲一讲

<0x01> 任务说明

这个任务很简单,在pintos的源码中有一些小问题
这会使pintos在启动用户进程时崩溃
你需要找出这个错误并修改
这个任务最简单的解决办法只需要更改一处代码

这个任务需要通过的测试是do-nothingstack-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
这样这两个测试也是满足的

说到底还是缓兵之计,等到之后实现参数传递的时候就不需要这样写了