前言

开启一场漫长且有趣的旅程,即日起开始学习MIT 6.828。总共有七个lablab6lab7其中选一个做。

PC启动流程简述

先了解一下当PC加电后,整个物理地址空间分布。

早期的16位计算机是只有1MB的寻址能力。也就是说只能使用0x0000 - 0xffff这么多,如上图所示,640kb以下的部分为早期计算机使用的部分。而上面640kb - 1mb的这些地方则保留下来作为特殊使用,比如视频显示之类的。其中最重要的部分为BIOS( Basic Input/Output System)BIOS的主要责任为进行一些自检,然后从启动设备中,并载入它的第一扇区到内存的特定位置,即0x7c00,然后跳转到这个位置继续执行。此时,BIOS的工作完成。0x7c00 即是bootloader的入口点。

当现代计算机突破了1MB内存,达到4GB甚至更多的时候,依然保留了最开始的1M内存空间。所以开始一开始加电以后,是处于实模式的,也就是只有1MB内存可以访问。bootloader会把实模式切换到保护模式。也就是4GB寻址模式。然后再读取kernel到内存中,并且把控制权转给kernel。至此操作系统才算真正的启动起来。

下面简单总结下启动流程。

  1. PC启动,并且执行第一条指令,位于0xffff0。是一条跳转指令,跳到BIOS刚开始的地方
  2. BIOS 进行初始化。
  3. BIOS搜寻启动设备,并加载bootloader到内存中,转移控制给bootloader
  4. bootloader从实模式切换到保护模式
  5. bootloader从硬盘中读取kernel到内存中,并转移控制给kernel
  6. 操作系统启动

关于实模式和保护模式

实模式即是当PC加电时,处于的模式,仅仅只有16位寻址能力。实模式将内存看成分段的区域。程序段和数据位于不同的区域。但是不区分kernel的操作还是用户的操作,也就是说每一个指针都指向实际的物理地址。很明显,这是致命的。可以通过修改A20地址线可以完成从实模式到保护模式的转换,具体如何转换我也不是很清楚,有兴趣的朋友自己研究。

在实模式下,地址如下翻译 physical address = 16 * segment + offset。目前你只需要知道在保护模式下,地址翻译与实模式下不同即可。

Exercise

Exercise 3

At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?

1
2
3
4
5
6
7
8
9
10
11
12
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

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

不多解释,注释很清楚。

What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?

((void (*)(void)) (ELFHDR->e_entry))(); 根据main.c 的代码,这是最后一条代码。然后我们去看反汇编文件boot.asm。搜索上面那条代码,很清楚的可以看到最后一条指令如下。

1
2
3
4
// call the entry point from the ELF header
// note: does not return!
((void (*)(void)) (ELFHDR->e_entry))();
7d6b: ff 15 18 00 01 00 call *0x10018

第一条kernel执行的语句如下。

1
movw    $0x1234,0x472           # warm boot

Where is the first instruction of the kernel?

让我困惑的是,当我实际debug的时候发现,并没有跳到0x10018去执行kernel,反而跳到了0x10000c。估计大概是由于内存映射之类的原因。故猜想0x10018可是是c语言的虚拟地址。

How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?

1
2
3
4
5
6
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff); //e_phoff是header表的位置偏移
eph = ph + ELFHDR->e_phnum; //e_phnum是header的数目
for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

注释很清楚了,也就是分别获取起始地址和结束地址,再来个循环,分块读取。

Exercise 5

ELF二进制文件,文件开头是一唱串固定长度的program header,保存了程序所需要的各种section,比如.text保存了程序指令,.data则保存了已经初始化的静态变量,如int x =0。使用objdump -h obj/kern/kernel可查看段信息。其实特别需要注意.text段中的 VMALMA列。分别代表链接地址(link address)和加载地址(load address)VMA指的是程序运行时的虚拟地址,而LMA则是程序真正载入到内存的时的物理地址。大部分时候这两个地址是一样的,但也有不同的时候。

这个练习就是修改一下boot/Makefrag-Ttext 0x7C00的地址,让bootloaderVMALMA不一样。重新编译运行,程序会崩溃。

Exercise 6

Examine the 8 words of memory at 0x00100000 at the point the BIOS enters the boot loader, and then again at the point the boot loader enters the kernel. Why are they different? What is there at the second breakpoint? (You do not really need to use QEMU to answer this question. Just think.)

实验结果如下

bootloader 运行之前0x00100000是空,运行完之后,被填充满了。猜想是bootloaderkernel填充到这里来了。查看代码,确实如此。

1
2
3
4
5
/* AT(...) gives the load address of this section, which tells
the boot loader where to load the kernel in physical memory */
.text : AT(0x100000) {
*(.text .stub .text.* .gnu.linkonce.t.*)
}

Exercise 7

Use QEMU and GDB to trace into the JOS kernel and stop at the movl %eax, %cr0. Examine memory at 0x00100000 and at 0xf0100000. Now, single step over that instruction using the stepi GDB command. Again, examine memory at 0x00100000 and at 0xf0100000. Make sure you understand what just happened.

What is the first instruction after the new mapping is established that would fail to work properly if the mapping weren’t in place? Comment out the movl %eax, %cr0 inkern/entry.S, trace into it, and see if you were right.

实验结果如下

当运行movl %eax, %cr0 之后, 两个地址都指向了同一个地方也就是0x00100000,说明完成了地址映射。当我们把movl %eax, %cr0 注释掉之后,os启动崩溃,生成错误如下。

qemu: fatal: Trying to execute code outside RAM or ROM at 0xf010002c

Exercise 8

We have omitted a small fragment of code - the code necessary to print octal numbers using patterns of the form “%o”. Find and fill in this code fragment.

修改代码如下。

1
2
3
4
5
6
7
8
case 'o':
// Replace this with your code.
//putch('X', putdat);
//putch('X', putdat);
//putch('X', putdat);
num = getuint(&ap, lflag);
base = 8;
goto number;

回答下列问题

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在其putch()函数中调用了cputchar()console.c 封装了一些与硬件接触的函数,如getchar()cputchar()

Explain the following from console.c:

1
2
3
4
5
6
7
8
> 1      if (crt_pos >= CRT_SIZE) {
> 2 int i;
> 3 memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
> 4 for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
> 5 crt_buf[i] = 0x0700 | ' ';
> 6 crt_pos -= CRT_COLS;
> 7 }
>

处理屏幕满了的情况。丢弃第一行。然后把后面的往上移。

For the following questions you might wish to consult the notes for Lecture 2. These notes cover GCC’s calling convention on the x86.

Trace the execution of the following code step-by-step:

1
2
3
> int x = 1, y = 3, z = 4;
> cprintf("x %d, y %x, z %d\n", x, y, z);
>
  • In the call to cprintf(), to what does fmt point? To what does ap point?
  • List (in order of execution) each call to cons_putc, va_arg, and vcprintf. For cons_putc, list its argument as well. For va_arg, list what ap points to before and after the call. For vcprintf list the values of its two arguments.

fmt 指向字符串 $4 = 0xf0101b4e "x %d, y %x, z %d\n"ap 则指向第二个参数的地址,即是x的地址。

接下来我们跟踪调用

1
2
3
4
cprintf (fmt=0xf0101b4e "x %d, y %x, z %d\n")
vcprintf (fmt=0xf0101b4e "x %d, y %x, z %d\n", ap=0xf010ff64 "\001")
vprintfmt (putch=0xf01008cf <putch>, putdat=0xf010ff2c, fmt=0xf0101b4e "x %d, y %x, z %d\n", ap=0xf010ff64 "\001")
cons_putc (c=120)

va_arg调用之后,(va_list) 0xf010ff68 "\003" 指向了第二个参数,也就是 y

Run the following code.

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

What is the output?

输出为He110 World 。比较简单,不多做解释。

In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?

cprintf("x=%d y=%d", 3);

输出为x=3 y=-267380676。因为只传入了一个参数,也就是va_list长度为一,当要输出第二个参数,ap处存放的是随机的数。

Let’s say that GCC changed its calling convention so that it pushed arguments on the stack in declaration order, so that the last argument is pushed last. How would you have to change cprintf or its interface so that it would still be possible to pass it a variable number of arguments?

既然要改变了入栈了,那么则要改变va_arg读取顺序。在网上找到一种方法。

1
2
#define va_arg(ap,t) \
(*(t *)((ap -= __va_size(t)) + __va_size(t)))

Exercise 9

Determine where the kernel initializes its stack, and exactly where in memory its stack is located. How does the kernel reserve space for its stack? And at which “end” of this reserved area is the stack pointer initialized to point to?

1
2
movl    $0x0,%ebp           # nuke frame pointer
movl $(bootstacktop),%esp # Set the stack pointer

kern/entry.S 中的上述代码设置了栈。根据反汇编文件可知,这个bootstacktop的地址为0xf0110000 。栈的预留靠.space KSTKSIZE实现。

Exercise 10

To become familiar with the C calling conventions on the x86, find the address of the test_backtrace function in obj/kern/kernel.asm, set a breakpoint there, and examine what happens each time it gets called after the kernel starts. How many 32-bit words does each recursive nesting level of test_backtrace push on the stack, and what are those words?

obj/kern/kernel.asm 中找到 函数的入口地址为0xf0100040 。接下来跟踪调试,查看每次esp 的变化。这里涉及到的是栈的知识,不多解释,最好的学习资料是csapplab2

每调用一次test_backtrace。会发生如下事情。

  1. 压入参数
  2. 压入返回地址,为下一行地址。
  3. 压入ebp
  4. 更新ebpesp的值,此时设立了函数的栈帧
  5. 压入ebx 用来保存临时变量之类的
  6. 扩大栈,也就是esp减去某个值(栈是向下生长的),为函数分配空间

通过gdb分析,每次调用会压入8个字。压入的内容就是我上面说的那些。

Exercise 11

Implement the backtrace function as specified above

通过上面的分析,此题不难。无非是用read_ebp获得ebp,然后就可获得所有需要的内容。

Exercise 12

Modify your stack backtrace function to display, for each eip, the function name, source file name, and line number corresponding to that eip.

此题关键是补全debuginfo_eip,实现查找行号。下面是我的实现。

1
2
3
4
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if (lline > rline)
return -1;
info->eip_line = stabs[lline].n_desc;

总结

以上则是全部内容。具体代码在 github:mit 6.828 。看似短短的一个lab1,花费了大量的时间,确实不容易。虽然写的代码不多,但对操作系统启动的理解是很深入的。

Reference

mit 6.828 2016

fatsheep9146的csdn博客

valkjsaaa的github