笔者实验环境:ubuntu 20.02
本实验的实验环境主要包括两部分:
由于实验环境搭建网上已经有很多详尽的资料,这里引用一位大佬的博客作为参考。
该实验主要分为3个部分:
PC的开机程序
。内核的加载过程
,探究开机后,是如何将内核加载到内存并运行的。内核的基本结构
。6.828的实验代码存储在https://pdos.csail.mit.edu/6.828/2018/jos.git
代码仓库中,可以通过如下代码拉取到本地:
git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
6.828一共有6个实验,每个实验对应一个分支,因此需要切换到对应分支,即:
cd lab # lab code 被克隆到了lab目录下
git checkout lab1 # 切换到lab1分支
由于操作系统的主要编程语言是C和汇编,因此需要有一定的汇编基础,为了保证可以顺畅进行后面的实验,可以先阅读一下Brennan's Guide to Inline Assembly
做好这些准备工作后,让我们开始实验内容。
这里我们会尝试利用QEMU模拟PC的启动过程。首先,我们尝试先将QEMU跑起来,依次执行如下命令:
cd lab
make
运行结果如下:
+ 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
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 380 bytes (max 510)
+ mk obj/kern/kernel.img
到此为止,我们已经得到了一个镜像文件(obj/kern/kernel.img),该文件包括了两个部分:
boot loader(启动加载器):obj/boot/boot
kernel(内核):obj/kernel
这两个部分后面都会分别介绍,拥有了这个镜像文件,我们就可以运行QEMU了。
make qemu-nox # 或者 make qemu,建议使用make qemu-nox,否则在虚拟机环境下还是有一点小麻烦的
然后如下内容将被显示,如果想要退出qemu请依次按下Ctrl+a
、x
:
Booting from Hard Disk...
# 下面部分是习题用的print出来的内容
6828 decimal is XXX 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
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>
我们可以看到,QEMU模拟的操作系统打印出了许多类似于debug的信息,这些都是后面的习题要使用的内容,为了我们能够很好的对操作系统debug,以便后面处理上面的信息,我们需要学会使用GDB(调试工具),使用方式也很简单。
make qemu-nox-gdb
make gdb
结果如下:
终端1是预览窗口,终端2是debug窗口。我们可以在终端2中输入一些命令来控制程序的运行,或者获取当前计算机中的信息,详细使用方式可以查看MIT 6.828 lab 工具guide,本实验我们只需要使用到3条命令,第一条为si
即单句运行。
通过上图我们可以看到,我们对6.828提供的操作系统debug,运行的第一行代码是在内存0xffff0
位置的代码ljmp $0xf000,$0xe05b
,并且在这行代码上还有一句提示The target architecture is assumed to be i8086
。这里有一个问题:
0xffff0
这个位置开始运行?这里是什么?我们考察MIT 6.828 lab1 讲义中给出的地址空间布局图:
+------------------+ <- 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
可以看到0xffff0
对应BIOS ROM
的最后16个字节。继续查看讲义,我们发现,原来这里是沿用了早起8088的设计,PC中的BIOS是烧录进入0x000f0000-0x000fffff
位置的,这使得在PC启动时BIOS总能第一时间控制PC,毕竟此时,内存中除了BIOS的部分,都是随机的数据。因此,设计者将入口设置到了0xffff0
,即CS=0xf000,IP=0xfff0
。注意,BIOS只能运行在实模式下,实模式的物理地址计算方式为:
physical address = 16 * segment + offset
根据公式可以看出实模式只能访问前1MB的内存(0x00000-0xfffff)。然而16个字节的内存并不能存储多少代码,因此,真实的处理逻辑被存储在其他地方,这里只负责jump到对应的地点而已。这部分代码主要用于进行上电自检等设备检验和初始化操作,当一切硬件设备都处理好了,就要开始引导并加载操作系统内核了。
传统意义上,操作系统存储在硬盘空间中,而硬盘又被划分为一个个的扇区,每个扇区512bytes。根据冯诺依曼体系结构,操作系统的内核映像需要被装载到内存中才能运行,因此,Boot Loader的职责就是将操作系统内核映像加载到内存中,并且将控制权限交给内核。
然而这里存在一个问题,回顾物理内存的布局结构,可以看到前1MB的内存基本已经被占用满了。
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
这里的Low Memory也需要做保留用于他用,可见实模式的寻址已经不足以满足当前的需求,我们需要一种方式能够访问更多的内存,以便可以将内核加载到其中。因此,就需要将实模式切换到32-bit的保护模式,在这个模式下,可以访问4GB内存(32bit 30=1GB 2=4)。处理这部分的代码在boot/boot.S中。
了解了内存寻址不够的处理方案,那么还有一个问题,我们需要从哪里加载操作系统内核?怎么让机器了解这个位置?
事实上,为了从硬盘或者软盘上启动,必须把它们用于启动的第一个扇区中所存放的指令(boot/boot.S)装载到内存中执行,而这些指令再把包含内核映像的其他所有扇区拷贝到内存中。处理这部分的代码在boot/main.c中。第一个扇区的装载位置同PC的最初启动位置一样,是一个固定值,在 0x7c00 到 0x7dff
中。这里我们学习GDB的第二条和第三条命令:
这里我们尝试在0x7c00位置设置断点,即b *0x7c00
,然后使用c命令,将程序运行到断点处。
对比右侧和左侧的代码可以发现,boot/boot.S
被加载到了0x7c00
中,我们也可以通过obj/boot/boot.asm
查看代码和它的内存空间中的物理地址分布。
考察boot.S的第44行(boot.asm的第61行):
通过注释,可以看到,ljmp $PROT_MODE_CSEG, $protcseg
指令后,跳转到了32-bit保护模式,如果我们将断点打在这里,并跳过这一行代码可以发现:
然而,真正造成实模式到保护模式转变的包括两个部分:
使能A20总线
设置保护模式flag
完成这两步之后,还需要对保护模式下的各大段寄存器进行初始化,并为C语言运行设置esp,保证C语言运行有栈可用:
最后调用call bootmain
进入到boot/main.c
中,在boot/main.c
中将会读取整个内核镜像到内存中。编译后的内核镜像是一个ELF格式的文件,整个装载过程就是装载该ELF文件的过程(这个文件很复杂,我们只简单看一下装载过程),查看boot/main.c
文件:
#define SECTSIZE 512
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);
void
bootmain(void)
{
struct Proghdr *ph, *eph;
// 读取磁盘中的第一页(4096 bytes)
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);
// 根据ELFHeader中的信息,每次读取一个segment,直到读取完毕为止。
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))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
可以看到,ELF文件的Header中存放了各个segment的信息,boot/main.c
根据这些信息将整个内核加载入内存中,最后进入内核。
略
通过本次实验,我们了解了6.828的使用的QEMU的整个BOOT流程,真实的Linux是如何启动的呢?笔者考察了《深入理解linux内核》这一著作,其附录一《系统启动》描述了该问题:
BIOS:在开始启动时,有一个特殊的硬件电路在CPU的一个引脚上产生一个RESET逻辑值,在RESET产生以后,就把处理器的一些寄存器(包括cs和eip)设置成固定的值,并执行在物理地址0xfffffff0
处找到的代码。硬件把这个地址映射到某个只读、持久的存储芯片(ROM),ROM中存放的程序集在80x86体系中通常叫做基本输入/输出系统(BIOS),因为它包括几个终端驱动的低级过程。所有的操作系统在启动时,都要通过这些过程对计算机硬件进行设备初始化。BIOS的启动过程主要包括4个操作:
引导装入程序:
进入内核进行初始化。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章