如果说计算机科学的三大浪漫是操作系统、编译原理和计算机图形学的话,谈及操作系统,Linux 就一定是那个程序员对它又爱又恨的存在。本文带大家了解 Linux 中的基本概念和原理,正所谓知其然也要知其所以然。
更新历史
Linux 的出现其实是一位大学生的心血来潮,Linus Torvalds(就是 Linux 之父)不满意当年学习操作系统时所使用的 Minix 系统,在其代码的基础上参考 Unix 的设计,写出了第一版 Linux 内核。之后 Linus 开源了代码,随着网络时代的大幕逐渐揭开,Linux 和 GNU 金风玉露一相逢,在开源协议下迅速发展成熟。
正所谓『不懂 Unix 的人注定最终还要重复发明一个蹩脚的 Unix』,通过学习 Linux 来掌握 Linux/Unix 的核心思想其实是非常有意义的。作为诸多天才的智慧结晶,能够从中偷师一星半点,也能受益匪浅。(新闻插播:2016.12.03 Solaris 操作系统将终止开发)
准确来说,Linux 其实只是一个内核,负责管理硬件和为上层应用提供接口。不过随着 Linux 概念的不断外延,现在提到 Linux,更多是指以 Linux 内核为基础配上各种应用的 Linux 发行版本(比如 Ubuntu, Debian 等等)。这个系列的文章不会过多着眼于各个发行版,而是专注于 Linux 内核和系统基本概念本身,比如操作系统中重要的抽象:文件系统、输入输出操作、进程、线程和进程间通信。
因为 Linux 开源的特性,各种不同的发行版层出不穷,感兴趣的同学可以在 维基百科 - Linux 发行版列表 这个条目中看到各式各样的发行版及简介,也可以在 distrowatch.com 查看更加详细的排名,这之中比较流行的发行版有:
因为手头上只有基于 Ubuntu 的虚拟机(包括 Win10 中自带的 Linux),所以接下来的示例都是基于 Ubuntu 14.04 LTS 的。
虽然现在我们使用的云主机基本都已经预装好了 Linux,也不需要自己去操心开机,但是操作系统毕竟不是凭空出现的,了解从按下开机键到操作系统启动之间的过程有助于我们深入理解计算机系统。整个过程的步骤如下:
现在我们有了一个可以运行的 Linux 操作系统,具体它是怎么工作的呢?这就要从架构说起了。
最底层是硬件,硬件之上是内核,前面说内核负责管理所有的硬件资源的意思是,所有的计算机操作都需要通过内核传递给硬件。如果接触过硬件的同学一定知道,硬件本身是颇为复杂的,即使有了内核代为管理,仍旧非常繁琐,所以在内核之上我们有了系统调用。我们不需要了解内核和硬件的细节,就可以通过系统调用来操作它们,系统调用是操作系统的最小组成单位,也就是说,计算机能做的所有操作,最终能且仅能分解成已有的系统调用。
我们可以看到,内核实际上是硬件的抽象,而系统调用是内核的抽象,在这之上的 shell 和 library 甚至应用程序其实是更高层次的抽象,正是通过这样一层一层的抽象,计算机才得以发展成为如今这么庞大却简洁的系统。
我们在命令行中输入 man 2 syscalls
就可以浏览系统调用的说明了,顺着列表往下滑,就可以看到一些我们常常使用的命令了,比如 chmod
, fork
, kill
等等。反应快的同学应该已经意识到了,这些命令不就是我们在 shell 中常常使用的嘛,原来它们就是系统调用!
现在最常用的 shell 叫做 bash,其他诸如 zsh, fish 等也各有各的拥趸。这里要具体说一下 shell 和终端(Terminal)的不同,在大型机时代,终端是一个硬件设备,用来进行输入输出,而随着计算机硬件的发展,终端已经慢慢从实体变成了一个概念。我们打开 Gnome Terminal 的 About 页面,就可以发现下面的介绍是这样写的:
注意这个说法 “A terminal emulator for the GNOME desktop”,什么是 emulator 呢?中文翻译叫做仿真器,等于是说,这个程序是一个仿真终端的程序。与 emulator 相关的一个非常容易混淆的概念是 simulator(模拟器),他们的差别在于:
我们找一个 Mac OS 上最流行的终端的介绍来看看,同样会发现,这是一个仿真器:
所以可以这样理解,现代计算中的终端是一个用软件仿真的终端,我们在这上面输入输出的命令会传给具体执行这些命令的 shell 程序,再由 shell 程序执行对应的系统调用。重要的事情说三遍:终端不是 shell,终端不是 shell,终端不是 shell。
因为系统调用是操作系统的最小功能单位,所以一般来说提供的功能是非常零碎的,我们完成一个操作一般需要多个系统调用进行配合,于是 Linux 定义了一些 library(库),将常见的系统调用组合打包成各种功能。如果说系统调用是笔画的话,那么库函数大概就是偏旁部首了。一般来说 Linux/Unix 系统都会有 ISO C 标准库和 POSIX 标准库,用来保证不同平台的兼容性。
在 shell 和 library 的基础上,我们就可以构造各式各样强大的应用了,当然除了这两种方式外,也可以根据需要自己进行系统调用。
至此,我们就简单介绍了 Linux 架构中的各个层级:
在图形化界面出现之前,在命令行中输入命令是跟电脑交互的主要方式。而在图形化界面出现这么多年之后,命令行依然扮演者举足轻重的角色,一是因为简单粗暴,二是因为可以方便地自动化流程化。
简单来说,文件系统是 0 与 1 的逻辑组织形式,常见的抽象是文件和目录。在 Linux 中,文件系统是一个树结构,树的根就是我们常常能看到的根目录 /
,每一个分叉表示一个文件夹,如下图所示:
文件名加上从根目录到该文件所在目录的目录名就构成了一个路径。对于目录来说,里面至少会包含两个条目:
. 指向当前目录 .. 指向父目录
当一个文件被放入到目录中,实际上就是建立了一个到该文件的硬链接(hard link),当对这个文件的硬链接数目为零的时候,文件实际上就被删除了。不过现在基本都使用软链接(soft link),类似于 windows 中的快捷方式,不会影响链接数目。
我们能对文件进行三种操作:
但是三种操作都有各自的权限,我们使用 ls -l filename
就可以看到详情,比如:
wdxtub@ubuntu:~/GO/bin$ ls -l -rwxrwxr-x 1 wdxtub wdxtub 11277064 Sep 14 10:35 bee wdxtub@ubuntu:~/GO$ ls -l bin drwxrwxr-x 2 wdxtub wdxtub 4096 Sep 14 10:35 bin
这里简单介绍下各个字段的含义:
-
表示常规文件,如果是 d
表示目录 -
则表示没有对应的权限 文件系统的使用基本上就是这些内容,但是这样的一个文件系统到底是怎么实现的呢?这又要从存储设备说起了。前面提到,存储设备的前 512 字节是 MBR,用于开机启动,剩余的空间可能会被分为多个分区(partition),每个分区有对应的分区表(partition table)来记录分区的相关信息(比如起始位置和分区大小)。需要注意的是,分区表并不保存在该分区中,不然万一分区挂了,连最关键的分区表都找不到了。
每个分区大概的样子是这样的:
关于 inode 的具体实现细节这里因为篇幅所限就不展开了,会在系列后面的文章中进行介绍。
在 Linux 中 “Everything is a stream of bytes”,用设计模式的话说其实这就是一个数据流导向的设计,信息在不同的应用之间流动,最终成为我们所需要的信息。Linux 在执行程序的时候,会自动打开三个流:
我们可以按需进行使用。而如果我们想把一个程序的标准输出作为另一个程序的标准输入,就需要使用管道(pipeline)了。而正是因为这样的机制,我们可以把诸多小功能组合成强大的应用,一个简单的例子是:
wdxtub@ubuntu:~$ cat hello.txt welcome to wdxtub.com wdxtub@ubuntu:~$ cat hello.txt | wc -w 3
最基础的操作是指令,一堆指令在一起就是程序,而进程就是程序的具体实现,也就是把程序载入到内存中并执行的过程。操作系统的重要功能之一便是对进程进行从摇篮(分配内存空间)到坟墓(回收)的管理。我们先执行如下命令看看 ps -eo pid,comm,cmd
(列出全部进程并展示 pid, command 和 cmd 信息)
这里每一行都是一个进程,第一列是 pid,相当于身份证号;第二列是进程的简称;第三列是进程启动时候的命令。如果我们往上滚动,就会找到这样的一行 1 init /sbin/init
,这个就是内核建立的唯一一个进程了,剩下的进程都是 init 通过 fork 方式创建的,也就是说,所有的其他进程都是 init 的子进程。
子进程终结的时候会通知父进程进行内存空间的回收,而如果父进程比子进程还早终结,那么这个子进程就会被过继给 init 进程,并由 init 进程通过调用 wait
函数进行回收。如果无法正确回收,那么这个子进程就成为了僵尸进程,所占据的内存空间就无法被访问了。
除了父子进程的关系外,还有一个进程组(process group)的概念:每个进程组中有多个进程,进程组的 pid 由进程组 leader 的 pid 决定。而多个进程组还可以组成一个会话(session),会话使得前台和后台程序得以展示出来。当我们创建了多个终端窗口,实际上就创建了多个会话,每个会话都有其前台和后台进程。
前面介绍了进程,但是进程之前如果想要交互怎么办?除了管道之外,有没有其他方法?当然有也必须要有。其中最简单的一种就是信号,所谓信号就是一个整数,一个由进程 A 发送给进程 B 的整数。因为一个整数所能携带的信息量有限,所以一般用于系统管理。
信号的传递机制也很简单,由内核,或者由其他进程经由内核往目标进程发送信号,实际上是在该进程对应的表中写入信号。当进程执行完系统调用退出内核的时候,就会查看这个信号,然后根据信号的不同执行不同的操作。
具体什么整数表示什么意思可以通过 man 7 signal
来查看,常见的有:
SIGINT
: 当键盘按下 CTRL+C 从 shell 中发出信号,信号被传递给 shell 中前台运行的进程,对应该信号的默认操作是中断(INTERRUPT)该进程 SIGQUIT
: 当键盘按下 CTRL+/ 从 shell 中发出信号,信号被传递给 shell 中前台运行的进程,对应该信号的默认操作是退出(QUIT)该进程 SIGTSTP
: 当键盘按下 CTRL+Z 从 shell 中发出信号,信号被传递给 shell 中前台运行的进程,对应该信号的默认操作是暂停(STOP)该进程 SIGCONT
: 用于通知暂停的进程继续 SIGALRM
: 起到定时器的作用,通常是程序在一定的时间之后才生成该信号 上面的介绍说『默认』操作,那么也就意味着我们是可以采取其他操作的,比方说直接无视掉,或者执行我们自定义的操作。
除了信号,消息队列(message queue)和共享内存(shared memory)也可以在进程间进行信息共享。不过因为这种机制比较复杂,尤其是涉及到同步的问题,所以在使用的时候需要多加注意。