实验目的
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。
练习1:理解通过make生成执行文件的过程。
列出本实验各练习中对应的OS原理的知识点,并说明本实验中的实现部分如何对应和体现了原理中的基本概念和关键知识点。
操作系统镜像文件ucore.img是如何一步一步生成的
在Makefile中可以看到生成ucore.img的代码如下
181行: 说明生成ucore.img需要依赖kernel和bootblock
kernel的生成
为了生成kernel,需要kernel.ld init.o readline.o stdio.o kdebug.o
| | kmonitor.o panic.o clock.o console.o intr.o picirq.o trap.o
| | trapentry.o vectors.o pmm.o printfmt.o string.o
| | kernel.ld已存在
1. 将一些二进制文件进行链接(而这些二进制文件的编译过程在makefile的其他地方已经定义),其中链接 器使用的脚本kernel.ld已经存在(对应147行)。
2. 移除所有符号和重定位信息。(对应第148行)
3. 输出目标文件的符号表。(对应149行)
bootblock的生成
make "V="
可以看到生成bootblock的具体过程如下
说明为了生成bootblock,需要生成bootasm.o、bootmain.o、sign。并且我们也可以看到具体过程:
1. 将bootasm.S编译并链接为bootasm.o
+ cc boot/bootasm.S
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
2. 将bootmain.c编译并链接为bootmain.o
+ cc boot/bootmain.c
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
3. 将sign.c编译并链接为sign
+ cc tools/sign.c
gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
对应161行
4. 将bootasm.o、bootmain.o链接为bootblock.o:
+ ld bin/bootblock
ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
对应makefile代码中的163行(164行是预处理,移除所有符号和重定位信息)
5. 拷贝二进制代码bootblock.o到bootblock.out,并进行符号重定位。 对应makefile代码中的165行。
6. 利用sign工具处理bootblock.out生成bootblock。 对应makefile代码中的166行
初始化
执行 make "V="
dd if=/dev/zero of=bin/ucore.img count=10000
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0434373 s, 118 MB/s
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes (512 B) copied, 9.1415e-05 s, 5.6 MB/s
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
146+1 records in
146+1 records out
74923 bytes (75 kB) copied, 0.0002752 s, 272 MB/s
1. 生成的文件有10000个块,每个块默认用0填充。
2. 把bootblock内容写到第一个块。
3. 将第二个块作为写kernel的起点。
一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
在sign.c文件中
引导扇区只有512字节,也就是一个块的大小,且第510个字节是0x55,第511个字节是0xAA。
练习2:使用qemu执行并调试lab1中的软件。
- 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
(1)在lab1目录下执行
make debug
(2)下一步
si
(3)查看指令
x $pc
结果不展示
2、在初始化位置0x7c00设置实地址断点,测试断点正常。
执行结果
3、从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
在bootasm.S代码中:
在bootblock.asm代码中:
在三者中代码相同
4、自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
不展示
练习3:分析bootloader进入保护模式的过程。
(1) 实模式
在bootloader接手BIOS的工作后,当前的PC系统处于实模式(16位模式)运行状态,在这种状态下软件可访问的物理内存空间不能超过1MB,且无法发挥Intel 80386以上级别的32位CPU的4GB内存管理能力。
实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址。这样,用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的。通过修改A20地址线可以完成从实模式到保护模式的转换。有关A20的进一步信息可参考附录“关于A20 Gate”。
(2) 保护模式
只有在保护模式下,80386的全部32根地址线有效,可寻址高达4G字节的线性地址空间和物理地址空间,可访问64TB(有2^14个段,每个段最大空间为2^32字节)的逻辑地址空间,可采用分段存储管理机制和分页存储管理机制。这不仅为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。通过提供4个特权级和完善的特权检查机制,既能实现资源共享又能保证代码数据的安全及任务的隔离。
A20
A20 Gate的产生是由于80286 和 8086/8088 间的兼容性问题。
当A20被禁止时:程序员给出100000H~10FFEFH间的地址,80286和8086/8088 的系统表现是一致的, 即按照对1M求模的方式进行寻址,满足系统升级的兼容性问题;
当A20被开启时:程序员给出的100000H~10FFEFH间的地址,80286是访问的真实地址,而8086/8088是 始终是按照对1M求模的方式进行的(这里注意,是始终); 为了解决上述问题,IBM使用键盘控制器上剩余的一些输出线来管理第21根地址线(从0开始数是第20 根),被称为A20Gate:如果A20 Gate被打开,则当程序员给出100000H-10FFEFH之间的地址的时候, 系统将真正访问这块内存区域;如果A20Gate被禁止,则当程序员给出100000H-10FFEFH之间的地址的 时候,系统仍然使用8086/8088的方式。
具体过程
1、初始化
首先从0x7c00进入程序,最先执行的操作是关闭中断和使得传送方向由低地址到高地址(决定了块传送 的方向)。
CLI: Clear Interupt CLD: Clear Director 然后将DS、ES、SS段寄存器复位为0。
2、开启A20
1. 首先,等待8042输入缓冲区为空(从0x64端口读入一个字节到al,如果al的第2位为0,则跳出循 环)
2. 然后,发送写8042输出端口的指令(将0xd1写入到al中,将al的数据写入到端口0x64中)
3. 同1,等待8042缓冲区为空。
4. 最后将0xdf发送至0x60。 总的来说,就是需要发送0xd1给0x64端口,发送0xdf给0x60端口,就可 以打开A20了。
3、 初始化GDT表
lgdt gdtdesc
载入即可,因为GDT和其描述符已经静态存储在引导区中了
4、进入保护模式
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
只要将cr0寄存器置为1即表示开启了保护模式。
5、通过长跳转更新cs的基地址
ljmp $PROT_MODE_CSEG, $protcseg
.code32 # 使用32位模式编译
protcseg:
6、设置段寄存器,并建立堆栈
movw $PROT_MODE_DSEG, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
movl $0x0, %ebp
movl $start, %esp
7、转到保护模式完成,进入boot主方法
call bootmain
练习4:分析bootloader加载ELF格式的OS的过程。
通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS
bootloader如何读取硬盘扇区的?
观察bootmain.c代码readsect部分:
readsect从设备的第secno扇区读取数据到dst位置
static void
readsect(void *dst, uint32_t secno) {
waitdisk();
outb(0x1F2, 1); // 设置读取扇区的数目为1
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
// 上面四条指令联合制定了扇区号
// 在这4个字节线联合构成的32位参数中
// 29-31位强制设为1
// 28位(=0)表示访问"Disk 0"
// 0-27位是28位的偏移量
outb(0x1F7, 0x20); // 0x20命令,读取扇区
waitdisk();insl(0x1F0, dst, SECTSIZE / 4); // 读取到dst位置,
// 幻数4因为这里以DW为单位
}
读取一个扇区就执行一次readsect函数,其主要步骤:
1. 等待磁盘准备好。
2. 发出读扇区的命令。
3. 等待磁盘准备好。
4. 把扇区数据读到指定内存。
而readseg函数主要就是循环调用了readsect,使之可以读取任意 count的数据。
readseg简单包装了readsect,可以从设备读取任意长度的内容。
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;
va -= offset % SECTSIZE;
uint32_t secno = (offset / SECTSIZE) + 1;
// 加1因为0扇区被引导占用
// ELF文件从1扇区开始
for (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);
}
}
bootloader是如何加载ELF格式的OS?
在bootmain函数中,
void
bootmain(void) {
// 首先读取ELF的头部
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// 通过储存在头部的幻数判断是否是合法的ELF文件
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
// ELF头部有描述ELF文件应加载到内存什么位置的描述表,
// 先将描述表的头地址存在ph
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
// 按照描述表将ELF文件中数据载入内存
for (; ph < eph; ph ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
// ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
// ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000// 根据ELF头部储存的入口信息,找到内核的入口
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1);
}
练习5 实现函数调用堆栈跟踪函数 (需要编程)
我们需要在lab1中完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。
调用函数的过程其实就是移动ebp和esp,开辟新的空间,并且在call的时候压入返回地址以及修 改eip的值。而return返回调用的过程其实就是它的逆过程,我们照这个思想可以写出以下代码:
uint32_t ebp = read_ebp(), eip = read_eip(); //获取ebp栈底指针和eip(CPU读取的指 令的地址)
int i, j;
for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) { //终止循环的条件是到了栈 底,或者超出了调用的函数的数目
cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip);//打印ebp和eip的值
uint32_t *args = (uint32_t *)ebp + 2;//参数的首地址
for (j = 0; j < 4; j ++) {
cprintf("0x%08x ", args[j]);
}//打印参数,假设有4个参数
cprintf("\n");
print_debuginfo(eip - 1);//打印上一个指令
eip = ((uint32_t *)ebp)[1];//返回地址
ebp = ((uint32_t *)ebp)[0];//old ebp
}
练习6:完善中断初始化和处理 (需要编程)
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几 位代表中断处理代码的入口?
中断向量表一个表项占8个字节,下图是前32位。
其中最开始两个字节和最末尾两个字节构成了offset也就是位移,而2-3字节也就是图中的16-31位定义了 处理代码入口地址的段选择子,使用段选择子在GDT(全局描述符表)中查找到相应段的base address, 加上offset就是中断处理带码的入口。
请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
extern uintptr_t __vectors[];
int i;//将所有的中断都初始化位内核级的中断 //vectors数组中存放了中断入口的地址偏移量
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
// set for switch from user to kernel
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
// load the IDT
lidt(&idt_pd);
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”
case T_SWITCH_TOU:
if (tf->tf_cs != USER_CS) {
switchk2u = *tf;
switchk2u.tf_cs = USER_CS;
switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS;
switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8;
// set eflags, make sure ucore can use io under user mode.
// if CPL > IOPL, then cpu will generate a general protection.
switchk2u.tf_eflags |= FL_IOPL_MASK;
// set temporary stack
// then iret will jump to the right stack
*((uint32_t *)tf - 1) = (uint32_t)&switchk2u;
}
break;