Linux-0.11 boot目录head.s详解

模块简介

从这里开始,内核完全是在保护模式下运行了。head.s汇编程序与前面的语法格式不同,它采用的是AT&T汇编格式,需要使用GNU的gas和gld进行编译链接。

在head.s中,操作系统主要做了如下几件事:

  • 重新设置中断描述符和全局描述符
  • 检查A20地址线是否开启
  • 检查x87数学协处理器
  • 初始化页表并开启分页
  • 跳转到main函数执行

过程详解

step1:重新设置IDT和GDT

下面是head.s的17-32行,其作用是重新设置IDT和GDT。

在setup.s中我们已经设置过了IDT和GDT, 为什么还要再设置一遍?

因为setup.s中设置的IDT和GDT后面会被覆盖,因此在head.s中会重新设置一遍。

.globl startup_32
startup_32:
	movl $0x10,%eax      !0x10 = 0000000000010_00_0, GDT表中的第2项,即内核数据段
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	mov %ax,%gs
	lss stack_start,%esp   !定义在sched.c中 
	call setup_idt     !设置中断
	call setup_gdt     !设置全局描述符表
	movl $0x10,%eax		# reload all the segment registers
	mov %ax,%ds		    # after changing gdt. CS was already
	mov %ax,%es		    # reloaded in 'setup_gdt'
	mov %ax,%fs
	mov %ax,%gs
	lss stack_start,%esp

这段代码的开始依次将ds,esfsgs设置为0x10

接下来设置了栈指针。

lss stack_start,%esp   !定义在sched.c中 

栈顶指针的位置定义在了sched.c中,因此这样操作之后,ss = 0x10, esp指向了user_stack的最后一个元素。

long user_stack [ PAGE_SIZE>>2 ] ;
struct {
	long * a;
	short b;
	} stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };

接着调用setup_idt方法对中断描述符进行初始化,setup_idt方法位于head.s的88-95行:

setup_idt:
	lea ignore_int,%edx        // 将ignore_int的地址传递给edx
	movl $0x00080000,%eax      // 将选择符0x0008放入eax的高16位中
	movw %dx,%ax		       // 将偏移值的低16位移入ax中
	movw $0x8E00,%dx	       /* interrupt gate - dpl=0, present */

	lea idt,%edi
	mov $256,%ecx
rp_sidt:
	movl %eax,(%edi)
	movl %edx,4(%edi)
	addl $8,%edi
	dec %ecx
	jne rp_sidt
	lidt idt_descr
	ret

在阅读该段代码之前,需要首先了解中断门描述符的格式,如下所示:

中断门描述符格式

代码中使用eax作为中断门的0-31位, edx作为中断门的32-63位。

首先观察对于eax的操作。将ignore_int的地址赋给了edx,随后将0x0008赋值给eax。最后将ignore_int的低16位放到的eax中。

	lea ignore_int,%edx        // 将ignore_int的地址传递给edx
	movl $0x00080000,%eax      // 将选择符0x0008放入eax的高16位中
	movw %dx,%ax		       // 将偏移值的低16位移入ax中

操作结束之后eax的构成如下所示,其实就是组装好了中断描述符的低31位。

31                                     0
+------------------+-------------------+
+     段描述符      +   偏移地址低16位   +
+------------------+-------------------+
+       0x8        + ignore_int[15:0] +
+------------------+-------------------+
+                 EAX                  +
+--------------------------------------+

接下来构建edxedx的高16位先前已经组装好,存储的是ignore_int[31:16]edx的低16位存储的是中断描述符的属性,设置存在位P为1, DPL=0。

movw $0x8E00,%dx	 // 0x8E00 = 1_00_0111000000000

组装好之后的edx的布局如下所示:

63                                      32 
+------------------+-+-+------+---+-----+
+                  | |D |     |   |     +
+  偏移地址高16位   |P|P |01110|000|     +
+                  | |L |     |   |     +
+------------------+-+--+-----+---+-----+
+ ignore[31:16]   |1|00|01110|000|00000+
+------------------+-+--+-----+---+-----+
+                 EDX                   +
+---------------------------------------+

接下来的事情就比较简单了,循环的给中断表中的256项内容都设置成哑中断(ignore_int)。最后使用lidt idt_descr加载中断描述符表ldit要求6字节操作数,前2字节是idt表的限长,后4字节是idt表在线性空间的32位基地址。

	lea idt,%edi
	mov $256,%ecx
rp_sidt:
	movl %eax,(%edi)
	movl %edx,4(%edi)
	addl $8,%edi
	dec %ecx
	jne rp_sidt
	lidt idt_descr
	ret

下图显示了setup_idt的之后,中断描述符的情况:

在这里插入图片描述

这里再看一下哑中断(ignore_int)做了些什么,其位于head.s的148-172行。

/* This is the default interrupt "handler" :-) */
int_msg:
	.asciz "Unknown interrupt\n\r"
.align 2
ignore_int:
	pushl %eax
	pushl %ecx
	pushl %edx
	push %ds
	push %es
	push %fs
	movl $0x10,%eax
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	pushl $int_msg
	call printk
	popl %eax
	pop %fs
	pop %es
	pop %ds
	popl %edx
	popl %ecx
	popl %eax
	iret

该方法其实只是会调用printk向中断打印一句Unknown interrupt

接下来继续看setup_gdt,其比较简单,直接使用lgdtgdt_descr加载进全局描述符寄存器。

	lgdt gdt_descr
	ret

gdt_descr内容如下所示,设置了长度为256*8字节, 地址位于gdt

gdt_descr:
	.word 256*8-1		# so does gdt (not that that's any
	.long gdt		# magic number, but it works for me :^)

gdt处定义的内容如下所示:

gdt:	.quad 0x0000000000000000	/* NULL descriptor */
	.quad 0x00c09a0000000fff	/* 16Mb */ 0x08
	.quad 0x00c0920000000fff	/* 16Mb */  0x10
	.quad 0x0000000000000000	/* TEMPORARY - don't use */
	.fill 252,8,0			/* space for LDT's and TSS's etc */

gdt表中第一项是一个空置。第二项和第三项是内核代码段和数据段。其含义如下所示:

0x00c09a00_00000fff

  • 段基址 = 0x00000000
  • 段长度 = 0xfff+1 = 4096 * 4Kb = 16MB
  • 段类型值 = 9a, 代表存在于内存中,段特权级别为0,可读可执行代码段,段代码是32位,颗粒度是4KB

0x00c09200_00000fff

  • 段基址 = 0x00000000
  • 段长度 = 0xfff+1 = 4096 * 4Kb = 16MB
  • 段类型值 = 92, 代表存在于内存中,段特权级别为0,可读可写数据段,段代码是32位,颗粒度是4KB

后续的252项是LDTTSS,这里为其开启存储空间,后续会对其进行操作。

程序的最后,重新给段寄存器进行赋值。再次设置为0x10。

	movl $0x10,%eax		# reload all the segment registers
	mov %ax,%ds		    # after changing gdt. CS was already
	mov %ax,%es		    # reloaded in 'setup_gdt'
	mov %ax,%fs
	mov %ax,%gs
	lss stack_start,%esp

step2:检查A20地址线是否开启

下面用于检测A20地址线是否已经开启。

	xorl %eax,%eax
1:	incl %eax		# check that A20 really IS enabled
	movl %eax,0x000000	# loop forever if it isn't
	cmpl %eax,0x100000
	je 1b

如果没有开启A20地址线,那么其寻址空间是0-fffff。超过fffff的部分的地址的高位将会被移除,这就会产生地址环绕。

例如1_00000去除了高位的1之后,就是000000。对00000处写一个值,然后看1_00000处的值是否相同,如果相同,则代表产生了地址环绕,A20没有开启。如果不相同,则代表没有地址环绕,A20成功开启。

step3: 检查数学协处理器

下面head.s的45-65行,用于检查x87数学协处理器芯片是否存在, x87数学协处理器主要用于浮点数的计算,x86_64下浮点数运算的指令有xmm和x87两种。我的另一篇文章汇编语言-浮点数中有相关介绍。

	movl %cr0,%eax		# check math chip
	andl $0x80000011,%eax	# Save PG,PE,ET
/* "orl $0x10020,%eax" here for 486 might be good */
	orl $2,%eax		# set MP
	movl %eax,%cr0
	call check_x87
	jmp after_page_tables

/*
 * We depend on ET to be correct. This checks for 287/387.
 */
check_x87:
	fninit          !向协处理发出初始化命令
	fstsw %ax       !取协处理器状态字到ax寄存器中
	cmpb $0,%al
	je 1f			/* no coprocessor: have to set bits */
	movl %cr0,%eax
	xorl $6,%eax		/* reset MP, set EM */
	movl %eax,%cr0
	ret

这里检查的主要思路是修改控制寄存器CRO,假设协处理器存在,执行一个协处理器指令,如果出错则说明协处理器不存在。

这里首先修改了cr0寄存器,将MP位为设置为1。

	movl %cr0,%eax		# check math chip
	andl $0x80000011,%eax	# Save PG,PE,ET
/* "orl $0x10020,%eax" here for 486 might be good */
	orl $2,%eax		# set MP
	movl %eax,%cr0

这里需要了解一下cr0寄存器的结构:

比特位名称完整的名称描述
0PE启用保护模式如果为1,则启用保护模式,否则系统处于实模式
1MP监控协处理器控制 WAIT/FWAIT 指令与 CR0 中 TS 标志的交互
2EM仿真如果设置,则不存在 x87 浮点单元,如果清除,则存在 x87 FPU
3TS任务切换仅在使用 x87 指令后才允许在任务切换时保存 x87 任务上下文
4ET扩展类型在 386 上,它允许指定外部数学协处理器是 80287 还是 80387
5NE数学错误设置时启用内部 x87 浮点错误报告,否则启用 PC 风格 x87 错误检测
16WP写保护设置后,当特权级别为 0 时,CPU 无法写入只读页
18AM对齐掩码如果设置了 AM、设置了 AC 标志(在 EFLAGS 寄存器中)且特权级别为 3,则启用对齐检查
29NW非直写全局启用/禁用直写式缓存
30CD缓存禁用全局启用/禁用缓存
32PG分页如果为 1,则启用分页并使用 § CR3 寄存器,否则禁用分页。

这里向协处理器发出初始化命令,取协处理器状态字到ax寄存器中,如果协处理器储不存在,则al = 0

	fninit
	fstsw %ax
	cmpb $0,%al

如果存在,则将80287设置为保护模式,这里不用过多理解,大概了解即可。

.align 2
1:	.byte 0xDB,0xE4		/* fsetpm for 287, ignored by 387 */
	ret

如果协处理器不存在,需要将MP位设置为0, 将EM位设置为1。

	movl %cr0,%eax
	xorl $6,%eax		/* reset MP, set EM */
	movl %eax,%cr0
	ret

step4:初始化页表并开启分页

下面是head.s的200-220行,其作用是初始化页表,并开启分页功能。

after_page_tables:
	pushl $0		# These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6		# return address for main, if it decides to.
	pushl $main
	jmp setup_paging

setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax
	xorl %edi,%edi			/* pg_dir is at 0x000 */
	cld;rep;stosl
	movl $pg0+7,pg_dir		/* set present bit/user r/w */
	movl $pg1+7,pg_dir+4		/*  --------- " " --------- */
	movl $pg2+7,pg_dir+8		/*  --------- " " --------- */
	movl $pg3+7,pg_dir+12		/*  --------- " " --------- */
	movl $pg3+4092,%edi
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b
	cld
	xorl %eax,%eax		 !设置页目录表基址寄存器cr3的值
	movl %eax,%cr3		
	movl %cr0,%eax       !设置启动使用分页处理
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

建立页表的第一步是对页目录表页表项进行清零的初始化操作。

setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax
	xorl %edi,%edi			/* pg_dir is at 0x000 */
	cld;rep;stosl

由于后面会使用rep前缀,因此首先需要设置循环的次数。页目录表和页表的总大小是1024*4*(4+1),由于我们使用的是stosl,即一次进行4个字节的初始化操作,于是ecx设置为1024*5

xorl %eax,%eaxxorl %edi,%edieaxedi设置为0。

最后使用cld;rep;stosl进行循环赋值。将eax的值依次赋值给0x0, 0x4, 0x8 ...

总结起来,这里的作用就是将页目录表和页表全部清零。

接下来的操作是给页目录表进行赋值。这里我们回顾一下页目录项和页表项的结构。其中高20位代表的是帧地址。第0位表示存在位,第1位表示读写标志位,第2位表示用户超级用户标志。

31                 12   9   7 6 5 4 3   2   1 0
+--------------------+---+-+-+-+-+-+-+---+---+-+
+ Frame Address      +   |0 0|D|A|0 0+U/S|R/W|P|
+--------------------+---+-+-+-+-+-+-+---+---+-+

第一个页表所在的地址是0x00001007 & 0xfffff000 = 0x1000,属性标志是0x00001007 & 0x00000fff = 0x07

第一个页表所在的地址是0x00002007 & 0xfffff000 = 0x2000,属性标志是0x00002007 & 0x00000fff = 0x07

第一个页表所在的地址是0x00003007 & 0xfffff000 = 0x3000,属性标志是0x00003007 & 0x00000fff = 0x07

第一个页表所在的地址是0x00004007 & 0xfffff000 = 0x4000,属性标志是0x00004007 & 0x00000fff = 0x07

	movl $pg0+7,pg_dir		/* set present bit/user r/w */
	movl $pg1+7,pg_dir+4		/*  --------- " " --------- */
	movl $pg2+7,pg_dir+8		/*  --------- " " --------- */
	movl $pg3+7,pg_dir+12		/*  --------- " " --------- */

这一番操作使得页目录表中的四个元素指向了对应的页表,如下图所示:

页目录表初始化

接下俩就是初始化四个页表中的内容了,这里的构建方式是物理地址和线性地址一一对应的关系。

	movl $pg3+4092,%edi
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b

最终初始化后的页表如下图所示:

在这里插入图片描述

下面设置cr3指向全局页目录表,并且开启分页。

	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		/* cr3 - page directory start */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

step5:跳转到main函数执行

在setup_paging执行完毕之后,会通过ret返回,ret指令会将栈顶的内容弹出到PC指针中去执行。此时esp指向的位置存放的是main函数的地址。因此接下来会执行main函数。

注意到在将main入栈时,还一同入栈了一些其他参数

	pushl $0		# These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6

这里就需要回顾一下c语言的调用规约,如下图所示:

启动中内存分布变化

因此这里可以得到L6是main函数的返回值。立即数0,0,0将会被作为main函数的入参。

接下来再看下面的代码就很清晰了,实际就是在建立好页表的映射关系后,就开始跳转到main函数去执行了(init/main.c)。

after_page_tables:
	pushl $0		# These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6		# return address for main, if it decides to.
	pushl $main
	jmp setup_paging

setup_paging:
   ...
   ret

在阅读main的内容之前,我们回顾一下此时内存中的数据状态,如下所示:

在这里插入图片描述

Q & A

setup_paging在建立页表时会将head.s的部分代码覆盖,怎么保证不会把正在执行的代码覆盖?

可以通过反汇编查看一下system模块的内存分布

objdump -d tools/system

如下所示:

00000000 <pg_dir>:
       0:	b8 10 00 00 00       	mov    $0x10,%eax
       5:	8e d8                	mov    %eax,%ds
	   ...
0000005a <check_x87>:
      5a:	db e3                	fninit 
      5c:	9b df e0             	fstsw  %ax
      5f:	3c 00                	cmp    $0x0,%al
	  ...
00000071 <setup_idt>:
      71:	8d 15 28 54 00 00    	lea    0x5428,%edx
      77:	b8 00 00 08 00       	mov    $0x80000,%eax
	  ...
0000008e <rp_sidt>:
      8e:	89 07                	mov    %eax,(%edi)
      90:	89 57 04             	mov    %edx,0x4(%edi)
	  ...
000000a1 <setup_gdt>:
      a1:	0f 01 15 b2 54 00 00 	lgdtl  0x54b2
      a8:	c3                   	ret    
	...
00001000 <pg0>:
	...

00002000 <pg1>:
	...

00003000 <pg2>:
	...

00004000 <pg3>:
	...
00005000 <tmp_floppy_area>:
	...
00005400 <after_page_tables>:
    5400:	6a 00                	push   $0x0
    5402:	6a 00                	push   $0x0
	...
00005412 <L6>:
    5412:	eb fe                	jmp    5412 <L6>
00005414 <int_msg>:
    5414:	55                   	push   %ebp
    5415:	6e                   	outsb  %ds:(%esi),(%dx)
	...
00005428 <ignore_int>:
    5428:	50                   	push   %eax
    5429:	51                   	push   %ecx
	...
0000544e <setup_paging>:
    544e:	b9 00 14 00 00       	mov    $0x1400,%ecx
    5453:	31 c0                	xor    %eax,%eax
    5455:	31 ff                	xor  
	...
000054aa <idt_descr>:
    54aa:	ff 07                	incl   (%edi)
    54ac:	b8 54 00 00 00       	mov    $0x54,%eax
	...

000054b2 <gdt_descr>:
    54b2:	ff 07                	incl   (%edi)
    54b4:	b8                   	.byte 0xb8
    54b5:	5c                   	pop    %esp
	...

000054b8 <idt>:
	...

00005cb8 <gdt>:
	...
    5cc0:	ff 0f                	decl   (%edi)

可以看到代码标号setup_page的起始地址是0000544e,而内存页表和页目录表的地址范围是0x0000-0x5000。因此当程序执行到setup_page时,将建立页目录表和页表, 这将会覆盖0x0000-0x5000的部分代码,即pg_dir,check_x87,setup_idt,rp_sidt,setup_gdt, 并不会覆盖到setup_page的代码,head.s在代码的分布计算上确实是费了一番功夫。

Logo

更多推荐