Lab1:Booting a PC
概述
本文主要介绍lab1,从内容上分为三部分,part1简单介绍了汇编语言,物理内存地址空间,BIOS。part2介绍了BIOS从磁盘0号扇区读取boot loader到0000:7c00处,并将cs:ip设置成0000:7c00。boot loader主要做两件事:
- 创建两个全局描述符表项(代码段和数据段),然后进入保护模式
- 从磁盘加载kernel到内存
part3主要介绍进入内核后的一些操作:
- 首先会开启分页模式。
- 格式化输出字符串的原理。本质还是往物理内存0xB8000起始的显存写数据。
- 函数调用过程。
对应的lab主页为:lab1
Part 1: PC Bootstrap
本课程使用的汇编使用AT&T语法,Brennan's Guide to Inline Assembly给出Intel语法和AT&T语法之间的一些对应关系。
物理地址内存空间可用下图来描述:
+------------------+ <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
//////////
//////////
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
最早期的16-bit Intel 8088处理器仅支持1MB(0x00000000~0x000FFFFF)的物理寻址能力。到了80286和80386处理器,分别支持16MB和4GB的物理寻址能力。为了做到向后兼容,保留了低1MB的内存布局。
PC通电后会设置CS为0xf000,IP为0xfff0,也就是说第一条指令会在物理内存0xffff0处,该地址位于BIOS区域的尾部。
QEMU提供了调试功能,打开两个终端,一个在lab目录下执行make qemu-gdb
,QEMU会在执行第一条指令前暂停,等待GDB的连接。另一个终端执行make gdb
执行完后会出现如下输出
GNU gdb (GDB) 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
+ target remote localhost:26000
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb)
可以看到第一条指令确实在0xf000:0xfff0处,该条指令为ljmp $0xf000,$0xe05b
跳转到BIOS的前半部分。然后做一些初始化工作,最后从磁盘起始扇区加载512字节到物理地址0x7c00处,并用jmp指令将CS:IP设置为0x0000:0x7c00,从而进入boot loader的控制。
Part 2: The Boot Loader
boot laoder代码在boot/boot.S和boot/main.c中,主要做了两件事:
- 从实模式进入保护模式,加载全局描述符表(boot/boot.S)
- 从磁盘加载kernel到内存(boot/main.c)
先看boot/boot.S,
cli # Disable interrupts
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
cli
这条指令应该是被加载到0x7c00处的指令,也就是进入boot loader后执行的第一条指令。后面几行主要就是设置段寄存器ds, es, ss为0。
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
这几行主要是为了开启A20,也就是处理器的第21根地址线。在早期8086处理器上每次到物理地址达到最高端的0xFFFFF时,再加1,就又会绕回到最低地址0x00000,当时很多程序员会利用这个特性编写代码,但是到了80286时代,处理器有了24根地址线,为了保证之前编写的程序还能运行在80286机子上。设计人员默认关闭了A20,需要我们自己打开,这样就解决了兼容性问题。接着往下看:
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
lgdt
这条指令的格式是lgdt m48
操作数是一个48位的内存区域,该指令将这6字节加载到全局描述表寄存器(GDTR)中,低16位是全局描述符表(GDT)的界限值,高32位是GDT的基地址。”gdtdesc“被定义在第82行:
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
可以看到GDT有3项,第一项时空项,第二第三项分别是代码段,数据段,它们的起始地址都是0x0,段界限都是0xffffffff。lgdt
指令后面的三行是将CR0寄存器第一位置为1,其他位保持不变,这将导致处理器的运行变成保护模式。支持处理器已经进入保护模式。保护模式有疑问的同学可以参考《x86汇编语言-从实模式到保护模式》的第10,11章。
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
接下来的分别设置esp,然后调用bootmain函数,该函数定义在/boot/main.c中。接着bootmain函数:
struct Proghdr *ph, *eph;
// read 1st page off disk
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// load each program segment (ignores ph flags)
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
// call the entry point from the ELF header
// note: does not return!
((void (*)(void)) (ELFHDR->e_entry))();
void readseg(uint32_t pa, uint32_t count, uint32_t offset)
函数从磁盘offset字节(offset相对于第一个扇区第一个字节开始算)对应的扇区开始读取count字节到物理内存pa处。首先读取第一个扇区的SECTSIZE*8(一页)字节的内核文件(ELF格式)到物理内存ELFHDR(0x10000)处。接下来检查ELF文件的魔数。如果对ELF文件格式不熟悉可以看我之前的文章ELF格式。接下来从ELF文件头读取ELF Header的e_phoff和e_phnum字段,分别表示Segment结构在ELF文件中的偏移,和项数。然后将每一个Segment从ph->p_offset对应的扇区读到物理内存ph->p_pa处。
将内核ELF文件中的Segment从磁盘全部读取到内存后,跳转到ELFHDR->e_entry指向的指令处。正式进入内核代码中。
这一步执行完后CPU,内存,磁盘可以抽象出下面的图:
可能有人会有疑问,如何保证boot/boot.S和boot/main.c编译链接后刚好512字节(一个扇区)?而且作为主引导扇区,最后两个字节必须是0x55AA,boot/boot.S和boot/main.c都没有相应的措施来保证。
刚开始我也很疑惑,后面发现boot目录下有一个sign.pl文件:
open(BB, $ARGV[0]) || die "open $ARGV[0]: $!";
binmode BB;
my $buf;
read(BB, $buf, 1000);
$n = length($buf);
if($n > 510){
print STDERR "boot block too large: $n bytes (max 510)
";
exit 1;
}
print STDERR "boot block is $n bytes (max 510)
";
$buf .= " " x (510-$n);
$buf .= "x55xAA";
open(BB, ">$ARGV[0]") || die "open >$ARGV[0]: $!";
binmode BB;
print BB $buf;
close BB;
这段脚本将输入文件,填充为512字节并且最后以0x55AA结尾。编译过程中,makefile会将链接后的文件做这么一个处理。
Part 3: The Kernel
该部分将进入内核执行,主要讲三件事:
- 开启分页模式,将虚拟地址[0, 4MB)映射到物理地址[0, 4MB),[0xF0000000, 0xF0000000+4MB)映射到[0, 4MB)(/kern/entry.S)
- 在控制台输出字符串(/kern/init.c)
- 测试函数的调用过程 (/kern/init.c)
开启分页模式
操作系统经常被加载到高虚拟地址处,比如0xf0100000,但是并不是所有机器都有这么大的物理内存。可以使用内存管理硬件做到将高地址虚拟地址映射到低地址物理内存。虚拟地址转换为物理地址的过程可用下面的图描述:
虚拟地址的高10位(0000000010B)作为页目录的下标,从页目录中获取页表的物理地址0x08001000,虚拟地址的第11~20位(0000000001B)作为页表的下标,得到该页对应的物理地址0x0000c000,最后将虚拟地址的低12位(000001010000B或者0x50)和得到的页的物理地址(0x0000c000)加得到0x00000c050就是虚拟地址0x00801050转换后的物理地址。
来看/kern/entry.S:
movl $(RELOC(entry_pgdir)), %eax
movl %eax, %cr3 //cr3 寄存器保存页目录表的物理基地址
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0 //cr0 的最高位PG位设置为1后,正式打开分页功能
第1行将$(RELOC(entry_pgdir))的值赋给eax寄存器,entry_pgdir定义在/kern/entrypgdir.c中,是页目录的数据结构,将虚拟地址[0, 4MB)映射到物理地址[0, 4MB),[0xF0000000, 0xF0000000+4MB)映射到[0, 4MB)
__attribute__((__aligned__(PGSIZE))) //强制编译器分配给entry_pgdir的空间地址是4096(一页大小)对齐的
pde_t entry_pgdir[NPDENTRIES] = { //页目录表。这是uint32_t类型长度为1024的数组
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P, //设置页目录表的第0项
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W //设置页目录表的第KERNBASE>>PDXSHIFT(0xF0000000>>22)项
};
但是为什么要RELOC(entry_pgdir)呢?RELOC这个宏的定义如下:#define RELOC(x) ((x) - KERNBASE)
KERNBASE又被定义在/inc/memlayout.h中#define KERNBASE 0xF0000000
。那为什么要减0xF0000000呢?因为现在还没开启分页模式,entry_pgdir这个符号代表的地址又是以0xF0000000为基址的(为什么?没有为什么,这个是在链接时,链接器根据/kern/kernel.ld中的. = 0xF0100000;
来指定的。可以参考《程序员的自我修养》p127-使用ld链接脚本)。总结来说就是etnry_pgdir结构所在的物理内存在RELOC(entry_pgdir)
处。接下来将页目录的物理地址复制到cr3寄存器,并且将cr0 的最高位PG位设置为1后,正式打开分页功能。
格式化输出到控制的台
这一小结提供了一些函数,用于将字符串输出到控制台。我们需要了解这些函数的原理,并且正式开始动手写代码。这些函数分布在kern/printf.c, lib/printfmt.c, kern/console.c中。阅读总结出如下的调用关系:
void
cputchar(int c)
{
cons_putc(c);
}
static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c);
cga_putc(c);
}
static void
cga_putc(int c)
{
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;
switch (c & 0xff) {
case '':
if (crt_pos > 0) {
crt_pos--;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;
case '
': //如果遇到的是换行符,将光标位置下移一行,也就是加上80(每一行占80个光标位置)
crt_pos += CRT_COLS;
/* fallthru */
case '
': //如果遇到的是回车符,将光标移到当前行的开头,也就是crt_post-crt_post%80
crt_pos -= (crt_pos % CRT_COLS);
break;
case ' ': //制表符很显然
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;
default: //普通字符的情况,直接将ascii码填到显存中
crt_buf[crt_pos++] = c; /* write the character */
break;
}
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) { //判断是否需要滚屏。文本模式下一页屏幕最多显示25*80个字符,
int i; //超出时,需要将2~25行往上提一行,最后一行用黑底白字的空白块填充
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;
}
/* 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);
}
这些函数最终都会调用到cputchar(),cputchar()打印一个字符到屏幕。cputchar()会调到kern/console.c中的cga_putc(),该函数将int c打印到控制台,可以看到该函数处理会打印正常的字符外,还能处理回车换行等控制字符,甚至还能处理滚屏。cga_putc()会将字符对应的ascii码存储到crt_buf[crt_pos]处,实际上crt_buf在初始化的时候被初始为
KERNBASE(0xF00B8000) + CGA_BUF(0xB8000),也就是虚拟地址0xF00B8000处,这里正是显存的起始地址(根据目前的页表虚拟地址0xF00B8000将被映射到物理地址0xB8000处)。
所以往控制台写字符串,本质还是往物理地址0xB8000开始的显存写数据。
根据函数调用图,可以发现真正实现字符串输出的是vprintfmt()函数,其他函数都是对它的包装。vprintfmt()函数很长,大的框架是一个while循环,while循环中首先会处理常规字符:
while ((ch = *(unsigned char *) fmt++) != '%') { //先将非格式化字符输出到控制台。
if (ch == ' ') //如果没有格式化字符直接返回
return;
putch(ch, putdat);
}
对于格式化的处理使用switch语句。不难理解。
看下Exercise 8,要求添加一些代码,使能支持"%o"输出八进制。那就很简单了,在vprintfmt()中找到case 'o'
的地方:
补充如下代码:
// 从ap指向的可变字符串中获取输出的值
num = getuint(&ap, lflag);
//设置基数为8
base = 8;
goto number;
非常容易理解,getuint函数从ap指向的可变字符串中获取要输出的值,将基数设置为8就行了。保存后,重新make,然后执行./grade-lab1查看当前实验是否通过。在我的机子上显示如下:
可以看到printf后显示ok,说明我们通过了该实验。
栈
gcc函数调用过程可以用如下图解释:
- 执行call指令前,函数调用者将参数入栈,按照函数列表从右到左的顺序入栈
- call指令会自动将当前eip入栈,ret指令将自动从栈中弹出该值到eip寄存器
- 被调用函数负责:将ebp入栈,esp的值赋给ebp。所以反汇编一个函数会发现开头两个指令都是
push %ebp, mov %esp,%ebp
。
直接看Exercise 11,让我们补全mon_backtrace()函数,该函数打印函数调用栈打印格式如下:
Stack backtrace:
ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...
mon_backtrace()定义在/kern/monitor.c中,在/kern/init.c中被test_backtrace()调用,进入内核后会调用test_backtrace()
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);
}
test_backtrace(5);
调用后会进行递归,最终调用mon_backtrace,mon_backtrace的任务就是将递归调用过程中的栈信息打印出来。结合之前的知识,我们可以画出函数调用过程中ebp的值存储图:
至于为什么一开始ebp的值是0?看kern/entry.S中如下代码:
# Clear the frame pointer register (EBP)
# so that once we get into debugging C code,
# stack backtraces will be terminated properly.
movl $0x0,%ebp # nuke frame pointer
# Set the stack pointer
movl $(bootstacktop),%esp
# now to C code
call i386_init
在跳转到i386_init函数前,已经将ebp寄存器设置为0了。同时我们也发现esp寄存器被设置为了$(bootstacktop),bootstacktop被定义在kern/entry.S中,也就是说我们在内核编译链接成的ELF文件中保留了KSTKSIZE字节的空间,作为栈使用。
bootstack:
.space KSTKSIZE //申请KSTKSIZE字节的空间作为栈
.globl bootstacktop //.globl表示导出bootstacktop
bootstacktop:
现在就简单了,开始动手实现mon_backtrace函数。
实验提供了read_ebp()函数,可以让我们方便获取寄存器ebp的值。我们如下实现mon_backtrace函数。
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp = (uint32_t *)read_ebp(); //获取ebp的值
while (ebp != 0) { //终止条件是ebp为0
//打印ebp, eip, 最近的五个参数
uint32_t eip = *(ebp + 1);
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x
", ebp, eip, *(ebp + 2), *(ebp + 3), *(ebp + 4), *(ebp + 5), *(ebp + 6));
//更新ebp
ebp = (uint32_t *)(*ebp);
}
return 0;
}
接着看Exercise 12,该实验要求我们在实验11的基础上还要输出当前eip(也就是当前正在执行的指令)对应的文件名,所在行号,对应函数,以及在函数内的偏移。
实验提供了int debuginfo_eip(uintptr_t addr, struct Eipdebuginfo *info)
函数(在/kern/kdebug.c中),该函数输入eip,和一个Eipdebuginfo结构指针,执行完毕后,会将eip对应的信息填充到该结构中。接着完善mon_backtrace函数:
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp = (uint32_t *)read_ebp();
struct Eipdebuginfo eipdebuginfo;
while (ebp != 0) {
//打印ebp, eip, 最近的五个参数
uint32_t eip = *(ebp + 1);
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x
", ebp, eip, *(ebp + 2), *(ebp + 3), *(ebp + 4), *(ebp + 5), *(ebp + 6));
//打印文件名等信息
debuginfo_eip((uintptr_t)eip, &eipdebuginfo);
cprintf("%s:%d", eipdebuginfo.eip_file, eipdebuginfo.eip_line);
cprintf(": %.*s+%d
", eipdebuginfo.eip_fn_namelen, eipdebuginfo.eip_fn_name, eipdebuginfo.eip_fn_addr);
//更新ebp
ebp = (uint32_t *)(*ebp);
}
return 0;
}
在lab目录下执行make, ./grade-lab1,如果一切顺利将看到如下输出:
就说明我们通过了lab1的所有实验。
本人的实验代码已经上传github,欢迎关注https://github.com/gatsbyd/mit_6.828_jos
如有错误,欢迎指正:
15313676365
参考资料
《x86汇编语言-从实模式到保护模式》
《程序员的自我修养》