This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
<audio id="audio" title="01 | 程序的运行过程:从代码到机器运行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/d8/564366b21406b6d32c86b4ecdbc984d8.mp3"></audio>
你好我是LMOS。
欢迎来到操作系统第一课。在真正打造操作系统前,有一条必经之路:你知道程序是如何运行的吗?
一个熟练的编程老手只需肉眼看着代码,就能对其运行的过程了如指掌。但对于初学者来说,这常常是很困难的事,这需要好几年的程序开发经验,和在长期的程序开发过程中对编程基本功的积累。
我记得自己最初学习操作系统的时候,面对逻辑稍微复杂的一些程序,在编写、调试代码时,就会陷入代码的迷宫,找不到东南西北。
不知道你现在处在什么阶段,是否曾有同样的感受?**我常常说,扎实的基本功就像手里的指南针,你可以一步步强大到不依赖它,但是不能没有。**
因此今天我将带领你从“Hello World”起扎实基本功探索程序如何运行的所有细节和原理。
## 一切要从牛人做的牛逼事说起
**第一位牛人是世界级计算机大佬的传奇——Unix之父Ken Thompson**
在上世纪60年代的一个夏天Ken Thompson的妻子要回娘家一个月。呆在贝尔实验室的他竟然利用这极为孤独的一个月开发出了UNiplexed Information and Computing SystemUNICS——即UNIX的雏形一个全新的操作系统。
要知道在当时C语言并没有诞生从严格意义上说他是用B语言和汇编语言在PDP-7的机器上完成的。
<img src="https://static001.geekbang.org/resource/image/41/56/418b94aed8aab2abf6538a103d9f2856.png" alt="">
**牛人的朋友也是牛人他的朋友Dennis Ritchie也随之加入其中共同创造了大名鼎鼎的C语言并用C语言写出了UNIX和后来的类UNIX体系的几十种操作系统也写出了对后世影响深远的第一版“Hello World”**
```
#include &quot;stdio.h&quot;
int main(int argc, char const *argv[])
{
printf(&quot;Hello World!\n&quot;);
return 0;
}
```
计算机硬件是无法直接运行这个C语言文本程序代码的需要C语言编译器把这个代码编译成具体硬件平台的二进制代码。再由具体操作系统建立进程把这个二进制文件装进其进程的内存空间中才能运行。
听起来很复杂?别急,接着往下看。
## 程序编译过程
我们暂且不急着摸清操作系统所做的工作先来研究一下编译过程和硬件执行程序的过程约定使用GCC相关的工具链。
那么使用命令gcc HelloWorld.c -o HelloWorld 或者 gcc ./HelloWorld.c -o ./HelloWorld 就可以编译这段代码。其实GCC只是完成编译工作的驱动程序它会根据编译流程分别调用预处理程序、编译程序、汇编程序、链接程序来完成具体工作。
下图就是编译这段代码的过程:
<img src="https://static001.geekbang.org/resource/image/f2/4a/f2b10135ed52436888a793327e4d5a4a.jpg" alt="" title="HelloWorld编译流程">
其实,我们也可以手动控制以上这个编译流程,从而留下中间文件方便研究:
- gcc HelloWorld.c -E -o HelloWorld.i预处理加入头文件替换宏。
- gcc HelloWorld.c -s -c HelloWorld.s编译包含预处理将C程序转换成汇编程序。
- gcc HelloWorld.c -c HelloWorld.o汇编包含预处理和编译将汇编程序转换成可链接的二进制程序。
- gcc HelloWorld.c -o HelloWorld链接包含以上所有操作将可链接的二进制程序和其它别的库链接在一起形成可执行的程序文件。
## 程序装载执行
对运行内容有了了解后,我们开始程序的装载执行。
我们将请出**第三位牛人——大名鼎鼎的阿兰·图灵。在他的众多贡献中,很重要的一个就是提出了一种理想中的机器:图灵机。**
图灵机是一个抽象的模型,它是这样的:有一条无限长的纸带,纸带上有无限个小格子,小格子中写有相关的信息,纸带上有一个读头,读头能根据纸带小格子里的信息做相关的操作并能来回移动。
文字叙述还不够形象,我们来画一幅插图:
<img src="https://static001.geekbang.org/resource/image/69/7d/6914497643dbb0aaefffc32b865dcf7d.png" alt="">
不理解下面我再带你用图灵机执行一下“1+1=2”的计算你就明白了。我们定义读头读到“+”之后,就依次移动读头两次并读取格子中的数据,最后读头计算把结果写入第二个数据的下一个格子里,整个过程如下图:
<img src="https://static001.geekbang.org/resource/image/43/87/43812abfe104d6885815825f07622e87.jpg" alt="" title="图灵机计算过程演示">
这个理想的模型是好,但是理想终归是理想,想要成为现实,我们得想其它办法。
**于是,第四位牛人来了,他提出了电子计算机使用二进制数制系统和储存程序,并按照程序顺序执行,他叫冯诺依曼,他的电子计算机理论叫冯诺依曼体系结构。**
根据冯诺依曼体系结构构成的计算机,必须具有如下功能:
- 把程序和数据装入到计算机中;
- 必须具有长期记住程序、数据的中间结果及最终运算结果;
- 完成各种算术、逻辑运算和数据传送等数据加工处理;
- 根据需要控制程序走向,并能根据指令控制机器的各部件协调操作;
- 能够按照要求将处理的数据结果显示给用户。
为了完成上述的功能,计算机必须具备五大基本组成部件:
- 装载数据和程序的输入设备;
- 记住程序和数据的存储器;
- 完成数据加工处理的运算器;
- 控制程序执行的控制器;
- 显示处理结果的输出设备。
根据冯诺依曼的理论,我们只要把图灵机的几个部件换成电子设备,就可以变成一个最小核心的电子计算机,如下图:
<img src="https://static001.geekbang.org/resource/image/bd/26/bde34df011c397yy42dc00fe6bd35226.jpg" alt="">
是不是非常简单?这次我们发现读头不再来回移动了,而是靠地址总线寻找对应的“纸带格子”。读取写入数据由数据总线完成,而动作的控制就是控制总线的职责了。
## 更形象地将HelloWorld程序装入原型计算机
下面我们尝试将HelloWorld程序装入这个原型计算机在装入之前我们先要搞清楚HelloWorld程序中有什么。
我们可以通过gcc -c -S HelloWorld得到只能得到其汇编代码而不能得到二进制数据。我们用objdump -d HelloWorld程序得到/lesson01/HelloWorld.dump其中有很多库代码只需关注main函数相关的代码如下图
<img src="https://static001.geekbang.org/resource/image/39/14/3991a042107b90612122b14596c65614.jpeg" alt="">
以上图中分成四列第一列为地址第二列为十六进制表示真正装入机器中的代码数据第三列是对应的汇编代码第四列是相关代码的注释。这是x86_64体系的代码由此可以看出x86 CPU是变长指令集。
接下来,我们把这段代码数据装入最小电子计算机,状态如下图:
<img src="https://static001.geekbang.org/resource/image/5d/6e/5d4889e7bf20e670ee71cc9b6285c96e.jpg" alt="" title="PS上图内存条中一个小格子中只要一个字节但是 [br] 图中放的字节数目不等,这是为了方便阅读,不然图要画得很大。">
## 重点回顾
以上,对应图中的伪代码你应该明白了:现代电子计算机正是通过内存中的信息(指令和数据)做出相应的操作,并通过内存地址的变化,达到程序读取数据,控制程序流程(顺序、跳转对应该图灵机的读头来回移动)的功能。
这和图灵机的核心思想相比没有根本性的变化。只要配合一些I/O设备让用户输入并显示计算结果给用户就是一台现代意义的电子计算机。
到这里我们理清了程序运行的所有细节和原理。还有一点你可能有点疑惑即printf对应的puts函数到底做了什么而这正是我们后面的课程要探索的
这节课的配套代码,你可以从[这里](https://gitee.com/lmos/cosmos/tree/master/lesson01/HelloWorld)下载。
## 思考题
为了实现C语言中函数的调用和返回功能CPU实现了函数调用和返回指令即上图汇编代码中的“call”“ret”指令请你思考一下call和ret指令在逻辑上执行的操作是怎样的呢
期待你在留言区跟我交流互动。如果这节课对你有所启发,也欢迎转发给你的朋友、同事,跟他们一起学习进步。

View File

@@ -0,0 +1,322 @@
<audio id="audio" title="02 | 几行汇编几行C实现一个最简单的内核" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/c2/fa83e7db04c115964e83ece8648e53c2.mp3"></audio>
你好我是LMOS。
我们知道在学习许多编程语言一开始的时候都有一段用其语言编写的经典程序——Hello World。这不过是某一操作系统平台之上的应用程序却心高气傲地问候世界。
而我们学习操作系统的时候那么也不妨撇开其它现有的操作系统基于硬件写一个最小的操作系统——Hello OS先练练手、热热身直观感受一下。
## PC机的引导流程
看标题就知道写操作系统要用汇编和C语言尽管这个Hello OS很小但也要用到两种编程语言。其实现有的商业操作系统都是用这两种语言开发出来的。
先不用害怕Hello OS的代码量很少。
其实我们也不打算从PC的引导程序开始写起原因是目前我们的知识储备还不够所以先借用一下GRUB引导程序只要我们的PC机上安装了Ubuntu Linux操作系统GRUB就已经存在了。这会大大降低我们开始的难度也不至于打消你的热情。
<img src="https://static001.geekbang.org/resource/image/1d/dc/1db2342da1abdc9f1f77e4c69a94d0dc.png" alt="">
那在写Hello OS之前我们先要搞清楚Hello OS的引导流程如下图所示
<img src="https://static001.geekbang.org/resource/image/f2/bd/f2d31ab7144bf309761711efa9d6d4bd.jpg" alt="" title="Hello OS引导流程图">
简单解释一下PC机BIOS固件是固化在PC机主板上的ROM芯片中的掉电也能保存PC机上电后的第一条指令就是BIOS固件中的它负责**检测和初始化CPU、内存及主板平台**然后加载引导设备大概率是硬盘中的第一个扇区数据到0x7c00地址开始的内存空间再接着跳转到0x7c00处执行指令在我们这里的情况下就是GRUB引导程序。
当然,更先进的[UEFI BIOS](https://www.uefi.org/)则不同,这里就不深入其中了,你可以通过链接自行了解。
## Hello OS引导汇编代码
明白了PC机的启动流程下面只剩下我们的Hello OS了我们马上就去写好它。
我们先来写一段汇编代码。这里我要特别说明一个问题为什么不能直接用C
**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
```
以上的汇编代码(/lesson01/HelloOS/entry.asm分为4个部分
1.代码1~40行用汇编定义的GRUB的多引导协议头其实就是一定格式的数据我们的Hello OS是用GRUB引导的当然要遵循**GRUB的多引导协议标准**让GRUB能识别我们的Hello OS。之所以有两个引导头是为了兼容GRUB1和GRUB2。
2.代码44~52行关掉中断设定CPU的工作模式。你现在可能不懂没事儿后面CPU相关的课程我们会专门再研究它。
3.代码54~73行初始化CPU的寄存器和C语言的运行环境。
4.代码78~87行GDT_START开始的是CPU工作模式所需要的数据同样后面讲CPU时会专门介绍。
## Hello OS的主函数
到这,不知道你有没有发现一个问题?上面的汇编代码调用了main函数而在其代码中并没有看到其函数体而是从外部引入了一个符号。
那是因为这个函数是用C语言写的在/lesson01/HelloOS/main.c最终它们分别由nasm和GCC编译成可链接模块由LD链接器链接在一起形成可执行的程序文件
```
//彭东 @ 2021.01.09
#include &quot;vgastr.h&quot;
void main()
{
printf(&quot;Hello OS!&quot;);
return;
}
```
以上这段代码你应该很熟悉了吧不过这不是应用程序的main函数而是Hello OS的main函数。
其中的printf也不是应用程序库中的那个printf了而是需要我们自己实现了。你可以先停下歇歇再去实现printf函数。
## 控制计算机屏幕
接着我们再看下显卡,这和我们接下来要写的代码有直接关联。
计算机屏幕显示往往是显卡的输出显卡有很多形式集成在主板的叫集显做在CPU芯片内的叫核显独立存在通过PCIE接口连接的叫独显性能依次上升价格也是。
独显的高性能是游戏玩家们所钟爱的3D图形显示往往要涉及顶点处理、多边形的生成和变换、纹理、着色、打光、栅格化等。而这些任务的计算量超级大所以独显往往有自己的RAM、多达几百个运算核心的处理器。因此独显不仅仅是可以显示图像而且可以执行大规模并行计算比如“挖矿”。
我们要在屏幕上显示字符,就要编程操作显卡。
其实无论我们PC上是什么显卡它们都支持一种叫**VESA**的标准这种标准下有两种工作模式字符模式和图形模式。显卡们为了兼容这种标准不得不自己提供一种叫VGABIOS的固件程序。
下面,我们来看看显卡的字符模式的工作细节。
它把屏幕分成24行每行80个字符把这24*80个位置映射到以0xb8000地址开始的内存中每两个字节对应一个字符其中一个字节是字符的ASCII码另一个字节为字符的颜色值。如下图所示
<img src="https://static001.geekbang.org/resource/image/78/f5/782ef574b96084fa44a33ea1f83146f5.jpg" alt="" title="计算机显卡文本模式">
明白了显卡的字符模式的工作细节,下面我们开始写代码。
这里先提个醒:**C语言字符串是以0结尾的其字符编码通常是utf8而utf8编码对ASCII字符是兼容的即英文字符的ASCII编码和utf8编码是相等的**(关于[utf8](https://www.utf8.com/)编码你可以自行了解)。
```
//彭东 @ 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;
}
```
代码很简单printf函数直接调用了_strwrite函数而_strwrite函数正是将字符串里每个字符依次定入到0xb8000地址开始的显存中而p_strdst每次加2这也是为了跳过字符的颜色信息的空间。
到这Hello OS相关的代码就写好了下面就是编译和安装了。你可别以为这个事情就简单了下面请跟着我去看一看。
## 编译和安装Hello OS
Hello OS的代码都已经写好这时就要进入安装测试环节了。在安装之前我们要进行系统编译即把每个代码模块编译最后链接成可执行的二进制文件。
你可能觉得我在小题大做,编译不就是输入几条命令吗,这么简单的工作也值得一说?
确实对于我们Hello OS的编译工作来说特别简单因为总共才三个代码文件最多四条命令就可以完成。
但是以后我们Hello OS的文件数量会爆炸式增长一个成熟的商业操作系统更是多达几万个代码模块文件几千万行的代码量是这世间最复杂的软件工程之一。所以需要一个牛逼的工具来控制这个巨大的编译过程。
## make工具
make历史悠久小巧方便也是很多成熟操作系统编译所使用的构建工具。
在软件开发中make是一个工具程序它读取一个叫“makefile”的文件也是一种文本文件这个文件中写好了构建软件的规则它能根据这些规则自动化构建软件。
makefile文件中规则是这样的首先有一个或者多个构建目标称为“target”目标后面紧跟着用于构建该目标所需要的文件目标下面是构建该目标所需要的命令及参数。
与此同时,它也检查文件的依赖关系,如果需要的话,它会调用一些外部软件来完成任务。
第一次构建目标后下一次执行make时它会根据该目标所依赖的文件是否更新决定是否编译该目标如果所依赖的文件没有更新且该目标又存在那么它便不会构建该目标。这种特性非常有利于编译程序源代码。
任何一个Linux发行版中都默认自带这个make程序所以不需要额外的安装工作我们直接使用即可。
为了让你进一步了解make的使用接下来我们一起看一个有关makefile的例子
```
CC = gcc #定义一个宏CC 等于gcc
CFLAGS = -c #定义一个宏 CFLAGS 等于-c
OBJS_FILE = file.c file1.c file2.c file3.c file4.c #定义一个宏
.PHONY : all everything #定义两个伪目标all、everything
all:everything #伪目标all依赖于伪目标everything
everything :$( OBJS_FILE) #伪目标everything依赖于OBJS_FILE而OBJS_FILE是宏会被
#替换成file.c file1.c file2.c file3.c file4.c
%.o : %.c
$(CC) $(CFLAGS) -o $@ $&lt;
```
我来解释一下这个例子:
make规定“#”后面为注释make处理makefile时会自动丢弃。
makefile中可以定义宏方法是**在一个字符串后跟一个“=”或者“:=”符号**,引用宏时要用“$(宏名)”,宏最终会在宏出现的地方替换成相应的字符串,例如:$(CC)会被替换成gcc$( OBJS_FILE) 会被替换成file.c file1.c file2.c file3.c file4.c。
.PHONY在makefile中表示定义伪目标。所谓伪目标就是它不代表一个真正的文件名在执行make时可以指定这个目标来执行其所在规则定义的命令。但是伪目标可以依赖于另一个伪目标或者文件例如all依赖于everythingeverything最终依赖于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 $@ $&lt;命令当然最终会转换为gcc c o xxxx.o xxxx.c这里的“xxxx”表示一个具体的文件名。
## 编译
下面我们用一张图来描述我们Hello OS的编译过程如下所示
<img src="https://static001.geekbang.org/resource/image/cb/34/cbd634cd5256e372bcbebd4b95f21b34.jpg" alt="" title="Hello OS编译过程">
## 安装Hello OS
经过上述流程我们就会得到Hello OS.bin文件但是我们还要让GRUB能够找到它才能在计算机启动时加载它。这个过程我们称为安装不过这里没有写安装程序得我们手动来做。
经研究发现GRUB在启动时会加载一个grub.cfg的文本文件根据其中的内容执行相应的操作其中一部分内容就是启动项。
GRUB首先会显示启动项到屏幕然后让我们选择启动项最后GRUB根据启动项对应的信息加载OS文件到内存。
下面来看看我们Hello 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”就是硬盘的第四个分区但是GRUB的menuentry中不能写sda4而是要写“hd0,msdos4”这是GRUB的命名方式hd0表示第一块硬盘结合起来就是第一块硬盘的第四个分区。
把上面启动项的代码插入到你的Linux机器上的/boot/grub/grub.cfg文件中然后把Hello OS.bin文件复制到/boot/目录下最后重启计算机你就可以看到Hello OS的启动选项了。
选择Hello OS按下Enter键这样就可以成功启动我们自己的Hello OS了。
## 重点回顾
有没有很开心我们终于看到我们自己的OS运行了就算它再简单也是我们自己的OS。下面我们再次回顾下这节课的重点。
首先我们了解了从按下PC机电源开关开始PC机的引导过程。它从CPU上电到加载BIOS固件再由BIOS固件对计算机进行自检和默认的初始化并加载GRUB引导程序最后由GRUB加载具体的操作系统。
其次就到了我们这节课最难的部分即用汇编语言和C语言实现我们的Hello OS。
第一步用汇编程序初始化CPU的寄存器、设置CPU的工作模式和栈最重要的是**加入了GRUB引导协议头**第二步切换到C语言用C语言写好了**主函数和控制显卡输出的函数**,其间还了解了显卡的一些工作细节。
最后就是编译和安装Hello OS了。我们用了make工具编译整个代码其实make会根据一些规则调用具体的nasm、gcc、ld等编译器然后形成Hello OS.bin文件你把这个文件写复制到boot分区写好GRUB启动项这样就好了。
这里只是上上手下面我们还会去准备一些别的东西然后就真正开始了。但你此刻也许还有很多问题没有搞清楚比如重新加载GDT、关中断等先不要担心我们后面会一一解决的。
本节课的配套代码,你可以从[这里](https://gitee.com/lmos/cosmos/tree/master/lesson02/HelloOS)下载。
## 思考题
以上printf函数定义其中有个形式参数很奇怪请你思考下为什么是“…”形式参数这个形式参数有什么作用
欢迎你在留言区分享你的思考或疑问。
我是LMOS我们下节课见

View File

@@ -0,0 +1,101 @@
<audio id="audio" title="开篇词 | 为什么要学写一个操作系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/e5/903db79b3071509f68d4d55707d351e5.mp3"></audio>
你好我是彭东网名LMOS欢迎加入我的专栏跟我一起开启操作系统的修炼之路。
先来介绍一下我自己。我是Intel 傲腾项目开发者之一,也是《深度探索嵌入式操作系统》这本书的作者。
我曾经为Intel做过内核层面的开发工作也对Linux、BSD、SunOS等开源操作系统还有Windows的NT内核很熟悉。这十几年来我一直专注于操作系统内核研发。
LMOS基于x86平台支持多进程、多CPU、虚拟化等技术的全64位操作系统内核跟LMOSEM基于ARM处理器平台的嵌入式操作系统内核是我独立开发的两套全新的操作系统内核其中LMOS的代码规模达到了数十万行两个系统现在仍在更新。
当时是基于兴趣和学习的目的开始了这两套操作系统,在这个过程中,我遇到了各种各样的技术问题,解决了诸多疑难杂症,总结了大量的开发操作系统的方法和经验。非常希望能在这个专栏与你一起交流。
### 每个工程师都有必要学好操作系统吗?
经常会有同学问我这样一些问题:我是一个做应用层开发的工程师,有必要学习操作系统吗?我的日常工作中,好像用不到什么深奥的操作系统内核知识,而且大学时已经学过了操作系统课程,还有必要再学吗?
对于这些问题,我的答案当然是**“有必要”**。至于理由么,请听我慢慢为你道来。
你是否也跟我一样曾经在一个数千万行代码的大项目中茫然失措一次次徘徊在内存为什么会泄漏、服务进程为什么会dang掉、文件为什么打不开等一系列“基础”问题的漩涡中
你是否惊叹于Nginx的高并发性是不是感觉Golang的垃圾回收器真的很垃圾除了这样的感叹你也许还好奇过这样一些问题MySQL的I/O性能还能不能再提升网络服务为什么会掉线Redis中经典的Reactor设计模式靠什么技术支撑Node.js 的 I/O 模型长什么模样……
如果你也追问过上面的这些问题,那这会儿我也差不多可以给充满求知欲的你指一条“明路”了。这些都将在后面的学习中,找到答案。
### 为什么说操作系统很重要?
首先我们都知道,**操作系统是所有软件的基础**,所有上层软件都要依赖于操作系统提供的各种机制,才能运行。
而我在工作中也认识了很多技术大牛,根据我的观察,他们的基本功往往十分扎实,这对他们的架构视野、技术成长都十分有帮助。
如果你是后端工程师在做高性能服务端编程的时候内存、进程、线程、I/O相关的知识就会经常用到。还有在做一些前端层面的性能调优时操作系统相关的一些知识更是必不可少。
除了Web开发做高性能计算超级计算机的时候操作系统内核相关的开发能力也至关重要。其实即使单纯的操作系统内核相关的开发能力对于工程师来说也是绕不过的基本功。
对于运维、测试同学,你要维护和测试的任何产品,其实是基于操作系统的。比如给服务配置多大的内存、多大的缓存空间?怎样根据操作系统给出的信息,判断服务器的问题出现在哪里。随着你对操作系统的深入理解和掌握,你才能透过现象看本质,排查监控思路也会更开阔。
除了工作,操作系统离我们的生活也并不遥远,甚至可以说是息息相关。要知道,操作系统其实不仅仅局限于手机和电脑,你的智能手表、机顶盒、路由器,甚至各种家电中都运行着各种各样的操作系统。
可以说,操作系统作为计算机的灵魂,眼前的工作、日常的生活,甚至这个行业未来的“诗与远方”都离不开它。
### 操作系统很难,我能学得会么?
但即使是大学时期就学过操作系统的同学,也可能会感觉学得云里雾里。更别说非科班的一些人,难度更甚,甚至高不可攀。那为什么我这么有信心,给你讲好操作系统这门课呢?这还要从我自己的学习经历说起。
跟许多人一样我看的第一本C教程就是那本“老谭C”。看了之后除了能写出那个家喻户晓的“hello world”程序其它什么也干不了。接着我又开始折腾C++、Java结果如出一辙还是只能写个“hello world”程序。
还好我有互联网,它让我发现了数据结构与算法,经过一番学习,后来我总算可以写一些小功能的软件了,但或许那根本就称不上功能。既然如此,我就继续折腾,继续学习微机原理、汇编语言这些内容。
最后我终于发现,**操作系统才是我最想写的软件。**我像着了魔一样,一切操作系统、硬件层相关的书籍都找来看。
有了这么多的“输入”,我就想啊,既然是写操作系统,**为什么不能把这些想法用代码实现出来,放在真正的计算机上验证一下呢?**
LMOS的雏形至此诞生。从第一行引导代码开始一次又一次代码重构一次又一次地面对莫名的死机而绝望倒逼我不断改进最终才有了现在的LMOS。因为一个人从零开始独立开发操作系统这种行为有点疯狂我索性就用LMOSlibertymadnessoperatingsystem来命名了我的操作系统。
经过我这几年的独立开发现在LMOS已经发布了**8个测试版本**。先后从32位单CPU架构发展到64位多CPU架构现在的LMOS已经是多进程、多线程、多CPU、支持虚拟内存的x86_64体系下的全64位操作系统内核代码量已经有**10万多行了**。
后来我又没忍住自己的好奇心写了个嵌入式操作系统——LMOSEM。由于有了先前的功底加上ARM体系很简单所以我再学习和实现嵌入式操作系统时就感觉驾轻就熟了。
经过跋山涉水,我再回头来看,很容易就发现了为什么操作系统很难学。
操作系统需要你有大量的知识储备,但是现在大多的课程、学习资料,往往都是根据目前已有的一些操作系统,做局部解读。所以,我们学的时候,前后的知识是无法串联在一起的。结果就会越看越迷惑,不去查吧,看不懂,再去搜索又加重了学习负担,最后只能遗憾放弃。
那怎样学习操作系统才是最高效的呢?理论基础是要补充的,但相对来说,实践更为重要。我认为,千里之行还得始于足下。
所以,通过这个专栏,我会带你从无到有实现一个自己的操作系统。
我会使用大量的插图代码和风趣幽默的段子来帮助你更好地理解操作系统内核的本质。同时在介绍每个内核组件实现时都会先给你说明白为什么带着你基于设计理解去动手实现然后再给你详细描述Linux内核对应的实现做前后对比。这样既能让你边学边练又能帮你从“上帝视角”审视Linux内核。
### 我们课程怎么安排的?
操作系统作为计算机王国的权力中枢,我们的课程就是讲解如何实现它。
为此我们将从了解计算机王国的资源开始如CPU、MMU、内存和Cache。其次要为这个权力中枢设计基本法即各种同步机制如信号量与自旋锁。接着进行夺权从固件程序的手中抢过计算机并进行初始化其中包含初始化CPU、内存、中断、显示等。
然后开始建设中枢的各级部门它们分别是内存管理部门、进程管理部门、I/O管理部门、文件管理部门、通信管理部门。最后将这些部门组合在一起就形成了计算机王国的权力中枢——操作系统。
<img src="https://static001.geekbang.org/resource/image/d6/d9/d68f8a262c1582f04377476f9ed9yyd9.jpg" alt="" title="操作系统课程图解">
我们的课程就是按照上述逻辑,依次为你讲解这些部门的实现过程和细节。每节课都配有可以工作的代码,让你能跟着课程一步步实现。你也可以直接使用我提供的[代码](https://gitee.com/lmos/cosmos)一步步调试直到最终实现一个基于x86平台的64位多进程的操作系统——**Cosmos**。
<img src="https://static001.geekbang.org/resource/image/5f/cf/5fbeyy963478d11db45da0dd3e8effcf.jpg" alt="" title="操作系统核心子部门">
### 你能获得什么?
走这样一条“明路”一步一个脚印最终你会到达这样一个目的地拥有一个属于自己的操作系统内核同时收获对Linux内核更深入的理解。
学完这门课,你会明显提升操作系统架构设计能力,并且可以学会系统级别的软件编程技巧。我相信,这对你拓展技术深度和广度是大有裨益的。之后你在日常开发中遇到问题的时候,就可以尝试用更多维度的能力去解决问题了。
同时由于操作系统内核是有核心竞争力的高技术含量软件这能给你职业生涯的成长带来长远的帮助。如今在任何一家中大型互联网公司都使用大量的Linux服务器。
操作系统相关的内容,已经成为你涨薪、晋升的必考项,比如 Linux 内核相关的技术中断、I/O、网络、多线程、并发、性能、内存管理、系统稳定性、文件系统、容器和虚拟化等等这些核心知识都来源于操作系统。
而跳出个人从大局观出发的话计算机系统作为20世纪以来人类最伟大的发明之一已经深入人们生活的方方面面而计算机作为国家级战略基础软件却受制于人这关系到整个国家的信息安全也关系到互联网信息行业以及其它相关基础行业的前途和未来。
而要改变这一困局,就要从培养技术人才开始。对于我们工程师来说,树高叶茂,系于根深,只有不断升级自己的认知,才能让你的技术之路行稳致远。
下面,我给出一个简化的操作系统知识体系图,也是后面课程涉及到的所有知识点。尽管图中只是最简短的一些词汇,但随着课程的展开,你会发现图中的每一小块,都犹如一片汪洋。
<img src="https://static001.geekbang.org/resource/image/2c/bd/2c6abcd035e5c83cdd7d356eca26b9bd.jpg" alt="">
现在让我们一起带着好奇,带着梦想,向星辰大海进发!

View File

@@ -0,0 +1,201 @@
<audio id="audio" title="03 | 黑盒之中有什么:内核结构与设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/19/1ccd265954fa3bf67ea907dab6087c19.mp3"></audio>
你好我是LMOS。
在上节课中我们写了一个极简的操作系统——Hello OS并成功运行直观地感受了一下自己控制计算机的乐趣或许你正沉浸在这种乐趣之中但我不得不提醒你赶快从这种快乐中走出来。
因为我们的Hello OS虽然能使计算机运行起来但其实没有任何实际的功能。
什么?没有实际功能,我们往里增加功能不就好了吗?
你可能会这样想,但是这样想就草率了,开发操作系统内核(以下简称内核)就像建房子一样,房子要建得好,就先要设计。比如用什么结构,什么材料,房间怎么布局,电路、水路等,最后画出设计图纸,依据图纸按部就班地进行建造。
而一个内核的复杂程度要比房子的复杂程度高出几个数量级,所以在开发内核之前先要对其进行设计。
下面我们就先搞清楚内核之中有些什么东西,然后探讨一下怎么组织它们、用什么架构来组织、并对比成熟的架构,最后设计出我们想要的内核架构。
## 黑盒之中有什么
从用户和应用程序的角度来看,内核之中有什么并不重要,能提供什么服务才是重要的,所以内核在用户和上层应用眼里,就像一个大黑盒,至于黑盒里面有什么,怎么实现的,就不用管了。
不过,作为内核这个黑盒的开发者,我们要实现它,就必先设计它,而要设计它,就必先搞清楚内核中有什么。
从抽象角度来看,内核就是计算机资源的管理者,当然管理资源是为了让应用使用资源。既然内核是资源的管理者,我们先来看看计算机中有哪些资源,然后通过资源的归纳,就能推导出内核这个大黑盒中应该有什么。
计算机中资源大致可以分为两类资源,一种是硬件资源,一种是软件资源。先来看看硬件资源有哪些,如下:
1.总线,负责连接各种其它设备,是其它设备工作的基础。<br>
2.CPU即中央处理器负责执行程序和处理数据运算。<br>
3.内存,负责储存运行时的代码和数据。<br>
4.硬盘,负责长久储存用户文件数据。<br>
5.网卡,负责计算机与计算机之间的通信。<br>
6.显卡,负责显示工作。<br>
7.各种I/O设备如显示器打印机键盘鼠标等。
下面给出一幅经典的计算机内部结构图,如下:
<img src="https://static001.geekbang.org/resource/image/28/14/28cc064d767d792071a789a5b4e7d714.jpg" alt="" title="经典计算机结构图">
而计算机中的软件资源,则可表示为计算机中的各种形式的数据。如各种文件、软件程序等。
内核作为硬件资源和软件资源的管理者,其内部组成在逻辑上大致如下:
1.**管理CPU**由于CPU是执行程序的而内核把运行时的程序抽象成进程所以又称为进程管理。<br>
2.**管理内存**,由于程序和数据都要占用内存,内存是非常宝贵的资源,所以内核要非常小心地分配、释放内存。<br>
3.**管理硬盘**,而硬盘主要存放用户数据,而内核把用户数据抽象成文件,即管理文件,文件需要合理地组织,方便用户查找和读写,所以形成了文件系统。<br>
4.**管理显卡**负责显示信息而现在操作系统都是支持GUI图形用户接口管理显卡自然而然地就成了内核中的图形系统。<br>
5.**管理网卡**,网卡主要完成网络通信,网络通信需要各种通信协议,最后在内核中就形成了网络协议栈,又称网络组件。<br>
6.**管理各种I/O设备**我们经常把键盘、鼠标、打印机、显示器等统称为I/O输入输出设备在内核中抽象成I/O管理器。
内核除了这些必要组件之外,根据功能不同还有安全组件等,最值得一提的是,各种计算机硬件的性能不同,硬件型号不同,硬件种类不同,硬件厂商不同,内核要想管理和控制这些硬件就要编写对应的代码,通常这样的代码我们称之为**驱动程序**。
硬件厂商就可以根据自己不同的硬件编写不同的驱动,加入到内核之中。
以上我们已经大致知道了内核之中有哪些组件,但是另一个问题又出现了,即如何组织这些组件,让系统更加稳定和高效,这就需要我们从现有的一些**经典内核结构**里找灵感了。
## 宏内核结构
其实看这名字,就已经能猜到了,宏即大也,这种最简单适用,也是最早的一种内核结构。
宏内核就是把以上诸如管理进程的代码、管理内存的代码、管理各种I/O设备的代码、文件系统的代码、图形系统代码以及其它功能模块的代码把这些所有的代码经过编译最后链接在一起形成一个大的可执行程序。
这个大程序里有实现支持这些功能的所有代码向用户应用软件提供一些接口这些接口就是常说的系统API函数。而这个大程序会在处理器的特权模式下运行这个模式通常被称为宏内核模式。结构如下图所示。
<img src="https://static001.geekbang.org/resource/image/eb/6b/eb8e9487475f960dccda0fd939999b6b.jpg" alt="" title="宏内核结构图">
尽管图中一层一层的,这并不是它们有层次关系,仅仅表示它们链接在一起。
为了理解宏内核的工作原理,我们来看一个例子,宏内核提供内存分配功能的服务过程,具体如下:
1.应用程序调用内存分配的API应用程序接口函数。<br>
2.处理器切换到特权模式,开始运行内核代码。<br>
3.内核里的内存管理代码按照特定的算法,分配一块内存。<br>
4.把分配的内存块的首地址返回给内存分配的API函数。<br>
5.内存分配的API函数返回处理器开始运行用户模式下的应用程序应用程序就得到了一块内存的首地址并且可以使用这块内存了。
上面这个过程和一个实际的操作系统中的运行过程可能有差异但大同小异。当然系统API和应用程序之间可能还有库函数也可能只是分配了一个虚拟地址空间但是我们关注的只是这个过程。
上图的宏内核结构有明显的缺点,因为它没有模块化,没有扩展性、没有移植性,高度耦合在一起,一旦其中一个组件有漏洞,内核中所有的组件可能都会出问题。
开发一个新的功能也得重新编译、链接、安装内核。其实现在这种原始的宏内核结构已经没有人用了。这种宏内核唯一的优点是性能很好,因为在内核中,这些组件可以互相调用,性能极高。
为了方便我们了解不同内核架构间的优缺点,下面我们看一个和宏内核结构对应的反例。
## 微内核结构
微内核架构正好与宏内核架构相反,它提倡内核功能尽可能少:仅仅只有进程调度、处理中断、内存空间映射、进程间通信等功能(目前不懂没事,这是属于管理进程和管理内存的功能模块,后面课程里还会专门探讨的)。
这样的内核是不能完成什么实际功能的,开发者们把实际的进程管理、内存管理、设备管理、文件管理等服务功能,做成一个个服务进程。和用户应用进程一样,只是它们很特殊,宏内核提供的功能,在微内核架构里由这些服务进程专门负责完成。
微内核定义了一种良好的进程间通信的机制——**消息**。应用程序要请求相关服务,就向微内核发送一条与此服务对应的消息,微内核再把这条消息转发给相关的服务进程,接着服务进程会完成相关的服务。服务进程的编程模型就是循环处理来自其它进程的消息,完成相关的服务功能。其结构如下所示:
<img src="https://static001.geekbang.org/resource/image/4b/64/4b190d617206379ee6cd77fcea231c64.jpg" alt="" title="微内核结构图">
为了理解微内核的工程原理,我们来看看微内核提供内存分配功能的服务过程,具体如下:
1.应用程序发送内存分配的消息这个发送消息的函数是微内核提供的相当于系统API微内核的API应用程序接口相当少极端情况下仅需要两个一个接收消息的API和一个发送消息的API。<br>
2.处理器切换到特权模式,开始运行内核代码。<br>
3.微内核代码让当前进程停止运行,并根据消息包中的数据,确定消息发送给谁,分配内存的消息当然是发送给内存管理服务进程。<br>
4.内存管理服务进程收到消息,分配一块内存。<br>
5.内存管理服务进程,也会通过消息的形式返回分配内存块的地址给内核,然后继续等待下一条消息。<br>
6.微内核把包含内存块地址的消息返回给发送内存分配消息的应用程序。<br>
7.处理器开始运行用户模式下的应用程序,应用程序就得到了一块内存的首地址,并且可以使用这块内存了。
微内核的架构实现虽然不同,但是大致过程和上面一样。同样是分配内存,在微内核下拐了几个弯,一来一去的消息带来了非常大的开销,当然各个服务进程的切换开销也不小。这样系统性能就大打折扣。
但是微内核有很多优点,首先,系统结构相当清晰利于协作开发。其次,系统有良好的移植性,微内核代码量非常少,就算重写整个内核也不是难事。最后,微内核有相当好的伸缩性、扩展性,因为那些系统功能只是一个进程,可以随时拿掉一个服务进程以减少系统功能,或者增加几个服务进程以增强系统功能。
微内核的代表作有MACH、MINIX、L4系统这些系统都是微内核但是它们不是商业级的系统商业级的系统不采用微内核主要还是因为性能差。
好了,粗略了解了宏内核和微内核两大系统内核架构的优、缺点,以后设计我们自己的系统内核时,心里也就有了底了,到时就可以扬长避短了,下面我们先学习一点其它的东西,即分离硬件相关性,为设计出我们自己的内核架构打下基础。
## 分离硬件的相关性
我们会经常听说Windows内核有什么HAL层、Linux内核有什么arch层。这些xx层就是Windows和Linux内核设计者给他们的系统内核分的第一个层。
今天如此庞杂的计算机,其实也是一层一层地构建起来的,从硬件层到操作系统层再到应用软件层这样构建。分层的主要目的和好处在于**屏蔽底层细节,使上层开发更加简单。**
计算机领域的一个基本方法是增加一个抽象层,从而使得抽象层的上下两层独立地发展,所以在内核内部再分若干层也不足为怪。
分离硬件的相关性,就是要把操作硬件和处理硬件功能差异的代码抽离出来,形成一个独立的**软件抽象层**,对外提供相应的接口,方便上层开发。
为了让你更好理解,我们举进程管理中的一个模块实现细节的例子:进程调度模块。通过这个例子,来看看分层对系统内核的设计与开发有什么影响。
一般操作系统理论课程都会花大量篇幅去讲进程相关的概念其实说到底进程是操作系统开发者为了实现多任务而提出的并让每个进程在CPU上运行一小段时间这样就能实现多任务同时运行的假象。
当然,这种假象十分奏效。要实现这种假象,就要实现下面这两种机制:
1.**进程调度**,它的目的是要从众多进程中选择一个将要运行的进程,当然有各种选择的算法,例如,轮转算法、优先级算法等。<br>
2.**进程切换**,它的目的是停止当前进程,运行新的进程,主要动作是保存当前进程的机器上下文,装载新进程的机器上下文。
我们不难发现不管是在ARM硬件平台上还是在x86硬件平台上选择一个进程的算法和代码是不容易发生改变的需要改变的代码是进程切换的相关代码因为不同的硬件平台的机器上下文是不同的。
所以,这时最好是将进程切换的代码放在一个独立的层中实现,比如硬件平台相关层,当操作系统要运行在不同的硬件平台上时,就只是需要修改硬件平台相关层中的相关代码,这样操作系统的**移植性**就大大增强了。
如果把所有硬件平台相关的代码,都抽离出来,放在一个独立硬件相关层中实现并且定义好相关的调用接口,再在这个层之上开发内核的其它功能代码,就会方便得多,结构也会清晰很多。操作系统的移植性也会大大增强,移植到不同的硬件平台时,就构造开发一个与之对应的硬件相关层。这就是分离硬件相关性的好处。
## 我们的选择
从前面内容中,我们知道了内核必须要完成的功能,宏内核架构和微内核架构各自的优、缺点,最后还分析了分离硬件相关层的重要性,其实说了这么多,就是为了设计我们自己的操作系统内核。
虽然前面的内容,对操作系统设计这个领域还远远不够,但是对于我们自己从零开始的操作系统内核这已经够了。
首先大致将我们的操作系统内核分为三个大层,分别是:
1.内核接口层。<br>
2.内核功能层。<br>
3.内核硬件层。
内核接口层,定义了一系列接口,主要有两点内容,如下:
1.定义了一套UNIX接口的子集我们出于学习和研究的目的使用UNIX接口的子集优点之一是接口少只有几个并且这几个接口又能大致定义出操作系统的功能。<br>
2.这套接口的代码,就是检查其参数是否合法,如果参数有问题就返回相关的错误,接着调用下层完成功能的核心代码。
内核功能层,主要完成各种实际功能,这些功能按照其类别可以分成各种模块,当然这些功能模块最终会用具体的算法、数据结构、代码去实现它,内核功能层的模块如下:
1.进程管理,主要是实现进程的创建、销毁、调度进程,当然这要设计几套数据结构用于表示进程和组织进程,还要实现一个简单的进程调度算法。<br>
2.内存管理,在内核功能层中只有内存池管理,分两种内存池:页面内存池和任意大小的内存池,你现在可能不明白什么是内存池,这里先有个印象就行,后面课程研究它的时候再详细介绍。<br>
3.中断管理,这个在内核功能层中非常简单:就是把一个中断回调函数安插到相关的数据结构中,一旦发生相关的中断就会调用这个函数。<br>
4.设备管理,这个是最难的,需要用一系列的数据结构表示驱动程序模块、驱动程序本身、驱动程序创建的设备,最后把它们组织在一起,还要实现创建设备、销毁设备、访问设备的代码,这些代码最终会调用设备驱动程序,达到操作设备的目的。
内核硬件层,主要包括一个具体硬件平台相关的代码,如下:
1.初始化初始化代码是内核被加载到内存中最先需要运行的代码例如初始化少量的设备、CPU、内存、中断的控制、内核用于管理的数据结构等。<br>
2. CPU控制提供CPU模式设定、开、关中断、读写CPU特定寄存器等功能的代码。<br>
3.中断处理,保存中断时机器的上下文,调用中断回调函数,操作中断控制器等。<br>
4.物理内存管理提供分配、释放大块内存内存空间映射操作MMU、Cache等。<br>
5.平台其它相关的功能,有些硬件平台上有些特殊的功能,需要额外处理一下。
如果上述文字让你看得头晕,我们来画幅图,可能就会好很多,如下所示,当然这里没有画出用户空间的应用进程,**API接口以下的为内核空间这才是设计、开发内核的重点。**
<img src="https://static001.geekbang.org/resource/image/6c/3c/6cf68bebe4f114f00f848d1d5679d33c.jpg" alt="" title="我们的内核结构">
从上述文字和图示,可以发现,我们的操作系统内核没有任何设备驱动程序,甚至没有文件系统和网络组件,内核所实现的功能很少。这吸取了微内核的优势,内核小出问题的可能性就少,扩展性就越强。
同时,我们把文件系统、网络组件、其它功能组件作为虚拟设备交由设备管理,比如需要文件系统时就写一个文件系统虚拟设备的驱动,完成文件系统的功能,需要网络时就开发一个网络虚拟设备的驱动,完成网络功能。
这些驱动一旦被装载,就是内核的一部分了,并不是像微内核一样作为服务进程运行。这又吸取了宏内核的优势,代码高度耦合,性能强劲。
这样的内核架构既不是宏内核架构也不是微内核架构,而是这两种架构综合的结果,可以说是混合内核架构,也可以说这是我们自己的内核架构……
好了,到这里为止,我们已经设计了内核,确定了内核的功能并且设计了一种内核架构用来组织这些功能,这离完成我们自己的操作系统内核又进了一步。
## 重点回顾
内核设计真是件让人兴奋的事情,今天的内容讲完了,我们先停下赶路的脚步,回过头来看一看这一节课我们学到了什么。
我们一开始感觉内核是个大黑盒但通过分析通用计算机有哪些资源就能推导出内核作为资源管理者应该有这些组件I/O管理组件、内存管理组件、文件系统组件、进程管理组件、图形系统组件、网络组件、安全组件等。
接着,我们探讨了用两种结构来组织这些组件,这两种结构分别是宏内核结构和微内核结构,知道了他们各自的优缺点,**宏内核有极致的性能,微内核有极致的可移植性、可扩展性。**还弄清楚了它们各自完成应用程序服务的机制与流程。
然后,我们研究了分层的重要性,为什么分离硬件相关性。用实例说明了分离硬件相关性的好处,这是为了更容易扩展和移植。
最后,在前面的基础上,我们为自己的内核设计作出了选择。
我们的内核结构分为三层内核硬件层内核功能层内核接口层内核接口层主要是定义了一套UNIX接口的子集内核功能层主要完成I/O管理组件、内存管理组件、文件系统组件、进程管理组件、图形系统组件、网络组件、安全组件的通用功能型代码内核硬件层则完成其内核组件对应的具体硬件平台相关的代码。
## 思考题
其实我们的内核架构不是我们首创的,它是属于微内核、宏内核之外的第三种架构,请问这是什么架构?
欢迎你在留言区跟我交流互动。如果这节课对你有启发,也欢迎分享给你的朋友或同事。

View File

@@ -0,0 +1,147 @@
<audio id="audio" title="04 | 震撼的Linux全景图业界成熟的内核架构长什么样" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/ea/9304970b119d7bd464c2acc4b65460ea.mp3"></audio>
你好我是LMOS。
什么?你想成为计算机黑客?
梦想坐在计算机前敲敲键盘,银行账号里的数字就会自己往上涨。拜托,估计明天你就该被警察逮捕了。真正的黑客是对计算机技术有近乎极致的追求,而不是干坏事。
下面我就带你认识这样一个计算机黑客看看他是怎样创造出影响世界的Linux然后进一步了解一下Linux的内部结构。
同时我也会带你看看Windows NT和Darwin的内部结构三者形成对比你能更好地了解它们之间的差异和共同点这对我们后面写操作系统会很有帮助。
## 关于Linus
Linus Benedict Torvalds这个名字很长下面简称Linus他1969年12月28日出生在芬兰的赫尔辛基市并不是美国人。Linus在赫尔辛基大学学的就是计算机妻子还是空手道高手一个“码林高手”和一个“武林高手”真的是绝配啊。
Linus在小时候就对各种事情充满好奇这点非常具有黑客精神后来有了自己的计算机更是痴迷其中开始自己控制计算机做一些事情并深挖其背后的原理。就是这种黑客精神促使他后来写出了颠覆世界的软件——Linux也因此登上了美国《时代》周刊。
你是否对很多垃圾软件感到愤慨但自己又无法改变。Linus就不一样他为了方便访问大学服务器中的资源 ,而在自己的机器上写了一个文件系统和硬盘驱动,这样就可以把自己需要的资源下载到自己的机器中。
再后来这成为了Linux的第一个版本。看看牛人之所以为牛人就是敢于对现有的规则说不并勇于改变。
如果仅仅如此那么也不会有后来的Linux内核。Linus随后做了一个重要决定他把这款操作系统雏形开源并加入到自由软件运动以GPL协议授权允许用户自由复制或者改动程序代码但用户必须公开自己的修改并传播。
无疑正是Linus的这一重要决定使得Linux和他自己名声大振。短短几年时间就已经聚集了成千上万的狂热分子大家不计得失的为Linux添砖加瓦很多程序员更是对Linus像神明一样顶礼膜拜。
## Linux内核
好了回到正题回到Linux。Linus也不是什么神明现有的Linux99.9%的代码都不是Linus所写而且他的代码也不一定比你我的代码写得更好。
Linux全称GNU/Linux是一套免费使用和自由传播的操作系统支持类UNIX、POSIX标准接口也支持多用户、多进程、多线程可以在多CPU的机器上运行。由于互联网的发展Linux吸引了来自全世界各地软件爱好者、科技公司的支持它已经从大型机到服务器蔓延至个人电脑、嵌入式系统等领域。
Linux系统性能稳定且开源。在很多公司企业网络中被当作服务器来使用这是Linux的一大亮点也是它得以壮大的关键。
Linux的基本思想是一切都是文件每个文件都有确定的用途包括用户数据、命令、配置参数、硬件设备等对于操作系统内核而言都被视为各种类型的文件。Linux支持多用户各个用户对于自己的文件有自己特殊的权利保证了各用户之间互不影响。多任务则是现代操作系统最重要的一个特点Linux可以使多个程序同时并独立地运行。
Linux发展到今天不是哪一个人能做到的更不是一群计算机黑客能做到的而是由很多世界级的顶尖科技公司联合开发如IBM、甲骨文、红帽、英特尔、微软它们开发Linux并向Linux社区提供补丁使Linux工作在它们的服务器上向客户出售业务服务。
Linux发展到今天其代码量近2000万行可以用浩如烟海来形容没人能在短时间内弄清楚。但是你也不用害怕我们可以先看看Linux内部的全景图从全局了解一下Linux的内部结构如下图。
<img src="https://static001.geekbang.org/resource/image/92/cb/92ec3d008c77bb66a148772d3c5ea9cb.png" alt="" title="Linux内部的全景图">
啊哈是不是感觉壮观之后一阵头晕目眩头晕目眩就对了因为Linux太大了别怕下面我们来分解一下。但这里我要先解释一下上图仍然不足于描述Linux的全部只是展示了重要且显而易见的部分。
上图中大致分为**五大重要组件**,每个组件又分成许多模块从上到下贯穿各个层次,每个模块中有重要的函数和数据结构。具体每个模块的主要功能,我都给你列在了文稿里,你可以详细看看后面这张图。
<img src="https://static001.geekbang.org/resource/image/8d/b2/8dc5780041e95c8ec269bc4b97eda0b2.jpg" alt="">
不要着急不要心慌因为现在我们不需要搞清楚这些Linux模块的全部实现细节只要在心里默念Linux的模块真多啊大概有五大组件有好几十个模块每个模块主要完成什么功能就行了。
是不是松了口气先定定神然后我们就能发现Linux这么多模块挤在一起之间的通信主要是函数调用而且函数间的调用没有一定的层次关系更加没有左右边界的限定。函数的调用路径是纵横交错的从图中的线条可以得到印证。
继续深入思考你就会发现,这些纵横交错的路径上有一个函数出现了问题,就麻烦大了,它会波及到全部组件,导致整个系统崩溃。当然调试解决这个问题,也是相当困难的。同样,模块之间没有隔离,安全隐患也是巨大的。
当然,这种结构不是一无是处,它的性能极高,而性能是衡量操作系统的一个重要指标。这种结构就是传统的内核结构,也称为**宏内核架构**。
想要评判一个产品好不好最直接的方法就是用相似的产品对比。你说Linux很好但是什么为好呢我说Linux很差它又差在什么地方呢
下面我们就拿出Windows和macOS进行对比注意我们只是对比它们的内核架构。
## Darwin-XNU内核
我们先来看看DarwinDarwin是由苹果公司在2000年开发的一个开放源代码的操作系统。
一个经久不衰的公司必然有自己的核心竞争力也许是商业策略也许是技术产品又或是这两者的结合。而作为苹果公司各种产品和强大的应用生态系统的支撑者——Darwin更是公司核心竞争力中的核心。
苹果公司有台式计算机、笔记本、平板、手机台式计算机、笔记本使用了macOS操作系统平板和手机则使用了iOS操作系统。Darwin作为macOS与iOS操作系统的核心从技术实现角度说它必然要支持PowerPC、x86、ARM架构的处理器。
Darwin 使用了一种微内核Mach和相应的固件来支持不同的处理器平台并提供操作系统原始的基础服务上层的功能性系统服务和工具则是整合了BSD系统所提供的。苹果公司还为其开发了大量的库、框架和服务不过它们都工作在用户态且闭源。
下面我们先从整体看一下Darwin的架构。
<img src="https://static001.geekbang.org/resource/image/5e/8d/5e9bd6dd86fba5482fab14b6b292aa8d.jpg" alt="" title="Darwin架构图">
什么两套内核惊不惊喜由于我们是研究Darwin内核所以上图中我们只需要关注内核-用户转换层以下的部分即可。显然它有两个内核层——**Mach层与BSD层**。
Mach内核是卡耐基梅隆大学开发的经典微内核意在提供最基本的操作系统服务从而达到高性能、安全、可扩展的目的而BSD则是伯克利大学开发的类UNIX操作系统提供一整套操作系统服务。
那为什么两套内核会同时存在呢?
MAC OS X2011年之前的称呼的发展经过了不同时期随着时代的进步产品功能需求增加单纯的Mach之上实现出现了性能瓶颈但是为了兼容之前为Mach开发的应用和设备驱动就保留了Mach内核同时加入了BSD内核。
Mach内核仍然提供十分简单的进程、线程、IPC通信、虚拟内存设备驱动相关的功能服务BSD则提供强大的安全特性完善的网络服务各种文件系统的支持同时对Mach的进程、线程、IPC、虚拟内核组件进行细化、扩展延伸。
那么应用如何使用Darwin系统的服务呢应用会通过用户层的框架和库来请求Darwin系统的服务即调用Darwin系统API。
在调用Darwin系统API时会传入一个API号码用这个号码去索引Mach陷入中断服务表中的函数。此时API号码如果小于0则表明请求的是Mach内核的服务API号码如果大于0则表明请求的是BSD内核的服务它提供一整套标准的POSIX接口。
就这样Mach和BSD就同时存在了。
Mach中还有一个重要的组件Libkern它是一个库提供了很多底层的操作函数同时支持C++运行环境。
依赖这个库的还有IOKitIOKit管理所有的设备驱动和内核功能扩展模块。驱动程序开发人员则可以使用C++面向对象的方式开发驱动这个方式很优雅你完全可以找一个成熟的驱动程序作为父类继承它要特别实现某个功能就重载其中的函数也可以同时继承其它驱动程序这大大节省了内存也大大降低了出现BUG的可能。
如果你要详细了解Darwin内核的话可以自行阅读[相应的代码](https://github.com/apple/darwin-xnu)。而在这里,你只要从全局认识一下它的结构就行了。
## Windows NT内核
接下来我们再看下 NT 内核。现代Windows的内核就是NT我们不妨先看看NT的历史。
如果你是90后大概没有接触过MS-DOS它的交互方式是你在键盘上输入相应的功能命令它完成相应的功能后给用户返回相应的操作信息没有图形界面。
在MS-DOS内核的实现上也没有应用现代硬件的保护机制这导致后来微软基于它开发的图形界面的操作系统如Windows 3.1、Windows95/98/ME极其不稳定且容易死机。
加上类UNIX操作系统在互联网领域大行其道所以微软急需一款全新的操作系统来与之竞争。所以Windows NT诞生了。
Windows NT是微软于1993年推出的面向工作站、网络服务器和大型计算机的网络操作系统也可做PC操作系统。它是一款全新从零开始开发的新操作系统并应用了现代硬件的所有特性“NT”所指的便是“新技术”New Technology
而普通用户第一次接触基于NT内核的Windows是Windows 2000一开始用户其实是不愿意接受的因为Windows 2000对用户的硬件和应用存在兼容性问题。
随着硬件厂商和应用厂商对程序的升级这个兼容性问题被缓解了加之Windows 2000的高性能、高稳定性、高安全性用户很快便接受了这个操作系统。这可以从Windows 2000的迭代者Windows XP的巨大成功得到验证。
现在NT内核在设计上层次非常清晰明了各组件之间界限耦合程度很低。下面我们就来看看NT内核架构图了解一下NT内核是如何“庄严宏伟”。如下图
<img src="https://static001.geekbang.org/resource/image/c5/c9/c547b6252736375fcdb1456e6dfaa3c9.jpg" alt="" title="NT内核架构图">
这样看NT内核架构是不是就清晰了很多但这并不是我画图画得清晰事实上的NT确实如此。
这里我要提示一下,上图中我们只关注内核模式下的东西,也就是传统意义上的内核。
当然微软自己在HAL层上是定义了一个小内核小内核之下是硬件抽象层HAL这个HAL存在的好处是不同的硬件平台只要提供对应的HAL就可以移植系统了。小内核之上是各种内核组件微软称之为内核执行体它们完成进程、内存、配置、I/O文件缓存、电源与即插即用、安全等相关的服务。
每个执行体互相独立只对外提供相应的接口其它执行体要通过内核模式可调用接口和其它执行体通信或者请求其完成相应的功能服务。所有的设备驱动和文件系统都由I/O管理器统一管理驱动程序可以堆叠形成I/O驱动栈功能请求被封装成I/O包在栈中一层层流动处理。Windows引以为傲的图形子系统也在内核中。
显而易见NT内核中各层次分明各个执行体互相独立这种“高内聚、低偶合”的特性正是检验一个软件工程是否优秀的重要标准。而这些你都可以通过微软公开的WRK代码得到佐证如果你觉得WRK代码量太少也可以看一看[REACT OS](https://reactos.org/)这个号称“开源版”的NT。
## 重点回顾
到这里我们了解了Linux、Darwin-XNU和Windows的发展历史也清楚了它们内部的组件和结构并对它们的架构进行了对比对比后我们发现**Linux性能良好结构异常复杂不利于问题的排查和功能的扩展而Darwin-XNU和Windows结构良好层面分明利于功能扩展不容易产生问题且性能稳定。**
下面我们来回顾下这节课的重点。
首先我们从一名计算机黑客切入简单介绍了一下Linus他由于沉迷于技术对不好的规则敢于挑战而写出了Linux雏形并且利用了GNU开源软件的精神推动了Linux后来的发展这样的精神很值得我们学习。
然后我们探讨了Linux内核架构大致搞清楚了Linux内核中的各种组件它们是系统、进程、内存、储存、网络。其中每个组件都是从接口到硬件经过了几个层次组件与组件之间的层次互联调用。这些组件组合在一起其调用关系形成了一个巨大的网状结构。因此Linux也成了宏内核的代表。
为了有所对比我们研究了苹果的Darwin-XNU内核结构发现其分层更细固件层、Mach层屏蔽了硬件平台的细节向上层提供了最基础的服务。在Mach层之上的BSD层提供了更完善的服务它们是进程与线程、IPC通信、虚拟内存、安全、网络协议栈以及文件系统。通过Mach中断嵌入表可以让应用自己决定使用Mach层服务还是使用BSD层的服务因此Darwin-XNU拥有了两套内核Darwin-XNU内核层也成为了多内核架构的代表。
最后我们研究了迄今为止最成功的商业操作系统——Windows它的内核是NT其结构清晰明了各组件完全遵循了软件工程**高内聚、低偶合**的设计标准。最下层是HAL硬件抽象HAL层是为了适配各种不同的硬件平台在HAL层之上就是微软定义的小内核你可以理解成是NT内核的内核在这个小内核之上就是各种执行体了这些执行体提供了操作系统的进程、虚拟内存、文件数据缓存、安全、对象管理、配置等服务还有Windows的技术核心图形系统。
## 思考题
Windows NT内核属于哪种架构类型
很期待在留言区看到你的分享,也欢迎你把这节课分享给身边的同事、朋友。
我是LMOS让我们下节课见。

View File

@@ -0,0 +1,410 @@
<audio id="audio" title="05 | CPU工作模式执行程序的三种模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/a9/a91a3070b28b2a8b5e73bf5d34c446a9.mp3"></audio>
你好我是LMOS。
我们在前面已经设计了我们的OS架构你也许正在考虑怎么写代码实现它。恕我直言现在我们还有很多东西没搞清楚。
由于OS内核直接运行在硬件之上所以我们要对运行我们代码的硬件平台有一定的了解。接下来我会通过三节课带你搞懂硬件平台的关键内容。
今天我们先来学习CPU的工作模式硬件中最重要的就是CPU它就是执行程序的核心部件。而我们常用的电脑就是x86平台所以我们要对x86 CPU有一些基本的了解。
按照CPU功能升级迭代的顺序CPU的工作模式有**实模式**、**保护模式**、**长模式**这几种工作模式下CPU执行程序的方式截然不同下面我们一起来探讨这几种工作模式。
## 从一段死循环的代码说起
请思考一下,如果下面这段应用程序代码能够成功运行,会有什么后果?
```
int main()
{
int* addr = (int*)0;
cli(); //关中断
while(1)
{
*addr = 0;
addr++;
}
return 0;
}
```
上述代码首先关掉了CPU中断让CPU停止响应中断信号然后进入死循环最后从内存0地址开始写入0。你马上就会想到这段代码只做了两件事一是锁住了CPU二是清空了内存你也许会觉得如果这样的代码能正常运行那简直太可怕了。
不过如果是在实模式下,这样的代码确实是能正常运行。因为在很久以前,计算机资源太少,内存太小,都是单道程序执行,程序大多是由专业人员编写调试好了,才能预约到一个时间去上机运行,没有现代操作系统的概念。
后来有DOS操作系统也是单道程序系统不具备执行多道程序的能力所以CPU这种模式也能很好地工作。
下面我们就从最简单,也是最原始的实模式开始讲起。
## 实模式
实模式又称实地址模式,实,即真实,这个真实分为两个方面,一个方面是**运行真实的指令**,对指令的动作不作区分,直接**执行指令的真实功能**,另一方面是**发往内存的地址是真实的**,对任何地址**不加限制**地发往内存。
### 实模式寄存器
由于CPU是根据指令完成相应的功能举个例子ADD AX,CX这条指令完成加法操作AX、CX为ADD指令的操作数可以理解为ADD函数的两个参数其功能就是把AX、CX中的数据相加。
指令的操作数可以是寄存器、内存地址、常数其实通常情况下是寄存器AX、CX就是x86 CPU中的寄存器。
下面我们就去看看x86 CPU在实模式下的寄存器。表中每个寄存器都是16位的。
<img src="https://static001.geekbang.org/resource/image/f8/f8/f837811192730cc9c152afbcccf4eff8.jpeg" alt="" title="实模式下的寄存器">
### 实模式下访问内存
虽然有了寄存器,但是数据和指令都是存放在内存中的。通常情况下,需要把数据装载进寄存器中才能操作,还要有获取指令的动作,这些都要访问内存才行,而我们知道访问内存靠的是地址值。
那问题来了,这个值是如何计算的呢?计算过程如下图。
<img src="https://static001.geekbang.org/resource/image/14/13/14633ea933972e19f3439eb6aeab3d13.jpg" alt="" title="实模式下访问内存">
结合上图可以发现所有的内存地址都是由段寄存器左移4位再加上一个通用寄存器中的值或者常数形成地址然后由这个地址去访问内存。这就是大名鼎鼎的分段内存管理模型。
只不过这里要特别注意的是,**代码段是由CS和IP确定的而栈段是由SS和SP段确定的。**
下面我们写一个DOS下的Hello World应用程序这是一个工作在实模式下的汇编代码程序一共16位具体代码如下
```
data SEGMENT ;定义一个数据段存放Hello World!
hello DB 'Hello World!$' ;注意要以$结束
data ENDS
code SEGMENT ;定义一个代码段存放程序指令
ASSUME CS:CODE,DS:DATA ;告诉汇编程序DS指向数据段CS指向代码段
start:
MOV AX,data ;将data段首地址赋值给AX
MOV DS,AX ;将AX赋值给DS使DS指向data段
LEA DX,hello ;使DX指向hello首地址
MOV AH,09h ;给AH设置参数09HAH是AX高8位AL是AX低8位其它类似
INT 21h ;执行DOS中断输出DS指向的DX指向的字符串hello
MOV AX,4C00h ;给AH设置参数4C00h
INT 21h ;调用4C00h号功能结束程序
code ENDS
END start
```
上述代码中的结构模型也是符合CPU实模式下分段内存管理模式的它们被汇编器转换成二进制数据后也是以段的形式存在的。
代码中的注释已经很明确了你应该很容易就能理解大多数是操作寄存器其中LEA是取地址指令MOV是数据传输指令就是INT中断你可能还不太明白下面我们就来研究它。
### 实模式中断
中断即中止执行当前程序转而跳转到另一个特定的地址上去运行特定的代码。在实模式下它的实现过程是先保存CS和IP寄存器然后装载新的CS和IP寄存器那么中断是如何产生的呢
第一种情况是中断控制器给CPU发送了一个电子信号CPU会对这个信号作出应答。随随后中断控制器会将中断号发送给CPU这是**硬件中断**。
第二种情况就是CPU执行了**INT指令**,这个指令后面会跟随一个常数,这个常数即是软中断号。这种情况是软件中断。
无论是硬件中断还是软件中断都是CPU响应外部事件的一种方式。
为了实现中断就需要在内存中放一个中断向量表这个表的地址和长度由CPU的特定寄存器IDTR指向。实模式下表中的一个条目由代码段地址和段内偏移组成如下图所示。
<img src="https://static001.geekbang.org/resource/image/e8/57/e8876e8561b949b8af5d5237e48f8757.jpg" alt="" title="实模式中断表">
有了中断号以后CPU就能根据IDTR寄存器中的信息计算出中断向量中的条目进而装载CS装入代码段基地址、IP装入代码段内偏移寄存器最终响应中断。
## 保护模式
随着软件的规模不断增加,需要更高的计算量、更大的内存容量。
内存一大,首先要解决的问题是**寻址问题**因为16位的寄存器最多只能表示$2^{16}$个地址所以CPU的寄存器和运算单元都要扩展成32位的。
不过虽然扩展CPU内部器件的位数解决了计算和寻址问题但仍然没有解决前面那个实模式场景下的问题导致前面场景出问题的原因有两点。第一CPU对任何指令不加区分地执行第二CPU对访问内存的地址不加限制。
基于这些原因CPU实现了保护模式。保护模式是如何实现保护功能的呢我们接着往下看。
### 保护模式寄存器
保护模式相比于实模式增加了一些控制寄存器和段寄存器扩展通用寄存器的位宽所有的通用寄存器都是32位的还可以单独使用低16位这个低16位又可以拆分成两个8位寄存器如下表。
<img src="https://static001.geekbang.org/resource/image/0f/2a/0f564d0aac8514245805eea31aa32c2a.jpeg" alt="" title="保护模式下的寄存器">
### 保护模式特权级
为了区分哪些指令如in、out、cli和哪些资源如寄存器、I/O端口、内存地址可以被访问CPU实现了特权级。
特权级分为4级R0~R3每个特权级执行指令的数量不同R0可以执行所有指令R1、R2、R3依次递减它们只能执行上一级指令数量的子集。而内存的访问则是靠后面所说的段描述符和特权级相互配合去实现的。如下图.
<img src="https://static001.geekbang.org/resource/image/d2/2b/d29yyb3f4ac30552e4c0835525d72b2b.jpg" alt="" title="CPU特权级示意图">
**上面的圆环图,从外到内,既能体现权力的大小,又能体现各特权级对资源控制访问的多少,还能体现各特权级之间的包含关系。**R0拥有最大权力可以访问低特权级的资源反之则不行。
### 保护模式段描述符
目前为止,内存还是分段模型,要对内存进行保护,就可以转换成对段的保护。
由于CPU的扩展导致了32位的段基地址和段内偏移还有一些其它信息所以16位的段寄存器肯定放不下。放不下就要找内存借空间然后把描述一个段的信息封装成特定格式的**段描述符****放在内存中**,其格式如下。
<img src="https://static001.geekbang.org/resource/image/b4/34/b40a64dd5ca1dc1efd8957525e904634.jpg" alt="" title="保护模式段描述符">
一个段描述符有64位8字节数据里面包含了段基地址、段长度、段权限、段类型可以是系统段、代码段、数据段、段是否可读写可执行等。虽然数据分布有点乱这是由于历史原因造成的。
多个段描述符在内存中形成全局段描述符表该表的基地址和长度由CPU和GDTR寄存器指示。如下图所示。
<img src="https://static001.geekbang.org/resource/image/ab/f7/ab203e85dd8468051eca238c3ebd81f7.jpg" alt="" title="全局段描述符表">
我们一眼就可以看出段寄存器中不再存放段基地址而是具体段描述符的索引访问一个内存地址时段寄存器中的索引首先会结合GDTR寄存器找到内存中的段描述符再根据其中的段信息判断能不能访问成功。
### 保护模式段选择子
如果你认为CS、DS、ES、SS、FS、GS这些段寄存器里面存放的就是一个内存段的描述符索引那你可就草率了其实它们是由影子寄存器、段描述符索引、描述符表索引、权限级别组成的。如下图所示。
<img src="https://static001.geekbang.org/resource/image/d0/a4/d08ec3163c80a5dd94e488a71588f8a4.jpg" alt="" title="保护模式段选择子">
上图中**影子寄存器**是靠硬件来操作的,对系统程序员不可见,是硬件为了**减少性能损耗**而设计的一个段描述符的高速缓存不然每次内存访问都要去内存中查表那性能损失是巨大的影子寄存器也正好是64位里面存放了8字节段描述符数据。
低三位之所以能放TI和RPL是因为段描述符8字节对齐每个索引低3位都为0我们不用关注LDT只需要使用GDT全局描述符表所以TI永远设为0。
通常情况下CS和SS中RPL就组成了CPL当前权限级别所以常常是RPL=CPL进而CPL就表示发起访问者要以什么权限去访问目标段当CPL大于目标段DPL时则CPU禁止访问只有CPL小于等于目标段DPL时才能访问。
### 保护模式平坦模型
分段模型有很多缺陷这在后面课程讲内存管理时有详细介绍其实现代操作系统都会使用分页模型这点在后面讲MMU那节课再探讨
但是x86 CPU并不能直接使用分页模型而是要在分段模型的前提下根据需要决定是否要开启分页。因为这是硬件的规定程序员是无法改变的。但是我们可以简化设计来使分段成为一种“虚设”这就是保护模式的平坦模型。
根据前面的描述我们发现CPU 32位的寄存器最多只能产生4GB大小的地址而一个段长度也只能是4GB所以我们把所有段的基地址设为0段的长度设为0xFFFFF段长度的粒度设为4KB这样所有的段都指向同一个0~4GB-1字节大小的地址空间。
下面我们还是看一看前面Hello OS中段描述符表如下所示。
```
GDT_START:
knull_dsc: dq 0
;第一个段描述符CPU硬件规定必须为0
kcode_dsc: dq 0x00cf9e000000ffff
;段基地址=0段长度=0xfffff
;G=1,D/B=1,L=0,AVL=0
;P=1,DPL=0,S=1
;T=1,C=1,R=1,A=0
kdata_dsc: dq 0x00cf92000000ffff
;段基地址=0段长度=0xfffff
;G=1,D/B=1,L=0,AVL=0
;P=1,DPL=0,S=1
;T=0,C=0,R=1,A=0
GDT_END:
GDT_PTR:
GDTLEN dw GDT_END-GDT_START-1
GDTBASE dd GDT_START
```
上面代码中注释已经很明白了段长度需要和G位配合若G位为1则段长度等于0xfffff个4KB。上面段描述符的DPL=0这说明需要最高权限即CPL=0才能访问。
### 保护模式中断
你还记得实模式下CPU是如何处理中断的吗如果不记得了请回到前面看一看。
因为实模式下CPU不需要做权限检查所以它可以直接通过中断向量表中的值装载CS:IP寄存器就好了。
而保护模式下的中断要权限检查,还有特权级的切换,所以就需要扩展中断向量表的信息,即每个中断用一个中断门描述符来表示,也可以简称为中断门,中断门描述符依然有自己的格式,如下图所示。
<img src="https://static001.geekbang.org/resource/image/e1/0b/e11b9de930a09fb41bd6ded9bf12620b.jpg" alt="" title="保护模式中断门描述符">
同样的保护模式要实现中断也必须在内存中有一个中断向量表同样是由IDTR寄存器指向只不过中断向量表中的条目变成了中断门描述符如下图所示。
<img src="https://static001.geekbang.org/resource/image/ff/5b/ff5c25c85a7fa28b17f386848f19fb5b.jpg" alt="" title="保护模式段中断表">
产生中断后CPU首先会检查中断号是否大于**最后一个中断门描述符**x86 CPU最大支持256个中断源即中断号0~255然后检查描述符类型是否是中断门或者陷阱门、是否为系统描述符是不是存在于内存中。
接着,检查中断门描述符中的段选择子指向的段描述符。
最后做**权限检查**如果CPL小于等于中断门的DPL并且CPL大于等于中断门中的段选择子就指向段描述符的DPL。
进一步的CPL等于中断门中的段选择子指向段描述符的DPL则为同级权限不进行栈切换否则进行栈切换。如果进行栈切换还需要从TSS中加载具体权限的SS、ESP当然也要对SS中段选择子指向的段描述符进行检查。
做完这一系列检查之后CPU才会加载中断门描述符中目标代码段选择子到CS寄存器中把目标代码段偏移加载到EIP寄存器中。
### 切换到保护模式
x86 CPU在第一次加电和每次reset后都会自动进入实模式要想进入保护模式就需要程序员写代码实现从实模式切换到保护模式。切换到保护模式的步骤如下。
第一步,准备全局段描述符表,代码如下。
```
GDT_START:
knull_dsc: dq 0
kcode_dsc: dq 0x00cf9e000000ffff
kdata_dsc: dq 0x00cf92000000ffff
GDT_END:
GDT_PTR:
GDTLEN dw GDT_END-GDT_START-1
GDTBASE dd GDT_START
```
第二步加载设置GDTR寄存器使之指向全局段描述符表。
```
lgdt [GDT_PTR]
```
第三步设置CR0寄存器开启保护模式。
```
;开启 PE
mov eax, cr0
bts eax, 0 ; CR0.PE =1
mov cr0, eax
```
第四步进行长跳转加载CS段寄存器即段选择子。
```
jmp dword 0x8 :_32bits_mode ;_32bits_mode为32位代码标号即段偏移
```
你也许会有疑问为什么要进行长跳转这是因为我们无法直接或间接mov一个数据到CS寄存器中因为刚刚开启保护模式时CS的影子寄存器还是实模式下的值所以需要告诉CPU加载新的段信息。
接下来CPU发现了CRO寄存器第0位的值是1就会按GDTR的指示找到全局描述符表然后根据索引值8把新的段描述符信息加载到CS影子寄存器当然这里的前提是进行一系列合法的检查。
到此为止CPU真正进入了保护模式CPU也有了32位的处理能力。
## 长模式
长模式又名AMD64因为这个标准是AMD公司最早定义的它使CPU在现有的基础上有了64位的处理能力既能完成64位的数据运算也能寻址64位的地址空间。这在大型计算机上犹为重要因为它们的物理内存通常有几百GB。
### 长模式寄存器
长模式相比于保护模式增加了一些通用寄存器并扩展通用寄存器的位宽所有的通用寄存器都是64位还可以单独使用低32位。
这个低32位可以拆分成一个低16位寄存器低16位又可以拆分成两个8位寄存器如下表。
<img src="https://static001.geekbang.org/resource/image/cc/34/cce7aa5fe43552357bc51455cd86a734.jpg" alt="" title="长模式下的寄存器">
### 长模式段描述符
长模式依然具备保护模式绝大多数特性,如特权级和权限检查。相同的部分就不再重述了,这里只会说明长模式和保护模式下的差异。
下面我们来看看长模式下段描述的格式,如下图所示。<br>
<img src="https://static001.geekbang.org/resource/image/97/c4/974b59084976ddb3df9bdc3bea9325c4.jpg" alt="" title="长模式段描述符">
在长模式下CPU不再对段基址和段长度进行检查只对DPL进行相关的检查这个检查流程和保护模式下一样。
当描述符中的L=1D/B=0时就是64位代码段DPL还是0~3的特权级。然后有多个段描述在内存中形成一个全局段描述符表同样由CPU的GDTR寄存器指向。
下面我们来写一个长模式下的段描述符表,加深一下理解,如下所示.
```
ex64_GDT:
null_dsc: dq 0
;第一个段描述符CPU硬件规定必须为0
c64_dsc:dq 0x0020980000000000 ;64位代码段
;无效位填0
;D/B=0,L=1,AVL=0
;P=1,DPL=0,S=1
;T=1,C=0,R=0,A=0
d64_dsc:dq 0x0000920000000000 ;64位数据段
;无效位填0
;P=1,DPL=0,S=1
;T=0,C/E=0,R/W=1,A=0
eGdtLen equ $ - null_dsc ;GDT长度
eGdtPtr:dw eGdtLen - 1 ;GDT界限
dq ex64_GDT
```
上面代码中注释已经很清楚了段长度和段基址都是无效的填充为0CPU不做检查。但是上面段描述符的DPL=0这说明需要最高权限即CPL=0才能访问。若是数据段的话G、D/B、L位都是无效的。
### 长模式中断
保护模式下为了实现对中断进行权限检查实现了中断门描述符在中断门描述符中存放了对应的段选择子和其段内偏移还有DPL权限如果权限检查通过则用对应的段选择子和其段内偏移装载CS:EIP寄存器。
如果你还记得中断门描述符就会发现其中的段内偏移只有32位但是长模式支持64位内存寻址所以要对中断门描述符进行修改和扩展下面我们就来看看长模式下的中断门描述符的格式如下图所示。
<img src="https://static001.geekbang.org/resource/image/28/c4/28f28817ca5a3e47f80ea798698dbdc4.jpg" alt="" title="长模式中断门描述符">
结合上图,我们可以看出**长模式下中断门描述符的格式变化**。
首先为了支持64位寻址中断门描述符在原有基础上增加8字节用于存放目标段偏移的高32位值。其次目标代码段选择子对应的代码段描述符必须是64位的代码段。最后其中的IST是64位TSS中的IST指针因为我们不使用这个特性所以不作详细介绍。
长模式也同样在内存中有一个中断门描述符表只不过表中的条目如上图所示是16字节大小最多支持256个中断源对中断的响应和相关权限的检查和保护模式一样这里不再赘述。
### 切换到长模式
我们既可以从实模式直接切换到长模式,也可以从保护模式切换长模式。切换到长模式的步骤如下。
第一步,准备长模式全局段描述符表。
```
ex64_GDT:
null_dsc: dq 0
;第一个段描述符CPU硬件规定必须为0
c64_dsc:dq 0x0020980000000000 ;64位代码段
d64_dsc:dq 0x0000920000000000 ;64位数据段
eGdtLen equ $ - null_dsc ;GDT长度
eGdtPtr:dw eGdtLen - 1 ;GDT界限
dq ex64_GDT
```
第二步准备长模式下的MMU页表这个是为了开启分页模式**切换到长模式必须要开启分页**,想想看,长模式下已经不对段基址和段长度进行检查了,那么内存地址空间就得不到保护了。
而长模式下内存地址空间的保护交给了MMUMMU依赖页表对地址进行转换页表有特定的格式存放在内存中其地址由CPU的CR3寄存器指向这在后面讲MMU的那节课会专门讲。
```
mov eax, cr4
bts eax, 5 ;CR4.PAE = 1
mov cr4, eax ;开启 PAE
mov eax, PAGE_TLB_BADR ;页表物理地址
mov cr3, eax
```
1. 加载GDTR寄存器使之指向全局段描述表
```
lgdt [eGdtPtr]
```
1. 开启长模式要同时开启保护模式和分页模式在实现长模式时定义了MSR寄存器需要用专用的指令rdmsr、wrmsr进行读写IA32_EFER寄存器的地址为0xC0000080它的第8位决定了是否开启长模式。
```
;开启 64位长模式
mov ecx, IA32_EFER
rdmsr
bts eax, 8 ;IA32_EFER.LME =1
wrmsr
;开启 保护模式和分页模式
mov eax, cr0
bts eax, 0 ;CR0.PE =1
bts eax, 31
mov cr0, eax
```
1. 进行跳转加载CS段寄存器刷新其影子寄存器。
```
jmp 08:entry64 ;entry64为程序标号即64位偏移地址
```
切换到长模式和切换保护模式的流程差不多,只是需要准备的段描述符有所区别,还有就是要注意同时开启保护模式和分页模式。原因在上面已经说明了。
## 重点回顾
好,这节课的内容告一段落了,我来给你做个总结。
今天我们从一段死循环的代码开始思考研究这类代码产生的问题和解决思路然后一步步探索CPU为了处理这些问题而做出的改进和升级。这些功能上的改进和升级渐渐演变成了CPU的工作模式这也是系统开发人员需要了解的编程模型。这三种模式梳理如下。
1.实模式早期CPU是为了支持单道程序运行而实现的单道程序能掌控计算机所有的资源早期的软件规模不大内存资源也很少所以实模式极其简单仅支持16位地址空间分段的内存模型**对指令不加限制地运行,对内存没有保护隔离作用**。
2.保护模式随着多道程序的出现就需要操作系统了。内存需求量不断增加所以CPU实现了保护模式以支持这些需求。
保护模式包含**特权级**对指令及其访问的资源进行控制对内存段与段之间的访问进行严格检查没有权限的绝不放行对中断的响应也要进行严格的权限检查扩展了CPU寄存器位宽使之能够寻址32位的内存地址空间和处理32位的数据从而CPU的性能大大提高。
3.长模式又名AMD64模式最早由AMD公司制定。由于软件对CPU性能需求永无止境所以长模式在保护模式的基础上把寄存器扩展到64位同时增加了一些寄存器使CPU具有了能处理64位数据和寻址64位的内存地址空间的能力。
长模式**弱化段模式管理**只保留了权限级别的检查忽略了段基址和段长度而地址的检查则交给了MMU。
## 思考题
请问实模式下能寻址多大的内存空间?
期待你在留言区跟我交流互动如果你身边有对CPU工作模式感兴趣的朋友也欢迎把这节课的内容转发给他我们一起学习进步。

View File

@@ -0,0 +1,266 @@
<audio id="audio" title="06 | 虚幻与真实:程序中的地址如何转换?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/93/93a78183f65a089cd2e950a90d2b7393.mp3"></audio>
你好我是LMOS。
从前面的课程我们得知CPU执行程序、处理数据都要和内存打交道这个打交道的方式就是内存地址。
读取指令、读写数据都需要首先告诉内存芯片hi内存老哥请你把0x10000地址处的数据交给我……hi内存老哥我已经计算完成请让我把结果写回0x200000地址的空间。这些地址存在于代码指令字段后的常数或者存在于某个寄存器中。
<img src="https://static001.geekbang.org/resource/image/b0/fc/b0e93b744dfdc62c4a3ce8816b25b1fc.jpg" alt="">
今天,我们就来专门研究一下程序中的地址。说起程序中的地址,不知道你是否好奇过,为啥系统设计者要引入虚拟地址呢?
我会先带你从一个多程序并发的场景热身,一起思考这会导致哪些问题,为什么能用虚拟地址解决这些问题。
搞懂原理之后,我还会带你一起探索**虚拟地址和物理地址的关系和转换机制**。在后面的课里,你会发现,我们最宝贵的内存资源正是通过这些机制来管理的。
## 从一个多程序并发的场景说起
设想一下如果一台计算机的内存中只运行一个程序A这种方式正好用前面CPU的[实模式](https://time.geekbang.org/column/article/375278)来运行因为程序A的地址在链接时就可以确定例如从内存地址0x8000开始每次运行程序A都装入内存0x8000地址处开始运行没有其它程序干扰。
现在改变一下内存中又放一道程序B程序A和程序B各自运行一秒钟如此循环直到其中之一结束。这个新场景下就会产生一些问题当然这里我们只关心内存相关的这几个核心问题。
1.谁来保证程序A跟程序B **没有内存地址的冲突**换句话说就是程序A、B各自放在什么内存地址这个问题是由A、B程序协商还是由操作系统决定。
2.怎样保证程序A跟程序B **不会互相读写各自的内存空间**?这个问题相对简单,用保护模式就能解决。
3.如何解决**内存容量**问题程序A和程序B在不断开发迭代中程序代码占用的空间会越来越大导致内存装不下。
4.还要考虑一个**扩展后的复杂情况**如果不只程序A、B还可能有程序C、D、E、F、G……它们分别由不同的公司开发而每台计算机的内存容量不同。这时候又对我们的内存方案有怎样的影响呢
要想完美地解决以上最核心的4个问题一个较好的方案是让所有的程序都各自享有一个从0开始到最大地址的空间这个地址空间是独立的是该程序私有的其它程序既看不到也不能访问该地址空间这个地址空间和其它程序无关和具体的计算机也无关。
事实上,计算机科学家们早就这么做了,这个方案就是**虚拟地址**,下面我们就来看看它。
## 虚拟地址
正如其名,这个地址是虚拟的,自然而然地和具体环境进行了解耦,这个环境包括系统软件环境和硬件环境。
虚拟地址是逻辑上存在的一个数据值比如0~100就有101个整数值这个0~100的区间就可以说是一个虚拟地址空间该虚拟地址空间有101个地址。
我们再来看看最开始Hello World的例子我们用objdump工具反汇编一下Hello World二进制文件就会得到如下的代码片段
```
00000000000004e8 &lt;_init&gt;:
4e8: 48 83 ec 08 sub $0x8,%rsp
4ec: 48 8b 05 f5 0a 20 00 mov 0x200af5(%rip),%rax # 200fe8 &lt;__gmon_start__&gt;
4f3: 48 85 c0 test %rax,%rax
4f6: 74 02 je 4fa &lt;_init+0x12&gt;
4f8: ff d0 callq *%rax
4fa: 48 83 c4 08 add $0x8,%rsp
4fe: c3 retq
```
上述代码中左边第一列数据就是虚拟地址第三列中是程序指令“mov 0x200af5(%rip),%raxje 4facallq *%rax”指令中的数据都是虚拟地址。
事实上,所有的应用程序开始的部分都是这样的。这正是因为每个应用程序的虚拟地址空间都是相同且独立的。
那么这个地址是由谁产生的呢?
答案是链接器,其实我们开发软件经过编译步骤后,就需要链接成可执行文件才可以运行,而链接器的主要工作就是把多个代码模块组装在一起,并解决模块之间的引用,即处理程序代码间的地址引用,形成程序运行的静态内存空间视图。
只不过这个地址是虚拟而统一的,而根据操作系统的不同,这个虚拟地址空间的定义也许不同,应用软件开发人员无需关心,由开发工具链给自动处理了。由于这虚拟地址是独立且统一的,所以各个公司开发的各个应用完全不用担心自己的内存空间被占用和改写。
## 物理地址
虽然虚拟地址解决了很多问题,但是虚拟地址只是逻辑上存在的地址,无法作用于硬件电路的,程序装进内存中想要执行,就需要和内存打交道,从内存中取得指令和数据。而内存只认一种地址,那就是**物理地址**。
什么是物理地址呢?物理地址在逻辑上也是一个数据,只不过这个数据会被地址译码器等电子器件变成电子信号,放在地址总线上,地址总线电子信号的各种组合就可以选择到内存的储存单元了。
但是地址总线上的信号即物理地址也可以选择到别的设备中的储存单元如显卡中的显存、I/O设备中的寄存器、网卡上的网络帧缓存器。不过如果不做特别说明我们说的物理地址就是指**选择内存单元的地址**。
## 虚拟地址到物理地址的转换
明白了虚拟地址和物理地址之后我们发现虚拟地址必须转换成物理地址这样程序才能正常执行。要转换就必须要转换机构它相当于一个函数p=f(v)输入虚拟地址v输出物理地址p。
那么要怎么实现这个函数呢?
用软件方式实现太低效用硬件实现没有灵活性最终就用了软硬件结合的方式实现它就是MMU内存管理单元。MMU可以接受软件给出的地址对应关系数据进行地址转换。
我们先来看看逻辑上的MMU工作原理框架图。如下图所示<br>
<img src="https://static001.geekbang.org/resource/image/d5/99/d582ff647549b8yy986d90e697d33499.jpg" alt="" title="MMU工作原理图">
上图中展示了MMU通过地址关系转换表将0x80000~0x84000的虚拟地址空间转换成 0x10000~0x14000的物理地址空间而地址关系转换表本身则是放物理内存中的。
下面我们不妨想一想地址关系转换表的实现.如果在地址关系转换表中,这样来存放:一个虚拟地址对应一个物理地址。
那么问题来了32位地址空间下4GB虚拟地址的地址关系转换表就会把整个32位物理地址空间用完这显然不行。
要是结合前面的保护模式下分段方式呢,地址关系转换表中存放:一个虚拟段基址对应一个物理段基址,这样看似可以,但是因为段长度各不相同,所以依然不可取。
综合刚才的分析,系统设计者最后采用一个折中的方案,即**把虚拟地址空间和物理地址空间都分成同等大小的块,也称为页,按照虚拟页和物理页进行转换。**根据软件配置不同这个页的大小可以设置为4KB、2MB、4MB、1GB这样就进入了现代内存管理模式——**分页模型**。
下面来看看分页模型框架,如下图所示:
<img src="https://static001.geekbang.org/resource/image/9b/d0/9b19677448ee973c4f3yya6b3af7b4d0.jpg" alt="" title="分页模型框架图">
结合图片可以看出,一个虚拟页可以对应到一个物理页,由于页大小一经配置就是固定的,所以在地址关系转换表中,只要存放**虚拟页地址对应的物理页地址**就行了。
我知道说到这里也许你仍然没搞清楚MMU和地址关系转换表的细节别急我们现在已经具备了研究它们的基础下面我们就去探索它们。
## MMU
MMU即内存管理单元是用硬件电路逻辑实现的一个地址转换器件它负责接受虚拟地址和地址关系转换表以及输出物理地址。
根据实现方式的不同MMU可以是独立的芯片也可以是集成在其它芯片内部的比如集成在CPU内部x86、ARM系列的CPU就是将MMU集成在CPU核心中的。
SUN公司的CPU是将独立的MMU芯片卡在总线上的有一夫当关的架势。下面我们只研究x86 CPU中的MMU。x86 CPU要想开启MMU就必须先开启保护模式或者长模式实模式下是不能开启MMU的。
由于保护模式的内存模型是分段模型它并不适合于MMU的分页模型所以我们要使用保护模式的平坦模式这样就绕过了分段模型。这个平坦模型和长模式下忽略段基址和段长度是异曲同工的。地址产生的过程如下所示。
<img src="https://static001.geekbang.org/resource/image/b4/88/b41a2bb00e19e662b34a1b7b7c0ae288.jpg" alt="" title="CPU地址转换图">
上图中程序代码中的虚拟地址经过CPU的分段机制产生了线性地址平坦模式和长模式下线性地址和虚拟地址是相等的。
如果不开启MMU在保护模式下可以关闭MMU这个线性地址就是物理地址。因为长模式下的分段**弱化了地址空间的隔离**所以开启MMU是必须要做的开启MMU才能访问内存地址空间。
### MMU页表
现在我们开始研究地址关系转换表,其实它有个更加专业的名字——**页表**。它描述了虚拟地址到物理地址的转换关系,也可以说是虚拟页到物理页的映射关系,所以称为页表。
为了增加灵活性和节约物理内存空间因为页表是放在物理内存中的所以页表中并不存放虚拟地址和物理地址的对应关系只存放物理页面的地址MMU以虚拟地址为索引去查表返回物理页面地址而且页表是分级的总体分为三个部分一个顶级页目录多个中级页目录最后才是页表逻辑结构图如下.
<img src="https://static001.geekbang.org/resource/image/2d/yf/2df904c8ba75065e1491138d63820yyf.jpg" alt="" title="MMU页表原理图">
从上面可以看出,一个虚拟地址被分成从左至右四个位段。
第一个位段索引顶级页目录中一个项,该项指向一个中级页目录,然后用第二个位段去索引中级页目录中的一个项,该项指向一个页目录,再用第三个位段去索引页目录中的项,该项指向一个物理页地址,最后用第四个位段作该物理页内的偏移去访问物理内存。**这就是MMU的工作流程。**
## 保护模式下的分页
前面的内容都是理论上帮助我们了解分页模式原理的,分页模式的**灵活性、通用性、安全性**,是现代操作系统内存管理的基石,更是事实上的标准内存管理模型,现代商用操作系统都必须以此为基础实现虚拟内存功能模块。
因为我们的主要任务是开发操作系统而开发操作系统就落实到真实的硬件平台上去的下面我们就来研究x86 CPU上的分页模式。
首先来看看保护模式下的分页保护模式下只有32位地址空间最多4GB-1大小的空间。
根据前面得知32位虚拟地址经过分段机制之后得到线性地址又因为通常使用平坦模式所以线性地址和虚拟地址是相同的。
保护模式下的分页大小通常有两种一种是4KB大小的页一种是4MB大小的页。分页大小的不同会导致虚拟地址位段的分隔和页目录的层级不同但虚拟页和物理页的大小始终是等同的。
### 保护模式下的分页——4KB页
该分页方式下32位虚拟地址被分为三个位段**页目录索引、页表索引、页内偏移**只有一级页目录其中包含1024个条目 每个条目指向一个页表每个页表中有1024个条目。其中一个条目就指向一个物理页每个物理页4KB。这正好是4GB地址空间。如下图所示。
<img src="https://static001.geekbang.org/resource/image/00/f8/00b7f1ef4a1c4f6fc9e6b69109ae0bf8.jpg" alt="" title="保护模式下的4KB分页">
上图中CR3就是CPU的一个32位的寄存器MMU就是根据这个寄存器找到页目录的。下面我们看看当前分页模式下的CR3、页目录项、页表项的格式。
<img src="https://static001.geekbang.org/resource/image/36/c9/361c48e1876a412f9ff9f29bf2dbecc9.jpg" alt="">
可以看到页目录项、页表项都是4字节32位1024个项正好是4KB一个页因此它们的地址始终是4KB对齐的所以低12位才可以另作它用形成了页面的相关属性如是否存在、是否可读可写、是用户页还是内核页、是否已写入、是否已访问等。
### 保护模式下的分页——4MB页
该分页方式下32位虚拟地址被分为两个位段**页表索引、页内偏移**只有一级页目录其中包含1024个条目。其中一个条目指向一个物理页每个物理页4MB正好为4GB地址空间如下图所示。
<img src="https://static001.geekbang.org/resource/image/76/52/76932c52a7b6109854f2de72d71bba52.jpg" alt="" title="保护模式下的4MB分页">
CR3还是32位的寄存器只不过不再指向顶级页目录了而是指向一个4KB大小的页表这个页表依然要4KB地址对齐其中包含1024个页表项格式如下图。<br>
<img src="https://static001.geekbang.org/resource/image/9a/08/9a4afdc60b790c3e2b7e94b0c7fd4208.jpg" alt="">
可以发现4MB大小的页面下页表项还是4字节32位但只需要用高10位来保存物理页面的基地址就可以。因为每个物理页面都是4MB所以低22位始终为0为了兼容4MB页表项低8位和4KB页表项一样只不过第7位变成了PS位且必须为1而PAT位移到了12位。
## 长模式下的分页
如果开启了长模式,则必须同时开启分页模式,因为长模式弱化了分段模型,而分段模型也确实有很多不足,不适应现在操作系统和应用软件的发展。
同时长模式也扩展了CPU的位宽使得CPU能使用64位的超大内存地址空间。所以长模式下的虚拟地址必须等于线性地址且为64位。
长模式下的分页大小通常也有两种4KB大小的页和2MB大小的页。
### 长模式下的分页——4KB页
该分页方式下64位虚拟地址被分为6个位段分别是保留位段顶级页目录索引、页目录指针索引、页目录索引、页表索引、页内偏移顶级页目录、页目录指针、页目录、页表各占有4KB大小其中各有512个条目每个条目8字节64位大小如下图所示。
<img src="https://static001.geekbang.org/resource/image/ec/c9/ecdea93c2544cf9c1d84461b602b03c9.jpg" alt="" title="长模式下的4KB分页">
上面图中CR3已经变成64位的CPU的寄存器它指向一个顶级页目录里面的顶级页目项指向页目录指针依次类推。
需要注意的是虚拟地址48到63这6位是根据**第47位**来决定的47位为1它们就为1反之为0这是因为x86 CPU并没有实现全64位的地址总线而是只实现了48位但是CPU的寄存器却是64位的。
这种最高有效位填充的方式即使后面扩展CPU的地址总线也不会有任何影响下面我们去看看当前分页模式下的CR3、顶级页目录项、页目录指针项、页目录项、页表项的格式我画了一张图帮你理解。
<img src="https://static001.geekbang.org/resource/image/e3/55/e342246f5cfa21c5b5173b9e494bdc55.jpg" alt="">
由上图可知长模式下的4KB分页下由一个顶层目录、二级中间层目录和一层页表组成了64位地址翻译过程。
顶级页目录项指向页目录指针页页目录指针项指向页目录页页目录项指向页表页页表项指向一个4KB大小的物理页各级页目录项中和页表项中依然存在各种属性位这在图中已经说明。其中的XD位可以控制代码页面是否能够运行。
### 长模式下的分页——2MB页
在这种分页方式下64位虚拟地址被分为5个位段 保留位段、顶级页目录索引、页目录指针索引、页目录索引页内偏移顶级页目录、页目录指针、页目录各占有4KB大小其中各有512个条目每个条目8字节64位大小。
<img src="https://static001.geekbang.org/resource/image/68/ea/68bf70d8bcae7802e5291140ac1ec6ea.jpg" alt="" title="长模式下的2MB分页">
可以发现长模式下2MB和4KB分页的区别是2MB分页下是页目录项直接指向了2MB大小的物理页面放弃了**页表项**然后把虚拟地址的低21位作为页内偏移21位正好索引2MB大小的地址空间。
下面我们还是要去看看2MB分页模式下的CR3、顶级页目录项、页目录指针项、页目录项的格式格式如下图。
<img src="https://static001.geekbang.org/resource/image/45/0b/457f6965d0f25bf64bfb9ec698ab7e0b.jpg" alt="">
上图中没有了页表项取而代之的是页目录项中直接存放了2MB物理页基地址。由于物理页始终2MB对齐所以其地址的低21位为0用于存放页面属性位。
## 开启MMU
要使用分页模式就必先开启MMU但是开启MMU的前提是CPU进入保护模式或者长模式开启CPU这两种模式的方法我们在前面[第五节课](https://time.geekbang.org/column/article/375278)已经讲过了下面我们就来开启MMU步骤如下
1.使CPU进入保护模式或者长模式。
2.准备好页表数据,这包含顶级页目录,中间层页目录,页表,假定我们已经编写了代码,在物理内存中生成了这些数据。
3.把顶级页目录的物理内存地址赋值给CR3寄存器。
```
mov eax, PAGE_TLB_BADR ;页表物理地址
mov cr3, eax
```
1. 设置CPU的CR0的PE位为1这样就开启了MMU。
```
;开启 保护模式和分页模式
mov eax, cr0
bts eax, 0 ;CR0.PE =1
bts eax, 31 ;CR0.P = 1
mov cr0, eax
```
## MMU地址转换失败
MMU的主要功能是根据页表数据把虚拟地址转换成物理地址但有没有可能转换失败
绝对有可能例如页表项中的数据为空用户程序访问了超级管理者的页面向只读页面中写入数据。这些都会导致MMU地址转换失败。
MMU地址转换失败了怎么办呢失败了既不能放行也不是resetMMU执行的操作如下。
1.MMU停止转换地址。<br>
2.MMU把转换失败的虚拟地址写入CPU的CR2寄存器。<br>
3.MMU触发CPU的14号中断使CPU停止执行当前指令。<br>
4.CPU开始执行14号中断的处理代码代码会检查原因处理好页表数据返回。<br>
5.CPU中断返回继续执行MMU地址转换失败时的指令。
这里你只要先明白这个流程就好了,后面课程讲到内存管理的时候我们继续探讨。
## 重点回顾
又到了课程的尾声,把心情放松下来,我们一起来回顾这节课的重点。
首先,我们从一个场景开始热身,发现多道程序同时运行有很多问题,都是内存相关的问题,内存需要**隔离和保护**。从而提出了虚拟地址与物理地址分离,让应用程序从实际的物理内存中解耦出来。
虽然虚拟地址是个非常不错的方案但是虚拟地址必须转换成物理地址才能在硬件上执行。为了执行这个转换过程才开发出了MMU内存管理单元MMU**增加了转换的灵活性**,它的实现方式是**硬件执行转换过程,但又依赖于软件提供的地址转换表。**
最后我们下落到具体的硬件平台研究了x86 CPU上的MMU。
x86 CPU上的MMU在其保护模式和长模式下提供4KB、2MB、4MB等页面转换方案我们详细分析了它们的**页表格式**。同时,也搞清楚了**如何开启MMU以及MMU地址转换失败后执行的操作。**
## 思考题
在分页模式下,操作系统是如何对应用程序的地址空间进行隔离的?
欢迎你在留言区和我交流互动。如果这节课对你有启发的话,也欢迎你转发给朋友、同事,说不定就能帮他解决疑问。
我是LMOS我们下节课见

View File

@@ -0,0 +1,235 @@
<audio id="audio" title="07 | Cache与内存程序放在哪儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/40/d8/405e834be71c1c94339022eef65e0cd8.mp3"></audio>
你好我是LMOS。
在前面的课程里我们已经知道了CPU是如何执行程序的也研究了程序的地址空间这里我们终于到了程序的存放地点——内存。
你知道什么是Cache吗在你心中真实的内存又是什么样子呢今天我们就来重新认识一下Cache和内存这对我们利用Cache写出高性能的程序代码和实现操作系统管理内存有着巨大的帮助。
通过这节课的内容我们一起来看看内存到底是啥它有什么特性。有了这个认识你就能更加深入地理解我们看似熟悉的局部性原理从而搞清楚为啥Cache是解决内存瓶颈的神来之笔。最后我还会带你分析x86平台上的Cache规避Cache引发的一致性问题并让你掌握获取内存视图的方法。
那话不多说,带着刚才的问题,我们正式进入今天的学习吧!
## 从一段“经典”代码看局部性原理
不知道你还记不记得C语言打印九九乘法表的代码想不起来也没关系下面我把它贴出来代码很短也很简单就算你自己写一个也用不了一分钟如下所示。
```
#include &lt;stdio.h&gt;
int main(){
int i,j;
for(i=1;i&lt;=9;i++){
for(j=1;j&lt;=i;j++){
printf(&quot;%d*%d=%2d &quot;,i,j,i*j);
}
printf(&quot;\n&quot;);
}
return 0;
}
```
我们当然不是为了研究代码本身,这个代码非常简单,这里我们主要是观察这个结构,代码的结构主要是**顺序、分支、循环**,这三种结构可以写出现存所有算法的程序。
我们常规情况下写的代码是顺序和循环结构居多。上面的代码中有两重循环,内层循环的次数受到外层循环变量的影响。就是这么简单,但是越简单的东西越容易看到本质。
可以看到这个代码大数时间在执行一个乘法计算和调用一个printf函数而程序一旦编译装载进内存中它的地址就确定了。也就是说CPU大多数时间在访问相同或者与此相邻的地址换句话说就是CPU大多数时间在执行相同的指令或者与此相邻的指令。这就是大名鼎鼎的**程序局部性原理**。
## 内存
明白了程序的局部性原理之后,我们再来看看内存。你或许感觉这跨越有点大,但是只有明白了内存的结构和特性,你才能明白程序局部性原理的应用场景和它的重要性。
内存也可称为主存,不管硬盘多大、里面存放了多少程序和数据,只要程序运行或者数据要进行计算处理,就必须先将它们装入内存。我们先来看看内存长什么样(你也可以上网自行搜索),如下图所示。
<img src="https://static001.geekbang.org/resource/image/0d/8e/0d0d85383416f2f8841aeebe7021a88e.jpg" alt="">
从上图可以看到在PCB板上有内存颗粒芯片主要是用来存放数据的。SPD芯片用于存放内存自身的容量、频率、厂商等信息。还有最显眼的金手指用于连接数据总线和地址总线电源等。
其实从专业角度讲,内存应该叫**DRAM**即动态随机存储器。内存储存颗粒芯片中的存储单元是由电容和相关元件做成的电容存储电荷的多、少代表数字信号0和1。
而随着时间的流逝电容存在漏电现象这导致电荷不足就会让存储单元的数据出错所以DRAM需要周期性刷新以保持电荷状态。DRAM结构较简单且集成度很高通常用于制造内存条中的储存颗粒芯片。
虽然内存技术标准不断更新,但是储存颗粒的内部结构没有本质改变,还是电容存放电荷,标准看似更多,实际上只是提升了位宽、工作频率,以及传输时预取的数据位数。
比如DDR SDRAM即双倍速率同步动态随机存储器它使用2.5V的工作电压数据位宽为64位核心频率最高为166MHz。下面简称DDR内存它表示每一个时钟脉冲传输两次数据分别在时钟脉冲的上升沿和下降沿各传输一次数据因此称为双倍速率的SDRAM。
后来的DDR2、DDR3、DDR4也都在核心频率和预取位数上做了提升。最新的DDR4采用1.2V工作电压数据位宽为64位预取16位数据。DDR4取消了双通道机制一条内存即为一条通道工作频率最高可达4266MHz单根DDR4内存的数据传输带宽最高为34GB/s。
其实我们无需过多关注内存硬件层面的技术规格标准,重点需要关注的是,**内存的速度还有逻辑上内存和系统的连接方式和结构**,这样你就能意识到内存有多慢,还有是什么原因导致内存慢的。
我们还是画幅图说明吧,如下图所示。
<img src="https://static001.geekbang.org/resource/image/1b/ed/1b41056cce55b17a2366d7b9dd922aed.jpg" alt="" title="DDR内存逻辑结构连接图">
结合图片我们看到控制内存刷新和内存读写的是内存控制器而内存控制器集成在北桥芯片中。传统方式下北桥芯片存在于系统主板上而现在由于芯片制造工艺的升级芯片集成度越来越高所以北桥芯片被就集成到CPU芯片中了同时这也大大提升了CPU访问内存的性能。
而作为软件开发人员,从逻辑上我们只需要把内存看成一个巨大的字节数组就可以,而内存地址就是这个数组的下标。
## CPU到内存的性能瓶颈
尽管CPU和内存是同时代发展的但CPU所使用技术工艺的材料和内存是不同的侧重点也不同价格也不同。如果内存使用CPU的工艺和材料制造那内存条的昂贵程度会超乎想象没有多少人能买得起。
由于这些不同导致了CPU和内存条的数据吞吐量天差地别。尽管最新的DDR4内存条带宽高达34GB/s然而这相比CPU的数据吞吐量要慢上几个数量级。再加上多核心CPU同时访问内存会导致总线争用问题数据吞吐量会进一步下降。
CPU要数据内存一时给不了怎么办CPU就得等通常CPU会让总线插入等待时钟周期直到内存准备好到这里你就会发现无论CPU的性能多高都没用而**内存才是决定系统整体性能的关键**。显然依靠目前的理论直接提升内存性能达到CPU的同等水平这是不可行的得想其它的办法。
## Cache
让我们重新回到前面的场景中回到程序的局部性原理它告诉我们CPU大多数时间在访问相同或者与此相邻的地址。那么我们立马就可以想到用一块**小而快**的储存器放在CPU和内存之间就可以利用程序的局部性原理来缓解CPU和内存之间的性能瓶颈。这块**小而快**的储存器就是Cache即高速缓存。
Cache中存放了内存中的一部分数据CPU在访问内存时要先访问Cache若Cache中有需要的数据就直接从Cache中取出若没有则需要从内存中读取数据并同时把这块数据放入Cache中。但是由于程序的局部性原理在一段时间内CPU总是能从Cache中读取到自己想要的数据。
Cache可以集成在CPU内部也可以做成独立的芯片放在总线上现在x86 CPU和ARM CPU都是集成在CPU内部的。其逻辑结构如下图所示。
<img src="https://static001.geekbang.org/resource/image/47/56/474189597993406a01a2ae171b754756.jpg" alt="" title="Cache结构框架图">
Cache主要由高速的静态储存器、地址转换模块和Cache行替换模块组成。
Cache会把自己的高速静态储存器和内存分成大小相同的行一行大小通常为32字节或者64字节。Cache和内存交换数据的最小单位是一行为方便管理在Cache内部的高速储存器中多个行又会形成一组。
除了正常的数据空间外Cache行中还有一些标志位如脏位、回写位访问位等这些位会被Cache的替换模块所使用。
Cache大致的逻辑工作流程如下。
1.CPU发出的地址由Cache的地址转换模块分成3段组号行号行内偏移。
2.Cache会根据组号、行号查找高速静态储存器中对应的行。如果找到即命中用行内偏移读取并返回数据给CPU否则就分配一个新行并访问内存把内存中对应的数据加载到Cache行并返回给CPU。写入操作则比较直接分为回写和直通写回写是写入对应的Cache行就结束了直通写则是在写入Cache行的同时写入内存。
3.如果没有新行了就要进入行替换逻辑即找出一个Cache行写回内存腾出空间替换行有相关的算法**替换算法是为了让替换的代价最小化**。例如找出一个没有修改的Cache行这样就不用把它其中的数据回写到内存中了还有找出存在时间最久远的那个Cache行因为它大概率不会再访问了。
以上这些逻辑都由Cache硬件独立实现软件不用做任何工作对软件是透明的。
## Cache带来的问题
Cache虽然带来性能方面的提升但同时也给和硬件和软件开发带来了问题那就是数据一致性问题。
为了搞清楚这个问题我们必须先搞清楚Cache在硬件层面的结构下面我画了x86 CPU的Cache结构图
<img src="https://static001.geekbang.org/resource/image/97/bd/976f5cf91bc656e2a876235a5d2efabd.jpg" alt="" title="x86 CPU的Cache结构图">
这是一颗最简单的双核心CPU它有三级Cache第一级Cache是指令和数据分开的第二级Cache是独立于CPU核心的第三级Cache是所有CPU核心共享的。
下面来看看Cache的一致性问题主要包括这三个方面.
1.一个CPU核心中的指令Cache和数据Cache的一致性问题。<br>
2.多个CPU核心各自的2级Cache的一致性问题。<br>
3.CPU的3级Cache与设备内存如DMA、网卡帧储存显存之间的一致性问题。这里我们不需要关注这个问题。
我们先来看看CPU核心中的指令Cache和数据Cache的一致性问题对于程序代码运行而言指令都是经过指令Cache而指令中涉及到的数据则会经过数据Cache。
所以对自修改的代码即修改运行中代码指令数据变成新的程序而言比如我们修改了内存地址A这个位置的代码典型的情况是Java运行时编译器这个时候我们是通过储存的方式去写的地址A所以新的指令会进入数据Cache。
但是我们接下来去执行地址A处的指令的时候指令Cache里面可能命中的是修改之前的指令。所以这个时候软件需要把数据Cache中的数据写入到内存中然后让指令Cache无效重新加载内存中的数据。
再来看看多个CPU核心各自的2级Cache的一致性问题。从上图中可以发现两个CPU核心共享了一个3级Cache。比如第一个CPU核心读取了一个A地址处的变量第二个CPU也读取A地址处的变量那么第二个CPU核心是不是需要从内存里面经过第3、2、1级Cache再读一遍这个显然是没有必要的。
在硬件上Cache相关的控制单元可以把第一个CPU核心的A地址处Cache内容直接复制到第二个CPU的第2、1级Cache这样两个CPU核心都得到了A地址的数据。不过如果这时第一个CPU核心改写了A地址处的数据而第二个CPU核心的2级Cache里面还是原来的值数据显然就不一致了。
为了解决这些问题硬件工程师们开发了多种协议典型的多核心Cache数据同步协议有MESI和MOESI。MOESI和MESI大同小异下面我们就去研究一下MESI协议。
## Cache的MESI协议
MESI协议定义了4种基本状态M、E、S、I即修改Modified、独占Exclusive、共享Shared和无效Invalid。下面我结合示意图给你解释一下这四种状态。
1.M修改Modified当前Cache的内容有效数据已经被修改而且与内存中的数据不一致数据只在当前Cache里存在。比如说内存里面X=5而CPU核心1的Cache中X=2Cache与内存不一致CPU核心2中没有X。
<img src="https://static001.geekbang.org/resource/image/b1/61/b19d638e9a37290c1ea1feebce2d7e61.jpg" alt="" title="MESI协议-M">
1. E独占Exclusive当前Cache中的内容有效数据与内存中的数据一致数据只在当前Cache里存在类似RAM里面X=5同样CPU核心1的Cache中X=5Cache和内存中的数据一致CPU核心2中没有X。
<img src="https://static001.geekbang.org/resource/image/bb/e2/bb1fc473f93089a1414a4e01f888dae2.jpg" alt="" title="MESI协议-E">
1. S共享Shared当前Cache中的内容有效Cache中的数据与内存中的数据一致数据在多个CPU核心中的Cache里面存在。例如在CPU核心1、CPU核心2里面Cache中的X=5而内存中也是X=5保持一致。
<img src="https://static001.geekbang.org/resource/image/03/b0/030bd3e97c93bdef900abc0c6a72b9b0.jpg" alt="" title="MESI协议-S">
1. 无效Invalid当前Cache无效。前面三幅图Cache中没有数据的那些都属于这个情况。
最后还要说一下Cache硬件它会监控所有CPU上Cache的操作根据相应的操作使得Cache里的数据行在上面这些状态之间切换。Cache硬件通过这些状态的变化就能安全地控制各Cache间、各Cache与内存之间的数据一致性了。
这里不再深入探讨MESI协议了感兴趣的话你可以自行拓展学习。这里只是为了让你明白有了Cache虽然提升了系统性能却也带来了很多问题好在这些问题都由硬件自动完成对软件而言是透明的。
不过看似对软件透明这却是有代价的因为硬件需要耗费时间来处理这些问题。如果我们编程的时候不注意不能很好地规避这些问题就会引起硬件去维护大量的Cache数据同步这就会使程序运行的效能大大下降。
## 开启Cache
前面我们研究了大量的Cache底层细节和问题就是为了使用Cache目前Cache已经成为了现代计算机的标配但是x86 CPU上默认是关闭Cache的需要在CPU初始化时将其开启。
在x86 CPU上开启Cache非常简单只需要将CR0寄存器中CD、NW位同时清0即可。CD=1时表示Cache关闭NW=1时CPU不维护内存数据一致性。所以**CD=0、NW=0的组合**才是开启Cache的正确方法。
开启Cache只需要用四行汇编代码代码如下
```
mov eax, cr0
;开启 CACHE
btr eax,29 ;CR0.NW=0
btr eax,30 ;CR0.CD=0
mov cr0, eax
```
## 获取内存视图
作为系统软件开发人员,与其了解内存内部构造原理,不如了解系统内存有多大。这个作用更大。
根据前面课程所讲给出一个物理地址并不能准确地定位到内存空间内存空间只是映射物理地址空间中的一个子集物理地址空间中可能有空洞有ROM有内存有显存有IO寄存器所以获取内存有多大没用关键是**要获取哪些物理地址空间是可以读写的内存**。
物理地址空间是由北桥芯片控制管理的那我们是不是要找北桥要内存的地址空间呢当然不是在x86平台上还有更方便简单的办法那就是BIOS提供的实模式下中断服务就是int指令后面跟着一个常数的形式。
由于PC机上电后由BIOS执行硬件初始化中断向量表是BIOS设置的所以执行中断自然执行BIOS服务。这个中断服务是int 15h但是它需要一些参数就是在执行int 15h之前对特定寄存器设置一些值代码如下。
```
_getmemmap:
xor ebx,ebx ;ebx设为0
mov edi,E80MAP_ADR ;edi设为存放输出结果的1MB内的物理内存地址
loop:
mov eax,0e820h ;eax必须为0e820h
mov ecx,20 ;输出结果数据项的大小为20字节8字节内存基地址8字节内存长度4字节内存类型
mov edx,0534d4150h ;edx必须为0534d4150h
int 15h ;执行中断
jc error ;如果flags寄存器的C位置1则表示出错
add edi,20;更新下一次输出结果的地址
cmp ebx,0 ;如ebx为0则表示循环迭代结束
jne loop ;还有结果项,继续迭代
ret
error:;出错处理
```
上面的代码是在迭代中执行中断每次中断都输出一个20字节大小数据项最后会形成一个该数据项结构体的数组可以用C语言结构表示如下。
```
#define RAM_USABLE 1 //可用内存
#define RAM_RESERV 2 //保留内存不可使用
#define RAM_ACPIREC 3 //ACPI表相关的
#define RAM_ACPINVS 4 //ACPI NVS空间
#define RAM_AREACON 5 //包含坏内存
typedef struct s_e820{
u64_t saddr; /* 内存开始地址 */
u64_t lsize; /* 内存大小 */
u32_t type; /* 内存类型 */
}e820map_t;
```
## 重点回顾
又到了课程尾声内存和Cache的学习就告一段落了。今天我们主要讲了四部分内容局部性原理、内存结构特性、Cache工作原理和x86上的应用。我们一起来回顾一下这节课的重点。
首先从一个场景开始我们了解了程序通常的结构。通过观察这种结构我们发现CPU大多数时间在访问相同或者与此相邻的地址执行相同的指令或者与此相邻的指令。这种现象就是程序**局部性原理**。
然后我们研究了内存的结构和特性。了解它的工艺标准和内部原理知道内存容量相对可以做得较大程序和数据都要放在其中才能被CPU执行和处理。但是内存的速度却远远赶不上CPU的速度。
因为内存和CPU之间性能瓶颈和程序局部性原理所以才开发出了Cache即高速缓存它由高速静态储存器和相应的控制逻辑组成。
Cache容量比内存小速度却比内存高它在CPU和内存之间CPU访问内存首先会访问Cache如果访问命中则会大大提升性能然而它却带来了问题那就是**数据的一致性问题**为了解决这个问题工程师又开发了Cache**一致性协议MESI**。这个协议由Cache硬件执行对软件透明。
最后我们掌握了x86平台上开启Cache和获取物理内存视图的方法。
因为这节课也是我们硬件模块的最后一节,可以说**没有硬件平台知识,写操作系统就如同空中建楼**,通过这个部分的学习,就算是为写操作系统打好了地基。为了让你更系统地认识这个模块,我给你整理了这三节课的知识导图。
<img src="https://static001.geekbang.org/resource/image/c0/96/c0f2fe32b7b9753df692618cf5be2696.jpg" alt="">
## 思考题
请你思考一下如何写出让CPU跑得更快的代码由于Cache比内存快几个数量级所以这个问题也可以转换成如何写出提高Cache命中率的代码