这才是计科之 Onix & XV6 源码分析(1、XV6-x86的启动)
前言
Onix是一款相对于XV6来说功能更为健全的单核OS,由于功能更加完善,Onix也更加复杂。代码阅读起来会比较绕。
XV6是一款简单易读的多核操作系统,但其功能相对Onix来说更为简陋,比如Onix对物理内存的管理采用了位图、内核内存和进程相关的内存进行了分开管理,页目录使用单独的内核内存,没有和页表、页框混用等。而XV6显得非常简陋。尽管XV6的实验可以弥补部分缺陷。
Onix操作系统也实现了bootloader,对于将OS加载到内存的操作,Onix是采用汇编进行内核加载,并且在加载内核前,还会进行一个内存探测的操作,所以Onix的bootloader稍微有些复炸。而XV6操作系统的启动操作写的非常简洁,加载内核的操作采用的是C语言的形式,非常利于阅读学习,但是XV6不会进行内存探测。为求方便,本文主要叙述XV6的启动流程。
Onix相关链接:
-
github仓库链接
-
B站配套视频链接
XV6-x86的github链接:
- 链接
Makefile & kernel.ld文件的分析
在叙述os启动前,必须要了解其Makefile是怎么写的。同时,在了解bootmain从镜像中加载os的代码到内存中前,因为os是elf格式,所以我们需要了解os的link脚本是怎么布局的。以便我们能更好掌握os的内存布局。
Makefile
这里贴出makefile比较关键的代码:
# 利用dd命令制作OS镜像,依赖bootblock和kernel,
# 首先划分了10000个扇区
# 然后将bootblock写到了第0号扇区
# 最后从1号扇区开始,填入OS的代码文件。
xv6.img: bootblock kerneldd if=/dev/zero of=xv6.img count=10000dd if=bootblock of=xv6.img conv=notruncdd if=kernel of=xv6.img seek=1 conv=notrunc# 单独产生os的bootloader模块,并且该模块是使用$(OBJCOPY)产生,
# 所以没有elf文件头信息,只是单纯的二进制可执行文件。并且$(LD)规定
# 代码的入口点是start、并且从地址0x7C00(物理地址)开始,最最的文件名是bootblock
bootblock: bootasm.S bootmain.c$(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c bootmain.c$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c bootasm.S$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o$(OBJDUMP) -S bootblock.o > bootblock.asm$(OBJCOPY) -S -O binary -j .text bootblock.o bootblock./sign.pl bootblock# 产生AP cpu的boot代码
# 指定程序的加载地址是0x7C00(物理地址)
# 可执行代码的格式和bootblock相同,纯粹的二进制程序,没有elf的头部信息
# 但最终输出的entryother会和kernel合并
entryother: entryother.S$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c entryother.S$(LD) $(LDFLAGS) -N -e start -Ttext 0x7000 -o bootblockother.o entryother.o$(OBJCOPY) -S -O binary -j .text bootblockother.o entryother$(OBJDUMP) -S bootblockother.o > entryother.asm# 产生第一个进程,init进程的boot代码
# 同样以$(OBJCOPY)提取纯粹的可执行代码,没有elf头部信息,输出文件名为initcode
# 指定程序的加载地址是0(虚拟地址)
initcode: initcode.S$(CC) $(CFLAGS) -nostdinc -I. -c initcode.S$(LD) $(LDFLAGS) -N -e start -Ttext 0 -o initcode.out initcode.o$(OBJCOPY) -S -O binary initcode.out initcode$(OBJDUMP) -S initcode.o > initcode.asm# 产生内核的elf可执行文件
kernel: $(OBJS) entry.o entryother initcode kernel.ld$(LD) $(LDFLAGS) -T kernel.ld -o kernel entry.o $(OBJS) -b binary initcode entryother$(OBJDUMP) -S kernel > kernel.asm$(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym
这里编译器、链接器的选项具体作用读者可以自行百度,这里只阐述比较关键的部分。
首先是利用dd命令制作xv6.img镜像,从代码中可以很清楚的看到,bootblock填到了第0号扇区、kernel填到了1号以及之后的扇区。bootblock作用就是使cpu从实时模式转换为保护模式,然后将kernel从磁盘上加载到内存。这里要注意一个特殊的数字0x7C00,它出现在生成bootblock二进制文件的$(LD)阶段,这里暗示了bootblock代码在加载进内存时应该被放在0x7C00的位置。事实也是如此,在BIOS完成硬件初始化后,就会将第0号扇区(一个扇区一般就是512字节)的512字节代码加载到内存的0x7C00的位置,然后BIOS就会让eip指向0x7C00的位置,去执行bootasm.S里面的汇编代码。
这里有个关键点:在使用$(LD)命令生成bootblock.o时,命令参数部分bootasm.o放在bootmain.o前面,会导致链接时,bootasm.o代码就会靠前,这样在eip执行0x7C00位置的代码时,一定是从start开始。
bootblock是由$(OBJCOPY)生成,$(OBJCOPY)的作用就是去除elf文件中的各种头部,因为BIOS只负责从第0号扇区加载bootblock,不会解析elf文件,所以,$(OBJCOPY)去提取纯粹的二进制是非常有必要的!
bootblock具体细节下面会详细探讨
kernel.ld
这里贴出kernel.ld比较关键的代码:
OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)SECTIONS
{/* Link the kernel at this address: "." means the current address *//* Must be equal to KERNLINK */. = 0x80100000; // 定义代码起始的虚拟地址.text : AT(0x100000) { // 定义了起始加载地址*(.text .stub .text.* .gnu.linkonce.t.*)}/*省略...*/
}
从kernel的链接脚本我们可以看到,kernel的起始虚拟内存地址是0x8010 0000,内核实际加载的物理地址是x100000,由AT定义。这里我反复标注了 虚拟地址 / 物理地址 ,这两者一定要分清!
从实时模式到保护模式
本段代码实现位于xv6的bootasm.S文件。具体细节如下,xv6使用的AT&T的汇编,还是比较好懂的,读者有疑问的话,可以百度去搜相关指令的作用。英文注释已经非常详细,我就直接引用了。
#include "asm.h"
#include "memlayout.h"
#include "mmu.h"# Start the first 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..code16 # Assemble for 16-bit mode
.globl start
start:cli # BIOS enabled interrupts; disable# Zero data segment registers DS, ES, and SS.xorw %ax,%ax # Set %ax to zeromovw %ax,%ds # -> Data Segmentmovw %ax,%es # -> Extra Segmentmovw %ax,%ss # -> Stack Segment# 硬件相关,主线是OS的启动,该部分不深究也没影响,其实就是一个固定步骤。# Physical address line A20 is tied to zero so that the first PCs # with 2 MB would run software that assumed 1 MB. Undo that.
seta20.1:inb $0x64,%al # Wait for not busytestb $0x2,%aljnz seta20.1movb $0xd1,%al # 0xd1 -> port 0x64outb %al,$0x64seta20.2:inb $0x64,%al # Wait for not busytestb $0x2,%aljnz seta20.2movb $0xdf,%al # 0xdf -> port 0x60outb %al,$0x60# Switch from real to protected mode. Use a bootstrap GDT that makes# virtual addresses map directly to physical addresses so that the# effective memory map doesn't change during the transition.lgdt gdtdescmovl %cr0, %eaxorl $CR0_PE, %eaxmovl %eax, %cr0########################### 以下正式进入32位保护模式
//PAGEBREAK!# Complete the transition to 32-bit protected mode by using a long jmp# to reload %cs and %eip. The segment descriptors are set up with no# translation, so that the mapping is still the identity mapping.ljmp $(SEG_KCODE<<3), $start32.code32 # Tell assembler to generate 32-bit code now.
start32:# Set up the protected-mode data segment registersmovw $(SEG_KDATA<<3), %ax # Our data segment selectormovw %ax, %ds # -> DS: Data Segmentmovw %ax, %es # -> ES: Extra Segmentmovw %ax, %ss # -> SS: Stack Segmentmovw $0, %ax # Zero segments not ready for usemovw %ax, %fs # -> FSmovw %ax, %gs # -> GS# Set up the stack pointer and call into C.movl $start, %esp # 这里将esp栈设置到了start,由于栈向低地址处增长,所以刚好和bootasm文件的代码背道而驰。call bootmain# If bootmain returns (it shouldn't), trigger a Bochs# breakpoint if running under Bochs, then loop.movw $0x8a00, %ax # 0x8a00 -> port 0x8a00movw %ax, %dxoutw %ax, %dxmovw $0x8ae0, %ax # 0x8ae0 -> port 0x8a00outw %ax, %dx
spin:jmp spin# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:SEG_NULLASM # null segSEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code segSEG_ASM(STA_W, 0x0, 0xffffffff) # data seggdtdesc:.word (gdtdesc - gdt - 1) # sizeof(gdt) - 1.long gdt # address gdt
这里先普及一下实时模式和保护模式的区别:
-
实时模式:为兼容以前的PC。特点是:寄存器都是16位。寻址方式:16位的段寄存器 + 16位的偏移寄存器,最大寻址范围是20位。
-
保护模式:现代CPU的寻址方式。特点是:寄存器有16位、32为、64位。寻址方式:段描述符 + 32位偏移寄存器。最大寻址范围4G+。
bootasm.S是操作系统被加载内存前,最先开始执行的代码。BIOS是运行在实时模式下的程序,只拥有1M的寻址空间(boot代码被加载到0x7C00 < 1M 就能证明存在1M的限制),所以在cpu拥有4G寻址空间前,还需要进行一些初始化操作,
从实时模式 -> 保护模式的转变流程非常固定,在xv6的bootasm中实现如下:
-
对于多核处理器,最先启动的CPU(我们称为BSP(bootstrap processor)),BSP一上电就是实时模式。其余的从处理器(我们称为AP)在后面的内核初始化阶段会被BSP依次唤醒并初始化。
-
关中断。清空各种段寄存器,包括ds、es、ss。
-
打开A20地址线.
-
加载全局描述符表。即使用lgdt指令将表的地址和大小放在GDTR中。(这里的全局描述符表是临时的,后面内核初始化会更换一张gdt表,那张表更加完善。
-
将CR0寄存器第0位设置为1(PE位),此时正式转换成保护模式。
-
使用ljmp(长跳转指令)指令刷新段寄存器,跳到start32。
# xv6对ljmp的注释如下: # Complete the transition to 32-bit protected mode by using a long jmp # to reload %cs and %eip.
-
初始化数据段、栈段寄存器,将esp设置到0x7C00处,跳到bootmain函数中,该函数会将XV6 OS加载到内存。
相关的结构如下:
段描述符(Descriptor),描述一段地址的特性,包括基地址、范围、权限、粒度(范围以字节为单位还是以4K为单位)、类型(代码/数据)等信息:
全局描述符表,由许多个8字节段描述符组成的表:
全局描述符表寄存器,其基地址的内容是全局描述符表的首地址,界限是全局描述符表的大小:
段选择子,【代码、数据、栈等】段使用了哪个段描述符,索引号指使用段描述符在全局描述符表的偏移(8字节为单位),第2位指明是全局描述符还是局部描述符,0~1位指示段的特权级:
从低特权级的段空间 跳到 高特权级的段空间就会发生cpu特权级的切换,cpu就是通过全局描述符表来确定一个段的特权级。最典型的就是用户进程调用系统调用产生的特权级切换,这中间涉及查tss段、切栈等复杂操作,我们后面在进行详细的讨论。
对于GDT的详细描述,这里推荐两篇博客,这两篇博客写的真的非常好!相信阅读之后,对全局描述符会有一个清晰的认识,全局描述符的几幅图片也是取自这两篇文章,如有侵权,可告知删除:
详细介绍了段描述符各个位的作用
可以作为扩展,里面介绍了局部描述符的作用
正式将XV6加载到内存
分析该部分细节前,先需要了解一下elf文件的格式,我们重点关注elf文件的ELF header和Program header table。直接上各个字段的描述:
引用自博客https://zhuanlan.zhihu.com/p/165336511如有侵权,可告知删除:
#define ELF_MAGIC 0x464C457FU // "\x7FELF" in little endian// ELF 文件的头部
struct elfhdr {uint magic; // 4 字节,为 0x464C457FU(大端模式)或 0x7felf(小端模式)// 表明该文件是个 ELF 格式文件uchar elf[12]; // 12 字节,每字节对应意义如下:// 0 : 1 = 32 位程序;2 = 64 位程序// 1 : 数据编码方式,0 = 无效;1 = 小端模式;2 = 大端模式// 2 : 只是版本,固定为 0x1// 3 : 目标操作系统架构// 4 : 目标操作系统版本// 5 ~ 11 : 固定为 0ushort type; // 2 字节,表明该文件类型,意义如下:// 0x0 : 未知目标文件格式// 0x1 : 可重定位文件// 0x2 : 可执行文件// 0x3 : 共享目标文件// 0x4 : 转储文件// 0xff00 : 特定处理器文件// 0xffff : 特定处理器文件ushort machine; // 2 字节,表明运行该程序需要的计算机体系架构,// 这里我们只需要知道 0x0 为未指定;0x3 为 x86 架构uint version; // 4 字节,表示该文件的版本号uint entry; // 4 字节,该文件的入口地址,没有入口(非可执行文件)则为 0uint phoff; // 4 字节,表示该文件的“程序头部表”相对于文件的位置,单位是字节uint shoff; // 4 字节,表示该文件的“节区头部表”相对于文件的位置,单位是字节uint flags; // 4 字节,特定处理器标志ushort ehsize; // 2 字节,ELF文件头部的大小,单位是字节ushort phentsize; // 2 字节,表示程序头部表中一个入口的大小,单位是字节ushort phnum; // 2 字节,表示程序头部表的入口个数,// phnum * phentsize = 程序头部表大小(单位是字节)ushort shentsize; // 2 字节,节区头部表入口大小,单位是字节ushort shnum; // 2 字节,节区头部表入口个数,// shnum * shentsize = 节区头部表大小(单位是字节)ushort shstrndx; // 2 字节,表示字符表相关入口的节区头部表索引
};// 程序头表
struct proghdr {uint type; // 4 字节, 段类型// 1 PT_LOAD : 可载入的段// 2 PT_DYNAMIC : 动态链接信息// 3 PT_INTERP : 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小// 4 PT_NOTE : 指定辅助信息的位置和大小// 5 PT_SHLIB : 保留类型,但具有未指定的语义// 6 PT_PHDR : 指定程序头表在文件及程序内存映像中的位置和大小// 7 PT_TLS : 指定线程局部存储模板uint off; // 4 字节, 段的第一个字节在文件中的偏移uint vaddr; // 4 字节, 段的第一个字节在内存中的虚拟地址uint paddr; // 4 字节, 段的第一个字节在内存中的物理地址(适用于物理内存定位型的系统)uint filesz; // 4 字节, 段在文件中的长度uint memsz; // 4 字节, 段在内存中的长度uint flags; // 4 字节, 段标志// 1 : 可执行// 2 : 可写入// 4 : 可读取uint align; // 4 字节, 段在文件及内存中如何对齐
};
如果感兴趣的话,可以参考文章:ELF文件格式的详解,这篇文章讲解的更为详细,但是对于现阶段来说,可以不用了解太仔细,知道elf文件的ELF header和Program header table各个字段的作用就足够你继续学习XV6操作系统。
对kernel elf文件解析的主流程:
void
bootmain(void)
{struct elfhdr *elf;struct proghdr *ph, *eph;void (*entry)(void);uchar* pa;// 预留足够空间// 46Kelf = (struct elfhdr*)0x10000; // scratch space// 以0为偏移读4096个字节,读elf文件头// Read 1st page off diskreadseg((uchar*)elf, 4096, 0);// 判断elf文件魔数。// Is this an ELF executable?if(elf->magic != ELF_MAGIC)return; // let bootasm.S handle error// 定位到Program header table// Load each program segment (ignores ph flags).ph = (struct proghdr*)((uchar*)elf + elf->phoff);// 程序头部表个数eph = ph + elf->phnum;// 一个段一个段的读for(; ph < eph; ph++){ // 以struct proghdr为单位自增。// 应该加载到的物理内存,xv6中是0x100000pa = (uchar*)ph->paddr;// 读取整个段到pa中readseg(pa, ph->filesz, ph->off);if(ph->memsz > ph->filesz)// mem大小比file大小大,多余的补零stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);}// Call the entry point from the ELF header.// Does not return!entry = (void(*)(void))(elf->entry); // 在xv6的kernel.ld中描述为_startentry();
}
前半部分做的操作就是不断从kernel的elf文件中按照程序头将各个段读取到内存。后面通过elf头中的entry,该字段保存内核入口点_start(这可以通过阅读kernel.ld文件来证明),去执行_start开始的代码,这里要提醒读者的是,到目前为止,对cpu来说状态是:保护模式 & 已经装填了全局描述符 & 一切地址皆是物理地址。而kernel的elf文件中代码都是使用的(0x8010 0000开始的)虚拟地址,所以我们后面在entry.S中会看到,在给_start赋值前,会通过一个宏将真正的入口地址的虚拟地址转换为XV6加载到内存的物理地址。从而我们在物理地址寻址模式下,可以利用_start准确跳转到xv6的入口代码。
需要注意的是:在执行bootasm汇编代码时,esp的位置是不确定的,唯一能确定的是esp在1M空间之内。在bootasm汇编最后才会将esp挪到0x7c00的位置!
在bootmain执行完毕后,内存布局如下:
接下来深入分析一下从磁盘读取文件的细节,这些内容都是和硬件相关的,通过操作硬件寄存器来实现读写磁盘,这部分深入下去也是挺让人头大的,作者也是菜鸟一个,也就力所能及的叙述一些自己明白的东西吧。
void
waitdisk(void)
{// Wait for disk ready.while((inb(0x1F7) & 0xC0) != 0x40);
}// readsect也引用自https://zhuanlan.zhihu.com/p/165336511,如有侵权,可联系我删除
// Read a single sector at offset into dst.
void
readsect(void *dst, uint offset)
{// Issue command.waitdisk();outb(0x1F2, 1); // count = 1 // 要读取的扇区数量 count = 1outb(0x1F3, offset); // 扇区 LBA 地址的 0-7 位outb(0x1F4, offset >> 8); // 扇区 LBA 地址的 8-15 位outb(0x1F5, offset >> 16); // 扇区 LBA 地址的 16-23 位outb(0x1F6, (offset >> 24) | 0xE0); // offset | 11100000 保证高三位恒为 1// 第7位 恒为1// 第6位 LBA模式的开关,置1为LBA模式// 第5位 恒为1// 第4位 为0代表主硬盘、为1代表从硬盘// 第3~0位 扇区 LBA 地址的 24-27 位outb(0x1F7, 0x20); // cmd 0x20 - read sectors // 20h为读,30h为写// Read data.waitdisk();insl(0x1F0, dst, SECTSIZE/4); // 读的时候以4字节位单位读,所以需要扇区除以4,代表要读的次数
}// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked.
void
readseg(uchar* pa, uint count, uint offset)
{uchar* epa; // end phy addrepa = pa + count; // 结束地址// 这里将起始物理地址回退了offset的SECTSIZE(512)余数个Byte// 因为在从磁盘上读数据的时候,以512字节进行读取。所以offset会以512为单位向下取整,// 被换算成扇区号的偏移。如果offset原来不是SECTSIZE的整数倍,向下取整会导致offset截断,// 记换算后的offset为ofs,此时如果直接读取ofs扇区会多读offset % SECTSIZE个字节,// 所以需要提前将pa的地址减去offset % SECTSIZE个字节来排除offset被截断的多余的字节。// Round down to sector boundary.pa -= offset % SECTSIZE; // 将offset换算成扇区号// Translate from bytes to sectors; kernel starts at sector 1.offset = (offset / SECTSIZE) + 1; // 依次读取每个扇区,最后一个扇区多读了也没关系。for(; pa < epa; pa += SECTSIZE, offset++)readsect(pa, offset);
}
仔细推敲readseg函数中pa回滚的操作,总感觉这样贸然回滚,会覆盖之前已经被加载到内存中的代码。既然xv6敢这样写,八成说明是没有问题的,具体的原因,作者实在琢磨不透,如果有了解的朋友,可以在评论区交流一下。
跳到_start,正式进入内核前的预初始化
相关代码文件是entry.S
#include "asm.h"
#include "memlayout.h"
#include "mmu.h"
#include "param.h"# Multiboot header. Data to direct multiboot loader.
.p2align 2
.text
.globl multiboot_header
multiboot_header:#define magic 0x1badb002#define flags 0.long magic.long flags.long (-magic-flags)# 这里就能看到,因为elf描述的一些标签是使用的虚拟地址,
# 而在进入entry开启分页前都是使用的物理地址,所以使用了
# V2P_WO宏将entry转换成了物理地址。方便bootmain跳转到entry.
# V2P_WO展开就是将输入的地址减去 0x8000 0000 的偏移。
.globl _start
_start = V2P_WO(entry)# Entering xv6 on boot processor, with paging off.
.globl entry
entry:# 打开4M big page开关# Turn on page size extension for 4Mbyte pagesmovl %cr4, %eaxorl $(CR4_PSE), %eaxmovl %eax, %cr4# 将页目录设置为entrypgdir,同样由于在启用虚拟内存前,# 需要将虚拟地址entrypgdir转换为物理地址,有关entrypgdir# 的定义在内存管理章节进行详细叙述。# Set page directorymovl $(V2P_WO(entrypgdir)), %eaxmovl %eax, %cr3# 开启分页# Turn on paging.movl %cr0, %eaxorl $(CR0_PG|CR0_WP), %eaxmovl %eax, %cr0########################### 以下正式进入分页模式,地址皆是虚拟地址# 再一次修改esp指针,将esp移到内核代码范围中# Set up the stack pointer.movl $(stack + KSTACKSIZE), %esp# 真正进入内核main函数,开始各种初始化。mov $main, %eaxjmp *%eax.comm stack, KSTACKSIZE
该部分代码负责进入main函数前的初始化,主要工作如下:
-
打开4M big page分页开关,让cpu支持4M大页。entrypgdir会将内核区域与映射为物理地址低4M的大页,我们后面会详细进行讨论,entrypgdir的生命周期非常短,在main函数中初始化过程中,会另外产生一个粒度更小的页表kpgdir(4K为一页),该页表会一直作为xv6的内核页表。
-
设置entrypgdir为BSP(booststrap processor)的页目录。
-
开启分页。
-
修改esp。指向内核自己分配的4K大小的栈上。
-
进入main。
在entry.S代码执行完后,cpu开启分页模式,所有的地址都将以虚拟地址的形式存在。此时内存布局如下:
至此,cpu的预初始化进行完毕。下面几章将围绕内核的初始化去讲解类unix操作系统的内存管理、进程调度、文件系统子模块。
本章完结