xv6-Boot

简介

学习 xv6 的同时,对照 Linux 0.11 源代码进行理解,希望自己能够有所提升吧。顺便给自己立一个 flag,看能不能花两个月的时间啃完。

xv6 这儿有源文件 是一个 UNIX 风格的操作系统。后续的代码,我会按照顺序,对其进行拆分,和一些自己理解的说明,其实基本上内容都是在 xv6 的这本书里。

The boot loader

xv6 是基于 X86 的,可以参考的芯片手册是 i386。在实验部分有细讲。当 X86 的机子开机的时候,PC 会首先执行 BIOS(Basic Input/Output System)程序,BIOS 是保存在主板上的非易失性存储器上。

BIOS 的任务是准备好 PC 的硬件,然后将控制权转移给操作系统。BIOS 首先会把控制权转移给保存在启动盘的第一个 512 字节的扇区内的代码,也就是 boot loader:一些汇编代码组成的指令,它主要把内核加载到内存。BIOS 把启动扇区内的 boot loader 加载到内存地址为 0x7c00 的地方,然后将处理器的 pc(%ip) 设置为该地址。

当 boot loader 开始执行,处理器模拟 Intel 8088 模式(16-bit 实模式)运行,并且 boot loader 的任务就是处理器设置成为现代处理器模式(32-bit 保护模式)。然后 boot loader 从磁盘把 xv6 的内核加载到内存,把控制权转交给内核。xv6 的 boot loader 分为两个文件,分别是 bootasm.S 和 bootmain.c

Code: Assembly bootstrap

boot loader 的第一条指令是 cli,它的作用是关中断(中断:硬件提供的调用操作系统的中断处理函数的机制)。为啥要关中断呢?现代处理器的 BIOS 实际上是一个小型的操作系统,BIOS 在运行的时候,可能也会使用一些中断处理函数来初始化硬件。但是,在运行 boot loader 的时候,BIOS 已经没有运行了,原来定义的中断处理函数例程不应该再被使用,所以就要先关中断。所以,也会猜到,当 xv6 操作系统就绪的时候,中断应该会再次被打开,而此时的中断服务例程是由 xv6 提供。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "asm.h"
#include "memlayout.h"
#include "mmu.h"

# Start the first CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.

.code16 # Assemble for 16-bit mode
.globl start
start:
cli # BIOS enabled interrupts; disable

接下来就是老生常谈了,X86 的特点了,兼容性带来的一系列的特殊东西。
具体的细节可以查询这个手册:https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html

现在,X86 处理器是运行在实模式的,它实际上是模拟 Intel 8088 运行机制。实模式下有以下特征,总共有 8 个 16-bit 的通用寄存器,但是 CPU 进行内存访问使用的是 20-bit 的地址。所以段寄存器 %cs,%ds,%es,%ss为 16-bit 的寄存器们提供额外的 4-bit 的寻址,来凑齐 20-bit,这样的机制是处理器本身的硬件结构设计导致的,至于为什么,估计是为了兼容性导致的了。当程序在访问内存的时候,处理器会自动将段寄存器的值乘以 16,然后加上这些寄存器里的内存地址。内存使用的种类通常会指明使用哪个寄存器:指令的获取使用代码段寄存器%cs,数据的读写使用数据段寄存器%ds

relationship between logical,linear,physical address

xv6 是假设 X86 的指令,对其内存操作数是使用的虚拟地址(virtual address),但是一条 X86 指令实际上使用的是逻辑地址(logical address)。一条逻辑地址包含段选择子(segment selector)和偏移(offset),可以写作segment:offset。常见的情况是,段是隐式的,程序实际上只操作偏移量。段处理相关的硬件,将逻辑地址转换为线性地址(linear address)。如果页相关的硬件支持打开的话,那么页处理相关的硬件将会把线性地址转换为物理地址(physical address);如果页没有启用,那么处理器会将线性地址,直接当做物理地址来使用。

说明:虚拟地址就是程序操纵的地址,出于历史原因被这样叫做。其他三个地址概念是 Intel 处理器出现的硬件上的概念。

boot loader 是没有使用页机制的,逻辑地址被直接转换为线性地址,线性地址直接当做物理地址使用。xv6 配置硬件将逻辑地址翻译为线性地址,并且不做任何改变,所以此时的逻辑地址和线性地址是相等的。xv6 的虚拟地址是和 X86 的逻辑地址是相同的,并且和线性地址相同。(虽然很奇怪,段机制没有使用上的感觉,xv6 的逻辑是这样的)当页机制开起的时候,线性地址转换为物理地址就成了我们后续唯一需要重点关注的。

BIOS 并没有保证%ds,%es,%ss的值为某个特定的值,因此第一件事情就是对每个段寄存器清零。那为什么要用xorw %ax,%ax,而不是movw $0,%ax呢,在逻辑电路上边儿表示的话,应该是会更快一点。

1
2
3
4
5
# Zero data segment registers DS, ES, and SS.
xorw %ax,%ax # Set %ax to zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

地址segment:offset可以达到 21-bit 的物理地址,但是 Intel 8088 只能使用 20-bit 的内存地址,所以丢弃掉了高地址0xffff0 + 0xffff = 0x10ffef,但是虚拟地址0xffff:0xffff指向物理地址的0x0ffef。早期的依赖硬件的一些软件会选择忽略到第 21 位地址,IBM 提供了一种方式让硬件更加的灵活。它是这样操作的,如果键盘控制器的输出端口的第 2 个bit位是关着的,那么第 21 位物理地址总是清零;如果是开着的,那么第 21 位地址是正常的。boot loader 必须将第 21 位地址位变为可用,把键盘控制器端口0x640x60设置为开起,即可达到该效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 # Physical address line A20 is tied to zero so that the first PCs 
# with 2 MB would run software that assumed 1 MB. Undo that.
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1

movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64

seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2

movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60

实模式 16-bit 的通用寄存器和段寄存器,最多只能访问 1M 的内存,X86 处理器从 80286 开始支持保护模式,增加了寻址能力,然后 80386 支持 32-bit 模式, 让寻址、寄存器等支持 32 位。

保护模式下,段寄存器是一个到段描述符表(segment descriptor table)的索引。每个表条目都指定一个物理地址基址(base),一个最大虚拟地址的限制(limit),一个权限控制 bit (permission bits)等组成。权限控制位在保护模式下,让操作系统内核保证代码只能使用自己的内存,这样就可以起到保护作用。

Segment in protected mode

xv6 基本上没使用段,它主要使用的是页机制。xv6 的 boot loader 设置好短描述符表 gdt,让每个表项的基地址都为 0,同时每个表项的最大可能的限制都设置为 4G。段描述符表有一个空的表项,一个执行代码段表项,一个数据段表项。代码段描述符有一个标志(flag)位设置,它可以表示代码是运行在 32-bit 模式的,当这个开起的时候,boot loader 进入保护模式,逻辑地址就一条一条的被映射成为物理地址(注意这个时候还没有开起页机制,线性地址就是当做物理地址用)。

1
2
3
4
5
6
7
8
9
10
11
12

# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg

gdtdesc:
.word (gdtdesc - gdt - 1) # sizeof(gdt) - 1
.long gdt # address gdt

boot loader 执行 lgdt 指令,将段描述符表的信息 gdtdesc 加载到全局描述符表寄存器(GDT register,简称 gdtr),寄存器的值指向全局描述符表 gdt。当 boot loader 加载了全局描述符表寄存器,boot loader 通过将寄存器 %cr0 的 1 bit(CR0_PE) 位设置开起保护模式。

1
2
3
4
5
6
7
8
9

# Switch from real to protected mode. Use a bootstrap GDT that makes
# virtual addresses map directly to physical addresses so that the
# effective memory map doesn't change during the transition.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

开起保护模式过后,处理器不是立马就是在保护模式了,也并不是立刻就改变处理器是如何将逻辑地址转换成物理地址的。这个时候,只有当处理器从GDT读取并加载一个新的值到段寄存器,然后它才会改变内部的段机制相关的设置。由于我们不可以直接手动设置 %cs段寄存器,因此只有执行代码 ljmp 长跳转指令,才能保证段选择子被定义。长跳转所跳转的指令继续执行,由于此时 %cs 指向的代码段是 32-bit 的,所以处理器切换到 32-bit 模式。

此时,boot loader将处理器的运行模式,从 8088 模式,切换到 80286 模式,最后切换到了 80386 模式。

1
2
3
4
5
6
7

# PAGEBREAK!
# Complete the transition to 32-bit protected mode by using a long jmp
# to reload %cs and %eip. The segment descriptors are set up with no
# translation, so that the mapping is still the identity mapping.
ljmp $(SEG_KCODE<<3), $start32

进入 32-bit 模式过后,boot loader 第一件事情,是设置代码段寄存器的值为 SEG_KDATA。现在逻辑地址仍然是直接映射到物理地址的,这个时候为了支持C代码的运行,需要在未使用的内存区域,建立好堆栈。目前,内存中从 0xa0000 到 0x100000 是设备内存区,然后 xv6 的内核将放置在 0x100000。boot loader 则是放置在 0x7c00 到 0x7e00(512 bytes)的区域。其他的任何空闲的区域都可以作为堆栈来使用,boot loader 选择 0x7c00 作为堆栈的栈顶,堆栈将从该地址开始往下压栈,一直直到 0x0000,这样可以很好的远离 boot loader。

1
2
3
4
5
6
7
8
9
10
11
12

.code32 # Tell assembler to generate 32-bit code now.
start32:
# Set up the protected-mode data segment registers
movw $(SEG_KDATA<<3), %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %ss # -> SS: Stack Segment
movw $0, %ax # Zero segments not ready for use
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS

最后,boot loader 调用 C 函数 bootmain。bootmain 的主要任务是,加载并且运行操作系统的内核。后续的代码主要是为了与模拟器做准备,call bootmain 它仅在出现问题时返回。在这种情况下,代码会在端口 0x8a00 上发送一些输出字。在真正的硬件上,没有设备连接到那个端口,所以这段代码什么都不做。如果引导加载程序在 PC 模拟器中运行,端口 0x8a00 连接到模拟器本身,并且可以将控制权传回模拟器。不管是否是模拟器,代码都会执行一个无限循环。真正的引导加载程序可能会首先尝试打印错误消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain

# If bootmain returns (it shouldn't), trigger a Bochs
# breakpoint if running under Bochs, then loop.
movw $0x8a00, %ax # 0x8a00 -> port 0x8a00
movw %ax, %dx
outw %ax, %dx
movw $0x8ae0, %ax # 0x8ae0 -> port 0x8a00
outw %ax, %dx
spin:
jmp spin

Code: C bootstrap

boot loader 的 C 语言部分,bootmain.c 主要是在磁盘的第二个扇区寻找可执行的内核,内核文件以 ELF 二进制文件的格式保存。读取内核二进制文件的时候,首先读取 ELF 的 header,bootmain 首先读取 ELF 文件的前 4096 bytes,将其拷贝到内存地址为 0x10000的位置。然后就是检测,将要读取的是否是 ELF 文件,如果不是,那么直接返回,bootasm.S定义好的错误处理进行处理;如果是的话,接着 header 后边的内容,从 phoff 字节开始写入到内存地址为 paddr 的位置。Bootmain 调用 readseg 从磁盘加载数据并调用 stosb 将段的剩余部分归零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// Boot loader.
//
// Part of the boot block, along with bootasm.S, which calls bootmain().
// bootasm.S has put the processor into protected 32-bit mode.
// bootmain() loads an ELF kernel image from the disk starting at
// sector 1 and then jumps to the kernel entry routine.

#include "types.h"
#include "elf.h"
#include "x86.h"
#include "memlayout.h"

#define SECTSIZE 512

void readseg(uchar*, uint, uint);

void
bootmain(void)
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;

elf = (struct elfhdr*)0x10000; // scratch space

// Read 1st page off disk
readseg((uchar*)elf, 4096, 0);

// Is this an ELF executable?
if(elf->magic != ELF_MAGIC)
return; // let bootasm.S handle error

// Load each program segment (ignores ph flags).
ph = (struct proghdr*)((uchar*)elf + elf->phoff);
eph = ph + elf->phnum;
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off);
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
}

// Call the entry point from the ELF header.
// Does not return!
entry = (void(*)(void))(elf->entry);
entry();
}

void
waitdisk(void)
{
// Wait for disk ready.
while((inb(0x1F7) & 0xC0) != 0x40)
;
}

// Read a single sector at offset into dst.
void
readsect(void *dst, uint offset)
{
// Issue command.
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors

// Read data.
waitdisk();
insl(0x1F0, dst, SECTSIZE/4);
}

// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked.
void
readseg(uchar* pa, uint count, uint offset)
{
uchar* epa;

epa = pa + count;

// Round down to sector boundary.
pa -= offset % SECTSIZE;

// Translate from bytes to sectors; kernel starts at sector 1.
offset = (offset / SECTSIZE) + 1;

// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
for(; pa < epa; pa += SECTSIZE, offset++)
readsect(pa, offset);
}

Bootmain 调用 readseg 从磁盘加载数据并调用 stosb 将段的剩余部分归零。Stosb 使用 x86 指令 rep stosb 来初始化内存块的每个字节。

1
2
3
4
5
6
7
8
9
10

static inline void
stosb(void *addr, int data, int cnt)
{
asm volatile("cld; rep stosb" :
"=D" (addr), "=c" (cnt) :
"0" (addr), "1" (cnt), "a" (data) :
"memory", "cc");
}

内核经过编译和链接,在虚拟地址为 0x80100000 中找到内核。因此,函数调用指令必须类似于 call 0x801xxxxx 的目标地址。这个地址在kernel.ld中配置0x80100000是一个比较高的地址,朝向32位地址空间的末尾; 第 2 章解释了做出这种选择的原因。 在这么高的地址可能没有任何物理内存。 一旦内核开始执行,它将设置分页硬件来映射虚拟地址 0x80100000 到 0x00100000 开始的物理地址;内核假定在这个低地址有物理内存。但是,这个时候 boot loader 未启用分页机制。于是,编写链接文件的时候,kernel.ld 指定 ELF paddr 从 0x00100000 开始,这会导致 boot loader 将内核复制到 0x00100000 低物理地址,当开起分页机制过后,分页硬件将会将 0x80100000 最终指向该地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/* Simple linker script for the JOS kernel.
See the GNU ld 'info' manual ("info ld") to learn the syntax. */

OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)

SECTIONS
{
/* Link the kernel at this address: "." means the current address */
/* Must be equal to KERNLINK */
. = 0x80100000;

.text : AT(0x100000) {
*(.text .stub .text.* .gnu.linkonce.t.*)
}

PROVIDE(etext = .); /* Define the 'etext' symbol to this value */

.rodata : {
*(.rodata .rodata.* .gnu.linkonce.r.*)
}

/* Include debugging information in kernel memory */
.stab : {
PROVIDE(__STAB_BEGIN__ = .);
*(.stab);
PROVIDE(__STAB_END__ = .);
}

.stabstr : {
PROVIDE(__STABSTR_BEGIN__ = .);
*(.stabstr);
PROVIDE(__STABSTR_END__ = .);
}

/* Adjust the address for the data segment to the next page */
. = ALIGN(0x1000);

/* Conventionally, Unix linkers provide pseudo-symbols
* etext, edata, and end, at the end of the text, data, and bss.
* For the kernel mapping, we need the address at the beginning
* of the data section, but that's not one of the conventional
* symbols, because the convention started before there was a
* read-only rodata section between text and data. */
PROVIDE(data = .);

/* The data segment */
.data : {
*(.data)
}

PROVIDE(edata = .);

.bss : {
*(.bss)
}

PROVIDE(end = .);

/DISCARD/ : {
*(.eh_frame .note.GNU-stack)
}
}

boot loader 的最后一步是调用内核的入口,也就是调用内核开始执行的地方。xv6 的入口地址是 0x10000c。
xv6 kernel entry point

按照惯例,_start 符号指定 ELF 入口点,它在文件 entry.S 中定义。 由于xv6还没有设置虚拟内存,所以xv6的entry point就是entry的物理地址。

1
2
3
4
5
6
7

# By convention, the _start symbol specifies the ELF entry point.
# Since we haven't set up virtual memory yet, our entry point is
# the physical address of 'entry'.
.globl _start
_start = V2P_WO(entry)

到这个地方 xv6 的 boot loader 就基本上结束了,嗯,xv6 的设计的话,还是进行了简化,但是确实是可行的,其中段机制的使用很少,然后就是由于页机制迟迟没有打开,先是在物理内存上,把整个操作系统安排好了,再做页机制的映射,后续对照一下 linux 看一下是怎么做到的。


xv6-Boot
https://www.bencorn.com/2023/04/25/xv6-Boot/
作者
Bencorn
发布于
2023年4月25日
许可协议