Part 3: Kernel
这部分自然是读kernel并写一些代码。
Using virtual memory to work around position dependence
一般来说操作系统内核都会被链接并运行在最高的虚地址空间,将较低的虚地址空间留给用户程序使用。就像在之前的例子中VMA=0xf0100000
,而LMA=0x00100000
. 显然不存在0xf0100000
对应的物理地址,因此实际上是将该虚拟地址映射到0x00100000
对应的物理地址,即BIOS上方的物理地址。下一个lab将把从0x00000000
到0x0fffffff
的物理地址映射到从0xf0000000
到0xffffffff
虚拟地址(这是JOS只能用256MB物理内存的原因)。
现在则只需要映射最开始4MB的物理内存。这一映射目前还是手动完成的。当设置了CR0_PG
标志位之后,enrty_pgdir
将从0xf0000000
到0xf0400000
和从0x00000000
到0x00400000
的虚拟地址映射到从0x00000000
到0x00400000
的物理地址,任何其它虚拟地址访问会出现错误。
Exercise 7 要求使用QEMU和GDB研究一下JOS kernel. 在movl %eax, %cr0
处设置断点并检查内存地址为0x00100000
和0xf0100000
的内存内容。接着用GDB单步调试,再次检查相同内存地址的内容。
回答以下问题:如果虚拟地址到物理地址映射不存在的话,映射指令之后的哪一条指令会出问题?
b *0x100025 #Set a breakpoint when executing movl %eax, %cr0
c #Continue execution until breakpoint
Breakpoint 1, 0x00100025 in ?? ()
(gdb) x/8x 0x00100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0x100010: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>: 0x00000000 0x00000000 0x00000000 0x00000000
0xf0100010 <entry+4>: 0x00000000 0x00000000 0x00000000 0x00000000
(gdb) stepi
=> 0x100028: mov $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/8x 0x00100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0x100010: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8
(gdb) x/8x 0xf0100000
0xf0100000 <_start+4026531828>: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0xf0100010 <entry+4>: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8
运行之后,页表已经加载进来了。高的虚拟地址已经映射到低的物理地址上去了。注释掉mov指令后,跳转指令jmp *%eax
会崩溃。
Formatted Printing to the Console
这部分主要说kernel怎样实现所有的I/O操作。
首先要读一下 kern/printf.c
, lib/printfmt.c
, 和kern/console.c
, 并做一下Exercise 8.
Exercise 8 要求补全代码,打印%o
格式的八进制数(可以照搬十进制的部分)。
num = getuint(&ap, lflag);
base = 8;
goto number;
这部分还需要看代码回答以下几个问题:
- 解释
printf.c
和console.c
之间的接口。特别的,console.c
提供了什么函数?它是如何被printf.c
使用的?
printf.c
使用了console.c
提供的cputchar()
函数。由于cprintf()
中参数长度是边长的,需要用一个参数列表va_list
和格式字符串来决定参数格数。之后调用vcprintf()
函数,这个函数根据格式字符串从va_list
中读取参数,并使用cputchar()
将参数打印出来。 - 解释
console.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;
}
作用是当整个屏幕写满的时候,把第二行到最后一行的内容上移一行,把最后一行清空,把开始位置指向最后一行的开头。
3. 单步跟踪以下代码的执行:
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
回答以下两个问题
- 调用
cprintf()
时fmt和ap分别指向什么?
fmt指向格式字符串,ap指向x的地址。 - 按照执行顺序依次列举对
cons_putc
,va_arg
,vcprintf
的调用。列举cons_putc
调用时的参数,列举va_arg
调用前后ap指向,列举vcprintf
调用时两个参数的值。
使用gdb,根据反汇编文件,在进入函数cons_putc
,vcprintf
,cprintf()
的入口处设置断点,并使用bt
(backtrace)命令查看函数调用信息。由于va_arg
函数已经被内联优化,可以在进入vcprintf
函数后为变量ap设置watchpoint. 运行结果如下:
Breakpoint 1, vcprintf (fmt=0xf0101aa0 "x %d, y %x, z %d\n", ap=0xf010ffc4 "\001") at kern/printf.c:18
Breakpoint 2, cons_putc (c=120) at kern/console.c:434
Breakpoint 2, cons_putc (c=32) at kern/console.c:434
Breakpoint 2, cons_putc (c=49) at kern/console.c:434
Breakpoint 2, cons_putc (c=44) at kern/console.c:434
Breakpoint 2, cons_putc (c=32) at kern/console.c:434
Breakpoint 2, cons_putc (c=121) at kern/console.c:434
Breakpoint 2, cons_putc (c=32) at kern/console.c:434
Breakpoint 2, cons_putc (c=51) at kern/console.c:434
Breakpoint 2, cons_putc (c=44) at kern/console.c:434
Breakpoint 2, cons_putc (c=32) at kern/console.c:434
Breakpoint 2, cons_putc (c=122) at kern/console.c:434
Breakpoint 2, cons_putc (c=32) at kern/console.c:434
Breakpoint 2, cons_putc (c=52) at kern/console.c:434
Breakpoint 2, cons_putc (c=10) at kern/console.c:434
输出中看不到ap的变化。虽然已经为ap设置了watchpoint,ap的变化应当是后面比前面加四,指向下一个地址,但是不知道内联会影响结果watch point的输出嘛?
- 运行以下代码:
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
代码输出是什么?一步一步解释输出是如何得到的?如果x86是大端法,需要如何设置i来得到同样的输出?是否需要把57616改成一个不一样的值?
可以把这段代码插在i386_init()
之中,然后重新make
.可以看到输出是:
He110 World
同样使用gdb,运行结果如下:
Breakpoint 2, cprintf (fmt=0xf0101a97 "H%x Wo%s") at kern/printf.c:27
Breakpoint 3, vcprintf (fmt=0xf0101a97 "H%x Wo%s", ap=0xf010ffd4 <incomplete sequence \341>) at kern/printf.c:18
Breakpoint 4, cons_putc (c=72) at kern/console.c:434
Breakpoint 4, cons_putc (c=101) at kern/console.c:434
Breakpoint 4, cons_putc (c=49) at kern/console.c:434
Breakpoint 4, cons_putc (c=49) at kern/console.c:434
Breakpoint 4, cons_putc (c=48) at kern/console.c:434
Breakpoint 4, cons_putc (c=32) at kern/console.c:434
Breakpoint 4, cons_putc (c=87) at kern/console.c:434
Breakpoint 4, cons_putc (c=111) at kern/console.c:434
Breakpoint 4, cons_putc (c=114) at kern/console.c:434
Breakpoint 4, cons_putc (c=108) at kern/console.c:434
Breakpoint 4, cons_putc (c=100) at kern/console.c:434
大端法的话i应当改为0x726c6400,57616不需要改动,因为它就是一个数字,和大端法小端法无关。
- 下面代码中"y="之后会输出什么? (注意回答不是一个特定的值)这是如何发生的?
cprintf("x=%d y=%d", 3);
因为这个函数没有边界检查,默认是3这个数对应的地址后面一个位置的数。若3对应的地址为x,那么第二个输出指向的地址就是x+4。这是一个未定义行为,将输出x+4开始的四个字节的内容。
6. 如果GCC改变了它的调用例程,按照声明顺序将参数放在栈里,即最后一个参数最后压栈。那么要如何修改cprintf
来让它接受边长数量的参数?
这样的话把参数倒过来定义,需要把格式字符串放在最后,参数列表也需要反过来写。
The Stack
这部分要求写一个kernel monitor function来打印栈的backtrace
Exercise 9 要求找到kernel初始化栈的位置以及栈在内存中的位置并回答问题:
- kernel如何为栈保留空间?
- 栈指针指向保留空间的那一端?
在kern/entry.S
中为kernel初始化了位置:
# Set the stack pointer
movl $(bootstacktop),%esp
根据反汇编文件中mov $0xf0110000,%esp
确定起始位置为0xf0110000
.
kernel在.data段给栈留了KSTKSIZE
大小的空间为8*page_size
.
栈指针指向栈顶,栈向低地址延申。
Exercise 10 要求x86上C语言的调用例程。找到test_backtrace
函数的起始地址,在那里设置断点,并检查kernel启动之后该函数每次被调用时发生的事情。回答每次test_backtrace
每一次递归嵌套向栈里压了多少个32位的word以及这些word都是什么?
首先根据反汇编文件确定test_backtrace
函数的入口地址为0xf0100040
,在这个位置设置断点,根据gdb信息:
Breakpoint 1, test_backtrace (x=5) at kern/init.c:13
13 {
(gdb) i r
eax 0x0 0
ecx 0x3d4 980
edx 0x3d5 981
ebx 0xf0111308 -267316472
esp 0xf010ffcc 0xf010ffcc
ebp 0xf010fff8 0xf010fff8
esi 0x10094 65684
edi 0x0 0
eip 0xf0100040 0xf0100040 <test_backtrace>
eflags 0x46 [ PF ZF ]
cs 0x8 8
ss 0x10 16
ds 0x10 16
es 0x10 16
fs 0x10 16
gs 0x10 16
(gdb) c
Continuing.
=> 0xf0100040 <test_backtrace>: push %ebp
Breakpoint 1, test_backtrace (x=4) at kern/init.c:13
13 {
(gdb) i r
eax 0x4 4
ecx 0x3d4 980
edx 0x3d5 981
ebx 0xf0111308 -267316472
esp 0xf010ffac 0xf010ffac
ebp 0xf010ffc8 0xf010ffc8
esi 0x5 5
edi 0x0 0
eip 0xf0100040 0xf0100040 <test_backtrace>
eflags 0x96 [ PF AF SF ]
cs 0x8 8
ss 0x10 16
ds 0x10 16
es 0x10 16
fs 0x10 16
gs 0x10 16
可知压入了8个32位的word。打印以下栈顶的这8个word:
(gdb) x/8x 0xf010ffac
0xf010ffac: 0xf01000a1 0x00000004 0x00000005 0xf010fff8
0xf010ffbc: 0xf010004a 0xf0111308 0x00010094 0xf010fff8
经过一番汇编语言的比对,可以确定这8个word自顶向下一次是:
%ebp
%esi
%ebx
Return address of __x86.get_pc_thunk.bx
%esi
%eax
value of x
Return address of test_backtrace(x-1)
Exercise 11 要求实现把backtrace function. 实现之前首先要明白这个调用链是怎样的。当前函数栈中自顶向上依次为如下内容:
...
argument
argument
return address of current function
%esp---->base pointer of previous function (also previous %ebp)
此时%ebp中存放的是此时%esp的值。
如果发生了对下一个函数的调用,那么下一个函数在入口处会将刚才的%ebp寄存器中的值压栈,然后在%ebp寄存器中记录当前%esp寄存器的值,仍然保持刚才所说的栈的结构。于是我们可以通过对base pointer像列表一样不断地访问来找到每个函数入口处栈指针所指的位置,函数的返回地址,以及参数内容。
在了解了%ebp的调用逻辑之后,完善backtrace函数就非常简单了(注意在打印的时候需要使用%08x
补齐高位的0),代码如下:
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
uint32_t* ebp = (uint32_t*)read_ebp();
cprintf("Stack backtrace:\n");
while (ebp)
{
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp, *(ebp+1),*(ebp+2),*(ebp+3),*(ebp+4),*(ebp+5),*(ebp+6));
ebp = (uint32_t*)(*ebp);
}
return 0;
}
Exercise 12要求补全函数debuginfo_eip()
. 这个函数在符号表中查询%eip
并返回关于那个位置的debugging信息。具体而言就是插入对stab_binsearch
的调用来找到对应地址的行数,在monitor中添加新的backtrace功能,扩展之前的mon_backtrace
函数,调用debuginfo_eip
函数并按照给定格式打印栈帧信息。
首先修改commands
数组内容。
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
{ "backtrace","Display stack backtrace information", mon_backtrace},
};
接着补全函数debuginfo_eip()
:
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);//Search in text segment lines.
if (lline<=rline) info->eip_line = stabs[lline].n_desc;
else
{
return -1;
}
最后修改mon_backtrace
函数:
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
struct Eipdebuginfo info;
uint32_t* ebp = (uint32_t*)read_ebp();
cprintf("Stack backtrace:\n");
while (ebp)
{
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp, *(ebp+1),*(ebp+2),*(ebp+3),*(ebp+4),*(ebp+5),*(ebp+6));
if(debuginfo_eip(*(ebp+1), &info)==0)
{
cprintf(" %s:%d: ",info.eip_file,info.eip_line);
cprintf("%.*s", info.eip_fn_namelen, info.eip_fn_name); //eip_fn_name may end without zero so it's necessary to specify the maximal length to be printed out.
cprintf("+%d\n",((*(ebp+1))-(uint32_t)info.eip_fn_addr));
}
ebp = (uint32_t*)(*ebp);
}
return 0;
}
这样我们的lab就做完了。
最后还有三个坑要补:
- 如何在内联优化之下定位某个被优化的变量
2. 最后一个exercise讲了什么故事
3. lab1的challenge
先回答以下问题2.
首先它讲了一个关于stabs的故事。 stabs取名于symbol table strings. GNU C 编译器把c源代码编译成汇编语言.s文件, .s文件然后被翻译成.o文件,.o文件被链接成可执行文件。如果编译的时候加了-g 参数 , gcc 会添加额外的调试信息到.s文件中。 这些调试信息最终被带到可执行文件中,最为ELF文件中.stab
节存在
/* Include debugging information in kernel memory */
.stab : {
PROVIDE(__STAB_BEGIN__ = .);
*(.stab);
PROVIDE(__STAB_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}
在kernel.ld
文件中的这段代码让链接器为.stab
节分配了内存空间,然后将.stab
节一并加载到内核内存之中。运行以下命令可以看到预留了这一节的空间。
objdump -h obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00001b59 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 00000754 f0101b60 00101b60 00002b60 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 00003cfd f01022b4 001022b4 000032b4 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 0000197e f0105fb1 00105fb1 00006fb1 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 00009300 f0108000 00108000 00009000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .got 00000008 f0111300 00111300 00012300 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .got.plt 0000000c f0111308 00111308 00012308 2**2
CONTENTS, ALLOC, LOAD, DATA
7 .data.rel.local 00001000 f0112000 00112000 00013000 2**12
CONTENTS, ALLOC, LOAD, DATA
8 .data.rel.ro.local 00000060 f0113000 00113000 00014000 2**5
CONTENTS, ALLOC, LOAD, DATA
9 .bss 00000648 f0113060 00113060 00014060 2**5
CONTENTS, ALLOC, LOAD, DATA
10 .comment 00000029 00000000 00000000 000146a8 2**0
CONTENTS, READONLY
汇编器把stabs信息添加到目标文件的符号表和字串表中。链接器再把所有目标文件中的符号表和字串表合并成一个。调试器会最终使用可执行文件中的这两个表最为调试信息的来源。
可以在inc/stab.h
文件中查看stab结构体的定义:
// Entries in the STABS table are formatted as follows.
struct Stab {
uint32_t n_strx; // index into string table of name
uint8_t n_type; // type of symbol
uint8_t n_other; // misc info (usually empty)
uint16_t n_desc; // description field
uintptr_t n_value; // value of symbol
};
运行以下命令可以看到kernel中.stab
的文件内容:
objdump -G obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Contents of .stab section:
Symnum n_type n_othr n_desc n_value n_strx String
-1 HdrSym 0 1300 0000197d 1
0 SO 0 0 f0100000 1 {standard input}
1 SOL 0 0 f010000c 18 kern/entry.S
2 SLINE 0 44 f010000c 0
3 SLINE 0 57 f0100015 0
4 SLINE 0 58 f010001a 0
5 SLINE 0 60 f010001d 0
6 SLINE 0 61 f0100020 0
7 SLINE 0 62 f0100025 0
8 SLINE 0 67 f0100028 0
9 SLINE 0 68 f010002d 0
10 SLINE 0 74 f010002f 0
11 SLINE 0 77 f0100034 0
12 SLINE 0 80 f0100039 0
13 SLINE 0 83 f010003e 0
14 SO 0 2 f0100040 31 kern/entrypgdir.c
15 OPT 0 0 00000000 49 gcc2_compiled.
...
而debuginfo_eip()
函数实现的就是在.stab
节中z找包含%eip
的源文件,再在源文件中找到包含%eip
的函数,再在函数中找到包含%eip
的行,将相应信息记录在Eipdebuginfo
结构体之中。到此这个故事差不多讲完了。
再来看一下lab1的challenge:
Challenge要求实现彩色打印。
首先找到console.c
中的控制color的部分
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;
可以猜测变量c低八位以上的部分都是控制颜色的部分。于是我们可以在inc/color_printer.h
新添加一个控制颜色的全局变量COLOR
:
#include <inc/types.h>
uint32_t COLOR;
修改刚刚console.c
中的部分代码:
// if COLOR is not defined, then use black on white
if(!COLOR) COLOR = 0x0700;
if (!(c & ~0xFF))
c |= COLOR; //else use COLOR as CGA output color.
可以使用%C
控制颜色:
switch (ch = *(unsigned char *) fmt++) {
//flags to control the output color
case 'C':
num = getint(&ap, lflag);
COLOR = (uint32_t)num;
break;
...
同时记得在一次输出结束之后将COLOR
重置:
while (1) {
while ((ch = *(unsigned char *) fmt++) != '%') {
if (ch == '\0')
{
COLOR = 0x0700; //reset the COLOR as black on white.
return;
}
putch(ch, putdat);
}
...
如此就实现了彩色打印,challenge完结撒花。
问题1暂时还没有想到什么好的方法,只能通过读反汇编文件,确定内联之后的地址,然后在该地址处设置breakpoint实现。可以以后在lab讨论课上进一步深入研究。