mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
128
极客时间专栏/操作系统实战45讲/尝尝鲜:从一个Hello到另一个Hello/01 | 程序的运行过程:从代码到机器运行.md
Normal file
128
极客时间专栏/操作系统实战45讲/尝尝鲜:从一个Hello到另一个Hello/01 | 程序的运行过程:从代码到机器运行.md
Normal 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 System(UNICS)——即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 "stdio.h"
|
||||
int main(int argc, char const *argv[])
|
||||
{
|
||||
printf("Hello World!\n");
|
||||
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指令在逻辑上执行的操作是怎样的呢?
|
||||
|
||||
期待你在留言区跟我交流互动。如果这节课对你有所启发,也欢迎转发给你的朋友、同事,跟他们一起学习进步。
|
||||
@@ -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 "vgastr.h"
|
||||
void main()
|
||||
{
|
||||
printf("Hello OS!");
|
||||
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 $@ $<
|
||||
|
||||
```
|
||||
|
||||
我来解释一下这个例子:
|
||||
|
||||
make规定“#”后面为注释,make处理makefile时会自动丢弃。
|
||||
|
||||
makefile中可以定义宏,方法是**在一个字符串后跟一个“=”或者“:=”符号**,引用宏时要用“$(宏名)”,宏最终会在宏出现的地方替换成相应的字符串,例如:$(CC)会被替换成gcc,$( OBJS_FILE) 会被替换成file.c file1.c file2.c file3.c file4.c。
|
||||
|
||||
.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的编译过程,如下所示:
|
||||
|
||||
<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,我们下节课见!
|
||||
101
极客时间专栏/操作系统实战45讲/开篇词/开篇词 | 为什么要学写一个操作系统?.md
Normal file
101
极客时间专栏/操作系统实战45讲/开篇词/开篇词 | 为什么要学写一个操作系统?.md
Normal 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。因为一个人从零开始,独立开发操作系统这种行为有点疯狂,我索性就用LMOS(liberty,madness,operating,system)来命名了我的操作系统。
|
||||
|
||||
经过我这几年的独立开发,现在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="">
|
||||
|
||||
现在让我们一起带着好奇,带着梦想,向星辰大海进发!
|
||||
201
极客时间专栏/操作系统实战45讲/心有蓝图:设计/03 | 黑盒之中有什么:内核结构与设计.md
Normal file
201
极客时间专栏/操作系统实战45讲/心有蓝图:设计/03 | 黑盒之中有什么:内核结构与设计.md
Normal 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管理组件、内存管理组件、文件系统组件、进程管理组件、图形系统组件、网络组件、安全组件的通用功能型代码;内核硬件层则完成其内核组件对应的具体硬件平台相关的代码。
|
||||
|
||||
## 思考题
|
||||
|
||||
其实我们的内核架构不是我们首创的,它是属于微内核、宏内核之外的第三种架构,请问这是什么架构?
|
||||
|
||||
欢迎你在留言区跟我交流互动。如果这节课对你有启发,也欢迎分享给你的朋友或同事。
|
||||
147
极客时间专栏/操作系统实战45讲/心有蓝图:设计/04 | 震撼的Linux全景图:业界成熟的内核架构长什么样?.md
Normal file
147
极客时间专栏/操作系统实战45讲/心有蓝图:设计/04 | 震撼的Linux全景图:业界成熟的内核架构长什么样?.md
Normal 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也不是什么神明,现有的Linux,99.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内核
|
||||
|
||||
我们先来看看Darwin,Darwin是由苹果公司在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 X(2011年之前的称呼)的发展经过了不同时期,随着时代的进步,产品功能需求增加,单纯的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++运行环境。
|
||||
|
||||
依赖这个库的还有IOKit,IOKit管理所有的设备驱动和内核功能扩展模块。驱动程序开发人员则可以使用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,让我们下节课见。
|
||||
410
极客时间专栏/操作系统实战45讲/程序的基石:硬件/05 | CPU工作模式:执行程序的三种模式.md
Normal file
410
极客时间专栏/操作系统实战45讲/程序的基石:硬件/05 | CPU工作模式:执行程序的三种模式.md
Normal 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设置参数09H,AH是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=1,D/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
|
||||
|
||||
```
|
||||
|
||||
上面代码中注释已经很清楚了,段长度和段基址都是无效的填充为0,CPU不做检查。但是上面段描述符的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页表,这个是为了开启分页模式,**切换到长模式必须要开启分页**,想想看,长模式下已经不对段基址和段长度进行检查了,那么内存地址空间就得不到保护了。
|
||||
|
||||
而长模式下内存地址空间的保护交给了MMU,MMU依赖页表对地址进行转换,页表有特定的格式存放在内存中,其地址由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工作模式感兴趣的朋友,也欢迎把这节课的内容转发给他,我们一起学习进步。
|
||||
266
极客时间专栏/操作系统实战45讲/程序的基石:硬件/06 | 虚幻与真实:程序中的地址如何转换?.md
Normal file
266
极客时间专栏/操作系统实战45讲/程序的基石:硬件/06 | 虚幻与真实:程序中的地址如何转换?.md
Normal 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 <_init>:
|
||||
4e8: 48 83 ec 08 sub $0x8,%rsp
|
||||
4ec: 48 8b 05 f5 0a 20 00 mov 0x200af5(%rip),%rax # 200fe8 <__gmon_start__>
|
||||
4f3: 48 85 c0 test %rax,%rax
|
||||
4f6: 74 02 je 4fa <_init+0x12>
|
||||
4f8: ff d0 callq *%rax
|
||||
4fa: 48 83 c4 08 add $0x8,%rsp
|
||||
4fe: c3 retq
|
||||
|
||||
```
|
||||
|
||||
上述代码中,左边第一列数据就是虚拟地址,第三列中是程序指令,如:“mov 0x200af5(%rip),%rax,je 4fa,callq *%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地址转换失败了怎么办呢?失败了既不能放行,也不是reset,MMU执行的操作如下。
|
||||
|
||||
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,我们下节课见!
|
||||
235
极客时间专栏/操作系统实战45讲/程序的基石:硬件/07 | Cache与内存:程序放在哪儿?.md
Normal file
235
极客时间专栏/操作系统实战45讲/程序的基石:硬件/07 | Cache与内存:程序放在哪儿?.md
Normal 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 <stdio.h>
|
||||
int main(){
|
||||
int i,j;
|
||||
for(i=1;i<=9;i++){
|
||||
for(j=1;j<=i;j++){
|
||||
printf("%d*%d=%2d ",i,j,i*j);
|
||||
}
|
||||
printf("\n");
|
||||
}
|
||||
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=2,Cache与内存不一致,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=5(Cache和内存中的数据一致),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命中率的代码?
|
||||
Reference in New Issue
Block a user