1 总述

bootloader的程序存放在硬盘的第一个扇区(512B)。BIOS程序会将bootloader加载到内存0x7c00处,并跳转到这里执行。

bootloader的主要作用:

  1. 打开A20地址线,使CPU进入32位实模式;
  2. 探测物理内存大小;
  3. 设置CR0,进入32位保护模式;
  4. 加载内核镜像,把控制权交给内核。

2 准备

  1. 进入bootloader后,为了后向兼容,此时的CPU是16位模式(20根地址线,16位地址模式),20-31的地址线为0。此时的汇编代码应该有“.code16”前缀,表示16位模式。

  2. 在bootloader执行过程中,必须关闭中断,将数据段、附加段、堆栈段的段寄存器设置为0。

1
2
3
4
5
6
7
8
9
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
xorw %ax, %ax # Segment number zero
movw %ax, %ds # -> Data Segment
movw %ax, %es # -> Extra Segment
movw %ax, %ss # -> Stack Segment

3 打开A20

3.1 背景

为了兼容16位地址模式,32位CPU使用键盘控制器(8042控制器)的一个控制线(即A20控制线)来控制20-31位地址线的打开关闭。当A20控制线打开时可以使用20-31的地址线。而当A20 关闭时20-31的地址线全部为0。

3.2 8042控制器

8042控制器内部拥有4个8位寄存器:状态寄存器、输出寄存器、输入寄存器、控制寄存器,对外通过两个I/O端口:0x64(命令端口)、0x60(数据端口)进行通信。

状态寄存器各位的定义如下:

  • Bit7: 从键盘获得的数据奇偶校验错误
  • Bit6: 接收超时,置1
  • Bit5: 发送超时,置1
  • Bit4: 为1,键盘没有被禁止。为0,键盘被禁止。
  • Bit3: 为1,输入缓冲器中的内容为命令,为0,输入缓冲器中的内容为数据。
  • Bit2: 系统标志,加电启动置0,自检通过后置1
  • Bit1: 输入缓冲器满置1,i8042 取走后置0
  • BitO: 输出缓冲器满置1,CPU读取后置0

读写0x64和0x60的含义:

  • 读取0x64端口时,返回状态寄存器的内容,可以判断8042控制器的忙闲状态;
  • 写0x64端口时,一般写入操作命令。
  • 读写0x60端口一般都跟在写0x64端口后,主要作用是读取发送操作命令后控制器的响应,或者写入操作命令需要的参数。

举个例子,如果要读取8042的控制寄存器的内容,需要发送20h命令,控制器就会把结果放在输出缓存器中等待CPU取走,接下来读取0x60端口读取内容;如果要写控制寄存器的内容,要发送60h命令,接下来将要设置的内容通过0x60端口写入。

但需要注意的是,无论是通过哪个端口(0x60、0x64)写入数据,都需要等待8042的输入缓存为空,否则就会冲掉上次的输入。判断输入缓存为空只需要判断状态寄存器的第2位是否为0(见状态寄存器的Bit1位)。

3.3 A20使能

A20控制线是8042输出端口的第2位,只要设置该输出端口为1,就可以打开A20。写输出端口的命令是0xD1。那么整个流程可以描述为:1)等待输入缓存为空;2)从0x64端口写入0xD1命令;3)等待输入缓存为空;4)从0x60端口写入要设置的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
seta20.1:
inb $0x64, %al # 读取状态寄存器
testb $0x2, %al # 判断输入缓存是否为空
jnz seta20.1
# 0xd1表示写输出端口命令,参数随后通过0x60端口写入
movb $0xd1, %al
outb %al, $0x64
# 等待8042将输入命令取走
seta20.2:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.2
# 通过0x60写入数据 11011111
movb $0xdf, %al # 0xdf -> port 0x60
outb %al, $0x60 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1

4 探测物理内存分布

内核在管理内存前,需要知道物理内存的分布情况,哪些部分可以使用,哪些部分不能使用。内存分布情况可以通过BIOS中断来获取,具体就是INT 15h,参数eax为0xe820。

在调用INT 15h中断获取内存分布情况时,需要设置一些输入参数:

  1. 设置eax为0xE820
  2. es:edi指向缓存区,缓存返回的内存分布描述符(Address Range Descriptor),表示内存段的状态。
  3. ecx存放返回数据的大小,也就是内存分布描述符的大小,一般BIOS总是返回20字节
  4. edx的值为0x534d4150,也就是“SMAP”的ASCII码。

输出:

  1. CF为1表示出现错误,否则无错
  2. eax为“SMAP”的ASCII码,用于验证BIOS的返回是否正确。
  3. es:di:返回内存分布描述符的地址,与输入值一样
  4. ebx:返回获取下一描述符的后续值,作为下次中断的输入值。如果为0,则表示所有描述符获取完毕。

我们可以利用INT 15h获取到多个内存分布描述符。在ucore中,这些内存分布描述符存放在连续的内存空间中,也就是一个数组中,这个数组的起始地址为0x8004;而这个数组的长度存放在0x8000-0x8003的四字节内存中,作为整形使用。在kern/mm/memlayout.h中确实定义了等价的结构体e820map来表示这段内存:

1
2
3
4
5
6
7
8
struct e820map {
int nr_map; # 4字节,表示描述符的数量
struct { # 20字节的结构体
uint64_t addr; # 8字节,表示内存段的起始地址
uint64_t size; # 8字节,表示内存段的大小
uint32_t type; # 4字节,表示内存段的类型:保留或空闲
} __attribute__((packed)) map[E820MAX]; # 这是一个结构体数组
};

再回到如何利用INT 15h获取e820map的汇编代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
probe_memory:
movl $0, 0x8000 # 0x8000-0x8003代表e820map中的nr_map,初始值为0
xorl %ebx, %ebx # ebx初始值设为0,以后每次的返回值作为输入
movw $0x8004, %di # 0x8004代表e820map中数组map的初始地址
start_probe:
movl $0xE820, %eax # 给eax赋值为E820,由于eax会被修改,所以这条语句放在循环中,重复赋值为E820
movl $20, %ecx
movl $SMAP, %edx
int $0x15
jnc cont
movw $12345, 0x8000 # 出错后,将nr_map赋值为12345
jmp finish_probe
cont:
addw $20, %di # 缓存区地址增加20字节,也就是数组的下一元素
incl 0x8000 # nr_map++
cmpl $0, %ebx
jnz start_probe # 判断是否存在下一个内存分布描述符
finish_probe:

5 进入保护模式

将gdtdesc加载到gdtr。gdtdesc指向一块6字节内存,其中包含了全局描述符表(gdt)的位置和长度。

gdt和gdtdesc的内容:

1
2
3
4
5
6
7
8
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt

接下来设置cr0的PE位(第0位)为1,开启保护模式。此时全局描述符表开始起作用,其中代码段和数据段的段基址均为0,也就是说此时的虚拟地址和线性地址是相等的。由于此时也没有开启分页模式,线性地址也就是物理地址。最后通过一条长跳转指令,进入32位代码段。

1
2
3
4
5
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
ljmp $PROT_MODE_CSEG, $protcseg

接下来设置个数据段的段选择子(代码段的段选择子在ljmp执行后自动设置好),设置堆栈空间为0-0x7C00。堆栈设置好以后就可以调用C函数了。最后调用bootmain函数,此函数的主要作用就是从硬盘读取内核并加载到内存中并执行。

6 加载内核

这部分的代码在boot/bootmain.c中,主要功能是从硬盘中读取内核(ELF文件)并加载到内存中。

6.1 磁盘操作

对磁盘的操作需要通过IDE接口实现。其中IDE主通道的IO地址为0x1F0-0x1F7,IDE次通道的IO地址为0x170-0x177。每个通道可以挂载两块硬盘。这里需要操作的是第一个通道的第一块硬盘。

对硬盘的读写有两种模式:CHS和LBA(logic block address)。CHS也就是通过柱面、磁头、扇区的方式读写硬盘,LBA就像内存一样,通过线性地址来访问磁盘。

下面介绍LBA的方式读取硬盘。

0x1F0 - 0x1F7的IO端口含义如下:

  • 0x1f0: 读/写数据
  • 0x1f1: 读取错误状态
  • 0x1f2: 读/写的扇区数目
  • 0x1f3: LBA的0-7位(相当于扇区)
  • 0x1f4: LBA的8-15位(相当于柱面)
  • 0x1f5: LBA的16-23位(相当于柱面)
  • 0x1f6: bit7、bit5必须为1;bit6:0为CHS模式,1为LBA模式;bit4:0为主盘,1为从盘;bit3-0:LBA的27-24位。
  • 0x1f7: 命令/状态寄存器。发送命令或读取状态。

状态寄存器的各位被设置的含义:

  • Bit7: 控制器忙
  • Bit6: 正常运转(停止转动或出错,该位清零)
  • Bit5: 控制器严重错误
  • Bit4: Overlapped Mode Service Request
  • Bit3: 控制器发出来数据或者准备好接收数据
  • Bit0: 出错

那么操作硬盘的步骤具体如下:

  1. 等待硬盘控制器不忙
  2. 写入操作硬盘的参数:模式、地址、主从盘等
  3. 读/写数据

6.2 读取扇区

ucore将读取扇区的操作封装在函数readsect中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void waitdisk(void) {
while ((inb(0x1F7) & 0xC0) != 0x40) // 读取状态寄存器,等待空闲
/* do nothing */;
}
/* 读取扇区号为secno的扇区到内存dst的地址中 */
static void readsect(void *dst, uint32_t secno) {
waitdisk();
outb(0x1F2, 1); // 读取扇区数为1
outb(0x1F3, secno & 0xFF); // secno的0-7位
outb(0x1F4, (secno >> 8) & 0xFF); // secno的8-15位
outb(0x1F5, (secno >> 16) & 0xFF); // secno的16-23位
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0); // secno的24-27位;0xE0=11100000b表示LBA模式、操作第一块硬盘
outb(0x1F7, 0x20); // 0x20读扇区命令
// wait for disk to be ready
waitdisk();
insl(0x1F0, dst, SECTSIZE / 4);
}

readseg函数调用了readsect函数,它的主要作用就是从内核偏移量为offset的地方读取count字节的数据,存放在起始位置为va的内存中。因为readsect是以整个扇区为单位读取,所以该函数读取的内容大于等于count。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count; // 计算内存段可能的终止位置
// round down to sector boundary
va -= offset % SECTSIZE; // 与扇区边界对齐
// translate from bytes to sectors; kernel starts at sector 1
uint32_t secno = (offset / SECTSIZE) + 1; // 内核起始位置在第一个扇区(第0个扇区为bootloader)
// 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 (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);
}
}

6.3 ELF文件

ELF文件是指可执行链接格式(Executable and Linking Format),最初由UNIX 系统实验室开发并发布的,作为应用程序二进制接口的一部分,也是Linux的主要可执行文件。

ELF文件有三种类型:1) 可重定位文件,也就是通常称的目标文件,后缀为.o。2) 共享文件:也就是通常称的库文件,后缀为.so。3) 可执行文件:本文主要考虑的文件格式。

文件格式:
文件格式

  • ELF头部:用来描述整个文件的组织
  • 程序头部表:是一个结构数组,它的大小等于ELF头表中字段e_phnum定义的条目,结构描述一个段或其他系统准备执行该程序所需要的信息。

ucore中定义的ELF头结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct elfhdr {
uint32_t e_magic; // must equal ELF_MAGIC
uint8_t e_elf[12];
uint16_t e_type; // 1=relocatable, 2=executable, 3=shared object, 4=core image
uint16_t e_machine; // 3=x86, 4=68K, etc.
uint32_t e_version; // file version, always 1
uint32_t e_entry; // entry point if executable
uint32_t e_phoff; // file position of program header or 0
uint32_t e_shoff; // file position of section header or 0
uint32_t e_flags; // architecture-specific flags, usually 0
uint16_t e_ehsize; // size of this elf header
uint16_t e_phentsize; // size of an entry in program header
uint16_t e_phnum; // number of entries in program header or 0
uint16_t e_shentsize; // size of an entry in section header
uint16_t e_shnum; // number of entries in section header or 0
uint16_t e_shstrndx; // section number that contains section name strings
};

其中:

  • e_magic是0x7f、’E’、’L’、’F’,常数
  • e_elf[12]存放一些系统信息
  • e_phoff:程序头部表在文件中的位置
  • e_phnum:程序头部表中元素个数
  • e_entry:可执行程序入口

紧跟着ELF头部的就是程序头部表,它是一个数组,数组项的结构定义如下:

1
2
3
4
5
6
7
8
9
10
struct proghdr {
uint32_t p_type; // loadable code or data, dynamic linking info,etc.
uint32_t p_offset; // 对应的段在文件中的位置
uint32_t p_va; // 需要映射的虚拟地址
uint32_t p_pa; // 物理地址,没用
uint32_t p_filesz; // 段在文件中的大小
uint32_t p_memsz; // 段在内存中的大小
uint32_t p_flags; // 段的标志
uint32_t p_align; // 是否对齐
};

加载内核就是根据程序头部表的内容,将所有代码段加载到对应的内存中,并从ELF头部的可执行入口执行程序。

  1. 首先bootloader将内核最开始4k的内容加载到内容64k的地方。这4k的内容包含了ELF头部和程序头部表的内容。
  2. 遍历程序头部表,根据每一项的内容,加载对应的段到内存中。
  3. 从ELF头部的e_entry项执行程序。
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
void bootmain(void) {
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0); // 读取4k到ELFHDR位置
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
// load each program segment (ignores ph flags)
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff); //程序头部表起始位置
eph = ph + ELFHDR->e_phnum; //程序头部表终止位置
for (; ph < eph; ph ++) {
// 加载段到地址p_va&0xFFFFFF中。
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
// call the entry point from the ELF header
// note: does not return
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
/* do nothing */
while (1);
}

这里需要注意一点,段被加载到的物理内存对应的虚拟地址应该是ph->p_va,但这里是ph->p_va & 0xFFFFFF,这是为什么?

文件tools/kernel.ld是链接产生内核的ld脚本。从中可以看到代码段的起始虚拟地址是0xC0100000。内核实际被加载到了0xC0100000 & 0xFFFFFF = 0x100000的物理地址中,也就是内存起始1M的地方。当前代码段和数据段的段基址均为0,且没有开启分页机制,虚拟地址等于物理地址。为了让内核正常运行,需要把虚拟地址0xC0100000映射到物理地址0x100000处。所以进入内核的第一件事就是重新设置gdt,设置代码段和数据段的段基址为-0xC0000000。这部分不在bootloader中,这里不详述了。

另外,为了正确进入内核入口地址时,也需要和0xFFFFFF相与:ELFHDR->e_entry & 0xFFFFFF = ELFHDR->e_entry - 0xC0000000,这是内核入口的真正物理地址。

7 参考

  1. http://wiki.osdev.org/%228042%22\_PS/2\_Controller “”8042” PS/2 Controller”
  2. http://www.uruk.org/orig-grub/mem64mb.html “Query System Address Map”
  3. http://wiki.osdev.org/ATA\_PIO\_Mode “ATA PIO Mode”
  4. 滕启明 “ELF文件格式分析”
  5. http://www.ibm.com/developerworks/cn/linux/l-excutff/ “UNIX/LINUX 平台可执行文件格式分析”