目录
1、概述
此文章用于描述并回顾ucore lab1中学习过程,加深对x86启动的认识。错误理解和认识还望看到的伙伴加以指正,感谢!
2、引用
通过ucore学习OS(一):First part——Booting
Intel 80386 Reference Programmers Manual-i386
3、关键字
无
4、cup上电过程
机器上电时,将CS寄存器设置成0xF000,它的shadow register(CPU真正直接引用的寄存器)的值初始化设置为0xFFFF0000,EIP寄存器初始化设置为0x0000FFF0。则CPU从物理地址0XFFFFFFF0开始执行。此处存放了一条跳转指令,这条指令是16字节长的长跳转指令JMP F000:E05B。
长跳转指令将CS更新成0xF000,CS的shadow register被更新成0x000F0000,则此时形成的物理地址为0x000FE05B,为CPU将要执行的第二条指令,即为BIOS的第一条指令。
BIOS将通过读取硬盘主引导扇区内存一个特定的地址0x7c00处,并jump到此地址开始执行bootloader。
5、bootloader
bootloader完成的工作包括:
- 切换到保护模式,启用分段机制
- 读磁盘中ELF执行文件格式的ucore操作系统到内存
- 显示字符串信息
- 把控制权交给ucore操作系统
对应其工作的实现文件在lab1中的boot目录下的三个文件asm.h、bootasm.S和bootmain.c。
完整的bootasm.S汇编代码如下所示:
#include <asm.h>
# Start the 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.
.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag
# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.globl start
start:
.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
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.1
movb $0xd1, %al # 0xd1 -> port 0x64
outb %al, $0x64 # 0xd1 means: write data to 8042's P2 port
seta20.2:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.2
movb $0xdf, %al # 0xdf -> port 0x60
outb %al, $0x60 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
.code32 # Assemble for 32-bit mode
protcseg:
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
movl $0x0, %ebp
movl $start, %esp
call bootmain
# If bootmain returns (it shouldn't), loop.
spin:
jmp spin
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
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
5.1、关闭中断、初始化段寄存器
.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
此时cup处于实模式下,为16位模式。
cli指令禁用所有外部中断。
cld指令将标志寄存器Flag的方向标志位DF清零,在字串操作中使变址寄存器SI或DI的地址指针自动增加,字串处理由前往后。
接下来四行将ax寄存器置0,然后把ds,es,ss三个段寄存器置0。
5.2、开启A20总线
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.1
movb $0xd1, %al # 0xd1 -> port 0x64
outb %al, $0x64 # 0xd1 means: write data to 8042's P2 port
seta20.2:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.2
movb $0xdf, %al # 0xdf -> port 0x60
outb %al, $0x60 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1
详细见描述 关于A20 Gate
5.3、初始化GDT表
下面开始啰嗦的从保护模式开始讲起,方便知识点的串联。
5.3.1、实模式和保护模式
- 实模式
在bootloader接手BIOS的工作后,当前的PC系统处于实模式(16位模式)运行状态,在这种状态下软件可访问的物理内存空间不能超过1MB,且无法发挥Intel 80386以上级别的32位CPU的4GB内存管理能力。
实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址。这样,用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的。
- 保护模式
只有在保护模式下,80386的全部32根地址线有效,可寻址高达4G字节的线性地址空间和物理地址空间,可访问64TB(有2^14个段,每个段最大空间为2^32字节)的逻辑地址空间,可采用分段存储管理机制和分页存储管理机制。这不仅为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。通过提供4个特权级和完善的特权检查机制,既能实现资源共享又能保证代码数据的安全及任务的隔离。
5.3.2、分段存储管理机制
只有在保护模式下才能使用分段存储管理机制。分段机制将内存划分成以起始地址和长度限制这两个二维参数表示的内存块,这些内存块就称之为段(Segment)。分段机制涉及4个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)。转换逻辑地址(Logical Address,应用程序员看到的地址)到物理地址(Physical Address, 实际的物理内存地址)分以下两步:
- 分段地址转换:CPU把逻辑地址(由段选择子selector和段偏移offset组成)中的段选择子的内容作为段描述符表的索引,找到表中对应的段描述符,然后把段描述符中保存的段基址加上段偏移值,形成线性地址(Linear Address)。如果不启动分页存储管理机制,则线性地址等于物理地址。
- 分页地址转换,这一步中把线性地址转换为物理地址。(注意:这一步是可选的,由操作系统决定是否需要。在后续试验中会涉及)
分段机制启动、分页机制未启动:逻辑地址--->段机制处理--->线性地址=物理地址
分段机制和分页机制都启动:逻辑地址--->段机制处理--->线性地址--->页机制处理--->物理地址
上述转换过程对于应用程序来说是不可见的。线性地址空间由一维的线性地址构成,线性地址空间和物理地址空间对等。线性地址32位长,线性地址空间容量为4G字节。分段地址转换的基本过程如下图所示:
段描述符:为处理器提供将逻辑地址映射到线性地址所需的数据。描述符由编译器、链接器、加载程序或操作系统创建,而不是由应用程序程序员创建。如下为段描述符常规格式:
段描述符表:为内存中的一个数组,这个数组中的每个元素都是一个段描述符 ,存储着一个段的对应的属性与信息,其起始地址保存在全局描述符表寄存器GDTR中。
段选择子:线性地址部分的选择子是用来选择哪个描述符表和在该表中索引一个描述符的。选择子可以做为指针变量的一部分,从而对应用程序员是可见的,但是一般是由连接加载器来设置的,格式如下图所示:
- 索引(Index):在描述符表中从8192个描述符中选择一个描述符。处理器自动将这个索引值乘以8(描述符的长度),再加上描述符表的基址来索引描述符表,从而选出一个合适的描述符。
- 表指示位(Table Indicator,TI):选择应该访问哪一个描述符表。0代表应该访问全局描述符表(GDT),1代表应该访问局部描述符表(LDT)。
- 请求特权级(Requested Privilege Level,RPL):保护机制,在后续试验中会进一步讲解。
5.3.3、段表初始化
从上述可以我们了解到80386在保护模式下,能够发挥其强大的功能。分段存储管理机制也需要在启动保护模式的前提下建立,为了使得分段存储管理机制正常运行,需要建立好段描述符和段描述符表。
80386依然带有8086的四个段寄存器(又附加了两个),但进入保护模式后,这些段寄存器中的值不再代表段基址,而是作为段选择子,来选取GDT表中对应段的段描述符。段描述符中包含这个段在内存中的基址,我们再用这个基址加上寄存器EIP中的值来计算最终地址。下面代码对应着GDT表的加载,以及表的声明,其中lgdt指令的作用是将GDTR寄存器置为指定内容,此处将GDTR的值置为gdtdesc。
lgdt gdtdesc #加载gdt表首地址
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
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
GDTR长48位,其中高32位为基地址,低16位为段界限。由于GDT 不能有GDT本身之内的描述符进行描述定义,所以处理器采用GDTR为GDT这一特殊的系统段。约定全局描述符表中第一个段描述符设定为空段描述符。GDTR中的段界限以字节为单位。对于含有N个描述符的描述符表的段界限通常可设为8*N-1。
在bootams.S中为内存设置了两个段:代码段和数据段,所以需要向GDT填入两个有效段描述符。
- 第一项SEG_NULLASM为空的选择子(段选择子的索引(Index)部分和表指示位(Table Indicator)都为0)。
- 第二项SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff),设置代码段。
- 第三项SEG_ASM(STA_W, 0x0, 0xffffffff),设置数据段。
SEG_NULLASM和SEG_ASM为宏操作,SEG_ASM根据给定参数:type(段属性),base(段基址),lim(段界限),来设置相应的段描述符。有意思的是lab1将两个段的基址都设置为0,将它们的界限都设为0xFFFFFFF,即整个4G地址空间。意味着无论我们的段选择子选取了哪个段描述符,我们最终得到的地址都是等于 段基址 + $eip = 0 + $eip = $eip,即我们寻址时eip寄存其中的值,就是我们最终送上地址总线的地址值,且肯定不会越界(eip寄存器为32位,最大值就为0xFFFFFFF)。这样的设置被称为平坦内存设置。简单的gdt就初始化好了。
5.4、开启保护模式
在GDT表设置好后,我们就可以开启x86的保护模式了。开启保护模式的方法是使能CR0的PE位,便进入保护模式。
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
进入保护模式后,cup切换置32位处理模式,以后的寻址便会基于我们刚刚设置的GDT表来进行。cpu会读取我们CS寄存器的值作为段选择子来按照上述方法寻址,然而此时CS寄存器的值为0(BIOS跳转至现在的boot时用了 ljmp 0000:7c00),我们若不重新设置CS寄存器则会定位到空段描述符(GDT表的第一项),寻址时会导致错误。使用ljmp指令设CS寄存器的值,使其作为段选择子定位到我们刚刚设的GDT表中代码段的描述符。PROT_MODE_CSEG定义为0x8,即将0x8写入CS寄存器,并跳转到protcseg代码段。
5.5、设置函数栈,跳转到C语言运行
这是bootasm.S最后一部分代码,这部分代码运行在32位保护模式下,我们将其他5个段寄存器填入数据段的段选择子,之后它们访问数据,将会得到我们在GDT表中设置的数据段描述符所描述的属性,如可写。接下来设置C程序环境,代码中将0x0000~0x7c00的这段连续的内存空间作为bootloader后续要使用的栈。并用call bootmain调用c函数,跳转到bootmain。
.code32 # Assemble for 32-bit mode
protcseg:
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
movl $0x0, %ebp
movl $start, %esp
call bootmain
5.6、 加载内核程序并运行
bootmain只做了一个工作:将kernel从硬盘(ucore.img)加载进内存后跳转至内核的第一行代码。既然是要对硬盘进行操作,程序中必然涉及到I/O操作代码。以下为bootmain代码:
#include <defs.h>
#include <x86.h>
#include <elf.h>
/* *********************************************************************
* This a dirt simple boot loader, whose sole job is to boot
* an ELF kernel image from the first IDE hard disk.
*
* DISK LAYOUT
* * This program(bootasm.S and bootmain.c) is the bootloader.
* It should be stored in the first sector of the disk.
*
* * The 2nd sector onward holds the kernel image.
*
* * The kernel image must be in ELF format.
*
* BOOT UP STEPS
* * when the CPU boots it loads the BIOS into memory and executes it
*
* * the BIOS intializes devices, sets of the interrupt routines, and
* reads the first sector of the boot device(e.g., hard-drive)
* into memory and jumps to it.
*
* * Assuming this boot loader is stored in the first sector of the
* hard-drive, this code takes over...
*
* * control starts in bootasm.S -- which sets up protected mode,
* and a stack so C code then run, then calls bootmain()
*
* * bootmain() in this file takes over, reads in the kernel and jumps to it.
* */
#define SECTSIZE 512
#define ELFHDR ((struct elfhdr *)0x10000) // scratch space
/* waitdisk - wait for disk ready */
static void
waitdisk(void) {
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
/* readsect - read a single sector at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {
// wait for disk to be ready
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
insl(0x1F0, dst, SECTSIZE / 4);
}
/* *
* readseg - read @count bytes at @offset from kernel into virtual address @va,
* might copy more than asked.
* */
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;
// 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);
}
}
/* bootmain - the entry of bootloader */
void
bootmain(void) {
// read the 1st page off disk
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// 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 ++) {
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);
}
bootmain将kernel ELF文件,加载到内存分为两步:
- 1.将ELF头(ELF header)读入内存。
- 2.得到程序头(program header),将程序每个段读入内存。
通过ELF头,我们可以找到程序头相对ELF头的位置;通过程序头,我们可以找到各个段相对ELF头的位置。不仅如此,程序头还会告诉我们该将这些段装载到内存的哪些位置。
5.6.1、读取ELF文件头
首先代码调用了readseg函数,将kernel的第一页(4K)字节读入到起始地址为ELFHDR = 0x10000开始的4K内存空间中,并强制转换为elfhdr结构使用。这一页不仅包含了ELF头,其实程序头也在其中,代码如下:
// read the 1st page off disk
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
readseg函数,主要用于将磁盘数据读取到内存,代码如下:
/* *
* readseg - read @count bytes at @offset from kernel into virtual address @va,
* might copy more than asked.
* */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;
// 向下取整到扇区边界
//主要作用为当offset不为0时,使得真正读取的数据第一字节任然落在指定的起始地址
va -= offset % SECTSIZE;
// 将offset翻译为对应扇区号,且kernel是从1号扇区开始的
uint32_t secno = (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 (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);
}
}
readsect函数,主要作用将单个secno扇区数据读取到dst,函数内涉及到IO操作,留到后面学习,代码如下:
/* readsect - read a single sector at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {
// wait for disk to be ready
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
insl(0x1F0, dst, SECTSIZE / 4);
}
5.6.2、加载每个程序段
读取ELF头之后,我们通过比较e_magic来判断读取的是否是合法的ELF文件。ELF头中e_puoff字段保存了程序头相对于ELF头的偏移量,通过它即可找到ph在内存中的位置。并计算程序头终止地址eph。接着通过遍历程序头,根据每个程序头信息,调用readseg函数将kernel加载进内存中,代码如下:
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 ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
5.6.3、跳转执行kernel
加载完成后,最后一步就是跳转到kernel执行。ELFHDR->e_entry作为kernel程序入口点,(void (*)(void))将ELFHDR->e_entry强制转换为参数为空且返回值为空的函数指针,最终调用此函数。为代码如下:
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();