这部分开始讲解内核部分的代码,内核代码依旧由汇编和 C 语言两部分组成,分为两部分依旧是 C 语言无法实现一些高级特性,需要汇编来实现。

其中汇编代码是操作系统内核的启动代码,主要完成了多引导协议头部的定义,内核入口点的设置,页目录的建立,分页的启用,以及栈的初始化等操作。接下来逐段讲解内核中的汇编代码的实现细节,代码位于 kern/entry.S 中。

多引导协议头部

下面代码定义了多引导协议(Multiboot)的头部。多引导协议是一种规定,它允许引导加载器(bootloader)在不了解内核细节的情况下加载操作系统内核。

// 定义多引导协议的魔数、标志和校验和
#define MULTIBOOT_HEADER_MAGIC (0x1BADB002)
#define MULTIBOOT_HEADER_FLAGS (0)
#define CHECKSUM (-(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS))

###################################################################
# entry point
###################################################################

.text

// 定义多引导协议头部
.align 4
.long MULTIBOOT_HEADER_MAGIC
.long MULTIBOOT_HEADER_FLAGS
.long CHECKSUM
  • MULTIBOOT_HEADER_MAGIC 是多引导协议的魔数(magic number),它是一个特定的值,用于标识遵循多引导协议的内核。引导加载器会查找这个魔数,以确定内核是否支持多引导协议。

  • MULTIBOOT_HEADER_FLAGS 是多引导协议的标志位,用于指定内核需要的特定功能。在这里,它被设置为 0,表示内核不需要任何特定的功能。

  • CHECKSUM 是校验和,它的值是魔数和标志位的负和。引导加载器会计算头部中所有字段的和,如果结果为 0,那么头部就是有效的。

.text 段中,使用 .align 4 指令将多引导协议头部对齐到 4 字节边界,然后使用 .long 指令定义了头部的三个字段:魔数、标志位和校验和。这样,当引导加载器加载内核时,就可以找到并识别这个头部,从而正确地加载和启动内核。

内核的入口点(entry)

下面这段代码主要是用于设置操作系统内核的启动入口点,并进行一些初始化操作。

// 内核链接地址到物理内存地址的转换
#define	RELOC(x) ((x) - KERNBASE)

// 定义ELF入口点
.globl		_start
_start = RELOC(entry)

.globl entry
entry:
	movw	$0x1234,0x472			# warm boot

首先,定义了一个宏RELOC(x),用于将内核链接地址转换为物理内存地址。这是因为在操作系统内核被加载到内存时,它通常被加载到一个高地址,此处是 0xF0000000,即 KERNBASE 的定义为 #define KERNBASE 0xF0000000 这是内核的链接地址。但是在早期的引导阶段,CPU 还在实模式下运行,此时 CPU 还不能访问这么高的地址。因此,需要将内核的链接地址转换为物理地址,这就是RELOC(x)宏的作用。

然后,定义了全局符号_start,并将其设置为entry的物理地址。_start是 ELF(可执行与可链接格式)的入口点,当操作系统被加载并执行时,CPU 会跳转到这个地址开始执行。因为此时 CPU 还在实模式下,所以这里需要的是entry的物理地址,而不是链接地址。

接下来,定义了全局符号entry,并在其后面定义了一个标签entry:,这是内核的入口点。当 CPU 跳转到这个地址后,就会开始执行后面的代码。

最后,执行了一条movw指令,将0x1234写入到地址0x472。这是一个传统的技巧,用于触发所谓的"warm boot"。在 PC 架构中,地址0x472是一个特殊的地址,BIOS 会在启动时检查这个地址,如果其值为0x1234,那么 BIOS 会执行一个"warm boot",也就是重新启动计算机,但不会关闭电源。这通常用于在系统配置更改或软件问题发生时快速重启计算机。

设置了一个简单的页目录

在 JOS 中,内核被链接和运行在非常高的虚拟地址(例如 0xf0100000 )上。这样做的好处是,内核的虚拟地址空间和用户程序的虚拟地址空间不会重叠,这样就可以避免内核和用户程序之间的地址冲突。同时,由于内核的虚拟地址是固定的,所以内核可以在任何位置的物理内存中加载和运行,这就解决了位置依赖性的问题。

具体来说,JOS 使用处理器的内存管理硬件将虚拟地址 0xf0100000 映射到物理地址 0x00100000 。这样,虽然内核的虚拟地址是 0xf0100000 ,但是它实际上是在物理地址 0x00100000 的位置上执行。

但此时操作系统还没有建立起完整的虚拟内存系统,也就是说,还没有建立起页表来将虚拟地址映射到物理地址。所以为了让内核能在高地址运行,我们需要建立一个简单的页表,将高地址映射到物理内存的实际位置。即需要手动维护一张页目录,这个页目录将虚拟地址 [KERNBASE, KERNBASE+4MB) 转换为物理地址 [0, 4MB)

这个简单的页表只能支持 4MB 的地址空间,但这足够我们在设置完整的页表之前使用。在后续的操作系统初始化过程中,我们会在mem_init函数中建立完整的页表,支持更大的地址空间和更复杂的内存管理功能。

下面是设置页表对应的汇编代码

	movl	$(RELOC(entry_pgdir)), %eax
	movl	%eax, %cr3

这段汇编代码的主要目的是设置虚拟内存。它首先将entry_pgdir的物理地址加载到eax寄存器中,然后将eax寄存器的值存入cr3寄存器。

entry_pgdir是在entrypgdir.c文件中定义的页目录的物理地址。在 x86 架构中,cr3寄存器用于存储当前活动的页目录的物理地址。当 CPU 需要转换虚拟地址到物理地址时,它会使用cr3寄存器中的地址作为页目录的基址。

RELOC 上面已经提及了,用于将链接地址转换为物理地址。在这里,它将entry_pgdir的链接地址转换为物理地址。

movl是一个汇编指令,用于将一个值从源操作数移动到目标操作数。在这里,它首先将entry_pgdir的物理地址移动到eax寄存器,然后将eax寄存器的值移动到cr3寄存器。

总的来说,这段代码的作用是将页目录的物理地址加载到cr3寄存器,从而设置虚拟内存。

entry_pgdir

接下来结合具体的代码讲解 entry_pgdir 是如何实现这段页表的映射。

pte_t entry_pgtable[NPTENTRIES];

__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
	[0]
		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
	[KERNBASE>>PDXSHIFT]
		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
};

// 页表的第0个条目映射到物理页0,第1个条目映射到物理页1,依此类推。
__attribute__((__aligned__(PGSIZE)))
pte_t entry_pgtable[NPTENTRIES] = {
	0x000000 | PTE_P | PTE_W,
	0x001000 | PTE_P | PTE_W,
    ....
	0x3ff000 | PTE_P | PTE_W,
};

首先,定义了一个页目录entry_pgdir,它是一个数组,包含NPDENTRIES个页目录项。每个页目录项都是一个指向页表的指针。这个页目录将虚拟地址[0, 4MB)[KERNBASE, KERNBASE+4MB)映射到物理地址[0, 4MB)PTE_PPTE_W是页表项的标志,分别表示页存在和可写。

__attribute__((__aligned__(PGSIZE)))是一个 GCC 特性,用于确保entry_pgdir的地址是PGSIZE的倍数。这是因为在 x86 架构中,页目录和页表的地址必须是页大小(通常是 4KB)的倍数。

[0] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P这行代码将虚拟地址[0, 4MB)映射到物理地址[0, 4MB)entry_pgtable是页表的地址,KERNBASE是内核的起始虚拟地址,所以entry_pgtable - KERNBASE就是页表的物理地址。PTE_P是页表项的标志,表示页存在。

[KERNBASE>>PDXSHIFT] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W这行代码将虚拟地址[KERNBASE, KERNBASE+4MB)映射到物理地址[0, 4MB)KERNBASE>>PDXSHIFT是页目录项的索引,PTE_W是页表项的标志,表示页可写。

然后,定义了一个页表entry_pgtable,它是一个数组,包含NPTENTRIES个页表项。每个页表项都是一个物理页的地址。这个页表的每个条目都将一个虚拟页映射到一个物理页,例如,第 0 个条目将虚拟页 0 映射到物理页 0,第 1 个条目将虚拟页 1 映射到物理页 1,依此类推。

这段代码的目的是在操作系统的早期阶段建立一个简单的虚拟内存环境,使得内核可以在高地址运行。在后续的操作系统初始化过程中,我们会建立完整的页表,支持更大的地址空间和更复杂的内存管理功能。

启用分页

接下来先从代码层面讲解如何启用分页,随后讲解分页机制的理论。下面这段代码用于启用分页机制的。在 x86 架构中,分页机制是通过控制寄存器 CR0 来控制的。

	movl	%cr0, %eax
	orl	$(CR0_PE|CR0_PG|CR0_WP), %eax
	movl	%eax, %cr0

首先,movl %cr0, %eax这行代码将 CR0 寄存器的值加载到 EAX 寄存器中。

然后,orl $(CR0_PE|CR0_PG|CR0_WP), %eax这行代码将 EAX 寄存器的值与CR0_PECR0_PGCR0_WP这三个标志位进行或运算。这三个标志位的含义如下:

  • CR0_PE:保护使能,当设置为 1 时,启用保护模式。
  • CR0_PG:分页使能,当设置为 1 时,启用分页机制。
  • CR0_WP:写保护,当设置为 1 时,禁止超级用户程序向用户只读页面写入。

最后,movl %eax, %cr0这行代码将 EAX 寄存器的值存回 CR0 寄存器,从而启用分页机制。

总的来说,这段代码的作用是启用分页机制,保护模式和写保护。

启用分页机制是指在计算机系统中开启一种内存管理的方式,即分页(Paging)。分页是一种内存管理策略,它将计算机的虚拟内存划分为一系列固定大小的页,每一页都有一个独立的地址。当程序需要访问内存时,它会指定页号和页内偏移量,然后硬件会自动将这个虚拟地址转换为实际的物理内存地址。

在 x86 架构中,分页机制是通过控制寄存器 CR0 来控制的。CR0 寄存器中的某一位(PG 位)用于控制分页机制是否开启。当 PG 位被设置为 1 时,分页机制就被启用了。

启用分页机制后,操作系统可以更有效地管理内存,例如防止程序间的内存冲突,提高内存利用率,实现虚拟内存等。

当程序需要使用内存时,操作系统会为其分配一个或多个页框。这些页框可能在物理内存中并不连续,但对于程序来说,它们看起来是连续的,这就是虚拟内存的概念。

分页机制的主要优点是简化了内存管理。由于所有的页和页框都是同样大小的,所以操作系统可以用简单的数据结构(如数组)来跟踪哪些页框正在被使用,哪些页框是空闲的。此外,分页机制还可以提供内存保护,因为每个页都有自己的访问权限(如只读、可写等)。

然而,分页机制也有一些缺点。例如,如果程序需要使用的内存大小不是页大小的整数倍,那么就会有一部分页框被浪费,这被称为“内部碎片”。此外,由于页框在物理内存中可能不连续,所以可能会增加数据访问的延迟。

栈的初始化

启用分页后,下面的代码将程序的执行流程从低地址空间跳转到高地址空间。

	mov	$relocated, %eax
	jmp	*%eax
relocated:
    // 清除帧指针寄存器(EBP)
	movl	$0x0,%ebp
    // 设置栈指针
	movl	$(bootstacktop),%esp
    // 跳转到C语言初始化函数
	call	i386_init
    // 无限循环,实际上永远不应该到达这里
spin:	jmp	spin

.data
###################################################################
# boot stack
###################################################################
	// 定义启动栈
	.p2align	PGSHIFT		# force page alignment
	.globl		bootstack
bootstack:
	.space		KSTKSIZE
	.globl		bootstacktop
bootstacktop:

这段代码主要是在启用分页后,将程序的执行流程从低地址空间跳转到高地址空间,并进行一些初始化操作。

首先,mov $relocated, %eax 这行代码将 relocated 的地址值加载到寄存器 %eax 中。这里的 relocated 是一个标签,通常表示程序在高地址空间的入口点。

然后,jmp *%eax 这行代码执行一个间接跳转,跳转到 %eax 寄存器中存储的地址处执行。因为 %eax 中存储的是 relocated 的地址,所以这行代码的效果就是跳转到 relocated 处执行。

接下来,relocated: 是一个标签,表示跳转的目标地址。在这个地址上,程序进行了一些初始化操作:

  • movl $0x0,%ebp 这行代码将寄存器 %ebp(帧指针寄存器)清零。这是因为在新的地址空间中,我们不需要保留旧的帧指针。

  • movl $(bootstacktop),%esp 这行代码将 bootstacktop 的值(一个地址)加载到寄存器 %esp(栈指针寄存器)中。这是设置新的栈顶地址。

  • call i386_init 这行代码调用 i386_init 函数。这个函数通常用于进行一些硬件和操作系统的初始化操作。

最后,spin: jmp spin 是一个无限循环。这是因为 i386_init 函数在完成所有初始化操作后,应该直接跳转到操作系统的主循环,而不应该返回。如果程序执行到了这里,那么说明有错误发生。

.data 段中,定义了启动栈 bootstack,并且设置了栈的大小为 KSTKSIZEbootstacktop 是栈顶的地址,它被用在上面的代码中,来设置新的栈顶地址。

总结

上面完成了多引导协议头部的定义,内核入口点的设置,页目录的建立,分页的启用,以及栈的初始化等操作。接下来会跳转到 i386_init 函数中,即 C 语言部分,下一章节会详细讲解。