你管这叫操作系统源码(十四)

shell程序跑起来了

shell只是个标准,具体的是实现有很多,比如Ubuntu18.04上,具体的shell实现是bash:

~$ echo $SHELL
/bin/bash

Mac上,具体的实现是zsh:

~ echo $SHELL
/bin/zsh

当然,默认的 shell 实现也可以手动进行设置并更改。还有个有意思的事,shell 前面的提示符,也是可以修改的,具体参考笔者另一篇文章Ubuntu命令提示符

[root@DESKTOP]# echo $PS1
[\u@\h \W]\$
[root@DESKTOP]# PS1=[呵呵呵]
[呵呵呵]

其实,shell 程序也仅仅是个程序而已,它的输出,它的输入,它的执行逻辑,是完全可以通过阅读程序源码来知道的,和一个普通的程序并没有任何区别。

接下来我们就阅读一下 shell 程序的源码,只需要找到它的一个具体实现即可。但是 bash,zsh 等实现都过于复杂,很多东西对于我们学习完全没必要。所以这里我通过一个非常非常精简的 shell 实现,即 xv6 里的 shell 实现为例,来进行讲解。

xv6 是一个非常非常经典且简单的操作系统,是由麻省理工学院为操作系统工程的课程开发的一个教学目的的操作系统,所以非常适合操作系统的学习
麻省shell-Xv6
而在它的源代码中,又恰好实现了一个简单的 shell 程序,所以阅读它的代码,对我们这个系列课程来说,简直再合适不过了。

麻省Xv6之shell源码

看到没,甚至在这么一个小小的截图里,已经可以完整展示 sh.c 里全部的 main 方法代码了。但我仍然十分贪婪,即便是这么短的代码,我也帮你把一些多余的校验逻辑去掉,再去掉关于 cd 命令的特殊处理分支,来一个最干净的版本。

// xv6-public sh.c
int main(void) {
    static char buf[100];
    // 读取命令
    while(getcmd(buf, sizeof(buf)) >= 0){
        // 创建新进程
        if(fork() == 0)
            // 执行命令
            runcmd(parsecmd(buf));
        // 等待进程退出
        wait();
    }
}

总得来说,shell 程序就是个死循环,它永远不会自己退出,除非我们手动终止了这个 shell 进程。在死循环里面,shell 就是不断读取(getcmd)我们用户输入的命令,创建一个新的进程(fork),在新进程里执行(runcmd)刚刚读取到的命令,最后等待(wait)进程退出,再次进入读取下一条命令的循环中。

由此你是不是也感受到了 xv6 源码的简单之美,真的是见名知意,当你跟着走完这个 Linux 0.11 之旅后,再去阅读 xv6 的源码你会觉得非常舒服,因为 Linux 0.11 很多地方都用了非常骚的编码技巧,使得理解起来很困难,谁让 Linus 这么特立独行呢。

我们之前说过 shell 就是不断 fork + execve 完成执行一个新程序的功能的,那 execve 在哪呢?那我们就要看执行命令的 runcmd代码了:

void runcmd(struct cmd *cmd) {
    ...
    struct execcmd ecmd = (struct execcmd*)cmd;
    ...
    exec(ecmd->argv[0], ecmd->argv);
    ...
}

这里我又省略了很多代码,比如遇到管道命令 PIPE,遇到命令集合 LIST 时的处理逻辑,我们仅仅看单纯执行一条命令的逻辑。可以看到,就是简简单单调用了个 exec 函数,这个 exec 是 xv6 代码里的名字,在 Linux 0.11 里就是在系列之十三execve加载并执行shell程序里讲的execve函数。

shell 执行一个我们所指定的程序,就和我们在 Linux 0.11 里通过 fork + execve 函数执行了 /bin/sh 程序是一个道理。你看,fork 和 execve 函数你一旦懂了,shell 程序的原理你就直接秒懂了。而 fork 和 execve 函数的原理,其实如果你非常熟练地掌握中断、虚拟内存、文件系统、进程调度等更为底层的基础知识,其实也不难理解。所以,根基真的很重要,本回已经到操作系统启动流程的最后一哆嗦了,如果你现在感觉十分混乱,最好的办法就是,不断去啃之前那些你认为"无聊的"、"没用的"章节。

操作系统启动完毕

上节说到一个 shell 程序的执行原理,至此我们的操作系统终于将控制权转交给了 shell,由 shell 程序和我们人类进行友好的交互。其实到这里,操作系统的使命就基本结束了。

此时我想到了之前有人问过的一个问题,说为什么现在的电脑开机后和操作系统启动前,还隔着好长一段时间,这段时间运行的代码是什么?后来才知道,他说的操作系统的开始部分,是我们看到了诸如 Windows 登陆画面的时候。
操作系统登录

这个登陆画面就和我们 Linux 0.11 里讲的这个 shell 程序一样,已经可以说标志着操作系统启动完毕了,通过 shell 不断接受用户命令并执行命令的死循环过程中。

甚至在 Linux 0.11 里根本都找不到 shell 的源代码,说明 Linux 0.11 并没有认为 shell 是操作系统的一部分,它只是个普通的用户程序,和你在操作系统里自己写个 hello world 编译成 a.out 执行一样。在执行这个 shell 程序前已经可以认为操作系统启动完毕了。

操作系统就是初始化了一堆数据结构进行管理,并且提供了一揽子系统调用接口供上层的应用程序调用,仅此而已。再多做点事就是提供一些常用的用户程序,但这不是必须的。

shell 程序执行了,操作系统就结束了么?此时我们不妨从宏观视角来看一下当前的进度:

shell的进度

看最右边的蓝色部分的流程即可。

我们先是建立了操作系统的一些最基本的环境与管理结构,然后由进 0 fork 出处于用户态执行的进程 1,进程 1 加载了文件系统并打开终端文件,紧接着就 fork 出了进程 2,进程 2 通过我们刚刚讲述的 execve 函数将自己替换成了 shell 程序。

如果看代码的话,其实我们此时处于一个以 rc 为标准输入的 shell 程序:

// main.c
void main(void) {
    ...
    if (!fork()) {
        init();
    }
    for(;;) pause();
}

void init(void) {
    ...
    // 一个以 rc 为标准输入的 shell
    if (!(pid=fork())) {
        ...
        open("/etc/rc",O_RDONLY,0);
        execve("/bin/sh",argv_rc,envp_rc);
    }
    // 等待这个 shell 结束
    if (pid>0)
        while (pid != wait(&i))
    ...
    // 大的死循环,不再退出了
    while (1) {
        // 一个以 tty0 终端为标准输入的 shell
        if (!(pid=fork())) {
            ...
            (void) open("/dev/tty0",O_RDWR,0);
            execve("/bin/sh",argv,envp);
        }
        // 这个 shell 退出了继续进大的死循环
        while (1)
            if (pid == wait(&i))
                break;
        ...
    }
}

就是 open 了 /etc/rc 然后 execve 了 /bin/sh 的这个程序,代码中L12-L17的部分。

shell 程序有个特点,就是如果标准输入为一个普通文件,比如 /etc/rc,那么文件读取后就会使得 shell 进程退出,如果是字符设备文件,比如由我们键盘输入的 /dev/tty0,则不会使 shell 进程退出。

这就使得标准输入为 /etc/rc 文件的 shell 进程在读取完 /etc/rc 这个文件并执行这个文件里的命令后,就退出了。所以,这个 /etc/rc 文件可以写一些你觉得在正式启动大死循环的 shell 程序之前,要做的一些事,比如启动一个登陆程序,让用户输入用户名和密码。

好了,那作为这个 shell 程序的父进程,也就是进程 0,在检测到 shell 进程退出后,就会继续往下走:

// main.c
void init(void) {
    ...
    // 一个以 rc 为标准输入的 shell
    ...
    // 等待这个 shell 结束
    if (pid>0)
        while (pid != wait(&i))
    ...
    // 大的死循环,不再退出了
    while (1) {
        ...
    }
}

下面的 while(1)死循环里,是和创建第一个 shell 进程的代码几乎一样:

// main.c
void init(void) {
    ...
    // 大的死循环,不再退出了
    while (1) {
        // 一个以 tty0 终端为标准输入的 shell
        if (!(pid=fork())) {
            ...
            (void) open("/dev/tty0",O_RDWR,0);
            execve("/bin/sh",argv,envp);
        }
        // 这个 shell 退出了继续进大的死循环
        while (1)
            if (pid == wait(&i))
                break;
        ...
    }
}

只不过它的标准输入被替换成了 tty0,也就是接受我们键盘的输入。这个 shell 程序不会退出,它会不断接受我们键盘输入的命令,然后通过 fork+execve 函数执行我们的命令,这在上节讲过了。当然,如果这个 shell 进程也退出了,那么操作系统也不会跳出这个大循环,而是继续重试。整个操作系统到此为止,看起来就是这个样子:

// main.c
void main() {
    // 初始化环境
    ...
    // 外层操作系统大循环
    while(1) {
        // 内层 shell 程序小循环
        while(1) {
            // 读取命令 read
            ...
            // 创建进程 fork
            ...
            // 执行命令 execve
            ...
        }
    }
}

当然,这只是表层的。除此之外,这里所有的键盘输入、系统调用、进程调度,统统都需要中断来驱动,所以很久之前我说过,操作系统就是个中断驱动的死循环,就是这个道理。

总结

到此为止,操作系统终于启动完毕,达到了怠速的状态,它本身设置好了一堆中断处理程序,随时等待着中断的到来进行处理,同时它运行了一个 shell 程序用来接受我们普通用户的命令,以同人类友好的方式进行交互。

纵观整个操作系统的源码,前四部分:

  1. 进入内核前的苦力活:系列之一 ~ 三,完成了执行 main 方法前的准备工作,如加载内核代码,开启保护模式,开启分页机制等工作;
  2. 大战前期的初始化工作:系列之四 ~ 六,完成了内核中各种管理结构的初始化,如内存管理结构初始化 mem_init,进程调度管理结构初始化 shed_init 等;
  3. 一个新进程的诞生:系列之七 ~ 十,讲述了 fork 函数的原理,也就是进程 0 创建进程 1 的过程;
  4. shell程序的到来:系列之十一 ~ 十四,讲述了从加载根文件系统到最终创建出与用户交互的 shell 进程的过程。

对应的代码如下,这就是启动流程中的全部代码了:

//--- 第一部分 ---
bootsect.s
setup.s
head.s

//main.c
void main(void) {
//--- 第二部分 ---
    mem_init(main_memory_start,memory_end);
    trap_init();
    blk_dev_init();
    chr_dev_init();
    tty_init();
    time_init();
    sched_init();
    buffer_init(buffer_memory_end);
    hd_init();
    floppy_init();
    sti();
//--- 第三部分 ---
    move_to_user_mode();
    if (!fork()) {
//--- 第四部分 ---
        init();
    }
    for(;;) pause();
}

对于第四部分,首先通过 拿到硬盘信息&加载根文件系统 使得内核具有了以文件系统的形式管理硬盘中的数据的能力:

ch17-4

系列之十二中 打开终端设备文件 使用刚刚建立好的文件系统能力,打开了 /dev/tty0 这个终端设备文件,此时内核便具有了与外设交互的能力,具体可以体现为调用 printf 函数可以往屏幕上打印字符串了:

ch17-5

系列之十二中 进程2的创建 利用刚刚建立好的文件系统,以及进程 1 的与外设交互的能力,创建出了进程 2,此时进程 2 与进程 1 一样也具有与外设交互的能力,这为后面 shell 程序的创建打好了基础:

ch13-1

然后,进程 2 此时摇身一变,在系列之十三中 execv加载并执行shell程序 利用 execve 函数使自己变成了 shell 程序,配合上一回 fork 的进程 2 的过程,这就是 Linux 里经典的 fork + execve 函数。

execve 函数摇身一变的关键,其实就是改变了栈空间中的 EIPESP 的值,使得中断返回后的地址被程序进行了魔改,改到了 shell 程序加载到的内存地址上:

ch14-4a

此时,execve 系统调用的中断返回后,指向了 shell 程序所在的内存地址起始处,就要开始执行 shell 程序了。但此时 shell 程序还没有从硬盘中加载到内存呢,所以此时会触发缺页中断,将硬盘中的 shell 程序(除 exec 头部的其他部分)按需加载到内存,这就是系列之十三中 缺页中断

ch18-5

这回,终于可以开始执行 shell 程序了,在本文第一节中我们以 xv6 源码中的超级简单的 shell 程序源码为例,讲解了 shell 程序的原理:就是不断读取我们用户输入的命令,创建一个新的进程并执行刚刚读取到的命令,最后等待进程退出,再次进入读取下一条命令的循环中。

shell 程序是个死循环,我们再回过头来看操作系统的死循环。在本文第二节最后给出了整个操作系统启动代码的鸟瞰视角代码。可以看出,不仅 shell 程序是个死循环,整个操作系统也是个死循环。除此之外,这里所有的键盘输入、系统调用、进程调度,统统都需要中断来驱动,所以很久之前我说过,操作系统就是个中断驱动的死循环,就是这个道理。

前四个部分,终于把整个操作系统的启动流程讲述清楚了,但理解操作系统不单单是启动流程这个视角,还需要内存管理、文件系统、进程调度、设备管理、系统调用等操作系统提供的功能的视角看。

启动流程是一次性的,就这么来一下子,而这些功能是持续不断的,用户程序不断通过系统调用和操作系统提供的这些功能,完成自己想要让计算机帮忙做的事情。所以接下来的第五部分,我打算用一条 shell 命令的执行过程,来把操作系统这些模块和所提供的功能讲述清楚。因为一条 shell 命令的执行,包括了内存管理、文件系统、进程调度、设备管理、中断控制、特权级切换等等各方面的内容,实在是把它们都串起来的好办法。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值