彭东的操作系统实战45讲的笔记https://time.geekbang.org/column/article/369502
前言
一天更个三讲吧,就算是总结一下操作系统的笔记,最近规划了一下半年的目标以及规划,想要说,有一个长远的目标去做,后面还会更新数据库,开发以及c语言和算法的一些内容
01|程序的运行过程:从代码到机器运行
Unix之父Ken Thompson
他使用B语言和汇编语言在PDP-7上完成
他的朋友Dennis Ritchie
创造了c语言,并写出来类UNIX体系的十几种操作系统
程序编译过程
那么使用命令:
gcc HelloWorld.c -o HelloWorld
gcc ./HelloWorld.c -o ./HelloWorld
就可以编译这串代码。
其实,GCC只是完成编译工作的驱动程序,他会根据编译流程分别调用预处理程序、编译程序、汇编程序、链接程序来完成具体工作
其实,我们也可以手动控制以上这个编译流程,从而留下中间文件方便研究:
gcc HelloWorld.c -E -o HelloWorld.i 预处理:加入头文件,替换宏。
gcc HelloWorld.c -S -c -o HelloWorld.s 编译:包含预处理,将 C 程序转换成汇编程序。
gcc HelloWorld.c -c -o HelloWorld.o 汇编:包含预处理和编译,将汇编程序转换成可链接的二进制程序。
gcc HelloWorld.c -o HelloWorld 链接:包含以上所有操作,将可链接的二进制程序和其它别的库链接在一起,形成可执行的程序文件。
程序装载执行
图灵提出了理想中的机器:图灵机。
图灵机是一个抽象的模型,它是这样的:有一条无限长的纸带,纸带上有无限个小格子,小格子中写有相关的信息,纸带上有一个读头,读头能根据纸带小格子里的信息做相关的操作并能来回移动。
要使图灵机得到实现,后来有一个人想了一个办法,他是冯诺依曼,他的电子计算机理论叫冯诺依曼体系结构
根据冯诺依曼体系结构构成的计算机,必须具有如下功能:
- 把程序和数据装入到计算机中;
- 必须具有长期记住程序、数据的中间结果及最终运算结果;
- 完成各种算术、逻辑运算和数据传送等数据加工处理;
- 根据需要控制程序走向,并能根据指令控制机器的各部件协调操作;能够按照要求将处理的数据结果显示给用户。
为了完成上述的功能,计算机必须具备五大基本组成部件:
- 装载数据和程序的输入设备;
- 记住程序和数据的储存器
- 完成数据加工处理的运算器
- 控制程序执行的控制器
- 显示处理结果的输出设备
读取写入数据由数据总线完成,而动作的控制就是控制总线的职责了。
更形象地将 HelloWorld 程序装入原型计算机
我们可以通过 gcc -c -S HelloWorld得到(只能得到其汇编代码,而不能得到二进制数据)。我们用objdump -d HelloWorld 得到 main函数代码
地址: 十六进制(表示存入的代码数据) 汇编代码 相关代码的注释
这是 x86_64 体系的代码,由此可以看出 x86 CPU 是变长指令集。
02|几行汇编几行C:实现一个最简单的内核
写一个最小的操作系统——Hello OS
PC 机的引导流程
看标题就知道,写操作系统要用汇编和 C 语言,尽管这个 Hello OS 很小,但也要用到两种编程语言。其实,现有的商业操作系统都是用这两种语言开发出来的。
我们还不足够从pc机的引导程序开始,所以我们借用GRUB引导程序(前提是安装Ubuntu Linux操作系统)
Hello OS 的引导流程
C 机 BIOS 固件是固化在 PC 机主板上的 ROM 芯片中的,掉电也能保存,PC 机上电后的第一条指令就是 BIOS 固件中的,它负责检测和初始化 CPU、内存及主板平台,然后加载引导设备(大概率是硬盘)中的第一个扇区数据,到 0x7c00 地址开始的内存空间,再接着跳转到 0x7c00 处执行指令,在我们这里的情况下就是 GRUB 引导程序。
Hello OS 引导汇编代码
这里我们使用汇编去写
原因:
C 作为通用的高级语言,不能直接操作特定的硬件,而且 C 语言的函数调用、函数传参,都需要用栈。栈简单来说就是一块内存空间,其中数据满足后进先出的特性,它由 CPU 特定的栈寄存器指向,所以我们要先用汇编代码处理好这些 C 语言的工作环境。
;彭东 @ 2021.01.09
MBT_HDR_FLAGS EQU 0x00010003
MBT_HDR_MAGIC EQU 0x1BADB002 ;多引导协议头魔数
MBT_HDR2_MAGIC EQU 0xe85250d6 ;第二版多引导协议头魔数
global _start ;导出_start符号
extern main ;导入外部的main函数符号
[section .start.text] ;定义.start.text代码节
[bits 32] ;汇编成32位代码
_start:
jmp _entry
ALIGN 8
mbt_hdr:
dd MBT_HDR_MAGIC
dd MBT_HDR_FLAGS
dd -(MBT_HDR_MAGIC+MBT_HDR_FLAGS)
dd mbt_hdr
dd _start
dd 0
dd 0
dd _entry
;以上是GRUB所需要的头
ALIGN 8
mbt2_hdr:
DD MBT_HDR2_MAGIC
DD 0
DD mbt2_hdr_end - mbt2_hdr
DD -(MBT_HDR2_MAGIC + 0 + (mbt2_hdr_end - mbt2_hdr))
DW 2, 0
DD 24
DD mbt2_hdr
DD _start
DD 0
DD 0
DW 3, 0
DD 12
DD _entry
DD 0
DW 0, 0
DD 8
mbt2_hdr_end:
;以上是GRUB2所需要的头
;包含两个头是为了同时兼容GRUB、GRUB2
ALIGN 8
_entry:
;关中断
cli
;关不可屏蔽中断
in al, 0x70
or al, 0x80
out 0x70,al
;重新加载GDT
lgdt [GDT_PTR]
jmp dword 0x8 :_32bits_mode
_32bits_mode:
;下面初始化C语言可能会用到的寄存器
mov ax, 0x10
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
xor eax,eax
xor ebx,ebx
xor ecx,ecx
xor edx,edx
xor edi,edi
xor esi,esi
xor ebp,ebp
xor esp,esp
;初始化栈,C语言需要栈才能工作
mov esp,0x9000
;调用C语言函数main
call main
;让CPU停止执行指令
halt_step:
halt
jmp halt_step
GDT_START:
knull_dsc: dq 0
kcode_dsc: dq 0x00cf9e000000ffff
kdata_dsc: dq 0x00cf92000000ffff
k16cd_dsc: dq 0x00009e000000ffff
k16da_dsc: dq 0x000092000000ffff
GDT_END:
GDT_PTR:
GDTLEN dw GDT_END-GDT_START-1
GDTBASE dd GDT_START
以上的汇编代码(/lesson02/HelloOS/entry.asm)分为 4 个部分:
1~40行:用汇编定义的GRUB的多协议引导头,并兼容GRUB1和GRUB2
44~52行:关掉中断,设定CPU的工作模式
54~73行:初始化CPU的寄存器和c语言的运行环境
78~87 行,GDT_START 开始的,是 CPU 工作模式所需要的数据
我们在上面调用了main函数,但是在汇编中并没看见函数体
那是因为这个函数是用 C 语言写的在(/lesson02/HelloOS/main.c)中,最终它们分别由 nasm 和 GCC 编译成可链接模块,由 LD 链接器链接在一起,形成可执行的程序文件:
控制计算机屏幕
控制计算机屏幕的是显卡,往往都是显卡输出,集成在主板是集显,坐在cpu上是核显,独立存在通过PCIE接口链接的是独显。
各种显卡都支持vesa标准,有两种工作模式:字符模式和图形模式,为了兼容,提供了一种叫VGABIOS的固件
显卡工作细节
它把屏幕分成 24 行,每行 80 个字符,把这(24*80)个位置映射到以 0xb8000 地址开始的内存中,每两个字节对应一个字符,其中一个字节是字符的 ASCII 码,另一个字节为字符的颜色值。如下图所示:
ps:C 语言字符串是以 0 结尾的,其字符编码通常是 utf8,而 utf8 编码对 ASCII 字符是兼容的,即英文字符的 ASCII 编码和 utf8 编码是相等的
Hello OS代码 接下来是编译和安装
//彭东 @ 2021.01.09
void _strwrite(char* string)
{
char* p_strdst = (char*)(0xb8000);//指向显存的开始地址
while (*string)
{
*p_strdst = *string++;
p_strdst += 2;
}
return;
}
void printf(char* fmt, ...)
{
_strwrite(fmt);
return;
}
编译和安装 Hello OS
安装之前我们进行系统编译,即把每个代码模块编译后链接成可执行的二进制文件,我们使用make
make工具
make 历史悠久,小巧方便,也是很多成熟操作系统编译所使用的构建工具。在软件开发中,make 是一个工具程序,它读取一个叫“makefile”的文件,也是一种文本文件,这个文件中写好了构建软件的规则,它能根据这些规则自动化构建软件。
首先有一个或者多个构建目标称为“target”;目标后面紧跟着用于构建该目标所需要的文件,目标下面是构建该目标所需要的命令及参数。
与此同时,它也检查文件的依赖关系,如果需要的话,它会调用一些外部软件来完成任务。
第一次构建目标后,下一次执行 make 时,它会根据该目标所依赖的文件是否更新决定是否编译该目标,如果所依赖的文件没有更新且该目标又存在,那么它便不会构建该目标。这种特性非常有利于编译程序源代码。
makefile的例子:
CC = gcc #定义一个宏CC 等于gcc
CFLAGS = -c #定义一个宏 CFLAGS 等于-c
OBJS_FILE = file.o file1.o file2.o file3.o file4.o #定义一个宏
.PHONY : all everything #定义两个伪目标all、everything
all:everything #伪目标all依赖于伪目标everything
everything :$(OBJS_FILE) #伪目标everything依赖于OBJS_FILE,而OBJS_FILE是宏会被
#替换成file.o file1.o file2.o file3.o file4.o
%.o : %.c
$(CC) $(CFLAGS) -o $@ $<
我来解释一下这个例子:make 规定“#”后面为注释,make 处理 makefile 时会自动丢弃。
makefile 中可以定义宏,方法是在一个字符串后跟一个“=”或者“:=”符号,引用宏时要用“$(宏名)”,宏最终会在宏出现的地方替换成相应的字符串,例如:$(CC) 会被替换成 gcc,$( OBJS_FILE) 会被替换成 file.o file1.o file2.o file3.o file4.o。
.PHONY 在 makefile 中表示定义伪目标。
所谓伪目标,就是它不代表一个真正的文件名,在执行 make 时可以指定这个目标来执行其所在规则定义的命令。
但是伪目标可以依赖于另一个伪目标或者文件,例如:all 依赖于 everything,everything 最终依赖于 file.c file1.c file2.c file3.c file4.c。
虽然我们会发现,everything 下面并没有相关的执行命令,但是下面有个通用规则:“%.o : %.c”。其中的“%”表示通配符,表示所有以“.o”结尾的文件依赖于所有以“.c”结尾的文件。例如:file.c、file1.c、file2.c、file3.c、file4.c,通过这个通用规则会自动转换为依赖关系:file.o: file.c、file1.o: file1.c、file2.o: file2.c、file3.o: file3.c、file4.o: file4.c。
然后,针对这些依赖关系,分别会执行:$(CC) $(CFLAGS) -o $@ $< 命令,当然最终会转换为:gcc –c –o xxxx.o xxxx.c,这里的“xxxx”表示一个具体的文件名。
编译
编译过程:
安装 Hello OS
通过以上流程,我们会得到Hello OS.bin 文件,但是我们还要让GRUB能找到它,在计算机启动时加载它,我们称之为安装,但是我们这里没有安装程序,得手动
GRUB 在启动时会加载一个 grub.cfg 的文本文件,根据其中的内容执行相应的操作,其中一部分内容就是启动项。
GRUB 首先会显示启动项到屏幕,然后让我们选择启动项,最后 GRUB 根据启动项对应的信息,加载 OS 文件到内存。
menuentry 'HelloOS' {
insmod part_msdos #GRUB加载分区模块识别分区
insmod ext2 #GRUB加载ext文件系统模块识别ext文件系统
set root='hd0,msdos4' #注意boot目录挂载的分区,这是我机器上的情况
multiboot2 /boot/HelloOS.bin #GRUB以multiboot2协议加载HelloOS.bin
boot #GRUB启动HelloOS.bin
}
如果你不知道你的 boot 目录挂载的分区,可以在 Linux 系统的终端下输入命令:df /boot/,就会得到如下结果:
文件系统 1K-块 已用 可用 已用% 挂载点
/dev/sda4 48752308 8087584 38158536 18% /
其中的“sda4”就是硬盘的第四个分区(硬件分区选择 MBR),但是 GRUB 的 menuentry 中不能写 sda4,而是要写“hd0,msdos4”,这是 GRUB 的命名方式,hd0 表示第一块硬盘,结合起来就是第一块硬盘的第四个分区。
把上面启动项的代码插入到你的 Linux 机器上的 /boot/grub/grub.cfg 文件末尾,然后把 Hello OS.bin 文件复制到 /boot/ 目录下,一定注意这里是追加不是覆盖。最后重启计算机,你就可以看到 Hello OS 的启动选项了。
选择 Hello OS,按下 Enter 键(或者重启按 ESC 键),这样就可以成功启动我们自己的 Hello OS 了。
03 | 黑盒之中有什么:内核结构与设计
计算机中资源大致可以分为两类资源,一种是硬件资源,一种是软件资源。先来看看硬件资源有哪些,如下:
- 总线,负责连接各种其它设备,是其它设备工作的基础。2.CPU,即中央处理器,负责执行程序和处理数据运算。3. 内存,负责储存运行时的代码和数据。4. 硬盘,负责长久储存用户文件数据。5. 网卡,负责计算机与计算机之间的通信。6. 显卡,负责显示工作。7. 各种 I/O 设备,如显示器,打印机,键盘,鼠标等。
面给出一幅经典的计算机内部结构图,如下: - 而计算机中的软件资源,则可表示为计算机中的各种形式的数据。如各种文件、软件程序等。
1.管理 CPU,由于 CPU 是执行程序的,而内核把运行时的程序抽象成进程,所以又称为进程管理。
2.管理内存,由于程序和数据都要占用内存,内存是非常宝贵的资源,所以内核要非常小心地分配、释放内存。
3.管理硬盘,而硬盘主要存放用户数据,而内核把用户数据抽象成文件,即管理文件,文件需要合理地组织,方便用户查找和读写,所以形成了文件系统。
4.管理显卡,负责显示信息,而现在操作系统都是支持 GUI(图形用户接口)的,管理显卡自然而然地就成了内核中的图形系统。
5.管理网卡,网卡主要完成网络通信,网络通信需要各种通信协议,最后在内核中就形成了网络协议栈,又称网络组件。
6.管理各种 I/O 设备,我们经常把键盘、鼠标、打印机、显示器等统称为 I/O(输入输出)设备,在内核中抽象成 I/O 管理器。
最值得一提的是,各种计算机硬件的性能不同,硬件型号不同,硬件种类不同,硬件厂商不同,内核要想管理和控制这些硬件就要编写对应的代码,通常这样的代码我们称之为驱动程序。
即如何组织这些组件,让系统更加稳定和高效,这就需要我们从现有的一些经典内核结构里找灵感了。
宏内核结构
宏内核就是把以上诸如管理进程的代码、管理内存的代码、管理各种 I/O 设备的代码、文件系统的代码、图形系统代码以及其它功能模块的代码,把这些所有的代码经过编译,最后链接在一起,形成一个大的可执行程序。
这个大程序里有实现支持这些功能的所有代码,向用户应用软件提供一些接口,这些接口就是常说的系统 API 函数。而这个大程序会在处理器的特权模式下运行,这个模式通常被称为宏内核模式。结构如下图所示。
为了理解宏内核的工作原理,我们来看一个例子,宏内核提供内存分配功能的服务过程,具体如下:
- 应用程序调用内存分配的 API(应用程序接口)函数。
- 处理器切换到特权模式,开始运行内核代码。
- 内核里的内存管理代码按照特定的算法,分配一块内存。
- 把分配的内存块的首地址,返回给内存分配的 API 函数。
- 内存分配的 API 函数返回,处理器开始运行用户模式下的应用程序,应用程序就得到了一块内存的首地址,并且可以使用这块内存了。
上图的宏内核结构有明显的缺点,因为它没有模块化,没有扩展性、没有移植性,高度耦合在一起,一旦其中一个组件有漏洞,内核中所有的组件可能都会出问题。
这种宏内核唯一的优点是性能很好,因为在内核中,这些组件可以互相调用,性能极高。
为了理解宏内核的工作原理,我们来看一个例子,宏内核提供内存分配功能的服务过程,具体如下: - 应用程序调用内存分配的 API(应用程序接口)函数。
- 处理器切换到特权模式,开始运行内核代码。
- 内核里的内存管理代码按照特定的算法,分配一块内存。
- 把分配的内存块的首地址,返回给内存分配的 API 函数。
- 内存分配的 API 函数返回,处理器开始运行用户模式下的应用程序,应用程序就得到了一块内存的首地址,并且可以使用这块内存了。
上图的宏内核结构有明显的缺点,因为它没有模块化,没有扩展性、没有移植性,高度耦合在一起,一旦其中一个组件有漏洞,内核中所有的组件可能都会出问题。
这种宏内核唯一的优点是性能很好,因为在内核中,这些组件可以互相调用,性能极高。
微内核结构
微内核架构正好与宏内核架构相反,它提倡内核功能尽可能少:只有进程调度、处理中断、内存空间映射、进程间通信等功能
这样的内核是不能完成什么实际功能的,开发者们把实际的进程管理、内存管理、设备管理、文件管理等服务功能,做成一个个服务进程。和用户应用进程一样,只是它们很特殊,宏内核提供的功能,在微内核架构里由这些服务进程专门负责完成。
微内核定义了一种良好的进程间通信的机制——消息。应用程序要请求相关服务,就向微内核发送一条与此服务对应的消息,微内核再把这条消息转发给相关的服务进程,接着服务进程会完成相关的服务。服务进程的编程模型就是循环处理来自其它进程的消息,完成相关的服务功能。其结构如下所示:
为了理解微内核的工程原理,我们来看看微内核提供内存分配功能的服务过程,具体如下:1. 应用程序发送内存分配的消息,这个发送消息的函数是微内核提供的,相当于系统 API,微内核的 API(应用程序接口)相当少,极端情况下仅需要两个,一个接收消息的 API 和一个发送消息的 API。
2. 处理器切换到特权模式,开始运行内核代码。
3. 微内核代码让当前进程停止运行,并根据消息包中的数据,确定消息发送给谁,分配内存的消息当然是发送给内存管理服务进程。
4. 内存管理服务进程收到消息,分配一块内存。
5. 内存管理服务进程,也会通过消息的形式返回分配内存块的地址给内核,然后继续等待下一条消息。
6. 微内核把包含内存块地址的消息返回给发送内存分配消息的应用程序。
7. 处理器开始运行用户模式下的应用程序,应用程序就得到了一块内存的首地址,并且可以使用这块内存了。
微内核的架构实现虽然不同,但是大致过程和上面一样。同样是分配内存,在微内核下拐了几个弯,一来一去的消息带来了非常大的开销,当然各个服务进程的切换开销也不小。这样系统性能就大打折扣。
但是微内核有很多优点,首先,系统结构相当清晰利于协作开发。其次,系统有良好的移植性,微内核代码量非常少,就算重写整个内核也不是难事。最后,微内核有相当好的伸缩性、扩展性,因为那些系统功能只是一个进程,可以随时拿掉一个服务进程以减少系统功能,或者增加几个服务进程以增强系统功能。
微内核的代表作有 MACH、MINIX、L4 系统,这些系统都是微内核,但是它们不是商业级的系统,商业级的系统不采用微内核主要还是因为性能差。在设计我们自己的系统内核时,就可以扬长避短了。
分离硬件的相关性
Windows 内核有什么 HAL 层、Linux 内核有什么 arch 层。这些 xx 层就是 Windows 和 Linux 内核设计者,给他们的系统内核分的第一个层。
从硬件层到操作系统层再到应用软件层这样构建。分层的主要目的和好处在于屏蔽底层细节,使上层开发更加简单。
通过建立抽象层,屏蔽不同硬件功能的差异。
为了让你更好理解,我们举进程管理中的一个模块实现细节的例子:进程调度模块。通过这个例子,来看看分层对系统内核的设计与开发有什么影响。
进程是操作系统开发者为了实现多任务而提出的,并让每个进程在 CPU 上运行一小段时间,这样就能实现多任务同时运行的假象。
要实现这种假象,就要实现下面这两种机制:
1.进程调度,它的目的是要从众多进程中选择一个将要运行的进程,当然有各种选择的算法,例如,轮转算法、优先级算法等。
2.进程切换,它的目的是停止当前进程,运行新的进程,主要动作是保存当前进程的机器上下文,装载新进程的机器上下文。
我们不难发现,从 ARM 硬件平台搭配 x86 硬件平台上,选择一个进程的算法和代码是不容易发生改变的,需要改变的代码是进程切换的相关代码,因为不同的硬件平台的机器上下文是不同的。所以,这时最好是将进程切换的代码放在一个独立的层中实现,比如硬件平台相关层,当操作系统要运行在不同的硬件平台上时,就只是需要修改硬件平台相关层中的相关代码,这样操作系统的移植性就大大增强了。即变化的类似接口不变的是模板
选择设计
首先大致将我们的操作系统内核分为三个大层,分别是:
- 内核接口层。
- 内核功能层。
- 内核硬件层。
内核接口层,定义了一系列接口,主要有两点内容,如下:
- 定义了一套 UNIX 接口的子集,我们出于学习和研究的目的,使用 UNIX 接口的子集,优点之一是接口少,只有几个,并且这几个接口又能大致定义出操作系统的功能。
这套接口的代码,就是检查其参数是否合法,如果参数有问题就返回相关的错误,接着调用下层完成功能的核心代码。内核功能层,主要完成各种实际功能,这些功能按照其类别可以分成各种模块,当然这些功能模块最终会用具体的算法、数据结构、代码去实现它,内核功能层的模块如下:
- 定义了一套 UNIX 接口的子集,我们出于学习和研究的目的,使用 UNIX 接口的子集,优点之一是接口少,只有几个,并且这几个接口又能大致定义出操作系统的功能。
- 这套接口的代码,就是检查其参数是否合法,如果参数有问题就返回相关的错误,接着调用下层完成功能的核心代码。
- 中断管理,这个在内核功能层中非常简单:就是把一个中断回调函数安插到相关的数据结构中,一旦发生相关的中断就会调用这个函数。
- 设备管理,这个是最难的,需要用一系列的数据结构表示驱动程序模块、驱动程序本身、驱动程序创建的设备,最后把它们组织在一起,还要实现创建设备、销毁设备、访问设备的代码,这些代码最终会调用设备驱动程序,达到操作设备的目的。
内核硬件层,主要包括一个具体硬件平台相关的代码,如下:
- 初始化,初始化代码是内核被加载到内存中最先需要运行的代码,例如初始化少量的设备、CPU、内存、中断的控制、内核用于管理的数据结构等。
- CPU 控制,提供 CPU 模式设定、开、关中断、读写 CPU 特定寄存器等功能的代码。
- 中断处理,保存中断时机器的上下文,调用中断回调函数,操作中断控制器等。
- 物理内存管理,提供分配、释放大块内存,内存空间映射,操作 MMU、Cache 等。
- 平台其它相关的功能,有些硬件平台上有些特殊的功能,需要额外处理一下。
如果上述文字让你看得头晕,我们来画幅图,可能就会好很多,如下所示,当然这里没有画出用户空间的应用进程,API 接口以下的为内核空间,这才是设计、开发内核的重点
可以发现,我们的操作系统内核没有任何设备驱动程序,甚至没有文件系统和网络组件,内核所实现的功能很少。这吸取了微内核的优势,内核小出问题的可能性就少,扩展性就越强。
同时,我们把文件系统、网络组件、其它功能组件作为虚拟设备交由设备管理,比如需要文件系统时就写一个文件系统虚拟设备的驱动,完成文件系统的功能,需要网络时就开发一个网络虚拟设备的驱动,完成网络功能。
这些驱动一旦被装载,就是内核的一部分了,并不是像微内核一样作为服务进程运行。这又吸取了宏内核的优势,代码高度耦合,性能强劲。
这样的内核架构既不是宏内核架构也不是微内核架构,而是这两种架构综合的结果,可以说是混合内核架构,也可以说这是我们自己的内核架构……