操作系统实验报告清华大学LAB1

1 实验内容

1.1 实验步骤:

  1. 启动操作系统的bootloader,用于了解操作系统启动前的状态和要做的准备工作,了解运行操作系统的硬件支持,操作系统如何加载到内存中,理解两类中断–“外设中断”,“陷阱中断”等;
  2. 物理内存管理子系统,用于理解x86分段/分页模式,了解操作系统如何管理物理内存;
  3. 虚拟内存管理子系统,通过页表机制和换入换出(swap)机制,以及中断-“故障中断”、缺页故障处理等,实现基于页的内存替换算法;
  4. 内核线程子系统,用于了解如何创建相对与用户进程更加简单的内核态线程,如果对内核线程进行动态管理等;
  5. 用户进程管理子系统,用于了解用户态进程创建、执行、切换和结束的动态管理过程,了解在用户态通过系统调用得到内核态的内核服务的过程;
  6. 处理器调度子系统,用于理解操作系统的调度过程和调度算法;
  7. 同步互斥与进程间通信子系统,了解进程间如何进行信息交换和共享,并了解同步互斥的具体实现以及对系统性能的影响,研究死锁产生的原因,以及如何避免死锁;
  8. 文件系统,了解文件系统的具体实现,与进程管理等的关系,了解缓存对操作系统IO访问的性能改进,了解虚拟文件系统(VFS)、buffer cache和disk driver之间的关系。

操作系统实验报告清华大学LAB1_第1张图片

1.2 实验报告要求

为了实现lab1的目标,lab1提供了6个基本练习和1个扩展练习,要求完成实验报告。

对实验报告的要求:

  • 基于markdown格式来完成,以文本方式为主。
  • 填写各个基本练习中要求完成的报告内容
  • 完成实验后,请分析ucore_lab中提供的参考答案,并请在实验报告中说明你的实现与参考答案的区别
  • 列出你认为本实验中重要的知识点,以及与对应的OS原理中的知识点,并简要说明你对二者的含义,关系,差异等方面的理解(也可能出现实验中的知识点没有对应的原理知识点)
  • 列出你认为OS原理中很重要,但在实验中没有对应上的知识点

2 实验L0

2.1 QEMU

qemu:硬件模拟器,对于我们的实验而言,它可用于模拟一台 X86 计算机,让ucore能够在其上运行。

qemu 运行可以有多参数,格式如:qemu [options] [disk_image]

disk_image是硬盘镜像文件

部分参数说明:

`-hda file'        `-hdb file' `-hdc file' `-hdd file'
    使用 file  作为硬盘0、1、2、3镜像。
`-fda file'  `-fdb file'
    使用 file  作为软盘镜像,可以使用 /dev/fd0 作为 file 来使用主机软盘。
`-cdrom file'
    使用 file  作为光盘镜像,可以使用 /dev/cdrom 作为 file 来使用主机 cd-rom。
`-boot [a|c|d]'
    从软盘(a)、光盘(c)、硬盘启动(d),默认硬盘启动。
`-snapshot'
    写入临时文件而不写回磁盘镜像,可以使用 C-a s 来强制写回。
`-m megs'
    设置虚拟内存为 msg M字节,默认为 128M 字节。
`-smp n'
    设置为有 n 个 CPU 的 SMP 系统。以 PC 为目标机,最多支持 255 个 CPU。
`-nographic'
    禁止使用图形输出。
其他:
    可用的主机设备 dev 例如:
        vc
            虚拟终端。
        null
            空设备
        /dev/XXX
            使用主机的 tty。
        file: filename
            将输出写入到文件 filename 中。
        stdio
            标准输入/输出。
        pipe:pipename
            命令管道 pipename。
        等。    
    使用 dev 设备的命令如:
        `-serial dev'
            重定向虚拟串口到主机设备 dev 中。
        `-parallel dev'
            重定向虚拟并口到主机设备 dev 中。
        `-monitor dev'
            重定向 monitor 到主机设备 dev 中。
    其他参数:
        `-s'
            等待 gdb 连接到端口 1234。
        `-p port'
            改变 gdb 连接端口到 port。
        `-S'
            在启动时不启动 CPU, 需要在 monitor 中输入 'c',才能让qemu继续模拟工作。
        `-d'
            输出日志到 qemu.log 文件。

例如:

qemu -hda ucore.img -parallel stdio        // 让ucore在qemu模拟的x86硬件环境中运行
qemu -S -s -hda ucore.img -monitor stdio    // 开启远程调试

2.2 make

在实验文件夹下使用make命令即可,make会按照当前目录下的Makefile脚本构建项目。

例如在Lab1中:

cd .../lab1
make

此时在lab1目录下的bin目录中会生成一系列的目标文件:

  • ucore.img:系统镜像文件
  • kernel: ELF格式的系统内核文件,被嵌入到了ucore.img
  • bootblock: 虚拟的硬盘主引导扇区(512字节),包含了bootloader执行代码,被嵌入到了ucore.img
  • sign:小工具,用来生成符合规范的虚拟硬盘主引导扇区

2.3 调试

为了与qemu配合进行源代码级别的调试,需要先让qemu等待gdb调试器的接入并且不能让qemu中的CPU在此之前执行,因此启动qemu的时候,我们需要使用参数-S –s来做到这一点,这相当于在本地的1234端口开启远程调试服务。

在使用了前面提到的参数启动qemu之后,qemu中的CPU并不会马上开始执行,这时我们启动gdb,然后在gdb命令行界面下,使用下面的命令连接到qemu:

//在lab1目录下
(gdb)  target remote :1234

然后输入c(也就是continue)命令之后,qemu会继续执行下去,但是gdb由于不知道任何符号信息,并且也没有下断点,是不能进行源码级的调试的。为了让gdb获知符号信息,需要指定调试目标文件,gdb中使用file命令:

(gdb)  file xxxx

之后gdb就会载入这个文件中的符号信息,这时通过gdb就可以对ucore代码进行调试了.

在lab1中调试memset函数为例:

在第一个终端中执行:

//此时在lab1目录下
qemu -S -s -hda ./bin/ucore.img -monitor stdio     // 启动qemu运行ucore并开启远程调试服务

这时会弹出一个窗口,qemu已经开始等待远程gdb的连接了,接下来打开第二个终端运行gdb。(如果你在此过程中发现鼠标指针不见了,这是因为你点击到了qemu的图形窗口导致鼠标被其捕获,使用快捷键Ctrl+Alt+G即可重新获取鼠标控制权。)

在第二个终端中执行:

cd .../lab1    //切换至lab1的目录
gdb    //启动gdb
(gdb) file ./bin/kernel    //在gdb中载入目标文件以获取符号信息
(gdb) target remote :1234    //用gdb连接至本地的1234端口进行调试
(gdb) break memset    //在memset函数处下断点
(gdb) continue    //调试至断点

为了方便,可以将调试命令保存在脚本中,并让gdb在启动的时候载入。

以lab1为例,在lab1/tools目录下,执行完make后,我们可以修改gdbinit文件:

打开文件,进入文本编辑窗口,如下编辑文件内容(第5-10行)

file bin/kernel    //在gdb中载入目标文件以获取符号信息
set architecture i8086    //设置CPU架构为i8086
target remote :1234    //用gdb连接至本地的1234端口进行调试
break kern_init    //在内核初始化函数处设置断点
define hook-stop    //这部分设置每次单步调试都显示出附近两行的汇编以方便调试
x/2i $pc
end

使用上面编辑好的脚本启动gdb:

cd ..    //退回lab1目录
gdb -tui -x tools/gdbinit    //以gdbinit脚本启动gdb并开启源代码视图

2.4 其他指令

make grade    // 测试编写的实验代码是否基本正确
make handin    // 如果实现基本正确(即看到上条指令输出都是OK)则生成实验打包 
make qemu    // 让OS实验工程在qemu上运行
make debug    // 实现通过gdb远程调试OS实验工程

设定调试目标架构:

在调试的时候,我们也许需要调试非i386保护模式的代码,而是比如8086实模式的代码,这时我们需要设定当前使用的架构:

(gdb) set architecture i8086

加载调试目标:

在使用qemu进行远程调试的时候,我们必须手动加载符号表,也就是在gdb中用file命令。

这样加载调试信息都是按照elf文件中制定的虚拟地址进行加载的,这在静态连接的代码中没有任何问题。但是在调试含有动态链接库的代码时,动态链接库的ELF执行文件头中指定的加载虚拟地址都是0,这个地址实际上是不正确的。从操作系统角度来看,用户态的动态链接库的加载地址都是由操作系统动态分配的,没有一个固定值。然后操作系统再把动态链接库加载到这个地址,并由用户态的库链接器(linker)把动态链接库中的地址信息重新设置,自此动态链接库才可正常运行。

由于分配地址的动态性,gdb并不知道这个分配的地址是多少,因此当我们在对这样动态链接的代码进行调试的时候,需要手动要求gdb将调试信息加载到指定地址。下面,我们要求gdb将linker加载到0x6fee6180这个地址上:

(gdb) add-symbol-file ....../linker 0x6fee6180

3 L1

3.1 Lab1基础了解

3.1.1 lab1的整体目录结构如下所示:

lab1的整体目录结构如下所示:
>>> tree
  .
  ├── bin  // =======编译后生成======================================
  │   ├── bootblock  // 是引导区
  │   ├── kernel     // 是操作系统内核
  │   ├── sign       // 用于生成一个符合规范的硬盘主引导扇区
  │   └── ucore.img // ucore.img 通过dd指令,将上面我们生成的 bootblock 和 kernel 的ELF文件拷贝到ucore.img
  ├── boot // =======bootloader 代码=================================
  │   ├── asm.h      // 是bootasm.S汇编文件所需要的头文件, 是一些与X86保护模式的段访问方式相关的宏定义.
  │   ├── bootasm.S // 0. 定义了最先执行的函数start,部分初始化,从实模式切换到保护模式,调用bootmain.c中的bootmain函数
  │   └── bootmain.c // 1. 实现了bootmain函数, 通过屏幕、串口和并口显示字符串,加载ucore操作系统到内存,然后跳转到ucore的入口处执行.
  |                  // 生成 bootblock.out 
  |                  // 由 sign.c 在最后添加 0x55AA之后生成 规范的 512字节的
  ├── kern  // =======ucore系统部分===================================
  │   ├── debug// 内核调试部分 ==================================================
  │   │   ├── assert.h   // 保证宏 assert宏,在发现错误后调用 内核监视器kernel monitor
  │   │   ├── kdebug.c  // 提供源码和二进制对应关系的查询功能,用于显示调用栈关系。
  │   │   ├── kdebug.h   // 其中补全print_stackframe函数是需要完成的练习。
  │   │   ├── kmonitor.c // 实现提供动态分析命令的kernel monitor,便于在ucore出现bug或问题后,
  │   │   ├── kmonitor.h // 能够进入kernel monitor中,查看当前调用关系。
  │   │   ├── panic.c    // 内核错误(Kernel panic)是指操作系统在监测到内部的致命错误,
  │   │   └── stab.h
  │   ├── driver //驱动==========================================================
  │   │   ├── clock.c    // 实现了对时钟控制器8253的初始化操作 系统时钟 
  │   │   ├── clock.h   
  │   │   ├── console.c  // 实现了对串口和键盘的中断方式的处理操作 串口命令行终端
  │   │   ├── console.h
  │   │   ├── intr.c     // 实现了通过设置CPU的eflags来屏蔽和使能中断的函数
  │   │   ├── intr.h
  │   │   ├── kbdreg.h   // 
  │   │   ├── picirq.c   // 实现了对中断控制器8259A的初始化和使能操作   
  │   │   └── picirq.h
  │   ├── init // 系统初始化======================================================
  │   │   └── init.c       // ucore操作系统的初始化启动代码
  │   ├── libs
  │   │   ├── readline.c
  │   │   └── stdio.c
  │   ├── mm // 内存管理 Memory management========================================
  │   │   ├── memlayout.h  // 操作系统有关段管理(段描述符编号、段号等)的一些宏定义
  │   │   ├── mmu.h        // 内存管理单元硬件 Memory Management Unit 将线性地址映射为物理地址,包括EFLAGS寄存器等段定义
  │   │   ├── pmm.c     // 设定了ucore操作系统在段机制中要用到的全局变量
  │   │   └── pmm.h        // 任务状态段ts,全局描述符表 gdt[],加载gdt的函数lgdt, 初始化函数gdt_init
  │   └── trap // 陷阱trap 异常exception 中断interrupt 中断处理部分=================
  │       ├── trap.c       // 紧接着第二步初步处理后,继续完成具体的各种中断处理操作;
  │       ├── trapentry.S  // 紧接着第一步初步处理后,进一步完成第二步初步处理;
  |       |                // 并且有恢复中断上下文的处理,即中断处理完毕后的返回准备工作;
  │       ├── trap.h       // 紧接着第二步初步处理后,继续完成具体的各种中断处理操作;
  │       └── vectors.S    // 包括256个中断服务例程的入口地址和第一步初步处理实现。
  |                        // 此文件是由tools/vector.c在编译ucore期间动态生成的
  ├── libs // 公共库部分===========================================================
  │   ├── defs.h           // 包含一些无符号整型的缩写定义
  │   ├── elf.h
  │   ├── error.h
  │   ├── printfmt.c
  │   ├── stdarg.h     // argument 参数
  │   ├── stdio.h          // 标志输入输出 io
  │   ├── string.c
  │   ├── string.h
  │   └── x86.h            // 一些用GNU C嵌入式汇编实现的C函数
  ├── Makefile             // 指导make完成整个软件项目的编译,清除等工作。
  └── tools // 工具部分============================================================
      ├── function.mk      // mk模块 指导make完成整个软件项目的编译,清除等工作。
      ├── gdbinit       // gnu debugger 调试
      ├── grade.sh
      ├── kernel.ld
      ├── sign.c           // 一个C语言小程序,是辅助工具,用于生成一个符合规范的硬盘主引导扇区。
      |                    // 规范的硬盘主引导扇区大小为512字节,结束符为0x55AA
      |                    // obj/bootblock.out( <= 500 )  +  0x55AA -> bootblock(512字节)
      └── vector.c         // 生成vectors.S 中断服务例程的入口地址和第一步初步处理实现

3.1.2 bootloader部分

  • boot/bootasm.S :定义并实现了bootloader最先执行的函数start,此函数进行了一定的初始化,完成了从实模式到保护模式的转换,并调用bootmain.c中的bootmain函数。
  • boot/bootmain.c:定义并实现了bootmain函数实现了通过屏幕、串口和并口显示字符串。bootmain函数加载ucore操作系统到内存,然后跳转到ucore的入口处执行。
  • boot/asm.h:是bootasm.S汇编文件所需要的头文件,主要是一些与X86保护模式的段访问方式相关的宏定义。

3.1.3 ucore操作系统部分

1 系统初始化部分:
  • kern/init/init.c:ucore操作系统的初始化启动代码
2 内存管理部分:
  • kern/mm/memlayout.h:ucore操作系统有关段管理(段描述符编号、段号等)的一些宏定义
  • kern/mm/mmu.h:ucore操作系统有关X86 MMU等硬件相关的定义,包括EFLAGS寄存器中各位的含义,应用/系统段类型,中断门描述符定义,段描述符定义,任务状态段定义,NULL段声明的宏SEG_NULL, 特定段声明的宏SEG,设置中断门描述符的宏SETGATE
  • kern/mm/pmm.[ch]:设定了ucore操作系统在段机制中要用到的全局变量:任务状态段ts,全局描述符表 gdt[],加载全局描述符表寄存器的函数lgdt,临时的内核栈stack0;以及对全局描述符表和任务状态段的初始化函数gdt_init
3 外设驱动部分:
  • kern/driver/intr.[ch]:实现了通过设置CPU的eflags来屏蔽和使能中断的函数
  • kern/driver/picirq.[ch]:实现了对中断控制器8259A的初始化和使能操作
  • kern/driver/clock.[ch]:实现了对时钟控制器8253的初始化操作
  • kern/driver/console.[ch]:实现了对串口和键盘的中断方式的处理操作
4 中断处理部分:
  • kern/trap/vectors.S:包括256个中断服务例程的入口地址和第一步初步处理实现。注意,此文件是由tools/vector.c在编译ucore期间动态生成的
  • kern/trap/trapentry.S:紧接着第一步初步处理后,进一步完成第二步初步处理;并且有恢复中断上下文的处理,即中断处理完毕后的返回准备工作
  • kern/trap/trap.[ch]:紧接着第二步初步处理后,继续完成具体的各种中断处理操作
5 内核调试部分:
  • kern/debug/kdebug.[ch]:提供源码和二进制对应关系的查询功能,用于显示调用栈关系。其中补全print_stackframe函数是需要完成的练习。其他实现部分不必深究。
  • kern/debug/kmonitor.[ch]:实现提供动态分析命令的kernel monitor,便于在ucore出现bug或问题后,能够进入kernel monitor中,查看当前调用关系。实现部分不必深究。
  • kern/debug/panic.c | assert.h:提供了panic函数和assert宏,便于在发现错误后,调用kernel monitor。大家可在编程实验中充分利用assert宏和panic函数,提高查找错误的效率。
6 公共库部分
  • libs/defs.h:包含一些无符号整型的缩写定义。
  • libs/x86.h:一些用GNU C嵌入式汇编实现的C函数(由于使用了inline关键字,所以可以理解为宏)。
7 工具部分
  • Makefile和function.mk:指导make完成整个软件项目的编译,清除等工作。
  • sign.c:一个C语言小程序,是辅助工具,用于生成一个符合规范的硬盘主引导扇区。
  • tools/vector.c:生成vectors.S,此文件包含了中断向量处理的统一实现。

练习1

题目 1.1:

操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中每一条相关命令和命令参数的含义,以及说明命令导致的结果)

操作系统镜像文件ucore.img是如何一步一步生成的?

执行命令make v=,通过阅读其输出的步骤,我们可以得知:

  1. make执行将所有的源代码编译成对象文件,并分别链接形成kernel、bootblock文件。

  2. 使用dd命令,将生成的两个文件的数据拷贝至img文件中,形成映像文件。

dd命令与cp命令不同,该命令针对于磁盘,功能更加底层;dd 命令主要用来进行数据备份,并且可以在备份的过程中进行格式转换。.

1. 生成ucore.img:

相关代码:

# create ucore.img
#
UCOREIMG	:= $(call totarget,ucore.img)

$(UCOREIMG): $(kernel) $(bootblock)#可见生成ucore.img需要kernel和bootblock文件
	$(V)dd if=/dev/zero of=$@ count=10000  #从/dev/zero文件中获取10000个block,每一个block为512字节,并且均为空字符,并且输出到目标文件ucore.img中
	$(V)dd if=$(bootblock) of=$@ conv=notrunc  #将$(bootblock)拷贝到目标文件ucore.img中,-notruct选项表示不要对数据进行删减
	$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc  #将$(kernel)拷贝到目标文件ucore.img中, 并且seek = 1表示跳过第一个block,输出到第二个块

代码详解:

利用dd命令使用bootblock, kernel文件来生成ucore.img文件

  1. 创建一个10000块将/dev/zero拷贝进去(获得10000block的空间)

  2. 将$(bootblock)拷贝到ucore.img

  3. 将$(kernel)拷贝到ucore.img(从输出文件开头跳过1个块后再开始拷贝,即第二块)

生成一个叫ucore.img count的虚拟硬盘:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8Z6sdA6R-1650864248882)(C:\Users\PC\AppData\Roaming\Typora\typora-user-images\image-20220423141317049.png)]

2. 生成kernel:

(1)生成kernel的相关代码:

# 编译生成bin/kernel所需的文件
$(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS))

# 链接生成bin/kernel
kernel = $(call totarget,kernel)

$(kernel): tools/kernel.ld

$(kernel): $(KOBJS)
    @echo + ld $@
    $(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
    @$(OBJDUMP) -S $@ > $(call asmfile,kernel)
    @$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)
$(call create_target,kernel)

$(call totraget,kernel)指令中,将bin/前缀加到kernel中,生成bin/kernel

(2)代码详解

编译获得所需要的.o文件:

KCFLAGS		+= $(addprefix -I,$(KINCLUDE))#指定了若干gcc编译选项,存放在KCFLAGS变量中;

$(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS))
#该段代码的含义:生成kernel的所有子目录下包含的.s, .c文件所对应的.o文件以及.d文件。
#具体而言,该命令最终生成的文件为obj/kern下子目录里的以stdio, readline, panic, kdebug, kmonitor, clock, console, picirq, intr, trap, vector, trapentry, pmm为前缀的.d, .o文件;

通过make V=命令可以看见

① 调用了gcc

操作系统实验报告清华大学LAB1_第2张图片

生成kernel的过程中,需要用GCC将目标文件从.c转换成如下的.o文件;

也就是:

obj/kern/init/init.o 
obj/kern/libs/readline.o 
obj/kern/libs/stdio.o 
obj/kern/debug/kdebug.o 
obj/kern/debug/kmonitor.o 
obj/kern/debug/panic.o 
obj/kern/driver/clock.o 
obj/kern/driver/console.o 
obj/kern/driver/intr.o 
obj/kern/driver/picirq.o 
obj/kern/trap/trap.o 
obj/kern/trap/trapentry.o 
obj/kern/trap/vectors.o 
obj/kern/mm/pmm.o 
obj/libs/printfmt.o 
obj/libs/string.o

链接所有的目标文件生成elf-i386的内核文件:

kernel = $(call totarget,kernel)

$(kernel): tools/kernel.ld   #表示/bin/kernel文件依赖于tools/kernel.ld文件,并且没有指定生成规则,也就是说如果没有预先准备好kernel.ld,就会在make的时候产生错误

$(kernel): $(KOBJS)  #表示kernel文件的生成还依赖于上述生成的obj/libs, obj/kernels下的.o文件

    @echo + ld $@#并且生成规则为使用ld链接器将这些.o文件连接成kernel文件,
    $(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS) #-T表示指定使用kernel.ld来替代默认的链接器脚本
    @$(OBJDUMP) -S $@ > $(call asmfile,kernel) #-S表示将源代码与汇编代码混合展示出来,这部分代码最终保存在kernel.asm文件中
    @$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)  #-t表示打印出文件的符号表表项,然后通过管道将带有符号表的反汇编结果作为sed命令的标准输入进行处理,最终将符号表信息保存到kernel.sym文件中
$(call create_target,kernel)

3.生成bootblock

(1)生成bootblock的相关代码:

# 表示将boot/文件夹下的bootasm.S, bootmain.c两个文件编译成相应的.o文件,并且生成依赖文件.d
#两个gcc编译选项含义:
#-nostdinc: 不搜索默认路径头文件;
#-0s: 针对生成代码的大小进行优化,这是因为bootloader的总大小被限制为不大于512-2=510字节;
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))


bootblock = $(call totarget,bootblock)

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)#可知,bootblock依赖于bootasm.o, bootmain.o文件与sign文件
    @echo + ld $@
    $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
#使用ld链接器将依赖的.o文件链接成bootblock.o
#-N:将代码段和数据段设置为可读可写;
#-e:设置入口;
#-Ttext:设置起始地址为0X7C00;
    @$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)#使用objdump将编译结果反汇编出来,保存在bootclock.asm中,-S表示将源代码与汇编代码混合表示
    @$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)#使用objcopy将bootblock.o二进制拷贝到bootblock.out
#-S:表示移除符号和重定位信息;
#-O:表示指定输出格式;
    @$(call totarget,sign) $(call outfile,bootblock) $(bootblock)#使用sign程序, 利用bootblock.out生成bootblock;
    
$(call create_target,bootblock)#利用tools/sing.c生成sign.o

由此可知生成bootblock,首先需要生成bootasm.o、bootmain.o、sign

(2)代码详解

bootflies表示boot下所有文件,包括

asm.h
bootasm.S
bootmain.c 

需要把bootfiles中的bootasm.S , bootmain.c编译成bootasm.obootmain.o

② 生成bootasm.obootmain.o的代码:

# 表示将boot/文件夹下的bootasm.S, bootmain.c两个文件编译成相应的.o文件,并且生成依赖文件.d
#两个gcc编译选项含义:
#-nostdinc: 不搜索默认路径头文件;
#-0s: 针对生成代码的大小进行优化,这是因为bootloader的总大小被限制为不大于512-2=510字节;
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

通过make V=可以看到生成bootasm.obootmain.o的过程:

操作系统实验报告清华大学LAB1_第3张图片

③ 生成sign的代码:

# create 'sign' tools
$(call add_files_host,tools/sign.c,sign,sign)#利用tools/sing.c生成sign.o
$(call create_target_host,sign,sign)#利用sign.o生成sign,至此bootblock所依赖的文件均生成完毕;

sign.c是一个C语言小程序,是辅助工具,用于生成一个符合规范的硬盘主引导扇区。

通过make V=看到生成sign的过程:

操作系统实验报告清华大学LAB1_第4张图片

④ 由bootasm.o,bootmain.osign生成bootblock

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FrvYkpgH-1650864248888)(C:\Users\PC\AppData\Roaming\Typora\typora-user-images\image-20220423133314497.png)]

4.总结

构建ucore.img时大致进行了以下操作:

  • 编译了若干内核文件,构建出内核kernel
  • 生成bootblock引导程序
    • 编译bootasm.S bootmain.c,链接 obj/bootblock.o
    • 编译sign.c生成sign.o工具
    • 使用sign.o工具规范化bootblock.o,生成bin/bootblock引导扇区
  • 生成ucore.img虚拟磁盘
    • dd初始化一个大小为5120000bytes且内容为0的文件
    • dd拷贝bin/bootblock引导文件到ucore.img的第一个扇区
    • dd拷贝bin/kernel内核文件到ucore.img第二个扇区往后的空间

5. 查阅相关资料

(1)GCC相关参数:

-I:添加包含目录

-fno-builtin:只接受以“_builtin”开头的名称的内建函数

-Wall:开启全部警告提示

-ggdb:生成GDB需要的调试信息

-m32:为32位环境生成代码,int、long和指针都是32位

-gstab:生成stab格式的调试信息,仅用于gdb

-nostdinc:不扫描标准系统头文件,只在-I指令指定的目录中扫描

-fno-stack-protector:生成用于检查栈溢出的额外代码,如果发生错误,则打印错误信息并退出

-c:编译源文件但不进行链接

-o:结果的输出文件

(2)ld相关参数:

-m elf_i386:使用elf_i386模拟器

-nostdlib:只查找命令行中明确给出的库目录,不查找链接器脚本中给出的(即使链接器脚本是在命令行中给出的)

-T tools/kernel.ld:将tools/kernel.ld作为链接器脚本

-o bin/kernel:输出到bin/kernel文件

(3)生成bootblock和sign工具所需全部OBJ文件的相关命令参数:

-Os:对输出文件大小进行优化,开启全部不增加代码大小的-O2优化

-g:以操作系统原生格式输出调试信息,gdb可以处理这一信息

-O2:进行大部分不以空间换时间的优化

(4)链接生成bootblock二进制文件的相关命令参数为:

-N:将文字和数据部分置为可读写,不将数据section置为与页对齐, 不链接共享库

-e start:将start符号置为程序起始点

-Ttext 0x7C00:链接时将".bss"、".data"或".text"置于绝对地址0x7C00处

(5)生成ucore.img的命令相关参数:

if:输入

of:输出

count=10000:只拷贝输入的10000块

conv=notrunc:不截短输出文件

seek=1:从输出文件开头跳过1个块后再开始复制

题目 1.2:

一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

通常我们将包含MBR(主引导记录)引导代码的扇区称为主引导扇区。通常由3部分组成:

  • 主引导程序(MBR,占446字节)
  • 磁盘分区表项(占4×16个字节,负责说明磁盘上的分区情况)
  • 结束标志位(占2个字节,其值为55 AA) 。

上题中的sign.o工具可以规范化bootblock.o,生成bin/bootblock引导扇区,因此查看sign.c部分源代码进行分析:

// 文件大小检查,超过510字节则报错,因为最后2个字节要用作结束标志位
    if (st.st_size > 510) {
        fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
        return -1;
    }    
// 写入结束标志位
    buf[510] = 0x55;
    buf[511] = 0xAA;    
    
    // 文件大小检查
    if (size != 512) {
        fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
        return -1;
    }
    fclose(ofp);    // 释放文件

由上可知,符合规范的硬盘主引导扇区的特征是:

① 扇区总体大小为512字节;

② 512字节的组成:

  • 启动代码:不超过466字节;
  • 硬盘分区表:不超过64字节;
  • 两个字节的结束符

③ 磁盘最后两个字节是0x55 0xAA。

练习2

题目 2.1:

从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。

从CPU加电后执行的第一条指令存放于地址0xfffffff0中,执行命令x/2i 0xfffffff0,打印该地址的值,发现第一条指令是长跳转指令:ljmp $0xf000,$0xe05b

操作系统实验报告清华大学LAB1_第5张图片

题目 2.2:

在初始化位置0x7c00 设置实地址断点,测试断点正常.

使用qemu执行并调试lab1中的文件。

修改tools/gdbinit为

操作系统实验报告清华大学LAB1_第6张图片

调试指令解释:

set architecture i386 //设置当前调试的CPU是80386
b* 0x7c00  //在0x7c00处设置断点。此地址是bootloader入口点地址,可看boot/bootasm.S的start地址处
define hook-stop ... end//可以在每次gdb断下时自动执行内部的指令,断下时输出下一条指令,方便调试。

操作系统实验报告清华大学LAB1_第7张图片

问题 2.3:

将执行的汇编代码与bootasm.S 和 bootblock.asm 进行比较,看看二者是否一致。

改写makefile文件:

在该行增加语句-d in_asm -D q.log

操作系统实验报告清华大学LAB1_第8张图片

重新执行make debug命令,在bin目录下生成q.log文件,便可以在q.log中读到"call bootmain"前执行的命令。

进行比较并截取部分截图如下:

自己截图就好啦~

由此得到q.log文件中在断点后的部分与bootasm.Sbootblock.asm相同

题目 2.4:

自己找一个bootloader或内核中的代码位置,设置断点并进行测试。

设置断点0x7c01

操作系统实验报告清华大学LAB1_第9张图片

练习3

题目:分析bootloader 进入保护模式的过程。BIOS 将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行 bootloader。请分析bootloader是如何完成从实模式进入保护模式的?

打开文件bootblock.S,分析bootloader:

1. 关闭中断,将各个段寄存器重置

# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.globl start
start:
.code16                                             # Assemble for 16-bit mode
    cli                                             # Disable interrupts
    cld                                             # String operations increment

    # Set up the important data segment registers (DS, ES, SS).
    xorw %ax, %ax                                   # Segment number zero
    movw %ax, %ds                                   # -> Data Segment
    movw %ax, %es                                   # -> Extra Segment
    movw %ax, %ss                                   # -> Stack Segment

关闭中断,清理环境:将flag和寄存器AX,DS,ES,SS清零

2. 开启A20

(1)为什么会出现A20?

Intel早期的8086 CPU提供了20根地址线,但寄存器只有16位,因此采用段寄存器值 << 4 + 段内偏移值的方法来访问到所有内存,但按这种方式来计算出的地址的最大值为1088KB,超过20根地址线所能表示的范围,会发生“回卷”(和整数溢出有点类似)。但下一代的基于Intel 80286 CPU的计算机系统提供了24根地址线,当CPU计算出的地址超过1MB时便不会发生回卷,而这就造成了向下不兼容。为了保持完全的向下兼容性,IBM在计算机系统上加个硬件逻辑来模仿早期的回绕特征,而这就是A20 Gate.

(2)A20的机制

A20 Gate的方法是把A20地址线控制和键盘控制器的一个输出进行AND操作,这样来控制A20地址线的打开(使能)和关闭(屏蔽\禁止)。一开始时A20地址线控制是被屏蔽的(总为0),直到系统软件通过一定的IO操作去打开它。当A20 地址线控制禁止时,程序就像在 8086 中运行,软件可访问的物理内存空间不能超过1MB,且无法发挥Intel 80386以上级别的32位CPU的4GB内存管理能力。而开启A20,通过将键盘控制器上的A20线置于高电位1,使得全部32条地址线可用,保护模式下 A20 地址线控制打开,此时才可以访问4G内存

开启方式 :

  1. 等待8042 Input buffer为空,8042键盘控制器闲置;
  2. 发送Write 8042 Output Port (P2)命令到8042 Input buffer;
  3. 等待8042 Input buffer为空;
  4. 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer;

代码:

 # Enable A20:
    #  For backwards compatibility with the earliest PCs, physical
    #  address line 20 is tied low, so that addresses higher than
    #  1MB wrap around to zero by default. This code undoes this.
seta20.1:
    inb $0x64, %al      # 读取状态寄存器,等待8042键盘控制器闲置
    testb $0x2, %al		# 读取到2则表明缓冲区中没有数据
    jnz seta20.1		# 如果缓冲区有数据就继续循环

# 接下来往0x64写入0xd1,表示请求修改8042的端口P2
    movb $0xd1, %al             # 0xd1表示写输出端口命令,参数随后通过0x64端口写入
    outb %al, $0x64             # 0xd1 means: write data to 8042's P2 port

seta20.2:
    inb $0x64, %al               # 等待8042键盘控制器闲置
    testb $0x2, %al
    jnz seta20.2
    
# 往0x60端口写入0xdf,表示将端口P2的位1(A20选通使能)置为1,开启A20
    movb $0xdf, %al                 # 0xdf -> port 0x60
    outb %al, $0x60                   # 通过0x60写入数据 0xdf = 11011111, 意味着将A20置1

至此,A20开启,CPU进入保护模式之后可以充分使用32位4G内存的寻址能力

3. GDT表

什么是GDT?

GDT(Global Descriptor Table, 全局描述表),在实模式和保护模式下,对内存的访问仍采用短地址加偏移地址的方式。其内存的管理方式有两种,段模式页模式。在保护模式下,对于一个段的描述包括:Base Address(基址),Limit(段的最大长度),Access(权限),这三个数据加在一起被放在一个 64 bit 的数据结构中,被称为段描述符。而由于寄存器为 16 bit,很明显,我们无法直接通过 16 bit 长度的寄存器来直接使用 64 bit 的段描述符。而对此的解决方案便是将这些段描述符放入一个全局数组中,将段寄存器中的值作为下标索引(段寄存器中的高 13 bit 的内容作为索引)来间接引用。而这个全局数组便是 GDT

如何初始化GDT表?

# Bootstrap GDT
.p2align 2    # force 4 byte alignment向后移动位置计数器置为4字节的倍数 为了内存对齐
gdt:
    SEG_NULLASM                             # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)   #可读可执行
    SEG_ASM(STA_W, 0x0, 0xffffffff)      #可写但不可执行

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt
#.long后面的参数为gdt运行时生成的值,即gdt表的地址

① GDT中的第一项描述符设置为空。

② GDT中的第二项描述符为代码段使用,设置属性为可读写可执行。

③ GDT中的第三项描述符为数据段使用,设置属性为可读写。

GDT的结构:全局描述符号的第一段为空段,这是intel的规定。后两个段是数据段和代码段。

怎么加载GDT表?

一个简单的GDT表和其描述符已经静态储存在引导区中,载入即可

    lgdt gdtdesc  

4.如何使能和进入保护模式?

x86 引入了几个新的控制寄存器 (Control Registers) cr0 cr1… cr7 ,每个长 32 位。这其中的某些寄存器的某些位被用来控制 CPU 的工作模式,其中 cr0 的最低位,就是用来控制 CPU 是否处于保护模式的。因为控制寄存器不能直接拿来运算,所以需要通过通用寄存器来进行一次存取,设置 cr0 最低位为1 之后就已经进入保护模式。但是由于现代 CPU 的一些特性 (乱序执行和分支预测等),在转到保护模式之后 CPU 可能仍然在跑着实模式下的代码,这显然会造成一些问题。因此必须强制 CPU 清空一次缓冲,最有效的方法就是进行一次**long jump **。

 # GDT从实模式切换到保护模式, 使用GDT(全局描述表,Global Descriptor Table)和段变换,
    # 使得虚拟地址和物理地址相同,这样,切换过程中不会改变有效内存映射。
    # 将CR0的保护允许位PE(Protedted Enable)置1,开启保护模式
    lgdt gdtdesc   #加载GDT表
    
     # 将cr0寄存器PE置1,开启保护模式
    movl %cr0, %eax     #加载cr0到eax
    orl $CR0_PE_ON, %eax  #将eax的第0位置为1
    movl %eax, %cr0      #将cr0的第0位置为1

    #跳转到处于32位代码块中的下一条指令,重装CS和EIP
    # Switches processor into 32-bit mode.
    ljmp $PROT_MODE_CSEG, $protcseg# # 通过长跳转更新cs的基地址
    
# 设置段寄存器,建立堆栈 
.code32                                             #  32-bit模式汇编代码
protcseg:
#重装DS、ES等段寄存器
    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

#设置栈指针并调用C代码,进入保护模式完成,建立堆栈,转到bootmain
#栈区是0~start(0x7c00)
    movl $0x0, %ebp
    movl $start, %esp
    call bootmain

    # If bootmain returns (it shouldn't), loop.
spin:
    jmp spin

如何进入保护模式?

将%cr0寄存器置1。(通过将cr0寄存器PE位置1便开启了保护模式)

bootloader实模式进入保护模式的过程:

  1. 在开启A20之后,加载了GDT全局描述符表,它是被静态储存在引导区中的,载入即可。接着,将cr0寄存器的bit 0置为1,标志着从实模式转换到保护模式
  2. 由于我们无法直接或间接 mov 一个数据到 cs 寄存器中,而刚刚开启保护模式时,cs 的影子寄存器还是实模式下的值,所以需要告诉 CPU 加载新的段信息。长跳转可以设置cs寄存器,CPU 发现了 cr0 寄存器第 0 位的值是 1,就会按 GDTR 的指示找到全局描述符表GDT,然后根据索引值 把新的段描述符信息加载到 cs 影子寄存器,当然前提是进行了一系列合法的检查。所以使用一个长跳转ljmp $PROT_MODE_CSEG, $protcseg以更新cs基地址,至此CPU真正进入了保护模式,拥有了 32 位的处理能力。
  3. 进入保护模式后,设置ds,es,fs,gs,ss段寄存器,建立堆栈**(0~0x7c00)**,最后进入bootmain函数。

练习4

题目:分析bootloader加载ELF格式的OS的过程。

打开文件bootmain.c分析代码:

阅读其最开始的注释:

/* 
 * 这是一个非常简单的引导加载程序(bootloader),其唯一的工作是从第一个IDE硬盘引导ELF内核映像。
 *
 * 磁盘格式:
 *  * 这个程序(bootasm.S和bootmain.c)是bootloader,
 *    它应该被存储在磁盘的第一个扇区内;第二个扇区之后存储的是映像,必须是ELF格式的。
 *
 * BOOT步骤:
 *  * 当 CPU 启动时,它会将 BIOS 加载到内存中并执行它。
 *
 *  * BIOS初始化设备、设置中断程序,并将bootloader的第一个扇区(如硬盘)读入内存并跳转到这一部分。
 *
 *  * 假设bootloader存储在硬盘的第一个扇区中,那么它就开始工作了
 *
 *  * bootasm.S中的代码先开始执行,它开启保护态,并设置C代码能够运行的栈;
 *  * 最后调用本文件中的bootmain()函数;
 *  * bootmain()函数将kernel读入内存并跳转到它。
 */

1. bootloader读取硬盘扇区

分析main函数可得:首先是由readseg函数读取硬盘扇区,而readseg函数则循环调用了真正读取硬盘扇区的函数readsect来每次读出一个扇区。

void bootmain(void) {
    // 从磁盘读出kernel映像的第一页,得到ELF头
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
...
}

(1)具体分析函数readsect:

/* readsect - read 一个扇区 at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {
    waitdisk();//等待硬盘就绪
 // 写地址0x1f2~0x1f5,0x1f7,发出读取磁盘的命令
    outb(0x1F2, 1);                         //设置磁盘参数,往0X1F2地址中写入要读取的扇区数,这里为1

    //设置LBA模式的参数
    outb(0x1F3, secno & 0xFF);          //LBA参数的第0-7位
    outb(0x1F4, (secno >> 8) & 0xFF);//LBA参数的第8-15位
    outb(0x1F5, (secno >> 16) & 0xFF);//LBA参数的第16-23位
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);// 输入LBA参数的24-27位(对应到0-3位),第四位为0表示从主盘读取,其余位被强制置为1
    outb(0x1F7, 0x20);                      // 发出读取扇区的指令
    outb(0x1F7, 0x20);                      //  //命令0x20: 读扇区
    waitdisk();
    insl(0x1F0, dst, SECTSIZE / 4);//从0x1F0端口处读数据,读取一个扇区,读取到dst位置,除以4是因为此处是以4个字节为单位的
}

读一个硬盘扇区流程:

① 等待磁盘准备好

② 发出读取扇区的命令

③ 等待磁盘准备好

④ 把磁盘扇区数据读到指定内存

磁盘各IO地址代表意义:

其中第六位:为1=LBA模式;为0=CHS模式;第七位和第五位必须为1

IO地址 功能
0x1f0 读数据,当0x1f7不为忙状态时,可以读。
0x1f2 扇区数寄存器,记录操作的扇区数,每次读写前,表明你要读写几个扇区。最小是1个扇区
0x1f3 如果是LBA模式,就是LBA参数的0-7位。扇区号寄存器,记录操作的起始扇区号
0x1f4 如果是LBA模式,就是LBA参数的8-15位。柱面号寄存器,记录柱面号的低 8 位
0x1f5 如果是LBA模式,就是LBA参数的16-23位。柱面号寄存器,记录柱面号的高 8 位
0x1f6 驱动器/磁头寄存器,记录操作的磁头号、驱动器号和寻道方式,前 4 位代表逻辑扇区号的高 4 位,DRV = 0/1 代表主/从驱动器,LBA = 0/1 代表 CHS/LBA 方式。
0x1f7 状态寄存器,第 6、7 位分别代表驱动器准备好/驱动器忙
0x1f8 命令寄存器,0x20 命令代表读取扇区

(2)分析函数resdseg

readseg封装了readsect,通过迭代使其可以读取任意长度内容,代码如下:

/* *
 * readseg 
 * 从内核的offset处读count个字节到虚拟地址va中。
 * 复制的内容可能比count个字节多。
 * */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    // 向下舍入到扇区边
    va -= offset % SECTSIZE;

   // 从字节转换到扇区;ELF文件从1扇区开始,因为0扇区被引导占用
    uint32_t secno = (offset / SECTSIZE) + 1;

  
    // 如果这个函数太慢,我们可以同时读多个扇区。
    // 我们在写到内存时会比请求的更多,但这没有关系
    // 我们是以内存递增次序加载的
    for (; va < end_va; va += SECTSIZE, secno ++) {
        readsect((void *)va, secno);
    }
}

2. bootloader是如何加载ELF格式的OS?

打开libs/elf.h文件查看elfhdr、proghdr的相关信息:

ELF header在文件开始处描述了整个文件的组织。ELF的文件头包含整个执行文件的控制结构:

#ifndef __LIBS_ELF_H__
#define __LIBS_ELF_H__
#include 
#define ELF_MAGIC    0x464C457FU            // 小端格式下"\x7FELF"
/* 文件头 */
struct elfhdr {
    uint32_t e_magic;     // 必须等于ELF_MAGIC魔数
    uint8_t e_elf[12];    // 12 字节,每字节对应意义如下:
    // 0 : 1 = 32 位程序;2 = 64 位程序
    // 1 : 数据编码方式,0 = 无效;1 = 小端模式;2 = 大端模式
    // 2 : 只是版本,固定为 0x1
    // 3 : 目标操作系统架构
    // 4 : 目标操作系统版本
    // 5 ~ 11 : 固定为 0

    uint16_t e_type;      // 1=可重定位, 2=可执行, 3=共享对象, 4=核心镜像
    uint16_t e_machine;   // 3=x86, 4=68K, etc.
    uint32_t e_version;   // 文件版本,总为1
    uint32_t e_entry;     // 程序入口地址(如果可执行)
    uint32_t e_phoff;     // 程序段表头相对elfhdr偏移位置
    uint32_t e_shoff;     // 节头表相对elfhdr偏移量
    uint32_t e_flags;     // 处理器特定标志,通常为0
    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;  // 节头部字符索引
};
/* 程序段表头 */
struct proghdr {
    uint32_t p_type;   // 段类型
    // 1 PT_LOAD : 可载入的段
    // 2 PT_DYNAMIC : 动态链接信息
    // 3 PT_INTERP : 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小
    // 4 PT_NOTE : 指定辅助信息的位置和大小
    // 5 PT_SHLIB : 保留类型,但具有未指定的语义
    // 6 PT_PHDR : 指定程序头表在文件及程序内存映像中的位置和大小
    // 7 PT_TLS : 指定线程局部存储模板

    uint32_t p_offset; // 段相对文件头的偏移值
    uint32_t p_va;     // 段的第一个字节将被放到内存中的虚拟地址
    uint32_t p_pa;     //段的第一个字节在内存中的物理地址
    uint32_t p_filesz; //段在文件中的长度
    uint32_t p_memsz;  // 段在内存映像中占用的字节数
    uint32_t p_flags;  //可读可写可执行标志位。
    uint32_t p_align;   //段在文件及内存的对齐方式
};

#endif /* !__LIBS_ELF_H__ */

bootmain.c文件中,主函数:

加载的过程为:

/* bootmain - the entry of bootloader */
void
bootmain(void) {
    // 从磁盘读出kernel映像的第一页,得到ELF头
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
    // 比对ELF的magic number来判断读入的ELF文件是否正确,即判断是不是一个合法的ELF的文件
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }
    struct proghdr *ph, *eph;
     // load each program segment (ignores ph flags)
    // 先将描述表的头地址存在ph
    // ELF头部有描述ELF文件应加载到内存什么位置的描述表
    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头部储存的入口信息,找到内核的入口,跳转至ELF文件的程序入口点(entry point)。 
   // note: does not return
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bad: // ELF文件不合法
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);
    /* do nothing */
    while (1);
}

加载ELF格式的OS的大致流程为:

(1)读取磁盘上的1页(8个扇区),得到ELF头部

(2)校验e_magic字段,判断是否为合法ELF文件

(3)从ELF头中获得程序头的位置,从中获得每段的信息

(4)分别读取每段的信息,根据偏移量分别把程序段的数据读取到内存中。

(5)根据ELF头部储存的入口信息找到内核的入口并跳转

练习5

题目: 实现函数调用堆栈跟踪函数

即:完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。

1. 函数堆栈的原理

一个函数调用动作可分解为零到多个 PUSH指令(用于参数入栈)和一个 CALL 指令。CALL 指令内部其实还暗含了一个将返回地址压栈的动作,这是由硬件完成的。

函数被调用的最开始一般会出现类似如下的汇编指令:

pushl %ebp
movl %esp,%ebp

这两条汇编指令:

  1. 完成的操作是:首先将ebp的值入栈,然后将栈顶指针 esp 赋值给 ebp。

  2. 含义是:将旧ebp值压栈(保存原来栈底的地址),然后让ebp恰恰指向旧栈顶(即ebp寄存器中存储着旧ebp入栈后的栈顶)。

  3. 意义:目前ebp储存的地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的旧栈底的值。

如图:

操作系统实验报告清华大学LAB1_第10张图片

函数调用大概包括以下几个步骤

1、参数入栈:将参数从右向左依次压入系统栈中。

2、返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。

3、代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。

4、栈帧调整

​ 4.1 保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈)。

​ 4.2 将当前栈帧切换到新栈帧(将ESP值装入EBP,更新栈帧底部)。

​ 4.3 给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶)。

函数返回大概包括以下几个步骤:

1、保存返回值,通常将函数的返回值保存在寄存器EAX中。

2、弹出当前帧,恢复上一个栈帧。

​ 2.1 在堆栈平衡的基础上,给ESP加上栈帧的大小,降低栈顶,回收当前栈帧的空间

​ 2.2 将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复出上一个栈帧。

​ 2.3 将函数返回地址弹给EIP寄存器。

3、跳转:按照函数返回地址跳回母函数中继续执行。

因此我们可以直接根据ebp就能读取到各个栈帧的地址和值,一般而言,[ebp+4]处为返回地址,[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用 4 字节内存,对应32位系统),[ebp-4]处为第一个局部变量,[ebp]处为上一层 ebp 值。

2. print_stackframe函数的实现

打开文件kern/debug/kdebug.c

(1)查看注释

 /* LAB1 你的代码:第 1 步 

(1) 调用 read_ebp() 获取 ebp 的值。 类型是(uint32_t);
(2)调用read_eip()获取eip的值。 类型是(uint32_t);
(3) 从 0 .. STACKFRAME_DEPTH
	(3.1) ebp, eip 的 printf 值
	(3.2) (uint32_t) 调用参数 [0..4] = 地址 (unit32_t)ebp +2 [0..4] 中的内容
	(3.3) cprintf("\n");
	(3.4)调用print_debuginfo(eip-1)打印C调用函数名和行号等
	(3.5) 弹出调用栈帧
	注意:调用函数的返回地址 eip = ss:[ebp+4]
		调用函数的 ebp = ss:[ebp]
*/

(2)根据注释和相关知识编写成程序,如下所示:

void print_stackframe(void) {
	//读取当前栈帧的ebp和eip. 
	uint32_t ebp=read_ebp();
	uint32_t eip=read_eip();
	int i;// from 0 .. STACKFRAME_DEPTH
	for (i=0;i

(3)运行结果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DhryOzSC-1650864248900)(C:\Users\PC\AppData\Roaming\Typora\typora-user-images\image-20220423163336069.png)]

最后一行的解释:

对应的是第一个使用堆栈的函数,即bootmain.c中的bootmain。ebp与eip的值对应着bootmain函数的栈帧与调用kern_init后的指令地址。

bootloader设置的堆栈从0x7c00开始,使用call bootmain进入bootmain函数。 call指令压栈,所以bootmainebp0x7bf8。后面的unknow之后的0x00007d67bootmain函数内调用 OS kernel 入口函数指令的地址eip则为0x00007d67的下一条地址,即0x00007d68。后面的``args则表示传递给bootmain函数的参数,但是由于bootmain`函数不需要任何参数,因此这些打印出来的数值并没有实际意义。

练习6

题目 6.1:

中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

从kern/mm/mmu.h文件中获得表项的结构如下:

/* Gate descriptors for interrupts and traps */
struct gatedesc {
    unsigned gd_off_15_0 : 16;        // low 16 bits of offset in segment
    unsigned gd_ss : 16;            // segment selector
    unsigned gd_args : 5;            // # args, 0 for interrupt/trap gates
    unsigned gd_rsv1 : 3;            // reserved(should be zero I guess)
    unsigned gd_type : 4;            // type(STS_{TG,IG32,TG32})
    unsigned gd_s : 1;                // must be 0 (system)
    unsigned gd_dpl : 2;            // descriptor(meaning new) privilege level
    unsigned gd_p : 1;                // Present
    unsigned gd_off_31_16 : 16;        // high bits of offset in segment
};

中断描述符表一个表项为16+16+5+3+4+1+2+1+16=64bit 即占8字节

① Bit 0—15: gd_odd_15_0,偏移量的低16位

② Bit 16—31: gd_ss,中断例程的段选择器,用于索引全局描述符表GDT来获取中断处理代码对应的段地址。

③ bit 47–32: 属性信息,包括DPL、P flag等

④ bit 48—63: gd_odd_31_16 ,为偏移量的16高位.

两个偏移值一起构成段内偏移量,根据段选择子段内偏移地址就可以得出中断处理程序的地址

题目 6.2:

请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。

(1)从mmu.h文件中获得SETGATE宏的定义

define SETGATE(gate, istrap, sel, off, dpl) {            
    (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;        
    (gate).gd_ss = (sel);                                
    (gate).gd_args = 0;                                    
    (gate).gd_rsv1 = 0;                                    
    (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    
    (gate).gd_s = 0;                                    
    (gate).gd_dpl = (dpl);                                
    (gate).gd_p = 1;                                    
    (gate).gd_off_31_16 = (uint32_t)(off) >> 16;        
}

解释部分参数:

① gate:为相应的idt[]数组内容,处理函数的入口地址

② istrap:系统段设置为1,中断门设置为0 ,判断是中断还是trap

③ sel:段选择器

④ off:为__vectors[]数组内容 ,表示偏移

⑤ dpl:设置特权级,表示中断的优先级,这里中断都设置为内核级,即第0级

(2)trap.c文件中查看函数注释

 /* LAB1 YOUR CODE : STEP 2 */
     /* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
      *     All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
      *     __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
      *     (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
      *     You can use  "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
      * (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
      *     Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
      * (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
      *     You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
      *     Notice: the argument of lidt is idt_pd. try to find it!
      */

重点就是两步

① 声明__vertors[],其中存放着中断服务程序的入口地址。这个数组生成于vertor.S中。

② 填充中断描述符表IDT。

③ 加载中断描述符表。

对应到代码中如下所示:

void idt_init(void) {
//声明中断入口, __vectors[] 来对应中断描述符表中的256个中断符 定义于vector.S中
  extern uintptr_t __vectors[];// 代码段偏移量
  int i;
// 通过for循环运用SETGATE宏定义函数(类似c++ inline内连函数)  进行 中断门idt[i] 的初始化
  for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++)
      // 目标idt项为idt[i]
      // 该idt项为内核代码,所以使用GD_KTEXT段选择子
      // 中断处理程序的入口地址存放于__vectors[i]
      //DPL_KERNEL 内核权限  DPL_USER 用户权限  在kernel/mm/memlayout.h中
      SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
  // 设置从用户态转为内核态的中断的特权级为DPL_USER
  SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
 //最后加载idt中断描述符表  libs/x86.h
// 将 &idt_pd 首地址 加载到 中断描述符表寄存器 (IDTR)
  lidt(&idt_pd);
}

总结:

题目要求我们为每个中断设置权限,只有T_SYSCALL用户态权限(DPL_USER),其他都为内核态权限(DPL_KERNEL)。首先通过__vectors[]获得所有中断的入口,再通过循环为每个中断设置权限(默认为内核态权限),为T_SYSCALL设置用户态权限,最后通过lidt将IDT的起始地址装入IDTR寄存器即可。

题目 6.3:

请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。

由于所有中断最后都是统一在trap_dispatch中进行处理或者分配的,因此不妨考虑在该函数中对应处理时钟中断的部分,对全局变量ticks加1,并且当计数到达100时,调用print_ticks函数,从而完成每隔一段时间打印100 ticks的功能。

/* trap_dispatch - dispatch based on what type of trap occurred */
static void
trap_dispatch(struct trapframe *tf) {
    char c;
    switch (tf->tf_trapno) {
    case IRQ_OFFSET + IRQ_TIMER:
        // 全局变量ticks定义于kern/driver/clock.c
		ticks++;//每一次时钟信号会使变量ticks加1
		if(ticks % TICK_NUM == 0)//TICK_NUM已经被预定义成了100,每个周期输出一次
			print_ticks();
        break;
       //....
}

代码保存后在/lab1下运行make qemu得到如下结果:

操作系统实验报告清华大学LAB1_第11张图片

这是借鉴了许多版本以及自己吸收知识,修改整合后的结果。
验收的时候完美的过了,被助教夸写的很详细hhh,在这里给大家分享,仅供给大家借鉴,
如果有不足的地方,欢迎指正,如果觉得有用,请多多点赞!阿里嘎多~

你可能感兴趣的:(科班学习,操作系统,实验,清华大学,实验一)