内核
操作系统的内核往往运行在高的虚拟地址空间, 使低的地址空间留给用户程序.上一节我们知道, 内核运行的入口物理地址是 0x0010000c
, 这个地址是在 0~ 4MB 地址空间范围内的, 这个空间完全足够内核开始运行. 内核的虚拟地址是内核希望执行的地址, 但是内存并没有那么大的空间, 所以内核实际执行的地址是物理地址. 内核的虚拟地址是一个高地址, 是怎么映射到 0x00000000
到 0x00400000
这个低的物理地址空间的呢? 我们知道, boot loader 也是在实模式下运行的, 在 ./boot/boot.S
中启用了保护模式, 但是 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 本身也是个虚拟地址,
# 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.
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
文件中,
先来介绍一下控制台使用的端口有哪些:
/***** 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.
// 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);
}
// 将读入的数据放入输入串口
void
serial_intr(void)
{
if (serial_exists)
cons_intr(serial_proc_data);
}
上面使用的 COM1 + COM_LSR
的地址为 0xFD
, 这是一个只可读的端口. 作用就是上面代码的注释部分. 需要注意的是, 源码中所说的串口作为缓冲区, (串口的本质就是一个缓冲区), 真正输出的位置是在控制台.
// 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.buf[cons.wpos++] = c;
if (cons.wpos == CONSBUFSIZE)
cons.wpos = 0;
}
}
这个缓冲区的定义为:
#define CONSBUFSIZE 512
static struct {
uint8_t buf[CONSBUFSIZE];
// 大小是 512 字节
uint32_t rpos;
// 从缓冲区读数据的时候的位置
uint32_t wpos;
// 向缓冲区写数据的时候的位置,也就是写入的个数与位置
} cons;
以上部分是串口以及缓冲区的部分. 这是数据的中间部分, 也许现在还不是很清楚串口的作用, 之后会具体说明, 下面我们先说明一下控制台与键盘输入,
控制台输出
控制台的输出主要分为两个部分, 控制台光标的获取与从光标位置输出一个字符. 这一部分的代码比较多, 中间部分的代码就不详细说明了,
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!
");
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 函数中参数往往不止一个, 所以要用多个参数方式决定输出,
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 == ' ')
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++) != ' ' && (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 ", 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
"
的指针, ap
是一个对象指针, 这个对象是参数对象, 由于前面字符串中的声明符号, 例如 %d 这些与后面的变量是一一对应的, 所以 ap
中的参数也是一一对应的. 因此 ap
是指向栈顶的那一个指针, call 之后就指向下一个参数. 注意, 在 GCC 中, 函数调用时的参数压栈顺序是与声明的顺序相反的, 所以 ap 指针会向上移动.
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 文件的时候可以知道, 每个进程都有自己的虚拟空间, 而且对于程序而言, 数据段, 代码段, 以及额外段的划分都是在虚拟空间中的. 学过编译的都知道, 虚拟空间本质是不存在的, 我们常说的虚拟存储系统, 比如说硬盘与虚拟空间并没有关系, 也就是说, 虚拟存储系统上并没有存储虚拟空间, 因为虚拟空间本身被程序划分了, 程序在编译的过程中决定了虚拟空间的划分, 举个例子, 一个 .c 程序在编译成一个汇编程序的时候, 汇编程序中使用的就是虚拟地址, 比如说他会确定栈的大小, 以及参数在栈中的位置, 所以在虚拟磁盘上存储的是一个完整的程序. 这个程序加载到物理内存的时候, 虚拟地址会转变为物理地址, 最后在物理内存上运行.
从上面的表可以得出的直接结论是, 内核程序与用户程序运行的位置在虚拟空间的不同位置, KERNBASE 我们并不陌生, 在 boot loader 的过程中我们将内核地址减去的就是这个 KERNBASE, 所以这个节点往上就是内核的代码段, 将会被映射成物理地址, 再往下看:
KSTACKTOP* 表示内核栈的开头, 之后我们会看到内核栈与用户栈在虚拟空间不同的位置, 内核栈的大小KSTKSIZE 在 entry.S 里面, 在 GDB 里面调试可以获得,
(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
", x);
if (x > 0)
test_backtrace(x-1);
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d
", x);
}
在 obj/kern/kernel.asm
文件中, 我们找到 test_backtrace
的汇编代码可以得到, 在递归调用的时候执行的代码段为:
// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
f0100040: 55 push %ebp # 存入 ebp 寄存器
f0100041: 89 e5 mov %esp,%ebp
f0100043: 56 push %esi
f0100044: 53 push %ebx
f0100045: e8 72 01 00 00 call f01001bc <__x86.get_pc_thunk.bx>
f010004a: 81 c3 be 12 01 00 add $0x112be,%ebx
f0100050: 8b 75 08 mov 0x8(%ebp),%esi
cprintf("entering test_backtrace %d
", x);
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 e6 09 00 00 call f0100a49 <cprintf>
if (x > 0)
f0100063: 83 c4 10 add $0x10,%esp
f0100066: 85 f6 test %esi,%esi
f0100068: 7f 2b jg f0100095 <test_backtrace+0x55>
test_backtrace(x-1);
# 跳转到下面的 test_backtrace(x-1)
test_backtrace(x-1);
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
f01000a4: eb d5 jmp f010007b <test_backtrace+0x3b>
这一段有 4 次 push,其中,在printf的过程中,调用完printf函数后,栈指针回到调用前的位置, 最后是 call 下一个的时候,会首先将%eip push入栈,在上述的计算中, 在(x>0)
时, 栈的地址变化为 4+4+4+16+4, 在(x<0)
时,地址变化为 4+4+4+12+4+4, 所以一共是32bytes
对于汇编语言, 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
也就是说, 这五个参数空间是静态的, 有可能有的函数不足五个参数. 对于 Exercise 11 的代码实现就很简单了:
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
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:
");
while (ebp != NULL)
{
cprintf(" ebp:0x%08x eip:0x%08x args:0x%08x 0x%08x 0x%08x 0x%08x 0x%08x
", 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;
}
通过符号表 Debug
调试器中的符号表
Stabs 是程序的一种信息格式, 用于在调试器中描述程序. 在 GNU 中使用“ -g”选项,GCC在.s文件中放入其他调试信息,这些信息由汇编器和链接器稍作转换,并传递到最终的可执行文件中。这些调试信息描述了源文件的功能,例如行号,变量的类型和范围以及函数名称,参数和范围。对于某些目标文件格式,调试信息被封装在称为stab(符号表)指令的汇编程序指令中,该指令散布在生成的代码中。 Stabs 是a.out和XCOFF目标文件格式的调试信息的本机格式。GNU工具还可以以COFF和ECOFF对象文件格式产生 stabs。默认情况下汇编器将stab中的信息添加到要构建的.o文件的符号表和字符串表中的符号信息中。 链接器将.o文件合并为一个可执行文件,其中包含一个符号表和一个字符串表。 调试器使用可执行文件中的符号和字符串表作为有关程序的调试信息的来源。
符号表的基本格式
stab汇编程序指令的总体格式有三种 .stabs
(string), .stabn
(number), .stabd
(dot). ,按stab的第一个单词进行区分。 伪指令的名称描述了以下四个可能的数据字段的组合:
.stabs "string",type,other,desc,value
.stabn type,other,desc,value
.stabd type,other,desc
.stabx "string",value,type,sdb-type
对于.stan
和.stand
,没有字符串, 对于.stabd
,值字段是隐式的,并具有当前文件位置的值。对于.stabx
,sdb-type
字段未用于stabs,并且始终可以设置为零。 另一个字段几乎总是未使用,可以设置为零。
然后回头看一下 /kern/kdebug.c
的文件的内容, 主要是两个函数, 第一个函数我们只要知道功能是什么就可以了,
Given an instruction address, this function finds the single stab entry of type 'type' that contains that address.
//
static void stab_binsearch(const struct Stab *stabs, int *region_left, int *region_right, int type, uintptr_t addr)
// 参数的说明
// stabs 是符号表
// *region_left 是查找的左边地址, *region_right 是查找的右边地址
// type 是类型, 比如说函数, 或者 .c文件
// addr 是要查询的地址
Exercise 12. Modify your stack backtrace function to display, for each
eip
, the function name, source file name, and line number corresponding to thateip
.
首先看一下 stab 的结构体的定义:
// Entries in the STABS table are formatted as follows.
struct Stab {
uint32_t ; // 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
};
/*
stabstr 是对应的字符串数组
n_strx 是字符串索引,这里是对于文件名来说, 函数名来说, 是存储字符串数组的下标(偏移)
n_type 是符号类型,FUN指函数名,SLINE指在text段中的行号
n_othr 目前没被使用,其值固定为0
n_desc 表示在文件中的行号
n_value 表示地址。特别要注意的是,这里只有FUN类型的符号的地址是绝对地址,SLINE符号的地址是偏移量,
其实际地址为函数入口地址加上偏移量。比如第3行的含义是地址f01000b8(=0xf01000a6+0x00000012)对应文件第34行。
*/
完成 /kern/kdebug.c
的代码,
if (lfun <= rfun) {
// stabs[lfun] points to the function name
// in the string table, but check bounds just in case.
if (stabs[lfun].n_strx < stabstr_end - stabstr)
info->eip_fn_name = stabstr + stabs[lfun].n_strx;
info->eip_fn_addr = stabs[lfun].n_value;
// 被调用函数的地址
addr -= info->eip_fn_addr;
// Search within the function definition for the line number.
lline = lfun;
rline = rfun;
} else {
// Couldn't find function stab! Maybe we're in an assembly
// file. Search the whole file for the line number.
info->eip_fn_addr = addr;
lline = lfile;
rline = rfile;
}
// Ignore stuff after the colon.
info->eip_fn_namelen = strfind(info->eip_fn_name, ':') - info->eip_fn_name;
// 类似于上面的寻找函数与文件名的方法
// 查找得到 N_SLINE 表示 line 类型
// lline 与 rline 都是从前面继承过来的
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if (lline > rline)
return -1;
info->eip_line = stabs[lline].n_desc;
// 对于 line 类型来说, n_desc 存储行号
// Hint:
// There's a particular stabs type used for line numbers.
// Look at the STABS documentation and <inc/stab.h> to find
// which one.
// Your code here.
需要注意的是, 后面还有一段代码,
// 从符号表的 line 部分减到有效的文件部分,
while (lline >= lfile && stabs[lline].n_type != N_SOL && (stabs[lline].n_type != N_SO || !stabs[lline].n_value))
lline--;
if (lline >= lfile && stabs[lline].n_strx < stabstr_end - stabstr)
info->eip_file = stabstr + stabs[lline].n_strx;
这一段代码的作用是, 在查找行数之后要重新查找一次源文件, 因为有可能该函数是内联函数, 来自其他文件, 那种文件的类型定义为 N_SOL, 最后在对应的字符串数组中找到文件名称.
下一个问题:
Add a
backtrace
command to the kernel monitor, and extend your implementation ofmon_backtrace
to calldebuginfo_eip
and print a line for each stack frame of the form: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>
这里需要修改的是 kern/monitor.c
文件中的 mon_kerninfo 函数, 上面的部分完成就很明显了, 主要是使用 debuginfo_eip
函数获得文件名, 函数名与行号, 所以定义一个 struct Eipdebuginfo *info
类型的指针即可. 补全的函数如下:
int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp = (uint32_t *)read_ebp();
uint32_t *eip = (uint32_t *)ebp[1];
uint32_t args[5], i;
struct Eipdebuginfo dbg_info;
for (i = 0; i < 5; i++)
args[i] = ebp[i + 2];
cprintf("Stack_backtrace:
");
while (ebp != NULL)
{
cprintf(" ebp:0x%08x eip:0x%08x args:0x%08x 0x%08x 0x%08x 0x%08x 0x%08x
",
ebp, eip, args[0], args[1], args[2], args[3], args[4]);
debuginfo_eip((uintptr_t)eip, &dbg_info);
// 获取信息
cprintf(" %s:%d %.*s+%d
", dbg_info.eip_file, dbg_info.eip_line, dbg_info.eip_fn_namelen, dbg_info.eip_fn_name, ebp[1] - dbg_info.eip_fn_addr);
ebp = (uint32_t *)ebp[0];
eip = (uint32_t *)ebp[1];
for (i = 0; i < 5; i++)
args[i] = ebp[i + 2];
}
return 0;
}
至此 Lab1 的内容就全部完成了.
总结开机的过程
我们将整个开机的过程以及对应的地址关系, 以及执行的文件对应一下, 就是下面的内容:
执行的过程 | 执行的物理地址 | 代码所在的虚拟地址 | 对应的文件 | 功能 |
---|---|---|---|---|
BIOS | 0x000F0000 ~ 0x00100000 | 在 ROM 中 | 无 | 开机引导磁盘,找到boot loader文件, 并将其导入物理内存 |
boot loader | 0x7c00 ~ 0x7dff | 系统盘的第一个扇区 | ./boot/boot.S 和 ./boot/main.c | 先将内核的 ELF 头部导入物理地址为 0x10000 处, 然后将内核数据段与代码段导入到物理地址为 0x00100000 处 |
entry | 0x0010000c | 0xf010000c | entry.S | 启用页表机制, 将页目录的物理地址存入 CR0 寄存器 |