1. 背景
原本计划自己学习写个操作系统的,但是工欲善其事必先利其器,先学习下别人是怎么做出来的,自己再动手,自然会更加得心应手一些。于是开始学习xv6这个MIT出品的小型操作系统。
2. 声明
这个不是原创文章,只是在学习XV6的过程中的一些笔记或者文档整理。文章大部队内容都是xv6的官方文档或者翻译文档。
3. 源码地址
xv6的github代码: https://github.com/mit-pdos/xv6-public
xv6的中文文档: https://github.com/ranxian/xv6-chinese
4. 正文
4.1 bootloader启动
4.1.1 bootloader是什么
bootloader, 很多地方翻译成引导加载器,总感觉怪怪的,个人习惯于直接叫bootloader更加直接点。
很早之前做通信的时候,每次拿着电路板要烧录我们的新系统,第一就是烧bootloader,之前一直不知道是什么东西,只是大概知道是启动的程序。最近看操作系统才真正明白,简单点说,就是一段代码,而启动一个系统,假设不考虑硬件本身上电触发的底层程序,操作系统的入口程序就是bootloader。
在xv6中,bootloader是什么东西?
就是一个由16位和32位汇编混合编写而成的bootasm.S,和一个由 C 写成的bootmain.c。
想深入学习bootloader的可以先从wiki看起来:X86 Assembly/Bootloaders
4.1.2 bootloader启动前的背景
当 x86 PC 启动时,它执行的是一个叫 BIOS 的程序。BIOS 存放在非易失存储器中,BIOS 的作用是在启动时进行硬件的准备工作,接着把控制权交给操作系统。具体来说,BIOS 会把控制权交给从引导扇区(用于引导的磁盘的第一个512字节的数据区)加载的代码。引导扇区中包含引导加载器——负责内核加载到内存中。BIOS 会把引导扇区加载到内存 0x7c00 处,接着(通过设置寄存器 %ip)跳转至该地址。引导加载器开始执行后,处理器处于模拟 Intel 8088 处理器的模式下。而接下来的工作就是把处理器设置为现代的操作模式,并从磁盘中把 xv6 内核载入到内存中,然后将控制权交给内核。 [From xv6文档]
找到一个流程图,虽然不是xv6的,但是毕竟通用的系统启动过程,可以作为参照:
4.1.3 bootloader启动的第一步
先上代码:
# 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 zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
备注:At&t的汇编格式要求寄存器前面带%; Intel 汇编格式中,寄存器名不需要加前缀
对这段代码的解释是:
BIOS 完成工作后,%ds, %es, %ss 的值是未知的,所以在屏蔽中断后,引导加载器的第一个工作就是将 %ax 置零,然后把这个零值拷贝到三个段寄存器中。 [From xv6文档]
那么问题的关键来了,首先我们研究的是操作系统,那么BIOS之前的事情(就是硬件层面的流程),不是这次的重点,姑且先放到一边。我们bootasm.S
的代码中第一行 cli 是一个禁止中断的操作,这是以后用到的东西,姑且也先放到一边。
从看到xorw %ax,%ax
这一行代码开始,是否会产生如下一些疑问?
- ax是个什么东西?
- 为什么代码里我们上来就要先将 ax 置零?
- 为什么第二步接着就是对ds,es,ss这些寄存器的操作?
- bootloader 这会在干什么?
要回答这些问题,那么我们就先要具备一些基础知识:
补充基础知识1:寄存器
如果不懂ax是什么东西,那么需要先去学习下汇编的基础知识后,再看后面的内容,否则感觉不懂汇编相当于"一个法师出门不带蓝就想去打仗..."
这个就不列链接了,随便百度google就可以知道这些基本概念。
补充基础知识2: 实模式与保护模式
a> uCore OS实验指导书中的: 保护模式和分段机制
b> CSDN上一篇排版很烂但内容不错的博客: 实模式和保护模式区别及寻址方式
补充基础知识3:寻址
- 实模式下的内存寻址:
段首地址×16+偏移量 = 物理地址
段首地址×16:CPU将段寄存器中的数值向左偏移4-bit,放到20-bit的地址线上就成为20-bit的Base Address- 保护模式下分段机制的内存寻址:
保护模式下 分段机制是利用一个称作段选择符的偏移量,从而到描述符表找到需要的段描述符,而这个段描述符中就存放着真正的段的物理首地址,再加上偏移量
OK, 假设我们花了点时间,了解了实模式和保护模式以及对应的寻址方式,那么我们应该能反应过来:
- 当bootloader启动的时候,当前是在实模式下, 这个是前提条件
- 在处理器处在模拟 Intel 8088 的实模式下:
我们的操作系统有8个16位通用寄存器可用,但实际上处理器发送给内存的是20位的地址。这时,多出来的4位其实是由段寄存器%cs, %ds, %es, %ss提供的。当程序用到一个内存地址时,处理器会自动在该地址上加上某个16位段寄存器值的16倍。因此,内存引用中其实隐含地使用了段寄存器的值:取值会用到 %cs,读写数据会用到 %ds,读写栈会用到 %ss
备注:CS(Code Segment),DS(Data Segment),SS(Stack Segment)
- 基于1-2两个步骤,我们知道了为啥会出现ds,ss这些东西了。那么最开始产生的疑问,是不是也就迎刃而解了。
- 总结下,就是bootloader启动的第一步其实就是为实模式做一些收尾工作:BIOS必须通过实模式启动,但当需要我们引入xv6自己的内核时,需要从实模式转换到保护模式(因为保护模式的寻址方式更加优秀,可访问地址位更多)。
但是实模式刚运行完,很多遗留下的一些寄存器的值需要我们整理下,所以我们需要“将 %ax 置零,然后把这个零值拷贝到三个段寄存器中”。
4.1.4 打开A20开关
实模式的东西处理完,我们的重点是要转换到保护模式中去开始真正的工作。
- 如何开启保护模式呢?
答:首先需要打开A20开关。 - 为什么要打开A20开关?
答:保护模式是需要将地址位从20位切换到32位(如果是64位系统,则是切换到64位)。所以先打开这个开关,表明将32位地址总线打开。
直接看代码:
# 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 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
虚拟地址 segment:offset 可能产生21位物理地址,但 Intel 8088 只能向内存传递20位地址,所以它截断了地址的最高位:0xffff0 + 0xffff = 0x10ffef,但在8088上虚拟地址 0xffff:0xffff 则是引用物理地址 0x0ffef。早期的软件依赖硬件来忽略第21位地址位,所以当 Intel 研发出使用超过20位物理地址的处理器时,IBM 就想出了一个技巧来保证兼容性。那就是,如果键盘控制器输出端口的第2位是低位,则物理地址的第21位被清零;否则,第21位可以正常使用。引导加载器用 I/O 指令控制端口 0x64 和 0x60 上的键盘控制器,使其输出端口的第2位为高位,来使第21位地址正常工作。[From xv6文档]
4.1.5 从实模式切换到保护模式
4.1.5.1 加载全局符号表GDT
lgdt gdtdesc
引导加载器执行 lgdt指令来把指向 gdt 的指针 gdtdesc加载到全局描述符表(GDT)寄存器中。[From xv6文档]
4.1.5.2 保护模式正式开启
# 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 gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0
//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.
开始保护模式的标识是CR0_PE=1, 这里通过orl $CR0_PE, %eax
实现,接下来,处理器还需要将16位模式(因为之前是实模式,所以目前还是处于16位的模式)切换到32位。
- 允许保护模式并不会马上改变处理器把逻辑地址翻译成物理地址的过程;只有当某个段寄存器加载了一个新的值,然后处理器通过这个值读取 GDT 的一项从而改变了内部的段设置。
- 我们没法直接修改 %cs,所以使用了一个 ljmp 指令
ljmp $(SEG_KCODE<<3), $start32
。跳转指令会接着在下一行执行,但这样做实际上将 %cs 指向了 gdt 中的一个代码描述符表项。该描述符描述了一个32位代码段,这样处理器就切换到了32位模式下。[From xv6文档]
4.1.6 保护模式下初始化数据
# Set up the protected-mode data segment registers
movw $(SEG_KDATA<<3), %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %ss # -> SS: Stack Segment
movw $0, %ax # Zero segments not ready for use
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
在32位模式下,引导加载器首先用 SEG_KDATA(8458-8461)初始化了数据段寄存器。逻辑地址现在是直接映射到物理地址的。
4.1.7 运行bootmain.c代码
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
运行 C 代码之前的最后一个步骤是在空闲内存中建立一个栈。内存 0xa0000 到 0x100000 属于设备区,而 xv6 内核则是放在 0x100000 处。引导加载器自己是在 0x7c00 到 0x7d00。本质上来讲,内存的其他任何部分都能用来存放栈。引导加载器选择了 0x7c00(在该文件中即 $start)作为栈顶;栈从此处向下增长,直到 0x0000,不断远离引导加载器代码。[From xv6文档]
这里说了半天的地址分配,为了方便理解我简单画了个图,如下:
接着,我们看bootmain.c 里面的代码:
引导加载器的 C 语言部分 bootmain.c(8500)目的是在磁盘的第二个扇区开头找到内核程序。如我们在第2章所见,内核是 ELF 格式的二进制文件。为了读取 ELF 头,bootmain 载入 ELF 文件的前4096字节(8514),并将其拷贝到内存中 0x10000 处。
下一步要通过 ELF 头检查这是否的确是一个 ELF 文件。bootmain 从磁盘中 ELF 头之后 off 字节处读取扇区的内容,并写到内存中地址 paddr 处。bootmain 调用 readseg 将数据从磁盘中载入(8538),并调用 stosb 将段的剩余部分置零(8540)。stosb(0492)使用 x86 指令 rep stosb 来初始化内存块中的每个字节。[From xv6文档]
4.1.8 bootloader总结
以上部分简单整理了bootloader启动相关的一些代码和流程。简单点说,就是把地址的寻址模式设置好,然后把内核加载进来。这部分从整体上来说,只是启动的部分,对整个操作系统来说,是很简单的部分,但因为涉及的基本功要求比较多,所以对于入门小白(比如我)来说,还是花了一些时间,才真正明白里面是个什么意思。且回头看,这部分对我来说信息量是最大的。
接下来,真正的操作系统开始了...
4.2 第一个地址空间
当 PC 开机时,它会初始化自己然后从磁盘中载入 boot loader 到内存并运行。然后,boot loader 把 xv6 内核从磁盘中载入并从 entry(1040)开始运行。x86 的分页硬件在此时还没有开始工作;所以这时的虚拟地址是直接映射到物理地址上的。
boot loader 把 xv6 内核装载到物理地址 0x100000 处。之所以没有装载到内核指令和内核数据应该出现的 0x80100000,是因为小型机器上很可能没有这么大的物理内存。而之所以在 0x100000 而不是 0x0 则是因为地址 0xa0000 到 0x100000 是属于 I/O 设备的。[From xv6文档]
虚拟地址的空间分布如下图:
bootloader紧接着就是entry开始运行,entry具体做了哪些事情呢?
# By convention, the _start symbol specifies the ELF entry point.
# Since we haven't set up virtual memory yet, our entry point is
# the physical address of 'entry'.
.globl _start
_start = V2P_WO(entry)
# Entering xv6 on boot processor, with paging off.
.globl entry
entry:
# Turn on page size extension for 4Mbyte pages
movl %cr4, %eax
orl $(CR4_PSE), %eax
movl %eax, %cr4
# Set page directory
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0
# Set up the stack pointer.
movl $(stack + KSTACKSIZE), %esp
# Jump to main(), and switch to executing at
# high addresses. The indirect call is needed because
# the assembler produces a PC-relative instruction
# for a direct jump.
mov $main, %eax
jmp *%eax
.comm stack, KSTACKSIZE
为了让内核的剩余部分能够运行,entry 的代码设置了页表,将 0x80000000(称为 KERNBASE(0207))开始的虚拟地址映射到物理地址 0x0 处。
它将 entrypgdir 的物理地址载入到控制寄存器 %cr3 中。分页硬件必须知道 entrypgdir 的物理地址,因为此时它还不知道如何翻译虚拟地址;它也还没有页表。entrypgdir 这个符号指向内存的高地址处,但只要用宏 V2P_WO(0220)减去 KERNBASE 便可以找到其物理地址。为了让分页硬件运行起来, xv6 会设置控制寄存器 %cr0 中的标志位 CR0_PG。
现在 entry 就要跳转到内核的 C 代码,并在内存的高地址中执行它了。首先它将栈指针 %esp 指向被用作栈的一段内存(1054)。所有的符号包括 stack 都在高地址,所以当低地址的映射被移除时,栈仍然是可用的。最后 entry 跳转到高地址的 main 代码中。我们必须使用间接跳转,否则汇编器会生成 PC 相关的直接跳转(PC-relative direct jump),而该跳转会运行在内存低地址处的 main。 main 不会返回,因为栈上并没有返回 PC 值。好了,现在内核已经运行在高地址处的函数 main(1217)中了。
4.3 第一个进程
4.3.1 进程结构体
xv6 使用结构体 struct proc 来维护一个进程的状态,其中最为重要的状态是进程的页表,内核栈,当前运行状态。我们接下来会用 p->xxx 来指代 proc 结构中的元素。[From xv6文档]
结构体 struct proc包含进程大小、页表、内核栈、进程状态、进程id、父进程、目录等等,代码如下:
// Per-process state
struct proc {
uint sz; // Size of process memory (bytes)
pde_t* pgdir; // Page table
char *kstack; // Bottom of kernel stack for this process
enum procstate state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
struct trapframe *tf; // Trap frame for current syscall
struct context *context; // swtch() here to run process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
每个进程都有一个运行线程(或简称为线程)来执行进程的指令。线程可以被暂时挂起,稍后再恢复运行。系统在进程之间切换实际上就是挂起当前运行的线程,恢复另一个进程的线程。线程的大多数状态(局部变量和函数调用的返回地址)都保存在线程的栈上。
4.3.2 创建第一个进程
xv6上第一个进程的创建是由userinit(void)
触发的,在mian.c的代码里可以看到
userinit(); // first user process
userinit(void)
具体干了什么事情呢?要去看proc.c的代码:
// Set up first user process.
void
userinit(void)
{
struct proc *p;
extern char _binary_initcode_start[], _binary_initcode_size[];
p = allocproc();
initproc = p;
if((p->pgdir = setupkvm()) == 0)
panic("userinit: out of memory?");
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);
p->sz = PGSIZE;
memset(p->tf, 0, sizeof(*p->tf));
p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
p->tf->ds = (SEG_UDATA << 3) | DPL_USER;
p->tf->es = p->tf->ds;
p->tf->ss = p->tf->ds;
p->tf->eflags = FL_IF;
p->tf->esp = PGSIZE;
p->tf->eip = 0; // beginning of initcode.S
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
// this assignment to p->state lets other cores
// run this process. the acquire forces the above
// writes to be visible, and the lock is also needed
// because the assignment might not be atomic.
acquire(&ptable.lock);
p->state = RUNNABLE;
release(&ptable.lock);
}
在 main 初始化了一些设备和子系统后,它通过调用 userinit(1239)建立了第一个进程。userinit 首先调用 allocproc。allocproc(2205)的工作是在页表中分配一个槽(即结构体 struct proc),并初始化进程的状态,为其内核线程的运行做准备。注意一点:userinit 仅仅在创建第一个进程时被调用,而 allocproc 创建每个进程时都会被调用。allocproc 会在 proc 的表中找到一个标记为 UNUSED(2211-2213)的槽位。当它找到这样一个未被使用的槽位后,allocproc 将其状态设置为 EMBRYO,使其被标记为被使用的并给这个进程一个独有的 pid(2201-2219)。接下来,它尝试为进程的内核线程分配内核栈。如果分配失败了,allocproc 会把这个槽位的状态恢复为 UNUSED 并返回0以标记失败。[From xv6文档]
4.3.4 运行第一个进程
现在第一个进程的状态已经被设置好了,让我们来运行它。在 main 调用了 userinit 之后, mpmain 调用 scheduler 开始运行进程(1267)。scheduler(2458)会找到一个 p->state 为 RUNNABLE 的进程 initproc,然后将 per-cpu 的变量 proc 指向该进程,接着调用 switchuvm 通知硬件开始使用目标进程的页表(1768)。注意,由于 setupkvm 使得所有的进程的页表都有一份相同的映射,指向内核的代码和数据,所以当内核运行时我们改变页表是没有问题的。switchuvm 同时还设置好任务状态段 SEG_TSS,让硬件在进程的内核栈中执行系统调用与中断。[From xv6文档]
// Common CPU setup code.
static void
mpmain(void)
{
cprintf("cpu%d: starting\n", cpunum());
idtinit(); // load idt register
xchg(&cpu->started, 1); // tell startothers() we're up
scheduler(); // start running processes
}
待续...