LAB1_Part3_The Kernel

1. Part 3: The Kernel

我们现在将开始更详细地研究JOS内核。(最后你会写一些代码!)。与引导加载程序一样,内核从一些汇编语言代码开始,这些代码设置可以使C语言代码正确执行。

1.1. 使用虚拟内存来解决位置依赖问题

操作系统内核通常被链接到非常高的虚拟地址(例如0xf0100000)下运行,以便留下处理器虚拟地址空间的低地址部分供用户程序使用。 在下一个lab中,这种安排的原因将变得更加清晰。

许多机器在地址范围无法达到0xf0100000,因此我们无法指望能够在那里存储内核。相反,我们将使用处理器的内存管理硬件将虚拟地址0xf0100000(内核代码期望运行的链接地址)映射到物理地址0x00100000(引导加载程序将内核加载到物理内存中)。

现在,我们只需映射前4MB的物理内存,这足以让我们启动并运行。 我们使用kern/entrypgdir.c中手写的,静态初始化的页面目录和页表来完成此操作。 现在,你不必了解其工作原理的细节,只需注意其实现的效果。

实现虚拟地址,有一个很重要的寄存器CR0-PG;

PG:CR0的位31是分页(Paging)标志。当设置该位时即开启了分页机制;当复位时则禁止分页机制,此时所有线性地址等同于物理地址。在开启这个标志之前必须已经或者同时开启PE标志。即若要启用分页机制,那么PE和PG标志都要置位。

1.1.1. Exercise 7

1.使用QEMU和GDB跟踪到JOS内核并停在movl%eax,%cr0。 检查内存为0x00100000和0xf0100000。 现在,使用stepi GDB命令单步执行该指令。 再次检查内存为0x00100000和0xf0100000。 确保你了解刚刚发生的事情。
在地址0x100020处,将第31位PG置为1,后写入cr0


0x100015:	mov    $0x118000,%eax
0x10001a:	mov    %eax,%cr3

0x100020:	or     $0x80010001,%eax
0x100025:	mov    %eax,%cr0

在0x00100000内存处没有变化
执行前:
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x8000b812	0x220f0011	0xc0200fd8

(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>:	0x00000000	0x00000000	0x00000000	0x00000000
0xf0100010 :	0x00000000	0x00000000	0x00000000	0x00000000
执行后
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0xf0100010 :	0x34000004	0x8000b812	0x220f0011	0xc0200fd8

可以发现,两者内容完全一致,虚拟地址0xf0100000已经被映射到0x00100000处了,为什么会出现这种变化?
在修改cr0之前修改了cr3寄存器。将地址0x118000写入了页目录寄存器,页目录表应该就是存放在地址0x118000处。其他操作应该是由entry_pgdir的// Map VA’s [KERNBASE, KERNBASE+4MB) to PA’s [0, 4MB),完成了映射。使得再读取0xf0100000地址时,自动映射到了0~4M的某个位置(暂时不清楚)。

CR3是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以4K字节为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。

entry.S中说:

The kernel (this code) is linked at address ~(KERNBASE + 1 Meg),

在程序编译后,被链接到高地址处。在kernel.ld链接脚本文件里指定了。

/* Link the kernel at this address: "." means the current address */
	. = 0xF0100000;

但是bootloader 实际把kernel加载到了0x100000的位置

2.注释掉 kern/entry.S中的movl %eax, %cr0,再编译执行会出现什么问题。
因为没有开启分页虚拟存储机制,当访问高位地址时,会出现RAM or ROM 越界错误。

1.2. 格式化输出到控制台

激动人心的时刻到了,我们终于到了能对设备进行操作的阶段了,Linus当时为自己能在显示屏上打印出信息而感到十分自豪(尽管他拿给她妹妹看,他妹妹不以为然,哈哈)。能打印出信息,是实现交互的开始,也是我们之后调试的一个重要途径。

大多数人都把printf()这样的函数认为是理所当然的,有时甚至认为它们是C语言的“原语“。但在OS内核中,我们必须自己实现所有I / O.

阅读kern/printf.c, lib/printfmt.c, and kern/console.c三个源代码,理清三者之间的关系。

  • printf.c 基于 printfmt()kernel console's cputchar()

1.2.1. Exercise 8

我们省略了一小段代码 - 使用“%o”形式的模式打印八进制数所需的代码。 查找并填写此代码片段。

case 'o':
			// Replace this with your code.
			putch('0', putdat);
			num = getuint(&ap, lflag);
			base = 8;
			goto number;

就是把%u的代码复制一遍,base 改为 8 就差不多了,并不复杂。

1.Explain the interface between printf.c and console.c. Specifically, what function does console.c export? How is this function used by printf.c?

printf.c中使用了console.c 中的cputchar函数,并封装为putch函数。并以函数形参传递到printfmt.c中的vprintfmt函数,用于向屏幕上输出一个字符。

  1. 解释console.c中的一段代码。
// What is the purpose of this?
    if (crt_pos >= CRT_SIZE) {
    // 显示字符数超过CRT一屏可显示的字符数
    	int i;
    //清除buf中"第一行"的字符
    	memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
    	//CRT显示器需要对其用空格擦写才能去掉本来以及显示了的字符。
    	for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
    		crt_buf[i] = 0x0700 | ' ';
    	//显示起点退回到最后一行起始
    	crt_pos -= CRT_COLS;
    }    

首先理解几个宏定义和函数

  • CRT_ROWS,CRT_COLS:CRT显示器行列最大值, 此处是25x80
  • ctr_buf 在初始化时指向了显示器I/O地址
  • memmove 没有理清哪个是源,哪个是目的。 按理解清除第一行的数据,应该第二个是源。即2~n行的数据(CRT_SIZE - CRT_COLS)个,移动到1~n-1行的位置。
  1. 跟踪执行以下代码,在调用cprintf()时,fmt, ap指向什么?
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

在kern/init.c的i386_init()下加入代码,就可以直接测试;加Lab1_exercise8_3标号的目的是为了在kern/kernel.asm反汇编代码中容易找到添加的代码的位置。可以看到地址在0xf0100080

// lab1 Exercise_8
{
	cprintf("Lab1_Exercise_8:\n");
	int x = 1, y = 3, z = 4;
	// 
	Lab1_exercise8_3:
	cprintf("x %d, y %x, z %d\n", x, y, z);

	unsigned int i = 0x00646c72;
	cprintf("H%x Wo%s", 57616, &i);
}

调试过程fmt=0xf010478d , ap=0xf0118fc4; fmt指向字符串,ap指向栈顶

cprintf (fmt=0xf010478d "x %d, y %x, z %d\n") at kern/printf.c:27
可以看到以上地址处就存了字符串
(gdb) x/s 0xf010478d
0xf010478d:	"x %d, y %x, z %d\n"

gdb) si
=> 0xf0102f85 :	push   %ebp
vcprintf (fmt=0xf010478d "x %d, y %x, z %d\n", ap=0xf0118fc4 "\001")
    at kern/printf.c:18
18	{

(gdb) x/16b 0xf0118fc4
0xf0118fc4:	0x01	0x00	0x00	0x00	0x03	0x00	0x00	0x00
0xf0118fcc:	0x04	0x00	0x00	0x00	0x7b	0x47	0x10	0xf0

引用一段Github上大神做的labclpsz/mit-jos-2014的execise8中的一段话。

从这个练习可以看出来,正是因为C函数调用实参的入栈顺序是从右到左的,才使得调用参数个数可变的函数成为可能(且不用显式地指出参数的个数)。但是必须有一个方式来告诉实际调用时传入的参数到底是几个,这个是在格式化字符串中指出的。如果这个格式化字符串指出的参数个数和实际传入的个数不一致,比如说传入的参数比格式化字符串指出的要少,就可能会使用到栈上错误的内存作为传入的参数,编译器必须检查出这样的错误[2]

4.运行以下代码,输出结果是什么。

unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);

我的妈呀,调试出来竟然输出了Hello World! 57616的十六进制形式为E110, 因为是小端机,i的在内存中为0x72,0x6c,0x64,0x00. 对应ASCII为rld\0

  1. In the following code, what is going to be printed after ‘y=’? (note: the answer is not a specific value.) Why does this happen?
    压栈的时候,地址是向低位还是高位增长? 入栈由高位向低位增长(即sp减小),x会先读出3,再出栈,y会读出3内存的”+1“的位置的值并以整型打印。
  1. 假设GCC改变了它的调用约定,以致于它按声明顺序压栈。 你要如何更改cprintf或其接口,以便仍然可以传递可变数量的参数?
    猜想,同时向cprintf传入可变参数个数。 无从验证,暂并不论。

1.3. The Stack

1.3.1. Exercise 9

确定内核在什么时候初始化了堆栈,以及堆栈所在内存的确切位置。 内核如何为其堆栈保留空间? 并且在这个保留区域的“end”是堆栈指针最初指向的位置吗?

  • entry.S 77行初始化栈
  • 栈的位置是0xf0108000-0xf0110000
  • 设置栈的方法是在kernel的数据段预留32KB空间(entry.S 92行)
  • 栈顶的初始化位置是0xf0110000

将值压入堆栈涉及到堆栈指针–,然后将值写入堆栈指针指向的位置。 从堆栈中弹出一个值包括读取堆栈指针指向的值,然后堆栈指针++。

1.3.2. Exercise 10

obj/kern/kernel.asm找到test_backtrace函数,并设置断点。进行调试。此次练习最好使用工具页面提到的QEMU修补版本。 否则,您必须手动将所有断点和内存地址转换为线性地址。

没找到test_backtrace, 只找到了mon_backtrace; 占坑----
下载的代码里竟然没有test_backtrace函数,果断弃坑找了Github上另一位大神的代码。

在另一份代码反汇编的kernel.asm中,找到test_backtrace函数。

	// Test the stack backtrace function (lab 1 only)
	test_backtrace(5);
f0100104:	c7 04 24 05 00 00 00 	movl   $0x5,(%esp)
f010010b:	e8 30 ff ff ff       	call   f0100040 

我们分析下最初两次调用函数test_backtrace的栈里面的内容:

                                                0x00000000      0x00000000
0xf010ffa0:     0x00000000      0x00000005      0xf010ffc8      0xf0100069
                                            --------------------------------
                                            |   ->第一次init 调用test_backtrace
0xf010ffb0:     0x00000004      0x00000005  |    0x00000000      0x00010094
--------------------------------------------|  
0xf010ffc0:     0x00010094      0x00010094      0xf010fff8      0xf0100144
0xf010ffd0:     0x00000005

最后一个是init调用函数传入的参数5,倒数第二个为init函数中test_backtrace的下一行指令地址。 第三行第二个数0x00000005是临时变量x的值,0x00000004是传入test_backtrace的值,0xf0100069为函数的返回地址。

1.3.3. Ecercise 11

实现指定的回溯函数

执行结果,打印的第一行反映当前正在执行的函数,即mon_backtrace本身,第二行反映调用mon_backtrace的函数,第三行反映调用该函数的函数,依此类推。 您应该打印所有未完成的堆栈帧。 通过研究kern / entry.S你会发现有一种简单的方法可以判断何时停止。

我滴?呀,用别人的代码真是不好,我发现有的地方(比如mon_backtrace函数)这个作者已经写好了,我现在都搞不清楚有的函数到底是不是本来就已经实现了。 导致我都看不懂题目了。 我在想,回调函数不是已经实现了吗???

在entry.S函数的中执行了movl $0x0,%ebp # nuke frame pointer然后就call了init函数,所以函数终止点为ebp == 0x0;

monitor.c中mon_backtrace函数:

// 获取寄存器ebp本身的位置
	int regebp = read_ebp();
	// 获取ebp指向的位置,即ebp中的内容
	regebp = *((int *)regebp);
	// ebp 最终指向栈的某个位置
	int *ebp = (int *)regebp;
	
	cprintf("Stack backtrace:\n");
	//If only we haven't pass the stack frame of i386_init
	while((int)ebp != 0x0) {
		
		cprintf("  ebp %08x", (int)ebp);
		// 返回地址
		cprintf("  eip %08x", *(ebp+1));
		cprintf("  args");
		cprintf(" %08x", *(ebp+2));
		cprintf(" %08x", *(ebp+3));
		cprintf(" %08x", *(ebp+4));
		cprintf(" %08x", *(ebp+5));
		cprintf(" %08x\n", *(ebp+6));
		// 上一层函数的ebp指针
		ebp = (int *)(*ebp);
	}

mon_backtrace函数中调用的read_ebp()函数声明在 inc/x86.h中,函数实现

static __inline uint32_t
read_ebp(void)
{
	uint32_t ebp;
	__asm __volatile("movl %%ebp,%0" : "=r" (ebp));
	return ebp;
}

Execise 12

修改堆栈回溯功能,为每个eip显示与该eip对应的函数名称,源文件名和行号。
好像有点复杂,先占个坑。

总结

这个lab真的是做了我整整三天时间,感觉信息量好大,收获挺多,对PC的整个启动流程有了整体上的概念。

  • 从开机启动->BIOS->Boot Loader->加载内核; 尽管之前对这部分就有概念,但没有真正自己一步一步调试、经历过这个过程,感觉十分有成就感。
  • 基本熟悉了x86体系控制寄存器(cr0,cr3);实模式到保护模式得跳转(寄存器的操作);因为之前有过对裸单片机的开发经历,所以感觉挺简单的。
  • MMU以及虚拟内存; 相当于回顾了一遍操作系统虚拟内存的内容。
  • 函数调用过程栈的变化(感觉自己过得比较草率); 尽管对栈的特性已经特别熟悉了,但在函数调用过程栈的变化过程还是有点模糊的。

但是个人感觉总体还是做得不够细致,很多细节地方都略过了。不过这叫战略性学习,哈哈。

–2019.5.27

你可能感兴趣的:(MIT6.828操作系统,MIT,6.828,操作系统)