MINIX 笔记 2 —— From bootloader to MINIX

Oct 27, 2022 / 10 min read

本文是我学习 MINIX 的第二篇笔记,第一篇主要介绍了 x86 架构。这篇着重介绍 MINIX 的启动流程,从按下开机键到执行第一行 MINIX 代码的过程。在读 Operating System Design And Implementation 3rd 一书时,书中对 boot 过程只是几句简单带过,出于个人兴趣,很想弄清楚 boot 阶段到底发生了什么,通过啃代码加查资料,大致弄明白了整个流程,总结在这里。

这里用到的 MINIX 的源代码可以在 MINIX 网站上 下载。这个镜像虽然非常古老了(2005 年),但使用 qemu 仍然是可以安装启动的。

要弄懂整个流程要求熟悉 x86 架构,本文中用到的 x86 术语都可以在上篇中找到,如果不熟悉可以先读一下上篇文章。

From Bootloader

MBR

Bootstrap

按下开机键后,位于 ROM 里面的bootloader会读取 boot 设备(存储设备,比如磁盘或者光驱)的第一个 block,存入一个固定的内存区域,并执行这段代码。这块区域的代码叫 masterboot record (MBR),masterboot 根据硬盘分区信息读取各个分区开头区域的内容,这里保存着 在 MINIX 中,这块区域存储的是 bootblock 的代码,接下来 bootblock 程序会加载当前磁盘分区上的 boot 程序(MINIX 中该程序叫 boot monitor), boot monitor 会根据存储在硬盘上的信息读取 boot 程序,boot 程序会扫描系统配置,找到并读取存储设备上的 MINIX system image,并执行其中的代码——此时就进入到操作系统了。

如果把操作系统看成一个可执行文件的话,system image 就是这个可执行文件,这个过程牵涉到大量硬件相关的底层操作,并且不属于操作系统本身,暂时视为一个暗箱操作,BIOS 从约定好的区域读取 bootblock,bootblock 处的代码会加载 boot 程序,boot 程序加载并执行 MINIX

flowchart TB A["BIOS"] B["masterboot"] C["bootblock"] D["boot monitor 程序"] E["MINIX"] A -->|loads| B B -->|loads| C C -->|loads| D D -->|loads| E

masterboot -> bootblock 这一步是可以跳过的,也就是说,如果直接把 bootblock 的数据复制粘贴到 masterboot 的位置,开机后能直接执行该 bootblock 的代码并进入 MINIX。masterboot 的存在是为了支持多个分区上有独立的 bootblock,比如在磁盘分区 1 上安装 Windows,分区 2 安装 MINIX,masterbootblockblock 这一步提供了选择进入哪个操作系统的机会。 那么 bootblock -> boot monitor 这一步的意义在哪里呢?为什么需要 bootblock 读取 boot 程序这一步呢,因为 BIOS 只会读取 512 bytes 用作配置保存的内容,如果 boot monitor 放在这里,其大小就被限制在 512 bytes 以下了。所以实际的 boot monitor 程序存储在其他区域,bootblock 处存放的是从该区域加载 boot monitor 的代码,通过这种“中间人”的方式绕过了空间限制。

masterboot 的代码在boot/masterboot.s 文件中 ,bootloader 的代码在 boot/bootblock.s 中,两者都是用汇编写的。boot 过程牵涉到大量硬件相关的底层操作,并且不属于 MINIX 本身(boot 本身就是一个微型操作系统),可以暂时视为暗箱操作。这里不做进一步讨论。

Boot Monitor

我们从 boot monitor 开始探究 MINIX 的启动过程。

boot monitor 的代码在 boot 目录下,由 C 代码boot.c 和汇编代码 bootblock.s 两部分组成。可以从从 bootblock.s 中的 boot label 开始看代码。同样这里只挑出 boot 中我觉得非常非常关键的操作,首先就是可用内存信息。

mem

MINIX 中内存布局如图所示(这里的地址都是物理内存地址),BIOS, boot 和 kernel 都位于 1M 以下的内存区域。普通用户进程的可用内存区域分成了两块,55~590KB 段和 3549K 以上的区域。boot monitor 会扫描机器的内存信息并写入下面要提到的 mem 变量中程序太复杂,就无法存在这块区域了,所以 bootblock 扮演一个中转的角色。

boot 程序在 MINIX 中也被称为 monitor program。 在 UNIX 中,一个程序是由 exec 函数开始结果指挥棒,开始执行自己的,同样的我们从 从boot/bootimage.c中的 exec_image 函数开始探索 MINIX。 exec_image 函数接受的是一个 image 指针,这里存储的就是 boot 程序从存储设备中读取出来的 MINIX system image 数据。

mem在变量

boot/.boot.h 中声明:

c ///
#ifndef  EXTERN
#define  EXTERN  extern
#endif

EXTERN  memory  mem[3]; /* List of available memory. */

boot/boot.c

c ///
#undef  EXTERN
#define  EXTERN  /* Empty */
#include  "boot.h"
///

这种声明方式在 MINIX 中经常出现,直接 include<boot.h> 的时候,因为未定义 EXTERN 宏,EXTERN 被替换为 extern,于是 EXTERN memory mem[3] 表现为一个声明。在 boot.c 中,先定义了 EXTERN 宏为空,再进行 include,EXTERN memory mem[3] 展开后变成了定义。这样就不用额外在 boot.c 中写定义 mem 的代码了。

具体怎么扫描可用内存的过程就不细述了,都是硬件相关的汇编代码,下面列出汇编中赋值 mem 的代码。这里有一个很重要的规则:C 代码中的符号名 X,在汇编中要加下划线 _X 来访问。boot.c 中的 mem,在下面的汇编代码里都是以 _mem 的方式访问的。注意这里对 mem[0].sizeu32 数据的赋值用了两个 mov——因为现在 CPU 还在 16 bits Real Mode 下。

mem 是在 asm 代码boot/boothead.s中进行赋值的:

nasmsembly ///
.extern _mem 					! Free memory list

! Determine available memory as a list of (base,size) pairs as follows:
! mem[0] = low memory, mem[1] = memory between 1M and 16M, mem[2] = memory
! above 16M. Last two coalesced into mem[1] if adjacent.

	mov  di, #_mem ! di = memory list
	int  0x12 ! Returns low memory size (in K) in  ax
	
	...
	
	mul c1024
	mov  4(di), ax ! mem[0].size = low memory size in bytes
	mov  6(di), dx
	mov  cx, ax ! cx = copy of ext mem at 1M
	mov  10(di), #0x0010 ! mem[1].base = 0x00100000 (1M)
	
	...
	
	mov  cx, ax ! cx = copy of ext mem at 1M
	mov  10(di), #0x0010 ! mem[1].base = 0x00100000 (1M)
	mul c1024
	mov  12(di), ax ! mem[1].size = "ext mem at 1M" * 1024
	mov  14(di), dx
	
	...
	
	mov  18(di), #0x0100 ! mem[2].base = 0x01000000 (16M)
	mov  22(di), bx ! mem[2].size = "ext mem at 16M" * 64K

	...
	
	! Time to switch to a higher level language (not much higher)
	call _boot
	

顺便提一下,Real Mode 下内存访问也是 Segmention 的方式,但计算方法有点不太一样。Real Mode 中的物理内存地址是由 Segment Selector 中的值直接乘 16 再加上 Offset 组成,没有 Protect Mode 中的 GDT

$$ PhysicalAddress = Segment Selector \times 16 + Offset $$

比如下面的代码:

nasm ///
boot:
	mov  ax, #LOADSEG
	mov  ds, ax

数据段 Data Segment 被设置成了 #LOADSEG (值为 0x1000),意味着后续对数据段内存的访问都是从 0x1000 处开始的。那么:

nasm ///
mov ax, (10)

会读取物理内存 0x10010 处的数据到 ax 中。 这里有个细节可以注意下,设置 ds 时,使用的是 ax 而不是直接用 #LOADSEG 。似乎 Segment register 只能用内存地址或者寄存器来赋值

初始化完 mem 内存信息后,接下来的 call _boot 就跳去 c 代码中执行了(见上面的符号名转换规则)。这里是用户可以看到的 boot 菜单界面。boot 函数定义在 boot/boot.c 中。

c ///
void  boot(void)
/* Load Minix and start it, among other things. */
{
	/* Initialize tables. */
	initialize();
	/* Get environment variables from the parameter sector. */

	get_parameters();
	while (1) {
		/* While there are commands, execute them! */
		while (cmds  !=  nil) execute();

		/* The "monitor" is just a "read one command" thing. */
		monitor();
	}
}

boot 函数是个 REPL,等待用户的输入操作然后执行指令。

c ///
void  initialize(void)
{
	...
  
	/* Copy the boot program to the far end of low memory, this must be
	* done to get out of the way of Minix, and to put the data area
	* cleanly inside a 64K chunk if using BIOS I/O (no DMA problems).
	*/

根据代码中的注释,initialize 函数主要的作用是将 boot monitor 自己拷贝到另一块内存区域,腾出空间给 MINIX kernel。

c ///
	u32_t  oldaddr=  caddr;
	u32_t  memend=  mem[0].base  +  mem[0].size;
	u32_t  newaddr= (memend  -  runsize) &  ~0x0000FL;

#if  !DOS
	u32_t  dma64k= (memend  -  1) &  ~0x0FFFFL;
	/* Check if data segment crosses a 64K boundary. */
	
	if (newaddr  + (daddr  -  caddr) <  dma64k) newaddr=  dma64k  -  runsize;
#endif

	/* Set the new caddr for relocate. */
	caddr =  newaddr;

	/* Copy code and data. */
	raw_copy(newaddr, oldaddr, runsize);

	/* Make the copy running. */
	relocate();

MINIX 的注释十分详细,结合注释应该不难理解上面的代码:首先计算出一个新的内存地址newaddr,将旧地址oldaddr 的数据拷贝过去,然后执行 relocate 跳转到新的内存区域执行。除了 relocate 其他的应该都很直观。下面会详细说明 relocate 的代码。

caddr 中保存的是当前 boot monitor 代码段的内存地址,首次是在 boothead.s 中被赋值(第二次就是上面这段代码):

nasm ///
xor  ax, ax
mov  dx, cs
call seg2abs
mov _caddr+0, ax
mov _caddr+2, dx

第一行代码为设置 ax 寄存器为零,第二行是将 cs 寄存器(回忆一下,这是代码段的 Segment Selectorseeg2absax:dxsegment:offset 转换为 32 bit 内存地址,并将结果的高 16 位放回 ax 中,低 16 位放在 dx 中。最后两行代码把内存地址赋值给 caddr。 我们这里详细看一下 seg2abs 的代码

nasm ///
seg2abs:			! Translate  dx:ax to the 32 bit address dx-ax
	push	cx
	movb	ch, dh
	movb	cl, #4
	shl	dx, cl
	shrb	ch, cl		! ch-dx = dx << 4
	add	ax, dx
	adcb	ch, #0		! ch-ax = ch-dx + ax
	movb	dl, ch
	xorb	dh, dh		! dx-ax = ch-ax
	pop	cx
	ret

首先是保存 cx 到栈中,跟后面的 pop cx 恢复 cx 结成一对,因为我们会在这个函数中"借用" cx 寄存器,但其他人可能之前在 cx 中保存了有用的数据,所以我们有责任在用完后恢复其中的内容,这种模式几乎出现在每一个汇编函数中。seg2abs 计算流程如下图所示,主要思路是从 cx 寄存器的高 8 位 ch 借一些位数过来进行运算。adcb 这个指令会将上一次加法产生的溢出进位标志 (Carry Flag) 计算进来。

text ///
  dx:ax = 1010 1111 0000 0001 : 1000 0000 0000 0001

                           ax 
                 ______________________   
                |          ax          |  
                | 1000 0000  0000 0001 |  
                |     ah    |    al    |  
                 ______________________   


    ch = dh                 dx 
  ___________    ______________________    
 |           |  |          dx          |   
 | 1010 1111 |  | 1010 1111  0000 0001 |   
 |     ch    |  |     dh    |    dl    |   
  ___________    ______________________    


    ch >> 4             dx << 4
  ___________    ______________________    
 |           |  |          dx          |   
 | 0000 1010 |  | 1111 0000  0001 0000 |   
 |     ch    |  |     dh    |    dl    |   
  ___________    ______________________    


                         ax = ax + dx 
                 ______________________   
                |          ax          |  
                | 0111 0000  0001 0001 |  
                |     ah    |    al    |  
                 ______________________   


  ch = 0+ch+cf     dh = 0     dl = ch         # cf: carry flag
  ___________    ______________________    
 |           |  |          dx          |   
 | 0000 1011 |  | 0000 0000  0000 1011 |   
 |     ch    |  |     dh    |    dl    |   
  ___________    ______________________    



  dx:ax = 0000 0000 0000 1011 : 0111 0000  0001 0001


0b1010111100000001 * 16 + 0b1000000000000001 == 0b10110111000000010001

最后的等式是一个合法的 C 语句,可以执行一下看是否成立。

再来看 relocate

nasm ///
_relocate:
	pop  bx ! Return address
	
	...
	
	push  cx ! New text segment
	push  bx ! Return offset of this function
	retf ! Relocate

中间大部分是计算新的 Segment Selector 的内存地址的代码,这里省略了,先看最后一条指令 Far Return retf,这条指令是会从当前的栈上先 pop 出一个值当作新的 ip (Instruction Pointer)下一条代码的地址(相对当前 Segmentation),然后再 pop 出一个值当作新的 cs,即代码段的 Segment Selector,然后跳转到该处执行。通过上述规则我们可以得知,新的 ip 寄存器的值是刚 push 进来的 bx 寄存器的值,而 bx 里面保存的是第一条指令从当时的栈顶 pop 出来的值。这是为什么呢。答案涉及到汇编的函数调用 call 指令。

代码不过是保存在内存中的一段数据而已,所谓函数调用,简单来说就是 goto 另一块内存地址执行存储在那里的指令。但是函数调用还有个关键步骤,执行完函数我们需要"返回",即回到刚刚调用该函数的地方继续执行,怎么实现的呢?其实就是 call 指令在跳转到函数所在的地址之前,先把位于函数调用后的下一条指令的地址保存在了栈上,函数执行完后,ret 指令会从栈上取出来并跳转回该地址执行。 C 函数调用也是同样的机制,不同地方在 C 函数调用是有参数的,除了返回地址,参数也会被 push 到栈上。(C 调用在不同操作系统的实现细节不太一样,比如参数在栈上的顺序)

MINIX 中 C 函数调用的栈(stack frame)如下图所示,右边是函数调用一个接受两个参数的函数 func(999, 888) 时的栈。记住,栈的地址是往下增长的arg 1 所在的内存地址要比 arg 2 低。sp 是栈顶(stack pointer):

text ///

   +--------------+ <- sp      
   |              |            
   |     arg      |              func(999, 888)
   |              |            
   +--------------+             +--------------+ <- sp
   |              |             |              |
   |     arg 2    |             |      999     |
   |              |             |              |
   +--------------+             +--------------+
   |        ...   |             |              |
   |     arg n    |             |     888      |
   |              |             |              |
   +--------------+             +--------------+ 
   |              |             |              |
   |Return address|             |Return address|  
   |              |             |              |
   +--------------+             +--------------+ 

回到刚才的 bx 寄存器上,bx 保存了位于函数调用下一条的指令地址(在内存段内的相对地址),这里是将boot monitor 代码完整拷贝到了另一个内存段内,代码在段内的相对地址还是一样的,所以用旧的相对地址加上新的 Segment Selector,就实现了在新的内存处继续执行当前的代码逻辑。

To MINIX

剩下的 boot monitor 的代码就是处理用户的各种操作以及从磁盘中读取 MINIX 的 boot image。磁盘读取听起来是不是很简单,不就是 open 然后 read 么?等等,我们并没有 open 函数,连文件系统都没有,操作系统都还躺在硬盘里呢,鸡都没有怎么下蛋。boot 中的磁盘读取是用底层的硬件指令实现的。这些代码跟 MINIX 本关系不大又很繁琐,我们只关心最重要步骤,怎么进入 kernel 执行的。这个操作的入口在 bootminix() 函数中,定义在 boot/bootimage.c 文件,该函数把 MINIX 加载到内存中,并记录下来内核代码段数据段的所在内存地址,然后调用 boot monitor 中的 MINIX 的入口函数 minix

c ///
switch (res) {
case  R_BOOT: bootminix(); ok=  1; break;
	...
}
c ///
/* Minix. */
minix(process[KERNEL].entry, process[KERNEL].cs, process[KERNEL].ds, params, sizeof(params), aout);

minix 是个汇编写的函数,参数 process[KERNEL].entry 是内核代码地址的 offsetprocess[KERNEL].cs 是内核代码地址的 base address,(内核入口地址 = base address + offset),minix 定义在 boot/boothead.s 中:

nasm ///
! void minix(u32_t koff, u32_t kcs, u32_t kds,
! char *bootparams, size_t paramsize, u32_t aout);
! Call Minix.

_minix:

	push  bp
	mov  bp, sp ! Pointer to arguments
	...
	cli ! No more interruptions
	test _k_flags, #K_I386 ! Switch to 386 mode?
	jnz minix386

回忆上面关于 MINIX 中 C 调用的 stack frame,此时的栈是这样:

text ///
   +--------------+  24
   |              |
   |     aout     |
   |              |
   +--------------+  20
   |   paramsize  |
   +--------------+  18
   |  bootparams  |  
   +--------------+  16
   |              |
   |     kds      |  14
   |              |
   +--------------+  12
   |              |
   |     kcs      |  10
   |              |
   +--------------+  8
   |              |
   |     koff     |  6
   |              |
   +--------------+  4
   |   ret addr   |  
   +--------------+  2 + bp
   |   bp (old)   |  
   +--------------+ <- bp sp

图右侧为内存地址相对 bp(stack base)的偏移值,栈地址是向下增长的,例如我们要读取 paramsize 的值,应该使用 bp+18 得到的内存地址。bootparams 是个指针 char*,但在图中只占了 2 字节,因为我们现在还在 16 bit Real Mode 下。

minix 函数先禁用了中断,然后判断是否跳转去 386 模式的代码,也就是 Protect Mode 下。Real Mode 下的 MINIX 这里就不提了。

nasm ///
! Call Minix in  386 mode.

minix386:
	cseg mov cs_real-2, cs ! Patch CS  and  DS  into the instructions that
	cseg mov ds_real-2, ds ! reload them when switching back to real mode
	.data1 0x0F,0x20,0xC0 ! mov  eax, cr0
	orb al, #0x01 ! Set PE (protection enable) bit
	.data1 o32
	mov msw, ax ! Save as protected mode machine status word

首先保存当前的 csds,用于 MINIX 退出后恢复 boot monitor 的执行环境。没错,操作系统退出后还有一个"操作系统"在执行。第三行代码很有意思,不看注释是看不懂这句代码的,汇编本质上是给机器指令(二进制)数据取了一个人类能读懂的别名,理论上可以只用 01 来写代码,这里比二进制稍微高级了一点,用了 16 进制。我的猜测是 MINIX 使用的汇编不支持 cr0 这个寄存器(所有操作 cr0 的地方都是用的 “16 进制代码”),只能人工"编译"了…接下来的 o32 也是同样的情况,o32 (0x66) 是个指令前缀,用来改变 Operand Size,比如在 16 位模式下,该前缀让后面的一条指令变成 32 位操作,32 位下则反过来。

nasm ///
p_gdt_desc:
! Descriptor for this descriptor table
.data2 8*8-1, UNSET
.data1 UNSET, 0x00, 0x00, 0x00

...

mov  dx, ds ! Monitor  ds
mov  ax, #p_gdt ! dx:ax = Global descriptor table
call seg2abs
mov p_gdt_desc+2, ax
movb p_gdt_desc+4, dl ! Set base of global descriptor table

接下来开始初始化 GDT,首先将 p_gdt 所在的段内存地址转换为物理内存地址, p_gdtboot monitor 中定义的一个"全局变量"(一块内存区域),当然存在于 boot monitorData Segement 中。 16 位下的 gdt descriptor内存布局如下:

text ///
   +-------------------------------------------+  
   |  limit    |   base addr  |                |  
   |  0000 0000 0000 0000 0000 0000 0000 0000  |  
   +-------------------------------------------+  

所以上面的代码将转换出来的物理地址 dx:ax 放入 base addr 中,且dx的高 8 位 dh 被抛弃了,16 位模式下只有 24 位 base addr 被使用,高 8 位被置为 0。 limit 在 定义 p_gdt 的时候已经指指定(8*8-1),对应 8 条 descriptor每条占用 8 bytes。

然后是代码段的 cs descriptor 和数据段的 ds descriptords descriptor 来源是 bp 往前偏移 12 字节后,长度 4 字节的内存数据。参考上面的 stack frame 图可以得知是 minix 函数的 kdskernal data segment)参数。同样 cs descriptorbp+8 对应 kcskernal code segment)参数。

nasm ///
mov  ax, 12(bp)
mov  dx, 14(bp) ! Kernel ds (absolute address)
mov p_ds_desc+2, ax
movb p_ds_desc+4, dl ! Set base of kernel data segment

mov  ax, 8(bp)
mov  dx, 10(bp) ! Kernel cs (absolute address)
mov p_cs_desc+2, ax
movb p_cs_desc+4, dl

ss 使用的还是 boot monitor 的栈段:

nasm ///
mov  dx, ss ! Monitor  ss
xor  ax, ax ! dx:ax = Monitor stack segment
call seg2abs ! Minix starts with the stack of the monitor
mov p_ss_desc+2, ax
movb p_ss_desc+4, dl

剩下几个没那么重要的就不列出来了,以上 GDT 的数据就已经设置好了。

下面两行将boot monitorcs selector 和标签 ret386 的内存压入栈中,供MINIX 执行完(退出操作系统)后的 retf 使用,回来后会 goto ret386。这里压入栈的 #CS_SELECTOR 是个常量,代表 GDT 中的下标,而不是之前的 cs 寄存器的值,因为从 MINIX 回来的时候,是在 Protect Mode 模式下。

nasm ///
push #MCS_SELECTOR
push #ret386 ! Monitor  far return address

紧接着四个 push 指令,分别把 MINIX 的 code segment selectorsegment offset koffbp+ 4)参数压入栈中。我们马上要去到的是 32 位模式,因此这里 selectoroffset 分别用了两个 16 位 push

nasm ///
push #0
push #CS_SELECTOR
push  6(bp)
push  4(bp) ! 32 bit far address to kernel entry point

到此为止已经设置好了 GDT,且栈顶保存着 MINIX kernel 的入口地址,如果理解了前面的内容的话,应该不难猜到现在只差一个 retf 就可以跳转进入 MINIX 内核了。但在此之前还有个非常重要的步骤——切换到 32 位 Protect Mode:

nasm ///
! Switch from real to protected mode.
real2prot:
	movb ah, #0x02 ! Code for A20 enable
	call gate_A20
	lgdt p_gdt_desc ! Global descriptor table
	.data1 o32
	mov  ax, pdbr ! Load page directory base register
	.data1 0x0F,0x22,0xD8 ! mov  cr3, eax
	.data1 0x0F,0x20,0xC0 ! mov  eax, cr0
	.data1 o32
	xchg  ax, msw ! Exchange real mode msw for protected mode msw
	.data1 0x0F,0x22,0xC0 ! mov  cr0, eax
	jmpf cs_prot, MCS_SELECTOR ! Set code segment selector
cs_prot:
	mov  ax, #SS_SELECTOR ! Set data selectors
	mov  ds, ax
	mov  es, ax
	mov  ss, ax
	ret
	
...
	
call real2prot ! Switch to protected mode
mov  ax, #DS_SELECTOR ! Kernel data
mov  ds, ax
mov  ax, #ES_SELECTOR ! Flat 4 Gb
mov  es, ax
.data1 o32 ! Make a far  call to the kernel
retf

real2prot 大部分代码是切换模式的固有流程。刚刚初始化好的 GDT 数据就是在这个函数里用 lgdt 加载生效的。最后几个 mov 是设置 dsesGDT 中的相应下标。末尾是等待已久的 retf,就是这个 retf 完成了 From Bootloader to MINIX。

小结

以上就是非常粗略的整个 MINIX 启动过程,因为牵涉到大量底层代码和 x86 架构的缘故,很难把握准每一个细节,汇编的可读性也非常差,好在 MINIX 的注释很详细(本来就是教育用途)。但从抽象的角度来看,是很简单的,无非就是几次加载执行代码加跳转,没有什么奇技淫巧。

///

本文的名字模仿了 Linux Inside的第一章 From the bootloader to the kernel,如果对 Linux 底层感兴趣,这是个还不错的系列。