head.s 剖析——Linux-0.11 剖析笔记(五)

head.s 程序在被编译生成目标文件后会与内核其他程序一起被链接成 system 模块,它位于 system 模块的最开始部分,这也就是为什么称其为“头部(head)”程序的原因。

从这里开始,内核完全是在保护模式下运行了。head.s 汇编程序与前面的语法格式不同,它采用的是AT&T汇编语言格式,并且需要使用 GNU 的 as 和 ld 进行编译和连接。因此请注意代码中赋值的方向是从左到右

这段程序实际上处于内存地址 0 处,在理解代码的时候,请务必记住。

一、加载段寄存器

.text
.globl idt,gdt,pg_dir,tmp_floppy_area
pg_dir:        # 页目录将会存放在这里,把这里的代码覆盖掉
.globl startup_32
startup_32:
	movl $0x10,%eax
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	mov %ax,%gs
	lss stack_start,%esp

第6行:0x10 是数据段的选择子,在 setup.s 文件的末尾处定义,基地址是 0,段界限是 0x7FF,粒度 4KB,可读可写,向上扩展。如果读者忘了,可以参考我的博文:setup.s 分析

7~10行:令ds,es,fs,gs指向数据段。

第11行:stack_start 的定义在 kernel/sched.c(以后会分析)中。

为了阅读方便,截取部分代码在这里。

// kernel/sched.c
long user_stack [ PAGE_SIZE>>2 ] ;

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

LSS指令

lss 指令的格式是

LSS r32,m16:32

注意,这是 Intel 汇编语法,赋值方向是从右到左。含义是用内存中的长指针加载 SS:r32

Load SS : r32 with far pointer from memory

m16:32表示一个内存操作数,这个操作数是一个长指针,由 2 部分组成:16 位的段选择子和 32 位的偏移。

A memory operand containing a far pointer composed of two numbers. The number to the left of the colon corresponds to the pointer’s segment selector. The number to the right corresponds to its offset.

注意,长指针在内存中的布局如下:低4字节是偏移,高2字节是段选择子。

这里写图片描述

stack_start 处的 6 字节是 long * ashort b.
a 被赋值为user_stack[]数组最末端的地址,b 被赋值为 0x10.

所以,第 11 行代码表示用 a 的值加载 ESP,用 b 的值加载 SS,即栈的初始化。

二、设置中断描述符表(IDT)

call setup_idt
setup_idt:
	lea ignore_int,%edx
	movl $0x00080000,%eax
	movw %dx,%ax		      /* selector = 0x0008 = cs */
	movw $0x8E00,%dx /* interrupt gate:dpl=0, present */
	# 以上,在 edx、eax 中组合设置出 8 字节默认的中断描述符值
	lea idt,%edi      # 取idt的偏移给edi
	mov $256,%ecx     # 循环256次,和后面的 dec %ecx 联合使用
rp_sidt:
	movl %eax,(%edi)     # eax -> [edi]
	movl %edx,4(%edi)    # edx -> [edi+4]
	addl $8,%edi         # edi + 8 -> edi
	dec %ecx
	jne rp_sidt
	lidt idt_descr       # 加载IDTR
	ret

...



idt_descr:
	.word 256*8-1		# idt contains 256 entries
	.long idt           # IDT 的线性基地址
	
...

idt: 
	.fill 256,8,0		# idt is uninitialized

IDT 共 256 项,作者使各个表项均指向一个只报错误的哑中断子程序ignore_int

lea 指令:Load Effective Address,用来加载有效地址到寄存器,有效地址,就是偏移地址。

第 2 行:取标号 ignore_int 的偏移地址到 edx

2~5行:组装中断门,示意图如下,蓝色圆圈是行号。

这里写图片描述

这里写图片描述

2~5行:在 edx、eax 中组合设置出 8 字节默认的中断描述符值。eax 含有描述符低 4 字节(对应中断门描述符格式的第二行),edx 含有高 4 字节(对应中断门描述符格式的第一行)。

8E00 是因为:P = 1;DPL = 0;

9~14 行:在 idt 表每一项中都放置该描述符,共 256 项。内核在随后的初始化过程中会替换那些真正使用的中断描述符项。

中断处理过程 ignore_int

/* This is the default interrupt "handler" :-) */
int_msg:
	.asciz "Unknown interrupt\n\r"
.align 2  ; 4字节对齐
ignore_int:
	pushl %eax
	pushl %ecx
	pushl %edx
	push %ds    # 这里请注意ds,es,fs,gs等虽然是16位的寄存器,
	            # 但仍然会以32位的形式入栈,即需要占用4个字节的栈空间。 
	push %es
	push %fs        # 以上用于保存寄存器
	movl $0x10,%eax # 0x10是数据段选择子
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs     # ds,es,fs均指向数据段
	pushl $int_msg                    
	call printk     # 该函数在 kernel/printk.c 中
	popl %eax       # 清理参数
	pop %fs
	pop %es
	pop %ds
	popl %edx
	popl %ecx
	popl %eax
	iret

第 3 行,伪指令 .asciz

为了说明清楚,先要介绍 .ascii

.ascii
从位置计数器所计当前位置为字符串分配空间并存储字符串。可写多个字符串,并用逗号分开。例如:

.ascii hello world!", "How are you?"

.asciz
该伪指令与 .ascii类似,但是每个字符串后面会自动添加 NULL 字符。

第 4 行,.align 的含义是指存储边界对齐调整。“2” 表示把随后的代码或数据的偏移位置调整到地址值最后 2 比特位为零的位置,即按 4 字节方式对齐。

需要注意的是,“2” 在不同的语境下有不同的涵义:

对于现在使用 ELF 目标格式的 Intel 80X86 CPU ,2 表示以 2 字节对齐。再比如, .align 8 表示调整位置计数器,让它在 8 的倍数边界上。

但对于 Linux 0.11 中使用 a.out 目标格式的系统来说,2 表示对齐到 2 的 2 次方。再比如,.align 3 表示位置计数器需要位于 8 的倍数边界上。

gas(GNU as) 对 ELF 和 a.out 这两个目标格式的处理方法不同是由于 gas 为了模仿各种体系结构系统上自带的汇编器的行为而形成的。

想了解 .align 的完整格式和含义,可以参考我的博文:伪指令 .align 的含义

第 17 行:把 printk 函数的参数(即 int_msg 代表的偏移地址)入栈。注意:若符号 int_msg 前不加 $,则表示把 int_msg 符号处的双字“Unkn”入栈。

第18行:调用 printk 函数,该函数在 kernel/printk.c 中,以后再具体分析。

第19行:清理参数 $int_msg.

说明:汇编程序调用 C 函数时,函数的入口参数使用栈来传送,参数的传递顺序是从右到左。调用者负责清除参数占用的栈空间。C 函数的返回值如果是 32 位整数,则保存在 eax 寄存器;如果是 64 位整数,则保存在 edx:eax 寄存器。

具体可以参考我的博文: 在汇编程序中调用C函数

三、设置全局描述符表(GDT),加载 GDTR

call setup_gdt
setup_gdt:
	lgdt gdt_descr # 加载GDTR
	ret

LGDT 指令的格式是:

LGDT m16&32

该指令的操作数是一个 48 位(6字节)的内存区域。在这 6 字节的内存区域中,要求前(低)16位是 GDT 的界限值,后(高)32 位是 GDT 的基地址 。该指令在实模式和保护模式下都可以执行。

gdt_descr:

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

GDT 定义的描述符如下:

索引段选择子段类型基址LimitGDPLP其他
0--------
10x08代码段00xFFF1(表示 4KB)01非一致,可读
20x10数据段00xFFF1(表示 4KB)01向上扩展,可写

段长度可以这样算:(Limit + 1)* 4KB = (0xFFF + 1) * 4KB = 0x1000 * 4KB = 16MB

有人问,在 setup.s 中不是已经定义且加载 GDT 了吗?这里为何要再做一遍?

答案是之前的那个 GDT 只是临时的,这里才是真正的。

四、重新加载段寄存器

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         ; 以上加载数据段到 DS、ES、FS、GS
lss stack_start,%esp  ; 本文开头已经解释了

由于段描述符中的段限长(Limit)从 setup.s 中的 8MB 改成了本程序设置的 16MB,因此这里再次对所有段寄存器执行加载操作是必须的。另外,如果不对 CS 再次加载,那么在执行到第1行时,CS段寄存器的“描述符高速缓存器”中的段限长还是 8MB。这样看来应该重新加载 CS

但是由于 setup.s 中的代码段描述符与本程序中重新设置的代码段描述符除了段限长以外其余部分完全一样,8MB 的限长在内核初始化阶段不会有问题,而且在以后内核执行段间跳转时会重新加载 CS,因此这里没有加载它并没有让程序出错。

针对该问题,目前内核中就在movl $0x10,%eax之前添加了一条长跳转指令ljmp $(_KERNEL_CS), $1f,大概的代码如下:

	call setup_idt
	call setup_gdt
# reload all the segment registers
	ljmp $(_KERNEL_CS), $1f
1:
	movl $0x10,%eax		
	mov %ax,%ds		   
	mov %ax,%es		  
	mov %ax,%fs
	mov %ax,%gs
	lss stack_start,%esp

注意:以上的代码只是为了说明问题,并非源码。

ljmp $(_KERNEL_CS), $1f

$1f 中的 1 是标号,紧跟在其后的 f 表示向前(forwards);_KERNEL_CS 表示内核代码段的选择子,这条指令会跳转到第 6 行来确保 CS 被重新加载。

五、检测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 地址线是否已开启。

1981 年,IBM 公司最初推出的个人计算机所使用的 CPU 是 Intel 8088。在该微机中地址线只有 20 根(A0~A19)。当时,计算机的 RAM 只有几百 KB 或不到 1MB 时,20 根地址线已足够用来寻址。对于超出 0x100000(1MB) 的寻址地址, 将默认回卷到 0x00000。

所以,如果 A20 没有开启,那就存在回卷的现象,访问 0x100000,就是访问 0 地址。

检查方法是向内存地址 0 处写入一个数值(比如 1),然后访问内存地址 Ox100000(1M),如果读出来的也是 1,那就说明没有开启 A20。

第 4 行,读出 Ox100000 处的内容,和 1 比较,如果相同,就给 0 地址写 2,再读出 Ox100000 处的内容,和 2 比较……

六、检测 x87 协处理器

为了弥补 x86 系列在进行浮点运算时的不足,Intel 于 1980 年推出了 x87 系列数学协处理器,那时 x87 是一个外置的、可选的芯片。1989 年,Intel 发布了 486 处理器。从 486 开始,以后的 CPU 一般都内置了协处理器。这样,对于 486 以前的计算机而言,操作系统检测 x87 协处理器是否存在就非常必要了。

注:1991 年,一名 21 岁的就读于芬兰赫尔辛基大学的计算机科学专业学生—— Linus Torvalds 基于 gcc、bash 开发了针对 386 机器的 Liniux 内核。

下面这段程序用于检查数学协处理器芯片是否存在。方法是修改控制寄存器 CR0,在假设协处理器存在的情况下执行一个协处理器指令,如果出错的话则说明协处理器芯片不存在。

	movl %cr0,%eax		    # check math chip
	andl $0x80000011,%eax	# Save PE ET PG
	orl $2,%eax		        # set MP=1
	movl %eax,%cr0
	call check_x87
	jmp after_page_tables

这里写图片描述

第2行:保留 PE、ET、PG 位,其他位都清零(包含 EM)。

PE 指示是否开启保护模式。PG 指示是否开启分页。关于 ET,Intel 手册如是说:

The ET (extension type) flag (bit 4 of the CR0 register) is used in the Intel386™ processor to
indicate whether the math coprocessor in the system is an Intel 287 math coprocessor (flag is
clear) or an Intel 387 DX math coprocessor (flag is set).

也就是说,在 386 中,ET 指示数学协处理器是 287 还是 387

第 3 行,设置 MP=1.

这块我不是很明白,根据下面的表格,在数学协处理器存在的时候,推荐(见最后一行)设置 EM=0,MP=1.

既然作者的意图是假设数学协处理器存在,那么就设置 EM=0,MP=1 吧。

这里写图片描述

关于 NE 位:

The NE flag determines whether unmasked floating-point exceptions are handled by generating
a floating-point error exception internally (NE is set, native mode) or through an external inter-
rupt (NE is cleared). In systems where an external interrupt controller is used to invoke numeric
exception handlers (such as MS-DOS-based systems), the NE bit should be cleared.

看来,NE 是控制浮点异常的处理模式的,以后用到再研究。

check_x87:
	fninit     # 向协处理器发出初始化命令
	fstsw %ax  # 把FPU的状态字保存到AX中
	           # 初始化后状态字应该为0,否则说明协处理器不存在
	cmpb $0,%al
	je 1f	   # 存在则跳转到标号1处
	movl %cr0,%eax    
	xorl $6,%eax		# 把 eax 的值和 0110b 异或,翻转 MP、EM
	movl %eax,%cr0
	ret
	.align 2   # 4字节对齐
1:	.byte 0xDB,0xE4	/* fsetpm for 287, ignored by 387 */
	ret

第 2 行,fninit 是一条指令,手册上这样解释:Initialize FPU without checking for pending unmasked
floating-point exceptions. 此指令执行后,FPUStatusWord ← 0;

第 3 行,fstsw 也是一条指令,手册上说:Store FPU status word in AX register after checking for
pending unmasked floating-point exceptions.

7~9 行:把 eax 的值和 110b 异或,也就是翻转 MP、EM,即设置成上面的表格中没有数学协处理器的配置(EM=1,MP=0)

第12行:0xDB,0xE4 这两个字节是 80287 协处理器指令 fsetpm 的机器码。其作用是通知 80287 :处理器处于保护模式。80387 无需该指令,它会把该指令看作是空操作。

With the 32-bit Intel Architecture FPUs, the FSETPM instruction is treated as NOP (no opera-
tion). This instruction informs the Intel 287 math coprocessor that the processor is in protected
mode.

关于异或

按位异或的3个特点

  1. 0 异或任何数 = 任何数
  2. 111….111b 异或任何数 = 任何数取反
  3. 任何数异或自己 = 把自己置 0

按位异或的几个常见用途

1. 使某些特定的位翻转

​ 例如要使 EAX 的 b1 位和 b2 位翻转:
      EAX = EAX ^ 00000110

​ 代码第8行就是这种用法,把 EM 和 MP 翻转。

2. 不使用临时变量就可以实现两个值的交换

​ 例如 a=11110000,b=00001111,要交换a、b的值,可通过下列语句实现:

a = a^b;   //a=11111111
b = b^a;   //b=11110000
a = a^b;   //a=00001111
3. 在汇编语言中经常用于将变量置零

xor eax,eax

4. 快速判断两个值是否相等

​ 例如判断两个整数a、b是否相等,可通过下列 C 语句实现:
return ((a ^ b) == 0);

七、开启分页,跳转到 main()

Linus 将内核的页表直接放在页目录之后,使用了 4 个页表来寻址 16MB 的物理内存。如果你有多于 16MB 的内存,就需要在这里进行扩充修改。关于分页机制,说来话长,不了解的朋友可以参考我的博文:

简单的分页模型

x86分页机制详解

一个页表有 1024 个表项,每个表项对应一页,大小是 4KB,所以,一个页表对应 1024*4KB,即 4MB。所以,4 个页表就是 16MB。

Linus 在物理地址 0x0 处开始存放 1 页页目录,这就导致偏移地址从 0 开始到 0x1000 的代码会被覆盖。

页目录后面是 4 页页表。

页目录是系统所有进程公用的,而其后的 4 页页表则属于内核专用,它们把线性地址 0x000000~0xFFFFFF 一一映射到物理地址 0x000000~0xFFFFFF(大小是 16MB)。

.org 0x1000   #从偏移 0x1000 处开始放第1个页表(偏移0开始处存放页目录) 
pg0:

.org 0x2000   #从偏移 0x2000 处开始放第2个页表
pg1:

.org 0x3000   #从偏移 0x3000 处开始放第3个页表
pg2:

.org 0x4000   #从偏移 0x4000 处开始放第4个页表
pg3:

.org 0x5000   #定义下面的内存数据块从偏移 0x5000 处开始

.ORG伪指令用来表示起始的偏移地址,紧接着ORG的数值就是偏移地址的起始值。ORG伪操作常用来指定数据的存储地址,有时也用来指定代码段的起始地址。更详细的解释可以参考我的博文:

ORG 伪指令

/*
 * tmp_floppy_area is used by the floppy-driver when DMA cannot
 * reach to a buffer-block. It needs to be aligned, so that it isn't
 * on a 64kB border.
 */
_tmp_floppy_area:
	.fill 1024,1,0  #共保留1024项,每项1字节,填充数值0


fill伪指令的格式是 .fill repeat,size,value
表示产生 repeat 个大小为 size 字节的重复拷贝。size 最大是 8,size 字节的值是 value.

当 DMA (直接存储器访问)不能访问缓冲块时,tmp_floppy_area 内存块就可供软盘驱动程序使用。其地址需要对齐,这样就不会跨越 64KB 边界。

这是赵炯老师的翻译,我不甚理解。暂不深究,以后再说。

为调用 main()函数做准备

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 # 设置页目录和页表,并开启分页
L6:
	jmp L6			# main should never return here, but
				    # just in case, we know what happens.

2~6行:为跳转到 init/main.c 中的 main() 函数作准备工作。

2~4行:前3个入栈 0 值应该分别表示 envp、argv 指针和 argc 的值,但 main() 没有用到。

函数原型是:

int main(int argc, char *argv[], char *envp[])

第5行:压入返回地址。模拟调用 main.c 程序时首先将返回地址入栈的操作,如果 main.c 程序真的退出,就会返回到标号 L6 处继续执行下去,即死循环。

第6行:压入 main() 函数代码的起始地址。当后面执行 ret 指令时(后文会讲),就会弹出 main() 的地址,并把控制权转移到 init/main.c 程序中。

如果对这里的压栈不理解的话,可以参考我的博文: 在汇编程序中调用C函数

设置页目录和页表

再次强调,页目录是系统所有进程公用的,而其后的 4 页页表则属于内核专用,它们把线性地址 0x000000~0xFFFFFF 一一映射到物理地址 0x000000~0xFFFFFF(大小是 16MB)。

一个页表有 1024 个表项,每个表项对应一页,大小是 4KB,所以,一个页表对应 1024*4KB,即 4MB。

如何映射呢,也就是如何构建页目录和页表才能实现这种原地映射?

根据分页的原理,0x00FFFFFF 写成二进制就是

0000 0000 1111 1111 1111 1111 1111 1111b

按照高 10 位,中间 10 位,低 12 位排列就是

0000000011 1111111111 111111111111

高 10 位用来索引页目录,00~11,即页目录的 0-3 项;

中间 10 位用来索引页表,00000000~1111111111 ,即页表的 0~1023 项;

低 12 位作为页内偏移;

为了实现原地映射,页目录的第 0 项必然指向某个页表,这个页表的第 0 项应该指向物理地址 0x0000,第 1 项应该指向物理地址 0x1000,第 2 项应该指向物理地址 0x2000,…,第 1023 项应该指向物理地址 0x3FF000;

同理,页目录的第 1 项必然指向某个页表,这个页表的第 0 项应该指向物理地址 0x400000,第 1 项应该指向物理地址 0x401000,第 2 项应该指向物理地址 0x402000,…,第 1023 项应该指向物理地址 0x7FF000;

……

页目录的第 3 项必然指向某个页表,这个页表的第 0 项应该指向物理地址 0xC00000,第 1 项应该指向物理地址 0xC01000,第 2 项应该指向物理地址 0xC02000,…,第 1023 项应该指向物理地址 0xFFF000;

根据 Linus 的安排,页目录的第 0 项指向第一个页表(位于内存 0x1000),页目录的第 1 项指向第二个页表(位于内存 0x2000),……

所以,给页表填写物理地址的时候,可以从第一个页表开始,一直填写到第四个页表,从第 0 项开始,顺次往后填:0x0000,0x1000,0x2000,…,每次增加 0x1000;不过,Linus 是倒着填的。

思路弄清楚后,可以看代码了

.org 0x1000
pg0:

.org 0x2000
pg1:

.org 0x3000
pg2:

.org 0x4000
pg3:
setup_paging:
	movl $1024*5,%ecx		# 每个页表占用1024个双字(双字=4B),共5个页表
	xorl %eax,%eax          # eax = 0
	xorl %edi,%edi			# edi = 0
	cld
	rep; stosl               # eax -> es:[edi],edi每次增加4,重复ecx次
	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
	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 */

2~5行,把页目录和页表清零。

stosl:Store EAX at address ES:EDI

STOSL 指令将 EAX 中的值保存到 ES:EDI 指向的地址中,若 EFLAGS 中的方向位置位(即在 STOSL 指令前使用 STD 指令),则 EDI 自减 4,若清空方向位(即在 STOSL 指令前使用 CLD 指令),则 EDI 自增 4;

单纯的 STOSL 只能执行一次,如果希望处理器自动地反复执行,可以加上指令前缀 rep;

在寄存器 CX(16 位模式)或者 ECX(32 位模式)中设置传送的次数。当 CX/ECX 不等于0时,则执行STOSL ,执行后,CX/ECX 的值减一,直到减为 0 为止。

7~10行,填写页目录表的前 4 项。pg_dir 就是 0,在开头就定义了;pg0 就是 0x1000,表示页表的基地址(物理地址)

/*
 *  head.s contains the 32-bit startup code.
 *
 * NOTE!!! Startup happens at absolute address 0x00000000, which is also where
 * the page directory will exist. The startup code will be overwritten by
 * the page directory.
 */
.text
.globl _idt,_gdt,_pg_dir,_tmp_floppy_area
_pg_dir:
startup_32:

“+7” 是什么意思?

关于表项的格式,如下图。(详细内容可以参考我的博文 页目录项和页表项

在这里插入图片描述

名称含义
0P存在位。为1表示页表或者页位于内存中,为0表示不在内存中,必须先予以创建或者从磁盘调入内存后方可使用。
1R/W读写标志。为1表示页面可以被读写,为0表示只读。当处理器运行在0、1、2特权级时,此位不起作用。页目录中的这个位对其所映射的所有页面起作用。
2U/S用户/超级用户标志。为1时,允许所有特权级别的程序访问;为0时,仅允许特权级为0、1、2的程序访问。页目录中的这个位对其所映射的所有页面起作用。

根据上表,可以知道 setup_paging 这段代码 7~10 行中的“+7”表示:页表存在,可读可写,允许所有特权级别的程序访问。

填写后示意图如下:
这里写图片描述

继续看代码

	movl $pg3+4092,%edi # 页表最后一项的地址 => edi
	movl $0xfff007,%eax		
	std                 # 设置方向位DF=1
1:	stosl	            # Store EAX at address ES:EDI
	subl $0x1000,%eax   # 更新下一个表项的值,因为一个表项对应 0x1000B 的内存,所以要把页基址减去0x1000
	jge 1b   # 用于有符号数大小的比较,eax 大于等于 0x1000 则跳转到1处

以上代码的目的是填写4个页表。

页表项的格式如下图,0、1、2 比特位的含义见前文的表格。

在这里插入图片描述

movl $pg3+4092,%edi

一张页表最多可以容纳 1024 个表项,每项占 4 个字节。下图左边是表项的序号,从 0 到 1023,右边是偏移地址(= 序号*4),4092 是最后一个表项的偏移地址。

上面的代码表示把页表 3(最后一个页表)的最后一项的地址传入 edi. 作者的意图是从最后一个表项开始,倒着填写,直到填完页表 0 的第 0 个表项。

这里写图片描述

movl $0xfff007,%eax 中的 0xfff007 是页表 3 的最后一项的值,“7”就不用再解释了,解释一下为什么是 0xfff000

其实前文用列举法已经列出来是 0xfff000 了,这里再算一遍。

页表的每一项对应 4KB(2^12=4K)的内存,一个页表有 1024(=1K)项,共对应 4KB*1K=4MB 的内存。代码中安排了4个页表,即共可以映射 4*4MB=16M 内存。

16M - 4K = 0xFFF000

或者:

16M - 1 = 0x1000000-1 = 0xFFFFFF 算出了最后一个物理地址

根据 4KB 对齐,0xFFFFFF & 0xFFFFF000 = 0xFFF000

jge 用于有符号数大小的比较,当 DEST(这里是 eax) 大于等于 SRC(这里是 0x1000) 则跳转。当 eax = 0x1007 时,eax>=0x1000,跳转之后 eax = 0x0007,这时候条件不再成立,则结束跳转。所以,最后填写的表项值是 0x0007。

	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		# 把页目录的物理地址写入CR3
	
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		#以上三行使 CR0 的 PG=1, 开启分页机制
	ret			

CR3寄存器的格式如下:

在这里插入图片描述

The P6 family and Pentium ® processors support page-level cache management in the same
manner as the Intel486™ processor by using the PCD and PWT flags in control register CR3,
the page-directory entries, and the page-table entries. The Intel486™ processor, however, is not
affected by the state of the PWT flag since the internal cache of the Intel486™ processor is a
write-through cache.

PCD 和 PWT 和页级别的缓存管理有关,这里没有用,就写 0 了。

CR0 的格式如下,第 31 位是 PG 位,为 1 的时候开启分页机制。

在这里插入图片描述

movl %eax,%cr0执行后,段部件产生的线性地址就不再被看成物理地址,而是要送往页部件进行变换,以得到真正的物理地址。

注意,现在内核工作在分页机制的一个特殊情况下,线性地址和经过页部件转换后的物理地址相同,这是作者精心安排的。

最后的ret指令有2个作用。

  1. 在改变分页处理标志后要求使用转移指令刷新预取指令队列,这里用的是返回指令ret

  2. 将之前压入栈中的 main() 程序入口地址弹出,并跳转到 init/main.c 程序去运行。

本程序到这里就分析结束了。

八、总结

本程序作的工作有:

  1. 用数据段的选择子(在 setup.s 文件的末尾处定义)加载 DS,ES,FS,GS,SS
  2. 构造并加载中断描述符表 IDT,作者使各个表项均指向一个只报错误的哑中断子程序 ignore_int
  3. 构造并加载全局描述符表 GDT(有人问,在 setup.s 中不是已经定义且加载 GDT 了吗?这里为何要再做一遍?答案是之前的那个 GDT 是临时的,这里才是真正的)
  4. 重新加载段寄存器 DS,ES,FS,GS,SS(由于段描述符中的段限长从 setup.s 中的 8MB 改成了本程序设置的 16MB,因此再次对所有段寄存器执行加载操作是必须的)
  5. 测试 A20 地址线是否已开启
  6. 检测 x87 协处理器
  7. 为跳转到 init/main.c 中的 main() 函数作准备
  8. 构造页目录、页表,开启分页
  9. 跳转到 init/main.c 程序去运行

参考资料

《Linux内核完全剖析》(赵炯,机械工业出版社,2006)

  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值