从 0 开始写一个操作系统
作者:解琛
时间:2020 年 8 月 29 日
一、准备知识
写一个操作系统难吗?别被现在上百万行的 Linux 和 Windows 操作系统吓倒。
当年 Thompson 乘他老婆带着小孩度假留他一人在家时,写了 UNIX。
当年 Linus 还是一个 21 岁大学生时完成了 Linux 雏形。
MIT 的 Frans Kaashoek 等在 2006 年参考 PDP-11 上的 UNIX Version 6 写了一个可在 X86 上跑的操作系统 xv6(基于MIT License),用于学生学习操作系统。
我们可以站在他们的肩膀上,基于 xv6 的设计,尝试从 0 开始完成一个操作系统 ucore,包含虚存管理、进程管理、处理器调度、同步互斥、进程间通信、文件系统等主要内核功能,总的内核代码量(C + asm)不会超过5K行。
ucore 的运行环境可以是真实的 X86 计算机,不过考虑到调试和开发的方便,我们可采用 X86 硬件模拟器,比如 QEMU、BOCHS、VirtualBox、VMware Player 等。
ucore 的开发环境主要是 GCC 中的 gcc、gas、ld 和 MAKE 等工具,也可采用集成了这些工具的 IDE 开发环境 Eclipse-CDT 等。
在分析源代码上,可以采用 Scitools 提供的 understand 软件(跨平台),windows 环境上的 source insight 软件,或者基于 emacs + ctags,vim + ctags 等,都可以比较方便在在一堆文件中查找变量、函数定义、调用/访问关系等。
软件开发的版本管理可以采用 GIT、SVN 等。
比较文件和目录的不同可发现不同实验中的差异性和进行文件合并操作,可使用 meld、kdiff3、UltraCompare 等软件。
调试(deubg)实验有助于发现设计中的错误,可采用 gdb(配合qemu)等调试工具软件。
并可整个实验的运行环境和开发环境既可以在 Linux 或 Windows 中使用。推荐使用 Linux 环境。
1.1 实现方案
通过如下步骤来一步步实现这个操作系统。
- 启动操作系统的 bootloader,用于了解操作系统启动前的状态和要做的准备工作,了解运行操作系统的硬件支持,操作系统如何加载到内存中,理解两类中断————“外设中断”,“陷阱中断”等;
- 物理内存管理子系统,用于理解 x86 分段 / 分页模式,了解操作系统如何管理物理内存;
- 虚拟内存管理子系统,通过页表机制和换入换出(swap)机制,以及中断-“故障中断”、缺页故障处理等,实现基于页的内存替换算法;
- 内核线程子系统,用于了解如何创建相对与用户进程更加简单的内核态线程,如果对内核线程进行动态管理等;
- 用户进程管理子系统,用于了解用户态进程创建、执行、切换和结束的动态管理过程,了解在用户态通过系统调用得到内核态的内核服务的过程;
- 处理器调度子系统,用于理解操作系统的调度过程和调度算法;
- 同步互斥与进程间通信子系统,了解进程间如何进行信息交换和共享,并了解同步互斥的具体实现以及对系统性能的影响,研究死锁产生的原因,以及如何避免死锁;
- 文件系统,了解文件系统的具体实现,与进程管理等的关系,了解缓存对操作系统 IO 访问的性能改进,了解虚拟文件系统(VFS)、buffer cache和disk driver之间的关系。
1.2 gcc
我的 Linux 开发环境如下。
xiechen@xiechen-Ubuntu:~$ uname -a
Linux xiechen-Ubuntu 5.4.0-42-generic #46~18.04.1-Ubuntu SMP Fri Jul 10 07:21:24 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
在 Ubuntu Linux 中的 C 语言编程主要基于 GNU C 的语法,通过 gcc 来编译并生成最终执行文件。GNU 汇编(assembler)采用的是 AT&T 汇编格式,Microsoft 汇编采用 Intel 格式。
使用下面的指令安装 gcc 编译环境。
sudo apt-get install build-essential
gcc 编译时,添加 -Wall
开启编译器几乎所有常用的警告。
1.2.1 AT&T 汇编基本语法
Ucore 中用到的是 AT&T 格式的汇编,与 Intel 格式的汇编有一些不同。二者语法上主要有以下几个不同:
不同点 | AT&T | Intel |
---|---|---|
寄存器命名原则 | %eax | eax |
源/目的操作数顺序 | movl %eax, %ebx | mov ebx, eax |
常数/立即数的格式 | movl $_value, %ebx | mov eax, _value |
把 value 的地址放入 eax 寄存器 | movl $0xd00d, %ebx | mov ebx, 0xd00d |
操作数长度标识 | movw %ax, %bx | mov bx, ax |
寻址方式 | immed32(basepointer, indexpointer, indexscale) | [basepointer + indexpointer × indexscale + imm32) |
如果操作系统工作于保护模式下,用的是 32 位线性地址,所以在计算地址时不用考虑 segment:offset
的问题。上式中的地址应为:
imm32 + basepointer + indexpointer × indexscale
1.2.2 GCC 基本内联汇编
GCC 提供了两类内联汇编语句(inline asm statements)。
- 基本内联汇编语句(basic inline asm statement);
- 扩展内联汇编语句(extended inline asm statement)。
GCC基本内联汇编很简单,一般是按照下面的格式。
asm("statements");
“asm” 和 “__asm__” 的含义是完全一样的。如果有多行汇编,则每一行都要加上 “\n\t”。
在每条命令的 结束加这两个符号,是为了让 gcc 把内联汇编代码翻译成一般的汇编代码时能够保证换行和留有一定的空格。
对于基本 asm 语句,GCC 编译出来的汇编代码就是双引号里的内容。
实际上 gcc 在处理汇编时,是要把 asm(…) 的内容"打印"到汇编文件中,所以格式控制字符是必要的。
asm("movl %eax, %ebx");
asm("xorl %ebx, %edx");
asm("movl $0, _boo);
在上面的例子中,由于我们在内联汇编中改变了 edx 和 ebx 的值,但是由于 gcc 的特殊的处理方法,即先形成汇编文件,再交给 GAS 去汇编,所以 GAS 并不知道我们已经改变了 edx和 ebx 的值。
如果程序的上下文需要 edx 或 ebx 作其他内存单元或变量的暂存,就会产生没有预料的多次赋值,引起严重的后果。
对于变量 _boo 也存在一样的问题。为了解决这个问题,就要用到扩展 GCC 内联汇编语法。
1.2.3 GCC 拓展内联汇编
#define read_cr0() ({ \
unsigned int __dummy; \
__asm__( \
"movl %%cr0,%0\n\t" \
:"=r" (__dummy)); \
__dummy; \
})
GCC扩展内联汇编的基本格式如下。
asm [volatile] ( Assembler Template
: Output Operands
[ : Input Operands
[ : Clobbers ] ])
- __asm__ 表示汇编代码的开始;
- 其后可以跟 __volatile__(这是可选项),其含义是避免 “asm” 指令被删除、移动或组合,在执行代码时,如果不希望汇编语句被 gcc 优化而改变位置,就需要在 asm 符号后添加 volatile 关键词;
- 括弧中的内容是具体的内联汇编指令代码;
- “” 为汇编指令部分;
- 数字前加前缀 “%“,如 %1,%2 等表示使用寄存器的样板操作数,可以使用的操作数总数取决于具体CPU中通用寄存器的数量;
- 由于这些样板操作数的前缀使用了 ”%“,因此,在用到具体的寄存器时就在前面加两个 “%”,如 %%cr0;
- 输出部分(output operand list),用以规定对输出变量(目标操作数)如何与寄存器结合的约束(constraint),输出部分可以有多个约束,互相以逗号分开;
- 每个约束以“=”开头,接着用一个字母来表示操作数的类型,然后是关于变量结合的约束。
表示约束条件的字母很多,下表给出几个主要的约束字母及其含义。
字母 | 含义 |
---|---|
m, v, o | 内存单元 |
R | 任何通用寄存器 |
Q | 寄存器eax, ebx, ecx,edx之一 |
I, h | 直接操作数 |
E, F | 浮点数 |
G | 任意 |
a, b, c, d | 寄存器eax/ax/al, ebx/bx/bl, ecx/cx/cl或edx/dx/dl |
S, D | 寄存器esi或edi |
I | 常数(0~31) |
输入部分(input operand list):输入部分与输出部分相似,但没有“=”。
如果输入部分一个操作数所要求使用的寄存器,与前面输出部分某个约束所要求的是同一个寄存器,那就把对应操