练习1
- 操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
补充说明:如想了解make执行了哪些命令,可以执行:
$ make "V="

说明要制作ucore.img要用kernel和bootblock
kernel


bootblock


ld -e start -Ttext 0x7c00 在ld时就把bootblock放在0x7c00地址
到目前为止我们已经有了bootblock(就是bootloader)和kernel(ucore os)

但是我们还只是有bootblock但bootblock还不满足我们关于mbr的要求
完成了全部的编译链接后,还需要生成启动扇区。使用tools/sign和objdump对目标文件bootblock.o进行修改得到。
在生成了bootblock.o,
-
调用objdump提取了bootblock.o中的代码输出到bootblock.out中;
-
调用tools/sign检查bootblock.out是否小于510字节,如果大于510字节就报错;
-
tools/sign将bootblock.out输出到一个bin/bootblock,并将文件最后两个字节设置为0x55,0xAA。

虚拟磁盘的生成

dd if=/dev/zero of=bin/ucore.img count=10000
首先我们要知道dd命令的块默认是512字节。这一行的命令是生成一个ucore.img文件(由1000个512字节的块组成,每个块由0填充)
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
conv=notrunc 表示不要截断输出文件,我的理解是不会覆盖全部,这一行意思就是把bootblock放在ucore.img的第一个块
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
seek=n就是跳过前面n个块。这一行意思就是从ucore.img的第二个块开始写kernel的内容
这样就制作出了ucore.img
2.一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

练习2
1.使用qemu和gdb调试
首先qemu和gdb通过用网络端口1234进行通信,在打开qemu后再打开gdb输入
target remote localhost:1234`
即可连接qemu,此时qemu会进入停止状态,听从gdb的命令。另外,我们可能需要qemu在一开始便进入等待模式,那么qemu参数就要带-S -s
第一行就是启动qemu并让它停下,第三行就是为了让gdb在启动时执行这些命令,使用gdb -q-x tools/lab1init启动gdb:
第一行是让gdb读取kernel的符号表
第二行是让qemu和gdb通信
第三行是让qemu处在实模式下
第四行是再0x7c00打断点
结果

练习3
1.分析bootloader进入保护模式的过程
阅读boot/bootasm.s的代码(bootloader 在0x7c00)

在开始时 cli指令关中断,cld 将df置0 ,下面四行 将各个段寄存器置0
- 为何开启A20,以及如何开启A20
旧程序只能寻址1mb,在访问1mb以上的地址采取环绕的方法。(就是mod)后来的内存变大了地址线变大的,可以寻址4gb的空间,可是他们又不想破环原来的程序,要兼容就想出来了a20方法,给a20一个外部门,默认关闭并采取环绕模式
该门(A20门)由键盘控制器IC上的GPIO引脚控制 . 因此,需要在进入保护模式之前启用它 ,那怎么打开呢?
A20线是一个OR逻辑电路门,被放置在第20位的地址总线上,而且可以开启或关闭。于历史原因A20地址位由键盘控制器芯片8042管理,这样通过键盘控制器可以开启开关闭A20线。

inb 指令是向0x64 port 读取一个字节,端口0x64是键盘控制器的IO端口 (也就是get status)
testb al 就是看port是不是满的,也就是看port是不是busy
补充
Status Register - PS/2 Controller
Bit Meaning
2 System Flag - Meant to be cleared on reset and set by firmware
如果不是空的(也就是处于busy状态),就重复以上过程。
上面三行代码就是以轮询方式询问port
后面 两行就是向64port发送0xdf,oxd1 means 向8042控制器(键盘控制器)的p2port发送写数据命令
补充:
端口0x64(命令端口)用于向键盘控制器(PS / 2)发送命令 .
端口0x60(数据端口)用于向/从PS / 2(键盘)控制器或PS / 2设备本身发送数据 .
后面的代码将0xdf发送给了控制器,实现了打开a20。
总而言之,第一个代码块是等键盘控制器不busy时,向64port发送写命令
第二个代码块时等键盘控制器不busy时,向60port发送0xdf来打开a20地址线
到现在的总结:
1.禁止中断;
2.等待,直到8042 Input buffer为空为止;
3.发送Write 8042 Output Port命令到8042 Input buffer;
4.等待,直到8042 Input buffer为空为止;
5.向P2写入数据,将OR2置1。
-
如何初始化GDT表
首先gdt在bootasm.s中有

初始化gdt表,用lgdt指令加载gdt地址进gdtr寄存器。但是在细节上lgdt会加载64bit也就是6byte。也是说gdtdesc有4字节的gdt表地址和2字节gdt表的大小

所以gdt大小可以通过gdtdesc-gdt-1算出,再用.word定义(.word x 就相当于定义16bit 用x填充).lomg gdt 就是4字节gdt的地址
那gdt:下面三行代码是干什么的吗?后来才发现他们是定义在asm.h的宏

所以可以看到gdt表中有三项,第一项是8个字节的0
那为什么是8个字节呢,而且是全0了?于是我去查了
这是selecter(16bit 13bit做index 1bit做区分 2bit做rpl 特权级)

gdte


另外gdte(gdt entry)设计也与段寄存器向对应
段寄存器
struct set
{
WORD Selector, //段选择子 16位
WORD Attribute,//段属性 16位
DWORD Base, //段基地址 32位
DWORD Limit, //段限长 32位
}
首先段选择子我们上面讲了,段属性是匹配gdte的8到23位
段基址于gdte的三段base匹配,段描述符的基地址由三部分组成. 原因是CPU实在16位上扩展的.要兼容16位.32位 64位.所以只能不断扩展
limit这个就不用说了.看上图就知道. 第四个字节的 0-15位来做成的limit,高地址的 16 - 19位 也是
那第二项 宏通过定义也可以看出来是sta_x|sta_r (采用了bitset) 就是可读可执行,那就是代码段
宏翻译过后是
.word 0xffff, 0x0000;
.byte 0x00, 0x9a, 0xcf, 0x00
是不是感觉一头雾水,让我们把他于上面写的gdte一一对应起来
另外我们的机器是小段机所以我们生成的数字其实是
00 c f 9a 00
-- - - -- --
base flags limit acessbyte base
00 00 ff ff
------ -----
base limit
acessbyte
p dpl s e dc rw a
1 00 1 1 0 1 0
S: 1 代表数据段、代码段或堆栈段,0 代表系统段如中断门或调用门
E: 1 代表代码段,可执行标记,0 代表数据段
就是通过sta_x|sta_r 来设置段的属性
就可以得出gdt second entry 是base位0x00000000的代码段
同理可得third entry 是数据段。另外limit都是0xffff(4gb)是因为在保护模式采用了平坦寻址
- 如何使能和进入保护模式
将cro的pe置1(同过与1或最后一位),代表进入保护模式
而如何使能保护模式:一:地址的转换(段地址的使用)

ljmp的使用 同时设置cs,ip寄存器
将cs该为kernel code selecter(ox8 就是0x0*16+1000)所以index是1正好是gdt的第二项代码段
需要注意由于我们bootloader程序代码段在实模式加载到内存时其从0x00007C00物理地址向高位内存加载,而当我们在分段模式下设定Base地址为0时,偏移地址为protcseg地址时,在保护模式下正好能运行bootloader中protcseg段代码。
设置段寄存器将各个寄存器的值由值设置为 selecter。在lab1中,我们已经碰到到了简单的段映射,即对等映射关系,保证了物理地址和虚拟地址相等,也就是通过建立全局段描述符表,让每个段的基址为0,从而确定了对等映射关系。


建立stack:
start地址0x7c00
建立了地址从0到0x7c00的stack

调运bootmian函数

练习4
1.分析加载elf的os过程
要解决两个问题
-
bootloader如何读取硬盘扇区的?
通过readsect函数,而readseg函数是readsect函数的包装。大概是readsect函数一次只能读一个磁盘扇区
读一个扇区的流程(可参看boot/bootmain.c中的readsect函数实现)大致如下:
-
等待磁盘准备好
-
发出读取扇区的命令
-
等待磁盘准备好
-
把磁盘扇区数据读到指定内存
#define ELFHDR ((struct elfhdr *)0x10000) readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0); static void readseg(uintptr_t va, uint32_t count, uint32_t offset) { uintptr_t end_va = va + count;//要在磁盘里读从va到end_va // round down to sector boundary va -= offset % SECTSIZE;//offset % 512 也就是 offset这个位置相对于这个扇区的offset。找到开始的磁盘边界,因为readsect要读一整个磁盘 // translate from bytes to sectors; kernel starts at sector 1 uint32_t secno = (offset / SECTSIZE) + 1; //找到开始的磁盘,+1是因kernel是在第二个磁盘,offset是相对于kernel来说的 // 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); } } 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);//读取的扇区起始编号共28位,分成4部分依次放在0x1F3~0x1F6寄存器中。 outb(0x1F7, 0x20); // cmd 0x20 - read sectors 发出读取扇区的命令 // wait for disk to be ready waitdisk(); //等待磁盘准备好 // read a sector insl(0x1F0, dst, SECTSIZE / 4); //从0x1F0读取SECTSIZE字节数到dst的位置,每次读四个字节,读取 SECTSIZE/ 4次。 }
-
-
bootloader是如何加载ELF格式的OS?
首先看魔数判断是不是elf格式,然后通过program header加载段(在link是将相同属性的section合成一个segment,在根据program header放到内存的位置中.)
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); //program header的address
eph = ph + ELFHDR->e_phnum; //e_phnum pht的entry数
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))();//e_entry kernel的入口
内核编译后,链接成elf格式,线性地址0xf0100000起始的位置,但是这时候我们kernel被bootloader放在了物理地址0x10000,
所以物理地址是ph->p_va & 0xFFFFFF,实现了映射
总结一下就是
- 从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用;
- 校验e_magic字段;
- 根据偏移量分别把程序段的数据读取到内存中。
练习5
1.需要在lab1中完成kdebug.c中函数print_stackframe的实现
就照着注释写就行,只要知道函数调用堆栈就问题不大(具体可以看一下caspp chapter2)
结果:

练习6
-
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
2.中断处理过程
2.1. 首先在指令周期的最后时间是检查中断控制器。
指令周期:
2.2.如果有中断,就读取中断向量,作为idt的index
得到中断描述符。(就是得到selecter和offset)
2.3.通过selecter在gdt中找到段描述符,得到base,再将base与上面得到的offset结合得到handler的地址
2.4.并且CPU会根据CPL和中断服务例程的段描述符的DPL信息确认是否发生了特权级的转换。如果发生特权级的转换就要进行段的跳转,也意味者要进行堆栈的切换。
堆栈切换:
2.4.1.根据dpl从tss选择ss,esp
2.4.2.进行ss,esp检验
2.4.3.保存当前的ss,esp
2.4.4.加载new ss,esp
2.4.5.将old ss,esp压入新栈
2.4.6.数据复制到新栈,复制的num根据门的param count

3.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
就照着注释写行,答案本身就有
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”
这更简单了
4.ucore的中断处理
idt的handle是vector[n]接着到了_alltraps,构建了trpframe(保存了中断信息),call trap函数
trap函数处理中断就是用来switch case

结果:
中断返回
在trap函数返回后,代码的控制流回到了trapentry.S中,即CPU指令指向call trap的下一条指令。为了保证中断服务返回后之前被中断程序上下文的正确性,需要将执行call trap之前的压入的数据一一弹出还原。
- 按照相反的顺序弹出、还原各个常用寄存器的值(popl esp、popal、popl gs/fs/es/ds)。
- 通过addl $0x8, %esp,以直接上移栈顶指针的方式,略过之前压入的中断号tf_trapno和错误码tf_err。
- 执行iret指令,iret指令会将之前硬件自动压入的eip、cs、eflags按照顺序弹出。当CPU发现弹出时的cs值和当前cs值不一致,则认定此次中断发生了特权级的变化。此时CPU会接着弹出之前压入了的esp、ss寄存器的值,令其返回到中断发生前对应的特权级栈中继续执行。
CPU认为只有之前发生特权级变化时才会额外压入ss、esp,所以中断返回时如果发现弹出的cs与当前cs不一致时,除了恢复之前栈上的cs(也恢复了CPL),同时会额外的弹出esp、ss。
这一特权级机制在lab1的挑战练习lab1 challenge1中被利用了起来,挑战练习1需要模拟出内核态转化至用户态,再从用户态再转换回内核态的过程。

Challenge 1
1.当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务
kern_init 调用 switch_test,该函数如下
static void
switch_test(void) {
print_cur_status(); // print 当前 cs/ss/ds 等寄存器状态
cprintf("+++ switch to user mode +++\n");
switch_to_user(); // switch to user mode
print_cur_status();
cprintf("+++ switch to kernel mode +++\n");
switch_to_kernel(); // switch to kernel mode
print_cur_status();
}
switch_to_*函数用中断实现进行特权级的转换
在代码中已经有这两个中断号,直接在switch_to_*函数中通过int 指令调用,在__alltraps函数中添加相应的handler就行
实现基础:我们通过练习6已经知道中断的过程,在中断时会有一个trapframe结构来保存计算机的状态,在之后就会弹出这个结构,我们只要在trapframe还在stack上更改trapframe的信息,就让计算机进行状态的转移。
那我们要改什么呢?首先知道进行特权级的转变,就是改段寄存器的selecter,还有进行堆栈的变化
CPU认为只有之前发生特权级变化时才会额外压入ss、esp,所以中断返回时如果发现弹出的cs与当前cs不一致时,除了恢复之前栈上的cs(也恢复了CPL),同时会额外的弹出esp、ss。通过这个机制我们也能欺骗机器进行堆栈的转换
switch_to_user
在我们调用中断之前,我们首先要解决堆栈的问题。因为我们从内核到用户不会进行堆栈的切换,但是既让我们要到以哦用户态就要进行切换,所以我们要手动压入ss,esp,再调用中断。其实这个堆栈的问题困扰了我很久,后来我自己的理解就是他堆栈的建立有两个过程。一个是从我们目前的状态到处理中断的代码(也就是到内核态),这时候crl是0,drl也是0所以不会切换堆栈,不会压入ss,esp才要我们手动,另一个是后来返回时,cs的crl已经被我们设置称3,drl是0于是这时候就可以进行堆栈切换
可是我们手动压入导致堆栈进行了变化,所以在handler时要对esp进行调整。因为在返回时要保持trapframe的结果

所以你知道为什么我们压入ss,esp而esp只用加4吗?因为esp作为那个指向trapframe的指针也作为trapframe的结构的一部分
但是这个时候我们运行不会出现我们期望的结果

那是因为我们到了用户态是不能用in out指令。为了保证在用户态下也能使用I/O,将IOPL降低到了ring 3。
switch_to_kernel
我们在调用中断时,要对他进行初始化,我们是在用户态调用的,所以这个中断要用陷阱们并且dpl也要设置为用户
而这时候我们是处于用户态,进行中断,crl是3,drl是0,自动压入ss,esp。可是再返回是我们已经把cs设置成内核态了,所以在IRET返回时会误认为没有特权级转换发生,不会把SS、ESP弹出,因此从用户态切换到内核态时需要手动弹出SS、ESP。

在handler倒是没什么特别的,也不用输出不用考虑io。

结果:
Challenge2 我就没做了
ps:王力宏歌是真的好听,另外说一句,本来我都是手工进行校验的,后来我都用meld了