This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 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我们下节课见