Post

一文看懂Linux

一文看懂Linux

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

Linux操作系统概要

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

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

内核

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

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

设备子系统

鼠标和键盘是计算机的输入设备,显示器和扬声器是计算机的输出设备,分别由显卡和声卡进行控制。

调用外部硬件设备是 Linux 的一个重要功能。如果没有 Linux 这样的 OS,就不得不为每个进程单独编写调用设备的代码,开发成本高,当多个进程同时调用设备时,会引起各种预料之外的问题。为了解决上述问题,Linux 把设备调用处理整合成了一个叫作设备驱动程序的程序,使进程通过设备驱动程序访问设备。虽然世界上存在各种设备,但对于同一类型的设备,Linux 可以通过同一个接口进行调用。

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

文件管理子系统

电脑上的程序有很多,什么有道云笔记的程序、Word 程序等等,它们都以二进制文件的形式保存在硬盘上。硬盘是个物理设备,要按照规定格式化成为文件系统,才能存放这些程序。文件系统需要一个系统进行统一管理,称为文件管理子系统(File Management Subsystem)。

例如,当操作系统拿到 QQ 的二进制执行文件的时候,就可以运行这个文件了。QQ 的二进制文件是静态的,称为程序(Program),而运行起来的 QQ,是不断进行的,称为进程(Process)。

进程管理子系统

在操作系统中,进程的执行需要分配 CPU 进行执行,也就是按照程序里面的二进制代码一行一行地执行。为了管理进程,我们需要一个进程管理子系统(Process Management Subsystem)。如果运行的进程很多,则一个 CPU 会并发运行多个进程,也就需要 CPU 的调度能力了。

内存管理子系统

在操作系统中,不同的进程有不同的内存空间,但是整个电脑内存就这么点儿,所以需要统一的管理和分配,这就需要内存管理子系统(Memory Management Subsystem)。

在进程运行的过程中,各种数据会以内存为中心,在 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 时,为了防止丢失系统运行期间在内存上创建的数据,必须在关闭电源前把这些数据写入外部存储器。

初期配置

浏览文件

1
2
3
# ls -l
drwxr-xr-x 6 root root    4096 Oct 20  2017 apt
-rw-r--r-- 1 root root     211 Oct 20  2017 hosts
  • 第一个字段的第一个字符是文件类型。如果是-,表示普通文件;如果是d,就表示目录。
  • 第一个字段剩下的 9 个字符是权限位(access permission bits)。3 个一组,每一组rwx表示读(read)写(write)执行(execute)。如果是字母,就说明有这个权限;如果是横线,就是没有这个权限。
  • 这三组分别表示文件所属的用户权限、文件所属的组权限以及其他用户的权限。例如,上面的例子中,-rw-r–r--就可以翻译为,这是一个普通文件,对于所属用户,可读可写不能执行;对于所属的组,仅仅可读;对于其他用户,也是仅仅可读。如果想改变权限,可以使用命令chmod 711 hosts

第二个字段是硬链接(hard link)数目,第三个字段是所属用户,第四个字段是所属组。第五个字段是文件的大小,第六个字段是文件被修改的日期,最后是文件名。

可以通过命令chown改变所属用户,chgrp改变所属组,chmod更改权限。

安装程序

在 Linux 下安装程序有以下三种方法:

下载安装包进行安装

对于 Linux 来讲,可以下载 rpm 或者 deb 来安装程序。这个就是 Linux 下面的安装包,类似于 Windows 下的 exe 文件。

为什么有两种呢?因为 Linux 现在常用的有两大体系,一个是 CentOS 体系,一个是 Ubuntu 体系,前者使用 rpm,后者使用 deb。

在 Linux 上面,没有双击安装的说法,因此想要安装程序,我们还得需要命令。CentOS 下面使用pm -i jdk-XXX_linux-x64_bin.rpm进行安装,Ubuntu 下面使用dpkg -i jdk-XXX_linux-x64_bin.deb。其中 -i 就是 install 的意思。

在 Linux 下面,凭借rpm -qadpkg -l就可以查看安装的软件列表,-q 就是 query,a 就是 all,-l 的意思就是 list。

如果真的去运行的话,你会发现这个列表很长很长,很难找到你安装的软件。如果你知道要安装的软件包含某个关键词,可以用一个很好用的搜索工具 grep,即rpm -qa | grep jdk

如果你不知道关键词,可以使用rpm -qa | morerpm -qa | less这两个命令,它们可以将很长的结果分页展示出来,方便查找,more 是分页后只能往后翻页,翻到最后一页自动结束返回命令行,less 是往前往后都能翻页,需要输入 q 返回命令行。

如果要删除,可以用rpm -edpkg -r-e 就是 erase,-r 就是 remove。

通过软件管家进行安装

Linux 也有自己的软件管家,CentOS 下面是 yum,Ubuntu 下面是 apt-get。例如,对于上面的 JDK 程序,可以用yum install java-11-openjdk.x86_64apt-get install openjdk-9-jdk来进行安装。

安装以后想要卸载的话,我们可以使用yum erase java-11-openjdk.x86_64apt-get purge openjdk-9-jdk

Ubuntu 现在都用 apt 作为软件管家,apt = apt-get、apt-cache 和 apt-config 中最常用命令选项的集合。

此外,Linux 还允许我们配置从哪里下载这些软件,地点就在配置文件里面。对于 CentOS 来讲,配置文件在/etc/yum.repos.d/CentOS-Base.repo里。对于 Ubuntu 来讲,配置文件在/etc/apt/sources.list里。

下载压缩包进行安章

其实无论是先下载再安装,还是通过软件管家进行安装,都是下载一些文件,然后将这些文件放在某个路径下,然后在相应的配置文件中配置一下。例如,在 Windows 里面,最终会变成 C:\Program Files 下面的一个文件夹以及注册表里面的一些配置。对应 Linux 里面会放的更散一点。例如,主执行文件会放在 /usr/bin 或者 /usr/sbin 下面,其他的库文件会放在 /var 下面,配置文件会放在 /etc 下面。

所以其实还有一种简单粗暴的方法,就是将安装好的路径直接下载下来,然后解压缩成为一个整的路径。在 JDK 的安装目录中,Windows 有 jdk-XXX_Windows-x64_bin.zip,这是 Windows 下常用的压缩模式。Linux 有 jdk-XXX_linux-x64_bin.tar.gz,这是 Linux 下常用的压缩模式,从网上下载下来之后,通过 tar xvzf jdk-XXX_linux-x64_bin.tar.gz 就可以解压缩了。

对于 Windows 上 jdk 的安装,如果采取这种下载压缩包的格式,需要在系统设置的环境变量配置里面设置JAVA_HOMEPATH。在 Linux 也是一样的,通过 tar 解压缩之后,也需要配置环境变量,可以通过 export 命令来配置。

1
2
export JAVA_HOME=/root/jdk-XXX_linux-x64
export PATH=$JAVA_HOME/bin:$PATH

export 命令仅在当前命令行的会话中管用,一旦退出重新登录进来,就不管用了,如果希望一次配置永远管用,我们可以把上面的export命令保存在在当前用户的默认工作目录下面的.bashrc 文件中,这样每次登录的时候,这个文件就会自动执行。当然也可以通过 source .bashrc 手动执行。

运行程序

在Linux中,运行一个程序也有 3 种方式:

在命令行里交互运行

我们都知道 Windows 下的程序,如果后缀名是 exe,双击就可以运行了。

Linux 不是根据后缀名来执行的。它的执行条件是这样的:只要文件有 x 执行权限,都能到文件所在的目录下,通过在命令行中输入 ./filename 来运行这个程序。当然,如果放在 PATH 里设置的路径下面,就不用 ./ 了,直接输入文件名就可以运行了,Linux 会帮你找到该文件。

这样执行的程序可能需要和用户进行交互,例如允许让用户输入,然后输出结果也打印到交互命令行上。这种方式比较适合运行一些简单的命令,例如通过 date 获取当前时间。这种模式的缺点是,一旦当前的交互命令行退出,程序就停止运行了。

后台运行

如果我们想要运行一个博客程序,总不能老是开着交互命令行,博客才可以提供服务。一旦我们关了命令行,别人就不能访问博客了,这样肯定是不行的。

这个时候,我们往往使用 nohup 命令。这个命令的意思是 no hang up(不挂起),也就是说,当前交互命令行退出的时候,程序还要处于运行状态。

当然这个时候,程序不能霸占交互命令行,而是应该在后台运行。最后加一个 &,就表示后台运行。

另外一个要处理的就是输出,原来什么都打印在交互命令行里,现在在后台运行了,一般会把输出重定向到文件里。

最终命令的一般形式为:nohup command >out.file 2>&1 &

这里面,“1”表示文件描述符1,代表标准输出,“2”表示文件描述符2,代表标准错误输出,“2>&1”表示标准输出和错误输出合并了,合并到 out.file 里。

那这个进程如何关闭呢?我们假设启动的程序包含某个关键字 keyword,那就可以使用下面的命令。

1
ps -ef | grep keyword | awk '{print $2}' | xargs kill -9

其中 ps -ef 可以单独执行,列出所有正在运行的程序,也可以使用 ps auxawk '{print $2}' 是指第二列的内容,即运行的程序 PID。我们可以通过 xargs 把上一步的 PID 传递给 kill -9,也就是发给这个运行的程序一个信号,让它关闭。如果你已经知道运行的程序 ID,可以直接使用 kill 关闭运行的程序。

以服务的方式运行

在 Windows 里面还有一种程序,称为服务。这是系统启动的时候就在的,我们可以通过控制面板的服务管理启动和关闭它。Linux 也有相应的服务,例如常用的数据库 MySQL,就可以使用这种方式运行。

在 Ubuntu 中,我们可以通过 apt-get install mysql-server 的方式安装 MySQL,然后通过命令 systemctl start mysql 启动 MySQL,通过 systemctl enable mysql 设置开机启动。之所以成为服务并且能够开机启动,是因为在 /lib/systemd/system 目录下会创建一个 XXX.service 的配置文件,里面定义了如何启动、如何关闭。

系统调用

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

  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 系统调用非常多,访问 https://www.kernel.org 可以下载一份 Linux 内核源代码,对于 64 位操作系统,找到 unistd_64.h 文件,可以看到里面对于系统调用的定义。

进程控制

在 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() 函数后,两个进程执行的代码也不同了。

有时候,父进程要关心子进程的运行情况,这时候可以使用系统调用 waitpid,将子进程的进程号作为参数传给父进程,这样父进程就知道子进程运行完了没有,成功与否。

execve() 函数

想要启动另一个程序时,需要调用 execve() 函数。我们来看一下内核在启动新程序时的流程。

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

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

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

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

_exit() 函数

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

内存管理

在操作系统中,每个进程都有自己的内存,互相之间不干扰,有独立的进程内存空间。对于进程的内存空间来讲,放程序代码的这部分,我们称为代码段(Code Segment)。放进程运行中产生数据的这部分,我们称为数据段(Data Segment)。其中局部变量的部分,在当前函数执行的时候起作用,当进入另一个函数时,这个变量就释放了;也有动态分配的,会较长时间保存,指明才销毁的,这部分称为堆(Heap)。

一个进程的内存空间是很大的,32 位的是 4G,64 位的就更大了,我们不可能有这么多物理内存。所以,进程自己不用的部分就不用管,只有进程要去使用部分内存的时候,才会使用内存管理的系统调用来登记,说自己马上就要用了,希望分配一部分内存给它,但是这还不代表真的就对应到了物理内存。只有真的写入数据的时候,发现没有对应物理内存,才会触发一个中断,现分配物理内存。

  • 当分配的内存数量比较小的时候,使用系统调用 brk,分配的内存会和原来的堆的数据连在一起,这就像多分配两三个工位,在原来的区域旁边搬两把椅子就行了。
  • 当分配的内存数量比较大的时候,使用系统调用 mmap,会重新划分一块区域,也就是说,当办公空间需要太多的时候,索性来个一整块。

文件管理

对于文件的操作,下面这六个系统调用是最重要的:

  • 对于已经有的文件,可以使用open打开这个文件,close关闭这个文件;
  • 对于没有的文件,可以使用creat创建文件;
  • 打开文件以后,可以使用lseek跳到文件的某个位置;可以对文件的内容进行读写,读的系统调用是read,写是write。

此外,Linux 还有一个重要特点,那就是一切皆文件

  1. 启动一个进程,需要一个程序文件,这是一个二进制文件
  2. 启动的时候,要加载一些配置文件,例如 yml、properties 等,这是文本文件;启动之后会打印一些日志,如果写到硬盘上,也是文本文件。
  3. 如果想把日志打印到交互控制台上,这其实也是一个文件,是标准输出 stdout 文件
  4. 这个进程的输出可以作为另一个进程的输入,这种方式称为管道,管道也是一个文件。
  5. 进程可以通过网络和其他进程进行通信,建立的 Socket 也是一个文件。
  6. 进程需要访问外部设备,设备也是一个文件。
  7. 文件都被存储在文件夹里面,其实文件夹也是一个文件。
  8. 进程运行起来,要想看到进程运行的情况,会在 /proc 下面有对应的进程号,还是一系列文件。

每个文件,Linux 都会分配一个文件描述符(File Descriptor),这是一个整数。有了这个文件描述符,我们就可以使用系统调用,查看或者干预进程运行的方方面面。所以说,文件操作是贯穿始终的,这也是“一切皆文件”的优势,就是统一了操作的入口,提供了极大的便利。

异常处理与信号处理

当遇到异常情况,例如中断,做到一半不做了。这时候就需要发送一个信号(Signal)给进程。经常遇到的信号有以下几种:

  • 在执行一个程序的时候,在键盘输入“CTRL+C”,这就是中断的信号,正在执行的命令就会中止退出;
  • 非法访问内存;
  • 硬件故障,设备出了问题,需要通知进程;
  • 用户进程通过kill函数,将一个用户信号发送给另一个进程。

当进程收到信号的时候,需要决定如何处理这些异常情况。

对于一些不严重的信号,可以忽略,该干啥干啥,但是像 SIGKILL(用于终止一个进程的信号)和 SIGSTOP(用于中止一个进程的信号)是不能忽略的,可以执行对于该信号的默认动作。每种信号都定义了默认的动作,例如硬件故障,默认终止;也可以提供信号处理函数,可以通过 sigaction 系统调用,注册一个信号处理函数。提供了信号处理服务,进程执行过程中一旦有变动,就可以及时处理了。

进程间通信

当某个程序比较大的时候,可能分成多个进程,不同的进程需要相互交流、相互配合,这就需要一个进程之间的沟通机制。

首先就是发个消息,不需要一段很长的数据,这种方式称为消息队列(Message Queue)。由于一台 Linux 主机的多个进程沟通时,这个消息队列是在内核里的,我们可以通过 msgget 创建一个新的队列,msgsnd 将消息发送到消息队列,而消息接收方可以使用 msgrcv 从队列中取消息。

当两个进程需要交互的信息比较大的时候,可以使用共享内存的方式,即两个进程共享一个内存空间(这样数据就不需要拷贝来拷贝去)。我们可以通过 shmget 创建一个共享内存块,通过 shmat 将共享内存映射到自己的内存空间,然后就可以读写了。但是,两个进程共同访问一个内存空间里的数据,就会存在“竞争”的问题。如果大家同时修改同一块数据怎么办?这就需要有一种方式,让不同的人能够排他地访问,这就是信号量的机制 Semaphore。

以一种简单的场景为例,对于只允许一个人访问的需求,我们可以将信号量设为 1。当一个人要访问的时候,先调用 sem_wait。如果这时候没有人访问,则占用这个信号量,他就可以开始访问了。如果这个时候另一个人要访问,也会调用 sem_wait。由于前一个人已经在访问了,所以后面这个人就必须等待上一个人访问完之后才能访问。当上一个人访问完毕后,会调用 sem_post 将信号量释放,于是下一个人等待结束,可以访问这个资源了。

主机间网络通信

不同机器的通过网络相互通信,要遵循相同的网络协议,也即 TCP/IP 网络协议栈。Linux 内核里有对于网络协议栈的实现。

网络服务是通过套接字 Socket 来提供服务的。Socket 这个名字很有意思,可以作“插口”或者“插槽”讲。虽然我们是写软件程序,但是你可以想象成弄一根网线,一头插在客户端,一头插在服务端,然后进行通信。因此,在通信之前,双方都要建立一个 Socket。 我们可以通过 Socket 系统调用建立一个 Socket。Socket 也是一个文件,也有一个文件描述符,也可以通过读写函数进行通信。

中介与 Glibc

在 Linux 下做软件开发时,你会觉得刚才讲的和平时调用的函数不太一样。这是因为,平时你并没有直接使用系统调用,而是通过中介 Glibc 来自动转换成为系统调用。

Glibc 是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库。Glibc 为程序员提供丰富的 API,除了例如字符串处理、数学运算等用户态服务之外,最重要的是封装了操作系统提供的系统服务,即系统调用的封装。

每个特定的系统调用对应了至少一个 Glibc 封装的库函数,比如说,系统提供的打开文件系统调用 sys_open 对应的是 Glibc 中的 open 函数。

有时候,Glibc 一个单独的 API 可能调用多个系统调用,比如说,Glibc 提供的 printf 函数就会调用如 sys_open、sys_mmap、sys_write、sys_close 等等系统调用。

也有时候,多个 API 也可能只对应同一个系统调用,如 Glibc 下实现的 malloc、calloc、free 等函数用来分配和释放内存,都利用了内核的 sys_brk 的系统调用。

x86系统架构

对于一个计算机来讲,最核心的就是 CPU,这是这台计算机的大脑,所有的设备都围绕它展开。

CPU 和其他设备连接,要靠一种叫作总线(Bus)的东西,其实就是主板上密密麻麻的集成电路,这些东西组成了 CPU 和其他设备的高速通道。

在这些设备中,最重要的是内存(Memory)。因为单靠 CPU 是没办法完成计算任务的,很多复杂的计算任务都需要将中间结果保存下来,然后基于中间结果进行进一步的计算。CPU 本身没办法保存这么多中间结果,这就要依赖内存了。

当然总线上还有一些其他设备,例如显卡会连接显示器、磁盘控制器会连接硬盘、USB 控制器会连接键盘和鼠标等等。

CPU 其实也不是单纯的一块,它包括三个部分,运算单元、数据单元和控制单元。

  1. 运算单元只管运算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。
  2. 运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了数据单元。数据单元包括 CPU 内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。
  3. 有了放数据的地方,也有了运算的地方,还需要有个指挥到底做什么运算的地方,这就是控制单元。控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。

程序运行的过程中要操作的数据和产生的计算结果,都会放在属于进程的独立内存空间的数据段里面。那 CPU 怎么执行这些程序,操作这些数据,产生一些结果,并写入回内存呢?

CPU 的控制单元里面,有一个指令指针寄存器,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。

当前的指令分两部分,一部分是做什么操作,例如是加法还是位移;一部分是操作哪些数据。要执行这条指令,就要把第一部分交给运算单元,第二部分交给数据单元。数据单元根据数据的地址,从数据段里读到数据寄存器里,就可以参与运算了。运算单元做完运算,产生的结果会暂存在数据单元的数据寄存器里。最终,会有指令将数据写回内存中的数据段。

你可能会问,上面算来算去执行的都是进程 A 里的指令,那进程 B 呢?CPU 里有两个寄存器,专门保存当前处理进程的代码段的起始地址,以及数据段的起始地址。这里面写的都是进程 A,那当前执行的就是进程 A 的指令,等切换成进程 B,就会执行 B 的指令了,这个过程叫作进程切换(Process Switch)。

CPU 和内存来来回回传数据,靠的都是总线。其实总线上主要有两类数据,一个是地址数据,也就是我想拿内存中哪个位置的数据,这类总线叫地址总线(Address Bus);另一类是真正的数据,这类总线叫数据总线(Data Bus)。

所以说,总线其实有点像连接 CPU 和内存这两个设备的高速公路,总线是多少位,就类似高速公路有几个车道。但是这两种总线的位数意义是不同的。

  • 地址总线的位数,决定了能访问的地址范围到底有多广。例如只有两位,那 CPU 就只能认 00,01,10,11 四个位置,超过四个位置,就区分不出来了。位数越多,能够访问的位置就越多,能管理的内存的范围也就越广。
  • 数据总线的位数,决定了一次能拿多少个数据进来。例如只有两位,那 CPU 一次只能从内存拿两位数。要想拿八位,就要拿四次。位数越多,一次拿的数据就越多,访问速度也就越快。

x86 芯片架构

x86 架构起源于英特尔的 8086 系列 CPU 芯片,因其早期的广泛应用和标准、开放、兼容的特点,成为 CPU 芯片的经典架构。

数据单元

为了暂存数据,8086 处理器内部有 8 个 16 位的通用寄存器,也就是刚才说的 CPU 内部的数据单元,分别是 AX、BX、CX、DX、SP、BP、SI、DI。这些寄存器主要用于在计算过程中暂存数据。

这些寄存器比较灵活,其中 AX、BX、CX、DX 又可以分成两个 8 位的寄存器来使用,分别是 AH、AL、BH、BL、CH、CL、DH、DL,其中 H 就是 High(高位),L 就是 Low(低位)的意思。

控制单元

IP 寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置。CPU 会根据它来不断地将指令从内存的代码段中,加载到 CPU 的指令队列中,然后交给运算单元去执行。

每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个 16 位的段寄存器,分别是 CS、DS、SS、ES。其中,

  • CS 就是代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;
  • DS 是数据段的寄存器,通过它可以找到数据在内存中的位置。
  • SS 是栈寄存器(Stack Register)。栈是程序运行中一个特殊的数据结构,数据的存取只能从一端进行,秉承后进先出的原则,push 就是入栈,pop 就是出栈。

如果运算中需要加载内存中的数据,需要通过 DS 找到内存中的数据,加载到通用寄存器中:对于一个段,有一个起始的地址,而段内的具体位置,我们称为偏移量(Offset)。在 CS 和 DS 中都存放着一个段的起始地址。代码段的偏移量在 IP 寄存器中,数据段的偏移量会放在通用寄存器中。

这时候问题来了,CS 和 DS 都是 16 位的,也就是说,起始地址都是 16 位的,IP 寄存器和通用寄存器都是 16 位的,即偏移量也是 16 位的,但是 8086 的地址总线是 20 位。为了凑够这 20 位,需要“起始地址 * 16 + 偏移量”,也就是把 CS 和 DS 中的值左移 4 位,变成 20 位的,加上 16 位的偏移量,这样就可以得到最终 20 位的数据地址。

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