主要是学习启动一个操作系统前的引导加载程序,学会使用gdb调试

前言

早就听说MIT6.828的大名了,现在来学习一下这门操作系统的课程,完成了实验一之后也对这门课有了一些认识。

和我们学校的操作系统课程相比,能学到的东西实在是多太多了,非常注重实践(实验有7个,每个分量都挺大的),从启动一个操作系统开始,循序渐进,自己亲手实验一个操作系统,让你学学你不知道的坑。

下面就是我的实验一完成的报告。

环境搭建

实验一指导

环境搭建是最重要的一步,不然后面的实验会有很多莫名其妙的错误

环境搭建

  1. 先按照指导,下载源码并编译链接
  2. 开始做实验

第1部分:PC Bootstrap

看指导书

第2部分:引导加载程序

笔记

  • PC的软盘和硬盘分为512个字节区域,称为扇区。扇区是磁盘的最小传输粒度:每个读取或写入操作必须是一个或多个扇区,并在扇区边界上对齐。
  • 如果磁盘是可引导的,则第一个扇区称为引导扇区,因为这是引导加载程序代码所在的位置。
  • BIOS找到可引导的软盘或硬盘时,它会将512字节的引导扇区加载到物理地址0x7c000x7dff的内存中, 然后使用jmp指令将CS:IP设置为0000:7c00,将控制权交给引导装载机。与BIOS加载地址一样,这些地址相当随意——但它们是针对PC修复和标准化的。

实验准备

对于本实验,引导加载程序由一个汇编语言源文件boot/boot.S和一个C源文件boot/main.c组成,我们先阅读这两个文件的源码

然后学习了解这次要用到的工具

练习3

处理器在什么时候开始执行32位代码?究竟是什么导致从16位模式切换到32位模式?

boot/boot.S中,我们可以看到这样一行代码

  # Jump to next instruction, but in 32-bit code segment.
  # Switches processor into 32-bit mode.
  ljmp    $PROT_MODE_CSEG, $protcseg

在这一步,执行了一个段间跳转指令,格式为ljmp $SECTION, $OFFSET,并且从此开始执行32位代码。

si命令一步一步到有ljmp这一步

以下指令导致了从实模式到保护模式到转换。cr0寄存器的0位置1。

(gdb) 
[   0:7c23] => 0x7c23:	mov    %cr0,%eax
0x00007c23 in ?? ()
(gdb) 
[   0:7c26] => 0x7c26:	or     $0x1,%ax
0x00007c26 in ?? ()
(gdb) 
[   0:7c2a] => 0x7c2a:	mov    %eax,%cr0
0x00007c2a in ?? ()
(gdb) 
[   0:7c2d] => 0x7c2d:	ljmp   $0xb866,$0x87c32
0x00007c2d in ?? ()

引导加载程序执行的最后一条指令是什么,它刚加载的内核的第一条指令是什么?

boot/main.c中可以到这一行代码

	// call the entry point from the ELF header
	// note: does not return!
	((void (*)(void)) (ELFHDR->e_entry))();

这就是引导加载程序执行的最后一条指令,我们可以在gdb找出来,地址为0x7d6b

(gdb) b *0x7d6b
Breakpoint 3 at 0x7d6b
(gdb) c
Continuing.
=> 0x7d6b:	call   *0x10018

接下去执行的就是内核执行的第一条指令:

(gdb) si
=> 0x10000c:	movw   $0x1234,0x472
0x0010000c in ?? ()

内核的第一条指令在哪里?

由上一问可以知道就是地址0x10000c

引导加载程序如何决定从磁盘获取整个内核必须读取多少扇区?它在哪里找到这些信息?

根据对main.c的分析,显然是通过 ELF 文件头获取所有program header table

通过 objdump 命令可以查看:

$ objdump -p obj/kern/kernel                                                                                                   

obj/kern/kernel:     file format elf32-i386

Program Header:
    LOAD off    0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
         filesz 0x0000759d memsz 0x0000759d flags r-x
    LOAD off    0x00009000 vaddr 0xf0108000 paddr 0x00108000 align 2**12
         filesz 0x0000b044 memsz 0x0000b6a4 flags rw-
   STACK off    0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
         filesz 0x00000000 memsz 0x00000000 flags rwx

练习5

  • 再次跟踪引导加载程序的前几条指令,并确定第一条指令执行错误操作,如果您要使引导加载程序的链接地址错误。然后将boot/Makefrag中的链接地址更改为错误的地址,运行make clean,重新编译实验make,然后再次跟踪到引导加载程序以查看发生的情况。

boot/Makefrag中的-Ttext 0x7c00改为-Ttext 0x7c20,仍然将断点设置在0x7c00

(gdb) 
[   0:7c2d] => 0x7c2d:	ljmp   $0xb866,$0x87c52
0x00007c2d in ?? ()

与之前对比可以发现,相差了0x20,也就是修改增加的值。然而由于BIOS会把引导加载程序固定加载在0x7c00,于是导致了错误。

练习6

BIOS进入引导加载程序时检查内存的8个字在0x00100000处,然后在引导加载程序进入内核时再次检查。他们为什么不同?第二个断点有什么?

ELF头中有一个很重要的字段,名为e_entry。该字段保存程序中入口点的链接地址:程序应该开始执行的程序文本部分中的内存地址。你可以看到入口点:

$ objdump -f obj/kern/kernel 

obj/kern/kernel:     file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

这和练习3的内核的第一条指令的位置相符。 可以看出,boot/main.c的作用就是从硬盘读取内核的每个段,然后跳转到内核的e_entry

首先在BIOS进入引导加载程序时检查一次(还未读取内核至内存),再在从引导加载程序进入内核时检查一次(此时已经将内核读入内存)。

(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.
[   0:7c00] => 0x7c00:	cli    

Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x00000000	0x00000000	0x00000000	0x00000000
0x100010:	0x00000000	0x00000000	0x00000000	0x00000000
(gdb) b *0x7d6b
Breakpoint 2 at 0x7d6b
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x7d6b:	call   *0x10018

Breakpoint 2, 0x00007d6b in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8

练习7

使用QEMU和GDB跟踪到JOS内核并停在movl %eax, %cr0。检查内存为0x001000000xf0100000。现在,使用stepiGDB命令单步执行该指令。再次,检查内存为0x001000000xf0100000。确保你了解刚刚发生的事情。

先将断点设置到加载内核的前一句

(gdb) b *0x7d6b
Breakpoint 1 at 0x7d6b
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x7d6b:	call   *0x10018

然后进入内核并停在movl %eax, %cr0,查看地址0x1000000xf0100000的内容,可以发现内容不一样

(gdb) 
=> 0x100025:	mov    %eax,%cr0
0x00100025 in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>:	0x00000000	0x00000000	0x00000000	0x00000000
0xf0100010 <entry+4>:	0x00000000	0x00000000	0x00000000	0x00000000

继续执行,执行完movl %eax, %cr0后,可以发现,VMALMA现在具有同样的内容。这是因为0x00100000被映射到了0xf0100000

(gdb) si
=> 0x100028:	mov    $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/8x 0x100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0xf0100010 <entry+4>:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8

建立新映射后,如果映射不到位,将无法正常工作的第一条指令是什么?movl %eax, %cr0kern/entry.S中注释掉,跟踪它,看看你是否正确。

程序会直接崩溃,这是在执行完jmp *%eax后崩溃的

(gdb) 
=> 0x10002a:	jmp    *%eax
0x0010002a in ?? ()
(gdb)
=> 0xf010002c <relocated>:	add    %al,(%eax)
relocated () at kern/entry.S:74
74		movl	$0x0,%ebp			# nuke frame pointer
(gdb) 
Remote connection closed

练习8

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

要补充的代码在lib/printfmt.c

// (unsigned) octal
case 'o':
	// Replace this with your code.
	putch('X', putdat);
	putch('X', putdat);
	putch('X', putdat);
	break;

参考上面十进制的输出,替换为

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

解释printf.cconsole.c之间的接口。具体来说,console.c导出什么功能?printf.c如何使用此函数?

console.c解释以下内容

内容在console.ccga_putc函数里

if (crt_pos >= CRT_SIZE) {
	int i;
	// 把从第1~n行的内容复制到0~(n-1)行,第n行未变化
	memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
	// 将第n行覆盖为默认属性下的空格
	for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
		crt_buf[i] = 0x0700 | ' ';
	// 清空了最后一行,同步crt_pos
	crt_pos -= CRT_COLS;
}

看完这个函数大概可以猜出来这个函数应该是在屏幕上输出字符,而显示屏幕是有大小的,crt_pos是当前光标的位置,而CRT_SIZE是显示屏幕的大小,超过这个大小,则要向下移动一行

逐步跟踪以下代码的执行:

int x = 1,y = 3,z = 4;
cprintf(“x%d,y%x,z%d \ n”,x,y,z);
  • 在调用cprintf()时,到底fmt有什么意义?到什么ap点? fmtcprintf函数的第一个参数,即指向字符串"x %d, y %x, z %d\n"的指针

ap指向第二个参数的地址。注意ap中存放的是第二个参数的地址,而非第二个参数。 + 列出(按执行顺序)每次调用 cons_putcva_argvcprintf。对于cons_putc,也列出其论点。对于va_arg,列出ap呼叫之前和之后的点。对于vcprintf名单的两个参数的值。 调用关系为cprintf -> vcprintf -> vprintfmt -> putch -> cputchar -> cons_putc

运行以下代码

unsigned int i = 0x00646c72;
cprintf(“H%x Wo%s”,57616,&i);
  • 什么是输出?解释如何以前一个练习的逐步方式获得此输出。 输出是He110 World。 57616的16进制形式为 e110,这个很好理解。 输出字符串时,从给定字符串的第一个字符地址开始,按字节读取字符,直到遇到 ‘\0’ 结束。

于是,Wo%s, &i 的意义是把 i 作为字符串输出。查阅 ASCII 码表可知,0x00 对应 ‘\0’,0x64 对应 ’d’,0x6c 对应 ‘l’,0x72 对应 ‘r’。

在下面的代码中,将要打印的是什么 ‘y=‘?(注意:答案不是特定值。)为什么会发生这种情况?

cprintf(“x =%dy =%d”,3);

输出为:x = 3, y = -267321588。 由于第二个参数尚未指定,输出3以后无法确定ap的值应该变化多少,更无法根据ap的值获取参数。va_arg取当前栈地址,并将指针移动到下个“参数”所在位置简单的栈内移动,没有任何标志或者条件能够让你确定可变参函数的参数个数,也不能判断当前栈指针的合法性。

假设GCC更改了它的调用约定,以便它按声明顺序在堆栈上推送参数,以便最后推送最后一个参数。您将如何更改cprintf或其界面,以便仍然可以传递可变数量的参数?

需要更改va_start以及va_arg两个宏的实现。

练习9

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

kern/entry.S中找到初始化ebpesp的语句:

# Clear the frame pointer register (EBP)
# so that once we get into debugging C code,
# stack backtraces will be terminated properly.
movl	$0x0,%ebp			# nuke frame pointer

# Set the stack pointer
movl	$(bootstacktop),%esp

用gdb查看地址:

(gdb) b kern/entry.S:74
Breakpoint 1 at 0xf010002f: file kern/entry.S, line 74.
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0xf010002f <relocated>:	mov    $0x0,%ebp

Breakpoint 1, relocated () at kern/entry.S:74
74		movl	$0x0,%ebp			# nuke frame pointer
(gdb) si
=> 0xf0100034 <relocated+5>:	mov    $0xf0110000,%esp
relocated () at kern/entry.S:77
77		movl	$(bootstacktop),%esp

可以看出,栈顶在0xf0110000,然后在kern/entry.S中找到:

bootstack:
	.space		KSTKSIZE

inc/memlayout.h中找到以下定义:

// Kernel stack.
#define KSTACKTOP	KERNBASE
#define KSTKSIZE	(8*PGSIZE)   		// size of a kernel stack
#define KSTKGAP		(8*PGSIZE)   		// size of a kernel stack guard

inc/mmu.h中找到以下定义:

#define PGSIZE		4096		// bytes mapped by a page

可以看出,栈大小为32kB。

由于栈是从内存高位向低位生长,所以堆栈指针指向的是高位。

练习10

要熟悉x86上的C调用约定,test_backtraceobj/kern/kernel.asm中找到函数的地址,在那里设置断点,并检查每次在内核启动后调用它时会发生什么。每个递归嵌套级别test_backtrace的堆栈中有多少32位字,这些字是什么?

test_backtrace函数在kern/init.c里定义和使用

// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
	cprintf("entering test_backtrace %d\n", x);
	if (x > 0)
		test_backtrace(x-1);
	else
		mon_backtrace(0, 0, 0);
	cprintf("leaving test_backtrace %d\n", x);
}
// Test the stack backtrace function (lab 1 only)
test_backtrace(5);

开始调试:

(gdb) b *0xf0100076
Breakpoint 1 at 0xf0100076: file kern/init.c, line 18.
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0xf0100076 <test_backtrace+54>:	call   0xf010076e <mon_backtrace>
Breakpoint 1, 0xf0100076 in test_backtrace (x=0) at kern/init.c:18
18			mon_backtrace(0, 0, 0);
(gdb) x/52x $esp
0xf010ff20:	0x00000000	0x00000000	0x00000000	0x00000000
0xf010ff30:	0xf01008ef	0x00000001	0xf010ff58	0xf0100068
0xf010ff40:	0x00000000	0x00000001	0xf010ff78	0x00000000
0xf010ff50:	0xf01008ef	0x00000002	0xf010ff78	0xf0100068
0xf010ff60:	0x00000001	0x00000002	0xf010ff98	0x00000000
0xf010ff70:	0xf01008ef	0x00000003	0xf010ff98	0xf0100068
0xf010ff80:	0x00000002	0x00000003	0xf010ffb8	0x00000000
0xf010ff90:	0xf01008ef	0x00000004	0xf010ffb8	0xf0100068
0xf010ffa0:	0x00000003	0x00000004	0x00000000	0x00000000
0xf010ffb0:	0x00000000	0x00000005	0xf010ffd8	0xf0100068
0xf010ffc0:	0x00000004	0x00000005	0x00000000	0x00010094
0xf010ffd0:	0x00010094	0x00010094	0xf010fff8	0xf01000d4
0xf010ffe0:	0x00000005	0x00001aac	0x00000644	0x00000000
0xf010fff0:	0x00000000	0x00000000	0x00000000	0xf010003e

因为栈向下生长,从后往前看即为执行顺序。 在调用函数时,对栈需要进行以下操作: 1. 将参数由右向左压入栈 2. 将返回地址 (eip中的内容) 入栈,在 call 指令执行 3. 将上一个函数的 ebp 入栈 4. 将 ebx 入栈,保护寄存器状态 5. 在栈上开辟一个空间存储局部变量 可以看出,第二列出现的0x000000050x00000000都是参数。 在参数前一个存储的是返回地址,0xf0100068出现了多次,是test_backtrace递归过程中的返回地址。而0xf01000d4出现仅一次,是i386_init函数中的返回地址。可以通过查看obj/kern/kernel.asm证明。

练习11

实现上面指定的回溯函数。使用与示例中相同的格式,否则将使评分脚本混淆。如果您认为它正常工作,请运行make grade以查看其输出是否符合我们的评分脚本所期望的内容,如果不符合则修复它。 在您交付Lab 1代码后,欢迎您以任何方式更改回溯功能的输出格式。

输出格式为:

Stack backtrace:
  ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
  ebp f0109ed8  eip f01000d6  args 00000000 00000000 f0100058 f0109f28 00000061
  ...

主要是根据提示来改写kern/monitor.c,要点: 1. 利用read_ebp() 函数获取当前ebp值 2. 利用 ebp 的初始值0判断是否停止 3. 利用数组指针运算来获取 eip 以及 args

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	uint32_t ebp, *p;
	ebp = read_ebp();
	while(ebp != 0)
	{
		p = (uint32_t*)ebp;
		cprintf("ebp %x eip %x args %08x %08x %08x %08x %08x\n", ebp, p[1], p[2], p[3], p[4], p[5], p[6]);
		ebp = *p;
	}
	return 0;
}

练习12

修改堆栈回溯功能,为每个eip显示与该eip对应的函数名称,源文件名和行号。

输出格式为:

K> backtrace
Stack backtrace:
  ebp f010ff78  eip f01008ae  args 00000001 f010ff8c 00000000 f0110580 00000000
         kern/monitor.c:143: monitor+106
  ebp f010ffd8  eip f0100193  args 00000000 00001aac 00000660 00000000 00000000
         kern/init.c:49: i386_init+59
  ebp f010fff8  eip f010003d  args 00000000 00000000 0000ffff 10cf9a00 0000ffff
         kern/entry.S:70: <unknown>+0
K>

首先是完成二分查找stab表确定行号的函数,在kern/kdebug.c的173行处 根据注释的提示基本就能完成:

// Search within [lline, rline] for the line number stab.
// If found, set info->eip_line to the right line number.
// If not found, return -1.
//
// Hint:
//	There's a particular stabs type used for line numbers.
//	Look at the STABS documentation and <inc/stab.h> to find
//	which one.
// Your code here.
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if(lline <= rline)
{
	info->eip_line = stabs[lline].n_desc;
}
else
{
	return -1;
}

此后是添加命令,在 kern/monitor.c 的第27行:

static struct Command commands[] = {
	{ "help", "Display this list of commands", mon_help },
	{ "kerninfo", "Display information about the kernel", mon_kerninfo },
	{ "backtrace", "Display information about the backtrace", mon_backtrace },
};

最后是添加backtrace的输出信息,将kern/monitor.cmon_backtrace函数改为:

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	uint32_t ebp, *p;
	struct Eipdebuginfo eip_info;
	ebp = read_ebp();
	while(ebp != 0)
	{
		p = (uint32_t*)ebp;
		cprintf("ebp %x eip %x args %08x %08x %08x %08x %08x\n", ebp, p[1], p[2], p[3], p[4], p[5], p[6]);
		if(debuginfo_eip(p[1], &eip_info) == 0)
		{
			uint32_t offset = p[1] - eip_info.eip_fn_addr;
			cprintf("\t\t%s:%d: %.*s+%d\n", eip_info.eip_file, eip_info.eip_line, eip_info.eip_fn_namelen,  eip_info.eip_fn_name, offset);
		}
		ebp = *p;
	}
	return 0;
}

检测

$ make grade
running JOS: (1.1s)
  printf: OK
  backtrace count: OK
  backtrace arguments: OK
  backtrace symbols: OK
  backtrace lines: OK
Score: 50/50

总结

刚开始环境没搭建好就开始做实验导致后面的实验出现了一些不明所以的bug,浪费了很多时间,所以环境搭建真的很重要!

实验过程主要就是阅读了引导加载程序到加载内核前后的代码,然后用gdb调试看看发生了什么有趣的事情。总体来说不难,主要是理解。 准备开始实验二——内存管理。