6.828 - lab1

这个实验分为三个部分:
1. 熟悉x86汇编语言,QEMU x86模拟器,和PC上电启动过程。
2. 介绍6.828内核的引导加载程序(boot loader),位于boot目录。
3. 介绍6.828内核的原型JOS,位于kernel目录

软件设置:
下载jos源代码,搭建环境

第一部分:PC引导


* 熟悉x86汇编


x86的汇编语法有NASM汇编和GNU汇编两种,NASM是所谓的Intel语法,GNU使用AT&T语法。两者语义相同表达方式不同,区别可见http://www.delorie.com/djgpp/doc/brennan/brennan_att_inline_djgpp.html
其中关于嵌入汇编的介绍非常重要。

* 模拟x86


使用模拟器来开发操作系统,这样可以做一些简单的调试,比如设置断点之类的。模拟器完全模拟了PC的行为,代码能运行在模拟器上也就能在真实PC上跑起来。

这里使用QEMU模拟器,它可以与GDB一起联调。

make qemu
make qemu-nox

* PC的物理地址空间

早基的PC基于16位的8088处理器,只有1MB大小的物理内存寻址能力。物理地址空间为0x0000,0000 ~ 0x000f,ffff。最低的640KB被称为"Low Memory",是可供PC随机访问的RAM。

0x000a,0000~0x000f,ffff 这个区间的380KB用于显存和固件。其中最重要的就是BIOS,位于0x000f,0000~0x000f,ffff区间的64KB中。BIOS负责最基本的系统初始化,比如激活显卡,检查可用内存大小。做完这些事情之后,BIOS尝试从某个地方(软盘、硬盘、CD-ROM或网络)加载操作系统,并将机器的控制权交给操作系统。

随着80286和80386的问世,Intel终于打破了1MB内存的屏障。为了兼容旧的软件,仍然保留最低1MB空间的内存布局。所以在现代PC中,0x000a,0000~0x000f,ffff 这个区域将整个内存RAM分割为低端内存和扩展内存。

出于设计上的考虑,JOS只使用PC上的头256MB内存。

* ROM BIOS


make qemu-gdb ( or make qemu-nox-gdb) + gdb

可以观测到以下事实:
(1) 上电后,CPU执行的第一跳指令位于0x000f,fff0 (cs=0xf000, ip=0xfff0)
(2) 位于该位置的指令是一条跳转指令ljmp   $0xf000,$0xe05b,目的地是cs=0xf000,ip=0xe05b

可以在gdb中使用si命令(单步执行)跟踪一下BIOS做了些什么。
我跟踪了一些,发BIOS中设置了gdt/idt,开启了保护模式,可能这样做一些工作会更方便些。

第二部分:Boot Loader


软盘和硬盘被划分为512字节大小的块,称为扇区(sector)。扇区是磁盘的最小传输单位。一个引导磁盘的第一个扇区称为引导扇区,boot loader就位于该处。BIOS会将引导扇区加载到物理内存的0x7c00~0x7dff处,然后跳转到该处继续执行(cs=0x0000,ip=0x7c00)。

从CD-ROM启动会有一点点不同,因为CD-ROM的扇区大小是2048字节,所以BIOS会加载2048字节的内容到内存中。

6.828使用硬盘启动,boot loader必须限制在512字节以内。其主要完成两个功能:
(1) 将处理器由实模式(real mode)切换到32位保护模式(32-bit protected mode),这样才可以访问1MB以上的内存空间。
(2) 从IDE硬盘上读出内核到内存中。这里我们无关太关心操作IDE设备的具体细节,这属于设备驱动的范畴。

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

ljmp $PROT_MODE_CSEG, $protcseg

段寄存器有可见部分和不可见部分。如cs段寄存器,可见部分就是16bit的值,而其不可见部分则类似一个段描述符,包含段基址、限长、属性等信息。

这句长跳转指令仍然是16位代码,因为执行这条指令时虽然开启了保护模式,但是cs并未重新加载,其隐藏部分的D/B是0,表示16位代码。

执行完这条指令之后,cs被重新加载,而因为打开了保护模式,所以其隐藏部分要从gdt的相应位置加载。这些都是代码中已经设置好了的值。

那么这里的意思就是,这条指令的执行就已经是按保护模式去寻址的,但这条指令本身仍是16位代码,其后才是32位代码。

参考 http://oss.org.cn/ossdocs/gnu_linux/own_os/booting-protecte_mode_7.htm

 

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

 

((void (*)(void)) (ELFHDR->e_entry))();

bootloader最后使用这个调用跳入kernel,翻译成汇编,这其实是个call调用。

kernel第一条执行的指令由entry符号指定,在这里是:

movw $0x1234,0x472 # warm boot


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?

kernel是ELF格式的,ELF头信息中记录了其大小和入口地址。

bootloader最后的工作就是解析这个ELF头信息,得到了ELF映像文件的大小和入口地址。那么就知道kernel占多少扇区。

 

* Loading the kernel

代码位于boot/main.c中。

练习4: boot/main.c就开始涉及到c语言的知识了,特别是c语言指针。这里强烈建议先通读K&C的第五章。

boot/main.c中涉及到了ELF的知识。当我们编译并链接一个c程序(比如JOS内核),编译器将c文件翻译成目标文件(.o文件),目标文件中包含了二进制的汇编语言指令。链接器将所有的目标文件整合成一个二进制映像文件(比如obj/kern/kernel),这个二进制映像文件就是ELF格式的,表示"Executable and Linkable Format"。

主要关心的是ELF中的三个段:

- .text : 代码段

- .rodata : 只读数据段,比如代码中的字符串常量就在这个段里。

- .data : 数据段,这个段里包含已初始化的变量。

未初始化的变量都位于.bss段中(这个段紧着.data段)。C语言中未初始的变量值都为0,所以只需要记录.bss段的地址和大小。由加载器或程序本身来将.bss段清零。

objdump -h obj/kern/kernel

列出目标文件各段的头信息。

在打印出的信息中可以看到VMA(link address)和LMA(load address)

LMA(load address) - 表示该段应该被加载到内存的地址。ELF头信息中的ph->p_pa就表示该值。

VMA(link address) - 表示该程序期望运行的地址。

 

Exercise 5. Trace through the first few instructions of the boot loader again and identify the first instruction that would "break" or otherwise do the wrong thing if you were to get the boot loader's link address wrong. Then change the link address in boot/Makefrag to something wrong, run make clean, recompile the lab with make, and trace into the boot loader again to see what happens. Don't forget to change the link address back and make clean again afterward!

lgdt gdtdesc

问题就出在这一句代码上。gdtdesc相当于一个全局变量,这个符号本身就是一个地址,它的值在编译完成后就固定了。如果链接地址改变了,而代码的加载地址仍然是0x7c00,那么这里就加载了一个错误的地址到gdt中。接下来的运行肯定会出错。

a. -Ttext 0x7c00

lgdt gdtdesc => lgdtw  0x7c64

b. 改为 -Ttext 0x8c00

lgdt gdtdesc => lgdtw  -0x739c (-0x739就是0x8c64c的补码)

 

对于内核来说, 内核会被bootloader加载到一个低地址(0x10,0000),但它却期望自己运行在高地址处(0xf010,0000)。这样一来,内核的链接地址肯定是0xf010,0000,它最开始的一点代码是位置无关的,可以在任意处运行。这部分代码会开启虚拟内存,将0x100000映射到0xf010,0000上,于是接下来内核代码认为自己真的在0xf010,0000以上的地址运行了。

Exercise 6. We can examine memory using GDB's x command. The GDB manual has full details, but for now, it is enough to know that the command x/NADDR prints N words of memory at ADDR. (Note that both 'x's in the command are lowercase.) Warning: The size of a word is not a universal standard. In GNU assembly, a word is two bytes (the 'w' in xorw, which stands for word, means 2 bytes).

Reset the machine (exit QEMU/GDB and start them again). 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.)

x/Nx addr 打印addr地址开始的N个字长的数据值

x/Ni addr 打印addr地址开始的N条指令

(1) BIOS刚进入bootloader时,0x100000地址处的值全为0

(2) bootloader要进入kernel时,因为已经将kernel加载到0x10,0000地址开始的地方,这时该处的值为

(gdb) x/8x 0x100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0x100010: 0x34000004 0x0000b812 0x220f0011 0xc0200fd8

用objdump -f obj/kern/kernel查看到kernel的入口地址是 0x10,000c,所以把断点设在这里就可以了。

 

第三部分:The Kernel


* 使用虚拟内存来解决位置相关问题


Bootloader的链接地址和加载地址相同,而内核却不同。内核的链接过程要复杂的多,这需要专门的一个链接脚本来(kern/kernel.ld)完成这件事。

操作系统通常喜欢链接运行在高的虚拟地址上,将低部分的地址留给其他程序。下个实验中再多解释为什么这样布局。

entry.S中映射前4MB的物理内存,将虚拟地址0x0000,0000~0x0040,0000映射到物理地址0x0000,0000~0x0040,0000上,其实就是前4MB空间直接映射。接着开启分页模式(CR0_PG)。一旦设置了CR0_PG,接下来所有的地址都是虚拟地址,经过虚拟内存硬件转换最终找到实际的物理地址。一个页目录管理4MB的内存映射, entry_pgdir[NPDENTRIES]中将虚拟地址空间[0xf0000000,0xf0400000]和[0x00000000,0x00400000]都映射到物理地址空间[0x00000000,0x00400000]。这样做是必须的,因为接下来要从虚拟低地址跳到高地址时,可以正确的找到物理地址。

 

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 in kern/entry.S, trace into it, and see if you were right.

如下的单步实验,结果是显而易见的。当mov %eax,%cr0开启了分页模式,这之后x/8x 0xf0100000实际上查看的是0x10,0000物理地址上的内容。

0x10001d: mov %cr0,%eax
0x0010001d in ?? ()
(gdb) si
0x100020: or $0x80010001,%eax
0x00100020 in ?? ()
(gdb) si
0x100025: mov %eax,%cr0
0x00100025 in ?? ()
(gdb) x/8x 0xf0100000
0xf0100000: 0xffffffff 0xffffffff 0xffffffff 0xffffffff
0xf0100010: 0xffffffff 0xffffffff 0xffffffff 0xffffffff
(gdb) si
0x100028: mov $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/8x 0xf0100000
0xf0100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0xf0100010: 0x34000004 0x0000b812 0x220f0011 0xc0200fd8

 

如果映射地址不对的话,肯定会出错。

(1) 开启分页之后,取下一条指令就需要经过虚拟地址到物理地址的转换。如果Virtual address [0x00000000,0x00400000] ~ physical address [0x00000000,0x00400000] 这里出错的话,那么取得的下一条指令就不是我们预期的,立即就出错了。

(2) 如果Virtual address [0xf0000000,0xf0400000] ~ physical address [0x00000000,0x00400000]映射出错,那么 jmp *%eax 这条指令就会跳到一个错误的地方。

 

* 格式化输出

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.

Be able to answer the following questions:

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?

 

2. Explain the following from console.c:

1      if (crt_pos >= CRT_SIZE) {
2              int i;
3              memcpy(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      }

这几行代码的目的是实现滚屏功能。当屏幕光标位置crt_pos超过一屏时,则将显存内容向前移动一行,并将最后一行都清空(写成空格)。

 

3. 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:

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指字字符串 "x %d, y %x, z %d\n"

调用时各个参数都会入栈,ap则指向第二个参数"..."在栈中的地址。每次调用va_arg()取一次值,ap就会向栈顶移动,移动距离就看va_arg()的参数了。比如:

va_arg(*ap, unsigned long long);
va_arg(*ap, unsigned long);
va_arg(*ap, unsigned int);

 

4. Run the following code.
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
What is the output? Explain how this output is arrived at in the step-by-step manner of the previous exercise. Here's an ASCII table that maps bytes to characters.
The output depends on that fact that the x86 is little-endian. If the x86 were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value?

He110 World

57616的16进制是0xe110. 0x00646c72分别对应ascii码里的 '\0' 'd' 'l' 'r'

如果大端存储的话,自然要反过来 0x726c6400。但是不需要改57616。

 

 

5. 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);

结果是不确定的,3存于栈中,ap最先指向3这个单元的地址。这里要从ap往后取两个数,第一个是3,第二个就是栈中3上面那一个了。

 

6. 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?

 不知道

 

* The Stack

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?
在entry.S中初始化了内核栈

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

下面这几句是在代码里预留了KSTKSIZE大小的空间作为栈。栈顶是bootstacktop,栈底是bootstack

.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop 
bootstacktop:

 

为了方便,将ebp(base pointer)与栈一起使用。当进入一个c函数时,先将上一个程序的ebp压入栈中,然后将当前esp的值赋给ebp。如果所有的函数都遵守这个规则,则可以根据ebp追踪到函数调用链。显然,这个特性对调试非常有用。

 

posted @ 2012-11-21 11:49  sammei  阅读(1074)  评论(0编辑  收藏  举报