MIT CS6.828-Lab 1: Booting a PC
Lab 1: Booting a PC
基础概念
处理器架构
i386 i686 x86是同一套指令集,86指这一系列CPU,x则表示i3/i6是不同的处理器型号,x86即泛指i386 i686等,都是32位处理器x86_64则为x86的衍生,为x86的64位版本,另外amd64也是一样的东西aarch32指armv7处理器架构下的指令集aarch64指armv8处理器架构下的指令集
AT&T格式汇编指令介绍
- 基本格式
command source, destination,AT&T将源操作数放在第一位,目标操作数放在第二位,这与Intel格式的汇编语法是相反的 - 在寄存器操作数前用
%标记,立即操作数前用$标记 testb/testw/testl s dtest表示对两个操作数进行AND操作,其结果如果为0则将CPU中的ZF标志寄存器设置为1否则清除ZF寄存器的值,即设置为0b/w/l为助记符,b用于8位字节值,w用于16位字值,l则用于32位长字值
je/jz检查ZF标志是否为1, 为1则跳转到目标地址jne/jnz检查ZF标志是否为0, 为0则跳转到目标地址
ZF标志寄存器(FLAGS register)
- 此寄存器存储上一条指令的计算结果,结果为
0则此寄存器值为1,否则为0
Parts
制作内核
根据给出的 make命令构建出 kernel.img文件,这步基本没什么问题,该安装的环境装上即可,GNU的那套东西
使用QEMU加载内核
有了内核文件当然要加载,由于以后的开发不可都是真机去调试,所以介绍了使用 QEMU模拟加载内核,与上个步骤一样,该安装的装上,我是在Ubuntu 20.04 LTS环境下直接安装的QEMU,没有按照Lab步骤自己编译。使用 qemu-system-i386 kernel.img(qemu-system-i686或 qemu-system-x86)便能将内核跑起来,展示出一个简单的 Shell环境,可以运行两个简单的命令,help和kerninfo
常规PC内存地址布局
+------------------+ <- 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
由此图可看出,前1MB的内存空间是保留的,给各种硬件设备使用,例如从0x000F0000->0x000FFFFF为BIOS的地址空间(想法:用来存储BIOS程序的地址空间?),从0x000A0000->0x000BFFFF为VGA显示的内存空间,而前1MB以外的所有内存叫做拓展内存,前640KB则叫做低内存区域。
- 早期的
CPU仅仅支持1MB的寻址空间,所以设计师将最顶部的区域留给了硬件,最底部的640KB留给软件使用。而当CPU终于支持了超过1MB寻址空间达到4GB后,设计师为了兼容性依然保留了1MB情况下的内存设计。 - 当
CPU支持4GB内存寻址(32位可寻址空间)能力后,一般会将顶部(最高位)的一些内存地址留给32位PCI设备 - 当
CPU支持大于4GB内存寻址能力后,也会保留32位可寻址空间顶部的一些地址空间给需要映射32位地址的设备使用 BIOS程序会做一些硬件自检与检测,所以会读取上述保留的硬件内存地址
使用GDB调试内核
新版的QEMU使用-s -S参数来侦听GDB的传入连接(GDB usage),同时开启另一个终端,使用make gdb命令启动GDB调试,JOS源码中已经自带GDB的相关配置(.gdbinit),跑起来后我们能看到GDB吐出的调试信息:
gdb -n -x .gdbinit
GNU gdb (Ubuntu 9.1-0ubuntu1) 9.1
Copyright (C) 2020 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 "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word".
+ target remote localhost:1234
warning: No executable has been specified and target does not support
determining executable automatically. Try using the "file" command.
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0x3630,$0xf000e05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
warning: A handler for the OS ABI "GNU/Linux" is not built into this configuration
of GDB. Attempting to continue with the default i8086 settings.
(gdb)
能看到几个关键信息:[f000:fff0] 0xffff0: ljmp $0x3630,$0xf000e05b
[f000:fff0]表示CPU起始的CS和IP寄存器指向的地址(这两寄存器的地址加在一起组成一个完整的地址,也就是PC的概念)0xffff0,此地址指向为BIOS ROM保留的64KB区域的最顶部0xffff0地址存储了第一个BIOS程序指令:ljmp $ 0xf000,$ 0xe05b,一个jmp指令,指示跳转到CS=0x3630,IP=0xf000e05b
物理地址=16*分段地址+偏移量
16 * 0xf000(CS) + 0xfff0(IP) # 十六进制*16只需要在后面加上0即可
= 0xf0000 + 0xfff0
= 0xffff0
由上得知,CPU通电后CS:IP中存储的第一个地址就是BIOS ROM所在内存区域的顶部地址,这是在CPU的CS:IP中写死的地址,是Intel 8086处理器开始的方式。另外需要注意的是,此处BIOS ROM所对应的实际物理位置,是在主板上的,并不是在内存条中,包括上述的一些为硬件保留的地址,也应当都是在主板上的,而非内存条提供的内存。
Boot.S文件分析
整段代码内容就是将CPU从一开始的实模式切换到保护模式,实模式下只能操作16位内存地址空间(1MB),保护模式可以启用32位地址空间(4GB)。最后设置栈指针为0x7c00,这是一个固定大小的栈,栈地址向低位地址递减。
#include <inc/mmu.h>
# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.
#
# BIOS将硬盘第一个扇区数据读取到物理内存地址0x7c00(cs=0 ip=7c00)上并使用实模式执行
# 分段地址就是为了在CPU只有16位寄存器时通过两个寄存器(CS<<4+IP)存一个物理地址的骚操作,因为当年的CPU地址线具有20根,也就是20位寄存器才能存储下,
# 定义几个常量后续会用到
.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag
# 暴露出start方法给外部调用,链接器链接时需要用到
.globl start
# 开始标记
start:
# 以16位模式运行
.code16 # Assemble for 16-bit mode
# 禁用中断
cli # Disable interrupts
# 清除顺序(设置字符串操作为递增)
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
# 其实就是将这几个寄存器清零
# xor %ax,%ax,一个异或操作,自己异或自己=0
xorw %ax,%ax # Segment number zero
# 将寄存器ax的值移动到ds寄存器,以下同理
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
# 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.
# 遥想当年的CPU寄存器只有16位,而CPU的地址线则有20根,
# 16位寄存器无法表示完整的20根地址线的寻址空间(1MB),于是有了分段地址这种操作。
# 将一个寄存器的地址作为分段地址,另一个寄存器作为OFFSET,
# 合并到一起用来表示20根地址线的寻址空间(1MB),表示方式如下:
# 分段地址左移4位+OFFSET,换算成二进制能得到一个20位的地址,超过20位,则CPU地址线不够,
# 所以会将第21个bit(bit位是从0开始数的,第20位其实是第21个bit位)始终设置为0
# 因此,这里需要启用A20
# 怎么激活呢,由于历史原因A20地址位由键盘控制器芯片8042管理。所以要给8042发命令激活A20
# 8042有两个IO端口:0x60和0x64
# 激活流程位: 发送0xd1命令到0x64端口 --> 发送0xdf到0x60
# 发送命令给端口前先判断端口缓冲区是否为空
seta20.1:
# in操作是将目标地址0x64端口数据读取到al寄存器
inb $0x64,%al # Wait for not busy
# 判断al寄存器值第二位是否为1 test指令用来将 $0x2 & $al,并将结果设置给ZF标志寄存器
testb $0x2,%al
# 判断ZF的值,如果缓冲区被占用跳转到seta20.1重新检测
jnz seta20.1
# 将0xd1值移动到寄存器al,由于in与out操作都只能从寄存器进行操作,所以这里先移动数据到寄存器
movb $0xd1,%al # 0xd1 -> port 0x64
# 发送寄存器中的数据到端口
outb %al,$0x64
# 同上seta20.1
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
# cr0寄存器中的第0位用来设置是否为保护模式(Protedted Enable),设置为1表示启动保护模式
# 先移动cr0寄存器中的数据到eax寄存器
movl %cr0, %eax
# 做一个或操作,CR0_PE_ON为我们一开始定义的常量,值就是1,这个操作就是将寄存器eax中的数据第0位设置为1
orl $CR0_PE_ON, %eax
# 将eax寄存器数据移动到cr0寄存器,至此已经开启保护模式
movl %eax, %cr0
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
# 使用32位模式
.code32 # Assemble for 32-bit mode
protcseg:
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# Set up the stack pointer and call into C.
# 设置栈指针为0x7c00
movl $start, %esp
# 调用内核
call bootmain
# If bootmain returns (it shouldn't), loop.
# 理论上call kernel代码后永远不会退出,但是如果真的退出,这里进行了一个循环
spin:
jmp spin
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
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
至此我们已经设置CPU为保护模式并且能够寻址32位地址空间,接下来跳转到bootmain方法。
main.c文件分析
此文件主要用来引导内核程序,它将扫描硬盘第一个扇区,然后将ELF格式的kernel程序加载到内存中,然后跳转到内核入口处。
#include <inc/x86.h>
#include <inc/elf.h>
/**********************************************************************
* This a dirt simple boot loader, whose sole job is to boot
* an ELF kernel image from the first IDE hard disk.
*
* DISK LAYOUT
* * This program(boot.S and main.c) is the bootloader. It should
* be stored in the first sector of the disk.
*
* * The 2nd sector onward holds the kernel image.
*
* * The kernel image must be in ELF format.
*
* BOOT UP STEPS
* * when the CPU boots it loads the BIOS into memory and executes it
*
* * the BIOS intializes devices, sets of the interrupt routines, and
* reads the first sector of the boot device(e.g., hard-drive)
* into memory and jumps to it.
*
* * Assuming this boot loader is stored in the first sector of the
* hard-drive, this code takes over...
*
* * control starts in boot.S -- which sets up protected mode,
* and a stack so C code then run, then calls bootmain()
*
* * bootmain() in this file takes over, reads in the kernel and jumps to it.
**********************************************************************/
//定义扇区大小
#define SECTSIZE 512
//定义ELF头文件在内存中的位置
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);
void
bootmain(void)
{
//定义program header 结构指针,这两个指针将指向内存中segment开始和结尾的地址
struct Proghdr *ph, *eph;
//将第一页加载到ELFHDR内存空间处
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
//检测刚加载到ELFHDR处的数据是否是ELFHDR
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
//指定内存中的program segment起始地址为ELFHDR+ELFHDR->e_phoff就是从ELFHDR结束开始e_phoff为ELFHD结束位置的偏移量
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
//指定内存中最后一个program segment地址
eph = ph + ELFHDR->e_phnum;
//循环将扇区的program segment加载到内存中
for (; ph < eph; ph++)
//指针++则表示指针指向的地址+指针数据结构大小*2,这里可以看成指针指向下一个program segment
//ph->p_pa为需要加载到内存中地址的起始位置,ph->p_memsz为需要的内存大小,ph->p_offset为program segment
//数据所在硬盘上的基于ELFHDR起始地址的偏移量
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
//跳转到内核入口处(示例中ELF入口指定为0x10000c处,内核代码被加载到由ELF指定的LMA:0x100000处)
((void (*)(void)) (ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
//program segment在内存中的结束地址
uint32_t end_pa;
//由起始地址+内存大小
end_pa = pa + count;
pa &= ~(SECTSIZE - 1);
//将偏移量转换为扇区
offset = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
//循环读取
while (pa < end_pa) {
// 将一个扇区读取到指定的内存位置
readsect((uint8_t*) pa, offset);
pa += SECTSIZE;
offset++;
}
}
void
waitdisk(void)
{
// wait for disk reaady
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
void
readsect(void *dst, uint32_t offset)
{
// 等待硬盘准备好
waitdisk();
// 设置读取扇区的数目为1
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // 0x20命令,读取扇区
//上面四条指令联合制定了扇区号
//在这4个字节线联合构成的32位参数中
//29-31位强制设为1
//28位(=0)表示访问"Disk 0"
//0-27位是28位的偏移量
waitdisk();
// 读取数据到目标内存位置,每次读取128 bit读取4次
insl(0x1F0, dst, SECTSIZE/4);
}
内核加载顺序
BIOS 程序硬件检测完成后会将硬盘引导扇区加载到0x7c00至0x7dff的内存地址中,然后设置PC为0x7c00执行boot.S
boot.S将CPU从real mode转换为protected mode,这将启用32位寻址空间,然后设置栈指针为0x7c00,栈空间增加时,栈指针地址递减,所以这将是一个固定大小的栈空间。设置完栈指针后,调用main.c。
main.c扫描硬盘的第一页,加载ELF格式的文件头,根据ELF的文件定义将内核加载到指定内存地址(0x00100000),然后跳转到内核程序入口(0x0010000c)开始执行entry.S。
entry.S之前使用的都为物理地址,entry.S则将entrypgdir.c中定义的页目录加载到cr3寄存器,这会将物理地址前4MB(0x000000-0x3ff000)映射到虚拟地址KERNBASE+4MB的位置。然后设置cr0寄存器以开启分页,最后设置%esp指定栈指针位置后跳转到i386_init.c
Booting Output
Booting from Hard Disk..
6828 decimal is 15254 octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
Stack backtrace:
ebp f0110f18 eip f01000a5 args 00000000 00000000 00000000 f010004e f0112308
kern/init.c:18: test_backtrace+101
ebp f0110f38 eip f010007a args 00000000 00000001 f0110f78 f010004e f0112308
kern/init.c:16: test_backtrace+58
ebp f0110f58 eip f010007a args 00000001 00000002 f0110f98 f010004e f0112308
kern/init.c:16: test_backtrace+58
ebp f0110f78 eip f010007a args 00000002 00000003 f0110fb8 f010004e f0112308
kern/init.c:16: test_backtrace+58
ebp f0110f98 eip f010007a args 00000003 00000004 00000000 f010004e f0112308
kern/init.c:16: test_backtrace+58
ebp f0110fb8 eip f010007a args 00000004 00000005 00000000 f010004e f0112308
kern/init.c:16: test_backtrace+58
ebp f0110fd8 eip f0100110 args 00000005 00001aac 00000640 00000000 00000000
kern/init.c:41: i386_init+102
ebp f0110ff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003
debuginfo_eip error
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>
Make Grade Output
$ make grade
make clean
make[1]: 进入目录“/home/fattiger/work/MIT-CS6.828”
rm -rf obj .gdbinit jos.in qemu.log
make[1]: 离开目录“/home/fattiger/work/MIT-CS6.828”
./grade-lab1
make[1]: 进入目录“/home/fattiger/work/MIT-CS6.828”
+ as kern/entry.S
+ cc kern/entrypgdir.c
+ cc kern/init.c
+ cc kern/console.c
+ cc kern/monitor.c
+ cc kern/printf.c
+ cc kern/kdebug.c
+ cc lib/printfmt.c
+ cc lib/readline.c
+ cc lib/string.c
+ ld obj/kern/kernel
ld: warning: section `.bss' type changed to PROGBITS
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 412 bytes (max 510)
+ mk obj/kern/kernel.img
make[1]: 离开目录“/home/fattiger/work/MIT-CS6.828”
running JOS: (1.1s)
printf: OK
backtrace count: OK
backtrace arguments: OK
backtrace symbols: OK
backtrace lines: OK
Score: 50/50