1 总述
bootloader的程序存放在硬盘的第一个扇区(512B)。BIOS程序会将bootloader加载到内存0x7c00处,并跳转到这里执行。
bootloader的主要作用:
- 打开A20地址线,使CPU进入32位实模式;
- 探测物理内存大小;
- 设置CR0,进入32位保护模式;
- 加载内核镜像,把控制权交给内核。
2 准备
进入bootloader后,为了后向兼容,此时的CPU是16位模式(20根地址线,16位地址模式),20-31的地址线为0。此时的汇编代码应该有“.code16”前缀,表示16位模式。
在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中断获取内存分布情况时,需要设置一些输入参数:
- 设置eax为0xE820
- es:edi指向缓存区,缓存返回的内存分布描述符(Address Range Descriptor),表示内存段的状态。
- ecx存放返回数据的大小,也就是内存分布描述符的大小,一般BIOS总是返回20字节
- edx的值为0x534d4150,也就是“SMAP”的ASCII码。
输出:
- CF为1表示出现错误,否则无错
- eax为“SMAP”的ASCII码,用于验证BIOS的返回是否正确。
- es:di:返回内存分布描述符的地址,与输入值一样
- 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: 出错
那么操作硬盘的步骤具体如下:
- 等待硬盘控制器不忙
- 写入操作硬盘的参数:模式、地址、主从盘等
- 读/写数据
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头部的可执行入口执行程序。
- 首先bootloader将内核最开始4k的内容加载到内容64k的地方。这4k的内容包含了ELF头部和程序头部表的内容。
- 遍历程序头部表,根据每一项的内容,加载对应的段到内存中。
- 从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 参考
- http://wiki.osdev.org/%228042%22\_PS/2\_Controller “”8042” PS/2 Controller”
- http://www.uruk.org/orig-grub/mem64mb.html “Query System Address Map”
- http://wiki.osdev.org/ATA\_PIO\_Mode “ATA PIO Mode”
- 滕启明 “ELF文件格式分析”
- http://www.ibm.com/developerworks/cn/linux/l-excutff/ “UNIX/LINUX 平台可执行文件格式分析”