Part 1: PC Bootstrap

Exercise 1

通过 PC Assembly Language Book 了解汇编语言,不需要现在就去读这些,但是要在读写 x86 汇编时知道去查阅这些资料。 建议阅读 Brennan’s Guide to Inline Assembly 了解 AT&T 汇编语法,因为 Jos 使用 GNU 汇编器,GNU 汇编器采用 AT&T 语法。

Exercise 2

使用 GDB 的si指令跟踪 BIOS 启动过程,看看 BIOS 启动后做了些什么,可以参考 Phil Storrs I/O Ports Description,不需要知道细枝末节,只需要大概知道 BIOS 启动后做了些什么就行了。

 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
66
67
68
69
70
71
72
73
[f000:fff0]    0xffff0:	ljmp   $0xf000,$0xe05b //bios 从 0xffff0 开始执行第一条指令:跳转到 0xfe05b 执行
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb) si
[f000:e05b]    0xfe05b:	cmpl   $0x0,%cs:0x6ac8 //0xf6ac8 处的值是否为 0
0x0000e05b in ?? ()
(gdb) si
[f000:e062]    0xfe062:	jne    0xfd2e1 //如果 0xf6ac8 处的值不为零则跳转到 0xfd2e1 执行(这应该是一个异常处理,具体是做什么不知道,网上找了很多也没有找到相关的解释)
0x0000e062 in ?? ()
(gdb) si
[f000:e066]    0xfe066:	xor    %dx,%dx //将 dx 寄存器置零
0x0000e066 in ?? ()
(gdb) si
[f000:e068]    0xfe068:	mov    %dx,%ss //将 ss 栈段地址寄存器置零
0x0000e068 in ?? ()
(gdb) si
[f000:e06a]    0xfe06a:	mov    $0x7000,%esp //将栈指针寄存器的值设为 0x7000
0x0000e06a in ?? ()
(gdb) si
[f000:e070]    0xfe070:	mov    $0xf34c2,%edx //将 edx 寄存器的值设为 0xf34c2
0x0000e070 in ?? ()
(gdb) si
[f000:e076]    0xfe076:	jmp    0xfd15c //跳转到 0xfd15c 处执行
0x0000e076 in ?? ()
(gdb) si
[f000:d15c]    0xfd15c:	mov    %eax,%ecx //将 ecx 寄存器的值设为 eax 寄存器中的值
0x0000d15c in ?? ()
(gdb) si
[f000:d15f]    0xfd15f:	cli //关中断
0x0000d15f in ?? ()
(gdb) si
[f000:d160]    0xfd160:	cld //将内存增长方向设为向高地址增长
0x0000d160 in ?? ()
(gdb) si
[f000:d161]    0xfd161:	mov    $0x8f,%eax //将 eax 的值设为 0x8f
0x0000d161 in ?? ()
(gdb) si
[f000:d167]    0xfd167:	out    %al,$0x70 //把 al 的值输出到 0x70 端口,al 为 eax 的低八位,值为 0xf
0x0000d167 in ?? ()
(gdb) si
[f000:d169]    0xfd169:	in     $0x71,%al //把 0x71 端口的值读到 al 寄存器中
0x0000d169 in ?? ()
(gdb) si
[f000:d16b]    0xfd16b:	in     $0x92,%al //把 0x92 端口的值读到 al 寄存器中
0x0000d16b in ?? ()
(gdb) si
[f000:d16d]    0xfd16d:	or     $0x2,%al //al 寄存器中的值|=2
0x0000d16d in ?? ()
(gdb) si
[f000:d16f]    0xfd16f:	out    %al,$0x92 //将 al 值输出到 0x92 端口
0x0000d16f in ?? ()
(gdb) si
[f000:d171]    0xfd171:	lidtw  %cs:0x6ab8 //将 0xf6ab8 开始后的 6 个字节的数据加载到中断描述符表寄存器
0x0000d171 in ?? ()
(gdb) si
[f000:d177]    0xfd177:	lgdtw  %cs:0x6a74 //将 0xf6ab8 开始后的 6 个字节的数据加载到全局描述符表寄存器
0x0000d177 in ?? ()
(gdb) si
[f000:d17d]    0xfd17d:	mov    %cr0,%eax //将 cr0 寄存器的值赋值给 eax 寄存器
0x0000d17d in ?? ()
(gdb) si
[f000:d180]    0xfd180:	or     $0x1,%eax //将 eax 寄存器的第 0 位置为 1
0x0000d180 in ?? ()
(gdb) si
[f000:d184]    0xfd184:	mov    %eax,%cr0 //将 eax 寄存器中的值赋给 cr0 寄存器,相当于 cr0 的最后一位置为 1,这个是开启保护模式的标识位
0x0000d184 in ?? ()
(gdb) si
[f000:d187]    0xfd187:	ljmpl  $0x8,$0xfd18f //跳转到 segment:0x8 offset:0xfd18f, 看到 offset 已经超过 16 位,进入 32 位保护模式,具体跳转的物理地址要看全局描述符中对应段选择子为 0x8 对应的基地址是多少,查出基地址在加上 offset 就是跳转的物理地址,但是这是在 bios 里执行的,不知道全局描述符表的结构
0x0000d187 in ?? ()
(gdb) si
The target architecture is assumed to be i386 
=> 0xfd18f:	mov    $0x10,%eax //将 eax 寄存器的值设为 0x10, 看执行的地址为 0xfd18f 来看,之前 0x8 段选择子的基地址应该是 0,进入 32 为模式后的操作看不懂了,应该是在做一些硬件检查的工作。
0x000fd18f in ?? ()

Part 2: The Boot Loader

Exercise 3

看看 lab tools guide,尤其是 GDB 命令部分,它包含了一些这个实验需要用到的复杂的 GDB 命令。 在0x7c00处打个断点,继续执行到该断点,通过 boot/boot.s 和反汇编文件 obj/boot/boot.asm 看看执行到了哪个位置,然后通过 x/i 命令查看每一条指令然后和 obj/boot/boot.asm 以及 GDB 显示出来的命令进行比较。 跟踪 boot/main.c 中的 bootmain(),然后继续跟踪进入 readsect(), 标记出汇编指令对应 readsect() 中那一句代码,跟踪完 readsect() 的剩余部分回到 bootmain(),指出 for 循环读取内核剩余的扇区的开始和结束,找出 for 循环结束的地方,在那里打一个断点,然后执行到断点,一步步跟踪 boot loader 剩下的部分。 需要能够回答一下问题:

  • 什么时候开始执行 32 位代码?什么导致了 16 位到 32 位的转换?
 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
Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x/i
   0x7c01:	cld
(gdb) x/i
   0x7c02:	xor    %ax,%ax
(gdb) x/i
   0x7c04:	mov    %ax,%ds
(gdb) x/i
   0x7c06:	mov    %ax,%es
(gdb) x/i
   0x7c08:	mov    %ax,%ss
(gdb) x/i
   0x7c0a:	in     $0x64,%al
(gdb) x/i
   0x7c0c:	test   $0x2,%al
(gdb) x/i
   0x7c0e:	jne    0x7c0a
(gdb) x/i
   0x7c10:	mov    $0xd1,%al
(gdb) x/i
   0x7c12:	out    %al,$0x64
(gdb) x/i
   0x7c14:	in     $0x64,%al
(gdb) x/i
   0x7c16:	test   $0x2,%al
(gdb) x/i
   0x7c18:	jne    0x7c14
(gdb) x/i
   0x7c1a:	mov    $0xdf,%al
(gdb) x/i
   0x7c1c:	out    %al,$0x60
(gdb) x/i
   0x7c1e:	lgdtw  0x7c64
(gdb) x/i
   0x7c23:	mov    %cr0,%eax
(gdb) x/i
   0x7c26:	or     $0x1,%eax
(gdb) x/i
   0x7c2a:	mov    %eax,%cr0
(gdb) x/i
   0x7c2d:	ljmp   $0x8,$0x7c32
(gdb) x/i
   0x7c32:	mov    $0xd88e0010,%eax //从这里开始执行 32 位代码 上面的代码和 bios 刚启动时执行的代码差不多,都是开启 A20, 进入 32 位保护模式的过程,只不过这里开启 A20 用了另外一种方式,通过 0x64 端口的方式,而 bios 使用 0x92 端口的方式
  • bootloader 执行的最后一条指令是什么?内核装载进来执行的第一条指令是什么?
1
2
3
4
5
=> 0x7d6b:	call   *0x10018 //booloader 执行的最后一条指令

Breakpoint 1, 0x00007d6b in ?? ()
(gdb) si
=> 0x10000c:	movw   $0x1234,0x472 //内核执行的第一条指令
  • 内核的第一条指令在什么位置?
1
0x10000c
  • 为了从磁盘读取完整的内核,boot loader 怎么知道要读多少个扇区?它从哪得到的这些信息?

内核是 elf 格式的可执行文件,通过 elf header 中的的信息知道要读多少个扇区

 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
root@cef7ff2a5180:~/lab/obj/kern# readelf -h kernel
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x10000c //内核入口地址与上面反编译调试找出来的内核第一条指令执行的位置一致
  Start of program headers:          52 (bytes into file) //对应 bootmain 中的 ELFHDR->e_phoff
  Start of section headers:          86776 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         3 //对应 bootmain 中的 ELFHDR->e_phnum,这样就只知道从哪里开始加载,要加载
  Size of section headers:           40 (bytes)
  Number of section headers:         15
  Section header string table index: 14
  
  Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align //这个对应 bootmain 中 for 循环要加载的内容
  LOAD           0x001000 0xf0100000 0x00100000 0x0759d 0x0759d R E 0x1000
  LOAD           0x009000 0xf0108000 0x00108000 0x0b6a8 0x0b6a8 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10

Exercise 4

读一下_The C Programming Language 这本书,了解 c 语言中的指针_

Exercise 5

看一下 bootloader 的前几条指令,想想如果链接的地址不是 0x7c00, 第一条出错的指令是哪一条?然后改一下 boot/Makfrag 中的链接地址,看看会发生什么? 首先第一条出错的指令应该是

1
  ljmp    $PROT_MODE_CSEG, $protcseg //protcseg 是。text 中的标识符,会根据链接的地址来计算

将链接的地址改为 0x7c02 后,gdb 调试

1
2
[   0:7c2d] => 0x7c2d:	ljmp   $0x8,$0x7032 //执行到这的时候报错了
0x00007c2d in ?? ()

Exercise 6

gdb 命令 x/Nx ADDR 可以打印出 ADDR 后面 n 个字的内容,字长没有标准,在 GNU 汇编器中字长为两个字节, 在进入 bootloader 后和从 bootloader 进入内核后分别看一下 0x00100000 后面 8 个字的内容,看看两处有什么不同?

0x00100000 是内核装载的位置,进入 bootloader 后未载入 kernel 前应该被 qemu 清零了,载入后应该是内核代码

通过在 gdb 断点调试的结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) b *0x10000c
Breakpoint 2 at 0x10000c
(gdb) c
Continuing.
[   0:7c00] => 0x7c00:	cli

Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x/8x 0x00100000
0x100000:	0x00000000	0x00000000	0x00000000	0x00000000
0x100010:	0x00000000	0x00000000	0x00000000	0x00000000
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x10000c:	movw   $0x1234,0x472

Breakpoint 2, 0x0010000c in ?? ()
(gdb) si
=> 0x100015:	mov    $0x112000,%eax
0x00100015 in ?? ()
(gdb) x/8x 0x00100000
0x100000:	0x1badb002	0x00000000	0xe4524ffe	0x7205c766
0x100010:	0x34000004	0x2000b812	0x220f0011	0xc0200fd8

Part 3: The Kernel

Exercise 7

使用 GDB 看看在 movl %eax, %cr0 指令执行之前和执行 0x00100000 和 0xf0100000 出的内容分别是什么? 然后注释掉这条指令看看第一条出错的指令是哪一条?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(gdb) b *0x100025
Breakpoint 1 at 0x100025
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x100025:	mov    %eax,%cr0

Breakpoint 1, 0x00100025 in ?? ()
(gdb) x/8x 0x00100000
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
(gdb) si
=> 0x100028:	mov    $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/8x 0x00100000
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

可以看到 mov %eax, %cr0 指令执行之前的 0x00100000 的内容和执行之后的 0xf0100000 的内容完全一样 其实 jos 在这里就已经开启了虚拟内存,开启虚拟内存之前内核代码在 0x00100000,而开启虚拟内存后,0xf0100000 被映射到了 0x00100000 如果注释掉这条指令那么

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Breakpoint 1 at 0x100025
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0x100025:	mov    $0xf010002c,%eax

Breakpoint 1, 0x00100025 in ?? ()
(gdb) si
=> 0x10002a:	jmp    *%eax //执行这个跳转指令就会出错了 不开启虚拟内存无法寻址到 0xf010002c
0x0010002a in ?? ()
(gdb) si
=> 0xf010002c <relocated>:	add    %al,(%eax)
relocated () at kern/entry.S:74
74		movl	$0x0,%ebp			# nuke frame pointer
(gdb) si
Remote connection closed

Exercise 8

弄清 kern/console.c,kern/printf.c, lib/printfmt.c 之间的关系,找到漏掉的打印 8 进制数代码的地方,并且把这段代码补上去 在 lib/printfmt.c 中把打印 8 进制数的代码加上 image 回答以下几个问题

  • 解释 printf.c 和 console.c 之间的关系,尤其是 console.c 导出了什么函数,printf.c 是怎样使用这些函数的?

首先 printf.c 会调用 console.c 提供的函数,console.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
// `High'-level console I/O.  Used by readline and cprintf.

void
cputchar(int c)
{
	cons_putc(c);
}

int
getchar(void)
{
	int c;

	while ((c = cons_getc()) == 0)
		/* do nothing */;
	return c;
}

int
iscons(int fdnum)
{
	// used by readline
	return 1;
}

printf.c 会调用 printf.c 中的 vprintfmt 函数,vprintfmt 函数接受一个函数指针参数将 printf.c 中 putchar 函数传进去,而 putchar 函数调用了 console.c 中的 cputchar 函数。

  • 解释下面的代码
1
2
3
4
5
6
7
8
9
// What is the purpose of this?
	if (crt_pos >= CRT_SIZE) {//如果当前游标位置已经到了整个控制台的最后一个位置,相当于整个控制台都已填满了
		int i;

		memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t)); //滚动屏幕,第一行的内容被覆盖掉
		for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++) //将最后一行的内容置为空字符和黑背景
			crt_buf[i] = 0x0700 | ' ';
		crt_pos -= CRT_COLS; //将光标移到行首
	}
  • 一步一步追踪下面代码的执行
1
2
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

在调用 cprintf 时 fmt 指向了什么?ap 指向了什么? fmt 指向"x %d, y %x, z %d\n“,ap 指向参数列表 x, y, z。 列出 cons_putc,va_arg,vcprintf 的调用顺序,列出 cons_putc 接收的参数,指出调用 va_arg 前后 ap 指针的指向,列出 vcprintf 的参数?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
vcprintf (fmt=0xf0101a77 "x %d, y %x, z %d\n", ap=0xf010ffd4)
cons_putc (c=120 'x')
cons_putc (c=32 ' ')
ap=0xf010ffd4 -> 1
va_arg (*ap, int) // 获取可变参数的当前参数,返回指定类型并将指针指向下一参数
ap=0xf010ffd8 -> 3
cons_putc (c=49 '1')
cons_putc (c=44 ',')
cons_putc (c=32 ' ')
cons_putc (c=121 'y')
cons_putc (c=32 ' ')
ap=0xf010ffd8 -> 3
va_arg ((*ap, int)
ap=0xf010ffdc -> 4
cons_putc (c=51 '3')
cons_putc (c=44 ',')
cons_putc (c=32 ' ')
cons_putc (c=122 'z')
cons_putc (c=32 ' ')
ap=0xf010ffdc -> 4
va_arg (*ap, int)
ap=0xf010ffe0 -> 0xf0113060
cons_putc (c=52 '4')
cons_putc (c=10 '\n')
  • 下面的代码会输出什么?解释一下为什么会输出这个结果?
1
2
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);

输出结果为:He110 World, 57616 的 16 进制表示为 e110, 对 i 取地址以%s 的形式输出,那么&i 相当于 char*, 因为 x86 是小端序那个 i 在内存中有低到高的顺序为 72,6c,64,00, ascii 码为’rld\0’, 刚好是一个 c 语言中的字符串存储方式,因此%s 会打印出 rld。

因为 x86 是小端序(低位在低地址)所以会有以上的输出结果,如果是大端序你会将 i 的值改为多少使得输出同样的内容呢?是否需要改变 57616 的值呢? 如果是大端序,那么 i 在内存中有低到高的顺序依然需要是 72,6c,64,00, 但因为大端序高为在低地址,所以 i=0x726c6400, 57616 虽然在内存中的存储顺序不一样但是机器在取的时候会根据其自身的字节序取,取出来的 16 进制表示是相同的都是 e110,所以不需要改变 57616 的值。

  • 以下代码会输出什么?答案是不确定的,想想为什么是不确定的
1
cprintf("x=%d y=%d", 3);

输出为 x=3 y=1600,根据 gdb 调试 cprintf 栈中信息可以看出,0x00000003 的后面是 0x00000640,转成 10 进制刚好是 1600,也就是说 y=%d 的输出是在 3 之前进栈的参数,所以可能会输出任意的数。 image

  • 如果说 gcc 改变了参数的入栈顺序,入栈顺序和参数声明顺序一样,最后的参数最后进栈,那么应该怎样修改 cprintf,使得仍然能够像上面一样传递变量参数?

cprintf 中获取变量参数的方式是通过__builtin_va_start(),__builtin_va_arg(), __builtin_va_end() 这些 gcc 内置函数来获取的,如果 gcc 调用方式变了,那么这些内置函数也应该会变,因此我们无需对 cprintf 做任何修改。

Exercise 9

确定内核在哪初始化的栈,并且确定栈空间位于哪里,内核是怎样为栈保留空间的,被初始化的栈指针指向了这个保留空间"结尾",这个"结尾"在哪? 在 entry.S 中初始化的栈 image 从反编译的代码里可以看出 esp 指向的是 0xf0110000 image 结合栈大小 image 栈大小为 8 页,一页为 4k, 所以栈大小是 32k image 由于栈是向下增长的,所以栈空间为0xf0108000  到 0xf0110000

Exercise 10

为了熟悉 x86 下 c 语言的调用方式,通过 obj/kern/kernel.asm 找到 test_backtrace 的地址,在那打一个端点,看看在内核启动后的 test_backtrace 被调用的每一次发生了什么?每一次递归调用 test_backtrace 有多少个 32 位字进栈,这些字是什么? image 根据这反编译的代码可知,ebp 入栈,esi 入栈,ebx 入栈,esp 向下移动 8 个字节,esi 进栈,接下来是调用 cpritnf 的过程,这里忽略它,调到 f010095 的 test_backtrace 中,eax 入栈,call 的过程中 eip 会入栈,所以总共是 4+4+4+8+4+4=32 字节,也就是 8 个字。

Exercise 11

实现 mon_backtrace() 函数,如果使用 read_ebp() 的话要确保 read_ebp() 发生在函数创建函数栈帧之后 image

Exercise 12

修改 backstrace 的实现,使其能够打印当前的 eip 所属的函数名,文件名,行号,在 debuginfo_eip() 中__STAB__ 来自哪里?通过 kern/kernel.ld, 运行命令 objdump -h obj/kern/kernel,objdump -G obj/kern/kernel,gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c,看看 bootloader 是不是把符号表作为内核的一部分载入了内存。通过调用 stab_binsearch 来找到某个地址的行号来完成 debuginfo_eip 打印行号的功能。printf("%.*s", length, string) 能够打印非’\0’结尾的字符串 image 通过 Eipdebuinfo 我们能够拿到文件名,行号,函数名,行数名长度,函数开始地址,函数的参数个数信息。 所以我们在 mon_backtrace 中添加如下代码 image 由于 debuginfo_eip() 并没有完全实现,要通过地址获取行号,使用 stab_binsearch() 这函数来实现 image 最后在 kern/monitor.c 中加上这个命令 image

最后

看下是否通过 image