自制操作系统(17):围绕增强Shell进行的一系列改进

我们实现了文件系统…但是我们的shell居然还不能自由地访问这个文件系统!是可忍熟不可忍!

我们来制定目标:

1、可以用cd 切换当前工作目录;

2、输入命令时,能匹配/usr/bin下面的文件(最好是可配的),还有当前目录的文件,还能把紧跟着的参数作为入参传给这个文件;

3、运行进程时能阻塞shell,还能把返回值返回给shell。

接下来我们来逐个实现。

匹配/usr/bin下面的文件并执行

我们先来看看怎么让shell默认匹配/usr/bin下面的文件并执行。

我们构建一个数据,冒充path环境变量:

constexpr char* PATH[2] = {
    "/usr/bin/",
    "/"
};

然后我们适配几个系统调用:文件相关操作,以及内存的分配…慢着,我们还没有做用户态的内存分配呢!看来只好读在栈上了,希望我们分配给用户态的栈空间足够。

image-20260301024854885

exec的问题:我们的代码不在内核!

image-20260301025205956

我们可以先把用户代码拷贝到内核缓冲区,后面复制到了新的用户空间之后再释放。

image-20260301025302629

这次运行成功了。

入参传递给文件

这就需要我们升级一下create_user_process函数了。

我们在用户栈上构造Argc,argv地址,argv地址直接就指向我们的栈更深处。

我们来试试创建进程时传参:

            bool flag = false;
            char fn[256] = "/usr/bin/";
            for (int i = 0; i < 2; ++i) {
                int fd = open(strcat(fn, input), 1);
                if (fd == -1) continue;
                int size = read(fd, buffer, 32768);
                if (size == -1) continue;
                printf("Executing %s: %d bytes loaded\n", fn, size);
                char* v[] = {"rumianomnomnom\n", "yay\n", "can you hear me?"};
                exec(buffer, size, 1, 3, v);
                flag = true;
            }

image-20260301033223983

现在我们可以向运行中的程序传参了!

cwd

我们在PCB里面新加入一个cwd字段记录当前的工作目录:

然后再在创建内核进程的逻辑中,继承父进程的cwd:

    strcpy(new_process->cwd, process_list[cur_process_id]->cwd);

然后我们添加两个系统调用,chdir还有getcwd

我们就可以编出来一个pwd还有一个ls

cd我们在shell内建实现,我们还为shell添加了分词器

image-20260301043754669

但是我们现在有了输出打架的情况,我们需要一个waitpid!

waitpid

基本思路

通过类似spawn的机制创建子进程后,我要把自己设置为等待状态,这时候我基本有两种选择:

一个是在进程PCB里面加一个等待队列的数据结构,那我把自己设置为等待状态后,就把我自己从调度队列移除,然后调用schedule,子进程退出时,在exit_process再遍历一遍等待序列,把里面的进程放回去调度队列;或者是进入一个循环,每次调度到我的时候就检测现在的子进程pid,如果子进程还没有变成zombie态就继续循环,变成zombie态就拿它的返回码并执行回收其PCB的进程。

我们这里选择方案一,因为方案二本质上是忙等待+轮询。

初版实现

首先,在创建子进程时,将父进程挂入子进程的等待队列:

    new_process->parent_pid = cur_process_id;
    new_process->waiting_queue = nullptr;
    new_process->fd_num = 0;
    strcpy(new_process->cwd, process_list[cur_process_id]->cwd);

    // 父进程设置为等待态,从调度队列中移除
    remove_from_scheduling_queue(cur_process_id);
    process_list[cur_process_id]->state = process_state::WAITING;
    // 放入新进程的等待序列
    insert_into_process_queue(new_process->waiting_queue, process_list[cur_process_id]);

    insert_into_scheduling_queue(newpid, priority);

然后我们在退出进程时,去把自己的等待序列里面的进程重新放回可调度序列:

uint32_t exit_process(uint8_t pid) {
    printf("exiting process %d...\n", pid);
    if (pid == 0 || process_list[pid] == nullptr) return 1;

    PCB*& exiting_process = process_list[pid];
    if (pid != cur_process_id) { // 要退出的进程不是自己的话
        exiting_process->to_exit = 1; // 不要直接清理这个进程的空间,告诉进程自己将要被退出就好
        return 0;
    }
    PCB* itr = exiting_process->waiting_queue;
    while (itr) {
        insert_into_scheduling_queue(itr->pid);
        itr = itr->next;
    }
    exiting_process->state = process_state::ZOMBIE;
    remove_from_scheduling_queue(pid);
    // insert_into_process_recycle_queue(cur_process); 不需要了
    yield();
    // 不应该执行到这里
    return 0;
}

在这里我还顺带修复了另一个问题,就是如果退出的不是当前进程,是不是不应该去把别的进程的数据给干掉,而是给它发一个信号或者别的什么东西,让它后面被调度之后自己退出。新加一个to_exit字段,需要在初始化和schedule的时候做匹配:

    process_switch_to(chosen_process->pid);

    flags = process_list[cur_process_id]->saved_eflags;
    asm volatile ("pushl %0; popfl" : : "r"(flags));

    if (cur_pcb->to_exit) {
        exit_process(cur_process_id);
    }
}

waitpid的系统调用:

// WAITPID(ebx = pid)
int sys_waitpid(interrupt_frame* reg) {
    uint8_t pid = static_cast<uint8_t>(reg->ebx);
    return waitpid(pid);
}

另外,原来的进程回收序列逻辑不需要了,我们直接删掉。我还多做了一个修改:process_id用pid_t类型,转用int。这里感觉是要吃苦头的,但是目前来看转换之后没有什么bug。

调试过程

初版跑起来之后,碰到了不少问题。

死锁问题:打开中断的时机不对导致死锁。

asm volatile ("sti");
spinlock_release(&process_list_lock);

把两条指令调换即可——先释放锁再开中断。

image-20260301145034920

调度回shell失败:程序能跑了,但是没有正确调度回shell。

image-20260301145146392

一方面是我们没有正确遍历等待序列。

另一方面,yield会把当前进程状态改成READY,覆盖了我们之前设置的WAITING状态。yield要改变状态的,搭配改变进程状态的语句要注意。

image-20260301145630680

image-20260301145641733

哟吼,居然说我们还在调度队列。。

image-20260301152402530

这里会把状态改成ready…

改进后,调度器调整为schedule只把running的进程重新入队:

image-20260301152721476

image-20260301152832123

image-20260301152849346

完事了。于是现在已经不会有输出打架的问题了。

image-20260301153704990

git commit -m “增加waitpid调用;调度器调整为schedule只把running的进程重新入队;process_id用pid_t类型,转用int;Exit_process退出别的进程时,使用类似信号量的机制。”

将阻塞语义从create_process移到waitpid

发现不应该把阻塞语义放在create_process里,应该单独放到waitpid中:

int waitpid(pid_t child) {
    if (child < 0 || child > MAX_PROCESSES_NUM ||
        !process_list[child] || process_list[child]->parent_pid != cur_process_id) {
        return -1;
    }
    PCB* child_pcb = process_list[child];
    // 父进程设置为等待态,从调度队列中移除
    process_list[cur_process_id]->state = process_state::WAITING;
    remove_from_scheduling_queue(cur_process_id);
    // 放入新进程的等待序列
    insert_into_process_queue(child_pcb->waiting_queue, process_list[cur_process_id]);
    while(child_pcb->state != process_state::ZOMBIE) {
        yield();
    }
    free_pcb(process_list[child]);
    return 0;
}

竞态条件分析与修复

这个版本的waitpid存在一个微妙的竞态条件。设想这样的场景:

waitpid中的while循环每次执行yield()之前会把自己设为WAITING,但如果在执行到这条赋值语句之前被调度走,而子进程恰好在这时退出了——exit_process会把父进程从等待队列取出并设为READY放回调度队列。等父进程被调度回来继续执行时,它又会把自己设成WAITING,从此不再被调度。

与此同时,如果调度队列完全为空,schedule的保底机制会选择0号进程。0号进程持续运行,当MLFQ的move_all_to_top_priority()触发时,它假设就绪队列中一定有进程,解引用空指针,导致崩溃:

(gdb) print tail
$1 = (PCB *) 0x0
(gdb) where
#0  move_all_to_top_priority () at arch/i386/schedule.cpp:46
#1  0xc0102ef1 in schedule () at arch/i386/schedule.cpp:71
#2  0xc0102fa5 in yield () at arch/i386/schedule.cpp:104
#3  0xc0102b68 in waitpid (child=1) at arch/i386/process.cpp:47
#4  0xc0106dd1 in kernel_main (mbi=0x0) at kernel/kernel.cpp:254
#5  0xc0101155 in _tokernelmain ()

(gdb) print process_list[0]->state
$4 = process_state::WAITING
(gdb) print process_list[1]->state
$2 = process_state::WAITING

两个进程全部陷入WAITING,系统死锁。

还有一种情况:子进程执行得非常快,在waitpid还没来得及调用schedule之前就已经结束了。这时候如果不做特殊处理,waitpid会把自己挂进一个已经是ZOMBIE状态的子进程的等待队列,永远不会被唤醒。

修复方案是在waitpid开头关中断检查子进程状态,如果已经是ZOMBIE就直接回收,同时去掉循环中反复设置WAITING的语句:

int waitpid(pid_t child) {
    if (child < 0 || child > MAX_PROCESSES_NUM ||
        !process_list[child] || process_list[child]->parent_pid != cur_process_id) {
        return -1;
    }
    PCB* child_pcb = process_list[child];

    asm volatile("cli");
    if (child_pcb->state == process_state::ZOMBIE) {
        // 子进程已经退出了,直接回收
        asm volatile("sti");
        free_pcb(process_list[child]);
        return 0;
    }
    process_list[cur_process_id]->state = process_state::WAITING;
    insert_into_process_queue(child_pcb->waiting_queue, process_list[cur_process_id]);
    yield();

    free_pcb(process_list[child]);
    return 0;
}

另外还发现PCB太大踩内存了,也一并修复。

image-20260301155829412

image-20260301155841752

image-20260301161500722

image-20260301225522380

image-20260301225701484

image-20260301225720849

返回值适配

waitpid稳定之后,我们来适配进程的退出码。

首先需要在PCB准备一个exit_code字段,然后修改exit_process接收退出码:

uint32_t exit_process(pid_t pid, int exit_code) {
    if (pid == 0 || process_list[pid] == nullptr) return 1;
    PCB*& exiting_process = process_list[pid];
    if (!exiting_process->to_exit) {
        // 当前进程还没被指派退出,使用传入的退出码
        exiting_process->exit_code = exit_code;
    }

这样做保证退出码不被覆盖——如果进程已经被外部指派退出(to_exit),那退出码以先前设置的为准。

waitpid中也要去接住返回码:

int waitpid(pid_t child) {
    if (child < 0 || child > MAX_PROCESSES_NUM ||
        !process_list[child] || process_list[child]->parent_pid != cur_process_id) {
        return -1;
    }
    PCB* child_pcb = process_list[child];

    asm volatile("cli");
    if (child_pcb->state == process_state::ZOMBIE) {
        // 子进程已经退出了,直接回收
        asm volatile("sti");
        int exit_code = child_pcb->exit_code;
        free_pcb(process_list[child]);
        return exit_code;
    }
    process_list[cur_process_id]->state = process_state::WAITING;
    insert_into_process_queue(child_pcb->waiting_queue, process_list[cur_process_id]);
    yield();

    int exit_code = child_pcb->exit_code;
    free_pcb(process_list[child]);
    return exit_code;
}

sys_exit系统调用,用ebx去接退出码:

// CLOSE(ebx = exit_code)
int sys_exit(interrupt_frame* reg) {
    int exit_code = static_cast<int>(reg->ebx);
    exit_process(cur_process_id, exit_code);
    return 0;
}

crt0也得一块改,main的返回码在eax,我们先拷贝一份到ebx传入sys_exit:

_start:
    call main
    mov %eax, %ebx
    mov $0, %eax
    int $0x80
    ret

image-20260302010919652

可以做算术了。


这一节我们围绕增强shell进行了很多os的改进和bug修复,下一节我们来实现用户态 malloc/free。