练习1:理解通过make生成执行文件的过程。
列出本实验各练习中对应的OS原理的知识点,并说明本实验中的实现部分如何对应和体现了原理中的基本概念和关键知识点
在此练习中,大家需要通过静态分析代码来了解:
-
操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
进入lab1目录下运行
make
,会在bin
文件夹下生成ucore.img
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66moocos-> pwd
/home/moocos/Desktop/ucore_lab/labcodes/lab1
[~/Desktop/ucore_lab/labcodes/lab1]
moocos-> ls
boot kern libs Makefile q.log tools
[~/Desktop/ucore_lab/labcodes/lab1]
moocos-> make
+ cc kern/init/init.c
kern/init/init.c:95:1: warning: ‘lab1_switch_test’ defined but not used [-Wunused -function]
lab1_switch_test(void) {
^
+ cc kern/libs/readline.c
+ cc kern/libs/stdio.c
+ cc kern/debug/kdebug.c
kern/debug/kdebug.c:251:1: warning: ‘read_eip’ defined but not used [-Wunused-fun ction]
read_eip(void) {
^
+ cc kern/debug/kmonitor.c
+ cc kern/debug/panic.c
+ cc kern/driver/clock.c
+ cc kern/driver/console.c
+ cc kern/driver/intr.c
+ cc kern/driver/picirq.c
+ cc kern/trap/trap.c
kern/trap/trap.c:14:13: warning: ‘print_ticks’ defined but not used [-Wunused-fun ction]
static void print_ticks() {
^
kern/trap/trap.c:30:26: warning: ‘idt_pd’ defined but not used [-Wunused-variable ]
static struct pseudodesc idt_pd = {
^
+ cc kern/trap/trapentry.S
+ cc kern/trap/vectors.S
+ cc kern/mm/pmm.c
+ cc libs/printfmt.c
+ cc libs/string.c
+ ld bin/kernel
+ cc boot/bootasm.S
+ cc boot/bootmain.c
+ cc tools/sign.c
+ ld bin/bootblock
'obj/bootblock.out' size: 472 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0179732 s, 285 MB/s
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0.000302065 s, 1.7 MB/s
138+1 records in
138+1 records out
70775 bytes (71 kB) copied, 0.000541628 s, 131 MB/s
[~/Desktop/ucore_lab/labcodes/lab1]
moocos-> ls
bin boot kern libs Makefile obj q.log tools
[~/Desktop/ucore_lab/labcodes/lab1]
moocos-> ls bin/
bootblock kernel sign ucore.img
moocos-> ls obj/
boot bootblock.o kern kernel.sym sign
bootblock.asm bootblock.out kernel.asm libs
[~/Desktop/ucore_lab/labcodes/lab1]
moocos-> cd ..
[~/Desktop/ucore_lab/labcodes]
moocos-> diff -r lab1 ~/moocos/ucore_lab/labcodes/lab1 #比较文件
Only in lab1: bin
Only in lab1: obj从上面
diff
命令可以看到,唯一不同于原文件的是生成了bin文件夹和obj文件夹
查看Makefile内容
1 | PROJ := challenge |
删除bin
和obj
文件夹 ,或者用make clean
清除编译文件,运行下面命令得到make
的详细信息
1 | $ make "V=" |
1 | [~/Desktop/ucore_lab/labcodes/lab1] |
可以看到,ucore.img 通过make的最后三个dd命令生成:
对应Makefile文件命令为
第一个dd表示在bin
文件夹下创建ucore.img
,其大小为10000个块。
第二个dd表示将/bin/bootblock
的内容复制到第一个块,其中noerror
选项意味着如果发生错误,程序也将继续运行。
第三个dd表示从第二个块开始复制/bin/kernel
中的内容,seek
表示跳过第一个块开始写。
因此首先要先生成bootlock
和kernel
文件
kernel生成:
对应Makefile文件命令为
gcc命令参数解释
-I 添加搜索头文件的路径
-fno-builtin 不进行builtin函数的优化
-Wall 编译后显示所有警告
-ggdb 生成可供gdb使用的调试信息
-m32 生成适用于32位环境的代码
-gstabs 生成stabs格式的调试信息
-nostdinc 不使用标准库
-fno-stack-protector 不生成用于检测缓冲区溢出的代码
-Os 为减小代码大小而进行优化
-c只激活预处理,编译,和汇编,也就是他只把程序做成obj文件
由图可知,要生成文件kernel
,需要将obj/kern
文件夹下的所有.c
文件编译生成.o
文件,有如下文件:
1 | obj/kern/init/init.o |
然后用ld
命令将这些.o
文件生成一个可执行文件
1 | + ld bin/bootblock |
ld命令参数解释
-m elf_i386表示模拟指定的连接器为elf_i386
-T tools/kernel.ld表示使用tools/kernel.ld作为链接器脚本。此脚本将替换ld的默认链接器脚本(而不是添加到其中)。
-o bin/kernel表示将输出文件在bin文件夹下,文件名为kernel
后面跟着的.o
文件是所要转化的文件
bootlock生成
对应的Makefile命令为
可以看到,要生成bootlock,需要先生成bootams.o, bootmian.c, sign.c
gcc命令参数解释
-I 添加搜索头文件的路径
-fno-builtin 不进行builtin函数的优化
-Wall 编译后显示所有警告
-ggdb 生成可供gdb使用的调试信息
-m32 生成适用于32位环境的代码
-gstabs 生成stabs格式的调试信息
-nostdinc 不使用标准库
-fno-stack-protector 不生成用于检测缓冲区溢出的代码
-Os 为减小代码大小而进行优化
-c只激活预处理,编译,和汇编,也就是他只把程序做成obj文件
生成bootfile对应的源码由宏定义批量实现
生成sign对应的Makefile源码为
最后用ld命令生成
1 | + ld bin/bootblock |
ld命令参数解释
-m elf_i386表示模拟指定的连接器为elf_i386
-nostdlib 表示不连接系统标准启动文件和标准库文件,只把指定的文件传递给连接器。这个选项常用于编译内核、bootloader等程序,它们不需要启动文件、标准库文件。
-N 设置代码段和数据段均可读写
-e 指定入口
start 代码段开始于
-Ttext 制定代码段开始位置
-o obj/bootblock.o表示将输出文件在obj文件夹下,文件名为bootblock
-
系统认为是符合规范的硬盘主引导扇区的特征是什么?
查看sign.c文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46moocos-> ls tools/
function.mk gdbinit grade.sh kernel.ld sign.c vector.c
[~/Desktop/ucore_lab/labcodes/lab1]
moocos-> cat tools/sign.c
int
main(int argc, char *argv[]) {
struct stat st;
if (argc != 3) {
fprintf(stderr, "Usage: <input filename> <output filename>\n");
return -1;
}
if (stat(argv[1], &st) != 0) {
fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
return -1;
}
printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
if (st.st_size > 510) {
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return -1;
}
char buf[512];
memset(buf, 0, sizeof(buf));
FILE *ifp = fopen(argv[1], "rb");
int size = fread(buf, 1, st.st_size, ifp);
if (size != st.st_size) {
fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
return -1;
}
fclose(ifp);
buf[510] = 0x55;
buf[511] = 0xAA;
FILE *ofp = fopen(argv[2], "wb+");
size = fwrite(buf, 1, 512, ofp);
if (size != 512) {
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
fclose(ofp);
printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
return 0;
}得知一个被系统认为是符合规范的硬盘主引导扇区的特征有以下几点:
- 磁盘主引导扇区只有512字节
- 磁盘最后两个字节为0x55AA
练习2:使用qemu执行并调试lab1中的软件。
为了熟悉使用qemu和gdb进行的调试工作,我们进行如下的小练习:
-
从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
-
在初始化位置0x7c00设置实地址断点,测试断点正常。
-
从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
-
自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
练习2.1
首先修改文件
1 | moocos-> vim tools/gdbinit |
增加set architecture i8086
1 | file bin/kernel |
在GUI界面lab1目录下,执行如下语句
1 | make debug |
ssh环境下执行会报错
之后使用si
命令可使BIOS单步执行
在gdb中执行x /2i $pc 来看BIOS的代码
1 | x /2i $pc #显示当前eip处的汇编指令 |
练习2.2
在 tools/gdbinit中加入中断
1 | target remote :1234 //连接qemu,此时qemu会进入停止状态,听从gdb的命令 |
重新make debug
得到
1 | => 0x7c00: cli |
练习2.3
查看bootasm.S
1 | moocos-> cat bootasm.S -n |
bootblock.asm的代码
1 | moocos-> cat ./obj/bootblock.asm -n |
在调用qemu 时增加-d in_asm -D q.log 参数,便可以将运行的汇编指令保存在q.log 中。
改写Makefile文件第220行
1 | debug: $(UCOREIMG) |
重新make debug
gdb中运行命令
1 | b *0x7c4a |
得到0x7c00到bootmain函数入口前(0x7c4a)的debug的汇编指令
1 | ---------------- |
可见三者对应且一致。
练习2.4
设置0x7cd7为下一个断点
得到bootmain对应的汇编码
1 | ---------------- |
对应bootasm.S源码
1 | 71 call bootmain |
查看bootblock.asm的代码
1 | 233 /* bootmain - the entry of bootloader */ |
三者对应
练习3:分析bootloader进入保护模式的过程。
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。
提示:需要阅读**小节“保护模式和分段机制”**和lab1/boot/bootasm.S源码,了解如何从实模式切换到保护模式,需要了解:
-
为何开启A20,以及如何开启A20
-
如何初始化GDT表
-
如何使能和进入保护模式
bootasm.S 源码
1 | moocos-> cat boot/bootasm.S -n |
asm.h的内容
1 | moocos-> cat boot/asm.h -n |
练习3.1
为何开启A20,以及如何开启A20
当A20地址线控制禁止时,程序就像运行在8086上,1MB以上的地址是不可访问的,只能访问奇数MB的不连续的地址。为了使能所有地址位的寻址能力,必须向键盘控制器8082发送一个命令,键盘控制器8042会将A20线置于高电位,使全部32条地址线可用,实现访问4GB内存。
激活方法:要给8042发命令激活A20,8042有两个IO端口:0x60和0x64, 激活流程位: 发送0xd1命令到0x64端口 --> 发送0xdf到0x60
练习3.2
如何初始化GDT表
**全局描述符表(GDT)**的是提供内存保护。在80286之前的处理器中只有实模式,所有程序都可访问任意内存。GDT是保护模式下限制非法内存访问的一种方式。
直接载入gpt
1 | lgdt gdtdesc #载入gdt |
练习3.3
如何使能和进入保护模式
通过CR0_PE_ON或cr0寄存器,将第0位置1
1 | 50 movl %cr0, %eax |
练习4:分析bootloader加载ELF格式的OS的过程。
通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS,
- bootloader如何读取硬盘扇区的?
- bootloader是如何加载ELF格式的OS?
提示:可阅读“硬盘访问概述”,“ELF执行文件格式概述”这两小节。
bootmain.c源码
1 | moocos-> cat boot/bootmain.c -n |
练习4.1
bootloader如何读取硬盘扇区的?
读取扇区代码
1 | 36 /* waitdisk-等待磁盘就绪 */ |
磁盘IO各个端口作用
IO地址 | 功能 |
---|---|
0x1f0 | 读数据,当0x1f7不为忙状态时,可以读。 |
0x1f2 | 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区 |
0x1f3 | 如果是LBA模式,就是LBA参数的0-7位 |
0x1f4 | 如果是LBA模式,就是LBA参数的8-15位 |
0x1f5 | 如果是LBA模式,就是LBA参数的16-23位 |
0x1f6 | 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘 |
0x1f7 | 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据 |
- 待硬盘空闲。waitdisk的函数实现只有一行:
while ((inb(0x1F7) & 0xC0) != 0x40)
,0xC0=1100 0000 b,0x40=0100 0000b,意思是不断查询读0x1F7寄存器的最高两位,直到最高位为0、次高位为1(这个状态应该意味着磁盘空闲)才返回。 - 硬盘空闲后,发出读取扇区的命令。对应的命令字为0x20,放在0x1F7寄存器中;读取的扇区数为1,放在0x1F2寄存器中;读取的扇区起始编号共28位,分成4部分依次放在0x1F3~0x1F6寄存器中。
- 发出命令后,再次等待硬盘空闲。
- 硬盘再次空闲后,开始从0x1F0寄存器中读数据。注意insl的作用是"That function will read cnt dwords from the input port specified by port into the supplied output array addr.",是以dword即4字节为单位的,因此这里SECTIZE需要除以4
bootblock.asm中的insl函数定义
1 | /*包含3个输入参数,port代表端口号,addr代表这个扇区存放在主存中的起始地址,cnt则代表读取的次数*/ |
练习4.2
bootloader是如何加载ELF格式的OS?
1 | 85 /* bootmain-引导加载程序的条目*/ |
练习5:实现函数调用堆栈跟踪函数
我们需要在lab1中完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。在如果能够正确实现此函数,可在lab1中执行 “make qemu”后,在qemu模拟器中得到类似如下的输出:
1 | …… |
请完成实验,看看输出是否与上述显示大致一致,并解释最后一行各个数值的含义。
提示:可阅读小节“函数堆栈”,了解编译器如何建立函数调用关系的。在完成lab1编译后,查看lab1/obj/bootblock.asm,了解bootloader源码与机器码的语句和地址等的对应关系;查看lab1/obj/kernel.asm,了解 ucore OS源码与机器码的语句和地址等的对应关系。
要求完成函数kern/debug/kdebug.c::print_stackframe的实现,提交改进后源代码包(可以编译执行),并在实验报告中简要说明实现过程,并写出对上述问题的回答。
当运行make qemu得到如下:
1 | moocos-> make qemu |
查看kern/debug/kdebug.c::print_stackframe
按照注释来做
1 | void |
中cprintf表示控制台(console)输出
重新make qemu后
最后一行是
1 | ebp:0x00007bf8 eip:0x00007d68 args: 0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 |
共有ebp,eip和args三类参数
函数调用栈
函数开头
1 | pushl %ebp |
这两条汇编指令的含义是:首先将ebp寄存器入栈,然后将栈顶指针esp赋值给ebp。“mov ebp esp”这条指令表面上看是用esp覆盖ebp原来的值,其实不然。因为给ebp赋值之前,原ebp值已经被压栈(位于栈顶),而新的ebp又恰恰指向栈顶。此时ebp寄存器就已经处于一个非常重要的地位,该寄存器中存储着栈中的一个地址(原ebp入栈后的栈顶),从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的ebp值。
一般而言,EBP 基址指针,是保存调用者函数的地址,总是指向函数栈栈底,ESP被调函数的指针,总是指向函数栈栈顶。ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用4字节内存),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层ebp值。
最后一行输出的ebp为0x00007bf8
,eip为0x00007d68
,这是因为bootloader被加载到了0x00007c00地址处,在执行到 [bootasm.S](#bootasm.S 源码) 最后"call bootmain"指令时,首先将返回地址压栈,再将当前ebp压栈,所以此时esp为0x00007bf8。ss:ebp+4指向caller调用时的eip在bootmain函数入口处,有mov %esp %ebp指令,故bootmain中ebp为0x00007bf8。
1 | 289 7cfc: 75 6a jne 7d68 <bootmain+0x97> |
ss:ebp+4指向caller调用时的eip,可以看到其地址。
一般来说,args存放的4个dword是对应4个输入参数的值。但这里比较特殊,由于bootmain函数调用时并没传递任何输入参数,并且栈顶的位置恰好在bootloader第一条指令存放的地址的上面,而args恰好是ebp寄存器指向的栈顶往上第2~5个单元,因此args存放的就是bootloader指令的前16个字节。可以对比obj/bootblock.asm文件来验证(其字节序为小端字节序)
1 | 14 7c00: fa cli |
练习6:完善中断初始化和处理
请完成编码工作和回答如下问题:
- 中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
- 请编程完善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”。
【注意】除了系统调用中断(T_SYSCALL)使用陷阱门描述符且权限为用户态权限以外,其它中断均使用特权级(DPL)为0的中断门描述符,权限为内核态权限;而ucore的应用程序处于特权级3,需要采用`int 0x80`指令操作(这种方式称为软中断,软件中断,Tra中断,在lab5会碰到)来发出系统调用请求,并要能实现从特权级3到特权级0的转换,所以系统调用中断(T_SYSCALL)所对应的中断门描述符中的特权级(DPL)需要设置为3。
要求完成问题2和问题3 提出的相关函数实现,提交改进后的源代码包(可以编译执行),并在实验报告中简要说明实现过程,并写出对问题1的回答。完成这问题2和3要求的部分代码后,运行整个系统,可以看到大约每1秒会输出一次”100 ticks”,而按下的键也会在屏幕上显示。
练习6.1
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
中断向量表一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成位移,
两者联合便是中断处理程序的入口地址。
练习6.2
kern/trap/trap.c的片段
1 | 34 /* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */ |
kern/mm/mmu.h中的SETGATE宏
1 | 71 |
补全函数如下
1 | extern uintptr_t __vectors[]; |
练习6.3
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
提示片段
1 | /* LAB1 YOUR CODE : STEP 3 */ |
补充如下
1 | ticks++; |
运行make qemu显示如下
扩展练习 Challenge 1
扩展proj4,增加syscall功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务。
提示: 规范一下 challenge 的流程。
kern_init 调用 switch_test,该函数如下:
1 | static void |
switchto函数建议通过 中断处理的方式实现。主要要完成的代码是在 trap 里面处理 T_SWITCH_TO* 中断,并设置好返回的状态。
在 lab1 里面完成代码以后,执行 make grade 应该能够评测结果是否正确。
Solution:
1 | kern/init/init.c |
去掉42行注释
运行make grade
莫名其妙的满分。。。
扩展练习 Challenge 2
用键盘实现用户模式内核模式切换。具体目标是:“键盘输入3时切换到用户模式,键盘输入0时切换到内核模式”。 基本思路是借鉴软中断(syscall功能)的代码,并且把trap.c中软中断处理的设置语句拿过来。
注意:
1.关于调试工具,不建议用lab1_print_cur_status()来显示,要注意到寄存器的值要在中断完成后tranentry.S里面iret结束的时候才写回,所以再trap.c里面不好观察,建议用print_trapframe(tf)
2.关于内联汇编,最开始调试的时候,参数容易出现错误,可能的错误代码如下
1 | asm volatile ( "sub $0x8, %%esp \n" |
要去掉参数int %0 \n这一行
3.软中断是利用了临时栈来处理的,所以有压栈和出栈的汇编语句。硬件中断本身就在内核态了,直接处理就可以了。
Solution:
补充代码
1 | kern/trap/trap.c |
make qemu
键盘输入3时切换到用户模式
键盘输入0时切换到内核模式