一文看懂Linux
Linux是基于UNIX的系统,UNIX是1969年由贝尔实验室开发的操作系统。
Linux操作系统概要
程序
在计算机系统运行时,在硬件设备上会重复执行以下步骤。
- 用户通过输入设备或网络适配器,直接或通过中间件(web服务器、数据库等)向OS发起请求。
- OS读取内存中的命令,并在CPU上执行,把结果写入负责保存数据的内存区域中。
- OS将内存中的数据写入外部存储器(HDD、SSD等),或者通过网络发送给其他计算机,或者通过输出设备提供给用户。
- 回到步骤1。
进程
通常情况下,程序在OS上以进程为单位运行。每个程序由一个或者多个进程构成。包括Linux在内的大部分OS能同时运行多个进程。
Linux与硬件设备的关系
调用外部设备是Linux的一个重要功能。如果没有Linux这样的OS,就不得不为每个进程单独编写调用设备的代码,开发成本高,当多个进程同时调用设备时,会引起各种预料之外的问题。
为了解决上述问题,Linux把设备调用处理整合成了一个叫作设备驱动程序的程序,使进程通过设备驱动程序访问设备。虽然世界上存在各种设备,但对于同一类型的设备,Linux 可以通过同一个接口进行调用。
在某个进程因为Bug或者程序员的恶意操作而违背了“通过设备驱动程序访问设备”这一规则的情况下,依然会出现多个进程同时调用设备的情况。为了避免这种情况,Linux设计了内核模式和用户模式两种模式,只有处于内核模式时才允许访问设备。让设备驱动程序在内核模式下运行,让进程在用户模式下运行,从而进程无法直接访问硬件设备。
内核
除此之外,还有许多不应被普通进程调用的处理程序,例如进程管理系统、进程调度器、内存管理系统等。这些程序也全都在内核模式下运行。把这些在内核模式下运行的OS的核心处理整合在一起的程序就叫作内核(kernel)。内核负责管理计算机系统上的CPU和内存等各种资源,然后把这些资源按需分配给在系统上运行的各个进程。如果进程想要使用设备驱动程序等由内核提供的功能,就需要通过被称为系统调用(system call)的特殊处理来向内核发出请求。
OS并不单指内核,它是由内核与许多在用户模式下运行的程序构成的。
存储系统
在进程运行的过程中,各种数据会以内存为中心,在CPU上的寄存器或外部存储器等各种存储器之间进行交换。这些存储器在容量、价格和访问速度等方面都有各自的优缺点,从而构成存储系统层次结构。从提高程序运行速度和稳定性方面来说,灵活有效地运用各种存储器是必不可少的一环。
层级 | 容量范围 | 访问时间 | 管理方 | 作用 |
---|---|---|---|---|
寄存器 | 几十字节 | 接近0 | CPU硬件 | 存储当前执行的指令和运算数据 |
L1~L3缓存 | KB-MB级 | 1-数十个时钟周期 | CPU硬件 | 缓存主存中的热点数据 |
主存 | GB级 | 几十到百纳秒 | 操作系统 | CPU与磁盘间的缓存,存储运行中的程序和数据 |
辅助存储(SSD/HDD) | GB-TB级 | 微秒级或毫秒级 | 文件系统 | 持久化存储操作系统、应用程序及用户数据 |
三级存储(磁带/云存储) | PB级 | 秒级或更长 | 软件/网络 | 长期备份和冷数据存储 |
此外,进程虽然可以通过底层的设备驱动程序访问外部存储器中的数据,但为了简化这一过程,一般会利用被称为文件系统的中间件进行访问。
对于OS来说,外部存储器是不可或缺的。
- 在启动OS时,需要通过BIOS(Basic input Output System)或UEFI(Unified Extensible Firmware Interface)等固件来初始化硬件设备,然后运行引导程序选择需要启动的OS,最后从外部存储器中读取相应的OS。
- 在关闭OS时,为了防止丢失系统运行期间在内存上创建的数据,必须在关闭电源前把这些数据写入外部存储器。
用户模式
OS 并非仅由内核构成,还包含许多在用户模式下运行的程序。这些程序有的以库的形式存在,有的作为单独的进程运行。在用户模式下运行的程序,既可以通过系统调用直接向内核发送请求,也可以采用函数调用的方式,通过第三方库和OS提供的库间接向内核发送请求。
系统调用
用户程序在执行进程创建、硬件操作等依赖于内核的处理时,必须通过系统调用向内核发起请求。系统调用的种类如下。
- 进程控制(创建和删除)
- 内存管理(分配和释放)
- 进程间通信
- 网络管理
- 文件系统操作
- 文件操作(访问设备)
与常规的函数调用不同,系统调用并不能被C语言之类的高级编程语言直接发起,只能通过与系统架构紧密相连的汇编语言代码来发起。为了提高开发效率,OS提供了一系列被称为系统调用的包装函数的函数,用于在系统内部发起系统调用。各种架构上都存在着对应的包装函数。因此,使用高级编程语言编写的用户程序,只需调用由高级编程语言提供的包装函数即可。
通常进程运行在用户模式下,当通过系统调用向内核发送请求时,CPU中断在用户模式下的进程,进入内核模式响应系统调用请求,当CPU处理完所有系统调用请求后,将重新回到用户模式,继续运行之前中断的进程。
通过strace可以查看程序在执行过程中发生了哪些系统调用:
1
2
3
4
5
strace -o hello_c.log ./hello
cat hello_c.log
strace -o hello_py.log python3 ./hello.py
cat hello_py.log
通过执行上面的命令,可以发现不管是C语言还是Python编写的hello world程序,都会请求负责向画面或文件等输出数据的write()系统调用。但是C语言编写的程序运行时总共发起了31个系统调用,而Python则发起了705个系统调用(log中的每一行代表一个系统调用,行数则为系统调用的总次数),这也解释了为什么Python程序的效率不如C程序高。
进程管理
在 Linux 中,创建进程有如下两个目的。
- 将同一个程序分成多个进程进行处理(例如,使用 Web 服务器接收多个请求),通常使用fork函数
- 创建另一个程序(例如,从 bash 启动一个新的程序),通常使用execve函数
fork()函数
要想将同一个程序分成多个进程进行处理,需要使用 fork() 函数。在调用 fork() 函数后,就会基于发起调用的进程,创建一个新的进程。发出请求的进程称为父进程,新创建的进程称为子进程。
创建新进程的流程如下所示:
- 为子进程申请内存空间,并复制父进程的内存到子进程的内存空间。
- 父进程与子进程分裂成两个进程,以执行不同的代码。这一点的实现依赖于 fork() 函数分别返回不同的值给父进程与子进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <err.h>
static void child() {
printf("I'm child! my pid is %d.\n", getpid());
exit(EXIT_SUCCESS);
}
static void parent(pid_t pid_c) {
printf("I'm parent! my pid is %d and the pid of my child is %d.\n", getpid(), pid_c);
exit(EXIT_SUCCESS);
}
int main(void){
pid_t test;
test = fork();
if(test == -1)
err(EXIT_FAILURE, "fork() failed");
if(test == 0) {
//fork()会返回0给子进程,因此这里调用child()进入子进程
child();
} else {
//fork()会返回新创建的子进程的进程ID给父进程,因此这里调用parent()进入父进程
parent(test);
}
// 在正常运行时,不可能运行到这里
err(EXIT_FAILURE, "shouldn't reach here");}
// output:
// I'm parent! my pid is 96182 and the pid of my child is 96183.
// I'm child! my pid is 96183.
从上面的运行结果可以看出,进程 ID 为 96182的父进程通过fork函数创建了一个进程 ID 为 96183 的新进程。同时也能看到,在调用 fork() 函数后,两个进程执行的代码也不同了。
execve() 函数
想要启动另一个程序时,需要调用 execve() 函数。我们来看一下内核在启动新程序时的流程。
- 读取可执行文件中创建新进程所需的数据。
- 将新进程的数据覆盖写入当前进程的内存中。
- 从最初的命令(入口点)开始运行新进程。
也就是说,在启动另一个程序时,并非新增一个进程,而是替换了当前进程。
可执行文件中不仅包含进程在运行过程中使用的代码与数据,还包含开始运行程序时所需的数据。例如:
- 包含代码的代码段在文件中的偏移量、大小,以及内存映像的起始地址
- 包含代码以外的变量等数据的数据段在文件中的偏移量、大小,以及内存映像的起始地址
- 程序执行的第一条指令的内存地址(入口点)
结束进程
可以使用 _exit() 函数(底层发起 exit_group() 系统调用)来结束进程。在进程运行结束后,所有分配给进程的内存将被回收。不过,通常我们很少会直接调用 _exit() 函数,而是通过调用 C 标准库中的 exit() 函数来结束进程的运行。在这种情况下,C 标准库会在调用完自身的终止处理后调用 _exit() 函数。在从 main() 函数中恢复时也是同样的方式。
进程调度器
Linux 内核具有进程调度器的功能,它使得多个进程能够同时运行(准确来说,是看起来在同时运行)。用户在使用Linux系统时通常是意识不到调度器的存在的。
调度器的基本工作原理:一个 CPU 核心同时只运行一个进程,在同时运行多个进程时,每个进程都会获得适当的时长 ,轮流在 CPU 核心上执行处理。