操作系统 ucore os lab1实验

操作系统 ucore os lab1实验

实验目的:
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS,读者可以了解到:

计算机原理
CPU的编址与寻址: 基于分段机制的内存管理
CPU的中断机制
外设:串口/并口/CGA,时钟,硬盘

Bootloader软件
编译运行bootloader的过程
调试bootloader的方法
PC启动bootloader的过程
ELF执行文件的格式和加载
外设访问:读硬盘,在CGA上显示字符串

ucore OS软件
编译运行ucore OS的过程
ucore OS的启动过程
调试ucore OS的方法
函数调用关系:在汇编级了解函数调用栈的结构和处理过程
中断管理:与软件相关的中断处理
外设管理:时钟

练习1:理解通过make生成执行文件的过程

问题1:操作系统镜像文件ucore.img是如何一步一步生成的?
进入 /home/moocos/ucore_lab/labcodes_answer/lab1_result 目录下
执行 make “V=”, 观察生成 ucore.img 的过程
如果当前目录已有 /bin/ 目录和 /obj/ 目录,我们先去执行 make clean ,再执行 make “V=” 观察 ucore.img 的生成过程。
在这里插入图片描述
由以上过程可知
编译16个内核文件,构建出内核bin/kernel
生成 bin/bootblock 引导程序
编译bootasm.S,bootmain.c,链接生成obj/bootblock.o
编译sign.c生成sign.o工具
使用sign.o工具规范化bootblock.o,生成bin/bootblock引导扇区
生成 ucore.img 虚拟磁盘
dd初始化ucore.img为5120000 bytes,内容为0的文件
dd拷贝bin/bootblock到ucore.img第一个扇区
dd拷贝bin/kernel到ucore.img第二个扇区往后的空间
问题2:一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
根据问题1可知通过sign.c文件的操作使得bootblock.o成为一个符合规范的引导扇区,因此查看sign.c的内容
在这里插入图片描述
由以上代码可知,硬盘主引导扇区特征为:
大小为512字节,空余部分用0填充
文件内容不超过510 bytes
最后2 bytes为0x55 0xAA

练习2:使用qemu执行并调试lab1中的软件

从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
在初始化位置0x7c00设置实地址断点,测试断点正常。
从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
我们可以先看看 Makefile 文件里面都需要干哪些事情。
我们在 /home/moocos/ucore_lab/labcodes_answer/lab1_result 目录下使用 less Makefile 命令去浏览 Makefile 文件中的内容,通过 /lab1-mon 去定位到相应行数的代码
在这里插入图片描述
我们可以看到这条命令大概干了两件事情:
第一个是让 qemu 把它执行的指令给记录下来,放到 q.log 这个地方
第二个是和 gdb 结合来调试正在执行的 Bootloader
我们看看初始化执行指令中都有哪些内容,我们使用如下命令:less tools/lablinit
会显示:
在这里插入图片描述
它大概干了如下的一些事情:
第一条指令是加载 bin/kernel。(加载符号信息,事实上是ucore的信息)
第二条指令是与 qemu 进行连接,通过这个TRP进行连接
刚开始的时候,BIOS是进入8086的16位实模式方式,一直到0x7c00。在BIOS这个阶段,启动,最后把Bootloader加载进去,把控制权交给Bootloader,那么Bootloader第一条指令就是在0x7c00处,所以我们在这个地方设置一个断点,break 0x7c00
然后让这个系统继续运行,那么我们就会看到它会在这个断点处停下来,那我们可以把相应的这个指令给打印出来。
最后一条指令的意思是把PC(也就是EIP,即指令指针寄存器),它存在当前正在执行这个指令的地址,
那么x是显示的意思,/2i是显示两条,i是指令。
我们尝试用命令去执行一下 bootloader第一条指令看看效果:make lab1-mon
qemu 已经启动起来了。但是它断下来了,断在哪里呢?我们可以看到断点箭头指向 0x7c00 处。我们还可以显示更多的条数信息,比如我们可以执行 x /10i $pc ,可以把当前的10条指令都显示出来。
在这里插入图片描述
我们可以查看 boot/bootasm.S 文件,我们已经断到 Bootloader 起始的位置,我们接下来可以让它继续运行。
Bootloader 已经加载进来了。
我们修改tools/gdbinit如下:
在这里插入图片描述
在 /home/moocos/ucore_lab/labcodes_answer/lab1_result下执行make debug:
此时CS为0xF000,PC为0xFFF0,内存地址为0xFFFF0
可知,CPU加电后第一条执行位于0xFFFF0,并且第一条指令为长跳转指令
可知,BIOS实例存储在cs:ip为0xf000:0xe05b的位置
使用si命令可对BIOS进行单步跟踪
我们再对 tools/gdbinit 做如下修改:
在这里插入图片描述
在 /home/moocos/ucore_lab/labcodes_answer/lab1_result下执行make debug:
调试发现0x7C00为主引导程序的入口地址,代码与bootasm.S一致
使用ni可进行单步调试
我们再对 tools/gdbinit 做如下修改
在这里插入图片描述
在 /home/moocos/ucore_lab/labcodes_answer/lab1_result下执行make debug:
在内核入口处增加断点,可以看到代码停在kern_init函数
使用ni可进行单步调试

练习3:分析bootloader进入保护模式的过程

事实上,Bootloader 完成了一些最基本的功能,比如 它能够把80386的保护模式给开启,使得现在的软件进入了一个32位的寻址空间,这就是我们的寻址方式发生了变化。为了做好这一步,它需要干如下一些事情:
开启A20
初始化GDT表(全局描述符表)
使能和进入保护模式
为何开启A20,以及如何开启A20
在i8086时代,CPU的数据总线是16bit,地址总线是20bit(20根地址总线),寄存器是16bit,因此CPU只能访问1MB以内的空间。因为数据总线和寄存器只有16bit,如果需要获取20bit的数据, 我们需要做一些额外的操作,比如移位。实际上,CPU是通过对segment(每个segment大小恒定为64K) 进行移位后和offset一起组成了一个20bit的地址,这个地址就是实模式下访问内存的地址:
address = segment << 4 | offset
理论上,20bit的地址可以访问1MB的内存空间(0x00000 - (2^20 - 1 = 0xFFFFF))。但在实模式下, 这20bit的地址理论上能访问从0x00000 - (0xFFFF0 + 0xFFFF = 0x10FFEF)的内存空间。也就是说,理论上我们可以访问超过1MB的内存空间,但越过0xFFFFF后,地址又会回到0x00000。
上面这个特征在i8086中是没有任何问题的(因为它最多只能访问1MB的内存空间),但到了i80286/i80386后,CPU有了更宽的地址总线,数据总线和寄存器后,这就会出现一个问题: 在实模式下, 我们可以访问超过1MB的空间,但我们只希望访问 1MB 以内的内存空间。为了解决这个问题, CPU中添加了一个可控制A20地址线的模块,通过这个模块,我们在实模式下将第20bit的地址线限制为0,这样CPU就不能访问超过1MB的空间了。进入保护模式后,我们再通过这个模块解除对A20地址线的限制,这样我们就能访问超过1MB的内存空间了。
注:事实上,A20就是第21根线,用来控制是否允许对 0x10FFEF 以上的实际内存寻址。称为A20 Gate
默认情况下,A20地址线是关闭的(20bit以上的地址线限制为0),因此在进入保护模式(需要访问超过1MB的内存空间)前,我们需要开启A20地址线(20bit以上的地址线可为0或者1)。
在这里插入图片描述

练习4:分析bootloader加载ELF格式的OS的过程

进入保护模式之后,Bootloader 需要干的很重要的一件事就是加载 ELF 文件。因为我们的 kernel(也就是ucore OS)是以 ELF 文件格式存在硬盘上的。
[~/moocos/ucore_lab/labcodes_answer/lab1_result]
moocos-> file bin/kernel
bin/kernel: ELF 32-bit LSB executable, Intel 80386, version 1(SYSV), statically linked, not stripped
定义ELF头指针,指向0x10000
读取8个扇区大小的ELF头到内存地址0x10000
校验ELF header中的模数,判断是否为0x464C457FU
读取ELF header中的程序段到内存中
跳转到操作系统入口
定义ELF头指针,指向0x10000
读取8个扇区大小的ELF头到内存地址0x10000
校验ELF header中的模数,判断是否为0x464C457FU
读取ELF header中的程序段到内存中
跳转到操作系统入口
Bootloader 如何把 ucore 加载到内存中去呢?它需要完成如下的两步操作:
bootloader如何读取硬盘扇区的
bootloader是如何加载ELF格式的OS
执行完bootasm.S后,系统进入保护模式, 进行bootmain.c开始加载OS
定义ELF头指针,指向0x10000
读取8个扇区大小的ELF头到内存地址0x10000
校验ELF header中的模数,判断是否为0x464C457FU
读取ELF header中的程序段到内存中
跳转到操作系统入口
bootloader如何读取硬盘扇区的
bootloader是如何加载ELF格式的OS
bootloader如何读取硬盘扇区的

  • bootloader进入保护模式并载入c程序bootmain* bootmain中readsect函数完成读取磁盘扇区的工作,函数传入一个指针和一个uint_32类型secno,函数将secno对应的扇区内容拷贝至指针处* 调用waitdisk函数等待地址0x1F7中低8、7位变为0,1,准备好磁盘* 向0x1F2输出1,表示读1个扇区,0x1F3输出secno低8位,0x1F4输出secno的815位,0x1F5输出secno的1623位,0x1F6输出0xe+secno的24~27位,第四位0表示主盘,第六位1表示LBA模式,0x1F7输出0x20* 调用waitdisk函数等待磁盘准备好* 调用insl函数把磁盘扇区数据读到指定内存
    bootloader是如何加载ELF格式的OS
    bootloader通过bootmain函数完成ELF格式OS的加载。
  • 调用readseg函数从kernel头读取8个扇区得到elfher* 判断elfher的成员变量magic是否等于ELF_MAGIC,不等则进入bad死循环* 相等表明是符合格式的ELF文件,循环调用readseg函数加载每一个程序段* 调用elfher的入口指针进入OS

练习5:实现函数调用堆栈跟踪函数

完成kdebug.c中函数print_stackframe的实现
要完成实验首先必须了解函数栈的构建过程
ebp为基址指针寄存器
esp为堆栈指针寄存器(指向栈顶)
ebp寄存器处于一个非常重要的地位,该寄存器中存储着栈中的一个地址(原ebp入栈后的栈顶),从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的ebp值
举一个实际的例子查看ebp与esp两个寄存器如何构建出完整的函数栈:
leave等同于movl %ebp, %esp,popl %ebp两条指令
int g(int x) { return x + 10; } int f(int x) { return g(x); } int main(void) { return f(20) + 8; }
实现过程如下:

  • 使用 read_ebp(), read_eip()函数获得ebp,eip的值 * 循环: 1. 输出ebp,eip的值 2. 输出4个参数的值,其中第一个参数的地址为ebp+8,依次加4得到下一个参数的地址 3. 更新ebp,eip,其中新的ebp的地址为ebp,新的eip的地址为ebp+4,即返回地址 4. ebp为0时表明程序返回到了最开始初始化的函数,ebp=0为循环的退出条件 void print_stackframe(void){ uint32_t ebp = read_ebp(), eip = read_eip(); int i, j; for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) { cprintf(“ebp:0x%08x eip:0x%08x args:”, ebp, eip); // ebp向上移动4个字节为eip uint32_t *args = (uint32_t *)ebp + 2; // 再向上每4个字节都为输入的参数(这里只是假设4个参数,做实验) for (j = 0; j < 4; j ++) { cprintf(“0x%08x “, args[j]); } cprintf(”\n”); print_debuginfo(eip - 1); // ebp指针指向的位置向上一个地址为上一个函数的eip eip = ((uint32_t *)ebp)[1]; // ebp指针指向的位置存储的上一个ebp的地址 ebp = ((uint32_t *)ebp)[0]; } }

练习6:完善中断初始化和处理

为什么有中断?
操作系统需要对计算机系统中的各种外设进行管理,这就需要CPU和外设能够相互通信才行,CPU速度远快于外设,若采用通常的轮询(polling)机制,则太浪费CPU资源了。所以需要操作系统和CPU能够一起提供某种机制,让外设在需要操作系统处理外设相关事件的时候,能够“主动通知”操作系统,即打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行。这种机制称为中断。
中断的类型
由CPU外部设备引起的外部事件如I/O中断、时钟中断、控制台中断等是异步产生的(即产生的时刻不确定),与CPU的执行无关,我们称之为异步中断,也称外部中断
在CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作同步中断,也称内部中断
在程序中使用请求系统服务的系统调用而引发的事件,称作陷入中断,也称软中断,系统调用简称trap
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
当CPU收到中断时,会查找对应的中断描述符表(IDT),确定对应的中断服务例程。
IDT是一个8字节的描述符数组,IDT 可以位于内存的任意位置,CPU 通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址。指令LIDT和SIDT用来操作IDTR。
DT的一个表项如下,4个字节分别存储offset的高位地址、段选择子和offset低位地址
请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
查看SETGATE宏定义
由代码看出SETGATE本质是设置生成一个4字节的中断描述表项
gate为中断描述符表项对应的数据结构,定义在mmu.h为struct gatedesc
istrap标识是中断还是系统调用,唯一区别在于,中断会清空IF标志,不允许被打断
sel与off分别为中断服务例程的代码段与偏移量,dpl为访问权限
在这里插入图片描述
查看vector.S定义的中断号定义
保护模式下有256个中断号,0~31是保留的, 用于处理异常和NMI(不可屏蔽中断); 32~255由用户定义, 可以是设备中断或系统调用.
所有的中断服务例程,最终都是跳到__alltraps进行处理
注意这里的标号对应的地址为代码段偏移量
在这里插入图片描述
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
通过之前的分析查看__alltraps所在的trappentry.S文件
压栈各种需要传递给中断服务例程的信息,形成trapFrame,调用trap函数
注意进入这个函数前,vector.S中已经压栈了1,2个参数
最终调用了trap_dispatch根据中断号将中断分发给不同的服务例程
+IRQ_OFFSET为32,与之前32~255由用户定义, 为设备中断或系统调用的描述一致.
填充时钟中断响应代码,完成实验。

  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值