引导代码位于boot文件夹下,由一个16位与32位汇编混合的汇编文件(boot.S)和一个C语言文件(main.c)组成。
程序的入口在boot.S中,采用的是AT&T语法,下面先对这个文件进行分析:
#include <inc/mmu.h>
在inc文件夹下有一个mmu.h头文件,这里存放了一些经常会用到的宏定义
.set PROT_MODE_CSEG, 0x8
.set PROT_MODE_DSEG, 0x10
.set CR0_PE_ON, 0x1
上面声明了一些常量,用来设置一些段寄存器的值
.globl start
start:
这里就要进入正题了,“.globl start”用来告诉编译器start是程序入口,事实上“.globl”的主要作用是说明“start”这个函数可以被其他文件的调用,编译器默认的入口标号为“_start”因此,在linux中编译时,会指定编译入口,用这个参数“-e start”。
.code16
cli
cld
“.code16”的意思是从这里往后的代码是16位程序,要用16位的编译模式(16位与32位生成的机器码是不一样的,因此不指明的话,会造成不可预计的错误。)
“cli”指令用来关掉可屏蔽中断,为在之后的运行中不受打扰。以后还会打开的。
“cld”重新确定串操作方向,主要是为了main.c文件中复制内核程序时使用。
xorw %ax,%ax #将ax清0 ,分别将ds、es、ss的值变成0,因为在之前的BIOS运行中,不能确定这些寄存器的值。
movw %ax,%ds
movw %ax,%es
movw %ax,%ss
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
lgdt gdtdesc
jnz seta20.1 movb $0xd1,%al # 0xd1 -> port 0x64 outb %al,$0x64seta20.2: inb $0x64,%al # Wait for not busy testb $0x2,%al jnz seta20.2 movb $0xdf,%al # 0xdf -> port 0x60 outb %al,$0x60
简单来讲,这段代码主要就是一个目的,使能A20地址线,使计算机能够访问更大的内存空间。具体关于A20地址线网上有很多介绍,这里不再废话。要注意的是打开A20地址线并不不能使计算机直接进入保护模式(32位模式),只是使计算机可以访问更大的内存。
lgdt gdtdesc
这条指令尤为重要,加载全局描述符(GDT),标号gdtsec处存放了6字节有关GDT位置的信息,稍后我们会看到。
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
这里将cr0寄存器的0-bit(最低位)置1,告诉cpu进入保护模式,在这里才算是真正进入了保护模式。
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
“ljmp”这条指令可能会让人感到奇怪,因为这个长跳转指令后的就是他要跳转的位置。事实上,必须这么做,在加载GDT以后,由于内部硬件设计的原因,必须要重设所有段寄存器的值,但我们没有直接改变指令寄存器cs的指令,因此使用一个长跳转指令改变cs的值(跳转指令实际上就是在对cs进行操作,使cs指向预定的位置,cup是根据cs指向的位置来执行机器码的)。
之后的指令就好理解了,因为进入了保护模式,所以“.code32”告诉编译器使用32位方式编译代码。然后是将其他5个段寄存器的值重新设为0x10。
movl $start, %esp
call bootmain
这里很简单,先将栈顶指针指向我们的引导程序首地址,即0x7c00处,由于堆栈是向下生长的,因此实际的堆栈区域变设置为了0x7c00~0x0010这一段。(注:ss被称为栈底指针,指向堆栈的最底部,sp是栈顶指针,时刻指向当前堆栈第一个元素的位置,esp是32位的,64位是rsp)
最后调用bootmain函数,这个函数在main.c文件中。用来将内核复制到内存的指定位置。
spin: #这里是一个死循环,如果bootmain函数发生错误返回,计算机就会卡在这儿,就操作系统而言,最好在这里设置一些提示信息
jmp spin
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg 代码段
SEG(STA_W, 0x0, 0xffffffff) # data seg 数据段
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
标号gdt处设计了有3个表项的GDT表,每个表项长度为8字节,第一个表项必须设置为空的
标号gdtdesc是要往GDTR寄存器里加载的6字节信息,GDTR寄存器的低2字节储存GDT表的长度,高4字节储存GDT表在内存中的首地址.由于GDT讲解比较繁杂,这里暂时略过。
下面对main.c文件分析
首先要说明一下bootmain函数的工作方式。
首先它会判断要加载的内核文件是否属于elf格式,这是一种专为unix系统设计的二进制文件,unix/linux下的应用程序均为elf结构,mit 6.828课程也使用了这种结构,结构定义在inc文件夹的elf.h文件中,如下:
struct Elf {
uint32_t e_magic; // 魔数,elf文件的前四字节分别为(0x7f,E,L,F)
uint8_t e_elf[12]; //这12字节没有定义
uint16_t e_type; //目标文件属性
uint16_t e_machine; //硬件平台类型
uint32_t e_version; //elf的版本
uint32_t e_entry; //程序入口
uint32_t e_phoff; //程序头表偏移量,bootmain函数中的第一行的那个结构便是程序表头的结构
uint32_t e_shoff; //节表头偏移量,节表多用来储存程序会用到的数据
uint32_t e_flags; //处理器特定标志
uint16_t e_ehsize; //elf头部长度
uint16_t e_phentsize; //程序头表中一个条目的长度
uint16_t e_phnum; //程序头表条目数目
uint16_t e_shentsize; //节头表中一个条目的长度
uint16_t e_shnum; //节头表条目个数
uint16_t e_shstrndx; //节头表字符索引
};
来看bootmain函数的第一行:
struct Proghdr *ph, *eph;
声明了两个指向程序表头的指针,还没有定义。
//这里是将一页(4kb)内核程序读入内存,放在首地址0x10000处
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
if (ELFHDR->e_magic != ELF_MAGIC)//判断是否为elf文件,elf结构中的前4字节为(0x7f,E,L,F)
goto bad;//bad处是一个错误处理程序,编写者只是简单地做了个循环,毕竟是用来学习的
//加载每个程序段
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;//e_phnum储存了程序段的数量,在这里将eph指向了最后一个程序段
在这里,ph指向了内核的程序表头,eph则指向了最后一个程序段。
for (; ph < eph; ph++)
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
反复循环,通过readseg函数将内核全部加载到内存中。
最后的bad,基本没有意义。
main.c中还有两个磁盘操作函数,用来帮助bootmain函数加载内核。
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count;
// round down to sector boundary
pa &= ~(SECTSIZE - 1);//确保该地址指向所在扇区的第一个字节
// translate from bytes to sectors, and kernel starts at sector 1
offset = (offset / SECTSIZE) + 1;//确保偏移量小于512,大于0
// 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.
while (pa < end_pa) {
// Since we haven't enabled paging yet and we're using
// an identity segment mapping (see boot.S), we can
// use physical addresses directly. This won't be the
// case once JOS enables the MMU.
readsect((uint8_t*) pa, offset);//pa的值是0x10000,即内核的地址,
pa += SECTSIZE;
offset++;
}
}
void
waitdisk(void)
{
// wait for disk reaady
//从端口0x1f7获取一字节信息,并判断最高两位是否为01(第7位和第6位),否则不断循环
//0x1f7 用来存放硬盘操作后的状态,其中第7位为1代表控制器忙碌,第六位为1代表磁盘驱动准备好了
//这段代码用来判断硬盘是否可以读取,不能读的话计算机就会卡在这里
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
void
readsect(void *dst, uint32_t offset)
{
// wait for disk to be ready
waitdisk();
//0x1f2存放要读写的扇区数量
outb(0x1F2, 1); // count = 1
//0x1f3用来存放要读写的扇区号码,就是偏移量
outb(0x1F3, offset);
//0x1f4 用来存放读写柱面的低8位字节
outb(0x1F4, offset >> 8);
//0x1f5 用来存放读写柱面的高2位字节
outb(0x1F5, offset >> 16);
//0x1f6 用来存放要读写的磁盘号,磁头号。7-bit:恒为1;6-bit:恒为0;5-bit:恒为1;
//4-bit:0代表第一块硬盘,1代表第二块硬盘
//3~0-bit:用来存放要读写的磁头号
outb(0x1F6, (offset >> 24) | 0xE0);
//0x1f7 用来存放硬盘操作后的状态,以下为设置为1的情况
//7-bit 控制器忙碌
//6-bit 磁盘驱动器准备好了
//5-bit 写入错误
//4-bit 搜索完成
//3-bit 扇区缓冲区没有准备好
//2-bit 是否正确读取磁盘数据
//1-bit 磁盘每转一周将此位设置为1
//0-bit 之前的命令因发生错误而结束
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();//检查磁盘状态
// read a sector
insl(0x1F0, dst, SECTSIZE/4);//0x1f0读写功能,其内容为正在传输的一字节数据
}
上面已经说得很细了,在inc文件夹的x86.h文件中可以找的上面用到的一些端口操作函数,使用gcc内联汇编的方式操作端口,不得不说,内联汇编确实不简单。