和现代操作系统比起来,xv6基本只能算是个复古风格的玩具模型。它仿照unix version 6设计,不涉及现代操作系统中各种复杂的优化。但好处是简单明了,码工精美,十分适合拿来给我等(挑战linux kernel失败的)初级选手作入门教程。
这一系列主要的参考资料是xv6自带说明书Xv6, a simple Unix-like teaching operating system以及更形而上一点的Operating Systems: Three Easy Pieces(OSTEP)。
xv6适用于多核x86系统,主要使用ANSI C(以及少量AT&T风格的汇编语言)编码。我在阅读源码过程中最初的障碍来源于对硬件和汇编的无知,所以就从相关基础知识开始。
xv6系列包括:
- minimal assembly
- how system boots
- address space
- interrupts
- system calls
- process
- context switch
- synchronization
- system initialization
寄存器
CPU包含若干组寄存器,xv6涉及到的主要有:
- 8个general purpose registers:%eax、%ebx、%ecx、%edx、%edi、%esi、%ebp、%esp
依据惯例,寄存器名称中的e
代表extended,标明其长度为32位。它们较低的16位分别可以通过%ax、%bx、%cx、%dx、%di、%si、%bp、%sp访问。修改%ax即修改%eax,反之也对。更近一步,%ax、%bx、%cx、%dx较高的8位可以通过%ah、%bh、%ch、%dh,较低的8位可以通过%al、%bl、%cl、%dl。
其中比较特殊的是%esp(stack pointer,指向栈的最低位置)和%ebp(base pointer,在函数过程中指示frame的起始位置)。
- 1个instruction pointer:%eip
32位。%eip存储program counter,指向当前指令开始的位置。%eip通常不能直接操作,需要通过control flow instructions来控制。
- 4个control registers:%cr0、%cr2、%cr3、%cr4
32位。在xv6中主要用于支持内存分页。%cr0用于存储一系列标识内存状态的flag,%cr3用于存储分页表地址,%cr4用于控制分页大小。
- 6个segment registers:%cs、%ss、%ds、%es、%fs、%gs
16位。包括%cs(code segment),%ss(stack segment),%ds(data segment)等。
- 3个descriptor registers:%gdtr、%ldtr、%idtr
16位。%gdtr、%ldtr用于访问segment descriptor table(SDT),%idtr用于访问interrupt descriptor table(IDT)。
另外还有若干专用的寄存器,例如%eflags用于存储若干CPU状态相关的标志位、%tr用于指示task state segment等。
xv6没有涉及用于浮点数运算、debug和测试等的寄存器。
内存
主内存访问速度比寄存器慢102倍。x86系统支持32位地址,可以用于访问4 GB内存空间。
静态数据
静态数据可以通过它们在声明时设定的label访问。
.data
x:
.word 42 # 常数
array:
.long 1, 2, 3 # 数组
str:
.string "hello" # 字符串
访问方式
addressed by register: (%eax)
with offset: -4(%eax), memory addressed by %eax-4
simple arithmetic: (%esi, %eax, 4), memory addressed by %esi+4*%eax
长度后缀
当操作所涉及的数据长度存在多种可能的时候,必须用后缀标明。可用的后缀包括b
(byte)、w
(word,2 bytes)和l
(long,4 bytes)。
mov: movb、movw、movl
add: addb、addw、addl
指令
data movement instructions
- mov <src> <dst>
mov
将<src>中的数据复制到<dst>。数据可以在两个寄存器之间、寄存器和内存之间移动,但不能直接在两个内存地址之间移动。
mov %esi, %eax: move value stored in %esi to %eax
mov (%esi), %eax: move value stored at the memory position indexed by %esi to %eax
mov $1, %eax: move value 1 to %eax
movb $1, (%esi): move one byte value of 1 to the memory position indexed by %esi
- push <val>
push
指令首先将%esp中的数值减4,然后将<val>复制到%esp指向的内存地址
push %eax: push value stored in %eax onto stack
push (%eax): push value stored at the memory position indexed by %eax onto stack
push $1: push value 1 onto stack
- pop <addr>
pop
指令将%esp指向的内存中的数据(4 bytes)复制到<addr>中,然后将%esp中的数值加4。<addr>可以是寄存器或内存地址。
pop %eax: pop value on top of stack to %eax
pop (%eax): pop value on top of stack to the memory position indexed by %eax
arithmetic & logic instructions
-
add/sub
-
inc/dec
-
imul
-
idiv
-
and/or/xor
control flow instructions
-
jmp <label>
-
cmp & jcondition
-
call <label>
call
首先将返回后将下一个指令(即函数返回后将要执行的指令)的地址压栈(称此地址为return address),然后跳转到<label>指向的指令位置。
return address指向%eip+sizeof(call instruction)的位置。在跨segment调用(即所谓far call)的情况下,return address也包含当前指令所属的segment(%cs)。
- ret
ret
从栈上弹出跳转之前的指令位置,跳转回这个位置。
调用规则
C语言的函数调用过程将栈切分成若干frame,每个函数各自维护一个frame。在当前函数的frame中,%ebp指示frame的起始位置(因此%ebp被称为base pointer),而%esp指示栈的底端,即frame的结束位置。
函数调用时,主调和被调函数都需要遵从一定的规则。
caller rules
在调用之前,主调函数需要做一些准备工作:
- 被调函数可能会修改寄存器内容,所以在执行之前需要先备份寄存器状态。根据约定,主调函数负责%eax,%ecx,%edx。
- 将函数参数压栈(x86 64bit有了更多寄存器,当参数不多的时候可以直接通过寄存器传参)。
- 执行
call
,上文提过,call
会将返回之后将要运行的指令位置压栈,然后执行一个无条件跳转。
从被调函数返回之后,继续执行当前控制流指令之前,主调函数需要做一些清理工作:
- 将函数参数从栈上移除。
- 将之前备份过的%eax、%ecx、%edx中的数据从栈上取出,还原到相应寄存器中。
callee rules
被调过程在执行自己的指令之前,同样需要做一些准备工作:
- 开始执行被调函数之前,需要在栈上为它分配一个新的frame。具体的操作是将%ebp中的内容(主调函数对应frame的base)备份到栈上,然后令%ebp指向%esp的位置(当前过程对应的frame的base)。
- 在栈上为局域变量分配空间
- 备份寄存器上的数据,按照约定%ebx、%edi、%esi由被调函数负责。
在被调过程返回之前,也有一些收尾工作:
- 将返回值存储在%eax中。
- 将之前备份过的%ebx、%edi、%esi中的数据还原。
- 移除局域变量。这可以通过令%esp指向%ebp(当前过程对应frame的起始位置)实现。
- 清除当前frame,将之前备份过的主调函数的frame base值从栈上弹出,还原到%ebp中。
- 执行
ret
指令,上文提过ret
将从栈上弹出一个指令位置,并无条件跳转到这个位置处。