shell与测例的加载

本节导读

本节将会展示新的bin_loader加载测例到进程的方式,并且展示我们的shell测例是如何运行的。

新的bin_loader

exec会调用bin_loader,将对应文件名的测例加载到指定的进程p之中。请结合注释理解 bin_loader 的变化:

 1int bin_loader(uint64 start, uint64 end, struct proc *p)
 2{
 3    void *page;
 4    // 注意现在我们不要求对其了,代码的核心逻辑还是把 [start, end)
 5    // 映射到虚拟内存的 [BASE_ADDRESS, BASE_ADDRESS + length)
 6    uint64 pa_start = PGROUNDDOWN(start);
 7    uint64 pa_end = PGROUNDUP(end);
 8    uint64 length = pa_end - pa_start;
 9    uint64 va_start = BASE_ADDRESS;
10    uint64 va_end = BASE_ADDRESS + length;
11    // 不再一次 map 很多页面,而是逐页 map,为什么?
12    for (uint64 va = va_start, pa = pa_start; pa < pa_end;
13        va += PGSIZE, pa += PGSIZE) {
14        // 这里我们不会直接映射,而是新分配一个页面,然后使用 memmove 进行拷贝
15        // 这样就不会有对其的问题了,但为何这么做其实有更深层的原因。
16        page = kalloc();
17        memmove(page, (const void *)pa, PGSIZE);
18        // 这个 if 就是为了防止 start end 不对其导致拷贝了多余的内核数据
19        // 我们需要手动把它们清空
20        if (pa < start) {
21            memset(page, 0, start - va);
22        } else if (pa + PAGE_SIZE > end) {
23            memset(page + (end - pa), 0, PAGE_SIZE - (end - pa));
24        }
25        mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W | PTE_X);
26    }
27    // 同 lab4 map user stack
28    p->ustack = va_end + PAGE_SIZE;
29    for (uint64 va = p->ustack; va < p->ustack + USTACK_SIZE;
30        va += PGSIZE) {
31        page = kalloc();
32        memset(page, 0, PGSIZE);
33        mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W);
34    }
35    // 设置 trapframe
36    p->trapframe->sp = p->ustack + USTACK_SIZE;
37    p->trapframe->epc = va_start;
38    p->max_page = PGROUNDUP(p->ustack + USTACK_SIZE - 1) / PAGE_SIZE;
39    p->state = RUNNABLE;
40    return 0;
41}

其中,对于用户栈、trapframe、trampoline 的映射没有变化,但是对 .bin 数据的映射似乎面目全非了,竟然由一个循环完成。其实,这个循环的逻辑十分简单,就是对于 .bin 的每一页,都申请一个新页并进行内容拷贝,最后建立这一页的映射。之所以这么麻烦完全是由于我们的物理内存管理过于简陋,一次只能分配一个页,如果能够分配连续的物理页,那么这个循环可以被一个 mappages 替代。

那么另一个更重要的问题是,为什么要拷贝呢?想想 lab4 我们是怎么干的,直接把虚存和物理内存映射就好了,根本没有拷贝。那么,拷贝是为了什么呢?其实,按照 lab4 的做法,程序运行之后就会修改仅有一份的程序”原像”,你会发现,lab4 的程序都是一次性的,如果第二次执行,会发现 .data 和 .bss 段数据都被上一次执行改掉了,不是初始化的状态。但是 lab4 的时候,每个程序最多执行一次,所以这么做是可以的。但在 lab5 所有程序都可能被无数次的执行,我们就必须对“程序原像”做保护,在“原像”的拷贝上运行程序了。

测例的执行

从本章开始,大家可以发现我们的 run_all_app 函数被 load_init_app 取代了:

 1// os/loader.c
 2
 3// load all apps and init the corresponding `proc` structure.
 4int load_init_app()
 5{
 6    int id = get_id_by_name(INIT_PROC);
 7    if (id < 0)
 8        panic("Cannpt find INIT_PROC %s", INIT_PROC);
 9    struct proc *p = allocproc();
10    if (p == NULL) {
11        panic("allocproc\n");
12    }
13    debugf("load init proc %s", INIT_PROC);
14    loader(id, p);
15    return 0;
16}

这个 load_init_app load 的 INIT_PROC 一般来说就是我们在本章第一节展示的那个 usershell,不过可以通过在 Makefile 中传入 INIT_PROC 参数而改变,大部分情况下,不推荐修改,这是由于 usershell 具有不错的灵活性。

usershell

user/src/usershell.c 就是 usershell 的代码了,有兴趣的同学可以研究下这个 shell:

const unsigned char LF = 0x0a;
const unsigned char CR = 0x0d;
const unsigned char DL = 0x7f;
const unsigned char BS = 0x08;

// 手搓了一个极简的 stack,用来维护用户输入,保存一行的输入
char line[100] = {};
int top = 0;
void push(char c){ line[top++] = c; }
void pop() { --top; }
int is_empty() { return top == 0;}
void clear() { top = 0; }

int main()
{
    printf("C user shell\n");
    printf(">> ");
    fflush(stdout);
    while (1) {
        char c = getchar();
        switch (c) {
        // 回车,执行当前 stack 中字符串对应的程序
        case LF:
        case CR:
            printf("\n");
            if (!is_empty()) {
                push('\0');
                int pid = fork();
                if (pid == 0) {
                    // child process
                    if (exec(line, NULL) < 0) {
                        printf("no such program: %s\n",
                            line);
                        exit(0);
                    }
                    panic("unreachable!");
                } else {
                    int xstate = 0;
                    int exit_pid = 0;
                    exit_pid = waitpid(pid, &xstate);
                    assert(pid == exit_pid);
                    printf("Shell: Process %d exited with code %d\n",
                        pid, xstate);
                }
                clear();
            }
            printf(">> ");
            fflush(stdout);
            break;
        // 退格建,pop一个char
        case BS:
        case DL:
            if (!is_empty()) {
                putchar(BS);
                printf(" ");
                putchar(BS);
                fflush(stdout);
                pop();
            }
            break;
        // 普通输入,回显并 push 一个 char
        default:
            putchar(c);
            fflush(stdout);
            push(c);
            break;
        }
    }
    return 0;
}

可以看到这个测例实际上就是实现了一个简单的字符串处理的函数,并且针对解析得到的不同的指令调用不同的系统调用。要注意这需要shell支持read的系统调用。当读入用户的输入时,它会死循环的等待用户输入一个代表程序名称的字符串(通过sys_read),当用户按下空格之后,shell 会使用 fork 和 exec 创建并执行这个程序,然后通过 sys_wait 来等待程序执行结束,并输出 exit_code。有了 shell 之后,我们可以只执行自己希望的程序,也可以执行某一个程序很多次来观察输出,这对于使用体验是极大的提升!可以说,第五章的所有努力都是为了支持 shell。

我们简单看一下sys_read的实现,它与 sys_write 有点相似:

uint64 sys_read(int fd, uint64 va, uint64 len)
{
    if (fd != STDIN)
        return -1;
    struct proc *p = curr_proc();
    char str[MAX_STR_LEN];
    len = MIN(len, MAX_STR_LEN);
    for (int i = 0; i < len; ++i) {
        // consgetc() 会阻塞式的等待读取一个 char
        int c = consgetc();
        str[i] = c;
    }
    copyout(p->pagetable, va, str, len);
    return len;
}

目前我们只支持标准输入stdin的输入(对应fd = STDIN)。