写在前面
本系列文章将会尝试以学习笔记的形式展开,即每篇文章都没有一个明确的主线,主要是堆砌实验过程中遇到的知识点和解决的问题
本系列涉及的学习对象是MIT面向研究生开设的操作系统工程课程的Lab部分,该课程的编号为MIT6.828
(区别一下MIT6.S081,这个课程是面向本科生开设的,也叫Operating System Engineering,Lab的难度相对较低,本系列不讨论这门课程)
本系列将直接就这门课程的Lab部分进行学习,忽略这门课程的授课部分
课程网站
https://pdos.csail.mit.edu/6.828/2018/
点击上方导航栏的Labs菜单栏可以进入每个Lab的页面
Lab1学习笔记(第一部分)
Lab1涉及到了大量的涉及到x86硬件的知识,以及x86指令集的知识,所以该Lab的笔记分为多个部分记录。
Lab1主要向我们展示了一台计算机是怎样启动的,涉及到了如何编写Bootloader,如何加载内核,物理内存的分布和用途,实模式与保护模式等。
这个lab的链接如下:
https://pdos.csail.mit.edu/6.828/2018/labs/lab1/
下面正式开始对于Lab1的笔记。
计算机是如何启动的?
对于不同体系结构的计算机,其启动方式会略有差异,这里只讨论x86架构的计算机的启动流程(因为这个课程也是针对x86设计的,其他平台可以很轻易地推广)。
1.加载BIOS
主板上有一块只读的存储区域(ROM),这里面保存了BIOS代码,BIOS会负责执行基础的系统初始化,如启动显示卡,检查内存容量等。
计算机启动的第一个步骤是把BIOS加载到内存中,对于不同的CPU,BIOS的加载位置可能略有差异。对于实验中使用的QEMU模拟器,BIOS大小为64KB,在物理内存中从0x000F0000到0x000FFFFF
(注:本实验中有很多参数都是和具体的CPU型号有关,本文后面就不着重强调这一点了,后面涉及到的参数都默认是QEMU模拟器的参数)
2.执行BIOS的第一个指令
在加载完BIOS之后,CPU会把CS寄存器设置为0xf000,把IP寄存器设置为0xfff0,把控制权交给BIOS。
这里刚启动的时候CPU还处于实模式,CPU对于指令的寻址方式是使用段基址+段内偏移地址的方法来计算物理地址的,其中段基址保存在CS寄存器中,IP寄存器就是计算机组成原理里面的提到的PC(Program Counter)寄存器,指向了下一条将要执行的指令的地址。
所以BIOS第一条指令在物理内存中的地址是0xFFFF0
3.加载并运行Bootloader
BIOS程序会读取磁盘的第一个扇区(大小512KB),如果第一个扇区的最后两个字节是0x55AA,那么BIOS就会认为这个磁盘是个启动介质,并把第一个扇区加载到内存中的0x7C00的位置,然后把CS设置为0x0000,IP设置为0x7C00,把控制权交给Bootloader
4.Bootloader加载并运行内核
Bootloader的指令将会从磁盘中把完整的内核加载到内存中,然后把IP寄存器设置为kernel的起始位置的地址,把控制权交给内核。
实际操作系统中还会在Bootloader里面把CPU转换到保护模式,并配置好段表GDT。
JOS项目是如何启动的?
Lab官方网站上给出的启动方法是使用cmake,只需要make && make qemu
即可启动整个项目,但是这其中具体的流程是怎样的呢?这是很值得探索的,这将有助于我们更深入地理解操作系统的启动流程。
首先介绍一下项目的重要组成部分:
- boot目录:这里面有boot.S和main.c,这是Bootloader的代码
- kern目录:这里面保存的是kernel的代码
- obj目录:最终输出文件的目录
1.编译Bootloader
这个步骤是为了得到只包含指令的Bootloader,然后把它填充到512KB,并把最后2个字节设置为0x55AA。这个步骤得到的二进制文件是需要直接写到磁盘的第一个扇区里面的。
具体的步骤可以见boot目录下的Makefrag文件,如下图所示
首先把boot.S和main.c编译为elf格式的可执行文件;然后使用链接器把两个可执行文件链接起来;然后提取链接后的elf文件的text段,也就是指令部分;最后使用一个perl脚本填充得到的指令文件,并把最后2个字节设置为0x55AA
为什么要这么麻烦,先编译,再链接,再提取text段呢?
因为代码中涉及到大量的符号,例如下图所示的跳转到main.c里面写的bootmain函数
在bootloader里面,是只能用实际地址的,所以必须要使用链接器来把bootmain这一类的符号替换成具体的地址数值。
2.编译Kernel
把kern目录下的源码编译成elf格式可执行文件,然后链接起来,最终得到一个名为kernel的elf文件。
这其中的链接部分稍微有点复杂,目前暂时还没有去深入研究这部分。
3.打包启动镜像kernel.img
这一步就很简单粗暴了,就是纯纯的文件复制。
首先新建一个空白的kernel.img,然后把之前的那个512KB的boot文件复制进去,然后再把kernel这个elf文件复制进去,就完成了镜像的制作。
这个步骤具体涉及到的指令如下图(kern/Makefrag)所示
需要注意的是,这个步骤产出的kernel.img是可以在真正的计算机上运行的启动镜像。也就是说,如果把这个kernel.img写入到一个U盘里面,然后再把U盘插到一台电脑上启动,那么这个电脑屏幕上就会显示出和下图QEMU窗口所示一模一样的画面
(其实这里的QEMU窗口可以理解为一个显示器了,这里面的文字都是kernel使用底层代码展示出来的,至于是如何展示的,后文会揭晓答案)
4.启动QEMU模拟器
查看根目录下的GNUmakefile可知,该项目使用了如下图所示的options来启动QEMU
也就是说,在启动时候把kernel.img作为了磁盘里的内容。
分析entry.S
首先分析整个bootloader的入口,也就是entry.S
(关于为什么入口是它?是因为:观察boot/Makefrag,在link的时候,指定了程序入口是start这个符号,而start符号是在entry.S的最开头就定义了的)
汇编语言
关于汇编语法,整个项目的语法风格使用的都是AT&T风格,具体区别可以访问http://www.delorie.com/djgpp/doc/brennan/brennan_att_inline_djgpp.html
简单了解一下。
伪指令
汇编语言代码中会涉及到一些伪指令,这些指令是用来指导编译器的行为的,这里参考了博客文章https://blog.csdn.net/Roland_Sun/article/details/107705952
对Bootloader中会用到的伪指令做简单介绍
.set
相当于是定义了一个常量,如下图
相当于是定义了一个名叫PROT_MODE_CSEG的常量,以后可以直接使用$PROT_MODE_CSEG
来表示这个常量对应的具体值
.global
这个伪指令告诉编译器,某个符号后面还会用到,所以把这个符号标记为全局符号。
如下图这段代码
这里的.global start
告诉编译器在编译entry.S的时候,要保留start这个符号,因为后面在链接的时候还会用上这个符号的
.code16 .code32
这个伪指令是告诉编译器接下来的代码是以16位(32位)的格式编译,16位与32位的寻址方式有区别,如果CPU已经进入保护模式了,但是代码却还是用16位寻址方式编译的,那么就会出问题。
(关于这一点,后面会有具体的例子来说明)
这里只需要知道,在编译器遇到.code16
之后,会把后面的代码都以16位寻址方式编译;当遇到.code32
之后,就会把后面的代码都以32位寻址方式编译
.word .long
这是填充数据的指令,编译器在遇到这一系列指令后,会向目标文件中填充指定的数据
代码解析:entry.S
下面这段代码首先关中断,避免系统启动时被中断打扰,然后初始化了数据段寄存器DS,栈段寄存器SS和另一个段寄存器ES
start:
.code16 # Assemble for 16-bit mode
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
接下来,按照注释的说法,是开启了A20,这里不再深究其内部机制了
# 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
接下来这段代码是让CPU进入保护模式,然后通过一个jump来清空指令流水线里面的16位指令(https://www.kancloud.cn/digest/protectedmode/121470)因为接下来CPU将会进入32位模式
(关于实模式与保护模式,后面会重点阐述一下的,这里暂时认为保护模式下寻址方式使用32位寻址方式即可)
# 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
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
接下来就到了protcseg部分,这部分做的事情就是首先把数据段,代码段的段描述符设置了(这个段描述符和保护模式有关,后面会提到),然后设置栈的起始位置为0x7C00,最后使用一个call指令来把控制权交给写在main.c里面的bootmain函数
.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.
movl $start, %esp
call bootmain
按照道理,bootmain函数是不会使用ret指令返回的,为了防止意外情况,这里写了一个死循环
# If bootmain returns (it shouldn't), loop.
spin:
jmp spin
死循环下面就是对于全局段表GDT的配置,这个会放在后面来详细解释。这里相当于是通知编译器向输出的二进制文件中直接写入GDT,由于这段二进制文件中的内容在启动的时候会被加载到内存中,所以就相当于在内存中开辟了一块区域用来存放GDT表项。
# 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
实模式与保护模式
这部分内容主要参考了博客文章https://www.kancloud.cn/digest/protectedmode/121468和《深入理解Linux内核》的内容
两者的区别
实模式和保护模式主要有以下几个差别:
- 寻址方式不同
- 保护模式包含有硬件级别的安全包含,而实模式没有
下面来具体看看这些区别。
寻址方式
首先是寻址方式的区别,如果CPU处于实模式下,那么CPU的寻址方式就是直接使用段寄存器+偏移量的方式去寻找。
例如,对于指令MOV 0x7C04 %ax
,CPU做的事情是:
首先把数据段寄存器DS的值与0x7C04相加,得到要访问的物理地址,然后访问这个物理地址,读取数据。
如果CPU处于保护模式,那么CPU就是使用硬件进行段页式寻址。这过程中涉及到3类地址:逻辑地址,线性地址和物理地址,其中逻辑地址就是指令里面写的地址,例如MOV 0x7C04 %ax
中的0x7C04
;物理地址是RAM上面的地址,是数据在物理介质上存储的地址。
保护模式下的CPU的寻址方式分为2个步骤:
首先是逻辑地址会通过分段单元(硬件)转换为线性地址,然后线性地址再由分页单元(同样也是硬件)转换为物理地址,如下图所示。
具体而言,段寄存器(例如DS)存储的内容不再是段基址,而是段选择符(Segment Selector),段选择符会指向全局描述符表GDT中的某一个段描述符(Segment Descriptor),段描述符记录了段基址,段类型,权限等管理信息。使用段描述符中的段基址+逻辑地址就得到了线性地址。
接下来分页单元会查页表,把线性地址映射到物理地址。现代CPU通常都是使用的多级页表,例如下图所示的80x86处理器的分页方式
同时还会使用TLB来缓存页表项,来减少访问主存的频率,提高效率。
安全保护
在段选择符中有一个字段叫RPL,这个字段表示了当前CPU的特权等级
特权等级从0到3,依次递减。CPU在执行指令时会判断当前的特权等级,如果等级不足,会拒绝执行。例如,像LGDT
这类的操作GDT的指令,就需要特权等级为0才能执行