MIT_JOS 学习笔记_Lab1.2
内核
操作系统的内核往往运行在高的虚拟地址空间, 使低的地址空间留给用户程序.上一节我们知道, 内核运行的入口物理地址是 0x0010000c
, 这个地址是在 0~ 4MB 地址空间范围内的, 这个空间完全足够内核开始运行. 内核的虚拟地址是内核希望执行的地址, 但是内存并没有那么大的空间, 所以内核实际执行的地址是物理地址. 内核的虚拟地址是一个高地址, 是怎么映射到 0x00000000
到 0x00400000
这个低的物理地址空间的呢?
我们知道, boot loader 也是在实模式下运行的, 在 ./boot/boot.S
中启用了保护模式, 但是 boot loader 的物理地址与虚拟地址是相同的, 这里提一下, boot loader 的虚拟地址与物理地址相同是编译操作系统的时候链接器决定的.
所以操作系统最开始使用的页表是 kern/entrypgdir.c
中的静态映射表, 将虚拟地址映射到这个地址空间上的. 这个静态映射表只能映射一部分的内存空间, 也就是将 0xf0000000
到 0xf0400000
与 0x00000000
through 0x00400000
都映射到物理地址为 0x00000000
through 0x00400000
的地址空间中. 所以我们在 ./kern/entry.S
中要做的就是确定页目录的物理地址与, 将这个地址存入 CR3 寄存器, 这个寄存器的作用就是存储页目录的物理地址, 然后开启页表机制, 这样就可以使用 kern/entrypgdir.c
中的 entry_pgtable
将 4MB 的虚拟地址映射到物理地址上, 那么 kern/entrypgdir.c
又是如何实现的呢?
#include <inc/mmu.h>
#include <inc/memlayout.h>
pte_t entry_pgtable[NPTENTRIES];
// 页表, 表示的是从 0x00000000 到 0x00400000 这 4MB 物理内存对应的页表
__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT] = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
// KERNBASE>>PDXSHIFT是得到页目录号, 该页目录号对应的页表也是 entry_ptable这个页表
// 所以, 页目录号为 0, 与页目录号为 3C00 对应的页表都是 entry_pgdir, 最后是加上写使能, 与页表中的存在标志
};
// Entry 0 of the page table maps to physical page 0, entry 1 tophysical page 1, etc.
// 页表的项到物理页的地址, 静态声明的页表
__attribute__((__aligned__(PGSIZE)))
pte_t entry_pgtable[NPTENTRIES] = {
0x000000 | PTE_P | PTE_W,
}
我们在回过头来看 ./kern/entry.S
的内容就十分明显了:
.globl _start
_start = RELOC(entry)
# 将 entry 的虚拟地址变成物理地址, entry 本身也是个虚拟地址(这是因为entry 不在bootloader 中, 而是在内核中)
# Load the physical address of entry_pgdir into cr3. entry_pgdir is defined in entrypgdir.c.
movl $(RELOC(entry_pgdir)), %eax
# 需要注意的是这里, entry_pgdir 只有虚拟地址, 并且他不在 boot loader里面, 而是在内核里面, 对应的虚拟地址是在 0xf0000000 之后
movl %eax, %cr3
# 将 页目录的物理地址存入 cr3寄存器
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0
# Now paging is enabled, but we're still running at a low EIP
# (why is this okay?). Jump up above KERNBASE before entering
# C code. 这是因为 0~4MB 的虚拟地址也映射到 0~4M 的物理地址
# 需要注意的是,内核在 0x0010000c, 而bootloader在0x7c00 ~ 0x7dff, 所以并不会冲突.
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
inkern/entry.S
, trace into it, and see if you were right.
根据上面的代码, 这个问题就很显然了, 在
movl %eax, %cr0
之前, 未启用页表机制, 0xf0100000 的数据要么内存没有那么大, 要么未初始化, 启用页表机制后, 我们在查询内存时候的 memory references 就是虚拟地址通过页表机制将其转化为的物理地址, 此时 0xf0100000 和 0x00100000 地址处的数据变成相同的了.
控制台格式化输出
这一部分我分成两部分讲, 从控制台的角度讲一下操作系统的 I/O 设备, 以及缓冲区, 这部分主要在 console.c
文件中,
控制台主要是输入与输出设备, 在JOS 中输入是键盘(keyboard)设备, 输出是显示器(CGA)设备, 输入与输出的方式是通过端口与缓冲区来实现的, 我们先看一下缓冲区的实现,
#define CONSBUFSIZE 512
static struct {
uint8_t buf[CONSBUFSIZE];
// 大小是 512 字节
uint32_t rpos;
// 从缓冲区读数据的时候的位置
uint32_t wpos;
// 向缓冲区写数据的时候的位置,也就是写入的个数与位置
} cons;
然后介绍一下控制台使用的端口有哪些, 这些端口大部分其实是硬件端口寄存器, 也就是硬件读入与输出设备.
/***** Serial I/O code *****/
// 串口的地址, 注意 x86 的 I/O 编址是独立编址
#define COM1 0x3F8
#define COM_RX 0 // In: Receive buffer (DLAB=0)
#define COM_TX 0 // Out: Transmit buffer (DLAB=0)
#define COM_DLL 0 // Out: Divisor Latch Low (DLAB=1)
#define COM_DLM 1 // Out: Divisor Latch High (DLAB=1)
#define COM_IER 1 // Out: Interrupt Enable Register
#define COM_IER_RDI 0x01 // Enable receiver data interrupt
#define COM_IIR 2 // In: Interrupt ID Register
#define COM_FCR 2 // Out: FIFO Control Register
#define COM_LCR 3 // Out: Line Control Register
#define COM_LCR_DLAB 0x80 // Divisor latch access bit
#define COM_LCR_WLEN8 0x03 // Wordlength: 8 bits
#define COM_MCR 4 // Out: Modem Control Register
#define COM_MCR_RTS 0x02 // RTS complement
#define COM_MCR_DTR 0x01 // DTR complement
#define COM_MCR_OUT2 0x08 // Out2 complement
#define COM_LSR 5 // In: Line Status Register
#define COM_LSR_DATA 0x01 // Data available
#define COM_LSR_TXRDY 0x20 // Transmit buffer avail
#define COM_LSR_TSRE 0x40 // Transmitter off
具体的这些端口的用法在 一些常用端口的地址与用途 中提到了. 这里需要注意的是第一个地址, 0x3F8
. 我们看一下网站的介绍
03F8 w serial port, transmitter holding register, which contains the character to be sent. Bit 0 is sent first.
bit 7-0 data bits when DLAB=0 (Divisor Latch Access Bit)
r receiver buffer register, which contains the received character
Bit 0 is received first
bit 7-0 data bits when DLAB=0 (Divisor Latch Access Bit)
r/w divisor latch low byte when DLAB=1
这一个端口有三种用途, 所以在上面的宏定义中使用了三种符号定义着同一个端口, 表示三种用途.
我们看一下下面这个例子:
// 返回从 COM1 端口读入的数据
// COM_LSR 寄存器的 bit 0 = 1 表示 data ready. 对应上面端口介绍的 In: Receive buffer (DLAB=0).
// a complete incoming character has been received and sent to the receiver buffer register.
static int serial_proc_data(void)
{
if (!(inb(COM1+COM_LSR) & COM_LSR_DATA))
return -1;
return inb(COM1+COM_RX);
}
// called by device interrupt routines to feed input characters into the circular console input buffer.
// 将输入字符放入缓冲区
// 这里的设备中断是指, 比如说正在运行其他程序, 键盘开始输入, 需要中断其他程序
static void
cons_intr(int (*proc)(void))
{
int c;
// 这个函数的变量是一个函数的返回值, 这样写的目的是, 比如说对于键盘输入, 下面的while 循环就需要不断地从函数 proc 中获取返回值
while ((c = (*proc)()) != -1) {
if (c == 0)
continue;
// cons 是我们所说的缓冲区
cons.buf[cons.wpos++] = c;
if (cons.wpos == CONSBUFSIZE)
cons.wpos = 0;
}
}
// 将读入的数据放入输入串口
void
serial_intr(void)
{
if (serial_exists)
// 这里是 serial_proc_data 不断的读入数据, 然后 cons_intr 不断地将数据放入缓冲区
cons_intr(serial_proc_data);
}
上面使用的 COM1 + COM_LSR
的地址为 0xFD
, 这是一个只可读的端口. 作用就是上面代码的注释部分. 需要注意的是, 源码中所说的串口作为缓冲区, (串口的本质就是一个缓冲区), 真正输出的位置是在控制台. 也就是说, 目前我们完成了从端口读入数据到缓冲区, 还需要完成的功能是, 从键盘获取端口, 以及将缓冲区的数据传输到输出端口, 并通过 (CGA) 设备显示.
控制台输出
控制台的输出主要分为两个部分, 控制台光标的获取与从光标位置输出一个字符. 这一部分的代码比较多, 中间部分的代码就不详细说明了,
// 控制台的输出地址
static unsigned addr_6845;
// 控制台的输出内容
static uint16_t *crt_buf;
// 光标的位置, 输出缓冲字符的个数
static uint16_t crt_pos;
static void
cga_init(void)
{
volatile uint16_t *cp;
uint16_t was;
unsigned pos;
// 这个 cp 也是控制台的输出地址, 相当于一个输出的缓冲区
cp = (uint16_t*) (KERNBASE + CGA_BUF);
was = *cp;
*cp = (uint16_t) 0xA55A;
if (*cp != 0xA55A) {
cp = (uint16_t*) (KERNBASE + MONO_BUF);
addr_6845 = MONO_BASE;
} else {
*cp = was;
addr_6845 = CGA_BASE;
}
/* Extract cursor location */
outb(addr_6845, 14);
pos = inb(addr_6845 + 1) << 8;
outb(addr_6845, 15);
pos |= inb(addr_6845 + 1);
crt_buf = (uint16_t*) cp;
crt_pos = pos;
}
上面最重要的是最后两部分, cp
是根据不同的端口情况等计算出来的控制台输出缓冲区, 最后将输出的控制台内容指向这个缓冲区, 就得到控制台的输出了, 而最后光标的位置就在 crt_pos 处, 获取位置之后在控制台输出一个字符, 注意这个字符是来自于输出串口, 而不是内存.
// 从光标处输出一个字符
static void
cga_putc(int c)
{
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;
// 改变输出背景颜色
/*
这里省略了一些转义字符的输出, 是一种功能性输出
*/
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
// 位置超过了屏幕大小
int i;
// crt_buf + CRT_COLS 表示添加一行的数目, CRT_COLS 表示的是列的个数
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
// crt_buf + CRT_COLS 表示添加一行的数目, CRT_COLS 表示的是列的个数
// 将所有的行往前移动一行
// 下面是将最后一行换成空格, 黑色的底
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
// 更新光标的位置
}
// 移动光标
/* move that little blinky thing */
outb(addr_6845, 14);
outb(addr_6845 + 1, crt_pos >> 8);
outb(addr_6845, 15);
outb(addr_6845 + 1, crt_pos);
}
以上就是控制台的内容, 这是从输出的角度来看 I/O 设备的, 接下来是键盘的内容, 从输入的角度看 I/O 设备.
/* Get data from the keyboard. If we finish a character, return it. Else 0.
* Return -1 if no data.
*/
static int
kbd_proc_data(void)
{
int c;
uint8_t stat, data;
static uint32_t shift;
// 得到键盘控制器的状态
stat = inb(KBSTATP);
// 如果键盘控制器的数据在缓冲区
if ((stat & KBS_DIB) == 0)
return -1;
// Ignore data from mouse.
if (stat & KBS_TERR)
return -1;
// 从键盘数据寄存器读取数据
data = inb(KBDATAP);
// 中间省略大部分内容, 不是很重要的
// Process special keys
// Ctrl-Alt-Del: reboot
if (!(~shift & (CTL | ALT)) && c == KEY_DEL) {
cprintf("Rebooting!\n");
outb(0x92, 0x3); // courtesy of Chris Frost
}
return c;
}
其实这部分内容本质也调用接口, 返回数据, 输入与输出讲完, 下面再讲一下完整的 I/O 输入与输出的过程, 输入过程:
// return the next input character from the console, or 0 if none waiting
int cons_getc(void)
{
int c;
// poll for any pending input characters,
// so that this function works even when interrupts are disabled
// (e.g., when called from the kernel monitor).
serial_intr();
// 将 COM 端口读入的数据放入输入串口
kbd_intr();
// 将键盘的输入放入缓冲区
// grab the next character from the input buffer.
// 从缓冲区读数据
if (cons.rpos != cons.wpos) {
c = cons.buf[cons.rpos++];
if (cons.rpos == CONSBUFSIZE)
cons.rpos = 0;
return c;
// 返回控制台(键盘)的输入
}
return 0;
}
对于输出的过程就是:
// output a character to the console
static void
cons_putc(int c)
{
// 将输出放入输出串口
serial_putc(c);
// 串口并行化
lpt_putc(c);
// 从光标处输出一个字符
cga_putc(c);
}
输出的格式化
在此之前, 必须要讲一下 C语言中可变参数传参, 这是 C语言的一个库宏 va_arg()
, 对于固定参数的函数, 在调用的时候, 会将栈指针向下移动, 将参数压入栈顶端, 然后在进入函数后取出, 其实对于可变参数, 本质上也是一样的. 宏定义的代码是:
#define va_arg(ap, type) __builtin_va_arg(ap, type)
参数
- ap -- 这是一个 va_list 类型的对象,存储了有关额外参数和检索状态的信息。该对象应在第一次调用 va_arg 之前通过调用 va_start 进行初始化。
- type -- 这是一个类型名称。该类型名称是作为扩展自该宏的表达式的类型来使用的。
返回值
该宏返回下一个额外的参数,是一个类型为 type 的表达式。例如 va_arg(ap, int)
, 就返回一个 int 类型的参数.
使用这个的目的是, 在 printf 函数中参数往往不止一个, 所以要用多个参数方式决定输出, 下面的函数中 va_list ap
就是一个 va_list 对象, 我们需要知道他的位置是在这个函数的栈的参数部分.
void
vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
{
// putch 是简单的cputchar(),然后对已经输出的字符个数进行统计:
// putch 是控制台输出函数
// putdat 是输出最后一个字符的指针
register const char *p;
register int ch, err;
unsigned long long num;
int base, lflag, width, precision, altflag;
char padc;
while (1) {
while ((ch = *(unsigned char *) fmt++) != '%') {
if (ch == '\0')
return;
putch(ch, putdat);
// 将 % 前面的全部输出到控制台
// 将 ch 输出到控制台, putdat 是指向记录输出的个数的指针
}
// Process a %-escape sequence
// 处理一系列的 % 的过程
padc = ' ';
width = -1;
precision = -1;
lflag = 0;
altflag = 0;
// Alt 的 Flag
reswitch:
switch (ch = *(unsigned char *) fmt++) {
// flag to pad on the right, 左对齐
case '-':
padc = '-';
goto reswitch;
// flag to pad with 0's instead of spaces, 前置零
case '0':
padc = '0';
goto reswitch;
// width field
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
for (precision = 0; ; ++fmt) {
precision = precision * 10 + ch - '0';
ch = *fmt;
if (ch < '0' || ch > '9')
break;
}
goto process_precision;
case '*':
precision = va_arg(ap, int);
goto process_precision;
case '.':
if (width < 0)
width = 0;
goto reswitch;
case '#':
altflag = 1;
goto reswitch;
process_precision:
if (width < 0)
width = precision, precision = -1;
goto reswitch;
// long flag (doubled for long long)
case 'l':
// long 类型flag ++
lflag++;
goto reswitch;
// 上面这些都是一些没有意义的标志, 所以需要再读取一个字符标志
// character
case 'c':
putch(va_arg(ap, int), putdat);
break;
// error message
case 'e':
err = va_arg(ap, int);
if (err < 0)
err = -err;
if (err >= MAXERROR || (p = error_string[err]) == NULL)
printfmt(putch, putdat, "error %d", err);
else
printfmt(putch, putdat, "%s", p);
break;
// string
case 's':
if ((p = va_arg(ap, char *)) == NULL)
p = "(null)";
if (width > 0 && padc != '-')
for (width -= strnlen(p, precision); width > 0; width--)
putch(padc, putdat);
for (; (ch = *p++) != '\0' && (precision < 0 || --precision >= 0); width--)
if (altflag && (ch < ' ' || ch > '~'))
putch('?', putdat);
else
putch(ch, putdat);
for (; width > 0; width--)
putch(' ', putdat);
break;
// (signed) decimal
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.
num = getuint(&ap, lflag);
base = 8;
goto number;
break;
// pointer
case 'p':
putch('0', putdat);
putch('x', putdat);
num = (unsigned long long)
(uintptr_t) va_arg(ap, void *);
base = 16;
goto number;
// (unsigned) hexadecimal
case 'x':
num = getuint(&ap, lflag);
base = 16;
number:
printnum(putch, putdat, num, base, width, padc);
break;
// escaped '%' character
case '%':
putch(ch, putdat);
break;
// unrecognized escape sequence - just print it literally
default:
putch('%', putdat);
for (fmt--; fmt[-1] != '%'; fmt--)
/* do nothing */;
break;
}
}
}
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.
这一步根据上面的代码很容易得出, 和16进制对比即可. 下面回答一下后面的问题:
Explain the interface between
printf.c
andconsole.c
. Specifically, what function doesconsole.c
export? How is this function used byprintf.c
?
这个很显然, 在 printf.c
文件中写的很清楚
static void
putch(int ch, int *cnt)
{
cputchar(ch);
// 向控制台输出一个字符
*cnt++;
// 指向最近输出字符的指针
}
int
vcprintf(const char *fmt, va_list ap)
{
int cnt = 0;
// 指定vprintfmt的字符输出函数putch()
vprintfmt((void*)putch, &cnt, fmt, ap);
return cnt;
}
第二个问题前面代码部分解释过了,
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 doesfmt
point? To what doesap
point?- List (in order of execution) each call to
cons_putc
,va_arg
, andvcprintf
. Forcons_putc
, list its argument as well. Forva_arg
, list whatap
points to before and after the call. Forvcprintf
list the values of its two arguments.
根据我们前面所解释的 C语言的多参数调用, fmt
是指向字符串 "x %d, y %x, z %d\n"
的指针, ap
是一个对象指针, 这个对象是参数对象, 由于前面字符串中的声明符号, 例如 %d 这些与后面的变量是一一对应的, 所以 ap
中的参数也是一一对应的. 因此 ap
是指向栈顶的那一个指针, call 之后就指向下一个参数. 注意, 在 GCC 中, 函数调用时的参数压栈顺序是与声明的顺序相反的, 所以 ap 指针会向上移动. 也就是栈中从上往下存储的是, z, y, x
.
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.
尝试之后的结果很明显, 输出是 "He110 World" , 怎么来的也很明显, 需要注意的是 0x00646c72
, 在 x86 架构中是小端存储的, 也就是说从高到低存储了 00646c72
, 而输出 %s 是从低到高读取的, 其实是这样的
00 | 64 | 6c | 72 |
如果是%d 输出就会从 00 之前输出, 因为 int 是四字节, 就是这个范围, 而 %s 是根据字符输出的, 所以每次读两个字节, 也就是从 72 到 6c 到 64 再到 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?cprintf("x=%d y=%d", 3);
这里 x = 3 输出之后, 因为 printf 的 ap 指针从 3 再往上移动一位, 这一指针所指的数据可能就是 ESP 寄存器存储的栈顶了.
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?
相当于转返了参数调用时候的压栈顺序, 所以需要改变上面的 ap 指针的移动方向, 这个方向可能定义在 va_start 函数中.
深入理解栈
这里先看一下虚拟内存分布:
/*
* Virtual memory map: Permissions
* kernel/user
*
* 4 Gig --------> +------------------------------+
* | | RW/--
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* : . :
* : . :
* : . :
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
* | | RW/--
* | Remapped Physical Memory | RW/--
* | | RW/--
* KERNBASE, ----> +------------------------------+ 0xf0000000 --+
* KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| |
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* | CPU1's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| PTSIZE
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* : . : |
* : . : |
* MMIOLIM ------> +------------------------------+ 0xefc00000 --+
* | Memory-mapped I/O | RW/-- PTSIZE
* ULIM, MMIOBASE --> +------------------------------+ 0xef800000
* | Cur. Page Table (User R-) | R-/R- PTSIZE
* UVPT ----> +------------------------------+ 0xef400000
* | RO PAGES | R-/R- PTSIZE
* UPAGES ----> +------------------------------+ 0xef000000
* | RO ENVS | R-/R- PTSIZE
* UTOP,UENVS ------> +------------------------------+ 0xeec00000
* UXSTACKTOP -/ | User Exception Stack | RW/RW PGSIZE
* +------------------------------+ 0xeebff000
* | Empty Memory (*) | --/-- PGSIZE
* USTACKTOP ---> +------------------------------+ 0xeebfe000
* | Normal User Stack | RW/RW PGSIZE
* +------------------------------+ 0xeebfd000
* | |
* | |
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* . .
* . .
* . .
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
* | Program Data & Heap |
* UTEXT --------> +------------------------------+ 0x00800000
* PFTEMP -------> | Empty Memory (*) | PTSIZE
* | |
* UTEMP --------> +------------------------------+ 0x00400000 --+
* | Empty Memory (*) | |
* | - - - - - - - - - - - - - - -| |
* | User STAB Data (optional) | PTSIZE
* USTABDATA ----> +------------------------------+ 0x00200000 |
* | Empty Memory (*) | |
* 0 ------------> +------------------------------+ --+
*
* (*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
* "Empty Memory" is normally unmapped, but user programs may map pages
* there if desired. JOS user programs map pages temporarily at UTEMP.
*/
这一部分的解释主要在 /inc/memlayout.c
文件中, 首先我们要知道一句话, 虚拟空间是由操作系统决定的, 与ELF文件类似. Windows 和 Linux 程序不能夸操作系统运行的原因就是 ELF 文件格式不同, 就算相同, 虚拟空间的划分不同, 也不能正确运行. 我们在设计操作系统的同时, 就会设计在该操作系统下运行的程序(内核 与 用户)的虚拟空间. 内核的虚拟空间地址与用户往往是隔开的, 还有一点就是每个用户进程都会 copy 内核页表,也就会知道内核虚拟空间的结构.
从上面的表可以得出的直接结论是, 内核程序与用户程序运行的位置在虚拟空间的不同位置, KERNBASE 我们并不陌生, 在 boot loader 的过程中我们将内核地址减去的就是这个 KERNBASE, 所以这个节点往上就是内核的代码段, 将会被映射成物理地址, 再往下看:
KSTACKTOP* 表示内核栈的开头, 之后我们会看到内核栈与用户栈在虚拟空间不同的位置, 内核栈的大小KSTKSIZE 在 entry.S 里面, 在 GDB 里面调试可以获得, 这里 ebp 寄存器是栈基地址寄存器, 表示一个函数开始运行时栈的基地址, esp 是栈顶寄存器, 是不断向下的.
(gdb) b kern/entry.S : 80
Breakpoint 1 at 0xf010002f: file kern/entry.S, line 80.
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0xf010002f <relocated>: mov $0x0,%ebp
Breakpoint 1, relocated () at kern/entry.S:80
80 movl $0x0,%ebp # nuke frame pointer
(gdb) si
=> 0xf0100034 <relocated+5>: mov $0xf0110000,%esp
relocated () at kern/entry.S:83
83 movl $(bootstacktop),%esp
(gdb) b *0xf0100076
Breakpoint 2 at 0xf01
对于 x86 的寄存器已经不用多介绍了, 所以内核栈的大小就是 esp 的位置减去 ebp, 所以大小是 32KB.
然后我们从地址高处向下看, 到达了 MMIOLIM, 表示的是内存映射 I/O 的结尾, 可以看出, 内存映射 I/O 分配的空间大小为 4MB, 一个页表的大小:
// Memory-mapped IO.
#define MMIOLIM (KSTACKTOP - PTSIZE)
#define MMIOBASE (MMIOLIM - PTSIZE)
下面的一部分是用户的页表, UVPT 表示当前进程页表的基地址, 下面一部分是 UPAGES 开始的页表的副本. 再往下的 UENVS 是全局虚拟环境结构的副本, 再往下是用户空间, 用户空间的数据段是由栈与堆构成的, 其余的内容就不赘述了. 很容易理解.
Exercise 10. To become familiar with the C calling conventions on the x86, find the address of the
test_backtrace
function inobj/kern/kernel.asm
, set a breakpoint there, and examine what happens each time it gets called after the kernel starts. How many 32-bit words does each recursive nesting level oftest_backtrace
push on the stack, and what are those words?
其实这部分直接看源码就可以理解, 在实验的时候, 我们调用两次看一下有什么不同,
从 C语言的角度来说, 这是个递归函数:
// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
cprintf("entering test_backtrace %d\n", x);
if (x > 0)
test_backtrace(x-1);
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d\n", x);
}
在 obj/kern/kernel.asm
文件中, 我们找到 test_backtrace
的汇编代码可以得到, 在递归调用的时候执行的代码段为:
# test_backtrace 开始的位置, 这条指令的地址是 f0100040,
f0100040: 55 push %ebp
f0100041: 89 e5 mov %esp,%ebp
f0100043: 56 push %esi
f0100044: 53 push %ebx
f0100045: e8 5b 01 00 00 call f01001a5 <\_\_x86.get_pc_thunk.bx>
f010004a: 81 c3 be 12 01 00 add $0x112be,%ebx
f0100050: 8b 75 08 mov 0x8(%ebp),%esi
# 调用 cprintf 函数
f0100053: 83 ec 08 sub $0x8,%esp
f0100056: 56 push %esi
f0100057: 8d 83 18 07 ff ff lea -0xf8e8(%ebx),%eax
f010005d: 50 push %eax
f010005e: e8 cf 09 00 00 call f0100a32 <cprintf>
# 从 cprintf 返回的时候, %esp 寄存器回到调用之前
f0100063: 83 c4 10 add $0x10,%esp
#
f0100066: 85 f6 test %esi,%esi
f0100068: 7f 2b jg f0100095 <test\_backtrace+0x55>
# 调用 mon_backtrace 函数
f010006a: 83 ec 04 sub $0x4,%esp
f010006d: 6a 00 push $0x0
f010006f: 6a 00 push $0x0
f0100071: 6a 00 push $0x0
f0100073: e8 f4 07 00 00 call f010086c <mon\_backtrace>
f0100078: 83 c4 10 add $0x10,%esp
# 再次调用 cprintf 函数
f010007b: 83 ec 08 sub $0x8,%esp
f010007e: 56 push %esi
f010007f: 8d 83 34 07 ff ff lea -0xf8cc(%ebx),%eax
f0100085: 50 push %eax
f0100086: e8 a7 09 00 00 call f0100a32 <cprintf>
}
f010008b: 83 c4 10 add $0x10,%esp
# 这里是 test_backtrace 函数的结尾, 我们需要 pop 寄存器,
f010008e: 8d 65 f8 lea -0x8(%ebp),%esp
f0100091: 5b pop %ebx
f0100092: 5e pop %esi
f0100093: 5d pop %ebp
f0100094: c3 ret
# 如果不是 mon_backtrace 函数, 递归进行 test_backtrace 函数
# 将 %eax(x-1)进栈, 这里第一行是为了每次申请栈空间16位字节对齐, 因为只是push %eax 只占了4字节
f0100095: 83 ec 0c sub $0xc,%esp
f0100098: 8d 46 ff lea -0x1(%esi),%eax
f010009b: 50 push %eax
f010009c: e8 9f ff ff ff call f0100040 <test\_backtrace>
f01000a1: 83 c4 10 add $0x10,%esp
# 这里是对应 C语言代码的第二个 cprintf("leaving test_backtrace %d\n", x);
f01000a4: eb d5 jmp f010007b <test\_backtrace+0x3b>
这里我们将每一次函数的调用分离开, 每次其实都是一个压栈(push)与出栈(pop)的过程, 以调用一次 mon_backtrace
函数为例,
# 调用 mon_backtrace 函数
f010006a: 83 ec 04 sub $0x4,%esp
f010006d: 6a 00 push $0x0
f010006f: 6a 00 push $0x0
f0100071: 6a 00 push $0x0
f0100073: e8 f4 07 00 00 call f010086c <mon\_backtrace>
f0100078: 83 c4 10 add $0x10,%esp
需要注意的是, 每次函数调用结束恢复栈指针的时候, 使用的都是 add $0x10,%esp
, 因为在分配栈的时候, 是以 16 字节为单位的. 例如 mon_backtrace
函数调用时, 压栈了三个 0, 一共 12 字节, 但是在此之前, 我们使用 sub $0x4,%esp
使得实际上栈下降了 16 字节. 在 test_backtrace
中实际上做了两次push, 分别是调用printf 函数之前, 和之后, 我们可以看到是,
push %eip
f0100040: 55 push %ebp
f0100041: 89 e5 mov %esp,%ebp
f0100043: 56 push %esi
f0100044: 53 push %ebx
.........
f0100095: 83 ec 0c sub $0xc,%esp
f0100098: 8d 46 ff lea -0x1(%esi),%eax
f010009b: 50 push %eax
所以一共实际上是 32 字节.
对于汇编语言, mov %esp,%ebp
可以看做是函数开始的标志, 因为 ebp 标志了函数栈的 Top, esp 是栈底, 所以在进入函数之前的指令为:
# 在进入之前, 需要将 eip 存入栈底, 但是这个栈是调用函数栈
test_backtrace
f0100040: 55 push %ebp # 存入 ebp 寄存器
f0100041: 89 e5 mov %esp,%ebp
# 所以在 ebp 的上面有两个前一个函数栈的内容, 按照从下往上的顺序是 ebp 自己, 和 eip
Exercise 11. Implement the backtrace function as specified above. Use the same format as in the example, since otherwise the grading script will be confused. When you think you have it working right, run make grade to see if its output conforms to what our grading script expects, and fix it if it doesn't. After you have handed in your Lab 1 code, you are welcome to change the output format of the backtrace function any way you like
需要注意的是:
Finally, the five hex values listed after
args
are the first five arguments to the function in question, which would have been pushed on the stack just before the function was called
这里需要注意题目是什么意思, 是打印在调用函数的时候栈中存储的数据. 我们知道此时栈中存储的数据格式如下:
我们可以通过 (uint32_t *)read_ebp();
来获得当前函数的 ebp, 然后例如对于 当前函数mon_backtrace
我们可以看到它在栈的最顶部的数据,(栈顶在下面), 然后网上的数据分别是 0, 0, 0, ... 还有一些寄存器的值, 从一个 %ebp 到上一个 %ebp 是一个函数, 然后 %ebp存储的是上一个函数栈的基地址, 所以 ebp = (uint32_t *)ebp[0];
就可以获得上一个函数栈的基地址. 因此实现的代码如下.
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
//通过 read_ebp()函数获取当前函数的 %ebp, ebp是一个指针(地址),这个地址存储的是上一个函数的 ebp,
uint32_t *ebp = (uint32_t *)read_ebp();
uint32_t *eip = (uint32_t *)ebp[1];
// ebp 与 eip 的值
uint32_t args[5], i;
for (i = 0; i < 5; i++)
args[i] = ebp[i + 2];
// 参数的位置还在 eip 上面
cprintf("Stack_backtrace:\n");
while (ebp != NULL)
{
cprintf(" ebp:0x%08x eip:0x%08x args:0x%08x 0x%08x 0x%08x 0x%08x 0x%08x\n", ebp, eip, args[0], args[1], args[2], args[3], args[4]);
ebp = (uint32_t *)ebp[0];
eip = (uint32_t *)ebp[1];
for (i = 0; i < 5; i++)
args[i] = ebp[i + 2];
}
return 0;
}
我们可以看到输出如下:
Stack backtrace:
ebp f010ff18 eip f0100078 args 00000000 00000000 00000000 f010004a f0111308
ebp f010ff38 eip f01000a1 args 00000000 00000001 f010ff78 f010004a f0111308
ebp f010ff58 eip f01000a1 args 00000001 00000002 f010ff98 f010004a f0111308
ebp f010ff78 eip f01000a1 args 00000002 00000003 f010ffb8 f010004a f0111308
ebp f010ff98 eip f01000a1 args 00000003 00000004 00000000 f010004a f0111308
ebp f010ffb8 eip f01000a1 args 00000004 00000005 00000000 f010004a f0111308
ebp f010ffd8 eip f01000dd args 00000005 00001aac f010fff8 f01000bd 00000000
ebp f010fff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003
根据这个ebp的地址我们也验证了exercise 10 中, 每一个函数 test_backtrace
栈的大小为 32 字节, 然后就是例如, 我们输出的 args 第一个是 (x-1)
, 第二个 x
来自哪里呢? 他就是我们在 cprintf
函数执行后栈的遗留数据,.