这次拖得有够久的,毕竟需要将知识串联起来并不是一件容易的事情。更何况很多内容可以说和常理(个人理解的常理)有了比较大的偏差。

不过确实比较有意思。从引导程序到操作系统启动,这中间究竟经历了多少流程呢?

由于前几篇已经有过介绍,这里不会再对引导程序及汇编语法做过多的介绍。而着重描述整个操作系统的启动流程。

引导程序

从 BIOS 将512字节长的引导程序加载到物理内存0x7c00开始的连续递增空间后,即将程序的执行权交给了引导程序(这里指的执行权可以简单理解成CS:IP)

CS:IP

CS. 代码段基址。一般用于划定一段连续代码开始的地址。是一个16位段寄存器,类似的还有 DS, ES, FS, GS, SS 等16位段寄存器,分别提供不同的段基址存储功能) IP. 代码段偏移值。配合 CS 共同为 CPU 确定下一个将要被执行的机器指令的线性地址。

Linux 0.11 版本的引导程序实现的支持比较简单。

  1. 将引导程序代码(自身) 512 字节的内容移动到 0x90000 开始的 512B 内存空间上
  2. 跳到 0x90000 开始的段的相应位置继续执行
  3. 从磁盘中读取 4*512 字节的 setup 程序的二进制内容
  4. 读取操作系统的二进制内容到内存 0x10000 开始的连续地址空间中 (此处最大可以有 0x90000 - 0x10000 = 0x80000 = 512 KB,即当时的操作系统二进制程序+数据最大只能是 512KB, 不过也很多了)
  5. 确认将要作为文件系统的磁盘是否存在
  6. 将控制权交给 setup 程序

SETUP 程序

读取硬件配置

setup 程序,顾名思义,就是在搞一些配置。那么究竟在配置什么?

就是通过 BIOS 中断读取各种硬件信息(诸如内存大小,显示器长宽比,磁盘等),并将它们存储在内存的指定位置。

哈哈,由于引导程序已经执行完了,再也不会使用了。因此,setup 程序就是将这些信息存储到了原引导程序所在的位置,即 0x90000 ~ 0x901FF 共 512 B 的内存中。

当然,每个字节存的是什么内容都是一种约定(比如 setup 程序把内存大小的信息存到了哪,后面其它程序就到相应的位置去取)。

移动操作系统程序指令

OK,setup 程序也不是仅仅只干这么点事情的,不然要 4*512 字节岂不是太浪费了,哪用得了这么多。

setup 还要负责将操作系统程序移动到方便的位置。前面将操作系统程序加载到了内存 0x10000 处,主要是为了暂时保持 BIOS 中断向量表 (位于 0x0000 开始的连续 1024 B 内存中,由 BIOS 程序创建,用于各种中断调用) 。在 setup 决定不再使用 BIOS 中断之后,就已经可以完全废弃了。

因此,操作系统程序将从 0x10000 顺次移动到 0x00000 的位置。

重置中断

软中断与硬中断,应该是在软硬件划分下唯二的两种中断形式。软中断一般通过汇编指令 INT {中断号} 的形式通过软件执行的形式产生,而硬中断是硬件由于某些错误指令而自动产生的中断。

当然,这些中断都需要 CPU 进行相应的处理。那么,在 BIOS 中断向量表被覆盖了之后,如何来处理这些中断呢?

首先,在上一步 移动操作系统程序 开始时,就直接通过汇编指令 cli 强制禁止除 非可屏蔽中断 外的所有中断,因此也就基本不考虑中断的问题。

但是,总还得解除禁止吧。因此,setup 程序尽快地重新编制了硬件中断,进入保护模式。使用保护模式下的中断描述符表来替代 BIOS 中断向量表。

至于如何创建中断描述符表,哈哈,这都是直接在汇编编程的时候写好的。直接划出了一块区域写上了中断描述符表的表项,然后与 setup 代码一起被读取到了内存的指定位置。至于唯一要做的,就是将 IDTR (中断描述符表寄存器) 修改为中断描述符表在内存的开始地址。

进入保护模式

首先,简单介绍一下实模式 & 保护模式。

保护模式与实模式的主要区别,就在于两者的内存寻址方式,归根结底也就是段寄存器如何辅助完成内存地址定位的问题。

先简单的回顾下实模式下的寻址方式

段寄存器的出现,很大程度上是为了对 16 bit CPU 下可寻址地址不足所作出的补充。 诸如 CS:IP = 0x07C0:0x0001 -> 0x07C01 。通过CS:IP 的配合,物理地址 = CS « 4 + IP ,将原本 128 KB 的可寻址空间扩展到 0xFFFF:0xFFFF -> 0x10FFFE 的寻址空间

而保护模式的出现,也是由于实模式下的地址寻址仍然不能满足对内存寻址的需求。

那么保护模式究竟将如何进行寻址呢?简单来说就是通过新增了一个寄存器 GDTR (全局描述符表寄存器。当然,暂时不考虑 LDTR) 用来存储全局描述符表(全局描述符表是一些在内存中的有结构的数据) ,那么类似的,本来是段寄存器中是表示一个内存段基址,现在通过借助中间项 (GDTR) ,由全局描述符表的每一个表项来表示每一个内存段基址,从而达到在最大 4GB 的内存中进行内存地址的寻址。

而与上面的中断描述符表类似的,全局描述符表也是以同样的方式,预先写好,然后设置一下 GDTR 就 OK 了。

转入操作系统程序

最后的最后,当然是把控制权交给操作系统程序。即跳转到内存 0x00000 处。当然,由于现在已经处于保护模式下,因此 jmp 指令略有不同。 为 jmpi 0, 8

简单解释一下这一条指令

jmpi 指令表示进行段间跳转。(段间跳转与段内跳转的区别在于: 程序代码的寻址一般通过 CS:IP 配合完成,如果是段内跳转,则段基址不变,即 CS 不变,只需要给出 IP 即可;而段间跳转需要同时给出新的段基址(CS)和新的段偏移(IP)。

0 这里 0 就表示的是段偏移。

8 保护模式下的段寄存器以一种特殊的方式来配合 GDTR/LDTR 来完成对全局/局部描述符表项的定位。如下图所示。最后两位表示特权级,用于区分用户态(11) or 内核态(00) (Linux 只使用了两个,还有 01 和 10 不使用)

第三位表示使用 GDTR 还是 LDTR

高 13 位共同组成了对 GDTR/LDTR 表项的描述,究竟使用哪一个表项。当然,这里其实也侧面反映了,其实每个全局描述符表/局部描述符表最多只能有 $2^{13} = 8192$ 个表项。

操作系统程序

终于要到操作系统程序了。这个就比较复杂了。这里只简单描述将操作系统启动起来的最基本使用到的代码。其它以后继续。

其实整一个操作系统程序是一个比较大的量,毕竟最大能够达到 512 KB 呢。

简单的看一下文档树,见附录一,编译前的源代码文件总计 100 多个。

首先被执行的是 head.s 中的代码,这里完成的工作主要包括:

  1. 重新编制每个中断的具体处理代码(这个应该比较好理解,说到底中断出现了,也还是要通过实现代码来完成中断逻辑。不论是 BIOS 中断向量还是保护模式下使用的中断描述符表,都不过是记录了一下中断处理代码的位置,进行了定位而已)。

  2. 初始化分页模式(不详述,以后有机会在说)

  3. 验证 80387 数学协处理器。

  4. 进入 main.c 程序 (既然 Linus 都将其命名为 main 了,那么显然这是整个操作系统最核心的部分了)。

下面将简单给出 main.c 程序的两段代码 main(...)init(...),并直接针对代码进行直接解释。

main(void)

void main(void)		
{
 	ROOT_DEV = ORIG_ROOT_DEV;   // 读取在引导程序执行时获取到的文件系统所在的磁盘
 	drive_info = DRIVE_INFO;
	memory_end = (1<<20) + (EXT_MEM_K<<10); // 首先先确认整个内存的大小
	memory_end &= 0xfffff000;               // 当然,这里为了分页方便 (每页 4096 B),直接将不满一页的多余内存忽略掉
	if (memory_end > 16*1024*1024)          // 最大支持 16 MB 的内存,这里是 Linus 当时的机器只有 16 MB,因此没有做更大内存的支持,但可以自己去改源码
		memory_end = 16*1024*1024;
	if (memory_end > 12*1024*1024)          // 确认缓冲区的末地址 (根据实际内存大小调整, >12MB 留 4MB 做高速缓冲区,>6MB 留 2 MB 缓冲,否则 1MB )
		buffer_memory_end = 4*1024*1024;
	else if (memory_end > 6*1024*1024)
		buffer_memory_end = 2*1024*1024;
	else
		buffer_memory_end = 1*1024*1024;
	main_memory_start = buffer_memory_end;
#ifdef RAMDISK
	main_memory_start += rd_init(main_memory_start, RAMDISK*1024);      // 如果需要虚拟盘,则再留一部分作为交换区
#endif
	mem_init(main_memory_start,memory_end); // 内存初始化,毕竟 Linux 操作系统下的所有内存都将被统一分配,用户程序没有权限随便使用内存,只能用被分配的
	trap_init();            // 初始化中断
	blk_dev_init();         // 初始化块设备
	chr_dev_init();         // 初始化字符设备
	tty_init();             // 初始化 tty
	time_init();            // 设置开机启动时间
	sched_init();           // 初始化任务调度程序,由此就将可以进行多任务切换了
	buffer_init(buffer_memory_end); // 缓存区初始化
	hd_init();              // 硬盘初始化
	floppy_init();          // 软盘初始化
	sti();                  // 不再禁止中断,现在开始又允许中断了
	move_to_user_mode();    // 进入用户态
	if (!fork()) {          // 关于 fork 函数,下面将简单介绍。
		init();
	}

	for(;;) pause();
}

fork()

如果熟悉 C 语言,应该熟悉 fork 函数的作用。它创建一个新的任务 (任务是操作系统自己抽象出来的概念,这里将原来的操作系统程序认为是 0 号任务,将产生 1 号进程/任务)。新的任务与原任务几乎一模一样,除了 fork() 调用的返回值不同。子任务将返回 0 ,原任务将返回子任务的任务号。

因此,对于上面的代码,if(!fork()) ,0号任务将不执行 if(){} 语句块内的 init ,而 1 号任务将执行 init() 函数

0 号进程要继续做什么呢?

很简单,下面 for(;;) pause();pause() 是指让处理器完全停止,只有发生硬件中断, CPU 才会从停止状态中恢复 (前面已经设置了定时的/ 10ms/次的时钟中断,因此处理器总是能够恢复运行),

具体的描述就是,如果任务调度程序把时间片分配给了 0 号进程,那么 0 号进程唯一做的,就是让 CPU 停止。而且 0 号进程的 for 是死循环,每次把时间分配给 0 号进程,它就 CPU 停止。我们在 Linux 操作系统上,通过 top 可以看到对 cpu 有如下描述

%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st

其中对 idle(id) 的统计,其实就在 0 号任务让 cpu 停止工作了而已。

init(void)

void init(void)
{
	int pid,i;

	setup((void *) &drive_info);
	(void) open("/dev/tty0",O_RDWR,0);
	(void) dup(0);
	(void) dup(0);
	printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS,
		NR_BUFFERS*BLOCK_SIZE);
	printf("Free mem: %d bytes\n\r",memory_end-main_memory_start);
	if (!(pid=fork())) {
		close(0);
		if (open("/etc/rc",O_RDONLY,0))
			_exit(1);
		execve("/bin/sh",argv_rc,envp_rc);
		_exit(2);
	}
	if (pid>0)
		while (pid != wait(&i))
			/* nothing */;
	while (1) {
		if ((pid=fork())<0) {
			printf("Fork failed in init\r\n");
			continue;
		}
		if (!pid) {
			close(0);close(1);close(2);
			setsid();
			(void) open("/dev/tty0",O_RDWR,0);
			(void) dup(0);
			(void) dup(0);
			_exit(execve("/bin/sh",argv,envp));
		}
		while (1)
			if (pid == wait(&i))
				break;
		printf("\n\rchild %d died with code %04x\n\r",pid,i);
		sync();
	}
	_exit(0);	/* NOTE! _exit, not exit() */
}

那么 1 号任务在做什么呢?看 init 的代码,可以看到大量诸如 /dev/tty0, /bin/sh 的代码。哈哈,下面通过 1,2,3… 对步骤进行标号简单描述一下。

  1. 1 号进程在做的是 fork 出新的进程(暂时叫它进程 X )

  2. 1 号进程不断询问 X 是否被销毁

    • 如果 X 进程被销毁了:那么继续执行步骤 1
    • 否则:继续执行步骤 2 (为什么会出现不断循环呢?其实由于任务调度程序的存在,在单个 CPU 看来,这个轮询并不一直发生,有一些时间片已经给了其他进程继续执行任务了
  3. X 任务通过调用 execve() 把当前程序的代码+数据全部替换成是 shell 的 (其实大量的用户程序都是通过这种 fork + execve 的形式出现的)

  4. X 任务(也就是 Shell 程序) 开始通过 tty 与用户开始交互。直到用户选择 exit 退出 shell

execve(…)

前面介绍过 fork 是复制一份程序的各个寄存器的状态 + 程序的数据。现在 execve 做出的操作是直接将当前程序的代码和数据替换成目标程序的代码和数据,并通过对寄存器一定的重置,完成一个用户程序的启动流程(程序启动说到底不就是在内存中存在这个程序的代码,然后通过 CS:IP 定位到即将执行的程序指令,最后让 CPU 开始执行就 OK 了)

小结

整个操作系统的启动流程就简单地介绍到这里。

0 号任务作为操作系统的常驻程序,在 OS 启动完成,每次时间片被分配给它,它就让 CPU 停止工作(通过 pause() 系统调用)

1 号任务作为操作系统的常驻程序,唯一的任务就是判断进程X是否被退出,如果退出了,就重新创建一个(当然,也可能有人有疑问,为什么现在的 Linux 开多个 bash 都没有问题呢? 这里有个猜测是有一个隐藏的 shell 是被 1 号任务启动的,其它的 bash 是多余的。不过这只是猜测,待考察)

附录

Linux 0.11 源码目录结构

.
|-- Makefile
|-- boot
|   |-- head.s
|-- fs
|   |-- Makefile
|   |-- bitmap.c
|   |-- block_dev.c
|   |-- buffer.c
|   |-- char_dev.c
|   |-- exec.c
|   |-- fcntl.c
|   |-- file_dev.c
|   |-- file_table.c
|   |-- inode.c
|   |-- ioctl.c
|   |-- namei.c
|   |-- open.c
|   |-- pipe.c
|   |-- read_write.c
|   |-- stat.c
|   |-- super.c
|   `-- truncate.c
|-- include
|   |-- a.out.h
|   |-- asm
|   |   |-- io.h
|   |   |-- memory.h
|   |   |-- segment.h
|   |   `-- system.h
|   |-- const.h
|   |-- ctype.h
|   |-- errno.h
|   |-- fcntl.h
|   |-- linux
|   |   |-- config.h
|   |   |-- fdreg.h
|   |   |-- fs.h
|   |   |-- hdreg.h
|   |   |-- head.h
|   |   |-- kernel.h
|   |   |-- mm.h
|   |   |-- sched.h
|   |   |-- sys.h
|   |   `-- tty.h
|   |-- signal.h
|   |-- stdarg.h
|   |-- stddef.h
|   |-- string.h
|   |-- sys
|   |   |-- stat.h
|   |   |-- times.h
|   |   |-- types.h
|   |   |-- utsname.h
|   |   `-- wait.h
|   |-- termios.h
|   |-- time.h
|   |-- unistd.h
|   `-- utime.h
|-- init
|   |-- main.c
|-- kernel
|   |-- Makefile
|   |-- asm.o
|   |-- asm.s
|   |-- blk_drv
|   |   |-- Makefile
|   |   |-- blk.h
|   |   |-- floppy.c
|   |   |-- hd.c
|   |   |-- ll_rw_blk.c
|   |   `-- ramdisk.c
|   |-- chr_drv
|   |   |-- Makefile
|   |   |-- console.c
|   |   |-- keyboard.S
|   |   |-- rs_io.s
|   |   |-- serial.c
|   |   |-- tty_io.c
|   |   `-- tty_ioctl.c
|   |-- exit.c
|   |-- fork.c
|   |-- fork.i
|   |-- math
|   |   |-- Makefile
|   |   `-- math_emulate.c
|   |-- mktime.c
|   |-- panic.c
|   |-- printk.c
|   |-- sched.c
|   |-- sched.o
|   |-- signal.c
|   |-- sys.c
|   |-- system_call.o
|   |-- system_call.s
|   |-- traps.c
|   |-- traps.o
|   `-- vsprintf.c
|-- lib
|   |-- Makefile
|   |-- _exit.c
|   |-- close.c
|   |-- ctype.c
|   |-- dup.c
|   |-- errno.c
|   |-- execve.c
|   |-- malloc.c
|   |-- open.c
|   |-- setsid.c
|   |-- string.c
|   |-- wait.c
|   `-- write.c
|-- mm
|   |-- Makefile
|   |-- memory.c
|   `-- page.s
`-- tools
    `-- build.c
  __                    __                  
 / _| __ _ _ __   __ _ / _| ___ _ __   __ _ 
| |_ / _` | '_ \ / _` | |_ / _ \ '_ \ / _` |
|  _| (_| | | | | (_| |  _|  __/ | | | (_| |
|_|  \__,_|_| |_|\__, |_|  \___|_| |_|\__, |
                 |___/                |___/