第4部分小结

第4部分小结

整个操作系统终于通过四个部分的讲解,完成了它的启动,达到了一个怠速状态,留下了一个 shell 程序等待用户指令的输入并执行。

图片

具体来说。

通过 第一部分 | 进入内核前的苦力活 完成了执行 main 方法前的准备工作,如加载内核代码,开启保护模式,开启分页机制等工作,对应内核源码中 boot 文件夹里的三个汇编文件 bootsect.s setup.s head.s

通过 第二部分 | 大战前期的初始化工作 完成了内核中各种管理结构的初始化,如内存管理结构初始化 mem_init,进程调度管理结构初始化 shed_init 等,对应 main 方法中的 xxx_init 系列方法。

通过 第三部分 | 一个新进程的诞生 讲述了 fork 函数的原理,也就是进程 0 创建进程 1 的过程,对应 main 方法中的 fork 函数。

通过 第四部分 | shell 程序的到来 讲述了从加载根文件系统到最终创建出与用户交互的 shell 进程的过程,对应 main 方法中的 init 函数。

至此操作系统启动完毕,达到怠速状态

纵观整个操作系统的源码,前四部分对应的代码如下,这就是启动流程中的全部代码了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// --- 第一部分 进入内核前的苦力活 ---
// 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()) {
// --- 第四部分 shell程序的到来 ---
        init();
    }
    for(;;) pause();
}

具体展开第四部分,我们首先通过 第31回 | 拿到硬盘信息第32回 | 加载根文件系统 使得内核具有了以文件系统的形式管理硬盘中的数据的能力。

图片

接下来 第33回 | 打开终端设备文件 使用刚刚建立好的文件系统能力,打开了 /dev/tty0 这个终端设备文件,此时内核便具有了与外设交互的能力,具体可以体现为调用 printf 函数可以往屏幕上打印字符串了。

图片

再接下来,第34回 | 进程2的创建 利用刚刚建立好的文件系统,以及进程 1 的与外设交互的能力,创建出了进程 2,此时进程 2 与进程 1 一样也具有与外设交互的能力,这为后面 shell 程序的创建打好了基础。

图片

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

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

图片

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

图片

这回,终于可以开始执行 shell 程序了,在 第37回 | shell 程序跑起来了 中我们以 xv6 源码中的超级简单的 shell 程序源码为例,讲解了 shell 程序的原理。

就是不断读取我们用户输入的命令,创建一个新的进程并执行刚刚读取到的命令,最后等待进程退出,再次进入读取下一条命令的循环中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 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 程序是个死循环,我们再回过头来看操作系统的死循环。

第38回 | 操作系统启动完毕 中给出了整个操作系统启动代码的鸟瞰视角。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// main.c
void main() {    
    // 初始化环境    
    ...    
    // 外层操作系统大循环    
    while(1) {        
        // 内层 shell 程序小循环        
        while(1) {            
            // 读取命令 read            
            ...            
            // 创建进程 fork            
            ...            
            // 执行命令 execve            
            ...        
        }    
    }
}

可以看出,不仅 shell 程序是个死循环,整个操作系统也是个死循环。

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

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


我们前四个部分,终于把整个操作系统的启动流程讲述清楚了,如果你头脑中已经有像过电影般把整个启动流程清晰地印在脑子里,相信你已经不再恐惧操作系统源码了。

但理解操作系统不单单是启动流程这个视角,还需要内存管理、文件系统、进程调度、设备管理、系统调用等操作系统提供的功能的视角看。

启动流程是一次性的,就这么来一下子,而这些功能是持续不断的,用户程序不断通过系统调用和操作系统提供的这些功能,完成自己想要让计算机帮忙做的事情。

所以接下来的第五部分,我打算用一条 shell 命令的执行过程,来把操作系统这些模块和所提供的功能讲述清楚。

因为一条 shell 命令的执行,包括了内存管理、文件系统、进程调度、设备管理、中断控制、特权级切换等等各方面的内容,实在是把它们都串起来的好办法。

updatedupdated2024-05-102024-05-10