UCORE实验原理
实验一
本文章只解释实验的原理和相关内容,并不会附带题目代码。
task1:理解通过make生成执行文件的过程。
操作系统镜像文件ucore.img是如何一步一步生成的?
原理为使用make指令,对makefile文件进行解释。makefile是对于复杂工程文件中的一个定义文件,主要是告诉make指令编译的顺序,以及一些更复杂的操作。故可以使用make指令来查看具体的编译内容,就可以看到makefile的执行顺序,也就可以看到镜像文件如何一步步生成。
一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
根据主引导扇区的数据结构可以看出,主引导扇区主要分为三部分:启动代码、磁盘分区表、结束引导字。问题的解答只需要对代码进行分析即可,查看是否包括这三部分,对于的字节数大小如何即可。
task2:使用qemu进行调试
qemu是调试工具。
task3:分析bootloader进入保护模式的过程。
该任务主要描述了bootloader被BIOS读取之后做的事情。其实,bootloader被读取后做了三件事:
(1)开启保护模式和段机制
(2)将内核从硬盘读进内存
(3)跳转到内核入口(kernel entry),并将控制权交给内核。
而task3讲述了三件事:开启A20、初始化GDT,进入保护模式。其中A20是进入保护模式中的一个过程,而初始化GDT则是进入段机制的一个过程。
首先是A20,为何开启A20,以及如何开启A20
开启A20是为了向下兼容。在实模式下,由于8086有20根地址线,故寻址最大为1MB,而segment+offset的寻址方式最多为1088KB(0xffff0+0xfff0),故会出现超出最大寻址范围的情况。因此设置了回卷的特性(理解成正溢出)。但由于后续的80286地址线的增多,导致最大寻址范围超过了1088KB,因此不可能出现回卷的情况(如果再回卷,那么就浪费了多出的地址空间。也就是说,回卷本身就是应对寻址方式和地址线寻址的最大值无法对应的情况,但如果已经得到解决,那么就不需要回卷了)。由于希望对高版本对8086的向下兼容,故设置了A20,来进行模拟回卷,做到向下兼容。A20关闭时,高版本芯片仍实行8086的模式,而A20开启时,芯片则可以向高位地址访问,即超过1MB
初始化全局段描述符GDT,只需要一条语句即可。这里主要说明该机制:
实模式下的地址表示就是段地址+偏移量,虽然是一种开创性的表述方式,但仍存在了严重的问题。首先是该模式用户权限等同于系统,因此可以随意修改地址。其次,该模式得到的地址是实打实的物理地址,因此程序可随意修改物理地址,也就会影响到内存,会产生严重的问题。
因此,创立了保护模式(值得一提,真正时间上的命名顺序并不是先有实模式的。因为保护模式是为了解决实模式产生的问题,为了保护地址所用,因此才叫保护模式,而为了与之前的模式进行区分,称之前的模式为实模式。)保护模式下的寻址则会多一层映射,也就会变得更加复杂。同时引入了更加复杂的数据结构。
按照图中的逻辑,无分页情况下的逻辑地址到线性地址的转化就完成了。需要注意的是,逻辑地址中的部分叫段标识符,其包含了段选择符,段选择符指向段描述符,而多个段描述符则构成了段描述符表。段描述符中的内容加上偏移量则可以得到线性地址。如果加上分页机制,那么就需要再经过页机制的处理,也就是页表的处理才可以。页表的内容不在此实验中考虑,故暂不提及。
至于保护模式的开启,cr0为控制寄存器,0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行
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 #长跳转更新基地址
//初始化段寄存器,建立堆栈,保护模式开启完成,进入bootmain
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
task4:分析bootloader加载ELF格式的OS的过程。
ELF文件结构如图:显示头部,头部会标识着若干信息,具体在数据结构出会有介绍。然后是描述
段,将ELF文件分为了若干个段,每个段都是OS的信息。每个段又分成了若干的sections。我们在
读取硬盘扇区时便按照ELF文件结构的特点设置函数并相互调用。
这里主要分析三个函数。
-
waitdisk:判断磁盘驱动是否空闲,为从磁盘读取OS提供帮助。
/* waitdisk - wait for disk ready */ static void waitdisk(void) { while ((inb(0x1F7) & 0xC0) != 0x40) //0x1F7可以标志驱动器准备好/忙 /* do nothing */; }
readsect:读取小节,实际上是读取扇区。这里大量的使用了内联编程的方式。
/* readsect - read a single sector at @secno into @dst */ static void readsect(void *dst, uint32_t secno) { //secno为编号 // wait for disk to be ready waitdisk(); outb(0x1F2, 1); // count = 1 // 往0X1F2地址中写入要读取的扇区数,由于此处需要读一个扇区,因此参数为1 // 0x1F2是要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区 outb(0x1F3, secno & 0xFF); outb(0x1F4, (secno >> 8) & 0xFF); outb(0x1F5, (secno >> 16) & 0xFF); outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0); // 0x1F3-6是LBA参数 outb(0x1F7, 0x20); // cmd 0x20 - read sectors // 开读! // wait for disk to be ready waitdisk(); // read a sector insl(0x1F0, dst, SECTSIZE / 4); }
readseg:读取段,实际上调用了readsect,对节多次读取。
/* * * 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); } }
而bootloader加载OS的过程是读取ELF之后发生的,是上述三个函数经过迭代操作后的结构。加载OS实际上也是伴随着这个过程的。因此不对bootmain函数进行二次拆分。具体加载方式和跳转到入口过程见注释分析。
/* bootmain - the entry of bootloader */
void
bootmain(void) {
// read the 1st page off disk
//先读入ELF头
//ELFHDR是宏,替换为一个elfhdr的数据结构,该结构如下:
/*
struct elfhdr {
uint magic; // must equal ELF_MAGIC
uchar elf[12];
ushort type;
ushort machine;
uint version;
uint entry; // 程序入口的虚拟地址
uint phoff; // program header 表的位置偏移
uint shoff;
uint flags;
ushort ehsize;
ushort phentsize;
ushort phnum; //program header表中的入口数目
ushort shentsize;
ushort shnum;
ushort shstrndx;
};
*/
//第一步操作为先读入调用readseg读入OS的ELF的文件头
//特别注意:在进入保护模式之后,寻址方式为段寻址
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// is this a valid ELF?
//第二步为判断判是否合法
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
/*
struct proghdr {
uint type; // 段类型
uint offset; // 段相对文件头的偏移值
uint va; // 段的第一个字节将被放到内存中的虚拟地址
uint pa;
uint filesz;
uint memsz; // 段在内存映像中占用的字节数
uint flags;
uint align;
};
*/
// load each program segment (ignores ph flags)
// ph是文件描述符表的头部
// eph为尾部(ph加上了描述符表的表项数)
//第三步为一段一段的从磁盘读入OS
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
//最后一步:跳转到OS的入口,将控制权转交给OS
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
/* do nothing */
while (1);
}
task5:实现函数调用堆栈跟踪函数 (需要编程)
这里面主要需要了解的原理就是堆栈寄存器。涉及到的寄存器有三个:EIP、EBP、ESP。
EIP的内容为下一条指令的地址。
EBP是基址寄存器,因为可以通过EBP加上偏移量来访问栈中的不同内容。例如,EBP+1(这里是加一字,即四个字节)就可以得到EIP。
ESP则是栈顶寄存器,始终指向栈顶的位置。如果调用函数,那么ESP则会指向调用函数后的栈顶。总之,ESP一定是指向当前栈的栈顶的,也就是低位地址。
除此之外,我们还需要知道栈先入后出的特性,以及栈的生长方向是从高位地址向低位地址生长。即,低位地址的位置才是栈顶。
实际上,图中的EIP并不是一直是寄存器的值。在函数调用之前(调用函数的第一步是将EBP压栈,因此参数和EIP的内容都是在调用函数之前的),需要将返回地址放入图中的EIP的地方,而EBP的位置存的也是调用者的EBP的地址。这其实也很好理解,因为EIP的值不断在变化,EIP寄存器存储的是下一条指令的地址 ,所以EIP不可能一直指向一个位置。但是可以通过提前写入EIP的值(放在EBP的高位),再通过EBP加上偏移值的方法,就可以得到返回地址。因此,你可以认为图中的EIP的值是“暂存的”。EBP的内容是上一个EBP的地址,EBP寄存器存的是指针,指向当前调用函数的EBP的位置。不过在简单理解堆栈的过程而不涉及到具体的C或者assembly代码时,按照图上的理解就可以,即,EBP是基址,可以通过基址加上偏移值得到返回地址,根据返回地址就可以回到调用当前函数的函数。不需要知道更细致的内容。
对于一个函数来说,入栈的顺序是固定的:先进入的是传入的参数,从右向左进入。例如:
void fun(var1,var2)
{
//op
}
那么先进入栈的就是var2这个参数,再进入的是var1,紧接着是EIP(返回地址)、EBP(基址)然后是函数的局部变量等存储在栈内的内容。最后则一定是ESP,栈顶寄存器。
当main函数调用其他函数的时候(main一定先于其他函数入栈,因为任何函数的调用一定和main有关系,不是直接调用就是间接调用),最先做的操作往往是将EBP进行压栈(如上面所说,EIP在调用函数之前就已经入栈)。汇编代码如下:
push ebp
mov ebp,esp
即,开拓区域存储EBP的值,将EBP的值放入栈中,并且将ESP的值给EBP。注意,ESP的值是当前的栈顶地址,那此时EBP寄存器的值就是栈顶的地址,在ESP不断生长的过程中,EBP自然就成为了当前函数栈的栈底。
而出栈也大同小异:
mov esp, ebp
pop ebp
即将ESP指向当前函数的栈底(由于函数出栈,也可以理解为栈顶),再将EBP的内容pop出栈,完成对函数的出栈。
当然。本实验要求的内容并没有这么多,主要理解EBP是基址寄存器,且使用EBP+偏移可以得到返回地址(EIP)、args等内容即可。
task6:中断
1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
由段寻址的方式可以得知,中断服务程序的地址是有offset+base得到的,而base是源于段选择子的。故处理代码的入口应该为gd_ss的部分,也就是16-31位。
2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
extern uintptr_t __vectors[]; //声明中断入口,__vectors定义于vector.S中
uint32_t i;
for (i = 0; i < (sizeof(idt) / sizeof(struct gatedesc)); i++)
{
// 该idt项为内核代码,所以使用GD_KTEXT段选择子
// 中断处理程序的入口地址存放于__vectors[i]
// 特权级为DPL_KERNEL
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); //为中断程序设置内核态权限
}
SETGATE(idt[T_SYSCALL], 0, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER); //为T_SYSCALL设置用户态权限
lidt(&idt_pd); //使用lidt指令加载中断描述符表
传入 SETGATE 的参数:
第一个参数 gate 是中断描述符表
第二个参数 istrap 用来判断是中断还是 trap
第三个参数 sel 的作用是进行段的选择
第四个参数 off 表示偏移
第五个参数 dpl 表示中断的优先级
代码分析:题目要求我们为每个中断设置权限,只有 T_SYSCALL 是用户 态权限(DPL_USER),其他都为内核态权限(DPL_KERNEL)。首先通过 _vectors[]获得所有中断的入口,再通过循环为每个中断设置权限(默 认为内核态权限),为 T_SYSCALL 设置用户态权限,最后通过 lidt 将 IDT 的起始地址装入 IDTR 寄存器即可。
3.请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
太简单了,看提示吧。