我们现在将开始更详细地研究JOS内核。(最后你会写一些代码!)。与引导加载程序一样,内核从一些汇编语言代码开始,这些代码设置可以使C语言代码正确执行。
操作系统内核通常被链接到非常高的虚拟地址(例如0xf0100000)下运行,以便留下处理器虚拟地址空间的低地址部分供用户程序使用。 在下一个lab中,这种安排的原因将变得更加清晰。
许多机器在地址范围无法达到0xf0100000
,因此我们无法指望能够在那里存储内核。相反,我们将使用处理器的内存管理硬件将虚拟地址0xf0100000
(内核代码期望运行的链接地址)映射到物理地址0x00100000
(引导加载程序将内核加载到物理内存中)。
现在,我们只需映射前4MB的物理内存,这足以让我们启动并运行。 我们使用kern/entrypgdir.c
中手写的,静态初始化的页面目录和页表来完成此操作。 现在,你不必了解其工作原理的细节,只需注意其实现的效果。
实现虚拟地址,有一个很重要的寄存器CR0-PG
;
PG:CR0的位31是分页(Paging)标志。当设置该位时即开启了分页机制;当复位时则禁止分页机制,此时所有线性地址等同于物理地址。在开启这个标志之前必须已经或者同时开启PE标志。即若要启用分页机制,那么PE和PG标志都要置位。
1.使用QEMU和GDB跟踪到JOS内核并停在
movl%eax,%cr0
。 检查内存为0x00100000和0xf0100000。 现在,使用stepi GDB命令单步执行该指令。 再次检查内存为0x00100000和0xf0100000。 确保你了解刚刚发生的事情。
在地址0x100020处,将第31位PG置为1,后写入cr0
中
0x100015: mov $0x118000,%eax
0x10001a: mov %eax,%cr3
0x100020: or $0x80010001,%eax
0x100025: mov %eax,%cr0
在0x00100000内存处没有变化
执行前:
(gdb) x/8x 0x100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0x100010: 0x34000004 0x8000b812 0x220f0011 0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>: 0x00000000 0x00000000 0x00000000 0x00000000
0xf0100010 : 0x00000000 0x00000000 0x00000000 0x00000000
执行后
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0xf0100010 : 0x34000004 0x8000b812 0x220f0011 0xc0200fd8
可以发现,两者内容完全一致,虚拟地址0xf0100000
已经被映射到0x00100000
处了,为什么会出现这种变化?
在修改cr0之前修改了cr3寄存器。将地址0x118000
写入了页目录寄存器,页目录表应该就是存放在地址0x118000
处。其他操作应该是由entry_pgdir
的// Map VA’s [KERNBASE, KERNBASE+4MB) to PA’s [0, 4MB),完成了映射。使得再读取0xf0100000
地址时,自动映射到了0~4M
的某个位置(暂时不清楚)。
CR3是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以4K字节为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。
在entry.S
中说:
The kernel (this code) is linked at address ~(KERNBASE + 1 Meg),
在程序编译后,被链接到高地址处。在kernel.ld
链接脚本文件里指定了。
/* Link the kernel at this address: "." means the current address */
. = 0xF0100000;
但是bootloader 实际把kernel加载到了0x100000的位置
2.注释掉
kern/entry.S
中的movl %eax, %cr0
,再编译执行会出现什么问题。
因为没有开启分页虚拟存储机制,当访问高位地址时,会出现RAM or ROM 越界错误。
激动人心的时刻到了,我们终于到了能对设备进行操作的阶段了,Linus当时为自己能在显示屏上打印出信息而感到十分自豪(尽管他拿给她妹妹看,他妹妹不以为然,哈哈)。能打印出信息,是实现交互的开始,也是我们之后调试的一个重要途径。
大多数人都把printf()这样的函数认为是理所当然的,有时甚至认为它们是C语言的“原语“。但在OS内核中,我们必须自己实现所有I / O.
阅读kern/printf.c
, lib/printfmt.c
, and kern/console.c
三个源代码,理清三者之间的关系。
printf.c
基于 printfmt()
和 kernel console's cputchar()
;我们省略了一小段代码 - 使用“%o”形式的模式打印八进制数所需的代码。 查找并填写此代码片段。
case 'o':
// Replace this with your code.
putch('0', putdat);
num = getuint(&ap, lflag);
base = 8;
goto number;
就是把%u的代码复制一遍,base 改为 8 就差不多了,并不复杂。
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?
printf.c中使用了console.c 中的cputchar
函数,并封装为putch
函数。并以函数形参传递到printfmt.c中的vprintfmt
函数,用于向屏幕上输出一个字符。
- 解释console.c中的一段代码。
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
// 显示字符数超过CRT一屏可显示的字符数
int i;
//清除buf中"第一行"的字符
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
//CRT显示器需要对其用空格擦写才能去掉本来以及显示了的字符。
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
//显示起点退回到最后一行起始
crt_pos -= CRT_COLS;
}
首先理解几个宏定义和函数
memmove
没有理清哪个是源,哪个是目的。 按理解清除第一行的数据,应该第二个是源。即2~n行的数据(CRT_SIZE - CRT_COLS)个,移动到1~n-1行的位置。
- 跟踪执行以下代码,在调用
cprintf()
时,fmt, ap指向什么?
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
在kern/init.c的i386_init()
下加入代码,就可以直接测试;加Lab1_exercise8_3标号的目的是为了在kern/kernel.asm反汇编代码中容易找到添加的代码的位置。可以看到地址在0xf0100080
处
// lab1 Exercise_8
{
cprintf("Lab1_Exercise_8:\n");
int x = 1, y = 3, z = 4;
//
Lab1_exercise8_3:
cprintf("x %d, y %x, z %d\n", x, y, z);
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
}
调试过程fmt=0xf010478d , ap=0xf0118fc4; fmt指向字符串,ap指向栈顶
cprintf (fmt=0xf010478d "x %d, y %x, z %d\n") at kern/printf.c:27
可以看到以上地址处就存了字符串
(gdb) x/s 0xf010478d
0xf010478d: "x %d, y %x, z %d\n"
gdb) si
=> 0xf0102f85 : push %ebp
vcprintf (fmt=0xf010478d "x %d, y %x, z %d\n", ap=0xf0118fc4 "\001")
at kern/printf.c:18
18 {
(gdb) x/16b 0xf0118fc4
0xf0118fc4: 0x01 0x00 0x00 0x00 0x03 0x00 0x00 0x00
0xf0118fcc: 0x04 0x00 0x00 0x00 0x7b 0x47 0x10 0xf0
引用一段Github上大神做的labclpsz/mit-jos-2014的execise8中的一段话。
从这个练习可以看出来,正是因为C函数调用实参的入栈顺序是从右到左的,才使得调用参数个数可变的函数成为可能(且不用显式地指出参数的个数)。但是必须有一个方式来告诉实际调用时传入的参数到底是几个,这个是在格式化字符串中指出的。如果这个格式化字符串指出的参数个数和实际传入的个数不一致,比如说传入的参数比格式化字符串指出的要少,就可能会使用到栈上错误的内存作为传入的参数,编译器必须检查出这样的错误[2]。
4.运行以下代码,输出结果是什么。
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
我的妈呀,调试出来竟然输出了Hello World! 57616的十六进制形式为E110
, 因为是小端机,i的在内存中为0x72,0x6c,0x64,0x00. 对应ASCII为rld\0
- In the following code, what is going to be printed after ‘y=’? (note: the answer is not a specific value.) Why does this happen?
压栈的时候,地址是向低位还是高位增长? 入栈由高位向低位增长(即sp减小),x会先读出3,再出栈,y会读出3内存的”+1“的位置的值并以整型打印。
- 假设GCC改变了它的调用约定,以致于它按声明顺序压栈。 你要如何更改cprintf或其接口,以便仍然可以传递可变数量的参数?
猜想,同时向cprintf传入可变参数个数。 无从验证,暂并不论。
确定内核在什么时候初始化了堆栈,以及堆栈所在内存的确切位置。 内核如何为其堆栈保留空间? 并且在这个保留区域的“end”是堆栈指针最初指向的位置吗?
将值压入堆栈涉及到堆栈指针–,然后将值写入堆栈指针指向的位置。 从堆栈中弹出一个值包括读取堆栈指针指向的值,然后堆栈指针++。
在
obj/kern/kernel.asm
找到test_backtrace
函数,并设置断点。进行调试。此次练习最好使用工具页面提到的QEMU修补版本。 否则,您必须手动将所有断点和内存地址转换为线性地址。
没找到test_backtrace
, 只找到了mon_backtrace
; 占坑----
下载的代码里竟然没有test_backtrace
函数,果断弃坑找了Github上另一位大神的代码。
在另一份代码反汇编的kernel.asm中,找到test_backtrace
函数。
// Test the stack backtrace function (lab 1 only)
test_backtrace(5);
f0100104: c7 04 24 05 00 00 00 movl $0x5,(%esp)
f010010b: e8 30 ff ff ff call f0100040
我们分析下最初两次调用函数test_backtrace
的栈里面的内容:
0x00000000 0x00000000
0xf010ffa0: 0x00000000 0x00000005 0xf010ffc8 0xf0100069
--------------------------------
| ->第一次init 调用test_backtrace
0xf010ffb0: 0x00000004 0x00000005 | 0x00000000 0x00010094
--------------------------------------------|
0xf010ffc0: 0x00010094 0x00010094 0xf010fff8 0xf0100144
0xf010ffd0: 0x00000005
最后一个是init调用函数传入的参数5,倒数第二个为init函数中test_backtrace
的下一行指令地址。 第三行第二个数0x00000005是临时变量x的值,0x00000004是传入test_backtrace
的值,0xf0100069为函数的返回地址。
实现指定的回溯函数
执行结果,打印的第一行反映当前正在执行的函数,即mon_backtrace本身,第二行反映调用mon_backtrace的函数,第三行反映调用该函数的函数,依此类推。 您应该打印所有未完成的堆栈帧。 通过研究kern / entry.S你会发现有一种简单的方法可以判断何时停止。
我滴?呀,用别人的代码真是不好,我发现有的地方(比如mon_backtrace
函数)这个作者已经写好了,我现在都搞不清楚有的函数到底是不是本来就已经实现了。 导致我都看不懂题目了。 我在想,回调函数不是已经实现了吗???
在entry.S函数的中执行了movl $0x0,%ebp # nuke frame pointer
然后就call了init函数,所以函数终止点为ebp == 0x0
;
monitor.c中mon_backtrace
函数:
// 获取寄存器ebp本身的位置
int regebp = read_ebp();
// 获取ebp指向的位置,即ebp中的内容
regebp = *((int *)regebp);
// ebp 最终指向栈的某个位置
int *ebp = (int *)regebp;
cprintf("Stack backtrace:\n");
//If only we haven't pass the stack frame of i386_init
while((int)ebp != 0x0) {
cprintf(" ebp %08x", (int)ebp);
// 返回地址
cprintf(" eip %08x", *(ebp+1));
cprintf(" args");
cprintf(" %08x", *(ebp+2));
cprintf(" %08x", *(ebp+3));
cprintf(" %08x", *(ebp+4));
cprintf(" %08x", *(ebp+5));
cprintf(" %08x\n", *(ebp+6));
// 上一层函数的ebp指针
ebp = (int *)(*ebp);
}
mon_backtrace
函数中调用的read_ebp()
函数声明在 inc/x86.h
中,函数实现
static __inline uint32_t
read_ebp(void)
{
uint32_t ebp;
__asm __volatile("movl %%ebp,%0" : "=r" (ebp));
return ebp;
}
修改堆栈回溯功能,为每个eip显示与该eip对应的函数名称,源文件名和行号。
好像有点复杂,先占个坑。
这个lab真的是做了我整整三天时间,感觉信息量好大,收获挺多,对PC的整个启动流程有了整体上的概念。
但是个人感觉总体还是做得不够细致,很多细节地方都略过了。不过这叫战略性学习,哈哈。
–2019.5.27