环境
ubuntu 20.04 64系统
本文大部分内容翻译mit6.828 Lab1 part3的内容,原地址点击这里
课程主页:MIT6.828-2018 Fall
正文
本篇是MIT6.828 Lab1 的part3的内容。经过了part2对boot loader的深入研究以及知道了如何去加载一个elf文件,现在我们终于可以来看看JOS的kernel了。像boot loader一样,kernel也是由一些汇编代码开始的,并且汇编代码做好了准备工作,这样才使得C语言代码能够正常的工作。
当你观察boot loader的link address(虚拟地址)和loaded address(物理地址)的时候,会发现他们俩匹配的很好
ps:
这里稍微提一下,可能我们会产生疑问为什么有虚拟地址和物理地址这样的东西。首先一个最直观的好处在这样的机制下,每一个程序都有自己的地址空间,我们只需要将不同的虚拟地址映射(mapping)到不同的物理地址,这样当某些恶心程序想修改某些属于内核地址的时候,经过映射的转换,那些地址并不是真正的内存地址,这样就使得每一个程序在运行的时候相互独立,不会影响到其他程序或者是操作系统。我们根据elf文件提供的虚拟地址和实际地址,将程序加载到elf文件中制定的物理地址,然后那些虚拟地址要映射(mapping)到这些物理地址,就可以正常的运行了。
回归正题,我们先来看下boot loader的地址映射,如下图:
我们可以看到,在boot loader当中,虚拟地址和物理地址刚好是相等的。也就是part3里面说的matched perfectly。回想以下,我们在对boot loader链接的时候,-Ttext指定了0x7c00为链接地址,当bios把boot loader加载到0x7c00后,因为虚拟地址是从0x7c00(可以联想下 org这条汇编语句,他们所达到的效果是一样的)开始的,恰好就一一对应,所以可以正常的运行。
回归part3,但是在kernel的link address和physical address之间由很大的间隔(相当的大)。好我们来看看效果如何,如下图所示:
果然如此,我们看到link address和physical address之间有很大的间隔,关于VirtAddr和PhyAddr的指定,看一下./lab/kernel/kernel.ld文件,里面指定了link address和映射到的Physical address(重申一边,我对linker了解的不多,感兴趣的老铁请看《linker and loader》)。
操作系统长常常将地址连接到内存的高半部分,比如说0xf0100000(就是上图的例子),剩下的很大一块虚拟地址都留给用户程序,至于这样做的结果在下一个lab会很清楚。
很多机器并没有真正的这么多内存,所以我们不能将内核放到这里。因此,我们使用了处理的内存管理硬件来将虚拟地址0xf0100000映射到物理内存的0x100000。这样一来,内核的虚拟内存就留下了很大一块空间来留给用户程序。这样映射的要求我们内存地址要大于1MB,不过在1990年后生产的电脑,都满足这个要求。
实际上,在下一个实验当中,我们将会将低端的256MB的物理地址,也就是0x0到256MB,映射到虚拟地址的0xf000_0000到0xffff_ffff。所以你现在就知道了JOS只需要256MB的内存。
到目前为止,我们只需要其中的4MB,这么点大已经足够了。这些内存的页(在kernel.asm当中通过几行代码已经开启了页式内存管理), 采用了一种比较笨的方法在./kernel/entrypgdir.c当中初始化好了。到目前位置我们不需要理解这些初始化的含义,只要知道它已经是页式内存管理了就行了。在./kernel/entrypgdir.c的entry_pgdir将虚拟地址翻译到物理地址.entry_pgdir将虚拟地址从0xf000_0000到0xf040_0000映射到了物理地址的0x0到0x0040_0000,并且将虚拟地址的0x0到0x0040_0000也映射到了物理地址的0x0到0x0040_0000。这说明,在页内存管理模式下,我们看到的虚拟地址0xf000_0000到0xf040_0000和0x0到0x0040_0000两者的内容应该相同,因为他们映射到了相同的物理地址,这一点很重要,下面的实验部分将会展示这个效果。
接下来是虚拟地址的一个实验:
Exercise 7
使用QEMU和GDB来最终JOS的内核(主要是调试obj/kern/kernel.asm),停在
movl %eax, %cr0
这条语句,查看内存0x0010_0000和0xf010_0000处的内容,接着使用si命令单步调试,查看mov命令执行后的效果。
实验:
执行前
可以看到在执行前,0x0010_0000处的数据就是我们的内核代码,第一个字的内容为:0x1bad_b002,来对照一下obj/kern/kernel.asm的第一条语句,如下图所示(注意这里因为little-endian的缘故,看到的数据和实际上的数据的内容是相反的):
可以看到,在0x0010_0000处正式我们的第一条语句的十六进制码,说明我们已经正确地将内核加载到了内存。另外此时还没有执行mov语句,所以内存0xf010_0000的内容都是空的。接下来,我们单步调试,执行mov语句,结果如下:
好了大功告成了,我们正确的开启了页表功能,可以看到此时0xf010_0000和0x0010_0000处的内容相同。说明此时虚拟地址已经映射正确。此时要想起,我们已经开启了页式内存管理,所以原来的0x0010_0000也不是原来的的物理地址,这里之所以能看到原来的内容,原因就是上面那一行黑体字了,因为我们映射到了相同的物理地址。
阅读kern/printf.c,lib/printfmt.c和kern/console.c,弄清楚他们之间的关系。
Exercise 8
我们省略了一小段代码--使用%o来格式化八进制的数,请在代码中找到并且写上代码实现
并且回答下列问题
- 解释printf.c和console.c它们的interface(这里说的是接口,我没有完全理解,我的理解应该是让我们解释每个函数的作用)。console.c对外提供了什么函数?,这个函数在printf.c当中如何被使用的?
- 解释下面代码的意思
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;
}
- 接下来的问题要看Lecture2 notes才能彻底理解,这些notes描述了关于x86平台上的C calling convention
逐步执行以下的代码
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
回答两个小问题:
- 在调用cprintf()的时候,fmt指向了哪里?
- 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.
- 运行下面的代码
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
输出结果是什么? 逐步调试来解释它的输出结果(这个问题和endian相关)
- 下面代码的输出结果是什么?
cprintf("x=%d y=%d", 3);
不得不说part3的题目多多了,而且做起来也不容易,不过学习路上不能偷懒,开始干活吧~
解释下printf.c和console.c当中各个函数是干嘛的?
说在前面,console.c当中不少和硬件相关的,我没有完全理解,比如说串口(serial port)和并口(parallel port)的初始化以及相关数据的写入,我很多都没有了解,我认为,学习操作系统更应该是关注软件层面的东西。遇到相关的,知道的我说一下,不懂的省略,希望有时间来补充这里的空白吧。
当我们使用cprintf()这个函数的时候,调用顺序是如下的:
cprintf () -> vcprintf() -> vprintfmt() -> vprintfmt() (定义在printfmt.c中)->putch()(定义在printf.c中) -> cputchar() (定义在console.c当中) -> ....(console.c还调用了别的函数)。
下面着重讲几个比较重要的函数
cprint
int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;
va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);
return cnt;
}
这里使用了C语言中的可变参数,这里使用的几个va开头的都是GCC compiler builtin的宏(macros)。这里介绍了这几个宏的意思,在lab1中的这几个宏定义在stdarg.h当中,在这里我们只需要记住一点,va_start,va_arg,va_end都是和处理可变参数有关,而cprintf用到了可变参数,所以这里出现了他们的身影。根据文章描述,va_start的第一个参数是va_list类型的并且指向可选参数的第一个参数,va_start的第二个参数是指向必须参数(required parameters)(并且必要参数要在可选参数之前),可以看到代码确实按照这样的思路来实现的。
After all arguments have been retrieved, va_end resets the pointer to NULL. va_end must be called on each argument list that's initialized with va_start or va_copy before the function returns.
va_end必须要在函数返回前被调用,所以在return cnt前面我们调用了va_end。
vcprintf
int
vcprintf(const char *fmt, va_list ap)
{
int cnt = 0;
vprintfmt((void*)putch, &cnt, fmt, ap);
return cnt;
}
vcprintf是针对格式化符号处理的,putch()是一个函数指针,在vcprintf内调用vprintfmt()格式化字符串结束后,调用putch()来改变屏幕上的光标位置(虽然改变光标位置的真正的函数并不是putch,putch调用了其他函数来实现这个功能)。vprintfmt()中完成了对屏幕内容的输出。
接下来看一下vprintfmt()
原来函数的实现有点长,而且有些代码也不好懂,我选了一点我们最熟悉的。比如说%d打印数字
可以看到case 'd'就是处理打印数字的代码。通过这样就可以知道,vprintfmt()完成了格式化字符串的任务。另外作业也做好了,实现打印八进制的数字,基本思路和十进制的一样,只需要改以下base就行.
case 'd':
num = getint(&ap, lflag);
if ((long long) num < 0) {
putch('-', putdat);
num = -(long long) num;
}
base = 10;
goto number;
// unsigned decimal
case 'u':
num = getuint(&ap, lflag);
base = 10;
goto number;
// (unsigned) octal
case 'o':
// Replace this with your code.
//打印8进制数
num = getuint(&ap, lflag);
base = 8;
goto number;
下面解释下一下putch()函数
static void putch(int ch, int *cnt)
{
cputchar(ch);
*cnt++;
}
//在console.c中
void cputchar(int c)
{
cons_putc(c);
}
//这个在console.c当中
static void cons_putc(int c)
{
//输出到串口(serial port)
serial_putc(c);
//输出到并口(parallel port)
lpt_putc(c);
//输出到屏幕
cga_putc(c);
}
putch每次向屏幕会输出一个字符,cnt用于记录输出的字符个数,putch()调用了cputchar(),虽然参数是一个int类型,实际上每次传入的都是ascii,cputchar()调用cons_putc(),cons_putc()中的cga_putc()才是真正的输出内容到屏幕的函数。
好了现在就可以回答第一个问题了,虽然我们没有查看console.c中所有的函数。但是已经大概知道了如何向屏幕输出内容。console.c中向printf.c提供了cputchar()函数,Printf.c中的putch函数来使用cputchar()完成了输出
第二到问题,解释代码意思
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;
}
这段代码的意思是,当屏幕满的时候,滚屏。屏幕可以显示的是25*80字节的内容。所以if (crt_pos >= CRT_SIZE)
判断当前光标位置是否超过了屏幕。很自然的下面的代码就是为滚屏服务的,memove()
函数就是为了在复制内容。新空出来的一行,以黑底白字,空格填充,最后光标位置减80。这个函数当中的其他部主要处理的都是改变光表位置,比如\n,光标位置+80。
第三问题,逐步调代码
从上面结果可以看到,可以看到把可变参数都压入到栈了,所以我们可以得到结论,ap肯定是指向栈顶的,这样才可以压入参数。那么fmt自然指向的就是前面的字符串了。
第四道题,调试cprintf语句
这道题目非常有意思.废话少说,先看实验结果:
竟然打印出来了Hello world。不过仔细观察以下这里,两个不是字母L,而是数字1。57616=0xe110。
在来看一下World是怎么出现的。这里涉及到little-endian这个问题,在这里我暂时先不仔细的说endian的问题。先记住一点,这个问题会出现在多字节数据或者字(word)存储的时候,上面的无符号数就是一个4字节的数据,当他通过总线写入到内存的时候,是低字节在前,高字节在后,是反着的。然后我们使用了%s来读取,读出来的数据自然也是反着的。在将他转为对应的ascii,r = 114 = 0x72,l = 108 = 0x6c, d = 100 = 0x64。这就产生了He110 World,注意,这个He110是假冒伪劣
第五个问题, In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?
实话说,第五个问题为不知道他的用意是什么,逐步调试到这里也十分麻烦。我说一下为观察到的结果,
在我的电脑上输出结果为:x=3,y=1600,我想当然猜测为什么会出现这样的结果,是因为,y没有压入到栈,所有栈得到的数据是内存中的其他值。
上面一个实验,我们知道了JOS中那些函数完成了向屏幕输出内容,如何格式化输出的内容,并且是如何改变光标位置的,并且草草地知道了几个GCC内置的用于处理可变参数的宏。接下来的内容是和栈相关的,又是比较麻烦的一个part。
栈(Stack)
本个lab最后一个实验就是,我们将会更加详细地探索以下C语言是如何使用x86的栈的,并且我们要写一个非常有用的内核监控函数(kernel monitor function),它会打印目前所执行函数之前的函数的EIP。
Exercise 9
看一看在哪里初始化了内核的栈,并且内核栈初始化在哪个内存地址?内核是如何为它的内核保留栈的?这块区域的哪一端是esp指向的呢?
这个Exercise相对来说还是比较容易的,一个一个回答。
首先栈的初始化是在./lab/kern/entry.S中初始化的,代码如下:
其中,KSTKSIZE在inc/memlayout.h当中,它的大小是40968。在这里,.space*应个和intel语法下的db 用法差不多,内核预留内存空间给栈使用。bootstacktop这个标号就是栈的初始地址,由于在x86当中栈是由高地址向低地址延伸的,所以esp最开始指向bootstacktop,代码实现如下所示:
Exercise 10
为了使得我们对x86 calling convention更加熟悉,找到test_backtrace的地址,并且打一个断点在那里,每次在调用它后发生了什么?栈当中压入了多少数据? 我们推荐使用qemu pachted,MIT推荐使用这个,但是我好像在lab1 没有发现什么问题。
首先来跟踪以下test_backtrace这个函数,这个函数是递归调用的,初始的参数是5,接着4.3.2.1。在这里我代码就不贴了,我就跟踪了test_backtrace(5)到test_backtrace(4)的情况,下面先给反汇编的代码:
void
test_backtrace(int x)
{
f0100040: f3 0f 1e fb endbr32
f0100044: 55 push %ebp ;将原来的ebp亚入,esp = init-4
f0100045: 89 e5 mov %esp,%ebp ; ebp = init-4
f0100047: 56 push %esi ;esp = init-8
f0100048: 53 push %ebx ;esp = init-12
f0100049: e8 8f 01 00 00 call f01001dd <__x86.get_pc_thunk.bx>
f010004e: 81 c3 ba 12 01 00 add $0x112ba,%ebx ;dont konw
f0100054: 8b 75 08 mov 0x8(%ebp),%esi ;ebp+8 = agrs
cprintf("entering test_backtrace %d\n", x);
f0100057: 83 ec 08 sub $0x8,%esp ;空出8字节
f010005a: 56 push %esi ;args压栈
f010005b: 8d 83 18 08 ff ff lea -0xf7e8(%ebx),%eax ;不知道lea命令是干嘛,猜测是获得字符串的地址
f0100061: 50 push %eax ;字符串地址压栈,总共esp变化了16字节
f0100062: e8 37 0a 00 00 call f0100a9e
if (x > 0)
f0100067: 83 c4 10 add $0x10,%esp ;esp+16,恢复了上面cprintf的调用
f010006a: 85 f6 test %esi,%esi
f010006c: 7e 29 jle f0100097 ;条件不成立,跳转mon_backtrace
test_backtrace(x-1);
f010006e: 83 ec 0c sub $0xc,%esp ;空出12字节
f0100071: 8d 46 ff lea -0x1(%esi),%eax ;猜测,原来args在esi,这里做了esi=esi-1,eax=esi
f0100074: 50 push %eax ;eax=4压栈
f0100075: e8 c6 ff ff ff call f0100040 ;递归执行
f010007a: 83 c4 10 add $0x10,%esp
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d\n", x);
f010007d: 83 ec 08 sub $0x8,%esp ;空出两个字节
f0100080: 56 push %esi ;压入esi=5
f0100081: 8d 83 34 08 ff ff lea -0xf7cc(%ebx),%eax
f0100087: 50 push %eax ;压入字符串
f0100088: e8 11 0a 00 00 call f0100a9e ;打印leaving
}
f010008d: 83 c4 10 add $0x10,%esp ;总共压入了16字节,恢复栈,所以add0x10
f0100090: 8d 65 f8 lea -0x8(%ebp),%esp
f0100093: 5b pop %ebx ;test_backtrace执行结束
f0100094: 5e pop %esi ;恢复寄存器内容,这几个寄存器都是callee register
f0100095: 5d pop %ebp ;也是callee register
f0100096: c3 ret ;递归已经结束,从这里返回
mon_backtrace(0, 0, 0);
f0100097: 83 ec 04 sub $0x4,%esp
f010009a: 6a 00 push $0x0 ;mon_backtrace三个参数压栈
f010009c: 6a 00 push $0x0
f010009e: 6a 00 push $0x0
f01000a0: e8 1d 08 00 00 call f01008c2
f01000a5: 83 c4 10 add $0x10,%esp ;因为上面sub 0x04,加上上面三个0x00的亚栈,因此这里add 0x10
f01000a8: eb d3 jmp f010007d ;跳转到打印leaving那一段函数
我的实验目标,看一下test_backtrace(5)到test_backtrace(4) call之前的栈的情况。下面是上面代码的一部分:
在push ebx这条指令结束后。应该是(从高到低):参数(就是数字5),下一条执行的命令的地址(ret需要用),ebp(0xf010_0040的代码),esi,ebx。
在我的实验环境下,此时ebp = 0xf010_fff8,eip=0xf010010d,esi=0x00010094,ebx=0xf0111308。如下图
看一下实际内存当中的信息,这里稍微有点不一样,因为我此时的内存是执行完sub $0x8,%esp后的结果,但是没关系。可以看到前几个内存的内容和我的表格是一一对应的:
下面稍微偷懒,我只记录下对栈操作的时候(例如push)的时候栈的截图:
sub $0x8,%esp
这个不赘述,就在上面。,它这里实际上是让栈直接空了8字节的内容,在上图,一个是0xf010_004e,另外一个是0x0。0xf010_004e是怎么产生的,我没有去逐步调试。
push %esi
认真看上面的代码,并且结合自己的编译出来的boot.asm,我们知道esi存放的是agrs,就是0x05,所以此时栈如下所示:
可以看到0x05已经被压入了。
push %eax
这里eax是字符串的地址,和上面的x一起当作参数给cprintf使用,此时栈内如下,其中0xf010_1b20和0x05都是cprintf的参数:
回想以下,到目前栈减少了4个字节的数据,分别是sub指令,两个push指令,所以cprintf结束后
f0100067: 83 c4 10 add $0x10,%esp ;esp+16,恢复了上面cprintf的调用
f010006a: 85 f6 test %esi,%esi
f010006c: 7e 29 jle f0100097 ;条件不成立,跳转mon_backtrace
调用者恢复栈(C calling convention),此时的栈恢复到了最初情况,如下,仔细对照以下上面点的那个表格:
好继续前进,在f0100075停下,查看一下栈
test_backtrace(x-1);
f010006e: 83 ec 0c sub $0xc,%esp ;空出12字节
f0100071: 8d 46 ff lea -0x1(%esi),%eax ;猜测,原来args在esi,这里做了esi=esi-1,eax=esi
f0100074: 50 push %eax ;eax=4压栈
f0100075: e8 c6 ff ff ff call f0100040 ;递归执行
栈的情况如下所示:
看上面代码,sub $0xc,%esp空出12字节,接着push %eax ;压入参数4,看上图,我们的4被压入了。成功了,如果你此时在按下si,就去执行test_backtrace(4)了。
下面对test_traceback(5)的寄存器做一个总结,这个对于待会的作业非常重要:
如上图所示,ebp持有的是之前ebp的地址,esp是本次test_backtrace()的参数底。上面一共8个int的长度,所以是32字节。现在我们可以回答问题了,每次调用test_backtrace()压入了32字节的数据。不得不说,这一串真的挺麻烦的,花了不少时间在研究。
回到正文,下面继续介绍part3的内容(我要开始继续翻译了哈哈哈)。
以上的练习,他应该给了你一些信息,并且你需要根据这些信息实现一个mon_backtrace()。函数的声明已经在kern/monitor.c当中了。
mon_backtrace()打印出来的信息应该如下所示:
Stack backtrace:
ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...
每一行都包括了ebp,eip,和args。ebp应该是在进入后函数后的ebp寄存器的值,就是栈指针的在进入函数以及完成开场白后的位置。eip的值是返回指针的值(也就是被ret所使用的地址)。返回指针通常指向call之后的下一条语句(这个很好理解吧,只有指向下一条指令才可以继续运行)。最后,5个十六进制数就是5个需要传递的参数。如果函数所需要的参数少于5个,当然并不是这5个所有的参数都有用(通过上面的观察,确实好几个参数没用),遗留问题:为什么不能够从代码中获得到底有多少个参数呢?如何解决这个问题呢?
Exercise 11
根据上面的输出例子实现mon_backtrace()函数。请使用上面的输出格式,否这grade script无法通过,如果你觉得你做好了就用make grade来测试下你是不是作对了。
代码实现:
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// 获取寄存器ebp本身的位置
int regebp = read_ebp(); //获得ebp寄存器的内容
int* ebp = (int*)regebp; //将内容转为指针类型,然后就可以获得需要的参数了
while(*ebp != 0) {
cprintf("ebp:%08x ",*ebp);
cprintf("eip:%08x ",*(ebp+1));
cprintf("args:%08x ",*(ebp+2));
cprintf("%08x ",*(ebp+3));
cprintf("%08x ",*(ebp+4));
cprintf("%08x ",*(ebp+5));
cprintf("%08x \n",*(ebp+6));
ebp = (int *)(*ebp);
}
return 0;
}
注意看之前的那个寄存器总结图,ebp内的内容就是一个地址,因为我们执行了mov esp,ebp命令。所以思路很简单,先获得ebp的值,就得到了栈单元的地址。然后在根据上面的图,就可以计算处各个参数的值了。效果如下:
唉,就这么一点简单的代码,花了我差不多一天的时间在调试,最后把它写出来,属实不容易。在最后,我还有一个Exercise 12没有实现。先暂时这样吧,草草看了一下Exercise 12,Exercise 12是在Exercise 11的基础上增加一点功能,我暂时没做,留到有时间在做吧,我想Exercise 11已经让我们收获很多了,我们知道了C calling convention,并且根据此知道了如何获得之前的eip寄存器内容。