为了实现lab1的目标,lab1提供了6个基本练习和1个扩展练习,要求完成实验报告。
对实验报告的要求:
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 // 开启远程调试
在实验文件夹下使用make
命令即可,make会按照当前目录下的Makefile
脚本构建项目。
例如在Lab1中:
cd .../lab1
make
此时在lab1目录下的bin
目录中会生成一系列的目标文件:
为了与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并开启源代码视图
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
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 中断服务例程的入口地址和第一步初步处理实现
boot/bootasm.S
:定义并实现了bootloader最先执行的函数start,此函数进行了一定的初始化,完成了从实模式到保护模式的转换,并调用bootmain.c中的bootmain函数。boot/bootmain.c
:定义并实现了bootmain函数实现了通过屏幕、串口和并口显示字符串。bootmain函数加载ucore操作系统到内存,然后跳转到ucore的入口处执行。boot/asm.h
:是bootasm.S汇编文件所需要的头文件,主要是一些与X86保护模式的段访问方式相关的宏定义。kern/init/init.c
:ucore操作系统的初始化启动代码kern/mm/memlayout.h
:ucore操作系统有关段管理(段描述符编号、段号等)的一些宏定义kern/mm/mmu.h
:ucore操作系统有关X86 MMU等硬件相关的定义,包括EFLAGS寄存器中各位的含义,应用/系统段类型,中断门描述符定义,段描述符定义,任务状态段定义,NULL段声明的宏SEG_NULL, 特定段声明的宏SEG,设置中断门描述符的宏SETGATEkern/mm/pmm.[ch]
:设定了ucore操作系统在段机制中要用到的全局变量:任务状态段ts,全局描述符表 gdt[],加载全局描述符表寄存器的函数lgdt,临时的内核栈stack0;以及对全局描述符表和任务状态段的初始化函数gdt_initkern/driver/intr.[ch]
:实现了通过设置CPU的eflags来屏蔽和使能中断的函数kern/driver/picirq.[ch]
:实现了对中断控制器8259A的初始化和使能操作kern/driver/clock.[ch]
:实现了对时钟控制器8253的初始化操作kern/driver/console.[ch]
:实现了对串口和键盘的中断方式的处理操作kern/trap/vectors.S
:包括256个中断服务例程的入口地址和第一步初步处理实现。注意,此文件是由tools/vector.c在编译ucore期间动态生成的kern/trap/trapentry.S
:紧接着第一步初步处理后,进一步完成第二步初步处理;并且有恢复中断上下文的处理,即中断处理完毕后的返回准备工作kern/trap/trap.[ch]
:紧接着第二步初步处理后,继续完成具体的各种中断处理操作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函数,提高查找错误的效率。libs/defs.h
:包含一些无符号整型的缩写定义。libs/x86.h
:一些用GNU C嵌入式汇编实现的C函数(由于使用了inline关键字,所以可以理解为宏)。Makefile和function.mk
:指导make完成整个软件项目的编译,清除等工作。sign.c
:一个C语言小程序,是辅助工具,用于生成一个符合规范的硬盘主引导扇区。tools/vector.c
:生成vectors.S,此文件包含了中断向量处理的统一实现。操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
操作系统镜像文件ucore.img是如何一步一步生成的?
执行命令make v=
,通过阅读其输出的步骤,我们可以得知:
make执行将所有的源代码编译成对象文件,并分别链接形成kernel、bootblock文件。
使用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
文件
创建一个10000块将/dev/zero拷贝进去(获得10000block的空间)
将$(bootblock)拷贝到ucore.img
将$(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
生成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.o
和bootmain.o
② 生成bootasm.o
和bootmain.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.o
和bootmain.o
的过程:
③ 生成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的过程:
④ 由bootasm.o,bootmain.o
和sign
生成bootblock
:
4.总结
构建ucore.img
时大致进行了以下操作:
kernel
,bootblock
引导程序
bootasm.S bootmain.c
,链接 obj/bootblock.o
sign.c
生成sign.o
工具sign.o
工具规范化bootblock.o
,生成bin/bootblock
引导扇区ucore.img
虚拟磁盘
5120000bytes
且内容为0的文件bin/bootblock
引导文件到ucore.img
的第一个扇区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个块后再开始复制
一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
通常我们将包含MBR(主引导记录)引导代码的扇区称为主引导扇区。通常由3部分组成:
上题中的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字节的组成:
③ 磁盘最后两个字节是0x55 0xAA。
从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
从CPU加电后执行的第一条指令存放于地址0xfffffff0中,执行命令x/2i 0xfffffff0
,打印该地址的值,发现第一条指令是长跳转指令:ljmp $0xf000,$0xe05b
在初始化位置0x7c00 设置实地址断点,测试断点正常.
使用qemu执行并调试lab1中的文件。
修改tools/gdbinit为
调试指令解释:
set architecture i386 //设置当前调试的CPU是80386
b* 0x7c00 //在0x7c00处设置断点。此地址是bootloader入口点地址,可看boot/bootasm.S的start地址处
define hook-stop ... end//可以在每次gdb断下时自动执行内部的指令,断下时输出下一条指令,方便调试。
将执行的汇编代码与bootasm.S 和 bootblock.asm 进行比较,看看二者是否一致。
改写makefile文件:
在该行增加语句-d in_asm -D q.log
重新执行make debug命令,在bin目录下生成q.log
文件,便可以在q.log
中读到"call bootmain"前执行的命令。
进行比较并截取部分截图如下:
自己截图就好啦~
由此得到q.log
文件中在断点后的部分与bootasm.S
和bootblock.asm
相同
自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
设置断点0x7c01
题目:分析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内存
开启方式 :
代码:
# 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
从实模式进入保护模式的过程:
GDT
全局描述符表,它是被静态储存在引导区中的,载入即可。接着,将cr0
寄存器的bit 0
置为1
,标志着从实模式转换到保护模式。cs
寄存器,CPU 发现了 cr0 寄存器第 0 位的值是 1,就会按 GDTR 的指示找到全局描述符表GDT,然后根据索引值 把新的段描述符信息加载到 cs 影子寄存器,当然前提是进行了一系列合法的检查。所以使用一个长跳转ljmp $PROT_MODE_CSEG, $protcseg
以更新cs
基地址,至此CPU真正进入了保护模式,拥有了 32 位的处理能力。bootmain
函数。题目:分析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头部储存的入口信息找到内核的入口并跳转
题目: 实现函数调用堆栈跟踪函数
即:完成kdebug.c
中函数print_stackframe
的实现,可以通过函数print_stackframe
来跟踪函数调用堆栈中记录的返回地址。
1. 函数堆栈的原理
一个函数调用动作可分解为零到多个 PUSH指令(用于参数入栈)和一个 CALL 指令。CALL 指令内部其实还暗含了一个将返回地址压栈的动作,这是由硬件完成的。
函数被调用的最开始一般会出现类似如下的汇编指令:
pushl %ebp
movl %esp,%ebp
这两条汇编指令:
完成的操作是:首先将ebp的值入栈,然后将栈顶指针 esp 赋值给 ebp。
含义是:将旧ebp值压栈(保存原来栈底的地址),然后让ebp恰恰指向旧栈顶(即ebp寄存器中存储着旧ebp入栈后的栈顶)。
意义:目前ebp储存的地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的旧栈底的值。
如图:
函数调用大概包括以下几个步骤:
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指令压栈,所以bootmain
中ebp
为0x7bf8
。后面的unknow
之后的0x00007d67
是bootmain
函数内调用 OS kernel 入口函数的指令的地址。eip
则为0x00007d67
的下一条地址,即0x00007d68
。后面的``args则表示传递给
bootmain函数的参数,但是由于
bootmain`函数不需要任何参数,因此这些打印出来的数值并没有实际意义。
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
从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高位.
两个偏移值一起构成段内偏移量,根据段选择子和段内偏移地址就可以得出中断处理程序的地址。
请编程完善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寄存器即可。
请编程完善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
得到如下结果:
这是借鉴了许多版本以及自己吸收知识,修改整合后的结果。
验收的时候完美的过了,被助教夸写的很详细hhh,在这里给大家分享,仅供给大家借鉴,
如果有不足的地方,欢迎指正,如果觉得有用,请多多点赞!阿里嘎多~