Post

一文看懂Linux

一文看懂Linux

Linux是基于UNIX的系统,UNIX是1969年由贝尔实验室开发的操作系统。

Linux操作系统概要

程序

在计算机系统运行时,在硬件设备上会重复执行以下步骤。

  1. 用户通过输入设备或网络适配器,直接或通过中间件(web服务器、数据库等)向OS发起请求。
  2. OS读取内存中的命令,并在CPU上执行,把结果写入负责保存数据的内存区域中。
  3. OS将内存中的数据写入外部存储器(HDD、SSD等),或者通过网络发送给其他计算机,或者通过输出设备提供给用户。
  4. 回到步骤1。

进程

通常情况下,程序在OS上以进程为单位运行。每个程序由一个或者多个进程构成。包括Linux在内的大部分OS能同时运行多个进程。

Linux与硬件设备的关系

调用外部设备是Linux的一个重要功能。如果没有Linux这样的OS,就不得不为每个进程单独编写调用设备的代码,开发成本高,当多个进程同时调用设备时,会引起各种预料之外的问题。

为了解决上述问题,Linux把设备调用处理整合成了一个叫作设备驱动程序的程序,使进程通过设备驱动程序访问设备。虽然世界上存在各种设备,但对于同一类型的设备,Linux 可以通过同一个接口进行调用。

在某个进程因为Bug或者程序员的恶意操作而违背了“通过设备驱动程序访问设备”这一规则的情况下,依然会出现多个进程同时调用设备的情况。为了避免这种情况,Linux设计了内核模式和用户模式两种模式,只有处于内核模式时才允许访问设备。让设备驱动程序在内核模式下运行,让进程在用户模式下运行,从而进程无法直接访问硬件设备。

内核

除此之外,还有许多不应被普通进程调用的处理程序,例如进程管理系统、进程调度器、内存管理系统等。这些程序也全都在内核模式下运行。把这些在内核模式下运行的OS的核心处理整合在一起的程序就叫作内核(kernel)。内核负责管理计算机系统上的CPU和内存等各种资源,然后把这些资源按需分配给在系统上运行的各个进程。如果进程想要使用设备驱动程序等由内核提供的功能,就需要通过被称为系统调用(system call)的特殊处理来向内核发出请求。

OS并不单指内核,它是由内核与许多在用户模式下运行的程序构成的。

存储系统

在进程运行的过程中,各种数据会以内存为中心,在CPU上的寄存器或外部存储器等各种存储器之间进行交换。这些存储器在容量、价格和访问速度等方面都有各自的优缺点,从而构成存储系统层次结构。从提高程序运行速度和稳定性方面来说,灵活有效地运用各种存储器是必不可少的一环。

层级容量范围访问时间管理方作用
寄存器几十字节接近0CPU硬件存储当前执行的指令和运算数据
L1~L3缓存KB-MB级1-数十个时钟周期CPU硬件缓存主存中的热点数据
主存GB级几十到百纳秒操作系统CPU与磁盘间的缓存,存储运行中的程序和数据
辅助存储(SSD/HDD)GB-TB级微秒级或毫秒级文件系统持久化存储操作系统、应用程序及用户数据
三级存储(磁带/云存储)PB级秒级或更长软件/网络长期备份和冷数据存储

此外,进程虽然可以通过底层的设备驱动程序访问外部存储器中的数据,但为了简化这一过程,一般会利用被称为文件系统的中间件进行访问。

对于OS来说,外部存储器是不可或缺的。

  1. 在启动OS时,需要通过BIOS(Basic input Output System)或UEFI(Unified Extensible Firmware Interface)等固件来初始化硬件设备,然后运行引导程序选择需要启动的OS,最后从外部存储器中读取相应的OS。
  2. 在关闭OS时,为了防止丢失系统运行期间在内存上创建的数据,必须在关闭电源前把这些数据写入外部存储器。

用户模式

OS 并非仅由内核构成,还包含许多在用户模式下运行的程序。这些程序有的以库的形式存在,有的作为单独的进程运行。在用户模式下运行的程序,既可以通过系统调用直接向内核发送请求,也可以采用函数调用的方式,通过第三方库和OS提供的库间接向内核发送请求。

系统调用

用户程序在执行进程创建、硬件操作等依赖于内核的处理时,必须通过系统调用向内核发起请求。系统调用的种类如下。

  1. 进程控制(创建和删除)
  2. 内存管理(分配和释放)
  3. 进程间通信
  4. 网络管理
  5. 文件系统操作
  6. 文件操作(访问设备)

与常规的函数调用不同,系统调用并不能被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 中,创建进程有如下两个目的。

  1. 将同一个程序分成多个进程进行处理(例如,使用 Web 服务器接收多个请求),通常使用fork函数
  2. 创建另一个程序(例如,从 bash 启动一个新的程序),通常使用execve函数

fork()函数

要想将同一个程序分成多个进程进行处理,需要使用 fork() 函数。在调用 fork() 函数后,就会基于发起调用的进程,创建一个新的进程。发出请求的进程称为父进程,新创建的进程称为子进程。

创建新进程的流程如下所示:

  1. 为子进程申请内存空间,并复制父进程的内存到子进程的内存空间。
  2. 父进程与子进程分裂成两个进程,以执行不同的代码。这一点的实现依赖于 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() 函数。我们来看一下内核在启动新程序时的流程。

  1. 读取可执行文件中创建新进程所需的数据。
  2. 将新进程的数据覆盖写入当前进程的内存中。
  3. 从最初的命令(入口点)开始运行新进程。

也就是说,在启动另一个程序时,并非新增一个进程,而是替换了当前进程。

可执行文件中不仅包含进程在运行过程中使用的代码与数据,还包含开始运行程序时所需的数据。例如:

  1. 包含代码的代码段在文件中的偏移量、大小,以及内存映像的起始地址
  2. 包含代码以外的变量等数据的数据段在文件中的偏移量、大小,以及内存映像的起始地址
  3. 程序执行的第一条指令的内存地址(入口点)

结束进程

可以使用 _exit() 函数(底层发起 exit_group() 系统调用)来结束进程。在进程运行结束后,所有分配给进程的内存将被回收。不过,通常我们很少会直接调用 _exit() 函数,而是通过调用 C 标准库中的 exit() 函数来结束进程的运行。在这种情况下,C 标准库会在调用完自身的终止处理后调用 _exit() 函数。在从 main() 函数中恢复时也是同样的方式。

进程调度器

Linux 内核具有进程调度器的功能,它使得多个进程能够同时运行(准确来说,是看起来在同时运行)。用户在使用Linux系统时通常是意识不到调度器的存在的。

调度器的基本工作原理:一个 CPU 核心同时只运行一个进程,在同时运行多个进程时,每个进程都会获得适当的时长 ,轮流在 CPU 核心上执行处理。

This post is licensed under CC BY 4.0 by the author.