找回密码
 骑士注册

QQ登录

微博登录

搜索
❏ 站外平台:

Linux中国开源社区 技术 查看内容

Caffeinated 6.828:实验 1:PC 的引导过程

作者: Mit 译者: LCTT qhwdw

| 2018-06-12 18:08   收藏: 1    

第三部分:内核

我们现在开始去更详细地研究最小的 JOS 内核。(最后你还将写一些代码!)就像引导加载器一样,内核也是从一些汇编语言代码设置一些东西开始的,以便于 C 语言代码可以正确运行。

使用虚拟内存去解决位置依赖问题

前面在你检查引导加载器的链接和加载地址时,它们是完全一样的,但是内核的链接地址(可以通过 objdump 来输出)和它的加载地址之间差别很大。可以回到前面去看一下,以确保你明白我们所讨论的内容。(链接内核比引导加载器更复杂,因此,链接和加载地址都在 kern/kernel.ld 的顶部。)

操作系统内核经常链接和运行在高位的虚拟地址,比如,0xf0100000,为的是给让用户程序去使用处理器的虚拟地址空间的低位部分。至于为什么要这么安排,在下一个实验中我们将会知道。

许多机器在 0xf0100000 处并没有物理地址,因此,我们不能指望在那个位置可以存储内核。相反,我们使用处理器的内存管理硬件去映射虚拟地址 0xf0100000(内核代码打算运行的链接地址)到物理地址 0x00100000(引导加载器将内核加载到内存的物理地址的位置)。通过这种方法,虽然内核的虚拟地址是高位的,离用户程序的地址空间足够远,它将被加载到 PC 的物理内存的 1MB 的位置,只处于 BIOS ROM 之上。这种方法要求 PC 至少要多于 1 MB 的物理内存(以便于物理地址 0x00100000 可以工作),这在上世纪九十年代以后生产的PC 上应该是没有问题的。

实际上,在下一个实验中,我们将映射整个 256 MB 的 PC 的物理地址空间,从物理地址 0x000000000x0fffffff,映射到虚拟地址 0xf00000000xffffffff。你现在就应该明白了为什么 JOS 只能使用物理内存的前 256 MB 的原因了。

现在,我们只映射前 4 MB 的物理内存,它足够我们的内核启动并运行。我们通过在 kern/entrypgdir.c 中手工写入静态初始化的页面目录和页面表就可以实现。现在,你不需要理解它们是如何工作的详细细节,只需要达到目的就行了。将上面的 kern/entry.S 文件中设置 CR0_PG 标志,内存引用就被视为物理地址(严格来说,它们是线性地址,但是,在 boot/boot.S 中设置了一个从线性地址到物理地址的映射标识,我们绝对不能改变它)。一旦 CR0_PG 被设置,内存引用的就是虚拟地址,这个虚拟地址是通过虚拟地址硬件将物理地址转换得到的。entry_pgdir 将把从 0x000000000x00400000 的物理地址范围转换在 0xf00000000xf0400000 的范围内的虚拟地址。任何不在这两个范围之一中的地址都将导致硬件异常,因为,我们还没有设置中断去处理这种情况,这种异常将导致 QEMU 去转储机器状态然后退出。(或者如果你没有在 QEMU 中应用 6.828 专用补丁,将导致 QEMU 无限重启。)

练习 7

使用 QEMU 和 GDB 去跟踪进入到 JOS 内核,然后停止在 movl %eax, %cr0 指令处。检查 0x001000000xf0100000 处的内存。现在使用GDB 的 stepi 命令去单步执行那个指令。再次检查 0x001000000xf0100000 处的内存。确保你能理解这时发生的事情。

新映射建立之后的第一个指令是什么?如果没有映射到位,它将不能正常工作。在 kern/entry.S 中注释掉 movl %eax, %cr0。然后跟踪它,看看你的猜测是否正确。

格式化控制台的输出

大多数人认为像 printf() 这样的函数是天生就有的,有时甚至认为这是 C 语言的 “原语”。但是在操作系统的内核中,我们需要自己去实现所有的 I/O。

通过阅读 kern/printf.clib/printfmt.c、以及 kern/console.c,确保你理解了它们之间的关系。在后面的实验中,你将会明白为什么 printfmt.c 是位于单独的 lib 目录中。

练习 8

我们将省略掉一小部分代码片断 —— 这部分代码片断是使用 ”%o" 模式输出八进制数字所需要的。找到它并填充到这个代码片断中。

然后你就能够回答下列的问题:

  1. 解释 printf.cconsole.c 之间的接口。尤其是,console.c 出口的函数是什么?这个函数是如何被 printf.c 使用的?

  2. console.c 中解释下列的代码:

     if (crt_pos >= CRT_SIZE) {
        int i;
        memcpy(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;
     }
    
  3. 下列的问题你可能需要参考第一节课中的笔记。这些笔记涵盖了 GCC 在 x86 上的调用规则。

    一步一步跟踪下列代码的运行:

     int x = 1, y = 3, z = 4;
     cprintf("x %d, y %x, z %d\n", x, y, z);
    
    1. 在调用 cprintf() 时,fmt 做了些什么?ap 做了些什么?
    2. (按运行顺序)列出 cons_putcva_arg、以及 vcprintf 的调用列表。对于 cons_putc,同时列出它的参数。对于va_arg,列出调用之前和之后的 ap 内容?对于 vcprintf,列出它的两个参数值。
  4. 运行下列代码:

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

    输出是什么?解释如何在前面的练习中一步一步实现这个输出。这是一个 ASCII 表,它是一个字节到字符串的映射表。

    这个输出取决于 x86 是小端法这一事实。如果这个 x86 采用大端法格式,你怎么去设置 i,以产生相同的输出?你需要将 57616 改变为一个不同值吗?

    这是小端法和大端法的描述一个更古怪的描述

  5. 在下列代码中,y= 会输出什么?(注意:这个问题没有确切值)为什么会发生这种情况? cprintf("x=%d y=%d", 3);

  6. 假设修改了 GCC 的调用规则,以便于按声明的次序在栈上推送参数,这样最后的参数就是最后一个推送进去的。那你如何去改变 cprintf 或者它的接口,以便它仍然可以传递数量可变的参数?

在本实验的最后一个练习中,我们将理详细地解释在 x86 中 C 语言是如何使用栈的,以及在这个过程中,我们将写一个新的内核监视函数,这个函数将输出栈的回溯信息:一个保存了指令指针(IP)值的列表,这个列表中有嵌套的 call 指令运行在当前运行点的指针值。

练习 9

搞清楚内核在什么地方初始化栈,以及栈在内存中的准确位置。内核如何为栈保留空间?以及这个保留区域的 “结束” 位置是指向初始化结束后的指针吗?

x86 栈指针(esp 寄存器)指向当前使用的栈的最低位置。在这个区域中那个位置以下的所有部分都是空闲的。给一个栈推送一个值涉及下移栈指针和栈指针指向的位置中写入值。从栈中弹出一个值涉及到从栈指针指向的位置读取值和上移栈指针。在 32 位模式中,栈中仅能保存 32 位值,并且 esp 通常分为四部分。各种 x86 指令,比如,call,是 “硬编码” 去使用栈指针寄存器的。

相比之下,ebp(基指针)寄存器,按软件惯例主要是由栈关联的。在进入一个 C 函数时,函数的前序代码在函数运行期间,通常会通过推送它到栈中来保存前一个函数的基指针,然后拷贝当前的 esp 值到 ebp 中。如果一个程序中的所有函数都遵守这个规则,那么,在程序运行过程中的任何一个给定时间点,通过在 ebp 中保存的指针链和精确确定的函数嵌套调用顺序是如何到达程序中的这个特定的点,就可以通过栈来跟踪回溯。这种跟踪回溯的函数在实践中非常有用,比如,由于给某个函数传递了一个错误的参数,导致一个 assert 失败或者 panic,但是,你并不能确定是谁传递了错误的参数。栈的回溯跟踪可以让你找到这个惹麻烦的函数。

练习 10

要熟悉 x86 上的 C 调用规则,可以在 obj/kern/kernel.asm 文件中找到函数 test_backtrace 的地址,设置一个断点,然后检查在内核启动后,每次调用它时发生了什么。每个递归嵌套的 test_backtrace 函数在栈上推送了多少个词(word),这些词(word)是什么?

上面的练习可以给你提供关于实现栈跟踪回溯函数的一些信息,为实现这个函数,你应该去调用 mon_backtrace()。在 kern/monitor.c 中已经给你提供了这个函数的一个原型。你完全可以在 C 中去使用它,但是,你可能需要在 inc/x86.h 中使用到 read_ebp() 函数。你应该在这个新函数中实现一个到内核监视命令的钩子,以便于用户可以与它交互。

这个跟踪回溯函数将以下面的格式显示一个函数调用列表:

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

输出的第一行列出了当前运行的函数,名字为 mon_backtrace,就是它自己,第二行列出了被 mon_backtrace 调用的函数,第三行列出了另一个被调用的函数,依次类推。你可以输出所有未完成的栈帧。通过研究 kern/entry.S,你可以发现,有一个很容易的方法告诉你何时停止。

在每一行中,ebp 表示了那个函数进入栈的基指针:即,栈指针的位置,它就是函数进入之后,函数的前序代码设置的基指针。eip 值列出的是函数的返回指令指针:当函数返回时,指令地址将控制返回。返回指令指针一般指向 call 指令之后的指令(想一想为什么?)。在 args 之后列出的五个十六进制值是在问题中传递给函数的前五个参数。当然,如果函数调用时传递的参数少于五个,那么,在这里就不会列出全部五个值了。(为什么跟踪回溯代码不能检测到调用时实际上传递了多少个参数?如何去修复这个 “缺陷”?)

下面是在阅读 K&R 的书中的第 5 章中的一些关键点,为了接下来的练习和将来的实验,你应该记住它们。

  • 如果 int *p = (int*)100,那么 (int)p + 1(int)(p + 1) 是不同的数字:前一个是 101,但是第二个是 104。当在一个指针上加一个整数时,就像第二种情况,这个整数将隐式地与指针所指向的对象相乘。
  • p[i] 的定义与 *(p+i) 定义是相同的,都反映了在内存中由 p 指向的第 i 个对象。当对象大于一个字节时,上面的加法规则可以使这个定义正常工作。
  • &p[i](p+i) 是相同的,获取在内存中由 p 指向的第 i 个对象的地址。

虽然大多数 C 程序不需要在指针和整数之间转换,但是操作系统经常做这种转换。不论何时,当你看到一个涉及内存地址的加法时,你要问你自己,你到底是要做一个整数加法还是一个指针加法,以确保做完加法后的值是正确的,而不是相乘后的结果。

练 11

实现一个像上面详细描述的那样的跟踪回溯函数。一定使用与示例中相同的输出格式,否则,将会引发评级脚本的识别混乱。在你认为你做的很好的时候,运行 make grade 这个评级脚本去查看它的输出是否是我们的脚本所期望的结果,如果不是去修改它。你提交了你的实验 1 代码后,我们非常欢迎你将你的跟踪回溯函数的输出格式修改成任何一种你喜欢的格式。

在这时,你的跟踪回溯函数将能够给你提供导致 mon_backtrace() 被运行的,在栈上调用它的函数的地址。但是,在实践中,你经常希望能够知道这个地址相关的函数名字。比如,你可能希望知道是哪个有 Bug 的函数导致了你的内核崩溃。

为帮助你实现这个功能,我们提供了 debuginfo_eip() 函数,它在符号表中查找 eip,然后返回那个地址的调试信息。这个函数定义在 kern/kdebug.c 文件中。

练习 12

修改你的栈跟踪回溯函数,对于每个 eip,显示相关的函数名字、源文件名、以及那个 eip 的行号。

debuginfo_eip 中,__STAB_* 来自哪里?这个问题的答案很长;为帮助你找到答案,下面是你需要做的一些事情:

  • kern/kernel.ld 文件中查找 __STAB_*
  • 运行 i386-jos-elf-objdump -h obj/kern/kernel
  • 运行 i386-jos-elf-objdump -G obj/kern/kernel
  • 运行 i386-jos-elf-gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c, and look at init.s
  • 如果引导加载器在加载二进制内核时,将符号表作为内核的一部分加载进内存中,那么,去查看它。

通过在 stab_binsearch 中插入调用,可以完成在 debuginfo_eip 中通过地址找到行号的功能。

在内核监视中添加一个 backtrace 命令,扩展你实现的 mon_backtrace 的功能,通过调用 debuginfo_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>

每行都给出了文件名和在那个文件中栈帧的 eip 所在的行,紧接着是函数的名字和那个函数的第一个指令到 eip 的偏移量(比如,monitor+106 意味着返回 eip 是从 monitor 开始之后的 106 个字节)。

为防止评级脚本引起混乱,应该将文件和函数名输出在单独的行上。

提示:printf 格式的字符串提供一个易用(尽管有些难理解)的方式去输出非空终止non-null-terminated字符串,就像在 STABS 表中的这些一样。printf("%.*s", length, string) 输出 string 中的最多 length 个字符。查阅 printf 的 man 页面去搞清楚为什么这样工作。

你可以从 backtrace 中找到那些没有的功能。比如,你或者可能看到一个到 monitor() 的调用,但是没有到 runcmd() 中。这是因为编译器的行内(in-lines)函数调用。其它的优化可能导致你看到一些意外的行号。如果你从 GNUMakefile 删除 -O2 参数,backtraces 可能会更有意义(但是你的内核将运行的更慢)。

到此为止, 在 lab 目录中的实验全部完成,使用 git commit 提交你的改变,然后输入 make handin 去提交你的代码。


via: https://sipb.mit.edu/iap/6.828/lab/lab1/

作者:mit 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

123
查看其它分页:
LCTT 译者
qhwdw 🌟 🌟 🌟 🌟 🌟
共计翻译: 121 篇 | 共计贡献: 232
贡献时间:2017-10-31 -> 2018-06-19
访问我的 LCTT 主页 | 在 GitHub 上关注我


最新评论

我也要发表评论

返回顶部

分享到微信

打开微信,点击顶部的“╋”,
使用“扫一扫”将网页分享至微信。